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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-08-18 13:50:51 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-08-18 13:50:51 +0300
commitdb384e6b19af03b4c3c82a5760d83a3fd79f7982 (patch)
tree34beaef37df5f47ccbcf5729d7583aae093cffa0 /app
parent54fd7b1bad233e3944434da91d257fa7f63c3996 (diff)
Add latest changes from gitlab-org/gitlab@16-3-stable-eev16.3.0-rc42
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/access_tokens/components/access_token_table_app.vue116
-rw-r--r--app/assets/javascripts/access_tokens/components/new_access_token_app.vue26
-rw-r--r--app/assets/javascripts/access_tokens/components/token.vue8
-rw-r--r--app/assets/javascripts/access_tokens/components/tokens_app.vue1
-rw-r--r--app/assets/javascripts/access_tokens/index.js2
-rw-r--r--app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue1
-rw-r--r--app/assets/javascripts/add_context_commits_modal/components/token.vue1
-rw-r--r--app/assets/javascripts/add_context_commits_modal/store/index.js1
-rw-r--r--app/assets/javascripts/admin/abuse_report/components/report_actions.vue10
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue33
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue46
-rw-r--r--app/assets/javascripts/admin/abuse_reports/constants.js40
-rw-r--r--app/assets/javascripts/admin/abuse_reports/index.js1
-rw-r--r--app/assets/javascripts/admin/abuse_reports/utils.js18
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/components/base.vue60
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/components/message_form.vue45
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue37
-rw-r--r--app/assets/javascripts/admin/deploy_keys/components/table.vue176
-rw-r--r--app/assets/javascripts/admin/statistics_panel/components/app.vue1
-rw-r--r--app/assets/javascripts/admin/statistics_panel/store/index.js1
-rw-r--r--app/assets/javascripts/admin/topics/index.js4
-rw-r--r--app/assets/javascripts/admin/users/components/actions/activate.vue1
-rw-r--r--app/assets/javascripts/admin/users/components/actions/approve.vue1
-rw-r--r--app/assets/javascripts/admin/users/components/actions/ban.vue3
-rw-r--r--app/assets/javascripts/admin/users/components/actions/block.vue1
-rw-r--r--app/assets/javascripts/admin/users/components/actions/deactivate.vue1
-rw-r--r--app/assets/javascripts/admin/users/components/actions/delete.vue1
-rw-r--r--app/assets/javascripts/admin/users/components/actions/reject.vue1
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unban.vue1
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unblock.vue1
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unlock.vue1
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue28
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue540
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue83
-rw-r--r--app/assets/javascripts/alerts_settings/constants.js3
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/base.vue1
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue1
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue15
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/store/index.js1
-rw-r--r--app/assets/javascripts/analytics/devops_reports/components/devops_score.vue2
-rw-r--r--app/assets/javascripts/analytics/shared/components/daterange.vue1
-rw-r--r--app/assets/javascripts/api.js15
-rw-r--r--app/assets/javascripts/api/groups_api.js17
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue7
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/constants.js2
-rw-r--r--app/assets/javascripts/badges/components/badge.vue1
-rw-r--r--app/assets/javascripts/badges/components/badge_form.vue73
-rw-r--r--app/assets/javascripts/badges/components/badge_list.vue169
-rw-r--r--app/assets/javascripts/badges/components/badge_list_row.vue81
-rw-r--r--app/assets/javascripts/badges/components/badge_settings.vue86
-rw-r--r--app/assets/javascripts/badges/constants.js2
-rw-r--r--app/assets/javascripts/badges/store/index.js1
-rw-r--r--app/assets/javascripts/batch_comments/components/diff_file_drafts.vue1
-rw-r--r--app/assets/javascripts/batch_comments/components/draft_note.vue1
-rw-r--r--app/assets/javascripts/batch_comments/components/drafts_count.vue1
-rw-r--r--app/assets/javascripts/batch_comments/components/preview_dropdown.vue1
-rw-r--r--app/assets/javascripts/batch_comments/components/preview_item.vue1
-rw-r--r--app/assets/javascripts/batch_comments/components/review_bar.vue1
-rw-r--r--app/assets/javascripts/batch_comments/components/submit_dropdown.vue80
-rw-r--r--app/assets/javascripts/batch_comments/index.js6
-rw-r--r--app/assets/javascripts/batch_comments/mixins/resolved_status.js1
-rw-r--r--app/assets/javascripts/batch_comments/stores/index.js1
-rw-r--r--app/assets/javascripts/behaviors/index.js3
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/keybindings.js20
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcut.vue1
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js34
-rw-r--r--app/assets/javascripts/behaviors/toasts.js9
-rw-r--r--app/assets/javascripts/blob/components/blob_content.vue17
-rw-r--r--app/assets/javascripts/blob/components/table_contents.vue1
-rw-r--r--app/assets/javascripts/blob/file_template_mediator.js202
-rw-r--r--app/assets/javascripts/blob/file_template_selector.js105
-rw-r--r--app/assets/javascripts/blob/filepath_form/components/filepath_form.vue63
-rw-r--r--app/assets/javascripts/blob/filepath_form/components/template_selector.vue161
-rw-r--r--app/assets/javascripts/blob/filepath_form/index.js41
-rw-r--r--app/assets/javascripts/blob/filepath_form_mediator.js105
-rw-r--r--app/assets/javascripts/blob/legacy_template_selector.js (renamed from app/assets/javascripts/blob/template_selector.js)2
-rw-r--r--app/assets/javascripts/blob/line_highlighter.js2
-rw-r--r--app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue2
-rw-r--r--app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js30
-rw-r--r--app/assets/javascripts/blob/template_selectors/dockerfile_selector.js31
-rw-r--r--app/assets/javascripts/blob/template_selectors/gitignore_selector.js29
-rw-r--r--app/assets/javascripts/blob/template_selectors/license_selector.js46
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js4
-rw-r--r--app/assets/javascripts/blob_edit/edit_blob.js23
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column.vue73
-rw-r--r--app/assets/javascripts/boards/components/board_app.vue33
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue5
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue3
-rw-r--r--app/assets/javascripts/boards/components/board_card_move_to_position.vue1
-rw-r--r--app/assets/javascripts/boards/components/board_column.vue1
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue23
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue27
-rw-r--r--app/assets/javascripts/boards/components/board_filtered_search.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue1
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue137
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue47
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.vue1
-rw-r--r--app/assets/javascripts/boards/components/board_settings_sidebar.vue45
-rw-r--r--app/assets/javascripts/boards/components/board_top_bar.vue8
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue43
-rw-r--r--app/assets/javascripts/boards/components/config_toggle.vue3
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue39
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue1
-rw-r--r--app/assets/javascripts/boards/graphql/group_boards.query.graphql8
-rw-r--r--app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql8
-rw-r--r--app/assets/javascripts/boards/graphql/project_boards.query.graphql8
-rw-r--r--app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql8
-rw-r--r--app/assets/javascripts/boards/index.js2
-rw-r--r--app/assets/javascripts/boards/stores/index.js1
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue11
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue233
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue68
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue32
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue397
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/constants.js17
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/index.js8
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue1
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue6
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue6
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue1
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue1
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue2
-rw-r--r--app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue1
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue16
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue66
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue13
-rw-r--r--app/assets/javascripts/ci/reports/codequality_report/store/index.js1
-rw-r--r--app/assets/javascripts/ci/reports/components/report_item.vue2
-rw-r--r--app/assets/javascripts/ci/reports/components/report_section.vue5
-rw-r--r--app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue6
-rw-r--r--app/assets/javascripts/ci/runner/admin_runners/index.js16
-rw-r--r--app/assets/javascripts/ci/runner/admin_runners/provide.js22
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue2
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_token.vue1
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_details.vue5
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_filtered_search_bar.vue2
-rw-r--r--app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js19
-rw-r--r--app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js23
-rw-r--r--app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue11
-rw-r--r--app/assets/javascripts/ci_secure_files/components/metadata/button.vue3
-rw-r--r--app/assets/javascripts/ci_secure_files/components/metadata/modal.vue1
-rw-r--r--app/assets/javascripts/ci_secure_files/components/metadata/table.vue1
-rw-r--r--app/assets/javascripts/ci_secure_files/components/secure_files_list.vue148
-rw-r--r--app/assets/javascripts/ci_settings_general_pipeline/index.js19
-rw-r--r--app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue66
-rw-r--r--app/assets/javascripts/clusters/agents/components/show.vue1
-rw-r--r--app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue1
-rw-r--r--app/assets/javascripts/clusters/forms/components/integration_form.vue4
-rw-r--r--app/assets/javascripts/clusters/forms/stores/index.js1
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_token.vue1
-rw-r--r--app/assets/javascripts/clusters_list/components/agents.vue1
-rw-r--r--app/assets/javascripts/clusters_list/components/ancestor_notice.vue1
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters.vue2
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_actions.vue6
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_view_all.vue1
-rw-r--r--app/assets/javascripts/clusters_list/components/install_agent_modal.vue28
-rw-r--r--app/assets/javascripts/clusters_list/constants.js4
-rw-r--r--app/assets/javascripts/clusters_list/store/index.js1
-rw-r--r--app/assets/javascripts/code_navigation/components/app.vue1
-rw-r--r--app/assets/javascripts/code_navigation/components/popover.vue1
-rw-r--r--app/assets/javascripts/code_navigation/index.js1
-rw-r--r--app/assets/javascripts/code_navigation/store/index.js1
-rw-r--r--app/assets/javascripts/comment_templates/components/form.vue15
-rw-r--r--app/assets/javascripts/comment_templates/components/list.vue48
-rw-r--r--app/assets/javascripts/comment_templates/components/list_item.vue8
-rw-r--r--app/assets/javascripts/comment_templates/pages/edit.vue1
-rw-r--r--app/assets/javascripts/comment_templates/pages/index.vue48
-rw-r--r--app/assets/javascripts/commit/components/signature_badge.vue4
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table_wrapper.vue0
-rw-r--r--app/assets/javascripts/confidential_merge_request/components/dropdown.vue1
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue9
-rw-r--r--app/assets/javascripts/content_editor/extensions/copy_paste.js10
-rw-r--r--app/assets/javascripts/content_editor/services/highlight_js_language_loader.js4
-rw-r--r--app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_closed.vue43
-rw-r--r--app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_commented.vue71
-rw-r--r--app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_created.vue62
-rw-r--r--app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_reopened.vue36
-rw-r--r--app/assets/javascripts/contribution_events/components/contribution_events.vue20
-rw-r--r--app/assets/javascripts/contribution_events/components/target_link.vue2
-rw-r--r--app/assets/javascripts/contribution_events/constants.js123
-rw-r--r--app/assets/javascripts/contribution_events/utils.js9
-rw-r--r--app/assets/javascripts/contributors/components/contributors.vue2
-rw-r--r--app/assets/javascripts/contributors/stores/index.js1
-rw-r--r--app/assets/javascripts/crm/contacts/components/contacts_root.vue1
-rw-r--r--app/assets/javascripts/crm/organizations/components/organizations_root.vue1
-rw-r--r--app/assets/javascripts/custom_emoji/components/app.vue15
-rw-r--r--app/assets/javascripts/custom_emoji/components/delete_item.vue90
-rw-r--r--app/assets/javascripts/custom_emoji/components/form.vue143
-rw-r--r--app/assets/javascripts/custom_emoji/components/list.vue154
-rw-r--r--app/assets/javascripts/custom_emoji/custom_emoji_bundle.js39
-rw-r--r--app/assets/javascripts/custom_emoji/graphql_client.js3
-rw-r--r--app/assets/javascripts/custom_emoji/pages/index.vue67
-rw-r--r--app/assets/javascripts/custom_emoji/pages/new.vue24
-rw-r--r--app/assets/javascripts/custom_emoji/queries/create_custom_emoji.mutation.graphql5
-rw-r--r--app/assets/javascripts/custom_emoji/queries/custom_emojis.query.graphql25
-rw-r--r--app/assets/javascripts/custom_emoji/queries/delete_custom_emoji.mutation.graphql7
-rw-r--r--app/assets/javascripts/custom_emoji/queries/user_permissions.query.graphql8
-rw-r--r--app/assets/javascripts/custom_emoji/routes.js35
-rw-r--r--app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue25
-rw-r--r--app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue121
-rw-r--r--app/assets/javascripts/deploy_freeze/store/index.js1
-rw-r--r--app/assets/javascripts/deploy_keys/components/app.vue49
-rw-r--r--app/assets/javascripts/deploy_keys/components/key.vue85
-rw-r--r--app/assets/javascripts/deploy_keys/components/keys_panel.vue11
-rw-r--r--app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue30
-rw-r--r--app/assets/javascripts/deploy_tokens/components/revoke_button.vue4
-rw-r--r--app/assets/javascripts/deploy_tokens/deploy_token_translations.js3
-rw-r--r--app/assets/javascripts/deprecated_notes.js4
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_note.vue3
-rw-r--r--app/assets/javascripts/design_management/components/image.vue1
-rw-r--r--app/assets/javascripts/design_management/components/list/item.vue1
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/index.vue1
-rw-r--r--app/assets/javascripts/design_management/components/upload/button.vue1
-rw-r--r--app/assets/javascripts/design_management/pages/design/index.vue1
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue1
-rw-r--r--app/assets/javascripts/diffs/components/app.vue43
-rw-r--r--app/assets/javascripts/diffs/components/collapsed_files_warning.vue1
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions.vue5
-rw-r--r--app/assets/javascripts/diffs/components/diff_comment_cell.vue1
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue12
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussion_reply.vue1
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussions.vue1
-rw-r--r--app/assets/javascripts/diffs/components/diff_expansion_cell.vue1
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue1
-rw-r--r--app/assets/javascripts/diffs/components/diff_inline_findings.vue6
-rw-r--r--app/assets/javascripts/diffs/components/diff_inline_findings_item.vue (renamed from app/assets/javascripts/diffs/components/diff_code_quality_item.vue)3
-rw-r--r--app/assets/javascripts/diffs/components/diff_line.vue23
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue6
-rw-r--r--app/assets/javascripts/diffs/components/diff_row.vue29
-rw-r--r--app/assets/javascripts/diffs/components/diff_row_utils.js4
-rw-r--r--app/assets/javascripts/diffs/components/diff_view.vue38
-rw-r--r--app/assets/javascripts/diffs/components/diffs_file_tree.vue79
-rw-r--r--app/assets/javascripts/diffs/components/image_diff_overlay.vue1
-rw-r--r--app/assets/javascripts/diffs/components/inline_findings.vue (renamed from app/assets/javascripts/diffs/components/diff_code_quality.vue)18
-rw-r--r--app/assets/javascripts/diffs/components/no_changes.vue1
-rw-r--r--app/assets/javascripts/diffs/components/settings_dropdown.vue1
-rw-r--r--app/assets/javascripts/diffs/components/shared/findings_drawer.vue2
-rw-r--r--app/assets/javascripts/diffs/components/tree_list.vue1
-rw-r--r--app/assets/javascripts/diffs/index.js1
-rw-r--r--app/assets/javascripts/diffs/mixins/draft_comments.js1
-rw-r--r--app/assets/javascripts/diffs/mixins/image_diff.js1
-rw-r--r--app/assets/javascripts/diffs/store/actions.js59
-rw-r--r--app/assets/javascripts/diffs/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js3
-rw-r--r--app/assets/javascripts/diffs/store/utils.js1
-rw-r--r--app/assets/javascripts/diffs/utils/tree_worker_utils.js4
-rw-r--r--app/assets/javascripts/editor/schema/ci.json140
-rw-r--r--app/assets/javascripts/emoji/awards_app/index.js1
-rw-r--r--app/assets/javascripts/emoji/awards_app/store/index.js1
-rw-r--r--app/assets/javascripts/emoji/components/category.vue3
-rw-r--r--app/assets/javascripts/emoji/components/picker.vue1
-rw-r--r--app/assets/javascripts/emoji/no_emoji_validator.js2
-rw-r--r--app/assets/javascripts/environments/components/commit.vue1
-rw-r--r--app/assets/javascripts/environments/components/container.vue1
-rw-r--r--app/assets/javascripts/environments/components/deployment.vue1
-rw-r--r--app/assets/javascripts/environments/components/edit_environment.vue7
-rw-r--r--app/assets/javascripts/environments/components/environment_flux_resource_selector.vue210
-rw-r--r--app/assets/javascripts/environments/components/environment_form.vue35
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue1
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_overview.vue17
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_status_bar.vue180
-rw-r--r--app/assets/javascripts/environments/components/new_environment.vue1
-rw-r--r--app/assets/javascripts/environments/components/new_environment_item.vue22
-rw-r--r--app/assets/javascripts/environments/constants.js64
-rw-r--r--app/assets/javascripts/environments/environment_details/components/deployment_actions.vue1
-rw-r--r--app/assets/javascripts/environments/environment_details/index.vue1
-rw-r--r--app/assets/javascripts/environments/environment_details/pagination.vue1
-rw-r--r--app/assets/javascripts/environments/graphql/client.js17
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environment.query.graphql1
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environment_cluster_agent.query.graphql1
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environment_cluster_agent_with_flux_resource.query.graphql (renamed from app/assets/javascripts/environments/graphql/queries/environment_cluster_agent_with_namespace.query.graphql)3
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environment_with_flux_resource.query.graphql (renamed from app/assets/javascripts/environments/graphql/queries/environment_with_namespace.graphql)3
-rw-r--r--app/assets/javascripts/environments/graphql/queries/flux_helm_release_status.query.graphql17
-rw-r--r--app/assets/javascripts/environments/graphql/queries/flux_helm_releases.query.graphql9
-rw-r--r--app/assets/javascripts/environments/graphql/queries/flux_kustomization_status.query.graphql17
-rw-r--r--app/assets/javascripts/environments/graphql/queries/flux_kustomizations.query.graphql9
-rw-r--r--app/assets/javascripts/environments/graphql/resolvers.js320
-rw-r--r--app/assets/javascripts/environments/graphql/resolvers/base.js165
-rw-r--r--app/assets/javascripts/environments/graphql/resolvers/flux.js115
-rw-r--r--app/assets/javascripts/environments/graphql/resolvers/kubernetes.js155
-rw-r--r--app/assets/javascripts/environments/graphql/typedefs.graphql15
-rw-r--r--app/assets/javascripts/environments/helpers/k8s_integration_helper.js2
-rw-r--r--app/assets/javascripts/environments/index.js2
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details.vue1
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue1
-rw-r--r--app/assets/javascripts/error_tracking/components/stacktrace.vue1
-rw-r--r--app/assets/javascripts/error_tracking/store/index.js1
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/app.vue1
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue1
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/index.js1
-rw-r--r--app/assets/javascripts/feature_flags/components/edit_feature_flag.vue1
-rw-r--r--app/assets/javascripts/feature_flags/components/empty_state.vue1
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags.vue8
-rw-r--r--app/assets/javascripts/feature_flags/components/form.vue1
-rw-r--r--app/assets/javascripts/feature_flags/components/new_feature_flag.vue1
-rw-r--r--app/assets/javascripts/feature_flags/components/strategies/default.vue1
-rw-r--r--app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue1
-rw-r--r--app/assets/javascripts/feature_flags/components/strategy.vue1
-rw-r--r--app/assets/javascripts/feature_flags/edit.js1
-rw-r--r--app/assets/javascripts/feature_flags/index.js1
-rw-r--r--app/assets/javascripts/feature_flags/new.js1
-rw-r--r--app/assets/javascripts/feature_flags/store/edit/index.js1
-rw-r--r--app/assets/javascripts/feature_flags/store/index/index.js1
-rw-r--r--app/assets/javascripts/feature_flags/store/new/index.js1
-rw-r--r--app/assets/javascripts/forks/components/forks_button.vue86
-rw-r--r--app/assets/javascripts/forks/init_forks_button.js41
-rw-r--r--app/assets/javascripts/frequent_items/store/index.js1
-rw-r--r--app/assets/javascripts/google_cloud/aiml/panel.vue1
-rw-r--r--app/assets/javascripts/google_cloud/configuration/panel.vue1
-rw-r--r--app/assets/javascripts/google_cloud/databases/panel.vue1
-rw-r--r--app/assets/javascripts/google_cloud/deployments/panel.vue1
-rw-r--r--app/assets/javascripts/google_cloud/gcp_regions/form.vue1
-rw-r--r--app/assets/javascripts/google_cloud/gcp_regions/list.vue1
-rw-r--r--app/assets/javascripts/google_cloud/service_accounts/form.vue1
-rw-r--r--app/assets/javascripts/google_cloud/service_accounts/list.vue1
-rw-r--r--app/assets/javascripts/graphql_shared/issuable_client.js10
-rw-r--r--app/assets/javascripts/graphql_shared/possible_types.json3
-rw-r--r--app/assets/javascripts/graphql_shared/queries/users_autocomplete.query.graphql20
-rw-r--r--app/assets/javascripts/group_settings/components/shared_runners_form.vue81
-rw-r--r--app/assets/javascripts/group_settings/mount_shared_runners.js8
-rw-r--r--app/assets/javascripts/groups/components/app.vue9
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue12
-rw-r--r--app/assets/javascripts/groups/components/group_name_and_path.vue4
-rw-r--r--app/assets/javascripts/groups/components/groups.vue1
-rw-r--r--app/assets/javascripts/groups/groups_list.js18
-rw-r--r--app/assets/javascripts/groups/index.js61
-rw-r--r--app/assets/javascripts/groups/service/archived_projects_service.js3
-rw-r--r--app/assets/javascripts/groups/settings/components/access_dropdown.vue1
-rw-r--r--app/assets/javascripts/groups_projects/components/transfer_locations.vue1
-rw-r--r--app/assets/javascripts/header_search/components/app.vue3
-rw-r--r--app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue1
-rw-r--r--app/assets/javascripts/header_search/components/header_search_default_items.vue1
-rw-r--r--app/assets/javascripts/header_search/components/header_search_scoped_items.vue1
-rw-r--r--app/assets/javascripts/header_search/store/index.js1
-rw-r--r--app/assets/javascripts/ide/components/activity_bar.vue1
-rw-r--r--app/assets/javascripts/ide/components/branches/item.vue1
-rw-r--r--app/assets/javascripts/ide/components/branches/search_list.vue1
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/actions.vue2
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue1
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue1
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/form.vue2
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list.vue2
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_item.vue1
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue1
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue1
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/success_message.vue1
-rw-r--r--app/assets/javascripts/ide/components/error_message.vue1
-rw-r--r--app/assets/javascripts/ide/components/file_row_extra.vue1
-rw-r--r--app/assets/javascripts/ide/components/file_templates/bar.vue2
-rw-r--r--app/assets/javascripts/ide/components/file_templates/dropdown.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide_file_row.vue1
-rw-r--r--app/assets/javascripts/ide/components/ide_review.vue1
-rw-r--r--app/assets/javascripts/ide/components/ide_side_bar.vue1
-rw-r--r--app/assets/javascripts/ide/components/ide_status_bar.vue1
-rw-r--r--app/assets/javascripts/ide/components/ide_status_list.vue1
-rw-r--r--app/assets/javascripts/ide/components/ide_tree.vue1
-rw-r--r--app/assets/javascripts/ide/components/ide_tree_list.vue1
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail.vue2
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail/description.vue1
-rw-r--r--app/assets/javascripts/ide/components/jobs/item.vue1
-rw-r--r--app/assets/javascripts/ide/components/jobs/list.vue2
-rw-r--r--app/assets/javascripts/ide/components/jobs/stage.vue1
-rw-r--r--app/assets/javascripts/ide/components/merge_requests/item.vue1
-rw-r--r--app/assets/javascripts/ide/components/merge_requests/list.vue2
-rw-r--r--app/assets/javascripts/ide/components/nav_dropdown.vue1
-rw-r--r--app/assets/javascripts/ide/components/nav_dropdown_button.vue1
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/button.vue1
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/index.vue2
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue2
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/upload.vue1
-rw-r--r--app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue1
-rw-r--r--app/assets/javascripts/ide/components/panes/right.vue1
-rw-r--r--app/assets/javascripts/ide/components/pipelines/empty_state.vue1
-rw-r--r--app/assets/javascripts/ide/components/pipelines/list.vue2
-rw-r--r--app/assets/javascripts/ide/components/repo_commit_section.vue1
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue4
-rw-r--r--app/assets/javascripts/ide/components/repo_tab.vue1
-rw-r--r--app/assets/javascripts/ide/components/repo_tabs.vue1
-rw-r--r--app/assets/javascripts/ide/components/resizable_panel.vue1
-rw-r--r--app/assets/javascripts/ide/components/terminal/session.vue2
-rw-r--r--app/assets/javascripts/ide/components/terminal/terminal.vue2
-rw-r--r--app/assets/javascripts/ide/components/terminal/view.vue2
-rw-r--r--app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status.vue1
-rw-r--r--app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status_safe.vue1
-rw-r--r--app/assets/javascripts/ide/index.js1
-rw-r--r--app/assets/javascripts/ide/lib/alerts/environments.vue1
-rw-r--r--app/assets/javascripts/ide/stores/index.js1
-rw-r--r--app/assets/javascripts/ide/utils.js13
-rw-r--r--app/assets/javascripts/import_entities/components/group_dropdown.vue2
-rw-r--r--app/assets/javascripts/import_entities/components/import_status.vue8
-rw-r--r--app/assets/javascripts/import_entities/components/import_target_dropdown.vue119
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue54
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/github_status_table.vue1
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue1
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue41
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/index.js1
-rw-r--r--app/assets/javascripts/incidents/components/incidents_list.vue1
-rw-r--r--app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue2
-rw-r--r--app/assets/javascripts/integrations/edit/components/active_checkbox.vue1
-rw-r--r--app/assets/javascripts/integrations/edit/components/dynamic_field.vue1
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue55
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form_actions.vue125
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_forms/section.vue54
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_auth_fields.vue1
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue1
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue1
-rw-r--r--app/assets/javascripts/integrations/edit/components/override_dropdown.vue1
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/apple_app_store.vue1
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/configuration.vue1
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/connection.vue1
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/google_play.vue5
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/jira_issues.vue1
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/jira_trigger.vue1
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/trigger.vue1
-rw-r--r--app/assets/javascripts/integrations/edit/components/trigger_field.vue1
-rw-r--r--app/assets/javascripts/integrations/edit/components/trigger_fields.vue1
-rw-r--r--app/assets/javascripts/integrations/edit/store/index.js1
-rw-r--r--app/assets/javascripts/integrations/index/components/integrations_list.vue49
-rw-r--r--app/assets/javascripts/integrations/index/components/integrations_table.vue31
-rw-r--r--app/assets/javascripts/invite_members/components/confetti.vue1
-rw-r--r--app/assets/javascripts/invite_members/components/group_select.vue38
-rw-r--r--app/assets/javascripts/invite_members/components/import_project_members_modal.vue144
-rw-r--r--app/assets/javascripts/invite_members/components/user_limit_notification.vue19
-rw-r--r--app/assets/javascripts/invite_members/constants.js8
-rw-r--r--app/assets/javascripts/issuable/components/issuable_header_warnings.vue1
-rw-r--r--app/assets/javascripts/issuable/issuable_bulk_update_actions.js1
-rw-r--r--app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js2
-rw-r--r--app/assets/javascripts/issuable/issuable_template_selector.js4
-rw-r--r--app/assets/javascripts/issues/create_merge_request_dropdown.js36
-rw-r--r--app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue25
-rw-r--r--app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql8
-rw-r--r--app/assets/javascripts/issues/dashboard/queries/get_issues_counts.query.graphql16
-rw-r--r--app/assets/javascripts/issues/index.js9
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue154
-rw-r--r--app/assets/javascripts/issues/list/constants.js33
-rw-r--r--app/assets/javascripts/issues/list/graphql.js5
-rw-r--r--app/assets/javascripts/issues/list/index.js7
-rw-r--r--app/assets/javascripts/issues/list/queries/get_issues.query.graphql12
-rw-r--r--app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql28
-rw-r--r--app/assets/javascripts/issues/list/utils.js82
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue1
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/store/index.js1
-rw-r--r--app/assets/javascripts/issues/show/components/app.vue77
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue1
-rw-r--r--app/assets/javascripts/issues/show/components/edited.vue1
-rw-r--r--app/assets/javascripts/issues/show/components/fields/description.vue1
-rw-r--r--app/assets/javascripts/issues/show/components/fields/title.vue1
-rw-r--r--app/assets/javascripts/issues/show/components/fields/type.vue1
-rw-r--r--app/assets/javascripts/issues/show/components/form.vue9
-rw-r--r--app/assets/javascripts/issues/show/components/header_actions.vue12
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue1
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue2
-rw-r--r--app/assets/javascripts/issues/show/components/issue_header.vue128
-rw-r--r--app/assets/javascripts/issues/show/components/new_header_actions_popover.vue46
-rw-r--r--app/assets/javascripts/issues/show/components/sentry_error_stack_trace.vue1
-rw-r--r--app/assets/javascripts/issues/show/components/title.vue8
-rw-r--r--app/assets/javascripts/issues/show/index.js71
-rw-r--r--app/assets/javascripts/jira_connect/branches/pages/index.vue1
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/api.js7
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue13
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue1
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/app.vue1
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue1
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue1
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue1
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue1
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/store/index.js1
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/store/state.js3
-rw-r--r--app/assets/javascripts/jobs/components/job/job_app.vue1
-rw-r--r--app/assets/javascripts/jobs/components/job/manual_variables_form.vue2
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue56
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue1
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue1
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue1
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue1
-rw-r--r--app/assets/javascripts/jobs/components/log/line.vue14
-rw-r--r--app/assets/javascripts/jobs/components/log/line_header.vue8
-rw-r--r--app/assets/javascripts/jobs/components/log/log.vue2
-rw-r--r--app/assets/javascripts/jobs/store/index.js1
-rw-r--r--app/assets/javascripts/labels/index.js3
-rw-r--r--app/assets/javascripts/lib/apollo/persistence_mapper.js2
-rw-r--r--app/assets/javascripts/lib/mousetrap.js2
-rw-r--r--app/assets/javascripts/lib/print_markdown_dom.js50
-rw-r--r--app/assets/javascripts/lib/utils/constants.js1
-rw-r--r--app/assets/javascripts/lib/utils/error_utils.js149
-rw-r--r--app/assets/javascripts/lib/utils/file_utility.js12
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js30
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js32
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/vue_router.js11
-rw-r--r--app/assets/javascripts/lib/utils/vuex_module_mappers.js1
-rw-r--r--app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue1
-rw-r--r--app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue1
-rw-r--r--app/assets/javascripts/members/components/action_buttons/remove_member_button.vue1
-rw-r--r--app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue1
-rw-r--r--app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue1
-rw-r--r--app/assets/javascripts/members/components/app.vue1
-rw-r--r--app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue1
-rw-r--r--app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue1
-rw-r--r--app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue1
-rw-r--r--app/assets/javascripts/members/components/members_tabs.vue1
-rw-r--r--app/assets/javascripts/members/components/modals/leave_modal.vue1
-rw-r--r--app/assets/javascripts/members/components/modals/remove_group_link_modal.vue1
-rw-r--r--app/assets/javascripts/members/components/modals/remove_member_modal.vue2
-rw-r--r--app/assets/javascripts/members/components/table/expiration_datepicker.vue1
-rw-r--r--app/assets/javascripts/members/components/table/members_table.vue1
-rw-r--r--app/assets/javascripts/members/components/table/role_dropdown.vue1
-rw-r--r--app/assets/javascripts/members/index.js1
-rw-r--r--app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue1
-rw-r--r--app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue1
-rw-r--r--app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue1
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue1
-rw-r--r--app/assets/javascripts/merge_conflicts/store/index.js1
-rw-r--r--app/assets/javascripts/merge_request_tabs.js2
-rw-r--r--app/assets/javascripts/merge_requests/components/sticky_header.vue1
-rw-r--r--app/assets/javascripts/milestones/components/milestone_combobox.vue1
-rw-r--r--app/assets/javascripts/milestones/stores/index.js1
-rw-r--r--app/assets/javascripts/mr_notes/init_notes.js4
-rw-r--r--app/assets/javascripts/mr_notes/stores/index.js1
-rw-r--r--app/assets/javascripts/nav/components/new_nav_toggle.vue7
-rw-r--r--app/assets/javascripts/nav/mount.js1
-rw-r--r--app/assets/javascripts/nav/stores/index.js1
-rw-r--r--app/assets/javascripts/notebook/cells/markdown.vue1
-rw-r--r--app/assets/javascripts/notebook/cells/output/html.vue1
-rw-r--r--app/assets/javascripts/notebook/cells/output/image.vue1
-rw-r--r--app/assets/javascripts/notebook/cells/output/index.vue1
-rw-r--r--app/assets/javascripts/notebook/cells/prompt.vue1
-rw-r--r--app/assets/javascripts/notebook/index.vue1
-rw-r--r--app/assets/javascripts/notes/components/attachments_warning.vue2
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue1
-rw-r--r--app/assets/javascripts/notes/components/diff_discussion_header.vue1
-rw-r--r--app/assets/javascripts/notes/components/diff_with_note.vue1
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue1
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter.vue1
-rw-r--r--app/assets/javascripts/notes/components/discussion_notes.vue1
-rw-r--r--app/assets/javascripts/notes/components/email_participants_warning.vue2
-rw-r--r--app/assets/javascripts/notes/components/mr_discussion_filter.vue1
-rw-r--r--app/assets/javascripts/notes/components/multiline_comment_form.vue1
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue1
-rw-r--r--app/assets/javascripts/notes/components/note_awards_list.vue1
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue1
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue72
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue1
-rw-r--r--app/assets/javascripts/notes/components/note_signed_out_widget.vue1
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue1
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue1
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue3
-rw-r--r--app/assets/javascripts/notes/components/sidebar_subscription.vue1
-rw-r--r--app/assets/javascripts/notes/components/timeline_toggle.vue1
-rw-r--r--app/assets/javascripts/notes/constants.js3
-rw-r--r--app/assets/javascripts/notes/mixins/diff_line_note_form.js1
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js1
-rw-r--r--app/assets/javascripts/notes/mixins/issuable_state.js1
-rw-r--r--app/assets/javascripts/notes/mixins/resolvable.js8
-rw-r--r--app/assets/javascripts/notes/stores/actions.js43
-rw-r--r--app/assets/javascripts/notes/stores/index.js1
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js22
-rw-r--r--app/assets/javascripts/notifications/components/notifications_dropdown_item.vue1
-rw-r--r--app/assets/javascripts/oauth_application/components/oauth_secret.vue1
-rw-r--r--app/assets/javascripts/observability/client.js167
-rw-r--r--app/assets/javascripts/observability/components/observability_container.vue4
-rw-r--r--app/assets/javascripts/observability/components/skeleton/dashboards.vue1
-rw-r--r--app/assets/javascripts/observability/components/skeleton/embed.vue1
-rw-r--r--app/assets/javascripts/observability/components/skeleton/explore.vue1
-rw-r--r--app/assets/javascripts/observability/components/skeleton/index.vue1
-rw-r--r--app/assets/javascripts/observability/components/skeleton/manage.vue1
-rw-r--r--app/assets/javascripts/observability/mock_traces.json2859
-rw-r--r--app/assets/javascripts/organizations/groups_and_projects/components/app.vue202
-rw-r--r--app/assets/javascripts/organizations/groups_and_projects/components/groups_page.vue43
-rw-r--r--app/assets/javascripts/organizations/groups_and_projects/components/projects_page.vue46
-rw-r--r--app/assets/javascripts/organizations/groups_and_projects/constants.js29
-rw-r--r--app/assets/javascripts/organizations/groups_and_projects/graphql/queries/groups.query.graphql22
-rw-r--r--app/assets/javascripts/organizations/groups_and_projects/graphql/queries/projects.query.graphql1
-rw-r--r--app/assets/javascripts/organizations/groups_and_projects/graphql/resolvers.js12
-rw-r--r--app/assets/javascripts/organizations/groups_and_projects/index.js17
-rw-r--r--app/assets/javascripts/organizations/groups_and_projects/utils.js23
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue40
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue53
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue3
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue21
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue12
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/index.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue68
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/utils.js24
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue8
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue6
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/pages/index.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/details_title.vue6
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/index.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/index.js1
-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/metadata/composer.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/conan.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/maven.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/pypi.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue5
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue29
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue5
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/index.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue7
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/constants.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/persisted_pagination.vue56
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue39
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue4
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/utils.js8
-rw-r--r--app/assets/javascripts/pages/explore/groups/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/work_items/index.js3
-rw-r--r--app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue51
-rw-r--r--app/assets/javascripts/pages/import/bulk_imports/history/index.js6
-rw-r--r--app/assets/javascripts/pages/import/bulk_imports/history/utils/index.js7
-rw-r--r--app/assets/javascripts/pages/profiles/keys/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js1
-rw-r--r--app/assets/javascripts/pages/projects/feature_flags_user_lists/index/index.js1
-rw-r--r--app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js1
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue6
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue30
-rw-r--r--app/assets/javascripts/pages/projects/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/tracing/show/index.js4
-rw-r--r--app/assets/javascripts/pages/sessions/new/index.js2
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_export.vue40
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue2
-rw-r--r--app/assets/javascripts/pages/shared/wikis/show.js21
-rw-r--r--app/assets/javascripts/pages/shared/wikis/wikis.js7
-rw-r--r--app/assets/javascripts/pdf/index.vue1
-rw-r--r--app/assets/javascripts/pdf/page/index.vue1
-rw-r--r--app/assets/javascripts/performance_bar/components/detailed_metric.vue2
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue5
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/commit.vue2
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/widgets/text.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/dag/dag.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue149
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue186
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_job_item.vue168
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph.vue98
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_pipeline_stage.vue176
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue185
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue195
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue22
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_tabs.vue19
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_job_details.vue49
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue31
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue78
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue36
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue12
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_reports.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue1
-rw-r--r--app/assets/javascripts/pipelines/constants.js4
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql5
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_stage.query.graphql32
-rw-r--r--app/assets/javascripts/pipelines/pipeline_tabs.js1
-rw-r--r--app/assets/javascripts/pipelines/utils.js12
-rw-r--r--app/assets/javascripts/popovers/components/popovers.vue1
-rw-r--r--app/assets/javascripts/profile/add_ssh_key_validation.js3
-rw-r--r--app/assets/javascripts/profile/components/follow.vue1
-rw-r--r--app/assets/javascripts/profile/edit/components/profile_edit_app.vue182
-rw-r--r--app/assets/javascripts/profile/edit/components/user_avatar.vue174
-rw-r--r--app/assets/javascripts/profile/edit/constants.js34
-rw-r--r--app/assets/javascripts/profile/edit/index.js30
-rw-r--r--app/assets/javascripts/profile/gl_crop.js8
-rw-r--r--app/assets/javascripts/projects/commit/components/branches_dropdown.vue1
-rw-r--r--app/assets/javascripts/projects/commit/components/form_modal.vue1
-rw-r--r--app/assets/javascripts/projects/commit/components/projects_dropdown.vue1
-rw-r--r--app/assets/javascripts/projects/commit/store/index.js1
-rw-r--r--app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue8
-rw-r--r--app/assets/javascripts/projects/commits/components/author_select.vue1
-rw-r--r--app/assets/javascripts/projects/commits/index.js1
-rw-r--r--app/assets/javascripts/projects/commits/store/index.js1
-rw-r--r--app/assets/javascripts/projects/components/shared/delete_button.vue148
-rw-r--r--app/assets/javascripts/projects/components/shared/delete_modal.vue155
-rw-r--r--app/assets/javascripts/projects/feature_flags_user_lists/show/index.js1
-rw-r--r--app/assets/javascripts/projects/new/components/new_project_url_select.vue4
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/index.js5
-rw-r--r--app/assets/javascripts/projects/project_name_rules.js2
-rw-r--r--app/assets/javascripts/projects/project_new.js2
-rw-r--r--app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue41
-rw-r--r--app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js8
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/app.vue70
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue50
-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/custom_email.vue139
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/custom_email_confirm_modal.vue74
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/custom_email_form.vue291
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/custom_email_wrapper.vue245
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue18
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js146
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/index.js5
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js29
-rw-r--r--app/assets/javascripts/ref/components/ref_selector.vue1
-rw-r--r--app/assets/javascripts/ref/stores/index.js1
-rw-r--r--app/assets/javascripts/releases/components/app_edit_new.vue1
-rw-r--r--app/assets/javascripts/releases/components/asset_links_form.vue1
-rw-r--r--app/assets/javascripts/releases/components/confirm_delete_modal.vue1
-rw-r--r--app/assets/javascripts/releases/components/tag_create.vue1
-rw-r--r--app/assets/javascripts/releases/components/tag_field.vue1
-rw-r--r--app/assets/javascripts/releases/components/tag_field_existing.vue1
-rw-r--r--app/assets/javascripts/releases/components/tag_field_new.vue2
-rw-r--r--app/assets/javascripts/releases/components/tag_search.vue1
-rw-r--r--app/assets/javascripts/releases/mount_edit.js1
-rw-r--r--app/assets/javascripts/releases/mount_new.js1
-rw-r--r--app/assets/javascripts/releases/stores/index.js1
-rw-r--r--app/assets/javascripts/repository/commits_service.js8
-rw-r--r--app/assets/javascripts/repository/components/blob_button_group.vue6
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue55
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue2
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/index.js6
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue15
-rw-r--r--app/assets/javascripts/repository/components/delete_blob_modal.vue199
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue6
-rw-r--r--app/assets/javascripts/repository/components/preview/index.vue1
-rw-r--r--app/assets/javascripts/repository/components/table/header.vue1
-rw-r--r--app/assets/javascripts/repository/components/table/index.vue1
-rw-r--r--app/assets/javascripts/repository/components/table/parent_row.vue6
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue27
-rw-r--r--app/assets/javascripts/repository/components/tree_content.vue4
-rw-r--r--app/assets/javascripts/repository/constants.js4
-rw-r--r--app/assets/javascripts/repository/index.js11
-rw-r--r--app/assets/javascripts/repository/mixins/highlight_mixin.js5
-rw-r--r--app/assets/javascripts/repository/mixins/preload.js2
-rw-r--r--app/assets/javascripts/repository/pages/blob.vue9
-rw-r--r--app/assets/javascripts/repository/pages/index.vue10
-rw-r--r--app/assets/javascripts/repository/pages/tree.vue11
-rw-r--r--app/assets/javascripts/repository/router.js15
-rw-r--r--app/assets/javascripts/repository/utils/ref_switcher_utils.js2
-rw-r--r--app/assets/javascripts/search/sidebar/components/app.vue42
-rw-r--r--app/assets/javascripts/search/sidebar/components/archived_filter/data.js19
-rw-r--r--app/assets/javascripts/search/sidebar/components/archived_filter/index.vue55
-rw-r--r--app/assets/javascripts/search/sidebar/components/archived_filter/tracking.js38
-rw-r--r--app/assets/javascripts/search/sidebar/components/blobs_filters.vue18
-rw-r--r--app/assets/javascripts/search/sidebar/components/checkbox_filter.vue94
-rw-r--r--app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue25
-rw-r--r--app/assets/javascripts/search/sidebar/components/confidentiality_filter/data.js (renamed from app/assets/javascripts/search/sidebar/constants/confidential_filter_data.js)0
-rw-r--r--app/assets/javascripts/search/sidebar/components/confidentiality_filter/index.vue23
-rw-r--r--app/assets/javascripts/search/sidebar/components/filters_template.vue60
-rw-r--r--app/assets/javascripts/search/sidebar/components/issues_filters.vue66
-rw-r--r--app/assets/javascripts/search/sidebar/components/label_filter/index.vue15
-rw-r--r--app/assets/javascripts/search/sidebar/components/language_filter/checkbox_filter.vue5
-rw-r--r--app/assets/javascripts/search/sidebar/components/language_filter/index.vue145
-rw-r--r--app/assets/javascripts/search/sidebar/components/language_filter/tracking.js10
-rw-r--r--app/assets/javascripts/search/sidebar/components/merge_requests_filters.vue18
-rw-r--r--app/assets/javascripts/search/sidebar/components/projects_filters.vue18
-rw-r--r--app/assets/javascripts/search/sidebar/components/radio_filter.vue5
-rw-r--r--app/assets/javascripts/search/sidebar/components/results_filters.vue54
-rw-r--r--app/assets/javascripts/search/sidebar/components/scope_legacy_navigation.vue1
-rw-r--r--app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue3
-rw-r--r--app/assets/javascripts/search/sidebar/components/status_filter.vue25
-rw-r--r--app/assets/javascripts/search/sidebar/components/status_filter/data.js (renamed from app/assets/javascripts/search/sidebar/constants/state_filter_data.js)2
-rw-r--r--app/assets/javascripts/search/sidebar/components/status_filter/index.vue18
-rw-r--r--app/assets/javascripts/search/sidebar/constants/index.js2
-rw-r--r--app/assets/javascripts/search/sort/components/app.vue1
-rw-r--r--app/assets/javascripts/search/store/actions.js20
-rw-r--r--app/assets/javascripts/search/store/constants.js8
-rw-r--r--app/assets/javascripts/search/store/getters.js9
-rw-r--r--app/assets/javascripts/search/store/index.js1
-rw-r--r--app/assets/javascripts/search/topbar/components/app.vue1
-rw-r--r--app/assets/javascripts/search/topbar/components/group_filter.vue1
-rw-r--r--app/assets/javascripts/search/topbar/components/project_filter.vue1
-rw-r--r--app/assets/javascripts/security_configuration/components/app.vue2
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js2
-rw-r--r--app/assets/javascripts/service_desk/components/empty_state_with_any_issues.vue58
-rw-r--r--app/assets/javascripts/service_desk/components/empty_state_without_any_issues.vue74
-rw-r--r--app/assets/javascripts/service_desk/components/info_banner.vue2
-rw-r--r--app/assets/javascripts/service_desk/components/service_desk_list_app.vue324
-rw-r--r--app/assets/javascripts/service_desk/constants.js238
-rw-r--r--app/assets/javascripts/service_desk/index.js25
-rw-r--r--app/assets/javascripts/service_desk/queries/label.fragment.graphql6
-rw-r--r--app/assets/javascripts/service_desk/queries/milestone.fragment.graphql4
-rw-r--r--app/assets/javascripts/service_desk/queries/search_project_labels.query.graphql14
-rw-r--r--app/assets/javascripts/service_desk/queries/search_project_milestones.query.graphql17
-rw-r--r--app/assets/javascripts/service_desk/search_tokens.js97
-rw-r--r--app/assets/javascripts/service_desk/utils.js37
-rw-r--r--app/assets/javascripts/sessions/new/components/email_verification.vue211
-rw-r--r--app/assets/javascripts/sessions/new/components/update_email.vue133
-rw-r--r--app/assets/javascripts/sessions/new/constants.js30
-rw-r--r--app/assets/javascripts/sessions/new/index.js29
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_form.vue35
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/confidentiality_dropdown.vue55
-rw-r--r--app/assets/javascripts/sidebar/components/incidents/escalation_status.vue67
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_button.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_title.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue7
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewers.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/severity/severity.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/constants.js1
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/report.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/set_time_estimate_form.vue215
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue62
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/todo.vue1
-rw-r--r--app/assets/javascripts/sidebar/mount_milestone_sidebar.js1
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js17
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_set_time_estimate.mutation.graphql10
-rw-r--r--app/assets/javascripts/sidebar/queries/merge_request_set_time_estimate.mutation.graphql10
-rw-r--r--app/assets/javascripts/snippets/components/edit.vue2
-rw-r--r--app/assets/javascripts/snippets/components/show.vue1
-rw-r--r--app/assets/javascripts/super_sidebar/components/brand_logo.vue2
-rw-r--r--app/assets/javascripts/super_sidebar/components/context_header.vue56
-rw-r--r--app/assets/javascripts/super_sidebar/components/context_switcher.vue9
-rw-r--r--app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue41
-rw-r--r--app/assets/javascripts/super_sidebar/components/counter.vue1
-rw-r--r--app/assets/javascripts/super_sidebar/components/create_menu.vue1
-rw-r--r--app/assets/javascripts/super_sidebar/components/flyout_menu.vue65
-rw-r--r--app/assets/javascripts/super_sidebar/components/frequent_items_list.vue45
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue8
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue4
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/frequent_groups.vue40
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item.vue64
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue133
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/frequent_projects.vue40
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue26
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue8
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_issuables.vue45
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue63
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_places.vue35
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue1
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/constants.js2
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/getters.js26
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/index.js1
-rw-r--r--app/assets/javascripts/super_sidebar/components/items_list.vue8
-rw-r--r--app/assets/javascripts/super_sidebar/components/menu_section.vue42
-rw-r--r--app/assets/javascripts/super_sidebar/components/nav_item.vue23
-rw-r--r--app/assets/javascripts/super_sidebar/components/pinned_section.vue16
-rw-r--r--app/assets/javascripts/super_sidebar/components/sidebar_menu.vue28
-rw-r--r--app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue11
-rw-r--r--app/assets/javascripts/super_sidebar/components/super_sidebar.vue21
-rw-r--r--app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue2
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_bar.vue47
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_name_group.vue1
-rw-r--r--app/assets/javascripts/super_sidebar/super_sidebar_bundle.js9
-rw-r--r--app/assets/javascripts/super_sidebar/utils.js44
-rw-r--r--app/assets/javascripts/tags/components/delete_tag_modal.vue72
-rw-r--r--app/assets/javascripts/tags/components/sort_dropdown.vue1
-rw-r--r--app/assets/javascripts/tags/constants.js37
-rw-r--r--app/assets/javascripts/terraform/components/init_command_modal.vue1
-rw-r--r--app/assets/javascripts/token_access/components/inbound_token_access.vue76
-rw-r--r--app/assets/javascripts/token_access/components/outbound_token_access.vue60
-rw-r--r--app/assets/javascripts/token_access/components/token_access_app.vue4
-rw-r--r--app/assets/javascripts/tooltips/components/tooltips.vue8
-rw-r--r--app/assets/javascripts/tracing/components/tracing_details.vue90
-rw-r--r--app/assets/javascripts/tracing/components/tracing_empty_state.vue21
-rw-r--r--app/assets/javascripts/tracing/components/tracing_list.vue46
-rw-r--r--app/assets/javascripts/tracing/components/tracing_list_filtered_search.vue87
-rw-r--r--app/assets/javascripts/tracing/components/tracing_table_list.vue38
-rw-r--r--app/assets/javascripts/tracing/details_index.vue49
-rw-r--r--app/assets/javascripts/tracing/filters.js104
-rw-r--r--app/assets/javascripts/tracking/constants.js1
-rw-r--r--app/assets/javascripts/tracking/index.js1
-rw-r--r--app/assets/javascripts/tracking/internal_events.js31
-rw-r--r--app/assets/javascripts/usage_quotas/storage/components/project_storage_app.vue76
-rw-r--r--app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue72
-rw-r--r--app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue7
-rw-r--r--app/assets/javascripts/usage_quotas/storage/constants.js50
-rw-r--r--app/assets/javascripts/usage_quotas/storage/init_project_storage.js2
-rw-r--r--app/assets/javascripts/usage_quotas/storage/utils.js52
-rw-r--r--app/assets/javascripts/user_lists/components/add_user_modal.vue1
-rw-r--r--app/assets/javascripts/user_lists/components/edit_user_list.vue1
-rw-r--r--app/assets/javascripts/user_lists/components/new_user_list.vue1
-rw-r--r--app/assets/javascripts/user_lists/components/user_list.vue2
-rw-r--r--app/assets/javascripts/user_lists/components/user_lists.vue1
-rw-r--r--app/assets/javascripts/user_lists/store/edit/index.js1
-rw-r--r--app/assets/javascripts/user_lists/store/index/index.js1
-rw-r--r--app/assets/javascripts/user_lists/store/new/index.js1
-rw-r--r--app/assets/javascripts/user_lists/store/show/index.js1
-rw-r--r--app/assets/javascripts/users/profile/actions/components/user_actions_app.vue60
-rw-r--r--app/assets/javascripts/users/profile/actions/index.js14
-rw-r--r--app/assets/javascripts/users/profile/index.js2
-rw-r--r--app/assets/javascripts/users_select/constants.js2
-rw-r--r--app/assets/javascripts/users_select/index.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/artifacts_list_app.vue1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/loading.vue1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue66
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/action_buttons.vue58
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue11
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue69
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/constants.js8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.vue157
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/index.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/index.js1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js11
-rw-r--r--app/assets/javascripts/vue_shared/components/actions_button.vue74
-rw-r--r--app/assets/javascripts/vue_shared/components/badges/beta_badge.stories.js24
-rw-r--r--app/assets/javascripts/vue_shared/components/badges/beta_badge.vue67
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_badge_link.vue15
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue19
-rw-r--r--app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/commit.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/index.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/item.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js18
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js129
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/date_token.vue73
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/form/index.js59
-rw-r--r--app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js7
-rw-r--r--app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue70
-rw-r--r--app/assets/javascripts/vue_shared/components/form/title.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/groups_list/groups_list.stories.js19
-rw-r--r--app/assets/javascripts/vue_shared/components/groups_list/groups_list.vue29
-rw-r--r--app/assets/javascripts/vue_shared/components/groups_list/groups_list_item.vue168
-rw-r--r--app/assets/javascripts/vue_shared/components/help_popover.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/listbox_input/init_listbox_inputs.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue36
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue25
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue18
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/tracking.js18
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/store/index.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/page_size_selector.vue37
-rw-r--r--app/assets/javascripts/vue_shared/components/projects_list/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue303
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue30
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/registry_search.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/title_area.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/constants.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/languages/svelte.js81
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue52
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue85
-rw-r--r--app/assets/javascripts/vue_shared/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/global_search/constants.js1
-rw-r--r--app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue37
-rw-r--r--app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue4
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue64
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue16
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue1
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue191
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue3
-rw-r--r--app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue1
-rw-r--r--app/assets/javascripts/webhooks/components/form_url_app.vue1
-rw-r--r--app/assets/javascripts/whats_new/components/app.vue1
-rw-r--r--app/assets/javascripts/whats_new/components/feature.vue3
-rw-r--r--app/assets/javascripts/whats_new/index.js1
-rw-r--r--app/assets/javascripts/whats_new/store/index.js1
-rw-r--r--app/assets/javascripts/work_items/components/item_state.vue79
-rw-r--r--app/assets/javascripts/work_items/components/item_title.vue2
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_add_note.vue1
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue81
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note.vue5
-rw-r--r--app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue196
-rw-r--r--app/assets/javascripts/work_items/components/shared/work_item_link_child_metadata.vue (renamed from app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue)0
-rw-r--r--app/assets/javascripts/work_items/components/shared/work_item_links_menu.vue (renamed from app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue)0
-rw-r--r--app/assets/javascripts/work_items/components/work_item_actions.vue76
-rw-r--r--app/assets/javascripts/work_items/components/work_item_assignees.vue4
-rw-r--r--app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue8
-rw-r--r--app/assets/javascripts/work_items/components/work_item_award_emoji.vue11
-rw-r--r--app/assets/javascripts/work_items/components/work_item_created_updated.vue53
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue70
-rw-r--r--app/assets/javascripts/work_items/components/work_item_due_date.vue2
-rw-r--r--app/assets/javascripts/work_items/components/work_item_labels.vue15
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue58
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue130
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue40
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue1
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue1
-rw-r--r--app/assets/javascripts/work_items/components/work_item_state_badge.vue41
-rw-r--r--app/assets/javascripts/work_items/components/work_item_state_toggle_button.vue (renamed from app/assets/javascripts/work_items/components/work_item_state.vue)71
-rw-r--r--app/assets/javascripts/work_items/components/work_item_type_icon.vue13
-rw-r--r--app/assets/javascripts/work_items/constants.js12
-rw-r--r--app/assets/javascripts/work_items/graphql/milestone.fragment.graphql1
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql14
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql2
-rw-r--r--app/assets/javascripts/work_items/list/components/work_items_list_app.vue73
-rw-r--r--app/assets/javascripts/work_items/list/index.js26
-rw-r--r--app/assets/javascripts/work_items/list/queries/get_work_items.query.graphql56
-rw-r--r--app/assets/javascripts/work_items/utils.js6
-rw-r--r--app/assets/javascripts/work_items_hierarchy/components/hierarchy.vue1
-rw-r--r--app/assets/stylesheets/_page_specific_files.scss1
-rw-r--r--app/assets/stylesheets/components/content_editor.scss12
-rw-r--r--app/assets/stylesheets/framework/awards.scss4
-rw-r--r--app/assets/stylesheets/framework/buttons.scss21
-rw-r--r--app/assets/stylesheets/framework/diffs.scss18
-rw-r--r--app/assets/stylesheets/framework/emojis.scss14
-rw-r--r--app/assets/stylesheets/framework/files.scss5
-rw-r--r--app/assets/stylesheets/framework/header.scss38
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss2
-rw-r--r--app/assets/stylesheets/framework/mixins.scss5
-rw-r--r--app/assets/stylesheets/framework/new_card.scss61
-rw-r--r--app/assets/stylesheets/framework/secondary_navigation_elements.scss2
-rw-r--r--app/assets/stylesheets/framework/super_sidebar.scss51
-rw-r--r--app/assets/stylesheets/framework/variables.scss2
-rw-r--r--app/assets/stylesheets/highlight/diff_custom_colors_addition.scss2
-rw-r--r--app/assets/stylesheets/highlight/diff_custom_colors_deletion.scss2
-rw-r--r--app/assets/stylesheets/highlight/themes/dark.scss10
-rw-r--r--app/assets/stylesheets/highlight/themes/monokai.scss10
-rw-r--r--app/assets/stylesheets/highlight/themes/none.scss2
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-dark.scss10
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-light.scss10
-rw-r--r--app/assets/stylesheets/highlight/white_base.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/_pipeline_mixins.scss12
-rw-r--r--app/assets/stylesheets/page_bundles/editor.scss37
-rw-r--r--app/assets/stylesheets/page_bundles/incident_management_list.scss122
-rw-r--r--app/assets/stylesheets/page_bundles/issues_list.scss10
-rw-r--r--app/assets/stylesheets/page_bundles/merge_request.scss (renamed from app/assets/stylesheets/pages/merge_requests.scss)50
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss61
-rw-r--r--app/assets/stylesheets/page_bundles/profile.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/tree.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/work_items.scss35
-rw-r--r--app/assets/stylesheets/pages/labels.scss4
-rw-r--r--app/assets/stylesheets/pages/notes.scss1
-rw-r--r--app/assets/stylesheets/pages/projects.scss39
-rw-r--r--app/assets/stylesheets/pages/settings.scss57
-rw-r--r--app/assets/stylesheets/print.scss36
-rw-r--r--app/assets/stylesheets/snippets.scss4
-rw-r--r--app/assets/stylesheets/themes/_dark.scss6
-rw-r--r--app/assets/stylesheets/themes/theme_blue.scss9
-rw-r--r--app/assets/stylesheets/themes/theme_gray.scss9
-rw-r--r--app/assets/stylesheets/themes/theme_green.scss9
-rw-r--r--app/assets/stylesheets/themes/theme_helper.scss60
-rw-r--r--app/assets/stylesheets/themes/theme_indigo.scss9
-rw-r--r--app/assets/stylesheets/themes/theme_light_blue.scss9
-rw-r--r--app/assets/stylesheets/themes/theme_light_green.scss9
-rw-r--r--app/assets/stylesheets/themes/theme_light_indigo.scss9
-rw-r--r--app/assets/stylesheets/themes/theme_light_red.scss9
-rw-r--r--app/assets/stylesheets/themes/theme_red.scss9
-rw-r--r--app/assets/stylesheets/utilities.scss21
-rw-r--r--app/channels/noteable/notes_channel.rb23
-rw-r--r--app/components/projects/ml/models_index_component.html.haml1
-rw-r--r--app/components/projects/ml/models_index_component.rb29
-rw-r--r--app/controllers/admin/abuse_reports_controller.rb18
-rw-r--r--app/controllers/admin/application_settings_controller.rb1
-rw-r--r--app/controllers/admin/broadcast_messages_controller.rb12
-rw-r--r--app/controllers/admin/identities_controller.rb2
-rw-r--r--app/controllers/admin/impersonation_tokens_controller.rb2
-rw-r--r--app/controllers/admin/labels_controller.rb18
-rw-r--r--app/controllers/admin/users_controller.rb35
-rw-r--r--app/controllers/concerns/authenticates_with_two_factor.rb8
-rw-r--r--app/controllers/concerns/enforces_two_factor_authentication.rb2
-rw-r--r--app/controllers/concerns/google_syndication_csp.rb21
-rw-r--r--app/controllers/concerns/integrations/params.rb11
-rw-r--r--app/controllers/concerns/issuable_actions.rb1
-rw-r--r--app/controllers/concerns/kas_cookie.rb15
-rw-r--r--app/controllers/concerns/notes_actions.rb7
-rw-r--r--app/controllers/concerns/onboarding/status.rb11
-rw-r--r--app/controllers/concerns/product_analytics_tracking.rb13
-rw-r--r--app/controllers/concerns/verifies_with_email.rb67
-rw-r--r--app/controllers/confirmations_controller.rb1
-rw-r--r--app/controllers/graphql_controller.rb14
-rw-r--r--app/controllers/groups/application_controller.rb12
-rw-r--r--app/controllers/groups/dependency_proxy/application_controller.rb2
-rw-r--r--app/controllers/groups/labels_controller.rb9
-rw-r--r--app/controllers/groups/runners_controller.rb2
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb7
-rw-r--r--app/controllers/groups/work_items_controller.rb11
-rw-r--r--app/controllers/groups_controller.rb16
-rw-r--r--app/controllers/import/github_controller.rb8
-rw-r--r--app/controllers/jira_connect/app_descriptor_controller.rb7
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb18
-rw-r--r--app/controllers/organizations/application_controller.rb7
-rw-r--r--app/controllers/organizations/organizations_controller.rb2
-rw-r--r--app/controllers/profiles/notifications_controller.rb2
-rw-r--r--app/controllers/profiles/preferences_controller.rb7
-rw-r--r--app/controllers/projects/blob_controller.rb27
-rw-r--r--app/controllers/projects/ci/daily_build_group_report_results_controller.rb2
-rw-r--r--app/controllers/projects/ci/pipeline_editor_controller.rb2
-rw-r--r--app/controllers/projects/discussions_controller.rb23
-rw-r--r--app/controllers/projects/environments_controller.rb9
-rw-r--r--app/controllers/projects/issues_controller.rb3
-rw-r--r--app/controllers/projects/labels_controller.rb11
-rw-r--r--app/controllers/projects/merge_requests_controller.rb3
-rw-r--r--app/controllers/projects/metrics/dashboards/builder_controller.rb47
-rw-r--r--app/controllers/projects/pages_controller.rb10
-rw-r--r--app/controllers/projects/performance_monitoring/dashboards_controller.rb114
-rw-r--r--app/controllers/projects/pipeline_schedules_controller.rb24
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb3
-rw-r--r--app/controllers/projects/tracing_controller.rb4
-rw-r--r--app/controllers/projects/tree_controller.rb1
-rw-r--r--app/controllers/projects_controller.rb4
-rw-r--r--app/controllers/registrations/welcome_controller.rb10
-rw-r--r--app/controllers/registrations_controller.rb1
-rw-r--r--app/controllers/repositories/lfs_api_controller.rb4
-rw-r--r--app/controllers/repositories/lfs_storage_controller.rb5
-rw-r--r--app/controllers/search_controller.rb8
-rw-r--r--app/controllers/sessions_controller.rb1
-rw-r--r--app/events/package_metadata/ingested_advisory_event.rb15
-rw-r--r--app/events/project_authorizations/authorizations_changed_event.rb15
-rw-r--r--app/events/repositories/default_branch_changed_event.rb16
-rw-r--r--app/finders/abuse_reports_finder.rb60
-rw-r--r--app/finders/admin/abuse_report_labels_finder.rb33
-rw-r--r--app/finders/autocomplete/group_users_finder.rb87
-rw-r--r--app/finders/autocomplete/routes_finder.rb9
-rw-r--r--app/finders/autocomplete/users_finder.rb19
-rw-r--r--app/finders/deployments_finder.rb3
-rw-r--r--app/finders/group_descendants_finder.rb2
-rw-r--r--app/finders/group_members_finder.rb5
-rw-r--r--app/finders/group_projects_finder.rb16
-rw-r--r--app/finders/groups_finder.rb20
-rw-r--r--app/finders/issuable_finder/params.rb2
-rw-r--r--app/finders/issuables/assignee_filter.rb4
-rw-r--r--app/finders/issuables/author_filter.rb3
-rw-r--r--app/finders/issues_finder.rb1
-rw-r--r--app/finders/labels_finder.rb7
-rw-r--r--app/finders/merge_request_target_project_finder.rb5
-rw-r--r--app/finders/metrics/dashboards/annotations_finder.rb43
-rw-r--r--app/finders/metrics/users_starred_dashboards_finder.rb37
-rw-r--r--app/finders/packages/nuget/package_finder.rb32
-rw-r--r--app/finders/packages/pipelines_finder.rb22
-rw-r--r--app/finders/projects/ml/model_finder.rb9
-rw-r--r--app/finders/repositories/tree_finder.rb6
-rw-r--r--app/finders/snippets_finder.rb39
-rw-r--r--app/finders/work_items/namespace_work_items_finder.rb49
-rw-r--r--app/graphql/mutations/alert_management/alerts/set_assignees.rb12
-rw-r--r--app/graphql/mutations/alert_management/base.rb26
-rw-r--r--app/graphql/mutations/alert_management/http_integration/create.rb12
-rw-r--r--app/graphql/mutations/alert_management/http_integration/destroy.rb4
-rw-r--r--app/graphql/mutations/alert_management/http_integration/http_integration_base.rb6
-rw-r--r--app/graphql/mutations/alert_management/http_integration/reset_token.rb4
-rw-r--r--app/graphql/mutations/alert_management/http_integration/update.rb12
-rw-r--r--app/graphql/mutations/alert_management/prometheus_integration/create.rb12
-rw-r--r--app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb6
-rw-r--r--app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb4
-rw-r--r--app/graphql/mutations/alert_management/prometheus_integration/update.rb12
-rw-r--r--app/graphql/mutations/alert_management/update_alert_status.rb4
-rw-r--r--app/graphql/mutations/award_emojis/base.rb4
-rw-r--r--app/graphql/mutations/ci/pipeline_schedule/create.rb32
-rw-r--r--app/graphql/mutations/ci/pipeline_trigger/base.rb18
-rw-r--r--app/graphql/mutations/ci/pipeline_trigger/create.rb38
-rw-r--r--app/graphql/mutations/ci/pipeline_trigger/delete.rb19
-rw-r--r--app/graphql/mutations/ci/pipeline_trigger/update.rb32
-rw-r--r--app/graphql/mutations/concerns/mutations/validate_time_estimate.rb22
-rw-r--r--app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb2
-rw-r--r--app/graphql/mutations/environments/create.rb5
-rw-r--r--app/graphql/mutations/environments/update.rb5
-rw-r--r--app/graphql/mutations/issues/update.rb5
-rw-r--r--app/graphql/mutations/merge_requests/update.rb7
-rw-r--r--app/graphql/mutations/metrics/dashboard/annotations/create.rb10
-rw-r--r--app/graphql/mutations/metrics/dashboard/annotations/delete.rb14
-rw-r--r--app/graphql/mutations/namespace/package_settings/update.rb20
-rw-r--r--app/graphql/mutations/work_items/create.rb8
-rw-r--r--app/graphql/mutations/work_items/linked_items/add.rb32
-rw-r--r--app/graphql/mutations/work_items/linked_items/base.rb58
-rw-r--r--app/graphql/mutations/work_items/subscribe.rb41
-rw-r--r--app/graphql/queries/repository/blob_info.query.graphql3
-rw-r--r--app/graphql/queries/repository/files.query.graphql3
-rw-r--r--app/graphql/queries/repository/paginated_tree.query.graphql10
-rw-r--r--app/graphql/queries/repository/path_last_commit.query.graphql4
-rw-r--r--app/graphql/resolvers/abuse_report_labels_resolver.rb19
-rw-r--r--app/graphql/resolvers/abuse_report_resolver.rb21
-rw-r--r--app/graphql/resolvers/autocomplete_users_resolver.rb32
-rw-r--r--app/graphql/resolvers/ci/pipeline_triggers_resolver.rb23
-rw-r--r--app/graphql/resolvers/concerns/resolves_merge_requests.rb3
-rw-r--r--app/graphql/resolvers/concerns/work_items/look_ahead_preloads.rb57
-rw-r--r--app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb5
-rw-r--r--app/graphql/resolvers/namespaces/work_items_resolver.rb34
-rw-r--r--app/graphql/resolvers/work_items/linked_items_resolver.rb36
-rw-r--r--app/graphql/resolvers/work_items_resolver.rb46
-rw-r--r--app/graphql/types/abuse_report_type.rb12
-rw-r--r--app/graphql/types/access_levels/deploy_key_type.rb32
-rw-r--r--app/graphql/types/access_levels/user_type.rb61
-rw-r--r--app/graphql/types/achievements/achievement_type.rb2
-rw-r--r--app/graphql/types/achievements/user_achievement_type.rb2
-rw-r--r--app/graphql/types/alert_management/alert_type.rb14
-rw-r--r--app/graphql/types/alert_management/http_integration_type.rb2
-rw-r--r--app/graphql/types/alert_management/prometheus_integration_type.rb2
-rw-r--r--app/graphql/types/branch_protections/push_access_level_type.rb5
-rw-r--r--app/graphql/types/ci/ci_cd_setting_type.rb55
-rw-r--r--app/graphql/types/ci/detailed_status_type.rb20
-rw-r--r--app/graphql/types/ci/group_environment_scope_type.rb2
-rw-r--r--app/graphql/types/ci/group_variable_type.rb4
-rw-r--r--app/graphql/types/ci/instance_variable_type.rb2
-rw-r--r--app/graphql/types/ci/job_type.rb2
-rw-r--r--app/graphql/types/ci/manual_variable_type.rb2
-rw-r--r--app/graphql/types/ci/pipeline_schedule_type.rb2
-rw-r--r--app/graphql/types/ci/pipeline_schedule_variable_type.rb2
-rw-r--r--app/graphql/types/ci/pipeline_trigger_type.rb44
-rw-r--r--app/graphql/types/ci/pipeline_type.rb2
-rw-r--r--app/graphql/types/ci/project_variable_type.rb4
-rw-r--r--app/graphql/types/ci/recent_failures_type.rb2
-rw-r--r--app/graphql/types/ci/runner_manager_type.rb17
-rw-r--r--app/graphql/types/ci/runner_type.rb19
-rw-r--r--app/graphql/types/ci/test_case_type.rb2
-rw-r--r--app/graphql/types/ci/test_suite_summary_type.rb2
-rw-r--r--app/graphql/types/ci/test_suite_type.rb2
-rw-r--r--app/graphql/types/clusters/agent_activity_event_type.rb2
-rw-r--r--app/graphql/types/clusters/agent_token_type.rb2
-rw-r--r--app/graphql/types/clusters/agent_type.rb2
-rw-r--r--app/graphql/types/commit_type.rb23
-rw-r--r--app/graphql/types/custom_emoji_type.rb8
-rw-r--r--app/graphql/types/deployment_type.rb3
-rw-r--r--app/graphql/types/design_management/design_type.rb8
-rw-r--r--app/graphql/types/diff_type.rb26
-rw-r--r--app/graphql/types/environment_type.rb3
-rw-r--r--app/graphql/types/group_type.rb12
-rw-r--r--app/graphql/types/issue_type.rb16
-rw-r--r--app/graphql/types/label_type.rb2
-rw-r--r--app/graphql/types/merge_request_state_enum.rb3
-rw-r--r--app/graphql/types/merge_request_type.rb15
-rw-r--r--app/graphql/types/mutation_type.rb5
-rw-r--r--app/graphql/types/namespace/package_settings_type.rb8
-rw-r--r--app/graphql/types/notes/discussion_type.rb2
-rw-r--r--app/graphql/types/notes/note_type.rb4
-rw-r--r--app/graphql/types/notes/position_type_enum.rb1
-rw-r--r--app/graphql/types/packages/package_base_type.rb2
-rw-r--r--app/graphql/types/permission_types/group.rb2
-rw-r--r--app/graphql/types/project_type.rb774
-rw-r--r--app/graphql/types/projects/services/base_service_type.rb2
-rw-r--r--app/graphql/types/projects/services/jira_service_type.rb2
-rw-r--r--app/graphql/types/query_type.rb12
-rw-r--r--app/graphql/types/release_type.rb2
-rw-r--r--app/graphql/types/saved_reply_type.rb2
-rw-r--r--app/graphql/types/snippet_type.rb2
-rw-r--r--app/graphql/types/snippets/blob_type.rb2
-rw-r--r--app/graphql/types/terraform/state_type.rb2
-rw-r--r--app/graphql/types/timelog_type.rb2
-rw-r--r--app/graphql/types/todo_action_enum.rb1
-rw-r--r--app/graphql/types/users/autocompleted_user_type.rb24
-rw-r--r--app/graphql/types/work_item_type.rb2
-rw-r--r--app/graphql/types/work_items/linked_item_type.rb22
-rw-r--r--app/graphql/types/work_items/related_link_type_enum.rb14
-rw-r--r--app/graphql/types/work_items/widget_interface.rb5
-rw-r--r--app/graphql/types/work_items/widgets/award_emoji_type.rb4
-rw-r--r--app/graphql/types/work_items/widgets/linked_items_type.rb25
-rw-r--r--app/helpers/admin/application_settings/settings_helper.rb60
-rw-r--r--app/helpers/admin/broadcast_messages_helper.rb123
-rw-r--r--app/helpers/admin/deploy_key_helper.rb2
-rw-r--r--app/helpers/application_helper.rb21
-rw-r--r--app/helpers/application_settings_helper.rb11
-rw-r--r--app/helpers/broadcast_messages_helper.rb121
-rw-r--r--app/helpers/ci/runners_helper.rb37
-rw-r--r--app/helpers/commits_helper.rb27
-rw-r--r--app/helpers/dropdowns_helper.rb5
-rw-r--r--app/helpers/emails_helper.rb39
-rw-r--r--app/helpers/environments_helper.rb4
-rw-r--r--app/helpers/integrations_helper.rb5
-rw-r--r--app/helpers/issuables_helper.rb29
-rw-r--r--app/helpers/issues_helper.rb9
-rw-r--r--app/helpers/jira_connect_helper.rb2
-rw-r--r--app/helpers/labels_helper.rb8
-rw-r--r--app/helpers/markup_helper.rb32
-rw-r--r--app/helpers/merge_requests_helper.rb6
-rw-r--r--app/helpers/mirror_helper.rb5
-rw-r--r--app/helpers/nav_helper.rb2
-rw-r--r--app/helpers/notes_helper.rb4
-rw-r--r--app/helpers/packages_helper.rb2
-rw-r--r--app/helpers/profiles_helper.rb15
-rw-r--r--app/helpers/projects/cluster_agents_helper.rb2
-rw-r--r--app/helpers/projects/observability_helper.rb10
-rw-r--r--app/helpers/projects_helper.rb36
-rw-r--r--app/helpers/sessions_helper.rb23
-rw-r--r--app/helpers/sidebars_helper.rb44
-rw-r--r--app/helpers/snippets_helper.rb34
-rw-r--r--app/helpers/time_helper.rb24
-rw-r--r--app/helpers/todos_helper.rb1
-rw-r--r--app/helpers/tree_helper.rb3
-rw-r--r--app/helpers/users/callouts_helper.rb2
-rw-r--r--app/helpers/users_helper.rb36
-rw-r--r--app/mailers/devise_mailer.rb9
-rw-r--r--app/mailers/emails/members.rb16
-rw-r--r--app/mailers/emails/merge_requests.rb3
-rw-r--r--app/mailers/emails/profile.rb6
-rw-r--r--app/mailers/emails/reviews.rb15
-rw-r--r--app/mailers/notify.rb1
-rw-r--r--app/mailers/previews/devise_mailer_preview.rb4
-rw-r--r--app/mailers/previews/notify_preview.rb23
-rw-r--r--app/models/abuse_report.rb20
-rw-r--r--app/models/admin/abuse_report_label.rb6
-rw-r--r--app/models/ai/service_access_token.rb1
-rw-r--r--app/models/application_record.rb3
-rw-r--r--app/models/application_setting.rb47
-rw-r--r--app/models/application_setting_implementation.rb5
-rw-r--r--app/models/authentication_event.rb2
-rw-r--r--app/models/batched_git_ref_updates/deletion.rb67
-rw-r--r--app/models/broadcast_message.rb163
-rw-r--r--app/models/ci/bridge.rb24
-rw-r--r--app/models/ci/build.rb156
-rw-r--r--app/models/ci/catalog/resource.rb2
-rw-r--r--app/models/ci/catalog/resources/component.rb24
-rw-r--r--app/models/ci/catalog/resources/version.rb22
-rw-r--r--app/models/ci/job_annotation.rb4
-rw-r--r--app/models/ci/job_artifact.rb17
-rw-r--r--app/models/ci/job_token/project_scope_link.rb2
-rw-r--r--app/models/ci/persistent_ref.rb8
-rw-r--r--app/models/ci/pipeline.rb16
-rw-r--r--app/models/ci/pipeline_chat_data.rb3
-rw-r--r--app/models/ci/pipeline_message.rb4
-rw-r--r--app/models/ci/pipeline_variable.rb1
-rw-r--r--app/models/ci/processable.rb6
-rw-r--r--app/models/ci/runner.rb16
-rw-r--r--app/models/ci/runner_manager.rb20
-rw-r--r--app/models/ci/sources/pipeline.rb5
-rw-r--r--app/models/ci/stage.rb5
-rw-r--r--app/models/clusters/cluster.rb1
-rw-r--r--app/models/commit.rb3
-rw-r--r--app/models/commit_collection.rb8
-rw-r--r--app/models/commit_range.rb5
-rw-r--r--app/models/commit_status.rb8
-rw-r--r--app/models/concerns/application_setting_masked_attrs.rb14
-rw-r--r--app/models/concerns/approvable.rb10
-rw-r--r--app/models/concerns/ci/deployable.rb161
-rw-r--r--app/models/concerns/ci/metadatable.rb6
-rw-r--r--app/models/concerns/ci/partitionable.rb1
-rw-r--r--app/models/concerns/ci/partitionable/switch.rb44
-rw-r--r--app/models/concerns/cross_database_ignored_tables.rb47
-rw-r--r--app/models/concerns/each_batch.rb1
-rw-r--r--app/models/concerns/enum_inheritance.rb58
-rw-r--r--app/models/concerns/from_union.rb3
-rw-r--r--app/models/concerns/has_repository.rb3
-rw-r--r--app/models/concerns/issuable_link.rb6
-rw-r--r--app/models/concerns/linkable_item.rb37
-rw-r--r--app/models/concerns/milestoneable.rb4
-rw-r--r--app/models/concerns/noteable.rb8
-rw-r--r--app/models/concerns/packages/nuget/version_normalizable.rb50
-rw-r--r--app/models/concerns/reset_on_union_error.rb37
-rw-r--r--app/models/concerns/resolvable_discussion.rb2
-rw-r--r--app/models/concerns/resolvable_note.rb10
-rw-r--r--app/models/concerns/routable.rb26
-rw-r--r--app/models/concerns/time_trackable.rb11
-rw-r--r--app/models/concerns/web_hooks/auto_disabling.rb31
-rw-r--r--app/models/customer_relations/contact.rb16
-rw-r--r--app/models/dependency_proxy/manifest.rb3
-rw-r--r--app/models/deployment.rb45
-rw-r--r--app/models/discussion.rb4
-rw-r--r--app/models/email.rb2
-rw-r--r--app/models/environment.rb21
-rw-r--r--app/models/event.rb2
-rw-r--r--app/models/group.rb59
-rw-r--r--app/models/group_group_link.rb1
-rw-r--r--app/models/identity.rb2
-rw-r--r--app/models/identity/uniqueness_scopes.rb2
-rw-r--r--app/models/instance_configuration.rb4
-rw-r--r--app/models/integration.rb8
-rw-r--r--app/models/integrations/apple_app_store.rb2
-rw-r--r--app/models/integrations/asana.rb2
-rw-r--r--app/models/integrations/assembla.rb2
-rw-r--r--app/models/integrations/bamboo.rb2
-rw-r--r--app/models/integrations/base_chat_notification.rb27
-rw-r--r--app/models/integrations/buildkite.rb2
-rw-r--r--app/models/integrations/campfire.rb2
-rw-r--r--app/models/integrations/chat_message/issue_message.rb4
-rw-r--r--app/models/integrations/datadog.rb6
-rw-r--r--app/models/integrations/discord.rb23
-rw-r--r--app/models/integrations/drone_ci.rb2
-rw-r--r--app/models/integrations/emails_on_push.rb8
-rw-r--r--app/models/integrations/field.rb19
-rw-r--r--app/models/integrations/google_play.rb15
-rw-r--r--app/models/integrations/hangouts_chat.rb4
-rw-r--r--app/models/integrations/harbor.rb2
-rw-r--r--app/models/integrations/irker.rb4
-rw-r--r--app/models/integrations/jenkins.rb2
-rw-r--r--app/models/integrations/jira.rb2
-rw-r--r--app/models/integrations/mattermost_slash_commands.rb2
-rw-r--r--app/models/integrations/microsoft_teams.rb4
-rw-r--r--app/models/integrations/packagist.rb2
-rw-r--r--app/models/integrations/pipelines_email.rb8
-rw-r--r--app/models/integrations/pivotaltracker.rb2
-rw-r--r--app/models/integrations/prometheus.rb4
-rw-r--r--app/models/integrations/pumble.rb31
-rw-r--r--app/models/integrations/pushover.rb8
-rw-r--r--app/models/integrations/slack_slash_commands.rb2
-rw-r--r--app/models/integrations/squash_tm.rb2
-rw-r--r--app/models/integrations/teamcity.rb2
-rw-r--r--app/models/integrations/unify_circuit.rb4
-rw-r--r--app/models/integrations/webex_teams.rb4
-rw-r--r--app/models/integrations/zentao.rb2
-rw-r--r--app/models/issue.rb37
-rw-r--r--app/models/issue_link.rb9
-rw-r--r--app/models/label.rb17
-rw-r--r--app/models/loose_foreign_keys/deleted_record.rb37
-rw-r--r--app/models/member.rb1
-rw-r--r--app/models/merge_request.rb60
-rw-r--r--app/models/merge_request/metrics.rb3
-rw-r--r--app/models/merge_request_diff.rb3
-rw-r--r--app/models/metrics/dashboard/annotation.rb17
-rw-r--r--app/models/ml/experiment.rb4
-rw-r--r--app/models/ml/model.rb11
-rw-r--r--app/models/ml/model_version.rb11
-rw-r--r--app/models/namespace.rb6
-rw-r--r--app/models/namespace/aggregation_schedule.rb6
-rw-r--r--app/models/namespace/detail.rb4
-rw-r--r--app/models/namespace/package_setting.rb4
-rw-r--r--app/models/namespace_setting.rb1
-rw-r--r--app/models/namespaces/project_namespace.rb39
-rw-r--r--app/models/network/graph.rb17
-rw-r--r--app/models/note.rb11
-rw-r--r--app/models/operations/feature_flag.rb4
-rw-r--r--app/models/operations/feature_flags/strategy.rb16
-rw-r--r--app/models/organizations/organization.rb4
-rw-r--r--app/models/packages/nuget/metadatum.rb8
-rw-r--r--app/models/packages/package.rb34
-rw-r--r--app/models/pages_deployment.rb9
-rw-r--r--app/models/performance_monitoring/prometheus_dashboard.rb78
-rw-r--r--app/models/plan.rb2
-rw-r--r--app/models/pool_repository.rb12
-rw-r--r--app/models/project.rb108
-rw-r--r--app/models/project_authorization.rb54
-rw-r--r--app/models/project_authorizations/changes.rb143
-rw-r--r--app/models/project_group_link.rb2
-rw-r--r--app/models/project_setting.rb5
-rw-r--r--app/models/project_statistics.rb19
-rw-r--r--app/models/project_team.rb6
-rw-r--r--app/models/redirect_route.rb2
-rw-r--r--app/models/release.rb2
-rw-r--r--app/models/repository.rb28
-rw-r--r--app/models/review.rb2
-rw-r--r--app/models/route.rb2
-rw-r--r--app/models/sent_notification.rb4
-rw-r--r--app/models/service_desk/custom_email_verification.rb2
-rw-r--r--app/models/system/broadcast_message.rb165
-rw-r--r--app/models/todo.rb8
-rw-r--r--app/models/tree.rb6
-rw-r--r--app/models/user.rb25
-rw-r--r--app/models/user_detail.rb3
-rw-r--r--app/models/user_preference.rb2
-rw-r--r--app/models/user_status.rb2
-rw-r--r--app/models/user_synced_attributes_metadata.rb2
-rw-r--r--app/models/users/callout.rb2
-rw-r--r--app/models/users/calloutable.rb4
-rw-r--r--app/models/wiki_page.rb7
-rw-r--r--app/models/work_item.rb17
-rw-r--r--app/models/work_items/parent_link.rb11
-rw-r--r--app/models/work_items/related_work_item_link.rb27
-rw-r--r--app/models/work_items/type.rb12
-rw-r--r--app/models/work_items/widget_definition.rb3
-rw-r--r--app/models/work_items/widgets/linked_items.rb9
-rw-r--r--app/policies/admin/abuse_report_label_policy.rb9
-rw-r--r--app/policies/ci/bridge_policy.rb2
-rw-r--r--app/policies/ci/build_policy.rb26
-rw-r--r--app/policies/ci/deployable_policy.rb17
-rw-r--r--app/policies/concerns/find_group_projects.rb4
-rw-r--r--app/policies/deploy_key_policy.rb10
-rw-r--r--app/policies/group_policy.rb4
-rw-r--r--app/policies/organizations/organization_policy.rb14
-rw-r--r--app/policies/packages/policies/project_policy.rb3
-rw-r--r--app/policies/project_policy.rb8
-rw-r--r--app/policies/work_item_policy.rb5
-rw-r--r--app/presenters/issue_presenter.rb4
-rw-r--r--app/presenters/merge_request_presenter.rb11
-rw-r--r--app/presenters/ml/model_presenter.rb17
-rw-r--r--app/presenters/ml/models_index_presenter.rb21
-rw-r--r--app/presenters/packages/npm/package_presenter.rb27
-rw-r--r--app/presenters/packages/nuget/v2/metadata_index_presenter.rb48
-rw-r--r--app/presenters/packages/nuget/v2/service_index_presenter.rb48
-rw-r--r--app/presenters/projects/import_export/project_export_presenter.rb2
-rw-r--r--app/serializers/admin/abuse_report_details_entity.rb8
-rw-r--r--app/serializers/admin/abuse_report_entity.rb7
-rw-r--r--app/serializers/base_discussion_entity.rb14
-rw-r--r--app/serializers/deployment_entity.rb4
-rw-r--r--app/serializers/discussion_entity.rb5
-rw-r--r--app/serializers/environment_entity.rb2
-rw-r--r--app/serializers/environment_serializer.rb2
-rw-r--r--app/serializers/environment_status_entity.rb2
-rw-r--r--app/serializers/integrations/event_entity.rb5
-rw-r--r--app/serializers/integrations/field_entity.rb10
-rw-r--r--app/serializers/merge_request_serializer.rb2
-rw-r--r--app/serializers/note_entity.rb5
-rw-r--r--app/serializers/profile/event_entity.rb2
-rw-r--r--app/serializers/project_note_entity.rb8
-rw-r--r--app/services/admin/abuse_report_update_service.rb91
-rw-r--r--app/services/admin/abuse_reports/moderate_user_service.rb93
-rw-r--r--app/services/admin/plan_limits/update_service.rb41
-rw-r--r--app/services/auth/container_registry_authentication_service.rb22
-rw-r--r--app/services/authorized_project_update/project_recalculate_service.rb11
-rw-r--r--app/services/award_emojis/add_service.rb4
-rw-r--r--app/services/batched_git_ref_updates/cleanup_scheduler_service.rb29
-rw-r--r--app/services/batched_git_ref_updates/project_cleanup_service.rb44
-rw-r--r--app/services/boards/base_items_list_service.rb23
-rw-r--r--app/services/bulk_imports/file_download_service.rb9
-rw-r--r--app/services/ci/create_pipeline_schedule_service.rb16
-rw-r--r--app/services/ci/job_artifacts/create_service.rb5
-rw-r--r--app/services/ci/parse_annotations_artifact_service.rb61
-rw-r--r--app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb13
-rw-r--r--app/services/ci/pipeline_processing/atomic_processing_service.rb4
-rw-r--r--app/services/ci/pipeline_schedules/base_save_service.rb54
-rw-r--r--app/services/ci/pipeline_schedules/create_service.rb40
-rw-r--r--app/services/ci/pipeline_schedules/update_service.rb34
-rw-r--r--app/services/ci/retry_job_service.rb2
-rw-r--r--app/services/clusters/management/validate_management_project_permissions_service.rb2
-rw-r--r--app/services/concerns/merge_requests/error_logger.rb31
-rw-r--r--app/services/concerns/update_repository_storage_methods.rb13
-rw-r--r--app/services/deployments/create_for_job_service.rb (renamed from app/services/deployments/create_for_build_service.rb)46
-rw-r--r--app/services/deployments/older_deployments_drop_service.rb35
-rw-r--r--app/services/environments/create_for_build_service.rb40
-rw-r--r--app/services/environments/create_for_job_service.rb40
-rw-r--r--app/services/environments/create_service.rb2
-rw-r--r--app/services/environments/update_service.rb2
-rw-r--r--app/services/git/base_hooks_service.rb1
-rw-r--r--app/services/grafana/proxy_service.rb94
-rw-r--r--app/services/groups/create_service.rb6
-rw-r--r--app/services/groups/participants_service.rb8
-rw-r--r--app/services/groups/transfer_service.rb32
-rw-r--r--app/services/groups/update_service.rb31
-rw-r--r--app/services/issuable/bulk_update_service.rb4
-rw-r--r--app/services/issuable_links/create_service.rb2
-rw-r--r--app/services/issues/import_csv_service.rb10
-rw-r--r--app/services/issues/move_service.rb4
-rw-r--r--app/services/issues/relative_position_rebalancing_service.rb4
-rw-r--r--app/services/labels/available_labels_service.rb15
-rw-r--r--app/services/labels/create_service.rb4
-rw-r--r--app/services/labels/update_service.rb10
-rw-r--r--app/services/members/import_project_team_service.rb81
-rw-r--r--app/services/members/update_service.rb1
-rw-r--r--app/services/merge_requests/base_service.rb38
-rw-r--r--app/services/merge_requests/create_ref_service.rb130
-rw-r--r--app/services/merge_requests/merge_base_service.rb7
-rw-r--r--app/services/merge_requests/merge_service.rb63
-rw-r--r--app/services/merge_requests/merge_strategies/from_source_branch.rb112
-rw-r--r--app/services/merge_requests/merge_strategies/strategy_error.rb7
-rw-r--r--app/services/merge_requests/merge_to_ref_service.rb19
-rw-r--r--app/services/merge_requests/squash_service.rb20
-rw-r--r--app/services/metrics/dashboard/annotations/create_service.rb81
-rw-r--r--app/services/metrics/dashboard/annotations/delete_service.rb44
-rw-r--r--app/services/metrics/dashboard/base_embed_service.rb40
-rw-r--r--app/services/metrics/dashboard/base_service.rb140
-rw-r--r--app/services/metrics/dashboard/clone_dashboard_service.rb174
-rw-r--r--app/services/metrics/dashboard/cluster_dashboard_service.rb39
-rw-r--r--app/services/metrics/dashboard/cluster_metrics_embed_service.rb37
-rw-r--r--app/services/metrics/dashboard/custom_dashboard_service.rb53
-rw-r--r--app/services/metrics/dashboard/custom_metric_embed_service.rb116
-rw-r--r--app/services/metrics/dashboard/default_embed_service.rb69
-rw-r--r--app/services/metrics/dashboard/dynamic_embed_service.rb78
-rw-r--r--app/services/metrics/dashboard/gitlab_alert_embed_service.rb77
-rw-r--r--app/services/metrics/dashboard/grafana_metric_embed_service.rb179
-rw-r--r--app/services/metrics/dashboard/panel_preview_service.rb55
-rw-r--r--app/services/metrics/dashboard/pod_dashboard_service.rb37
-rw-r--r--app/services/metrics/dashboard/predefined_dashboard_service.rb63
-rw-r--r--app/services/metrics/dashboard/system_dashboard_service.rb42
-rw-r--r--app/services/metrics/dashboard/transient_embed_service.rb46
-rw-r--r--app/services/metrics/dashboard/update_dashboard_service.rb127
-rw-r--r--app/services/metrics/users_starred_dashboards/create_service.rb76
-rw-r--r--app/services/metrics/users_starred_dashboards/delete_service.rb35
-rw-r--r--app/services/ml/experiment_tracking/candidate_repository.rb14
-rw-r--r--app/services/ml/find_or_create_experiment_service.rb19
-rw-r--r--app/services/ml/find_or_create_model_service.rb22
-rw-r--r--app/services/ml/find_or_create_model_version_service.rb22
-rw-r--r--app/services/namespace_settings/update_service.rb4
-rw-r--r--app/services/namespaces/package_settings/update_service.rb2
-rw-r--r--app/services/notification_service.rb27
-rw-r--r--app/services/packages/ml_model/create_package_file_service.rb15
-rw-r--r--app/services/packages/nuget/update_package_from_metadata_service.rb3
-rw-r--r--app/services/packages/rubygems/process_gem_service.rb5
-rw-r--r--app/services/personal_access_tokens/revoke_token_family_service.rb24
-rw-r--r--app/services/post_receive_service.rb4
-rw-r--r--app/services/projects/create_service.rb8
-rw-r--r--app/services/projects/destroy_service.rb6
-rw-r--r--app/services/projects/participants_service.rb38
-rw-r--r--app/services/projects/prometheus/alerts/notify_service.rb41
-rw-r--r--app/services/projects/transfer_service.rb38
-rw-r--r--app/services/projects/update_repository_storage_service.rb70
-rw-r--r--app/services/projects/update_service.rb8
-rw-r--r--app/services/projects/update_statistics_service.rb4
-rw-r--r--app/services/prometheus/proxy_service.rb145
-rw-r--r--app/services/prometheus/proxy_variable_substitution_service.rb155
-rw-r--r--app/services/quick_actions/interpret_service.rb3
-rw-r--r--app/services/search_service.rb4
-rw-r--r--app/services/security/ci_configuration/base_create_service.rb24
-rw-r--r--app/services/spam/spam_action_service.rb13
-rw-r--r--app/services/spam/spam_verdict_service.rb6
-rw-r--r--app/services/todos/destroy/base_service.rb5
-rw-r--r--app/services/todos/destroy/group_private_service.rb5
-rw-r--r--app/services/users/email_verification/base_service.rb2
-rw-r--r--app/services/users/email_verification/update_email_service.rb76
-rw-r--r--app/services/users/refresh_authorized_projects_service.rb6
-rw-r--r--app/services/users/update_service.rb2
-rw-r--r--app/services/web_hook_service.rb10
-rw-r--r--app/services/work_items/related_work_item_links/create_service.rb65
-rw-r--r--app/validators/import/gitlab_projects/remote_file_validator.rb9
-rw-r--r--app/validators/json_schemas/application_setting_database_apdex_settings.json34
-rw-r--r--app/validators/json_schemas/application_setting_prometheus_alert_db_indicators_settings.json54
-rw-r--r--app/validators/json_schemas/build_metadata_secrets.json21
-rw-r--r--app/validators/json_schemas/catalog_resource_component_inputs.json24
-rw-r--r--app/validators/json_schemas/default_branch_protection_defaults.json12
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml11
-rw-r--r--app/views/admin/application_settings/_ai_access.html.haml31
-rw-r--r--app/views/admin/application_settings/_ci_cd.html.haml88
-rw-r--r--app/views/admin/application_settings/_diagramsnet.html.haml2
-rw-r--r--app/views/admin/application_settings/_eks.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.haml4
-rw-r--r--app/views/admin/application_settings/_floc.html.haml4
-rw-r--r--app/views/admin/application_settings/_gitlab_shell_operation_limits.html.haml4
-rw-r--r--app/views/admin/application_settings/_gitpod.html.haml7
-rw-r--r--app/views/admin/application_settings/_jira_connect.html.haml24
-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/_package_registry.html.haml91
-rw-r--r--app/views/admin/application_settings/_plantuml.html.haml2
-rw-r--r--app/views/admin/application_settings/_projects_api_limits.html.haml4
-rw-r--r--app/views/admin/application_settings/_slack.html.haml8
-rw-r--r--app/views/admin/application_settings/_snowplow.html.haml7
-rw-r--r--app/views/admin/application_settings/_sourcegraph.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/appearances/_form.html.haml267
-rw-r--r--app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml51
-rw-r--r--app/views/admin/application_settings/ci/_header.html.haml11
-rw-r--r--app/views/admin/application_settings/ci_cd.html.haml20
-rw-r--r--app/views/admin/application_settings/general.html.haml21
-rw-r--r--app/views/admin/application_settings/metrics_and_profiling.html.haml10
-rw-r--r--app/views/admin/application_settings/network.html.haml48
-rw-r--r--app/views/admin/application_settings/preferences.html.haml26
-rw-r--r--app/views/admin/application_settings/reporting.html.haml6
-rw-r--r--app/views/admin/application_settings/repository.html.haml10
-rw-r--r--app/views/admin/application_settings/service_usage_data.html.haml8
-rw-r--r--app/views/admin/applications/index.html.haml89
-rw-r--r--app/views/admin/deploy_keys/new.html.haml14
-rw-r--r--app/views/admin/health_check/show.html.haml2
-rw-r--r--app/views/admin/impersonation_tokens/index.html.haml23
-rw-r--r--app/views/admin/labels/index.html.haml38
-rw-r--r--app/views/admin/projects/show.html.haml16
-rw-r--r--app/views/admin/users/_access_levels.html.haml96
-rw-r--r--app/views/admin/users/_admin_notes.html.haml16
-rw-r--r--app/views/admin/users/_form.html.haml112
-rw-r--r--app/views/ci/variables/_header.html.haml2
-rw-r--r--app/views/ci/variables/_index.html.haml10
-rw-r--r--app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml5
-rw-r--r--app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml2
-rw-r--r--app/views/clusters/clusters/user/_form.html.haml4
-rw-r--r--app/views/dashboard/_groups_head.html.haml2
-rw-r--r--app/views/dashboard/projects/_starred_empty_state.html.haml14
-rw-r--r--app/views/dashboard/todos/_todo.html.haml1
-rw-r--r--app/views/devise/confirmations/almost_there.haml2
-rw-r--r--app/views/devise/mailer/_confirmation_instructions_account.html.haml3
-rw-r--r--app/views/devise/mailer/_confirmation_instructions_account.text.erb2
-rw-r--r--app/views/devise/mailer/email_changed_gitlab_com.html.haml11
-rw-r--r--app/views/devise/mailer/email_changed_gitlab_com.text.erb9
-rw-r--r--app/views/devise/sessions/_broadcast.html.haml1
-rw-r--r--app/views/devise/sessions/_new_base.html.haml3
-rw-r--r--app/views/devise/sessions/email_verification.haml13
-rw-r--r--app/views/devise/sessions/new.html.haml3
-rw-r--r--app/views/devise/shared/_signup_omniauth_provider_list.haml4
-rw-r--r--app/views/devise/shared/_signup_omniauth_providers.haml4
-rw-r--r--app/views/discussions/_diff_with_notes.html.haml5
-rw-r--r--app/views/doorkeeper/authorizations/new.html.haml8
-rw-r--r--app/views/groups/_home_panel.html.haml2
-rw-r--r--app/views/groups/_import_group_from_another_instance_panel.html.haml6
-rw-r--r--app/views/groups/_import_group_from_file_panel.html.haml2
-rw-r--r--app/views/groups/_new_group_fields.html.haml2
-rw-r--r--app/views/groups/edit.html.haml12
-rw-r--r--app/views/groups/packages/index.html.haml2
-rw-r--r--app/views/groups/projects.html.haml30
-rw-r--r--app/views/groups/settings/_advanced.html.haml47
-rw-r--r--app/views/groups/settings/_export.html.haml67
-rw-r--r--app/views/groups/settings/_permanent_deletion.html.haml20
-rw-r--r--app/views/groups/settings/_remove_button.html.haml2
-rw-r--r--app/views/groups/settings/_subgroup_creation_level.html.haml3
-rw-r--r--app/views/groups/settings/_transfer.html.haml39
-rw-r--r--app/views/groups/settings/access_tokens/index.html.haml41
-rw-r--r--app/views/groups/settings/ci_cd/show.html.haml6
-rw-r--r--app/views/groups/settings/integrations/index.html.haml2
-rw-r--r--app/views/groups/settings/repository/_default_branch.html.haml2
-rw-r--r--app/views/groups/work_items/index.html.haml4
-rw-r--r--app/views/help/instance_configuration/_size_limits.html.haml6
-rw-r--r--app/views/import/bulk_imports/history.html.haml2
-rw-r--r--app/views/import/fogbugz/new_user_map.html.haml3
-rw-r--r--app/views/import/gitea/new.html.haml15
-rw-r--r--app/views/import/gitea/status.html.haml4
-rw-r--r--app/views/layouts/_page.html.haml4
-rw-r--r--app/views/layouts/application.html.haml11
-rw-r--r--app/views/layouts/group.html.haml1
-rw-r--r--app/views/layouts/header/_current_user_dropdown.html.haml2
-rw-r--r--app/views/layouts/header/_default.html.haml13
-rw-r--r--app/views/layouts/header/_super_sidebar_logged_out.haml47
-rw-r--r--app/views/layouts/header/_title.html.haml8
-rw-r--r--app/views/layouts/project.html.haml1
-rw-r--r--app/views/notify/changed_reviewer_of_merge_request_email.html.haml2
-rw-r--r--app/views/notify/changed_reviewer_of_merge_request_email.text.erb1
-rw-r--r--app/views/notify/member_about_to_expire_email.html.haml6
-rw-r--r--app/views/notify/member_about_to_expire_email.text.erb5
-rw-r--r--app/views/notify/new_review_email.html.haml1
-rw-r--r--app/views/notify/new_review_email.text.erb2
-rw-r--r--app/views/notify/request_review_merge_request_email.html.haml1
-rw-r--r--app/views/notify/request_review_merge_request_email.text.erb1
-rw-r--r--app/views/profiles/_name.html.haml2
-rw-r--r--app/views/profiles/emails/index.html.haml128
-rw-r--r--app/views/profiles/gpg_keys/_form.html.haml2
-rw-r--r--app/views/profiles/gpg_keys/_key.html.haml47
-rw-r--r--app/views/profiles/gpg_keys/_key_table.html.haml15
-rw-r--r--app/views/profiles/gpg_keys/index.html.haml35
-rw-r--r--app/views/profiles/keys/_form.html.haml9
-rw-r--r--app/views/profiles/keys/_key.html.haml80
-rw-r--r--app/views/profiles/keys/_key_details.html.haml109
-rw-r--r--app/views/profiles/keys/_key_table.html.haml17
-rw-r--r--app/views/profiles/keys/index.html.haml35
-rw-r--r--app/views/profiles/personal_access_tokens/index.html.haml27
-rw-r--r--app/views/profiles/preferences/show.html.haml50
-rw-r--r--app/views/profiles/show.html.haml2
-rw-r--r--app/views/projects/_deletion_failed.html.haml4
-rw-r--r--app/views/projects/_export.html.haml59
-rw-r--r--app/views/projects/_files.html.haml6
-rw-r--r--app/views/projects/_home_panel.html.haml5
-rw-r--r--app/views/projects/_merge_request_settings_description_text.html.haml2
-rw-r--r--app/views/projects/_new_project_fields.html.haml14
-rw-r--r--app/views/projects/_remove.html.haml18
-rw-r--r--app/views/projects/_remove_fork.html.haml18
-rw-r--r--app/views/projects/_service_desk_settings.html.haml5
-rw-r--r--app/views/projects/_transfer.html.haml42
-rw-r--r--app/views/projects/_wiki.html.haml2
-rw-r--r--app/views/projects/blob/_blob.html.haml2
-rw-r--r--app/views/projects/blob/_breadcrumb.html.haml8
-rw-r--r--app/views/projects/blob/_editor.html.haml22
-rw-r--r--app/views/projects/blob/_filepath_form.html.haml1
-rw-r--r--app/views/projects/blob/_pipeline_tour_success.html.haml2
-rw-r--r--app/views/projects/blob/_template_selectors.html.haml10
-rw-r--r--app/views/projects/blob/show.html.haml4
-rw-r--r--app/views/projects/branch_defaults/_show.html.haml2
-rw-r--r--app/views/projects/branch_rules/_show.html.haml4
-rw-r--r--app/views/projects/branches/_branch.html.haml4
-rw-r--r--app/views/projects/branches/_panel.html.haml2
-rw-r--r--app/views/projects/buttons/_fork.html.haml15
-rw-r--r--app/views/projects/cleanup/_show.html.haml4
-rw-r--r--app/views/projects/commits/_commits.html.haml5
-rw-r--r--app/views/projects/commits/show.html.haml1
-rw-r--r--app/views/projects/compare/show.html.haml7
-rw-r--r--app/views/projects/confluences/show.html.haml2
-rw-r--r--app/views/projects/deploy_keys/edit.html.haml12
-rw-r--r--app/views/projects/edit.html.haml75
-rw-r--r--app/views/projects/feature_flags/index.html.haml2
-rw-r--r--app/views/projects/feature_flags_user_lists/index.html.haml2
-rw-r--r--app/views/projects/feature_flags_user_lists/show.html.haml2
-rw-r--r--app/views/projects/find_file/show.html.haml4
-rw-r--r--app/views/projects/hook_logs/show.html.haml3
-rw-r--r--app/views/projects/integrations/shimos/show.html.haml2
-rw-r--r--app/views/projects/issuable/_show.html.haml1
-rw-r--r--app/views/projects/issues/index.html.haml1
-rw-r--r--app/views/projects/issues/new.html.haml1
-rw-r--r--app/views/projects/issues/service_desk.html.haml2
-rw-r--r--app/views/projects/jobs/index.html.haml1
-rw-r--r--app/views/projects/jobs/show.html.haml1
-rw-r--r--app/views/projects/labels/index.html.haml6
-rw-r--r--app/views/projects/merge_requests/_page.html.haml2
-rw-r--r--app/views/projects/merge_requests/_widget.html.haml2
-rw-r--r--app/views/projects/merge_requests/conflicts/show.html.haml1
-rw-r--r--app/views/projects/merge_requests/creations/new.html.haml9
-rw-r--r--app/views/projects/merge_requests/diffs.html.haml2
-rw-r--r--app/views/projects/merge_requests/edit.html.haml1
-rw-r--r--app/views/projects/merge_requests/index.html.haml1
-rw-r--r--app/views/projects/merge_requests/show.html.haml2
-rw-r--r--app/views/projects/mirrors/_mirror_repos.html.haml67
-rw-r--r--app/views/projects/mirrors/_mirror_repos_list.html.haml86
-rw-r--r--app/views/projects/ml/models/index.html.haml3
-rw-r--r--app/views/projects/notes/_more_actions_dropdown.html.haml3
-rw-r--r--app/views/projects/packages/infrastructure_registry/show.html.haml2
-rw-r--r--app/views/projects/packages/packages/index.html.haml2
-rw-r--r--app/views/projects/pages/_pages_settings.html.haml20
-rw-r--r--app/views/projects/pipeline_schedules/index.html.haml1
-rw-r--r--app/views/projects/pipelines/charts.html.haml1
-rw-r--r--app/views/projects/pipelines/show.html.haml1
-rw-r--r--app/views/projects/project_templates/_template.html.haml4
-rw-r--r--app/views/projects/protected_tags/shared/_create_protected_tag.html.haml37
-rw-r--r--app/views/projects/protected_tags/shared/_dropdown.html.haml2
-rw-r--r--app/views/projects/protected_tags/shared/_index.html.haml27
-rw-r--r--app/views/projects/protected_tags/shared/_protected_tag.html.haml8
-rw-r--r--app/views/projects/protected_tags/shared/_tags_list.html.haml14
-rw-r--r--app/views/projects/runners/_group_runners.html.haml2
-rw-r--r--app/views/projects/settings/_archive.html.haml32
-rw-r--r--app/views/projects/settings/access_tokens/index.html.haml30
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml10
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml18
-rw-r--r--app/views/projects/settings/integrations/_form.html.haml4
-rw-r--r--app/views/projects/settings/integrations/index.html.haml2
-rw-r--r--app/views/projects/settings/operations/_alert_management.html.haml2
-rw-r--r--app/views/projects/settings/operations/_error_tracking.html.haml2
-rw-r--r--app/views/projects/tracing/show.html.haml5
-rw-r--r--app/views/projects/tree/_tree_header.html.haml4
-rw-r--r--app/views/projects/tree/show.html.haml5
-rw-r--r--app/views/projects/triggers/_form.html.haml4
-rw-r--r--app/views/projects/triggers/_index.html.haml43
-rw-r--r--app/views/projects/usage_quotas/index.html.haml7
-rw-r--r--app/views/protected_branches/shared/_branches_list.html.haml10
-rw-r--r--app/views/protected_branches/shared/_create_protected_branch.html.haml81
-rw-r--r--app/views/protected_branches/shared/_index.html.haml30
-rw-r--r--app/views/protected_branches/shared/_protected_branch.html.haml16
-rw-r--r--app/views/pwa/manifest.json.erb2
-rw-r--r--app/views/search/show.html.haml2
-rw-r--r--app/views/shared/_email_with_badge.html.haml7
-rw-r--r--app/views/shared/_label.html.haml4
-rw-r--r--app/views/shared/_label_row.html.haml2
-rw-r--r--app/views/shared/_no_password.html.haml4
-rw-r--r--app/views/shared/_service_ping_consent.html.haml2
-rw-r--r--app/views/shared/access_tokens/_form.html.haml14
-rw-r--r--app/views/shared/deploy_keys/_form.html.haml51
-rw-r--r--app/views/shared/deploy_keys/_index.html.haml14
-rw-r--r--app/views/shared/deploy_keys/_project_group_form.html.haml16
-rw-r--r--app/views/shared/deploy_tokens/_index.html.haml12
-rw-r--r--app/views/shared/deploy_tokens/_table.html.haml82
-rw-r--r--app/views/shared/doorkeeper/applications/_delete_form.html.haml7
-rw-r--r--app/views/shared/doorkeeper/applications/_form.html.haml5
-rw-r--r--app/views/shared/doorkeeper/applications/_index.html.haml153
-rw-r--r--app/views/shared/doorkeeper/applications/_show.html.haml2
-rw-r--r--app/views/shared/empty_states/_milestones.html.haml4
-rw-r--r--app/views/shared/empty_states/_milestones_tab.html.haml4
-rw-r--r--app/views/shared/empty_states/_profile_tabs.html.haml6
-rw-r--r--app/views/shared/empty_states/_wikis.html.haml10
-rw-r--r--app/views/shared/empty_states/_wikis_layout.html.haml2
-rw-r--r--app/views/shared/issuable/_bulk_update_sidebar.html.haml4
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml2
-rw-r--r--app/views/shared/issuable/form/_branch_chooser.html.haml18
-rw-r--r--app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml2
-rw-r--r--app/views/shared/issue_type/_details_content.html.haml1
-rw-r--r--app/views/shared/issue_type/_details_header.html.haml2
-rw-r--r--app/views/shared/nav/_your_work_scope_header.html.haml2
-rw-r--r--app/views/shared/notes/_comment_button.html.haml3
-rw-r--r--app/views/shared/notes/_hints.html.haml6
-rw-r--r--app/views/shared/packages/_no_packages.html.haml3
-rw-r--r--app/views/shared/projects/_project.html.haml3
-rw-r--r--app/views/shared/ssh_keys/_key_delete.html.haml5
-rw-r--r--app/views/shared/web_hooks/_form.html.haml2
-rw-r--r--app/views/shared/web_hooks/_hook.html.haml6
-rw-r--r--app/views/shared/web_hooks/_index.html.haml27
-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.yml74
-rw-r--r--app/workers/batched_git_ref_updates/cleanup_scheduler_worker.rb20
-rw-r--r--app/workers/batched_git_ref_updates/project_cleanup_worker.rb18
-rw-r--r--app/workers/build_success_worker.rb2
-rw-r--r--app/workers/bulk_imports/pipeline_batch_worker.rb1
-rw-r--r--app/workers/bulk_imports/pipeline_worker.rb7
-rw-r--r--app/workers/ci/initial_pipeline_process_worker.rb2
-rw-r--r--app/workers/ci/pipeline_success_unlock_artifacts_worker.rb4
-rw-r--r--app/workers/click_house/events_sync_worker.rb45
-rw-r--r--app/workers/clusters/agents/notify_git_push_worker.rb1
-rw-r--r--app/workers/concerns/gitlab/github_import/rescheduling_methods.rb5
-rw-r--r--app/workers/concerns/packages/error_handling.rb48
-rw-r--r--app/workers/concerns/worker_attributes.rb8
-rw-r--r--app/workers/environments/stop_job_success_worker.rb23
-rw-r--r--app/workers/integrations/group_mention_worker.rb20
-rw-r--r--app/workers/members/expiring_email_notification_worker.rb28
-rw-r--r--app/workers/members/expiring_worker.rb32
-rw-r--r--app/workers/merge_requests/mergeability_check_batch_worker.rb3
-rw-r--r--app/workers/packages/debian/process_package_file_worker.rb14
-rw-r--r--app/workers/packages/helm/extraction_worker.rb10
-rw-r--r--app/workers/packages/nuget/extraction_worker.rb10
-rw-r--r--app/workers/packages/rubygems/extraction_worker.rb10
-rw-r--r--app/workers/pause_control/resume_worker.rb50
-rw-r--r--app/workers/process_commit_worker.rb1
-rw-r--r--app/workers/service_desk/custom_email_verification_cleanup_worker.rb36
-rw-r--r--app/workers/users/deactivate_dormant_users_worker.rb13
-rw-r--r--app/workers/web_hook_worker.rb5
1882 files changed, 27240 insertions, 16194 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 d15c8e6e703..85b3c994e02 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
@@ -32,7 +32,6 @@ export default {
i18n: {
emptyField: __('Never'),
expired: __('Expired'),
- header: __('Active %{accessTokenTypePlural} (%{totalAccessTokens})'),
modalMessage: __(
'Are you sure you want to revoke this %{accessTokenType}? This action cannot be undone.',
),
@@ -45,7 +44,6 @@ export default {
'initialActiveAccessTokens',
'noActiveTokensMessage',
'showRole',
- 'information',
],
data() {
return {
@@ -74,12 +72,6 @@ export default {
return FIELDS.filter(({ key }) => !ignoredFields.includes(key));
},
- header() {
- return sprintf(this.$options.i18n.header, {
- accessTokenTypePlural: this.accessTokenTypePlural,
- totalAccessTokens: this.activeAccessTokens.length,
- });
- },
modalMessage() {
return sprintf(this.$options.i18n.modalMessage, {
accessTokenType: this.accessTokenType,
@@ -114,65 +106,66 @@ export default {
<template>
<dom-element-listener :selector="$options.FORM_SELECTOR" @[$options.EVENT_SUCCESS]="onSuccess">
- <div class="gl-pt-6">
- <h5>{{ header }}</h5>
-
- <p v-if="information" data-testid="information-section">
- {{ information }}
- </p>
-
- <gl-table
- data-testid="active-tokens"
- :empty-text="noActiveTokensMessage"
- :fields="filteredFields"
- :items="activeAccessTokens"
- :per-page="$options.PAGE_SIZE"
- :current-page="currentPage"
- :sort-compare="sortingChanged"
- show-empty
+ <div>
+ <div
+ class="gl-new-card-body gl-px-0 gl-overflow-hidden gl-bg-gray-10 gl-border-l gl-border-r gl-border-b gl-rounded-bottom-base gl-mb-5 gl-md-mb-0"
>
- <template #cell(createdAt)="{ item: { createdAt } }">
- <user-date :date="createdAt" />
- </template>
+ <gl-table
+ data-testid="active-tokens"
+ :empty-text="noActiveTokensMessage"
+ :fields="filteredFields"
+ :items="activeAccessTokens"
+ :per-page="$options.PAGE_SIZE"
+ :current-page="currentPage"
+ :sort-compare="sortingChanged"
+ show-empty
+ stacked="sm"
+ >
+ <template #cell(createdAt)="{ item: { createdAt } }">
+ <user-date :date="createdAt" />
+ </template>
- <template #head(lastUsedAt)="{ label }">
- <span>{{ label }}</span>
- <gl-link :href="$options.lastUsedHelpLink"
- ><gl-icon name="question-o" /><span class="gl-sr-only">{{
- s__('AccessTokens|The last time a token was used')
- }}</span></gl-link
- >
- </template>
+ <template #head(lastUsedAt)="{ label }">
+ <span>{{ label }}</span>
+ <gl-link :href="$options.lastUsedHelpLink"
+ ><gl-icon name="question-o" /><span class="gl-sr-only">{{
+ s__('AccessTokens|The last time a token was used')
+ }}</span></gl-link
+ >
+ </template>
- <template #cell(lastUsedAt)="{ item: { lastUsedAt } }">
- <time-ago-tooltip v-if="lastUsedAt" :time="lastUsedAt" />
- <template v-else> {{ $options.i18n.emptyField }}</template>
- </template>
+ <template #cell(lastUsedAt)="{ item: { lastUsedAt } }">
+ <time-ago-tooltip v-if="lastUsedAt" :time="lastUsedAt" />
+ <template v-else> {{ $options.i18n.emptyField }}</template>
+ </template>
- <template #cell(expiresAt)="{ item: { expiresAt, expired, expiresSoon } }">
- <template v-if="expiresAt">
- <span v-if="expired" class="text-danger">{{ $options.i18n.expired }}</span>
- <time-ago-tooltip v-else :class="{ 'text-warning': expiresSoon }" :time="expiresAt" />
+ <template #cell(expiresAt)="{ item: { expiresAt, expired, expiresSoon } }">
+ <template v-if="expiresAt">
+ <span v-if="expired" class="text-danger">{{ $options.i18n.expired }}</span>
+ <time-ago-tooltip v-else :class="{ 'text-warning': expiresSoon }" :time="expiresAt" />
+ </template>
+ <span v-else v-gl-tooltip :title="$options.i18n.tokenValidity">{{
+ $options.i18n.emptyField
+ }}</span>
</template>
- <span v-else v-gl-tooltip :title="$options.i18n.tokenValidity">{{
- $options.i18n.emptyField
- }}</span>
- </template>
- <template #cell(action)="{ item: { revokePath } }">
- <gl-button
- v-if="revokePath"
- category="tertiary"
- :aria-label="$options.i18n.revokeButton"
- :data-confirm="modalMessage"
- data-confirm-btn-variant="danger"
- data-qa-selector="revoke_button"
- data-method="put"
- :href="revokePath"
- icon="remove"
- />
- </template>
- </gl-table>
+ <template #cell(action)="{ item: { revokePath } }">
+ <gl-button
+ v-if="revokePath"
+ category="tertiary"
+ :title="$options.i18n.revokeButton"
+ :aria-label="$options.i18n.revokeButton"
+ :data-confirm="modalMessage"
+ data-confirm-btn-variant="danger"
+ data-qa-selector="revoke_button"
+ data-method="put"
+ :href="revokePath"
+ icon="remove"
+ class="has-tooltip"
+ />
+ </template>
+ </gl-table>
+ </div>
<gl-pagination
v-if="showPagination"
v-model="currentPage"
@@ -183,6 +176,7 @@ export default {
:label-next-page="__('Go to next page')"
:label-prev-page="__('Go to previous page')"
align="center"
+ class="gl-mt-5"
/>
</div>
</dom-element-listener>
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 02159d4d524..4b51b4333aa 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
@@ -93,6 +93,12 @@ export default {
this.form.querySelectorAll('input[type=checkbox]').forEach((el) => {
el.checked = false;
});
+ document.querySelectorAll('.js-token-card').forEach((el) => {
+ el.querySelector('.js-add-new-token-form').style.display = '';
+ el.querySelector('.js-toggle-button').style.display = 'block';
+ el.querySelector('.js-token-count').innerText =
+ parseInt(el.querySelector('.js-token-count').innerText, 10) + 1;
+ });
},
},
};
@@ -105,23 +111,35 @@ export default {
@[$options.EVENT_SUCCESS]="onSuccess"
>
<div ref="container" data-testid="access-token-section" data-qa-selector="access_token_section">
- <template v-if="newToken">
+ <gl-alert
+ v-if="newToken"
+ variant="success"
+ data-testid="success-message"
+ @dismiss="newToken = null"
+ >
<input-copy-toggle-visibility
:copy-button-title="copyButtonTitle"
:label="label"
:label-for="$options.tokenInputId"
:value="newToken"
:form-input-group-props="formInputGroupProps"
+ readonly
+ size="lg"
+ class="gl-mb-0"
>
<template #description>
{{ $options.i18n.description }}
</template>
</input-copy-toggle-visibility>
- <hr />
- </template>
+ </gl-alert>
<template v-if="errors">
- <gl-alert :title="alertDangerTitle" variant="danger" @dismiss="errors = null">
+ <gl-alert
+ :title="alertDangerTitle"
+ variant="danger"
+ data-testid="error-message"
+ @dismiss="errors = null"
+ >
<ul class="gl-m-0">
<li v-for="error in errors" :key="error">
{{ error }}
diff --git a/app/assets/javascripts/access_tokens/components/token.vue b/app/assets/javascripts/access_tokens/components/token.vue
index 23803e82476..756d761ec97 100644
--- a/app/assets/javascripts/access_tokens/components/token.vue
+++ b/app/assets/javascripts/access_tokens/components/token.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue';
@@ -20,6 +21,11 @@ export default {
type: String,
required: true,
},
+ size: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
computed: {
formInputGroupProps() {
@@ -39,6 +45,8 @@ export default {
:form-input-group-props="formInputGroupProps"
:value="token"
:copy-button-title="copyButtonTitle"
+ readonly
+ :size="size"
>
<template #description>
<slot name="input-description"></slot>
diff --git a/app/assets/javascripts/access_tokens/components/tokens_app.vue b/app/assets/javascripts/access_tokens/components/tokens_app.vue
index 88119ed8a84..af26bf85941 100644
--- a/app/assets/javascripts/access_tokens/components/tokens_app.vue
+++ b/app/assets/javascripts/access_tokens/components/tokens_app.vue
@@ -88,6 +88,7 @@ export default {
:input-label="$options.i18n[tokenType].label"
:copy-button-title="$options.i18n[tokenType].copyButtonTitle"
:data-testid="$options.htmlAttributes[tokenType].containerTestId"
+ size="md"
>
<template #title>
<div class="settings-sticky-header">
diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js
index 510f118bbb5..4e0acaa74da 100644
--- a/app/assets/javascripts/access_tokens/index.js
+++ b/app/assets/javascripts/access_tokens/index.js
@@ -20,7 +20,6 @@ export const initAccessTokenTableApp = () => {
const {
accessTokenType,
accessTokenTypePlural,
- information,
initialActiveAccessTokens: initialActiveAccessTokensJson,
noActiveTokensMessage: noTokensMessage,
} = el.dataset;
@@ -39,7 +38,6 @@ export const initAccessTokenTableApp = () => {
provide: {
accessTokenType,
accessTokenTypePlural,
- information,
initialActiveAccessTokens,
noActiveTokensMessage,
showRole,
diff --git a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue
index a9fb692b299..c1ec46cfc50 100644
--- a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue
+++ b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue
@@ -1,5 +1,6 @@
<script>
import { GlModal, GlTabs, GlTab, GlSprintf, GlBadge, GlFilteredSearch } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
import ReviewTabContainer from '~/add_context_commits_modal/components/review_tab_container.vue';
import { createAlert } from '~/alert';
diff --git a/app/assets/javascripts/add_context_commits_modal/components/token.vue b/app/assets/javascripts/add_context_commits_modal/components/token.vue
index c403adbbf60..020c121d1e4 100644
--- a/app/assets/javascripts/add_context_commits_modal/components/token.vue
+++ b/app/assets/javascripts/add_context_commits_modal/components/token.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlFilteredSearchToken } from '@gitlab/ui';
diff --git a/app/assets/javascripts/add_context_commits_modal/store/index.js b/app/assets/javascripts/add_context_commits_modal/store/index.js
index 560834a26ae..978e1b98437 100644
--- a/app/assets/javascripts/add_context_commits_modal/store/index.js
+++ b/app/assets/javascripts/add_context_commits_modal/store/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import filters from '~/vue_shared/components/filtered_search_bar/store/modules/filters';
import * as actions from './actions';
diff --git a/app/assets/javascripts/admin/abuse_report/components/report_actions.vue b/app/assets/javascripts/admin/abuse_report/components/report_actions.vue
index 57d5d46ceb4..92478e10289 100644
--- a/app/assets/javascripts/admin/abuse_report/components/report_actions.vue
+++ b/app/assets/javascripts/admin/abuse_report/components/report_actions.vue
@@ -95,10 +95,12 @@ export default {
return;
}
- axios
- .put(this.report.updatePath, this.form)
- .then(this.handleResponse)
- .catch(this.handleError);
+ // TODO: In 16.4 use moderateUserPath without falling back to using updatePath
+ // See https://gitlab.com/gitlab-org/modelops/anti-abuse/team-tasks/-/issues/167?work_item_iid=443
+ const { moderateUserPath, updatePath } = this.report;
+ const path = moderateUserPath || updatePath;
+
+ axios.put(path, this.form).then(this.handleResponse).catch(this.handleError);
},
handleResponse({ data }) {
this.toggleActionsDrawer();
diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue
index b229dd9e993..f24e491a745 100644
--- a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue
+++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue
@@ -14,6 +14,13 @@ export default {
ListItem,
AbuseCategory,
},
+ i18n: {
+ updatedAt: __('Updated %{timeAgo}'),
+ createdAt: __('Created %{timeAgo}'),
+ deletedUser: s__('AbuseReports|Deleted user'),
+ row: s__('AbuseReports|%{reportedUser} reported for %{category} by %{reporter}'),
+ rowWithCount: s__('AbuseReports|%{reportedUser} reported for %{category} by %{count} users'),
+ },
props: {
report: {
type: Object,
@@ -25,18 +32,24 @@ export default {
const { sort } = queryToObject(window.location.search);
const { createdAt, updatedAt } = this.report;
const { template, timeAgo } = Object.values(SORT_UPDATED_AT.sortDirection).includes(sort)
- ? { template: __('Updated %{timeAgo}'), timeAgo: updatedAt }
- : { template: __('Created %{timeAgo}'), timeAgo: createdAt };
+ ? { template: this.$options.i18n.updatedAt, timeAgo: updatedAt }
+ : { template: this.$options.i18n.createdAt, timeAgo: createdAt };
return sprintf(template, { timeAgo: getTimeago().format(timeAgo) });
},
title() {
- const { reportedUser, category, reporter } = this.report;
- const template = s__('AbuseReports|%{reportedUser} reported for %{category} by %{reporter}');
- return sprintf(template, {
- reportedUser: reportedUser?.name || s__('AbuseReports|Deleted user'),
- reporter: reporter?.name || s__('AbuseReports|Deleted user'),
+ const { reportedUser, category, reporter, count } = this.report;
+
+ const reportedUserName = reportedUser?.name || this.$options.i18n.deletedUser;
+ const reporterName = reporter?.name || this.$options.i18n.deletedUser;
+
+ const i18nRowCount = count > 1 ? this.$options.i18n.rowWithCount : this.$options.i18n.row;
+
+ return sprintf(i18nRowCount, {
+ reportedUser: reportedUserName,
+ reporter: reporterName,
category,
+ count,
});
},
},
@@ -55,11 +68,7 @@ export default {
</gl-link>
</template>
<template #left-secondary>
- <abuse-category
- :category="report.category"
- class="gl-mt-2 gl-mb-3"
- data-testid="abuse-report-category"
- />
+ <abuse-category :category="report.category" class="gl-mt-2 gl-mb-3" />
</template>
<template #right-secondary>
diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue
index b1eb5371a35..bab0fe6dd7d 100644
--- a/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue
+++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue
@@ -4,43 +4,52 @@ import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_ba
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import {
FILTERED_SEARCH_TOKENS,
- DEFAULT_SORT,
- SORT_OPTIONS,
- isValidSortKey,
+ DEFAULT_SORT_STATUS_OPEN,
+ DEFAULT_SORT_STATUS_CLOSED,
} from '~/admin/abuse_reports/constants';
-import { buildFilteredSearchCategoryToken, isValidStatus } from '~/admin/abuse_reports/utils';
+
+import {
+ buildFilteredSearchCategoryToken,
+ isValidStatus,
+ isOpenStatus,
+ isValidSortKey,
+ sortOptions,
+} from '~/admin/abuse_reports/utils';
export default {
name: 'AbuseReportsFilteredSearchBar',
components: { FilteredSearchBar },
- sortOptions: SORT_OPTIONS,
inject: ['categories'],
data() {
return {
initialFilterValue: [],
- initialSortBy: DEFAULT_SORT,
+ initialSortBy: DEFAULT_SORT_STATUS_OPEN,
};
},
computed: {
tokens() {
return [...FILTERED_SEARCH_TOKENS, buildFilteredSearchCategoryToken(this.categories)];
},
+ query() {
+ return queryToObject(window.location.search);
+ },
+ currentSortOptions() {
+ return sortOptions(this.query.status);
+ },
},
created() {
- const query = queryToObject(window.location.search);
+ const { query } = this;
// Backend shows open reports by default if status param is not specified.
// To match that behavior, update the current URL to include status=open
- // query when no status query is specified on load.
+ // query when no status is specified on load.
if (!isValidStatus(query.status)) {
query.status = 'open';
updateHistory({ url: setUrlParams(query), replace: true });
}
- const sort = this.currentSortKey();
- if (sort) {
- this.initialSortBy = query.sort;
- }
+ const sortKey = this.currentSortKey();
+ this.initialSortBy = sortKey;
const tokens = this.tokens
.filter((token) => query[token.type])
@@ -56,9 +65,13 @@ export default {
},
methods: {
currentSortKey() {
- const { sort } = queryToObject(window.location.search);
+ const { status, sort } = this.query;
- return isValidSortKey(sort) ? sort : undefined;
+ if (!isValidSortKey(status, sort) || !sort) {
+ return isOpenStatus(status) ? DEFAULT_SORT_STATUS_OPEN : DEFAULT_SORT_STATUS_CLOSED;
+ }
+
+ return sort;
},
handleFilter(tokens) {
let params = tokens.reduce((accumulator, token) => {
@@ -76,6 +89,7 @@ export default {
}, {});
const sort = this.currentSortKey();
+
if (sort) {
params = { ...params, sort };
}
@@ -83,7 +97,7 @@ export default {
redirectTo(setUrlParams(params, window.location.href, true)); // eslint-disable-line import/no-deprecated
},
handleSort(sort) {
- const { page, ...query } = queryToObject(window.location.search);
+ const { page, ...query } = this.query;
redirectTo(setUrlParams({ ...query, sort }, window.location.href, true)); // eslint-disable-line import/no-deprecated
},
@@ -101,7 +115,7 @@ export default {
:search-input-placeholder="__('Filter reports')"
:initial-filter-value="initialFilterValue"
:initial-sort-by="initialSortBy"
- :sort-options="$options.sortOptions"
+ :sort-options="currentSortOptions"
data-testid="abuse-reports-filtered-search-bar"
@onFilter="handleFilter"
@onSort="handleSort"
diff --git a/app/assets/javascripts/admin/abuse_reports/constants.js b/app/assets/javascripts/admin/abuse_reports/constants.js
index acb79293dfb..8b14745543e 100644
--- a/app/assets/javascripts/admin/abuse_reports/constants.js
+++ b/app/assets/javascripts/admin/abuse_reports/constants.js
@@ -7,10 +7,9 @@ import {
} from '~/vue_shared/components/filtered_search_bar/constants';
import { s__, __ } from '~/locale';
-const STATUS_OPTIONS = [
- { value: 'closed', title: __('Closed') },
- { value: 'open', title: __('Open') },
-];
+export const STATUS_OPEN = { value: 'open', title: __('Open') };
+
+const STATUS_OPTIONS = [{ value: 'closed', title: __('Closed') }, STATUS_OPEN];
export const FILTERED_SEARCH_TOKEN_USER = {
type: 'user',
@@ -39,30 +38,39 @@ export const FILTERED_SEARCH_TOKEN_STATUS = {
operators: OPERATORS_IS,
};
-export const DEFAULT_SORT = 'created_at_desc';
-export const SORT_UPDATED_AT = Object.freeze({
+export const DEFAULT_SORT_STATUS_OPEN = 'number_of_reports_desc';
+export const DEFAULT_SORT_STATUS_CLOSED = 'created_at_desc';
+
+export const SORT_UPDATED_AT = {
id: 20,
title: __('Updated date'),
sortDirection: {
descending: 'updated_at_desc',
ascending: 'updated_at_asc',
},
-});
-const SORT_CREATED_AT = Object.freeze({
+};
+
+const SORT_CREATED_AT = {
id: 10,
title: __('Created date'),
sortDirection: {
- descending: DEFAULT_SORT,
+ descending: DEFAULT_SORT_STATUS_CLOSED,
ascending: 'created_at_asc',
},
-});
+};
+
+const SORT_NUMBER_OF_REPORTS = {
+ id: 30,
+ title: __('Number of Reports'),
+ sortDirection: {
+ descending: DEFAULT_SORT_STATUS_OPEN,
+ },
+};
-export const SORT_OPTIONS = [SORT_CREATED_AT, SORT_UPDATED_AT];
+export const SORT_OPTIONS_STATUS_CLOSED = [SORT_CREATED_AT, SORT_UPDATED_AT];
-export const isValidSortKey = (key) =>
- SORT_OPTIONS.some(
- (sort) => sort.sortDirection.ascending === key || sort.sortDirection.descending === key,
- );
+// when filtered for status=open reports, add an additional sorting option -> number of reports
+export const SORT_OPTIONS_STATUS_OPEN = [SORT_NUMBER_OF_REPORTS, ...SORT_OPTIONS_STATUS_CLOSED];
export const FILTERED_SEARCH_TOKEN_CATEGORY = {
type: 'category',
@@ -74,9 +82,9 @@ export const FILTERED_SEARCH_TOKEN_CATEGORY = {
};
export const FILTERED_SEARCH_TOKENS = [
+ FILTERED_SEARCH_TOKEN_STATUS,
FILTERED_SEARCH_TOKEN_USER,
FILTERED_SEARCH_TOKEN_REPORTER,
- FILTERED_SEARCH_TOKEN_STATUS,
];
export const ABUSE_CATEGORIES = {
diff --git a/app/assets/javascripts/admin/abuse_reports/index.js b/app/assets/javascripts/admin/abuse_reports/index.js
index dbc466af2d2..e4174e6c851 100644
--- a/app/assets/javascripts/admin/abuse_reports/index.js
+++ b/app/assets/javascripts/admin/abuse_reports/index.js
@@ -19,6 +19,7 @@ export const initAbuseReportsApp = () => {
return new Vue({
el,
+ name: 'AbuseReportsAppRoot',
provide: { categories },
render: (createElement) =>
createElement(AbuseReportsApp, {
diff --git a/app/assets/javascripts/admin/abuse_reports/utils.js b/app/assets/javascripts/admin/abuse_reports/utils.js
index d30e8fb0ae5..a3d05e4dcb3 100644
--- a/app/assets/javascripts/admin/abuse_reports/utils.js
+++ b/app/assets/javascripts/admin/abuse_reports/utils.js
@@ -1,4 +1,10 @@
-import { FILTERED_SEARCH_TOKEN_CATEGORY, FILTERED_SEARCH_TOKEN_STATUS } from './constants';
+import {
+ FILTERED_SEARCH_TOKEN_CATEGORY,
+ FILTERED_SEARCH_TOKEN_STATUS,
+ STATUS_OPEN,
+ SORT_OPTIONS_STATUS_OPEN,
+ SORT_OPTIONS_STATUS_CLOSED,
+} from './constants';
export const buildFilteredSearchCategoryToken = (categories) => {
const options = categories.map((c) => ({ value: c, title: c }));
@@ -7,3 +13,13 @@ export const buildFilteredSearchCategoryToken = (categories) => {
export const isValidStatus = (status) =>
FILTERED_SEARCH_TOKEN_STATUS.options.map((o) => o.value).includes(status);
+
+export const isOpenStatus = (status) => status === STATUS_OPEN.value;
+
+export const sortOptions = (status) =>
+ isOpenStatus(status) ? SORT_OPTIONS_STATUS_OPEN : SORT_OPTIONS_STATUS_CLOSED;
+
+export const isValidSortKey = (status, key) =>
+ sortOptions(status).some(
+ (sort) => sort.sortDirection.ascending === key || sort.sortDirection.descending === key,
+ );
diff --git a/app/assets/javascripts/admin/broadcast_messages/components/base.vue b/app/assets/javascripts/admin/broadcast_messages/components/base.vue
index 667ab4c34f5..55bffe0a340 100644
--- a/app/assets/javascripts/admin/broadcast_messages/components/base.vue
+++ b/app/assets/javascripts/admin/broadcast_messages/components/base.vue
@@ -1,5 +1,5 @@
<script>
-import { GlPagination } from '@gitlab/ui';
+import { GlButton, GlCard, GlIcon, GlPagination } from '@gitlab/ui';
import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
import { createAlert, VARIANT_DANGER } from '~/alert';
@@ -15,6 +15,9 @@ export default {
name: 'BroadcastMessagesBase',
NEW_BROADCAST_MESSAGE,
components: {
+ GlButton,
+ GlCard,
+ GlIcon,
GlPagination,
MessageForm,
MessagesTable,
@@ -36,6 +39,10 @@ export default {
},
i18n: {
+ title: s__('BroadcastMessages|Messages'),
+ addTitle: s__('BroadcastMessages|Add new message'),
+ emptyMessage: s__('BroadcastMessages|No broadcast messages defined yet.'),
+ addButton: s__('BroadcastMessages|Add new message'),
deleteError: s__(
'BroadcastMessages|There was an issue deleting this message, please try again later.',
),
@@ -49,6 +56,7 @@ export default {
...message,
disable_delete: false,
})),
+ showAddForm: false,
};
},
@@ -75,7 +83,12 @@ export default {
buildPageUrl(newPage) {
return buildUrlWithCurrentLocation(`?page=${newPage}`);
},
-
+ toggleAddForm() {
+ this.showAddForm = !this.showAddForm;
+ },
+ closeAddForm() {
+ this.showAddForm = false;
+ },
async deleteMessage(messageId) {
const index = this.visibleMessages.findIndex((m) => m.id === messageId);
if (!index === -1) return;
@@ -101,17 +114,48 @@ export default {
<template>
<div>
- <message-form :broadcast-message="$options.NEW_BROADCAST_MESSAGE" />
- <messages-table
- v-if="hasVisibleMessages"
- :messages="visibleMessages"
- @delete-message="deleteMessage"
- />
+ <gl-card
+ class="gl-new-card"
+ header-class="gl-new-card-header"
+ body-class="gl-new-card-body gl-overflow-hidden gl-px-0"
+ >
+ <template #header>
+ <div class="gl-new-card-title-wrapper">
+ <h3 class="gl-new-card-title">{{ $options.i18n.title }}</h3>
+ <div class="gl-new-card-count">
+ <gl-icon name="messages" class="gl-mr-2" />
+ {{ messagesCount }}
+ </div>
+ </div>
+ <gl-button v-if="!showAddForm" size="small" @click="toggleAddForm">{{
+ $options.i18n.addButton
+ }}</gl-button>
+ </template>
+
+ <div v-if="showAddForm" class="gl-new-card-add-form gl-m-3">
+ <h4 class="gl-mt-0">{{ $options.i18n.addTitle }}</h4>
+ <message-form
+ :broadcast-message="$options.NEW_BROADCAST_MESSAGE"
+ @close-add-form="closeAddForm"
+ />
+ </div>
+
+ <messages-table
+ v-if="hasVisibleMessages"
+ :messages="visibleMessages"
+ @delete-message="deleteMessage"
+ />
+ <div v-else-if="!showAddForm" class="gl-new-card-empty gl-px-5 gl-py-4">
+ {{ $options.i18n.emptyMessage }}
+ </div>
+ </gl-card>
+
<gl-pagination
v-model="currentPage"
:total-items="totalMessages"
:link-gen="buildPageUrl"
align="center"
+ class="gl-mt-5"
/>
</div>
</template>
diff --git a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue
index 42a959e1b89..109df943c42 100644
--- a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue
+++ b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue
@@ -69,8 +69,13 @@ export default {
dismissableDescription: s__('BroadcastMessages|Allow users to dismiss the broadcast message'),
target: s__('BroadcastMessages|Target broadcast message'),
targetRoles: s__('BroadcastMessages|Target roles'),
+ targetRolesRequired: s__('BroadcastMessages|Select at least one role.'),
+ targetRolesValidationMsg: s__('BroadcastMessages|One or more roles is required.'),
targetPath: s__('BroadcastMessages|Target Path'),
- targetPathDescription: s__('BroadcastMessages|Paths can contain wildcards, like */welcome'),
+ targetPathDescription: s__('BroadcastMessages|Paths can contain wildcards, like */welcome.'),
+ targetPathWithRolesReminder: s__(
+ 'BroadcastMessages|Leave blank to target all group and project pages.',
+ ),
startsAt: s__('BroadcastMessages|Starts at'),
endsAt: s__('BroadcastMessages|Ends at'),
add: s__('BroadcastMessages|Add broadcast message'),
@@ -110,6 +115,7 @@ export default {
endsAt: new Date(this.broadcastMessage.endsAt.getTime()),
renderedMessage: '',
showInCli: this.broadcastMessage.showInCli,
+ isValidated: false,
};
},
computed: {
@@ -138,6 +144,18 @@ export default {
this.targetSelected === TARGET_ROLES || this.targetSelected === TARGET_ALL_MATCHING_PATH
);
},
+ targetPathDescription() {
+ const defaultDescription = this.$options.i18n.targetPathDescription;
+
+ if (this.showTargetRoles) {
+ return `${defaultDescription} ${this.$options.i18n.targetPathWithRolesReminder}`;
+ }
+
+ return defaultDescription;
+ },
+ targetRolesValid() {
+ return !this.showTargetRoles || this.targetAccessLevels.length > 0;
+ },
formPayload() {
return JSON.stringify({
message: this.message,
@@ -172,8 +190,17 @@ export default {
this.targetSelected = this.initialTarget();
},
methods: {
+ closeForm() {
+ this.$emit('close-add-form');
+ },
async onSubmit() {
this.loading = true;
+ this.isValidated = true;
+
+ if (!this.targetRolesValid) {
+ this.loading = false;
+ return;
+ }
const success = await this.submitForm();
if (success) {
@@ -182,7 +209,6 @@ export default {
this.loading = false;
}
},
-
async submitForm() {
const requestMethod = this.isAddForm ? 'post' : 'patch';
@@ -197,7 +223,6 @@ export default {
}
return true;
},
-
async renderPreview() {
try {
const res = await axios.post(this.previewPath, this.formPayload, FORM_HEADERS);
@@ -206,7 +231,6 @@ export default {
this.renderedMessage = '';
}
},
-
initialTarget() {
if (this.targetAccessLevels.length > 0) {
return TARGET_ROLES;
@@ -238,6 +262,7 @@ export default {
id="message-textarea"
v-model="message"
size="sm"
+ autofocus
:debounce="$options.DEFAULT_DEBOUNCE_AND_THROTTLE_MS"
:placeholder="$options.i18n.messagePlaceholder"
data-testid="message-input"
@@ -293,6 +318,9 @@ export default {
<gl-form-group
v-show="showTargetRoles"
:label="$options.i18n.targetRoles"
+ :label-description="$options.i18n.targetRolesRequired"
+ :invalid-feedback="$options.i18n.targetRolesValidationMsg"
+ :state="!isValidated || targetRolesValid"
data-testid="target-roles-checkboxes"
>
<gl-form-checkbox-group v-model="targetAccessLevels" :options="targetAccessLevelOptions" />
@@ -306,7 +334,7 @@ export default {
>
<gl-form-input id="target-path-input" v-model="targetPath" />
<gl-form-text>
- {{ $options.i18n.targetPathDescription }}
+ {{ targetPathDescription }}
</gl-form-text>
</gl-form-group>
@@ -325,11 +353,14 @@ export default {
:loading="loading"
:disabled="messageBlank"
data-testid="submit-button"
- class="gl-mr-2"
+ class="js-no-auto-disable gl-mr-2"
>
{{ isAddForm ? $options.i18n.add : $options.i18n.update }}
</gl-button>
- <gl-button v-if="!isAddForm" :href="messagesPath" data-testid="cancel-button">
+ <gl-button v-if="isAddForm" @click="closeForm">
+ {{ $options.i18n.cancel }}
+ </gl-button>
+ <gl-button v-else :href="messagesPath" data-testid="cancel-button">
{{ $options.i18n.cancel }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue b/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue
index c95d4c96ea9..924b6e7451b 100644
--- a/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue
+++ b/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue
@@ -1,7 +1,7 @@
<script>
-import { GlBroadcastMessage, GlButton, GlTableLite } from '@gitlab/ui';
+import { GlBroadcastMessage, GlButton, GlTableLite, GlModal, GlModalDirective } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import { formatDate } from '~/lib/utils/datetime/date_format_utility';
const DEFAULT_TD_CLASSES = 'gl-vertical-align-middle!';
@@ -12,13 +12,31 @@ export default {
GlBroadcastMessage,
GlButton,
GlTableLite,
+ GlModal,
},
directives: {
SafeHtml,
+ GlModal: GlModalDirective,
},
i18n: {
+ title: s__('BroadcastMessages|Delete broadcast message'),
edit: __('Edit'),
delete: __('Delete'),
+ modalMessage: s__('BroadcastMessages|Do you really want to delete this broadcast message?'),
+ },
+ modal: {
+ actionPrimary: {
+ text: s__('BroadcastMessages|Delete message'),
+ attributes: {
+ variant: 'danger',
+ },
+ },
+ actionSecondary: {
+ text: __('Cancel'),
+ attributes: {
+ variant: 'default',
+ },
+ },
},
props: {
messages: {
@@ -81,6 +99,7 @@ export default {
:items="messages"
:fields="$options.fields"
:tbody-tr-attr="{ 'data-testid': 'message-row' }"
+ class="gl-mt-n1 gl-mb-n2"
stacked="md"
>
<template #cell(preview)="{ item: { message, theme, broadcast_type, dismissable } }">
@@ -104,17 +123,25 @@ export default {
:href="edit_path"
data-testid="edit-message"
/>
-
<gl-button
+ v-gl-modal="`delete-message-${id}`"
class="gl-ml-3"
icon="remove"
- variant="danger"
:aria-label="$options.i18n.delete"
rel="nofollow"
:disabled="disable_delete"
:data-testid="`delete-message-${id}`"
- @click="$emit('delete-message', id)"
/>
+ <gl-modal
+ :title="$options.i18n.title"
+ :action-primary="$options.modal.actionPrimary"
+ :action-secondary="$options.modal.actionSecondary"
+ :modal-id="`delete-message-${id}`"
+ size="sm"
+ @primary="$emit('delete-message', id)"
+ >
+ {{ $options.i18n.modalMessage }}
+ </gl-modal>
</template>
</gl-table-lite>
</template>
diff --git a/app/assets/javascripts/admin/deploy_keys/components/table.vue b/app/assets/javascripts/admin/deploy_keys/components/table.vue
index 134498af348..6610a0caec5 100644
--- a/app/assets/javascripts/admin/deploy_keys/components/table.vue
+++ b/app/assets/javascripts/admin/deploy_keys/components/table.vue
@@ -1,5 +1,14 @@
<script>
-import { GlTable, GlButton, GlPagination, GlLoadingIcon, GlEmptyState, GlModal } from '@gitlab/ui';
+import {
+ GlCard,
+ GlTable,
+ GlButton,
+ GlPagination,
+ GlIcon,
+ GlLoadingIcon,
+ GlEmptyState,
+ GlModal,
+} from '@gitlab/ui';
import { __ } from '~/locale';
import Api, { DEFAULT_PER_PAGE } from '~/api';
@@ -15,7 +24,7 @@ export default {
newDeployKeyButtonText: __('New deploy key'),
emptyStateTitle: __('No public deploy keys'),
emptyStateDescription: __(
- 'Deploy keys grant read/write access to all repositories in your instance',
+ 'Deploy keys grant read/write access to all repositories in your instance, start by creating a new one above.',
),
delete: __('Delete deploy key'),
edit: __('Edit deploy key'),
@@ -37,10 +46,12 @@ export default {
{
key: 'fingerprint_sha256',
label: __('Fingerprint (SHA256)'),
+ tdClass: 'gl-md-max-w-26',
},
{
key: 'fingerprint',
label: __('Fingerprint (MD5)'),
+ tdClass: 'gl-md-max-w-26',
},
{
key: 'projects',
@@ -75,10 +86,12 @@ export default {
csrf,
DEFAULT_PER_PAGE,
components: {
+ GlCard,
GlTable,
GlButton,
GlPagination,
TimeAgoTooltip,
+ GlIcon,
GlLoadingIcon,
GlEmptyState,
GlModal,
@@ -177,85 +190,106 @@ export default {
</script>
<template>
- <div>
- <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-py-5">
- <h4 class="gl-m-0">
- {{ $options.i18n.pageTitle }}
- </h4>
- <gl-button variant="confirm" :href="createPath" data-testid="new-deploy-key-button">{{
- $options.i18n.newDeployKeyButtonText
- }}</gl-button>
- </div>
- <template v-if="shouldShowTable">
- <gl-table
- :busy="loading"
- :items="items"
- :fields="$options.fields"
- stacked="lg"
- data-testid="deploy-keys-list"
- >
- <template #table-busy>
- <gl-loading-icon size="lg" class="gl-my-5" />
- </template>
+ <gl-card
+ class="gl-new-card gl-overflow-hidden"
+ header-class="gl-new-card-header"
+ body-class="gl-new-card-body gl-overflow-hidden gl-px-0"
+ >
+ <template #header>
+ <div class="gl-new-card-title-wrapper">
+ <h3 class="gl-new-card-title">{{ $options.i18n.pageTitle }}</h3>
+ <span class="gl-new-card-count">
+ <gl-icon name="key" class="gl-mr-2" />
+ {{ totalItems }}
+ </span>
+ </div>
+ <div class="gl-new-card-actions">
+ <gl-button size="small" :href="createPath" data-testid="new-deploy-key-button">{{
+ $options.i18n.newDeployKeyButtonText
+ }}</gl-button>
+ </div>
+ </template>
- <template #cell(projects)="{ item: { projects } }">
- <a
- v-for="project in projects"
- :key="project.id"
- :href="projectHref(project)"
- class="gl-display-block"
- >{{ project.name_with_namespace }}</a
- >
- </template>
+ <gl-table
+ v-if="shouldShowTable"
+ :busy="loading"
+ :items="items"
+ :fields="$options.fields"
+ stacked="md"
+ data-testid="deploy-keys-list"
+ class="gl-mt-n1 gl-mb-n2"
+ >
+ <template #table-busy>
+ <gl-loading-icon size="sm" class="gl-my-5" />
+ </template>
- <template #cell(fingerprint_sha256)="{ item: { fingerprint_sha256 } }">
- <span v-if="fingerprint_sha256" class="monospace">{{ fingerprint_sha256 }}</span>
- </template>
+ <template #cell(projects)="{ item: { projects } }">
+ <a
+ v-for="project in projects"
+ :key="project.id"
+ :href="projectHref(project)"
+ class="gl-display-block"
+ >{{ project.name_with_namespace }}</a
+ >
+ </template>
+ <template #cell(fingerprint_sha256)="{ item: { fingerprint_sha256 } }">
+ <div
+ v-if="fingerprint_sha256"
+ class="gl-font-monospace gl-text-truncate"
+ :title="fingerprint_sha256"
+ >
+ {{ fingerprint_sha256 }}
+ </div>
+ </template>
- <template #cell(fingerprint)="{ item: { fingerprint } }">
- <span v-if="fingerprint" class="monospace">{{ fingerprint }}</span>
- </template>
+ <template #cell(fingerprint)="{ item: { fingerprint } }">
+ <div v-if="fingerprint" class="gl-font-monospace gl-text-truncate" :title="fingerprint">
+ {{ fingerprint }}
+ </div>
+ </template>
- <template #cell(created)="{ item: { created } }">
- <time-ago-tooltip :time="created" />
- </template>
+ <template #cell(created)="{ item: { created } }">
+ <time-ago-tooltip :time="created" />
+ </template>
- <template #head(actions)="{ label }">
- <span class="gl-sr-only">{{ label }}</span>
- </template>
+ <template #head(actions)="{ label }">
+ <span class="gl-sr-only">{{ label }}</span>
+ </template>
- <template #cell(actions)="{ item: { id } }">
- <gl-button
- icon="pencil"
- :aria-label="$options.i18n.edit"
- :href="editHref(id)"
- class="gl-mr-2"
- />
- <gl-button
- variant="danger"
- icon="remove"
- :aria-label="$options.i18n.delete"
- @click="handleDeleteClick(id)"
- />
- </template>
- </gl-table>
- <gl-pagination
- v-if="!loading"
- v-model="page"
- :per-page="$options.DEFAULT_PER_PAGE"
- :total-items="totalItems"
- :next-text="$options.i18n.pagination.next"
- :prev-text="$options.i18n.pagination.prev"
- align="center"
- />
- </template>
+ <template #cell(actions)="{ item: { id } }">
+ <gl-button
+ icon="pencil"
+ size="small"
+ :aria-label="$options.i18n.edit"
+ :href="editHref(id)"
+ class="gl-mr-2"
+ />
+ <gl-button
+ variant="danger"
+ category="secondary"
+ icon="remove"
+ size="small"
+ :aria-label="$options.i18n.delete"
+ @click="handleDeleteClick(id)"
+ />
+ </template>
+ </gl-table>
<gl-empty-state
v-else
:svg-path="emptyStateSvgPath"
+ :svg-height="150"
:title="$options.i18n.emptyStateTitle"
:description="$options.i18n.emptyStateDescription"
- :primary-button-text="$options.i18n.newDeployKeyButtonText"
- :primary-button-link="createPath"
+ />
+ <gl-pagination
+ v-if="!loading"
+ v-model="page"
+ :per-page="$options.DEFAULT_PER_PAGE"
+ :total-items="totalItems"
+ :next-text="$options.i18n.pagination.next"
+ :prev-text="$options.i18n.pagination.prev"
+ align="center"
+ class="gl-mt-5"
/>
<gl-modal
:modal-id="$options.modal.id"
@@ -273,5 +307,5 @@ export default {
</form>
{{ $options.i18n.modal.body }}
</gl-modal>
- </div>
+ </gl-card>
</template>
diff --git a/app/assets/javascripts/admin/statistics_panel/components/app.vue b/app/assets/javascripts/admin/statistics_panel/components/app.vue
index 347d5f0229c..87325b07144 100644
--- a/app/assets/javascripts/admin/statistics_panel/components/app.vue
+++ b/app/assets/javascripts/admin/statistics_panel/components/app.vue
@@ -1,5 +1,6 @@
<script>
import { GlCard, GlLoadingIcon } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters, mapActions } from 'vuex';
import statisticsLabels from '../constants';
diff --git a/app/assets/javascripts/admin/statistics_panel/store/index.js b/app/assets/javascripts/admin/statistics_panel/store/index.js
index ece9e6419dd..67d2f83c788 100644
--- a/app/assets/javascripts/admin/statistics_panel/store/index.js
+++ b/app/assets/javascripts/admin/statistics_panel/store/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
diff --git a/app/assets/javascripts/admin/topics/index.js b/app/assets/javascripts/admin/topics/index.js
index d81690e8f4c..1125c1e35ee 100644
--- a/app/assets/javascripts/admin/topics/index.js
+++ b/app/assets/javascripts/admin/topics/index.js
@@ -1,13 +1,9 @@
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';
-const toasts = document.querySelectorAll('.js-toast-message');
-toasts.forEach((toast) => showToast(toast.dataset.message));
-
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
diff --git a/app/assets/javascripts/admin/users/components/actions/activate.vue b/app/assets/javascripts/admin/users/components/actions/activate.vue
index af09c7618e2..f62c41e32d6 100644
--- a/app/assets/javascripts/admin/users/components/actions/activate.vue
+++ b/app/assets/javascripts/admin/users/components/actions/activate.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
diff --git a/app/assets/javascripts/admin/users/components/actions/approve.vue b/app/assets/javascripts/admin/users/components/actions/approve.vue
index 2060528c7a0..5b13bd177ae 100644
--- a/app/assets/javascripts/admin/users/components/actions/approve.vue
+++ b/app/assets/javascripts/admin/users/components/actions/approve.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
diff --git a/app/assets/javascripts/admin/users/components/actions/ban.vue b/app/assets/javascripts/admin/users/components/actions/ban.vue
index 36dcde619cf..966a4c4b291 100644
--- a/app/assets/javascripts/admin/users/components/actions/ban.vue
+++ b/app/assets/javascripts/admin/users/components/actions/ban.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
@@ -19,7 +20,7 @@ const messageHtml = `
<p>${sprintf(
s__('AdminUsers|Learn more about %{link_start}banned users.%{link_end}'),
{
- link_start: `<a href="${helpPagePath('user/admin_area/moderate_users', {
+ link_start: `<a href="${helpPagePath('administration/moderate_users', {
anchor: 'ban-a-user',
})}" target="_blank">`,
link_end: '</a>',
diff --git a/app/assets/javascripts/admin/users/components/actions/block.vue b/app/assets/javascripts/admin/users/components/actions/block.vue
index 534e1c76b8f..f288fb22d80 100644
--- a/app/assets/javascripts/admin/users/components/actions/block.vue
+++ b/app/assets/javascripts/admin/users/components/actions/block.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
diff --git a/app/assets/javascripts/admin/users/components/actions/deactivate.vue b/app/assets/javascripts/admin/users/components/actions/deactivate.vue
index 40911131d6d..dfbee2ab4db 100644
--- a/app/assets/javascripts/admin/users/components/actions/deactivate.vue
+++ b/app/assets/javascripts/admin/users/components/actions/deactivate.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
diff --git a/app/assets/javascripts/admin/users/components/actions/delete.vue b/app/assets/javascripts/admin/users/components/actions/delete.vue
index 83aa78c9f03..455f35aa8c1 100644
--- a/app/assets/javascripts/admin/users/components/actions/delete.vue
+++ b/app/assets/javascripts/admin/users/components/actions/delete.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale';
diff --git a/app/assets/javascripts/admin/users/components/actions/reject.vue b/app/assets/javascripts/admin/users/components/actions/reject.vue
index 7f786991709..454690af63a 100644
--- a/app/assets/javascripts/admin/users/components/actions/reject.vue
+++ b/app/assets/javascripts/admin/users/components/actions/reject.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
diff --git a/app/assets/javascripts/admin/users/components/actions/unban.vue b/app/assets/javascripts/admin/users/components/actions/unban.vue
index f84c7594f87..ca81f6400b7 100644
--- a/app/assets/javascripts/admin/users/components/actions/unban.vue
+++ b/app/assets/javascripts/admin/users/components/actions/unban.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
diff --git a/app/assets/javascripts/admin/users/components/actions/unblock.vue b/app/assets/javascripts/admin/users/components/actions/unblock.vue
index 064f05ef8b1..c92728711bc 100644
--- a/app/assets/javascripts/admin/users/components/actions/unblock.vue
+++ b/app/assets/javascripts/admin/users/components/actions/unblock.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
diff --git a/app/assets/javascripts/admin/users/components/actions/unlock.vue b/app/assets/javascripts/admin/users/components/actions/unlock.vue
index 039ab3d651e..f19da56fdcd 100644
--- a/app/assets/javascripts/admin/users/components/actions/unlock.vue
+++ b/app/assets/javascripts/admin/users/components/actions/unlock.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
index 3860831169e..38b861a430a 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
@@ -36,9 +36,6 @@ export const i18n = {
},
};
-const bodyTrClass =
- 'gl-border-1 gl-border-t-solid gl-border-b-solid gl-border-gray-100 gl-hover-cursor-pointer gl-hover-bg-blue-50 gl-hover-border-blue-200';
-
export default {
i18n,
typeSet,
@@ -85,20 +82,23 @@ export default {
{
key: 'active',
label: __('Status'),
+ tdClass: 'gl-vertical-align-middle!',
},
{
key: 'name',
label: s__('AlertsIntegrations|Integration Name'),
+ tdClass: 'gl-vertical-align-middle!',
},
{
key: 'type',
label: __('Type'),
+ tdClass: 'gl-vertical-align-middle!',
formatter: (value) => (value === typeSet.prometheus ? capitalize(value) : value),
},
{
key: 'actions',
- thClass: `gl-text-center`,
- tdClass: `gl-text-center`,
+ thClass: 'gl-text-right',
+ tdClass: 'gl-text-right gl-vertical-align-middle!',
label: __('Actions'),
},
],
@@ -127,12 +127,6 @@ export default {
this.observer.observe(this.$el);
},
methods: {
- tbodyTrClass(item) {
- return {
- [bodyTrClass]: this.integrations?.length,
- 'gl-bg-blue-50': (item !== null && item.id) === this.currentIntegration?.id,
- };
- },
trackPageViews() {
const { category, action } = trackAlertIntegrationsViewsOptions;
Tracking.event(category, action);
@@ -160,7 +154,6 @@ export default {
:fields="$options.fields"
:busy="loading"
stacked="md"
- :tbody-tr-class="tbodyTrClass"
show-empty
>
<template #cell(active)="{ item }">
@@ -187,7 +180,7 @@ export default {
</template>
<template #cell(actions)="{ item }">
- <gl-button-group class="gl-ml-3">
+ <gl-button-group class="gl-ml-3 gl-mt-n2 gl-mb-n2">
<gl-button
icon="settings"
:aria-label="$options.i18n.editIntegration"
@@ -204,17 +197,14 @@ export default {
</template>
<template #table-busy>
- <gl-loading-icon size="lg" color="dark" class="mt-3" />
+ <gl-loading-icon size="sm" />
</template>
<template #empty>
- <div
- class="gl-border-t-solid gl-border-b-solid gl-border-1 gl-border gl-border-gray-100 mt-n3 gl-px-5"
- >
- <p class="gl-text-gray-400 gl-py-3 gl-my-3">{{ $options.i18n.emptyState }}</p>
- </div>
+ <p class="gl-new-card-empty gl-text-center gl-mb-0">{{ $options.i18n.emptyState }}</p>
</template>
</gl-table>
+
<gl-modal
modal-id="deleteIntegration"
:title="$options.i18n.deleteIntegration"
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
index 033f48827f1..56740e436ca 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
@@ -387,307 +387,309 @@ export default {
</script>
<template>
- <gl-form class="gl-mt-6" @submit.prevent="submit" @reset.prevent="reset">
- <gl-tabs v-model="activeTabIndex">
- <gl-tab :title="$options.i18n.integrationTabs.configureDetails" class="gl-mt-3">
- <gl-form-group
- v-if="isCreating"
- id="integration-type"
- :label="
- getLabelWithStepNumber(
- $options.integrationSteps.selectType,
- $options.i18n.integrationFormSteps.selectType.label,
- )
- "
- label-for="integration-type"
- >
- <gl-form-select
- v-model="integrationForm.type"
- :disabled="isSelectDisabled"
- class="gl-max-w-full"
- data-qa-selector="integration_type_dropdown"
- :options="integrationTypesOptions"
- />
-
- <alert-settings-form-help-block
- v-if="!canAddIntegration"
- disabled="true"
- class="gl-display-inline-block gl-my-4"
- :message="$options.i18n.integrationFormSteps.selectType.enterprise"
- :link="pricingLink"
- data-testid="multi-integrations-not-supported"
- />
- </gl-form-group>
- <div class="gl-mt-3">
+ <div class="gl-new-card-add-form gl-py-0 gl-m-3">
+ <gl-form @submit.prevent="submit" @reset.prevent="reset">
+ <gl-tabs v-model="activeTabIndex">
+ <gl-tab :title="$options.i18n.integrationTabs.configureDetails" class="gl-mt-3">
<gl-form-group
- v-if="isHttp"
+ v-if="isCreating"
+ id="integration-type"
:label="
getLabelWithStepNumber(
- $options.integrationSteps.nameIntegration,
- $options.i18n.integrationFormSteps.nameIntegration.label,
+ $options.integrationSteps.selectType,
+ $options.i18n.integrationFormSteps.selectType.label,
)
"
- label-for="name-integration"
- :invalid-feedback="$options.i18n.integrationFormSteps.nameIntegration.error"
- :state="validationState.name"
+ label-for="integration-type"
>
- <gl-form-input
- id="name-integration"
- ref="integrationName"
- v-model="integrationForm.name"
- type="text"
- :placeholder="$options.i18n.integrationFormSteps.nameIntegration.placeholder"
- data-qa-selector="integration_name_field"
- @input="validateName"
+ <gl-form-select
+ v-model="integrationForm.type"
+ :disabled="isSelectDisabled"
+ class="gl-max-w-full"
+ data-qa-selector="integration_type_dropdown"
+ :options="integrationTypesOptions"
+ autofocus
/>
- </gl-form-group>
- <gl-form-group
- v-if="!isNone"
- :label="
- getLabelWithStepNumber(
- isHttp
- ? $options.integrationSteps.enableHttpIntegration
- : $options.integrationSteps.enablePrometheusIntegration,
- $options.i18n.integrationFormSteps.enableIntegration.label,
- )
- "
- >
- <span>{{ $options.i18n.integrationFormSteps.enableIntegration.help }}</span>
-
- <gl-toggle
- id="enable-integration"
- v-model="integrationForm.active"
- :is-loading="loading"
- :label="$options.i18n.integrationFormSteps.nameIntegration.activeToggle"
- data-qa-selector="active_toggle_container"
- class="gl-mt-4 gl-font-weight-normal"
+ <alert-settings-form-help-block
+ v-if="!canAddIntegration"
+ disabled="true"
+ class="gl-display-inline-block gl-my-4"
+ :message="$options.i18n.integrationFormSteps.selectType.enterprise"
+ :link="pricingLink"
+ data-testid="multi-integrations-not-supported"
/>
</gl-form-group>
- <template v-if="showMappingBuilder">
+ <div class="gl-mt-3">
<gl-form-group
- data-testid="sample-payload-section"
+ v-if="isHttp"
:label="
getLabelWithStepNumber(
- $options.integrationSteps.customizeMapping,
- $options.i18n.integrationFormSteps.mapFields.label,
+ $options.integrationSteps.nameIntegration,
+ $options.i18n.integrationFormSteps.nameIntegration.label,
)
"
- label-for="sample-payload"
- class="gl-mb-0!"
- :invalid-feedback="samplePayload.error"
+ label-for="name-integration"
+ :invalid-feedback="$options.i18n.integrationFormSteps.nameIntegration.error"
+ :state="validationState.name"
>
- <span>{{ $options.i18n.integrationFormSteps.mapFields.help }}</span>
-
- <gl-form-textarea
- id="sample-payload"
- v-model="samplePayload.json"
- :disabled="canEditPayload"
- :state="isSampePayloadValid"
- :placeholder="$options.i18n.integrationFormSteps.mapFields.placeholder"
- class="gl-my-3"
- :debounce="$options.JSON_VALIDATE_DELAY"
- rows="6"
- max-rows="10"
- @input="validateJson"
+ <gl-form-input
+ id="name-integration"
+ ref="integrationName"
+ v-model="integrationForm.name"
+ type="text"
+ :placeholder="$options.i18n.integrationFormSteps.nameIntegration.placeholder"
+ data-qa-selector="integration_name_field"
+ @input="validateName"
/>
</gl-form-group>
+ <gl-form-group
+ v-if="!isNone"
+ :label="
+ getLabelWithStepNumber(
+ isHttp
+ ? $options.integrationSteps.enableHttpIntegration
+ : $options.integrationSteps.enablePrometheusIntegration,
+ $options.i18n.integrationFormSteps.enableIntegration.label,
+ )
+ "
+ >
+ <span>{{ $options.i18n.integrationFormSteps.enableIntegration.help }}</span>
+
+ <gl-toggle
+ id="enable-integration"
+ v-model="integrationForm.active"
+ :is-loading="loading"
+ :label="$options.i18n.integrationFormSteps.nameIntegration.activeToggle"
+ data-qa-selector="active_toggle_container"
+ class="gl-mt-4 gl-font-weight-normal"
+ />
+ </gl-form-group>
+ <template v-if="showMappingBuilder">
+ <gl-form-group
+ data-testid="sample-payload-section"
+ :label="
+ getLabelWithStepNumber(
+ $options.integrationSteps.customizeMapping,
+ $options.i18n.integrationFormSteps.mapFields.label,
+ )
+ "
+ label-for="sample-payload"
+ class="gl-mb-0!"
+ :invalid-feedback="samplePayload.error"
+ >
+ <span>{{ $options.i18n.integrationFormSteps.mapFields.help }}</span>
+
+ <gl-form-textarea
+ id="sample-payload"
+ v-model="samplePayload.json"
+ :disabled="canEditPayload"
+ :state="isSampePayloadValid"
+ :placeholder="$options.i18n.integrationFormSteps.mapFields.placeholder"
+ class="gl-my-3"
+ :debounce="$options.JSON_VALIDATE_DELAY"
+ rows="6"
+ max-rows="10"
+ @input="validateJson"
+ />
+ </gl-form-group>
+
+ <gl-button
+ v-if="canEditPayload"
+ v-gl-modal.resetPayloadModal
+ data-testid="payload-action-btn"
+ :disabled="!integrationForm.active"
+ class="gl-mt-3"
+ >
+ {{ $options.i18n.integrationFormSteps.mapFields.editPayload }}
+ </gl-button>
+
+ <gl-button
+ v-else
+ data-testid="payload-action-btn"
+ :class="{ 'gl-mt-3': samplePayload.error }"
+ :disabled="!canParseSamplePayload"
+ :loading="samplePayload.loading"
+ @click="parseSamplePayload"
+ >
+ {{ $options.i18n.integrationFormSteps.mapFields.parsePayload }}
+ </gl-button>
+ <gl-modal
+ modal-id="resetPayloadModal"
+ :title="$options.i18n.integrationFormSteps.mapFields.resetHeader"
+ :ok-title="$options.i18n.integrationFormSteps.mapFields.resetOk"
+ ok-variant="danger"
+ @ok="resetPayloadAndMappingConfirmed = true"
+ >
+ {{ $options.i18n.integrationFormSteps.mapFields.resetBody }}
+ </gl-modal>
+
+ <div class="gl-mt-5">
+ <span>{{ $options.i18n.integrationFormSteps.mapFields.mapIntro }}</span>
+ <mapping-builder
+ :parsed-payload="parsedPayload"
+ :saved-mapping="mapping"
+ :alert-fields="alertFields"
+ @onMappingUpdate="updateMapping"
+ />
+ </div>
+ </template>
+ </div>
+ <div class="gl-display-flex gl-justify-content-start gl-py-3">
<gl-button
- v-if="canEditPayload"
- v-gl-modal.resetPayloadModal
- data-testid="payload-action-btn"
- :disabled="!integrationForm.active"
- class="gl-mt-3"
+ :disabled="!canSubmitForm"
+ variant="confirm"
+ class="js-no-auto-disable"
+ data-testid="integration-form-submit"
+ @click="submit(false)"
>
- {{ $options.i18n.integrationFormSteps.mapFields.editPayload }}
+ {{ $options.i18n.saveIntegration }}
</gl-button>
<gl-button
- v-else
- data-testid="payload-action-btn"
- :class="{ 'gl-mt-3': samplePayload.error }"
- :disabled="!canParseSamplePayload"
- :loading="samplePayload.loading"
- @click="parseSamplePayload"
+ :disabled="!canSubmitForm"
+ variant="confirm"
+ category="secondary"
+ class="gl-ml-3 js-no-auto-disable"
+ data-qa-selector="save_and_create_alert_button"
+ @click="submit(true)"
>
- {{ $options.i18n.integrationFormSteps.mapFields.parsePayload }}
+ {{ $options.i18n.saveAndTestIntegration }}
</gl-button>
- <gl-modal
- modal-id="resetPayloadModal"
- :title="$options.i18n.integrationFormSteps.mapFields.resetHeader"
- :ok-title="$options.i18n.integrationFormSteps.mapFields.resetOk"
- ok-variant="danger"
- @ok="resetPayloadAndMappingConfirmed = true"
- >
- {{ $options.i18n.integrationFormSteps.mapFields.resetBody }}
- </gl-modal>
-
- <div class="gl-mt-5">
- <span>{{ $options.i18n.integrationFormSteps.mapFields.mapIntro }}</span>
- <mapping-builder
- :parsed-payload="parsedPayload"
- :saved-mapping="mapping"
- :alert-fields="alertFields"
- @onMappingUpdate="updateMapping"
- />
- </div>
- </template>
- </div>
- <div class="gl-display-flex gl-justify-content-start gl-py-3">
- <gl-button
- :disabled="!canSubmitForm"
- variant="confirm"
- class="js-no-auto-disable"
- data-testid="integration-form-submit"
- @click="submit(false)"
- >
- {{ $options.i18n.saveIntegration }}
- </gl-button>
-
- <gl-button
- :disabled="!canSubmitForm"
- variant="confirm"
- category="secondary"
- class="gl-ml-3 js-no-auto-disable"
- data-testid="integration-form-test-and-submit"
- data-qa-selector="save_and_create_alert_button"
- @click="submit(true)"
- >
- {{ $options.i18n.saveAndTestIntegration }}
- </gl-button>
-
- <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{
- $options.i18n.cancelAndClose
- }}</gl-button>
- </div>
- </gl-tab>
-
- <gl-tab
- :title="$options.i18n.integrationTabs.viewCredentials"
- :disabled="isCreating"
- class="gl-mt-3"
- >
- <alert-settings-form-help-block
- :message="viewCredentialsHelpMsg"
- :link="$options.incidentManagementDocsLink"
- />
-
- <gl-form-group id="integration-webhook">
- <div class="gl-my-4">
- <span class="gl-font-weight-bold">
- {{ $options.i18n.integrationFormSteps.setupCredentials.webhookUrl }}
- </span>
-
- <gl-form-input-group id="url" readonly :value="integrationForm.url">
- <template #append>
- <clipboard-button
- :text="integrationForm.url || ''"
- :title="$options.i18n.copy"
- class="gl-m-0!"
- />
- </template>
- </gl-form-input-group>
- </div>
-
- <div class="gl-my-4">
- <span class="gl-font-weight-bold">
- {{ $options.i18n.integrationFormSteps.setupCredentials.authorizationKey }}
- </span>
- <gl-form-input-group
- id="authorization-key"
- class="gl-mb-3"
- readonly
- :value="integrationForm.token"
- >
- <template #append>
- <clipboard-button
- :text="integrationForm.token || ''"
- :title="$options.i18n.copy"
- class="gl-m-0!"
- />
- </template>
- </gl-form-input-group>
+ <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{
+ $options.i18n.cancelAndClose
+ }}</gl-button>
</div>
- </gl-form-group>
-
- <div class="gl-display-flex gl-justify-content-start gl-py-3">
- <gl-button v-gl-modal.authKeyModal variant="danger">
- {{ $options.i18n.integrationFormSteps.setupCredentials.reset }}
- </gl-button>
-
- <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">
- {{ $options.i18n.cancelAndClose }}
- </gl-button>
- </div>
-
- <gl-modal
- modal-id="authKeyModal"
- :title="$options.i18n.integrationFormSteps.setupCredentials.reset"
- :ok-title="$options.i18n.integrationFormSteps.setupCredentials.reset"
- ok-variant="danger"
- @ok="resetAuthKey"
+ </gl-tab>
+
+ <gl-tab
+ :title="$options.i18n.integrationTabs.viewCredentials"
+ :disabled="isCreating"
+ class="gl-mt-3"
>
- {{ $options.i18n.integrationFormSteps.restKeyInfo.label }}
- </gl-modal>
- </gl-tab>
-
- <gl-tab
- :title="$options.i18n.integrationTabs.sendTestAlert"
- :disabled="isCreating"
- class="gl-mt-3"
- >
- <gl-form-group id="test-integration" :invalid-feedback="testPayload.error">
<alert-settings-form-help-block
- :message="$options.i18n.integrationFormSteps.testPayload.help"
- :link="alertsUsageUrl"
+ :message="viewCredentialsHelpMsg"
+ :link="$options.incidentManagementDocsLink"
/>
- <gl-form-textarea
- id="test-payload"
- v-model="testPayload.json"
- :state="isTestPayloadValid"
- :placeholder="$options.i18n.integrationFormSteps.testPayload.placeholder"
- class="gl-my-3"
- :debounce="$options.JSON_VALIDATE_DELAY"
- rows="6"
- max-rows="10"
- data-qa-selector="test_payload_field"
- @input="validateJson(false)"
- />
- </gl-form-group>
- <div class="gl-display-flex gl-justify-content-start gl-py-3">
- <gl-button
- v-gl-modal="testAlertModal"
- :disabled="!isTestPayloadValid"
- :loading="loading"
- data-testid="send-test-alert"
- variant="confirm"
- class="js-no-auto-disable"
- data-qa-selector="send_test_alert_button"
- @click="isFormDirty ? null : sendTestAlert()"
+ <gl-form-group id="integration-webhook">
+ <div class="gl-my-4">
+ <span class="gl-font-weight-bold">
+ {{ $options.i18n.integrationFormSteps.setupCredentials.webhookUrl }}
+ </span>
+
+ <gl-form-input-group id="url" readonly :value="integrationForm.url">
+ <template #append>
+ <clipboard-button
+ :text="integrationForm.url || ''"
+ :title="$options.i18n.copy"
+ class="gl-m-0!"
+ />
+ </template>
+ </gl-form-input-group>
+ </div>
+
+ <div class="gl-my-4">
+ <span class="gl-font-weight-bold">
+ {{ $options.i18n.integrationFormSteps.setupCredentials.authorizationKey }}
+ </span>
+
+ <gl-form-input-group
+ id="authorization-key"
+ class="gl-mb-3"
+ readonly
+ :value="integrationForm.token"
+ >
+ <template #append>
+ <clipboard-button
+ :text="integrationForm.token || ''"
+ :title="$options.i18n.copy"
+ class="gl-m-0!"
+ />
+ </template>
+ </gl-form-input-group>
+ </div>
+ </gl-form-group>
+
+ <div class="gl-display-flex gl-justify-content-start gl-py-3">
+ <gl-button v-gl-modal.authKeyModal variant="danger">
+ {{ $options.i18n.integrationFormSteps.setupCredentials.reset }}
+ </gl-button>
+
+ <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">
+ {{ $options.i18n.cancelAndClose }}
+ </gl-button>
+ </div>
+
+ <gl-modal
+ modal-id="authKeyModal"
+ :title="$options.i18n.integrationFormSteps.setupCredentials.reset"
+ :ok-title="$options.i18n.integrationFormSteps.setupCredentials.reset"
+ ok-variant="danger"
+ @ok="resetAuthKey"
>
- {{ $options.i18n.send }}
- </gl-button>
-
- <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">
- {{ $options.i18n.cancelAndClose }}
- </gl-button>
- </div>
-
- <gl-modal
- :modal-id="$options.testAlertModalId"
- :title="$options.i18n.integrationFormSteps.testPayload.modalTitle"
- :action-primary="$options.primaryProps"
- :action-secondary="$options.secondaryProps"
- :action-cancel="$options.cancelProps"
- @primary="saveAndSendTestAlert"
- @secondary="sendTestAlert"
+ {{ $options.i18n.integrationFormSteps.restKeyInfo.label }}
+ </gl-modal>
+ </gl-tab>
+
+ <gl-tab
+ :title="$options.i18n.integrationTabs.sendTestAlert"
+ :disabled="isCreating"
+ class="gl-mt-3"
>
- {{ $options.i18n.integrationFormSteps.testPayload.modalBody }}
- </gl-modal>
- </gl-tab>
- </gl-tabs>
- </gl-form>
+ <gl-form-group id="test-integration" :invalid-feedback="testPayload.error">
+ <alert-settings-form-help-block
+ :message="$options.i18n.integrationFormSteps.testPayload.help"
+ :link="alertsUsageUrl"
+ />
+
+ <gl-form-textarea
+ id="test-payload"
+ v-model="testPayload.json"
+ :state="isTestPayloadValid"
+ :placeholder="$options.i18n.integrationFormSteps.testPayload.placeholder"
+ class="gl-my-3"
+ :debounce="$options.JSON_VALIDATE_DELAY"
+ rows="6"
+ max-rows="10"
+ data-qa-selector="test_payload_field"
+ @input="validateJson(false)"
+ />
+ </gl-form-group>
+ <div class="gl-display-flex gl-justify-content-start gl-py-3">
+ <gl-button
+ v-gl-modal="testAlertModal"
+ :disabled="!isTestPayloadValid"
+ :loading="loading"
+ data-testid="send-test-alert"
+ variant="confirm"
+ class="js-no-auto-disable"
+ data-qa-selector="send_test_alert_button"
+ @click="isFormDirty ? null : sendTestAlert()"
+ >
+ {{ $options.i18n.send }}
+ </gl-button>
+
+ <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">
+ {{ $options.i18n.cancelAndClose }}
+ </gl-button>
+ </div>
+
+ <gl-modal
+ :modal-id="$options.testAlertModalId"
+ :title="$options.i18n.integrationFormSteps.testPayload.modalTitle"
+ :action-primary="$options.primaryProps"
+ :action-secondary="$options.secondaryProps"
+ :action-cancel="$options.cancelProps"
+ @primary="saveAndSendTestAlert"
+ @secondary="sendTestAlert"
+ >
+ {{ $options.i18n.integrationFormSteps.testPayload.modalBody }}
+ </gl-modal>
+ </gl-tab>
+ </gl-tabs>
+ </gl-form>
+ </div>
</template>
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
index cc8913c2f45..e4fc37f9760 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlAlert, GlTabs, GlTab } from '@gitlab/ui';
+import { GlAlert, GlButton, GlCard, GlTabs, GlTab, GlIcon } from '@gitlab/ui';
import createHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql';
import updateHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql';
import { createAlert, VARIANT_SUCCESS } from '~/alert';
@@ -43,8 +43,10 @@ export default {
AlertSettingsForm,
GlAlert,
GlButton,
+ GlCard,
GlTabs,
GlTab,
+ GlIcon,
},
inject: {
projectPath: {
@@ -364,39 +366,58 @@ export default {
{{ $options.i18n.integrationCreated.successMsg }}
</gl-alert>
- <integrations-list
- :integrations="integrations"
- :loading="loading"
- @edit-integration="editIntegration"
- @delete-integration="deleteIntegration"
- />
- <gl-button
- v-if="canAddIntegration && !formVisible"
- category="secondary"
- variant="confirm"
- data-testid="add-integration-btn"
- data-qa-selector="add_integration_button"
- class="gl-mt-3"
- @click="setFormVisibility(true)"
+ <gl-card
+ class="gl-new-card gl-mt-2"
+ header-class="gl-new-card-header"
+ body-class="gl-new-card-body gl-px-0 gl-overflow-hidden"
>
- {{ $options.i18n.addNewIntegration }}
- </gl-button>
- <alert-settings-form
- v-if="formVisible"
- :loading="isUpdating"
- :can-add-integration="canAddIntegration"
- :alert-fields="alertFields"
- :tab-index="tabIndex"
- @create-new-integration="createNewIntegration"
- @update-integration="updateIntegration"
- @reset-token="resetToken"
- @clear-current-integration="clearCurrentIntegration"
- @test-alert-payload="testAlertPayload"
- @save-and-test-alert-payload="saveAndTestAlertPayload"
- />
+ <template #header>
+ <div class="gl-new-card-title-wrapper">
+ <h5 class="gl-new-card-title">
+ {{ $options.i18n.card.title }}
+ <span class="gl-new-card-count">
+ <gl-icon name="warning" class="gl-mr-2" />
+ {{ integrations.length }}
+ </span>
+ </h5>
+ </div>
+ <div class="gl-new-card-actions">
+ <gl-button
+ v-if="canAddIntegration && !formVisible"
+ size="small"
+ data-testid="add-integration-btn"
+ data-qa-selector="add_integration_button"
+ @click="setFormVisibility(true)"
+ >
+ {{ $options.i18n.addNewIntegration }}
+ </gl-button>
+ </div>
+ </template>
+
+ <alert-settings-form
+ v-if="formVisible"
+ :loading="isUpdating"
+ :can-add-integration="canAddIntegration"
+ :alert-fields="alertFields"
+ :tab-index="tabIndex"
+ @create-new-integration="createNewIntegration"
+ @update-integration="updateIntegration"
+ @reset-token="resetToken"
+ @clear-current-integration="clearCurrentIntegration"
+ @test-alert-payload="testAlertPayload"
+ @save-and-test-alert-payload="saveAndTestAlertPayload"
+ />
+
+ <integrations-list
+ :integrations="integrations"
+ :loading="loading"
+ @edit-integration="editIntegration"
+ @delete-integration="deleteIntegration"
+ />
+ </gl-card>
</gl-tab>
<gl-tab :title="$options.i18n.settingsTabs.integrationSettings">
- <alerts-form class="gl-pt-3" data-testid="alert-integration-settings-tab" />
+ <alerts-form class="gl-pt-3" />
</gl-tab>
</gl-tabs>
</template>
diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js
index 218b09cb1b6..a5f18fda542 100644
--- a/app/assets/javascripts/alerts_settings/constants.js
+++ b/app/assets/javascripts/alerts_settings/constants.js
@@ -1,6 +1,9 @@
import { s__, __ } from '~/locale';
export const i18n = {
+ card: {
+ title: s__('AlertSettings|Active alerts'),
+ },
integrationTabs: {
configureDetails: s__('AlertSettings|Configure details'),
viewCredentials: s__('AlertSettings|View credentials'),
diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/base.vue b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue
index 39da3484dfe..84ee8f41b11 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/components/base.vue
+++ b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue
@@ -1,5 +1,6 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState, mapGetters } from 'vuex';
import { getCookie, setCookie } from '~/lib/utils/common_utils';
import ValueStreamMetrics from '~/analytics/shared/components/value_stream_metrics.vue';
diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue
index 92649477922..a69909a68dd 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue
+++ b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import {
OPERATORS_IS,
diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue
index 1e158baa925..38f9936c7c1 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue
+++ b/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue
@@ -218,11 +218,11 @@ export default {
<span data-testid="vsa-stage-header-duration">{{ data.label }}</span>
</template>
<template #head(end_event)="data">
- <span data-testid="vsa-stage-header-last-event">{{ data.label }}</span>
+ <span>{{ data.label }}</span>
</template>
<template #cell(title)="{ item }">
<div data-testid="vsa-stage-event">
- <div v-if="item.id" data-testid="vsa-stage-content">
+ <div v-if="item.id">
<p class="gl-m-0">
<gl-link
data-testid="vsa-stage-event-link"
@@ -240,15 +240,10 @@ export default {
<span class="icon-branch gl-text-gray-400">
<gl-icon name="commit" :size="14" />
</span>
- <gl-link
- class="commit-sha"
- :href="item.commitUrl"
- data-testid="vsa-stage-event-build-sha"
- >{{ item.shortSha }}</gl-link
- >
+ <gl-link class="commit-sha" :href="item.commitUrl">{{ item.shortSha }}</gl-link>
</p>
<p class="gl-m-0">
- <span data-testid="vsa-stage-event-build-author-and-date">
+ <span>
<gl-link class="gl-text-black-normal" :href="item.url">{{ item.date }}</gl-link>
{{ s__('ByAuthor|by') }}
<gl-link
@@ -259,7 +254,7 @@ export default {
</span>
</p>
</div>
- <div v-else data-testid="vsa-stage-content">
+ <div v-else>
<h5 class="gl-font-weight-bold gl-my-1" data-testid="vsa-stage-event-title">
<gl-link class="gl-text-black-normal" :href="item.url">{{ itemTitle(item) }}</gl-link>
</h5>
diff --git a/app/assets/javascripts/analytics/cycle_analytics/store/index.js b/app/assets/javascripts/analytics/cycle_analytics/store/index.js
index 76e3e835016..c54d4eea7f1 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/store/index.js
+++ b/app/assets/javascripts/analytics/cycle_analytics/store/index.js
@@ -6,6 +6,7 @@
*/
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import filters from '~/vue_shared/components/filtered_search_bar/store/modules/filters';
import * as actions from './actions';
diff --git a/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue b/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue
index fd966425920..593de1dcee7 100644
--- a/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue
+++ b/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue
@@ -40,7 +40,7 @@ export default {
return this.devopsScoreMetrics.averageScore === undefined;
},
},
- devopsReportDocsPath: helpPagePath('user/admin_area/analytics/dev_ops_reports'),
+ devopsReportDocsPath: helpPagePath('administration/analytics/dev_ops_reports'),
tableHeaderFields: [
{
key: 'title',
diff --git a/app/assets/javascripts/analytics/shared/components/daterange.vue b/app/assets/javascripts/analytics/shared/components/daterange.vue
index a14b0bafecf..f47e0ccbbf2 100644
--- a/app/assets/javascripts/analytics/shared/components/daterange.vue
+++ b/app/assets/javascripts/analytics/shared/components/daterange.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlDaterangePicker } from '@gitlab/ui';
import { n__, __, sprintf } from '~/locale';
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 95da3b3cf49..185cdaa1c99 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -86,6 +86,7 @@ const Api = {
freezePeriodsPath: '/api/:version/projects/:id/freeze_periods',
freezePeriodPath: '/api/:version/projects/:id/freeze_periods/:freeze_period_id',
serviceDataIncrementCounterPath: '/api/:version/usage_data/increment_counter',
+ serviceDataInternalEventPath: '/api/:version/usage_data/track_event',
serviceDataIncrementUniqueUsersPath: '/api/:version/usage_data/increment_unique_users',
featureFlagUserLists: '/api/:version/projects/:id/feature_flags_user_lists',
featureFlagUserList: '/api/:version/projects/:id/feature_flags_user_lists/:list_iid',
@@ -910,6 +911,20 @@ const Api = {
return axios.post(url, { event }, { headers });
},
+ trackInternalEvent(event) {
+ if (!gon.current_user_id || !gon.features?.usageDataApi) {
+ return null;
+ }
+ const url = Api.buildUrl(this.serviceDataInternalEventPath);
+ const headers = {
+ 'Content-Type': 'application/json',
+ };
+
+ const { data = {} } = { ...window.gl?.snowplowStandardContext };
+ const { project_id, namespace_id } = data;
+ return axios.post(url, { event, project_id, namespace_id }, { headers });
+ },
+
buildUrl(url) {
return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version));
},
diff --git a/app/assets/javascripts/api/groups_api.js b/app/assets/javascripts/api/groups_api.js
index f9edebb9141..e7d066efe13 100644
--- a/app/assets/javascripts/api/groups_api.js
+++ b/app/assets/javascripts/api/groups_api.js
@@ -9,7 +9,7 @@ const GROUP_ALL_MEMBERS_PATH = '/api/:version/groups/:id/members/all';
const DESCENDANT_GROUPS_PATH = '/api/:version/groups/:id/descendant_groups';
const GROUP_TRANSFER_LOCATIONS_PATH = 'api/:version/groups/:id/transfer_locations';
-const axiosGet = (url, query, options, callback) => {
+const axiosGet = (url, query, options, callback, axiosOptions = {}) => {
return axios
.get(url, {
params: {
@@ -17,6 +17,7 @@ const axiosGet = (url, query, options, callback) => {
per_page: DEFAULT_PER_PAGE,
...options,
},
+ ...axiosOptions,
})
.then(({ data, headers }) => {
callback(data);
@@ -25,14 +26,20 @@ const axiosGet = (url, query, options, callback) => {
});
};
-export function getGroups(query, options, callback = () => {}) {
+export function getGroups(query, options, callback = () => {}, axiosOptions = {}) {
const url = buildApiUrl(GROUPS_PATH);
- return axiosGet(url, query, options, callback);
+ return axiosGet(url, query, options, callback, axiosOptions);
}
-export function getDescendentGroups(parentGroupId, query, options, callback = () => {}) {
+export function getDescendentGroups(
+ parentGroupId,
+ query,
+ options,
+ callback = () => {},
+ axiosOptions = {},
+) {
const url = buildApiUrl(DESCENDANT_GROUPS_PATH.replace(':id', parentGroupId));
- return axiosGet(url, query, options, callback);
+ return axiosGet(url, query, options, callback, axiosOptions);
}
export function updateGroup(groupId, data = {}) {
diff --git a/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
index f23a6fbcaa0..d3b914ea8aa 100644
--- a/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
+++ b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
@@ -1,6 +1,6 @@
<script>
import { GlSprintf, GlButton, GlAlert, GlCard } from '@gitlab/ui';
-import { Mousetrap } from '~/lib/mousetrap';
+import { Mousetrap, MOUSETRAP_COPY_KEYBOARD_SHORTCUT } from '~/lib/mousetrap';
import { __ } from '~/locale';
import Tracking from '~/tracking';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -10,7 +10,6 @@ import {
PRINT_BUTTON_ACTION,
TRACKING_LABEL_PREFIX,
RECOVERY_CODE_DOWNLOAD_FILENAME,
- COPY_KEYBOARD_SHORTCUT,
} from '../constants';
export const i18n = {
@@ -62,14 +61,14 @@ export default {
created() {
this.$options.mousetrap = new Mousetrap();
- this.$options.mousetrap.bind(COPY_KEYBOARD_SHORTCUT, this.handleKeyboardCopy);
+ this.$options.mousetrap.bind(MOUSETRAP_COPY_KEYBOARD_SHORTCUT, this.handleKeyboardCopy);
},
beforeDestroy() {
if (!this.$options.mousetrap) {
return;
}
- this.$options.mousetrap.unbind(COPY_KEYBOARD_SHORTCUT);
+ this.$options.mousetrap.unbind(MOUSETRAP_COPY_KEYBOARD_SHORTCUT);
},
methods: {
handleButtonClick(action) {
diff --git a/app/assets/javascripts/authentication/two_factor_auth/constants.js b/app/assets/javascripts/authentication/two_factor_auth/constants.js
index 35fc49c88b2..8ca188c293f 100644
--- a/app/assets/javascripts/authentication/two_factor_auth/constants.js
+++ b/app/assets/javascripts/authentication/two_factor_auth/constants.js
@@ -7,5 +7,3 @@ export const TRACKING_LABEL_PREFIX = '2fa_recovery_codes_';
export const RECOVERY_CODE_DOWNLOAD_FILENAME = 'gitlab-recovery-codes.txt';
export const SUCCESS_QUERY_PARAM = 'two_factor_auth_enabled_successfully';
-
-export const COPY_KEYBOARD_SHORTCUT = 'mod+c';
diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue
index 8bef972cc58..31531c90b94 100644
--- a/app/assets/javascripts/badges/components/badge.vue
+++ b/app/assets/javascripts/badges/components/badge.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlLoadingIcon, GlTooltipDirective, GlIcon, GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue
index 1a80030c7e6..2be59f00773 100644
--- a/app/assets/javascripts/badges/components/badge_form.vue
+++ b/app/assets/javascripts/badges/components/badge_form.vue
@@ -1,6 +1,7 @@
<script>
import { GlLoadingIcon, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui';
import { escape, debounce } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { createAlert, VARIANT_INFO } from '~/alert';
@@ -28,6 +29,11 @@ export default {
type: Boolean,
required: true,
},
+ inModal: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -119,16 +125,28 @@ export default {
exampleUrl,
});
},
+ cancelButtonType() {
+ return this.isEditing ? 'button' : 'reset';
+ },
+ saveText() {
+ return this.isEditing ? s__('Badges|Save changes') : s__('Badges|Add badge');
+ },
+ },
+ mounted() {
+ // declared here to make it cancel-able
+ this.debouncedPreview = debounce(function search() {
+ this.renderBadge();
+ }, badgePreviewDelayInMilliseconds);
},
methods: {
...mapActions(['addBadge', 'renderBadge', 'saveBadge', 'stopEditing', 'updateBadgeInForm']),
- debouncedPreview: debounce(function preview() {
- this.renderBadge();
- }, badgePreviewDelayInMilliseconds),
- onCancel() {
- this.stopEditing();
+ updatePreview() {
+ this.debouncedPreview();
},
onSubmit() {
+ this.debouncedPreview.cancel();
+ this.renderBadge();
+
const form = this.$el;
if (!form.checkValidity()) {
this.wasValidated = true;
@@ -161,6 +179,7 @@ export default {
variant: VARIANT_INFO,
});
this.wasValidated = false;
+ this.$emit('close-add-form');
})
.catch((error) => {
createAlert({
@@ -171,6 +190,17 @@ export default {
throw error;
});
},
+ closeForm() {
+ this.$refs.form.reset();
+ this.$emit('close-add-form');
+ },
+ handleCancel() {
+ if (this.isEditing) {
+ this.stopEditing();
+ } else {
+ this.closeForm();
+ }
+ },
},
safeHtmlConfig: { ALLOW_TAGS: ['a', 'code'] },
};
@@ -178,12 +208,13 @@ export default {
<template>
<form
+ ref="form"
:class="{ 'was-validated': wasValidated }"
class="gl-mt-3 gl-mb-3 needs-validation"
novalidate
@submit.prevent.stop="onSubmit"
>
- <gl-form-group :label="s__('Badges|Name')" label-for="badge-name">
+ <gl-form-group :label="s__('Badges|Name')" label-for="badge-name" class="gl-max-w-48">
<gl-form-input id="badge-name" v-model="name" data-qa-selector="badge_name_field" />
</gl-form-group>
@@ -195,9 +226,9 @@ export default {
v-model="linkUrl"
data-qa-selector="badge_link_url_field"
type="URL"
- class="form-control gl-form-input"
+ class="form-control gl-form-input gl-max-w-80"
required
- @input="debouncedPreview"
+ @input="updatePreview"
/>
<div class="invalid-feedback">{{ s__('Badges|Enter a valid URL') }}</div>
<span class="form-text text-muted">{{ badgeLinkUrlExample }}</span>
@@ -211,9 +242,9 @@ export default {
v-model="imageUrl"
data-qa-selector="badge_image_url_field"
type="URL"
- class="form-control gl-form-input"
+ class="form-control gl-form-input gl-max-w-80"
required
- @input="debouncedPreview"
+ @input="updatePreview"
/>
<div class="invalid-feedback">{{ s__('Badges|Enter a valid URL') }}</div>
<span class="form-text text-muted">{{ badgeImageUrlExample }}</span>
@@ -235,29 +266,23 @@ export default {
</p>
</div>
- <div v-if="isEditing" class="row-content-block">
- <gl-button class="btn-cancel gl-mr-4" data-testid="cancelEditing" @click="onCancel">
- {{ __('Cancel') }}
- </gl-button>
+ <div v-if="!inModal" class="form-group" data-testid="action-buttons">
<gl-button
:loading="isSaving"
type="submit"
variant="confirm"
category="primary"
- data-testid="saveEditing"
+ data-qa-selector="add_badge_button"
+ class="gl-mr-3"
>
- {{ s__('Badges|Save changes') }}
+ {{ saveText }}
</gl-button>
- </div>
- <div v-else class="form-group">
<gl-button
- :loading="isSaving"
- type="submit"
- variant="confirm"
- category="primary"
- data-qa-selector="add_badge_button"
+ :type="cancelButtonType"
+ data-qa-selector="cancel_badge_button"
+ @click="handleCancel"
>
- {{ s__('Badges|Add badge') }}
+ {{ __('Cancel') }}
</gl-button>
</div>
</form>
diff --git a/app/assets/javascripts/badges/components/badge_list.vue b/app/assets/javascripts/badges/components/badge_list.vue
index 76625fe9a60..b69890572eb 100644
--- a/app/assets/javascripts/badges/components/badge_list.vue
+++ b/app/assets/javascripts/badges/components/badge_list.vue
@@ -1,15 +1,43 @@
<script>
-import { GlLoadingIcon, GlBadge } from '@gitlab/ui';
-import { mapState } from 'vuex';
-import { GROUP_BADGE } from '../constants';
-import BadgeListRow from './badge_list_row.vue';
+import {
+ GlBadge,
+ GlLoadingIcon,
+ GlTable,
+ GlPagination,
+ GlButton,
+ GlModalDirective,
+} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
+import { mapActions, mapState } from 'vuex';
+import { __, s__ } from '~/locale';
+import { GROUP_BADGE, PROJECT_BADGE, INITIAL_PAGE, PAGE_SIZE } from '../constants';
+import Badge from './badge.vue';
export default {
+ PAGE_SIZE,
+ INITIAL_PAGE,
name: 'BadgeList',
components: {
- BadgeListRow,
- GlLoadingIcon,
+ Badge,
GlBadge,
+ GlLoadingIcon,
+ GlTable,
+ GlPagination,
+ GlButton,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ i18n: {
+ emptyGroupMessage: s__('Badges|This group has no badges, start by creating a new one above.'),
+ emptyProjectMessage: s__(
+ 'Badges|This project has no badges, start by creating a new one above.',
+ ),
+ },
+ data() {
+ return {
+ currentPage: INITIAL_PAGE,
+ };
},
computed: {
...mapState(['badges', 'isLoading', 'kind']),
@@ -19,28 +47,123 @@ export default {
isGroupBadge() {
return this.kind === GROUP_BADGE;
},
+ showPagination() {
+ return this.badges.length > PAGE_SIZE;
+ },
+ emptyMessage() {
+ return this.isGroupBadge
+ ? this.$options.i18n.emptyGroupMessage
+ : this.$options.i18n.emptyProjectMessage;
+ },
+ fields() {
+ return [
+ {
+ key: 'name',
+ label: __('Name'),
+ tdClass: 'gl-vertical-align-middle!',
+ },
+ {
+ key: 'badge',
+ label: __('Badge'),
+ tdClass: 'gl-vertical-align-middle!',
+ },
+ {
+ key: 'url',
+ label: __('URL'),
+ tdClass: 'gl-vertical-align-middle!',
+ },
+ {
+ key: 'actions',
+ label: __('Actions'),
+ thClass: 'gl-text-right',
+ tdClass: 'gl-text-right',
+ },
+ ];
+ },
+ },
+ methods: {
+ ...mapActions(['editBadge', 'updateBadgeInModal']),
+ badgeKindText(item) {
+ if (item.kind === PROJECT_BADGE) {
+ return s__('Badges|Project Badge');
+ }
+
+ return s__('Badges|Group Badge');
+ },
+ canEditBadge(item) {
+ return item.kind === this.kind;
+ },
},
};
</script>
<template>
- <div class="card">
- <div class="card-header">
- {{ s__('Badges|Your badges') }}
- <gl-badge v-show="!isLoading" size="sm">{{ badges.length }}</gl-badge>
- </div>
- <gl-loading-icon v-show="isLoading" size="lg" class="card-body" />
- <div v-if="hasNoBadges" class="card-body">
- <span v-if="isGroupBadge">{{ s__('Badges|This group has no badges') }}</span>
- <span v-else>{{ s__('Badges|This project has no badges') }}</span>
- </div>
- <div v-else class="card-body" data-qa-selector="badge_list_content">
- <badge-list-row
- v-for="badge in badges"
- :key="badge.id"
- :badge="badge"
- data-qa-selector="badge_list_row"
- :data-qa-badge-name="badge.name"
+ <div>
+ <gl-loading-icon v-show="isLoading" size="md" />
+ <div data-qa-selector="badge_list_content">
+ <gl-table
+ :empty-text="emptyMessage"
+ :fields="fields"
+ :items="badges"
+ :per-page="$options.PAGE_SIZE"
+ :current-page="currentPage"
+ stacked="md"
+ show-empty
+ data-qa-selector="badge_list"
+ >
+ <template #cell(name)="{ item }">
+ <label class="label-bold str-truncated mb-0">{{ item.name }}</label>
+ <gl-badge size="sm">{{ badgeKindText(item) }}</gl-badge>
+ </template>
+
+ <template #cell(badge)="{ item }">
+ <badge :image-url="item.renderedImageUrl" :link-url="item.renderedLinkUrl" />
+ </template>
+
+ <template #cell(url)="{ item }">
+ {{ item.linkUrl }}
+ </template>
+
+ <template #cell(actions)="{ item }">
+ <div v-if="canEditBadge(item)" class="table-action-buttons" data-testid="badge-actions">
+ <gl-button
+ v-gl-modal.edit-badge-modal
+ :disabled="item.isDeleting"
+ class="gl-mr-3"
+ variant="default"
+ icon="pencil"
+ size="medium"
+ :aria-label="__('Edit')"
+ data-testid="edit-badge-button"
+ @click="editBadge(item)"
+ />
+ <gl-button
+ v-gl-modal.delete-badge-modal
+ :disabled="item.isDeleting"
+ category="secondary"
+ variant="danger"
+ icon="remove"
+ size="medium"
+ :aria-label="__('Delete')"
+ data-testid="delete-badge"
+ @click="updateBadgeInModal(item)"
+ />
+ <gl-loading-icon v-show="item.isDeleting" size="sm" :inline="true" />
+ </div>
+ </template>
+ </gl-table>
+
+ <gl-pagination
+ v-if="showPagination"
+ v-model="currentPage"
+ :per-page="$options.PAGE_SIZE"
+ :total-items="badges.length"
+ :prev-text="__('Prev')"
+ :next-text="__('Next')"
+ :label-next-page="__('Go to next page')"
+ :label-prev-page="__('Go to previous page')"
+ align="center"
+ class="gl-mt-5"
/>
</div>
</div>
diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue
deleted file mode 100644
index 4c2b700c7ff..00000000000
--- a/app/assets/javascripts/badges/components/badge_list_row.vue
+++ /dev/null
@@ -1,81 +0,0 @@
-<script>
-import { GlLoadingIcon, GlButton, GlModalDirective, GlBadge } from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
-import { s__ } from '~/locale';
-import { PROJECT_BADGE } from '../constants';
-import Badge from './badge.vue';
-
-export default {
- name: 'BadgeListRow',
- components: {
- Badge,
- GlLoadingIcon,
- GlButton,
- GlBadge,
- },
- directives: {
- GlModal: GlModalDirective,
- },
- props: {
- badge: {
- type: Object,
- required: true,
- },
- },
- computed: {
- ...mapState(['kind']),
- badgeKindText() {
- if (this.badge.kind === PROJECT_BADGE) {
- return s__('Badges|Project Badge');
- }
-
- return s__('Badges|Group Badge');
- },
- canEditBadge() {
- return this.badge.kind === this.kind;
- },
- },
- methods: {
- ...mapActions(['editBadge', 'updateBadgeInModal']),
- },
-};
-</script>
-
-<template>
- <div class="gl-responsive-table-row-layout gl-responsive-table-row">
- <badge
- :image-url="badge.renderedImageUrl"
- :link-url="badge.renderedLinkUrl"
- class="table-section section-30"
- />
- <div class="table-section section-30">
- <label class="label-bold str-truncated mb-0">{{ badge.name }}</label>
- <gl-badge size="sm">{{ badgeKindText }}</gl-badge>
- </div>
- <span class="table-section section-30 str-truncated">{{ badge.linkUrl }}</span>
- <div class="table-section section-10 table-button-footer">
- <div v-if="canEditBadge" class="table-action-buttons">
- <gl-button
- :disabled="badge.isDeleting"
- class="gl-mr-3"
- variant="default"
- icon="pencil"
- size="medium"
- :aria-label="__('Edit')"
- @click="editBadge(badge)"
- />
- <gl-button
- v-gl-modal.delete-badge-modal
- :disabled="badge.isDeleting"
- variant="danger"
- icon="remove"
- size="medium"
- :aria-label="__('Delete')"
- data-testid="delete-badge"
- @click="updateBadgeInModal(badge)"
- />
- <gl-loading-icon v-show="badge.isDeleting" size="sm" :inline="true" />
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/badges/components/badge_settings.vue b/app/assets/javascripts/badges/components/badge_settings.vue
index 09f997d73aa..f0d354c6378 100644
--- a/app/assets/javascripts/badges/components/badge_settings.vue
+++ b/app/assets/javascripts/badges/components/badge_settings.vue
@@ -1,5 +1,6 @@
<script>
-import { GlSprintf, GlModal } from '@gitlab/ui';
+import { GlButton, GlCard, GlModal, GlIcon, GlSprintf } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
import { createAlert, VARIANT_INFO } from '~/alert';
import { __, s__ } from '~/locale';
@@ -13,17 +14,34 @@ export default {
Badge,
BadgeForm,
BadgeList,
+ GlButton,
+ GlCard,
GlModal,
+ GlIcon,
GlSprintf,
},
i18n: {
+ title: s__('Badges|Your badges'),
+ addButton: s__('Badges|Add badge'),
+ addFormTitle: s__('Badges|Add new badge'),
deleteModalText: s__(
'Badges|You are going to delete this badge. Deleted badges %{strongStart}cannot%{strongEnd} be restored.',
),
},
+ data() {
+ return {
+ addFormVisible: false,
+ };
+ },
computed: {
- ...mapState(['badgeInModal', 'isEditing']),
- primaryProps() {
+ ...mapState(['badges', 'badgeInModal', 'isEditing']),
+ saveProps() {
+ return {
+ text: __('Save changes'),
+ attributes: { category: 'primary', variant: 'confirm' },
+ };
+ },
+ deleteProps() {
return {
text: __('Delete badge'),
attributes: { category: 'primary', variant: 'danger' },
@@ -37,7 +55,16 @@ export default {
},
methods: {
...mapActions(['deleteBadge']),
- onSubmitModal() {
+ showAddForm() {
+ this.addFormVisible = !this.addFormVisible;
+ },
+ closeAddForm() {
+ this.addFormVisible = false;
+ },
+ onSubmitEditModal() {
+ this.$refs.editForm.onSubmit();
+ },
+ onSubmitDeleteModal() {
this.deleteBadge(this.badgeInModal)
.then(() => {
createAlert({
@@ -58,12 +85,54 @@ export default {
<template>
<div class="badge-settings">
+ <gl-card
+ class="gl-new-card"
+ header-class="gl-new-card-header"
+ body-class="gl-new-card-body gl-overflow-hidden gl-px-0"
+ >
+ <template #header>
+ <div class="gl-new-card-title-wrapper">
+ <h3 class="gl-new-card-title">{{ $options.i18n.title }}</h3>
+ <span class="gl-new-card-count">
+ <gl-icon name="labels" class="gl-mr-2" />
+ {{ badges.length }}
+ </span>
+ </div>
+ <div class="gl-new-card-actions">
+ <gl-button
+ v-if="!addFormVisible"
+ size="small"
+ data-testid="show-badge-add-form"
+ @click="showAddForm"
+ >{{ $options.i18n.addButton }}</gl-button
+ >
+ </div>
+ </template>
+
+ <div v-if="addFormVisible" class="gl-new-card-add-form gl-m-5">
+ <h4 class="gl-mt-0">{{ $options.i18n.addFormTitle }}</h4>
+ <badge-form :is-editing="false" @close-add-form="closeAddForm" />
+ </div>
+
+ <badge-list />
+ </gl-card>
+
+ <gl-modal
+ modal-id="edit-badge-modal"
+ :title="s__('Badges|Edit badge')"
+ :action-primary="saveProps"
+ :action-cancel="cancelProps"
+ @primary="onSubmitEditModal"
+ >
+ <badge-form ref="editForm" :is-editing="true" :in-modal="true" data-testid="edit-badge" />
+ </gl-modal>
+
<gl-modal
modal-id="delete-badge-modal"
:title="s__('Badges|Delete badge?')"
- :action-primary="primaryProps"
+ :action-primary="deleteProps"
:action-cancel="cancelProps"
- @primary="onSubmitModal"
+ @primary="onSubmitDeleteModal"
>
<div class="well">
<badge
@@ -79,10 +148,5 @@ export default {
</gl-sprintf>
</p>
</gl-modal>
-
- <badge-form v-show="isEditing" :is-editing="true" data-testid="edit-badge" />
-
- <badge-form v-show="!isEditing" :is-editing="false" data-testid="add-new-badge" />
- <badge-list v-show="!isEditing" />
</div>
</template>
diff --git a/app/assets/javascripts/badges/constants.js b/app/assets/javascripts/badges/constants.js
index 709436abca6..56b45abbe6b 100644
--- a/app/assets/javascripts/badges/constants.js
+++ b/app/assets/javascripts/badges/constants.js
@@ -8,3 +8,5 @@ export const PLACEHOLDERS = [
'default_branch',
'commit_sha',
];
+export const INITIAL_PAGE = 1;
+export const PAGE_SIZE = 10;
diff --git a/app/assets/javascripts/badges/store/index.js b/app/assets/javascripts/badges/store/index.js
index 848deb2baa7..4894a1b7755 100644
--- a/app/assets/javascripts/badges/store/index.js
+++ b/app/assets/javascripts/badges/store/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import actions from './actions';
import mutations from './mutations';
diff --git a/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue b/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue
index 74917da6426..62fd77ed534 100644
--- a/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue
+++ b/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import imageDiff from '~/diffs/mixins/image_diff';
import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue';
diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue
index b78874d372c..f231db33b1e 100644
--- a/app/assets/javascripts/batch_comments/components/draft_note.vue
+++ b/app/assets/javascripts/batch_comments/components/draft_note.vue
@@ -1,5 +1,6 @@
<script>
import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import NoteableNote from '~/notes/components/noteable_note.vue';
diff --git a/app/assets/javascripts/batch_comments/components/drafts_count.vue b/app/assets/javascripts/batch_comments/components/drafts_count.vue
index 0cd093823bc..d7a8c09565a 100644
--- a/app/assets/javascripts/batch_comments/components/drafts_count.vue
+++ b/app/assets/javascripts/batch_comments/components/drafts_count.vue
@@ -1,5 +1,6 @@
<script>
import { GlBadge } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
export default {
diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
index ca9cb03ca37..42fc85cc5fb 100644
--- a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
@@ -1,5 +1,6 @@
<script>
import { GlIcon, GlDisclosureDropdown, GlButton } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import { setUrlParams, visitUrl } from '~/lib/utils/url_utility';
import PreviewItem from './preview_item.vue';
diff --git a/app/assets/javascripts/batch_comments/components/preview_item.vue b/app/assets/javascripts/batch_comments/components/preview_item.vue
index 71560c7de3a..8806550ceb5 100644
--- a/app/assets/javascripts/batch_comments/components/preview_item.vue
+++ b/app/assets/javascripts/batch_comments/components/preview_item.vue
@@ -1,5 +1,6 @@
<script>
import { GlSprintf, GlIcon } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import { IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants';
import { sprintf, __ } from '~/locale';
diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue
index cc52285dd81..00bb9250403 100644
--- a/app/assets/javascripts/batch_comments/components/review_bar.vue
+++ b/app/assets/javascripts/batch_comments/components/review_bar.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters } from 'vuex';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { SET_REVIEW_BAR_RENDERED } from '~/batch_comments/stores/modules/batch_comments/mutation_types';
diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
index 96889f0059c..72116b1eb7f 100644
--- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
@@ -1,5 +1,6 @@
<script>
-import { GlDropdown, GlButton, GlIcon, GlForm, GlFormGroup, GlFormCheckbox } from '@gitlab/ui';
+import { GlDropdown, GlButton, GlIcon, GlForm, GlFormCheckbox } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters, mapActions, mapState } from 'vuex';
import { __ } from '~/locale';
import { createAlert } from '~/alert';
@@ -16,12 +17,16 @@ export default {
GlButton,
GlIcon,
GlForm,
- GlFormGroup,
GlFormCheckbox,
MarkdownEditor,
ApprovalPassword: () => import('ee_component/batch_comments/components/approval_password.vue'),
+ SummarizeMyReview: () =>
+ import('ee_component/batch_comments/components/summarize_my_review.vue'),
},
mixins: [glFeatureFlagsMixin()],
+ inject: {
+ canSummarize: { default: false },
+ },
data() {
return {
isSubmitting: false,
@@ -68,7 +73,7 @@ export default {
// whenever a item in the autocomplete dropdown is clicked
const originalClickOutHandler = this.$refs.submitDropdown.$refs.dropdown.clickOutHandler;
this.$refs.submitDropdown.$refs.dropdown.clickOutHandler = (e) => {
- if (!e.composedPath().includes(this.$el)) {
+ if (!e.target.closest('.atwho-container')) {
originalClickOutHandler(e);
}
};
@@ -107,6 +112,9 @@ export default {
this.isSubmitting = false;
},
+ updateNote(note) {
+ this.noteData.note = note;
+ },
},
restrictedToolbarItems: ['full-screen'],
};
@@ -128,35 +136,45 @@ export default {
<gl-icon class="dropdown-chevron" name="chevron-up" />
</template>
<gl-form data-testid="submit-gl-form" @submit.prevent="submitReview">
- <gl-form-group label-for="review-note-body" label-class="gl-mb-2">
- <template #label>
+ <div class="gl-display-flex gl-mb-4 gl-align-items-center">
+ <label for="review-note-body" class="gl-mb-0">
{{ __('Summary comment (optional)') }}
- </template>
- <div class="common-note-form gfm-form">
- <markdown-editor
- ref="markdownEditor"
- v-model="noteData.note"
- :enable-content-editor="Boolean(glFeatures.contentEditorOnIssues)"
- class="js-no-autosize"
- :is-submitting="isSubmitting"
- :render-markdown-path="getNoteableData.preview_note_path"
- :markdown-docs-path="getNotesData.markdownDocsPath"
- :form-field-props="formFieldProps"
- enable-autocomplete
- :autocomplete-data-sources="autocompleteDataSources"
- :disabled="isSubmitting"
- :restricted-tool-bar-items="$options.restrictedToolbarItems"
- :force-autosize="false"
- :autosave-key="autosaveKey"
- supports-quick-actions
- @input="$emit('input', $event)"
- @keydown.meta.enter="submitReview"
- @keydown.ctrl.enter="submitReview"
- />
- </div>
- </gl-form-group>
+ </label>
+ <summarize-my-review
+ v-if="canSummarize"
+ :id="getNoteableData.id"
+ class="gl-ml-auto"
+ @input="updateNote"
+ />
+ </div>
+ <div class="common-note-form gfm-form">
+ <markdown-editor
+ ref="markdownEditor"
+ v-model="noteData.note"
+ :enable-content-editor="Boolean(glFeatures.contentEditorOnIssues)"
+ class="js-no-autosize"
+ :is-submitting="isSubmitting"
+ :render-markdown-path="getNoteableData.preview_note_path"
+ :markdown-docs-path="getNotesData.markdownDocsPath"
+ :form-field-props="formFieldProps"
+ enable-autocomplete
+ :autocomplete-data-sources="autocompleteDataSources"
+ :disabled="isSubmitting"
+ :restricted-tool-bar-items="$options.restrictedToolbarItems"
+ :force-autosize="false"
+ :autosave-key="autosaveKey"
+ supports-quick-actions
+ @input="$emit('input', $event)"
+ @keydown.meta.enter="submitReview"
+ @keydown.ctrl.enter="submitReview"
+ />
+ </div>
<template v-if="getNoteableData.current_user.can_approve">
- <gl-form-checkbox v-model="noteData.approve" data-testid="approve_merge_request">
+ <gl-form-checkbox
+ v-model="noteData.approve"
+ data-testid="approve_merge_request"
+ class="gl-mt-4"
+ >
{{ __('Approve merge request') }}
</gl-form-checkbox>
<approval-password
@@ -167,7 +185,7 @@ export default {
data-testid="approve_password"
/>
</template>
- <div class="gl-display-flex gl-justify-content-start gl-mt-5">
+ <div class="gl-display-flex gl-justify-content-start gl-mt-4">
<gl-button
:loading="isSubmitting"
variant="confirm"
diff --git a/app/assets/javascripts/batch_comments/index.js b/app/assets/javascripts/batch_comments/index.js
index bf9769ff359..fe3e27a253c 100644
--- a/app/assets/javascripts/batch_comments/index.js
+++ b/app/assets/javascripts/batch_comments/index.js
@@ -1,10 +1,12 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters } from 'vuex';
+import { parseBoolean } from '~/lib/utils/common_utils';
import { apolloProvider } from '~/graphql_shared/issuable_client';
import store from '~/mr_notes/stores';
-export const initReviewBar = ({ editorAiActions = [] } = {}) => {
+export const initReviewBar = () => {
const el = document.getElementById('js-review-bar');
if (!el) return;
@@ -21,7 +23,7 @@ export const initReviewBar = ({ editorAiActions = [] } = {}) => {
},
provide: {
newCommentTemplatePath: el.dataset.newCommentTemplatePath,
- editorAiActions,
+ canSummarize: parseBoolean(el.dataset.canSummarize),
},
computed: {
...mapGetters('batchComments', ['draftsCount']),
diff --git a/app/assets/javascripts/batch_comments/mixins/resolved_status.js b/app/assets/javascripts/batch_comments/mixins/resolved_status.js
index bec360e3b2e..fddb843bb52 100644
--- a/app/assets/javascripts/batch_comments/mixins/resolved_status.js
+++ b/app/assets/javascripts/batch_comments/mixins/resolved_status.js
@@ -1,3 +1,4 @@
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import { sprintf, s__, __ } from '~/locale';
diff --git a/app/assets/javascripts/batch_comments/stores/index.js b/app/assets/javascripts/batch_comments/stores/index.js
index 08dc9ea70f8..2574d125746 100644
--- a/app/assets/javascripts/batch_comments/stores/index.js
+++ b/app/assets/javascripts/batch_comments/stores/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import batchComments from './modules/batch_comments';
diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js
index 1d36661ee63..871b1279ce6 100644
--- a/app/assets/javascripts/behaviors/index.js
+++ b/app/assets/javascripts/behaviors/index.js
@@ -8,6 +8,7 @@ import initCopyAsGFM from './markdown/copy_as_gfm';
import './quick_submit';
import './requires_input';
import initPageShortcuts from './shortcuts';
+import { initToastMessages } from './toasts';
import './toggler_behavior';
import './preview_markdown';
@@ -21,6 +22,8 @@ initCopyToClipboard();
initPageShortcuts();
initCollapseSidebarOnWindowResize();
+initToastMessages();
+
window.requestIdleCallback(
() => {
// Check if we have to Load GFM Input
diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
index bd13bcb35fc..689f2f0898e 100644
--- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js
+++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
@@ -399,6 +399,12 @@ export const ISSUABLE_CHANGE_LABEL = {
defaultKeys: ['l'],
};
+export const ISSUABLE_COPY_REF = {
+ id: 'issuables.copyIssuableRef',
+ description: __('Copy reference'),
+ defaultKeys: ['c r'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
export const ISSUE_MR_CHANGE_ASSIGNEE = {
id: 'issuesMRs.changeAssignee',
description: __('Change assignee'),
@@ -430,6 +436,13 @@ export const MR_GO_TO_FILE = {
customizable: false,
};
+export const MR_TOGGLE_FILE_BROWSER = {
+ id: 'mergeRequests.toggleFileBrowser',
+ description: __('Toggle file browser'),
+ defaultKeys: ['f'],
+ customizable: false,
+};
+
export const MR_NEXT_UNRESOLVED_DISCUSSION = {
id: 'mergeRequests.nextUnresolvedDiscussion',
description: __('Next unresolved discussion'),
@@ -599,7 +612,12 @@ const PROJECT_FILES_SHORTCUTS_GROUP = {
const ISSUABLE_SHORTCUTS_GROUP = {
id: 'issuables',
name: __('Epics, issues, and merge requests'),
- keybindings: [ISSUABLE_COMMENT_OR_REPLY, ISSUABLE_EDIT_DESCRIPTION, ISSUABLE_CHANGE_LABEL],
+ keybindings: [
+ ISSUABLE_COMMENT_OR_REPLY,
+ ISSUABLE_EDIT_DESCRIPTION,
+ ISSUABLE_CHANGE_LABEL,
+ ISSUABLE_COPY_REF,
+ ],
};
const ISSUE_MR_SHORTCUTS_GROUP = {
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcut.vue b/app/assets/javascripts/behaviors/shortcuts/shortcut.vue
index 38384157007..b69a9b690b3 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcut.vue
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcut.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { getModifierKey } from '~/constants';
import { __, s__ } from '~/locale';
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
index 0c882ff9ea2..b0e515ac19d 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
@@ -14,6 +14,7 @@ import {
ISSUABLE_COMMENT_OR_REPLY,
ISSUABLE_EDIT_DESCRIPTION,
MR_COPY_SOURCE_BRANCH_NAME,
+ ISSUABLE_COPY_REF,
} from './keybindings';
import Shortcuts from './shortcuts';
@@ -21,15 +22,24 @@ export default class ShortcutsIssuable extends Shortcuts {
constructor() {
super();
- this.inMemoryButton = document.createElement('button');
- this.clipboardInstance = new ClipboardJS(this.inMemoryButton);
- this.clipboardInstance.on('success', () => {
+ this.branchInMemoryButton = document.createElement('button');
+ this.branchClipboardInstance = new ClipboardJS(this.branchInMemoryButton);
+ this.branchClipboardInstance.on('success', () => {
toast(s__('GlobalShortcuts|Copied source branch name to clipboard.'));
});
- this.clipboardInstance.on('error', () => {
+ this.branchClipboardInstance.on('error', () => {
toast(s__('GlobalShortcuts|Unable to copy the source branch name at this time.'));
});
+ this.refInMemoryButton = document.createElement('button');
+ this.refClipboardInstance = new ClipboardJS(this.refInMemoryButton);
+ this.refClipboardInstance.on('success', () => {
+ toast(s__('GlobalShortcuts|Copied reference to clipboard.'));
+ });
+ this.refClipboardInstance.on('error', () => {
+ toast(s__('GlobalShortcuts|Unable to copy the reference at this time.'));
+ });
+
this.bindCommands([
[ISSUE_MR_CHANGE_ASSIGNEE, () => ShortcutsIssuable.openSidebarDropdown('assignee')],
[ISSUE_MR_CHANGE_MILESTONE, () => ShortcutsIssuable.openSidebarDropdown('milestone')],
@@ -37,6 +47,7 @@ export default class ShortcutsIssuable extends Shortcuts {
[ISSUABLE_COMMENT_OR_REPLY, ShortcutsIssuable.replyWithSelectedText],
[ISSUABLE_EDIT_DESCRIPTION, ShortcutsIssuable.editIssue],
[MR_COPY_SOURCE_BRANCH_NAME, () => this.copyBranchName()],
+ [ISSUABLE_COPY_REF, () => this.copyIssuableRef()],
]);
/**
@@ -163,9 +174,20 @@ export default class ShortcutsIssuable extends Shortcuts {
const branchName = button?.dataset.clipboardText;
if (branchName) {
- this.inMemoryButton.dataset.clipboardText = branchName;
+ this.branchInMemoryButton.dataset.clipboardText = branchName;
+
+ this.branchInMemoryButton.dispatchEvent(new CustomEvent('click'));
+ }
+ }
+
+ async copyIssuableRef() {
+ const refButton = document.querySelector('.js-copy-reference');
+ const copiedRef = refButton?.dataset.clipboardText;
+
+ if (copiedRef) {
+ this.refInMemoryButton.dataset.clipboardText = copiedRef;
- this.inMemoryButton.dispatchEvent(new CustomEvent('click'));
+ this.refInMemoryButton.dispatchEvent(new CustomEvent('click'));
}
}
}
diff --git a/app/assets/javascripts/behaviors/toasts.js b/app/assets/javascripts/behaviors/toasts.js
new file mode 100644
index 00000000000..b6ac78cb540
--- /dev/null
+++ b/app/assets/javascripts/behaviors/toasts.js
@@ -0,0 +1,9 @@
+export async function initToastMessages() {
+ const toasts = document.querySelectorAll('.js-toast-message');
+ if (!toasts.length) {
+ return;
+ }
+
+ const { default: showToast } = await import('~/vue_shared/plugins/global_toast');
+ toasts.forEach((toast) => showToast(toast.dataset.message));
+}
diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue
index f032e2e7fb8..cb9997b7c54 100644
--- a/app/assets/javascripts/blob/components/blob_content.vue
+++ b/app/assets/javascripts/blob/components/blob_content.vue
@@ -3,7 +3,11 @@ import { GlLoadingIcon } from '@gitlab/ui';
import { RichViewer, SimpleViewer } from '~/vue_shared/components/blob_viewers';
import BlobContentError from './blob_content_error.vue';
-import { BLOB_RENDER_EVENT_LOAD, BLOB_RENDER_EVENT_SHOW_SOURCE } from './constants';
+import {
+ BLOB_RENDER_EVENT_LOAD,
+ BLOB_RENDER_EVENT_SHOW_SOURCE,
+ RICH_BLOB_VIEWER,
+} from './constants';
export default {
name: 'BlobContent',
@@ -47,6 +51,9 @@ export default {
default: false,
},
},
+ data() {
+ return { richContentLoaded: false };
+ },
computed: {
viewer() {
switch (this.activeViewer.type) {
@@ -59,13 +66,18 @@ export default {
viewerError() {
return this.activeViewer.renderError;
},
+ isContentLoaded() {
+ return this.activeViewer.type === RICH_BLOB_VIEWER
+ ? !this.loading && this.richContentLoaded
+ : !this.loading;
+ },
},
BLOB_RENDER_EVENT_LOAD,
BLOB_RENDER_EVENT_SHOW_SOURCE,
};
</script>
<template>
- <div class="blob-viewer" :data-type="activeViewer.type" :data-loaded="!loading">
+ <div class="blob-viewer" :data-type="activeViewer.type" :data-loaded="isContentLoaded">
<gl-loading-icon v-if="loading" size="lg" color="dark" class="my-4 mx-auto" />
<template v-else>
@@ -87,6 +99,7 @@ export default {
:type="activeViewer.fileType"
:hide-line-numbers="hideLineNumbers"
data-qa-selector="blob_viewer_file_content"
+ @richContentLoaded="richContentLoaded = true"
/>
</template>
</div>
diff --git a/app/assets/javascripts/blob/components/table_contents.vue b/app/assets/javascripts/blob/components/table_contents.vue
index ee8bd23f844..d59e357877d 100644
--- a/app/assets/javascripts/blob/components/table_contents.vue
+++ b/app/assets/javascripts/blob/components/table_contents.vue
@@ -25,7 +25,6 @@ export default {
} else if (blobViewerAttr('data-loaded') === 'true') {
this.isHidden = false;
this.generateHeaders();
-
this.observer.disconnect();
}
});
diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js
deleted file mode 100644
index e0ecfca75f5..00000000000
--- a/app/assets/javascripts/blob/file_template_mediator.js
+++ /dev/null
@@ -1,202 +0,0 @@
-import $ from 'jquery';
-
-import Api from '~/api';
-import initPopover from '~/blob/suggest_gitlab_ci_yml';
-import { createAlert } from '~/alert';
-import { __ } from '~/locale';
-import toast from '~/vue_shared/plugins/global_toast';
-
-import BlobCiYamlSelector from './template_selectors/ci_yaml_selector';
-import DockerfileSelector from './template_selectors/dockerfile_selector';
-import GitignoreSelector from './template_selectors/gitignore_selector';
-import LicenseSelector from './template_selectors/license_selector';
-
-export default class FileTemplateMediator {
- constructor({ editor, currentAction, projectId }) {
- this.editor = editor;
- this.currentAction = currentAction;
- this.projectId = projectId;
-
- this.initTemplateSelectors();
- this.initDomElements();
- this.initDropdowns();
- this.initPageEvents();
- this.cacheFileContents();
- }
-
- initTemplateSelectors() {
- // Order dictates template type dropdown item order
- this.templateSelectors = [
- GitignoreSelector,
- BlobCiYamlSelector,
- DockerfileSelector,
- LicenseSelector,
- ].map((TemplateSelectorClass) => new TemplateSelectorClass({ mediator: this }));
- }
-
- initDomElements() {
- const $templatesMenu = $('.template-selectors-menu');
- const $undoMenu = $templatesMenu.find('.template-selectors-undo-menu');
- const $fileEditor = $('.file-editor');
-
- this.$templatesMenu = $templatesMenu;
- this.$undoMenu = $undoMenu;
- this.$undoBtn = $undoMenu.find('button');
- this.$templateSelectors = $templatesMenu.find('.template-selector-dropdowns-wrap');
- this.$filenameInput = $fileEditor.find('.js-file-path-name-input');
- this.$fileContent = $fileEditor.find('#file-content');
- this.$commitForm = $fileEditor.find('form');
- this.$navLinks = $fileEditor.find('.nav-links');
- }
-
- initDropdowns() {
- if (this.currentAction !== 'create') {
- this.hideTemplateSelectorMenu();
- }
-
- this.displayMatchedTemplateSelector();
- }
-
- initPageEvents() {
- this.listenForFilenameInput();
- this.listenForPreviewMode();
- }
-
- listenForFilenameInput() {
- this.$filenameInput.on('keyup blur', () => {
- this.displayMatchedTemplateSelector();
- });
- }
-
- listenForPreviewMode() {
- this.$navLinks.on('click', 'a', (e) => {
- const urlPieces = e.target.href.split('#');
- const hash = urlPieces[1];
- if (hash === 'preview') {
- this.hideTemplateSelectorMenu();
- } else if (hash === 'editor' && this.templateSelectors.find((sel) => sel.dropdown !== null)) {
- this.showTemplateSelectorMenu();
- }
- });
- }
-
- selectTemplateFile(selector, query, data) {
- const self = this;
- const { name } = selector.config;
- const suggestCommitChanges = document.querySelector('.js-suggest-gitlab-ci-yml-commit-changes');
-
- selector.renderLoading();
-
- this.fetchFileTemplate(selector.config.type, query, data)
- .then((file) => {
- this.setEditorContent(file);
- this.setFilename(name);
- selector.renderLoaded();
-
- toast(__(`${query} template applied`), {
- action: {
- text: __('Undo'),
- onClick: (e, toastObj) => {
- self.restoreFromCache();
- toastObj.hide();
- },
- },
- });
-
- if (suggestCommitChanges) {
- initPopover(suggestCommitChanges);
- }
- })
- .catch((err) =>
- createAlert({
- message: __(`An error occurred while fetching the template: ${err}`),
- }),
- );
- }
-
- displayMatchedTemplateSelector() {
- const currentInput = this.getFilename();
- const matchedSelector = this.templateSelectors.find((sel) =>
- sel.config.pattern.test(currentInput),
- );
- const currentSelector = this.templateSelectors.find((sel) => !sel.isHidden());
-
- if (matchedSelector) {
- if (currentSelector) {
- currentSelector.hide();
- }
- matchedSelector.show();
- this.showTemplateSelectorMenu();
- } else {
- this.hideTemplateSelectorMenu();
- }
- }
-
- fetchFileTemplate(type, query, data = {}) {
- return new Promise((resolve) => {
- const resolveFile = (file) => resolve(file);
-
- Api.projectTemplate(this.projectId, type, query, data, resolveFile);
- });
- }
-
- setEditorContent(file) {
- if (!file && file !== '') return;
-
- const newValue = file.content || file;
-
- this.editor.setValue(newValue, 1);
-
- this.editor.focus();
-
- this.editor.navigateFileStart();
- }
-
- hideTemplateSelectorMenu() {
- this.$templatesMenu.hide();
- }
-
- showTemplateSelectorMenu() {
- this.$templatesMenu.show();
- this.cacheToggleText();
- }
-
- cacheToggleText() {
- this.cachedToggleText = this.getTemplateSelectorToggleText();
- }
-
- cacheFileContents() {
- this.cachedContent = this.editor.getValue();
- this.cachedFilename = this.getFilename();
- }
-
- restoreFromCache() {
- this.setEditorContent(this.cachedContent);
- this.setFilename(this.cachedFilename);
- this.setTemplateSelectorToggleText();
- }
-
- getTemplateSelectorToggleText() {
- return this.$templateSelectors
- .find('.js-template-selector-wrap:visible .dropdown-toggle-text')
- .text();
- }
-
- setTemplateSelectorToggleText() {
- return this.$templateSelectors
- .find('.js-template-selector-wrap:visible .dropdown-toggle-text')
- .text(this.cachedToggleText);
- }
-
- getFilename() {
- return this.$filenameInput.val();
- }
-
- setFilename(name) {
- const input = this.$filenameInput.get(0);
- if (name !== undefined && input.value !== name) {
- input.value = name;
- input.dispatchEvent(new Event('change'));
- }
- }
-}
diff --git a/app/assets/javascripts/blob/file_template_selector.js b/app/assets/javascripts/blob/file_template_selector.js
deleted file mode 100644
index 4f970d657c2..00000000000
--- a/app/assets/javascripts/blob/file_template_selector.js
+++ /dev/null
@@ -1,105 +0,0 @@
-import $ from 'jquery';
-import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
-
-export default class FileTemplateSelector {
- constructor(mediator) {
- this.mediator = mediator;
- this.$dropdown = null;
- this.$wrapper = null;
-
- this.dropdown = null;
- this.wrapper = null;
- }
-
- init() {
- const cfg = this.config;
-
- this.$dropdown = $(cfg.dropdown);
- this.$wrapper = $(cfg.wrapper);
-
- this.dropdown = document.querySelector(cfg.dropdown);
- this.wrapper = document.querySelector(cfg.wrapper);
-
- this.dropdownIcon = this.wrapper.querySelector('.dropdown-menu-toggle-icon');
- this.loadingIcon = loadingIconForLegacyJS({ classes: ['gl-display-none'] });
- this.dropdown.appendChild(this.loadingIcon);
- this.dropdownToggleText = this.wrapper.querySelector('.dropdown-toggle-text');
-
- this.initDropdown();
- this.selectInitialTemplate();
- }
-
- selectInitialTemplate() {
- const template = this.dropdown.dataset.selected;
-
- if (!template) {
- return;
- }
-
- this.mediator.selectTemplateFile(this, template);
- }
-
- show() {
- if (this.dropdown === null) {
- this.init();
- }
-
- this.wrapper.classList.remove('hidden');
-
- /**
- * We set the focus on the dropdown that was just shown. This is done so that, after selecting
- * a template type, the template selector immediately receives the focus.
- * This improves the UX of the tour as the suggest_gitlab_ci_yml popover requires its target to
- * be have the focus to appear. This way, users don't have to interact with the template
- * selector to actually see the first hint: it is shown as soon as the selector becomes visible.
- * We also need a timeout here, otherwise the template type selector gets stuck and can not be
- * closed anymore.
- */
- setTimeout(() => {
- this.dropdown.focus();
- }, 0);
- }
-
- hide() {
- if (this.dropdown !== null) {
- this.wrapper.classList.add('hidden');
- }
- }
-
- isHidden() {
- return !this.wrapper || this.wrapper.classList.contains('hidden');
- }
-
- getToggleText() {
- return this.dropdownToggleText.textContent;
- }
-
- setToggleText(text) {
- this.dropdownToggleText.textContent = text;
- }
-
- renderLoading() {
- this.loadingIcon.classList.remove('gl-display-none');
- this.dropdownIcon.classList.add('gl-display-none');
- }
-
- renderLoaded() {
- this.loadingIcon.classList.add('gl-display-none');
- this.dropdownIcon.classList.remove('gl-display-none');
- }
-
- reportSelection(options) {
- const { query, e, data } = options;
- e.preventDefault();
- return this.mediator.selectTemplateFile(this, query, data);
- }
-
- reportSelectionName(options) {
- const opts = options;
- opts.query = options.selectedObj.name;
- opts.data = options.selectedObj;
- opts.data.source_template_project_id = options.selectedObj.project_id;
-
- this.reportSelection(opts);
- }
-}
diff --git a/app/assets/javascripts/blob/filepath_form/components/filepath_form.vue b/app/assets/javascripts/blob/filepath_form/components/filepath_form.vue
new file mode 100644
index 00000000000..7a3e31a642b
--- /dev/null
+++ b/app/assets/javascripts/blob/filepath_form/components/filepath_form.vue
@@ -0,0 +1,63 @@
+<script>
+import { GlFormInput } from '@gitlab/ui';
+import TemplateSelector from '~/blob/filepath_form/components/template_selector.vue';
+
+export default {
+ components: {
+ GlFormInput,
+ TemplateSelector,
+ },
+ props: {
+ templates: {
+ type: Object,
+ required: true,
+ },
+ initialTemplate: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ inputOptions: {
+ type: Object,
+ required: true,
+ },
+ suggestCiYmlData: {
+ type: Object,
+ required: false,
+ default: undefined,
+ },
+ },
+ data() {
+ return {
+ filename: this.inputOptions.value || '',
+ showTemplateSelector: true,
+ };
+ },
+ beforeMount() {
+ const navLinksElement = document.querySelector('.file-editor .nav-links');
+ navLinksElement?.addEventListener('click', (e) => {
+ this.showTemplateSelector = e.target.href.split('#')[1] !== 'preview';
+ });
+ },
+ methods: {
+ onTemplateSelected(data) {
+ this.$emit('template-selected', data);
+ },
+ },
+};
+</script>
+<template>
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-w-full gl-lg-w-auto gl-gap-3 gl-mr-3"
+ >
+ <gl-form-input v-model="filename" v-bind="inputOptions" />
+ <template-selector
+ v-if="showTemplateSelector"
+ :filename="filename"
+ :templates="templates"
+ :initial-template="initialTemplate"
+ :suggest-ci-yml-data="suggestCiYmlData"
+ @selected="onTemplateSelected"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/blob/filepath_form/components/template_selector.vue b/app/assets/javascripts/blob/filepath_form/components/template_selector.vue
new file mode 100644
index 00000000000..51c69590796
--- /dev/null
+++ b/app/assets/javascripts/blob/filepath_form/components/template_selector.vue
@@ -0,0 +1,161 @@
+<script>
+import { GlCollapsibleListbox } from '@gitlab/ui';
+import SuggestGitlabCiYml from '~/blob/suggest_gitlab_ci_yml/components/popover.vue';
+import { __ } from '~/locale';
+
+const templateSelectors = [
+ {
+ key: 'gitignore_names',
+ name: '.gitignore',
+ pattern: /(.gitignore)/,
+ type: 'gitignores',
+ },
+ {
+ key: 'gitlab_ci_ymls',
+ name: '.gitlab-ci.yml',
+ pattern: /(.gitlab-ci.yml)/,
+ type: 'gitlab_ci_ymls',
+ },
+ {
+ key: 'dockerfile_names',
+ name: __('Dockerfile'),
+ pattern: /(Dockerfile)/,
+ type: 'dockerfiles',
+ },
+ {
+ key: 'licenses',
+ name: 'LICENSE',
+ pattern: /^(.+\/)?(licen[sc]e|copying)($|\.)/i,
+ type: 'licenses',
+ },
+];
+
+export default {
+ name: 'TemplateSelector',
+ components: {
+ SuggestGitlabCiYml,
+ GlCollapsibleListbox,
+ },
+ props: {
+ filename: {
+ type: String,
+ required: true,
+ },
+ templates: {
+ type: Object,
+ required: true,
+ },
+ initialTemplate: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ suggestCiYmlData: {
+ type: Object,
+ required: false,
+ default: undefined,
+ },
+ },
+ data() {
+ return {
+ loading: false,
+ searchTerm: '',
+ selectedTemplate: undefined,
+ types: templateSelectors,
+ };
+ },
+ computed: {
+ activeType() {
+ return templateSelectors.find((selector) => selector.pattern.test(this.filename));
+ },
+ activeTemplatesList() {
+ return this.templates[this.activeType?.key];
+ },
+ selectedTemplateKey() {
+ return this.selectedTemplate?.key;
+ },
+ dropdownToggleText() {
+ return this.selectedTemplate?.name || this.$options.i18n.templateSelectorTxt;
+ },
+ dropdownItems() {
+ return Object.entries(this.activeTemplatesList)
+ .map(([key, items]) => ({
+ text: key,
+ options: items
+ .filter((item) => item.name.toLowerCase().includes(this.searchTerm))
+ .map((item) => ({
+ text: item.name,
+ value: item.key,
+ })),
+ }))
+ .filter((group) => group.options.length > 0);
+ },
+ templateItems() {
+ return Object.values(this.activeTemplatesList).reduce((acc, items) => [...acc, ...items], []);
+ },
+ showDropdown() {
+ return this.activeType && this.templateItems.length > 0;
+ },
+ showPopover() {
+ return this.activeType?.key === 'gitlab_ci_ymls' && this.suggestCiYmlData;
+ },
+ },
+ beforeMount() {
+ if (this.activeType) this.applyTemplate(this.initialTemplate);
+ },
+ methods: {
+ applyTemplate(templateKey) {
+ this.selectedTemplate = this.templateItems.find((item) => item.key === templateKey);
+ if (this.selectedTemplate) {
+ this.loading = true;
+ this.$emit('selected', {
+ template: this.selectedTemplate,
+ type: this.activeType,
+ clearSelectedTemplate: this.clearSelectedTemplate,
+ stopLoading: this.stopLoading,
+ });
+ }
+ },
+ stopLoading() {
+ this.loading = false;
+ },
+ clearSelectedTemplate() {
+ this.selectedTemplate = undefined;
+ },
+ onSearch(searchTerm) {
+ this.searchTerm = searchTerm.trim().toLowerCase();
+ },
+ },
+ i18n: {
+ templateSelectorTxt: __('Apply a template'),
+ searchPlaceholder: __('Filter'),
+ },
+};
+</script>
+<template>
+ <div v-if="showDropdown">
+ <suggest-gitlab-ci-yml
+ v-if="showPopover"
+ target="template-selector"
+ :track-label="suggestCiYmlData.trackLabel"
+ :dismiss-key="suggestCiYmlData.dismissKey"
+ :merge-request-path="suggestCiYmlData.mergeRequestPath"
+ :human-access="suggestCiYmlData.humanAccess"
+ />
+ <gl-collapsible-listbox
+ id="template-selector"
+ searchable
+ block
+ class="gl-font-regular"
+ data-testid="template-selector"
+ data-qa-selector="template_selector"
+ :toggle-text="dropdownToggleText"
+ :search-placeholder="$options.i18n.searchPlaceholder"
+ :items="dropdownItems"
+ :selected="selectedTemplateKey"
+ :loading="loading"
+ @select="applyTemplate"
+ @search="onSearch"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/blob/filepath_form/index.js b/app/assets/javascripts/blob/filepath_form/index.js
new file mode 100644
index 00000000000..bcb285ddf34
--- /dev/null
+++ b/app/assets/javascripts/blob/filepath_form/index.js
@@ -0,0 +1,41 @@
+import Vue from 'vue';
+import FilepathForm from './components/filepath_form.vue';
+
+const getPopoverData = (el) => ({
+ trackLabel: el.dataset.trackLabel,
+ dismissKey: el.dataset.dismissKey,
+ mergeRequestPath: el.dataset.mergeRequestPath,
+ humanAccess: el.dataset.humanAccess,
+});
+
+const getInputOptions = (el) => {
+ const { testid, qa_selector: qaSelector, ...options } = JSON.parse(el.dataset.inputOptions);
+ return {
+ ...options,
+ 'data-testid': testid,
+ };
+};
+
+export default ({ onTemplateSelected }) => {
+ const el = document.getElementById('js-template-selectors-menu');
+
+ const suggestCiYmlEl = document.querySelector('.js-suggest-gitlab-ci-yml');
+ const suggestCiYmlData = suggestCiYmlEl ? getPopoverData(suggestCiYmlEl) : undefined;
+
+ return new Vue({
+ el,
+ render(h) {
+ return h(FilepathForm, {
+ props: {
+ suggestCiYmlData,
+ inputOptions: getInputOptions(el),
+ templates: JSON.parse(el.dataset.templates),
+ initialTemplate: el.dataset.selected,
+ },
+ on: {
+ 'template-selected': onTemplateSelected,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/blob/filepath_form_mediator.js b/app/assets/javascripts/blob/filepath_form_mediator.js
new file mode 100644
index 00000000000..c26b8ea842b
--- /dev/null
+++ b/app/assets/javascripts/blob/filepath_form_mediator.js
@@ -0,0 +1,105 @@
+import $ from 'jquery';
+
+import Api from '~/api';
+import initPopover from '~/blob/suggest_gitlab_ci_yml';
+import { createAlert } from '~/alert';
+import { __, sprintf } from '~/locale';
+import toast from '~/vue_shared/plugins/global_toast';
+import mountFilepathForm from '~/blob/filepath_form';
+
+export default class FilepathFormMediator {
+ constructor({ editor, currentAction, projectId }) {
+ this.editor = editor;
+ this.currentAction = currentAction;
+ this.projectId = projectId;
+
+ this.initFilepathForm();
+ this.initDomElements();
+ this.cacheFileContents();
+ }
+
+ initFilepathForm() {
+ const handleTemplateSelect = ({ template, type, clearSelectedTemplate, stopLoading }) => {
+ this.selectTemplateFile(template, type, clearSelectedTemplate, stopLoading);
+ };
+ mountFilepathForm({ action: this.currentAction, onTemplateSelected: handleTemplateSelect });
+ }
+
+ initDomElements() {
+ const $fileEditor = $('.file-editor');
+
+ this.$filenameInput = $fileEditor.find('.js-file-path-name-input');
+ }
+
+ selectTemplateFile(template, type, clearSelectedTemplate, stopLoading) {
+ const self = this;
+ const suggestCommitChanges = document.querySelector('.js-suggest-gitlab-ci-yml-commit-changes');
+
+ this.fetchFileTemplate(type.type, template.key, template)
+ .then((file) => {
+ this.setEditorContent(file);
+ this.setFilename(type.name);
+
+ toast(sprintf(__('%{templateType} template applied'), { templateType: template.key }), {
+ action: {
+ text: __('Undo'),
+ onClick: (e, toastObj) => {
+ clearSelectedTemplate();
+ self.restoreFromCache();
+ toastObj.hide();
+ },
+ },
+ });
+
+ if (suggestCommitChanges) {
+ initPopover(suggestCommitChanges);
+ }
+ })
+ .catch((err) =>
+ createAlert({
+ message: sprintf(__('An error occurred while fetching the template: %{err}'), { err }),
+ }),
+ )
+ .finally(() => stopLoading());
+ }
+
+ fetchFileTemplate(type, query, data = {}) {
+ return new Promise((resolve) => {
+ const resolveFile = (file) => resolve(file);
+
+ Api.projectTemplate(this.projectId, type, query, data, resolveFile);
+ });
+ }
+
+ setEditorContent(file) {
+ if (!file && file !== '') return;
+
+ const newValue = file.content || file;
+
+ this.editor.setValue(newValue, 1);
+
+ this.editor.focus();
+
+ this.editor.navigateFileStart();
+ }
+
+ cacheFileContents() {
+ this.cachedContent = this.editor.getValue();
+ }
+
+ restoreFromCache() {
+ this.setEditorContent(this.cachedContent);
+ }
+
+ getFilename() {
+ return this.$filenameInput.val();
+ }
+
+ setFilename(name) {
+ const input = this.$filenameInput.get(0);
+ if (name !== undefined && input.value !== name) {
+ input.value = name;
+ input.dispatchEvent(new Event('input'));
+ }
+ }
+}
diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/legacy_template_selector.js
index 59b7f82c10e..b712cc63fc9 100644
--- a/app/assets/javascripts/blob/template_selector.js
+++ b/app/assets/javascripts/blob/legacy_template_selector.js
@@ -2,7 +2,7 @@ import $ from 'jquery';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
-export default class TemplateSelector {
+export default class LegacyTemplateSelector {
constructor({ dropdown, data, pattern, wrapper, editor, $input } = {}) {
this.pattern = pattern;
this.editor = editor;
diff --git a/app/assets/javascripts/blob/line_highlighter.js b/app/assets/javascripts/blob/line_highlighter.js
index 1ec204b4034..4258d16b69f 100644
--- a/app/assets/javascripts/blob/line_highlighter.js
+++ b/app/assets/javascripts/blob/line_highlighter.js
@@ -153,7 +153,7 @@ LineHighlighter.prototype.highlightRange = function (range) {
const results = [];
const ref = range[0] <= range[1] ? range : range.reverse();
- for (let lineNumber = ref[0]; lineNumber <= ref[1]; lineNumber += 1) {
+ for (let lineNumber = range[0]; lineNumber <= ref[1]; lineNumber += 1) {
results.push(this.highlightLine(lineNumber));
}
diff --git a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue
index e0b0857f7b4..8d37d272e50 100644
--- a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue
+++ b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue
@@ -23,7 +23,9 @@ const popoverStates = {
),
},
};
+
export default {
+ name: 'SuggestGitlabCiYml',
dismissTrackValue: 10,
clickTrackValue: 'click_button',
components: {
diff --git a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js
deleted file mode 100644
index 0cdfd153675..00000000000
--- a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import FileTemplateSelector from '../file_template_selector';
-
-export default class BlobCiYamlSelector extends FileTemplateSelector {
- constructor({ mediator }) {
- super(mediator);
- this.config = {
- key: 'gitlab-ci-yaml',
- name: '.gitlab-ci.yml',
- pattern: /(.gitlab-ci.yml)/,
- type: 'gitlab_ci_ymls',
- dropdown: '.js-gitlab-ci-yml-selector',
- wrapper: '.js-gitlab-ci-yml-selector-wrap',
- };
- }
-
- initDropdown() {
- // maybe move to super class as well
- initDeprecatedJQueryDropdown(this.$dropdown, {
- data: this.$dropdown.data('data'),
- filterable: true,
- selectable: true,
- search: {
- fields: ['name'],
- },
- clicked: (options) => this.reportSelectionName(options),
- text: (item) => item.name,
- });
- }
-}
diff --git a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
deleted file mode 100644
index b48b3d6bec3..00000000000
--- a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { __ } from '~/locale';
-import FileTemplateSelector from '../file_template_selector';
-
-export default class DockerfileSelector extends FileTemplateSelector {
- constructor({ mediator }) {
- super(mediator);
- this.config = {
- key: 'dockerfile',
- name: __('Dockerfile'),
- pattern: /(Dockerfile)/,
- type: 'dockerfiles',
- dropdown: '.js-dockerfile-selector',
- wrapper: '.js-dockerfile-selector-wrap',
- };
- }
-
- initDropdown() {
- // maybe move to super class as well
- initDeprecatedJQueryDropdown(this.$dropdown, {
- data: this.$dropdown.data('data'),
- filterable: true,
- selectable: true,
- search: {
- fields: ['name'],
- },
- clicked: (options) => this.reportSelectionName(options),
- text: (item) => item.name,
- });
- }
-}
diff --git a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js
deleted file mode 100644
index 50a11692e98..00000000000
--- a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import FileTemplateSelector from '../file_template_selector';
-
-export default class BlobGitignoreSelector extends FileTemplateSelector {
- constructor({ mediator }) {
- super(mediator);
- this.config = {
- key: 'gitignore',
- name: '.gitignore',
- pattern: /(.gitignore)/,
- type: 'gitignores',
- dropdown: '.js-gitignore-selector',
- wrapper: '.js-gitignore-selector-wrap',
- };
- }
-
- initDropdown() {
- initDeprecatedJQueryDropdown(this.$dropdown, {
- data: this.$dropdown.data('data'),
- filterable: true,
- selectable: true,
- search: {
- fields: ['name'],
- },
- clicked: (options) => this.reportSelectionName(options),
- text: (item) => item.name,
- });
- }
-}
diff --git a/app/assets/javascripts/blob/template_selectors/license_selector.js b/app/assets/javascripts/blob/template_selectors/license_selector.js
deleted file mode 100644
index e7fabf18ea1..00000000000
--- a/app/assets/javascripts/blob/template_selectors/license_selector.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import FileTemplateSelector from '../file_template_selector';
-
-export default class BlobLicenseSelector extends FileTemplateSelector {
- constructor({ mediator }) {
- super(mediator);
- this.config = {
- key: 'license',
- name: 'LICENSE',
- pattern: /^(.+\/)?(licen[sc]e|copying)($|\.)/i,
- type: 'licenses',
- dropdown: '.js-license-selector',
- wrapper: '.js-license-selector-wrap',
- };
- }
-
- initDropdown() {
- initDeprecatedJQueryDropdown(this.$dropdown, {
- data: this.$dropdown.data('data'),
- filterable: true,
- selectable: true,
- search: {
- fields: ['name'],
- },
- clicked: (options) => {
- const { e } = options;
- const el = options.$el;
- const query = options.selectedObj;
-
- const data = {
- project: this.$dropdown.data('project'),
- fullname: this.$dropdown.data('fullname'),
- source_template_project_id: query.project_id,
- };
-
- this.reportSelection({
- query: query.id,
- el,
- e,
- data,
- });
- },
- text: (item) => item.name,
- });
- }
-}
diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js
index 7e667409556..b3bd23e49f8 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -1,5 +1,4 @@
import $ from 'jquery';
-import initPopover from '~/blob/suggest_gitlab_ci_yml';
import { createAlert } from '~/alert';
import { setCookie } from '~/lib/utils/common_utils';
import Tracking from '~/tracking';
@@ -10,9 +9,6 @@ const initPopovers = () => {
if (suggestEl) {
const commitButton = document.querySelector('#commit-changes');
-
- initPopover(suggestEl);
-
if (commitButton) {
const { dismissKey, humanAccess } = suggestEl.dataset;
const urlParams = new URLSearchParams(window.location.search);
diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js
index f021553ae98..007fbd29e82 100644
--- a/app/assets/javascripts/blob_edit/edit_blob.js
+++ b/app/assets/javascripts/blob_edit/edit_blob.js
@@ -8,7 +8,7 @@ import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown';
import { insertFinalNewline } from '~/lib/utils/text_utility';
-import TemplateSelectorMediator from '../blob/file_template_mediator';
+import FilepathFormMediator from '~/blob/filepath_form_mediator';
import { BLOB_EDITOR_ERROR, BLOB_PREVIEW_ERROR } from './constants';
export default class EditBlob {
@@ -25,7 +25,7 @@ export default class EditBlob {
}
this.initModePanesAndLinks();
- this.initFileSelectors();
+ this.initFilepathForm();
this.initSoftWrap();
this.editor.focus();
}
@@ -56,7 +56,6 @@ export default class EditBlob {
configureMonacoEditor() {
const editorEl = document.getElementById('editor');
- const fileNameEl = document.getElementById('file_path') || document.getElementById('file_name');
const fileContentEl = document.getElementById('file-content');
const form = document.querySelector('.js-edit-blob-form');
@@ -64,7 +63,6 @@ export default class EditBlob {
this.editor = rootEditor.createInstance({
el: editorEl,
- blobPath: fileNameEl.value,
blobContent: editorEl.innerText,
});
this.editor.use([
@@ -73,10 +71,6 @@ export default class EditBlob {
{ definition: FileTemplateExtension },
]);
- fileNameEl.addEventListener('change', () => {
- this.editor.updateModelLanguage(fileNameEl.value);
- });
-
form.addEventListener('submit', () => {
fileContentEl.value = insertFinalNewline(this.editor.getValue());
});
@@ -92,13 +86,22 @@ export default class EditBlob {
});
}
- initFileSelectors() {
+ initFilepathForm() {
const { currentAction, projectId } = this.options;
- this.fileTemplateMediator = new TemplateSelectorMediator({
+ this.filepathFormMediator = new FilepathFormMediator({
currentAction,
editor: this.editor,
projectId,
});
+ this.initFilepathListeners();
+ }
+
+ initFilepathListeners() {
+ const fileNameEl = document.getElementById('file_path') || document.getElementById('file_name');
+ this.editor.updateModelLanguage(fileNameEl.value);
+ fileNameEl.addEventListener('input', () => {
+ this.editor.updateModelLanguage(fileNameEl.value);
+ });
}
initModePanesAndLinks() {
diff --git a/app/assets/javascripts/boards/components/board_add_new_column.vue b/app/assets/javascripts/boards/components/board_add_new_column.vue
index 985b9798b36..f103feecab2 100644
--- a/app/assets/javascripts/boards/components/board_add_new_column.vue
+++ b/app/assets/javascripts/boards/components/board_add_new_column.vue
@@ -7,12 +7,14 @@ import {
GlCollapsibleListbox,
GlIcon,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import { createListMutations, listsQuery, BoardType, ListType } from 'ee_else_ce/boards/constants';
import boardLabelsQuery from '../graphql/board_labels.query.graphql';
+import { setError } from '../graphql/cache_updates';
import { getListByTypeId } from '../boards_util';
export default {
@@ -70,6 +72,12 @@ export default {
skip() {
return !this.isApolloBoard;
},
+ error(error) {
+ setError({
+ error,
+ message: s__('Boards|An error occurred while fetching labels. Please try again.'),
+ });
+ },
},
},
computed: {
@@ -102,36 +110,43 @@ export default {
},
methods: {
...mapActions(['createList', 'fetchLabels', 'highlightList']),
- createListApollo({ labelId }) {
- return this.$apollo.mutate({
- mutation: createListMutations[this.issuableType].mutation,
- variables: {
- labelId,
- boardId: this.boardId,
- },
- update: (
- store,
- {
- data: {
- boardListCreate: { list },
+ async createListApollo({ labelId }) {
+ try {
+ await this.$apollo.mutate({
+ mutation: createListMutations[this.issuableType].mutation,
+ variables: {
+ labelId,
+ boardId: this.boardId,
+ },
+ update: (
+ store,
+ {
+ data: {
+ boardListCreate: { list },
+ },
},
+ ) => {
+ const sourceData = store.readQuery({
+ query: listsQuery[this.issuableType].query,
+ variables: this.listQueryVariables,
+ });
+ const data = produce(sourceData, (draftData) => {
+ draftData[this.boardType].board.lists.nodes.push(list);
+ });
+ store.writeQuery({
+ query: listsQuery[this.issuableType].query,
+ variables: this.listQueryVariables,
+ data,
+ });
+ this.$emit('highlight-list', list.id);
},
- ) => {
- const sourceData = store.readQuery({
- query: listsQuery[this.issuableType].query,
- variables: this.listQueryVariables,
- });
- const data = produce(sourceData, (draftData) => {
- draftData[this.boardType].board.lists.nodes.push(list);
- });
- store.writeQuery({
- query: listsQuery[this.issuableType].query,
- variables: this.listQueryVariables,
- data,
- });
- this.$emit('highlight-list', list.id);
- },
- });
+ });
+ } catch (error) {
+ setError({
+ error,
+ message: s__('Boards|An error occurred while creating the list. Please try again.'),
+ });
+ }
},
addList() {
if (!this.selectedLabel) {
diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue
index ca8299ddf80..1cfa35ffd91 100644
--- a/app/assets/javascripts/boards/components/board_app.vue
+++ b/app/assets/javascripts/boards/components/board_app.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import { refreshCurrentPage, queryToObject } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
@@ -6,10 +7,11 @@ import BoardContent from '~/boards/components/board_content.vue';
import BoardSettingsSidebar from '~/boards/components/board_settings_sidebar.vue';
import BoardTopBar from '~/boards/components/board_top_bar.vue';
import eventHub from '~/boards/eventhub';
-import { listsQuery } from 'ee_else_ce/boards/constants';
-import { formatBoardLists } from 'ee_else_ce/boards/boards_util';
+import { listsQuery, FilterFields } from 'ee_else_ce/boards/constants';
+import { formatBoardLists, filterVariables, FiltersInfo } from 'ee_else_ce/boards/boards_util';
import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql';
import errorQuery from '../graphql/client/error.query.graphql';
+import { setError } from '../graphql/cache_updates';
export default {
i18n: {
@@ -34,12 +36,12 @@ export default {
],
data() {
return {
+ boardListsApollo: {},
activeListId: '',
boardId: this.initialBoardId,
filterParams: { ...this.initialFilterParams },
addColumnFormVisible: false,
isShowingEpicsSwimlanes: Boolean(queryToObject(window.location.search).group_by),
- apolloError: null,
error: null,
};
},
@@ -74,8 +76,11 @@ export default {
const { lists } = data[this.boardType].board;
return formatBoardLists(lists);
},
- error() {
- this.apolloError = this.$options.i18n.fetchError;
+ error(error) {
+ setError({
+ error,
+ message: this.$options.i18n.fetchError,
+ });
},
},
error: {
@@ -87,7 +92,6 @@ export default {
computed: {
...mapGetters(['isSidebarOpen']),
listQueryVariables() {
- if (this.filterParams.groupBy) delete this.filterParams.groupBy;
return {
...(this.isIssueBoard && {
isGroup: this.isGroupBoard,
@@ -95,7 +99,7 @@ export default {
}),
fullPath: this.fullPath,
boardId: this.boardId,
- filters: this.filterParams,
+ filters: this.formattedFilterParams,
};
},
isSwimlanesOn() {
@@ -110,6 +114,15 @@ export default {
activeList() {
return this.activeListId ? this.boardListsApollo[this.activeListId] : undefined;
},
+ formattedFilterParams() {
+ if (this.filterParams.groupBy) delete this.filterParams.groupBy;
+ return filterVariables({
+ filters: this.filterParams,
+ issuableType: this.issuableType,
+ filterInfo: FiltersInfo,
+ filterFields: FilterFields,
+ });
+ },
},
created() {
window.addEventListener('popstate', refreshCurrentPage);
@@ -132,7 +145,6 @@ export default {
},
setFilters(filters) {
const filterParams = { ...filters };
- if (filterParams.groupBy) delete filterParams.groupBy;
this.filterParams = filterParams;
},
},
@@ -151,13 +163,12 @@ export default {
@toggleSwimlanes="isShowingEpicsSwimlanes = $event"
/>
<board-content
- v-if="!isApolloBoard || boardListsApollo"
:board-id="boardId"
:add-column-form-visible="addColumnFormVisible"
:is-swimlanes-on="isSwimlanesOn"
- :filter-params="filterParams"
+ :filter-params="formattedFilterParams"
:board-lists-apollo="boardListsApollo"
- :apollo-error="apolloError || error"
+ :apollo-error="error"
:list-query-variables="listQueryVariables"
@setActiveList="setActiveId"
@setAddColumnFormVisibility="addColumnFormVisible = $event"
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue
index 18495f285da..05865dc7305 100644
--- a/app/assets/javascripts/boards/components/board_card.vue
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import Tracking from '~/tracking';
import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql';
@@ -113,8 +114,8 @@ export default {
this.$apollo.mutate({
mutation: setActiveBoardItemMutation,
variables: {
- boardItem: this.item,
- isIssue: this.isIssueBoard,
+ boardItem: this.isActive ? null : this.item,
+ isIssue: this.isActive ? undefined : this.isIssueBoard,
},
});
},
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index 6036f0c359c..692ca6bf59b 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -8,6 +8,7 @@ import {
GlSprintf,
} from '@gitlab/ui';
import { sortBy } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner';
import { isScopedLabel } from '~/lib/utils/common_utils';
@@ -306,7 +307,7 @@ export default {
</span>
<span class="board-info-items gl-mt-3 gl-display-inline-block">
<span v-if="shouldRenderEpicCountables" data-testid="epic-countables">
- <gl-tooltip :target="() => $refs.countBadge" data-testid="epic-countables-tooltip">
+ <gl-tooltip :target="() => $refs.countBadge">
<p v-if="allowSubEpics" class="gl-font-weight-bold gl-m-0">
{{ __('Epics') }} &#8226;
<span class="gl-font-weight-normal">
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
index 19eddbfdd68..8034819732a 100644
--- a/app/assets/javascripts/boards/components/board_card_move_to_position.vue
+++ b/app/assets/javascripts/boards/components/board_card_move_to_position.vue
@@ -1,5 +1,6 @@
<script>
import { GlDisclosureDropdown } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import Tracking from '~/tracking';
import {
diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue
index 2ee0b4593d6..bcd7db8dcb4 100644
--- a/app/assets/javascripts/boards/components/board_column.vue
+++ b/app/assets/javascripts/boards/components/board_column.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapGetters, mapActions, mapState } from 'vuex';
import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue';
import { isListDraggable } from '../boards_util';
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index 14c781f588f..3c2659b00c9 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -3,8 +3,10 @@ import { GlAlert } from '@gitlab/ui';
import { sortBy } from 'lodash';
import produce from 'immer';
import Draggable from 'vuedraggable';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue';
+import { s__ } from '~/locale';
import { defaultSortableOptions } from '~/sortable/constants';
import {
DraggableItemTypes,
@@ -13,6 +15,7 @@ import {
updateListQueries,
} from 'ee_else_ce/boards/constants';
import { calculateNewPosition } from 'ee_else_ce/boards/boards_util';
+import { setError } from '../graphql/cache_updates';
import BoardColumn from './board_column.vue';
export default {
@@ -122,7 +125,14 @@ export default {
this.highlightedLists = this.highlightedLists.filter((id) => id !== listId);
}, flashAnimationDuration);
},
- updateListPosition({
+ dismissError() {
+ if (this.isApolloBoard) {
+ setError({ message: null, captureError: false });
+ } else {
+ this.unsetError();
+ }
+ },
+ async updateListPosition({
item: {
dataset: { listId: movedListId, draggableItemType },
},
@@ -153,7 +163,7 @@ export default {
const targetPosition = this.boardListsById[displacedListId].position;
try {
- this.$apollo.mutate({
+ await this.$apollo.mutate({
mutation: updateListQueries[this.issuableType].mutation,
variables: {
listId: movedListId,
@@ -195,8 +205,11 @@ export default {
},
},
});
- } catch {
- // handle error
+ } catch (error) {
+ setError({
+ error,
+ message: s__('Boards|An error occurred while moving the list. Please try again.'),
+ });
}
},
},
@@ -209,7 +222,7 @@ export default {
data-qa-selector="boards_list"
class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-min-h-0"
>
- <gl-alert v-if="errorToDisplay" variant="danger" :dismissible="true" @dismiss="unsetError">
+ <gl-alert v-if="errorToDisplay" variant="danger" :dismissible="true" @dismiss="dismissError">
{{ errorToDisplay }}
</gl-alert>
<component
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
index 1b97214ff8b..5e1e46dd198 100644
--- a/app/assets/javascripts/boards/components/board_content_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -1,12 +1,13 @@
<script>
import { GlDrawer } from '@gitlab/ui';
import { MountingPortal } from 'portal-vue';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions, mapGetters } from 'vuex';
import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue';
import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql';
import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql';
-import { __, sprintf } from '~/locale';
-import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue';
+import { __, s__, sprintf } from '~/locale';
+import SidebarTimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
import { INCIDENT } from '~/boards/constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
@@ -18,6 +19,7 @@ import SidebarSeverityWidget from '~/sidebar/components/severity/sidebar_severit
import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
import SidebarLabelsWidget from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue';
+import { setError } from '../graphql/cache_updates';
export default {
components: {
@@ -26,7 +28,7 @@ export default {
SidebarAssigneesWidget,
SidebarDateWidget,
SidebarConfidentialityWidget,
- BoardSidebarTimeTracker,
+ SidebarTimeTracker,
SidebarLabelsWidget,
SidebarSubscriptionsWidget,
SidebarDropdownWidget,
@@ -74,6 +76,9 @@ export default {
isApolloBoard: {
default: false,
},
+ timeTrackingLimitToHours: {
+ default: false,
+ },
},
inheritAttrs: false,
apollo: {
@@ -94,6 +99,12 @@ export default {
skip() {
return !this.isApolloBoard;
},
+ error(error) {
+ setError({
+ error,
+ message: s__('Boards|An error occurred while selecting the card. Please try again.'),
+ });
+ },
},
},
computed: {
@@ -250,7 +261,15 @@ export default {
data-testid="iteration-edit"
/>
</div>
- <board-sidebar-time-tracker />
+ <sidebar-time-tracker
+ :can-add-time-entries="canUpdate"
+ :can-set-time-estimate="canUpdate"
+ :full-path="projectPathForActiveIssue"
+ :issuable-id="activeBoardIssuable.id"
+ :issuable-iid="activeBoardIssuable.iid"
+ :limit-to-hours="timeTrackingLimitToHours"
+ :show-collapsed="false"
+ />
<sidebar-date-widget
:iid="activeBoardIssuable.iid"
:full-path="projectPathForActiveIssue"
diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue
index b5d3613ca27..91dd5c81f77 100644
--- a/app/assets/javascripts/boards/components/board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/board_filtered_search.vue
@@ -1,5 +1,6 @@
<script>
import { pickBy, isEmpty, mapValues } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import { getIdFromGraphQLId, isGid, convertToGraphQLId } from '~/graphql_shared/utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
@@ -342,7 +343,8 @@ export default {
);
},
formattedFilterParams() {
- const filtersCopy = { ...this.filterParams };
+ const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true });
+ const filtersCopy = convertObjectPropsToCamelCase(rawFilterParams, {});
if (this.filterParams?.iterationId) {
filtersCopy.iterationId = convertToGraphQLId(
TYPENAME_ITERATION,
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index 9ea801dc9a2..4986c3780e5 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -1,5 +1,6 @@
<script>
import { GlModal, GlAlert } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { visitUrl, updateHistory, getParameterByName } from '~/lib/utils/url_utility';
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index b4249c63b4d..67bfcfb9d97 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -1,13 +1,15 @@
<script>
import { GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
import Draggable from 'vuedraggable';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import { STATUS_CLOSED } from '~/issues/constants';
-import { sprintf, __ } from '~/locale';
+import { sprintf, __, s__ } from '~/locale';
import { defaultSortableOptions } from '~/sortable/constants';
import { sortableStart, sortableEnd } from '~/sortable/utils';
import Tracking from '~/tracking';
import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql';
+import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql';
import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
@@ -49,6 +51,7 @@ export default {
mixins: [Tracking.mixin(), glFeatureFlagMixin()],
inject: [
'isEpicBoard',
+ 'isIssueBoard',
'isGroupBoard',
'disabled',
'fullPath',
@@ -122,6 +125,12 @@ export default {
context: {
isSingleRequest: true,
},
+ error(error) {
+ setError({
+ error,
+ message: s__('Boards|An error occurred while fetching a list. Please try again.'),
+ });
+ },
},
toList: {
query() {
@@ -142,8 +151,16 @@ export default {
context: {
isSingleRequest: true,
},
- error() {
- // handle error
+ error(error) {
+ setError({
+ error,
+ message: sprintf(
+ s__('Boards|An error occurred while moving the %{issuableType}. Please try again.'),
+ {
+ issuableType: this.isEpicBoard ? 'epic' : 'issue',
+ },
+ ),
+ });
},
},
},
@@ -442,8 +459,16 @@ export default {
},
},
});
- } catch {
- // handle error
+ } catch (error) {
+ setError({
+ error,
+ message: sprintf(
+ s__('Boards|An error occurred while moving the %{issuableType}. Please try again.'),
+ {
+ issuableType: this.isEpicBoard ? 'epic' : 'issue',
+ },
+ ),
+ });
}
},
updateCacheAfterMovingItem({ issuableMoveList, fromListId, toListId, newIndex, cache }) {
@@ -494,56 +519,69 @@ export default {
});
}
},
- moveToPosition(positionInList, oldIndex, item) {
- this.$apollo.mutate({
- mutation: listIssuablesQueries[this.issuableType].moveMutation,
- variables: {
- ...moveItemVariables({
- iid: item.iid,
- epicId: item.id,
- fromListId: this.currentList.id,
- toListId: this.currentList.id,
- isIssue: !this.isEpicBoard,
- boardId: this.boardId,
- itemToMove: item,
- }),
- positionInList,
- withColor: this.isEpicBoard && this.glFeatures.epicColorHighlight,
- },
- optimisticResponse: {
- issuableMoveList: {
- issuable: item,
- errors: [],
+ async moveToPosition(positionInList, oldIndex, item) {
+ try {
+ await this.$apollo.mutate({
+ mutation: listIssuablesQueries[this.issuableType].moveMutation,
+ variables: {
+ ...moveItemVariables({
+ iid: item.iid,
+ epicId: item.id,
+ fromListId: this.currentList.id,
+ toListId: this.currentList.id,
+ isIssue: !this.isEpicBoard,
+ boardId: this.boardId,
+ itemToMove: item,
+ }),
+ positionInList,
+ withColor: this.isEpicBoard && this.glFeatures.epicColorHighlight,
},
- },
- update: (cache, { data: { issuableMoveList } }) => {
- const { issuable } = issuableMoveList;
- removeItemFromList({
- query: listIssuablesQueries[this.issuableType].query,
- variables: { ...this.listQueryVariables, id: this.currentList.id },
- boardType: this.boardType,
- id: issuable.id,
- issuableType: this.issuableType,
- cache,
- });
- if (positionInList === 0 || this.listItemsCount <= this.boardListItems.length) {
- const newIndex = positionInList === 0 ? 0 : this.boardListItems.length - 1;
- addItemToList({
+ optimisticResponse: {
+ issuableMoveList: {
+ issuable: item,
+ errors: [],
+ },
+ },
+ update: (cache, { data: { issuableMoveList } }) => {
+ const { issuable } = issuableMoveList;
+ removeItemFromList({
query: listIssuablesQueries[this.issuableType].query,
variables: { ...this.listQueryVariables, id: this.currentList.id },
- issuable,
- newIndex,
boardType: this.boardType,
+ id: issuable.id,
issuableType: this.issuableType,
cache,
});
- }
- },
- });
+ if (positionInList === 0 || this.listItemsCount <= this.boardListItems.length) {
+ const newIndex = positionInList === 0 ? 0 : this.boardListItems.length - 1;
+ addItemToList({
+ query: listIssuablesQueries[this.issuableType].query,
+ variables: { ...this.listQueryVariables, id: this.currentList.id },
+ issuable,
+ newIndex,
+ boardType: this.boardType,
+ issuableType: this.issuableType,
+ cache,
+ });
+ }
+ },
+ });
+ } catch (error) {
+ setError({
+ error,
+ message: sprintf(
+ s__('Boards|An error occurred while moving the %{issuableType}. Please try again.'),
+ {
+ issuableType: this.isEpicBoard ? 'epic' : 'issue',
+ },
+ ),
+ });
+ }
},
async addListItem(input) {
this.toggleForm();
this.addItemToListInProgress = true;
+ let issuable;
try {
await this.$apollo.mutate({
mutation: listIssuablesQueries[this.issuableType].createMutation,
@@ -552,7 +590,7 @@ export default {
withColor: this.isEpicBoard && this.glFeatures.epicColorHighlight,
},
update: (cache, { data: { createIssuable } }) => {
- const { issuable } = createIssuable;
+ issuable = createIssuable.issuable;
addItemToList({
query: listIssuablesQueries[this.issuableType].query,
variables: { ...this.listQueryVariables, id: this.currentList.id },
@@ -583,7 +621,7 @@ export default {
} catch (error) {
setError({
message: sprintf(
- __('An error occurred while creating the %{issuableType}. Please try again.'),
+ s__('Boards|An error occurred while creating the %{issuableType}. Please try again.'),
{
issuableType: this.isEpicBoard ? 'epic' : 'issue',
},
@@ -592,6 +630,13 @@ export default {
});
} finally {
this.addItemToListInProgress = false;
+ this.$apollo.mutate({
+ mutation: setActiveBoardItemMutation,
+ variables: {
+ boardItem: issuable,
+ isIssue: this.isIssueBoard,
+ },
+ });
}
},
},
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index 8db86d0e894..068db98a750 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -8,9 +8,11 @@ import {
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import { isListDraggable } from '~/boards/boards_util';
import { isScopedLabel, parseBoolean } from '~/lib/utils/common_utils';
+import { fetchPolicies } from '~/lib/graphql';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { n__, s__ } from '~/locale';
import sidebarEventHub from '~/sidebar/event_hub';
@@ -18,7 +20,6 @@ import Tracking from '~/tracking';
import { TYPE_ISSUE } from '~/issues/constants';
import { formatDate } from '~/lib/utils/datetime_utility';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql';
import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql';
import AccessorUtilities from '~/lib/utils/accessor';
import {
@@ -28,8 +29,10 @@ import {
toggleFormEventPrefix,
updateListQueries,
toggleCollapsedMutations,
+ listsDeferredQuery,
} from 'ee_else_ce/boards/constants';
import eventHub from '../eventhub';
+import { setError } from '../graphql/cache_updates';
import ItemCount from './item_count.vue';
export default {
@@ -39,6 +42,9 @@ export default {
listSettings: s__('Boards|Edit list settings'),
expand: s__('Boards|Expand'),
collapse: s__('Boards|Collapse'),
+ fetchError: s__(
+ "Boards|An error occurred while fetching list's information. Please try again.",
+ ),
},
components: {
GlButton,
@@ -184,8 +190,16 @@ export default {
userCanDrag() {
return !this.disabled && isListDraggable(this.list);
},
+ // due to the issues with cache-and-network, we need this hack to check if there is any data for the query in the cache.
+ // if we have cached data, we disregard the loading state
isLoading() {
- return this.$apollo.queries.boardList.loading;
+ return (
+ this.$apollo.queries.boardList.loading &&
+ !this.$apollo.provider.clients.defaultClient.readQuery({
+ query: listsDeferredQuery[this.issuableType].query,
+ variables: this.countQueryVariables,
+ })
+ );
},
totalWeight() {
return this.boardList?.totalWeight;
@@ -193,19 +207,31 @@ export default {
canShowTotalWeight() {
return this.weightFeatureAvailable && !this.isLoading;
},
+ countQueryVariables() {
+ return {
+ id: this.list.id,
+ filters: this.filterParams,
+ };
+ },
},
apollo: {
boardList: {
- query: listQuery,
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
+ query() {
+ return listsDeferredQuery[this.issuableType].query;
+ },
variables() {
- return {
- id: this.list.id,
- filters: this.filterParams,
- };
+ return this.countQueryVariables;
},
context: {
isSingleRequest: true,
},
+ error(error) {
+ setError({
+ error,
+ message: this.$options.i18n.fetchError,
+ });
+ },
},
},
created() {
@@ -293,8 +319,11 @@ export default {
},
},
});
- } catch {
- this.$emit('error');
+ } catch (error) {
+ setError({
+ error,
+ message: s__('Boards|An error occurred while updating the list. Please try again.'),
+ });
}
} else {
this.updateList({ listId: this.list.id, collapsed });
diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue
index b68444fb011..d78b60e91a8 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.vue
+++ b/app/assets/javascripts/boards/components/board_new_issue.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters } from 'vuex';
import { s__ } from '~/locale';
import { getMilestone, formatIssueInput, getBoardQuery } from 'ee_else_ce/boards/boards_util';
diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
index 0f43aae3936..58db2c9ac2a 100644
--- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
@@ -2,6 +2,7 @@
import produce from 'immer';
import { GlButton, GlDrawer, GlLabel, GlModal, GlModalDirective } from '@gitlab/ui';
import { MountingPortal } from 'portal-vue';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState, mapGetters } from 'vuex';
import {
LIST,
@@ -11,10 +12,11 @@ import {
deleteListQueries,
} from 'ee_else_ce/boards/constants';
import { isScopedLabel } from '~/lib/utils/common_utils';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import eventHub from '~/sidebar/event_hub';
import Tracking from '~/tracking';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { setError } from '../graphql/cache_updates';
export default {
listSettingsText: __('List settings'),
@@ -131,23 +133,30 @@ export default {
}
},
async deleteList(listId) {
- await this.$apollo.mutate({
- mutation: deleteListQueries[this.issuableType].mutation,
- variables: {
- listId,
- },
- update: (store) => {
- store.updateQuery(
- { query: listsQuery[this.issuableType].query, variables: this.queryVariables },
- (sourceData) =>
- produce(sourceData, (draftData) => {
- draftData[this.boardType].board.lists.nodes = draftData[
- this.boardType
- ].board.lists.nodes.filter((list) => list.id !== listId);
- }),
- );
- },
- });
+ try {
+ await this.$apollo.mutate({
+ mutation: deleteListQueries[this.issuableType].mutation,
+ variables: {
+ listId,
+ },
+ update: (store) => {
+ store.updateQuery(
+ { query: listsQuery[this.issuableType].query, variables: this.queryVariables },
+ (sourceData) =>
+ produce(sourceData, (draftData) => {
+ draftData[this.boardType].board.lists.nodes = draftData[
+ this.boardType
+ ].board.lists.nodes.filter((list) => list.id !== listId);
+ }),
+ );
+ },
+ });
+ } catch (error) {
+ setError({
+ error,
+ message: s__('Boards|An error occurred while deleting the list. Please try again.'),
+ });
+ }
},
},
};
diff --git a/app/assets/javascripts/boards/components/board_top_bar.vue b/app/assets/javascripts/boards/components/board_top_bar.vue
index fd9043a561f..2b8418333a8 100644
--- a/app/assets/javascripts/boards/components/board_top_bar.vue
+++ b/app/assets/javascripts/boards/components/board_top_bar.vue
@@ -1,8 +1,10 @@
<script>
import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue';
+import { s__ } from '~/locale';
import BoardsSelector from 'ee_else_ce/boards/components/boards_selector.vue';
import IssueBoardFilteredSearch from 'ee_else_ce/boards/components/issue_board_filtered_search.vue';
import { getBoardQuery } from 'ee_else_ce/boards/boards_util';
+import { setError } from '../graphql/cache_updates';
import ConfigToggle from './config_toggle.vue';
import NewBoardButton from './new_board_button.vue';
import ToggleFocus from './toggle_focus.vue';
@@ -70,6 +72,12 @@ export default {
labels: board.labels?.nodes,
};
},
+ error(error) {
+ setError({
+ error,
+ message: s__('Boards|An error occurred while fetching board details. Please try again.'),
+ });
+ },
},
},
computed: {
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index fddb58c45fe..b3fe52944dc 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -10,6 +10,7 @@ import {
} from '@gitlab/ui';
import { produce } from 'immer';
import { throttle } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import BoardForm from 'ee_else_ce/boards/components/board_form.vue';
@@ -24,12 +25,16 @@ import groupBoardsQuery from '../graphql/group_boards.query.graphql';
import projectBoardsQuery from '../graphql/project_boards.query.graphql';
import groupRecentBoardsQuery from '../graphql/group_recent_boards.query.graphql';
import projectRecentBoardsQuery from '../graphql/project_recent_boards.query.graphql';
+import { setError } from '../graphql/cache_updates';
import { fullBoardId } from '../boards_util';
const MIN_BOARDS_TO_VIEW_RECENT = 10;
export default {
name: 'BoardsSelector',
+ i18n: {
+ fetchBoardsError: s__('Boards|An error occurred while fetching boards. Please try again.'),
+ },
components: {
BoardForm,
GlLoadingIcon,
@@ -90,9 +95,12 @@ export default {
parentType() {
return this.boardType;
},
- boardQuery() {
+ issueBoardsQuery() {
return this.isGroupBoard ? groupBoardsQuery : projectBoardsQuery;
},
+ boardsQuery() {
+ return this.issueBoardsQuery;
+ },
loading() {
return this.loadingRecentBoards || this.loadingBoards;
},
@@ -143,7 +151,7 @@ export default {
eventHub.$off('showBoardModal', this.showPage);
},
methods: {
- ...mapActions(['setError', 'fetchBoard', 'unsetActiveId']),
+ ...mapActions(['fetchBoard', 'unsetActiveId']),
fullBoardId(boardId) {
return fullBoardId(boardId);
},
@@ -157,7 +165,7 @@ export default {
if (!data?.[this.parentType]) {
return [];
}
- return data[this.parentType][boardType].edges.map(({ node }) => ({
+ return data[this.parentType][boardType].nodes.map((node) => ({
id: getIdFromGraphQLId(node.id),
name: node.name,
}));
@@ -174,11 +182,17 @@ export default {
variables() {
return { fullPath: this.fullPath };
},
- query: this.boardQuery,
+ query: this.boardsQuery,
update: (data) => this.boardUpdate(data, 'boards'),
watchLoading: (isLoading) => {
this.loadingBoards = isLoading;
},
+ error(error) {
+ setError({
+ error,
+ message: this.$options.i18n.fetchBoardsError,
+ });
+ },
});
this.loadRecentBoards();
@@ -193,25 +207,33 @@ export default {
watchLoading: (isLoading) => {
this.loadingRecentBoards = isLoading;
},
+ error(error) {
+ setError({
+ error,
+ message: s__(
+ 'Boards|An error occurred while fetching recent boards. Please try again.',
+ ),
+ });
+ },
});
},
addBoard(board) {
const { defaultClient: store } = this.$apollo.provider.clients;
const sourceData = store.readQuery({
- query: this.boardQuery,
+ query: this.boardsQuery,
variables: { fullPath: this.fullPath },
});
const newData = produce(sourceData, (draftState) => {
- draftState[this.parentType].boards.edges = [
- ...draftState[this.parentType].boards.edges,
- { node: board },
+ draftState[this.parentType].boards.nodes = [
+ ...draftState[this.parentType].boards.nodes,
+ { ...board },
];
});
store.writeQuery({
- query: this.boardQuery,
+ query: this.boardsQuery,
variables: { fullPath: this.fullPath },
data: newData,
});
@@ -267,9 +289,6 @@ export default {
}
},
},
- i18n: {
- errorFetchingBoard: s__('Board|An error occurred while fetching the board, please try again.'),
- },
};
</script>
diff --git a/app/assets/javascripts/boards/components/config_toggle.vue b/app/assets/javascripts/boards/components/config_toggle.vue
index dd3b9472879..bc896932ffc 100644
--- a/app/assets/javascripts/boards/components/config_toggle.vue
+++ b/app/assets/javascripts/boards/components/config_toggle.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import { formType } from '~/boards/constants';
import eventHub from '~/boards/eventhub';
@@ -29,7 +30,7 @@ export default {
return this.canAdminList ? s__('Boards|Edit board') : s__('Boards|View scope');
},
tooltipTitle() {
- return this.hasScope ? __("This board's scope is reduced") : '';
+ return this.hasScope || this.boardHasScope ? __("This board's scope is reduced") : '';
},
},
methods: {
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue
deleted file mode 100644
index b70294c9db3..00000000000
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue
+++ /dev/null
@@ -1,39 +0,0 @@
-<script>
-import { mapGetters } from 'vuex';
-import IssuableTimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
-
-export default {
- components: {
- IssuableTimeTracker,
- },
- inject: ['timeTrackingLimitToHours', 'canUpdate'],
- computed: {
- ...mapGetters(['activeBoardItem']),
- initialTimeTracking() {
- const {
- timeEstimate,
- totalTimeSpent,
- humanTimeEstimate,
- humanTotalTimeSpent,
- } = this.activeBoardItem;
- return {
- timeEstimate,
- totalTimeSpent,
- humanTimeEstimate,
- humanTotalTimeSpent,
- };
- },
- },
-};
-</script>
-
-<template>
- <issuable-time-tracker
- :issuable-id="activeBoardItem.id.toString()"
- :issuable-iid="activeBoardItem.iid.toString()"
- :limit-to-hours="timeTrackingLimitToHours"
- :initial-time-tracking="initialTimeTracking"
- :show-collapsed="false"
- :can-add-time-entries="canUpdate"
- />
-</template>
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue
index 020edcb01b8..1c2c0022ddf 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue
@@ -1,5 +1,6 @@
<script>
import { GlAlert, GlButton, GlForm, GlFormGroup, GlFormInput, GlLink } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters, mapActions } from 'vuex';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import { joinPaths } from '~/lib/utils/url_utility';
diff --git a/app/assets/javascripts/boards/graphql/group_boards.query.graphql b/app/assets/javascripts/boards/graphql/group_boards.query.graphql
index ce9f7bbfd2a..0fb5748abfc 100644
--- a/app/assets/javascripts/boards/graphql/group_boards.query.graphql
+++ b/app/assets/javascripts/boards/graphql/group_boards.query.graphql
@@ -2,11 +2,9 @@ query group_boards($fullPath: ID!) {
group(fullPath: $fullPath) {
id
boards {
- edges {
- node {
- id
- name
- }
+ nodes {
+ 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 b9fe778d4d4..9dbf4528cec 100644
--- a/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql
+++ b/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql
@@ -2,11 +2,9 @@ query group_recent_boards($fullPath: ID!) {
group(fullPath: $fullPath) {
id
recentIssueBoards {
- edges {
- node {
- id
- name
- }
+ nodes {
+ 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 770c246a95b..97a298db246 100644
--- a/app/assets/javascripts/boards/graphql/project_boards.query.graphql
+++ b/app/assets/javascripts/boards/graphql/project_boards.query.graphql
@@ -2,11 +2,9 @@ query project_boards($fullPath: ID!) {
project(fullPath: $fullPath) {
id
boards {
- edges {
- node {
- id
- name
- }
+ nodes {
+ 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 c633107a409..0d3a8616603 100644
--- a/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql
+++ b/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql
@@ -2,11 +2,9 @@ query project_recent_boards($fullPath: ID!) {
project(fullPath: $fullPath) {
id
recentIssueBoards {
- edges {
- node {
- id
- name
- }
+ nodes {
+ id
+ name
}
}
}
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index 67388284d31..a03ec9193ea 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -29,7 +29,7 @@ function mountBoardApp(el) {
const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true });
const initialFilterParams = {
- ...convertObjectPropsToCamelCase(rawFilterParams),
+ ...convertObjectPropsToCamelCase(rawFilterParams, {}),
};
const boardType = el.dataset.parent;
diff --git a/app/assets/javascripts/boards/stores/index.js b/app/assets/javascripts/boards/stores/index.js
index 0a87c6ab821..ee0a5e27d9a 100644
--- a/app/assets/javascripts/boards/stores/index.js
+++ b/app/assets/javascripts/boards/stores/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import actions from 'ee_else_ce/boards/stores/actions';
import getters from 'ee_else_ce/boards/stores/getters';
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue
index 2045b127a82..842d88e1267 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue
@@ -23,15 +23,6 @@ export default {
graphqlId() {
return convertToGraphQLId(TYPENAME_GROUP, this.groupId);
},
- queriesAvailable() {
- if (this.glFeatures.ciGroupEnvScopeGraphql) {
- return this.$options.queryData;
- }
-
- return {
- ciVariables: this.$options.queryData.ciVariables,
- };
- },
},
mutationData: {
[ADD_MUTATION_ACTION]: addGroupVariable,
@@ -59,6 +50,6 @@ export default {
entity="group"
:full-path="groupPath"
:mutation-data="$options.mutationData"
- :query-data="queriesAvailable"
+ :query-data="$options.queryData"
/>
</template>
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue
new file mode 100644
index 00000000000..0ce11da658c
--- /dev/null
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue
@@ -0,0 +1,233 @@
+<script>
+import {
+ GlButton,
+ GlDrawer,
+ GlFormCheckbox,
+ GlFormCombobox,
+ GlFormGroup,
+ GlFormSelect,
+ GlFormTextarea,
+ GlIcon,
+ GlLink,
+ GlSprintf,
+} from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
+import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import {
+ defaultVariableState,
+ ENVIRONMENT_SCOPE_LINK_TITLE,
+ EXPANDED_VARIABLES_NOTE,
+ FLAG_LINK_TITLE,
+ VARIABLE_ACTIONS,
+ variableOptions,
+} from '../constants';
+import CiEnvironmentsDropdown from './ci_environments_dropdown.vue';
+import { awsTokenList } from './ci_variable_autocomplete_tokens';
+
+const i18n = {
+ addVariable: s__('CiVariables|Add Variable'),
+ cancel: __('Cancel'),
+ environments: __('Environments'),
+ environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE,
+ expandedField: s__('CiVariables|Expand variable reference'),
+ expandedDescription: EXPANDED_VARIABLES_NOTE,
+ flags: __('Flags'),
+ flagsLinkTitle: FLAG_LINK_TITLE,
+ key: __('Key'),
+ maskedField: s__('CiVariables|Mask variable'),
+ maskedDescription: s__(
+ 'CiVariables|Variable will be masked in job logs. Requires values to meet regular expression requirements.',
+ ),
+ protectedField: s__('CiVariables|Protect variable'),
+ protectedDescription: s__(
+ 'CiVariables|Export variable to pipelines running on protected branches and tags only.',
+ ),
+ type: __('Type'),
+ value: __('Value'),
+};
+
+export default {
+ DRAWER_Z_INDEX,
+ components: {
+ CiEnvironmentsDropdown,
+ GlButton,
+ GlDrawer,
+ GlFormCheckbox,
+ GlFormCombobox,
+ GlFormGroup,
+ GlFormSelect,
+ GlFormTextarea,
+ GlIcon,
+ GlLink,
+ GlSprintf,
+ },
+ inject: ['environmentScopeLink'],
+ props: {
+ areEnvironmentsLoading: {
+ type: Boolean,
+ required: true,
+ },
+ environments: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ hasEnvScopeQuery: {
+ type: Boolean,
+ required: true,
+ },
+ mode: {
+ type: String,
+ required: true,
+ validator(val) {
+ return VARIABLE_ACTIONS.includes(val);
+ },
+ },
+ },
+ data() {
+ return {
+ key: defaultVariableState.key,
+ variableType: defaultVariableState.variableType,
+ };
+ },
+ computed: {
+ getDrawerHeaderHeight() {
+ return getContentWrapperHeight();
+ },
+ },
+ methods: {
+ close() {
+ this.$emit('close-form');
+ },
+ },
+ awsTokenList,
+ flagLink: helpPagePath('ci/variables/index', {
+ anchor: 'define-a-cicd-variable-in-the-ui',
+ }),
+ i18n,
+ variableOptions,
+};
+</script>
+<template>
+ <gl-drawer
+ open
+ data-testid="ci-variable-drawer"
+ :header-height="getDrawerHeaderHeight"
+ :z-index="$options.DRAWER_Z_INDEX"
+ @close="close"
+ >
+ <template #title>
+ <h2 class="gl-m-0">{{ $options.i18n.addVariable }}</h2>
+ </template>
+ <gl-form-group
+ :label="$options.i18n.type"
+ label-for="ci-variable-type"
+ class="gl-border-none gl-mb-n5"
+ >
+ <gl-form-select
+ id="ci-variable-type"
+ v-model="variableType"
+ :options="$options.variableOptions"
+ />
+ </gl-form-group>
+ <gl-form-group
+ class="gl-border-none gl-mb-n5"
+ label-for="ci-variable-env"
+ data-testid="environment-scope"
+ >
+ <template #label>
+ <div class="gl-display-flex gl-align-items-center">
+ <span class="gl-mr-2">
+ {{ $options.i18n.environments }}
+ </span>
+ <gl-link
+ class="gl-display-flex"
+ :title="$options.i18n.environmentScopeLinkTitle"
+ :href="environmentScopeLink"
+ target="_blank"
+ data-testid="environment-scope-link"
+ >
+ <gl-icon name="question-o" :size="14" />
+ </gl-link>
+ </div>
+ </template>
+ <ci-environments-dropdown
+ class="gl-mb-5"
+ :are-environments-loading="areEnvironmentsLoading"
+ :environments="environments"
+ :has-env-scope-query="hasEnvScopeQuery"
+ selected-environment-scope=""
+ />
+ </gl-form-group>
+ <gl-form-group class="gl-border-none gl-mb-n8">
+ <template #label>
+ <div class="gl-display-flex gl-align-items-center gl-mb-n3">
+ <span class="gl-mr-2">
+ {{ $options.i18n.flags }}
+ </span>
+ <gl-link
+ class="gl-display-flex"
+ :title="$options.i18n.flagsLinkTitle"
+ :href="$options.flagLink"
+ target="_blank"
+ >
+ <gl-icon name="question-o" :size="14" />
+ </gl-link>
+ </div>
+ </template>
+ <gl-form-checkbox data-testid="ci-variable-protected-checkbox">
+ {{ $options.i18n.protectedField }}
+ <p class="gl-text-secondary">
+ {{ $options.i18n.protectedDescription }}
+ </p>
+ </gl-form-checkbox>
+ <gl-form-checkbox data-testid="ci-variable-masked-checkbox">
+ {{ $options.i18n.maskedField }}
+ <p class="gl-text-secondary">{{ $options.i18n.maskedDescription }}</p>
+ </gl-form-checkbox>
+ <gl-form-checkbox data-testid="ci-variable-expanded-checkbox">
+ {{ $options.i18n.expandedField }}
+ <p class="gl-text-secondary">
+ <gl-sprintf :message="$options.i18n.expandedDescription" class="gl-text-secondary">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ </gl-form-checkbox>
+ </gl-form-group>
+ <gl-form-combobox
+ v-model="key"
+ :token-list="$options.awsTokenList"
+ :label-text="$options.i18n.key"
+ class="gl-border-none gl-pb-0! gl-mb-n5"
+ data-testid="pipeline-form-ci-variable-key"
+ data-qa-selector="ci_variable_key_field"
+ />
+ <gl-form-group
+ :label="$options.i18n.value"
+ label-for="ci-variable-value"
+ class="gl-border-none gl-mb-n2"
+ >
+ <gl-form-textarea
+ id="ci-variable-value"
+ class="gl-border-none gl-font-monospace!"
+ rows="3"
+ max-rows="10"
+ data-testid="pipeline-form-ci-variable-value"
+ data-qa-selector="ci_variable_value_field"
+ spellcheck="false"
+ />
+ </gl-form-group>
+ <div class="gl-display-flex gl-justify-content-end">
+ <gl-button category="primary" class="gl-mr-3" data-testid="cancel-button" @click="close"
+ >{{ $options.i18n.cancel }}
+ </gl-button>
+ <gl-button category="primary" variant="confirm" data-testid="confirm-button"
+ >{{ $options.i18n.addVariable }}
+ </gl-button>
+ </div>
+ </gl-drawer>
+</template>
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue
index 3af48635f3f..86c0f34215e 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue
@@ -25,6 +25,7 @@ import {
AWS_TOKEN_CONSTANTS,
ADD_CI_VARIABLE_MODAL_ID,
AWS_TIP_DISMISSED_COOKIE_NAME,
+ AWS_TIP_TITLE,
AWS_TIP_MESSAGE,
CONTAINS_VARIABLE_REFERENCE_MESSAGE,
defaultVariableState,
@@ -62,10 +63,6 @@ export default {
},
mixins: [glFeatureFlagsMixin(), trackingMixin],
inject: [
- 'awsLogoSvgPath',
- 'awsTipCommandsLink',
- 'awsTipDeployLink',
- 'awsTipLearnLink',
'containsVariableReferenceLink',
'environmentScopeLink',
'isProtectedByDefault',
@@ -241,7 +238,7 @@ export default {
this.resetVariableData();
this.resetValidationErrorEvents();
- this.$emit('hideModal');
+ this.$emit('close-form');
},
resetVariableData() {
this.variable = { ...defaultVariableState };
@@ -295,6 +292,7 @@ export default {
},
},
i18n: {
+ awsTipTitle: AWS_TIP_TITLE,
awsTipMessage: AWS_TIP_MESSAGE,
containsVariableReferenceMessage: CONTAINS_VARIABLE_REFERENCE_MESSAGE,
defaultScope: allEnvironments.text,
@@ -305,6 +303,9 @@ export default {
flagLink: helpPagePath('ci/variables/index', {
anchor: 'define-a-cicd-variable-in-the-ui',
}),
+ oidcLink: helpPagePath('ci/cloud_services/index', {
+ anchor: 'oidc-authorization-with-your-cloud-provider',
+ }),
modalId: ADD_CI_VARIABLE_MODAL_ID,
tokens: awsTokens,
tokenList: awsTokenList,
@@ -322,6 +323,23 @@ export default {
@hidden="resetModalHandler"
@shown="onShow"
>
+ <gl-collapse :visible="isTipVisible">
+ <gl-alert
+ :title="$options.i18n.awsTipTitle"
+ variant="warning"
+ class="gl-mb-5"
+ data-testid="aws-guidance-tip"
+ @dismiss="dismissTip"
+ >
+ <gl-sprintf :message="$options.i18n.awsTipMessage">
+ <template #link="{ content }">
+ <gl-link :href="$options.oidcLink">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ </gl-collapse>
<form>
<gl-form-combobox
v-model="variable.key"
@@ -468,45 +486,7 @@ export default {
</gl-form-checkbox>
</gl-form-group>
</form>
- <gl-collapse :visible="isTipVisible">
- <gl-alert
- :title="__('Deploying to AWS is easy with GitLab')"
- variant="tip"
- data-testid="aws-guidance-tip"
- @dismiss="dismissTip"
- >
- <div class="gl-display-flex gl-flex-direction-row gl-flex-wrap gl-md-flex-nowrap gl-gap-3">
- <div>
- <p>
- <gl-sprintf :message="$options.i18n.awsTipMessage">
- <template #deployLink="{ content }">
- <gl-link :href="awsTipDeployLink" target="_blank">{{ content }}</gl-link>
- </template>
- <template #commandsLink="{ content }">
- <gl-link :href="awsTipCommandsLink" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </p>
- <p>
- <gl-button
- :href="awsTipLearnLink"
- target="_blank"
- category="secondary"
- variant="confirm"
- class="gl-overflow-wrap-break"
- >{{ __('Learn more about deploying to AWS') }}</gl-button
- >
- </p>
- </div>
- <img
- class="gl-mt-3"
- :alt="__('Amazon Web Services Logo')"
- :src="awsLogoSvgPath"
- height="32"
- />
- </div>
- </gl-alert>
- </gl-collapse>
+
<gl-alert
v-if="containsVariableReference"
:title="__('Value might contain a variable reference')"
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue
index b8a95f9081a..f4e1da9b34f 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue
@@ -1,13 +1,17 @@
<script>
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { ADD_VARIABLE_ACTION, EDIT_VARIABLE_ACTION, VARIABLE_ACTIONS } from '../constants';
+import CiVariableDrawer from './ci_variable_drawer.vue';
import CiVariableTable from './ci_variable_table.vue';
import CiVariableModal from './ci_variable_modal.vue';
export default {
components: {
+ CiVariableDrawer,
CiVariableTable,
CiVariableModal,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
areEnvironmentsLoading: {
type: Boolean,
@@ -62,23 +66,32 @@ export default {
};
},
computed: {
- showModal() {
+ showForm() {
return VARIABLE_ACTIONS.includes(this.mode);
},
+ useDrawerForm() {
+ return this.glFeatures?.ciVariableDrawer;
+ },
+ showDrawer() {
+ return this.showForm && this.useDrawerForm;
+ },
+ showModal() {
+ return this.showForm && !this.useDrawerForm;
+ },
},
methods: {
addVariable(variable) {
this.$emit('add-variable', variable);
},
+ closeForm() {
+ this.mode = null;
+ },
deleteVariable(variable) {
this.$emit('delete-variable', variable);
},
updateVariable(variable) {
this.$emit('update-variable', variable);
},
- hideModal() {
- this.mode = null;
- },
setSelectedVariable(variable = null) {
if (!variable) {
this.selectedVariable = {};
@@ -104,6 +117,7 @@ export default {
@handle-prev-page="$emit('handle-prev-page')"
@handle-next-page="$emit('handle-next-page')"
@set-selected-variable="setSelectedVariable"
+ @delete-variable="deleteVariable"
@sort-changed="(val) => $emit('sort-changed', val)"
/>
<ci-variable-modal
@@ -118,10 +132,18 @@ export default {
:selected-variable="selectedVariable"
@add-variable="addVariable"
@delete-variable="deleteVariable"
- @hideModal="hideModal"
+ @close-form="closeForm"
@update-variable="updateVariable"
@search-environment-scope="$emit('search-environment-scope', $event)"
/>
+ <ci-variable-drawer
+ v-if="showDrawer"
+ :are-environments-loading="areEnvironmentsLoading"
+ :has-env-scope-query="hasEnvScopeQuery"
+ :mode="mode"
+ v-on="$listeners"
+ @close-form="closeForm"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue
index ec7a921664f..a14cd1e387a 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue
@@ -3,11 +3,14 @@ import {
GlAlert,
GlBadge,
GlButton,
+ GlCard,
+ GlIcon,
GlLoadingIcon,
GlModalDirective,
GlKeysetPagination,
GlLink,
GlTable,
+ GlModal,
GlTooltipDirective,
} from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
@@ -45,9 +48,8 @@ export default {
},
{
key: 'actions',
- label: '',
- tdClass: 'text-right',
- thClass: 'gl-w-5p',
+ label: __('Actions'),
+ thClass: 'gl-text-right',
},
],
inheritedVarsFields: [
@@ -73,10 +75,13 @@ export default {
GlAlert,
GlBadge,
GlButton,
+ GlCard,
GlKeysetPagination,
GlLink,
+ GlIcon,
GlLoadingIcon,
GlTable,
+ GlModal,
},
directives: {
GlModalDirective,
@@ -84,6 +89,14 @@ export default {
},
mixins: [glFeatureFlagsMixin()],
inject: ['isInheritedGroupVars'],
+ i18n: {
+ title: s__('CiVariables|CI/CD Variables'),
+ addButton: s__('CiVariables|Add variable'),
+ editButton: __('Edit'),
+ deleteButton: __('Delete'),
+ modalDeleteTitle: s__('CiVariables|Delete variable'),
+ modalDeleteMessage: s__('CiVariables|Do you want to delete the variable %{key}?'),
+ },
props: {
entity: {
type: String,
@@ -107,6 +120,20 @@ export default {
required: true,
},
},
+ deleteModal: {
+ actionPrimary: {
+ text: __('Delete'),
+ attributes: {
+ variant: 'danger',
+ },
+ },
+ actionSecondary: {
+ text: __('Cancel'),
+ attributes: {
+ variant: 'default',
+ },
+ },
+ },
data() {
return {
areValuesHidden: true,
@@ -165,6 +192,9 @@ export default {
setSelectedVariable(index = -1) {
this.$emit('set-selected-variable', this.variables[index] ?? null);
},
+ deleteSelectedVariable(index = -1) {
+ this.$emit('delete-variable', this.variables[index] ?? null);
+ },
getAttributes(item) {
const attributes = [];
if (item.variableType === variableTypes.fileType) {
@@ -181,188 +211,219 @@ export default {
}
return attributes;
},
+ removeVariableMessage(key) {
+ return sprintf(this.$options.i18n.modalDeleteMessage, {
+ key,
+ });
+ },
},
maximumVariableLimitReached: MAXIMUM_VARIABLE_LIMIT_REACHED,
};
</script>
<template>
- <div class="ci-variable-table" :data-testid="tableDataTestId">
- <gl-loading-icon v-if="isLoading" />
- <gl-alert
- v-if="showAlert"
- :dismissible="false"
- :title="$options.maximumVariableLimitReached"
- variant="info"
- >
- {{ exceedsVariableLimitText }}
- </gl-alert>
- <div
- v-if="showPagination && !isInheritedGroupVars"
- class="ci-variable-actions gl-display-flex gl-justify-content-end gl-my-3"
+ <div>
+ <gl-card
+ class="gl-new-card ci-variable-table"
+ header-class="gl-new-card-header"
+ body-class="gl-new-card-body gl-px-0"
+ :data-testid="tableDataTestId"
>
- <gl-button v-if="!isTableEmpty" @click="toggleHiddenState">{{ valuesButtonText }}</gl-button>
- <gl-button
- v-gl-modal-directive="$options.modalId"
- class="gl-mx-3"
- data-qa-selector="add_ci_variable_button"
- variant="confirm"
- category="primary"
- :aria-label="__('Add')"
- :disabled="exceedsVariableLimit"
- @click="setSelectedVariable()"
- >{{ __('Add variable') }}</gl-button
- >
- </div>
- <gl-table
- v-if="!isLoading"
- :fields="fields"
- :items="variablesWithAttributes"
- tbody-tr-class="js-ci-variable-row"
- sort-by="key"
- sort-direction="asc"
- stacked="lg"
- fixed
- show-empty
- sort-icon-left
- no-sort-reset
- no-local-sorting
- @sort-changed="(val) => $emit('sort-changed', val)"
- >
- <template #table-colgroup="scope">
- <col v-for="field in scope.fields" :key="field.key" :style="field.customStyle" />
- </template>
- <template #cell(key)="{ item }">
- <div
- class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3"
- >
- <span
- :id="`ci-variable-key-${item.id}`"
- class="gl-display-inline-block gl-max-w-full gl-word-break-word"
- >{{ item.key }}</span
- >
+ <template #header>
+ <div class="gl-new-card-title-wrapper">
+ <h5 class="gl-new-card-title">{{ $options.i18n.title }}</h5>
+ <span class="gl-new-card-count">
+ <gl-icon name="code" class="gl-mr-2" />
+ {{ variables.length }}
+ </span>
+ </div>
+ <div v-if="!isInheritedGroupVars" class="gl-new-card-actions gl-font-size-0">
<gl-button
- v-gl-tooltip
+ v-if="!isTableEmpty"
category="tertiary"
- icon="copy-to-clipboard"
- class="gl-my-n3 gl-ml-2"
- :title="__('Copy key')"
- :data-clipboard-text="item.key"
- :aria-label="__('Copy to clipboard')"
- />
- </div>
- </template>
- <template v-if="!isInheritedGroupVars" #cell(value)="{ item }">
- <div
- class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3"
- >
- <span v-if="areValuesHidden" data-testid="hiddenValue">*****</span>
- <span
- v-else
- :id="`ci-variable-value-${item.id}`"
- class="gl-display-inline-block gl-max-w-full gl-text-truncate"
- data-testid="revealedValue"
- >{{ item.value }}</span
+ size="small"
+ class="gl-mr-3"
+ @click="toggleHiddenState"
+ >{{ valuesButtonText }}</gl-button
>
<gl-button
- v-gl-tooltip
- category="tertiary"
- icon="copy-to-clipboard"
- class="gl-my-n3 gl-ml-2"
- :title="__('Copy value')"
- :data-clipboard-text="item.value"
- :aria-label="__('Copy to clipboard')"
- />
+ v-gl-modal-directive="$options.modalId"
+ size="small"
+ :disabled="exceedsVariableLimit"
+ data-qa-selector="add_ci_variable_button"
+ data-testid="add-ci-variable-button"
+ @click="setSelectedVariable()"
+ >{{ $options.i18n.addButton }}</gl-button
+ >
</div>
</template>
- <template #cell(attributes)="{ item }">
- <span data-testid="ci-variable-table-row-attributes">
- <gl-badge
- v-for="attribute in item.attributes"
- :key="`${item.key}-${attribute}`"
- class="gl-mr-2"
- variant="info"
- size="sm"
+
+ <gl-loading-icon v-if="isLoading" class="gl-p-4" />
+ <gl-alert
+ v-if="showAlert"
+ :dismissible="false"
+ :title="$options.maximumVariableLimitReached"
+ variant="info"
+ >
+ {{ exceedsVariableLimitText }}
+ </gl-alert>
+ <gl-table
+ v-if="!isLoading"
+ :fields="fields"
+ :items="variablesWithAttributes"
+ tbody-tr-class="js-ci-variable-row"
+ sort-by="key"
+ sort-direction="asc"
+ stacked="md"
+ fixed
+ show-empty
+ sort-icon-left
+ no-sort-reset
+ no-local-sorting
+ @sort-changed="(val) => $emit('sort-changed', val)"
+ >
+ <template #table-colgroup="scope">
+ <col v-for="field in scope.fields" :key="field.key" :style="field.customStyle" />
+ </template>
+ <template #cell(key)="{ item }">
+ <div
+ class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3"
>
- {{ attribute }}
- </gl-badge>
- </span>
- </template>
- <template #cell(environmentScope)="{ item }">
- <div
- class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3"
- >
- <span
- :id="`ci-variable-env-${item.id}`"
- class="gl-display-inline-block gl-max-w-full gl-word-break-word"
- >{{ convertEnvironmentScopeValue(item.environmentScope) }}</span
+ <span
+ :id="`ci-variable-key-${item.id}`"
+ class="gl-display-inline-block gl-max-w-full gl-word-break-word"
+ >{{ item.key }}</span
+ >
+ <gl-button
+ v-gl-tooltip
+ category="tertiary"
+ icon="copy-to-clipboard"
+ class="gl-my-n3 gl-ml-2"
+ :title="__('Copy key')"
+ :data-clipboard-text="item.key"
+ :aria-label="__('Copy to clipboard')"
+ />
+ </div>
+ </template>
+ <template v-if="!isInheritedGroupVars" #cell(value)="{ item }">
+ <div
+ class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3"
>
- <gl-button
- v-gl-tooltip
- category="tertiary"
- icon="copy-to-clipboard"
- class="gl-my-n3 gl-ml-2"
- :title="__('Copy environment')"
- :data-clipboard-text="convertEnvironmentScopeValue(item.environmentScope)"
- :aria-label="__('Copy to clipboard')"
- />
- </div>
- </template>
- <template v-if="isInheritedGroupVars" #cell(group)="{ item }">
- <div
- class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3"
- >
- <gl-link
- :id="`ci-variable-group-${item.id}`"
- data-testid="ci-variable-table-row-cicd-path"
- class="gl-display-inline-block gl-max-w-full gl-word-break-word"
- :href="item.groupCiCdSettingsPath"
+ <span v-if="areValuesHidden" data-testid="hiddenValue">*****</span>
+ <span
+ v-else
+ :id="`ci-variable-value-${item.id}`"
+ class="gl-display-inline-block gl-max-w-full gl-text-truncate"
+ data-testid="revealedValue"
+ >{{ item.value }}</span
+ >
+ <gl-button
+ v-gl-tooltip
+ category="tertiary"
+ icon="copy-to-clipboard"
+ class="gl-my-n3 gl-ml-2"
+ :title="__('Copy value')"
+ :data-clipboard-text="item.value"
+ :aria-label="__('Copy to clipboard')"
+ />
+ </div>
+ </template>
+ <template #cell(attributes)="{ item }">
+ <span data-testid="ci-variable-table-row-attributes">
+ <gl-badge
+ v-for="attribute in item.attributes"
+ :key="`${item.key}-${attribute}`"
+ class="gl-mr-2"
+ variant="info"
+ size="sm"
+ >
+ {{ attribute }}
+ </gl-badge>
+ </span>
+ </template>
+ <template #cell(environmentScope)="{ item }">
+ <div
+ class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3"
>
- {{ item.groupName }}
- </gl-link>
- </div>
- </template>
- <template v-if="!isInheritedGroupVars" #cell(actions)="{ item }">
- <gl-button
- v-gl-modal-directive="$options.modalId"
- icon="pencil"
- :aria-label="__('Edit')"
- data-qa-selector="edit_ci_variable_button"
- @click="setSelectedVariable(item.index)"
- />
- </template>
- <template #empty>
- <p class="gl-text-center gl-py-6 gl-text-black-normal gl-mb-0">
- {{ __('There are no variables yet.') }}
- </p>
- </template>
- </gl-table>
- <gl-alert
- v-if="showAlert"
- :dismissible="false"
- :title="$options.maximumVariableLimitReached"
- variant="info"
- >
- {{ exceedsVariableLimitText }}
- </gl-alert>
+ <span
+ :id="`ci-variable-env-${item.id}`"
+ class="gl-display-inline-block gl-max-w-full gl-word-break-word"
+ >{{ convertEnvironmentScopeValue(item.environmentScope) }}</span
+ >
+ <gl-button
+ v-gl-tooltip
+ category="tertiary"
+ icon="copy-to-clipboard"
+ class="gl-my-n3 gl-ml-2"
+ :title="__('Copy environment')"
+ :data-clipboard-text="convertEnvironmentScopeValue(item.environmentScope)"
+ :aria-label="__('Copy to clipboard')"
+ />
+ </div>
+ </template>
+ <template v-if="isInheritedGroupVars" #cell(group)="{ item }">
+ <div
+ class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3"
+ >
+ <gl-link
+ :id="`ci-variable-group-${item.id}`"
+ data-testid="ci-variable-table-row-cicd-path"
+ class="gl-display-inline-block gl-max-w-full gl-word-break-word"
+ :href="item.groupCiCdSettingsPath"
+ >
+ {{ item.groupName }}
+ </gl-link>
+ </div>
+ </template>
+ <template v-if="!isInheritedGroupVars" #cell(actions)="{ item }">
+ <div class="gl-display-flex gl-justify-content-end gl-mt-n2 gl-mb-n2">
+ <gl-button
+ v-gl-modal-directive="$options.modalId"
+ icon="pencil"
+ size="small"
+ class="gl-mr-3"
+ :aria-label="$options.i18n.editButton"
+ data-qa-selector="edit_ci_variable_button"
+ @click="setSelectedVariable(item.index)"
+ />
+ <gl-button
+ v-gl-modal-directive="`delete-variable-${item.index}`"
+ variant="danger"
+ category="secondary"
+ icon="remove"
+ size="small"
+ :aria-label="$options.i18n.deleteButton"
+ data-qa-selector="delete_ci_variable_button"
+ />
+ <gl-modal
+ ref="modal"
+ :modal-id="`delete-variable-${item.index}`"
+ :title="$options.i18n.modalDeleteTitle"
+ :action-primary="$options.deleteModal.actionPrimary"
+ :action-secondary="$options.deleteModal.actionSecondary"
+ @primary="deleteSelectedVariable(item.index)"
+ >
+ {{ removeVariableMessage(item.key) }}
+ </gl-modal>
+ </div>
+ </template>
+ <template #empty>
+ <p class="gl-text-secondary gl-text-center gl-py-1 gl-mb-0">
+ {{ __('There are no variables yet.') }}
+ </p>
+ </template>
+ </gl-table>
+ <gl-alert
+ v-if="showAlert"
+ :dismissible="false"
+ :title="$options.maximumVariableLimitReached"
+ variant="info"
+ >
+ {{ exceedsVariableLimitText }}
+ </gl-alert>
+ </gl-card>
<div v-if="!isInheritedGroupVars">
- <div v-if="!showPagination" class="ci-variable-actions gl-display-flex gl-mt-5">
- <gl-button
- v-gl-modal-directive="$options.modalId"
- class="gl-mr-3"
- data-qa-selector="add_ci_variable_button"
- variant="confirm"
- category="primary"
- :aria-label="__('Add')"
- :disabled="exceedsVariableLimit"
- @click="setSelectedVariable()"
- >{{ __('Add variable') }}</gl-button
- >
- <gl-button v-if="!isTableEmpty" @click="toggleHiddenState">{{
- valuesButtonText
- }}</gl-button>
- </div>
- <div v-else class="gl-display-flex gl-justify-content-center gl-mt-6">
+ <div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5">
<gl-keyset-pagination
v-bind="pageInfo"
:prev-text="__('Previous')"
diff --git a/app/assets/javascripts/ci/ci_variable_list/constants.js b/app/assets/javascripts/ci/ci_variable_list/constants.js
index d702dd073ec..825b39e0cf9 100644
--- a/app/assets/javascripts/ci/ci_variable_list/constants.js
+++ b/app/assets/javascripts/ci/ci_variable_list/constants.js
@@ -7,14 +7,6 @@ export const SORT_DIRECTIONS = {
ASC: 'KEY_ASC',
DESC: 'KEY_DESC',
};
-
-// This const will be deprecated once we remove VueX from the section
-export const displayText = {
- variableText: __('Variable'),
- fileText: __('File'),
- allEnvironmentsText: __('All (default)'),
-};
-
export const variableTypes = {
envType: 'ENV_VAR',
fileType: 'FILE',
@@ -26,8 +18,8 @@ export const allEnvironments = {
};
export const variableOptions = [
- { value: variableTypes.envType, text: variableTypes.envType },
- { value: variableTypes.fileType, text: variableTypes.fileType },
+ { value: variableTypes.envType, text: __('Variable (default)') },
+ { value: variableTypes.fileType, text: __('File') },
];
export const defaultVariableState = {
@@ -48,8 +40,9 @@ export const instanceString = 'Instance';
export const projectString = 'Project';
export const AWS_TIP_DISMISSED_COOKIE_NAME = 'ci_variable_list_constants_aws_tip_dismissed';
-export const AWS_TIP_MESSAGE = __(
- '%{deployLinkStart}Use a template to deploy to ECS%{deployLinkEnd}, or use a docker image to %{commandsLinkStart}run AWS commands in GitLab CI/CD%{commandsLinkEnd}.',
+export const AWS_TIP_TITLE = s__('CiVariable|Use OIDC to securely connect to cloud services');
+export const AWS_TIP_MESSAGE = s__(
+ 'CiVariable|GitLab CI/CD supports OpenID Connect (OIDC) to give your build and deployment jobs access to cloud credentials and services. %{linkStart}How do I configure OIDC for my cloud provider?%{linkEnd}',
);
export const EVENT_LABEL = 'ci_variable_modal';
diff --git a/app/assets/javascripts/ci/ci_variable_list/index.js b/app/assets/javascripts/ci/ci_variable_list/index.js
index e47b41ceae5..9342f57f2d8 100644
--- a/app/assets/javascripts/ci/ci_variable_list/index.js
+++ b/app/assets/javascripts/ci/ci_variable_list/index.js
@@ -10,10 +10,6 @@ import { generateCacheConfig, resolvers } from './graphql/settings';
const mountCiVariableListApp = (containerEl) => {
const {
- awsLogoSvgPath,
- awsTipCommandsLink,
- awsTipDeployLink,
- awsTipLearnLink,
containsVariableReferenceLink,
endpoint,
environmentScopeLink,
@@ -57,10 +53,6 @@ const mountCiVariableListApp = (containerEl) => {
el: containerEl,
apolloProvider,
provide: {
- awsLogoSvgPath,
- awsTipCommandsLink,
- awsTipDeployLink,
- awsTipLearnLink,
containsVariableReferenceLink,
endpoint,
environmentScopeLink,
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue
index 6ba8884f9a6..bc0cad75c60 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue
@@ -107,7 +107,6 @@ export default {
v-if="glFeatures.ciJobAssistantDrawer"
icon="bulb"
size="small"
- data-testid="job-assistant-drawer-toggle"
data-qa-selector="job_assistant_drawer_toggle"
@click="toggleJobAssistantDrawer"
>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue
index 656b1a6c347..f1c9770714a 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue
@@ -1,7 +1,7 @@
<script>
import { __ } from '~/locale';
import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils';
-import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
+import LegacyPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph.vue';
import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql';
import { PIPELINE_FAILURE } from '../../constants';
@@ -10,7 +10,7 @@ export default {
linkedPipelinesFetchError: __('Unable to fetch upstream and downstream pipelines.'),
},
components: {
- PipelineMiniGraph,
+ LegacyPipelineMiniGraph,
},
inject: ['projectFullPath'],
props: {
@@ -84,7 +84,7 @@ export default {
</script>
<template>
- <pipeline-mini-graph
+ <legacy-pipeline-mini-graph
v-if="hasPipelineStages"
:downstream-pipelines="downstreamPipelines"
:pipeline-path="pipelinePath"
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue
index bb79a4d74da..3bce50224d9 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue
@@ -11,7 +11,7 @@ import {
} from '~/pipelines/components/graph/utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
-import GraphqlPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue';
+import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
import PipelineEditorMiniGraph from './pipeline_editor_mini_graph.vue';
const POLL_INTERVAL = 10000;
@@ -34,8 +34,8 @@ export default {
GlLink,
GlLoadingIcon,
GlSprintf,
- GraphqlPipelineMiniGraph,
PipelineEditorMiniGraph,
+ PipelineMiniGraph,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -179,7 +179,7 @@ export default {
</span>
</div>
<div class="gl-display-flex gl-flex-wrap-wrap">
- <graphql-pipeline-mini-graph
+ <pipeline-mini-graph
v-if="isUsingPipelineMiniGraphQueries"
:full-path="projectFullPath"
:iid="pipeline.iid"
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue b/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue
index d7b8e7151d9..25e4e99bf54 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue
@@ -61,6 +61,7 @@ export default {
<gl-button
variant="confirm"
class="gl-mt-3"
+ data-testid="create_new_ci_button"
data-qa-selector="create_new_ci_button"
@click="createEmptyConfigFile"
>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue b/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue
index ba33888e2fb..7583fa7a3b5 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue
@@ -201,7 +201,6 @@ export default {
:title="$options.i18n.pipelineSourceTooltip"
:toggle-text="$options.i18n.pipelineSourceDefault"
disabled
- data-testid="pipeline-source"
/>
<validate-pipeline-popover />
<gl-icon
diff --git a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue
index 0495546529a..41e5199e204 100644
--- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue
@@ -3,9 +3,9 @@ import { GlModal } from '@gitlab/ui';
import { __ } from '~/locale';
import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import JobAssistantDrawer from 'jh_else_ce/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue';
import CommitSection from './components/commit/commit_section.vue';
import PipelineEditorDrawer from './components/drawer/pipeline_editor_drawer.vue';
-import JobAssistantDrawer from './components/job_assistant_drawer/job_assistant_drawer.vue';
import PipelineEditorFileNav from './components/file_nav/pipeline_editor_file_nav.vue';
import PipelineEditorFileTree from './components/file_tree/container.vue';
import PipelineEditorHeader from './components/header/pipeline_editor_header.vue';
diff --git a/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue
index 6fd5c8130ad..cc7d9bd2340 100644
--- a/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue
+++ b/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue
@@ -476,7 +476,6 @@ export default {
<gl-dropdown-item
v-for="option in configVariablesWithDescription.options[variable.key]"
:key="option"
- data-testid="pipeline-form-ci-variable-value-dropdown-items"
data-qa-selector="ci_variable_value_dropdown_item"
@click="setVariableAttribute(variable.key, 'value', option)"
>
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue
index 0700d9e5439..c993b65f6c0 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue
@@ -94,6 +94,7 @@ export default {
return {
schedules: {
list: [],
+ currentUser: {},
},
scope,
hasError: false,
@@ -135,6 +136,14 @@ export default {
},
];
},
+ onAllTab() {
+ // scope is undefined on first load, scope is only defined
+ // after tab switching
+ return this.scope === ALL_SCOPE || !this.scope;
+ },
+ showEmptyState() {
+ return !this.isLoading && this.schedulesCount === 0 && this.onAllTab;
+ },
},
watch: {
// this watcher ensures that the count on the all tab
@@ -258,8 +267,10 @@ export default {
</gl-sprintf>
</gl-alert>
+ <pipeline-schedule-empty-state v-if="showEmptyState" />
+
<gl-tabs
- v-if="isLoading || schedulesCount > 0"
+ v-else
sync-active-tab-with-query-params
query-param-name="scope"
nav-class="gl-flex-grow-1 gl-align-items-center gl-mt-2"
@@ -284,6 +295,7 @@ export default {
</template>
<gl-loading-icon v-if="isLoading" size="lg" />
+
<pipeline-schedules-table
v-else
:schedules="schedules.list"
@@ -306,8 +318,6 @@ export default {
</template>
</gl-tabs>
- <pipeline-schedule-empty-state v-else-if="!isLoading && schedulesCount === 0" />
-
<take-ownership-modal
:visible="showTakeOwnershipModal"
@takeOwnership="takeOwnership"
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue
index d84a9a4a4b5..396ff9808f2 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue
@@ -112,6 +112,7 @@ export default {
cronTimezone: '',
variables: [],
schedule: {},
+ showVarValues: false,
};
},
i18n: {
@@ -140,6 +141,8 @@ export default {
scheduleFetchError: s__(
'PipelineSchedules|An error occurred while trying to fetch the pipeline schedule.',
),
+ revealText: __('Reveal values'),
+ hideText: __('Hide values'),
},
typeOptions: {
[VARIABLE_TYPE]: __('Variable'),
@@ -167,11 +170,24 @@ export default {
getEnabledRefTypes() {
return [REF_TYPE_BRANCHES, REF_TYPE_TAGS];
},
+ filledVariables() {
+ return this.variables.filter((variable) => variable.key !== '' && !variable.empty);
+ },
preparedVariablesUpdate() {
- return this.variables.filter((variable) => variable.key !== '');
+ return this.filledVariables.map((variable) => {
+ return {
+ id: variable.id,
+ key: variable.key,
+ value: variable.value,
+ variableType: variable.variableType,
+ destroy: variable.destroy,
+ };
+ });
},
preparedVariablesCreate() {
- return this.preparedVariablesUpdate.map((variable) => {
+ const vars = this.variables.filter((variable) => variable.key !== '');
+
+ return vars.map((variable) => {
return {
key: variable.key,
value: variable.value,
@@ -187,6 +203,15 @@ export default {
? this.$options.i18n.editScheduleBtnText
: this.$options.i18n.createScheduleBtnText;
},
+ varSecurityBtnText() {
+ return this.showVarValues ? this.$options.i18n.hideText : this.$options.i18n.revealText;
+ },
+ hasExistingScheduleVariables() {
+ return this.schedule?.variables?.nodes.length > 0;
+ },
+ showVarSecurityBtn() {
+ return this.editing && this.hasExistingScheduleVariables;
+ },
},
created() {
this.addEmptyVariable();
@@ -204,6 +229,7 @@ export default {
key: '',
value: '',
destroy: false,
+ empty: true,
});
},
setVariableAttribute(key, attribute, value) {
@@ -289,6 +315,14 @@ export default {
setTimezone(timezone) {
this.cronTimezone = timezone.identifier || '';
},
+ displayHiddenChars(variable) {
+ return (
+ this.editing && this.hasExistingScheduleVariables && !this.showVarValues && !variable.empty
+ );
+ },
+ resetVariable(index) {
+ this.variables[index].empty = false;
+ },
},
};
</script>
@@ -342,7 +376,7 @@ export default {
/>
</gl-form-group>
<!--Variable List-->
- <gl-form-group :label="$options.i18n.variables">
+ <gl-form-group class="gl-mb-2" :label="$options.i18n.variables">
<div
v-for="(variable, index) in variables"
:key="`var-${index}`"
@@ -372,10 +406,21 @@ export default {
:class="$options.formElementClasses"
data-testid="pipeline-form-ci-variable-key"
data-qa-selector="ci_variable_key_field"
- @change="addEmptyVariable()"
+ @change="addEmptyVariable(variable)"
/>
<gl-form-textarea
+ v-if="displayHiddenChars(variable)"
+ value="*****************"
+ disabled
+ class="gl-mb-3 gl-h-7!"
+ :style="$options.textAreaStyle"
+ :no-resize="false"
+ data-testid="pipeline-form-ci-variable-hidden-value"
+ />
+
+ <gl-form-textarea
+ v-else
v-model="variable.value"
:placeholder="s__('CiVariables|Input variable value')"
class="gl-mb-3 gl-h-7!"
@@ -383,6 +428,7 @@ export default {
:no-resize="false"
data-testid="pipeline-form-ci-variable-value"
data-qa-selector="ci_variable_value_field"
+ @change="resetVariable(index)"
/>
<template v-if="variables.length > 1">
@@ -406,6 +452,18 @@ export default {
</div>
</div>
</gl-form-group>
+
+ <gl-button
+ v-if="showVarSecurityBtn"
+ class="gl-mb-5"
+ category="secondary"
+ variant="confirm"
+ data-testid="variable-security-btn"
+ @click="showVarValues = !showVarValues"
+ >
+ {{ varSecurityBtnText }}
+ </gl-button>
+
<!--Activated-->
<gl-form-checkbox id="schedule-active" v-model="activated" class="gl-mb-3">
{{ $options.i18n.activated }}
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue
index b97914f8c26..368cfb9c10d 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTableLite } from '@gitlab/ui';
+import { GlTable } from '@gitlab/ui';
import { s__ } from '~/locale';
import PipelineScheduleActions from './cells/pipeline_schedule_actions.vue';
import PipelineScheduleLastPipeline from './cells/pipeline_schedule_last_pipeline.vue';
@@ -8,6 +8,9 @@ import PipelineScheduleOwner from './cells/pipeline_schedule_owner.vue';
import PipelineScheduleTarget from './cells/pipeline_schedule_target.vue';
export default {
+ i18n: {
+ emptyText: s__('PipelineSchedules|No pipeline schedules'),
+ },
fields: [
{
key: 'description',
@@ -47,7 +50,7 @@ export default {
},
],
components: {
- GlTableLite,
+ GlTable,
PipelineScheduleActions,
PipelineScheduleLastPipeline,
PipelineScheduleNextRun,
@@ -68,10 +71,12 @@ export default {
</script>
<template>
- <gl-table-lite
+ <gl-table
:fields="$options.fields"
:items="schedules"
:tbody-tr-attr="{ 'data-testid': 'pipeline-schedule-table-row' }"
+ :empty-text="$options.i18n.emptyText"
+ show-empty
stacked="md"
>
<template #table-colgroup="{ fields }">
@@ -109,5 +114,5 @@ export default {
@playPipelineSchedule="$emit('playPipelineSchedule', $event)"
/>
</template>
- </gl-table-lite>
+ </gl-table>
</template>
diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/index.js b/app/assets/javascripts/ci/reports/codequality_report/store/index.js
index 5bfcd69edec..c2f706e56e6 100644
--- a/app/assets/javascripts/ci/reports/codequality_report/store/index.js
+++ b/app/assets/javascripts/ci/reports/codequality_report/store/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
diff --git a/app/assets/javascripts/ci/reports/components/report_item.vue b/app/assets/javascripts/ci/reports/components/report_item.vue
index ca1f3301691..7342df6da67 100644
--- a/app/assets/javascripts/ci/reports/components/report_item.vue
+++ b/app/assets/javascripts/ci/reports/components/report_item.vue
@@ -53,7 +53,7 @@ export default {
};
</script>
<template>
- <li class="report-block-list-issue gl-p-3!" data-qa-selector="report_item_row">
+ <li class="report-block-list-issue gl-p-3!" data-testid="report-item-row">
<component
:is="iconComponent"
v-if="showReportSectionStatusIcon"
diff --git a/app/assets/javascripts/ci/reports/components/report_section.vue b/app/assets/javascripts/ci/reports/components/report_section.vue
index 468c8916b8d..a4ec7b6a325 100644
--- a/app/assets/javascripts/ci/reports/components/report_section.vue
+++ b/app/assets/javascripts/ci/reports/components/report_section.vue
@@ -185,10 +185,7 @@ export default {
<div class="media">
<status-icon :status="statusIconName" :size="24" class="align-self-center" />
<div class="media-body gl-display-flex gl-align-items-flex-start gl-flex-direction-row!">
- <div
- data-testid="report-section-code-text"
- class="js-code-text code-text gl-align-self-center gl-flex-grow-1"
- >
+ <div class="js-code-text code-text gl-align-self-center gl-flex-grow-1">
<div class="gl-display-flex gl-align-items-center">
<p class="gl-line-height-normal gl-m-0">{{ headerText }}</p>
<slot :name="slotName"></slot>
diff --git a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue
index 2168685e703..e6813211fe9 100644
--- a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue
@@ -52,6 +52,8 @@ export default {
RunnerTypeTabs,
RunnerActionsCell,
RunnerJobStatusBadge,
+ RunnerDashboardLink: () =>
+ import('ee_component/ci/runner/components/runner_dashboard_link.vue'),
},
mixins: [glFeatureFlagMixin()],
props: {
@@ -188,12 +190,12 @@ export default {
nav-class="gl-border-none!"
/>
- <div class="gl-w-full gl-md-w-auto gl-display-flex">
+ <div class="gl-w-full gl-md-w-auto gl-display-flex gl-gap-3">
+ <runner-dashboard-link />
<gl-button :href="newRunnerPath" variant="confirm">
{{ s__('Runners|New instance runner') }}
</gl-button>
<registration-dropdown
- class="gl-ml-3"
:registration-token="registrationToken"
:type="$options.INSTANCE_TYPE"
placement="right"
diff --git a/app/assets/javascripts/ci/runner/admin_runners/index.js b/app/assets/javascripts/ci/runner/admin_runners/index.js
index 54eb37f8c90..d4df1393487 100644
--- a/app/assets/javascripts/ci/runner/admin_runners/index.js
+++ b/app/assets/javascripts/ci/runner/admin_runners/index.js
@@ -1,6 +1,9 @@
import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+
+import { provide } from 'ee_else_ce/ci/runner/admin_runners/provide';
+
import { visitUrl } from '~/lib/utils/url_utility';
import { updateOutdatedUrl } from '~/ci/runner/runner_search_utils';
import createDefaultClient from '~/lib/graphql';
@@ -29,14 +32,7 @@ export const initAdminRunners = (selector = '#js-admin-runners') => {
return null;
}
- const {
- runnerInstallHelpPage,
- newRunnerPath,
- registrationToken,
- onlineContactTimeoutSecs,
- staleTimeoutSecs,
- } = el.dataset;
-
+ const { newRunnerPath, registrationToken } = el.dataset;
const { cacheConfig, typeDefs, localMutations } = createLocalState();
const apolloProvider = new VueApollo({
@@ -47,10 +43,8 @@ export const initAdminRunners = (selector = '#js-admin-runners') => {
el,
apolloProvider,
provide: {
- runnerInstallHelpPage,
+ ...provide(el.dataset),
localMutations,
- onlineContactTimeoutSecs,
- staleTimeoutSecs,
},
render(h) {
return h(AdminRunnersApp, {
diff --git a/app/assets/javascripts/ci/runner/admin_runners/provide.js b/app/assets/javascripts/ci/runner/admin_runners/provide.js
new file mode 100644
index 00000000000..81a39801718
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/admin_runners/provide.js
@@ -0,0 +1,22 @@
+/**
+ * Provides global values to the admin runners app.
+ *
+ * @param {Object} `data-` HTML attributes of the mounting point
+ * @returns An object with properties to use provide/inject of the root app.
+ * See EE version
+ */
+export const provide = (elDataset) => {
+ const {
+ runnerInstallHelpPage,
+ onlineContactTimeoutSecs,
+ staleTimeoutSecs,
+ tagSuggestionsPath,
+ } = elDataset;
+
+ return {
+ runnerInstallHelpPage,
+ onlineContactTimeoutSecs,
+ staleTimeoutSecs,
+ tagSuggestionsPath,
+ };
+};
diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue b/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue
index 69021dde0e9..771ecb1a0d4 100644
--- a/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue
@@ -163,7 +163,7 @@ export default {
"
>
<template #link="{ content }">
- <gl-link data-testid="runner-install-link" @click="toggleDrawer">{{ content }}</gl-link>
+ <gl-link @click="toggleDrawer">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_token.vue b/app/assets/javascripts/ci/runner/components/registration/registration_token.vue
index 339c92a427f..50d2fcfa961 100644
--- a/app/assets/javascripts/ci/runner/components/registration/registration_token.vue
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_token.vue
@@ -45,6 +45,7 @@ export default {
:label-for="inputId"
:copy-button-title="$options.I18N_COPY_BUTTON_TITLE"
:form-input-group-props="formInputGroupProps"
+ readonly
@copy="onCopy"
>
<template v-for="slot in Object.keys($scopedSlots)" #[slot]>
diff --git a/app/assets/javascripts/ci/runner/components/runner_details.vue b/app/assets/javascripts/ci/runner/components/runner_details.vue
index 8c1280cffb9..fac90fb0370 100644
--- a/app/assets/javascripts/ci/runner/components/runner_details.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_details.vue
@@ -94,10 +94,7 @@ export default {
<div>
<runner-upgrade-status-alert class="gl-my-4" :runner="runner" />
<div class="gl-pt-4">
- <dl
- class="gl-mb-0 gl-display-grid runner-details-grid-template"
- data-testid="runner-details-list"
- >
+ <dl class="gl-mb-0 gl-display-grid runner-details-grid-template">
<runner-detail :label="s__('Runners|Description')" :value="runner.description" />
<runner-detail
:label="s__('Runners|Last contact')"
diff --git a/app/assets/javascripts/ci/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/ci/runner/components/runner_filtered_search_bar.vue
index ee56fea8282..3634dcf1c93 100644
--- a/app/assets/javascripts/ci/runner/components/runner_filtered_search_bar.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_filtered_search_bar.vue
@@ -93,6 +93,8 @@ export default {
:tokens="validTokens"
:initial-sort-by="initialSortBy"
:search-input-placeholder="__('Search or filter results...')"
+ :search-text-option-label="s__('Runners|Search description...')"
+ terms-as-tokens
data-testid="runners-filtered-search"
@onFilter="onFilter"
@onSort="onSort"
diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js
index 71a145dd4a3..5bec9804002 100644
--- a/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js
+++ b/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js
@@ -3,26 +3,15 @@ import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/consta
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import { PARAM_KEY_PAUSED, I18N_PAUSED } from '../../constants';
-const options = [
- { value: 'true', title: __('Yes') },
- { value: 'false', title: __('No') },
-];
-
export const pausedTokenConfig = {
icon: 'pause',
title: I18N_PAUSED,
type: PARAM_KEY_PAUSED,
token: BaseToken,
unique: true,
- options: options.map(({ value, title }) => ({
- value,
- // Replace whitespace with a special character to avoid
- // splitting this value.
- // Replacing in each option, as translations may also
- // contain spaces!
- // see: https://gitlab.com/gitlab-org/gitlab/-/issues/344142
- // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438
- title: title.replace(/\s/g, '\u00a0'),
- })),
+ options: [
+ { value: 'true', title: __('Yes') },
+ { value: 'false', title: __('No') },
+ ],
operators: OPERATORS_IS,
};
diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js
index 4bc32909777..1e4774bff72 100644
--- a/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js
+++ b/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js
@@ -15,28 +15,17 @@ import {
PARAM_KEY_STATUS,
} from '../../constants';
-const options = [
- { 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 = {
icon: 'status',
title: TOKEN_TITLE_STATUS,
type: PARAM_KEY_STATUS,
token: BaseToken,
unique: true,
- options: options.map(({ value, title }) => ({
- value,
- // Replace whitespace with a special character to avoid
- // splitting this value.
- // Replacing in each option, as translations may also
- // contain spaces!
- // see: https://gitlab.com/gitlab-org/gitlab/-/issues/344142
- // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438
- title: title.replace(/\s/g, '\u00a0'),
- })),
+ options: [
+ { 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 },
+ ],
operators: OPERATORS_IS,
};
diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue
index 1de7775090a..dd1cca0a05c 100644
--- a/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue
+++ b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue
@@ -7,20 +7,13 @@ import { s__ } from '~/locale';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import { RUNNER_TAG_BG_CLASS } from '../../constants';
-// TODO This should be implemented via a GraphQL API
-// The API should
-// 1) scope to the rights of the user
-// 2) stay up to date to the removal of old tags
-// 3) consider the scope of search, like searching within the tags of a group
-// See: https://gitlab.com/gitlab-org/gitlab/-/issues/333796
-export const TAG_SUGGESTIONS_PATH = '/admin/runners/tag_list.json';
-
export default {
components: {
BaseToken,
GlFilteredSearchSuggestion,
GlToken,
},
+ inject: ['tagSuggestionsPath'],
props: {
config: {
type: Object,
@@ -36,7 +29,7 @@ export default {
methods: {
getTagsOptions(search) {
return axios
- .get(TAG_SUGGESTIONS_PATH, {
+ .get(this.tagSuggestionsPath, {
params: {
search,
},
diff --git a/app/assets/javascripts/ci_secure_files/components/metadata/button.vue b/app/assets/javascripts/ci_secure_files/components/metadata/button.vue
index 799c6ec79d4..6a45c1313ca 100644
--- a/app/assets/javascripts/ci_secure_files/components/metadata/button.vue
+++ b/app/assets/javascripts/ci_secure_files/components/metadata/button.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
@@ -45,10 +46,8 @@ export default {
v-gl-modal="modalId"
v-gl-tooltip.hover.top="$options.i18n.metadataLabel"
category="secondary"
- variant="info"
icon="doc-text"
:aria-label="$options.i18n.metadataLabel"
- data-testid="metadata-button"
@click="selectSecureFile()"
/>
</template>
diff --git a/app/assets/javascripts/ci_secure_files/components/metadata/modal.vue b/app/assets/javascripts/ci_secure_files/components/metadata/modal.vue
index a459b721394..fdf720a5f94 100644
--- a/app/assets/javascripts/ci_secure_files/components/metadata/modal.vue
+++ b/app/assets/javascripts/ci_secure_files/components/metadata/modal.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlModal, GlSprintf, GlModalDirective } from '@gitlab/ui';
import { __, s__, createDateTimeFormat } from '~/locale';
diff --git a/app/assets/javascripts/ci_secure_files/components/metadata/table.vue b/app/assets/javascripts/ci_secure_files/components/metadata/table.vue
index 92043ff0a31..3acfac30245 100644
--- a/app/assets/javascripts/ci_secure_files/components/metadata/table.vue
+++ b/app/assets/javascripts/ci_secure_files/components/metadata/table.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlTableLite } from '@gitlab/ui';
diff --git a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue
index dd80698ec1a..509bdabdd9e 100644
--- a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue
+++ b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue
@@ -2,6 +2,7 @@
import {
GlAlert,
GlButton,
+ GlCard,
GlIcon,
GlLoadingIcon,
GlModal,
@@ -24,6 +25,7 @@ export default {
components: {
GlAlert,
GlButton,
+ GlCard,
GlIcon,
GlLoadingIcon,
GlModal,
@@ -42,6 +44,7 @@ export default {
inject: ['projectId', 'admin', 'fileSizeLimit'],
DEFAULT_PER_PAGE,
i18n: {
+ title: __('Files'),
deleteLabel: __('Delete File'),
uploadLabel: __('Upload File'),
uploadingLabel: __('Uploading...'),
@@ -89,7 +92,8 @@ export default {
},
{
key: 'actions',
- label: '',
+ label: __('Actions'),
+ thClass: 'gl-text-right',
tdClass: 'gl-text-right gl-vertical-align-middle!',
},
],
@@ -184,78 +188,95 @@ export default {
<template>
<div>
- <div class="ci-secure-files-table">
+ <div>
<gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="error = null">
{{ errorMessage }}
</gl-alert>
- <gl-table
- :busy="loading"
- :fields="fields"
- :items="projectSecureFiles"
- tbody-tr-class="js-ci-secure-files-row"
- data-qa-selector="ci_secure_files_table_content"
- sort-by="key"
- sort-direction="asc"
- stacked="lg"
- table-class="text-secondary"
- show-empty
- sort-icon-left
- no-sort-reset
- :empty-text="$options.i18n.noFilesMessage"
+ <gl-card
+ class="gl-new-card"
+ header-class="gl-new-card-header"
+ body-class="gl-new-card-body gl-px-0"
>
- <template #table-busy>
- <gl-loading-icon size="lg" class="gl-my-5" />
+ <template #header>
+ <div class="gl-new-card-title-wrapper">
+ <h5 class="gl-new-card-title gl-my-0">
+ {{ $options.i18n.title }}
+ <span class="gl-new-card-count">
+ <gl-icon name="document" class="gl-mr-2" />
+ {{ projectSecureFiles.length }}
+ </span>
+ </h5>
+ </div>
+ <div class="gl-new-card-actions">
+ <gl-button v-if="admin" size="small" @click="loadFileSelector">
+ <span v-if="uploading">
+ <gl-loading-icon class="gl-my-5" inline />
+ {{ $options.i18n.uploadingLabel }}
+ </span>
+ <span v-else>
+ <gl-icon name="upload" class="gl-mr-2" /> {{ $options.i18n.uploadLabel }}
+ </span>
+ </gl-button>
+ <input
+ id="file-upload"
+ ref="fileUpload"
+ type="file"
+ class="hidden"
+ data-qa-selector="file_upload_field"
+ @change="uploadSecureFile"
+ />
+ </div>
</template>
- <template #cell(name)="{ item }">
- {{ item.name }}
- </template>
+ <gl-table
+ :busy="loading"
+ :fields="fields"
+ :items="projectSecureFiles"
+ tbody-tr-class="js-ci-secure-files-row"
+ sort-by="key"
+ sort-direction="asc"
+ stacked="md"
+ table-class="text-secondary"
+ show-empty
+ sort-icon-left
+ no-sort-reset
+ :empty-text="$options.i18n.noFilesMessage"
+ >
+ <template #table-busy>
+ <gl-loading-icon size="lg" class="gl-my-5" />
+ </template>
- <template #cell(created_at)="{ item }">
- <timeago-tooltip :time="item.created_at" />
- </template>
+ <template #cell(name)="{ item }">
+ {{ item.name }}
+ </template>
- <template #cell(actions)="{ item }">
- <metadata-button
- :secure-file="item"
- :admin="admin"
- modal-id="$options.metadataModalId"
- @selectSecureFile="updateMetadataSecureFile"
- />
- <gl-button
- v-if="admin"
- v-gl-modal="$options.deleteModalId"
- v-gl-tooltip.hover.top="$options.i18n.deleteLabel"
- category="secondary"
- variant="danger"
- icon="remove"
- :aria-label="$options.i18n.deleteLabel"
- data-testid="delete-button"
- @click="setDeleteModalData(item)"
- />
- </template>
- </gl-table>
- </div>
+ <template #cell(created_at)="{ item }">
+ <timeago-tooltip :time="item.created_at" />
+ </template>
- <div class="gl-display-flex gl-mt-5">
- <gl-button v-if="admin" variant="confirm" @click="loadFileSelector">
- <span v-if="uploading">
- <gl-loading-icon class="gl-my-5" inline />
- {{ $options.i18n.uploadingLabel }}
- </span>
- <span v-else>
- <gl-icon name="upload" class="gl-mr-2" /> {{ $options.i18n.uploadLabel }}
- </span>
- </gl-button>
- <input
- id="file-upload"
- ref="fileUpload"
- type="file"
- class="hidden"
- data-qa-selector="file_upload_field"
- @change="uploadSecureFile"
- />
+ <template #cell(actions)="{ item }">
+ <metadata-button
+ :secure-file="item"
+ :admin="admin"
+ modal-id="$options.metadataModalId"
+ @selectSecureFile="updateMetadataSecureFile"
+ />
+ <gl-button
+ v-if="admin"
+ v-gl-modal="$options.deleteModalId"
+ v-gl-tooltip.hover.top="$options.i18n.deleteLabel"
+ size="small"
+ category="tertiary"
+ variant="default"
+ icon="remove"
+ :aria-label="$options.i18n.deleteLabel"
+ data-testid="delete-button"
+ @click="setDeleteModalData(item)"
+ />
+ </template>
+ </gl-table>
+ </gl-card>
</div>
<gl-pagination
@@ -266,6 +287,7 @@ export default {
:next-text="$options.i18n.pagination.next"
:prev-text="$options.i18n.pagination.prev"
align="center"
+ class="gl-mt-5"
/>
<gl-modal
diff --git a/app/assets/javascripts/ci_settings_general_pipeline/index.js b/app/assets/javascripts/ci_settings_general_pipeline/index.js
new file mode 100644
index 00000000000..5053786fbba
--- /dev/null
+++ b/app/assets/javascripts/ci_settings_general_pipeline/index.js
@@ -0,0 +1,19 @@
+export const initGeneralPipelinesOptions = () => {
+ const forwardDeploymentEnabledCheckbox = document.getElementById(
+ 'project_ci_cd_settings_attributes_forward_deployment_enabled',
+ );
+ const forwardDeploymentRollbackAllowedCheckbox = document.getElementById(
+ 'project_ci_cd_settings_attributes_forward_deployment_rollback_allowed',
+ );
+
+ if (forwardDeploymentRollbackAllowedCheckbox && forwardDeploymentEnabledCheckbox) {
+ forwardDeploymentRollbackAllowedCheckbox.disabled = !forwardDeploymentEnabledCheckbox.checked;
+
+ forwardDeploymentEnabledCheckbox.addEventListener('change', () => {
+ if (!forwardDeploymentEnabledCheckbox.checked) {
+ forwardDeploymentRollbackAllowedCheckbox.checked = false;
+ }
+ forwardDeploymentRollbackAllowedCheckbox.disabled = !forwardDeploymentEnabledCheckbox.checked;
+ });
+ }
+};
diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
index a1b264cfe54..0871d543d46 100644
--- a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
+++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
@@ -1,13 +1,5 @@
<script>
-import {
- GlAlert,
- GlAvatar,
- GlAvatarLink,
- GlBadge,
- GlButton,
- GlTable,
- GlTooltipDirective,
-} from '@gitlab/ui';
+import { GlAvatar, GlAvatarLink, GlBadge, GlButton, GlTable, GlTooltipDirective } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { thWidthPercent } from '~/lib/utils/table_utility';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -25,7 +17,6 @@ export default {
},
components: {
ClipboardButton,
- GlAlert,
GlAvatar,
GlAvatarLink,
GlBadge,
@@ -53,28 +44,32 @@ export default {
{
key: 'token',
label: s__('Pipelines|Token'),
- thClass: thWidthPercent(70),
+ thClass: thWidthPercent(60),
+ tdClass: 'gl-vertical-align-middle!',
},
{
key: 'description',
label: s__('Pipelines|Description'),
- thClass: thWidthPercent(15),
+ thClass: thWidthPercent(20),
+ tdClass: 'gl-vertical-align-middle!',
},
{
key: 'owner',
label: s__('Pipelines|Owner'),
thClass: thWidthPercent(5),
+ tdClass: 'gl-vertical-align-middle!',
},
{
key: 'lastUsed',
label: s__('Pipelines|Last Used'),
- thClass: thWidthPercent(5),
+ thClass: thWidthPercent(10),
+ tdClass: 'gl-vertical-align-middle!',
},
{
key: 'actions',
- label: '',
+ label: __('Actions'),
tdClass: 'gl-text-right gl-white-space-nowrap',
- thClass: thWidthPercent(5),
+ thClass: `gl-text-right ${thWidthPercent(5)}`,
},
],
computed: {
@@ -88,9 +83,22 @@ export default {
return '*'.repeat(47);
},
},
+ mounted() {
+ const revealButton = document.querySelector('[data-testid="reveal-hide-values-button"]');
+ if (revealButton) {
+ if (this.triggers.length === 0) {
+ revealButton.style.display = 'none';
+ }
+
+ revealButton.addEventListener('click', () => {
+ this.toggleHiddenState(revealButton);
+ });
+ }
+ },
methods: {
- toggleHiddenState() {
+ toggleHiddenState(element) {
this.areValuesHidden = !this.areValuesHidden;
+ element.innerText = this.valuesButtonText;
},
},
};
@@ -102,7 +110,8 @@ export default {
v-if="hasTriggers"
:fields="$options.fields"
:items="triggers"
- class="triggers-list"
+ class="triggers-list gl-mb-0"
+ stacked="md"
responsive
>
<template #cell(token)="{ item }">
@@ -116,8 +125,8 @@ export default {
:title="$options.i18n.copyTrigger"
css-class="gl-border-none gl-py-0 gl-px-2"
/>
- <div class="gl-display-inline-block gl-ml-3">
- <gl-badge v-if="!item.canAccessProject" variant="danger">
+ <div v-if="!item.canAccessProject" class="gl-display-inline-block gl-ml-3">
+ <gl-badge variant="danger">
<span
v-gl-tooltip.viewport
boundary="viewport"
@@ -132,7 +141,7 @@ export default {
:title="item.description"
truncate-target="child"
placement="top"
- class="gl-max-w-15 gl-display-flex"
+ class="gl-max-w-15 gl-display-inline-flex"
>
<div class="gl-flex-grow-1 gl-text-truncate">{{ item.description }}</div>
</tooltip-on-truncate>
@@ -157,6 +166,7 @@ export default {
:title="$options.i18n.editButton"
:aria-label="$options.i18n.editButton"
icon="pencil"
+ category="tertiary"
data-testid="edit-btn"
:href="item.editProjectTriggerPath"
/>
@@ -164,32 +174,24 @@ export default {
:title="$options.i18n.revokeButton"
:aria-label="$options.i18n.revokeButton"
icon="remove"
+ category="tertiary"
:data-confirm="$options.i18n.revokeButtonConfirm"
data-method="delete"
data-confirm-btn-variant="danger"
rel="nofollow"
- class="gl-ml-3"
data-testid="trigger_revoke_button"
data-qa-selector="trigger_revoke_button"
:href="item.projectTriggerPath"
/>
</template>
</gl-table>
- <gl-alert
+ <div
v-else
- variant="warning"
- :dismissible="false"
- :show-icon="false"
+ class="gl-new-card-empty gl-px-5 gl-py-4"
data-testid="no_triggers_content"
data-qa-selector="no_triggers_content"
>
{{ s__('Pipelines|No triggers have been created yet. Add one using the form above.') }}
- </gl-alert>
- <gl-button
- v-if="hasTriggers"
- data-testid="reveal-hide-values-button"
- @click="toggleHiddenState"
- >{{ valuesButtonText }}</gl-button
- >
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue
index d740d1c8865..9d7d68ee31c 100644
--- a/app/assets/javascripts/clusters/agents/components/show.vue
+++ b/app/assets/javascripts/clusters/agents/components/show.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import {
GlAlert,
diff --git a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
index 8a997624a36..eabe809fbd2 100644
--- a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
+++ b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
@@ -147,7 +147,6 @@ export default {
<template v-if="confirmCleanup">
<gl-button
:disabled="!canSubmit"
- data-testid="remove-integration-and-resources-modal-button"
variant="danger"
category="primary"
@click="handleSubmit(true)"
diff --git a/app/assets/javascripts/clusters/forms/components/integration_form.vue b/app/assets/javascripts/clusters/forms/components/integration_form.vue
index b2a8381f937..25669e4fd0c 100644
--- a/app/assets/javascripts/clusters/forms/components/integration_form.vue
+++ b/app/assets/javascripts/clusters/forms/components/integration_form.vue
@@ -8,6 +8,7 @@ import {
GlLink,
GlButton,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import { s__ } from '~/locale';
@@ -74,7 +75,6 @@ export default {
v-gl-tooltip:tooltipcontainer
name="cluster[enabled]"
class="gl-mb-0 js-project-feature-toggle"
- data-qa-selector="integration_status_toggle"
aria-describedby="toggleCluster"
:disabled="!editable"
:label="$options.i18n.toggleLabel"
@@ -111,7 +111,6 @@ export default {
id="cluster_base_domain"
v-model="baseDomainField"
name="cluster[base_domain]"
- data-qa-selector="base_domain_field"
class="col-md-6"
type="text"
/>
@@ -144,7 +143,6 @@ export default {
type="submit"
:disabled="!canSubmit"
:aria-disabled="!canSubmit"
- data-qa-selector="save_changes_button"
>{{ s__('ClusterIntegration|Save changes') }}</gl-button
>
</div>
diff --git a/app/assets/javascripts/clusters/forms/stores/index.js b/app/assets/javascripts/clusters/forms/stores/index.js
index 87f1c05fdf9..b0cacde17a1 100644
--- a/app/assets/javascripts/clusters/forms/stores/index.js
+++ b/app/assets/javascripts/clusters/forms/stores/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import state from './state';
diff --git a/app/assets/javascripts/clusters_list/components/agent_token.vue b/app/assets/javascripts/clusters_list/components/agent_token.vue
index 93c37226a09..5f40815bd02 100644
--- a/app/assets/javascripts/clusters_list/components/agent_token.vue
+++ b/app/assets/javascripts/clusters_list/components/agent_token.vue
@@ -89,6 +89,7 @@ export default {
<p class="gl-display-flex gl-align-items-flex-start">
<code-block class="gl-w-full" :code="agentRegistrationCommand" />
<modal-copy-button
+ data-testid="agent-registration-command"
:title="$options.i18n.copyCommand"
:text="agentRegistrationCommand"
:modal-id="modalId"
diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue
index b1765d336c8..33d98c381fb 100644
--- a/app/assets/javascripts/clusters_list/components/agents.vue
+++ b/app/assets/javascripts/clusters_list/components/agents.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlAlert, GlLoadingIcon, GlBanner } from '@gitlab/ui';
import { s__ } from '~/locale';
diff --git a/app/assets/javascripts/clusters_list/components/ancestor_notice.vue b/app/assets/javascripts/clusters_list/components/ancestor_notice.vue
index b241aa34283..d2cc0df8a9d 100644
--- a/app/assets/javascripts/clusters_list/components/ancestor_notice.vue
+++ b/app/assets/javascripts/clusters_list/components/ancestor_notice.vue
@@ -1,5 +1,6 @@
<script>
import { GlLink, GlSprintf, GlAlert } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
export default {
diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue
index 4b85ca2b508..590fdb947b3 100644
--- a/app/assets/javascripts/clusters_list/components/clusters.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import {
GlBadge,
@@ -9,6 +10,7 @@ import {
GlTableLite,
GlTooltipDirective,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
import { __, sprintf } from '~/locale';
import { CLUSTER_TYPES, STATUSES } from '../constants';
diff --git a/app/assets/javascripts/clusters_list/components/clusters_actions.vue b/app/assets/javascripts/clusters_list/components/clusters_actions.vue
index 7b97a5af373..c388d3fee71 100644
--- a/app/assets/javascripts/clusters_list/components/clusters_actions.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters_actions.vue
@@ -92,11 +92,7 @@ export default {
<!--TODO: Replace button-group workaround once `split` option for new dropdowns is implemented.-->
<!-- See issue at https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2263-->
- <gl-button-group
- ref="actions"
- data-qa-selector="clusters_actions_button"
- class="gl-w-full gl-mb-3 gl-md-w-auto gl-md-mb-0"
- >
+ <gl-button-group ref="actions" class="gl-w-full gl-mb-3 gl-md-w-auto gl-md-mb-0">
<gl-button
v-gl-modal-directive="shouldTriggerModal && $options.INSTALL_AGENT_MODAL_ID"
:href="defaultActionUrl"
diff --git a/app/assets/javascripts/clusters_list/components/clusters_view_all.vue b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue
index d831d79b994..4450c85661a 100644
--- a/app/assets/javascripts/clusters_list/components/clusters_view_all.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue
@@ -1,5 +1,6 @@
<script>
import { GlCard, GlSprintf, GlPopover, GlLink, GlBadge, GlLoadingIcon } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import { AGENT_CARD_INFO, CERTIFICATE_BASED_CARD_INFO, MAX_CLUSTERS_LIST } from '../constants';
import Clusters from './clusters.vue';
diff --git a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
index 55e62d1c698..e98e2b37362 100644
--- a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
+++ b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
@@ -32,6 +32,10 @@ export default {
registerAgentPath: helpPagePath('user/clusters/agent/install/index', {
anchor: 'register-the-agent-with-gitlab',
}),
+ terraformDocsLink:
+ 'https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs/resources/cluster_agent_token',
+ minAgentsForTerraform: 10,
+ maxAgents: 100,
components: {
AvailableAgentsDropdown,
AgentToken,
@@ -80,6 +84,7 @@ export default {
clusterAgent: null,
availableAgents: [],
kasDisabled: false,
+ configuredAgentsCount: 0,
};
},
computed: {
@@ -113,6 +118,12 @@ export default {
modalSize() {
return this.kasDisabled ? 'sm' : 'md';
},
+ showTerraformSuggestionAlert() {
+ return this.configuredAgentsCount >= this.$options.minAgentsForTerraform;
+ },
+ showMaxAgentsAlert() {
+ return this.configuredAgentsCount >= this.$options.maxAgents;
+ },
},
methods: {
setAgentName(name) {
@@ -135,6 +146,7 @@ export default {
const configuredAgents =
data?.project?.agentConfigurations?.nodes.map((config) => config.agentName) ?? [];
+ this.configuredAgentsCount = configuredAgents.length;
this.availableAgents = configuredAgents.filter((agent) => !installedAgents.includes(agent));
},
createAgentMutation() {
@@ -233,6 +245,22 @@ export default {
</gl-sprintf>
</p>
+ <gl-alert
+ v-if="showTerraformSuggestionAlert"
+ :dismissible="false"
+ variant="warning"
+ class="gl-my-4"
+ >
+ <span v-if="showMaxAgentsAlert">{{ $options.i18n.maxAgentsSupport }}</span>
+ <span>
+ <gl-sprintf :message="$options.i18n.useTerraformText">
+ <template #link="{ content }">
+ <gl-link :href="$options.terraformDocsLink">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+ </gl-alert>
+
<form>
<gl-form-group label-for="agent-name">
<available-agents-dropdown
diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js
index 3ce10f7c3a2..7c5a2d27829 100644
--- a/app/assets/javascripts/clusters_list/constants.js
+++ b/app/assets/javascripts/clusters_list/constants.js
@@ -131,6 +131,10 @@ export const I18N_AGENT_MODAL = {
learnMoreLink: s__('ClusterAgents|How do I register an agent?'),
registrationErrorTitle: s__('ClusterAgents|Failed to register an agent'),
unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'),
+ maxAgentsSupport: s__('ClusterAgents|We only support 100 agents on the UI.'),
+ useTerraformText: s__(
+ 'ClusterAgents|To manage more agents, %{linkStart}use Terraform%{linkEnd}.',
+ ),
};
export const KAS_DISABLED_ERROR = 'Gitlab::Kas::Client::ConfigurationError';
diff --git a/app/assets/javascripts/clusters_list/store/index.js b/app/assets/javascripts/clusters_list/store/index.js
index 7cdd93eeae9..4161098f199 100644
--- a/app/assets/javascripts/clusters_list/store/index.js
+++ b/app/assets/javascripts/clusters_list/store/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import mutations from './mutations';
diff --git a/app/assets/javascripts/code_navigation/components/app.vue b/app/assets/javascripts/code_navigation/components/app.vue
index 81edbb4182e..67c9ce235cc 100644
--- a/app/assets/javascripts/code_navigation/components/app.vue
+++ b/app/assets/javascripts/code_navigation/components/app.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import eventHub from '~/notes/event_hub';
import Popover from './popover.vue';
diff --git a/app/assets/javascripts/code_navigation/components/popover.vue b/app/assets/javascripts/code_navigation/components/popover.vue
index b7fa3242fbf..8d388fb8a53 100644
--- a/app/assets/javascripts/code_navigation/components/popover.vue
+++ b/app/assets/javascripts/code_navigation/components/popover.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton, GlTabs, GlTab, GlLink, GlBadge } from '@gitlab/ui';
import DocLine from './doc_line.vue';
diff --git a/app/assets/javascripts/code_navigation/index.js b/app/assets/javascripts/code_navigation/index.js
index 83906245b81..11bffec7ae0 100644
--- a/app/assets/javascripts/code_navigation/index.js
+++ b/app/assets/javascripts/code_navigation/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import App from './components/app.vue';
import createStore from './store';
diff --git a/app/assets/javascripts/code_navigation/store/index.js b/app/assets/javascripts/code_navigation/store/index.js
index 6b448bc5fb7..3f9a21c0514 100644
--- a/app/assets/javascripts/code_navigation/store/index.js
+++ b/app/assets/javascripts/code_navigation/store/index.js
@@ -1,3 +1,4 @@
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import actions from './actions';
import mutations from './mutations';
diff --git a/app/assets/javascripts/comment_templates/components/form.vue b/app/assets/javascripts/comment_templates/components/form.vue
index 6bdf1b313cb..c29482eab7a 100644
--- a/app/assets/javascripts/comment_templates/components/form.vue
+++ b/app/assets/javascripts/comment_templates/components/form.vue
@@ -1,9 +1,11 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton, GlForm, GlFormGroup, GlFormInput, GlAlert } from '@gitlab/ui';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { helpPagePath } from '~/helpers/help_page_helper';
import { logError } from '~/lib/logger';
import { __ } from '~/locale';
+import { InternalEvents } from '~/tracking';
import createSavedReplyMutation from '../queries/create_saved_reply.mutation.graphql';
import updateSavedReplyMutation from '../queries/update_saved_reply.mutation.graphql';
@@ -16,6 +18,7 @@ export default {
GlAlert,
MarkdownField,
},
+ mixins: [InternalEvents.mixin()],
props: {
id: {
type: String,
@@ -60,6 +63,13 @@ export default {
},
},
methods: {
+ onCancel() {
+ if (this.id) {
+ this.$router.push({ path: '/' });
+ } else {
+ this.$emit('cancel');
+ }
+ },
onSubmit() {
this.showValidation = true;
@@ -83,6 +93,7 @@ export default {
this.$emit('saved');
this.updateCommentTemplate = { name: '', content: '' };
this.showValidation = false;
+ this.track_event('i_code_review_saved_replies_create');
}
},
})
@@ -135,6 +146,7 @@ export default {
v-model="updateCommentTemplate.name"
:placeholder="__('Enter a name for your comment template')"
data-testid="comment-template-name-input"
+ class="gl-form-input-xl"
/>
</gl-form-group>
<gl-form-group
@@ -142,6 +154,7 @@ export default {
:state="isContentValid"
:invalid-feedback="__('Please enter the comment template content.')"
data-testid="comment-template-content-form-group"
+ class="gl-lg-max-w-80p"
>
<markdown-field
:enable-preview="false"
@@ -177,6 +190,6 @@ export default {
>
{{ __('Save') }}
</gl-button>
- <gl-button v-if="id" :to="{ path: '/' }">{{ __('Cancel') }}</gl-button>
+ <gl-button @click="onCancel">{{ __('Cancel') }}</gl-button>
</gl-form>
</template>
diff --git a/app/assets/javascripts/comment_templates/components/list.vue b/app/assets/javascripts/comment_templates/components/list.vue
index 46d6b49297d..9c460297335 100644
--- a/app/assets/javascripts/comment_templates/components/list.vue
+++ b/app/assets/javascripts/comment_templates/components/list.vue
@@ -1,20 +1,14 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
-import { GlKeysetPagination, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+import { GlKeysetPagination } from '@gitlab/ui';
import ListItem from './list_item.vue';
export default {
components: {
- GlLoadingIcon,
GlKeysetPagination,
- GlSprintf,
ListItem,
},
props: {
- loading: {
- type: Boolean,
- required: false,
- default: false,
- },
savedReplies: {
type: Array,
required: true,
@@ -23,10 +17,6 @@ export default {
type: Object,
required: true,
},
- count: {
- type: Number,
- required: true,
- },
},
methods: {
prevPage() {
@@ -44,28 +34,16 @@ export default {
</script>
<template>
- <div class="settings-section">
- <gl-loading-icon v-if="loading" size="lg" />
- <template v-else>
- <div class="settings-sticky-header">
- <div class="settings-sticky-header-inner">
- <h4 class="gl-my-0" data-testid="title">
- <gl-sprintf :message="__('My comment templates (%{count})')">
- <template #count>{{ count }}</template>
- </gl-sprintf>
- </h4>
- </div>
- </div>
- <ul class="gl-list-style-none gl-p-0 gl-m-0">
- <list-item v-for="template in savedReplies" :key="template.id" :template="template" />
- </ul>
- <gl-keyset-pagination
- v-if="pageInfo.hasPreviousPage || pageInfo.hasNextPage"
- v-bind="pageInfo"
- class="gl-mt-4"
- @prev="prevPage"
- @next="nextPage"
- />
- </template>
+ <div class="gl-new-card-content gl-p-0">
+ <ul class="content-list">
+ <list-item v-for="template in savedReplies" :key="template.id" :template="template" />
+ </ul>
+ <gl-keyset-pagination
+ v-if="pageInfo.hasPreviousPage || pageInfo.hasNextPage"
+ v-bind="pageInfo"
+ class="gl-mt-4"
+ @prev="prevPage"
+ @next="nextPage"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/comment_templates/components/list_item.vue b/app/assets/javascripts/comment_templates/components/list_item.vue
index 70ba449113b..0619201e346 100644
--- a/app/assets/javascripts/comment_templates/components/list_item.vue
+++ b/app/assets/javascripts/comment_templates/components/list_item.vue
@@ -74,8 +74,8 @@ export default {
</script>
<template>
- <li class="gl-pt-4 gl-pb-5 gl-border-b">
- <div class="gl-display-flex gl-align-items-center">
+ <li class="gl-px-5! gl-py-4!">
+ <div class="gl-display-flex">
<h6 class="gl-mr-3 gl-my-0" data-testid="comment-template-name">{{ template.name }}</h6>
<div class="gl-ml-auto">
<gl-disclosure-dropdown
@@ -94,7 +94,9 @@ export default {
</gl-tooltip>
</div>
</div>
- <div class="gl-mt-3 gl-font-monospace gl-white-space-pre-wrap">{{ template.content }}</div>
+ <div class="gl-font-monospace gl-white-space-pre-line gl-font-sm gl-mt-n5">
+ {{ template.content }}
+ </div>
<gl-modal
ref="delete-modal"
:title="__('Delete comment template')"
diff --git a/app/assets/javascripts/comment_templates/pages/edit.vue b/app/assets/javascripts/comment_templates/pages/edit.vue
index 343efdccefa..e9515352399 100644
--- a/app/assets/javascripts/comment_templates/pages/edit.vue
+++ b/app/assets/javascripts/comment_templates/pages/edit.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { fetchPolicies } from '~/lib/graphql';
diff --git a/app/assets/javascripts/comment_templates/pages/index.vue b/app/assets/javascripts/comment_templates/pages/index.vue
index daa4ba689a7..58fbe3574bc 100644
--- a/app/assets/javascripts/comment_templates/pages/index.vue
+++ b/app/assets/javascripts/comment_templates/pages/index.vue
@@ -1,4 +1,6 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
+import { GlCard, GlLoadingIcon, GlIcon, GlButton } from '@gitlab/ui';
import { fetchPolicies } from '~/lib/graphql';
import CreateForm from '../components/form.vue';
import savedRepliesQuery from '../queries/saved_replies.query.graphql';
@@ -27,6 +29,10 @@ export default {
},
},
components: {
+ GlCard,
+ GlButton,
+ GlLoadingIcon,
+ GlIcon,
CreateForm,
List,
},
@@ -36,34 +42,58 @@ export default {
count: 0,
pageInfo: {},
pagination: {},
+ showForm: false,
};
},
methods: {
refetchSavedReplies() {
this.pagination = {};
this.$apollo.queries.savedReplies.refetch();
+ this.toggleShowForm();
},
changePage(pageInfo) {
this.pagination = pageInfo;
},
+ toggleShowForm() {
+ this.showForm = !this.showForm;
+ },
},
};
</script>
<template>
- <div>
- <div class="settings-section">
- <h5 class="gl-mt-0 gl-font-lg">
- {{ __('Add new comment template') }}
- </h5>
- <create-form @saved="refetchSavedReplies" />
+ <gl-card
+ class="gl-new-card gl-overflow-hidden"
+ header-class="gl-new-card-header"
+ body-class="gl-new-card-body gl-px-0"
+ >
+ <template #header>
+ <div class="gl-new-card-title-wrapper" data-testid="title">
+ <h3 class="gl-new-card-title">
+ {{ __('My comment templates') }}
+ </h3>
+ <div class="gl-new-card-count">
+ <gl-icon name="comment-lines" class="gl-mr-2" />
+ {{ count }}
+ </div>
+ </div>
+ <gl-button v-if="!showForm" size="small" class="gl-ml-3" @click="toggleShowForm">
+ {{ __('Add new') }}
+ </gl-button>
+ </template>
+ <div v-if="showForm" class="gl-new-card-add-form gl-m-3 gl-mb-4">
+ <h4 class="gl-mt-0">{{ __('Add new comment template') }}</h4>
+ <create-form @saved="refetchSavedReplies" @cancel="toggleShowForm" />
</div>
+ <gl-loading-icon v-if="$apollo.queries.savedReplies.loading" size="sm" class="gl-my-5" />
<list
- :loading="$apollo.queries.savedReplies.loading"
+ v-else-if="savedReplies"
:saved-replies="savedReplies"
:page-info="pageInfo"
- :count="count"
@input="changePage"
/>
- </div>
+ <div v-else class="gl-new-card-empty gl-px-5 gl-py-4">
+ {{ __('You have no saved replies yet.') }}
+ </div>
+ </gl-card>
</template>
diff --git a/app/assets/javascripts/commit/components/signature_badge.vue b/app/assets/javascripts/commit/components/signature_badge.vue
index 344536df093..edc7c9d2f96 100644
--- a/app/assets/javascripts/commit/components/signature_badge.vue
+++ b/app/assets/javascripts/commit/components/signature_badge.vue
@@ -51,11 +51,11 @@ export default {
class="gl-border-0 gl-outline-0! gl-p-0 gl-bg-transparent"
:aria-label="statusConfig.label"
>
- <gl-badge :variant="statusConfig.variant" size="md" data-testid="signature-status">
+ <gl-badge :variant="statusConfig.variant" size="md">
{{ statusConfig.label }}
</gl-badge>
</button>
- <gl-popover target="signature" triggers="focus" data-testid="signature-info">
+ <gl-popover target="signature" triggers="focus">
<template #title>
{{ statusConfig.title }}
</template>
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table_wrapper.vue b/app/assets/javascripts/commit/pipelines/pipelines_table_wrapper.vue
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table_wrapper.vue
diff --git a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue
index c937e65abe3..aeac744f319 100644
--- a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue
+++ b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlCollapsibleListbox } from '@gitlab/ui';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index 1036b6552d1..25c03496a76 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -193,9 +193,12 @@ export default {
}
},
focus() {
+ this.contentEditor.tiptapEditor.commands.focus();
+ },
+ onFocus() {
this.focused = true;
},
- blur() {
+ onBlur() {
this.focused = false;
},
notifyLoading() {
@@ -230,8 +233,8 @@ export default {
<div class="md-area gl-overflow-hidden">
<editor-state-observer
@docUpdate="notifyChange"
- @focus="focus"
- @blur="blur"
+ @focus="onFocus"
+ @blur="onBlur"
@keydown="$emit('keydown', $event)"
/>
<content-editor-alert />
diff --git a/app/assets/javascripts/content_editor/extensions/copy_paste.js b/app/assets/javascripts/content_editor/extensions/copy_paste.js
index f484ce98e90..ab9e5619600 100644
--- a/app/assets/javascripts/content_editor/extensions/copy_paste.js
+++ b/app/assets/javascripts/content_editor/extensions/copy_paste.js
@@ -182,6 +182,16 @@ export default Extension.create({
);
}
+ const preStartRegex = /^<pre[^>]*lang="markdown"[^>]*>/;
+ const preEndRegex = /<\/pre>$/;
+ const htmlContentWithoutMeta = htmlContent?.replace(/^<meta[^>]*>/, '');
+ const pastingMarkdownBlock =
+ hasHTML &&
+ preStartRegex.test(htmlContentWithoutMeta) &&
+ preEndRegex.test(htmlContentWithoutMeta);
+
+ if (pastingMarkdownBlock) return this.editor.commands.pasteContent(textContent, true);
+
return this.editor.commands.pasteContent(hasHTML ? htmlContent : textContent, !hasHTML);
},
},
diff --git a/app/assets/javascripts/content_editor/services/highlight_js_language_loader.js b/app/assets/javascripts/content_editor/services/highlight_js_language_loader.js
index 5e7c981ace3..964455a3922 100644
--- a/app/assets/javascripts/content_editor/services/highlight_js_language_loader.js
+++ b/app/assets/javascripts/content_editor/services/highlight_js_language_loader.js
@@ -222,6 +222,10 @@ export default {
step21: () => import(/* webpackChunkName: 'hl-step21' */ 'highlight.js/lib/languages/step21'),
stylus: () => import(/* webpackChunkName: 'hl-stylus' */ 'highlight.js/lib/languages/stylus'),
subunit: () => import(/* webpackChunkName: 'hl-subunit' */ 'highlight.js/lib/languages/subunit'),
+ svelte: () =>
+ import(
+ /* webpackChunkName: 'hl-svelte' */ '~/vue_shared/components/source_viewer/languages/svelte'
+ ),
swift: () => import(/* webpackChunkName: 'hl-swift' */ 'highlight.js/lib/languages/swift'),
taggerscript: () =>
import(/* webpackChunkName: 'hl-taggerscript' */ 'highlight.js/lib/languages/taggerscript'),
diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_closed.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_closed.vue
new file mode 100644
index 00000000000..85c42ca5485
--- /dev/null
+++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_closed.vue
@@ -0,0 +1,43 @@
+<script>
+import {
+ EVENT_CLOSED_I18N,
+ TARGET_TYPE_MERGE_REQUEST,
+ EVENT_CLOSED_ICONS,
+} from 'ee_else_ce/contribution_events/constants';
+import { getValueByEventTarget } from '../../utils';
+import ContributionEventBase from './contribution_event_base.vue';
+
+export default {
+ name: 'ContributionEventClosed',
+ components: { ContributionEventBase },
+ props: {
+ event: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ targetType() {
+ return this.event.target.type;
+ },
+ message() {
+ return getValueByEventTarget(EVENT_CLOSED_I18N, this.event);
+ },
+ iconName() {
+ return getValueByEventTarget(EVENT_CLOSED_ICONS, this.event);
+ },
+ iconClass() {
+ return this.targetType === TARGET_TYPE_MERGE_REQUEST ? 'gl-text-red-500' : 'gl-text-blue-500';
+ },
+ },
+};
+</script>
+
+<template>
+ <contribution-event-base
+ :event="event"
+ :message="message"
+ :icon-name="iconName"
+ :icon-class="iconClass"
+ />
+</template>
diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_commented.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_commented.vue
new file mode 100644
index 00000000000..ee433c17792
--- /dev/null
+++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_commented.vue
@@ -0,0 +1,71 @@
+<script>
+import { GlSprintf, GlLink } from '@gitlab/ui';
+import {
+ EVENT_COMMENTED_I18N,
+ EVENT_COMMENTED_SNIPPET_I18N,
+} from 'ee_else_ce/contribution_events/constants';
+import { SNIPPET_NOTEABLE_TYPE, COMMIT_NOTEABLE_TYPE } from '~/notes/constants';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import ResourceParentLink from '../resource_parent_link.vue';
+import ContributionEventBase from './contribution_event_base.vue';
+
+export default {
+ name: 'ContributionEventCommented',
+ components: { ContributionEventBase, GlSprintf, GlLink, ResourceParentLink },
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ event: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ resourceParent() {
+ return this.event.resource_parent;
+ },
+ noteable() {
+ return this.event.noteable;
+ },
+ noteableType() {
+ return this.noteable.type;
+ },
+ message() {
+ if (this.noteableType === SNIPPET_NOTEABLE_TYPE) {
+ return (
+ EVENT_COMMENTED_SNIPPET_I18N[this.resourceParent?.type] ||
+ EVENT_COMMENTED_SNIPPET_I18N.fallback
+ );
+ }
+
+ return EVENT_COMMENTED_I18N[this.noteableType] || EVENT_COMMENTED_I18N.fallback;
+ },
+ noteableLinkClass() {
+ if (this.noteableType === COMMIT_NOTEABLE_TYPE) {
+ return ['gl-font-monospace'];
+ }
+
+ return [];
+ },
+ },
+};
+</script>
+
+<template>
+ <contribution-event-base :event="event" icon-name="comment" icon-class="gl-text-blue-600">
+ <gl-sprintf :message="message">
+ <template #noteableLink>
+ <gl-link :class="noteableLinkClass" :href="noteable.web_url">{{
+ noteable.reference_link_text
+ }}</gl-link>
+ </template>
+ <template #resourceParentLink>
+ <resource-parent-link :event="event" />
+ </template>
+ </gl-sprintf>
+ <template v-if="noteable.first_line_in_markdown" #additional-info>
+ <div v-safe-html="noteable.first_line_in_markdown" class="md"></div>
+ </template>
+ </contribution-event-base>
+</template>
diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_created.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_created.vue
new file mode 100644
index 00000000000..7915cd6679d
--- /dev/null
+++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_created.vue
@@ -0,0 +1,62 @@
+<script>
+import {
+ EVENT_CREATED_I18N,
+ TARGET_TYPE_DESIGN,
+ TYPE_FALLBACK,
+} from 'ee_else_ce/contribution_events/constants';
+import { getValueByEventTarget } from '../../utils';
+import ContributionEventBase from './contribution_event_base.vue';
+
+export default {
+ name: 'ContributionEventCreated',
+ components: { ContributionEventBase },
+ props: {
+ event: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ target() {
+ return this.event.target;
+ },
+ resourceParent() {
+ return this.event.resource_parent;
+ },
+ message() {
+ if (!this.target) {
+ return EVENT_CREATED_I18N[this.resourceParent.type] || EVENT_CREATED_I18N[TYPE_FALLBACK];
+ }
+
+ return getValueByEventTarget(EVENT_CREATED_I18N, this.event);
+ },
+ iconName() {
+ switch (this.target?.type) {
+ case TARGET_TYPE_DESIGN:
+ return 'upload';
+
+ default:
+ return 'status_open';
+ }
+ },
+ iconClass() {
+ switch (this.target?.type) {
+ case TARGET_TYPE_DESIGN:
+ return null;
+
+ default:
+ return 'gl-text-green-500';
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <contribution-event-base
+ :event="event"
+ :message="message"
+ :icon-name="iconName"
+ :icon-class="iconClass"
+ />
+</template>
diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_reopened.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_reopened.vue
new file mode 100644
index 00000000000..36c65950238
--- /dev/null
+++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_reopened.vue
@@ -0,0 +1,36 @@
+<script>
+import {
+ EVENT_REOPENED_I18N,
+ EVENT_REOPENED_ICONS,
+} from 'ee_else_ce/contribution_events/constants';
+import { getValueByEventTarget } from '../../utils';
+import ContributionEventBase from './contribution_event_base.vue';
+
+export default {
+ name: 'ContributionEventReopened',
+ components: { ContributionEventBase },
+ props: {
+ event: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ message() {
+ return getValueByEventTarget(EVENT_REOPENED_I18N, this.event);
+ },
+ iconName() {
+ return getValueByEventTarget(EVENT_REOPENED_ICONS, this.event);
+ },
+ },
+};
+</script>
+
+<template>
+ <contribution-event-base
+ :event="event"
+ :message="message"
+ :icon-name="iconName"
+ icon-class="gl-text-green-500"
+ />
+</template>
diff --git a/app/assets/javascripts/contribution_events/components/contribution_events.vue b/app/assets/javascripts/contribution_events/components/contribution_events.vue
index 62c803b9217..8b42d77675f 100644
--- a/app/assets/javascripts/contribution_events/components/contribution_events.vue
+++ b/app/assets/javascripts/contribution_events/components/contribution_events.vue
@@ -8,6 +8,10 @@ import {
EVENT_TYPE_PUSHED,
EVENT_TYPE_PRIVATE,
EVENT_TYPE_MERGED,
+ EVENT_TYPE_CREATED,
+ EVENT_TYPE_CLOSED,
+ EVENT_TYPE_REOPENED,
+ EVENT_TYPE_COMMENTED,
} from '../constants';
import ContributionEventApproved from './contribution_event/contribution_event_approved.vue';
import ContributionEventExpired from './contribution_event/contribution_event_expired.vue';
@@ -16,6 +20,10 @@ import ContributionEventLeft from './contribution_event/contribution_event_left.
import ContributionEventPushed from './contribution_event/contribution_event_pushed.vue';
import ContributionEventPrivate from './contribution_event/contribution_event_private.vue';
import ContributionEventMerged from './contribution_event/contribution_event_merged.vue';
+import ContributionEventCreated from './contribution_event/contribution_event_created.vue';
+import ContributionEventClosed from './contribution_event/contribution_event_closed.vue';
+import ContributionEventReopened from './contribution_event/contribution_event_reopened.vue';
+import ContributionEventCommented from './contribution_event/contribution_event_commented.vue';
export default {
props: {
@@ -131,6 +139,18 @@ export default {
case EVENT_TYPE_MERGED:
return ContributionEventMerged;
+ case EVENT_TYPE_CREATED:
+ return ContributionEventCreated;
+
+ case EVENT_TYPE_CLOSED:
+ return ContributionEventClosed;
+
+ case EVENT_TYPE_REOPENED:
+ return ContributionEventReopened;
+
+ case EVENT_TYPE_COMMENTED:
+ return ContributionEventCommented;
+
default:
return EmptyComponent;
}
diff --git a/app/assets/javascripts/contribution_events/components/target_link.vue b/app/assets/javascripts/contribution_events/components/target_link.vue
index 6559d6c7272..a14574ed826 100644
--- a/app/assets/javascripts/contribution_events/components/target_link.vue
+++ b/app/assets/javascripts/contribution_events/components/target_link.vue
@@ -14,7 +14,7 @@ export default {
return this.event.target;
},
targetLinkText() {
- return this.target.reference_link_text;
+ return this.target.reference_link_text || this.target.title;
},
targetLinkAttributes() {
return {
diff --git a/app/assets/javascripts/contribution_events/constants.js b/app/assets/javascripts/contribution_events/constants.js
index d4444e3bede..b5eddbf7e25 100644
--- a/app/assets/javascripts/contribution_events/constants.js
+++ b/app/assets/javascripts/contribution_events/constants.js
@@ -1,3 +1,11 @@
+import { s__ } from '~/locale';
+import {
+ ISSUE_NOTEABLE_TYPE,
+ MERGE_REQUEST_NOTEABLE_TYPE,
+ DESIGN_NOTEABLE_TYPE,
+ COMMIT_NOTEABLE_TYPE,
+} from '~/notes/constants';
+
// From app/models/event.rb#L16
export const EVENT_TYPE_CREATED = 'created';
export const EVENT_TYPE_UPDATED = 'updated';
@@ -16,3 +24,118 @@ export const EVENT_TYPE_PRIVATE = 'private';
// From app/models/push_event_payload.rb#L22
export const PUSH_EVENT_REF_TYPE_BRANCH = 'branch';
export const PUSH_EVENT_REF_TYPE_TAG = 'tag';
+
+export const RESOURCE_PARENT_TYPE_PROJECT = 'project';
+
+// From app/models/event.rb#L39
+export const TARGET_TYPE_ISSUE = 'Issue';
+export const TARGET_TYPE_MILESTONE = 'Milestone';
+export const TARGET_TYPE_MERGE_REQUEST = 'MergeRequest';
+export const TARGET_TYPE_WIKI = 'WikiPage::Meta';
+export const TARGET_TYPE_DESIGN = 'DesignManagement::Design';
+export const TARGET_TYPE_WORK_ITEM = 'WorkItem';
+
+// From app/models/work_items/type.rb#L28
+export const WORK_ITEM_ISSUE_TYPE_ISSUE = 'issue';
+export const WORK_ITEM_ISSUE_TYPE_TASK = 'task';
+export const WORK_ITEM_ISSUE_TYPE_INCIDENT = 'incident';
+
+export const TYPE_FALLBACK = 'fallback';
+
+export const EVENT_CREATED_I18N = Object.freeze({
+ [RESOURCE_PARENT_TYPE_PROJECT]: s__('ContributionEvent|Created project %{resourceParentLink}.'),
+ [TARGET_TYPE_MILESTONE]: s__(
+ 'ContributionEvent|Opened milestone %{targetLink} in %{resourceParentLink}.',
+ ),
+ [TARGET_TYPE_MERGE_REQUEST]: s__(
+ 'ContributionEvent|Opened merge request %{targetLink} in %{resourceParentLink}.',
+ ),
+ [TARGET_TYPE_WIKI]: s__(
+ 'ContributionEvent|Created wiki page %{targetLink} in %{resourceParentLink}.',
+ ),
+ [TARGET_TYPE_DESIGN]: s__(
+ 'ContributionEvent|Added design %{targetLink} in %{resourceParentLink}.',
+ ),
+ [WORK_ITEM_ISSUE_TYPE_ISSUE]: s__(
+ 'ContributionEvent|Opened issue %{targetLink} in %{resourceParentLink}.',
+ ),
+ [WORK_ITEM_ISSUE_TYPE_TASK]: s__(
+ 'ContributionEvent|Opened task %{targetLink} in %{resourceParentLink}.',
+ ),
+ [WORK_ITEM_ISSUE_TYPE_INCIDENT]: s__(
+ 'ContributionEvent|Opened incident %{targetLink} in %{resourceParentLink}.',
+ ),
+ [TYPE_FALLBACK]: s__('ContributionEvent|Created resource.'),
+});
+
+export const EVENT_CLOSED_I18N = Object.freeze({
+ [TARGET_TYPE_MILESTONE]: s__(
+ 'ContributionEvent|Closed milestone %{targetLink} in %{resourceParentLink}.',
+ ),
+ [TARGET_TYPE_MERGE_REQUEST]: s__(
+ 'ContributionEvent|Closed merge request %{targetLink} in %{resourceParentLink}.',
+ ),
+ [WORK_ITEM_ISSUE_TYPE_ISSUE]: s__(
+ 'ContributionEvent|Closed issue %{targetLink} in %{resourceParentLink}.',
+ ),
+ [WORK_ITEM_ISSUE_TYPE_TASK]: s__(
+ 'ContributionEvent|Closed task %{targetLink} in %{resourceParentLink}.',
+ ),
+ [WORK_ITEM_ISSUE_TYPE_INCIDENT]: s__(
+ 'ContributionEvent|Closed incident %{targetLink} in %{resourceParentLink}.',
+ ),
+ [TYPE_FALLBACK]: s__('ContributionEvent|Closed resource.'),
+});
+
+export const EVENT_REOPENED_I18N = Object.freeze({
+ [TARGET_TYPE_MILESTONE]: s__(
+ 'ContributionEvent|Reopened milestone %{targetLink} in %{resourceParentLink}.',
+ ),
+ [TARGET_TYPE_MERGE_REQUEST]: s__(
+ 'ContributionEvent|Reopened merge request %{targetLink} in %{resourceParentLink}.',
+ ),
+ [WORK_ITEM_ISSUE_TYPE_ISSUE]: s__(
+ 'ContributionEvent|Reopened issue %{targetLink} in %{resourceParentLink}.',
+ ),
+ [WORK_ITEM_ISSUE_TYPE_TASK]: s__(
+ 'ContributionEvent|Reopened task %{targetLink} in %{resourceParentLink}.',
+ ),
+ [WORK_ITEM_ISSUE_TYPE_INCIDENT]: s__(
+ 'ContributionEvent|Reopened incident %{targetLink} in %{resourceParentLink}.',
+ ),
+ [TYPE_FALLBACK]: s__('ContributionEvent|Reopened resource.'),
+});
+
+export const EVENT_COMMENTED_I18N = Object.freeze({
+ [ISSUE_NOTEABLE_TYPE]: s__(
+ 'ContributionEvent|Commented on issue %{noteableLink} in %{resourceParentLink}.',
+ ),
+ [MERGE_REQUEST_NOTEABLE_TYPE]: s__(
+ 'ContributionEvent|Commented on merge request %{noteableLink} in %{resourceParentLink}.',
+ ),
+ [DESIGN_NOTEABLE_TYPE]: s__(
+ 'ContributionEvent|Commented on design %{noteableLink} in %{resourceParentLink}.',
+ ),
+ [COMMIT_NOTEABLE_TYPE]: s__(
+ 'ContributionEvent|Commented on commit %{noteableLink} in %{resourceParentLink}.',
+ ),
+ fallback: s__('ContributionEvent|Commented on %{noteableLink}.'),
+});
+
+export const EVENT_COMMENTED_SNIPPET_I18N = Object.freeze({
+ [RESOURCE_PARENT_TYPE_PROJECT]: s__(
+ 'ContributionEvent|Commented on snippet %{noteableLink} in %{resourceParentLink}.',
+ ),
+ fallback: s__('ContributionEvent|Commented on snippet %{noteableLink}.'),
+});
+
+export const EVENT_CLOSED_ICONS = Object.freeze({
+ [WORK_ITEM_ISSUE_TYPE_ISSUE]: 'issue-closed',
+ [TARGET_TYPE_MERGE_REQUEST]: 'merge-request-close',
+ [TYPE_FALLBACK]: 'status_closed',
+});
+
+export const EVENT_REOPENED_ICONS = Object.freeze({
+ [TARGET_TYPE_MERGE_REQUEST]: 'merge-request-open',
+ [TYPE_FALLBACK]: 'status_open',
+});
diff --git a/app/assets/javascripts/contribution_events/utils.js b/app/assets/javascripts/contribution_events/utils.js
new file mode 100644
index 00000000000..0760b5187c6
--- /dev/null
+++ b/app/assets/javascripts/contribution_events/utils.js
@@ -0,0 +1,9 @@
+import { TYPE_FALLBACK } from './constants';
+
+export const getValueByEventTarget = (map, event) => {
+ const {
+ target: { type: targetType, issue_type: issueType },
+ } = event;
+
+ return map[issueType || targetType] || map[TYPE_FALLBACK];
+};
diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue
index ce99d5da3cc..21428ff9eca 100644
--- a/app/assets/javascripts/contributors/components/contributors.vue
+++ b/app/assets/javascripts/contributors/components/contributors.vue
@@ -1,7 +1,9 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
import { debounce, uniq } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState, mapGetters } from 'vuex';
import { visitUrl } from '~/lib/utils/url_utility';
import { getDatesInRange } from '~/lib/utils/datetime_utility';
diff --git a/app/assets/javascripts/contributors/stores/index.js b/app/assets/javascripts/contributors/stores/index.js
index a4d0004cee5..f451f50e454 100644
--- a/app/assets/javascripts/contributors/stores/index.js
+++ b/app/assets/javascripts/contributors/stores/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
diff --git a/app/assets/javascripts/crm/contacts/components/contacts_root.vue b/app/assets/javascripts/crm/contacts/components/contacts_root.vue
index 2be17d1f80f..f10565e98e5 100644
--- a/app/assets/javascripts/crm/contacts/components/contacts_root.vue
+++ b/app/assets/javascripts/crm/contacts/components/contacts_root.vue
@@ -231,7 +231,6 @@ export default {
<gl-button
v-if="canAdminCrmContact"
v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel"
- data-testid="edit-contact-button"
icon="pencil"
:aria-label="$options.i18n.editButtonLabel"
/>
diff --git a/app/assets/javascripts/crm/organizations/components/organizations_root.vue b/app/assets/javascripts/crm/organizations/components/organizations_root.vue
index 28f0b34f031..78e1433ab24 100644
--- a/app/assets/javascripts/crm/organizations/components/organizations_root.vue
+++ b/app/assets/javascripts/crm/organizations/components/organizations_root.vue
@@ -226,7 +226,6 @@ export default {
<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"
/>
diff --git a/app/assets/javascripts/custom_emoji/components/app.vue b/app/assets/javascripts/custom_emoji/components/app.vue
new file mode 100644
index 00000000000..405a296397f
--- /dev/null
+++ b/app/assets/javascripts/custom_emoji/components/app.vue
@@ -0,0 +1,15 @@
+<script>
+export default {};
+</script>
+
+<template>
+ <div class="row gl-mt-5">
+ <div class="col-12">
+ <h4 class="gl-mt-0">
+ {{ __('Custom emoji') }}
+ </h4>
+ <p>{{ __('Custom emoji will be available to use in every project in group.') }}</p>
+ <router-view />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/custom_emoji/components/delete_item.vue b/app/assets/javascripts/custom_emoji/components/delete_item.vue
new file mode 100644
index 00000000000..9d13d40dc47
--- /dev/null
+++ b/app/assets/javascripts/custom_emoji/components/delete_item.vue
@@ -0,0 +1,90 @@
+<script>
+import * as Sentry from '@sentry/browser';
+import { uniqueId } from 'lodash';
+import { GlButton, GlTooltipDirective, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
+import { createAlert } from '~/alert';
+import { __ } from '~/locale';
+import deleteCustomEmojiMutation from '../queries/delete_custom_emoji.mutation.graphql';
+
+export default {
+ name: 'DeleteItem',
+ components: {
+ GlButton,
+ GlModal,
+ GlSprintf,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ GlModal: GlModalDirective,
+ },
+ props: {
+ emoji: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isDeleting: false,
+ modalId: uniqueId('delete-custom-emoji-'),
+ };
+ },
+ methods: {
+ showModal() {
+ this.$refs['delete-modal'].show();
+ },
+ async onDelete() {
+ this.isDeleting = true;
+
+ try {
+ await this.$apollo.mutate({
+ mutation: deleteCustomEmojiMutation,
+ variables: {
+ id: this.emoji.id,
+ },
+ update: (cache) => {
+ const cacheId = cache.identify(this.emoji);
+ cache.evict({ id: cacheId });
+ },
+ });
+ } catch (e) {
+ createAlert(__('Failed to delete custom emoji. Please try again.'));
+ Sentry.captureException(e);
+ }
+ },
+ },
+ actionPrimary: { text: __('Delete'), attributes: { variant: 'danger' } },
+ actionSecondary: { text: __('Cancel'), attributes: { variant: 'default' } },
+};
+</script>
+
+<template>
+ <div>
+ <gl-button
+ v-gl-tooltip
+ icon="remove"
+ :aria-label="__('Delete custom emoji')"
+ :title="__('Delete custom emoji')"
+ :loading="isDeleting"
+ data-testid="delete-button"
+ @click="showModal"
+ />
+ <gl-modal
+ ref="delete-modal"
+ :title="__('Delete custom emoji')"
+ :action-primary="$options.actionPrimary"
+ :action-secondary="$options.actionSecondary"
+ :modal-id="modalId"
+ size="sm"
+ @primary="onDelete"
+ >
+ <gl-sprintf
+ :message="__('Are you sure you want to delete %{name}? This action cannot be undone.')"
+ >
+ <template #name
+ ><strong>{{ emoji.name }}</strong></template
+ >
+ </gl-sprintf>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/custom_emoji/components/form.vue b/app/assets/javascripts/custom_emoji/components/form.vue
new file mode 100644
index 00000000000..9f9bc064640
--- /dev/null
+++ b/app/assets/javascripts/custom_emoji/components/form.vue
@@ -0,0 +1,143 @@
+<script>
+import { GlButton, GlForm, GlFormGroup, GlFormInput, GlAlert } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { logError } from '~/lib/logger';
+import { __ } from '~/locale';
+import createCustomEmojiMutation from '../queries/create_custom_emoji.mutation.graphql';
+
+export default {
+ name: 'CustomEmojiForm',
+ components: {
+ GlButton,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlAlert,
+ },
+ inject: {
+ groupPath: {
+ default: '',
+ },
+ },
+ data() {
+ return {
+ errors: [],
+ saving: false,
+ showValidation: false,
+ updateCustomEmoji: {
+ name: '',
+ url: '',
+ },
+ };
+ },
+ computed: {
+ isNameValid() {
+ if (this.showValidation) return Boolean(this.updateCustomEmoji.name);
+
+ return true;
+ },
+ isUrlValid() {
+ if (this.showValidation) return Boolean(this.updateCustomEmoji.url);
+
+ return true;
+ },
+ isValid() {
+ return this.isNameValid && this.isUrlValid;
+ },
+ },
+ methods: {
+ onSubmit() {
+ this.showValidation = true;
+
+ if (!this.isValid) return;
+
+ this.errors = [];
+ this.saving = true;
+
+ this.$apollo
+ .mutate({
+ mutation: createCustomEmojiMutation,
+ variables: {
+ groupPath: this.groupPath,
+ name: this.updateCustomEmoji.name,
+ url: this.updateCustomEmoji.url,
+ },
+ update: (store, { data: { createCustomEmoji } }) => {
+ if (createCustomEmoji.errors.length) {
+ this.errors = createCustomEmoji.errors.map((e) => e);
+ } else {
+ this.$emit('saved');
+ this.updateCustomEmoji = { name: '', url: '' };
+ this.showValidation = false;
+ }
+ },
+ })
+ .catch((error) => {
+ const errors = error.graphQLErrors;
+
+ if (errors?.length) {
+ this.errors = errors.map((e) => e.message);
+ } else {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ logError('Unexpected error while saving emoji', error);
+
+ this.errors = [__('An unexpected error occurred. Please try again.')];
+ }
+ })
+ .finally(() => {
+ this.saving = false;
+ });
+ },
+ },
+ restrictedToolbarItems: ['full-screen'],
+ markdownDocsPath: helpPagePath('user/markdown'),
+};
+</script>
+
+<template>
+ <gl-form class="gl-mb-6" data-testid="custom-emoji-form" @submit.prevent="onSubmit">
+ <gl-alert
+ v-for="error in errors"
+ :key="error"
+ variant="danger"
+ class="gl-mb-3"
+ :dismissible="false"
+ >
+ {{ error }}
+ </gl-alert>
+ <gl-form-group
+ :label="__('Name')"
+ :state="isNameValid"
+ :invalid-feedback="__('Please enter a name for the custom emoji.')"
+ data-testid="custom-emoji-name-form-group"
+ >
+ <gl-form-input
+ v-model="updateCustomEmoji.name"
+ :placeholder="__('eg party_tanuki')"
+ data-testid="custom-emoji-name-input"
+ />
+ </gl-form-group>
+ <gl-form-group
+ :label="__('URL')"
+ :state="isUrlValid"
+ :invalid-feedback="__('Please enter a URL for the custom emoji.')"
+ data-testid="custom-emoji-url-form-group"
+ >
+ <gl-form-input
+ v-model="updateCustomEmoji.url"
+ :placeholder="__('Enter a URL for your custom emoji')"
+ data-testid="custom-emoji-url-input"
+ />
+ </gl-form-group>
+ <gl-button
+ variant="confirm"
+ class="gl-mr-3 js-no-auto-disable"
+ type="submit"
+ :loading="saving"
+ data-testid="custom-emoji-form-submit-btn"
+ >
+ {{ __('Save') }}
+ </gl-button>
+ <gl-button :to="{ path: '/' }">{{ __('Cancel') }}</gl-button>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/custom_emoji/components/list.vue b/app/assets/javascripts/custom_emoji/components/list.vue
new file mode 100644
index 00000000000..72b28e8db4a
--- /dev/null
+++ b/app/assets/javascripts/custom_emoji/components/list.vue
@@ -0,0 +1,154 @@
+<!-- eslint-disable vue/multi-word-component-names -->
+<script>
+import { GlLoadingIcon, GlTableLite, GlTabs, GlTab, GlBadge, GlKeysetPagination } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { formatDate } from '~/lib/utils/datetime/date_format_utility';
+import DeleteItem from './delete_item.vue';
+
+export default {
+ components: {
+ GlTableLite,
+ GlLoadingIcon,
+ GlTabs,
+ GlTab,
+ GlBadge,
+ GlKeysetPagination,
+ DeleteItem,
+ },
+ props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ customEmojis: {
+ type: Array,
+ required: true,
+ },
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
+ count: {
+ type: Number,
+ required: true,
+ },
+ userPermissions: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ primaryAction() {
+ if (!this.userPermissions.createCustomEmoji) return undefined;
+
+ return {
+ text: __('New custom emoji'),
+ attributes: {
+ variant: 'info',
+ to: '/new',
+ },
+ };
+ },
+ },
+ methods: {
+ prevPage() {
+ this.$emit('input', {
+ before: this.pageInfo.startCursor,
+ });
+ },
+ nextPage() {
+ this.$emit('input', {
+ after: this.pageInfo.endCursor,
+ });
+ },
+ formatDate(date) {
+ return formatDate(date, 'mmmm d, yyyy');
+ },
+ },
+ fields: [
+ {
+ key: 'emoji',
+ label: __('Image'),
+ thClass: 'gl-border-t-0!',
+ tdClass: 'gl-vertical-align-middle!',
+ columnWidth: '70px',
+ },
+ {
+ key: 'name',
+ label: __('Name'),
+ thClass: 'gl-border-t-0!',
+ tdClass: 'gl-vertical-align-middle! gl-font-monospace',
+ },
+ {
+ key: 'created_at',
+ label: __('Created date'),
+ thClass: 'gl-border-t-0!',
+ tdClass: 'gl-vertical-align-middle!',
+ columnWidth: '25%',
+ },
+ {
+ key: 'action',
+ label: '',
+ thClass: 'gl-border-t-0!',
+ columnWidth: '64px',
+ },
+ ],
+};
+</script>
+
+<template>
+ <div>
+ <gl-loading-icon v-if="loading" size="lg" />
+ <template v-else>
+ <gl-tabs content-class="gl-pt-0" :action-primary="primaryAction">
+ <gl-tab>
+ <template #title>
+ {{ __('Emoji') }}
+ <gl-badge size="sm" class="gl-tab-counter-badge">{{ count }}</gl-badge>
+ </template>
+ <gl-table-lite
+ :items="customEmojis"
+ :fields="$options.fields"
+ table-class="gl-table-layout-fixed"
+ >
+ <template #table-colgroup="scope">
+ <col
+ v-for="field in scope.fields"
+ :key="field.key"
+ :style="{ width: field.columnWidth }"
+ />
+ </template>
+ <template #cell(emoji)="data">
+ <gl-emoji
+ :data-name="data.item.name"
+ :data-fallback-src="data.item.url"
+ data-unicode-version="custom"
+ />
+ </template>
+ <template #cell(action)="data">
+ <delete-item
+ v-if="data.item.userPermissions.deleteCustomEmoji"
+ :key="data.item.name"
+ :emoji="data.item"
+ />
+ </template>
+ <template #cell(created_at)="data">
+ {{ formatDate(data.item.createdAt) }}
+ </template>
+ <template #cell(name)="data">
+ <strong class="gl-str-truncated">:{{ data.item.name }}:</strong>
+ </template>
+ </gl-table-lite>
+ <gl-keyset-pagination
+ v-if="pageInfo.hasPreviousPage || pageInfo.hasNextPage"
+ v-bind="pageInfo"
+ class="gl-mt-4"
+ @prev="prevPage"
+ @next="nextPage"
+ />
+ </gl-tab>
+ </gl-tabs>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/custom_emoji/custom_emoji_bundle.js b/app/assets/javascripts/custom_emoji/custom_emoji_bundle.js
new file mode 100644
index 00000000000..c9c3f0831fd
--- /dev/null
+++ b/app/assets/javascripts/custom_emoji/custom_emoji_bundle.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+import VueApollo from 'vue-apollo';
+import defaultClient from './graphql_client';
+import routes from './routes';
+import App from './components/app.vue';
+
+export const initCustomEmojis = () => {
+ Vue.use(VueApollo);
+ Vue.use(VueRouter);
+
+ const el = document.getElementById('js-custom-emojis-root');
+
+ if (!el) return;
+
+ const apolloProvider = new VueApollo({
+ defaultClient,
+ });
+ const router = new VueRouter({
+ base: el.dataset.basePath,
+ mode: 'history',
+ routes,
+ });
+ const { groupPath } = el.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ name: 'CustomEmojiApp',
+ router,
+ apolloProvider,
+ provide: {
+ groupPath,
+ },
+ render(createElement) {
+ return createElement(App);
+ },
+ });
+};
diff --git a/app/assets/javascripts/custom_emoji/graphql_client.js b/app/assets/javascripts/custom_emoji/graphql_client.js
new file mode 100644
index 00000000000..42d0c82c828
--- /dev/null
+++ b/app/assets/javascripts/custom_emoji/graphql_client.js
@@ -0,0 +1,3 @@
+import createDefaultClient from '~/lib/graphql';
+
+export default createDefaultClient();
diff --git a/app/assets/javascripts/custom_emoji/pages/index.vue b/app/assets/javascripts/custom_emoji/pages/index.vue
new file mode 100644
index 00000000000..118d6213acd
--- /dev/null
+++ b/app/assets/javascripts/custom_emoji/pages/index.vue
@@ -0,0 +1,67 @@
+<!-- eslint-disable vue/multi-word-component-names -->
+<script>
+import { fetchPolicies } from '~/lib/graphql';
+import customEmojisQuery from '../queries/custom_emojis.query.graphql';
+import List from '../components/list.vue';
+
+export default {
+ apollo: {
+ customEmojis: {
+ fetchPolicy: fetchPolicies.NETWORK_ONLY,
+ query: customEmojisQuery,
+ update: (r) => r.group?.customEmoji?.nodes,
+ variables() {
+ return {
+ groupPath: this.groupPath,
+ ...this.pagination,
+ };
+ },
+ result({ data }) {
+ const pageInfo = data.group?.customEmoji?.pageInfo;
+ this.count = data.group?.customEmoji?.count;
+ this.userPermissions = data.group?.userPermissions;
+
+ if (pageInfo) {
+ this.pageInfo = pageInfo;
+ }
+ },
+ },
+ },
+ components: {
+ List,
+ },
+ inject: {
+ groupPath: {
+ default: '',
+ },
+ },
+ data() {
+ return {
+ customEmojis: [],
+ count: 0,
+ pageInfo: {},
+ pagination: {},
+ userPermissions: {},
+ };
+ },
+ methods: {
+ refetchCustomEmojis() {
+ this.$apollo.queries.customEmojis.refetch();
+ },
+ changePage(pageInfo) {
+ this.pagination = pageInfo;
+ },
+ },
+};
+</script>
+
+<template>
+ <list
+ :count="count"
+ :loading="$apollo.queries.customEmojis.loading"
+ :page-info="pageInfo"
+ :custom-emojis="customEmojis"
+ :user-permissions="userPermissions"
+ @input="changePage"
+ />
+</template>
diff --git a/app/assets/javascripts/custom_emoji/pages/new.vue b/app/assets/javascripts/custom_emoji/pages/new.vue
new file mode 100644
index 00000000000..803a3b7a7ae
--- /dev/null
+++ b/app/assets/javascripts/custom_emoji/pages/new.vue
@@ -0,0 +1,24 @@
+<!-- eslint-disable vue/multi-word-component-names -->
+<script>
+import CreateForm from '../components/form.vue';
+
+export default {
+ components: {
+ CreateForm,
+ },
+ methods: {
+ redirectToIndex() {
+ this.$router.push('/');
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h5 class="gl-mt-0 gl-font-lg">
+ {{ __('Add new emoji') }}
+ </h5>
+ <create-form @saved="redirectToIndex" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/custom_emoji/queries/create_custom_emoji.mutation.graphql b/app/assets/javascripts/custom_emoji/queries/create_custom_emoji.mutation.graphql
new file mode 100644
index 00000000000..2c0dbfdf93e
--- /dev/null
+++ b/app/assets/javascripts/custom_emoji/queries/create_custom_emoji.mutation.graphql
@@ -0,0 +1,5 @@
+mutation createCustomEmoji($groupPath: ID!, $name: String!, $url: String!) {
+ createCustomEmoji(input: { groupPath: $groupPath, name: $name, url: $url }) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/custom_emoji/queries/custom_emojis.query.graphql b/app/assets/javascripts/custom_emoji/queries/custom_emojis.query.graphql
new file mode 100644
index 00000000000..a4189f80436
--- /dev/null
+++ b/app/assets/javascripts/custom_emoji/queries/custom_emojis.query.graphql
@@ -0,0 +1,25 @@
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
+
+query getCustomEmojis($groupPath: ID!, $after: String = "", $before: String = "") {
+ group(fullPath: $groupPath) {
+ id
+ userPermissions {
+ createCustomEmoji
+ }
+ customEmoji(after: $after, before: $before) {
+ count
+ pageInfo {
+ ...PageInfo
+ }
+ nodes {
+ id
+ name
+ url
+ createdAt
+ userPermissions {
+ deleteCustomEmoji
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/custom_emoji/queries/delete_custom_emoji.mutation.graphql b/app/assets/javascripts/custom_emoji/queries/delete_custom_emoji.mutation.graphql
new file mode 100644
index 00000000000..37618bc2749
--- /dev/null
+++ b/app/assets/javascripts/custom_emoji/queries/delete_custom_emoji.mutation.graphql
@@ -0,0 +1,7 @@
+mutation deleteCustomEmoji($id: CustomEmojiID!) {
+ destroyCustomEmoji(input: { id: $id }) {
+ customEmoji {
+ id
+ }
+ }
+}
diff --git a/app/assets/javascripts/custom_emoji/queries/user_permissions.query.graphql b/app/assets/javascripts/custom_emoji/queries/user_permissions.query.graphql
new file mode 100644
index 00000000000..284a9290a03
--- /dev/null
+++ b/app/assets/javascripts/custom_emoji/queries/user_permissions.query.graphql
@@ -0,0 +1,8 @@
+query customEmojiPermissions($groupPath: ID!) {
+ group(fullPath: $groupPath) {
+ id
+ userPermissions {
+ createCustomEmoji
+ }
+ }
+}
diff --git a/app/assets/javascripts/custom_emoji/routes.js b/app/assets/javascripts/custom_emoji/routes.js
new file mode 100644
index 00000000000..938475d81cd
--- /dev/null
+++ b/app/assets/javascripts/custom_emoji/routes.js
@@ -0,0 +1,35 @@
+import IndexComponent from './pages/index.vue';
+import NewComponent from './pages/new.vue';
+import userPermissionsQuery from './queries/user_permissions.query.graphql';
+import defaultClient from './graphql_client';
+
+export default [
+ {
+ path: '/',
+ component: IndexComponent,
+ },
+ {
+ path: '/new',
+ component: NewComponent,
+ async beforeEnter(to, from, next) {
+ const {
+ data: {
+ group: {
+ userPermissions: { createCustomEmoji },
+ },
+ },
+ } = await defaultClient.query({
+ query: userPermissionsQuery,
+ variables: {
+ groupPath: document.body.dataset.groupFullPath,
+ },
+ });
+
+ if (!createCustomEmoji) {
+ next({ path: '/' });
+ } else {
+ next();
+ }
+ },
+ },
+];
diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue
index b13b0ede9f0..72d1ce9768a 100644
--- a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue
+++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue
@@ -1,6 +1,7 @@
<script>
import { GlFormGroup, GlFormInput, GlModal, GlSprintf, GlLink } from '@gitlab/ui';
import { isValidCron } from 'cron-validator';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import { __ } from '~/locale';
import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue';
@@ -24,10 +25,10 @@ export default {
static: true,
lazy: true,
},
- translations: {
+ i18n: {
cronPlaceholder: '* * * * *',
cronSyntaxInstructions: __(
- 'Define a custom deploy freeze pattern with %{cronSyntaxStart}cron syntax%{cronSyntaxEnd}',
+ 'Define a custom deploy freeze pattern with %{cronSyntaxStart}cron syntax%{cronSyntaxEnd}.',
),
addTitle: __('Add deploy freeze'),
editTitle: __('Edit deploy freeze'),
@@ -81,9 +82,7 @@ export default {
return Boolean(this.selectedId);
},
modalTitle() {
- return this.isEditing
- ? this.$options.translations.editTitle
- : this.$options.translations.addTitle;
+ return this.isEditing ? this.$options.i18n.editTitle : this.$options.i18n.addTitle;
},
},
methods: {
@@ -104,6 +103,13 @@ export default {
this.addFreezePeriod();
}
},
+ focusFirstInput() {
+ if (this.$refs.freezeStartCron) {
+ setTimeout(() => {
+ this.$refs.freezeStartCron?.$el?.focus();
+ }, 250);
+ }
+ },
},
};
</script>
@@ -115,9 +121,10 @@ export default {
:action-primary="addDeployFreezeButton"
@primary="submit"
@canceled="resetModalHandler"
+ @change="focusFirstInput"
>
<p>
- <gl-sprintf :message="$options.translations.cronSyntaxInstructions">
+ <gl-sprintf :message="$options.i18n.cronSyntaxInstructions">
<template #cronSyntax="{ content }">
<gl-link href="https://crontab.guru/" target="_blank">{{ content }}</gl-link>
</template>
@@ -132,11 +139,13 @@ export default {
>
<gl-form-input
id="deploy-freeze-start"
+ ref="freezeStartCron"
v-model="freezeStartCron"
class="gl-font-monospace!"
data-qa-selector="deploy_freeze_start_field"
- :placeholder="$options.translations.cronPlaceholder"
+ :placeholder="$options.i18n.cronPlaceholder"
:state="freezeStartCronState"
+ autofocus
trim
/>
</gl-form-group>
@@ -152,7 +161,7 @@ export default {
v-model="freezeEndCron"
class="gl-font-monospace!"
data-qa-selector="deploy_freeze_end_field"
- :placeholder="$options.translations.cronPlaceholder"
+ :placeholder="$options.i18n.cronPlaceholder"
:state="freezeEndCronState"
trim
/>
diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue
index 77767456f76..705b1871ec0 100644
--- a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue
+++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue
@@ -1,39 +1,49 @@
<script>
-import { GlTable, GlButton, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
+import {
+ GlCard,
+ GlTable,
+ GlButton,
+ GlIcon,
+ GlModal,
+ GlModalDirective,
+ GlSprintf,
+} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
export default {
fields: [
{
key: 'freezeStart',
label: s__('DeployFreeze|Freeze start'),
+ tdClass: 'gl-vertical-align-middle!',
},
{
key: 'freezeEnd',
label: s__('DeployFreeze|Freeze end'),
+ tdClass: 'gl-vertical-align-middle!',
},
{
key: 'cronTimezone',
label: s__('DeployFreeze|Time zone'),
+ tdClass: 'gl-vertical-align-middle!',
},
{
- key: 'edit',
- label: s__('DeployFreeze|Edit'),
- },
- {
- key: 'delete',
- label: s__('DeployFreeze|Delete'),
+ key: 'actions',
+ label: __('Actions'),
+ thClass: 'gl-text-right',
},
],
- translations: {
+ i18n: {
+ title: s__('DeployFreeze|Deploy freezes'),
addDeployFreeze: s__('DeployFreeze|Add deploy freeze'),
deleteDeployFreezeTitle: s__('DeployFreeze|Delete deploy freeze?'),
deleteDeployFreezeMessage: s__(
'DeployFreeze|Deploy freeze from %{start} to %{end} in %{timezone} will be removed. Are you sure?',
),
emptyStateText: s__(
- 'DeployFreeze|No deploy freezes exist for this project. To add one, select %{strongStart}Add deploy freeze%{strongEnd}',
+ 'DeployFreeze|No deploy freezes exist for this project. To add one, select %{strongStart}Add deploy freeze%{strongEnd} above.',
),
},
modal: {
@@ -42,10 +52,16 @@ export default {
text: s__('DeployFreeze|Delete freeze period'),
attributes: { variant: 'danger', 'data-testid': 'modal-confirm' },
},
+ actionSecondary: {
+ text: __('Cancel'),
+ attributes: { variant: 'default' },
+ },
},
components: {
+ GlCard,
GlTable,
GlButton,
+ GlIcon,
GlModal,
GlSprintf,
},
@@ -80,65 +96,78 @@ export default {
</script>
<template>
- <div class="deploy-freeze-table">
+ <gl-card
+ class="gl-new-card deploy-freeze-table"
+ header-class="gl-new-card-header"
+ body-class="gl-new-card-body gl-px-0"
+ >
+ <template #header>
+ <div class="gl-new-card-title-wrapper">
+ <h3 class="gl-new-card-title">{{ $options.i18n.title }}</h3>
+ <span class="gl-new-card-count">
+ <gl-icon name="deployments" class="gl-mr-2" />
+ {{ freezePeriods.length }}
+ </span>
+ </div>
+ <div class="gl-new-card-actions">
+ <gl-button v-gl-modal.deploy-freeze-modal size="small" data-testid="add-deploy-freeze">{{
+ $options.i18n.addDeployFreeze
+ }}</gl-button>
+ </div>
+ </template>
+
<gl-table
data-testid="deploy-freeze-table"
:items="freezePeriods"
:fields="$options.fields"
show-empty
- stacked="lg"
+ stacked="md"
>
<template #cell(cronTimezone)="{ item }">
{{ item.cronTimezone.formattedTimezone }}
</template>
- <template #cell(edit)="{ item }">
- <gl-button
- v-gl-modal.deploy-freeze-modal
- icon="pencil"
- data-testid="edit-deploy-freeze"
- :aria-label="__('Edit deploy freeze')"
- @click="setFreezePeriod(item)"
- />
- </template>
- <template #cell(delete)="{ item }">
- <gl-button
- v-gl-modal="$options.modal.id"
- category="secondary"
- variant="danger"
- icon="remove"
- :aria-label="$options.modal.actionPrimary.text"
- :loading="item.isDeleting"
- data-testid="delete-deploy-freeze"
- @click="handleDeleteFreezePeriod(item)"
- />
+ <template #cell(actions)="{ item }">
+ <div class="gl-display-flex gl-justify-content-end gl-mt-n2 gl-mb-n2">
+ <gl-button
+ v-gl-modal.deploy-freeze-modal
+ icon="pencil"
+ data-testid="edit-deploy-freeze"
+ :aria-label="__('Edit deploy freeze')"
+ class="gl-mr-3"
+ @click="setFreezePeriod(item)"
+ />
+ <gl-button
+ v-gl-modal="$options.modal.id"
+ category="secondary"
+ variant="danger"
+ icon="remove"
+ :aria-label="$options.modal.actionPrimary.text"
+ :loading="item.isDeleting"
+ data-testid="delete-deploy-freeze"
+ @click="handleDeleteFreezePeriod(item)"
+ />
+ </div>
</template>
<template #empty>
- <p data-testid="empty-freeze-periods" class="gl-text-center text-plain">
- <gl-sprintf :message="$options.translations.emptyStateText">
+ <p data-testid="empty-freeze-periods" class="gl-text-secondary gl-text-center gl-mb-0">
+ <gl-sprintf :message="$options.i18n.emptyStateText">
<template #strong="{ content }">
- <strong>{{ content }}</strong>
+ {{ content }}
</template>
</gl-sprintf>
</p>
</template>
</gl-table>
- <gl-button
- v-gl-modal.deploy-freeze-modal
- data-testid="add-deploy-freeze"
- category="primary"
- variant="confirm"
- >
- {{ $options.translations.addDeployFreeze }}
- </gl-button>
<gl-modal
- :title="$options.translations.deleteDeployFreezeTitle"
+ :title="$options.i18n.deleteDeployFreezeTitle"
:modal-id="$options.modal.id"
:action-primary="$options.modal.actionPrimary"
+ :action-secondary="$options.modal.actionSecondary"
static
@primary="confirmDeleteFreezePeriod"
>
<template v-if="freezePeriodToDelete">
- <gl-sprintf :message="$options.translations.deleteDeployFreezeMessage">
+ <gl-sprintf :message="$options.i18n.deleteDeployFreezeMessage">
<template #start>
<code>{{ freezePeriodToDelete.freezeStart }}</code>
</template>
@@ -149,5 +178,5 @@ export default {
</gl-sprintf>
</template>
</gl-modal>
- </div>
+ </gl-card>
</template>
diff --git a/app/assets/javascripts/deploy_freeze/store/index.js b/app/assets/javascripts/deploy_freeze/store/index.js
index 2da7ed31a13..d8c35eac7ee 100644
--- a/app/assets/javascripts/deploy_freeze/store/index.js
+++ b/app/assets/javascripts/deploy_freeze/store/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import mutations from './mutations';
diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue
index 5fc15578827..ec17bbea48f 100644
--- a/app/assets/javascripts/deploy_keys/components/app.vue
+++ b/app/assets/javascripts/deploy_keys/components/app.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
+import { GlButton, GlIcon, GlLoadingIcon } from '@gitlab/ui';
import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
@@ -14,8 +14,9 @@ export default {
ConfirmModal,
KeysPanel,
NavigationTabs,
- GlLoadingIcon,
+ GlButton,
GlIcon,
+ GlLoadingIcon,
},
props: {
endpoint: {
@@ -42,6 +43,10 @@ export default {
available_project_keys: s__('DeployKeys|Privately accessible deploy keys'),
public_keys: s__('DeployKeys|Publicly accessible deploy keys'),
},
+ i18n: {
+ loading: s__('DeployKeys|Loading deploy keys'),
+ addButton: s__('DeployKeys|Add new key'),
+ },
computed: {
tabs() {
return Object.keys(this.$options.scopes).map((scope) => {
@@ -132,30 +137,48 @@ export default {
</script>
<template>
- <div class="gl-mb-3 deploy-keys">
+ <div class="deploy-keys">
<confirm-modal :visible="confirmModalVisible" @remove="removeKey" @cancel="cancel" />
<gl-loading-icon
v-if="isLoading && !hasKeys"
- :label="s__('DeployKeys|Loading deploy keys')"
- size="lg"
+ :label="$options.i18n.loading"
+ size="sm"
+ class="gl-m-5"
/>
<template v-else-if="hasKeys">
- <div class="top-area scrolling-tabs-container inner-page-scroll-tabs">
- <div class="fade-left">
- <gl-icon name="chevron-lg-left" :size="12" />
- </div>
- <div class="fade-right">
- <gl-icon name="chevron-lg-right" :size="12" />
+ <div class="gl-new-card-header gl-align-items-center gl-pt-0 gl-pb-0 gl-pl-0">
+ <div class="top-area scrolling-tabs-container inner-page-scroll-tabs gl-border-b-0">
+ <div class="fade-left">
+ <gl-icon name="chevron-lg-left" :size="12" />
+ </div>
+ <div class="fade-right">
+ <gl-icon name="chevron-lg-right" :size="12" />
+ </div>
+
+ <navigation-tabs
+ :tabs="tabs"
+ scope="deployKeys"
+ class="gl-rounded-lg"
+ @onChangeTab="onChangeTab"
+ />
</div>
- <navigation-tabs :tabs="tabs" scope="deployKeys" @onChangeTab="onChangeTab" />
+ <div class="gl-new-card-actions">
+ <gl-button
+ size="small"
+ class="js-toggle-button js-toggle-content"
+ data-testid="add-new-deploy-key-button"
+ >
+ {{ $options.i18n.addButton }}
+ </gl-button>
+ </div>
</div>
<keys-panel
:project-id="projectId"
:keys="keys[currentTab]"
:store="store"
:endpoint="endpoint"
- data-qa-selector="project_deploy_keys_container"
+ data-testid="project-deploy-keys-container"
/>
</template>
</div>
diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue
index 94f27dbf048..16c745d8cff 100644
--- a/app/assets/javascripts/deploy_keys/components/key.vue
+++ b/app/assets/javascripts/deploy_keys/components/key.vue
@@ -1,5 +1,6 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
-import { GlIcon, GlLink, GlTooltipDirective, GlButton } from '@gitlab/ui';
+import { GlBadge, GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { head, tail } from 'lodash';
import { s__, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
@@ -9,9 +10,9 @@ import ActionBtn from './action_btn.vue';
export default {
components: {
ActionBtn,
+ GlBadge,
GlButton,
GlIcon,
- GlLink,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -110,21 +111,30 @@ export default {
</script>
<template>
- <div class="gl-responsive-table-row deploy-key">
+ <div
+ class="gl-responsive-table-row gl-align-items-flex-start deploy-key gl-bg-gray-10 gl-md-pl-5 gl-md-pr-5 gl-border-gray-100!"
+ >
<div class="table-section section-40">
- <div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Deploy key') }}</div>
- <div class="table-mobile-content" data-qa-selector="key_container">
- <strong class="title" data-qa-selector="key_title_content"> {{ deployKey.title }} </strong>
- <dl>
+ <div
+ role="rowheader"
+ class="table-mobile-header gl-align-self-start gl-font-weight-bold gl-text-gray-700"
+ >
+ {{ s__('DeployKeys|Deploy key') }}
+ </div>
+ <div class="table-mobile-content" data-testid="key-container">
+ <p class="title gl-font-weight-semibold gl-text-gray-700" data-testid="key-title-content">
+ {{ deployKey.title }}
+ </p>
+ <dl class="gl-font-sm gl-mb-0">
<dt>{{ __('SHA256') }}</dt>
- <dd class="fingerprint" data-qa-selector="key_sha256_fingerprint_content">
+ <dd class="fingerprint" data-testid="key-sha256-fingerprint-content">
{{ deployKey.fingerprint_sha256 }}
</dd>
<template v-if="deployKey.fingerprint">
<dt>
{{ __('MD5') }}
</dt>
- <dd class="fingerprint" data-qa-selector="key_md5_fingerprint_content">
+ <dd class="fingerprint">
{{ deployKey.fingerprint }}
</dd>
</template>
@@ -132,53 +142,62 @@ export default {
</div>
</div>
<div class="table-section section-20 section-wrap">
- <div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Project usage') }}</div>
- <div class="table-mobile-content deploy-project-list">
+ <div role="rowheader" class="table-mobile-header gl-font-weight-bold gl-text-gray-700">
+ {{ s__('DeployKeys|Project usage') }}
+ </div>
+ <div class="table-mobile-content deploy-project-list gl-display-flex gl-flex-wrap">
<template v-if="projects.length > 0">
- <gl-link
+ <gl-badge
v-gl-tooltip
:title="projectTooltipTitle(firstProject)"
- class="label deploy-project-label"
+ :icon="firstProject.can_push ? 'lock-open' : 'lock'"
+ class="deploy-project-label gl-mr-2 gl-mb-2 gl-truncate"
>
- <span> {{ firstProject.project.full_name }} </span>
- <gl-icon :name="firstProject.can_push ? 'lock-open' : 'lock'" />
- </gl-link>
- <gl-link
+ <span class="gl-text-truncate">{{ firstProject.project.full_name }}</span>
+ </gl-badge>
+
+ <gl-badge
v-if="isExpandable"
v-gl-tooltip
:title="restProjectsTooltip"
- class="label deploy-project-label"
- @click="toggleExpanded"
+ class="deploy-project-label gl-mr-2 gl-mb-2 gl-truncate"
+ href="#"
+ @click.native="toggleExpanded"
>
- <span>{{ restProjectsLabel }}</span>
- </gl-link>
- <gl-link
+ <span class="gl-text-truncate">{{ restProjectsLabel }}</span>
+ </gl-badge>
+
+ <gl-badge
v-for="deployKeysProject in restProjects"
v-else-if="isExpanded"
:key="deployKeysProject.project.full_path"
v-gl-tooltip
:href="deployKeysProject.project.full_path"
:title="projectTooltipTitle(deployKeysProject)"
- class="label deploy-project-label"
+ :icon="deployKeysProject.can_push ? 'lock-open' : 'lock'"
+ class="deploy-project-label gl-mr-2 gl-mb-2 gl-truncate"
>
- <span> {{ deployKeysProject.project.full_name }} </span>
- <gl-icon :name="deployKeysProject.can_push ? 'lock-open' : 'lock'" />
- </gl-link>
+ <span class="gl-text-truncate">{{ deployKeysProject.project.full_name }}</span>
+ </gl-badge>
</template>
- <span v-else class="text-secondary">{{ __('None') }}</span>
+ <span v-else class="gl-text-secondary">{{ __('None') }}</span>
</div>
</div>
<div class="table-section section-15">
- <div role="rowheader" class="table-mobile-header">{{ __('Created') }}</div>
- <div class="table-mobile-content text-secondary key-created-at">
+ <div role="rowheader" class="table-mobile-header gl-font-weight-bold gl-text-gray-700">
+ {{ __('Created') }}
+ </div>
+ <div class="table-mobile-content gl-text-gray-700 key-created-at">
<span v-gl-tooltip :title="tooltipTitle(deployKey.created_at)">
<gl-icon name="calendar" /> <span>{{ timeFormatted(deployKey.created_at) }}</span>
</span>
</div>
</div>
<div class="table-section section-15">
- <div role="rowheader" class="table-mobile-header">{{ __('Expires') }}</div>
- <div class="table-mobile-content text-secondary key-expires-at">
+ <div role="rowheader" class="table-mobile-header gl-font-weight-bold gl-text-gray-700">
+ {{ __('Expires') }}
+ </div>
+ <div class="table-mobile-content gl-text-gray-700 key-expires-at">
<span
v-if="deployKey.expires_at"
v-gl-tooltip
@@ -213,7 +232,7 @@ export default {
:deploy-key="deployKey"
:title="__('Remove')"
:aria-label="__('Remove')"
- category="primary"
+ category="secondary"
variant="danger"
icon="remove"
type="remove"
@@ -228,7 +247,7 @@ export default {
type="disable"
data-container="body"
icon="cancel"
- category="primary"
+ category="secondary"
variant="danger"
/>
</div>
diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
index e04cbbe72b9..dac63188aa5 100644
--- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue
+++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
@@ -28,9 +28,12 @@ export default {
</script>
<template>
- <div class="deploy-keys-panel table-holder">
+ <div class="deploy-keys-panel table-holder gl-bg-white gl-rounded-lg">
<template v-if="keys.length > 0">
- <div role="row" class="gl-responsive-table-row table-row-header">
+ <div
+ role="row"
+ class="gl-responsive-table-row table-row-header gl-font-base gl-font-weight-bold gl-text-gray-900 gl-md-pl-5 gl-md-pr-5 gl-bg-gray-10 gl-border-gray-100!"
+ >
<div role="rowheader" class="table-section section-40">
{{ s__('DeployKeys|Deploy key') }}
</div>
@@ -50,8 +53,8 @@ export default {
:project-id="projectId"
/>
</template>
- <div v-else class="settings-message text-center gl-mt-5">
- {{ s__('DeployKeys|No deploy keys found. Create one with the form above.') }}
+ <div v-else class="gl-new-card-empty gl-bg-gray-10 gl-text-center gl-p-5">
+ {{ s__('DeployKeys|No deploy keys found, start by adding a new one above.') }}
</div>
</div>
</template>
diff --git a/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue
index 7ec3ec3f84d..a56fce98f85 100644
--- a/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue
+++ b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue
@@ -8,7 +8,9 @@ import {
GlFormInputGroup,
GlSprintf,
GlLink,
+ GlAlert,
} from '@gitlab/ui';
+import { MountingPortal } from 'portal-vue';
import { createAlert, VARIANT_INFO } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { formatDate } from '~/lib/utils/datetime_utility';
@@ -26,6 +28,8 @@ export default {
ClipboardButton,
GlSprintf,
GlLink,
+ GlAlert,
+ MountingPortal,
},
props: {
@@ -170,12 +174,17 @@ export default {
</script>
<template>
<div>
- <div v-if="newTokenDetails" class="created-deploy-token-container info-well">
- <div class="well-segment">
- <h5>{{ $options.translations.newTokenMessage }}</h5>
+ <mounting-portal append mount-to="#new-deploy-token-alert">
+ <gl-alert
+ v-if="newTokenDetails"
+ variant="success"
+ class="gl-mb-4"
+ @dismiss="newTokenDetails = null"
+ >
+ <h5 class="gl-mt-0!">{{ $options.translations.newTokenMessage }}</h5>
<gl-form-group>
<template #description>
- <div class="deploy-token-help-block gl-mt-2 text-success">
+ <div class="deploy-token-help-block gl-mt-2">
<gl-sprintf
:message="$options.translations.newTokenUsernameDescription"
:placeholders="placeholders.link"
@@ -200,9 +209,9 @@ export default {
</template>
</gl-form-input-group>
</gl-form-group>
- <gl-form-group>
+ <gl-form-group class="gl-mb-0">
<template #description>
- <div class="deploy-token-help-block gl-mt-2 text-danger">
+ <div class="deploy-token-help-block gl-mt-2">
<gl-sprintf
:message="$options.translations.newTokenDescription"
:placeholders="placeholders.i"
@@ -222,9 +231,9 @@ export default {
</template>
</gl-form-input-group>
</gl-form-group>
- </div>
- </div>
- <h5>{{ $options.translations.addTokenHeader }}</h5>
+ </gl-alert>
+ </mounting-portal>
+ <h4 class="gl-mt-0">{{ $options.translations.addTokenHeader }}</h4>
<p>
<gl-sprintf
:message="$options.translations.addTokenDescription"
@@ -296,6 +305,9 @@ export default {
<gl-button variant="confirm" @click="createDeployToken">
{{ $options.translations.addTokenButton }}
</gl-button>
+ <gl-button class="gl-ml-3 js-toggle-button">
+ {{ $options.translations.cancelTokenCreation }}
+ </gl-button>
</div>
<gl-datepicker v-model="expiresAt" target="#deploy_token_expires_at" container="body" />
</div>
diff --git a/app/assets/javascripts/deploy_tokens/components/revoke_button.vue b/app/assets/javascripts/deploy_tokens/components/revoke_button.vue
index 7879357a042..52d94e65e72 100644
--- a/app/assets/javascripts/deploy_tokens/components/revoke_button.vue
+++ b/app/assets/javascripts/deploy_tokens/components/revoke_button.vue
@@ -35,9 +35,9 @@ export default {
<div>
<gl-button
v-gl-modal="modalId"
- category="primary"
+ category="secondary"
variant="danger"
- class="gl-float-right"
+ size="small"
data-testid="revoke-button"
>{{ s__('DeployTokens|Revoke') }}</gl-button
>
diff --git a/app/assets/javascripts/deploy_tokens/deploy_token_translations.js b/app/assets/javascripts/deploy_tokens/deploy_token_translations.js
index 410864a83a2..0d3f92b2347 100644
--- a/app/assets/javascripts/deploy_tokens/deploy_token_translations.js
+++ b/app/assets/javascripts/deploy_tokens/deploy_token_translations.js
@@ -2,6 +2,7 @@ import { s__ } from '~/locale';
const translations = {
addTokenButton: s__('DeployTokens|Create deploy token'),
+ cancelTokenCreation: s__('DeployTokens|Cancel'),
addTokenExpiryLabel: s__('DeployTokens|Expiration date (optional)'),
addTokenExpiryDescription: s__(
'DeployTokens|Enter an expiration date for your token. Defaults to never expire.',
@@ -23,7 +24,7 @@ const translations = {
newTokenDescription: s__(
'DeployTokens|Use this token as a password. Save it. This password can %{i_start}not%{i_end} be recovered.',
),
- newTokenMessage: s__('DeployTokens|Your New Deploy Token'),
+ newTokenMessage: s__('DeployTokens|Your new deploy token'),
newTokenUsernameCopy: s__('DeployTokens|Copy username'),
newTokenUsernameDescription: s__(
'DeployTokens|This username supports access. %{link_start}What kind of access?%{link_end}',
diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js
index 6dbf12054cf..4e5e07c57e4 100644
--- a/app/assets/javascripts/deprecated_notes.js
+++ b/app/assets/javascripts/deprecated_notes.js
@@ -136,9 +136,9 @@ export default class Notes {
// Reopen and close actions for Issue/MR combined with note form submit
this.$wrapperEl.on(
'click',
- // this oddly written selector needs to match the old style (input with class) as
+ // this oddly written selector needs to match the old style (button with class) as
// well as the new DOM styling from the Vue-based note form
- 'input.js-comment-submit-button, .js-comment-submit-button > button:first-child',
+ 'button.js-comment-submit-button, .js-comment-submit-button > button:first-child',
this.postComment,
);
this.$wrapperEl.on('click', '.js-comment-save-button', this.updateComment);
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 1f2c9f19a95..a5b6d6276f8 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
@@ -166,7 +166,7 @@ export default {
);
},
/**
- * Prepare award emoji nodes based on emoji name
+ * Prepare emoji reaction nodes based on emoji name
* and whether the user has toggled the emoji off or on
*/
getAwardEmojiNodes(name, toggledOn) {
@@ -312,7 +312,6 @@ export default {
icon="ellipsis_v"
category="tertiary"
data-qa-selector="design_discussion_actions_ellipsis_dropdown"
- data-testid="more-actions-dropdown"
text-sr-only
:title="$options.i18n.moreActionsLabel"
:aria-label="$options.i18n.moreActionsLabel"
diff --git a/app/assets/javascripts/design_management/components/image.vue b/app/assets/javascripts/design_management/components/image.vue
index fd691d1f04e..5f399573c7e 100644
--- a/app/assets/javascripts/design_management/components/image.vue
+++ b/app/assets/javascripts/design_management/components/image.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlIcon } from '@gitlab/ui';
import { throttle } from 'lodash';
diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue
index 8339034fae9..7b98557f4f0 100644
--- a/app/assets/javascripts/design_management/components/list/item.vue
+++ b/app/assets/javascripts/design_management/components/list/item.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlLoadingIcon, GlIcon, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui';
import { n__, __ } from '~/locale';
diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue
index cd76b6c1885..741fdee5567 100644
--- a/app/assets/javascripts/design_management/components/toolbar/index.vue
+++ b/app/assets/javascripts/design_management/components/toolbar/index.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton, GlIcon, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui';
import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql';
diff --git a/app/assets/javascripts/design_management/components/upload/button.vue b/app/assets/javascripts/design_management/components/upload/button.vue
index 98b7ab5c094..e03f2668f75 100644
--- a/app/assets/javascripts/design_management/components/upload/button.vue
+++ b/app/assets/javascripts/design_management/components/upload/button.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { VALID_DESIGN_FILE_MIMETYPE } from '../../constants';
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue
index 65e04b1ff98..77e2803d092 100644
--- a/app/assets/javascripts/design_management/pages/design/index.vue
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlAlert } from '@gitlab/ui';
import { isNull } from 'lodash';
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index af7c5a25d94..09f99f0927f 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlLoadingIcon, GlButton, GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 5149dcc5d17..e3882ce42c2 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -1,6 +1,7 @@
<script>
import { GlLoadingIcon, GlPagination, GlSprintf, GlAlert } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters, mapActions } from 'vuex';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import api from '~/api';
@@ -18,16 +19,11 @@ import { parseBoolean } from '~/lib/utils/common_utils';
import { Mousetrap } from '~/lib/mousetrap';
import { updateHistory } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import notesEventHub from '~/notes/event_hub';
import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller';
import {
- TREE_LIST_WIDTH_STORAGE_KEY,
- INITIAL_TREE_WIDTH,
- MIN_TREE_WIDTH,
- TREE_HIDE_STATS_WIDTH,
MR_TREE_SHOW_KEY,
ALERT_OVERFLOW_HIDDEN,
ALERT_MERGE_CONFLICT,
@@ -56,12 +52,13 @@ import CompareVersions from './compare_versions.vue';
import DiffFile from './diff_file.vue';
import HiddenFilesWarning from './hidden_files_warning.vue';
import NoChanges from './no_changes.vue';
-import TreeList from './tree_list.vue';
import VirtualScrollerScrollSync from './virtual_scroller_scroll_sync';
+import DiffsFileTree from './diffs_file_tree.vue';
export default {
name: 'DiffsApp',
components: {
+ DiffsFileTree,
FindingsDrawer,
DynamicScroller,
DynamicScrollerItem,
@@ -72,9 +69,7 @@ export default {
HiddenFilesWarning,
CollapsedFilesWarning,
CommitWidget,
- TreeList,
GlLoadingIcon,
- PanelResizer,
GlPagination,
GlSprintf,
GlAlert,
@@ -124,11 +119,7 @@ export default {
},
},
data() {
- const treeWidth =
- parseInt(localStorage.getItem(TREE_LIST_WIDTH_STORAGE_KEY), 10) || INITIAL_TREE_WIDTH;
-
return {
- treeWidth,
diffFilesLength: 0,
virtualScrollCurrentIndex: -1,
subscribedToVirtualScrollingEvents: false,
@@ -141,7 +132,6 @@ export default {
}),
...mapState('findingsDrawer', ['activeDrawer']),
...mapState('diffs', [
- 'showTreeList',
'isLoading',
'diffFiles',
'diffViewType',
@@ -194,12 +184,6 @@ export default {
diffsIncomplete() {
return this.flatBlobsList.length !== this.diffFiles.length;
},
- renderFileTree() {
- return this.renderDiffFiles && this.showTreeList;
- },
- hideFileStats() {
- return this.treeWidth <= TREE_HIDE_STATS_WIDTH;
- },
isFullChangeset() {
return this.startVersion === null && this.latestDiff;
},
@@ -273,7 +257,6 @@ export default {
this.subscribeToVirtualScrollingEvents();
},
isLoading: 'adjustView',
- renderFileTree: 'adjustView',
},
mounted() {
if (this.endpointCodequality) {
@@ -376,7 +359,6 @@ export default {
'startRenderDiffsQueue',
'assignDiscussionsToDiff',
'setHighlightedRow',
- 'cacheTreeListWidth',
'goToFile',
'setShowTreeList',
'navigateToDiffFileIndex',
@@ -590,8 +572,6 @@ export default {
window.location.reload();
},
},
- minTreeWidth: MIN_TREE_WIDTH,
- maxTreeWidth: window.innerWidth / 2,
howToMergeDocsPath: helpPagePath('user/project/merge_requests/reviews/index.md', {
anchor: 'checkout-merge-requests-locally-through-the-head-ref',
}),
@@ -624,22 +604,7 @@ export default {
:data-can-create-note="getNoteableData.current_user.can_create_note"
class="files d-flex gl-mt-2"
>
- <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
- :size.sync="treeWidth"
- :start-size="treeWidth"
- :min-size="$options.minTreeWidth"
- :max-size="$options.maxTreeWidth"
- side="right"
- @resize-end="cacheTreeListWidth"
- />
- <tree-list :hide-file-stats="hideFileStats" />
- </div>
+ <diffs-file-tree :render-diff-files="renderDiffFiles" @toggled="adjustView" />
<div class="col-12 col-md-auto diff-files-holder">
<commit-widget v-if="commit" :commit="commit" :collapsible="false" />
<gl-alert
diff --git a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue
index ebb6ec1e7c8..c8b644e3538 100644
--- a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue
+++ b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue
@@ -1,5 +1,6 @@
<script>
import { GlAlert, GlButton } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import { EVT_EXPAND_ALL_FILES } from '../constants';
diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue
index 6104a304fbd..bc2376fec09 100644
--- a/app/assets/javascripts/diffs/components/compare_versions.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions.vue
@@ -1,5 +1,6 @@
<script>
import { GlTooltipDirective, GlIcon, GlLink, GlButtonGroup, GlButton, GlSprintf } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import { __ } from '~/locale';
import { setUrlParams } from '~/lib/utils/url_utility';
@@ -46,7 +47,9 @@ export default {
'removedLines',
]),
toggleFileBrowserTitle() {
- return this.showTreeList ? __('Hide file browser') : __('Show file browser');
+ return this.showTreeList
+ ? __('Hide file browser (or press F)')
+ : __('Show file browser (or press F)');
},
hasChanges() {
return this.diffFiles.length > 0;
diff --git a/app/assets/javascripts/diffs/components/diff_comment_cell.vue b/app/assets/javascripts/diffs/components/diff_comment_cell.vue
index a4fae652d02..3eae6263eca 100644
--- a/app/assets/javascripts/diffs/components/diff_comment_cell.vue
+++ b/app/assets/javascripts/diffs/components/diff_comment_cell.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import DiffDiscussionReply from './diff_discussion_reply.vue';
import DiffDiscussions from './diff_discussions.vue';
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
index 1c93cb4d021..720d9b6d3bf 100644
--- a/app/assets/javascripts/diffs/components/diff_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -1,9 +1,10 @@
<script>
import { GlLoadingIcon, GlButton } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import { sprintf } from '~/locale';
import { createAlert } from '~/alert';
-import { mapParallel, mapParallelNoSast } from 'ee_else_ce/diffs/components/diff_row_utils';
+import { mapParallel } from 'ee_else_ce/diffs/components/diff_row_utils';
import DiffFileDrafts from '~/batch_comments/components/diff_file_drafts.vue';
import draftCommentsMixin from '~/diffs/mixins/draft_comments';
import { diffViewerModes } from '~/ide/constants';
@@ -14,7 +15,6 @@ import NotDiffableViewer from '~/vue_shared/components/diff_viewer/viewers/not_d
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 glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { IMAGE_DIFF_POSITION_TYPE } from '../constants';
import { SAVING_THE_COMMENT_FAILED, SOMETHING_WENT_WRONG } from '../i18n';
import { getDiffMode } from '../store/utils';
@@ -36,7 +36,7 @@ export default {
UserAvatarLink,
DiffFileDrafts,
},
- mixins: [diffLineNoteFormMixin, draftCommentsMixin, glFeatureFlagsMixin()],
+ mixins: [diffLineNoteFormMixin, draftCommentsMixin],
props: {
diffFile: {
type: Object,
@@ -92,11 +92,7 @@ export default {
return this.getUserData;
},
mappedLines() {
- if (this.glFeatures.sastReportsInInlineDiff) {
- return this.diffLines(this.diffFile).map(mapParallel(this)) || [];
- }
-
- return this.diffLines(this.diffFile).map(mapParallelNoSast(this)) || [];
+ return this.diffLines(this.diffFile).map(mapParallel(this)) || [];
},
imageDiscussions() {
return this.diffFile.discussions.filter(
diff --git a/app/assets/javascripts/diffs/components/diff_discussion_reply.vue b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue
index 8b747aa08dd..4de38b09be6 100644
--- a/app/assets/javascripts/diffs/components/diff_discussion_reply.vue
+++ b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import NoteSignedOutWidget from '~/notes/components/note_signed_out_widget.vue';
diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue
index 9e399a642d0..8915f32eadf 100644
--- a/app/assets/javascripts/diffs/components/diff_discussions.vue
+++ b/app/assets/javascripts/diffs/components/diff_discussions.vue
@@ -1,5 +1,6 @@
<script>
import { GlIcon } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue';
import NoteableDiscussion from '~/notes/components/noteable_discussion.vue';
diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
index 53a55aac1ec..54fee000368 100644
--- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
+++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
@@ -1,5 +1,6 @@
<script>
import { GlTooltipDirective, GlIcon, GlLoadingIcon } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { createAlert } from '~/alert';
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index 4e1ccfc530e..f99edced361 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -1,6 +1,7 @@
<script>
import { GlButton, GlLoadingIcon, GlSprintf, GlAlert } from '@gitlab/ui';
import { escape } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { IdState } from 'vendor/vue-virtual-scroller';
@@ -226,6 +227,12 @@ export default {
}
this.manageViewedEffects();
+
+ if (this.viewDiffsFileByFile) {
+ requestIdleCallback(() => {
+ this.prefetchFileNeighbors();
+ });
+ }
},
beforeDestroy() {
eventHub.$off(EVT_EXPAND_ALL_FILES, this.expandAllListener);
@@ -234,6 +241,7 @@ export default {
...mapActions('diffs', [
'loadCollapsedDiff',
'assignDiscussionsToDiff',
+ 'prefetchFileNeighbors',
'setFileCollapsedByUser',
'saveDiffDiscussion',
'toggleFileCommentForm',
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index e336161f952..d62d0e11bff 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -12,6 +12,7 @@ import {
GlLoadingIcon,
} from '@gitlab/ui';
import { escape } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { IdState } from 'vendor/vue-virtual-scroller';
diff --git a/app/assets/javascripts/diffs/components/diff_inline_findings.vue b/app/assets/javascripts/diffs/components/diff_inline_findings.vue
index 1e9a1825d3e..59f92040776 100644
--- a/app/assets/javascripts/diffs/components/diff_inline_findings.vue
+++ b/app/assets/javascripts/diffs/components/diff_inline_findings.vue
@@ -1,8 +1,8 @@
<script>
-import DiffCodeQualityItem from './diff_code_quality_item.vue';
+import DiffInlineFindingsItem from './diff_inline_findings_item.vue';
export default {
- components: { DiffCodeQualityItem },
+ components: { DiffInlineFindingsItem },
props: {
title: {
type: String,
@@ -22,7 +22,7 @@ export default {
{{ title }}
</h4>
<ul class="gl-list-style-none gl-mb-0 gl-p-0">
- <diff-code-quality-item
+ <diff-inline-findings-item
v-for="finding in findings"
:key="finding.description"
:finding="finding"
diff --git a/app/assets/javascripts/diffs/components/diff_code_quality_item.vue b/app/assets/javascripts/diffs/components/diff_inline_findings_item.vue
index 727b2a0c099..5cc2a3079b0 100644
--- a/app/assets/javascripts/diffs/components/diff_code_quality_item.vue
+++ b/app/assets/javascripts/diffs/components/diff_inline_findings_item.vue
@@ -1,5 +1,6 @@
<script>
import { GlLink, GlIcon } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import { getSeverity } from '~/ci/reports/utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -42,7 +43,7 @@ export default {
:size="12"
:name="enhancedFinding.name"
:class="enhancedFinding.class"
- class="codequality-severity-icon"
+ class="inline-findings-severity-icon"
/>
</span>
<span
diff --git a/app/assets/javascripts/diffs/components/diff_line.vue b/app/assets/javascripts/diffs/components/diff_line.vue
index 40e53438bc8..4867a21493f 100644
--- a/app/assets/javascripts/diffs/components/diff_line.vue
+++ b/app/assets/javascripts/diffs/components/diff_line.vue
@@ -1,9 +1,9 @@
<script>
-import DiffCodeQuality from './diff_code_quality.vue';
+import InlineFindings from './inline_findings.vue';
export default {
components: {
- DiffCodeQuality,
+ InlineFindings,
},
props: {
line: {
@@ -15,31 +15,18 @@ export default {
parsedCodeQuality() {
return (this.line.left ?? this.line.right)?.codequality;
},
- parsedSast() {
- return (this.line.left ?? this.line.right)?.sast;
- },
codeQualityLineNumber() {
return this.parsedCodeQuality[0]?.line;
},
- sastLineNumber() {
- return this.parsedSast[0]?.line;
- },
},
methods: {
- hideCodeQualityFindings() {
- this.$emit(
- 'hideCodeQualityFindings',
- this.codeQualityLineNumber ? this.codeQualityLineNumber : this.sastLineNumber,
- );
+ hideInlineFindings() {
+ this.$emit('hideInlineFindings', this.codeQualityLineNumber);
},
},
};
</script>
<template>
- <diff-code-quality
- :code-quality="parsedCodeQuality"
- :sast="parsedSast"
- @hideCodeQualityFindings="hideCodeQualityFindings"
- />
+ <inline-findings :code-quality="parsedCodeQuality" @hideInlineFindings="hideInlineFindings" />
</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 9a3256beff4..287b2fc1973 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -1,8 +1,11 @@
<script>
+import { nextTick } from 'vue';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters, mapActions } from 'vuex';
import { s__, __, sprintf } from '~/locale';
import { createAlert } from '~/alert';
import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
+import { clearDraft } from '~/lib/utils/autosave';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -208,6 +211,9 @@ export default {
lineCode: this.line.line_code,
fileHash: this.diffFileHash,
});
+ nextTick(() => {
+ clearDraft(this.autosaveKey);
+ });
}),
handleSaveNote(note, parentElement, errorCallback) {
return this.saveDiffDiscussion({ note, formData: this.formData })
diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue
index 3c9770864fa..318ecc89d14 100644
--- a/app/assets/javascripts/diffs/components/diff_row.vue
+++ b/app/assets/javascripts/diffs/components/diff_row.vue
@@ -24,7 +24,8 @@ import * as utils from './diff_row_utils';
export default {
DiffGutterAvatars,
- CodeQualityGutterIcon: () => import('ee_component/diffs/components/code_quality_gutter_icon.vue'),
+ InlineFindingsGutterIcon: () =>
+ import('ee_component/diffs/components/inline_findings_gutter_icon.vue'),
// Temporary mixin for migration from Vue.js 2 to @vue/compat
mixins: [compatFunctionalMixin],
@@ -79,7 +80,7 @@ export default {
type: Function,
required: true,
},
- codeQualityExpanded: {
+ inlineFindingsExpanded: {
type: Boolean,
required: false,
default: false,
@@ -325,6 +326,7 @@ export default {
</div>
<div
:title="$options.coverageStateLeft(props).text"
+ :data-tooltip-custom-class="$options.coverageStateLeft(props).class"
:class="[
$options.parallelViewLeftLineType(props),
$options.coverageStateLeft(props).class,
@@ -332,17 +334,16 @@ export default {
class="diff-td line-coverage left-side has-tooltip"
></div>
<div
- class="diff-td line-codequality left-side"
+ class="diff-td line-inline-findings left-side"
:class="$options.parallelViewLeftLineType(props)"
>
<component
- :is="$options.CodeQualityGutterIcon"
+ :is="$options.InlineFindingsGutterIcon"
v-if="$options.showCodequalityLeft(props) || $options.showSecurityLeft(props)"
- :code-quality-expanded="props.codeQualityExpanded"
+ :inline-findings-expanded="props.inlineFindingsExpanded"
:codequality="props.line.left.codequality"
- :sast="props.line.left.sast"
:file-path="props.filePath"
- @showCodeQualityFindings="
+ @showInlineFindings="
listeners.toggleCodeQualityFindings(
props.line.left.codequality[0]
? props.line.left.codequality[0].line
@@ -381,7 +382,7 @@ export default {
:class="$options.classNameMapCellLeft(props)"
></div>
<div
- class="diff-td line-codequality left-side empty-cell"
+ class="diff-td line-inline-findings left-side empty-cell"
:class="$options.classNameMapCellLeft(props)"
></div>
<div
@@ -465,6 +466,7 @@ export default {
</div>
<div
:title="$options.coverageStateRight(props).text"
+ :data-tooltip-custom-class="$options.coverageStateRight(props).class"
:class="[
props.line.right.type,
$options.coverageStateRight(props).class,
@@ -473,17 +475,16 @@ export default {
class="diff-td line-coverage right-side has-tooltip"
></div>
<div
- class="diff-td line-codequality right-side"
+ class="diff-td line-inline-findings right-side"
:class="$options.classNameMapCellRight(props)"
>
<component
- :is="$options.CodeQualityGutterIcon"
+ :is="$options.InlineFindingsGutterIcon"
v-if="$options.showCodequalityRight(props) || $options.showSecurityRight(props)"
:codequality="props.line.right.codequality"
- :sast="props.line.right.sast"
:file-path="props.filePath"
- data-testid="codeQualityIcon"
- @showCodeQualityFindings="
+ data-testid="inlineFindingsIcon"
+ @showInlineFindings="
listeners.toggleCodeQualityFindings(
props.line.right.codequality[0]
? props.line.right.codequality[0].line
@@ -518,7 +519,7 @@ export default {
:class="$options.classNameMapCellRight(props)"
></div>
<div
- class="diff-td line-codequality right-side empty-cell"
+ class="diff-td line-inline-findings right-side empty-cell"
:class="$options.classNameMapCellRight(props)"
></div>
<div
diff --git a/app/assets/javascripts/diffs/components/diff_row_utils.js b/app/assets/javascripts/diffs/components/diff_row_utils.js
index 28834dab3b3..a489c96b0c9 100644
--- a/app/assets/javascripts/diffs/components/diff_row_utils.js
+++ b/app/assets/javascripts/diffs/components/diff_row_utils.js
@@ -189,7 +189,3 @@ export const mapParallel = (content) => (line) => {
commentRowClasses: hasDiscussions(left) || hasDiscussions(right) ? '' : 'js-temp-notes-holder',
};
};
-
-export const mapParallelNoSast = (content) => {
- return mapParallel(content);
-};
diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue
index 6bacc6839d8..88ea4e15552 100644
--- a/app/assets/javascripts/diffs/components/diff_view.vue
+++ b/app/assets/javascripts/diffs/components/diff_view.vue
@@ -1,9 +1,11 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapGetters, mapState, mapActions } from 'vuex';
import { throttle } from 'lodash';
import { IdState } from 'vendor/vue-virtual-scroller';
import DraftNote from '~/batch_comments/components/draft_note.vue';
import draftCommentsMixin from '~/diffs/mixins/draft_comments';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { getCommentedLines } from '~/notes/components/multiline_comment_utils';
import { hide } from '~/tooltips';
import { pickDirection } from '../utils/diff_line';
@@ -21,7 +23,11 @@ export default {
DiffCommentCell,
DraftNote,
},
- mixins: [draftCommentsMixin, IdState({ idProp: (vm) => vm.diffFile.file_hash })],
+ mixins: [
+ draftCommentsMixin,
+ IdState({ idProp: (vm) => vm.diffFile.file_hash }),
+ glFeatureFlagsMixin(),
+ ],
props: {
diffFile: {
type: Object,
@@ -44,7 +50,7 @@ export default {
},
data() {
return {
- codeQualityExpandedLines: [],
+ inlineFindingsExpandedLines: [],
};
},
idState() {
@@ -75,12 +81,15 @@ export default {
this.diffLines,
);
},
- hasCodequalityChanges() {
+ hasInlineFindingsChanges() {
return (
this.codequalityDiff?.files?.[this.diffFile.file_path]?.length > 0 ||
this.sastDiff?.added?.length > 0
);
},
+ sastReportsInInlineDiff() {
+ return this.glFeatures.sastReportsInInlineDiff;
+ },
},
created() {
this.onDragOverThrottled = throttle((line) => this.onDragOver(line), 100, { leading: true });
@@ -100,17 +109,17 @@ export default {
}
this.idState.dragStart = line;
},
- hideCodeQualityFindings(line) {
- const index = this.codeQualityExpandedLines.indexOf(line);
+ hideInlineFindings(line) {
+ const index = this.inlineFindingsExpandedLines.indexOf(line);
if (index > -1) {
- this.codeQualityExpandedLines.splice(index, 1);
+ this.inlineFindingsExpandedLines.splice(index, 1);
}
},
toggleCodeQualityFindings(line) {
- if (!this.codeQualityExpandedLines.includes(line)) {
- this.codeQualityExpandedLines.push(line);
+ if (!this.inlineFindingsExpandedLines.includes(line)) {
+ this.inlineFindingsExpandedLines.push(line);
} else {
- this.hideCodeQualityFindings(line);
+ this.hideInlineFindings(line);
}
},
onDragOver(line) {
@@ -207,7 +216,7 @@ export default {
<div
:class="[
$options.userColorScheme,
- { 'inline-diff-view': inline, 'with-codequality': hasCodequalityChanges },
+ { 'inline-diff-view': inline, 'with-inline-findings': hasInlineFindingsChanges },
]"
:data-commit-id="commitId"
class="diff-grid diff-table code diff-wrap-lines js-syntax-highlight text-file"
@@ -256,7 +265,7 @@ export default {
:is-last-highlighted-line="isLastHighlightedLine(line) || index === commentedLines.endLine"
:inline="inline"
:index="index"
- :code-quality-expanded="codeQualityExpandedLines.includes(getCodeQualityLine(line))"
+ :inline-findings-expanded="inlineFindingsExpandedLines.includes(getCodeQualityLine(line))"
:file-line-coverage="fileLineCoverage"
:coverage-loaded="coverageLoaded"
@showCommentForm="(code) => singleLineComment(code, line)"
@@ -270,11 +279,14 @@ export default {
@startdragging="onStartDragging"
@stopdragging="onStopDragging"
/>
+ <!-- Don't display InlineFindings expanded section when sastReportsInInlineDiff is false -->
<diff-line
- v-if="codeQualityExpandedLines.includes(getCodeQualityLine(line))"
+ v-if="
+ inlineFindingsExpandedLines.includes(getCodeQualityLine(line)) && !sastReportsInInlineDiff
+ "
:key="line.line_code"
:line="line"
- @hideCodeQualityFindings="hideCodeQualityFindings"
+ @hideInlineFindings="hideInlineFindings"
/>
<div
v-if="line.renderCommentRow"
diff --git a/app/assets/javascripts/diffs/components/diffs_file_tree.vue b/app/assets/javascripts/diffs/components/diffs_file_tree.vue
new file mode 100644
index 00000000000..34cd901dd0c
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diffs_file_tree.vue
@@ -0,0 +1,79 @@
+<script>
+// eslint-disable-next-line no-restricted-imports
+import { mapActions, mapState } from 'vuex';
+import { Mousetrap } from '~/lib/mousetrap';
+import { keysFor, MR_TOGGLE_FILE_BROWSER } from '~/behaviors/shortcuts/keybindings';
+import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import {
+ INITIAL_TREE_WIDTH,
+ MIN_TREE_WIDTH,
+ TREE_HIDE_STATS_WIDTH,
+ TREE_LIST_WIDTH_STORAGE_KEY,
+} from '../constants';
+import TreeList from './tree_list.vue';
+
+export default {
+ name: 'DiffsFileTree',
+ components: { TreeList, PanelResizer },
+ mixins: [glFeatureFlagsMixin()],
+ minTreeWidth: MIN_TREE_WIDTH,
+ maxTreeWidth: window.innerWidth / 2,
+ props: {
+ renderDiffFiles: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ const treeWidth =
+ parseInt(localStorage.getItem(TREE_LIST_WIDTH_STORAGE_KEY), 10) || INITIAL_TREE_WIDTH;
+
+ return {
+ treeWidth,
+ };
+ },
+ computed: {
+ ...mapState('diffs', ['showTreeList']),
+ renderFileTree() {
+ return this.renderDiffFiles && this.showTreeList;
+ },
+ hideFileStats() {
+ return this.treeWidth <= TREE_HIDE_STATS_WIDTH;
+ },
+ },
+ watch: {
+ renderFileTree() {
+ this.$emit('toggled');
+ },
+ },
+ mounted() {
+ Mousetrap.bind(keysFor(MR_TOGGLE_FILE_BROWSER), this.toggleTreeList);
+ },
+ beforeDestroy() {
+ Mousetrap.unbind(keysFor(MR_TOGGLE_FILE_BROWSER), this.toggleTreeList);
+ },
+ methods: {
+ ...mapActions('diffs', ['cacheTreeListWidth', 'toggleTreeList']),
+ },
+};
+</script>
+
+<template>
+ <div
+ v-if="renderFileTree"
+ :style="{ width: `${treeWidth}px` }"
+ :class="{ 'is-sidebar-moved': glFeatures.movedMrSidebar }"
+ class="diff-tree-list gl-px-5"
+ >
+ <panel-resizer
+ :size.sync="treeWidth"
+ :start-size="treeWidth"
+ :min-size="$options.minTreeWidth"
+ :max-size="$options.maxTreeWidth"
+ side="right"
+ @resize-end="cacheTreeListWidth"
+ />
+ <tree-list :hide-file-stats="hideFileStats" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/diffs/components/image_diff_overlay.vue b/app/assets/javascripts/diffs/components/image_diff_overlay.vue
index bd040cd1ba1..aafb9bb9d0b 100644
--- a/app/assets/javascripts/diffs/components/image_diff_overlay.vue
+++ b/app/assets/javascripts/diffs/components/image_diff_overlay.vue
@@ -1,5 +1,6 @@
<script>
import { isArray } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters } from 'vuex';
import imageDiffMixin from 'ee_else_ce/diffs/mixins/image_diff';
import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue';
diff --git a/app/assets/javascripts/diffs/components/diff_code_quality.vue b/app/assets/javascripts/diffs/components/inline_findings.vue
index 4ed54ecdf66..efceedd1141 100644
--- a/app/assets/javascripts/diffs/components/diff_code_quality.vue
+++ b/app/assets/javascripts/diffs/components/inline_findings.vue
@@ -14,18 +14,14 @@ export default {
type: Array,
required: true,
},
- sast: {
- type: Array,
- required: true,
- },
},
};
</script>
<template>
<div
- data-testid="diff-codequality"
- class="gl-relative codequality-findings-list gl-border-top-1 gl-border-bottom-1 gl-bg-gray-10 gl-text-black-normal gl-pl-5 gl-pt-4 gl-pb-4"
+ data-testid="inline-findings"
+ class="gl-relative inline-findings-list gl-border-top-1 gl-border-bottom-1 gl-bg-gray-10 gl-text-black-normal gl-pl-5 gl-pt-4 gl-pb-4"
>
<diff-inline-findings
v-if="codeQuality.length"
@@ -33,19 +29,13 @@ export default {
:findings="codeQuality"
/>
- <diff-inline-findings
- v-if="sast.length"
- :title="$options.i18n.newSastFindings"
- :findings="sast"
- />
-
<gl-button
- data-testid="diff-codequality-close"
+ data-testid="inline-findings-close"
category="tertiary"
size="small"
icon="close"
class="gl-absolute gl-right-2 gl-top-2"
- @click="$emit('hideCodeQualityFindings')"
+ @click="$emit('hideInlineFindings')"
/>
</div>
</template>
diff --git a/app/assets/javascripts/diffs/components/no_changes.vue b/app/assets/javascripts/diffs/components/no_changes.vue
index 42af2ab7880..ab5f31a1fb7 100644
--- a/app/assets/javascripts/diffs/components/no_changes.vue
+++ b/app/assets/javascripts/diffs/components/no_changes.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlSprintf } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
export default {
diff --git a/app/assets/javascripts/diffs/components/settings_dropdown.vue b/app/assets/javascripts/diffs/components/settings_dropdown.vue
index a705f29ff65..9744b650d3c 100644
--- a/app/assets/javascripts/diffs/components/settings_dropdown.vue
+++ b/app/assets/javascripts/diffs/components/settings_dropdown.vue
@@ -6,6 +6,7 @@ import {
GlFormCheckbox,
GlTooltip,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import { SETTINGS_DROPDOWN } from '../i18n';
diff --git a/app/assets/javascripts/diffs/components/shared/findings_drawer.vue b/app/assets/javascripts/diffs/components/shared/findings_drawer.vue
index 2cffe928d7b..fddd455b17e 100644
--- a/app/assets/javascripts/diffs/components/shared/findings_drawer.vue
+++ b/app/assets/javascripts/diffs/components/shared/findings_drawer.vue
@@ -68,7 +68,7 @@ export default {
:size="12"
:name="severityIcon(drawer.severity)"
:class="severityClass(drawer.severity)"
- class="codequality-severity-icon"
+ class="inline-findings-severity-icon"
/>
{{ drawer.severity }}
diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue
index 6f17d70b952..7a661d51c9b 100644
--- a/app/assets/javascripts/diffs/components/tree_list.vue
+++ b/app/assets/javascripts/diffs/components/tree_list.vue
@@ -1,5 +1,6 @@
<script>
import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import micromatch from 'micromatch';
import { debounce } from 'lodash';
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index b9cf26827f2..49f25416585 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState, mapGetters } from 'vuex';
import { apolloProvider } from '~/graphql_shared/issuable_client';
import { getCookie, parseBoolean, removeCookie } from '~/lib/utils/common_utils';
diff --git a/app/assets/javascripts/diffs/mixins/draft_comments.js b/app/assets/javascripts/diffs/mixins/draft_comments.js
index 5ca9ade668c..4d54dcfa765 100644
--- a/app/assets/javascripts/diffs/mixins/draft_comments.js
+++ b/app/assets/javascripts/diffs/mixins/draft_comments.js
@@ -1,3 +1,4 @@
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import { IMAGE_DIFF_POSITION_TYPE } from '../constants';
diff --git a/app/assets/javascripts/diffs/mixins/image_diff.js b/app/assets/javascripts/diffs/mixins/image_diff.js
index 9067ea6f8b3..93fb5afbce1 100644
--- a/app/assets/javascripts/diffs/mixins/image_diff.js
+++ b/app/assets/javascripts/diffs/mixins/image_diff.js
@@ -1,3 +1,4 @@
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
export default {
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 2a557017953..bbc602aedf6 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -113,6 +113,46 @@ export const setBaseConfig = ({ commit }, options) => {
});
};
+export const prefetchSingleFile = async ({ state, getters, commit }, treeEntry) => {
+ const versionPath = state.mergeRequestDiff?.version_path;
+
+ if (
+ treeEntry &&
+ !treeEntry.diffLoaded &&
+ !treeEntry.diffLoading &&
+ !getters.getDiffFileByHash(treeEntry.fileHash)
+ ) {
+ const urlParams = {
+ old_path: treeEntry.filePaths.old,
+ new_path: treeEntry.filePaths.new,
+ w: state.showWhitespace ? '0' : '1',
+ view: 'inline',
+ commit_id: getters.commitId,
+ };
+
+ if (versionPath) {
+ const { diffId, startSha } = getDerivedMergeRequestInformation({ endpoint: versionPath });
+
+ urlParams.diff_id = diffId;
+ urlParams.start_sha = startSha;
+ }
+
+ commit(types.TREE_ENTRY_DIFF_LOADING, { path: treeEntry.filePaths.new });
+
+ try {
+ const { data: diffData } = await axios.get(
+ mergeUrlParams({ ...urlParams }, state.endpointDiffForPath),
+ );
+
+ commit(types.SET_DIFF_DATA_BATCH, { diff_files: diffData.diff_files });
+
+ eventHub.$emit('diffFilesModified');
+ } catch (e) {
+ commit(types.TREE_ENTRY_DIFF_LOADING, { path: treeEntry.filePaths.new, loading: false });
+ }
+ }
+};
+
export const fetchFileByFile = async ({ state, getters, commit }) => {
const isNoteLink = isUrlHashNoteLink(window?.location?.hash);
const id = parseUrlHashAsFileHash(window?.location?.hash, state.currentDiffFileId);
@@ -304,6 +344,17 @@ export const fetchDiffFilesMeta = ({ commit, state }) => {
}
});
};
+
+export function prefetchFileNeighbors({ getters, dispatch }) {
+ const { flatBlobsList: allBlobs, currentDiffIndex: currentIndex } = getters;
+
+ const previous = Math.max(currentIndex - 1, 0);
+ const next = Math.min(allBlobs.length - 1, currentIndex + 1);
+
+ dispatch('prefetchSingleFile', allBlobs[next]);
+ dispatch('prefetchSingleFile', allBlobs[previous]);
+}
+
export const fetchCoverageFiles = ({ commit, state }) => {
const coveragePoll = new Poll({
resource: {
@@ -425,7 +476,9 @@ export const showCommentForm = ({ commit }, { lineCode, fileHash }) => {
// works. If we focus the comment form on mount and the comment form gets removed and then
// added again the page will scroll in unexpected ways
setTimeout(() => {
- const el = document.querySelector(`[data-line-code="${lineCode}"] textarea`);
+ const el = document.querySelector(
+ `[data-line-code="${lineCode}"] textarea, [data-line-code="${lineCode}"] [contenteditable="true"]`,
+ );
if (!el) return;
@@ -643,6 +696,10 @@ export const setShowTreeList = ({ commit }, { showTreeList, saving = true }) =>
}
};
+export const toggleTreeList = ({ state, commit }) => {
+ commit(types.SET_SHOW_TREE_LIST, !state.showTreeList);
+};
+
export const openDiffFileCommentForm = ({ commit, getters }, formData) => {
const form = getters.getCommentFormForDiffFile(formData.fileHash);
diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js
index c32d82faad0..3df491503a4 100644
--- a/app/assets/javascripts/diffs/store/mutation_types.js
+++ b/app/assets/javascripts/diffs/store/mutation_types.js
@@ -19,6 +19,7 @@ export const RENDER_FILE = 'RENDER_FILE';
export const SET_LINE_DISCUSSIONS_FOR_FILE = 'SET_LINE_DISCUSSIONS_FOR_FILE';
export const REMOVE_LINE_DISCUSSIONS_FOR_FILE = 'REMOVE_LINE_DISCUSSIONS_FOR_FILE';
export const TOGGLE_FOLDER_OPEN = 'TOGGLE_FOLDER_OPEN';
+export const TREE_ENTRY_DIFF_LOADING = 'TREE_ENTRY_DIFF_LOADING';
export const SET_SHOW_TREE_LIST = 'SET_SHOW_TREE_LIST';
export const SET_CURRENT_DIFF_FILE = 'SET_CURRENT_DIFF_FILE';
export const SET_DIFF_FILE_VIEWED = 'SET_DIFF_FILE_VIEWED';
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index f90e0a24d0e..3af2d6ee6b1 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -266,6 +266,9 @@ export default {
[types.TOGGLE_FOLDER_OPEN](state, path) {
state.treeEntries[path].opened = !state.treeEntries[path].opened;
},
+ [types.TREE_ENTRY_DIFF_LOADING](state, { path, loading = true }) {
+ state.treeEntries[path].diffLoading = loading;
+ },
[types.SET_SHOW_TREE_LIST](state, showTreeList) {
state.showTreeList = showTreeList;
},
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 97dfd351e67..307c41a98f8 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -593,6 +593,7 @@ export function markTreeEntriesLoaded({ priorEntries, loadedFiles }) {
if (entry) {
entry.diffLoaded = true;
+ entry.diffLoading = false;
}
});
diff --git a/app/assets/javascripts/diffs/utils/tree_worker_utils.js b/app/assets/javascripts/diffs/utils/tree_worker_utils.js
index 8689809cfa9..e1e3495a51f 100644
--- a/app/assets/javascripts/diffs/utils/tree_worker_utils.js
+++ b/app/assets/javascripts/diffs/utils/tree_worker_utils.js
@@ -1,4 +1,3 @@
-import { truncatePathMiddleToLength } from '~/lib/utils/text_utility';
import { TREE_TYPE } from '../constants';
export const getLowestSingleFolder = (folder) => {
@@ -28,7 +27,7 @@ export const getLowestSingleFolder = (folder) => {
const { path, tree } = getFolder(folder, [folder.name]);
return {
- path: truncatePathMiddleToLength(path.join('/'), 40),
+ path: path.join('/'),
treeAcc: tree.length ? tree[tree.length - 1].tree : null,
};
};
@@ -86,6 +85,7 @@ export const generateTreeList = (files) => {
Object.assign(entry, {
changed: true,
diffLoaded: false,
+ diffLoading: false,
filePaths: {
old: file.old_path,
new: file.new_path,
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
index 3a1188d7aab..65091487c93 100644
--- a/app/assets/javascripts/editor/schema/ci.json
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -703,6 +703,21 @@
}
]
},
+ "azure_key_vault": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "name"
+ ],
+ "additionalProperties": false
+ },
"file": {
"type": "boolean",
"default": true,
@@ -713,8 +728,17 @@
"description": "Specifies the JWT variable that should be used to authenticate with Hashicorp Vault."
}
},
- "required": [
- "vault"
+ "anyOf": [
+ {
+ "required": [
+ "vault"
+ ]
+ },
+ {
+ "required": [
+ "azure_key_vault"
+ ]
+ }
],
"additionalProperties": false
}
@@ -1074,6 +1098,73 @@
}
]
},
+ "parallel": {
+ "description": "Splits up a single job into multiple that run in parallel. Provides `CI_NODE_INDEX` and `CI_NODE_TOTAL` environment variables to the jobs.",
+ "oneOf": [
+ {
+ "type": "integer",
+ "description": "Creates N instances of the job that run in parallel.",
+ "default": 0,
+ "minimum": 2,
+ "maximum": 200
+ },
+ {
+ "type": "object",
+ "properties": {
+ "matrix": {
+ "type": "array",
+ "description": "Defines different variables for jobs that are running in parallel.",
+ "items": {
+ "type": "object",
+ "description": "Defines the variables for a specific job.",
+ "additionalProperties": {
+ "type": [
+ "string",
+ "number",
+ "array"
+ ]
+ }
+ },
+ "maxItems": 200
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "matrix"
+ ]
+ }
+ ]
+ },
+ "parallel_matrix": {
+ "description": "Use the `needs:parallel:matrix` keyword to specify parallelized jobs needed to be completed for the job to run. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#needsparallelmatrix)",
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "matrix": {
+ "type": "array",
+ "description": "Defines different variables for jobs that are running in parallel.",
+ "items": {
+ "type": "object",
+ "description": "Defines the variables for a specific job.",
+ "additionalProperties": {
+ "type": [
+ "string",
+ "number",
+ "array"
+ ]
+ }
+ },
+ "maxItems": 200
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "matrix"
+ ]
+ }
+ ]
+ },
"when": {
"markdownDescription": "Describes the conditions for when to run the job. Defaults to 'on_success'. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#when).",
"default": "on_success",
@@ -1494,6 +1585,9 @@
},
"optional": {
"type": "boolean"
+ },
+ "parallel": {
+ "$ref": "#/definitions/parallel_matrix"
}
},
"required": [
@@ -1512,6 +1606,9 @@
},
"artifacts": {
"type": "boolean"
+ },
+ "parallel": {
+ "$ref": "#/definitions/parallel_matrix"
}
},
"required": [
@@ -1534,6 +1631,9 @@
},
"artifacts": {
"type": "boolean"
+ },
+ "parallel": {
+ "$ref": "#/definitions/parallel_matrix"
}
},
"required": [
@@ -1747,41 +1847,7 @@
"$ref": "#/definitions/retry"
},
"parallel": {
- "description": "Parallel will split up a single job into several, and provide `CI_NODE_INDEX` and `CI_NODE_TOTAL` environment variables for the running jobs.",
- "oneOf": [
- {
- "type": "integer",
- "description": "Creates N instances of the same job that run in parallel.",
- "default": 0,
- "minimum": 2,
- "maximum": 200
- },
- {
- "type": "object",
- "properties": {
- "matrix": {
- "type": "array",
- "description": "Defines different variables for jobs that are running in parallel.",
- "items": {
- "type": "object",
- "description": "Defines environment variables for specific job.",
- "additionalProperties": {
- "type": [
- "string",
- "number",
- "array"
- ]
- }
- },
- "maxItems": 200
- }
- },
- "additionalProperties": false,
- "required": [
- "matrix"
- ]
- }
- ]
+ "$ref": "#/definitions/parallel"
},
"interruptible": {
"$ref": "#/definitions/interruptible"
diff --git a/app/assets/javascripts/emoji/awards_app/index.js b/app/assets/javascripts/emoji/awards_app/index.js
index 931407f4cf7..f3d72c2dba5 100644
--- a/app/assets/javascripts/emoji/awards_app/index.js
+++ b/app/assets/javascripts/emoji/awards_app/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import { parseBoolean } from '~/lib/utils/common_utils';
import AwardsList from '~/vue_shared/components/awards_list.vue';
diff --git a/app/assets/javascripts/emoji/awards_app/store/index.js b/app/assets/javascripts/emoji/awards_app/store/index.js
index 53ed50f9f5d..71c1071c719 100644
--- a/app/assets/javascripts/emoji/awards_app/store/index.js
+++ b/app/assets/javascripts/emoji/awards_app/store/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import mutations from './mutations';
diff --git a/app/assets/javascripts/emoji/components/category.vue b/app/assets/javascripts/emoji/components/category.vue
index a72e7df7769..d8607cbc60b 100644
--- a/app/assets/javascripts/emoji/components/category.vue
+++ b/app/assets/javascripts/emoji/components/category.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlIntersectionObserver } from '@gitlab/ui';
import { humanize } from '~/lib/utils/text_utility';
@@ -55,7 +56,7 @@ export default {
/>
</template>
<p v-else>
- {{ s__('AwardEmoji|No emojis found.') }}
+ {{ s__('AwardEmoji|No emoji found.') }}
</p>
</gl-intersection-observer>
</template>
diff --git a/app/assets/javascripts/emoji/components/picker.vue b/app/assets/javascripts/emoji/components/picker.vue
index 0e3dd9f7535..fcc54f17466 100644
--- a/app/assets/javascripts/emoji/components/picker.vue
+++ b/app/assets/javascripts/emoji/components/picker.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlIcon, GlDropdown, GlSearchBoxByType } from '@gitlab/ui';
import { findLastIndex } from 'lodash';
diff --git a/app/assets/javascripts/emoji/no_emoji_validator.js b/app/assets/javascripts/emoji/no_emoji_validator.js
index 85c8204225a..e052508fd26 100644
--- a/app/assets/javascripts/emoji/no_emoji_validator.js
+++ b/app/assets/javascripts/emoji/no_emoji_validator.js
@@ -20,7 +20,7 @@ export default class NoEmojiValidator extends InputValidator {
const { value } = this.inputDomElement;
- this.errorMessage = __('Invalid input, please avoid emojis');
+ this.errorMessage = __('Invalid input, please avoid emoji');
this.validatePattern(value);
this.setValidationStateAndMessage();
diff --git a/app/assets/javascripts/environments/components/commit.vue b/app/assets/javascripts/environments/components/commit.vue
index 8577bf629a3..9ee716ccbab 100644
--- a/app/assets/javascripts/environments/components/commit.vue
+++ b/app/assets/javascripts/environments/components/commit.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlAvatar, GlAvatarLink, GlLink, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
import { escape } from 'lodash';
diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue
index b2844ed5ad6..2186941e00c 100644
--- a/app/assets/javascripts/environments/components/container.vue
+++ b/app/assets/javascripts/environments/components/container.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
diff --git a/app/assets/javascripts/environments/components/deployment.vue b/app/assets/javascripts/environments/components/deployment.vue
index 01b8208fd55..96d2a8d9ba2 100644
--- a/app/assets/javascripts/environments/components/deployment.vue
+++ b/app/assets/javascripts/environments/components/deployment.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import {
GlBadge,
diff --git a/app/assets/javascripts/environments/components/edit_environment.vue b/app/assets/javascripts/environments/components/edit_environment.vue
index a2405d23924..f90a1dcd193 100644
--- a/app/assets/javascripts/environments/components/edit_environment.vue
+++ b/app/assets/javascripts/environments/components/edit_environment.vue
@@ -4,7 +4,7 @@ import { createAlert } from '~/alert';
import { visitUrl } from '~/lib/utils/url_utility';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import getEnvironment from '../graphql/queries/environment.query.graphql';
-import getEnvironmentWithNamespace from '../graphql/queries/environment_with_namespace.graphql';
+import getEnvironmentWithFluxResource from '../graphql/queries/environment_with_flux_resource.query.graphql';
import updateEnvironment from '../graphql/mutations/update_environment.mutation.graphql';
import EnvironmentForm from './environment_form.vue';
@@ -18,8 +18,8 @@ export default {
apollo: {
environment: {
query() {
- return this.glFeatures?.kubernetesNamespaceForEnvironment
- ? getEnvironmentWithNamespace
+ return this.glFeatures?.fluxResourceForEnvironment
+ ? getEnvironmentWithFluxResource
: getEnvironment;
},
variables() {
@@ -60,6 +60,7 @@ export default {
externalUrl: this.formEnvironment.externalUrl,
clusterAgentId: this.formEnvironment.clusterAgentId,
kubernetesNamespace: this.formEnvironment.kubernetesNamespace,
+ fluxResourcePath: this.formEnvironment.fluxResourcePath,
},
},
});
diff --git a/app/assets/javascripts/environments/components/environment_flux_resource_selector.vue b/app/assets/javascripts/environments/components/environment_flux_resource_selector.vue
new file mode 100644
index 00000000000..cad6752da94
--- /dev/null
+++ b/app/assets/javascripts/environments/components/environment_flux_resource_selector.vue
@@ -0,0 +1,210 @@
+<script>
+import { GlFormGroup, GlCollapsibleListbox, GlAlert } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import fluxKustomizationsQuery from '../graphql/queries/flux_kustomizations.query.graphql';
+import fluxHelmReleasesQuery from '../graphql/queries/flux_helm_releases.query.graphql';
+import {
+ HELM_RELEASES_RESOURCE_TYPE,
+ KUSTOMIZATIONS_RESOURCE_TYPE,
+ KUSTOMIZATION,
+ HELM_RELEASE,
+} from '../constants';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlCollapsibleListbox,
+ GlAlert,
+ },
+ props: {
+ configuration: {
+ required: true,
+ type: Object,
+ },
+ namespace: {
+ required: true,
+ type: String,
+ },
+ fluxResourcePath: {
+ required: false,
+ type: String,
+ default: '',
+ },
+ },
+ i18n: {
+ fluxResourceLabel: s__('Environments|Select Flux resource (optional)'),
+ kustomizationsGroupLabel: s__('Environments|Kustomizations'),
+ helmReleasesGroupLabel: s__('Environments|HelmReleases'),
+ fluxResourcesHelpText: s__('Environments|Select Flux resource'),
+ errorTitle: s__(
+ 'Environments|Unable to access the following resources from this environment. Check your authorization on the following and try again:',
+ ),
+ reset: __('Reset'),
+ },
+ data() {
+ return {
+ fluxResourceSearchTerm: '',
+ kustomizationsError: '',
+ helmReleasesError: '',
+ };
+ },
+ apollo: {
+ fluxKustomizations: {
+ query: fluxKustomizationsQuery,
+ variables() {
+ return {
+ configuration: this.configuration,
+ namespace: this.namespace,
+ };
+ },
+ skip() {
+ return !this.namespace;
+ },
+ update(data) {
+ return data?.fluxKustomizations || [];
+ },
+ error() {
+ this.kustomizationsError = KUSTOMIZATION;
+ },
+ result(result) {
+ if (!result?.error && !result.errors?.length) {
+ this.kustomizationsError = '';
+ }
+ },
+ },
+ fluxHelmReleases: {
+ query: fluxHelmReleasesQuery,
+ variables() {
+ return {
+ configuration: this.configuration,
+ namespace: this.namespace,
+ };
+ },
+ skip() {
+ return !this.namespace;
+ },
+ update(data) {
+ return data?.fluxHelmReleases || [];
+ },
+ error() {
+ this.helmReleasesError = HELM_RELEASE;
+ },
+ result(result) {
+ if (!result?.error && !result.errors?.length) {
+ this.helmReleasesError = '';
+ }
+ },
+ },
+ },
+ computed: {
+ variables() {
+ return {
+ configuration: this.configuration,
+ namespace: this.namespace,
+ };
+ },
+ loadingFluxResourcesList() {
+ return this.$apollo.loading;
+ },
+ kubernetesErrors() {
+ const errors = [];
+ if (this.kustomizationsError) {
+ errors.push(this.kustomizationsError);
+ }
+ if (this.helmReleasesError) {
+ errors.push(this.helmReleasesError);
+ }
+ return errors;
+ },
+ fluxResourcesDropdownToggleText() {
+ const selectedResourceParts = this.fluxResourcePath ? this.fluxResourcePath.split('/') : [];
+ return selectedResourceParts.length
+ ? selectedResourceParts.at(-1)
+ : this.$options.i18n.fluxResourcesHelpText;
+ },
+ fluxKustomizationsList() {
+ return (
+ this.fluxKustomizations?.map((item) => {
+ return {
+ value: `${item.apiVersion}/namespaces/${item.metadata.namespace}/${KUSTOMIZATIONS_RESOURCE_TYPE}/${item.metadata.name}`,
+ text: item.metadata.name,
+ };
+ }) || []
+ );
+ },
+ fluxHelmReleasesList() {
+ return (
+ this.fluxHelmReleases?.map((item) => {
+ return {
+ value: `${item.apiVersion}/namespaces/${item.metadata.namespace}/${HELM_RELEASES_RESOURCE_TYPE}/${item.metadata.name}`,
+ text: item.metadata.name,
+ };
+ }) || []
+ );
+ },
+ filteredKustomizationsList() {
+ const lowerCasedSearchTerm = this.fluxResourceSearchTerm.toLowerCase();
+ return this.fluxKustomizationsList.filter((item) =>
+ item.text.toLowerCase().includes(lowerCasedSearchTerm),
+ );
+ },
+ filteredHelmResourcesList() {
+ const lowerCasedSearchTerm = this.fluxResourceSearchTerm.toLowerCase();
+ return this.fluxHelmReleasesList.filter((item) =>
+ item.text.toLowerCase().includes(lowerCasedSearchTerm),
+ );
+ },
+ fluxResourcesList() {
+ const list = [];
+ if (this.filteredKustomizationsList?.length) {
+ list.push({
+ text: this.$options.i18n.kustomizationsGroupLabel,
+ options: this.filteredKustomizationsList,
+ });
+ }
+
+ if (this.filteredHelmResourcesList?.length) {
+ list.push({
+ text: this.$options.i18n.helmReleasesGroupLabel,
+ options: this.filteredHelmResourcesList,
+ });
+ }
+ return list;
+ },
+ },
+ methods: {
+ onChange(event) {
+ this.$emit('change', event);
+ },
+ onSearch(search) {
+ this.fluxResourceSearchTerm = search;
+ },
+ },
+};
+</script>
+<template>
+ <gl-form-group :label="$options.i18n.fluxResourceLabel" label-for="environment_flux_resource">
+ <gl-alert v-if="kubernetesErrors.length" variant="warning" :dismissible="false" class="gl-mb-5">
+ {{ $options.i18n.errorTitle }}
+ <ul class="gl-mb-0 gl-pl-6">
+ <li v-for="(error, index) of kubernetesErrors" :key="index">{{ error }}</li>
+ </ul>
+ </gl-alert>
+
+ <gl-collapsible-listbox
+ id="environment_flux_resource_path"
+ class="gl-w-full"
+ block
+ :selected="fluxResourcePath"
+ :items="fluxResourcesList"
+ :loading="loadingFluxResourcesList"
+ :toggle-text="fluxResourcesDropdownToggleText"
+ :header-text="$options.i18n.fluxResourcesHelpText"
+ :reset-button-label="$options.i18n.reset"
+ searchable
+ @search="onSearch"
+ @select="onChange"
+ @reset="onChange(null)"
+ />
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/environments/components/environment_form.vue b/app/assets/javascripts/environments/components/environment_form.vue
index 1bff013b9c2..d89dcf56b7c 100644
--- a/app/assets/javascripts/environments/components/environment_form.vue
+++ b/app/assets/javascripts/environments/components/environment_form.vue
@@ -21,6 +21,7 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import getNamespacesQuery from '../graphql/queries/k8s_namespaces.query.graphql';
import getUserAuthorizedAgents from '../graphql/queries/user_authorized_agents.query.graphql';
+import EnvironmentFluxResourceSelector from './environment_flux_resource_selector.vue';
export default {
components: {
@@ -32,6 +33,7 @@ export default {
GlLink,
GlSprintf,
GlAlert,
+ EnvironmentFluxResourceSelector,
},
mixins: [glFeatureFlagsMixin()],
inject: {
@@ -173,15 +175,20 @@ export default {
item.text.toLowerCase().includes(lowerCasedSearchTerm),
);
},
- isKasKubernetesNamespaceAvailable() {
- return this.glFeatures?.kubernetesNamespaceForEnvironment;
- },
showNamespaceSelector() {
- return Boolean(this.isKasKubernetesNamespaceAvailable && this.selectedAgentId);
+ return Boolean(this.selectedAgentId);
},
namespaceDropdownToggleText() {
return this.selectedNamespace || this.$options.i18n.namespaceHelpText;
},
+ isKasFluxResourceAvailable() {
+ return this.glFeatures?.fluxResourceForEnvironment;
+ },
+ showFluxResourceSelector() {
+ return Boolean(
+ this.isKasFluxResourceAvailable && this.selectedNamespace && this.selectedAgentId,
+ );
+ },
k8sAccessConfiguration() {
if (!this.showNamespaceSelector) {
return null;
@@ -201,6 +208,7 @@ export default {
watch: {
environment(change) {
this.selectedAgentId = change.clusterAgentId;
+ this.selectedNamespace = change.kubernetesNamespace;
},
},
methods: {
@@ -229,7 +237,12 @@ export default {
},
onAgentChange($event) {
this.selectedNamespace = null;
- this.onChange({ ...this.environment, clusterAgentId: $event, kubernetesNamespace: null });
+ this.onChange({
+ ...this.environment,
+ clusterAgentId: $event,
+ kubernetesNamespace: null,
+ fluxResourcePath: null,
+ });
},
onNamespaceSearch(search) {
this.namespaceSearchTerm = search;
@@ -348,11 +361,21 @@ export default {
:reset-button-label="$options.i18n.reset"
:searchable="true"
@search="onNamespaceSearch"
- @select="onChange({ ...environment, kubernetesNamespace: $event })"
+ @select="
+ onChange({ ...environment, kubernetesNamespace: $event, fluxResourcePath: null })
+ "
@reset="onChange({ ...environment, kubernetesNamespace: null })"
/>
</gl-form-group>
+ <environment-flux-resource-selector
+ v-if="showFluxResourceSelector"
+ :namespace="selectedNamespace"
+ :configuration="k8sAccessConfiguration"
+ :flux-resource-path="environment.fluxResourcePath"
+ @change="onChange({ ...environment, fluxResourcePath: $event })"
+ />
+
<div class="gl-mr-6">
<gl-button
:loading="loading"
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index a95b5b273f7..795cbf5327a 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -250,7 +250,6 @@ export default {
v-if="canSetupReviewApp"
v-model="isReviewAppModalVisible"
:modal-id="$options.modalId"
- data-testid="enable-review-app-modal"
/>
<stop-stale-environments-modal
v-if="canCleanUpEnvs"
diff --git a/app/assets/javascripts/environments/components/kubernetes_overview.vue b/app/assets/javascripts/environments/components/kubernetes_overview.vue
index a1efeaac359..0e52a80c2c5 100644
--- a/app/assets/javascripts/environments/components/kubernetes_overview.vue
+++ b/app/assets/javascripts/environments/components/kubernetes_overview.vue
@@ -24,11 +24,20 @@ export default {
required: true,
type: Object,
},
+ environmentName: {
+ required: true,
+ type: String,
+ },
namespace: {
required: false,
type: String,
default: '',
},
+ fluxResourcePath: {
+ required: false,
+ type: String,
+ default: '',
+ },
},
data() {
return {
@@ -96,7 +105,13 @@ export default {
</p>
<gl-collapse :visible="isVisible" class="gl-md-pl-7 gl-md-pr-5 gl-mt-4">
<template v-if="isVisible">
- <kubernetes-status-bar :cluster-health-status="clusterHealthStatus" class="gl-mb-4" />
+ <kubernetes-status-bar
+ :cluster-health-status="clusterHealthStatus"
+ :configuration="k8sAccessConfiguration"
+ :namespace="namespace"
+ :environment-name="environmentName"
+ :flux-resource-path="fluxResourcePath"
+ class="gl-mb-3" />
<kubernetes-agent-info :cluster-agent="clusterAgent" class="gl-mb-5" />
<gl-alert v-if="error" variant="danger" :dismissible="false" class="gl-mb-5">
diff --git a/app/assets/javascripts/environments/components/kubernetes_status_bar.vue b/app/assets/javascripts/environments/components/kubernetes_status_bar.vue
index 94cd7438e46..e8857dfe459 100644
--- a/app/assets/javascripts/environments/components/kubernetes_status_bar.vue
+++ b/app/assets/javascripts/environments/components/kubernetes_status_bar.vue
@@ -1,12 +1,24 @@
<script>
-import { GlLoadingIcon, GlBadge } from '@gitlab/ui';
+import { GlLoadingIcon, GlBadge, GlPopover, GlSprintf, GlLink } from '@gitlab/ui';
import { s__ } from '~/locale';
-import { HEALTH_BADGES } from '../constants';
+import {
+ HEALTH_BADGES,
+ SYNC_STATUS_BADGES,
+ STATUS_TRUE,
+ STATUS_FALSE,
+ HELM_RELEASES_RESOURCE_TYPE,
+ KUSTOMIZATIONS_RESOURCE_TYPE,
+} from '../constants';
+import fluxKustomizationStatusQuery from '../graphql/queries/flux_kustomization_status.query.graphql';
+import fluxHelmReleaseStatusQuery from '../graphql/queries/flux_helm_release_status.query.graphql';
export default {
components: {
GlLoadingIcon,
GlBadge,
+ GlPopover,
+ GlSprintf,
+ GlLink,
},
props: {
clusterHealthStatus: {
@@ -17,23 +29,175 @@ export default {
return ['error', 'success', ''].includes(val);
},
},
+ configuration: {
+ required: true,
+ type: Object,
+ },
+ environmentName: {
+ required: true,
+ type: String,
+ },
+ namespace: {
+ required: false,
+ type: String,
+ default: '',
+ },
+ fluxResourcePath: {
+ required: false,
+ type: String,
+ default: '',
+ },
+ },
+ apollo: {
+ fluxKustomizationStatus: {
+ query: fluxKustomizationStatusQuery,
+ variables() {
+ return {
+ configuration: this.configuration,
+ namespace: this.namespace,
+ environmentName: this.environmentName.toLowerCase(),
+ fluxResourcePath: this.fluxResourcePath,
+ };
+ },
+ skip() {
+ return Boolean(
+ !this.namespace || this.fluxResourcePath?.includes(HELM_RELEASES_RESOURCE_TYPE),
+ );
+ },
+ error(err) {
+ this.fluxApiError = err.message;
+ },
+ },
+ fluxHelmReleaseStatus: {
+ query: fluxHelmReleaseStatusQuery,
+ variables() {
+ return {
+ configuration: this.configuration,
+ namespace: this.namespace,
+ environmentName: this.environmentName.toLowerCase(),
+ fluxResourcePath: this.fluxResourcePath,
+ };
+ },
+ skip() {
+ return Boolean(
+ !this.namespace ||
+ this.$apollo.queries.fluxKustomizationStatus.loading ||
+ this.hasKustomizations ||
+ this.fluxResourcePath?.includes(KUSTOMIZATIONS_RESOURCE_TYPE),
+ );
+ },
+ error(err) {
+ this.fluxApiError = err.message;
+ },
+ },
+ },
+ data() {
+ return {
+ fluxApiError: '',
+ };
},
computed: {
healthBadge() {
return HEALTH_BADGES[this.clusterHealthStatus];
},
+ hasKustomizations() {
+ return this.fluxKustomizationStatus?.length;
+ },
+ hasHelmReleases() {
+ return this.fluxHelmReleaseStatus?.length;
+ },
+ isLoading() {
+ return (
+ this.$apollo.queries.fluxKustomizationStatus.loading ||
+ this.$apollo.queries.fluxHelmReleaseStatus.loading
+ );
+ },
+ fluxBadgeId() {
+ return `${this.environmentName}-flux-sync-badge`;
+ },
+ fluxCRD() {
+ if (!this.hasKustomizations && !this.hasHelmReleases) {
+ return [];
+ }
+
+ return this.hasKustomizations ? this.fluxKustomizationStatus : this.fluxHelmReleaseStatus;
+ },
+ fluxAnyStalled() {
+ return this.fluxCRD.find((condition) => {
+ return condition.status === STATUS_TRUE && condition.type === 'Stalled';
+ });
+ },
+ fluxAnyReconciling() {
+ return this.fluxCRD.find((condition) => {
+ return condition.status === STATUS_TRUE && condition.type === 'Reconciling';
+ });
+ },
+ fluxAnyReconciled() {
+ return this.fluxCRD.find((condition) => {
+ return condition.status === STATUS_TRUE && condition.type === 'Ready';
+ });
+ },
+ fluxAnyFailed() {
+ return this.fluxCRD.find((condition) => {
+ return condition.status === STATUS_FALSE && condition.type === 'Ready';
+ });
+ },
+ syncStatusBadge() {
+ if (!this.fluxCRD.length && this.fluxApiError) {
+ return { ...SYNC_STATUS_BADGES.unavailable, popoverText: this.fluxApiError };
+ } else if (!this.fluxCRD.length) {
+ return SYNC_STATUS_BADGES.unavailable;
+ } else if (this.fluxAnyFailed) {
+ return { ...SYNC_STATUS_BADGES.failed, popoverText: this.fluxAnyFailed.message };
+ } else if (this.fluxAnyStalled) {
+ return { ...SYNC_STATUS_BADGES.stalled, popoverText: this.fluxAnyStalled.message };
+ } else if (this.fluxAnyReconciling) {
+ return SYNC_STATUS_BADGES.reconciling;
+ } else if (this.fluxAnyReconciled) {
+ return SYNC_STATUS_BADGES.reconciled;
+ }
+ return SYNC_STATUS_BADGES.unknown;
+ },
},
i18n: {
healthLabel: s__('Environment|Environment health'),
+ syncStatusLabel: s__('Environment|Sync status'),
},
+ badgeContainerClasses: 'gl-display-flex gl-align-items-center gl-flex-shrink-0 gl-mr-3 gl-mb-2',
};
</script>
<template>
- <div class="gl-display-flex gl-align-items-center gl-mr-3 gl-mb-2">
- <span class="gl-font-sm gl-font-monospace gl-mr-3">{{ $options.i18n.healthLabel }}</span>
- <gl-loading-icon v-if="!clusterHealthStatus" size="sm" inline />
- <gl-badge v-else-if="healthBadge" :variant="healthBadge.variant">
- {{ healthBadge.text }}
- </gl-badge>
+ <div class="gl-display-flex gl-flex-wrap">
+ <div :class="$options.badgeContainerClasses">
+ <span class="gl-mr-3">{{ $options.i18n.healthLabel }}</span>
+ <gl-loading-icon v-if="!clusterHealthStatus" size="sm" inline />
+ <gl-badge v-else-if="healthBadge" :variant="healthBadge.variant" data-testid="health-badge">
+ {{ healthBadge.text }}
+ </gl-badge>
+ </div>
+
+ <div :class="$options.badgeContainerClasses">
+ <span class="gl-mr-3">{{ $options.i18n.syncStatusLabel }}</span>
+ <gl-loading-icon v-if="isLoading" size="sm" inline />
+ <template v-else-if="syncStatusBadge">
+ <gl-badge
+ :id="fluxBadgeId"
+ :icon="syncStatusBadge.icon"
+ :variant="syncStatusBadge.variant"
+ data-testid="sync-badge"
+ tabindex="0"
+ >{{ syncStatusBadge.text }}
+ </gl-badge>
+ <gl-popover :target="fluxBadgeId" :title="syncStatusBadge.popoverTitle">
+ <gl-sprintf :message="syncStatusBadge.popoverText">
+ <template #link="{ content }">
+ <gl-link :href="syncStatusBadge.popoverLink" class="gl-font-sm">{{
+ content
+ }}</gl-link></template
+ >
+ </gl-sprintf>
+ </gl-popover>
+ </template>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/environments/components/new_environment.vue b/app/assets/javascripts/environments/components/new_environment.vue
index c6bc94b0b80..6a4ed34989f 100644
--- a/app/assets/javascripts/environments/components/new_environment.vue
+++ b/app/assets/javascripts/environments/components/new_environment.vue
@@ -35,6 +35,7 @@ export default {
projectPath: this.projectPath,
clusterAgentId: this.environment.clusterAgentId,
kubernetesNamespace: this.environment.kubernetesNamespace,
+ fluxResourcePath: this.environment.fluxResourcePath,
},
},
});
diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue
index fda1c85f739..2148343f690 100644
--- a/app/assets/javascripts/environments/components/new_environment_item.vue
+++ b/app/assets/javascripts/environments/components/new_environment_item.vue
@@ -14,7 +14,7 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import isLastDeployment from '../graphql/queries/is_last_deployment.query.graphql';
import getEnvironmentClusterAgent from '../graphql/queries/environment_cluster_agent.query.graphql';
-import getEnvironmentClusterAgentWithNamespace from '../graphql/queries/environment_cluster_agent_with_namespace.query.graphql';
+import getEnvironmentClusterAgentWithFluxResource from '../graphql/queries/environment_cluster_agent_with_flux_resource.query.graphql';
import ExternalUrl from './environment_external_url.vue';
import Actions from './environment_actions.vue';
import StopComponent from './environment_stop.vue';
@@ -83,7 +83,7 @@ export default {
tierTooltip: s__('Environment|Deployment tier'),
},
data() {
- return { visible: false, clusterAgent: null, kubernetesNamespace: '' };
+ return { visible: false, clusterAgent: null, kubernetesNamespace: '', fluxResourcePath: '' };
},
computed: {
icon() {
@@ -165,8 +165,8 @@ export default {
rolloutStatus() {
return this.environment?.rolloutStatus;
},
- isKubernetesNamespaceAvailable() {
- return this.glFeatures?.kubernetesNamespaceForEnvironment;
+ isFluxResourceAvailable() {
+ return this.glFeatures?.fluxResourceForEnvironment;
},
},
methods: {
@@ -185,13 +185,14 @@ export default {
return { environmentName: this.environment.name, projectFullPath: this.projectPath };
},
query() {
- return this.isKubernetesNamespaceAvailable
- ? getEnvironmentClusterAgentWithNamespace
+ return this.isFluxResourceAvailable
+ ? getEnvironmentClusterAgentWithFluxResource
: getEnvironmentClusterAgent;
},
update(data) {
this.clusterAgent = data?.project?.environment?.clusterAgent;
- this.kubernetesNamespace = data?.project?.environment?.kubernetesNamespace || '';
+ this.kubernetesNamespace = data?.project?.environment?.kubernetesNamespace;
+ this.fluxResourcePath = data?.project?.environment?.fluxResourcePath || '';
},
});
},
@@ -372,7 +373,12 @@ export default {
</gl-sprintf>
</div>
<div v-if="clusterAgent" :class="$options.kubernetesOverviewClasses">
- <kubernetes-overview :cluster-agent="clusterAgent" :namespace="kubernetesNamespace" />
+ <kubernetes-overview
+ :cluster-agent="clusterAgent"
+ :namespace="kubernetesNamespace"
+ :flux-resource-path="fluxResourcePath"
+ :environment-name="environment.name"
+ />
</div>
<div v-if="rolloutStatus" :class="$options.deployBoardClasses">
<deploy-board-wrapper
diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js
index dc9481a5429..7214454c45c 100644
--- a/app/assets/javascripts/environments/constants.js
+++ b/app/assets/javascripts/environments/constants.js
@@ -1,5 +1,6 @@
import { __, s__ } from '~/locale';
import { getDateInPast } from '~/lib/utils/datetime_utility';
+import { helpPagePath } from '~/helpers/help_page_helper';
// These statuses are based on how the backend defines pod phases here
// lib/gitlab/kubernetes/pod.rb
@@ -104,6 +105,56 @@ export const HEALTH_BADGES = {
},
};
+export const SYNC_STATUS_BADGES = {
+ reconciled: {
+ variant: 'success',
+ icon: 'status_success',
+ text: s__('Environment|Reconciled'),
+ popoverText: s__('Deployment|Flux sync reconciled successfully'),
+ },
+ reconciling: {
+ variant: 'info',
+ icon: 'status_running',
+ text: s__('Environment|Reconciling'),
+ popoverText: s__('Deployment|Flux sync reconciling'),
+ },
+ stalled: {
+ variant: 'warning',
+ icon: 'status_pending',
+ text: s__('Environment|Stalled'),
+ popoverTitle: s__('Deployment|Flux sync stalled'),
+ },
+ failed: {
+ variant: 'danger',
+ icon: 'status_failed',
+ text: s__('Deployment|Failed'),
+ popoverTitle: s__('Deployment|Flux sync failed'),
+ },
+ unknown: {
+ variant: 'neutral',
+ icon: 'status_notfound',
+ text: s__('Deployment|Unknown'),
+ popoverTitle: s__('Deployment|Flux sync status is unknown'),
+ popoverText: s__(
+ 'Deployment|Unable to detect state. %{linkStart}How are states detected?%{linkEnd}',
+ ),
+ popoverLink: 'https://gitlab.com/gitlab-org/gitlab/-/issues/419666#results',
+ },
+ unavailable: {
+ variant: 'muted',
+ icon: 'status_notfound',
+ text: s__('Deployment|Unavailable'),
+ popoverTitle: s__('Deployment|Flux sync status is unavailable'),
+ popoverText: s__(
+ 'Deployment|Sync status is unknown. %{linkStart}How do I configure Flux for my deployment?%{linkEnd}',
+ ),
+ popoverLink: helpPagePath('user/clusters/agent/gitops/flux_tutorial'),
+ },
+};
+
+export const STATUS_TRUE = 'True';
+export const STATUS_FALSE = 'False';
+
export const PHASE_RUNNING = 'Running';
export const PHASE_PENDING = 'Pending';
export const PHASE_SUCCEEDED = 'Succeeded';
@@ -124,3 +175,16 @@ export const CLUSTER_AGENT_ERROR_MESSAGES = {
[ERROR_NOT_FOUND]: s__('Environment|Cluster agent not found.'),
[ERROR_OTHER]: s__('Environment|There was an error connecting to the cluster agent.'),
};
+
+export const CLUSTER_FLUX_RECOURSES_ERROR_MESSAGES = {
+ [ERROR_UNAUTHORIZED]: s__(
+ 'Environment|Unauthorized to access %{resourceType} from this environment.',
+ ),
+ [ERROR_OTHER]: s__('Environment|There was an error fetching %{resourceType}.'),
+};
+
+export const HELM_RELEASES_RESOURCE_TYPE = 'helmreleases';
+export const KUSTOMIZATIONS_RESOURCE_TYPE = 'kustomizations';
+
+export const KUSTOMIZATION = 'Kustomization';
+export const HELM_RELEASE = 'HelmRelease';
diff --git a/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue b/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue
index 787302df60f..686acc22585 100644
--- a/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue
+++ b/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue
@@ -107,7 +107,6 @@ export default {
v-if="isRollbackAvailable"
v-gl-modal.confirm-rollback-modal
v-gl-tooltip
- data-testid="rollback-button"
:title="rollbackButtonTitle"
:icon="rollbackIcon"
:aria-label="rollbackButtonTitle"
diff --git a/app/assets/javascripts/environments/environment_details/index.vue b/app/assets/javascripts/environments/environment_details/index.vue
index ff2dd9935ae..aa836299bcc 100644
--- a/app/assets/javascripts/environments/environment_details/index.vue
+++ b/app/assets/javascripts/environments/environment_details/index.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
diff --git a/app/assets/javascripts/environments/environment_details/pagination.vue b/app/assets/javascripts/environments/environment_details/pagination.vue
index 414610b306a..f8bacca061b 100644
--- a/app/assets/javascripts/environments/environment_details/pagination.vue
+++ b/app/assets/javascripts/environments/environment_details/pagination.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlKeysetPagination } from '@gitlab/ui';
import { setUrlParams } from '~/lib/utils/url_utility';
diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js
index 553b06e632f..8faed710402 100644
--- a/app/assets/javascripts/environments/graphql/client.js
+++ b/app/assets/javascripts/environments/graphql/client.js
@@ -9,6 +9,8 @@ import k8sPodsQuery from './queries/k8s_pods.query.graphql';
import k8sServicesQuery from './queries/k8s_services.query.graphql';
import k8sWorkloadsQuery from './queries/k8s_workloads.query.graphql';
import k8sNamespacesQuery from './queries/k8s_namespaces.query.graphql';
+import fluxKustomizationStatusQuery from './queries/flux_kustomization_status.query.graphql';
+import fluxHelmReleaseStatusQuery from './queries/flux_helm_release_status.query.graphql';
import { resolvers } from './resolvers';
import typeDefs from './typedefs.graphql';
@@ -170,6 +172,21 @@ export const apolloProvider = (endpoint) => {
},
},
});
+ cache.writeQuery({
+ query: fluxKustomizationStatusQuery,
+ data: {
+ status: '',
+ type: '',
+ },
+ });
+ cache.writeQuery({
+ query: fluxHelmReleaseStatusQuery,
+ data: {
+ status: '',
+ type: '',
+ },
+ });
+
return new VueApollo({
defaultClient,
});
diff --git a/app/assets/javascripts/environments/graphql/queries/environment.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment.query.graphql
index 20402e8d32e..53dfe5303f3 100644
--- a/app/assets/javascripts/environments/graphql/queries/environment.query.graphql
+++ b/app/assets/javascripts/environments/graphql/queries/environment.query.graphql
@@ -5,6 +5,7 @@ query getEnvironment($projectFullPath: ID!, $environmentName: String) {
id
name
externalUrl
+ kubernetesNamespace
clusterAgent {
id
name
diff --git a/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent.query.graphql
index 760f1fba897..19374ae7a81 100644
--- a/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent.query.graphql
+++ b/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent.query.graphql
@@ -3,6 +3,7 @@ query getEnvironmentClusterAgent($projectFullPath: ID!, $environmentName: String
id
environment(name: $environmentName) {
id
+ kubernetesNamespace
clusterAgent {
id
name
diff --git a/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent_with_namespace.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent_with_flux_resource.query.graphql
index 5e72c2dac20..80363a06d42 100644
--- a/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent_with_namespace.query.graphql
+++ b/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent_with_flux_resource.query.graphql
@@ -1,9 +1,10 @@
-query getEnvironmentClusterAgentWithNamespace($projectFullPath: ID!, $environmentName: String) {
+query getEnvironmentClusterAgentWithFluxResource($projectFullPath: ID!, $environmentName: String) {
project(fullPath: $projectFullPath) {
id
environment(name: $environmentName) {
id
kubernetesNamespace
+ fluxResourcePath
clusterAgent {
id
name
diff --git a/app/assets/javascripts/environments/graphql/queries/environment_with_namespace.graphql b/app/assets/javascripts/environments/graphql/queries/environment_with_flux_resource.query.graphql
index 42796f982b6..166cd64189f 100644
--- a/app/assets/javascripts/environments/graphql/queries/environment_with_namespace.graphql
+++ b/app/assets/javascripts/environments/graphql/queries/environment_with_flux_resource.query.graphql
@@ -1,4 +1,4 @@
-query getEnvironmentWithNamespace($projectFullPath: ID!, $environmentName: String) {
+query getEnvironmentWithFluxResource($projectFullPath: ID!, $environmentName: String) {
project(fullPath: $projectFullPath) {
id
environment(name: $environmentName) {
@@ -6,6 +6,7 @@ query getEnvironmentWithNamespace($projectFullPath: ID!, $environmentName: Strin
name
externalUrl
kubernetesNamespace
+ fluxResourcePath
clusterAgent {
id
name
diff --git a/app/assets/javascripts/environments/graphql/queries/flux_helm_release_status.query.graphql b/app/assets/javascripts/environments/graphql/queries/flux_helm_release_status.query.graphql
new file mode 100644
index 00000000000..544232dafd7
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/flux_helm_release_status.query.graphql
@@ -0,0 +1,17 @@
+query getFluxHelmReleaseStatusQuery(
+ $configuration: LocalConfiguration
+ $namespace: String
+ $environmentName: String
+ $fluxResourcePath: String
+) {
+ fluxHelmReleaseStatus(
+ configuration: $configuration
+ namespace: $namespace
+ environmentName: $environmentName
+ fluxResourcePath: $fluxResourcePath
+ ) @client {
+ message
+ status
+ type
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/queries/flux_helm_releases.query.graphql b/app/assets/javascripts/environments/graphql/queries/flux_helm_releases.query.graphql
new file mode 100644
index 00000000000..fb37aba5adb
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/flux_helm_releases.query.graphql
@@ -0,0 +1,9 @@
+query getFluxHelmReleasesQuery($configuration: LocalConfiguration, $namespace: String) {
+ fluxHelmReleases(configuration: $configuration, namespace: $namespace) @client {
+ apiVersion
+ metadata {
+ name
+ namespace
+ }
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/queries/flux_kustomization_status.query.graphql b/app/assets/javascripts/environments/graphql/queries/flux_kustomization_status.query.graphql
new file mode 100644
index 00000000000..2884f95355e
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/flux_kustomization_status.query.graphql
@@ -0,0 +1,17 @@
+query getFluxHelmKustomizationStatusQuery(
+ $configuration: LocalConfiguration
+ $namespace: String
+ $environmentName: String
+ $fluxResourcePath: String
+) {
+ fluxKustomizationStatus(
+ configuration: $configuration
+ namespace: $namespace
+ environmentName: $environmentName
+ fluxResourcePath: $fluxResourcePath
+ ) @client {
+ message
+ status
+ type
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/queries/flux_kustomizations.query.graphql b/app/assets/javascripts/environments/graphql/queries/flux_kustomizations.query.graphql
new file mode 100644
index 00000000000..ea7966560c3
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/flux_kustomizations.query.graphql
@@ -0,0 +1,9 @@
+query getFluxKustomizationsQuery($configuration: LocalConfiguration, $namespace: String) {
+ fluxKustomizations(configuration: $configuration, namespace: $namespace) @client {
+ apiVersion
+ metadata {
+ name
+ namespace
+ }
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js
index 8cfe44c5a05..017e3ccb45b 100644
--- a/app/assets/javascripts/environments/graphql/resolvers.js
+++ b/app/assets/javascripts/environments/graphql/resolvers.js
@@ -1,320 +1,14 @@
-import { CoreV1Api, Configuration, AppsV1Api, BatchV1Api } from '@gitlab/cluster-client';
-import axios from '~/lib/utils/axios_utils';
-import { s__ } from '~/locale';
-import {
- convertObjectPropsToCamelCase,
- parseIntPagination,
- normalizeHeaders,
-} from '~/lib/utils/common_utils';
-import { humanizeClusterErrors } from '../helpers/k8s_integration_helper';
-
-import pollIntervalQuery from './queries/poll_interval.query.graphql';
-import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql';
-import environmentToStopQuery from './queries/environment_to_stop.query.graphql';
-import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql';
-import environmentToChangeCanaryQuery from './queries/environment_to_change_canary.query.graphql';
-import isEnvironmentStoppingQuery from './queries/is_environment_stopping.query.graphql';
-import pageInfoQuery from './queries/page_info.query.graphql';
-
-const buildErrors = (errors = []) => ({
- errors,
- __typename: 'LocalEnvironmentErrors',
-});
-
-const mapNestedEnvironment = (env) => ({
- ...convertObjectPropsToCamelCase(env, { deep: true }),
- __typename: 'NestedLocalEnvironment',
-});
-const mapEnvironment = (env) => ({
- ...convertObjectPropsToCamelCase(env, { deep: true }),
- __typename: 'LocalEnvironment',
-});
-
-const mapWorkloadItems = (items, kind) => {
- return items.map((item) => {
- const updatedItem = {
- status: {},
- spec: {},
- };
-
- switch (kind) {
- case 'DeploymentList':
- updatedItem.status.conditions = item.status.conditions || [];
- break;
- case 'DaemonSetList':
- updatedItem.status = {
- numberMisscheduled: item.status.numberMisscheduled || 0,
- numberReady: item.status.numberReady || 0,
- desiredNumberScheduled: item.status.desiredNumberScheduled || 0,
- };
- break;
- case 'StatefulSetList':
- case 'ReplicaSetList':
- updatedItem.status.readyReplicas = item.status.readyReplicas || 0;
- updatedItem.spec.replicas = item.spec.replicas || 0;
- break;
- case 'JobList':
- updatedItem.status.failed = item.status.failed || 0;
- updatedItem.status.succeeded = item.status.succeeded || 0;
- updatedItem.spec.completions = item.spec.completions || 0;
- break;
- case 'CronJobList':
- updatedItem.status.active = item.status.active || 0;
- updatedItem.status.lastScheduleTime = item.status.lastScheduleTime || '';
- updatedItem.spec.suspend = item.spec.suspend || 0;
- break;
- default:
- updatedItem.status = item?.status;
- updatedItem.spec = item?.spec;
- break;
- }
-
- return updatedItem;
- });
-};
-
-const handleClusterError = (err) => {
- const error = err?.response?.data?.message ? new Error(err.response.data.message) : err;
- throw error;
-};
+import { baseQueries, baseMutations } from './resolvers/base';
+import kubernetesQueries from './resolvers/kubernetes';
+import fluxQueries from './resolvers/flux';
export const resolvers = (endpoint) => ({
Query: {
- environmentApp(_context, { page, scope, search }, { cache }) {
- return axios.get(endpoint, { params: { nested: true, page, scope, search } }).then((res) => {
- const headers = normalizeHeaders(res.headers);
- const interval = headers['POLL-INTERVAL'];
- const pageInfo = { ...parseIntPagination(headers), __typename: 'LocalPageInfo' };
-
- if (interval) {
- cache.writeQuery({ query: pollIntervalQuery, data: { interval: parseFloat(interval) } });
- } else {
- cache.writeQuery({ query: pollIntervalQuery, data: { interval: undefined } });
- }
-
- cache.writeQuery({
- query: pageInfoQuery,
- data: { pageInfo },
- });
-
- return {
- availableCount: res.data.available_count,
- environments: res.data.environments.map(mapNestedEnvironment),
- reviewApp: {
- ...convertObjectPropsToCamelCase(res.data.review_app),
- __typename: 'ReviewApp',
- },
- canStopStaleEnvironments: res.data.can_stop_stale_environments,
- stoppedCount: res.data.stopped_count,
- __typename: 'LocalEnvironmentApp',
- };
- });
- },
- folder(_, { environment: { folderPath }, scope, search }) {
- return axios.get(folderPath, { params: { scope, search, per_page: 3 } }).then((res) => ({
- availableCount: res.data.available_count,
- environments: res.data.environments.map(mapEnvironment),
- stoppedCount: res.data.stopped_count,
- __typename: 'LocalEnvironmentFolder',
- }));
- },
- isLastDeployment(_, { environment }) {
- return environment?.lastDeployment?.isLast;
- },
- k8sPods(_, { configuration, namespace }) {
- const coreV1Api = new CoreV1Api(new Configuration(configuration));
- const podsApi = namespace
- ? coreV1Api.listCoreV1NamespacedPod(namespace)
- : coreV1Api.listCoreV1PodForAllNamespaces();
-
- return podsApi
- .then((res) => res?.data?.items || [])
- .catch((err) => {
- handleClusterError(err);
- });
- },
- k8sServices(_, { configuration }) {
- const coreV1Api = new CoreV1Api(new Configuration(configuration));
- return coreV1Api
- .listCoreV1ServiceForAllNamespaces()
- .then((res) => {
- const items = res?.data?.items || [];
- return items.map((item) => {
- const { type, clusterIP, externalIP, ports } = item.spec;
- return {
- metadata: item.metadata,
- spec: {
- type,
- clusterIP: clusterIP || '-',
- externalIP: externalIP || '-',
- ports,
- },
- };
- });
- })
- .catch((err) => {
- handleClusterError(err);
- });
- },
- k8sWorkloads(_, { configuration, namespace }) {
- const appsV1api = new AppsV1Api(configuration);
- const batchV1api = new BatchV1Api(configuration);
-
- let promises;
-
- if (namespace) {
- promises = [
- appsV1api.listAppsV1NamespacedDeployment(namespace),
- appsV1api.listAppsV1NamespacedDaemonSet(namespace),
- appsV1api.listAppsV1NamespacedStatefulSet(namespace),
- appsV1api.listAppsV1NamespacedReplicaSet(namespace),
- batchV1api.listBatchV1NamespacedJob(namespace),
- batchV1api.listBatchV1NamespacedCronJob(namespace),
- ];
- } else {
- promises = [
- appsV1api.listAppsV1DeploymentForAllNamespaces(),
- appsV1api.listAppsV1DaemonSetForAllNamespaces(),
- appsV1api.listAppsV1StatefulSetForAllNamespaces(),
- appsV1api.listAppsV1ReplicaSetForAllNamespaces(),
- batchV1api.listBatchV1JobForAllNamespaces(),
- batchV1api.listBatchV1CronJobForAllNamespaces(),
- ];
- }
-
- const summaryList = {
- DeploymentList: [],
- DaemonSetList: [],
- StatefulSetList: [],
- ReplicaSetList: [],
- JobList: [],
- CronJobList: [],
- };
-
- return Promise.allSettled(promises).then((results) => {
- if (results.every((res) => res.status === 'rejected')) {
- const error = results[0].reason;
- const errorMessage = error?.response?.data?.message ?? error;
- throw new Error(errorMessage);
- }
- for (const promiseResult of results) {
- if (promiseResult.status === 'fulfilled' && promiseResult?.value?.data) {
- const { kind, items } = promiseResult.value.data;
-
- if (items?.length > 0) {
- summaryList[kind] = mapWorkloadItems(items, kind);
- }
- }
- }
-
- return summaryList;
- });
- },
- k8sNamespaces(_, { configuration }) {
- const coreV1Api = new CoreV1Api(new Configuration(configuration));
- const namespacesApi = coreV1Api.listCoreV1Namespace();
-
- return namespacesApi
- .then((res) => {
- return res?.data?.items || [];
- })
- .catch((err) => {
- const error = err?.response?.data?.reason || err;
- throw new Error(humanizeClusterErrors(error));
- });
- },
+ ...baseQueries(endpoint),
+ ...kubernetesQueries,
+ ...fluxQueries,
},
Mutation: {
- stopEnvironmentREST(_, { environment }, { client }) {
- client.writeQuery({
- query: isEnvironmentStoppingQuery,
- variables: { environment },
- data: { isEnvironmentStopping: true },
- });
- return axios
- .post(environment.stopPath)
- .then(() => buildErrors())
- .catch(() => {
- client.writeQuery({
- query: isEnvironmentStoppingQuery,
- variables: { environment },
- data: { isEnvironmentStopping: false },
- });
- return buildErrors([
- s__('Environments|An error occurred while stopping the environment, please try again'),
- ]);
- });
- },
- deleteEnvironment(_, { environment: { deletePath } }) {
- return axios
- .delete(deletePath)
- .then(() => buildErrors())
- .catch(() =>
- buildErrors([
- s__(
- 'Environments|An error occurred while deleting the environment. Check if the environment stopped; if not, stop it and try again.',
- ),
- ]),
- );
- },
- rollbackEnvironment(_, { environment, isLastDeployment }) {
- return axios
- .post(environment?.retryUrl)
- .then(() => buildErrors())
- .catch(() => {
- buildErrors([
- isLastDeployment
- ? s__(
- 'Environments|An error occurred while re-deploying the environment, please try again',
- )
- : s__(
- 'Environments|An error occurred while rolling back the environment, please try again',
- ),
- ]);
- });
- },
- setEnvironmentToStop(_, { environment }, { client }) {
- client.writeQuery({
- query: environmentToStopQuery,
- data: { environmentToStop: environment },
- });
- },
- action(_, { action: { playPath } }) {
- return axios
- .post(playPath)
- .then(() => buildErrors())
- .catch(() =>
- buildErrors([s__('Environments|An error occurred while making the request.')]),
- );
- },
- setEnvironmentToDelete(_, { environment }, { client }) {
- client.writeQuery({
- query: environmentToDeleteQuery,
- data: { environmentToDelete: environment },
- });
- },
- setEnvironmentToRollback(_, { environment }, { client }) {
- client.writeQuery({
- query: environmentToRollbackQuery,
- data: { environmentToRollback: environment },
- });
- },
- setEnvironmentToChangeCanary(_, { environment, weight }, { client }) {
- client.writeQuery({
- query: environmentToChangeCanaryQuery,
- data: { environmentToChangeCanary: environment, weight },
- });
- },
- cancelAutoStop(_, { autoStopUrl }) {
- return axios
- .post(autoStopUrl)
- .then(() => buildErrors())
- .catch((err) =>
- buildErrors([
- err?.response?.data?.message ||
- s__('Environments|An error occurred while canceling the auto stop, please try again'),
- ]),
- );
- },
+ ...baseMutations,
},
});
diff --git a/app/assets/javascripts/environments/graphql/resolvers/base.js b/app/assets/javascripts/environments/graphql/resolvers/base.js
new file mode 100644
index 00000000000..9752a3a6634
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/resolvers/base.js
@@ -0,0 +1,165 @@
+import axios from '~/lib/utils/axios_utils';
+import { s__ } from '~/locale';
+import {
+ convertObjectPropsToCamelCase,
+ parseIntPagination,
+ normalizeHeaders,
+} from '~/lib/utils/common_utils';
+
+import pollIntervalQuery from '../queries/poll_interval.query.graphql';
+import environmentToRollbackQuery from '../queries/environment_to_rollback.query.graphql';
+import environmentToStopQuery from '../queries/environment_to_stop.query.graphql';
+import environmentToDeleteQuery from '../queries/environment_to_delete.query.graphql';
+import environmentToChangeCanaryQuery from '../queries/environment_to_change_canary.query.graphql';
+import isEnvironmentStoppingQuery from '../queries/is_environment_stopping.query.graphql';
+import pageInfoQuery from '../queries/page_info.query.graphql';
+
+const buildErrors = (errors = []) => ({
+ errors,
+ __typename: 'LocalEnvironmentErrors',
+});
+
+const mapNestedEnvironment = (env) => ({
+ ...convertObjectPropsToCamelCase(env, { deep: true }),
+ __typename: 'NestedLocalEnvironment',
+});
+const mapEnvironment = (env) => ({
+ ...convertObjectPropsToCamelCase(env, { deep: true }),
+ __typename: 'LocalEnvironment',
+});
+
+export const baseQueries = (endpoint) => ({
+ environmentApp(_context, { page, scope, search }, { cache }) {
+ return axios.get(endpoint, { params: { nested: true, page, scope, search } }).then((res) => {
+ const headers = normalizeHeaders(res.headers);
+ const interval = headers['POLL-INTERVAL'];
+ const pageInfo = { ...parseIntPagination(headers), __typename: 'LocalPageInfo' };
+
+ if (interval) {
+ cache.writeQuery({ query: pollIntervalQuery, data: { interval: parseFloat(interval) } });
+ } else {
+ cache.writeQuery({ query: pollIntervalQuery, data: { interval: undefined } });
+ }
+
+ cache.writeQuery({
+ query: pageInfoQuery,
+ data: { pageInfo },
+ });
+
+ return {
+ availableCount: res.data.available_count,
+ environments: res.data.environments.map(mapNestedEnvironment),
+ reviewApp: {
+ ...convertObjectPropsToCamelCase(res.data.review_app),
+ __typename: 'ReviewApp',
+ },
+ canStopStaleEnvironments: res.data.can_stop_stale_environments,
+ stoppedCount: res.data.stopped_count,
+ __typename: 'LocalEnvironmentApp',
+ };
+ });
+ },
+ folder(_, { environment: { folderPath }, scope, search }) {
+ return axios.get(folderPath, { params: { scope, search, per_page: 3 } }).then((res) => ({
+ availableCount: res.data.available_count,
+ environments: res.data.environments.map(mapEnvironment),
+ stoppedCount: res.data.stopped_count,
+ __typename: 'LocalEnvironmentFolder',
+ }));
+ },
+ isLastDeployment(_, { environment }) {
+ return environment?.lastDeployment?.isLast;
+ },
+});
+
+export const baseMutations = {
+ stopEnvironmentREST(_, { environment }, { client }) {
+ client.writeQuery({
+ query: isEnvironmentStoppingQuery,
+ variables: { environment },
+ data: { isEnvironmentStopping: true },
+ });
+ return axios
+ .post(environment.stopPath)
+ .then(() => buildErrors())
+ .catch(() => {
+ client.writeQuery({
+ query: isEnvironmentStoppingQuery,
+ variables: { environment },
+ data: { isEnvironmentStopping: false },
+ });
+ return buildErrors([
+ s__('Environments|An error occurred while stopping the environment, please try again'),
+ ]);
+ });
+ },
+ deleteEnvironment(_, { environment: { deletePath } }) {
+ return axios
+ .delete(deletePath)
+ .then(() => buildErrors())
+ .catch(() =>
+ buildErrors([
+ s__(
+ 'Environments|An error occurred while deleting the environment. Check if the environment stopped; if not, stop it and try again.',
+ ),
+ ]),
+ );
+ },
+ rollbackEnvironment(_, { environment, isLastDeployment }) {
+ return axios
+ .post(environment?.retryUrl)
+ .then(() => buildErrors())
+ .catch(() => {
+ buildErrors([
+ isLastDeployment
+ ? s__(
+ 'Environments|An error occurred while re-deploying the environment, please try again',
+ )
+ : s__(
+ 'Environments|An error occurred while rolling back the environment, please try again',
+ ),
+ ]);
+ });
+ },
+ setEnvironmentToStop(_, { environment }, { client }) {
+ client.writeQuery({
+ query: environmentToStopQuery,
+ data: { environmentToStop: environment },
+ });
+ },
+ action(_, { action: { playPath } }) {
+ return axios
+ .post(playPath)
+ .then(() => buildErrors())
+ .catch(() => buildErrors([s__('Environments|An error occurred while making the request.')]));
+ },
+ setEnvironmentToDelete(_, { environment }, { client }) {
+ client.writeQuery({
+ query: environmentToDeleteQuery,
+ data: { environmentToDelete: environment },
+ });
+ },
+ setEnvironmentToRollback(_, { environment }, { client }) {
+ client.writeQuery({
+ query: environmentToRollbackQuery,
+ data: { environmentToRollback: environment },
+ });
+ },
+ setEnvironmentToChangeCanary(_, { environment, weight }, { client }) {
+ client.writeQuery({
+ query: environmentToChangeCanaryQuery,
+ data: { environmentToChangeCanary: environment, weight },
+ });
+ },
+ cancelAutoStop(_, { autoStopUrl }) {
+ return axios
+ .post(autoStopUrl)
+ .then(() => buildErrors())
+ .catch((err) =>
+ buildErrors([
+ err?.response?.data?.message ||
+ s__('Environments|An error occurred while canceling the auto stop, please try again'),
+ ]),
+ );
+ },
+};
diff --git a/app/assets/javascripts/environments/graphql/resolvers/flux.js b/app/assets/javascripts/environments/graphql/resolvers/flux.js
new file mode 100644
index 00000000000..f9ca35a3165
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/resolvers/flux.js
@@ -0,0 +1,115 @@
+import axios from '~/lib/utils/axios_utils';
+import {
+ HELM_RELEASES_RESOURCE_TYPE,
+ KUSTOMIZATIONS_RESOURCE_TYPE,
+} from '~/environments/constants';
+
+const helmReleasesApiVersion = 'helm.toolkit.fluxcd.io/v2beta1';
+const kustomizationsApiVersion = 'kustomize.toolkit.fluxcd.io/v1beta1';
+
+const handleClusterError = (err) => {
+ const error = err?.response?.data?.message ? new Error(err.response.data.message) : err;
+ throw error;
+};
+
+const buildFluxResourceUrl = ({
+ basePath,
+ namespace,
+ apiVersion,
+ resourceType,
+ environmentName = '',
+}) => {
+ return `${basePath}/apis/${apiVersion}/namespaces/${namespace}/${resourceType}/${environmentName}`;
+};
+
+const getFluxResourceStatus = (configuration, url) => {
+ const { headers } = configuration.baseOptions;
+ const withCredentials = true;
+
+ return axios
+ .get(url, { withCredentials, headers })
+ .then((res) => {
+ return res?.data?.status?.conditions || [];
+ })
+ .catch((err) => {
+ handleClusterError(err);
+ });
+};
+
+const getFluxResources = (configuration, url) => {
+ const { headers } = configuration.baseOptions;
+ const withCredentials = true;
+
+ return axios
+ .get(url, { withCredentials, headers })
+ .then((res) => {
+ const items = res?.data?.items || [];
+ const result = items.map((item) => {
+ return {
+ apiVersion: item.apiVersion,
+ metadata: {
+ name: item.metadata?.name,
+ namespace: item.metadata?.namespace,
+ },
+ };
+ });
+ return result || [];
+ })
+ .catch((err) => {
+ const error = err?.response?.data?.reason || err;
+ throw new Error(error);
+ });
+};
+
+export default {
+ fluxKustomizationStatus(_, { configuration, namespace, environmentName, fluxResourcePath = '' }) {
+ let url;
+
+ if (fluxResourcePath) {
+ url = `${configuration.basePath}/apis/${fluxResourcePath}`;
+ } else {
+ url = buildFluxResourceUrl({
+ basePath: configuration.basePath,
+ resourceType: KUSTOMIZATIONS_RESOURCE_TYPE,
+ apiVersion: kustomizationsApiVersion,
+ namespace,
+ environmentName,
+ });
+ }
+ return getFluxResourceStatus(configuration, url);
+ },
+ fluxHelmReleaseStatus(_, { configuration, namespace, environmentName, fluxResourcePath }) {
+ let url;
+
+ if (fluxResourcePath) {
+ url = `${configuration.basePath}/apis/${fluxResourcePath}`;
+ } else {
+ url = buildFluxResourceUrl({
+ basePath: configuration.basePath,
+ resourceType: HELM_RELEASES_RESOURCE_TYPE,
+ apiVersion: helmReleasesApiVersion,
+ namespace,
+ environmentName,
+ });
+ }
+ return getFluxResourceStatus(configuration, url);
+ },
+ fluxKustomizations(_, { configuration, namespace }) {
+ const url = buildFluxResourceUrl({
+ basePath: configuration.basePath,
+ resourceType: KUSTOMIZATIONS_RESOURCE_TYPE,
+ apiVersion: kustomizationsApiVersion,
+ namespace,
+ });
+ return getFluxResources(configuration, url);
+ },
+ fluxHelmReleases(_, { configuration, namespace }) {
+ const url = buildFluxResourceUrl({
+ basePath: configuration.basePath,
+ resourceType: HELM_RELEASES_RESOURCE_TYPE,
+ apiVersion: helmReleasesApiVersion,
+ namespace,
+ });
+ return getFluxResources(configuration, url);
+ },
+};
diff --git a/app/assets/javascripts/environments/graphql/resolvers/kubernetes.js b/app/assets/javascripts/environments/graphql/resolvers/kubernetes.js
new file mode 100644
index 00000000000..9ab65d0bb7f
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/resolvers/kubernetes.js
@@ -0,0 +1,155 @@
+import { CoreV1Api, Configuration, AppsV1Api, BatchV1Api } from '@gitlab/cluster-client';
+import { humanizeClusterErrors } from '../../helpers/k8s_integration_helper';
+
+const mapWorkloadItems = (items, kind) => {
+ return items.map((item) => {
+ const updatedItem = {
+ status: {},
+ spec: {},
+ };
+
+ switch (kind) {
+ case 'DeploymentList':
+ updatedItem.status.conditions = item.status.conditions || [];
+ break;
+ case 'DaemonSetList':
+ updatedItem.status = {
+ numberMisscheduled: item.status.numberMisscheduled || 0,
+ numberReady: item.status.numberReady || 0,
+ desiredNumberScheduled: item.status.desiredNumberScheduled || 0,
+ };
+ break;
+ case 'StatefulSetList':
+ case 'ReplicaSetList':
+ updatedItem.status.readyReplicas = item.status.readyReplicas || 0;
+ updatedItem.spec.replicas = item.spec.replicas || 0;
+ break;
+ case 'JobList':
+ updatedItem.status.failed = item.status.failed || 0;
+ updatedItem.status.succeeded = item.status.succeeded || 0;
+ updatedItem.spec.completions = item.spec.completions || 0;
+ break;
+ case 'CronJobList':
+ updatedItem.status.active = item.status.active || 0;
+ updatedItem.status.lastScheduleTime = item.status.lastScheduleTime || '';
+ updatedItem.spec.suspend = item.spec.suspend || 0;
+ break;
+ default:
+ updatedItem.status = item?.status;
+ updatedItem.spec = item?.spec;
+ break;
+ }
+
+ return updatedItem;
+ });
+};
+
+const handleClusterError = (err) => {
+ const error = err?.response?.data?.message ? new Error(err.response.data.message) : err;
+ throw error;
+};
+
+export default {
+ k8sPods(_, { configuration, namespace }) {
+ const coreV1Api = new CoreV1Api(new Configuration(configuration));
+ const podsApi = namespace
+ ? coreV1Api.listCoreV1NamespacedPod(namespace)
+ : coreV1Api.listCoreV1PodForAllNamespaces();
+
+ return podsApi
+ .then((res) => res?.data?.items || [])
+ .catch((err) => {
+ handleClusterError(err);
+ });
+ },
+ k8sServices(_, { configuration }) {
+ const coreV1Api = new CoreV1Api(new Configuration(configuration));
+ return coreV1Api
+ .listCoreV1ServiceForAllNamespaces()
+ .then((res) => {
+ const items = res?.data?.items || [];
+ return items.map((item) => {
+ const { type, clusterIP, externalIP, ports } = item.spec;
+ return {
+ metadata: item.metadata,
+ spec: {
+ type,
+ clusterIP: clusterIP || '-',
+ externalIP: externalIP || '-',
+ ports,
+ },
+ };
+ });
+ })
+ .catch((err) => {
+ handleClusterError(err);
+ });
+ },
+ k8sWorkloads(_, { configuration, namespace }) {
+ const appsV1api = new AppsV1Api(configuration);
+ const batchV1api = new BatchV1Api(configuration);
+
+ let promises;
+
+ if (namespace) {
+ promises = [
+ appsV1api.listAppsV1NamespacedDeployment(namespace),
+ appsV1api.listAppsV1NamespacedDaemonSet(namespace),
+ appsV1api.listAppsV1NamespacedStatefulSet(namespace),
+ appsV1api.listAppsV1NamespacedReplicaSet(namespace),
+ batchV1api.listBatchV1NamespacedJob(namespace),
+ batchV1api.listBatchV1NamespacedCronJob(namespace),
+ ];
+ } else {
+ promises = [
+ appsV1api.listAppsV1DeploymentForAllNamespaces(),
+ appsV1api.listAppsV1DaemonSetForAllNamespaces(),
+ appsV1api.listAppsV1StatefulSetForAllNamespaces(),
+ appsV1api.listAppsV1ReplicaSetForAllNamespaces(),
+ batchV1api.listBatchV1JobForAllNamespaces(),
+ batchV1api.listBatchV1CronJobForAllNamespaces(),
+ ];
+ }
+
+ const summaryList = {
+ DeploymentList: [],
+ DaemonSetList: [],
+ StatefulSetList: [],
+ ReplicaSetList: [],
+ JobList: [],
+ CronJobList: [],
+ };
+
+ return Promise.allSettled(promises).then((results) => {
+ if (results.every((res) => res.status === 'rejected')) {
+ const error = results[0].reason;
+ const errorMessage = error?.response?.data?.message ?? error;
+ throw new Error(errorMessage);
+ }
+ for (const promiseResult of results) {
+ if (promiseResult.status === 'fulfilled' && promiseResult?.value?.data) {
+ const { kind, items } = promiseResult.value.data;
+
+ if (items?.length > 0) {
+ summaryList[kind] = mapWorkloadItems(items, kind);
+ }
+ }
+ }
+
+ return summaryList;
+ });
+ },
+ k8sNamespaces(_, { configuration }) {
+ const coreV1Api = new CoreV1Api(new Configuration(configuration));
+ const namespacesApi = coreV1Api.listCoreV1Namespace();
+
+ return namespacesApi
+ .then((res) => {
+ return res?.data?.items || [];
+ })
+ .catch((err) => {
+ const error = err?.response?.data?.reason || err;
+ throw new Error(humanizeClusterErrors(error));
+ });
+ },
+};
diff --git a/app/assets/javascripts/environments/graphql/typedefs.graphql b/app/assets/javascripts/environments/graphql/typedefs.graphql
index e2c22dda554..41f165ad1da 100644
--- a/app/assets/javascripts/environments/graphql/typedefs.graphql
+++ b/app/assets/javascripts/environments/graphql/typedefs.graphql
@@ -167,6 +167,11 @@ type LocalK8sNamespaces {
metadata: k8sNamespaceMetadata
}
+type LocalFluxResourceStatus {
+ status: String
+ type: String
+}
+
extend type Query {
environmentApp(page: Int, scope: String): LocalEnvironmentApp
folder(environment: NestedLocalEnvironmentInput): LocalEnvironmentFolder
@@ -179,6 +184,16 @@ extend type Query {
k8sPods(configuration: LocalConfiguration, namespace: String): [LocalK8sPods]
k8sServices(configuration: LocalConfiguration): [LocalK8sServices]
k8sWorkloads(configuration: LocalConfiguration, namespace: String): LocalK8sWorkloads
+ fluxKustomizationStatus(
+ configuration: LocalConfiguration
+ namespace: String
+ environmentName: String
+ ): LocalFluxResourceStatus
+ fluxHelmReleaseStatus(
+ configuration: LocalConfiguration
+ namespace: String
+ environmentName: String
+ ): LocalFluxResourceStatus
}
extend type Mutation {
diff --git a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js
index e49f1451759..164a2d98e90 100644
--- a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js
+++ b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js
@@ -142,7 +142,7 @@ export function getCronJobsStatuses(items) {
}
export function humanizeClusterErrors(reason) {
- const errorReason = reason.toLowerCase();
+ const errorReason = String(reason).toLowerCase();
const errorMessage = CLUSTER_AGENT_ERROR_MESSAGES[errorReason];
return errorMessage || CLUSTER_AGENT_ERROR_MESSAGES.other;
}
diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js
index 3f746bc5383..0e0ea018618 100644
--- a/app/assets/javascripts/environments/index.js
+++ b/app/assets/javascripts/environments/index.js
@@ -1,11 +1,13 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { GlToast } from '@gitlab/ui';
import { removeLastSlashInUrlPath } from '~/lib/utils/url_utility';
import { parseBoolean } from '../lib/utils/common_utils';
import { apolloProvider } from './graphql/client';
import EnvironmentsApp from './components/environments_app.vue';
Vue.use(VueApollo);
+Vue.use(GlToast);
export default (el) => {
if (el) {
diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue
index bd8a7257d0c..1821aa073bc 100644
--- a/app/assets/javascripts/error_tracking/components/error_details.vue
+++ b/app/assets/javascripts/error_tracking/components/error_details.vue
@@ -8,6 +8,7 @@ import {
GlSprintf,
GlDisclosureDropdown,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import { createAlert, VARIANT_WARNING } from '~/alert';
import { __, sprintf, n__ } from '~/locale';
diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
index 0c9a98f3b33..9b30ec4afbb 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -16,6 +16,7 @@ import {
GlPagination,
} from '@gitlab/ui';
import { isEmpty } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import { helpPagePath } from '~/helpers/help_page_helper';
import AccessorUtils from '~/lib/utils/accessor';
diff --git a/app/assets/javascripts/error_tracking/components/stacktrace.vue b/app/assets/javascripts/error_tracking/components/stacktrace.vue
index 54b9d37be73..0e34e7ebda7 100644
--- a/app/assets/javascripts/error_tracking/components/stacktrace.vue
+++ b/app/assets/javascripts/error_tracking/components/stacktrace.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import StackTraceEntry from './stacktrace_entry.vue';
diff --git a/app/assets/javascripts/error_tracking/store/index.js b/app/assets/javascripts/error_tracking/store/index.js
index 1d8f1998583..b62618ca02f 100644
--- a/app/assets/javascripts/error_tracking/store/index.js
+++ b/app/assets/javascripts/error_tracking/store/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue
index 3bc91a2adbf..b59bf88a140 100644
--- a/app/assets/javascripts/error_tracking_settings/components/app.vue
+++ b/app/assets/javascripts/error_tracking_settings/components/app.vue
@@ -10,6 +10,7 @@ import {
GlLink,
GlSprintf,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
index da942dbd0ae..2f4d7c48cf2 100644
--- a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
+++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
@@ -1,5 +1,6 @@
<script>
import { GlFormInput, GlIcon, GlButton } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
export default {
diff --git a/app/assets/javascripts/error_tracking_settings/store/index.js b/app/assets/javascripts/error_tracking_settings/store/index.js
index 1cd6d119657..2362cfb741f 100644
--- a/app/assets/javascripts/error_tracking_settings/store/index.js
+++ b/app/assets/javascripts/error_tracking_settings/store/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
diff --git a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
index 2bdc95e798c..63f61df7d01 100644
--- a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
+++ b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
@@ -1,5 +1,6 @@
<script>
import { GlAlert, GlLoadingIcon, GlToggle } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
import { sprintf, __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
diff --git a/app/assets/javascripts/feature_flags/components/empty_state.vue b/app/assets/javascripts/feature_flags/components/empty_state.vue
index a66215cdae6..60aeb297700 100644
--- a/app/assets/javascripts/feature_flags/components/empty_state.vue
+++ b/app/assets/javascripts/feature_flags/components/empty_state.vue
@@ -81,6 +81,7 @@ export default {
v-else-if="emptyState"
:title="emptyTitle"
:svg-path="errorStateSvgPath"
+ :svg-height="150"
data-testid="empty-state"
>
<template #description>
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue
index 34e0b94af3b..daaeb5f8e85 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue
@@ -1,6 +1,7 @@
<script>
import { GlAlert, GlBadge, GlButton, GlModalDirective, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
import { buildUrlWithCurrentLocation, historyPushState } from '~/lib/utils/common_utils';
@@ -154,7 +155,6 @@ export default {
variant="confirm"
category="tertiary"
class="gl-mb-3"
- data-testid="ff-new-list-button"
>
{{ s__('FeatureFlags|View user lists') }}
</gl-button>
@@ -183,10 +183,7 @@ export default {
class="gl-display-flex gl-align-items-baseline gl-flex-direction-row gl-justify-content-space-between gl-mt-6"
>
<div class="gl-display-flex gl-align-items-center">
- <h2
- data-testid="feature-flags-tab-title"
- class="page-title gl-font-size-h-display gl-my-0"
- >
+ <h2 class="page-title gl-font-size-h-display gl-my-0">
{{ s__('FeatureFlags|Feature flags') }}
</h2>
<gl-badge v-if="count" class="gl-ml-4">{{ count }}</gl-badge>
@@ -240,7 +237,6 @@ export default {
'FeatureFlags|Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality.',
)
"
- data-testid="feature-flags-tab"
@dismissAlert="clearAlert"
>
<feature-flags-table :feature-flags="featureFlags" @toggle-flag="toggleFeatureFlag" />
diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue
index 35abcc3d561..7c32d41a2bb 100644
--- a/app/assets/javascripts/feature_flags/components/form.vue
+++ b/app/assets/javascripts/feature_flags/components/form.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton } from '@gitlab/ui';
import { memoize, cloneDeep, isNumber, uniqueId } from 'lodash';
diff --git a/app/assets/javascripts/feature_flags/components/new_feature_flag.vue b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue
index bc05e88e643..fa9c9d40c91 100644
--- a/app/assets/javascripts/feature_flags/components/new_feature_flag.vue
+++ b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue
@@ -1,5 +1,6 @@
<script>
import { GlAlert } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { ROLLOUT_STRATEGY_ALL_USERS } from '../constants';
diff --git a/app/assets/javascripts/feature_flags/components/strategies/default.vue b/app/assets/javascripts/feature_flags/components/strategies/default.vue
index 04190d7bfda..73c32e52a56 100644
--- a/app/assets/javascripts/feature_flags/components/strategies/default.vue
+++ b/app/assets/javascripts/feature_flags/components/strategies/default.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
export default {
mounted() {
diff --git a/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue b/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue
index 53745d3b021..68170bafeeb 100644
--- a/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue
+++ b/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue
@@ -1,6 +1,7 @@
<script>
import { GlCollapsibleListbox } from '@gitlab/ui';
import { debounce } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { createNamespacedHelpers } from 'vuex';
import { s__ } from '~/locale';
import ParameterFormGroup from './parameter_form_group.vue';
diff --git a/app/assets/javascripts/feature_flags/components/strategy.vue b/app/assets/javascripts/feature_flags/components/strategy.vue
index 6c8a2d90209..4594719a247 100644
--- a/app/assets/javascripts/feature_flags/components/strategy.vue
+++ b/app/assets/javascripts/feature_flags/components/strategy.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlAlert, GlButton, GlFormSelect, GlFormGroup, GlIcon, GlLink, GlToken } from '@gitlab/ui';
import { isNumber } from 'lodash';
diff --git a/app/assets/javascripts/feature_flags/edit.js b/app/assets/javascripts/feature_flags/edit.js
index 55dad87ea5b..6aca9d1d124 100644
--- a/app/assets/javascripts/feature_flags/edit.js
+++ b/app/assets/javascripts/feature_flags/edit.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import EditFeatureFlag from './components/edit_feature_flag.vue';
import createStore from './store/edit';
diff --git a/app/assets/javascripts/feature_flags/index.js b/app/assets/javascripts/feature_flags/index.js
index 5c0d9cb8624..b0f57006b7f 100644
--- a/app/assets/javascripts/feature_flags/index.js
+++ b/app/assets/javascripts/feature_flags/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import csrf from '~/lib/utils/csrf';
import FeatureFlagsComponent from './components/feature_flags.vue';
diff --git a/app/assets/javascripts/feature_flags/new.js b/app/assets/javascripts/feature_flags/new.js
index f763b12fedb..c36fd7ab591 100644
--- a/app/assets/javascripts/feature_flags/new.js
+++ b/app/assets/javascripts/feature_flags/new.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import { parseBoolean } from '~/lib/utils/common_utils';
import NewFeatureFlag from './components/new_feature_flag.vue';
diff --git a/app/assets/javascripts/feature_flags/store/edit/index.js b/app/assets/javascripts/feature_flags/store/edit/index.js
index 16b8a5ae970..6550c8a922c 100644
--- a/app/assets/javascripts/feature_flags/store/edit/index.js
+++ b/app/assets/javascripts/feature_flags/store/edit/index.js
@@ -1,3 +1,4 @@
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import userLists from '../gitlab_user_list';
import * as actions from './actions';
diff --git a/app/assets/javascripts/feature_flags/store/index/index.js b/app/assets/javascripts/feature_flags/store/index/index.js
index 96ccb35fa21..183305d1147 100644
--- a/app/assets/javascripts/feature_flags/store/index/index.js
+++ b/app/assets/javascripts/feature_flags/store/index/index.js
@@ -1,3 +1,4 @@
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import mutations from './mutations';
diff --git a/app/assets/javascripts/feature_flags/store/new/index.js b/app/assets/javascripts/feature_flags/store/new/index.js
index 16b8a5ae970..6550c8a922c 100644
--- a/app/assets/javascripts/feature_flags/store/new/index.js
+++ b/app/assets/javascripts/feature_flags/store/new/index.js
@@ -1,3 +1,4 @@
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import userLists from '../gitlab_user_list';
import * as actions from './actions';
diff --git a/app/assets/javascripts/forks/components/forks_button.vue b/app/assets/javascripts/forks/components/forks_button.vue
new file mode 100644
index 00000000000..40cf74ff4cc
--- /dev/null
+++ b/app/assets/javascripts/forks/components/forks_button.vue
@@ -0,0 +1,86 @@
+<script>
+import { GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ components: {
+ GlButtonGroup,
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: {
+ forksCount: {
+ default: 0,
+ },
+ projectFullPath: {
+ default: '',
+ },
+ projectForksUrl: {
+ default: '',
+ },
+ userForkUrl: {
+ default: '',
+ },
+ newForkUrl: {
+ default: '',
+ },
+ canReadCode: {
+ default: false,
+ },
+ canCreateFork: {
+ default: false,
+ },
+ canForkProject: {
+ default: false,
+ },
+ },
+ computed: {
+ forkButtonUrl() {
+ return this.userForkUrl || this.newForkUrl;
+ },
+ userHasForkAccess() {
+ return Boolean(this.userForkUrl) && this.canReadCode;
+ },
+ userCanFork() {
+ return this.canReadCode && this.canCreateFork && this.canForkProject;
+ },
+ forkButtonEnabled() {
+ return this.userHasForkAccess || this.userCanFork;
+ },
+ forkButtonTooltip() {
+ if (!this.canForkProject) {
+ return s__("ProjectOverview|You don't have permission to fork this project");
+ }
+
+ if (!this.canCreateFork) {
+ return s__('ProjectOverview|You have reached your project limit');
+ }
+
+ if (this.userHasForkAccess) {
+ return s__('ProjectOverview|Go to your fork');
+ }
+
+ return s__('ProjectOverview|Create new fork');
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button-group :vertical="false">
+ <gl-button
+ v-gl-tooltip
+ data-testid="fork-button"
+ :disabled="!forkButtonEnabled"
+ :href="forkButtonUrl"
+ icon="fork"
+ :title="forkButtonTooltip"
+ >{{ s__('ProjectOverview|Forks') }}</gl-button
+ >
+ <gl-button data-testid="forks-count" :disabled="!canReadCode" :href="projectForksUrl">{{
+ forksCount
+ }}</gl-button>
+ </gl-button-group>
+</template>
diff --git a/app/assets/javascripts/forks/init_forks_button.js b/app/assets/javascripts/forks/init_forks_button.js
new file mode 100644
index 00000000000..b899d1c51db
--- /dev/null
+++ b/app/assets/javascripts/forks/init_forks_button.js
@@ -0,0 +1,41 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import ForksButton from './components/forks_button.vue';
+
+const initForksButton = () => {
+ const el = document.getElementById('js-forks-button');
+
+ if (!el) {
+ return false;
+ }
+
+ const {
+ forksCount,
+ projectFullPath,
+ projectForksUrl,
+ userForkUrl,
+ newForkUrl,
+ canReadCode,
+ canCreateFork,
+ canForkProject,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ provide: {
+ forksCount,
+ projectFullPath,
+ projectForksUrl,
+ userForkUrl,
+ newForkUrl,
+ canReadCode: parseBoolean(canReadCode),
+ canCreateFork: parseBoolean(canCreateFork),
+ canForkProject: parseBoolean(canForkProject),
+ },
+ render(createElement) {
+ return createElement(ForksButton);
+ },
+ });
+};
+
+export default initForksButton;
diff --git a/app/assets/javascripts/frequent_items/store/index.js b/app/assets/javascripts/frequent_items/store/index.js
index 1faacff84e5..3e5c9618805 100644
--- a/app/assets/javascripts/frequent_items/store/index.js
+++ b/app/assets/javascripts/frequent_items/store/index.js
@@ -1,3 +1,4 @@
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import { FREQUENT_ITEMS_DROPDOWNS } from '../constants';
import * as actions from './actions';
diff --git a/app/assets/javascripts/google_cloud/aiml/panel.vue b/app/assets/javascripts/google_cloud/aiml/panel.vue
index f591c47ac40..751de20b16b 100644
--- a/app/assets/javascripts/google_cloud/aiml/panel.vue
+++ b/app/assets/javascripts/google_cloud/aiml/panel.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import GoogleCloudMenu from '../components/google_cloud_menu.vue';
import IncubationBanner from '../components/incubation_banner.vue';
diff --git a/app/assets/javascripts/google_cloud/configuration/panel.vue b/app/assets/javascripts/google_cloud/configuration/panel.vue
index ee046eb1988..34298e3dbf5 100644
--- a/app/assets/javascripts/google_cloud/configuration/panel.vue
+++ b/app/assets/javascripts/google_cloud/configuration/panel.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import GcpRegionsList from '../gcp_regions/list.vue';
import GoogleCloudMenu from '../components/google_cloud_menu.vue';
diff --git a/app/assets/javascripts/google_cloud/databases/panel.vue b/app/assets/javascripts/google_cloud/databases/panel.vue
index 8b91c508871..95e18af7038 100644
--- a/app/assets/javascripts/google_cloud/databases/panel.vue
+++ b/app/assets/javascripts/google_cloud/databases/panel.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import GoogleCloudMenu from '../components/google_cloud_menu.vue';
import IncubationBanner from '../components/incubation_banner.vue';
diff --git a/app/assets/javascripts/google_cloud/deployments/panel.vue b/app/assets/javascripts/google_cloud/deployments/panel.vue
index 89db132ad5e..8a40351002b 100644
--- a/app/assets/javascripts/google_cloud/deployments/panel.vue
+++ b/app/assets/javascripts/google_cloud/deployments/panel.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import GoogleCloudMenu from '../components/google_cloud_menu.vue';
import IncubationBanner from '../components/incubation_banner.vue';
diff --git a/app/assets/javascripts/google_cloud/gcp_regions/form.vue b/app/assets/javascripts/google_cloud/gcp_regions/form.vue
index 23011e5a5b0..86850817a74 100644
--- a/app/assets/javascripts/google_cloud/gcp_regions/form.vue
+++ b/app/assets/javascripts/google_cloud/gcp_regions/form.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton, GlFormGroup, GlFormSelect } from '@gitlab/ui';
import { __, s__ } from '~/locale';
diff --git a/app/assets/javascripts/google_cloud/gcp_regions/list.vue b/app/assets/javascripts/google_cloud/gcp_regions/list.vue
index 5d403d5cd65..2e76b32dcc4 100644
--- a/app/assets/javascripts/google_cloud/gcp_regions/list.vue
+++ b/app/assets/javascripts/google_cloud/gcp_regions/list.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/google_cloud/service_accounts/form.vue b/app/assets/javascripts/google_cloud/service_accounts/form.vue
index faec94e735b..2ab4ead5d14 100644
--- a/app/assets/javascripts/google_cloud/service_accounts/form.vue
+++ b/app/assets/javascripts/google_cloud/service_accounts/form.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton, GlFormCheckbox, GlFormGroup, GlFormSelect } from '@gitlab/ui';
import { s__ } from '~/locale';
diff --git a/app/assets/javascripts/google_cloud/service_accounts/list.vue b/app/assets/javascripts/google_cloud/service_accounts/list.vue
index 4ac788aafbe..635b185d207 100644
--- a/app/assets/javascripts/google_cloud/service_accounts/list.vue
+++ b/app/assets/javascripts/google_cloud/service_accounts/list.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf, GlTable } from '@gitlab/ui';
import { setUrlParams, DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility';
diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js
index 08733bbe620..eb807bc7540 100644
--- a/app/assets/javascripts/graphql_shared/issuable_client.js
+++ b/app/assets/javascripts/graphql_shared/issuable_client.js
@@ -203,6 +203,16 @@ export const config = {
};
},
},
+ Query: {
+ fields: {
+ boardList: {
+ keyArgs: ['id'],
+ },
+ epicBoardList: {
+ keyArgs: ['id'],
+ },
+ },
+ },
}
: {}),
},
diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json
index 7651bbba71c..e6c0b86d9a6 100644
--- a/app/assets/javascripts/graphql_shared/possible_types.json
+++ b/app/assets/javascripts/graphql_shared/possible_types.json
@@ -99,6 +99,7 @@
"DependencyProxyBlobRegistry",
"DependencyProxyManifestRegistry",
"DesignManagementRepositoryRegistry",
+ "GroupWikiRepositoryRegistry",
"JobArtifactRegistry",
"LfsObjectRegistry",
"MergeRequestDiffRegistry",
@@ -138,6 +139,7 @@
"WorkItem"
],
"User": [
+ "AutocompletedUser",
"MergeRequestAssignee",
"MergeRequestAuthor",
"MergeRequestParticipant",
@@ -179,6 +181,7 @@
"WorkItemWidgetHierarchy",
"WorkItemWidgetIteration",
"WorkItemWidgetLabels",
+ "WorkItemWidgetLinkedItems",
"WorkItemWidgetMilestone",
"WorkItemWidgetNotes",
"WorkItemWidgetNotifications",
diff --git a/app/assets/javascripts/graphql_shared/queries/users_autocomplete.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_autocomplete.query.graphql
new file mode 100644
index 00000000000..39efd5eddef
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/queries/users_autocomplete.query.graphql
@@ -0,0 +1,20 @@
+query usersAutocomplete($fullPath: ID!, $search: String, $isProject: Boolean = false) {
+ group(fullPath: $fullPath) @skip(if: $isProject) {
+ id
+ autocompleteUsers(search: $search) {
+ id
+ avatarUrl
+ name
+ username
+ }
+ }
+ project(fullPath: $fullPath) @include(if: $isProject) {
+ id
+ autocompleteUsers(search: $search) {
+ id
+ avatarUrl
+ name
+ username
+ }
+ }
+}
diff --git a/app/assets/javascripts/group_settings/components/shared_runners_form.vue b/app/assets/javascripts/group_settings/components/shared_runners_form.vue
index a4ec48ffd2f..d396169c0a3 100644
--- a/app/assets/javascripts/group_settings/components/shared_runners_form.vue
+++ b/app/assets/javascripts/group_settings/components/shared_runners_form.vue
@@ -1,5 +1,5 @@
<script>
-import { GlToggle, GlAlert } from '@gitlab/ui';
+import { GlAlert, GlLink, GlSprintf, GlToggle } from '@gitlab/ui';
import { sprintf } from '~/locale';
import { updateGroup } from '~/api/groups_api';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
@@ -14,19 +14,29 @@ import {
export default {
components: {
- GlToggle,
GlAlert,
+ GlLink,
+ GlSprintf,
+ GlToggle,
+ },
+ inject: {
+ groupId: {},
+ groupName: {},
+ groupIsEmpty: {},
+ sharedRunnersSetting: {},
+
+ runnerEnabledValue: {},
+ runnerDisabledValue: {},
+ runnerAllowOverrideValue: {},
+
+ // Parent group, only present in sub-groups
+
+ parentSharedRunnersSetting: { default: null },
+
+ // Available when user can admin parent
+ parentName: { default: null },
+ parentSettingsPath: { default: null },
},
- inject: [
- 'groupId',
- 'groupName',
- 'groupIsEmpty',
- 'sharedRunnersSetting',
- 'parentSharedRunnersSetting',
- 'runnerEnabledValue',
- 'runnerDisabledValue',
- 'runnerAllowOverrideValue',
- ],
data() {
return {
isLoading: false,
@@ -48,6 +58,9 @@ export default {
overrideToggleValue() {
return this.value === this.runnerAllowOverrideValue;
},
+ isParentAvailable() {
+ return this.parentSettingsPath && this.parentName;
+ },
},
methods: {
async onSharedRunnersToggle(enabled) {
@@ -109,26 +122,28 @@ export default {
<gl-alert v-if="error" variant="danger" :dismissible="false" class="gl-mb-5">
{{ error }}
</gl-alert>
-
- <gl-alert
- v-if="isSharedRunnersToggleDisabled"
- variant="warning"
- :dismissible="false"
- class="gl-mb-5"
- >
- {{ __('Shared runners are disabled for the parent group') }}
- </gl-alert>
-
<section class="gl-mb-5">
<gl-toggle
:value="sharedRunnersToggleValue"
:is-loading="isLoading"
:disabled="isSharedRunnersToggleDisabled"
:label="__('Enable shared runners for this group')"
- :help="__('Enable shared runners for all projects and subgroups in this group.')"
+ :description="__('Enable shared runners for all projects and subgroups in this group.')"
data-testid="shared-runners-toggle"
@change="onSharedRunnersToggle"
- />
+ >
+ <template v-if="isSharedRunnersToggleDisabled" #help>
+ {{ s__('Runners|Shared runners are disabled.') }}
+ <gl-sprintf
+ v-if="isParentAvailable"
+ :message="s__('Runners|Go to %{groupLink} to enable them.')"
+ >
+ <template #groupLink>
+ <gl-link :href="parentSettingsPath">{{ parentName }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-toggle>
</section>
<section class="gl-mb-5">
@@ -137,10 +152,24 @@ export default {
:is-loading="isLoading"
:disabled="isOverrideToggleDisabled"
:label="__('Allow projects and subgroups to override the group setting')"
- :help="__('Allows projects or subgroups in this group to override the global setting.')"
+ :description="
+ __('Allows projects or subgroups in this group to override the global setting.')
+ "
data-testid="override-runners-toggle"
@change="onOverrideToggle"
- />
+ >
+ <template v-if="isSharedRunnersToggleDisabled" #help>
+ {{ s__('Runners|Shared runners are disabled.') }}
+ <gl-sprintf
+ v-if="isParentAvailable"
+ :message="s__('Runners|Go to %{groupLink} to enable them.')"
+ >
+ <template #groupLink>
+ <gl-link :href="parentSettingsPath">{{ parentName }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-toggle>
</section>
</div>
</template>
diff --git a/app/assets/javascripts/group_settings/mount_shared_runners.js b/app/assets/javascripts/group_settings/mount_shared_runners.js
index 0767330cd54..334192a6f87 100644
--- a/app/assets/javascripts/group_settings/mount_shared_runners.js
+++ b/app/assets/javascripts/group_settings/mount_shared_runners.js
@@ -10,6 +10,8 @@ export default (containerId = 'update-shared-runners-form') => {
groupName,
groupIsEmpty,
sharedRunnersSetting,
+ parentName,
+ parentSettingsPath,
parentSharedRunnersSetting,
runnerEnabledValue,
runnerDisabledValue,
@@ -23,10 +25,14 @@ export default (containerId = 'update-shared-runners-form') => {
groupName,
groupIsEmpty: parseBoolean(groupIsEmpty),
sharedRunnersSetting,
- parentSharedRunnersSetting,
+
runnerEnabledValue,
runnerDisabledValue,
runnerAllowOverrideValue,
+
+ parentName,
+ parentSettingsPath,
+ parentSharedRunnersSetting,
},
render(createElement) {
return createElement(UpdateSharedRunnersForm);
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
index c6fe16b13b5..3440bd87e6b 100644
--- a/app/assets/javascripts/groups/components/app.vue
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -28,11 +28,6 @@ export default {
required: false,
default: '',
},
- containerId: {
- type: String,
- required: false,
- default: '',
- },
store: {
type: Object,
required: true,
@@ -94,10 +89,6 @@ export default {
},
mounted() {
this.fetchAllGroups();
-
- if (this.containerId) {
- this.containerEl = document.getElementById(this.containerId);
- }
},
beforeDestroy() {
eventHub.$off(`${this.action}fetchPage`, this.fetchPage);
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 8d202194de7..af1af86d0c4 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -21,7 +21,7 @@ import {
VISIBILITY_TYPE_ICON,
GROUP_VISIBILITY_TYPE,
} from '~/visibility_level/constants';
-import { ITEM_TYPE } from '../constants';
+import { ITEM_TYPE, ACTIVE_TAB_SHARED } from '../constants';
import eventHub from '../event_hub';
@@ -50,7 +50,11 @@ export default {
ItemActions,
ItemStats,
},
- inject: ['currentGroupVisibility'],
+ inject: {
+ currentGroupVisibility: {
+ default: '',
+ },
+ },
props: {
parentGroup: {
type: Object,
@@ -114,7 +118,7 @@ export default {
},
shouldShowVisibilityWarning() {
return (
- this.action === 'shared' &&
+ this.action === ACTIVE_TAB_SHARED &&
VISIBILITY_LEVELS_STRING_TO_INTEGER[this.group.visibility] >
VISIBILITY_LEVELS_STRING_TO_INTEGER[this.currentGroupVisibility]
);
@@ -201,7 +205,7 @@ export default {
data-testid="group-name"
:href="group.relativePath"
:title="group.fullName"
- class="no-expand gl-mr-3 gl-text-gray-900!"
+ class="no-expand gl-mr-3 gl-text-gray-900! gl-word-break-word"
:itemprop="microdata.nameItemprop"
>
<!-- ending bracket must be by closing tag to prevent -->
diff --git a/app/assets/javascripts/groups/components/group_name_and_path.vue b/app/assets/javascripts/groups/components/group_name_and_path.vue
index 8d193310a98..fd633df3022 100644
--- a/app/assets/javascripts/groups/components/group_name_and_path.vue
+++ b/app/assets/javascripts/groups/components/group_name_and_path.vue
@@ -293,7 +293,7 @@ export default {
required
:name="fields.name.name"
:placeholder="$options.i18n.inputs.name.placeholder"
- data-qa-selector="group_name_field"
+ data-testid="group-name-field"
:size="$options.inputSize"
:state="nameFeedbackState"
@invalid="handleInvalidName"
@@ -376,7 +376,7 @@ export default {
:state="pathFeedbackState"
:size="pathInputSize"
required
- data-qa-selector="group_path_field"
+ data-testid="group-path-field"
:data-bind-in="mattermostEnabled ? $options.mattermostDataBindName : null"
@input="handlePathInput"
@invalid="handleInvalidPath"
diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue
index 5075be62214..969b41f4755 100644
--- a/app/assets/javascripts/groups/components/groups.vue
+++ b/app/assets/javascripts/groups/components/groups.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
import { getParameterByName } from '~/lib/utils/url_utility';
diff --git a/app/assets/javascripts/groups/groups_list.js b/app/assets/javascripts/groups/groups_list.js
deleted file mode 100644
index 866dd7a61ff..00000000000
--- a/app/assets/javascripts/groups/groups_list.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import FilterableList from '~/filterable_list';
-
-/**
- * Makes search request for groups when user types a value in the search input.
- * Updates the html content of the page with the received one.
- */
-export default class GroupsList {
- constructor() {
- const form = document.querySelector('form#group-filter-form');
- const filter = document.querySelector('.js-groups-list-filter');
- const holder = document.querySelector('.js-groups-list-holder');
-
- if (form && filter && holder) {
- const list = new FilterableList(form, filter, holder);
- list.initSearch();
- }
- }
-}
diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js
index df2a23dc0f7..e71ff6d9107 100644
--- a/app/assets/javascripts/groups/index.js
+++ b/app/assets/javascripts/groups/index.js
@@ -7,92 +7,49 @@ import Translate from '../vue_shared/translate';
import GroupsApp from './components/app.vue';
import GroupFolderComponent from './components/group_folder.vue';
-import { GROUPS_LIST_HOLDER_CLASS, CONTENT_LIST_CLASS } from './constants';
import GroupFilterableList from './groups_filterable_list';
import GroupsService from './service/groups_service';
import GroupsStore from './store/groups_store';
Vue.use(Translate);
-export default (containerId = 'js-groups-tree', endpoint, action = '') => {
- const containerEl = document.getElementById(containerId);
- let dataEl;
+export default () => {
+ const el = document.getElementById('js-groups-tree');
// eslint-disable-next-line no-new
new UserCallout();
- // Don't do anything if element doesn't exist (No groups)
- // This is for when the user enters directly to the page via URL
- if (!containerEl) {
+ if (!el) {
return;
}
- const el = action ? containerEl.querySelector(GROUPS_LIST_HOLDER_CLASS) : containerEl;
-
- if (action) {
- dataEl = containerEl.querySelector(CONTENT_LIST_CLASS);
- }
-
Vue.component('GroupFolder', GroupFolderComponent);
Vue.component('GroupItem', GroupItemComponent);
Vue.use(GlToast);
+ const { dataset } = el;
+
// eslint-disable-next-line no-new
new Vue({
el,
components: {
GroupsApp,
},
- provide() {
- const {
- dataset: {
- newSubgroupPath,
- newProjectPath,
- newSubgroupIllustration,
- newProjectIllustration,
- emptyProjectsIllustration,
- emptySubgroupIllustration,
- canCreateSubgroups,
- canCreateProjects,
- currentGroupVisibility,
- },
- } = this.$options.el;
-
- return {
- newSubgroupPath,
- newProjectPath,
- newSubgroupIllustration,
- newProjectIllustration,
- emptyProjectsIllustration,
- emptySubgroupIllustration,
- canCreateSubgroups: parseBoolean(canCreateSubgroups),
- canCreateProjects: parseBoolean(canCreateProjects),
- currentGroupVisibility,
- };
- },
data() {
- const { dataset } = dataEl || this.$options.el;
const showSchemaMarkup = parseBoolean(dataset.showSchemaMarkup);
const renderEmptyState = parseBoolean(dataset.renderEmptyState);
- const service = new GroupsService(endpoint || dataset.endpoint);
+ const service = new GroupsService(dataset.endpoint);
const store = new GroupsStore({ hideProjects: true, showSchemaMarkup });
return {
- action,
store,
service,
renderEmptyState,
loading: true,
- containerId,
};
},
beforeMount() {
- if (this.action) {
- return;
- }
-
- const { dataset } = dataEl || this.$options.el;
let groupFilterList = null;
const form = document.querySelector(dataset.formSel);
const filter = document.querySelector(dataset.filterSel);
@@ -102,11 +59,11 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
form,
filter,
holder,
- filterEndpoint: endpoint || dataset.endpoint,
+ filterEndpoint: dataset.endpoint,
pagePath: dataset.path,
dropdownSel: dataset.dropdownSel,
filterInputField: 'filter',
- action: this.action,
+ action: '',
};
groupFilterList = new GroupFilterableList(opts);
@@ -115,11 +72,9 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
render(createElement) {
return createElement('groups-app', {
props: {
- action: this.action,
store: this.store,
service: this.service,
renderEmptyState: this.renderEmptyState,
- containerId: this.containerId,
},
});
},
diff --git a/app/assets/javascripts/groups/service/archived_projects_service.js b/app/assets/javascripts/groups/service/archived_projects_service.js
index 5ffa3f91b06..b9d48cc660e 100644
--- a/app/assets/javascripts/groups/service/archived_projects_service.js
+++ b/app/assets/javascripts/groups/service/archived_projects_service.js
@@ -17,6 +17,7 @@ export default class ArchivedProjectsService {
const { data: projects, headers } = await Api.groupProjects(this.groupId, query, {
archived: true,
+ include_subgroups: true,
page,
order_by: supportedOrderBy[orderBy],
sort,
@@ -46,7 +47,7 @@ export default class ArchivedProjectsService {
number_users_with_delimiter: 0,
star_count: project.star_count,
updated_at: project.updated_at,
- marked_for_deletion: project.marked_for_deletion_at !== null,
+ marked_for_deletion: Boolean(project.marked_for_deletion_at),
last_activity_at: project.last_activity_at,
};
}),
diff --git a/app/assets/javascripts/groups/settings/components/access_dropdown.vue b/app/assets/javascripts/groups/settings/components/access_dropdown.vue
index 8bc5f28ebfb..457a2db174c 100644
--- a/app/assets/javascripts/groups/settings/components/access_dropdown.vue
+++ b/app/assets/javascripts/groups/settings/components/access_dropdown.vue
@@ -181,7 +181,6 @@ export default {
<gl-dropdown-item
v-for="group in groups"
:key="`${group.id}${group.name}`"
- data-testid="group-dropdown-item"
:avatar-url="group.avatar_url"
is-check-item
:is-checked="isSelected(group)"
diff --git a/app/assets/javascripts/groups_projects/components/transfer_locations.vue b/app/assets/javascripts/groups_projects/components/transfer_locations.vue
index 360af772a10..997e2bc3138 100644
--- a/app/assets/javascripts/groups_projects/components/transfer_locations.vue
+++ b/app/assets/javascripts/groups_projects/components/transfer_locations.vue
@@ -241,6 +241,7 @@ export default {
data-testid="transfer-locations-dropdown"
block
toggle-class="gl-mb-0"
+ class="gl-form-input-xl"
@show="handleShow"
>
<template #header>
diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue
index 3cb0963e561..120b51f07cc 100644
--- a/app/assets/javascripts/header_search/components/app.vue
+++ b/app/assets/javascripts/header_search/components/app.vue
@@ -6,6 +6,7 @@ import {
GlTooltipDirective,
GlResizeObserverDirective,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions, mapGetters } from 'vuex';
import { debounce } from 'lodash';
import { visitUrl } from '~/lib/utils/url_utility';
@@ -225,7 +226,7 @@ export default {
v-model="searchText"
role="searchbox"
class="gl-z-index-1"
- data-testid="global_search_input"
+ data-testid="global-search-input"
autocomplete="off"
:placeholder="$options.i18n.SEARCH_GITLAB"
:aria-activedescendant="currentFocusedId"
diff --git a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
index 1838214def6..a785ae2a859 100644
--- a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
+++ b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
@@ -7,6 +7,7 @@ import {
GlAlert,
GlLoadingIcon,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import highlight from '~/lib/utils/highlight';
diff --git a/app/assets/javascripts/header_search/components/header_search_default_items.vue b/app/assets/javascripts/header_search/components/header_search_default_items.vue
index f0d398297e9..6afee197c60 100644
--- a/app/assets/javascripts/header_search/components/header_search_default_items.vue
+++ b/app/assets/javascripts/header_search/components/header_search_default_items.vue
@@ -1,5 +1,6 @@
<script>
import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters } from 'vuex';
import { ALL_GITLAB } from '~/vue_shared/global_search/constants';
diff --git a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue
index 1ef88492b23..7faef5f9bd7 100644
--- a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue
+++ b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue
@@ -1,5 +1,6 @@
<script>
import { GlDropdownItem, GlIcon, GlToken } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters } from 'vuex';
import { s__, sprintf } from '~/locale';
import { truncate } from '~/lib/utils/text_utility';
diff --git a/app/assets/javascripts/header_search/store/index.js b/app/assets/javascripts/header_search/store/index.js
index b83433c5b49..ca5519f529c 100644
--- a/app/assets/javascripts/header_search/store/index.js
+++ b/app/assets/javascripts/header_search/store/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue
index d788104edc8..44a94f5fefe 100644
--- a/app/assets/javascripts/ide/components/activity_bar.vue
+++ b/app/assets/javascripts/ide/components/activity_bar.vue
@@ -1,5 +1,6 @@
<script>
import { GlIcon, GlTooltipDirective, GlBadge } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { leftSidebarViews } from '../constants';
diff --git a/app/assets/javascripts/ide/components/branches/item.vue b/app/assets/javascripts/ide/components/branches/item.vue
index bdfcff3136b..5fb8e4247d7 100644
--- a/app/assets/javascripts/ide/components/branches/item.vue
+++ b/app/assets/javascripts/ide/components/branches/item.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
import { GlIcon } from '@gitlab/ui';
diff --git a/app/assets/javascripts/ide/components/branches/search_list.vue b/app/assets/javascripts/ide/components/branches/search_list.vue
index ce39c796386..603f2cedce2 100644
--- a/app/assets/javascripts/ide/components/branches/search_list.vue
+++ b/app/assets/javascripts/ide/components/branches/search_list.vue
@@ -1,6 +1,7 @@
<script>
import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
import { debounce } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import Item from './item.vue';
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
index fcc900bbc96..bc8496e359c 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
@@ -1,6 +1,8 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlSprintf } from '@gitlab/ui';
import { escape } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters, createNamespacedHelpers } from 'vuex';
import { s__ } from '~/locale';
import {
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
index 7112c43bab8..44528685339 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
@@ -1,5 +1,6 @@
<script>
import { GlModal, GlButton } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import { sprintf, __ } from '~/locale';
import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue';
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue
index 3ffbcbf99e8..ef9d9fd6048 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
export default {
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
index ef3da57c240..281a3054721 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
@@ -1,5 +1,7 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlModal, GlButton, GlTooltipDirective } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions, mapGetters } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { n__ } from '~/locale';
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
index 91d78a7c28c..76d3acb8e1f 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
@@ -1,5 +1,7 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton, GlModal, GlTooltipDirective } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import { __, sprintf } from '~/locale';
import ListItem from './list_item.vue';
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
index 79b6fd1ec68..69d84bcc6aa 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
@@ -1,5 +1,6 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import getCommitIconMap from '../../commit_icon';
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue
index 0a8fec49aac..462dab3d1cf 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue
@@ -1,5 +1,6 @@
<script>
import { GlTooltipDirective, GlFormCheckbox } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { createNamespacedHelpers } from 'vuex';
import { s__ } from '~/locale';
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
index bd5d28dbb56..38b71e3da73 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
@@ -6,6 +6,7 @@ import {
GlFormGroup,
GlFormInput,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState, mapGetters } from 'vuex';
export default {
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue
index dd343bc5f79..db366a1b465 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
diff --git a/app/assets/javascripts/ide/components/error_message.vue b/app/assets/javascripts/ide/components/error_message.vue
index eba9bbcdf09..ce3d8f53fd2 100644
--- a/app/assets/javascripts/ide/components/error_message.vue
+++ b/app/assets/javascripts/ide/components/error_message.vue
@@ -1,5 +1,6 @@
<script>
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue
index d80ad723fce..d2d53ece4c5 100644
--- a/app/assets/javascripts/ide/components/file_row_extra.vue
+++ b/app/assets/javascripts/ide/components/file_row_extra.vue
@@ -1,5 +1,6 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import { n__ } from '~/locale';
import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue';
diff --git a/app/assets/javascripts/ide/components/file_templates/bar.vue b/app/assets/javascripts/ide/components/file_templates/bar.vue
index ba679ae7c9b..287ebc99662 100644
--- a/app/assets/javascripts/ide/components/file_templates/bar.vue
+++ b/app/assets/javascripts/ide/components/file_templates/bar.vue
@@ -1,5 +1,7 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton, GlDropdown, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/ide/components/file_templates/dropdown.vue b/app/assets/javascripts/ide/components/file_templates/dropdown.vue
index e8b42ac9490..f58a35e7624 100644
--- a/app/assets/javascripts/ide/components/file_templates/dropdown.vue
+++ b/app/assets/javascripts/ide/components/file_templates/dropdown.vue
@@ -1,6 +1,8 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlIcon, GlLoadingIcon } from '@gitlab/ui';
import $ from 'jquery';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue';
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index 6bbad88715f..6cb26643b66 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -1,5 +1,7 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import { __ } from '~/locale';
import {
diff --git a/app/assets/javascripts/ide/components/ide_file_row.vue b/app/assets/javascripts/ide/components/ide_file_row.vue
index 248677d6a99..72d63f6a4ad 100644
--- a/app/assets/javascripts/ide/components/ide_file_row.vue
+++ b/app/assets/javascripts/ide/components/ide_file_row.vue
@@ -3,6 +3,7 @@
* This component is an iterative step towards refactoring and simplifying `vue_shared/components/file_row.vue`
* https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23720
*/
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import FileRow from '~/vue_shared/components/file_row.vue';
import FileRowExtra from './file_row_extra.vue';
diff --git a/app/assets/javascripts/ide/components/ide_review.vue b/app/assets/javascripts/ide/components/ide_review.vue
index bea25d42756..be7865b09c1 100644
--- a/app/assets/javascripts/ide/components/ide_review.vue
+++ b/app/assets/javascripts/ide/components/ide_review.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapGetters, mapState, mapActions } from 'vuex';
import { viewerTypes } from '../constants';
import EditorModeDropdown from './editor_mode_dropdown.vue';
diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue
index f32d35bf774..d422c7c00d9 100644
--- a/app/assets/javascripts/ide/components/ide_side_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_side_bar.vue
@@ -1,5 +1,6 @@
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters } from 'vuex';
import { SIDEBAR_INIT_WIDTH, leftSidebarViews } from '../constants';
import ActivityBar from './activity_bar.vue';
diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue
index edc6cc3dcdc..76b284b6185 100644
--- a/app/assets/javascripts/ide/components/ide_status_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_status_bar.vue
@@ -1,6 +1,7 @@
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState, mapGetters } from 'vuex';
import timeAgoMixin from '~/vue_shared/mixins/timeago';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
diff --git a/app/assets/javascripts/ide/components/ide_status_list.vue b/app/assets/javascripts/ide/components/ide_status_list.vue
index da393b42dca..bd61625a530 100644
--- a/app/assets/javascripts/ide/components/ide_status_list.vue
+++ b/app/assets/javascripts/ide/components/ide_status_list.vue
@@ -1,5 +1,6 @@
<script>
import { GlLink, GlTooltipDirective } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import { isTextFile, getFileEOL } from '~/ide/utils';
import TerminalSyncStatusSafe from './terminal_sync/terminal_sync_status_safe.vue';
diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue
index 6998f8ef0c4..427b3743961 100644
--- a/app/assets/javascripts/ide/components/ide_tree.vue
+++ b/app/assets/javascripts/ide/components/ide_tree.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters, mapActions } from 'vuex';
import { modalTypes, viewerTypes } from '../constants';
import IdeTreeList from './ide_tree_list.vue';
diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue
index 737ff49f74c..f2a97e62190 100644
--- a/app/assets/javascripts/ide/components/ide_tree_list.vue
+++ b/app/assets/javascripts/ide/components/ide_tree_list.vue
@@ -1,5 +1,6 @@
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import { WEBIDE_MARK_FILE_CLICKED } from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue
index 9676233a443..209d67b0d28 100644
--- a/app/assets/javascripts/ide/components/jobs/detail.vue
+++ b/app/assets/javascripts/ide/components/jobs/detail.vue
@@ -1,6 +1,8 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlTooltipDirective, GlButton, GlIcon } from '@gitlab/ui';
import { throttle } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/ide/components/jobs/detail/description.vue b/app/assets/javascripts/ide/components/jobs/detail/description.vue
index 00059d01308..f0c5b29e210 100644
--- a/app/assets/javascripts/ide/components/jobs/detail/description.vue
+++ b/app/assets/javascripts/ide/components/jobs/detail/description.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlIcon } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
diff --git a/app/assets/javascripts/ide/components/jobs/item.vue b/app/assets/javascripts/ide/components/jobs/item.vue
index f84315b63d2..dcae6b70d4f 100644
--- a/app/assets/javascripts/ide/components/jobs/item.vue
+++ b/app/assets/javascripts/ide/components/jobs/item.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton } from '@gitlab/ui';
import JobDescription from './detail/description.vue';
diff --git a/app/assets/javascripts/ide/components/jobs/list.vue b/app/assets/javascripts/ide/components/jobs/list.vue
index 0ce21c5c36c..9f5da1d1217 100644
--- a/app/assets/javascripts/ide/components/jobs/list.vue
+++ b/app/assets/javascripts/ide/components/jobs/list.vue
@@ -1,5 +1,7 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlLoadingIcon } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import Stage from './stage.vue';
diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue
index 4d8c62d3430..ce4d657f941 100644
--- a/app/assets/javascripts/ide/components/jobs/stage.vue
+++ b/app/assets/javascripts/ide/components/jobs/stage.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlLoadingIcon, GlIcon, GlTooltipDirective, GlBadge } from '@gitlab/ui';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/ide/components/merge_requests/item.vue b/app/assets/javascripts/ide/components/merge_requests/item.vue
index 2d9f74a06ee..61a595d3b5a 100644
--- a/app/assets/javascripts/ide/components/merge_requests/item.vue
+++ b/app/assets/javascripts/ide/components/merge_requests/item.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlIcon } from '@gitlab/ui';
diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue
index 829a9d64cb7..be070891586 100644
--- a/app/assets/javascripts/ide/components/merge_requests/list.vue
+++ b/app/assets/javascripts/ide/components/merge_requests/list.vue
@@ -1,6 +1,8 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
import { debounce } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import { __ } from '~/locale';
import TokenedInput from '../shared/tokened_input.vue';
diff --git a/app/assets/javascripts/ide/components/nav_dropdown.vue b/app/assets/javascripts/ide/components/nav_dropdown.vue
index f5f0db3a7a3..99ece59cbda 100644
--- a/app/assets/javascripts/ide/components/nav_dropdown.vue
+++ b/app/assets/javascripts/ide/components/nav_dropdown.vue
@@ -1,5 +1,6 @@
<script>
import $ from 'jquery';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import NavDropdownButton from './nav_dropdown_button.vue';
import NavForm from './nav_form.vue';
diff --git a/app/assets/javascripts/ide/components/nav_dropdown_button.vue b/app/assets/javascripts/ide/components/nav_dropdown_button.vue
index 6c26cde42e3..18f0ca013a6 100644
--- a/app/assets/javascripts/ide/components/nav_dropdown_button.vue
+++ b/app/assets/javascripts/ide/components/nav_dropdown_button.vue
@@ -1,5 +1,6 @@
<script>
import { GlIcon } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue';
diff --git a/app/assets/javascripts/ide/components/new_dropdown/button.vue b/app/assets/javascripts/ide/components/new_dropdown/button.vue
index ce80fbee2e0..06f40ce0100 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/button.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/button.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue
index 9f83de840b9..7cd415169cc 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/index.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue
@@ -1,5 +1,7 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlIcon } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import { modalTypes } from '../../constants';
import ItemButton from './button.vue';
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index 4d728bd35d4..854daa20628 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -1,5 +1,7 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlModal, GlButton } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState, mapGetters } from 'vuex';
import { createAlert } from '~/alert';
import { __, sprintf } from '~/locale';
diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue
index 7c10e055e91..9664c5bc597 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { isTextFile } from '~/ide/utils';
import ItemButton from './button.vue';
diff --git a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue
index bf99538a2ad..ce55d88437d 100644
--- a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue
+++ b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import IdeSidebarNav from '../ide_sidebar_nav.vue';
diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue
index 8342b3f428c..b59b43e2691 100644
--- a/app/assets/javascripts/ide/components/panes/right.vue
+++ b/app/assets/javascripts/ide/components/panes/right.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import { __ } from '~/locale';
import { rightSidebarViews, SIDEBAR_INIT_WIDTH, SIDEBAR_NAV_WIDTH } from '../../constants';
diff --git a/app/assets/javascripts/ide/components/pipelines/empty_state.vue b/app/assets/javascripts/ide/components/pipelines/empty_state.vue
index 25e1698e3f4..7048246a979 100644
--- a/app/assets/javascripts/ide/components/pipelines/empty_state.vue
+++ b/app/assets/javascripts/ide/components/pipelines/empty_state.vue
@@ -1,5 +1,6 @@
<script>
import { GlEmptyState } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import { s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue
index 7f662f528d7..6bf51ed06a6 100644
--- a/app/assets/javascripts/ide/components/pipelines/list.vue
+++ b/app/assets/javascripts/ide/components/pipelines/list.vue
@@ -1,5 +1,7 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlLoadingIcon, GlIcon, GlTabs, GlTab, GlBadge, GlAlert } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import IDEServices from '~/ide/services';
diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue
index 854ff74d0af..0452d566313 100644
--- a/app/assets/javascripts/ide/components/repo_commit_section.vue
+++ b/app/assets/javascripts/ide/components/repo_commit_section.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions, mapGetters } from 'vuex';
import { stageKeys } from '../constants';
import EmptyState from './commit_sidebar/empty_state.vue';
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index 9e29cd94a20..137df9aa102 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -1,6 +1,7 @@
<script>
import { GlTabs, GlTab } from '@gitlab/ui';
import { debounce } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters, mapActions } from 'vuex';
import {
EDITOR_TYPE_DIFF,
@@ -28,6 +29,7 @@ import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer
import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import { markRaw } from '~/lib/utils/vue3compat/mark_raw';
+import { readFileAsDataURL } from '~/lib/utils/file_utility';
import {
leftSidebarViews,
@@ -40,7 +42,7 @@ import { getRulesWithTraversal } from '../lib/editorconfig/parser';
import mapRulesToMonaco from '../lib/editorconfig/rules_mapper';
import { getFileEditorOrDefault } from '../stores/modules/editor/utils';
import { extractMarkdownImagesFromEntries } from '../stores/utils';
-import { getPathParent, readFileAsDataURL, registerSchema, isTextFile } from '../utils';
+import { getPathParent, registerSchema, isTextFile } from '../utils';
import FileAlert from './file_alert.vue';
import FileTemplatesBar from './file_templates/bar.vue';
diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue
index 0fe909fcce8..15cb0571cbf 100644
--- a/app/assets/javascripts/ide/components/repo_tab.vue
+++ b/app/assets/javascripts/ide/components/repo_tab.vue
@@ -1,5 +1,6 @@
<script>
import { GlIcon, GlTab } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters } from 'vuex';
import { __, sprintf } from '~/locale';
diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue
index 932040c7fa5..ae8becea242 100644
--- a/app/assets/javascripts/ide/components/repo_tabs.vue
+++ b/app/assets/javascripts/ide/components/repo_tabs.vue
@@ -1,5 +1,6 @@
<script>
import { GlTabs } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters } from 'vuex';
import RepoTab from './repo_tab.vue';
diff --git a/app/assets/javascripts/ide/components/resizable_panel.vue b/app/assets/javascripts/ide/components/resizable_panel.vue
index b49d743d877..660057f8f98 100644
--- a/app/assets/javascripts/ide/components/resizable_panel.vue
+++ b/app/assets/javascripts/ide/components/resizable_panel.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
import { SIDEBAR_MIN_WIDTH } from '../constants';
diff --git a/app/assets/javascripts/ide/components/terminal/session.vue b/app/assets/javascripts/ide/components/terminal/session.vue
index 384e27844c6..a1999465033 100644
--- a/app/assets/javascripts/ide/components/terminal/session.vue
+++ b/app/assets/javascripts/ide/components/terminal/session.vue
@@ -1,5 +1,7 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import { __ } from '~/locale';
import { isEndingStatus } from '../../stores/modules/terminal/utils';
diff --git a/app/assets/javascripts/ide/components/terminal/terminal.vue b/app/assets/javascripts/ide/components/terminal/terminal.vue
index c91a98c9527..9e8b3d87397 100644
--- a/app/assets/javascripts/ide/components/terminal/terminal.vue
+++ b/app/assets/javascripts/ide/components/terminal/terminal.vue
@@ -1,5 +1,7 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlLoadingIcon } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import { __ } from '~/locale';
import GLTerminal from '~/terminal/terminal';
diff --git a/app/assets/javascripts/ide/components/terminal/view.vue b/app/assets/javascripts/ide/components/terminal/view.vue
index fcf23eb1f73..872557cb777 100644
--- a/app/assets/javascripts/ide/components/terminal/view.vue
+++ b/app/assets/javascripts/ide/components/terminal/view.vue
@@ -1,4 +1,6 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import EmptyState from './empty_state.vue';
diff --git a/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status.vue b/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status.vue
index 67692c842b8..38e53b64503 100644
--- a/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status.vue
+++ b/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status.vue
@@ -1,6 +1,7 @@
<script>
import { GlTooltipDirective, GlLoadingIcon, GlIcon } from '@gitlab/ui';
import { throttle } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import {
MSG_TERMINAL_SYNC_CONNECTING,
diff --git a/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status_safe.vue b/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status_safe.vue
index afaf06f7f68..214a13a6668 100644
--- a/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status_safe.vue
+++ b/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status_safe.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import TerminalSyncStatus from './terminal_sync_status.vue';
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index 29c44d2f596..b09cd7f6643 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -1,5 +1,6 @@
import { identity } from 'lodash';
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import { DEFAULT_BRANCH } from '~/ide/constants';
import PerformancePlugin from '~/performance/vue_performance_plugin';
diff --git a/app/assets/javascripts/ide/lib/alerts/environments.vue b/app/assets/javascripts/ide/lib/alerts/environments.vue
index ac9a3c3f82c..bfe101bc7e7 100644
--- a/app/assets/javascripts/ide/lib/alerts/environments.vue
+++ b/app/assets/javascripts/ide/lib/alerts/environments.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlSprintf, GlLink } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js
index c2f7126159c..54ae4b5aa91 100644
--- a/app/assets/javascripts/ide/stores/index.js
+++ b/app/assets/javascripts/ide/stores/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js
index 83a3d7f2ac3..3a42d7b3027 100644
--- a/app/assets/javascripts/ide/utils.js
+++ b/app/assets/javascripts/ide/utils.js
@@ -123,19 +123,6 @@ export function getPathParent(path) {
return getPathParents(path, 1)[0];
}
-/**
- * Takes a file object and returns a data uri of its contents.
- *
- * @param {File} file
- */
-export function readFileAsDataURL(file) {
- return new Promise((resolve) => {
- const reader = new FileReader();
- reader.addEventListener('load', (e) => resolve(e.target.result), { once: true });
- reader.readAsDataURL(file);
- });
-}
-
export function getFileEOL(content = '') {
return content.includes('\r\n') ? 'CRLF' : 'LF';
}
diff --git a/app/assets/javascripts/import_entities/components/group_dropdown.vue b/app/assets/javascripts/import_entities/components/group_dropdown.vue
index 1c31c04a416..68bdcf7ef90 100644
--- a/app/assets/javascripts/import_entities/components/group_dropdown.vue
+++ b/app/assets/javascripts/import_entities/components/group_dropdown.vue
@@ -9,6 +9,8 @@ import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/cons
import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+// This is added outside the component as each dropdown on the page triggers a query,
+// so if multiple queries fail, we only show 1 error.
const reportNamespaceLoadError = debounce(
() =>
createAlert({
diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue
index 6c84684dedc..94c04123112 100644
--- a/app/assets/javascripts/import_entities/components/import_status.vue
+++ b/app/assets/javascripts/import_entities/components/import_status.vue
@@ -1,7 +1,6 @@
<script>
import { GlAccordion, GlAccordionItem, GlBadge, GlIcon, GlLink } from '@gitlab/ui';
import { __, s__ } from '~/locale';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { STATISTIC_ITEMS } from '~/import/constants';
import { STATUSES } from '../constants';
@@ -58,7 +57,6 @@ export default {
GlIcon,
GlLink,
},
- mixins: [glFeatureFlagMixin()],
inject: {
detailsPath: {
default: undefined,
@@ -116,11 +114,7 @@ export default {
},
showDetails() {
- return (
- Boolean(this.detailsPathForProject) &&
- this.glFeatures.importDetailsPage &&
- this.isIncomplete
- );
+ return Boolean(this.detailsPathForProject) && this.isIncomplete;
},
detailsPathForProject() {
diff --git a/app/assets/javascripts/import_entities/components/import_target_dropdown.vue b/app/assets/javascripts/import_entities/components/import_target_dropdown.vue
new file mode 100644
index 00000000000..b18a106608a
--- /dev/null
+++ b/app/assets/javascripts/import_entities/components/import_target_dropdown.vue
@@ -0,0 +1,119 @@
+<script>
+import { GlCollapsibleListbox } from '@gitlab/ui';
+import { debounce } from 'lodash';
+
+import { __, s__ } from '~/locale';
+import { createAlert } from '~/alert';
+import { truncate } from '~/lib/utils/text_utility';
+import searchNamespacesWhereUserCanImportProjectsQuery from '~/import_entities/import_projects/graphql/queries/search_namespaces_where_user_can_import_projects.query.graphql';
+import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
+import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+
+// This is added outside the component as each dropdown on the page triggers a query,
+// so if multiple queries fail, we only show 1 error.
+const reportNamespaceLoadError = debounce(
+ () =>
+ createAlert({
+ message: s__('ImportProjects|Requesting namespaces failed'),
+ }),
+ DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
+);
+
+export default {
+ components: {
+ GlCollapsibleListbox,
+ },
+
+ props: {
+ selected: {
+ type: String,
+ required: true,
+ },
+ userNamespace: {
+ type: String,
+ required: true,
+ },
+ },
+
+ MAX_IMPORT_TARGET_LENGTH: 24,
+
+ data() {
+ return {
+ searchTerm: '',
+ };
+ },
+
+ apollo: {
+ namespaces: {
+ query: searchNamespacesWhereUserCanImportProjectsQuery,
+ variables() {
+ return {
+ search: this.searchTerm,
+ };
+ },
+ skip() {
+ const hasNotEnoughSearchCharacters =
+ this.searchTerm.length > 0 && this.searchTerm.length < MINIMUM_SEARCH_LENGTH;
+ return hasNotEnoughSearchCharacters;
+ },
+ update(data) {
+ return data.currentUser.groups.nodes;
+ },
+ error: reportNamespaceLoadError,
+ debounce: DEBOUNCE_DELAY,
+ },
+ },
+
+ computed: {
+ filteredNamespaces() {
+ return (this.namespaces ?? []).filter((ns) =>
+ ns.fullPath.toLowerCase().includes(this.searchTerm.toLowerCase()),
+ );
+ },
+
+ toggleText() {
+ return truncate(this.selected, this.$options.MAX_IMPORT_TARGET_LENGTH);
+ },
+
+ items() {
+ return [
+ {
+ text: __('Users'),
+ options: [{ text: this.userNamespace, value: this.userNamespace }],
+ },
+ {
+ text: __('Groups'),
+ options: this.filteredNamespaces.map((namespace) => {
+ return { text: namespace.fullPath, value: namespace.fullPath };
+ }),
+ },
+ ];
+ },
+ },
+
+ methods: {
+ onSelect(value) {
+ this.$emit('select', value);
+ },
+
+ onSearch(value) {
+ this.searchTerm = value.trim();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-collapsible-listbox
+ :items="items"
+ :selected="selected"
+ :toggle-text="toggleText"
+ searchable
+ fluid-width
+ toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
+ data-qa-selector="target_namespace_selector_dropdown"
+ @select="onSelect"
+ @search="onSearch"
+ />
+</template>
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue
index d91f314a86c..678efc536f2 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue
@@ -1,11 +1,20 @@
<script>
-import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
+import {
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+ GlIcon,
+ GlButtonGroup,
+ GlButton,
+ GlTooltipDirective as GlTooltip,
+} from '@gitlab/ui';
export default {
components: {
GlIcon,
- GlDropdown,
- GlDropdownItem,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+ GlButtonGroup,
+ GlButton,
},
directives: {
GlTooltip,
@@ -34,20 +43,31 @@ export default {
<template>
<span class="gl-white-space-nowrap gl-inline-flex gl-align-items-center">
- <gl-dropdown
- v-if="isAvailableForImport || isFinished"
- :text="isFinished ? __('Re-import with projects') : __('Import with projects')"
- :disabled="isInvalid"
- variant="confirm"
- category="secondary"
- data-qa-selector="import_group_button"
- split
- @click="importGroup({ migrateProjects: true })"
- >
- <gl-dropdown-item @click="importGroup({ migrateProjects: false })">{{
- isFinished ? __('Re-import without projects') : __('Import without projects')
- }}</gl-dropdown-item>
- </gl-dropdown>
+ <gl-button-group v-if="isAvailableForImport || isFinished">
+ <gl-button
+ variant="confirm"
+ category="secondary"
+ data-testid="import-group-button"
+ @click="importGroup({ migrateProjects: true })"
+ >{{ isFinished ? __('Re-import with projects') : __('Import with projects') }}</gl-button
+ >
+ <gl-disclosure-dropdown
+ toggle-text="Import options"
+ text-sr-only
+ :disabled="isInvalid"
+ icon="chevron-down"
+ no-caret
+ variant="confirm"
+ category="secondary"
+ >
+ <gl-disclosure-dropdown-item @action="importGroup({ migrateProjects: false })">
+ <template #list-item>
+ {{ isFinished ? __('Re-import without projects') : __('Import without projects') }}
+ </template></gl-disclosure-dropdown-item
+ >
+ </gl-disclosure-dropdown>
+ </gl-button-group>
+
<gl-icon
v-if="isFinished"
v-gl-tooltip
diff --git a/app/assets/javascripts/import_entities/import_projects/components/github_status_table.vue b/app/assets/javascripts/import_entities/import_projects/components/github_status_table.vue
index 20dcd0356cd..cb3476c48db 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/github_status_table.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/github_status_table.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlSearchBoxByClick, GlTabs, GlTab } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import { s__ } from '~/locale';
import ImportProjectsTable from './import_projects_table.vue';
diff --git a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
index 1c830d8c2c5..009945f8b9b 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
@@ -6,6 +6,7 @@ import {
GlModal,
GlSearchBoxByClick,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState, mapGetters } from 'vuex';
import { n__, __, sprintf } from '~/locale';
diff --git a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
index 735939f991f..d75ba53d727 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
@@ -5,17 +5,15 @@ import {
GlFormInput,
GlButton,
GlLink,
- GlDropdownItem,
- GlDropdownDivider,
- GlDropdownSectionHeader,
GlTooltip,
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters, mapActions } from 'vuex';
import { __ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
-import ImportGroupDropdown from '../../components/group_dropdown.vue';
+import ImportTargetDropdown from '../../components/import_target_dropdown.vue';
import ImportStatus from '../../components/import_status.vue';
import { STATUSES } from '../../constants';
import { isProjectImportable, isImporting, isIncompatible, getImportStatus } from '../utils';
@@ -23,13 +21,10 @@ import { isProjectImportable, isImporting, isIncompatible, getImportStatus } fro
export default {
name: 'ProviderRepoTableRow',
components: {
- ImportGroupDropdown,
ImportStatus,
+ ImportTargetDropdown,
GlFormInput,
GlButton,
- GlDropdownItem,
- GlDropdownDivider,
- GlDropdownSectionHeader,
GlIcon,
GlBadge,
GlLink,
@@ -151,6 +146,10 @@ export default {
});
}
},
+
+ onSelect(value) {
+ this.updateImportTarget({ targetNamespace: value });
+ },
},
helpUrl: helpPagePath('/user/project/import/github.md'),
@@ -188,27 +187,13 @@ export default {
<template v-if="repo.importSource.target">{{ repo.importSource.target }}</template>
<template v-else-if="isImportNotStarted || isSelectedForReimport">
<div class="gl-display-flex gl-align-items-stretch gl-w-full">
- <import-group-dropdown #default="{ namespaces }" :text="importTarget.targetNamespace">
- <template v-if="namespaces.length">
- <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="ns in namespaces"
- :key="ns.fullPath"
- data-qa-selector="target_group_dropdown_item"
- :data-qa-group-name="ns.fullPath"
- @click="updateImportTarget({ targetNamespace: ns.fullPath })"
- >
- {{ ns.fullPath }}
- </gl-dropdown-item>
- <gl-dropdown-divider />
- </template>
- <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header>
- <gl-dropdown-item @click="updateImportTarget({ targetNamespace: userNamespace })">{{
- userNamespace
- }}</gl-dropdown-item>
- </import-group-dropdown>
+ <import-target-dropdown
+ :selected="importTarget.targetNamespace"
+ :user-namespace="userNamespace"
+ @select="onSelect"
+ />
<div
- class="gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1"
+ class="gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1 gl-border-gray-400"
>
/
</div>
diff --git a/app/assets/javascripts/import_entities/import_projects/store/index.js b/app/assets/javascripts/import_entities/import_projects/store/index.js
index a2880e7d031..d3edb48e1db 100644
--- a/app/assets/javascripts/import_entities/import_projects/store/index.js
+++ b/app/assets/javascripts/import_entities/import_projects/store/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import actionsFactory from './actions';
import * as getters from './getters';
diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue
index e15cb2224f4..e5a88cf9510 100644
--- a/app/assets/javascripts/incidents/components/incidents_list.vue
+++ b/app/assets/javascripts/incidents/components/incidents_list.vue
@@ -447,7 +447,6 @@ export default {
:issue-iid="item.iid"
:project-path="projectPath"
:sla-due-at="item.slaDueAt"
- data-testid="incident-sla"
class="gl-display-block gl-max-w-full gl-text-truncate"
/>
</template>
diff --git a/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue
index af4905deef4..fe3f4ed4bf9 100644
--- a/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue
+++ b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue
@@ -38,7 +38,7 @@ export default {
<gl-button ref="toggleBtn" class="js-settings-toggle">{{
$options.i18n.expandBtnLabel
}}</gl-button>
- <p ref="sectionSubHeader">
+ <p ref="sectionSubHeader" class="gl-text-secondary">
{{ $options.i18n.subHeaderText }}
</p>
</div>
diff --git a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
index a4415a5a2b3..f78513a98b8 100644
--- a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
+++ b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
@@ -1,5 +1,6 @@
<script>
import { GlFormGroup, GlFormCheckbox } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters, mapState } from 'vuex';
export default {
diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
index 0a29906d5aa..bd45412a481 100644
--- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
+++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
@@ -1,6 +1,7 @@
<script>
import { GlFormGroup, GlFormCheckbox, GlFormInput, GlFormSelect, GlFormTextarea } from '@gitlab/ui';
import { capitalize, lowerCase, isEmpty } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index f119668048d..fa9a59212eb 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -2,6 +2,7 @@
import { GlAlert, GlForm } from '@gitlab/ui';
import axios from 'axios';
import * as Sentry from '@sentry/browser';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions, mapGetters } from 'vuex';
import { s__ } from '~/locale';
import SafeHtml from '~/vue_shared/directives/safe_html';
@@ -217,29 +218,24 @@ export default {
@change="setOverride"
/>
- <section v-if="showHelpHtml" class="gl-lg-display-flex gl-justify-content-end gl-mb-6">
+ <!-- helpHtml is trusted input -->
+ <section v-if="showHelpHtml" class="gl-mb-6">
<!-- helpHtml is trusted input -->
- <div
- v-safe-html:[$options.helpHtmlConfig]="helpHtml"
- data-testid="help-html"
- class="gl-flex-basis-two-thirds"
- ></div>
+ <div v-safe-html:[$options.helpHtmlConfig]="helpHtml" data-testid="help-html"></div>
</section>
- <section v-if="!hasSections" class="gl-lg-display-flex gl-justify-content-end">
- <div class="gl-flex-basis-two-thirds">
- <active-checkbox
- v-if="propsSource.showActive"
- :key="`${currentKey}-active-checkbox`"
- @toggle-integration-active="onToggleIntegrationState"
- />
- <trigger-fields
- v-if="propsSource.triggerEvents.length"
- :key="`${currentKey}-trigger-fields`"
- :events="propsSource.triggerEvents"
- :type="propsSource.type"
- />
- </div>
+ <section v-if="!hasSections">
+ <active-checkbox
+ v-if="propsSource.showActive"
+ :key="`${currentKey}-active-checkbox`"
+ @toggle-integration-active="onToggleIntegrationState"
+ />
+ <trigger-fields
+ v-if="propsSource.triggerEvents.length"
+ :key="`${currentKey}-trigger-fields`"
+ :events="propsSource.triggerEvents"
+ :type="propsSource.type"
+ />
</section>
<template v-if="hasSections">
@@ -254,22 +250,19 @@ export default {
/>
</template>
- <section v-if="hasFieldsWithoutSection" class="gl-lg-display-flex gl-justify-content-end">
- <div class="gl-flex-basis-two-thirds">
- <dynamic-field
- v-for="field in fieldsWithoutSection"
- :key="`${currentKey}-${field.name}`"
- v-bind="field"
- :is-validated="isValidated"
- :data-qa-selector="`${field.name}_div`"
- />
- </div>
+ <section v-if="hasFieldsWithoutSection">
+ <dynamic-field
+ v-for="field in fieldsWithoutSection"
+ :key="`${currentKey}-${field.name}`"
+ v-bind="field"
+ :is-validated="isValidated"
+ :data-qa-selector="`${field.name}_div`"
+ />
</section>
<integration-form-actions
v-if="isEditable"
:has-sections="hasSections"
- :class="{ 'gl-lg-display-flex gl-justify-content-end': !hasSections }"
:is-saving="isSaving"
:is-testing="isTesting"
:is-resetting="isResetting"
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue b/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue
index e5ad5149cf7..a9d7c1ca378 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlModalDirective } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters } from 'vuex';
import { integrationLevels } from '~/integrations/constants';
import ConfirmationModal from './confirmation_modal.vue';
@@ -69,75 +70,69 @@ export default {
};
</script>
<template>
- <section>
- <div :class="{ 'gl-flex-basis-two-thirds': !hasSections }">
- <div
- class="footer-block row-content-block gl-lg-display-flex gl-justify-content-space-between"
+ <section class="gl-lg-display-flex gl-justify-content-space-between">
+ <div>
+ <template v-if="isInstanceOrGroupLevel">
+ <gl-button
+ v-gl-modal.confirmSaveIntegration
+ category="primary"
+ variant="confirm"
+ :loading="isSaving"
+ :disabled="disableButtons"
+ data-testid="save-button"
+ data-qa-selector="save_changes_button"
+ >
+ {{ __('Save changes') }}
+ </gl-button>
+ <confirmation-modal @submit="onSaveClick" />
+ </template>
+ <gl-button
+ v-else
+ category="primary"
+ variant="confirm"
+ type="submit"
+ :loading="isSaving"
+ :disabled="disableButtons"
+ data-testid="save-button"
+ data-qa-selector="save_changes_button"
+ @click.prevent="onSaveClick"
>
- <div>
- <template v-if="isInstanceOrGroupLevel">
- <gl-button
- v-gl-modal.confirmSaveIntegration
- category="primary"
- variant="confirm"
- :loading="isSaving"
- :disabled="disableButtons"
- data-testid="save-button"
- data-qa-selector="save_changes_button"
- >
- {{ __('Save changes') }}
- </gl-button>
- <confirmation-modal @submit="onSaveClick" />
- </template>
- <gl-button
- v-else
- category="primary"
- variant="confirm"
- type="submit"
- :loading="isSaving"
- :disabled="disableButtons"
- data-testid="save-button"
- data-qa-selector="save_changes_button"
- @click.prevent="onSaveClick"
- >
- {{ __('Save changes') }}
- </gl-button>
+ {{ __('Save changes') }}
+ </gl-button>
- <gl-button
- v-if="showTestButton"
- category="secondary"
- variant="confirm"
- :loading="isTesting"
- :disabled="disableButtons"
- data-testid="test-button"
- @click.prevent="onTestClick"
- >
- {{ __('Test settings') }}
- </gl-button>
+ <gl-button
+ v-if="showTestButton"
+ category="secondary"
+ variant="confirm"
+ :loading="isTesting"
+ :disabled="disableButtons"
+ data-testid="test-button"
+ @click.prevent="onTestClick"
+ >
+ {{ __('Test settings') }}
+ </gl-button>
- <gl-button
- :href="propsSource.cancelPath"
- data-testid="cancel-button"
- :disabled="disableButtons"
- >{{ __('Cancel') }}</gl-button
- >
- </div>
+ <gl-button
+ :href="propsSource.cancelPath"
+ data-testid="cancel-button"
+ :disabled="disableButtons"
+ >{{ __('Cancel') }}</gl-button
+ >
+ </div>
- <template v-if="showResetButton">
- <gl-button
- v-gl-modal.confirmResetIntegration
- category="tertiary"
- variant="danger"
- :loading="isResetting"
- :disabled="disableButtons"
- data-testid="reset-button"
- >
- {{ __('Reset') }}
- </gl-button>
+ <template v-if="showResetButton">
+ <gl-button
+ v-gl-modal.confirmResetIntegration
+ category="tertiary"
+ variant="danger"
+ :loading="isResetting"
+ :disabled="disableButtons"
+ data-testid="reset-button"
+ >
+ {{ __('Reset') }}
+ </gl-button>
- <reset-confirmation-modal @reset="onResetClick" />
- </template>
- </div>
- </div>
+ <reset-confirmation-modal @reset="onResetClick" />
+ </template>
</section>
</template>
diff --git a/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue b/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue
index 5335b7b6ee2..d322e3b4cd2 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue
@@ -1,5 +1,6 @@
<script>
import { GlBadge } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { integrationFormSectionComponents, billingPlanNames } from '~/integrations/constants';
@@ -63,36 +64,29 @@ export default {
};
</script>
<template>
- <section class="gl-lg-display-flex">
- <div class="gl-flex-basis-third gl-mr-4">
- <h4 class="gl-mt-0">
- {{ section.title
- }}<gl-badge
- v-if="section.plan"
- :href="propsSource.aboutPricingUrl"
- target="_blank"
- rel="noopener noreferrer"
- variant="tier"
- icon="license"
- class="gl-ml-3"
- >
- {{ $options.billingPlanNames[section.plan] }}
- </gl-badge>
- </h4>
- <p v-safe-html="section.description"></p>
- </div>
+ <section>
+ <h4 class="gl-mt-0">
+ {{ section.title
+ }}<gl-badge
+ v-if="section.plan"
+ :href="propsSource.aboutPricingUrl"
+ target="_blank"
+ rel="noopener noreferrer"
+ variant="tier"
+ icon="license"
+ class="gl-ml-3"
+ >
+ {{ $options.billingPlanNames[section.plan] }}
+ </gl-badge>
+ </h4>
+ <p v-safe-html="section.description"></p>
- <div
- v-if="$options.integrationFormSectionComponents[section.type]"
- class="gl-flex-basis-two-thirds"
- >
- <component
- :is="$options.integrationFormSectionComponents[section.type]"
- :fields="fieldsForSection(section)"
- :is-validated="isValidated"
- @toggle-integration-active="$emit('toggle-integration-active', $event)"
- @request-jira-issue-types="$emit('request-jira-issue-types', $event)"
- />
- </div>
+ <component
+ :is="$options.integrationFormSectionComponents[section.type]"
+ :fields="fieldsForSection(section)"
+ :is-validated="isValidated"
+ @toggle-integration-active="$emit('toggle-integration-active', $event)"
+ @request-jira-issue-types="$emit('request-jira-issue-types', $event)"
+ />
</section>
</template>
diff --git a/app/assets/javascripts/integrations/edit/components/jira_auth_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_auth_fields.vue
index 30a39e48959..f3036e44df4 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_auth_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_auth_fields.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import { isEmpty } from 'lodash';
import { GlFormGroup, GlFormRadio, GlFormRadioGroup } from '@gitlab/ui';
diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
index 584d23e17e1..c1c09cfa3d6 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
@@ -1,5 +1,6 @@
<script>
import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import { s__, __ } from '~/locale';
diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
index c7cbdff72e3..034867f8b5f 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
@@ -7,6 +7,7 @@ import {
GlLink,
GlSprintf,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
diff --git a/app/assets/javascripts/integrations/edit/components/override_dropdown.vue b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue
index 96ba276033c..951b936f805 100644
--- a/app/assets/javascripts/integrations/edit/components/override_dropdown.vue
+++ b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue
@@ -1,5 +1,6 @@
<script>
import { GlCollapsibleListbox, GlLink } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import { s__ } from '~/locale';
import { defaultIntegrationLevel, overrideDropdownDescriptions } from '~/integrations/constants';
diff --git a/app/assets/javascripts/integrations/edit/components/sections/apple_app_store.vue b/app/assets/javascripts/integrations/edit/components/sections/apple_app_store.vue
index 775600a9a62..3d37bab1dce 100644
--- a/app/assets/javascripts/integrations/edit/components/sections/apple_app_store.vue
+++ b/app/assets/javascripts/integrations/edit/components/sections/apple_app_store.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import { sprintf, s__ } from '~/locale';
import UploadDropzoneField from '../upload_dropzone_field.vue';
diff --git a/app/assets/javascripts/integrations/edit/components/sections/configuration.vue b/app/assets/javascripts/integrations/edit/components/sections/configuration.vue
index b8fd8995744..052e8d8488d 100644
--- a/app/assets/javascripts/integrations/edit/components/sections/configuration.vue
+++ b/app/assets/javascripts/integrations/edit/components/sections/configuration.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import DynamicField from '../dynamic_field.vue';
diff --git a/app/assets/javascripts/integrations/edit/components/sections/connection.vue b/app/assets/javascripts/integrations/edit/components/sections/connection.vue
index 6237f7983a6..60b8bef24dc 100644
--- a/app/assets/javascripts/integrations/edit/components/sections/connection.vue
+++ b/app/assets/javascripts/integrations/edit/components/sections/connection.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import { INTEGRATION_FORM_TYPE_JIRA, jiraIntegrationAuthFields } from '~/integrations/constants';
diff --git a/app/assets/javascripts/integrations/edit/components/sections/google_play.vue b/app/assets/javascripts/integrations/edit/components/sections/google_play.vue
index 3094e24241a..20f0d3bba97 100644
--- a/app/assets/javascripts/integrations/edit/components/sections/google_play.vue
+++ b/app/assets/javascripts/integrations/edit/components/sections/google_play.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import { sprintf, s__ } from '~/locale';
import UploadDropzoneField from '../upload_dropzone_field.vue';
@@ -12,7 +13,7 @@ export default {
},
data() {
return {
- dropzoneAllowList: ['.json'],
+ dropzoneAllowList: ['.JSON'],
};
},
i18n: {
@@ -23,7 +24,7 @@ export default {
"GooglePlay|Error: The file you're trying to upload is not a service account key.",
),
dropzoneConfirmMessage: s__('GooglePlay|Drag your key file to start the upload.'),
- dropzoneEmptyInputName: s__('GooglePlay|Service account key (.json)'),
+ dropzoneEmptyInputName: s__('GooglePlay|Service account key (.JSON)'),
dropzoneNonEmptyInputName: s__(
'GooglePlay|Upload a new service account key (replace %{currentFileName})',
),
diff --git a/app/assets/javascripts/integrations/edit/components/sections/jira_issues.vue b/app/assets/javascripts/integrations/edit/components/sections/jira_issues.vue
index 75202209d38..859d83df5e3 100644
--- a/app/assets/javascripts/integrations/edit/components/sections/jira_issues.vue
+++ b/app/assets/javascripts/integrations/edit/components/sections/jira_issues.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import JiraIssuesFields from '../jira_issues_fields.vue';
diff --git a/app/assets/javascripts/integrations/edit/components/sections/jira_trigger.vue b/app/assets/javascripts/integrations/edit/components/sections/jira_trigger.vue
index f36d3b1fbda..5e3d60e0489 100644
--- a/app/assets/javascripts/integrations/edit/components/sections/jira_trigger.vue
+++ b/app/assets/javascripts/integrations/edit/components/sections/jira_trigger.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import JiraTriggerFields from '../jira_trigger_fields.vue';
diff --git a/app/assets/javascripts/integrations/edit/components/sections/trigger.vue b/app/assets/javascripts/integrations/edit/components/sections/trigger.vue
index 00546671aa7..bc9adf5263a 100644
--- a/app/assets/javascripts/integrations/edit/components/sections/trigger.vue
+++ b/app/assets/javascripts/integrations/edit/components/sections/trigger.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import TriggerField from '../trigger_field.vue';
diff --git a/app/assets/javascripts/integrations/edit/components/trigger_field.vue b/app/assets/javascripts/integrations/edit/components/trigger_field.vue
index 57753c61587..0c5511bfebb 100644
--- a/app/assets/javascripts/integrations/edit/components/trigger_field.vue
+++ b/app/assets/javascripts/integrations/edit/components/trigger_field.vue
@@ -1,5 +1,6 @@
<script>
import { GlFormCheckbox, GlFormInput } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import {
diff --git a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
index 3820a87e5ad..96ce95856c9 100644
--- a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
@@ -1,5 +1,6 @@
<script>
import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import { placeholderForType } from 'jh_else_ce/integrations/constants';
diff --git a/app/assets/javascripts/integrations/edit/store/index.js b/app/assets/javascripts/integrations/edit/store/index.js
index a8375f345c6..4f3d13f59ba 100644
--- a/app/assets/javascripts/integrations/edit/store/index.js
+++ b/app/assets/javascripts/integrations/edit/store/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
diff --git a/app/assets/javascripts/integrations/index/components/integrations_list.vue b/app/assets/javascripts/integrations/index/components/integrations_list.vue
index 7331437d484..32a4e2ae718 100644
--- a/app/assets/javascripts/integrations/index/components/integrations_list.vue
+++ b/app/assets/javascripts/integrations/index/components/integrations_list.vue
@@ -1,4 +1,5 @@
<script>
+import { GlCard } from '@gitlab/ui';
import { s__ } from '~/locale';
import IntegrationsTable from './integrations_table.vue';
@@ -6,6 +7,7 @@ export default {
name: 'IntegrationsList',
components: {
IntegrationsTable,
+ GlCard,
},
props: {
integrations: {
@@ -40,20 +42,37 @@ export default {
<template>
<div>
- <h4>{{ $options.i18n.activeIntegrationsHeading }}</h4>
- <integrations-table
- class="gl-mb-7!"
- :integrations="integrationsGrouped.active"
- :empty-text="$options.i18n.activeTableEmptyText"
- show-updated-at
- data-testid="active-integrations-table"
- />
-
- <h4>{{ $options.i18n.inactiveIntegrationsHeading }}</h4>
- <integrations-table
- :integrations="integrationsGrouped.inactive"
- :empty-text="$options.i18n.inactiveTableEmptyText"
- data-testid="inactive-integrations-table"
- />
+ <gl-card
+ class="gl-new-card gl-overflow-hidden"
+ header-class="gl-new-card-header gl-border-b-0"
+ body-class="gl-new-card-body gl-px-0"
+ >
+ <template #header>
+ <h3 class="gl-new-card-title">{{ $options.i18n.activeIntegrationsHeading }}</h3>
+ </template>
+ <integrations-table
+ class="gl-mb-n2"
+ :integrations="integrationsGrouped.active"
+ :empty-text="$options.i18n.activeTableEmptyText"
+ show-updated-at
+ data-testid="active-integrations-table"
+ />
+ </gl-card>
+ <gl-card
+ class="gl-new-card gl-overflow-hidden"
+ header-class="gl-new-card-header gl-border-b-0"
+ body-class="gl-new-card-body gl-px-0"
+ >
+ <template #header>
+ <h3 class="gl-new-card-title">{{ $options.i18n.inactiveIntegrationsHeading }}</h3>
+ </template>
+ <integrations-table
+ class="gl-mb-n2"
+ inactive
+ :integrations="integrationsGrouped.inactive"
+ :empty-text="$options.i18n.inactiveTableEmptyText"
+ data-testid="inactive-integrations-table"
+ />
+ </gl-card>
</div>
</template>
diff --git a/app/assets/javascripts/integrations/index/components/integrations_table.vue b/app/assets/javascripts/integrations/index/components/integrations_table.vue
index 59a29f81727..eff64ed7c42 100644
--- a/app/assets/javascripts/integrations/index/components/integrations_table.vue
+++ b/app/assets/javascripts/integrations/index/components/integrations_table.vue
@@ -30,19 +30,30 @@ export default {
required: false,
default: undefined,
},
+ inactive: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
fields() {
- return [
+ if (this.filteredIntegrations.length === 0) {
+ return [];
+ }
+
+ const fields = [];
+
+ fields.push(
{
key: 'active',
label: '',
- thClass: 'gl-w-10',
+ thClass: 'gl-w-7',
},
{
key: 'title',
label: __('Integration'),
- thClass: 'gl-w-quarter',
+ thClass: 'gl-w-quarter gl-xs-w-full',
},
{
key: 'description',
@@ -50,12 +61,18 @@ export default {
thClass: 'gl-display-none d-sm-table-cell',
tdClass: 'gl-display-none d-sm-table-cell',
},
- {
+ );
+
+ if (!this.inactive && this.filteredIntegrations.length > 0) {
+ fields.push({
key: 'updated_at',
label: this.showUpdatedAt ? __('Last updated') : '',
- thClass: 'gl-w-20p',
- },
- ];
+ thClass: 'gl-w-20 gl-text-right',
+ tdClass: 'gl-text-right',
+ });
+ }
+
+ return fields;
},
filteredIntegrations() {
return this.integrations.filter(
diff --git a/app/assets/javascripts/invite_members/components/confetti.vue b/app/assets/javascripts/invite_members/components/confetti.vue
index 2e5744afcd4..562f935e2b3 100644
--- a/app/assets/javascripts/invite_members/components/confetti.vue
+++ b/app/assets/javascripts/invite_members/components/confetti.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import confetti from 'canvas-confetti';
diff --git a/app/assets/javascripts/invite_members/components/group_select.vue b/app/assets/javascripts/invite_members/components/group_select.vue
index 1369deae3f9..42257127bbc 100644
--- a/app/assets/javascripts/invite_members/components/group_select.vue
+++ b/app/assets/javascripts/invite_members/components/group_select.vue
@@ -1,5 +1,6 @@
<script>
import { GlAvatarLabeled, GlCollapsibleListbox } from '@gitlab/ui';
+import axios from 'axios';
import { debounce } from 'lodash';
import { s__ } from '~/locale';
import { getGroups, getDescendentGroups } from '~/rest_api';
@@ -42,6 +43,7 @@ export default {
searchTerm: '',
pagination: {},
infiniteScrollLoading: false,
+ activeApiRequestAbortController: null,
};
},
computed: {
@@ -61,15 +63,13 @@ export default {
methods: {
retrieveGroups: debounce(async function debouncedRetrieveGroups() {
this.isFetching = true;
-
try {
const response = await this.fetchGroups();
this.pagination = this.processPagination(response);
this.groups = this.processGroups(response);
- } catch {
- this.onApiError();
- } finally {
this.isFetching = false;
+ } catch (e) {
+ this.onApiError(e);
}
}, SEARCH_DELAY),
processGroups({ data }) {
@@ -98,16 +98,32 @@ export default {
this.retrieveGroups();
},
fetchGroups(options = {}) {
+ if (this.activeApiRequestAbortController !== null) {
+ this.activeApiRequestAbortController.abort();
+ }
+
+ this.activeApiRequestAbortController = new AbortController();
+
const combinedOptions = {
...this.$options.defaultFetchOptions,
...options,
};
+ const axiosConfig = {
+ signal: this.activeApiRequestAbortController.signal,
+ };
+
switch (this.groupsFilter) {
case GROUP_FILTERS.DESCENDANT_GROUPS:
- return getDescendentGroups(this.parentGroupId, this.searchTerm, combinedOptions);
+ return getDescendentGroups(
+ this.parentGroupId,
+ this.searchTerm,
+ combinedOptions,
+ undefined,
+ axiosConfig,
+ );
default:
- return getGroups(this.searchTerm, combinedOptions);
+ return getGroups(this.searchTerm, combinedOptions, undefined, axiosConfig);
}
},
async onBottomReached() {
@@ -117,13 +133,15 @@ export default {
const response = await this.fetchGroups({ page: this.pagination.page + 1 });
this.pagination = this.processPagination(response);
this.groups.push(...this.processGroups(response));
- } catch {
- this.onApiError();
- } finally {
this.infiniteScrollLoading = false;
+ } catch (e) {
+ this.onApiError(e);
}
},
- onApiError() {
+ onApiError(error) {
+ if (axios.isCancel(error)) return;
+ this.isFetching = false;
+ this.infiniteScrollLoading = false;
this.$emit('error', this.$options.i18n.errorMessage);
},
},
diff --git a/app/assets/javascripts/invite_members/components/import_project_members_modal.vue b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue
index 66d4a9ccc07..5599ad276f0 100644
--- a/app/assets/javascripts/invite_members/components/import_project_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue
@@ -1,5 +1,5 @@
<script>
-import { GlFormGroup, GlModal, GlSprintf } from '@gitlab/ui';
+import { GlFormGroup, GlModal, GlSprintf, GlAlert, GlCollapse, GlIcon, GlButton } from '@gitlab/ui';
import { uniqueId, isEmpty } from 'lodash';
import { importProjectMembers } from '~/api/projects_api';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
@@ -16,8 +16,10 @@ import {
PROJECT_SELECT_LABEL_ID,
IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_CATEGORY,
IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_LABEL,
+ MEMBER_MODAL_LABELS,
} from '../constants';
+import { responseFromSuccess } from '../utils/response_message_parser';
import UserLimitNotification from './user_limit_notification.vue';
import ProjectSelect from './project_select.vue';
@@ -27,6 +29,10 @@ export default {
GlFormGroup,
GlModal,
GlSprintf,
+ GlAlert,
+ GlCollapse,
+ GlIcon,
+ GlButton,
UserLimitNotification,
ProjectSelect,
},
@@ -60,6 +66,9 @@ export default {
return {
projectToBeImported: {},
invalidFeedbackMessage: '',
+ totalMembersCount: 0,
+ invalidMembers: {},
+ isErrorsSectionExpanded: false,
isLoading: false,
};
},
@@ -94,6 +103,40 @@ export default {
actionCancel() {
return { text: this.$options.i18n.modalCancelButton };
},
+ hasInvalidMembers() {
+ return !isEmpty(this.invalidMembers);
+ },
+ memberErrorTitle() {
+ return sprintf(
+ s__(
+ 'InviteMembersModal|The following %{errorMembersLength} out of %{totalMembersCount} members could not be added',
+ ),
+ { errorMembersLength: this.errorList.length, totalMembersCount: this.totalMembersCount },
+ );
+ },
+ errorList() {
+ return Object.entries(this.invalidMembers).map(([member, error]) => {
+ return { member, displayedMemberName: `@${member}`, message: error };
+ });
+ },
+ errorsLimited() {
+ return this.errorList.slice(0, this.$options.errorsLimit);
+ },
+ errorsExpanded() {
+ return this.errorList.slice(this.$options.errorsLimit);
+ },
+ shouldErrorsSectionExpand() {
+ return Boolean(this.errorsExpanded.length);
+ },
+ errorCollapseText() {
+ if (this.isErrorsSectionExpanded) {
+ return this.$options.labels.expandedErrors;
+ }
+
+ return sprintf(this.$options.labels.collapsedErrors, {
+ count: this.errorsExpanded.length,
+ });
+ },
},
mounted() {
if (this.reloadPageOnSubmit) {
@@ -113,21 +156,37 @@ export default {
this.$root.$emit(BV_HIDE_MODAL, this.$options.modalId);
},
resetFields() {
+ this.clearValidation();
this.invalidFeedbackMessage = '';
this.projectToBeImported = {};
},
- submitImport(e) {
+ async submitImport(event) {
// We never want to hide when submitting
- e.preventDefault();
+ event.preventDefault();
this.isLoading = true;
- return importProjectMembers(this.projectId, this.projectToBeImported.id)
- .then(this.onInviteSuccess)
- .catch(this.showErrorAlert)
- .finally(() => {
- this.isLoading = false;
- this.projectToBeImported = {};
- });
+
+ try {
+ const response = await importProjectMembers(this.projectId, this.projectToBeImported.id);
+
+ const { error, message } = responseFromSuccess(response);
+
+ if (error) {
+ this.totalMembersCount = response.data.total_members_count;
+ this.showMemberErrors(message);
+ } else {
+ this.onInviteSuccess();
+ }
+ } catch {
+ this.showErrorAlert();
+ } finally {
+ this.isLoading = false;
+ this.projectToBeImported = {};
+ }
+ },
+ showMemberErrors(message) {
+ this.invalidMembers = message;
+ this.$refs.alerts.focus();
},
onInviteSuccess() {
this.track('invite_successful');
@@ -151,6 +210,13 @@ export default {
onClose() {
this.track('click_x');
},
+ clearValidation() {
+ this.invalidFeedbackMessage = '';
+ this.invalidMembers = {};
+ },
+ toggleErrorExpansion() {
+ this.isErrorsSectionExpanded = !this.isErrorsSectionExpanded;
+ },
},
toastOptions() {
return {
@@ -173,8 +239,10 @@ export default {
defaultError: s__('ImportAProjectModal|Unable to import project members'),
successMessage: s__('ImportAProjectModal|Successfully imported'),
},
+ errorsLimit: 2,
projectSelectLabelId: PROJECT_SELECT_LABEL_ID,
modalId: uniqueId('import-a-project-modal-'),
+ labels: MEMBER_MODAL_LABELS,
};
</script>
@@ -186,18 +254,62 @@ export default {
:title="$options.i18n.modalTitle"
:action-primary="actionPrimary"
:action-cancel="actionCancel"
+ data-testid="import-project-members-modal"
no-focus-on-show
@primary="submitImport"
@hidden="resetFields"
@cancel="onCancel"
@close="onClose"
>
- <user-limit-notification
- v-if="showUserLimitNotification"
- class="gl-mb-5"
- :limit-variant="limitVariant"
- :users-limit-dataset="usersLimitDataset"
- />
+ <div ref="alerts" tabindex="-1">
+ <gl-alert
+ v-if="hasInvalidMembers"
+ class="gl-mb-4"
+ variant="danger"
+ :dismissible="false"
+ :title="memberErrorTitle"
+ data-testid="alert-member-error"
+ >
+ {{ $options.labels.memberErrorListText }}
+ <ul class="gl-pl-5 gl-mb-0">
+ <li v-for="error in errorsLimited" :key="error.member" data-testid="errors-limited-item">
+ <strong>{{ error.displayedMemberName }}:</strong> {{ error.message }}
+ </li>
+ </ul>
+ <template v-if="shouldErrorsSectionExpand">
+ <gl-collapse v-model="isErrorsSectionExpanded">
+ <ul class="gl-pl-5 gl-mb-0">
+ <li
+ v-for="error in errorsExpanded"
+ :key="error.member"
+ data-testid="errors-expanded-item"
+ >
+ <strong>{{ error.displayedMemberName }}:</strong> {{ error.message }}
+ </li>
+ </ul>
+ </gl-collapse>
+ <gl-button
+ class="gl-text-decoration-none! gl-shadow-none! gl-mt-3"
+ data-testid="accordion-button"
+ variant="link"
+ @click="toggleErrorExpansion"
+ >
+ {{ errorCollapseText }}
+ <gl-icon
+ name="chevron-down"
+ class="gl-transition-medium"
+ :class="{ 'gl-rotate-180': isErrorsSectionExpanded }"
+ />
+ </gl-button>
+ </template>
+ </gl-alert>
+ <user-limit-notification
+ v-else-if="showUserLimitNotification"
+ class="gl-mb-5"
+ :limit-variant="limitVariant"
+ :users-limit-dataset="usersLimitDataset"
+ />
+ </div>
<p ref="modalIntro">
<gl-sprintf :message="modalIntro">
<template #strong="{ content }">
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 1d061a4b81e..5d8f2ddfe15 100644
--- a/app/assets/javascripts/invite_members/components/user_limit_notification.vue
+++ b/app/assets/javascripts/invite_members/components/user_limit_notification.vue
@@ -4,15 +4,12 @@ import { n__, sprintf } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
import {
- INFO_ALERT_TITLE,
WARNING_ALERT_TITLE,
DANGER_ALERT_TITLE,
REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE,
REACHED_LIMIT_VARIANT,
CLOSE_TO_LIMIT_MESSAGE,
CLOSE_TO_LIMIT_VARIANT,
- NOTIFICATION_LIMIT_MESSAGE,
- NOTIFICATION_LIMIT_VARIANT,
} from '../constants';
export default {
@@ -32,15 +29,6 @@ export default {
computed: {
limitAttributes() {
return {
- [NOTIFICATION_LIMIT_VARIANT]: {
- variant: 'info',
- title: this.notificationTitle(
- INFO_ALERT_TITLE,
- this.name,
- this.usersLimitDataset.freeUsersLimit,
- ),
- message: this.message(NOTIFICATION_LIMIT_MESSAGE, this.usersLimitDataset.freeUsersLimit),
- },
[CLOSE_TO_LIMIT_VARIANT]: {
variant: 'warning',
title: this.title(WARNING_ALERT_TITLE, this.usersLimitDataset.remainingSeats),
@@ -55,13 +43,6 @@ export default {
},
},
methods: {
- notificationTitle(titleTemplate, namespaceName, dashboardLimit) {
- return sprintf(titleTemplate, {
- namespaceName,
- dashboardLimit,
- });
- },
-
title(titleTemplate, count) {
return sprintf(titleTemplate, {
count,
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index a4fe1a413aa..1cee0c32008 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -157,9 +157,6 @@ export const GROUP_MODAL_LABELS = {
export const ON_SHOW_TRACK_LABEL = 'over_limit_modal_viewed';
export const ON_CELEBRATION_TRACK_LABEL = 'invite_celebration_modal';
-export const INFO_ALERT_TITLE = s__(
- 'InviteMembersModal|Your top-level group %{namespaceName} is over the %{dashboardLimit} user limit.',
-);
export const WARNING_ALERT_TITLE = s__(
'InviteMembersModal|You only have space for %{count} more %{members} in %{name}',
);
@@ -169,7 +166,6 @@ export const DANGER_ALERT_TITLE = s__(
export const REACHED_LIMIT_VARIANT = 'reached';
export const CLOSE_TO_LIMIT_VARIANT = 'close';
-export const NOTIFICATION_LIMIT_VARIANT = 'notification';
export const REACHED_LIMIT_MESSAGE = s__(
'InviteMembersModal|To invite new users to this top-level group, you must remove existing users. You can still add existing users from the top-level group, including any subgroups and projects.',
@@ -184,7 +180,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 NOTIFICATION_LIMIT_MESSAGE = s__(
- 'InviteMembersModal|GitLab will enforce this limit in the future. If you are over %{dashboardLimit} users when enforcement begins, your top-level group will be placed in a %{freeUserLimitLinkStart}read-only state%{freeUserLimitLinkEnd}. To avoid being placed in a read-only state, reduce your top-level group to %{dashboardLimit} users or less, or purchase a paid tier.',
-);
diff --git a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
index eab7d01be14..a0854be099d 100644
--- a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
+++ b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
@@ -1,5 +1,6 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import { sprintf, __ } from '~/locale';
import { TYPE_ISSUE, TYPE_MERGE_REQUEST, WORKSPACE_PROJECT } from '~/issues/constants';
diff --git a/app/assets/javascripts/issuable/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable/issuable_bulk_update_actions.js
index e5a2388580b..45ea5c4827e 100644
--- a/app/assets/javascripts/issuable/issuable_bulk_update_actions.js
+++ b/app/assets/javascripts/issuable/issuable_bulk_update_actions.js
@@ -55,6 +55,7 @@ export default {
sprint_id: this.form.find('input[name="update[iteration_id]"]').val(),
add_label_ids: [],
remove_label_ids: [],
+ confidential: this.form.find('input[name="update[confidentiality]"]').val(),
},
};
if (assigneeIds) {
diff --git a/app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js
index 9c891bcfc9e..b45271c7fe1 100644
--- a/app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js
+++ b/app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js
@@ -4,6 +4,7 @@ import issuableEventHub from '~/issues/list/eventhub';
import LabelsSelect from '~/labels/labels_select';
import {
mountAssigneesDropdown,
+ mountConfidentialityDropdown,
mountMilestoneDropdown,
mountMoveIssuesButton,
mountStatusDropdown,
@@ -65,6 +66,7 @@ export default class IssuableBulkUpdateSidebar {
mountStatusDropdown();
mountSubscriptionsDropdown();
mountAssigneesDropdown();
+ mountConfidentialityDropdown();
// Checking IS_EE and using ee_else_ce is odd, but we do it here to satisfy
// the import/no-unresolved lint rule when FOSS_ONLY=1, even though at
diff --git a/app/assets/javascripts/issuable/issuable_template_selector.js b/app/assets/javascripts/issuable/issuable_template_selector.js
index 6b8f3de8d49..cc2da0a7105 100644
--- a/app/assets/javascripts/issuable/issuable_template_selector.js
+++ b/app/assets/javascripts/issuable/issuable_template_selector.js
@@ -1,9 +1,9 @@
import $ from 'jquery';
-import TemplateSelector from '~/blob/template_selector';
+import LegacyTemplateSelector from '~/blob/legacy_template_selector';
import { __ } from '~/locale';
import Api from '../api';
-export default class IssuableTemplateSelector extends TemplateSelector {
+export default class IssuableTemplateSelector extends LegacyTemplateSelector {
constructor(...args) {
super(...args);
diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js
index de0334b4ffe..98888f9f9b2 100644
--- a/app/assets/javascripts/issues/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js
@@ -137,8 +137,6 @@ export default class CreateMergeRequestDropdown {
this.refInput.addEventListener('keyup', this.onChangeInput.bind(this));
// Detect when user clicks inside the input to apply the suggested ref
this.refInput.addEventListener('click', this.onChangeInput.bind(this));
- // Detect when user clicks outside the input to apply the suggested ref
- this.refInput.addEventListener('blur', this.onChangeInput.bind(this));
// Detect when user presses tab to apply the suggested ref
this.refInput.addEventListener('keydown', CreateMergeRequestDropdown.processTab.bind(this));
}
@@ -178,8 +176,16 @@ export default class CreateMergeRequestDropdown {
createBranch(navigateToBranch = true) {
this.isCreatingBranch = true;
+ const endpoint = createEndpoint(
+ this.projectPath,
+ mergeUrlParams(
+ { ref: this.refInput.value, branch_name: this.branchInput.value },
+ this.createBranchPath,
+ ),
+ );
+
return axios
- .post(createEndpoint(this.projectPath, this.createBranchPath), {
+ .post(endpoint, {
confidential_issue_project_id: canCreateConfidentialMergeRequest() ? this.projectId : null,
})
.then(({ data }) => {
@@ -407,9 +413,6 @@ export default class CreateMergeRequestDropdown {
// If the input is empty, use the original value generated by the backend.
if (!value) {
- this.createBranchPath = this.wrapperEl.dataset.createBranchPath;
- this.createMrPath = this.wrapperEl.dataset.createMrPath;
-
if (target === INPUT_TARGET_BRANCH) {
this.branchIsValid = true;
} else {
@@ -539,7 +542,6 @@ export default class CreateMergeRequestDropdown {
updateBranchName(suggestedBranchName) {
this.branchInput.value = suggestedBranchName;
this.updateInputState(INPUT_TARGET_BRANCH, suggestedBranchName, '');
- this.updateCreatePaths(INPUT_TARGET_BRANCH, suggestedBranchName);
}
updateInputState(target, ref, result) {
@@ -561,7 +563,6 @@ export default class CreateMergeRequestDropdown {
if (ref === result) {
this.refIsValid = true;
this.showAvailableMessage(INPUT_TARGET_REF);
- this.updateCreatePaths(INPUT_TARGET_REF, ref);
} else {
this.refIsValid = false;
this.refInput.dataset.value = ref;
@@ -585,7 +586,6 @@ export default class CreateMergeRequestDropdown {
// Or user typed input contains invalid chars,
// that means a new branch cannot be created as it already exists.
this.showAvailableMessage(INPUT_TARGET_BRANCH, VALIDATION_TYPE_BRANCH_UNAVAILABLE);
- this.updateCreatePaths(INPUT_TARGET_BRANCH, ref);
} else if (isInvalidString) {
this.branchIsValid = false;
this.showNotAvailableMessage(INPUT_TARGET_BRANCH, VALIDATION_TYPE_INVALID_CHARS);
@@ -594,22 +594,4 @@ export default class CreateMergeRequestDropdown {
this.showNotAvailableMessage(INPUT_TARGET_BRANCH);
}
}
-
- // target - 'branch' or 'ref'
- // ref - string - the new value to use as branch or ref
- updateCreatePaths(target, ref) {
- const pathReplacement = `$1${encodeURIComponent(ref)}`;
-
- this.createBranchPath = this.createBranchPath.replace(
- this.regexps[target].createBranchPath,
- pathReplacement,
- );
- this.createMrPath = this.createMrPath.replace(
- this.regexps[target].createMrPath,
- pathReplacement,
- );
-
- this.wrapperEl.dataset.createBranchPath = this.createBranchPath;
- this.wrapperEl.dataset.createMrPath = this.createMrPath;
- }
}
diff --git a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
index eb73f8e0182..9febebf7e55 100644
--- a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
+++ b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
@@ -36,6 +36,7 @@ import { getParameterByName } from '~/lib/utils/url_utility';
import {
OPERATORS_IS,
OPERATORS_IS_NOT_OR,
+ OPERATORS_AFTER_BEFORE,
TOKEN_TITLE_ASSIGNEE,
TOKEN_TITLE_AUTHOR,
TOKEN_TITLE_CONFIDENTIAL,
@@ -44,6 +45,8 @@ import {
TOKEN_TITLE_MY_REACTION,
TOKEN_TITLE_SEARCH_WITHIN,
TOKEN_TITLE_TYPE,
+ TOKEN_TITLE_CREATED,
+ TOKEN_TITLE_CLOSED,
TOKEN_TYPE_ASSIGNEE,
TOKEN_TYPE_AUTHOR,
TOKEN_TYPE_CONFIDENTIAL,
@@ -52,6 +55,8 @@ import {
TOKEN_TYPE_MY_REACTION,
TOKEN_TYPE_SEARCH_WITHIN,
TOKEN_TYPE_TYPE,
+ TOKEN_TYPE_CREATED,
+ TOKEN_TYPE_CLOSED,
} from '~/vue_shared/components/filtered_search_bar/constants';
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
import { DEFAULT_PAGE_SIZE, issuableListTabs } from '~/vue_shared/issuable/list/constants';
@@ -63,6 +68,7 @@ const EmojiToken = () =>
import('~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue');
const LabelToken = () =>
import('~/vue_shared/components/filtered_search_bar/tokens/label_token.vue');
+const DateToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/date_token.vue');
const MilestoneToken = () =>
import('~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue');
@@ -89,6 +95,7 @@ export default {
'emptyStateWithoutFilterSvgPath',
'hasBlockedIssuesFeature',
'hasIssuableHealthStatusFeature',
+ 'hasIssueDateFilterFeature',
'hasIssueWeightsFeature',
'hasScopedLabelsFeature',
'initialSort',
@@ -318,6 +325,24 @@ export default {
fetchEmojis: this.fetchEmojis,
recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-my_reaction',
});
+
+ if (this.hasIssueDateFilterFeature) {
+ tokens.push({
+ type: TOKEN_TYPE_CREATED,
+ title: TOKEN_TITLE_CREATED,
+ icon: 'history',
+ token: DateToken,
+ operators: OPERATORS_AFTER_BEFORE,
+ });
+
+ tokens.push({
+ type: TOKEN_TYPE_CLOSED,
+ title: TOKEN_TITLE_CLOSED,
+ icon: 'history',
+ token: DateToken,
+ operators: OPERATORS_AFTER_BEFORE,
+ });
+ }
}
tokens.sort((a, b) => a.title.localeCompare(b.title));
diff --git a/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql
index 5c331fe95e2..51e38d44c85 100644
--- a/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql
+++ b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql
@@ -23,6 +23,10 @@ query getDashboardIssues(
$beforeCursor: String
$firstPageSize: Int
$lastPageSize: Int
+ $createdAfter: Time
+ $createdBefore: Time
+ $closedAfter: Time
+ $closedBefore: Time
) {
issues(
search: $search
@@ -44,6 +48,10 @@ query getDashboardIssues(
before: $beforeCursor
first: $firstPageSize
last: $lastPageSize
+ createdAfter: $createdAfter
+ createdBefore: $createdBefore
+ closedAfter: $closedAfter
+ closedBefore: $closedBefore
) @persist {
nodes {
__persist
diff --git a/app/assets/javascripts/issues/dashboard/queries/get_issues_counts.query.graphql b/app/assets/javascripts/issues/dashboard/queries/get_issues_counts.query.graphql
index b36f546e4ab..a91f15f0c04 100644
--- a/app/assets/javascripts/issues/dashboard/queries/get_issues_counts.query.graphql
+++ b/app/assets/javascripts/issues/dashboard/queries/get_issues_counts.query.graphql
@@ -12,6 +12,10 @@ query getDashboardIssuesCount(
$in: [IssuableSearchableField!]
$not: NegatedIssueFilterInput
$or: UnionedIssueFilterInput
+ $createdAfter: Time
+ $createdBefore: Time
+ $closedAfter: Time
+ $closedBefore: Time
) {
openedIssues: issues(
state: opened
@@ -28,6 +32,10 @@ query getDashboardIssuesCount(
in: $in
not: $not
or: $or
+ createdAfter: $createdAfter
+ createdBefore: $createdBefore
+ closedAfter: $closedAfter
+ closedBefore: $closedBefore
) {
count
}
@@ -46,6 +54,10 @@ query getDashboardIssuesCount(
in: $in
not: $not
or: $or
+ createdAfter: $createdAfter
+ createdBefore: $createdBefore
+ closedAfter: $closedAfter
+ closedBefore: $closedBefore
) {
count
}
@@ -64,6 +76,10 @@ query getDashboardIssuesCount(
in: $in
not: $not
or: $or
+ createdAfter: $createdAfter
+ createdBefore: $createdBefore
+ closedAfter: $closedAfter
+ closedBefore: $closedBefore
) {
count
}
diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js
index 4d2df9e3602..eec7c6bf842 100644
--- a/app/assets/javascripts/issues/index.js
+++ b/app/assets/javascripts/issues/index.js
@@ -9,12 +9,7 @@ import Issue from '~/issues/issue';
import { initTitleSuggestions, initTypePopover, initTypeSelect } from '~/issues/new';
import { initRelatedMergeRequests } from '~/issues/related_merge_requests';
import { initRelatedIssues } from '~/related_issues';
-import {
- initHeaderActions,
- initIncidentApp,
- initIssueApp,
- initSentryErrorStackTrace,
-} from '~/issues/show';
+import { initIncidentApp, initIssueApp, initSentryErrorStackTrace } from '~/issues/show';
import { parseIssuableData } from '~/issues/show/utils/parse_data';
import LabelsSelect from '~/labels/labels_select';
import initNotesApp from '~/notes';
@@ -58,12 +53,10 @@ export function initShow({ notesParams } = {}) {
if (issueType === TYPE_INCIDENT) {
initIncidentApp({ ...issuableData, issuableId: el.dataset.issuableId }, store);
- initHeaderActions(store, TYPE_INCIDENT);
initLinkedResources();
initRelatedIssues(TYPE_INCIDENT);
} else {
initIssueApp(issuableData, store);
- initHeaderActions(store);
}
new Issue(); // eslint-disable-line no-new
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 f7693dd7102..c50b48ca0d8 100644
--- a/app/assets/javascripts/issues/list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -6,8 +6,12 @@ import {
GlDisclosureDropdownGroup,
GlFilteredSearchToken,
GlTooltipDirective,
+ GlDrawer,
+ GlLink,
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
+
+import produce from 'immer';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { isEmpty } from 'lodash';
import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue';
@@ -17,6 +21,7 @@ import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_coun
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { createAlert, VARIANT_INFO } from '~/alert';
import { TYPENAME_USER } from '~/graphql_shared/constants';
+import usersAutocompleteQuery from '~/graphql_shared/queries/users_autocomplete.query.graphql';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
@@ -36,6 +41,7 @@ import {
OPERATORS_IS,
OPERATORS_IS_NOT,
OPERATORS_IS_NOT_OR,
+ OPERATORS_AFTER_BEFORE,
TOKEN_TITLE_ASSIGNEE,
TOKEN_TITLE_AUTHOR,
TOKEN_TITLE_CONFIDENTIAL,
@@ -47,6 +53,8 @@ import {
TOKEN_TITLE_RELEASE,
TOKEN_TITLE_SEARCH_WITHIN,
TOKEN_TITLE_TYPE,
+ TOKEN_TITLE_CREATED,
+ TOKEN_TITLE_CLOSED,
TOKEN_TYPE_ASSIGNEE,
TOKEN_TYPE_AUTHOR,
TOKEN_TYPE_CONFIDENTIAL,
@@ -58,11 +66,16 @@ import {
TOKEN_TYPE_RELEASE,
TOKEN_TYPE_SEARCH_WITHIN,
TOKEN_TYPE_TYPE,
+ TOKEN_TYPE_CREATED,
+ TOKEN_TYPE_CLOSED,
} from '~/vue_shared/components/filtered_search_bar/constants';
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
import { DEFAULT_PAGE_SIZE, issuableListTabs } from '~/vue_shared/issuable/list/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import NewResourceDropdown from '~/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue';
+import WorkItemDetail from '~/work_items/components/work_item_detail.vue';
+import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql';
+import { WORK_ITEM_TYPE_ENUM_OBJECTIVE } from '~/work_items/constants';
import {
CREATED_DESC,
defaultTypeTokenOptions,
@@ -98,6 +111,8 @@ import {
getSortKey,
getSortOptions,
isSortKey,
+ mapWorkItemWidgetsToIssueFields,
+ updateUpvotesCount,
} from '../utils';
import { hasNewIssueDropdown } from '../has_new_issue_dropdown_mixin';
import EmptyStateWithAnyIssues from './empty_state_with_any_issues.vue';
@@ -116,6 +131,7 @@ const CrmContactToken = () =>
import('~/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue');
const CrmOrganizationToken = () =>
import('~/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue');
+const DateToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/date_token.vue');
export default {
i18n,
@@ -131,12 +147,15 @@ export default {
EmptyStateWithoutAnyIssues,
GlButton,
GlButtonGroup,
+ GlDrawer,
IssuableByEmail,
IssuableList,
IssueCardStatistics,
IssueCardTimeInfo,
NewResourceDropdown,
LocalStorageSync,
+ WorkItemDetail,
+ GlLink,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -154,6 +173,7 @@ export default {
'hasAnyProjects',
'hasBlockedIssuesFeature',
'hasIssuableHealthStatusFeature',
+ 'hasIssueDateFilterFeature',
'hasIssueWeightsFeature',
'hasScopedLabelsFeature',
'initialEmail',
@@ -218,6 +238,7 @@ export default {
},
],
},
+ activeIssuable: null,
};
},
apollo: {
@@ -230,6 +251,7 @@ export default {
return data[this.namespace]?.issues.nodes ?? [];
},
fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
+ nextFetchPolicy: fetchPolicies.CACHE_FIRST,
// We need this for handling loading state when using frontend cache
// See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/106004#note_1217325202 for details
notifyOnNetworkStatusChange: true,
@@ -446,6 +468,24 @@ export default {
{ icon: 'eye', value: 'no', title: this.$options.i18n.confidentialNo },
],
});
+
+ if (this.hasIssueDateFilterFeature) {
+ tokens.push({
+ type: TOKEN_TYPE_CREATED,
+ title: TOKEN_TITLE_CREATED,
+ icon: 'history',
+ token: DateToken,
+ operators: OPERATORS_AFTER_BEFORE,
+ });
+
+ tokens.push({
+ type: TOKEN_TYPE_CLOSED,
+ title: TOKEN_TITLE_CLOSED,
+ icon: 'history',
+ token: DateToken,
+ operators: OPERATORS_AFTER_BEFORE,
+ });
+ }
}
if (this.canReadCrmContact) {
@@ -536,6 +576,12 @@ export default {
isGridView() {
return this.viewType === ISSUES_GRID_VIEW_KEY;
},
+ isIssuableSelected() {
+ return !isEmpty(this.activeIssuable);
+ },
+ issuesDrawerEnabled() {
+ return this.glFeatures?.issuesListDrawer;
+ },
},
watch: {
$route(newValue, oldValue) {
@@ -603,6 +649,15 @@ export default {
.then(({ data }) => data[this.namespace]?.milestones.nodes);
},
fetchUsers(search) {
+ if (gon.features?.newGraphqlUsersAutocomplete) {
+ return this.$apollo
+ .query({
+ query: usersAutocompleteQuery,
+ variables: { fullPath: this.fullPath, search, isProject: this.isProject },
+ })
+ .then(({ data }) => data[this.namespace]?.autocompleteUsers);
+ }
+
return this.$apollo
.query({
query: searchUsersQuery,
@@ -805,12 +860,108 @@ export default {
// The default view is list view
this.viewType = ISSUES_LIST_VIEW_KEY;
},
+ handleSelectIssuable(issuable) {
+ this.activeIssuable = issuable;
+ },
+ updateIssuablesCache(workItem) {
+ const client = this.$apollo.provider.clients.defaultClient;
+ const issuesList = client.readQuery({
+ query: getIssuesQuery,
+ variables: this.queryVariables,
+ });
+
+ const activeIssuable = issuesList.project.issues.nodes.find(
+ (issue) => issue.iid === workItem.iid,
+ );
+
+ // when we change issuable state, it's moved to a different tab
+ // to ensure that we show 20 items of the first page, we need to refetch issuables
+ if (!activeIssuable.state.includes(workItem.state.toLowerCase())) {
+ this.refetchIssuables();
+ return;
+ }
+
+ // handle all other widgets
+ const data = mapWorkItemWidgetsToIssueFields(issuesList, workItem);
+
+ client.writeQuery({ query: getIssuesQuery, variables: this.queryVariables, data });
+ },
+ promoteToObjective(workItemIid) {
+ const { cache } = this.$apollo.provider.clients.defaultClient;
+
+ cache.updateQuery({ query: getIssuesQuery, variables: this.queryVariables }, (issuesList) =>
+ produce(issuesList, (draftData) => {
+ const activeItem = draftData.project.issues.nodes.find(
+ (issue) => issue.iid === workItemIid,
+ );
+
+ activeItem.type = WORK_ITEM_TYPE_ENUM_OBJECTIVE;
+ }),
+ );
+ },
+ refetchIssuables() {
+ this.$apollo.queries.issues.refetch();
+ this.$apollo.queries.issuesCounts.refetch();
+ },
+ deleteIssuable({ workItemId }) {
+ this.$apollo
+ .mutate({
+ mutation: deleteWorkItemMutation,
+ variables: { input: { id: workItemId } },
+ })
+ .then(({ data }) => {
+ if (data.workItemDelete.errors?.length) {
+ throw new Error(data.workItemDelete.errors[0]);
+ }
+ this.activeIssuable = null;
+ this.refetchIssuables();
+ })
+ .catch((error) => {
+ this.issuesError = this.$options.i18n.deleteError;
+ Sentry.captureException(error);
+ });
+ },
+ updateIssuableEmojis(workItem) {
+ const client = this.$apollo.provider.clients.defaultClient;
+ const issuesList = client.readQuery({
+ query: getIssuesQuery,
+ variables: this.queryVariables,
+ });
+
+ const data = updateUpvotesCount(issuesList, workItem);
+
+ client.writeQuery({ query: getIssuesQuery, variables: this.queryVariables, data });
+ },
},
};
</script>
<template>
<div>
+ <gl-drawer
+ v-if="issuesDrawerEnabled"
+ :open="isIssuableSelected"
+ header-height="calc(var(--top-bar-height) + var(--performance-bar-height))"
+ class="gl-w-40p gl-xs-w-full"
+ @close="activeIssuable = null"
+ >
+ <template #title>
+ <gl-link :href="activeIssuable.webUrl" class="gl-text-black-normal">{{
+ __('Open full view')
+ }}</gl-link>
+ </template>
+ <template #default>
+ <work-item-detail
+ :key="activeIssuable.iid"
+ :work-item-iid="activeIssuable.iid"
+ @work-item-updated="updateIssuablesCache"
+ @work-item-emoji-updated="updateIssuableEmojis"
+ @addChild="refetchIssuables"
+ @deleteWorkItem="deleteIssuable"
+ @promotedToObjective="promoteToObjective"
+ />
+ </template>
+ </gl-drawer>
<issuable-list
v-if="hasAnyIssues"
:namespace="fullPath"
@@ -840,7 +991,9 @@ export default {
:has-previous-page="pageInfo.hasPreviousPage"
:show-filtered-search-friendly-text="hasOrFeature"
:is-grid-view="isGridView"
+ :active-issuable="activeIssuable"
show-work-item-type-icon
+ :prevent-redirect="issuesDrawerEnabled"
@click-tab="handleClickTab"
@dismiss-alert="handleDismissAlert"
@filter="handleFilter"
@@ -850,6 +1003,7 @@ export default {
@sort="handleSort"
@update-legacy-bulk-edit="handleUpdateLegacyBulkEdit"
@page-size-change="handlePageSizeChange"
+ @select-issuable="handleSelectIssuable"
>
<template #nav-actions>
<local-storage-sync
diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js
index 1a3d97277c7..85e300b6474 100644
--- a/app/assets/javascripts/issues/list/constants.js
+++ b/app/assets/javascripts/issues/list/constants.js
@@ -9,6 +9,8 @@ import {
OPERATOR_IS,
OPERATOR_NOT,
OPERATOR_OR,
+ OPERATOR_AFTER,
+ OPERATOR_BEFORE,
TOKEN_TYPE_ASSIGNEE,
TOKEN_TYPE_AUTHOR,
TOKEN_TYPE_CONFIDENTIAL,
@@ -24,6 +26,8 @@ import {
TOKEN_TYPE_TYPE,
TOKEN_TYPE_WEIGHT,
TOKEN_TYPE_SEARCH_WITHIN,
+ TOKEN_TYPE_CREATED,
+ TOKEN_TYPE_CLOSED,
} from '~/vue_shared/components/filtered_search_bar/constants';
import {
WORK_ITEM_TYPE_ENUM_INCIDENT,
@@ -115,6 +119,7 @@ export const i18n = {
noSearchResultsTitle: __('Sorry, your filter produced no results'),
relatedMergeRequests: __('Related merge requests'),
reorderError: __('An error occurred while reordering issues.'),
+ deleteError: __('An error occurred while deleting an issuable.'),
rssLabel: __('Subscribe to RSS feed'),
searchPlaceholder: __('Search or filter results...'),
upvotes: __('Upvotes'),
@@ -415,4 +420,32 @@ export const filtersMap = {
},
},
},
+ [TOKEN_TYPE_CREATED]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'createdBefore',
+ [ALTERNATIVE_FILTER]: 'createdAfter',
+ },
+ [URL_PARAM]: {
+ [OPERATOR_AFTER]: {
+ [ALTERNATIVE_FILTER]: 'created_after',
+ },
+ [OPERATOR_BEFORE]: {
+ [NORMAL_FILTER]: 'created_before',
+ },
+ },
+ },
+ [TOKEN_TYPE_CLOSED]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'closedBefore',
+ [ALTERNATIVE_FILTER]: 'closedAfter',
+ },
+ [URL_PARAM]: {
+ [OPERATOR_AFTER]: {
+ [ALTERNATIVE_FILTER]: 'closed_after',
+ },
+ [OPERATOR_BEFORE]: {
+ [NORMAL_FILTER]: 'closed_before',
+ },
+ },
+ },
};
diff --git a/app/assets/javascripts/issues/list/graphql.js b/app/assets/javascripts/issues/list/graphql.js
index e64870152bd..6e9a566cb5c 100644
--- a/app/assets/javascripts/issues/list/graphql.js
+++ b/app/assets/javascripts/issues/list/graphql.js
@@ -1,6 +1,7 @@
import produce from 'immer';
import createDefaultClient, { createApolloClientWithCaching } from '~/lib/graphql';
import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql';
+import { config } from '~/graphql_shared/issuable_client';
let client;
@@ -27,7 +28,7 @@ const resolvers = {
export async function gqlClient() {
if (client) return client;
client = gon.features?.frontendCaching
- ? await createApolloClientWithCaching(resolvers, { localCacheKey: 'issues_list' })
- : createDefaultClient(resolvers);
+ ? await createApolloClientWithCaching(resolvers, { localCacheKey: 'issues_list', ...config })
+ : createDefaultClient(resolvers, config);
return client;
}
diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js
index d1b45294026..8c60ad6dc4e 100644
--- a/app/assets/javascripts/issues/list/index.js
+++ b/app/assets/javascripts/issues/list/index.js
@@ -71,6 +71,7 @@ export async function mountIssuesListApp() {
hasAnyProjects,
hasBlockedIssuesFeature,
hasIssuableHealthStatusFeature,
+ hasIssueDateFilterFeature,
hasIssueWeightsFeature,
hasIterationsFeature,
hasScopedLabelsFeature,
@@ -95,6 +96,8 @@ export async function mountIssuesListApp() {
showNewIssueLink,
signInPath,
groupId = '',
+ reportAbusePath,
+ registerPath,
} = el.dataset;
return new Vue({
@@ -117,11 +120,15 @@ export async function mountIssuesListApp() {
canReadCrmOrganization: parseBoolean(canReadCrmOrganization),
emptyStateSvgPath,
fullPath,
+ projectPath: fullPath,
groupPath,
+ reportAbusePath,
+ registerPath,
hasAnyIssues: parseBoolean(hasAnyIssues),
hasAnyProjects: parseBoolean(hasAnyProjects),
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
+ hasIssueDateFilterFeature: parseBoolean(hasIssueDateFilterFeature),
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
hasIterationsFeature: parseBoolean(hasIterationsFeature),
hasScopedLabelsFeature: parseBoolean(hasScopedLabelsFeature),
diff --git a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql
index 1018848fb53..23410ea0f81 100644
--- a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql
@@ -30,6 +30,10 @@ query getIssues(
$afterCursor: String
$firstPageSize: Int
$lastPageSize: Int
+ $createdAfter: Time
+ $createdBefore: Time
+ $closedAfter: Time
+ $closedBefore: Time
) {
group(fullPath: $fullPath) @skip(if: $isProject) @persist {
id
@@ -57,6 +61,10 @@ query getIssues(
after: $afterCursor
first: $firstPageSize
last: $lastPageSize
+ createdAfter: $createdAfter
+ createdBefore: $createdBefore
+ closedAfter: $closedAfter
+ closedBefore: $closedBefore
) {
__persist
pageInfo {
@@ -96,6 +104,10 @@ query getIssues(
after: $afterCursor
first: $firstPageSize
last: $lastPageSize
+ createdAfter: $createdAfter
+ createdBefore: $createdBefore
+ closedAfter: $closedAfter
+ closedBefore: $closedBefore
) {
__persist
pageInfo {
diff --git a/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql
index fdb0eeb5970..7953dc423b6 100644
--- a/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql
@@ -18,6 +18,10 @@ query getIssuesCount(
$crmOrganizationId: String
$not: NegatedIssueFilterInput
$or: UnionedIssueFilterInput
+ $createdAfter: Time
+ $createdBefore: Time
+ $closedAfter: Time
+ $closedBefore: Time
) {
group(fullPath: $fullPath) @skip(if: $isProject) {
id
@@ -39,6 +43,10 @@ query getIssuesCount(
crmOrganizationId: $crmOrganizationId
not: $not
or: $or
+ createdAfter: $createdAfter
+ createdBefore: $createdBefore
+ closedAfter: $closedAfter
+ closedBefore: $closedBefore
) {
count
}
@@ -60,6 +68,10 @@ query getIssuesCount(
crmOrganizationId: $crmOrganizationId
not: $not
or: $or
+ createdAfter: $createdAfter
+ createdBefore: $createdBefore
+ closedAfter: $closedAfter
+ closedBefore: $closedBefore
) {
count
}
@@ -81,6 +93,10 @@ query getIssuesCount(
crmOrganizationId: $crmOrganizationId
not: $not
or: $or
+ createdAfter: $createdAfter
+ createdBefore: $createdBefore
+ closedAfter: $closedAfter
+ closedBefore: $closedBefore
) {
count
}
@@ -106,6 +122,10 @@ query getIssuesCount(
crmOrganizationId: $crmOrganizationId
not: $not
or: $or
+ createdAfter: $createdAfter
+ createdBefore: $createdBefore
+ closedAfter: $closedAfter
+ closedBefore: $closedBefore
) {
count
}
@@ -128,6 +148,10 @@ query getIssuesCount(
crmOrganizationId: $crmOrganizationId
not: $not
or: $or
+ createdAfter: $createdAfter
+ createdBefore: $createdBefore
+ closedAfter: $closedAfter
+ closedBefore: $closedBefore
) {
count
}
@@ -150,6 +174,10 @@ query getIssuesCount(
crmOrganizationId: $crmOrganizationId
not: $not
or: $or
+ createdAfter: $createdAfter
+ createdBefore: $createdBefore
+ closedAfter: $closedAfter
+ closedBefore: $closedBefore
) {
count
}
diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js
index d053400dd03..37df0c8f9ff 100644
--- a/app/assets/javascripts/issues/list/utils.js
+++ b/app/assets/javascripts/issues/list/utils.js
@@ -1,3 +1,4 @@
+import produce from 'immer';
import { isPositiveInteger } from '~/lib/utils/number_utils';
import { getParameterByName } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
@@ -5,6 +6,7 @@ import {
FILTERED_SEARCH_TERM,
OPERATOR_NOT,
OPERATOR_OR,
+ OPERATOR_AFTER,
TOKEN_TYPE_ASSIGNEE,
TOKEN_TYPE_AUTHOR,
TOKEN_TYPE_CONFIDENTIAL,
@@ -17,6 +19,15 @@ import {
} from '~/vue_shared/components/filtered_search_bar/constants';
import { DEFAULT_PAGE_SIZE } from '~/vue_shared/issuable/list/constants';
import {
+ WORK_ITEM_TO_ISSUE_MAP,
+ WIDGET_TYPE_MILESTONE,
+ WIDGET_TYPE_AWARD_EMOJI,
+ EMOJI_THUMBSUP,
+ EMOJI_THUMBSDOWN,
+ WIDGET_TYPE_ASSIGNEES,
+ WIDGET_TYPE_LABELS,
+} from '~/work_items/constants';
+import {
ALTERNATIVE_FILTER,
API_PARAM,
BLOCKING_ISSUES_ASC,
@@ -222,10 +233,10 @@ export const getFilterTokens = (locationSearch) =>
};
});
-const isNotEmptySearchToken = (token) =>
+export const isNotEmptySearchToken = (token) =>
!(token.type === FILTERED_SEARCH_TERM && !token.value.data);
-const isSpecialFilter = (type, data) => {
+export const isSpecialFilter = (type, data) => {
const isAssigneeIdParam =
type === TOKEN_TYPE_ASSIGNEE &&
isPositiveInteger(data) &&
@@ -236,8 +247,9 @@ const isSpecialFilter = (type, data) => {
const getFilterType = ({ type, value: { data, operator } }) => {
const isUnionedAuthor = type === TOKEN_TYPE_AUTHOR && operator === OPERATOR_OR;
const isUnionedLabel = type === TOKEN_TYPE_LABEL && operator === OPERATOR_OR;
+ const isAfter = operator === OPERATOR_AFTER;
- if (isUnionedAuthor || isUnionedLabel) {
+ if (isUnionedAuthor || isUnionedLabel || isAfter) {
return ALTERNATIVE_FILTER;
}
if (isSpecialFilter(type, data)) {
@@ -318,3 +330,67 @@ export const convertToSearchQuery = (filterTokens) =>
.filter((token) => token.type === FILTERED_SEARCH_TERM && token.value.data)
.map((token) => token.value.data)
.join(' ') || undefined;
+
+function findWidget(type, workItem) {
+ return workItem?.widgets?.find((widget) => widget.type === type);
+}
+
+export function mapWorkItemWidgetsToIssueFields(issuesList, workItem) {
+ return produce(issuesList, (draftData) => {
+ const activeItem = draftData.project.issues.nodes.find((issue) => issue.iid === workItem.iid);
+
+ Object.keys(WORK_ITEM_TO_ISSUE_MAP).forEach((type) => {
+ const currentWidget = findWidget(type, workItem);
+ if (!currentWidget) {
+ return;
+ }
+ const property = WORK_ITEM_TO_ISSUE_MAP[type];
+
+ // handling the case for assignees and labels
+ if (
+ property === WORK_ITEM_TO_ISSUE_MAP[WIDGET_TYPE_ASSIGNEES] ||
+ property === WORK_ITEM_TO_ISSUE_MAP[WIDGET_TYPE_LABELS]
+ ) {
+ activeItem[property] = {
+ ...currentWidget[property],
+ nodes: currentWidget[property].nodes.map((node) => ({
+ __persist: true,
+ ...node,
+ })),
+ };
+ return;
+ }
+
+ // handling the case for milestone
+ if (property === WORK_ITEM_TO_ISSUE_MAP[WIDGET_TYPE_MILESTONE] && currentWidget[property]) {
+ activeItem[property] = { __persist: true, ...currentWidget[property] };
+ return;
+ }
+ activeItem[property] = currentWidget[property];
+ });
+
+ activeItem.title = workItem.title;
+ activeItem.confidential = workItem.confidential;
+ });
+}
+
+export function updateUpvotesCount(issuesList, workItem) {
+ const type = WIDGET_TYPE_AWARD_EMOJI;
+ const property = WORK_ITEM_TO_ISSUE_MAP[type];
+
+ return produce(issuesList, (draftData) => {
+ const activeItem = draftData.project.issues.nodes.find((issue) => issue.iid === workItem.iid);
+
+ const currentWidget = findWidget(type, workItem);
+ if (!currentWidget) {
+ return;
+ }
+
+ const upvotesCount =
+ currentWidget[property].nodes.filter((emoji) => emoji.name === EMOJI_THUMBSUP)?.length ?? 0;
+ const downvotesCount =
+ currentWidget[property].nodes.filter((emoji) => emoji.name === EMOJI_THUMBSDOWN)?.length ?? 0;
+ activeItem.upvotes = upvotesCount;
+ activeItem.downvotes = downvotesCount;
+ });
+}
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 cbec10b4ebe..d819a371c69 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
@@ -1,5 +1,6 @@
<script>
import { GlLink, GlLoadingIcon, GlIcon } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
import { sprintf, __, n__ } from '~/locale';
import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue';
diff --git a/app/assets/javascripts/issues/related_merge_requests/store/index.js b/app/assets/javascripts/issues/related_merge_requests/store/index.js
index 925cc36cd76..b0bf8986547 100644
--- a/app/assets/javascripts/issues/related_merge_requests/store/index.js
+++ b/app/assets/javascripts/issues/related_merge_requests/store/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import mutations from './mutations';
diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue
index fcdf1f7741b..26c3db647a3 100644
--- a/app/assets/javascripts/issues/show/components/app.vue
+++ b/app/assets/javascripts/issues/show/components/app.vue
@@ -23,6 +23,8 @@ import Store from '../stores';
import DescriptionComponent from './description.vue';
import EditedComponent from './edited.vue';
import FormComponent from './form.vue';
+import HeaderActions from './header_actions.vue';
+import IssueHeader from './issue_header.vue';
import PinnedLinks from './pinned_links.vue';
import TitleComponent from './title.vue';
@@ -32,6 +34,8 @@ export default {
GlIcon,
GlBadge,
GlIntersectionObserver,
+ HeaderActions,
+ IssueHeader,
TitleComponent,
EditedComponent,
FormComponent,
@@ -42,6 +46,11 @@ export default {
GlTooltip: GlTooltipDirective,
},
props: {
+ author: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
endpoint: {
required: true,
type: String,
@@ -54,6 +63,11 @@ export default {
required: true,
type: Boolean,
},
+ createdAt: {
+ type: String,
+ required: false,
+ default: '',
+ },
enableAutocomplete: {
type: Boolean,
required: false,
@@ -193,6 +207,31 @@ export default {
required: false,
default: null,
},
+ duplicatedToIssueUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ movedToIssueUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ promotedToEpicUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ isFirstContribution: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ serviceDeskReplyTo: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
const store = new Store({
@@ -222,6 +261,9 @@ export default {
},
},
computed: {
+ headerClasses() {
+ return this.issuableType === TYPE_INCIDENT ? 'gl-mb-3' : 'gl-mb-6';
+ },
issuableTemplates() {
return this.store.formState.issuableTemplates;
},
@@ -259,10 +301,10 @@ export default {
: '';
},
statusIcon() {
- if (this.issuableType === TYPE_ISSUE) {
- return this.isClosed ? 'issue-closed' : 'issues';
+ if (this.issuableType === TYPE_EPIC) {
+ return this.isClosed ? 'epic-closed' : 'epic';
}
- return this.isClosed ? 'epic-closed' : 'epic';
+ return this.isClosed ? 'issue-closed' : 'issues';
},
statusVariant() {
return this.isClosed ? 'info' : 'success';
@@ -271,7 +313,7 @@ export default {
return issuableStatusText[this.issuableStatus];
},
shouldShowStickyHeader() {
- return [TYPE_ISSUE, TYPE_EPIC].includes(this.issuableType);
+ return [TYPE_INCIDENT, TYPE_ISSUE, TYPE_EPIC].includes(this.issuableType);
},
},
created() {
@@ -509,7 +551,13 @@ export default {
:can-update="canUpdate"
:title-html="state.titleHtml"
:title-text="state.titleText"
- />
+ >
+ <template #actions>
+ <slot name="actions">
+ <header-actions />
+ </slot>
+ </template>
+ </title-component>
<gl-intersection-observer
v-if="shouldShowStickyHeader"
@@ -567,6 +615,25 @@ export default {
</transition>
</gl-intersection-observer>
+ <slot name="header">
+ <issue-header
+ class="gl-p-0 gl-mt-2 gl-sm-mt-0"
+ :class="headerClasses"
+ :author="author"
+ :confidential="isConfidential"
+ :created-at="createdAt"
+ :duplicated-to-issue-url="duplicatedToIssueUrl"
+ :is-first-contribution="isFirstContribution"
+ :is-hidden="isHidden"
+ :is-locked="isLocked"
+ :issuable-state="issuableStatus"
+ :issuable-type="issuableType"
+ :moved-to-issue-url="movedToIssueUrl"
+ :promoted-to-epic-url="promotedToEpicUrl"
+ :service-desk-reply-to="serviceDeskReplyTo"
+ />
+ </slot>
+
<pinned-links
:zoom-meeting-url="zoomMeetingUrl"
:published-incident-url="publishedIncidentUrl"
diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue
index 3bf4dfc7a99..90f01603f96 100644
--- a/app/assets/javascripts/issues/show/components/description.vue
+++ b/app/assets/javascripts/issues/show/components/description.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlToast } from '@gitlab/ui';
import Sortable from 'sortablejs';
diff --git a/app/assets/javascripts/issues/show/components/edited.vue b/app/assets/javascripts/issues/show/components/edited.vue
index 6a0edb59b65..73dbf5bc77e 100644
--- a/app/assets/javascripts/issues/show/components/edited.vue
+++ b/app/assets/javascripts/issues/show/components/edited.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlLink, GlSprintf } from '@gitlab/ui';
import { n__, sprintf } from '~/locale';
diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue
index a1463d0e911..efe1619ed1f 100644
--- a/app/assets/javascripts/issues/show/components/fields/description.vue
+++ b/app/assets/javascripts/issues/show/components/fields/description.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { __ } from '~/locale';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
diff --git a/app/assets/javascripts/issues/show/components/fields/title.vue b/app/assets/javascripts/issues/show/components/fields/title.vue
index 58d32256da4..25801b3307c 100644
--- a/app/assets/javascripts/issues/show/components/fields/title.vue
+++ b/app/assets/javascripts/issues/show/components/fields/title.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import updateMixin from '../../mixins/update';
diff --git a/app/assets/javascripts/issues/show/components/fields/type.vue b/app/assets/javascripts/issues/show/components/fields/type.vue
index 576d157e0fc..4ab49e5df38 100644
--- a/app/assets/javascripts/issues/show/components/fields/type.vue
+++ b/app/assets/javascripts/issues/show/components/fields/type.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlFormGroup, GlIcon, GlCollapsibleListbox } from '@gitlab/ui';
import { TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants';
diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue
index 831248d9603..047bdcdcefc 100644
--- a/app/assets/javascripts/issues/show/components/form.vue
+++ b/app/assets/javascripts/issues/show/components/form.vue
@@ -1,9 +1,10 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlAlert } from '@gitlab/ui';
import { getDraft, updateDraft, getLockVersion, clearDraft } from '~/lib/utils/autosave';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPENAME_ISSUE, TYPENAME_USER } from '~/graphql_shared/constants';
-import { TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../event_hub';
import EditActions from './edit_actions.vue';
@@ -106,8 +107,8 @@ export default {
showLockedWarning() {
return this.formState.lockedWarningVisible && !this.formState.updateLoading;
},
- isIssueType() {
- return this.issuableType === TYPE_ISSUE;
+ showTypeField() {
+ return [TYPE_INCIDENT, TYPE_ISSUE].includes(this.issuableType);
},
resourceId() {
return this.issueId && convertToGraphQLId(TYPENAME_ISSUE, this.issueId);
@@ -201,7 +202,7 @@ export default {
</div>
</div>
<div class="row gl-gap-3">
- <div v-if="isIssueType" class="col-12 col-md-4 pr-md-0">
+ <div v-if="showTypeField" class="col-12 col-md-4 pr-md-0">
<issuable-type-field ref="issue-type" />
</div>
diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue
index 719f252781d..1ade5e654e9 100644
--- a/app/assets/javascripts/issues/show/components/header_actions.vue
+++ b/app/assets/javascripts/issues/show/components/header_actions.vue
@@ -10,6 +10,7 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import { createAlert, VARIANT_SUCCESS } from '~/alert';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
@@ -146,7 +147,7 @@ export default {
variables() {
return {
fullPath: this.fullPath,
- iid: this.iid,
+ iid: String(this.iid),
};
},
update(data) {
@@ -289,7 +290,7 @@ export default {
mutation: promoteToEpicMutation,
variables: {
input: {
- iid: this.iid,
+ iid: String(this.iid),
projectPath: this.projectPath,
},
},
@@ -374,6 +375,7 @@ export default {
<template v-if="isMrSidebarMoved">
<gl-dropdown-item
:data-clipboard-text="issuableReference"
+ button-class="js-copy-reference"
data-testid="copy-reference"
@click="copyReference"
>{{ $options.i18n.copyReferenceText }}</gl-dropdown-item
@@ -418,7 +420,7 @@ export default {
v-gl-tooltip.bottom
:title="$options.i18n.editTitleAndDescription"
:aria-label="$options.i18n.editTitleAndDescription"
- class="js-issuable-edit gl-display-none gl-sm-display-block"
+ class="js-issuable-edit gl-display-none! gl-sm-display-block!"
data-testid="edit-button"
@click="edit"
>
@@ -464,6 +466,7 @@ export default {
</template>
<gl-dropdown-item
v-if="showToggleIssueStateButton && glFeatures.moveCloseIntoDropdown"
+ data-testid="toggle-issue-state-button"
@click="toggleIssueState"
>
{{ buttonText }}
@@ -485,6 +488,7 @@ export default {
<template v-if="isMrSidebarMoved">
<gl-dropdown-item
:data-clipboard-text="issuableReference"
+ button-class="js-copy-reference"
data-testid="copy-reference"
@click="copyReference"
>{{ $options.i18n.copyReferenceText }}</gl-dropdown-item
@@ -517,7 +521,7 @@ export default {
<gl-dropdown-item
v-gl-modal="$options.deleteModalId"
variant="danger"
- data-testid="delete_issue_button"
+ data-testid="delete-issue-button"
@click="track('click_dropdown')"
>
{{ deleteButtonText }}
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 2a59b7a2042..1905678209f 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
@@ -270,7 +270,6 @@ export default {
v-if="showSaveAndAdd"
variant="confirm"
category="secondary"
- data-testid="save-and-add-button"
:disabled="!isTimelineTextValid"
:loading="isEventProcessed"
@click="handleSave(true)"
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 b776822bd9a..8da4f0f44e9 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
@@ -76,7 +76,7 @@ export default {
>
<gl-icon :name="getEventIcon(action)" class="note-icon" />
</div>
- <div class="timeline-event-note timeline-event-border" data-testid="event-text-container">
+ <div class="timeline-event-note timeline-event-border">
<div class="gl-display-flex gl-flex-wrap gl-align-items-center gl-gap-3 gl-mb-2">
<h3
class="timeline-event-note-date gl-font-weight-bold gl-font-sm gl-my-0"
diff --git a/app/assets/javascripts/issues/show/components/issue_header.vue b/app/assets/javascripts/issues/show/components/issue_header.vue
new file mode 100644
index 00000000000..211f3217ddc
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/issue_header.vue
@@ -0,0 +1,128 @@
+<script>
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { STATUS_OPEN, STATUS_REOPENED, WORKSPACE_PROJECT } from '~/issues/constants';
+import { __, s__ } from '~/locale';
+import IssuableHeader from '~/vue_shared/issuable/show/components/issuable_header.vue';
+
+export default {
+ WORKSPACE_PROJECT,
+ components: {
+ GlLink,
+ GlSprintf,
+ IssuableHeader,
+ },
+ props: {
+ author: {
+ type: Object,
+ required: true,
+ },
+ confidential: {
+ type: Boolean,
+ required: true,
+ },
+ createdAt: {
+ type: String,
+ required: true,
+ },
+ duplicatedToIssueUrl: {
+ type: String,
+ required: true,
+ },
+ isFirstContribution: {
+ type: Boolean,
+ required: true,
+ },
+ isHidden: {
+ type: Boolean,
+ required: true,
+ },
+ isLocked: {
+ type: Boolean,
+ required: true,
+ },
+ issuableState: {
+ type: String,
+ required: true,
+ },
+ issuableType: {
+ type: String,
+ required: true,
+ },
+ movedToIssueUrl: {
+ type: String,
+ required: true,
+ },
+ promotedToEpicUrl: {
+ type: String,
+ required: true,
+ },
+ serviceDeskReplyTo: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ closedStatusLink() {
+ return this.duplicatedToIssueUrl || this.movedToIssueUrl || this.promotedToEpicUrl;
+ },
+ closedStatusText() {
+ if (this.duplicatedToIssueUrl) {
+ return s__('IssuableStatus|duplicated');
+ }
+ if (this.movedToIssueUrl) {
+ return s__('IssuableStatus|moved');
+ }
+ if (this.promotedToEpicUrl) {
+ return s__('IssuableStatus|promoted');
+ }
+ return '';
+ },
+ isOpen() {
+ return this.issuableState === STATUS_OPEN || this.issuableState === STATUS_REOPENED;
+ },
+ statusIcon() {
+ return this.isOpen ? 'issues' : 'issue-closed';
+ },
+ statusText() {
+ if (this.isOpen) {
+ return __('Open');
+ }
+ if (this.closedStatusLink) {
+ return s__('IssuableStatus|Closed (%{link})');
+ }
+ return s__('IssuableStatus|Closed');
+ },
+ },
+};
+</script>
+
+<template>
+ <issuable-header
+ :author="author"
+ :blocked="isLocked"
+ :confidential="confidential"
+ :created-at="createdAt"
+ :is-first-contribution="isFirstContribution"
+ :is-hidden="isHidden"
+ :issuable-state="issuableState"
+ :issuable-type="issuableType"
+ :service-desk-reply-to="serviceDeskReplyTo"
+ show-work-item-type-icon
+ :status-icon="statusIcon"
+ :workspace-type="$options.WORKSPACE_PROJECT"
+ >
+ <template #status-badge>
+ <gl-sprintf v-if="closedStatusLink" :message="statusText">
+ <template #link>
+ <gl-link
+ class="gl-reset-color! gl-reset-font-size gl-text-decoration-underline"
+ :href="closedStatusLink"
+ >{{ closedStatusText }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ <template v-else>{{ statusText }}</template>
+ </template>
+ </issuable-header>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/new_header_actions_popover.vue b/app/assets/javascripts/issues/show/components/new_header_actions_popover.vue
index 8262b3ac0ff..f7a324d9f3f 100644
--- a/app/assets/javascripts/issues/show/components/new_header_actions_popover.vue
+++ b/app/assets/javascripts/issues/show/components/new_header_actions_popover.vue
@@ -54,29 +54,27 @@ export default {
</script>
<template>
- <div>
- <gl-popover
- v-if="showPopover"
- target="new-actions-header-dropdown"
- container="viewport"
- placement="left"
- :show="showPopover"
- triggers="manual"
- content="text"
- :css-classes="['gl-p-2 new-header-popover']"
+ <gl-popover
+ v-if="showPopover"
+ target="new-actions-header-dropdown"
+ container="viewport"
+ placement="left"
+ :show="showPopover"
+ triggers="manual"
+ content="text"
+ :css-classes="['gl-p-2 new-header-popover']"
+ >
+ <template #title>
+ <div class="gl-font-base gl-font-weight-normal">
+ {{ popoverText }}
+ </div>
+ </template>
+ <gl-button
+ data-testid="confirm-button"
+ variant="confirm"
+ type="submit"
+ @click="dismissPopover"
+ >{{ $options.i18n.confirmButtonText }}</gl-button
>
- <template #title>
- <div class="gl-font-base gl-font-weight-normal">
- {{ popoverText }}
- </div>
- </template>
- <gl-button
- data-testid="confirm-button"
- variant="confirm"
- type="submit"
- @click="dismissPopover"
- >{{ $options.i18n.confirmButtonText }}</gl-button
- >
- </gl-popover>
- </div>
+ </gl-popover>
</template>
diff --git a/app/assets/javascripts/issues/show/components/sentry_error_stack_trace.vue b/app/assets/javascripts/issues/show/components/sentry_error_stack_trace.vue
index 1530e9a15b5..08cda8c3cdc 100644
--- a/app/assets/javascripts/issues/show/components/sentry_error_stack_trace.vue
+++ b/app/assets/javascripts/issues/show/components/sentry_error_stack_trace.vue
@@ -1,5 +1,6 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState, mapGetters } from 'vuex';
import Stacktrace from '~/error_tracking/components/stacktrace.vue';
diff --git a/app/assets/javascripts/issues/show/components/title.vue b/app/assets/javascripts/issues/show/components/title.vue
index c464f48d574..375180446d9 100644
--- a/app/assets/javascripts/issues/show/components/title.vue
+++ b/app/assets/javascripts/issues/show/components/title.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
@@ -52,16 +53,19 @@ export default {
</script>
<template>
- <div class="title-container">
+ <div
+ class="gl-display-flex gl-align-items-flex-start gl-flex-direction-column gl-sm-flex-direction-row gl-gap-3 gl-pt-3"
+ >
<h1
v-safe-html="titleHtml"
:class="{
'issue-realtime-pre-pulse': preAnimation,
'issue-realtime-trigger-pulse': pulseAnimation,
}"
- class="title gl-font-size-h-display"
+ class="title gl-font-size-h-display gl-m-0!"
data-testid="issue-title"
dir="auto"
></h1>
+ <slot name="actions"></slot>
</div>
</template>
diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js
index bc4284457f6..a27f86bd9c3 100644
--- a/app/assets/javascripts/issues/show/index.js
+++ b/app/assets/javascripts/issues/show/index.js
@@ -1,9 +1,10 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import errorTrackingStore from '~/error_tracking/store';
import { apolloProvider } from '~/graphql_shared/issuable_client';
-import { TYPE_INCIDENT } from '~/issues/constants';
-import { parseBoolean } from '~/lib/utils/common_utils';
+import { TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants';
+import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import { scrollToTargetOnResize } from '~/lib/utils/resize_observer';
import IssueApp from './components/app.vue';
import HeaderActions from './components/header_actions.vue';
@@ -29,9 +30,13 @@ export function initIncidentApp(issueData = {}, store) {
return undefined;
}
- bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
+ bootstrapApollo({ ...issueState, issueType: TYPE_INCIDENT });
const {
+ authorId,
+ authorName,
+ authorUsername,
+ authorWebUrl,
canCreateIncident,
canUpdate,
canUpdateTimelineEvent,
@@ -45,8 +50,8 @@ export function initIncidentApp(issueData = {}, store) {
hasLinkedAlerts,
slaFeatureAvailable,
uploadMetricsFeatureAvailable,
- state,
} = issueData;
+ const headerActionsData = convertObjectPropsToCamelCase(JSON.parse(el.dataset.headerActionsData));
const fullPath = `${projectNamespace}/${projectPath}`;
const router = createRouter(currentPath, currentTab);
@@ -70,6 +75,22 @@ export function initIncidentApp(issueData = {}, store) {
slaFeatureAvailable: parseBoolean(slaFeatureAvailable),
uploadMetricsFeatureAvailable: parseBoolean(uploadMetricsFeatureAvailable),
contentEditorOnIssues: gon.features.contentEditorOnIssues,
+ // for HeaderActions component
+ canCreateIssue: parseBoolean(headerActionsData.canCreateIncident),
+ canDestroyIssue: parseBoolean(headerActionsData.canDestroyIssue),
+ canPromoteToEpic: parseBoolean(headerActionsData.canPromoteToEpic),
+ canReopenIssue: parseBoolean(headerActionsData.canReopenIssue),
+ canReportSpam: parseBoolean(headerActionsData.canReportSpam),
+ canUpdateIssue: parseBoolean(headerActionsData.canUpdateIssue),
+ isIssueAuthor: parseBoolean(headerActionsData.isIssueAuthor),
+ issuePath: headerActionsData.issuePath,
+ newIssuePath: headerActionsData.newIssuePath,
+ projectPath: headerActionsData.projectPath,
+ reportAbusePath: headerActionsData.reportAbusePath,
+ reportedUserId: headerActionsData.reportedUserId,
+ reportedFromUrl: headerActionsData.reportedFromUrl,
+ submitAsSpamPath: headerActionsData.submitAsSpamPath,
+ issuableEmailAddress: headerActionsData.issuableEmailAddress,
},
computed: {
...mapGetters(['getNoteableData']),
@@ -78,8 +99,15 @@ export function initIncidentApp(issueData = {}, store) {
return createElement(IssueApp, {
props: {
...issueData,
+ author: {
+ id: authorId,
+ name: authorName,
+ username: authorUsername,
+ webUrl: authorWebUrl,
+ },
issueId: Number(issuableId),
- issuableStatus: state,
+ issuableStatus: this.getNoteableData?.state,
+ issuableType: TYPE_INCIDENT,
descriptionComponent: IncidentTabs,
showTitleBorder: false,
isConfidential: this.getNoteableData?.confidential,
@@ -97,12 +125,17 @@ export function initIssueApp(issueData, store) {
}
const { fullPath, registerPath, signInPath } = el.dataset;
+ const headerActionsData = convertObjectPropsToCamelCase(JSON.parse(el.dataset.headerActionsData));
scrollToTargetOnResize();
- bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
+ bootstrapApollo({ ...issueState, issueType: TYPE_ISSUE });
const {
+ authorId,
+ authorName,
+ authorUsername,
+ authorWebUrl,
canCreateIncident,
hasIssueWeightsFeature,
hasIterationsFeature,
@@ -121,6 +154,26 @@ export function initIssueApp(issueData, store) {
signInPath,
hasIssueWeightsFeature,
hasIterationsFeature,
+ // for HeaderActions component
+ canCreateIssue: parseBoolean(headerActionsData.canCreateIssue),
+ canDestroyIssue: parseBoolean(headerActionsData.canDestroyIssue),
+ canPromoteToEpic: parseBoolean(headerActionsData.canPromoteToEpic),
+ canReopenIssue: parseBoolean(headerActionsData.canReopenIssue),
+ canReportSpam: parseBoolean(headerActionsData.canReportSpam),
+ canUpdateIssue: parseBoolean(headerActionsData.canUpdateIssue),
+ iid: headerActionsData.iid,
+ issuableId: headerActionsData.issuableId,
+ isIssueAuthor: parseBoolean(headerActionsData.isIssueAuthor),
+ issuePath: headerActionsData.issuePath,
+ issueType: headerActionsData.issueType,
+ newIssuePath: headerActionsData.newIssuePath,
+ projectPath: headerActionsData.projectPath,
+ projectId: headerActionsData.projectId,
+ reportAbusePath: headerActionsData.reportAbusePath,
+ reportedUserId: headerActionsData.reportedUserId,
+ reportedFromUrl: headerActionsData.reportedFromUrl,
+ submitAsSpamPath: headerActionsData.submitAsSpamPath,
+ issuableEmailAddress: headerActionsData.issuableEmailAddress,
},
computed: {
...mapGetters(['getNoteableData']),
@@ -129,6 +182,12 @@ export function initIssueApp(issueData, store) {
return createElement(IssueApp, {
props: {
...issueProps,
+ author: {
+ id: authorId,
+ name: authorName,
+ username: authorUsername,
+ webUrl: authorWebUrl,
+ },
isConfidential: this.getNoteableData?.confidential,
isLocked: this.getNoteableData?.discussion_locked,
issuableStatus: this.getNoteableData?.state,
diff --git a/app/assets/javascripts/jira_connect/branches/pages/index.vue b/app/assets/javascripts/jira_connect/branches/pages/index.vue
index 953e823ec96..3824e2350e8 100644
--- a/app/assets/javascripts/jira_connect/branches/pages/index.vue
+++ b/app/assets/javascripts/jira_connect/branches/pages/index.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlEmptyState } from '@gitlab/ui';
import { sprintf } from '~/locale';
diff --git a/app/assets/javascripts/jira_connect/subscriptions/api.js b/app/assets/javascripts/jira_connect/subscriptions/api.js
index 184635e63f3..17e654fd6f8 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/api.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/api.js
@@ -26,9 +26,14 @@ export const removeSubscription = async (removePath) => {
});
};
-export const fetchGroups = async (groupsPath, { page, perPage, search }, accessToken = null) => {
+export const fetchGroups = async (
+ groupsPath,
+ { minAccessLevel, page, perPage, search },
+ accessToken = null,
+) => {
return axiosInstance.get(groupsPath, {
params: {
+ min_access_level: minAccessLevel,
page,
per_page: perPage,
search,
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue
index 3d02dcb1198..fb74306afc0 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import { GlLoadingIcon, GlPagination, GlAlert, GlSearchBoxByType } from '@gitlab/ui';
import { fetchGroups } from '~/jira_connect/subscriptions/api';
@@ -6,6 +7,7 @@ import {
DEFAULT_GROUPS_PER_PAGE,
MINIMUM_SEARCH_TERM_LENGTH,
} from '~/jira_connect/subscriptions/constants';
+import { ACCESS_LEVEL_MAINTAINER_INTEGER } from '~/access_level/constants';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import GroupsListItem from './groups_list_item.vue';
@@ -36,10 +38,13 @@ export default {
};
},
computed: {
+ ...mapState(['accessToken', 'currentUser']),
showPagination() {
return this.totalItems > this.$options.DEFAULT_GROUPS_PER_PAGE && this.groups.length > 0;
},
- ...mapState(['accessToken']),
+ isAdmin() {
+ return Boolean(this.currentUser.is_admin);
+ },
},
mounted() {
return this.loadGroups().finally(() => {
@@ -52,6 +57,7 @@ export default {
return fetchGroups(
this.groupsPath,
{
+ minAccessLevel: this.isAdmin ? undefined : ACCESS_LEVEL_MAINTAINER_INTEGER,
page: this.page,
perPage: this.$options.DEFAULT_GROUPS_PER_PAGE,
search: this.searchValue,
@@ -110,7 +116,10 @@ export default {
@input="onGroupSearch"
/>
- <p class="gl-mb-3">
+ <p v-if="isAdmin" class="gl-mb-3">
+ {{ s__('JiraConnect|Not seeing your groups? Only groups you have access to appear here.') }}
+ </p>
+ <p v-else class="gl-mb-3">
{{
s__(
'JiraConnect|Not seeing your groups? Only groups you have at least the Maintainer role for appear here.',
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue
index cd0f4c2f66f..d283da1649f 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import { GlButton } from '@gitlab/ui';
import GroupItemName from '../group_item_name.vue';
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
index c5f6f736626..d916e7ec798 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
@@ -1,6 +1,7 @@
<script>
import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapMutations, mapActions } from 'vuex';
import { retrieveAlert } from '~/jira_connect/subscriptions/utils';
import AccessorUtilities from '~/lib/utils/accessor';
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 ba264d0be34..1bf8af523a9 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,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapMutations } from 'vuex';
import { GlButton } from '@gitlab/ui';
import { sprintf } from '~/locale';
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue
index a765040a6e7..83a26a88a4d 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue
@@ -1,6 +1,7 @@
<script>
import { GlButton, GlTableLite } from '@gitlab/ui';
import { isEmpty } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapMutations, mapState } from 'vuex';
import { removeSubscription } from '~/jira_connect/subscriptions/api';
import { reloadPage } from '~/jira_connect/subscriptions/utils';
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 e05eb900efa..f2a1afaffab 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,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapMutations } from 'vuex';
import { GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue
index ac30fa2faa0..dab987675fc 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
diff --git a/app/assets/javascripts/jira_connect/subscriptions/store/index.js b/app/assets/javascripts/jira_connect/subscriptions/store/index.js
index abad1920bcc..8cf9ec4d28c 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/store/index.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/store/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import mutations from './mutations';
diff --git a/app/assets/javascripts/jira_connect/subscriptions/store/state.js b/app/assets/javascripts/jira_connect/subscriptions/store/state.js
index f35f79e3f53..ba17a068c72 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/store/state.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/store/state.js
@@ -1,4 +1,5 @@
export default function createState({
+ accessToken = null,
subscriptions = [],
subscriptionsLoading = false,
currentUser = null,
@@ -13,6 +14,6 @@ export default function createState({
currentUser,
currentUserError: null,
- accessToken: null,
+ accessToken,
};
}
diff --git a/app/assets/javascripts/jobs/components/job/job_app.vue b/app/assets/javascripts/jobs/components/job/job_app.vue
index a5a92a3c4ff..52030a0f830 100644
--- a/app/assets/javascripts/jobs/components/job/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job/job_app.vue
@@ -2,6 +2,7 @@
import { GlLoadingIcon, GlIcon, GlAlert } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { throttle, isEmpty } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters, mapState, mapActions } from 'vuex';
import LogTopBar from 'ee_else_ce/jobs/components/job/job_log_controllers.vue';
import SafeHtml from '~/vue_shared/directives/safe_html';
diff --git a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue
index d3b2ddc5422..356d65e1d14 100644
--- a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue
+++ b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue
@@ -203,7 +203,7 @@ export default {
<template>
<gl-loading-icon v-if="$apollo.queries.variables.loading" class="gl-mt-9" size="lg" />
<div v-else class="row gl-justify-content-center">
- <div class="col-10" data-testid="manual-vars-form">
+ <div class="col-10">
<label>{{ $options.i18n.header }}</label>
<div
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue
index 1c7ba1d331b..a78cacf110f 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue
@@ -1,14 +1,32 @@
<script>
-import { GlButton, GlButtonGroup, GlIcon, GlLink } from '@gitlab/ui';
+import { GlButton, GlButtonGroup, GlIcon, GlLink, GlPopover } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
+ i18n: {
+ jobArtifacts: s__('Job|Job artifacts'),
+ artifactsHelpText: s__(
+ 'Job|Job artifacts are files that are configured to be uploaded when a job finishes execution. Artifacts could be compiled files, unit tests or scanning reports, or any other files generated by a job.',
+ ),
+ expiredText: s__('Job|The artifacts were removed'),
+ willExpireText: s__('Job|The artifacts will be removed'),
+ lockedText: s__(
+ 'Job|These artifacts are the latest. They will not be deleted (even if expired) until newer artifacts are available.',
+ ),
+ keepText: s__('Job|Keep'),
+ downloadText: s__('Job|Download'),
+ browseText: s__('Job|Browse'),
+ },
+ artifactsHelpPath: helpPagePath('ci/jobs/job_artifacts'),
components: {
GlButton,
GlButtonGroup,
GlIcon,
GlLink,
+ GlPopover,
TimeagoTooltip,
},
mixins: [timeagoMixin],
@@ -38,16 +56,28 @@ export default {
</script>
<template>
<div>
- <div class="title gl-font-weight-bold">{{ s__('Job|Job artifacts') }}</div>
+ <div class="title gl-font-weight-bold">
+ <span class="gl-mr-2">{{ $options.i18n.jobArtifacts }}</span>
+ <gl-link :href="$options.artifactsHelpPath" data-testid="artifacts-help-link">
+ <gl-icon id="artifacts-help" name="question-o" />
+ </gl-link>
+ <gl-popover
+ target="artifacts-help"
+ :title="$options.i18n.jobArtifacts"
+ triggers="hover focus"
+ >
+ {{ $options.i18n.artifactsHelpText }}
+ </gl-popover>
+ </div>
<p
v-if="isExpired || willExpire"
class="build-detail-row"
data-testid="artifacts-remove-timeline"
>
- <span v-if="isExpired">{{ s__('Job|The artifacts were removed') }}</span>
- <span v-if="willExpire" data-qa-selector="artifacts_unlocked_message_content">{{
- s__('Job|The artifacts will be removed')
- }}</span>
+ <span v-if="isExpired">{{ $options.i18n.expiredText }}</span>
+ <span v-if="willExpire" data-qa-selector="artifacts_unlocked_message_content">
+ {{ $options.i18n.willExpireText }}
+ </span>
<timeago-tooltip v-if="artifact.expire_at" :time="artifact.expire_at" />
<gl-link
:href="helpUrl"
@@ -59,11 +89,9 @@ export default {
</gl-link>
</p>
<p v-else-if="isLocked" class="build-detail-row">
- <span data-testid="job-locked-message" data-qa-selector="artifacts_locked_message_content">{{
- s__(
- 'Job|These artifacts are the latest. They will not be deleted (even if expired) until newer artifacts are available.',
- )
- }}</span>
+ <span data-testid="job-locked-message" data-qa-selector="artifacts_locked_message_content">
+ {{ $options.i18n.lockedText }}
+ </span>
</p>
<gl-button-group class="gl-display-flex gl-mt-3">
<gl-button
@@ -71,7 +99,7 @@ export default {
:href="artifact.keep_path"
data-method="post"
data-testid="keep-artifacts"
- >{{ s__('Job|Keep') }}</gl-button
+ >{{ $options.i18n.keepText }}</gl-button
>
<gl-button
v-if="artifact.download_path"
@@ -79,14 +107,14 @@ export default {
rel="nofollow"
data-testid="download-artifacts"
download
- >{{ s__('Job|Download') }}</gl-button
+ >{{ $options.i18n.downloadText }}</gl-button
>
<gl-button
v-if="artifact.browse_path"
:href="artifact.browse_path"
data-testid="browse-artifacts"
data-qa-selector="browse_artifacts_button"
- >{{ s__('Job|Browse') }}</gl-button
+ >{{ $options.i18n.browseText }}</gl-button
>
</gl-button-group>
</div>
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue b/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue
index e70f9199b55..87c47f592aa 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlDisclosureDropdown, GlModalDirective } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import { JOB_SIDEBAR_COPY } from '~/jobs/constants';
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue
index 69271cc9022..92e1557ada2 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue
@@ -1,6 +1,7 @@
<script>
import { GlButton, GlIcon } from '@gitlab/ui';
import { isEmpty } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants';
import ArtifactsBlock from './artifacts_block.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
index d791705d80d..56fcd8738d7 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import { createAlert } from '~/alert';
import { TYPENAME_COMMIT_STATUS } from '~/graphql_shared/constants';
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue
index 3cd90eb3bca..09335476008 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import { GlBadge } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
diff --git a/app/assets/javascripts/jobs/components/log/line.vue b/app/assets/javascripts/jobs/components/log/line.vue
index 36b350f4d64..3c9c5097122 100644
--- a/app/assets/javascripts/jobs/components/log/line.vue
+++ b/app/assets/javascripts/jobs/components/log/line.vue
@@ -1,6 +1,7 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
+import { getLocationHash } from '~/lib/utils/url_utility';
import { linkRegex } from '../../utils';
-
import LineNumber from './line_number.vue';
export default {
@@ -63,10 +64,19 @@ export default {
});
}
+ if (window.location.hash) {
+ const hash = getLocationHash();
+ const lineToMatch = `L${line.lineNumber + 1}`;
+
+ if (hash === lineToMatch) {
+ applyHighlight = true;
+ }
+ }
+
return h(
'div',
{
- class: ['js-line', 'log-line', applyHighlight ? 'gl-bg-gray-500' : ''],
+ class: ['js-line', 'log-line', applyHighlight ? 'gl-bg-gray-700' : ''],
},
[
h(LineNumber, {
diff --git a/app/assets/javascripts/jobs/components/log/line_header.vue b/app/assets/javascripts/jobs/components/log/line_header.vue
index de774e8408b..115b090b32a 100644
--- a/app/assets/javascripts/jobs/components/log/line_header.vue
+++ b/app/assets/javascripts/jobs/components/log/line_header.vue
@@ -1,5 +1,6 @@
<script>
import { GlIcon } from '@gitlab/ui';
+import { getLocationHash } from '~/lib/utils/url_utility';
import DurationBadge from './duration_badge.vue';
import LineNumber from './line_number.vue';
@@ -32,6 +33,12 @@ export default {
iconName() {
return this.isClosed ? 'chevron-lg-right' : 'chevron-lg-down';
},
+ applyHighlight() {
+ const hash = getLocationHash();
+ const lineToMatch = `L${this.line.lineNumber + 1}`;
+
+ return hash === lineToMatch;
+ },
},
methods: {
handleOnClick() {
@@ -44,6 +51,7 @@ export default {
<template>
<div
class="log-line collapsible-line d-flex justify-content-between ws-normal gl-align-items-flex-start"
+ :class="{ 'gl-bg-gray-700': applyHighlight }"
role="button"
@click="handleOnClick"
>
diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue
index ba1801f5c58..6a1101bf297 100644
--- a/app/assets/javascripts/jobs/components/log/log.vue
+++ b/app/assets/javascripts/jobs/components/log/log.vue
@@ -1,4 +1,6 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
import { scrollToElement } from '~/lib/utils/common_utils';
import { getLocationHash } from '~/lib/utils/url_utility';
diff --git a/app/assets/javascripts/jobs/store/index.js b/app/assets/javascripts/jobs/store/index.js
index 467c692b438..b9d76765d8d 100644
--- a/app/assets/javascripts/jobs/store/index.js
+++ b/app/assets/javascripts/jobs/store/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
diff --git a/app/assets/javascripts/labels/index.js b/app/assets/javascripts/labels/index.js
index c7c17607af6..bb3975ce61d 100644
--- a/app/assets/javascripts/labels/index.js
+++ b/app/assets/javascripts/labels/index.js
@@ -126,6 +126,9 @@ export function initAdminLabels() {
'ul.manage-labels-list li.label-list-item:not(.gl-display-none\\!)',
).length;
+ // update labels count in UI
+ document.querySelector('.js-admin-labels-count').innerText = labelsCount;
+
// display the empty state if there are no more labels
if (labelsCount < 1 && !pagination && emptyState) {
emptyState.classList.remove('gl-display-none');
diff --git a/app/assets/javascripts/lib/apollo/persistence_mapper.js b/app/assets/javascripts/lib/apollo/persistence_mapper.js
index f8ae180107c..56f6606808e 100644
--- a/app/assets/javascripts/lib/apollo/persistence_mapper.js
+++ b/app/assets/javascripts/lib/apollo/persistence_mapper.js
@@ -50,7 +50,7 @@ export const persistenceMapper = async (data) => {
// we need this to prevent overcaching when we fetch the same entity (e.g. project) more than once
// with different set of fields
- if (Object.values(rootQuery).some((value) => value.__ref === key)) {
+ if (Object.values(rootQuery).some((value) => value?.__ref === key)) {
const mappedEntity = {};
Object.entries(parsedEntity).forEach(([parsedKey, parsedValue]) => {
if (!parsedValue || typeof parsedValue !== 'object' || parsedValue['__persist']) {
diff --git a/app/assets/javascripts/lib/mousetrap.js b/app/assets/javascripts/lib/mousetrap.js
index ef3f54ec314..297ca00f4e4 100644
--- a/app/assets/javascripts/lib/mousetrap.js
+++ b/app/assets/javascripts/lib/mousetrap.js
@@ -56,4 +56,6 @@ export const clearStopCallbacksForTests = () => {
additionalStopCallbacks.length = 0;
};
+export const MOUSETRAP_COPY_KEYBOARD_SHORTCUT = 'mod+c';
+
export { Mousetrap };
diff --git a/app/assets/javascripts/lib/print_markdown_dom.js b/app/assets/javascripts/lib/print_markdown_dom.js
new file mode 100644
index 00000000000..fb5ea09b6c8
--- /dev/null
+++ b/app/assets/javascripts/lib/print_markdown_dom.js
@@ -0,0 +1,50 @@
+function getPrintContent(target, ignoreSelectors) {
+ const cloneDom = target.cloneNode(true);
+ cloneDom.querySelectorAll('details').forEach((detail) => {
+ detail.setAttribute('open', '');
+ });
+
+ if (Array.isArray(ignoreSelectors) && ignoreSelectors.length > 0) {
+ cloneDom.querySelectorAll(ignoreSelectors.join(',')).forEach((ignoredNode) => {
+ ignoredNode.remove();
+ });
+ }
+
+ cloneDom.querySelectorAll('img').forEach((img) => {
+ img.setAttribute('loading', 'eager');
+ });
+
+ return cloneDom.innerHTML;
+}
+
+function getTitleContent(title) {
+ const titleElement = document.createElement('h2');
+ titleElement.className = 'gl-mt-0 gl-mb-5';
+ titleElement.innerText = title;
+ return titleElement.outerHTML;
+}
+
+export default async function printMarkdownDom({
+ target,
+ title,
+ ignoreSelectors = [],
+ stylesheet = [],
+}) {
+ const printJS = (await import('print-js')).default;
+
+ const printContent = getPrintContent(target, ignoreSelectors);
+
+ const titleElement = title ? getTitleContent(title) : '';
+
+ const markdownElement = `<div class="md">${printContent}</div>`;
+
+ const printable = titleElement + markdownElement;
+
+ printJS({
+ printable,
+ type: 'raw-html',
+ documentTitle: title,
+ scanStyles: false,
+ css: stylesheet,
+ });
+}
diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js
index d1e5e4eea13..aceae188b73 100644
--- a/app/assets/javascripts/lib/utils/constants.js
+++ b/app/assets/javascripts/lib/utils/constants.js
@@ -15,6 +15,7 @@ export const DATETIME_RANGE_TYPES = {
export const BV_SHOW_MODAL = 'bv::show::modal';
export const BV_HIDE_MODAL = 'bv::hide::modal';
export const BV_HIDE_TOOLTIP = 'bv::hide::tooltip';
+export const BV_SHOW_TOOLTIP = 'bv::show::tooltip';
export const BV_DROPDOWN_SHOW = 'bv::dropdown::show';
export const BV_DROPDOWN_HIDE = 'bv::dropdown::hide';
diff --git a/app/assets/javascripts/lib/utils/error_utils.js b/app/assets/javascripts/lib/utils/error_utils.js
new file mode 100644
index 00000000000..82dba803c3e
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/error_utils.js
@@ -0,0 +1,149 @@
+import { isEmpty, isString, isObject } from 'lodash';
+import { sprintf, __ } from '~/locale';
+
+export class ActiveModelError extends Error {
+ constructor(errorAttributeMap = {}, ...params) {
+ // Pass remaining arguments (including vendor specific ones) to parent constructor
+ super(...params);
+
+ // Maintains proper stack trace for where our error was thrown (only available on V8)
+ if (Error.captureStackTrace) {
+ Error.captureStackTrace(this, ActiveModelError);
+ }
+
+ this.name = 'ActiveModelError';
+ // Custom debugging information
+ this.errorAttributeMap = errorAttributeMap;
+ }
+}
+
+const DEFAULT_ERROR = {
+ message: __('Something went wrong. Please try again.'),
+ links: {},
+};
+
+/**
+ * @typedef {Object<ErrorAttribute,ErrorType[]>} ErrorAttributeMap - Map of attributes to error details
+ * @typedef {string} ErrorAttribute - the error attribute https://api.rubyonrails.org/v7.0.4.2/classes/ActiveModel/Error.html
+ * @typedef {string} ErrorType - the error type https://api.rubyonrails.org/v7.0.4.2/classes/ActiveModel/Error.html
+ *
+ * @example { "email": ["taken", ...] }
+ * // returns `${UNLINKED_ACCOUNT_ERROR}`, i.e. the `EMAIL_TAKEN_ERROR_TYPE` error message
+ *
+ * @param {ErrorAttributeMap} errorAttributeMap
+ * @param {Object} errorDictionary
+ * @returns {(null|string)} null or error message if found
+ */
+function getMessageFromType(errorAttributeMap = {}, errorDictionary = {}) {
+ if (!isObject(errorAttributeMap)) {
+ return null;
+ }
+
+ return Object.keys(errorAttributeMap).reduce((_, attribute) => {
+ const errorType = errorAttributeMap[attribute].find(
+ (type) => errorDictionary[`${attribute}:${type}`.toLowerCase()],
+ );
+ if (errorType) {
+ return errorDictionary[`${attribute}:${errorType}`.toLowerCase()];
+ }
+
+ return null;
+ }, null);
+}
+
+/**
+ * @example "Email has already been taken, Email is invalid"
+ * // returns `${UNLINKED_ACCOUNT_ERROR}`, i.e. the `EMAIL_TAKEN_ERROR_TYPE` error message
+ *
+ * @param {string} errorString
+ * @param {Object} errorDictionary
+ * @returns {(null|string)} null or error message if found
+ */
+function getMessageFromErrorString(errorString, errorDictionary = {}) {
+ if (isEmpty(errorString) || !isString(errorString)) {
+ return null;
+ }
+
+ const messages = errorString.split(', ');
+ const errorMessage = messages.find((message) => errorDictionary[message.toLowerCase()]);
+ if (errorMessage) {
+ return errorDictionary[errorMessage.toLowerCase()];
+ }
+
+ return {
+ message: errorString,
+ links: {},
+ };
+}
+
+/**
+ * Receives an Error and attempts to extract the `errorAttributeMap` in
+ * case it is an `ActiveModelError` and returns the message if it exists.
+ * If a match is not found it will attempt to map a message from the
+ * Error.message to be returned.
+ * Otherwise, it will return a general error message.
+ *
+ * @param {Error|String} systemError
+ * @param {Object} errorDictionary
+ * @param {Object} defaultError
+ * @returns error message
+ */
+export function mapSystemToFriendlyError(
+ systemError,
+ errorDictionary = {},
+ defaultError = DEFAULT_ERROR,
+) {
+ if (systemError instanceof String || typeof systemError === 'string') {
+ const messageFromErrorString = getMessageFromErrorString(systemError, errorDictionary);
+ if (messageFromErrorString) {
+ return messageFromErrorString;
+ }
+ return defaultError;
+ }
+
+ if (!(systemError instanceof Error)) {
+ return defaultError;
+ }
+
+ const { errorAttributeMap, message } = systemError;
+ const messageFromType = getMessageFromType(errorAttributeMap, errorDictionary);
+ if (messageFromType) {
+ return messageFromType;
+ }
+
+ const messageFromErrorString = getMessageFromErrorString(message, errorDictionary);
+ if (messageFromErrorString) {
+ return messageFromErrorString;
+ }
+
+ return defaultError;
+}
+
+function generateLinks(links) {
+ return Object.keys(links).reduce((allLinks, link) => {
+ /* eslint-disable-next-line @gitlab/require-i18n-strings */
+ const linkStart = `${link}Start`;
+ /* eslint-disable-next-line @gitlab/require-i18n-strings */
+ const linkEnd = `${link}End`;
+
+ return {
+ ...allLinks,
+ [linkStart]: `<a href="${links[link]}" target="_blank" rel="noopener noreferrer">`,
+ [linkEnd]: '</a>',
+ };
+ }, {});
+}
+
+export const generateHelpTextWithLinks = (error) => {
+ if (isString(error)) {
+ return error;
+ }
+
+ if (isEmpty(error)) {
+ /* eslint-disable-next-line @gitlab/require-i18n-strings */
+ throw new Error('The error cannot be empty.');
+ }
+
+ const links = generateLinks(error.links);
+ return sprintf(error.message, links, false);
+};
diff --git a/app/assets/javascripts/lib/utils/file_utility.js b/app/assets/javascripts/lib/utils/file_utility.js
new file mode 100644
index 00000000000..e5a41f3b042
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/file_utility.js
@@ -0,0 +1,12 @@
+/**
+ * Takes a file object and returns a data uri of its contents.
+ *
+ * @param {File} file
+ */
+export function readFileAsDataURL(file) {
+ return new Promise((resolve) => {
+ const reader = new FileReader();
+ reader.addEventListener('load', (e) => resolve(e.target.result), { once: true });
+ reader.readAsDataURL(file);
+ });
+}
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index 42f481261a2..31e16f7b4db 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -167,36 +167,6 @@ export const truncateWidth = (string, options = {}) => {
*/
export const truncateSha = (sha) => sha.substring(0, 8);
-const ELLIPSIS_CHAR = '…';
-export const truncatePathMiddleToLength = (text, maxWidth) => {
- let returnText = text;
- let ellipsisCount = 0;
-
- while (returnText.length >= maxWidth) {
- const textSplit = returnText.split('/').filter((s) => s !== ELLIPSIS_CHAR);
-
- if (textSplit.length === 0) {
- // There are n - 1 path separators for n segments, so 2n - 1 <= maxWidth
- const maxSegments = Math.floor((maxWidth + 1) / 2);
- return new Array(maxSegments).fill(ELLIPSIS_CHAR).join('/');
- }
-
- const middleIndex = Math.floor(textSplit.length / 2);
-
- returnText = textSplit
- .slice(0, middleIndex)
- .concat(
- new Array(ellipsisCount + 1).fill().map(() => ELLIPSIS_CHAR),
- textSplit.slice(middleIndex + 1),
- )
- .join('/');
-
- ellipsisCount += 1;
- }
-
- return returnText;
-};
-
/**
* Capitalizes first character
*
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index 85740117c00..08c98298121 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -20,6 +20,7 @@ export const PROMO_HOST = `about.${DOMAIN}`; // about.gitlab.com
// About Gitlab default url
export const PROMO_URL = `https://${PROMO_HOST}`;
+// eslint-disable-next-line no-restricted-syntax
export const DOCS_URL_IN_EE_DIR = `${DOCS_URL}/ee`;
// Reset the cursor in a Regex so that multiple uses before a recompile don't fail
@@ -686,11 +687,23 @@ export function redirectTo(url) {
}
/**
- * Navigates to a URL
- * @param {*} url - url to navigate to
+ * Navigates to a URL.
+ *
+ * If destination is a querystring, it will be automatically transformed into a fully qualified URL.
+ * If the URL is not a safe URL (see isSafeURL implementation), this function will log an exception into Sentry.
+ *
+ * @param {*} destination - url to navigate to. This can be a fully qualified URL or a querystring.
* @param {*} external - if true, open a new page or tab
*/
-export function visitUrl(url, external = false) {
+export function visitUrl(destination, external = false) {
+ let url = destination;
+
+ if (destination.startsWith('?')) {
+ const currentUrl = new URL(window.location.href);
+ currentUrl.search = destination;
+ url = currentUrl.toString();
+ }
+
if (!isSafeURL(url)) {
// For now log this to Sentry and do not block the execution.
// See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/121551#note_1408873600
@@ -713,3 +726,16 @@ export function visitUrl(url, external = false) {
export function refreshCurrentPage() {
visitUrl(window.location.href);
}
+
+// Adds a ref_type param to the path if refType is available
+export function buildURLwithRefType({ base = window.location.origin, path, refType = null }) {
+ const url = new URL('', base);
+ url.pathname = path; // This assignment does proper _escapes_
+
+ if (refType) {
+ url.searchParams.set('ref_type', refType.toLowerCase());
+ } else {
+ url.searchParams.delete('ref_type');
+ }
+ return url.pathname + url.search;
+}
diff --git a/app/assets/javascripts/lib/utils/vue3compat/vue_router.js b/app/assets/javascripts/lib/utils/vue3compat/vue_router.js
index 62054d5a80d..daea9815d60 100644
--- a/app/assets/javascripts/lib/utils/vue3compat/vue_router.js
+++ b/app/assets/javascripts/lib/utils/vue3compat/vue_router.js
@@ -67,7 +67,16 @@ const transformers = {
const transformOptions = (options = {}) => {
const defaultConfig = {
- routes: [],
+ routes: [
+ {
+ path: '/',
+ component: {
+ render() {
+ return '';
+ },
+ },
+ },
+ ],
history: createWebHashHistory(),
};
return Object.keys(options).reduce((acc, key) => {
diff --git a/app/assets/javascripts/lib/utils/vuex_module_mappers.js b/app/assets/javascripts/lib/utils/vuex_module_mappers.js
index 95a794dd268..5298eb67c2b 100644
--- a/app/assets/javascripts/lib/utils/vuex_module_mappers.js
+++ b/app/assets/javascripts/lib/utils/vuex_module_mappers.js
@@ -1,4 +1,5 @@
import { mapValues, isString } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
export const REQUIRE_STRING_ERROR_MESSAGE =
diff --git a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue
index 88d5384c9d5..1ae341820d1 100644
--- a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlForm, GlTooltipDirective } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import csrf from '~/lib/utils/csrf';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue
index 3b4b7516934..2f10a333bf4 100644
--- a/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import { s__ } from '~/locale';
diff --git a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
index 4b3bb89da55..caa292b37ce 100644
--- a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
export default {
diff --git a/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue b/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue
index 2173974c6f4..6b0a236c586 100644
--- a/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import csrf from '~/lib/utils/csrf';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue b/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue
index 627b47a1e81..920febb0e67 100644
--- a/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue
+++ b/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue
@@ -1,5 +1,6 @@
<script>
import { GlDisclosureDropdownItem } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
export default {
diff --git a/app/assets/javascripts/members/components/app.vue b/app/assets/javascripts/members/components/app.vue
index c5083bc4826..a70ee8fc865 100644
--- a/app/assets/javascripts/members/components/app.vue
+++ b/app/assets/javascripts/members/components/app.vue
@@ -1,5 +1,6 @@
<script>
import { GlAlert } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapMutations } from 'vuex';
import { scrollToElement } from '~/lib/utils/common_utils';
import { HIDE_ERROR } from '../store/mutation_types';
diff --git a/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue b/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue
index 419b7b83c0f..ae809b26f24 100644
--- a/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue
+++ b/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import MembersFilteredSearchBar from './members_filtered_search_bar.vue';
import SortDropdown from './sort_dropdown.vue';
diff --git a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
index 8cdaa76e673..0e5e394dd40 100644
--- a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
+++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import { __ } from '~/locale';
import {
diff --git a/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue b/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue
index 01f145e0862..b61b2bdd0c9 100644
--- a/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue
+++ b/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue
@@ -1,5 +1,6 @@
<script>
import { GlSorting, GlSortingItem } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import { visitUrl } from '~/lib/utils/url_utility';
import { FIELDS } from '~/members/constants';
diff --git a/app/assets/javascripts/members/components/members_tabs.vue b/app/assets/javascripts/members/components/members_tabs.vue
index 5ac8b30614d..75241d1ff26 100644
--- a/app/assets/javascripts/members/components/members_tabs.vue
+++ b/app/assets/javascripts/members/components/members_tabs.vue
@@ -1,5 +1,6 @@
<script>
import { GlTabs, GlTab, GlBadge, GlButton } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import { __ } from '~/locale';
import { queryToObject } from '~/lib/utils/url_utility';
diff --git a/app/assets/javascripts/members/components/modals/leave_modal.vue b/app/assets/javascripts/members/components/modals/leave_modal.vue
index 8bc6aca9cc1..0f76cb6e9d8 100644
--- a/app/assets/javascripts/members/components/modals/leave_modal.vue
+++ b/app/assets/javascripts/members/components/modals/leave_modal.vue
@@ -1,5 +1,6 @@
<script>
import { GlModal, GlForm, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import csrf from '~/lib/utils/csrf';
import { __, s__, sprintf } from '~/locale';
diff --git a/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue
index b28ca6e385b..18db8fe9cfb 100644
--- a/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue
+++ b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue
@@ -1,5 +1,6 @@
<script>
import { GlModal, GlSprintf, GlForm } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
import csrf from '~/lib/utils/csrf';
import { __, s__, sprintf } from '~/locale';
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 f1da1cd8ffc..c7bd1525558 100644
--- a/app/assets/javascripts/members/components/modals/remove_member_modal.vue
+++ b/app/assets/javascripts/members/components/modals/remove_member_modal.vue
@@ -1,5 +1,6 @@
<script>
import { GlFormCheckbox, GlModal } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import csrf from '~/lib/utils/csrf';
import { s__, __ } from '~/locale';
@@ -103,7 +104,6 @@ export default {
:title="actionText"
:visible="removeMemberModalVisible"
data-qa-selector="remove_member_modal"
- data-testid="remove-member-modal-content"
@primary="submitForm"
@hide="hideRemoveMemberModal"
>
diff --git a/app/assets/javascripts/members/components/table/expiration_datepicker.vue b/app/assets/javascripts/members/components/table/expiration_datepicker.vue
index 9f6e8979102..f28f4e6f605 100644
--- a/app/assets/javascripts/members/components/table/expiration_datepicker.vue
+++ b/app/assets/javascripts/members/components/table/expiration_datepicker.vue
@@ -1,5 +1,6 @@
<script>
import { GlDatepicker } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import { getDateInFuture } from '~/lib/utils/datetime_utility';
import { s__ } from '~/locale';
diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue
index f6fd84c46cb..68f624e9a3d 100644
--- a/app/assets/javascripts/members/components/table/members_table.vue
+++ b/app/assets/javascripts/members/components/table/members_table.vue
@@ -1,5 +1,6 @@
<script>
import { GlTable, GlBadge, GlPagination } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import MembersTableCell from 'ee_else_ce/members/components/table/members_table_cell.vue';
import {
diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue
index c854d865869..4b39c000b8f 100644
--- a/app/assets/javascripts/members/components/table/role_dropdown.vue
+++ b/app/assets/javascripts/members/components/table/role_dropdown.vue
@@ -1,6 +1,7 @@
<script>
import { GlCollapsibleListbox } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import * as Sentry from '@sentry/browser';
import { s__ } from '~/locale';
diff --git a/app/assets/javascripts/members/index.js b/app/assets/javascripts/members/index.js
index c7398127727..87ae670c146 100644
--- a/app/assets/javascripts/members/index.js
+++ b/app/assets/javascripts/members/index.js
@@ -1,5 +1,6 @@
import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import { parseDataAttributes } from '~/members/utils';
import { MEMBER_TYPES } from 'ee_else_ce/members/constants';
diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue
index c6feb684795..4f638dfdf42 100644
--- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue
+++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue
@@ -1,6 +1,7 @@
<script>
import { GlButton } from '@gitlab/ui';
import { debounce } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
diff --git a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue
index 6c431dc8af3..2c4845b85ad 100644
--- a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue
+++ b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import syntaxHighlight from '~/syntax_highlight';
diff --git a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue
index f8a097a3a0f..cb45d0f3c76 100644
--- a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue
+++ b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import syntaxHighlight from '~/syntax_highlight';
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
index af66600089f..d80517c1c1f 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
+++ b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
@@ -1,5 +1,6 @@
<script>
import { GlSprintf, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters, mapState, mapActions } from 'vuex';
import { __ } from '~/locale';
import FileIcon from '~/vue_shared/components/file_icon.vue';
diff --git a/app/assets/javascripts/merge_conflicts/store/index.js b/app/assets/javascripts/merge_conflicts/store/index.js
index 18e3351ed13..f3a0ba3f89d 100644
--- a/app/assets/javascripts/merge_conflicts/store/index.js
+++ b/app/assets/javascripts/merge_conflicts/store/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 883b9e6919b..09fe611262c 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -183,7 +183,7 @@ function getActionFromHref(href) {
}
const pageBundles = {
- show: () => import(/* webpackPrefetch: true */ 'ee_else_ce/mr_notes/mount_app'),
+ show: () => import(/* webpackPrefetch: true */ '~/mr_notes/mount_app'),
diffs: () => import(/* webpackPrefetch: true */ '~/diffs'),
};
diff --git a/app/assets/javascripts/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue
index 362ecca6d6c..3c3bee9b108 100644
--- a/app/assets/javascripts/merge_requests/components/sticky_header.vue
+++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue
@@ -1,5 +1,6 @@
<script>
import { GlIntersectionObserver, GlLink, GlSprintf, GlBadge } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters, mapState } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants';
diff --git a/app/assets/javascripts/milestones/components/milestone_combobox.vue b/app/assets/javascripts/milestones/components/milestone_combobox.vue
index 5c3b969655b..2f7fb542d0e 100644
--- a/app/assets/javascripts/milestones/components/milestone_combobox.vue
+++ b/app/assets/javascripts/milestones/components/milestone_combobox.vue
@@ -9,6 +9,7 @@ import {
GlIcon,
} from '@gitlab/ui';
import { debounce, isEqual } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import { s__, __, sprintf } from '~/locale';
import createStore from '../stores';
diff --git a/app/assets/javascripts/milestones/stores/index.js b/app/assets/javascripts/milestones/stores/index.js
index 2bebffc19ab..44ad5468dcd 100644
--- a/app/assets/javascripts/milestones/stores/index.js
+++ b/app/assets/javascripts/milestones/stores/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js
index 1795363f24c..04167518d3f 100644
--- a/app/assets/javascripts/mr_notes/init_notes.js
+++ b/app/assets/javascripts/mr_notes/init_notes.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState, mapGetters } from 'vuex';
import { apolloProvider } from '~/graphql_shared/issuable_client';
@@ -12,7 +13,7 @@ import NotesApp from '../notes/components/notes_app.vue';
import { getNotesFilterData } from '../notes/utils/get_notes_filter_data';
import initWidget from '../vue_merge_request_widget';
-export default ({ editorAiActions = [] } = {}) => {
+export default () => {
requestIdleCallback(
() => {
renderGFM(document.getElementById('diff-notes-app'));
@@ -42,7 +43,6 @@ export default ({ editorAiActions = [] } = {}) => {
provide: {
reportAbusePath: notesDataset.reportAbusePath,
newCommentTemplatePath: notesDataset.newCommentTemplatePath,
- editorAiActions,
mrFilter: true,
},
data() {
diff --git a/app/assets/javascripts/mr_notes/stores/index.js b/app/assets/javascripts/mr_notes/stores/index.js
index 1f8e61beff0..7cd8d073f54 100644
--- a/app/assets/javascripts/mr_notes/stores/index.js
+++ b/app/assets/javascripts/mr_notes/stores/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import batchCommentsModule from '~/batch_comments/stores/modules/batch_comments';
import diffsModule from '~/diffs/store/modules';
diff --git a/app/assets/javascripts/nav/components/new_nav_toggle.vue b/app/assets/javascripts/nav/components/new_nav_toggle.vue
index c36c56d7e43..4e5d6b0ce6c 100644
--- a/app/assets/javascripts/nav/components/new_nav_toggle.vue
+++ b/app/assets/javascripts/nav/components/new_nav_toggle.vue
@@ -68,14 +68,14 @@ export default {
<gl-disclosure-dropdown-item v-if="newNavigation" @action="toggleNav">
<div class="gl-new-dropdown-item-content">
<div
- class="gl-new-dropdown-item-text-wrapper gl-display-flex! gl-justify-content-space-between gl-align-items-center gl-py-2!"
+ class="gl-new-dropdown-item-text-wrapper gl-display-flex! gl-justify-content-space-between gl-align-items-center gl-py-2! gl-gap-3"
>
{{ $options.i18n.toggleMenuItemLabel }}
<gl-toggle
+ class="gl-flex-grow-0!"
:value="isEnabled"
:label="$options.i18n.toggleLabel"
label-position="hidden"
- data-testid="new-navigation-toggle"
/>
</div>
</div>
@@ -89,11 +89,12 @@ export default {
</div>
<div
- class="menu-item gl-cursor-pointer gl-display-flex! gl-justify-content-space-between gl-align-items-center"
+ class="menu-item gl-cursor-pointer gl-display-flex! gl-justify-content-space-between gl-align-items-center gl-gap-3"
@click.prevent.stop="toggleNav"
>
{{ $options.i18n.toggleMenuItemLabel }}
<gl-toggle
+ class="gl-flex-grow-0!"
:value="isEnabled"
:label="$options.i18n.toggleLabel"
label-position="hidden"
diff --git a/app/assets/javascripts/nav/mount.js b/app/assets/javascripts/nav/mount.js
index 7b0cc977107..0fc946bea76 100644
--- a/app/assets/javascripts/nav/mount.js
+++ b/app/assets/javascripts/nav/mount.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import ResponsiveApp from './components/responsive_app.vue';
import App from './components/top_nav_app.vue';
diff --git a/app/assets/javascripts/nav/stores/index.js b/app/assets/javascripts/nav/stores/index.js
index 527bbdd5c3f..7c8f93f042c 100644
--- a/app/assets/javascripts/nav/stores/index.js
+++ b/app/assets/javascripts/nav/stores/index.js
@@ -1,3 +1,4 @@
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import { createStoreOptions } from '~/frequent_items/store';
diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue
index 2caa93c3c93..2c8b41063bd 100644
--- a/app/assets/javascripts/notebook/cells/markdown.vue
+++ b/app/assets/javascripts/notebook/cells/markdown.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import katex from 'katex';
import { marked } from 'marked';
diff --git a/app/assets/javascripts/notebook/cells/output/html.vue b/app/assets/javascripts/notebook/cells/output/html.vue
index 74a5dd3806d..de7fbaf5090 100644
--- a/app/assets/javascripts/notebook/cells/output/html.vue
+++ b/app/assets/javascripts/notebook/cells/output/html.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import Prompt from '../prompt.vue';
diff --git a/app/assets/javascripts/notebook/cells/output/image.vue b/app/assets/javascripts/notebook/cells/output/image.vue
index da7d83539d3..228385697ae 100644
--- a/app/assets/javascripts/notebook/cells/output/image.vue
+++ b/app/assets/javascripts/notebook/cells/output/image.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import Prompt from '../prompt.vue';
diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue
index 0437b85913b..c8268b1a9ae 100644
--- a/app/assets/javascripts/notebook/cells/output/index.vue
+++ b/app/assets/javascripts/notebook/cells/output/index.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import CodeOutput from '../code/index.vue';
import HtmlOutput from './html.vue';
diff --git a/app/assets/javascripts/notebook/cells/prompt.vue b/app/assets/javascripts/notebook/cells/prompt.vue
index 1eeb61844a4..2acd30eb3e1 100644
--- a/app/assets/javascripts/notebook/cells/prompt.vue
+++ b/app/assets/javascripts/notebook/cells/prompt.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
export default {
props: {
diff --git a/app/assets/javascripts/notebook/index.vue b/app/assets/javascripts/notebook/index.vue
index 5f254cae73d..59637ee2cff 100644
--- a/app/assets/javascripts/notebook/index.vue
+++ b/app/assets/javascripts/notebook/index.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { MarkdownCell, CodeCell } from './cells';
diff --git a/app/assets/javascripts/notes/components/attachments_warning.vue b/app/assets/javascripts/notes/components/attachments_warning.vue
index aaa4b0d92b9..aa19dd58c0f 100644
--- a/app/assets/javascripts/notes/components/attachments_warning.vue
+++ b/app/assets/javascripts/notes/components/attachments_warning.vue
@@ -12,7 +12,7 @@ export default {
</script>
<template>
- <div class="issuable-note-warning" data-testid="attachment-warning">
+ <div class="issuable-note-warning">
{{ message }}
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index c6d94a3b7b7..a009f2975bb 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -1,6 +1,7 @@
<script>
import { GlAlert, GlButton, GlIcon, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui';
import $ from 'jquery';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import { createAlert } from '~/alert';
diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue
index e7b7ba7743e..75cacd9ace0 100644
--- a/app/assets/javascripts/notes/components/diff_discussion_header.vue
+++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue
@@ -1,6 +1,7 @@
<script>
import { GlAvatar, GlAvatarLink } from '@gitlab/ui';
import { escape } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { truncateSha } from '~/lib/utils/text_utility';
diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue
index b1a2ab77fa8..f08c005259c 100644
--- a/app/assets/javascripts/notes/components/diff_with_note.vue
+++ b/app/assets/javascripts/notes/components/diff_with_note.vue
@@ -1,5 +1,6 @@
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
index cff1043c258..d8883f90eda 100644
--- a/app/assets/javascripts/notes/components/discussion_counter.vue
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlButtonGroup, GlDisclosureDropdown, GlTooltipDirective } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters } from 'vuex';
import { throttle } from 'lodash';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue
index 692fd6cc500..7266cdb6405 100644
--- a/app/assets/javascripts/notes/components/discussion_filter.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter.vue
@@ -5,6 +5,7 @@ import {
GlDisclosureDropdownGroup,
GlDisclosureDropdownItem,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters, mapActions } from 'vuex';
import { getLocationHash, doesHashExistInUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue
index 080787884c8..79157c3f99c 100644
--- a/app/assets/javascripts/notes/components/discussion_notes.vue
+++ b/app/assets/javascripts/notes/components/discussion_notes.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapGetters, mapActions } from 'vuex';
import { __ } from '~/locale';
import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue';
diff --git a/app/assets/javascripts/notes/components/email_participants_warning.vue b/app/assets/javascripts/notes/components/email_participants_warning.vue
index 1875d48e7b2..cf9108992be 100644
--- a/app/assets/javascripts/notes/components/email_participants_warning.vue
+++ b/app/assets/javascripts/notes/components/email_participants_warning.vue
@@ -55,7 +55,7 @@ export default {
</script>
<template>
- <div class="issuable-note-warning" data-testid="email-participants-warning">
+ <div class="issuable-note-warning">
<gl-sprintf :message="message">
<template #andMore>
<button type="button" class="gl-button btn-link" @click="showMoreParticipants">
diff --git a/app/assets/javascripts/notes/components/mr_discussion_filter.vue b/app/assets/javascripts/notes/components/mr_discussion_filter.vue
index 7ca0c4730a9..08d3670ae6a 100644
--- a/app/assets/javascripts/notes/components/mr_discussion_filter.vue
+++ b/app/assets/javascripts/notes/components/mr_discussion_filter.vue
@@ -1,5 +1,6 @@
<script>
import { GlCollapsibleListbox, GlButton, GlIcon, GlSprintf, GlButtonGroup } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/notes/components/multiline_comment_form.vue b/app/assets/javascripts/notes/components/multiline_comment_form.vue
index 1633b79c3be..2c2264c36f3 100644
--- a/app/assets/javascripts/notes/components/multiline_comment_form.vue
+++ b/app/assets/javascripts/notes/components/multiline_comment_form.vue
@@ -1,5 +1,6 @@
<script>
import { GlFormSelect, GlSprintf } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import { getSymbol, getLineClasses } from './multiline_comment_utils';
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index 8d2d8095a44..7f23ee70086 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -5,6 +5,7 @@ import {
GlDisclosureDropdown,
GlDisclosureDropdownItem,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import Api from '~/api';
import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status';
diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue
index 9c04a72375b..21f226cd207 100644
--- a/app/assets/javascripts/notes/components/note_awards_list.vue
+++ b/app/assets/javascripts/notes/components/note_awards_list.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters } from 'vuex';
import { createAlert } from '~/alert';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index 1c6be0cfd77..fcb9dc43e8e 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -1,5 +1,6 @@
<script>
import { escape } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 4e816038539..8b43f068f11 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters, mapActions, mapState } from 'vuex';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
@@ -245,15 +246,16 @@ export default {
},
methods: {
...mapActions(['toggleResolveNote']),
- shouldToggleResolved(shouldResolve, beforeSubmitDiscussionState) {
- const newResolvedStateAfterUpdate =
- this.shouldBeResolved && this.shouldBeResolved(shouldResolve);
-
- const shouldToggleState =
- newResolvedStateAfterUpdate !== undefined &&
- beforeSubmitDiscussionState !== newResolvedStateAfterUpdate;
-
- return shouldResolve || shouldToggleState;
+ shouldToggleResolved(beforeSubmitDiscussionState) {
+ return (
+ this.showResolveDiscussionToggle && beforeSubmitDiscussionState !== this.newResolvedState()
+ );
+ },
+ newResolvedState() {
+ return (
+ (this.discussionResolved && !this.isUnresolving) ||
+ (!this.discussionResolved && this.isResolving)
+ );
},
editMyLastNote() {
if (this.updatedNoteBody === '') {
@@ -293,7 +295,7 @@ export default {
}
this.updatedNoteBody = '';
},
- handleUpdate(shouldResolve) {
+ handleUpdate() {
const beforeSubmitDiscussionState = this.discussionResolved;
this.isSubmitting = true;
@@ -309,23 +311,13 @@ export default {
() => {
this.isSubmitting = false;
- if (this.shouldToggleResolved(shouldResolve, beforeSubmitDiscussionState)) {
+ if (this.shouldToggleResolved(beforeSubmitDiscussionState)) {
this.resolveHandler(beforeSubmitDiscussionState);
}
},
this.discussionResolved ? !this.isUnresolving : this.isResolving,
);
},
- shouldBeResolved(resolveStatus) {
- if (this.withBatchComments) {
- return (
- (this.discussionResolved && !this.isUnresolving) ||
- (!this.discussionResolved && this.isResolving)
- );
- }
-
- return resolveStatus;
- },
handleAddToReview() {
// check if draft should resolve thread
const shouldResolve =
@@ -390,21 +382,22 @@ export default {
/>
</comment-field-layout>
<div class="note-form-actions">
+ <p v-if="showResolveDiscussionToggle">
+ <label>
+ <template v-if="discussionResolved">
+ <gl-form-checkbox v-model="isUnresolving" class="js-unresolve-checkbox">
+ {{ __('Unresolve thread') }}
+ </gl-form-checkbox>
+ </template>
+ <template v-else>
+ <gl-form-checkbox v-model="isResolving" class="js-resolve-checkbox">
+ {{ __('Resolve thread') }}
+ </gl-form-checkbox>
+ </template>
+ </label>
+ </p>
+
<template v-if="showBatchCommentsActions">
- <p v-if="showResolveDiscussionToggle">
- <label>
- <template v-if="discussionResolved">
- <gl-form-checkbox v-model="isUnresolving" class="js-unresolve-checkbox">
- {{ __('Unresolve thread') }}
- </gl-form-checkbox>
- </template>
- <template v-else>
- <gl-form-checkbox v-model="isResolving" class="js-resolve-checkbox">
- {{ __('Resolve thread') }}
- </gl-form-checkbox>
- </template>
- </label>
- </p>
<div class="gl-display-flex gl-flex-wrap gl-mb-n3">
<gl-button
:disabled="isDisabled"
@@ -451,15 +444,6 @@ export default {
{{ saveButtonTitle }}
</gl-button>
<gl-button
- v-if="discussion.resolvable"
- category="secondary"
- variant="default"
- class="gl-sm-mr-3 gl-xs-mb-3 js-comment-resolve-button"
- @click.prevent="handleUpdate(true)"
- >
- {{ resolveButtonTitle }}
- </gl-button>
- <gl-button
class="note-edit-cancel js-close-discussion-note-form"
category="secondary"
variant="default"
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index 83cebb9a0e0..bdf9ea2057c 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -1,5 +1,6 @@
<script>
import { GlIcon, GlBadge, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
diff --git a/app/assets/javascripts/notes/components/note_signed_out_widget.vue b/app/assets/javascripts/notes/components/note_signed_out_widget.vue
index 94636b3e47b..30d3bfcb989 100644
--- a/app/assets/javascripts/notes/components/note_signed_out_widget.vue
+++ b/app/assets/javascripts/notes/components/note_signed_out_widget.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { __, sprintf } from '~/locale';
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 7e79edfea15..94d5dc25b9e 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -1,5 +1,6 @@
<script>
import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters } from 'vuex';
import DraftNote from '~/batch_comments/components/draft_note.vue';
import { createAlert } from '~/alert';
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 69c41af97ab..9a7cc1a4d37 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -2,6 +2,7 @@
import { GlSprintf, GlAvatarLink, GlAvatar } from '@gitlab/ui';
import $ from 'jquery';
import { escape, isEmpty } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters, mapActions } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 06c925002b6..6fb958e810b 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapGetters, mapActions } from 'vuex';
import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
@@ -231,7 +232,7 @@ export default {
:ai-loading="aiLoading"
@set-ai-loading="setAiLoading"
/>
- <ai-summary v-if="aiLoading !== null" @set-ai-loading="setAiLoading" />
+ <ai-summary v-if="aiLoading !== null" :ai-loading="aiLoading" @set-ai-loading="setAiLoading" />
<ordered-layout :slot-keys="slotKeys">
<template #form>
<comment-form
diff --git a/app/assets/javascripts/notes/components/sidebar_subscription.vue b/app/assets/javascripts/notes/components/sidebar_subscription.vue
index 2a0a3d5414f..f60a17eb36b 100644
--- a/app/assets/javascripts/notes/components/sidebar_subscription.vue
+++ b/app/assets/javascripts/notes/components/sidebar_subscription.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import { TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants';
import { fetchPolicies } from '~/lib/graphql';
diff --git a/app/assets/javascripts/notes/components/timeline_toggle.vue b/app/assets/javascripts/notes/components/timeline_toggle.vue
index 59a3cc2d306..a627047faf9 100644
--- a/app/assets/javascripts/notes/components/timeline_toggle.vue
+++ b/app/assets/javascripts/notes/components/timeline_toggle.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters } from 'vuex';
import { s__ } from '~/locale';
import TrackEventDirective from '~/vue_shared/directives/track_event';
diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js
index 419b427682e..999ef8ff905 100644
--- a/app/assets/javascripts/notes/constants.js
+++ b/app/assets/javascripts/notes/constants.js
@@ -10,6 +10,9 @@ export const COMMENT = 'comment';
export const ISSUE_NOTEABLE_TYPE = 'Issue';
export const EPIC_NOTEABLE_TYPE = 'Epic';
export const MERGE_REQUEST_NOTEABLE_TYPE = 'MergeRequest';
+export const SNIPPET_NOTEABLE_TYPE = 'Snippet';
+export const DESIGN_NOTEABLE_TYPE = 'DesignManagement::Design';
+export const COMMIT_NOTEABLE_TYPE = 'Commit';
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';
diff --git a/app/assets/javascripts/notes/mixins/diff_line_note_form.js b/app/assets/javascripts/notes/mixins/diff_line_note_form.js
index cb6f72538b9..34090d22cec 100644
--- a/app/assets/javascripts/notes/mixins/diff_line_note_form.js
+++ b/app/assets/javascripts/notes/mixins/diff_line_note_form.js
@@ -1,3 +1,4 @@
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import { getDraftReplyFormData, getDraftFormData } from '~/batch_comments/utils';
import {
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index 8e69f1ddc88..212ca6851f6 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -1,3 +1,4 @@
+// eslint-disable-next-line no-restricted-imports
import { mapGetters, mapActions, mapState } from 'vuex';
import { scrollToElement, contentTop } from '~/lib/utils/common_utils';
diff --git a/app/assets/javascripts/notes/mixins/issuable_state.js b/app/assets/javascripts/notes/mixins/issuable_state.js
index 52b67764b70..54f1a1a0cb3 100644
--- a/app/assets/javascripts/notes/mixins/issuable_state.js
+++ b/app/assets/javascripts/notes/mixins/issuable_state.js
@@ -1,3 +1,4 @@
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
export default {
diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js
index 63822a31cd1..814702b724d 100644
--- a/app/assets/javascripts/notes/mixins/resolvable.js
+++ b/app/assets/javascripts/notes/mixins/resolvable.js
@@ -11,14 +11,6 @@ export default {
return this.note.resolved;
},
resolveButtonTitle() {
- if (this.updatedNoteBody) {
- if (this.discussionResolved) {
- return __('Comment & unresolve thread');
- }
-
- return __('Comment & resolve thread');
- }
-
return this.discussionResolved ? __('Unresolve thread') : __('Resolve thread');
},
},
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 1bb44988c4d..0444eca9aa7 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import Visibility from 'visibilityjs';
import Vue from 'vue';
+import actionCable from '~/actioncable_consumer';
import Api from '~/api';
import { createAlert, VARIANT_INFO } from '~/alert';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
@@ -151,7 +152,30 @@ export const initPolling = ({ state, dispatch, getters, commit }) => {
dispatch('setLastFetchedAt', getters.getNotesDataByProp('lastFetchedAt'));
- dispatch('poll');
+ if (gon.features?.actionCableNotes) {
+ actionCable.subscriptions.create(
+ {
+ channel: 'Noteable::NotesChannel',
+ project_id: state.notesData.projectId,
+ group_id: state.notesData.groupId,
+ noteable_type: state.notesData.noteableType,
+ noteable_id: state.notesData.noteableId,
+ },
+ {
+ connected() {
+ dispatch('fetchUpdatedNotes');
+ },
+ received(data) {
+ if (data.event === 'updated') {
+ dispatch('fetchUpdatedNotes');
+ }
+ },
+ },
+ );
+ } else {
+ dispatch('poll');
+ }
+
commit(types.SET_IS_POLLING_INITIALIZED, true);
};
@@ -491,7 +515,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
{"commands_changes":{},"valid":false,"errors":{"commands_only":["Commands applied"]}}
*/
if (hasQuickActions && message) {
- eTagPoll.makeRequest();
+ if (eTagPoll) eTagPoll.makeRequest();
// synchronizing the quick action with the sidebar widget
// this is a temporary solution until we have confidentiality real-time updates
@@ -592,6 +616,21 @@ const getFetchDataParams = (state) => {
return { endpoint, options };
};
+export const fetchUpdatedNotes = ({ commit, state, getters, dispatch }) => {
+ const { endpoint, options } = getFetchDataParams(state);
+
+ return axios
+ .get(endpoint, options)
+ .then(({ data }) => {
+ pollSuccessCallBack(data, commit, state, getters, dispatch);
+ })
+ .catch(() => {
+ createAlert({
+ message: __('Something went wrong while fetching latest comments.'),
+ });
+ });
+};
+
export const poll = ({ commit, state, getters, dispatch }) => {
const notePollOccurrenceTracking = create();
let alert;
diff --git a/app/assets/javascripts/notes/stores/index.js b/app/assets/javascripts/notes/stores/index.js
index c4895f58656..483e21b340e 100644
--- a/app/assets/javascripts/notes/stores/index.js
+++ b/app/assets/javascripts/notes/stores/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import notesModule from './modules';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index a67928c387b..966f4184780 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -238,26 +238,36 @@ export default {
},
[types.UPDATE_NOTE](state, note) {
- const noteObj = utils.findNoteObjectById(state.discussions, note.discussion_id);
+ const discussion = utils.findNoteObjectById(state.discussions, note.discussion_id);
// Disable eslint here so we can delete the property that we no longer need
// in the note object
// eslint-disable-next-line no-param-reassign
delete note.base_discussion;
- if (noteObj.individual_note) {
+ if (discussion.individual_note) {
if (note.type === constants.DISCUSSION_NOTE) {
- noteObj.individual_note = false;
+ discussion.individual_note = false;
}
- noteObj.notes.splice(0, 1, note);
+ discussion.notes.splice(0, 1, note);
} else {
- const comment = utils.findNoteObjectById(noteObj.notes, note.id);
+ const comment = utils.findNoteObjectById(discussion.notes, note.id);
if (!isEqual(comment, note)) {
- noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note);
+ discussion.notes.splice(discussion.notes.indexOf(comment), 1, note);
}
}
+
+ if (note.resolvable && note.id === discussion.notes[0].id) {
+ Object.assign(discussion, {
+ resolvable: note.resolvable,
+ resolved: note.resolved,
+ resolved_at: note.resolved_at,
+ resolved_by: note.resolved_by,
+ resolved_by_push: note.resolved_by_push,
+ });
+ }
},
[types.APPLY_SUGGESTION](state, { noteId, discussionId, suggestionId }) {
diff --git a/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue b/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue
index 2138372d8ad..16dff6ef784 100644
--- a/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue
+++ b/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue
@@ -37,7 +37,6 @@ export default {
is-check-item
:is-checked="isActive"
:class="{ 'is-active': isActive }"
- data-testid="notification-item"
@click="$emit('item-selected', level)"
>
<div class="gl-display-flex gl-flex-direction-column">
diff --git a/app/assets/javascripts/oauth_application/components/oauth_secret.vue b/app/assets/javascripts/oauth_application/components/oauth_secret.vue
index c4a928c5e07..12374bcf261 100644
--- a/app/assets/javascripts/oauth_application/components/oauth_secret.vue
+++ b/app/assets/javascripts/oauth_application/components/oauth_secret.vue
@@ -81,6 +81,7 @@ export default {
v-if="secret"
:copy-button-title="$options.COPY_SECRET"
:value="secret"
+ readonly
class="gl-mt-n3 gl-mb-0"
>
<template #description>
diff --git a/app/assets/javascripts/observability/client.js b/app/assets/javascripts/observability/client.js
index 251c165e7dd..c55600f3db2 100644
--- a/app/assets/javascripts/observability/client.js
+++ b/app/assets/javascripts/observability/client.js
@@ -1,4 +1,5 @@
import axios from '~/lib/utils/axios_utils';
+// import mockData from './mock_traces.json';
function enableTraces() {
// TODO remove mocks https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2271
@@ -19,25 +20,169 @@ function isTracingEnabled() {
});
}
-async function fetchTraces(tracingUrl) {
- const { data } = await axios.get(tracingUrl, { withCredentials: true });
- if (!Array.isArray(data.traces)) {
+function traceWithDuration(trace) {
+ // aggregating duration on the client for now, but expecting to be coming from the backend
+ // https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2274
+ const duration = trace.spans[0].duration_nano;
+ return {
+ ...trace,
+ duration: duration / 1000,
+ };
+}
+
+async function fetchTrace(tracingUrl, traceId) {
+ if (!traceId) {
+ throw new Error('traceId is required.');
+ }
+
+ const { data } = await axios.get(tracingUrl, {
+ withCredentials: true,
+ params: {
+ trace_id: traceId,
+ },
+ });
+
+ // TODO: Improve local GDK dev experience with tracing https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2308
+ // const data = mockData;
+ // const trace = data.traces.find((t) => t.trace_id === traceId);
+
+ if (!Array.isArray(data.traces) || data.traces.length === 0) {
throw new Error('traces are missing/invalid in the response.'); // eslint-disable-line @gitlab/require-i18n-strings
}
- return data.traces.map((t) => {
- // aggregating duration on the client for now, but expecting to be coming from the backend
- const duration = t.spans.reduce((acc, cur) => acc + cur.duration_nano, 0);
- return {
- ...t,
- duration: duration / 1000,
- };
+
+ const trace = data.traces[0];
+ return traceWithDuration(trace);
+}
+
+/**
+ * Filters (and operators) allowed by tracing query API
+ */
+const SUPPORTED_FILTERS = {
+ durationMs: ['>', '<'],
+ operation: ['=', '!='],
+ serviceName: ['=', '!='],
+ period: ['='],
+ traceId: ['=', '!='],
+ // free-text 'search' temporarily ignored https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2309
+};
+
+/**
+ * Mapping of filter name to query param
+ */
+const FILTER_TO_QUERY_PARAM = {
+ durationMs: 'duration_nano',
+ operation: 'operation',
+ serviceName: 'service_name',
+ period: 'period',
+ traceId: 'trace_id',
+};
+
+const FILTER_OPERATORS_PREFIX = {
+ '!=': 'not',
+ '>': 'gt',
+ '<': 'lt',
+};
+
+/**
+ * Builds the query param name for the given filter and operator
+ *
+ * @param {String} filterName - The filter name
+ * @param {String} operator - The operator
+ * @returns String | undefined - Query param name
+ */
+function getFilterParamName(filterName, operator) {
+ const paramKey = FILTER_TO_QUERY_PARAM[filterName];
+ if (!paramKey) return undefined;
+
+ if (operator === '=') {
+ return paramKey;
+ }
+
+ const prefix = FILTER_OPERATORS_PREFIX[operator];
+ if (prefix) {
+ return `${prefix}[${paramKey}]`;
+ }
+
+ return undefined;
+}
+
+/**
+ * Builds URLSearchParams from a filter object of type { [filterName]: undefined | null | Array<{operator: String, value: any} }
+ * e.g:
+ *
+ * filterObj = {
+ * durationMs: [{operator: '>', value: '100'}, {operator: '<', value: '1000' }],
+ * operation: [{operator: '=', value: 'someOp' }],
+ * serviceName: [{operator: '!=', value: 'foo' }]
+ * }
+ *
+ * It handles converting the filter to the proper supported query params
+ *
+ * @param {Object} filterObj : An Object representing filters
+ * @returns URLSearchParams
+ */
+function filterObjToQueryParams(filterObj) {
+ const filterParams = new URLSearchParams();
+
+ Object.keys(SUPPORTED_FILTERS).forEach((filterName) => {
+ const filterValues = filterObj[filterName] || [];
+ const supportedFilters = filterValues.filter((f) =>
+ SUPPORTED_FILTERS[filterName].includes(f.operator),
+ );
+ supportedFilters.forEach(({ operator, value: rawValue }) => {
+ const paramName = getFilterParamName(filterName, operator);
+
+ let value = rawValue;
+ if (filterName === 'durationMs') {
+ // converting durationMs to duration_nano
+ value *= 1000;
+ }
+
+ if (paramName && value) {
+ filterParams.append(paramName, value);
+ }
+ });
});
+ return filterParams;
+}
+
+/**
+ * Fetches traces with given tracing API URL and filters
+ *
+ * @param {String} tracingUrl : The API base URL
+ * @param {Object} filters : A filter object of type: { [filterName]: undefined | null | Array<{operator: String, value: String} }
+ * e.g:
+ *
+ * {
+ * durationMs: [ {operator: '>', value: '100'}, {operator: '<', value: '1000'}],
+ * operation: [ {operator: '=', value: 'someOp}],
+ * serviceName: [ {operator: '!=', value: 'foo}]
+ * }
+ *
+ * @returns Array<Trace> : A list of traces
+ */
+async function fetchTraces(tracingUrl, filters = {}) {
+ const filterParams = filterObjToQueryParams(filters);
+
+ const { data } = await axios.get(tracingUrl, {
+ withCredentials: true,
+ params: filterParams,
+ });
+ // TODO: Improve local GDK dev experience with tracing https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2308
+ // Uncomment the line below to test this locally
+ // const data = mockData;
+
+ if (!Array.isArray(data.traces)) {
+ throw new Error('traces are missing/invalid in the response.'); // eslint-disable-line @gitlab/require-i18n-strings
+ }
+ return data.traces.map(traceWithDuration);
}
export function buildClient({ provisioningUrl, tracingUrl }) {
return {
enableTraces: () => enableTraces(provisioningUrl),
isTracingEnabled: () => isTracingEnabled(provisioningUrl),
- fetchTraces: () => fetchTraces(tracingUrl),
+ fetchTraces: (filters) => fetchTraces(tracingUrl, filters),
+ fetchTrace: (traceId) => fetchTrace(tracingUrl, traceId),
};
}
diff --git a/app/assets/javascripts/observability/components/observability_container.vue b/app/assets/javascripts/observability/components/observability_container.vue
index 4306f531ab5..b7697cea299 100644
--- a/app/assets/javascripts/observability/components/observability_container.vue
+++ b/app/assets/javascripts/observability/components/observability_container.vue
@@ -31,8 +31,8 @@ export default {
mounted() {
window.addEventListener('message', this.messageHandler);
- // TODO Remove once backend work done - just for testing
- // https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2270
+ // TODO: Improve local GDK dev experience with tracing https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2308
+ // Uncomment the lines below to to test this locally
// setTimeout(() => {
// this.messageHandler({
// data: { type: 'AUTH_COMPLETION', status: 'success' },
diff --git a/app/assets/javascripts/observability/components/skeleton/dashboards.vue b/app/assets/javascripts/observability/components/skeleton/dashboards.vue
index 8b106407953..887a0a9f094 100644
--- a/app/assets/javascripts/observability/components/skeleton/dashboards.vue
+++ b/app/assets/javascripts/observability/components/skeleton/dashboards.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
diff --git a/app/assets/javascripts/observability/components/skeleton/embed.vue b/app/assets/javascripts/observability/components/skeleton/embed.vue
index 7abaf2b1bc7..965beb168bf 100644
--- a/app/assets/javascripts/observability/components/skeleton/embed.vue
+++ b/app/assets/javascripts/observability/components/skeleton/embed.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
diff --git a/app/assets/javascripts/observability/components/skeleton/explore.vue b/app/assets/javascripts/observability/components/skeleton/explore.vue
index 1fcbd4fb1cb..3f748086eef 100644
--- a/app/assets/javascripts/observability/components/skeleton/explore.vue
+++ b/app/assets/javascripts/observability/components/skeleton/explore.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
diff --git a/app/assets/javascripts/observability/components/skeleton/index.vue b/app/assets/javascripts/observability/components/skeleton/index.vue
index 4df0f86be1f..d3c6892df50 100644
--- a/app/assets/javascripts/observability/components/skeleton/index.vue
+++ b/app/assets/javascripts/observability/components/skeleton/index.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlSkeletonLoader, GlAlert, GlLoadingIcon } from '@gitlab/ui';
diff --git a/app/assets/javascripts/observability/components/skeleton/manage.vue b/app/assets/javascripts/observability/components/skeleton/manage.vue
index 4b029120328..cf8c900fe11 100644
--- a/app/assets/javascripts/observability/components/skeleton/manage.vue
+++ b/app/assets/javascripts/observability/components/skeleton/manage.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
diff --git a/app/assets/javascripts/observability/mock_traces.json b/app/assets/javascripts/observability/mock_traces.json
index 6f83f718d96..ee59258e591 100644
--- a/app/assets/javascripts/observability/mock_traces.json
+++ b/app/assets/javascripts/observability/mock_traces.json
@@ -1,2807 +1,348 @@
{
- "project_id": "1",
- "message": "",
+ "project_id": "10141740",
"traces": [
{
- "timestamp": "2023-07-10T15:02:30.677538Z",
- "trace_id": "97e6b7b3-9579-b6e9-eb86-25f1ded2cff0",
- "service_name": "tracegen",
- "operation": "lets-go",
+ "timestamp": "2023-07-18T10:31:23.661285Z",
+ "trace_id": "08a1b018-e1b9-88b2-094b-ca5fd40783ad",
+ "service_name": "my-service-name",
+ "operation": "Multiplication",
"statusCode": "STATUS_CODE_UNSET",
"spans": [
{
- "timestamp": "2023-07-10T15:02:30.677538Z",
- "span_id": "E2CB9B54BB6FCAC1",
- "trace_id": "97e6b7b3-9579-b6e9-eb86-25f1ded2cff0",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 147000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677561Z",
- "span_id": "4B29015A902EF378",
- "trace_id": "97e6b7b3-9579-b6e9-eb86-25f1ded2cff0",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677538Z",
- "trace_id": "97e6b7b3-9579-b6e9-eb86-25f1ded2cff0",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677538Z",
- "span_id": "E2CB9B54BB6FCAC1",
- "trace_id": "97e6b7b3-9579-b6e9-eb86-25f1ded2cff0",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 147000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677561Z",
- "span_id": "4B29015A902EF378",
- "trace_id": "97e6b7b3-9579-b6e9-eb86-25f1ded2cff0",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.67758Z",
- "trace_id": "36f65703-d085-0674-a589-b35db23f77d5",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.67758Z",
- "span_id": "F0788D69026E13A1",
- "trace_id": "36f65703-d085-0674-a589-b35db23f77d5",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677581Z",
- "span_id": "14987F8F6FDD27AE",
- "trace_id": "36f65703-d085-0674-a589-b35db23f77d5",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.67758Z",
- "trace_id": "36f65703-d085-0674-a589-b35db23f77d5",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.67758Z",
- "span_id": "F0788D69026E13A1",
- "trace_id": "36f65703-d085-0674-a589-b35db23f77d5",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677581Z",
- "span_id": "14987F8F6FDD27AE",
- "trace_id": "36f65703-d085-0674-a589-b35db23f77d5",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677583Z",
- "trace_id": "219da434-0271-581c-0bfa-1c0e31f7bc03",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677583Z",
- "span_id": "F5AB66F29F53ECAF",
- "trace_id": "219da434-0271-581c-0bfa-1c0e31f7bc03",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677584Z",
- "span_id": "17D79F52E57E9C6A",
- "trace_id": "219da434-0271-581c-0bfa-1c0e31f7bc03",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677583Z",
- "trace_id": "219da434-0271-581c-0bfa-1c0e31f7bc03",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677583Z",
- "span_id": "F5AB66F29F53ECAF",
- "trace_id": "219da434-0271-581c-0bfa-1c0e31f7bc03",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677584Z",
- "span_id": "17D79F52E57E9C6A",
- "trace_id": "219da434-0271-581c-0bfa-1c0e31f7bc03",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677585Z",
- "trace_id": "e899d9a8-4d54-b131-9580-e540697604b4",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677585Z",
- "span_id": "468B2959252EDA28",
- "trace_id": "e899d9a8-4d54-b131-9580-e540697604b4",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677586Z",
- "span_id": "7AC8860F5CB85E0A",
- "trace_id": "e899d9a8-4d54-b131-9580-e540697604b4",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677585Z",
- "trace_id": "e899d9a8-4d54-b131-9580-e540697604b4",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677585Z",
- "span_id": "468B2959252EDA28",
- "trace_id": "e899d9a8-4d54-b131-9580-e540697604b4",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677586Z",
- "span_id": "7AC8860F5CB85E0A",
- "trace_id": "e899d9a8-4d54-b131-9580-e540697604b4",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677588Z",
- "trace_id": "057228bd-3c6d-2551-b779-641d71f111c7",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677588Z",
- "span_id": "3411BDD296DB9370",
- "trace_id": "057228bd-3c6d-2551-b779-641d71f111c7",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677589Z",
- "span_id": "1774F16A178B8FCB",
- "trace_id": "057228bd-3c6d-2551-b779-641d71f111c7",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677588Z",
- "trace_id": "057228bd-3c6d-2551-b779-641d71f111c7",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677588Z",
- "span_id": "3411BDD296DB9370",
- "trace_id": "057228bd-3c6d-2551-b779-641d71f111c7",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677589Z",
- "span_id": "1774F16A178B8FCB",
- "trace_id": "057228bd-3c6d-2551-b779-641d71f111c7",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677613Z",
- "trace_id": "a6d8c574-d1ce-e46d-8634-e12d1251a009",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677613Z",
- "span_id": "CDACF24BB78C3534",
- "trace_id": "a6d8c574-d1ce-e46d-8634-e12d1251a009",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677614Z",
- "span_id": "7B69777569B9EA84",
- "trace_id": "a6d8c574-d1ce-e46d-8634-e12d1251a009",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677613Z",
- "trace_id": "a6d8c574-d1ce-e46d-8634-e12d1251a009",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677613Z",
- "span_id": "CDACF24BB78C3534",
- "trace_id": "a6d8c574-d1ce-e46d-8634-e12d1251a009",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677614Z",
- "span_id": "7B69777569B9EA84",
- "trace_id": "a6d8c574-d1ce-e46d-8634-e12d1251a009",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677616Z",
- "trace_id": "d29efaa9-f0dd-8ce8-901c-9bb076292144",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677616Z",
- "span_id": "1265DF31CD5EC4E2",
- "trace_id": "d29efaa9-f0dd-8ce8-901c-9bb076292144",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677617Z",
- "span_id": "3E0260222F729537",
- "trace_id": "d29efaa9-f0dd-8ce8-901c-9bb076292144",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677616Z",
- "trace_id": "d29efaa9-f0dd-8ce8-901c-9bb076292144",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677616Z",
- "span_id": "1265DF31CD5EC4E2",
- "trace_id": "d29efaa9-f0dd-8ce8-901c-9bb076292144",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677617Z",
- "span_id": "3E0260222F729537",
- "trace_id": "d29efaa9-f0dd-8ce8-901c-9bb076292144",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677619Z",
- "trace_id": "175fe57e-ce90-3e58-6e75-ffb37e6ed787",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677619Z",
- "span_id": "3A06AC7ABCA9D043",
- "trace_id": "175fe57e-ce90-3e58-6e75-ffb37e6ed787",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677619Z",
- "span_id": "9C99F917736586E1",
- "trace_id": "175fe57e-ce90-3e58-6e75-ffb37e6ed787",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677619Z",
- "trace_id": "175fe57e-ce90-3e58-6e75-ffb37e6ed787",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677619Z",
- "span_id": "3A06AC7ABCA9D043",
- "trace_id": "175fe57e-ce90-3e58-6e75-ffb37e6ed787",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677619Z",
- "span_id": "9C99F917736586E1",
- "trace_id": "175fe57e-ce90-3e58-6e75-ffb37e6ed787",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677621Z",
- "trace_id": "5bda0cda-5c5d-ccc6-7266-5e438572bccf",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677621Z",
- "span_id": "B2417463C771A704",
- "trace_id": "5bda0cda-5c5d-ccc6-7266-5e438572bccf",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677622Z",
- "span_id": "897DD866880697F0",
- "trace_id": "5bda0cda-5c5d-ccc6-7266-5e438572bccf",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677621Z",
- "trace_id": "5bda0cda-5c5d-ccc6-7266-5e438572bccf",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677621Z",
- "span_id": "B2417463C771A704",
- "trace_id": "5bda0cda-5c5d-ccc6-7266-5e438572bccf",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677622Z",
- "span_id": "897DD866880697F0",
- "trace_id": "5bda0cda-5c5d-ccc6-7266-5e438572bccf",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677637Z",
- "trace_id": "1b00f009-80fb-4389-5629-0e6326838a87",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677637Z",
- "span_id": "AB982E41826E4CB4",
- "trace_id": "1b00f009-80fb-4389-5629-0e6326838a87",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677638Z",
- "span_id": "8577639018E3ACE2",
- "trace_id": "1b00f009-80fb-4389-5629-0e6326838a87",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677637Z",
- "trace_id": "1b00f009-80fb-4389-5629-0e6326838a87",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677637Z",
- "span_id": "AB982E41826E4CB4",
- "trace_id": "1b00f009-80fb-4389-5629-0e6326838a87",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677638Z",
- "span_id": "8577639018E3ACE2",
- "trace_id": "1b00f009-80fb-4389-5629-0e6326838a87",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677651Z",
- "trace_id": "effe5f7a-b902-eb19-cccf-9ac4a0aea683",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677651Z",
- "span_id": "E4D0C62B763FC25C",
- "trace_id": "effe5f7a-b902-eb19-cccf-9ac4a0aea683",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677653Z",
- "span_id": "C059EDEE59610CB1",
- "trace_id": "effe5f7a-b902-eb19-cccf-9ac4a0aea683",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677651Z",
- "trace_id": "effe5f7a-b902-eb19-cccf-9ac4a0aea683",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677651Z",
- "span_id": "E4D0C62B763FC25C",
- "trace_id": "effe5f7a-b902-eb19-cccf-9ac4a0aea683",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677653Z",
- "span_id": "C059EDEE59610CB1",
- "trace_id": "effe5f7a-b902-eb19-cccf-9ac4a0aea683",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677654Z",
- "trace_id": "9532c21e-1e43-14b3-d5e9-239256361eb4",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677654Z",
- "span_id": "EF63FD474F6898CE",
- "trace_id": "9532c21e-1e43-14b3-d5e9-239256361eb4",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677655Z",
- "span_id": "694494E5AA2A2763",
- "trace_id": "9532c21e-1e43-14b3-d5e9-239256361eb4",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677654Z",
- "trace_id": "9532c21e-1e43-14b3-d5e9-239256361eb4",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677654Z",
- "span_id": "EF63FD474F6898CE",
- "trace_id": "9532c21e-1e43-14b3-d5e9-239256361eb4",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677655Z",
- "span_id": "694494E5AA2A2763",
- "trace_id": "9532c21e-1e43-14b3-d5e9-239256361eb4",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677657Z",
- "trace_id": "311ab221-6e09-0ec6-e8f4-5bd1a50110f1",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677657Z",
- "span_id": "A7C41B19AC60C808",
- "trace_id": "311ab221-6e09-0ec6-e8f4-5bd1a50110f1",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677657Z",
- "span_id": "57A5EDD6AF5CEB5B",
- "trace_id": "311ab221-6e09-0ec6-e8f4-5bd1a50110f1",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677657Z",
- "trace_id": "311ab221-6e09-0ec6-e8f4-5bd1a50110f1",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677657Z",
- "span_id": "A7C41B19AC60C808",
- "trace_id": "311ab221-6e09-0ec6-e8f4-5bd1a50110f1",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677657Z",
- "span_id": "57A5EDD6AF5CEB5B",
- "trace_id": "311ab221-6e09-0ec6-e8f4-5bd1a50110f1",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677659Z",
- "trace_id": "d32d61e6-a3c7-ac62-3a54-37b4f82255e4",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677659Z",
- "span_id": "EA174F950C4D04D8",
- "trace_id": "d32d61e6-a3c7-ac62-3a54-37b4f82255e4",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.67766Z",
- "span_id": "BA21BB5236E2EF8E",
- "trace_id": "d32d61e6-a3c7-ac62-3a54-37b4f82255e4",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677659Z",
- "trace_id": "d32d61e6-a3c7-ac62-3a54-37b4f82255e4",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677659Z",
- "span_id": "EA174F950C4D04D8",
- "trace_id": "d32d61e6-a3c7-ac62-3a54-37b4f82255e4",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.67766Z",
- "span_id": "BA21BB5236E2EF8E",
- "trace_id": "d32d61e6-a3c7-ac62-3a54-37b4f82255e4",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677661Z",
- "trace_id": "43690e3b-acbd-512d-5a4a-a5b2db28e84c",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677661Z",
- "span_id": "BF7E718C91691CBE",
- "trace_id": "43690e3b-acbd-512d-5a4a-a5b2db28e84c",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 129000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677666Z",
- "span_id": "50F782517AF36EA8",
- "trace_id": "43690e3b-acbd-512d-5a4a-a5b2db28e84c",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677661Z",
- "trace_id": "43690e3b-acbd-512d-5a4a-a5b2db28e84c",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677661Z",
- "span_id": "BF7E718C91691CBE",
- "trace_id": "43690e3b-acbd-512d-5a4a-a5b2db28e84c",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 129000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677666Z",
- "span_id": "50F782517AF36EA8",
- "trace_id": "43690e3b-acbd-512d-5a4a-a5b2db28e84c",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677668Z",
- "trace_id": "36764123-3cff-bc0e-70a5-8b781294556f",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677668Z",
- "span_id": "3B5B0841228A565D",
- "trace_id": "36764123-3cff-bc0e-70a5-8b781294556f",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 131000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677669Z",
- "span_id": "62ADBDBA30734B48",
- "trace_id": "36764123-3cff-bc0e-70a5-8b781294556f",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 130000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677668Z",
- "trace_id": "36764123-3cff-bc0e-70a5-8b781294556f",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677668Z",
- "span_id": "3B5B0841228A565D",
- "trace_id": "36764123-3cff-bc0e-70a5-8b781294556f",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 131000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677669Z",
- "span_id": "62ADBDBA30734B48",
- "trace_id": "36764123-3cff-bc0e-70a5-8b781294556f",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 130000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677685Z",
- "trace_id": "c891efc0-b266-cb02-4b8e-1085df4ac0c1",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677685Z",
- "span_id": "8A9D888CF37A3F57",
- "trace_id": "c891efc0-b266-cb02-4b8e-1085df4ac0c1",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677686Z",
- "span_id": "388636C7D201F9FB",
- "trace_id": "c891efc0-b266-cb02-4b8e-1085df4ac0c1",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677685Z",
- "trace_id": "c891efc0-b266-cb02-4b8e-1085df4ac0c1",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677685Z",
- "span_id": "8A9D888CF37A3F57",
- "trace_id": "c891efc0-b266-cb02-4b8e-1085df4ac0c1",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677686Z",
- "span_id": "388636C7D201F9FB",
- "trace_id": "c891efc0-b266-cb02-4b8e-1085df4ac0c1",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677688Z",
- "trace_id": "e78889ea-f202-5c87-e64b-f6db159d9632",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677688Z",
- "span_id": "9E84A870338BBACA",
- "trace_id": "e78889ea-f202-5c87-e64b-f6db159d9632",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 128000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677693Z",
- "span_id": "FC1665CC8A7536B6",
- "trace_id": "e78889ea-f202-5c87-e64b-f6db159d9632",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677688Z",
- "trace_id": "e78889ea-f202-5c87-e64b-f6db159d9632",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677688Z",
- "span_id": "9E84A870338BBACA",
- "trace_id": "e78889ea-f202-5c87-e64b-f6db159d9632",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 128000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677693Z",
- "span_id": "FC1665CC8A7536B6",
- "trace_id": "e78889ea-f202-5c87-e64b-f6db159d9632",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677695Z",
- "trace_id": "5154401c-5126-856b-9474-29efb69b8588",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677695Z",
- "span_id": "1B5140A527AC99F8",
- "trace_id": "5154401c-5126-856b-9474-29efb69b8588",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677695Z",
- "span_id": "6C328C8E60FBB3A8",
- "trace_id": "5154401c-5126-856b-9474-29efb69b8588",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677695Z",
- "trace_id": "5154401c-5126-856b-9474-29efb69b8588",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677695Z",
- "span_id": "1B5140A527AC99F8",
- "trace_id": "5154401c-5126-856b-9474-29efb69b8588",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677695Z",
- "span_id": "6C328C8E60FBB3A8",
- "trace_id": "5154401c-5126-856b-9474-29efb69b8588",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677697Z",
- "trace_id": "fe2fab22-75c3-afa2-6046-2c90b1cd6224",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677697Z",
- "span_id": "BC05CC86EF04A641",
- "trace_id": "fe2fab22-75c3-afa2-6046-2c90b1cd6224",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 129000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677703Z",
- "span_id": "3DAF5282D4311F57",
- "trace_id": "fe2fab22-75c3-afa2-6046-2c90b1cd6224",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677697Z",
- "trace_id": "fe2fab22-75c3-afa2-6046-2c90b1cd6224",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677697Z",
- "span_id": "BC05CC86EF04A641",
- "trace_id": "fe2fab22-75c3-afa2-6046-2c90b1cd6224",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 129000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677703Z",
- "span_id": "3DAF5282D4311F57",
- "trace_id": "fe2fab22-75c3-afa2-6046-2c90b1cd6224",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677707Z",
- "trace_id": "1899b1d8-7f12-e38e-7e7f-e39189ae7c7e",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677707Z",
- "span_id": "AEAF8AC47E800113",
- "trace_id": "1899b1d8-7f12-e38e-7e7f-e39189ae7c7e",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677708Z",
- "span_id": "D6314BCF73DC741F",
- "trace_id": "1899b1d8-7f12-e38e-7e7f-e39189ae7c7e",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677707Z",
- "trace_id": "1899b1d8-7f12-e38e-7e7f-e39189ae7c7e",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677707Z",
- "span_id": "AEAF8AC47E800113",
- "trace_id": "1899b1d8-7f12-e38e-7e7f-e39189ae7c7e",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677708Z",
- "span_id": "D6314BCF73DC741F",
- "trace_id": "1899b1d8-7f12-e38e-7e7f-e39189ae7c7e",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677718Z",
- "trace_id": "e5655518-6f37-8226-0161-f8cb85eb4ba4",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677718Z",
- "span_id": "D0BEDA55261815BE",
- "trace_id": "e5655518-6f37-8226-0161-f8cb85eb4ba4",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677719Z",
- "span_id": "E1FA20547B7056ED",
- "trace_id": "e5655518-6f37-8226-0161-f8cb85eb4ba4",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677718Z",
- "trace_id": "e5655518-6f37-8226-0161-f8cb85eb4ba4",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677718Z",
- "span_id": "D0BEDA55261815BE",
- "trace_id": "e5655518-6f37-8226-0161-f8cb85eb4ba4",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677719Z",
- "span_id": "E1FA20547B7056ED",
- "trace_id": "e5655518-6f37-8226-0161-f8cb85eb4ba4",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677721Z",
- "trace_id": "b0717d3c-d555-e6fa-1e4d-1aca7c889c25",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677721Z",
- "span_id": "B171E7315A8B7FD1",
- "trace_id": "b0717d3c-d555-e6fa-1e4d-1aca7c889c25",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677721Z",
- "span_id": "CD8D690AC2924C06",
- "trace_id": "b0717d3c-d555-e6fa-1e4d-1aca7c889c25",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677721Z",
- "trace_id": "b0717d3c-d555-e6fa-1e4d-1aca7c889c25",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677721Z",
- "span_id": "B171E7315A8B7FD1",
- "trace_id": "b0717d3c-d555-e6fa-1e4d-1aca7c889c25",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677721Z",
- "span_id": "CD8D690AC2924C06",
- "trace_id": "b0717d3c-d555-e6fa-1e4d-1aca7c889c25",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677723Z",
- "trace_id": "0dd663d5-d314-1736-cd5a-90d2811232fc",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677723Z",
- "span_id": "224B0CB973E7D237",
- "trace_id": "0dd663d5-d314-1736-cd5a-90d2811232fc",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677724Z",
- "span_id": "452D473BC85DDBA5",
- "trace_id": "0dd663d5-d314-1736-cd5a-90d2811232fc",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677723Z",
- "trace_id": "0dd663d5-d314-1736-cd5a-90d2811232fc",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677723Z",
- "span_id": "224B0CB973E7D237",
- "trace_id": "0dd663d5-d314-1736-cd5a-90d2811232fc",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677724Z",
- "span_id": "452D473BC85DDBA5",
- "trace_id": "0dd663d5-d314-1736-cd5a-90d2811232fc",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677726Z",
- "trace_id": "b057b9c7-96dd-337a-ff46-c62254e4b0f8",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677726Z",
- "span_id": "6491B39AA9F2CB97",
- "trace_id": "b057b9c7-96dd-337a-ff46-c62254e4b0f8",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677727Z",
- "span_id": "96C2E96EA8D3AF1D",
- "trace_id": "b057b9c7-96dd-337a-ff46-c62254e4b0f8",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677726Z",
- "trace_id": "b057b9c7-96dd-337a-ff46-c62254e4b0f8",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677726Z",
- "span_id": "6491B39AA9F2CB97",
- "trace_id": "b057b9c7-96dd-337a-ff46-c62254e4b0f8",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677727Z",
- "span_id": "96C2E96EA8D3AF1D",
- "trace_id": "b057b9c7-96dd-337a-ff46-c62254e4b0f8",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677728Z",
- "trace_id": "c2b52b78-3a3a-0a44-bc73-04288175f112",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677728Z",
- "span_id": "3A7F5394923A5AF0",
- "trace_id": "c2b52b78-3a3a-0a44-bc73-04288175f112",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677729Z",
- "span_id": "24CC4AB1032650CF",
- "trace_id": "c2b52b78-3a3a-0a44-bc73-04288175f112",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677728Z",
- "trace_id": "c2b52b78-3a3a-0a44-bc73-04288175f112",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677728Z",
- "span_id": "3A7F5394923A5AF0",
- "trace_id": "c2b52b78-3a3a-0a44-bc73-04288175f112",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677729Z",
- "span_id": "24CC4AB1032650CF",
- "trace_id": "c2b52b78-3a3a-0a44-bc73-04288175f112",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677743Z",
- "trace_id": "e25bf442-7823-44e0-4400-0570b4bf525c",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677743Z",
- "span_id": "F783055E10DA9E2D",
- "trace_id": "e25bf442-7823-44e0-4400-0570b4bf525c",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677744Z",
- "span_id": "E581CC7A539137BF",
- "trace_id": "e25bf442-7823-44e0-4400-0570b4bf525c",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677743Z",
- "trace_id": "e25bf442-7823-44e0-4400-0570b4bf525c",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677743Z",
- "span_id": "F783055E10DA9E2D",
- "trace_id": "e25bf442-7823-44e0-4400-0570b4bf525c",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677744Z",
- "span_id": "E581CC7A539137BF",
- "trace_id": "e25bf442-7823-44e0-4400-0570b4bf525c",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677746Z",
- "trace_id": "7737691c-16f6-8aa4-9326-2114e3a1048e",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677746Z",
- "span_id": "89BD651A0E16281F",
- "trace_id": "7737691c-16f6-8aa4-9326-2114e3a1048e",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677747Z",
- "span_id": "1FADCC9FB8DDE886",
- "trace_id": "7737691c-16f6-8aa4-9326-2114e3a1048e",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677746Z",
- "trace_id": "7737691c-16f6-8aa4-9326-2114e3a1048e",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677746Z",
- "span_id": "89BD651A0E16281F",
- "trace_id": "7737691c-16f6-8aa4-9326-2114e3a1048e",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677747Z",
- "span_id": "1FADCC9FB8DDE886",
- "trace_id": "7737691c-16f6-8aa4-9326-2114e3a1048e",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677749Z",
- "trace_id": "79cc7e3b-10dc-7f53-03a6-2a5c4417bdc5",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677749Z",
- "span_id": "09E59AECCEDE7725",
- "trace_id": "79cc7e3b-10dc-7f53-03a6-2a5c4417bdc5",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.67775Z",
- "span_id": "9885AAE43420A45B",
- "trace_id": "79cc7e3b-10dc-7f53-03a6-2a5c4417bdc5",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677749Z",
- "trace_id": "79cc7e3b-10dc-7f53-03a6-2a5c4417bdc5",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677749Z",
- "span_id": "09E59AECCEDE7725",
- "trace_id": "79cc7e3b-10dc-7f53-03a6-2a5c4417bdc5",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.67775Z",
- "span_id": "9885AAE43420A45B",
- "trace_id": "79cc7e3b-10dc-7f53-03a6-2a5c4417bdc5",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677751Z",
- "trace_id": "5e7ba616-863c-a3d9-531d-def5659bfb5f",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677751Z",
- "span_id": "CC204570C29BDBF4",
- "trace_id": "5e7ba616-863c-a3d9-531d-def5659bfb5f",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677752Z",
- "span_id": "D17C651E1245C0F9",
- "trace_id": "5e7ba616-863c-a3d9-531d-def5659bfb5f",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677751Z",
- "trace_id": "5e7ba616-863c-a3d9-531d-def5659bfb5f",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677751Z",
- "span_id": "CC204570C29BDBF4",
- "trace_id": "5e7ba616-863c-a3d9-531d-def5659bfb5f",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677752Z",
- "span_id": "D17C651E1245C0F9",
- "trace_id": "5e7ba616-863c-a3d9-531d-def5659bfb5f",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677755Z",
- "trace_id": "9be40513-f8ae-1f93-0a2b-043323294f75",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677755Z",
- "span_id": "B9C3F1DAF9940B7F",
- "trace_id": "9be40513-f8ae-1f93-0a2b-043323294f75",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677755Z",
- "span_id": "06A614DD43EC1E9A",
- "trace_id": "9be40513-f8ae-1f93-0a2b-043323294f75",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677755Z",
- "trace_id": "9be40513-f8ae-1f93-0a2b-043323294f75",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677755Z",
- "span_id": "B9C3F1DAF9940B7F",
- "trace_id": "9be40513-f8ae-1f93-0a2b-043323294f75",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677755Z",
- "span_id": "06A614DD43EC1E9A",
- "trace_id": "9be40513-f8ae-1f93-0a2b-043323294f75",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677757Z",
- "trace_id": "89f98a5b-09d4-0e0b-3f92-3572152ddc3d",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677757Z",
- "span_id": "46A99707D5225859",
- "trace_id": "89f98a5b-09d4-0e0b-3f92-3572152ddc3d",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 131000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677763Z",
- "span_id": "F489DDD88539BDA4",
- "trace_id": "89f98a5b-09d4-0e0b-3f92-3572152ddc3d",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677757Z",
- "trace_id": "89f98a5b-09d4-0e0b-3f92-3572152ddc3d",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677757Z",
- "span_id": "46A99707D5225859",
- "trace_id": "89f98a5b-09d4-0e0b-3f92-3572152ddc3d",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 131000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677763Z",
- "span_id": "F489DDD88539BDA4",
- "trace_id": "89f98a5b-09d4-0e0b-3f92-3572152ddc3d",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677773Z",
- "trace_id": "b8ea3732-f870-f54c-1b3e-dff49376a1ec",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677773Z",
- "span_id": "3223B8A1131D2A70",
- "trace_id": "b8ea3732-f870-f54c-1b3e-dff49376a1ec",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677774Z",
- "span_id": "82904DC8C7ED5487",
- "trace_id": "b8ea3732-f870-f54c-1b3e-dff49376a1ec",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
+ "timestamp": "2023-07-18T10:31:23.661285Z",
+ "span_id": "30A9220B254C42B1",
+ "trace_id": "08a1b018-e1b9-88b2-094b-ca5fd40783ad",
+ "service_name": "my-service-name",
+ "operation": "Multiplication",
+ "duration_nano": 250,
"statusCode": "STATUS_CODE_UNSET"
}
],
- "totalSpans": 2
+ "totalSpans": 1
},
{
- "timestamp": "2023-07-10T15:02:30.677773Z",
- "trace_id": "b8ea3732-f870-f54c-1b3e-dff49376a1ec",
- "service_name": "tracegen",
- "operation": "lets-go",
+ "timestamp": "2023-07-18T10:31:17.026724Z",
+ "trace_id": "1c2099e0-6da8-d5fb-a91d-bdd5a5bea82c",
+ "service_name": "my-service-name2",
+ "operation": "Addition",
"statusCode": "STATUS_CODE_UNSET",
"spans": [
{
- "timestamp": "2023-07-10T15:02:30.677773Z",
- "span_id": "3223B8A1131D2A70",
- "trace_id": "b8ea3732-f870-f54c-1b3e-dff49376a1ec",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677774Z",
- "span_id": "82904DC8C7ED5487",
- "trace_id": "b8ea3732-f870-f54c-1b3e-dff49376a1ec",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677776Z",
- "trace_id": "a8ed5cde-b713-64ac-e11a-1222134e2857",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677776Z",
- "span_id": "D9EC63C08230FB02",
- "trace_id": "a8ed5cde-b713-64ac-e11a-1222134e2857",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677777Z",
- "span_id": "F504530C5C200E2E",
- "trace_id": "a8ed5cde-b713-64ac-e11a-1222134e2857",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677776Z",
- "trace_id": "a8ed5cde-b713-64ac-e11a-1222134e2857",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677776Z",
- "span_id": "D9EC63C08230FB02",
- "trace_id": "a8ed5cde-b713-64ac-e11a-1222134e2857",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677777Z",
- "span_id": "F504530C5C200E2E",
- "trace_id": "a8ed5cde-b713-64ac-e11a-1222134e2857",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677778Z",
- "trace_id": "bcbb0a0a-35a4-ff10-65b6-848271d6101a",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677778Z",
- "span_id": "6F0F8D30DF04BA3E",
- "trace_id": "bcbb0a0a-35a4-ff10-65b6-848271d6101a",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677779Z",
- "span_id": "2FF73BB3675EBE65",
- "trace_id": "bcbb0a0a-35a4-ff10-65b6-848271d6101a",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677778Z",
- "trace_id": "bcbb0a0a-35a4-ff10-65b6-848271d6101a",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677778Z",
- "span_id": "6F0F8D30DF04BA3E",
- "trace_id": "bcbb0a0a-35a4-ff10-65b6-848271d6101a",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677779Z",
- "span_id": "2FF73BB3675EBE65",
- "trace_id": "bcbb0a0a-35a4-ff10-65b6-848271d6101a",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.67778Z",
- "trace_id": "636bfe8f-c6f3-54a7-f828-b1b85740c33f",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.67778Z",
- "span_id": "3D79FEC1831E8F1A",
- "trace_id": "636bfe8f-c6f3-54a7-f828-b1b85740c33f",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677781Z",
- "span_id": "35D99AA84BCD627A",
- "trace_id": "636bfe8f-c6f3-54a7-f828-b1b85740c33f",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.67778Z",
- "trace_id": "636bfe8f-c6f3-54a7-f828-b1b85740c33f",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.67778Z",
- "span_id": "3D79FEC1831E8F1A",
- "trace_id": "636bfe8f-c6f3-54a7-f828-b1b85740c33f",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677781Z",
- "span_id": "35D99AA84BCD627A",
- "trace_id": "636bfe8f-c6f3-54a7-f828-b1b85740c33f",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677783Z",
- "trace_id": "685185ef-cf46-c7c7-9a00-6553b0171911",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677783Z",
- "span_id": "0077A5FDB210BAB9",
- "trace_id": "685185ef-cf46-c7c7-9a00-6553b0171911",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 130000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677783Z",
- "span_id": "BD4D0517234DC84A",
- "trace_id": "685185ef-cf46-c7c7-9a00-6553b0171911",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 130000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677783Z",
- "trace_id": "685185ef-cf46-c7c7-9a00-6553b0171911",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677783Z",
- "span_id": "0077A5FDB210BAB9",
- "trace_id": "685185ef-cf46-c7c7-9a00-6553b0171911",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 130000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677783Z",
- "span_id": "BD4D0517234DC84A",
- "trace_id": "685185ef-cf46-c7c7-9a00-6553b0171911",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 130000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677793Z",
- "trace_id": "fb17567a-a567-d0ac-c5f0-bbd5abb66cb8",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677793Z",
- "span_id": "C148B50CA183BF05",
- "trace_id": "fb17567a-a567-d0ac-c5f0-bbd5abb66cb8",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677794Z",
- "span_id": "91C5D79D971A4A6E",
- "trace_id": "fb17567a-a567-d0ac-c5f0-bbd5abb66cb8",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677793Z",
- "trace_id": "fb17567a-a567-d0ac-c5f0-bbd5abb66cb8",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677793Z",
- "span_id": "C148B50CA183BF05",
- "trace_id": "fb17567a-a567-d0ac-c5f0-bbd5abb66cb8",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677794Z",
- "span_id": "91C5D79D971A4A6E",
- "trace_id": "fb17567a-a567-d0ac-c5f0-bbd5abb66cb8",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677804Z",
- "trace_id": "d35cd179-4e71-8be7-ba39-b7b5ae2fdaa4",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677804Z",
- "span_id": "948B20672FD5954F",
- "trace_id": "d35cd179-4e71-8be7-ba39-b7b5ae2fdaa4",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677805Z",
- "span_id": "529CA18AF8EF5017",
- "trace_id": "d35cd179-4e71-8be7-ba39-b7b5ae2fdaa4",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
+ "timestamp": "2023-07-18T10:31:17.026724Z",
+ "span_id": "154925D3DA2C1307",
+ "trace_id": "1c2099e0-6da8-d5fb-a91d-bdd5a5bea82c",
+ "service_name": "my-service-name",
+ "operation": "Addition",
+ "duration_nano": 208,
"statusCode": "STATUS_CODE_UNSET"
}
],
- "totalSpans": 2
+ "totalSpans": 1
},
{
- "timestamp": "2023-07-10T15:02:30.677804Z",
- "trace_id": "d35cd179-4e71-8be7-ba39-b7b5ae2fdaa4",
- "service_name": "tracegen",
- "operation": "lets-go",
+ "timestamp": "2023-07-18T10:31:21.602132Z",
+ "trace_id": "f4c2f964-afee-cc2e-bd1a-c654ff55db4e",
+ "service_name": "my-service-name",
+ "operation": "Multiplication",
"statusCode": "STATUS_CODE_UNSET",
"spans": [
{
- "timestamp": "2023-07-10T15:02:30.677804Z",
- "span_id": "948B20672FD5954F",
- "trace_id": "d35cd179-4e71-8be7-ba39-b7b5ae2fdaa4",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677805Z",
- "span_id": "529CA18AF8EF5017",
- "trace_id": "d35cd179-4e71-8be7-ba39-b7b5ae2fdaa4",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
+ "timestamp": "2023-07-18T10:31:21.602132Z",
+ "span_id": "53A4AE94DFF72A28",
+ "trace_id": "f4c2f964-afee-cc2e-bd1a-c654ff55db4e",
+ "service_name": "my-service-name",
+ "operation": "Multiplication",
+ "duration_nano": 5125,
"statusCode": "STATUS_CODE_UNSET"
}
],
- "totalSpans": 2
+ "totalSpans": 1
},
{
- "timestamp": "2023-07-10T15:02:30.677806Z",
- "trace_id": "e156a5f9-c203-2979-8fbc-5f50b8a67f32",
- "service_name": "tracegen",
- "operation": "lets-go",
+ "timestamp": "2023-07-18T10:31:14.772009Z",
+ "trace_id": "fa6302ad-7214-7c05-40f7-91195356774e",
+ "service_name": "my-service-name",
+ "operation": "Multiplication",
"statusCode": "STATUS_CODE_UNSET",
"spans": [
{
- "timestamp": "2023-07-10T15:02:30.677806Z",
- "span_id": "ADF60B9EFA97AD89",
- "trace_id": "e156a5f9-c203-2979-8fbc-5f50b8a67f32",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677807Z",
- "span_id": "03DE70E55CBF857C",
- "trace_id": "e156a5f9-c203-2979-8fbc-5f50b8a67f32",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
+ "timestamp": "2023-07-18T10:31:14.772009Z",
+ "span_id": "5BB240D099656820",
+ "trace_id": "fa6302ad-7214-7c05-40f7-91195356774e",
+ "service_name": "my-service-name",
+ "operation": "Multiplication",
+ "duration_nano": 1584,
"statusCode": "STATUS_CODE_UNSET"
}
],
- "totalSpans": 2
+ "totalSpans": 1
},
{
- "timestamp": "2023-07-10T15:02:30.677806Z",
- "trace_id": "e156a5f9-c203-2979-8fbc-5f50b8a67f32",
- "service_name": "tracegen",
- "operation": "lets-go",
+ "timestamp": "2023-07-18T10:31:22.623552Z",
+ "trace_id": "54021e57-a25c-c6fe-1f53-542bbdbcb16c",
+ "service_name": "my-service-name",
+ "operation": "Multiplication",
"statusCode": "STATUS_CODE_UNSET",
"spans": [
{
- "timestamp": "2023-07-10T15:02:30.677806Z",
- "span_id": "ADF60B9EFA97AD89",
- "trace_id": "e156a5f9-c203-2979-8fbc-5f50b8a67f32",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677807Z",
- "span_id": "03DE70E55CBF857C",
- "trace_id": "e156a5f9-c203-2979-8fbc-5f50b8a67f32",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
+ "timestamp": "2023-07-18T10:31:22.623552Z",
+ "span_id": "C5AE65D0C26BF3FD",
+ "trace_id": "54021e57-a25c-c6fe-1f53-542bbdbcb16c",
+ "service_name": "my-service-name",
+ "operation": "Multiplication",
+ "duration_nano": 750,
"statusCode": "STATUS_CODE_UNSET"
}
],
- "totalSpans": 2
+ "totalSpans": 1
},
{
- "timestamp": "2023-07-10T15:02:30.677809Z",
- "trace_id": "7637dadf-0405-3e70-e78f-f0fa26d42511",
- "service_name": "tracegen",
- "operation": "lets-go",
+ "timestamp": "2023-07-18T10:31:21.602156Z",
+ "trace_id": "34d455cc-e518-fb4e-513f-e88030d4ccc8",
+ "service_name": "my-service-name",
+ "operation": "Multiplication",
"statusCode": "STATUS_CODE_UNSET",
"spans": [
{
- "timestamp": "2023-07-10T15:02:30.677809Z",
- "span_id": "4350413FDF6C0DCD",
- "trace_id": "7637dadf-0405-3e70-e78f-f0fa26d42511",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 128000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677814Z",
- "span_id": "BDC8BD58C638FC78",
- "trace_id": "7637dadf-0405-3e70-e78f-f0fa26d42511",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
+ "timestamp": "2023-07-18T10:31:21.602156Z",
+ "span_id": "5288B61252594EB2",
+ "trace_id": "34d455cc-e518-fb4e-513f-e88030d4ccc8",
+ "service_name": "my-service-name",
+ "operation": "Multiplication",
+ "duration_nano": 750,
"statusCode": "STATUS_CODE_UNSET"
}
],
- "totalSpans": 2
+ "totalSpans": 1
},
{
- "timestamp": "2023-07-10T15:02:30.677809Z",
- "trace_id": "7637dadf-0405-3e70-e78f-f0fa26d42511",
- "service_name": "tracegen",
- "operation": "lets-go",
+ "timestamp": "2023-07-18T10:31:20.567364Z",
+ "trace_id": "3892a93a-f4eb-b416-372e-3c9237be97e3",
+ "service_name": "my-service-name",
+ "operation": "Multiplication",
"statusCode": "STATUS_CODE_UNSET",
"spans": [
{
- "timestamp": "2023-07-10T15:02:30.677809Z",
- "span_id": "4350413FDF6C0DCD",
- "trace_id": "7637dadf-0405-3e70-e78f-f0fa26d42511",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 128000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677814Z",
- "span_id": "BDC8BD58C638FC78",
- "trace_id": "7637dadf-0405-3e70-e78f-f0fa26d42511",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
+ "timestamp": "2023-07-18T10:31:20.567364Z",
+ "span_id": "1D690E5094345C98",
+ "trace_id": "3892a93a-f4eb-b416-372e-3c9237be97e3",
+ "service_name": "my-service-name",
+ "operation": "Multiplication",
+ "duration_nano": 958,
"statusCode": "STATUS_CODE_UNSET"
}
],
- "totalSpans": 2
+ "totalSpans": 1
},
{
- "timestamp": "2023-07-10T15:02:30.677815Z",
- "trace_id": "9fdab630-253f-d030-6f7f-ce1a359fa330",
- "service_name": "tracegen",
- "operation": "lets-go",
+ "timestamp": "2023-07-18T10:31:23.661289Z",
+ "trace_id": "9d0630d5-21b5-686f-57cb-d97c647fc31f",
+ "service_name": "my-service-name",
+ "operation": "Addition",
"statusCode": "STATUS_CODE_UNSET",
"spans": [
{
- "timestamp": "2023-07-10T15:02:30.677815Z",
- "span_id": "1A9A97C656E06304",
- "trace_id": "9fdab630-253f-d030-6f7f-ce1a359fa330",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677816Z",
- "span_id": "5C6D85FF7954A628",
- "trace_id": "9fdab630-253f-d030-6f7f-ce1a359fa330",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677815Z",
- "trace_id": "9fdab630-253f-d030-6f7f-ce1a359fa330",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677815Z",
- "span_id": "1A9A97C656E06304",
- "trace_id": "9fdab630-253f-d030-6f7f-ce1a359fa330",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677816Z",
- "span_id": "5C6D85FF7954A628",
- "trace_id": "9fdab630-253f-d030-6f7f-ce1a359fa330",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
+ "timestamp": "2023-07-18T10:31:23.661289Z",
+ "span_id": "8F548EE08F9C2EAC",
+ "trace_id": "9d0630d5-21b5-686f-57cb-d97c647fc31f",
+ "service_name": "my-service-name",
+ "operation": "Addition",
+ "duration_nano": 167,
"statusCode": "STATUS_CODE_UNSET"
}
],
- "totalSpans": 2
+ "totalSpans": 1
},
{
- "timestamp": "2023-07-10T15:02:30.677819Z",
- "trace_id": "f3a4220e-79e8-dc0a-44b0-33642b2561ad",
- "service_name": "tracegen",
- "operation": "lets-go",
+ "timestamp": "2023-07-18T10:31:14.77197Z",
+ "trace_id": "f2470b0e-3bbb-8af2-68f8-c97343fba7ee",
+ "service_name": "my-service-name",
+ "operation": "Multiplication",
"statusCode": "STATUS_CODE_UNSET",
"spans": [
{
- "timestamp": "2023-07-10T15:02:30.677819Z",
- "span_id": "6ED8D4E93C42E03E",
- "trace_id": "f3a4220e-79e8-dc0a-44b0-33642b2561ad",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.67782Z",
- "span_id": "E3A02E872E9E95EA",
- "trace_id": "f3a4220e-79e8-dc0a-44b0-33642b2561ad",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
+ "timestamp": "2023-07-18T10:31:14.77197Z",
+ "span_id": "6B5AB710CE8A4471",
+ "trace_id": "f2470b0e-3bbb-8af2-68f8-c97343fba7ee",
+ "service_name": "my-service-name",
+ "operation": "Multiplication",
+ "duration_nano": 5583,
"statusCode": "STATUS_CODE_UNSET"
}
],
- "totalSpans": 2
+ "totalSpans": 1
},
{
- "timestamp": "2023-07-10T15:02:30.677819Z",
- "trace_id": "f3a4220e-79e8-dc0a-44b0-33642b2561ad",
- "service_name": "tracegen",
- "operation": "lets-go",
+ "timestamp": "2023-07-18T10:31:17.026712Z",
+ "trace_id": "f4e64ee0-ee32-0edb-c4d1-a15f4047bdc4",
+ "service_name": "my-service-name",
+ "operation": "Multiplication",
"statusCode": "STATUS_CODE_UNSET",
"spans": [
{
- "timestamp": "2023-07-10T15:02:30.677819Z",
- "span_id": "6ED8D4E93C42E03E",
- "trace_id": "f3a4220e-79e8-dc0a-44b0-33642b2561ad",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.67782Z",
- "span_id": "E3A02E872E9E95EA",
- "trace_id": "f3a4220e-79e8-dc0a-44b0-33642b2561ad",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
+ "timestamp": "2023-07-18T10:31:17.026712Z",
+ "span_id": "199D402DE1A29F3F",
+ "trace_id": "f4e64ee0-ee32-0edb-c4d1-a15f4047bdc4",
+ "service_name": "my-service-name",
+ "operation": "Multiplication",
+ "duration_nano": 6959,
"statusCode": "STATUS_CODE_UNSET"
}
],
- "totalSpans": 2
+ "totalSpans": 1
},
{
- "timestamp": "2023-07-10T15:02:30.677824Z",
- "trace_id": "ac93cf21-6d07-94a2-7e87-f64a637a4a05",
- "service_name": "tracegen",
- "operation": "lets-go",
+ "timestamp": "2023-07-18T10:31:20.567337Z",
+ "trace_id": "344b4db1-c890-514c-b94f-425fff3a795b",
+ "service_name": "my-service-name",
+ "operation": "Multiplication",
"statusCode": "STATUS_CODE_UNSET",
"spans": [
{
- "timestamp": "2023-07-10T15:02:30.677824Z",
- "span_id": "2AF1B764C7572560",
- "trace_id": "ac93cf21-6d07-94a2-7e87-f64a637a4a05",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677825Z",
- "span_id": "321BA81B83ABAB3C",
- "trace_id": "ac93cf21-6d07-94a2-7e87-f64a637a4a05",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
+ "timestamp": "2023-07-18T10:31:20.567337Z",
+ "span_id": "CAC38748150E5A0C",
+ "trace_id": "344b4db1-c890-514c-b94f-425fff3a795b",
+ "service_name": "my-service-name",
+ "operation": "Multiplication",
+ "duration_nano": 3917,
"statusCode": "STATUS_CODE_UNSET"
}
],
- "totalSpans": 2
+ "totalSpans": 1
},
{
- "timestamp": "2023-07-10T15:02:30.677824Z",
- "trace_id": "ac93cf21-6d07-94a2-7e87-f64a637a4a05",
- "service_name": "tracegen",
- "operation": "lets-go",
+ "timestamp": "2023-07-18T10:31:22.623559Z",
+ "trace_id": "40b933be-11d7-7b41-e63a-ff7e7c5d50ab",
+ "service_name": "my-service-name",
+ "operation": "Addition",
"statusCode": "STATUS_CODE_UNSET",
"spans": [
{
- "timestamp": "2023-07-10T15:02:30.677824Z",
- "span_id": "2AF1B764C7572560",
- "trace_id": "ac93cf21-6d07-94a2-7e87-f64a637a4a05",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677825Z",
- "span_id": "321BA81B83ABAB3C",
- "trace_id": "ac93cf21-6d07-94a2-7e87-f64a637a4a05",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
+ "timestamp": "2023-07-18T10:31:22.623559Z",
+ "span_id": "3485100A27958F59",
+ "trace_id": "40b933be-11d7-7b41-e63a-ff7e7c5d50ab",
+ "service_name": "my-service-name",
+ "operation": "Addition",
+ "duration_nano": 709,
"statusCode": "STATUS_CODE_UNSET"
}
],
- "totalSpans": 2
+ "totalSpans": 1
},
{
- "timestamp": "2023-07-10T15:02:30.677835Z",
- "trace_id": "d0695ec7-66dd-bbdc-9577-86aaeffe9ec5",
- "service_name": "tracegen",
- "operation": "lets-go",
+ "timestamp": "2023-07-18T10:31:17.026723Z",
+ "trace_id": "c3347c43-316f-2b08-0b46-7d142d6764b7",
+ "service_name": "my-service-name",
+ "operation": "Multiplication",
"statusCode": "STATUS_CODE_UNSET",
"spans": [
{
- "timestamp": "2023-07-10T15:02:30.677835Z",
- "span_id": "3A2C3D8803D79AC4",
- "trace_id": "d0695ec7-66dd-bbdc-9577-86aaeffe9ec5",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677836Z",
- "span_id": "B44FBDD3FD165A7D",
- "trace_id": "d0695ec7-66dd-bbdc-9577-86aaeffe9ec5",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
+ "timestamp": "2023-07-18T10:31:17.026723Z",
+ "span_id": "1CF28C36AB7EB3F9",
+ "trace_id": "c3347c43-316f-2b08-0b46-7d142d6764b7",
+ "service_name": "my-service-name",
+ "operation": "Multiplication",
+ "duration_nano": 208,
"statusCode": "STATUS_CODE_UNSET"
}
],
- "totalSpans": 2
+ "totalSpans": 1
},
{
- "timestamp": "2023-07-10T15:02:30.677835Z",
- "trace_id": "d0695ec7-66dd-bbdc-9577-86aaeffe9ec5",
- "service_name": "tracegen",
- "operation": "lets-go",
+ "timestamp": "2023-07-18T10:31:23.661272Z",
+ "trace_id": "762f9104-d3db-c762-d6d7-0476ca21249f",
+ "service_name": "my-service-name",
+ "operation": "Multiplication",
"statusCode": "STATUS_CODE_UNSET",
"spans": [
{
- "timestamp": "2023-07-10T15:02:30.677835Z",
- "span_id": "3A2C3D8803D79AC4",
- "trace_id": "d0695ec7-66dd-bbdc-9577-86aaeffe9ec5",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677836Z",
- "span_id": "B44FBDD3FD165A7D",
- "trace_id": "d0695ec7-66dd-bbdc-9577-86aaeffe9ec5",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
+ "timestamp": "2023-07-18T10:31:23.661272Z",
+ "span_id": "83D8D6D2BD99A4D1",
+ "trace_id": "762f9104-d3db-c762-d6d7-0476ca21249f",
+ "service_name": "my-service-name",
+ "operation": "Multiplication",
+ "duration_nano": 10000,
"statusCode": "STATUS_CODE_UNSET"
}
],
- "totalSpans": 2
+ "totalSpans": 1
},
{
- "timestamp": "2023-07-10T15:02:30.677838Z",
- "trace_id": "ac155a1f-ab02-d5dc-b77e-65d87e53ab8d",
- "service_name": "tracegen",
- "operation": "okey-dokey",
+ "timestamp": "2023-07-18T10:31:22.623524Z",
+ "trace_id": "b46ded15-f900-fba7-7396-a6b453221038",
+ "service_name": "my-service-name",
+ "operation": "Multiplication",
"statusCode": "STATUS_CODE_UNSET",
"spans": [
{
- "timestamp": "2023-07-10T15:02:30.677838Z",
- "span_id": "B3541547AC06BBBE",
- "trace_id": "ac155a1f-ab02-d5dc-b77e-65d87e53ab8d",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677838Z",
- "span_id": "D9A66F89D75DF7A9",
- "trace_id": "ac155a1f-ab02-d5dc-b77e-65d87e53ab8d",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
+ "timestamp": "2023-07-18T10:31:22.623524Z",
+ "span_id": "EB84455AE35DEAD5",
+ "trace_id": "b46ded15-f900-fba7-7396-a6b453221038",
+ "service_name": "my-service-name",
+ "operation": "Multiplication",
+ "duration_nano": 17666,
"statusCode": "STATUS_CODE_UNSET"
}
],
- "totalSpans": 2
+ "totalSpans": 1
},
{
- "timestamp": "2023-07-10T15:02:30.677838Z",
- "trace_id": "ac155a1f-ab02-d5dc-b77e-65d87e53ab8d",
- "service_name": "tracegen",
- "operation": "okey-dokey",
+ "timestamp": "2023-07-18T10:31:21.60216Z",
+ "trace_id": "8dcd7b90-7f94-6b19-4a8f-b681801568ba",
+ "service_name": "my-service-name",
+ "operation": "Addition",
"statusCode": "STATUS_CODE_UNSET",
"spans": [
{
- "timestamp": "2023-07-10T15:02:30.677838Z",
- "span_id": "B3541547AC06BBBE",
- "trace_id": "ac155a1f-ab02-d5dc-b77e-65d87e53ab8d",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677838Z",
- "span_id": "D9A66F89D75DF7A9",
- "trace_id": "ac155a1f-ab02-d5dc-b77e-65d87e53ab8d",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
+ "timestamp": "2023-07-18T10:31:21.60216Z",
+ "span_id": "A5C773414186949D",
+ "trace_id": "8dcd7b90-7f94-6b19-4a8f-b681801568ba",
+ "service_name": "my-service-name",
+ "operation": "Addition",
+ "duration_nano": 250,
"statusCode": "STATUS_CODE_UNSET"
}
],
- "totalSpans": 2
+ "totalSpans": 1
},
{
- "timestamp": "2023-07-10T15:02:30.67784Z",
- "trace_id": "51b58b66-f449-6183-7844-7d1c6fb834fd",
- "service_name": "tracegen",
- "operation": "lets-go",
+ "timestamp": "2023-07-18T10:31:14.772014Z",
+ "trace_id": "51e23c12-033d-a0db-d2b0-2f3f4e3d9fb3",
+ "service_name": "my-service-name",
+ "operation": "Addition",
"statusCode": "STATUS_CODE_UNSET",
"spans": [
{
- "timestamp": "2023-07-10T15:02:30.67784Z",
- "span_id": "6319FEA0EAA4E4C2",
- "trace_id": "51b58b66-f449-6183-7844-7d1c6fb834fd",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677841Z",
- "span_id": "ACF1D215C677342D",
- "trace_id": "51b58b66-f449-6183-7844-7d1c6fb834fd",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
+ "timestamp": "2023-07-18T10:31:14.772014Z",
+ "span_id": "3397060046FD4428",
+ "trace_id": "51e23c12-033d-a0db-d2b0-2f3f4e3d9fb3",
+ "service_name": "my-service-name",
+ "operation": "Addition",
+ "duration_nano": 291,
"statusCode": "STATUS_CODE_UNSET"
}
],
- "totalSpans": 2
+ "totalSpans": 1
},
{
- "timestamp": "2023-07-10T15:02:30.67784Z",
- "trace_id": "51b58b66-f449-6183-7844-7d1c6fb834fd",
- "service_name": "tracegen",
- "operation": "lets-go",
+ "timestamp": "2023-07-18T10:31:20.567369Z",
+ "trace_id": "d54b5015-3938-ddf8-3aa1-49882503233e",
+ "service_name": "my-service-name",
+ "operation": "Addition",
"statusCode": "STATUS_CODE_UNSET",
"spans": [
{
- "timestamp": "2023-07-10T15:02:30.67784Z",
- "span_id": "6319FEA0EAA4E4C2",
- "trace_id": "51b58b66-f449-6183-7844-7d1c6fb834fd",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677841Z",
- "span_id": "ACF1D215C677342D",
- "trace_id": "51b58b66-f449-6183-7844-7d1c6fb834fd",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 123000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677847Z",
- "trace_id": "fcdba2a3-e163-1676-09fd-7e8a579b2d16",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677847Z",
- "span_id": "BFC1E9D5F3C3DC01",
- "trace_id": "fcdba2a3-e163-1676-09fd-7e8a579b2d16",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 126000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677848Z",
- "span_id": "021083418A0CC7D6",
- "trace_id": "fcdba2a3-e163-1676-09fd-7e8a579b2d16",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677847Z",
- "trace_id": "fcdba2a3-e163-1676-09fd-7e8a579b2d16",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677847Z",
- "span_id": "BFC1E9D5F3C3DC01",
- "trace_id": "fcdba2a3-e163-1676-09fd-7e8a579b2d16",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 126000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677848Z",
- "span_id": "021083418A0CC7D6",
- "trace_id": "fcdba2a3-e163-1676-09fd-7e8a579b2d16",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 125000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677877Z",
- "trace_id": "fb03a9e2-cfb5-cf88-5765-9373285acb88",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677877Z",
- "span_id": "D732E63B1D99C410",
- "trace_id": "fb03a9e2-cfb5-cf88-5765-9373285acb88",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677877Z",
- "span_id": "2115BD5B480ED78A",
- "trace_id": "fb03a9e2-cfb5-cf88-5765-9373285acb88",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.677877Z",
- "trace_id": "fb03a9e2-cfb5-cf88-5765-9373285acb88",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.677877Z",
- "span_id": "D732E63B1D99C410",
- "trace_id": "fb03a9e2-cfb5-cf88-5765-9373285acb88",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677877Z",
- "span_id": "2115BD5B480ED78A",
- "trace_id": "fb03a9e2-cfb5-cf88-5765-9373285acb88",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.67788Z",
- "trace_id": "a1b1e40c-63d1-cd61-c962-e6672540ad11",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.67788Z",
- "span_id": "479F386B26842545",
- "trace_id": "a1b1e40c-63d1-cd61-c962-e6672540ad11",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 129000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677885Z",
- "span_id": "AC44E5C2E91E801A",
- "trace_id": "a1b1e40c-63d1-cd61-c962-e6672540ad11",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
- "statusCode": "STATUS_CODE_UNSET"
- }
- ],
- "totalSpans": 2
- },
- {
- "timestamp": "2023-07-10T15:02:30.67788Z",
- "trace_id": "a1b1e40c-63d1-cd61-c962-e6672540ad11",
- "service_name": "tracegen",
- "operation": "lets-go",
- "statusCode": "STATUS_CODE_UNSET",
- "spans": [
- {
- "timestamp": "2023-07-10T15:02:30.67788Z",
- "span_id": "479F386B26842545",
- "trace_id": "a1b1e40c-63d1-cd61-c962-e6672540ad11",
- "service_name": "tracegen",
- "operation": "lets-go",
- "duration_nano": 129000,
- "statusCode": "STATUS_CODE_UNSET"
- },
- {
- "timestamp": "2023-07-10T15:02:30.677885Z",
- "span_id": "AC44E5C2E91E801A",
- "trace_id": "a1b1e40c-63d1-cd61-c962-e6672540ad11",
- "service_name": "tracegen",
- "operation": "okey-dokey",
- "duration_nano": 124000,
+ "timestamp": "2023-07-18T10:31:20.567369Z",
+ "span_id": "DAC36ACC2DBA8B11",
+ "trace_id": "d54b5015-3938-ddf8-3aa1-49882503233e",
+ "service_name": "my-service-name",
+ "operation": "Addition",
+ "duration_nano": 208,
"statusCode": "STATUS_CODE_UNSET"
}
],
- "totalSpans": 2
+ "totalSpans": 1
}
],
- "totalTraces": 200
+ "totalTraces": 18
}
diff --git a/app/assets/javascripts/organizations/groups_and_projects/components/app.vue b/app/assets/javascripts/organizations/groups_and_projects/components/app.vue
index 2b42c821cd5..10471cc1fdd 100644
--- a/app/assets/javascripts/organizations/groups_and_projects/components/app.vue
+++ b/app/assets/javascripts/organizations/groups_and_projects/components/app.vue
@@ -1,53 +1,127 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-import { __, s__ } from '~/locale';
-import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { createAlert } from '~/alert';
-import projectsQuery from '../graphql/queries/projects.query.graphql';
+import { GlCollapsibleListbox, GlSorting, GlSortingItem } from '@gitlab/ui';
+import { isEqual } from 'lodash';
+import { s__, __ } from '~/locale';
+import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+import {
+ filterToQueryObject,
+ processFilters,
+ urlQueryToFilter,
+ prepareTokens,
+} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
+import {
+ FILTERED_SEARCH_TERM,
+ TOKEN_EMPTY_SEARCH_TERM,
+} from '~/vue_shared/components/filtered_search_bar/constants';
+import {
+ DISPLAY_QUERY_GROUPS,
+ DISPLAY_QUERY_PROJECTS,
+ DISPLAY_LISTBOX_ITEMS,
+ SORT_DIRECTION_ASC,
+ SORT_DIRECTION_DESC,
+ SORT_ITEMS,
+ SORT_ITEM_CREATED,
+ FILTERED_SEARCH_TERM_KEY,
+} from '../constants';
+import GroupsPage from './groups_page.vue';
+import ProjectsPage from './projects_page.vue';
export default {
i18n: {
pageTitle: __('Groups and projects'),
- errorMessage: s__(
- 'Organization|An error occurred loading the projects. Please refresh the page to try again.',
- ),
+ searchInputPlaceholder: s__('Organization|Search or filter list'),
+ displayListboxHeaderText: __('Display'),
},
- components: {
- ProjectsList,
- GlLoadingIcon,
+ components: { FilteredSearchBar, GlCollapsibleListbox, GlSorting, GlSortingItem },
+ filteredSearch: {
+ tokens: [],
+ namespace: 'organization_groups_and_projects',
+ recentSearchesStorageKey: 'organization_groups_and_projects',
},
- data() {
- return {
- projects: [],
- };
- },
- apollo: {
- projects: {
- query: projectsQuery,
- update(data) {
- return data.organization.projects.nodes;
- },
- error(error) {
- createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true });
- },
+ displayListboxItems: DISPLAY_LISTBOX_ITEMS,
+ sortItems: SORT_ITEMS,
+ computed: {
+ routerView() {
+ const { display } = this.$route.query;
+
+ switch (display) {
+ case DISPLAY_QUERY_GROUPS:
+ return GroupsPage;
+
+ case DISPLAY_QUERY_PROJECTS:
+ return ProjectsPage;
+
+ default:
+ return GroupsPage;
+ }
+ },
+ activeSortItem() {
+ return this.$options.sortItems.find((sortItem) => sortItem.name === this.sortName);
+ },
+ sortName() {
+ return this.$route.query.sort_name || SORT_ITEM_CREATED.name;
+ },
+ isAscending() {
+ return this.$route.query.sort_direction !== SORT_DIRECTION_DESC;
+ },
+ sortText() {
+ return this.activeSortItem.text;
+ },
+ filteredSearchValue() {
+ const tokens = prepareTokens(
+ urlQueryToFilter(this.$route.query, {
+ filteredSearchTermKey: FILTERED_SEARCH_TERM_KEY,
+ filterNamesAllowList: [FILTERED_SEARCH_TERM],
+ }),
+ );
+
+ return tokens.length ? tokens : [TOKEN_EMPTY_SEARCH_TERM];
+ },
+ displayListboxSelected() {
+ const { display } = this.$route.query;
+
+ return [DISPLAY_QUERY_GROUPS, DISPLAY_QUERY_PROJECTS].includes(display)
+ ? display
+ : DISPLAY_QUERY_GROUPS;
},
},
- computed: {
- formattedProjects() {
- return this.projects.map(({ id, nameWithNamespace, accessLevel, ...project }) => ({
- ...project,
- id: getIdFromGraphQLId(id),
- name: nameWithNamespace,
- permissions: {
- projectAccess: {
- accessLevel: accessLevel.integerValue,
- },
- },
- }));
- },
- isLoading() {
- return this.$apollo.queries.projects?.loading;
+ methods: {
+ pushQuery(query) {
+ const currentQuery = this.$route.query;
+
+ if (isEqual(currentQuery, query)) {
+ return;
+ }
+
+ this.$router.push({ query });
+ },
+ onDisplayListboxSelect(display) {
+ this.pushQuery({ display });
+ },
+ onSortItemClick(sortItem) {
+ if (this.$route.query.sort_name === sortItem.name) {
+ return;
+ }
+
+ this.pushQuery({ ...this.$route.query, sort_name: sortItem.name });
+ },
+ onSortDirectionChange(isAscending) {
+ this.pushQuery({
+ ...this.$route.query,
+ sort_direction: isAscending ? SORT_DIRECTION_ASC : SORT_DIRECTION_DESC,
+ });
+ },
+ onFilter(filters) {
+ const { display, sort_name, sort_direction } = this.$route.query;
+
+ this.pushQuery({
+ display,
+ sort_name,
+ sort_direction,
+ ...filterToQueryObject(processFilters(filters), {
+ filteredSearchTermKey: FILTERED_SEARCH_TERM_KEY,
+ }),
+ });
},
},
};
@@ -56,7 +130,49 @@ export default {
<template>
<div>
<h1 class="gl-font-size-h-display">{{ $options.i18n.pageTitle }}</h1>
- <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="md" />
- <projects-list v-else :projects="formattedProjects" show-project-icon />
+ <div class="gl-p-5 gl-bg-gray-10 gl-border-t gl-border-b">
+ <div class="gl-mx-n2 gl-my-n2 gl-md-display-flex">
+ <div class="gl-p-2 gl-flex-grow-1">
+ <filtered-search-bar
+ :namespace="$options.filteredSearch.namespace"
+ :tokens="$options.filteredSearch.tokens"
+ :initial-filter-value="filteredSearchValue"
+ sync-filter-and-sort
+ :recent-searches-storage-key="$options.filteredSearch.recentSearchesStorageKey"
+ :search-input-placeholder="$options.i18n.searchInputPlaceholder"
+ @onFilter="onFilter"
+ />
+ </div>
+ <div class="gl-p-2">
+ <gl-collapsible-listbox
+ :selected="displayListboxSelected"
+ :items="$options.displayListboxItems"
+ :header-text="$options.i18n.displayListboxHeaderText"
+ block
+ toggle-class="gl-md-w-30"
+ @select="onDisplayListboxSelect"
+ />
+ </div>
+ <div class="gl-p-2">
+ <gl-sorting
+ class="gl-display-flex"
+ dropdown-class="gl-w-full"
+ :text="sortText"
+ :is-ascending="isAscending"
+ @sortDirectionChange="onSortDirectionChange"
+ >
+ <gl-sorting-item
+ v-for="sortItem in $options.sortItems"
+ :key="sortItem.name"
+ :active="activeSortItem.name === sortItem.name"
+ @click="onSortItemClick(sortItem)"
+ >
+ {{ sortItem.text }}
+ </gl-sorting-item>
+ </gl-sorting>
+ </div>
+ </div>
+ </div>
+ <component :is="routerView" />
</div>
</template>
diff --git a/app/assets/javascripts/organizations/groups_and_projects/components/groups_page.vue b/app/assets/javascripts/organizations/groups_and_projects/components/groups_page.vue
new file mode 100644
index 00000000000..20db38403f7
--- /dev/null
+++ b/app/assets/javascripts/organizations/groups_and_projects/components/groups_page.vue
@@ -0,0 +1,43 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { createAlert } from '~/alert';
+import { s__ } from '~/locale';
+import GroupsList from '~/vue_shared/components/groups_list/groups_list.vue';
+import groupsQuery from '../graphql/queries/groups.query.graphql';
+import { formatGroups } from '../utils';
+
+export default {
+ i18n: {
+ errorMessage: s__(
+ 'Organization|An error occurred loading the groups. Please refresh the page to try again.',
+ ),
+ },
+ components: { GlLoadingIcon, GroupsList },
+ data() {
+ return {
+ groups: [],
+ };
+ },
+ apollo: {
+ groups: {
+ query: groupsQuery,
+ update(data) {
+ return formatGroups(data.organization.groups.nodes);
+ },
+ error(error) {
+ createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true });
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.groups.loading;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="md" />
+ <groups-list v-else :groups="groups" show-group-icon />
+</template>
diff --git a/app/assets/javascripts/organizations/groups_and_projects/components/projects_page.vue b/app/assets/javascripts/organizations/groups_and_projects/components/projects_page.vue
new file mode 100644
index 00000000000..d6958ee996e
--- /dev/null
+++ b/app/assets/javascripts/organizations/groups_and_projects/components/projects_page.vue
@@ -0,0 +1,46 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue';
+import { createAlert } from '~/alert';
+import projectsQuery from '../graphql/queries/projects.query.graphql';
+import { formatProjects } from '../utils';
+
+export default {
+ i18n: {
+ errorMessage: s__(
+ 'Organization|An error occurred loading the projects. Please refresh the page to try again.',
+ ),
+ },
+ components: {
+ ProjectsList,
+ GlLoadingIcon,
+ },
+ data() {
+ return {
+ projects: [],
+ };
+ },
+ apollo: {
+ projects: {
+ query: projectsQuery,
+ update(data) {
+ return formatProjects(data.organization.projects.nodes);
+ },
+ error(error) {
+ createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true });
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.projects.loading;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="md" />
+ <projects-list v-else :projects="projects" show-project-icon />
+</template>
diff --git a/app/assets/javascripts/organizations/groups_and_projects/constants.js b/app/assets/javascripts/organizations/groups_and_projects/constants.js
new file mode 100644
index 00000000000..529caa666a0
--- /dev/null
+++ b/app/assets/javascripts/organizations/groups_and_projects/constants.js
@@ -0,0 +1,29 @@
+import { __ } from '~/locale';
+
+export const DISPLAY_QUERY_GROUPS = 'groups';
+export const DISPLAY_QUERY_PROJECTS = 'projects';
+
+export const ORGANIZATION_ROOT_ROUTE_NAME = 'root';
+
+export const FILTERED_SEARCH_TERM_KEY = 'search';
+
+export const DISPLAY_LISTBOX_ITEMS = [
+ {
+ value: DISPLAY_QUERY_GROUPS,
+ text: __('Groups'),
+ },
+ {
+ value: DISPLAY_QUERY_PROJECTS,
+ text: __('Projects'),
+ },
+];
+
+export const SORT_DIRECTION_ASC = 'asc';
+export const SORT_DIRECTION_DESC = 'desc';
+
+export const SORT_ITEM_CREATED = {
+ name: 'created',
+ text: __('Created'),
+};
+
+export const SORT_ITEMS = [SORT_ITEM_CREATED];
diff --git a/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/groups.query.graphql b/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/groups.query.graphql
new file mode 100644
index 00000000000..842c601e326
--- /dev/null
+++ b/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/groups.query.graphql
@@ -0,0 +1,22 @@
+query getOrganizationGroups {
+ organization @client {
+ id
+ groups {
+ nodes {
+ id
+ fullName
+ parent
+ webUrl
+ descriptionHtml
+ avatarUrl
+ descendantGroupsCount
+ projectsCount
+ groupMembersCount
+ visibility
+ accessLevel {
+ integerValue
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/projects.query.graphql b/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/projects.query.graphql
index b4cb8c607d4..2a7971e1106 100644
--- a/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/projects.query.graphql
+++ b/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/projects.query.graphql
@@ -15,6 +15,7 @@ query getOrganizationProjects {
descriptionHtml
issuesAccessLevel
forkingAccessLevel
+ isForked
accessLevel {
integerValue
}
diff --git a/app/assets/javascripts/organizations/groups_and_projects/graphql/resolvers.js b/app/assets/javascripts/organizations/groups_and_projects/graphql/resolvers.js
index 794410c2a78..8a375b28797 100644
--- a/app/assets/javascripts/organizations/groups_and_projects/graphql/resolvers.js
+++ b/app/assets/javascripts/organizations/groups_and_projects/graphql/resolvers.js
@@ -1,4 +1,8 @@
-import { organizationProjects } from 'jest/organizations/groups_and_projects/components/mock_data';
+import {
+ organization,
+ organizationProjects,
+ organizationGroups,
+} from 'jest/organizations/groups_and_projects/mock_data';
export default {
Query: {
@@ -8,7 +12,11 @@ export default {
setTimeout(resolve, 1000);
});
- return organizationProjects;
+ return {
+ ...organization,
+ projects: organizationProjects,
+ groups: organizationGroups,
+ };
},
},
};
diff --git a/app/assets/javascripts/organizations/groups_and_projects/index.js b/app/assets/javascripts/organizations/groups_and_projects/index.js
index d0790bcc040..f3f15c635f1 100644
--- a/app/assets/javascripts/organizations/groups_and_projects/index.js
+++ b/app/assets/javascripts/organizations/groups_and_projects/index.js
@@ -1,22 +1,39 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import VueRouter from 'vue-router';
import createDefaultClient from '~/lib/graphql';
import resolvers from './graphql/resolvers';
import App from './components/app.vue';
+import { ORGANIZATION_ROOT_ROUTE_NAME } from './constants';
+
+export const createRouter = () => {
+ const routes = [{ path: '/', name: ORGANIZATION_ROOT_ROUTE_NAME }];
+
+ const router = new VueRouter({
+ routes,
+ base: '/',
+ mode: 'history',
+ });
+
+ return router;
+};
export const initOrganizationsGroupsAndProjects = () => {
const el = document.getElementById('js-organizations-groups-and-projects');
if (!el) return false;
+ Vue.use(VueRouter);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(resolvers),
});
+ const router = createRouter();
return new Vue({
el,
name: 'OrganizationsGroupsAndProjects',
apolloProvider,
+ router,
render(createElement) {
return createElement(App);
},
diff --git a/app/assets/javascripts/organizations/groups_and_projects/utils.js b/app/assets/javascripts/organizations/groups_and_projects/utils.js
new file mode 100644
index 00000000000..d2a4e05e806
--- /dev/null
+++ b/app/assets/javascripts/organizations/groups_and_projects/utils.js
@@ -0,0 +1,23 @@
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/projects_list/constants';
+
+export const formatProjects = (projects) =>
+ projects.map(({ id, nameWithNamespace, accessLevel, webUrl, ...project }) => ({
+ ...project,
+ id: getIdFromGraphQLId(id),
+ name: nameWithNamespace,
+ permissions: {
+ projectAccess: {
+ accessLevel: accessLevel.integerValue,
+ },
+ },
+ webUrl,
+ editPath: `${webUrl}/edit`,
+ actions: [ACTION_EDIT, ACTION_DELETE],
+ }));
+
+export const formatGroups = (groups) =>
+ groups.map(({ id, ...group }) => ({
+ ...group,
+ id: getIdFromGraphQLId(id),
+ }));
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
index da88f768c03..75af0286e12 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
@@ -1,5 +1,10 @@
<script>
-import { GlIcon, GlTooltipDirective, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import {
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+ GlIcon,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import { sprintf, n__, s__ } from '~/locale';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
@@ -17,6 +22,8 @@ import {
CLEANUP_ONGOING_TOOLTIP,
CLEANUP_UNFINISHED_TOOLTIP,
CLEANUP_DISABLED_TOOLTIP,
+ DELETE_IMAGE_TEXT,
+ MORE_ACTIONS_TEXT,
UNFINISHED_STATUS,
UNSCHEDULED_STATUS,
SCHEDULED_STATUS,
@@ -29,7 +36,7 @@ import { getImageName } from '../../utils';
export default {
name: 'DetailsHeader',
- components: { GlIcon, TitleArea, MetadataItem, GlDropdown, GlDropdownItem },
+ components: { GlDisclosureDropdown, GlDisclosureDropdownItem, GlIcon, TitleArea, MetadataItem },
directives: {
GlTooltip: GlTooltipDirective,
},
@@ -108,6 +115,10 @@ export default {
return size ? numberToHumanSize(Number(size)) : null;
},
},
+ i18n: {
+ DELETE_IMAGE_TEXT,
+ MORE_ACTIONS_TEXT,
+ },
};
</script>
@@ -152,20 +163,23 @@ export default {
data-testid="created-and-visibility"
/>
</template>
- <template #right-actions>
- <gl-dropdown
- v-if="!deleteButtonDisabled"
- icon="ellipsis_v"
- text="More actions"
- :text-sr-only="true"
+ <template v-if="!deleteButtonDisabled" #right-actions>
+ <gl-disclosure-dropdown
category="tertiary"
+ icon="ellipsis_v"
+ placement="right"
+ :toggle-text="$options.i18n.MORE_ACTIONS_TEXT"
+ text-sr-only
no-caret
- right
>
- <gl-dropdown-item variant="danger" @click="$emit('delete')">
- {{ __('Delete image repository') }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <gl-disclosure-dropdown-item @action="$emit('delete')">
+ <template #list-item>
+ <span class="gl-text-red-500">
+ {{ $options.i18n.DELETE_IMAGE_TEXT }}
+ </span>
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown>
</template>
</title-area>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue
index 9ea1958a0d1..b58e2249829 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue
@@ -2,9 +2,11 @@
import { GlEmptyState } from '@gitlab/ui';
import { createAlert } from '~/alert';
import { n__ } from '~/locale';
+import { fetchPolicies } from '~/lib/graphql';
import { joinPaths } from '~/lib/utils/url_utility';
import Tracking from '~/tracking';
import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
+import PersistedPagination from '~/packages_and_registries/shared/components/persisted_pagination.vue';
import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue';
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
@@ -26,6 +28,7 @@ import {
import getContainerRepositoryTagsQuery from '../../graphql/queries/get_container_repository_tags.query.graphql';
import deleteContainerRepositoryTagsMutation from '../../graphql/mutations/delete_container_repository_tags.mutation.graphql';
import DeleteModal from '../delete_modal.vue';
+import { getPageParams, getNextPageParams, getPreviousPageParams } from '../../utils';
import TagsListRow from './tags_list_row.vue';
export default {
@@ -36,6 +39,7 @@ export default {
TagsListRow,
TagsLoader,
RegistryList,
+ PersistedPagination,
PersistedSearch,
},
mixins: [Tracking.mixin()],
@@ -61,7 +65,7 @@ export default {
required: false,
},
},
- searchConfig: { NAME_SORT_FIELD },
+ sortableFields: [NAME_SORT_FIELD],
i18n: {
REMOVE_TAGS_BUTTON_TITLE,
TAGS_LIST_TITLE,
@@ -69,6 +73,7 @@ export default {
apollo: {
containerRepository: {
query: getContainerRepositoryTagsQuery,
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
skip() {
return !this.sort;
},
@@ -85,8 +90,9 @@ export default {
containerRepository: {},
filters: {},
itemsToBeDeleted: [],
- mutationLoading: false,
+ isDeleteInProgress: false,
sort: null,
+ pageParams: {},
};
},
computed: {
@@ -108,6 +114,7 @@ export default {
first: GRAPHQL_PAGE_SIZE,
name: this.filters?.name,
sort: this.sort,
+ ...this.pageParams,
};
},
hasNoTags() {
@@ -117,7 +124,7 @@ export default {
return (
this.isImageLoading ||
this.$apollo.queries.containerRepository.loading ||
- this.mutationLoading ||
+ this.isDeleteInProgress ||
!this.sort
);
},
@@ -149,7 +156,7 @@ export default {
async handleDeleteTag() {
this.track('confirm_delete');
const { itemsToBeDeleted } = this;
- this.mutationLoading = true;
+ this.isDeleteInProgress = true;
try {
const { data } = await this.$apollo.mutate({
mutation: deleteContainerRepositoryTagsMutation,
@@ -176,27 +183,17 @@ export default {
} catch (e) {
this.$emit('delete', itemsToBeDeleted.length === 1 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS);
} finally {
- this.mutationLoading = false;
+ this.isDeleteInProgress = false;
}
},
fetchNextPage() {
- this.$apollo.queries.containerRepository.fetchMore({
- variables: {
- after: this.tagsPageInfo?.endCursor,
- first: GRAPHQL_PAGE_SIZE,
- },
- });
+ this.pageParams = getNextPageParams(this.tagsPageInfo?.endCursor);
},
fetchPreviousPage() {
- this.$apollo.queries.containerRepository.fetchMore({
- variables: {
- first: null,
- before: this.tagsPageInfo?.startCursor,
- last: GRAPHQL_PAGE_SIZE,
- },
- });
+ this.pageParams = getPreviousPageParams(this.tagsPageInfo?.startCursor);
},
- handleSearchUpdate({ sort, filters }) {
+ handleSearchUpdate({ sort, filters, pageInfo }) {
+ this.pageParams = getPageParams(pageInfo);
this.sort = sort;
const parsed = {
@@ -223,10 +220,8 @@ export default {
<div>
<persisted-search
class="gl-mb-5"
- :sortable-fields="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [
- $options.searchConfig.NAME_SORT_FIELD,
- ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
- :default-order="$options.searchConfig.NAME_SORT_FIELD.orderBy"
+ :sortable-fields="$options.sortableFields"
+ :default-order="$options.sortableFields[0].orderBy"
default-sort="asc"
@update="handleSearchUpdate"
/>
@@ -243,11 +238,8 @@ export default {
<registry-list
:hidden-delete="hideBulkDelete"
:title="listTitle"
- :pagination="tagsPageInfo"
:items="tags"
id-property="name"
- @prev-page="fetchPreviousPage"
- @next-page="fetchNextPage"
@delete="deleteTags"
>
<template #default="{ selectItem, isSelected, item, first }">
@@ -271,5 +263,14 @@ export default {
/>
</template>
</template>
+
+ <div v-if="!isDeleteInProgress" class="gl-display-flex gl-justify-content-center">
+ <persisted-pagination
+ class="gl-mt-3"
+ :pagination="tagsPageInfo"
+ @prev="fetchPreviousPage"
+ @next="fetchNextPage"
+ />
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue
index a3f58cc3323..c8a4f32d5a7 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue
@@ -81,7 +81,6 @@ export default {
extraAttrs: {
class: 'gl-text-red-500!',
'data-testid': 'single-delete-button',
- 'data-qa-selector': 'tag_delete_button',
},
action: () => {
this.$emit('delete');
@@ -143,7 +142,6 @@ export default {
<div
v-gl-tooltip="{ title: tag.name }"
data-testid="name"
- data-qa-selector="tag_name_content"
class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap"
:class="mobileClasses"
>
@@ -201,7 +199,6 @@ export default {
placement="right"
:class="{ 'gl-opacity-0 gl-pointer-events-none': disabled }"
data-testid="additional-actions"
- data-qa-selector="more_actions_menu"
:items="items"
/>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue
index 6f1f67e251f..ffba64f58f8 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue
@@ -1,11 +1,9 @@
<script>
-import { GlKeysetPagination } from '@gitlab/ui';
import ImageListRow from './image_list_row.vue';
export default {
name: 'ImageList',
components: {
- GlKeysetPagination,
ImageListRow,
},
props: {
@@ -18,21 +16,12 @@ export default {
default: false,
required: false,
},
- pageInfo: {
- type: Object,
- required: true,
- },
expirationPolicy: {
type: Object,
default: () => ({}),
required: false,
},
},
- computed: {
- showPagination() {
- return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage;
- },
- },
};
</script>
@@ -46,15 +35,5 @@ export default {
:expiration-policy="expirationPolicy"
@delete="$emit('delete', $event)"
/>
- <div class="gl-display-flex gl-justify-content-center">
- <gl-keyset-pagination
- v-if="showPagination"
- :has-next-page="pageInfo.hasNextPage"
- :has-previous-page="pageInfo.hasPreviousPage"
- class="gl-mt-3"
- @prev="$emit('prev-page')"
- @next="$emit('next-page')"
- />
- </div>
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
index f6f816f435c..d7043626446 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
@@ -133,7 +133,6 @@ export default {
ref="imageName"
class="gl-text-body gl-font-weight-bold"
data-testid="details-link"
- data-qa-selector="registry_image_content"
:to="{ name: 'details', params: { id } }"
>
{{ imageName }}
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
index 3a5992d182a..ab848d209db 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
@@ -93,6 +93,7 @@ export const DETAILS_DELETE_IMAGE_ERROR_MESSAGE = s__(
'ContainerRegistry|Something went wrong while scheduling the image for deletion.',
);
+export const DELETE_IMAGE_TEXT = s__('ContainerRegistry|Delete image repository');
export const DELETE_IMAGE_CONFIRMATION_TITLE = s__('ContainerRegistry|Delete image repository?');
export const DELETE_IMAGE_CONFIRMATION_TEXT = s__(
'ContainerRegistry|Deleting the image repository will delete all images and tags inside. This action cannot be undone. Please type the following to confirm: %{code}',
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue
index 3126af69c2c..c266dbf7e98 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue
@@ -1,11 +1,10 @@
<script>
-import { GlResizeObserverDirective, GlEmptyState } from '@gitlab/ui';
+import { GlResizeObserverDirective, GlEmptyState, GlSkeletonLoader } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import Tracking from '~/tracking';
-import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue';
import DeleteImage from '../components/delete_image.vue';
import DeleteAlert from '../components/details_page/delete_alert.vue';
import DeleteModal from '../components/delete_modal.vue';
@@ -28,12 +27,12 @@ export default {
name: 'RegistryDetailsPage',
components: {
GlEmptyState,
+ GlSkeletonLoader,
DeleteAlert,
PartialCleanupAlert,
DetailsHeader,
DeleteModal,
TagsList,
- TagsLoader,
StatusAlert,
DeleteImage,
},
@@ -151,16 +150,17 @@ export default {
<status-alert v-if="containerRepository.status" :status="containerRepository.status" />
+ <div v-if="isLoading" class="gl-my-6">
+ <gl-skeleton-loader />
+ </div>
<details-header
- v-if="!isLoading"
+ v-else
:image="containerRepository"
:disabled="pageActionsAreDisabled"
@delete="deleteImage"
/>
- <tags-loader v-if="isLoading" />
<tags-list
- v-else
:id="$route.params.id"
:is-image-loading="isLoading"
:is-mobile="isMobile"
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/index.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/index.vue
index dca63e1a569..ca0261f1036 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/index.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/index.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<template>
<div>
<router-view ref="router-view" />
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
index fe29fa8fdd7..df87ee79111 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
@@ -12,7 +12,9 @@ import { get } from 'lodash';
import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql';
import { createAlert } from '~/alert';
import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
+import { fetchPolicies } from '~/lib/graphql';
import Tracking from '~/tracking';
+import PersistedPagination from '~/packages_and_registries/shared/components/persisted_pagination.vue';
import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
import DeleteImage from '../components/delete_image.vue';
@@ -32,6 +34,7 @@ import {
SETTINGS_TEXT,
} from '../constants/index';
import getContainerRepositoriesDetails from '../graphql/queries/get_container_repositories_details.query.graphql';
+import { getPageParams, getNextPageParams, getPreviousPageParams } from '../utils';
export default {
name: 'RegistryListPage',
@@ -61,6 +64,7 @@ export default {
GlSkeletonLoader,
RegistryHeader,
DeleteImage,
+ PersistedPagination,
PersistedSearch,
},
directives: {
@@ -87,6 +91,7 @@ export default {
return !this.fetchBaseQuery;
},
query: getContainerRepositoriesQuery,
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
variables() {
return this.queryVariables;
},
@@ -109,6 +114,7 @@ export default {
return !this.fetchAdditionalDetails;
},
query: getContainerRepositoriesDetails,
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
variables() {
return this.queryVariables;
},
@@ -133,6 +139,7 @@ export default {
mutationLoading: false,
fetchBaseQuery: false,
fetchAdditionalDetails: false,
+ pageParams: {},
};
},
computed: {
@@ -158,6 +165,7 @@ export default {
fullPath: this.config.isGroupPage ? this.config.groupPath : this.config.projectPath,
isGroupPage: this.config.isGroupPage,
first: GRAPHQL_PAGE_SIZE,
+ ...this.pageParams,
};
},
tracking() {
@@ -193,54 +201,18 @@ export default {
this.deleteAlertType = null;
this.itemToDelete = {};
},
- updateQuery(_, { fetchMoreResult }) {
- return fetchMoreResult;
+ fetchNextPage() {
+ this.pageParams = getNextPageParams(this.pageInfo?.endCursor);
},
- async fetchNextPage() {
- if (this.pageInfo?.hasNextPage) {
- const variables = {
- after: this.pageInfo?.endCursor,
- first: GRAPHQL_PAGE_SIZE,
- };
-
- this.$apollo.queries.baseImages.fetchMore({
- variables,
- updateQuery: this.updateQuery,
- });
-
- await this.$nextTick();
-
- this.$apollo.queries.additionalDetails.fetchMore({
- variables,
- updateQuery: this.updateQuery,
- });
- }
- },
- async fetchPreviousPage() {
- if (this.pageInfo?.hasPreviousPage) {
- const variables = {
- first: null,
- before: this.pageInfo?.startCursor,
- last: GRAPHQL_PAGE_SIZE,
- };
- this.$apollo.queries.baseImages.fetchMore({
- variables,
- updateQuery: this.updateQuery,
- });
-
- await this.$nextTick();
-
- this.$apollo.queries.additionalDetails.fetchMore({
- variables,
- updateQuery: this.updateQuery,
- });
- }
+ fetchPreviousPage() {
+ this.pageParams = getPreviousPageParams(this.pageInfo?.startCursor);
},
startDelete() {
this.track('confirm_delete');
this.mutationLoading = true;
},
- handleSearchUpdate({ sort, filters }) {
+ handleSearchUpdate({ sort, filters, pageInfo }) {
+ this.pageParams = getPageParams(pageInfo);
this.sorting = sort;
const search = filters.find((i) => i.type === FILTERED_SEARCH_TERM);
@@ -346,11 +318,8 @@ export default {
v-if="images.length"
:images="images"
:metadata-loading="$apollo.queries.additionalDetails.loading"
- :page-info="pageInfo"
:expiration-policy="config.expirationPolicy"
@delete="deleteImage"
- @prev-page="fetchPreviousPage"
- @next-page="fetchNextPage"
/>
<gl-empty-state
@@ -370,6 +339,15 @@ export default {
</template>
</template>
+ <div v-if="!mutationLoading" class="gl-display-flex gl-justify-content-center">
+ <persisted-pagination
+ class="gl-mt-3"
+ :pagination="pageInfo"
+ @prev="fetchPreviousPage"
+ @next="fetchNextPage"
+ />
+ </div>
+
<delete-image
:id="itemToDelete.id"
@start="startDelete"
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/utils.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/utils.js
index 751ab5180a1..7ed4ff52b06 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/utils.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/utils.js
@@ -1,4 +1,5 @@
import { approximateDuration, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility';
+import { GRAPHQL_PAGE_SIZE } from './constants/index';
export const getImageName = (image = {}) => {
return image.name || image.project?.path;
@@ -10,3 +11,26 @@ export const timeTilRun = (time) => {
const difference = calculateRemainingMilliseconds(time);
return approximateDuration(difference / 1000);
};
+
+export const getNextPageParams = (cursor) => ({
+ after: cursor,
+ first: GRAPHQL_PAGE_SIZE,
+});
+
+export const getPreviousPageParams = (cursor) => ({
+ first: null,
+ before: cursor,
+ last: GRAPHQL_PAGE_SIZE,
+});
+
+export const getPageParams = (pageInfo = {}) => {
+ if (pageInfo.before) {
+ return getPreviousPageParams(pageInfo.before);
+ }
+
+ if (pageInfo.after) {
+ return getNextPageParams(pageInfo.after);
+ }
+
+ return {};
+};
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
index 87a2eb362d5..e18e6f7ed1a 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
@@ -160,11 +160,7 @@ export default {
<template>
<div>
- <gl-alert
- v-if="showDeleteCacheAlert"
- data-testid="delete-cache-alert"
- @dismiss="showDeleteCacheAlert = false"
- >
+ <gl-alert v-if="showDeleteCacheAlert" @dismiss="showDeleteCacheAlert = false">
{{ deleteCacheAlertMessage }}
</gl-alert>
<title-area :title="$options.i18n.pageTitle">
@@ -215,7 +211,7 @@ export default {
</template>
</gl-form-input-group>
<template #description>
- <span data-qa-selector="dependency_proxy_count" data-testid="proxy-count">
+ <span data-testid="proxy-count">
<gl-sprintf :message="$options.i18n.blobCountAndSize">
<template #count>{{ group.dependencyProxyBlobCount }}</template>
<template #size>{{ group.dependencyProxyTotalSize }}</template>
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue
index 94c958308dd..462de03d19f 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue
@@ -37,11 +37,6 @@ export default {
i18n: {
listTitle: s__('DependencyProxy|Image list'),
},
- computed: {
- showPagination() {
- return this.pagination.hasNextPage || this.pagination.hasPreviousPage;
- },
- },
};
</script>
@@ -68,7 +63,6 @@ export default {
</div>
<div class="gl-display-flex gl-justify-content-center">
<gl-keyset-pagination
- v-if="showPagination"
v-bind="pagination"
class="gl-mt-3"
@prev="$emit('prev-page')"
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/index.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/index.vue
index dca63e1a569..ca0261f1036 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/index.vue
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/index.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<template>
<div>
<router-view ref="router-view" />
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 fdc58e4bd05..cb96f3d96cb 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
@@ -9,6 +9,7 @@ import {
GlTabs,
GlSprintf,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { objectToQuery } from '~/lib/utils/url_utility';
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/details_title.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/details_title.vue
index 3e551706ed0..cd5f9f5a676 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/details_title.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/details_title.vue
@@ -1,5 +1,6 @@
<script>
import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters } from 'vuex';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { __ } from '~/locale';
@@ -29,11 +30,6 @@ export default {
return numberToHumanSize(this.packageFiles.reduce((acc, p) => acc + p.size, 0));
},
},
- methods: {
- dynamicSlotName(index) {
- return `metadata-tag${index}`;
- },
- },
};
</script>
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue
index c62bf7fb722..5febda59119 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue
@@ -1,5 +1,6 @@
<script>
import { GlLink, GlSprintf } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import { s__ } from '~/locale';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/index.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/index.js
index 15e17bcfaac..8a283148c7d 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/index.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue
index 2046b717362..ea87ec31f0f 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
import { LIST_KEY_PACKAGE_TYPE } from '~/packages_and_registries/infrastructure_registry/list/constants';
import { sortableFields } from '~/packages_and_registries/infrastructure_registry/list/utils';
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 707e8f09045..6139db9f3bd 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
@@ -1,5 +1,6 @@
<script>
import { GlPagination } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters } from 'vuex';
import Tracking from '~/tracking';
import DeletePackageModal from '~/packages_and_registries/shared/components/delete_package_modal.vue';
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue
index 37fc326f902..73a897ad7d5 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue
@@ -1,5 +1,6 @@
<script>
import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import { createAlert, VARIANT_INFO } from '~/alert';
import { historyReplaceState } from '~/lib/utils/common_utils';
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/index.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/index.js
index 1d6a4bf831d..d462c38451a 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/index.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import getList from './getters';
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 cc52235eaf3..c92208abfc3 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
@@ -84,7 +84,7 @@ export default {
<gl-link
:href="packageLink"
class="gl-text-body gl-min-w-0"
- data-qa-selector="package_link"
+ data-testid="details-link"
:disabled="disabledRow"
>
<gl-truncate :text="packageEntity.name" />
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/composer.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/composer.vue
index e3edaa3e45e..ccd76b8fc68 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/composer.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/composer.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/conan.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/conan.vue
index de7c1bc4cd3..8f92111cebb 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/conan.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/conan.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/maven.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/maven.vue
index 7c3eb476a99..7135691816b 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/maven.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/maven.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue
index 1ddd419a639..eab101350a1 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/pypi.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/pypi.vue
index ef35349c228..498ddbae7b1 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/pypi.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/pypi.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
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 96d097eff38..c8924e6548b 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
@@ -178,10 +178,6 @@ export default {
first: GRAPHQL_PACKAGE_FILES_PAGE_SIZE,
};
},
- showPagination() {
- const { hasPreviousPage, hasNextPage } = this.pageInfo;
- return hasPreviousPage || hasNextPage;
- },
tracking() {
return {
category: packageTypeToTrackCategory(this.packageType),
@@ -490,7 +486,6 @@ export default {
</gl-table>
<div class="gl-display-flex gl-justify-content-center">
<gl-keyset-pagination
- v-if="showPagination"
:disabled="isLoading"
v-bind="pageInfo"
:prev-text="$options.i18n.prev"
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue
index 5eabcea9e15..cdf03d64b27 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue
@@ -80,7 +80,7 @@ export default {
data-qa-selector="package_title"
>
<template #sub-header>
- <div data-testid="sub-header" class="gl-display-flex gl-gap-3">
+ <div data-testid="sub-header" class="gl-display-flex gl-flex-wrap gl-gap-3">
<gl-sprintf :message="$options.i18n.packageInfo">
<template #version>
{{ packageEntity.version }}
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 c690e8fac43..a545ad1d09c 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
@@ -1,7 +1,7 @@
<script>
import {
- GlDropdown,
- GlDropdownItem,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
GlFormCheckbox,
GlIcon,
GlSprintf,
@@ -28,8 +28,8 @@ import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
name: 'PackageListRow',
components: {
- GlDropdown,
- GlDropdownItem,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
GlFormCheckbox,
GlIcon,
GlSprintf,
@@ -135,7 +135,6 @@ export default {
:class="errorPackageStyle"
class="gl-text-body gl-min-w-0"
data-testid="details-link"
- data-qa-selector="package_link"
:to="{ name: 'details', params: { id: packageId } }"
>
<gl-truncate :text="packageEntity.name" />
@@ -195,18 +194,22 @@ export default {
</template>
<template v-if="packageEntity.canDestroy" #right-action>
- <gl-dropdown
+ <gl-disclosure-dropdown
+ category="tertiary"
data-testid="delete-dropdown"
icon="ellipsis_v"
- :text="$options.i18n.moreActions"
- :text-sr-only="true"
- category="tertiary"
+ :toggle-text="$options.i18n.moreActions"
+ text-sr-only
no-caret
>
- <gl-dropdown-item data-testid="action-delete" variant="danger" @click="$emit('delete')">{{
- $options.i18n.deletePackage
- }}</gl-dropdown-item>
- </gl-dropdown>
+ <gl-disclosure-dropdown-item data-testid="action-delete" @action="$emit('delete')">
+ <template #list-item>
+ <span class="gl-text-red-500">
+ {{ $options.i18n.deletePackage }}
+ </span>
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown>
</template>
</list-item>
</template>
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 d96418571e1..d1982464eb9 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
@@ -221,7 +221,7 @@ export default {
attributes: {
variant: 'danger',
category: 'primary',
- 'data-qa-selector': 'delete_modal_button',
+ 'data-testid': 'delete-modal-button',
},
},
fileDeletePrimaryAction: {
@@ -254,7 +254,6 @@ export default {
v-gl-modal="'delete-modal'"
variant="danger"
category="primary"
- data-qa-selector="delete_button"
data-testid="delete-package"
>
{{ __('Delete') }}
@@ -264,7 +263,7 @@ export default {
<gl-tabs>
<gl-tab :title="__('Detail')">
- <div data-qa-selector="package_information_content">
+ <div data-testid="package-information-content">
<package-history :package-entity="packageEntity" :project-name="projectName" />
<installation-commands :package-entity="packageEntity" />
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/index.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/index.vue
index a14d0c32cbe..1e0715ff544 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/pages/index.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/index.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<template>
<div>
<router-view />
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue
index 486c3ef31c5..6de89748708 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton, GlEmptyState, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { createAlert, VARIANT_INFO } from '~/alert';
@@ -189,7 +190,11 @@ export default {
@delete="deletePackages"
>
<template #empty-state>
- <gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration">
+ <gl-empty-state
+ :title="emptyStateTitle"
+ :svg-path="emptyListIllustration"
+ :svg-height="150"
+ >
<template #description>
<gl-sprintf v-if="hasFilters" :message="$options.i18n.widenFilters" />
<gl-sprintf v-else :message="$options.i18n.noResultsText">
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 fa73c01c5c4..bfb57e3ac1c 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
@@ -45,7 +45,7 @@ export const PACKAGE_FORWARDING_FORM_BUTTON = __('Save changes');
export const DEPENDENCY_PROXY_HEADER = s__('DependencyProxy|Dependency Proxy');
export const DEPENDENCY_PROXY_DESCRIPTION = s__(
- 'DependencyProxy|Enable the Dependency Proxy and settings for clearing the cache.',
+ 'DependencyProxy|Enable the Dependency Proxy to cache container images from Docker Hub and automatically clear the cache.',
);
export const PACKAGE_FORWARDING_FIELDS = [
diff --git a/app/assets/javascripts/packages_and_registries/shared/components/persisted_pagination.vue b/app/assets/javascripts/packages_and_registries/shared/components/persisted_pagination.vue
new file mode 100644
index 00000000000..01c2c751cac
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/shared/components/persisted_pagination.vue
@@ -0,0 +1,56 @@
+<script>
+import { GlKeysetPagination } from '@gitlab/ui';
+import UrlSync from '~/vue_shared/components/url_sync.vue';
+
+export default {
+ name: 'PersistedPagination',
+ components: {
+ GlKeysetPagination,
+ UrlSync,
+ },
+ inheritAttrs: false,
+ props: {
+ pagination: {
+ type: Object,
+ default: () => ({}),
+ required: false,
+ },
+ },
+ computed: {
+ attrs() {
+ return {
+ ...this.pagination,
+ ...this.$attrs,
+ };
+ },
+ },
+ methods: {
+ onPrev(updateQuery) {
+ updateQuery({
+ before: this.pagination?.startCursor,
+ after: null,
+ });
+ this.$emit('prev');
+ },
+ onNext(updateQuery) {
+ updateQuery({
+ after: this.pagination?.endCursor,
+ before: null,
+ });
+ this.$emit('next');
+ },
+ },
+};
+</script>
+
+<template>
+ <url-sync>
+ <template #default="{ updateQuery }">
+ <gl-keyset-pagination
+ v-bind="attrs"
+ @prev="onPrev(updateQuery)"
+ @next="onNext(updateQuery)"
+ />
+ </template>
+ </url-sync>
+</template>
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 363304c20ce..95343a3a09b 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
@@ -1,7 +1,11 @@
<script>
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
-import { extractFilterAndSorting, getQueryParams } from '~/packages_and_registries/shared/utils';
+import {
+ extractFilterAndSorting,
+ extractPageInfo,
+ getQueryParams,
+} from '~/packages_and_registries/shared/utils';
export default {
components: { RegistrySearch, UrlSync },
@@ -31,6 +35,7 @@ export default {
orderBy: this.defaultOrder,
sort: this.defaultSort,
},
+ pageInfo: {},
mountRegistrySearch: false,
};
},
@@ -40,27 +45,49 @@ export default {
return `${cleanOrderBy}_${this.sorting?.sort}`.toUpperCase();
},
},
+ watch: {
+ $route(newValue, oldValue) {
+ if (newValue.fullPath !== oldValue.fullPath) {
+ this.updateDataFromUrl();
+ this.emitUpdate();
+ }
+ },
+ },
mounted() {
- const queryParams = getQueryParams(window.document.location.search);
- const { sorting, filters } = extractFilterAndSorting(queryParams);
- this.updateSorting(sorting);
- this.updateFilters(filters);
+ this.updateDataFromUrl();
this.mountRegistrySearch = true;
this.emitUpdate();
},
methods: {
+ updateDataFromUrl() {
+ const queryParams = getQueryParams(window.location.search);
+ const { sorting, filters } = extractFilterAndSorting(queryParams);
+ const pageInfo = extractPageInfo(queryParams);
+ this.updateSorting(sorting);
+ this.updateFilters(filters);
+ this.updatePageInfo(pageInfo);
+ },
updateFilters(newValue) {
+ this.updatePageInfo({});
this.filters = newValue;
},
updateSorting(newValue) {
+ this.updatePageInfo({});
this.sorting = { ...this.sorting, ...newValue };
},
+ updatePageInfo(newValue) {
+ this.pageInfo = newValue;
+ },
updateSortingAndEmitUpdate(newValue) {
this.updateSorting(newValue);
this.emitUpdate();
},
emitUpdate() {
- this.$emit('update', { sort: this.parsedSorting, filters: this.filters });
+ this.$emit('update', {
+ sort: this.parsedSorting,
+ filters: this.filters,
+ pageInfo: this.pageInfo,
+ });
},
},
};
diff --git a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue
index 1c8f80972df..f67bee77eb6 100644
--- a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue
+++ b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue
@@ -47,9 +47,6 @@ export default {
};
},
computed: {
- showPagination() {
- return this.pagination.hasPreviousPage || this.pagination.hasNextPage;
- },
disableDeleteButton() {
return this.isLoading || this.selectedItems.length === 0;
},
@@ -131,7 +128,6 @@ export default {
<div class="gl-display-flex gl-justify-content-center">
<gl-keyset-pagination
- v-if="showPagination"
v-bind="pagination"
class="gl-mt-3"
@prev="$emit('prev-page')"
diff --git a/app/assets/javascripts/packages_and_registries/shared/utils.js b/app/assets/javascripts/packages_and_registries/shared/utils.js
index adffab277cc..bda0839092e 100644
--- a/app/assets/javascripts/packages_and_registries/shared/utils.js
+++ b/app/assets/javascripts/packages_and_registries/shared/utils.js
@@ -30,6 +30,14 @@ export const extractFilterAndSorting = (queryObject) => {
return { filters, sorting };
};
+export const extractPageInfo = (queryObject) => {
+ const { before, after } = queryObject;
+ return {
+ before,
+ after,
+ };
+};
+
export const beautifyPath = (path) => (path ? path.split('/').join(' / ') : '');
export const getCommitLink = ({ project_path: projectPath, pipeline = {} }, isGroup = false) => {
diff --git a/app/assets/javascripts/pages/explore/groups/index.js b/app/assets/javascripts/pages/explore/groups/index.js
index 05078191e5c..9b60b1f51a8 100644
--- a/app/assets/javascripts/pages/explore/groups/index.js
+++ b/app/assets/javascripts/pages/explore/groups/index.js
@@ -1,9 +1,7 @@
import initGroupsList from '~/groups';
-import GroupsList from '~/groups/groups_list';
import Landing from '~/groups/landing';
function exploreGroups() {
- new GroupsList(); // eslint-disable-line no-new
initGroupsList();
const landingElement = document.querySelector('.js-explore-groups-landing');
if (!landingElement) return;
diff --git a/app/assets/javascripts/pages/groups/work_items/index.js b/app/assets/javascripts/pages/groups/work_items/index.js
new file mode 100644
index 00000000000..a95070b1857
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/work_items/index.js
@@ -0,0 +1,3 @@
+import { mountWorkItemsListApp } from '~/work_items/list';
+
+mountWorkItemsListApp();
diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue
index 582aee3c9a3..1d0eaae4c57 100644
--- a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue
+++ b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue
@@ -15,21 +15,21 @@ import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import { getBulkImportsHistory } from '~/rest_api';
import ImportStatus from '~/import_entities/components/import_status.vue';
+import { StatusPoller } from '~/import_entities/import_groups/services/status_poller';
+
import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import { isImporting } from '../utils';
import { DEFAULT_ERROR } from '../utils/error_messages';
const DEFAULT_PER_PAGE = 20;
-const DEFAULT_TH_CLASSES =
- 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-200! gl-border-b-1! gl-p-5!';
const HISTORY_PAGINATION_SIZE_PERSIST_KEY = 'gl-bulk-imports-history-per-page';
const tableCell = (config) => ({
- thClass: `${DEFAULT_TH_CLASSES}`,
tdClass: (value, key, item) => {
return {
// eslint-disable-next-line no-underscore-dangle
@@ -57,6 +57,8 @@ export default {
GlTooltip,
},
+ inject: ['realtimeChangesPath'],
+
data() {
return {
loading: true,
@@ -73,12 +75,12 @@ export default {
tableCell({
key: 'source_full_path',
label: s__('BulkImport|Source'),
- thClass: `${DEFAULT_TH_CLASSES} gl-w-30p`,
+ thClass: `gl-w-30p`,
}),
tableCell({
key: 'destination_name',
label: s__('BulkImport|Destination'),
- thClass: `${DEFAULT_TH_CLASSES} gl-w-40p`,
+ thClass: `gl-w-40p`,
}),
tableCell({
key: 'created_at',
@@ -95,6 +97,12 @@ export default {
hasHistoryItems() {
return this.historyItems.length > 0;
},
+
+ importingHistoryItemIds() {
+ return this.historyItems
+ .filter((item) => isImporting(item.status))
+ .map((item) => item.bulk_import_id);
+ },
},
watch: {
@@ -104,10 +112,43 @@ export default {
},
deep: true,
},
+
+ importingHistoryItemIds(value) {
+ if (value.length > 0) {
+ this.statusPoller.startPolling();
+ } else {
+ this.statusPoller.stopPolling();
+ }
+ },
},
mounted() {
this.loadHistoryItems();
+
+ this.statusPoller = new StatusPoller({
+ pollPath: this.realtimeChangesPath,
+ updateImportStatus: (update) => {
+ if (!this.importingHistoryItemIds.includes(update.id)) {
+ return;
+ }
+
+ const updateItemIndex = this.historyItems.findIndex(
+ (item) => item.bulk_import_id === update.id,
+ );
+ const updateItem = this.historyItems[updateItemIndex];
+
+ if (updateItem.status !== update.status_name) {
+ this.$set(this.historyItems, updateItemIndex, {
+ ...updateItem,
+ status: update.status_name,
+ });
+ }
+ },
+ });
+ },
+
+ beforeDestroy() {
+ this.statusPoller.stopPolling();
},
methods: {
diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/index.js b/app/assets/javascripts/pages/import/bulk_imports/history/index.js
index 5a67aa99baa..cc12723572d 100644
--- a/app/assets/javascripts/pages/import/bulk_imports/history/index.js
+++ b/app/assets/javascripts/pages/import/bulk_imports/history/index.js
@@ -4,8 +4,14 @@ import BulkImportHistoryApp from './components/bulk_imports_history_app.vue';
function mountImportHistoryApp(mountElement) {
if (!mountElement) return undefined;
+ const { realtimeChangesPath } = mountElement.dataset;
+
return new Vue({
el: mountElement,
+ name: 'BulkImportHistoryRoot',
+ provide: {
+ realtimeChangesPath,
+ },
render(createElement) {
return createElement(BulkImportHistoryApp);
},
diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/utils/index.js b/app/assets/javascripts/pages/import/bulk_imports/history/utils/index.js
new file mode 100644
index 00000000000..09cba40dd36
--- /dev/null
+++ b/app/assets/javascripts/pages/import/bulk_imports/history/utils/index.js
@@ -0,0 +1,7 @@
+import { STATUSES } from '~/import_entities/constants';
+
+export function isImporting(status) {
+ return [STATUSES.SCHEDULING, STATUSES.SCHEDULED, STATUSES.CREATED, STATUSES.STARTED].includes(
+ status,
+ );
+}
diff --git a/app/assets/javascripts/pages/profiles/keys/index.js b/app/assets/javascripts/pages/profiles/keys/index.js
index 28b1aa02dfa..b79acfd5c57 100644
--- a/app/assets/javascripts/pages/profiles/keys/index.js
+++ b/app/assets/javascripts/pages/profiles/keys/index.js
@@ -12,6 +12,7 @@ function initSshKeyValidation() {
const warning = document.querySelector('.js-add-ssh-key-validation-warning');
const originalSubmit = input.form.querySelector('.js-add-ssh-key-validation-original-submit');
const confirmSubmit = warning.querySelector('.js-add-ssh-key-validation-confirm-submit');
+ const cancelButton = input.form.querySelector('.js-add-ssh-key-validation-cancel');
const addSshKeyValidation = new AddSshKeyValidation(
supportedAlgorithms,
@@ -19,6 +20,7 @@ function initSshKeyValidation() {
warning,
originalSubmit,
confirmSubmit,
+ cancelButton,
);
addSshKeyValidation.register();
}
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index f5e09d972a9..a3d930433c3 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
@@ -71,6 +72,7 @@ if (viewBlobEl) {
resourceId,
userId,
explainCodeAvailable,
+ refType,
...dataset
} = viewBlobEl.dataset;
@@ -94,6 +96,7 @@ if (viewBlobEl) {
props: {
path: blobPath,
projectPath,
+ refType,
},
});
},
diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js
index 43fd5375222..e28834b1ccd 100644
--- a/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js
+++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js
@@ -1,6 +1,7 @@
/* eslint-disable no-new */
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import EditUserList from '~/user_lists/components/edit_user_list.vue';
import createStore from '~/user_lists/store/edit';
diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/index/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/index/index.js
index 519e04e14fb..1f6ae5ee287 100644
--- a/app/assets/javascripts/pages/projects/feature_flags_user_lists/index/index.js
+++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/index/index.js
@@ -1,6 +1,7 @@
/* eslint-disable no-new */
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import UserLists from '~/user_lists/components/user_lists.vue';
import createStore from '~/user_lists/store/index';
diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js
index e855447d5ce..86d2b5038d0 100644
--- a/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js
+++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js
@@ -1,6 +1,7 @@
/* eslint-disable no-new */
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import NewUserList from '~/user_lists/components/new_user_list.vue';
import createStore from '~/user_lists/store/new';
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 9eaf490abb2..cacfb00fa2c 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
@@ -1,4 +1,4 @@
-import mountNotesApp from 'ee_else_ce/mr_notes/mount_app';
+import mountNotesApp from '~/mr_notes/mount_app';
import { initMrPage } from 'ee_else_ce/pages/projects/merge_requests/page';
initMrPage();
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js
deleted file mode 100644
index 6dd21380bec..00000000000
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import initForm from '../shared/init_form';
-
-initForm();
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
index a79f20d596c..5f6a73782c3 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
@@ -96,7 +96,7 @@ export default {
},
{
value: KEY_CUSTOM,
- text: s__('PipelineScheduleIntervalPattern|Custom (%{linkStart}Learn more.%{linkEnd})'),
+ text: s__('PipelineScheduleIntervalPattern|Custom (%{linkStart}Learn more%{linkEnd}.)'),
link: this.cronSyntaxUrl,
},
];
@@ -168,9 +168,7 @@ export default {
>
<gl-sprintf v-if="option.link" :message="option.text">
<template #link="{ content }">
- <gl-link :href="option.link" target="_blank" class="gl-font-sm">
- {{ content }}
- </gl-link>
+ <gl-link :href="option.link" target="_blank" class="gl-font-sm">{{ content }}</gl-link>
</template>
</gl-sprintf>
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js
deleted file mode 100644
index 6dd21380bec..00000000000
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import initForm from '../shared/init_form';
-
-initForm();
diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
index b2681267e06..4a5d5580c08 100644
--- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
@@ -14,6 +14,7 @@ import { initCiSecureFiles } from '~/ci_secure_files';
import initDeployTokens from '~/deploy_tokens';
import { initProjectRunners } from '~/ci/runner/project_runners';
import { initProjectRunnersRegistrationDropdown } from '~/ci/runner/project_runners/register';
+import { initGeneralPipelinesOptions } from '~/ci_settings_general_pipeline';
// Initialize expandable settings panels
initSettingsPanels();
@@ -51,3 +52,4 @@ initRefSwitcherBadges();
initInstallRunner();
initTokenAccess();
initCiSecureFiles();
+initGeneralPipelinesOptions();
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 c54596488af..6ff48b7de95 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
@@ -277,7 +277,7 @@ export default {
requestAccessEnabled: true,
enforceAuthChecksOnUploads: true,
highlightChangesClass: false,
- emailsDisabled: false,
+ emailsEnabled: true,
cveIdRequestEnabled: true,
featureAccessLevelEveryone,
featureAccessLevelMembers,
@@ -370,7 +370,10 @@ export default {
return this.packageRegistryAccessLevel === FEATURE_ACCESS_LEVEL_ANONYMOUS[0];
},
packageRegistryApiForEveryoneEnabledShown() {
- return this.visibilityLevel !== VISIBILITY_LEVEL_PUBLIC_INTEGER;
+ return (
+ this.packageRegistryAllowAnyoneToPullOption &&
+ this.visibilityLevel !== VISIBILITY_LEVEL_PUBLIC_INTEGER
+ );
},
monitorOperationsFeatureAccessLevelOptions() {
return this.featureAccessLevelOptions.filter(([value]) => value <= this.monitorAccessLevel);
@@ -1001,14 +1004,19 @@ export default {
:full-path="confirmationPhrase"
/>
<project-setting-row v-if="canDisableEmails" ref="email-settings" class="mb-3">
- <label class="js-emails-disabled">
- <input :value="emailsDisabled" type="hidden" name="project[emails_disabled]" />
- <input v-model="emailsDisabled" type="checkbox" />
- {{ s__('ProjectSettings|Disable email notifications') }}
+ <label class="js-emails-enabled">
+ <input
+ :value="emailsEnabled"
+ type="hidden"
+ name="project[project_setting_attributes][emails_enabled]"
+ />
+ <gl-form-checkbox v-model="emailsEnabled">
+ {{ s__('ProjectSettings|Enable email notifications') }}
+ <template #help>{{
+ s__('ProjectSettings|Enable sending email notifications for this project')
+ }}</template>
+ </gl-form-checkbox>
</label>
- <span class="form-text text-muted">{{
- s__('ProjectSettings|Override user notification preferences for all project members.')
- }}</span>
</project-setting-row>
<project-setting-row class="mb-3">
<input
@@ -1020,10 +1028,10 @@ export default {
v-model="showDefaultAwardEmojis"
name="project[project_setting_attributes][show_default_award_emojis]"
>
- {{ s__('ProjectSettings|Show default award emojis') }}
+ {{ s__('ProjectSettings|Show default emoji reactions') }}
<template #help>{{
s__(
- 'ProjectSettings|Always show thumbs-up and thumbs-down award emoji buttons on issues, merge requests, and snippets.',
+ 'ProjectSettings|Always show thumbs-up and thumbs-down emoji buttons on issues, merge requests, and snippets.',
)
}}</template>
</gl-form-checkbox>
diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js
index e17f5255c54..c43a0eb597c 100644
--- a/app/assets/javascripts/pages/projects/show/index.js
+++ b/app/assets/javascripts/pages/projects/show/index.js
@@ -8,6 +8,8 @@ import initTerraformNotification from '~/projects/terraform_notification';
import { initUploadFileTrigger } from '~/projects/upload_file';
import initReadMore from '~/read_more';
+import initForksButton from '~/forks/init_forks_button';
+
// Project show page loads different overview content based on user preferences
if (document.getElementById('js-tree-list')) {
import(/* webpackChunkName: 'treeList' */ 'ee_else_ce/repository')
@@ -57,3 +59,5 @@ if (document.querySelector('.js-autodevops-banner')) {
})
.catch(() => {});
}
+
+initForksButton();
diff --git a/app/assets/javascripts/pages/projects/tracing/show/index.js b/app/assets/javascripts/pages/projects/tracing/show/index.js
new file mode 100644
index 00000000000..107c004aa5f
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/tracing/show/index.js
@@ -0,0 +1,4 @@
+import { initSimpleApp } from '~/helpers/init_simple_app_helper';
+import DetailsIndex from '~/tracing/details_index.vue';
+
+initSimpleApp('#js-tracing-details', DetailsIndex);
diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js
index a8b4dca0845..1d5d885753c 100644
--- a/app/assets/javascripts/pages/sessions/new/index.js
+++ b/app/assets/javascripts/pages/sessions/new/index.js
@@ -3,6 +3,7 @@ import initVueAlerts from '~/vue_alerts';
import NoEmojiValidator from '~/emoji/no_emoji_validator';
import { initLanguageSwitcher } from '~/language_switcher';
import LengthValidator from '~/validators/length_validator';
+import mountEmailVerificationApplication from '~/sessions/new';
import OAuthRememberMe from './oauth_remember_me';
import preserveUrlFragment from './preserve_url_fragment';
import SigninTabsMemoizer from './signin_tabs_memoizer';
@@ -22,3 +23,4 @@ new OAuthRememberMe({
preserveUrlFragment(window.location.hash);
initVueAlerts();
initLanguageSwitcher();
+mountEmailVerificationApplication();
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_export.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_export.vue
new file mode 100644
index 00000000000..4d13f25c4cb
--- /dev/null
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_export.vue
@@ -0,0 +1,40 @@
+<script>
+import { GlDisclosureDropdown } from '@gitlab/ui';
+import { __ } from '~/locale';
+import printMarkdownDom from '~/lib/print_markdown_dom';
+
+export default {
+ components: {
+ GlDisclosureDropdown,
+ },
+ inject: ['target', 'title', 'stylesheet'],
+ computed: {
+ dropdownItems() {
+ return [
+ {
+ text: __('Print as PDF'),
+ action: this.print,
+ },
+ ];
+ },
+ },
+ methods: {
+ print() {
+ printMarkdownDom({
+ target: document.querySelector(this.target),
+ title: this.title,
+ stylesheet: this.stylesheet,
+ });
+ },
+ },
+};
+</script>
+<template>
+ <gl-disclosure-dropdown
+ :items="dropdownItems"
+ icon="ellipsis_v"
+ category="tertiary"
+ placement="right"
+ no-caret
+ />
+</template>
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 5bc630c61cb..553cb1f0464 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -344,7 +344,7 @@ export default {
</div>
</div>
- <div class="row" data-testid="wiki-form-content-fieldset">
+ <div class="row">
<div class="col-sm-12 row-sm-5">
<gl-form-group>
<markdown-editor
diff --git a/app/assets/javascripts/pages/shared/wikis/show.js b/app/assets/javascripts/pages/shared/wikis/show.js
index 9906cb595f8..9bc399d07b3 100644
--- a/app/assets/javascripts/pages/shared/wikis/show.js
+++ b/app/assets/javascripts/pages/shared/wikis/show.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import Wikis from './wikis';
import WikiContent from './components/wiki_content.vue';
+import WikiExport from './components/wiki_export.vue';
const mountWikiContentApp = () => {
const el = document.querySelector('.js-async-wiki-page-content');
@@ -20,8 +21,28 @@ const mountWikiContentApp = () => {
}
};
+const mountWikiExportApp = () => {
+ const el = document.querySelector('#js-export-actions');
+
+ if (!el) return false;
+ const { target, title, stylesheet } = JSON.parse(el.dataset.options);
+
+ return new Vue({
+ el,
+ provide: {
+ target,
+ title,
+ stylesheet,
+ },
+ render(createElement) {
+ return createElement(WikiExport);
+ },
+ });
+};
+
export const mountApplications = () => {
// eslint-disable-next-line no-new
new Wikis();
mountWikiContentApp();
+ mountWikiExportApp();
};
diff --git a/app/assets/javascripts/pages/shared/wikis/wikis.js b/app/assets/javascripts/pages/shared/wikis/wikis.js
index ec085eae199..b32cc700e16 100644
--- a/app/assets/javascripts/pages/shared/wikis/wikis.js
+++ b/app/assets/javascripts/pages/shared/wikis/wikis.js
@@ -1,6 +1,5 @@
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import Tracking from '~/tracking';
-import showToast from '~/vue_shared/plugins/global_toast';
import ShortcutsWiki from '~/behaviors/shortcuts/shortcuts_wiki';
const TRACKING_EVENT_NAME = 'view_wiki_page';
@@ -31,7 +30,6 @@ export default class Wikis {
this.renderSidebar();
Wikis.trackPageView();
- Wikis.showToasts();
Wikis.initShortcuts();
}
@@ -73,11 +71,6 @@ export default class Wikis {
});
}
- static showToasts() {
- const toasts = document.querySelectorAll('.js-toast-message');
- toasts.forEach((toast) => showToast(toast.dataset.message));
- }
-
static initShortcuts() {
new ShortcutsWiki(); // eslint-disable-line no-new
}
diff --git a/app/assets/javascripts/pdf/index.vue b/app/assets/javascripts/pdf/index.vue
index f35f9341fa1..43457faff4a 100644
--- a/app/assets/javascripts/pdf/index.vue
+++ b/app/assets/javascripts/pdf/index.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist/legacy/build/pdf';
diff --git a/app/assets/javascripts/pdf/page/index.vue b/app/assets/javascripts/pdf/page/index.vue
index ae0a7f0298b..46affdc588a 100644
--- a/app/assets/javascripts/pdf/page/index.vue
+++ b/app/assets/javascripts/pdf/page/index.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
export default {
props: {
diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
index 69d60a7caf9..5bef7e6e322 100644
--- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue
+++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
@@ -171,7 +171,7 @@ export default {
sprintf(__('%{duration}ms'), { duration: item.duration })
}}</span>
</td>
- <td data-testid="performance-item-content">
+ <td>
<div>
<div
v-for="(key, keyIndex) in keys"
diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
index fac070d6e47..128c744f282 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -84,6 +84,11 @@ export default {
keys: ['request', 'body'],
},
{
+ metric: 'ch',
+ header: s__('PerformanceBar|ClickHouse queries'),
+ keys: ['sql', 'database', 'statistics'],
+ },
+ {
metric: 'external-http',
title: 'external',
header: s__('PerformanceBar|External Http calls'),
diff --git a/app/assets/javascripts/pipeline_wizard/components/commit.vue b/app/assets/javascripts/pipeline_wizard/components/commit.vue
index e68458a494f..f1cdb4630fd 100644
--- a/app/assets/javascripts/pipeline_wizard/components/commit.vue
+++ b/app/assets/javascripts/pipeline_wizard/components/commit.vue
@@ -177,7 +177,6 @@ export default {
<gl-form-group
:invalid-feedback="$options.i18n.fieldRequiredFeedback"
:label="$options.i18n.commitMessageLabel"
- data-testid="commit_message_group"
label-for="commit_message"
>
<gl-form-textarea
@@ -192,7 +191,6 @@ export default {
<gl-form-group
:invalid-feedback="$options.i18n.fieldRequiredFeedback"
:label="$options.i18n.branchSelectorLabel"
- data-testid="branch_selector_group"
label-for="branch"
>
<ref-selector id="branch" v-model="branch" :project-id="projectPath" data-testid="branch" />
diff --git a/app/assets/javascripts/pipeline_wizard/components/widgets/text.vue b/app/assets/javascripts/pipeline_wizard/components/widgets/text.vue
index 26235b20ce9..0542aa461ab 100644
--- a/app/assets/javascripts/pipeline_wizard/components/widgets/text.vue
+++ b/app/assets/javascripts/pipeline_wizard/components/widgets/text.vue
@@ -105,7 +105,7 @@ export default {
</script>
<template>
- <div data-testid="text-widget">
+ <div>
<gl-form-group
:description="description"
:invalid-feedback="invalidFeedbackMessage"
diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue
index be12df68f76..afb5aa05098 100644
--- a/app/assets/javascripts/pipelines/components/dag/dag.vue
+++ b/app/assets/javascripts/pipelines/components/dag/dag.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
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 02d0c07ea54..d4852224df5 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
@@ -99,7 +99,7 @@ export default {
:dropdown-length="group.size"
:job="job"
:type="$options.jobItemTypes.singleJob"
- css-class-job-name="mini-pipeline-graph-dropdown-item"
+ css-class-job-name="pipeline-job-item"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
/>
</li>
diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue
index ec7000120f1..f84ae13180d 100644
--- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue
+++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue
@@ -3,10 +3,11 @@ import { GlButton, GlLink, GlTableLite } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { __, s__ } from '~/locale';
import { createAlert } from '~/alert';
+import Tracking from '~/tracking';
import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
import RetryFailedJobMutation from '../../graphql/mutations/retry_failed_job.mutation.graphql';
-import { DEFAULT_FIELDS } from '../../constants';
+import { DEFAULT_FIELDS, TRACKING_CATEGORIES } from '../../constants';
export default {
fields: DEFAULT_FIELDS,
@@ -20,6 +21,7 @@ export default {
directives: {
SafeHtml,
},
+ mixins: [Tracking.mixin()],
props: {
failedJobs: {
type: Array,
@@ -28,6 +30,8 @@ export default {
},
methods: {
async retryJob(id) {
+ this.track('click_retry', { label: TRACKING_CATEGORIES.failed });
+
try {
const {
data: {
diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue
deleted file mode 100644
index 91630d4cfd4..00000000000
--- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue
+++ /dev/null
@@ -1,149 +0,0 @@
-<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-import { createAlert } from '~/alert';
-import { __ } from '~/locale';
-import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils';
-import {
- getQueryHeaders,
- toggleQueryPollingByVisibility,
-} from '~/pipelines/components/graph/utils';
-import { PIPELINE_MINI_GRAPH_POLL_INTERVAL } from '~/pipelines/constants';
-import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql';
-import getPipelineStagesQuery from '~/pipelines/graphql/queries/get_pipeline_stages.query.graphql';
-import PipelineMiniGraph from './pipeline_mini_graph.vue';
-
-export default {
- i18n: {
- linkedPipelinesFetchError: __('There was a problem fetching linked pipelines.'),
- stagesFetchError: __('There was a problem fetching the pipeline stages.'),
- },
- components: {
- GlLoadingIcon,
- PipelineMiniGraph,
- },
- props: {
- pipelineEtag: {
- type: String,
- required: true,
- },
- fullPath: {
- type: String,
- required: true,
- },
- iid: {
- type: String,
- required: true,
- },
- isMergeTrain: {
- type: Boolean,
- required: false,
- default: false,
- },
- pollInterval: {
- type: Number,
- required: false,
- default: PIPELINE_MINI_GRAPH_POLL_INTERVAL,
- },
- },
- data() {
- return {
- linkedPipelines: null,
- pipelineStages: [],
- };
- },
- apollo: {
- linkedPipelines: {
- context() {
- return getQueryHeaders(this.pipelineEtag);
- },
- query: getLinkedPipelinesQuery,
- pollInterval() {
- return this.pollInterval;
- },
- variables() {
- return {
- fullPath: this.fullPath,
- iid: this.iid,
- };
- },
- update({ project }) {
- return project?.pipeline || this.linkedpipelines;
- },
- error() {
- createAlert({ message: this.$options.i18n.linkedPipelinesFetchError });
- },
- },
- pipelineStages: {
- context() {
- return getQueryHeaders(this.pipelineEtag);
- },
- query: getPipelineStagesQuery,
- pollInterval() {
- return this.pollInterval;
- },
- variables() {
- return {
- fullPath: this.fullPath,
- iid: this.iid,
- };
- },
- update({ project }) {
- return project?.pipeline?.stages?.nodes || this.pipelineStages;
- },
- error() {
- createAlert({ message: this.$options.i18n.stagesFetchError });
- },
- },
- },
- computed: {
- downstreamPipelines() {
- return keepLatestDownstreamPipelines(this.linkedPipelines?.downstream?.nodes);
- },
- formattedStages() {
- return this.pipelineStages.map((stage) => {
- const { name, detailedStatus } = stage;
- return {
- // TODO: Once we fetch stage by ID with GraphQL,
- // this method will change.
- // see https://gitlab.com/gitlab-org/gitlab/-/issues/384853
- id: stage.id,
- dropdown_path: `${this.pipelinePath}/stage.json?stage=${name}`,
- name,
- path: `${this.pipelinePath}#${name}`,
- status: {
- details_path: `${this.pipelinePath}#${name}`,
- has_details: detailedStatus?.hasDetails || false,
- ...detailedStatus,
- },
- title: `${name}: ${detailedStatus?.text || ''}`,
- };
- });
- },
- pipelinePath() {
- return this.linkedPipelines?.path || '';
- },
- upstreamPipeline() {
- return this.linkedPipelines?.upstream;
- },
- },
- mounted() {
- toggleQueryPollingByVisibility(this.$apollo.queries.linkedPipelines);
- toggleQueryPollingByVisibility(this.$apollo.queries.pipelineStages);
- },
-};
-</script>
-
-<template>
- <div>
- <gl-loading-icon v-if="$apollo.queries.pipelineStages.loading" />
- <pipeline-mini-graph
- v-else
- data-testid="graphql-pipeline-mini-graph"
- :downstream-pipelines="downstreamPipelines"
- :is-merge-train="isMergeTrain"
- :pipeline-path="pipelinePath"
- :stages="formattedStages"
- :upstream-pipeline="upstreamPipeline"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue
index 66bf5068149..7f97097def6 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue
@@ -1,197 +1,13 @@
<script>
-import { GlTooltipDirective, GlLink } from '@gitlab/ui';
-import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
-import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
-import { __, sprintf } from '~/locale';
-import { reportToSentry } from '../../utils';
-import ActionComponent from '../jobs_shared/action_component.vue';
-import JobNameComponent from '../jobs_shared/job_name_component.vue';
-
-/**
- * Renders the badge for the pipeline graph and the job's dropdown.
- *
- * The following object should be provided as `job`:
- *
- * {
- * "id": 4256,
- * "name": "test",
- * "status": {
- * "icon": "status_success",
- * "text": "passed",
- * "label": "passed",
- * "group": "success",
- * "tooltip": "passed",
- * "details_path": "/root/ci-mock/builds/4256",
- * "action": {
- * "icon": "retry",
- * "title": "Retry",
- * "path": "/root/ci-mock/builds/4256/retry",
- * "method": "post"
- * }
- * }
- * }
- */
-
export default {
- i18n: {
- runAgainTooltipText: __('Run again'),
- },
- hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500',
- components: {
- ActionComponent,
- JobNameComponent,
- GlLink,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- mixins: [delayedJobMixin],
props: {
job: {
type: Object,
required: true,
},
- cssClassJobName: {
- type: String,
- required: false,
- default: '',
- },
- dropdownLength: {
- type: Number,
- required: false,
- default: Infinity,
- },
- jobHovered: {
- type: String,
- required: false,
- default: '',
- },
- pipelineExpanded: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- pipelineId: {
- type: Number,
- required: false,
- default: -1,
- },
- },
- computed: {
- boundary() {
- return this.dropdownLength === 1 ? 'viewport' : 'scrollParent';
- },
- detailsPath() {
- return this.status.details_path;
- },
- hasDetails() {
- return this.status.has_details;
- },
- status() {
- return this.job && this.job.status ? this.job.status : {};
- },
- tooltipText() {
- const textBuilder = [];
- const { name: jobName } = this.job;
-
- if (jobName) {
- textBuilder.push(jobName);
- }
-
- const { tooltip: statusTooltip } = this.status;
- if (jobName && statusTooltip) {
- textBuilder.push('-');
- }
-
- if (statusTooltip) {
- if (this.isDelayedJob) {
- textBuilder.push(sprintf(statusTooltip, { remainingTime: this.remainingTime }));
- } else {
- textBuilder.push(statusTooltip);
- }
- }
-
- return textBuilder.join(' ');
- },
- /**
- * Verifies if the provided job has an action path
- *
- * @return {Boolean}
- */
- hasAction() {
- return this.job.status && this.job.status.action && this.job.status.action.path;
- },
- relatedDownstreamHovered() {
- return this.job.name === this.jobHovered;
- },
- relatedDownstreamExpanded() {
- return this.job.name === this.pipelineExpanded.jobName && this.pipelineExpanded.expanded;
- },
- jobClasses() {
- return this.relatedDownstreamHovered || this.relatedDownstreamExpanded
- ? `${this.$options.hoverClass} ${this.cssClassJobName}`
- : this.cssClassJobName;
- },
- jobActionTooltipText() {
- const { group } = this.status;
- const { title, icon } = this.status.action;
-
- return icon === 'retry' && group === 'success'
- ? this.$options.i18n.runAgainTooltipText
- : title;
- },
- },
- errorCaptured(err, _vm, info) {
- reportToSentry('pipelines_job_item', `pipelines_job_item error: ${err}, info: ${info}`);
- },
- methods: {
- hideTooltips() {
- this.$root.$emit(BV_HIDE_TOOLTIP);
- },
},
};
</script>
<template>
- <div
- class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between"
- data-qa-selector="job_item_container"
- >
- <gl-link
- v-if="hasDetails"
- v-gl-tooltip="{
- boundary: 'viewport',
- placement: 'bottom',
- customClass: 'gl-pointer-events-none',
- }"
- :href="detailsPath"
- :title="tooltipText"
- :class="jobClasses"
- class="js-pipeline-graph-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none"
- data-testid="job-with-link"
- @click.stop="hideTooltips"
- @mouseout="hideTooltips"
- >
- <job-name-component :name="job.name" :status="job.status" />
- </gl-link>
-
- <div
- v-else
- v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }"
- :title="tooltipText"
- :class="jobClasses"
- class="js-job-component-tooltip non-details-job-component menu-item"
- data-testid="job-without-link"
- @mouseout="hideTooltips"
- >
- <job-name-component :name="job.name" :status="job.status" />
- </div>
-
- <action-component
- v-if="hasAction"
- :tooltip-text="jobActionTooltipText"
- :link="status.action.path"
- :action-icon="status.action.icon"
- data-qa-selector="action_button"
- />
- </div>
+ <div>{{ job.id }}</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_job_item.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_job_item.vue
new file mode 100644
index 00000000000..d6e585d093b
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_job_item.vue
@@ -0,0 +1,168 @@
+<script>
+import { GlTooltipDirective, GlLink } from '@gitlab/ui';
+import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
+import { s__, sprintf } from '~/locale';
+import { reportToSentry } from '../../utils';
+import ActionComponent from '../jobs_shared/action_component.vue';
+import JobNameComponent from '../jobs_shared/job_name_component.vue';
+import { ICONS } from '../../constants';
+
+/**
+ * Renders the badge for the pipeline graph and the job's dropdown.
+ *
+ * The following object should be provided as `job`:
+ *
+ * {
+ * "id": 4256,
+ * "name": "test",
+ * "status": {
+ * "icon": "status_success",
+ * "text": "passed",
+ * "label": "passed",
+ * "group": "success",
+ * "tooltip": "passed",
+ * "details_path": "/root/ci-mock/builds/4256",
+ * "action": {
+ * "icon": "retry",
+ * "title": "Retry",
+ * "path": "/root/ci-mock/builds/4256/retry",
+ * "method": "post"
+ * }
+ * }
+ * }
+ */
+
+export default {
+ i18n: {
+ runAgainTooltipText: s__('Pipeline|Run again'),
+ },
+ tooltipConfig: {
+ boundary: 'viewport',
+ placement: 'bottom',
+ customClass: 'gl-pointer-events-none',
+ },
+ components: {
+ ActionComponent,
+ JobNameComponent,
+ GlLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [delayedJobMixin],
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ cssClassJobName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ dropdownLength: {
+ type: Number,
+ required: false,
+ default: Infinity,
+ },
+ pipelineId: {
+ type: Number,
+ required: false,
+ default: -1,
+ },
+ },
+ computed: {
+ boundary() {
+ return this.dropdownLength === 1 ? 'viewport' : 'scrollParent';
+ },
+ detailsPath() {
+ return this.status?.details_path;
+ },
+ hasDetails() {
+ return this.status?.has_details;
+ },
+ status() {
+ return this.job?.status ? this.job.status : {};
+ },
+ tooltipText() {
+ const textBuilder = [];
+ const { name: jobName } = this.job;
+
+ if (jobName) {
+ textBuilder.push(jobName);
+ }
+
+ const { tooltip: statusTooltip } = this.status;
+ if (jobName && statusTooltip) {
+ textBuilder.push('-');
+ }
+
+ if (statusTooltip) {
+ if (this.isDelayedJob) {
+ textBuilder.push(sprintf(statusTooltip, { remainingTime: this.remainingTime }));
+ } else {
+ textBuilder.push(statusTooltip);
+ }
+ }
+
+ return textBuilder.join(' ');
+ },
+ /**
+ * Verifies if the provided job has an action path
+ *
+ * @return {Boolean}
+ */
+ hasJobAction() {
+ return Boolean(this.job?.status?.action?.path);
+ },
+ jobActionTooltipText() {
+ const { group } = this.status;
+ const { title, icon } = this.status.action;
+
+ return icon === ICONS.RETRY && group === ICONS.SUCCESS
+ ? this.$options.i18n.runAgainTooltipText
+ : title;
+ },
+ },
+ errorCaptured(err, _vm, info) {
+ reportToSentry('pipelines_job_item', `pipelines_job_item error: ${err}, info: ${info}`);
+ },
+};
+</script>
+<template>
+ <div
+ class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between"
+ data-qa-selector="job_item_container"
+ >
+ <gl-link
+ v-if="hasDetails"
+ v-gl-tooltip="$options.tooltipConfig"
+ :href="detailsPath"
+ :title="tooltipText"
+ :class="cssClassJobName"
+ 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"
+ >
+ <job-name-component :name="job.name" :status="job.status" />
+ </gl-link>
+
+ <div
+ v-else
+ v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }"
+ :title="tooltipText"
+ :class="cssClassJobName"
+ class="js-job-component-tooltip non-details-job-component menu-item"
+ data-testid="job-without-link"
+ >
+ <job-name-component :name="job.name" :status="job.status" />
+ </div>
+
+ <action-component
+ v-if="hasJobAction"
+ :tooltip-text="jobActionTooltipText"
+ :link="status.action.path"
+ :action-icon="status.action.icon"
+ data-qa-selector="action_button"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph.vue
new file mode 100644
index 00000000000..8c0e65d1d39
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph.vue
@@ -0,0 +1,98 @@
+<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.
+ * TODO: After all apps have updated to GraphQL data and use the `pipeline_mini_graph.vue` file as an entry,
+ * we should rename this file to `pipeline_mini_graph_wrapper.vue`
+ */
+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: () => [],
+ },
+ isGraphql: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isMergeTrain: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ pipelinePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ stages: {
+ type: Array,
+ required: true,
+ default: () => [],
+ },
+ updateDropdown: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ upstreamPipeline: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ },
+ computed: {
+ hasDownstreamPipelines() {
+ return Boolean(this.downstreamPipelines.length);
+ },
+ },
+};
+</script>
+<template>
+ <div 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-graphql="isGraphql"
+ :is-merge-train="isMergeTrain"
+ :stages="stages"
+ :update-dropdown="updateDropdown"
+ @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/pipeline_mini_graph/legacy_pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_pipeline_stage.vue
new file mode 100644
index 00000000000..048e42731c7
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_pipeline_stage.vue
@@ -0,0 +1,176 @@
+<script>
+/**
+ * Renders each stage of the pipeline mini graph.
+ *
+ * Given the provided endpoint will make a request to
+ * fetch the dropdown data when the stage is clicked.
+ *
+ * Request is made inside this component to make it reusable between:
+ * 1. Pipelines main table
+ * 2. Pipelines table in commit and Merge request views
+ * 3. Merge request widget
+ * 4. Commit widget
+ */
+
+import { GlDropdown, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import { createAlert } from '~/alert';
+import axios from '~/lib/utils/axios_utils';
+import { __, s__, sprintf } from '~/locale';
+import eventHub from '../../event_hub';
+import LegacyJobItem from './legacy_job_item.vue';
+
+export default {
+ i18n: {
+ errorMessage: __('Something went wrong on our end.'),
+ loadingText: __('Loading...'),
+ mergeTrainMessage: s__('Pipeline|Merge train pipeline jobs can not be retried'),
+ stage: __('Stage:'),
+ viewStageLabel: __('View Stage: %{title}'),
+ },
+ dropdownPopperOpts: {
+ placement: 'bottom',
+ positionFixed: true,
+ },
+ components: {
+ CiIcon,
+ GlLoadingIcon,
+ GlDropdown,
+ LegacyJobItem,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ stage: {
+ type: Object,
+ required: true,
+ },
+ updateDropdown: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isMergeTrain: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ isDropdownOpen: false,
+ isLoading: false,
+ dropdownContent: [],
+ stageName: '',
+ };
+ },
+ watch: {
+ updateDropdown() {
+ if (this.updateDropdown && this.isDropdownOpen && !this.isLoading) {
+ this.fetchJobs();
+ }
+ },
+ },
+ methods: {
+ onHideDropdown() {
+ this.isDropdownOpen = false;
+ },
+ onShowDropdown() {
+ eventHub.$emit('clickedDropdown');
+ 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
+ .get(this.stage.dropdown_path)
+ .then(({ data }) => {
+ this.dropdownContent = data.latest_statuses;
+ this.stageName = data.name;
+ this.isLoading = false;
+ })
+ .catch(() => {
+ this.$refs.dropdown.hide();
+ this.isLoading = false;
+
+ createAlert({
+ message: this.$options.i18n.errorMessage,
+ });
+ });
+ },
+ stageAriaLabel(title) {
+ return sprintf(this.$options.i18n.viewStageLabel, { title });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ ref="dropdown"
+ v-gl-tooltip.hover.ds0
+ v-gl-tooltip="stage.title"
+ data-testid="mini-pipeline-graph-dropdown"
+ variant="link"
+ :aria-label="stageAriaLabel(stage.title)"
+ :lazy="true"
+ :popper-opts="$options.dropdownPopperOpts"
+ :toggle-class="['gl-rounded-full!']"
+ menu-class="mini-pipeline-graph-dropdown-menu"
+ @hide="onHideDropdown"
+ @show="onShowDropdown"
+ >
+ <template #button-content>
+ <ci-icon
+ is-borderless
+ is-interactive
+ css-classes="gl-rounded-full"
+ :is-active="isDropdownOpen"
+ :size="24"
+ :status="stage.status"
+ class="gl-display-inline-flex gl-align-items-center gl-border gl-z-index-1"
+ />
+ </template>
+ <div v-if="isLoading" class="gl--flex-center gl-p-2" data-testid="pipeline-stage-loading-state">
+ <gl-loading-icon size="sm" class="gl-mr-3" />
+ <p class="gl-line-height-normal gl-mb-0">{{ $options.i18n.loadingText }}</p>
+ </div>
+ <ul
+ v-else
+ class="js-builds-dropdown-list scrollable-menu"
+ data-testid="mini-pipeline-graph-dropdown-menu-list"
+ >
+ <div class="gl--flex-center gl-border-b gl-font-weight-bold gl-mb-3 gl-pb-3">
+ <span class="gl-mr-1">{{ $options.i18n.stage }}</span>
+ <span data-testid="pipeline-stage-dropdown-menu-title">{{ stageName }}</span>
+ </div>
+ <li v-for="job in dropdownContent" :key="job.id">
+ <legacy-job-item
+ :dropdown-length="dropdownContent.length"
+ :job="job"
+ css-class-job-name="pipeline-job-item"
+ />
+ </li>
+ <template v-if="isMergeTrain">
+ <li class="gl-dropdown-divider" role="presentation">
+ <hr role="separator" aria-orientation="horizontal" class="dropdown-divider" />
+ </li>
+ <li>
+ <div
+ class="gl-display-flex gl-align-items-center"
+ data-testid="warning-message-merge-trains"
+ >
+ <div class="menu-item gl-font-sm gl-text-gray-300!">
+ {{ $options.i18n.mergeTrainMessage }}
+ </div>
+ </div>
+ </li>
+ </template>
+ </ul>
+ </gl-dropdown>
+</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
index 827adf9f7f7..7cdaec81466 100644
--- 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
@@ -1,91 +1,150 @@
<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.
- */
+import { GlLoadingIcon } from '@gitlab/ui';
+import { createAlert } from '~/alert';
+import { __ } from '~/locale';
+import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils';
+import {
+ getQueryHeaders,
+ toggleQueryPollingByVisibility,
+} from '~/pipelines/components/graph/utils';
+import { PIPELINE_MINI_GRAPH_POLL_INTERVAL } from '~/pipelines/constants';
+import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql';
+import getPipelineStagesQuery from '~/pipelines/graphql/queries/get_pipeline_stages.query.graphql';
+import LegacyPipelineMiniGraph from './legacy_pipeline_mini_graph.vue';
+
export default {
+ i18n: {
+ linkedPipelinesFetchError: __('There was a problem fetching linked pipelines.'),
+ stagesFetchError: __('There was a problem fetching the pipeline stages.'),
+ },
components: {
- GlIcon,
- LinkedPipelinesMiniList,
- PipelineStages,
+ GlLoadingIcon,
+ LegacyPipelineMiniGraph,
},
- 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,
+ pipelineEtag: {
+ type: String,
+ required: true,
},
- pipelinePath: {
+ fullPath: {
type: String,
- required: false,
- default: '',
+ required: true,
},
- stages: {
- type: Array,
+ iid: {
+ type: String,
required: true,
- default: () => [],
},
- updateDropdown: {
+ isMergeTrain: {
type: Boolean,
required: false,
default: false,
},
- upstreamPipeline: {
- type: Object,
+ pollInterval: {
+ type: Number,
required: false,
- default: () => {},
+ default: PIPELINE_MINI_GRAPH_POLL_INTERVAL,
+ },
+ },
+ data() {
+ return {
+ linkedPipelines: null,
+ pipelineStages: [],
+ };
+ },
+ apollo: {
+ linkedPipelines: {
+ context() {
+ return getQueryHeaders(this.pipelineEtag);
+ },
+ query: getLinkedPipelinesQuery,
+ pollInterval() {
+ return this.pollInterval;
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: this.iid,
+ };
+ },
+ update({ project }) {
+ return project?.pipeline || this.linkedpipelines;
+ },
+ error() {
+ createAlert({ message: this.$options.i18n.linkedPipelinesFetchError });
+ },
+ },
+ pipelineStages: {
+ context() {
+ return getQueryHeaders(this.pipelineEtag);
+ },
+ query: getPipelineStagesQuery,
+ pollInterval() {
+ return this.pollInterval;
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: this.iid,
+ };
+ },
+ update({ project }) {
+ return project?.pipeline?.stages?.nodes || this.pipelineStages;
+ },
+ error() {
+ createAlert({ message: this.$options.i18n.stagesFetchError });
+ },
},
},
computed: {
- hasDownstreamPipelines() {
- return Boolean(this.downstreamPipelines.length);
+ downstreamPipelines() {
+ return keepLatestDownstreamPipelines(this.linkedPipelines?.downstream?.nodes);
+ },
+ formattedStages() {
+ return this.pipelineStages.map((stage) => {
+ const { name, detailedStatus } = stage;
+ return {
+ // TODO: Once we fetch stage by ID with GraphQL,
+ // this method will change.
+ // see https://gitlab.com/gitlab-org/gitlab/-/issues/384853
+ id: stage.id,
+ dropdown_path: `${this.pipelinePath}/stage.json?stage=${name}`,
+ name,
+ path: `${this.pipelinePath}#${name}`,
+ status: {
+ details_path: `${this.pipelinePath}#${name}`,
+ has_details: detailedStatus?.hasDetails || false,
+ ...detailedStatus,
+ },
+ title: `${name}: ${detailedStatus?.text || ''}`,
+ };
+ });
+ },
+ pipelinePath() {
+ return this.linkedPipelines?.path || '';
},
+ upstreamPipeline() {
+ return this.linkedPipelines?.upstream;
+ },
+ },
+ mounted() {
+ toggleQueryPollingByVisibility(this.$apollo.queries.linkedPipelines);
+ toggleQueryPollingByVisibility(this.$apollo.queries.pipelineStages);
},
};
</script>
+
<template>
- <div 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
+ <div>
+ <gl-loading-icon v-if="$apollo.queries.pipelineStages.loading" />
+ <legacy-pipeline-mini-graph
+ v-else
+ data-testid="pipeline-mini-graph"
+ is-graphql
+ :downstream-pipelines="downstreamPipelines"
:is-merge-train="isMergeTrain"
- :stages="stages"
- :update-dropdown="updateDropdown"
- data-testid="pipeline-stages"
- @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"
+ :stages="formattedStages"
+ :upstream-pipeline="upstreamPipeline"
/>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue
index 936cd6f0be5..8e22f440089 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue
@@ -1,173 +1,84 @@
<script>
-/**
- * Renders each stage of the pipeline mini graph.
- *
- * Given the provided endpoint will make a request to
- * fetch the dropdown data when the stage is clicked.
- *
- * Request is made inside this component to make it reusable between:
- * 1. Pipelines main table
- * 2. Pipelines table in commit and Merge request views
- * 3. Merge request widget
- * 4. Commit widget
- */
-
-import { GlDropdown, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { createAlert } from '~/alert';
-import axios from '~/lib/utils/axios_utils';
-import { __, sprintf } from '~/locale';
-import eventHub from '../../event_hub';
+import { __ } from '~/locale';
+import { PIPELINE_MINI_GRAPH_POLL_INTERVAL } from '~/pipelines/constants';
+import {
+ getQueryHeaders,
+ toggleQueryPollingByVisibility,
+} from '~/pipelines/components/graph/utils';
+import getPipelineStageQuery from '~/pipelines/graphql/queries/get_pipeline_stage.query.graphql';
import JobItem from './job_item.vue';
export default {
i18n: {
- stage: __('Stage:'),
- loadingText: __('Loading, please wait.'),
- },
- dropdownPopperOpts: {
- placement: 'bottom',
- positionFixed: true,
+ stageFetchError: __('There was a problem fetching the pipeline stage.'),
},
+
components: {
- CiIcon,
- GlLoadingIcon,
- GlDropdown,
JobItem,
},
- directives: {
- GlTooltip: GlTooltipDirective,
- },
props: {
- stage: {
- type: Object,
- required: true,
- },
- updateDropdown: {
+ isMergeTrain: {
type: Boolean,
required: false,
default: false,
},
- isMergeTrain: {
- type: Boolean,
+ pipelineEtag: {
+ type: String,
+ required: true,
+ },
+ pollInterval: {
+ type: Number,
required: false,
- default: false,
+ default: PIPELINE_MINI_GRAPH_POLL_INTERVAL,
+ },
+ stageId: {
+ type: String,
+ required: true,
},
},
data() {
return {
- isDropdownOpen: false,
- isLoading: false,
- dropdownContent: [],
- stageName: '',
+ jobs: [],
+ stage: null,
};
},
- watch: {
- updateDropdown() {
- if (this.updateDropdown && this.isDropdownOpen && !this.isLoading) {
- this.fetchJobs();
- }
+ apollo: {
+ stage: {
+ context() {
+ return getQueryHeaders(this.pipelineEtag);
+ },
+ query: getPipelineStageQuery,
+ pollInterval() {
+ return this.pollInterval;
+ },
+ variables() {
+ return {
+ id: this.stageId,
+ };
+ },
+ skip() {
+ return !this.stageId;
+ },
+ update(data) {
+ this.jobs = data?.ciPipelineStage?.jobs.nodes;
+ return data?.ciPipelineStage;
+ },
+ error() {
+ createAlert({ message: this.$options.i18n.stageFetchError });
+ },
},
},
- methods: {
- onHideDropdown() {
- this.isDropdownOpen = false;
- },
- onShowDropdown() {
- eventHub.$emit('clickedDropdown');
- 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
- .get(this.stage.dropdown_path)
- .then(({ data }) => {
- this.dropdownContent = data.latest_statuses;
- this.stageName = data.name;
- this.isLoading = false;
- })
- .catch(() => {
- this.$refs.dropdown.hide();
- this.isLoading = false;
-
- createAlert({
- message: __('Something went wrong on our end.'),
- });
- });
- },
- stageAriaLabel(title) {
- return sprintf(__('View Stage: %{title}'), { title });
- },
+ mounted() {
+ toggleQueryPollingByVisibility(this.$apollo.queries.stage);
},
};
</script>
<template>
- <gl-dropdown
- ref="dropdown"
- v-gl-tooltip.hover.ds0
- v-gl-tooltip="stage.title"
- data-testid="mini-pipeline-graph-dropdown"
- variant="link"
- :aria-label="stageAriaLabel(stage.title)"
- :lazy="true"
- :popper-opts="$options.dropdownPopperOpts"
- :toggle-class="['gl-rounded-full!']"
- menu-class="mini-pipeline-graph-dropdown-menu"
- @hide="onHideDropdown"
- @show="onShowDropdown"
- >
- <template #button-content>
- <ci-icon
- is-borderless
- is-interactive
- css-classes="gl-rounded-full"
- :is-active="isDropdownOpen"
- :size="24"
- :status="stage.status"
- class="gl-display-inline-flex gl-align-items-center gl-border gl-z-index-1"
- />
- </template>
- <div v-if="isLoading" class="gl--flex-center gl-p-2" data-testid="pipeline-stage-loading-state">
- <gl-loading-icon size="sm" class="gl-mr-3" />
- <p class="gl-line-height-normal gl-mb-0">{{ $options.i18n.loadingText }}</p>
- </div>
- <ul
- v-else
- class="js-builds-dropdown-list scrollable-menu"
- data-testid="mini-pipeline-graph-dropdown-menu-list"
- >
- <div class="gl--flex-center gl-border-b gl-font-weight-bold gl-mb-3 gl-pb-3">
- <span class="gl-mr-1">{{ $options.i18n.stage }}</span>
- <span data-testid="pipeline-stage-dropdown-menu-title">{{ stageName }}</span>
- </div>
- <li v-for="job in dropdownContent" :key="job.id">
- <job-item
- :dropdown-length="dropdownContent.length"
- :job="job"
- css-class-job-name="mini-pipeline-graph-dropdown-item"
- />
- </li>
- <template v-if="isMergeTrain">
- <li class="gl-dropdown-divider" role="presentation">
- <hr role="separator" aria-orientation="horizontal" class="dropdown-divider" />
- </li>
- <li>
- <div
- class="gl-display-flex gl-align-items-center"
- data-testid="warning-message-merge-trains"
- >
- <div class="menu-item gl-font-sm gl-text-gray-300!">
- {{ s__('Pipeline|Merge train pipeline jobs can not be retried') }}
- </div>
- </div>
- </li>
- </template>
+ <div data-testid="pipeline-stage">
+ <ul v-for="job in jobs" :key="job.id">
+ <job-item :job="job" />
</ul>
- </gl-dropdown>
+ </div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue
index ba549d9b423..02dba9ba30f 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue
@@ -1,10 +1,12 @@
<script>
import PipelineStage from './pipeline_stage.vue';
+import LegacyPipelineStage from './legacy_pipeline_stage.vue';
/**
* Renders the pipeline stages portion of the pipeline mini graph.
*/
export default {
components: {
+ LegacyPipelineStage,
PipelineStage,
},
props: {
@@ -17,22 +19,40 @@ export default {
required: false,
default: false,
},
+ isGraphql: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
isMergeTrain: {
type: Boolean,
required: false,
default: false,
},
+ pipelineEtag: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
};
</script>
<template>
- <div data-testid="pipeline-stages" class="gl-display-inline gl-vertical-align-middle">
+ <div class="gl-display-inline gl-vertical-align-middle">
<div
v-for="stage in stages"
:key="stage.name"
class="pipeline-mini-graph-stage-container dropdown gl-display-inline-block gl-mr-2 gl-my-2 gl-vertical-align-middle"
>
<pipeline-stage
+ v-if="isGraphql"
+ :stage-id="stage.id"
+ :is-merge-train="isMergeTrain"
+ :pipeline-etag="pipelineEtag"
+ @miniGraphStageClick="$emit('miniGraphStageClick')"
+ />
+ <legacy-pipeline-stage
+ v-else
:stage="stage"
:update-dropdown="updateDropdown"
:is-merge-train="isMergeTrain"
diff --git a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue
index d2ec3c352fe..35dde6379dd 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue
@@ -1,12 +1,14 @@
<script>
import { GlBadge, GlTabs, GlTab } from '@gitlab/ui';
import { __ } from '~/locale';
+import Tracking from '~/tracking';
import {
failedJobsTabName,
jobsTabName,
needsTabName,
pipelineTabName,
testReportTabName,
+ TRACKING_CATEGORIES,
} from '../constants';
export default {
@@ -31,6 +33,7 @@ export default {
GlTab,
GlTabs,
},
+ mixins: [Tracking.mixin()],
inject: ['defaultTabValue', 'failedJobsCount', 'totalJobCount', 'testsCount'],
data() {
return {
@@ -52,8 +55,20 @@ export default {
return tabName === this.activeTab;
},
navigateTo(tabName) {
+ if (this.isActive(tabName)) return;
+
this.$router.push({ name: tabName });
},
+ failedJobsTabClick() {
+ this.track('click_tab', { label: TRACKING_CATEGORIES.failed });
+
+ this.navigateTo(this.$options.tabNames.failures);
+ },
+ testsTabClick() {
+ this.track('click_tab', { label: TRACKING_CATEGORIES.tests });
+
+ this.navigateTo(this.$options.tabNames.tests);
+ },
},
};
</script>
@@ -98,7 +113,7 @@ export default {
:active="isActive($options.tabNames.failures)"
data-testid="failed-jobs-tab"
lazy
- @click="navigateTo($options.tabNames.failures)"
+ @click="failedJobsTabClick"
>
<template #title>
<span class="gl-mr-2">{{ $options.i18n.tabs.failedJobsTitle }}</span>
@@ -110,7 +125,7 @@ export default {
:active="isActive($options.tabNames.tests)"
data-testid="tests-tab"
lazy
- @click="navigateTo($options.tabNames.tests)"
+ @click="testsTabClick"
>
<template #title>
<span class="gl-mr-2">{{ $options.i18n.tabs.testsTitle }}</span>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_job_details.vue b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_job_details.vue
index 6b5e3d77b92..edf4cc87a87 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_job_details.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_job_details.vue
@@ -1,17 +1,17 @@
<script>
-import { GlButton, GlCollapse, GlIcon, GlLink, GlTooltip } from '@gitlab/ui';
+import { GlButton, GlIcon, GlLink, GlTooltip } from '@gitlab/ui';
import { createAlert } from '~/alert';
import { __, s__, sprintf } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import SafeHtml from '~/vue_shared/directives/safe_html';
+import { BRIDGE_KIND } from '~/pipelines/components/graph/constants';
import RetryMrFailedJobMutation from '../../../graphql/mutations/retry_mr_failed_job.mutation.graphql';
export default {
components: {
CiIcon,
GlButton,
- GlCollapse,
GlIcon,
GlLink,
GlTooltip,
@@ -33,17 +33,14 @@ export default {
};
},
computed: {
- activeClass() {
- return this.isHovered ? 'gl-bg-gray-50' : '';
- },
canReadBuild() {
return this.job.userPermissions.readBuild;
},
canRetryJob() {
- return this.job.retryable && this.job.userPermissions.updateBuild;
+ return this.job.retryable && this.job.userPermissions.updateBuild && !this.isBridgeJob;
},
- isVisibleId() {
- return `log-${this.isJobLogVisible ? 'is-visible' : 'is-hidden'}`;
+ isBridgeJob() {
+ return this.job.kind === BRIDGE_KIND;
},
jobChevronName() {
return this.isJobLogVisible ? 'chevron-down' : 'chevron-right';
@@ -58,6 +55,11 @@ export default {
parsedJobId() {
return getIdFromGraphQLId(this.job.id);
},
+ tooltipErrorText() {
+ return this.isBridgeJob
+ ? this.$options.i18n.cannotRetryTrigger
+ : this.$options.i18n.cannotRetry;
+ },
tooltipText() {
return sprintf(this.$options.i18n.jobActionTooltipText, { jobName: this.job.name });
},
@@ -102,8 +104,9 @@ export default {
},
},
i18n: {
- cannotReadBuild: s__("Job|You do not have permission to read this job's log"),
- cannotRetry: s__('Job|You do not have permission to retry this job'),
+ cannotReadBuild: s__("Job|You do not have permission to read this job's log."),
+ cannotRetry: s__('Job|You do not have permission to run this job again.'),
+ cannotRetryTrigger: s__('Job|You cannot rerun trigger jobs from this list.'),
jobActionTooltipText: s__('Pipelines|Retry %{jobName} Job'),
noTraceText: s__('Job|No job log'),
retry: __('Retry'),
@@ -114,8 +117,7 @@ export default {
<template>
<div class="container-fluid gl-grid-tpl-rows-auto">
<div
- class="row gl-py-4 gl-cursor-pointer gl-display-flex gl-align-items-center"
- :class="activeClass"
+ class="row gl-my-3 gl-cursor-pointer gl-display-flex gl-align-items-center"
:aria-pressed="isJobLogVisible"
role="button"
tabindex="0"
@@ -127,22 +129,23 @@ export default {
@mouseout="resetActiveRow"
>
<div class="col-6 gl-text-gray-900 gl-font-weight-bold gl-text-left">
- <gl-icon :name="jobChevronName" class="gl-fill-blue-500" />
+ <gl-icon :name="jobChevronName" />
<ci-icon :status="job.detailedStatus" />
{{ job.name }}
</div>
<div class="col-2 gl-text-left">{{ job.stage.name }}</div>
<div class="col-2 gl-text-left">
- <gl-link :href="job.webPath">#{{ parsedJobId }}</gl-link>
+ <gl-link :href="job.detailedStatus.detailsPath">#{{ parsedJobId }}</gl-link>
</div>
<gl-tooltip v-if="!canRetryJob" :target="() => $refs.retryBtn" placement="top">
- {{ $options.i18n.cannotRetry }}
+ {{ tooltipErrorText }}
</gl-tooltip>
- <div class="col-2 gl-text-left">
+ <div class="col-2 gl-text-right">
<span ref="retryBtn">
<gl-button
:disabled="!canRetryJob"
icon="retry"
+ category="tertiary"
:loading="isLoadingAction"
:title="$options.i18n.retry"
:aria-label="$options.i18n.retry"
@@ -151,14 +154,12 @@ export default {
</span>
</div>
</div>
- <div class="row">
- <gl-collapse :visible="isJobLogVisible" class="gl-w-full">
- <pre
- v-safe-html="jobTrace"
- class="gl-bg-gray-900 gl-text-white"
- :data-testid="isVisibleId"
- ></pre>
- </gl-collapse>
+ <div v-if="isJobLogVisible" class="row">
+ <pre
+ v-safe-html="jobTrace"
+ class="gl-bg-gray-900 gl-text-white gl-w-full"
+ data-testid="job-log"
+ ></pre>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue
index 36687129cdd..2c5aa84bc4f 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue
@@ -9,9 +9,8 @@ import FailedJobDetails from './failed_job_details.vue';
const POLL_INTERVAL = 10000;
-const JOB_ACTION_HEADER = __('Actions');
-const JOB_ID_HEADER = __('Job ID');
-const JOB_NAME_HEADER = __('Job name');
+const JOB_ID_HEADER = __('ID');
+const JOB_NAME_HEADER = __('Name');
const STAGE_HEADER = __('Stage');
export default {
@@ -19,8 +18,12 @@ export default {
GlLoadingIcon,
FailedJobDetails,
},
- inject: ['fullPath', 'graphqlPath'],
+ inject: ['graphqlPath'],
props: {
+ failedJobsCount: {
+ required: true,
+ type: Number,
+ },
isPipelineActive: {
required: true,
type: Boolean,
@@ -29,6 +32,10 @@ export default {
type: Number,
required: true,
},
+ projectPath: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -46,7 +53,7 @@ export default {
pollInterval: POLL_INTERVAL,
variables() {
return {
- fullPath: this.fullPath,
+ fullPath: this.projectPath,
pipelineIid: this.pipelineIid,
};
},
@@ -92,6 +99,13 @@ export default {
isActive(flag) {
this.handlePolling(flag);
},
+ failedJobsCount(count) {
+ // If the REST data is updated first, we force a refetch
+ // to keep them in sync
+ if (this.failedJobs.length !== count) {
+ this.$apollo.queries.failedJobs.refetch();
+ }
+ },
},
mounted() {
if (!this.isActive && !this.isPipelineActive) {
@@ -129,7 +143,6 @@ export default {
{ text: JOB_NAME_HEADER, class: 'col-6' },
{ text: STAGE_HEADER, class: 'col-2' },
{ text: JOB_ID_HEADER, class: 'col-2' },
- { text: JOB_ACTION_HEADER, class: 'col-2' },
],
i18n: {
fetchError: __('There was a problem fetching failed jobs'),
@@ -141,10 +154,10 @@ export default {
<template>
<div>
- <gl-loading-icon v-if="isInitialLoading" />
- <div v-else-if="!hasFailedJobs">{{ $options.i18n.noFailedJobs }}</div>
+ <gl-loading-icon v-if="isInitialLoading" class="gl-p-4" />
+ <div v-else-if="!hasFailedJobs" class="gl-p-4">{{ $options.i18n.noFailedJobs }}</div>
<div v-else class="container-fluid gl-grid-tpl-rows-auto">
- <div class="row gl-mb-6 gl-text-gray-900">
+ <div class="row gl-my-4 gl-text-gray-900">
<div
v-for="col in $options.columns"
:key="col.text"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue
index 5e49c05f47d..60c429459bf 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue
@@ -1,12 +1,12 @@
<script>
-import { GlButton, GlCollapse, GlIcon, GlLink, GlPopover, GlSprintf } from '@gitlab/ui';
+import { GlButton, GlCard, GlIcon, GlLink, GlPopover, GlSprintf } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
import FailedJobsList from './failed_jobs_list.vue';
export default {
components: {
GlButton,
- GlCollapse,
+ GlCard,
GlIcon,
GlLink,
GlPopover,
@@ -31,6 +31,10 @@ export default {
required: true,
type: String,
},
+ projectPath: {
+ required: true,
+ type: String,
+ },
},
data() {
return {
@@ -44,7 +48,7 @@ export default {
return this.isExpanded ? '' : 'gl-display-none';
},
failedJobsCountText() {
- return sprintf(this.$options.i18n.showFailedJobs, { count: this.currentFailedJobsCount });
+ return sprintf(this.$options.i18n.failedJobsLabel, { count: this.currentFailedJobsCount });
},
iconName() {
return this.isExpanded ? 'chevron-down' : 'chevron-right';
@@ -71,37 +75,47 @@ export default {
'Pipelines|You will see a maximum of 100 jobs in this list. To view all failed jobs, %{linkStart}go to the details page%{linkEnd} of this pipeline.',
),
additionalInfoTitle: __('Limitation on this view'),
- showFailedJobs: __('Show failed jobs (%{count})'),
+ failedJobsLabel: __('Failed jobs (%{count})'),
},
};
</script>
<template>
- <div class="gl-border-none!">
- <gl-button variant="link" @click="toggleWidget">
- <gl-icon :name="iconName" />
- {{ failedJobsCountText }}
- <gl-icon :id="popoverId" name="information-o" />
- <gl-popover :target="popoverId" placement="top">
- <template #title> {{ $options.i18n.additionalInfoTitle }} </template>
- <slot>
- <gl-sprintf :message="$options.i18n.additionalInfoPopover">
- <template #link="{ content }">
- <gl-link class="gl-font-sm" :href="pipelinePath"> {{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </slot>
- </gl-popover>
- </gl-button>
- <gl-collapse
- v-model="isExpanded"
- class="gl-bg-gray-10 gl-border-1 gl-border-t gl-border-color-gray-100 gl-mt-4 gl-pt-3"
- >
- <failed-jobs-list
- v-if="isExpanded"
- :is-pipeline-active="isPipelineActive"
- :pipeline-iid="pipelineIid"
- @failed-jobs-count="setFailedJobsCount"
- />
- </gl-collapse>
- </div>
+ <gl-card
+ class="gl-new-card"
+ :class="{ 'gl-border-white gl-hover-border-gray-100': !isExpanded }"
+ header-class="gl-new-card-header gl-px-3 gl-py-3"
+ body-class="gl-new-card-body"
+ data-testid="failed-jobs-card"
+ :aria-expanded="isExpanded.toString()"
+ >
+ <template #header>
+ <gl-button
+ variant="link"
+ class="gl-text-gray-700! gl-font-weight-semibold"
+ @click="toggleWidget"
+ >
+ <gl-icon :name="iconName" />
+ {{ failedJobsCountText }}
+ <gl-icon :id="popoverId" name="information-o" class="gl-ml-2" />
+ <gl-popover :target="popoverId" placement="top">
+ <template #title> {{ $options.i18n.additionalInfoTitle }} </template>
+ <slot>
+ <gl-sprintf :message="$options.i18n.additionalInfoPopover">
+ <template #link="{ content }">
+ <gl-link class="gl-font-sm" :href="pipelinePath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </slot>
+ </gl-popover>
+ </gl-button>
+ </template>
+ <failed-jobs-list
+ v-if="isExpanded"
+ :failed-jobs-count="failedJobsCount"
+ :is-pipeline-active="isPipelineActive"
+ :pipeline-iid="pipelineIid"
+ :project-path="projectPath"
+ @failed-jobs-count="setFailedJobsCount"
+ />
+ </gl-card>
</template>
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 73a255f392b..747d94d92f2 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
@@ -16,6 +16,9 @@ import { TRACKING_CATEGORIES } from '../../constants';
export const i18n = {
downloadArtifacts: __('Download artifacts'),
artifactsFetchErrorMessage: s__('Pipelines|Could not load artifacts.'),
+ artifactsFetchWarningMessage: s__(
+ 'Pipelines|Failed to update. Please reload page to update the list of artifacts.',
+ ),
emptyArtifactsMessage: __('No artifacts found'),
};
@@ -52,6 +55,7 @@ export default {
hasError: false,
isLoading: false,
searchQuery: '',
+ isNewPipeline: false,
};
},
computed: {
@@ -64,13 +68,24 @@ export default {
: this.artifacts;
},
},
+ watch: {
+ pipelineId() {
+ this.isNewPipeline = true;
+ },
+ },
methods: {
fetchArtifacts() {
// refactor tracking based on action once this dropdown supports
// actions other than artifacts
this.track('click_artifacts_dropdown', { label: TRACKING_CATEGORIES.table });
+ // Preserve the last good list and present it if a request fails
+ const oldArtifacts = [...this.artifacts];
+ this.artifacts = [];
+
+ this.hasError = false;
this.isLoading = true;
+
// Replace the placeholder with the ID of the pipeline we are viewing
const endpoint = this.artifactsEndpoint.replace(
this.artifactsEndpointPlaceholder,
@@ -80,9 +95,13 @@ export default {
.get(endpoint)
.then(({ data }) => {
this.artifacts = data.artifacts;
+ this.isNewPipeline = false;
})
.catch(() => {
this.hasError = true;
+ if (!this.isNewPipeline) {
+ this.artifacts = oldArtifacts;
+ }
})
.finally(() => {
this.isLoading = false;
@@ -108,10 +127,10 @@ export default {
right
lazy
text-sr-only
- @show.once="fetchArtifacts"
+ @show="fetchArtifacts"
@shown="handleDropdownShown"
>
- <gl-alert v-if="hasError" variant="danger" :dismissible="false">
+ <gl-alert v-if="hasError && !hasArtifacts" variant="danger" :dismissible="false">
{{ $options.i18n.artifactsFetchErrorMessage }}
</gl-alert>
@@ -136,5 +155,18 @@ export default {
>
{{ artifact.name }}
</gl-dropdown-item>
+
+ <template #footer>
+ <gl-dropdown-item
+ v-if="hasError && hasArtifacts"
+ class="gl-list-style-none"
+ disabled
+ data-testid="artifacts-fetch-warning"
+ >
+ <span class="gl-font-sm">
+ {{ $options.i18n.artifactsFetchWarningMessage }}
+ </span>
+ </gl-dropdown-item>
+ </template>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
index 7d41700c492..574d291a767 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlEmptyState, GlIcon, GlLoadingIcon, GlCollapsibleListbox } from '@gitlab/ui';
import { isEqual } from 'lodash';
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 dbb0b443235..c03085e6419 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -1,10 +1,11 @@
<script>
import { GlTableLite, GlTooltipDirective } from '@gitlab/ui';
+import { cleanLeadingSeparator } from '~/lib/utils/url_utility';
import { s__, __ } from '~/locale';
import Tracking from '~/tracking';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils';
-import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
+import LegacyPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph.vue';
import PipelineFailedJobsWidget from '~/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue';
import eventHub from '../../event_hub';
import { TRACKING_CATEGORIES } from '../../constants';
@@ -21,8 +22,8 @@ const DEFAULT_TH_CLASSES =
export default {
components: {
GlTableLite,
+ LegacyPipelineMiniGraph,
PipelineFailedJobsWidget,
- PipelineMiniGraph,
PipelineOperations,
PipelinesStatusBadge,
PipelineStopModal,
@@ -146,6 +147,9 @@ export default {
const downstream = pipeline.triggered;
return keepLatestDownstreamPipelines(downstream);
},
+ getProjectPath(item) {
+ return cleanLeadingSeparator(item.project.full_path);
+ },
failedJobsCount(pipeline) {
return pipeline?.failed_builds?.length || 0;
},
@@ -204,7 +208,7 @@ export default {
</template>
<template #cell(stages)="{ item }">
- <pipeline-mini-graph
+ <legacy-pipeline-mini-graph
:downstream-pipelines="getDownstreamPipelines(item)"
:pipeline-path="item.path"
:stages="item.details.stages"
@@ -225,6 +229,8 @@ export default {
:is-pipeline-active="item.active"
:pipeline-iid="item.iid"
:pipeline-path="item.path"
+ :project-path="getProjectPath(item)"
+ class="gl-ml-n4 gl-mt-n3 gl-mb-n1"
/>
</template>
</gl-table-lite>
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
index 3f2c013d44a..a7737d33285 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
@@ -1,5 +1,6 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import EmptyState from './empty_state.vue';
import TestSuiteTable from './test_suite_table.vue';
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
index 19318cb0c8b..d8af926a796 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
@@ -10,6 +10,7 @@ import {
GlEmptyState,
GlSprintf,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters, mapActions } from 'vuex';
import { __, s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
index 2b7b2d78424..9141947ea04 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
@@ -1,5 +1,6 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import { s__ } from '~/locale';
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index a6dd835bb15..93ca3738ff0 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -13,6 +13,8 @@ export const ICONS = {
TAG: 'tag',
MR: 'git-merge',
BRANCH: 'branch',
+ RETRY: 'retry',
+ SUCCESS: 'success',
};
export const TestStatus = {
@@ -109,6 +111,8 @@ export const TRACKING_CATEGORIES = {
table: 'pipelines_table_component',
tabs: 'pipelines_filter_tabs',
search: 'pipelines_filtered_search',
+ failed: 'pipeline_failed_jobs_tab',
+ tests: 'pipeline_tests_tab',
};
// Pipeline Mini Graph
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql
index 3d69c5e451b..6b553866f63 100644
--- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql
@@ -4,13 +4,14 @@ query getPipelineFailedJobs($fullPath: ID!, $pipelineIid: ID!) {
pipeline(iid: $pipelineIid) {
id
active
- jobs(statuses: [FAILED], retried: false, jobKind: BUILD) {
+ jobs(statuses: [FAILED], retried: false) {
count
nodes {
id
allowFailure
detailedStatus {
id
+ detailsPath
group
icon
action {
@@ -19,6 +20,7 @@ query getPipelineFailedJobs($fullPath: ID!, $pipelineIid: ID!) {
icon
}
}
+ kind
name
retried
retryable
@@ -33,7 +35,6 @@ query getPipelineFailedJobs($fullPath: ID!, $pipelineIid: ID!) {
readBuild
updateBuild
}
- webPath
}
}
}
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_stage.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_stage.query.graphql
new file mode 100644
index 00000000000..64a5964dbeb
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_stage.query.graphql
@@ -0,0 +1,32 @@
+query getPipelineStage($id: CiStageID!) {
+ ciPipelineStage(id: $id) {
+ id
+ name
+ detailedStatus {
+ id
+ group
+ icon
+ }
+ jobs {
+ nodes {
+ id
+ detailedStatus {
+ id
+ action {
+ id
+ icon
+ path
+ title
+ }
+ detailsPath
+ hasDetails
+ group
+ icon
+ tooltip
+ }
+ name
+ }
+ }
+ status
+ }
+}
diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js
index 33bdedee764..00a1810926c 100644
--- a/app/assets/javascripts/pipelines/pipeline_tabs.js
+++ b/app/assets/javascripts/pipelines/pipeline_tabs.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import VueRouter from 'vue-router';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import VueApollo from 'vue-apollo';
import { GlToast } from '@gitlab/ui';
diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js
index b8276327843..38be5becfb8 100644
--- a/app/assets/javascripts/pipelines/utils.js
+++ b/app/assets/javascripts/pipelines/utils.js
@@ -1,7 +1,12 @@
import * as Sentry from '@sentry/browser';
import { pickBy } from 'lodash';
import { parseUrlPathname } from '~/lib/utils/url_utility';
-import { NEEDS_PROPERTY, SUPPORTED_FILTER_PARAMETERS, validPipelineTabNames } from './constants';
+import {
+ NEEDS_PROPERTY,
+ SUPPORTED_FILTER_PARAMETERS,
+ validPipelineTabNames,
+ pipelineTabName,
+} from './constants';
/*
The following functions are the main engine in transforming the data as
received from the endpoint into the format the d3 graph expects.
@@ -144,9 +149,8 @@ export const getPipelineDefaultTab = (url) => {
const regexp = /\w*$/;
const [tabName] = strippedUrl.match(regexp);
- if (tabName && validPipelineTabNames.includes(tabName)) {
- return tabName;
- }
+ if (tabName && validPipelineTabNames.includes(tabName)) return tabName;
+ if (tabName === '') return pipelineTabName;
return null;
};
diff --git a/app/assets/javascripts/popovers/components/popovers.vue b/app/assets/javascripts/popovers/components/popovers.vue
index 7ec54231e65..f91a9e1e33a 100644
--- a/app/assets/javascripts/popovers/components/popovers.vue
+++ b/app/assets/javascripts/popovers/components/popovers.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlPopover } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
diff --git a/app/assets/javascripts/profile/add_ssh_key_validation.js b/app/assets/javascripts/profile/add_ssh_key_validation.js
index 628dd159db8..8e395a1c6e9 100644
--- a/app/assets/javascripts/profile/add_ssh_key_validation.js
+++ b/app/assets/javascripts/profile/add_ssh_key_validation.js
@@ -5,6 +5,7 @@ export default class AddSshKeyValidation {
warningElement,
originalSubmitElement,
confirmSubmitElement,
+ cancelButtonElement,
) {
this.inputElement = inputElement;
this.form = inputElement.form;
@@ -16,6 +17,7 @@ export default class AddSshKeyValidation {
this.originalSubmitElement = originalSubmitElement;
this.confirmSubmitElement = confirmSubmitElement;
+ this.cancelButtonElement = cancelButtonElement;
this.isValid = false;
}
@@ -44,6 +46,7 @@ export default class AddSshKeyValidation {
toggleWarning(isVisible) {
this.warningElement.classList.toggle('hide', !isVisible);
this.originalSubmitElement.classList.toggle('hide', isVisible);
+ this.cancelButtonElement?.classList.toggle('hide', isVisible);
}
isPublicKey(value) {
diff --git a/app/assets/javascripts/profile/components/follow.vue b/app/assets/javascripts/profile/components/follow.vue
index 2673ab6fbf4..d57f884e345 100644
--- a/app/assets/javascripts/profile/components/follow.vue
+++ b/app/assets/javascripts/profile/components/follow.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import {
GlAvatarLabeled,
diff --git a/app/assets/javascripts/profile/edit/components/profile_edit_app.vue b/app/assets/javascripts/profile/edit/components/profile_edit_app.vue
index ab29d94c41c..6b39f137880 100644
--- a/app/assets/javascripts/profile/edit/components/profile_edit_app.vue
+++ b/app/assets/javascripts/profile/edit/components/profile_edit_app.vue
@@ -1,10 +1,182 @@
<script>
-export default {};
+import { nextTick } from 'vue';
+import { GlForm, GlButton } from '@gitlab/ui';
+import { VARIANT_DANGER, VARIANT_INFO, createAlert } from '~/alert';
+import axios from '~/lib/utils/axios_utils';
+import { readFileAsDataURL } from '~/lib/utils/file_utility';
+import SetStatusForm from '~/set_status_modal/set_status_form.vue';
+import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue';
+import { isUserBusy, computedClearStatusAfterValue } from '~/set_status_modal/utils';
+import { AVAILABILITY_STATUS } from '~/set_status_modal/constants';
+
+import { i18n, statusI18n } from '../constants';
+import UserAvatar from './user_avatar.vue';
+
+export default {
+ components: {
+ UserAvatar,
+ GlForm,
+ GlButton,
+ SettingsBlock,
+ SetStatusForm,
+ },
+ inject: [
+ 'currentEmoji',
+ 'currentMessage',
+ 'currentAvailability',
+ 'defaultEmoji',
+ 'currentClearStatusAfter',
+ ],
+ props: {
+ profilePath: {
+ type: String,
+ required: true,
+ },
+ userPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ uploadingProfile: false,
+ avatarBlob: null,
+ status: {
+ emoji: this.currentEmoji,
+ message: this.currentMessage,
+ availability: isUserBusy(this.currentAvailability),
+ clearStatusAfter: null,
+ },
+ };
+ },
+ computed: {
+ shouldIncludeClearStatusAfterInApiRequest() {
+ return this.status.clearStatusAfter !== null;
+ },
+ clearStatusAfterApiRequestValue() {
+ return computedClearStatusAfterValue(this.status.clearStatusAfter);
+ },
+ },
+ methods: {
+ async onSubmit() {
+ // TODO: Do validation before organizing data.
+ this.uploadingProfile = true;
+ const formData = new FormData();
+
+ // Setting up status data
+ const statusFieldNameBase = 'user[status]';
+ formData.append(`${statusFieldNameBase}[emoji]`, this.status.emoji);
+ formData.append(`${statusFieldNameBase}[message]`, this.status.message);
+ formData.append(
+ `${statusFieldNameBase}[availability]`,
+ this.status.availability ? AVAILABILITY_STATUS.BUSY : AVAILABILITY_STATUS.NOT_SET,
+ );
+
+ if (this.shouldIncludeClearStatusAfterInApiRequest) {
+ formData.append(
+ `${statusFieldNameBase}[clear_status_after]`,
+ this.clearStatusAfterApiRequestValue,
+ );
+ }
+
+ if (this.avatarBlob) {
+ formData.append('user[avatar]', this.avatarBlob, 'avatar.png');
+ }
+
+ try {
+ const { data } = await axios.put(this.profilePath, formData);
+
+ if (this.avatarBlob) {
+ this.syncHeaderAvatars();
+ }
+ createAlert({
+ message: data.message,
+ variant: data.status === 'error' ? VARIANT_DANGER : VARIANT_INFO,
+ });
+
+ nextTick(() => {
+ window.scrollTo(0, 0);
+ this.uploadingProfile = false;
+ });
+ } catch (e) {
+ createAlert({
+ message: e.message,
+ variant: VARIANT_DANGER,
+ });
+ this.updateProfileSettings = false;
+ }
+ },
+ async syncHeaderAvatars() {
+ const dataURL = await readFileAsDataURL(this.avatarBlob);
+
+ // TODO: implement sync for super sidebar
+ ['.header-user-avatar', '.js-sidebar-user-avatar'].forEach((selector) => {
+ const node = document.querySelector(selector);
+ if (!node) return;
+
+ node.setAttribute('src', dataURL);
+ node.setAttribute('srcset', dataURL);
+ });
+ },
+ onBlobChange(blob) {
+ this.avatarBlob = blob;
+ },
+ onMessageInput(value) {
+ this.status.message = value;
+ },
+ onEmojiClick(emoji) {
+ this.status.emoji = emoji;
+ },
+ onClearStatusAfterClick(after) {
+ this.status.clearStatusAfter = after;
+ },
+ onAvailabilityInput(value) {
+ this.status.availability = value;
+ },
+ },
+ i18n: {
+ ...i18n,
+ ...statusI18n,
+ },
+};
</script>
<template>
- <!-- This is left empty intensionally -->
- <!-- It will be implemented in the upcoming MRs -->
- <!-- Related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/389918 -->
- <div></div>
+ <gl-form class="edit-user" @submit.prevent="onSubmit">
+ <user-avatar @blob-change="onBlobChange" />
+ <settings-block class="js-search-settings-section">
+ <template #title>{{ $options.i18n.setStatusTitle }}</template>
+ <template #description>{{ $options.i18n.setStatusDescription }}</template>
+ <div class="gl-max-w-80">
+ <set-status-form
+ :default-emoji="defaultEmoji"
+ :emoji="status.emoji"
+ :message="status.message"
+ :availability="status.availability"
+ :clear-status-after="status.clearStatusAfter"
+ :current-clear-status-after="currentClearStatusAfter"
+ @message-input="onMessageInput"
+ @emoji-click="onEmojiClick"
+ @clear-status-after-click="onClearStatusAfterClick"
+ @availability-input="onAvailabilityInput"
+ />
+ </div>
+ </settings-block>
+ <!-- TODO: to implement profile editing form fields -->
+ <!-- It will be implemented in the upcoming MRs -->
+ <!-- Related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/389918 -->
+ <div class="js-hide-when-nothing-matches-search gl-border-t gl-py-6">
+ <gl-button
+ variant="confirm"
+ type="submit"
+ class="gl-mr-3 js-password-prompt-btn"
+ :disabled="uploadingProfile"
+ >
+ {{ $options.i18n.updateProfileSettings }}
+ </gl-button>
+ <gl-button :href="userPath" data-testid="cancel-edit-button">
+ {{ $options.i18n.cancel }}
+ </gl-button>
+ </div>
+ </gl-form>
</template>
diff --git a/app/assets/javascripts/profile/edit/components/user_avatar.vue b/app/assets/javascripts/profile/edit/components/user_avatar.vue
new file mode 100644
index 00000000000..f0ff972336b
--- /dev/null
+++ b/app/assets/javascripts/profile/edit/components/user_avatar.vue
@@ -0,0 +1,174 @@
+<script>
+import $ from 'jquery';
+import { GlAvatar, GlAvatarLink, GlButton, GlLink, GlSprintf } from '@gitlab/ui';
+import { loadCSSFile } from '~/lib/utils/css_utils';
+import SafeHtmlDirective from '~/vue_shared/directives/safe_html';
+
+import { avatarI18n } from '../constants';
+
+export default {
+ name: 'EditProfileUserAvatar',
+ components: {
+ GlAvatar,
+ GlAvatarLink,
+ GlButton,
+ GlLink,
+ GlSprintf,
+ },
+ directives: {
+ SafeHtml: SafeHtmlDirective,
+ },
+ inject: [
+ 'avatarUrl',
+ 'brandProfileImageGuidelines',
+ 'cropperCssPath',
+ 'hasAvatar',
+ 'gravatarEnabled',
+ 'gravatarLink',
+ 'profileAvatarPath',
+ ],
+ computed: {
+ avatarHelpText() {
+ const { changeOrRemoveAvatar, changeAvatar, uploadOrChangeAvatar, uploadAvatar } = avatarI18n;
+ if (this.hasAvatar) {
+ return this.gravatarEnabled ? changeOrRemoveAvatar : changeAvatar;
+ }
+ return this.gravatarEnabled ? uploadOrChangeAvatar : uploadAvatar;
+ },
+ },
+
+ mounted() {
+ this.initializeCropper();
+ loadCSSFile(this.cropperCssPath);
+ },
+
+ methods: {
+ initializeCropper() {
+ const cropOpts = {
+ filename: '.js-avatar-filename',
+ previewImage: '.avatar-image .gl-avatar',
+ modalCrop: '.modal-profile-crop',
+ pickImageEl: '.js-choose-user-avatar-button',
+ uploadImageBtn: '.js-upload-user-avatar',
+ modalCropImg: '.modal-profile-crop-image',
+ onBlobChange: this.onBlobChange,
+ };
+ // This has to be used with jQuery, considering migrate that from jQuery to Vue in the future.
+ $('.js-user-avatar-input').glCrop(cropOpts).data('glcrop');
+ },
+ onBlobChange(blob) {
+ this.$emit('blob-change', blob);
+ },
+ },
+ i18n: avatarI18n,
+};
+</script>
+
+<template>
+ <div class="js-search-settings-section gl-pb-6">
+ <div class="profile-settings-sidebar">
+ <h4 class="gl-my-0">
+ {{ $options.i18n.publicAvatar }}
+ </h4>
+ <p class="gl-text-secondary">
+ <gl-sprintf :message="avatarHelpText">
+ <template #gravatar_link>
+ <gl-link :href="gravatarLink.url" target="__blank">
+ {{ gravatarLink.hostname }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ <div
+ v-if="brandProfileImageGuidelines"
+ v-safe-html="brandProfileImageGuidelines"
+ class="md gl-mb-5"
+ data-testid="brand-profile-image-guidelines"
+ ></div>
+ </div>
+ <div class="gl-display-flex">
+ <div class="avatar-image">
+ <gl-avatar-link :href="avatarUrl" target="blank">
+ <gl-avatar class="gl-mr-5" :src="avatarUrl" :size="96" shape="circle" />
+ </gl-avatar-link>
+ </div>
+ <div class="gl-flex-grow-1">
+ <h5 class="gl-mt-0">
+ {{ $options.i18n.uploadNewAvatar }}
+ </h5>
+ <div class="gl-display-flex gl-align-items-center gl-my-3">
+ <gl-button class="js-choose-user-avatar-button">
+ {{ $options.i18n.chooseFile }}
+ </gl-button>
+ <span class="gl-ml-3 js-avatar-filename">{{ $options.i18n.noFileChosen }}</span>
+ <input
+ id="user_avatar"
+ class="js-user-avatar-input hidden"
+ accept="image/*"
+ type="file"
+ name="user[avatar]"
+ />
+ </div>
+ <p class="gl-mb-0 gl-text-gray-500">{{ $options.i18n.maximumFileSize }}</p>
+ <gl-button
+ v-if="hasAvatar"
+ class="gl-mt-3"
+ category="secondary"
+ variant="danger"
+ data-method="delete"
+ rel="nofollow"
+ data-testid="remove-avatar-button"
+ :data-confirm="$options.i18n.removeAvatarConfirmation"
+ :href="profileAvatarPath"
+ >
+ {{ $options.i18n.removeAvatar }}
+ </gl-button>
+ </div>
+ </div>
+ <!-- For bs.modal to take over -->
+ <div class="modal modal-profile-crop" :data-cropper-css-path="cropperCssPath">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h4 class="modal-title">
+ {{ $options.i18n.cropAvatarTitle }}
+ </h4>
+ <gl-button
+ category="tertiary"
+ icon="close"
+ class="close"
+ data-dismiss="modal"
+ :aria-label="__('Close')"
+ />
+ </div>
+ <div class="modal-body">
+ <div class="profile-crop-image-container">
+ <img :alt="$options.i18n.cropAvatarImageAltText" class="modal-profile-crop-image" />
+ </div>
+ <div class="gl-text-center gl-mt-4">
+ <div class="btn-group">
+ <gl-button
+ :aria-label="__('Zoom out')"
+ icon="search-minus"
+ data-method="zoom"
+ data-option="-0.1"
+ />
+ <gl-button
+ :aria-label="__('Zoom in')"
+ icon="search-plus"
+ data-method="zoom"
+ data-option="0.1"
+ />
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <gl-button class="js-upload-user-avatar" variant="confirm">{{
+ $options.i18n.cropAvatarSetAsNewAvatar
+ }}</gl-button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/profile/edit/constants.js b/app/assets/javascripts/profile/edit/constants.js
new file mode 100644
index 00000000000..e07615273f7
--- /dev/null
+++ b/app/assets/javascripts/profile/edit/constants.js
@@ -0,0 +1,34 @@
+import { s__, __ } from '~/locale';
+
+export const avatarI18n = {
+ publicAvatar: s__('Profiles|Public avatar'),
+ changeOrRemoveAvatar: s__(
+ 'Profiles|You can change your avatar here or remove the current avatar to revert to %{gravatar_link}',
+ ),
+ changeAvatar: s__('Profiles|You can change your avatar here'),
+ uploadOrChangeAvatar: s__(
+ 'Profiles|You can upload your avatar here or change it at %{gravatar_link}',
+ ),
+ uploadAvatar: s__('Profiles|You can upload your avatar here'),
+ uploadNewAvatar: s__('Profiles|Upload new avatar'),
+ chooseFile: s__('Profiles|Choose file...'),
+ noFileChosen: s__('Profiles|No file chosen.'),
+ maximumFileSize: s__('Profiles|The maximum file size allowed is 200KB.'),
+ removeAvatar: s__('Profiles|Remove avatar'),
+ removeAvatarConfirmation: s__('Profiles|Avatar will be removed. Are you sure?'),
+ cropAvatarTitle: s__('Profiles|Position and size your new avatar'),
+ cropAvatarImageAltText: s__('Profiles|Avatar cropper'),
+ cropAvatarSetAsNewAvatar: s__('Profiles|Set new profile picture'),
+};
+
+export const statusI18n = {
+ setStatusTitle: s__('Profiles|Current status'),
+ setStatusDescription: s__(
+ 'Profiles|This emoji and message will appear on your profile and throughout the interface.',
+ ),
+};
+
+export const i18n = {
+ updateProfileSettings: s__('Profiles|Update profile settings'),
+ cancel: __('Cancel'),
+};
diff --git a/app/assets/javascripts/profile/edit/index.js b/app/assets/javascripts/profile/edit/index.js
index b46a395d6f5..27b410c3a12 100644
--- a/app/assets/javascripts/profile/edit/index.js
+++ b/app/assets/javascripts/profile/edit/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
import ProfileEditApp from './components/profile_edit_app.vue';
export const initProfileEdit = () => {
@@ -6,11 +7,38 @@ export const initProfileEdit = () => {
if (!mountEl) return false;
+ const {
+ profilePath,
+ userPath,
+ currentEmoji,
+ currentMessage,
+ currentAvailability,
+ defaultEmoji,
+ currentClearStatusAfter,
+ ...provides
+ } = mountEl.dataset;
+
return new Vue({
el: mountEl,
name: 'ProfileEditRoot',
+ provide: {
+ ...provides,
+ currentEmoji,
+ currentMessage,
+ currentAvailability,
+ defaultEmoji,
+ currentClearStatusAfter,
+ hasAvatar: parseBoolean(provides.hasAvatar),
+ gravatarEnabled: parseBoolean(provides.gravatarEnabled),
+ gravatarLink: JSON.parse(provides.gravatarLink),
+ },
render(createElement) {
- return createElement(ProfileEditApp);
+ return createElement(ProfileEditApp, {
+ props: {
+ profilePath,
+ userPath,
+ },
+ });
},
});
};
diff --git a/app/assets/javascripts/profile/gl_crop.js b/app/assets/javascripts/profile/gl_crop.js
index 107bfd159dd..ea1a5199ece 100644
--- a/app/assets/javascripts/profile/gl_crop.js
+++ b/app/assets/javascripts/profile/gl_crop.js
@@ -25,6 +25,7 @@ import { loadCSSFile } from '../lib/utils/css_utils';
exportHeight = 200,
cropBoxWidth = 200,
cropBoxHeight = 200,
+ onBlobChange = () => {},
} = {},
) {
this.onUploadImageBtnClick = this.onUploadImageBtnClick.bind(this);
@@ -54,6 +55,7 @@ import { loadCSSFile } from '../lib/utils/css_utils';
this.uploadImageBtn = isString(uploadImageBtn) ? $(uploadImageBtn) : uploadImageBtn;
this.modalCropImg = isString(modalCropImg) ? $(modalCropImg) : modalCropImg;
this.cropActionsBtn = this.modalCrop.find('[data-method]');
+ this.onBlobChange = onBlobChange;
this.bindEvents();
}
@@ -75,6 +77,7 @@ import { loadCSSFile } from '../lib/utils/css_utils';
const btn = this;
return _this.onActionBtnClick(btn);
});
+ this.onBlobChange(null);
return (this.croppedImageBlob = null);
}
@@ -187,7 +190,10 @@ import { loadCSSFile } from '../lib/utils/css_utils';
height: 200,
})
.toDataURL('image/png');
- return (this.croppedImageBlob = this.dataURLtoBlob(this.dataURL));
+
+ this.croppedImageBlob = this.dataURLtoBlob(this.dataURL);
+ this.onBlobChange(this.croppedImageBlob);
+ return this.croppedImageBlob;
}
getBlob() {
diff --git a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue
index 77e809e88ce..c44d97c9bf8 100644
--- a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue
+++ b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue
@@ -1,5 +1,6 @@
<script>
import { GlCollapsibleListbox } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import { debounce, uniqBy } from 'lodash';
import {
diff --git a/app/assets/javascripts/projects/commit/components/form_modal.vue b/app/assets/javascripts/projects/commit/components/form_modal.vue
index 28bbf67c090..44b8ccb57ca 100644
--- a/app/assets/javascripts/projects/commit/components/form_modal.vue
+++ b/app/assets/javascripts/projects/commit/components/form_modal.vue
@@ -1,5 +1,6 @@
<script>
import { GlModal, GlForm, GlFormCheckbox, GlSprintf, GlFormGroup } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import api from '~/api';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
diff --git a/app/assets/javascripts/projects/commit/components/projects_dropdown.vue b/app/assets/javascripts/projects/commit/components/projects_dropdown.vue
index fe54b62e2c8..e2b004e0892 100644
--- a/app/assets/javascripts/projects/commit/components/projects_dropdown.vue
+++ b/app/assets/javascripts/projects/commit/components/projects_dropdown.vue
@@ -1,5 +1,6 @@
<script>
import { GlCollapsibleListbox } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters, mapState } from 'vuex';
import { debounce, uniqBy } from 'lodash';
import {
diff --git a/app/assets/javascripts/projects/commit/store/index.js b/app/assets/javascripts/projects/commit/store/index.js
index 83802f6a36f..450b40091dd 100644
--- a/app/assets/javascripts/projects/commit/store/index.js
+++ b/app/assets/javascripts/projects/commit/store/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
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 84e7edb48c1..6ff9bd7390f 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
@@ -7,7 +7,7 @@ import {
toggleQueryPollingByVisibility,
} from '~/pipelines/components/graph/utils';
import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils';
-import GraphqlPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue';
+import LegacyPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph.vue';
import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql';
@@ -23,7 +23,7 @@ export default {
},
components: {
GlLoadingIcon,
- GraphqlPipelineMiniGraph,
+ LegacyPipelineMiniGraph,
PipelineMiniGraph,
},
mixins: [glFeatureFlagsMixin()],
@@ -139,14 +139,14 @@ export default {
<div>
<gl-loading-icon v-if="$apollo.queries.pipeline.loading" />
<template v-else>
- <graphql-pipeline-mini-graph
+ <pipeline-mini-graph
v-if="isUsingPipelineMiniGraphQueries"
data-testid="commit-box-pipeline-mini-graph"
:pipeline-etag="graphqlResourceEtag"
:full-path="fullPath"
:iid="iid"
/>
- <pipeline-mini-graph
+ <legacy-pipeline-mini-graph
v-else
data-testid="commit-box-pipeline-mini-graph"
:downstream-pipelines="downstreamPipelines"
diff --git a/app/assets/javascripts/projects/commits/components/author_select.vue b/app/assets/javascripts/projects/commits/components/author_select.vue
index cf251bc7465..8bc7a27bcad 100644
--- a/app/assets/javascripts/projects/commits/components/author_select.vue
+++ b/app/assets/javascripts/projects/commits/components/author_select.vue
@@ -1,6 +1,7 @@
<script>
import { GlAvatar, GlCollapsibleListbox, GlTooltipDirective } from '@gitlab/ui';
import { debounce } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import { queryToObject, visitUrl } from '~/lib/utils/url_utility';
import { n__, __ } from '~/locale';
diff --git a/app/assets/javascripts/projects/commits/index.js b/app/assets/javascripts/projects/commits/index.js
index d37c1800718..ff7ad67c0a5 100644
--- a/app/assets/javascripts/projects/commits/index.js
+++ b/app/assets/javascripts/projects/commits/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import { visitUrl } from '~/lib/utils/url_utility';
import RefSelector from '~/ref/components/ref_selector.vue';
diff --git a/app/assets/javascripts/projects/commits/store/index.js b/app/assets/javascripts/projects/commits/store/index.js
index e864ef5716e..4fb1bc093c7 100644
--- a/app/assets/javascripts/projects/commits/store/index.js
+++ b/app/assets/javascripts/projects/commits/store/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import actions from './actions';
import mutations from './mutations';
diff --git a/app/assets/javascripts/projects/components/shared/delete_button.vue b/app/assets/javascripts/projects/components/shared/delete_button.vue
index 06c0230c8e0..c749034d2a8 100644
--- a/app/assets/javascripts/projects/components/shared/delete_button.vue
+++ b/app/assets/javascripts/projects/components/shared/delete_button.vue
@@ -1,19 +1,14 @@
<script>
-import { GlModal, GlModalDirective, GlFormInput, GlButton, GlAlert, GlSprintf } from '@gitlab/ui';
-import { uniqueId } from 'lodash';
+import { GlButton, GlForm } from '@gitlab/ui';
import csrf from '~/lib/utils/csrf';
import { __ } from '~/locale';
+import DeleteModal from './delete_modal.vue';
export default {
components: {
- GlAlert,
- GlModal,
- GlFormInput,
GlButton,
- GlSprintf,
- },
- directives: {
- GlModal: GlModalDirective,
+ GlForm,
+ DeleteModal,
},
props: {
confirmPhrase: {
@@ -47,139 +42,54 @@ export default {
},
data() {
return {
- userInput: null,
- modalId: uniqueId('delete-project-modal-'),
+ isModalVisible: false,
};
},
computed: {
- confirmDisabled() {
- return this.userInput !== this.confirmPhrase;
- },
csrfToken() {
return csrf.token;
},
- modalActionProps() {
- return {
- primary: {
- text: __('Yes, delete project'),
- attributes: {
- variant: 'danger',
- disabled: this.confirmDisabled,
- 'data-qa-selector': 'confirm_delete_button',
- },
- },
- cancel: {
- text: __('Cancel, keep project'),
- },
- };
- },
},
methods: {
submitForm() {
- this.$refs.form.submit();
+ this.$refs.form.$el.submit();
+ },
+ onButtonClick() {
+ this.isModalVisible = true;
},
},
- strings: {
+ i18n: {
deleteProject: __('Delete project'),
- title: __('Are you absolutely sure?'),
- confirmText: __('Enter the following to confirm:'),
- isForkAlertTitle: __('You are about to delete this forked project containing:'),
- isNotForkAlertTitle: __('You are about to delete this project containing:'),
- isForkAlertBody: __('This process deletes the project repository and all related resources.'),
- isNotForkAlertBody: __(
- 'This project is %{strongStart}NOT%{strongEnd} a fork. This process deletes the project repository and all related resources.',
- ),
- isNotForkMessage: __(
- 'This project is %{strongStart}NOT%{strongEnd} a fork, and has the following:',
- ),
},
};
</script>
<template>
- <form ref="form" :action="formPath" method="post">
+ <gl-form ref="form" :action="formPath" method="post">
<input type="hidden" name="_method" value="delete" />
<input :value="csrfToken" type="hidden" name="authenticity_token" />
+ <delete-modal
+ v-model="isModalVisible"
+ :confirm-phrase="confirmPhrase"
+ :is-fork="isFork"
+ :issues-count="issuesCount"
+ :merge-requests-count="mergeRequestsCount"
+ :forks-count="forksCount"
+ :stars-count="starsCount"
+ @primary="submitForm"
+ >
+ <template #modal-footer>
+ <slot name="modal-footer"></slot>
+ </template>
+ </delete-modal>
+
<gl-button
- v-gl-modal="modalId"
category="primary"
variant="danger"
data-qa-selector="delete_button"
- >{{ $options.strings.deleteProject }}</gl-button
+ @click="onButtonClick"
+ >{{ $options.i18n.deleteProject }}</gl-button
>
-
- <gl-modal
- ref="removeModal"
- :modal-id="modalId"
- ok-variant="danger"
- footer-class="gl-bg-gray-10 gl-p-5"
- title-class="gl-text-red-500"
- :action-primary="modalActionProps.primary"
- :action-cancel="modalActionProps.cancel"
- @ok="submitForm"
- >
- <template #modal-title>{{ $options.strings.title }}</template>
- <div>
- <gl-alert class="gl-mb-5" variant="danger" :dismissible="false">
- <h4 v-if="isFork" data-testid="delete-alert-title" class="gl-alert-title">
- {{ $options.strings.isForkAlertTitle }}
- </h4>
- <h4 v-else data-testid="delete-alert-title" class="gl-alert-title">
- {{ $options.strings.isNotForkAlertTitle }}
- </h4>
- <ul>
- <li>
- <gl-sprintf :message="n__('%d issue', '%d issues', issuesCount)">
- <template #issuesCount>{{ issuesCount }}</template>
- </gl-sprintf>
- </li>
- <li>
- <gl-sprintf
- :message="n__('%d merge requests', '%d merge requests', mergeRequestsCount)"
- >
- <template #mergeRequestsCount>{{ mergeRequestsCount }}</template>
- </gl-sprintf>
- </li>
- <li>
- <gl-sprintf :message="n__('%d fork', '%d forks', forksCount)">
- <template #forksCount>{{ forksCount }}</template>
- </gl-sprintf>
- </li>
- <li>
- <gl-sprintf :message="n__('%d star', '%d stars', starsCount)">
- <template #starsCount>{{ starsCount }}</template>
- </gl-sprintf>
- </li>
- </ul>
- <gl-sprintf
- v-if="isFork"
- data-testid="delete-alert-body"
- :message="$options.strings.isForkAlertBody"
- />
- <gl-sprintf
- v-else
- data-testid="delete-alert-body"
- :message="$options.strings.isNotForkAlertBody"
- >
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- </gl-alert>
- <p class="gl-mb-1">{{ $options.strings.confirmText }}</p>
- <p>
- <code class="gl-white-space-pre-wrap">{{ confirmPhrase }}</code>
- </p>
- <gl-form-input
- id="confirm_name_input"
- v-model="userInput"
- name="confirm_name_input"
- type="text"
- data-qa-selector="confirm_name_field"
- />
- <slot name="modal-footer"></slot>
- </div>
- </gl-modal>
- </form>
+ </gl-form>
</template>
diff --git a/app/assets/javascripts/projects/components/shared/delete_modal.vue b/app/assets/javascripts/projects/components/shared/delete_modal.vue
new file mode 100644
index 00000000000..44e29d00d45
--- /dev/null
+++ b/app/assets/javascripts/projects/components/shared/delete_modal.vue
@@ -0,0 +1,155 @@
+<script>
+import { GlModal, GlAlert, GlSprintf, GlFormInput } from '@gitlab/ui';
+import uniqueId from 'lodash/uniqueId';
+import { __ } from '~/locale';
+
+export default {
+ i18n: {
+ deleteProject: __('Delete project'),
+ title: __('Are you absolutely sure?'),
+ confirmText: __('Enter the following to confirm:'),
+ isForkAlertTitle: __('You are about to delete this forked project containing:'),
+ isNotForkAlertTitle: __('You are about to delete this project containing:'),
+ isForkAlertBody: __('This process deletes the project repository and all related resources.'),
+ isNotForkAlertBody: __(
+ 'This project is %{strongStart}NOT%{strongEnd} a fork. This process deletes the project repository and all related resources.',
+ ),
+ isNotForkMessage: __(
+ 'This project is %{strongStart}NOT%{strongEnd} a fork, and has the following:',
+ ),
+ },
+ components: { GlModal, GlAlert, GlSprintf, GlFormInput },
+ model: {
+ prop: 'visible',
+ event: 'change',
+ },
+ props: {
+ visible: {
+ type: Boolean,
+ required: true,
+ },
+ confirmPhrase: {
+ type: String,
+ required: true,
+ },
+ isFork: {
+ type: Boolean,
+ required: true,
+ },
+ issuesCount: {
+ type: [Number, String],
+ required: false,
+ default: null,
+ },
+ mergeRequestsCount: {
+ type: [Number, String],
+ required: false,
+ default: null,
+ },
+ forksCount: {
+ type: [Number, String],
+ required: false,
+ default: null,
+ },
+ starsCount: {
+ type: [Number, String],
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ userInput: null,
+ modalId: uniqueId('delete-project-modal-'),
+ };
+ },
+ computed: {
+ confirmDisabled() {
+ return this.userInput !== this.confirmPhrase;
+ },
+ modalActionProps() {
+ return {
+ primary: {
+ text: __('Yes, delete project'),
+ attributes: {
+ variant: 'danger',
+ disabled: this.confirmDisabled,
+ 'data-qa-selector': 'confirm_delete_button',
+ },
+ },
+ cancel: {
+ text: __('Cancel, keep project'),
+ },
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ :visible="visible"
+ :modal-id="modalId"
+ footer-class="gl-bg-gray-10 gl-p-5"
+ title-class="gl-text-red-500"
+ :action-primary="modalActionProps.primary"
+ :action-cancel="modalActionProps.cancel"
+ @primary="$emit('primary', $event)"
+ @change="$emit('change', $event)"
+ >
+ <template #modal-title>{{ $options.i18n.title }}</template>
+ <div>
+ <gl-alert class="gl-mb-5" variant="danger" :dismissible="false">
+ <h4 v-if="isFork" class="gl-alert-title">
+ {{ $options.i18n.isForkAlertTitle }}
+ </h4>
+ <h4 v-else class="gl-alert-title">
+ {{ $options.i18n.isNotForkAlertTitle }}
+ </h4>
+ <ul>
+ <li v-if="issuesCount !== null">
+ <gl-sprintf :message="n__('%d issue', '%d issues', issuesCount)">
+ <template #issuesCount>{{ issuesCount }}</template>
+ </gl-sprintf>
+ </li>
+ <li v-if="mergeRequestsCount !== null">
+ <gl-sprintf
+ :message="n__('%d merge requests', '%d merge requests', mergeRequestsCount)"
+ >
+ <template #mergeRequestsCount>{{ mergeRequestsCount }}</template>
+ </gl-sprintf>
+ </li>
+ <li v-if="forksCount !== null">
+ <gl-sprintf :message="n__('%d fork', '%d forks', forksCount)">
+ <template #forksCount>{{ forksCount }}</template>
+ </gl-sprintf>
+ </li>
+ <li v-if="starsCount !== null">
+ <gl-sprintf :message="n__('%d star', '%d stars', starsCount)">
+ <template #starsCount>{{ starsCount }}</template>
+ </gl-sprintf>
+ </li>
+ </ul>
+ <gl-sprintf v-if="isFork" :message="$options.i18n.isForkAlertBody" />
+ <gl-sprintf v-else :message="$options.i18n.isNotForkAlertBody">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ <p class="gl-mb-1">{{ $options.i18n.confirmText }}</p>
+ <p>
+ <code class="gl-white-space-pre-wrap">{{ confirmPhrase }}</code>
+ </p>
+
+ <gl-form-input
+ id="confirm_name_input"
+ v-model="userInput"
+ name="confirm_name_input"
+ type="text"
+ data-qa-selector="confirm_name_field"
+ />
+ <slot name="modal-footer"></slot>
+ </div>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/projects/feature_flags_user_lists/show/index.js b/app/assets/javascripts/projects/feature_flags_user_lists/show/index.js
index 2bd3e57322d..59210b31d32 100644
--- a/app/assets/javascripts/projects/feature_flags_user_lists/show/index.js
+++ b/app/assets/javascripts/projects/feature_flags_user_lists/show/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import UserList from '~/user_lists/components/user_list.vue';
import createStore from '~/user_lists/store/show';
diff --git a/app/assets/javascripts/projects/new/components/new_project_url_select.vue b/app/assets/javascripts/projects/new/components/new_project_url_select.vue
index d6d88b5b297..ef2a2aa5526 100644
--- a/app/assets/javascripts/projects/new/components/new_project_url_select.vue
+++ b/app/assets/javascripts/projects/new/components/new_project_url_select.vue
@@ -157,7 +157,7 @@ export default {
<gl-dropdown
class="js-group-namespace-dropdown gl-flex-grow-1"
:toggle-class="`gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20 ${dropdownPlaceholderClass}`"
- data-qa-selector="select_namespace_dropdown"
+ data-testid="select-namespace-dropdown"
@show="trackDropdownShow"
@shown="handleDropdownShown"
>
@@ -173,7 +173,7 @@ export default {
ref="search"
v-model.trim="search"
:is-loading="$apollo.queries.currentUser.loading"
- data-qa-selector="select_namespace_dropdown_search_field"
+ data-testid="select-namespace-dropdown-search-field"
/>
<template v-if="!$apollo.queries.currentUser.loading">
<template v-if="hasGroupMatches">
diff --git a/app/assets/javascripts/projects/pipelines/charts/index.js b/app/assets/javascripts/projects/pipelines/charts/index.js
index 0cfea401be6..35c8046bfe7 100644
--- a/app/assets/javascripts/projects/pipelines/charts/index.js
+++ b/app/assets/javascripts/projects/pipelines/charts/index.js
@@ -2,6 +2,8 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPENAME_PROJECT } from '~/graphql_shared/constants';
import ProjectPipelinesCharts from './components/app.vue';
Vue.use(VueApollo);
@@ -12,6 +14,7 @@ const apolloProvider = new VueApollo({
const mountPipelineChartsApp = (el) => {
const {
+ projectId,
projectPath,
failedPipelinesLink,
coverageChartPath,
@@ -22,6 +25,7 @@ const mountPipelineChartsApp = (el) => {
const shouldRenderDoraCharts = parseBoolean(el.dataset.shouldRenderDoraCharts);
const shouldRenderQualitySummary = parseBoolean(el.dataset.shouldRenderQualitySummary);
+ const contextId = convertToGraphQLId(TYPENAME_PROJECT, projectId);
return new Vue({
el,
@@ -39,6 +43,7 @@ const mountPipelineChartsApp = (el) => {
defaultBranch,
testRunsEmptyStateImagePath,
projectQualitySummaryFeedbackImagePath,
+ contextId,
},
render: (createElement) => createElement(ProjectPipelinesCharts, {}),
});
diff --git a/app/assets/javascripts/projects/project_name_rules.js b/app/assets/javascripts/projects/project_name_rules.js
index 4f62aa29ce4..90f9290ffb8 100644
--- a/app/assets/javascripts/projects/project_name_rules.js
+++ b/app/assets/javascripts/projects/project_name_rules.js
@@ -8,7 +8,7 @@ export const START_RULE = {
export const CONTAINS_RULE = {
reg: /^[a-zA-Z0-9\p{Pd}\u{002B}\u{00A9}-\u{1f9ff}_. ]+$/u,
msg: __(
- 'Name can contain only lowercase or uppercase letters, digits, emojis, spaces, dots, underscores, dashes, or pluses.',
+ 'Name can contain only lowercase or uppercase letters, digits, emoji, spaces, dots, underscores, dashes, or pluses.',
),
};
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index 33320f59b0f..2b5e2dcb301 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -76,7 +76,7 @@ const namespaceError = () => document.querySelector('.js-group-namespace-error')
const validateGroupNamespaceDropdown = (e) => {
if (selectedNamespaceId() && !selectedNamespaceId().attributes.value) {
- document.querySelector('input[data-qa-selector="project_name"]').reportValidity();
+ document.querySelector('#project_name').reportValidity();
e.preventDefault();
dropdownButton().classList.add(invalidDropdownClass);
namespaceButton().classList.add(invalidDropdownClass);
diff --git a/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue
index b8e7e9e15db..a02a33992b5 100644
--- a/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue
+++ b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAlert, GlToggle } from '@gitlab/ui';
+import { GlAlert, GlLink, GlToggle, GlSprintf } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { __, s__ } from '~/locale';
import { CC_VALIDATION_REQUIRED_ERROR } from '../constants';
@@ -15,7 +15,9 @@ export default {
},
components: {
GlAlert,
+ GlLink,
GlToggle,
+ GlSprintf,
CcValidationRequiredAlert: () =>
import('ee_component/billings/components/cc_validation_required_alert.vue'),
},
@@ -36,6 +38,16 @@ export default {
type: String,
required: true,
},
+ groupName: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ groupSettingsPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -57,6 +69,9 @@ export default {
!this.ccAlertDismissed
);
},
+ isGroupSettingsAvailable() {
+ return this.groupSettingsPath && this.groupName;
+ },
},
methods: {
creditCardValidated() {
@@ -103,16 +118,6 @@ export default {
{{ errorMessage }}
</gl-alert>
- <gl-alert
- v-if="isDisabledAndUnoverridable"
- data-testid="unoverridable-alert"
- variant="warning"
- :dismissible="false"
- class="gl-mb-5"
- >
- {{ s__('Runners|Shared runners are disabled in the group settings') }}
- </gl-alert>
-
<gl-toggle
ref="sharedRunnersToggle"
:disabled="isDisabledAndUnoverridable"
@@ -121,7 +126,19 @@ export default {
:value="isSharedRunnerEnabled"
data-testid="toggle-shared-runners"
@change="toggleSharedRunners"
- />
+ >
+ <template v-if="isDisabledAndUnoverridable" #help>
+ {{ s__('Runners|Shared runners are disabled in the group settings.') }}
+ <gl-sprintf
+ v-if="isGroupSettingsAvailable"
+ :message="s__('Runners|Go to %{groupLink} to enable them.')"
+ >
+ <template #groupLink>
+ <gl-link :href="groupSettingsPath">{{ groupName }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-toggle>
</section>
</div>
</template>
diff --git a/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js b/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js
index 54120b3525d..ace5fd5c6e4 100644
--- a/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js
+++ b/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js
@@ -9,10 +9,15 @@ export default (containerId = 'toggle-shared-runners-form') => {
}
const {
+ // required
isDisabledAndUnoverridable,
isEnabled,
updatePath,
isCreditCardValidationRequired,
+
+ // optional
+ groupName,
+ groupSettingsPath,
} = containerEl.dataset;
return new Vue({
@@ -24,6 +29,9 @@ export default (containerId = 'toggle-shared-runners-form') => {
isEnabled: parseBoolean(isEnabled),
isCreditCardValidationRequired: parseBoolean(isCreditCardValidationRequired),
updatePath,
+
+ groupName,
+ groupSettingsPath,
},
});
},
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 dcf5155644d..7753b850744 100644
--- a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui';
+import { GlButton, GlModal, GlModalDirective, GlCard, GlIcon } from '@gitlab/ui';
import { createAlert } from '~/alert';
import branchRulesQuery from 'ee_else_ce/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql';
import { expandSection } from '~/settings_panels';
@@ -14,6 +14,8 @@ export default {
BranchRule,
GlButton,
GlModal,
+ GlCard,
+ GlIcon,
},
directives: {
GlModal: GlModalDirective,
@@ -55,29 +57,47 @@ export default {
</script>
<template>
- <div class="settings-content gl-mb-0">
- <branch-rule
- v-for="(rule, index) in branchRules"
- :key="`${rule.name}-${index}`"
- :name="rule.name"
- :is-default="rule.isDefault"
- :branch-protection="rule.branchProtection"
- :status-checks-total="rule.externalStatusChecks ? rule.externalStatusChecks.nodes.length : 0"
- :approval-rules-total="rule.approvalRules ? rule.approvalRules.nodes.length : 0"
- :matching-branches-count="rule.matchingBranchesCount"
- />
-
- <div v-if="!branchRules.length" data-testid="empty">{{ $options.i18n.emptyState }}</div>
-
- <gl-button
- v-gl-modal="$options.modalId"
- class="gl-mt-5"
- data-qa-selector="add_branch_rule_button"
- category="secondary"
- variant="info"
- >{{ $options.i18n.addBranchRule }}</gl-button
- >
-
+ <gl-card
+ class="gl-new-card gl-overflow-hidden"
+ header-class="gl-new-card-header"
+ body-class="gl-new-card-body gl-px-0"
+ >
+ <template #header>
+ <div class="gl-new-card-title-wrapper" data-testid="title">
+ <h3 class="gl-new-card-title">
+ {{ __('Branch Rules') }}
+ </h3>
+ <div class="gl-new-card-count">
+ <gl-icon name="branch" class="gl-mr-2" />
+ {{ branchRules.length }}
+ </div>
+ </div>
+ <gl-button
+ v-gl-modal="$options.modalId"
+ size="small"
+ class="gl-ml-3"
+ data-qa-selector="add_branch_rule_button"
+ >{{ $options.i18n.addBranchRule }}</gl-button
+ >
+ </template>
+ <ul class="content-list">
+ <branch-rule
+ v-for="(rule, index) in branchRules"
+ :key="`${rule.name}-${index}`"
+ :name="rule.name"
+ :is-default="rule.isDefault"
+ :branch-protection="rule.branchProtection"
+ :status-checks-total="
+ rule.externalStatusChecks ? rule.externalStatusChecks.nodes.length : 0
+ "
+ :approval-rules-total="rule.approvalRules ? rule.approvalRules.nodes.length : 0"
+ :matching-branches-count="rule.matchingBranchesCount"
+ class="gl-px-5! gl-py-4!"
+ />
+ <div v-if="!branchRules.length" class="gl-new-card-empty gl-px-5 gl-py-4" data-testid="empty">
+ {{ $options.i18n.emptyState }}
+ </div>
+ </ul>
<gl-modal
:ref="$options.modalId"
:modal-id="$options.modalId"
@@ -88,5 +108,5 @@ export default {
<p>{{ $options.i18n.branchRuleModalDescription }}</p>
<p>{{ $options.i18n.branchRuleModalContent }}</p>
</gl-modal>
- </div>
+ </gl-card>
</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
index a5ff478a826..f45a5b12db6 100644
--- 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
@@ -6,7 +6,7 @@ import { getAccessLevels } from '../../../utils';
export const i18n = {
defaultLabel: s__('BranchRules|default'),
protectedLabel: s__('BranchRules|protected'),
- detailsButtonLabel: s__('BranchRules|Details'),
+ detailsButtonLabel: s__('BranchRules|View details'),
allowForcePush: s__('BranchRules|Allowed to force push'),
codeOwnerApprovalRequired: s__('BranchRules|Requires CODEOWNERS approval'),
statusChecks: s__('BranchRules|%{total} status %{subject}'),
@@ -153,28 +153,36 @@ export default {
</script>
<template>
- <div
- class="gl-border-b gl-pt-5 gl-pb-5 gl-display-flex gl-justify-content-space-between"
- data-qa-selector="branch_content"
- :data-qa-branch-name="name"
- >
- <div>
- <strong class="gl-font-monospace">{{ name }}</strong>
+ <li>
+ <div
+ class="gl-display-flex gl-justify-content-space-between"
+ data-qa-selector="branch_content"
+ :data-qa-branch-name="name"
+ >
+ <div>
+ <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="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>
+ <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>
+ <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>
+ <gl-button
+ class="gl-align-self-start"
+ category="tertiary"
+ size="small"
+ data-qa-selector="details_button"
+ :href="detailsPath"
+ >
+ {{ $options.i18n.detailsButtonLabel }}</gl-button
+ >
</div>
- <gl-button class="gl-align-self-start" data-qa-selector="details_button" :href="detailsPath">
- {{ $options.i18n.detailsButtonLabel }}</gl-button
- >
- </div>
+ </li>
</template>
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 47477d39b8a..2f980e20c1e 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
@@ -60,7 +60,7 @@ export default {
return this.selectedTokens.length ? '' : this.$options.i18n.placeholder;
},
topicsHelpUrl() {
- return helpPagePath('user/admin_area/index.html', {
+ return helpPagePath('administration/index', {
anchor: 'administering-topics',
});
},
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/custom_email.vue b/app/assets/javascripts/projects/settings_service_desk/components/custom_email.vue
new file mode 100644
index 00000000000..f7a9949db4b
--- /dev/null
+++ b/app/assets/javascripts/projects/settings_service_desk/components/custom_email.vue
@@ -0,0 +1,139 @@
+<script>
+import { GlBadge, GlButton, GlSprintf, GlToggle } from '@gitlab/ui';
+import {
+ I18N_STATE_INTRO_PARAGRAPH,
+ I18N_STATE_VERIFICATION_FINISHED_INTRO_PARAGRAPH,
+ I18N_STATE_VERIFICATION_STARTED,
+ I18N_RESET_BUTTON_LABEL,
+ I18N_STATE_VERIFICATION_FAILED,
+ I18N_STATE_VERIFICATION_FINISHED_TOGGLE_LABEL,
+ I18N_STATE_VERIFICATION_FINISHED_TOGGLE_HELP,
+ I18N_STATE_RESET_PARAGRAPH,
+ I18N_VERIFICATION_ERRORS,
+} from '../custom_email_constants';
+
+export default {
+ components: {
+ GlBadge,
+ GlButton,
+ GlSprintf,
+ GlToggle,
+ },
+ I18N_STATE_VERIFICATION_STARTED,
+ I18N_STATE_VERIFICATION_FAILED,
+ I18N_STATE_VERIFICATION_FINISHED_TOGGLE_LABEL,
+ I18N_STATE_VERIFICATION_FINISHED_TOGGLE_HELP,
+ I18N_RESET_BUTTON_LABEL,
+ props: {
+ customEmail: {
+ type: String,
+ required: true,
+ },
+ smtpAddress: {
+ type: String,
+ required: true,
+ },
+ verificationState: {
+ type: String,
+ required: true,
+ },
+ verificationError: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ isEnabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isSubmitting: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ isVerificationFailed() {
+ return this.verificationState === 'failed';
+ },
+ isVerificationFinished() {
+ return this.verificationState === 'finished';
+ },
+ containerClass() {
+ return this.isVerificationFinished ? '' : 'gl-text-center';
+ },
+ introNote() {
+ return this.isVerificationFinished
+ ? I18N_STATE_VERIFICATION_FINISHED_INTRO_PARAGRAPH
+ : I18N_STATE_INTRO_PARAGRAPH;
+ },
+ badgeVariant() {
+ return this.isVerificationFailed ? 'danger' : 'info';
+ },
+ badgeContent() {
+ return this.isVerificationFailed
+ ? I18N_STATE_VERIFICATION_FAILED
+ : I18N_STATE_VERIFICATION_STARTED;
+ },
+ verificationErrorI18nObject() {
+ return I18N_VERIFICATION_ERRORS[this.verificationError];
+ },
+ errorLabel() {
+ return this.verificationErrorI18nObject?.label;
+ },
+ errorDescription() {
+ return this.verificationErrorI18nObject?.description;
+ },
+ resetNote() {
+ return I18N_STATE_RESET_PARAGRAPH[this.verificationState];
+ },
+ },
+};
+</script>
+
+<template>
+ <div :class="containerClass">
+ <p>
+ <gl-sprintf :message="introNote">
+ <template #customEmail>
+ <strong>{{ customEmail }}</strong>
+ </template>
+ <template #smtpAddress>
+ <strong>{{ smtpAddress }}</strong>
+ </template>
+ <template #badge="{ content }">
+ <gl-badge variant="success">{{ content }}</gl-badge>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <div v-if="!isVerificationFinished" class="gl-mb-5">
+ <gl-badge :variant="badgeVariant">{{ badgeContent }}</gl-badge>
+ </div>
+
+ <template v-if="isVerificationFinished">
+ <gl-toggle
+ :value="isEnabled"
+ :is-loading="isSubmitting"
+ :label="$options.I18N_STATE_VERIFICATION_FINISHED_TOGGLE_LABEL"
+ :help="$options.I18N_STATE_VERIFICATION_FINISHED_TOGGLE_HELP"
+ label-position="top"
+ @change="$emit('toggle', $event)"
+ />
+ <hr />
+ </template>
+
+ <template v-if="verificationError">
+ <p class="gl-mb-0">
+ <strong>{{ errorLabel }}</strong>
+ </p>
+ <p>{{ errorDescription }}</p>
+ </template>
+
+ <p>{{ resetNote }}</p>
+ <gl-button :loading="isSubmitting" @click="$emit('reset')">
+ {{ $options.I18N_RESET_BUTTON_LABEL }}
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/custom_email_confirm_modal.vue b/app/assets/javascripts/projects/settings_service_desk/components/custom_email_confirm_modal.vue
new file mode 100644
index 00000000000..2fb1ea52e05
--- /dev/null
+++ b/app/assets/javascripts/projects/settings_service_desk/components/custom_email_confirm_modal.vue
@@ -0,0 +1,74 @@
+<script>
+import { GlModal, GlSprintf } from '@gitlab/ui';
+import {
+ I18N_MODAL_TITLE,
+ I18N_MODAL_CANCEL_BUTTON_LABEL,
+ I18N_RESET_BUTTON_LABEL,
+ I18N_MODAL_DISABLE_CUSTOM_EMAIL_PARAGRAPH,
+ I18N_MODAL_SET_UP_AGAIN_PARAGRAPH,
+} from '../custom_email_constants';
+
+export default {
+ components: {
+ GlModal,
+ GlSprintf,
+ },
+ I18N_MODAL_TITLE,
+ I18N_RESET_BUTTON_LABEL,
+ I18N_MODAL_DISABLE_CUSTOM_EMAIL_PARAGRAPH,
+ I18N_MODAL_SET_UP_AGAIN_PARAGRAPH,
+ props: {
+ visible: {
+ type: Boolean,
+ required: true,
+ },
+ customEmail: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ primaryButtonAttributes() {
+ return {
+ text: I18N_RESET_BUTTON_LABEL,
+ attributes: {
+ variant: 'danger',
+ },
+ };
+ },
+ cancelButtonAttributes() {
+ return {
+ text: I18N_MODAL_CANCEL_BUTTON_LABEL,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ modal-id="custom-email-confirm-modal"
+ :title="$options.I18N_MODAL_TITLE"
+ :action-primary="primaryButtonAttributes"
+ :action-cancel="cancelButtonAttributes"
+ :visible="visible"
+ @primary="$emit('remove')"
+ @canceled="$emit('cancel')"
+ @hidden="$emit('cancel')"
+ >
+ <p>
+ <gl-sprintf :message="$options.I18N_MODAL_DISABLE_CUSTOM_EMAIL_PARAGRAPH">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ <template #customEmail>
+ <code>{{ customEmail }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ <p>
+ {{ $options.I18N_MODAL_SET_UP_AGAIN_PARAGRAPH }}
+ </p>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/custom_email_form.vue b/app/assets/javascripts/projects/settings_service_desk/components/custom_email_form.vue
new file mode 100644
index 00000000000..4affcd926d4
--- /dev/null
+++ b/app/assets/javascripts/projects/settings_service_desk/components/custom_email_form.vue
@@ -0,0 +1,291 @@
+<script>
+import { GlButton, GlForm, GlFormGroup, GlFormInputGroup, GlFormInput } from '@gitlab/ui';
+import { isEmptyValue, hasMinimumLength, isIntegerGreaterThan, isEmail } from '~/lib/utils/forms';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import {
+ I18N_FORM_INTRODUCTION_PARAGRAPH,
+ I18N_FORM_CUSTOM_EMAIL_LABEL,
+ I18N_FORM_CUSTOM_EMAIL_DESCRIPTION,
+ I18N_FORM_FORWARDING_LABEL,
+ I18N_FORM_FORWARDING_CLIPBOARD_BUTTON_TITLE,
+ I18N_FORM_SMTP_ADDRESS_LABEL,
+ I18N_FORM_SMTP_PORT_LABEL,
+ I18N_FORM_SMTP_PORT_DESCRIPTION,
+ I18N_FORM_SMTP_USERNAME_LABEL,
+ I18N_FORM_SMTP_PASSWORD_LABEL,
+ I18N_FORM_SMTP_PASSWORD_DESCRIPTION,
+ I18N_FORM_SUBMIT_LABEL,
+ I18N_FORM_INVALID_FEEDBACK_CUSTOM_EMAIL,
+ I18N_FORM_INVALID_FEEDBACK_SMTP_ADDRESS,
+ I18N_FORM_INVALID_FEEDBACK_SMTP_PORT,
+ I18N_FORM_INVALID_FEEDBACK_SMTP_USERNAME,
+ I18N_FORM_INVALID_FEEDBACK_SMTP_PASSWORD,
+} from '../custom_email_constants';
+
+export default {
+ components: {
+ ClipboardButton,
+ GlButton,
+ GlForm,
+ GlFormGroup,
+ GlFormInputGroup,
+ GlFormInput,
+ },
+ I18N_FORM_INTRODUCTION_PARAGRAPH,
+ I18N_FORM_CUSTOM_EMAIL_LABEL,
+ I18N_FORM_CUSTOM_EMAIL_DESCRIPTION,
+ I18N_FORM_FORWARDING_LABEL,
+ I18N_FORM_FORWARDING_CLIPBOARD_BUTTON_TITLE,
+ I18N_FORM_SMTP_ADDRESS_LABEL,
+ I18N_FORM_SMTP_PORT_LABEL,
+ I18N_FORM_SMTP_PORT_DESCRIPTION,
+ I18N_FORM_SMTP_USERNAME_LABEL,
+ I18N_FORM_SMTP_PASSWORD_LABEL,
+ I18N_FORM_SMTP_PASSWORD_DESCRIPTION,
+ I18N_FORM_SUBMIT_LABEL,
+ I18N_FORM_INVALID_FEEDBACK_CUSTOM_EMAIL,
+ I18N_FORM_INVALID_FEEDBACK_SMTP_ADDRESS,
+ I18N_FORM_INVALID_FEEDBACK_SMTP_PORT,
+ I18N_FORM_INVALID_FEEDBACK_SMTP_USERNAME,
+ I18N_FORM_INVALID_FEEDBACK_SMTP_PASSWORD,
+ props: {
+ incomingEmail: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ isSubmitting: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ customEmail: '',
+ forwardingConfigured: false,
+ smtpAddress: '',
+ smtpPort: '587',
+ smtpUsername: '',
+ smtpPassword: '',
+ validationState: {
+ customEmail: null,
+ smtpAddress: null,
+ smtpPort: true,
+ smtpUsername: null,
+ smtpPassword: null,
+ },
+ };
+ },
+ computed: {
+ isFormValid() {
+ return Object.values(this.validationState).every(Boolean);
+ },
+ },
+ methods: {
+ onSubmit() {
+ this.triggerVerification();
+
+ if (!this.isFormValid) {
+ return;
+ }
+
+ this.$emit('submit', this.getRequestFormData());
+ },
+ getRequestFormData() {
+ return {
+ custom_email: this.customEmail,
+ smtp_address: this.smtpAddress,
+ smtp_port: this.smtpPort,
+ smtp_username: this.smtpUsername,
+ smtp_password: this.smtpPassword,
+ };
+ },
+ onCustomEmailChange() {
+ this.validateCustomEmail();
+
+ if (this.validationState.customEmail && isEmptyValue(this.smtpUsername)) {
+ this.smtpUsername = this.customEmail;
+ this.validateSmtpUsername();
+ }
+ },
+ validateCustomEmail() {
+ this.validationState.customEmail = isEmail(this.customEmail);
+ },
+ validateSmtpAddress() {
+ this.validationState.smtpAddress = !isEmptyValue(this.smtpAddress);
+ },
+ validateSmtpPort() {
+ this.validationState.smtpPort = isIntegerGreaterThan(this.smtpPort, 0);
+ },
+ validateSmtpUsername() {
+ this.validationState.smtpUsername = !isEmptyValue(this.smtpUsername);
+ },
+ validateSmtpPassword() {
+ this.validationState.smtpPassword = hasMinimumLength(this.smtpPassword, 8);
+ },
+ triggerVerification() {
+ this.validateCustomEmail();
+ this.validateSmtpAddress();
+ this.validateSmtpPort();
+ this.validateSmtpUsername();
+ this.validateSmtpPassword();
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <p>{{ $options.I18N_FORM_INTRODUCTION_PARAGRAPH }}</p>
+ <gl-form class="js-quick-submit" @submit.prevent="onSubmit">
+ <gl-form-group
+ :label="$options.I18N_FORM_FORWARDING_LABEL"
+ label-for="custom-email-form-forwarding"
+ class="gl-mt-3"
+ >
+ <gl-form-input-group>
+ <gl-form-input
+ id="custom-email-form-forwarding"
+ ref="service-desk-incoming-email"
+ type="text"
+ data-testid="custom-email-form-forwarding"
+ :aria-label="$options.I18N_FORM_FORWARDING_LABEL"
+ :value="incomingEmail"
+ :disabled="true"
+ />
+ <template #append>
+ <clipboard-button
+ :title="$options.I18N_FORM_FORWARDING_CLIPBOARD_BUTTON_TITLE"
+ :text="incomingEmail"
+ css-class="input-group-text"
+ />
+ </template>
+ </gl-form-input-group>
+ </gl-form-group>
+
+ <gl-form-group
+ :label="$options.I18N_FORM_CUSTOM_EMAIL_LABEL"
+ label-for="custom-email-form-custom-email"
+ data-testid="form-group-custom-email"
+ :invalid-feedback="$options.I18N_FORM_INVALID_FEEDBACK_CUSTOM_EMAIL"
+ class="gl-mt-3"
+ :description="$options.I18N_FORM_CUSTOM_EMAIL_DESCRIPTION"
+ >
+ <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings -->
+ <gl-form-input
+ id="custom-email-form-custom-email"
+ v-model.trim="customEmail"
+ data-testid="form-custom-email"
+ :aria-label="$options.I18N_FORM_CUSTOM_EMAIL_LABEL"
+ placeholder="contact@example.com"
+ type="email"
+ :state="validationState.customEmail"
+ :required="true"
+ :disabled="isSubmitting"
+ @change="onCustomEmailChange"
+ />
+ <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings -->
+ </gl-form-group>
+
+ <gl-form-group
+ :label="$options.I18N_FORM_SMTP_ADDRESS_LABEL"
+ label-for="custom-email-form-smtp-address"
+ data-testid="form-group-smtp-address"
+ :invalid-feedback="$options.I18N_FORM_INVALID_FEEDBACK_SMTP_ADDRESS"
+ class="gl-mt-3"
+ >
+ <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings -->
+ <gl-form-input
+ id="custom-email-form-smtp-address"
+ v-model.trim="smtpAddress"
+ data-testid="form-smtp-address"
+ :aria-label="$options.I18N_FORM_SMTP_ADDRESS_LABEL"
+ placeholder="smtp.example.com"
+ type="email"
+ :state="validationState.smtpAddress"
+ :required="true"
+ :disabled="isSubmitting"
+ @change="validateSmtpAddress"
+ />
+ <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings -->
+ </gl-form-group>
+
+ <gl-form-group
+ :label="$options.I18N_FORM_SMTP_PORT_LABEL"
+ label-for="custom-email-form-smtp-port"
+ :invalid-feedback="$options.I18N_FORM_INVALID_FEEDBACK_SMTP_PORT"
+ class="gl-mt-3"
+ :description="$options.I18N_FORM_SMTP_PORT_DESCRIPTION"
+ >
+ <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings -->
+ <gl-form-input
+ id="custom-email-form-smtp-port"
+ v-model.trim="smtpPort"
+ data-testid="form-smtp-port"
+ :aria-label="$options.I18N_FORM_SMTP_PORT_LABEL"
+ placeholder="587"
+ type="number"
+ :state="validationState.smtpPort"
+ :required="true"
+ :disabled="isSubmitting"
+ @change="validateSmtpPort"
+ />
+ <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings -->
+ </gl-form-group>
+
+ <gl-form-group
+ :label="$options.I18N_FORM_SMTP_USERNAME_LABEL"
+ label-for="custom-email-form-smtp-username"
+ :invalid-feedback="$options.I18N_FORM_INVALID_FEEDBACK_SMTP_USERNAME"
+ class="gl-mt-3"
+ >
+ <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings -->
+ <gl-form-input
+ id="custom-email-form-smtp-username"
+ v-model.trim="smtpUsername"
+ data-testid="form-smtp-username"
+ :aria-label="$options.I18N_FORM_SMTP_USERNAME_LABEL"
+ placeholder="contact@example.com"
+ :state="validationState.smtpUsername"
+ :required="true"
+ :disabled="isSubmitting"
+ @change="validateSmtpUsername"
+ />
+ <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings -->
+ </gl-form-group>
+
+ <gl-form-group
+ :label="$options.I18N_FORM_SMTP_PASSWORD_LABEL"
+ label-for="custom-email-form-smtp-password"
+ :invalid-feedback="$options.I18N_FORM_INVALID_FEEDBACK_SMTP_PASSWORD"
+ class="gl-mt-3"
+ :description="$options.I18N_FORM_SMTP_PASSWORD_DESCRIPTION"
+ >
+ <gl-form-input
+ id="custom-email-form-smtp-password"
+ v-model.trim="smtpPassword"
+ data-testid="form-smtp-password"
+ :aria-label="$options.I18N_FORM_SMTP_PASSWORD_LABEL"
+ type="password"
+ :state="validationState.smtpPassword"
+ :required="true"
+ :disabled="isSubmitting"
+ @change="validateSmtpPassword"
+ />
+ </gl-form-group>
+
+ <gl-button
+ type="submit"
+ variant="confirm"
+ class="gl-mt-5"
+ data-testid="form-submit"
+ :disabled="!isFormValid"
+ :loading="isSubmitting"
+ @click="onSubmit"
+ >
+ {{ $options.I18N_FORM_SUBMIT_LABEL }}
+ </gl-button>
+ </gl-form>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/custom_email_wrapper.vue b/app/assets/javascripts/projects/settings_service_desk/components/custom_email_wrapper.vue
new file mode 100644
index 00000000000..7e040e6001a
--- /dev/null
+++ b/app/assets/javascripts/projects/settings_service_desk/components/custom_email_wrapper.vue
@@ -0,0 +1,245 @@
+<script>
+import { GlAlert, GlLoadingIcon, GlSprintf, GlLink, GlCard } from '@gitlab/ui';
+import BetaBadge from '~/vue_shared/components/badges/beta_badge.vue';
+import axios from '~/lib/utils/axios_utils';
+import {
+ FEEDBACK_ISSUE_URL,
+ I18N_LOADING_LABEL,
+ I18N_CARD_TITLE,
+ I18N_GENERIC_ERROR,
+ I18N_FEEDBACK_PARAGRAPH,
+ I18N_TOAST_SAVED,
+ I18N_TOAST_DELETED,
+ I18N_TOAST_ENABLED,
+ I18N_TOAST_DISABLED,
+} from '../custom_email_constants';
+import CustomEmailConfirmModal from './custom_email_confirm_modal.vue';
+import CustomEmailForm from './custom_email_form.vue';
+import CustomEmail from './custom_email.vue';
+
+export default {
+ components: {
+ BetaBadge,
+ GlAlert,
+ GlLoadingIcon,
+ GlSprintf,
+ GlLink,
+ GlCard,
+ CustomEmailConfirmModal,
+ CustomEmailForm,
+ CustomEmail,
+ },
+ FEEDBACK_ISSUE_URL,
+ I18N_LOADING_LABEL,
+ I18N_CARD_TITLE,
+ I18N_FEEDBACK_PARAGRAPH,
+ I18N_TOAST_SAVED,
+ I18N_TOAST_DELETED,
+ props: {
+ incomingEmail: {
+ type: String,
+ required: true,
+ },
+ customEmailEndpoint: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isLoading: true,
+ isSubmitting: false,
+ confirmModalVisible: false,
+ customEmail: null,
+ isEnabled: false,
+ verificationState: null,
+ verificationError: null,
+ smtpAddress: null,
+ alertMessage: null,
+ };
+ },
+ computed: {
+ customEmailNotSetUp() {
+ return !this.isEnabled && this.verificationState === null && this.customEmail === null;
+ },
+ toastToggleText() {
+ return this.isEnabled ? I18N_TOAST_ENABLED : I18N_TOAST_DISABLED;
+ },
+ },
+ mounted() {
+ this.getCustomEmailDetails();
+ },
+ methods: {
+ dismissAlert() {
+ this.alertMessage = null;
+ },
+ getCustomEmailDetails() {
+ axios
+ .get(this.customEmailEndpoint)
+ .then(({ data }) => {
+ this.updateData(data);
+ })
+ .catch(this.handleRequestError)
+ .finally(() => {
+ this.isLoading = false;
+ this.enqueueReFetchVerification();
+ });
+ },
+ enqueueReFetchVerification() {
+ setTimeout(this.reFetchVerification, 8000);
+ },
+ reFetchVerification() {
+ if (this.verificationState !== 'started') {
+ return;
+ }
+ this.getCustomEmailDetails();
+ },
+ handleRequestError() {
+ this.alertMessage = I18N_GENERIC_ERROR;
+ },
+ updateData(data) {
+ this.customEmail = data.custom_email;
+ this.isEnabled = data.custom_email_enabled;
+ this.verificationState = data.custom_email_verification_state;
+ this.verificationError = data.custom_email_verification_error;
+ this.smtpAddress = data.custom_email_smtp_address;
+ },
+ onSaveCustomEmail(requestData) {
+ this.alertMessage = null;
+ this.isSubmitting = true;
+
+ axios
+ .post(this.customEmailEndpoint, requestData)
+ .then(({ data }) => {
+ this.updateData(data);
+ this.$toast.show(this.$options.I18N_TOAST_SAVED);
+ this.enqueueReFetchVerification();
+ })
+ .catch(this.handleRequestError)
+ .finally(() => {
+ this.isSubmitting = false;
+ });
+ },
+ onResetCustomEmail() {
+ this.confirmModalVisible = true;
+ },
+ onConfirmModalCanceled() {
+ this.confirmModalVisible = false;
+ },
+ onConfirmModalProceed() {
+ this.isSubmitting = true;
+ this.confirmModalVisible = false;
+
+ this.deleteCustomEmail();
+ },
+ deleteCustomEmail() {
+ axios
+ .delete(this.customEmailEndpoint)
+ .then(({ data }) => {
+ this.updateData(data);
+ this.$toast.show(I18N_TOAST_DELETED);
+ })
+ .catch(this.handleRequestError)
+ .finally(() => {
+ this.isSubmitting = false;
+ });
+ },
+ onToggleCustomEmail(isChecked) {
+ this.isEnabled = isChecked;
+ this.isSubmitting = true;
+
+ const body = {
+ custom_email_enabled: this.isEnabled,
+ };
+
+ axios
+ .put(this.customEmailEndpoint, body)
+ .then(({ data }) => {
+ this.updateData(data);
+ this.$toast.show(this.toastToggleText);
+ })
+ .catch(this.handleRequestError)
+ .finally(() => {
+ this.isSubmitting = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="row gl-mt-7">
+ <div class="col-md-9">
+ <gl-card>
+ <template #header>
+ <div class="gl-display-flex align-items-center justify-content-between">
+ <h5 class="gl-my-0">{{ $options.I18N_CARD_TITLE }}</h5>
+ <beta-badge />
+ </div>
+ </template>
+
+ <template #default>
+ <div v-if="isLoading" class="gl-p-3 gl-text-center">
+ <gl-loading-icon
+ :label="$options.I18N_LOADING_LABEL"
+ size="md"
+ color="dark"
+ variant="spinner"
+ />
+ {{ $options.I18N_LOADING_LABEL }}
+ </div>
+
+ <custom-email-confirm-modal
+ :visible="confirmModalVisible"
+ :custom-email="customEmail"
+ @remove="onConfirmModalProceed"
+ @cancel="onConfirmModalCanceled"
+ />
+
+ <gl-alert
+ v-if="alertMessage"
+ variant="warning"
+ class="gl-mt-n5 gl-mb-4 gl-mx-n5"
+ @dismiss="dismissAlert"
+ >
+ {{ alertMessage }}
+ </gl-alert>
+
+ <!-- Use v-show to preserve form data after verification failure
+ without the need to maintain a state in this component. -->
+ <custom-email-form
+ v-show="customEmailNotSetUp && !isLoading"
+ :incoming-email="incomingEmail"
+ :is-submitting="isSubmitting"
+ @submit="onSaveCustomEmail"
+ />
+
+ <custom-email
+ v-if="customEmail"
+ :custom-email="customEmail"
+ :smtp-address="smtpAddress"
+ :verification-state="verificationState"
+ :verification-error="verificationError"
+ :is-enabled="isEnabled"
+ :is-submitting="isSubmitting"
+ @toggle="onToggleCustomEmail"
+ @reset="onResetCustomEmail"
+ />
+ </template>
+
+ <template #footer>
+ <gl-sprintf :message="$options.I18N_FEEDBACK_PARAGRAPH">
+ <template #link="{ content }">
+ <gl-link
+ :href="$options.FEEDBACK_ISSUE_URL"
+ target="_blank"
+ class="gl-text-blue-600 font-size-inherit"
+ >{{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-card>
+ </div>
+ </div>
+</template>
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 ae28694f5d2..2b2722ab329 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
@@ -4,8 +4,11 @@ import SafeHtml from '~/vue_shared/directives/safe_html';
import axios from '~/lib/utils/axios_utils';
import { helpPagePath } from '~/helpers/help_page_helper';
import { __, sprintf } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ServiceDeskSetting from './service_desk_setting.vue';
+const CustomEmailWrapper = () => import('./custom_email_wrapper.vue');
+
export default {
serviceDeskEmailHelpPath: helpPagePath('/user/project/service_desk.html', {
anchor: 'use-an-additional-service-desk-alias-email',
@@ -15,10 +18,12 @@ export default {
GlSprintf,
GlLink,
ServiceDeskSetting,
+ CustomEmailWrapper,
},
directives: {
SafeHtml,
},
+ mixins: [glFeatureFlagsMixin()],
inject: {
initialIsEnabled: {
default: false,
@@ -56,6 +61,9 @@ export default {
publicProject: {
default: false,
},
+ customEmailEndpoint: {
+ default: '',
+ },
},
data() {
return {
@@ -68,6 +76,11 @@ export default {
updatedServiceDeskEmail: this.serviceDeskEmail,
};
},
+ computed: {
+ showCustomEmailWrapper() {
+ return this.glFeatures.serviceDeskCustomEmail && this.isEnabled && this.isIssueTrackerEnabled;
+ },
+ },
methods: {
onEnableToggled(isChecked) {
this.isEnabled = isChecked;
@@ -179,5 +192,10 @@ export default {
@save="onSaveTemplate"
@toggle="onEnableToggled"
/>
+ <custom-email-wrapper
+ v-if="showCustomEmailWrapper"
+ :incoming-email="incomingEmail"
+ :custom-email-endpoint="customEmailEndpoint"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js b/app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js
new file mode 100644
index 00000000000..cdf2e53982e
--- /dev/null
+++ b/app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js
@@ -0,0 +1,146 @@
+import { s__, __ } from '~/locale';
+
+export const FEEDBACK_ISSUE_URL = 'https://gitlab.com/gitlab-org/gitlab/-/issues/416637';
+
+export const I18N_LOADING_LABEL = __('Loading');
+export const I18N_CARD_TITLE = s__('ServiceDesk|Configure a custom email address');
+export const I18N_FEEDBACK_PARAGRAPH = s__(
+ 'ServiceDesk|Please share your feedback on this feature in the %{linkStart}feedback issue%{linkEnd}',
+);
+export const I18N_GENERIC_ERROR = __('An error occurred. Please try again.');
+
+export const I18N_TOAST_SAVED = s__(
+ 'ServiceDesk|Saved custom email address and started verification.',
+);
+export const I18N_TOAST_DELETED = s__('ServiceDesk|Reset custom email address.');
+export const I18N_TOAST_ENABLED = s__('ServiceDesk|Custom email enabled.');
+export const I18N_TOAST_DISABLED = s__('ServiceDesk|Custom email disabled.');
+
+export const I18N_FORM_INTRODUCTION_PARAGRAPH = s__(
+ 'ServiceDesk|Connect a custom email address your customers can use to create Service Desk issues. Forward all emails from your custom email address to the Service Desk email address of this project. GitLab will send Service Desk emails from the custom address on your behalf using your SMTP credentials.',
+);
+export const I18N_FORM_FORWARDING_LABEL = s__(
+ 'ServiceDesk|Service Desk email address to forward emails to',
+);
+export const I18N_FORM_FORWARDING_CLIPBOARD_BUTTON_TITLE = s__(
+ 'ServiceDesk|Copy Service Desk email address',
+);
+export const I18N_FORM_CUSTOM_EMAIL_LABEL = s__('ServiceDesk|Custom email address');
+export const I18N_FORM_CUSTOM_EMAIL_DESCRIPTION = s__(
+ 'ServiceDesk|Email address your customers can use to send support requests. It must support sub-addressing.',
+);
+export const I18N_FORM_SMTP_ADDRESS_LABEL = s__('ServiceDesk|SMTP host');
+export const I18N_FORM_SMTP_PORT_LABEL = s__('ServiceDesk|SMTP port');
+export const I18N_FORM_SMTP_PORT_DESCRIPTION = s__(
+ 'ServiceDesk|Common ports are 587 when using TLS, and 25 when not.',
+);
+export const I18N_FORM_SMTP_USERNAME_LABEL = s__('ServiceDesk|SMTP username');
+export const I18N_FORM_SMTP_PASSWORD_LABEL = s__('ServiceDesk|SMTP password');
+export const I18N_FORM_SMTP_PASSWORD_DESCRIPTION = s__('ServiceDesk|Minimum 8 characters long.');
+export const I18N_FORM_SUBMIT_LABEL = s__('ServiceDesk|Save and test connection');
+
+export const I18N_FORM_INVALID_FEEDBACK_CUSTOM_EMAIL = s__(
+ 'ServiceDesk|Custom email is required and must be a valid email address.',
+);
+export const I18N_FORM_INVALID_FEEDBACK_SMTP_ADDRESS = s__(
+ 'ServiceDesk|SMTP address is required and must be resolvable.',
+);
+export const I18N_FORM_INVALID_FEEDBACK_SMTP_PORT = s__(
+ 'ServiceDesk|SMTP port is required and must be a port number larger than 0.',
+);
+export const I18N_FORM_INVALID_FEEDBACK_SMTP_USERNAME = s__(
+ 'ServiceDesk|SMTP username is required.',
+);
+export const I18N_FORM_INVALID_FEEDBACK_SMTP_PASSWORD = s__(
+ 'ServiceDesk|SMTP password is required and must be at least 8 characters long.',
+);
+
+export const I18N_MODAL_TITLE = s__(
+ 'ServiceDesk|Reset custom email address and delete credentials',
+);
+export const I18N_MODAL_CANCEL_BUTTON_LABEL = s__('ServiceDesk|Keep custom email');
+export const I18N_MODAL_DISABLE_CUSTOM_EMAIL_PARAGRAPH = s__(
+ 'ServiceDesk|You are about to %{strongStart}disable the custom email address%{strongEnd} %{customEmail} %{strongStart}and delete its credentials%{strongEnd}.',
+);
+export const I18N_MODAL_SET_UP_AGAIN_PARAGRAPH = s__(
+ "ServiceDesk|To use a custom email address for this Service Desk, you'll need to configure and verify an email address again.",
+);
+
+export const I18N_STATE_INTRO_PARAGRAPH = s__(
+ 'ServiceDesk|Verify %{customEmail} with SMTP host %{smtpAddress}:',
+);
+export const I18N_STATE_VERIFICATION_STARTED = s__('ServiceDesk|Verification started');
+export const I18N_STATE_VERIFICATION_STARTED_RESET_PARAGRAPH = s__(
+ 'ServiceDesk|A verification email has been sent to a sub-address of your custom email address. This can take up to 30 minutes. The screen refreshes automatically.',
+);
+export const I18N_RESET_BUTTON_LABEL = s__('ServiceDesk|Reset custom email');
+
+export const I18N_STATE_VERIFICATION_FINISHED_INTRO_PARAGRAPH = s__(
+ 'ServiceDesk|%{customEmail} with SMTP host %{smtpAddress} is %{badgeStart}verified%{badgeEnd}',
+);
+export const I18N_STATE_VERIFICATION_FINISHED_TOGGLE_LABEL = s__(
+ 'ServiceDesk|Enable custom email address',
+);
+export const I18N_STATE_VERIFICATION_FINISHED_TOGGLE_HELP = s__(
+ 'ServiceDesk|When enabled, Service Desk emails will be sent using the provided credentials.',
+);
+export const I18N_STATE_VERIFICATION_FINISHED_RESET_PARAGRAPH = s__(
+ 'ServiceDesk|Or reset and connect a new custom email address to this Service Desk.',
+);
+
+export const I18N_STATE_VERIFICATION_FAILED = s__('ServiceDesk|Verification failed');
+export const I18N_STATE_VERIFICATION_FAILED_RESET_PARAGRAPH = s__(
+ 'ServiceDesk|Please try again. Check email forwarding settings and credentials, and then restart verification.',
+);
+
+export const I18N_STATE_RESET_PARAGRAPH = {
+ started: I18N_STATE_VERIFICATION_STARTED_RESET_PARAGRAPH,
+ failed: I18N_STATE_VERIFICATION_FAILED_RESET_PARAGRAPH,
+ finished: I18N_STATE_VERIFICATION_FINISHED_RESET_PARAGRAPH,
+};
+
+export const I18N_ERROR_SMTP_HOST_ISSUE_LABEL = s__('ServiceDesk|SMTP host issue');
+export const I18N_ERROR_SMTP_HOST_ISSUE_DESC = s__(
+ 'ServiceDesk|A connection to the specified host could not be made or an SSL issue occurred.',
+);
+export const I18N_ERROR_INVALID_CREDENTIALS_LABEL = s__('ServiceDesk|Invalid credentials');
+export const I18N_ERROR_INVALID_CREDENTIALS_DESC = s__(
+ 'ServiceDesk|The given credentials (username and password) were rejected by the SMTP server.',
+);
+export const I18N_ERROR_MAIL_NOT_RECEIVED_IN_TIMEFRAME_LABEL = s__(
+ 'ServiceDesk|Verification email not received within timeframe',
+);
+export const I18N_ERROR_MAIL_NOT_RECEIVED_IN_TIMEFRAME_DESC = s__(
+ "ServiceDesk|The verification email wasn't received in time. There is a 30 minutes timeframe for verification emails to appear in your instance's Service Desk. Make sure that you have set up email forwarding correctly.",
+);
+export const I18N_ERROR_INCORRECT_FROM_LABEL = s__('ServiceDesk|Incorrect From header');
+export const I18N_ERROR_INCORRECT_FROM_DESC = s__(
+ 'ServiceDesk|Check your forwarding settings and make sure the original email sender remains in the From header.',
+);
+export const I18N_ERROR_INCORRECT_TOKEN_LABEL = s__('ServiceDesk|Incorrect verification token');
+export const I18N_ERROR_INCORRECT_TOKEN_DESC = s__(
+ "ServiceDesk|The received email didn't contain the verification token that was sent to your email address.",
+);
+
+export const I18N_VERIFICATION_ERRORS = {
+ smtp_host_issue: {
+ label: I18N_ERROR_SMTP_HOST_ISSUE_LABEL,
+ description: I18N_ERROR_SMTP_HOST_ISSUE_DESC,
+ },
+ invalid_credentials: {
+ label: I18N_ERROR_INVALID_CREDENTIALS_LABEL,
+ description: I18N_ERROR_INVALID_CREDENTIALS_DESC,
+ },
+ mail_not_received_within_timeframe: {
+ label: I18N_ERROR_MAIL_NOT_RECEIVED_IN_TIMEFRAME_LABEL,
+ description: I18N_ERROR_MAIL_NOT_RECEIVED_IN_TIMEFRAME_DESC,
+ },
+ incorrect_from: {
+ label: I18N_ERROR_INCORRECT_FROM_LABEL,
+ description: I18N_ERROR_INCORRECT_FROM_DESC,
+ },
+ incorrect_token: {
+ label: I18N_ERROR_INCORRECT_TOKEN_LABEL,
+ description: I18N_ERROR_INCORRECT_TOKEN_DESC,
+ },
+};
diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js
index 0f4c747a7b6..c4d4f42576f 100644
--- a/app/assets/javascripts/projects/settings_service_desk/index.js
+++ b/app/assets/javascripts/projects/settings_service_desk/index.js
@@ -1,7 +1,10 @@
+import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import ServiceDeskRoot from './components/service_desk_root.vue';
+Vue.use(GlToast);
+
export default () => {
const el = document.querySelector('.js-service-desk-setting-root');
@@ -22,6 +25,7 @@ export default () => {
selectedFileTemplateProjectId,
templates,
publicProject,
+ customEmailEndpoint,
} = el.dataset;
return new Vue({
@@ -39,6 +43,7 @@ export default () => {
selectedFileTemplateProjectId: parseInt(selectedFileTemplateProjectId, 10) || null,
templates: JSON.parse(templates),
publicProject: parseBoolean(publicProject),
+ customEmailEndpoint,
},
render: (createElement) => createElement(ServiceDeskRoot),
});
diff --git a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
deleted file mode 100644
index 4094c300a50..00000000000
--- a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { __ } from '~/locale';
-
-export default class ProtectedTagAccessDropdown {
- constructor(options) {
- this.options = options;
- this.initDropdown();
- }
-
- initDropdown() {
- const { onSelect } = this.options;
- initDeprecatedJQueryDropdown(this.options.$dropdown, {
- data: this.options.data,
- selectable: true,
- inputId: this.options.$dropdown.data('inputId'),
- fieldName: this.options.$dropdown.data('fieldName'),
- toggleLabel(item, $el) {
- if ($el.is('.is-active')) {
- return item.text;
- }
- return __('Select');
- },
- clicked(options) {
- options.e.preventDefault();
- onSelect();
- },
- });
- }
-}
diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue
index 7f58b394547..e5f5800c99c 100644
--- a/app/assets/javascripts/ref/components/ref_selector.vue
+++ b/app/assets/javascripts/ref/components/ref_selector.vue
@@ -1,6 +1,7 @@
<script>
import { GlBadge, GlIcon, GlCollapsibleListbox } from '@gitlab/ui';
import { debounce, isArray } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import { sprintf } from '~/locale';
import {
diff --git a/app/assets/javascripts/ref/stores/index.js b/app/assets/javascripts/ref/stores/index.js
index fb2196fa1d0..d97d7578b00 100644
--- a/app/assets/javascripts/ref/stores/index.js
+++ b/app/assets/javascripts/ref/stores/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue
index 516162b57b5..c68fbceb4f6 100644
--- a/app/assets/javascripts/releases/components/app_edit_new.vue
+++ b/app/assets/javascripts/releases/components/app_edit_new.vue
@@ -8,6 +8,7 @@ import {
GlLink,
GlSprintf,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions, mapGetters } from 'vuex';
import { isSameOriginUrl, getParameterByName } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue
index dc465851721..81986456ca4 100644
--- a/app/assets/javascripts/releases/components/asset_links_form.vue
+++ b/app/assets/javascripts/releases/components/asset_links_form.vue
@@ -9,6 +9,7 @@ import {
GlFormInput,
GlFormSelect,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions, mapGetters } from 'vuex';
import { s__ } from '~/locale';
import { DEFAULT_ASSET_LINK_TYPE, ASSET_LINK_TYPE } from '../constants';
diff --git a/app/assets/javascripts/releases/components/confirm_delete_modal.vue b/app/assets/javascripts/releases/components/confirm_delete_modal.vue
index aa948fbbaf6..d42cf267064 100644
--- a/app/assets/javascripts/releases/components/confirm_delete_modal.vue
+++ b/app/assets/javascripts/releases/components/confirm_delete_modal.vue
@@ -1,5 +1,6 @@
<script>
import { GlModal, GlSprintf, GlLink, GlButton } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import { __, s__, sprintf } from '~/locale';
diff --git a/app/assets/javascripts/releases/components/tag_create.vue b/app/assets/javascripts/releases/components/tag_create.vue
index 44269bccec9..4fea93f5b81 100644
--- a/app/assets/javascripts/releases/components/tag_create.vue
+++ b/app/assets/javascripts/releases/components/tag_create.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlFormGroup, GlFormInput, GlFormTextarea } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
import { uniqueId } from 'lodash';
import { __, s__ } from '~/locale';
diff --git a/app/assets/javascripts/releases/components/tag_field.vue b/app/assets/javascripts/releases/components/tag_field.vue
index b4fea9bee35..17bcdb22350 100644
--- a/app/assets/javascripts/releases/components/tag_field.vue
+++ b/app/assets/javascripts/releases/components/tag_field.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import TagFieldExisting from './tag_field_existing.vue';
import TagFieldNew from './tag_field_new.vue';
diff --git a/app/assets/javascripts/releases/components/tag_field_existing.vue b/app/assets/javascripts/releases/components/tag_field_existing.vue
index 11945fbaf3d..ca899420c02 100644
--- a/app/assets/javascripts/releases/components/tag_field_existing.vue
+++ b/app/assets/javascripts/releases/components/tag_field_existing.vue
@@ -1,6 +1,7 @@
<script>
import { GlFormGroup, GlFormInput } from '@gitlab/ui';
import { uniqueId } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import FormFieldContainer from './form_field_container.vue';
diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue
index ec058cc3603..fe996a2a734 100644
--- a/app/assets/javascripts/releases/components/tag_field_new.vue
+++ b/app/assets/javascripts/releases/components/tag_field_new.vue
@@ -1,5 +1,6 @@
<script>
import { GlDropdown, GlFormGroup, GlPopover } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions, mapGetters } from 'vuex';
import { __, s__ } from '~/locale';
@@ -95,7 +96,6 @@ export default {
:state="!showTagNameValidationError"
:invalid-feedback="tagFeedback"
optional
- data-testid="tag-name-field"
>
<gl-dropdown
:id="id"
diff --git a/app/assets/javascripts/releases/components/tag_search.vue b/app/assets/javascripts/releases/components/tag_search.vue
index 33b44c90e1f..791b5e0e2a0 100644
--- a/app/assets/javascripts/releases/components/tag_search.vue
+++ b/app/assets/javascripts/releases/components/tag_search.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlDropdownItem, GlSearchBoxByType, GlSprintf } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
import { debounce } from 'lodash';
import { REF_TYPE_TAGS, SEARCH_DEBOUNCE_MS } from '~/ref/constants';
diff --git a/app/assets/javascripts/releases/mount_edit.js b/app/assets/javascripts/releases/mount_edit.js
index c3130a0b778..ae67d5eba35 100644
--- a/app/assets/javascripts/releases/mount_edit.js
+++ b/app/assets/javascripts/releases/mount_edit.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import ReleaseEditNewApp from './components/app_edit_new.vue';
import createStore from './stores';
diff --git a/app/assets/javascripts/releases/mount_new.js b/app/assets/javascripts/releases/mount_new.js
index efd82edcdf0..ff8da047061 100644
--- a/app/assets/javascripts/releases/mount_new.js
+++ b/app/assets/javascripts/releases/mount_new.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import { createRefModule } from '../ref/stores';
import ReleaseEditNewApp from './components/app_edit_new.vue';
diff --git a/app/assets/javascripts/releases/stores/index.js b/app/assets/javascripts/releases/stores/index.js
index b2e93d789d7..825bbd30852 100644
--- a/app/assets/javascripts/releases/stores/index.js
+++ b/app/assets/javascripts/releases/stores/index.js
@@ -1,3 +1,4 @@
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
export default ({ modules, featureFlags }) =>
diff --git a/app/assets/javascripts/repository/commits_service.js b/app/assets/javascripts/repository/commits_service.js
index e26036b5620..1e0de045d39 100644
--- a/app/assets/javascripts/repository/commits_service.js
+++ b/app/assets/javascripts/repository/commits_service.js
@@ -24,7 +24,7 @@ const addRequestedOffset = (offset) => {
const removeLeadingSlash = (path) => path.replace(/^\//, '');
-const fetchData = (projectPath, path, ref, offset) => {
+const fetchData = (projectPath, path, ref, offset, refType) => {
if (fetchedBatches.includes(offset) || offset < 0) {
return [];
}
@@ -41,12 +41,12 @@ const fetchData = (projectPath, path, ref, offset) => {
);
return axios
- .get(url, { params: { format: 'json', offset } })
+ .get(url, { params: { format: 'json', offset, ref_type: refType } })
.then(({ data }) => normalizeData(data, path))
.catch(() => createAlert({ message: I18N_COMMIT_DATA_FETCH_ERROR }));
};
-export const loadCommits = async (projectPath, path, ref, offset) => {
+export const loadCommits = async (projectPath, path, ref, offset, refType) => {
if (isRequested(offset)) {
return [];
}
@@ -54,7 +54,7 @@ export const loadCommits = async (projectPath, path, ref, offset) => {
// We fetch in batches of 25, so this ensures we don't refetch
Array.from(Array(COMMIT_BATCH_SIZE)).forEach((_, i) => addRequestedOffset(offset + i));
- const commits = await fetchData(projectPath, path, ref, offset);
+ const commits = await fetchData(projectPath, path, ref, offset, refType);
return commits;
};
diff --git a/app/assets/javascripts/repository/components/blob_button_group.vue b/app/assets/javascripts/repository/components/blob_button_group.vue
index d79ccde61a8..99b861ca104 100644
--- a/app/assets/javascripts/repository/components/blob_button_group.vue
+++ b/app/assets/javascripts/repository/components/blob_button_group.vue
@@ -76,6 +76,11 @@ export default {
type: Boolean,
required: true,
},
+ isUsingLfs: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
replaceModalTitle() {
@@ -148,6 +153,7 @@ export default {
:can-push-code="canPushCode"
:can-push-to-branch="canPushToBranch"
:empty-repo="emptyRepo"
+ :is-using-lfs="isUsingLfs"
/>
</div>
</template>
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index 969036f84b7..6f9f0a81dfd 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -21,7 +21,13 @@ import projectInfoQuery from '../queries/project_info.query.graphql';
import getRefMixin from '../mixins/get_ref';
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';
+import {
+ DEFAULT_BLOB_INFO,
+ TEXT_FILE_TYPE,
+ LFS_STORAGE,
+ LEGACY_FILE_TYPES,
+ CODEOWNERS_FILE_NAME,
+} from '../constants';
import BlobButtonGroup from './blob_button_group.vue';
import ForkSuggestion from './fork_suggestion.vue';
import { loadViewer } from './blob_viewers';
@@ -32,6 +38,7 @@ export default {
BlobButtonGroup,
BlobContent,
GlLoadingIcon,
+ CodeownersValidation: () => import('ee_component/blob/components/codeowners_validation.vue'),
GlButton,
ForkSuggestion,
WebIdeLink,
@@ -76,12 +83,15 @@ export default {
project: {
query: blobInfoQuery,
variables() {
- return {
+ const queryVariables = {
projectPath: this.projectPath,
filePath: this.path,
- ref: this.originalBranch || this.ref,
- shouldFetchRawText: Boolean(this.glFeatures.highlightJs),
+ ref: this.currentRef,
+ refType: this.refType?.toUpperCase() || null,
+ shouldFetchRawText: true,
};
+
+ return queryVariables;
},
result({ data }) {
const blob = data.project?.repository?.blobs?.nodes[0] || {};
@@ -130,6 +140,11 @@ export default {
type: String,
required: true,
},
+ refType: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -163,6 +178,12 @@ export default {
return nodes[0] || {};
},
+ currentRef() {
+ return this.originalBranch || this.ref;
+ },
+ isCodeownersFile() {
+ return this.path.includes(CODEOWNERS_FILE_NAME);
+ },
viewer() {
const { richViewer, simpleViewer } = this.blobInfo;
return this.activeViewerType === RICH_BLOB_VIEWER ? richViewer : simpleViewer;
@@ -185,8 +206,7 @@ export default {
);
},
shouldLoadLegacyViewer() {
- const isTextFile = this.viewer.fileType === TEXT_FILE_TYPE && !this.glFeatures.highlightJs;
- return isTextFile || LEGACY_FILE_TYPES.includes(this.blobInfo.fileType) || this.useFallback;
+ return LEGACY_FILE_TYPES.includes(this.blobInfo.fileType) || this.useFallback;
},
legacyViewerLoaded() {
return (
@@ -213,7 +233,9 @@ export default {
const { createMergeRequestIn, forkProject } = this.userPermissions;
const { canModifyBlob } = this.blobInfo;
- return this.isLoggedIn && !canModifyBlob && createMergeRequestIn && forkProject;
+ return (
+ this.isLoggedIn && !this.isUsingLfs && !canModifyBlob && createMergeRequestIn && forkProject
+ );
},
forkPath() {
const forkPaths = {
@@ -259,8 +281,12 @@ export default {
const type = this.activeViewerType;
this.isLoadingLegacyViewer = true;
+
+ const newUrl = new URL(this.blobInfo.webPath, window.location.origin);
+ newUrl.searchParams.set('format', 'json');
+ newUrl.searchParams.set('viewer', type);
axios
- .get(`${this.blobInfo.webPath}?format=json&viewer=${type}`)
+ .get(newUrl.pathname + newUrl.search)
.then(async ({ data: { html, binary } }) => {
this.isRenderingLegacyTextViewer = true;
@@ -343,7 +369,7 @@ export default {
:active-viewer-type="viewer.type"
:has-render-error="hasRenderError"
:show-path="false"
- :override-copy="glFeatures.highlightJs"
+ :override-copy="true"
@viewer-changed="handleViewerChanged"
@copy="onCopy"
>
@@ -382,6 +408,7 @@ export default {
:is-locked="Boolean(pathLockedByUser)"
:can-lock="canLock"
:show-fork-suggestion="showForkSuggestion"
+ :is-using-lfs="isUsingLfs"
@fork="setForkTarget('view')"
/>
</template>
@@ -391,6 +418,12 @@ export default {
:fork-path="forkPath"
@cancel="setForkTarget(null)"
/>
+ <codeowners-validation
+ v-if="isCodeownersFile"
+ :current-ref="currentRef"
+ :project-path="projectPath"
+ :file-path="path"
+ />
<blob-content
v-if="!blobViewer"
class="js-syntax-highlight"
@@ -416,12 +449,12 @@ export default {
:code-navigation-path="blobInfo.codeNavigationPath"
:blob-path="blobInfo.path"
:path-prefix="blobInfo.projectBlobPathRoot"
- :wrap-text-nodes="glFeatures.highlightJs"
+ :wrap-text-nodes="true"
/>
</div>
<ai-genie
v-if="explainCodeAvailable"
- container-id="fileHolder"
+ container-selector=".file-content"
:file-path="path"
class="gl-ml-7"
/>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue
index 014f1abc121..9a8bb8e4aa6 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue
@@ -8,7 +8,7 @@ export default {
},
data() {
return {
- url: this.blob.rawPath,
+ url: this.blob.externalStorageUrl || this.blob.rawPath,
alt: this.blob.name,
};
},
diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js
index 368f42e0064..d434700b29f 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/index.js
+++ b/app/assets/javascripts/repository/components/blob_viewers/index.js
@@ -1,4 +1,6 @@
-const viewers = {
+import { TEXT_FILE_TYPE, JSON_LANGUAGE } from '../../constants';
+
+export const viewers = {
csv: () => import('./csv_viewer.vue'),
download: () => import('./download_viewer.vue'),
image: () => import('./image_viewer.vue'),
@@ -18,7 +20,7 @@ const viewers = {
export const loadViewer = (type, isUsingLfs, hljsWorkerEnabled, language) => {
let viewer = viewers[type];
- if (hljsWorkerEnabled && language === 'json') {
+ if (hljsWorkerEnabled && language === JSON_LANGUAGE && type === TEXT_FILE_TYPE) {
// The New Source Viewer currently only supports JSON files.
// More language support will be added in: https://gitlab.com/gitlab-org/gitlab/-/issues/415753
viewer = () => import('~/vue_shared/components/source_viewer/source_viewer_new.vue');
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
index d498be0b2bb..b347f97a5ae 100644
--- a/app/assets/javascripts/repository/components/breadcrumbs.vue
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -1,7 +1,8 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlDisclosureDropdown, GlModalDirective } from '@gitlab/ui';
import permissionsQuery from 'shared_queries/repository/permissions.query.graphql';
-import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
+import { joinPaths, escapeFileUrl, buildURLwithRefType } from '~/lib/utils/url_utility';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import { __ } from '~/locale';
import getRefMixin from '../mixins/get_ref';
@@ -49,6 +50,11 @@ export default {
required: false,
default: '',
},
+ refType: {
+ type: String,
+ required: false,
+ default: null,
+ },
canCollaborate: {
type: Boolean,
required: false,
@@ -141,14 +147,17 @@ export default {
return acc.concat({
name,
path,
- to,
+ to: buildURLwithRefType({ path: to, refType: this.refType }),
});
},
[
{
name: this.projectShortPath,
path: '/',
- to: `/-/tree/${this.escapedRef}/`,
+ to: buildURLwithRefType({
+ path: joinPaths('/-/tree', this.escapedRef),
+ refType: this.refType,
+ }),
},
],
);
diff --git a/app/assets/javascripts/repository/components/delete_blob_modal.vue b/app/assets/javascripts/repository/components/delete_blob_modal.vue
index cbdf6ef9ccd..97171a3282b 100644
--- a/app/assets/javascripts/repository/components/delete_blob_modal.vue
+++ b/app/assets/javascripts/repository/components/delete_blob_modal.vue
@@ -1,8 +1,18 @@
<script>
-import { GlModal, GlFormGroup, GlFormInput, GlFormTextarea, GlToggle, GlForm } from '@gitlab/ui';
+import {
+ GlModal,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlToggle,
+ GlForm,
+ GlSprintf,
+ GlLink,
+} from '@gitlab/ui';
import csrf from '~/lib/utils/csrf';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import validation from '~/vue_shared/directives/validation';
+import { helpPagePath } from '~/helpers/help_page_helper';
import {
SECONDARY_OPTIONS_TEXT,
COMMIT_LABEL,
@@ -28,8 +38,19 @@ export default {
GlFormTextarea,
GlToggle,
GlForm,
+ GlSprintf,
+ GlLink,
},
i18n: {
+ LFS_WARNING_TITLE: __("The file you're about to delete is tracked by LFS"),
+ LFS_WARNING_PRIMARY_CONTENT: s__(
+ 'BlobViewer|If you delete the file, it will be removed from the branch %{branch}.',
+ ),
+ LFS_WARNING_SECONDARY_CONTENT: s__(
+ 'BlobViewer|This file will still take up space in your LFS storage. %{linkStart}How do I remove tracked objects from Git LFS?%{linkEnd}',
+ ),
+ LFS_CONTINUE_TEXT: __('Continue…'),
+ LFS_CANCEL_TEXT: __('Cancel'),
PRIMARY_OPTIONS_TEXT: __('Delete file'),
SECONDARY_OPTIONS_TEXT,
COMMIT_LABEL,
@@ -79,6 +100,11 @@ export default {
type: Boolean,
required: true,
},
+ isUsingLfs: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
const form = {
@@ -91,6 +117,7 @@ export default {
},
};
return {
+ lfsWarningDismissed: false,
loading: false,
createNewMr: true,
error: '',
@@ -99,7 +126,7 @@ export default {
},
computed: {
primaryOptions() {
- return {
+ const defaultOptions = {
text: this.$options.i18n.PRIMARY_OPTIONS_TEXT,
attributes: {
variant: 'danger',
@@ -107,6 +134,13 @@ export default {
disabled: this.loading || !this.form.state,
},
};
+
+ const lfsWarningOptions = {
+ text: this.$options.i18n.LFS_CONTINUE_TEXT,
+ attributes: { variant: 'confirm' },
+ };
+
+ return this.showLfsWarning ? lfsWarningOptions : defaultOptions;
},
cancelOptions() {
return {
@@ -139,14 +173,39 @@ export default {
(hasFirstLineExceedMaxLength || hasOtherLineExceedMaxLength)
);
},
- /* eslint-enable dot-notation */
+ showLfsWarning() {
+ return this.isUsingLfs && !this.lfsWarningDismissed;
+ },
+ title() {
+ return this.showLfsWarning ? this.$options.i18n.LFS_WARNING_TITLE : this.modalTitle;
+ },
+ showDeleteForm() {
+ return !this.isUsingLfs || (this.isUsingLfs && this.lfsWarningDismissed);
+ },
},
methods: {
show() {
this.$refs[this.modalId].show();
+ this.lfsWarningDismissed = false;
+ },
+ cancel() {
+ this.$refs[this.modalId].hide();
},
- submitForm(e) {
+ async handleContinueLfsWarning() {
+ this.lfsWarningDismissed = true;
+ await this.$nextTick();
+ this.$refs.message?.$el.focus();
+ },
+ async handlePrimaryAction(e) {
e.preventDefault(); // Prevent modal from closing
+
+ if (this.showLfsWarning) {
+ this.lfsWarningDismissed = true;
+ await this.$nextTick();
+ this.$refs.message?.$el.focus();
+ return;
+ }
+
this.form.showValidation = true;
if (!this.form.state) {
@@ -158,6 +217,7 @@ export default {
this.$refs.form.$el.submit();
},
},
+ deleteLfsHelpPath: helpPagePath('topics/git/lfs/index', { anchor: 'removing-objects-from-lfs' }),
};
</script>
@@ -165,67 +225,86 @@ export default {
<gl-modal
:ref="modalId"
v-bind="$attrs"
- data-testid="modal-delete"
:modal-id="modalId"
- :title="modalTitle"
+ :title="title"
:action-primary="primaryOptions"
:action-cancel="cancelOptions"
- @primary="submitForm"
+ @primary="handlePrimaryAction"
>
- <gl-form ref="form" novalidate :action="deletePath" method="post">
- <input type="hidden" name="_method" value="delete" />
- <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
- <template v-if="emptyRepo">
- <input type="hidden" name="branch_name" :value="originalBranch" class="js-branch-name" />
- </template>
- <template v-else>
- <input type="hidden" name="original_branch" :value="originalBranch" />
- <input
- v-if="createNewMr || !canPushToBranch"
- type="hidden"
- name="create_merge_request"
- value="1"
- />
- <gl-form-group
- :label="$options.i18n.COMMIT_LABEL"
- label-for="commit_message"
- :invalid-feedback="form.fields['commit_message'].feedback"
- >
- <gl-form-textarea
- v-model="form.fields['commit_message'].value"
- v-validation:[form.showValidation]
- name="commit_message"
- data-qa-selector="commit_message_field"
- :state="form.fields['commit_message'].state"
- :disabled="loading"
- required
+ <div v-if="showLfsWarning">
+ <p>
+ <gl-sprintf :message="$options.i18n.LFS_WARNING_PRIMARY_CONTENT">
+ <template #branch>
+ <code>{{ targetBranch }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <p>
+ <gl-sprintf :message="$options.i18n.LFS_WARNING_SECONDARY_CONTENT">
+ <template #link="{ content }">
+ <gl-link :href="$options.deleteLfsHelpPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+ <div v-if="showDeleteForm">
+ <gl-form ref="form" novalidate :action="deletePath" method="post">
+ <input type="hidden" name="_method" value="delete" />
+ <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+ <template v-if="emptyRepo">
+ <input type="hidden" name="branch_name" :value="originalBranch" class="js-branch-name" />
+ </template>
+ <template v-else>
+ <input type="hidden" name="original_branch" :value="originalBranch" />
+ <input
+ v-if="createNewMr || !canPushToBranch"
+ type="hidden"
+ name="create_merge_request"
+ value="1"
/>
- <p v-if="showHint" class="form-text gl-text-gray-600" data-testid="hint">
- {{ $options.i18n.COMMIT_MESSAGE_HINT }}
- </p>
- </gl-form-group>
- <gl-form-group
- v-if="canPushCode"
- :label="$options.i18n.TARGET_BRANCH_LABEL"
- label-for="branch_name"
- :invalid-feedback="form.fields['branch_name'].feedback"
- >
- <gl-form-input
- v-model="form.fields['branch_name'].value"
- v-validation:[form.showValidation]
- :state="form.fields['branch_name'].state"
+ <gl-form-group
+ :label="$options.i18n.COMMIT_LABEL"
+ label-for="commit_message"
+ :invalid-feedback="form.fields['commit_message'].feedback"
+ >
+ <gl-form-textarea
+ ref="message"
+ v-model="form.fields['commit_message'].value"
+ v-validation:[form.showValidation]
+ name="commit_message"
+ data-qa-selector="commit_message_field"
+ :state="form.fields['commit_message'].state"
+ :disabled="loading"
+ required
+ />
+ <p v-if="showHint" class="form-text gl-text-gray-600" data-testid="hint">
+ {{ $options.i18n.COMMIT_MESSAGE_HINT }}
+ </p>
+ </gl-form-group>
+ <gl-form-group
+ v-if="canPushCode"
+ :label="$options.i18n.TARGET_BRANCH_LABEL"
+ label-for="branch_name"
+ :invalid-feedback="form.fields['branch_name'].feedback"
+ >
+ <gl-form-input
+ v-model="form.fields['branch_name'].value"
+ v-validation:[form.showValidation]
+ :state="form.fields['branch_name'].state"
+ :disabled="loading"
+ name="branch_name"
+ required
+ />
+ </gl-form-group>
+ <gl-toggle
+ v-if="showCreateNewMrToggle"
+ v-model="createNewMr"
:disabled="loading"
- name="branch_name"
- required
+ :label="$options.i18n.TOGGLE_CREATE_MR_LABEL"
/>
- </gl-form-group>
- <gl-toggle
- v-if="showCreateNewMrToggle"
- v-model="createNewMr"
- :disabled="loading"
- :label="$options.i18n.TOGGLE_CREATE_MR_LABEL"
- />
- </template>
- </gl-form>
+ </template>
+ </gl-form>
+ </div>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index bdc9ed210ed..fa51ef30546 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -43,6 +43,7 @@ export default {
return {
projectPath: this.projectPath,
ref: this.ref,
+ refType: this.refType?.toUpperCase(),
path: this.currentPath.replace(/^\//, ''),
};
},
@@ -69,6 +70,11 @@ export default {
required: false,
default: '',
},
+ refType: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue
index 90949536cc1..bdcacd80b30 100644
--- a/app/assets/javascripts/repository/components/preview/index.vue
+++ b/app/assets/javascripts/repository/components/preview/index.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
diff --git a/app/assets/javascripts/repository/components/table/header.vue b/app/assets/javascripts/repository/components/table/header.vue
index 9d30aa88155..f99cfea2e6e 100644
--- a/app/assets/javascripts/repository/components/table/header.vue
+++ b/app/assets/javascripts/repository/components/table/header.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<template>
<thead>
<tr>
diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue
index 46d546c2ee4..557e9cd168f 100644
--- a/app/assets/javascripts/repository/components/table/index.vue
+++ b/app/assets/javascripts/repository/components/table/index.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlSkeletonLoader, GlButton } from '@gitlab/ui';
import { sprintf, __ } from '~/locale';
diff --git a/app/assets/javascripts/repository/components/table/parent_row.vue b/app/assets/javascripts/repository/components/table/parent_row.vue
index 8a081944600..0bc22253bd2 100644
--- a/app/assets/javascripts/repository/components/table/parent_row.vue
+++ b/app/assets/javascripts/repository/components/table/parent_row.vue
@@ -1,5 +1,6 @@
<script>
import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+import { joinPaths, buildURLwithRefType } from '~/lib/utils/url_utility';
export default {
components: {
@@ -8,6 +9,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ inject: ['refType'],
props: {
commitRef: {
type: String,
@@ -31,7 +33,9 @@ export default {
return splitArray.map((p) => encodeURIComponent(p)).join('/');
},
parentRoute() {
- return { path: `/-/tree/${this.commitRef}/${this.parentPath}` };
+ const path = joinPaths('/-/tree', this.commitRef, this.parentPath);
+
+ return buildURLwithRefType({ path, refType: this.refType });
},
},
methods: {
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index 6dd059a349f..c839d7a53cd 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import {
GlBadge,
@@ -12,11 +13,10 @@ import {
import { escapeRegExp } from 'lodash';
import SafeHtml from '~/vue_shared/directives/safe_html';
import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql';
-import { escapeFileUrl } from '~/lib/utils/url_utility';
+import { buildURLwithRefType, joinPaths } from '~/lib/utils/url_utility';
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';
@@ -36,7 +36,8 @@ export default {
GlHoverLoad: GlHoverLoadDirective,
SafeHtml,
},
- mixins: [getRefMixin, glFeatureFlagMixin()],
+ mixins: [getRefMixin],
+ inject: ['refType'],
props: {
commitInfo: {
type: Object,
@@ -117,14 +118,18 @@ export default {
return this.commitInfo;
},
routerLinkTo() {
- const blobRouteConfig = { path: `/-/blob/${this.escapedRef}/${escapeFileUrl(this.path)}` };
- const treeRouteConfig = { path: `/-/tree/${this.escapedRef}/${escapeFileUrl(this.path)}` };
-
if (this.isBlob) {
- return blobRouteConfig;
+ return buildURLwithRefType({
+ path: joinPaths('/-/blob', this.escapedRef, this.path),
+ refType: this.refType,
+ });
+ } else if (this.isFolder) {
+ return buildURLwithRefType({
+ path: joinPaths('/-/tree', this.escapedRef, this.path),
+ refType: this.refType,
+ });
}
-
- return this.isFolder ? treeRouteConfig : null;
+ return null;
},
isBlob() {
return this.type === 'blob';
@@ -159,6 +164,7 @@ export default {
this.apolloQuery(paginatedTreeQuery, {
projectPath: this.projectPath,
ref: this.ref,
+ refType: this.refType?.toUpperCase() || null,
path: this.path,
nextPageCursor: '',
pageSize: TREE_PAGE_SIZE,
@@ -169,7 +175,8 @@ export default {
projectPath: this.projectPath,
filePath: this.path,
ref: this.ref,
- shouldFetchRawText: Boolean(this.glFeatures.highlightJs),
+ refType: this.refType?.toUpperCase() || null,
+ shouldFetchRawText: true,
});
},
apolloQuery(query, variables) {
diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue
index 0c9b46344c5..dd2cfddc94e 100644
--- a/app/assets/javascripts/repository/components/tree_content.vue
+++ b/app/assets/javascripts/repository/components/tree_content.vue
@@ -27,6 +27,7 @@ export default {
query: projectPathQuery,
},
},
+ inject: ['refType'],
props: {
path: {
type: String,
@@ -99,6 +100,7 @@ export default {
variables: {
projectPath: this.projectPath,
ref: this.ref,
+ refType: this.refType?.toUpperCase(),
path: originalPath,
nextPageCursor: this.nextPageCursor,
pageSize: TREE_PAGE_SIZE,
@@ -171,7 +173,7 @@ export default {
}
},
loadCommitData(rowNumber) {
- loadCommits(this.projectPath, this.path, this.ref, rowNumber)
+ loadCommits(this.projectPath, this.path, this.ref, rowNumber, this.refType)
.then(this.setCommitData)
.catch(() => {});
},
diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js
index b711f671850..3079ef0bfbb 100644
--- a/app/assets/javascripts/repository/constants.js
+++ b/app/assets/javascripts/repository/constants.js
@@ -83,6 +83,8 @@ export const DEFAULT_BLOB_INFO = {
},
};
+export const JSON_LANGUAGE = 'json';
+export const OPENAPI_FILE_TYPE = 'openapi';
export const TEXT_FILE_TYPE = 'text';
export const LFS_STORAGE = 'lfs';
@@ -114,3 +116,5 @@ export const POLLING_INTERVAL_BACKOFF = 2;
export const CONFLICTS_MODAL_ID = 'fork-sync-conflicts-modal';
export const FORK_UPDATED_EVENT = 'fork:updated';
+
+export const CODEOWNERS_FILE_NAME = 'CODEOWNERS';
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index c1e0104c6ac..9753173ac30 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -1,5 +1,6 @@
import { GlButton } from '@gitlab/ui';
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import { parseBoolean } from '~/lib/utils/common_utils';
import { joinPaths, escapeFileUrl, visitUrl } from '~/lib/utils/url_utility';
@@ -121,6 +122,7 @@ export default function setupVueRepositoryList() {
return h(LastCommit, {
props: {
currentPath: this.$route.params.path,
+ refType: this.$route.query.ref_type,
},
});
},
@@ -207,6 +209,7 @@ export default function setupVueRepositoryList() {
return h(Breadcrumbs, {
props: {
currentPath: this.$route.params.path,
+ refType: this.$route.query.ref_type,
canCollaborate: parseBoolean(canCollaborate),
canEditTree: parseBoolean(canEditTree),
canPushCode: parseBoolean(canPushCode),
@@ -228,20 +231,12 @@ export default function setupVueRepositoryList() {
const treeHistoryLinkEl = document.getElementById('js-tree-history-link');
const { historyLink } = treeHistoryLinkEl.dataset;
- let { isProjectOverview } = treeHistoryLinkEl.dataset;
-
- const isProjectOverviewAfterEach = router.afterEach(() => {
- isProjectOverview = false;
- isProjectOverviewAfterEach();
- });
// eslint-disable-next-line no-new
new Vue({
el: treeHistoryLinkEl,
router,
render(h) {
- if (parseBoolean(isProjectOverview) && !this.$route.params.path) return null;
-
return h(
GlButton,
{
diff --git a/app/assets/javascripts/repository/mixins/highlight_mixin.js b/app/assets/javascripts/repository/mixins/highlight_mixin.js
index 822a8b4ee38..5b6f68681bb 100644
--- a/app/assets/javascripts/repository/mixins/highlight_mixin.js
+++ b/app/assets/javascripts/repository/mixins/highlight_mixin.js
@@ -8,7 +8,6 @@ import { splitIntoChunks } from '~/vue_shared/components/source_viewer/workers/h
import LineHighlighter from '~/blob/line_highlighter';
import languageLoader from '~/content_editor/services/highlight_js_language_loader';
import Tracking from '~/tracking';
-import { TEXT_FILE_TYPE } from '../constants';
/*
* This mixin is intended to be used as an interface between our highlight worker and Vue components
@@ -37,8 +36,8 @@ export default {
this.trackEvent(EVENT_LABEL_FALLBACK, language);
this?.onError();
},
- initHighlightWorker({ rawTextBlob, language, simpleViewer, fileType }) {
- if (simpleViewer?.fileType !== TEXT_FILE_TYPE || !this.glFeatures.highlightJsWorker) return;
+ initHighlightWorker({ rawTextBlob, language, fileType }) {
+ if (language !== 'json' || !this.glFeatures.highlightJsWorker) return;
if (this.isUnsupportedLanguage(language)) {
this.handleUnsupportedLanguage(language);
diff --git a/app/assets/javascripts/repository/mixins/preload.js b/app/assets/javascripts/repository/mixins/preload.js
index 30c36dee48f..473317ecf5d 100644
--- a/app/assets/javascripts/repository/mixins/preload.js
+++ b/app/assets/javascripts/repository/mixins/preload.js
@@ -18,13 +18,13 @@ export default {
methods: {
preload(path = '/', next) {
this.loadingPath = path.replace(/^\//, '');
-
return this.$apollo
.query({
query: paginatedTreeQuery,
variables: {
projectPath: this.projectPath,
ref: this.ref,
+ refType: this.refType?.toUpperCase(),
path: this.loadingPath,
nextPageCursor: '',
pageSize: 100,
diff --git a/app/assets/javascripts/repository/pages/blob.vue b/app/assets/javascripts/repository/pages/blob.vue
index c09e2133936..89bfba79a37 100644
--- a/app/assets/javascripts/repository/pages/blob.vue
+++ b/app/assets/javascripts/repository/pages/blob.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
// This file is in progress and behind a feature flag, please see the following issue for more:
// https://gitlab.com/gitlab-org/gitlab/-/issues/323200
@@ -31,11 +32,15 @@ export default {
type: String,
required: true,
},
+ refType: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
limitedContainerElements: document.querySelectorAll(`.${LIMITED_CONTAINER_WIDTH_CLASS}`),
};
</script>
-
<template>
- <blob-content-viewer :path="path" :project-path="projectPath" />
+ <blob-content-viewer :path="path" :project-path="projectPath" :ref-type="refType" />
</template>
diff --git a/app/assets/javascripts/repository/pages/index.vue b/app/assets/javascripts/repository/pages/index.vue
index 0e53235779c..0e46871cee1 100644
--- a/app/assets/javascripts/repository/pages/index.vue
+++ b/app/assets/javascripts/repository/pages/index.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { updateElementsVisibility } from '../utils/dom';
import TreePage from './tree.vue';
@@ -6,6 +7,13 @@ export default {
components: {
TreePage,
},
+ props: {
+ refType: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
mounted() {
this.updateProjectElements(true);
},
@@ -21,5 +29,5 @@ export default {
</script>
<template>
- <tree-page path="/" />
+ <tree-page path="/" :ref-type="refType" />
</template>
diff --git a/app/assets/javascripts/repository/pages/tree.vue b/app/assets/javascripts/repository/pages/tree.vue
index 6bf674eb3f1..0d35bfa679f 100644
--- a/app/assets/javascripts/repository/pages/tree.vue
+++ b/app/assets/javascripts/repository/pages/tree.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import TreeContent from 'jh_else_ce/repository/components/tree_content.vue';
import preloadMixin from '../mixins/preload';
@@ -8,12 +9,22 @@ export default {
TreeContent,
},
mixins: [preloadMixin],
+ provide() {
+ return {
+ refType: this.refType,
+ };
+ },
props: {
path: {
type: String,
required: false,
default: '/',
},
+ refType: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
isRoot() {
diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js
index 0a675e14eb5..5f73912ed2b 100644
--- a/app/assets/javascripts/repository/router.js
+++ b/app/assets/javascripts/repository/router.js
@@ -13,15 +13,19 @@ export default function createRouter(base, baseRef) {
component: TreePage,
props: (route) => ({
path: route.params.path?.replace(/^\//, '') || '/',
+ refType: route.query.ref_type || null,
}),
};
const blobPathRoute = {
component: BlobPage,
- props: (route) => ({
- path: route.params.path,
- projectPath: base,
- }),
+ props: (route) => {
+ return {
+ path: route.params.path,
+ projectPath: base,
+ refType: route.query.ref_type || null,
+ };
+ },
};
const router = new VueRouter({
@@ -56,6 +60,9 @@ export default function createRouter(base, baseRef) {
path: '/',
name: 'projectRoot',
component: IndexPage,
+ props: {
+ refType: 'HEADS',
+ },
},
],
});
diff --git a/app/assets/javascripts/repository/utils/ref_switcher_utils.js b/app/assets/javascripts/repository/utils/ref_switcher_utils.js
index bcad4a2c822..f3d21971771 100644
--- a/app/assets/javascripts/repository/utils/ref_switcher_utils.js
+++ b/app/assets/javascripts/repository/utils/ref_switcher_utils.js
@@ -28,7 +28,7 @@ export function generateRefDestinationPath(projectRootPath, ref, selectedRef) {
[, refType, actualRef] = matches;
}
if (refType) {
- url.searchParams.set('ref_type', refType);
+ url.searchParams.set('ref_type', refType.toLowerCase());
} else {
url.searchParams.delete('ref_type');
}
diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue
index cd289be4c05..9962f711892 100644
--- a/app/assets/javascripts/search/sidebar/components/app.vue
+++ b/app/assets/javascripts/search/sidebar/components/app.vue
@@ -1,11 +1,15 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters } from 'vuex';
import ScopeLegacyNavigation from '~/search/sidebar/components/scope_legacy_navigation.vue';
import ScopeSidebarNavigation from '~/search/sidebar/components/scope_sidebar_navigation.vue';
import SidebarPortal from '~/super_sidebar/components/sidebar_portal.vue';
-import { SCOPE_ISSUES, SCOPE_MERGE_REQUESTS, SCOPE_BLOB } from '../constants';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { SCOPE_ISSUES, SCOPE_MERGE_REQUESTS, SCOPE_BLOB, SCOPE_PROJECTS } from '../constants';
import IssuesFilters from './issues_filters.vue';
-import LanguageFilter from './language_filter/index.vue';
+import MergeRequestsFilters from './merge_requests_filters.vue';
+import BlobsFilters from './blobs_filters.vue';
+import ProjectsFilters from './projects_filters.vue';
export default {
name: 'GlobalSearchSidebar',
@@ -13,25 +17,33 @@ export default {
IssuesFilters,
ScopeLegacyNavigation,
ScopeSidebarNavigation,
- LanguageFilter,
SidebarPortal,
+ MergeRequestsFilters,
+ BlobsFilters,
+ ProjectsFilters,
},
+ mixins: [glFeatureFlagsMixin()],
computed: {
// useSidebarNavigation refers to whether the new left sidebar navigation is enabled
...mapState(['useSidebarNavigation']),
...mapGetters(['currentScope']),
- showIssueAndMergeFilters() {
- return this.currentScope === SCOPE_ISSUES || this.currentScope === SCOPE_MERGE_REQUESTS;
+ showIssuesFilters() {
+ return this.currentScope === SCOPE_ISSUES;
+ },
+ showMergeRequestFilters() {
+ return this.currentScope === SCOPE_MERGE_REQUESTS;
},
- showBlobFilter() {
+ showBlobFilters() {
return this.currentScope === SCOPE_BLOB;
},
- showLabelFilter() {
- return this.currentScope === SCOPE_ISSUES;
+ showProjectsFilters() {
+ // for now the feature flag is here. Since we have only one filter in projects scope
+ return this.currentScope === SCOPE_PROJECTS && this.glFeatures.searchProjectsHideArchived;
},
showScopeNavigation() {
// showScopeNavigation refers to whether the scope navigation should be shown
- // while the legacy navigation is being used and there are no search results the scope navigation has to be hidden
+ // while the legacy navigation is being used and there are no search results
+ // the scope navigation has to be hidden
return Boolean(this.currentScope);
},
},
@@ -42,8 +54,10 @@ export default {
<section v-if="useSidebarNavigation">
<sidebar-portal>
<scope-sidebar-navigation />
- <issues-filters v-if="showIssueAndMergeFilters" />
- <language-filter v-if="showBlobFilter" />
+ <issues-filters v-if="showIssuesFilters" />
+ <merge-requests-filters v-if="showMergeRequestFilters" />
+ <blobs-filters v-if="showBlobFilters" />
+ <projects-filters v-if="showProjectsFilters" />
</sidebar-portal>
</section>
<section
@@ -51,7 +65,9 @@ export default {
class="search-sidebar gl-display-flex gl-flex-direction-column gl-md-mr-5 gl-mb-6 gl-mt-5"
>
<scope-legacy-navigation />
- <issues-filters v-if="showIssueAndMergeFilters" />
- <language-filter v-if="showBlobFilter" />
+ <issues-filters v-if="showIssuesFilters" />
+ <merge-requests-filters v-if="showMergeRequestFilters" />
+ <blobs-filters v-if="showBlobFilters" />
+ <projects-filters v-if="showProjectsFilters" />
</section>
</template>
diff --git a/app/assets/javascripts/search/sidebar/components/archived_filter/data.js b/app/assets/javascripts/search/sidebar/components/archived_filter/data.js
new file mode 100644
index 00000000000..77efbdd9e60
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/archived_filter/data.js
@@ -0,0 +1,19 @@
+import { s__ } from '~/locale';
+
+const headerLabel = s__('GlobalSearch|Archived');
+const checkboxLabel = s__('GlobalSearch|Include archived');
+export const TRACKING_NAMESPACE = 'search:archived:select';
+export const TRACKING_LABEL_CHECKBOX = 'checkbox';
+
+const scopes = {
+ PROJECTS: 'projects',
+};
+
+const filterParam = 'include_archived';
+
+export const archivedFilterData = {
+ headerLabel,
+ checkboxLabel,
+ scopes,
+ filterParam,
+};
diff --git a/app/assets/javascripts/search/sidebar/components/archived_filter/index.vue b/app/assets/javascripts/search/sidebar/components/archived_filter/index.vue
new file mode 100644
index 00000000000..1984e3a36c4
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/archived_filter/index.vue
@@ -0,0 +1,55 @@
+<script>
+import { GlFormCheckboxGroup, GlFormCheckbox } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
+import { mapState, mapActions } from 'vuex';
+import Tracking from '~/tracking';
+import { parseBoolean } from '~/lib/utils/common_utils';
+
+import { archivedFilterData, TRACKING_NAMESPACE, TRACKING_LABEL_CHECKBOX } from './data';
+
+export default {
+ name: 'ArchivedFilter',
+ components: {
+ GlFormCheckboxGroup,
+ GlFormCheckbox,
+ },
+ computed: {
+ ...mapState(['urlQuery']),
+ selectedFilter: {
+ get() {
+ return [parseBoolean(this.urlQuery?.include_archived)];
+ },
+ set(value) {
+ const newValue = value?.pop() ?? false;
+ this.setQuery({ key: archivedFilterData.filterParam, value: newValue?.toString() });
+ this.trackSelectCheckbox(newValue);
+ },
+ },
+ },
+ methods: {
+ ...mapActions(['setQuery']),
+ trackSelectCheckbox(value) {
+ Tracking.event(TRACKING_NAMESPACE, TRACKING_LABEL_CHECKBOX, {
+ label: archivedFilterData.checkboxLabel,
+ property: value,
+ });
+ },
+ },
+ archivedFilterData,
+};
+</script>
+
+<template>
+ <gl-form-checkbox-group v-model="selectedFilter">
+ <h5>{{ $options.archivedFilterData.headerLabel }}</h5>
+ <gl-form-checkbox
+ class="gl-flex-grow-1 gl-display-inline-flex gl-justify-content-space-between gl-w-full"
+ :class="$options.LABEL_DEFAULT_CLASSES"
+ :value="true"
+ >
+ <span data-testid="label">
+ {{ $options.archivedFilterData.checkboxLabel }}
+ </span>
+ </gl-form-checkbox>
+ </gl-form-checkbox-group>
+</template>
diff --git a/app/assets/javascripts/search/sidebar/components/archived_filter/tracking.js b/app/assets/javascripts/search/sidebar/components/archived_filter/tracking.js
new file mode 100644
index 00000000000..d9d139bf572
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/archived_filter/tracking.js
@@ -0,0 +1,38 @@
+import Tracking from '~/tracking';
+
+export const TRACKING_CATEGORY = 'Language filters';
+export const TRACKING_LABEL_FILTERS = 'Filters';
+
+export const TRACKING_LABEL_MAX = 'Max Shown';
+export const TRACKING_LABEL_SHOW_MORE = 'Show More';
+export const TRACKING_LABEL_APPLY = 'Apply Filters';
+export const TRACKING_LABEL_RESET = 'Reset Filters';
+export const TRACKING_LABEL_ALL = 'All Filters';
+export const TRACKING_PROPERTY_MAX = `More than filters to show`;
+
+export const TRACKING_ACTION_CLICK = 'search:agreggations:language:click';
+export const TRACKING_ACTION_SHOW = 'search:agreggations:language:show';
+
+// select is imported and used in checkbox_filter.vue
+export const TRACKING_ACTION_SELECT = 'search:agreggations:language:select';
+
+export const trackShowMore = () =>
+ Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_SHOW_MORE, {
+ label: TRACKING_LABEL_ALL,
+ });
+
+export const trackShowHasOverMax = () =>
+ Tracking.event(TRACKING_ACTION_SHOW, TRACKING_LABEL_FILTERS, {
+ label: TRACKING_LABEL_MAX,
+ property: TRACKING_PROPERTY_MAX,
+ });
+
+export const trackSubmitQuery = () =>
+ Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_APPLY, {
+ label: TRACKING_CATEGORY,
+ });
+
+export const trackResetQuery = () =>
+ Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_RESET, {
+ label: TRACKING_CATEGORY,
+ });
diff --git a/app/assets/javascripts/search/sidebar/components/blobs_filters.vue b/app/assets/javascripts/search/sidebar/components/blobs_filters.vue
new file mode 100644
index 00000000000..5f4d6fbd56c
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/blobs_filters.vue
@@ -0,0 +1,18 @@
+<script>
+import LanguageFilter from './language_filter/index.vue';
+import FiltersTemplate from './filters_template.vue';
+
+export default {
+ name: 'BlobsFilters',
+ components: {
+ LanguageFilter,
+ FiltersTemplate,
+ },
+};
+</script>
+
+<template>
+ <filters-template>
+ <language-filter class="gl-mb-5" />
+ </filters-template>
+</template>
diff --git a/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue b/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue
deleted file mode 100644
index feff3f77dd2..00000000000
--- a/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue
+++ /dev/null
@@ -1,94 +0,0 @@
-<script>
-import Vue from 'vue';
-import { GlFormCheckboxGroup, GlFormCheckbox } from '@gitlab/ui';
-import { mapState, mapActions, mapGetters } from 'vuex';
-import { intersection } from 'lodash';
-import Tracking from '~/tracking';
-import { NAV_LINK_COUNT_DEFAULT_CLASSES, LABEL_DEFAULT_CLASSES } from '../constants';
-import { formatSearchResultCount } from '../../store/utils';
-
-export const TRACKING_LABEL_SET = 'set';
-export const TRACKING_LABEL_CHECKBOX = 'checkbox';
-
-export default {
- name: 'CheckboxFilter',
- components: {
- GlFormCheckboxGroup,
- GlFormCheckbox,
- },
- props: {
- filtersData: {
- type: Object,
- required: true,
- },
- trackingNamespace: {
- type: String,
- required: true,
- },
- },
- computed: {
- ...mapState(['query', 'useNewNavigation']),
- ...mapGetters(['queryLanguageFilters']),
- dataFilters() {
- return Object.values(this.filtersData?.filters || []);
- },
- flatDataFilterValues() {
- return this.dataFilters.map(({ value }) => value);
- },
- selectedFilter: {
- get() {
- return intersection(this.flatDataFilterValues, this.queryLanguageFilters);
- },
- async set(value) {
- this.setQuery({ key: this.filtersData?.filterParam, value });
-
- await Vue.nextTick();
- this.trackSelectCheckbox();
- },
- },
- labelCountClasses() {
- return [...NAV_LINK_COUNT_DEFAULT_CLASSES, 'gl-text-gray-500'];
- },
- },
- methods: {
- ...mapActions(['setQuery']),
- getFormattedCount(count) {
- return formatSearchResultCount(count);
- },
- trackSelectCheckbox() {
- Tracking.event(this.trackingNamespace, TRACKING_LABEL_CHECKBOX, {
- label: TRACKING_LABEL_SET,
- property: this.selectedFilter,
- });
- },
- },
- NAV_LINK_COUNT_DEFAULT_CLASSES,
- LABEL_DEFAULT_CLASSES,
-};
-</script>
-
-<template>
- <div class="gl-mx-5">
- <h5 class="gl-mt-0" :class="{ 'gl-font-sm': useNewNavigation }">{{ filtersData.header }}</h5>
- <gl-form-checkbox-group v-model="selectedFilter">
- <gl-form-checkbox
- v-for="f in dataFilters"
- :key="f.label"
- :value="f.label"
- class="gl-flex-grow-1 gl-display-inline-flex gl-justify-content-space-between gl-w-full"
- :class="$options.LABEL_DEFAULT_CLASSES"
- >
- <span
- class="gl-flex-grow-1 gl-display-inline-flex gl-justify-content-space-between gl-w-full"
- >
- <span data-testid="label">
- {{ f.label }}
- </span>
- <span v-if="f.count" :class="labelCountClasses" data-testid="labelCount">
- {{ getFormattedCount(f.count) }}
- </span>
- </span>
- </gl-form-checkbox>
- </gl-form-checkbox-group>
- </div>
-</template>
diff --git a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
deleted file mode 100644
index 2a7988cd4c6..00000000000
--- a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
+++ /dev/null
@@ -1,25 +0,0 @@
-<script>
-import { mapState } from 'vuex';
-import { confidentialFilterData } from '../constants/confidential_filter_data';
-import { HR_DEFAULT_CLASSES } from '../constants';
-import RadioFilter from './radio_filter.vue';
-
-export default {
- name: 'ConfidentialityFilter',
- components: {
- RadioFilter,
- },
- computed: {
- ...mapState(['useNewNavigation']),
- },
- confidentialFilterData,
- HR_DEFAULT_CLASSES,
-};
-</script>
-
-<template>
- <div>
- <radio-filter :filter-data="$options.confidentialFilterData" />
- <hr v-if="!useNewNavigation" :class="$options.HR_DEFAULT_CLASSES" />
- </div>
-</template>
diff --git a/app/assets/javascripts/search/sidebar/constants/confidential_filter_data.js b/app/assets/javascripts/search/sidebar/components/confidentiality_filter/data.js
index ecb63ed9eea..ecb63ed9eea 100644
--- a/app/assets/javascripts/search/sidebar/constants/confidential_filter_data.js
+++ b/app/assets/javascripts/search/sidebar/components/confidentiality_filter/data.js
diff --git a/app/assets/javascripts/search/sidebar/components/confidentiality_filter/index.vue b/app/assets/javascripts/search/sidebar/components/confidentiality_filter/index.vue
new file mode 100644
index 00000000000..176614be6da
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/confidentiality_filter/index.vue
@@ -0,0 +1,23 @@
+<script>
+// eslint-disable-next-line no-restricted-imports
+import { mapState } from 'vuex';
+import RadioFilter from '../radio_filter.vue';
+import { confidentialFilterData } from './data';
+
+export default {
+ name: 'ConfidentialityFilter',
+ components: {
+ RadioFilter,
+ },
+ computed: {
+ ...mapState(['useSidebarNavigation']),
+ },
+ confidentialFilterData,
+};
+</script>
+
+<template>
+ <div>
+ <radio-filter :filter-data="$options.confidentialFilterData" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/search/sidebar/components/filters_template.vue b/app/assets/javascripts/search/sidebar/components/filters_template.vue
new file mode 100644
index 00000000000..3dae05ccc69
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/filters_template.vue
@@ -0,0 +1,60 @@
+<script>
+import { GlButton, GlLink, GlForm } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
+import { mapActions, mapState, mapGetters } from 'vuex';
+import Tracking from '~/tracking';
+
+import {
+ HR_DEFAULT_CLASSES,
+ TRACKING_ACTION_CLICK,
+ TRACKING_LABEL_APPLY,
+ TRACKING_LABEL_RESET,
+} from '../constants/index';
+
+export default {
+ name: 'FiltersTemplate',
+ components: {
+ GlButton,
+ GlLink,
+ GlForm,
+ },
+ computed: {
+ ...mapState(['sidebarDirty', 'useSidebarNavigation']),
+ ...mapGetters(['currentScope']),
+ hrClasses() {
+ return [...HR_DEFAULT_CLASSES, 'gl-display-none', 'gl-md-display-block'];
+ },
+ },
+ methods: {
+ ...mapActions(['applyQuery', 'resetQuery']),
+ applyQueryWithTracking() {
+ Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_APPLY, {
+ label: this.currentScope,
+ });
+ this.applyQuery();
+ },
+ resetQueryWithTracking() {
+ Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_RESET, {
+ label: this.currentScope,
+ });
+ this.resetQuery();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form class="issue-filters gl-px-5 gl-pt-0" @submit.prevent="applyQueryWithTracking">
+ <hr v-if="!useSidebarNavigation" :class="hrClasses" />
+ <slot></slot>
+ <hr v-if="!useSidebarNavigation" :class="hrClasses" />
+ <div class="gl-display-flex gl-align-items-center gl-mt-4">
+ <gl-button category="primary" variant="confirm" type="submit" :disabled="!sidebarDirty">
+ {{ __('Apply') }}
+ </gl-button>
+ <gl-link v-if="sidebarDirty" class="gl-ml-auto" @click="resetQueryWithTracking">{{
+ __('Reset filters')
+ }}</gl-link>
+ </div>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/search/sidebar/components/issues_filters.vue b/app/assets/javascripts/search/sidebar/components/issues_filters.vue
index 8928f80d83a..919bd2b2e49 100644
--- a/app/assets/javascripts/search/sidebar/components/issues_filters.vue
+++ b/app/assets/javascripts/search/sidebar/components/issues_filters.vue
@@ -1,43 +1,34 @@
<script>
-import { GlButton, GlLink } from '@gitlab/ui';
-import { mapActions, mapState, mapGetters } from 'vuex';
-import Tracking from '~/tracking';
+// eslint-disable-next-line no-restricted-imports
+import { mapGetters, mapState } from 'vuex';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import {
- HR_DEFAULT_CLASSES,
- TRACKING_ACTION_CLICK,
- TRACKING_LABEL_APPLY,
- TRACKING_CATEGORY,
- TRACKING_LABEL_RESET,
-} from '../constants/index';
-import { confidentialFilterData } from '../constants/confidential_filter_data';
-import { stateFilterData } from '../constants/state_filter_data';
-import ConfidentialityFilter from './confidentiality_filter.vue';
+import { HR_DEFAULT_CLASSES } from '../constants/index';
+import { confidentialFilterData } from './confidentiality_filter/data';
+import { statusFilterData } from './status_filter/data';
+import ConfidentialityFilter from './confidentiality_filter/index.vue';
import { labelFilterData } from './label_filter/data';
import LabelFilter from './label_filter/index.vue';
-import StatusFilter from './status_filter.vue';
+import StatusFilter from './status_filter/index.vue';
+
+import FiltersTemplate from './filters_template.vue';
export default {
name: 'IssuesFilters',
components: {
- GlButton,
- GlLink,
StatusFilter,
ConfidentialityFilter,
LabelFilter,
+ FiltersTemplate,
},
mixins: [glFeatureFlagsMixin()],
computed: {
- ...mapState(['urlQuery', 'sidebarDirty', 'useNewNavigation']),
...mapGetters(['currentScope']),
- showReset() {
- return this.urlQuery.state || this.urlQuery.confidential || this.urlQuery.labels;
- },
+ ...mapState(['useSidebarNavigation']),
showConfidentialityFilter() {
return Object.values(confidentialFilterData.scopes).includes(this.currentScope);
},
showStatusFilter() {
- return Object.values(stateFilterData.scopes).includes(this.currentScope);
+ return Object.values(statusFilterData.scopes).includes(this.currentScope);
},
showLabelFilter() {
return (
@@ -45,41 +36,22 @@ export default {
this.glFeatures.searchIssueLabelAggregation
);
},
+ showDivider() {
+ return !this.useSidebarNavigation;
+ },
hrClasses() {
return [...HR_DEFAULT_CLASSES, 'gl-display-none', 'gl-md-display-block'];
},
},
- methods: {
- ...mapActions(['applyQuery', 'resetQuery']),
- applyQueryWithTracking() {
- Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_APPLY, {
- label: TRACKING_CATEGORY,
- });
- this.applyQuery();
- },
- resetQueryWithTracking() {
- Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_RESET, {
- label: TRACKING_CATEGORY,
- });
- this.resetQuery();
- },
- },
};
</script>
<template>
- <form class="issue-filters gl-px-5 gl-pt-0" @submit.prevent="applyQueryWithTracking">
- <hr v-if="!useNewNavigation" :class="hrClasses" />
+ <filters-template>
<status-filter v-if="showStatusFilter" class="gl-mb-5" />
+ <hr v-if="showConfidentialityFilter && showDivider" :class="hrClasses" />
<confidentiality-filter v-if="showConfidentialityFilter" class="gl-mb-5" />
+ <hr v-if="showLabelFilter && showDivider" :class="hrClasses" />
<label-filter v-if="showLabelFilter" />
- <div class="gl-display-flex gl-align-items-center gl-mt-4">
- <gl-button category="primary" variant="confirm" type="submit" :disabled="!sidebarDirty">
- {{ __('Apply') }}
- </gl-button>
- <gl-link v-if="showReset" class="gl-ml-auto" @click="resetQueryWithTracking">{{
- __('Reset filters')
- }}</gl-link>
- </div>
- </form>
+ </filters-template>
</template>
diff --git a/app/assets/javascripts/search/sidebar/components/label_filter/index.vue b/app/assets/javascripts/search/sidebar/components/label_filter/index.vue
index eb3556ac2cf..a6af789baad 100644
--- a/app/assets/javascripts/search/sidebar/components/label_filter/index.vue
+++ b/app/assets/javascripts/search/sidebar/components/label_filter/index.vue
@@ -10,6 +10,7 @@ import {
GlAlert,
GlOutsideDirective as Outside,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState, mapGetters } from 'vuex';
import { uniq } from 'lodash';
import { rgbFromHex } from '@gitlab/ui/dist/utils/utils';
@@ -19,8 +20,6 @@ import { sprintf } from '~/locale';
import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
import { I18N } from '~/vue_shared/global_search/constants';
-
-import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../../constants';
import LabelDropdownItems from './label_dropdown_items.vue';
import {
@@ -62,7 +61,6 @@ export default {
'filteredUnselectedLabels',
'filteredAppliedSelectedLabels',
'appliedSelectedLabels',
- 'filteredUnappliedSelectedLabels',
]),
searchInputDescribeBy() {
if (this.isLoggedIn) {
@@ -107,9 +105,6 @@ export default {
hasUnselectedLabels() {
return this.filteredUnselectedLabels.length > 0;
},
- dividerClasses() {
- return [...HR_DEFAULT_CLASSES, ...ONLY_SHOW_MD];
- },
labelSearchBox() {
return this.$refs.searchLabelInputBox?.$el.querySelector('[role=searchbox]');
},
@@ -253,15 +248,10 @@ export default {
<gl-form-checkbox-group v-model="selectedFilters">
<label-dropdown-items
v-if="hasSelectedLabels"
- data-testid="selected-lavel-items"
:labels="filteredAppliedSelectedLabels"
/>
<gl-dropdown-divider v-if="hasSelectedLabels && hasUnselectedLabels" />
- <label-dropdown-items
- v-if="hasUnselectedLabels"
- data-testid="unselected-lavel-items"
- :labels="filteredUnselectedLabels"
- />
+ <label-dropdown-items v-if="hasUnselectedLabels" :labels="filteredUnselectedLabels" />
</gl-form-checkbox-group>
</gl-dropdown-form>
</div>
@@ -277,6 +267,5 @@ export default {
<gl-loading-icon v-if="aggregations.fetching" size="lg" class="my-4" />
</div>
</div>
- <hr v-if="!useSidebarNavigation" :class="dividerClasses" />
</div>
</template>
diff --git a/app/assets/javascripts/search/sidebar/components/language_filter/checkbox_filter.vue b/app/assets/javascripts/search/sidebar/components/language_filter/checkbox_filter.vue
index b820ca837bc..7a1bfa200bb 100644
--- a/app/assets/javascripts/search/sidebar/components/language_filter/checkbox_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/language_filter/checkbox_filter.vue
@@ -1,7 +1,8 @@
<script>
import Vue from 'vue';
import { GlFormCheckboxGroup, GlFormCheckbox } from '@gitlab/ui';
-import { mapState, mapActions, mapGetters } from 'vuex';
+// eslint-disable-next-line no-restricted-imports
+import { mapActions, mapGetters } from 'vuex';
import { intersection } from 'lodash';
import Tracking from '~/tracking';
import { NAV_LINK_COUNT_DEFAULT_CLASSES, LABEL_DEFAULT_CLASSES } from '../../constants';
@@ -27,7 +28,6 @@ export default {
},
},
computed: {
- ...mapState(['query', 'useNewNavigation']),
...mapGetters(['queryLanguageFilters']),
dataFilters() {
return Object.values(this.filtersData?.filters || []);
@@ -62,7 +62,6 @@ export default {
});
},
},
- NAV_LINK_COUNT_DEFAULT_CLASSES,
LABEL_DEFAULT_CLASSES,
};
</script>
diff --git a/app/assets/javascripts/search/sidebar/components/language_filter/index.vue b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue
index c10b14bd116..ca1503d7c64 100644
--- a/app/assets/javascripts/search/sidebar/components/language_filter/index.vue
+++ b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue
@@ -1,17 +1,11 @@
<script>
-import { GlButton, GlAlert, GlForm } from '@gitlab/ui';
+import { GlButton, GlAlert } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions, mapGetters } from 'vuex';
-import { __, s__, sprintf } from '~/locale';
-import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../../constants';
+import { s__, sprintf } from '~/locale';
import { convertFiltersData } from '../../utils';
import CheckboxFilter from './checkbox_filter.vue';
-import {
- trackShowMore,
- trackShowHasOverMax,
- trackSubmitQuery,
- trackResetQuery,
- TRACKING_ACTION_SELECT,
-} from './tracking';
+import { trackShowMore, trackShowHasOverMax, TRACKING_ACTION_SELECT } from './tracking';
import { DEFAULT_ITEM_LENGTH, MAX_ITEM_LENGTH, languageFilterData } from './data';
@@ -21,7 +15,6 @@ export default {
CheckboxFilter,
GlButton,
GlAlert,
- GlForm,
},
data() {
return {
@@ -30,18 +23,12 @@ export default {
},
i18n: {
showMore: s__('GlobalSearch|Show more'),
- apply: __('Apply'),
showingMax: sprintf(s__('GlobalSearch|Showing top %{maxItems}'), { maxItems: MAX_ITEM_LENGTH }),
loadError: s__('GlobalSearch|Aggregations load error.'),
- reset: s__('GlobalSearch|Reset filters'),
},
computed: {
- ...mapState(['aggregations', 'sidebarDirty', 'useNewNavigation']),
- ...mapGetters([
- 'languageAggregationBuckets',
- 'currentUrlQueryHasLanguageFilters',
- 'queryLanguageFilters',
- ]),
+ ...mapState(['aggregations', 'useSidebarNavigation']),
+ ...mapGetters(['languageAggregationBuckets']),
hasBuckets() {
return this.languageAggregationBuckets.length > 0;
},
@@ -63,26 +50,12 @@ export default {
hasOverMax() {
return this.languageAggregationBuckets.length > MAX_ITEM_LENGTH;
},
- dividerClassesTop() {
- return [...HR_DEFAULT_CLASSES, ...ONLY_SHOW_MD];
- },
- dividerClassesBottom() {
- return [...HR_DEFAULT_CLASSES, 'gl-mt-5'];
- },
- hasQueryFilters() {
- return this.queryLanguageFilters.length > 0;
- },
},
async created() {
await this.fetchAllAggregation();
},
methods: {
- ...mapActions([
- 'applyQuery',
- 'resetLanguageQuery',
- 'resetLanguageQueryWithRedirect',
- 'fetchAllAggregation',
- ]),
+ ...mapActions(['fetchAllAggregation']),
onShowMore() {
this.showAll = true;
trackShowMore();
@@ -91,91 +64,47 @@ export default {
trackShowHasOverMax();
}
},
- submitQuery() {
- trackSubmitQuery();
- this.applyQuery();
- },
trimBuckets(length) {
return this.languageAggregationBuckets.slice(0, length);
},
- cleanResetFilters() {
- trackResetQuery();
- if (this.currentUrlQueryHasLanguageFilters) {
- return this.resetLanguageQueryWithRedirect();
- }
- this.showAll = false;
- return this.resetLanguageQuery();
- },
},
- HR_DEFAULT_CLASSES,
TRACKING_ACTION_SELECT,
languageFilterData,
};
</script>
<template>
- <div>
- <gl-form
- v-if="hasBuckets"
- class="gl-m-5 gl-my-0 language-filter-checkbox"
- @submit.prevent="submitQuery"
+ <div v-if="hasBuckets" class="gl-my-0 language-filter-checkbox">
+ <h5 class="gl-mt-0 gl-mb-5" :class="{ 'gl-font-sm': useSidebarNavigation }">
+ {{ $options.languageFilterData.header }}
+ </h5>
+ <div
+ v-if="!aggregations.error"
+ class="gl-overflow-x-hidden gl-overflow-y-auto"
+ :class="{ 'language-filter-max-height': showAll }"
>
- <hr v-if="!useNewNavigation" :class="dividerClassesTop" />
- <h5 class="gl-mt-0 gl-mb-5" :class="{ 'gl-font-sm': useNewNavigation }">
- {{ $options.languageFilterData.header }}
- </h5>
- <div
- v-if="!aggregations.error"
- class="gl-overflow-x-hidden gl-overflow-y-auto"
- :class="{ 'language-filter-max-height': showAll }"
+ <checkbox-filter
+ :filters-data="filtersData"
+ :tracking-namespace="$options.TRACKING_ACTION_SELECT"
+ />
+ <span v-if="showAll && hasOverMax" data-testid="has-over-max-text">{{
+ $options.i18n.showingMax
+ }}</span>
+ </div>
+ <gl-alert v-else class="gl-mx-5" variant="danger" :dismissible="false">{{
+ $options.i18n.loadError
+ }}</gl-alert>
+ <div v-if="hasShowMore && !showAll" class="language-filter-show-all">
+ <gl-button
+ data-testid="show-more-button"
+ category="tertiary"
+ variant="link"
+ size="small"
+ button-text-classes="gl-font-sm"
+ @click="onShowMore"
>
- <checkbox-filter
- :filters-data="filtersData"
- :tracking-namespace="$options.TRACKING_ACTION_SELECT"
- />
- <span v-if="showAll && hasOverMax" data-testid="has-over-max-text">{{
- $options.i18n.showingMax
- }}</span>
- </div>
- <gl-alert v-else class="gl-mx-5" variant="danger" :dismissible="false">{{
- $options.i18n.loadError
- }}</gl-alert>
- <div v-if="hasShowMore && !showAll" class="gl-px-5 language-filter-show-all">
- <gl-button
- data-testid="show-more-button"
- category="tertiary"
- variant="link"
- size="small"
- button-text-classes="gl-font-sm"
- @click="onShowMore"
- >
- {{ $options.i18n.showMore }}
- </gl-button>
- </div>
- <div v-if="!aggregations.error">
- <hr v-if="!useNewNavigation" :class="dividerClassesBottom" />
- <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-mt-4">
- <gl-button
- category="primary"
- variant="confirm"
- type="submit"
- :disabled="!sidebarDirty"
- data-testid="apply-button"
- >
- {{ $options.i18n.apply }}
- </gl-button>
- <gl-button
- v-if="hasQueryFilters && sidebarDirty"
- category="tertiary"
- variant="link"
- size="small"
- data-testid="reset-button"
- @click="cleanResetFilters"
- >
- {{ $options.i18n.reset }}
- </gl-button>
- </div>
- </div>
- </gl-form>
+ {{ $options.i18n.showMore }}
+ </gl-button>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/search/sidebar/components/language_filter/tracking.js b/app/assets/javascripts/search/sidebar/components/language_filter/tracking.js
index db107830329..5f085c7df7e 100644
--- a/app/assets/javascripts/search/sidebar/components/language_filter/tracking.js
+++ b/app/assets/javascripts/search/sidebar/components/language_filter/tracking.js
@@ -27,13 +27,3 @@ export const trackShowHasOverMax = () =>
label: TRACKING_LABEL_MAX,
property: TRACKING_PROPERTY_MAX,
});
-
-export const trackSubmitQuery = () =>
- Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_APPLY, {
- label: TRACKING_CATEGORY,
- });
-
-export const trackResetQuery = () =>
- Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_RESET, {
- label: TRACKING_CATEGORY,
- });
diff --git a/app/assets/javascripts/search/sidebar/components/merge_requests_filters.vue b/app/assets/javascripts/search/sidebar/components/merge_requests_filters.vue
new file mode 100644
index 00000000000..bc5b797dd56
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/merge_requests_filters.vue
@@ -0,0 +1,18 @@
+<script>
+import StatusFilter from './status_filter/index.vue';
+import FiltersTemplate from './filters_template.vue';
+
+export default {
+ name: 'MergeRequestsFilters',
+ components: {
+ StatusFilter,
+ FiltersTemplate,
+ },
+};
+</script>
+
+<template>
+ <filters-template>
+ <status-filter class="gl-mb-5" />
+ </filters-template>
+</template>
diff --git a/app/assets/javascripts/search/sidebar/components/projects_filters.vue b/app/assets/javascripts/search/sidebar/components/projects_filters.vue
new file mode 100644
index 00000000000..093bfd2297f
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/projects_filters.vue
@@ -0,0 +1,18 @@
+<script>
+import ArchivedFilter from './archived_filter/index.vue';
+import FiltersTemplate from './filters_template.vue';
+
+export default {
+ name: 'ProjectsFilters',
+ components: {
+ ArchivedFilter,
+ FiltersTemplate,
+ },
+};
+</script>
+
+<template>
+ <filters-template>
+ <archived-filter class="gl-mb-5" />
+ </filters-template>
+</template>
diff --git a/app/assets/javascripts/search/sidebar/components/radio_filter.vue b/app/assets/javascripts/search/sidebar/components/radio_filter.vue
index 10ece1b82eb..a1eb5ccecd8 100644
--- a/app/assets/javascripts/search/sidebar/components/radio_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/radio_filter.vue
@@ -1,5 +1,6 @@
<script>
import { GlFormRadioGroup, GlFormRadio } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions, mapGetters } from 'vuex';
import { sprintf, __ } from '~/locale';
@@ -16,7 +17,7 @@ export default {
},
},
computed: {
- ...mapState(['query', 'useNewNavigation']),
+ ...mapState(['query', 'useSidebarNavigation']),
...mapGetters(['currentScope']),
ANY() {
return this.filterData.filters.ANY;
@@ -56,7 +57,7 @@ export default {
<template>
<div>
- <h5 class="gl-mt-0 gl-mb-5" :class="{ 'gl-font-sm': useNewNavigation }">
+ <h5 class="gl-mt-0 gl-mb-5" :class="{ 'gl-font-sm': useSidebarNavigation }">
{{ filterData.header }}
</h5>
<gl-form-radio-group v-model="selectedFilter">
diff --git a/app/assets/javascripts/search/sidebar/components/results_filters.vue b/app/assets/javascripts/search/sidebar/components/results_filters.vue
deleted file mode 100644
index 24804baef44..00000000000
--- a/app/assets/javascripts/search/sidebar/components/results_filters.vue
+++ /dev/null
@@ -1,54 +0,0 @@
-<script>
-import { GlButton, GlLink } from '@gitlab/ui';
-import { mapActions, mapState, mapGetters } from 'vuex';
-import { HR_DEFAULT_CLASSES } from '../constants/index';
-import { confidentialFilterData } from '../constants/confidential_filter_data';
-import { stateFilterData } from '../constants/state_filter_data';
-import ConfidentialityFilter from './confidentiality_filter.vue';
-import StatusFilter from './status_filter.vue';
-
-export default {
- name: 'ResultsFilters',
- components: {
- GlButton,
- GlLink,
- StatusFilter,
- ConfidentialityFilter,
- },
- computed: {
- ...mapState(['urlQuery', 'sidebarDirty', 'useNewNavigation']),
- ...mapGetters(['currentScope']),
- showReset() {
- return this.urlQuery.state || this.urlQuery.confidential;
- },
- showConfidentialityFilter() {
- return Object.values(confidentialFilterData.scopes).includes(this.currentScope);
- },
- showStatusFilter() {
- return Object.values(stateFilterData.scopes).includes(this.currentScope);
- },
- hrClasses() {
- return [...HR_DEFAULT_CLASSES, 'gl-display-none', 'gl-md-display-block'];
- },
- },
- methods: {
- ...mapActions(['applyQuery', 'resetQuery']),
- },
-};
-</script>
-
-<template>
- <form class="gl-pt-5 gl-md-pt-0" @submit.prevent="applyQuery">
- <hr v-if="!useNewNavigation" :class="hrClasses" />
- <status-filter v-if="showStatusFilter" />
- <confidentiality-filter v-if="showConfidentialityFilter" />
- <div class="gl-display-flex gl-align-items-center gl-mt-4 gl-px-5">
- <gl-button category="primary" variant="confirm" type="submit" :disabled="!sidebarDirty">
- {{ __('Apply') }}
- </gl-button>
- <gl-link v-if="showReset" class="gl-ml-auto" @click="resetQuery">{{
- __('Reset filters')
- }}</gl-link>
- </div>
- </form>
-</template>
diff --git a/app/assets/javascripts/search/sidebar/components/scope_legacy_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_legacy_navigation.vue
index e682369d60b..e8d5de4d769 100644
--- a/app/assets/javascripts/search/sidebar/components/scope_legacy_navigation.vue
+++ b/app/assets/javascripts/search/sidebar/components/scope_legacy_navigation.vue
@@ -1,5 +1,6 @@
<script>
import { GlNav, GlNavItem, GlIcon } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
diff --git a/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue
index 3707e152e47..f30618ad9b7 100644
--- a/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue
+++ b/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue
@@ -1,7 +1,7 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState, mapGetters } from 'vuex';
import { s__ } from '~/locale';
-import Tracking from '~/tracking';
import NavItem from '~/super_sidebar/components/nav_item.vue';
import { NAV_LINK_DEFAULT_CLASSES, NAV_LINK_COUNT_DEFAULT_CLASSES } from '../constants';
@@ -13,7 +13,6 @@ export default {
components: {
NavItem,
},
- mixins: [Tracking.mixin()],
computed: {
...mapState(['navigation', 'urlQuery']),
...mapGetters(['navigationItems']),
diff --git a/app/assets/javascripts/search/sidebar/components/status_filter.vue b/app/assets/javascripts/search/sidebar/components/status_filter.vue
deleted file mode 100644
index 2a3d9ede982..00000000000
--- a/app/assets/javascripts/search/sidebar/components/status_filter.vue
+++ /dev/null
@@ -1,25 +0,0 @@
-<script>
-import { mapState } from 'vuex';
-import { stateFilterData } from '../constants/state_filter_data';
-import { HR_DEFAULT_CLASSES } from '../constants';
-import RadioFilter from './radio_filter.vue';
-
-export default {
- name: 'StatusFilter',
- components: {
- RadioFilter,
- },
- computed: {
- ...mapState(['useNewNavigation']),
- },
- stateFilterData,
- HR_DEFAULT_CLASSES,
-};
-</script>
-
-<template>
- <div>
- <radio-filter :filter-data="$options.stateFilterData" />
- <hr v-if="!useNewNavigation" :class="$options.HR_DEFAULT_CLASSES" />
- </div>
-</template>
diff --git a/app/assets/javascripts/search/sidebar/constants/state_filter_data.js b/app/assets/javascripts/search/sidebar/components/status_filter/data.js
index 2f9f8a7cb46..1e3cd59214b 100644
--- a/app/assets/javascripts/search/sidebar/constants/state_filter_data.js
+++ b/app/assets/javascripts/search/sidebar/components/status_filter/data.js
@@ -33,7 +33,7 @@ const filterByScope = {
const filterParam = 'state';
-export const stateFilterData = {
+export const statusFilterData = {
header,
filters,
scopes,
diff --git a/app/assets/javascripts/search/sidebar/components/status_filter/index.vue b/app/assets/javascripts/search/sidebar/components/status_filter/index.vue
new file mode 100644
index 00000000000..a5f717dcf06
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/status_filter/index.vue
@@ -0,0 +1,18 @@
+<script>
+import { HR_DEFAULT_CLASSES } from '../../constants';
+import RadioFilter from '../radio_filter.vue';
+import { statusFilterData } from './data';
+
+export default {
+ name: 'StatusFilter',
+ components: {
+ RadioFilter,
+ },
+ statusFilterData,
+ HR_DEFAULT_CLASSES,
+};
+</script>
+
+<template>
+ <radio-filter :filter-data="$options.statusFilterData" />
+</template>
diff --git a/app/assets/javascripts/search/sidebar/constants/index.js b/app/assets/javascripts/search/sidebar/constants/index.js
index 99d8821db61..01d0aad206c 100644
--- a/app/assets/javascripts/search/sidebar/constants/index.js
+++ b/app/assets/javascripts/search/sidebar/constants/index.js
@@ -1,6 +1,7 @@
export const SCOPE_ISSUES = 'issues';
export const SCOPE_MERGE_REQUESTS = 'merge_requests';
export const SCOPE_BLOB = 'blobs';
+export const SCOPE_PROJECTS = 'projects';
export const LABEL_DEFAULT_CLASSES = [
'gl-display-flex',
'gl-flex-direction-row',
@@ -18,4 +19,3 @@ export const ONLY_SHOW_MD = ['gl-display-none', 'gl-md-display-block'];
export const TRACKING_ACTION_CLICK = 'search:filters:click';
export const TRACKING_LABEL_APPLY = 'Apply Filters';
export const TRACKING_LABEL_RESET = 'Reset Filters';
-export const TRACKING_CATEGORY = 'Issue filters';
diff --git a/app/assets/javascripts/search/sort/components/app.vue b/app/assets/javascripts/search/sort/components/app.vue
index 9f28d2bfc99..79717802dc6 100644
--- a/app/assets/javascripts/search/sort/components/app.vue
+++ b/app/assets/javascripts/search/sort/components/app.vue
@@ -1,5 +1,6 @@
<script>
import { GlCollapsibleListbox, GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
import { SORT_DIRECTION_UI } from '../constants';
diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js
index 077c46bbe22..a68a0f75a2f 100644
--- a/app/assets/javascripts/search/store/actions.js
+++ b/app/assets/javascripts/search/store/actions.js
@@ -4,7 +4,6 @@ import axios from '~/lib/utils/axios_utils';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import { logError } from '~/lib/logger';
import { __ } from '~/locale';
-import { languageFilterData } from '~/search/sidebar/components/language_filter/data';
import { labelFilterData } from '~/search/sidebar/components/label_filter/data';
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, SIDEBAR_PARAMS } from './constants';
import * as types from './mutation_types';
@@ -108,9 +107,18 @@ export const applyQuery = ({ state }) => {
};
export const resetQuery = ({ state }) => {
+ const resetParams = SIDEBAR_PARAMS.reduce((acc, param) => {
+ acc[param] = null;
+ return acc;
+ }, {});
+
visitUrl(
setUrlParams(
- { ...state.query, page: null, state: null, confidential: null, labels: null },
+ {
+ ...state.query,
+ page: null,
+ ...resetParams,
+ },
undefined,
true,
),
@@ -127,14 +135,6 @@ export const setLabelFilterSearch = ({ commit }, { value }) => {
commit(types.SET_LABEL_SEARCH_STRING, value);
};
-export const resetLanguageQueryWithRedirect = ({ state }) => {
- visitUrl(setUrlParams({ ...state.query, language: null }, undefined, true));
-};
-
-export const resetLanguageQuery = ({ commit }) => {
- commit(types.SET_QUERY, { key: languageFilterData?.filterParam, value: [] });
-};
-
export const fetchSidebarCount = ({ commit, state }) => {
const promises = Object.values(state.navigation).map((navItem) => {
// active nav item has count already so we skip it
diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js
index bb112c122ae..f3b4a09b45b 100644
--- a/app/assets/javascripts/search/store/constants.js
+++ b/app/assets/javascripts/search/store/constants.js
@@ -1,7 +1,8 @@
-import { stateFilterData } from '~/search/sidebar/constants/state_filter_data';
-import { confidentialFilterData } from '~/search/sidebar/constants/confidential_filter_data';
+import { statusFilterData } from '~/search/sidebar/components/status_filter/data';
+import { confidentialFilterData } from '~/search/sidebar/components/confidentiality_filter/data';
import { languageFilterData } from '~/search/sidebar/components/language_filter/data';
import { labelFilterData } from '~/search/sidebar/components/label_filter/data';
+import { archivedFilterData } from '~/search/sidebar/components/archived_filter/data';
export const MAX_FREQUENT_ITEMS = 5;
@@ -12,10 +13,11 @@ export const GROUPS_LOCAL_STORAGE_KEY = 'global-search-frequent-groups';
export const PROJECTS_LOCAL_STORAGE_KEY = 'global-search-frequent-projects';
export const SIDEBAR_PARAMS = [
- stateFilterData.filterParam,
+ statusFilterData.filterParam,
confidentialFilterData.filterParam,
languageFilterData.filterParam,
labelFilterData.filterParam,
+ archivedFilterData.filterParam,
];
export const NUMBER_FORMATING_OPTIONS = { notation: 'compact', compactDisplay: 'short' };
diff --git a/app/assets/javascripts/search/store/getters.js b/app/assets/javascripts/search/store/getters.js
index c7cb595f42f..d01fd884bad 100644
--- a/app/assets/javascripts/search/store/getters.js
+++ b/app/assets/javascripts/search/store/getters.js
@@ -1,4 +1,4 @@
-import { findKey, has } from 'lodash';
+import { findKey } from 'lodash';
import { languageFilterData } from '~/search/sidebar/components/language_filter/data';
import { labelFilterData } from '~/search/sidebar/components/label_filter/data';
import { formatSearchResultCount, addCountOverLimit } from '~/search/store/utils';
@@ -47,9 +47,6 @@ export const appliedSelectedLabels = (state) => {
);
};
-export const filteredUnappliedSelectedLabels = (state) =>
- filteredLabels(state)?.filter((label) => state?.query?.labels?.includes(label.key));
-
export const filteredUnselectedLabels = (state) => {
if (!state?.urlQuery?.labels) {
return filteredLabels(state);
@@ -62,10 +59,6 @@ export const currentScope = (state) => findKey(state.navigation, { active: true
export const queryLanguageFilters = (state) => state.query[languageFilterData.filterParam] || [];
-export const currentUrlQueryHasLanguageFilters = (state) =>
- has(state.urlQuery, languageFilterData.filterParam) &&
- state.urlQuery[languageFilterData.filterParam]?.length > 0;
-
export const navigationItems = (state) =>
Object.values(state.navigation).map((item) => ({
title: item.label,
diff --git a/app/assets/javascripts/search/store/index.js b/app/assets/javascripts/search/store/index.js
index 2478518c157..e77438d9d6b 100644
--- a/app/assets/javascripts/search/store/index.js
+++ b/app/assets/javascripts/search/store/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
diff --git a/app/assets/javascripts/search/topbar/components/app.vue b/app/assets/javascripts/search/topbar/components/app.vue
index 16ff8c94885..ee66bdb2632 100644
--- a/app/assets/javascripts/search/topbar/components/app.vue
+++ b/app/assets/javascripts/search/topbar/components/app.vue
@@ -1,5 +1,6 @@
<script>
import { GlSearchBoxByClick, GlButton } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
import { s__ } from '~/locale';
import { parseBoolean } from '~/lib/utils/common_utils';
diff --git a/app/assets/javascripts/search/topbar/components/group_filter.vue b/app/assets/javascripts/search/topbar/components/group_filter.vue
index 4798f1127eb..a177eb28991 100644
--- a/app/assets/javascripts/search/topbar/components/group_filter.vue
+++ b/app/assets/javascripts/search/topbar/components/group_filter.vue
@@ -1,5 +1,6 @@
<script>
import { isEmpty } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions, mapGetters } from 'vuex';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '../constants';
diff --git a/app/assets/javascripts/search/topbar/components/project_filter.vue b/app/assets/javascripts/search/topbar/components/project_filter.vue
index 1cce3e3db8b..c8190b4002d 100644
--- a/app/assets/javascripts/search/topbar/components/project_filter.vue
+++ b/app/assets/javascripts/search/topbar/components/project_filter.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions, mapGetters } from 'vuex';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '../constants';
diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue
index e7d97989195..c7d89113895 100644
--- a/app/assets/javascripts/security_configuration/components/app.vue
+++ b/app/assets/javascripts/security_configuration/components/app.vue
@@ -182,7 +182,7 @@ export default {
<section-layout class="gl-border-b-0" :heading="$options.i18n.securityTesting">
<template #description>
<p>
- <span data-testid="latest-pipeline-info-security">
+ <span>
<gl-sprintf
v-if="latestPipelinePath"
:message="$options.i18n.latestPipelineDescription"
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index 1c2be99b393..b427820144d 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -57,7 +57,7 @@ export const DAST_HELP_PATH = helpPagePath('user/application_security/dast/index
export const DAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/dast/index', {
anchor: 'enable-automatic-dast-run',
});
-export const DAST_BADGE_TEXT = __('Available on-demand');
+export const DAST_BADGE_TEXT = __('Available on demand');
export const DAST_BADGE_TOOLTIP = __(
'On-demand scans run outside of the DevOps cycle and find vulnerabilities in your projects',
);
diff --git a/app/assets/javascripts/service_desk/components/empty_state_with_any_issues.vue b/app/assets/javascripts/service_desk/components/empty_state_with_any_issues.vue
new file mode 100644
index 00000000000..a15c8ee2e9f
--- /dev/null
+++ b/app/assets/javascripts/service_desk/components/empty_state_with_any_issues.vue
@@ -0,0 +1,58 @@
+<script>
+import { GlEmptyState } from '@gitlab/ui';
+import {
+ noSearchResultsTitle,
+ noSearchResultsDescription,
+ infoBannerUserNote,
+ noOpenIssuesTitle,
+ noClosedIssuesTitle,
+} from '../constants';
+
+export default {
+ i18n: {
+ noSearchResultsTitle,
+ noSearchResultsDescription,
+ infoBannerUserNote,
+ noOpenIssuesTitle,
+ noClosedIssuesTitle,
+ },
+ components: {
+ GlEmptyState,
+ },
+ inject: ['emptyStateSvgPath'],
+ props: {
+ hasSearch: {
+ type: Boolean,
+ required: true,
+ },
+ isOpenTab: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ content() {
+ if (this.hasSearch) {
+ return {
+ title: noSearchResultsTitle,
+ description: noSearchResultsDescription,
+ svgHeight: 150,
+ };
+ } else if (this.isOpenTab) {
+ return { title: noOpenIssuesTitle, description: infoBannerUserNote };
+ }
+
+ return { title: noClosedIssuesTitle, svgHeight: 150 };
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-empty-state
+ :description="content.description"
+ :title="content.title"
+ :svg-path="emptyStateSvgPath"
+ :svg-height="content.svgHeight"
+ />
+</template>
diff --git a/app/assets/javascripts/service_desk/components/empty_state_without_any_issues.vue b/app/assets/javascripts/service_desk/components/empty_state_without_any_issues.vue
new file mode 100644
index 00000000000..9dbed2c2579
--- /dev/null
+++ b/app/assets/javascripts/service_desk/components/empty_state_without_any_issues.vue
@@ -0,0 +1,74 @@
+<script>
+import { GlEmptyState, GlLink } from '@gitlab/ui';
+import {
+ noIssuesSignedOutButtonText,
+ infoBannerTitle,
+ infoBannerUserNote,
+ infoBannerAdminNote,
+ learnMore,
+} from '../constants';
+
+export default {
+ i18n: {
+ noIssuesSignedOutButtonText,
+ infoBannerTitle,
+ infoBannerUserNote,
+ infoBannerAdminNote,
+ learnMore,
+ },
+ components: {
+ GlEmptyState,
+ GlLink,
+ },
+ inject: [
+ 'emptyStateSvgPath',
+ 'isSignedIn',
+ 'signInPath',
+ 'canAdminIssues',
+ 'isServiceDeskEnabled',
+ 'serviceDeskEmailAddress',
+ 'serviceDeskHelpPath',
+ ],
+ computed: {
+ canSeeEmailAddress() {
+ return this.canAdminIssues && this.isServiceDeskEnabled;
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="isSignedIn">
+ <gl-empty-state
+ :title="$options.i18n.infoBannerTitle"
+ :svg-path="emptyStateSvgPath"
+ content-class="gl-max-w-80!"
+ >
+ <template #description>
+ <p v-if="canSeeEmailAddress">
+ {{ $options.i18n.infoBannerAdminNote }} <br /><code>{{ serviceDeskEmailAddress }}</code>
+ </p>
+ <p>{{ $options.i18n.infoBannerUserNote }}</p>
+ <gl-link :href="serviceDeskHelpPath">
+ {{ $options.i18n.learnMore }}
+ </gl-link>
+ </template>
+ </gl-empty-state>
+ </div>
+
+ <gl-empty-state
+ v-else
+ :title="$options.i18n.infoBannerTitle"
+ :svg-path="emptyStateSvgPath"
+ :primary-button-text="$options.i18n.noIssuesSignedOutButtonText"
+ :primary-button-link="signInPath"
+ content-class="gl-max-w-80!"
+ >
+ <template #description>
+ <p>{{ $options.i18n.infoBannerUserNote }}</p>
+ <gl-link :href="serviceDeskHelpPath">
+ {{ $options.i18n.learnMore }}
+ </gl-link>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/service_desk/components/info_banner.vue b/app/assets/javascripts/service_desk/components/info_banner.vue
index 8aaced839a5..5667ee2f31d 100644
--- a/app/assets/javascripts/service_desk/components/info_banner.vue
+++ b/app/assets/javascripts/service_desk/components/info_banner.vue
@@ -51,7 +51,7 @@ export default {
</p>
<p>
{{ $options.i18n.infoBannerUserNote }}
- <gl-link :href="serviceDeskHelpPath" target="_blank">{{ $options.i18n.learnMore }}</gl-link
+ <gl-link :href="serviceDeskHelpPath">{{ $options.i18n.learnMore }}</gl-link
>.
</p>
<p v-if="canEnableServiceDesk" class="gl-mt-3">
diff --git a/app/assets/javascripts/service_desk/components/service_desk_list_app.vue b/app/assets/javascripts/service_desk/components/service_desk_list_app.vue
index e8b05642e7d..56cd21d7ea9 100644
--- a/app/assets/javascripts/service_desk/components/service_desk_list_app.vue
+++ b/app/assets/javascripts/service_desk/components/service_desk_list_app.vue
@@ -1,49 +1,116 @@
<script>
-import { GlEmptyState } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import { isEmpty } from 'lodash';
import { fetchPolicies } from '~/lib/graphql';
+import { isPositiveInteger } from '~/lib/utils/number_utils';
+import axios from '~/lib/utils/axios_utils';
+import { getParameterByName } from '~/lib/utils/url_utility';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
-import { issuableListTabs } from '~/vue_shared/issuable/list/constants';
-import { STATUS_OPEN, STATUS_CLOSED, STATUS_ALL } from '~/issues/constants';
-import getServiceDeskIssuesQuery from '../queries/get_service_desk_issues.query.graphql';
-import getServiceDeskIssuesCounts from '../queries/get_service_desk_issues_counts.query.graphql';
+import { DEFAULT_PAGE_SIZE, issuableListTabs } from '~/vue_shared/issuable/list/constants';
+import {
+ convertToSearchQuery,
+ convertToApiParams,
+ getInitialPageParams,
+ getFilterTokens,
+ isSortKey,
+} from '~/issues/list/utils';
+import {
+ OPERATORS_IS_NOT,
+ OPERATORS_IS_NOT_OR,
+} from '~/vue_shared/components/filtered_search_bar/constants';
+import {
+ MAX_LIST_SIZE,
+ ISSUE_REFERENCE,
+ PARAM_STATE,
+ PARAM_FIRST_PAGE_SIZE,
+ PARAM_LAST_PAGE_SIZE,
+ PARAM_PAGE_AFTER,
+ PARAM_PAGE_BEFORE,
+ PARAM_SORT,
+ CREATED_DESC,
+ UPDATED_DESC,
+ urlSortParams,
+} from '~/issues/list/constants';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPENAME_USER } from '~/graphql_shared/constants';
+import searchProjectMembers from '~/graphql_shared/queries/project_user_members_search.query.graphql';
+import getServiceDeskIssuesQuery from 'ee_else_ce/service_desk/queries/get_service_desk_issues.query.graphql';
+import getServiceDeskIssuesCounts from 'ee_else_ce/service_desk/queries/get_service_desk_issues_counts.query.graphql';
+import searchProjectLabelsQuery from '../queries/search_project_labels.query.graphql';
+import searchProjectMilestonesQuery from '../queries/search_project_milestones.query.graphql';
import {
errorFetchingCounts,
errorFetchingIssues,
- noSearchNoFilterTitle,
searchPlaceholder,
SERVICE_DESK_BOT_USERNAME,
+ STATUS_OPEN,
+ STATUS_CLOSED,
+ STATUS_ALL,
+ WORKSPACE_PROJECT,
} from '../constants';
+import { convertToUrlParams } from '../utils';
+import {
+ searchWithinTokenBase,
+ assigneeTokenBase,
+ milestoneTokenBase,
+ labelTokenBase,
+ releaseTokenBase,
+ reactionTokenBase,
+ confidentialityTokenBase,
+} from '../search_tokens';
import InfoBanner from './info_banner.vue';
+import EmptyStateWithAnyIssues from './empty_state_with_any_issues.vue';
+import EmptyStateWithoutAnyIssues from './empty_state_without_any_issues.vue';
export default {
i18n: {
errorFetchingCounts,
errorFetchingIssues,
- noSearchNoFilterTitle,
searchPlaceholder,
},
issuableListTabs,
components: {
- GlEmptyState,
IssuableList,
InfoBanner,
+ EmptyStateWithAnyIssues,
+ EmptyStateWithoutAnyIssues,
},
+ mixins: [glFeatureFlagMixin()],
inject: [
+ 'releasesPath',
+ 'autocompleteAwardEmojisPath',
+ 'hasIterationsFeature',
+ 'hasIssueWeightsFeature',
+ 'hasIssuableHealthStatusFeature',
+ 'groupPath',
'emptyStateSvgPath',
'isProject',
'isSignedIn',
'fullPath',
'isServiceDeskSupported',
'hasAnyIssues',
+ 'initialSort',
],
+ props: {
+ eeSearchTokens: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
data() {
return {
serviceDeskIssues: [],
serviceDeskIssuesCounts: {},
- searchTokens: [],
sortOptions: [],
+ filterTokens: [],
+ pageInfo: {},
+ pageParams: {},
+ sortKey: CREATED_DESC,
state: STATUS_OPEN,
+ pageSize: DEFAULT_PAGE_SIZE,
issuesError: null,
};
},
@@ -71,7 +138,7 @@ export default {
Sentry.captureException(error);
},
skip() {
- return !this.hasAnyIssues;
+ return this.shouldSkipQuery;
},
},
serviceDeskIssuesCounts: {
@@ -86,6 +153,9 @@ export default {
this.issuesError = this.$options.i18n.errorFetchingCounts;
Sentry.captureException(error);
},
+ skip() {
+ return this.shouldSkipQuery;
+ },
context: {
isSingleRequest: true,
},
@@ -93,14 +163,23 @@ export default {
},
computed: {
queryVariables() {
+ const isIidSearch = ISSUE_REFERENCE.test(this.searchQuery);
return {
fullPath: this.fullPath,
+ iid: isIidSearch ? this.searchQuery.slice(1) : undefined,
isProject: this.isProject,
isSignedIn: this.isSignedIn,
authorUsername: SERVICE_DESK_BOT_USERNAME,
+ sort: this.sortKey,
state: this.state,
+ ...this.pageParams,
+ ...this.apiFilterParams,
+ search: isIidSearch ? undefined : this.searchQuery,
};
},
+ shouldSkipQuery() {
+ return !this.hasAnyIssues || isEmpty(this.pageParams);
+ },
tabCounts() {
const { openedIssues, closedIssues, allIssues } = this.serviceDeskIssuesCounts;
return {
@@ -109,16 +188,222 @@ export default {
[STATUS_ALL]: allIssues?.count,
};
},
+ isLoading() {
+ return this.$apollo.queries.serviceDeskIssues.loading;
+ },
+ isOpenTab() {
+ return this.state === STATUS_OPEN;
+ },
+ urlParams() {
+ return {
+ sort: urlSortParams[this.sortKey],
+ state: this.state,
+ ...this.urlFilterParams,
+ first_page_size: this.pageParams.firstPageSize,
+ last_page_size: this.pageParams.lastPageSize,
+ page_after: this.pageParams.afterCursor ?? undefined,
+ page_before: this.pageParams.beforeCursor ?? undefined,
+ };
+ },
isInfoBannerVisible() {
- return this.isServiceDeskSupported && this.hasAnyIssues;
+ return this.isServiceDeskSupported && this.hasAnyServiceDeskIssues;
},
+ hasAnyServiceDeskIssues() {
+ return this.hasSearch || Boolean(this.tabCounts.all);
+ },
+ hasOrFeature() {
+ return this.glFeatures.orIssuableQueries;
+ },
+ hasSearch() {
+ return Boolean(
+ this.searchQuery ||
+ Object.keys(this.urlFilterParams).length ||
+ this.pageParams.afterCursor ||
+ this.pageParams.beforeCursor,
+ );
+ },
+ apiFilterParams() {
+ return convertToApiParams(this.filterTokens);
+ },
+ urlFilterParams() {
+ return convertToUrlParams(this.filterTokens);
+ },
+ searchQuery() {
+ return convertToSearchQuery(this.filterTokens);
+ },
+ searchTokens() {
+ const preloadedUsers = [];
+
+ if (gon.current_user_id) {
+ preloadedUsers.push({
+ id: convertToGraphQLId(TYPENAME_USER, gon.current_user_id),
+ name: gon.current_user_fullname,
+ username: gon.current_username,
+ avatar_url: gon.current_user_avatar_url,
+ });
+ }
+
+ const tokens = [
+ {
+ ...searchWithinTokenBase,
+ },
+ {
+ ...assigneeTokenBase,
+ operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT,
+ fetchUsers: this.fetchUsers,
+ recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-assignee`,
+ preloadedUsers,
+ },
+ {
+ ...milestoneTokenBase,
+ fetchMilestones: this.fetchMilestones,
+ recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-milestone`,
+ },
+ {
+ ...labelTokenBase,
+ operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT,
+ fetchLabels: this.fetchLabels,
+ fetchLatestLabels: this.glFeatures.frontendCaching ? this.fetchLatestLabels : null,
+ recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-label`,
+ },
+ ];
+
+ if (this.isProject) {
+ tokens.push({
+ ...releaseTokenBase,
+ fetchReleases: this.fetchReleases,
+ recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-release`,
+ });
+ }
+
+ if (this.isSignedIn) {
+ tokens.push({
+ ...reactionTokenBase,
+ fetchEmojis: this.fetchEmojis,
+ recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-my_reaction`,
+ });
+
+ tokens.push({
+ ...confidentialityTokenBase,
+ });
+ }
+
+ if (this.eeSearchTokens.length) {
+ tokens.push(...this.eeSearchTokens);
+ }
+
+ tokens.sort((a, b) => a.title.localeCompare(b.title));
+
+ return tokens;
+ },
+ },
+ watch: {
+ $route(newValue, oldValue) {
+ if (newValue.fullPath !== oldValue.fullPath) {
+ this.updateData(getParameterByName(PARAM_SORT));
+ }
+ },
+ },
+ created() {
+ this.updateData(this.initialSort);
+ this.cache = {};
},
methods: {
+ fetchWithCache(path, cacheName, searchKey, search) {
+ if (this.cache[cacheName]) {
+ const data = search
+ ? fuzzaldrinPlus.filter(this.cache[cacheName], search, { key: searchKey })
+ : this.cache[cacheName].slice(0, MAX_LIST_SIZE);
+ return Promise.resolve(data);
+ }
+
+ return axios.get(path).then(({ data }) => {
+ this.cache[cacheName] = data;
+ return data.slice(0, MAX_LIST_SIZE);
+ });
+ },
+ fetchUsers(search) {
+ return this.$apollo
+ .query({
+ query: searchProjectMembers,
+ variables: { fullPath: this.fullPath, search },
+ })
+ .then(({ data }) =>
+ data[WORKSPACE_PROJECT]?.[`${WORKSPACE_PROJECT}Members`].nodes.map(
+ (member) => member.user,
+ ),
+ );
+ },
+ fetchMilestones(search) {
+ return this.$apollo
+ .query({
+ query: searchProjectMilestonesQuery,
+ variables: { fullPath: this.fullPath, search },
+ })
+ .then(({ data }) => data[WORKSPACE_PROJECT]?.milestones.nodes);
+ },
+ fetchEmojis(search) {
+ return this.fetchWithCache(this.autocompleteAwardEmojisPath, 'emojis', 'name', search);
+ },
+ fetchReleases(search) {
+ return this.fetchWithCache(this.releasesPath, 'releases', 'tag', search);
+ },
+ fetchLabelsWithFetchPolicy(search, fetchPolicy = fetchPolicies.CACHE_FIRST) {
+ return this.$apollo
+ .query({
+ query: searchProjectLabelsQuery,
+ variables: { fullPath: this.fullPath, search },
+ fetchPolicy,
+ })
+ .then(({ data }) => data[WORKSPACE_PROJECT]?.labels.nodes)
+ .then((labels) =>
+ // TODO remove once we can search by title-only on the backend
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/346353
+ labels.filter((label) => label.title.toLowerCase().includes(search.toLowerCase())),
+ );
+ },
+ fetchLabels(search) {
+ return this.fetchLabelsWithFetchPolicy(search);
+ },
+ fetchLatestLabels(search) {
+ return this.fetchLabelsWithFetchPolicy(search, fetchPolicies.NETWORK_ONLY);
+ },
handleClickTab(state) {
if (this.state === state) {
return;
}
this.state = state;
+ this.pageParams = getInitialPageParams(this.pageSize);
+
+ this.$router.push({ query: this.urlParams });
+ },
+ handleFilter(tokens) {
+ this.filterTokens = tokens;
+ this.pageParams = getInitialPageParams(this.pageSize);
+
+ this.$router.push({ query: this.urlParams });
+ },
+ updateData(sortValue) {
+ const firstPageSize = getParameterByName(PARAM_FIRST_PAGE_SIZE);
+ const lastPageSize = getParameterByName(PARAM_LAST_PAGE_SIZE);
+ const state = getParameterByName(PARAM_STATE);
+
+ const defaultSortKey = state === STATUS_CLOSED ? UPDATED_DESC : CREATED_DESC;
+ const graphQLSortKey = isSortKey(sortValue?.toUpperCase()) && sortValue.toUpperCase();
+
+ const sortKey = graphQLSortKey || defaultSortKey;
+
+ this.filterTokens = getFilterTokens(window.location.search);
+
+ this.pageParams = getInitialPageParams(
+ this.pageSize,
+ isPositiveInteger(firstPageSize) ? parseInt(firstPageSize, 10) : undefined,
+ isPositiveInteger(lastPageSize) ? parseInt(lastPageSize, 10) : undefined,
+ getParameterByName(PARAM_PAGE_AFTER),
+ getParameterByName(PARAM_PAGE_BEFORE),
+ );
+ this.sortKey = sortKey;
+ this.state = state || STATUS_OPEN;
},
},
};
@@ -128,24 +413,31 @@ export default {
<section>
<info-banner v-if="isInfoBannerVisible" />
<issuable-list
+ v-if="isLoading || hasAnyServiceDeskIssues"
namespace="service-desk"
- recent-searches-storage-key="issues"
+ recent-searches-storage-key="service-desk-issues"
:error="issuesError"
:search-input-placeholder="$options.i18n.searchPlaceholder"
:search-tokens="searchTokens"
+ :issuables-loading="isLoading"
+ :initial-filter-value="filterTokens"
+ :show-filtered-search-friendly-text="hasOrFeature"
:sort-options="sortOptions"
+ :initial-sort-by="sortKey"
:issuables="serviceDeskIssues"
:tabs="$options.issuableListTabs"
:tab-counts="tabCounts"
:current-tab="state"
+ :default-page-size="pageSize"
+ sync-filter-and-sort
@click-tab="handleClickTab"
+ @filter="handleFilter"
>
<template #empty-state>
- <gl-empty-state
- :svg-path="emptyStateSvgPath"
- :title="$options.i18n.noSearchNoFilterTitle"
- />
+ <empty-state-with-any-issues :has-search="hasSearch" :is-open-tab="isOpenTab" />
</template>
</issuable-list>
+
+ <empty-state-without-any-issues v-else />
</section>
</template>
diff --git a/app/assets/javascripts/service_desk/constants.js b/app/assets/javascripts/service_desk/constants.js
index 685ad738792..a83c0d9ca57 100644
--- a/app/assets/javascripts/service_desk/constants.js
+++ b/app/assets/javascripts/service_desk/constants.js
@@ -1,10 +1,240 @@
import { __, s__ } from '~/locale';
+import {
+ FILTERED_SEARCH_TERM,
+ OPERATOR_IS,
+ OPERATOR_NOT,
+ OPERATOR_OR,
+ TOKEN_TYPE_ASSIGNEE,
+ TOKEN_TYPE_CONFIDENTIAL,
+ TOKEN_TYPE_EPIC,
+ TOKEN_TYPE_HEALTH,
+ TOKEN_TYPE_ITERATION,
+ TOKEN_TYPE_LABEL,
+ TOKEN_TYPE_MILESTONE,
+ TOKEN_TYPE_MY_REACTION,
+ TOKEN_TYPE_RELEASE,
+ TOKEN_TYPE_TYPE,
+ TOKEN_TYPE_WEIGHT,
+ TOKEN_TYPE_SEARCH_WITHIN,
+} from '~/vue_shared/components/filtered_search_bar/constants';
+import {
+ ALTERNATIVE_FILTER,
+ API_PARAM,
+ NORMAL_FILTER,
+ SPECIAL_FILTER,
+ URL_PARAM,
+} from '~/issues/list/constants';
export const SERVICE_DESK_BOT_USERNAME = 'support-bot';
+export const ISSUE_REFERENCE = /^#\d+$/;
+
+export const STATUS_ALL = 'all';
+export const STATUS_CLOSED = 'closed';
+export const STATUS_OPEN = 'opened';
+
+export const WORKSPACE_PROJECT = 'project';
+
+export const filtersMap = {
+ [FILTERED_SEARCH_TERM]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'search',
+ },
+ [URL_PARAM]: {
+ [undefined]: {
+ [NORMAL_FILTER]: 'search',
+ },
+ },
+ },
+ [TOKEN_TYPE_SEARCH_WITHIN]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'in',
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'in',
+ },
+ },
+ },
+ [TOKEN_TYPE_ASSIGNEE]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'assigneeUsernames',
+ [SPECIAL_FILTER]: 'assigneeId',
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'assignee_username[]',
+ [SPECIAL_FILTER]: 'assignee_id',
+ [ALTERNATIVE_FILTER]: 'assignee_username',
+ },
+ [OPERATOR_NOT]: {
+ [NORMAL_FILTER]: 'not[assignee_username][]',
+ },
+ [OPERATOR_OR]: {
+ [NORMAL_FILTER]: 'or[assignee_username][]',
+ },
+ },
+ },
+ [TOKEN_TYPE_MILESTONE]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'milestoneTitle',
+ [SPECIAL_FILTER]: 'milestoneWildcardId',
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'milestone_title',
+ [SPECIAL_FILTER]: 'milestone_title',
+ },
+ [OPERATOR_NOT]: {
+ [NORMAL_FILTER]: 'not[milestone_title]',
+ [SPECIAL_FILTER]: 'not[milestone_title]',
+ },
+ },
+ },
+ [TOKEN_TYPE_LABEL]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'labelName',
+ [SPECIAL_FILTER]: 'labelName',
+ [ALTERNATIVE_FILTER]: 'labelNames',
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'label_name[]',
+ [SPECIAL_FILTER]: 'label_name[]',
+ [ALTERNATIVE_FILTER]: 'label_name',
+ },
+ [OPERATOR_NOT]: {
+ [NORMAL_FILTER]: 'not[label_name][]',
+ },
+ [OPERATOR_OR]: {
+ [ALTERNATIVE_FILTER]: 'or[label_name][]',
+ },
+ },
+ },
+ [TOKEN_TYPE_TYPE]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'types',
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'type[]',
+ },
+ [OPERATOR_NOT]: {
+ [NORMAL_FILTER]: 'not[type][]',
+ },
+ },
+ },
+ [TOKEN_TYPE_RELEASE]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'releaseTag',
+ [SPECIAL_FILTER]: 'releaseTagWildcardId',
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'release_tag',
+ [SPECIAL_FILTER]: 'release_tag',
+ },
+ [OPERATOR_NOT]: {
+ [NORMAL_FILTER]: 'not[release_tag]',
+ },
+ },
+ },
+ [TOKEN_TYPE_MY_REACTION]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'myReactionEmoji',
+ [SPECIAL_FILTER]: 'myReactionEmoji',
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'my_reaction_emoji',
+ [SPECIAL_FILTER]: 'my_reaction_emoji',
+ },
+ [OPERATOR_NOT]: {
+ [NORMAL_FILTER]: 'not[my_reaction_emoji]',
+ },
+ },
+ },
+ [TOKEN_TYPE_CONFIDENTIAL]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'confidential',
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'confidential',
+ },
+ },
+ },
+ [TOKEN_TYPE_ITERATION]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'iterationId',
+ [SPECIAL_FILTER]: 'iterationWildcardId',
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'iteration_id',
+ [SPECIAL_FILTER]: 'iteration_id',
+ },
+ [OPERATOR_NOT]: {
+ [NORMAL_FILTER]: 'not[iteration_id]',
+ [SPECIAL_FILTER]: 'not[iteration_id]',
+ },
+ },
+ },
+ [TOKEN_TYPE_EPIC]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'epicId',
+ [SPECIAL_FILTER]: 'epicId',
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'epic_id',
+ [SPECIAL_FILTER]: 'epic_id',
+ },
+ [OPERATOR_NOT]: {
+ [NORMAL_FILTER]: 'not[epic_id]',
+ },
+ },
+ },
+ [TOKEN_TYPE_WEIGHT]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'weight',
+ [SPECIAL_FILTER]: 'weight',
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'weight',
+ [SPECIAL_FILTER]: 'weight',
+ },
+ [OPERATOR_NOT]: {
+ [NORMAL_FILTER]: 'not[weight]',
+ },
+ },
+ },
+ [TOKEN_TYPE_HEALTH]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'healthStatusFilter',
+ [SPECIAL_FILTER]: 'healthStatusFilter',
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'health_status',
+ [SPECIAL_FILTER]: 'health_status',
+ },
+ [OPERATOR_NOT]: {
+ [NORMAL_FILTER]: 'not[health_status]',
+ },
+ },
+ },
+};
export const errorFetchingCounts = __('An error occurred while getting issue counts');
export const errorFetchingIssues = __('An error occurred while loading issues');
-export const noSearchNoFilterTitle = __('Please select at least one filter to see results');
+export const noOpenIssuesTitle = __('There are no open issues');
+export const noClosedIssuesTitle = __('There are no closed issues');
+export const noIssuesSignedOutButtonText = __('Register / Sign In');
+export const noSearchResultsDescription = __(
+ 'To widen your search, change or remove filters above',
+);
+export const noSearchResultsTitle = __('Sorry, your filter produced no results');
export const searchPlaceholder = __('Search or filter results...');
export const infoBannerTitle = s__(
'ServiceDesk|Use Service Desk to connect with your users and offer customer support through email right inside GitLab',
@@ -14,4 +244,8 @@ export const infoBannerUserNote = s__(
'ServiceDesk|Issues created from Service Desk emails will appear here. Each comment becomes part of the email conversation.',
);
export const enableServiceDesk = s__('ServiceDesk|Enable Service Desk');
-export const learnMore = __('Learn more');
+export const learnMore = __('Learn more about Service Desk');
+export const titles = __('Titles');
+export const descriptions = __('Descriptions');
+export const no = __('No');
+export const yes = __('Yes');
diff --git a/app/assets/javascripts/service_desk/index.js b/app/assets/javascripts/service_desk/index.js
index a9172f96540..afb2d0e8de3 100644
--- a/app/assets/javascripts/service_desk/index.js
+++ b/app/assets/javascripts/service_desk/index.js
@@ -1,8 +1,9 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import VueRouter from 'vue-router';
import { parseBoolean } from '~/lib/utils/common_utils';
+import ServiceDeskListApp from 'ee_else_ce/service_desk/components/service_desk_list_app.vue';
import { gqlClient } from './graphql';
-import ServiceDeskListApp from './components/service_desk_list_app.vue';
export async function mountServiceDeskListApp() {
const el = document.querySelector('.js-service-desk-list');
@@ -12,11 +13,19 @@ export async function mountServiceDeskListApp() {
}
const {
+ projectDataReleasesPath,
+ projectDataAutocompleteAwardEmojisPath,
+ projectDataHasIterationsFeature,
+ projectDataHasIssueWeightsFeature,
+ projectDataHasIssuableHealthStatusFeature,
+ projectDataGroupPath,
projectDataEmptyStateSvgPath,
projectDataFullPath,
projectDataIsProject,
projectDataIsSignedIn,
+ projectDataSignInPath,
projectDataHasAnyIssues,
+ projectDataInitialSort,
serviceDeskEmailAddress,
canAdminIssues,
canEditProjectSettings,
@@ -28,6 +37,7 @@ export async function mountServiceDeskListApp() {
} = el.dataset;
Vue.use(VueApollo);
+ Vue.use(VueRouter);
return new Vue({
el,
@@ -35,7 +45,18 @@ export async function mountServiceDeskListApp() {
apolloProvider: new VueApollo({
defaultClient: await gqlClient(),
}),
+ router: new VueRouter({
+ base: window.location.pathname,
+ mode: 'history',
+ routes: [{ path: '/' }],
+ }),
provide: {
+ releasesPath: projectDataReleasesPath,
+ autocompleteAwardEmojisPath: projectDataAutocompleteAwardEmojisPath,
+ hasIterationsFeature: parseBoolean(projectDataHasIterationsFeature),
+ hasIssueWeightsFeature: parseBoolean(projectDataHasIssueWeightsFeature),
+ hasIssuableHealthStatusFeature: parseBoolean(projectDataHasIssuableHealthStatusFeature),
+ groupPath: projectDataGroupPath,
emptyStateSvgPath: projectDataEmptyStateSvgPath,
fullPath: projectDataFullPath,
isProject: parseBoolean(projectDataIsProject),
@@ -48,7 +69,9 @@ export async function mountServiceDeskListApp() {
serviceDeskHelpPath,
isServiceDeskSupported: parseBoolean(isServiceDeskSupported),
isServiceDeskEnabled: parseBoolean(isServiceDeskEnabled),
+ signInPath: projectDataSignInPath,
hasAnyIssues: parseBoolean(projectDataHasAnyIssues),
+ initialSort: projectDataInitialSort,
},
render: (createComponent) => createComponent(ServiceDeskListApp),
});
diff --git a/app/assets/javascripts/service_desk/queries/label.fragment.graphql b/app/assets/javascripts/service_desk/queries/label.fragment.graphql
new file mode 100644
index 00000000000..bb1d8f1ac9b
--- /dev/null
+++ b/app/assets/javascripts/service_desk/queries/label.fragment.graphql
@@ -0,0 +1,6 @@
+fragment Label on Label {
+ id
+ color
+ textColor
+ title
+}
diff --git a/app/assets/javascripts/service_desk/queries/milestone.fragment.graphql b/app/assets/javascripts/service_desk/queries/milestone.fragment.graphql
new file mode 100644
index 00000000000..3cdf69bf585
--- /dev/null
+++ b/app/assets/javascripts/service_desk/queries/milestone.fragment.graphql
@@ -0,0 +1,4 @@
+fragment Milestone on Milestone {
+ id
+ title
+}
diff --git a/app/assets/javascripts/service_desk/queries/search_project_labels.query.graphql b/app/assets/javascripts/service_desk/queries/search_project_labels.query.graphql
new file mode 100644
index 00000000000..89ce14134b4
--- /dev/null
+++ b/app/assets/javascripts/service_desk/queries/search_project_labels.query.graphql
@@ -0,0 +1,14 @@
+#import "./label.fragment.graphql"
+
+query searchProjectLabels($fullPath: ID!, $search: String) {
+ project(fullPath: $fullPath) @persist {
+ id
+ labels(searchTerm: $search, includeAncestorGroups: true) {
+ __persist
+ nodes {
+ __persist
+ ...Label
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/service_desk/queries/search_project_milestones.query.graphql b/app/assets/javascripts/service_desk/queries/search_project_milestones.query.graphql
new file mode 100644
index 00000000000..f34166be87d
--- /dev/null
+++ b/app/assets/javascripts/service_desk/queries/search_project_milestones.query.graphql
@@ -0,0 +1,17 @@
+#import "./milestone.fragment.graphql"
+
+query searchProjectMilestones($fullPath: ID!, $search: String) {
+ project(fullPath: $fullPath) {
+ id
+ milestones(
+ searchTitle: $search
+ includeAncestors: true
+ sort: EXPIRED_LAST_DUE_DATE_ASC
+ state: active
+ ) {
+ nodes {
+ ...Milestone
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/service_desk/search_tokens.js b/app/assets/javascripts/service_desk/search_tokens.js
new file mode 100644
index 00000000000..72750f518e4
--- /dev/null
+++ b/app/assets/javascripts/service_desk/search_tokens.js
@@ -0,0 +1,97 @@
+import { GlFilteredSearchToken } from '@gitlab/ui';
+import {
+ OPERATORS_IS,
+ TOKEN_TITLE_ASSIGNEE,
+ TOKEN_TITLE_CONFIDENTIAL,
+ TOKEN_TITLE_LABEL,
+ TOKEN_TITLE_MILESTONE,
+ TOKEN_TITLE_MY_REACTION,
+ TOKEN_TITLE_RELEASE,
+ TOKEN_TITLE_SEARCH_WITHIN,
+ TOKEN_TYPE_ASSIGNEE,
+ TOKEN_TYPE_CONFIDENTIAL,
+ TOKEN_TYPE_LABEL,
+ TOKEN_TYPE_MILESTONE,
+ TOKEN_TYPE_RELEASE,
+ TOKEN_TYPE_MY_REACTION,
+ TOKEN_TYPE_SEARCH_WITHIN,
+} from '~/vue_shared/components/filtered_search_bar/constants';
+import { titles, descriptions, yes, no } from './constants';
+
+const UserToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/user_token.vue');
+const EmojiToken = () =>
+ import('~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue');
+const LabelToken = () =>
+ import('~/vue_shared/components/filtered_search_bar/tokens/label_token.vue');
+const MilestoneToken = () =>
+ import('~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue');
+const ReleaseToken = () =>
+ import('~/vue_shared/components/filtered_search_bar/tokens/release_token.vue');
+
+export const searchWithinTokenBase = {
+ type: TOKEN_TYPE_SEARCH_WITHIN,
+ title: TOKEN_TITLE_SEARCH_WITHIN,
+ icon: 'search',
+ token: GlFilteredSearchToken,
+ unique: true,
+ operators: OPERATORS_IS,
+ options: [
+ { icon: 'title', value: 'TITLE', title: titles },
+ {
+ icon: 'text-description',
+ value: 'DESCRIPTION',
+ title: descriptions,
+ },
+ ],
+};
+
+export const assigneeTokenBase = {
+ type: TOKEN_TYPE_ASSIGNEE,
+ title: TOKEN_TITLE_ASSIGNEE,
+ icon: 'user',
+ token: UserToken,
+ dataType: 'user',
+};
+
+export const milestoneTokenBase = {
+ type: TOKEN_TYPE_MILESTONE,
+ title: TOKEN_TITLE_MILESTONE,
+ icon: 'clock',
+ token: MilestoneToken,
+ shouldSkipSort: true,
+};
+
+export const labelTokenBase = {
+ type: TOKEN_TYPE_LABEL,
+ title: TOKEN_TITLE_LABEL,
+ icon: 'labels',
+ token: LabelToken,
+};
+
+export const releaseTokenBase = {
+ type: TOKEN_TYPE_RELEASE,
+ title: TOKEN_TITLE_RELEASE,
+ icon: 'rocket',
+ token: ReleaseToken,
+};
+
+export const reactionTokenBase = {
+ type: TOKEN_TYPE_MY_REACTION,
+ title: TOKEN_TITLE_MY_REACTION,
+ icon: 'thumb-up',
+ token: EmojiToken,
+ unique: true,
+};
+
+export const confidentialityTokenBase = {
+ type: TOKEN_TYPE_CONFIDENTIAL,
+ title: TOKEN_TITLE_CONFIDENTIAL,
+ icon: 'eye-slash',
+ token: GlFilteredSearchToken,
+ unique: true,
+ operators: OPERATORS_IS,
+ options: [
+ { icon: 'eye-slash', value: 'yes', title: yes },
+ { icon: 'eye', value: 'no', title: no },
+ ],
+};
diff --git a/app/assets/javascripts/service_desk/utils.js b/app/assets/javascripts/service_desk/utils.js
new file mode 100644
index 00000000000..86f76da3880
--- /dev/null
+++ b/app/assets/javascripts/service_desk/utils.js
@@ -0,0 +1,37 @@
+import {
+ OPERATOR_OR,
+ TOKEN_TYPE_LABEL,
+} from '~/vue_shared/components/filtered_search_bar/constants';
+import { isSpecialFilter, isNotEmptySearchToken } from '~/issues/list/utils';
+import {
+ ALTERNATIVE_FILTER,
+ NORMAL_FILTER,
+ SPECIAL_FILTER,
+ URL_PARAM,
+} from '~/issues/list/constants';
+import { filtersMap } from './constants';
+
+const getFilterType = ({ type, value: { data, operator } }) => {
+ const isUnionedLabel = type === TOKEN_TYPE_LABEL && operator === OPERATOR_OR;
+
+ if (isUnionedLabel) {
+ return ALTERNATIVE_FILTER;
+ }
+ if (isSpecialFilter(type, data)) {
+ return SPECIAL_FILTER;
+ }
+ return NORMAL_FILTER;
+};
+
+export const convertToUrlParams = (filterTokens) => {
+ const urlParamsMap = filterTokens.filter(isNotEmptySearchToken).reduce((acc, token) => {
+ const filterType = getFilterType(token);
+ const urlParam = filtersMap[token.type][URL_PARAM][token.value.operator]?.[filterType];
+ return acc.set(
+ urlParam,
+ acc.has(urlParam) ? [acc.get(urlParam), token.value.data].flat() : token.value.data,
+ );
+ }, new Map());
+
+ return Object.fromEntries(urlParamsMap);
+};
diff --git a/app/assets/javascripts/sessions/new/components/email_verification.vue b/app/assets/javascripts/sessions/new/components/email_verification.vue
new file mode 100644
index 00000000000..6a67c25b58f
--- /dev/null
+++ b/app/assets/javascripts/sessions/new/components/email_verification.vue
@@ -0,0 +1,211 @@
+<script>
+import { GlSprintf, GlForm, GlFormGroup, GlFormInput, GlButton } from '@gitlab/ui';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+import axios from '~/lib/utils/axios_utils';
+import {
+ I18N_EXPLANATION,
+ I18N_INPUT_LABEL,
+ I18N_EMAIL_EMPTY_CODE,
+ I18N_EMAIL_INVALID_CODE,
+ I18N_SUBMIT_BUTTON,
+ I18N_RESEND_LINK,
+ I18N_EMAIL_RESEND_SUCCESS,
+ I18N_GENERIC_ERROR,
+ I18N_UPDATE_EMAIL,
+ VERIFICATION_CODE_REGEX,
+ SUCCESS_RESPONSE,
+ FAILURE_RESPONSE,
+} from '../constants';
+import UpdateEmail from './update_email.vue';
+
+export default {
+ name: 'EmailVerification',
+ components: {
+ GlSprintf,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlButton,
+ UpdateEmail,
+ },
+ props: {
+ obfuscatedEmail: {
+ type: String,
+ required: true,
+ },
+ verifyPath: {
+ type: String,
+ required: true,
+ },
+ resendPath: {
+ type: String,
+ required: true,
+ },
+ isOfferEmailReset: {
+ type: Boolean,
+ required: true,
+ },
+ updateEmailPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ email: this.obfuscatedEmail,
+ verificationCode: '',
+ submitted: false,
+ verifyError: '',
+ showUpdateEmail: false,
+ };
+ },
+ computed: {
+ inputValidation() {
+ return {
+ state: !(this.submitted && this.invalidFeedback),
+ message: this.invalidFeedback,
+ };
+ },
+ invalidFeedback() {
+ if (!this.submitted) {
+ return '';
+ }
+
+ if (!this.verificationCode) {
+ return I18N_EMAIL_EMPTY_CODE;
+ }
+
+ if (!VERIFICATION_CODE_REGEX.test(this.verificationCode)) {
+ return I18N_EMAIL_INVALID_CODE;
+ }
+
+ return this.verifyError;
+ },
+ },
+ watch: {
+ verificationCode() {
+ this.verifyError = '';
+ },
+ },
+ methods: {
+ verify() {
+ this.submitted = true;
+
+ if (!this.inputValidation.state) return;
+
+ axios
+ .post(this.verifyPath, { user: { verification_token: this.verificationCode } })
+ .then(this.handleVerificationResponse)
+ .catch(this.handleError);
+ },
+ handleVerificationResponse(response) {
+ if (response.data.status === undefined) {
+ this.handleError();
+ } else if (response.data.status === SUCCESS_RESPONSE) {
+ visitUrl(response.data.redirect_path);
+ } else if (response.data.status === FAILURE_RESPONSE) {
+ this.verifyError = response.data.message;
+ }
+ },
+ resend() {
+ axios
+ .post(this.resendPath)
+ .then(this.handleResendResponse)
+ .catch(this.handleError)
+ .finally(this.resetForm);
+ },
+ handleResendResponse(response) {
+ if (response.data.status === undefined) {
+ this.handleError();
+ } else if (response.data.status === SUCCESS_RESPONSE) {
+ createAlert({
+ message: I18N_EMAIL_RESEND_SUCCESS,
+ variant: VARIANT_SUCCESS,
+ });
+ } else if (response.data.status === FAILURE_RESPONSE) {
+ createAlert({ message: response.data.message });
+ }
+ },
+ handleError(error) {
+ createAlert({
+ message: I18N_GENERIC_ERROR,
+ captureError: true,
+ error,
+ });
+ },
+ resetForm() {
+ this.verificationCode = '';
+ this.submitted = false;
+ this.$refs.input.$el.focus();
+ },
+ updateEmail() {
+ this.showUpdateEmail = true;
+ },
+ verifyToken(email = '') {
+ this.showUpdateEmail = false;
+ if (email.length) this.email = email;
+ this.$nextTick(this.resetForm);
+ },
+ },
+ i18n: {
+ explanation: I18N_EXPLANATION,
+ inputLabel: I18N_INPUT_LABEL,
+ submitButton: I18N_SUBMIT_BUTTON,
+ resendLink: I18N_RESEND_LINK,
+ updateEmail: I18N_UPDATE_EMAIL,
+ },
+};
+</script>
+
+<template>
+ <div>
+ <update-email
+ v-if="showUpdateEmail"
+ :update-email-path="updateEmailPath"
+ @verifyToken="verifyToken"
+ />
+ <gl-form v-else @submit.prevent="verify">
+ <section class="gl-mb-5">
+ <gl-sprintf :message="$options.i18n.explanation">
+ <template #email>
+ <strong>{{ email }}</strong>
+ </template>
+ </gl-sprintf>
+ </section>
+ <gl-form-group
+ :label="$options.i18n.inputLabel"
+ label-for="verification-code"
+ :state="inputValidation.state"
+ :invalid-feedback="inputValidation.message"
+ >
+ <gl-form-input
+ id="verification-code"
+ ref="input"
+ v-model="verificationCode"
+ autofocus
+ autocomplete="one-time-code"
+ inputmode="numeric"
+ maxlength="6"
+ :state="inputValidation.state"
+ />
+ </gl-form-group>
+ <section class="gl-mt-5">
+ <gl-button block variant="confirm" type="submit" :disabled="!inputValidation.state">{{
+ $options.i18n.submitButton
+ }}</gl-button>
+ <gl-button block variant="link" class="gl-mt-3 gl-h-7" @click="resend">{{
+ $options.i18n.resendLink
+ }}</gl-button>
+ <gl-button
+ v-if="isOfferEmailReset"
+ block
+ variant="link"
+ class="gl-mt-3 gl-h-7"
+ @click="updateEmail"
+ >{{ $options.i18n.updateEmail }}</gl-button
+ >
+ </section>
+ </gl-form>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sessions/new/components/update_email.vue b/app/assets/javascripts/sessions/new/components/update_email.vue
new file mode 100644
index 00000000000..124cd671169
--- /dev/null
+++ b/app/assets/javascripts/sessions/new/components/update_email.vue
@@ -0,0 +1,133 @@
+<script>
+import { GlForm, GlFormGroup, GlFormInput, GlButton } from '@gitlab/ui';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+import axios from '~/lib/utils/axios_utils';
+import {
+ I18N_EMAIL,
+ I18N_UPDATE_EMAIL,
+ I18N_UPDATE_EMAIL_GUIDANCE,
+ I18N_CANCEL,
+ I18N_EMAIL_INVALID,
+ I18N_UPDATE_EMAIL_SUCCESS,
+ I18N_GENERIC_ERROR,
+ EMAIL_REGEXP,
+ SUCCESS_RESPONSE,
+ FAILURE_RESPONSE,
+} from '../constants';
+
+export default {
+ name: 'UpdateEmail',
+ components: {
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlButton,
+ },
+ props: {
+ updateEmailPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ email: '',
+ submitted: false,
+ verifyError: '',
+ };
+ },
+ computed: {
+ inputValidation() {
+ return {
+ state: !(this.submitted && this.invalidFeedback),
+ message: this.invalidFeedback,
+ };
+ },
+ invalidFeedback() {
+ if (!this.submitted) {
+ return '';
+ }
+
+ if (!EMAIL_REGEXP.test(this.email)) {
+ return I18N_EMAIL_INVALID;
+ }
+
+ return this.verifyError;
+ },
+ },
+ watch: {
+ email() {
+ this.verifyError = '';
+ },
+ },
+ methods: {
+ updateEmail() {
+ this.submitted = true;
+
+ if (!this.inputValidation.state) return;
+
+ axios
+ .patch(this.updateEmailPath, { user: { email: this.email } })
+ .then(this.handleResponse)
+ .catch(this.handleError);
+ },
+ handleResponse(response) {
+ if (response.data.status === undefined) {
+ this.handleError();
+ } else if (response.data.status === SUCCESS_RESPONSE) {
+ this.handleSuccess();
+ } else if (response.data.status === FAILURE_RESPONSE) {
+ this.verifyError = response.data.message;
+ }
+ },
+ handleSuccess() {
+ createAlert({
+ message: I18N_UPDATE_EMAIL_SUCCESS,
+ variant: VARIANT_SUCCESS,
+ });
+ this.$emit('verifyToken', this.email);
+ },
+ handleError(error) {
+ createAlert({
+ message: I18N_GENERIC_ERROR,
+ captureError: true,
+ error,
+ });
+ },
+ },
+ i18n: {
+ email: I18N_EMAIL,
+ updateEmail: I18N_UPDATE_EMAIL,
+ cancel: I18N_CANCEL,
+ guidance: I18N_UPDATE_EMAIL_GUIDANCE,
+ },
+};
+</script>
+
+<template>
+ <gl-form novalidate @submit.prevent="updateEmail">
+ <gl-form-group
+ :label="$options.i18n.email"
+ label-for="update-email"
+ :state="inputValidation.state"
+ :invalid-feedback="inputValidation.message"
+ >
+ <gl-form-input
+ id="update-email"
+ v-model="email"
+ type="email"
+ autofocus
+ :state="inputValidation.state"
+ />
+ <p class="gl-mt-3 gl-text-secondary">{{ $options.i18n.guidance }}</p>
+ </gl-form-group>
+ <section class="gl-mt-5">
+ <gl-button block variant="confirm" type="submit" :disabled="!inputValidation.state">{{
+ $options.i18n.updateEmail
+ }}</gl-button>
+ <gl-button block variant="link" class="gl-mt-3 gl-h-7" @click="$emit('verifyToken')">{{
+ $options.i18n.cancel
+ }}</gl-button>
+ </section>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/sessions/new/constants.js b/app/assets/javascripts/sessions/new/constants.js
new file mode 100644
index 00000000000..e9bd26099aa
--- /dev/null
+++ b/app/assets/javascripts/sessions/new/constants.js
@@ -0,0 +1,30 @@
+import { s__, __ } from '~/locale';
+
+export const I18N_EXPLANATION = s__(
+ "IdentityVerification|For added security, you'll need to verify your identity. We've sent a verification code to %{email}",
+);
+export const I18N_INPUT_LABEL = s__('IdentityVerification|Verification code');
+export const I18N_EMAIL_EMPTY_CODE = s__('IdentityVerification|Enter a code.');
+export const I18N_EMAIL_INVALID_CODE = s__('IdentityVerification|Please enter a valid code');
+export const I18N_SUBMIT_BUTTON = s__('IdentityVerification|Verify code');
+export const I18N_RESEND_LINK = s__('IdentityVerification|Resend code');
+export const I18N_EMAIL_RESEND_SUCCESS = s__('IdentityVerification|A new code has been sent.');
+export const I18N_GENERIC_ERROR = s__(
+ 'IdentityVerification|Something went wrong. Please try again.',
+);
+
+export const I18N_EMAIL = __('Email');
+export const I18N_UPDATE_EMAIL = s__('IdentityVerification|Update email');
+export const I18N_UPDATE_EMAIL_GUIDANCE = s__(
+ "EmailVerification|Update your email to a valid permanent address. If you use a temporary email, you won't be able to sign in later.",
+);
+export const I18N_CANCEL = __('Cancel');
+export const I18N_EMAIL_INVALID = s__('IdentityVerification|Please enter a valid email address.');
+export const I18N_UPDATE_EMAIL_SUCCESS = s__(
+ 'IdentityVerification|A new code has been sent to your updated email address.',
+);
+
+export const VERIFICATION_CODE_REGEX = /^\d{6}$/;
+export const EMAIL_REGEXP = /^[^@\s]+@[^@\s]+$/; // Taken from DeviseEmailValidator
+export const SUCCESS_RESPONSE = 'success';
+export const FAILURE_RESPONSE = 'failure';
diff --git a/app/assets/javascripts/sessions/new/index.js b/app/assets/javascripts/sessions/new/index.js
new file mode 100644
index 00000000000..bf126b0e202
--- /dev/null
+++ b/app/assets/javascripts/sessions/new/index.js
@@ -0,0 +1,29 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import EmailVerification from './components/email_verification.vue';
+
+export default () => {
+ const el = document.querySelector('.js-email-verification');
+
+ if (!el) {
+ return null;
+ }
+
+ const { obfuscatedEmail, verifyPath, resendPath, offerEmailReset, updateEmailPath } = el.dataset;
+
+ return new Vue({
+ el,
+ name: 'EmailVerificationRoot',
+ render(createElement) {
+ return createElement(EmailVerification, {
+ props: {
+ obfuscatedEmail,
+ verifyPath,
+ resendPath,
+ isOfferEmailReset: parseBoolean(offerEmailReset),
+ updateEmailPath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/set_status_modal/set_status_form.vue b/app/assets/javascripts/set_status_modal/set_status_form.vue
index c96189c7cae..60ed0d073fe 100644
--- a/app/assets/javascripts/set_status_modal/set_status_form.vue
+++ b/app/assets/javascripts/set_status_modal/set_status_form.vue
@@ -6,8 +6,7 @@ import {
GlFormCheckbox,
GlFormInput,
GlFormInputGroup,
- GlDropdown,
- GlDropdownItem,
+ GlCollapsibleListbox,
GlFormGroup,
} from '@gitlab/ui';
import $ from 'jquery';
@@ -25,8 +24,7 @@ export default {
GlFormCheckbox,
GlFormInput,
GlFormInputGroup,
- GlDropdown,
- GlDropdownItem,
+ GlCollapsibleListbox,
GlFormGroup,
EmojiPicker: () => import('~/emoji/components/picker.vue'),
},
@@ -79,6 +77,9 @@ export default {
noEmoji() {
return this.emojiTag === '';
},
+ clearStatusAfterValue() {
+ return this.clearStatusAfter?.name;
+ },
clearStatusAfterDropdownText() {
if (this.clearStatusAfter === null && this.currentClearStatusAfter.length) {
return this.formatClearStatusAfterDate(new Date(this.currentClearStatusAfter));
@@ -94,11 +95,18 @@ export default {
return NEVER_TIME_RANGE.label;
},
+ clearStatusAfterDropdownItems() {
+ return TIME_RANGES_WITH_NEVER.map((item) => ({ text: item.label, value: item.name }));
+ },
},
mounted() {
this.setupEmojiListAndAutocomplete();
},
methods: {
+ onClearStatusAfterValueChange(value) {
+ const selectedValue = TIME_RANGES_WITH_NEVER.find((i) => i.name === value);
+ this.$emit('clear-status-after-click', selectedValue);
+ },
async setupEmojiListAndAutocomplete() {
const emojiAutocomplete = new GfmAutoComplete();
emojiAutocomplete.setup($(this.$refs.statusMessageField.$el), { emojis: true });
@@ -221,20 +229,13 @@ export default {
</gl-form-checkbox>
<gl-form-group :label="$options.i18n.clearStatusAfterDropdownLabel" class="gl-mb-0">
- <gl-dropdown
- block
- :text="clearStatusAfterDropdownText"
+ <gl-collapsible-listbox
+ :selected="clearStatusAfterValue"
+ :toggle-text="clearStatusAfterDropdownText"
+ :items="clearStatusAfterDropdownItems"
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>
+ @select="onClearStatusAfterValueChange"
+ />
</gl-form-group>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
index 5cdebee04ad..9d6a8bf47e0 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { TYPE_ISSUE } from '~/issues/constants';
import CollapsedAssigneeList from './collapsed_assignee_list.vue';
@@ -48,7 +49,7 @@ export default {
<div>
<collapsed-assignee-list :users="sortedAssigness" :issuable-type="issuableType" />
- <div data-testid="expanded-assignee" class="value hide-collapsed">
+ <div class="value hide-collapsed">
<span v-if="hasNoUsers" class="no-value" data-testid="no-value">
{{ __('None') }}
<template v-if="editable">
diff --git a/app/assets/javascripts/sidebar/components/confidential/confidentiality_dropdown.vue b/app/assets/javascripts/sidebar/components/confidential/confidentiality_dropdown.vue
new file mode 100644
index 00000000000..d1463bb813a
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/confidential/confidentiality_dropdown.vue
@@ -0,0 +1,55 @@
+<script>
+import { GlCollapsibleListbox } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlCollapsibleListbox,
+ },
+ data() {
+ return {
+ value: null,
+ };
+ },
+ i18n: {
+ defaultDropdownText: __('Select confidentiality'),
+ headerText: __('Change confidentiality'),
+ resetText: __('Reset'),
+ },
+ computed: {
+ toggleText() {
+ return this.value ? null : this.$options.i18n.defaultDropdownText;
+ },
+ },
+ methods: {
+ handleReset() {
+ this.value = null;
+ },
+ },
+ dropdownOptions: [
+ {
+ text: __('Confidential'),
+ value: 'true',
+ },
+ {
+ text: __('Not confidential'),
+ value: 'false',
+ },
+ ],
+};
+</script>
+
+<template>
+ <div>
+ <input type="hidden" name="update[confidentiality]" :value="value" />
+ <gl-collapsible-listbox
+ v-model="value"
+ block
+ :header-text="$options.i18n.headerText"
+ :reset-button-label="$options.i18n.resetText"
+ :toggle-text="toggleText"
+ :items="$options.dropdownOptions"
+ @reset="handleReset"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue
index 72a572087c7..8203dce67cd 100644
--- a/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue
+++ b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlCollapsibleListbox } from '@gitlab/ui';
import {
INCIDENTS_I18N as i18n,
STATUS_ACKNOWLEDGED,
@@ -14,8 +14,7 @@ export default {
i18n,
STATUS_LIST,
components: {
- GlDropdown,
- GlDropdownItem,
+ GlCollapsibleListbox,
},
props: {
value: {
@@ -26,52 +25,64 @@ export default {
return [...STATUS_LIST, null].includes(value);
},
},
- preventDropdownClose: {
- type: Boolean,
+ headerText: {
+ type: String,
required: false,
- default: false,
+ default: null,
+ },
+ statusSubtexts: {
+ type: Object,
+ required: false,
+ default() {
+ return {};
+ },
},
},
+ data() {
+ return {
+ selected: this.value,
+ };
+ },
computed: {
+ statusDropdownOptions() {
+ return this.$options.STATUS_LIST.map((status) => ({
+ text: this.getStatusLabel(status),
+ subtext: this.statusSubtexts[status],
+ value: status,
+ }));
+ },
currentStatusLabel() {
return this.getStatusLabel(this.value);
},
},
+
methods: {
show() {
- this.$refs.dropdown.show();
+ this.$refs.dropdown.open();
},
hide() {
- this.$refs.dropdown.hide();
+ this.$refs.dropdown.close();
},
getStatusLabel,
- hideDropdown(event) {
- if (this.preventDropdownClose) {
- event.preventDefault();
- }
- },
},
};
</script>
<template>
- <gl-dropdown
+ <gl-collapsible-listbox
ref="dropdown"
+ v-model="selected"
+ :header-text="headerText"
block
- :text="currentStatusLabel"
+ :toggle-text="currentStatusLabel"
+ :items="statusDropdownOptions"
toggle-class="dropdown-menu-toggle gl-mb-2"
- @hide="hideDropdown"
+ data-testid="escalation-status-dropdown"
+ @select="$emit('input', selected)"
>
- <slot name="header"> </slot>
- <gl-dropdown-item
- v-for="status in $options.STATUS_LIST"
- :key="status"
- data-testid="status-dropdown-item"
- is-check-item
- :is-checked="status === value"
- @click="$emit('input', status)"
- >
- {{ getStatusLabel(status) }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <template #list-item="{ item }">
+ <span class="gl-display-block">{{ item.text }}</span>
+ <span v-if="item.subtext" class="gl-font-sm gl-text-gray-500">{{ item.subtext }}</span>
+ </template>
+ </gl-collapsible-listbox>
</template>
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_button.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_button.vue
index 864d9b308e7..33299ab56e0 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_button.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_button.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlIcon } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters } from 'vuex';
// @deprecated This component should only be used when there is no GraphQL API.
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue
index 1c27df2418d..86c544ec52a 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapGetters, mapState } from 'vuex';
import DropdownContentsCreateView from './dropdown_contents_create_view.vue';
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue
index 8535398decf..1d4a1601a27 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue
@@ -1,5 +1,6 @@
<script>
import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
// @deprecated This component should only be used when there is no GraphQL API.
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue
index 3db962c7fe8..3e4297887f0 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue
@@ -7,6 +7,7 @@ import {
GlLink,
} from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters, mapActions } from 'vuex';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_title.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_title.vue
index 1e9edd222c5..50fcd3c9350 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_title.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_title.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
// @deprecated This component should only be used when there is no GraphQL API.
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value.vue
index 583f060be8a..5ca18969f0b 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value.vue
@@ -1,6 +1,7 @@
<script>
import { GlLabel } from '@gitlab/ui';
import { sortBy } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import { isScopedLabel } from '~/lib/utils/common_utils';
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue
index 74e47b333ef..af4215b663c 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue
@@ -1,5 +1,6 @@
<script>
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex, { mapState, mapActions, mapGetters } from 'vuex';
import { isInViewport } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue
index 1d8b21700c3..19fe78aca87 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownForm, GlDropdownItem, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
+import { GlDropdownItem, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { createAlert } from '~/alert';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
@@ -9,7 +9,6 @@ import LabelItem from './label_item.vue';
export default {
components: {
- GlDropdownForm,
GlDropdownItem,
GlLoadingIcon,
GlIntersectionObserver,
@@ -142,7 +141,7 @@ export default {
<template>
<gl-intersection-observer @appear="onDropdownAppear">
- <gl-dropdown-form class="labels-select-contents-list js-labels-list">
+ <div class="js-labels-list">
<div ref="labelsListContainer" data-testid="dropdown-content">
<gl-loading-icon
v-if="labelsFetchInProgress"
@@ -171,6 +170,6 @@ export default {
</gl-dropdown-item>
</template>
</div>
- </gl-dropdown-form>
+ </div>
</gl-intersection-observer>
</template>
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue
index 72567b7d4a4..74c3f08a47b 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue
@@ -412,6 +412,7 @@ export default {
:workspace-type="workspaceType"
:attr-workspace-path="attrWorkspacePath"
:label-create-type="labelCreateType"
+ class="gl-mt-3"
@setLabels="handleDropdownClose"
@closeDropdown="collapseEditableItem"
/>
@@ -421,8 +422,8 @@ export default {
<template v-else>
<dropdown-contents
ref="dropdownContents"
- :allow-multiselect="allowMultiselect"
:dropdown-button-text="dropdownButtonText"
+ :allow-multiselect="allowMultiselect"
:labels-list-title="labelsListTitle"
:footer-create-label-title="footerCreateLabelTitle"
:footer-manage-label-title="footerManageLabelTitle"
diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
index 606d374158b..fa6ae8f6a1b 100644
--- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
+++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
@@ -1,6 +1,7 @@
<script>
import { GlButton } from '@gitlab/ui';
import $ from 'jquery';
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import { createAlert } from '~/alert';
import { __, sprintf } from '~/locale';
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 1ea8ab19012..165499696de 100644
--- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
+++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
@@ -6,6 +6,7 @@ import {
GlTooltipDirective,
GlOutsideDirective as Outside,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters, mapActions } from 'vuex';
import { TYPE_ISSUE } from '~/issues/constants';
import { __, sprintf } from '~/locale';
diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue
index bad73273409..7b288e15a3e 100644
--- a/app/assets/javascripts/sidebar/components/participants/participants.vue
+++ b/app/assets/javascripts/sidebar/components/participants/participants.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { __, n__, sprintf } from '~/locale';
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
index bd1d9fbff0c..a3282932f84 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
// NOTE! For the first iteration, we are simply copying the implementation of Assignees
// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
diff --git a/app/assets/javascripts/sidebar/components/severity/severity.vue b/app/assets/javascripts/sidebar/components/severity/severity.vue
index 776dab98f01..bf1a67d86a1 100644
--- a/app/assets/javascripts/sidebar/components/severity/severity.vue
+++ b/app/assets/javascripts/sidebar/components/severity/severity.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlIcon } from '@gitlab/ui';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
index c0424dc2873..b13f594603b 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/constants.js b/app/assets/javascripts/sidebar/components/time_tracking/constants.js
index 56e986e3b27..ddfbf5ab2a6 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/constants.js
+++ b/app/assets/javascripts/sidebar/components/time_tracking/constants.js
@@ -1 +1,2 @@
export const CREATE_TIMELOG_MODAL_ID = 'create-timelog-modal';
+export const SET_TIME_ESTIMATE_MODAL_ID = 'set-time-estimate-modal';
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue
index 109e1af85ec..70d8024f46a 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/report.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlLoadingIcon, GlTableLite, GlButton, GlTooltipDirective } from '@gitlab/ui';
import { createAlert } from '~/alert';
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/set_time_estimate_form.vue b/app/assets/javascripts/sidebar/components/time_tracking/set_time_estimate_form.vue
new file mode 100644
index 00000000000..44c5896d658
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/set_time_estimate_form.vue
@@ -0,0 +1,215 @@
+<script>
+import { GlFormGroup, GlFormInput, GlModal, GlAlert, GlLink } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
+import { s__, __, sprintf } from '~/locale';
+import issueSetTimeEstimateMutation from '../../queries/issue_set_time_estimate.mutation.graphql';
+import mergeRequestSetTimeEstimateMutation from '../../queries/merge_request_set_time_estimate.mutation.graphql';
+import { SET_TIME_ESTIMATE_MODAL_ID } from './constants';
+
+const MUTATIONS = {
+ [TYPE_ISSUE]: issueSetTimeEstimateMutation,
+ [TYPE_MERGE_REQUEST]: mergeRequestSetTimeEstimateMutation,
+};
+
+export default {
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ GlModal,
+ GlAlert,
+ GlLink,
+ },
+ inject: ['issuableType'],
+ props: {
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ issuableIid: {
+ type: String,
+ required: true,
+ },
+ /**
+ * This object must contain the following keys, used to show
+ * the initial time estimate in the form:
+ * - timeEstimate: the time estimate numeric value
+ * - humanTimeEstimate: the time estimate in human readable format
+ */
+ timeTracking: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ currentEstimate: this.timeTracking.timeEstimate ?? 0,
+ timeEstimate: this.timeTracking.humanTimeEstimate ?? '0h',
+ isSaving: false,
+ isResetting: false,
+ saveError: '',
+ };
+ },
+ computed: {
+ submitDisabled() {
+ return this.isSaving || this.isResetting || this.timeEstimate === '';
+ },
+ resetDisabled() {
+ return this.isSaving || this.isResetting || this.currentEstimate === 0;
+ },
+ primaryProps() {
+ return {
+ text: __('Save'),
+ attributes: {
+ variant: 'confirm',
+ disabled: this.submitDisabled,
+ loading: this.isSaving,
+ },
+ };
+ },
+ secondaryProps() {
+ return this.currentEstimate === 0
+ ? null
+ : {
+ text: __('Remove'),
+ attributes: {
+ disabled: this.resetDisabled,
+ loading: this.isResetting,
+ },
+ };
+ },
+ cancelProps() {
+ return {
+ text: __('Cancel'),
+ };
+ },
+ timeTrackingDocsPath() {
+ return helpPagePath('user/project/time_tracking.md');
+ },
+ modalTitle() {
+ return this.currentEstimate === 0
+ ? s__('TimeTracking|Set time estimate')
+ : s__('TimeTracking|Edit time estimate');
+ },
+ isIssue() {
+ return this.issuableType === TYPE_ISSUE;
+ },
+ modalText() {
+ return sprintf(s__('TimeTracking|Set estimated time to complete this %{issuableTypeName}.'), {
+ issuableTypeName: this.isIssue ? __('issue') : __('merge request'),
+ });
+ },
+ },
+ watch: {
+ timeTracking() {
+ this.currentEstimate = this.timeTracking.timeEstimate ?? 0;
+ this.timeEstimate = this.timeTracking.humanTimeEstimate ?? '0h';
+ },
+ },
+ methods: {
+ resetModal() {
+ this.isSaving = false;
+ this.isResetting = false;
+ this.saveError = '';
+ },
+ close() {
+ this.$refs.modal.close();
+ },
+ saveTimeEstimate(event) {
+ event?.preventDefault();
+
+ if (this.timeEstimate === '') {
+ return;
+ }
+
+ this.isSaving = true;
+ this.updateEstimatedTime(this.timeEstimate);
+ },
+ resetTimeEstimate() {
+ this.isResetting = true;
+ this.updateEstimatedTime('0');
+ },
+ updateEstimatedTime(timeEstimate) {
+ this.saveError = '';
+
+ this.$apollo
+ .mutate({
+ mutation: MUTATIONS[this.issuableType],
+ variables: {
+ input: {
+ projectPath: this.fullPath,
+ iid: this.issuableIid,
+ timeEstimate,
+ },
+ },
+ })
+ .then(({ data }) => {
+ if (data.issuableSetTimeEstimate?.errors.length) {
+ this.saveError =
+ data.issuableSetTimeEstimate.errors[0].message ||
+ data.issuableSetTimeEstimate.errors[0];
+ } else {
+ this.close();
+ }
+ })
+ .catch((error) => {
+ this.saveError =
+ error?.message || s__('TimeTracking|An error occurred while saving the time estimate.');
+ })
+ .finally(() => {
+ this.isSaving = false;
+ this.isResetting = false;
+ });
+ },
+ },
+ SET_TIME_ESTIMATE_MODAL_ID,
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="modal"
+ :title="modalTitle"
+ :modal-id="$options.SET_TIME_ESTIMATE_MODAL_ID"
+ size="sm"
+ data-testid="set-time-estimate-modal"
+ :action-primary="primaryProps"
+ :action-secondary="secondaryProps"
+ :action-cancel="cancelProps"
+ @hidden="resetModal"
+ @primary.prevent="saveTimeEstimate"
+ @secondary.prevent="resetTimeEstimate"
+ @cancel="close"
+ >
+ <p data-testid="timetracking-docs-link">
+ {{ modalText }}
+
+ <gl-link :href="timeTrackingDocsPath">{{
+ s__('TimeTracking|How do I estimate and track time?')
+ }}</gl-link>
+ </p>
+ <form class="js-quick-submit" @submit.prevent="saveTimeEstimate">
+ <gl-form-group
+ label-for="time-estimate"
+ :label="s__('TimeTracking|Estimate')"
+ :description="
+ s__(
+ `TimeTracking|Enter time as a total duration (for example, 1mo 2w 3d 5h 10m), or specify hours and minutes (for example, 75:30).`,
+ )
+ "
+ >
+ <gl-form-input
+ id="time-estimate"
+ v-model="timeEstimate"
+ data-testid="time-estimate"
+ autocomplete="off"
+ />
+ </gl-form-group>
+ <gl-alert v-if="saveError" variant="danger" class="gl-mt-5" :dismissible="false">
+ {{ saveError }}
+ </gl-alert>
+ <!-- This is needed to have the quick-submit behaviour (with Ctrl + Enter or Cmd + Enter) -->
+ <input type="submit" hidden />
+ </form>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue
index 06adc048942..54f10cac075 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue
@@ -35,6 +35,11 @@ export default {
required: false,
default: true,
},
+ canSetTimeEstimate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
mounted() {
this.listenForQuickActions();
@@ -73,6 +78,7 @@ export default {
:issuable-iid="issuableIid"
:limit-to-hours="limitToHours"
:can-add-time-entries="canAddTimeEntries"
+ :can-set-time-estimate="canSetTimeEstimate"
/>
</div>
</template>
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 f6968558122..1d427a871e1 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
@@ -18,8 +18,9 @@ import TimeTrackingCollapsedState from './collapsed_state.vue';
import TimeTrackingComparisonPane from './comparison_pane.vue';
import TimeTrackingReport from './report.vue';
import TimeTrackingSpentOnlyPane from './spent_only_pane.vue';
-import { CREATE_TIMELOG_MODAL_ID } from './constants';
+import { CREATE_TIMELOG_MODAL_ID, SET_TIME_ESTIMATE_MODAL_ID } from './constants';
import CreateTimelogForm from './create_timelog_form.vue';
+import SetTimeEstimateForm from './set_time_estimate_form.vue';
export default {
name: 'IssuableTimeTracker',
@@ -38,6 +39,7 @@ export default {
TimeTrackingComparisonPane,
TimeTrackingReport,
CreateTimelogForm,
+ SetTimeEstimateForm,
},
directives: {
GlModal: GlModalDirective,
@@ -94,6 +96,11 @@ export default {
required: false,
default: true,
},
+ canSetTimeEstimate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -181,6 +188,11 @@ export default {
timeTrackingIconName() {
return this.showHelpState ? 'close' : 'question-o';
},
+ timeEstimateTooltip() {
+ return this.hasTimeEstimate
+ ? s__('TimeTracking|Edit estimate')
+ : s__('TimeTracking|Set estimate');
+ },
},
watch: {
/**
@@ -203,6 +215,7 @@ export default {
this.$root.$emit(BV_SHOW_MODAL, CREATE_TIMELOG_MODAL_ID);
},
},
+ setTimeEstimateModalId: SET_TIME_ESTIMATE_MODAL_ID,
};
</script>
@@ -223,18 +236,31 @@ export default {
>
{{ __('Time tracking') }}
<gl-loading-icon v-if="isTimeTrackingInfoLoading" size="sm" class="gl-ml-2" inline />
- <gl-button
- v-if="canAddTimeEntries"
- v-gl-tooltip.left
- category="tertiary"
- size="small"
- class="gl-ml-auto"
- data-testid="add-time-entry-button"
- :title="__('Add time entry')"
- @click="openRegisterTimeSpentModal()"
- >
- <gl-icon name="plus" class="gl-text-gray-900!" />
- </gl-button>
+ <div v-if="canSetTimeEstimate || canAddTimeEntries" class="gl-ml-auto gl-display-flex">
+ <gl-button
+ v-if="canSetTimeEstimate"
+ v-gl-modal="$options.setTimeEstimateModalId"
+ v-gl-tooltip.top
+ category="tertiary"
+ size="small"
+ data-testid="set-time-estimate-button"
+ :title="timeEstimateTooltip"
+ :aria-label="timeEstimateTooltip"
+ >
+ <gl-icon name="timer" class="gl-text-gray-900!" />
+ </gl-button>
+ <gl-button
+ v-if="canAddTimeEntries"
+ v-gl-tooltip.top
+ category="tertiary"
+ size="small"
+ data-testid="add-time-entry-button"
+ :title="__('Add time entry')"
+ @click="openRegisterTimeSpentModal()"
+ >
+ <gl-icon name="plus" class="gl-text-gray-900!" />
+ </gl-button>
+ </div>
</div>
<div v-if="!isTimeTrackingInfoLoading" class="hide-collapsed">
<div v-if="showEstimateOnlyState" data-testid="estimateOnlyPane">
@@ -255,10 +281,11 @@ export default {
:time-estimate-human-readable="humanTimeEstimate"
:limit-to-hours="limitToHours"
/>
- <template v-if="isTimeReportSupported">
+ <div v-if="isTimeReportSupported">
<gl-link
v-if="hasTotalTimeSpent"
v-gl-modal="'time-tracking-report'"
+ class="gl-text-black-normal"
data-testid="reportLink"
href="#"
>
@@ -272,8 +299,13 @@ export default {
>
<time-tracking-report :limit-to-hours="limitToHours" :issuable-id="issuableId" />
</gl-modal>
- </template>
+ </div>
<create-timelog-form :issuable-id="issuableId" />
+ <set-time-estimate-form
+ :full-path="fullPath"
+ :issuable-iid="issuableIid"
+ :time-tracking="timeTracking"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
index d5782e4b371..2c8c23c1152 100644
--- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlLoadingIcon, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
index b0060e4c28d..cb6d503d6ef 100644
--- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
@@ -36,6 +36,7 @@ export default class SidebarMilestone {
humanTotalTimeSpent: humanTimeSpent,
},
canAddTimeEntries: false,
+ canSetTimeEstimate: false,
},
}),
});
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 8f6b855ecd6..1f3119e14db 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -42,6 +42,7 @@ import { IssuableAttributeType } from './constants';
import CrmContacts from './components/crm_contacts/crm_contacts.vue';
import trackShowInviteMemberLink from './track_invite_members';
import MoveIssueButton from './components/move/move_issue_button.vue';
+import ConfidentialityDropdown from './components/confidential/confidentiality_dropdown.vue';
Vue.use(Translate);
Vue.use(VueApollo);
@@ -545,6 +546,7 @@ function mountSidebarTimeTracking() {
issuableType,
timeTrackingLimitToHours,
canCreateTimelogs,
+ editable,
} = getSidebarOptions();
if (!el) {
@@ -564,6 +566,7 @@ function mountSidebarTimeTracking() {
issuableIid: iid.toString(),
limitToHours: timeTrackingLimitToHours,
canAddTimeEntries: canCreateTimelogs,
+ canSetTimeEstimate: parseBoolean(editable),
},
}),
});
@@ -694,6 +697,20 @@ export function mountSubscriptionsDropdown() {
});
}
+export function mountConfidentialityDropdown() {
+ const el = document.querySelector('.js-confidentiality-dropdown');
+
+ if (!el) {
+ return null;
+ }
+
+ return new Vue({
+ el,
+ name: 'ConfidentialityDropdownRoot',
+ render: (createElement) => createElement(ConfidentialityDropdown),
+ });
+}
+
export function mountMoveIssueButton() {
const el = document.querySelector('.js-sidebar-move-issue-block');
diff --git a/app/assets/javascripts/sidebar/queries/issue_set_time_estimate.mutation.graphql b/app/assets/javascripts/sidebar/queries/issue_set_time_estimate.mutation.graphql
new file mode 100644
index 00000000000..3e3ebb3869e
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/issue_set_time_estimate.mutation.graphql
@@ -0,0 +1,10 @@
+mutation issueSetTimeEstimate($input: UpdateIssueInput!) {
+ issuableSetTimeEstimate: updateIssue(input: $input) {
+ errors
+ issuable: issue {
+ id
+ humanTimeEstimate
+ timeEstimate
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/merge_request_set_time_estimate.mutation.graphql b/app/assets/javascripts/sidebar/queries/merge_request_set_time_estimate.mutation.graphql
new file mode 100644
index 00000000000..398b3b1c520
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/merge_request_set_time_estimate.mutation.graphql
@@ -0,0 +1,10 @@
+mutation mergeRequestSetTimeEstimate($input: MergeRequestUpdateInput!) {
+ issuableSetTimeEstimate: mergeRequestUpdate(input: $input) {
+ errors
+ issuable: mergeRequest {
+ id
+ humanTimeEstimate
+ timeEstimate
+ }
+ }
+}
diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue
index 5e2f194e133..9e80210de51 100644
--- a/app/assets/javascripts/snippets/components/edit.vue
+++ b/app/assets/javascripts/snippets/components/edit.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton, GlLoadingIcon, GlFormInput, GlFormGroup } from '@gitlab/ui';
@@ -261,7 +262,6 @@ export default {
type="submit"
variant="confirm"
data-qa-selector="submit_button"
- data-testid="snippet-submit-btn"
:disabled="isUpdating"
>{{ saveButtonLabel }}</gl-button
>
diff --git a/app/assets/javascripts/snippets/components/show.vue b/app/assets/javascripts/snippets/components/show.vue
index 074c5fda29b..549b1bdd209 100644
--- a/app/assets/javascripts/snippets/components/show.vue
+++ b/app/assets/javascripts/snippets/components/show.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import eventHub from '~/blob/components/eventhub';
diff --git a/app/assets/javascripts/super_sidebar/components/brand_logo.vue b/app/assets/javascripts/super_sidebar/components/brand_logo.vue
index 66381e4da4d..1589f4978e1 100644
--- a/app/assets/javascripts/super_sidebar/components/brand_logo.vue
+++ b/app/assets/javascripts/super_sidebar/components/brand_logo.vue
@@ -26,7 +26,7 @@ export default {
<template>
<a
- v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.homepage"
+ v-gl-tooltip:super-sidebar.hover.noninteractive.bottom.ds500="$options.i18n.homepage"
class="brand-logo"
:href="rootPath"
:title="$options.i18n.homepage"
diff --git a/app/assets/javascripts/super_sidebar/components/context_header.vue b/app/assets/javascripts/super_sidebar/components/context_header.vue
new file mode 100644
index 00000000000..11b9840a409
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/context_header.vue
@@ -0,0 +1,56 @@
+<script>
+import { GlTruncate, GlAvatar, GlIcon } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlTruncate,
+ GlAvatar,
+ GlIcon,
+ },
+ props: {
+ /*
+ * Contains metadata about the current view, e.g. `id`, `title` and `avatar`
+ */
+ context: {
+ type: Object,
+ required: true,
+ },
+ tag: {
+ type: String,
+ required: false,
+ default: 'div',
+ },
+ },
+ computed: {
+ avatarShape() {
+ return this.context.avatar_shape || 'rect';
+ },
+ },
+};
+</script>
+
+<template>
+ <component
+ :is="tag"
+ class="border-top border-bottom gl-border-gray-a-08! gl-display-flex gl-align-items-center gl-gap-3 gl-font-weight-bold gl-w-full gl-h-8 gl-px-4 gl-flex-shrink-0"
+ >
+ <span
+ v-if="context.icon"
+ class="gl-avatar avatar-container gl-bg-t-gray-a-08 icon-avatar rect-avatar s24"
+ >
+ <gl-icon class="gl-text-gray-700" :name="context.icon" :size="16" />
+ </span>
+ <gl-avatar
+ v-else
+ :size="24"
+ :shape="avatarShape"
+ :entity-name="context.title"
+ :entity-id="context.id"
+ :src="context.avatar"
+ />
+ <div class="gl-flex-grow-1 gl-overflow-auto gl-text-gray-900">
+ <gl-truncate :text="context.title" />
+ </div>
+ <slot name="end"></slot>
+ </component>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher.vue b/app/assets/javascripts/super_sidebar/components/context_switcher.vue
index c5f3410a68f..d4aa11b6e04 100644
--- a/app/assets/javascripts/super_sidebar/components/context_switcher.vue
+++ b/app/assets/javascripts/super_sidebar/components/context_switcher.vue
@@ -64,11 +64,8 @@ export default {
ProjectsList,
GroupsList,
},
+ inject: ['contextSwitcherLinks'],
props: {
- persistentLinks: {
- type: Array,
- required: true,
- },
username: {
type: String,
required: true,
@@ -177,7 +174,7 @@ export default {
<gl-alert v-else-if="hasError" variant="danger" :dismissible="false" class="gl-m-2">
{{ $options.i18n.searchError }}
</gl-alert>
- <nav v-else :aria-label="$options.i18n.contextNavigation" data-qa-selector="context_navigation">
+ <nav v-else :aria-label="$options.i18n.contextNavigation" data-testid="context-navigation">
<ul class="gl-p-0 gl-m-0 gl-list-style-none">
<li v-if="!isSearch">
<ul
@@ -185,7 +182,7 @@ export default {
class="gl-border-b gl-border-gray-50 gl-px-0 gl-py-2"
>
<nav-item
- v-for="item in persistentLinks"
+ v-for="item in contextSwitcherLinks"
:key="item.link"
:item="item"
:link-classes="{ [item.link_classes]: item.link_classes }"
diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue
index 17227a2b123..faa7eba6470 100644
--- a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue
+++ b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue
@@ -1,11 +1,11 @@
<script>
-import { GlTruncate, GlAvatar, GlIcon } from '@gitlab/ui';
+import { GlIcon } from '@gitlab/ui';
+import ContextHeader from './context_header.vue';
export default {
components: {
- GlTruncate,
- GlAvatar,
GlIcon,
+ ContextHeader,
},
props: {
/*
@@ -24,39 +24,20 @@ export default {
collapseIcon() {
return this.expanded ? 'chevron-up' : 'chevron-down';
},
- avatarShape() {
- return this.context.avatar_shape || 'rect';
- },
},
};
</script>
<template>
- <button
+ <context-header
+ :context="context"
+ tag="button"
type="button"
- class="context-switcher-toggle gl-p-0 gl-bg-transparent gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-border-0 border-top border-bottom gl-border-gray-a-08! gl-box-shadow-none gl-display-flex gl-align-items-center gl-font-weight-bold gl-w-full gl-h-8 gl-flex-shrink-0"
- data-qa-selector="context_switcher"
+ class="context-switcher-toggle gl-p-0 gl-bg-transparent gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-border-0 gl-box-shadow-none gl-text-left"
+ data-testid="context-switcher"
>
- <span
- v-if="context.icon"
- class="gl-avatar avatar-container gl-bg-t-gray-a-08 icon-avatar rect-avatar s24 gl-mr-3 gl-ml-4"
- >
- <gl-icon class="gl-text-gray-700" :name="context.icon" :size="16" />
- </span>
- <gl-avatar
- v-else
- :size="24"
- :shape="avatarShape"
- :entity-name="context.title"
- :entity-id="context.id"
- :src="context.avatar"
- class="gl-mr-3 gl-ml-4"
- />
- <div class="gl-overflow-auto gl-text-gray-900">
- <gl-truncate :text="context.title" />
- </div>
- <span class="gl-flex-grow-1 gl-text-right gl-mr-4">
+ <template #end>
<gl-icon class="gl-text-gray-400" :name="collapseIcon" />
- </span>
- </button>
+ </template>
+ </context-header>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/counter.vue b/app/assets/javascripts/super_sidebar/components/counter.vue
index a6f19ff95f3..c0e1959fba4 100644
--- a/app/assets/javascripts/super_sidebar/components/counter.vue
+++ b/app/assets/javascripts/super_sidebar/components/counter.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlIcon } from '@gitlab/ui';
import { highCountTrim } from '~/lib/utils/text_utility';
diff --git a/app/assets/javascripts/super_sidebar/components/create_menu.vue b/app/assets/javascripts/super_sidebar/components/create_menu.vue
index 82f4fd18e80..3645606515f 100644
--- a/app/assets/javascripts/super_sidebar/components/create_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/create_menu.vue
@@ -95,6 +95,7 @@ export default {
:target="`#${$options.toggleId}`"
placement="bottom"
container="#super-sidebar"
+ noninteractive
>
{{ $options.i18n.createNew }}
</gl-tooltip>
diff --git a/app/assets/javascripts/super_sidebar/components/flyout_menu.vue b/app/assets/javascripts/super_sidebar/components/flyout_menu.vue
new file mode 100644
index 00000000000..fa7960da2f4
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/flyout_menu.vue
@@ -0,0 +1,65 @@
+<script>
+import { computePosition, autoUpdate, offset, flip, shift } from '@floating-ui/dom';
+import NavItem from './nav_item.vue';
+
+export default {
+ name: 'FlyoutMenu',
+ components: { NavItem },
+ props: {
+ targetId: {
+ type: String,
+ required: true,
+ },
+ items: {
+ type: Array,
+ required: true,
+ },
+ },
+ cleanupFunction: undefined,
+ mounted() {
+ const target = document.querySelector(`#${this.targetId}`);
+ const flyout = document.querySelector(`#${this.targetId}-flyout`);
+
+ function updatePosition() {
+ return computePosition(target, flyout, {
+ middleware: [offset({ alignmentAxis: -12 }), flip(), shift()],
+ placement: 'right-start',
+ strategy: 'fixed',
+ }).then(({ x, y }) => {
+ Object.assign(flyout.style, {
+ left: `${x}px`,
+ top: `${y}px`,
+ });
+ });
+ }
+
+ this.$options.cleanupFunction = autoUpdate(target, flyout, updatePosition);
+ },
+ beforeUnmount() {
+ this.$options.cleanupFunction();
+ },
+};
+</script>
+
+<template>
+ <div
+ :id="`${targetId}-flyout`"
+ class="gl-fixed gl-p-4 gl-mx-n1 gl-z-index-9999 gl-max-h-full gl-overflow-y-auto"
+ @mouseover="$emit('mouseover')"
+ @mouseleave="$emit('mouseleave')"
+ >
+ <ul
+ v-if="items.length > 0"
+ class="gl-min-w-20 gl-max-w-34 gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-100 gl-shadow-md gl-bg-white gl-p-2 gl-pb-1 gl-list-style-none"
+ >
+ <nav-item
+ v-for="item of items"
+ :key="item.id"
+ :item="item"
+ :is-flyout="true"
+ @pin-add="(itemId) => $emit('pin-add', itemId)"
+ @pin-remove="(itemId) => $emit('pin-remove', itemId)"
+ />
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue
index 342e1284e86..fe1a907bd91 100644
--- a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue
+++ b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue
@@ -1,9 +1,11 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import * as Sentry from '@sentry/browser';
-import AccessorUtilities from '~/lib/utils/accessor';
import { __ } from '~/locale';
-import { getTopFrequentItems, formatContextSwitcherItems } from '../utils';
+import {
+ getItemsFromLocalStorage,
+ removeItemFromLocalStorage,
+ formatContextSwitcherItems,
+} from '../utils';
import ItemsList from './items_list.vue';
export default {
@@ -43,35 +45,21 @@ export default {
},
},
created() {
- this.getItemsFromLocalStorage();
+ this.cachedFrequentItems = formatContextSwitcherItems(
+ getItemsFromLocalStorage({
+ storageKey: this.storageKey,
+ maxItems: this.maxItems,
+ }),
+ );
},
methods: {
- getItemsFromLocalStorage() {
- if (!AccessorUtilities.canUseLocalStorage()) {
- return;
- }
- try {
- const parsedCachedFrequentItems = JSON.parse(localStorage.getItem(this.storageKey));
- const topFrequentItems = getTopFrequentItems(parsedCachedFrequentItems, this.maxItems);
- this.cachedFrequentItems = formatContextSwitcherItems(topFrequentItems);
- } catch (e) {
- Sentry.captureException(e);
- }
- },
handleItemRemove(item) {
- try {
- // Remove item from local storage
- const parsedCachedFrequentItems = JSON.parse(localStorage.getItem(this.storageKey));
- localStorage.setItem(
- this.storageKey,
- JSON.stringify(parsedCachedFrequentItems.filter((i) => i.id !== item.id)),
- );
+ removeItemFromLocalStorage({
+ storageKey: this.storageKey,
+ item,
+ });
- // Update the list
- this.cachedFrequentItems = this.cachedFrequentItems.filter((i) => i.id !== item.id);
- } catch (e) {
- Sentry.captureException(e);
- }
+ this.cachedFrequentItems = this.cachedFrequentItems.filter((i) => i.id !== item.id);
},
},
i18n: {
@@ -103,6 +91,7 @@ export default {
size="small"
category="tertiary"
icon="dash"
+ class="show-on-focus-or-hover--target"
:aria-label="$options.i18n.removeItem"
:title="$options.i18n.removeItem"
data-testid="item-remove"
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue
index a1d0e400b5f..bd79962f1a1 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue
@@ -133,6 +133,12 @@ export default {
},
immediate: true,
},
+ handle: {
+ handler() {
+ this.debouncedSearch();
+ },
+ immediate: false,
+ },
},
updated() {
this.$emit('updated');
@@ -180,7 +186,7 @@ export default {
}
},
async getScopedItems() {
- if (this.searchQuery && this.searchQuery.length < 3) return;
+ if (this.searchQuery?.length < 3) return;
this.loading = true;
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue
index efd93e88fa9..28e50dceb48 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue
@@ -36,7 +36,7 @@ export default {
<style scoped>
.fake-input {
- top: 12px;
- left: 33px;
+ top: 18px;
+ left: 39px;
}
</style>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_groups.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_groups.vue
new file mode 100644
index 00000000000..6f0a0a1fe79
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_groups.vue
@@ -0,0 +1,40 @@
+<script>
+import { s__ } from '~/locale';
+import { MAX_FREQUENT_GROUPS_COUNT } from '~/super_sidebar/constants';
+import FrequentItems from './frequent_items.vue';
+
+export default {
+ name: 'FrequentlyVisitedGroups',
+ components: {
+ FrequentItems,
+ },
+ inject: ['groupsPath'],
+ data() {
+ const username = gon.current_username;
+
+ return {
+ storageKey: username ? `${username}/frequent-groups` : null,
+ };
+ },
+ i18n: {
+ groupName: s__('Navigation|Frequently visited groups'),
+ viewAllText: s__('Navigation|View all my groups'),
+ emptyStateText: s__('Navigation|Groups you visit often will appear here.'),
+ },
+ MAX_FREQUENT_GROUPS_COUNT,
+};
+</script>
+
+<template>
+ <frequent-items
+ :empty-state-text="$options.i18n.emptyStateText"
+ :group-name="$options.i18n.groupName"
+ :max-items="$options.MAX_FREQUENT_GROUPS_COUNT"
+ :storage-key="storageKey"
+ view-all-items-icon="group"
+ :view-all-items-text="$options.i18n.viewAllText"
+ :view-all-items-path="groupsPath"
+ v-bind="$attrs"
+ v-on="$listeners"
+ />
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item.vue
new file mode 100644
index 00000000000..5371887ee0f
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item.vue
@@ -0,0 +1,64 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
+import { __ } from '~/locale';
+
+export default {
+ name: 'FrequentlyVisitedItem',
+ components: {
+ GlButton,
+ ProjectAvatar,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
+ },
+ methods: {
+ onRemove() {
+ this.$emit('remove', this.item);
+ },
+ },
+ i18n: {
+ remove: __('Remove'),
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-center gl-gap-3">
+ <project-avatar
+ :project-id="item.id"
+ :project-name="item.title"
+ :project-avatar-url="item.avatar"
+ :size="24"
+ aria-hidden="true"
+ />
+
+ <div class="gl-flex-grow-1 gl-truncate-end">
+ {{ item.title }}
+ <div
+ v-if="item.subtitle"
+ data-testid="subtitle"
+ class="gl-font-sm gl-text-gray-500 gl-truncate-end"
+ >
+ {{ item.subtitle }}
+ </div>
+ </div>
+
+ <gl-button
+ v-gl-tooltip.left
+ icon="dash"
+ category="tertiary"
+ :aria-label="$options.i18n.remove"
+ :title="$options.i18n.remove"
+ class="show-on-focus-or-hover--target"
+ @click.stop.prevent="onRemove"
+ @keydown.enter.stop.prevent="onRemove"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue
new file mode 100644
index 00000000000..382d844ceee
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue
@@ -0,0 +1,133 @@
+<script>
+import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlIcon } from '@gitlab/ui';
+import { truncateNamespace } from '~/lib/utils/text_utility';
+import { getItemsFromLocalStorage, removeItemFromLocalStorage } from '~/super_sidebar/utils';
+import FrequentItem from './frequent_item.vue';
+
+export default {
+ name: 'FrequentlyVisitedItems',
+ components: {
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+ GlIcon,
+ FrequentItem,
+ },
+ props: {
+ emptyStateText: {
+ type: String,
+ required: true,
+ },
+ groupName: {
+ type: String,
+ required: true,
+ },
+ maxItems: {
+ type: Number,
+ required: true,
+ },
+ storageKey: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ viewAllItemsText: {
+ type: String,
+ required: true,
+ },
+ viewAllItemsIcon: {
+ type: String,
+ required: true,
+ },
+ viewAllItemsPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ items: getItemsFromLocalStorage({
+ storageKey: this.storageKey,
+ maxItems: this.maxItems,
+ }),
+ };
+ },
+ computed: {
+ formattedItems() {
+ // Each item needs two different representations. One is for the
+ // GlDisclosureDropdownItem, and the other is for the FrequentItem
+ // renderer component inside it.
+ return this.items.map((item) => ({
+ forDropdown: {
+ id: item.id,
+
+ // The text field satsifies GlDisclosureDropdownItem's prop
+ // validator, and the href field ensures it renders a link.
+ text: item.name,
+ href: item.webUrl,
+ },
+ forRenderer: {
+ id: item.id,
+ title: item.name,
+ subtitle: truncateNamespace(item.namespace),
+ avatar: item.avatarUrl,
+ },
+ }));
+ },
+ showEmptyState() {
+ return this.items.length === 0;
+ },
+ viewAllItem() {
+ return {
+ text: this.viewAllItemsText,
+ href: this.viewAllItemsPath,
+ };
+ },
+ },
+ created() {
+ if (!this.storageKey) {
+ this.$emit('nothing-to-render');
+ }
+ },
+ methods: {
+ removeItem(item) {
+ removeItemFromLocalStorage({
+ storageKey: this.storageKey,
+ item,
+ });
+
+ this.items = this.items.filter((i) => i.id !== item.id);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-disclosure-dropdown-group v-if="storageKey" v-bind="$attrs">
+ <template #group-label>{{ groupName }}</template>
+
+ <gl-disclosure-dropdown-item
+ v-for="item of formattedItems"
+ :key="item.forDropdown.id"
+ :item="item.forDropdown"
+ class="show-on-focus-or-hover--context"
+ >
+ <template #list-item
+ ><frequent-item :item="item.forRenderer" @remove="removeItem"
+ /></template>
+ </gl-disclosure-dropdown-item>
+
+ <gl-disclosure-dropdown-item v-if="showEmptyState" class="gl-cursor-text">
+ <span class="gl-text-gray-500 gl-font-sm gl-my-3 gl-mx-3">{{ emptyStateText }}</span>
+ </gl-disclosure-dropdown-item>
+
+ <gl-disclosure-dropdown-item key="all" :item="viewAllItem">
+ <template #list-item>
+ <span>
+ <gl-icon :name="viewAllItemsIcon" class="gl-w-6!" />
+ {{ viewAllItemsText }}
+ </span>
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown-group>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_projects.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_projects.vue
new file mode 100644
index 00000000000..35b254099c2
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_projects.vue
@@ -0,0 +1,40 @@
+<script>
+import { s__ } from '~/locale';
+import { MAX_FREQUENT_PROJECTS_COUNT } from '~/super_sidebar/constants';
+import FrequentItems from './frequent_items.vue';
+
+export default {
+ name: 'FrequentlyVisitedProjects',
+ components: {
+ FrequentItems,
+ },
+ inject: ['projectsPath'],
+ data() {
+ const username = gon.current_username;
+
+ return {
+ storageKey: username ? `${username}/frequent-projects` : null,
+ };
+ },
+ i18n: {
+ groupName: s__('Navigation|Frequently visited projects'),
+ viewAllText: s__('Navigation|View all my projects'),
+ emptyStateText: s__('Navigation|Projects you visit often will appear here.'),
+ },
+ MAX_FREQUENT_PROJECTS_COUNT,
+};
+</script>
+
+<template>
+ <frequent-items
+ :empty-state-text="$options.i18n.emptyStateText"
+ :group-name="$options.i18n.groupName"
+ :max-items="$options.MAX_FREQUENT_PROJECTS_COUNT"
+ :storage-key="storageKey"
+ view-all-items-icon="project"
+ :view-all-items-text="$options.i18n.viewAllText"
+ :view-all-items-path="projectsPath"
+ v-bind="$attrs"
+ v-on="$listeners"
+ />
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue
index bec8c191b31..b64f3ac52b2 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue
@@ -8,6 +8,7 @@ import {
GlResizeObserverDirective,
GlModal,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions, mapGetters } from 'vuex';
import { debounce, clamp } from 'lodash';
import { truncate } from '~/lib/utils/text_utility';
@@ -200,17 +201,21 @@ export default {
const isSearchInput = target.matches(SEARCH_INPUT_SELECTOR);
if (code === HOME_KEY) {
+ if (isSearchInput) return;
+
this.focusItem(0, elements);
} else if (code === END_KEY) {
+ if (isSearchInput) return;
+
this.focusItem(elements.length - 1, elements);
} else if (code === ARROW_UP_KEY) {
if (isSearchInput) return;
if (elements.indexOf(target) === 0) {
this.focusSearchInput();
- return;
+ } else {
+ this.focusNextItem(event, elements, -1);
}
- this.focusNextItem(event, elements, -1);
} else if (code === ARROW_DOWN_KEY) {
this.focusNextItem(event, elements, 1);
} else if (code === ESC_KEY) {
@@ -290,10 +295,9 @@ export default {
<form
role="search"
:aria-label="searchPlaceholder"
- class="gl-relative gl-rounded-base gl-w-full"
- data-testid="global-search-form"
+ class="gl-relative gl-rounded-base gl-w-full gl-pb-0"
>
- <div class="gl-p-1 gl-relative">
+ <div class="gl-relative gl-bg-white gl-border-b gl-mb-n1 gl-p-3">
<gl-search-box-by-type
id="search"
ref="searchInput"
@@ -346,8 +350,7 @@ export default {
</span>
<div
ref="resultsList"
- data-testid="global-search-results"
- class="global-search-results gl-overflow-y-auto gl-w-full gl-pb-2"
+ class="global-search-results gl-overflow-y-auto gl-w-full gl-pb-3"
@keydown="onKeydown"
>
<command-palette-items
@@ -357,12 +360,11 @@ export default {
@updated="highlightFirstCommand"
/>
+ <global-search-default-items v-else-if="showDefaultItems" />
+
<template v-else>
- <global-search-default-items v-if="showDefaultItems" />
- <template v-else>
- <global-search-scoped-items v-if="showScopedSearchItems" />
- <global-search-autocomplete-items />
- </template>
+ <global-search-scoped-items v-if="showScopedSearchItems" />
+ <global-search-autocomplete-items />
</template>
</div>
<template v-if="searchContext">
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue
index cd623200b03..23ea0af12fc 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue
@@ -1,5 +1,6 @@
<script>
import { GlAvatar, GlAlert, GlLoadingIcon, GlDisclosureDropdownGroup } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import highlight from '~/lib/utils/highlight';
@@ -23,9 +24,6 @@ export default {
computed: {
...mapState(['search', 'loading', 'autocompleteError']),
...mapGetters(['autocompleteGroupedSearchOptions', 'scopedSearchOptions']),
- isPrecededByScopedOptions() {
- return this.scopedSearchOptions.length > 1;
- },
},
methods: {
highlightedName(val) {
@@ -40,9 +38,9 @@ export default {
<div>
<ul v-if="!loading" class="gl-m-0 gl-p-0 gl-list-style-none">
<gl-disclosure-dropdown-group
- v-for="group in autocompleteGroupedSearchOptions"
+ v-for="(group, index) in autocompleteGroupedSearchOptions"
:key="group.name"
- :class="{ 'gl-mt-0!': !isPrecededByScopedOptions }"
+ :class="{ 'gl-mt-0!': index === 0 }"
:group="group"
bordered
>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_issuables.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_issuables.vue
new file mode 100644
index 00000000000..1b7b8268ee3
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_issuables.vue
@@ -0,0 +1,45 @@
+<script>
+import { GlDisclosureDropdownGroup } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
+import { mapState, mapGetters } from 'vuex';
+import { ALL_GITLAB } from '~/vue_shared/global_search/constants';
+
+export default {
+ name: 'DefaultIssuables',
+ i18n: {
+ ALL_GITLAB,
+ },
+ components: {
+ GlDisclosureDropdownGroup,
+ },
+ computed: {
+ ...mapState(['searchContext']),
+ ...mapGetters(['defaultSearchOptions']),
+ currentContextName() {
+ return (
+ this.searchContext?.project?.name ||
+ this.searchContext?.group?.name ||
+ this.$options.i18n.ALL_GITLAB
+ );
+ },
+ shouldRender() {
+ return this.group.items.length > 0;
+ },
+ group() {
+ return {
+ name: this.currentContextName,
+ items: this.defaultSearchOptions,
+ };
+ },
+ },
+ created() {
+ if (!this.shouldRender) {
+ this.$emit('nothing-to-render');
+ }
+ },
+};
+</script>
+
+<template>
+ <gl-disclosure-dropdown-group v-if="shouldRender" v-bind="$attrs" :group="group" />
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue
index 239c61fd750..27935d92a5c 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue
@@ -1,38 +1,53 @@
<script>
-import { GlDisclosureDropdownGroup } from '@gitlab/ui';
-import { mapState, mapGetters } from 'vuex';
-import { ALL_GITLAB } from '~/vue_shared/global_search/constants';
+import DefaultPlaces from './global_search_default_places.vue';
+import DefaultIssuables from './global_search_default_issuables.vue';
+import FrequentGroups from './frequent_groups.vue';
+import FrequentProjects from './frequent_projects.vue';
+
+const components = [DefaultPlaces, FrequentProjects, FrequentGroups, DefaultIssuables];
export default {
name: 'GlobalSearchDefaultItems',
- i18n: {
- ALL_GITLAB,
- },
- components: {
- GlDisclosureDropdownGroup,
+ data() {
+ return {
+ // The components here are expected to:
+ // - be responsible for getting their own data,
+ // - render a GlDisclosureDropdownGroup as the root vnode,
+ // - transparently pass all attrs to it (e.g., `bordered`),
+ // - not render anything if they have no data,
+ // - emit a `nothing-to-render` event if they have nothing to render.
+ // - have a unique `name`
+ componentNames: components.map(({ name }) => name),
+ };
},
- computed: {
- ...mapState(['searchContext']),
- ...mapGetters(['defaultSearchOptions']),
- sectionHeader() {
- return (
- this.searchContext?.project?.name ||
- this.searchContext?.group?.name ||
- this.$options.i18n.ALL_GITLAB
- );
+ methods: {
+ componentFromName(name) {
+ return components.find((component) => component.name === name);
+ },
+ remove(nameToRemove) {
+ const indexToRemove = this.componentNames.findIndex((name) => name === nameToRemove);
+ if (indexToRemove !== -1) this.componentNames.splice(indexToRemove, 1);
},
- defaultItemsGroup() {
- return {
- name: this.sectionHeader,
- items: this.defaultSearchOptions,
- };
+ attrs(index) {
+ return index === 0
+ ? null
+ : {
+ bordered: true,
+ class: 'gl-mt-3',
+ };
},
},
};
</script>
<template>
- <ul class="gl-p-0 gl-m-0 gl-list-style-none">
- <gl-disclosure-dropdown-group :group="defaultItemsGroup" bordered class="gl-mt-0!" />
+ <ul class="gl-p-0 gl-m-0 gl-pt-2 gl-list-style-none">
+ <component
+ :is="componentFromName(name)"
+ v-for="(name, index) in componentNames"
+ :key="name"
+ v-bind="attrs(index)"
+ @nothing-to-render="remove(name)"
+ />
</ul>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_places.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_places.vue
new file mode 100644
index 00000000000..9a375837102
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_places.vue
@@ -0,0 +1,35 @@
+<script>
+import { GlDisclosureDropdownGroup } from '@gitlab/ui';
+import { PLACES } from '~/vue_shared/global_search/constants';
+
+export default {
+ name: 'DefaultPlaces',
+ i18n: {
+ PLACES,
+ },
+ components: {
+ GlDisclosureDropdownGroup,
+ },
+ inject: ['contextSwitcherLinks'],
+ computed: {
+ shouldRender() {
+ return this.contextSwitcherLinks.length > 0;
+ },
+ group() {
+ return {
+ name: this.$options.i18n.PLACES,
+ items: this.contextSwitcherLinks.map(({ title, link }) => ({ text: title, href: link })),
+ };
+ },
+ },
+ created() {
+ if (!this.shouldRender) {
+ this.$emit('nothing-to-render');
+ }
+ },
+};
+</script>
+
+<template>
+ <gl-disclosure-dropdown-group v-if="shouldRender" v-bind="$attrs" :group="group" />
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue
index 76600f829f6..1f5e7e45cc1 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue
@@ -1,5 +1,6 @@
<script>
import { GlIcon, GlToken, GlDisclosureDropdownGroup } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters } from 'vuex';
import { s__, sprintf } from '~/locale';
import { truncate } from '~/lib/utils/text_utility';
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/constants.js b/app/assets/javascripts/super_sidebar/components/global_search/constants.js
index 5a860fcd1ab..dc8fc4d2452 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/constants.js
+++ b/app/assets/javascripts/super_sidebar/components/global_search/constants.js
@@ -21,6 +21,6 @@ export const INPUT_FIELD_PADDING = 84;
export const FETCH_TYPES = ['generic', 'search'];
export const SEARCH_MODAL_ID = 'super-sidebar-search-modal';
-export const SEARCH_INPUT_SELECTOR = '.gl-search-box-by-type-input-borderless';
+export const SEARCH_INPUT_SELECTOR = 'input[role="searchbox"]';
export const SEARCH_RESULTS_ITEM_SELECTOR = '.gl-new-dropdown-item';
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js
index 4a42f416206..6871dabc9a1 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js
@@ -1,6 +1,8 @@
import { omitBy, isNil } from 'lodash';
import { objectToQuery } from '~/lib/utils/url_utility';
import {
+ ISSUES_CATEGORY,
+ MERGE_REQUEST_CATEGORY,
MSG_ISSUES_ASSIGNED_TO_ME,
MSG_ISSUES_IVE_CREATED,
MSG_MR_ASSIGNED_TO_ME,
@@ -46,7 +48,7 @@ export const scopedIssuesPath = (state) => {
return (
state.searchContext?.project_metadata?.issues_path ||
state.searchContext?.group_metadata?.issues_path ||
- state.issuesPath
+ (gon.current_username ? state.issuesPath : false)
);
};
@@ -54,13 +56,33 @@ export const scopedMRPath = (state) => {
return (
state.searchContext?.project_metadata?.mr_path ||
state.searchContext?.group_metadata?.mr_path ||
- state.mrPath
+ (gon.current_username ? state.mrPath : false)
);
};
export const defaultSearchOptions = (state, getters) => {
const userName = gon.current_username;
+ if (!userName) {
+ const options = [];
+
+ if (getters.scopedIssuesPath) {
+ options.push({
+ text: ISSUES_CATEGORY,
+ href: getters.scopedIssuesPath,
+ });
+ }
+
+ if (getters.scopedMRPath) {
+ options.push({
+ text: MERGE_REQUEST_CATEGORY,
+ href: getters.scopedMRPath,
+ });
+ }
+
+ return options;
+ }
+
const issues = [
{
text: MSG_ISSUES_ASSIGNED_TO_ME,
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/index.js b/app/assets/javascripts/super_sidebar/components/global_search/store/index.js
index b83433c5b49..ca5519f529c 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/store/index.js
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
diff --git a/app/assets/javascripts/super_sidebar/components/items_list.vue b/app/assets/javascripts/super_sidebar/components/items_list.vue
index 764db490751..1bad13f91e8 100644
--- a/app/assets/javascripts/super_sidebar/components/items_list.vue
+++ b/app/assets/javascripts/super_sidebar/components/items_list.vue
@@ -19,7 +19,13 @@ export default {
<template>
<ul class="gl-p-0 gl-list-style-none">
- <nav-item v-for="item in items" :key="item.id" :item="item" is-subitem>
+ <nav-item
+ v-for="item in items"
+ :key="item.id"
+ :item="item"
+ is-subitem
+ class="show-on-focus-or-hover--context"
+ >
<template #icon>
<project-avatar
:project-id="item.id"
diff --git a/app/assets/javascripts/super_sidebar/components/menu_section.vue b/app/assets/javascripts/super_sidebar/components/menu_section.vue
index 73a899eeb83..d2d45ca7b6e 100644
--- a/app/assets/javascripts/super_sidebar/components/menu_section.vue
+++ b/app/assets/javascripts/super_sidebar/components/menu_section.vue
@@ -2,6 +2,7 @@
import { kebabCase } from 'lodash';
import { GlCollapse, GlIcon } from '@gitlab/ui';
import NavItem from './nav_item.vue';
+import FlyoutMenu from './flyout_menu.vue';
export default {
name: 'MenuSection',
@@ -9,6 +10,7 @@ export default {
GlCollapse,
GlIcon,
NavItem,
+ FlyoutMenu,
},
props: {
item: {
@@ -30,10 +32,18 @@ export default {
required: false,
default: 'div',
},
+ hasFlyout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
isExpanded: Boolean(this.expanded || this.item.is_active),
+ isMouseOverSection: false,
+ isMouseOverFlyout: false,
+ keepFlyoutClosed: false,
};
},
computed: {
@@ -45,6 +55,9 @@ export default {
};
},
collapseIcon() {
+ if (this.hasFlyout) {
+ return this.isExpanded ? 'chevron-down' : 'chevron-right';
+ }
return this.isExpanded ? 'chevron-up' : 'chevron-down';
},
computedLinkClasses() {
@@ -58,10 +71,23 @@ export default {
itemId() {
return kebabCase(this.item.title);
},
+ isMouseOver() {
+ return this.isMouseOverSection || this.isMouseOverFlyout;
+ },
},
watch: {
isExpanded(newIsExpanded) {
this.$emit('collapse-toggle', newIsExpanded);
+ this.keepFlyoutClosed = !this.newIsExpanded;
+ },
+ },
+ methods: {
+ handlePointerover(e) {
+ this.isMouseOverSection = e.pointerType === 'mouse';
+ },
+ handlePointerleave() {
+ this.isMouseOverSection = false;
+ this.keepFlyoutClosed = false;
},
},
};
@@ -71,15 +97,18 @@ export default {
<component :is="tag">
<hr v-if="separated" aria-hidden="true" class="gl-mx-4 gl-my-2" />
<button
+ :id="`menu-section-button-${itemId}`"
class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-min-h-7 gl-gap-3 gl-mb-2 gl-py-2 gl-px-3 gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-appearance-none gl-border-0 gl-bg-transparent gl-text-left gl-w-full gl-focus--focus"
:class="computedLinkClasses"
data-qa-selector="menu_section_button"
:data-qa-section-name="item.title"
v-bind="buttonProps"
@click="isExpanded = !isExpanded"
+ @pointerover="handlePointerover"
+ @pointerleave="handlePointerleave"
>
<span
- :class="[isActive ? 'gl-bg-blue-500' : 'gl-bg-transparent']"
+ :class="[isActive ? 'active-indicator gl-bg-blue-500' : 'gl-bg-transparent']"
class="gl-absolute gl-left-2 gl-top-2 gl-bottom-2 gl-transition-slow"
aria-hidden="true"
style="width: 3px; border-radius: 3px; margin-right: 1px"
@@ -99,6 +128,17 @@ export default {
</span>
</button>
+ <flyout-menu
+ v-if="hasFlyout"
+ v-show="isMouseOver && !isExpanded && !keepFlyoutClosed"
+ :target-id="`menu-section-button-${itemId}`"
+ :items="item.items"
+ @mouseover="isMouseOverFlyout = true"
+ @mouseleave="isMouseOverFlyout = false"
+ @pin-add="(itemId) => $emit('pin-add', itemId)"
+ @pin-remove="(itemId) => $emit('pin-remove', itemId)"
+ />
+
<gl-collapse
:id="itemId"
v-model="isExpanded"
diff --git a/app/assets/javascripts/super_sidebar/components/nav_item.vue b/app/assets/javascripts/super_sidebar/components/nav_item.vue
index c1e1f64dbc1..36803a885e7 100644
--- a/app/assets/javascripts/super_sidebar/components/nav_item.vue
+++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue
@@ -56,6 +56,11 @@ export default {
required: false,
default: false,
},
+ isFlyout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
pillData() {
@@ -104,6 +109,8 @@ export default {
return {
'gl-px-2 gl-mx-2 gl-line-height-normal': this.isSubitem,
'gl-px-3': !this.isSubitem,
+ 'gl-pl-5! gl-rounded-small': this.isFlyout,
+ 'gl-rounded-base': !this.isFlyout,
[this.item.link_classes]: this.item.link_classes,
...this.linkClasses,
};
@@ -121,25 +128,25 @@ export default {
:is="navItemLinkComponent"
#default="{ isActive }"
v-bind="linkProps"
- class="nav-item-link gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-min-h-7 gl-gap-3 gl-mb-1 gl-py-2 gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-focus--focus"
+ class="nav-item-link gl-relative gl-display-flex gl-align-items-center gl-min-h-7 gl-gap-3 gl-mb-1 gl-py-2 gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-focus--focus show-on-focus-or-hover--context"
:class="computedLinkClasses"
data-qa-selector="nav_item_link"
data-testid="nav-item-link"
>
<div
- :class="[isActive ? 'gl-bg-blue-500' : 'gl-bg-transparent']"
- class="gl-absolute gl-left-2 gl-top-2 gl-bottom-2 gl-transition-slow"
+ :class="[isActive ? 'gl-opacity-10' : 'gl-opacity-0']"
+ class="active-indicator gl-bg-blue-500 gl-absolute gl-left-2 gl-top-2 gl-bottom-2 gl-transition-slow"
aria-hidden="true"
style="width: 3px; border-radius: 3px; margin-right: 1px"
data-testid="active-indicator"
></div>
- <div class="gl-flex-shrink-0 gl-w-6 gl-display-flex">
+ <div v-if="!isFlyout" class="gl-flex-shrink-0 gl-w-6 gl-display-flex">
<slot name="icon">
<gl-icon v-if="item.icon" :name="item.icon" class="gl-m-auto item-icon" />
<gl-icon
v-else-if="isInPinnedSection"
name="grip"
- class="gl-m-auto gl-text-gray-400 draggable-icon"
+ class="gl-m-auto gl-text-gray-400 js-draggable-icon gl-cursor-grab show-on-focus-or-hover--target"
/>
</slot>
</div>
@@ -161,20 +168,22 @@ export default {
</gl-badge>
<gl-button
v-if="isPinnable && !isPinned"
- v-gl-tooltip.right.viewport="$options.i18n.pinItem"
+ v-gl-tooltip.noninteractive.ds500.right.viewport="$options.i18n.pinItem"
size="small"
category="tertiary"
icon="thumbtack"
+ class="show-on-focus-or-hover--target"
:aria-label="$options.i18n.pinItem"
@click.prevent="$emit('pin-add', item.id)"
/>
<gl-button
v-else-if="isPinnable && isPinned"
- v-gl-tooltip.right.viewport="$options.i18n.unpinItem"
+ v-gl-tooltip.noninteractive.ds500.right.viewport="$options.i18n.unpinItem"
size="small"
category="tertiary"
:aria-label="$options.i18n.unpinItem"
icon="thumbtack-solid"
+ class="show-on-focus-or-hover--target"
@click.prevent="$emit('pin-remove', item.id)"
/>
</span>
diff --git a/app/assets/javascripts/super_sidebar/components/pinned_section.vue b/app/assets/javascripts/super_sidebar/components/pinned_section.vue
index ccd739c8bb1..1e2201fbdff 100644
--- a/app/assets/javascripts/super_sidebar/components/pinned_section.vue
+++ b/app/assets/javascripts/super_sidebar/components/pinned_section.vue
@@ -28,6 +28,11 @@ export default {
required: false,
default: false,
},
+ hasFlyout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -40,7 +45,12 @@ export default {
return this.items.some((item) => item.is_active);
},
sectionItem() {
- return { title: this.$options.i18n.pinned, icon: 'thumbtack', is_active: this.isActive };
+ return {
+ title: this.$options.i18n.pinned,
+ icon: 'thumbtack',
+ is_active: this.isActive,
+ items: this.draggableItems,
+ };
},
itemIds() {
return this.draggableItems.map((item) => item.id);
@@ -75,14 +85,16 @@ export default {
:item="sectionItem"
:expanded="expanded"
:separated="separated"
+ :has-flyout="hasFlyout"
@collapse-toggle="expanded = !expanded"
+ @pin-remove="(itemId) => $emit('pin-remove', itemId)"
>
<draggable
v-if="items.length > 0"
v-model="draggableItems"
class="gl-p-0 gl-m-0"
data-testid="pinned-nav-items"
- handle=".draggable-icon"
+ handle=".js-draggable-icon"
tag="ul"
@end="handleDrag"
>
diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue
index 287e4f57d01..821b9dbcb7b 100644
--- a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue
@@ -1,7 +1,9 @@
<script>
import * as Sentry from '@sentry/browser';
+import { GlBreakpointInstance, breakpoints } from '@gitlab/ui/dist/utils';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { PANELS_WITH_PINS } from '../constants';
import NavItem from './nav_item.vue';
import PinnedSection from './pinned_section.vue';
@@ -14,6 +16,7 @@ export default {
NavItem,
PinnedSection,
},
+ mixins: [glFeatureFlagsMixin()],
provide() {
return {
@@ -27,6 +30,10 @@ export default {
type: Array,
required: true,
},
+ isLoggedIn: {
+ type: Boolean,
+ required: true,
+ },
pinnedItemIds: {
type: Array,
required: false,
@@ -39,7 +46,8 @@ export default {
},
updatePinsUrl: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
},
@@ -49,6 +57,8 @@ export default {
data() {
return {
+ showFlyoutMenus: false,
+
// This is used as a provide and injected into the nav items.
// Note: It has to be an object to be reactive.
changedPinnedItemIds: { ids: this.pinnedItemIds },
@@ -92,12 +102,21 @@ export default {
.filter(Boolean);
},
supportsPins() {
- return PANELS_WITH_PINS.includes(this.panelType);
+ return this.isLoggedIn && PANELS_WITH_PINS.includes(this.panelType);
},
hasStaticItems() {
return this.staticItems.length > 0;
},
},
+ mounted() {
+ if (this.glFeatures.superSidebarFlyoutMenus) {
+ this.decideFlyoutState();
+ window.addEventListener('resize', this.decideFlyoutState);
+ }
+ },
+ beforeDestroy() {
+ window.removeEventListener('resize', this.decideFlyoutState);
+ },
methods: {
createPin(itemId) {
this.changedPinnedItemIds.ids.push(itemId);
@@ -137,6 +156,9 @@ export default {
isSection(navItem) {
return navItem.items?.length;
},
+ decideFlyoutState() {
+ this.showFlyoutMenus = GlBreakpointInstance.windowWidth() >= breakpoints.md;
+ },
},
};
</script>
@@ -150,6 +172,7 @@ export default {
v-if="supportsPins"
separated
:items="pinnedItems"
+ :has-flyout="showFlyoutMenus"
@pin-remove="destroyPin"
@pin-reorder="movePin"
/>
@@ -166,6 +189,7 @@ export default {
:key="item.id"
:item="item"
:separated="item.separated"
+ :has-flyout="showFlyoutMenus"
@pin-add="createPin"
@pin-remove="destroyPin"
/>
diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue b/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue
index 6058ed3a1cd..ec728b4af9e 100644
--- a/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue
+++ b/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue
@@ -11,6 +11,12 @@ export const STATE_WILL_CLOSE = 'will-close';
export default {
name: 'SidebarPeek',
mixins: [Tracking.mixin()],
+ props: {
+ isMouseOverSidebar: {
+ type: Boolean,
+ required: true,
+ },
+ },
created() {
// Nothing needs to observe these properties, so they are not reactive.
this.state = null;
@@ -57,6 +63,11 @@ export default {
this.close();
}
} else if (this.state === STATE_OPEN) {
+ // Do not close the sidebar if it or one of its child elements still
+ // has mouseover. This allows to move the mouse from the sidebar to
+ // one of its flyout menus.
+ if (this.isMouseOverSidebar) return;
+
if (clientX >= this.xAwayFromSidebar) {
this.close();
} else if (clientX >= this.xSidebarEdge) {
diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
index c194401ce95..29a3147e949 100644
--- a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
+++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
@@ -8,6 +8,7 @@ import { sidebarState } from '../constants';
import { isCollapsed, toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager';
import UserBar from './user_bar.vue';
import SidebarPortalTarget from './sidebar_portal_target.vue';
+import ContextHeader from './context_header.vue';
import ContextSwitcher from './context_switcher.vue';
import HelpCenter from './help_center.vue';
import SidebarMenu from './sidebar_menu.vue';
@@ -17,6 +18,7 @@ export default {
components: {
GlButton,
UserBar,
+ ContextHeader,
ContextSwitcher,
HelpCenter,
SidebarMenu,
@@ -42,6 +44,7 @@ export default {
return {
sidebarState,
showPeekHint: false,
+ isMouseover: false,
};
},
computed: {
@@ -57,7 +60,7 @@ export default {
},
watch: {
'sidebarState.isCollapsed': function isCollapsedWatcher(newIsCollapsed) {
- if (newIsCollapsed) {
+ if (newIsCollapsed && this.$refs['context-switcher']) {
this.$refs['context-switcher'].close();
}
},
@@ -118,6 +121,8 @@ export default {
data-testid="super-sidebar"
data-qa-selector="navbar"
:inert="sidebarState.isCollapsed"
+ @mouseenter="isMouseover = true"
+ @mouseleave="isMouseover = false"
>
<user-bar :has-collapse-button="!sidebarState.isPeek" :sidebar-data="sidebarData" />
<div v-if="showTrialStatusWidget" class="gl-px-2 gl-py-2">
@@ -126,15 +131,17 @@ export default {
/>
<trial-status-popover />
</div>
- <div class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-overflow-hidden">
+ <div
+ class="contextual-nav gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-overflow-hidden"
+ >
<div
class="gl-flex-grow-1"
:class="{ 'gl-overflow-auto': !sidebarState.contextSwitcherOpen }"
data-testid="nav-container"
>
<context-switcher
+ v-if="sidebarData.is_logged_in"
ref="context-switcher"
- :persistent-links="sidebarData.context_switcher_links"
:username="sidebarData.username"
:projects-path="sidebarData.projects_path"
:groups-path="sidebarData.groups_path"
@@ -142,9 +149,11 @@ export default {
:context-header="sidebarData.current_context_header"
@toggle="onContextSwitcherToggled"
/>
+ <context-header v-else :context="sidebarData.current_context_header" />
<sidebar-menu
v-if="menuItems.length"
:items="menuItems"
+ :is-logged-in="sidebarData.is_logged_in"
:panel-type="sidebarData.panel_type"
:pinned-item-ids="sidebarData.pinned_items"
:update-pins-url="sidebarData.update_pins_url"
@@ -170,6 +179,10 @@ export default {
Only mount SidebarPeekBehavior if the sidebar is peekable, to avoid
setting up event listeners unnecessarily.
-->
- <sidebar-peek-behavior v-if="sidebarState.isPeekable" @change="onPeekChange" />
+ <sidebar-peek-behavior
+ v-if="sidebarState.isPeekable"
+ :is-mouse-over-sidebar="isMouseover"
+ @change="onPeekChange"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue
index 87762a62c0f..7d5e87805d5 100644
--- a/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue
+++ b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue
@@ -74,7 +74,7 @@ export default {
<template>
<gl-button
- v-gl-tooltip.hover="tooltip"
+ v-gl-tooltip.hover.noninteractive.ds500="tooltip"
aria-controls="super-sidebar"
:aria-expanded="ariaExpanded"
:aria-label="$options.i18n.navigationSidebar"
diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue
index a882df057fa..b76ef91b768 100644
--- a/app/assets/javascripts/super_sidebar/components/user_bar.vue
+++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue
@@ -105,17 +105,20 @@ export default {
<template>
<div class="user-bar">
<div class="gl-display-flex gl-align-items-center gl-px-3 gl-py-2">
- <brand-logo :logo-url="sidebarData.logo_url" />
- <gl-badge
- v-if="sidebarData.gitlab_com_and_canary"
- variant="success"
- :href="sidebarData.canary_toggle_com_url"
- size="sm"
- class="gl-ml-2"
- >
- {{ $options.NEXT_LABEL }}
- </gl-badge>
- <div class="gl-flex-grow-1"></div>
+ <template v-if="sidebarData.is_logged_in">
+ <brand-logo :logo-url="sidebarData.logo_url" />
+ <gl-badge
+ v-if="sidebarData.gitlab_com_and_canary"
+ variant="success"
+ :href="sidebarData.canary_toggle_com_url"
+ size="sm"
+ class="gl-ml-2"
+ >
+ {{ $options.NEXT_LABEL }}
+ </gl-badge>
+ <div class="gl-flex-grow-1"></div>
+ </template>
+
<super-sidebar-toggle
v-if="hasCollapseButton"
:class="$options.JS_TOGGLE_COLLAPSE_CLASS"
@@ -123,11 +126,11 @@ export default {
tooltip-container="super-sidebar"
data-testid="super-sidebar-collapse-button"
/>
- <create-menu :groups="sidebarData.create_new_menu_groups" />
+ <create-menu v-if="sidebarData.is_logged_in" :groups="sidebarData.create_new_menu_groups" />
<gl-button
id="super-sidebar-search"
- v-gl-tooltip.bottom.hover.html="searchTooltip"
+ v-gl-tooltip.bottom.hover.noninteractive.ds500.html="searchTooltip"
v-gl-modal="$options.SEARCH_MODAL_ID"
data-testid="super-sidebar-search-button"
icon="search"
@@ -136,24 +139,26 @@ export default {
/>
<search-modal @shown="hideSearchTooltip" @hidden="showSearchTooltip" />
- <user-menu :data="sidebarData" />
+ <user-menu v-if="sidebarData.is_logged_in" :data="sidebarData" />
<gl-button
v-if="isImpersonating"
- v-gl-tooltip
+ v-gl-tooltip.noninteractive.ds500.bottom
:href="sidebarData.stop_impersonation_path"
:title="$options.i18n.stopImpersonating"
:aria-label="$options.i18n.stopImpersonating"
icon="incognito"
- variant="confirm"
category="tertiary"
data-method="delete"
data-testid="stop-impersonation-btn"
/>
</div>
- <div class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2">
+ <div
+ v-if="sidebarData.is_logged_in"
+ class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2"
+ >
<counter
- v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.issues"
+ v-gl-tooltip:super-sidebar.hover.noninteractive.ds500.bottom="$options.i18n.issues"
class="gl-flex-basis-third dashboard-shortcuts-issues"
icon="issues"
:count="userCounts.assigned_issues"
@@ -171,7 +176,9 @@ export default {
@hidden="mrMenuShown = false"
>
<counter
- v-gl-tooltip:super-sidebar.hover.bottom="mrMenuShown ? '' : $options.i18n.mergeRequests"
+ v-gl-tooltip:super-sidebar.hover.noninteractive.ds500.bottom="
+ mrMenuShown ? '' : $options.i18n.mergeRequests
+ "
class="gl-w-full"
icon="merge-request-open"
:count="mergeRequestTotalCount"
@@ -183,7 +190,7 @@ export default {
/>
</merge-request-menu>
<counter
- v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.todoList"
+ v-gl-tooltip:super-sidebar.hover.noninteractive.ds500.bottom="$options.i18n.todoList"
class="gl-flex-basis-third shortcuts-todos js-todos-count"
icon="todo-done"
:count="userCounts.todos"
diff --git a/app/assets/javascripts/super_sidebar/components/user_name_group.vue b/app/assets/javascripts/super_sidebar/components/user_name_group.vue
index f3e8816cd37..13f19338610 100644
--- a/app/assets/javascripts/super_sidebar/components/user_name_group.vue
+++ b/app/assets/javascripts/super_sidebar/components/user_name_group.vue
@@ -76,6 +76,7 @@ export default {
<gl-emoji :data-name="user.status.emoji" class="gl-mr-1" />
<span v-safe-html="user.status.message_html" class="gl-text-truncate"></span>
<gl-tooltip
+ v-if="user.status.message_html"
:target="() => $refs.statusTooltipTarget"
boundary="viewport"
placement="bottom"
diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
index 322eca72016..2b62e7a6ede 100644
--- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
+++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
@@ -40,6 +40,7 @@ const getTrialStatusWidgetData = (sidebarData) => {
lastName,
companyName,
glmContent,
+ createHandRaiseLeadPath,
} = convertObjectPropsToCamelCase(sidebarData.trial_status_popover_data_attrs);
return {
@@ -53,6 +54,7 @@ const getTrialStatusWidgetData = (sidebarData) => {
plansHref,
daysRemaining,
targetId,
+ createHandRaiseLeadPath,
trialEndDate: new Date(trialEndDate),
user: { namespaceId, userName, firstName, lastName, companyName, glmContent },
};
@@ -79,11 +81,15 @@ export const initSuperSidebar = () => {
const sidebarData = JSON.parse(sidebar);
const searchData = convertObjectPropsToCamelCase(sidebarData.search);
+ const projectsPath = sidebarData.projects_path;
+ const groupsPath = sidebarData.groups_path;
+
const commandPaletteData = JSON.parse(commandPalette);
const projectFilesPath = commandPaletteData.project_files_url;
const projectBlobPath = commandPaletteData.project_blob_url;
const commandPaletteCommands = sidebarData.create_new_menu_groups || [];
const commandPaletteLinks = convertObjectPropsToCamelCase(sidebarData.current_menu_items || []);
+ const contextSwitcherLinks = sidebarData.context_switcher_links;
const { searchPath, issuesPath, mrPath, autocompletePath, searchContext } = searchData;
const isImpersonating = parseBoolean(sidebarData.is_impersonating);
@@ -99,10 +105,13 @@ export const initSuperSidebar = () => {
...getTrialStatusWidgetData(sidebarData),
commandPaletteCommands,
commandPaletteLinks,
+ contextSwitcherLinks,
autocompletePath,
searchContext,
projectFilesPath,
projectBlobPath,
+ projectsPath,
+ groupsPath,
},
store: createStore({
searchPath,
diff --git a/app/assets/javascripts/super_sidebar/utils.js b/app/assets/javascripts/super_sidebar/utils.js
index 3b17a35c5bc..cbf93155fb6 100644
--- a/app/assets/javascripts/super_sidebar/utils.js
+++ b/app/assets/javascripts/super_sidebar/utils.js
@@ -1,3 +1,4 @@
+import * as Sentry from '@sentry/browser';
import AccessorUtilities from '~/lib/utils/accessor';
import { FREQUENT_ITEMS, FIFTEEN_MINUTES_IN_MS } from '~/frequent_items/constants';
import { truncateNamespace } from '~/lib/utils/text_utility';
@@ -35,17 +36,15 @@ export const getTopFrequentItems = (items, maxCount) => {
return frequentItems.slice(0, maxCount);
};
-const updateItemAccess = (item) => {
+const updateItemAccess = (contextItem, { lastAccessedOn, frequency = 0 } = {}) => {
const now = Date.now();
- const neverAccessed = !item.lastAccessedOn;
- const shouldUpdate =
- neverAccessed || Math.abs(now - item.lastAccessedOn) / FIFTEEN_MINUTES_IN_MS > 1;
- const currentFrequency = item.frequency ?? 0;
+ const neverAccessed = !lastAccessedOn;
+ const shouldUpdate = neverAccessed || Math.abs(now - lastAccessedOn) / FIFTEEN_MINUTES_IN_MS > 1;
return {
- ...item,
- frequency: shouldUpdate ? currentFrequency + 1 : currentFrequency,
- lastAccessedOn: shouldUpdate ? now : item.lastAccessedOn,
+ ...contextItem,
+ frequency: shouldUpdate ? frequency + 1 : frequency,
+ lastAccessedOn: shouldUpdate ? now : lastAccessedOn,
};
};
@@ -62,7 +61,7 @@ export const trackContextAccess = (username, context) => {
);
if (existingItemIndex > -1) {
- storedItems[existingItemIndex] = updateItemAccess(storedItems[existingItemIndex]);
+ storedItems[existingItemIndex] = updateItemAccess(context.item, storedItems[existingItemIndex]);
} else {
const newItem = updateItemAccess(context.item);
if (storedItems.length === FREQUENT_ITEMS.MAX_COUNT) {
@@ -84,4 +83,31 @@ export const formatContextSwitcherItems = (items) =>
link,
}));
+export const getItemsFromLocalStorage = ({ storageKey, maxItems }) => {
+ if (!AccessorUtilities.canUseLocalStorage()) {
+ return [];
+ }
+
+ try {
+ const parsedCachedFrequentItems = JSON.parse(localStorage.getItem(storageKey));
+ return getTopFrequentItems(parsedCachedFrequentItems, maxItems);
+ } catch (e) {
+ Sentry.captureException(e);
+ return [];
+ }
+};
+
+export const removeItemFromLocalStorage = ({ storageKey, item }) => {
+ try {
+ const parsedCachedFrequentItems = JSON.parse(localStorage.getItem(storageKey));
+ const filteredItems = parsedCachedFrequentItems.filter((i) => i.id !== item.id);
+ localStorage.setItem(storageKey, JSON.stringify(filteredItems));
+
+ return filteredItems;
+ } catch (e) {
+ Sentry.captureException(e);
+ return [];
+ }
+};
+
export const ariaCurrent = (isActive) => (isActive ? 'page' : null);
diff --git a/app/assets/javascripts/tags/components/delete_tag_modal.vue b/app/assets/javascripts/tags/components/delete_tag_modal.vue
index e3b666ec968..c4f9db70d2a 100644
--- a/app/assets/javascripts/tags/components/delete_tag_modal.vue
+++ b/app/assets/javascripts/tags/components/delete_tag_modal.vue
@@ -1,9 +1,10 @@
<script>
-import { GlButton, GlFormInput, GlModal, GlSprintf, GlAlert } from '@gitlab/ui';
+import { GlButton, GlFormInput, GlModal, GlSprintf } from '@gitlab/ui';
import { parseBoolean } from '~/lib/utils/common_utils';
import csrf from '~/lib/utils/csrf';
-import { sprintf, s__ } from '~/locale';
+import { sprintf } from '~/locale';
import eventHub from '../event_hub';
+import { I18N_DELETE_TAG_MODAL } from '../constants';
export default {
csrf,
@@ -12,7 +13,6 @@ export default {
GlButton,
GlFormInput,
GlSprintf,
- GlAlert,
},
data() {
return {
@@ -94,57 +94,38 @@ export default {
this.$refs.modal.hide();
},
},
- i18n: {
- modalTitle: s__('TagsPage|Delete tag. Are you ABSOLUTELY SURE?'),
- modalTitleProtectedTag: s__('TagsPage|Delete protected tag. Are you ABSOLUTELY SURE?'),
- modalMessage: s__(
- "TagsPage|You're about to permanently delete the tag %{strongStart}%{tagName}.%{strongEnd}",
- ),
- modalMessageProtectedTag: s__(
- "TagsPage|You're about to permanently delete the protected tag %{strongStart}%{tagName}.%{strongEnd}",
- ),
- undoneWarning: s__(
- 'TagsPage|After you confirm and select %{strongStart}%{buttonText},%{strongEnd} you cannot recover this tag.',
- ),
- cancelButtonText: s__('TagsPage|Cancel, keep tag'),
- confirmationText: s__(
- 'TagsPage|Deleting the %{strongStart}%{tagName}%{strongEnd} tag cannot be undone. Are you sure?',
- ),
- confirmationTextProtectedTag: s__('TagsPage|Please type the following to confirm:'),
- deleteButtonText: s__('TagsPage|Yes, delete tag'),
- deleteButtonTextProtectedTag: s__('TagsPage|Yes, delete protected tag'),
- },
+ i18n: I18N_DELETE_TAG_MODAL,
};
</script>
<template>
<gl-modal ref="modal" size="sm" :modal-id="modalId" :title="title">
- <gl-alert class="gl-mb-5" variant="danger" :dismissible="false">
- <div data-testid="modal-message">
- <gl-sprintf :message="message">
- <template #strong="{ content }">
- <strong> {{ content }} </strong>
- </template>
- </gl-sprintf>
- </div>
- </gl-alert>
+ <div data-testid="modal-message">
+ <gl-sprintf :message="message">
+ <template #strong="{ content }">
+ <strong> {{ content }} </strong>
+ </template>
+ </gl-sprintf>
+ </div>
+ <p class="gl-mt-4">
+ <gl-sprintf :message="confirmationText">
+ <template #strong="{ content }">
+ <strong>
+ {{ content }}
+ </strong>
+ </template>
+ </gl-sprintf>
+ </p>
<form ref="form" :action="path" method="post">
<div v-if="isProtected" class="gl-mt-4">
<p>
- <gl-sprintf :message="undoneWarning">
- <template #strong="{ content }">
- <strong> {{ content }} </strong>
- </template>
- </gl-sprintf>
- </p>
- <p>
<gl-sprintf :message="$options.i18n.confirmationTextProtectedTag">
<template #strong="{ content }">
{{ content }}
</template>
</gl-sprintf>
- <code class="gl-white-space-pre-wrap"> {{ tagName }} </code>
+ <code> {{ tagName }} </code>
<gl-form-input
v-model="enteredTagName"
name="delete_tag_input"
@@ -155,17 +136,6 @@ export default {
/>
</p>
</div>
- <div v-else>
- <p class="gl-mt-4">
- <gl-sprintf :message="confirmationText">
- <template #strong="{ content }">
- <strong>
- {{ content }}
- </strong>
- </template>
- </gl-sprintf>
- </p>
- </div>
<input ref="method" type="hidden" name="_method" value="delete" />
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
diff --git a/app/assets/javascripts/tags/components/sort_dropdown.vue b/app/assets/javascripts/tags/components/sort_dropdown.vue
index bb4f3ac0571..87c8742076b 100644
--- a/app/assets/javascripts/tags/components/sort_dropdown.vue
+++ b/app/assets/javascripts/tags/components/sort_dropdown.vue
@@ -60,7 +60,6 @@ export default {
v-model="searchTerm"
:placeholder="$options.i18n.searchPlaceholder"
class="gl-pr-3"
- data-testid="tag-search"
@submit="visitUrlFromOption(selectedKey)"
/>
<gl-collapsible-listbox
diff --git a/app/assets/javascripts/tags/constants.js b/app/assets/javascripts/tags/constants.js
new file mode 100644
index 00000000000..a8096a08a97
--- /dev/null
+++ b/app/assets/javascripts/tags/constants.js
@@ -0,0 +1,37 @@
+import { s__ } from '~/locale';
+
+export const MODAL_TITLE = s__('TagsPage|Permanently delete tag?');
+
+export const MODAL_TITLE_PROTECTED_TAG = s__('TagsPage|Permanently delete protected tag?');
+
+export const MODAL_MESSAGE = s__(
+ 'TagsPage|Deleting the %{strongStart}%{tagName}%{strongEnd} tag cannot be undone.',
+);
+
+export const MODAL_MESSAGE_PROTECTED_TAG = s__(
+ 'TagsPage|Deleting the %{strongStart}%{tagName}%{strongEnd} protected tag cannot be undone.',
+);
+
+export const CANCEL_BUTTON_TEXT = s__('TagsPage|Cancel, keep tag');
+
+export const CONFIRMATION_TEXT = s__('TagsPage|Are you sure you want to delete this tag?');
+
+export const CONFIRMATION_TEXT_PROTECTED_TAG = s__(
+ 'TagsPage|Please type the following to confirm:',
+);
+
+export const DELETE_BUTTON_TEXT = s__('TagsPage|Yes, delete tag');
+
+export const DELETE_BUTTON_TEXT_PROTECTED_TAG = s__('TagsPage|Yes, delete protected tag');
+
+export const I18N_DELETE_TAG_MODAL = {
+ modalTitle: MODAL_TITLE,
+ modalTitleProtectedTag: MODAL_TITLE_PROTECTED_TAG,
+ modalMessage: MODAL_MESSAGE,
+ modalMessageProtectedTag: MODAL_MESSAGE_PROTECTED_TAG,
+ cancelButtonText: CANCEL_BUTTON_TEXT,
+ confirmationText: CONFIRMATION_TEXT,
+ confirmationTextProtectedTag: CONFIRMATION_TEXT_PROTECTED_TAG,
+ deleteButtonText: DELETE_BUTTON_TEXT,
+ deleteButtonTextProtectedTag: DELETE_BUTTON_TEXT_PROTECTED_TAG,
+};
diff --git a/app/assets/javascripts/terraform/components/init_command_modal.vue b/app/assets/javascripts/terraform/components/init_command_modal.vue
index a63c2025e8b..74c41700f43 100644
--- a/app/assets/javascripts/terraform/components/init_command_modal.vue
+++ b/app/assets/javascripts/terraform/components/init_command_modal.vue
@@ -83,7 +83,6 @@ terraform init \\
:title="$options.i18n.copyToClipboardText"
:text="getModalInfoCopyStr()"
:modal-id="$options.modalId"
- data-testid="init-command-copy-clipboard"
css-classes="gl-align-self-start gl-ml-2"
/>
</div>
diff --git a/app/assets/javascripts/token_access/components/inbound_token_access.vue b/app/assets/javascripts/token_access/components/inbound_token_access.vue
index eb1222d5130..234ac0505b2 100644
--- a/app/assets/javascripts/token_access/components/inbound_token_access.vue
+++ b/app/assets/javascripts/token_access/components/inbound_token_access.vue
@@ -5,6 +5,7 @@ import {
GlCard,
GlFormInput,
GlLink,
+ GlIcon,
GlLoadingIcon,
GlSprintf,
GlToggle,
@@ -21,9 +22,9 @@ import TokenProjectsTable from './token_projects_table.vue';
export default {
i18n: {
- toggleLabelTitle: s__('CICD|Allow access to this project with a CI_JOB_TOKEN'),
+ toggleLabelTitle: s__('CICD|Limit access %{italicStart}to%{italicEnd} this project'),
toggleHelpText: s__(
- `CICD|Manage which projects can use their CI_JOB_TOKEN to access this project. 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}`,
+ `CICD|Prevent access to this project from other project CI/CD job tokens, unless the other project is added to the allowlist. 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|Allow CI job tokens from the following projects to access this project',
@@ -64,6 +65,7 @@ export default {
GlCard,
GlFormInput,
GlLink,
+ GlIcon,
GlLoadingIcon,
GlSprintf,
GlToggle,
@@ -109,6 +111,7 @@ export default {
inboundJobTokenScopeEnabled: null,
targetProjectPath: '',
projects: [],
+ isAddFormVisible: false,
};
},
computed: {
@@ -193,10 +196,14 @@ export default {
},
clearTargetProjectPath() {
this.targetProjectPath = '';
+ this.isAddFormVisible = false;
},
getProjects() {
this.$apollo.queries.projects.refetch();
},
+ showAddForm() {
+ this.isAddFormVisible = true;
+ },
},
};
</script>
@@ -209,6 +216,13 @@ export default {
:label="$options.i18n.toggleLabelTitle"
@change="updateCIJobTokenScope"
>
+ <template #label>
+ <gl-sprintf :message="$options.i18n.toggleLabelTitle">
+ <template #italic="{ content }">
+ <i>{{ content }}</i>
+ </template>
+ </gl-sprintf>
+ </template>
<template #help>
<gl-sprintf :message="$options.i18n.toggleHelpText">
<template #link="{ content }">
@@ -221,22 +235,55 @@ export default {
</gl-toggle>
<div>
- <gl-card class="gl-mt-5 gl-mb-3">
+ <gl-card
+ class="gl-new-card"
+ header-class="gl-new-card-header"
+ body-class="gl-new-card-body gl-px-0"
+ >
<template #header>
- <h5 class="gl-my-0">{{ $options.i18n.cardHeaderTitle }}</h5>
+ <div class="gl-new-card-title-wrapper">
+ <h5 class="gl-new-card-title">{{ $options.i18n.cardHeaderTitle }}</h5>
+ <span class="gl-new-card-count">
+ <gl-icon name="project" class="gl-mr-2" />
+ {{ projects.length }}
+ </span>
+ </div>
+ <div class="gl-new-card-actions">
+ <gl-button
+ v-if="!isAddFormVisible"
+ size="small"
+ data-testid="toggle-form-btn"
+ @click="showAddForm"
+ >{{ $options.i18n.addProject }}</gl-button
+ >
+ </div>
</template>
- <template #default>
+
+ <div v-if="isAddFormVisible" class="gl-new-card-add-form gl-m-3">
+ <h4 class="gl-mt-0">{{ $options.i18n.addProject }}</h4>
<gl-form-input
v-model="targetProjectPath"
:placeholder="$options.i18n.addProjectPlaceholder"
/>
- </template>
- <template #footer>
- <gl-button variant="confirm" :disabled="isProjectPathEmpty" @click="addProject">
- {{ $options.i18n.addProject }}
- </gl-button>
- <gl-button @click="clearTargetProjectPath">{{ $options.i18n.cancel }}</gl-button>
- </template>
+ <div class="gl-display-flex gl-mt-5">
+ <gl-button
+ variant="confirm"
+ :disabled="isProjectPathEmpty"
+ class="gl-mr-3"
+ data-testid="add-project-btn"
+ @click="addProject"
+ >
+ {{ $options.i18n.addProject }}
+ </gl-button>
+ <gl-button @click="clearTargetProjectPath">{{ $options.i18n.cancel }}</gl-button>
+ </div>
+ </div>
+
+ <token-projects-table
+ :projects="projects"
+ :table-fields="$options.fields"
+ @removeProject="removeProject"
+ />
</gl-card>
<gl-alert
v-if="!inboundJobTokenScopeEnabled"
@@ -247,11 +294,6 @@ export default {
>
{{ $options.i18n.settingDisabledMessage }}
</gl-alert>
- <token-projects-table
- :projects="projects"
- :table-fields="$options.fields"
- @removeProject="removeProject"
- />
</div>
</template>
</div>
diff --git a/app/assets/javascripts/token_access/components/outbound_token_access.vue b/app/assets/javascripts/token_access/components/outbound_token_access.vue
index f70bb77b780..7e1e6cc445c 100644
--- a/app/assets/javascripts/token_access/components/outbound_token_access.vue
+++ b/app/assets/javascripts/token_access/components/outbound_token_access.vue
@@ -3,8 +3,8 @@ import {
GlAlert,
GlButton,
GlCard,
- GlFormInput,
GlLink,
+ GlIcon,
GlLoadingIcon,
GlSprintf,
GlToggle,
@@ -23,9 +23,11 @@ import TokenProjectsTable from './token_projects_table.vue';
// Note: This component will be removed in 17.0, as the outbound access token is getting deprecated
export default {
i18n: {
- toggleLabelTitle: s__('CICD|Limit CI_JOB_TOKEN access'),
+ toggleLabelTitle: s__(
+ 'CICD|Limit access %{italicStart}from%{italicEnd} this project (Deprecated)',
+ ),
toggleHelpText: s__(
- `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}`,
+ `CICD|Prevent CI/CD job tokens from this project from being used to access other projects unless the other project is added to the allowlist. 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'),
settingDisabledMessage: s__(
@@ -69,8 +71,8 @@ export default {
GlAlert,
GlButton,
GlCard,
- GlFormInput,
GlLink,
+ GlIcon,
GlLoadingIcon,
GlSprintf,
GlToggle,
@@ -219,7 +221,7 @@ export default {
<gl-loading-icon v-if="$apollo.loading" size="lg" class="gl-mt-5" />
<template v-else>
<gl-alert
- class="gl-mb-3"
+ class="gl-mt-5 gl-mb-3"
variant="warning"
:dismissible="false"
:show-icon="false"
@@ -246,6 +248,13 @@ export default {
:disabled="disableTokenToggle"
@change="updateCIJobTokenScope"
>
+ <template #label>
+ <gl-sprintf :message="$options.i18n.toggleLabelTitle">
+ <template #italic="{ content }">
+ <i>{{ content }}</i>
+ </template>
+ </gl-sprintf>
+ </template>
<template #help>
<gl-sprintf :message="$options.i18n.toggleHelpText">
<template #link="{ content }">
@@ -259,30 +268,29 @@ export default {
</gl-toggle>
<div>
- <gl-card class="gl-mt-5 gl-mb-3">
+ <gl-card
+ class="gl-new-card"
+ header-class="gl-new-card-header"
+ body-class="gl-new-card-body gl-px-0"
+ >
<template #header>
- <h5 class="gl-my-0">{{ $options.i18n.cardHeaderTitle }}</h5>
- </template>
- <template #default>
- <gl-form-input
- v-model="targetProjectPath"
- :disabled="true"
- :placeholder="$options.i18n.addProjectPlaceholder"
- data-testid="project-path-input"
- />
- </template>
- <template #footer>
- <gl-button variant="confirm" :disabled="isProjectPathEmpty" @click="addProject">
- {{ $options.i18n.addProject }}
- </gl-button>
- <gl-button @click="clearTargetProjectPath">{{ $options.i18n.cancel }}</gl-button>
+ <div class="gl-new-card-title-wrapper">
+ <h5 class="gl-new-card-title">{{ $options.i18n.cardHeaderTitle }}</h5>
+ <span class="gl-new-card-count">
+ <gl-icon name="project" class="gl-mr-2" />
+ {{ projects.length }}
+ </span>
+ </div>
+ <div class="gl-new-card-actions">
+ <gl-button size="small" disabled>{{ $options.i18n.addProject }}</gl-button>
+ </div>
</template>
+ <token-projects-table
+ :projects="projects"
+ :table-fields="$options.fields"
+ @removeProject="removeProject"
+ />
</gl-card>
- <token-projects-table
- :projects="projects"
- :table-fields="$options.fields"
- @removeProject="removeProject"
- />
</div>
</template>
</div>
diff --git a/app/assets/javascripts/token_access/components/token_access_app.vue b/app/assets/javascripts/token_access/components/token_access_app.vue
index 167eebc8d9b..d2d5e6b2a5a 100644
--- a/app/assets/javascripts/token_access/components/token_access_app.vue
+++ b/app/assets/javascripts/token_access/components/token_access_app.vue
@@ -12,7 +12,7 @@ export default {
</script>
<template>
<div>
- <inbound-token-access class="gl-pb-5" />
- <outbound-token-access class="gl-py-5" />
+ <inbound-token-access />
+ <outbound-token-access />
</div>
</template>
diff --git a/app/assets/javascripts/tooltips/components/tooltips.vue b/app/assets/javascripts/tooltips/components/tooltips.vue
index a4dc783f1e4..26479aeffcf 100644
--- a/app/assets/javascripts/tooltips/components/tooltips.vue
+++ b/app/assets/javascripts/tooltips/components/tooltips.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlTooltip } from '@gitlab/ui';
import { uniqueId } from 'lodash';
@@ -7,9 +8,14 @@ const getTooltipTitle = (element) => {
return element.getAttribute('title') || element.dataset.title;
};
+const getTooltipCustomClass = (element) => {
+ return element.dataset.tooltipCustomClass;
+};
+
const newTooltip = (element, config = {}) => {
const { placement, container, boundary, html, triggers } = element.dataset;
const title = getTooltipTitle(element);
+ const customClass = getTooltipCustomClass(element);
return {
id: uniqueId('gl-tooltip'),
@@ -21,6 +27,7 @@ const newTooltip = (element, config = {}) => {
boundary,
triggers,
disabled: !title,
+ customClass,
...config,
};
};
@@ -115,6 +122,7 @@ export default {
:boundary="tooltip.boundary"
:disabled="tooltip.disabled"
:show="tooltip.show"
+ :custom-class="tooltip.customClass"
@hidden="$emit('hidden', tooltip)"
>
<span v-if="tooltip.html" v-safe-html:[$options.safeHtmlConfig]="tooltip.title"></span>
diff --git a/app/assets/javascripts/tracing/components/tracing_details.vue b/app/assets/javascripts/tracing/components/tracing_details.vue
new file mode 100644
index 00000000000..d8b2cbc9469
--- /dev/null
+++ b/app/assets/javascripts/tracing/components/tracing_details.vue
@@ -0,0 +1,90 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { createAlert } from '~/alert';
+import { visitUrl, isSafeURL } from '~/lib/utils/url_utility';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ },
+ i18n: {
+ error: s__('Tracing|Failed to load trace details.'),
+ },
+ props: {
+ observabilityClient: {
+ required: true,
+ type: Object,
+ },
+ traceId: {
+ required: true,
+ type: String,
+ },
+ tracingIndexUrl: {
+ required: true,
+ type: String,
+ validator: (val) => isSafeURL(val),
+ },
+ },
+ data() {
+ return {
+ trace: null,
+ loading: false,
+ };
+ },
+ created() {
+ this.validateAndFetch();
+ },
+ methods: {
+ async validateAndFetch() {
+ if (!this.traceId) {
+ createAlert({
+ message: this.$options.i18n.error,
+ });
+ }
+ this.loading = true;
+ try {
+ const enabled = await this.observabilityClient.isTracingEnabled();
+ if (enabled) {
+ await this.fetchTrace();
+ } else {
+ this.goToTracingIndex();
+ }
+ } catch (e) {
+ createAlert({
+ message: this.$options.i18n.error,
+ });
+ } finally {
+ this.loading = false;
+ }
+ },
+ async fetchTrace() {
+ this.loading = true;
+ try {
+ this.trace = await this.observabilityClient.fetchTrace(this.traceId);
+ } catch (e) {
+ createAlert({
+ message: this.$options.i18n.error,
+ });
+ } finally {
+ this.loading = false;
+ }
+ },
+ goToTracingIndex() {
+ visitUrl(this.tracingIndexUrl);
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="loading" class="gl-py-5">
+ <gl-loading-icon size="lg" />
+ </div>
+
+ <!-- TODO Replace with actual trace-details component-->
+ <div v-else-if="trace" data-testid="trace-details">
+ <p>{{ tracingIndexUrl }}</p>
+ <p>{{ trace }}</p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/tracing/components/tracing_empty_state.vue b/app/assets/javascripts/tracing/components/tracing_empty_state.vue
index 4cb3bd6d9f0..f17060db6bc 100644
--- a/app/assets/javascripts/tracing/components/tracing_empty_state.vue
+++ b/app/assets/javascripts/tracing/components/tracing_empty_state.vue
@@ -1,31 +1,20 @@
<script>
import EMPTY_TRACING_SVG from '@gitlab/svgs/dist/illustrations/monitoring/tracing.svg?url';
import { GlEmptyState, GlButton } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { s__ } from '~/locale';
export default {
EMPTY_TRACING_SVG,
name: 'TracingEmptyState',
i18n: {
- title: __('Get started with Tracing'),
- description: __('Monitor your applications with GitLab Distributed Tracing.'),
- enableButtonText: __('Enable'),
+ title: s__('Tracing|Get started with Tracing'),
+ description: s__('Tracing|Monitor your applications with GitLab Distributed Tracing.'),
+ enableButtonText: s__('Tracing|Enable'),
},
components: {
GlEmptyState,
GlButton,
},
- props: {
- enableTracing: {
- type: Function,
- required: true,
- },
- },
- methods: {
- onEnabledClicked() {
- this.enableTracing();
- },
- },
};
</script>
@@ -38,7 +27,7 @@ export default {
</template>
<template #actions>
- <gl-button variant="confirm" class="gl-mx-2 gl-mb-3" @click="onEnabledClicked">
+ <gl-button variant="confirm" class="gl-mx-2 gl-mb-3" @click="$emit('enable-tracing')">
{{ $options.i18n.enableButtonText }}
</gl-button>
</template>
diff --git a/app/assets/javascripts/tracing/components/tracing_list.vue b/app/assets/javascripts/tracing/components/tracing_list.vue
index 294e520d7ac..21d1353a86d 100644
--- a/app/assets/javascripts/tracing/components/tracing_list.vue
+++ b/app/assets/javascripts/tracing/components/tracing_list.vue
@@ -1,15 +1,26 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { s__ } from '~/locale';
import { createAlert } from '~/alert';
+import { visitUrl, joinPaths } from '~/lib/utils/url_utility';
+import UrlSync from '~/vue_shared/components/url_sync.vue';
+import {
+ queryToFilterObj,
+ filterObjToQuery,
+ filterObjToFilterToken,
+ filterTokensToFilterObj,
+} from '../filters';
import TracingEmptyState from './tracing_empty_state.vue';
import TracingTableList from './tracing_table_list.vue';
+import FilteredSearch from './tracing_list_filtered_search.vue';
export default {
components: {
GlLoadingIcon,
TracingTableList,
TracingEmptyState,
+ FilteredSearch,
+ UrlSync,
},
props: {
observabilityClient: {
@@ -26,8 +37,17 @@ export default {
*/
tracingEnabled: null,
traces: [],
+ filters: queryToFilterObj(window.location.search),
};
},
+ computed: {
+ query() {
+ return filterObjToQuery(this.filters);
+ },
+ initialFilterValue() {
+ return filterObjToFilterToken(this.filters);
+ },
+ },
async created() {
this.checkEnabled();
},
@@ -41,7 +61,7 @@ export default {
}
} catch (e) {
createAlert({
- message: __('Failed to load page.'),
+ message: s__('Tracing|Failed to load page.'),
});
} finally {
this.loading = false;
@@ -55,7 +75,7 @@ export default {
await this.fetchTraces();
} catch (e) {
createAlert({
- message: __('Failed to enable tracing.'),
+ message: s__('Tracing|Failed to enable tracing.'),
});
} finally {
this.loading = false;
@@ -64,16 +84,23 @@ export default {
async fetchTraces() {
this.loading = true;
try {
- const traces = await this.observabilityClient.fetchTraces();
+ const traces = await this.observabilityClient.fetchTraces(this.filters);
this.traces = traces;
} catch (e) {
createAlert({
- message: __('Failed to load traces.'),
+ message: s__('Tracing|Failed to load traces.'),
});
} finally {
this.loading = false;
}
},
+ selectTrace(trace) {
+ visitUrl(joinPaths(window.location.pathname, trace.trace_id));
+ },
+ handleFilters(filterTokens) {
+ this.filters = filterTokensToFilterObj(filterTokens);
+ this.fetchTraces();
+ },
},
};
</script>
@@ -85,9 +112,14 @@ export default {
</div>
<template v-else-if="tracingEnabled !== null">
- <tracing-empty-state v-if="tracingEnabled === false" :enable-tracing="enableTracing" />
+ <tracing-empty-state v-if="tracingEnabled === false" @enable-tracing="enableTracing" />
+
+ <template v-else>
+ <filtered-search :initial-filters="initialFilterValue" @submit="handleFilters" />
+ <url-sync :query="query" />
- <tracing-table-list v-else :traces="traces" @reload="fetchTraces" />
+ <tracing-table-list :traces="traces" @reload="fetchTraces" @trace-selected="selectTrace" />
+ </template>
</template>
</div>
</template>
diff --git a/app/assets/javascripts/tracing/components/tracing_list_filtered_search.vue b/app/assets/javascripts/tracing/components/tracing_list_filtered_search.vue
new file mode 100644
index 00000000000..d086f2d03ff
--- /dev/null
+++ b/app/assets/javascripts/tracing/components/tracing_list_filtered_search.vue
@@ -0,0 +1,87 @@
+<script>
+import { GlFilteredSearch, GlFilteredSearchToken } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import {
+ OPERATORS_IS,
+ OPERATORS_IS_NOT,
+} from '~/vue_shared/components/filtered_search_bar/constants';
+import {
+ PERIOD_FILTER_TOKEN_TYPE,
+ SERVICE_NAME_FILTER_TOKEN_TYPE,
+ OPERATION_FILTER_TOKEN_TYPE,
+ TRACE_ID_FILTER_TOKEN_TYPE,
+ DURATION_MS_FILTER_TOKEN_TYPE,
+} from '../filters';
+
+export default {
+ availableTokens: [
+ {
+ title: s__('Tracing|Period'),
+ icon: 'clock',
+ type: PERIOD_FILTER_TOKEN_TYPE,
+ token: GlFilteredSearchToken,
+ operators: OPERATORS_IS,
+ unique: true,
+ options: [
+ { value: '1m', title: s__('Tracing|Last 1 minute') },
+ { value: '15m', title: s__('Tracing|Last 15 minutes') },
+ { value: '30m', title: s__('Tracing|Last 30 minutes') },
+ { value: '1h', title: s__('Tracing|Last 1 hour') },
+ { value: '24h', title: s__('Tracing|Last 24 hours') },
+ { value: '7d', title: s__('Tracing|Last 7 days') },
+ { value: '14d', title: s__('Tracing|Last 14 days') },
+ { value: '30d', title: s__('Tracing|Last 30 days') },
+ ],
+ },
+ {
+ title: s__('Tracing|Service'),
+ type: SERVICE_NAME_FILTER_TOKEN_TYPE,
+ token: GlFilteredSearchToken,
+ operators: OPERATORS_IS_NOT,
+ },
+ {
+ title: s__('Tracing|Operation'),
+ type: OPERATION_FILTER_TOKEN_TYPE,
+ token: GlFilteredSearchToken,
+ operators: OPERATORS_IS_NOT,
+ },
+ {
+ title: s__('Tracing|Trace ID'),
+ type: TRACE_ID_FILTER_TOKEN_TYPE,
+ token: GlFilteredSearchToken,
+ operators: OPERATORS_IS_NOT,
+ },
+ {
+ title: s__('Tracing|Duration (ms)'),
+ type: DURATION_MS_FILTER_TOKEN_TYPE,
+ token: GlFilteredSearchToken,
+ operators: [
+ { value: '>', description: s__('Tracing|longer than') },
+ { value: '<', description: s__('Tracing|shorter than') },
+ ],
+ },
+ ],
+ components: {
+ GlFilteredSearch,
+ },
+ props: {
+ initialFilters: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="vue-filtered-search-bar-container row-content-block gl-border-t-none">
+ <gl-filtered-search
+ :value="initialFilters"
+ terms-as-tokens
+ :placeholder="s__('Tracing|Filter Traces')"
+ :available-tokens="$options.availableTokens"
+ @submit="$emit('submit', $event)"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/tracing/components/tracing_table_list.vue b/app/assets/javascripts/tracing/components/tracing_table_list.vue
index 7e8c296a7d4..abb1f3ae88c 100644
--- a/app/assets/javascripts/tracing/components/tracing_table_list.vue
+++ b/app/assets/javascripts/tracing/components/tracing_table_list.vue
@@ -1,37 +1,37 @@
<script>
import { GlTable, GlLink } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { s__ } from '~/locale';
export const tableDataClass = 'gl-display-flex gl-md-display-table-cell gl-align-items-center';
export default {
name: 'TracingTableList',
i18n: {
- title: __('Traces'),
- emptyText: __('No traces to display.'),
- emptyLinkText: __('Check again'),
+ title: s__('Tracing|Traces'),
+ emptyText: s__('Tracing|No traces to display.'),
+ emptyLinkText: s__('Tracing|Check again'),
},
fields: [
{
- key: 'date',
- label: __('Date'),
+ key: 'timestamp',
+ label: s__('Tracing|Date'),
tdClass: tableDataClass,
sortable: true,
},
{
- key: 'service',
- label: __('Service'),
+ key: 'service_name',
+ label: s__('Tracing|Service'),
tdClass: tableDataClass,
sortable: true,
},
{
key: 'operation',
- label: __('Operation'),
+ label: s__('Tracing|Operation'),
tdClass: tableDataClass,
sortable: true,
},
{
key: 'duration',
- label: __('Duration'),
+ label: s__('Tracing|Duration'),
thClass: 'gl-w-15p',
tdClass: tableDataClass,
sortable: true,
@@ -47,6 +47,13 @@ export default {
type: Array,
},
},
+ methods: {
+ onSelect(items) {
+ if (items[0]) {
+ this.$emit('trace-selected', items[0]);
+ }
+ },
+ },
};
</script>
@@ -55,19 +62,24 @@ export default {
<h4 class="gl-display-block gl-md-display-none! gl-my-5">{{ $options.i18n.title }}</h4>
<gl-table
- class="gl-mt-5"
:items="traces"
:fields="$options.fields"
show-empty
+ sort-by="timestamp"
+ :sort-desc="true"
fixed
stacked="md"
tbody-tr-class="table-row"
+ selectable
+ select-mode="single"
+ selected-variant=""
+ @row-selected="onSelect"
>
- <template #cell(date)="data">
+ <template #cell(timestamp)="data">
{{ data.item.timestamp }}
</template>
- <template #cell(service)="data">
+ <template #cell(service_name)="data">
{{ data.item.service_name }}
</template>
diff --git a/app/assets/javascripts/tracing/details_index.vue b/app/assets/javascripts/tracing/details_index.vue
new file mode 100644
index 00000000000..5702a88766c
--- /dev/null
+++ b/app/assets/javascripts/tracing/details_index.vue
@@ -0,0 +1,49 @@
+<script>
+import ObservabilityContainer from '~/observability/components/observability_container.vue';
+import TracingDetails from './components/tracing_details.vue';
+
+export default {
+ components: {
+ ObservabilityContainer,
+ TracingDetails,
+ },
+ props: {
+ traceId: {
+ type: String,
+ required: true,
+ },
+ oauthUrl: {
+ type: String,
+ required: true,
+ },
+ tracingUrl: {
+ type: String,
+ required: true,
+ },
+ provisioningUrl: {
+ type: String,
+ required: true,
+ },
+ tracingIndexUrl: {
+ required: true,
+ type: String,
+ },
+ },
+};
+</script>
+
+<template>
+ <observability-container
+ :oauth-url="oauthUrl"
+ :tracing-url="tracingUrl"
+ :provisioning-url="provisioningUrl"
+ >
+ <template #default="{ observabilityClient }">
+ <tracing-details
+ :trace-id="traceId"
+ :tracing-index-url="tracingIndexUrl"
+ :observability-client="observabilityClient"
+ />
+ </template>
+ </observability-container>
+</template>
diff --git a/app/assets/javascripts/tracing/filters.js b/app/assets/javascripts/tracing/filters.js
new file mode 100644
index 00000000000..88a54b2e69f
--- /dev/null
+++ b/app/assets/javascripts/tracing/filters.js
@@ -0,0 +1,104 @@
+import {
+ filterToQueryObject,
+ urlQueryToFilter,
+ prepareTokens,
+ processFilters,
+} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
+import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
+
+export const PERIOD_FILTER_TOKEN_TYPE = 'period';
+export const SERVICE_NAME_FILTER_TOKEN_TYPE = 'service-name';
+export const OPERATION_FILTER_TOKEN_TYPE = 'operation';
+export const TRACE_ID_FILTER_TOKEN_TYPE = 'trace-id';
+export const DURATION_MS_FILTER_TOKEN_TYPE = 'duration-ms';
+
+export function queryToFilterObj(url) {
+ const filter = urlQueryToFilter(url, {
+ filteredSearchTermKey: 'search',
+ customOperators: [
+ {
+ operator: '>',
+ prefix: 'gt',
+ },
+ {
+ operator: '<',
+ prefix: 'lt',
+ },
+ ],
+ });
+ const {
+ period = null,
+ service = null,
+ operation = null,
+ trace_id: traceId = null,
+ durationMs = null,
+ } = filter;
+ const search = filter[FILTERED_SEARCH_TERM];
+ return {
+ period,
+ service,
+ operation,
+ traceId,
+ durationMs,
+ search,
+ };
+}
+
+export function filterObjToQuery(filters) {
+ return filterToQueryObject(
+ {
+ period: filters.period,
+ service: filters.serviceName,
+ operation: filters.operation,
+ trace_id: filters.traceId,
+ durationMs: filters.durationMs,
+ [FILTERED_SEARCH_TERM]: filters.search,
+ },
+ {
+ filteredSearchTermKey: 'search',
+ customOperators: [
+ {
+ operator: '>',
+ prefix: 'gt',
+ applyOnlyToKey: 'durationMs',
+ },
+ {
+ operator: '<',
+ prefix: 'lt',
+ applyOnlyToKey: 'durationMs',
+ },
+ ],
+ },
+ );
+}
+
+export function filterObjToFilterToken(filters) {
+ return prepareTokens({
+ [PERIOD_FILTER_TOKEN_TYPE]: filters.period,
+ [SERVICE_NAME_FILTER_TOKEN_TYPE]: filters.serviceName,
+ [OPERATION_FILTER_TOKEN_TYPE]: filters.operation,
+ [TRACE_ID_FILTER_TOKEN_TYPE]: filters.traceId,
+ [DURATION_MS_FILTER_TOKEN_TYPE]: filters.durationMs,
+ [FILTERED_SEARCH_TERM]: filters.search,
+ });
+}
+
+export function filterTokensToFilterObj(tokens) {
+ const {
+ [SERVICE_NAME_FILTER_TOKEN_TYPE]: serviceName,
+ [PERIOD_FILTER_TOKEN_TYPE]: period,
+ [OPERATION_FILTER_TOKEN_TYPE]: operation,
+ [TRACE_ID_FILTER_TOKEN_TYPE]: traceId,
+ [DURATION_MS_FILTER_TOKEN_TYPE]: durationMs,
+ [FILTERED_SEARCH_TERM]: search,
+ } = processFilters(tokens);
+
+ return {
+ serviceName,
+ period,
+ operation,
+ traceId,
+ durationMs,
+ search,
+ };
+}
diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js
index d0447fa167c..114587bb363 100644
--- a/app/assets/javascripts/tracking/constants.js
+++ b/app/assets/javascripts/tracking/constants.js
@@ -20,6 +20,7 @@ export const DEFAULT_SNOWPLOW_OPTIONS = {
export const ACTION_ATTR_SELECTOR = '[data-track-action]';
export const LOAD_ACTION_ATTR_SELECTOR = '[data-track-action="render"]';
export const INTERNAL_EVENTS_SELECTOR = '[data-event-tracking]';
+export const LOAD_INTERNAL_EVENTS_SELECTOR = '[data-event-tracking-load="true"]';
export const URLS_CACHE_STORAGE_KEY = 'gl-snowplow-pseudonymized-urls';
diff --git a/app/assets/javascripts/tracking/index.js b/app/assets/javascripts/tracking/index.js
index 7c2cd6fde27..ffbd932c02b 100644
--- a/app/assets/javascripts/tracking/index.js
+++ b/app/assets/javascripts/tracking/index.js
@@ -71,4 +71,5 @@ export function initDefaultTrackers() {
Tracking.trackLoadEvents();
InternalEvents.bindInternalEventDocument();
+ InternalEvents.trackInternalLoadEvents();
}
diff --git a/app/assets/javascripts/tracking/internal_events.js b/app/assets/javascripts/tracking/internal_events.js
index 16cbb3e86e1..a5fbb55ff63 100644
--- a/app/assets/javascripts/tracking/internal_events.js
+++ b/app/assets/javascripts/tracking/internal_events.js
@@ -1,9 +1,13 @@
import API from '~/api';
import Tracking from './tracking';
-import { GITLAB_INTERNAL_EVENT_CATEGORY, SERVICE_PING_SCHEMA } from './constants';
+import {
+ GITLAB_INTERNAL_EVENT_CATEGORY,
+ LOAD_INTERNAL_EVENTS_SELECTOR,
+ SERVICE_PING_SCHEMA,
+} from './constants';
import { Tracker } from './tracker';
-import { InternalEventHandler } from './utils';
+import { InternalEventHandler, createInternalEventPayload } from './utils';
const InternalEvents = {
/**
@@ -11,7 +15,7 @@ const InternalEvents = {
* @param {string} event
*/
track_event(event) {
- API.trackRedisHllUserEvent(event);
+ API.trackInternalEvent(event);
Tracking.event(GITLAB_INTERNAL_EVENT_CATEGORY, event, {
context: {
schema: SERVICE_PING_SCHEMA,
@@ -53,6 +57,27 @@ const InternalEvents = {
parent.addEventListener(handler.name, handler.func);
return handler;
},
+ /**
+ * Attaches internal event handlers for load events.
+ * @param {HTMLElement} parent - element containing event targets
+ * @returns {Array}
+ */
+ trackInternalLoadEvents(parent = document) {
+ if (!Tracker.enabled()) {
+ return [];
+ }
+
+ const loadEvents = parent.querySelectorAll(LOAD_INTERNAL_EVENTS_SELECTOR);
+
+ loadEvents.forEach((element) => {
+ const action = createInternalEventPayload(element);
+ if (action) {
+ this.track_event(action);
+ }
+ });
+
+ return loadEvents;
+ },
};
export default InternalEvents;
diff --git a/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.vue b/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.vue
index 94bc15fa0d0..f271b284d78 100644
--- a/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.vue
+++ b/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.vue
@@ -2,6 +2,7 @@
import { GlAlert, GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui';
import { sprintf } from '~/locale';
import { updateRepositorySize } from '~/api/projects_api';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
import {
ERROR_MESSAGE,
LEARN_MORE_LABEL,
@@ -11,10 +12,13 @@ import {
TOTAL_USAGE_DEFAULT_TEXT,
HELP_LINK_ARIA_LABEL,
RECALCULATE_REPOSITORY_LABEL,
- projectContainerRegistryPopoverContent,
+ PROJECT_STORAGE_TYPES,
+ NAMESPACE_STORAGE_TYPES,
+ usageQuotasHelpPaths,
+ storageTypeHelpPaths,
} from '../constants';
import getProjectStorageStatistics from '../queries/project_storage.query.graphql';
-import { parseGetProjectStorageResults } from '../utils';
+import { getStorageTypesFromProjectStatistics, descendingStorageUsageSort } from '../utils';
import UsageGraph from './usage_graph.vue';
import ProjectStorageDetail from './project_storage_detail.vue';
@@ -28,10 +32,7 @@ export default {
UsageGraph,
ProjectStorageDetail,
},
- inject: ['projectPath', 'helpLinks'],
- provide: {
- containerRegistryPopoverContent: projectContainerRegistryPopoverContent,
- },
+ inject: ['projectPath'],
apollo: {
project: {
query: getProjectStorageStatistics,
@@ -40,9 +41,6 @@ export default {
fullPath: this.projectPath,
};
},
- update(data) {
- return parseGetProjectStorageResults(data, this.helpLinks);
- },
error() {
this.error = ERROR_MESSAGE;
},
@@ -56,11 +54,39 @@ export default {
};
},
computed: {
+ isStatisticsEmpty() {
+ return this.project?.statistics == null;
+ },
totalUsage() {
- return this.project?.storage?.totalUsage || TOTAL_USAGE_DEFAULT_TEXT;
+ if (!this.isStatisticsEmpty) {
+ return numberToHumanSize(this.project?.statistics?.storageSize, 1);
+ }
+
+ return TOTAL_USAGE_DEFAULT_TEXT;
},
- storageTypes() {
- return this.project?.storage?.storageTypes || [];
+ projectStorageTypes() {
+ if (this.isStatisticsEmpty) {
+ return [];
+ }
+
+ return getStorageTypesFromProjectStatistics(
+ PROJECT_STORAGE_TYPES,
+ this.project?.statistics,
+ this.project?.statisticsDetailsPaths,
+ storageTypeHelpPaths,
+ ).sort(descendingStorageUsageSort('value'));
+ },
+ namespaceStorageTypes() {
+ if (this.isStatisticsEmpty) {
+ return [];
+ }
+
+ return getStorageTypesFromProjectStatistics(
+ NAMESPACE_STORAGE_TYPES,
+ this.project?.statistics,
+ this.project?.statisticsDetailsPaths,
+ storageTypeHelpPaths,
+ );
},
},
methods: {
@@ -83,6 +109,7 @@ export default {
alertEl?.classList.remove('gl-display-none');
},
},
+ usageQuotasHelpPaths,
LEARN_MORE_LABEL,
USAGE_QUOTAS_LABEL,
TOTAL_USAGE_TITLE,
@@ -99,17 +126,15 @@ export default {
<div class="gl-pt-5 gl-px-3">
<div class="gl-display-flex gl-justify-content-space-between gl-align-items-center">
<div>
- <p class="gl-m-0 gl-font-lg gl-font-weight-bold">{{ $options.TOTAL_USAGE_TITLE }}</p>
+ <h2 class="gl-m-0 gl-font-lg gl-font-weight-bold">{{ $options.TOTAL_USAGE_TITLE }}</h2>
<p class="gl-m-0 gl-text-gray-400">
{{ $options.TOTAL_USAGE_SUBTITLE }}
<gl-link
- :href="helpLinks.usageQuotas"
+ :href="$options.usageQuotasHelpPaths.usageQuotas"
target="_blank"
:aria-label="helpLinkAriaLabel($options.USAGE_QUOTAS_LABEL)"
- data-testid="usage-quotas-help-link"
+ >{{ $options.LEARN_MORE_LABEL }}</gl-link
>
- {{ $options.LEARN_MORE_LABEL }}
- </gl-link>
</p>
</div>
<p class="gl-m-0 gl-font-size-h-display gl-font-weight-bold" data-testid="total-usage">
@@ -117,7 +142,7 @@ export default {
</p>
</div>
</div>
- <div v-if="project.statistics" class="gl-w-full">
+ <div v-if="!isStatisticsEmpty" class="gl-w-full">
<usage-graph :root-storage-statistics="project.statistics" :limit="0" />
</div>
<div class="gl-w-full gl-my-5">
@@ -129,6 +154,19 @@ export default {
{{ $options.RECALCULATE_REPOSITORY_LABEL }}
</gl-button>
</div>
- <project-storage-detail :storage-types="storageTypes" />
+ <project-storage-detail
+ :storage-types="projectStorageTypes"
+ data-testid="usage-quotas-project-usage-details"
+ />
+ <div>
+ <h2 class="gl-mb-2 gl-mt-5 gl-font-lg gl-font-weight-bold">
+ {{ s__('UsageQuota|Namespace entities') }}
+ </h2>
+
+ <project-storage-detail
+ :storage-types="namespaceStorageTypes"
+ data-testid="usage-quotas-namespace-usage-details"
+ />
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue b/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue
index ce487beca07..6cc1f63e04f 100644
--- a/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue
+++ b/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue
@@ -7,10 +7,7 @@ import {
HELP_LINK_ARIA_LABEL,
PROJECT_TABLE_LABEL_STORAGE_TYPE,
PROJECT_TABLE_LABEL_USAGE,
- containerRegistryId,
- containerRegistryPopoverId,
} from '../constants';
-import { descendingStorageUsageSort } from '../utils';
import StorageTypeIcon from './storage_type_icon.vue';
export default {
@@ -23,33 +20,12 @@ export default {
StorageTypeIcon,
GlPopover,
},
- inject: ['containerRegistryPopoverContent'],
props: {
storageTypes: {
type: Array,
required: true,
},
},
- computed: {
- sizeSortedStorageTypes() {
- const warnings = {
- [containerRegistryId]: {
- popoverId: containerRegistryPopoverId,
- popoverContent: this.containerRegistryPopoverContent,
- },
- };
-
- return this.storageTypes
- .map((type) => {
- const warning = warnings[type.storageType.id] || null;
- return {
- warning,
- ...type,
- };
- })
- .sort(descendingStorageUsageSort('value'));
- },
- },
methods: {
helpLinkAriaLabel(linkTitle) {
return sprintf(HELP_LINK_ARIA_LABEL, {
@@ -73,42 +49,39 @@ export default {
};
</script>
<template>
- <gl-table-lite :items="sizeSortedStorageTypes" :fields="$options.projectTableFields">
+ <gl-table-lite :items="storageTypes" :fields="$options.projectTableFields">
<template #cell(storageType)="{ item }">
<div class="gl-display-flex gl-flex-direction-row">
- <storage-type-icon
- :name="item.storageType.id"
- :data-testid="`${item.storageType.id}-icon`"
- />
+ <storage-type-icon :name="item.id" :data-testid="`${item.id}-icon`" />
<div>
- <p class="gl-font-weight-bold gl-mb-0" :data-testid="`${item.storageType.id}-name`">
+ <p class="gl-font-weight-bold gl-mb-0" :data-testid="`${item.id}-name`">
<gl-link
- v-if="item.storageType.detailsPath && item.value"
- :data-testid="`${item.storageType.id}-details-link`"
- :href="item.storageType.detailsPath"
- >{{ item.storageType.name }}</gl-link
+ v-if="item.detailsPath && item.value"
+ :data-testid="`${item.id}-details-link`"
+ :href="item.detailsPath"
+ >{{ item.name }}</gl-link
>
<template v-else>
- {{ item.storageType.name }}
+ {{ item.name }}
</template>
<gl-link
- v-if="item.storageType.helpPath"
- :href="item.storageType.helpPath"
+ v-if="item.helpPath"
+ :href="item.helpPath"
target="_blank"
- :aria-label="helpLinkAriaLabel(item.storageType.name)"
- :data-testid="`${item.storageType.id}-help-link`"
+ :aria-label="helpLinkAriaLabel(item.name)"
+ :data-testid="`${item.id}-help-link`"
>
<gl-icon name="question-o" :size="12" />
</gl-link>
</p>
- <p class="gl-mb-0" :data-testid="`${item.storageType.id}-description`">
- {{ item.storageType.description }}
+ <p class="gl-mb-0" :data-testid="`${item.id}-description`">
+ {{ item.description }}
</p>
- <p v-if="item.storageType.warningMessage" class="gl-mb-0 gl-font-sm">
+ <p v-if="item.warningMessage" class="gl-mb-0 gl-font-sm">
<gl-icon name="warning" :size="12" />
- <gl-sprintf :message="item.storageType.warningMessage">
+ <gl-sprintf :message="item.warningMessage">
<template #warningLink="{ content }">
- <gl-link :href="item.storageType.warningLink" target="_blank" class="gl-font-sm">{{
+ <gl-link :href="item.warningLink" target="_blank" class="gl-font-sm">{{
content
}}</gl-link>
</template>
@@ -119,20 +92,23 @@ export default {
</template>
<template #cell(value)="{ item }">
- {{ numberToHumanSize(item.value, 1) }}
+ <span :data-testid="item.id + '-value'">
+ {{ numberToHumanSize(item.value, 1) }}
+ </span>
<template v-if="item.warning">
<gl-icon
- :id="item.warning.popoverId"
+ :id="item.id + '-warning-icon'"
name="warning"
class="gl-mt-2 gl-lg-mt-0 gl-lg-ml-2"
+ :data-testid="item.id + '-warning-icon'"
/>
<gl-popover
triggers="hover focus"
placement="top"
- :target="item.warning.popoverId"
+ :target="item.id + '-warning-icon'"
:content="item.warning.popoverContent"
- :data-testid="item.warning.popoverId"
+ :data-testid="item.id + '-popover'"
/>
</template>
</template>
diff --git a/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue
index c1e513d3a00..33f202e69db 100644
--- a/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue
+++ b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue
@@ -18,7 +18,6 @@ export default {
computed: {
storageTypes() {
const {
- containerRegistrySize,
buildArtifactsSize,
lfsObjectsSize,
packagesSize,
@@ -52,12 +51,6 @@ export default {
size: packagesSize,
},
{
- id: 'containerRegistry',
- style: this.usageStyle(this.barRatio(containerRegistrySize)),
- class: 'gl-bg-data-viz-aqua-800',
- size: containerRegistrySize,
- },
- {
id: 'buildArtifacts',
style: this.usageStyle(this.barRatio(buildArtifactsSize)),
class: 'gl-bg-data-viz-green-500',
diff --git a/app/assets/javascripts/usage_quotas/storage/constants.js b/app/assets/javascripts/usage_quotas/storage/constants.js
index 8926e8c1e86..3fdf61a5947 100644
--- a/app/assets/javascripts/usage_quotas/storage/constants.js
+++ b/app/assets/javascripts/usage_quotas/storage/constants.js
@@ -14,25 +14,34 @@ export const TOTAL_USAGE_DEFAULT_TEXT = __('Not applicable.');
export const HELP_LINK_ARIA_LABEL = s__('UsageQuota|%{linkTitle} help link');
export const RECALCULATE_REPOSITORY_LABEL = s__('UsageQuota|Recalculate repository usage');
-export const projectContainerRegistryPopoverContent = s__(
- 'UsageQuotas|The project-level storage statistics for the Container Registry are directional only and do not include savings for instance-wide deduplication.',
-);
-
export const containerRegistryId = 'containerRegistrySize';
export const containerRegistryPopoverId = 'container-registry-popover';
+export const containerRegistryPopover = {
+ content: s__(
+ 'UsageQuotas|Container Registry storage statistics are not used to calculate the total project storage. Total project storage is calculated after namespace container deduplication, where the total of all unique containers is added to the namespace storage total.',
+ ),
+ docsLink: helpPagePath(
+ 'user/packages/container_registry/reduce_container_registry_storage.html',
+ { anchor: 'check-container-registry-storage-use' },
+ ),
+};
+
export const PROJECT_TABLE_LABEL_STORAGE_TYPE = s__('UsageQuota|Storage type');
export const PROJECT_TABLE_LABEL_USAGE = s__('UsageQuota|Usage');
+export const usageQuotasHelpPaths = {
+ usageQuotas: helpPagePath('user/usage_quotas'),
+ usageQuotasProjectStorageLimit: helpPagePath('user/usage_quotas', {
+ anchor: 'project-storage-limit',
+ }),
+ usageQuotasNamespaceStorageLimit: helpPagePath('user/usage_quotas', {
+ anchor: 'namespace-storage-limit',
+ }),
+};
+
export const PROJECT_STORAGE_TYPES = [
{
- id: 'containerRegistry',
- name: __('Container Registry'),
- description: s__(
- 'UsageQuota|Gitlab-integrated Docker Container Registry for storing Docker Images.',
- ),
- },
- {
id: 'buildArtifacts',
name: __('Job artifacts'),
description: s__('UsageQuota|Job artifacts created by CI/CD.'),
@@ -64,11 +73,20 @@ export const PROJECT_STORAGE_TYPES = [
},
];
-export const projectHelpPaths = {
- usageQuotas: helpPagePath('user/usage_quotas'),
- usageQuotasNamespaceStorageLimit: helpPagePath('user/usage_quotas', {
- anchor: 'namespace-storage-limit',
- }),
+export const NAMESPACE_STORAGE_TYPES = [
+ {
+ id: 'containerRegistry',
+ name: __('Container Registry'),
+ description: s__(
+ `UsageQuota|Gitlab-integrated Docker Container Registry for storing Docker Images.`,
+ ),
+ warning: {
+ popoverContent: containerRegistryPopover.content,
+ },
+ },
+];
+
+export const storageTypeHelpPaths = {
lfsObjects: helpPagePath('/user/project/repository/reducing_the_repo_size_using_git', {
anchor: 'repository-cleanup',
}),
diff --git a/app/assets/javascripts/usage_quotas/storage/init_project_storage.js b/app/assets/javascripts/usage_quotas/storage/init_project_storage.js
index 00cb274902d..e7378fcde0e 100644
--- a/app/assets/javascripts/usage_quotas/storage/init_project_storage.js
+++ b/app/assets/javascripts/usage_quotas/storage/init_project_storage.js
@@ -1,7 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import { projectHelpPaths as helpLinks } from './constants';
import ProjectStorageApp from './components/project_storage_app.vue';
Vue.use(VueApollo);
@@ -25,7 +24,6 @@ export default (containerId = 'js-project-storage-count-app') => {
name: 'ProjectStorageApp',
provide: {
projectPath,
- helpLinks,
},
render(createElement) {
return createElement(ProjectStorageApp);
diff --git a/app/assets/javascripts/usage_quotas/storage/utils.js b/app/assets/javascripts/usage_quotas/storage/utils.js
index 0460cd0a9b2..445c3efc9e6 100644
--- a/app/assets/javascripts/usage_quotas/storage/utils.js
+++ b/app/assets/javascripts/usage_quotas/storage/utils.js
@@ -1,54 +1,32 @@
-import { numberToHumanSize } from '~/lib/utils/number_utils';
-import { PROJECT_STORAGE_TYPES } from './constants';
-
+/**
+ * Populates an array of storage types with usage value and other details
+ *
+ * @param {Array} selectedStorageTypes selected storage types that will be populated
+ * @param {Object} projectStatistics object of storage values, with storage type as keys
+ * @param {Object} statisticsDetailsPaths object of storage detail paths, with storage type as keys
+ * @param {Object} helpLinks object of help paths, with storage type as keys
+ * @returns {Array}
+ */
export const getStorageTypesFromProjectStatistics = (
+ selectedStorageTypes,
projectStatistics,
- helpLinks = {},
statisticsDetailsPaths = {},
+ helpLinks = {},
) =>
- PROJECT_STORAGE_TYPES.reduce((types, currentType) => {
+ selectedStorageTypes.reduce((types, currentType) => {
const helpPath = helpLinks[currentType.id];
const value = projectStatistics[`${currentType.id}Size`];
const detailsPath = statisticsDetailsPaths[currentType.id];
return types.concat({
- storageType: {
- ...currentType,
- helpPath,
- detailsPath,
- },
+ ...currentType,
+ helpPath,
+ detailsPath,
value,
});
}, []);
/**
- * This method parses the results from `getProjectStorageStatistics` call.
- *
- * @param {Object} data graphql result
- * @returns {Object}
- */
-export const parseGetProjectStorageResults = (data, helpLinks) => {
- const projectStatistics = data?.project?.statistics;
- if (!projectStatistics) {
- return {};
- }
- const { storageSize } = projectStatistics;
- const storageTypes = getStorageTypesFromProjectStatistics(
- projectStatistics,
- helpLinks,
- data?.project?.statisticsDetailsPaths,
- );
-
- return {
- storage: {
- totalUsage: numberToHumanSize(storageSize, 1),
- storageTypes,
- },
- statistics: projectStatistics,
- };
-};
-
-/**
* Creates a sorting function to sort storage types by usage in the graph and in the table
*
* @param {string} storageUsageKey key storing value of storage usage
diff --git a/app/assets/javascripts/user_lists/components/add_user_modal.vue b/app/assets/javascripts/user_lists/components/add_user_modal.vue
index 37c9548ad64..ec6c7cf6c8d 100644
--- a/app/assets/javascripts/user_lists/components/add_user_modal.vue
+++ b/app/assets/javascripts/user_lists/components/add_user_modal.vue
@@ -55,7 +55,6 @@ export default {
<gl-modal
v-bind="$options.modalOptions"
:visible="visible"
- data-testid="add-users-modal"
@primary="submitUsers"
@canceled="clearInput"
>
diff --git a/app/assets/javascripts/user_lists/components/edit_user_list.vue b/app/assets/javascripts/user_lists/components/edit_user_list.vue
index 18f411f6cf2..e357874da7a 100644
--- a/app/assets/javascripts/user_lists/components/edit_user_list.vue
+++ b/app/assets/javascripts/user_lists/components/edit_user_list.vue
@@ -1,5 +1,6 @@
<script>
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import { s__, sprintf } from '~/locale';
import statuses from '../constants/edit';
diff --git a/app/assets/javascripts/user_lists/components/new_user_list.vue b/app/assets/javascripts/user_lists/components/new_user_list.vue
index 17ef4c037d2..71f93bb177e 100644
--- a/app/assets/javascripts/user_lists/components/new_user_list.vue
+++ b/app/assets/javascripts/user_lists/components/new_user_list.vue
@@ -1,5 +1,6 @@
<script>
import { GlAlert } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import { s__ } from '~/locale';
import UserListForm from './user_list_form.vue';
diff --git a/app/assets/javascripts/user_lists/components/user_list.vue b/app/assets/javascripts/user_lists/components/user_list.vue
index e86b3f81daa..29b9b68883b 100644
--- a/app/assets/javascripts/user_lists/components/user_list.vue
+++ b/app/assets/javascripts/user_lists/components/user_list.vue
@@ -6,6 +6,7 @@ import {
GlLoadingIcon,
GlModalDirective as GlModal,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import { s__, __ } from '~/locale';
import { states, ADD_USER_MODAL_ID } from '../constants/show';
@@ -137,6 +138,7 @@ export default {
:title="$options.translations.emptyStateTitle"
:description="$options.translations.emptyStateDescription"
:svg-path="emptyStatePath"
+ :svg-height="150"
/>
</div>
</div>
diff --git a/app/assets/javascripts/user_lists/components/user_lists.vue b/app/assets/javascripts/user_lists/components/user_lists.vue
index 0e3c6b396db..e50f4d81c1e 100644
--- a/app/assets/javascripts/user_lists/components/user_lists.vue
+++ b/app/assets/javascripts/user_lists/components/user_lists.vue
@@ -1,6 +1,7 @@
<script>
import { GlBadge, GlButton } from '@gitlab/ui';
import { isEmpty } from 'lodash';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
import EmptyState from '~/feature_flags/components/empty_state.vue';
import { buildUrlWithCurrentLocation, historyPushState } from '~/lib/utils/common_utils';
diff --git a/app/assets/javascripts/user_lists/store/edit/index.js b/app/assets/javascripts/user_lists/store/edit/index.js
index 9b9df59ed32..c0f00428b6c 100644
--- a/app/assets/javascripts/user_lists/store/edit/index.js
+++ b/app/assets/javascripts/user_lists/store/edit/index.js
@@ -1,3 +1,4 @@
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import mutations from './mutations';
diff --git a/app/assets/javascripts/user_lists/store/index/index.js b/app/assets/javascripts/user_lists/store/index/index.js
index 9b9df59ed32..c0f00428b6c 100644
--- a/app/assets/javascripts/user_lists/store/index/index.js
+++ b/app/assets/javascripts/user_lists/store/index/index.js
@@ -1,3 +1,4 @@
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import mutations from './mutations';
diff --git a/app/assets/javascripts/user_lists/store/new/index.js b/app/assets/javascripts/user_lists/store/new/index.js
index 9b9df59ed32..c0f00428b6c 100644
--- a/app/assets/javascripts/user_lists/store/new/index.js
+++ b/app/assets/javascripts/user_lists/store/new/index.js
@@ -1,3 +1,4 @@
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import mutations from './mutations';
diff --git a/app/assets/javascripts/user_lists/store/show/index.js b/app/assets/javascripts/user_lists/store/show/index.js
index 9b9df59ed32..c0f00428b6c 100644
--- a/app/assets/javascripts/user_lists/store/show/index.js
+++ b/app/assets/javascripts/user_lists/store/show/index.js
@@ -1,3 +1,4 @@
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import mutations from './mutations';
diff --git a/app/assets/javascripts/users/profile/actions/components/user_actions_app.vue b/app/assets/javascripts/users/profile/actions/components/user_actions_app.vue
index bf983d911ea..5dfa9c67852 100644
--- a/app/assets/javascripts/users/profile/actions/components/user_actions_app.vue
+++ b/app/assets/javascripts/users/profile/actions/components/user_actions_app.vue
@@ -1,23 +1,37 @@
<script>
import { GlDisclosureDropdown } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
+import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
export default {
components: {
GlDisclosureDropdown,
+ AbuseCategorySelector,
},
props: {
userId: {
type: String,
required: true,
},
+ rssSubscriptionPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ reportedUserId: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ reportedFromUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
- // Only implement the copy function in MR for now
- // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/122971
- // The rest will be implemented in the upcoming MR.
- dropdownItems: [
+ defaultDropdownItems: [
{
action: this.onUserIdCopy,
text: sprintf(this.$options.i18n.userId, { id: this.userId }),
@@ -26,20 +40,56 @@ export default {
},
},
],
+ open: false,
};
},
+ computed: {
+ dropdownItems() {
+ const dropdownItems = this.defaultDropdownItems.slice();
+ if (this.rssSubscriptionPath) {
+ dropdownItems.push({
+ href: this.rssSubscriptionPath,
+ text: this.$options.i18n.rssSubscribe,
+ extraAttrs: {
+ 'data-testid': 'user-profile-rss-subscription-link',
+ },
+ });
+ }
+ if (this.reportedUserId) {
+ dropdownItems.push({
+ action: () => this.toggleDrawer(true),
+ text: this.$options.i18n.reportToAdmin,
+ });
+ }
+ return dropdownItems;
+ },
+ },
methods: {
onUserIdCopy() {
this.$toast.show(this.$options.i18n.userIdCopied);
},
+ toggleDrawer(open) {
+ this.open = open;
+ },
},
i18n: {
userId: s__('UserProfile|Copy user ID: %{id}'),
userIdCopied: s__('UserProfile|User ID copied to clipboard'),
+ rssSubscribe: s__('UserProfile|Subscribe'),
+ reportToAdmin: s__('ReportAbuse|Report abuse to administrator'),
},
};
</script>
<template>
- <gl-disclosure-dropdown icon="ellipsis_v" category="tertiary" no-caret :items="dropdownItems" />
+ <span>
+ <gl-disclosure-dropdown icon="ellipsis_v" category="tertiary" no-caret :items="dropdownItems" />
+ <abuse-category-selector
+ v-if="reportedUserId"
+ :reported-user-id="reportedUserId"
+ :reported-from-url="reportedFromUrl"
+ :show-drawer="open"
+ @close-drawer="toggleDrawer(false)"
+ />
+ </span>
</template>
diff --git a/app/assets/javascripts/users/profile/actions/index.js b/app/assets/javascripts/users/profile/actions/index.js
index 37a3faf82a5..e1f9352966b 100644
--- a/app/assets/javascripts/users/profile/actions/index.js
+++ b/app/assets/javascripts/users/profile/actions/index.js
@@ -7,17 +7,29 @@ export const initUserActionsApp = () => {
if (!mountingEl) return false;
- const { userId } = mountingEl.dataset;
+ const {
+ userId,
+ rssSubscriptionPath,
+ reportAbusePath,
+ reportedUserId,
+ reportedFromUrl,
+ } = mountingEl.dataset;
Vue.use(GlToast);
return new Vue({
el: mountingEl,
name: 'UserActionsRoot',
+ provide: {
+ reportAbusePath,
+ },
render(createElement) {
return createElement(UserActionsApp, {
props: {
userId,
+ rssSubscriptionPath,
+ reportedUserId: reportedUserId ? parseInt(reportedUserId, 10) : null,
+ reportedFromUrl,
},
});
},
diff --git a/app/assets/javascripts/users/profile/index.js b/app/assets/javascripts/users/profile/index.js
index c6b85489785..3ae3cc2de98 100644
--- a/app/assets/javascripts/users/profile/index.js
+++ b/app/assets/javascripts/users/profile/index.js
@@ -13,7 +13,7 @@ export const initReportAbuse = () => {
name: 'ReportAbuseButtonRoot',
provide: {
reportAbusePath,
- reportedUserId: parseInt(reportedUserId, 10),
+ reportedUserId: reportedUserId ? parseInt(reportedUserId, 10) : null,
reportedFromUrl,
},
render(createElement) {
diff --git a/app/assets/javascripts/users_select/constants.js b/app/assets/javascripts/users_select/constants.js
index c100c1f4ca5..c2ca2ca3782 100644
--- a/app/assets/javascripts/users_select/constants.js
+++ b/app/assets/javascripts/users_select/constants.js
@@ -1,12 +1,10 @@
export const AJAX_USERS_SELECT_PARAMS_MAP = {
project_id: 'projectId',
group_id: 'groupId',
- skip_ldap: 'skipLdap',
todo_filter: 'todoFilter',
todo_state_filter: 'todoStateFilter',
current_user: 'showCurrentUser',
author_id: 'authorId',
- skip_users: 'skipUsers',
states: 'states',
};
diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js
index 66e54b59187..ab707e7e69c 100644
--- a/app/assets/javascripts/users_select/index.js
+++ b/app/assets/javascripts/users_select/index.js
@@ -4,7 +4,7 @@
import $ from 'jquery';
import { escape, template, uniqBy } from 'lodash';
-import { AJAX_USERS_SELECT_PARAMS_MAP } from 'ee_else_ce/users_select/constants';
+import { AJAX_USERS_SELECT_PARAMS_MAP } from '~/users_select/constants';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { TYPE_MERGE_REQUEST } from '~/issues/constants';
import { isUserBusy } from '~/set_status_modal/utils';
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list_app.vue b/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list_app.vue
index ebf42fa0be0..c7cfbece611 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list_app.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list_app.vue
@@ -1,4 +1,5 @@
<script>
+// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState, mapGetters } from 'vuex';
import createStore from '../stores/artifacts_list';
import ArtifactsList from './artifacts_list.vue';
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue
index 41edbc83cdb..8290e7e9232 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { MANUAL_DEPLOY, WILL_DEPLOY, CREATED } from './constants';
import DeploymentActions from './deployment_actions.vue';
@@ -36,7 +37,7 @@ export default {
</script>
<template>
- <div class="deploy-heading">
+ <div class="deploy-heading gl-px-5">
<div class="ci-widget media">
<div class="media-body">
<div class="deploy-body">
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue
index 501f5f1523f..98d334cbba1 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue
@@ -49,11 +49,7 @@ export default {
};
</script>
<template>
- <mr-collapsible-extension
- v-if="showCollapsedDeployments"
- :title="__('View all environments.')"
- data-testid="mr-collapsed-deployments"
- >
+ <mr-collapsible-extension v-if="showCollapsedDeployments" :title="__('View all environments.')">
<template #header>
<div class="gl-mr-3 gl-line-height-normal">
<gl-sprintf :message="multipleDeploymentsTitle">
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 f7c0f960c0e..31bf62b7e52 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
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton, GlLoadingIcon, GlTooltipDirective, GlIntersectionObserver } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/loading.vue b/app/assets/javascripts/vue_merge_request_widget/components/loading.vue
index cd4e31e0dae..9939152074b 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/loading.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/loading.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
index e435dc56503..b7017cebda3 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
@@ -48,7 +48,7 @@ export default {
<div class="d-flex align-items-center pl-3 gl-py-3">
<div v-if="hasError" class="ci-widget media">
<div class="media-body">
- <span class="gl-font-sm mr-widget-margin-left gl-line-height-24 js-error-state">
+ <span class="gl-font-sm gl-ml-7 gl-line-height-24 js-error-state">
{{ title }}
</span>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
index 26527361b2e..d82cb57e78c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
@@ -90,11 +90,18 @@ export default {
: `git fetch origin\ngit checkout -b ${this.escapedSourceBranch} ${escapedOriginBranch}`; // eslint-disable-line @gitlab/require-i18n-strings
},
mergeInfo2() {
- return `git push origin ${this.escapedSourceBranch}`; // eslint-disable-line @gitlab/require-i18n-strings
+ return this.isFork
+ ? `git push "${this.sourceProjectDefaultUrl}" ${this.escapedForkPushBranch}` // eslint-disable-line @gitlab/require-i18n-strings
+ : `git push origin ${this.escapedSourceBranch}`; // eslint-disable-line @gitlab/require-i18n-strings
},
escapedForkBranch() {
return escapeShellString(`${this.sourceProjectPath}-${this.sourceBranch}`);
},
+ escapedForkPushBranch() {
+ return escapeShellString(
+ `${this.sourceProjectPath}-${this.sourceBranch}:${this.sourceBranch}`,
+ );
+ },
escapedSourceBranch() {
return escapeShellString(this.sourceBranch);
},
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 4e16b92fc05..e94e0fbe6dc 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
@@ -13,7 +13,7 @@ import { s__, n__ } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils';
import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue';
-import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
+import LegacyPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/legacy_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';
@@ -27,8 +27,8 @@ export default {
GlIcon,
GlSprintf,
GlTooltip,
+ LegacyPipelineMiniGraph,
PipelineArtifacts,
- PipelineMiniGraph,
TimeAgoTooltip,
TooltipOnTruncate,
},
@@ -194,7 +194,7 @@ export default {
</p>
</template>
<template v-else-if="hasPipeline">
- <a :href="status.details_path" class="gl-align-self-center gl-mr-3">
+ <a :href="status.details_path" class="gl-align-self-start gl-mt-2 gl-mr-3">
<ci-icon :status="status" :size="24" class="gl-display-flex" />
</a>
<div class="ci-widget-container d-flex">
@@ -203,16 +203,41 @@ export default {
<div
data-testid="pipeline-info-container"
data-qa-selector="merge_request_pipeline_info_content"
+ class="gl-display-flex gl-flex-wrap gl-align-items-center gl-justify-content-space-between"
>
- {{ pipeline.details.event_type_name }}
- <gl-link
- :href="pipeline.path"
- class="pipeline-id"
- data-testid="pipeline-id"
- data-qa-selector="pipeline_link"
- >#{{ pipeline.id }}</gl-link
+ <p
+ class="mr-pipeline-title gl-m-0! gl-mr-3! gl-font-weight-bold gl-line-height-32 gl-text-gray-900"
>
- {{ pipeline.details.status.label }}
+ {{ pipeline.details.event_type_name }}
+ <gl-link
+ :href="pipeline.path"
+ class="pipeline-id"
+ data-testid="pipeline-id"
+ data-qa-selector="pipeline_link"
+ >#{{ pipeline.id }}</gl-link
+ >
+ {{ pipeline.details.status.label }}
+ </p>
+ <div
+ class="gl-align-items-center gl-display-inline-flex gl-flex-grow-1 gl-justify-content-space-between"
+ >
+ <legacy-pipeline-mini-graph
+ v-if="pipeline.details.stages"
+ :downstream-pipelines="downstreamPipelines"
+ :is-merge-train="isMergeTrain"
+ :pipeline-path="pipeline.path"
+ :stages="pipeline.details.stages"
+ :upstream-pipeline="pipeline.triggered_by"
+ />
+ <pipeline-artifacts
+ :pipeline-id="pipeline.id"
+ :artifacts="artifacts"
+ class="gl-ml-3"
+ />
+ </div>
+ </div>
+ <p data-testid="pipeline-details-container" class="gl-font-sm gl-text-gray-500 gl-m-0">
+ {{ pipeline.details.event_type_name }} {{ pipeline.details.status.label }}
<template v-if="hasCommitInfo">
{{ s__('Pipeline|for') }}
<gl-link
@@ -228,7 +253,7 @@ export default {
v-safe-html="sourceBranchLink"
:title="sourceBranch"
truncate-target="child"
- class="label-branch label-truncate gl-font-weight-normal"
+ class="label-branch label-truncate gl-font-weight-normal gl-vertical-align-text-bottom"
/>
</template>
<template v-if="finishedAt">
@@ -238,8 +263,8 @@ export default {
data-testid="finished-at"
/>
</template>
- </div>
- <div v-if="pipeline.coverage" class="coverage" data-testid="pipeline-coverage">
+ </p>
+ <div v-if="pipeline.coverage" class="coverage gl-mt-1" data-testid="pipeline-coverage">
{{ s__('Pipeline|Test coverage') }} {{ pipeline.coverage }}%
<span
v-if="pipelineCoverageDelta"
@@ -275,19 +300,6 @@ export default {
</div>
</div>
</div>
- <div>
- <span class="gl-align-items-center gl-display-inline-flex">
- <pipeline-mini-graph
- v-if="pipeline.details.stages"
- :downstream-pipelines="downstreamPipelines"
- :is-merge-train="isMergeTrain"
- :pipeline-path="pipeline.path"
- :stages="pipeline.details.stages"
- :upstream-pipeline="pipeline.triggered_by"
- />
- <pipeline-artifacts :pipeline-id="pipeline.id" :artifacts="artifacts" class="gl-ml-3" />
- </span>
- </div>
</div>
</template>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/action_buttons.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/action_buttons.vue
index c38c253564a..9dd4e76befe 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/widget/action_buttons.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/action_buttons.vue
@@ -19,8 +19,7 @@ export default {
},
tertiaryButtons: {
type: Array,
- required: false,
- default: () => [],
+ required: true,
},
},
data() {
@@ -76,7 +75,6 @@ export default {
<template>
<div class="gl-display-flex gl-align-items-flex-start">
<gl-dropdown
- v-if="tertiaryButtons.length"
v-gl-tooltip
:title="__('Options')"
:text="dropdownLabel"
@@ -102,33 +100,31 @@ export default {
{{ btn.text }}
</gl-dropdown-item>
</gl-dropdown>
- <template v-if="tertiaryButtons.length">
- <gl-button
- v-for="(btn, index) in tertiaryButtons"
- :id="btn.id"
- :key="index"
- v-gl-tooltip.hover
- :title="setTooltip(btn)"
- :href="btn.href"
- :target="btn.target"
- :class="[{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }, btn.class]"
- :data-clipboard-text="btn.dataClipboardText"
- :data-qa-selector="actionButtonQaSelector(btn)"
- :data-method="btn.dataMethod"
- :icon="btn.icon"
- :data-testid="btn.testId || 'extension-actions-button'"
- :variant="btn.variant || 'confirm'"
- :loading="btn.loading"
- :disabled="btn.loading"
- category="tertiary"
- size="small"
- class="gl-display-none gl-md-display-block gl-float-left"
- @click="onClickAction(btn)"
- >
- <template v-if="btn.text">
- {{ btn.text }}
- </template>
- </gl-button>
- </template>
+ <gl-button
+ v-for="(btn, index) in tertiaryButtons"
+ :id="btn.id"
+ :key="index"
+ v-gl-tooltip.hover
+ :title="setTooltip(btn)"
+ :href="btn.href"
+ :target="btn.target"
+ :class="[{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }, btn.class]"
+ :data-clipboard-text="btn.dataClipboardText"
+ :data-qa-selector="actionButtonQaSelector(btn)"
+ :data-method="btn.dataMethod"
+ :icon="btn.icon"
+ :data-testid="btn.testId || 'extension-actions-button'"
+ :variant="btn.variant || 'confirm'"
+ :loading="btn.loading"
+ :disabled="btn.loading"
+ category="tertiary"
+ size="small"
+ class="gl-display-none gl-md-display-block gl-float-left"
+ @click="onClickAction(btn)"
+ >
+ <template v-if="btn.text">
+ {{ btn.text }}
+ </template>
+ </gl-button>
</div>
</template>
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 258fa4edcda..9bb39ba22e0 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
@@ -5,8 +5,9 @@ export default {
import(
'~/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue'
),
-
MrTerraformWidget: () => import('~/vue_merge_request_widget/extensions/terraform/index.vue'),
+ MrCodeQualityWidget: () =>
+ import('~/vue_merge_request_widget/extensions/code_quality/index.vue'),
},
props: {
@@ -21,8 +22,14 @@ export default {
return this.mr.terraformReportsPath && 'MrTerraformWidget';
},
+ codeQualityWidget() {
+ return this.mr.codequalityReportsPath ? 'MrCodeQualityWidget' : undefined;
+ },
+
widgets() {
- return [this.terraformPlansWidget, 'MrSecurityWidget'].filter((w) => w);
+ return [this.codeQualityWidget, this.terraformPlansWidget, 'MrSecurityWidget'].filter(
+ (w) => w,
+ );
},
},
};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue
index ec979861283..618d1e71f81 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue
@@ -52,6 +52,9 @@ export default {
shouldShowThirdLevel() {
return this.data.children?.length > 0 && this.level === 2;
},
+ hasActionButtons() {
+ return this.data.actions?.length > 0;
+ },
},
methods: {
onClickedAction(action) {
@@ -73,15 +76,22 @@ export default {
<template #body>
<div class="gl-w-full gl-display-flex" :class="{ 'gl-flex-direction-column': level === 1 }">
<div class="gl-display-flex gl-flex-grow-1">
- <div class="gl-display-flex gl-flex-grow-1 gl-flex-direction-column">
- <p v-safe-html="generatedText" class="gl-mb-0 gl-mr-1"></p>
- <gl-link v-if="data.link" :href="data.link.href">{{ data.link.text }}</gl-link>
- <p v-if="data.supportingText" v-safe-html="generatedSupportingText" class="gl-mb-0"></p>
+ <div class="gl-display-flex gl-flex-grow-1 gl-align-items-baseline">
+ <div>
+ <p v-safe-html="generatedText" class="gl-mb-0 gl-mr-1"></p>
+ <gl-link v-if="data.link" :href="data.link.href">{{ data.link.text }}</gl-link>
+ <p
+ v-if="data.supportingText"
+ v-safe-html="generatedSupportingText"
+ class="gl-mb-0"
+ ></p>
+ </div>
<gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'">
{{ data.badge.text }}
</gl-badge>
</div>
<actions
+ v-if="hasActionButtons"
:widget="widgetName"
:tertiary-buttons="data.actions"
class="gl-ml-auto gl-pl-3"
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 e327d848d8f..2c8bf90064e 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,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton, GlLink, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
@@ -16,14 +17,19 @@ import DynamicContent from './dynamic_content.vue';
import StatusIcon from './status_icon.vue';
import ActionButtons from './action_buttons.vue';
-const FETCH_TYPE_COLLAPSED = 'collapsed';
-const FETCH_TYPE_EXPANDED = 'expanded';
const WIDGET_PREFIX = 'Widget';
const MISSING_RESPONSE_HEADERS =
'MR Widget: raesponse object should contain status and headers object. Make sure to include that in your `fetchCollapsedData` and `fetchExpandedData` functions.';
+const LOADING_STATE_COLLAPSED = 'collapsed';
+const LOADING_STATE_EXPANDED = 'expanded';
+const LOADING_STATE_STATUS_ICON = 'status_icon';
+
export default {
MISSING_RESPONSE_HEADERS,
+ LOADING_STATE_COLLAPSED,
+ LOADING_STATE_EXPANDED,
+ LOADING_STATE_STATUS_ICON,
components: {
ActionButtons,
@@ -42,20 +48,29 @@ export default {
SafeHtml,
},
props: {
- /**
- * @param {value.collapsed} Object
- * @param {value.expanded} Object
- */
- value: {
- type: Object,
- required: false,
- default: () => ({}),
- },
loadingText: {
type: String,
required: false,
default: __('Loading'),
},
+ // Use this property when you need to control the loading state from the
+ // parent component.
+ loadingState: {
+ type: String,
+ required: false,
+ default: undefined,
+ validator: (s) => {
+ if (!s) {
+ return true;
+ }
+
+ return [
+ LOADING_STATE_EXPANDED,
+ LOADING_STATE_COLLAPSED,
+ LOADING_STATE_STATUS_ICON,
+ ].includes(s);
+ },
+ },
errorText: {
type: String,
required: false,
@@ -158,7 +173,7 @@ export default {
return {
isExpandedForTheFirstTime: true,
isCollapsed: true,
- isLoading: true,
+ isLoadingCollapsedContent: true,
isLoadingExpandedContent: false,
summaryError: null,
contentError: null,
@@ -166,6 +181,12 @@ export default {
};
},
computed: {
+ isSummaryLoading() {
+ return this.isLoadingCollapsedContent || this.loadingState === LOADING_STATE_COLLAPSED;
+ },
+ shouldShowLoadingIcon() {
+ return this.isSummaryLoading || this.loadingState === LOADING_STATE_STATUS_ICON;
+ },
generatedSummary() {
return generateText(this.summary?.title || '');
},
@@ -192,7 +213,7 @@ export default {
},
immediate: true,
},
- isLoading(newValue) {
+ isLoadingCollapsedContent(newValue) {
this.$emit('is-loading', newValue);
},
},
@@ -202,18 +223,18 @@ export default {
}
},
async mounted() {
- this.isLoading = true;
+ this.isLoadingCollapsedContent = true;
this.telemetryHub?.viewed();
try {
if (this.fetchCollapsedData) {
- await this.fetch(this.fetchCollapsedData, FETCH_TYPE_COLLAPSED);
+ await this.fetch(this.fetchCollapsedData);
}
} catch {
this.summaryError = this.errorText;
}
- this.isLoading = false;
+ this.isLoadingCollapsedContent = false;
},
methods: {
onActionClick(action) {
@@ -240,7 +261,7 @@ export default {
this.contentError = null;
try {
- await this.fetch(this.fetchExpandedData, FETCH_TYPE_EXPANDED);
+ await this.fetch(this.fetchExpandedData);
} catch {
this.contentError = this.errorText;
@@ -251,7 +272,7 @@ export default {
this.isLoadingExpandedContent = false;
},
- fetch(handler, dataType) {
+ fetch(handler) {
const requests = this.multiPolling ? handler() : [handler];
const promises = requests.map((request) => {
@@ -288,9 +309,7 @@ export default {
});
});
- return Promise.all(promises).then((data) => {
- this.$emit('input', { ...this.value, [dataType]: this.multiPolling ? data : data[0] });
- });
+ return Promise.all(promises);
},
},
failedStatusIcon: EXTENSION_ICONS.failed,
@@ -306,7 +325,7 @@ export default {
<status-icon
:level="1"
:name="widgetName"
- :is-loading="isLoading"
+ :is-loading="shouldShowLoadingIcon"
:icon-name="summaryStatusIcon"
/>
<div
@@ -316,9 +335,9 @@ export default {
<div class="gl-flex-grow-1" data-testid="widget-extension-top-level-summary">
<span v-if="summaryError">{{ summaryError }}</span>
<slot v-else name="summary"
- ><div v-safe-html="isLoading ? loadingText : generatedSummary"></div>
+ ><div v-safe-html="isSummaryLoading ? loadingText : generatedSummary"></div>
<div
- v-if="!isLoading && generatedSubSummary"
+ v-if="!isSummaryLoading && generatedSubSummary"
v-safe-html="generatedSubSummary"
class="gl-font-sm gl-text-gray-700"
></div
@@ -356,7 +375,7 @@ export default {
</slot>
</div>
<div
- v-if="isCollapsible && !isLoading"
+ v-if="isCollapsible && !isSummaryLoading"
class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6"
>
<gl-button
diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js
index a59f48fb8b2..1a469f9b7bb 100644
--- a/app/assets/javascripts/vue_merge_request_widget/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/constants.js
@@ -13,12 +13,18 @@ export const WARNING = 'warning';
export const INFO = 'info';
export const MWPS_MERGE_STRATEGY = 'merge_when_pipeline_succeeds';
+export const MWCP_MERGE_STRATEGY = 'merge_when_checks_pass';
export const MTWPS_MERGE_STRATEGY = 'add_to_merge_train_when_pipeline_succeeds';
export const MT_MERGE_STRATEGY = 'merge_train';
export const PIPELINE_FAILED_STATE = 'failed';
-export const AUTO_MERGE_STRATEGIES = [MWPS_MERGE_STRATEGY, MTWPS_MERGE_STRATEGY, MT_MERGE_STRATEGY];
+export const AUTO_MERGE_STRATEGIES = [
+ MWPS_MERGE_STRATEGY,
+ MTWPS_MERGE_STRATEGY,
+ MT_MERGE_STRATEGY,
+ MWCP_MERGE_STRATEGY,
+];
// SP - "Suggest Pipelines"
export const SP_TRACK_LABEL = 'no_pipeline_noticed';
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.vue b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.vue
new file mode 100644
index 00000000000..d30acf24684
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.vue
@@ -0,0 +1,157 @@
+<script>
+import * as Sentry from '@sentry/browser';
+import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
+import { SEVERITY_ICONS_MR_WIDGET } from '~/ci/reports/codequality_report/constants';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+import axios from '~/lib/utils/axios_utils';
+import MrWidget from '~/vue_merge_request_widget/components/widget/widget.vue';
+import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants';
+import { i18n, codeQualityPrefixes } from './constants';
+
+const translations = i18n;
+
+export default {
+ name: 'WidgetCodeQuality',
+ components: {
+ MrWidget,
+ },
+ i18n: translations,
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ pollingFinished: false,
+ hasError: false,
+ collapsedData: {},
+ poll: null,
+ };
+ },
+ computed: {
+ summary() {
+ const { new_errors, resolved_errors } = this.collapsedData;
+
+ if (!this.pollingFinished) {
+ return { title: i18n.loading };
+ } else if (this.hasError) {
+ return { title: i18n.error };
+ } else if (
+ this.collapsedData?.new_errors?.length >= 1 &&
+ this.collapsedData?.resolved_errors?.length >= 1
+ ) {
+ return {
+ title: i18n.improvementAndDegradationCopy(
+ i18n.findings(resolved_errors, codeQualityPrefixes.fixed),
+ i18n.findings(new_errors, codeQualityPrefixes.new),
+ ),
+ };
+ } else if (this.collapsedData?.resolved_errors?.length >= 1) {
+ return {
+ title: i18n.singularCopy(i18n.findings(resolved_errors, codeQualityPrefixes.fixed)),
+ };
+ } else if (this.collapsedData?.new_errors?.length >= 1) {
+ return { title: i18n.singularCopy(i18n.findings(new_errors, codeQualityPrefixes.new)) };
+ }
+ return { title: i18n.noChanges };
+ },
+ expandedData() {
+ const fullData = [];
+ this.collapsedData?.new_errors?.forEach((e) => {
+ fullData.push({
+ text: e.check_name
+ ? `${capitalizeFirstCharacter(e.severity)} - ${e.check_name} - ${e.description}`
+ : `${capitalizeFirstCharacter(e.severity)} - ${e.description}`,
+ link: {
+ href: e.web_url,
+ text: `${i18n.prependText} ${e.file_path}:${e.line}`,
+ },
+ icon: {
+ name: SEVERITY_ICONS_MR_WIDGET[e.severity],
+ },
+ });
+ });
+
+ this.collapsedData?.resolved_errors?.forEach((e) => {
+ fullData.push({
+ text: e.check_name
+ ? `${capitalizeFirstCharacter(e.severity)} - ${e.check_name} - ${e.description}`
+ : `${capitalizeFirstCharacter(e.severity)} - ${e.description}`,
+ supportingText: `${i18n.prependText} ${e.file_path}:${e.line}`,
+ icon: {
+ name: SEVERITY_ICONS_MR_WIDGET[e.severity],
+ },
+ badge: {
+ variant: 'neutral',
+ text: i18n.fixed,
+ },
+ });
+ });
+
+ return fullData;
+ },
+ statusIcon() {
+ if (this.collapsedData?.new_errors?.length >= 1) {
+ return EXTENSION_ICONS.warning;
+ } else if (this.collapsedData?.resolved_errors?.length >= 1) {
+ return EXTENSION_ICONS.success;
+ }
+ return EXTENSION_ICONS.neutral;
+ },
+ shouldCollapse() {
+ const { new_errors: newErrors, resolved_errors: resolvedErrors } = this.collapsedData;
+
+ if ((newErrors?.length === 0 && resolvedErrors?.length === 0) || this.hasError) {
+ return false;
+ }
+ return true;
+ },
+ apiCodeQualityPath() {
+ return this.mr.codequalityReportsPath;
+ },
+ },
+ methods: {
+ setCollapsedError(err) {
+ this.hasError = true;
+
+ Sentry.captureException(err);
+ },
+ fetchCodeQuality() {
+ return axios
+ .get(this.apiCodeQualityPath)
+ .then(({ data, headers = {}, status }) => {
+ if (status === HTTP_STATUS_OK) {
+ this.pollingFinished = true;
+ }
+ if (data) {
+ this.collapsedData = data;
+ }
+ return {
+ headers,
+ status,
+ data,
+ };
+ })
+ .catch((e) => {
+ return this.setCollapsedError(e);
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <mr-widget
+ :fetch-collapsed-data="fetchCodeQuality"
+ :error-text="$options.i18n.error"
+ :has-error="hasError"
+ :content="expandedData"
+ :loading-text="$options.i18n.loading"
+ :summary="summary"
+ :widget-name="$options.name"
+ :status-icon-name="statusIcon"
+ :is-collapsible="shouldCollapse"
+ />
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js
index a2f088a7a58..e8b97098a2b 100644
--- a/app/assets/javascripts/vue_merge_request_widget/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/index.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue';
+import MrWidgetOptions from 'any_else_ce/vue_merge_request_widget/mr_widget_options.vue';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import Translate from '../vue_shared/translate';
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 52a2f42f8ec..acdcbf7afd7 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
@@ -56,7 +56,6 @@ import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variab
import getStateQuery from './queries/get_state.query.graphql';
import getStateSubscription from './queries/get_state.subscription.graphql';
import accessibilityExtension from './extensions/accessibility';
-import codeQualityExtension from './extensions/code_quality';
import testReportExtension from './extensions/test_report';
import ReportWidgetContainer from './components/report_widget_container.vue';
import MrWidgetReadyToMerge from './components/states/new_ready_to_merge.vue';
@@ -215,9 +214,6 @@ export default {
return !hasCI && mergeRequestAddCiConfigPath && !isDismissedSuggestPipeline;
},
- shouldRenderCodeQuality() {
- return this.mr?.codequalityReportsPath;
- },
shouldRenderCollaborationStatus() {
return this.mr.allowCollaboration && this.mr.isOpen;
},
@@ -280,11 +276,6 @@ export default {
this.initPostMergeDeploymentsPolling();
}
},
- shouldRenderCodeQuality(newVal) {
- if (newVal) {
- this.registerCodeQualityExtension();
- }
- },
shouldShowAccessibilityReport(newVal) {
if (newVal) {
this.registerAccessibilityExtension();
@@ -534,11 +525,6 @@ export default {
registerExtension(accessibilityExtension);
}
},
- registerCodeQualityExtension() {
- if (this.shouldRenderCodeQuality) {
- registerExtension(codeQualityExtension);
- }
- },
registerTestReportExtension() {
if (this.shouldRenderTestReport) {
registerExtension(testReportExtension);
@@ -559,7 +545,6 @@ export default {
</header>
<mr-widget-suggest-pipeline
v-if="shouldSuggestPipelines"
- data-testid="mr-suggest-pipeline"
class="mr-widget-workflow"
:pipeline-path="mr.mergeRequestAddCiConfigPath"
:pipeline-svg-path="mr.pipelinesEmptySvgPath"
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/index.js b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/index.js
index a2edfa94a48..2bce09f489e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
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 9ddf8241020..b1c069d9b1e 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
@@ -6,6 +6,7 @@ import { machine } from '~/lib/utils/finite_state_machine';
import {
MTWPS_MERGE_STRATEGY,
MT_MERGE_STRATEGY,
+ MWCP_MERGE_STRATEGY,
MWPS_MERGE_STRATEGY,
STATE_MACHINE,
stateToTransitionMap,
@@ -352,6 +353,8 @@ export default class MergeRequestStore {
return MTWPS_MERGE_STRATEGY;
} else if (availableAutoMergeStrategies.includes(MT_MERGE_STRATEGY)) {
return MT_MERGE_STRATEGY;
+ } else if (availableAutoMergeStrategies.includes(MWCP_MERGE_STRATEGY)) {
+ return MWCP_MERGE_STRATEGY;
} else if (availableAutoMergeStrategies.includes(MWPS_MERGE_STRATEGY)) {
return MWPS_MERGE_STRATEGY;
}
@@ -375,6 +378,14 @@ export default class MergeRequestStore {
return false;
}
+ get isApprovalNeeded() {
+ return this.hasApprovalsAvailable ? !this.isApproved : false;
+ }
+
+ get preventMerge() {
+ return this.isApprovalNeeded;
+ }
+
// Because the state machine doesn't yet handle every state and transition,
// some use-cases will need to force a state that can't be reached by
// a known transition. This is undesirable long-term (as it subverts
diff --git a/app/assets/javascripts/vue_shared/components/actions_button.vue b/app/assets/javascripts/vue_shared/components/actions_button.vue
deleted file mode 100644
index 1d6dbef799a..00000000000
--- a/app/assets/javascripts/vue_shared/components/actions_button.vue
+++ /dev/null
@@ -1,74 +0,0 @@
-<script>
-import {
- GlDisclosureDropdown,
- GlDisclosureDropdownGroup,
- GlDisclosureDropdownItem,
-} from '@gitlab/ui';
-
-export default {
- components: {
- GlDisclosureDropdown,
- GlDisclosureDropdownGroup,
- GlDisclosureDropdownItem,
- },
- props: {
- toggleText: {
- type: String,
- required: true,
- },
- actions: {
- type: Array,
- required: true,
- },
- category: {
- type: String,
- required: false,
- default: 'secondary',
- },
- variant: {
- type: String,
- required: false,
- default: 'default',
- },
- },
- methods: {
- handleItemClick(action) {
- return action.handle?.();
- },
- },
-};
-</script>
-
-<template>
- <gl-disclosure-dropdown
- :variant="variant"
- :category="category"
- :toggle-text="toggleText"
- data-qa-selector="action_dropdown"
- fluid-width
- block
- @shown="$emit('shown')"
- @hidden="$emit('hidden')"
- >
- <gl-disclosure-dropdown-group class="edit-dropdown-group-width">
- <gl-disclosure-dropdown-item
- v-for="action in actions"
- :key="action.key"
- v-bind="action.attrs"
- :item="action"
- :data-qa-selector="`${action.key}_menu_item`"
- @action="handleItemClick(action)"
- >
- <template #list-item>
- <div class="gl-display-flex gl-flex-direction-column">
- <span class="gl-font-weight-bold gl-mb-2">{{ action.text }}</span>
- <span class="gl-text-gray-700">
- {{ action.secondaryText }}
- </span>
- </div>
- </template>
- </gl-disclosure-dropdown-item>
- </gl-disclosure-dropdown-group>
- <slot></slot>
- </gl-disclosure-dropdown>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/badges/beta_badge.stories.js b/app/assets/javascripts/vue_shared/components/badges/beta_badge.stories.js
new file mode 100644
index 00000000000..805a32273f4
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/badges/beta_badge.stories.js
@@ -0,0 +1,24 @@
+import BetaBadge from './beta_badge.vue';
+
+export default {
+ component: BetaBadge,
+ title: 'vue_shared/beta-badge',
+};
+
+const template = `
+ <div style="height:600px;" class="gl-display-flex gl-justify-content-center gl-align-items-center">
+ <beta-badge :size="size" />
+ </div>
+ `;
+
+const Template = (args, { argTypes }) => ({
+ components: { BetaBadge },
+ data() {
+ return { value: args.value };
+ },
+ props: Object.keys(argTypes),
+ template,
+});
+
+export const Default = Template.bind({});
+Default.args = {};
diff --git a/app/assets/javascripts/vue_shared/components/badges/beta_badge.vue b/app/assets/javascripts/vue_shared/components/badges/beta_badge.vue
new file mode 100644
index 00000000000..e8d33b5538e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/badges/beta_badge.vue
@@ -0,0 +1,67 @@
+<script>
+import { GlBadge, GlPopover } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ name: 'BetaBadge',
+ components: { GlBadge, GlPopover },
+ i18n: {
+ badgeLabel: s__('BetaBadge|Beta'),
+ popoverTitle: s__("BetaBadge|What's Beta?"),
+ descriptionParagraph: s__(
+ "BetaBadge|A Beta feature is not production-ready, but is unlikely to change drastically before it's released. We encourage users to try Beta features and provide feedback.",
+ ),
+ listIntroduction: s__('BetaBadge|A Beta feature:'),
+ listItemStability: s__('BetaBadge|May be unstable.'),
+ listItemDataLoss: s__('BetaBadge|Should not cause data loss.'),
+ listItemReasonableEffort: s__('BetaBadge|Is supported by a commercially reasonable effort.'),
+ listItemNearCompletion: s__('BetaBadge|Is complete or near completion.'),
+ },
+ props: {
+ size: {
+ type: String,
+ required: false,
+ default: 'md',
+ },
+ },
+ methods: {
+ target() {
+ /**
+ * BVPopover retrieves the target during the `beforeDestroy` hook to deregister attached
+ * events. Since during `beforeDestroy` refs are `undefined`, it throws a warning in the
+ * console because we're trying to access the `$el` property of `undefined`. Optional
+ * chaining is not working in templates, which is why the method is used.
+ *
+ * See more on https://gitlab.com/gitlab-org/gitlab/-/merge_requests/49628#note_464803276
+ */
+ return this.$refs.badge?.$el;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-badge ref="badge" href="#" :size="size" variant="neutral" class="gl-cursor-pointer">{{
+ $options.i18n.badgeLabel
+ }}</gl-badge>
+ <gl-popover
+ triggers="hover focus click"
+ :show-close-button="true"
+ :target="target"
+ :title="$options.i18n.popoverTitle"
+ data-testid="beta-badge"
+ >
+ <p>{{ $options.i18n.descriptionParagraph }}</p>
+
+ <p class="gl-mb-0">{{ $options.i18n.listIntroduction }}</p>
+
+ <ul class="gl-pl-4">
+ <li>{{ $options.i18n.listItemStability }}</li>
+ <li>{{ $options.i18n.listItemDataLoss }}</li>
+ <li>{{ $options.i18n.listItemReasonableEffort }}</li>
+ <li>{{ $options.i18n.listItemNearCompletion }}</li>
+ </ul>
+ </gl-popover>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
index 3e24a35ea39..11ce6afbb1d 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
@@ -2,6 +2,7 @@
import SafeHtml from '~/vue_shared/directives/safe_html';
import { handleBlobRichViewer } from '~/blob/viewer';
import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue';
+import { handleLocationHash } from '~/lib/utils/common_utils';
import ViewerMixin from './mixins';
export default {
@@ -27,6 +28,8 @@ export default {
this.isLoading = false;
await this.$nextTick();
handleBlobRichViewer(this.$refs.content, this.type);
+ handleLocationHash();
+ this.$emit('richContentLoaded');
});
},
safeHtmlConfig: {
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 9023807eba3..9aa7a7d6c49 100644
--- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
@@ -24,6 +24,12 @@ import CiIcon from './ci_icon.vue';
* - On-demand scans list
*/
+const badgeSizeOptions = {
+ sm: 'sm',
+ md: 'md',
+ lg: 'lg',
+};
+
export default {
components: {
CiIcon,
@@ -45,10 +51,16 @@ export default {
badgeSize: {
type: String,
required: false,
- default: 'md',
+ default: badgeSizeOptions.md,
+ validator(value) {
+ return badgeSizeOptions[value] !== undefined;
+ },
},
},
computed: {
+ isSmallBadgeSize() {
+ return this.badgeSize === badgeSizeOptions.sm;
+ },
title() {
return !this.showText ? this.status?.text : '';
},
@@ -108,6 +120,7 @@ export default {
<template>
<gl-badge
v-gl-tooltip
+ :class="{ 'gl-pl-0!': isSmallBadgeSize }"
:title="title"
:href="detailsPath"
:size="badgeSize"
diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue
index b4751d51fcb..7889b558279 100644
--- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue
@@ -1,5 +1,6 @@
<script>
import { v4 as uuidv4 } from 'uuid';
+import { GlSkeletonLoader } from '@gitlab/ui';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
import { CHART_CONTAINER_HEIGHT } from './constants';
@@ -7,6 +8,7 @@ export default {
name: 'CiCdAnalyticsAreaChart',
components: {
GlAreaChart,
+ GlSkeletonLoader,
},
props: {
chartData: {
@@ -17,6 +19,11 @@ export default {
type: Object,
required: true,
},
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data: () => ({
chartKey: uuidv4(),
@@ -35,7 +42,9 @@ export default {
<p>
<slot></slot>
</p>
+ <gl-skeleton-loader v-if="loading" :width="300" :lines="3" />
<gl-area-chart
+ v-else
v-bind="$attrs"
:key="chartKey"
responsive
diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
index a30b18348ec..d1e1fe162f4 100644
--- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
@@ -18,6 +18,11 @@ export default {
required: true,
type: Object,
},
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -35,12 +40,22 @@ export default {
return sprintf(s__('CiCdAnalytics|Date range: %{range}'), { range: this.chart.range });
},
},
+ methods: {
+ onInput(selectedChart) {
+ this.selectedChart = selectedChart;
+ this.$emit('select-chart', selectedChart);
+ },
+ },
};
</script>
<template>
<div>
<div class="gl-display-flex gl-flex-wrap gl-gap-5">
- <segmented-control-button-group v-model="selectedChart" :options="chartRanges" />
+ <segmented-control-button-group
+ :options="chartRanges"
+ :value="selectedChart"
+ @input="onInput"
+ />
<slot name="extend-button-group"></slot>
</div>
<ci-cd-analytics-area-chart
@@ -48,7 +63,9 @@ export default {
v-bind="$attrs"
:chart-data="chart.data"
:area-chart-options="chartOptions"
+ :loading="loading"
>
+ <slot name="alerts"></slot>
<p>{{ dateRange }}</p>
<slot name="metrics" :selected-chart="selectedChart"></slot>
<template #tooltip-title>
diff --git a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue
index 78db2bf15b0..149082d036a 100644
--- a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue
+++ b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue
@@ -110,7 +110,7 @@ export default {
<gl-form-input-group
max-length="7"
type="text"
- class="gl-align-center gl-rounded-0 gl-rounded-top-right-base gl-rounded-bottom-right-base"
+ class="gl-align-center gl-rounded-0 gl-rounded-top-right-base gl-rounded-bottom-right-base gl-max-w-26"
:value="value"
:state="state"
@input="handleColorChange"
diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue
index 388353bc35b..c2f672b2edd 100644
--- a/app/assets/javascripts/vue_shared/components/commit.vue
+++ b/app/assets/javascripts/vue_shared/components/commit.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlTooltipDirective, GlLink, GlIcon } from '@gitlab/ui';
import { isString, isEmpty } from 'lodash';
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
index f7b817423de..68da772d1cd 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
@@ -1,5 +1,7 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlAlert, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import {
diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
index 18f9d26a13d..db0b0ea185b 100644
--- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlIcon, GlLoadingIcon } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
diff --git a/app/assets/javascripts/vue_shared/components/file_finder/item.vue b/app/assets/javascripts/vue_shared/components/file_finder/item.vue
index 7816c1d74ec..59a8a24baad 100644
--- a/app/assets/javascripts/vue_shared/components/file_finder/item.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/item.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlIcon } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
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 5b98af8c732..2b3d1b2c1f5 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
@@ -17,12 +17,19 @@ export const OPERATOR_NOT = '!=';
export const OPERATOR_NOT_TEXT = __('is not one of');
export const OPERATOR_OR = '||';
export const OPERATOR_OR_TEXT = __('is one of');
+export const OPERATOR_AFTER = '≥';
+export const OPERATOR_AFTER_TEXT = __('on or after');
+export const OPERATOR_BEFORE = '<';
+export const OPERATOR_BEFORE_TEXT = __('before');
export const OPERATORS_IS = [{ value: OPERATOR_IS, description: OPERATOR_IS_TEXT }];
export const OPERATORS_NOT = [{ value: OPERATOR_NOT, description: OPERATOR_NOT_TEXT }];
export const OPERATORS_OR = [{ value: OPERATOR_OR, description: OPERATOR_OR_TEXT }];
+export const OPERATORS_AFTER = [{ value: OPERATOR_AFTER, description: OPERATOR_AFTER_TEXT }];
+export const OPERATORS_BEFORE = [{ value: OPERATOR_BEFORE, description: OPERATOR_BEFORE_TEXT }];
export const OPERATORS_IS_NOT = [...OPERATORS_IS, ...OPERATORS_NOT];
export const OPERATORS_IS_NOT_OR = [...OPERATORS_IS, ...OPERATORS_NOT, ...OPERATORS_OR];
+export const OPERATORS_AFTER_BEFORE = [...OPERATORS_AFTER, ...OPERATORS_BEFORE];
export const OPTION_NONE = { value: FILTER_NONE, text: __('None'), title: __('None') };
export const OPTION_ANY = { value: FILTER_ANY, text: __('Any'), title: __('Any') };
@@ -45,6 +52,13 @@ export const SORT_DIRECTION = {
export const FILTERED_SEARCH_TERM = 'filtered-search-term';
+export const TOKEN_EMPTY_SEARCH_TERM = {
+ type: FILTERED_SEARCH_TERM,
+ value: {
+ data: '',
+ },
+};
+
export const TOKEN_TITLE_APPROVED_BY = __('Approved-By');
export const TOKEN_TITLE_ASSIGNEE = s__('SearchToken|Assignee');
export const TOKEN_TITLE_AUTHOR = __('Author');
@@ -62,6 +76,8 @@ export const TOKEN_TITLE_STATUS = __('Status');
export const TOKEN_TITLE_TARGET_BRANCH = __('Target Branch');
export const TOKEN_TITLE_TYPE = __('Type');
export const TOKEN_TITLE_SEARCH_WITHIN = __('Search Within');
+export const TOKEN_TITLE_CREATED = __('Created date');
+export const TOKEN_TITLE_CLOSED = __('Closed date');
export const TOKEN_TYPE_APPROVED_BY = 'approved-by';
export const TOKEN_TYPE_ASSIGNEE = 'assignee';
@@ -88,3 +104,5 @@ export const TOKEN_TYPE_TARGET_BRANCH = 'target-branch';
export const TOKEN_TYPE_TYPE = 'type';
export const TOKEN_TYPE_WEIGHT = 'weight';
export const TOKEN_TYPE_SEARCH_WITHIN = 'in';
+export const TOKEN_TYPE_CREATED = 'created';
+export const TOKEN_TYPE_CLOSED = 'closed';
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 c72356dc713..f31d4d53a23 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
@@ -105,6 +105,11 @@ export default {
required: false,
default: false,
},
+ searchTextOptionLabel: {
+ type: String,
+ required: false,
+ default: __('Search for this text'),
+ },
},
data() {
return {
@@ -362,7 +367,7 @@ export default {
:close-button-title="__('Close')"
:clear-recent-searches-text="__('Clear recent searches')"
:no-recent-searches-text="__(`You don't have any recent searches`)"
- :search-text-option-label="__('Search for this text')"
+ :search-text-option-label="searchTextOptionLabel"
:show-friendly-text="showFriendlyText"
:terms-as-tokens="termsAsTokens"
class="flex-grow-1"
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js
index 65c783ada55..d33c0bb4708 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js
@@ -1,4 +1,4 @@
-import { isEmpty, uniqWith, isEqual } from 'lodash';
+import { isEmpty, uniqWith, isEqual, isString } from 'lodash';
import AccessorUtilities from '~/lib/utils/accessor';
import { queryToObject } from '~/lib/utils/url_utility';
@@ -62,17 +62,30 @@ export function prepareTokens(filters = {}) {
}, []);
}
+/**
+ * This function takes a token array and translates it into a filter object
+ * @param filters
+ * @returns A Filter Object
+ */
export function processFilters(filters) {
return filters.reduce((acc, token) => {
- const { type, value } = token;
- const { operator } = value;
- const tokenValue = value.data;
+ let type;
+ let value;
+ let operator;
+ if (typeof token === 'string') {
+ type = FILTERED_SEARCH_TERM;
+ value = token;
+ } else {
+ type = token.type;
+ operator = token.value.operator;
+ value = token.value.data;
+ }
if (!acc[type]) {
acc[type] = [];
}
- acc[type].push({ value: tokenValue, operator });
+ acc[type].push({ value, operator });
return acc;
}, {});
}
@@ -89,59 +102,93 @@ function filteredSearchQueryParam(filter) {
* { myFilterName: { value: 'foo', operator: '=' }, search: [{ value: 'my' }, { value: 'search' }] }
* gets translated into:
* { myFilterName: 'foo', 'not[myFilterName]': null, search: 'my search' }
+ * By default it supports '=' and '!=' operators. This can be extended by providing the `customOperators` option
* @param {Object} filters
* @param {Object} filters.myFilterName a single filter value or an array of filters
* @param {Object} options
* @param {Object} [options.filteredSearchTermKey] if set, 'filtered-search-term' filters are assigned to this key, 'search' is suggested
+ * @param {Object} [options.customOperators] Allows to extend the supported operators, e.g.
+ *
+ * filterToQueryObject({foo: [{ value: '100', operator: '>' }]}, {customOperators: {operator: '>',prefix: 'gt'}})
+ * returns {gt[foo]: '100'}
+ * It's also possible to restrict custom operators to a given key by setting `applyOnlyToKey` string attribute.
+ *
* @return {Object} query object with both filter name and not-name with values
*/
export function filterToQueryObject(filters = {}, options = {}) {
- const { filteredSearchTermKey } = options;
+ const { filteredSearchTermKey, customOperators } = options;
return Object.keys(filters).reduce((memo, key) => {
const filter = filters[key];
- if (typeof filteredSearchTermKey === 'string' && key === FILTERED_SEARCH_TERM) {
+ if (typeof filteredSearchTermKey === 'string' && key === FILTERED_SEARCH_TERM && filter) {
return { ...memo, [filteredSearchTermKey]: filteredSearchQueryParam(filter) };
}
- let selected;
- let unselected;
+ const operators = [
+ { operator: '=' },
+ { operator: '!=', prefix: 'not' },
+ ...(customOperators ?? []),
+ ];
- if (Array.isArray(filter)) {
- selected = filter.filter((item) => item.operator === '=').map((item) => item.value);
- unselected = filter.filter((item) => item.operator === '!=').map((item) => item.value);
- } else {
- selected = filter?.operator === '=' ? filter.value : null;
- unselected = filter?.operator === '!=' ? filter.value : null;
- }
+ const result = {};
- if (isEmpty(selected)) {
- selected = null;
- }
- if (isEmpty(unselected)) {
- unselected = null;
+ for (const op of operators) {
+ const { operator, prefix, applyOnlyToKey } = op;
+
+ if (!applyOnlyToKey || applyOnlyToKey === key) {
+ let value;
+ if (Array.isArray(filter)) {
+ value = filter.filter((item) => item.operator === operator).map((item) => item.value);
+ } else {
+ value = filter?.operator === operator ? filter.value : null;
+ }
+ if (isEmpty(value)) {
+ value = null;
+ }
+ if (prefix) {
+ result[`${prefix}[${key}]`] = value;
+ } else {
+ result[key] = value;
+ }
+ }
}
- return { ...memo, [key]: selected, [`not[${key}]`]: unselected };
+ return { ...memo, ...result };
}, {});
}
/**
- * Extracts filter name from url name, e.g. `not[my_filter]` => `my_filter`
- * and returns the operator with it depending on the filter name
+ * Extracts filter name from url name and operator, e.g.
+ * e.g. input: not[my_filter]` output: {filterName: `my_filter`, operator: '!='}`
+ *
+ * By default it supports filter with the format `my_filter=foo` and `not[my_filter]=bar`. This can be extended with the `customOperators` option.
* @param {String} filterName from url
+ * @param {Object.customOperators} It allows to extend the supported parameter, e.g.
+ * input: 'gt[filter]', { customOperators: [{ operator: '>', prefix: 'gt' }]})
+ * output: '{filterName: 'filter', operator: '>'}
* @return {Object}
* @return {Object.filterName} extracted filter name
* @return {Object.operator} `=` or `!=`
*/
-function extractNameAndOperator(filterName) {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- if (filterName.startsWith('not[') && filterName.endsWith(']')) {
- return { filterName: filterName.slice(4, -1), operator: '!=' };
- }
+function extractNameAndOperator(filterName, customOperators) {
+ const ops = [
+ {
+ prefix: 'not',
+ operator: '!=',
+ },
+ ...(customOperators ?? []),
+ ];
- return { filterName, operator: '=' };
+ const operator = ops.find(
+ ({ prefix }) => filterName.startsWith(`${prefix}[`) && filterName.endsWith(']'),
+ );
+
+ if (!operator) {
+ return { filterName, operator: '=' };
+ }
+ const { prefix } = operator;
+ return { filterName: filterName.slice(prefix.length + 1, -1), operator: operator.operator };
}
/**
@@ -151,11 +198,7 @@ function extractNameAndOperator(filterName) {
*/
function filteredSearchTermValue(value) {
const values = Array.isArray(value) ? value : [value];
- return values
- .filter((term) => term)
- .join(' ')
- .split(' ')
- .map((term) => ({ value: term }));
+ return [{ value: values.filter((term) => term).join(' ') }];
}
/**
@@ -163,15 +206,21 @@ function filteredSearchTermValue(value) {
* '?myFilterName=foo'
* gets translated into:
* { myFilterName: { value: 'foo', operator: '=' } }
- * @param {String} query URL query string, e.g. from `window.location.search`
- * @param {Object} options
+ * By default it only support '=' and '!=' operators. This can be extended with the customOperator option.
+ * @param {String|Object} query URL query string or object, e.g. from `window.location.search` or `this.$route.query`
* @param {Object} options
* @param {String} [options.filteredSearchTermKey] if set, a FILTERED_SEARCH_TERM filter is created to this parameter. `'search'` is suggested
* @param {String[]} [options.filterNamesAllowList] if set, only this list of filters names is mapped
+ * @param {Object} [options.customOperator] It allows to extend the supported parameter, e.g.
+ * input: 'gt[myFilter]=100', { customOperators: [{ operator: '>', prefix: 'gt' }]})
+ * output: '{ myFilter: {value: '100', operator: '>'}}
* @return {Object} filter object with filter names and their values
*/
-export function urlQueryToFilter(query = '', { filteredSearchTermKey, filterNamesAllowList } = {}) {
- const filters = queryToObject(query, { gatherArrays: true });
+export function urlQueryToFilter(
+ query = '',
+ { filteredSearchTermKey, filterNamesAllowList, customOperators } = {},
+) {
+ const filters = isString(query) ? queryToObject(query, { gatherArrays: true }) : query;
return Object.keys(filters).reduce((memo, key) => {
const value = filters[key];
if (!value) {
@@ -184,7 +233,7 @@ export function urlQueryToFilter(query = '', { filteredSearchTermKey, filterName
};
}
- const { filterName, operator } = extractNameAndOperator(key);
+ const { filterName, operator } = extractNameAndOperator(key, customOperators);
if (filterNamesAllowList && !filterNamesAllowList.includes(filterName)) {
return memo;
}
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/date_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/date_token.vue
new file mode 100644
index 00000000000..4446886dc88
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/date_token.vue
@@ -0,0 +1,73 @@
+<script>
+import { GlDatepicker, GlFilteredSearchToken } from '@gitlab/ui';
+import { formatDate } from '~/lib/utils/datetime_utility';
+
+export default {
+ components: {
+ GlDatepicker,
+ GlFilteredSearchToken,
+ },
+ props: {
+ active: {
+ type: Boolean,
+ required: true,
+ },
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ selectedDate: null,
+ };
+ },
+ methods: {
+ selectValue(value) {
+ this.selectedDate = formatDate(value, 'yyyy-mm-dd');
+ },
+ close(submitValue) {
+ if (this.selectedDate == null) {
+ return;
+ }
+
+ submitValue(this.selectedDate);
+ },
+ handle() {
+ const listeners = { ...this.$listeners };
+ // If we don't remove this, clicking the month/year in the datepicker will deactivate
+ delete listeners.deactivate;
+ return listeners;
+ },
+ },
+ dataSegmentInputAttributes: {
+ id: 'glfs-datepicker',
+ placeholder: 'YYYY-MM-DD',
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token
+ :config="config"
+ :value="value"
+ :active="active"
+ :data-segment-input-attributes="$options.dataSegmentInputAttributes"
+ v-bind="{ ...$props, ...$attrs }"
+ v-on="handle()"
+ >
+ <template #before-data-segment-input="{ submitValue }">
+ <gl-datepicker
+ class="gl-display-none!"
+ target="#glfs-datepicker"
+ :container="null"
+ @input="selectValue($event)"
+ @close="close(submitValue)"
+ />
+ </template>
+ </gl-filtered-search-token>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
index 0ce784fab1a..f17e88d73a4 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
@@ -48,7 +48,7 @@ export default {
this.emojis = Array.isArray(response) ? response : response.data;
})
.catch(() => {
- createAlert({ message: __('There was a problem fetching emojis.') });
+ createAlert({ message: __('There was a problem fetching emoji.') });
})
.finally(() => {
this.loading = false;
diff --git a/app/assets/javascripts/vue_shared/components/form/index.js b/app/assets/javascripts/vue_shared/components/form/index.js
new file mode 100644
index 00000000000..65bc14dd807
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/form/index.js
@@ -0,0 +1,59 @@
+import Vue from 'vue';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import InputCopyToggleVisibility from './input_copy_toggle_visibility.vue';
+
+export function initInputCopyToggleVisibility() {
+ const els = document.getElementsByClassName('js-input-copy-visibility');
+
+ Array.from(els).forEach((el) => {
+ const {
+ name,
+ value,
+ initialVisibility,
+ showToggleVisibilityButton,
+ showCopyButton,
+ copyButtonTitle,
+ readonly,
+ formInputGroupProps,
+ formGroupAttributes,
+ } = el.dataset;
+
+ const parsedFormInputGroupProps = convertObjectPropsToCamelCase(
+ JSON.parse(formInputGroupProps || '{}'),
+ );
+ const parsedFormGroupAttributes = convertObjectPropsToCamelCase(
+ JSON.parse(formGroupAttributes || '{}'),
+ );
+
+ return new Vue({
+ el,
+ data() {
+ return {
+ value,
+ };
+ },
+ render(createElement) {
+ return createElement(InputCopyToggleVisibility, {
+ props: {
+ value: this.value,
+ initialVisibility,
+ showToggleVisibilityButton,
+ showCopyButton,
+ copyButtonTitle,
+ readonly,
+ formInputGroupProps: {
+ name,
+ ...parsedFormInputGroupProps,
+ },
+ },
+ attrs: parsedFormGroupAttributes,
+ on: {
+ input: (newValue) => {
+ this.value = newValue;
+ },
+ },
+ });
+ },
+ });
+ });
+}
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 377f1e7c136..531ed5fe0ea 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
@@ -8,16 +8,21 @@ export default {
const defaultProps = {
value: 'hR8x1fuJbzwu5uFKLf9e',
formInputGroupProps: { class: 'gl-form-input-xl' },
+ readonly: false,
};
const Template = (args, { argTypes }) => ({
components: { InputCopyToggleVisibility },
+ data() {
+ return { value: args.value };
+ },
props: Object.keys(argTypes),
template: `<input-copy-toggle-visibility
- :value="value"
+ v-model="value"
:initial-visibility="initialVisibility"
:show-toggle-visibility-button="showToggleVisibilityButton"
:show-copy-button="showCopyButton"
+ :readonly="readonly"
:form-input-group-props="formInputGroupProps"
:copy-button-title="copyButtonTitle"
/>`,
diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
index dea279890b1..ebc6b2cd740 100644
--- a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
+++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
@@ -8,6 +8,7 @@ import {
} from '@gitlab/ui';
import { __ } from '~/locale';
+import { Mousetrap, MOUSETRAP_COPY_KEYBOARD_SHORTCUT } from '~/lib/mousetrap';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
export default {
@@ -52,6 +53,11 @@ export default {
required: false,
default: __('Copy'),
},
+ readonly: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
formInputGroupProps: {
type: Object,
required: false,
@@ -59,8 +65,19 @@ export default {
return {};
},
},
+ size: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
+ if (!this.readonly && !this.value) {
+ return {
+ valueIsVisible: true,
+ };
+ }
+
return {
valueIsVisible: this.initialVisibility,
};
@@ -77,33 +94,60 @@ export default {
computedValueIsVisible() {
return !this.showToggleVisibilityButton || this.valueIsVisible;
},
- displayedValue() {
- return this.computedValueIsVisible ? this.value : '*'.repeat(this.value.length || 20);
+ inputType() {
+ return this.computedValueIsVisible ? 'text' : 'password';
},
},
+ mounted() {
+ this.$options.mousetrap = new Mousetrap(this.$refs.input.$el);
+ this.$options.mousetrap.bind(MOUSETRAP_COPY_KEYBOARD_SHORTCUT, this.handleFormInputCopy);
+ },
+ beforeDestroy() {
+ this.$options.mousetrap?.unbind(MOUSETRAP_COPY_KEYBOARD_SHORTCUT);
+ },
+
methods: {
handleToggleVisibilityButtonClick() {
this.valueIsVisible = !this.valueIsVisible;
this.$emit('visibility-change', this.valueIsVisible);
},
- handleClick() {
- this.$refs.input.$el.select();
+ async handleClick() {
+ if (this.readonly) {
+ this.$refs.input.$el.select();
+ } else if (!this.valueIsVisible) {
+ const { selectionStart, selectionEnd } = this.$refs.input.$el;
+ this.handleToggleVisibilityButtonClick();
+
+ setTimeout(() => {
+ // When the input type is changed from 'password'' to 'text', cursor position is reset in some browsers.
+ // This makes clicking to edit difficult due to typing in unexpected location, so we preserve the cursor position / selection
+ this.$refs.input.$el.setSelectionRange(selectionStart, selectionEnd);
+ }, 0);
+ }
},
handleCopyButtonClick() {
this.$emit('copy');
},
- handleFormInputCopy(event) {
- this.handleCopyButtonClick();
-
+ async handleFormInputCopy() {
+ // Value will be copied by native browser behavior
if (this.computedValueIsVisible) {
return;
}
- event.clipboardData.setData('text/plain', this.value);
- event.preventDefault();
+ try {
+ // user is trying to copy from the password input, set their clipboard for them
+ await navigator.clipboard?.writeText(this.value);
+ this.handleCopyButtonClick();
+ } catch (e) {
+ // Nothing we can do here, best effort to set clipboard value
+ }
+ },
+ handleInput(newValue) {
+ this.$emit('input', newValue);
},
},
+ mousetrap: null,
};
</script>
<template>
@@ -111,11 +155,13 @@ export default {
<gl-form-input-group>
<gl-form-input
ref="input"
- readonly
+ :readonly="readonly"
+ :size="size"
class="gl-font-monospace! gl-cursor-default!"
v-bind="formInputGroupProps"
- :value="displayedValue"
- @copy="handleFormInputCopy"
+ :value="value"
+ :type="inputType"
+ @input="handleInput"
@click="handleClick"
/>
diff --git a/app/assets/javascripts/vue_shared/components/form/title.vue b/app/assets/javascripts/vue_shared/components/form/title.vue
index 5d6633fa6d7..b17681319f3 100644
--- a/app/assets/javascripts/vue_shared/components/form/title.vue
+++ b/app/assets/javascripts/vue_shared/components/form/title.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlFormInput, GlFormGroup } from '@gitlab/ui';
diff --git a/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue b/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue
index dd0c0358ef6..abcd2f681f8 100644
--- a/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue
+++ b/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue
@@ -1,5 +1,6 @@
<script>
import { GlModal } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
diff --git a/app/assets/javascripts/vue_shared/components/groups_list/groups_list.stories.js b/app/assets/javascripts/vue_shared/components/groups_list/groups_list.stories.js
new file mode 100644
index 00000000000..235523054c3
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/groups_list/groups_list.stories.js
@@ -0,0 +1,19 @@
+import { groups } from 'jest/vue_shared/components/groups_list/mock_data';
+import GroupsList from './groups_list.vue';
+
+export default {
+ component: GroupsList,
+ title: 'vue_shared/groups_list',
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { GroupsList },
+ props: Object.keys(argTypes),
+ template: '<groups-list v-bind="$props" />',
+});
+
+export const Default = Template.bind({});
+Default.args = {
+ groups,
+ showGroupIcon: true,
+};
diff --git a/app/assets/javascripts/vue_shared/components/groups_list/groups_list.vue b/app/assets/javascripts/vue_shared/components/groups_list/groups_list.vue
new file mode 100644
index 00000000000..7da45169fee
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/groups_list/groups_list.vue
@@ -0,0 +1,29 @@
+<script>
+import GroupsListItem from './groups_list_item.vue';
+
+export default {
+ components: { GroupsListItem },
+ props: {
+ groups: {
+ type: Array,
+ required: true,
+ },
+ showGroupIcon: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+};
+</script>
+
+<template>
+ <ul class="gl-p-0 gl-list-style-none">
+ <groups-list-item
+ v-for="group in groups"
+ :key="group.id"
+ :group="group"
+ :show-group-icon="showGroupIcon"
+ />
+ </ul>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/groups_list/groups_list_item.vue b/app/assets/javascripts/vue_shared/components/groups_list/groups_list_item.vue
new file mode 100644
index 00000000000..8a301cd0dd0
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/groups_list/groups_list_item.vue
@@ -0,0 +1,168 @@
+<script>
+import { GlAvatarLabeled, GlIcon, GlTooltipDirective, GlTruncateText } from '@gitlab/ui';
+
+import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '~/visibility_level/constants';
+import { ACCESS_LEVEL_LABELS } from '~/access_level/constants';
+import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
+import { __ } from '~/locale';
+import { numberToMetricPrefix } from '~/lib/utils/number_utils';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+
+export default {
+ i18n: {
+ subgroups: __('Subgroups'),
+ projects: __('Projects'),
+ directMembers: __('Direct members'),
+ showMore: __('Show more'),
+ showLess: __('Show less'),
+ },
+ avatarSize: { default: 32, md: 48 },
+ safeHtmlConfig: {
+ ADD_TAGS: ['gl-emoji'],
+ },
+ components: {
+ GlAvatarLabeled,
+ GlIcon,
+ UserAccessRoleBadge,
+ GlTruncateText,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ SafeHtml,
+ },
+ props: {
+ group: {
+ type: Object,
+ required: true,
+ },
+ showGroupIcon: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ visibility() {
+ return this.group.visibility;
+ },
+ visibilityIcon() {
+ return VISIBILITY_TYPE_ICON[this.visibility];
+ },
+ visibilityTooltip() {
+ return GROUP_VISIBILITY_TYPE[this.visibility];
+ },
+ accessLevel() {
+ return this.group.accessLevel?.integerValue;
+ },
+ accessLevelLabel() {
+ return ACCESS_LEVEL_LABELS[this.accessLevel];
+ },
+ shouldShowAccessLevel() {
+ return this.accessLevel !== undefined;
+ },
+ groupIconName() {
+ return this.group.parent ? 'subgroup' : 'group';
+ },
+ statsPadding() {
+ return this.showGroupIcon ? 'gl-pl-11' : 'gl-pl-8';
+ },
+ descendantGroupsCount() {
+ return numberToMetricPrefix(this.group.descendantGroupsCount);
+ },
+ projectsCount() {
+ return numberToMetricPrefix(this.group.projectsCount);
+ },
+ groupMembersCount() {
+ return numberToMetricPrefix(this.group.groupMembersCount);
+ },
+ },
+};
+</script>
+
+<template>
+ <li class="groups-list-item gl-py-5 gl-md-display-flex gl-align-items-center gl-border-b">
+ <div class="gl-display-flex gl-flex-grow-1">
+ <gl-icon
+ v-if="showGroupIcon"
+ class="gl-mr-3 gl-mt-3 gl-md-mt-5 gl-flex-shrink-0 gl-text-secondary"
+ :name="groupIconName"
+ />
+ <gl-avatar-labeled
+ :entity-id="group.id"
+ :entity-name="group.fullName"
+ :label="group.fullName"
+ :label-link="group.webUrl"
+ shape="rect"
+ :size="$options.avatarSize"
+ >
+ <template #meta>
+ <div class="gl-px-2">
+ <div class="gl-mx-n2 gl-display-flex gl-align-items-center gl-flex-wrap">
+ <div class="gl-px-2">
+ <gl-icon
+ v-if="visibility"
+ v-gl-tooltip="visibilityTooltip"
+ :name="visibilityIcon"
+ class="gl-text-secondary"
+ />
+ </div>
+ <div class="gl-px-2">
+ <user-access-role-badge v-if="shouldShowAccessLevel">{{
+ accessLevelLabel
+ }}</user-access-role-badge>
+ </div>
+ </div>
+ </div>
+ </template>
+ <gl-truncate-text
+ v-if="group.descriptionHtml"
+ :lines="2"
+ :mobile-lines="2"
+ :show-more-text="$options.i18n.showMore"
+ :show-less-text="$options.i18n.showLess"
+ class="gl-mt-2"
+ >
+ <div
+ v-safe-html:[$options.safeHtmlConfig]="group.descriptionHtml"
+ class="gl-font-sm md"
+ data-testid="group-description"
+ ></div>
+ </gl-truncate-text>
+ </gl-avatar-labeled>
+ </div>
+ <div
+ class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-md-pl-0 gl-md-mt-0 gl-md-ml-3"
+ :class="statsPadding"
+ >
+ <div class="gl-display-flex gl-align-items-center gl-gap-x-3">
+ <div
+ v-gl-tooltip="$options.i18n.subgroups"
+ :aria-label="$options.i18n.subgroups"
+ class="gl-text-secondary"
+ data-testid="subgroups-count"
+ >
+ <gl-icon name="subgroup" />
+ <span>{{ descendantGroupsCount }}</span>
+ </div>
+ <div
+ v-gl-tooltip="$options.i18n.projects"
+ :aria-label="$options.i18n.projects"
+ class="gl-text-secondary"
+ data-testid="projects-count"
+ >
+ <gl-icon name="project" />
+ <span>{{ projectsCount }}</span>
+ </div>
+ <div
+ v-gl-tooltip="$options.i18n.directMembers"
+ :aria-label="$options.i18n.directMembers"
+ class="gl-text-secondary"
+ data-testid="members-count"
+ >
+ <gl-icon name="users" />
+ <span>{{ groupMembersCount }}</span>
+ </div>
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue
index 92d468cf970..0e82ef3aa65 100644
--- a/app/assets/javascripts/vue_shared/components/help_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/help_popover.vue
@@ -26,6 +26,11 @@ export default {
required: false,
default: 'question-o',
},
+ triggerClass: {
+ type: [String, Array, Object],
+ required: false,
+ default: '',
+ },
},
methods: {
targetFn() {
@@ -36,7 +41,13 @@ export default {
</script>
<template>
<span>
- <gl-button ref="popoverTrigger" variant="link" :icon="icon" :aria-label="__('Help')" />
+ <gl-button
+ ref="popoverTrigger"
+ :class="triggerClass"
+ variant="link"
+ :icon="icon"
+ :aria-label="__('Help')"
+ />
<gl-popover :target="targetFn" v-bind="options">
<template v-if="options.title" #title>
<span v-safe-html="options.title"></span>
diff --git a/app/assets/javascripts/vue_shared/components/listbox_input/init_listbox_inputs.js b/app/assets/javascripts/vue_shared/components/listbox_input/init_listbox_inputs.js
index b447822b1e0..e098103adde 100644
--- a/app/assets/javascripts/vue_shared/components/listbox_input/init_listbox_inputs.js
+++ b/app/assets/javascripts/vue_shared/components/listbox_input/init_listbox_inputs.js
@@ -6,7 +6,7 @@ export const initListboxInputs = () => {
const els = [...document.querySelectorAll('.js-listbox-input')];
els.forEach((el, index) => {
- const { label, description, name, defaultToggleText, value = null } = el.dataset;
+ const { label, description, name, defaultToggleText, value = null, toggleClass } = el.dataset;
const { id } = el;
const items = JSON.parse(el.dataset.items);
@@ -34,6 +34,7 @@ export const initListboxInputs = () => {
block: parseBoolean(el.dataset.block),
fluidWidth: parseBoolean(el.dataset.fluidWidth),
items,
+ toggleClass,
},
attrs: {
id,
diff --git a/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue b/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue
index a59a7494472..d20593d104e 100644
--- a/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue
+++ b/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue
@@ -62,6 +62,11 @@ export default {
required: false,
default: GlCollapsibleListbox.props.block.default,
},
+ toggleClass: {
+ type: [Array, String, Object],
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -117,7 +122,7 @@ export default {
},
toggleText() {
return this.selected
- ? this.allOptions.find((option) => option.value === this.selected).text
+ ? this.allOptions.find((option) => option.value === this.selected)?.text
: this.defaultToggleText;
},
},
@@ -134,6 +139,7 @@ export default {
<gl-collapsible-listbox
:selected="selected"
:toggle-text="toggleText"
+ :toggle-class="toggleClass"
:items="filteredItems"
:searchable="isSearchable"
:no-results-text="$options.i18n.noResultsText"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue
index 966a5556d24..b1c6f5e6056 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue
@@ -1,7 +1,10 @@
<script>
import { GlCollapsibleListbox, GlTooltip, GlButton } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import { getDerivedMergeRequestInformation } from '~/diffs/utils/merge_request';
+import { InternalEvents } from '~/tracking';
import savedRepliesQuery from './saved_replies.query.graphql';
+import { TRACKING_SAVED_REPLIES_USE, TRACKING_SAVED_REPLIES_USE_IN_MR } from './constants';
export default {
apollo: {
@@ -18,6 +21,7 @@ export default {
GlButton,
GlTooltip,
},
+ mixins: [InternalEvents.mixin()],
props: {
newCommentTemplatePath: {
type: String,
@@ -52,9 +56,14 @@ export default {
this.commentTemplateSearch = search;
},
onSelect(id) {
+ const isInMr = Boolean(getDerivedMergeRequestInformation({ endpoint: window.location }).id);
const savedReply = this.savedReplies.find((r) => r.id === id);
if (savedReply) {
this.$emit('select', savedReply.content);
+ this.track_event(TRACKING_SAVED_REPLIES_USE);
+ if (isInMr) {
+ this.track_event(TRACKING_SAVED_REPLIES_USE_IN_MR);
+ }
}
},
},
diff --git a/app/assets/javascripts/vue_shared/components/markdown/constants.js b/app/assets/javascripts/vue_shared/components/markdown/constants.js
new file mode 100644
index 00000000000..47ef7cccbc2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/constants.js
@@ -0,0 +1,2 @@
+export const TRACKING_SAVED_REPLIES_USE = 'i_code_review_saved_replies_use';
+export const TRACKING_SAVED_REPLIES_USE_IN_MR = 'i_code_review_saved_replies_use_in_mr';
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 7c569763a75..a26f8f71601 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import $ from 'jquery';
@@ -398,7 +399,6 @@ export default {
v-show="previewMarkdown"
ref="markdown-preview"
class="js-vue-md-preview md-preview-holder gl-px-5"
- :class="{ md: !hasSuggestion }"
>
<suggestions
v-if="hasSuggestion"
@@ -409,7 +409,7 @@ export default {
:help-page-path="helpPagePath"
/>
<template v-else>
- <div v-safe-html:[$options.safeHtmlConfig]="markdownPreview"></div>
+ <div v-safe-html:[$options.safeHtmlConfig]="markdownPreview" class="md"></div>
</template>
</div>
<div
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 0907e064e01..286a1b87ad0 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlPopover, GlButton, GlTooltipDirective } from '@gitlab/ui';
import $ from 'jquery';
@@ -278,6 +279,7 @@ export default {
:button-title="__('Insert suggestion')"
:cursor-offset="4"
:tag-content="lineContent"
+ tracking-property="codeSuggestion"
icon="doc-code"
data-qa-selector="suggestion_button"
class="js-suggestion-btn"
@@ -327,6 +329,7 @@ export default {
"
:shortcuts="$options.shortcuts.bold"
icon="bold"
+ tracking-property="bold"
/>
<toolbar-button
v-show="!previewMarkdown"
@@ -339,6 +342,7 @@ export default {
"
:shortcuts="$options.shortcuts.italic"
icon="italic"
+ tracking-property="italic"
/>
<toolbar-button
v-if="!restrictedToolBarItems.includes('strikethrough')"
@@ -353,6 +357,7 @@ export default {
"
:shortcuts="$options.shortcuts.strikethrough"
icon="strikethrough"
+ tracking-property="strike"
/>
<toolbar-button
v-if="!restrictedToolBarItems.includes('quote')"
@@ -361,6 +366,7 @@ export default {
:tag="tag"
:button-title="__('Insert a quote')"
icon="quote"
+ tracking-property="blockquote"
@click="handleQuote"
/>
<toolbar-button
@@ -369,6 +375,7 @@ export default {
tag-block="```"
:button-title="__('Insert code')"
icon="code"
+ tracking-property="code"
/>
<toolbar-button
v-show="!previewMarkdown"
@@ -382,6 +389,7 @@ export default {
"
:shortcuts="$options.shortcuts.link"
icon="link"
+ tracking-property="link"
/>
<toolbar-button
v-if="!restrictedToolBarItems.includes('bullet-list')"
@@ -390,6 +398,7 @@ export default {
tag="- "
:button-title="__('Add a bullet list')"
icon="list-bulleted"
+ tracking-property="bulletList"
/>
<toolbar-button
v-if="!restrictedToolBarItems.includes('numbered-list')"
@@ -398,6 +407,7 @@ export default {
tag="1. "
:button-title="__('Add a numbered list')"
icon="list-numbered"
+ tracking-property="orderedList"
/>
<toolbar-button
v-if="!restrictedToolBarItems.includes('task-list')"
@@ -406,6 +416,7 @@ export default {
tag="- [ ] "
:button-title="__('Add a checklist')"
icon="list-task"
+ tracking-property="taskList"
/>
<toolbar-button
v-if="!restrictedToolBarItems.includes('indent')"
@@ -420,6 +431,7 @@ export default {
:shortcuts="$options.shortcuts.indent"
command="indentLines"
icon="list-indent"
+ tracking-property="indent"
/>
<toolbar-button
v-if="!restrictedToolBarItems.includes('outdent')"
@@ -434,6 +446,7 @@ export default {
:shortcuts="$options.shortcuts.outdent"
command="outdentLines"
icon="list-outdent"
+ tracking-property="outdent"
/>
<toolbar-button
v-if="!restrictedToolBarItems.includes('collapsible-section')"
@@ -443,6 +456,7 @@ export default {
tag-select="Click to expand"
:button-title="__('Add a collapsible section')"
icon="details-block"
+ tracking-property="details"
/>
<toolbar-button
v-if="!restrictedToolBarItems.includes('table')"
@@ -451,17 +465,15 @@ export default {
:prepend="true"
:button-title="__('Add a table')"
icon="table"
+ tracking-property="table"
/>
- <gl-button
+ <toolbar-button
v-if="!previewMarkdown && !restrictedToolBarItems.includes('attach-file')"
- v-gl-tooltip
- :aria-label="__('Attach a file or image')"
- :title="__('Attach a file or image')"
- class="gl-mr-3"
data-testid="button-attach-file"
- category="tertiary"
+ :button-title="__('Attach a file or image')"
icon="paperclip"
- size="small"
+ class="gl-mr-3"
+ tracking-property="upload"
@click="handleAttachFile"
/>
<drawio-toolbar-button
@@ -477,6 +489,7 @@ export default {
tag="/"
:button-title="__('Add a quick action')"
icon="quick-actions"
+ tracking-property="quickAction"
/>
<comment-templates-dropdown
v-if="!previewMarkdown && newCommentTemplatePath && glFeatures.savedReplies"
@@ -484,16 +497,13 @@ export default {
@select="insertSavedReply"
/>
<div v-if="!previewMarkdown" class="full-screen">
- <gl-button
+ <toolbar-button
v-if="!restrictedToolBarItems.includes('full-screen')"
- v-gl-tooltip
class="js-zen-enter"
- category="tertiary"
icon="maximize"
- size="small"
- :title="__('Go full screen')"
+ :button-title="__('Go full screen')"
:prepend="true"
- :aria-label="__('Go full screen')"
+ tracking-property="fullScreen"
/>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
index 8b8247a5b2c..493b329f1b1 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
@@ -13,6 +13,22 @@ import {
import MarkdownField from './field.vue';
import eventHub from './eventhub';
+async function sleep(t = 10) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, t);
+ });
+}
+
+async function waitFor(getEl, interval = 10, timeout = 2000) {
+ if (timeout <= 0) return null;
+
+ const el = getEl();
+ if (el) return el;
+
+ await sleep(interval);
+ return waitFor(getEl, timeout - interval);
+}
+
export default {
components: {
LocalStorageSync,
@@ -190,8 +206,15 @@ export default {
this.$emit(editingMode);
this.notifyEditingModeChange(editingMode);
},
- notifyEditingModeChange(editingMode) {
+ async notifyEditingModeChange(editingMode) {
this.$emit(editingMode);
+
+ const componentToFocus =
+ editingMode === EDITING_MODE_CONTENT_EDITOR
+ ? () => this.$refs.contentEditor
+ : () => this.$refs.textarea;
+
+ (await waitFor(componentToFocus)).focus();
},
autofocusTextarea() {
if (this.autofocus && this.editingMode === EDITING_MODE_MARKDOWN_FIELD) {
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index 4423b26560f..532dec337e1 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import Vue from 'vue';
import SafeHtml from '~/vue_shared/directives/safe_html';
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index d4b1abedc02..a4516fae73d 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton, GlLoadingIcon, GlSprintf, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { updateText } from '~/lib/utils/text_markdown';
@@ -150,7 +151,7 @@ export default {
target="_blank"
category="tertiary"
size="small"
- title="Markdown is supported"
+ :title="__('Markdown is supported')"
class="gl-px-3!"
/>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
index 636c89c99d4..cf484443c07 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
@@ -1,5 +1,6 @@
<script>
import { GlTooltipDirective, GlButton } from '@gitlab/ui';
+import { TOOLBAR_CONTROL_TRACKING_ACTION, MARKDOWN_EDITOR_TRACKING_LABEL } from './tracking';
export default {
components: {
@@ -66,12 +67,28 @@ export default {
required: false,
default: () => [],
},
+ trackingProperty: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
computed: {
shortcutsString() {
const shortcutArray = Array.isArray(this.shortcuts) ? this.shortcuts : [this.shortcuts];
return JSON.stringify(shortcutArray);
},
+ trackingProps() {
+ const { trackingProperty } = this;
+
+ return trackingProperty
+ ? {
+ 'data-track-action': TOOLBAR_CONTROL_TRACKING_ACTION,
+ 'data-track-label': MARKDOWN_EDITOR_TRACKING_LABEL,
+ 'data-track-property': trackingProperty,
+ }
+ : {};
+ },
},
};
</script>
@@ -90,6 +107,7 @@ export default {
:title="buttonTitle"
:aria-label="buttonTitle"
:icon="icon"
+ v-bind="trackingProps"
type="button"
category="tertiary"
size="small"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/tracking.js b/app/assets/javascripts/vue_shared/components/markdown/tracking.js
index 2628054ae5f..6ce800730c7 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/tracking.js
+++ b/app/assets/javascripts/vue_shared/components/markdown/tracking.js
@@ -1,14 +1,14 @@
import Tracking from '~/tracking';
-export const EDITOR_TRACKING_LABEL = 'editor_tracking';
-export const EDITOR_TYPE_ACTION = 'editor_type_used';
-export const EDITOR_TYPE_PLAIN_TEXT_EDITOR = 'editor_type_plain_text_editor';
-export const EDITOR_TYPE_RICH_TEXT_EDITOR = 'editor_type_rich_text_editor';
+export const MARKDOWN_EDITOR_TRACKING_LABEL = 'markdown_editor';
+export const RICH_TEXT_EDITOR_TRACKING_LABEL = 'rich_text_editor';
-export const trackSavedUsingEditor = (isRichText, context) => {
- Tracking.event(undefined, EDITOR_TYPE_ACTION, {
- label: EDITOR_TRACKING_LABEL,
- editorType: isRichText ? EDITOR_TYPE_RICH_TEXT_EDITOR : EDITOR_TYPE_PLAIN_TEXT_EDITOR,
- context,
+export const SAVE_MARKDOWN_TRACKING_ACTION = 'save_markdown';
+export const TOOLBAR_CONTROL_TRACKING_ACTION = 'execute_toolbar_control';
+
+export const trackSavedUsingEditor = (isRichText, property) => {
+ Tracking.event(undefined, SAVE_MARKDOWN_TRACKING_ACTION, {
+ label: isRichText ? RICH_TEXT_EDITOR_TRACKING_LABEL : MARKDOWN_EDITOR_TRACKING_LABEL,
+ property,
});
};
diff --git a/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue
index 2cadc87eca3..96eede98fa1 100644
--- a/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue
+++ b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue
@@ -1,5 +1,6 @@
<script>
import { GlFormGroup, GlFormInput, GlLoadingIcon, GlModal } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
import { __, s__ } from '~/locale';
import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
diff --git a/app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue
index bbbaaeb8a9e..ac57a1df9c2 100644
--- a/app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue
+++ b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue
@@ -10,6 +10,7 @@ import {
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import { __, s__ } from '~/locale';
diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/index.js b/app/assets/javascripts/vue_shared/components/metric_images/store/index.js
index f13dde9a2bc..db67b633d07 100644
--- a/app/assets/javascripts/vue_shared/components/metric_images/store/index.js
+++ b/app/assets/javascripts/vue_shared/components/metric_images/store/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import actionsFactory from './actions';
import mutations from './mutations';
diff --git a/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue b/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue
index ba557878246..7871721f38b 100644
--- a/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue
@@ -283,7 +283,6 @@ export default {
<gl-disclosure-dropdown-item
v-if="isOpen && canUpdateMergeRequest"
- data-testid="close-merge-request"
@action="stateAction('close')"
>
<template #list-item>
diff --git a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
index 9ea04553536..9179331cdec 100644
--- a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
@@ -69,7 +69,7 @@ export default {
};
</script>
<template>
- <div class="issuable-note-warning" data-testid="confidential-warning">
+ <div class="issuable-note-warning">
<gl-icon v-if="!isLockedAndConfidential" :name="warningIcon" :size="16" class="icon inline" />
<span v-if="isLockedAndConfidential" ref="lockedAndConfidential">
diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
index 57b19620c10..5e2483cbcec 100644
--- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
@@ -17,6 +17,7 @@
* />
*/
import { GlAvatarLink, GlAvatar } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { renderMarkdown } from '~/notes/utils';
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 06ca90fa8c6..81cbbf951ad 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -18,6 +18,7 @@
*/
import { GlButton, GlSkeletonLoader, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import $ from 'jquery';
+// eslint-disable-next-line no-restricted-imports
import { mapGetters, mapActions, mapState } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history';
diff --git a/app/assets/javascripts/vue_shared/components/page_size_selector.vue b/app/assets/javascripts/vue_shared/components/page_size_selector.vue
index 9783946b786..5c097220d23 100644
--- a/app/assets/javascripts/vue_shared/components/page_size_selector.vue
+++ b/app/assets/javascripts/vue_shared/components/page_size_selector.vue
@@ -1,37 +1,42 @@
<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { s__, sprintf } from '~/locale';
+import { GlCollapsibleListbox } from '@gitlab/ui';
+import { n__ } from '~/locale';
-export const PAGE_SIZES = [20, 50, 100];
+export const PAGE_SIZES = [20, 50, 100].map((value) => ({
+ value,
+ text: n__('SecurityReports|Show %d item', 'SecurityReports|Show %d items', value),
+}));
export default {
- components: { GlDropdown, GlDropdownItem },
+ components: { GlCollapsibleListbox },
props: {
value: {
type: Number,
required: true,
},
},
+ computed: {
+ selectedItem() {
+ return PAGE_SIZES.find(({ value }) => value === this.value);
+ },
+ toggleText() {
+ return this.selectedItem.text;
+ },
+ },
methods: {
emitInput(pageSize) {
this.$emit('input', pageSize);
},
- getPageSizeText(pageSize) {
- return sprintf(s__('SecurityReports|Show %{pageSize} items'), { pageSize });
- },
},
PAGE_SIZES,
};
</script>
<template>
- <gl-dropdown :text="getPageSizeText(value)" right menu-class="gl-w-auto! gl-min-w-0">
- <gl-dropdown-item
- v-for="pageSize in $options.PAGE_SIZES"
- :key="pageSize"
- @click="emitInput(pageSize)"
- >
- <span class="gl-white-space-nowrap">{{ getPageSizeText(pageSize) }}</span>
- </gl-dropdown-item>
- </gl-dropdown>
+ <gl-collapsible-listbox
+ :toggle-text="toggleText"
+ :items="$options.PAGE_SIZES"
+ :selected="value"
+ @select="emitInput($event)"
+ />
</template>
diff --git a/app/assets/javascripts/vue_shared/components/projects_list/constants.js b/app/assets/javascripts/vue_shared/components/projects_list/constants.js
new file mode 100644
index 00000000000..aa0b1418a06
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/projects_list/constants.js
@@ -0,0 +1,2 @@
+export const ACTION_EDIT = 'edit';
+export const ACTION_DELETE = 'delete';
diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue
index cb8220a0407..3a4da54c84c 100644
--- a/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue
+++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue
@@ -46,6 +46,7 @@ export default {
:key="project.id"
:project="project"
:show-project-icon="showProjectIcon"
+ @delete="$emit('delete', $event)"
/>
</ul>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue
index d919f76e684..9fc4571b0dc 100644
--- a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue
@@ -7,6 +7,7 @@ import {
GlTooltipDirective,
GlPopover,
GlSprintf,
+ GlDisclosureDropdown,
} from '@gitlab/ui';
import uniqueId from 'lodash/uniqueId';
@@ -19,6 +20,8 @@ import { numberToMetricPrefix } from '~/lib/utils/number_utils';
import { truncate } from '~/lib/utils/text_utility';
import SafeHtml from '~/vue_shared/directives/safe_html';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import DeleteModal from '~/projects/components/shared/delete_modal.vue';
+import { ACTION_EDIT, ACTION_DELETE } from './constants';
const MAX_TOPICS_TO_SHOW = 3;
const MAX_TOPIC_TITLE_LENGTH = 15;
@@ -33,6 +36,7 @@ export default {
topicsPopoverTargetText: __('+ %{count} more'),
moreTopics: __('More topics'),
updated: __('Updated'),
+ actions: __('Actions'),
},
avatarSize: { default: 32, md: 48 },
safeHtmlConfig: {
@@ -47,6 +51,8 @@ export default {
GlPopover,
GlSprintf,
TimeAgoTooltip,
+ GlDisclosureDropdown,
+ DeleteModal,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -73,6 +79,9 @@ export default {
* };
* descriptionHtml: string;
* updatedAt: string;
+ * isForked: boolean;
+ * actions?: ('edit' | 'delete')[];
+ * editPath?: string;
* }
*/
project: {
@@ -88,6 +97,7 @@ export default {
data() {
return {
topicsPopoverTarget: uniqueId('project-topics-popover-'),
+ isDeleteModalVisible: false,
};
},
computed: {
@@ -136,9 +146,50 @@ export default {
popoverTopics() {
return this.project.topics.slice(MAX_TOPICS_TO_SHOW);
},
+ starCount() {
+ return numberToMetricPrefix(this.project.starCount);
+ },
+ forksCount() {
+ if (!this.isForkingEnabled) {
+ return null;
+ }
+
+ return numberToMetricPrefix(this.project.forksCount);
+ },
+ openIssuesCount() {
+ if (!this.isIssuesEnabled) {
+ return null;
+ }
+
+ return numberToMetricPrefix(this.project.openIssuesCount);
+ },
+ actionsDropdownItems() {
+ return [
+ {
+ id: ACTION_EDIT,
+ text: __('Edit'),
+ href: this.project.editPath,
+ },
+ {
+ id: ACTION_DELETE,
+ text: __('Delete'),
+ extraAttrs: {
+ class: 'gl-text-red-500!',
+ },
+ action: () => {
+ this.isDeleteModalVisible = true;
+ },
+ },
+ ].filter(({ id }) => this.project.actions?.includes(id));
+ },
+ hasActions() {
+ return this.actionsDropdownItems.length;
+ },
+ hasDeleteAction() {
+ return this.actionsDropdownItems.find((action) => action.id === ACTION_DELETE);
+ },
},
methods: {
- numberToMetricPrefix,
topicPath(topic) {
return `/explore/projects/topics/${encodeURIComponent(topic)}`;
},
@@ -158,128 +209,154 @@ export default {
</script>
<template>
- <li class="projects-list-item gl-py-5 gl-md-display-flex gl-align-items-center gl-border-b">
- <div class="gl-display-flex gl-flex-grow-1">
- <gl-icon
- v-if="showProjectIcon"
- class="gl-mr-3 gl-mt-3 gl-md-mt-5 gl-flex-shrink-0 gl-text-secondary"
- name="project"
- />
- <gl-avatar-labeled
- :entity-id="project.id"
- :entity-name="project.name"
- :label="project.name"
- :label-link="project.webUrl"
- shape="rect"
- :size="$options.avatarSize"
- >
- <template #meta>
- <div class="gl-px-2">
- <div class="gl-mx-n2 gl-display-flex gl-align-items-center gl-flex-wrap">
- <div class="gl-px-2">
- <gl-icon
- v-if="visibility"
- v-gl-tooltip="visibilityTooltip"
- :name="visibilityIcon"
- class="gl-text-secondary"
- />
- </div>
- <div class="gl-px-2">
- <user-access-role-badge v-if="shouldShowAccessLevel">{{
- accessLevelLabel
- }}</user-access-role-badge>
+ <li class="projects-list-item gl-py-5 gl-border-b gl-display-flex gl-align-items-flex-start">
+ <div class="gl-md-display-flex gl-align-items-center gl-flex-grow-1">
+ <div class="gl-display-flex gl-flex-grow-1">
+ <gl-icon
+ v-if="showProjectIcon"
+ class="gl-mr-3 gl-mt-3 gl-md-mt-5 gl-flex-shrink-0 gl-text-secondary"
+ name="project"
+ />
+ <gl-avatar-labeled
+ :entity-id="project.id"
+ :entity-name="project.name"
+ :label="project.name"
+ :label-link="project.webUrl"
+ shape="rect"
+ :size="$options.avatarSize"
+ >
+ <template #meta>
+ <div class="gl-px-2">
+ <div class="gl-mx-n2 gl-display-flex gl-align-items-center gl-flex-wrap">
+ <div class="gl-px-2">
+ <gl-icon
+ v-if="visibility"
+ v-gl-tooltip="visibilityTooltip"
+ :name="visibilityIcon"
+ class="gl-text-secondary"
+ />
+ </div>
+ <div class="gl-px-2">
+ <user-access-role-badge v-if="shouldShowAccessLevel">{{
+ accessLevelLabel
+ }}</user-access-role-badge>
+ </div>
</div>
</div>
- </div>
- </template>
- <div
- v-if="project.descriptionHtml"
- v-safe-html:[$options.safeHtmlConfig]="project.descriptionHtml"
- class="gl-font-sm gl-overflow-hidden gl-line-height-20 description md"
- data-testid="project-description"
- ></div>
- <div v-if="hasTopics" class="gl-mt-3" data-testid="project-topics">
+ </template>
<div
- class="gl-w-full gl-display-inline-flex gl-flex-wrap gl-font-base gl-font-weight-normal gl-align-items-center gl-mx-n2 gl-my-n2"
- >
- <span class="gl-p-2 gl-text-secondary">{{ $options.i18n.topics }}:</span>
- <div v-for="topic in visibleTopics" :key="topic" class="gl-p-2">
- <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)">
- {{ topicTitle(topic) }}
- </gl-badge>
- </div>
- <template v-if="popoverTopics.length">
- <div
- :id="topicsPopoverTarget"
- class="gl-p-2 gl-text-secondary"
- role="button"
- tabindex="0"
- >
- <gl-sprintf :message="$options.i18n.topicsPopoverTargetText">
- <template #count>{{ popoverTopics.length }}</template>
- </gl-sprintf>
+ v-if="project.descriptionHtml"
+ v-safe-html:[$options.safeHtmlConfig]="project.descriptionHtml"
+ class="gl-font-sm gl-overflow-hidden gl-line-height-20 description md"
+ data-testid="project-description"
+ ></div>
+ <div v-if="hasTopics" class="gl-mt-3" data-testid="project-topics">
+ <div
+ class="gl-w-full gl-display-inline-flex gl-flex-wrap gl-font-base gl-font-weight-normal gl-align-items-center gl-mx-n2 gl-my-n2"
+ >
+ <span class="gl-p-2 gl-text-secondary">{{ $options.i18n.topics }}:</span>
+ <div v-for="topic in visibleTopics" :key="topic" class="gl-p-2">
+ <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)">
+ {{ topicTitle(topic) }}
+ </gl-badge>
</div>
- <gl-popover :target="topicsPopoverTarget" :title="$options.i18n.moreTopics">
- <div class="gl-font-base gl-font-weight-normal gl-mx-n2 gl-my-n2">
- <div
- v-for="topic in popoverTopics"
- :key="topic"
- class="gl-p-2 gl-display-inline-block"
- >
- <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)">
- {{ topicTitle(topic) }}
- </gl-badge>
- </div>
+ <template v-if="popoverTopics.length">
+ <div
+ :id="topicsPopoverTarget"
+ class="gl-p-2 gl-text-secondary"
+ role="button"
+ tabindex="0"
+ >
+ <gl-sprintf :message="$options.i18n.topicsPopoverTargetText">
+ <template #count>{{ popoverTopics.length }}</template>
+ </gl-sprintf>
</div>
- </gl-popover>
- </template>
+ <gl-popover :target="topicsPopoverTarget" :title="$options.i18n.moreTopics">
+ <div class="gl-font-base gl-font-weight-normal gl-mx-n2 gl-my-n2">
+ <div
+ v-for="topic in popoverTopics"
+ :key="topic"
+ class="gl-p-2 gl-display-inline-block"
+ >
+ <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)">
+ {{ topicTitle(topic) }}
+ </gl-badge>
+ </div>
+ </div>
+ </gl-popover>
+ </template>
+ </div>
</div>
- </div>
- </gl-avatar-labeled>
- </div>
- <div
- class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-md-pl-0 gl-md-mt-0"
- :class="showProjectIcon ? 'gl-pl-11' : 'gl-pl-8'"
- >
- <div class="gl-display-flex gl-align-items-center gl-gap-x-3">
- <gl-badge v-if="project.archived" variant="warning">{{ $options.i18n.archived }}</gl-badge>
- <gl-link
- v-gl-tooltip="$options.i18n.stars"
- :href="starsHref"
- :aria-label="$options.i18n.stars"
- class="gl-text-secondary"
- >
- <gl-icon name="star-o" />
- <span>{{ numberToMetricPrefix(project.starCount) }}</span>
- </gl-link>
- <gl-link
- v-if="isForkingEnabled"
- v-gl-tooltip="$options.i18n.forks"
- :href="forksHref"
- :aria-label="$options.i18n.forks"
- class="gl-text-secondary"
- >
- <gl-icon name="fork" />
- <span>{{ numberToMetricPrefix(project.forksCount) }}</span>
- </gl-link>
- <gl-link
- v-if="isIssuesEnabled"
- v-gl-tooltip="$options.i18n.issues"
- :href="issuesHref"
- :aria-label="$options.i18n.issues"
- class="gl-text-secondary"
- >
- <gl-icon name="issues" />
- <span>{{ numberToMetricPrefix(project.openIssuesCount) }}</span>
- </gl-link>
+ </gl-avatar-labeled>
</div>
<div
- v-if="project.updatedAt"
- class="gl-font-sm gl-white-space-nowrap gl-text-secondary gl-mt-3"
+ class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-md-pl-0 gl-md-mt-0"
+ :class="showProjectIcon ? 'gl-pl-11' : 'gl-pl-8'"
>
- <span>{{ $options.i18n.updated }}</span>
- <time-ago-tooltip :time="project.updatedAt" />
+ <div class="gl-display-flex gl-align-items-center gl-gap-x-3">
+ <gl-badge v-if="project.archived" variant="warning">{{
+ $options.i18n.archived
+ }}</gl-badge>
+ <gl-link
+ v-gl-tooltip="$options.i18n.stars"
+ :href="starsHref"
+ :aria-label="$options.i18n.stars"
+ class="gl-text-secondary"
+ >
+ <gl-icon name="star-o" />
+ <span>{{ starCount }}</span>
+ </gl-link>
+ <gl-link
+ v-if="isForkingEnabled"
+ v-gl-tooltip="$options.i18n.forks"
+ :href="forksHref"
+ :aria-label="$options.i18n.forks"
+ class="gl-text-secondary"
+ >
+ <gl-icon name="fork" />
+ <span>{{ forksCount }}</span>
+ </gl-link>
+ <gl-link
+ v-if="isIssuesEnabled"
+ v-gl-tooltip="$options.i18n.issues"
+ :href="issuesHref"
+ :aria-label="$options.i18n.issues"
+ class="gl-text-secondary"
+ >
+ <gl-icon name="issues" />
+ <span>{{ openIssuesCount }}</span>
+ </gl-link>
+ </div>
+ <div
+ v-if="project.updatedAt"
+ class="gl-font-sm gl-white-space-nowrap gl-text-secondary gl-mt-3"
+ >
+ <span>{{ $options.i18n.updated }}</span>
+ <time-ago-tooltip :time="project.updatedAt" />
+ </div>
</div>
</div>
+ <gl-disclosure-dropdown
+ v-if="hasActions"
+ class="gl-ml-3 gl-md-align-self-center"
+ :items="actionsDropdownItems"
+ icon="ellipsis_v"
+ no-caret
+ :toggle-text="$options.i18n.actions"
+ text-sr-only
+ placement="right"
+ category="tertiary"
+ />
+
+ <delete-modal
+ v-if="hasDeleteAction"
+ v-model="isDeleteModalVisible"
+ :confirm-phrase="project.name"
+ :is-fork="project.isForked"
+ :issues-count="openIssuesCount"
+ :forks-count="forksCount"
+ :stars-count="starCount"
+ @primary="$emit('delete', project)"
+ />
</li>
</template>
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 32d7cdad568..3404423b5bb 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
@@ -1,12 +1,11 @@
<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlCollapsibleListbox } from '@gitlab/ui';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
export default {
name: 'PersistedDropdownSelection',
components: {
- GlDropdown,
- GlDropdownItem,
+ GlCollapsibleListbox,
LocalStorageSync,
},
props: {
@@ -21,16 +20,15 @@ export default {
},
data() {
return {
- selected: null,
+ selected: this.options[0].value,
};
},
computed: {
- dropdownText() {
- const selected = this.parsedOptions.find((o) => o.selected);
- return selected?.label || this.options[0].label;
- },
- parsedOptions() {
- return this.options.map((o) => ({ ...o, selected: o.value === this.selected }));
+ listboxItems() {
+ return this.options.map((option) => ({
+ value: option.value,
+ text: option.label,
+ }));
},
},
methods: {
@@ -44,16 +42,6 @@ export default {
<template>
<local-storage-sync :storage-key="storageKey" :value="selected" as-string @input="setSelected">
- <gl-dropdown :text="dropdownText" lazy>
- <gl-dropdown-item
- v-for="option in parsedOptions"
- :key="option.value"
- :is-checked="option.selected"
- is-check-item
- @click="setSelected(option.value)"
- >
- {{ option.label }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <gl-collapsible-listbox v-model="selected" :items="listboxItems" @select="setSelected" />
</local-storage-sync>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue
index 730e9e1c6cc..e41cd344b3f 100644
--- a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue
@@ -60,7 +60,13 @@ export default {
methods: {
generateQueryData({ sorting = {}, filter = [] } = {}) {
// Ensure that we clean up the query when we remove a token from the search
- const result = { ...this.baselineQueryStringFilters, ...sorting, search: [] };
+ const result = {
+ ...this.baselineQueryStringFilters,
+ ...sorting,
+ search: [],
+ after: null,
+ before: null,
+ };
filter.forEach((f) => {
if (f.type === FILTERED_SEARCH_TERM) {
diff --git a/app/assets/javascripts/vue_shared/components/registry/title_area.vue b/app/assets/javascripts/vue_shared/components/registry/title_area.vue
index ad979387596..2db56343210 100644
--- a/app/assets/javascripts/vue_shared/components/registry/title_area.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/title_area.vue
@@ -38,7 +38,7 @@ export default {
metadataSlots: [],
};
},
- mounted() {
+ created() {
this.recalculateMetadataSlots();
},
updated() {
@@ -47,8 +47,9 @@ export default {
methods: {
recalculateMetadataSlots() {
const METADATA_PREFIX = 'metadata-';
- // eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots
- const metadataSlots = Object.keys(this.$slots).filter((k) => k.startsWith(METADATA_PREFIX));
+ const metadataSlots = Object.keys(this.$scopedSlots).filter((k) =>
+ k.startsWith(METADATA_PREFIX),
+ );
if (!isEqual(metadataSlots, this.metadataSlots)) {
this.metadataSlots = metadataSlots;
@@ -77,9 +78,7 @@ export default {
</h2>
<div
- v-if="
- $slots['sub-header'] /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */
- "
+ v-if="$scopedSlots['sub-header']"
class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-3"
>
<slot name="sub-header"></slot>
@@ -110,8 +109,7 @@ export default {
</template>
</div>
</div>
- <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots -->
- <div v-if="$slots['right-actions']" class="gl-mt-3">
+ <div v-if="$scopedSlots['right-actions']" class="gl-mt-3">
<slot name="right-actions"></slot>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
index 28a16cd846a..bab5e5ff3a7 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlIntersectionObserver } from '@gitlab/ui';
import LineHighlighter from '~/blob/line_highlighter';
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 6c49a601401..582093e5739 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
@@ -97,6 +97,7 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = {
sql: 'sql',
stan: 'stan',
stata: 'stata',
+ svelte: 'svelte',
swift: 'swift',
tap: 'tap',
tcl: 'tcl',
@@ -151,3 +152,5 @@ export const LEGACY_FALLBACKS = ['python', 'haml'];
export const CODEOWNERS_FILE_NAME = 'CODEOWNERS';
export const CODEOWNERS_LANGUAGE = 'codeowners';
+
+export const SVELTE_LANGUAGE = 'svelte';
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/languages/svelte.js b/app/assets/javascripts/vue_shared/components/source_viewer/languages/svelte.js
new file mode 100644
index 00000000000..df92bdf87db
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/languages/svelte.js
@@ -0,0 +1,81 @@
+/*
+Language: Svelte.js
+Requires: xml, javascript, typescript, css, scss
+Description: Components of Svelte Framework
+*/
+
+export default (hljs) => {
+ return {
+ subLanguage: 'xml',
+ contains: [
+ hljs.COMMENT('<!--', '-->', {
+ relevance: 11,
+ }),
+ {
+ begin: /^(\s*)(<script.*(lang="ts").*>)/gm,
+ end: /^(\s*)(<\/script>)/gm,
+ subLanguage: 'typescript',
+ excludeBegin: true,
+ excludeEnd: true,
+ relevance: 20,
+ contains: [
+ // special svelte $ syntax
+ {
+ begin: /^(\s*)(\$:)/gm,
+ end: /(\s*)/gm,
+ className: 'keyword',
+ },
+ ],
+ },
+ {
+ begin: /^(\s*)(<script(\s*context="module")?.*>)/gm,
+ end: /^(\s*)(<\/script>)/gm,
+ subLanguage: 'javascript',
+ excludeBegin: true,
+ excludeEnd: true,
+ relevance: 15,
+ contains: [
+ // special svelte $ syntax
+ {
+ begin: /^(\s*)(\$:)/gm,
+ end: /(\s*)/gm,
+ className: 'keyword',
+ },
+ ],
+ },
+ {
+ begin: /^(\s*)(<style.*(lang="scss"|type="text\/scss").*>)/gm,
+ end: /^(\s*)(<\/style>)/gm,
+ subLanguage: 'scss',
+ excludeBegin: true,
+ excludeEnd: true,
+ relevance: 20,
+ },
+ {
+ begin: /^(\s*)(<style.*>)/gm,
+ end: /^(\s*)(<\/style>)/gm,
+ subLanguage: 'css',
+ excludeBegin: true,
+ excludeEnd: true,
+ relevance: 15,
+ },
+ {
+ begin: /\{/gm,
+ end: /}/gm,
+ subLanguage: 'javascript',
+ contains: [
+ {
+ begin: /[{]/,
+ end: /[}]/,
+ skip: true,
+ },
+ {
+ begin: /([#:/@])(if|else|each|await|then|catch|debug|html)/gm,
+ className: 'keyword',
+ relevance: 10,
+ },
+ ],
+ },
+ ],
+ };
+};
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 9dc6dc1b93a..a4d50466f8f 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
@@ -5,6 +5,7 @@ import eventHub from '~/notes/event_hub';
import languageLoader from '~/content_editor/services/highlight_js_language_loader';
import addBlobLinksTracking from '~/blob/blob_links_tracking';
import Tracking from '~/tracking';
+import axios from '~/lib/utils/axios_utils';
import {
EVENT_ACTION,
EVENT_LABEL_VIEWER,
@@ -14,6 +15,7 @@ import {
LEGACY_FALLBACKS,
CODEOWNERS_FILE_NAME,
CODEOWNERS_LANGUAGE,
+ SVELTE_LANGUAGE,
} from './constants';
import Chunk from './components/chunk.vue';
import { registerPlugins } from './plugins/index';
@@ -52,13 +54,24 @@ export default {
};
},
computed: {
+ isLfsBlob() {
+ const { storedExternally, externalStorage, simpleViewer } = this.blob;
+
+ return storedExternally && externalStorage === 'lfs' && simpleViewer?.fileType === 'text';
+ },
splitContent() {
return this.content.split(/\r?\n/);
},
language() {
- return this.blob.name === this.$options.codeownersFileName
- ? this.$options.codeownersLanguage
- : ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language?.toLowerCase()];
+ if (this.blob.name && this.blob.name.endsWith(`.${SVELTE_LANGUAGE}`)) {
+ // override for svelte files until https://github.com/rouge-ruby/rouge/issues/1717 is resolved
+ return SVELTE_LANGUAGE;
+ } else if (this.blob.name === this.$options.codeownersFileName) {
+ // override for codeowners files
+ return this.$options.codeownersLanguage;
+ }
+
+ return ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language?.toLowerCase()];
},
lineNumbers() {
return this.splitContent.length;
@@ -76,6 +89,15 @@ export default {
},
},
async created() {
+ if (this.isLfsBlob) {
+ await axios
+ .get(this.blob.externalStorageUrl || this.blob.rawPath)
+ .then((result) => {
+ this.content = result.data;
+ })
+ .catch(() => this.$emit('error'));
+ }
+
addBlobLinksTracking();
this.trackEvent(EVENT_LABEL_VIEWER);
@@ -168,12 +190,36 @@ export default {
// If no language can be mapped to highlight.js we load all common languages else we load only the core (smallest footprint)
return !this.language ? import('highlight.js/lib/common') : import('highlight.js/lib/core');
},
+ async loadSubLanguages(languageDefinition) {
+ if (!languageDefinition?.contains) return;
+
+ // generate list of languages to load
+ const languages = new Set(
+ languageDefinition.contains
+ .filter((component) => Boolean(component.subLanguage))
+ .map((component) => component.subLanguage),
+ );
+
+ if (languageDefinition.subLanguage) {
+ languages.add(languageDefinition.subLanguage);
+ }
+
+ // load all sub-languages at once
+ await Promise.all(
+ [...languages].map(async (subLanguage) => {
+ const subLanguageDefinition = await languageLoader[subLanguage]();
+ this.hljs.registerLanguage(subLanguage, subLanguageDefinition.default);
+ }),
+ );
+ },
async loadLanguage() {
let languageDefinition;
try {
languageDefinition = await languageLoader[this.language]();
this.hljs.registerLanguage(this.language, languageDefinition.default);
+
+ await this.loadSubLanguages(this.hljs.getLanguage(this.language));
} catch (message) {
this.$emit('error', message);
}
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue
index 8e4c438719e..0fb6e577f32 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue
@@ -40,6 +40,10 @@ export default {
this.track(EVENT_ACTION, { label: EVENT_LABEL_VIEWER, property: this.blob.language });
addBlobLinksTracking();
},
+ mounted() {
+ const { hash } = this.$route;
+ this.lineHighlighter.highlightHash(hash);
+ },
userColorScheme: window.gon.user_color_scheme,
};
</script>
diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
index 9a06c0ecf30..79d14b5f2d0 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -1,8 +1,15 @@
<script>
-import { GlModal, GlSprintf, GlLink } from '@gitlab/ui';
+import {
+ GlModal,
+ GlSprintf,
+ GlLink,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+} from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { visitUrl } from '~/lib/utils/url_utility';
-import ActionsButton from '~/vue_shared/components/actions_button.vue';
+import Tracking from '~/tracking';
import ConfirmForkModal from '~/vue_shared/components/web_ide/confirm_fork_modal.vue';
import { KEY_EDIT, KEY_WEB_IDE, KEY_GITPOD, KEY_PIPELINE_EDITOR } from './constants';
@@ -22,16 +29,21 @@ export const i18n = {
toggleText: __('Edit'),
};
+const TRACKING_ACTION_NAME = 'click_consolidated_edit';
+
export default {
name: 'CEWebIdeLink',
components: {
- ActionsButton,
GlModal,
GlSprintf,
GlLink,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
ConfirmForkModal,
},
i18n,
+ mixins: [Tracking.mixin()],
props: {
isFork: {
type: Boolean,
@@ -173,10 +185,9 @@ export default {
key: KEY_EDIT,
text: __('Edit single file'),
secondaryText: __('Edit this file only.'),
- attrs: {
- 'data-qa-selector': 'edit_button',
- 'data-track-action': 'click_consolidated_edit',
- 'data-track-label': 'edit',
+ tracking: {
+ action: TRACKING_ACTION_NAME,
+ label: 'single_file',
},
...handleOptions,
};
@@ -216,10 +227,9 @@ export default {
key: KEY_WEB_IDE,
text: this.webIdeActionText,
secondaryText: this.$options.i18n.webIdeText,
- attrs: {
- 'data-qa-selector': 'web_ide_button',
- 'data-track-action': 'click_consolidated_edit_ide',
- 'data-track-label': 'web_ide',
+ tracking: {
+ action: TRACKING_ACTION_NAME,
+ label: 'web_ide',
},
...handleOptions,
};
@@ -246,10 +256,11 @@ export default {
key: KEY_PIPELINE_EDITOR,
text: __('Edit in pipeline editor'),
secondaryText,
- attrs: {
- 'data-qa-selector': 'pipeline_editor_button',
- },
href: this.pipelineEditorUrl,
+ tracking: {
+ action: TRACKING_ACTION_NAME,
+ label: 'pipeline_editor',
+ },
};
},
gitpodAction() {
@@ -270,8 +281,9 @@ export default {
key: KEY_GITPOD,
text: this.gitpodActionText,
secondaryText,
- attrs: {
- 'data-qa-selector': 'gitpod_button',
+ tracking: {
+ action: TRACKING_ACTION_NAME,
+ label: 'gitpod',
},
...handleOptions,
};
@@ -306,25 +318,50 @@ export default {
showModal(dataKey) {
this[dataKey] = true;
},
+ executeAction(action) {
+ this.track(action.tracking.action, { label: action.tracking.label });
+ action.handle?.();
+ },
},
- webIdeButtonId: 'web-ide-link',
};
</script>
<template>
<div class="gl-sm-ml-3">
- <actions-button
+ <gl-disclosure-dropdown
v-if="hasActions"
- :id="$options.webIdeButtonId"
- :actions="actions"
- :toggle-text="$options.i18n.toggleText"
:variant="isBlob ? 'confirm' : 'default'"
:category="isBlob ? 'primary' : 'secondary'"
- @hidden="$emit('hidden')"
+ :toggle-text="$options.i18n.toggleText"
+ data-qa-selector="action_dropdown"
+ fluid-width
+ block
@shown="$emit('shown')"
+ @hidden="$emit('hidden')"
>
- <slot></slot>
- </actions-button>
+ <slot name="before-actions"></slot>
+ <gl-disclosure-dropdown-group class="edit-dropdown-group-width">
+ <gl-disclosure-dropdown-item
+ v-for="action in actions"
+ :key="action.key"
+ :item="action"
+ :data-qa-selector="`${action.key}_menu_item`"
+ @action="executeAction(action)"
+ >
+ <template #list-item>
+ <div class="gl-display-flex gl-flex-direction-column">
+ <span data-testid="action-primary-text" class="gl-font-weight-bold gl-mb-2">{{
+ action.text
+ }}</span>
+ <span data-testid="action-secondary-text" class="gl-text-gray-700">
+ {{ action.secondaryText }}
+ </span>
+ </div>
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown-group>
+ <slot name="after-actions"></slot>
+ </gl-disclosure-dropdown>
<gl-modal
v-if="computedShowGitpodButton && !gitpodEnabled"
v-model="showEnableGitpodModal"
diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js
index 8946a02e663..d9bc2c82688 100644
--- a/app/assets/javascripts/vue_shared/constants.js
+++ b/app/assets/javascripts/vue_shared/constants.js
@@ -86,7 +86,7 @@ export const confidentialityInfoText = (workspaceType, issuableType) =>
),
{
workspaceType: workspaceType === WORKSPACE_PROJECT ? __('project') : __('group'),
- issuableType: issuableType === TYPE_ISSUE ? __('issue') : __('epic'),
+ issuableType: issuableType.toLowerCase(),
permissions:
issuableType === TYPE_ISSUE
? __('at least the Reporter role, the author, and assignees')
diff --git a/app/assets/javascripts/vue_shared/global_search/constants.js b/app/assets/javascripts/vue_shared/global_search/constants.js
index a693d4f114d..43110c0c9af 100644
--- a/app/assets/javascripts/vue_shared/global_search/constants.js
+++ b/app/assets/javascripts/vue_shared/global_search/constants.js
@@ -6,6 +6,7 @@ export const AUTOCOMPLETE_ERROR_MESSAGE = s__(
export const ALL_GITLAB = __('All GitLab');
export const SEARCH_GITLAB = s__('GlobalSearch|Search GitLab');
+export const PLACES = s__('GlobalSearch|Places');
export const SEARCH_DESCRIBED_BY_DEFAULT = s__(
'GlobalSearch|%{count} default results provided. Use the up and down arrow keys to navigate search results list.',
diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue
index a19b568801d..699b41f3bf3 100644
--- a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue
+++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue
@@ -1,8 +1,9 @@
<script>
import { GlForm, GlFormInput, GlFormGroup } from '@gitlab/ui';
-import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import LabelsSelect from '~/sidebar/components/labels/labels_select_vue/labels_select_root.vue';
import { VARIANT_EMBEDDED } from '~/sidebar/components/labels/labels_select_widget/constants';
+import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
+import { __ } from '~/locale';
export default {
VARIANT_EMBEDDED,
@@ -10,7 +11,7 @@ export default {
GlForm,
GlFormInput,
GlFormGroup,
- MarkdownField,
+ MarkdownEditor,
LabelsSelect,
},
props: {
@@ -31,6 +32,14 @@ export default {
required: true,
},
},
+ descriptionFormFieldProps: {
+ ariaLabel: __('Description'),
+ class: 'rspec-issuable-form-description',
+ placeholder: __('Write a comment or drag your files here…'),
+ dataQaSelector: 'issuable_form_description_field',
+ id: 'issuable-description',
+ name: 'issuable-description',
+ },
data() {
return {
issuableTitle: '',
@@ -68,26 +77,12 @@ export default {
<div data-testid="issuable-description" class="form-group row">
<label for="issuable-description" class="col-12">{{ __('Description') }}</label>
<div class="col-12">
- <markdown-field
- :markdown-preview-path="descriptionPreviewPath"
+ <markdown-editor
+ v-model="issuableDescription"
+ :render-markdown-path="descriptionPreviewPath"
:markdown-docs-path="descriptionHelpPath"
- :add-spacing-classes="false"
- :show-suggest-popover="true"
- :textarea-value="issuableDescription"
- >
- <template #textarea>
- <textarea
- id="issuable-description"
- ref="textarea"
- v-model="issuableDescription"
- dir="auto"
- class="note-textarea rspec-issuable-form-description js-gfm-input js-autosize markdown-area"
- data-qa-selector="issuable_form_description_field"
- :aria-label="__('Description')"
- :placeholder="__('Write a comment or drag your files here…')"
- ></textarea>
- </template>
- </markdown-field>
+ :form-field-props="$options.descriptionFormFieldProps"
+ />
</div>
</div>
<div data-testid="issuable-labels" class="form-group row">
diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue
index efb6e626c07..b4287d86289 100644
--- a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue
+++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue
@@ -47,11 +47,11 @@ export default {
<template>
<gl-form-group class="row" label-class="gl-display-none">
- <label class="col-12 gl-display-flex gl-align-center gl-mb-1">
+ <label class="col-12 gl-display-flex gl-align-center">
{{ $options.i18n.fieldLabel }}
</label>
<div class="col-12">
- <div class="issuable-form-select-holder">
+ <div class="issuable-form-label-select-holder">
<input
v-for="selectedLabel in selectedLabels"
:key="selectedLabel.id"
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 ce33d7a9b4b..31dd49ca415 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
@@ -7,8 +7,10 @@ import { isScopedLabel } from '~/lib/utils/common_utils';
import { isExternal, setUrlFragment } from '~/lib/utils/url_utility';
import { __, n__, sprintf } from '~/locale';
import IssuableAssignees from '~/issuable/components/issue_assignees.vue';
-import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
+import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
+import { STATE_CLOSED } from '~/work_items/constants';
+import { isAssigneesWidget, isLabelsWidget } from '~/work_items/utils';
export default {
components: {
@@ -57,6 +59,16 @@ export default {
required: false,
default: false,
},
+ isActive: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ preventRedirect: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
issuableId() {
@@ -80,26 +92,41 @@ export default {
reference() {
return this.issuable.reference || `${this.issuableSymbol}${this.issuable.iid}`;
},
+ type() {
+ return this.issuable.type || this.issuable.workItemType?.name.toUpperCase();
+ },
labels() {
- return this.issuable.labels?.nodes || this.issuable.labels || [];
+ return (
+ this.issuable.labels?.nodes ||
+ this.issuable.labels ||
+ this.issuable.widgets?.find(isLabelsWidget)?.labels.nodes ||
+ []
+ );
},
labelIdsString() {
return JSON.stringify(this.labels.map((label) => getIdFromGraphQLId(label.id)));
},
assignees() {
- return this.issuable.assignees?.nodes || this.issuable.assignees || [];
+ return (
+ this.issuable.assignees?.nodes ||
+ this.issuable.assignees ||
+ this.issuable.widgets?.find(isAssigneesWidget)?.assignees.nodes ||
+ []
+ );
},
createdAt() {
return this.timeFormatted(this.issuable.createdAt);
},
+ isClosed() {
+ return this.issuable.state === STATUS_CLOSED || this.issuable.state === STATE_CLOSED;
+ },
timestamp() {
- if (this.issuable.state === STATUS_CLOSED && this.issuable.closedAt) {
- return this.issuable.closedAt;
- }
- return this.issuable.updatedAt;
+ return this.isClosed && this.issuable.closedAt
+ ? this.issuable.closedAt
+ : this.issuable.updatedAt;
},
formattedTimestamp() {
- if (this.issuable.state === STATUS_CLOSED && this.issuable.closedAt) {
+ if (this.isClosed && this.issuable.closedAt) {
return sprintf(__('closed %{timeago}'), {
timeago: this.timeFormatted(this.issuable.closedAt),
});
@@ -157,7 +184,10 @@ export default {
return Boolean(this.$slots[slotName]);
},
scopedLabel(label) {
- return this.hasScopedLabelsFeature && isScopedLabel(label);
+ const allowsScopedLabels =
+ this.hasScopedLabelsFeature ||
+ this.issuable.widgets?.find(isLabelsWidget)?.allowsScopedLabels;
+ return allowsScopedLabels && isScopedLabel(label);
},
labelTitle(label) {
return label.title || label.name;
@@ -177,6 +207,13 @@ export default {
}
return '';
},
+ handleIssuableItemClick(e) {
+ if (e.metaKey || e.ctrlKey || !this.preventRedirect) {
+ return;
+ }
+ e.preventDefault();
+ this.$emit('select-issuable', { iid: this.issuableIid, webUrl: this.webUrl });
+ },
},
};
</script>
@@ -185,9 +222,10 @@ export default {
<li
:id="`issuable_${issuableId}`"
class="issue gl-display-flex! gl-px-5!"
- :class="{ closed: issuable.closedAt }"
+ :class="{ closed: issuable.closedAt, 'gl-bg-blue-50': isActive }"
:data-labels="labelIdsString"
:data-qa-issue-id="issuableId"
+ data-testid="issuable-item-wrapper"
>
<gl-form-checkbox
v-if="showCheckbox"
@@ -195,7 +233,7 @@ export default {
:checked="checked"
:data-id="issuableId"
:data-iid="issuableIid"
- :data-type="issuable.type"
+ :data-type="type"
@input="$emit('checked-input', $event)"
>
<span class="gl-sr-only">{{ issuable.title }}</span>
@@ -204,7 +242,7 @@ export default {
<div data-testid="issuable-title" class="issue-title title">
<work-item-type-icon
v-if="showWorkItemTypeIcon"
- :work-item-type="issuable.type"
+ :work-item-type="type"
show-tooltip-on-hover
/>
<gl-icon
@@ -226,7 +264,9 @@ export default {
dir="auto"
:href="webUrl"
data-qa-selector="issuable_title_link"
+ data-testid="issuable-title-link"
v-bind="issuableTitleProps"
+ @click="handleIssuableItemClick"
>
{{ issuable.title }}
<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" />
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
index 4023337a1cb..7a9404e06c7 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
@@ -203,6 +203,16 @@ export default {
required: false,
default: false,
},
+ activeIssuable: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ preventRedirect: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -299,6 +309,9 @@ export default {
handlePageSizeChange(newPageSize) {
this.$emit('page-size-change', newPageSize);
},
+ isIssuableActive(issuable) {
+ return Boolean(issuable.iid === this.activeIssuable?.iid);
+ },
},
PAGE_SIZE_STORAGE_KEY,
};
@@ -373,7 +386,10 @@ export default {
:show-checkbox="showBulkEditSidebar"
:checked="issuableChecked(issuable)"
:show-work-item-type-icon="showWorkItemTypeIcon"
+ :prevent-redirect="preventRedirect"
+ :is-active="isIssuableActive(issuable)"
@checked-input="handleIssuableCheckedInput(issuable, $event)"
+ @select-issuable="$emit('select-issuable', $event)"
>
<template #reference>
<slot name="reference" :issuable="issuable"></slot>
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue
index 387fc5e0d1c..7c3dd5c3623 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue
@@ -130,7 +130,6 @@ export default {
:label="__('Description')"
:label-sr-only="!showFieldTitle"
label-for="issuable-description"
- label-class="gl-pb-0!"
class="col-12 gl-px-0 common-note-form"
>
<markdown-field
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
index 53e976d698b..29aef89a991 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
@@ -1,17 +1,9 @@
<script>
-import {
- GlIcon,
- GlBadge,
- GlButton,
- GlTooltipDirective,
- GlAvatarLink,
- GlAvatarLabeled,
-} from '@gitlab/ui';
-
+import { GlIcon, GlBadge, GlButton, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { issuableStatusText, STATUS_OPEN } from '~/issues/constants';
+import { issuableStatusText, STATUS_OPEN, STATUS_REOPENED } from '~/issues/constants';
import { isExternal } from '~/lib/utils/url_utility';
-import { n__, sprintf } from '~/locale';
+import { __, n__, sprintf } from '~/locale';
import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
@@ -22,8 +14,8 @@ export default {
GlIcon,
GlBadge,
GlButton,
- GlAvatarLink,
- GlAvatarLabeled,
+ GlLink,
+ GlSprintf,
TimeAgoTooltip,
WorkItemTypeIcon,
},
@@ -64,21 +56,36 @@ export default {
required: false,
default: false,
},
- taskCompletionStatus: {
- type: Object,
+ isFirstContribution: {
+ type: Boolean,
required: false,
- default: null,
+ default: false,
+ },
+ isHidden: {
+ type: Boolean,
+ required: false,
+ default: false,
},
issuableType: {
type: String,
required: false,
default: '',
},
+ serviceDeskReplyTo: {
+ type: String,
+ required: false,
+ default: '',
+ },
showWorkItemTypeIcon: {
type: Boolean,
required: false,
default: false,
},
+ taskCompletionStatus: {
+ type: Object,
+ required: false,
+ default: null,
+ },
workspaceType: {
type: String,
required: false,
@@ -90,13 +97,38 @@ export default {
return issuableStatusText[this.issuableState];
},
badgeVariant() {
- return this.issuableState === STATUS_OPEN ? 'success' : 'info';
+ return this.issuableState === STATUS_OPEN || this.issuableState === STATUS_REOPENED
+ ? 'success'
+ : 'info';
+ },
+ blockedTooltip() {
+ return sprintf(__('This %{issuable} is locked. Only project members can comment.'), {
+ issuable: this.issuableType,
+ });
+ },
+ hiddenTooltip() {
+ return sprintf(__('This %{issuable} is hidden because its author has been banned'), {
+ issuable: this.issuableType,
+ });
+ },
+ shouldShowWorkItemTypeIcon() {
+ return this.showWorkItemTypeIcon && this.issuableType;
+ },
+ createdMessage() {
+ if (this.serviceDeskReplyTo) {
+ return this.shouldShowWorkItemTypeIcon
+ ? __('created %{timeAgo} by %{email} via %{author}')
+ : __('Created %{timeAgo} by %{email} via %{author}');
+ }
+ return this.shouldShowWorkItemTypeIcon
+ ? __('created %{timeAgo} by %{author}')
+ : __('Created %{timeAgo} by %{author}');
},
authorId() {
return getIdFromGraphQLId(`${this.author.id}`);
},
isAuthorExternal() {
- return isExternal(this.author.webUrl);
+ return isExternal(this.author.webUrl ?? '');
},
taskStatusString() {
const { count, completedCount } = this.taskCompletionStatus;
@@ -130,72 +162,87 @@ export default {
<template>
<div class="detail-page-header gl-flex-direction-column gl-sm-flex-direction-row">
- <div class="detail-page-header-body">
- <gl-badge class="issuable-status-badge gl-mr-3" :variant="badgeVariant">
+ <div class="detail-page-header-body gl-flex-wrap">
+ <gl-badge class="gl-mr-2" :variant="badgeVariant">
<gl-icon v-if="statusIcon" :name="statusIcon" :class="statusIconClass" />
- <span class="gl-display-none gl-sm-display-block gl-ml-2">
+ <span class="gl-display-none gl-sm-display-block" :class="{ 'gl-ml-2': statusIcon }">
<slot name="status-badge">{{ badgeText }}</slot>
</span>
</gl-badge>
- <div class="issuable-meta gl-display-flex! gl-align-items-center gl-flex-wrap">
- <div v-if="blocked" data-testid="blocked" class="issuable-warning-icon inline">
- <gl-icon name="lock" :aria-label="__('Blocked')" />
- </div>
- <confidentiality-badge
- v-if="confidential"
- :issuable-type="issuableType"
- :workspace-type="workspaceType"
+ <confidentiality-badge
+ v-if="confidential"
+ :issuable-type="issuableType"
+ :workspace-type="workspaceType"
+ />
+ <span v-if="blocked" class="issuable-warning-icon">
+ <gl-icon
+ v-gl-tooltip.bottom
+ name="lock"
+ :title="blockedTooltip"
+ :aria-label="__('Blocked')"
/>
- <span>
- <template v-if="showWorkItemTypeIcon">
- <work-item-type-icon :work-item-type="issuableType" show-text />
- {{ __('created') }}
- </template>
- <template v-else>
- {{ __('Created') }}
- </template>
- <time-ago-tooltip data-testid="startTimeItem" :time="createdAt" />
- {{ __('by') }}
- </span>
- <gl-avatar-link
- data-testid="avatar"
- :data-user-id="authorId"
- :data-username="author.username"
- :data-name="author.name"
- :href="author.webUrl"
- target="_blank"
- class="js-user-link gl-vertical-align-middle gl-ml-2"
- >
- <gl-avatar-labeled
- :size="24"
- :src="author.avatarUrl"
- :label="author.name"
- :class="[{ 'gl-display-none': !isAuthorExternal }, 'gl-sm-display-inline-flex gl-mx-1']"
- >
- <template #meta>
- <gl-icon v-if="isAuthorExternal" name="external-link" class="gl-ml-1" />
- </template>
- </gl-avatar-labeled>
- <strong v-if="author.username" class="author gl-display-inline gl-sm-display-none!"
- >@{{ author.username }}</strong
+ </span>
+ <span v-if="isHidden" class="issuable-warning-icon">
+ <gl-icon
+ v-gl-tooltip.bottom
+ name="spam"
+ :title="hiddenTooltip"
+ :aria-label="__('Hidden')"
+ />
+ </span>
+ <work-item-type-icon
+ v-if="shouldShowWorkItemTypeIcon"
+ show-text
+ :work-item-type="issuableType.toUpperCase()"
+ />
+ <gl-sprintf :message="createdMessage">
+ <template #timeAgo>
+ <time-ago-tooltip class="gl-mx-2" :time="createdAt" />
+ </template>
+ <template #email>
+ {{ serviceDeskReplyTo }}
+ </template>
+ <template #author>
+ <gl-link
+ class="gl-font-weight-bold gl-mx-2 js-user-link"
+ :href="author.webUrl"
+ :data-user-id="authorId"
>
- </gl-avatar-link>
- <span
- v-if="taskCompletionStatus && hasTasks"
- data-testid="task-status"
- class="gl-display-none gl-md-display-block gl-lg-display-inline-block"
- >{{ taskStatusString }}</span
- >
- </div>
+ <span :class="[{ 'gl-display-none': !isAuthorExternal }, 'gl-sm-display-inline']">
+ {{ author.name }}
+ </span>
+ <gl-icon
+ v-if="isAuthorExternal"
+ name="external-link"
+ :aria-label="__('external link')"
+ />
+ <strong v-if="author.username" class="author gl-display-inline gl-sm-display-none!"
+ >@{{ author.username }}</strong
+ >
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ <gl-icon
+ v-if="isFirstContribution"
+ v-gl-tooltip
+ class="gl-mr-2"
+ name="first-contribution"
+ :title="__('1st contribution!')"
+ :aria-label="__('1st contribution!')"
+ />
+ <span
+ v-if="taskCompletionStatus && hasTasks"
+ class="gl-display-none gl-md-display-block gl-lg-display-inline-block"
+ >{{ taskStatusString }}</span
+ >
<gl-button
- data-testid="sidebar-toggle"
icon="chevron-double-lg-left"
- class="d-block d-sm-none gutter-toggle issuable-gutter-toggle"
+ class="gl-ml-auto gl-display-block gl-sm-display-none! js-sidebar-toggle"
:aria-label="__('Expand sidebar')"
@click="handleRightSidebarToggleClick"
/>
</div>
- <div data-testid="header-actions" class="detail-page-header-actions gl-display-flex">
+ <div class="detail-page-header-actions gl-display-flex">
<slot name="header-actions"></slot>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue
index 1b4da047057..01f9c223b55 100644
--- a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue
+++ b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import Tracking from '~/tracking';
@@ -28,7 +29,7 @@ export default {
>
<a
:href="`#${panel.name}`"
- data-qa-selector="panel_link"
+ data-testid="panel-link"
:data-qa-panel-name="panel.name"
class="new-namespace-panel gl-display-flex gl-flex-shrink-0 gl-flex-direction-column gl-lg-flex-direction-row gl-align-items-center gl-rounded-base gl-border-gray-100 gl-border-solid gl-border-1 gl-w-full gl-py-6 gl-px-3 gl-hover-text-decoration-none!"
@click="track('click_tab', { label: panel.name })"
diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
index fe408354f66..c1ec39e1545 100644
--- a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
+++ b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
@@ -107,7 +107,6 @@ export default {
<template>
<gl-button
v-if="!feature.configured"
- data-testid="configure-via-mr-button"
:loading="isLoading"
:variant="variant"
:category="category"
diff --git a/app/assets/javascripts/webhooks/components/form_url_app.vue b/app/assets/javascripts/webhooks/components/form_url_app.vue
index 4fafeff8804..f20d4d9312b 100644
--- a/app/assets/javascripts/webhooks/components/form_url_app.vue
+++ b/app/assets/javascripts/webhooks/components/form_url_app.vue
@@ -170,6 +170,7 @@ export default {
id="webhook-url"
v-model="url"
name="hook[url]"
+ class="gl-form-input-xl"
:state="urlState"
:placeholder="$options.i18n.urlPlaceholder"
data-testid="form-url"
diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue
index dd5d4edda59..d8f6e526dee 100644
--- a/app/assets/javascripts/whats_new/components/app.vue
+++ b/app/assets/javascripts/whats_new/components/app.vue
@@ -1,5 +1,6 @@
<script>
import { GlDrawer, GlInfiniteScroll, GlResizeObserverDirective } from '@gitlab/ui';
+// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
import Tracking from '~/tracking';
import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
diff --git a/app/assets/javascripts/whats_new/components/feature.vue b/app/assets/javascripts/whats_new/components/feature.vue
index 044a6db6d93..c3e1e2a8fbc 100644
--- a/app/assets/javascripts/whats_new/components/feature.vue
+++ b/app/assets/javascripts/whats_new/components/feature.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlBadge, GlIcon, GlLink, GlButton } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
@@ -22,7 +23,7 @@ export default {
computed: {
releaseDate() {
const { published_at } = this.feature;
- const date = new Date(published_at);
+ const date = new Date(`${published_at}T00:00:00`); // eslint-disable-line camelcase
if (!isValidDate(date) || date.getTime() === 0) {
return '';
diff --git a/app/assets/javascripts/whats_new/index.js b/app/assets/javascripts/whats_new/index.js
index 3ac3a3a3611..bc9e2d5c3b1 100644
--- a/app/assets/javascripts/whats_new/index.js
+++ b/app/assets/javascripts/whats_new/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import App from './components/app.vue';
import store from './store';
diff --git a/app/assets/javascripts/whats_new/store/index.js b/app/assets/javascripts/whats_new/store/index.js
index aea980060aa..5b8e4b58136 100644
--- a/app/assets/javascripts/whats_new/store/index.js
+++ b/app/assets/javascripts/whats_new/store/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import actions from './actions';
import mutations from './mutations';
diff --git a/app/assets/javascripts/work_items/components/item_state.vue b/app/assets/javascripts/work_items/components/item_state.vue
deleted file mode 100644
index 9053d8972de..00000000000
--- a/app/assets/javascripts/work_items/components/item_state.vue
+++ /dev/null
@@ -1,79 +0,0 @@
-<script>
-import { GlFormGroup, GlFormSelect } from '@gitlab/ui';
-import { __ } from '~/locale';
-import { STATE_OPEN, STATE_CLOSED } from '../constants';
-
-export default {
- i18n: {
- status: __('Status'),
- },
- states: [
- {
- value: STATE_OPEN,
- text: __('Open'),
- },
- {
- value: STATE_CLOSED,
- text: __('Closed'),
- },
- ],
- components: {
- GlFormGroup,
- GlFormSelect,
- },
- props: {
- state: {
- type: String,
- required: true,
- },
- disabled: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- computed: {
- currentState() {
- return this.$options.states[this.state];
- },
- },
- methods: {
- setState(newState) {
- if (newState !== this.state) {
- this.$emit('changed', newState);
- }
- },
- },
- labelId: 'work-item-state-select',
-};
-</script>
-
-<template>
- <gl-form-group
- :label="$options.i18n.status"
- :label-for="$options.labelId"
- label-cols="3"
- label-cols-lg="2"
- label-class="gl-pb-0! gl-overflow-wrap-break work-item-field-label"
- class="gl-align-items-center"
- >
- <gl-form-select
- :id="$options.labelId"
- :value="state"
- :options="$options.states"
- :disabled="disabled"
- data-testid="work-item-state-select"
- class="gl-w-auto hide-select-decoration gl-pl-4 gl-my-1 work-item-field-value"
- :class="{ 'gl-bg-transparent! gl-cursor-text!': disabled }"
- @change="setState"
- />
- </gl-form-group>
-</template>
-
-<style>
-.hide-select-decoration:not(:focus, :hover),
-.hide-select-decoration:disabled {
- background-image: none;
- box-shadow: none;
-}
-</style>
diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue
index 1dc6d341811..74bcc2717bd 100644
--- a/app/assets/javascripts/work_items/components/item_title.vue
+++ b/app/assets/javascripts/work_items/components/item_title.vue
@@ -52,7 +52,7 @@ export default {
: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 gl-display-block"
+ class="hide-unfocused-input-decoration gl-px-4 gl-py-3 gl-ml-n4 gl-border gl-rounded-base gl-display-block"
:class="{ 'gl-hover-border-gray-200 gl-pseudo-placeholder': !disabled }"
@paste="handlePaste"
@blur="handleBlur"
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue
index c330eccb186..66ad3d50287 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue
@@ -265,6 +265,7 @@ export default {
:comment-button-text="commentButtonText"
@submitForm="updateWorkItem"
@cancelEditing="cancelEditing"
+ @error="$emit('error', $event)"
/>
<textarea
v-else
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue
index c317ec48732..b143c529014 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue
@@ -1,22 +1,13 @@
<script>
import { GlButton, GlFormCheckbox, GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import * as Sentry from '@sentry/browser';
import { helpPagePath } from '~/helpers/help_page_helper';
-import { s__, __, sprintf } from '~/locale';
+import { s__, __ } from '~/locale';
import Tracking from '~/tracking';
-import {
- I18N_WORK_ITEM_ERROR_UPDATING,
- sprintfWorkItem,
- STATE_OPEN,
- STATE_EVENT_REOPEN,
- STATE_EVENT_CLOSE,
- TRACKING_CATEGORY_SHOW,
- i18n,
-} from '~/work_items/constants';
+import { STATE_OPEN, TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
-import { getUpdateWorkItemMutation } from '~/work_items/components/update_work_item';
+import WorkItemStateToggleButton from '~/work_items/components/work_item_state_toggle_button.vue';
export default {
i18n: {
@@ -25,6 +16,7 @@ export default {
'Notes|Internal notes are only visible to members with the role of Reporter or higher',
),
addInternalNote: __('Add internal note'),
+ cancelButtonText: __('Cancel'),
},
constantOptions: {
markdownDocsPath: helpPagePath('user/markdown'),
@@ -34,6 +26,7 @@ export default {
MarkdownEditor,
GlFormCheckbox,
GlIcon,
+ WorkItemStateToggleButton,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -123,14 +116,6 @@ export default {
isWorkItemOpen() {
return this.workItemState === STATE_OPEN;
},
- toggleWorkItemStateText() {
- return this.isWorkItemOpen
- ? sprintf(__('Close %{workItemType}'), { workItemType: this.workItemType.toLowerCase() })
- : sprintf(__('Reopen %{workItemType}'), { workItemType: this.workItemType.toLowerCase() });
- },
- cancelButtonText() {
- return this.isNewDiscussion ? this.toggleWorkItemStateText : __('Cancel');
- },
commentButtonTextComputed() {
return this.isNoteInternal ? this.$options.i18n.addInternalNote : this.commentButtonText;
},
@@ -166,48 +151,6 @@ export default {
this.$emit('cancelEditing');
clearDraft(this.autosaveKey);
},
- async toggleWorkItemState() {
- const input = {
- id: this.workItemId,
- stateEvent: this.isWorkItemOpen ? STATE_EVENT_CLOSE : STATE_EVENT_REOPEN,
- };
-
- this.updateInProgress = true;
-
- try {
- this.track('updated_state');
-
- const { mutation, variables } = getUpdateWorkItemMutation({
- workItemParentId: this.workItemParentId,
- input,
- });
-
- const { data } = await this.$apollo.mutate({
- mutation,
- variables,
- });
-
- const errors = data.workItemUpdate?.errors;
-
- if (errors?.length) {
- this.$emit('error', i18n.updateError);
- }
- } catch (error) {
- const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType);
-
- this.$emit('error', msg);
- Sentry.captureException(error);
- }
-
- this.updateInProgress = false;
- },
- cancelButtonAction() {
- if (this.isNewDiscussion) {
- this.toggleWorkItemState();
- } else {
- this.cancelEditing();
- }
- },
},
};
</script>
@@ -257,13 +200,23 @@ export default {
@click="$emit('submitForm', { commentText, isNoteInternal })"
>{{ commentButtonTextComputed }}
</gl-button>
+ <work-item-state-toggle-button
+ v-if="isNewDiscussion"
+ class="gl-ml-3"
+ :work-item-id="workItemId"
+ :work-item-state="workItemState"
+ :work-item-type="workItemType"
+ can-update
+ @error="$emit('error', $event)"
+ />
<gl-button
+ v-else
data-testid="cancel-button"
category="primary"
class="gl-ml-3"
:loading="updateInProgress"
- @click="cancelButtonAction"
- >{{ cancelButtonText }}
+ @click="cancelEditing"
+ >{{ $options.i18n.cancelButtonText }}
</gl-button>
</form>
</div>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_note.vue
index a2667a379e1..92560f2da9e 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_note.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note.vue
@@ -3,7 +3,7 @@ import { GlAvatarLink, GlAvatar } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import toast from '~/vue_shared/plugins/global_toast';
import { __ } from '~/locale';
-import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_ASSIGNEES } from '~/work_items/constants';
+import { i18n, TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
import Tracking from '~/tracking';
import { updateDraft, clearDraft } from '~/lib/utils/autosave';
import { renderMarkdown } from '~/notes/utils';
@@ -17,6 +17,7 @@ import NoteActions from '~/work_items/components/notes/work_item_note_actions.vu
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import updateWorkItemNoteMutation from '../../graphql/notes/update_work_item_note.mutation.graphql';
import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
+import { isAssigneesWidget } from '../../utils';
import WorkItemCommentForm from './work_item_comment_form.vue';
import WorkItemNoteAwardsList from './work_item_note_awards_list.vue';
@@ -228,8 +229,6 @@ export default {
newAssignees = [...this.assignees, this.author];
}
- const isAssigneesWidget = (widget) => widget.type === WIDGET_TYPE_ASSIGNEES;
-
const assigneesWidgetIndex = this.workItem.widgets.findIndex(isAssigneesWidget);
const editedWorkItemWidgets = [...this.workItem.widgets];
diff --git a/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue b/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue
new file mode 100644
index 00000000000..0a38dcb77f6
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue
@@ -0,0 +1,196 @@
+<script>
+import { GlLabel, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { isScopedLabel } from '~/lib/utils/common_utils';
+import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue';
+import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/shared/work_item_link_child_metadata.vue';
+import {
+ STATE_OPEN,
+ TASK_TYPE_NAME,
+ WIDGET_TYPE_PROGRESS,
+ WIDGET_TYPE_HIERARCHY,
+ WIDGET_TYPE_HEALTH_STATUS,
+ WIDGET_TYPE_MILESTONE,
+ WIDGET_TYPE_ASSIGNEES,
+ WIDGET_TYPE_LABELS,
+ WORK_ITEM_NAME_TO_ICON_MAP,
+} from '../../constants';
+import WorkItemLinksMenu from './work_item_links_menu.vue';
+
+export default {
+ i18n: {
+ confidential: __('Confidential'),
+ created: __('Created'),
+ closed: __('Closed'),
+ },
+ components: {
+ GlLabel,
+ GlLink,
+ GlIcon,
+ RichTimestampTooltip,
+ WorkItemLinkChildMetadata,
+ WorkItemLinksMenu,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ childItem: {
+ type: Object,
+ required: true,
+ },
+ canUpdate: {
+ type: Boolean,
+ required: true,
+ },
+ parentWorkItemId: {
+ type: String,
+ required: true,
+ },
+ workItemType: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ childPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ labels() {
+ return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || [];
+ },
+ metadataWidgets() {
+ return this.childItem.widgets?.reduce((metadataWidgets, widget) => {
+ // Skip Hierarchy widget as it is not part of metadata.
+ if (widget.type && widget.type !== WIDGET_TYPE_HIERARCHY) {
+ // eslint-disable-next-line no-param-reassign
+ metadataWidgets[widget.type] = widget;
+ }
+ return metadataWidgets;
+ }, {});
+ },
+ allowsScopedLabels() {
+ return this.metadataWidgets[WIDGET_TYPE_LABELS]?.allowsScopedLabels;
+ },
+ isChildItemOpen() {
+ return this.childItem.state === STATE_OPEN;
+ },
+ iconName() {
+ if (this.childItemType === TASK_TYPE_NAME) {
+ return this.isChildItemOpen ? 'issue-open-m' : 'issue-close';
+ }
+ return WORK_ITEM_NAME_TO_ICON_MAP[this.childItemType];
+ },
+ childItemType() {
+ return this.childItem.workItemType.name;
+ },
+ iconClass() {
+ if (this.childItemType === TASK_TYPE_NAME) {
+ return this.isChildItemOpen ? 'gl-text-green-500' : 'gl-text-blue-500';
+ }
+ return '';
+ },
+ stateTimestamp() {
+ return this.isChildItemOpen ? this.childItem.createdAt : this.childItem.closedAt;
+ },
+ stateTimestampTypeText() {
+ return this.isChildItemOpen ? this.$options.i18n.created : this.$options.i18n.closed;
+ },
+ hasMetadata() {
+ if (this.metadataWidgets) {
+ return (
+ Number.isInteger(this.metadataWidgets[WIDGET_TYPE_PROGRESS]?.progress) ||
+ Boolean(this.metadataWidgets[WIDGET_TYPE_HEALTH_STATUS]?.healthStatus) ||
+ Boolean(this.metadataWidgets[WIDGET_TYPE_MILESTONE]?.milestone) ||
+ this.metadataWidgets[WIDGET_TYPE_ASSIGNEES]?.assignees?.nodes.length > 0 ||
+ this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes.length > 0
+ );
+ }
+ return false;
+ },
+ },
+ methods: {
+ showScopedLabel(label) {
+ return isScopedLabel(label) && this.allowsScopedLabels;
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="item-body work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-py-2 gl-mx-n2 gl-rounded-base"
+ data-testid="links-child"
+ >
+ <div class="item-contents gl-display-flex gl-flex-grow-1 gl-flex-wrap gl-min-w-0">
+ <div
+ class="gl-display-flex gl-flex-grow-1 gl-flex-wrap flex-xl-nowrap gl-align-items-center gl-justify-content-space-between gl-gap-3 gl-min-w-0"
+ >
+ <div class="item-title gl-display-flex gl-gap-3 gl-min-w-0">
+ <span
+ :id="`stateIcon-${childItem.id}`"
+ class="gl-cursor-help"
+ data-testid="item-status-icon"
+ >
+ <gl-icon
+ class="gl-text-secondary"
+ :class="iconClass"
+ :name="iconName"
+ :aria-label="stateTimestampTypeText"
+ />
+ </span>
+ <rich-timestamp-tooltip
+ :target="`stateIcon-${childItem.id}`"
+ :raw-timestamp="stateTimestamp"
+ :timestamp-type-text="stateTimestampTypeText"
+ />
+ <span v-if="childItem.confidential">
+ <gl-icon
+ v-gl-tooltip.top
+ name="eye-slash"
+ class="gl-text-orange-500"
+ data-testid="confidential-icon"
+ :aria-label="$options.i18n.confidential"
+ :title="$options.i18n.confidential"
+ />
+ </span>
+ <gl-link
+ :href="childPath"
+ class="gl-text-truncate gl-text-black-normal! gl-font-weight-semibold"
+ data-testid="item-title"
+ @click="$emit('click', $event)"
+ @mouseover="$emit('mouseover')"
+ @mouseout="$emit('mouseout')"
+ >
+ {{ childItem.title }}
+ </gl-link>
+ </div>
+ <work-item-link-child-metadata
+ v-if="hasMetadata"
+ :metadata-widgets="metadataWidgets"
+ class="gl-ml-6 ml-xl-0"
+ />
+ </div>
+ <div v-if="labels.length" class="gl-display-flex gl-flex-wrap gl-flex-basis-full gl-ml-6">
+ <gl-label
+ v-for="label in labels"
+ :key="label.id"
+ :title="label.title"
+ :background-color="label.color"
+ :description="label.description"
+ :scoped="showScopedLabel(label)"
+ class="gl-my-2 gl-mr-2 gl-mb-auto gl-label-sm"
+ tooltip-placement="top"
+ />
+ </div>
+ </div>
+ <div v-if="canUpdate" class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex">
+ <work-item-links-menu
+ data-testid="links-menu"
+ @removeChild="$emit('removeChild', childItem)"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue b/app/assets/javascripts/work_items/components/shared/work_item_link_child_metadata.vue
index ddeac2b92ae..ddeac2b92ae 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue
+++ b/app/assets/javascripts/work_items/components/shared/work_item_link_child_metadata.vue
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue b/app/assets/javascripts/work_items/components/shared/work_item_links_menu.vue
index 53e8eedf060..53e8eedf060 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue
+++ b/app/assets/javascripts/work_items/components/shared/work_item_links_menu.vue
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 76a04bede61..e8fe64c932b 100644
--- a/app/assets/javascripts/work_items/components/work_item_actions.vue
+++ b/app/assets/javascripts/work_items/components/work_item_actions.vue
@@ -8,6 +8,7 @@ import {
GlModalDirective,
GlToggle,
} from '@gitlab/ui';
+import { produce } from 'immer';
import * as Sentry from '@sentry/browser';
@@ -15,6 +16,7 @@ import { __, s__ } from '~/locale';
import Tracking from '~/tracking';
import toast from '~/vue_shared/plugins/global_toast';
import { isLoggedIn } from '~/lib/utils/common_utils';
+import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import {
sprintfWorkItem,
@@ -127,6 +129,10 @@ export default {
required: false,
default: false,
},
+ workItemIid: {
+ type: String,
+ required: true,
+ },
},
apollo: {
workItemTypes: {
@@ -168,16 +174,6 @@ export default {
return this.workItemTypes.find((type) => type.name === WORK_ITEM_TYPE_VALUE_OBJECTIVE).id;
},
},
- watch: {
- subscribedToNotifications() {
- /**
- * To toggle the value if mutation fails, assign the
- * subscribedToNotifications boolean value directly
- * to data prop.
- */
- this.initialSubscribed = this.subscribedToNotifications;
- },
- },
methods: {
copyToClipboard(text, message) {
if (this.isModal) {
@@ -203,10 +199,9 @@ export default {
},
toggleNotifications(subscribed) {
const inputVariables = {
- id: this.workItemId,
- notificationsWidget: {
- subscribed,
- },
+ projectPath: this.fullPath,
+ iid: this.workItemIid,
+ subscribedState: subscribed,
};
this.$apollo
.mutate({
@@ -215,27 +210,34 @@ export default {
input: inputVariables,
},
optimisticResponse: {
- workItemUpdate: {
- errors: [],
- workItem: {
+ updateWorkItemNotificationsSubscription: {
+ issue: {
id: this.workItemId,
- widgets: [
- {
- type: WIDGET_TYPE_NOTIFICATIONS,
- subscribed,
- __typename: 'WorkItemWidgetNotifications',
- },
- ],
- __typename: 'WorkItem',
+ subscribed,
+ },
+ errors: [],
+ },
+ },
+ update: (
+ cache,
+ {
+ data: {
+ updateWorkItemNotificationsSubscription: { issue = {} },
},
- __typename: 'WorkItemUpdatePayload',
},
+ ) => {
+ // As the mutation and the query both are different,
+ // overwrite the subscribed value in the cache
+ this.updateWorkItemNotificationsWidgetCache({
+ cache,
+ issue,
+ });
},
})
.then(
({
data: {
- workItemUpdate: { errors },
+ updateWorkItemNotificationsSubscription: { errors },
},
}) => {
if (errors?.length) {
@@ -251,6 +253,25 @@ export default {
Sentry.captureException(error);
});
},
+ updateWorkItemNotificationsWidgetCache({ cache, issue }) {
+ const query = {
+ query: workItemByIidQuery,
+ variables: { fullPath: this.fullPath, iid: this.workItemIid },
+ };
+ // Read the work item object
+ const sourceData = cache.readQuery(query);
+
+ const newData = produce(sourceData, (draftState) => {
+ const { widgets } = draftState.workspace.workItems.nodes[0];
+
+ const widgetNotifications = widgets.find(({ type }) => type === WIDGET_TYPE_NOTIFICATIONS);
+ // overwrite the subscribed value
+ widgetNotifications.subscribed = issue.subscribed;
+ });
+
+ // write to the cache
+ cache.writeQuery({ ...query, data: newData });
+ },
throwConvertError() {
this.$emit('error', this.i18n.convertError);
},
@@ -275,6 +296,7 @@ export default {
}
this.$toast.show(s__('WorkItem|Promoted to objective.'));
this.track('promote_kr_to_objective');
+ this.$emit('promotedToObjective');
} catch (error) {
this.throwConvertError();
Sentry.captureException(error);
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 f7ac63e16c3..4b4aa7f96ca 100644
--- a/app/assets/javascripts/work_items/components/work_item_assignees.vue
+++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue
@@ -148,7 +148,7 @@ export default {
};
},
containerClass() {
- return !this.isEditing ? 'gl-shadow-none!' : '';
+ return !this.isEditing ? 'gl-shadow-none! hide-unfocused-input-decoration' : '';
},
isLoadingUsers() {
return this.$apollo.queries.users.loading;
@@ -318,7 +318,7 @@ export default {
: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 work-item-field-value"
+ class="assignees-selector hide-unfocused-input-decoration work-item-field-value gl-flex-grow-1 gl-border gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2"
data-testid="work-item-assignees-input"
@input="handleAssigneesInput"
@text-input="debouncedSearchKeyUpdate"
diff --git a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue
index c727075eaac..139f0f7919c 100644
--- a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue
+++ b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue
@@ -11,7 +11,6 @@ import {
WIDGET_TYPE_START_AND_DUE_DATE,
WIDGET_TYPE_WEIGHT,
} from '../constants';
-import WorkItemState from './work_item_state.vue';
import WorkItemDueDate from './work_item_due_date.vue';
import WorkItemAssignees from './work_item_assignees.vue';
import WorkItemLabels from './work_item_labels.vue';
@@ -23,7 +22,6 @@ export default {
WorkItemMilestone,
WorkItemAssignees,
WorkItemDueDate,
- WorkItemState,
WorkItemWeight: () => import('ee_component/work_items/components/work_item_weight.vue'),
WorkItemProgress: () => import('ee_component/work_items/components/work_item_progress.vue'),
WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'),
@@ -97,12 +95,6 @@ export default {
<template>
<div class="work-item-attributes-wrapper">
- <work-item-state
- :work-item="workItem"
- :work-item-parent-id="workItemParentId"
- :can-update="canUpdate"
- @error="$emit('error', $event)"
- />
<work-item-assignees
v-if="workItemAssignees"
:can-update="canUpdate"
diff --git a/app/assets/javascripts/work_items/components/work_item_award_emoji.vue b/app/assets/javascripts/work_items/components/work_item_award_emoji.vue
index 3dd3a072d0f..44bd17b59a2 100644
--- a/app/assets/javascripts/work_items/components/work_item_award_emoji.vue
+++ b/app/assets/javascripts/work_items/components/work_item_award_emoji.vue
@@ -51,7 +51,7 @@ export default {
return window.gon.current_user_fullname;
},
/**
- * Parse and convert award emoji list to a format that AwardsList can understand
+ * Parse and convert emoji reactions list to a format that AwardsList can understand
*/
awards() {
if (!this.awardEmoji) {
@@ -91,12 +91,15 @@ export default {
skip() {
return !this.workItemIid;
},
- result() {
+ result({ data }) {
if (this.hasNextPage) {
this.fetchAwardEmojis();
} else {
this.isLoading = false;
}
+ if (data) {
+ this.$emit('emoji-updated', data.workspace?.workItems?.nodes[0]);
+ }
},
error() {
this.$emit('error', I18N_WORK_ITEM_FETCH_AWARD_EMOJI_ERROR);
@@ -125,7 +128,7 @@ export default {
);
},
/**
- * Prepare award emoji nodes based on emoji name
+ * Prepare emoji reactions nodes based on emoji name
* and whether the user has toggled the emoji off or on
*/
getAwardEmojiNodes(name, toggledOn) {
@@ -204,7 +207,7 @@ export default {
},
},
) => {
- // update the cache of award emoji widget object
+ // update the cache of emoji reactions widget object
this.updateWorkItemAwardEmojiWidgetCache({ cache, name, toggledOn });
},
})
diff --git a/app/assets/javascripts/work_items/components/work_item_created_updated.vue b/app/assets/javascripts/work_items/components/work_item_created_updated.vue
index 78a86aa49a4..f93ea4a0753 100644
--- a/app/assets/javascripts/work_items/components/work_item_created_updated.vue
+++ b/app/assets/javascripts/work_items/components/work_item_created_updated.vue
@@ -1,7 +1,11 @@
<script>
-import { GlAvatarLink, GlSprintf } from '@gitlab/ui';
+import { GlAvatarLink, GlSprintf, GlLoadingIcon } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { WORKSPACE_PROJECT } from '~/issues/constants';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import WorkItemStateBadge from '~/work_items/components/work_item_state_badge.vue';
+import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue';
+import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
export default {
@@ -9,6 +13,10 @@ export default {
GlAvatarLink,
GlSprintf,
TimeAgoTooltip,
+ WorkItemStateBadge,
+ WorkItemTypeIcon,
+ ConfidentialityBadge,
+ GlLoadingIcon,
},
inject: ['fullPath'],
props: {
@@ -17,6 +25,11 @@ export default {
required: false,
default: null,
},
+ updateInProgress: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
createdAt() {
@@ -31,6 +44,18 @@ export default {
authorId() {
return getIdFromGraphQLId(this.author.id);
},
+ workItemState() {
+ return this.workItem?.state;
+ },
+ workItemType() {
+ return this.workItem?.workItemType?.name;
+ },
+ workItemIconName() {
+ return this.workItem?.workItemType?.iconName;
+ },
+ isWorkItemConfidential() {
+ return this.workItem?.confidential;
+ },
},
apollo: {
workItem: {
@@ -49,13 +74,29 @@ export default {
},
},
},
+ WORKSPACE_PROJECT,
};
</script>
<template>
- <div class="gl-mb-3">
- <span data-testid="work-item-created">
- <gl-sprintf v-if="author.name" :message="__('Created %{timeAgo} by %{author}')">
+ <div class="gl-mb-3 gl-text-gray-700">
+ <work-item-state-badge v-if="workItemState" :work-item-state="workItemState" />
+ <gl-loading-icon v-if="updateInProgress" :inline="true" class="gl-mr-3" />
+ <confidentiality-badge
+ v-if="isWorkItemConfidential"
+ class="gl-vertical-align-middle gl-display-inline-flex!"
+ data-testid="confidential"
+ :workspace-type="$options.WORKSPACE_PROJECT"
+ :issuable-type="workItemType"
+ />
+ <work-item-type-icon
+ class="gl-vertical-align-middle gl-mr-0!"
+ :work-item-icon-name="workItemIconName"
+ :work-item-type="workItemType"
+ show-text
+ />
+ <span data-testid="work-item-created" class="gl-vertical-align-middle">
+ <gl-sprintf v-if="author.name" :message="__('created %{timeAgo} by %{author}')">
<template #timeAgo>
<time-ago-tooltip :time="createdAt" />
</template>
@@ -70,7 +111,7 @@ export default {
</gl-avatar-link>
</template>
</gl-sprintf>
- <gl-sprintf v-else-if="createdAt" :message="__('Created %{timeAgo}')">
+ <gl-sprintf v-else-if="createdAt" :message="__('created %{timeAgo}')">
<template #timeAgo>
<time-ago-tooltip :time="createdAt" />
</template>
@@ -79,7 +120,7 @@ export default {
<span
v-if="updatedAt"
- class="gl-ml-5 gl-display-none gl-sm-display-inline-block"
+ class="gl-ml-5 gl-display-none gl-sm-display-inline-block gl-vertical-align-middle"
data-testid="work-item-updated"
>
<gl-sprintf :message="__('Updated %{timeAgo}')">
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 1402b313cee..d826ef9cbe7 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -5,7 +5,6 @@ import {
GlSkeletonLoader,
GlLoadingIcon,
GlIcon,
- GlBadge,
GlButton,
GlTooltipDirective,
GlEmptyState,
@@ -19,8 +18,9 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isLoggedIn } from '~/lib/utils/common_utils';
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
+import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue';
+import { WORKSPACE_PROJECT } from '~/issues/constants';
import {
- sprintfWorkItem,
i18n,
WIDGET_TYPE_ASSIGNEES,
WIDGET_TYPE_NOTIFICATIONS,
@@ -49,6 +49,7 @@ import WorkItemDescription from './work_item_description.vue';
import WorkItemNotes from './work_item_notes.vue';
import WorkItemDetailModal from './work_item_detail_modal.vue';
import WorkItemAwardEmoji from './work_item_award_emoji.vue';
+import WorkItemStateToggleButton from './work_item_state_toggle_button.vue';
export default {
i18n,
@@ -57,8 +58,8 @@ export default {
},
isLoggedIn: isLoggedIn(),
components: {
+ WorkItemStateToggleButton,
GlAlert,
- GlBadge,
GlButton,
GlLoadingIcon,
GlSkeletonLoader,
@@ -77,6 +78,7 @@ export default {
WorkItemDetailModal,
AbuseCategorySelector,
GlIntersectionObserver,
+ ConfidentialityBadge,
},
mixins: [glFeatureFlagMixin()],
inject: ['fullPath', 'reportAbusePath'],
@@ -134,6 +136,7 @@ export default {
if (!res.data) {
return;
}
+ this.$emit('work-item-updated', this.workItem);
if (isEmpty(this.workItem)) {
this.setEmptyState();
}
@@ -169,7 +172,7 @@ export default {
return this.workItem.workItemType?.id;
},
workItemBreadcrumbReference() {
- return this.workItemType ? `${this.workItemType} #${this.workItem.iid}` : '';
+ return this.workItemType ? `#${this.workItem.iid}` : '';
},
canUpdate() {
return this.workItem?.userPermissions?.updateWorkItem;
@@ -183,9 +186,6 @@ export default {
canAssignUnassignUser() {
return this.workItemAssignees && this.canSetWorkItemMetadata;
},
- confidentialTooltip() {
- return sprintfWorkItem(this.$options.i18n.confidentialTooltip, this.workItemType);
- },
fullPath() {
return this.workItem?.project.fullPath;
},
@@ -374,8 +374,8 @@ export default {
}
},
},
-
WORK_ITEM_TYPE_VALUE_OBJECTIVE,
+ WORKSPACE_PROJECT,
};
</script>
@@ -397,13 +397,13 @@ export default {
<div class="gl-display-flex gl-align-items-center" data-testid="work-item-body">
<ul
v-if="parentWorkItem"
- class="list-unstyled gl-display-flex gl-mr-auto gl-max-w-26 gl-md-max-w-50p gl-min-w-0 gl-mb-0 gl-z-index-0"
+ class="list-unstyled gl-display-flex gl-min-w-0 gl-mr-auto gl-mb-0 gl-z-index-0"
data-testid="work-item-parent"
>
- <li class="gl-ml-n4 gl-display-flex gl-align-items-center gl-overflow-hidden">
+ <li class="gl-ml-n4 gl-display-flex gl-align-items-center gl-min-w-0">
<gl-button
v-gl-tooltip.hover
- class="gl-text-truncate gl-max-w-full"
+ class="gl-text-truncate"
:icon="parentWorkItemIconName"
category="tertiary"
:href="parentUrl"
@@ -418,7 +418,8 @@ export default {
>
<work-item-type-icon
:work-item-icon-name="workItemIconName"
- :work-item-type="workItemType && workItemType.toUpperCase()"
+ :work-item-type="workItemType"
+ show-text
/>
{{ workItemBreadcrumbReference }}
</li>
@@ -430,20 +431,19 @@ export default {
>
<work-item-type-icon
:work-item-icon-name="workItemIconName"
- :work-item-type="workItemType && workItemType.toUpperCase()"
+ :work-item-type="workItemType"
+ show-text
/>
{{ workItemBreadcrumbReference }}
</div>
- <gl-loading-icon v-if="updateInProgress" :inline="true" class="gl-mr-3" />
- <gl-badge
- v-if="workItem.confidential"
- v-gl-tooltip.bottom
- :title="confidentialTooltip"
- variant="warning"
- icon="eye-slash"
- class="gl-mr-3 gl-cursor-help"
- >{{ __('Confidential') }}</gl-badge
- >
+ <work-item-state-toggle-button
+ v-if="canUpdate"
+ :work-item-id="workItem.id"
+ :work-item-state="workItem.state"
+ :work-item-parent-id="workItemParentId"
+ :work-item-type="workItemType"
+ @error="updateError = $event"
+ />
<work-item-todos
v-if="showWorkItemCurrentUserTodos"
:work-item-id="workItem.id"
@@ -464,9 +464,11 @@ export default {
:work-item-reference="workItem.reference"
:work-item-create-note-email="workItem.createNoteEmail"
:is-modal="isModal"
+ :work-item-iid="workItemIid"
@deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })"
@toggleWorkItemConfidentiality="toggleConfidentiality"
@error="updateError = $event"
+ @promotedToObjective="$emit('promotedToObjective', workItemIid)"
/>
<gl-button
v-if="isModal"
@@ -488,7 +490,10 @@ export default {
:can-update="canUpdate"
@error="updateError = $event"
/>
- <work-item-created-updated :work-item-iid="workItemIid" />
+ <work-item-created-updated
+ :work-item-iid="workItemIid"
+ :update-in-progress="updateInProgress"
+ />
</div>
<gl-intersection-observer
v-if="showIntersectionObserver"
@@ -508,15 +513,12 @@ export default {
{{ workItem.title }}
</span>
<gl-loading-icon v-if="updateInProgress" class="gl-mr-3" />
- <gl-badge
+ <confidentiality-badge
v-if="workItem.confidential"
- v-gl-tooltip.bottom
- :title="confidentialTooltip"
- variant="warning"
- icon="eye-slash"
- class="gl-mr-3 gl-cursor-help"
- >{{ __('Confidential') }}</gl-badge
- >
+ data-testid="confidential"
+ :workspace-type="$options.WORKSPACE_PROJECT"
+ :issuable-type="workItemType"
+ />
<work-item-todos
v-if="showWorkItemCurrentUserTodos"
:work-item-id="workItem.id"
@@ -537,11 +539,13 @@ export default {
:work-item-reference="workItem.reference"
:work-item-create-note-email="workItem.createNoteEmail"
:is-modal="isModal"
+ :work-item-iid="workItemIid"
@deleteWorkItem="
$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })
"
@toggleWorkItemConfidentiality="toggleConfidentiality"
@error="updateError = $event"
+ @promotedToObjective="$emit('promotedToObjective', workItemIid)"
/>
</div>
</div>
@@ -573,6 +577,7 @@ export default {
:award-emoji="workItemAwardEmoji.awardEmoji"
:work-item-iid="workItemIid"
@error="updateError = $event"
+ @emoji-updated="$emit('work-item-emoji-updated', $event)"
/>
<work-item-tree
v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE"
@@ -584,6 +589,7 @@ export default {
:can-update="canUpdate"
:confidential="workItem.confidential"
@show-modal="openInModal"
+ @addChild="$emit('addChild')"
/>
<work-item-notes
v-if="workItemNotes"
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
index b4b3049d669..1aa62a2b906 100644
--- a/app/assets/javascripts/work_items/components/work_item_due_date.vue
+++ b/app/assets/javascripts/work_items/components/work_item_due_date.vue
@@ -219,7 +219,6 @@ export default {
ref="startDatePicker"
v-model="dirtyStartDate"
container="body"
- data-testid="work-item-start-date-picker"
:disabled="isDatepickerDisabled"
:input-id="$options.startDateInputId"
show-clear-button
@@ -250,7 +249,6 @@ export default {
ref="dueDatePicker"
v-model="dirtyDueDate"
container="body"
- data-testid="work-item-due-date-picker"
:disabled="isDatepickerDisabled"
:input-id="$options.dueDateInputId"
:min-date="dirtyStartDate"
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 8676456a6a4..1405a12a101 100644
--- a/app/assets/javascripts/work_items/components/work_item_labels.vue
+++ b/app/assets/javascripts/work_items/components/work_item_labels.vue
@@ -9,13 +9,8 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { isScopedLabel } from '~/lib/utils/common_utils';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
-
-import {
- i18n,
- I18N_WORK_ITEM_ERROR_FETCHING_LABELS,
- TRACKING_CATEGORY_SHOW,
- WIDGET_TYPE_LABELS,
-} from '../constants';
+import { i18n, I18N_WORK_ITEM_ERROR_FETCHING_LABELS, TRACKING_CATEGORY_SHOW } from '../constants';
+import { isLabelsWidget } from '../utils';
function isTokenSelectorElement(el) {
return (
@@ -121,13 +116,13 @@ export default {
return this.labelsWidget?.allowsScopedLabels;
},
containerClass() {
- return !this.isEditing ? 'gl-shadow-none!' : '';
+ return !this.isEditing ? 'gl-shadow-none! hide-unfocused-input-decoration' : '';
},
isLoading() {
return this.$apollo.queries.searchLabels.loading;
},
labelsWidget() {
- return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS);
+ return this.workItem?.widgets?.find(isLabelsWidget);
},
labels() {
return this.labelsWidget?.labels?.nodes || [];
@@ -272,7 +267,7 @@ export default {
:loading="isLoading"
:view-only="!canUpdate"
:allow-clear-all="isEditing"
- class="gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2! work-item-field-value"
+ class="hide-unfocused-input-decoration work-item-field-value gl-flex-grow-1 gl-border gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2!"
menu-class="token-selector-menu-class"
data-testid="work-item-labels-input"
:class="{ 'gl-hover-border-gray-200': canUpdate }"
diff --git a/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue b/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue
index dc5bcdc3dcc..c5be1a3ead3 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue
@@ -1,7 +1,7 @@
<script>
-import { GlDropdown, GlDropdownSectionHeader, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
+import { GlDisclosureDropdown } from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
const objectiveActionItems = [
{
@@ -29,10 +29,30 @@ export default {
keyResultActionItems,
objectiveActionItems,
components: {
- GlDropdown,
- GlDropdownSectionHeader,
- GlDropdownItem,
- GlDropdownDivider,
+ GlDisclosureDropdown,
+ },
+ computed: {
+ objectiveDropdownItems() {
+ return {
+ name: __('Objective'),
+ items: this.$options.objectiveActionItems.map((item) => ({
+ text: item.title,
+ action: () => this.change(item),
+ })),
+ };
+ },
+ keyResultDropdownItems() {
+ return {
+ name: __('Key result'),
+ items: this.$options.keyResultActionItems.map((item) => ({
+ text: item.title,
+ action: () => this.change(item),
+ })),
+ };
+ },
+ dropdownItems() {
+ return [this.objectiveDropdownItems, this.keyResultDropdownItems];
+ },
},
methods: {
change({ eventName }) {
@@ -43,24 +63,10 @@ export default {
</script>
<template>
- <gl-dropdown :text="__('Add')" size="small" right>
- <gl-dropdown-section-header>{{ __('Objective') }}</gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="item in $options.objectiveActionItems"
- :key="item.eventName"
- @click="change(item)"
- >
- {{ item.title }}
- </gl-dropdown-item>
-
- <gl-dropdown-divider />
- <gl-dropdown-section-header>{{ __('Key result') }}</gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="item in $options.keyResultActionItems"
- :key="item.eventName"
- @click="change(item)"
- >
- {{ item.title }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <gl-disclosure-dropdown
+ :toggle-text="__('Add')"
+ size="small"
+ placement="right"
+ :items="dropdownItems"
+ />
</template>
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
index ec44a654e89..a9b0c2b98bf 100644
--- 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
@@ -1,39 +1,27 @@
<script>
-import { GlButton, GlLabel, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import * as Sentry from '@sentry/browser';
import { __, s__ } from '~/locale';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { createAlert } from '~/alert';
-import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue';
-import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/work_item_links/work_item_link_child_metadata.vue';
import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
import {
STATE_OPEN,
TASK_TYPE_NAME,
WORK_ITEM_TYPE_VALUE_OBJECTIVE,
- WIDGET_TYPE_PROGRESS,
- WIDGET_TYPE_HEALTH_STATUS,
- WIDGET_TYPE_MILESTONE,
WIDGET_TYPE_HIERARCHY,
- WIDGET_TYPE_ASSIGNEES,
- WIDGET_TYPE_LABELS,
WORK_ITEM_NAME_TO_ICON_MAP,
} from '../../constants';
import getWorkItemTreeQuery from '../../graphql/work_item_tree.query.graphql';
-import WorkItemLinksMenu from './work_item_links_menu.vue';
+import WorkItemLinkChildContents from '../shared/work_item_link_child_contents.vue';
import WorkItemTreeChildren from './work_item_tree_children.vue';
export default {
components: {
- GlLabel,
- GlLink,
GlButton,
- GlIcon,
- RichTimestampTooltip,
- WorkItemLinkChildMetadata,
- WorkItemLinksMenu,
WorkItemTreeChildren,
+ WorkItemLinkChildContents,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -74,25 +62,9 @@ export default {
};
},
computed: {
- labels() {
- return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || [];
- },
- allowsScopedLabels() {
- return this.metadataWidgets[WIDGET_TYPE_LABELS]?.allowsScopedLabels;
- },
canHaveChildren() {
return this.workItemType === WORK_ITEM_TYPE_VALUE_OBJECTIVE;
},
- metadataWidgets() {
- return this.childItem.widgets?.reduce((metadataWidgets, widget) => {
- // Skip Hierarchy widget as it is not part of metadata.
- if (widget.type && widget.type !== WIDGET_TYPE_HIERARCHY) {
- // eslint-disable-next-line no-param-reassign
- metadataWidgets[widget.type] = widget;
- }
- return metadataWidgets;
- }, {});
- },
isItemOpen() {
return this.childItem.state === STATE_OPEN;
},
@@ -126,18 +98,6 @@ export default {
chevronTooltip() {
return this.isExpanded ? __('Collapse') : __('Expand');
},
- hasMetadata() {
- if (this.metadataWidgets) {
- return (
- Number.isInteger(this.metadataWidgets[WIDGET_TYPE_PROGRESS]?.progress) ||
- Boolean(this.metadataWidgets[WIDGET_TYPE_HEALTH_STATUS]?.healthStatus) ||
- Boolean(this.metadataWidgets[WIDGET_TYPE_MILESTONE]?.milestone) ||
- this.metadataWidgets[WIDGET_TYPE_ASSIGNEES]?.assignees?.nodes.length > 0 ||
- this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes.length > 0
- );
- }
- return false;
- },
},
watch: {
childItem: {
@@ -270,81 +230,15 @@ export default {
data-testid="expand-child"
@click="toggleItem"
/>
- <div
- class="item-body work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-py-2 gl-mx-n2 gl-rounded-base"
- data-testid="links-child"
- >
- <div class="item-contents gl-display-flex gl-flex-grow-1 gl-flex-wrap gl-min-w-0">
- <div
- class="gl-display-flex gl-flex-grow-1 gl-flex-wrap flex-xl-nowrap gl-align-items-center gl-justify-content-space-between gl-gap-3 gl-min-w-0"
- >
- <div class="item-title gl-display-flex gl-gap-3 gl-min-w-0">
- <span
- :id="`stateIcon-${childItem.id}`"
- class="gl-cursor-help"
- data-testid="item-status-icon"
- >
- <gl-icon
- class="gl-text-secondary"
- :class="iconClass"
- :name="iconName"
- :aria-label="stateTimestampTypeText"
- />
- </span>
- <rich-timestamp-tooltip
- :target="`stateIcon-${childItem.id}`"
- :raw-timestamp="stateTimestamp"
- :timestamp-type-text="stateTimestampTypeText"
- />
- <span v-if="childItem.confidential">
- <gl-icon
- v-gl-tooltip.top
- name="eye-slash"
- class="gl-text-orange-500"
- data-testid="confidential-icon"
- :aria-label="__('Confidential')"
- :title="__('Confidential')"
- />
- </span>
- <gl-link
- :href="childPath"
- class="gl-text-truncate gl-text-black-normal! gl-font-weight-semibold"
- data-testid="item-title"
- @click="$emit('click', $event)"
- @mouseover="$emit('mouseover')"
- @mouseout="$emit('mouseout')"
- >
- {{ childItem.title }}
- </gl-link>
- </div>
- <work-item-link-child-metadata
- v-if="hasMetadata"
- :metadata-widgets="metadataWidgets"
- class="gl-ml-6 ml-xl-0"
- />
- </div>
- <div v-if="labels.length" class="gl-display-flex gl-flex-wrap gl-flex-basis-full gl-ml-6">
- <gl-label
- v-for="label in labels"
- :key="label.id"
- :title="label.title"
- :background-color="label.color"
- :description="label.description"
- :scoped="showScopedLabel(label)"
- class="gl-my-2 gl-mr-2 gl-mb-auto gl-label-sm"
- tooltip-placement="top"
- />
- </div>
- </div>
- <div v-if="canUpdate" class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex">
- <work-item-links-menu
- :work-item-id="childItem.id"
- :parent-work-item-id="issuableGid"
- data-testid="links-menu"
- @removeChild="$emit('removeChild', childItem)"
- />
- </div>
- </div>
+ <work-item-link-child-contents
+ :child-item="childItem"
+ :can-update="canUpdate"
+ :parent-work-item-id="issuableGid"
+ :work-item-type="workItemType"
+ :child-path="childPath"
+ @click="$emit('click', $event)"
+ @removeChild="$emit('removeChild', childItem)"
+ />
</div>
<work-item-tree-children
v-if="isExpanded"
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 bfc6ceefccc..a0ff693e156 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
@@ -1,5 +1,11 @@
<script>
-import { GlDropdown, GlDropdownItem, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+import {
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+ GlIcon,
+ GlLoadingIcon,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { s__ } from '~/locale';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
@@ -20,8 +26,8 @@ import WorkItemChildrenWrapper from './work_item_children_wrapper.vue';
export default {
components: {
- GlDropdown,
- GlDropdownItem,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
GlIcon,
GlLoadingIcon,
WidgetWrapper,
@@ -211,26 +217,30 @@ export default {
</span>
</template>
<template #header-right>
- <gl-dropdown
+ <gl-disclosure-dropdown
v-if="canUpdate && canAddTask"
- right
+ placement="right"
size="small"
- :text="$options.i18n.addChildButtonLabel"
+ :toggle-text="$options.i18n.addChildButtonLabel"
data-testid="toggle-form"
>
- <gl-dropdown-item
+ <gl-disclosure-dropdown-item
data-testid="toggle-create-form"
- @click="showAddForm($options.FORM_TYPES.create)"
+ @action="showAddForm($options.FORM_TYPES.create)"
>
- {{ $options.i18n.createChildOptionLabel }}
- </gl-dropdown-item>
- <gl-dropdown-item
+ <template #list-item>
+ {{ $options.i18n.createChildOptionLabel }}
+ </template>
+ </gl-disclosure-dropdown-item>
+ <gl-disclosure-dropdown-item
data-testid="toggle-add-form"
- @click="showAddForm($options.FORM_TYPES.add)"
+ @action="showAddForm($options.FORM_TYPES.add)"
>
- {{ $options.i18n.addChildOptionLabel }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <template #list-item>
+ {{ $options.i18n.addChildOptionLabel }}
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown>
</template>
<template #body>
<div class="gl-new-card-content">
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
index db649913602..4960189fb48 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
@@ -306,6 +306,7 @@ export default {
[this.error] = data.workItemCreate.errors;
} else {
this.unsetError();
+ this.$emit('addChild');
}
})
.catch(() => {
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
index 83f3c391769..246eac82c78 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
@@ -145,6 +145,7 @@ export default {
:children-ids="childrenIds"
:parent-confidential="confidential"
@cancel="hideAddForm"
+ @addChild="$emit('addChild')"
/>
<work-item-children-wrapper
:children="children"
diff --git a/app/assets/javascripts/work_items/components/work_item_state_badge.vue b/app/assets/javascripts/work_items/components/work_item_state_badge.vue
new file mode 100644
index 00000000000..1d1bc7352b1
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_state_badge.vue
@@ -0,0 +1,41 @@
+<script>
+import { GlBadge } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { STATE_OPEN } from '../constants';
+
+export default {
+ components: {
+ GlBadge,
+ },
+ props: {
+ workItemState: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ isWorkItemOpen() {
+ return this.workItemState === STATE_OPEN;
+ },
+ stateText() {
+ return this.isWorkItemOpen ? __('Open') : __('Closed');
+ },
+ workItemStateIcon() {
+ return this.isWorkItemOpen ? 'issue-open-m' : 'issue-close';
+ },
+ workItemStateVariant() {
+ return this.isWorkItemOpen ? 'success' : 'info';
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-badge
+ :icon="workItemStateIcon"
+ :variant="workItemStateVariant"
+ class="gl-mr-2 gl-vertical-align-middle"
+ >
+ {{ stateText }}
+ </gl-badge>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_state.vue b/app/assets/javascripts/work_items/components/work_item_state_toggle_button.vue
index 3880ae25c8c..0ea30845466 100644
--- a/app/assets/javascripts/work_items/components/work_item_state.vue
+++ b/app/assets/javascripts/work_items/components/work_item_state_toggle_button.vue
@@ -1,26 +1,35 @@
<script>
+import { GlButton } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import Tracking from '~/tracking';
+import { __, sprintf } from '~/locale';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+import { getUpdateWorkItemMutation } from '~/work_items/components/update_work_item';
import {
sprintfWorkItem,
I18N_WORK_ITEM_ERROR_UPDATING,
STATE_OPEN,
- STATE_CLOSED,
STATE_EVENT_CLOSE,
STATE_EVENT_REOPEN,
TRACKING_CATEGORY_SHOW,
} from '../constants';
-import { getUpdateWorkItemMutation } from './update_work_item';
-import ItemState from './item_state.vue';
export default {
components: {
- ItemState,
+ GlButton,
},
mixins: [Tracking.mixin()],
props: {
- workItem: {
- type: Object,
+ workItemState: {
+ type: String,
+ required: true,
+ },
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ workItemType: {
+ type: String,
required: true,
},
workItemParentId: {
@@ -28,11 +37,6 @@ export default {
required: false,
default: null,
},
- canUpdate: {
- type: Boolean,
- required: false,
- default: false,
- },
},
data() {
return {
@@ -40,8 +44,16 @@ export default {
};
},
computed: {
- workItemType() {
- return this.workItem.workItemType?.name;
+ isWorkItemOpen() {
+ return this.workItemState === STATE_OPEN;
+ },
+ toggleWorkItemStateText() {
+ const baseText = this.isWorkItemOpen
+ ? __('Close %{workItemType}')
+ : __('Reopen %{workItemType}');
+ return capitalizeFirstCharacter(
+ sprintf(baseText, { workItemType: this.workItemType.toLowerCase() }),
+ );
},
tracking() {
return {
@@ -52,25 +64,10 @@ export default {
},
},
methods: {
- updateWorkItemState(newState) {
- const stateEventMap = {
- [STATE_OPEN]: STATE_EVENT_REOPEN,
- [STATE_CLOSED]: STATE_EVENT_CLOSE,
- };
-
- const stateEvent = stateEventMap[newState];
-
- this.updateWorkItem(stateEvent);
- },
-
- async updateWorkItem(updatedState) {
- if (!updatedState) {
- return;
- }
-
+ async updateWorkItem() {
const input = {
- id: this.workItem.id,
- stateEvent: updatedState,
+ id: this.workItemId,
+ stateEvent: this.isWorkItemOpen ? STATE_EVENT_CLOSE : STATE_EVENT_REOPEN,
};
this.updateInProgress = true;
@@ -107,10 +104,10 @@ export default {
</script>
<template>
- <item-state
- v-if="workItem.state"
- :state="workItem.state"
- :disabled="updateInProgress || !canUpdate"
- @changed="updateWorkItemState"
- />
+ <gl-button
+ :loading="updateInProgress"
+ data-testid="work-item-state-toggle"
+ @click="updateWorkItem"
+ >{{ toggleWorkItemStateText }}</gl-button
+ >
</template>
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 96a6493357c..f27ae5f4e6d 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
@@ -32,13 +32,18 @@ export default {
},
},
computed: {
+ workItemTypeUppercase() {
+ return this.workItemType.toUpperCase().split(' ').join('_');
+ },
iconName() {
return (
- this.workItemIconName || WORK_ITEMS_TYPE_MAP[this.workItemType]?.icon || 'issue-type-issue'
+ this.workItemIconName ||
+ WORK_ITEMS_TYPE_MAP[this.workItemTypeUppercase]?.icon ||
+ 'issue-type-issue'
);
},
workItemTypeName() {
- return WORK_ITEMS_TYPE_MAP[this.workItemType]?.name;
+ return WORK_ITEMS_TYPE_MAP[this.workItemTypeUppercase]?.name;
},
workItemTooltipTitle() {
return this.showTooltipOnHover ? this.workItemTypeName : '';
@@ -48,12 +53,12 @@ export default {
</script>
<template>
- <span>
+ <span class="gl-mr-2">
<gl-icon
v-gl-tooltip.hover="showTooltipOnHover"
:name="iconName"
:title="workItemTooltipTitle"
- class="gl-mr-2 gl-text-secondary"
+ class="gl-text-secondary"
/>
<span v-if="workItemTypeName" :class="{ 'gl-sr-only': !showText }">{{ workItemTypeName }}</span>
</span>
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index b8324d7d552..57206550328 100644
--- a/app/assets/javascripts/work_items/constants.js
+++ b/app/assets/javascripts/work_items/constants.js
@@ -159,7 +159,7 @@ export const WORK_ITEMS_TYPE_MAP = {
},
[WORK_ITEM_TYPE_ENUM_KEY_RESULT]: {
icon: `issue-type-keyresult`,
- name: s__('WorkItem|Key Result'),
+ name: s__('WorkItem|Key result'),
value: WORK_ITEM_TYPE_VALUE_KEY_RESULT,
},
};
@@ -247,3 +247,13 @@ export const EMOJI_ACTION_ADD = 'ADD';
export const EMOJI_ACTION_REMOVE = 'REMOVE';
export const EMOJI_THUMBSUP = 'thumbsup';
export const EMOJI_THUMBSDOWN = 'thumbsdown';
+
+export const WORK_ITEM_TO_ISSUE_MAP = {
+ [WIDGET_TYPE_ASSIGNEES]: 'assignees',
+ [WIDGET_TYPE_LABELS]: 'labels',
+ [WIDGET_TYPE_MILESTONE]: 'milestone',
+ [WIDGET_TYPE_WEIGHT]: 'weight',
+ [WIDGET_TYPE_START_AND_DUE_DATE]: 'dueDate',
+ [WIDGET_TYPE_HEALTH_STATUS]: 'healthStatus',
+ [WIDGET_TYPE_AWARD_EMOJI]: 'awardEmoji',
+};
diff --git a/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql b/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql
index 5c93370aac9..9828363990b 100644
--- a/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql
@@ -5,4 +5,5 @@ fragment MilestoneFragment on Milestone {
state
startDate
dueDate
+ webPath
}
diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql
index f8952b62f28..f28317b79b5 100644
--- a/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql
@@ -1,13 +1,9 @@
-mutation updateWorkItemNotificationsWidget($input: WorkItemUpdateInput!) {
- workItemUpdate(input: $input) {
- workItem {
+mutation updateWorkItemNotificationsWidget($input: IssueSetSubscriptionInput!) {
+ updateWorkItemNotificationsSubscription: issueSetSubscription(input: $input) {
+ issue {
id
- widgets {
- ... on WorkItemWidgetNotifications {
- type
- subscribed
- }
- }
+ subscribed
}
+ errors
}
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql
index 4c3be007d96..66ac9dcd8d1 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql
@@ -1,7 +1,7 @@
#import "./work_item.fragment.graphql"
query workItemByIid($fullPath: ID!, $iid: String) {
- workspace: project(fullPath: $fullPath) {
+ workspace: project(fullPath: $fullPath) @persist {
id
workItems(iid: $iid) {
nodes {
diff --git a/app/assets/javascripts/work_items/list/components/work_items_list_app.vue b/app/assets/javascripts/work_items/list/components/work_items_list_app.vue
new file mode 100644
index 00000000000..fe7cb719bbb
--- /dev/null
+++ b/app/assets/javascripts/work_items/list/components/work_items_list_app.vue
@@ -0,0 +1,73 @@
+<script>
+import * as Sentry from '@sentry/browser';
+import { STATUS_OPEN } from '~/issues/constants';
+import { __, s__ } from '~/locale';
+import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
+import { issuableListTabs } from '~/vue_shared/issuable/list/constants';
+import { STATE_CLOSED } from '../../constants';
+import getWorkItemsQuery from '../queries/get_work_items.query.graphql';
+
+export default {
+ i18n: {
+ searchPlaceholder: __('Search or filter results...'),
+ },
+ issuableListTabs,
+ components: {
+ IssuableList,
+ },
+ inject: ['fullPath'],
+ data() {
+ return {
+ error: undefined,
+ searchTokens: [],
+ sortOptions: [],
+ state: STATUS_OPEN,
+ workItems: [],
+ };
+ },
+ apollo: {
+ workItems: {
+ query: getWorkItemsQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ };
+ },
+ update(data) {
+ return data.group.workItems.nodes ?? [];
+ },
+ error(error) {
+ this.error = s__(
+ 'WorkItem|Something went wrong when fetching work items. Please try again.',
+ );
+ Sentry.captureException(error);
+ },
+ },
+ },
+ methods: {
+ getStatus(issue) {
+ return issue.state === STATE_CLOSED ? __('Closed') : undefined;
+ },
+ },
+};
+</script>
+
+<template>
+ <issuable-list
+ :current-tab="state"
+ :error="error"
+ :issuables="workItems"
+ namespace="work-items"
+ recent-searches-storage-key="issues"
+ :search-input-placeholder="$options.i18n.searchPlaceholder"
+ :search-tokens="searchTokens"
+ show-work-item-type-icon
+ :sort-options="sortOptions"
+ :tabs="$options.issuableListTabs"
+ @dismiss-alert="error = undefined"
+ >
+ <template #status="{ issuable }">
+ {{ getStatus(issuable) }}
+ </template>
+ </issuable-list>
+</template>
diff --git a/app/assets/javascripts/work_items/list/index.js b/app/assets/javascripts/work_items/list/index.js
new file mode 100644
index 00000000000..5cd38600779
--- /dev/null
+++ b/app/assets/javascripts/work_items/list/index.js
@@ -0,0 +1,26 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import WorkItemsListApp from './components/work_items_list_app.vue';
+
+export const mountWorkItemsListApp = () => {
+ const el = document.querySelector('.js-work-items-list-root');
+
+ if (!el) {
+ return null;
+ }
+
+ Vue.use(VueApollo);
+
+ return new Vue({
+ el,
+ name: 'WorkItemsListRoot',
+ apolloProvider: new VueApollo({
+ defaultClient: createDefaultClient(),
+ }),
+ provide: {
+ fullPath: el.dataset.fullPath,
+ },
+ render: (createComponent) => createComponent(WorkItemsListApp),
+ });
+};
diff --git a/app/assets/javascripts/work_items/list/queries/get_work_items.query.graphql b/app/assets/javascripts/work_items/list/queries/get_work_items.query.graphql
new file mode 100644
index 00000000000..7ada2cf12dd
--- /dev/null
+++ b/app/assets/javascripts/work_items/list/queries/get_work_items.query.graphql
@@ -0,0 +1,56 @@
+query getWorkItems($fullPath: ID!) {
+ group(fullPath: $fullPath) {
+ id
+ workItems {
+ nodes {
+ id
+ author {
+ id
+ avatarUrl
+ name
+ username
+ webUrl
+ }
+ closedAt
+ confidential
+ createdAt
+ iid
+ reference(full: true)
+ state
+ title
+ updatedAt
+ webUrl
+ widgets {
+ ... on WorkItemWidgetAssignees {
+ assignees {
+ nodes {
+ id
+ avatarUrl
+ name
+ username
+ webUrl
+ }
+ }
+ type
+ }
+ ... on WorkItemWidgetLabels {
+ allowsScopedLabels
+ labels {
+ nodes {
+ id
+ color
+ description
+ title
+ }
+ }
+ type
+ }
+ }
+ workItemType {
+ id
+ name
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js
index 81dbe56b2ea..5a882977bc2 100644
--- a/app/assets/javascripts/work_items/utils.js
+++ b/app/assets/javascripts/work_items/utils.js
@@ -1,4 +1,8 @@
-import { WIDGET_TYPE_HIERARCHY } from '~/work_items/constants';
+import { WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_HIERARCHY, WIDGET_TYPE_LABELS } from './constants';
+
+export const isAssigneesWidget = (widget) => widget.type === WIDGET_TYPE_ASSIGNEES;
+
+export const isLabelsWidget = (widget) => widget.type === WIDGET_TYPE_LABELS;
export const findHierarchyWidgets = (widgets) =>
widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY);
diff --git a/app/assets/javascripts/work_items_hierarchy/components/hierarchy.vue b/app/assets/javascripts/work_items_hierarchy/components/hierarchy.vue
index 9b81218b6e4..69107c7df12 100644
--- a/app/assets/javascripts/work_items_hierarchy/components/hierarchy.vue
+++ b/app/assets/javascripts/work_items_hierarchy/components/hierarchy.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlIcon, GlBadge } from '@gitlab/ui';
diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss
index 483c4dc226b..47701d0490a 100644
--- a/app/assets/stylesheets/_page_specific_files.scss
+++ b/app/assets/stylesheets/_page_specific_files.scss
@@ -5,7 +5,6 @@
@import './pages/hierarchy';
@import './pages/issues';
@import './pages/labels';
-@import './pages/merge_requests';
@import './pages/note_form';
@import './pages/notes';
@import './pages/pipelines';
diff --git a/app/assets/stylesheets/components/content_editor.scss b/app/assets/stylesheets/components/content_editor.scss
index 08a956bf90f..2030f2c7095 100644
--- a/app/assets/stylesheets/components/content_editor.scss
+++ b/app/assets/stylesheets/components/content_editor.scss
@@ -114,6 +114,18 @@
max-width: 100%;
}
+ > ul {
+ list-style-type: disc;
+
+ ul {
+ list-style-type: circle;
+
+ ul {
+ list-style-type: square;
+ }
+ }
+ }
+
ul[data-type='taskList'] {
list-style: none;
padding: 0;
diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss
index 56ec61ffd84..28c0c071dc0 100644
--- a/app/assets/stylesheets/framework/awards.scss
+++ b/app/assets/stylesheets/framework/awards.scss
@@ -256,6 +256,10 @@
gl-emoji {
margin-top: -1px;
margin-bottom: -1px;
+
+ img {
+ top: 0;
+ }
}
}
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index 8059164782f..8a64b0999b6 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -126,23 +126,12 @@
}
@mixin btn-with-margin {
- margin-left: $btn-side-margin;
+ @include gl-ml-3;
float: left;
&.inline {
float: none;
}
-
- &.btn-sm {
- margin-left: $btn-sm-side-margin;
- }
-}
-
-@mixin btn-svg {
- height: $gl-padding;
- width: $gl-padding;
- top: 0;
- vertical-align: text-top;
}
.btn {
@@ -348,14 +337,6 @@
}
}
-// The .btn-svg class is available for legacy icon buttons to
-// preserve a 34px height and have 16x16 icons at the same time.
-// Once a button is migrated (to the current 32px height)
-// please remove this class from the new button.
-.btn-svg svg {
- @include btn-svg;
-}
-
// All disabled buttons, regardless of color, type, etc
.btn.disabled,
.btn[disabled],
diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss
index 7b35659e90a..4bf109a0bff 100644
--- a/app/assets/stylesheets/framework/diffs.scss
+++ b/app/assets/stylesheets/framework/diffs.scss
@@ -646,12 +646,12 @@ table.code {
.diff-comments-more-count,
.diff-notes-collapse,
-.diff-codequality-collapse {
+.inline-findings-collapse {
@include avatar-counter(50%);
}
.diff-notes-collapse,
-.diff-codequality-collapse {
+.inline-findings-collapse {
border: 0;
border-radius: 50%;
padding: 0;
@@ -735,7 +735,7 @@ table.code {
}
.diff-notes-collapse,
- .diff-codequality-collapse {
+ .inline-findings-collapse {
position: absolute;
left: -12px;
}
@@ -845,7 +845,7 @@ table.code {
}
.diff-notes-collapse,
- .diff-codequality-collapse,
+ .inline-findings-collapse,
.note,
.discussion-reply-holder {
display: none;
@@ -929,3 +929,13 @@ table.code {
border-bottom: 0;
}
}
+
+.tooltip {
+ &.coverage {
+ left: -3px !important;
+ }
+
+ &.no-coverage {
+ left: -2px !important;
+ }
+}
diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss
index 358f599e0e9..9b22e4cebb2 100644
--- a/app/assets/stylesheets/framework/emojis.scss
+++ b/app/assets/stylesheets/framework/emojis.scss
@@ -3,10 +3,14 @@ gl-emoji {
display: inline-flex;
vertical-align: baseline;
font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
+ font-size: 1.2em;
+ line-height: 1;
img {
width: 1.2em;
height: 1.2em;
+ position: relative;
+ top: 0.25em;
}
}
@@ -45,10 +49,18 @@ gl-emoji {
.emoji-picker-category-tab {
border-bottom-color: transparent;
+
+ &:hover {
+ @include gl-text-gray-900;
+
+ &:not(.emoji-picker-category-active) {
+ @include gl-border-b-gray-200;
+ }
+ }
}
.emoji-picker-category-active {
- border-bottom-color: var(--gl-theme-accent, $theme-indigo-500);
+ border-bottom-color: $blue-500;
}
.emoji-picker .gl-dropdown-inner > :last-child {
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 2e88b45d646..613e504c771 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -263,6 +263,11 @@ span.idiff {
}
}
+.file-validation {
+ // we use $gray-light variable instead of utility class, because it's value is dynamic per color theme
+ background-color: $gray-light;
+}
+
.blob-content-holder .file-actions {
@include media-breakpoint-down(sm) {
.btn {
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index f5ed85e8845..b9fbcfb642c 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -123,7 +123,9 @@ $search-input-field-x-min-width: 200px;
padding: 0;
@include media-breakpoint-down(xs) {
- flex: 1 1 auto;
+ .legacy-top-bar & {
+ flex: 1 1 auto;
+ }
}
.nav {
@@ -193,8 +195,10 @@ $search-input-field-x-min-width: 200px;
padding: 6px 8px;
height: 32px;
- @include media-breakpoint-down(xs) {
- padding: 0;
+ .legacy-top-bar & {
+ @include media-breakpoint-down(xs) {
+ padding: 0;
+ }
}
&.header-user-dropdown-toggle {
@@ -322,7 +326,7 @@ $search-input-field-x-min-width: 200px;
left: var(--application-bar-left);
position: fixed;
right: var(--application-bar-right);
- top: $calc-system-headers-height;
+ top: $calc-application-bars-height;
width: auto;
z-index: $top-bar-z-index;
@@ -427,7 +431,7 @@ $search-input-field-x-min-width: 200px;
}
@include media-breakpoint-down(xs) {
- .navbar-gitlab .container-fluid {
+ .navbar-gitlab.legacy-top-bar .container-fluid {
font-size: 18px;
.navbar-nav {
@@ -622,3 +626,27 @@ $search-input-field-x-min-width: 200px;
}
}
}
+
+header.navbar-gitlab.super-sidebar-logged-out {
+ background-color: $brand-charcoal !important;
+
+ li.nav-item > a {
+ @include gl-text-white;
+ @include gl-font-weight-normal;
+
+ &:hover,
+ &:focus {
+ background-color: $brand-gray-04;
+ text-decoration: none;
+ }
+
+ &:focus,
+ &:active {
+ box-shadow: inset 0 0 0 $gl-border-size-1 $white;
+ }
+
+ &:active {
+ background-color: $brand-gray-03;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index f8f54567ef2..b953ff3024b 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -91,7 +91,7 @@
}
.md-preview-holder {
- min-height: 173px;
+ min-height: 177px;
padding: 10px 0;
overflow-x: auto;
}
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index 529f6acaf04..edebe9c95ad 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -326,11 +326,6 @@
color: $gray-500;
fill: $gray-500;
- svg {
- @include btn-svg;
- margin: 0;
- }
-
.award-control-icon-positive,
.award-control-icon-super-positive {
position: absolute;
diff --git a/app/assets/stylesheets/framework/new_card.scss b/app/assets/stylesheets/framework/new_card.scss
index ef8f5cc1d1b..1432e7a174c 100644
--- a/app/assets/stylesheets/framework/new_card.scss
+++ b/app/assets/stylesheets/framework/new_card.scss
@@ -92,3 +92,64 @@
@include gl-rounded-base;
}
}
+
+.gl-new-card-body {
+ // Table adjustments
+ @mixin new-card-table-adjustments {
+ tbody > tr {
+ &:first-of-type > td[data-label],
+ &:first-of-type > td:first-of-type:last-of-type {
+ @include gl-border-t-0;
+ }
+
+ &:last-of-type td:not(:last-of-type) {
+ @include gl-border-b-1;
+ }
+
+ > td[data-label] {
+ @include gl-border-left-0;
+ @include gl-border-l-none;
+ @include gl-border-right-0;
+ @include gl-border-r-none;
+ }
+
+ > th {
+ @include gl-border-t-1;
+ @include gl-border-b-0;
+ }
+
+ &::after {
+ @include gl-bg-white;
+ }
+
+ &:last-child::after {
+ @include gl-display-none;
+ }
+ }
+ }
+
+ table.b-table-stacked-sm,
+ table.b-table-stacked-md {
+ @include gl-mb-0;
+
+ tr:first-of-type th {
+ @include gl-border-t-0;
+ }
+
+ tr:last-of-type td {
+ @include gl-border-b-0;
+ }
+ }
+
+ table.gl-table.b-table.b-table-stacked-sm {
+ @include gl-media-breakpoint-down(sm) {
+ @include new-card-table-adjustments;
+ }
+ }
+
+ table.gl-table.b-table.b-table-stacked-md {
+ @include gl-media-breakpoint-down(md) {
+ @include new-card-table-adjustments;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
index 5ba0b1d0828..f77a919ef0f 100644
--- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss
+++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
@@ -39,7 +39,7 @@
a.active {
color: $black;
font-weight: $gl-font-weight-bold;
- box-shadow: inset 0 -2px 0 0 var(--gl-theme-accent, $theme-indigo-500);
+ box-shadow: inset 0 -2px 0 0 $blue-500;
.badge.badge-pill {
color: $black;
diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss
index 12801b272e8..8610c41b43f 100644
--- a/app/assets/stylesheets/framework/super_sidebar.scss
+++ b/app/assets/stylesheets/framework/super_sidebar.scss
@@ -159,33 +159,12 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4;
}
.nav-item-link {
- button,
- .draggable-icon {
- opacity: 0;
- }
-
- .draggable-icon {
- cursor: grab;
- }
-
- &:hover {
- button,
- .draggable-icon {
- opacity: 1;
- }
- }
-
&:hover,
&:focus-within {
.nav-item-badge {
opacity: 0;
}
}
-
- &:focus button,
- button:focus {
- opacity: 1;
- }
}
#trial-status-sidebar-widget:hover {
@@ -294,8 +273,8 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4;
}
.search-scope-help {
- top: 0.625rem;
- right: 2.5rem;
+ top: 1rem;
+ right: 3rem;
}
.gl-search-box-by-type-input-borderless {
@@ -304,5 +283,31 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4;
.global-search-results {
max-height: 30rem;
+
+ .gl-new-dropdown-item {
+ @include gl-px-3;
+ }
+
+ // Target groups
+ [id*='gl-disclosure-dropdown-group'] {
+ @include gl-px-5;
+ }
+ }
+}
+
+.show-on-focus-or-hover--context {
+ .show-on-focus-or-hover--target {
+ opacity: 0;
+ }
+
+ &:hover,
+ &:focus {
+ .show-on-focus-or-hover--target {
+ opacity: 1;
+ }
+ }
+
+ .show-on-focus-or-hover--target:focus {
+ opacity: 1;
}
}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index ebaaece1281..d632689a4f6 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -473,7 +473,6 @@ $border-radius-large: 8px;
$default-icon-size: 16px;
$layout-link-gray: #7e7c7c;
$btn-side-margin: $grid-size;
-$btn-sm-side-margin: 7px;
$count-arrow-border: #dce0e5;
$general-hover-transition-duration: 100ms;
$general-hover-transition-curve: linear;
@@ -866,6 +865,7 @@ $security-and-compliance-carousel-image-discover-text-carousel-max-width: 650px;
$security-and-compliance-carousel-image-discover-text-carousel-caption-height: 100%;
$security-and-compliance-carousel-image-discover-text-carousel-caption-max-width: 500px;
$security-and-compliance-carousel-control-icon-width: 10px;
+$security-and-compliance-carousel-control-icon-middle-width: 20px;
$security-and-compliance-carousel-control-position: -5%;
$security-and-compliance-carousel-inner-width: 90%;
$security-and-compliance-carousel-indicators-bottom: -20px;
diff --git a/app/assets/stylesheets/highlight/diff_custom_colors_addition.scss b/app/assets/stylesheets/highlight/diff_custom_colors_addition.scss
index 5f195bc47bf..675a33617bf 100644
--- a/app/assets/stylesheets/highlight/diff_custom_colors_addition.scss
+++ b/app/assets/stylesheets/highlight/diff_custom_colors_addition.scss
@@ -6,7 +6,7 @@
.line_holder {
.diff-line-num,
.line-coverage,
- .line-codequality,
+ .line-inline-findings,
.line_content {
&.new {
&:not(.hll) {
diff --git a/app/assets/stylesheets/highlight/diff_custom_colors_deletion.scss b/app/assets/stylesheets/highlight/diff_custom_colors_deletion.scss
index a8ab43909eb..39b74f10199 100644
--- a/app/assets/stylesheets/highlight/diff_custom_colors_deletion.scss
+++ b/app/assets/stylesheets/highlight/diff_custom_colors_deletion.scss
@@ -6,7 +6,7 @@
.line_holder {
.diff-line-num,
.line-coverage,
- .line-codequality,
+ .line-inline-findings,
.line_content {
&.old {
&:not(.hll) {
diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss
index 9ad7c1b796c..8596d79ca83 100644
--- a/app/assets/stylesheets/highlight/themes/dark.scss
+++ b/app/assets/stylesheets/highlight/themes/dark.scss
@@ -191,7 +191,7 @@ $dark-il: #de935f;
.diff-td.diff-line-num.hll,
.diff-td.line-coverage.hll,
- .diff-td.line-codequality.hll,
+ .diff-td.line-inline-findings.hll,
.diff-td.line_content.hll,
td.diff-line-num.hll,
td.line-coverage.hll,
@@ -206,11 +206,11 @@ $dark-il: #de935f;
.diff-line-num.new,
.line-coverage.new,
- .line-codequality.new,
+ .line-inline-findings.new,
.line_content.new,
.diff-line-num.new-nomappinginraw,
.line-coverage.new-nomappinginraw,
- .line-codequality.new-nomappinginraw,
+ .line-inline-findings.new-nomappinginraw,
.line_content.new-nomappinginraw {
@include diff-background($dark-new-bg, $dark-new-idiff, $dark-border);
@@ -222,11 +222,11 @@ $dark-il: #de935f;
.diff-line-num.old,
.line-coverage.old,
- .line-codequality.old,
+ .line-inline-findings.old,
.line_content.old,
.diff-line-num.old-nomappinginraw,
.line-coverage.old-nomappinginraw,
- .line-codequality.old-nomappinginraw,
+ .line-inline-findings.old-nomappinginraw,
.line_content.old-nomappinginraw {
@include diff-background($dark-old-bg, $dark-old-idiff, $dark-border);
diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss
index b1d89d3c253..1c323ad15a6 100644
--- a/app/assets/stylesheets/highlight/themes/monokai.scss
+++ b/app/assets/stylesheets/highlight/themes/monokai.scss
@@ -182,7 +182,7 @@ $monokai-gh: #75715e;
.diff-td.diff-line-num.hll,
.diff-td.line-coverage.hll,
- .diff-td.line-codequality.hll,
+ .diff-td.line-inline-findings.hll,
.diff-td.line_content.hll,
td.diff-line-num.hll,
td.line-coverage.hll,
@@ -197,11 +197,11 @@ $monokai-gh: #75715e;
.diff-line-num.new,
.line-coverage.new,
- .line-codequality.new,
+ .line-inline-findings.new,
.line_content.new,
.diff-line-num.new-nomappinginraw,
.line-coverage.new-nomappinginraw,
- .line-codequality.new-nomappinginraw,
+ .line-inline-findings.new-nomappinginraw,
.line_content.new-nomappinginraw {
@include diff-background($monokai-new-bg, $monokai-new-idiff, $monokai-diff-border);
@@ -213,11 +213,11 @@ $monokai-gh: #75715e;
.diff-line-num.old,
.line-coverage.old,
- .line-codequality.old,
+ .line-inline-findings.old,
.line_content.old,
.diff-line-num.old-nomappinginraw,
.line-coverage.old-nomappinginraw,
- .line-codequality.old-nomappinginraw,
+ .line-inline-findings.old-nomappinginraw,
.line_content.old-nomappinginraw {
@include diff-background($monokai-old-bg, $monokai-old-idiff, $monokai-diff-border);
diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss
index 4762aae1d12..f36eaa663e5 100644
--- a/app/assets/stylesheets/highlight/themes/none.scss
+++ b/app/assets/stylesheets/highlight/themes/none.scss
@@ -81,7 +81,7 @@
}
.line-coverage:not(.hll),
- .line-codequality:not(.hll) {
+ .line-inline-findings:not(.hll) {
&.old,
&.new,
&.new-nomappinginraw,
diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
index 7958959bfc3..e92239c4e11 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
@@ -185,7 +185,7 @@ $solarized-dark-il: #2aa198;
.diff-td.diff-line-num.hll,
.diff-td.line-coverage.hll,
- .diff-td.line-codequality.hll,
+ .diff-td.line-inline-findings.hll,
.diff-td.line_content.hll,
td.diff-line-num.hll,
td.line-coverage.hll,
@@ -208,11 +208,11 @@ $solarized-dark-il: #2aa198;
.diff-line-num.new,
.line-coverage.new,
- .line-codequality.new,
+ .line-inline-findings.new,
.line_content.new,
.diff-line-num.new-nomappinginraw,
.line-coverage.new-nomappinginraw,
- .line-codequality.new-nomappinginraw,
+ .line-inline-findings.new-nomappinginraw,
.line_content.new-nomappinginraw {
@include diff-background($solarized-dark-new-bg, $solarized-dark-new-idiff, $solarized-dark-border);
@@ -224,11 +224,11 @@ $solarized-dark-il: #2aa198;
.diff-line-num.old,
.line-coverage.old,
- .line-codequality.old,
+ .line-inline-findings.old,
.line_content.old,
.diff-line-num.old-nomappinginraw,
.line-coverage.old-nomappinginraw,
- .line-codequality.old-nomappinginraw,
+ .line-inline-findings.old-nomappinginraw,
.line_content.old-nomappinginraw {
@include diff-background($solarized-dark-old-bg, $solarized-dark-old-idiff, $solarized-dark-border);
diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss
index f156077c64d..b3aa10c3ace 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-light.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss
@@ -172,7 +172,7 @@ $solarized-light-il: #2aa198;
.diff-td.diff-line-num.hll,
.diff-td.line-coverage.hll,
- .diff-td.line-codequality.hll,
+ .diff-td.line-inline-findings.hll,
.diff-td.line_content.hll,
td.diff-line-num.hll,
td.line-coverage.hll,
@@ -187,11 +187,11 @@ $solarized-light-il: #2aa198;
.diff-line-num.new,
.line-coverage.new,
- .line-codequality.new,
+ .line-inline-findings.new,
.line_content.new,
.diff-line-num.new-nomappinginraw,
.line-coverage.new-nomappinginraw,
- .line-codequality.new-nomappinginraw,
+ .line-inline-findings.new-nomappinginraw,
.line_content.new-nomappinginraw {
@include diff-background($solarized-light-new-bg,
$solarized-light-new-idiff, $solarized-light-border);
@@ -212,11 +212,11 @@ $solarized-light-il: #2aa198;
.diff-line-num.old,
.line-coverage.old,
- .line-codequality.old,
+ .line-inline-findings.old,
.line_content.old,
.diff-line-num.old-nomappinginraw,
.line-coverage.old-nomappinginraw,
- .line-codequality.old-nomappinginraw,
+ .line-inline-findings.old-nomappinginraw,
.line_content.old-nomappinginraw {
@include diff-background($solarized-light-old-bg, $solarized-light-old-idiff, $solarized-light-border);
diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss
index 14524e163b2..2631055706f 100644
--- a/app/assets/stylesheets/highlight/white_base.scss
+++ b/app/assets/stylesheets/highlight/white_base.scss
@@ -253,7 +253,7 @@ pre.code,
}
.line-coverage,
- .line-codequality {
+ .line-inline-findings {
&.old,
&.old-nomappinginraw {
background-color: $line-removed;
diff --git a/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss b/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss
index ed15e352b7d..a904afa7337 100644
--- a/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss
+++ b/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss
@@ -72,7 +72,7 @@
position: relative;
// link to the build
- .mini-pipeline-graph-dropdown-item {
+ .pipeline-job-item {
align-items: center;
clear: both;
display: flex;
@@ -86,11 +86,11 @@
}
}
- // ensure .mini-pipeline-graph-dropdown-item has hover style when action-icon is hovered
- &:hover > .mini-pipeline-graph-dropdown-item,
- &:hover > .ci-job-component > .mini-pipeline-graph-dropdown-item,
- .mini-pipeline-graph-dropdown-item:hover,
- .mini-pipeline-graph-dropdown-item:focus {
+ // ensure .pipeline-job-item has hover style when action-icon is hovered
+ &:hover > .pipeline-job-item,
+ &:hover > .ci-job-component > .pipeline-job-item,
+ .pipeline-job-item:hover,
+ .pipeline-job-item:focus {
outline: none;
text-decoration: none;
background-color: var(--gray-100, $gray-50);
diff --git a/app/assets/stylesheets/page_bundles/editor.scss b/app/assets/stylesheets/page_bundles/editor.scss
index 55fffad4a0e..c950f277264 100644
--- a/app/assets/stylesheets/page_bundles/editor.scss
+++ b/app/assets/stylesheets/page_bundles/editor.scss
@@ -125,7 +125,7 @@
@include media-breakpoint-down(sm) {
.file-editor .file-buttons {
flex-direction: column;
- padding: 0;
+ padding: $gl-padding-8 0 0;
.md-header-toolbar {
margin: $gl-padding-8 0;
@@ -166,41 +166,6 @@
width: 100%;
margin: 0 0 16px;
}
-
- .license-selector,
- .gitignore-selector,
- .gitlab-ci-yml-selector,
- .dockerfile-selector {
- display: inline-block;
- vertical-align: top;
- font-family: $regular_font;
- margin: 0 8px 0 0;
-
- @media(max-width: map-get($grid-breakpoints, lg)-1) {
- display: block;
- width: 100%;
- margin: 5px 0;
- }
-
- .dropdown {
- line-height: 21px;
- }
-
- .dropdown-menu-toggle {
- width: 200px;
- vertical-align: top;
-
- @media (max-width: map-get($grid-breakpoints, xl)-1) {
- width: auto;
- }
-
- @media(max-width: map-get($grid-breakpoints, lg)-1) {
- display: block;
- width: 100%;
- margin: 5px 0;
- }
- }
- }
}
.popover.suggest-gitlab-ci-yml {
diff --git a/app/assets/stylesheets/page_bundles/incident_management_list.scss b/app/assets/stylesheets/page_bundles/incident_management_list.scss
index 30a75103c30..312d5c2b10c 100644
--- a/app/assets/stylesheets/page_bundles/incident_management_list.scss
+++ b/app/assets/stylesheets/page_bundles/incident_management_list.scss
@@ -5,120 +5,6 @@
background-color: var(--green-50, $green-50);
}
- // these styles need to be deleted once GlTable component looks in GitLab same as in @gitlab/ui
- table {
- color: var(--gray-500, $gray-500);
-
- tbody {
- tr:not(.b-table-busy-slot):not(.b-table-empty-row) {
- &:hover {
- @include gl-border-t-double;
-
- td {
- @include gl-border-b-initial;
- }
- }
- }
- }
-
- tr {
- &:focus {
- @include gl-outline-none;
- }
-
- td,
- th {
- @include gl-py-5;
- @include gl-outline-none;
- @include gl-relative;
- }
-
- th {
- @include gl-bg-transparent;
- @include gl-font-weight-bold;
- color: var(--gray-400, $gray-400);
-
-
- &[aria-sort='none']:hover {
- background-image: url('data:image/svg+xml, %3csvg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="4 0 8 16"%3e %3cpath style="fill: %23BABABA;" fill-rule="evenodd" d="M11.707085,11.7071 L7.999975,15.4142 L4.292875,11.7071 C3.902375,11.3166 3.902375, 10.6834 4.292875,10.2929 C4.683375,9.90237 5.316575,9.90237 5.707075,10.2929 L6.999975, 11.5858 L6.999975,2 C6.999975,1.44771 7.447695,1 7.999975,1 C8.552255,1 8.999975,1.44771 8.999975,2 L8.999975,11.5858 L10.292865,10.2929 C10.683395 ,9.90237 11.316555,9.90237 11.707085,10.2929 C12.097605,10.6834 12.097605,11.3166 11.707085,11.7071 Z"/%3e %3c/svg%3e');
- }
- }
- }
-
- @include media-breakpoint-up(md) {
- tr {
- &:last-child {
- td {
- @include gl-border-0;
- }
- }
- }
-
- .sortable-cell {
- padding-left: calc(0.75rem + 0.65em);
- }
- }
- }
-
- @include media-breakpoint-down(sm) {
- table {
- tr {
- @include gl-border-t-0;
-
- .table-col {
- min-height: 68px;
- }
-
- &:hover {
- background-color: var(--white, $white);
- @include gl-border-none;
- }
-
- th,
- td {
- @include gl-pt-6;
- }
- }
-
- &.alert-management-table {
- .table-col {
- &:last-child {
- background-color: var(--gray-10, $gray-10);
-
- &::before {
- content: none !important;
- }
-
- div:not(.dropdown-title) {
- width: 100% !important;
- padding: 0 !important;
- }
- }
- }
- }
-
- .b-table-empty-row {
- td {
- @include gl-border-b-0;
-
- div {
- text-align: unset !important;
- }
- }
- }
-
- .b-table-busy-slot {
- td {
- @include gl-border-b-0;
-
- div {
- text-align: center !important;
- }
- }
- }
- }
- }
-
.gl-tabs-nav {
@include gl-border-b-0;
@@ -142,12 +28,4 @@
@include gl-w-full;
}
}
-
- .integration-list {
- .b-table-empty-row {
- td {
- @include gl-px-0;
- }
- }
- }
}
diff --git a/app/assets/stylesheets/page_bundles/issues_list.scss b/app/assets/stylesheets/page_bundles/issues_list.scss
index f39dee12126..b5afb8cdf4d 100644
--- a/app/assets/stylesheets/page_bundles/issues_list.scss
+++ b/app/assets/stylesheets/page_bundles/issues_list.scss
@@ -34,3 +34,13 @@
opacity: 0.3;
pointer-events: none;
}
+
+.work-item-labels {
+ .gl-token {
+ padding-left: $gl-spacing-scale-1;
+ }
+
+ .gl-token-close {
+ display: none;
+ }
+}
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_request.scss
index 0a17b2c47a4..113a50c4efa 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_request.scss
@@ -1,7 +1,5 @@
-/**
- * MR -> show: Automerge widget
- *
- */
+@import 'mixins_and_variables_and_functions';
+
$tabs-holder-z-index: 250;
$comparison-empty-state-height: 62px;
@@ -35,27 +33,6 @@ $comparison-empty-state-height: 62px;
}
}
-.mr-state-widget {
- .accept-merge-holder {
- .accept-action {
- .accept-merge-request {
- &.ci-preparing,
- &.ci-pending,
- &.ci-running {
- @include btn-blue;
- }
-
- &.ci-skipped,
- &.ci-failed,
- &.ci-canceled,
- &.ci-error {
- @include btn-red;
- }
- }
- }
- }
-}
-
.mr_source_commit,
.mr_target_commit {
margin-bottom: 0;
@@ -192,18 +169,28 @@ $comparison-empty-state-height: 62px;
.issuable-form-select-holder {
display: inline-block;
- width: 250px;
+ width: 100%;
+
+ @include media-breakpoint-up(md) {
+ width: 250px;
+ }
.dropdown-menu-toggle {
width: 100%;
}
}
+.issuable-form-label-select-holder .gl-dropdown-toggle {
+ @include media-breakpoint-up(md) {
+ width: 250px;
+ }
+}
+
.table-holder {
.ci-table {
th {
- background-color: $white;
- color: $gl-text-color-secondary;
+ background-color: var(--white, $white);
+ color: var(--gl-gray-700, $gl-text-color-secondary);
}
}
}
@@ -211,8 +198,7 @@ $comparison-empty-state-height: 62px;
.merge-request-tabs-holder {
top: $calc-application-header-height;
z-index: $tabs-holder-z-index;
- background-color: $body-bg;
- border-bottom: 1px solid $border-color;
+ border-bottom: 1px solid var(--border-color, $border-color);
@include media-breakpoint-up(md) {
position: sticky;
@@ -239,7 +225,7 @@ $comparison-empty-state-height: 62px;
margin-right: auto;
.inner-page-scroll-tabs {
- background-color: $white;
+ background-color: var(--white, $white);
margin-left: -$gl-padding;
padding-left: $gl-padding;
}
@@ -308,7 +294,7 @@ $comparison-empty-state-height: 62px;
opacity: 0.65;
&:hover {
- color: $gray-500;
+ color: var(--gray-500, $gray-500);
text-decoration: none;
}
}
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index 5e20588dd70..f39247f06c2 100644
--- a/app/assets/stylesheets/page_bundles/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -1,6 +1,5 @@
@import 'mixins_and_variables_and_functions';
-$mr-widget-margin-left: 40px;
$mr-widget-min-height: 69px;
$tabs-holder-z-index: 250;
@@ -373,12 +372,13 @@ $tabs-holder-z-index: 250;
white-space: nowrap;
}
- @include media-breakpoint-down(md) {
+ /* stylelint-disable scss/at-rule-no-unknown */
+ @container mr-widget-extension (max-width: 600px) {
flex-direction: column;
align-items: flex-start;
.deployment-info {
- margin-bottom: $gl-padding;
+ margin-bottom: $gl-padding-8;
}
}
@@ -413,10 +413,7 @@ $tabs-holder-z-index: 250;
.deploy-heading,
.merge-train-position-indicator {
- padding: $gl-padding-8;
- @include media-breakpoint-up(md) {
- padding: $gl-padding-8 $gl-padding;
- }
+ padding: $gl-padding-8 $gl-padding;
.media-body {
min-width: 0;
@@ -643,6 +640,13 @@ $tabs-holder-z-index: 250;
text-transform: capitalize;
}
+ .mr-pipeline-title {
+ // NOTE: CSS Hack to make the force the pipeline
+ // to the end of the line or to force it to a
+ // new line if there is not enough space.
+ flex-grow: 999;
+ }
+
.label-branch {
@include gl-font-monospace;
font-size: 95%;
@@ -659,7 +663,7 @@ $tabs-holder-z-index: 250;
> span {
display: inline-block;
max-width: 12.5em;
- margin-bottom: -6px;
+ margin-bottom: -5px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
@@ -830,6 +834,12 @@ $tabs-holder-z-index: 250;
.mr-widget-extension {
border-top: 1px solid var(--border-color, $border-color);
background-color: var(--gray-10, $gray-10);
+ container-name: mr-widget-extension;
+ container-type: inline-size;
+ // Adds a fix for the view app dropdown not showing up
+ // correctly.
+ @include gl-relative;
+ @include gl-z-index-1;
&.clickable:hover {
background-color: var(--gray-50, $gray-50);
@@ -881,10 +891,6 @@ $tabs-holder-z-index: 250;
padding-right: $gl-padding;
}
-.mr-widget-margin-left {
- margin-left: $mr-widget-margin-left;
-}
-
.mr-widget-section {
.code-text {
flex: 1;
@@ -990,37 +996,6 @@ $tabs-holder-z-index: 250;
.gl-dropdown-inner {
max-height: none !important;
}
-
- .md-header {
- .gl-tab-nav-item {
- color: var(--gl-text-color, $gl-text-color);
- @include gl-py-4;
- @include gl-px-3;
-
- &:hover {
- @include gl-bg-none;
- color: var(--gl-text-color, $gl-text-color);
-
- &:not(.gl-tab-nav-item-active) {
- @include gl-inset-border-b-2-gray-200;
- }
- }
- }
-
- .gl-tab-nav-item-active {
- @include gl-font-weight-bold;
- color: var(--gl-text-color, $gl-text-color);
- @include gl-inset-border-b-2-theme-accent;
-
- &:active,
- &:focus,
- &:focus:active {
- box-shadow: inset 0 -#{$gl-border-size-2} 0 0 var(--gl-theme-accent, $theme-indigo-500),
- $focus-ring;
- @include gl-outline-none;
- }
- }
- }
}
.gl-dropdown-contents {
diff --git a/app/assets/stylesheets/page_bundles/profile.scss b/app/assets/stylesheets/page_bundles/profile.scss
index dfc86a73635..dbe82f583d1 100644
--- a/app/assets/stylesheets/page_bundles/profile.scss
+++ b/app/assets/stylesheets/page_bundles/profile.scss
@@ -1,5 +1,5 @@
@import 'mixins_and_variables_and_functions';
-@import 'framework/buttons';
+@import 'framework/mixins';
.edit-user {
.emoji-menu-toggle-button {
diff --git a/app/assets/stylesheets/page_bundles/tree.scss b/app/assets/stylesheets/page_bundles/tree.scss
index e0ee157187b..66d828ed87d 100644
--- a/app/assets/stylesheets/page_bundles/tree.scss
+++ b/app/assets/stylesheets/page_bundles/tree.scss
@@ -14,6 +14,8 @@
@include media-breakpoint-up(sm) {
display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
.tree-ref-container {
flex: 1;
@@ -24,7 +26,11 @@
.control {
float: left;
- margin-left: 8px;
+ margin-right: 8px;
+
+ &:last-child {
+ margin-right: 0;
+ }
}
}
diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss
index 013aa064c4e..e8fa93e1504 100644
--- a/app/assets/stylesheets/page_bundles/work_items.scss
+++ b/app/assets/stylesheets/page_bundles/work_items.scss
@@ -1,5 +1,6 @@
@import 'mixins_and_variables_and_functions';
+$work-item-field-inset-shadow: inset 0 0 0 $gl-border-size-1 var(--gray-200, $gray-200) !important;
$work-item-overview-right-sidebar-width: 340px;
$work-item-sticky-header-height: 52px;
@@ -8,15 +9,14 @@ $work-item-sticky-header-height: 52px;
align-items: center;
}
-#weight-widget-input:not(:hover, :focus),
-#weight-widget-input[readonly] {
+.hide-unfocused-input-decoration:not(:focus, :hover),
+.hide-unfocused-input-decoration:disabled {
+ background-color: transparent;
+ border-color: transparent;
+ background-image: none;
box-shadow: none;
}
-#weight-widget-input[readonly] {
- background-color: var(--white, $white);
-}
-
.work-item-assignees {
.assign-myself {
display: none;
@@ -68,19 +68,27 @@ $work-item-sticky-header-height: 52px;
}
.work-item-dropdown {
- .gl-dropdown-toggle {
- background: none !important;
+ // duplicate classname because we are fighting with gl-button styles
+ .gl-dropdown-toggle.gl-dropdown-toggle {
+ background: none;
&:hover,
&:focus {
- box-shadow: inset 0 0 0 $gl-border-size-1 var(--gray-darkest, $gray-darkest) !important;
+ box-shadow: $work-item-field-inset-shadow;
+ background-color: $input-bg;
+
+ .gl-dark & {
+ // $input-bg is overridden in dark mode but that does not
+ // work in page bundles currently, manually override here
+ background-color: var(--gray-50, $input-bg);
+ }
}
&.is-not-focused:not(:hover, :focus) {
box-shadow: none;
.gl-button-icon {
- display: none;
+ visibility: hidden;
}
}
}
@@ -150,6 +158,13 @@ $work-item-sticky-header-height: 52px;
.work-item-overview & {
max-width: 65%;
}
+
+ &.gl-form-select {
+ &:hover,
+ &:focus {
+ box-shadow: $work-item-field-inset-shadow;
+ }
+ }
}
.token-selector-menu-class {
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index 15d4a0fec9a..29f2d15008b 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -91,12 +91,10 @@
.prioritized-labels:not(.is-not-draggable) & {
cursor: grab;
- border: 1px solid transparent;
&:hover,
&:focus-within {
- background-color: $white;
- border-color: $gray-50;
+ background-color: $blue-50;
}
&:active {
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 005fbc8b058..2722893d04c 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -222,6 +222,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
.discussion-reply-holder {
border: 1px solid $border-color;
+ background-color: $white;
}
}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 8cf0bebfc4e..b9cae28537d 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -65,7 +65,6 @@
white-space: normal;
}
- .deploy-project-label,
.key-created-at {
svg {
vertical-align: text-top;
@@ -83,18 +82,6 @@
.deploy-project-list {
margin-bottom: -$gl-padding-4;
-
- a.deploy-project-label {
- margin-right: $gl-padding-4;
- margin-bottom: $gl-padding-4;
- color: $gl-text-color-secondary;
- background-color: $gray-50;
- line-height: $gl-btn-line-height;
-
- &:hover {
- color: $blue-600;
- }
- }
}
.vs-public {
@@ -493,32 +480,6 @@
}
}
-.protected-branches-list,
-.protected-tags-list {
- margin-bottom: 32px;
-
- .settings-message {
- margin: 0;
- border-radius: 0 0 1px 1px;
- padding: 20px 0;
- border: 0;
- }
-
- .table-bordered {
- border-radius: 1px;
-
- th:not(:last-child),
- td:not(:last-child) {
- border-right: solid 1px transparent;
- }
- }
-
- .flash-container {
- padding: 0;
- }
-}
-
-
.compare-revision-cards {
@media (max-width: $breakpoint-lg) {
.swap-button {
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index 728eb1fe441..0ec342b9332 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -50,25 +50,7 @@
}
}
-.ci-variable-table,
-.deploy-freeze-table,
-.ci-secure-files-table {
- table {
- tr {
- td,
- th {
- padding-left: 0;
- }
-
- // When tables are "stacked", restore td padding
- @media(max-width: map-get($grid-breakpoints, lg)) {
- td {
- padding-left: $gl-spacing-scale-5;
- }
- }
- }
- }
-
+.deploy-freeze-table {
@media(max-width: map-get($grid-breakpoints, lg)-1) {
.truncated-container {
justify-content: flex-end;
@@ -76,26 +58,27 @@
}
}
-.settings-section {
- @include gl-pt-6;
-
- &::after {
- content: '';
- display: block;
- @include gl-pb-5;
- }
+.settings-section::after {
+ content: '';
+ display: block;
+ @include gl-mb-7;
}
.settings-section,
-.settings-section-no-bottom + .settings-section {
+.settings-section-no-bottom ~ .settings-section {
@include gl-pt-0;
}
+// Fix for sticky header when there is no search bar.
+.flash-container + .settings-section {
+ @include gl-pt-3;
+}
+
.settings-section ~ .settings-section {
@include gl-pt-6;
}
-.settings-section:not(.settings-section-no-bottom) + .settings-section {
+.settings-section:not(.settings-section-no-bottom) ~ .settings-section {
@include gl-border-t;
}
@@ -124,21 +107,25 @@ $sticky-header-z-index: 98;
display: block;
height: $gl-padding-8;
position: sticky;
- top: calc(#{$calc-application-header-height} + 40px);
- box-shadow: 0 1px 1px $gray-200;
+ top: calc(#{$calc-application-header-height} + 36px);
+ box-shadow: 0 1px 0 $gray-100;
}
}
.settings-sticky-header-inner {
position: sticky;
- padding: $gl-padding $gl-padding $gl-padding-12;
+ padding: $gl-padding-12 $gl-padding $gl-padding-8;
margin: #{-$gl-padding} #{-$gl-padding} 0;
background: $body-bg;
}
.settings-sticky-footer {
bottom: 0;
- padding-top: $gl-padding-8;
- padding-bottom: $gl-padding-8;
- box-shadow: 0 #{-$gl-padding-4} $gl-padding-12 $gl-padding-4 $body-bg;
+ padding: $gl-padding-8 0;
+ box-shadow: 0 -1px 0 $gray-100;
+}
+
+// Header shouldn't be sticky if only one section on page
+.settings-sticky-header:first-of-type:last-of-type {
+ position: static;
}
diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss
index 84181a00f34..c3662c3e6ea 100644
--- a/app/assets/stylesheets/print.scss
+++ b/app/assets/stylesheets/print.scss
@@ -1,3 +1,9 @@
+@import 'framework/variables';
+@import 'framework/variables_overrides';
+
+@import '@gitlab/ui/src/scss/variables';
+@import '@gitlab/ui/src/scss/utility-mixins/index';
+
.md h1,
.md h2,
.md h3,
@@ -20,6 +26,35 @@
font-weight: 600;
}
+.md {
+ print-color-adjust: exact;
+ -webkit-print-color-adjust: exact;
+
+ // fix blockquote style in print
+ blockquote {
+ &::before {
+ position: absolute;
+ top: 0;
+ left: -4px;
+ content: ' ';
+ height: 100%;
+ width: 4px;
+ background-color: $white-dark;
+ }
+
+ position: relative;
+ font-size: inherit;
+ @include gl-text-gray-700;
+ @include gl-py-3;
+ @include gl-pl-6;
+ @include gl-my-3;
+ @include gl-mx-0;
+ @include gl-inset-border-l-4-gray-100;
+ margin-left: 4px;
+ border: 0 !important;
+ }
+}
+
header,
nav,
nav.navbar-collapse,
@@ -40,6 +75,7 @@ ul.notes-form,
.note-action-button,
.right-sidebar,
.flash-container,
+copy-code,
#js-peek {
display: none !important;
}
diff --git a/app/assets/stylesheets/snippets.scss b/app/assets/stylesheets/snippets.scss
index e1f540c0f5f..e249ecbd10b 100644
--- a/app/assets/stylesheets/snippets.scss
+++ b/app/assets/stylesheets/snippets.scss
@@ -145,6 +145,10 @@
}
.btn-group {
+ position: relative;
+ display: inline-flex;
+ vertical-align: middle;
+
button.btn,
a.btn {
background-color: $white;
diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss
index c471d6183d8..36fa457f244 100644
--- a/app/assets/stylesheets/themes/_dark.scss
+++ b/app/assets/stylesheets/themes/_dark.scss
@@ -19,6 +19,12 @@ $gray-dark: darken($gray-100, 2);
$gray-darker: darken($gray-200, 2);
$gray-darkest: $gray-700;
+// Some of the other $t-gray-a variables are used
+// for borders and some other places, so we cannot override
+// them. These are used only for box shadows so we can
+$t-gray-a-16: rgba($gray-10, 0.16);
+$t-gray-a-24: rgba($gray-10, 0.24);
+
$black: #fff;
$black-normal: $gray-900;
$white: $gray-50;
diff --git a/app/assets/stylesheets/themes/theme_blue.scss b/app/assets/stylesheets/themes/theme_blue.scss
index 90122cec31f..06f3e13e99e 100644
--- a/app/assets/stylesheets/themes/theme_blue.scss
+++ b/app/assets/stylesheets/themes/theme_blue.scss
@@ -9,5 +9,14 @@ body {
$theme-blue-900,
$white
);
+
+ .page-with-super-sidebar {
+ @include gitlab-theme-super-sidebar(
+ $theme-blue-50,
+ $theme-blue-200,
+ $theme-blue-900,
+ $theme-blue-900,
+ );
+ }
}
}
diff --git a/app/assets/stylesheets/themes/theme_gray.scss b/app/assets/stylesheets/themes/theme_gray.scss
index a6cdfb36a7c..3112aaef227 100644
--- a/app/assets/stylesheets/themes/theme_gray.scss
+++ b/app/assets/stylesheets/themes/theme_gray.scss
@@ -9,5 +9,14 @@ body {
$gray-900,
$white
);
+
+ .page-with-super-sidebar {
+ @include gitlab-theme-super-sidebar(
+ $gray-50,
+ $gray-100,
+ $gray-900,
+ $gray-900,
+ );
+ }
}
}
diff --git a/app/assets/stylesheets/themes/theme_green.scss b/app/assets/stylesheets/themes/theme_green.scss
index 0300f261d64..c9ea1162206 100644
--- a/app/assets/stylesheets/themes/theme_green.scss
+++ b/app/assets/stylesheets/themes/theme_green.scss
@@ -9,5 +9,14 @@ body {
$theme-green-900,
$white
);
+
+ .page-with-super-sidebar {
+ @include gitlab-theme-super-sidebar(
+ $theme-green-50,
+ $theme-green-200,
+ $theme-green-900,
+ $theme-green-900,
+ );
+ }
}
}
diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss
index f841a9047cc..8f0e0781918 100644
--- a/app/assets/stylesheets/themes/theme_helper.scss
+++ b/app/assets/stylesheets/themes/theme_helper.scss
@@ -276,3 +276,63 @@
}
}
}
+
+@mixin gitlab-theme-super-sidebar(
+ $theme-color-lightest,
+ $theme-color-light,
+ $theme-color,
+ $theme-color-darkest,
+) {
+ --transparent-white-16: rgba(255, 255, 255, 0.16);
+ --transparent-white-24: rgba(255, 255, 255, 0.24);
+
+ .super-sidebar {
+ background-color: $theme-color-lightest;
+ }
+
+ .super-sidebar .user-bar {
+ background-color: $theme-color;
+
+ .counter {
+ background-color: var(--transparent-white-16) !important;
+ }
+
+ .brand-logo,
+ .btn-default-tertiary,
+ .counter {
+ color: $theme-color-lightest;
+ mix-blend-mode: normal;
+
+ &:hover,
+ &:focus {
+ background-color: var(--transparent-white-24) !important;
+ color: $white;
+ }
+
+ .gl-icon {
+ color: $theme-color-light;
+ }
+ }
+ }
+
+ .super-sidebar hr {
+ mix-blend-mode: multiply;
+ }
+
+ .btn-with-notification {
+ &:hover,
+ &:focus {
+ mix-blend-mode: multiply;
+ }
+
+ .notification-dot-info {
+ background-color: $theme-color-darkest;
+ border-color: $theme-color-lightest;
+
+ }
+ }
+
+ .active-indicator {
+ background-color: $theme-color;
+ }
+}
diff --git a/app/assets/stylesheets/themes/theme_indigo.scss b/app/assets/stylesheets/themes/theme_indigo.scss
index 5a27a9cfdc5..78ce96667d4 100644
--- a/app/assets/stylesheets/themes/theme_indigo.scss
+++ b/app/assets/stylesheets/themes/theme_indigo.scss
@@ -9,5 +9,14 @@ body {
$indigo-900,
$white
);
+
+ .page-with-super-sidebar {
+ @include gitlab-theme-super-sidebar(
+ $theme-indigo-50,
+ $theme-indigo-200,
+ $theme-indigo-900,
+ $theme-indigo-900,
+ );
+ }
}
}
diff --git a/app/assets/stylesheets/themes/theme_light_blue.scss b/app/assets/stylesheets/themes/theme_light_blue.scss
index 7cb0d98802e..73fe072393f 100644
--- a/app/assets/stylesheets/themes/theme_light_blue.scss
+++ b/app/assets/stylesheets/themes/theme_light_blue.scss
@@ -9,5 +9,14 @@ body {
$theme-light-blue-700,
$white
);
+
+ .page-with-super-sidebar {
+ @include gitlab-theme-super-sidebar(
+ $theme-light-blue-50,
+ $theme-light-blue-200,
+ $theme-light-blue-700,
+ $theme-light-blue-900,
+ );
+ }
}
}
diff --git a/app/assets/stylesheets/themes/theme_light_green.scss b/app/assets/stylesheets/themes/theme_light_green.scss
index 797279cc37b..720a0ec58b8 100644
--- a/app/assets/stylesheets/themes/theme_light_green.scss
+++ b/app/assets/stylesheets/themes/theme_light_green.scss
@@ -9,5 +9,14 @@ body {
$theme-light-green-700,
$white
);
+
+ .page-with-super-sidebar {
+ @include gitlab-theme-super-sidebar(
+ $theme-green-50,
+ $theme-green-200,
+ $theme-green-700,
+ $theme-green-900,
+ );
+ }
}
}
diff --git a/app/assets/stylesheets/themes/theme_light_indigo.scss b/app/assets/stylesheets/themes/theme_light_indigo.scss
index 3632c5ad45a..ff12366466a 100644
--- a/app/assets/stylesheets/themes/theme_light_indigo.scss
+++ b/app/assets/stylesheets/themes/theme_light_indigo.scss
@@ -9,5 +9,14 @@ body {
$indigo-700,
$white
);
+
+ .page-with-super-sidebar {
+ @include gitlab-theme-super-sidebar(
+ $theme-indigo-50,
+ $theme-indigo-200,
+ $theme-indigo-700,
+ $theme-indigo-900,
+ );
+ }
}
}
diff --git a/app/assets/stylesheets/themes/theme_light_red.scss b/app/assets/stylesheets/themes/theme_light_red.scss
index 6c10d9178f1..3ae67309014 100644
--- a/app/assets/stylesheets/themes/theme_light_red.scss
+++ b/app/assets/stylesheets/themes/theme_light_red.scss
@@ -9,5 +9,14 @@ body {
$theme-light-red-700,
$white
);
+
+ .page-with-super-sidebar {
+ @include gitlab-theme-super-sidebar(
+ $theme-light-red-50,
+ $theme-light-red-200,
+ $theme-light-red-700,
+ $theme-light-red-900,
+ );
+ }
}
}
diff --git a/app/assets/stylesheets/themes/theme_red.scss b/app/assets/stylesheets/themes/theme_red.scss
index 140e27de6e2..82de30e8b0e 100644
--- a/app/assets/stylesheets/themes/theme_red.scss
+++ b/app/assets/stylesheets/themes/theme_red.scss
@@ -9,5 +9,14 @@ body {
$theme-red-900,
$white
);
+
+ .page-with-super-sidebar {
+ @include gitlab-theme-super-sidebar(
+ $theme-red-50,
+ $theme-red-200,
+ $theme-red-900,
+ $theme-red-900,
+ );
+ }
}
}
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index db9802eeefa..d5e9d35983a 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -65,10 +65,6 @@
min-width: 0;
}
-.gl-min-h-100vh {
- min-height: 100vh;
-}
-
// .gl-font-size-inherit will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1466
.gl-font-size-inherit,
.font-size-inherit { font-size: inherit; }
@@ -135,3 +131,20 @@
.gl-fill-red-500 {
fill: $red-500;
}
+
+// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/3569
+.gl-mb-n5 {
+ margin-bottom: -$gl-spacing-scale-5;
+}
+
+.gl-mb-n7 {
+ margin-bottom: -$gl-spacing-scale-7;
+}
+
+.gl-mb-n8 {
+ margin-bottom: -$gl-spacing-scale-8;
+}
+
+.gl-hover-border-gray-100:hover {
+ border-color: $gray-100;
+}
diff --git a/app/channels/noteable/notes_channel.rb b/app/channels/noteable/notes_channel.rb
new file mode 100644
index 00000000000..021bc3ccd1b
--- /dev/null
+++ b/app/channels/noteable/notes_channel.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Noteable
+ class NotesChannel < ApplicationCable::Channel
+ def subscribed
+ project = Project.find(params[:project_id]) if params[:project_id].present?
+
+ noteable = NotesFinder.new(current_user, {
+ project: project,
+ group_id: params[:group_id],
+ target_type: params[:noteable_type],
+ target_id: params[:noteable_id]
+ }).target
+
+ return reject if noteable.nil?
+ return reject if Feature.disabled?(:action_cable_notes, project || noteable.try(:group))
+
+ stream_for noteable
+ rescue ActiveRecord::RecordNotFound
+ reject
+ end
+ end
+end
diff --git a/app/components/projects/ml/models_index_component.html.haml b/app/components/projects/ml/models_index_component.html.haml
new file mode 100644
index 00000000000..17a92c211d5
--- /dev/null
+++ b/app/components/projects/ml/models_index_component.html.haml
@@ -0,0 +1 @@
+#js-index-ml-models{ data: { view_model: view_model } }
diff --git a/app/components/projects/ml/models_index_component.rb b/app/components/projects/ml/models_index_component.rb
new file mode 100644
index 00000000000..c5c20565195
--- /dev/null
+++ b/app/components/projects/ml/models_index_component.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Projects
+ module Ml
+ class ModelsIndexComponent < ViewComponent::Base
+ attr_reader :models
+
+ def initialize(models:)
+ @models = models
+ end
+
+ private
+
+ def view_model
+ Gitlab::Json.generate({ models: models_view_model })
+ end
+
+ def models_view_model
+ models.map(&:present).map do |m|
+ {
+ name: m.name,
+ version: m.latest_version_name,
+ path: m.latest_package_path
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/app/controllers/admin/abuse_reports_controller.rb b/app/controllers/admin/abuse_reports_controller.rb
index 6b998c3d494..329c4e4921a 100644
--- a/app/controllers/admin/abuse_reports_controller.rb
+++ b/app/controllers/admin/abuse_reports_controller.rb
@@ -4,7 +4,7 @@ class Admin::AbuseReportsController < Admin::ApplicationController
feature_category :insider_threat
before_action :set_status_param, only: :index, if: -> { Feature.enabled?(:abuse_reports_list) }
- before_action :find_abuse_report, only: [:show, :update, :destroy]
+ before_action :find_abuse_report, only: [:show, :moderate_user, :update, :destroy]
def index
@abuse_reports = AbuseReportsFinder.new(params).execute
@@ -12,8 +12,22 @@ class Admin::AbuseReportsController < Admin::ApplicationController
def show; end
+ # Kept for backwards compatibility.
+ # TODO: See https://gitlab.com/gitlab-org/modelops/anti-abuse/team-tasks/-/issues/167?work_item_iid=443
+ # In 16.4 remove or re-use this endpoint after frontend has migrated to using moderate_user endpoint
def update
- response = Admin::AbuseReportUpdateService.new(@abuse_report, current_user, permitted_params).execute
+ response = Admin::AbuseReports::ModerateUserService.new(@abuse_report, current_user, permitted_params).execute
+
+ if response.success?
+ render json: { message: response.message }
+ else
+ render json: { message: response.message }, status: :unprocessable_entity
+ end
+ end
+
+ def moderate_user
+ response = Admin::AbuseReports::ModerateUserService.new(@abuse_report, current_user, permitted_params).execute
+
if response.success?
render json: { message: response.message }
else
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index f0b6d86d48d..be1edeb0d37 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -15,6 +15,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
before_action do
push_frontend_feature_flag(:ci_variables_pages, current_user)
+ push_frontend_feature_flag(:ci_variable_drawer, current_user)
end
feature_category :not_owned, [ # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb
index 7f85103816e..06ee178599d 100644
--- a/app/controllers/admin/broadcast_messages_controller.rb
+++ b/app/controllers/admin/broadcast_messages_controller.rb
@@ -2,7 +2,7 @@
module Admin
class BroadcastMessagesController < ApplicationController
- include BroadcastMessagesHelper
+ include Admin::BroadcastMessagesHelper
before_action :find_broadcast_message, only: [:edit, :update, :destroy]
before_action :find_broadcast_messages, only: [:index, :create]
@@ -11,13 +11,13 @@ module Admin
urgency :low
def index
- @broadcast_message = BroadcastMessage.new
+ @broadcast_message = System::BroadcastMessage.new
end
def edit; end
def create
- @broadcast_message = BroadcastMessage.new(broadcast_message_params)
+ @broadcast_message = System::BroadcastMessage.new(broadcast_message_params)
success = @broadcast_message.save
respond_to do |format|
@@ -69,18 +69,18 @@ module Admin
end
def preview
- @broadcast_message = BroadcastMessage.new(broadcast_message_params)
+ @broadcast_message = System::BroadcastMessage.new(broadcast_message_params)
render plain: render_broadcast_message(@broadcast_message), status: :ok
end
protected
def find_broadcast_message
- @broadcast_message = BroadcastMessage.find(params[:id])
+ @broadcast_message = System::BroadcastMessage.find(params[:id])
end
def find_broadcast_messages
- @broadcast_messages = BroadcastMessage.order(ends_at: :desc).page(params[:page]) # rubocop: disable CodeReuse/ActiveRecord
+ @broadcast_messages = System::BroadcastMessage.order(ends_at: :desc).page(params[:page]) # rubocop: disable CodeReuse/ActiveRecord
end
def broadcast_message_params
diff --git a/app/controllers/admin/identities_controller.rb b/app/controllers/admin/identities_controller.rb
index 0745ba328c6..15c4103a781 100644
--- a/app/controllers/admin/identities_controller.rb
+++ b/app/controllers/admin/identities_controller.rb
@@ -23,6 +23,8 @@ class Admin::IdentitiesController < Admin::ApplicationController
def index
@identities = @user.identities
+ @can_impersonate = helpers.can_impersonate_user(user, impersonation_in_progress?)
+ @impersonation_error_text = @can_impersonate ? nil : helpers.impersonation_error_text(user, impersonation_in_progress?)
end
def edit
diff --git a/app/controllers/admin/impersonation_tokens_controller.rb b/app/controllers/admin/impersonation_tokens_controller.rb
index dae3337d19b..1f25dad3428 100644
--- a/app/controllers/admin/impersonation_tokens_controller.rb
+++ b/app/controllers/admin/impersonation_tokens_controller.rb
@@ -8,6 +8,8 @@ class Admin::ImpersonationTokensController < Admin::ApplicationController
def index
set_index_vars
+ @can_impersonate = helpers.can_impersonate_user(user, impersonation_in_progress?)
+ @impersonation_error_text = @can_impersonate ? nil : helpers.impersonation_error_text(user, impersonation_in_progress?)
end
def create
diff --git a/app/controllers/admin/labels_controller.rb b/app/controllers/admin/labels_controller.rb
index 4747f3c5dea..10d060b8161 100644
--- a/app/controllers/admin/labels_controller.rb
+++ b/app/controllers/admin/labels_controller.rb
@@ -41,14 +41,20 @@ class Admin::LabelsController < Admin::ApplicationController
end
def destroy
- @label.destroy
- @labels = Label.templates
-
respond_to do |format|
- format.html do
- redirect_to admin_labels_path, status: :found, notice: _('Label was removed')
+ if @label.destroy
+ format.html do
+ redirect_to admin_labels_path, status: :found,
+ notice: format(_('%{label_name} was removed'), label_name: @label.name)
+ end
+ format.js { head :ok }
+ else
+ format.html do
+ redirect_to admin_labels_path, status: :found,
+ alert: @label.errors.full_messages.to_sentence
+ end
+ format.js { head :unprocessable_entity }
end
- format.js { head :ok }
end
end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index b75ca2649c3..f05b03c2787 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -7,6 +7,7 @@ class Admin::UsersController < Admin::ApplicationController
before_action :user, except: [:index, :new, :create]
before_action :check_impersonation_availability, only: :impersonate
before_action :ensure_destroy_prerequisites_met, only: [:destroy]
+ before_action :set_shared_view_parameters, only: [:show, :projects, :keys]
feature_category :user_management
@@ -24,10 +25,7 @@ class Admin::UsersController < Admin::ApplicationController
@users = @users.without_count if paginate_without_count?
end
- def show
- @can_impersonate = can_impersonate_user
- @impersonation_error_text = @can_impersonate ? nil : impersonation_error_text
- end
+ def show; end
# rubocop: disable CodeReuse/ActiveRecord
def projects
@@ -48,7 +46,7 @@ class Admin::UsersController < Admin::ApplicationController
end
def impersonate
- if can_impersonate_user
+ if helpers.can_impersonate_user(user, impersonation_in_progress?)
session[:impersonator_id] = current_user.id
warden.set_user(user, scope: :user)
@@ -60,7 +58,7 @@ class Admin::UsersController < Admin::ApplicationController
redirect_to root_path
else
- flash[:alert] = impersonation_error_text
+ flash[:alert] = helpers.impersonation_error_text(user, impersonation_in_progress?)
redirect_to admin_user_path(user)
end
@@ -384,28 +382,17 @@ class Admin::UsersController < Admin::ApplicationController
Gitlab::AppLogger.info(format(_("User %{current_user_username} has started impersonating %{username}"), current_user_username: current_user.username, username: user.username))
end
- def can_impersonate_user
- can?(user, :log_in) && !user.password_expired? && !impersonation_in_progress?
- end
-
- def impersonation_error_text
- if impersonation_in_progress?
- _("You are already impersonating another user")
- elsif user.blocked?
- _("You cannot impersonate a blocked user")
- elsif user.password_expired?
- _("You cannot impersonate a user with an expired password")
- elsif user.internal?
- _("You cannot impersonate an internal user")
- else
- _("You cannot impersonate a user who cannot log in")
- end
- end
-
# method overriden in EE
def unlock_user
update_user(&:unlock_access!)
end
+
+ private
+
+ def set_shared_view_parameters
+ @can_impersonate = helpers.can_impersonate_user(user, impersonation_in_progress?)
+ @impersonation_error_text = @can_impersonate ? nil : helpers.impersonation_error_text(user, impersonation_in_progress?)
+ end
end
Admin::UsersController.prepend_mod_with('Admin::UsersController')
diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb
index 9fd86e6a7e0..41a3ee3e1c8 100644
--- a/app/controllers/concerns/authenticates_with_two_factor.rb
+++ b/app/controllers/concerns/authenticates_with_two_factor.rb
@@ -52,6 +52,14 @@ module AuthenticatesWithTwoFactor
elsif user && user.valid_password?(user_params[:password])
prompt_for_two_factor(user)
end
+ rescue ActiveRecord::RecordInvalid => e
+ # We expect User to always be valid.
+ # Otherwise, raise internal server error instead of unprocessable entity to improve observability/alerting
+ if e.record.is_a?(User)
+ raise e.message
+ else
+ raise e
+ end
end
private
diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb
index 8068913eea2..539feb3cf1c 100644
--- a/app/controllers/concerns/enforces_two_factor_authentication.rb
+++ b/app/controllers/concerns/enforces_two_factor_authentication.rb
@@ -77,7 +77,7 @@ module EnforcesTwoFactorAuthentication
end
def two_factor_verifier
- @two_factor_verifier ||= Gitlab::Auth::TwoFactorAuthVerifier.new(current_user) # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ @two_factor_verifier ||= Gitlab::Auth::TwoFactorAuthVerifier.new(current_user, request) # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
def mfa_help_page_url
diff --git a/app/controllers/concerns/google_syndication_csp.rb b/app/controllers/concerns/google_syndication_csp.rb
new file mode 100644
index 00000000000..c55debe448b
--- /dev/null
+++ b/app/controllers/concerns/google_syndication_csp.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module GoogleSyndicationCSP
+ extend ActiveSupport::Concern
+
+ ALLOWED_SRC = ['*.google.com/pagead/landing', 'pagead2.googlesyndication.com/pagead/landing'].freeze
+
+ included do
+ content_security_policy do |policy|
+ next unless helpers.google_tag_manager_enabled? || policy.directives.present?
+
+ connect_src_values = Array.wrap(
+ policy.directives['connect-src'] || policy.directives['default-src']
+ )
+
+ connect_src_values.concat(ALLOWED_SRC) if helpers.google_tag_manager_enabled?
+
+ policy.connect_src(*connect_src_values.uniq)
+ end
+ end
+end
diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb
index 53dd06ce638..e344e0dcd8c 100644
--- a/app/controllers/concerns/integrations/params.rb
+++ b/app/controllers/concerns/integrations/params.rb
@@ -43,6 +43,7 @@ module Integrations
:external_wiki_url,
:google_iap_service_account_json,
:google_iap_audience_client_id,
+ :google_play_protected_refs,
:group_confidential_mention_events,
:group_mention_events,
:incident_events,
@@ -102,10 +103,14 @@ module Integrations
param_values = return_value[:integration]
if param_values.is_a?(ActionController::Parameters)
- if %w[update test].include?(action_name) && integration.chat? &&
- param_values['webhook'] == BaseChatNotification::SECRET_MASK
+ if %w[update test].include?(action_name) && integration.chat?
+ param_values.delete('webhook') if param_values['webhook'] == BaseChatNotification::SECRET_MASK
- param_values.delete('webhook')
+ if integration.try(:mask_configurable_channels?)
+ integration.event_channel_names.each do |channel|
+ param_values.delete(channel) if param_values[channel] == BaseChatNotification::SECRET_MASK
+ end
+ end
end
integration.secret_fields.each do |param|
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index a326fa308ad..1b49cffd408 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -256,6 +256,7 @@ module IssuableActions
:milestone_id,
:state_event,
:subscription_event,
+ :confidential,
assignee_ids: [],
add_label_ids: [],
remove_label_ids: []
diff --git a/app/controllers/concerns/kas_cookie.rb b/app/controllers/concerns/kas_cookie.rb
index 06a4ee873f8..fafd426da7a 100644
--- a/app/controllers/concerns/kas_cookie.rb
+++ b/app/controllers/concerns/kas_cookie.rb
@@ -8,11 +8,10 @@ module KasCookie
next unless ::Gitlab::Kas::UserAccess.enabled?
next unless Settings.gitlab.content_security_policy['enabled']
- kas_url = ::Gitlab::Kas.tunnel_url
next if URI(kas_url).host == ::Gitlab.config.gitlab.host # already allowed, no need for exception
- kas_url += '/' unless kas_url.end_with?('/')
- p.connect_src(*Array.wrap(p.directives['connect-src']), kas_url)
+ p.connect_src(*Array.wrap(p.directives['connect-src']), kas_ws_url.sub(%r{/?$}, '/'))
+ p.connect_src(*Array.wrap(p.directives['connect-src']), kas_url.sub(%r{/?$}, '/'))
end
end
@@ -26,4 +25,14 @@ module KasCookie
cookies[::Gitlab::Kas::COOKIE_KEY] = cookie_data
end
+
+ private
+
+ def kas_url
+ ::Gitlab::Kas.tunnel_url
+ end
+
+ def kas_ws_url
+ ::Gitlab::Kas.tunnel_ws_url
+ end
end
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index 7b2cf131fce..93cf1d15086 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -11,6 +11,7 @@ module NotesActions
included do
before_action :set_polling_interval_header, only: [:index]
+ before_action :require_last_fetched_at_header!, only: [:index]
before_action :require_noteable!, only: [:index, :create]
before_action :authorize_admin_note!, only: [:update, :destroy]
before_action :note_project, only: [:create]
@@ -262,6 +263,12 @@ module NotesActions
render_404 unless noteable
end
+ def require_last_fetched_at_header!
+ return if request.headers['X-Last-Fetched-At'].present?
+
+ render json: { message: 'X-Last-Fetched-At header is required' }, status: :bad_request
+ end
+
def last_fetched_at
microseconds = request.headers['X-Last-Fetched-At'].to_i
diff --git a/app/controllers/concerns/onboarding/status.rb b/app/controllers/concerns/onboarding/status.rb
index 986f3f17847..5112ebb3b5d 100644
--- a/app/controllers/concerns/onboarding/status.rb
+++ b/app/controllers/concerns/onboarding/status.rb
@@ -2,10 +2,17 @@
module Onboarding
class Status
- def initialize(user)
+ def self.tracking_label
+ { free: 'free_registration' }
+ end
+
+ def initialize(params, session, user)
+ @params = params
+ @session = session
@user = user
end
+ # overridden in EE
def continue_full_onboarding?
false
end
@@ -39,3 +46,5 @@ module Onboarding
end
end
end
+
+Onboarding::Status.prepend_mod_with('Onboarding::Status')
diff --git a/app/controllers/concerns/product_analytics_tracking.rb b/app/controllers/concerns/product_analytics_tracking.rb
index 5424354b92c..e148f5d063a 100644
--- a/app/controllers/concerns/product_analytics_tracking.rb
+++ b/app/controllers/concerns/product_analytics_tracking.rb
@@ -12,6 +12,19 @@ module ProductAnalyticsTracking
route_events_to(destinations, name, action, label, &block)
end
end
+
+ def track_internal_event(*controller_actions, name:, conditions: nil)
+ custom_conditions = [:trackable_html_request?, *conditions]
+
+ after_action only: controller_actions, if: custom_conditions do
+ Gitlab::InternalEvents.track_event(
+ name,
+ user: current_user,
+ project: tracking_project_source,
+ namespace: tracking_namespace_source
+ )
+ end
+ end
end
private
diff --git a/app/controllers/concerns/verifies_with_email.rb b/app/controllers/concerns/verifies_with_email.rb
index 13378800ea9..6affd7bb4cc 100644
--- a/app/controllers/concerns/verifies_with_email.rb
+++ b/app/controllers/concerns/verifies_with_email.rb
@@ -12,6 +12,7 @@ module VerifiesWithEmail
skip_before_action :required_signup_info, only: :successful_verification
end
+ # rubocop:disable Metrics/PerceivedComplexity
def verify_with_email
return unless user = find_user || find_verification_user
@@ -34,18 +35,42 @@ module VerifiesWithEmail
# - their account has been locked because of too many failed login attempts, or
# - they have logged in before, but never from the current ip address
reason = 'sign in from untrusted IP address' unless user.access_locked?
- send_verification_instructions(user, reason: reason)
+ send_verification_instructions(user, reason: reason) unless send_rate_limited?(user)
prompt_for_email_verification(user)
end
end
end
end
+ # rubocop:enable Metrics/PerceivedComplexity
def resend_verification_code
return unless user = find_verification_user
- send_verification_instructions(user)
- prompt_for_email_verification(user)
+ if send_rate_limited?(user)
+ message = format(
+ s_("IdentityVerification|You've reached the maximum amount of resends. Wait %{interval} and try again."),
+ interval: rate_limit_interval(:email_verification_code_send)
+ )
+ render json: { status: :failure, message: message }
+ else
+ send_verification_instructions(user)
+ render json: { status: :success }
+ end
+ end
+
+ def update_email
+ return unless user = find_verification_user
+
+ log_verification(user, :email_update_requested)
+ result = Users::EmailVerification::UpdateEmailService.new(user: user).execute(email: email_params[:email])
+
+ if result[:status] == :success
+ send_verification_instructions(user)
+ else
+ handle_verification_failure(user, result[:reason], result[:message])
+ end
+
+ render json: result
end
def successful_verification
@@ -67,19 +92,7 @@ module VerifiesWithEmail
User.find_by_id(session[:verification_user_id])
end
- # After successful verification and calling sign_in, devise redirects the
- # user to this path. Override it to show the successful verified page.
- def after_sign_in_path_for(resource)
- if action_name == 'create' && session[:verification_user_id] == resource.id
- return users_successful_verification_path
- end
-
- super
- end
-
def send_verification_instructions(user, reason: nil)
- return if send_rate_limited?(user)
-
service = Users::EmailVerification::GenerateTokenService.new(attr: :unlock_token, user: user)
raw_token, encrypted_token = service.execute
user.unlock_token = encrypted_token
@@ -90,7 +103,8 @@ module VerifiesWithEmail
def send_verification_instructions_email(user, token)
return unless user.can?(:receive_notifications)
- Notify.verification_instructions_email(user.email, token: token).deliver_later
+ email = verification_email(user)
+ Notify.verification_instructions_email(email, token: token).deliver_later
log_verification(user, :instructions_sent)
end
@@ -101,21 +115,23 @@ module VerifiesWithEmail
if result[:status] == :success
handle_verification_success(user)
+ render json: { status: :success, redirect_path: users_successful_verification_path }
else
handle_verification_failure(user, result[:reason], result[:message])
+ render json: result
end
end
def render_sign_in_rate_limited
message = format(
s_('IdentityVerification|Maximum login attempts exceeded. Wait %{interval} and try again.'),
- interval: user_sign_in_interval
+ interval: rate_limit_interval(:user_sign_in)
)
redirect_to new_user_session_path, alert: message
end
- def user_sign_in_interval
- interval_in_seconds = Gitlab::ApplicationRateLimiter.rate_limits[:user_sign_in][:interval]
+ def rate_limit_interval(rate_limit)
+ interval_in_seconds = Gitlab::ApplicationRateLimiter.rate_limits[rate_limit][:interval]
distance_of_time_in_words(interval_in_seconds)
end
@@ -126,15 +142,19 @@ module VerifiesWithEmail
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 handle_verification_success(user)
+ user.confirm if unconfirmed_verification_email?(user)
+ user.email_reset_offered_at = Time.current if user.email_reset_offered_at.nil?
user.unlock_access!
log_verification(user, :successful)
sign_in(user)
+
+ log_audit_event(current_user, user, with: authentication_method)
+ log_user_activity(user)
+ verify_known_sign_in
end
def trusted_ip_address?(user)
@@ -146,6 +166,7 @@ module VerifiesWithEmail
def prompt_for_email_verification(user)
session[:verification_user_id] = user.id
self.resource = user
+ add_gon_variables # Necessary to set the sprite_icons path, since we skip the ApplicationController before_filters
render 'devise/sessions/email_verification'
end
@@ -154,6 +175,10 @@ module VerifiesWithEmail
params.require(:user).permit(:verification_token)
end
+ def email_params
+ params.require(:user).permit(:email)
+ end
+
def log_verification(user, event, reason = nil)
Gitlab::AppLogger.info(
message: 'Email Verification',
diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb
index e94138c4d9b..f7c7ee62c1a 100644
--- a/app/controllers/confirmations_controller.rb
+++ b/app/controllers/confirmations_controller.rb
@@ -5,6 +5,7 @@ class ConfirmationsController < Devise::ConfirmationsController
include GitlabRecaptcha
include OneTrustCSP
include GoogleAnalyticsCSP
+ include GoogleSyndicationCSP
skip_before_action :required_signup_info
prepend_before_action :check_recaptcha, only: :create
diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb
index 5c0c2b4adf2..29bc48f93e9 100644
--- a/app/controllers/graphql_controller.rb
+++ b/app/controllers/graphql_controller.rb
@@ -38,6 +38,8 @@ class GraphqlController < ApplicationController
before_action :track_jetbrains_usage
before_action :track_jetbrains_bundled_usage
before_action :track_gitlab_cli_usage
+ before_action :track_visual_studio_usage
+ before_action :track_neovim_plugin_usage
before_action :disable_query_limiting
before_action :limit_query_size
@@ -59,7 +61,7 @@ class GraphqlController < ApplicationController
urgency :low, [:execute]
def execute
- result = if Feature.enabled?(:cache_introspection_query) && introspection_query?
+ result = if introspection_query?
execute_introspection_query
else
multiplex? ? execute_multiplex : execute_query
@@ -184,6 +186,16 @@ class GraphqlController < ApplicationController
.track_api_request_when_trackable(user_agent: request.user_agent, user: current_user)
end
+ def track_visual_studio_usage
+ Gitlab::UsageDataCounters::VisualStudioExtensionActivityUniqueCounter
+ .track_api_request_when_trackable(user_agent: request.user_agent, user: current_user)
+ end
+
+ def track_neovim_plugin_usage
+ Gitlab::UsageDataCounters::NeovimPluginActivityUniqueCounter
+ .track_api_request_when_trackable(user_agent: request.user_agent, user: current_user)
+ end
+
def track_gitlab_cli_usage
Gitlab::UsageDataCounters::GitLabCliActivityUniqueCounter
.track_api_request_when_trackable(user_agent: request.user_agent, user: current_user)
diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb
index 5440908aee7..59343ec8b08 100644
--- a/app/controllers/groups/application_controller.rb
+++ b/app/controllers/groups/application_controller.rb
@@ -37,18 +37,6 @@ class Groups::ApplicationController < ApplicationController
end
end
- def authorize_admin_group_runners!
- unless can?(current_user, :admin_group_runners, group)
- render_404
- end
- end
-
- def authorize_read_group_runners!
- unless can?(current_user, :read_group_runners, group)
- render_404
- end
- end
-
def authorize_create_deploy_token!
unless can?(current_user, :create_deploy_token, group)
render_404
diff --git a/app/controllers/groups/dependency_proxy/application_controller.rb b/app/controllers/groups/dependency_proxy/application_controller.rb
index 300a82eed78..12c3679cf2a 100644
--- a/app/controllers/groups/dependency_proxy/application_controller.rb
+++ b/app/controllers/groups/dependency_proxy/application_controller.rb
@@ -24,7 +24,7 @@ module Groups
case user_or_deploy_token
when User
@authentication_result = Gitlab::Auth::Result.new(user_or_deploy_token, nil, :user, [])
- sign_in(user_or_deploy_token)
+ sign_in(user_or_deploy_token) unless user_or_deploy_token.project_bot?
when DeployToken
@authentication_result = Gitlab::Auth::Result.new(user_or_deploy_token, nil, :deploy_token, [])
end
diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb
index 2d821676677..57bca5ebc52 100644
--- a/app/controllers/groups/labels_controller.rb
+++ b/app/controllers/groups/labels_controller.rb
@@ -64,8 +64,13 @@ class Groups::LabelsController < Groups::ApplicationController
end
def destroy
- @label.destroy
- redirect_to group_labels_path(@group), status: :found, notice: "#{@label.name} deleted permanently"
+ if @label.destroy
+ redirect_to group_labels_path(@group), status: :found,
+ notice: format(_('%{label_name} was removed'), label_name: @label.name)
+ else
+ redirect_to group_labels_path(@group), status: :found,
+ alert: @label.errors.full_messages.to_sentence
+ end
end
protected
diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb
index 2dd0e36b65f..b3539da8429 100644
--- a/app/controllers/groups/runners_controller.rb
+++ b/app/controllers/groups/runners_controller.rb
@@ -49,7 +49,7 @@ class Groups::RunnersController < Groups::ApplicationController
end
def authorize_update_runner!
- return if can?(current_user, :admin_group_runners, group) && can?(current_user, :update_runner, runner)
+ return if can?(current_user, :update_runner, runner)
render_404
end
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
index 169caabf9d8..f50cdd2b1de 100644
--- a/app/controllers/groups/settings/ci_cd_controller.rb
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -14,8 +14,8 @@ module Groups
feature_category :continuous_integration
before_action do
- push_frontend_feature_flag(:ci_group_env_scope_graphql, group)
push_frontend_feature_flag(:ci_variables_pages, current_user)
+ push_frontend_feature_flag(:ci_variable_drawer, current_user)
end
urgency :low
@@ -49,7 +49,6 @@ module Groups
def define_variables
define_ci_variables
- define_view_variables
end
def define_ci_variables
@@ -59,10 +58,6 @@ module Groups
.map { |variable| variable.present(current_user: current_user) }
end
- def define_view_variables
- @content_class = 'limit-container-width' unless fluid_layout
- end
-
def authorize_admin_group!
return render_404 unless can?(current_user, :admin_group, group)
end
diff --git a/app/controllers/groups/work_items_controller.rb b/app/controllers/groups/work_items_controller.rb
new file mode 100644
index 00000000000..d1e15c81471
--- /dev/null
+++ b/app/controllers/groups/work_items_controller.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Groups
+ class WorkItemsController < Groups::ApplicationController
+ feature_category :team_planning
+
+ def index
+ not_found unless Feature.enabled?(:namespace_level_work_items, group)
+ end
+ end
+end
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index ec16be8f85e..344de886a93 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -37,6 +37,7 @@ class GroupsController < Groups::ApplicationController
push_frontend_feature_flag(:frontend_caching, group)
push_force_frontend_feature_flag(:work_items, group.work_items_feature_flag_enabled?)
push_frontend_feature_flag(:issues_grid_view)
+ push_frontend_feature_flag(:new_graphql_users_autocomplete, group)
end
before_action only: :merge_requests do
@@ -218,8 +219,8 @@ class GroupsController < Groups::ApplicationController
return super unless html_request?
@has_issues = IssuesFinder.new(current_user, group_id: group.id, include_subgroups: true).execute
- .non_archived
- .exists?
+ .non_archived
+ .exists?
@has_projects = group_projects.exists?
@@ -293,6 +294,7 @@ class GroupsController < Groups::ApplicationController
:project_creation_level,
:subgroup_creation_level,
:default_branch_protection,
+ { default_branch_protection_defaults: [:allow_force_push, { allowed_to_merge: [:access_level], allowed_to_push: [:access_level] }] },
:default_branch_name,
:allow_mfa_for_subgroups,
:resource_access_token_creation_allowed,
@@ -309,13 +311,13 @@ class GroupsController < Groups::ApplicationController
options = { include_subgroups: true }
projects = GroupProjectsFinder.new(params: params, group: group, options: options, current_user: current_user)
- .execute
- .includes(:namespace)
+ .execute
+ .includes(:namespace)
@events = EventCollection
- .new(projects, offset: params[:offset].to_i, filter: event_filter, groups: groups)
- .to_a
- .map(&:present)
+ .new(projects, offset: params[:offset].to_i, filter: event_filter, groups: groups)
+ .to_a
+ .map(&:present)
Events::RenderService
.new(current_user)
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index 12210afd44a..28732d58484 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -13,10 +13,6 @@ class Import::GithubController < Import::BaseController
before_action :provider_auth, only: [:status, :realtime_changes, :create]
before_action :expire_etag_cache, only: [:status, :create]
- before_action only: [:status] do
- push_frontend_feature_flag(:import_details_page)
- end
-
rescue_from Octokit::Unauthorized, with: :provider_unauthorized
rescue_from Octokit::TooManyRequests, with: :provider_rate_limit
rescue_from Gitlab::GithubImport::RateLimitError, with: :rate_limit_threshold_exceeded
@@ -73,9 +69,7 @@ class Import::GithubController < Import::BaseController
end
end
- def details
- render_404 unless Feature.enabled?(:import_details_page)
- end
+ def details; end
def create
result = Import::GithubService.new(client, current_user, import_params).execute(access_params, provider_name)
diff --git a/app/controllers/jira_connect/app_descriptor_controller.rb b/app/controllers/jira_connect/app_descriptor_controller.rb
index 2c498820a1e..3c50d54fa10 100644
--- a/app/controllers/jira_connect/app_descriptor_controller.rb
+++ b/app/controllers/jira_connect/app_descriptor_controller.rb
@@ -8,7 +8,7 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController
skip_before_action :verify_atlassian_jwt!
def show
- result = {
+ render json: {
name: Atlassian::JiraConnect.app_name,
description: 'Integrate commits, branches and merge requests from GitLab into Jira',
key: Atlassian::JiraConnect.app_key,
@@ -36,15 +36,10 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController
gdpr: true
}
}
-
- result[:links][:feedback] = URI.join(HOME_URL, FEEDBACK_URL) if Feature.enabled?(:jira_for_cloud_app_feedback_link)
-
- render json: result
end
private
- FEEDBACK_URL = '/gitlab-org/gitlab/-/issues/413652'
HOME_URL = 'https://gitlab.com'
DOC_URL = 'https://docs.gitlab.com/ee/integration/jira/'
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index eda72400f17..72b3516ae3f 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -130,6 +130,8 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
link_identity(identity_linker)
set_remember_me(current_user)
+ store_idp_two_factor_status(build_auth_user(auth_module::User).bypass_two_factor?)
+
if identity_linker.changed?
redirect_identity_linked
elsif identity_linker.failed?
@@ -159,7 +161,9 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
end
def build_auth_user(auth_user_class)
- auth_user_class.new(oauth)
+ strong_memoize_with(:build_auth_user, auth_user_class) do
+ auth_user_class.new(oauth)
+ end
end
def sign_in_user_flow(auth_user_class)
@@ -179,12 +183,16 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
if user.two_factor_enabled? && !auth_user.bypass_two_factor?
prompt_for_two_factor(user)
+ store_idp_two_factor_status(false)
else
if user.deactivated?
user.activate
flash[:notice] = _('Welcome back! Your account had been deactivated due to inactivity but is now reactivated.')
end
+ # session variable for storing bypass two-factor request from IDP
+ store_idp_two_factor_status(true)
+
accept_pending_invitations(user: user) if new_user
persist_accepted_terms_if_required(user) if new_user
@@ -323,6 +331,14 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
def sign_in_and_redirect_or_verify_identity(user, _, _)
sign_in_and_redirect(user, event: :authentication)
end
+
+ def store_idp_two_factor_status(bypass_2fa)
+ if Feature.enabled?(:by_pass_two_factor_for_current_session)
+ session[:provider_2FA] = true if bypass_2fa
+ else
+ session.delete(:provider_2FA)
+ end
+ end
end
OmniauthCallbacksController.prepend_mod_with('OmniauthCallbacksController')
diff --git a/app/controllers/organizations/application_controller.rb b/app/controllers/organizations/application_controller.rb
index 43cc7014f62..568cfe6399d 100644
--- a/app/controllers/organizations/application_controller.rb
+++ b/app/controllers/organizations/application_controller.rb
@@ -2,6 +2,7 @@
module Organizations
class ApplicationController < ::ApplicationController
+ skip_before_action :authenticate_user!
before_action :organization
layout 'organization'
@@ -16,8 +17,10 @@ module Organizations
strong_memoize_attr :organization
def authorize_action!(action)
- access_denied! if Feature.disabled?(:ui_for_organizations)
- access_denied! unless can?(current_user, action, organization)
+ return if Feature.enabled?(:ui_for_organizations, current_user) &&
+ can?(current_user, action, organization)
+
+ access_denied!
end
end
end
diff --git a/app/controllers/organizations/organizations_controller.rb b/app/controllers/organizations/organizations_controller.rb
index 4781ef995b7..650ec97c264 100644
--- a/app/controllers/organizations/organizations_controller.rb
+++ b/app/controllers/organizations/organizations_controller.rb
@@ -4,7 +4,7 @@ module Organizations
class OrganizationsController < ApplicationController
feature_category :cell
- before_action { authorize_action!(:admin_organization) }
+ before_action { authorize_action!(:read_organization) }
def show; end
diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb
index 1477f8e0aac..02f7dbf8e6f 100644
--- a/app/controllers/profiles/notifications_controller.rb
+++ b/app/controllers/profiles/notifications_controller.rb
@@ -45,7 +45,7 @@ class Profiles::NotificationsController < Profiles::ApplicationController
projects = project_notifications.map(&:source)
ActiveRecord::Associations::Preloader.new(
records: projects,
- associations: { namespace: [:route, :owner], group: [], creator: [] }
+ associations: { namespace: [:route, :owner], group: [], creator: [], project_setting: [] }
).call
Preloaders::UserMaxAccessLevelInProjectsPreloader.new(projects, current_user).execute
diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb
index f19113276c2..3e8555a4ed1 100644
--- a/app/controllers/profiles/preferences_controller.rb
+++ b/app/controllers/profiles/preferences_controller.rb
@@ -37,7 +37,7 @@ class Profiles::PreferencesController < Profiles::ApplicationController
end
def preferences_param_names
- preferences_param_names = [
+ [
:color_scheme_id,
:diffs_deletion_color,
:diffs_addition_color,
@@ -57,10 +57,9 @@ class Profiles::PreferencesController < Profiles::ApplicationController
:project_shortcut_buttons,
:markdown_surround_selection,
:markdown_automatic_lists,
- :use_new_navigation
+ :use_new_navigation,
+ :enabled_following
]
- preferences_param_names << :enabled_following if ::Feature.enabled?(:disable_follow_users, user)
- preferences_param_names
end
end
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index b41e4d11d24..56e4b22ded2 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -48,7 +48,6 @@ class Projects::BlobController < Projects::ApplicationController
urgency :low, [:create, :show, :edit, :update, :diff]
before_action do
- push_frontend_feature_flag(:highlight_js, @project)
push_frontend_feature_flag(:highlight_js_worker, @project)
push_frontend_feature_flag(:explain_code_chat, current_user)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
@@ -275,8 +274,6 @@ class Projects::BlobController < Projects::ApplicationController
@last_commit = @repository.last_commit_for_path(@commit.id, @blob.path, literal_pathspec: true)
@code_navigation_path = Gitlab::CodeNavigationPath.new(@project, @blob.commit_id).full_json_path_for(@blob.path)
- allow_lfs_direct_download
-
render 'show'
end
@@ -321,30 +318,6 @@ class Projects::BlobController < Projects::ApplicationController
current_user&.id
end
- def allow_lfs_direct_download
- return unless directly_downloading_lfs_object? && content_security_policy_enabled?
- return unless (lfs_object = @project.lfs_objects.find_by_oid(@blob.lfs_oid))
-
- request.content_security_policy.directives['connect-src'] ||= []
- request.content_security_policy.directives['connect-src'] << lfs_src(lfs_object)
- end
-
- def directly_downloading_lfs_object?
- Gitlab.config.lfs.enabled &&
- !Gitlab.config.lfs.object_store.proxy_download &&
- @blob&.stored_externally?
- end
-
- def content_security_policy_enabled?
- Gitlab.config.gitlab.content_security_policy.enabled
- end
-
- def lfs_src(lfs_object)
- file = lfs_object.file
- file = file.cdn_enabled_url(request.remote_ip) if file.respond_to?(:cdn_enabled_url)
- file.url
- end
-
alias_method :tracking_project_source, :project
def tracking_namespace_source
diff --git a/app/controllers/projects/ci/daily_build_group_report_results_controller.rb b/app/controllers/projects/ci/daily_build_group_report_results_controller.rb
index 37138afc719..c1d325d8998 100644
--- a/app/controllers/projects/ci/daily_build_group_report_results_controller.rb
+++ b/app/controllers/projects/ci/daily_build_group_report_results_controller.rb
@@ -20,7 +20,7 @@ class Projects::Ci::DailyBuildGroupReportResultsController < Projects::Applicati
end
def render_csv(collection)
- CsvBuilders::SingleBatch.new(
+ CsvBuilder::SingleBatch.new(
collection,
{
date: 'date',
diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb
index 8499bf0ced7..6e7f764c5c1 100644
--- a/app/controllers/projects/ci/pipeline_editor_controller.rb
+++ b/app/controllers/projects/ci/pipeline_editor_controller.rb
@@ -21,3 +21,5 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController
render_404 unless can_collaborate_with_project?(@project)
end
end
+
+Projects::Ci::PipelineEditorController.prepend_mod
diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb
index 59de4fbb698..34b283b87f5 100644
--- a/app/controllers/projects/discussions_controller.rb
+++ b/app/controllers/projects/discussions_controller.rb
@@ -4,8 +4,8 @@ class Projects::DiscussionsController < Projects::ApplicationController
include NotesHelper
include RendersNotes
- before_action :check_merge_requests_available!
- before_action :merge_request
+ before_action :check_noteable_supports_resolvable_notes!
+ before_action :noteable
before_action :discussion, only: [:resolve, :unresolve]
before_action :authorize_resolve_discussion!, only: [:resolve, :unresolve]
@@ -56,13 +56,26 @@ class Projects::DiscussionsController < Projects::ApplicationController
end
# rubocop: disable CodeReuse/ActiveRecord
- def merge_request
- @merge_request ||= MergeRequestsFinder.new(current_user, project_id: @project.id).find_by!(iid: params[:merge_request_id])
+ def noteable
+ @noteable ||= noteable_finder_class.new(current_user, project_id: @project.id).find_by!(iid: params[:noteable_id])
end
# rubocop: enable CodeReuse/ActiveRecord
+ def noteable_finder_class
+ case params[:noteable_type]
+ when 'issues'
+ IssuesFinder
+ when 'merge_requests'
+ MergeRequestsFinder
+ end
+ end
+
+ def check_noteable_supports_resolvable_notes!
+ render_404 unless noteable_finder_class && noteable&.supports_resolvable_notes?
+ end
+
def discussion
- @discussion ||= @merge_request.find_discussion(params[:id]) || render_404
+ @discussion ||= @noteable.find_discussion(params[:id]) || render_404
end
def authorize_resolve_discussion!
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index 4cc1ed092d2..127fe40b0e3 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -13,7 +13,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
end
before_action only: [:index, :edit, :new] do
- push_frontend_feature_flag(:kubernetes_namespace_for_environment)
+ push_frontend_feature_flag(:flux_resource_for_environment)
end
before_action :authorize_read_environment!
@@ -110,10 +110,13 @@ class Projects::EnvironmentsController < Projects::ApplicationController
return render_404 unless @environment.available?
stop_actions = @environment.stop_with_actions!(current_user)
+ job = stop_actions.first if stop_actions&.count == 1
action_or_env_url =
- if stop_actions&.count == 1
- polymorphic_url([project, stop_actions.first])
+ if job.instance_of?(::Ci::Build)
+ polymorphic_url([project, job])
+ elsif job.instance_of?(::Ci::Bridge)
+ project_pipeline_url(project, job.pipeline_id)
else
project_environment_url(project, @environment)
end
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 05be34d63e0..83947c443f4 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -52,6 +52,7 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:saved_replies, current_user)
push_frontend_feature_flag(:issues_grid_view)
push_frontend_feature_flag(:service_desk_ticket)
+ push_frontend_feature_flag(:issues_list_drawer, project)
end
before_action only: [:index, :show] do
@@ -61,6 +62,7 @@ class Projects::IssuesController < Projects::ApplicationController
before_action only: [:index, :service_desk] do
push_frontend_feature_flag(:or_issuable_queries, project)
push_frontend_feature_flag(:frontend_caching, project&.group)
+ push_frontend_feature_flag(:new_graphql_users_autocomplete, project)
end
before_action only: :show do
@@ -71,6 +73,7 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:epic_widget_edit_confirmation, project)
push_frontend_feature_flag(:moved_mr_sidebar, project)
push_frontend_feature_flag(:move_close_into_dropdown, project)
+ push_frontend_feature_flag(:action_cable_notes, project)
end
around_action :allow_gitaly_ref_name_caching, only: [:discussions]
diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb
index 649bead0b6d..67cff16a76b 100644
--- a/app/controllers/projects/labels_controller.rb
+++ b/app/controllers/projects/labels_controller.rb
@@ -79,10 +79,13 @@ class Projects::LabelsController < Projects::ApplicationController
end
def destroy
- @label.destroy
- @labels = find_labels
-
- redirect_to project_labels_path(@project), status: :found, notice: 'Label was removed'
+ if @label.destroy
+ redirect_to project_labels_path(@project), status: :found,
+ notice: format(_('%{label_name} was removed'), label_name: @label.name)
+ else
+ redirect_to project_labels_path(@project), status: :found,
+ alert: @label.errors.full_messages.to_sentence
+ end
end
def remove_priority
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 2172c91fc76..30168558eff 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -50,6 +50,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:mr_activity_filters, current_user)
push_frontend_feature_flag(:review_apps_redeploy_mr_widget, project)
push_frontend_feature_flag(:ci_job_failures_in_mr, project)
+ push_frontend_feature_flag(:action_cable_notes, project)
end
before_action only: [:edit] do
@@ -165,7 +166,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
pipelines: PipelineSerializer
.new(project: @project, current_user: @current_user)
.with_pagination(request, response)
- .represent(@pipelines),
+ .represent(@pipelines, preload: true),
count: {
all: @pipelines.count
}
diff --git a/app/controllers/projects/metrics/dashboards/builder_controller.rb b/app/controllers/projects/metrics/dashboards/builder_controller.rb
deleted file mode 100644
index 02e3afcdc80..00000000000
--- a/app/controllers/projects/metrics/dashboards/builder_controller.rb
+++ /dev/null
@@ -1,47 +0,0 @@
-# frozen_string_literal: true
-
-module Projects
- module Metrics
- module Dashboards
- class BuilderController < Projects::ApplicationController
- before_action :authorize_metrics_dashboard!
-
- feature_category :metrics
- urgency :low
-
- def panel_preview
- return not_found if Feature.enabled?(:remove_monitor_metrics)
-
- respond_to do |format|
- format.json do
- if rendered_panel.success?
- render json: rendered_panel.payload
- else
- render json: { message: rendered_panel.message }, status: :unprocessable_entity
- end
- end
- end
- end
-
- private
-
- def rendered_panel
- @panel_preview ||= ::Metrics::Dashboard::PanelPreviewService.new(project, panel_yaml, environment).execute
- end
-
- def panel_yaml
- params.require(:panel_yaml)
- end
-
- def environment
- @environment ||=
- if params[:environment]
- project.environments.find(params[:environment])
- else
- project.default_environment
- end
- end
- end
- end
- end
-end
diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb
index 6cfbb61fbb2..02579cd4283 100644
--- a/app/controllers/projects/pages_controller.rb
+++ b/app/controllers/projects/pages_controller.rb
@@ -65,15 +65,7 @@ class Projects::PagesController < Projects::ApplicationController
end
def project_params_attributes
- attributes = %i[pages_https_only]
-
- return attributes unless Feature.enabled?(:pages_unique_domain, @project)
-
- attributes + [
- project_setting_attributes: [
- :pages_unique_domain_enabled
- ]
- ]
+ [:pages_https_only, { project_setting_attributes: [:pages_unique_domain_enabled] }]
end
end
diff --git a/app/controllers/projects/performance_monitoring/dashboards_controller.rb b/app/controllers/projects/performance_monitoring/dashboards_controller.rb
deleted file mode 100644
index 1255ec1dde2..00000000000
--- a/app/controllers/projects/performance_monitoring/dashboards_controller.rb
+++ /dev/null
@@ -1,114 +0,0 @@
-# frozen_string_literal: true
-
-module Projects
- module PerformanceMonitoring
- class DashboardsController < ::Projects::ApplicationController
- include BlobHelper
-
- before_action :check_repository_available!
- before_action :validate_required_params!
-
- rescue_from ActionController::ParameterMissing do |exception|
- respond_error(http_status: :bad_request, message: _('Request parameter %{param} is missing.') % { param: exception.param })
- end
-
- feature_category :metrics
- urgency :low
-
- def create
- return not_found if Feature.enabled?(:remove_monitor_metrics)
-
- result = ::Metrics::Dashboard::CloneDashboardService.new(project, current_user, dashboard_params).execute
-
- if result[:status] == :success
- respond_success(result)
- else
- respond_error(result)
- end
- end
-
- def update
- return not_found if Feature.enabled?(:remove_monitor_metrics)
-
- result = ::Metrics::Dashboard::UpdateDashboardService.new(project, current_user, dashboard_params.merge(file_content_params)).execute
-
- if result[:status] == :success
- respond_update_success(result)
- else
- respond_error(result)
- end
- end
-
- private
-
- def respond_success(result)
- set_web_ide_link_notice(result.dig(:dashboard, :path))
- respond_to do |format|
- format.json { render status: result.delete(:http_status), json: result }
- end
- end
-
- def respond_error(result)
- respond_to do |format|
- format.json { render json: { error: result[:message] }, status: result[:http_status] }
- end
- end
-
- def set_web_ide_link_notice(new_dashboard_path)
- web_ide_link_start = "<a href=\"#{ide_edit_path(project, redirect_safe_branch_name, new_dashboard_path)}\">"
- message = _("Your dashboard has been copied. You can %{web_ide_link_start}edit it here%{web_ide_link_end}.") % { web_ide_link_start: web_ide_link_start, web_ide_link_end: "</a>" }
- flash[:notice] = message.html_safe
- end
-
- def respond_update_success(result)
- set_web_ide_link_update_notice(result.dig(:dashboard, :path))
- respond_to do |format|
- format.json { render status: result.delete(:http_status), json: result }
- end
- end
-
- def set_web_ide_link_update_notice(new_dashboard_path)
- web_ide_link_start = "<a href=\"#{ide_edit_path(project, redirect_safe_branch_name, new_dashboard_path)}\">"
- message = _("Your dashboard has been updated. You can %{web_ide_link_start}edit it here%{web_ide_link_end}.") % { web_ide_link_start: web_ide_link_start, web_ide_link_end: "</a>" }
- flash[:notice] = message.html_safe
- end
-
- def validate_required_params!
- params.require(%i[branch file_name dashboard commit_message])
- end
-
- def redirect_safe_branch_name
- repository.find_branch(params[:branch]).name
- end
-
- def dashboard_params
- params.permit(%i[branch file_name dashboard commit_message]).to_h
- end
-
- def file_content_params
- params.permit(
- file_content: [
- :dashboard,
- panel_groups: [
- :group,
- :priority,
- panels: [
- :type,
- :title,
- :y_label,
- :weight,
- metrics: [
- :id,
- :unit,
- :label,
- :query,
- :query_range
- ]
- ]
- ]
- ]
- )
- end
- end
- end
-end
diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb
index 96c9aa89953..42b6d83ee85 100644
--- a/app/controllers/projects/pipeline_schedules_controller.rb
+++ b/app/controllers/projects/pipeline_schedules_controller.rb
@@ -24,25 +24,13 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
end
def create
- if ::Feature.enabled?(:ci_refactoring_pipeline_schedule_create_service, @project)
- response = Ci::PipelineSchedules::CreateService.new(@project, current_user, schedule_params).execute
- @schedule = response.payload
-
- if response.success?
- redirect_to pipeline_schedules_path(@project)
- else
- render :new
- end
+ response = Ci::PipelineSchedules::CreateService.new(@project, current_user, schedule_params).execute
+ @schedule = response.payload
+
+ if response.success?
+ redirect_to pipeline_schedules_path(@project)
else
- @schedule = Ci::CreatePipelineScheduleService
- .new(@project, current_user, schedule_params)
- .execute
-
- if @schedule.persisted?
- redirect_to pipeline_schedules_path(@project)
- else
- render :new
- end
+ render :new
end
end
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index 0e892ef3faa..0845fbc9713 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -14,6 +14,7 @@ module Projects
before_action do
push_frontend_feature_flag(:ci_variables_pages, current_user)
+ push_frontend_feature_flag(:ci_variable_drawer, current_user)
end
helper_method :highlight_badge
@@ -88,7 +89,7 @@ module Projects
:build_timeout_human_readable, :public_builds, :ci_separated_caches,
:auto_cancel_pending_pipelines, :ci_config_path, :auto_rollback_enabled,
auto_devops_attributes: [:id, :domain, :enabled, :deploy_strategy],
- ci_cd_settings_attributes: [:default_git_depth, :forward_deployment_enabled]
+ ci_cd_settings_attributes: [:default_git_depth, :forward_deployment_enabled, :forward_deployment_rollback_allowed]
].tap do |list|
list << :max_artifacts_size if can?(current_user, :update_max_artifacts_size, project)
end
diff --git a/app/controllers/projects/tracing_controller.rb b/app/controllers/projects/tracing_controller.rb
index d1218ebf344..45e773bf62b 100644
--- a/app/controllers/projects/tracing_controller.rb
+++ b/app/controllers/projects/tracing_controller.rb
@@ -10,6 +10,10 @@ module Projects
def index; end
+ def show
+ @trace_id = params[:id]
+ end
+
private
def check_tracing_enabled
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index b961339111b..0371fb21ac8 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -18,7 +18,6 @@ class Projects::TreeController < Projects::ApplicationController
before_action :authorize_edit_tree!, only: [:create_dir]
before_action do
- push_frontend_feature_flag(:highlight_js, @project)
push_frontend_feature_flag(:highlight_js_worker, @project)
push_frontend_feature_flag(:explain_code_chat, current_user)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 51f6158d9c0..2ad0f11dc91 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -37,11 +37,9 @@ class ProjectsController < Projects::ApplicationController
before_action :check_export_rate_limit!, only: [:export, :download_export, :generate_new_export]
before_action do
- push_frontend_feature_flag(:highlight_js, @project)
push_frontend_feature_flag(:highlight_js_worker, @project)
push_frontend_feature_flag(:remove_monitor_metrics, @project)
push_frontend_feature_flag(:explain_code_chat, current_user)
- push_frontend_feature_flag(:ci_namespace_catalog_experimental, @project)
push_frontend_feature_flag(:service_desk_custom_email, @project)
push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks)
push_licensed_feature(:security_orchestration_policies) if @project.present? && @project.licensed_feature_available?(:security_orchestration_policies)
@@ -471,6 +469,7 @@ class ProjectsController < Projects::ApplicationController
mr_default_target_self
warn_about_potentially_unwanted_characters
enforce_auth_checks_on_uploads
+ emails_enabled
]
end
@@ -483,7 +482,6 @@ class ProjectsController < Projects::ApplicationController
:resolve_outdated_diff_discussions,
:container_registry_enabled,
:description,
- :emails_disabled,
:external_authorization_classification_label,
:import_url,
:issues_tracker,
diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb
index 76f181e3ce8..68f8248d114 100644
--- a/app/controllers/registrations/welcome_controller.rb
+++ b/app/controllers/registrations/welcome_controller.rb
@@ -4,6 +4,7 @@ module Registrations
class WelcomeController < ApplicationController
include OneTrustCSP
include GoogleAnalyticsCSP
+ include GoogleSyndicationCSP
include ::Gitlab::Utils::StrongMemoize
layout 'minimal'
@@ -53,11 +54,6 @@ module Registrations
stored_location_for(user) || last_member_activity_path
end
- # overridden in EE
- def complete_signup_onboarding?
- onboarding_status.continue_full_onboarding?
- end
-
def last_member_activity_path
return dashboard_projects_path unless onboarding_status.last_invited_member_source.present?
@@ -67,7 +63,7 @@ module Registrations
def update_success_path
if onboarding_status.invite_with_tasks_to_be_done?
issues_dashboard_path(assignee_username: current_user.username)
- elsif complete_signup_onboarding? # trials/regular registration on .com
+ elsif onboarding_status.continue_full_onboarding? # trials/regular registration on .com
signup_onboarding_path
elsif onboarding_status.single_invite? # invites w/o tasks due to order
flash[:notice] = helpers.invite_accepted_notice(onboarding_status.last_invited_member)
@@ -94,7 +90,7 @@ module Registrations
end
def onboarding_status
- Onboarding::Status.new(current_user)
+ Onboarding::Status.new(params.to_unsafe_h.deep_symbolize_keys, session, current_user)
end
strong_memoize_attr :onboarding_status
end
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 76b7d30cd51..d8064bbbe82 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -8,6 +8,7 @@ class RegistrationsController < Devise::RegistrationsController
include OneTrustCSP
include BizibleCSP
include GoogleAnalyticsCSP
+ include GoogleSyndicationCSP
include PreferredLanguageSwitcher
include Gitlab::Tracking::Helpers::WeakPasswordErrorEvent
include SkipsAlreadySignedInMessage
diff --git a/app/controllers/repositories/lfs_api_controller.rb b/app/controllers/repositories/lfs_api_controller.rb
index 32119ddf89e..da243a0301e 100644
--- a/app/controllers/repositories/lfs_api_controller.rb
+++ b/app/controllers/repositories/lfs_api_controller.rb
@@ -64,11 +64,13 @@ module Repositories
.for_oids(objects_oids)
.index_by(&:oid)
+ guest_can_download = Guest.can?(:download_code, project)
+
objects.each do |object|
if lfs_object = existing_oids[object[:oid]]
object[:actions] = download_actions(object, lfs_object)
- if Guest.can?(:download_code, project)
+ if guest_can_download
object[:authenticated] = true
end
else
diff --git a/app/controllers/repositories/lfs_storage_controller.rb b/app/controllers/repositories/lfs_storage_controller.rb
index 22f1a81b95b..80f7153cd7a 100644
--- a/app/controllers/repositories/lfs_storage_controller.rb
+++ b/app/controllers/repositories/lfs_storage_controller.rb
@@ -18,10 +18,7 @@ module Repositories
def download
lfs_object = LfsObject.find_by_oid(oid)
- unless lfs_object && lfs_object.file.exists?
- render_lfs_not_found
- return
- end
+ return render_lfs_not_found unless lfs_object&.file&.exists?
send_upload(lfs_object.file, send_params: { content_type: "application/octet-stream" })
end
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 45aefe48538..6c1d9a20570 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -35,6 +35,10 @@ class SearchController < ApplicationController
update_scope_for_code_search
end
+ before_action only: :show do
+ push_frontend_feature_flag(:search_projects_hide_archived, current_user)
+ end
+
rescue_from ActiveRecord::QueryCanceled, with: :render_timeout
layout 'search'
@@ -107,7 +111,7 @@ class SearchController < ApplicationController
end
def autocomplete
- term = params[:term]
+ term = params.require(:term)
@project = search_service.project
@ref = params[:project_ref] if params[:project_ref].present?
@@ -248,7 +252,7 @@ class SearchController < ApplicationController
end
def search_type
- 'basic'
+ search_service.search_type
end
end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index a9972cbd885..66ace16400a 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -13,6 +13,7 @@ class SessionsController < Devise::SessionsController
include BizibleCSP
include VerifiesWithEmail
include GoogleAnalyticsCSP
+ include GoogleSyndicationCSP
include PreferredLanguageSwitcher
include SkipsAlreadySignedInMessage
diff --git a/app/events/package_metadata/ingested_advisory_event.rb b/app/events/package_metadata/ingested_advisory_event.rb
new file mode 100644
index 00000000000..1aa0d6b0833
--- /dev/null
+++ b/app/events/package_metadata/ingested_advisory_event.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module PackageMetadata
+ class IngestedAdvisoryEvent < ::Gitlab::EventStore::Event
+ def schema
+ {
+ 'type' => 'object',
+ 'properties' => {
+ 'advisory_id' => { 'type' => 'integer' }
+ },
+ 'required' => %w[advisory_id]
+ }
+ end
+ end
+end
diff --git a/app/events/project_authorizations/authorizations_changed_event.rb b/app/events/project_authorizations/authorizations_changed_event.rb
new file mode 100644
index 00000000000..24afae9d6fd
--- /dev/null
+++ b/app/events/project_authorizations/authorizations_changed_event.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module ProjectAuthorizations
+ class AuthorizationsChangedEvent < ::Gitlab::EventStore::Event
+ def schema
+ {
+ 'type' => 'object',
+ 'required' => %w[project_id],
+ 'properties' => {
+ 'project_id' => { 'type' => 'integer' }
+ }
+ }
+ end
+ end
+end
diff --git a/app/events/repositories/default_branch_changed_event.rb b/app/events/repositories/default_branch_changed_event.rb
new file mode 100644
index 00000000000..3519fb4be86
--- /dev/null
+++ b/app/events/repositories/default_branch_changed_event.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Repositories
+ class DefaultBranchChangedEvent < ::Gitlab::EventStore::Event
+ def schema
+ {
+ 'type' => 'object',
+ 'properties' => {
+ 'container_id' => { 'type' => 'integer' },
+ 'container_type' => { 'type' => 'string' }
+ },
+ 'required' => %w[container_id container_type]
+ }
+ end
+ end
+end
diff --git a/app/finders/abuse_reports_finder.rb b/app/finders/abuse_reports_finder.rb
index 6a6d0413194..43cebd16d92 100644
--- a/app/finders/abuse_reports_finder.rb
+++ b/app/finders/abuse_reports_finder.rb
@@ -3,9 +3,13 @@
class AbuseReportsFinder
attr_reader :params, :reports
- DEFAULT_STATUS_FILTER = 'open'
- DEFAULT_SORT = 'created_at_desc'
- ALLOWED_SORT = [DEFAULT_SORT, *%w[created_at_asc updated_at_desc updated_at_asc]].freeze
+ STATUS_OPEN = 'open'
+
+ DEFAULT_SORT_STATUS_CLOSED = 'created_at_desc'
+ ALLOWED_SORT = [DEFAULT_SORT_STATUS_CLOSED, *%w[created_at_asc updated_at_desc updated_at_asc]].freeze
+
+ DEFAULT_SORT_STATUS_OPEN = 'number_of_reports_desc'
+ SORT_BY_COUNT = [DEFAULT_SORT_STATUS_OPEN].freeze
def initialize(params = {})
@params = params
@@ -14,6 +18,7 @@ class AbuseReportsFinder
def execute
filter_reports
+ aggregate_reports
sort_reports
reports.with_users.page(params[:page])
@@ -22,20 +27,28 @@ class AbuseReportsFinder
private
def filter_reports
- filter_by_user_id
+ if Feature.disabled?(:abuse_reports_list)
+ filter_by_user_id
+ return
+ end
+ filter_by_status
filter_by_user
filter_by_reporter
- filter_by_status
filter_by_category
end
+ def filter_by_user_id
+ return unless params[:user_id].present?
+
+ @reports = @reports.by_user_id(params[:user_id])
+ end
+
def filter_by_status
- return unless Feature.enabled?(:abuse_reports_list)
return unless params[:status].present?
status = params[:status]
- status = DEFAULT_STATUS_FILTER unless status.in?(AbuseReport.statuses.keys)
+ status = STATUS_OPEN unless status.in?(AbuseReport.statuses.keys)
case status
when 'open'
@@ -69,10 +82,13 @@ class AbuseReportsFinder
@reports = @reports.by_reporter_id(user_id)
end
- def filter_by_user_id
- return unless params[:user_id].present?
+ def sort_key
+ sort_key = params[:sort]
- @reports = @reports.by_user_id(params[:user_id])
+ return sort_key if sort_key.in?(ALLOWED_SORT + SORT_BY_COUNT)
+ return DEFAULT_SORT_STATUS_OPEN if status_open?
+
+ DEFAULT_SORT_STATUS_CLOSED
end
def sort_reports
@@ -81,13 +97,31 @@ class AbuseReportsFinder
return
end
- sort_by = params[:sort]
- sort_by = DEFAULT_SORT unless sort_by.in?(ALLOWED_SORT)
+ # let sub_query in aggregate_reports do the sorting if sorting by number of reports
+ return if sort_key.in?(SORT_BY_COUNT)
- @reports = @reports.order_by(sort_by)
+ @reports = @reports.order_by(sort_key)
end
def find_user_id(username)
User.by_username(username).pick(:id)
end
+
+ def status_open?
+ return unless Feature.enabled?(:abuse_reports_list) && params[:status].present?
+
+ status = params[:status]
+ status = STATUS_OPEN unless status.in?(AbuseReport.statuses.keys)
+
+ status == STATUS_OPEN
+ end
+
+ def aggregate_reports
+ if status_open?
+ sort_by_count = sort_key.in?(SORT_BY_COUNT)
+ @reports = @reports.aggregated_by_user_and_category(sort_by_count)
+ end
+
+ @reports
+ end
end
diff --git a/app/finders/admin/abuse_report_labels_finder.rb b/app/finders/admin/abuse_report_labels_finder.rb
new file mode 100644
index 00000000000..f8ca40f77b2
--- /dev/null
+++ b/app/finders/admin/abuse_report_labels_finder.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Admin
+ class AbuseReportLabelsFinder
+ def initialize(current_user, params = {})
+ @current_user = current_user
+ @params = params
+ end
+
+ def execute
+ return Admin::AbuseReportLabel.none unless current_user&.can_admin_all_resources?
+
+ items = Admin::AbuseReportLabel.all
+ items = by_search(items)
+
+ items.order(title: :asc) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
+ private
+
+ attr_reader :current_user, :params
+
+ def by_search(labels)
+ return labels unless search_term
+
+ labels.search(search_term)
+ end
+
+ def search_term
+ params[:search_term]
+ end
+ end
+end
diff --git a/app/finders/autocomplete/group_users_finder.rb b/app/finders/autocomplete/group_users_finder.rb
new file mode 100644
index 00000000000..b24f3f7f032
--- /dev/null
+++ b/app/finders/autocomplete/group_users_finder.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+# This finder returns all users that are related to a given group because:
+# 1. They are members of the group, its sub-groups, or its ancestor groups
+# 2. They are members of a group that is invited to the group, its sub-groups, or its ancestors
+# 3. They are members of a project that belongs to the group
+# 4. They are members of a group that is invited to the group's descendant projects
+#
+# These users are not necessarily members of the given group and may not have access to the group
+# so this should not be used for access control
+module Autocomplete
+ class GroupUsersFinder
+ include Gitlab::Utils::StrongMemoize
+
+ def initialize(group:)
+ @group = group
+ end
+
+ def execute
+ members = Member
+ .with(group_hierarchy_cte.to_arel) # rubocop:disable CodeReuse/ActiveRecord
+ .with(descendant_projects_cte.to_arel) # rubocop:disable CodeReuse/ActiveRecord
+ .from_union(member_relations, remove_duplicates: false)
+
+ User
+ .id_in(members.select(:user_id))
+ .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/420387")
+ end
+
+ private
+
+ def member_relations
+ [
+ members_from_group_hierarchy.select(:user_id),
+ members_from_hierarchy_group_shares.select(:user_id),
+ members_from_descendant_projects.select(:user_id),
+ members_from_descendant_project_shares.select(:user_id)
+ ]
+ end
+
+ def members_from_group_hierarchy
+ GroupMember
+ .with_source_id(group_hierarchy_ids)
+ .without_invites_and_requests
+ end
+
+ def members_from_hierarchy_group_shares
+ invited_groups = GroupGroupLink.for_shared_groups(group_hierarchy_ids).select(:shared_with_group_id)
+
+ GroupMember
+ .with_source_id(invited_groups)
+ .without_invites_and_requests
+ end
+
+ def members_from_descendant_projects
+ ProjectMember
+ .with_source_id(descendant_project_ids)
+ .without_invites_and_requests
+ end
+
+ def members_from_descendant_project_shares
+ descendant_project_invited_groups = ProjectGroupLink.for_projects(descendant_project_ids).select(:group_id)
+
+ GroupMember
+ .with_source_id(descendant_project_invited_groups)
+ .without_invites_and_requests
+ end
+
+ def group_hierarchy_cte
+ Gitlab::SQL::CTE.new(:group_hierarchy, @group.self_and_hierarchy.select(:id))
+ end
+ strong_memoize_attr :group_hierarchy_cte
+
+ def group_hierarchy_ids
+ Namespace.from(group_hierarchy_cte.table).select(:id) # rubocop:disable CodeReuse/ActiveRecord
+ end
+
+ def descendant_projects_cte
+ Gitlab::SQL::CTE.new(:descendant_projects, @group.all_projects.select(:id))
+ end
+ strong_memoize_attr :descendant_projects_cte
+
+ def descendant_project_ids
+ Project.from(descendant_projects_cte.table).select(:id) # rubocop:disable CodeReuse/ActiveRecord
+ end
+ end
+end
diff --git a/app/finders/autocomplete/routes_finder.rb b/app/finders/autocomplete/routes_finder.rb
index ecede0c1c1c..ed807d3a295 100644
--- a/app/finders/autocomplete/routes_finder.rb
+++ b/app/finders/autocomplete/routes_finder.rb
@@ -19,6 +19,7 @@ module Autocomplete
.for_routable(routables)
.sort_by_path_length
.fuzzy_search(@search, [:path])
+ .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/421843')
.limit(LIMIT) # rubocop: disable CodeReuse/ActiveRecord
end
@@ -30,9 +31,11 @@ module Autocomplete
class NamespacesOnly < self
def routables
- return Namespace.without_project_namespaces if current_user.can_admin_all_resources?
-
- current_user.namespaces
+ if current_user.can_admin_all_resources?
+ Namespace.without_project_namespaces
+ else
+ current_user.namespaces
+ end.allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/420046")
end
end
diff --git a/app/finders/autocomplete/users_finder.rb b/app/finders/autocomplete/users_finder.rb
index 7ecf5c98ac0..e7a24cde2bd 100644
--- a/app/finders/autocomplete/users_finder.rb
+++ b/app/finders/autocomplete/users_finder.rb
@@ -10,21 +10,21 @@ module Autocomplete
# ensure good performance.
LIMIT = 20
- attr_reader :current_user, :project, :group, :search, :skip_users,
+ attr_reader :current_user, :project, :group, :search,
:author_id, :todo_filter, :todo_state_filter,
- :filter_by_current_user, :states
+ :filter_by_current_user, :states, :push_code
def initialize(params:, current_user:, project:, group:)
@current_user = current_user
@project = project
@group = group
@search = params[:search]
- @skip_users = params[:skip_users]
@author_id = params[:author_id]
@todo_filter = params[:todo_filter]
@todo_state_filter = params[:todo_state_filter]
@filter_by_current_user = params[:current_user]
@states = params[:states] || ['active']
+ @push_code = params[:push_code]
end
def execute
@@ -39,6 +39,8 @@ module Autocomplete
end
end
+ items = filter_users_by_push_ability(items)
+
items.uniq.tap do |unique_items|
preload_associations(unique_items)
end
@@ -65,7 +67,6 @@ module Autocomplete
.non_internal
.reorder_by_name
.optionally_search(search, use_minimum_char_limit: use_minimum_char_limit)
- .where_not_in(skip_users)
.limit_to_todo_authors(
user: current_user,
with_todos: todo_filter,
@@ -88,7 +89,7 @@ module Autocomplete
if project
project.authorized_users.union_with_user(author_id)
elsif group
- group.users_with_parents
+ ::Autocomplete::GroupUsersFinder.new(group: group).execute # rubocop: disable CodeReuse/Finder
elsif current_user
User.all
else
@@ -96,6 +97,12 @@ module Autocomplete
end
end
+ def filter_users_by_push_ability(items)
+ return items unless project && push_code.present?
+
+ items.select { |user| user.can?(:push_code, project) }
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def preload_associations(items)
ActiveRecord::Associations::Preloader.new(records: items, associations: :status).call
@@ -109,5 +116,3 @@ module Autocomplete
end
end
end
-
-Autocomplete::UsersFinder.prepend_mod_with('Autocomplete::UsersFinder')
diff --git a/app/finders/deployments_finder.rb b/app/finders/deployments_finder.rb
index 800158dfd0a..9881cb3fc74 100644
--- a/app/finders/deployments_finder.rb
+++ b/app/finders/deployments_finder.rb
@@ -25,7 +25,7 @@ class DeploymentsFinder
# performant with the other filtering/sorting parameters.
# The composed query could be significantly slower when the filtering and sorting columns are different.
# See https://gitlab.com/gitlab-org/gitlab/-/issues/325627 for example.
- ALLOWED_SORT_VALUES = %w[id iid created_at updated_at ref finished_at].freeze
+ ALLOWED_SORT_VALUES = %w[id iid created_at updated_at finished_at].freeze
DEFAULT_SORT_VALUE = 'id'
ALLOWED_SORT_DIRECTIONS = %w[asc desc].freeze
@@ -128,7 +128,6 @@ class DeploymentsFinder
def build_sort_params
order_by = ALLOWED_SORT_VALUES.include?(params[:order_by]) ? params[:order_by] : DEFAULT_SORT_VALUE
- order_by = DEFAULT_SORT_VALUE if order_by == 'ref' && Feature.enabled?(:remove_deployments_api_ref_sort)
order_direction = ALLOWED_SORT_DIRECTIONS.include?(params[:sort]) ? params[:sort] : DEFAULT_SORT_DIRECTION
{ order_by => order_direction }
diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb
index 72ab30cf567..3e31c7a2bb2 100644
--- a/app/finders/group_descendants_finder.rb
+++ b/app/finders/group_descendants_finder.rb
@@ -141,7 +141,7 @@ class GroupDescendantsFinder
# rubocop: disable CodeReuse/Finder
def direct_child_projects
- GroupProjectsFinder.new(group: parent_group, current_user: current_user, params: params, options: { only_owned: true })
+ GroupProjectsFinder.new(group: parent_group, current_user: current_user, params: params, options: { exclude_shared: true })
.execute
end
# rubocop: enable CodeReuse/Finder
diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb
index 1025e0ebc9b..639db58b00d 100644
--- a/app/finders/group_members_finder.rb
+++ b/app/finders/group_members_finder.rb
@@ -86,11 +86,6 @@ class GroupMembersFinder < UnionFinder
end
def members_of_groups(groups, shared_from_groups)
- if Feature.disabled?(:members_with_shared_group_access, @group.root_ancestor)
- groups << shared_from_groups unless shared_from_groups.nil?
- return GroupMember.non_request.of_groups(find_union(groups, Group))
- end
-
members = GroupMember.non_request.of_groups(find_union(groups, Group))
return members if shared_from_groups.nil?
diff --git a/app/finders/group_projects_finder.rb b/app/finders/group_projects_finder.rb
index db8a0f14fbc..21341797910 100644
--- a/app/finders/group_projects_finder.rb
+++ b/app/finders/group_projects_finder.rb
@@ -9,8 +9,10 @@
# project_ids_relation: int[] - project ids to use
# group
# options:
-# only_owned: boolean
+# exclude_shared: boolean
+# When true, only projects within the group are included in the result.
# only_shared: boolean
+# When true, only projects arising from group-project shares are included in the result.
# limit: integer
# include_subgroups: boolean
# include_ancestor_groups: boolean
@@ -63,10 +65,10 @@ class GroupProjectsFinder < ProjectsFinder
projects =
if only_shared?
[shared_projects]
- elsif only_owned?
- [owned_projects]
+ elsif exclude_shared?
+ [projects_within_group]
else
- [owned_projects, shared_projects]
+ [projects_within_group, shared_projects]
end
projects.map! do |project_relation|
@@ -104,8 +106,8 @@ class GroupProjectsFinder < ProjectsFinder
end
end
- def only_owned?
- options.fetch(:only_owned, false)
+ def exclude_shared?
+ options.fetch(:exclude_shared, false)
end
def owned_projects?
@@ -126,7 +128,7 @@ class GroupProjectsFinder < ProjectsFinder
options.fetch(:include_ancestor_groups, false)
end
- def owned_projects
+ def projects_within_group
return group.projects unless include_subgroups? || include_ancestor_groups?
union_relations = []
diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb
index 63f7616884f..074eb9add0f 100644
--- a/app/finders/groups_finder.rb
+++ b/app/finders/groups_finder.rb
@@ -32,14 +32,8 @@ class GroupsFinder < UnionFinder
end
def execute
- items = all_groups.map do |item|
- item = by_parent(item)
- item = by_custom_attributes(item)
- item = filter_group_ids(item)
- item = exclude_group_ids(item)
- item = by_search(item)
-
- item
+ items = all_groups.map do |groups|
+ filter_groups(groups)
end
find_union(items, Group).with_route.order_id_desc
@@ -49,6 +43,14 @@ class GroupsFinder < UnionFinder
attr_reader :current_user, :params
+ def filter_groups(groups)
+ groups = by_parent(groups)
+ groups = by_custom_attributes(groups)
+ groups = filter_group_ids(groups)
+ groups = exclude_group_ids(groups)
+ by_search(groups)
+ end
+
def all_groups
return [owned_groups] if params[:owned]
return [groups_with_min_access_level] if min_access_level?
@@ -147,3 +149,5 @@ class GroupsFinder < UnionFinder
groups
end
end
+
+GroupsFinder.prepend_mod_with('GroupsFinder')
diff --git a/app/finders/issuable_finder/params.rb b/app/finders/issuable_finder/params.rb
index e59c2224594..bc136848dd5 100644
--- a/app/finders/issuable_finder/params.rb
+++ b/app/finders/issuable_finder/params.rb
@@ -133,7 +133,7 @@ class IssuableFinder
def projects
strong_memoize(:projects) do
- next [project] if project?
+ next Array.wrap(project) if project?
projects =
if current_user && params[:authorized_only].presence && !current_user_related?
diff --git a/app/finders/issuables/assignee_filter.rb b/app/finders/issuables/assignee_filter.rb
index c97fdffd32e..5efcd5aa23e 100644
--- a/app/finders/issuables/assignee_filter.rb
+++ b/app/finders/issuables/assignee_filter.rb
@@ -5,8 +5,6 @@ module Issuables
def filter(issuables)
filtered = by_assignee(issuables)
filtered = by_assignee_union(filtered)
- # Cross Joins Fails tests in bin/rspec spec/requests/api/graphql/boards/board_list_issues_query_spec.rb
- filtered = filtered.allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417462")
by_negated_assignee(filtered)
end
@@ -74,7 +72,7 @@ module Issuables
elsif specific_params[:assignee_id].present?
Array(specific_params[:assignee_id])
elsif specific_params[:assignee_username].present?
- User.by_username(specific_params[:assignee_username]).select(:id)
+ User.by_username(specific_params[:assignee_username]).pluck_primary_key
end
end
end
diff --git a/app/finders/issuables/author_filter.rb b/app/finders/issuables/author_filter.rb
index f36daae553d..7707bf51f18 100644
--- a/app/finders/issuables/author_filter.rb
+++ b/app/finders/issuables/author_filter.rb
@@ -15,6 +15,7 @@ module Issuables
issuables.authored(params[:author_id])
elsif params[:author_username].present?
issuables.authored(User.by_username(params[:author_username]))
+ .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/419221")
else
issuables
end
@@ -24,6 +25,7 @@ module Issuables
return issuables unless or_filters_enabled? && or_params&.fetch(:author_username, false).present?
issuables.authored(User.by_username(or_params[:author_username]))
+ .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/419221")
end
def by_negated_author(issuables)
@@ -33,6 +35,7 @@ module Issuables
issuables.not_authored(not_params[:author_id])
elsif not_params[:author_username].present?
issuables.not_authored(User.by_username(not_params[:author_username]))
+ .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/419221")
else
issuables
end
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index bd81f06f93b..0ba93a76342 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -72,6 +72,7 @@ class IssuesFinder < IssuableFinder
OR EXISTS (:authorizations)))',
user_id: current_user.id,
authorizations: current_user.authorizations_for_projects(min_access_level: CONFIDENTIAL_ACCESS_LEVEL, related_project_column: "issues.project_id"))
+ .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422045')
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/finders/labels_finder.rb b/app/finders/labels_finder.rb
index b1387f2a104..1bf2e3b71e4 100644
--- a/app/finders/labels_finder.rb
+++ b/app/finders/labels_finder.rb
@@ -24,6 +24,7 @@ class LabelsFinder < UnionFinder
items = with_title(items)
items = by_subscription(items)
items = by_search(items)
+ items = by_locked_labels(items)
items = items.with_preloaded_container if @preload_parent_association
sort(items)
@@ -94,6 +95,12 @@ class LabelsFinder < UnionFinder
labels.optionally_subscribed_by(subscriber_id)
end
+ def by_locked_labels(items)
+ return items unless params[:locked_labels]
+
+ items.with_lock_on_merge
+ end
+
def subscriber_id
current_user&.id if subscribed?
end
diff --git a/app/finders/merge_request_target_project_finder.rb b/app/finders/merge_request_target_project_finder.rb
index ea1aa6d2e9e..ee340b79ed0 100644
--- a/app/finders/merge_request_target_project_finder.rb
+++ b/app/finders/merge_request_target_project_finder.rb
@@ -14,7 +14,8 @@ class MergeRequestTargetProjectFinder
def execute(search: nil, include_routes: false)
if source_project.fork_network
items = include_routes ? projects.inc_routes : projects
- by_search(items, search)
+ by_search(items, search).allow_cross_joins_across_databases(
+ url: "https://gitlab.com/gitlab-org/gitlab/-/issues/420046")
else
Project.id_in(source_project.id)
end
@@ -39,3 +40,5 @@ class MergeRequestTargetProjectFinder
end
# rubocop: enable CodeReuse/ActiveRecord
end
+
+MergeRequestTargetProjectFinder.prepend_mod_with("MergeRequestTargetProjectFinder")
diff --git a/app/finders/metrics/dashboards/annotations_finder.rb b/app/finders/metrics/dashboards/annotations_finder.rb
deleted file mode 100644
index e97704738ea..00000000000
--- a/app/finders/metrics/dashboards/annotations_finder.rb
+++ /dev/null
@@ -1,43 +0,0 @@
-# frozen_string_literal: true
-
-module Metrics
- module Dashboards
- class AnnotationsFinder
- def initialize(dashboard:, params:)
- @dashboard = dashboard
- @params = params
- end
-
- def execute
- if dashboard.environment
- apply_filters_to(annotations_for_environment)
- else
- Metrics::Dashboard::Annotation.none
- end
- end
-
- private
-
- attr_reader :dashboard, :params
-
- def apply_filters_to(annotations)
- annotations = annotations.after(params[:from]) if params[:from].present?
- annotations = annotations.before(params[:to]) if params[:to].present? && valid_timespan_boundaries?
-
- by_dashboard(annotations)
- end
-
- def annotations_for_environment
- dashboard.environment.metrics_dashboard_annotations
- end
-
- def by_dashboard(annotations)
- annotations.for_dashboard(dashboard.path)
- end
-
- def valid_timespan_boundaries?
- params[:from].blank? || params[:to] >= params[:from]
- end
- end
- end
-end
diff --git a/app/finders/metrics/users_starred_dashboards_finder.rb b/app/finders/metrics/users_starred_dashboards_finder.rb
deleted file mode 100644
index 2ef706c1b11..00000000000
--- a/app/finders/metrics/users_starred_dashboards_finder.rb
+++ /dev/null
@@ -1,37 +0,0 @@
-# frozen_string_literal: true
-
-module Metrics
- class UsersStarredDashboardsFinder
- def initialize(user:, project:, params: {})
- @user = user
- @project = project
- @params = params
- end
-
- def execute
- return ::Metrics::UsersStarredDashboard.none unless Ability.allowed?(user, :read_metrics_user_starred_dashboard, project)
-
- items = starred_dashboards
- items = by_project(items)
- by_dashboard(items)
- end
-
- private
-
- attr_reader :user, :project, :params
-
- def by_project(items)
- items.for_project(project)
- end
-
- def by_dashboard(items)
- return items unless params[:dashboard_path]
-
- items.merge(starred_dashboards.for_project_dashboard(project, params[:dashboard_path]))
- end
-
- def starred_dashboards
- @starred_dashboards ||= user.metrics_users_starred_dashboards
- end
- end
-end
diff --git a/app/finders/packages/nuget/package_finder.rb b/app/finders/packages/nuget/package_finder.rb
index 23345f29198..064698d3c37 100644
--- a/app/finders/packages/nuget/package_finder.rb
+++ b/app/finders/packages/nuget/package_finder.rb
@@ -4,19 +4,43 @@ module Packages
module Nuget
class PackageFinder < ::Packages::GroupOrProjectPackageFinder
MAX_PACKAGES_COUNT = 300
+ FORCE_NORMALIZATION_CLIENT_VERSION = '>= 3'
def execute
+ return ::Packages::Package.none unless @params[:package_name].present?
+
packages.limit_recent(@params[:limit] || MAX_PACKAGES_COUNT)
end
private
def packages
- result = base.nuget
- .has_version
- .with_name_like(@params[:package_name])
- result = result.with_case_insensitive_version(@params[:package_version]) if @params[:package_version].present?
+ result = find_by_name
+ find_by_version(result)
+ end
+
+ def find_by_name
+ base
+ .nuget
+ .has_version
+ .with_case_insensitive_name(@params[:package_name])
+ end
+
+ def find_by_version(result)
+ return result if @params[:package_version].blank?
+
result
+ .with_nuget_version_or_normalized_version(
+ @params[:package_version],
+ with_normalized: Feature.enabled?(:nuget_normalized_version, @project_or_group) &&
+ client_forces_normalized_version?
+ )
+ end
+
+ def client_forces_normalized_version?
+ return true if @params[:client_version].blank?
+
+ VersionSorter.compare(FORCE_NORMALIZATION_CLIENT_VERSION, @params[:client_version]) <= 0
end
end
end
diff --git a/app/finders/packages/pipelines_finder.rb b/app/finders/packages/pipelines_finder.rb
new file mode 100644
index 00000000000..c814efbf176
--- /dev/null
+++ b/app/finders/packages/pipelines_finder.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Packages
+ class PipelinesFinder
+ COLUMNS = %i[id iid project_id sha ref status source created_at updated_at user_id].freeze
+
+ def initialize(pipeline_ids)
+ @pipeline_ids = pipeline_ids
+ end
+
+ def execute
+ ::Ci::Pipeline
+ .id_in(pipeline_ids)
+ .select(COLUMNS)
+ .order_id_desc
+ end
+
+ private
+
+ attr_reader :pipeline_ids
+ end
+end
diff --git a/app/finders/projects/ml/model_finder.rb b/app/finders/projects/ml/model_finder.rb
index 9ef5dacb551..99c66f53de7 100644
--- a/app/finders/projects/ml/model_finder.rb
+++ b/app/finders/projects/ml/model_finder.rb
@@ -8,12 +8,9 @@ module Projects
end
def execute
- @project
- .packages
- .installable
- .ml_model
- .order_name_desc_version_desc
- .select_only_first_by_name
+ ::Ml::Model
+ .by_project(@project)
+ .including_latest_version
.limit(100) # This is a temporary limit before we add pagination
end
end
diff --git a/app/finders/repositories/tree_finder.rb b/app/finders/repositories/tree_finder.rb
index 231c1de1513..2a8971d4d86 100644
--- a/app/finders/repositories/tree_finder.rb
+++ b/app/finders/repositories/tree_finder.rb
@@ -13,7 +13,7 @@ module Repositories
def execute(gitaly_pagination: false)
raise CommitMissingError unless commit_exists?
- request_params = { recursive: recursive }
+ request_params = { recursive: recursive, rescue_not_found: rescue_not_found }
request_params[:pagination_params] = pagination_params if gitaly_pagination
repository.tree(commit.id, path, **request_params).sorted_entries
@@ -51,6 +51,10 @@ module Repositories
params[:recursive]
end
+ def rescue_not_found
+ params[:rescue_not_found]
+ end
+
def pagination_params
{
limit: params[:per_page] || Kaminari.config.default_per_page,
diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb
index 9dd7e508c22..cb824aca33f 100644
--- a/app/finders/snippets_finder.rb
+++ b/app/finders/snippets_finder.rb
@@ -67,17 +67,30 @@ class SnippetsFinder < UnionFinder
return Snippet.none if project.nil? && params[:project].present?
return Snippet.none if project && !project.feature_available?(:snippets, current_user)
- items = init_collection
- items = by_ids(items)
- items = items.with_optional_visibility(visibility_from_scope)
- items = by_created_at(items)
-
- items.order_by(sort_param)
+ filter_snippets.order_by(sort_param)
end
private
- def init_collection
+ def filter_snippets
+ if return_all_available_and_permited?
+ snippets = all_snippets_for_admin
+ else
+ snippets = all_snippets
+ snippets = by_ids(snippets)
+ snippets = snippets.with_optional_visibility(visibility_from_scope)
+ end
+
+ by_created_at(snippets)
+ end
+
+ def return_all_available_and_permited?
+ # Currently limited to access_levels `admin` and `auditor`
+ # See policies/base_policy.rb files for specifics.
+ params[:all_available] && current_user&.can_read_all_resources?
+ end
+
+ def all_snippets
if explore?
snippets_for_explore
elsif only_personal?
@@ -121,6 +134,12 @@ class SnippetsFinder < UnionFinder
prepared_union(queries)
end
+ def all_snippets_for_admin
+ return Snippet.only_project_snippets if only_project?
+
+ Snippet.all
+ end
+
def snippets_for_a_single_project
Snippet.for_project_with_user(project, current_user)
end
@@ -182,10 +201,10 @@ class SnippetsFinder < UnionFinder
end
end
- def by_ids(items)
- return items unless params[:ids].present?
+ def by_ids(snippets)
+ return snippets unless params[:ids].present?
- items.id_in(params[:ids])
+ snippets.id_in(params[:ids])
end
def author
diff --git a/app/finders/work_items/namespace_work_items_finder.rb b/app/finders/work_items/namespace_work_items_finder.rb
new file mode 100644
index 00000000000..aad99d710b6
--- /dev/null
+++ b/app/finders/work_items/namespace_work_items_finder.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module WorkItems
+ class NamespaceWorkItemsFinder < WorkItemsFinder
+ def initialize(...)
+ super
+
+ self.parent_param = namespace
+ end
+
+ def execute
+ items = init_collection
+ items = by_namespace(items)
+
+ sort(items)
+ end
+
+ override :with_confidentiality_access_check
+ def with_confidentiality_access_check
+ return model_class.all if params.user_can_see_all_issuables?
+
+ # Only admins can see hidden issues, so for non-admins, we filter out any hidden issues
+ issues = model_class.without_hidden
+
+ return issues.all if params.user_can_see_all_confidential_issues?
+
+ return issues.public_only if params.user_cannot_see_confidential_issues?
+
+ issues.with_confidentiality_check(current_user)
+ end
+
+ private
+
+ def by_namespace(items)
+ if namespace.blank? || !Ability.allowed?(current_user, "read_#{namespace.to_ability_name}".to_sym, namespace)
+ return klass.none
+ end
+
+ items.in_namespaces(namespace)
+ end
+
+ def namespace
+ return if params[:namespace_id].blank?
+
+ params[:namespace_id].is_a?(Namespace) ? params[:namespace_id] : Namespace.find_by_id(params[:namespace_id])
+ end
+ strong_memoize_attr :namespace
+ end
+end
diff --git a/app/graphql/mutations/alert_management/alerts/set_assignees.rb b/app/graphql/mutations/alert_management/alerts/set_assignees.rb
index 500e2b868b1..a8513417c1c 100644
--- a/app/graphql/mutations/alert_management/alerts/set_assignees.rb
+++ b/app/graphql/mutations/alert_management/alerts/set_assignees.rb
@@ -7,14 +7,14 @@ module Mutations
graphql_name 'AlertSetAssignees'
argument :assignee_usernames,
- [GraphQL::Types::String],
- required: true,
- description: 'Usernames to assign to the alert. Replaces existing assignees by default.'
+ [GraphQL::Types::String],
+ required: true,
+ description: 'Usernames to assign to the alert. Replaces existing assignees by default.'
argument :operation_mode,
- Types::MutationOperationModeEnum,
- required: false,
- description: 'Operation to perform. Defaults to REPLACE.'
+ Types::MutationOperationModeEnum,
+ required: false,
+ description: 'Operation to perform. Defaults to REPLACE.'
def resolve(args)
alert = authorized_find!(project_path: args[:project_path], iid: args[:iid])
diff --git a/app/graphql/mutations/alert_management/base.rb b/app/graphql/mutations/alert_management/base.rb
index 771ace5510f..615c0f43a15 100644
--- a/app/graphql/mutations/alert_management/base.rb
+++ b/app/graphql/mutations/alert_management/base.rb
@@ -6,27 +6,27 @@ module Mutations
include Gitlab::Utils::UsageData
argument :project_path, GraphQL::Types::ID,
- required: true,
- description: "Project the alert to mutate is in."
+ required: true,
+ description: "Project the alert to mutate is in."
argument :iid, GraphQL::Types::String,
- required: true,
- description: "IID of the alert to mutate."
+ required: true,
+ description: "IID of the alert to mutate."
field :alert,
- Types::AlertManagement::AlertType,
- null: true,
- description: "Alert after mutation."
+ Types::AlertManagement::AlertType,
+ null: true,
+ description: "Alert after mutation."
field :todo,
- Types::TodoType,
- null: true,
- description: "To-do item after mutation."
+ Types::TodoType,
+ null: true,
+ description: "To-do item after mutation."
field :issue,
- Types::IssueType,
- null: true,
- description: "Issue created after mutation."
+ Types::IssueType,
+ null: true,
+ description: "Issue created after mutation."
authorize :update_alert_management_alert
diff --git a/app/graphql/mutations/alert_management/http_integration/create.rb b/app/graphql/mutations/alert_management/http_integration/create.rb
index f8d1a383706..fccef8cd3ad 100644
--- a/app/graphql/mutations/alert_management/http_integration/create.rb
+++ b/app/graphql/mutations/alert_management/http_integration/create.rb
@@ -9,16 +9,16 @@ module Mutations
include FindsProject
argument :project_path, GraphQL::Types::ID,
- required: true,
- description: 'Project to create the integration in.'
+ required: true,
+ description: 'Project to create the integration in.'
argument :name, GraphQL::Types::String,
- required: true,
- description: 'Name of the integration.'
+ required: true,
+ description: 'Name of the integration.'
argument :active, GraphQL::Types::Boolean,
- required: true,
- description: 'Whether the integration is receiving alerts.'
+ required: true,
+ description: 'Whether the integration is receiving alerts.'
def resolve(args)
project = authorized_find!(args[:project_path])
diff --git a/app/graphql/mutations/alert_management/http_integration/destroy.rb b/app/graphql/mutations/alert_management/http_integration/destroy.rb
index dc5c73ecff6..9da50a4c4ce 100644
--- a/app/graphql/mutations/alert_management/http_integration/destroy.rb
+++ b/app/graphql/mutations/alert_management/http_integration/destroy.rb
@@ -7,8 +7,8 @@ module Mutations
graphql_name 'HttpIntegrationDestroy'
argument :id, Types::GlobalIDType[::AlertManagement::HttpIntegration],
- required: true,
- description: "ID of the integration to remove."
+ required: true,
+ description: "ID of the integration to remove."
def resolve(id:)
integration = authorized_find!(id: id)
diff --git a/app/graphql/mutations/alert_management/http_integration/http_integration_base.rb b/app/graphql/mutations/alert_management/http_integration/http_integration_base.rb
index 2f25d315d2e..9434ac1637e 100644
--- a/app/graphql/mutations/alert_management/http_integration/http_integration_base.rb
+++ b/app/graphql/mutations/alert_management/http_integration/http_integration_base.rb
@@ -5,9 +5,9 @@ module Mutations
module HttpIntegration
class HttpIntegrationBase < BaseMutation
field :integration,
- Types::AlertManagement::HttpIntegrationType,
- null: true,
- description: "HTTP integration."
+ Types::AlertManagement::HttpIntegrationType,
+ null: true,
+ description: "HTTP integration."
authorize :admin_operations
diff --git a/app/graphql/mutations/alert_management/http_integration/reset_token.rb b/app/graphql/mutations/alert_management/http_integration/reset_token.rb
index 83ad7762408..bed3cf08674 100644
--- a/app/graphql/mutations/alert_management/http_integration/reset_token.rb
+++ b/app/graphql/mutations/alert_management/http_integration/reset_token.rb
@@ -7,8 +7,8 @@ module Mutations
graphql_name 'HttpIntegrationResetToken'
argument :id, Types::GlobalIDType[::AlertManagement::HttpIntegration],
- required: true,
- description: "ID of the integration to mutate."
+ required: true,
+ description: "ID of the integration to mutate."
def resolve(id:)
integration = authorized_find!(id: id)
diff --git a/app/graphql/mutations/alert_management/http_integration/update.rb b/app/graphql/mutations/alert_management/http_integration/update.rb
index 78424e317b8..06d0b7163b0 100644
--- a/app/graphql/mutations/alert_management/http_integration/update.rb
+++ b/app/graphql/mutations/alert_management/http_integration/update.rb
@@ -7,16 +7,16 @@ module Mutations
graphql_name 'HttpIntegrationUpdate'
argument :id, Types::GlobalIDType[::AlertManagement::HttpIntegration],
- required: true,
- description: "ID of the integration to mutate."
+ required: true,
+ description: "ID of the integration to mutate."
argument :name, GraphQL::Types::String,
- required: false,
- description: "Name of the integration."
+ required: false,
+ description: "Name of the integration."
argument :active, GraphQL::Types::Boolean,
- required: false,
- description: "Whether the integration is receiving alerts."
+ required: false,
+ description: "Whether the integration is receiving alerts."
def resolve(args)
integration = authorized_find!(id: args[:id])
diff --git a/app/graphql/mutations/alert_management/prometheus_integration/create.rb b/app/graphql/mutations/alert_management/prometheus_integration/create.rb
index b06a4f58df5..665ce96f0f9 100644
--- a/app/graphql/mutations/alert_management/prometheus_integration/create.rb
+++ b/app/graphql/mutations/alert_management/prometheus_integration/create.rb
@@ -9,16 +9,16 @@ module Mutations
include FindsProject
argument :project_path, GraphQL::Types::ID,
- required: true,
- description: 'Project to create the integration in.'
+ required: true,
+ description: 'Project to create the integration in.'
argument :active, GraphQL::Types::Boolean,
- required: true,
- description: 'Whether the integration is receiving alerts.'
+ required: true,
+ description: 'Whether the integration is receiving alerts.'
argument :api_url, GraphQL::Types::String,
- required: false,
- description: 'Endpoint at which Prometheus can be queried.'
+ required: false,
+ description: 'Endpoint at which Prometheus can be queried.'
def resolve(args)
project = authorized_find!(args[:project_path])
diff --git a/app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb b/app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb
index 29834d63f35..28729ec70cd 100644
--- a/app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb
+++ b/app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb
@@ -5,9 +5,9 @@ module Mutations
module PrometheusIntegration
class PrometheusIntegrationBase < BaseMutation
field :integration,
- Types::AlertManagement::PrometheusIntegrationType,
- null: true,
- description: "Newly created integration."
+ Types::AlertManagement::PrometheusIntegrationType,
+ null: true,
+ description: "Newly created integration."
authorize :admin_project
diff --git a/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb b/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb
index 71c02efdc03..15e6763b1ee 100644
--- a/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb
+++ b/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb
@@ -7,8 +7,8 @@ module Mutations
graphql_name 'PrometheusIntegrationResetToken'
argument :id, Types::GlobalIDType[::Integrations::Prometheus],
- required: true,
- description: "ID of the integration to mutate."
+ required: true,
+ description: "ID of the integration to mutate."
def resolve(id:)
integration = authorized_find!(id: id)
diff --git a/app/graphql/mutations/alert_management/prometheus_integration/update.rb b/app/graphql/mutations/alert_management/prometheus_integration/update.rb
index 50aafdc26a6..593624aaafd 100644
--- a/app/graphql/mutations/alert_management/prometheus_integration/update.rb
+++ b/app/graphql/mutations/alert_management/prometheus_integration/update.rb
@@ -7,16 +7,16 @@ module Mutations
graphql_name 'PrometheusIntegrationUpdate'
argument :id, Types::GlobalIDType[::Integrations::Prometheus],
- required: true,
- description: "ID of the integration to mutate."
+ required: true,
+ description: "ID of the integration to mutate."
argument :active, GraphQL::Types::Boolean,
- required: false,
- description: "Whether the integration is receiving alerts."
+ required: false,
+ description: "Whether the integration is receiving alerts."
argument :api_url, GraphQL::Types::String,
- required: false,
- description: "Endpoint at which Prometheus can be queried."
+ required: false,
+ description: "Endpoint at which Prometheus can be queried."
def resolve(args)
integration = authorized_find!(id: args[:id])
diff --git a/app/graphql/mutations/alert_management/update_alert_status.rb b/app/graphql/mutations/alert_management/update_alert_status.rb
index be271a7d795..a0d06ebf221 100644
--- a/app/graphql/mutations/alert_management/update_alert_status.rb
+++ b/app/graphql/mutations/alert_management/update_alert_status.rb
@@ -6,8 +6,8 @@ module Mutations
graphql_name 'UpdateAlertStatus'
argument :status, Types::AlertManagement::StatusEnum,
- required: true,
- description: 'Status to set the alert.'
+ required: true,
+ description: 'Status to set the alert.'
def resolve(project_path:, iid:, status:)
alert = authorized_find!(project_path: project_path, iid: iid)
diff --git a/app/graphql/mutations/award_emojis/base.rb b/app/graphql/mutations/award_emojis/base.rb
index 65065de0de4..0223c978cf9 100644
--- a/app/graphql/mutations/award_emojis/base.rb
+++ b/app/graphql/mutations/award_emojis/base.rb
@@ -3,7 +3,7 @@
module Mutations
module AwardEmojis
class Base < BaseMutation
- NOT_EMOJI_AWARDABLE = 'You cannot award emoji to this resource.'
+ NOT_EMOJI_AWARDABLE = 'You cannot add emoji reactions to this resource.'
authorize :award_emoji
@@ -20,7 +20,7 @@ module Mutations
field :award_emoji,
Types::AwardEmojis::AwardEmojiType,
null: true,
- description: 'Award emoji after mutation.'
+ description: 'Emoji reactions after mutation.'
private
diff --git a/app/graphql/mutations/ci/pipeline_schedule/create.rb b/app/graphql/mutations/ci/pipeline_schedule/create.rb
index 71a366ed342..d21ac6fd727 100644
--- a/app/graphql/mutations/ci/pipeline_schedule/create.rb
+++ b/app/graphql/mutations/ci/pipeline_schedule/create.rb
@@ -51,28 +51,16 @@ module Mutations
params = pipeline_schedule_attrs.merge(variables_attributes: variables.map(&:to_h))
- if ::Feature.enabled?(:ci_refactoring_pipeline_schedule_create_service, project)
- response = ::Ci::PipelineSchedules::CreateService
- .new(project, current_user, params)
- .execute
-
- schedule = response.payload
-
- unless response.success?
- return {
- pipeline_schedule: nil, errors: response.errors
- }
- end
- else
- schedule = ::Ci::CreatePipelineScheduleService
- .new(project, current_user, params)
- .execute
-
- unless schedule.persisted?
- return {
- pipeline_schedule: nil, errors: schedule.errors.full_messages
- }
- end
+ response = ::Ci::PipelineSchedules::CreateService
+ .new(project, current_user, params)
+ .execute
+
+ schedule = response.payload
+
+ unless response.success?
+ return {
+ pipeline_schedule: nil, errors: response.errors
+ }
end
{
diff --git a/app/graphql/mutations/ci/pipeline_trigger/base.rb b/app/graphql/mutations/ci/pipeline_trigger/base.rb
new file mode 100644
index 00000000000..23be70e7754
--- /dev/null
+++ b/app/graphql/mutations/ci/pipeline_trigger/base.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Ci
+ module PipelineTrigger
+ class Base < BaseMutation
+ authorize :admin_build
+ authorize :admin_trigger
+
+ PipelineTriggerID = ::Types::GlobalIDType[::Ci::Trigger]
+
+ argument :id, PipelineTriggerID,
+ required: true,
+ description: 'ID of the pipeline trigger token to mutate.'
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/ci/pipeline_trigger/create.rb b/app/graphql/mutations/ci/pipeline_trigger/create.rb
new file mode 100644
index 00000000000..042f9b26dd0
--- /dev/null
+++ b/app/graphql/mutations/ci/pipeline_trigger/create.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Ci
+ module PipelineTrigger
+ class Create < BaseMutation
+ graphql_name 'PipelineTriggerCreate'
+
+ include FindsProject
+
+ authorize :admin_build
+
+ argument :project_path, GraphQL::Types::ID,
+ required: true,
+ description: 'Full path of the project that the pipeline trigger token to mutate is in.'
+
+ argument :description, GraphQL::Types::String,
+ required: true,
+ description: 'Description of the pipeline trigger token.'
+
+ field :pipeline_trigger, Types::Ci::PipelineTriggerType,
+ null: true,
+ description: 'Mutated pipeline trigger token.'
+
+ def resolve(project_path:, description:)
+ project = authorized_find!(project_path)
+
+ trigger = project.triggers.create(owner: current_user, description: description)
+
+ {
+ pipeline_trigger: trigger,
+ errors: trigger.errors.full_messages
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/ci/pipeline_trigger/delete.rb b/app/graphql/mutations/ci/pipeline_trigger/delete.rb
new file mode 100644
index 00000000000..bc18f58ec43
--- /dev/null
+++ b/app/graphql/mutations/ci/pipeline_trigger/delete.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Ci
+ module PipelineTrigger
+ class Delete < Base
+ graphql_name 'PipelineTriggerDelete'
+
+ def resolve(id:)
+ trigger = authorized_find!(id: id)
+
+ errors = trigger.destroy ? [] : ['Could not remove the trigger']
+
+ { errors: errors }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/ci/pipeline_trigger/update.rb b/app/graphql/mutations/ci/pipeline_trigger/update.rb
new file mode 100644
index 00000000000..fa68593eb09
--- /dev/null
+++ b/app/graphql/mutations/ci/pipeline_trigger/update.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Ci
+ module PipelineTrigger
+ class Update < Base
+ graphql_name 'PipelineTriggerUpdate'
+
+ argument :description, GraphQL::Types::String,
+ required: true,
+ description: 'Description of the pipeline trigger token.'
+
+ field :pipeline_trigger, Types::Ci::PipelineTriggerType,
+ null: true,
+ description: 'Mutated pipeline trigger token.'
+
+ def resolve(id:, description:)
+ trigger = authorized_find!(id: id)
+
+ trigger.description = description
+
+ trigger.save
+
+ {
+ pipeline_trigger: trigger,
+ errors: trigger.errors.full_messages
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/concerns/mutations/validate_time_estimate.rb b/app/graphql/mutations/concerns/mutations/validate_time_estimate.rb
new file mode 100644
index 00000000000..82a56fd04f3
--- /dev/null
+++ b/app/graphql/mutations/concerns/mutations/validate_time_estimate.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Mutations
+ module ValidateTimeEstimate
+ private
+
+ def validate_time_estimate(time_estimate)
+ return unless time_estimate
+
+ parsed_time_estimate = Gitlab::TimeTrackingFormatter.parse(time_estimate, keep_zero: true)
+
+ if parsed_time_estimate.nil?
+ raise Gitlab::Graphql::Errors::ArgumentError,
+ 'timeEstimate must be formatted correctly, for example `1h 30m`'
+ elsif parsed_time_estimate < 0
+ raise Gitlab::Graphql::Errors::ArgumentError,
+ 'timeEstimate must be greater than or equal to zero. ' \
+ 'Remember that every new timeEstimate overwrites the previous value.'
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb
index f009abdba70..7aa78509bea 100644
--- a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb
+++ b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb
@@ -47,7 +47,7 @@ module Mutations
argument :award_emoji_widget,
::Types::WorkItems::Widgets::AwardEmojiUpdateInputType,
required: false,
- description: 'Input for award emoji widget.'
+ description: 'Input for emoji reactions widget.'
end
end
end
diff --git a/app/graphql/mutations/environments/create.rb b/app/graphql/mutations/environments/create.rb
index f18ce0eba97..76a5bf2f551 100644
--- a/app/graphql/mutations/environments/create.rb
+++ b/app/graphql/mutations/environments/create.rb
@@ -40,6 +40,11 @@ module Mutations
required: false,
description: 'Kubernetes namespace of the environment.'
+ argument :flux_resource_path,
+ GraphQL::Types::String,
+ required: false,
+ description: 'Flux resource path of the environment.'
+
field :environment,
Types::EnvironmentType,
null: true,
diff --git a/app/graphql/mutations/environments/update.rb b/app/graphql/mutations/environments/update.rb
index 07ab22685cc..44b9398c233 100644
--- a/app/graphql/mutations/environments/update.rb
+++ b/app/graphql/mutations/environments/update.rb
@@ -33,6 +33,11 @@ module Mutations
required: false,
description: 'Kubernetes namespace of the environment.'
+ argument :flux_resource_path,
+ GraphQL::Types::String,
+ required: false,
+ description: 'Flux resource path of the environment.'
+
field :environment,
Types::EnvironmentType,
null: true,
diff --git a/app/graphql/mutations/issues/update.rb b/app/graphql/mutations/issues/update.rb
index 2a863893cf1..35deb9e0af8 100644
--- a/app/graphql/mutations/issues/update.rb
+++ b/app/graphql/mutations/issues/update.rb
@@ -6,6 +6,7 @@ module Mutations
graphql_name 'UpdateIssue'
include CommonMutationArguments
+ include ValidateTimeEstimate
argument :title, GraphQL::Types::String,
required: false,
@@ -54,9 +55,7 @@ module Mutations
raise Gitlab::Graphql::Errors::ArgumentError, 'labelIds is mutually exclusive with any of addLabelIds or removeLabelIds'
end
- if !time_estimate.nil? && Gitlab::TimeTrackingFormatter.parse(time_estimate, keep_zero: true).nil?
- raise Gitlab::Graphql::Errors::ArgumentError, 'timeEstimate must be formatted correctly, for example `1h 30m`'
- end
+ validate_time_estimate(time_estimate)
super
end
diff --git a/app/graphql/mutations/merge_requests/update.rb b/app/graphql/mutations/merge_requests/update.rb
index da4db7342a3..470292df86c 100644
--- a/app/graphql/mutations/merge_requests/update.rb
+++ b/app/graphql/mutations/merge_requests/update.rb
@@ -5,6 +5,8 @@ module Mutations
class Update < Base
graphql_name 'MergeRequestUpdate'
+ include ValidateTimeEstimate
+
description 'Update attributes of a merge request'
argument :title, GraphQL::Types::String,
@@ -45,10 +47,7 @@ module Mutations
end
def ready?(time_estimate: nil, **args)
- if !time_estimate.nil? && Gitlab::TimeTrackingFormatter.parse(time_estimate, keep_zero: true).nil?
- raise Gitlab::Graphql::Errors::ArgumentError,
- 'timeEstimate must be formatted correctly, for example `1h 30m`'
- end
+ validate_time_estimate(time_estimate)
super
end
diff --git a/app/graphql/mutations/metrics/dashboard/annotations/create.rb b/app/graphql/mutations/metrics/dashboard/annotations/create.rb
index 296efa19bb7..59ddffe3aad 100644
--- a/app/graphql/mutations/metrics/dashboard/annotations/create.rb
+++ b/app/graphql/mutations/metrics/dashboard/annotations/create.rb
@@ -61,14 +61,10 @@ module Mutations
end
end
- def resolve(args)
- annotation_response = ::Metrics::Dashboard::Annotations::CreateService.new(context[:current_user], annotation_create_params(args)).execute
-
- annotation = annotation_response[:annotation]
-
+ def resolve(_args)
{
- annotation: annotation.valid? ? annotation : nil,
- errors: errors_on_object(annotation)
+ annotation: nil,
+ errors: []
}
end
diff --git a/app/graphql/mutations/metrics/dashboard/annotations/delete.rb b/app/graphql/mutations/metrics/dashboard/annotations/delete.rb
index 32047cda213..61fcf8e0b13 100644
--- a/app/graphql/mutations/metrics/dashboard/annotations/delete.rb
+++ b/app/graphql/mutations/metrics/dashboard/annotations/delete.rb
@@ -13,19 +13,11 @@ module Mutations
required: true,
description: 'Global ID of the annotation to delete.'
+ # rubocop:disable Lint/UnusedMethodArgument
def resolve(id:)
- raise_resource_not_available_error! if Feature.enabled?(:remove_monitor_metrics)
-
- annotation = authorized_find!(id: id)
-
- result = ::Metrics::Dashboard::Annotations::DeleteService.new(context[:current_user], annotation).execute
-
- errors = Array.wrap(result[:message])
-
- {
- errors: errors
- }
+ raise_resource_not_available_error!
end
+ # rubocop:enable Lint/UnusedMethodArgument
end
end
end
diff --git a/app/graphql/mutations/namespace/package_settings/update.rb b/app/graphql/mutations/namespace/package_settings/update.rb
index 96bee693a1e..4e71bed52c6 100644
--- a/app/graphql/mutations/namespace/package_settings/update.rb
+++ b/app/graphql/mutations/namespace/package_settings/update.rb
@@ -8,6 +8,8 @@ module Mutations
include Mutations::ResolvesNamespace
+ NUGET_DUPLICATES_FF_ERROR = '`nuget_duplicates_option` feature flag is disabled.'
+
description <<~DESC
These settings can be adjusted by the group Owner or Maintainer.
[Issue 370471](https://gitlab.com/gitlab-org/gitlab/-/issues/370471) proposes limiting
@@ -41,6 +43,16 @@ module Mutations
required: false,
description: copy_field_description(Types::Namespace::PackageSettingsType, :generic_duplicate_exception_regex)
+ argument :nuget_duplicates_allowed,
+ GraphQL::Types::Boolean,
+ required: false,
+ description: copy_field_description(Types::Namespace::PackageSettingsType, :nuget_duplicates_allowed)
+
+ argument :nuget_duplicate_exception_regex,
+ Types::UntrustedRegexp,
+ required: false,
+ description: copy_field_description(Types::Namespace::PackageSettingsType, :nuget_duplicate_exception_regex)
+
argument :maven_package_requests_forwarding,
GraphQL::Types::Boolean,
required: false,
@@ -79,6 +91,10 @@ module Mutations
def resolve(namespace_path:, **args)
namespace = authorized_find!(namespace_path: namespace_path)
+ if nuget_duplicate_settings_present?(args) && Feature.disabled?(:nuget_duplicates_option, namespace)
+ raise_resource_not_available_error! NUGET_DUPLICATES_FF_ERROR
+ end
+
result = ::Namespaces::PackageSettings::UpdateService
.new(container: namespace, current_user: current_user, params: args)
.execute
@@ -94,6 +110,10 @@ module Mutations
def find_object(namespace_path:)
resolve_namespace(full_path: namespace_path)
end
+
+ def nuget_duplicate_settings_present?(args)
+ args.key?(:nuget_duplicates_allowed) || args.key?(:nuget_duplicate_exception_regex)
+ end
end
end
end
diff --git a/app/graphql/mutations/work_items/create.rb b/app/graphql/mutations/work_items/create.rb
index 9f7b7b5db97..7ce508e5ef1 100644
--- a/app/graphql/mutations/work_items/create.rb
+++ b/app/graphql/mutations/work_items/create.rb
@@ -14,6 +14,7 @@ module Mutations
authorize :create_work_item
MUTUALLY_EXCLUSIVE_ARGUMENTS_ERROR = 'Please provide either projectPath or namespacePath argument, but not both.'
+ DISABLED_FF_ERROR = 'namespace_level_work_items feature flag is disabled. Only project paths allowed.'
argument :confidential, GraphQL::Types::Boolean,
required: false,
@@ -59,6 +60,7 @@ module Mutations
def resolve(project_path: nil, namespace_path: nil, **attributes)
container_path = project_path || namespace_path
container = authorized_find!(container_path)
+ check_feature_available!(container)
params = global_id_compatibility_params(attributes).merge(author_id: current_user.id)
type = ::WorkItems::Type.find(attributes[:work_item_type_id])
@@ -81,6 +83,12 @@ module Mutations
private
+ def check_feature_available!(container)
+ return unless container.is_a?(::Group) && Feature.disabled?(:namespace_level_work_items, container)
+
+ raise Gitlab::Graphql::Errors::ArgumentError, DISABLED_FF_ERROR
+ end
+
def global_id_compatibility_params(params)
params[:work_item_type_id] = params[:work_item_type_id]&.model_id
diff --git a/app/graphql/mutations/work_items/linked_items/add.rb b/app/graphql/mutations/work_items/linked_items/add.rb
new file mode 100644
index 00000000000..b346b074e85
--- /dev/null
+++ b/app/graphql/mutations/work_items/linked_items/add.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Mutations
+ module WorkItems
+ module LinkedItems
+ class Add < Base
+ graphql_name 'WorkItemAddLinkedItems'
+ description 'Add linked items to the work item.'
+
+ argument :link_type, ::Types::WorkItems::RelatedLinkTypeEnum,
+ required: false, description: 'Type of link. Defaults to `RELATED`.'
+
+ private
+
+ def update_links(work_item, params)
+ Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/419555')
+
+ gids = params.delete(:work_items_ids)
+ work_items = begin
+ GitlabSchema.parse_gids(gids, expected_type: ::WorkItem).map(&:find)
+ rescue ActiveRecord::RecordNotFound => e
+ raise Gitlab::Graphql::Errors::ArgumentError, e
+ end
+
+ ::WorkItems::RelatedWorkItemLinks::CreateService
+ .new(work_item, current_user, { target_issuable: work_items, link_type: params[:link_type] })
+ .execute
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/work_items/linked_items/base.rb b/app/graphql/mutations/work_items/linked_items/base.rb
new file mode 100644
index 00000000000..1d8d74b02ac
--- /dev/null
+++ b/app/graphql/mutations/work_items/linked_items/base.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module Mutations
+ module WorkItems
+ module LinkedItems
+ class Base < BaseMutation
+ # Limit maximum number of items that can be linked at a time to avoid overloading the DB
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/419555
+ MAX_WORK_ITEMS = 3
+
+ argument :id, ::Types::GlobalIDType[::WorkItem],
+ required: true, description: 'Global ID of the work item.'
+ argument :work_items_ids, [::Types::GlobalIDType[::WorkItem]],
+ required: true,
+ description: "Global IDs of the items to link. Maximum number of IDs you can provide: #{MAX_WORK_ITEMS}."
+
+ field :work_item, Types::WorkItemType,
+ null: true, description: 'Updated work item.'
+
+ field :message, GraphQL::Types::String,
+ null: true, description: 'Linked items update result message.'
+
+ authorize :read_work_item
+
+ def ready?(**args)
+ if args[:work_items_ids].size > MAX_WORK_ITEMS
+ raise Gitlab::Graphql::Errors::ArgumentError,
+ format(
+ _('No more than %{max_work_items} work items can be linked at the same time.'),
+ max_work_items: MAX_WORK_ITEMS
+ )
+ end
+
+ super
+ end
+
+ def resolve(**args)
+ work_item = authorized_find!(id: args.delete(:id))
+ raise_resource_not_available_error! unless work_item.project.linked_work_items_feature_flag_enabled?
+
+ service_response = update_links(work_item, args)
+
+ {
+ work_item: work_item,
+ errors: service_response[:status] == :error ? Array.wrap(service_response[:message]) : [],
+ message: service_response[:status] == :success ? service_response[:message] : ''
+ }
+ end
+
+ private
+
+ def update_links(work_item, params)
+ raise NotImplementedError
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/work_items/subscribe.rb b/app/graphql/mutations/work_items/subscribe.rb
new file mode 100644
index 00000000000..a29c3416c3d
--- /dev/null
+++ b/app/graphql/mutations/work_items/subscribe.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Mutations
+ module WorkItems
+ class Subscribe < BaseMutation
+ graphql_name 'WorkItemSubscribe'
+
+ argument :id, ::Types::GlobalIDType[::WorkItem],
+ required: true,
+ description: 'Global ID of the work item.'
+
+ argument :subscribed,
+ GraphQL::Types::Boolean,
+ required: true,
+ description: 'Desired state of the subscription.'
+
+ field :work_item, Types::WorkItemType,
+ null: true,
+ description: 'Work item after mutation.'
+
+ authorize :update_subscription
+
+ def resolve(args)
+ work_item = authorized_find!(id: args[:id])
+
+ update_subscription(work_item, args[:subscribed])
+
+ {
+ work_item: work_item,
+ errors: []
+ }
+ end
+
+ private
+
+ def update_subscription(work_item, subscribed_state)
+ work_item.set_subscription(current_user, subscribed_state, work_item.project)
+ end
+ end
+ end
+end
diff --git a/app/graphql/queries/repository/blob_info.query.graphql b/app/graphql/queries/repository/blob_info.query.graphql
index fd463436ed4..7419961a564 100644
--- a/app/graphql/queries/repository/blob_info.query.graphql
+++ b/app/graphql/queries/repository/blob_info.query.graphql
@@ -2,6 +2,7 @@ query getBlobInfo(
$projectPath: ID!
$filePath: String!
$ref: String!
+ $refType: RefType
$shouldFetchRawText: Boolean!
) {
project(fullPath: $projectPath) {
@@ -10,7 +11,7 @@ query getBlobInfo(
repository {
__typename
empty
- blobs(paths: [$filePath], ref: $ref) {
+ blobs(paths: [$filePath], ref: $ref, refType: $refType) {
__typename
nodes {
__typename
diff --git a/app/graphql/queries/repository/files.query.graphql b/app/graphql/queries/repository/files.query.graphql
index a83880ce696..bd7bb8ba787 100644
--- a/app/graphql/queries/repository/files.query.graphql
+++ b/app/graphql/queries/repository/files.query.graphql
@@ -19,6 +19,7 @@ query getFiles(
$projectPath: ID!
$path: String
$ref: String!
+ $refType: RefType
$pageSize: Int!
$nextPageCursor: String
) {
@@ -27,7 +28,7 @@ query getFiles(
__typename
repository {
__typename
- tree(path: $path, ref: $ref) {
+ tree(path: $path, ref: $ref, refType: $refType) {
__typename
trees(first: $pageSize, after: $nextPageCursor) {
__typename
diff --git a/app/graphql/queries/repository/paginated_tree.query.graphql b/app/graphql/queries/repository/paginated_tree.query.graphql
index e82bc6d0734..68aa90046b9 100644
--- a/app/graphql/queries/repository/paginated_tree.query.graphql
+++ b/app/graphql/queries/repository/paginated_tree.query.graphql
@@ -7,13 +7,19 @@ fragment TreeEntry on Entry {
type
}
-query getPaginatedTree($projectPath: ID!, $path: String, $ref: String!, $nextPageCursor: String) {
+query getPaginatedTree(
+ $projectPath: ID!
+ $path: String
+ $ref: String!
+ $nextPageCursor: String
+ $refType: RefType
+) {
project(fullPath: $projectPath) {
id
__typename
repository {
__typename
- paginatedTree(path: $path, ref: $ref, after: $nextPageCursor) {
+ paginatedTree(path: $path, ref: $ref, refType: $refType, after: $nextPageCursor) {
__typename
pageInfo {
__typename
diff --git a/app/graphql/queries/repository/path_last_commit.query.graphql b/app/graphql/queries/repository/path_last_commit.query.graphql
index facbf1555fc..738fdf534cb 100644
--- a/app/graphql/queries/repository/path_last_commit.query.graphql
+++ b/app/graphql/queries/repository/path_last_commit.query.graphql
@@ -1,10 +1,10 @@
-query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) {
+query pathLastCommit($projectPath: ID!, $path: String, $ref: String!, $refType: RefType) {
project(fullPath: $projectPath) {
__typename
id
repository {
__typename
- paginatedTree(path: $path, ref: $ref) {
+ paginatedTree(path: $path, ref: $ref, refType: $refType) {
__typename
nodes {
__typename
diff --git a/app/graphql/resolvers/abuse_report_labels_resolver.rb b/app/graphql/resolvers/abuse_report_labels_resolver.rb
new file mode 100644
index 00000000000..86cebe8e541
--- /dev/null
+++ b/app/graphql/resolvers/abuse_report_labels_resolver.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class AbuseReportLabelsResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ authorize :read_label
+
+ type Types::LabelType.connection_type, null: true
+
+ argument :search_term, GraphQL::Types::String,
+ required: false,
+ description: 'Search term to find labels with.'
+
+ def resolve(**args)
+ ::Admin::AbuseReportLabelsFinder.new(context[:current_user], args).execute
+ end
+ end
+end
diff --git a/app/graphql/resolvers/abuse_report_resolver.rb b/app/graphql/resolvers/abuse_report_resolver.rb
new file mode 100644
index 00000000000..770409601b9
--- /dev/null
+++ b/app/graphql/resolvers/abuse_report_resolver.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class AbuseReportResolver < BaseResolver
+ description 'Retrieve an abuse report'
+
+ type Types::AbuseReportType, null: true
+
+ argument :id, Types::GlobalIDType[AbuseReport], required: true, description: 'ID of the abuse report.'
+
+ def resolve(id:)
+ ::AbuseReport.find_by_id(extract_abuse_report_id(id))
+ end
+
+ private
+
+ def extract_abuse_report_id(gid)
+ GitlabSchema.parse_gid(gid, expected_type: ::AbuseReport).model_id
+ end
+ end
+end
diff --git a/app/graphql/resolvers/autocomplete_users_resolver.rb b/app/graphql/resolvers/autocomplete_users_resolver.rb
new file mode 100644
index 00000000000..40c53a46311
--- /dev/null
+++ b/app/graphql/resolvers/autocomplete_users_resolver.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class AutocompleteUsersResolver < BaseResolver
+ type [::Types::Users::AutocompletedUserType], null: true
+
+ argument :search, GraphQL::Types::String,
+ required: false,
+ description: 'Query to search users by name, username, or public email.'
+
+ def resolve(search: nil)
+ ::Autocomplete::UsersFinder.new(
+ current_user: context[:current_user],
+ project: project,
+ group: group,
+ params: {
+ search: search
+ }
+ ).execute
+ end
+
+ private
+
+ def project
+ object if object.is_a?(Project)
+ end
+
+ def group
+ object if object.is_a?(Group)
+ end
+ end
+end
diff --git a/app/graphql/resolvers/ci/pipeline_triggers_resolver.rb b/app/graphql/resolvers/ci/pipeline_triggers_resolver.rb
new file mode 100644
index 00000000000..3d6e3b3e75d
--- /dev/null
+++ b/app/graphql/resolvers/ci/pipeline_triggers_resolver.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Ci
+ class PipelineTriggersResolver < BaseResolver
+ include LooksAhead
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ authorize :admin_build
+ type Types::Ci::PipelineTriggerType.connection_type, null: false
+
+ def resolve_with_lookahead
+ apply_lookahead(object.triggers)
+ end
+
+ private
+
+ def unconditional_includes
+ [:trigger_requests]
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/concerns/resolves_merge_requests.rb b/app/graphql/resolvers/concerns/resolves_merge_requests.rb
index c0a068097a7..e9e7ea9f77f 100644
--- a/app/graphql/resolvers/concerns/resolves_merge_requests.rb
+++ b/app/graphql/resolvers/concerns/resolves_merge_requests.rb
@@ -59,7 +59,8 @@ module ResolvesMergeRequests
timelogs: [:timelogs],
pipelines: [:merge_request_diffs], # used by `recent_diff_head_shas` to load pipelines
committers: [merge_request_diff: [:merge_request_diff_commits]],
- suggested_reviewers: [:predictions]
+ suggested_reviewers: [:predictions],
+ diff_stats: [latest_merge_request_diff: [:merge_request_diff_commits]]
}
end
end
diff --git a/app/graphql/resolvers/concerns/work_items/look_ahead_preloads.rb b/app/graphql/resolvers/concerns/work_items/look_ahead_preloads.rb
new file mode 100644
index 00000000000..92fb9ec5cef
--- /dev/null
+++ b/app/graphql/resolvers/concerns/work_items/look_ahead_preloads.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module LookAheadPreloads
+ extend ActiveSupport::Concern
+
+ prepended do
+ include ::LooksAhead
+ end
+
+ private
+
+ def preloads
+ {
+ work_item_type: :work_item_type,
+ web_url: { namespace: :route, project: [:project_namespace, { namespace: :route }] },
+ widgets: { work_item_type: :enabled_widget_definitions }
+ }
+ end
+
+ def nested_preloads
+ {
+ widgets: widget_preloads,
+ user_permissions: { update_work_item: :assignees },
+ project: { jira_import_status: { project: :jira_imports } },
+ author: {
+ location: { author: :user_detail },
+ gitpod_enabled: { author: :user_preference }
+ }
+ }
+ end
+
+ def widget_preloads
+ {
+ last_edited_by: :last_edited_by,
+ assignees: :assignees,
+ parent: :work_item_parent,
+ children: { work_item_children_by_relative_position: [:author, { project: :project_feature }] },
+ labels: :labels,
+ milestone: { milestone: [:project, :group] },
+ subscribed: [:assignees, :award_emoji, { notes: [:author, :award_emoji] }],
+ award_emoji: { award_emoji: :awardable }
+ }
+ end
+
+ def unconditional_includes
+ [
+ {
+ project: [:project_feature, :group]
+ },
+ :author
+ ]
+ end
+ end
+end
+
+WorkItems::LookAheadPreloads.prepend_mod
diff --git a/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb b/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb
index aad9bbebafb..b967460c7ff 100644
--- a/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb
+++ b/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb
@@ -16,11 +16,10 @@ module Resolvers
alias_method :dashboard, :object
- def resolve(**args)
+ def resolve(**_args)
return if Feature.enabled?(:remove_monitor_metrics)
- return [] unless dashboard
- ::Metrics::Dashboards::AnnotationsFinder.new(dashboard: dashboard, params: args).execute
+ []
end
end
end
diff --git a/app/graphql/resolvers/namespaces/work_items_resolver.rb b/app/graphql/resolvers/namespaces/work_items_resolver.rb
new file mode 100644
index 00000000000..54bb8392071
--- /dev/null
+++ b/app/graphql/resolvers/namespaces/work_items_resolver.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Namespaces
+ class WorkItemsResolver < BaseResolver
+ prepend ::WorkItems::LookAheadPreloads
+
+ type Types::WorkItemType.connection_type, null: true
+
+ def resolve_with_lookahead(**args)
+ return unless Feature.enabled?(:namespace_level_work_items, resource_parent)
+ return WorkItem.none if resource_parent.nil?
+
+ finder = ::WorkItems::NamespaceWorkItemsFinder.new(current_user, args.merge(
+ namespace_id: resource_parent
+ ))
+
+ Gitlab::Graphql::Loaders::IssuableLoader.new(resource_parent, finder).batching_find_all do |q|
+ apply_lookahead(q)
+ end
+ end
+
+ private
+
+ 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.
+ object.respond_to?(:sync) ? object.sync : object
+ end
+ strong_memoize_attr :resource_parent
+ end
+ end
+end
diff --git a/app/graphql/resolvers/work_items/linked_items_resolver.rb b/app/graphql/resolvers/work_items/linked_items_resolver.rb
new file mode 100644
index 00000000000..9c71cd7c0c9
--- /dev/null
+++ b/app/graphql/resolvers/work_items/linked_items_resolver.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module WorkItems
+ class LinkedItemsResolver < BaseResolver
+ alias_method :linked_items_widget, :object
+
+ type Types::WorkItems::LinkedItemType.connection_type, null: true
+
+ def resolve
+ related_work_items.map do |related_work_item|
+ {
+ link_id: related_work_item.issue_link_id,
+ link_type: related_work_item.issue_link_type,
+ link_created_at: related_work_item.issue_link_created_at,
+ link_updated_at: related_work_item.issue_link_updated_at,
+ work_item: related_work_item
+ }
+ end
+ end
+
+ private
+
+ def related_work_items
+ return [] unless work_item.project.linked_work_items_feature_flag_enabled?
+
+ work_item.related_issues(current_user, preload: { project: [:project_feature, :group] })
+ end
+
+ def work_item
+ linked_items_widget.work_item
+ end
+ strong_memoize_attr :work_item
+ end
+ end
+end
diff --git a/app/graphql/resolvers/work_items_resolver.rb b/app/graphql/resolvers/work_items_resolver.rb
index 14eec4f696a..d4f73361e05 100644
--- a/app/graphql/resolvers/work_items_resolver.rb
+++ b/app/graphql/resolvers/work_items_resolver.rb
@@ -2,8 +2,8 @@
module Resolvers
class WorkItemsResolver < BaseResolver
+ prepend ::WorkItems::LookAheadPreloads
include SearchArguments
- include LooksAhead
include ::WorkItems::SharedFilterArguments
argument :iid,
@@ -28,48 +28,6 @@ module Resolvers
private
- def preloads
- {
- work_item_type: :work_item_type,
- web_url: { namespace: :route, project: [:project_namespace, { namespace: :route }] },
- widgets: { work_item_type: :enabled_widget_definitions }
- }
- end
-
- def nested_preloads
- {
- widgets: widget_preloads,
- user_permissions: { update_work_item: :assignees },
- project: { jira_import_status: { project: :jira_imports } },
- author: {
- location: { author: :user_detail },
- gitpod_enabled: { author: :user_preference }
- }
- }
- end
-
- def widget_preloads
- {
- last_edited_by: :last_edited_by,
- assignees: :assignees,
- parent: :work_item_parent,
- children: { work_item_children_by_relative_position: [:author, { project: :project_feature }] },
- labels: :labels,
- milestone: { milestone: [:project, :group] },
- subscribed: [:assignees, :award_emoji, { notes: [:author, :award_emoji] }],
- award_emoji: { award_emoji: :awardable }
- }
- end
-
- def unconditional_includes
- [
- {
- project: [:project_feature, :group]
- },
- :author
- ]
- end
-
def prepare_finder_params(args)
params = super(args)
params[:iids] ||= [params.delete(:iid)].compact if params[:iid]
@@ -88,4 +46,4 @@ module Resolvers
end
end
-Resolvers::WorkItemsResolver.prepend_mod_with('Resolvers::WorkItemsResolver')
+Resolvers::WorkItemsResolver.prepend_mod
diff --git a/app/graphql/types/abuse_report_type.rb b/app/graphql/types/abuse_report_type.rb
new file mode 100644
index 00000000000..012e709cdb5
--- /dev/null
+++ b/app/graphql/types/abuse_report_type.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Types
+ class AbuseReportType < BaseObject
+ graphql_name 'AbuseReport'
+ description 'An abuse report'
+ authorize :read_abuse_report
+
+ field :labels, ::Types::LabelType.connection_type,
+ null: true, description: 'Labels of the abuse report.'
+ end
+end
diff --git a/app/graphql/types/access_levels/deploy_key_type.rb b/app/graphql/types/access_levels/deploy_key_type.rb
new file mode 100644
index 00000000000..e4e90619891
--- /dev/null
+++ b/app/graphql/types/access_levels/deploy_key_type.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Types
+ module AccessLevels
+ class DeployKeyType < BaseObject
+ graphql_name 'AccessLevelDeployKey'
+ description 'Representation of a GitLab deploy key.'
+
+ authorize :read_deploy_key
+
+ field :id,
+ type: GraphQL::Types::ID,
+ null: false,
+ description: 'ID of the deploy key.'
+
+ field :title,
+ type: GraphQL::Types::String,
+ null: false,
+ description: 'Title of the deploy key.'
+
+ field :expires_at,
+ type: Types::DateType,
+ null: true,
+ description: 'Expiration date of the deploy key.'
+
+ field :user,
+ type: Types::AccessLevels::UserType,
+ null: false,
+ description: 'User assigned to the deploy key.'
+ end
+ end
+end
diff --git a/app/graphql/types/access_levels/user_type.rb b/app/graphql/types/access_levels/user_type.rb
new file mode 100644
index 00000000000..82aba673250
--- /dev/null
+++ b/app/graphql/types/access_levels/user_type.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+module Types
+ module AccessLevels
+ class UserType < BaseObject
+ graphql_name 'AccessLevelUser'
+ description 'Representation of a GitLab user.'
+
+ authorize :read_user
+
+ present_using UserPresenter
+
+ field :id,
+ type: GraphQL::Types::ID,
+ null: false,
+ description: 'ID of the user.'
+
+ field :username,
+ type: GraphQL::Types::String,
+ null: false,
+ description: 'Username of the user.'
+
+ field :name,
+ type: GraphQL::Types::String,
+ null: false,
+ resolver_method: :redacted_name,
+ description: <<~DESC
+ Human-readable name of the user.
+ Returns `****` if the user is a project bot and the requester does not have permission to view the project.
+ DESC
+
+ field :public_email,
+ type: GraphQL::Types::String,
+ null: true,
+ description: "User's public email."
+
+ field :avatar_url,
+ type: GraphQL::Types::String,
+ null: true,
+ description: "URL of the user's avatar."
+
+ field :web_url,
+ type: GraphQL::Types::String,
+ null: false,
+ description: 'Web URL of the user.'
+
+ field :web_path,
+ type: GraphQL::Types::String,
+ null: false,
+ description: 'Web path of the user.'
+
+ def redacted_name
+ object.redacted_name(context[:current_user])
+ end
+
+ def avatar_url
+ object.avatar_url(only_path: false)
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/achievements/achievement_type.rb b/app/graphql/types/achievements/achievement_type.rb
index ec558981465..d733bf39a51 100644
--- a/app/graphql/types/achievements/achievement_type.rb
+++ b/app/graphql/types/achievements/achievement_type.rb
@@ -5,6 +5,8 @@ module Types
class AchievementType < BaseObject
graphql_name 'Achievement'
+ connection_type_class Types::CountableConnectionType
+
authorize :read_achievement
field :id,
diff --git a/app/graphql/types/achievements/user_achievement_type.rb b/app/graphql/types/achievements/user_achievement_type.rb
index bf161d2f1e5..7cdcb66576c 100644
--- a/app/graphql/types/achievements/user_achievement_type.rb
+++ b/app/graphql/types/achievements/user_achievement_type.rb
@@ -5,6 +5,8 @@ module Types
class UserAchievementType < BaseObject
graphql_name 'UserAchievement'
+ connection_type_class Types::CountableConnectionType
+
authorize :read_user_achievement
field :id,
diff --git a/app/graphql/types/alert_management/alert_type.rb b/app/graphql/types/alert_management/alert_type.rb
index c17406b3e56..e85d0213613 100644
--- a/app/graphql/types/alert_management/alert_type.rb
+++ b/app/graphql/types/alert_management/alert_type.rb
@@ -8,8 +8,8 @@ module Types
present_using ::AlertManagement::AlertPresenter
- implements(Types::Notes::NoteableInterface)
- implements(Types::TodoableInterface)
+ implements Types::Notes::NoteableInterface
+ implements Types::TodoableInterface
authorize :read_alert_management_alert
@@ -111,6 +111,12 @@ module Types
null: true,
description: 'Assignees of the alert.'
+ field :metrics_dashboard_url,
+ GraphQL::Types::String,
+ null: true,
+ description: 'URL for metrics embed for the alert.',
+ deprecated: { reason: 'Returns no data. Underlying feature was removed in 16.0',
+ milestone: '16.0' }
field :runbook,
GraphQL::Types::String,
null: true,
@@ -136,6 +142,10 @@ module Types
method: :details_url,
null: false,
description: 'URL of the alert.'
+
+ def metrics_dashboard_url
+ nil
+ end
end
end
end
diff --git a/app/graphql/types/alert_management/http_integration_type.rb b/app/graphql/types/alert_management/http_integration_type.rb
index bba9cb1bbfc..7c026be86a3 100644
--- a/app/graphql/types/alert_management/http_integration_type.rb
+++ b/app/graphql/types/alert_management/http_integration_type.rb
@@ -6,7 +6,7 @@ module Types
graphql_name 'AlertManagementHttpIntegration'
description 'An endpoint and credentials used to accept alerts for a project'
- implements(Types::AlertManagement::IntegrationType)
+ implements Types::AlertManagement::IntegrationType
authorize :admin_operations
diff --git a/app/graphql/types/alert_management/prometheus_integration_type.rb b/app/graphql/types/alert_management/prometheus_integration_type.rb
index 9a2ef78eca7..0f61eeaa177 100644
--- a/app/graphql/types/alert_management/prometheus_integration_type.rb
+++ b/app/graphql/types/alert_management/prometheus_integration_type.rb
@@ -8,7 +8,7 @@ module Types
include ::Gitlab::Routing
- implements(Types::AlertManagement::IntegrationType)
+ implements Types::AlertManagement::IntegrationType
authorize :admin_project
diff --git a/app/graphql/types/branch_protections/push_access_level_type.rb b/app/graphql/types/branch_protections/push_access_level_type.rb
index c5e21fad88d..2a66f1a4ec5 100644
--- a/app/graphql/types/branch_protections/push_access_level_type.rb
+++ b/app/graphql/types/branch_protections/push_access_level_type.rb
@@ -6,6 +6,11 @@ module Types
graphql_name 'PushAccessLevel'
description 'Defines which user roles, users, or groups can push to a protected branch.'
accepts ::ProtectedBranch::PushAccessLevel
+
+ field :deploy_key,
+ Types::AccessLevels::DeployKeyType,
+ null: true,
+ description: 'Deploy key assigned to the access level.'
end
end
end
diff --git a/app/graphql/types/ci/ci_cd_setting_type.rb b/app/graphql/types/ci/ci_cd_setting_type.rb
index f7ef94f58c0..45ecbf5c084 100644
--- a/app/graphql/types/ci/ci_cd_setting_type.rb
+++ b/app/graphql/types/ci/ci_cd_setting_type.rb
@@ -7,32 +7,37 @@ module Types
authorize :admin_project
- field :job_token_scope_enabled,
- GraphQL::Types::Boolean,
- null: true,
- description: 'Indicates CI/CD job tokens generated in this project ' \
- 'have restricted access to other projects.',
- method: :job_token_scope_enabled?
-
field :inbound_job_token_scope_enabled,
- GraphQL::Types::Boolean,
- null: true,
- description: 'Indicates CI/CD job tokens generated in other projects ' \
- 'have restricted access to this project.',
- method: :inbound_job_token_scope_enabled?
-
- field :keep_latest_artifact, GraphQL::Types::Boolean, null: true,
- description: 'Whether to keep the latest builds artifacts.',
- method: :keep_latest_artifacts_available?
- field :merge_pipelines_enabled, GraphQL::Types::Boolean, null: true,
- description: 'Whether merge pipelines are enabled.',
- method: :merge_pipelines_enabled?
- field :merge_trains_enabled, GraphQL::Types::Boolean, null: true,
- description: 'Whether merge trains are enabled.',
- method: :merge_trains_enabled?
-
- field :project, Types::ProjectType, null: true,
- description: 'Project the CI/CD settings belong to.'
+ GraphQL::Types::Boolean,
+ null: true,
+ description: 'Indicates CI/CD job tokens generated in other projects ' \
+ 'have restricted access to this project.',
+ method: :inbound_job_token_scope_enabled?
+ field :job_token_scope_enabled,
+ GraphQL::Types::Boolean,
+ null: true,
+ description: 'Indicates CI/CD job tokens generated in this project ' \
+ 'have restricted access to other projects.',
+ method: :job_token_scope_enabled?
+ field :keep_latest_artifact,
+ GraphQL::Types::Boolean,
+ null: true,
+ description: 'Whether to keep the latest builds artifacts.',
+ method: :keep_latest_artifacts_available?
+ field :merge_pipelines_enabled,
+ GraphQL::Types::Boolean,
+ null: true,
+ description: 'Whether merge pipelines are enabled.',
+ method: :merge_pipelines_enabled?
+ field :merge_trains_enabled,
+ GraphQL::Types::Boolean,
+ null: true,
+ description: 'Whether merge trains are enabled.',
+ method: :merge_trains_enabled?
+ field :project,
+ Types::ProjectType,
+ null: true,
+ description: 'Project the CI/CD settings belong to.'
end
end
end
diff --git a/app/graphql/types/ci/detailed_status_type.rb b/app/graphql/types/ci/detailed_status_type.rb
index 8bc50e974bb..e18770c2708 100644
--- a/app/graphql/types/ci/detailed_status_type.rb
+++ b/app/graphql/types/ci/detailed_status_type.rb
@@ -39,17 +39,15 @@ module Types
end
def action
- if object.has_action?
- {
- button_title: object.action_button_title,
- icon: object.action_icon,
- method: object.action_method,
- path: object.action_path,
- title: object.action_title
- }
- else
- nil
- end
+ return unless object.has_action?
+
+ {
+ button_title: object.action_button_title,
+ icon: object.action_icon,
+ method: object.action_method,
+ path: object.action_path,
+ title: object.action_title
+ }
end
end
# rubocop: enable Graphql/AuthorizeTypes
diff --git a/app/graphql/types/ci/group_environment_scope_type.rb b/app/graphql/types/ci/group_environment_scope_type.rb
index 3a3a5a3f59f..0dd0ad963ac 100644
--- a/app/graphql/types/ci/group_environment_scope_type.rb
+++ b/app/graphql/types/ci/group_environment_scope_type.rb
@@ -7,7 +7,7 @@ module Types
graphql_name 'CiGroupEnvironmentScope'
description 'Ci/CD environment scope for a group.'
- connection_type_class(Types::Ci::GroupEnvironmentScopeConnectionType)
+ connection_type_class Types::Ci::GroupEnvironmentScopeConnectionType
field :name, GraphQL::Types::String,
null: true,
diff --git a/app/graphql/types/ci/group_variable_type.rb b/app/graphql/types/ci/group_variable_type.rb
index 7e2afba0d53..7be9b3df0b8 100644
--- a/app/graphql/types/ci/group_variable_type.rb
+++ b/app/graphql/types/ci/group_variable_type.rb
@@ -7,8 +7,8 @@ module Types
graphql_name 'CiGroupVariable'
description 'CI/CD variables for a group.'
- connection_type_class(Types::Ci::GroupVariableConnectionType)
- implements(VariableInterface)
+ connection_type_class Types::Ci::GroupVariableConnectionType
+ implements VariableInterface
field :environment_scope, GraphQL::Types::String,
null: true,
diff --git a/app/graphql/types/ci/instance_variable_type.rb b/app/graphql/types/ci/instance_variable_type.rb
index 7ffc52deb73..e3230556769 100644
--- a/app/graphql/types/ci/instance_variable_type.rb
+++ b/app/graphql/types/ci/instance_variable_type.rb
@@ -7,7 +7,7 @@ module Types
graphql_name 'CiInstanceVariable'
description 'CI/CD variables for a GitLab instance.'
- implements(VariableInterface)
+ implements VariableInterface
field :id, GraphQL::Types::ID,
null: false,
diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb
index 02b10f3e4bd..22eb32993c5 100644
--- a/app/graphql/types/ci/job_type.rb
+++ b/app/graphql/types/ci/job_type.rb
@@ -9,7 +9,7 @@ module Types
present_using ::Ci::BuildPresenter
- connection_type_class(Types::LimitedCountableConnectionType)
+ connection_type_class Types::LimitedCountableConnectionType
expose_permissions Types::PermissionTypes::Ci::Job
diff --git a/app/graphql/types/ci/manual_variable_type.rb b/app/graphql/types/ci/manual_variable_type.rb
index ed92a6645b4..dcdaa3a6b19 100644
--- a/app/graphql/types/ci/manual_variable_type.rb
+++ b/app/graphql/types/ci/manual_variable_type.rb
@@ -7,7 +7,7 @@ module Types
graphql_name 'CiManualVariable'
description 'CI/CD variables given to a manual job.'
- implements(VariableInterface)
+ implements VariableInterface
field :environment_scope, GraphQL::Types::String,
null: true,
diff --git a/app/graphql/types/ci/pipeline_schedule_type.rb b/app/graphql/types/ci/pipeline_schedule_type.rb
index 904fa3f1c72..71a1f28ea38 100644
--- a/app/graphql/types/ci/pipeline_schedule_type.rb
+++ b/app/graphql/types/ci/pipeline_schedule_type.rb
@@ -7,7 +7,7 @@ module Types
description 'Represents a pipeline schedule'
- connection_type_class(Types::CountableConnectionType)
+ connection_type_class Types::CountableConnectionType
expose_permissions Types::PermissionTypes::Ci::PipelineSchedules
diff --git a/app/graphql/types/ci/pipeline_schedule_variable_type.rb b/app/graphql/types/ci/pipeline_schedule_variable_type.rb
index 1cb407bc2e4..f9c18d6f7df 100644
--- a/app/graphql/types/ci/pipeline_schedule_variable_type.rb
+++ b/app/graphql/types/ci/pipeline_schedule_variable_type.rb
@@ -7,7 +7,7 @@ module Types
authorize :read_pipeline_schedule_variables
- implements(VariableInterface)
+ implements VariableInterface
end
end
end
diff --git a/app/graphql/types/ci/pipeline_trigger_type.rb b/app/graphql/types/ci/pipeline_trigger_type.rb
new file mode 100644
index 00000000000..81345c14ba0
--- /dev/null
+++ b/app/graphql/types/ci/pipeline_trigger_type.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ class PipelineTriggerType < BaseObject
+ graphql_name 'PipelineTrigger'
+
+ present_using ::Ci::TriggerPresenter
+ connection_type_class Types::CountableConnectionType
+
+ authorize :admin_build
+
+ field :can_access_project, GraphQL::Types::Boolean,
+ null: false,
+ description: 'Indicates if the pipeline trigger token has access to the project.',
+ method: :can_access_project?
+
+ field :description, GraphQL::Types::String,
+ null: true,
+ description: 'Description of the pipeline trigger token.'
+
+ field :has_token_exposed, GraphQL::Types::Boolean,
+ null: false,
+ description: 'Indicates if the token is exposed.',
+ method: :has_token_exposed?
+
+ field :id, GraphQL::Types::ID,
+ null: false,
+ description: 'ID of the pipeline trigger token.'
+
+ field :last_used, Types::TimeType,
+ null: true,
+ description: 'Timestamp of the last usage of the pipeline trigger token.'
+
+ field :owner, Types::UserType,
+ null: false,
+ description: 'Owner of the pipeline trigger token.'
+
+ field :token, GraphQL::Types::String,
+ null: false,
+ description: 'Value of the pipeline trigger token.'
+ end
+ end
+end
diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb
index 19d261853a7..ba638d4bc47 100644
--- a/app/graphql/types/ci/pipeline_type.rb
+++ b/app/graphql/types/ci/pipeline_type.rb
@@ -5,7 +5,7 @@ module Types
class PipelineType < BaseObject
graphql_name 'Pipeline'
- connection_type_class(Types::CountableConnectionType)
+ connection_type_class Types::CountableConnectionType
authorize :read_pipeline
present_using ::Ci::PipelinePresenter
diff --git a/app/graphql/types/ci/project_variable_type.rb b/app/graphql/types/ci/project_variable_type.rb
index a9679000511..cd069be3320 100644
--- a/app/graphql/types/ci/project_variable_type.rb
+++ b/app/graphql/types/ci/project_variable_type.rb
@@ -7,8 +7,8 @@ module Types
graphql_name 'CiProjectVariable'
description 'CI/CD variables for a project.'
- connection_type_class(Types::Ci::ProjectVariableConnectionType)
- implements(VariableInterface)
+ connection_type_class Types::Ci::ProjectVariableConnectionType
+ implements VariableInterface
field :environment_scope, GraphQL::Types::String,
null: true,
diff --git a/app/graphql/types/ci/recent_failures_type.rb b/app/graphql/types/ci/recent_failures_type.rb
index 0892cb2735c..37850c62658 100644
--- a/app/graphql/types/ci/recent_failures_type.rb
+++ b/app/graphql/types/ci/recent_failures_type.rb
@@ -7,7 +7,7 @@ module Types
graphql_name 'RecentFailures'
description 'Recent failure history of a test case.'
- connection_type_class(Types::CountableConnectionType)
+ connection_type_class Types::CountableConnectionType
field :count, GraphQL::Types::Int, null: true,
description: 'Number of times the test case has failed in the past 14 days.'
diff --git a/app/graphql/types/ci/runner_manager_type.rb b/app/graphql/types/ci/runner_manager_type.rb
index 9c89b6537ea..9311836cf27 100644
--- a/app/graphql/types/ci/runner_manager_type.rb
+++ b/app/graphql/types/ci/runner_manager_type.rb
@@ -5,7 +5,7 @@ module Types
class RunnerManagerType < BaseObject
graphql_name 'CiRunnerManager'
- connection_type_class(::Types::CountableConnectionType)
+ connection_type_class ::Types::CountableConnectionType
authorize :read_runner_manager
@@ -26,6 +26,11 @@ module Types
description: 'ID of the runner manager.'
field :ip_address, GraphQL::Types::String, null: true,
description: 'IP address of the runner manager.'
+ field :job_execution_status,
+ Types::Ci::RunnerJobExecutionStatusEnum,
+ null: true,
+ description: 'Job execution status of the runner manager.',
+ alpha: { milestone: '16.3' }
field :platform_name, GraphQL::Types::String, null: true,
description: 'Platform provided by the runner manager.',
method: :platform
@@ -44,6 +49,16 @@ module Types
def executor_name
::Ci::Runner::EXECUTOR_TYPE_TO_NAMES[runner_manager.executor_type&.to_sym]
end
+
+ def job_execution_status
+ BatchLoader::GraphQL.for(runner_manager.id).batch(key: :running_builds_exist) do |runner_manager_ids, loader|
+ statuses = ::Ci::RunnerManager.id_in(runner_manager_ids).with_running_builds.index_by(&:id)
+
+ runner_manager_ids.each do |runner_manager_id|
+ loader.call(runner_manager_id, statuses[runner_manager_id] ? :running : :idle)
+ end
+ end
+ end
end
end
end
diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb
index 2baf64ca663..c9f92c05975 100644
--- a/app/graphql/types/ci/runner_type.rb
+++ b/app/graphql/types/ci/runner_type.rb
@@ -6,7 +6,7 @@ module Types
graphql_name 'CiRunner'
edge_type_class(RunnerWebUrlEdge)
- connection_type_class(RunnerCountableConnectionType)
+ connection_type_class RunnerCountableConnectionType
authorize :read_runner
present_using ::Ci::RunnerPresenter
@@ -59,7 +59,9 @@ module Types
deprecated: { reason: "Use field in `manager` object instead", milestone: '16.2' },
description: 'IP address of the runner.'
field :job_count, GraphQL::Types::Int, null: true,
- description: "Number of jobs processed by the runner (limited to #{JOB_COUNT_LIMIT}, plus one to indicate that more items exist).",
+ description: "Number of jobs processed by the runner (limited to #{JOB_COUNT_LIMIT}, plus one to " \
+ "indicate that more items exist).\n`jobCount` is an optimized version of `jobs { count }`, " \
+ "and can be requested for multiple runners on the same request.",
resolver: ::Resolvers::Ci::RunnerJobCountResolver
field :job_execution_status,
Types::Ci::RunnerJobExecutionStatusEnum,
@@ -76,7 +78,6 @@ module Types
description: 'Runner\'s maintenance notes.'
field :managers, ::Types::Ci::RunnerManagerType.connection_type, null: true,
description: 'Machines associated with the runner configuration.',
- method: :runner_managers,
alpha: { milestone: '15.10' }
field :maximum_timeout, GraphQL::Types::Int, null: true,
description: 'Maximum timeout (in seconds) for jobs processed by the runner.'
@@ -173,6 +174,18 @@ module Types
end
end
+ def managers
+ BatchLoader::GraphQL.for(runner.id).batch(key: :runner_managers) do |runner_ids, loader|
+ runner_managers_by_runner_id =
+ ::Ci::RunnerManager.for_runner(runner_ids).order_id_desc.group_by(&:runner_id)
+
+ runner_ids.each do |runner_id|
+ runner_managers = Array.wrap(runner_managers_by_runner_id[runner_id])
+ loader.call(runner_id, runner_managers)
+ end
+ end
+ end
+
def job_execution_status
BatchLoader::GraphQL.for(runner.id).batch(key: :running_builds_exist) do |runner_ids, loader|
statuses = ::Ci::Runner.id_in(runner_ids).with_running_builds.index_by(&:id)
diff --git a/app/graphql/types/ci/test_case_type.rb b/app/graphql/types/ci/test_case_type.rb
index f88923215eb..78c70fbcc7c 100644
--- a/app/graphql/types/ci/test_case_type.rb
+++ b/app/graphql/types/ci/test_case_type.rb
@@ -7,7 +7,7 @@ module Types
graphql_name 'TestCase'
description 'Test case in pipeline test report.'
- connection_type_class(Types::CountableConnectionType)
+ connection_type_class Types::CountableConnectionType
field :status,
Types::Ci::TestCaseStatusEnum,
diff --git a/app/graphql/types/ci/test_suite_summary_type.rb b/app/graphql/types/ci/test_suite_summary_type.rb
index 8801501c8d4..a98c47c6a30 100644
--- a/app/graphql/types/ci/test_suite_summary_type.rb
+++ b/app/graphql/types/ci/test_suite_summary_type.rb
@@ -7,7 +7,7 @@ module Types
graphql_name 'TestSuiteSummary'
description 'Test suite summary in a pipeline test report.'
- connection_type_class(Types::CountableConnectionType)
+ connection_type_class Types::CountableConnectionType
field :name, GraphQL::Types::String, null: true,
description: 'Name of the test suite.'
diff --git a/app/graphql/types/ci/test_suite_type.rb b/app/graphql/types/ci/test_suite_type.rb
index 8845338ed6d..a5cc6282abc 100644
--- a/app/graphql/types/ci/test_suite_type.rb
+++ b/app/graphql/types/ci/test_suite_type.rb
@@ -7,7 +7,7 @@ module Types
graphql_name 'TestSuite'
description 'Test suite in a pipeline test report.'
- connection_type_class(Types::CountableConnectionType)
+ connection_type_class Types::CountableConnectionType
field :name, GraphQL::Types::String, null: true,
description: 'Name of the test suite.'
diff --git a/app/graphql/types/clusters/agent_activity_event_type.rb b/app/graphql/types/clusters/agent_activity_event_type.rb
index 1d0ec7c4959..8c7dfa9c10a 100644
--- a/app/graphql/types/clusters/agent_activity_event_type.rb
+++ b/app/graphql/types/clusters/agent_activity_event_type.rb
@@ -7,7 +7,7 @@ module Types
authorize :read_cluster_agent
- connection_type_class(Types::CountableConnectionType)
+ connection_type_class Types::CountableConnectionType
field :recorded_at,
Types::TimeType,
diff --git a/app/graphql/types/clusters/agent_token_type.rb b/app/graphql/types/clusters/agent_token_type.rb
index 720ee2f685b..260c634e12e 100644
--- a/app/graphql/types/clusters/agent_token_type.rb
+++ b/app/graphql/types/clusters/agent_token_type.rb
@@ -7,7 +7,7 @@ module Types
authorize :read_cluster_agent
- connection_type_class(Types::CountableConnectionType)
+ connection_type_class Types::CountableConnectionType
field :cluster_agent,
Types::Clusters::AgentType,
diff --git a/app/graphql/types/clusters/agent_type.rb b/app/graphql/types/clusters/agent_type.rb
index 317a1aab628..c0989796141 100644
--- a/app/graphql/types/clusters/agent_type.rb
+++ b/app/graphql/types/clusters/agent_type.rb
@@ -7,7 +7,7 @@ module Types
authorize :read_cluster_agent
- connection_type_class(Types::CountableConnectionType)
+ connection_type_class Types::CountableConnectionType
field :created_at,
Types::TimeType,
diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb
index 5dd862c7388..9f83e955f4c 100644
--- a/app/graphql/types/commit_type.rb
+++ b/app/graphql/types/commit_type.rb
@@ -8,7 +8,7 @@ module Types
present_using CommitPresenter
- implements(Types::TodoableInterface)
+ implements Types::TodoableInterface
field :id, type: GraphQL::Types::ID, null: false,
description: 'ID (global ID) of the commit.'
@@ -34,6 +34,9 @@ module Types
field :authored_date, type: Types::TimeType, null: true,
description: 'Timestamp of when the commit was authored.'
+ field :committed_date, type: Types::TimeType, null: true,
+ description: 'Timestamp of when the commit was committed.'
+
field :web_url, type: GraphQL::Types::String, null: false,
description: 'Web URL of the commit.'
@@ -55,10 +58,24 @@ module Types
field :author_name, type: GraphQL::Types::String, null: true,
description: 'Commit authors name.'
+ field :committer_email, type: GraphQL::Types::String, null: true,
+ description: "Email of the committer."
+
+ field :committer_name, type: GraphQL::Types::String, null: true,
+ description: "Name of the committer."
+
# models/commit lazy loads the author by email
field :author, type: Types::UserType, null: true,
description: 'Author of the commit.'
+ field :diffs, [Types::DiffType], null: true, calls_gitaly: true,
+ description: 'Diffs contained within the commit. ' \
+ 'This field can only be resolved for 10 diffs in any single request.' do
+ # Limited to 10 calls per GraphQL request as calling `diffs` multiple times will
+ # lead to N+1 queries to Gitaly.
+ extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 10
+ end
+
field :pipelines,
null: true,
description: 'Pipelines of the commit ordered latest first.',
@@ -68,6 +85,10 @@ module Types
markdown_field :full_title_html, null: true
markdown_field :description_html, null: true
+ def diffs
+ object.diffs.diffs
+ end
+
def author_gravatar
GravatarService.new.execute(object.author_email, 40)
end
diff --git a/app/graphql/types/custom_emoji_type.rb b/app/graphql/types/custom_emoji_type.rb
index 379a0c44d67..b02cd56e6df 100644
--- a/app/graphql/types/custom_emoji_type.rb
+++ b/app/graphql/types/custom_emoji_type.rb
@@ -7,6 +7,10 @@ module Types
authorize :read_custom_emoji
+ connection_type_class(Types::CountableConnectionType)
+
+ expose_permissions Types::PermissionTypes::CustomEmoji
+
field :id, ::Types::GlobalIDType[::CustomEmoji],
null: false,
description: 'ID of the emoji.'
@@ -23,5 +27,9 @@ module Types
field :external, GraphQL::Types::Boolean,
null: false,
description: 'Whether the emoji is an external link.'
+
+ field :created_at, Types::TimeType,
+ null: false,
+ description: 'Timestamp of when the custom emoji was created.'
end
end
diff --git a/app/graphql/types/deployment_type.rb b/app/graphql/types/deployment_type.rb
index 6d895cc81cf..c49633275fb 100644
--- a/app/graphql/types/deployment_type.rb
+++ b/app/graphql/types/deployment_type.rb
@@ -54,8 +54,7 @@ module Types
field :job,
Types::Ci::JobType,
- description: 'Pipeline job of the deployment.',
- method: :build
+ description: 'Pipeline job of the deployment.'
field :triggerer,
Types::UserType,
diff --git a/app/graphql/types/design_management/design_type.rb b/app/graphql/types/design_management/design_type.rb
index be5edd17643..d253ca8bfea 100644
--- a/app/graphql/types/design_management/design_type.rb
+++ b/app/graphql/types/design_management/design_type.rb
@@ -10,10 +10,10 @@ module Types
alias_method :design, :object
- implements(Types::Notes::NoteableInterface)
- implements(Types::DesignManagement::DesignFields)
- implements(Types::CurrentUserTodos)
- implements(Types::TodoableInterface)
+ implements Types::Notes::NoteableInterface
+ implements Types::DesignManagement::DesignFields
+ implements Types::CurrentUserTodos
+ implements Types::TodoableInterface
field :description,
GraphQL::Types::String,
diff --git a/app/graphql/types/diff_type.rb b/app/graphql/types/diff_type.rb
new file mode 100644
index 00000000000..1c67c8c645a
--- /dev/null
+++ b/app/graphql/types/diff_type.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ class DiffType < BaseObject
+ graphql_name 'Diff'
+
+ field :a_mode, GraphQL::Types::String, null: true,
+ description: 'Old file mode of the file.'
+ field :b_mode, GraphQL::Types::String, null: true,
+ description: 'New file mode of the file.'
+ field :deleted_file, GraphQL::Types::String, null: true,
+ description: 'Indicates if the file has been removed. '
+ field :diff, GraphQL::Types::String, null: true,
+ description: 'Diff representation of the changes made to the file.'
+ field :new_file, GraphQL::Types::String, null: true,
+ description: 'Indicates if the file has just been added. '
+ field :new_path, GraphQL::Types::String, null: true,
+ description: 'New path of the file.'
+ field :old_path, GraphQL::Types::String, null: true,
+ description: 'Old path of the file.'
+ field :renamed_file, GraphQL::Types::String, null: true,
+ description: 'Indicates if the file has been renamed.'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+end
diff --git a/app/graphql/types/environment_type.rb b/app/graphql/types/environment_type.rb
index aee09e5a143..63f2b247e01 100644
--- a/app/graphql/types/environment_type.rb
+++ b/app/graphql/types/environment_type.rb
@@ -36,6 +36,9 @@ module Types
field :kubernetes_namespace, GraphQL::Types::String, null: true,
description: 'Kubernetes namespace of the environment.'
+ field :flux_resource_path, GraphQL::Types::String, null: true,
+ description: 'Flux resource path of the environment.'
+
field :created_at, Types::TimeType,
description: 'When the environment was created.'
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
index 5fd6ee948d3..258cf1539fb 100644
--- a/app/graphql/types/group_type.rb
+++ b/app/graphql/types/group_type.rb
@@ -87,6 +87,7 @@ module Types
Types::Ci::GroupEnvironmentScopeType.connection_type,
description: 'Environment scopes of the group.',
null: true,
+ authorize: :admin_group,
resolver: Resolvers::GroupEnvironmentScopesResolver
field :milestones,
@@ -261,6 +262,17 @@ module Types
resolver: Resolvers::DataTransfer::GroupDataTransferResolver,
description: 'Data transfer data point for a specific period. This is mocked data under a development feature flag.'
+ field :work_items,
+ null: true,
+ description: 'Work items that belong to the namespace.',
+ alpha: { milestone: '16.3' },
+ resolver: ::Resolvers::Namespaces::WorkItemsResolver
+
+ field :autocomplete_users,
+ null: true,
+ resolver: Resolvers::AutocompleteUsersResolver,
+ description: 'Search users for autocompletion'
+
def label(title:)
BatchLoader::GraphQL.for(title).batch(key: group) do |titles, loader, args|
LabelsFinder
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index 488e4d10cbc..4b7118d75a5 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -4,11 +4,11 @@ module Types
class IssueType < BaseObject
graphql_name 'Issue'
- connection_type_class(Types::IssueConnectionType)
+ connection_type_class Types::IssueConnectionType
- implements(Types::Notes::NoteableInterface)
- implements(Types::CurrentUserTodos)
- implements(Types::TodoableInterface)
+ implements Types::Notes::NoteableInterface
+ implements Types::CurrentUserTodos
+ implements Types::TodoableInterface
authorize :read_issue
@@ -92,7 +92,13 @@ module Types
field :emails_disabled, GraphQL::Types::Boolean, null: false,
method: :project_emails_disabled?,
- description: 'Indicates if a project has email notifications disabled: `true` if email notifications are disabled.'
+ description: 'Indicates if a project has email notifications disabled: `true` if email notifications are disabled.',
+ deprecated: { reason: 'Use `emails_enabled`', milestone: '16.3' }
+
+ field :emails_enabled, GraphQL::Types::Boolean, null: false,
+ method: :project_emails_enabled?,
+ description: 'Indicates if a project has email notifications disabled: `false` if email notifications are disabled.'
+
field :human_time_estimate, GraphQL::Types::String, null: true,
description: 'Human-readable time estimate of the issue.'
field :human_total_time_spent, GraphQL::Types::String, null: true,
diff --git a/app/graphql/types/label_type.rb b/app/graphql/types/label_type.rb
index 05b703e60af..4848ee30950 100644
--- a/app/graphql/types/label_type.rb
+++ b/app/graphql/types/label_type.rb
@@ -4,7 +4,7 @@ module Types
class LabelType < BaseObject
graphql_name 'Label'
- connection_type_class(Types::CountableConnectionType)
+ connection_type_class Types::CountableConnectionType
authorize :read_label
diff --git a/app/graphql/types/merge_request_state_enum.rb b/app/graphql/types/merge_request_state_enum.rb
index bcf18b836de..e03b79dfeb8 100644
--- a/app/graphql/types/merge_request_state_enum.rb
+++ b/app/graphql/types/merge_request_state_enum.rb
@@ -5,6 +5,7 @@ module Types
graphql_name 'MergeRequestState'
description 'State of a GitLab merge request'
- value 'merged', description: "Merge request has been merged."
+ value 'merged', description: 'Merge request has been merged.'
+ value 'opened', description: 'Opened merge request.'
end
end
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index 99c719f1402..3fe8a05b311 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -4,11 +4,11 @@ module Types
class MergeRequestType < BaseObject
graphql_name 'MergeRequest'
- connection_type_class(Types::MergeRequestConnectionType)
+ connection_type_class Types::MergeRequestConnectionType
- implements(Types::Notes::NoteableInterface)
- implements(Types::CurrentUserTodos)
- implements(Types::TodoableInterface)
+ implements Types::Notes::NoteableInterface
+ implements Types::CurrentUserTodos
+ implements Types::TodoableInterface
authorize :read_merge_request
@@ -192,6 +192,11 @@ module Types
field :total_time_spent, GraphQL::Types::Int, null: false,
description: 'Total time reported as spent on the merge request.'
+ field :approved, GraphQL::Types::Boolean,
+ method: :approved?,
+ null: false, calls_gitaly: true,
+ description: 'Indicates if the merge request has all the required approvals.'
+
field :approved_by, Types::UserType.connection_type, null: true,
description: 'Users who approved the merge request.', method: :approved_by_users
field :auto_merge_strategy, GraphQL::Types::String, null: true,
@@ -221,7 +226,7 @@ module Types
field :award_emoji, Types::AwardEmojis::AwardEmojiType.connection_type,
null: true,
- description: 'List of award emojis associated with the merge request.'
+ description: 'List of emoji reactions associated with the merge request.'
field :prepared_at, Types::TimeType, null: true,
description: 'Timestamp of when the merge request was prepared.'
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 16c46d172f3..957fd10690f 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -143,6 +143,9 @@ module Types
mount_mutation Mutations::Ci::PipelineSchedule::Play
mount_mutation Mutations::Ci::PipelineSchedule::Create
mount_mutation Mutations::Ci::PipelineSchedule::Update
+ mount_mutation Mutations::Ci::PipelineTrigger::Create, alpha: { milestone: '16.3' }
+ mount_mutation Mutations::Ci::PipelineTrigger::Update, alpha: { milestone: '16.3' }
+ mount_mutation Mutations::Ci::PipelineTrigger::Delete, alpha: { milestone: '16.3' }
mount_mutation Mutations::Ci::ProjectCiCdSettingsUpdate
mount_mutation Mutations::Ci::Job::ArtifactsDestroy
mount_mutation Mutations::Ci::Job::Play
@@ -177,12 +180,14 @@ module Types
mount_mutation Mutations::WorkItems::UpdateTask, alpha: { milestone: '15.1' }
mount_mutation Mutations::WorkItems::Export, alpha: { milestone: '15.10' }
mount_mutation Mutations::WorkItems::Convert, alpha: { milestone: '15.11' }
+ mount_mutation Mutations::WorkItems::LinkedItems::Add, alpha: { milestone: '16.3' }
mount_mutation Mutations::SavedReplies::Create
mount_mutation Mutations::SavedReplies::Update
mount_mutation Mutations::Pages::MarkOnboardingComplete
mount_mutation Mutations::SavedReplies::Destroy
mount_mutation Mutations::Uploads::Delete
mount_mutation Mutations::Users::SetNamespaceCommitEmail
+ mount_mutation Mutations::WorkItems::Subscribe, alpha: { milestone: '16.3' }
end
end
diff --git a/app/graphql/types/namespace/package_settings_type.rb b/app/graphql/types/namespace/package_settings_type.rb
index 84becba8001..61240243b1f 100644
--- a/app/graphql/types/namespace/package_settings_type.rb
+++ b/app/graphql/types/namespace/package_settings_type.rb
@@ -20,6 +20,14 @@ module Types
field :maven_duplicates_allowed, GraphQL::Types::Boolean,
null: false,
description: 'Indicates whether duplicate Maven packages are allowed for this namespace.'
+ field :nuget_duplicate_exception_regex, Types::UntrustedRegexp,
+ null: true,
+ description: 'When nuget_duplicates_allowed is false, you can publish duplicate packages with names that match this regex. Otherwise, this setting has no effect. ' \
+ 'Error is raised if `nuget_duplicates_option` feature flag is disabled.'
+ field :nuget_duplicates_allowed, GraphQL::Types::Boolean,
+ null: false,
+ description: 'Indicates whether duplicate NuGet packages are allowed for this namespace. ' \
+ 'Error is raised if `nuget_duplicates_option` feature flag is disabled.'
field :maven_package_requests_forwarding, GraphQL::Types::Boolean,
null: true,
diff --git a/app/graphql/types/notes/discussion_type.rb b/app/graphql/types/notes/discussion_type.rb
index 5e40c8008a9..7afb1f392d3 100644
--- a/app/graphql/types/notes/discussion_type.rb
+++ b/app/graphql/types/notes/discussion_type.rb
@@ -9,7 +9,7 @@ module Types
authorize :read_note
- implements(Types::ResolvableInterface)
+ implements Types::ResolvableInterface
field :created_at, Types::TimeType, null: false,
description: "Timestamp of the discussion's creation."
diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb
index eb1963f976a..e7e032c67c6 100644
--- a/app/graphql/types/notes/note_type.rb
+++ b/app/graphql/types/notes/note_type.rb
@@ -9,7 +9,7 @@ module Types
expose_permissions Types::PermissionTypes::Note
- implements(Types::ResolvableInterface)
+ implements Types::ResolvableInterface
field :max_access_level_of_author, GraphQL::Types::String,
null: true,
@@ -43,7 +43,7 @@ module Types
field :award_emoji, Types::AwardEmojis::AwardEmojiType.connection_type,
null: true,
- description: 'List of award emojis associated with the note.'
+ description: 'List of emoji reactions associated with the note.'
field :confidential, GraphQL::Types::Boolean,
null: true,
diff --git a/app/graphql/types/notes/position_type_enum.rb b/app/graphql/types/notes/position_type_enum.rb
index 157b0b4b884..b585d531192 100644
--- a/app/graphql/types/notes/position_type_enum.rb
+++ b/app/graphql/types/notes/position_type_enum.rb
@@ -8,6 +8,7 @@ module Types
value 'text', description: "Text file."
value 'image', description: "An image."
+ value 'file', description: "Unknown file type."
end
end
end
diff --git a/app/graphql/types/packages/package_base_type.rb b/app/graphql/types/packages/package_base_type.rb
index 8dd2a4467d6..cc41169bcda 100644
--- a/app/graphql/types/packages/package_base_type.rb
+++ b/app/graphql/types/packages/package_base_type.rb
@@ -6,7 +6,7 @@ module Types
graphql_name 'PackageBase'
description 'Represents a package in the Package Registry'
- connection_type_class(Types::CountableConnectionType)
+ connection_type_class Types::CountableConnectionType
authorize :read_package
diff --git a/app/graphql/types/permission_types/group.rb b/app/graphql/types/permission_types/group.rb
index 6a1031e2532..fbd1140babc 100644
--- a/app/graphql/types/permission_types/group.rb
+++ b/app/graphql/types/permission_types/group.rb
@@ -5,7 +5,7 @@ module Types
class Group < BasePermissionType
graphql_name 'GroupPermissions'
- abilities :read_group, :create_projects
+ abilities :read_group, :create_projects, :create_custom_emoji
end
end
end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 992663b4d98..2738d4da6c2 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -4,618 +4,625 @@ module Types
class ProjectType < BaseObject
graphql_name 'Project'
- connection_type_class(Types::CountableConnectionType)
+ connection_type_class Types::CountableConnectionType
authorize :read_project
expose_permissions Types::PermissionTypes::Project
field :id, GraphQL::Types::ID,
- null: false,
- description: 'ID of the project.'
+ 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.'
+ 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 :ref, GraphQL::Types::String,
- required: true,
- description: 'Ref.'
- end
+ null: true,
+ calls_gitaly: true,
+ authorize: :create_pipeline,
+ alpha: { milestone: '15.3' },
+ description: 'CI/CD config variable.' do
+ argument :ref, GraphQL::Types::String,
+ required: true,
+ description: 'Ref.'
+ end
field :full_path, GraphQL::Types::ID,
- null: false,
- description: 'Full path of the project.'
+ null: false,
+ description: 'Full path of the project.'
field :path, GraphQL::Types::String,
- null: false,
- description: 'Path of the project.'
+ null: false,
+ description: 'Path of the project.'
field :incident_management_timeline_event_tags, [Types::IncidentManagement::TimelineEventTagType],
- null: true,
- description: 'Timeline event tags for the project.'
+ null: true,
+ description: 'Timeline event tags for the project.'
field :sast_ci_configuration, Types::CiConfiguration::Sast::Type,
- null: true,
- calls_gitaly: true,
- description: 'SAST CI configuration for the project.'
+ 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).'
+ 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.'
+ null: false,
+ description: 'Full name of the project with its namespace.'
field :description, GraphQL::Types::String,
- null: true,
- description: 'Short description of the project.'
+ 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
+ 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
+ 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.'
+ 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.'
+ 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.'
+ 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.'
+ 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.'
+ null: false,
+ description: 'Number of times the project has been starred.'
field :created_at, Types::TimeType,
- null: true,
- description: 'Timestamp of the project creation.'
+ null: true,
+ description: 'Timestamp of the project creation.'
field :last_activity_at, Types::TimeType,
- null: true,
- description: 'Timestamp of the project last activity.'
+ null: true,
+ description: 'Timestamp of the project last activity.'
field :archived, GraphQL::Types::Boolean,
- null: true,
- description: 'Indicates the archived status of the project.'
+ null: true,
+ description: 'Indicates the archived status of the project.'
field :visibility, GraphQL::Types::String,
- null: true,
- description: 'Visibility of the project.'
+ 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.'
+ 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.'
+ 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.'
+ 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.'
+ 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.'
+ 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.'
+ 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.'
+ null: true,
+ description: 'Indicates if CI/CD pipeline jobs are enabled for the current user.'
field :is_catalog_resource, GraphQL::Types::Boolean,
- alpha: { milestone: '15.11' },
- null: true,
- description: 'Indicates if a project is a catalog resource.'
+ alpha: { milestone: '15.11' },
+ null: true,
+ description: 'Indicates if a project is a catalog resource.'
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
+ 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.'
+ 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.'
+ 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.'
+ 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.'
+ 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.'
+ 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.'
+ 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.'
+ 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.'
+ 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.'
+ 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.'
+ 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?
+ 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.'
+ 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.'
+ null: true,
+ description: 'Group of the project.'
field :namespace, Types::NamespaceType,
- null: true,
- description: 'Namespace of the project.'
+ null: true,
+ description: 'Namespace of the project.'
field :statistics, Types::ProjectStatisticsType,
- null: true,
- description: 'Statistics of the project.'
+ null: true,
+ description: 'Statistics of the project.'
field :statistics_details_paths, Types::ProjectStatisticsRedirectType,
- null: true,
- description: 'Redirects for Statistics of the project.',
- calls_gitaly: true
+ null: true,
+ description: 'Redirects for Statistics of the project.',
+ calls_gitaly: true
field :repository, Types::RepositoryType,
- null: true,
- description: 'Git repository of the project.'
+ null: true,
+ description: 'Git repository of the project.'
field :merge_requests,
- Types::MergeRequestType.connection_type,
- null: true,
- description: 'Merge requests of the project.',
- extras: [:lookahead],
- resolver: Resolvers::ProjectMergeRequestsResolver
+ Types::MergeRequestType.connection_type,
+ null: true,
+ description: 'Merge requests of the project.',
+ extras: [:lookahead],
+ resolver: Resolvers::ProjectMergeRequestsResolver
field :merge_request,
- Types::MergeRequestType,
- null: true,
- description: 'A single merge request of the project.',
- resolver: Resolvers::MergeRequestsResolver.single
+ Types::MergeRequestType,
+ null: true,
+ description: 'A single merge request of the project.',
+ resolver: Resolvers::MergeRequestsResolver.single
field :issues,
- Types::IssueType.connection_type,
- null: true,
- description: 'Issues of the project.',
- resolver: Resolvers::ProjectIssuesResolver
+ Types::IssueType.connection_type,
+ null: true,
+ description: 'Issues of the project.',
+ resolver: Resolvers::ProjectIssuesResolver
field :work_items,
- Types::WorkItemType.connection_type,
- null: true,
- alpha: { milestone: '15.1' },
- description: 'Work items of the project.',
- extras: [:lookahead],
- resolver: Resolvers::WorkItemsResolver
+ Types::WorkItemType.connection_type,
+ null: true,
+ alpha: { milestone: '15.1' },
+ description: 'Work items of the project.',
+ extras: [:lookahead],
+ resolver: Resolvers::WorkItemsResolver
field :issue_status_counts,
- Types::IssueStatusCountsType,
- null: true,
- description: 'Counts of issues by status for the project.',
- resolver: Resolvers::IssueStatusCountsResolver
+ Types::IssueStatusCountsType,
+ null: true,
+ description: 'Counts of issues by status for the project.',
+ resolver: Resolvers::IssueStatusCountsResolver
field :milestones, Types::MilestoneType.connection_type,
- null: true,
- description: 'Milestones of the project.',
- resolver: Resolvers::ProjectMilestonesResolver
+ null: true,
+ description: 'Milestones of the project.',
+ resolver: Resolvers::ProjectMilestonesResolver
field :project_members,
- description: 'Members of the project.',
- resolver: Resolvers::ProjectMembersResolver
+ description: 'Members of the project.',
+ resolver: Resolvers::ProjectMembersResolver
field :environments,
- Types::EnvironmentType.connection_type,
- null: true,
- description: 'Environments of the project. ' \
- 'This field can only be resolved for one project in any single request.',
- resolver: Resolvers::EnvironmentsResolver do
- extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1
- end
+ Types::EnvironmentType.connection_type,
+ null: true,
+ description: 'Environments of the project. ' \
+ 'This field can only be resolved for one project in any single request.',
+ resolver: Resolvers::EnvironmentsResolver do
+ extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1
+ end
field :environment,
- Types::EnvironmentType,
- null: true,
- description: 'A single environment of the project.',
- resolver: Resolvers::EnvironmentsResolver.single
+ Types::EnvironmentType,
+ null: true,
+ description: 'A single environment of the project.',
+ resolver: Resolvers::EnvironmentsResolver.single
field :nested_environments,
- Types::NestedEnvironmentType.connection_type,
- null: true,
- calls_gitaly: true,
- description: 'Environments for this project with nested folders, ' \
- 'can only be resolved for one project in any single request',
- resolver: Resolvers::Environments::NestedEnvironmentsResolver do
- extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1
- end
+ Types::NestedEnvironmentType.connection_type,
+ null: true,
+ calls_gitaly: true,
+ description: 'Environments for this project with nested folders, ' \
+ 'can only be resolved for one project in any single request',
+ resolver: Resolvers::Environments::NestedEnvironmentsResolver do
+ extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1
+ end
field :deployment,
- Types::DeploymentType,
- null: true,
- description: 'Details of the deployment of the project.',
- resolver: Resolvers::DeploymentResolver.single
+ Types::DeploymentType,
+ null: true,
+ description: 'Details of the deployment of the project.',
+ resolver: Resolvers::DeploymentResolver.single
field :issue,
- Types::IssueType,
- null: true,
- description: 'A single issue of the project.',
- resolver: Resolvers::ProjectIssuesResolver.single
+ Types::IssueType,
+ null: true,
+ description: 'A single issue of the project.',
+ resolver: Resolvers::ProjectIssuesResolver.single
field :packages,
- description: 'Packages of the project.',
- resolver: Resolvers::ProjectPackagesResolver
+ description: 'Packages of the project.',
+ resolver: Resolvers::ProjectPackagesResolver
field :packages_cleanup_policy,
- Types::Packages::Cleanup::PolicyType,
- null: true,
- description: 'Packages cleanup policy for the project.'
+ Types::Packages::Cleanup::PolicyType,
+ null: true,
+ description: 'Packages cleanup policy for the project.'
field :jobs,
- type: Types::Ci::JobType.connection_type,
- null: true,
- authorize: :read_build,
- description: 'Jobs of a project. This field can only be resolved for one project in any single request.',
- resolver: Resolvers::ProjectJobsResolver
+ type: Types::Ci::JobType.connection_type,
+ null: true,
+ authorize: :read_build,
+ 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
+ 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
+ null: true,
+ description: 'Build pipelines of the project.',
+ extras: [:lookahead],
+ resolver: Resolvers::ProjectPipelinesResolver
field :pipeline_schedules,
- type: Types::Ci::PipelineScheduleType.connection_type,
- null: true,
- description: 'Pipeline schedules of the project. This field can only be resolved for one project per request.',
- resolver: Resolvers::ProjectPipelineSchedulesResolver
+ type: Types::Ci::PipelineScheduleType.connection_type,
+ null: true,
+ description: 'Pipeline schedules of the project. This field can only be resolved for one project per request.',
+ resolver: Resolvers::ProjectPipelineSchedulesResolver
+
+ field :pipeline_triggers,
+ Types::Ci::PipelineTriggerType.connection_type,
+ null: true,
+ description: 'List of pipeline trigger tokens.',
+ resolver: Resolvers::Ci::PipelineTriggersResolver,
+ alpha: { milestone: '16.3' }
field :pipeline, Types::Ci::PipelineType,
- null: true,
- description: 'Build pipeline of the project.',
- extras: [:lookahead],
- resolver: Resolvers::ProjectPipelineResolver
+ null: true,
+ description: 'Build pipeline of the project.',
+ extras: [:lookahead],
+ resolver: Resolvers::ProjectPipelineResolver
field :pipeline_counts, Types::Ci::PipelineCountsType,
- null: true,
- description: 'Build pipeline counts of the project.',
- resolver: Resolvers::Ci::ProjectPipelineCountsResolver
+ null: true,
+ description: 'Build pipeline counts of the project.',
+ resolver: Resolvers::Ci::ProjectPipelineCountsResolver
field :ci_variables, Types::Ci::ProjectVariableType.connection_type,
- null: true,
- description: "List of the project's CI/CD variables.",
- authorize: :admin_build,
- resolver: Resolvers::Ci::VariablesResolver
+ null: true,
+ description: "List of the project's CI/CD variables.",
+ authorize: :admin_build,
+ resolver: Resolvers::Ci::VariablesResolver
field :inherited_ci_variables, Types::Ci::InheritedCiVariableType.connection_type,
- null: true,
- description: "List of CI/CD variables the project inherited from its parent group and ancestors.",
- authorize: :admin_build,
- resolver: Resolvers::Ci::InheritedVariablesResolver
+ null: true,
+ description: "List of CI/CD variables the project inherited from its parent group and ancestors.",
+ authorize: :admin_build,
+ resolver: Resolvers::Ci::InheritedVariablesResolver
field :ci_cd_settings, Types::Ci::CiCdSettingType,
- null: true,
- description: 'CI/CD settings for the project.'
+ null: true,
+ description: 'CI/CD settings for the project.'
field :sentry_detailed_error, Types::ErrorTracking::SentryDetailedErrorType,
- null: true,
- description: 'Detailed version of a Sentry error on the project.',
- resolver: Resolvers::ErrorTracking::SentryDetailedErrorResolver
+ null: true,
+ description: 'Detailed version of a Sentry error on the project.',
+ resolver: Resolvers::ErrorTracking::SentryDetailedErrorResolver
field :grafana_integration, Types::GrafanaIntegrationType,
- null: true,
- description: 'Grafana integration details for the project.',
- resolver: Resolvers::Projects::GrafanaIntegrationResolver
+ null: true,
+ description: 'Grafana integration details for the project.',
+ resolver: Resolvers::Projects::GrafanaIntegrationResolver
field :snippets, Types::SnippetType.connection_type,
- null: true,
- description: 'Snippets of the project.',
- resolver: Resolvers::Projects::SnippetsResolver
+ null: true,
+ description: 'Snippets of the project.',
+ resolver: Resolvers::Projects::SnippetsResolver
field :sentry_errors, Types::ErrorTracking::SentryErrorCollectionType,
- null: true,
- description: 'Paginated collection of Sentry errors on the project.',
- resolver: Resolvers::ErrorTracking::SentryErrorCollectionResolver
+ null: true,
+ description: 'Paginated collection of Sentry errors on the project.',
+ resolver: Resolvers::ErrorTracking::SentryErrorCollectionResolver
field :boards, Types::BoardType.connection_type,
- null: true,
- description: 'Boards of the project.',
- max_page_size: 2000,
- resolver: Resolvers::BoardsResolver
+ null: true,
+ description: 'Boards of the project.',
+ max_page_size: 2000,
+ resolver: Resolvers::BoardsResolver
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
+ null: true,
+ description: 'List of recently visited boards of the project. Maximum size is 4.',
+ resolver: Resolvers::RecentBoardsResolver
field :board, Types::BoardType,
- null: true,
- description: 'A single board of the project.',
- resolver: Resolvers::BoardResolver
+ null: true,
+ description: 'A single board of the project.',
+ resolver: Resolvers::BoardResolver
field :jira_imports, Types::JiraImportType.connection_type,
- null: true,
- description: 'Jira imports into the project.'
+ null: true,
+ description: 'Jira imports into the project.'
field :services, Types::Projects::ServiceType.connection_type,
- null: true,
- deprecated: {
- reason: 'This will be renamed to `Project.integrations`',
- milestone: '15.9'
- },
- description: 'Project services.',
- resolver: Resolvers::Projects::ServicesResolver
+ null: true,
+ deprecated: {
+ reason: 'This will be renamed to `Project.integrations`',
+ milestone: '15.9'
+ },
+ description: 'Project services.',
+ resolver: Resolvers::Projects::ServicesResolver
field :alert_management_alerts, Types::AlertManagement::AlertType.connection_type,
- null: true,
- description: 'Alert Management alerts of the project.',
- extras: [:lookahead],
- resolver: Resolvers::AlertManagement::AlertResolver
+ null: true,
+ description: 'Alert Management alerts of the project.',
+ extras: [:lookahead],
+ resolver: Resolvers::AlertManagement::AlertResolver
field :alert_management_alert, Types::AlertManagement::AlertType,
- null: true,
- description: 'A single Alert Management alert of the project.',
- resolver: Resolvers::AlertManagement::AlertResolver.single
+ 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,
- null: true,
- description: 'Counts of alerts by status for the project.',
- resolver: Resolvers::AlertManagement::AlertStatusCountsResolver
+ null: true,
+ description: 'Counts of alerts by status for the project.',
+ resolver: Resolvers::AlertManagement::AlertStatusCountsResolver
field :alert_management_integrations, Types::AlertManagement::IntegrationType.connection_type,
- null: true,
- description: 'Integrations which can receive alerts for the project.',
- resolver: Resolvers::AlertManagement::IntegrationsResolver
+ 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,
- null: true,
- description: 'HTTP Integrations which can receive alerts for the project.',
- resolver: Resolvers::AlertManagement::HttpIntegrationsResolver
+ 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,
- null: true,
- description: 'Incident Management Timeline events associated with the incident.',
- extras: [:lookahead],
- resolver: Resolvers::IncidentManagement::TimelineEventsResolver
+ 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,
- null: true,
- description: 'Incident Management Timeline event associated with the incident.',
- resolver: Resolvers::IncidentManagement::TimelineEventsResolver.single
+ null: true,
+ description: 'Incident Management Timeline event associated with the incident.',
+ resolver: Resolvers::IncidentManagement::TimelineEventsResolver.single
field :releases, Types::ReleaseType.connection_type,
- null: true,
- description: 'Releases of the project.',
- resolver: Resolvers::ReleasesResolver
+ null: true,
+ description: 'Releases of the project.',
+ resolver: Resolvers::ReleasesResolver
field :release, Types::ReleaseType,
- null: true,
- description: 'A single release of the project.',
- resolver: Resolvers::ReleasesResolver.single,
- authorize: :read_release
+ null: true,
+ description: 'A single release of the project.',
+ resolver: Resolvers::ReleasesResolver.single,
+ authorize: :read_release
field :container_expiration_policy, Types::ContainerExpirationPolicyType,
- null: true,
- description: 'Container expiration policy of the project.'
+ null: true,
+ description: 'Container expiration policy of the project.'
field :container_repositories, Types::ContainerRepositoryType.connection_type,
- null: true,
- description: 'Container repositories of the project.',
- resolver: Resolvers::ContainerRepositoriesResolver
+ 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.'
+ null: false,
+ description: 'Number of container repositories in the project.'
field :label, Types::LabelType,
- null: true,
- description: 'Label available on this project.' do
- argument :title, GraphQL::Types::String,
- required: true,
- description: 'Title of the label.'
- end
+ null: true,
+ description: 'Label available on this project.' do
+ argument :title, GraphQL::Types::String,
+ required: true,
+ description: 'Title of the label.'
+ end
field :terraform_state, Types::Terraform::StateType,
- null: true,
- description: 'Find a single Terraform state by name.',
- resolver: Resolvers::Terraform::StatesResolver.single
+ null: true,
+ description: 'Find a single Terraform state by name.',
+ resolver: Resolvers::Terraform::StatesResolver.single
field :terraform_states, Types::Terraform::StateType.connection_type,
- null: true,
- description: 'Terraform states associated with the project.',
- resolver: Resolvers::Terraform::StatesResolver
+ 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
+ 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
+ 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
+ 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
+ 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,
- null: true,
- description: 'Agent configurations defined by the project',
- resolver: ::Resolvers::Kas::AgentConfigurationsResolver
+ null: true,
+ description: 'Agent configurations defined by the project',
+ resolver: ::Resolvers::Kas::AgentConfigurationsResolver
field :cluster_agent, ::Types::Clusters::AgentType,
- null: true,
- description: 'Find a single cluster agent by name.',
- resolver: ::Resolvers::Clusters::AgentsResolver.single
+ null: true,
+ description: 'Find a single cluster agent by name.',
+ resolver: ::Resolvers::Clusters::AgentsResolver.single
field :cluster_agents, ::Types::Clusters::AgentType.connection_type,
- extras: [:lookahead],
- null: true,
- description: 'Cluster agents associated with the project.',
- resolver: ::Resolvers::Clusters::AgentsResolver
+ extras: [:lookahead],
+ null: true,
+ description: 'Cluster agents associated with the project.',
+ resolver: ::Resolvers::Clusters::AgentsResolver
field :ci_access_authorized_agents, ::Types::Clusters::Agents::Authorizations::CiAccessType.connection_type,
- null: true,
- description: 'Authorized cluster agents for the project through ci_access keyword.',
- resolver: ::Resolvers::Clusters::Agents::Authorizations::CiAccessResolver,
- authorize: :read_cluster_agent
+ null: true,
+ description: 'Authorized cluster agents for the project through ci_access keyword.',
+ resolver: ::Resolvers::Clusters::Agents::Authorizations::CiAccessResolver,
+ authorize: :read_cluster_agent
field :user_access_authorized_agents, ::Types::Clusters::Agents::Authorizations::UserAccessType.connection_type,
- null: true,
- description: 'Authorized cluster agents for the project through user_access keyword.',
- resolver: ::Resolvers::Clusters::Agents::Authorizations::UserAccessResolver,
- authorize: :read_cluster_agent
+ null: true,
+ description: 'Authorized cluster agents for the project through user_access keyword.',
+ resolver: ::Resolvers::Clusters::Agents::Authorizations::UserAccessResolver,
+ authorize: :read_cluster_agent
field :merge_commit_template, GraphQL::Types::String,
- null: true,
- description: 'Template used to create merge commit message in merge requests.'
+ null: true,
+ description: 'Template used to create merge commit message in merge requests.'
field :squash_commit_template, GraphQL::Types::String,
- null: true,
- description: 'Template used to create squash commit message in merge requests.'
+ null: true,
+ description: 'Template used to create squash commit message in merge requests.'
field :labels, Types::LabelType.connection_type,
- null: true,
- description: 'Labels available on this project.',
- resolver: Resolvers::LabelsResolver
+ null: true,
+ description: 'Labels available on this project.',
+ resolver: Resolvers::LabelsResolver
field :work_item_types, Types::WorkItems::TypeType.connection_type,
- resolver: Resolvers::WorkItems::TypesResolver,
- description: 'Work item types available to the project.'
+ resolver: Resolvers::WorkItems::TypesResolver,
+ description: 'Work item types available to the project.'
field :timelog_categories, Types::TimeTracking::TimelogCategoryType.connection_type,
- null: true,
- description: "Timelog categories for the project.",
- alpha: { milestone: '15.3' }
+ null: true,
+ description: "Timelog categories for the project.",
+ alpha: { milestone: '15.3' }
field :fork_targets, Types::NamespaceType.connection_type,
- resolver: Resolvers::Projects::ForkTargetsResolver,
- description: 'Namespaces in which the current user can fork the project into.'
+ resolver: Resolvers::Projects::ForkTargetsResolver,
+ description: 'Namespaces in which the current user can fork the project into.'
field :fork_details, Types::Projects::ForkDetailsType,
- calls_gitaly: true,
- alpha: { milestone: '15.7' },
- authorize: :read_code,
- resolver: Resolvers::Projects::ForkDetailsResolver,
- description: 'Details of the fork project compared to its upstream project.'
+ calls_gitaly: true,
+ alpha: { milestone: '15.7' },
+ authorize: :read_code,
+ resolver: Resolvers::Projects::ForkDetailsResolver,
+ description: 'Details of the fork project compared to its upstream project.'
field :branch_rules,
- Types::Projects::BranchRuleType.connection_type,
- null: true,
- description: "Branch rules configured for the project.",
- resolver: Resolvers::Projects::BranchRulesResolver
+ Types::Projects::BranchRuleType.connection_type,
+ null: true,
+ description: "Branch rules configured for the project.",
+ resolver: Resolvers::Projects::BranchRulesResolver
field :languages, [Types::Projects::RepositoryLanguageType],
- null: true,
- description: "Programming languages used in the project.",
- calls_gitaly: true
+ null: true,
+ description: "Programming languages used in the project.",
+ calls_gitaly: true
field :runners, Types::Ci::RunnerType.connection_type,
- null: true,
- resolver: ::Resolvers::Ci::ProjectRunnersResolver,
- description: "Find runners visible to the current user."
+ null: true,
+ resolver: ::Resolvers::Ci::ProjectRunnersResolver,
+ description: "Find runners visible to the current user."
field :data_transfer, Types::DataTransfer::ProjectDataTransferType,
- null: true, # disallow null once data_transfer_monitoring feature flag is rolled-out! https://gitlab.com/gitlab-org/gitlab/-/issues/391682
- resolver: Resolvers::DataTransfer::ProjectDataTransferResolver,
- description: 'Data transfer data point for a specific period. This is mocked data under a development feature flag.'
+ null: true, # disallow null once data_transfer_monitoring feature flag is rolled-out! https://gitlab.com/gitlab-org/gitlab/-/issues/391682
+ resolver: Resolvers::DataTransfer::ProjectDataTransferResolver,
+ description: 'Data transfer data point for a specific period. This is mocked data under a development feature flag.'
field :visible_forks, Types::ProjectType.connection_type,
- null: true,
- alpha: { milestone: '15.10' },
- description: "Visible forks of the project." do
- argument :minimum_access_level,
- type: ::Types::AccessLevelEnum,
- required: false,
- description: 'Minimum access level.'
- end
+ null: true,
+ alpha: { milestone: '15.10' },
+ description: "Visible forks of the project." do
+ argument :minimum_access_level,
+ type: ::Types::AccessLevelEnum,
+ required: false,
+ description: 'Minimum access level.'
+ end
field :flow_metrics,
- ::Types::Analytics::CycleAnalytics::FlowMetrics[:project],
- null: true,
- description: 'Flow metrics for value stream analytics.',
- method: :project_namespace,
- authorize: :read_cycle_analytics,
- alpha: { milestone: '15.10' }
+ ::Types::Analytics::CycleAnalytics::FlowMetrics[:project],
+ null: true,
+ description: 'Flow metrics for value stream analytics.',
+ method: :project_namespace,
+ authorize: :read_cycle_analytics,
+ alpha: { milestone: '15.10' }
field :commit_references, ::Types::CommitReferencesType,
null: true,
@@ -623,6 +630,11 @@ module Types
alpha: { milestone: '16.0' },
description: "Get tag names containing a given commit."
+ field :autocomplete_users,
+ null: true,
+ resolver: Resolvers::AutocompleteUsersResolver,
+ description: 'Search users for autocompletion'
+
def timelog_categories
object.project_namespace.timelog_categories if Feature.enabled?(:timelog_categories)
end
@@ -644,7 +656,7 @@ module Types
container_registry: 'Container Registry is'
}.each do |feature, name_string|
field "#{feature}_enabled", GraphQL::Types::Boolean, null: true,
- description: "Indicates if #{name_string} enabled for the current user"
+ description: "Indicates if #{name_string} enabled for the current user"
define_method "#{feature}_enabled" do
object.feature_available?(feature, context[:current_user])
@@ -707,7 +719,7 @@ module Types
if project.repository.empty?
raise Gitlab::Graphql::Errors::MutationError,
- Gitlab::Utils::ErrorMessage.to_user_facing(_(format('You must %s before using Security features.', add_file_docs_link.html_safe)).html_safe)
+ _(format('You must %s before using Security features.', add_file_docs_link.html_safe)).html_safe
end
::Security::CiConfiguration::SastParserService.new(object).configuration
@@ -754,11 +766,11 @@ module Types
def add_file_docs_link
ActionController::Base.helpers.link_to _('add at least one file to the repository'),
- Rails.application.routes.url_helpers.help_page_url(
- 'user/project/repository/index.md',
- anchor: 'add-files-to-a-repository'),
- target: '_blank',
- rel: 'noopener noreferrer'
+ Rails.application.routes.url_helpers.help_page_url(
+ 'user/project/repository/index.md',
+ anchor: 'add-files-to-a-repository'),
+ target: '_blank',
+ rel: 'noopener noreferrer'
end
end
end
diff --git a/app/graphql/types/projects/services/base_service_type.rb b/app/graphql/types/projects/services/base_service_type.rb
index 9a48aafa5a8..c77dc5c8539 100644
--- a/app/graphql/types/projects/services/base_service_type.rb
+++ b/app/graphql/types/projects/services/base_service_type.rb
@@ -7,7 +7,7 @@ module Types
class BaseServiceType < BaseObject
graphql_name 'BaseService'
- implements(Types::Projects::ServiceType)
+ implements Types::Projects::ServiceType
authorize :admin_project
end
diff --git a/app/graphql/types/projects/services/jira_service_type.rb b/app/graphql/types/projects/services/jira_service_type.rb
index ac274d7f890..a774d381e2b 100644
--- a/app/graphql/types/projects/services/jira_service_type.rb
+++ b/app/graphql/types/projects/services/jira_service_type.rb
@@ -7,7 +7,7 @@ module Types
class JiraServiceType < BaseObject
graphql_name 'JiraService'
- implements(Types::Projects::ServiceType)
+ implements Types::Projects::ServiceType
authorize :admin_project
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index b26e447f622..38b8973034d 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -171,6 +171,18 @@ module Types
description: 'Definitions for all audit events available on the instance.',
resolver: Resolvers::AuditEvents::AuditEventDefinitionsResolver
+ field :abuse_report, ::Types::AbuseReportType,
+ null: true,
+ alpha: { milestone: '16.3' },
+ description: 'Find an abuse report.',
+ resolver: Resolvers::AbuseReportResolver
+
+ field :abuse_report_labels, ::Types::LabelType.connection_type,
+ null: true,
+ alpha: { milestone: '16.3' },
+ description: 'Abuse report labels.',
+ resolver: Resolvers::AbuseReportLabelsResolver
+
def design_management
DesignManagementObject.new(nil)
end
diff --git a/app/graphql/types/release_type.rb b/app/graphql/types/release_type.rb
index 8516256b433..0bf723bcb1b 100644
--- a/app/graphql/types/release_type.rb
+++ b/app/graphql/types/release_type.rb
@@ -5,7 +5,7 @@ module Types
graphql_name 'Release'
description 'Represents a release'
- connection_type_class(Types::CountableConnectionType)
+ connection_type_class Types::CountableConnectionType
authorize :read_release
diff --git a/app/graphql/types/saved_reply_type.rb b/app/graphql/types/saved_reply_type.rb
index 8c9f3d19810..74b3796ef8a 100644
--- a/app/graphql/types/saved_reply_type.rb
+++ b/app/graphql/types/saved_reply_type.rb
@@ -4,7 +4,7 @@ module Types
class SavedReplyType < BaseObject
graphql_name 'SavedReply'
- connection_type_class(Types::CountableConnectionType)
+ connection_type_class Types::CountableConnectionType
authorize :read_saved_replies
diff --git a/app/graphql/types/snippet_type.rb b/app/graphql/types/snippet_type.rb
index 5ee0500b1e0..6e6d0edbe15 100644
--- a/app/graphql/types/snippet_type.rb
+++ b/app/graphql/types/snippet_type.rb
@@ -5,7 +5,7 @@ module Types
graphql_name 'Snippet'
description 'Represents a snippet entry'
- implements(Types::Notes::NoteableInterface)
+ implements Types::Notes::NoteableInterface
present_using SnippetPresenter
diff --git a/app/graphql/types/snippets/blob_type.rb b/app/graphql/types/snippets/blob_type.rb
index bb4a0a64de8..2d1993225d1 100644
--- a/app/graphql/types/snippets/blob_type.rb
+++ b/app/graphql/types/snippets/blob_type.rb
@@ -8,7 +8,7 @@ module Types
description 'Represents the snippet blob'
present_using SnippetBlobPresenter
- connection_type_class(Types::Snippets::BlobConnectionType)
+ connection_type_class Types::Snippets::BlobConnectionType
field :rich_data, GraphQL::Types::String,
description: 'Blob highlighted data.',
diff --git a/app/graphql/types/terraform/state_type.rb b/app/graphql/types/terraform/state_type.rb
index be17fc41c2c..0870194a934 100644
--- a/app/graphql/types/terraform/state_type.rb
+++ b/app/graphql/types/terraform/state_type.rb
@@ -7,7 +7,7 @@ module Types
authorize :read_terraform_state
- connection_type_class(Types::CountableConnectionType)
+ connection_type_class Types::CountableConnectionType
field :id, GraphQL::Types::ID,
null: false,
diff --git a/app/graphql/types/timelog_type.rb b/app/graphql/types/timelog_type.rb
index 88baca028ef..2adf2847221 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'
- connection_type_class(Types::TimeTracking::TimelogConnectionType)
+ connection_type_class Types::TimeTracking::TimelogConnectionType
authorize :read_issuable
diff --git a/app/graphql/types/todo_action_enum.rb b/app/graphql/types/todo_action_enum.rb
index fda96796c0f..45b83ea1d64 100644
--- a/app/graphql/types/todo_action_enum.rb
+++ b/app/graphql/types/todo_action_enum.rb
@@ -12,5 +12,6 @@ module Types
value 'merge_train_removed', value: 8, description: 'Merge request authored by the user was removed from the merge train.'
value 'review_requested', value: 9, description: 'Review was requested from the user.'
value 'member_access_requested', value: 10, description: 'Group or project access requested from the user.'
+ value 'review_submitted', value: 11, description: 'Merge request authored by the user received a review.'
end
end
diff --git a/app/graphql/types/users/autocompleted_user_type.rb b/app/graphql/types/users/autocompleted_user_type.rb
new file mode 100644
index 00000000000..8a70f398954
--- /dev/null
+++ b/app/graphql/types/users/autocompleted_user_type.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Types
+ module Users
+ class AutocompletedUserType < ::Types::UserType
+ graphql_name 'AutocompletedUser'
+
+ authorize :read_user
+
+ field :merge_request_interaction, Types::UserMergeRequestInteractionType,
+ null: true,
+ description: 'Merge request state related to the user.' do
+ argument :id, ::Types::GlobalIDType[::MergeRequest], required: true,
+ description: 'Global ID of the merge request.'
+ end
+
+ def merge_request_interaction(id: nil)
+ Gitlab::Graphql::Lazy.with_value(GitlabSchema.object_from_id(id, expected_class: ::MergeRequest)) do |mr|
+ ::Users::MergeRequestInteraction.new(user: object.user, merge_request: mr) if mr
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/work_item_type.rb b/app/graphql/types/work_item_type.rb
index 1e58781dbb9..05798ba3d2f 100644
--- a/app/graphql/types/work_item_type.rb
+++ b/app/graphql/types/work_item_type.rb
@@ -4,7 +4,7 @@ module Types
class WorkItemType < BaseObject
graphql_name 'WorkItem'
- implements(Types::TodoableInterface)
+ implements Types::TodoableInterface
authorize :read_work_item
diff --git a/app/graphql/types/work_items/linked_item_type.rb b/app/graphql/types/work_items/linked_item_type.rb
new file mode 100644
index 00000000000..a4dbeed7480
--- /dev/null
+++ b/app/graphql/types/work_items/linked_item_type.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Types
+ module WorkItems
+ # rubocop:disable Graphql/AuthorizeTypes
+ class LinkedItemType < BaseObject
+ graphql_name 'LinkedWorkItemType'
+
+ field :link_created_at, Types::TimeType,
+ description: 'Timestamp the link was created.', null: false
+ field :link_id, ::Types::GlobalIDType[::WorkItems::RelatedWorkItemLink],
+ description: 'Global ID of the link.', null: false
+ field :link_type, GraphQL::Types::String,
+ description: 'Type of link.', null: false
+ field :link_updated_at, Types::TimeType,
+ description: 'Timestamp the link was updated.', null: false
+ field :work_item, Types::WorkItemType,
+ description: 'Linked work item.', null: false
+ end
+ # rubocop:enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/work_items/related_link_type_enum.rb b/app/graphql/types/work_items/related_link_type_enum.rb
new file mode 100644
index 00000000000..d4bbc7cc404
--- /dev/null
+++ b/app/graphql/types/work_items/related_link_type_enum.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Types
+ module WorkItems
+ class RelatedLinkTypeEnum < BaseEnum
+ graphql_name 'WorkItemRelatedLinkType'
+ description 'Values for work item link types'
+
+ value 'RELATED', 'Related type.', value: 'relates_to'
+ end
+ end
+end
+
+Types::WorkItems::RelatedLinkTypeEnum.prepend_mod_with('Types::WorkItems::RelatedLinkTypeEnum')
diff --git a/app/graphql/types/work_items/widget_interface.rb b/app/graphql/types/work_items/widget_interface.rb
index 53ea901ea10..9f4dbdd1038 100644
--- a/app/graphql/types/work_items/widget_interface.rb
+++ b/app/graphql/types/work_items/widget_interface.rb
@@ -21,7 +21,8 @@ module Types
::Types::WorkItems::Widgets::NotesType,
::Types::WorkItems::Widgets::NotificationsType,
::Types::WorkItems::Widgets::CurrentUserTodosType,
- ::Types::WorkItems::Widgets::AwardEmojiType
+ ::Types::WorkItems::Widgets::AwardEmojiType,
+ ::Types::WorkItems::Widgets::LinkedItemsType
].freeze
def self.ce_orphan_types
@@ -53,6 +54,8 @@ module Types
::Types::WorkItems::Widgets::CurrentUserTodosType
when ::WorkItems::Widgets::AwardEmoji
::Types::WorkItems::Widgets::AwardEmojiType
+ when ::WorkItems::Widgets::LinkedItems
+ ::Types::WorkItems::Widgets::LinkedItemsType
else
raise "Unknown GraphQL type for widget #{object}"
end
diff --git a/app/graphql/types/work_items/widgets/award_emoji_type.rb b/app/graphql/types/work_items/widgets/award_emoji_type.rb
index 421bb8f0e98..eee04696df2 100644
--- a/app/graphql/types/work_items/widgets/award_emoji_type.rb
+++ b/app/graphql/types/work_items/widgets/award_emoji_type.rb
@@ -8,14 +8,14 @@ module Types
# rubocop:disable Graphql/AuthorizeTypes
class AwardEmojiType < BaseObject
graphql_name 'WorkItemWidgetAwardEmoji'
- description 'Represents the award emoji widget'
+ description 'Represents the emoji reactions widget'
implements Types::WorkItems::WidgetInterface
field :award_emoji,
::Types::AwardEmojis::AwardEmojiType.connection_type,
null: true,
- description: 'Award emoji on the work item.'
+ description: 'Emoji reactions on the work item.'
field :downvotes,
GraphQL::Types::Int,
null: false,
diff --git a/app/graphql/types/work_items/widgets/linked_items_type.rb b/app/graphql/types/work_items/widgets/linked_items_type.rb
new file mode 100644
index 00000000000..fa51742b9c1
--- /dev/null
+++ b/app/graphql/types/work_items/widgets/linked_items_type.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Types
+ module WorkItems
+ module Widgets
+ # rubocop:disable Graphql/AuthorizeTypes
+ class LinkedItemsType < BaseObject
+ graphql_name 'WorkItemWidgetLinkedItems'
+ description 'Represents the linked items widget'
+
+ implements Types::WorkItems::WidgetInterface
+
+ field :linked_items, Types::WorkItems::LinkedItemType.connection_type,
+ null: true, complexity: 5,
+ alpha: { milestone: '16.3' },
+ description: 'Linked items for the work item. Returns `null`' \
+ 'if `linked_work_items` feature flag is disabled.',
+ resolver: Resolvers::WorkItems::LinkedItemsResolver
+ end
+ # rubocop:enable Graphql/AuthorizeTypes
+ end
+ end
+end
+
+Types::WorkItems::Widgets::LinkedItemsType.prepend_mod
diff --git a/app/helpers/admin/application_settings/settings_helper.rb b/app/helpers/admin/application_settings/settings_helper.rb
index 9ea07ba4e6e..1741d6a953a 100644
--- a/app/helpers/admin/application_settings/settings_helper.rb
+++ b/app/helpers/admin/application_settings/settings_helper.rb
@@ -15,66 +15,6 @@ module Admin
def project_missing_pipeline_yaml?(project)
project.repository&.gitlab_ci_yml.blank?
end
-
- def code_suggestions_description
- link_start = code_suggestions_link_start(code_suggestions_docs_url)
-
- # rubocop:disable Layout/LineLength
- # rubocop:disable Style/FormatString
- s_('CodeSuggestionsSM|Enable Code Suggestions for users of this instance. %{link_start}What are Code Suggestions?%{link_end}')
- .html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
- # rubocop:enable Style/FormatString
- # rubocop:enable Layout/LineLength
- end
-
- def code_suggestions_token_explanation
- link_start = code_suggestions_link_start(code_suggestions_pat_docs_url)
-
- # rubocop:disable Layout/LineLength
- # rubocop:disable Style/FormatString
- s_('CodeSuggestionsSM|On GitLab.com, create a token. This token is required to use Code Suggestions on your self-managed instance. %{link_start}How do I create a token?%{link_end}')
- .html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
- # rubocop:enable Style/FormatString
- # rubocop:enable Layout/LineLength
- end
-
- def code_suggestions_agreement
- terms_link_start = code_suggestions_link_start(code_suggestions_agreement_url)
- ai_docs_link_start = code_suggestions_link_start(code_suggestions_ai_docs_url)
-
- # rubocop:disable Layout/LineLength
- # rubocop:disable Style/FormatString
- s_('CodeSuggestionsSM|By enabling this feature, you agree to the %{terms_link_start}GitLab Testing Agreement%{link_end} and acknowledge that GitLab will send data from the instance, including personal data, to our %{ai_docs_link_start}AI providers%{link_end} to provide this feature.')
- .html_safe % { terms_link_start: terms_link_start, ai_docs_link_start: ai_docs_link_start, link_end: '</a>'.html_safe }
- # rubocop:enable Style/FormatString
- # rubocop:enable Layout/LineLength
- end
-
- private
-
- # rubocop:disable Gitlab/DocUrl
- # We want to link SaaS docs for flexibility for every URL related to Code Suggestions on Self Managed.
- # We expect to update docs often during the Beta and we want to point user to the most up to date information.
- def code_suggestions_docs_url
- 'https://docs.gitlab.com/ee/user/project/repository/code_suggestions.html'
- end
-
- def code_suggestions_agreement_url
- 'https://about.gitlab.com/handbook/legal/testing-agreement/'
- end
-
- def code_suggestions_ai_docs_url
- 'https://docs.gitlab.com/ee/user/ai_features.html#third-party-services'
- end
-
- def code_suggestions_pat_docs_url
- 'https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#create-a-personal-access-token'
- end
- # rubocop:enable Gitlab/DocUrl
-
- def code_suggestions_link_start(url)
- "<a href=\"#{url}\" target=\"_blank\" rel=\"noopener noreferrer\">".html_safe
- end
end
end
end
diff --git a/app/helpers/admin/broadcast_messages_helper.rb b/app/helpers/admin/broadcast_messages_helper.rb
new file mode 100644
index 00000000000..e087361d52e
--- /dev/null
+++ b/app/helpers/admin/broadcast_messages_helper.rb
@@ -0,0 +1,123 @@
+# frozen_string_literal: true
+
+module Admin
+ module BroadcastMessagesHelper
+ include Gitlab::Utils::StrongMemoize
+
+ def current_broadcast_banner_messages
+ System::BroadcastMessage.current_banner_messages(
+ current_path: request.path,
+ user_access_level: current_user_access_level_for_project_or_group
+ ).select do |message|
+ cookies["hide_broadcast_message_#{message.id}"].blank?
+ end
+ end
+
+ def current_broadcast_notification_message
+ not_hidden_messages = System::BroadcastMessage.current_notification_messages(
+ current_path: request.path,
+ user_access_level: current_user_access_level_for_project_or_group
+ ).select do |message|
+ cookies["hide_broadcast_message_#{message.id}"].blank?
+ end
+ not_hidden_messages.last
+ end
+
+ def broadcast_message(message, opts = {})
+ return unless message.present?
+
+ render "shared/broadcast_message", { message: message, **opts }
+ end
+
+ def broadcast_message_status(broadcast_message)
+ if broadcast_message.active?
+ 'Active'
+ elsif broadcast_message.ended?
+ 'Expired'
+ else
+ 'Pending'
+ end
+ end
+
+ def render_broadcast_message(broadcast_message)
+ if broadcast_message.notification?
+ Banzai.render_field_and_post_process(broadcast_message, :message, {
+ current_user: current_user,
+ skip_project_check: true,
+ broadcast_message_placeholders: true
+ }).html_safe
+ else
+ Banzai.render_field(broadcast_message, :message).html_safe
+ end
+ end
+
+ def target_access_level_options
+ System::BroadcastMessage::ALLOWED_TARGET_ACCESS_LEVELS.map do |access_level|
+ [Gitlab::Access.human_access(access_level), access_level]
+ end
+ end
+
+ def target_access_levels_display(access_levels)
+ access_levels.map do |access_level|
+ Gitlab::Access.human_access(access_level)
+ end.join(', ')
+ end
+
+ def admin_broadcast_messages_data(broadcast_messages)
+ broadcast_messages.map do |message|
+ {
+ id: message.id,
+ status: broadcast_message_status(message),
+ message: message.message,
+ theme: message.theme,
+ broadcast_type: message.broadcast_type,
+ dismissable: message.dismissable,
+ starts_at: message.starts_at.iso8601,
+ ends_at: message.ends_at.iso8601,
+ target_roles: target_access_levels_display(message.target_access_levels),
+ target_path: message.target_path,
+ type: message.broadcast_type.capitalize,
+ edit_path: edit_admin_broadcast_message_path(message),
+ delete_path: "#{admin_broadcast_message_path(message)}.js"
+ }
+ end.to_json
+ end
+
+ def broadcast_message_data(broadcast_message)
+ {
+ id: broadcast_message.id,
+ message: broadcast_message.message,
+ broadcast_type: broadcast_message.broadcast_type,
+ theme: broadcast_message.theme,
+ dismissable: broadcast_message.dismissable.to_s,
+ target_access_levels: broadcast_message.target_access_levels,
+ messages_path: admin_broadcast_messages_path,
+ preview_path: preview_admin_broadcast_messages_path,
+ target_path: broadcast_message.target_path,
+ starts_at: broadcast_message.starts_at.iso8601,
+ ends_at: broadcast_message.ends_at.iso8601,
+ target_access_level_options: target_access_level_options.to_json,
+ show_in_cli: broadcast_message.show_in_cli.to_s
+ }
+ end
+
+ private
+
+ def current_user_access_level_for_project_or_group
+ return unless current_user.present?
+
+ strong_memoize(:current_user_access_level_for_project_or_group) do
+ case controller
+ when Projects::ApplicationController
+ next unless @project
+
+ @project.team.max_member_access(current_user.id)
+ when Groups::ApplicationController
+ next unless @group
+
+ @group.max_member_access_for_user(current_user)
+ end
+ end
+ end
+ end
+end
diff --git a/app/helpers/admin/deploy_key_helper.rb b/app/helpers/admin/deploy_key_helper.rb
index caf3757a68e..8b23c3e1e13 100644
--- a/app/helpers/admin/deploy_key_helper.rb
+++ b/app/helpers/admin/deploy_key_helper.rb
@@ -7,7 +7,7 @@ module Admin
edit_path: edit_admin_deploy_key_path(':id'),
delete_path: admin_deploy_key_path(':id'),
create_path: new_admin_deploy_key_path,
- empty_state_svg_path: image_path('illustrations/empty-state/empty-deploy-keys-lg.svg')
+ empty_state_svg_path: image_path('illustrations/empty-state/empty-access-token-md.svg')
}
end
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index ce338a8afdc..2bf239979f7 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -317,7 +317,7 @@ module ApplicationHelper
class_names << 'with-performance-bar' if performance_bar_enabled?
class_names << 'with-top-bar' if show_super_sidebar? && !@hide_top_bar
class_names << system_message_class
- class_names << 'logged-out-marketing-header' if !current_user && ::Gitlab.com?
+ class_names << 'logged-out-marketing-header' if !current_user && ::Gitlab.com? && !show_super_sidebar?
class_names
end
@@ -466,6 +466,25 @@ module ApplicationHelper
form_with(**args.merge({ builder: ::Gitlab::FormBuilders::GitlabUiFormBuilder }), &block)
end
+ def hidden_resource_icon(resource, css_class: nil)
+ issuable_title = _('This %{issuable} is hidden because its author has been banned')
+
+ case resource
+ when Issue
+ title = format(issuable_title, issuable: _('issue'))
+ when MergeRequest
+ title = format(issuable_title, issuable: _('merge request'))
+ when Project
+ title = _('This project is hidden because its creator has been banned')
+ end
+
+ return unless title
+
+ content_tag(:span, class: 'has-tooltip', title: title) do
+ sprite_icon('spam', css_class: ['gl-vertical-align-text-bottom', css_class].compact_blank.join(' '))
+ end
+ end
+
private
def browser_id
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index aa2466372e1..a45425474b5 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -218,7 +218,6 @@ module ApplicationSettingsHelper
:admin_mode,
:after_sign_out_path,
:after_sign_up_text,
- :ai_access_token,
:akismet_api_key,
:akismet_enabled,
:allow_local_requests_from_hooks_and_services,
@@ -242,6 +241,7 @@ module ApplicationSettingsHelper
:default_artifacts_expire_in,
:default_branch_name,
:default_branch_protection,
+ :default_branch_protection_defaults,
:default_ci_config_path,
:default_group_visibility,
:default_preferred_language,
@@ -310,7 +310,6 @@ module ApplicationSettingsHelper
:inactive_projects_delete_after_months,
:inactive_projects_min_size_mb,
:inactive_projects_send_warning_email_after_months,
- :instance_level_code_suggestions_enabled,
:invisible_captcha_enabled,
:jira_connect_application_key,
:jira_connect_public_key_storage_enabled,
@@ -319,6 +318,8 @@ module ApplicationSettingsHelper
:max_attachment_size,
:max_export_size,
:max_import_size,
+ :max_import_remote_file_size,
+ :max_decompressed_archive_size,
:max_pages_size,
:max_pages_custom_domains_per_project,
:max_terraform_state_size_bytes,
@@ -457,6 +458,7 @@ module ApplicationSettingsHelper
:wiki_asciidoc_allow_uri_includes,
:container_registry_delete_tags_service_timeout,
:rate_limiting_response_text,
+ :package_registry_allow_anyone_to_pull_option,
:package_registry_cleanup_policies_worker_capacity,
:container_registry_expiration_policies_worker_capacity,
:container_registry_cleanup_tags_service_max_list_size,
@@ -491,6 +493,7 @@ module ApplicationSettingsHelper
:invitation_flow_enforcement,
:can_create_group,
:bulk_import_enabled,
+ :bulk_import_max_download_file_size,
:allow_runner_registration_token,
:user_defaults_to_private_profile,
:deactivation_email_additional_text,
@@ -498,7 +501,9 @@ module ApplicationSettingsHelper
:gitlab_dedicated_instance,
:ci_max_includes,
:allow_account_deletion,
- :gitlab_shell_operation_limit
+ :gitlab_shell_operation_limit,
+ :namespace_aggregation_schedule_lease_duration_in_seconds,
+ :ci_max_total_yaml_size_bytes
].tap do |settings|
next if Gitlab.com?
diff --git a/app/helpers/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb
deleted file mode 100644
index a62ffa144f1..00000000000
--- a/app/helpers/broadcast_messages_helper.rb
+++ /dev/null
@@ -1,121 +0,0 @@
-# frozen_string_literal: true
-
-module BroadcastMessagesHelper
- include Gitlab::Utils::StrongMemoize
-
- def current_broadcast_banner_messages
- BroadcastMessage.current_banner_messages(
- current_path: request.path,
- user_access_level: current_user_access_level_for_project_or_group
- ).select do |message|
- cookies["hide_broadcast_message_#{message.id}"].blank?
- end
- end
-
- def current_broadcast_notification_message
- not_hidden_messages = BroadcastMessage.current_notification_messages(
- current_path: request.path,
- user_access_level: current_user_access_level_for_project_or_group
- ).select do |message|
- cookies["hide_broadcast_message_#{message.id}"].blank?
- end
- not_hidden_messages.last
- end
-
- def broadcast_message(message, opts = {})
- return unless message.present?
-
- render "shared/broadcast_message", { message: message, **opts }
- end
-
- def broadcast_message_status(broadcast_message)
- if broadcast_message.active?
- 'Active'
- elsif broadcast_message.ended?
- 'Expired'
- else
- 'Pending'
- end
- end
-
- def render_broadcast_message(broadcast_message)
- if broadcast_message.notification?
- Banzai.render_field_and_post_process(broadcast_message, :message, {
- current_user: current_user,
- skip_project_check: true,
- broadcast_message_placeholders: true
- }).html_safe
- else
- Banzai.render_field(broadcast_message, :message).html_safe
- end
- end
-
- def target_access_level_options
- BroadcastMessage::ALLOWED_TARGET_ACCESS_LEVELS.map do |access_level|
- [Gitlab::Access.human_access(access_level), access_level]
- end
- end
-
- def target_access_levels_display(access_levels)
- access_levels.map do |access_level|
- Gitlab::Access.human_access(access_level)
- end.join(', ')
- end
-
- def admin_broadcast_messages_data(broadcast_messages)
- broadcast_messages.map do |message|
- {
- id: message.id,
- status: broadcast_message_status(message),
- message: message.message,
- theme: message.theme,
- broadcast_type: message.broadcast_type,
- dismissable: message.dismissable,
- starts_at: message.starts_at.iso8601,
- ends_at: message.ends_at.iso8601,
- target_roles: target_access_levels_display(message.target_access_levels),
- target_path: message.target_path,
- type: message.broadcast_type.capitalize,
- edit_path: edit_admin_broadcast_message_path(message),
- delete_path: "#{admin_broadcast_message_path(message)}.js"
- }
- end.to_json
- end
-
- def broadcast_message_data(broadcast_message)
- {
- id: broadcast_message.id,
- message: broadcast_message.message,
- broadcast_type: broadcast_message.broadcast_type,
- theme: broadcast_message.theme,
- dismissable: broadcast_message.dismissable.to_s,
- target_access_levels: broadcast_message.target_access_levels,
- messages_path: admin_broadcast_messages_path,
- preview_path: preview_admin_broadcast_messages_path,
- target_path: broadcast_message.target_path,
- starts_at: broadcast_message.starts_at.iso8601,
- ends_at: broadcast_message.ends_at.iso8601,
- target_access_level_options: target_access_level_options.to_json,
- show_in_cli: broadcast_message.show_in_cli.to_s
- }
- end
-
- private
-
- def current_user_access_level_for_project_or_group
- return unless current_user.present?
-
- strong_memoize(:current_user_access_level_for_project_or_group) do
- case controller
- when Projects::ApplicationController
- next unless @project
-
- @project.team.max_member_access(current_user.id)
- when Groups::ApplicationController
- next unless @group
-
- @group.max_member_access_for_user(current_user)
- end
- end
- end
-end
diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb
index b1481f668bb..7cc554bbeeb 100644
--- a/app/helpers/ci/runners_helper.rb
+++ b/app/helpers/ci/runners_helper.rb
@@ -71,21 +71,35 @@ module Ci
new_runner_path: new_admin_runner_path,
registration_token: Gitlab::CurrentSettings.runners_registration_token,
online_contact_timeout_secs: ::Ci::Runner::ONLINE_CONTACT_TIMEOUT.to_i,
- stale_timeout_secs: ::Ci::Runner::STALE_TIMEOUT.to_i
+ stale_timeout_secs: ::Ci::Runner::STALE_TIMEOUT.to_i,
+ tag_suggestions_path: tag_list_admin_runners_path(format: :json)
}
end
def group_shared_runners_settings_data(group)
- {
+ data = {
group_id: group.id,
group_name: group.name,
group_is_empty: (group.projects.empty? && group.children.empty?).to_s,
shared_runners_setting: group.shared_runners_setting,
- parent_shared_runners_setting: group.parent&.shared_runners_setting,
+
runner_enabled_value: Namespace::SR_ENABLED,
runner_disabled_value: Namespace::SR_DISABLED_AND_UNOVERRIDABLE,
- runner_allow_override_value: Namespace::SR_DISABLED_AND_OVERRIDABLE
+ runner_allow_override_value: Namespace::SR_DISABLED_AND_OVERRIDABLE,
+
+ parent_shared_runners_setting: group.parent&.shared_runners_setting,
+ parent_name: nil,
+ parent_settings_path: nil
}
+
+ if group.parent && can?(current_user, :admin_group, group.parent)
+ data.merge!({
+ parent_name: group.parent.name,
+ parent_settings_path: group_settings_ci_cd_path(group.parent, anchor: 'runners-settings')
+ })
+ end
+
+ data
end
def group_runners_data_attributes(group)
@@ -99,11 +113,22 @@ module Ci
end
def toggle_shared_runners_settings_data(project)
- {
+ data = {
is_enabled: project.shared_runners_enabled?.to_s,
is_disabled_and_unoverridable: (project.group&.shared_runners_setting == Namespace::SR_DISABLED_AND_UNOVERRIDABLE).to_s,
- update_path: toggle_shared_runners_project_runners_path(project)
+ update_path: toggle_shared_runners_project_runners_path(project),
+ group_name: nil,
+ group_settings_path: nil
}
+
+ if project.group && can?(current_user, :admin_group, project.group)
+ data.merge!({
+ group_name: project.group.name,
+ group_settings_path: group_settings_ci_cd_path(project.group, anchor: 'runners-settings')
+ })
+ end
+
+ data
end
end
end
diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb
index ee86553d75d..42871dcc56f 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -111,7 +111,7 @@ module CommitsHelper
tooltip = _("Browse Directory")
end
- link_to url, class: "btn gl-button btn-default btn-icon has-tooltip", title: tooltip, data: { container: "body" } do
+ render Pajamas::ButtonComponent.new(href: url, button_options: { title: tooltip, class: 'has-tooltip btn-icon', data: { container: 'body' } }) do
sprite_icon('folder-open')
end
end
@@ -143,6 +143,16 @@ module CommitsHelper
end
end
+ def local_committed_date(commit, user)
+ server_timezone = Time.zone
+ user_timezone = user.timezone if user
+ user_timezone = ActiveSupport::TimeZone.new(user_timezone) if user_timezone
+
+ timezone = user_timezone || server_timezone
+
+ commit.committed_date.in_time_zone(timezone).to_date
+ end
+
def cherry_pick_projects_data(project)
[project, project.forked_from_project].compact.map do |project|
{
@@ -188,12 +198,11 @@ module CommitsHelper
entity = mode == 'raw' ? 'rawButton' : 'renderedButton'
title = "Display #{mode} diff"
- link_to(
- "##{mode}-diff-#{file_hash}",
- class: "btn gl-button btn-default btn-file-option has-tooltip btn-show-#{mode}-diff",
- title: title,
- data: { file_hash: file_hash, diff_toggle_entity: entity }
- ) do
+ render Pajamas::ButtonComponent.new(
+ href: "##{mode}-diff-#{file_hash}",
+ button_options: { title: title,
+ class: "btn-file-option has-tooltip btn-show-#{mode}-diff",
+ data: { file_hash: file_hash, diff_toggle_entity: entity } }) do
sprite_icon(icon)
end
end
@@ -242,7 +251,7 @@ module CommitsHelper
path = project_blob_path(project, tree_join(commit_sha, diff_new_path))
title = replaced ? _('View replaced file @ ') : _('View file @ ')
- link_to(path, class: 'btn gl-button btn-default gl-ml-3') do
+ render Pajamas::ButtonComponent.new(href: path, button_options: { class: 'gl-ml-3' }) do
raw(title) + content_tag(:span, truncate_sha(commit_sha), class: 'commit-sha')
end
end
@@ -253,7 +262,7 @@ module CommitsHelper
external_url = environment.external_url_for(diff_new_path, commit_sha)
return unless external_url
- link_to(external_url, class: 'btn gl-button btn-default btn-file-option has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: "View on #{environment.formatted_external_url}", data: { container: 'body' }) do
+ render Pajamas::ButtonComponent.new(href: external_url, target: '_blank', button_options: { rel: 'noopener noreferrer', title: "View on #{environment.formatted_external_url}", data: { container: 'body' } }) do
sprite_icon('external-link')
end
end
diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb
index 475ba3dcba8..ce18bedd25f 100644
--- a/app/helpers/dropdowns_helper.rb
+++ b/app/helpers/dropdowns_helper.rb
@@ -1,6 +1,11 @@
# frozen_string_literal: true
module DropdownsHelper
+ def dropdown_data_attr(options: {})
+ output = content_tag(:div, "", id: "js-template-selectors-menu", data: options[:data])
+ output.html_safe
+ end
+
# 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
diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb
index 7213bd074fc..af0f1bd6808 100644
--- a/app/helpers/emails_helper.rb
+++ b/app/helpers/emails_helper.rb
@@ -2,6 +2,7 @@
module EmailsHelper
include AppearancesHelper
+ include SafeFormatHelper
# Google Actions
# https://developers.google.com/gmail/markup/reference/go-to-action
@@ -236,6 +237,44 @@ module EmailsHelper
end
end
+ def member_about_to_expire_text(member_source, days_to_expire, format: nil)
+ days_formatted = pluralize(days_to_expire, 'day')
+
+ case member_source
+ when Project
+ url = project_url(member_source)
+ when Group
+ url = group_url(member_source)
+ end
+
+ case format
+ when :html
+ link_to = generate_link(member_source.human_name, url).html_safe
+ safe_format(_("Your membership in %{link_to} %{project_or_group_name} will expire in %{days_formatted}."), link_to: link_to, project_or_group_name: member_source.model_name.singular, days_formatted: days_formatted)
+ else
+ _("Your membership in %{project_or_group} %{project_or_group_name} will expire in %{days_formatted}.") % { project_or_group: member_source.human_name, project_or_group_name: member_source.model_name.singular, days_formatted: days_formatted }
+ end
+ end
+
+ def member_about_to_expire_link(member, member_source, format: nil)
+ project_or_group = member_source.human_name
+
+ case member_source
+ when Project
+ url = project_project_members_url(member_source, search: member.user.username)
+ when Group
+ url = group_group_members_url(member_source, search: member.user.username)
+ end
+
+ case format
+ when :html
+ link_to = generate_link("#{member_source.class.name.downcase} membership", url).html_safe
+ safe_format(_('For additional information, review your %{link_to} or contact your %{project_or_group} owner.'), link_to: link_to, project_or_group: project_or_group)
+ else
+ _('For additional information, review your %{project_or_group} membership: %{url} or contact your %{project_or_group} owner.') % { project_or_group: project_or_group, url: url }
+ end
+ end
+
def group_membership_expiration_changed_text(member, group)
if member.expires?
days = (member.expires_at - Date.today).to_i
diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb
index 3360a5256af..cd768ba8a7b 100644
--- a/app/helpers/environments_helper.rb
+++ b/app/helpers/environments_helper.rb
@@ -54,7 +54,6 @@ module EnvironmentsHelper
{
'settings_path' => edit_project_settings_integration_path(project, 'prometheus'),
'clusters_path' => project_clusters_path(project),
- 'dashboards_endpoint' => project_performance_monitoring_dashboards_path(project, format: :json),
'default_branch' => project.default_branch,
'project_path' => project_path(project),
'tags_path' => project_tags_path(project),
@@ -82,8 +81,7 @@ module EnvironmentsHelper
{
'deployments_endpoint' => project_environment_deployments_path(project, environment, format: :json),
'operations_settings_path' => project_settings_operations_path(project),
- 'can_access_operations_settings' => can?(current_user, :admin_operations, project).to_s,
- 'panel_preview_endpoint' => project_metrics_dashboards_builder_path(project, format: :json)
+ 'can_access_operations_settings' => can?(current_user, :admin_operations, project).to_s
}
end
diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb
index 4b5fadf3397..645a08bfcc0 100644
--- a/app/helpers/integrations_helper.rb
+++ b/app/helpers/integrations_helper.rb
@@ -162,7 +162,7 @@ module IntegrationsHelper
end
def integrations_help_page_path
- help_page_path('user/admin_area/settings/project_integration_management')
+ help_page_path('administration/settings/project_integration_management')
end
def project_jira_issues_integration?
@@ -179,7 +179,8 @@ module IntegrationsHelper
'incident' => _('Incident'),
'test_case' => _('Test case'),
'requirement' => _('Requirement'),
- 'task' => _('Task')
+ 'task' => _('Task'),
+ 'ticket' => _('Service Desk Ticket')
}
issue_type_i18n_map[issue_type] || issue_type
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index e921e9bae4d..c83545fa7a7 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -251,9 +251,16 @@ module IssuablesHelper
def issue_only_initial_data(issuable)
return {} unless issuable.is_a?(Issue)
- {
+ data = {
+ authorId: issuable.author.id,
+ authorName: issuable.author.name,
+ authorUsername: issuable.author.username,
+ authorWebUrl: url_for(user_path(issuable.author)),
+ createdAt: issuable.created_at.to_time.iso8601,
hasClosingMergeRequest: issuable.merge_requests_count(current_user) != 0,
+ isFirstContribution: issuable.first_contribution?,
issueType: issuable.issue_type,
+ serviceDeskReplyTo: issuable.present(current_user: current_user).service_desk_reply_to,
zoomMeetingUrl: ZoomMeeting.canonical_meeting_url(issuable),
sentryIssueIdentifier: SentryIssue.find_by(issue: issuable)&.sentry_issue_identifier, # rubocop:disable CodeReuse/ActiveRecord
iid: issuable.iid.to_s,
@@ -261,6 +268,16 @@ module IssuablesHelper
canCreateIncident: create_issue_type_allowed?(issuable.project, :incident),
**incident_only_initial_data(issuable)
}
+
+ data.tap do |d|
+ if issuable.duplicated? && can?(current_user, :read_issue, issuable.duplicated_to)
+ d[:duplicatedToIssueUrl] = url_for([issuable.duplicated_to.project, issuable.duplicated_to, { only_path: false }])
+ end
+
+ if issuable.moved? && can?(current_user, :read_issue, issuable.moved_to)
+ d[:movedToIssueUrl] = url_for([issuable.moved_to.project, issuable.moved_to, { only_path: false }])
+ end
+ end
end
def incident_only_initial_data(issue)
@@ -366,16 +383,6 @@ module IssuablesHelper
end
end
- def hidden_issuable_icon(issuable)
- title = format(
- _('This %{issuable} is hidden because its author has been banned'),
- issuable: issuable.is_a?(Issue) ? _('issue') : _('merge request')
- )
- content_tag(:span, class: 'has-tooltip', title: title) do
- sprite_icon('spam', css_class: 'gl-vertical-align-text-bottom')
- end
- end
-
def issuable_type_selector_data(issuable)
{
selected_type: issuable.issue_type,
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index d9b9b27d16c..ed655b562c2 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -55,7 +55,7 @@ module IssuesHelper
def hidden_issue_icon(issue)
return unless issue_hidden?(issue)
- hidden_issuable_icon(issue)
+ hidden_resource_icon(issue)
end
def award_user_list(awards, current_user, limit: 10)
@@ -195,7 +195,8 @@ module IssuesHelper
is_signed_in: current_user.present?.to_s,
jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'),
rss_path: url_for(safe_params.merge(rss_url_options)),
- sign_in_path: new_user_session_path
+ sign_in_path: new_user_session_path,
+ has_issue_date_filter_feature: Feature.enabled?(:issue_date_filter, namespace).to_s
}
end
@@ -220,7 +221,9 @@ module IssuesHelper
quick_actions_help_path: help_page_path('user/project/quick_actions'),
releases_path: project_releases_path(project, format: :json),
reset_path: new_issuable_address_project_path(project, issuable_type: 'issue'),
- show_new_issue_link: show_new_issue_link?(project).to_s
+ show_new_issue_link: show_new_issue_link?(project).to_s,
+ report_abuse_path: add_category_abuse_reports_path,
+ register_path: new_user_registration_path(redirect_to_referer: 'yes')
)
end
diff --git a/app/helpers/jira_connect_helper.rb b/app/helpers/jira_connect_helper.rb
index 5cf68db0611..2309dfc2a2b 100644
--- a/app/helpers/jira_connect_helper.rb
+++ b/app/helpers/jira_connect_helper.rb
@@ -5,7 +5,7 @@ module JiraConnectHelper
skip_groups = subscriptions.map(&:namespace_id)
{
- groups_path: api_v4_groups_path(params: { min_access_level: Gitlab::Access::MAINTAINER, skip_groups: skip_groups }),
+ groups_path: api_v4_groups_path(params: { skip_groups: skip_groups }),
subscriptions: subscriptions.map { |s| serialize_subscription(s) }.to_json,
subscriptions_path: jira_connect_subscriptions_path(format: :json),
gitlab_user_path: current_user ? user_path(current_user) : nil,
diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb
index c4967a42a45..79bab0969d1 100644
--- a/app/helpers/labels_helper.rb
+++ b/app/helpers/labels_helper.rb
@@ -46,11 +46,11 @@ module LabelsHelper
end
end
- def render_label(label, link: nil, tooltip: true, dataset: nil, small: false)
+ def render_label(label, link: nil, tooltip: true, dataset: nil, small: false, tooltip_shows_title: false)
html = render_colored_label(label)
if link
- title = label_tooltip_title(label) if tooltip
+ title = label_tooltip_title(label, tooltip_shows_title: tooltip_shows_title) if tooltip
html = render_label_link(html, link: link, title: title, dataset: dataset)
end
@@ -74,8 +74,8 @@ module LabelsHelper
%(<span class="#{wrapper_classes.join(' ')}">#{label_html}</span>).html_safe
end
- def label_tooltip_title(label)
- Sanitize.clean(label.description)
+ def label_tooltip_title(label, tooltip_shows_title: false)
+ Sanitize.clean(tooltip_shows_title ? label.title : label.description)
end
def suggested_colors
diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb
index 91fce6d6820..1a44f3554b0 100644
--- a/app/helpers/markup_helper.rb
+++ b/app/helpers/markup_helper.rb
@@ -110,8 +110,8 @@ module MarkupHelper
prepare_asciidoc_context(file_name, context)
html = Markup::RenderingService
- .new(text, file_name: file_name, context: context, postprocess_context: postprocess_context)
- .execute
+ .new(text, file_name: file_name, context: context, postprocess_context: postprocess_context)
+ .execute
Hamlit::RailsHelpers.preserve(html)
end
@@ -124,8 +124,8 @@ module MarkupHelper
prepare_asciidoc_context(wiki_page.path, context)
html = Markup::RenderingService
- .new(text, file_name: wiki_page.path, context: context, postprocess_context: postprocess_context)
- .execute
+ .new(text, file_name: wiki_page.path, context: context, postprocess_context: postprocess_context)
+ .execute
Hamlit::RailsHelpers.preserve(html)
end
@@ -192,15 +192,21 @@ module MarkupHelper
def markdown_toolbar_button(options = {})
data = options[:data].merge({ container: 'body' })
- css_classes = %w[gl-button btn btn-default-tertiary btn-icon btn-sm js-md has-tooltip] << options[:css_class].to_s
- content_tag :button,
- type: 'button',
- class: css_classes.join(' '),
- data: data,
- title: options[:title],
- aria: { label: options[:title] } do
- sprite_icon(options[:icon])
- end
+ css_classes = %w[js-md has-tooltip] << options[:css_class].to_s
+
+ render Pajamas::ButtonComponent.new(
+ category: :tertiary,
+ size: :small,
+ icon: options[:icon],
+ button_options: {
+ class: css_classes.join(' '),
+ data: data,
+ title: options[:title],
+ aria: {
+ label: options[:title]
+ }
+ }
+ )
end
def render_markdown_field(object, field, context = {})
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index af1c85532c3..32a183d6cd8 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -303,12 +303,16 @@ module MergeRequestsHelper
def hidden_merge_request_icon(merge_request)
return unless merge_request.hidden?
- hidden_issuable_icon(merge_request)
+ hidden_resource_icon(merge_request)
end
def tab_count_display(merge_request, count)
merge_request.preparing? ? "-" : count
end
+
+ def review_bar_data(_merge_request, _user)
+ { new_comment_template_path: profile_comment_templates_path }
+ end
end
MergeRequestsHelper.prepend_mod_with('MergeRequestsHelper')
diff --git a/app/helpers/mirror_helper.rb b/app/helpers/mirror_helper.rb
index 06deaeb5e9e..158aa5e0944 100644
--- a/app/helpers/mirror_helper.rb
+++ b/app/helpers/mirror_helper.rb
@@ -15,6 +15,11 @@ module MirrorHelper
html_escape(_('Git LFS objects will be synced if LFS is %{docs_link_start}enabled for the project%{docs_link_end}. Push mirrors will %{strong_open}not%{strong_close} sync LFS objects over SSH.')) %
{ docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe, strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
end
+
+ def mirrored_repositories_count
+ count = @project.mirror == true ? 1 : 0
+ count + @project.remote_mirrors.to_a.count(&:enabled)
+ end
end
MirrorHelper.prepend_mod_with('MirrorHelper')
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index c7864c1d45f..4cbd5029ac9 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -90,7 +90,7 @@ module NavHelper
# The new sidebar is not enabled for anonymous use
# Once we enable the new sidebar by default, this
# should return true
- return false unless user
+ return Feature.enabled?(:super_sidebar_logged_out) unless user
# Users who got the special `super_sidebar_nav_enrolled` enabled,
# see the new nav as long as they don't explicitly opt-out via the toggle
diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb
index 3e8872dc199..af8da86b391 100644
--- a/app/helpers/notes_helper.rb
+++ b/app/helpers/notes_helper.rb
@@ -178,6 +178,10 @@ module NotesHelper
def notes_data(issuable)
data = {
+ noteableType: @noteable.class.underscore,
+ noteableId: @noteable.id,
+ projectId: @project&.id,
+ groupId: @group&.id,
discussionsPath: discussions_path(issuable),
registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'),
newSessionPath: new_session_path(:user, redirect_to_referer: 'yes'),
diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb
index 31fcc77925b..fefc19d7c1a 100644
--- a/app/helpers/packages_helper.rb
+++ b/app/helpers/packages_helper.rb
@@ -16,7 +16,7 @@ module PackagesHelper
end
def package_registry_project_url(project_id, registry_type = :maven)
- project_api_path = expose_path(api_v4_projects_path(id: project_id))
+ project_api_path = api_v4_projects_path(id: project_id)
package_registry_project_path = "#{project_api_path}/packages/#{registry_type}"
expose_url(package_registry_project_path)
end
diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb
index 26463003f8d..05605394d57 100644
--- a/app/helpers/profiles_helper.rb
+++ b/app/helpers/profiles_helper.rb
@@ -73,6 +73,21 @@ module ProfilesHelper
def prevent_delete_account?
false
end
+
+ def user_profile_data(user)
+ {
+ profile_path: profile_path,
+ profile_avatar_path: profile_avatar_path,
+ avatar_url: avatar_icon_for_user(user, current_user: current_user),
+ has_avatar: user.avatar?.to_s,
+ gravatar_enabled: gravatar_enabled?.to_s,
+ gravatar_link: { hostname: Gitlab.config.gravatar.host, url: "https://#{Gitlab.config.gravatar.host}" }.to_json,
+ brand_profile_image_guidelines: current_appearance&.profile_image_guidelines? ? brand_profile_image_guidelines : '',
+ cropper_css_path: ActionController::Base.helpers.stylesheet_path('lazy_bundles/cropper.css'),
+ user_path: user_path(current_user),
+ **user_status_properties(user)
+ }
+ end
end
ProfilesHelper.prepend_mod
diff --git a/app/helpers/projects/cluster_agents_helper.rb b/app/helpers/projects/cluster_agents_helper.rb
index f62f5eadfb4..d285cfa03c2 100644
--- a/app/helpers/projects/cluster_agents_helper.rb
+++ b/app/helpers/projects/cluster_agents_helper.rb
@@ -6,7 +6,7 @@ module Projects::ClusterAgentsHelper
activity_empty_state_image: image_path('illustrations/empty-state/empty-state-agents.svg'),
agent_name: agent_name,
can_admin_vulnerability: can?(current_user, :admin_vulnerability, project).to_s,
- empty_state_svg_path: image_path('illustrations/operations-dashboard_empty.svg'),
+ empty_state_svg_path: image_path('illustrations/empty-state/empty-radar-md.svg'),
project_path: project.full_path,
kas_address: Gitlab::Kas.external_url,
kas_version: Gitlab::Kas.version_info,
diff --git a/app/helpers/projects/observability_helper.rb b/app/helpers/projects/observability_helper.rb
index 24bc1928a36..4515fdb1bc3 100644
--- a/app/helpers/projects/observability_helper.rb
+++ b/app/helpers/projects/observability_helper.rb
@@ -9,5 +9,15 @@ module Projects
oauthUrl: Gitlab::Observability.oauth_url
})
end
+
+ def observability_tracing_details_model(project, trace_id)
+ Gitlab::Json.generate({
+ tracingIndexUrl: namespace_project_tracing_index_path(project.group, project),
+ traceId: trace_id,
+ tracingUrl: Gitlab::Observability.tracing_url(project),
+ provisioningUrl: Gitlab::Observability.provisioning_url(project),
+ oauthUrl: Gitlab::Observability.oauth_url
+ })
+ end
end
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index e27ee1acb22..754e1b7c2a2 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -464,14 +464,23 @@ module ProjectsHelper
project.forking_enabled? && can?(user, :read_code, project)
end
- def fork_button_disabled_tooltip(project)
+ def fork_button_data_attributes(project)
return unless current_user
- if !current_user.can?(:fork_project, project)
- s_("ProjectOverview|You don't have permission to fork this project")
- elsif !current_user.can?(:create_fork)
- s_('ProjectOverview|You have reached your project limit')
+ if current_user.already_forked?(project) && current_user.forkable_namespaces.size < 2
+ user_fork_url = namespace_project_path(current_user, current_user.fork_of(project))
end
+
+ {
+ forks_count: project.forks_count,
+ project_full_path: project.full_path,
+ project_forks_url: project_forks_path(project),
+ user_fork_url: user_fork_url,
+ new_fork_url: new_project_fork_path(project),
+ can_read_code: can?(current_user, :read_code, project).to_s,
+ can_fork_project: can?(current_user, :fork_project, project).to_s,
+ can_create_fork: can?(current_user, :create_fork).to_s
+ }
end
def import_from_bitbucket_message
@@ -551,6 +560,20 @@ module ProjectsHelper
project_settings_repository_path(@project, anchor: 'js-branch-rules')
end
+ def visibility_level_content(project, css_class: nil, icon_css_class: nil)
+ if project.created_and_owned_by_banned_user? && Feature.enabled?(:hide_projects_of_banned_users)
+ return hidden_resource_icon(project, css_class: css_class)
+ end
+
+ title = visibility_icon_description(project)
+ container_class = ['has-tooltip', css_class].compact.join(' ')
+ data = { container: 'body', placement: 'top' }
+
+ content_tag(:span, class: container_class, data: data, title: title) do
+ visibility_level_icon(project.visibility_level, options: { class: icon_css_class })
+ end
+ end
+
private
def can_admin_project_clusters?(project)
@@ -706,6 +729,7 @@ module ProjectsHelper
{
packagesEnabled: !!project.packages_enabled,
packageRegistryAccessLevel: feature.package_registry_access_level,
+ packageRegistryAllowAnyoneToPullOption: ::Gitlab::CurrentSettings.package_registry_allow_anyone_to_pull_option,
visibilityLevel: project.visibility_level,
requestAccessEnabled: !!project.request_access_enabled,
issuesAccessLevel: feature.issues_access_level,
@@ -719,7 +743,7 @@ module ProjectsHelper
analyticsAccessLevel: feature.analytics_access_level,
containerRegistryEnabled: !!project.container_registry_enabled,
lfsEnabled: !!project.lfs_enabled,
- emailsDisabled: project.emails_disabled?,
+ emailsEnabled: project.emails_enabled?,
monitorAccessLevel: feature.monitor_access_level,
showDefaultAwardEmojis: project.show_default_award_emojis?,
warnAboutPotentiallyUnwantedCharacters: project.warn_about_potentially_unwanted_characters?,
diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb
index 9ef347fff16..cf5cc92587f 100644
--- a/app/helpers/sessions_helper.rb
+++ b/app/helpers/sessions_helper.rb
@@ -40,10 +40,6 @@ module SessionsHelper
request.env['rack.session.options'][:expire_after] = expiry_s
end
- def send_rate_limited?(user)
- Gitlab::ApplicationRateLimiter.peek(:email_verification_code_send, scope: user)
- end
-
def obfuscated_email(email)
# Moved to Gitlab::Utils::Email in 15.9
Gitlab::Utils::Email.obfuscated_email(email)
@@ -52,4 +48,23 @@ module SessionsHelper
def remember_me_enabled?
Gitlab::CurrentSettings.remember_me_enabled?
end
+
+ def unconfirmed_verification_email?(user)
+ token_valid_from = ::Users::EmailVerification::ValidateTokenService::TOKEN_VALID_FOR_MINUTES.minutes.ago
+ user.email_reset_offered_at.nil? && user.pending_reconfirmation? && user.confirmation_sent_at >= token_valid_from
+ end
+
+ def verification_email(user)
+ unconfirmed_verification_email?(user) ? user.unconfirmed_email : user.email
+ end
+
+ def verification_data(user)
+ {
+ obfuscated_email: obfuscated_email(verification_email(user)),
+ verify_path: session_path(:user),
+ resend_path: users_resend_verification_code_path,
+ offer_email_reset: user.email_reset_offered_at.nil?.to_s,
+ update_email_path: users_update_email_path
+ }
+ end
end
diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb
index 90917cb96e0..1bd7da0a352 100644
--- a/app/helpers/sidebars_helper.rb
+++ b/app/helpers/sidebars_helper.rb
@@ -45,14 +45,37 @@ module SidebarsHelper
end
def super_sidebar_context(user, group:, project:, panel:, panel_type:) # rubocop:disable Metrics/AbcSize
+ return super_sidebar_logged_out_context(panel: panel, panel_type: panel_type) unless user
+
+ super_sidebar_logged_in_context(user, group: group, project: project, panel: panel, panel_type: panel_type)
+ end
+
+ def super_sidebar_logged_out_context(panel:, panel_type:) # rubocop:disable Metrics/AbcSize
{
+ is_logged_in: false,
+ context_switcher_links: context_switcher_links,
current_menu_items: panel.super_sidebar_menu_items,
current_context_header: panel.super_sidebar_context_header,
+ support_path: support_url,
+ display_whats_new: display_whats_new?,
+ whats_new_most_recent_release_items_count: whats_new_most_recent_release_items_count,
+ whats_new_version_digest: whats_new_version_digest,
+ show_version_check: show_version_check?,
+ gitlab_version: Gitlab.version_info,
+ gitlab_version_check: gitlab_version_check,
+ search: search_data,
+ panel_type: panel_type
+ }
+ end
+
+ def super_sidebar_logged_in_context(user, group:, project:, panel:, panel_type:) # rubocop:disable Metrics/AbcSize
+ super_sidebar_logged_out_context(panel: panel, panel_type: panel_type).merge({
+ is_logged_in: true,
name: user.name,
username: user.username,
avatar_url: user.avatar_url,
has_link_to_profile: current_user_menu?(:profile),
- link_to_profile: user_url(user),
+ link_to_profile: user_path(user),
logo_url: current_appearance&.header_logo_path,
status: user_status_menu_data(user),
settings: {
@@ -75,26 +98,16 @@ module SidebarsHelper
merge_request_menu: create_merge_request_menu(user),
projects_path: dashboard_projects_path,
groups_path: dashboard_groups_path,
- support_path: support_url,
- display_whats_new: display_whats_new?,
- whats_new_most_recent_release_items_count: whats_new_most_recent_release_items_count,
- whats_new_version_digest: whats_new_version_digest,
- show_version_check: show_version_check?,
- gitlab_version: Gitlab.version_info,
- gitlab_version_check: gitlab_version_check,
gitlab_com_but_not_canary: Gitlab.com_but_not_canary?,
gitlab_com_and_canary: Gitlab.com_and_canary?,
canary_toggle_com_url: Gitlab::Saas.canary_toggle_com_url,
current_context: super_sidebar_current_context(project: project, group: group),
- context_switcher_links: context_switcher_links,
- search: search_data,
pinned_items: user.pinned_nav_items[panel_type] || super_sidebar_default_pins(panel_type),
- panel_type: panel_type,
- update_pins_url: pins_url,
+ update_pins_url: pins_path,
is_impersonating: impersonating?,
stop_impersonation_path: admin_impersonation_path,
shortcut_links: shortcut_links(user, project: project)
- }
+ })
end
def super_sidebar_nav_panel(
@@ -331,8 +344,7 @@ module SidebarsHelper
def context_switcher_links
links = [
- # We should probably not return "You work" when used is not logged-in
- { title: s_('Navigation|Your work'), link: root_path, icon: 'work' },
+ ({ title: s_('Navigation|Your work'), link: root_path, icon: 'work' } if current_user),
{ title: s_('Navigation|Explore'), link: explore_root_path, icon: 'compass' }
]
@@ -368,7 +380,7 @@ module SidebarsHelper
end
# rubocop: enable Cop/UserAdmin
- links
+ links.compact
end
def impersonating?
diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb
index 2f9117a74be..31ce8317d51 100644
--- a/app/helpers/snippets_helper.rb
+++ b/app/helpers/snippets_helper.rb
@@ -45,36 +45,26 @@ module SnippetsHelper
def embedded_raw_snippet_button(snippet, blob)
return if blob.empty? || blob.binary? || blob.stored_externally?
- link_to(
- external_snippet_icon('doc-code'),
- gitlab_raw_snippet_blob_url(snippet, blob.path),
- class: 'gl-button btn btn-default',
- target: '_blank',
- rel: 'noopener noreferrer',
- title: 'Open raw'
- )
+ render Pajamas::ButtonComponent.new(href: gitlab_raw_snippet_blob_url(snippet, blob.path), target: '_blank',
+ button_options: { rel: 'noopener noreferrer', title: 'Open raw' }) do
+ external_snippet_icon('doc-code')
+ end
end
def embedded_snippet_download_button(snippet, blob)
- link_to(
- external_snippet_icon('download'),
- gitlab_raw_snippet_blob_url(snippet, blob.path, nil, inline: false),
- class: 'gl-button btn btn-default',
- target: '_blank',
- title: 'Download',
- rel: 'noopener noreferrer'
- )
+ render Pajamas::ButtonComponent.new(href: gitlab_raw_snippet_blob_url(snippet, blob.path, nil, inline: false),
+ target: '_blank', button_options: { rel: 'noopener noreferrer', title: 'Download' }) do
+ external_snippet_icon('download')
+ end
end
def embedded_copy_snippet_button(blob)
return unless blob.rendered_as_text?(ignore_errors: false)
- content_tag(
- :button,
- class: 'gl-button btn btn-default copy-to-clipboard-btn',
- title: 'Copy snippet contents',
- onclick: "copyToClipboard('.blob-content[data-blob-id=\"#{blob.id}\"] > pre')"
- ) do
+ button_options = { title: 'Copy snippet contents',
+ onclick: "copyToClipboard('.blob-content[data-blob-id=\"#{blob.id}\"] > pre')" }
+
+ render Pajamas::ButtonComponent.new(button_options: button_options) do
external_snippet_icon('copy-to-clipboard')
end
end
diff --git a/app/helpers/time_helper.rb b/app/helpers/time_helper.rb
index ad473875a53..0a5751c5221 100644
--- a/app/helpers/time_helper.rb
+++ b/app/helpers/time_helper.rb
@@ -1,20 +1,20 @@
# frozen_string_literal: true
module TimeHelper
+ TIME_UNIT_TRANSLATION = {
+ seconds: ->(seconds) { n_('%d second', '%d seconds', seconds) % seconds },
+ minutes: ->(minutes) { n_('%d minute', '%d minutes', minutes) % minutes },
+ hours: ->(hours) { n_('%d hour', '%d hours', hours) % hours },
+ days: ->(days) { n_('%d day', '%d days', days) % days },
+ weeks: ->(weeks) { n_('%d week', '%d weeks', weeks) % weeks },
+ months: ->(months) { n_('%d month', '%d months', months) % months },
+ years: ->(years) { n_('%d year', '%d years', years) % years }
+ }.freeze
+
def time_interval_in_words(interval_in_seconds)
- interval_in_seconds = interval_in_seconds.to_i
- minutes = interval_in_seconds / 60
- seconds = interval_in_seconds - minutes * 60
+ time_parts = ActiveSupport::Duration.build(interval_in_seconds.to_i).parts
- if minutes >= 1
- if seconds % 60 == 0
- n_('%d minute', '%d minutes', minutes) % minutes
- else
- [n_('%d minute', '%d minutes', minutes) % minutes, n_('%d second', '%d seconds', seconds) % seconds].to_sentence
- end
- else
- n_('%d second', '%d seconds', seconds) % seconds
- end
+ time_parts.map { |unit, value| TIME_UNIT_TRANSLATION[unit].call(value) }.to_sentence
end
def duration_in_numbers(duration_in_seconds)
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index 9b0810f3d17..4f17634f3e4 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -30,6 +30,7 @@ module TodosHelper
when Todo::MEMBER_ACCESS_REQUESTED then format(
s_("Todos|has requested access to %{what} %{which}"), what: _(todo.member_access_type), which: _(todo.target.name)
)
+ when Todo::REVIEW_SUBMITTED then s_('Todos|reviewed your merge request')
end
end
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index 84512453b7c..880fb8aa9d8 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -153,7 +153,8 @@ module TreeHelper
project_short_path: project.path,
ref: ref,
escaped_ref: ActionDispatch::Journey::Router::Utils.escape_path(ref),
- full_name: project.name_with_namespace
+ full_name: project.name_with_namespace,
+ ref_type: @ref_type
}
end
diff --git a/app/helpers/users/callouts_helper.rb b/app/helpers/users/callouts_helper.rb
index 0f4cbd6642b..12f78d9bd16 100644
--- a/app/helpers/users/callouts_helper.rb
+++ b/app/helpers/users/callouts_helper.rb
@@ -89,6 +89,8 @@ module Users
end
def gitlab_com_user_created_after_new_nav_rollout?
+ return true unless current_user
+
Gitlab.com? && current_user.created_at >= Date.new(2023, 6, 2)
end
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index 29998a996e2..ac279904fd2 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -104,6 +104,24 @@ module UsersHelper
Gitlab.config.gitlab.impersonation_enabled
end
+ def can_impersonate_user(user, impersonation_in_progress)
+ can?(user, :log_in) && !user.password_expired? && !impersonation_in_progress
+ end
+
+ def impersonation_error_text(user, impersonation_in_progress)
+ if impersonation_in_progress
+ _("You are already impersonating another user")
+ elsif user.blocked?
+ _("You cannot impersonate a blocked user")
+ elsif user.password_expired?
+ _("You cannot impersonate a user with an expired password")
+ elsif user.internal?
+ _("You cannot impersonate an internal user")
+ else
+ _("You cannot impersonate a user who cannot log in")
+ end
+ end
+
def user_badges_in_admin_section(user)
[].tap do |badges|
badges << blocked_user_badge(user) if user.blocked?
@@ -208,6 +226,24 @@ module UsersHelper
end
end
+ def user_profile_actions_data(user)
+ basic_actions_data = {
+ user_id: user.id
+ }
+
+ if can?(current_user, :read_user_profile, user)
+ basic_actions_data[:rss_subscription_path] = user_path(user, rss_url_options)
+ end
+
+ return basic_actions_data if !current_user || current_user == user
+
+ basic_actions_data.merge(
+ report_abuse_path: add_category_abuse_reports_path,
+ reported_user_id: user.id,
+ reported_from_url: user_url(user)
+ )
+ end
+
private
def admin_users_paths
diff --git a/app/mailers/devise_mailer.rb b/app/mailers/devise_mailer.rb
index 7129e577cb8..6a11aeeadb3 100644
--- a/app/mailers/devise_mailer.rb
+++ b/app/mailers/devise_mailer.rb
@@ -8,6 +8,7 @@ class DeviseMailer < Devise::Mailer
helper EmailsHelper
helper ApplicationHelper
+ helper RegistrationsHelper
def password_change_by_admin(record, opts = {})
devise_mail(record, :password_change_by_admin, opts)
@@ -22,6 +23,14 @@ class DeviseMailer < Devise::Mailer
super
end
+ def email_changed(record, opts = {})
+ if Gitlab.com?
+ devise_mail(record, :email_changed_gitlab_com, opts)
+ else
+ devise_mail(record, :email_changed, opts)
+ end
+ end
+
protected
def subject_for(key)
diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb
index 33c955f94ee..221d359c8c6 100644
--- a/app/mailers/emails/members.rb
+++ b/app/mailers/emails/members.rb
@@ -133,6 +133,22 @@ module Emails
subject: subject(subject))
end
+ def member_about_to_expire_email(member_source_type, member_id)
+ @member_source_type = member_source_type
+ @member_id = member_id
+
+ return unless member_exists?
+ return unless member.expires_at
+
+ @days_to_expire = (member.expires_at - Date.today).to_i
+
+ return if @days_to_expire <= 0
+
+ email_with_layout(
+ to: member.user.notification_email_for(notification_group),
+ subject: subject(s_("Your membership will expire in %{days_to_expire} days") % { days_to_expire: @days_to_expire }))
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def member
@member ||= Member.find_by(id: @member_id)
diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb
index 6678bb563ed..cd7869123f3 100644
--- a/app/mailers/emails/merge_requests.rb
+++ b/app/mailers/emails/merge_requests.rb
@@ -55,6 +55,7 @@ module Emails
@previous_reviewers = []
@previous_reviewers = User.where(id: previous_reviewer_ids) if previous_reviewer_ids.any?
+ @updated_by_user = User.find(updated_by_user_id)
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason))
end
@@ -186,5 +187,3 @@ module Emails
end
end
end
-
-Emails::MergeRequests.prepend_mod_with('Emails::MergeRequests')
diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb
index a382ca15e46..25d68d47228 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_with_locale(to: @user.notification_email_or_default, subject: subject("Account was created for you"))
+ email_with_layout(to: @user.notification_email_or_default, subject: subject("Account was created for you"))
end
def instance_access_request_email(user, recipient)
@@ -65,7 +65,7 @@ module Emails
@target_url = profile_personal_access_tokens_url
@token_name = token_name
- mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("A new personal access token has been created")))
+ email_with_layout(to: @user.notification_email_or_default, subject: subject(_("A new personal access token has been created")))
end
def access_token_about_to_expire_email(user, token_names)
@@ -107,7 +107,7 @@ module Emails
@fingerprints = fingerprints
@target_url = profile_keys_url
- mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your SSH key has expired")))
+ email_with_layout(to: @user.notification_email_or_default, subject: subject(_("Your SSH key has expired")))
end
def ssh_key_expiring_soon_email(user, fingerprints)
diff --git a/app/mailers/emails/reviews.rb b/app/mailers/emails/reviews.rb
index b98fa8aa6c9..ed1166509a5 100644
--- a/app/mailers/emails/reviews.rb
+++ b/app/mailers/emails/reviews.rb
@@ -19,17 +19,16 @@ module Emails
end
def setup_review_email(review_id, recipient_id)
- review = Review.find_by_id(review_id)
-
- @notes = review.notes
- @discussions = Discussion.build_discussions(review.discussion_ids, preload_note_diff_file: true)
+ @review = Review.find_by_id(review_id)
+ @notes = @review.notes
+ @discussions = Discussion.build_discussions(@review.discussion_ids, preload_note_diff_file: true)
@include_diff_discussion_stylesheet = @discussions.values.any? do |discussion|
discussion.diff_discussion? && discussion.on_text?
end
- @author = review.author
- @author_name = review.author_name
- @project = review.project
- @merge_request = review.merge_request
+ @author = @review.author
+ @author_name = @review.author_name
+ @project = @review.project
+ @merge_request = @review.merge_request
@target_url = project_merge_request_url(@project, @merge_request)
@sent_notification = SentNotification.record(@merge_request, recipient_id, reply_key)
end
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index 036a0fc012e..4180e76e1a0 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -39,6 +39,7 @@ class Notify < ApplicationMailer
helper GitlabRoutingHelper
helper IssuablesHelper
helper InProductMarketingHelper
+ helper RegistrationsHelper
def test_email(recipient_email, subject, body)
mail_with_locale(
diff --git a/app/mailers/previews/devise_mailer_preview.rb b/app/mailers/previews/devise_mailer_preview.rb
index 2919d466073..53f960e64a7 100644
--- a/app/mailers/previews/devise_mailer_preview.rb
+++ b/app/mailers/previews/devise_mailer_preview.rb
@@ -28,6 +28,10 @@ class DeviseMailerPreview < ActionMailer::Preview
DeviseMailer.user_admin_approval(unsaved_user, {})
end
+ def email_changed
+ DeviseMailer.email_changed(unsaved_user, {})
+ end
+
private
def unsaved_user
diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb
index 576dbdd8b52..f43f4511913 100644
--- a/app/mailers/previews/notify_preview.rb
+++ b/app/mailers/previews/notify_preview.rb
@@ -17,6 +17,10 @@ class NotifyPreview < ActionMailer::Preview
end
end
+ def new_user_email
+ Notify.new_user_email(user.id).message
+ end
+
def note_merge_request_email_for_discussion
note_email(:note_merge_request_email) do
note = <<-MD.strip_heredoc
@@ -73,6 +77,11 @@ class NotifyPreview < ActionMailer::Preview
Notify.access_token_revoked_email(user, 'token_name').message
end
+ def ssh_key_expired_email
+ fingerprints = []
+ Notify.ssh_key_expired_email(user, fingerprints).message
+ end
+
def new_mention_in_merge_request_email
Notify.new_mention_in_merge_request_email(user.id, merge_request.id, user.id).message
end
@@ -166,6 +175,13 @@ class NotifyPreview < ActionMailer::Preview
Notify.member_invited_email('project', member.id, '1234').message
end
+ def member_about_to_expire_email
+ cleanup do
+ member = project.add_member(user, Gitlab::Access::GUEST, expires_at: 7.days.from_now.to_date)
+ Notify.member_about_to_expire_email('project', member.id).message
+ end
+ end
+
def pages_domain_enabled_email
cleanup do
pages_domain = PagesDomain.new(domain: 'my.example.com', project: project, verified_at: Time.now, enabled_until: 1.week.from_now)
@@ -284,6 +300,13 @@ class NotifyPreview < ActionMailer::Preview
Notify.request_review_merge_request_email(user.id, merge_request.id, user.id).message
end
+ def new_review_email
+ review = Review.last
+ mr_author = review.merge_request.author
+
+ Notify.new_review_email(mr_author.id, review.id).message
+ end
+
def project_was_moved_email
Notify.project_was_moved_email(project.id, user.id, "gitlab/gitlab").message
end
diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb
index 1d2eee82827..75c90d370c3 100644
--- a/app/models/abuse_report.rb
+++ b/app/models/abuse_report.rb
@@ -18,6 +18,8 @@ class AbuseReport < ApplicationRecord
belongs_to :assignee, class_name: 'User', inverse_of: :assigned_abuse_reports
has_many :events, class_name: 'ResourceEvents::AbuseReportEvent', inverse_of: :abuse_report
+ has_many :label_links, as: :target, inverse_of: :target
+ has_many :labels, through: :label_links
has_many :abuse_events, class_name: 'Abuse::Event', inverse_of: :abuse_report
@@ -214,6 +216,24 @@ class AbuseReport < ApplicationRecord
extension_list: valid_image_extensions.to_sentence(last_word_connector: ' or '))
)
end
+
+ def self.aggregated_by_user_and_category(sort_by_count = false)
+ sub_query = self
+ .select('user_id, category, COUNT(id) as count', 'MIN(id) as min')
+ .group(:user_id, :category)
+
+ reports = AbuseReport.with_users
+ .open
+ .select('aggregated.*, status, id, reporter_id, created_at, updated_at')
+ .from(sub_query, :aggregated)
+ .joins('INNER JOIN abuse_reports on aggregated.min = abuse_reports.id')
+
+ if sort_by_count
+ reports.order(count: :desc, created_at: :desc)
+ else
+ reports
+ end
+ end
end
AbuseReport.prepend_mod
diff --git a/app/models/admin/abuse_report_label.rb b/app/models/admin/abuse_report_label.rb
new file mode 100644
index 00000000000..a2ccc8b5513
--- /dev/null
+++ b/app/models/admin/abuse_report_label.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+module Admin
+ class AbuseReportLabel < Label
+ end
+end
diff --git a/app/models/ai/service_access_token.rb b/app/models/ai/service_access_token.rb
index 863bdfc7899..b8a2a271976 100644
--- a/app/models/ai/service_access_token.rb
+++ b/app/models/ai/service_access_token.rb
@@ -5,6 +5,7 @@ module Ai
self.table_name = 'service_access_tokens'
scope :expired, -> { where('expires_at < :now', now: Time.current) }
+ scope :active, -> { where('expires_at > :now', now: Time.current) }
scope :for_category, ->(category) { where(category: category) }
attr_encrypted :token,
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
index 291375f647c..7058bfd5650 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -6,6 +6,7 @@ class ApplicationRecord < ActiveRecord::Base
include LegacyBulkInsert
include CrossDatabaseModification
include SensitiveSerializableHash
+ include ResetOnUnionError
self.abstract_class = true
@@ -95,7 +96,7 @@ class ApplicationRecord < ActiveRecord::Base
end
def self.underscore
- Gitlab::SafeRequestStore.fetch("model:#{self}:underscore") { to_s.underscore }
+ @underscore ||= to_s.underscore
end
def self.where_exists(query)
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 827f8bc93be..f67efaf4f58 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -39,6 +39,12 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
encrypted_tofa_url_iv
vertex_project
], remove_with: '16.3', remove_after: '2023-07-22'
+ ignore_column :database_apdex_settings, remove_with: '16.4', remove_after: '2023-08-22'
+ ignore_columns %i[
+ dashboard_notification_limit
+ dashboard_enforcement_limit
+ dashboard_limit_new_namespace_creation_enforcement_date
+ ], remove_with: '16.5', remove_after: '2023-08-22'
INSTANCE_REVIEW_MIN_USERS = 50
GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \
@@ -254,6 +260,18 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validates :max_import_remote_file_size,
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
+ validates :bulk_import_max_download_file_size,
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
+ validates :max_decompressed_archive_size,
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
validates :max_pages_size,
presence: true,
numericality: {
@@ -407,6 +425,10 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') },
allow_nil: false
+ validates :protected_paths_for_get_request,
+ length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') },
+ allow_nil: false
+
validates :push_event_hooks_limit,
numericality: { greater_than_or_equal_to: 0 }
@@ -419,6 +441,8 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
validates :max_yaml_size_bytes, numericality: { only_integer: true, greater_than: 0 }, presence: true
validates :max_yaml_depth, numericality: { only_integer: true, greater_than: 0 }, presence: true
+ validates :ci_max_total_yaml_size_bytes, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, presence: true
+
validates :ci_max_includes, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, presence: true
validates :email_restrictions, untrusted_regexp: true
@@ -498,6 +522,10 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
end
end
+ validates :default_project_visibility, :default_group_visibility,
+ exclusion: { in: :restricted_visibility_levels, message: "cannot be set to a restricted visibility level" },
+ if: :should_prevent_visibility_restriction?
+
validates_each :import_sources do |record, attr, value|
value&.each do |source|
unless Gitlab::ImportSources.options.value?(source)
@@ -712,18 +740,21 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
validates :inactive_projects_send_warning_email_after_months,
numericality: { only_integer: true, greater_than: 0, less_than: :inactive_projects_delete_after_months }
- validates :database_apdex_settings, json_schema: { filename: 'application_setting_database_apdex_settings' }, allow_nil: true
+ validates :prometheus_alert_db_indicators_settings, json_schema: { filename: 'application_setting_prometheus_alert_db_indicators_settings' }, allow_nil: true
validates :namespace_aggregation_schedule_lease_duration_in_seconds,
numericality: { only_integer: true, greater_than: 0 }
+ validates :sentry_clientside_traces_sample_rate,
+ presence: true,
+ numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1, message: N_('must be a value between 0 and 1') }
+
validates :instance_level_code_suggestions_enabled,
allow_nil: false,
inclusion: { in: [true, false], message: N_('must be a boolean value') }
- validates :ai_access_token,
- presence: { message: N_("is required to enable Code Suggestions") },
- if: :instance_level_code_suggestions_enabled
+ validates :package_registry_allow_anyone_to_pull_option,
+ inclusion: { in: [true, false], message: N_('must be a boolean value') }
attr_encrypted :asset_proxy_secret_key,
mode: :per_attribute_iv,
@@ -951,7 +982,13 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
def reset_deletion_warning_redis_key
Gitlab::InactiveProjectsDeletionWarningTracker.reset_all
end
+
+ def should_prevent_visibility_restriction?
+ Feature.enabled?(:prevent_visibility_restriction) &&
+ (default_project_visibility_changed? ||
+ default_group_visibility_changed? ||
+ restricted_visibility_levels_changed?)
+ end
end
-ApplicationSetting.prepend(ApplicationSettingMaskedAttrs)
ApplicationSetting.prepend_mod_with('ApplicationSetting')
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index 81e816a5b7c..f6bf535158a 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -45,6 +45,7 @@ module ApplicationSettingImplementation
allow_possible_spam: false,
asset_proxy_enabled: false,
authorized_keys_enabled: true, # TODO default to false if the instance is configured to use AuthorizedKeysCommand
+ ci_max_total_yaml_size_bytes: 157286400, # max_yaml_size_bytes * ci_max_includes = 1.megabyte * 150
commit_email_hostname: default_commit_email_hostname,
container_expiration_policies_enable_historic_entries: false,
container_registry_features: [],
@@ -61,6 +62,7 @@ module ApplicationSettingImplementation
default_project_visibility: Settings.gitlab.default_projects_features['visibility_level'],
default_projects_limit: Settings.gitlab['default_projects_limit'],
default_snippet_visibility: Settings.gitlab.default_projects_features['visibility_level'],
+ default_syntax_highlighting_theme: 1,
deny_all_requests_except_allowed: false,
diff_max_patch_bytes: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES,
diff_max_files: Commit::DEFAULT_MAX_DIFF_FILES_SETTING,
@@ -119,6 +121,8 @@ module ApplicationSettingImplementation
max_attachment_size: Settings.gitlab['max_attachment_size'],
max_export_size: 0,
max_import_size: 0,
+ max_import_remote_file_size: 10240,
+ max_decompressed_archive_size: 25600,
max_terraform_state_size_bytes: 0,
max_yaml_size_bytes: 1.megabyte,
max_yaml_depth: 100,
@@ -254,6 +258,7 @@ module ApplicationSettingImplementation
users_get_by_id_limit_allowlist: [],
can_create_group: true,
bulk_import_enabled: false,
+ bulk_import_max_download_file_size: 5120,
allow_runner_registration_token: true,
user_defaults_to_private_profile: false,
projects_api_rate_limit_unauthenticated: 400,
diff --git a/app/models/authentication_event.rb b/app/models/authentication_event.rb
index a70ebb42008..e9fe49f980d 100644
--- a/app/models/authentication_event.rb
+++ b/app/models/authentication_event.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class AuthenticationEvent < ApplicationRecord
+class AuthenticationEvent < MainClusterwide::ApplicationRecord
include UsageStatistics
TWO_FACTOR = 'two-factor'
diff --git a/app/models/batched_git_ref_updates/deletion.rb b/app/models/batched_git_ref_updates/deletion.rb
new file mode 100644
index 00000000000..61bba8aeba9
--- /dev/null
+++ b/app/models/batched_git_ref_updates/deletion.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module BatchedGitRefUpdates
+ class Deletion < ApplicationRecord
+ PARTITION_DURATION = 1.day
+
+ include IgnorableColumns
+ include BulkInsertSafe
+ include PartitionedTable
+ include EachBatch
+
+ self.table_name = 'p_batched_git_ref_updates_deletions'
+ self.primary_key = :id
+ self.sequence_name = :to_be_deleted_git_refs_id_seq
+
+ # This column must be ignored otherwise Rails will cache the default value and `bulk_insert!` will start saving
+ # incorrect partition_id.
+ ignore_column :partition_id, remove_with: '3000.0', remove_after: '3000-01-01'
+
+ belongs_to :project, inverse_of: :to_be_deleted_git_refs
+
+ scope :for_partition, ->(partition) { where(partition_id: partition) }
+ scope :for_project, ->(project_id) { where(project_id: project_id) }
+ scope :select_ref_and_identity, -> { select(:ref, :id, arel_table[:partition_id].as('partition')) }
+
+ partitioned_by :partition_id, strategy: :sliding_list,
+ next_partition_if: ->(active_partition) do
+ oldest_record_in_partition = Deletion
+ .select(:id, :created_at)
+ .for_partition(active_partition.value)
+ .order(:id)
+ .limit(1)
+ .take
+
+ oldest_record_in_partition.present? &&
+ oldest_record_in_partition.created_at < PARTITION_DURATION.ago
+ end,
+ detach_partition_if: ->(partition) do
+ !Deletion
+ .for_partition(partition.value)
+ .status_pending
+ .exists?
+ end
+
+ enum status: { pending: 1, processed: 2 }, _prefix: :status
+
+ def self.mark_records_processed(records)
+ update_by_partition(records) do |partitioned_scope|
+ partitioned_scope.update_all(status: :processed)
+ end
+ end
+
+ # Your scope must select_ref_and_identity before calling this method as it relies on partition being explicitly
+ # selected
+ def self.update_by_partition(records)
+ records.group_by(&:partition).each do |partition, records_within_partition|
+ partitioned_scope = status_pending
+ .for_partition(partition)
+ .where(id: records_within_partition.map(&:id))
+
+ yield(partitioned_scope)
+ end
+ end
+
+ private_class_method :update_by_partition
+ end
+end
diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb
deleted file mode 100644
index ccc5ca7395d..00000000000
--- a/app/models/broadcast_message.rb
+++ /dev/null
@@ -1,163 +0,0 @@
-# frozen_string_literal: true
-
-class BroadcastMessage < MainClusterwide::ApplicationRecord
- include CacheMarkdownField
- include Sortable
-
- ALLOWED_TARGET_ACCESS_LEVELS = [
- Gitlab::Access::GUEST,
- Gitlab::Access::REPORTER,
- Gitlab::Access::DEVELOPER,
- Gitlab::Access::MAINTAINER,
- Gitlab::Access::OWNER
- ].freeze
-
- cache_markdown_field :message, pipeline: :broadcast_message, whitelisted: true
-
- validates :message, presence: true
- validates :starts_at, presence: true
- validates :ends_at, presence: true
- validates :broadcast_type, presence: true
- validates :target_access_levels, inclusion: { in: ALLOWED_TARGET_ACCESS_LEVELS }
- validates :show_in_cli, allow_nil: false, inclusion: { in: [true, false], message: N_('must be a boolean value') }
-
- validates :color, allow_blank: true, color: true
- validates :font, allow_blank: true, color: true
-
- attribute :color, default: '#E75E40'
- attribute :font, default: '#FFFFFF'
-
- scope :current_and_future_messages, -> { where('ends_at > :now', now: Time.current).order_id_asc }
-
- CACHE_KEY = 'broadcast_message_current_json'
- BANNER_CACHE_KEY = 'broadcast_message_current_banner_json'
- NOTIFICATION_CACHE_KEY = 'broadcast_message_current_notification_json'
-
- after_commit :flush_redis_cache
-
- enum theme: {
- indigo: 0,
- 'light-indigo': 1,
- blue: 2,
- 'light-blue': 3,
- green: 4,
- 'light-green': 5,
- red: 6,
- 'light-red': 7,
- dark: 8,
- light: 9
- }, _default: 0, _prefix: true
-
- enum broadcast_type: {
- banner: 1,
- notification: 2
- }
-
- class << self
- def current_banner_messages(current_path: nil, user_access_level: nil)
- fetch_messages BANNER_CACHE_KEY, current_path, user_access_level do
- current_and_future_messages.banner
- end
- end
-
- def current_show_in_cli_banner_messages
- current_banner_messages.select(&:show_in_cli?)
- end
-
- def current_notification_messages(current_path: nil, user_access_level: nil)
- fetch_messages NOTIFICATION_CACHE_KEY, current_path, user_access_level do
- current_and_future_messages.notification
- end
- end
-
- def current(current_path: nil, user_access_level: nil)
- fetch_messages CACHE_KEY, current_path, user_access_level do
- current_and_future_messages
- end
- end
-
- def cache
- ::Gitlab::SafeRequestStore.fetch(:broadcast_message_json_cache) do
- Gitlab::Cache::JsonCaches::JsonKeyed.new
- end
- end
-
- def cache_expires_in
- 2.weeks
- end
-
- private
-
- def fetch_messages(cache_key, current_path, user_access_level, &block)
- messages = cache.fetch(cache_key, as: BroadcastMessage, expires_in: cache_expires_in, &block)
-
- now_or_future = messages.select(&:now_or_future?)
-
- # If there are cached entries but they don't match the ones we are
- # displaying we'll refresh the cache so we don't need to keep filtering.
- cache.expire(cache_key) if now_or_future != messages
-
- messages = now_or_future.select(&:now?)
- messages = messages.select do |message|
- message.matches_current_user_access_level?(user_access_level)
- end
- messages.select do |message|
- message.matches_current_path(current_path)
- end
- end
- end
-
- def active?
- started? && !ended?
- end
-
- def started?
- Time.current >= starts_at
- end
-
- def ended?
- ends_at < Time.current
- end
-
- def now?
- (starts_at..ends_at).cover?(Time.current)
- end
-
- def future?
- starts_at > Time.current
- end
-
- def now_or_future?
- now? || future?
- end
-
- def matches_current_user_access_level?(user_access_level)
- return true unless target_access_levels.present?
-
- target_access_levels.include? user_access_level
- end
-
- def matches_current_path(current_path)
- return false if current_path.blank? && target_path.present?
- return true if current_path.blank? || target_path.blank?
-
- # Ensure paths are consistent across callers.
- # This fixes a mismatch between requests in the GUI and CLI
- #
- # This has to be reassigned due to frozen strings being provided.
- current_path = "/#{current_path}" unless current_path.start_with?("/")
-
- escaped = Regexp.escape(target_path).gsub('\\*', '.*')
- regexp = Regexp.new "^#{escaped}$", Regexp::IGNORECASE
-
- regexp.match(current_path)
- end
-
- def flush_redis_cache
- [CACHE_KEY, BANNER_CACHE_KEY, NOTIFICATION_CACHE_KEY].each do |key|
- self.class.cache.expire(key)
- end
- end
-end
-
-BroadcastMessage.prepend_mod_with('BroadcastMessage')
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index 5052d84378f..d0ccf5c543a 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -3,7 +3,7 @@
module Ci
class Bridge < Ci::Processable
include Ci::Contextable
- include Ci::Metadatable
+ include Ci::Deployable
include Importable
include AfterCommitQueue
include Ci::HasRef
@@ -71,7 +71,7 @@ module Ci
def self.clone_accessors
%i[pipeline project ref tag options name
allow_failure stage stage_idx
- yaml_variables when description needs_attributes
+ yaml_variables when environment description needs_attributes
scheduling_type ci_stage partition_id].freeze
end
@@ -180,20 +180,6 @@ module Ci
false
end
- def outdated_deployment?
- false
- end
-
- def expanded_environment_name
- end
-
- def persisted_environment
- end
-
- def deployment_job?
- false
- end
-
def execute_hooks
raise NotImplementedError
end
@@ -266,6 +252,12 @@ module Ci
end
end
+ def expand_file_refs?
+ strong_memoize(:expand_file_refs) do
+ !Feature.enabled?(:ci_prevent_file_var_expansion_downstream_pipeline, project)
+ end
+ end
+
private
def cross_project_params
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index bb1bfe8c889..7a623b0cefb 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -3,8 +3,8 @@
module Ci
class Build < Ci::Processable
prepend Ci::BulkInsertableTags
- include Ci::Metadatable
include Ci::Contextable
+ include Ci::Deployable
include TokenAuthenticatable
include AfterCommitQueue
include Presentable
@@ -34,7 +34,6 @@ module Ci
DEPLOYMENT_NAMES = %w[deploy release rollout].freeze
- has_one :deployment, as: :deployable, class_name: 'Deployment', inverse_of: :deployable
has_one :pending_state, class_name: 'Ci::BuildPendingState', foreign_key: :build_id, inverse_of: :build
has_one :queuing_entry, class_name: 'Ci::PendingBuild', foreign_key: :build_id, inverse_of: :build
has_one :runtime_metadata, class_name: 'Ci::RunningBuild', foreign_key: :build_id, inverse_of: :build
@@ -158,16 +157,9 @@ module Ci
.includes(:metadata, :job_artifacts_metadata)
end
- scope :with_project_and_metadata, -> do
- if Feature.enabled?(:non_public_artifacts, type: :development)
- joins(:metadata).includes(:metadata).preload(:project)
- end
- end
-
scope :with_artifacts_not_expired, -> { with_downloadable_artifacts.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.current) }
scope :with_pipeline_locked_artifacts, -> { joins(:pipeline).where('pipeline.locked': Ci::Pipeline.lockeds[:artifacts_locked]) }
scope :last_month, -> { where('created_at > ?', Date.today - 1.month) }
- scope :manual_actions, -> { where(when: :manual, status: COMPLETED_STATUSES + %i[manual]) }
scope :scheduled_actions, -> { where(when: :delayed, status: COMPLETED_STATUSES + %i[scheduled]) }
scope :ref_protected, -> { where(protected: true) }
scope :with_live_trace, -> { where('EXISTS (?)', Ci::BuildTraceChunk.where("#{quoted_table_name}.id = #{Ci::BuildTraceChunk.quoted_table_name}.build_id").select(1)) }
@@ -327,7 +319,6 @@ module Ci
after_transition any => [:success] do |build|
build.run_after_commit do
- BuildSuccessWorker.perform_async(id)
PagesWorker.perform_async(:deploy, id) if build.pages_generator?
end
end
@@ -345,18 +336,6 @@ module Ci
end
end
end
-
- # Synchronize Deployment Status
- # Please note that the data integirty is not assured because we can't use
- # a database transaction due to DB decomposition.
- after_transition do |build, transition|
- next if transition.loopback?
- next unless build.project
-
- build.run_after_commit do
- build.deployment&.sync_status_with(build)
- end
- end
end
def self.build_matchers(project)
@@ -400,10 +379,6 @@ module Ci
.fabricate!
end
- def other_manual_actions
- pipeline.manual_actions.reject { |action| action.name == name }
- end
-
def other_scheduled_actions
pipeline.scheduled_actions.reject { |action| action.name == name }
end
@@ -428,15 +403,6 @@ module Ci
action? && !archived? && (manual? || scheduled? || retryable?)
end
- def outdated_deployment?
- strong_memoize(:outdated_deployment) do
- deployment_job? &&
- incomplete? &&
- project.ci_forward_deployment_enabled? &&
- deployment&.older_than_last_successful_deployment?
- end
- end
-
def schedulable?
self.when == 'delayed' && options[:start_in].present?
end
@@ -478,94 +444,10 @@ module Ci
Gitlab::Ci::Build::Prerequisite::Factory.new(self).unmet
end
- def persisted_environment
- return unless has_environment_keyword?
-
- strong_memoize(:persisted_environment) do
- # This code path has caused N+1s in the past, since environments are only indirectly
- # associated to builds and pipelines; see https://gitlab.com/gitlab-org/gitlab/-/issues/326445
- # We therefore batch-load them to prevent dormant N+1s until we found a proper solution.
- BatchLoader.for(expanded_environment_name).batch(key: project_id) do |names, loader, args|
- Environment.where(name: names, project: args[:key]).find_each do |environment|
- loader.call(environment.name, environment)
- end
- end
- end
- end
-
- def persisted_environment=(environment)
- strong_memoize(:persisted_environment) { environment }
- end
-
- # If build.persisted_environment is a BatchLoader, we need to remove
- # the method proxy in order to clone into new item here
- # https://github.com/exAspArk/batch-loader/issues/31
- def actual_persisted_environment
- persisted_environment.respond_to?(:__sync) ? persisted_environment.__sync : persisted_environment
- end
-
- def expanded_environment_name
- return unless has_environment_keyword?
-
- strong_memoize(:expanded_environment_name) do
- # We're using a persisted expanded environment name in order to avoid
- # variable expansion per request.
- if metadata&.expanded_environment_name.present?
- metadata.expanded_environment_name
- else
- ExpandVariables.expand(environment, -> { simple_variables.sort_and_expand_all })
- end
- end
- end
-
- def expanded_kubernetes_namespace
- return unless has_environment_keyword?
-
- namespace = options.dig(:environment, :kubernetes, :namespace)
-
- if namespace.present?
- strong_memoize(:expanded_kubernetes_namespace) do
- ExpandVariables.expand(namespace, -> { simple_variables })
- end
- end
- end
-
- def has_environment_keyword?
- environment.present?
- end
-
- def deployment_job?
- has_environment_keyword? && environment_action == 'start'
- end
-
- def stops_environment?
- has_environment_keyword? && environment_action == 'stop'
- end
-
- def environment_action
- options.fetch(:environment, {}).fetch(:action, 'start') if options
- end
-
- def environment_tier_from_options
- options.dig(:environment, :deployment_tier) if options
- end
-
- def environment_tier
- environment_tier_from_options || persisted_environment.try(:tier)
- end
-
def triggered_by?(current_user)
user == current_user
end
- def on_stop
- options&.dig(:environment, :on_stop)
- end
-
- def stop_action_successful?
- success?
- end
-
##
# All variables, including persisted environment variables.
#
@@ -649,9 +531,8 @@ module Ci
def google_play_variables
return [] unless google_play_integration.try(:activated?)
- return [] unless pipeline.protected_ref?
- Gitlab::Ci::Variables::Collection.new(google_play_integration.ci_variables)
+ Gitlab::Ci::Variables::Collection.new(google_play_integration.ci_variables(protected_ref: pipeline.protected_ref?))
end
def features
@@ -1033,19 +914,6 @@ module Ci
job_artifacts.all_reports
end
- # Virtual deployment status depending on the environment status.
- def deployment_status
- return unless deployment_job?
-
- if success?
- return successful_deployment_status
- elsif failed?
- return :failed
- end
-
- :creating
- end
-
# Consider this object to have a structural integrity problems
def doom!
transaction do
@@ -1206,31 +1074,11 @@ module Ci
strong_memoize(:build_data) { Gitlab::DataBuilder::Build.build(self) }
end
- def successful_deployment_status
- if deployment&.last?
- :last
- else
- :out_of_date
- end
- end
-
def job_artifacts_for_types(report_types)
# Use select to leverage cached associations and avoid N+1 queries
job_artifacts.select { |artifact| artifact.file_type.in?(report_types) }
end
- def environment_url
- options&.dig(:environment, :url) || persisted_environment&.external_url
- end
-
- def environment_status
- strong_memoize(:environment_status) do
- if has_environment_keyword? && merge_request
- EnvironmentStatus.new(project, persisted_environment, merge_request, pipeline.sha)
- end
- end
- end
-
def has_expiring_artifacts?
artifacts_expire_at.present? && artifacts_expire_at > Time.current
end
diff --git a/app/models/ci/catalog/resource.rb b/app/models/ci/catalog/resource.rb
index 38603ddfe59..799cdce4af7 100644
--- a/app/models/ci/catalog/resource.rb
+++ b/app/models/ci/catalog/resource.rb
@@ -11,6 +11,8 @@ module Ci
self.table_name = 'catalog_resources'
belongs_to :project
+ has_many :components, class_name: 'Ci::Catalog::Resources::Component', inverse_of: :catalog_resource
+ has_many :versions, class_name: 'Ci::Catalog::Resources::Version', inverse_of: :catalog_resource
scope :for_projects, ->(project_ids) { where(project_id: project_ids) }
scope :order_by_created_at_desc, -> { reorder(created_at: :desc) }
diff --git a/app/models/ci/catalog/resources/component.rb b/app/models/ci/catalog/resources/component.rb
new file mode 100644
index 00000000000..7b95c14ba7e
--- /dev/null
+++ b/app/models/ci/catalog/resources/component.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Ci
+ module Catalog
+ module Resources
+ # This class represents a CI/CD Catalog resource component.
+ # The data will be used as metadata of a component.
+ class Component < ::ApplicationRecord
+ self.table_name = 'catalog_resource_components'
+
+ belongs_to :project, inverse_of: :ci_components
+ belongs_to :catalog_resource, class_name: 'Ci::Catalog::Resource', inverse_of: :components
+ belongs_to :version, class_name: 'Ci::Catalog::Resources::Version', inverse_of: :components
+
+ enum resource_type: { template: 1 }
+
+ validates :inputs, json_schema: { filename: 'catalog_resource_component_inputs' }
+ validates :version, :catalog_resource, :project, :name, presence: true
+ end
+ end
+ end
+end
+
+Ci::Catalog::Resources::Component.prepend_mod_with('Ci::Catalog::Resources::Component')
diff --git a/app/models/ci/catalog/resources/version.rb b/app/models/ci/catalog/resources/version.rb
new file mode 100644
index 00000000000..68f60e6a965
--- /dev/null
+++ b/app/models/ci/catalog/resources/version.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Ci
+ module Catalog
+ module Resources
+ # This class represents a CI/CD Catalog resource version.
+ # Only versions which contain valid CI components are included in this table.
+ class Version < ::ApplicationRecord
+ self.table_name = 'catalog_resource_versions'
+
+ belongs_to :release, inverse_of: :catalog_resource_version
+ belongs_to :catalog_resource, class_name: 'Ci::Catalog::Resource', inverse_of: :versions
+ belongs_to :project, inverse_of: :catalog_resource_versions
+ has_many :components, class_name: 'Ci::Catalog::Resources::Component', inverse_of: :version
+
+ validates :release, :catalog_resource, :project, presence: true
+ end
+ end
+ end
+end
+
+Ci::Catalog::Resources::Version.prepend_mod_with('Ci::Catalog::Resources::Version')
diff --git a/app/models/ci/job_annotation.rb b/app/models/ci/job_annotation.rb
index a8bef02cc42..a6ce4196cc1 100644
--- a/app/models/ci/job_annotation.rb
+++ b/app/models/ci/job_annotation.rb
@@ -3,6 +3,7 @@
module Ci
class JobAnnotation < Ci::ApplicationRecord
include Ci::Partitionable
+ include BulkInsertSafe
self.table_name = :p_ci_job_annotations
self.primary_key = :id
@@ -13,7 +14,6 @@ module Ci
validates :data, json_schema: { filename: 'ci_job_annotation_data' }
validates :name, presence: true,
- length: { maximum: 255 },
- uniqueness: { scope: [:job_id, :partition_id] }
+ length: { maximum: 255 }
end
end
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index 11d70e088e9..3f9d8f07b06 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -60,7 +60,8 @@ module Ci
requirements_v2: 'requirements_v2.json',
coverage_fuzzing: 'gl-coverage-fuzzing.json',
api_fuzzing: 'gl-api-fuzzing-report.json',
- cyclonedx: 'gl-sbom.cdx.json'
+ cyclonedx: 'gl-sbom.cdx.json',
+ annotations: 'gl-annotations.json'
}.freeze
INTERNAL_TYPES = {
@@ -79,6 +80,7 @@ module Ci
cluster_applications: :gzip, # DEPRECATED: https://gitlab.com/gitlab-org/gitlab/-/issues/361094
lsif: :zip,
cyclonedx: :gzip,
+ annotations: :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.
@@ -221,7 +223,8 @@ module Ci
api_fuzzing: 26, ## EE-specific
cluster_image_scanning: 27, ## EE-specific
cyclonedx: 28, ## EE-specific
- requirements_v2: 29 ## EE-specific
+ requirements_v2: 29, ## EE-specific
+ annotations: 30
}
# `file_location` indicates where actual files are stored.
@@ -341,10 +344,16 @@ module Ci
end
def to_deleted_object_attrs(pick_up_at = nil)
+ final_path_store_dir, final_path_filename = nil
+ if file_final_path.present?
+ final_path_store_dir = File.dirname(file_final_path)
+ final_path_filename = File.basename(file_final_path)
+ end
+
{
file_store: file_store,
- store_dir: file.store_dir.to_s,
- file: file_identifier,
+ store_dir: final_path_store_dir || file.store_dir.to_s,
+ file: final_path_filename || file_identifier,
pick_up_at: pick_up_at || expire_at || Time.current
}
end
diff --git a/app/models/ci/job_token/project_scope_link.rb b/app/models/ci/job_token/project_scope_link.rb
index 96e370bba1e..14c7ee14e71 100644
--- a/app/models/ci/job_token/project_scope_link.rb
+++ b/app/models/ci/job_token/project_scope_link.rb
@@ -8,7 +8,7 @@ module Ci
class ProjectScopeLink < Ci::ApplicationRecord
self.table_name = 'ci_job_token_project_scope_links'
- PROJECT_LINK_DIRECTIONAL_LIMIT = 100
+ PROJECT_LINK_DIRECTIONAL_LIMIT = 200
belongs_to :source_project, class_name: 'Project'
# the project added to the scope's allowlist
diff --git a/app/models/ci/persistent_ref.rb b/app/models/ci/persistent_ref.rb
index f713d5952bc..57e2d943a4c 100644
--- a/app/models/ci/persistent_ref.rb
+++ b/app/models/ci/persistent_ref.rb
@@ -11,7 +11,7 @@ module Ci
delegate :project, :sha, to: :pipeline
delegate :repository, to: :project
- delegate :ref_exists?, :create_ref, :delete_refs, to: :repository
+ delegate :ref_exists?, :create_ref, :delete_refs, :async_delete_refs, to: :repository
def exist?
ref_exists?(path)
@@ -42,6 +42,12 @@ module Ci
.track_exception(e, pipeline_id: pipeline.id)
end
+ def async_delete
+ return unless should_delete?
+
+ async_delete_refs(path)
+ end
+
def path
"refs/#{Repository::REF_PIPELINES}/#{pipeline.id}"
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index bd327cfbe7b..3a5db04a687 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -23,6 +23,7 @@ module Ci
include IgnorableColumns
ignore_column :id_convert_to_bigint, remove_with: '16.3', remove_after: '2023-08-22'
+ ignore_column :auto_canceled_by_id_convert_to_bigint, remove_with: '16.6', remove_after: '2023-10-22'
MAX_OPEN_MERGE_REQUESTS_REFS = 4
@@ -99,7 +100,7 @@ module Ci
has_many :downloadable_artifacts, -> do
not_expired.or(where_exists(Ci::Pipeline.artifacts_locked.where("#{Ci::Pipeline.quoted_table_name}.id = #{Ci::Build.quoted_table_name}.commit_id"))).downloadable.with_job
end, through: :latest_builds, source: :job_artifacts
- has_many :latest_successful_builds, -> { latest.success.with_project_and_metadata }, foreign_key: :commit_id, inverse_of: :pipeline, class_name: 'Ci::Build'
+ has_many :latest_successful_jobs, -> { latest.success.with_project_and_metadata }, foreign_key: :commit_id, inverse_of: :pipeline, class_name: 'Ci::Processable'
has_many :messages, class_name: 'Ci::PipelineMessage', inverse_of: :pipeline
@@ -114,7 +115,7 @@ module Ci
has_many :retryable_builds, -> { latest.failed_or_canceled.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline
has_many :cancelable_statuses, -> { cancelable }, foreign_key: :commit_id, class_name: 'CommitStatus',
inverse_of: :pipeline
- has_many :manual_actions, -> { latest.manual_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline
+ has_many :manual_actions, -> { latest.manual_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Processable', inverse_of: :pipeline
has_many :scheduled_actions, -> { latest.scheduled_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline
has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: :auto_canceled_by_id,
@@ -341,7 +342,9 @@ module Ci
# This needs to be kept in sync with `Ci::PipelineRef#should_delete?`
after_transition any => ::Ci::Pipeline.stopped_statuses do |pipeline|
pipeline.run_after_commit do
- if Feature.enabled?(:pipeline_cleanup_ref_worker_async, pipeline.project)
+ if Feature.enabled?(:pipeline_delete_gitaly_refs_in_batches, pipeline.project)
+ pipeline.persistent_ref.async_delete
+ elsif Feature.enabled?(:pipeline_cleanup_ref_worker_async, pipeline.project)
::Ci::PipelineCleanupRefWorker.perform_async(pipeline.id)
else
pipeline.persistent_ref.delete
@@ -409,6 +412,7 @@ module Ci
joins(:pipeline_metadata).where(name_column.eq(name))
end
+ scope :for_status, -> (status) { where(status: status) }
scope :created_after, -> (time) { where(arel_table[:created_at].gt(time)) }
scope :created_before_id, -> (id) { where(arel_table[:id].lt(id)) }
scope :before_pipeline, -> (pipeline) { created_before_id(pipeline.id).outside_pipeline_family(pipeline) }
@@ -960,11 +964,15 @@ module Ci
Ci::Bridge.latest.where(pipeline: self_and_project_descendants)
end
+ def jobs_in_self_and_project_descendants
+ Ci::Processable.latest.where(pipeline: self_and_project_descendants)
+ end
+
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_project_descendants.joins(:metadata)
+ jobs_in_self_and_project_descendants.joins(:metadata)
.where.not(Ci::BuildMetadata.table_name => { expanded_environment_name: nil })
.distinct("#{Ci::BuildMetadata.quoted_table_name}.expanded_environment_name")
.limit(100)
diff --git a/app/models/ci/pipeline_chat_data.rb b/app/models/ci/pipeline_chat_data.rb
index ba20c993e36..37916c0b302 100644
--- a/app/models/ci/pipeline_chat_data.rb
+++ b/app/models/ci/pipeline_chat_data.rb
@@ -3,6 +3,9 @@
module Ci
class PipelineChatData < Ci::ApplicationRecord
include Ci::NamespacedModelName
+ include IgnorableColumns
+
+ ignore_column :pipeline_id_convert_to_bigint, remove_with: '16.5', remove_after: '2023-10-22'
self.table_name = 'ci_pipeline_chat_data'
diff --git a/app/models/ci/pipeline_message.rb b/app/models/ci/pipeline_message.rb
index 5668da915e6..c997ec5cd62 100644
--- a/app/models/ci/pipeline_message.rb
+++ b/app/models/ci/pipeline_message.rb
@@ -2,6 +2,10 @@
module Ci
class PipelineMessage < Ci::ApplicationRecord
+ include IgnorableColumns
+
+ ignore_column :pipeline_id_convert_to_bigint, remove_with: '16.5', remove_after: '2023-09-22'
+
MAX_CONTENT_LENGTH = 10_000
belongs_to :pipeline
diff --git a/app/models/ci/pipeline_variable.rb b/app/models/ci/pipeline_variable.rb
index 9747f9ef527..a422aaa7daa 100644
--- a/app/models/ci/pipeline_variable.rb
+++ b/app/models/ci/pipeline_variable.rb
@@ -9,7 +9,6 @@ module Ci
include SafelyChangeColumnDefault
columns_changing_default :partition_id
- ignore_column :id_convert_to_bigint, remove_with: '16.3', remove_after: '2023-08-22'
ignore_column :pipeline_id_convert_to_bigint, remove_with: '16.5', remove_after: '2023-10-22'
belongs_to :pipeline
diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb
index 4c421f066f9..7ad1a727a0e 100644
--- a/app/models/ci/processable.rb
+++ b/app/models/ci/processable.rb
@@ -6,6 +6,7 @@ module Ci
class Processable < ::CommitStatus
include Gitlab::Utils::StrongMemoize
include FromUnion
+ include Ci::Metadatable
extend ::Gitlab::Utils::Override
has_one :resource, class_name: 'Ci::Resource', foreign_key: 'build_id', inverse_of: :processable
@@ -16,6 +17,7 @@ module Ci
accepts_nested_attributes_for :needs
scope :preload_needs, -> { preload(:needs) }
+ scope :manual_actions, -> { where(when: :manual, status: COMPLETED_STATUSES + %i[manual]) }
scope :with_needs, -> (names = nil) do
needs = Ci::BuildNeed.scoped_build.select(1)
@@ -138,6 +140,10 @@ module Ci
raise NotImplementedError
end
+ def other_manual_actions
+ pipeline.manual_actions.reject { |action| action.name == name }
+ end
+
def when
read_attribute(:when) || 'on_success'
end
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 4eb5c3c9ed2..8d93429fd24 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -87,19 +87,23 @@ module Ci
scope :active, -> (value = true) { where(active: value) }
scope :paused, -> { active(false) }
- scope :online, -> { where('contacted_at > ?', online_contact_time_deadline) }
+ scope :online, -> { where(arel_table[:contacted_at].gt(online_contact_time_deadline)) }
scope :recent, -> do
- where('ci_runners.created_at >= :datetime OR ci_runners.contacted_at >= :datetime', datetime: stale_deadline)
+ timestamp = stale_deadline
+
+ where(arel_table[:created_at].gteq(timestamp).or(arel_table[:contacted_at].gteq(timestamp)))
end
scope :stale, -> do
- where('ci_runners.created_at <= :datetime AND ' \
- '(ci_runners.contacted_at IS NULL OR ci_runners.contacted_at <= :datetime)', datetime: stale_deadline)
+ timestamp = stale_deadline
+
+ where(arel_table[:created_at].lteq(timestamp))
+ .where(arel_table[:contacted_at].eq(nil).or(arel_table[:contacted_at].lteq(timestamp)))
end
scope :offline, -> { where(arel_table[:contacted_at].lteq(online_contact_time_deadline)) }
scope :never_contacted, -> { where(contacted_at: nil) }
scope :ordered, -> { order(id: :desc) }
- scope :with_recent_runner_queue, -> { where('contacted_at > ?', recent_queue_deadline) }
+ scope :with_recent_runner_queue, -> { where(arel_table[:contacted_at].gt(recent_queue_deadline)) }
scope :with_running_builds, -> do
where('EXISTS(?)',
::Ci::Build.running.select(1)
@@ -513,7 +517,7 @@ module Ci
private
scope :with_upgrade_status, ->(upgrade_status) do
- joins(:runner_version).where(runner_version: { status: upgrade_status })
+ joins(:runner_managers).merge(RunnerManager.with_upgrade_status(upgrade_status))
end
EXECUTOR_NAME_TO_TYPES = {
diff --git a/app/models/ci/runner_manager.rb b/app/models/ci/runner_manager.rb
index 3a3f95a8c69..7d8fc097f51 100644
--- a/app/models/ci/runner_manager.rb
+++ b/app/models/ci/runner_manager.rb
@@ -14,7 +14,8 @@ module Ci
belongs_to :runner
- has_many :runner_manager_builds, inverse_of: :runner_manager, class_name: 'Ci::RunnerManagerBuild'
+ has_many :runner_manager_builds, inverse_of: :runner_manager, foreign_key: :runner_machine_id,
+ class_name: 'Ci::RunnerManagerBuild'
has_many :builds, through: :runner_manager_builds, class_name: 'Ci::Build'
belongs_to :runner_version, inverse_of: :runner_managers, primary_key: :version, foreign_key: :version,
class_name: 'Ci::RunnerVersion'
@@ -48,6 +49,23 @@ module Ci
where(runner_id: runner_id)
end
+ scope :with_running_builds, -> do
+ where('EXISTS(?)',
+ Ci::Build.select(1)
+ .joins(:runner_manager_build)
+ .running
+ .where("#{::Ci::Build.quoted_table_name}.runner_id = #{quoted_table_name}.runner_id")
+ .where("#{::Ci::RunnerManagerBuild.quoted_table_name}.runner_machine_id = #{quoted_table_name}.id")
+ .limit(1)
+ )
+ end
+
+ scope :order_id_desc, -> { order(id: :desc) }
+
+ scope :with_upgrade_status, ->(upgrade_status) do
+ joins(:runner_version).where(runner_version: { status: upgrade_status })
+ end
+
def self.online_contact_time_deadline
Ci::Runner.online_contact_time_deadline
end
diff --git a/app/models/ci/sources/pipeline.rb b/app/models/ci/sources/pipeline.rb
index 4853c57d41f..5b6946b04fd 100644
--- a/app/models/ci/sources/pipeline.rb
+++ b/app/models/ci/sources/pipeline.rb
@@ -6,6 +6,11 @@ module Ci
include Ci::Partitionable
include Ci::NamespacedModelName
include SafelyChangeColumnDefault
+ include IgnorableColumns
+
+ ignore_columns [
+ :pipeline_id_convert_to_bigint, :source_pipeline_id_convert_to_bigint
+ ], remove_with: '16.6', remove_after: '2023-10-22'
columns_changing_default :partition_id
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index 4f9a2e44562..3a498972153 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -8,9 +8,12 @@ module Ci
include Gitlab::OptimisticLocking
include Presentable
include SafelyChangeColumnDefault
+ include IgnorableColumns
columns_changing_default :partition_id
+ ignore_column :pipeline_id_convert_to_bigint, remove_with: '16.6', remove_after: '2023-10-22'
+
partitionable scope: :pipeline
enum status: Ci::HasStatus::STATUSES_ENUM
@@ -151,7 +154,7 @@ module Ci
end
def manual_playable?
- blocked?
+ blocked? || skipped?
end
# This will be removed with ci_remove_ensure_stage_service
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index 9cae71809fd..f9a34959675 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -45,7 +45,6 @@ module Clusters
end
has_many :kubernetes_namespaces
- has_many :metrics_dashboard_annotations, class_name: 'Metrics::Dashboard::Annotation', inverse_of: :cluster
accepts_nested_attributes_for :provider_gcp, update_only: true
accepts_nested_attributes_for :provider_aws, update_only: true
diff --git a/app/models/commit.rb b/app/models/commit.rb
index ded4b06a028..d7aa66588d3 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -29,7 +29,8 @@ class Commit
delegate :project, to: :repository, allow_nil: true
MIN_SHA_LENGTH = Gitlab::Git::Commit::MIN_SHA_LENGTH
- COMMIT_SHA_PATTERN = /\h{#{MIN_SHA_LENGTH},40}/.freeze
+ MAX_SHA_LENGTH = Gitlab::Git::Commit::MAX_SHA_LENGTH
+ COMMIT_SHA_PATTERN = Gitlab::Git::Commit::SHA_PATTERN.freeze
EXACT_COMMIT_SHA_PATTERN = /\A#{COMMIT_SHA_PATTERN}\z/.freeze
# Used by GFM to match and present link extensions on node texts and hrefs.
LINK_EXTENSION_PATTERN = /(patch)/.freeze
diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb
index edc60a757d2..993e1af20d5 100644
--- a/app/models/commit_collection.rb
+++ b/app/models/commit_collection.rb
@@ -24,8 +24,12 @@ class CommitCollection
commits.each(&block)
end
- def committers
- emails = without_merge_commits.filter_map(&:committer_email).uniq
+ def committers(with_merge_commits: false)
+ emails = if with_merge_commits
+ commits.filter_map(&:committer_email).uniq
+ else
+ without_merge_commits.filter_map(&:committer_email).uniq
+ end
User.by_any_email(emails)
end
diff --git a/app/models/commit_range.rb b/app/models/commit_range.rb
index c6e507e4b6c..d882a185464 100644
--- a/app/models/commit_range.rb
+++ b/app/models/commit_range.rb
@@ -31,9 +31,8 @@ class CommitRange
REF_PATTERN = /[0-9a-zA-Z][0-9a-zA-Z_.-]*[0-9a-zA-Z\^]/.freeze
PATTERN = /#{REF_PATTERN}\.{2,3}#{REF_PATTERN}/.freeze
- # In text references, the beginning and ending refs can only be SHAs
- # between 7 and 40 hex characters.
- STRICT_PATTERN = /\h{7,40}\.{2,3}\h{7,40}/.freeze
+ # In text references, the beginning and ending refs can only be valid SHAs.
+ STRICT_PATTERN = /#{Gitlab::Git::Commit::RAW_SHA_PATTERN}\.{2,3}#{Gitlab::Git::Commit::RAW_SHA_PATTERN}/.freeze
def self.reference_prefix
'@'
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 3f631f583b6..c2425e9460a 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -9,10 +9,16 @@ class CommitStatus < Ci::ApplicationRecord
include BulkInsertableAssociations
include TaggableQueries
+ ROUTING_FEATURE_FLAG = :ci_partitioning_use_ci_builds_routing_table
+
self.table_name = 'ci_builds'
self.sequence_name = 'ci_builds_id_seq'
self.primary_key = :id
- partitionable scope: :pipeline
+
+ partitionable scope: :pipeline, through: {
+ table: :p_ci_builds,
+ flag: ROUTING_FEATURE_FLAG
+ }
belongs_to :user
belongs_to :project
diff --git a/app/models/concerns/application_setting_masked_attrs.rb b/app/models/concerns/application_setting_masked_attrs.rb
deleted file mode 100644
index 14a7185e39e..00000000000
--- a/app/models/concerns/application_setting_masked_attrs.rb
+++ /dev/null
@@ -1,14 +0,0 @@
-# frozen_string_literal: true
-
-# Similar to MASK_PASSWORD mechanism we do for EE, see:
-# https://gitlab.com/gitlab-org/gitlab/-/blob/463bb1f855d71fadef931bd50f1692ee04f211a8/ee/app/models/ee/application_setting.rb#L15
-# but for non-EE attributes.
-module ApplicationSettingMaskedAttrs
- MASK = '*****'
-
- def ai_access_token=(value)
- return if value == MASK
-
- super
- end
-end
diff --git a/app/models/concerns/approvable.rb b/app/models/concerns/approvable.rb
index 55e138d84fb..b2828821c70 100644
--- a/app/models/concerns/approvable.rb
+++ b/app/models/concerns/approvable.rb
@@ -14,6 +14,7 @@ module Approvable
with_approvals
.merge(Approval.with_user)
.where(users: { id: user_ids })
+ .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422085')
.group(:id)
.having("COUNT(users.id) = ?", user_ids.size)
end
@@ -21,6 +22,7 @@ module Approvable
with_approvals
.merge(Approval.with_user)
.where(users: { username: usernames })
+ .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422085')
.group(:id)
.having("COUNT(users.id) = ?", usernames.size)
end
@@ -34,7 +36,7 @@ module Approvable
.where(app_table[:merge_request_id].eq(arel_table[:id]))
.select('true')
.arel.exists.not
- )
+ ).allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422085')
end
end
@@ -50,8 +52,12 @@ module Approvable
approvals.where(user: user).any?
end
+ def approved?
+ approvals.present?
+ end
+
def eligible_for_approval_by?(user)
- user && !approved_by?(user) && user.can?(:approve_merge_request, self)
+ user.present? && !approved_by?(user) && user.can?(:approve_merge_request, self)
end
def eligible_for_unapproval_by?(user)
diff --git a/app/models/concerns/ci/deployable.rb b/app/models/concerns/ci/deployable.rb
new file mode 100644
index 00000000000..b3b80989410
--- /dev/null
+++ b/app/models/concerns/ci/deployable.rb
@@ -0,0 +1,161 @@
+# frozen_string_literal: true
+
+# rubocop:disable Gitlab/StrongMemoizeAttr
+module Ci
+ module Deployable
+ extend ActiveSupport::Concern
+
+ included do
+ prepend_mod_with('Ci::Deployable') # rubocop: disable Cop/InjectEnterpriseEditionModule
+
+ has_one :deployment, as: :deployable, class_name: 'Deployment', inverse_of: :deployable
+
+ state_machine :status do
+ after_transition any => [:success] do |job|
+ job.run_after_commit do
+ Environments::StopJobSuccessWorker.perform_async(id)
+ end
+ end
+
+ # Synchronize Deployment Status
+ # Please note that the data integirty is not assured because we can't use
+ # a database transaction due to DB decomposition.
+ after_transition do |job, transition|
+ next if transition.loopback?
+ next unless job.project
+
+ job.run_after_commit do
+ job.deployment&.sync_status_with(job)
+ end
+ end
+ end
+ end
+
+ def outdated_deployment?
+ strong_memoize(:outdated_deployment) do
+ deployment_job? &&
+ project.ci_forward_deployment_enabled? &&
+ (!project.ci_forward_deployment_rollback_allowed? || incomplete?) &&
+ deployment&.older_than_last_successful_deployment?
+ end
+ end
+
+ # Virtual deployment status depending on the environment status.
+ def deployment_status
+ return unless deployment_job?
+
+ if success?
+ return successful_deployment_status
+ elsif failed?
+ return :failed
+ end
+
+ :creating
+ end
+
+ def successful_deployment_status
+ if deployment&.last?
+ :last
+ else
+ :out_of_date
+ end
+ end
+
+ def persisted_environment
+ return unless has_environment_keyword?
+
+ strong_memoize(:persisted_environment) do
+ # This code path has caused N+1s in the past, since environments are only indirectly
+ # associated to builds and pipelines; see https://gitlab.com/gitlab-org/gitlab/-/issues/326445
+ # We therefore batch-load them to prevent dormant N+1s until we found a proper solution.
+ BatchLoader.for(expanded_environment_name).batch(key: project_id) do |names, loader, args|
+ Environment.where(name: names, project: args[:key]).find_each do |environment|
+ loader.call(environment.name, environment)
+ end
+ end
+ end
+ end
+
+ def persisted_environment=(environment)
+ strong_memoize(:persisted_environment) { environment }
+ end
+
+ # If build.persisted_environment is a BatchLoader, we need to remove
+ # the method proxy in order to clone into new item here
+ # https://github.com/exAspArk/batch-loader/issues/31
+ def actual_persisted_environment
+ persisted_environment.respond_to?(:__sync) ? persisted_environment.__sync : persisted_environment
+ end
+
+ def expanded_environment_name
+ return unless has_environment_keyword?
+
+ strong_memoize(:expanded_environment_name) do
+ # We're using a persisted expanded environment name in order to avoid
+ # variable expansion per request.
+ if metadata&.expanded_environment_name.present?
+ metadata.expanded_environment_name
+ else
+ ExpandVariables.expand(environment, -> { simple_variables.sort_and_expand_all })
+ end
+ end
+ end
+
+ def expanded_kubernetes_namespace
+ return unless has_environment_keyword?
+
+ namespace = options.dig(:environment, :kubernetes, :namespace)
+
+ if namespace.present? # rubocop:disable Style/GuardClause
+ strong_memoize(:expanded_kubernetes_namespace) do
+ ExpandVariables.expand(namespace, -> { simple_variables })
+ end
+ end
+ end
+
+ def has_environment_keyword?
+ environment.present?
+ end
+
+ def deployment_job?
+ has_environment_keyword? && environment_action == 'start'
+ end
+
+ def stops_environment?
+ has_environment_keyword? && environment_action == 'stop'
+ end
+
+ def environment_action
+ options.fetch(:environment, {}).fetch(:action, 'start') if options
+ end
+
+ def environment_tier_from_options
+ options.dig(:environment, :deployment_tier) if options
+ end
+
+ def environment_tier
+ environment_tier_from_options || persisted_environment.try(:tier)
+ end
+
+ def environment_url
+ options&.dig(:environment, :url) || persisted_environment&.external_url
+ end
+
+ def environment_status
+ strong_memoize(:environment_status) do
+ if has_environment_keyword? && merge_request
+ EnvironmentStatus.new(project, persisted_environment, merge_request, pipeline.sha)
+ end
+ end
+ end
+
+ def on_stop
+ options&.dig(:environment, :on_stop)
+ end
+
+ def stop_action_successful?
+ success?
+ end
+ end
+end
+# rubocop:enable Gitlab/StrongMemoizeAttr
diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb
index 1c6b82d6ea7..b785e39523d 100644
--- a/app/models/concerns/ci/metadatable.rb
+++ b/app/models/concerns/ci/metadatable.rb
@@ -24,6 +24,12 @@ module Ci
delegate :id_tokens, to: :metadata, allow_nil: true
before_validation :ensure_metadata, on: :create
+
+ scope :with_project_and_metadata, -> do
+ if Feature.enabled?(:non_public_artifacts, type: :development)
+ joins(:metadata).includes(:metadata).preload(:project)
+ end
+ end
end
def has_exposed_artifacts?
diff --git a/app/models/concerns/ci/partitionable.rb b/app/models/concerns/ci/partitionable.rb
index a3bcc7bcbbc..ec6c85d888d 100644
--- a/app/models/concerns/ci/partitionable.rb
+++ b/app/models/concerns/ci/partitionable.rb
@@ -80,6 +80,7 @@ module Ci
def handle_partitionable_through(options)
return unless options
+ return if Gitlab::Utils.to_boolean(ENV['DISABLE_PARTITIONABLE_SWITCH'], default: false)
define_singleton_method(:routing_table_name) { options[:table] }
define_singleton_method(:routing_table_name_flag) { options[:flag] }
diff --git a/app/models/concerns/ci/partitionable/switch.rb b/app/models/concerns/ci/partitionable/switch.rb
index c1bbd107e9f..6195f92114f 100644
--- a/app/models/concerns/ci/partitionable/switch.rb
+++ b/app/models/concerns/ci/partitionable/switch.rb
@@ -2,6 +2,8 @@
module Ci
module Partitionable
+ MUTEX = Mutex.new
+
module Switch
extend ActiveSupport::Concern
@@ -14,18 +16,39 @@ module Ci
predicate_builder cached_find_by_statement].freeze
included do |base|
- partitioned = Class.new(base) do
- self.table_name = base.routing_table_name
+ install_partitioned_class(base)
+ end
+
+ class_methods do
+ # `Class.new(partitionable_model)` triggers `partitionable_model.inherited`
+ # and we need the mutex to break the recursion without adding extra accessors
+ # on the model. This will be used during code loading, not runtime.
+ #
+ def install_partitioned_class(partitionable_model)
+ Partitionable::MUTEX.synchronize do
+ partitioned = Class.new(partitionable_model) do
+ self.table_name = partitionable_model.routing_table_name
+
+ def self.routing_class?
+ true
+ end
+
+ def self.sti_name
+ superclass.sti_name
+ end
+ end
- def self.routing_class?
- true
+ partitionable_model.const_set(:Partitioned, partitioned)
end
end
- base.const_set(:Partitioned, partitioned)
- end
+ def inherited(child_class)
+ super
+ return if Partitionable::MUTEX.owned?
+
+ install_partitioned_class(child_class)
+ end
- class_methods do
def routing_class?
false
end
@@ -51,6 +74,13 @@ module Ci
end
end
end
+
+ def type_condition(table = arel_table)
+ sti_column = table[inheritance_column]
+ sti_names = ([self] + descendants).map(&:sti_name).uniq
+
+ predicate_builder.build(sti_column, sti_names)
+ end
end
end
end
diff --git a/app/models/concerns/cross_database_ignored_tables.rb b/app/models/concerns/cross_database_ignored_tables.rb
new file mode 100644
index 00000000000..c97e405cce4
--- /dev/null
+++ b/app/models/concerns/cross_database_ignored_tables.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module CrossDatabaseIgnoredTables
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def cross_database_ignore_tables(tables, options = {})
+ raise "missing issue url" if options[:url].blank?
+
+ options[:on] = %I[save destroy] if options[:on].blank?
+ events = Array.wrap(options[:on])
+ tables = Array.wrap(tables)
+
+ events.each do |event|
+ register_ignored_cross_database_event(tables, event, options)
+ end
+ end
+
+ private
+
+ def register_ignored_cross_database_event(tables, event, options)
+ case event
+ when :save
+ around_save(prepend: true) { |_, blk| temporary_ignore_cross_database_tables(tables, options, &blk) }
+ when :create
+ around_create(prepend: true) { |_, blk| temporary_ignore_cross_database_tables(tables, options, &blk) }
+ when :update
+ around_update(prepend: true) { |_, blk| temporary_ignore_cross_database_tables(tables, options, &blk) }
+ when :destroy
+ around_destroy(prepend: true) { |_, blk| temporary_ignore_cross_database_tables(tables, options, &blk) }
+ else
+ raise "Unknown #{event}"
+ end
+ end
+ end
+
+ private
+
+ def temporary_ignore_cross_database_tables(tables, options, &blk)
+ return yield unless options[:if].nil? || instance_eval(&options[:if])
+
+ url = options[:url]
+ Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.temporary_ignore_tables_in_transaction(
+ tables, url: url, &blk
+ )
+ end
+end
diff --git a/app/models/concerns/each_batch.rb b/app/models/concerns/each_batch.rb
index 79fb81e7820..945d286a2fd 100644
--- a/app/models/concerns/each_batch.rb
+++ b/app/models/concerns/each_batch.rb
@@ -219,6 +219,7 @@ module EachBatch
new_count, last_value =
unscoped
.from(inner_query)
+ .unscope(where: :type)
.order(count: :desc)
.limit(1)
.pick(:count, column)
diff --git a/app/models/concerns/enum_inheritance.rb b/app/models/concerns/enum_inheritance.rb
new file mode 100644
index 00000000000..1df1f3d43fd
--- /dev/null
+++ b/app/models/concerns/enum_inheritance.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module EnumInheritance
+ # == STI through Enum
+ #
+ # WARNING: Usage of STI is heavily discouraged: https://docs.gitlab.com/ee/development/database/single_table_inheritance.html
+ #
+ # Active Record allows definition of STI through the <tt>Base.inheritance_column</tt>. However, this stores the class
+ # name as string into the record, which is heavy and unnecessary. EnumInheritance adapts ActiveRecord to use an enum
+ # instead.
+ #
+ # Details:
+ # - Correct class mapping is specified in the <tt>self.sti_type_map<\tt>, which maps the symbol of the type to
+ # a fully classified class as string.
+ # - If the type passed does not have an specified class, then the class will be the base class
+ #
+ # Example
+ # class Animal
+ # include EnumInheritable
+ #
+ # enum animal_type: {
+ # dog: 1,
+ # cat: 2,
+ # bird: 3
+ # }
+ #
+ # def self.inheritance_column_to_class_map = {
+ # dog: 'Animals::Dog',
+ # cat: 'Animals::Cat'
+ # }
+ #
+ # def self.inheritance_column = 'animal_type'
+ # end
+ #
+ # class Animals::Dog < Animal; end
+ # class Animals::Cat < Animal; end
+ extend ActiveSupport::Concern
+
+ included do
+ def self.sti_class_to_enum_map = inheritance_column_to_class_map.invert
+ end
+
+ class_methods do
+ extend ::Gitlab::Utils::Override
+
+ def inheritance_column_to_class_map = {}.freeze
+
+ override :sti_class_for
+ def sti_class_for(type_name)
+ inheritance_column_to_class_map[type_name.to_sym]&.constantize || base_class
+ end
+
+ override :sti_name
+ def sti_name
+ sti_class_to_enum_map[name].to_s
+ end
+ end
+end
diff --git a/app/models/concerns/from_union.rb b/app/models/concerns/from_union.rb
index be6744f1b2a..e816608265b 100644
--- a/app/models/concerns/from_union.rb
+++ b/app/models/concerns/from_union.rb
@@ -32,6 +32,9 @@ module FromUnion
# remove_duplicates - A boolean indicating if duplicate entries should be
# removed. Defaults to true.
#
+ # remove_order - A boolean indicating if the order from the relations should be
+ # removed. Defaults to true.
+ #
# alias_as - The alias to use for the sub query. Defaults to the name of the
# table of the current model.
# rubocop: disable Gitlab/Union
diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb
index d614d6c4584..0e7381882b5 100644
--- a/app/models/concerns/has_repository.rb
+++ b/app/models/concerns/has_repository.rb
@@ -119,6 +119,9 @@ module HasRepository
def after_repository_change_head
reload_default_branch
+
+ Gitlab::EventStore.publish(
+ Repositories::DefaultBranchChangedEvent.new(data: { container_id: id, container_type: self.class.name }))
end
def after_change_head_branch_does_not_exist(branch)
diff --git a/app/models/concerns/issuable_link.rb b/app/models/concerns/issuable_link.rb
index 7f29083d6c6..e884e5acecf 100644
--- a/app/models/concerns/issuable_link.rb
+++ b/app/models/concerns/issuable_link.rb
@@ -21,6 +21,10 @@ module IssuableLink
raise NotImplementedError
end
+ def issuable_name
+ issuable_type.to_s.humanize(capitalize: false)
+ end
+
# Used to get the available types for the API
# overriden in EE
def available_link_types
@@ -53,7 +57,7 @@ module IssuableLink
return unless source && target
if self.class.base_class.find_by(source: target, target: source)
- errors.add(:source, "is already related to this #{self.class.issuable_type}")
+ errors.add(:source, "is already related to this #{self.class.issuable_name}")
end
end
end
diff --git a/app/models/concerns/linkable_item.rb b/app/models/concerns/linkable_item.rb
new file mode 100644
index 00000000000..135252727ab
--- /dev/null
+++ b/app/models/concerns/linkable_item.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+# == LinkableItem concern
+#
+# Contains common functionality shared between related issue links and related work item links
+#
+# Used by IssueLink, WorkItems::RelatedWorkItemLink
+#
+module LinkableItem
+ extend ActiveSupport::Concern
+ include FromUnion
+ include IssuableLink
+
+ included do
+ validate :check_existing_parent_link
+
+ scope :for_source, ->(item) { where(source_id: item.id) }
+ scope :for_target, ->(item) { where(target_id: item.id) }
+ scope :for_items, ->(source, target) do
+ where(source: source, target: target).or(where(source: target, target: source))
+ end
+
+ private
+
+ def check_existing_parent_link
+ return unless source && target
+
+ existing_relation = WorkItems::ParentLink.for_parents([source, target]).for_children([source, target])
+ return if existing_relation.none?
+
+ errors.add(:source, format(_('is a parent or child of this %{item}'), item: self.class.issuable_name))
+ end
+ end
+end
+
+LinkableItem.include_mod_with('LinkableItem::Callbacks')
+LinkableItem.prepend_mod_with('LinkableItem')
diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb
index e95a8a42aa6..b72d99d211c 100644
--- a/app/models/concerns/milestoneable.rb
+++ b/app/models/concerns/milestoneable.rb
@@ -52,7 +52,9 @@ module Milestoneable
def milestone_available?
return true if milestone_id.blank?
- project_id == milestone&.project_id || project.ancestors_upto.compact.include?(milestone&.group)
+ (project_id.present? && project_id == milestone&.project_id) ||
+ try(:namespace)&.self_and_ancestors&.include?(milestone&.group) ||
+ project&.ancestors_upto&.compact&.include?(milestone&.group)
end
##
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index 5c91f2460c4..40a91c8ac94 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -17,7 +17,7 @@ module Noteable
# `Noteable` class names that support resolvable notes.
def resolvable_types
- %w(MergeRequest DesignManagement::Design)
+ %w(Issue MergeRequest DesignManagement::Design)
end
# `Noteable` class names that support creating/forwarding individual notes.
@@ -49,6 +49,8 @@ module Noteable
end
def supports_resolvable_notes?
+ return false if is_a?(Issue) && Feature.disabled?(:resolvable_issue_threads, project)
+
self.class.resolvable_types.include?(base_class_name)
end
@@ -171,9 +173,9 @@ module Noteable
return unless etag_caching_enabled?
# TODO: We need to figure out a way to make ETag caching work for group-level work items
- return if is_a?(Issue) && project.nil?
+ Gitlab::EtagCaching::Store.new.touch(note_etag_key) unless is_a?(Issue) && project.nil?
- Gitlab::EtagCaching::Store.new.touch(note_etag_key)
+ Noteable::NotesChannel.broadcast_to(self, event: 'updated') if Feature.enabled?(:action_cable_notes, project || try(:group))
end
def note_etag_key
diff --git a/app/models/concerns/packages/nuget/version_normalizable.rb b/app/models/concerns/packages/nuget/version_normalizable.rb
new file mode 100644
index 00000000000..473e5f07811
--- /dev/null
+++ b/app/models/concerns/packages/nuget/version_normalizable.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module Packages
+ module Nuget
+ module VersionNormalizable
+ extend ActiveSupport::Concern
+
+ LEADING_ZEROES_REGEX = /^(?!0$)0+(?=\d)/
+
+ included do
+ before_validation :set_normalized_version, on: %i[create update]
+
+ private
+
+ def set_normalized_version
+ return unless package && Feature.enabled?(:nuget_normalized_version, package.project)
+
+ self.normalized_version = normalize
+ end
+
+ def normalize
+ version = remove_leading_zeroes
+ version = remove_build_metadata(version)
+ version = omit_zero_in_fourth_part(version)
+ append_suffix(version)
+ end
+
+ def remove_leading_zeroes
+ package_version.split('.').map { |part| part.sub(LEADING_ZEROES_REGEX, '') }.join('.')
+ end
+
+ def remove_build_metadata(version)
+ version.split('+').first.downcase
+ end
+
+ def omit_zero_in_fourth_part(version)
+ parts = version.split('.')
+ parts[3] = nil if parts.fourth == '0' && parts.third.exclude?('-')
+ parts.compact.join('.')
+ end
+
+ def append_suffix(version)
+ version << '.0.0' if version.count('.') == 0
+ version << '.0' if version.count('.') == 1
+ version
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/reset_on_union_error.rb b/app/models/concerns/reset_on_union_error.rb
new file mode 100644
index 00000000000..42e350b0bed
--- /dev/null
+++ b/app/models/concerns/reset_on_union_error.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module ResetOnUnionError
+ extend ActiveSupport::Concern
+
+ MAX_RESET_PERIOD = 10.minutes
+
+ included do |base|
+ base.rescue_from ActiveRecord::StatementInvalid, with: :reset_on_union_error
+
+ base.class_attribute :previous_reset_columns_from_error
+ end
+
+ class_methods do
+ def reset_on_union_error(exception)
+ if reset_on_statement_invalid?(exception)
+ class_to_be_reset = base_class
+
+ class_to_be_reset.reset_column_information
+ Gitlab::ErrorTracking.log_exception(exception, { reset_model_name: class_to_be_reset.name })
+
+ class_to_be_reset.previous_reset_columns_from_error = Time.current
+ end
+
+ raise
+ end
+
+ def reset_on_statement_invalid?(exception)
+ return false unless exception.message.include?("each UNION query must have the same number of columns")
+
+ return false if base_class.previous_reset_columns_from_error? &&
+ base_class.previous_reset_columns_from_error > MAX_RESET_PERIOD.ago
+
+ Feature.enabled?(:reset_column_information_on_statement_invalid, type: :ops)
+ end
+ end
+end
diff --git a/app/models/concerns/resolvable_discussion.rb b/app/models/concerns/resolvable_discussion.rb
index 45818942326..e967c78154d 100644
--- a/app/models/concerns/resolvable_discussion.rb
+++ b/app/models/concerns/resolvable_discussion.rb
@@ -116,6 +116,8 @@ module ResolvableDiscussion
# Set the notes array to the updated notes
@notes = notes_relation.fresh.to_a # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ noteable.expire_note_etag_cache
+
clear_memoized_values
end
end
diff --git a/app/models/concerns/resolvable_note.rb b/app/models/concerns/resolvable_note.rb
index 4e8a1bb643e..7f9a7faa3f5 100644
--- a/app/models/concerns/resolvable_note.rb
+++ b/app/models/concerns/resolvable_note.rb
@@ -23,12 +23,13 @@ module ResolvableNote
class_methods do
# This method must be kept in sync with `#resolve!`
def resolve!(current_user)
- unresolved.update_all(resolved_at: Time.current, resolved_by_id: current_user.id)
+ now = Time.current
+ unresolved.update_all(updated_at: now, resolved_at: now, resolved_by_id: current_user.id)
end
# This method must be kept in sync with `#unresolve!`
def unresolve!
- resolved.update_all(resolved_at: nil, resolved_by_id: nil)
+ resolved.update_all(updated_at: Time.current, resolved_at: nil, resolved_by_id: nil)
end
end
@@ -57,7 +58,9 @@ module ResolvableNote
return false unless resolvable?
return false if resolved?
- self.resolved_at = Time.current
+ now = Time.current
+ self.updated_at = now
+ self.resolved_at = now
self.resolved_by = current_user
self.resolved_by_push = resolved_by_push
@@ -69,6 +72,7 @@ module ResolvableNote
return false unless resolvable?
return false unless resolved?
+ self.updated_at = Time.current
self.resolved_at = nil
self.resolved_by = nil
diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb
index d70aad4e9ae..f2badfe48dd 100644
--- a/app/models/concerns/routable.rb
+++ b/app/models/concerns/routable.rb
@@ -25,17 +25,19 @@ module Routable
#
# We need to qualify the columns with the table name, to support both direct lookups on
# Route/RedirectRoute, and scoped lookups through the Routable classes.
- route =
- route_scope.find_by(routes: { path: path }) ||
- route_scope.iwhere(Route.arel_table[:path] => path).take
+ Gitlab::Database.allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/420046") do
+ route =
+ route_scope.find_by(routes: { path: path }) ||
+ route_scope.iwhere(Route.arel_table[:path] => path).take
- if follow_redirects
- route ||= redirect_route_scope.iwhere(RedirectRoute.arel_table[:path] => path).take
- end
+ if follow_redirects
+ route ||= redirect_route_scope.iwhere(RedirectRoute.arel_table[:path] => path).take
+ end
- return unless route
+ next unless route
- route.is_a?(Routable) ? route : route.source
+ route.is_a?(Routable) ? route : route.source
+ end
end
included do
@@ -46,7 +48,9 @@ module Routable
validates :route, presence: true, unless: -> { is_a?(Namespaces::ProjectNamespace) }
- scope :with_route, -> { includes(:route) }
+ scope :with_route, -> do
+ includes(:route).allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/421843')
+ end
after_validation :set_path_errors
@@ -94,7 +98,9 @@ module Routable
joins(:route)
end
- route.where(wheres.join(' OR '))
+ route
+ .where(wheres.join(' OR '))
+ .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/420046")
end
end
diff --git a/app/models/concerns/time_trackable.rb b/app/models/concerns/time_trackable.rb
index 2b7447dc700..0f361e70a91 100644
--- a/app/models/concerns/time_trackable.rb
+++ b/app/models/concerns/time_trackable.rb
@@ -17,8 +17,8 @@ module TimeTrackable
attribute :time_estimate, default: 0
- validates :time_estimate, numericality: { message: 'has an invalid format' }, allow_nil: false
- validate :check_negative_time_spent
+ validate :check_time_estimate
+ validate :check_negative_time_spent
has_many :timelogs, dependent: :destroy, autosave: true # rubocop:disable Cop/ActiveRecordDependent
after_initialize :set_time_estimate_default_value
@@ -106,4 +106,11 @@ module TimeTrackable
def original_total_time_spent
@original_total_time_spent ||= total_time_spent
end
+
+ def check_time_estimate
+ return unless new_record? || time_estimate_changed?
+ return if time_estimate.is_a?(Numeric) && time_estimate >= 0
+
+ errors.add(:time_estimate, _('must have a valid format and be greater than or equal to zero.'))
+ end
end
diff --git a/app/models/concerns/web_hooks/auto_disabling.rb b/app/models/concerns/web_hooks/auto_disabling.rb
index 2ad2e47ec4e..72812f35f72 100644
--- a/app/models/concerns/web_hooks/auto_disabling.rb
+++ b/app/models/concerns/web_hooks/auto_disabling.rb
@@ -3,6 +3,7 @@
module WebHooks
module AutoDisabling
extend ActiveSupport::Concern
+ include ::Gitlab::Loggable
ENABLED_HOOK_TYPES = %w[ProjectHook].freeze
MAX_FAILURES = 100
@@ -36,7 +37,9 @@ module WebHooks
# - and either:
# - disabled_until is nil (i.e. this was set by WebHook#fail!)
# - or disabled_until is in the future (i.e. this was set by WebHook#backoff!)
+ # - OR silent mode is enabled.
scope :disabled, -> do
+ return all if Gitlab::SilentMode.enabled?
return none unless auto_disabling_enabled?
where(
@@ -52,7 +55,9 @@ module WebHooks
# - OR we have exceeded the grace period and neither of the following is true:
# - disabled_until is nil (i.e. this was set by WebHook#fail!)
# - disabled_until is in the future (i.e. this was set by WebHook#backoff!)
+ # - AND silent mode is not enabled.
scope :executable, -> do
+ return none if Gitlab::SilentMode.enabled?
return all unless auto_disabling_enabled?
where(
@@ -82,17 +87,14 @@ module WebHooks
recent_failures > FAILURE_THRESHOLD && disabled_until.blank?
end
- def disable!
- return if !auto_disabling_enabled? || permanently_disabled?
-
- update_attribute(:recent_failures, EXCEEDED_FAILURE_THRESHOLD)
- end
-
def enable!
return unless auto_disabling_enabled?
return if recent_failures == 0 && disabled_until.nil? && backoff_count == 0
- assign_attributes(recent_failures: 0, disabled_until: nil, backoff_count: 0)
+ attrs = { recent_failures: 0, disabled_until: nil, backoff_count: 0 }
+
+ assign_attributes(attrs)
+ logger.info(hook_id: id, action: 'enable', **attrs)
save(validate: false)
end
@@ -110,14 +112,21 @@ module WebHooks
end
assign_attributes(attrs)
- save(validate: false) if changed?
+
+ return unless changed?
+
+ logger.info(hook_id: id, action: 'backoff', **attrs)
+ save(validate: false)
end
def failed!
return unless auto_disabling_enabled?
return unless recent_failures < MAX_FAILURES
- assign_attributes(disabled_until: nil, backoff_count: 0, recent_failures: next_failure_count)
+ attrs = { disabled_until: nil, backoff_count: 0, recent_failures: next_failure_count }
+
+ assign_attributes(**attrs)
+ logger.info(hook_id: id, action: 'disable', **attrs)
save(validate: false)
end
@@ -143,6 +152,10 @@ module WebHooks
private
+ def logger
+ @logger ||= Gitlab::WebHooks::Logger.build
+ end
+
def next_failure_count
recent_failures.succ.clamp(1, MAX_FAILURES)
end
diff --git a/app/models/customer_relations/contact.rb b/app/models/customer_relations/contact.rb
index 16c741d340f..f99b8fa5549 100644
--- a/app/models/customer_relations/contact.rb
+++ b/app/models/customer_relations/contact.rb
@@ -35,6 +35,22 @@ class CustomerRelations::Contact < ApplicationRecord
scope :order_by_organization_asc, -> { includes(:organization).order("customer_relations_organizations.name ASC NULLS LAST") }
scope :order_by_organization_desc, -> { includes(:organization).order("customer_relations_organizations.name DESC NULLS LAST") }
+ SAFE_ATTRIBUTES = %w[
+ created_at
+ description
+ first_name
+ group_id
+ id
+ last_name
+ organization_id
+ state
+ updated_at
+ ].freeze
+
+ def hook_attrs
+ attributes.slice(*SAFE_ATTRIBUTES)
+ end
+
def self.reference_prefix
'[contact:'
end
diff --git a/app/models/dependency_proxy/manifest.rb b/app/models/dependency_proxy/manifest.rb
index 11fe0503f50..702e1679f6a 100644
--- a/app/models/dependency_proxy/manifest.rb
+++ b/app/models/dependency_proxy/manifest.rb
@@ -15,7 +15,8 @@ class DependencyProxy::Manifest < ApplicationRecord
ACCEPTED_TYPES = [
ContainerRegistry::BaseClient::DOCKER_DISTRIBUTION_MANIFEST_V2_TYPE,
ContainerRegistry::BaseClient::OCI_MANIFEST_V1_TYPE,
- ContainerRegistry::BaseClient::OCI_DISTRIBUTION_INDEX_TYPE
+ ContainerRegistry::BaseClient::OCI_DISTRIBUTION_INDEX_TYPE,
+ ContainerRegistry::BaseClient::DOCKER_DISTRIBUTION_MANIFEST_LIST_V2_TYPE
].freeze
validates :group, presence: true
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index b59b22c10c4..0bdce18bab5 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -67,7 +67,7 @@ class Deployment < ApplicationRecord
state_machine :status, initial: :created do
event :run do
- transition created: :running
+ transition [:created, :blocked] => :running
end
event :block do
@@ -79,10 +79,6 @@ class Deployment < ApplicationRecord
transition skipped: :created
end
- event :unblock do
- transition blocked: :created
- end
-
event :succeed do
transition any - [:success] => :success
end
@@ -184,23 +180,23 @@ class Deployment < ApplicationRecord
# - deploy job B => production environment
# In this case, `last_deployment_group` returns both deployments.
#
- # NOTE: Preload environment.last_deployment and pipeline.latest_successful_builds prior to avoid N+1.
+ # NOTE: Preload environment.last_deployment and pipeline.latest_successful_jobs prior to avoid N+1.
def self.last_deployment_group_for_environment(env)
- return self.none unless env.last_deployment_pipeline&.latest_successful_builds&.present?
+ return self.none unless env.last_deployment_pipeline&.latest_successful_jobs&.present?
BatchLoader.for(env).batch(default_value: self.none) do |environments, loader|
- latest_successful_build_ids = []
+ latest_successful_job_ids = []
environments_hash = {}
environments.each do |environment|
environments_hash[environment.id] = environment
# Refer comment note above, if not preloaded this can lead to N+1.
- latest_successful_build_ids << environment.last_deployment_pipeline.latest_successful_builds.map(&:id)
+ latest_successful_job_ids << environment.last_deployment_pipeline.latest_successful_jobs.map(&:id)
end
Deployment
- .where(deployable_type: 'CommitStatus', deployable_id: latest_successful_build_ids.flatten)
+ .where(deployable_type: 'CommitStatus', deployable_id: latest_successful_job_ids.flatten)
.preload(last_deployment_group_associations)
.group_by { |deployment| deployment.environment_id }
.each do |env_id, deployment_group|
@@ -217,14 +213,14 @@ class Deployment < ApplicationRecord
# Fetching any unbounded or large intermediate dataset could lead to loading too many IDs into memory.
# See: https://docs.gitlab.com/ee/development/database/multiple_databases.html#use-disable_joins-for-has_one-or-has_many-through-relations
# For safety we default limit to fetch not more than 1000 records.
- def self.builds(limit = 1000)
+ def self.jobs(limit = 1000)
deployable_ids = where.not(deployable_id: nil).limit(limit).pluck(:deployable_id)
- Ci::Build.where(id: deployable_ids)
+ Ci::Processable.where(id: deployable_ids)
end
- def build
- deployable if deployable.is_a?(::Ci::Build)
+ def job
+ deployable if deployable.is_a?(::Ci::Processable)
end
class << self
@@ -289,8 +285,8 @@ class Deployment < ApplicationRecord
@scheduled_actions ||= deployable.try(:other_scheduled_actions)
end
- def playable_build
- strong_memoize(:playable_build) do
+ def playable_job
+ strong_memoize(:playable_job) do
deployable.try(:playable?) ? deployable : nil
end
end
@@ -355,8 +351,8 @@ class Deployment < ApplicationRecord
end
def deployed_by
- # We use deployable's user if available because Ci::PlayBuildService
- # does not update the deployment's user, just the one for the deployable.
+ # We use deployable's user if available because Ci::PlayBuildService and Ci::PlayBridgeService
+ # do not update the deployment's user, just the one for the deployable.
# TODO: use deployment's user once https://gitlab.com/gitlab-org/gitlab-foss/issues/66442
# is completed.
deployable&.user || user
@@ -402,14 +398,17 @@ class Deployment < ApplicationRecord
false
end
- def sync_status_with(build)
- return false unless ::Deployment.statuses.include?(build.status)
- return false if build.status == self.status
+ def sync_status_with(job)
+ job_status = job.status
+ job_status = 'blocked' if job_status == 'manual'
+
+ return false unless ::Deployment.statuses.include?(job_status)
+ return false if job_status == self.status
- update_status!(build.status)
+ update_status!(job_status)
rescue StandardError => e
Gitlab::ErrorTracking.track_exception(
- StatusSyncError.new(e.message), deployment_id: self.id, build_id: build.id)
+ StatusSyncError.new(e.message), deployment_id: self.id, job_id: job.id)
false
end
diff --git a/app/models/discussion.rb b/app/models/discussion.rb
index dc4794ed3cd..2d2519dc995 100644
--- a/app/models/discussion.rb
+++ b/app/models/discussion.rb
@@ -191,4 +191,8 @@ class Discussion
def to_global_id(options = {})
GlobalID.new(::Gitlab::GlobalId.build(model_name: Discussion.to_s, id: id))
end
+
+ def noteable_collection_name
+ noteable.class.underscore.pluralize
+ end
end
diff --git a/app/models/email.rb b/app/models/email.rb
index 3896dfd5d22..5fca57520b8 100644
--- a/app/models/email.rb
+++ b/app/models/email.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Email < ApplicationRecord
+class Email < MainClusterwide::ApplicationRecord
include Sortable
include Gitlab::SQL::Pattern
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 241b454f5ce..36445279b86 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -18,14 +18,13 @@ class Environment < ApplicationRecord
belongs_to :cluster_agent, class_name: 'Clusters::Agent', optional: true, inverse_of: :environments
use_fast_destroy :all_deployments
- nullify_if_blank :external_url, :kubernetes_namespace
+ nullify_if_blank :external_url, :kubernetes_namespace, :flux_resource_path
has_many :all_deployments, class_name: 'Deployment'
has_many :deployments, -> { visible }
has_many :successful_deployments, -> { success }, class_name: 'Deployment'
has_many :active_deployments, -> { active }, class_name: 'Deployment'
has_many :prometheus_alerts, inverse_of: :environment
- has_many :metrics_dashboard_annotations, class_name: 'Metrics::Dashboard::Annotation', inverse_of: :environment
has_many :self_managed_prometheus_alert_events, inverse_of: :environment
has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :environment
@@ -78,6 +77,10 @@ class Environment < ApplicationRecord
message: Gitlab::Regex.kubernetes_namespace_regex_message
}
+ validates :flux_resource_path,
+ length: { maximum: 255 },
+ allow_nil: true
+
validates :tier, presence: true
validate :safe_external_url
@@ -331,9 +334,9 @@ class Environment < ApplicationRecord
end
def cancel_deployment_jobs!
- active_deployments.builds.each do |build|
- Gitlab::OptimisticLocking.retry_lock(build, name: 'environment_cancel_deployment_jobs') do |build|
- build.cancel! if build&.cancelable?
+ active_deployments.jobs.each do |job|
+ Gitlab::OptimisticLocking.retry_lock(job, name: 'environment_cancel_deployment_jobs') do |job|
+ job.cancel! if job&.cancelable?
end
rescue StandardError => e
Gitlab::ErrorTracking.track_exception(e, environment_id: id, deployment_id: deployment.id)
@@ -355,8 +358,12 @@ class Environment < ApplicationRecord
Gitlab::OptimisticLocking.retry_lock(
stop_action,
name: 'environment_stop_with_actions'
- ) do |build|
- actions << build.play(current_user)
+ ) do |job|
+ actions << job.play(current_user)
+ rescue StateMachines::InvalidTransition
+ # Ci::PlayBuildService rescues an error of StateMachines::InvalidTransition and fall back to retry. However,
+ # Ci::PlayBridgeService doesn't rescue it, so we're ignoring the error if it's not playable.
+ # We should fix this inconsistency in https://gitlab.com/gitlab-org/gitlab/-/issues/420855.
end
end
diff --git a/app/models/event.rb b/app/models/event.rb
index 9345776c32b..4547d7b9e60 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -11,7 +11,7 @@ class Event < ApplicationRecord
include ShaAttribute
include IgnorableColumns
- ignore_column :target_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22'
+ ignore_column :target_id_convert_to_bigint, remove_with: '16.4', remove_after: '2023-09-22'
ACTIONS = HashWithIndifferentAccess.new(
created: 1,
diff --git a/app/models/group.rb b/app/models/group.rb
index 2b5a392e02c..9df3c143e0c 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -184,6 +184,7 @@ class Group < Namespace
ids_by_full_path = Route
.for_routable_type(Namespace.name)
.where('LOWER(routes.path) IN (?)', paths.map(&:downcase))
+ .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/420046")
.select(:namespace_id)
Group.from_union([by_id(ids), by_id(ids_by_full_path), where('LOWER(path) IN (?)', paths.map(&:downcase))])
@@ -397,7 +398,7 @@ class Group < Namespace
end
def visibility_level_allowed_by_projects?(level = self.visibility_level)
- !projects.where('visibility_level > ?', level).exists?
+ !projects.without_deleted.where('visibility_level > ?', level).exists?
end
def visibility_level_allowed_by_sub_groups?(level = self.visibility_level)
@@ -635,19 +636,26 @@ class Group < Namespace
end
# Returns all members that are part of the group, it's subgroups, and ancestor groups
- def direct_and_indirect_members
+ def hierarchy_members
GroupMember
.active_without_invites_and_requests
.where(source_id: self_and_hierarchy.reorder(nil).select(:id))
end
- def direct_and_indirect_members_with_inactive
+ def hierarchy_members_with_inactive
GroupMember
.non_request
.non_invite
.where(source_id: self_and_hierarchy.reorder(nil).select(:id))
end
+ def descendant_project_members_with_inactive
+ ProjectMember
+ .with_source_id(all_projects)
+ .non_request
+ .non_invite
+ end
+
def users_with_parents
User
.where(id: members_with_parents.select(:user_id))
@@ -660,45 +668,6 @@ class Group < Namespace
.reorder(nil)
end
- # Returns all users that are members of the group because:
- # 1. They belong to the group
- # 2. They belong to a project that belongs to the group
- # 3. They belong to a sub-group or project in such sub-group
- # 4. They belong to an ancestor group
- # 5. They belong to a group that is shared with this group, if share_with_groups is true
- def direct_and_indirect_users(share_with_groups: false)
- members = if share_with_groups
- # We only need :user_id column, but
- # `members_from_self_and_ancestor_group_shares` needs more
- # columns to make the CTE query work.
- GroupMember.from_union([
- direct_and_indirect_members.select(:user_id, :source_type, :type),
- members_from_self_and_ancestor_group_shares.reselect(:user_id, :source_type, :type)
- ])
- else
- direct_and_indirect_members
- end
-
- User.from_union([
- User.where(id: members.select(:user_id)).reorder(nil),
- project_users_with_descendants
- ])
- end
-
- # Returns all users (also inactive) that are members of the group because:
- # 1. They belong to the group
- # 2. They belong to a project that belongs to the group
- # 3. They belong to a sub-group or project in such sub-group
- # 4. They belong to an ancestor group
- def direct_and_indirect_users_with_inactive
- User.from_union([
- User
- .where(id: direct_and_indirect_members_with_inactive.select(:user_id))
- .reorder(nil),
- project_users_with_descendants
- ]).allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417455") # failed in spec/tasks/gitlab/user_management_rake_spec.rb
- end
-
def users_count
members.count
end
@@ -925,6 +894,10 @@ class Group < Namespace
feature_flag_enabled_for_self_or_ancestor?(:work_items_mvc_2)
end
+ def linked_work_items_feature_flag_enabled?
+ feature_flag_enabled_for_self_or_ancestor?(:linked_work_items)
+ end
+
def usage_quotas_enabled?
::Feature.enabled?(:usage_quotas_for_all_editions, self) && root?
end
@@ -951,7 +924,7 @@ class Group < Namespace
end
def update_two_factor_requirement_for_members
- direct_and_indirect_members.find_each(&:update_two_factor_requirement)
+ hierarchy_members.find_each(&:update_two_factor_requirement)
end
def readme_project
diff --git a/app/models/group_group_link.rb b/app/models/group_group_link.rb
index dba52aa51cd..13f74b938af 100644
--- a/app/models/group_group_link.rb
+++ b/app/models/group_group_link.rb
@@ -13,6 +13,7 @@ class GroupGroupLink < ApplicationRecord
validates :group_access, inclusion: { in: Gitlab::Access.all_values }, presence: true
scope :non_guests, -> { where('group_access > ?', Gitlab::Access::GUEST) }
+ scope :for_shared_groups, -> (group_ids) { where(shared_group_id: group_ids) }
scope :with_owner_or_maintainer_access, -> do
where(group_access: [Gitlab::Access::OWNER, Gitlab::Access::MAINTAINER])
diff --git a/app/models/identity.rb b/app/models/identity.rb
index df1185f330d..a4c59694050 100644
--- a/app/models/identity.rb
+++ b/app/models/identity.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Identity < ApplicationRecord
+class Identity < MainClusterwide::ApplicationRecord
include Sortable
include CaseSensitivity
diff --git a/app/models/identity/uniqueness_scopes.rb b/app/models/identity/uniqueness_scopes.rb
index b41b4572e82..598b7e34738 100644
--- a/app/models/identity/uniqueness_scopes.rb
+++ b/app/models/identity/uniqueness_scopes.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Identity < ApplicationRecord
+class Identity < MainClusterwide::ApplicationRecord
# This module and method are defined in a separate file to allow EE to
# redefine the `scopes` method before it is used in the `Identity` model.
module UniquenessScopes
diff --git a/app/models/instance_configuration.rb b/app/models/instance_configuration.rb
index 64c9680ce90..57638356362 100644
--- a/app/models/instance_configuration.rb
+++ b/app/models/instance_configuration.rb
@@ -53,7 +53,9 @@ class InstanceConfiguration
diff_max_patch_bytes: application_settings[:diff_max_patch_bytes].bytes,
max_artifacts_size: application_settings[:max_artifacts_size].megabytes,
max_pages_size: application_settings[:max_pages_size] > 0 ? application_settings[:max_pages_size].megabytes : nil,
- snippet_size_limit: application_settings[:snippet_size_limit]&.bytes
+ snippet_size_limit: application_settings[:snippet_size_limit]&.bytes,
+ max_import_remote_file_size: application_settings[:max_import_remote_file_size] > 0 ? application_settings[:max_import_remote_file_size].megabytes : 0,
+ bulk_import_max_download_file_size: application_settings[:bulk_import_max_download_file_size] > 0 ? application_settings[:bulk_import_max_download_file_size].megabytes : 0
}
end
diff --git a/app/models/integration.rb b/app/models/integration.rb
index f823a385022..bc86b08018f 100644
--- a/app/models/integration.rb
+++ b/app/models/integration.rb
@@ -167,7 +167,7 @@ class Integration < ApplicationRecord
raise ArgumentError, "Unknown field storage: #{storage}"
end
- boolean_accessor(name) if attrs[:type] == 'checkbox' && storage != :attribute
+ boolean_accessor(name) if attrs[:type] == :checkbox && storage != :attribute
end
# :nocov:
@@ -472,7 +472,7 @@ class Integration < ApplicationRecord
# use `#secret?` here.
# See: https://gitlab.com/groups/gitlab-org/-/epics/7652
def secret_fields
- fields.select { |f| f[:type] == 'password' }.pluck(:name)
+ fields.select { |f| f[:type] == :password }.pluck(:name)
end
# Expose a list of fields in the JSON endpoint.
@@ -517,11 +517,11 @@ class Integration < ApplicationRecord
end
def api_field_names
- fields.reject { _1[:type] == 'password' || _1[:name] == 'webhook' }.pluck(:name)
+ fields.reject { _1[:type] == :password || _1[:name] == 'webhook' || (_1.key?(:if) && _1[:if] != true) }.pluck(:name)
end
def form_fields
- fields.reject { _1[:api_only] == true }
+ fields.reject { _1[:api_only] == true || (_1.key?(:if) && _1[:if] != true) }
end
def configurable_events
diff --git a/app/models/integrations/apple_app_store.rb b/app/models/integrations/apple_app_store.rb
index a4036a82cec..6f96626718f 100644
--- a/app/models/integrations/apple_app_store.rb
+++ b/app/models/integrations/apple_app_store.rb
@@ -32,7 +32,7 @@ module Integrations
field :app_store_private_key, api_only: true
field :app_store_protected_refs,
- type: 'checkbox',
+ type: :checkbox,
section: SECTION_TYPE_CONFIGURATION,
title: -> { s_('AppleAppStore|Protected branches and tags only') },
checkbox_label: -> { s_('AppleAppStore|Only set variables on protected branches and tags') }
diff --git a/app/models/integrations/asana.rb b/app/models/integrations/asana.rb
index b8cfd718007..7436c08aa38 100644
--- a/app/models/integrations/asana.rb
+++ b/app/models/integrations/asana.rb
@@ -7,7 +7,7 @@ module Integrations
validates :api_key, presence: true, if: :activated?
field :api_key,
- type: 'password',
+ type: :password,
title: 'API key',
help: -> { s_('AsanaService|User Personal Access Token. User must have access to the task. All comments are attributed to this user.') },
non_empty_password_title: -> { s_('ProjectService|Enter new API key') },
diff --git a/app/models/integrations/assembla.rb b/app/models/integrations/assembla.rb
index 536d5584bf6..6831fac32e6 100644
--- a/app/models/integrations/assembla.rb
+++ b/app/models/integrations/assembla.rb
@@ -5,7 +5,7 @@ module Integrations
validates :token, presence: true, if: :activated?
field :token,
- type: 'password',
+ type: :password,
non_empty_password_title: -> { s_('ProjectService|Enter new token') },
non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') },
placeholder: '',
diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb
index 4638ca0c5f1..0b8432136dd 100644
--- a/app/models/integrations/bamboo.rb
+++ b/app/models/integrations/bamboo.rb
@@ -24,7 +24,7 @@ module Integrations
help: -> { s_('BambooService|The user with API access to the Bamboo server.') }
field :password,
- type: 'password',
+ type: :password,
non_empty_password_title: -> { s_('ProjectService|Enter new password') },
non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current password') }
diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb
index c9de4d2b3bb..4d207574ca7 100644
--- a/app/models/integrations/base_chat_notification.rb
+++ b/app/models/integrations/base_chat_notification.rb
@@ -23,7 +23,6 @@ module Integrations
].freeze
SECRET_MASK = '************'
- CHANNEL_LIMIT_PER_EVENT = 10
attribute :category, default: 'chat'
@@ -79,27 +78,27 @@ module Integrations
def default_fields
[
{
- type: 'checkbox',
+ type: :checkbox,
section: SECTION_TYPE_CONFIGURATION,
name: 'notify_only_broken_pipelines',
help: 'Do not send notifications for successful pipelines.'
}.freeze,
{
- type: 'select',
+ 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
}.freeze,
{
- type: 'text',
+ type: :text,
section: SECTION_TYPE_CONFIGURATION,
name: 'labels_to_be_notified',
placeholder: '~backend,~frontend',
help: 'Send notifications for issue, merge request, and comment events with the listed labels only. Leave blank to receive notifications for all events.'
}.freeze,
{
- type: 'select',
+ type: :select,
section: SECTION_TYPE_CONFIGURATION,
name: 'labels_to_be_notified_behavior',
choices: [
@@ -111,8 +110,8 @@ module Integrations
next unless requires_webhook?
fields.unshift(
- { type: 'text', name: 'webhook', help: webhook_help, required: true }.freeze,
- { type: 'text', name: 'username', placeholder: 'GitLab-integration' }.freeze
+ { type: :text, name: 'webhook', help: webhook_help, required: true }.freeze,
+ { type: :text, name: 'username', placeholder: 'GitLab-integration' }.freeze
)
end.freeze
end
@@ -186,6 +185,14 @@ module Integrations
true
end
+ def channel_limit_per_event
+ 10
+ end
+
+ def mask_configurable_channels?
+ false
+ end
+
private
def should_execute?(object_kind)
@@ -257,7 +264,7 @@ module Integrations
def build_event_channels
event_channel_names.map do |channel_field|
- { type: 'text', name: channel_field, placeholder: default_channel_placeholder }
+ { type: :text, name: channel_field, placeholder: default_channel_placeholder }
end
end
@@ -314,13 +321,13 @@ module Integrations
def validate_channel_limit
supported_events.each do |event|
count = channels_for_event(event).count
- next unless count > CHANNEL_LIMIT_PER_EVENT
+ next unless count > channel_limit_per_event
errors.add(
event_channel_name(event).to_sym,
format(
s_('SlackIntegration|cannot have more than %{limit} channels'),
- limit: CHANNEL_LIMIT_PER_EVENT
+ limit: channel_limit_per_event
)
)
end
diff --git a/app/models/integrations/buildkite.rb b/app/models/integrations/buildkite.rb
index 5c08eac8557..6cd36e545a5 100644
--- a/app/models/integrations/buildkite.rb
+++ b/app/models/integrations/buildkite.rb
@@ -16,7 +16,7 @@ module Integrations
required: true
field :token,
- type: 'password',
+ type: :password,
title: -> { _('Token') },
help: -> do
s_('ProjectService|The token you get after you create a Buildkite pipeline with a GitLab repository.')
diff --git a/app/models/integrations/campfire.rb b/app/models/integrations/campfire.rb
index 9b837faf79b..007578e5830 100644
--- a/app/models/integrations/campfire.rb
+++ b/app/models/integrations/campfire.rb
@@ -13,7 +13,7 @@ module Integrations
format: { with: SUBDOMAIN_REGEXP }, length: { in: 1..63 }
field :token,
- type: 'password',
+ type: :password,
title: -> { _('Campfire token') },
help: -> { s_('CampfireService|API authentication token from Campfire.') },
non_empty_password_title: -> { s_('ProjectService|Enter new token') },
diff --git a/app/models/integrations/chat_message/issue_message.rb b/app/models/integrations/chat_message/issue_message.rb
index 1c234630370..dd516362491 100644
--- a/app/models/integrations/chat_message/issue_message.rb
+++ b/app/models/integrations/chat_message/issue_message.rb
@@ -27,7 +27,7 @@ module Integrations
def attachments
return [] unless opened_issue?
- return description if markdown
+ return SlackMarkdownSanitizer.sanitize_slack_link(description) if markdown
description_message
end
@@ -55,7 +55,7 @@ module Integrations
[{
title: issue_title,
title_link: issue_url,
- text: format(description),
+ text: format(SlackMarkdownSanitizer.sanitize_slack_link(description)),
color: '#C95823'
}]
end
diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb
index c7306209174..1a56763fe57 100644
--- a/app/models/integrations/datadog.rb
+++ b/app/models/integrations/datadog.rb
@@ -32,7 +32,7 @@ module Integrations
help: -> { s_('DatadogIntegration|(Advanced) The full URL for your Datadog site.') }
field :api_key,
- type: 'password',
+ type: :password,
title: -> { _('API key') },
non_empty_password_title: -> { s_('ProjectService|Enter new API key') },
non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current API key') },
@@ -48,7 +48,7 @@ module Integrations
field :archive_trace_events,
storage: :attribute,
- type: 'checkbox',
+ type: :checkbox,
title: -> { _('Logs') },
checkbox_label: -> { _('Enable logs collection') },
help: -> { s_('When enabled, job logs are collected by Datadog and displayed along with pipeline execution traces.') }
@@ -73,7 +73,7 @@ module Integrations
end
field :datadog_tags,
- type: 'textarea',
+ type: :textarea,
title: -> { s_('DatadogIntegration|Tags') },
placeholder: "tag:value\nanother_tag:value",
help: -> do
diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb
index 061c491034d..7cae3ca20f9 100644
--- a/app/models/integrations/discord.rb
+++ b/app/models/integrations/discord.rb
@@ -10,15 +10,15 @@ module Integrations
field :webhook,
section: SECTION_TYPE_CONNECTION,
- help: 'e.g. https://discordapp.com/api/webhooks/…',
+ help: 'e.g. https://discord.com/api/webhooks/…',
required: true
field :notify_only_broken_pipelines,
- type: 'checkbox',
+ type: :checkbox,
section: SECTION_TYPE_CONFIGURATION
field :branches_to_be_notified,
- type: 'select',
+ type: :select,
section: SECTION_TYPE_CONFIGURATION,
title: -> { s_('Integrations|Branches for which notifications are to be sent') },
choices: -> { branch_choices }
@@ -45,7 +45,7 @@ module Integrations
end
def default_channel_placeholder
- # No-op.
+ s_('DiscordService|Override the default webhook (e.g. https://discord.com/api/webhooks/…)')
end
def self.supported_events
@@ -72,10 +72,23 @@ module Integrations
]
end
+ def configurable_channels?
+ true
+ end
+
+ def channel_limit_per_event
+ 1
+ end
+
+ def mask_configurable_channels?
+ true
+ end
+
private
def notify(message, opts)
- client = Discordrb::Webhooks::Client.new(url: webhook)
+ webhook_url = opts[:channel]&.first || webhook
+ client = Discordrb::Webhooks::Client.new(url: webhook_url)
client.execute do |builder|
builder.add_embed do |embed|
diff --git a/app/models/integrations/drone_ci.rb b/app/models/integrations/drone_ci.rb
index 781acf65c47..ac464c020dd 100644
--- a/app/models/integrations/drone_ci.rb
+++ b/app/models/integrations/drone_ci.rb
@@ -16,7 +16,7 @@ module Integrations
required: true
field :token,
- type: 'password',
+ type: :password,
help: -> { s_('ProjectService|Token for the Drone project.') },
non_empty_password_title: -> { s_('ProjectService|Enter new token') },
non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') },
diff --git a/app/models/integrations/emails_on_push.rb b/app/models/integrations/emails_on_push.rb
index 25bda8c2bf0..eb893ae45d0 100644
--- a/app/models/integrations/emails_on_push.rb
+++ b/app/models/integrations/emails_on_push.rb
@@ -10,7 +10,7 @@ module Integrations
validate :number_of_recipients_within_limit, if: :validate_recipients?
field :send_from_committer_email,
- type: 'checkbox',
+ type: :checkbox,
title: -> { s_("EmailsOnPushService|Send from committer") },
help: -> do
@help ||= begin
@@ -21,17 +21,17 @@ module Integrations
end
field :disable_diffs,
- type: 'checkbox',
+ type: :checkbox,
title: -> { s_("EmailsOnPushService|Disable code diffs") },
help: -> { s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") }
field :branches_to_be_notified,
- type: 'select',
+ type: :select,
title: -> { s_('Integrations|Branches for which notifications are to be sent') },
choices: branch_choices
field :recipients,
- type: 'textarea',
+ type: :textarea,
placeholder: -> { s_('EmailsOnPushService|tanuki@example.com gitlab@example.com') },
help: -> { s_('EmailsOnPushService|Emails separated by whitespace.') }
diff --git a/app/models/integrations/field.rb b/app/models/integrations/field.rb
index 9f2274216f6..9dc90629344 100644
--- a/app/models/integrations/field.rb
+++ b/app/models/integrations/field.rb
@@ -6,21 +6,24 @@ module Integrations
ATTRIBUTES = %i[
section type placeholder choices value checkbox_label
- title help
+ title help if
non_empty_password_help
non_empty_password_title
].concat(BOOLEAN_ATTRIBUTES).freeze
- TYPES = %w[text textarea password checkbox select].freeze
+ TYPES = %i[text textarea password checkbox select].freeze
attr_reader :name, :integration_class
- def initialize(name:, integration_class:, type: 'text', is_secret: false, api_only: false, **attributes)
+ delegate :key?, to: :attributes
+
+ def initialize(name:, integration_class:, type: :text, is_secret: false, api_only: false, **attributes)
@name = name.to_s.freeze
@integration_class = integration_class
- attributes[:type] = is_secret ? 'password' : type
+ attributes[:type] = is_secret ? :password : type
attributes[:api_only] = api_only
+ attributes[:if] = attributes.fetch(:if, true)
attributes[:is_secret] = is_secret
@attributes = attributes.freeze
@@ -35,14 +38,14 @@ module Integrations
def [](key)
return name if key == :name
- value = @attributes[key]
+ value = attributes[key]
return integration_class.class_exec(&value) if value.respond_to?(:call)
value
end
def secret?
- self[:type] == 'password'
+ self[:type] == :password
end
ATTRIBUTES.each do |name|
@@ -56,5 +59,9 @@ module Integrations
TYPES.each do |type|
define_method("#{type}?") { self[:type] == type }
end
+
+ private
+
+ attr_reader :attributes
end
end
diff --git a/app/models/integrations/google_play.rb b/app/models/integrations/google_play.rb
index 9fa6dc19f11..5389e8dfa81 100644
--- a/app/models/integrations/google_play.rb
+++ b/app/models/integrations/google_play.rb
@@ -12,6 +12,7 @@ module Integrations
}
validates :service_account_key_file_name, presence: true
validates :package_name, presence: true, format: { with: PACKAGE_NAME_REGEX }
+ validates :google_play_protected_refs, inclusion: [true, false]
end
field :package_name,
@@ -25,6 +26,12 @@ module Integrations
field :service_account_key, api_only: true
+ field :google_play_protected_refs,
+ type: :checkbox,
+ section: SECTION_TYPE_CONFIGURATION,
+ title: -> { s_('GooglePlayStore|Protected branches and tags only') },
+ checkbox_label: -> { s_('GooglePlayStore|Only set variables on protected branches and tags') }
+
def title
s_('GooglePlay|Google Play')
end
@@ -76,8 +83,9 @@ module Integrations
{ success: false, message: error }
end
- def ci_variables
+ def ci_variables(protected_ref:)
return [] unless activated?
+ return [] if google_play_protected_refs && !protected_ref
[
{ key: 'SUPPLY_JSON_KEY_DATA', value: service_account_key, masked: true, public: false },
@@ -85,6 +93,11 @@ module Integrations
]
end
+ def initialize_properties
+ super
+ self.google_play_protected_refs = true if google_play_protected_refs.nil?
+ end
+
private
def client
diff --git a/app/models/integrations/hangouts_chat.rb b/app/models/integrations/hangouts_chat.rb
index 7ba9bbc38e6..037c689c75e 100644
--- a/app/models/integrations/hangouts_chat.rb
+++ b/app/models/integrations/hangouts_chat.rb
@@ -10,11 +10,11 @@ module Integrations
required: true
field :notify_only_broken_pipelines,
- type: 'checkbox',
+ type: :checkbox,
section: SECTION_TYPE_CONFIGURATION
field :branches_to_be_notified,
- type: 'select',
+ type: :select,
section: SECTION_TYPE_CONFIGURATION,
title: -> { s_('Integrations|Branches for which notifications are to be sent') },
choices: -> { branch_choices }
diff --git a/app/models/integrations/harbor.rb b/app/models/integrations/harbor.rb
index 079811e0df0..559e48afd10 100644
--- a/app/models/integrations/harbor.rb
+++ b/app/models/integrations/harbor.rb
@@ -25,7 +25,7 @@ module Integrations
required: true
field :password,
- type: 'password',
+ type: :password,
title: -> { s_('HarborIntegration|Harbor password') },
help: -> { s_('HarborIntegration|Password for your Harbor username.') },
non_empty_password_title: -> { s_('HarborIntegration|Enter new Harbor password') },
diff --git a/app/models/integrations/irker.rb b/app/models/integrations/irker.rb
index 3f3e321f45e..a54946f074a 100644
--- a/app/models/integrations/irker.rb
+++ b/app/models/integrations/irker.rb
@@ -23,7 +23,7 @@ module Integrations
placeholder: 'irc://irc.network.net:6697/'
field :recipients,
- type: 'textarea',
+ type: :textarea,
title: -> { s_('IrkerService|Recipients') },
placeholder: 'irc[s]://irc.network.net[:port]/#channel',
required: true,
@@ -45,7 +45,7 @@ module Integrations
end
field :colorize_messages,
- type: 'checkbox',
+ type: :checkbox,
title: -> { _('Colorize messages') }
# NOTE: This field is only used internally to store the parsed
diff --git a/app/models/integrations/jenkins.rb b/app/models/integrations/jenkins.rb
index d2e8393ef95..7769ea7d2dd 100644
--- a/app/models/integrations/jenkins.rb
+++ b/app/models/integrations/jenkins.rb
@@ -22,7 +22,7 @@ module Integrations
help: -> { s_('The username for the Jenkins server.') }
field :password,
- type: 'password',
+ type: :password,
help: -> { s_('The password for the Jenkins server.') },
non_empty_password_title: -> { s_('ProjectService|Enter new password.') },
non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current password.') }
diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb
index 4e0c2dde13b..faf0a378a17 100644
--- a/app/models/integrations/jira.rb
+++ b/app/models/integrations/jira.rb
@@ -74,7 +74,7 @@ module Integrations
exposes_secrets: true
field :jira_auth_type,
- type: 'select',
+ type: :select,
required: true,
section: SECTION_TYPE_CONNECTION,
title: -> { s_('JiraService|Authentication type') },
diff --git a/app/models/integrations/mattermost_slash_commands.rb b/app/models/integrations/mattermost_slash_commands.rb
index e075400d9b5..73cddd163e0 100644
--- a/app/models/integrations/mattermost_slash_commands.rb
+++ b/app/models/integrations/mattermost_slash_commands.rb
@@ -5,7 +5,7 @@ module Integrations
include Ci::TriggersHelper
field :token,
- type: 'password',
+ type: :password,
non_empty_password_title: -> { s_('ProjectService|Enter new token') },
non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') },
placeholder: ''
diff --git a/app/models/integrations/microsoft_teams.rb b/app/models/integrations/microsoft_teams.rb
index a9ed0bd3da1..25308948d51 100644
--- a/app/models/integrations/microsoft_teams.rb
+++ b/app/models/integrations/microsoft_teams.rb
@@ -10,12 +10,12 @@ module Integrations
required: true
field :notify_only_broken_pipelines,
- type: 'checkbox',
+ type: :checkbox,
section: SECTION_TYPE_CONFIGURATION,
help: 'If selected, successful pipelines do not trigger a notification event.'
field :branches_to_be_notified,
- type: 'select',
+ type: :select,
section: SECTION_TYPE_CONFIGURATION,
title: -> { s_('Integrations|Branches for which notifications are to be sent') },
choices: -> { branch_choices }
diff --git a/app/models/integrations/packagist.rb b/app/models/integrations/packagist.rb
index 3973b492b6d..c9c08ec9771 100644
--- a/app/models/integrations/packagist.rb
+++ b/app/models/integrations/packagist.rb
@@ -11,7 +11,7 @@ module Integrations
required: true
field :token,
- type: 'password',
+ type: :password,
title: -> { _('Token') },
help: -> { _('Enter your Packagist token.') },
non_empty_password_title: -> { s_('ProjectService|Enter new token') },
diff --git a/app/models/integrations/pipelines_email.rb b/app/models/integrations/pipelines_email.rb
index 55a8ce0be11..fa22bd1a73c 100644
--- a/app/models/integrations/pipelines_email.rb
+++ b/app/models/integrations/pipelines_email.rb
@@ -10,19 +10,19 @@ module Integrations
validate :number_of_recipients_within_limit, if: :validate_recipients?
field :recipients,
- type: 'textarea',
+ type: :textarea,
help: -> { _('Comma-separated list of email addresses.') },
required: true
field :notify_only_broken_pipelines,
- type: 'checkbox'
+ type: :checkbox
field :notify_only_default_branch,
- type: 'checkbox',
+ type: :checkbox,
api_only: true
field :branches_to_be_notified,
- type: 'select',
+ type: :select,
title: -> { s_('Integrations|Branches for which notifications are to be sent') },
choices: branch_choices
diff --git a/app/models/integrations/pivotaltracker.rb b/app/models/integrations/pivotaltracker.rb
index 1acdbbbf9bc..0d9a3f05a86 100644
--- a/app/models/integrations/pivotaltracker.rb
+++ b/app/models/integrations/pivotaltracker.rb
@@ -7,7 +7,7 @@ module Integrations
validates :token, presence: true, if: :activated?
field :token,
- type: 'password',
+ type: :password,
help: -> { s_('PivotalTrackerService|Pivotal Tracker API token. User must have access to the story. All comments are attributed to this user.') },
non_empty_password_title: -> { s_('ProjectService|Enter new token') },
non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') },
diff --git a/app/models/integrations/prometheus.rb b/app/models/integrations/prometheus.rb
index 8969c6c13b2..736318ed707 100644
--- a/app/models/integrations/prometheus.rb
+++ b/app/models/integrations/prometheus.rb
@@ -6,7 +6,7 @@ module Integrations
include Gitlab::Utils::StrongMemoize
field :manual_configuration,
- type: 'checkbox',
+ type: :checkbox,
title: -> { s_('PrometheusService|Active') },
help: -> { s_('PrometheusService|Select this checkbox to override the auto configuration settings with your own settings.') },
required: true
@@ -24,7 +24,7 @@ module Integrations
required: false
field :google_iap_service_account_json,
- type: 'textarea',
+ type: :textarea,
title: 'Google IAP Service Account JSON',
placeholder: -> { s_('PrometheusService|{ "type": "service_account", "project_id": ... }') },
help: -> { s_('PrometheusService|The contents of the credentials.json file of your service account.') },
diff --git a/app/models/integrations/pumble.rb b/app/models/integrations/pumble.rb
index e08dc6d0f51..8f0dddcc5c5 100644
--- a/app/models/integrations/pumble.rb
+++ b/app/models/integrations/pumble.rb
@@ -2,6 +2,24 @@
module Integrations
class Pumble < BaseChatNotification
+ undef :notify_only_broken_pipelines
+
+ field :webhook,
+ section: SECTION_TYPE_CONNECTION,
+ help: 'https://api.pumble.com/workspaces/x/...',
+ required: true
+
+ field :notify_only_broken_pipelines,
+ type: :checkbox,
+ section: SECTION_TYPE_CONFIGURATION,
+ help: 'If selected, successful pipelines do not trigger a notification event.'
+
+ 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
'Pumble'
end
@@ -34,17 +52,8 @@ module Integrations
pipeline wiki_page]
end
- def default_fields
- [
- { type: 'text', name: 'webhook', help: 'https://api.pumble.com/workspaces/x/...', required: true },
- { type: 'checkbox', name: 'notify_only_broken_pipelines' },
- {
- type: 'select',
- name: 'branches_to_be_notified',
- title: s_('Integrations|Branches for which notifications are to be sent'),
- choices: self.class.branch_choices
- }
- ]
+ def fields
+ self.class.fields + build_event_channels
end
private
diff --git a/app/models/integrations/pushover.rb b/app/models/integrations/pushover.rb
index 6bb6b6d60f6..006b731c6c2 100644
--- a/app/models/integrations/pushover.rb
+++ b/app/models/integrations/pushover.rb
@@ -7,7 +7,7 @@ module Integrations
validates :api_key, :user_key, :priority, presence: true, if: :activated?
field :api_key,
- type: 'password',
+ type: :password,
title: -> { _('API key') },
help: -> { s_('PushoverService|Enter your application key.') },
non_empty_password_title: -> { s_('ProjectService|Enter new API key') },
@@ -16,7 +16,7 @@ module Integrations
required: true
field :user_key,
- type: 'password',
+ type: :password,
title: -> { _('User key') },
help: -> { s_('PushoverService|Enter your user key.') },
non_empty_password_title: -> { s_('PushoverService|Enter new user key') },
@@ -30,7 +30,7 @@ module Integrations
placeholder: ''
field :priority,
- type: 'select',
+ type: :select,
required: true,
choices: -> do
[
@@ -42,7 +42,7 @@ module Integrations
end
field :sound,
- type: 'select',
+ type: :select,
choices: -> do
[
['Device default sound', nil],
diff --git a/app/models/integrations/slack_slash_commands.rb b/app/models/integrations/slack_slash_commands.rb
index 343c8d68166..b209f37ee7c 100644
--- a/app/models/integrations/slack_slash_commands.rb
+++ b/app/models/integrations/slack_slash_commands.rb
@@ -5,7 +5,7 @@ module Integrations
include Ci::TriggersHelper
field :token,
- type: 'password',
+ type: :password,
non_empty_password_title: -> { s_('ProjectService|Enter new token') },
non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') },
placeholder: ''
diff --git a/app/models/integrations/squash_tm.rb b/app/models/integrations/squash_tm.rb
index e0a63b5ae6a..bf3f391564f 100644
--- a/app/models/integrations/squash_tm.rb
+++ b/app/models/integrations/squash_tm.rb
@@ -11,7 +11,7 @@ module Integrations
required: true
field :token,
- type: 'password',
+ type: :password,
title: -> { s_('SquashTmIntegration|Secret token (optional)') },
non_empty_password_title: -> { s_('ProjectService|Enter new token') },
non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') },
diff --git a/app/models/integrations/teamcity.rb b/app/models/integrations/teamcity.rb
index af629d6ef1e..c74e0aab030 100644
--- a/app/models/integrations/teamcity.rb
+++ b/app/models/integrations/teamcity.rb
@@ -22,7 +22,7 @@ module Integrations
help: -> { s_('ProjectService|Must have permission to trigger a manual build in TeamCity.') }
field :password,
- type: 'password',
+ type: :password,
non_empty_password_title: -> { s_('ProjectService|Enter new password') },
non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current password') }
diff --git a/app/models/integrations/unify_circuit.rb b/app/models/integrations/unify_circuit.rb
index 6c447c8f4e4..6de693b5278 100644
--- a/app/models/integrations/unify_circuit.rb
+++ b/app/models/integrations/unify_circuit.rb
@@ -10,11 +10,11 @@ module Integrations
required: true
field :notify_only_broken_pipelines,
- type: 'checkbox',
+ type: :checkbox,
section: SECTION_TYPE_CONFIGURATION
field :branches_to_be_notified,
- type: 'select',
+ type: :select,
section: SECTION_TYPE_CONFIGURATION,
title: -> { s_('Integrations|Branches for which notifications are to be sent') },
choices: -> { branch_choices }
diff --git a/app/models/integrations/webex_teams.rb b/app/models/integrations/webex_teams.rb
index ef1bc81ea58..21c65cc2b32 100644
--- a/app/models/integrations/webex_teams.rb
+++ b/app/models/integrations/webex_teams.rb
@@ -10,11 +10,11 @@ module Integrations
required: true
field :notify_only_broken_pipelines,
- type: 'checkbox',
+ type: :checkbox,
section: SECTION_TYPE_CONFIGURATION
field :branches_to_be_notified,
- type: 'select',
+ type: :select,
section: SECTION_TYPE_CONFIGURATION,
title: -> { s_('Integrations|Branches for which notifications are to be sent') },
choices: -> { branch_choices }
diff --git a/app/models/integrations/zentao.rb b/app/models/integrations/zentao.rb
index 459756c865b..fd2c741bd6b 100644
--- a/app/models/integrations/zentao.rb
+++ b/app/models/integrations/zentao.rb
@@ -19,7 +19,7 @@ module Integrations
exposes_secrets: true
field :api_token,
- type: 'password',
+ type: :password,
title: -> { s_('ZentaoIntegration|ZenTao API token') },
non_empty_password_title: -> { s_('ZentaoIntegration|Enter new ZenTao API token') },
non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') },
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 6e48dcab9ed..d227448961a 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -317,6 +317,10 @@ class Issue < ApplicationRecord
pattern: IssuableFinder::FULL_TEXT_SEARCH_TERM_PATTERN
)
end
+
+ def related_link_class
+ IssueLink
+ end
end
def self.participant_includes
@@ -542,18 +546,18 @@ class Issue < ApplicationRecord
end
def related_issues(current_user, preload: nil)
- related_issues = ::Issue
- .select(['issues.*', 'issue_links.id AS issue_link_id',
- 'issue_links.link_type as issue_link_type_value',
- 'issue_links.target_id as issue_link_source_id',
- 'issue_links.created_at as issue_link_created_at',
- 'issue_links.updated_at as issue_link_updated_at'])
- .joins("INNER JOIN issue_links ON
- (issue_links.source_id = issues.id AND issue_links.target_id = #{id})
- OR
- (issue_links.target_id = issues.id AND issue_links.source_id = #{id})")
- .preload(preload)
- .reorder('issue_link_id')
+ related_issues = self.class
+ .select(['issues.*', 'issue_links.id AS issue_link_id',
+ 'issue_links.link_type as issue_link_type_value',
+ 'issue_links.target_id as issue_link_source_id',
+ 'issue_links.created_at as issue_link_created_at',
+ 'issue_links.updated_at as issue_link_updated_at'])
+ .joins("INNER JOIN issue_links ON
+ (issue_links.source_id = issues.id AND issue_links.target_id = #{id})
+ OR
+ (issue_links.target_id = issues.id AND issue_links.source_id = #{id})")
+ .preload(preload)
+ .reorder('issue_link_id')
related_issues = yield related_issues if block_given?
@@ -642,12 +646,13 @@ class Issue < ApplicationRecord
end
def issue_link_type
+ link_class = self.class.related_link_class
return unless respond_to?(:issue_link_type_value) && respond_to?(:issue_link_source_id)
- type = IssueLink.link_types.key(issue_link_type_value) || IssueLink::TYPE_RELATES_TO
+ type = link_class.link_types.key(issue_link_type_value) || link_class::TYPE_RELATES_TO
return type if issue_link_source_id == id
- IssueLink.inverse_link_type(type)
+ link_class.inverse_link_type(type)
end
def relocation_target
@@ -770,7 +775,7 @@ class Issue < ApplicationRecord
return unless persisted?
if confidential? && WorkItems::ParentLink.has_public_children?(id)
- errors.add(:base, _('A confidential issue cannot have a parent that already has non-confidential children.'))
+ errors.add(:base, _('A confidential issue must have only confidential children. Make any child items confidential and try again.'))
end
if !confidential? && WorkItems::ParentLink.has_confidential_parent?(id)
@@ -784,7 +789,7 @@ class Issue < ApplicationRecord
# TODO: https://gitlab.com/gitlab-org/gitlab/-/work_items/393126
return unless project
- Issues::SearchData.upsert({ project_id: project_id, issue_id: id, search_vector: search_vector }, unique_by: %i(project_id issue_id))
+ Issues::SearchData.upsert({ namespace_id: namespace_id, project_id: project_id, issue_id: id, search_vector: search_vector }, unique_by: %i(project_id issue_id))
end
def ensure_metrics!
diff --git a/app/models/issue_link.rb b/app/models/issue_link.rb
index af55a5dec91..1c596ad0341 100644
--- a/app/models/issue_link.rb
+++ b/app/models/issue_link.rb
@@ -1,18 +1,11 @@
# frozen_string_literal: true
class IssueLink < ApplicationRecord
- include FromUnion
- include IssuableLink
+ include LinkableItem
belongs_to :source, class_name: 'Issue'
belongs_to :target, class_name: 'Issue'
- scope :for_source_issue, ->(issue) { where(source_id: issue.id) }
- scope :for_target_issue, ->(issue) { where(target_id: issue.id) }
- scope :for_issues, ->(source, target) do
- where(source: source, target: target).or(where(source: target, target: source))
- end
-
class << self
def issuable_type
:issue
diff --git a/app/models/label.rb b/app/models/label.rb
index 0831ba40536..d0d278b68fd 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -25,8 +25,10 @@ class Label < ApplicationRecord
has_many :merge_requests, through: :label_links, source: :target, source_type: 'MergeRequest'
before_validation :strip_whitespace_from_title
+ before_destroy :prevent_locked_label_destroy, prepend: true
validates :color, color: true, presence: true
+ validate :ensure_lock_on_merge_allowed
# Don't allow ',' for label titles
validates :title, presence: true, format: { with: /\A[^,]+\z/ }
@@ -42,6 +44,7 @@ class Label < ApplicationRecord
scope :templates, -> { where(template: true, type: [Label.name, nil]) }
scope :with_title, ->(title) { where(title: title) }
scope :with_lists_and_board, -> { joins(lists: :board).merge(List.movable) }
+ scope :with_lock_on_merge, -> { where(lock_on_merge: true) }
scope :on_project_boards, ->(project_id) { with_lists_and_board.where(boards: { project_id: project_id }) }
scope :on_board, ->(board_id) { with_lists_and_board.where(boards: { id: board_id }) }
scope :order_name_asc, -> { reorder(title: :asc) }
@@ -319,6 +322,20 @@ class Label < ApplicationRecord
def strip_whitespace_from_title
self[:title] = title&.strip
end
+
+ def prevent_locked_label_destroy
+ return unless lock_on_merge
+
+ errors.add(:base, format(_('%{label_name} is locked and was not removed'), label_name: name))
+ throw :abort # rubocop:disable Cop/BanCatchThrow
+ end
+
+ def ensure_lock_on_merge_allowed
+ return unless template?
+ return unless lock_on_merge || will_save_change_to_lock_on_merge?
+
+ errors.add(:lock_on_merge, _('can not be set for template labels'))
+ end
end
Label.prepend_mod_with('Label')
diff --git a/app/models/loose_foreign_keys/deleted_record.rb b/app/models/loose_foreign_keys/deleted_record.rb
index 7f64606e97b..1d26c3c11e4 100644
--- a/app/models/loose_foreign_keys/deleted_record.rb
+++ b/app/models/loose_foreign_keys/deleted_record.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class LooseForeignKeys::DeletedRecord < Gitlab::Database::SharedModel
+ include FromUnion
+
PARTITION_DURATION = 1.day
include PartitionedTable
@@ -34,13 +36,34 @@ class LooseForeignKeys::DeletedRecord < Gitlab::Database::SharedModel
enum status: { pending: 1, processed: 2 }, _prefix: :status
def self.load_batch_for_table(table, batch_size)
- # selecting partition as partition_number to workaround the sliding partitioning column ignore
- select(arel_table[Arel.star], arel_table[:partition].as('partition_number'))
- .for_table(table)
- .status_pending
- .consume_order
- .limit(batch_size)
- .to_a
+ if Feature.enabled?("loose_foreign_keys_batch_load_using_union")
+ partition_names = Gitlab::Database::PostgresPartitionedTable.each_partition(table_name).map(&:name)
+
+ unions = partition_names.map do |partition_name|
+ partition_number = partition_name[/\d+/].to_i
+
+ select(arel_table[Arel.star], arel_table[:partition].as('partition_number'))
+ .from("#{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}.#{partition_name} AS #{table_name}")
+ .for_table(table)
+ .where(partition: partition_number)
+ .status_pending
+ .consume_order
+ .limit(batch_size)
+ end
+
+ select(arel_table[Arel.star])
+ .from_union(unions, remove_duplicates: false, remove_order: false)
+ .limit(batch_size)
+ .to_a
+ else
+ # selecting partition as partition_number to workaround the sliding partitioning column ignore
+ select(arel_table[Arel.star], arel_table[:partition].as('partition_number'))
+ .for_table(table)
+ .status_pending
+ .consume_order
+ .limit(batch_size)
+ .to_a
+ end
end
def self.mark_records_processed(records)
diff --git a/app/models/member.rb b/app/models/member.rb
index f164ea244b4..cdf40eaa8f5 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -153,6 +153,7 @@ class Member < ApplicationRecord
scope :not_accepted_invitations, -> { invite.where(invite_accepted_at: nil) }
scope :not_accepted_invitations_by_user, -> (user) { not_accepted_invitations.where(created_by: user) }
scope :not_expired, -> (today = Date.current) { where(arel_table[:expires_at].gt(today).or(arel_table[:expires_at].eq(nil))) }
+ scope :expiring_and_not_notified, ->(date) { where("expiry_notified_at is null AND expires_at >= ? AND expires_at <= ?", Date.current, date) }
scope :created_today, -> do
now = Date.current
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 2773569161d..469dba42952 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -656,8 +656,8 @@ class MergeRequest < ApplicationRecord
[:assignees, :reviewers] + super
end
- def committers
- @committers ||= commits.committers
+ def committers(with_merge_commits: false)
+ @committers ||= commits.committers(with_merge_commits: with_merge_commits)
end
# Verifies if title has changed not taking into account Draft prefix
@@ -984,6 +984,18 @@ class MergeRequest < ApplicationRecord
branch_merge_base_commit.try(:sha)
end
+ def existing_mrs_targeting_same_branch
+ similar_mrs = target_project
+ .merge_requests
+ .where(source_branch: source_branch, target_branch: target_branch)
+ .where(source_project: source_project)
+ .opened
+
+ similar_mrs = similar_mrs.id_not_in(id) if persisted?
+
+ similar_mrs
+ end
+
def validate_branches
return unless target_project && source_project
@@ -995,25 +1007,24 @@ class MergeRequest < ApplicationRecord
[:source_branch, :target_branch].each { |attr| validate_branch_name(attr) }
if opened?
- similar_mrs = target_project
- .merge_requests
- .where(source_branch: source_branch, target_branch: target_branch)
- .where(source_project_id: source_project&.id)
- .opened
+ conflicting_mr = existing_mrs_targeting_same_branch.first
- similar_mrs = similar_mrs.where.not(id: id) if persisted?
-
- conflict = similar_mrs.first
-
- if conflict.present?
+ if conflicting_mr
errors.add(
:validate_branches,
- "Another open merge request already exists for this source branch: #{conflict.to_reference}"
+ conflicting_mr_message(conflicting_mr)
)
end
end
end
+ def conflicting_mr_message(conflicting_mr)
+ format(
+ _("Another open merge request already exists for this source branch: %{conflicting_mr_reference}"),
+ conflicting_mr_reference: conflicting_mr.to_reference
+ )
+ end
+
def validate_branch_name(attr)
return unless will_save_change_to_attribute?(attr)
@@ -1155,7 +1166,7 @@ class MergeRequest < ApplicationRecord
MergeRequests::ReloadDiffsService.new(self, current_user).execute
end
- def check_mergeability(async: false)
+ def check_mergeability(async: false, sync_retry_lease: false)
return unless recheck_merge_status?
check_service = MergeRequests::MergeabilityCheckService.new(self)
@@ -1163,7 +1174,7 @@ class MergeRequest < ApplicationRecord
if async
check_service.async_execute
else
- check_service.execute(retry_lease: false)
+ check_service.execute(retry_lease: sync_retry_lease)
end
end
# rubocop: enable CodeReuse/ServiceClass
@@ -1207,14 +1218,14 @@ class MergeRequest < ApplicationRecord
}
end
- def mergeable?(skip_ci_check: false, skip_discussions_check: false, skip_approved_check: false)
+ def mergeable?(skip_ci_check: false, skip_discussions_check: false, skip_approved_check: false, check_mergeability_retry_lease: false)
return false unless mergeable_state?(
skip_ci_check: skip_ci_check,
skip_discussions_check: skip_discussions_check,
skip_approved_check: skip_approved_check
)
- check_mergeability
+ check_mergeability(sync_retry_lease: check_mergeability_retry_lease)
can_be_merged? && !should_be_rebased?
end
@@ -1537,20 +1548,29 @@ class MergeRequest < ApplicationRecord
end
def schedule_cleanup_refs(only: :all)
- if Feature.enabled?(:merge_request_cleanup_ref_worker_async, target_project)
+ if Feature.enabled?(:merge_request_delete_gitaly_refs_in_batches, target_project)
+ async_cleanup_refs(only: only)
+ elsif Feature.enabled?(:merge_request_cleanup_ref_worker_async, target_project)
MergeRequests::CleanupRefWorker.perform_async(id, only.to_s)
else
cleanup_refs(only: only)
end
end
- def cleanup_refs(only: :all)
+ def refs_to_cleanup(only: :all)
target_refs = []
target_refs << ref_path if %i[all head].include?(only)
target_refs << merge_ref_path if %i[all merge].include?(only)
target_refs << train_ref_path if %i[all train].include?(only)
+ target_refs
+ end
+
+ def cleanup_refs(only: :all)
+ project.repository.delete_refs(*refs_to_cleanup(only: only))
+ end
- project.repository.delete_refs(*target_refs)
+ def async_cleanup_refs(only: :all)
+ project.repository.async_delete_refs(*refs_to_cleanup(only: only))
end
def self.merge_request_ref?(ref)
diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb
index a13cb353c7b..3c592c0008f 100644
--- a/app/models/merge_request/metrics.rb
+++ b/app/models/merge_request/metrics.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
class MergeRequest::Metrics < ApplicationRecord
- include IgnorableColumns
include DatabaseEventTracking
belongs_to :merge_request, inverse_of: :metrics
@@ -17,8 +16,6 @@ class MergeRequest::Metrics < ApplicationRecord
scope :with_valid_time_to_merge, -> { where(arel_table[:merged_at].gt(arel_table[:created_at])) }
scope :by_target_project, ->(project) { where(target_project_id: project) }
- ignore_column :id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22'
-
class << self
def time_to_merge_expression
Arel.sql('EXTRACT(epoch FROM SUM(AGE(merge_request_metrics.merged_at, merge_request_metrics.created_at)))')
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index 33930836c48..bddc03d8b21 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -7,6 +7,7 @@ class MergeRequestDiff < ApplicationRecord
include EachBatch
include Gitlab::Utils::StrongMemoize
include BulkInsertableAssociations
+ include ShaAttribute
# Don't display more than 100 commits at once
COMMITS_SAFE_SIZE = 100
@@ -34,6 +35,8 @@ class MergeRequestDiff < ApplicationRecord
has_many :merge_request_diff_commits, -> { order(:merge_request_diff_id, :relative_order) }, inverse_of: :merge_request_diff
+ sha_attribute :patch_id_sha
+
validates :base_commit_sha, :head_commit_sha, :start_commit_sha, sha: true
validates :merge_request_id, uniqueness: { scope: :diff_type }, if: :merge_head?
diff --git a/app/models/metrics/dashboard/annotation.rb b/app/models/metrics/dashboard/annotation.rb
index d3d3f973398..ac0fcb41089 100644
--- a/app/models/metrics/dashboard/annotation.rb
+++ b/app/models/metrics/dashboard/annotation.rb
@@ -7,15 +7,10 @@ module Metrics
self.table_name = 'metrics_dashboard_annotations'
- belongs_to :environment, inverse_of: :metrics_dashboard_annotations
- belongs_to :cluster, class_name: 'Clusters::Cluster', inverse_of: :metrics_dashboard_annotations
-
validates :starting_at, presence: true
validates :description, presence: true, length: { maximum: 255 }
validates :dashboard_path, presence: true, length: { maximum: 255 }
validates :panel_xid, length: { maximum: 255 }
- validate :single_ownership
- validate :orphaned_annotation
validate :ending_at_after_starting_at
scope :after, ->(after) { where('starting_at >= ?', after) }
@@ -34,18 +29,6 @@ module Metrics
errors.add(:ending_at, s_("MetricsDashboardAnnotation|can't be before starting_at time"))
end
-
- def single_ownership
- return if cluster.nil? ^ environment.nil?
-
- errors.add(:base, s_("MetricsDashboardAnnotation|Annotation can't belong to both a cluster and an environment at the same time"))
- end
-
- def orphaned_annotation
- return if cluster.present? || environment.present?
-
- errors.add(:base, s_("MetricsDashboardAnnotation|Annotation must belong to a cluster or an environment"))
- end
end
end
end
diff --git a/app/models/ml/experiment.rb b/app/models/ml/experiment.rb
index 5c5f8d3b2db..ad6c6b7b3bf 100644
--- a/app/models/ml/experiment.rb
+++ b/app/models/ml/experiment.rb
@@ -59,6 +59,10 @@ module Ml
numeric?(iid)
end
+ def find_or_create(project, name, user)
+ create_with(user: user).find_or_create_by(project: project, name: name)
+ end
+
private
def numeric?(value)
diff --git a/app/models/ml/model.rb b/app/models/ml/model.rb
index 684b8e1983b..fb15b9fea72 100644
--- a/app/models/ml/model.rb
+++ b/app/models/ml/model.rb
@@ -2,6 +2,8 @@
module Ml
class Model < ApplicationRecord
+ include Presentable
+
validates :project, :default_experiment, presence: true
validates :name,
format: Gitlab::Regex.ml_model_name_regex,
@@ -14,6 +16,10 @@ module Ml
has_one :default_experiment, class_name: 'Ml::Experiment'
belongs_to :project
has_many :versions, class_name: 'Ml::ModelVersion'
+ has_one :latest_version, -> { latest_by_model }, class_name: 'Ml::ModelVersion', inverse_of: :model
+
+ scope :including_latest_version, -> { includes(:latest_version) }
+ scope :by_project, ->(project) { where(project_id: project.id) }
def valid_default_experiment?
return unless default_experiment
@@ -21,5 +27,10 @@ module Ml
errors.add(:default_experiment) unless default_experiment.name == name
errors.add(:default_experiment) unless default_experiment.project_id == project_id
end
+
+ def self.find_or_create(project, name, experiment)
+ create_with(default_experiment: experiment)
+ .find_or_create_by(project: project, name: name)
+ end
end
end
diff --git a/app/models/ml/model_version.rb b/app/models/ml/model_version.rb
index 540fe6018a1..6d0e7c35865 100644
--- a/app/models/ml/model_version.rb
+++ b/app/models/ml/model_version.rb
@@ -5,7 +5,7 @@ module Ml
validates :project, :model, presence: true
validates :version,
- format: Gitlab::Regex.ml_model_version_regex,
+ format: Gitlab::Regex.semver_regex,
uniqueness: { scope: [:project, :model_id] },
presence: true,
length: { maximum: 255 }
@@ -18,6 +18,15 @@ module Ml
delegate :name, to: :model
+ scope :order_by_model_id_id_desc, -> { order('model_id, id DESC') }
+ scope :latest_by_model, -> { order_by_model_id_id_desc.select('DISTINCT ON (model_id) *') }
+
+ class << self
+ def find_or_create!(model, version, package)
+ create_with(package: package).find_or_create_by!(project: model.project, model: model, version: version)
+ end
+ end
+
private
def valid_model?
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 5449f006a2e..a7d03c3688a 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -137,6 +137,7 @@ class Namespace < ApplicationRecord
:pypi_package_requests_forwarding,
:npm_package_requests_forwarding,
to: :package_settings
+ delegate :default_branch_protection_defaults, to: :namespace_settings, allow_nil: true
before_save :update_new_emails_created_column, if: -> { emails_disabled_changed? }
before_create :sync_share_with_group_lock_with_parent
@@ -234,6 +235,7 @@ class Namespace < ApplicationRecord
if include_parents
without_project_namespaces
.where(id: Route.for_routable_type(Namespace.name)
+ .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/420046")
.fuzzy_search(query, [Route.arel_table[:path], Route.arel_table[:name]],
use_minimum_char_limit: use_minimum_char_limit)
.select(:source_id))
@@ -543,8 +545,8 @@ class Namespace < ApplicationRecord
def changing_allow_descendants_override_disabled_shared_runners_is_allowed
return unless new_record? || changes.has_key?(:allow_descendants_override_disabled_shared_runners)
- if shared_runners_enabled && !new_record?
- errors.add(:allow_descendants_override_disabled_shared_runners, _('cannot be changed if shared runners are enabled'))
+ if shared_runners_enabled && allow_descendants_override_disabled_shared_runners
+ errors.add(:allow_descendants_override_disabled_shared_runners, _('can not be true if shared runners are enabled'))
end
if allow_descendants_override_disabled_shared_runners && has_parent? && parent.shared_runners_setting == SR_DISABLED_AND_UNOVERRIDABLE
diff --git a/app/models/namespace/aggregation_schedule.rb b/app/models/namespace/aggregation_schedule.rb
index 6c977505f17..08187a9273e 100644
--- a/app/models/namespace/aggregation_schedule.rb
+++ b/app/models/namespace/aggregation_schedule.rb
@@ -13,11 +13,7 @@ class Namespace::AggregationSchedule < ApplicationRecord
after_create :schedule_root_storage_statistics
def default_lease_timeout
- if Feature.enabled?(:reduce_aggregation_schedule_lease, namespace.root_ancestor)
- ::Gitlab::CurrentSettings.namespace_aggregation_schedule_lease_duration_in_seconds
- else
- 30.minutes.to_i
- end
+ ::Gitlab::CurrentSettings.namespace_aggregation_schedule_lease_duration_in_seconds
end
def schedule_root_storage_statistics
diff --git a/app/models/namespace/detail.rb b/app/models/namespace/detail.rb
index 2660d11171e..6c825b5364f 100644
--- a/app/models/namespace/detail.rb
+++ b/app/models/namespace/detail.rb
@@ -4,6 +4,10 @@ class Namespace::Detail < ApplicationRecord
include IgnorableColumns
ignore_column :free_user_cap_over_limt_notified_at, remove_with: '15.7', remove_after: '2022-11-22'
+ ignore_column :dashboard_notification_at, remove_with: '16.5', remove_after: '2023-08-22'
+ ignore_column :dashboard_enforcement_at, remove_with: '16.5', remove_after: '2023-08-22'
+ ignore_column :next_over_limit_check_at, remove_with: '16.5', remove_after: '2023-08-22'
+ ignore_column :free_user_cap_over_limit_notified_at, remove_with: '16.5', remove_after: '2023-08-22'
belongs_to :namespace, inverse_of: :namespace_details
validates :namespace, presence: true
diff --git a/app/models/namespace/package_setting.rb b/app/models/namespace/package_setting.rb
index 22c3e41ff21..a249bb144f9 100644
--- a/app/models/namespace/package_setting.rb
+++ b/app/models/namespace/package_setting.rb
@@ -12,7 +12,7 @@ class Namespace::PackageSetting < ApplicationRecord
PackageSettingNotImplemented = Class.new(StandardError)
- PACKAGES_WITH_SETTINGS = %w[maven generic].freeze
+ PACKAGES_WITH_SETTINGS = %w[maven generic nuget].freeze
belongs_to :namespace, inverse_of: :package_setting_relation
@@ -21,6 +21,8 @@ class Namespace::PackageSetting < ApplicationRecord
validates :maven_duplicate_exception_regex, untrusted_regexp: true, length: { maximum: 255 }
validates :generic_duplicates_allowed, inclusion: { in: [true, false] }
validates :generic_duplicate_exception_regex, untrusted_regexp: true, length: { maximum: 255 }
+ validates :nuget_duplicates_allowed, inclusion: { in: [true, false] }
+ validates :nuget_duplicate_exception_regex, untrusted_regexp: true, length: { maximum: 255 }
class << self
def duplicates_allowed?(package)
diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb
index 5b114bb42aa..8d5d788c738 100644
--- a/app/models/namespace_setting.rb
+++ b/app/models/namespace_setting.rb
@@ -45,6 +45,7 @@ class NamespaceSetting < ApplicationRecord
enabled_git_access_protocol
subgroup_runner_token_expiration_interval
project_runner_token_expiration_interval
+ default_branch_protection_defaults
].freeze
# matches the size set in the database constraint
diff --git a/app/models/namespaces/project_namespace.rb b/app/models/namespaces/project_namespace.rb
index bf23fc21124..288c5c0d2d4 100644
--- a/app/models/namespaces/project_namespace.rb
+++ b/app/models/namespaces/project_namespace.rb
@@ -6,10 +6,10 @@ module Namespaces
# project.namespace/project.namespace_id attribute.
#
# TODO: we can remove these attribute aliases when we no longer need to sync these with project model,
- # see project#sync_attributes
+ # see ProjectNamespace#sync_attributes_from_project
alias_attribute :namespace, :parent
alias_attribute :namespace_id, :parent_id
- has_one :project, foreign_key: :project_namespace_id, inverse_of: :project_namespace
+ has_one :project, inverse_of: :project_namespace
delegate :execute_hooks, :execute_integrations, :group, to: :project, allow_nil: true
delegate :external_references_supported?, :default_issues_tracker?, to: :project
@@ -21,5 +21,40 @@ module Namespaces
def self.polymorphic_name
'Namespaces::ProjectNamespace'
end
+
+ def self.create_from_project!(project)
+ return unless project.new_record?
+ return unless project.namespace
+
+ proj_namespace = project.project_namespace || project.build_project_namespace
+ project.project_namespace.sync_attributes_from_project(project)
+ proj_namespace.save!
+ proj_namespace
+ end
+
+ def sync_attributes_from_project(project)
+ attributes_to_sync = project
+ .changes
+ .slice(*%w[name path namespace_id namespace visibility_level shared_runners_enabled])
+ .transform_values { |val| val[1] }
+
+ # if visibility_level is not set explicitly for project, it defaults to 0,
+ # but for namespace visibility_level defaults to 20,
+ # so it gets out of sync right away if we do not set it explicitly when creating the project namespace
+ attributes_to_sync['visibility_level'] ||= project.visibility_level if project.new_record?
+
+ # when a project is associated with a group while the group is created we need to ensure we associate the new
+ # group with the project namespace as well.
+ # E.g.
+ # project = create(:project) <- project is saved
+ # create(:group, projects: [project]) <- associate project with a group that is not yet created.
+ if attributes_to_sync.has_key?('namespace_id') &&
+ attributes_to_sync['namespace_id'].blank? &&
+ project.namespace.present?
+ attributes_to_sync['parent'] = project.namespace
+ end
+
+ assign_attributes(attributes_to_sync)
+ end
end
end
diff --git a/app/models/network/graph.rb b/app/models/network/graph.rb
index 7ffcb8b9219..0f410d4810d 100644
--- a/app/models/network/graph.rb
+++ b/app/models/network/graph.rb
@@ -86,6 +86,7 @@ module Network
skip = 0
while offset == -1
tmp_commits = find_commits(skip)
+
if tmp_commits.present?
index = tmp_commits.index do |c|
c.id == @commit.id
@@ -112,15 +113,17 @@ module Network
end
def find_commits(skip = 0)
- opts = {
- max_count: self.class.max_count,
- skip: skip,
- order: :date
- }
+ Gitlab::SafeRequestStore.fetch([@project, :network_graph_commits, skip]) do
+ opts = {
+ max_count: self.class.max_count,
+ skip: skip,
+ order: :date
+ }
- opts[:ref] = @commit.id if @filter_ref
+ opts[:ref] = @commit.id if @filter_ref
- Gitlab::Git::Commit.find_all(@repo.raw_repository, opts)
+ Gitlab::Git::Commit.find_all(@repo.raw_repository, opts)
+ end
end
def commits_sort_by_ref
diff --git a/app/models/note.rb b/app/models/note.rb
index 2df643c46aa..f1760a8dc4a 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -149,7 +149,7 @@ class Note < ApplicationRecord
scope :with_api_entity_associations, -> { preload(:note_diff_file, :author) }
scope :inc_relations_for_view, ->(noteable = nil) do
relations = [{ project: :group }, { author: :status }, :updated_by, :resolved_by,
- :award_emoji, { system_note_metadata: :description_version }, :suggestions]
+ :award_emoji, :note_metadata, { system_note_metadata: :description_version }, :suggestions]
if noteable.nil? || DiffNote.noteable_types.include?(noteable.class.name)
relations += [:note_diff_file, :diff_note_positions]
@@ -197,9 +197,7 @@ class Note < ApplicationRecord
# Syncs `confidential` with `internal` as we rename the column.
# https://gitlab.com/gitlab-org/gitlab/-/issues/367923
before_create :set_internal_flag
- after_destroy :expire_etag_cache
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?
after_commit :notify_after_create, on: :create
after_commit :notify_after_destroy, on: :destroy
@@ -207,6 +205,7 @@ class Note < ApplicationRecord
after_commit :trigger_note_subscription_create, on: :create
after_commit :trigger_note_subscription_update, on: :update
after_commit :trigger_note_subscription_destroy, on: :destroy
+ after_commit :expire_etag_cache, unless: :importing?
def trigger_note_subscription_create
return unless trigger_note_subscription?
@@ -498,7 +497,7 @@ class Note < ApplicationRecord
end
def can_be_discussion_note?
- self.noteable.supports_discussions? && !part_of_discussion?
+ self.noteable.supports_discussions? && !part_of_discussion? && !system?
end
def can_create_todo?
@@ -853,7 +852,9 @@ class Note < ApplicationRecord
user_visible_reference_count > 0 && user_visible_reference_count == total_reference_count
else
refs = all_references(user)
- refs.all.any? && refs.all_visible?
+ refs.all
+
+ refs.all_visible?
end
end
diff --git a/app/models/operations/feature_flag.rb b/app/models/operations/feature_flag.rb
index 6876af09c2c..01db0a5cf8b 100644
--- a/app/models/operations/feature_flag.rb
+++ b/app/models/operations/feature_flag.rb
@@ -30,7 +30,9 @@ module Operations
length: 2..63,
format: {
with: Gitlab::Regex.feature_flag_regex,
- message: Gitlab::Regex.feature_flag_regex_message
+ message: ->(_object, _data) {
+ s_("Validation|can contain only lowercase letters, digits, '_' and '-'. Must start with a letter, and cannot end with '-' or '_'")
+ }
}
validates :name, uniqueness: { scope: :project_id }
validates :description, allow_blank: true, length: 0..255
diff --git a/app/models/operations/feature_flags/strategy.rb b/app/models/operations/feature_flags/strategy.rb
index ed9400dde8f..5dc6de7dfc1 100644
--- a/app/models/operations/feature_flags/strategy.rb
+++ b/app/models/operations/feature_flags/strategy.rb
@@ -28,7 +28,7 @@ module Operations
validates :name,
inclusion: {
in: STRATEGIES.keys,
- message: 'strategy name is invalid'
+ message: ->(_object, _data) { s_('Validation|strategy name is invalid') }
}
validate :parameters_validations, if: -> { errors[:name].blank? }
@@ -46,7 +46,7 @@ module Operations
def same_project_validation
unless user_list.project_id == feature_flag.project_id
- errors.add(:user_list, 'must belong to the same project')
+ errors.add(:user_list, s_('Validation|must belong to the same project'))
end
end
@@ -57,13 +57,13 @@ module Operations
end
def validate_parameters_type
- parameters.is_a?(Hash) || parameters_error('parameters are invalid')
+ parameters.is_a?(Hash) || parameters_error(s_('Validation|parameters are invalid'))
end
def validate_parameters_keys
actual_keys = parameters.keys.sort
expected_keys = STRATEGIES[name].sort
- expected_keys == actual_keys || parameters_error('parameters are invalid')
+ expected_keys == actual_keys || parameters_error(s_('Validation|parameters are invalid'))
end
def validate_parameters_values
@@ -89,11 +89,11 @@ module Operations
group_id = parameters['groupId']
unless within_range?(percentage, 0, 100)
- parameters_error('percentage must be a string between 0 and 100 inclusive')
+ parameters_error(s_('Validation|percentage must be a string between 0 and 100 inclusive'))
end
unless group_id.is_a?(String) && group_id.match(/\A[a-z]{1,32}\z/)
- parameters_error('groupId parameter is invalid')
+ parameters_error(s_('Validation|groupId parameter is invalid'))
end
end
@@ -108,11 +108,11 @@ module Operations
end
unless group_id.is_a?(String) && group_id.match(/\A[a-z]{1,32}\z/)
- parameters_error('groupId parameter is invalid')
+ parameters_error(s_('Validation|groupId parameter is invalid'))
end
unless within_range?(rollout, 0, 100)
- parameters_error('rollout must be a string between 0 and 100 inclusive')
+ parameters_error(s_('Validation|rollout must be a string between 0 and 100 inclusive'))
end
end
diff --git a/app/models/organizations/organization.rb b/app/models/organizations/organization.rb
index 8aeca2eb137..9f2119949fb 100644
--- a/app/models/organizations/organization.rb
+++ b/app/models/organizations/organization.rb
@@ -37,6 +37,10 @@ module Organizations
path
end
+ def user?(user)
+ users.exists?(user.id)
+ end
+
private
def check_if_default_organization
diff --git a/app/models/packages/nuget/metadatum.rb b/app/models/packages/nuget/metadatum.rb
index fae7728cccb..e7cf4528f16 100644
--- a/app/models/packages/nuget/metadatum.rb
+++ b/app/models/packages/nuget/metadatum.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Packages::Nuget::Metadatum < ApplicationRecord
+ include Packages::Nuget::VersionNormalizable
+
MAX_AUTHORS_LENGTH = 255
MAX_DESCRIPTION_LENGTH = 4000
MAX_URL_LENGTH = 255
@@ -13,9 +15,15 @@ class Packages::Nuget::Metadatum < ApplicationRecord
validates :icon_url, public_url: { allow_blank: true }, length: { maximum: MAX_URL_LENGTH }
validates :authors, presence: true, length: { maximum: MAX_AUTHORS_LENGTH }
validates :description, presence: true, length: { maximum: MAX_DESCRIPTION_LENGTH }
+ validates :normalized_version, presence: true,
+ if: -> { Feature.enabled?(:nuget_normalized_version, package&.project) }
validate :ensure_nuget_package_type
+ delegate :version, to: :package, prefix: true
+
+ scope :normalized_version_in, ->(version) { where(normalized_version: version.downcase) }
+
private
def ensure_nuget_package_type
diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb
index b618c7c20c4..b09911f4216 100644
--- a/app/models/packages/package.rb
+++ b/app/models/packages/package.rb
@@ -10,6 +10,7 @@ class Packages::Package < ApplicationRecord
DISPLAYABLE_STATUSES = [:default, :error].freeze
INSTALLABLE_STATUSES = [:default, :hidden].freeze
+ STATUS_MESSAGE_MAX_LENGTH = 255
enum package_type: {
maven: 1,
@@ -123,6 +124,22 @@ class Packages::Package < ApplicationRecord
where('LOWER(version) = ?', version.downcase)
end
+ scope :with_case_insensitive_name, ->(name) do
+ where(arel_table[:name].lower.eq(name.downcase))
+ end
+
+ scope :with_nuget_version_or_normalized_version, ->(version, with_normalized: true) do
+ relation = with_case_insensitive_version(version)
+
+ return relation unless with_normalized
+
+ relation
+ .left_joins(:nuget_metadatum)
+ .or(
+ merge(Packages::Nuget::Metadatum.normalized_version_in(version))
+ )
+ end
+
scope :search_by_name, ->(query) { fuzzy_search(query, [:name], use_minimum_char_limit: false) }
scope :with_version, ->(version) { where(version: version) }
scope :without_version_like, -> (version) { where.not(arel_table[:version].matches(version)) }
@@ -161,6 +178,14 @@ class Packages::Package < ApplicationRecord
scope :preload_pypi_metadatum, -> { preload(:pypi_metadatum) }
scope :preload_conan_metadatum, -> { preload(:conan_metadatum) }
+ scope :with_npm_scope, ->(scope) do
+ if Feature.enabled?(:npm_package_registry_fix_group_path_validation)
+ npm.where("position('/' in packages_packages.name) > 0 AND split_part(packages_packages.name, '/', 1) = :package_scope", package_scope: "@#{sanitize_sql_like(scope)}")
+ else
+ npm.where("name ILIKE :package_name", package_name: "@#{sanitize_sql_like(scope)}/%")
+ end
+ end
+
scope :without_nuget_temporary_name, -> { where.not(name: Packages::Nuget::TEMPORARY_PACKAGE_NAME) }
scope :has_version, -> { where.not(version: nil) }
@@ -169,14 +194,12 @@ class Packages::Package < ApplicationRecord
scope :preload_pipelines, -> { preload(pipelines: :user) }
scope :limit_recent, ->(limit) { order_created_desc.limit(limit) }
scope :select_distinct_name, -> { select(:name).distinct }
- scope :select_only_first_by_name, -> { select('DISTINCT ON (name) *') }
# Sorting
scope :order_created, -> { reorder(created_at: :asc) }
scope :order_created_desc, -> { reorder(created_at: :desc) }
scope :order_name, -> { reorder(name: :asc) }
scope :order_name_desc, -> { reorder(name: :desc) }
- scope :order_name_desc_version_desc, -> { reorder(name: :desc, version: :desc) }
scope :order_version, -> { reorder(version: :asc) }
scope :order_version_desc, -> { reorder(version: :desc) }
scope :order_type, -> { reorder(package_type: :asc) }
@@ -184,7 +207,6 @@ class Packages::Package < ApplicationRecord
scope :order_project_name, -> { joins(:project).reorder('projects.name ASC') }
scope :order_project_name_desc, -> { joins(:project).reorder('projects.name DESC') }
scope :order_by_package_file, -> { joins(:package_files).order('packages_package_files.created_at ASC') }
- scope :with_npm_scope, ->(scope) { npm.where("name ILIKE :package_name", package_name: "@#{sanitize_sql_like(scope)}/%") }
scope :order_project_path, -> do
keyset_order = keyset_pagination_order(join_class: Project, column_name: :path, direction: :asc)
@@ -361,6 +383,12 @@ class Packages::Package < ApplicationRecord
name.gsub(/#{Gitlab::Regex::Packages::PYPI_NORMALIZED_NAME_REGEX_STRING}/o, '-').downcase
end
+ def normalized_nuget_version
+ return unless nuget?
+
+ nuget_metadatum&.normalized_version
+ end
+
def publish_creation_event
::Gitlab::EventStore.publish(
::Packages::PackageCreatedEvent.new(data: {
diff --git a/app/models/pages_deployment.rb b/app/models/pages_deployment.rb
index fa29cbf8352..ec2293fa032 100644
--- a/app/models/pages_deployment.rb
+++ b/app/models/pages_deployment.rb
@@ -29,18 +29,13 @@ class PagesDeployment < ApplicationRecord
mount_file_store_uploader ::Pages::DeploymentUploader
- skip_callback :save, :after, :store_file!, if: :store_after_commit?
- after_commit :store_file_after_commit!, on: [:create, :update], if: :store_after_commit?
+ skip_callback :save, :after, :store_file!
+ after_commit :store_file_after_commit!, on: [:create, :update]
def migrated?
file.filename == MIGRATED_FILE_NAME
end
- def store_after_commit?
- Feature.enabled?(:pages_deploy_upload_file_outside_transaction, project)
- end
- strong_memoize_attr :store_after_commit?
-
private
def set_size
diff --git a/app/models/performance_monitoring/prometheus_dashboard.rb b/app/models/performance_monitoring/prometheus_dashboard.rb
deleted file mode 100644
index 6fea3abf3d9..00000000000
--- a/app/models/performance_monitoring/prometheus_dashboard.rb
+++ /dev/null
@@ -1,78 +0,0 @@
-# frozen_string_literal: true
-
-module PerformanceMonitoring
- class PrometheusDashboard
- include ActiveModel::Model
-
- attr_accessor :dashboard, :panel_groups, :path, :environment, :priority, :templating, :links
-
- validates :dashboard, presence: true
- validates :panel_groups, array_members: { member_class: PerformanceMonitoring::PrometheusPanelGroup }
-
- class << self
- def from_json(json_content)
- build_from_hash(json_content).tap(&:validate!)
- end
-
- def find_for(project:, user:, path:, options: {})
- template = { path: path, environment: options[:environment] }
- rsp = Gitlab::Metrics::Dashboard::Finder.find(project, user, options.merge(dashboard_path: path))
-
- case rsp[:http_status] || rsp[:status]
- when :success
- new(template.merge(rsp[:dashboard] || {})) # when there is empty dashboard file returned rsp is still a success
- when :unprocessable_entity
- new(template) # validation error
- else
- nil # any other error
- end
- end
-
- private
-
- def build_from_hash(attributes)
- return new unless attributes.is_a?(Hash)
-
- new(
- dashboard: attributes['dashboard'],
- panel_groups: initialize_children_collection(attributes['panel_groups'])
- )
- end
-
- def initialize_children_collection(children)
- return unless children.is_a?(Array)
-
- children.map { |group| PerformanceMonitoring::PrometheusPanelGroup.from_json(group) }
- end
- end
-
- def to_yaml
- self.as_json(only: yaml_valid_attributes).to_yaml
- end
-
- # This method is planned to be refactored as a part of https://gitlab.com/gitlab-org/gitlab/-/issues/219398
- # implementation. For new existing logic was reused to faster deliver MVC
- def schema_validation_warnings
- self.class.from_json(reload_schema)
- []
- rescue Gitlab::Metrics::Dashboard::Errors::LayoutError => e
- [e.message]
- rescue ActiveModel::ValidationError => e
- e.model.errors.map { |error| "#{error.attribute}: #{error.message}" }
- end
-
- private
-
- # dashboard finder methods are somehow limited, #find includes checking if
- # user is authorised to view selected dashboard, but modifies schema, which in some cases may
- # cause false positives returned from validation, and #find_raw does not authorise users
- def reload_schema
- project = environment&.project
- project.nil? ? self.as_json : Gitlab::Metrics::Dashboard::Finder.find_raw(project, dashboard_path: path)
- end
-
- def yaml_valid_attributes
- %w(panel_groups panels metrics group priority type title y_label weight id unit label query query_range dashboard)
- end
- end
-end
diff --git a/app/models/plan.rb b/app/models/plan.rb
index e16ecb4c629..22c1201421c 100644
--- a/app/models/plan.rb
+++ b/app/models/plan.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Plan < ApplicationRecord
+class Plan < MainClusterwide::ApplicationRecord
DEFAULT = 'default'
has_one :limits, class_name: 'PlanLimits'
diff --git a/app/models/pool_repository.rb b/app/models/pool_repository.rb
index f22a63ee980..bc3898fafe7 100644
--- a/app/models/pool_repository.rb
+++ b/app/models/pool_repository.rb
@@ -12,7 +12,13 @@ class PoolRepository < ApplicationRecord
has_many :member_projects, class_name: 'Project'
- after_create :correct_disk_path
+ after_create :set_disk_path
+
+ scope :by_source_project, ->(project) { where(source_project: project) }
+ scope :by_source_project_and_shard_name, ->(project, shard_name) do
+ by_source_project(project)
+ .for_repository_storage(shard_name)
+ end
state_machine :state, initial: :none do
state :scheduled
@@ -107,8 +113,8 @@ class PoolRepository < ApplicationRecord
private
- def correct_disk_path
- update!(disk_path: storage.disk_path)
+ def set_disk_path
+ update!(disk_path: storage.disk_path) if disk_path.blank?
end
def storage
diff --git a/app/models/project.rb b/app/models/project.rb
index 8959eccbd1f..ad8757880fd 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -43,6 +43,9 @@ class Project < ApplicationRecord
include Subquery
include IssueParent
include UpdatedAtFilterable
+ include IgnorableColumns
+
+ ignore_column :emails_disabled, remove_with: '16.3', remove_after: '2023-08-22'
extend Gitlab::Cache::RequestCache
extend Gitlab::Utils::Override
@@ -125,7 +128,6 @@ class Project < ApplicationRecord
before_validation :remove_leading_spaces_on_name
after_validation :check_pending_delete
before_save :ensure_runners_token
- before_save :update_new_emails_created_column, if: -> { emails_disabled_changed? }
after_create -> { create_or_load_association(:project_feature) }
after_create -> { create_or_load_association(:ci_cd_settings) }
@@ -165,11 +167,14 @@ class Project < ApplicationRecord
belongs_to :namespace
# Sync deletion via DB Trigger to ensure we do not have
# a project without a project_namespace (or vice-versa)
- belongs_to :project_namespace, autosave: true, class_name: 'Namespaces::ProjectNamespace', foreign_key: 'project_namespace_id'
+ belongs_to :project_namespace, autosave: true, class_name: 'Namespaces::ProjectNamespace', foreign_key: 'project_namespace_id', inverse_of: :project
alias_method :parent, :namespace
alias_attribute :parent_id, :namespace_id
has_one :catalog_resource, class_name: 'Ci::Catalog::Resource', inverse_of: :project
+ has_many :ci_components, class_name: 'Ci::Catalog::Resources::Component', inverse_of: :project
+ has_many :catalog_resource_versions, class_name: 'Ci::Catalog::Resources::Version', inverse_of: :project
+
has_one :last_event, -> { order 'events.created_at DESC' }, class_name: 'Event'
has_many :boards
@@ -312,7 +317,8 @@ class Project < ApplicationRecord
has_many :designs, inverse_of: :project, class_name: 'DesignManagement::Design'
has_many :project_authorizations
- has_many :authorized_users, through: :project_authorizations, source: :user, class_name: 'User'
+ has_many :authorized_users, -> { allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422045') },
+ through: :project_authorizations, source: :user, class_name: 'User'
has_many :project_members, -> { where(requested_at: nil) },
as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
@@ -506,6 +512,7 @@ class Project < ApplicationRecord
with_options prefix: :ci do
delegate :default_git_depth, :default_git_depth=
delegate :forward_deployment_enabled, :forward_deployment_enabled=
+ delegate :forward_deployment_rollback_allowed, :forward_deployment_rollback_allowed=
delegate :inbound_job_token_scope_enabled, :inbound_job_token_scope_enabled=
delegate :allow_fork_pipelines_to_run_in_parent_project, :allow_fork_pipelines_to_run_in_parent_project=
delegate :separated_caches, :separated_caches=
@@ -518,6 +525,7 @@ class Project < ApplicationRecord
delegate :has_shimo?
delegate :show_diff_preview_in_email, :show_diff_preview_in_email=, :show_diff_preview_in_email?
delegate :runner_registration_enabled, :runner_registration_enabled=, :runner_registration_enabled?
+ delegate :emails_enabled, :emails_enabled=, :emails_enabled?
delegate :squash_always?, :squash_never?, :squash_enabled_by_default?, :squash_readonly?
delegate :mr_default_target_self, :mr_default_target_self=
delegate :previous_default_branch, :previous_default_branch=
@@ -585,6 +593,7 @@ class Project < ApplicationRecord
scope :pending_delete, -> { where(pending_delete: true) }
scope :without_deleted, -> { where(pending_delete: false) }
scope :not_hidden, -> { where(hidden: false) }
+ scope :not_in_groups, ->(groups) { where.not(group: groups) }
scope :not_aimed_for_deletion, -> { where(marked_for_deletion_at: nil).without_deleted }
scope :with_storage_feature, ->(feature) do
@@ -703,6 +712,7 @@ class Project < ApplicationRecord
# includes(:route) which we use in ProjectsFinder.
joins("INNER JOIN routes rs ON rs.source_id = projects.id AND rs.source_type = 'Project'")
.where('rs.path LIKE ?', "#{sanitize_sql_like(path)}/%")
+ .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/421843')
end
scope :with_feature_enabled, ->(feature) {
@@ -932,6 +942,7 @@ class Project < ApplicationRecord
if include_namespace
joins(:route).fuzzy_search(query, [Route.arel_table[:path], Route.arel_table[:name], :description],
use_minimum_char_limit: use_minimum_char_limit)
+ .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/421843')
else
fuzzy_search(query, [:path, :name, :description], use_minimum_char_limit: use_minimum_char_limit)
end
@@ -1209,14 +1220,8 @@ class Project < ApplicationRecord
end
def emails_disabled?
- strong_memoize(:emails_disabled) do
- # disabling in the namespace overrides the project setting
- super || namespace.emails_disabled?
- end
- end
-
- def emails_enabled?
- !emails_disabled?
+ # disabling in the namespace overrides the project setting
+ !emails_enabled?
end
override :lfs_enabled?
@@ -1760,7 +1765,8 @@ class Project < ApplicationRecord
# rubocop: disable CodeReuse/ServiceClass
def create_labels
Label.templates.each do |label|
- params = label.attributes.except('id', 'template', 'created_at', 'updated_at', 'type')
+ # slice on column_names to ensure an added DB column will not break a mixed deployment
+ params = label.attributes.slice(*Label.column_names).except('id', 'template', 'created_at', 'updated_at', 'type')
Labels::FindOrCreateService.new(nil, self, params).execute(skip_authorization: true)
end
end
@@ -1951,6 +1957,8 @@ class Project < ApplicationRecord
def track_project_repository
repository = project_repository || build_project_repository
repository.update!(shard_name: repository_storage, disk_path: disk_path)
+
+ cleanup if replicate_object_pool_on_move_ff_enabled?
end
def create_repository(force: false, default_branch: nil)
@@ -2466,7 +2474,7 @@ class Project < ApplicationRecord
break unless pages_enabled?
variables.append(key: 'CI_PAGES_DOMAIN', value: Gitlab.config.pages.host)
- variables.append(key: 'CI_PAGES_URL', value: Gitlab::Pages::UrlBuilder.new(self).pages_url)
+ variables.append(key: 'CI_PAGES_URL', value: Gitlab::Pages::UrlBuilder.new(self).pages_url(with_unique_domain: true))
end
end
@@ -2825,8 +2833,26 @@ class Project < ApplicationRecord
update_column(:pool_repository_id, nil)
end
+ # After repository is moved from shard to shard, disconnect it from the previous object pool and connect to the new pool
+ def swap_pool_repository!
+ return unless replicate_object_pool_on_move_ff_enabled?
+ return unless repository_exists?
+
+ old_pool_repository = pool_repository
+ return if old_pool_repository.blank?
+ return if pool_repository_shard_matches_repository?(old_pool_repository)
+
+ new_pool_repository = PoolRepository.by_source_project_and_shard_name(old_pool_repository.source_project, repository_storage).take!
+ update!(pool_repository: new_pool_repository)
+
+ old_pool_repository.unlink_repository(repository, disconnect: !pending_delete?)
+ end
+
def link_pool_repository
- pool_repository&.link_repository(repository)
+ return unless pool_repository
+ return if (pool_repository.shard_name != repository.shard) && replicate_object_pool_on_move_ff_enabled?
+
+ pool_repository.link_repository(repository)
end
def has_pool_repository?
@@ -3048,6 +3074,12 @@ class Project < ApplicationRecord
ci_cd_settings.forward_deployment_enabled?
end
+ def ci_forward_deployment_rollback_allowed?
+ return false unless ci_cd_settings
+
+ ci_cd_settings.forward_deployment_rollback_allowed?
+ end
+
def ci_allow_fork_pipelines_to_run_in_parent_project?
return false unless ci_cd_settings
@@ -3151,6 +3183,8 @@ class Project < ApplicationRecord
end
def created_and_owned_by_banned_user?
+ return false unless creator
+
creator.banned? && team.max_member_access(creator.id) == Gitlab::Access::OWNER
end
@@ -3170,6 +3204,10 @@ class Project < ApplicationRecord
group&.work_items_mvc_2_feature_flag_enabled? || Feature.enabled?(:work_items_mvc_2)
end
+ def linked_work_items_feature_flag_enabled?
+ group&.linked_work_items_feature_flag_enabled? || Feature.enabled?(:linked_work_items, self)
+ end
+
def enqueue_record_project_target_platforms
return unless Gitlab.com?
@@ -3437,7 +3475,7 @@ class Project < ApplicationRecord
# create project_namespace when project is created
build_project_namespace if project_namespace_creation_enabled?
- sync_attributes(project_namespace) if sync_project_namespace?
+ project_namespace.sync_attributes_from_project(self) if sync_project_namespace?
end
def project_namespace_creation_enabled?
@@ -3448,27 +3486,6 @@ class Project < ApplicationRecord
(changes.keys & %w(name path namespace_id namespace visibility_level shared_runners_enabled)).any? && project_namespace.present?
end
- def sync_attributes(project_namespace)
- attributes_to_sync = changes.slice(*%w(name path namespace_id namespace visibility_level shared_runners_enabled))
- .transform_values { |val| val[1] }
-
- # if visibility_level is not set explicitly for project, it defaults to 0,
- # but for namespace visibility_level defaults to 20,
- # so it gets out of sync right away if we do not set it explicitly when creating the project namespace
- attributes_to_sync['visibility_level'] ||= visibility_level if new_record?
-
- # when a project is associated with a group while the group is created we need to ensure we associate the new
- # group with the project namespace as well.
- # E.g.
- # project = create(:project) <- project is saved
- # create(:group, projects: [project]) <- associate project with a group that is not yet created.
- if attributes_to_sync.has_key?('namespace_id') && attributes_to_sync['namespace_id'].blank? && namespace.present?
- attributes_to_sync['parent'] = namespace
- end
-
- project_namespace.assign_attributes(attributes_to_sync)
- end
-
def reload_project_namespace_details
return unless (previous_changes.keys & %w(description description_html cached_markdown_version)).any? && project_namespace.namespace_details.present?
@@ -3511,19 +3528,18 @@ class Project < ApplicationRecord
end
end
- def update_new_emails_created_column
- return if project_setting.nil?
- return if project_setting.emails_enabled == !emails_disabled
+ def runners_token_prefix
+ RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX
+ end
- if project_setting.persisted?
- project_setting.update!(emails_enabled: !emails_disabled)
- elsif project_setting
- project_setting.emails_enabled = !emails_disabled
- end
+ def replicate_object_pool_on_move_ff_enabled?
+ Feature.enabled?(:replicate_object_pool_on_move, self)
end
- def runners_token_prefix
- RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX
+ def pool_repository_shard_matches_repository?(pool)
+ pool_repository_shard = pool.shard.name
+
+ pool_repository_shard == repository_storage
end
end
diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb
index cb578496f26..99128d3cddf 100644
--- a/app/models/project_authorization.rb
+++ b/app/models/project_authorization.rb
@@ -1,9 +1,6 @@
# frozen_string_literal: true
class ProjectAuthorization < ApplicationRecord
- BATCH_SIZE = 1000
- SLEEP_DELAY = 0.1
-
extend SuppressCompositePrimaryKeyWarning
include FromUnion
@@ -28,57 +25,6 @@ class ProjectAuthorization < ApplicationRecord
def self.insert_all(attributes)
super(attributes, unique_by: connection.schema_cache.primary_keys(table_name))
end
-
- def self.insert_all_in_batches(attributes, per_batch = BATCH_SIZE)
- add_delay = add_delay_between_batches?(entire_size: attributes.size, batch_size: per_batch)
- log_details(entire_size: attributes.size, batch_size: per_batch) if add_delay
-
- attributes.each_slice(per_batch) do |attributes_batch|
- insert_all(attributes_batch)
- perform_delay if add_delay
- end
- end
-
- def self.delete_all_in_batches_for_project(project:, user_ids:, per_batch: BATCH_SIZE)
- add_delay = add_delay_between_batches?(entire_size: user_ids.size, batch_size: per_batch)
- log_details(entire_size: user_ids.size, batch_size: per_batch) if add_delay
-
- user_ids.each_slice(per_batch) do |user_ids_batch|
- project.project_authorizations.where(user_id: user_ids_batch).delete_all
- perform_delay if add_delay
- end
- end
-
- def self.delete_all_in_batches_for_user(user:, project_ids:, per_batch: BATCH_SIZE)
- add_delay = add_delay_between_batches?(entire_size: project_ids.size, batch_size: per_batch)
- log_details(entire_size: project_ids.size, batch_size: per_batch) if add_delay
-
- project_ids.each_slice(per_batch) do |project_ids_batch|
- user.project_authorizations.where(project_id: project_ids_batch).delete_all
- perform_delay if add_delay
- end
- end
-
- private_class_method def self.add_delay_between_batches?(entire_size:, batch_size:)
- # The reason for adding a delay is to give the replica database enough time to
- # catch up with the primary when large batches of records are being added/removed.
- # Hance, we add a delay only if the GitLab installation has a replica database configured.
- entire_size > batch_size &&
- !::Gitlab::Database::LoadBalancing.primary_only?
- end
-
- private_class_method def self.log_details(entire_size:, batch_size:)
- Gitlab::AppLogger.info(
- entire_size: entire_size,
- total_delay: (entire_size / batch_size.to_f).ceil * SLEEP_DELAY,
- message: 'Project authorizations refresh performed with delay',
- **Gitlab::ApplicationContext.current
- )
- end
-
- private_class_method def self.perform_delay
- sleep(SLEEP_DELAY)
- end
end
ProjectAuthorization.prepend_mod_with('ProjectAuthorization')
diff --git a/app/models/project_authorizations/changes.rb b/app/models/project_authorizations/changes.rb
new file mode 100644
index 00000000000..1d717950c1c
--- /dev/null
+++ b/app/models/project_authorizations/changes.rb
@@ -0,0 +1,143 @@
+# frozen_string_literal: true
+
+module ProjectAuthorizations
+ # How to use this class
+ # authorizations_to_add:
+ # Rows to insert in the form `[{ user_id: user_id, project_id: project_id, access_level: access_level}, ...]
+ #
+ # ProjectAuthorizations::Changes.new do |changes|
+ # changes.add(authorizations_to_add)
+ # changes.remove_users_in_project(project, user_ids)
+ # changes.remove_projects_for_user(user, project_ids)
+ # end.apply!
+ class Changes
+ attr_reader :projects_to_remove, :users_to_remove, :authorizations_to_add
+
+ BATCH_SIZE = 1000
+ SLEEP_DELAY = 0.1
+
+ def initialize
+ @authorizations_to_add = []
+ @affected_project_ids = Set.new
+ yield self
+ end
+
+ def add(authorizations_to_add)
+ @authorizations_to_add += authorizations_to_add
+ end
+
+ def remove_users_in_project(project, user_ids)
+ @users_to_remove = { user_ids: user_ids, scope: project }
+ end
+
+ def remove_projects_for_user(user, project_ids)
+ @projects_to_remove = { project_ids: project_ids, scope: user }
+ end
+
+ def apply!
+ delete_authorizations_for_user if should_delete_authorizations_for_user?
+ delete_authorizations_for_project if should_delete_authorizations_for_project?
+ add_authorizations if should_add_authorization?
+
+ publish_events
+ end
+
+ private
+
+ def should_add_authorization?
+ authorizations_to_add.present?
+ end
+
+ def should_delete_authorizations_for_user?
+ user && project_ids.present?
+ end
+
+ def should_delete_authorizations_for_project?
+ project && user_ids.present?
+ end
+
+ def add_authorizations
+ insert_all_in_batches(authorizations_to_add)
+ @affected_project_ids += authorizations_to_add.pluck(:project_id)
+ end
+
+ def delete_authorizations_for_user
+ delete_all_in_batches(resource: user,
+ ids_to_remove: project_ids,
+ column_name_of_ids_to_remove: :project_id)
+ @affected_project_ids += project_ids
+ end
+
+ def delete_authorizations_for_project
+ delete_all_in_batches(resource: project,
+ ids_to_remove: user_ids,
+ column_name_of_ids_to_remove: :user_id)
+ @affected_project_ids << project.id
+ end
+
+ def delete_all_in_batches(resource:, ids_to_remove:, column_name_of_ids_to_remove:)
+ add_delay = add_delay_between_batches?(entire_size: ids_to_remove.size, batch_size: BATCH_SIZE)
+ log_details(entire_size: ids_to_remove.size, batch_size: BATCH_SIZE) if add_delay
+
+ ids_to_remove.each_slice(BATCH_SIZE) do |ids_batch|
+ resource.project_authorizations.where(column_name_of_ids_to_remove => ids_batch).delete_all
+ perform_delay if add_delay
+ end
+ end
+
+ def insert_all_in_batches(attributes)
+ add_delay = add_delay_between_batches?(entire_size: attributes.size, batch_size: BATCH_SIZE)
+ log_details(entire_size: attributes.size, batch_size: BATCH_SIZE) if add_delay
+
+ attributes.each_slice(BATCH_SIZE) do |attributes_batch|
+ ProjectAuthorization.insert_all(attributes_batch)
+ perform_delay if add_delay
+ end
+ end
+
+ def add_delay_between_batches?(entire_size:, batch_size:)
+ # The reason for adding a delay is to give the replica database enough time to
+ # catch up with the primary when large batches of records are being added/removed.
+ # Hence, we add a delay only if the GitLab installation has a replica database configured.
+ entire_size > batch_size &&
+ !::Gitlab::Database::LoadBalancing.primary_only?
+ end
+
+ def log_details(entire_size:, batch_size:)
+ Gitlab::AppLogger.info(
+ entire_size: entire_size,
+ total_delay: (entire_size / batch_size.to_f).ceil * SLEEP_DELAY,
+ message: 'Project authorizations refresh performed with delay',
+ **Gitlab::ApplicationContext.current
+ )
+ end
+
+ def perform_delay
+ sleep(SLEEP_DELAY)
+ end
+
+ def user
+ projects_to_remove&.[](:scope)
+ end
+
+ def project_ids
+ projects_to_remove&.[](:project_ids)
+ end
+
+ def project
+ users_to_remove&.[](:scope)
+ end
+
+ def user_ids
+ users_to_remove&.[](:user_ids)
+ end
+
+ def publish_events
+ @affected_project_ids.each do |project_id|
+ ::Gitlab::EventStore.publish(
+ ::ProjectAuthorizations::AuthorizationsChangedEvent.new(data: { project_id: project_id })
+ )
+ end
+ end
+ end
+end
diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb
index 9f9447c1de2..69d8c0db55b 100644
--- a/app/models/project_group_link.rb
+++ b/app/models/project_group_link.rb
@@ -3,6 +3,7 @@
class ProjectGroupLink < ApplicationRecord
include Expirable
include EachBatch
+ include AfterCommitQueue
belongs_to :project
belongs_to :group
@@ -16,6 +17,7 @@ class ProjectGroupLink < ApplicationRecord
scope :non_guests, -> { where('group_access > ?', Gitlab::Access::GUEST) }
scope :in_group, -> (group_ids) { where(group_id: group_ids) }
+ scope :for_projects, -> (project_ids) { where(project_id: project_ids) }
alias_method :shared_with_group, :group
alias_method :shared_from, :project
diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb
index aeefa5c8dcd..fec951eb7fe 100644
--- a/app/models/project_setting.rb
+++ b/app/models/project_setting.rb
@@ -99,6 +99,11 @@ class ProjectSetting < ApplicationRecord
Gitlab::CurrentSettings.valid_runner_registrars.include?('project') && read_attribute(:runner_registration_enabled)
end
+ def emails_enabled?
+ super && project.namespace.emails_enabled?
+ end
+ strong_memoize_attr :emails_enabled?
+
private
def validates_mr_default_target_self
diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb
index 365bb5237c3..942f20f6e5e 100644
--- a/app/models/project_statistics.rb
+++ b/app/models/project_statistics.rb
@@ -19,7 +19,7 @@ class ProjectStatistics < ApplicationRecord
Namespaces::ScheduleAggregationWorker.perform_async(project_statistics.namespace_id)
end
- before_save :update_storage_size
+ after_commit :refresh_storage_size!, on: :update, if: -> { storage_size_components_changed? }
COLUMNS_TO_REFRESH = [:repository_size, :wiki_size, :lfs_objects_size, :commit_count, :snippets_size, :uploads_size, :container_registry_size].freeze
INCREMENTABLE_COLUMNS = [
@@ -67,7 +67,7 @@ class ProjectStatistics < ApplicationRecord
end
def update_repository_size
- self.repository_size = project.repository.size * 1.megabyte
+ self.repository_size = project.repository.recent_objects_size.megabytes
end
def update_wiki_size
@@ -105,19 +105,14 @@ class ProjectStatistics < ApplicationRecord
super.to_i
end
- def update_storage_size
- self.storage_size = storage_size_components.sum { |component| method(component).call }
- end
-
+ # Since this incremental update method does not update the storage_size directly,
+ # we have to update the storage_size separately in an after_commit action.
def refresh_storage_size!
detect_race_on_record(log_fields: { caller: __method__, attributes: :storage_size }) do
- update!(storage_size: storage_size_sum)
+ self.class.where(id: id).update_all("storage_size = #{storage_size_sum}")
end
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_commit
#
@@ -169,6 +164,10 @@ class ProjectStatistics < ApplicationRecord
Namespaces::ScheduleAggregationWorker.perform_async(project.namespace_id)
end
end
+
+ def storage_size_components_changed?
+ (previous_changes.keys & STORAGE_SIZE_COMPONENTS.map(&:to_s)).any?
+ end
end
ProjectStatistics.prepend_mod_with('ProjectStatistics')
diff --git a/app/models/project_team.rb b/app/models/project_team.rb
index fbdc88e7b76..3b9b82ee094 100644
--- a/app/models/project_team.rb
+++ b/app/models/project_team.rb
@@ -141,12 +141,10 @@ class ProjectTeam
end
ProjectMember.transaction do
- source_members.each do |member|
- member.save
- end
+ source_members.each(&:save)
end
- true
+ source_members
rescue StandardError
false
end
diff --git a/app/models/redirect_route.rb b/app/models/redirect_route.rb
index 749f4a87818..54b4c9d0fe1 100644
--- a/app/models/redirect_route.rb
+++ b/app/models/redirect_route.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class RedirectRoute < ApplicationRecord
+class RedirectRoute < MainClusterwide::ApplicationRecord
include CaseSensitivity
belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
diff --git a/app/models/release.rb b/app/models/release.rb
index f0ba56390ab..6830f6e8480 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -20,6 +20,8 @@ class Release < ApplicationRecord
has_many :milestones, through: :milestone_releases
has_many :evidences, inverse_of: :release, class_name: 'Releases::Evidence'
+ has_one :catalog_resource_version, class_name: 'Ci::Catalog::Resources::Version', inverse_of: :release
+
accepts_nested_attributes_for :links, allow_destroy: true
before_create :set_released_at
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 1321c9da780..b8a46f80bc7 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -47,7 +47,7 @@ class Repository
#
# For example, for entry `:commit_count` there's a method called `commit_count` which
# stores its data in the `commit_count` cache key.
- CACHED_METHODS = %i(size commit_count readme_path contribution_guide
+ CACHED_METHODS = %i(size recent_objects_size commit_count readme_path contribution_guide
changelog license_blob license_gitaly gitignore
gitlab_ci_yml branch_names tag_names branch_count
tag_count avatar exists? root_ref merged_branch_names
@@ -363,7 +363,7 @@ class Repository
end
def expire_statistics_caches
- expire_method_caches(%i(size commit_count))
+ expire_method_caches(%i(size recent_objects_size commit_count))
end
def expire_all_method_caches
@@ -579,6 +579,12 @@ class Repository
end
cache_method :size, fallback: 0.0
+ # The recent objects size of this repository in mebibytes.
+ def recent_objects_size
+ exists? ? raw_repository.recent_objects_size : 0.0
+ end
+ cache_method :recent_objects_size, fallback: 0.0
+
def commit_count
root_ref ? raw_repository.commit_count(root_ref) : 0
end
@@ -691,7 +697,7 @@ class Repository
@head_tree ||= Tree.new(self, root_ref, nil, skip_flat_paths: skip_flat_paths)
end
- def tree(sha = :head, path = nil, recursive: false, skip_flat_paths: true, pagination_params: nil, ref_type: nil)
+ def tree(sha = :head, path = nil, recursive: false, skip_flat_paths: true, pagination_params: nil, ref_type: nil, rescue_not_found: true)
if sha == :head
return if empty? || root_ref.nil?
@@ -703,7 +709,7 @@ class Repository
end
end
- Tree.new(self, sha, path, recursive: recursive, skip_flat_paths: skip_flat_paths, pagination_params: pagination_params, ref_type: ref_type)
+ Tree.new(self, sha, path, recursive: recursive, skip_flat_paths: skip_flat_paths, pagination_params: pagination_params, ref_type: ref_type, rescue_not_found: rescue_not_found)
end
def blob_at_branch(branch_name, path)
@@ -1242,6 +1248,20 @@ class Repository
prohibited_branches.each { |name| raw_repository.delete_branch(name) }
end
+ def get_patch_id(old_revision, new_revision)
+ raw_repository.get_patch_id(old_revision, new_revision)
+ end
+
+ def object_pool
+ gitaly_object_pool = raw.object_pool
+
+ return unless gitaly_object_pool
+
+ source_project = project&.pool_repository&.source_project
+
+ Gitlab::Git::ObjectPool.init_from_gitaly(gitaly_object_pool, source_project&.repository)
+ end
+
private
def ancestor_cache_key(ancestor_id, descendant_id)
diff --git a/app/models/review.rb b/app/models/review.rb
index c621da3b03c..d47aaf027ce 100644
--- a/app/models/review.rb
+++ b/app/models/review.rb
@@ -32,3 +32,5 @@ class Review < ApplicationRecord
merge_request.user_mentions.where.not(note_id: nil)
end
end
+
+Review.prepend_mod
diff --git a/app/models/route.rb b/app/models/route.rb
index f2fe1664f9e..652c33a673c 100644
--- a/app/models/route.rb
+++ b/app/models/route.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Route < ApplicationRecord
+class Route < MainClusterwide::ApplicationRecord
include CaseSensitivity
include Gitlab::SQL::Pattern
diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb
index c2fd8b20942..f3a0479d3b7 100644
--- a/app/models/sent_notification.rb
+++ b/app/models/sent_notification.rb
@@ -1,10 +1,6 @@
# frozen_string_literal: true
class SentNotification < ApplicationRecord
- include IgnorableColumns
-
- ignore_column %i[line_code note_type position], remove_with: '16.3', remove_after: '2023-07-22'
-
belongs_to :project
belongs_to :noteable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :recipient, class_name: "User"
diff --git a/app/models/service_desk/custom_email_verification.rb b/app/models/service_desk/custom_email_verification.rb
index 482a10447ed..5099cf4c5bb 100644
--- a/app/models/service_desk/custom_email_verification.rb
+++ b/app/models/service_desk/custom_email_verification.rb
@@ -26,6 +26,8 @@ module ServiceDesk
validates :project, presence: true
validates :state, presence: true
+ scope :overdue, -> { where('triggered_at < ?', TIMEFRAME.ago) }
+
delegate :service_desk_setting, to: :project
state_machine :state do
diff --git a/app/models/system/broadcast_message.rb b/app/models/system/broadcast_message.rb
new file mode 100644
index 00000000000..332baea4449
--- /dev/null
+++ b/app/models/system/broadcast_message.rb
@@ -0,0 +1,165 @@
+# frozen_string_literal: true
+
+module System
+ class BroadcastMessage < MainClusterwide::ApplicationRecord
+ include CacheMarkdownField
+ include Sortable
+
+ ALLOWED_TARGET_ACCESS_LEVELS = [
+ Gitlab::Access::GUEST,
+ Gitlab::Access::REPORTER,
+ Gitlab::Access::DEVELOPER,
+ Gitlab::Access::MAINTAINER,
+ Gitlab::Access::OWNER
+ ].freeze
+
+ cache_markdown_field :message, pipeline: :broadcast_message, whitelisted: true
+
+ validates :message, presence: true
+ validates :starts_at, presence: true
+ validates :ends_at, presence: true
+ validates :broadcast_type, presence: true
+ validates :target_access_levels, inclusion: { in: ALLOWED_TARGET_ACCESS_LEVELS }
+ validates :show_in_cli, allow_nil: false, inclusion: { in: [true, false], message: N_('must be a boolean value') }
+
+ validates :color, allow_blank: true, color: true
+ validates :font, allow_blank: true, color: true
+
+ attribute :color, default: '#E75E40'
+ attribute :font, default: '#FFFFFF'
+
+ scope :current_and_future_messages, -> { where('ends_at > :now', now: Time.current).order_id_asc }
+
+ CACHE_KEY = 'broadcast_message_current_json'
+ BANNER_CACHE_KEY = 'broadcast_message_current_banner_json'
+ NOTIFICATION_CACHE_KEY = 'broadcast_message_current_notification_json'
+
+ after_commit :flush_redis_cache
+
+ enum theme: {
+ indigo: 0,
+ 'light-indigo': 1,
+ blue: 2,
+ 'light-blue': 3,
+ green: 4,
+ 'light-green': 5,
+ red: 6,
+ 'light-red': 7,
+ dark: 8,
+ light: 9
+ }, _default: 0, _prefix: true
+
+ enum broadcast_type: {
+ banner: 1,
+ notification: 2
+ }
+
+ class << self
+ def current_banner_messages(current_path: nil, user_access_level: nil)
+ fetch_messages BANNER_CACHE_KEY, current_path, user_access_level do
+ current_and_future_messages.banner
+ end
+ end
+
+ def current_show_in_cli_banner_messages
+ current_banner_messages.select(&:show_in_cli?)
+ end
+
+ def current_notification_messages(current_path: nil, user_access_level: nil)
+ fetch_messages NOTIFICATION_CACHE_KEY, current_path, user_access_level do
+ current_and_future_messages.notification
+ end
+ end
+
+ def current(current_path: nil, user_access_level: nil)
+ fetch_messages CACHE_KEY, current_path, user_access_level do
+ current_and_future_messages
+ end
+ end
+
+ def cache
+ ::Gitlab::SafeRequestStore.fetch(:broadcast_message_json_cache) do
+ Gitlab::Cache::JsonCaches::JsonKeyed.new
+ end
+ end
+
+ def cache_expires_in
+ 2.weeks
+ end
+
+ private
+
+ def fetch_messages(cache_key, current_path, user_access_level, &block)
+ messages = cache.fetch(cache_key, as: System::BroadcastMessage, expires_in: cache_expires_in, &block)
+
+ now_or_future = messages.select(&:now_or_future?)
+
+ # If there are cached entries but they don't match the ones we are
+ # displaying we'll refresh the cache so we don't need to keep filtering.
+ cache.expire(cache_key) if now_or_future != messages
+
+ messages = now_or_future.select(&:now?)
+ messages = messages.select do |message|
+ message.matches_current_user_access_level?(user_access_level)
+ end
+ messages.select do |message|
+ message.matches_current_path(current_path)
+ end
+ end
+ end
+
+ def active?
+ started? && !ended?
+ end
+
+ def started?
+ Time.current >= starts_at
+ end
+
+ def ended?
+ ends_at < Time.current
+ end
+
+ def now?
+ (starts_at..ends_at).cover?(Time.current)
+ end
+
+ def future?
+ starts_at > Time.current
+ end
+
+ def now_or_future?
+ now? || future?
+ end
+
+ def matches_current_user_access_level?(user_access_level)
+ return true unless target_access_levels.present?
+
+ target_access_levels.include? user_access_level
+ end
+
+ def matches_current_path(current_path)
+ return false if current_path.blank? && target_path.present?
+ return true if current_path.blank? || target_path.blank?
+
+ # Ensure paths are consistent across callers.
+ # This fixes a mismatch between requests in the GUI and CLI
+ #
+ # This has to be reassigned due to frozen strings being provided.
+ current_path = "/#{current_path}" unless current_path.start_with?("/")
+
+ escaped = Regexp.escape(target_path).gsub('\\*', '.*')
+ regexp = Regexp.new "^#{escaped}$", Regexp::IGNORECASE
+
+ regexp.match(current_path)
+ end
+
+ def flush_redis_cache
+ [CACHE_KEY, BANNER_CACHE_KEY, NOTIFICATION_CACHE_KEY].each do |key|
+ self.class.cache.expire(key)
+ end
+ end
+ end
+end
+
+System::BroadcastMessage.prepend_mod_with('System::BroadcastMessage')
diff --git a/app/models/todo.rb b/app/models/todo.rb
index f202e1a266d..d159b51a0eb 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -24,6 +24,7 @@ class Todo < ApplicationRecord
MERGE_TRAIN_REMOVED = 8 # This is an EE-only feature
REVIEW_REQUESTED = 9
MEMBER_ACCESS_REQUESTED = 10
+ REVIEW_SUBMITTED = 11 # This is an EE-only feature
ACTION_NAMES = {
ASSIGNED => :assigned,
@@ -35,7 +36,8 @@ class Todo < ApplicationRecord
UNMERGEABLE => :unmergeable,
DIRECTLY_ADDRESSED => :directly_addressed,
MERGE_TRAIN_REMOVED => :merge_train_removed,
- MEMBER_ACCESS_REQUESTED => :member_access_requested
+ MEMBER_ACCESS_REQUESTED => :member_access_requested,
+ REVIEW_SUBMITTED => :review_submitted
}.freeze
ACTIONS_MULTIPLE_ALLOWED = [Todo::MENTIONED, Todo::DIRECTLY_ADDRESSED, Todo::MEMBER_ACCESS_REQUESTED].freeze
@@ -223,6 +225,10 @@ class Todo < ApplicationRecord
action == MEMBER_ACCESS_REQUESTED
end
+ def review_submitted?
+ action == REVIEW_SUBMITTED
+ end
+
def member_access_type
target.class.name.downcase
end
diff --git a/app/models/tree.rb b/app/models/tree.rb
index 8622eb793c1..4d62334800d 100644
--- a/app/models/tree.rb
+++ b/app/models/tree.rb
@@ -7,7 +7,7 @@ class Tree
def initialize(
repository, sha, path = '/', recursive: false, skip_flat_paths: true, pagination_params: nil,
- ref_type: nil)
+ ref_type: nil, rescue_not_found: true)
path = '/' if path.blank?
@repository = repository
@@ -18,7 +18,9 @@ class Tree
ref = ExtractsRef.qualify_ref(@sha, ref_type)
- @entries, @cursor = Gitlab::Git::Tree.where(git_repo, ref, @path, recursive, skip_flat_paths, pagination_params)
+ @entries, @cursor = Gitlab::Git::Tree.where(git_repo, ref, @path, recursive, skip_flat_paths, rescue_not_found,
+ pagination_params)
+
@entries.each do |entry|
entry.ref_type = self.ref_type
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 4a57cc2e2e2..9f85d41b133 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -2,7 +2,7 @@
require 'carrierwave/orm/activerecord'
-class User < ApplicationRecord
+class User < MainClusterwide::ApplicationRecord
extend Gitlab::ConfigHelper
include Gitlab::ConfigHelper
@@ -403,6 +403,7 @@ class User < ApplicationRecord
delegate :location, :location=, to: :user_detail, allow_nil: true
delegate :organization, :organization=, to: :user_detail, allow_nil: true
delegate :discord, :discord=, to: :user_detail, allow_nil: true
+ delegate :email_reset_offered_at, :email_reset_offered_at=, to: :user_detail, allow_nil: true
accepts_nested_attributes_for :user_preference, update_only: true
accepts_nested_attributes_for :user_detail, update_only: true
@@ -520,7 +521,11 @@ class User < ApplicationRecord
scope :active, -> { with_state(:active).non_internal }
scope :active_without_ghosts, -> { with_state(:active).without_ghosts }
scope :deactivated, -> { with_state(:deactivated).non_internal }
- scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) }
+ scope :without_projects, -> do
+ joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id')
+ .where(project_authorizations: { user_id: nil })
+ .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422045')
+ end
scope :by_username, -> (usernames) { iwhere(username: Array(usernames).map(&:to_s)) }
scope :by_name, -> (names) { iwhere(name: Array(names)) }
scope :by_login, -> (login) do
@@ -1765,13 +1770,7 @@ class User < ApplicationRecord
def following_users_allowed?(user)
return false if self.id == user.id
- following_users_enabled? && user.following_users_enabled?
- end
-
- def following_users_enabled?
- return true unless ::Feature.enabled?(:disable_follow_users, self)
-
- enabled_following
+ enabled_following && user.enabled_following
end
def forkable_namespaces
@@ -2192,14 +2191,6 @@ class User < ApplicationRecord
callout_dismissed?(callout, ignore_dismissal_earlier_than)
end
- def dismissed_callout_before?(feature_name, dismissed_before)
- callout = callouts_by_feature_name[feature_name]
-
- return false unless callout
-
- callout.dismissed_before?(dismissed_before)
- end
-
def dismissed_callout_for_group?(feature_name:, group:, ignore_dismissal_earlier_than: nil)
source_feature_name = "#{feature_name}_#{group.id}"
callout = group_callouts_by_feature_name[source_feature_name]
diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb
index 5c9a73571c0..9ac814eebda 100644
--- a/app/models/user_detail.rb
+++ b/app/models/user_detail.rb
@@ -1,11 +1,10 @@
# frozen_string_literal: true
-class UserDetail < ApplicationRecord
+class UserDetail < MainClusterwide::ApplicationRecord
include IgnorableColumns
extend ::Gitlab::Utils::Override
ignore_column :requires_credit_card_verification, remove_with: '16.1', remove_after: '2023-06-22'
- ignore_column :provisioned_by_group_at, remove_with: '16.3', remove_after: '2023-07-22'
REGISTRATION_OBJECTIVE_PAIRS = { basics: 0, move_repository: 1, code_storage: 2, exploring: 3, ci: 4, other: 5, joining_team: 6 }.freeze
diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb
index c263d552d40..eac66905d0c 100644
--- a/app/models/user_preference.rb
+++ b/app/models/user_preference.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class UserPreference < ApplicationRecord
+class UserPreference < MainClusterwide::ApplicationRecord
include IgnorableColumns
# We could use enums, but Rails 4 doesn't support multiple
diff --git a/app/models/user_status.rb b/app/models/user_status.rb
index da24ef47a2a..35aa2427442 100644
--- a/app/models/user_status.rb
+++ b/app/models/user_status.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class UserStatus < ApplicationRecord
+class UserStatus < MainClusterwide::ApplicationRecord
include CacheMarkdownField
self.primary_key = :user_id
diff --git a/app/models/user_synced_attributes_metadata.rb b/app/models/user_synced_attributes_metadata.rb
index 6b23bce6406..0856febf3f6 100644
--- a/app/models/user_synced_attributes_metadata.rb
+++ b/app/models/user_synced_attributes_metadata.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class UserSyncedAttributesMetadata < ApplicationRecord
+class UserSyncedAttributesMetadata < MainClusterwide::ApplicationRecord
belongs_to :user
validates :user, presence: true
diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb
index 0d02a3b99aa..0d3262b2474 100644
--- a/app/models/users/callout.rb
+++ b/app/models/users/callout.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Users
- class Callout < ApplicationRecord
+ class Callout < MainClusterwide::ApplicationRecord
include Users::Calloutable
self.table_name = 'user_callouts'
diff --git a/app/models/users/calloutable.rb b/app/models/users/calloutable.rb
index 483d0d785a5..280a819e4d5 100644
--- a/app/models/users/calloutable.rb
+++ b/app/models/users/calloutable.rb
@@ -13,9 +13,5 @@ module Users
def dismissed_after?(dismissed_after)
dismissed_at > dismissed_after
end
-
- def dismissed_before?(dismissed_before)
- dismissed_at < dismissed_before
- end
end
end
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index e1468872f52..a7e2be0eae5 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -284,10 +284,9 @@ class WikiPage
def content_changed?
if persisted?
- # gollum-lib always converts CRLFs to LFs in Gollum::Wiki#normalize,
- # so we need to do the same here.
- # Also see https://gitlab.com/gitlab-org/gitlab/-/issues/21431
- raw_content.delete("\r") != page&.text_data
+ # To avoid end-of-line differences depending if Git is enforcing CRLF or not,
+ # we compare just the Wiki Content.
+ raw_content.lines(chomp: true) != page&.text_data&.lines(chomp: true)
else
raw_content.present?
end
diff --git a/app/models/work_item.rb b/app/models/work_item.rb
index adf424a1d94..73156b2f040 100644
--- a/app/models/work_item.rb
+++ b/app/models/work_item.rb
@@ -22,6 +22,18 @@ class WorkItem < Issue
foreign_key: :work_item_id, source: :work_item
scope :inc_relations_for_permission_check, -> { includes(:author, project: :project_feature) }
+ scope :in_namespaces, ->(namespaces) { where(namespace: namespaces) }
+
+ scope :with_confidentiality_check, ->(user) {
+ confidential_query = <<~SQL
+ 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)))
+ SQL
+
+ where(confidential_query, user_id: user.id)
+ }
class << self
def assignee_association_name
@@ -59,6 +71,11 @@ class WorkItem < Issue
includes(:parent_link).order(keyset_order)
end
+
+ override :related_link_class
+ def related_link_class
+ WorkItems::RelatedWorkItemLink
+ end
end
def noteable_target_type_name
diff --git a/app/models/work_items/parent_link.rb b/app/models/work_items/parent_link.rb
index 5dff9e8e8d5..d9e3690b6fc 100644
--- a/app/models/work_items/parent_link.rb
+++ b/app/models/work_items/parent_link.rb
@@ -19,8 +19,10 @@ module WorkItems
validate :validate_same_project
validate :validate_max_children
validate :validate_confidentiality
+ validate :check_existing_related_link
scope :for_parents, ->(parent_ids) { where(work_item_parent_id: parent_ids) }
+ scope :for_children, ->(children_ids) { where(work_item: children_ids) }
class << self
def has_public_children?(parent_id)
@@ -109,5 +111,14 @@ module WorkItems
errors.add :work_item, _('is already present in ancestors')
end
end
+
+ def check_existing_related_link
+ return unless work_item && work_item_parent
+
+ existing_link = WorkItems::RelatedWorkItemLink.for_items(work_item, work_item_parent)
+ return if existing_link.none?
+
+ errors.add(:work_item, _('cannot assign a linked work item as a parent'))
+ end
end
end
diff --git a/app/models/work_items/related_work_item_link.rb b/app/models/work_items/related_work_item_link.rb
new file mode 100644
index 00000000000..4de197d3d35
--- /dev/null
+++ b/app/models/work_items/related_work_item_link.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module WorkItems
+ class RelatedWorkItemLink < ApplicationRecord
+ include LinkableItem
+
+ self.table_name = 'issue_links'
+
+ belongs_to :source, class_name: 'WorkItem'
+ belongs_to :target, class_name: 'WorkItem'
+
+ class << self
+ extend ::Gitlab::Utils::Override
+
+ # Used as issuable table name for calculating blocked and blocking count in IssuableLink
+ override :issuable_type
+ def issuable_type
+ :issue
+ end
+
+ override :issuable_name
+ def issuable_name
+ 'work item'
+ end
+ end
+ end
+end
diff --git a/app/models/work_items/type.rb b/app/models/work_items/type.rb
index 6a619dbab21..369ffc660aa 100644
--- a/app/models/work_items/type.rb
+++ b/app/models/work_items/type.rb
@@ -19,7 +19,9 @@ module WorkItems
requirement: 'Requirement',
task: 'Task',
objective: 'Objective',
- key_result: 'Key Result'
+ key_result: 'Key Result',
+ epic: 'Epic',
+ ticket: 'Ticket'
}.freeze
# Base types need to exist on the DB on app startup
@@ -32,7 +34,9 @@ module WorkItems
requirement: { name: TYPE_NAMES[:requirement], icon_name: 'issue-type-requirements', enum_value: 3 }, ## EE-only
task: { name: TYPE_NAMES[:task], icon_name: 'issue-type-task', enum_value: 4 },
objective: { name: TYPE_NAMES[:objective], icon_name: 'issue-type-objective', enum_value: 5 }, ## EE-only
- key_result: { name: TYPE_NAMES[:key_result], icon_name: 'issue-type-keyresult', enum_value: 6 } ## EE-only
+ key_result: { name: TYPE_NAMES[:key_result], icon_name: 'issue-type-keyresult', enum_value: 6 }, ## EE-only
+ epic: { name: TYPE_NAMES[:epic], icon_name: 'issue-type-epic', enum_value: 7 }, ## EE-only
+ ticket: { name: TYPE_NAMES[:ticket], icon_name: 'issue-type-issue', enum_value: 8 }
}.freeze
# A list of types user can change between - both original and new
@@ -40,7 +44,7 @@ module WorkItems
# where it's possible to switch between issue and incident.
CHANGEABLE_BASE_TYPES = %w[issue incident test_case].freeze
- WI_TYPES_WITH_CREATED_HEADER = %w[issue incident].freeze
+ WI_TYPES_WITH_CREATED_HEADER = %w[issue incident ticket].freeze
cache_markdown_field :description, pipeline: :single_line
@@ -79,7 +83,7 @@ module WorkItems
end
def self.allowed_types_for_issues
- base_types.keys.excluding('task', 'objective', 'key_result')
+ base_types.keys.excluding('task', 'objective', 'key_result', 'epic', 'ticket')
end
def default?
diff --git a/app/models/work_items/widget_definition.rb b/app/models/work_items/widget_definition.rb
index 763b1a79069..f25c951406f 100644
--- a/app/models/work_items/widget_definition.rb
+++ b/app/models/work_items/widget_definition.rb
@@ -31,7 +31,8 @@ module WorkItems
test_reports: 13, # EE-only
notifications: 14,
current_user_todos: 15,
- award_emoji: 16
+ award_emoji: 16,
+ linked_items: 17
}
def self.available_widgets
diff --git a/app/models/work_items/widgets/linked_items.rb b/app/models/work_items/widgets/linked_items.rb
new file mode 100644
index 00000000000..06a0f6db964
--- /dev/null
+++ b/app/models/work_items/widgets/linked_items.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Widgets
+ class LinkedItems < Base
+ delegate :related_issues, to: :work_item
+ end
+ end
+end
diff --git a/app/policies/admin/abuse_report_label_policy.rb b/app/policies/admin/abuse_report_label_policy.rb
new file mode 100644
index 00000000000..69c877c90b3
--- /dev/null
+++ b/app/policies/admin/abuse_report_label_policy.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Admin
+ class AbuseReportLabelPolicy < ::BasePolicy
+ rule { admin }.policy do
+ enable :read_label
+ end
+ end
+end
diff --git a/app/policies/ci/bridge_policy.rb b/app/policies/ci/bridge_policy.rb
index 37a07ea8aaf..5f9e8eab08a 100644
--- a/app/policies/ci/bridge_policy.rb
+++ b/app/policies/ci/bridge_policy.rb
@@ -2,6 +2,8 @@
module Ci
class BridgePolicy < CommitStatusPolicy
+ include Ci::DeployablePolicy
+
condition(:can_update_downstream_branch) do
::Gitlab::UserAccess.new(@user, container: @subject.downstream_project)
.can_update_branch?(@subject.target_revision_ref)
diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb
index 73e4cbee54a..bce7ceafe17 100644
--- a/app/policies/ci/build_policy.rb
+++ b/app/policies/ci/build_policy.rb
@@ -2,6 +2,8 @@
module Ci
class BuildPolicy < CommitStatusPolicy
+ include Ci::DeployablePolicy
+
delegate { @subject.project }
condition(:protected_ref) do
@@ -22,15 +24,6 @@ module Ci
end
end
- # overridden in EE
- condition(:protected_environment) do
- false
- end
-
- condition(:outdated_deployment) do
- @subject.outdated_deployment?
- end
-
condition(:owner_of_job) do
@subject.triggered_by?(@user)
end
@@ -73,21 +66,24 @@ module Ci
# Use admin_ci_minutes for detailed quota and usage reporting
# this is limited to total usage and total quota for a builds namespace
- rule { can_read_project_build }.enable :read_ci_minutes_limited_summary
+ rule { can_read_project_build }.policy do
+ enable :read_ci_minutes_limited_summary
+ enable :read_build_trace
+ end
- rule { can_read_project_build }.enable :read_build_trace
rule { debug_mode & ~project_update_build }.prevent :read_build_trace
# 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 & ~admin) | protected_environment) }.policy do
- prevent :update_build
+ rule { ~can?(:jailbreak) & (archived | (protected_ref & ~admin)) }.policy do
prevent :update_commit_status
- prevent :erase_build
end
- rule { outdated_deployment }.prevent :update_build
+ rule { ~can?(:jailbreak) & (archived | protected_ref) }.policy do
+ prevent :update_build
+ prevent :erase_build
+ end
rule { can?(:admin_build) | (can?(:update_build) & owner_of_job & unprotected_ref) }.enable :erase_build
diff --git a/app/policies/ci/deployable_policy.rb b/app/policies/ci/deployable_policy.rb
new file mode 100644
index 00000000000..f0105b001f2
--- /dev/null
+++ b/app/policies/ci/deployable_policy.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Ci
+ module DeployablePolicy
+ extend ActiveSupport::Concern
+
+ included do
+ prepend_mod_with('Ci::DeployablePolicy') # rubocop: disable Cop/InjectEnterpriseEditionModule
+
+ condition(:outdated_deployment) do
+ @subject.outdated_deployment?
+ end
+
+ rule { outdated_deployment }.prevent :update_build
+ end
+ end
+end
diff --git a/app/policies/concerns/find_group_projects.rb b/app/policies/concerns/find_group_projects.rb
index aad9081bd7d..914e336b4ab 100644
--- a/app/policies/concerns/find_group_projects.rb
+++ b/app/policies/concerns/find_group_projects.rb
@@ -3,11 +3,11 @@
module FindGroupProjects
extend ActiveSupport::Concern
- def group_projects_for(user:, group:, only_owned: true)
+ def group_projects_for(user:, group:, exclude_shared: true)
GroupProjectsFinder.new(
group: group,
current_user: user,
- options: { include_subgroups: true, only_owned: only_owned }
+ options: { include_subgroups: true, exclude_shared: exclude_shared }
).execute
end
end
diff --git a/app/policies/deploy_key_policy.rb b/app/policies/deploy_key_policy.rb
index b117bb57921..ccf1bda26bb 100644
--- a/app/policies/deploy_key_policy.rb
+++ b/app/policies/deploy_key_policy.rb
@@ -3,10 +3,14 @@
class DeployKeyPolicy < BasePolicy
with_options scope: :subject, score: 0
condition(:private_deploy_key) { @subject.private? }
+ condition(:public_deploy_key) { @subject.public? }
condition(:has_deploy_key) { @user.project_deploy_keys.any? { |pdk| pdk.id.eql?(@subject.id) } }
rule { anonymous }.prevent_all
-
- rule { admin }.enable :update_deploy_key
- rule { private_deploy_key & has_deploy_key }.enable :update_deploy_key
+ rule { public_deploy_key | admin | has_deploy_key }.policy do
+ enable :read_deploy_key
+ end
+ rule { admin | (private_deploy_key & has_deploy_key) }.policy do
+ enable :update_deploy_key
+ end
end
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 29b966b43e2..c50f74f2b35 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -61,7 +61,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
end
condition(:design_management_enabled) do
- group_projects_for(user: @user, group: @subject, only_owned: false).any? { |p| p.design_management_enabled? }
+ group_projects_for(user: @user, group: @subject, exclude_shared: false).any? { |p| p.design_management_enabled? }
end
condition(:dependency_proxy_available, scope: :subject) do
@@ -148,6 +148,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
enable :read_group_member
enable :read_custom_emoji
enable :read_counts
+ enable :read_issue
end
rule { achievements_enabled }.policy do
@@ -230,7 +231,6 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
enable :read_usage_quotas
enable :read_group_runners
- enable :admin_group_runners
enable :register_group_runners
enable :create_runner
diff --git a/app/policies/organizations/organization_policy.rb b/app/policies/organizations/organization_policy.rb
index cac8d07811d..1c0d996c7d4 100644
--- a/app/policies/organizations/organization_policy.rb
+++ b/app/policies/organizations/organization_policy.rb
@@ -2,8 +2,22 @@
module Organizations
class OrganizationPolicy < BasePolicy
+ condition(:organization_user) { @subject.user?(@user) }
+
+ desc 'Organization is public'
+ condition(:public_organization, scope: :subject, score: 0) { true }
+
+ rule { public_organization }.policy do
+ enable :read_organization
+ end
+
rule { admin }.policy do
enable :admin_organization
+ enable :read_organization
+ end
+
+ rule { organization_user }.policy do
+ enable :read_organization
end
end
end
diff --git a/app/policies/packages/policies/project_policy.rb b/app/policies/packages/policies/project_policy.rb
index 35161fd95f1..deb6d13dd14 100644
--- a/app/policies/packages/policies/project_policy.rb
+++ b/app/policies/packages/policies/project_policy.rb
@@ -8,7 +8,8 @@ module Packages
overrides(:read_package)
condition(:packages_enabled_for_everyone, scope: :subject) do
- @subject.package_registry_access_level == ProjectFeature::PUBLIC
+ @subject.package_registry_access_level == ProjectFeature::PUBLIC &&
+ Gitlab::CurrentSettings.package_registry_allow_anyone_to_pull_option
end
rule { project.packages_disabled }.policy do
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index ad6155258ab..564215f6e50 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -44,6 +44,9 @@ class ProjectPolicy < BasePolicy
desc "Project is public"
condition(:public_project, scope: :subject, score: 0) { project.public? }
+ desc "project is private"
+ condition(:private_project, scope: :subject, score: 0) { project.private? }
+
desc "Project is visible to internal users"
condition(:internal_access) do
project.internal? && !user.external?
@@ -55,6 +58,9 @@ class ProjectPolicy < BasePolicy
desc "User is a requester of the group"
condition(:group_requester, scope: :subject) { project_group_requester? }
+ desc "User is external"
+ condition(:external_user) { user.external? }
+
desc "Project is archived"
condition(:archived, scope: :subject, score: 0) { project.archived? }
@@ -913,6 +919,8 @@ class ProjectPolicy < BasePolicy
prevent :read_project
end
+ rule { ~private_project & guest & external_user }.enable :read_container_image
+
private
def user_is_user?
diff --git a/app/policies/work_item_policy.rb b/app/policies/work_item_policy.rb
index 1ccc152bc6b..23b1d54b3bf 100644
--- a/app/policies/work_item_policy.rb
+++ b/app/policies/work_item_policy.rb
@@ -1,13 +1,14 @@
# frozen_string_literal: true
class WorkItemPolicy < IssuePolicy
+ condition(:is_member) { is_project_member? }
condition(:is_member_and_author) { is_project_member? & is_author? }
rule { can?(:admin_issue) }.enable :admin_work_item
-
rule { can?(:destroy_issue) | is_member_and_author }.enable :delete_work_item
rule { can?(:update_issue) }.enable :update_work_item
+
rule { can?(:set_issue_metadata) }.enable :set_work_item_metadata
rule { can?(:read_issue) }.enable :read_work_item
@@ -20,4 +21,6 @@ class WorkItemPolicy < IssuePolicy
rule { can?(:reporter_access) }.policy do
enable :admin_parent_link
end
+
+ rule { is_member & can?(:read_work_item) }.enable :admin_work_item_link
end
diff --git a/app/presenters/issue_presenter.rb b/app/presenters/issue_presenter.rb
index 69d775d8125..42ecbc9988e 100644
--- a/app/presenters/issue_presenter.rb
+++ b/app/presenters/issue_presenter.rb
@@ -16,6 +16,10 @@ class IssuePresenter < Gitlab::View::Presenter::Delegated
issue.project.emails_disabled?
end
+ def project_emails_enabled?
+ issue.project.emails_enabled?
+ end
+
delegator_override :service_desk_reply_to
def service_desk_reply_to
return unless super.present?
diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb
index 8d2baa6ee99..5c23af6e821 100644
--- a/app/presenters/merge_request_presenter.rb
+++ b/app/presenters/merge_request_presenter.rb
@@ -266,10 +266,15 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
def issues_sentence(project, issues)
# Sorting based on the `#123` or `group/project#123` reference will sort
- # local issues first.
- issues.map do |issue|
+ # local issues numerically first.
+ issue_refs = issues.map do |issue|
issue.to_reference(project)
- end.sort.to_sentence
+ end
+
+ issue_refs.sort_by do |issue_ref|
+ path_section = issue_ref.split('#')
+ [path_section.first, path_section.last.to_i]
+ end.to_sentence
end
def user_can_fork_project?
diff --git a/app/presenters/ml/model_presenter.rb b/app/presenters/ml/model_presenter.rb
new file mode 100644
index 00000000000..1317a13351b
--- /dev/null
+++ b/app/presenters/ml/model_presenter.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Ml
+ class ModelPresenter < Gitlab::View::Presenter::Delegated
+ presents ::Ml::Model, as: :model
+
+ def latest_version_name
+ model.latest_version&.version
+ end
+
+ def latest_package_path
+ return unless model.latest_version&.package_id.present?
+
+ Gitlab::Routing.url_helpers.project_package_path(model.project, model.latest_version.package_id)
+ end
+ end
+end
diff --git a/app/presenters/ml/models_index_presenter.rb b/app/presenters/ml/models_index_presenter.rb
deleted file mode 100644
index e2cb8e2d6c1..00000000000
--- a/app/presenters/ml/models_index_presenter.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-module Ml
- class ModelsIndexPresenter
- def initialize(models)
- @models = models
- end
-
- def present
- data = @models.map do |m|
- {
- name: m.name,
- version: m.version,
- path: Gitlab::Routing.url_helpers.project_package_path(m.project, m)
- }
- end
-
- Gitlab::Json.generate({ models: data })
- end
- end
-end
diff --git a/app/presenters/packages/npm/package_presenter.rb b/app/presenters/packages/npm/package_presenter.rb
deleted file mode 100644
index 42f61182ab8..00000000000
--- a/app/presenters/packages/npm/package_presenter.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-# frozen_string_literal: true
-
-module Packages
- module Npm
- class PackagePresenter
- def initialize(metadata)
- @metadata = metadata
- end
-
- def name
- metadata[:name]
- end
-
- def versions
- metadata[:versions]
- end
-
- def dist_tags
- metadata[:dist_tags]
- end
-
- private
-
- attr_reader :metadata
- end
- end
-end
diff --git a/app/presenters/packages/nuget/v2/metadata_index_presenter.rb b/app/presenters/packages/nuget/v2/metadata_index_presenter.rb
new file mode 100644
index 00000000000..0ce7c8956b3
--- /dev/null
+++ b/app/presenters/packages/nuget/v2/metadata_index_presenter.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module Packages
+ module Nuget
+ module V2
+ class MetadataIndexPresenter
+ def xml
+ Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
+ xml['edmx'].Edmx('xmlns:edmx' => 'http://schemas.microsoft.com/ado/2007/06/edmx', Version: '1.0') do
+ xml['edmx'].DataServices('xmlns:m' => 'http://schemas.microsoft.com/ado/2007/08/dataservices/metadata',
+ 'm:DataServiceVersion' => '2.0', 'm:MaxDataServiceVersion' => '2.0') do
+ xml.Schema(xmlns: 'http://schemas.microsoft.com/ado/2006/04/edm', Namespace: 'NuGetGallery.OData') do
+ xml.EntityType(Name: 'V2FeedPackage', 'm:HasStream' => true) do
+ xml.Key do
+ xml.PropertyRef(Name: 'Id')
+ xml.PropertyRef(Name: 'Version')
+ end
+ xml.Property(Name: 'Id', Type: 'Edm.String', Nullable: false)
+ xml.Property(Name: 'Version', Type: 'Edm.String', Nullable: false)
+ xml.Property(Name: 'Authors', Type: 'Edm.String')
+ xml.Property(Name: 'Dependencies', Type: 'Edm.String')
+ xml.Property(Name: 'Description', Type: 'Edm.String')
+ xml.Property(Name: 'DownloadCount', Type: 'Edm.Int64', Nullable: false)
+ xml.Property(Name: 'IconUrl', Type: 'Edm.String')
+ xml.Property(Name: 'Published', Type: 'Edm.DateTime', Nullable: false)
+ xml.Property(Name: 'ProjectUrl', Type: 'Edm.String')
+ xml.Property(Name: 'Tags', Type: 'Edm.String')
+ xml.Property(Name: 'Title', Type: 'Edm.String')
+ xml.Property(Name: 'LicenseUrl', Type: 'Edm.String')
+ end
+ end
+ xml.Schema(xmlns: 'http://schemas.microsoft.com/ado/2006/04/edm', Namespace: 'NuGetGallery') do
+ xml.EntityContainer(Name: 'V2FeedContext', 'm:IsDefaultEntityContainer' => true) do
+ xml.EntitySet(Name: 'Packages', EntityType: 'NuGetGallery.OData.V2FeedPackage')
+ xml.FunctionImport(Name: 'FindPackagesById',
+ ReturnType: 'Collection(NuGetGallery.OData.V2FeedPackage)', EntitySet: 'Packages') do
+ xml.Parameter(Name: 'id', Type: 'Edm.String', FixedLength: 'false', Unicode: 'false')
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/presenters/packages/nuget/v2/service_index_presenter.rb b/app/presenters/packages/nuget/v2/service_index_presenter.rb
new file mode 100644
index 00000000000..a8fc9b673bf
--- /dev/null
+++ b/app/presenters/packages/nuget/v2/service_index_presenter.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module Packages
+ module Nuget
+ module V2
+ class ServiceIndexPresenter
+ include API::Helpers::RelatedResourcesHelpers
+
+ ROOT_ATTRIBUTES = {
+ xmlns: 'http://www.w3.org/2007/app',
+ 'xmlns:atom' => 'http://www.w3.org/2005/Atom'
+ }.freeze
+
+ def initialize(project_or_group)
+ @project_or_group = project_or_group
+ end
+
+ def xml
+ Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
+ xml.service(ROOT_ATTRIBUTES.merge('xml:base' => xml_base)) do
+ xml.workspace do
+ xml['atom'].title('Default', type: 'text')
+ xml.collection(href: 'Packages') do
+ xml['atom'].title('Packages', type: 'text')
+ end
+ end
+ end
+ end
+ end
+
+ private
+
+ attr_reader :project_or_group
+
+ def xml_base
+ base_path = case project_or_group
+ when Project
+ api_v4_projects_packages_nuget_v2_path(id: project_or_group.id)
+ when Group
+ api_v4_groups___packages_nuget_v2_path(id: project_or_group.id)
+ end
+
+ expose_url(base_path)
+ end
+ end
+ end
+ end
+end
diff --git a/app/presenters/projects/import_export/project_export_presenter.rb b/app/presenters/projects/import_export/project_export_presenter.rb
index 76cc8242da8..0c16c729e9c 100644
--- a/app/presenters/projects/import_export/project_export_presenter.rb
+++ b/app/presenters/projects/import_export/project_export_presenter.rb
@@ -52,3 +52,5 @@ module Projects
end
end
end
+
+Projects::ImportExport::ProjectExportPresenter.prepend_mod_with('Projects::ImportExport::ProjectExportPresenter')
diff --git a/app/serializers/admin/abuse_report_details_entity.rb b/app/serializers/admin/abuse_report_details_entity.rb
index f0e84fc44d2..3efb8508e5e 100644
--- a/app/serializers/admin/abuse_report_details_entity.rb
+++ b/app/serializers/admin/abuse_report_details_entity.rb
@@ -79,9 +79,17 @@ module Admin
expose :reported_content, as: :content
expose :reported_from_url, as: :url
expose :screenshot_path, as: :screenshot
+
+ # Kept for backwards compatibility.
+ # TODO: See https://gitlab.com/gitlab-org/modelops/anti-abuse/team-tasks/-/issues/167?work_item_iid=443
+ # In 16.4 remove or re-use this field after frontend has migrated to using moderate_user_path
expose :update_path do |report|
admin_abuse_report_path(report)
end
+
+ expose :moderate_user_path do |report|
+ moderate_user_admin_abuse_report_path(report)
+ end
end
end
end
diff --git a/app/serializers/admin/abuse_report_entity.rb b/app/serializers/admin/abuse_report_entity.rb
index 58637445e81..22395a2fe91 100644
--- a/app/serializers/admin/abuse_report_entity.rb
+++ b/app/serializers/admin/abuse_report_entity.rb
@@ -7,6 +7,7 @@ module Admin
expose :category
expose :created_at
expose :updated_at
+ expose :count
expose :reported_user do |report|
UserEntity.represent(report.user, only: [:name])
@@ -19,5 +20,11 @@ module Admin
expose :report_path do |report|
admin_abuse_report_path(report)
end
+
+ private
+
+ def count
+ object.has_attribute?(:count) ? object.count : 1
+ end
end
end
diff --git a/app/serializers/base_discussion_entity.rb b/app/serializers/base_discussion_entity.rb
index 7d3b9651b8b..0b006078343 100644
--- a/app/serializers/base_discussion_entity.rb
+++ b/app/serializers/base_discussion_entity.rb
@@ -15,7 +15,6 @@ class BaseDiscussionEntity < Grape::Entity
expose :for_commit?, as: :for_commit
expose :individual_note?, as: :individual_note
expose :resolvable?, as: :resolvable
- expose :resolved_by_push?, as: :resolved_by_push
expose :truncated_diff_lines, using: DiffLineEntity, if: -> (d, _) { d.diff_discussion? && d.on_text? && (d.expanded? || render_truncated_diff_lines?) }
@@ -34,18 +33,23 @@ class BaseDiscussionEntity < Grape::Entity
discussion_path(discussion)
end
- with_options if: -> (d, _) { d.resolvable? } do
+ with_options if: -> (d, _) { d.noteable.supports_resolvable_notes? } do
+ expose :resolved?, as: :resolved
+ expose :resolved_by_push?, as: :resolved_by_push
+ expose :resolved_by, using: NoteUserEntity
+ expose :resolved_at
+
expose :resolve_path do |discussion|
- resolve_project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion.id)
+ resolve_project_discussion_path(discussion.project, discussion.noteable_collection_name, discussion.noteable, discussion.id)
end
- expose :resolve_with_issue_path do |discussion|
+ expose :resolve_with_issue_path, if: -> (d, _) { d.noteable.is_a?(MergeRequest) } do |discussion|
new_project_issue_path(discussion.project, merge_request_to_resolve_discussions_of: discussion.noteable.iid, discussion_to_resolve: discussion.id) if discussion&.project&.issues_enabled?
end
end
expose :truncated_diff_lines_path, if: -> (d, _) { !d.expanded? && !render_truncated_diff_lines? } do |discussion|
- project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion)
+ project_discussion_path(discussion.project, discussion.noteable_collection_name, discussion.noteable, discussion)
end
private
diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb
index f63a1bf094a..7cd913d057e 100644
--- a/app/serializers/deployment_entity.rb
+++ b/app/serializers/deployment_entity.rb
@@ -41,8 +41,8 @@ class DeploymentEntity < Grape::Entity
expose :commit, using: CommitEntity, if: -> (*) { include_details? }
expose :manual_actions, using: Ci::JobEntity, if: -> (*) { include_details? && can_create_deployment? }
expose :scheduled_actions, using: Ci::JobEntity, if: -> (*) { include_details? && can_create_deployment? }
- expose :playable_build, if: -> (deployment) { include_details? && can_create_deployment? && deployment.playable_build } do |deployment, options|
- Ci::JobEntity.represent(deployment.playable_build, options.merge(only: [:play_path, :retry_path]))
+ expose :playable_job, as: :playable_build, if: -> (deployment) { include_details? && can_create_deployment? && deployment.playable_job } do |deployment, options|
+ Ci::JobEntity.represent(deployment.playable_job, options.merge(only: [:play_path, :retry_path]))
end
expose :cluster do |deployment, options|
diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb
index 0dbfe0f0772..9ee2e145cd5 100644
--- a/app/serializers/discussion_entity.rb
+++ b/app/serializers/discussion_entity.rb
@@ -19,11 +19,6 @@ class DiscussionEntity < BaseDiscussionEntity
discussion.diff_note_positions.map(&:line_code)
end
- expose :resolved?, as: :resolved
- expose :resolved_by_push?, as: :resolved_by_push
- expose :resolved_by, using: NoteUserEntity
- expose :resolved_at
-
private
def current_user
diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb
index 0a3bf4c2a7b..b1f731cdd4d 100644
--- a/app/serializers/environment_entity.rb
+++ b/app/serializers/environment_entity.rb
@@ -4,7 +4,7 @@ class EnvironmentEntity < Grape::Entity
include RequestAwareEntity
UNNECESSARY_ENTRIES_FOR_UPCOMING_DEPLOYMENT =
- %i[manual_actions scheduled_actions playable_build cluster].freeze
+ %i[manual_actions scheduled_actions playable_job cluster].freeze
expose :id
diff --git a/app/serializers/environment_serializer.rb b/app/serializers/environment_serializer.rb
index d7820dff6ef..8f3aeea2eed 100644
--- a/app/serializers/environment_serializer.rb
+++ b/app/serializers/environment_serializer.rb
@@ -94,7 +94,7 @@ class EnvironmentSerializer < BaseSerializer
pipeline: {
manual_actions: [:metadata, :deployment],
scheduled_actions: [:metadata],
- latest_successful_builds: []
+ latest_successful_jobs: []
},
project: project_associations
}
diff --git a/app/serializers/environment_status_entity.rb b/app/serializers/environment_status_entity.rb
index 8865c030d94..62dc323616e 100644
--- a/app/serializers/environment_status_entity.rb
+++ b/app/serializers/environment_status_entity.rb
@@ -38,7 +38,7 @@ class EnvironmentStatusEntity < Grape::Entity
end
expose :deployment, as: :details do |es, options|
- DeploymentEntity.represent(es.deployment, options.merge(project: es.project, only: [:playable_build]))
+ DeploymentEntity.represent(es.deployment, options.merge(project: es.project, only: [:playable_job]))
end
expose :environment_available do |es|
diff --git a/app/serializers/integrations/event_entity.rb b/app/serializers/integrations/event_entity.rb
index 1cbd6114581..f7cac23f30c 100644
--- a/app/serializers/integrations/event_entity.rb
+++ b/app/serializers/integrations/event_entity.rb
@@ -23,7 +23,10 @@ module Integrations
integration.event_channel_name(event)
end
expose :value do |event|
- integration.event_channel_value(event)
+ value = integration.event_channel_value(event)
+ next BaseChatNotification::SECRET_MASK if value.present? && integration.mask_configurable_channels?
+
+ value
end
expose :placeholder do |_event|
integration.default_channel_placeholder
diff --git a/app/serializers/integrations/field_entity.rb b/app/serializers/integrations/field_entity.rb
index 1c548cfab78..dc2ec55d073 100644
--- a/app/serializers/integrations/field_entity.rb
+++ b/app/serializers/integrations/field_entity.rb
@@ -5,12 +5,16 @@ module Integrations
include RequestAwareEntity
include Gitlab::Utils::StrongMemoize
- expose :section, :type, :name, :placeholder, :required, :choices, :checkbox_label
+ expose :section, :name, :placeholder, :required, :choices, :checkbox_label
expose :title do |field|
non_empty_password?(field) ? field[:non_empty_password_title] : field[:title]
end
+ expose :type do |field|
+ field[:type].to_s
+ end
+
expose :help do |field|
non_empty_password?(field) ? field[:non_empty_password_help] : field[:help]
end
@@ -20,7 +24,7 @@ module Integrations
if non_empty_password?(field)
'true'
- elsif field[:type] == 'checkbox'
+ elsif field[:type] == :checkbox
ActiveRecord::Type::Boolean.new.deserialize(value).to_s
elsif field[:name] == 'webhook' && integration.chat?
BaseChatNotification::SECRET_MASK if value.present?
@@ -44,7 +48,7 @@ module Integrations
def non_empty_password?(field)
strong_memoize(:non_empty_password) do
- field[:type] == 'password' && value_for(field).present?
+ field[:type] == :password && value_for(field).present?
end
end
end
diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb
index 9fd50c8c51d..6f83978841d 100644
--- a/app/serializers/merge_request_serializer.rb
+++ b/app/serializers/merge_request_serializer.rb
@@ -27,3 +27,5 @@ class MergeRequestSerializer < BaseSerializer
super(merge_request, opts, entity)
end
end
+
+MergeRequestSerializer.prepend_mod
diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb
index 26dc748ad51..a50d893d244 100644
--- a/app/serializers/note_entity.rb
+++ b/app/serializers/note_entity.rb
@@ -48,6 +48,7 @@ class NoteEntity < API::Entities::Note
expose :resolvable?, as: :resolvable
expose :resolved_by, using: NoteUserEntity
+ expose :resolved_by_push?, as: :resolved_by_push
expose :system_note_icon_name, if: -> (note, _) { note.system? } do |note|
SystemNoteHelper.system_note_icon_name(note)
@@ -77,10 +78,10 @@ class NoteEntity < API::Entities::Note
end
expose :resolve_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note|
- resolve_project_merge_request_discussion_path(note.project, note.noteable, note.discussion_id)
+ resolve_project_discussion_path(discussion.project, discussion.noteable_collection_name, discussion.noteable, discussion.id)
end
- expose :resolve_with_issue_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note|
+ expose :resolve_with_issue_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? && note.noteable.is_a?(MergeRequest) } do |note|
new_project_issue_path(note.project, merge_request_to_resolve_discussions_of: note.noteable.iid, discussion_to_resolve: note.discussion_id)
end
diff --git a/app/serializers/profile/event_entity.rb b/app/serializers/profile/event_entity.rb
index b769a80ef58..f3c1a927084 100644
--- a/app/serializers/profile/event_entity.rb
+++ b/app/serializers/profile/event_entity.rb
@@ -51,7 +51,7 @@ module Profile
expose(:id) { |event| event.target.id }
expose(:target_type, as: :type)
expose(:target_title, as: :title)
- expose(:issue_type, if: ->(event) { event.work_item? }) do |event|
+ expose(:issue_type, if: ->(event) { event.work_item? || event.issue? }) do |event|
event.target.issue_type
end
diff --git a/app/serializers/project_note_entity.rb b/app/serializers/project_note_entity.rb
index 3cd34f6af0d..3e004fb5d32 100644
--- a/app/serializers/project_note_entity.rb
+++ b/app/serializers/project_note_entity.rb
@@ -21,14 +21,6 @@ class ProjectNoteEntity < NoteEntity
project_note_path(note.project, note)
end
- expose :resolve_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note|
- resolve_project_merge_request_discussion_path(note.project, note.noteable, note.discussion_id)
- end
-
- expose :resolve_with_issue_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note|
- new_project_issue_path(note.project, merge_request_to_resolve_discussions_of: note.noteable.iid, discussion_to_resolve: note.discussion_id)
- end
-
expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note|
delete_attachment_project_note_path(note.project, note)
end
diff --git a/app/services/admin/abuse_report_update_service.rb b/app/services/admin/abuse_report_update_service.rb
deleted file mode 100644
index 12cf8bf14a8..00000000000
--- a/app/services/admin/abuse_report_update_service.rb
+++ /dev/null
@@ -1,91 +0,0 @@
-# frozen_string_literal: true
-
-module Admin
- class AbuseReportUpdateService < BaseService
- attr_reader :abuse_report, :params, :current_user, :action
-
- def initialize(abuse_report, current_user, params)
- @abuse_report = abuse_report
- @current_user = current_user
- @params = params
- @action = determine_action
- end
-
- def execute
- return ServiceResponse.error(message: 'Admin is required') unless current_user&.can_admin_all_resources?
- return ServiceResponse.error(message: 'Action is required') unless action.present?
-
- result = perform_action
- if result[:status] == :success
- event = close_report_and_record_event
- ServiceResponse.success(message: event.success_message)
- else
- ServiceResponse.error(message: result[:message])
- end
- end
-
- private
-
- def determine_action
- action = params[:user_action]
- if action.in?(ResourceEvents::AbuseReportEvent.actions.keys)
- action.to_sym
- elsif close_report?
- :close_report
- end
- end
-
- def perform_action
- case action
- when :ban_user then ban_user
- when :block_user then block_user
- when :delete_user then delete_user
- when :close_report then close_report
- end
- end
-
- def ban_user
- Users::BanService.new(current_user).execute(abuse_report.user)
- end
-
- def block_user
- Users::BlockService.new(current_user).execute(abuse_report.user)
- end
-
- def delete_user
- abuse_report.user.delete_async(deleted_by: current_user)
- success
- end
-
- def close_report
- return error('Report already closed') if abuse_report.closed?
-
- abuse_report.closed!
- success
- end
-
- def close_report_and_record_event
- event = action
-
- if close_report? && action != :close_report
- close_report
- event = "#{action}_and_close_report"
- end
-
- record_event(event)
- end
-
- def close_report?
- params[:close].to_s == 'true'
- end
-
- def record_event(action)
- reason = params[:reason]
- unless reason.in?(ResourceEvents::AbuseReportEvent.reasons.keys)
- reason = ResourceEvents::AbuseReportEvent.reasons[:other]
- end
-
- abuse_report.events.create(action: action, user: current_user, reason: reason, comment: params[:comment])
- end
- end
-end
diff --git a/app/services/admin/abuse_reports/moderate_user_service.rb b/app/services/admin/abuse_reports/moderate_user_service.rb
new file mode 100644
index 00000000000..da61a4dc8f6
--- /dev/null
+++ b/app/services/admin/abuse_reports/moderate_user_service.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+module Admin
+ module AbuseReports
+ class ModerateUserService < BaseService
+ attr_reader :abuse_report, :params, :current_user, :action
+
+ def initialize(abuse_report, current_user, params)
+ @abuse_report = abuse_report
+ @current_user = current_user
+ @params = params
+ @action = determine_action
+ end
+
+ def execute
+ return ServiceResponse.error(message: 'Admin is required') unless current_user&.can_admin_all_resources?
+ return ServiceResponse.error(message: 'Action is required') unless action.present?
+
+ result = perform_action
+ if result[:status] == :success
+ event = close_report_and_record_event
+ ServiceResponse.success(message: event.success_message)
+ else
+ ServiceResponse.error(message: result[:message])
+ end
+ end
+
+ private
+
+ def determine_action
+ action = params[:user_action]
+ if action.in?(ResourceEvents::AbuseReportEvent.actions.keys)
+ action.to_sym
+ elsif close_report?
+ :close_report
+ end
+ end
+
+ def perform_action
+ case action
+ when :ban_user then ban_user
+ when :block_user then block_user
+ when :delete_user then delete_user
+ when :close_report then close_report
+ end
+ end
+
+ def ban_user
+ Users::BanService.new(current_user).execute(abuse_report.user)
+ end
+
+ def block_user
+ Users::BlockService.new(current_user).execute(abuse_report.user)
+ end
+
+ def delete_user
+ abuse_report.user.delete_async(deleted_by: current_user)
+ success
+ end
+
+ def close_report
+ return error('Report already closed') if abuse_report.closed?
+
+ abuse_report.closed!
+ success
+ end
+
+ def close_report_and_record_event
+ event = action
+
+ if close_report? && action != :close_report
+ close_report
+ event = "#{action}_and_close_report"
+ end
+
+ record_event(event)
+ end
+
+ def close_report?
+ params[:close].to_s == 'true'
+ end
+
+ def record_event(action)
+ reason = params[:reason]
+ unless reason.in?(ResourceEvents::AbuseReportEvent.reasons.keys)
+ reason = ResourceEvents::AbuseReportEvent.reasons[:other]
+ end
+
+ abuse_report.events.create(action: action, user: current_user, reason: reason, comment: params[:comment])
+ end
+ end
+ end
+end
diff --git a/app/services/admin/plan_limits/update_service.rb b/app/services/admin/plan_limits/update_service.rb
index cda9a7e7f8c..24ce3c4095f 100644
--- a/app/services/admin/plan_limits/update_service.rb
+++ b/app/services/admin/plan_limits/update_service.rb
@@ -15,6 +15,12 @@ module Admin
add_history_to_params!
+ plan_limits.assign_attributes(parsed_params)
+
+ validate_storage_limits
+
+ return error(plan_limits.errors.full_messages, :bad_request) if plan_limits.errors.any?
+
if plan_limits.update(parsed_params)
success
else
@@ -26,6 +32,8 @@ module Admin
attr_accessor :current_user, :params, :plan, :plan_limits
+ delegate :notification_limit, :storage_size_limit, :enforcement_limit, to: :plan_limits
+
def can_update?
current_user.can_admin_all_resources?
end
@@ -35,6 +43,39 @@ module Admin
parsed_params.merge!(limits_history: formatted_limits_history) unless formatted_limits_history.empty?
end
+ def validate_storage_limits
+ validate_notification_limit
+ validate_enforcement_limit
+ validate_storage_size_limit
+ end
+
+ def validate_notification_limit
+ return unless parsed_params.include?(:notification_limit)
+ return if notification_limit >= storage_size_limit && notification_limit <= enforcement_limit
+
+ plan_limits.errors.add(:notification_limit, "must be greater than or equal to " \
+ "storage_size_limit (Dashboard limit): #{storage_size_limit} " \
+ "and less than or equal to enforcement_limit: #{enforcement_limit}")
+ end
+
+ def validate_enforcement_limit
+ return unless parsed_params.include?(:enforcement_limit)
+ return if enforcement_limit >= storage_size_limit && enforcement_limit >= notification_limit
+
+ plan_limits.errors.add(:enforcement_limit, "must be greater than or equal to " \
+ "storage_size_limit (Dashboard limit): #{storage_size_limit} and " \
+ "greater than or equal to notification_limit: #{notification_limit}")
+ end
+
+ def validate_storage_size_limit
+ return unless parsed_params.include?(:storage_size_limit)
+ return if storage_size_limit <= enforcement_limit && storage_size_limit <= notification_limit
+
+ plan_limits.errors.add(:storage_size_limit, "(Dashboard limit) must be less than or equal to " \
+ "enforcement_limit: #{enforcement_limit} " \
+ "and notification_limit: #{notification_limit}")
+ end
+
# Overridden in EE
def parsed_params
params
diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb
index 3827d199325..eaee5ce70fc 100644
--- a/app/services/auth/container_registry_authentication_service.rb
+++ b/app/services/auth/container_registry_authentication_service.rb
@@ -112,6 +112,28 @@ module Auth
token.expire_time = self.class.token_expire_at
token[:auth_type] = params[:auth_type]
token[:access] = accesses.compact
+ token[:user] = user_info_token.encoded
+ end
+ end
+
+ def user_info_token
+ info =
+ if current_user
+ {
+ token_type: params[:auth_type],
+ username: current_user.username,
+ user_id: current_user.id
+ }
+ elsif deploy_token
+ {
+ token_type: params[:auth_type],
+ username: deploy_token.username,
+ deploy_token_id: deploy_token.id
+ }
+ end
+
+ JSONWebToken::RSAToken.new(registry.key).tap do |token|
+ token[:user_info] = info
end
end
diff --git a/app/services/authorized_project_update/project_recalculate_service.rb b/app/services/authorized_project_update/project_recalculate_service.rb
index cb83dc57478..535845b9f94 100644
--- a/app/services/authorized_project_update/project_recalculate_service.rb
+++ b/app/services/authorized_project_update/project_recalculate_service.rb
@@ -64,13 +64,10 @@ module AuthorizedProjectUpdate
end
def refresh_authorizations
- if user_ids_to_remove.any?
- ProjectAuthorization.delete_all_in_batches_for_project(
- project: project,
- user_ids: user_ids_to_remove)
- end
-
- ProjectAuthorization.insert_all_in_batches(authorizations_to_create) if authorizations_to_create.any?
+ ProjectAuthorizations::Changes.new do |changes|
+ changes.add(authorizations_to_create)
+ changes.remove_users_in_project(project, user_ids_to_remove)
+ end.apply!
end
def apply_scopes(project_authorizations)
diff --git a/app/services/award_emojis/add_service.rb b/app/services/award_emojis/add_service.rb
index 065ef9dc708..b5aa8f920ff 100644
--- a/app/services/award_emojis/add_service.rb
+++ b/app/services/award_emojis/add_service.rb
@@ -6,11 +6,11 @@ module AwardEmojis
def execute
unless awardable.user_can_award?(current_user)
- return error('User cannot award emoji to awardable', status: :forbidden)
+ return error('User cannot add emoji reactions to awardable', status: :forbidden)
end
unless awardable.emoji_awardable?
- return error('Awardable cannot be awarded emoji', status: :unprocessable_entity)
+ return error('Awardable cannot add emoji reactions', status: :unprocessable_entity)
end
award = awardable.award_emoji.create(name: name, user: current_user)
diff --git a/app/services/batched_git_ref_updates/cleanup_scheduler_service.rb b/app/services/batched_git_ref_updates/cleanup_scheduler_service.rb
new file mode 100644
index 00000000000..cf547c0e6b5
--- /dev/null
+++ b/app/services/batched_git_ref_updates/cleanup_scheduler_service.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module BatchedGitRefUpdates
+ class CleanupSchedulerService
+ include Gitlab::ExclusiveLeaseHelpers
+ MAX_PROJECTS = 10_000
+ BATCH_SIZE = 100
+ LOCK_TIMEOUT = 10.minutes
+
+ def execute
+ total_projects = 0
+
+ in_lock(self.class.name, retries: 0, ttl: LOCK_TIMEOUT) do
+ Deletion.status_pending.distinct_each_batch(column: :project_id, of: BATCH_SIZE) do |deletions|
+ ProjectCleanupWorker.bulk_perform_async_with_contexts(
+ deletions,
+ arguments_proc: ->(deletion) { deletion.project_id },
+ context_proc: ->(_) { {} } # No project context because loading the project is wasteful
+ )
+
+ total_projects += deletions.count
+ break if total_projects >= MAX_PROJECTS
+ end
+ end
+
+ { total_projects: total_projects }
+ end
+ end
+end
diff --git a/app/services/batched_git_ref_updates/project_cleanup_service.rb b/app/services/batched_git_ref_updates/project_cleanup_service.rb
new file mode 100644
index 00000000000..f9518cad975
--- /dev/null
+++ b/app/services/batched_git_ref_updates/project_cleanup_service.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module BatchedGitRefUpdates
+ class ProjectCleanupService
+ include ::Gitlab::ExclusiveLeaseHelpers
+
+ LOCK_TIMEOUT = 10.minutes
+ GITALY_BATCH_SIZE = 100
+ QUERY_BATCH_SIZE = 1000
+ MAX_DELETES = 10_000
+
+ def initialize(project_id)
+ @project_id = project_id
+ end
+
+ def execute
+ total_deletes = 0
+
+ in_lock("#{self.class}/#{@project_id}", retries: 0, ttl: LOCK_TIMEOUT) do
+ project = Project.find_by_id(@project_id)
+ break unless project
+
+ Deletion
+ .status_pending
+ .for_project(@project_id)
+ .select_ref_and_identity
+ .each_batch(of: QUERY_BATCH_SIZE) do |batch|
+ refs = batch.map(&:ref)
+
+ refs.each_slice(GITALY_BATCH_SIZE) do |refs_to_delete|
+ project.repository.delete_refs(*refs_to_delete)
+ end
+
+ total_deletes += refs.count
+ Deletion.mark_records_processed(batch)
+
+ break if total_deletes >= MAX_DELETES
+ end
+ end
+
+ { total_deletes: total_deletes }
+ end
+ end
+end
diff --git a/app/services/boards/base_items_list_service.rb b/app/services/boards/base_items_list_service.rb
index a4b1be1e599..7c8846d2fe8 100644
--- a/app/services/boards/base_items_list_service.rb
+++ b/app/services/boards/base_items_list_service.rb
@@ -13,17 +13,16 @@ module Boards
# rubocop: disable CodeReuse/ActiveRecord
def metadata(required_fields = [:issue_count, :total_issue_weight])
- # Failing tests in spec/requests/api/graphql/boards/board_lists_query_spec.rb
- ::Gitlab::Database.allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417465") do
- fields = metadata_fields(required_fields)
- keys = fields.keys
- # TODO: eliminate need for SQL literal fragment
- columns = Arel.sql(fields.values_at(*keys).join(', '))
- results = item_model.where(id: collection_ids)
- results = results.select(columns)
-
- Hash[keys.zip(results.pluck(columns).flatten)]
- end
+ fields = metadata_fields(required_fields)
+ keys = fields.keys
+ columns = fields.values_at(*keys)
+
+ results = item_model
+ .where(id: collection_ids)
+ .pluck(*columns)
+ .flatten
+
+ Hash[keys.zip(results)]
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -34,7 +33,7 @@ module Boards
end
def metadata_fields(required_fields)
- required_fields&.include?(:issue_count) ? { size: 'COUNT(*)' } : {}
+ required_fields&.include?(:issue_count) ? { size: Arel.sql('COUNT(*)') } : {}
end
def order(items)
diff --git a/app/services/bulk_imports/file_download_service.rb b/app/services/bulk_imports/file_download_service.rb
index ef7e0ae8258..48adb90fb4c 100644
--- a/app/services/bulk_imports/file_download_service.rb
+++ b/app/services/bulk_imports/file_download_service.rb
@@ -5,7 +5,7 @@
# @param configuration [BulkImports::Configuration] Config object containing url and access token
# @param relative_url [String] Relative URL to download the file from
# @param tmpdir [String] Temp directory to store downloaded file to. Must be located under `Dir.tmpdir`.
-# @param file_size_limit [Integer] Maximum allowed file size
+# @param file_size_limit [Integer] Maximum allowed file size. If 0, no limit will apply.
# @param allowed_content_types [Array<String>] Allowed file content types
# @param filename [String] Name of the file to download, if known. Use remote filename if none given.
module BulkImports
@@ -15,14 +15,13 @@ module BulkImports
ServiceError = Class.new(StandardError)
- DEFAULT_FILE_SIZE_LIMIT = 5.gigabytes
DEFAULT_ALLOWED_CONTENT_TYPES = %w(application/gzip application/octet-stream).freeze
def initialize(
configuration:,
relative_url:,
tmpdir:,
- file_size_limit: DEFAULT_FILE_SIZE_LIMIT,
+ file_size_limit: default_file_size_limit,
allowed_content_types: DEFAULT_ALLOWED_CONTENT_TYPES,
filename: nil)
@configuration = configuration
@@ -118,5 +117,9 @@ module BulkImports
schemes: %w(http https)
)
end
+
+ def default_file_size_limit
+ Gitlab::CurrentSettings.current_application_settings.bulk_import_max_download_file_size.megabytes
+ end
end
end
diff --git a/app/services/ci/create_pipeline_schedule_service.rb b/app/services/ci/create_pipeline_schedule_service.rb
deleted file mode 100644
index 4fdd65bcdb4..00000000000
--- a/app/services/ci/create_pipeline_schedule_service.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- # This class is deprecated and will be removed with the FF ci_refactoring_pipeline_schedule_create_service
- class CreatePipelineScheduleService < BaseService
- def execute
- project.pipeline_schedules.create(pipeline_schedule_params)
- end
-
- private
-
- def pipeline_schedule_params
- params.merge(owner: current_user)
- end
- end
-end
diff --git a/app/services/ci/job_artifacts/create_service.rb b/app/services/ci/job_artifacts/create_service.rb
index 3ac0e83232f..c09b0cf81f1 100644
--- a/app/services/ci/job_artifacts/create_service.rb
+++ b/app/services/ci/job_artifacts/create_service.rb
@@ -138,6 +138,7 @@ module Ci
def parse_artifact(artifact)
case artifact.file_type
when 'dotenv' then parse_dotenv_artifact(artifact)
+ when 'annotations' then parse_annotations_artifact(artifact)
else success
end
end
@@ -188,6 +189,10 @@ module Ci
def parse_dotenv_artifact(artifact)
Ci::ParseDotenvArtifactService.new(project, current_user).execute(artifact)
end
+
+ def parse_annotations_artifact(artifact)
+ Ci::ParseAnnotationsArtifactService.new(project, current_user).execute(artifact)
+ end
end
end
end
diff --git a/app/services/ci/parse_annotations_artifact_service.rb b/app/services/ci/parse_annotations_artifact_service.rb
new file mode 100644
index 00000000000..cbda7e827d4
--- /dev/null
+++ b/app/services/ci/parse_annotations_artifact_service.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+module Ci
+ class ParseAnnotationsArtifactService < ::BaseService
+ include ::Gitlab::Utils::StrongMemoize
+ include ::Gitlab::EncodingHelper
+
+ SizeLimitError = Class.new(StandardError)
+ ParserError = Class.new(StandardError)
+
+ def execute(artifact)
+ return error('Artifact is not annotations file type', :bad_request) unless artifact&.annotations?
+
+ return error("Annotations Artifact Too Big. Maximum Allowable Size: #{annotations_size_limit}", :bad_request) if
+ artifact.file.size > annotations_size_limit
+
+ annotations = parse!(artifact)
+ Ci::JobAnnotation.bulk_upsert!(annotations, unique_by: %i[partition_id job_id name])
+
+ success
+ rescue SizeLimitError, ParserError, Gitlab::Json.parser_error, ActiveRecord::RecordInvalid => error
+ error(error.message, :bad_request)
+ end
+
+ private
+
+ def parse!(artifact)
+ annotations = []
+
+ artifact.each_blob do |blob|
+ # Windows powershell may output UTF-16LE files, so convert the whole file
+ # to UTF-8 before proceeding.
+ blob = strip_bom(encode_utf8_with_replacement_character(blob))
+
+ blob_json = Gitlab::Json.parse(blob)
+ raise ParserError, 'Annotations files must be a JSON object' unless blob_json.is_a?(Hash)
+
+ blob_json.each do |key, value|
+ annotations.push(Ci::JobAnnotation.new(job: artifact.job, name: key, data: value))
+
+ if annotations.size > annotations_num_limit
+ raise SizeLimitError,
+ "Annotations files cannot have more than #{annotations_num_limit} annotation lists"
+ end
+ end
+ end
+
+ annotations
+ end
+
+ def annotations_num_limit
+ project.actual_limits.ci_job_annotations_num
+ end
+ strong_memoize_attr :annotations_num_limit
+
+ def annotations_size_limit
+ project.actual_limits.ci_job_annotations_size
+ end
+ strong_memoize_attr :annotations_size_limit
+ end
+end
diff --git a/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb b/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb
index e197821a0c0..953432a9dd3 100644
--- a/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb
+++ b/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb
@@ -47,7 +47,7 @@ module Ci
loop do
# leverage the index_ci_pipelines_on_project_id_and_status_and_created_at index
records = project.all_pipelines
- .created_after(1.week.ago)
+ .created_after(pipelines_created_after)
.order(:status, :created_at)
.page(page) # use offset pagination because there is no other way to loop over the data
.per(PAGE_SIZE)
@@ -63,10 +63,11 @@ module Ci
def parent_auto_cancelable_pipelines(ids = nil)
scope = project.all_pipelines
- .created_after(1.week.ago)
+ .created_after(pipelines_created_after)
.for_ref(pipeline.ref)
.where_not_sha(project.commit(pipeline.ref).try(:id))
.where("created_at < ?", pipeline.created_at)
+ .for_status(CommitStatus::AVAILABLE_STATUSES) # Force usage of project_id_and_status_and_created_at_index
.ci_sources
scope = scope.id_in(ids) if ids.present?
@@ -103,6 +104,14 @@ module Ci
end
end
+ def pipelines_created_after
+ if Feature.enabled?(:lower_interval_for_canceling_redundant_pipelines, project)
+ 3.days.ago
+ else
+ 1.week.ago
+ end
+ end
+
# Finding the pipelines to cancel is an expensive task that is not well
# covered by indexes for all project use-cases and sometimes it might
# harm other services. See https://gitlab.com/gitlab-com/gl-infra/production/-/issues/14758
diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb
index 8211507fb95..750272c3ecb 100644
--- a/app/services/ci/pipeline_processing/atomic_processing_service.rb
+++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb
@@ -144,6 +144,10 @@ module Ci
DEFAULT_LEASE_TIMEOUT
end
+ def lease_taken_log_level
+ :info
+ end
+
def log_running_reset_skipped_jobs_service(jobs)
Gitlab::AppJsonLogger.info(
class: self.class.name.to_s,
diff --git a/app/services/ci/pipeline_schedules/base_save_service.rb b/app/services/ci/pipeline_schedules/base_save_service.rb
new file mode 100644
index 00000000000..45d70e5a65d
--- /dev/null
+++ b/app/services/ci/pipeline_schedules/base_save_service.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module Ci
+ module PipelineSchedules
+ class BaseSaveService
+ include Gitlab::Utils::StrongMemoize
+
+ def execute
+ schedule.assign_attributes(params)
+
+ return forbidden_to_save unless allowed_to_save?
+ return forbidden_to_save_variables unless allowed_to_save_variables?
+
+ if schedule.save
+ ServiceResponse.success(payload: schedule)
+ else
+ ServiceResponse.error(payload: schedule, message: schedule.errors.full_messages)
+ end
+ end
+
+ private
+
+ attr_reader :project, :user, :params, :schedule
+
+ def allowed_to_save?
+ user.can?(self.class::AUTHORIZE, schedule)
+ end
+
+ def forbidden_to_save
+ # We add the error to the base object too
+ # because model errors are used in the API responses and the `form_errors` helper.
+ schedule.errors.add(:base, authorize_message)
+
+ ServiceResponse.error(payload: schedule, message: [authorize_message], reason: :forbidden)
+ end
+
+ def allowed_to_save_variables?
+ return true if params[:variables_attributes].blank?
+
+ user.can?(:set_pipeline_variables, project)
+ end
+
+ def forbidden_to_save_variables
+ message = _('The current user is not authorized to set pipeline schedule variables')
+
+ # We add the error to the base object too
+ # because model errors are used in the API responses and the `form_errors` helper.
+ schedule.errors.add(:base, message)
+
+ ServiceResponse.error(payload: schedule, message: [message], reason: :forbidden)
+ end
+ end
+ end
+end
diff --git a/app/services/ci/pipeline_schedules/create_service.rb b/app/services/ci/pipeline_schedules/create_service.rb
index c1825865bc0..23775e68399 100644
--- a/app/services/ci/pipeline_schedules/create_service.rb
+++ b/app/services/ci/pipeline_schedules/create_service.rb
@@ -2,46 +2,22 @@
module Ci
module PipelineSchedules
- class CreateService
- def initialize(project, user, params)
- @project = project
- @user = user
- @params = params
+ class CreateService < BaseSaveService
+ AUTHORIZE = :create_pipeline_schedule
+ def initialize(project, user, params)
@schedule = project.pipeline_schedules.new
- end
-
- def execute
- return forbidden unless allowed?
-
- schedule.assign_attributes(params.merge(owner: user))
-
- if schedule.save
- ServiceResponse.success(payload: schedule)
- else
- ServiceResponse.error(payload: schedule, message: schedule.errors.full_messages)
- end
+ @user = user
+ @project = project
+ @params = params.merge(owner: user)
end
private
- attr_reader :project, :user, :params, :schedule
-
- def allowed?
- user.can?(:create_pipeline_schedule, schedule)
- end
-
- def forbidden
- # We add the error to the base object too
- # because model errors are used in the API responses and the `form_errors` helper.
- schedule.errors.add(:base, forbidden_message)
-
- ServiceResponse.error(payload: schedule, message: [forbidden_message], reason: :forbidden)
- end
-
- def forbidden_message
+ def authorize_message
_('The current user is not authorized to create the pipeline schedule')
end
+ strong_memoize_attr :authorize_message
end
end
end
diff --git a/app/services/ci/pipeline_schedules/update_service.rb b/app/services/ci/pipeline_schedules/update_service.rb
index 28c22e0a868..2fd1173ecce 100644
--- a/app/services/ci/pipeline_schedules/update_service.rb
+++ b/app/services/ci/pipeline_schedules/update_service.rb
@@ -2,44 +2,22 @@
module Ci
module PipelineSchedules
- class UpdateService
+ class UpdateService < BaseSaveService
+ AUTHORIZE = :update_pipeline_schedule
+
def initialize(schedule, user, params)
@schedule = schedule
@user = user
+ @project = schedule.project
@params = params
end
- def execute
- return forbidden unless allowed?
-
- schedule.assign_attributes(params)
-
- if schedule.save
- ServiceResponse.success(payload: schedule)
- else
- ServiceResponse.error(message: schedule.errors.full_messages)
- end
- end
-
private
- attr_reader :schedule, :user, :params
-
- def allowed?
- user.can?(:update_pipeline_schedule, schedule)
- end
-
- def forbidden
- # We add the error to the base object too
- # because model errors are used in the API responses and the `form_errors` helper.
- schedule.errors.add(:base, forbidden_message)
-
- ServiceResponse.error(message: [forbidden_message], reason: :forbidden)
- end
-
- def forbidden_message
+ def authorize_message
_('The current user is not authorized to update the pipeline schedule')
end
+ strong_memoize_attr :authorize_message
end
end
end
diff --git a/app/services/ci/retry_job_service.rb b/app/services/ci/retry_job_service.rb
index e3cbba6de23..14ea09f17a0 100644
--- a/app/services/ci/retry_job_service.rb
+++ b/app/services/ci/retry_job_service.rb
@@ -39,7 +39,7 @@ module Ci
::Ci::CopyCrossDatabaseAssociationsService.new.execute(job, new_job)
- ::Deployments::CreateForBuildService.new.execute(new_job)
+ ::Deployments::CreateForJobService.new.execute(new_job)
::MergeRequests::AddTodoWhenBuildFailsService
.new(project: project)
diff --git a/app/services/clusters/management/validate_management_project_permissions_service.rb b/app/services/clusters/management/validate_management_project_permissions_service.rb
index e89a0afe6d2..e407c159bc7 100644
--- a/app/services/clusters/management/validate_management_project_permissions_service.rb
+++ b/app/services/clusters/management/validate_management_project_permissions_service.rb
@@ -46,7 +46,7 @@ module Clusters
::GroupProjectsFinder.new(
group: group,
current_user: current_user,
- options: { only_owned: true, include_subgroups: include_subgroups }
+ options: { exclude_shared: true, include_subgroups: include_subgroups }
).execute
end
end
diff --git a/app/services/concerns/merge_requests/error_logger.rb b/app/services/concerns/merge_requests/error_logger.rb
new file mode 100644
index 00000000000..c08525bf413
--- /dev/null
+++ b/app/services/concerns/merge_requests/error_logger.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ module ErrorLogger
+ def log_error(exception:, message:, save_message_on_model: false)
+ reference = merge_request.to_reference(full: true)
+ data = {
+ class: self.class.name,
+ message: message,
+ merge_request_id: merge_request.id,
+ merge_request: reference,
+ save_message_on_model: save_message_on_model
+ }
+
+ if exception
+ Gitlab::ApplicationContext.with_context(user: current_user) do
+ Gitlab::ErrorTracking.track_exception(exception, data)
+ end
+
+ data[:"exception.message"] = exception.message
+ end
+
+ # TODO: Deprecate Gitlab::GitLogger since ErrorTracking should suffice:
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/216379
+ data[:message] = "#{self.class.name} error (#{reference}): #{message}"
+ Gitlab::GitLogger.error(data)
+
+ merge_request.update(merge_error: message) if save_message_on_model
+ end
+ end
+end
diff --git a/app/services/concerns/update_repository_storage_methods.rb b/app/services/concerns/update_repository_storage_methods.rb
index bb43cab79bb..dca38abf7af 100644
--- a/app/services/concerns/update_repository_storage_methods.rb
+++ b/app/services/concerns/update_repository_storage_methods.rb
@@ -24,7 +24,13 @@ module UpdateRepositoryStorageMethods
return response if response
- mirror_repositories unless same_filesystem?
+ unless same_filesystem?
+ mirror_repositories
+
+ repository_storage_move.transaction do
+ mirror_object_pool(destination_storage_name)
+ end
+ end
repository_storage_move.transaction do
repository_storage_move.finish_replication!
@@ -53,6 +59,11 @@ module UpdateRepositoryStorageMethods
raise NotImplementedError
end
+ def mirror_object_pool(_destination_shard)
+ # no-op, redefined for Projects::UpdateRepositoryStorageService
+ nil
+ end
+
def mirror_repository(type:)
unless wait_for_pushes(type)
raise Error, s_('UpdateRepositoryStorage|Timeout waiting for %{type} repository pushes') % { type: type.name }
diff --git a/app/services/deployments/create_for_build_service.rb b/app/services/deployments/create_for_job_service.rb
index b58aa50a66f..e230515ce27 100644
--- a/app/services/deployments/create_for_build_service.rb
+++ b/app/services/deployments/create_for_job_service.rb
@@ -1,48 +1,48 @@
# frozen_string_literal: true
module Deployments
- # This class creates a deployment record for a build (a pipeline job).
- class CreateForBuildService
+ # This class creates a deployment record for a pipeline job.
+ class CreateForJobService
DeploymentCreationError = Class.new(StandardError)
- def execute(build)
- return unless build.instance_of?(::Ci::Build) && build.persisted_environment.present?
+ def execute(job)
+ return unless job.is_a?(::Ci::Processable) && job.persisted_environment.present?
- environment = build.actual_persisted_environment
+ environment = job.actual_persisted_environment
- deployment = to_resource(build, environment)
+ deployment = to_resource(job, environment)
return unless deployment
deployment.save!
- build.association(:deployment).target = deployment
- build.association(:deployment).loaded!
+ job.association(:deployment).target = deployment
+ job.association(:deployment).loaded!
deployment
rescue ActiveRecord::RecordInvalid => e
Gitlab::ErrorTracking.track_and_raise_for_dev_exception(
- DeploymentCreationError.new(e.message), build_id: build.id)
+ DeploymentCreationError.new(e.message), job_id: job.id)
end
private
- def to_resource(build, environment)
- return build.deployment if build.deployment
- return unless build.deployment_job?
+ def to_resource(job, environment)
+ return job.deployment if job.deployment
+ return unless job.deployment_job?
- deployment = ::Deployment.new(attributes(build, environment))
+ deployment = ::Deployment.new(attributes(job, environment))
# If there is a validation error on environment creation, such as
# the name contains invalid character, the job will fall back to a
# non-environment job.
return unless deployment.valid? && deployment.environment.persisted?
- if cluster = deployment.environment.deployment_platform&.cluster
+ if cluster = deployment.environment.deployment_platform&.cluster # rubocop: disable Lint/AssignmentInCondition
# double write cluster_id until 12.9: https://gitlab.com/gitlab-org/gitlab/issues/202628
deployment.cluster_id = cluster.id
deployment.deployment_cluster = ::DeploymentCluster.new(
cluster_id: cluster.id,
- kubernetes_namespace: cluster.kubernetes_namespace_for(deployment.environment, deployable: build)
+ kubernetes_namespace: cluster.kubernetes_namespace_for(deployment.environment, deployable: job)
)
end
@@ -53,16 +53,16 @@ module Deployments
deployment
end
- def attributes(build, environment)
+ def attributes(job, environment)
{
- project: build.project,
+ project: job.project,
environment: environment,
- deployable: build,
- user: build.user,
- ref: build.ref,
- tag: build.tag,
- sha: build.sha,
- on_stop: build.on_stop
+ deployable: job,
+ user: job.user,
+ ref: job.ref,
+ tag: job.tag,
+ sha: job.sha,
+ on_stop: job.on_stop
}
end
end
diff --git a/app/services/deployments/older_deployments_drop_service.rb b/app/services/deployments/older_deployments_drop_service.rb
deleted file mode 100644
index 15384fb0db1..00000000000
--- a/app/services/deployments/older_deployments_drop_service.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-module Deployments
- class OlderDeploymentsDropService
- attr_reader :deployment
-
- def initialize(deployment_id)
- @deployment = Deployment.find_by_id(deployment_id)
- end
-
- def execute
- return unless @deployment&.running?
-
- older_deployments_builds.each do |build|
- next if build.manual?
-
- Gitlab::OptimisticLocking.retry_lock(build, name: 'older_deployments_drop') do |build|
- build.drop(:forward_deployment_failure)
- end
- rescue StandardError => e
- Gitlab::ErrorTracking.track_exception(e, subject_id: @deployment.id, build_id: build.id)
- end
- end
-
- private
-
- def older_deployments_builds
- @deployment
- .environment
- .active_deployments
- .older_than(@deployment)
- .builds
- end
- end
-end
diff --git a/app/services/environments/create_for_build_service.rb b/app/services/environments/create_for_build_service.rb
deleted file mode 100644
index ff4da212002..00000000000
--- a/app/services/environments/create_for_build_service.rb
+++ /dev/null
@@ -1,40 +0,0 @@
-# frozen_string_literal: true
-
-module Environments
- # This class creates an environment record for a build (a pipeline job).
- class CreateForBuildService
- def execute(build)
- return unless build.instance_of?(::Ci::Build) && build.has_environment_keyword?
-
- environment = to_resource(build)
-
- if environment.persisted?
- build.persisted_environment = environment
- build.assign_attributes(metadata_attributes: { expanded_environment_name: environment.name })
- else
- build.assign_attributes(status: :failed, failure_reason: :environment_creation_failure)
- end
-
- environment
- end
-
- private
-
- # rubocop: disable Performance/ActiveRecordSubtransactionMethods
- def to_resource(build)
- build.project.environments.safe_find_or_create_by(name: build.expanded_environment_name) do |environment|
- # Initialize the attributes at creation
- environment.auto_stop_in = expanded_auto_stop_in(build)
- environment.tier = build.environment_tier_from_options
- environment.merge_request = build.pipeline.merge_request
- end
- end
- # rubocop: enable Performance/ActiveRecordSubtransactionMethods
-
- def expanded_auto_stop_in(build)
- return unless build.environment_auto_stop_in
-
- ExpandVariables.expand(build.environment_auto_stop_in, -> { build.simple_variables.sort_and_expand_all })
- end
- end
-end
diff --git a/app/services/environments/create_for_job_service.rb b/app/services/environments/create_for_job_service.rb
new file mode 100644
index 00000000000..02545ce03e0
--- /dev/null
+++ b/app/services/environments/create_for_job_service.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Environments
+ # This class creates an environment record for a pipeline job.
+ class CreateForJobService
+ def execute(job)
+ return unless job.is_a?(::Ci::Processable) && job.has_environment_keyword?
+
+ environment = to_resource(job)
+
+ if environment.persisted?
+ job.persisted_environment = environment
+ job.assign_attributes(metadata_attributes: { expanded_environment_name: environment.name })
+ else
+ job.assign_attributes(status: :failed, failure_reason: :environment_creation_failure)
+ end
+
+ environment
+ end
+
+ private
+
+ # rubocop: disable Performance/ActiveRecordSubtransactionMethods
+ def to_resource(job)
+ job.project.environments.safe_find_or_create_by(name: job.expanded_environment_name) do |environment|
+ # Initialize the attributes at creation
+ environment.auto_stop_in = expanded_auto_stop_in(job)
+ environment.tier = job.environment_tier_from_options
+ environment.merge_request = job.pipeline.merge_request
+ end
+ end
+ # rubocop: enable Performance/ActiveRecordSubtransactionMethods
+
+ def expanded_auto_stop_in(job)
+ return unless job.environment_auto_stop_in
+
+ ExpandVariables.expand(job.environment_auto_stop_in, -> { job.simple_variables.sort_and_expand_all })
+ end
+ end
+end
diff --git a/app/services/environments/create_service.rb b/app/services/environments/create_service.rb
index fd78a886e29..25a6d047177 100644
--- a/app/services/environments/create_service.rb
+++ b/app/services/environments/create_service.rb
@@ -2,7 +2,7 @@
module Environments
class CreateService < BaseService
- ALLOWED_ATTRIBUTES = %i[name external_url tier cluster_agent kubernetes_namespace].freeze
+ ALLOWED_ATTRIBUTES = %i[name external_url tier cluster_agent kubernetes_namespace flux_resource_path].freeze
def execute
unless can?(current_user, :create_environment, project)
diff --git a/app/services/environments/update_service.rb b/app/services/environments/update_service.rb
index 52f6198bada..85b27fe67ce 100644
--- a/app/services/environments/update_service.rb
+++ b/app/services/environments/update_service.rb
@@ -2,7 +2,7 @@
module Environments
class UpdateService < BaseService
- ALLOWED_ATTRIBUTES = %i[external_url tier cluster_agent kubernetes_namespace].freeze
+ ALLOWED_ATTRIBUTES = %i[external_url tier cluster_agent kubernetes_namespace flux_resource_path].freeze
def execute(environment)
unless can?(current_user, :update_environment, environment)
diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb
index f9280be7ee2..6a8e4d17859 100644
--- a/app/services/git/base_hooks_service.rb
+++ b/app/services/git/base_hooks_service.rb
@@ -83,7 +83,6 @@ module Git
def enqueue_notify_kas
return unless Gitlab::Kas.enabled?
- return unless Feature.enabled?(:notify_kas_on_git_push, project)
Clusters::Agents::NotifyGitPushWorker.perform_async(project.id)
end
diff --git a/app/services/grafana/proxy_service.rb b/app/services/grafana/proxy_service.rb
deleted file mode 100644
index 37272c85638..00000000000
--- a/app/services/grafana/proxy_service.rb
+++ /dev/null
@@ -1,94 +0,0 @@
-# frozen_string_literal: true
-
-# Proxies calls to a Grafana-integrated Prometheus instance
-# through the Grafana proxy API
-
-# This allows us to fetch and render metrics in GitLab from a Prometheus
-# instance for which dashboards are configured in Grafana
-module Grafana
- class ProxyService < BaseService
- include ReactiveCaching
-
- self.reactive_cache_key = ->(service) { service.cache_key }
- self.reactive_cache_lease_timeout = 30.seconds
- self.reactive_cache_refresh_interval = 30.seconds
- self.reactive_cache_work_type = :external_dependency
- self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) }
-
- SUPPORTED_DATASOURCE_PATTERN = %r{\A\d+\z}.freeze
-
- SUPPORTED_PROXY_PATH = Gitlab::Metrics::Dashboard::Stages::GrafanaFormatter::PROXY_PATH
-
- attr_accessor :project, :datasource_id, :proxy_path, :query_params
-
- # @param project_id [Integer] Project id for which grafana is configured.
- #
- # See #initialize for other parameters.
- def self.from_cache(project_id, datasource_id, proxy_path, query_params)
- project = Project.find(project_id)
-
- new(project, datasource_id, proxy_path, query_params)
- end
-
- # @param project [Project] Project for which grafana is configured.
- # @param datasource_id [String] Grafana datasource id for Prometheus instance
- # @param proxy_path [String] Path to Prometheus endpoint; EX) 'api/v1/query_range'
- # @param query_params [Hash<String, String>] Supported params: [query, start, end, step]
- def initialize(project, datasource_id, proxy_path, query_params)
- @project = project
- @datasource_id = datasource_id
- @proxy_path = proxy_path
- @query_params = query_params
- end
-
- def execute
- return cannot_proxy_response unless can_proxy?
- return cannot_proxy_response unless client
-
- with_reactive_cache(*cache_key) { |result| result }
- end
-
- def calculate_reactive_cache(*)
- return cannot_proxy_response unless client
-
- response = client.proxy_datasource(
- datasource_id: datasource_id,
- proxy_path: proxy_path,
- query: query_params
- )
-
- success(http_status: response.code, body: response.body)
- rescue ::Grafana::Client::Error => error
- service_unavailable_response(error)
- end
-
- # Required for ReactiveCaching; Usage overridden by
- # self.reactive_cache_worker_finder
- def id
- nil
- end
-
- def cache_key
- [project.id, datasource_id, proxy_path, query_params]
- end
-
- private
-
- def can_proxy?
- SUPPORTED_PROXY_PATH == proxy_path &&
- SUPPORTED_DATASOURCE_PATTERN.match?(datasource_id)
- end
-
- def client
- project.grafana_integration&.client
- end
-
- def service_unavailable_response(exception)
- error(exception.message, :service_unavailable)
- end
-
- def cannot_proxy_response
- error('Proxy support for this API is not available currently')
- end
- end
-end
diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb
index 25a1e9a9873..0f74b2d9349 100644
--- a/app/services/groups/create_service.rb
+++ b/app/services/groups/create_service.rb
@@ -60,7 +60,11 @@ module Groups
end
def remove_unallowed_params
- params.delete(:default_branch_protection) unless can?(current_user, :create_group_with_default_branch_protection)
+ unless can?(current_user, :create_group_with_default_branch_protection)
+ params.delete(:default_branch_protection)
+ params.delete(:default_branch_protection_defaults)
+ end
+
params.delete(:allow_mfa_for_subgroups)
end
diff --git a/app/services/groups/participants_service.rb b/app/services/groups/participants_service.rb
index e939d27d464..a2238264295 100644
--- a/app/services/groups/participants_service.rb
+++ b/app/services/groups/participants_service.rb
@@ -13,7 +13,7 @@ module Groups
participants_in_noteable +
all_members +
groups +
- group_members
+ group_hierarchy_users
render_participants_as_hash(participants.uniq)
end
@@ -26,12 +26,10 @@ module Groups
[{ username: "all", name: "All Group Members", count: group.users_count }]
end
- def group_members
+ def group_hierarchy_users
return [] unless group
- sorted(
- group.direct_and_indirect_users(share_with_groups: group.member?(current_user))
- )
+ sorted(Autocomplete::GroupUsersFinder.new(group: group).execute)
end
end
end
diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb
index 81d4dfddaab..64256e43ce3 100644
--- a/app/services/groups/transfer_service.rb
+++ b/app/services/groups/transfer_service.rb
@@ -11,19 +11,51 @@ module Groups
@error = nil
end
+ def log_group_transfer_success(group, new_parent_group)
+ log_transfer(group, new_parent_group, nil)
+ end
+
+ def log_group_transfer_error(group, new_parent_group, error_message)
+ log_transfer(group, new_parent_group, error_message)
+ end
+
def execute(new_parent_group)
@new_parent_group = new_parent_group
ensure_allowed_transfer
proceed_to_transfer
+ log_group_transfer_success(@group, @new_parent_group)
+
rescue TransferError, ActiveRecord::RecordInvalid, Gitlab::UpdatePathError => e
@group.errors.clear
@error = s_("TransferGroup|Transfer failed: %{error_message}") % { error_message: e.message }
+
+ log_group_transfer_error(@group, @new_parent_group, e.message)
+
false
end
private
+ def log_transfer(group, new_namespace, error_message = nil)
+ action = error_message.nil? ? "was" : "was not"
+
+ log_payload = {
+ message: "Group #{action} transferred to a new namespace",
+ group_path: group.full_path,
+ group_id: group.id,
+ new_parent_group_path: new_parent_group&.full_path,
+ new_parent_group_id: new_parent_group&.id,
+ error_message: error_message
+ }
+
+ if error_message.nil?
+ ::Gitlab::AppLogger.info(log_payload)
+ else
+ ::Gitlab::AppLogger.error(log_payload)
+ end
+ end
+
def proceed_to_transfer
old_root_ancestor_id = @group.root_ancestor.id
was_root_group = @group.root?
diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb
index df6ede87ef9..7d0142fc067 100644
--- a/app/services/groups/update_service.rb
+++ b/app/services/groups/update_service.rb
@@ -21,7 +21,7 @@ module Groups
return false unless valid_share_with_group_lock_change?
- return false unless valid_path_change_with_npm_packages?
+ return false unless valid_path_change?
return false unless update_shared_runners
@@ -46,6 +46,29 @@ module Groups
private
+ def valid_path_change?
+ unless Feature.enabled?(:npm_package_registry_fix_group_path_validation)
+ return valid_path_change_with_npm_packages?
+ end
+
+ return true unless group.packages_feature_enabled?
+ return true if params[:path].blank?
+ return true if group.has_parent?
+ return true if !group.has_parent? && group.path == params[:path]
+
+ # we have a path change on a root group:
+ # check that we don't have any npm package with a scope set to the group path
+ npm_packages = ::Packages::GroupPackagesFinder.new(current_user, group, package_type: :npm, preload_pipelines: false)
+ .execute
+ .with_npm_scope(group.path)
+
+ return true unless npm_packages.exists?
+
+ group.errors.add(:path, s_('GroupSettings|cannot change when group contains projects with NPM packages'))
+ false
+ end
+
+ # TODO: delete this function along with npm_package_registry_fix_group_path_validation
def valid_path_change_with_npm_packages?
return true unless group.packages_feature_enabled?
return true if params[:path].blank?
@@ -107,7 +130,11 @@ module Groups
# overridden in EE
def remove_unallowed_params
params.delete(:emails_disabled) unless can?(current_user, :set_emails_disabled, group)
- params.delete(:default_branch_protection) unless can?(current_user, :update_default_branch_protection, group)
+
+ unless can?(current_user, :update_default_branch_protection, group)
+ params.delete(:default_branch_protection)
+ params.delete(:default_branch_protection_defaults)
+ end
end
def handle_changes
diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb
index c01509bc4d1..166452968f4 100644
--- a/app/services/issuable/bulk_update_service.rb
+++ b/app/services/issuable/bulk_update_service.rb
@@ -45,7 +45,9 @@ module Issuable
def permitted_attrs(type)
attrs = %i(state_event milestone_id add_label_ids remove_label_ids subscription_event)
- if type == 'issue' || type == 'merge_request'
+ if type == 'issue'
+ attrs.push(:assignee_ids, :confidential)
+ elsif type == 'merge_request'
attrs.push(:assignee_ids)
else
attrs.push(:assignee_id)
diff --git a/app/services/issuable_links/create_service.rb b/app/services/issuable_links/create_service.rb
index 1069c9e0915..533e92f6225 100644
--- a/app/services/issuable_links/create_service.rb
+++ b/app/services/issuable_links/create_service.rb
@@ -96,7 +96,7 @@ module IssuableLinks
if params[:issuable_references].present?
extract_references
elsif target_issuable
- [target_issuable]
+ Array.wrap(target_issuable)
else
[]
end
diff --git a/app/services/issues/import_csv_service.rb b/app/services/issues/import_csv_service.rb
index c3d6af952b4..479d971382b 100644
--- a/app/services/issues/import_csv_service.rb
+++ b/app/services/issues/import_csv_service.rb
@@ -22,8 +22,18 @@ module Issues
Issues::CreateService
end
+ def extra_create_service_params
+ { perform_spam_check: perform_spam_check? }
+ end
+
+ def perform_spam_check?
+ !user.can_admin_all_resources?
+ end
+
def record_import_attempt
Issues::CsvImport.create!(user: user, project: project)
end
end
end
+
+Issues::ImportCsvService.prepend_mod
diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb
index c1599ceef6e..e26e3d0835b 100644
--- a/app/services/issues/move_service.rb
+++ b/app/services/issues/move_service.rb
@@ -123,10 +123,10 @@ module Issues
end
def rewrite_related_issues
- source_issue_links = IssueLink.for_source_issue(original_entity)
+ source_issue_links = IssueLink.for_source(original_entity)
source_issue_links.update_all(source_id: new_entity.id)
- target_issue_links = IssueLink.for_target_issue(original_entity)
+ target_issue_links = IssueLink.for_target(original_entity)
target_issue_links.update_all(target_id: new_entity.id)
end
diff --git a/app/services/issues/relative_position_rebalancing_service.rb b/app/services/issues/relative_position_rebalancing_service.rb
index b5c10430e83..a8d0ae01176 100644
--- a/app/services/issues/relative_position_rebalancing_service.rb
+++ b/app/services/issues/relative_position_rebalancing_service.rb
@@ -10,8 +10,8 @@ module Issues
TooManyConcurrentRebalances = Class.new(StandardError)
def initialize(projects)
- @projects_collection = (projects.is_a?(Array) ? Project.id_in(projects) : projects).projects_order_id_asc
- @root_namespace = @projects_collection.take.root_namespace # rubocop:disable CodeReuse/ActiveRecord
+ @projects_collection = (projects.is_a?(Array) ? Project.id_in(projects) : projects).select(:id).projects_order_id_asc
+ @root_namespace = @projects_collection.select(:namespace_id).reorder(nil).take.root_namespace # rubocop:disable CodeReuse/ActiveRecord
@caching = ::Gitlab::Issues::Rebalancing::State.new(@root_namespace, @projects_collection)
end
diff --git a/app/services/labels/available_labels_service.rb b/app/services/labels/available_labels_service.rb
index ff29358df86..21f92eeaf09 100644
--- a/app/services/labels/available_labels_service.rb
+++ b/app/services/labels/available_labels_service.rb
@@ -40,6 +40,21 @@ module Labels
ids.map(&:to_i) & existing_ids
end
+ def filter_locked_labels_ids_in_param(key)
+ ids = Array.wrap(params[key])
+ return [] if ids.empty?
+
+ params = finder_params
+ params[:locked_labels] = true
+ existing_labels = LabelsFinder.new(current_user, params).execute
+
+ # rubocop:disable CodeReuse/ActiveRecord
+ existing_ids = existing_labels.id_in(ids).pluck(:id)
+ # rubocop:enable CodeReuse/ActiveRecord
+
+ ids.map(&:to_i) & existing_ids
+ end
+
def available_labels
@available_labels ||= LabelsFinder.new(current_user, finder_params).execute
end
diff --git a/app/services/labels/create_service.rb b/app/services/labels/create_service.rb
index 6c070d15cdb..675439b2f64 100644
--- a/app/services/labels/create_service.rb
+++ b/app/services/labels/create_service.rb
@@ -13,6 +13,10 @@ module Labels
project_or_group = target_params[:project] || target_params[:group]
if project_or_group.present?
+ if Feature.disabled?(:enforce_locked_labels_on_merge, project_or_group, type: :ops)
+ params.delete(:lock_on_merge)
+ end
+
project_or_group.labels.create(params)
elsif target_params[:template]
label = Label.new(params)
diff --git a/app/services/labels/update_service.rb b/app/services/labels/update_service.rb
index be33947d0eb..4ac54959e84 100644
--- a/app/services/labels/update_service.rb
+++ b/app/services/labels/update_service.rb
@@ -10,9 +10,19 @@ module Labels
def execute(label)
params[:name] = params.delete(:new_name) if params.key?(:new_name)
params[:color] = convert_color_name_to_hex if params[:color].present?
+ params.delete(:lock_on_merge) unless allow_lock_on_merge?(label)
label.update(params)
label
end
+
+ private
+
+ def allow_lock_on_merge?(label)
+ return if label.template?
+ return unless label.respond_to?(:parent_container)
+
+ Feature.enabled?(:enforce_locked_labels_on_merge, label.parent_container, type: :ops)
+ end
end
end
diff --git a/app/services/members/import_project_team_service.rb b/app/services/members/import_project_team_service.rb
index 6efd65e2575..ef43d8206a9 100644
--- a/app/services/members/import_project_team_service.rb
+++ b/app/services/members/import_project_team_service.rb
@@ -2,36 +2,83 @@
module Members
class ImportProjectTeamService < BaseService
- attr_reader :params, :current_user
+ ImportProjectTeamForbiddenError = Class.new(StandardError)
- def target_project_id
- @target_project_id ||= params[:id].presence
+ def initialize(*args)
+ super
+
+ @errors = {}
end
- def source_project_id
- @source_project_id ||= params[:project_id].presence
+ def execute
+ check_target_and_source_projects_exist!
+ check_user_permissions!
+
+ import_project_team
+ process_import_result
+
+ result
+ rescue ArgumentError, ImportProjectTeamForbiddenError => e
+ ServiceResponse.error(message: e.message, reason: :unprocessable_entity)
end
- def target_project
- @target_project ||= Project.find_by_id(target_project_id)
+ private
+
+ attr_reader :members, :params, :current_user, :errors, :result
+
+ def import_project_team
+ @members = target_project.team.import(source_project, current_user)
+
+ if members.is_a?(Array)
+ members.each { |member| check_member_validity(member) }
+ else
+ @result = ServiceResponse.error(message: 'Import failed', reason: :unprocessable_entity)
+ end
end
- def source_project
- @source_project ||= Project.find_by_id(source_project_id)
+ def check_target_and_source_projects_exist!
+ if target_project.blank?
+ raise ArgumentError, 'Target project does not exist'
+ elsif source_project.blank?
+ raise ArgumentError, 'Source project does not exist'
+ end
end
- def execute
- import_project_team
+ def check_user_permissions!
+ return if can?(current_user, :read_project_member, source_project) &&
+ can?(current_user, :import_project_members_from_another_project, target_project)
+
+ raise ImportProjectTeamForbiddenError, 'Forbidden'
end
- private
+ def check_member_validity(member)
+ return if member.valid?
- def import_project_team
- return false unless target_project.present? && source_project.present? && current_user.present?
- return false unless can?(current_user, :read_project_member, source_project)
- return false unless can?(current_user, :import_project_members_from_another_project, target_project)
+ errors[member.user.username] = member.errors.full_messages.to_sentence
+ end
+
+ def process_import_result
+ @result ||= if errors.any?
+ ServiceResponse.error(message: errors, payload: { total_members_count: members.size })
+ else
+ ServiceResponse.success(message: 'Successfully imported')
+ end
+ end
+
+ def target_project_id
+ params[:id]
+ end
- target_project.team.import(source_project, current_user)
+ def source_project_id
+ params[:project_id]
+ end
+
+ def target_project
+ @target_project ||= Project.find_by_id(target_project_id)
+ end
+
+ def source_project
+ @source_project ||= Project.find_by_id(source_project_id)
end
end
end
diff --git a/app/services/members/update_service.rb b/app/services/members/update_service.rb
index b2c0fffc12d..3a3d0e53aae 100644
--- a/app/services/members/update_service.rb
+++ b/app/services/members/update_service.rb
@@ -36,6 +36,7 @@ module Members
member.attributes = params
return unless member.changed?
+ member.expiry_notified_at = nil if member.expires_at_changed?
member.tap(&:save!)
end
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index aaa91548d19..0fc85675e49 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -4,6 +4,7 @@ module MergeRequests
class BaseService < ::IssuableBaseService
extend ::Gitlab::Utils::Override
include MergeRequests::AssignsMergeParams
+ include MergeRequests::ErrorLogger
delegate :repository, to: :project
@@ -175,10 +176,21 @@ module MergeRequests
params.delete(:allow_collaboration)
end
+ filter_locked_labels(merge_request)
filter_reviewer(merge_request)
filter_suggested_reviewers
end
+ # Filter out any locked labels that are requested to be removed.
+ # Only supported for merged MRs.
+ def filter_locked_labels(merge_request)
+ return unless params[:remove_label_ids].present?
+ return unless merge_request.merged?
+ return unless Feature.enabled?(:enforce_locked_labels_on_merge, merge_request.project, type: :ops)
+
+ params[:remove_label_ids] -= labels_service.filter_locked_labels_ids_in_param(:remove_label_ids)
+ end
+
def filter_reviewer(merge_request)
return if params[:reviewer_ids].blank?
@@ -260,32 +272,6 @@ module MergeRequests
end
end
- def log_error(exception:, message:, save_message_on_model: false)
- reference = merge_request.to_reference(full: true)
- data = {
- class: self.class.name,
- message: message,
- merge_request_id: merge_request.id,
- merge_request: reference,
- save_message_on_model: save_message_on_model
- }
-
- if exception
- Gitlab::ApplicationContext.with_context(user: current_user) do
- Gitlab::ErrorTracking.track_exception(exception, data)
- end
-
- data[:"exception.message"] = exception.message
- end
-
- # TODO: Deprecate Gitlab::GitLogger since ErrorTracking should suffice:
- # https://gitlab.com/gitlab-org/gitlab/-/issues/216379
- data[:message] = "#{self.class.name} error (#{reference}): #{message}"
- Gitlab::GitLogger.error(data)
-
- merge_request.update(merge_error: message) if save_message_on_model
- end
-
def trigger_merge_request_reviewers_updated(merge_request)
GraphqlTriggers.merge_request_reviewers_updated(merge_request)
end
diff --git a/app/services/merge_requests/create_ref_service.rb b/app/services/merge_requests/create_ref_service.rb
new file mode 100644
index 00000000000..e0f10183bac
--- /dev/null
+++ b/app/services/merge_requests/create_ref_service.rb
@@ -0,0 +1,130 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ # CreateRefService creates or overwrites a ref under "refs/merge-requests/"
+ # with a commit for the merged result.
+ class CreateRefService
+ include Gitlab::Utils::StrongMemoize
+
+ CreateRefError = Class.new(StandardError)
+
+ def initialize(
+ current_user:, merge_request:, target_ref:, first_parent_ref:,
+ source_sha: nil, merge_commit_message: nil)
+
+ @current_user = current_user
+ @merge_request = merge_request
+ @initial_source_sha = source_sha
+ @target_ref = target_ref
+ @merge_commit_message = merge_commit_message
+ @first_parent_sha = target_project.commit(first_parent_ref)&.sha
+ end
+
+ def execute
+ commit_sha = initial_source_sha # the SHA to be at HEAD of target_ref
+ source_sha = initial_source_sha # the SHA to be the merged result of the source (minus the merge commit)
+ expected_old_oid = "" # the SHA we expect target_ref to be at prior to an update (an optimistic lock)
+
+ # TODO: Update this message with the removal of FF merge_trains_create_ref_service and update tests
+ # This is for compatibility with MergeToRefService during the rollout.
+ return ServiceResponse.error(message: '3:Invalid merge source') unless first_parent_sha.present?
+
+ commit_sha, source_sha, expected_old_oid = maybe_squash!(commit_sha, source_sha, expected_old_oid)
+ commit_sha, source_sha, expected_old_oid = maybe_rebase!(commit_sha, source_sha, expected_old_oid)
+ commit_sha, source_sha = maybe_merge!(commit_sha, source_sha, expected_old_oid)
+
+ ServiceResponse.success(
+ payload: {
+ commit_sha: commit_sha,
+ target_sha: first_parent_sha,
+ source_sha: source_sha
+ }
+ )
+ rescue CreateRefError => error
+ ServiceResponse.error(message: error.message)
+ end
+
+ private
+
+ attr_reader :current_user, :merge_request, :target_ref, :first_parent_sha, :initial_source_sha
+
+ delegate :target_project, to: :merge_request
+ delegate :repository, to: :target_project
+
+ def maybe_squash!(commit_sha, source_sha, expected_old_oid)
+ if merge_request.squash_on_merge?
+ squash_result = MergeRequests::SquashService.new(
+ merge_request: merge_request,
+ current_user: current_user,
+ commit_message: squash_commit_message
+ ).execute
+ raise CreateRefError, squash_result[:message] if squash_result[:status] == :error
+
+ commit_sha = squash_result[:squash_sha]
+ source_sha = commit_sha
+ end
+
+ # squash does not overwrite target_ref, so expected_old_oid remains the same
+ [commit_sha, source_sha, expected_old_oid]
+ end
+
+ def maybe_rebase!(commit_sha, source_sha, expected_old_oid)
+ if target_project.ff_merge_must_be_possible?
+ commit_sha = safe_gitaly_operation do
+ repository.rebase_to_ref(
+ current_user,
+ source_sha: source_sha,
+ target_ref: target_ref,
+ first_parent_ref: first_parent_sha
+ )
+ end
+
+ source_sha = commit_sha
+ expected_old_oid = commit_sha
+ end
+
+ [commit_sha, source_sha, expected_old_oid]
+ end
+
+ def maybe_merge!(commit_sha, source_sha, expected_old_oid)
+ unless target_project.merge_requests_ff_only_enabled
+ commit_sha = safe_gitaly_operation do
+ repository.merge_to_ref(
+ current_user,
+ source_sha: source_sha,
+ target_ref: target_ref,
+ message: merge_commit_message,
+ first_parent_ref: first_parent_sha,
+ branch: nil,
+ expected_old_oid: expected_old_oid
+ )
+ end
+ commit = target_project.commit(commit_sha)
+ _, source_sha = commit.parent_ids
+ end
+
+ [commit_sha, source_sha]
+ end
+
+ def safe_gitaly_operation
+ yield
+ rescue Gitlab::Git::PreReceiveError, Gitlab::Git::CommandError, ArgumentError => error
+ raise CreateRefError, error.message
+ end
+
+ def squash_commit_message
+ merge_request.merge_params['squash_commit_message'].presence ||
+ merge_request.default_squash_commit_message(user: current_user)
+ end
+ strong_memoize_attr :squash_commit_message
+
+ def merge_commit_message
+ return @merge_commit_message if @merge_commit_message.present?
+
+ @merge_commit_message = (
+ merge_request.merge_params['commit_message'].presence ||
+ merge_request.default_merge_commit_message(user: current_user)
+ )
+ end
+ end
+end
diff --git a/app/services/merge_requests/merge_base_service.rb b/app/services/merge_requests/merge_base_service.rb
index 2a3c1e8bc26..fa0a4f808e2 100644
--- a/app/services/merge_requests/merge_base_service.rb
+++ b/app/services/merge_requests/merge_base_service.rb
@@ -60,8 +60,11 @@ module MergeRequests
end
def squash_sha!
- params[:merge_request] = merge_request
- squash_result = ::MergeRequests::SquashService.new(project: project, current_user: current_user, params: params).execute
+ squash_result = ::MergeRequests::SquashService.new(
+ merge_request: merge_request,
+ current_user: current_user,
+ commit_message: params[:squash_commit_message]
+ ).execute
case squash_result[:status]
when :success
diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb
index 5e41375e7a0..1398a6dbb67 100644
--- a/app/services/merge_requests/merge_service.rb
+++ b/app/services/merge_requests/merge_service.rb
@@ -16,6 +16,8 @@ module MergeRequests
delegate :merge_jid, :state, to: :@merge_request
def execute(merge_request, options = {})
+ return execute_v2(merge_request, options) if Feature.enabled?(:refactor_merge_service, project)
+
if project.merge_requests_ff_only_enabled && !self.is_a?(FfMergeService)
FfMergeService.new(project: project, current_user: current_user, params: params).execute(merge_request)
return
@@ -45,11 +47,40 @@ module MergeRequests
exclusive_lease(merge_request.id).cancel
end
+ def execute_v2(merge_request, options = {})
+ return if merge_request.merged?
+ return unless exclusive_lease(merge_request.id).try_obtain
+
+ merge_strategy_class = options[:merge_strategy] || MergeRequests::MergeStrategies::FromSourceBranch
+ @merge_strategy = merge_strategy_class.new(merge_request, current_user, merge_params: params, options: options)
+
+ @merge_request = merge_request
+ @options = options
+ jid = merge_jid
+
+ validate!
+
+ merge_request.in_locked_state do
+ if commit_v2
+ after_merge
+ clean_merge_jid
+ success
+ end
+ end
+
+ log_info("Merge process finished on JID #{jid} with state #{state}")
+ rescue MergeError, MergeRequests::MergeStrategies::StrategyError => e
+ handle_merge_error(log_message: e.message, save_message_on_model: true)
+ ensure
+ exclusive_lease(merge_request.id).cancel
+ end
+
private
def validate!
authorization_check!
error_check!
+ validate_strategy!
updated_check!
end
@@ -59,15 +90,18 @@ module MergeRequests
end
end
+ # Can remove this entire method when :refactor_merge_service is enabled
def error_check!
super
+ return if Feature.enabled?(:refactor_merge_service, project)
+
check_source
error =
if @merge_request.should_be_rebased?
'Only fast-forward merge is allowed for your project. Please update your source branch'
- elsif !@merge_request.mergeable?(skip_discussions_check: @options[:skip_discussions_check])
+ elsif !@merge_request.mergeable?(skip_discussions_check: @options[:skip_discussions_check], check_mergeability_retry_lease: @options[:check_mergeability_retry_lease])
'Merge request is not mergeable'
elsif !@merge_request.squash && project.squash_always?
'This project requires squashing commits when merge requests are accepted.'
@@ -76,6 +110,10 @@ module MergeRequests
raise_error(error) if error
end
+ def validate_strategy!
+ @merge_strategy.validate! if Feature.enabled?(:refactor_merge_service, project)
+ end
+
def updated_check!
unless source_matches?
raise_error('Branch has been updated since the merge was requested. '\
@@ -83,9 +121,28 @@ module MergeRequests
end
end
+ def commit_v2
+ log_info("Git merge started on JID #{merge_jid}")
+
+ merge_result = try_merge { @merge_strategy.execute_git_merge! }
+
+ commit_sha = merge_result[:commit_sha]
+ raise_error(GENERIC_ERROR_MESSAGE) unless commit_sha
+
+ log_info("Git merge finished on JID #{merge_jid} commit #{commit_sha}")
+
+ new_merge_request_attributes = merge_result.slice(:merge_commit_sha, :squash_commit_sha)
+ merge_request.update!(new_merge_request_attributes) if new_merge_request_attributes.present?
+
+ commit_sha
+ ensure
+ merge_request.update_and_mark_in_progress_merge_commit_sha(nil)
+ log_info("Merge request marked in progress")
+ end
+
def commit
log_info("Git merge started on JID #{merge_jid}")
- commit_id = try_merge
+ commit_id = try_merge { execute_git_merge }
if commit_id
log_info("Git merge finished on JID #{merge_jid} commit #{commit_id}")
@@ -113,7 +170,7 @@ module MergeRequests
end
def try_merge
- execute_git_merge
+ yield
rescue Gitlab::Git::PreReceiveError => e
raise MergeError, "Something went wrong during merge pre-receive hook. #{e.message}".strip
rescue StandardError => e
diff --git a/app/services/merge_requests/merge_strategies/from_source_branch.rb b/app/services/merge_requests/merge_strategies/from_source_branch.rb
new file mode 100644
index 00000000000..9fe5fc5160b
--- /dev/null
+++ b/app/services/merge_requests/merge_strategies/from_source_branch.rb
@@ -0,0 +1,112 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ module MergeStrategies
+ # FromSourceBranch performs a git merge from a merge request's source branch
+ # to the target branch, including a squash if needed.
+ class FromSourceBranch
+ include Gitlab::Utils::StrongMemoize
+
+ delegate :repository, to: :project
+
+ def initialize(merge_request, current_user, merge_params: {}, options: {})
+ @merge_request = merge_request
+ @current_user = current_user
+ @project = merge_request.project
+ @merge_params = merge_params
+ @options = options
+ end
+
+ def validate!
+ error_message =
+ if source_sha.blank?
+ 'No source for merge'
+ elsif merge_request.should_be_rebased?
+ 'Only fast-forward merge is allowed for your project. Please update your source branch'
+ elsif !merge_request.mergeable?(
+ skip_discussions_check: @options[:skip_discussions_check],
+ check_mergeability_retry_lease: @options[:check_mergeability_retry_lease]
+ )
+ 'Merge request is not mergeable'
+ elsif !merge_request.squash && project.squash_always?
+ 'This project requires squashing commits when merge requests are accepted.'
+ end
+
+ raise_error(error_message) if error_message
+ end
+
+ def execute_git_merge!
+ result =
+ if project.merge_requests_ff_only_enabled
+ fast_forward!
+ else
+ merge_commit!
+ end
+
+ result[:squash_commit_sha] = source_sha if merge_request.squash_on_merge?
+
+ result
+ end
+
+ private
+
+ attr_reader :merge_request, :current_user, :merge_params, :options, :project
+
+ def source_sha
+ if merge_request.squash_on_merge?
+ squash_sha!
+ else
+ merge_request.diff_head_sha
+ end
+ end
+ strong_memoize_attr :source_sha
+
+ def squash_sha!
+ squash_result = ::MergeRequests::SquashService
+ .new(
+ merge_request: merge_request,
+ current_user: current_user,
+ commit_message: merge_params[:squash_commit_message]
+ ).execute
+
+ case squash_result[:status]
+ when :success
+ squash_result[:squash_sha]
+ when :error
+ raise_error(squash_result[:message])
+ end
+ end
+
+ def fast_forward!
+ commit_sha = repository.ff_merge(
+ current_user,
+ source_sha,
+ merge_request.target_branch,
+ merge_request: merge_request
+ )
+
+ { commit_sha: commit_sha }
+ end
+
+ def merge_commit!
+ commit_sha = repository.merge(
+ current_user,
+ source_sha,
+ merge_request,
+ merge_commit_message
+ )
+
+ { commit_sha: commit_sha, merge_commit_sha: commit_sha }
+ end
+
+ def merge_commit_message
+ merge_params[:commit_message] ||
+ merge_request.default_merge_commit_message(user: current_user)
+ end
+
+ def raise_error(message)
+ raise ::MergeRequests::MergeStrategies::StrategyError, message
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/merge_strategies/strategy_error.rb b/app/services/merge_requests/merge_strategies/strategy_error.rb
new file mode 100644
index 00000000000..144860f5c93
--- /dev/null
+++ b/app/services/merge_requests/merge_strategies/strategy_error.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ module MergeStrategies
+ StrategyError = Class.new(StandardError)
+ end
+end
diff --git a/app/services/merge_requests/merge_to_ref_service.rb b/app/services/merge_requests/merge_to_ref_service.rb
index acd3bc36e1d..8b79feb5e0f 100644
--- a/app/services/merge_requests/merge_to_ref_service.rb
+++ b/app/services/merge_requests/merge_to_ref_service.rb
@@ -13,13 +13,12 @@ module MergeRequests
class MergeToRefService < MergeRequests::MergeBaseService
extend ::Gitlab::Utils::Override
- def execute(merge_request, cache_merge_to_ref_calls = false)
+ def execute(merge_request)
@merge_request = merge_request
error_check!
- commit_id = commit(cache_merge_to_ref_calls)
-
+ commit_id = extracted_merge_to_ref
raise_error('Conflicts detected during merge') unless commit_id
commit = project.commit(commit_id)
@@ -56,16 +55,6 @@ module MergeRequests
params[:first_parent_ref] || merge_request.target_branch_ref
end
- def commit(cache_merge_to_ref_calls = false)
- if cache_merge_to_ref_calls
- Rails.cache.fetch(cache_key, expires_in: 1.day) do
- extracted_merge_to_ref
- end
- else
- extracted_merge_to_ref
- end
- end
-
def extracted_merge_to_ref
repository.merge_to_ref(current_user,
source_sha: source,
@@ -76,9 +65,5 @@ module MergeRequests
rescue Gitlab::Git::PreReceiveError, Gitlab::Git::CommandError => error
raise MergeError, error.message
end
-
- def cache_key
- [:merge_to_ref_service, project.full_path, merge_request.target_branch_sha, merge_request.source_branch_sha]
- end
end
end
diff --git a/app/services/merge_requests/squash_service.rb b/app/services/merge_requests/squash_service.rb
index f04682bf08a..0b1aefb30d7 100644
--- a/app/services/merge_requests/squash_service.rb
+++ b/app/services/merge_requests/squash_service.rb
@@ -1,7 +1,17 @@
# frozen_string_literal: true
module MergeRequests
- class SquashService < MergeRequests::BaseService
+ class SquashService
+ include BaseServiceUtility
+ include MergeRequests::ErrorLogger
+
+ def initialize(merge_request:, current_user:, commit_message:)
+ @merge_request = merge_request
+ @target_project = merge_request.target_project
+ @current_user = current_user
+ @commit_message = commit_message
+ end
+
def execute
# If performing a squash would result in no change, then
# immediately return a success message without performing a squash
@@ -16,6 +26,8 @@ module MergeRequests
private
+ attr_reader :merge_request, :target_project, :current_user, :commit_message
+
def squash!
squash_sha = repository.squash(current_user, merge_request, message)
@@ -34,12 +46,8 @@ module MergeRequests
target_project.repository
end
- def merge_request
- params[:merge_request]
- end
-
def message
- params[:squash_commit_message].presence || merge_request.default_squash_commit_message(user: current_user)
+ commit_message.presence || merge_request.default_squash_commit_message(user: current_user)
end
end
end
diff --git a/app/services/metrics/dashboard/annotations/create_service.rb b/app/services/metrics/dashboard/annotations/create_service.rb
deleted file mode 100644
index 47e9afa36b9..00000000000
--- a/app/services/metrics/dashboard/annotations/create_service.rb
+++ /dev/null
@@ -1,81 +0,0 @@
-# frozen_string_literal: true
-
-# Create Metrics::Dashboard::Annotation entry based on matched dashboard_path, environment, cluster
-module Metrics
- module Dashboard
- module Annotations
- class CreateService < ::BaseService
- include Stepable
-
- steps :authorize_environment_access,
- :authorize_cluster_access,
- :parse_dashboard_path,
- :create
-
- def initialize(user, params)
- @user = user
- @params = params
- end
-
- def execute
- execute_steps
- end
-
- private
-
- attr_reader :user, :params
-
- def authorize_environment_access(options)
- if environment.nil? || Ability.allowed?(user, :admin_metrics_dashboard_annotation, project)
- options[:environment] = environment
- success(options)
- else
- error(s_('MetricsDashboardAnnotation|You are not authorized to create annotation for selected environment'))
- end
- end
-
- def authorize_cluster_access(options)
- if cluster.nil? || Ability.allowed?(user, :admin_metrics_dashboard_annotation, cluster)
- options[:cluster] = cluster
- success(options)
- else
- error(s_('MetricsDashboardAnnotation|You are not authorized to create annotation for selected cluster'))
- end
- end
-
- def parse_dashboard_path(options)
- dashboard_path = params[:dashboard_path]
-
- Gitlab::Metrics::Dashboard::Finder.find_raw(project, dashboard_path: dashboard_path)
- options[:dashboard_path] = dashboard_path
-
- success(options)
- rescue Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError
- error(s_('MetricsDashboardAnnotation|Dashboard with requested path can not be found'))
- end
-
- def create(options)
- annotation = Annotation.new(options.slice(:environment, :cluster, :dashboard_path).merge(params.slice(:description, :starting_at, :ending_at)))
-
- if annotation.save
- success(annotation: annotation)
- else
- error(annotation.errors)
- end
- end
-
- def environment
- params[:environment]
- end
-
- def cluster
- params[:cluster]
- end
-
- def project
- (environment || cluster)&.project
- end
- end
- end
- end
-end
diff --git a/app/services/metrics/dashboard/annotations/delete_service.rb b/app/services/metrics/dashboard/annotations/delete_service.rb
deleted file mode 100644
index 34918c89304..00000000000
--- a/app/services/metrics/dashboard/annotations/delete_service.rb
+++ /dev/null
@@ -1,44 +0,0 @@
-# frozen_string_literal: true
-
-# Delete Metrics::Dashboard::Annotation entry
-module Metrics
- module Dashboard
- module Annotations
- class DeleteService < ::BaseService
- include Stepable
-
- steps :authorize_action,
- :delete
-
- def initialize(user, annotation)
- @user = user
- @annotation = annotation
- end
-
- def execute
- execute_steps
- end
-
- private
-
- attr_reader :user, :annotation
-
- def authorize_action(_options)
- if Ability.allowed?(user, :admin_metrics_dashboard_annotation, annotation)
- success
- else
- error(s_('MetricsDashboardAnnotation|You are not authorized to delete this annotation'))
- end
- end
-
- def delete(_options)
- if annotation.destroy
- success
- else
- error(s_('MetricsDashboardAnnotation|Annotation has not been deleted'))
- end
- end
- end
- end
- end
-end
diff --git a/app/services/metrics/dashboard/base_embed_service.rb b/app/services/metrics/dashboard/base_embed_service.rb
deleted file mode 100644
index 4c7fa454460..00000000000
--- a/app/services/metrics/dashboard/base_embed_service.rb
+++ /dev/null
@@ -1,40 +0,0 @@
-# frozen_string_literal: true
-
-# Base class for embed services. Contains a few basic helper
-# methods that the embed services share.
-module Metrics
- module Dashboard
- class BaseEmbedService < ::Metrics::Dashboard::BaseService
- def self.embedded?(embed_param)
- ActiveModel::Type::Boolean.new.cast(embed_param)
- end
-
- def cache_key
- "dynamic_metrics_dashboard_#{identifiers}"
- end
-
- protected
-
- def dashboard_path
- params[:dashboard_path].presence ||
- ::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH
- end
-
- def group
- params[:group]
- end
-
- def title
- params[:title]
- end
-
- def y_label
- params[:y_label]
- end
-
- def identifiers
- [dashboard_path, group, title, y_label].join('|')
- end
- end
- end
-end
diff --git a/app/services/metrics/dashboard/base_service.rb b/app/services/metrics/dashboard/base_service.rb
deleted file mode 100644
index 5975fa28b0b..00000000000
--- a/app/services/metrics/dashboard/base_service.rb
+++ /dev/null
@@ -1,140 +0,0 @@
-# frozen_string_literal: true
-
-# Searches a projects repository for a metrics dashboard and formats the output.
-# Expects any custom dashboards will be located in `.gitlab/dashboards`
-module Metrics
- module Dashboard
- class BaseService < ::BaseService
- include Gitlab::Metrics::Dashboard::Errors
-
- STAGES = ::Gitlab::Metrics::Dashboard::Stages
- SEQUENCE = [
- STAGES::CommonMetricsInserter,
- STAGES::MetricEndpointInserter,
- STAGES::VariableEndpointInserter,
- STAGES::PanelIdsInserter,
- STAGES::TrackPanelType,
- STAGES::UrlValidator
- ].freeze
-
- def get_dashboard
- return error('Insufficient permissions.', :unauthorized) unless allowed?
-
- success(dashboard: process_dashboard)
- rescue StandardError => e
- handle_errors(e)
- end
-
- # Summary of all known dashboards for the service.
- # @return [Array<Hash>] ex) [{ path: String, default: Boolean }]
- def self.all_dashboard_paths(_project)
- raise NotImplementedError
- end
-
- # Returns an un-processed dashboard from the cache.
- def raw_dashboard
- Gitlab::Metrics::Dashboard::Cache.for(project).fetch(cache_key) { get_raw_dashboard }
- end
-
- # Should return true if this dashboard service is for an out-of-the-box
- # dashboard.
- # This method is overridden in app/services/metrics/dashboard/predefined_dashboard_service.rb.
- # @return Boolean
- def self.out_of_the_box_dashboard?
- false
- end
-
- private
-
- # Determines whether users should be able to view
- # dashboards at all.
- def allowed?
- return false unless params[:environment]
-
- project&.feature_available?(:metrics_dashboard, current_user)
- end
-
- # Returns a new dashboard Hash, supplemented with DB info
- def process_dashboard
- # Get the dashboard from cache/disk before beginning the benchmark.
- dashboard = raw_dashboard
- processed_dashboard = nil
-
- benchmark_processing do
- processed_dashboard = ::Gitlab::Metrics::Dashboard::Processor
- .new(project, dashboard, sequence, process_params)
- .process
- end
-
- processed_dashboard
- end
-
- def benchmark_processing
- output = nil
-
- processing_time_seconds = Benchmark.realtime { output = yield }
-
- if output
- processing_time_metric.observe(
- processing_time_metric_labels,
- processing_time_seconds * 1_000
- )
- end
- end
-
- def process_params
- params
- end
-
- # @return [String] Relative filepath of the dashboard yml
- def dashboard_path
- params[:dashboard_path]
- end
-
- def load_yaml(data)
- ::Gitlab::Config::Loader::Yaml.new(data).load_raw!
- rescue Gitlab::Config::Loader::Yaml::NotHashError
- # Raise more informative error in app/models/performance_monitoring/prometheus_dashboard.rb.
- {}
- rescue Gitlab::Config::Loader::Yaml::DataTooLargeError => exception
- raise Gitlab::Metrics::Dashboard::Errors::LayoutError, exception.message
- rescue Gitlab::Config::Loader::FormatError
- raise Gitlab::Metrics::Dashboard::Errors::LayoutError, _('Invalid yaml')
- end
-
- # @return [Hash] an unmodified dashboard
- def get_raw_dashboard
- raise NotImplementedError
- end
-
- # @return [String]
- def cache_key
- raise NotImplementedError
- end
-
- def sequence
- SEQUENCE
- end
-
- def processing_time_metric
- @processing_time_metric ||= ::Gitlab::Metrics.summary(
- :gitlab_metrics_dashboard_processing_time_ms,
- 'Metrics dashboard processing time in milliseconds'
- )
- end
-
- def processing_time_metric_labels
- {
- stages: sequence_string,
- service: self.class.name
- }
- end
-
- # If @sequence is [STAGES::CommonMetricsInserter, STAGES::CustomMetricsInserter],
- # this function will output `CommonMetricsInserter-CustomMetricsInserter`.
- def sequence_string
- sequence.map { |stage_class| stage_class.to_s.split('::').last }.join('-')
- end
- end
- end
-end
diff --git a/app/services/metrics/dashboard/clone_dashboard_service.rb b/app/services/metrics/dashboard/clone_dashboard_service.rb
deleted file mode 100644
index 18623ad336d..00000000000
--- a/app/services/metrics/dashboard/clone_dashboard_service.rb
+++ /dev/null
@@ -1,174 +0,0 @@
-# frozen_string_literal: true
-
-# Copies system dashboard definition in .yml file into designated
-# .yml file inside `.gitlab/dashboards`
-module Metrics
- module Dashboard
- class CloneDashboardService < ::BaseService
- include Stepable
- include Gitlab::Utils::StrongMemoize
-
- ALLOWED_FILE_TYPE = '.yml'
- USER_DASHBOARDS_DIR = ::Gitlab::Metrics::Dashboard::RepoDashboardFinder::DASHBOARD_ROOT
- SEQUENCES = {
- ::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH => [
- ::Gitlab::Metrics::Dashboard::Stages::CommonMetricsInserter,
- ::Gitlab::Metrics::Dashboard::Stages::CustomMetricsInserter
- ].freeze,
-
- ::Metrics::Dashboard::ClusterDashboardService::DASHBOARD_PATH => [
- ::Gitlab::Metrics::Dashboard::Stages::CommonMetricsInserter
- ].freeze
- }.freeze
-
- steps :check_push_authorized,
- :check_branch_name,
- :check_file_type,
- :check_dashboard_template,
- :create_file,
- :refresh_repository_method_caches
-
- def execute
- execute_steps
- end
-
- private
-
- def check_push_authorized(result)
- return error(_('You are not allowed to push into this branch. Create another branch or open a merge request.'), :forbidden) unless push_authorized?
-
- success(result)
- end
-
- def check_branch_name(result)
- return error(_('There was an error creating the dashboard, branch name is invalid.'), :bad_request) unless valid_branch_name?
- return error(_('There was an error creating the dashboard, branch named: %{branch} already exists.') % { branch: params[:branch] }, :bad_request) unless new_or_default_branch?
-
- success(result)
- end
-
- def check_file_type(result)
- return error(_('The file name should have a .yml extension'), :bad_request) unless target_file_type_valid?
-
- success(result)
- end
-
- # Only allow out of the box metrics dashboards to be cloned. This can be
- # changed to allow cloning of any metrics dashboard, if desired.
- # However, only metrics dashboards should be allowed. If any file is
- # allowed to be cloned, this will become a security risk.
- def check_dashboard_template(result)
- return error(_('Not found.'), :not_found) unless dashboard_service&.out_of_the_box_dashboard?
-
- success(result)
- end
-
- def create_file(result)
- create_file_response = ::Files::CreateService.new(project, current_user, dashboard_attrs).execute
-
- if create_file_response[:status] == :success
- success(result.merge(create_file_response))
- else
- wrap_error(create_file_response)
- end
- end
-
- def refresh_repository_method_caches(result)
- repository.refresh_method_caches([:metrics_dashboard])
-
- success(result.merge(http_status: :created, dashboard: dashboard_details))
- end
-
- def dashboard_service
- strong_memoize(:dashboard_service) do
- Gitlab::Metrics::Dashboard::ServiceSelector.call(dashboard_service_options)
- end
- end
-
- def dashboard_attrs
- {
- commit_message: params[:commit_message],
- file_path: new_dashboard_path,
- file_content: new_dashboard_content,
- encoding: 'text',
- branch_name: branch,
- start_branch: repository.branch_exists?(branch) ? branch : project.default_branch
- }
- end
-
- def dashboard_details
- {
- path: new_dashboard_path,
- display_name: ::Metrics::Dashboard::CustomDashboardService.name_for_path(new_dashboard_path),
- default: false,
- system_dashboard: false
- }
- end
-
- def push_authorized?
- Gitlab::UserAccess.new(current_user, container: project).can_push_to_branch?(branch)
- end
-
- def dashboard_template
- @dashboard_template ||= params[:dashboard]
- end
-
- def branch
- @branch ||= params[:branch]
- end
-
- def new_or_default_branch?
- !repository.branch_exists?(params[:branch]) || project.default_branch == params[:branch]
- end
-
- def valid_branch_name?
- Gitlab::GitRefValidator.validate(params[:branch])
- end
-
- def new_dashboard_path
- @new_dashboard_path ||= File.join(USER_DASHBOARDS_DIR, file_name)
- end
-
- def file_name
- @file_name ||= File.basename(params[:file_name])
- end
-
- def target_file_type_valid?
- File.extname(params[:file_name]) == ALLOWED_FILE_TYPE
- end
-
- def wrap_error(result)
- if result[:message] == 'A file with this name already exists'
- error(_("A file with '%{file_name}' already exists in %{branch} branch") % { file_name: file_name, branch: branch }, :bad_request)
- else
- result
- end
- end
-
- def new_dashboard_content
- ::Gitlab::Metrics::Dashboard::Processor
- .new(project, raw_dashboard, sequence, {})
- .process.deep_stringify_keys.to_yaml
- end
-
- def repository
- @repository ||= project.repository
- end
-
- def raw_dashboard
- dashboard_service.new(project, current_user, dashboard_service_options).raw_dashboard
- end
-
- def dashboard_service_options
- {
- embedded: false,
- dashboard_path: dashboard_template
- }
- end
-
- def sequence
- SEQUENCES[dashboard_template] || []
- end
- end
- end
-end
diff --git a/app/services/metrics/dashboard/cluster_dashboard_service.rb b/app/services/metrics/dashboard/cluster_dashboard_service.rb
deleted file mode 100644
index 4a28e847fdd..00000000000
--- a/app/services/metrics/dashboard/cluster_dashboard_service.rb
+++ /dev/null
@@ -1,39 +0,0 @@
-# frozen_string_literal: true
-
-# Fetches the system metrics dashboard and formats the output.
-# Use Gitlab::Metrics::Dashboard::Finder to retrive dashboards.
-module Metrics
- module Dashboard
- class ClusterDashboardService < ::Metrics::Dashboard::PredefinedDashboardService
- DASHBOARD_PATH = 'config/prometheus/cluster_metrics.yml'
- DASHBOARD_NAME = 'Cluster'
-
- # SHA256 hash of dashboard content
- DASHBOARD_VERSION = 'e1a4f8cc2c044cf32273af2cd775eb484729baac0995db687d81d92686bf588e'
-
- SEQUENCE = [
- STAGES::ClusterEndpointInserter,
- STAGES::PanelIdsInserter
- ].freeze
-
- class << self
- def valid_params?(params)
- # support selecting this service by cluster id via .find
- # Use super to support selecting this service by dashboard_path via .find_raw
- (params[:cluster].present? && params[:embedded] != 'true') || super
- end
- end
-
- # Permissions are handled at the controller level
- def allowed?
- true
- end
-
- private
-
- def dashboard_version
- DASHBOARD_VERSION
- end
- end
- end
-end
diff --git a/app/services/metrics/dashboard/cluster_metrics_embed_service.rb b/app/services/metrics/dashboard/cluster_metrics_embed_service.rb
deleted file mode 100644
index 6fb39ed3004..00000000000
--- a/app/services/metrics/dashboard/cluster_metrics_embed_service.rb
+++ /dev/null
@@ -1,37 +0,0 @@
-# frozen_string_literal: true
-#
-module Metrics
- module Dashboard
- class ClusterMetricsEmbedService < Metrics::Dashboard::DynamicEmbedService
- class << self
- def valid_params?(params)
- [
- params[:cluster],
- embedded?(params[:embedded]),
- params[:group].present?,
- params[:title].present?,
- params[:y_label].present?
- ].all?
- end
- end
-
- private
-
- # Permissions are handled at the controller level
- def allowed?
- true
- end
-
- def dashboard_path
- ::Metrics::Dashboard::ClusterDashboardService::DASHBOARD_PATH
- end
-
- def sequence
- [
- STAGES::ClusterEndpointInserter,
- STAGES::PanelIdsInserter
- ]
- end
- end
- end
-end
diff --git a/app/services/metrics/dashboard/custom_dashboard_service.rb b/app/services/metrics/dashboard/custom_dashboard_service.rb
deleted file mode 100644
index bde8e86851a..00000000000
--- a/app/services/metrics/dashboard/custom_dashboard_service.rb
+++ /dev/null
@@ -1,53 +0,0 @@
-# frozen_string_literal: true
-
-# Searches a projects repository for a metrics dashboard and formats the output.
-# Expects any custom dashboards will be located in `.gitlab/dashboards`
-# Use Gitlab::Metrics::Dashboard::Finder to retrive dashboards.
-module Metrics
- module Dashboard
- class CustomDashboardService < ::Metrics::Dashboard::BaseService
- class << self
- def valid_params?(params)
- params[:dashboard_path].present?
- end
-
- def all_dashboard_paths(project)
- project.repository.user_defined_metrics_dashboard_paths
- .map do |filepath|
- {
- path: filepath,
- display_name: name_for_path(filepath),
- default: false,
- system_dashboard: false,
- out_of_the_box_dashboard: out_of_the_box_dashboard?
- }
- end
- end
-
- # Grabs the filepath after the base directory.
- def name_for_path(filepath)
- filepath.delete_prefix("#{Gitlab::Metrics::Dashboard::RepoDashboardFinder::DASHBOARD_ROOT}/")
- end
- end
-
- private
-
- # Searches the project repo for a custom-defined dashboard.
- def get_raw_dashboard
- yml = Gitlab::Metrics::Dashboard::RepoDashboardFinder.read_dashboard(project, dashboard_path)
-
- load_yaml(yml)
- end
-
- def cache_key
- "project_#{project.id}_metrics_dashboard_#{dashboard_path}"
- end
-
- def sequence
- [
- ::Gitlab::Metrics::Dashboard::Stages::CustomDashboardMetricsInserter
- ] + super
- end
- end
- end
-end
diff --git a/app/services/metrics/dashboard/custom_metric_embed_service.rb b/app/services/metrics/dashboard/custom_metric_embed_service.rb
deleted file mode 100644
index eff1db21aff..00000000000
--- a/app/services/metrics/dashboard/custom_metric_embed_service.rb
+++ /dev/null
@@ -1,116 +0,0 @@
-# frozen_string_literal: true
-
-# Responsible for returning a dashboard containing specified
-# custom metrics. Creates panels based on the matching metrics
-# stored in the database.
-#
-# Use Gitlab::Metrics::Dashboard::Finder to retrive dashboards.
-module Metrics
- module Dashboard
- class CustomMetricEmbedService < ::Metrics::Dashboard::BaseEmbedService
- extend ::Gitlab::Utils::Override
- include Gitlab::Utils::StrongMemoize
- include Gitlab::Metrics::Dashboard::Defaults
-
- class << self
- # Determines whether the provided params are sufficient
- # to uniquely identify a panel composed of user-defined
- # custom metrics from the DB.
- def valid_params?(params)
- [
- embedded?(params[:embedded]),
- valid_dashboard?(params[:dashboard_path]),
- valid_group_title?(params[:group]),
- params[:title].present?,
- params.has_key?(:y_label)
- ].all?
- end
-
- private
-
- # A group title is valid if it is one of the limited
- # options the user can select in the UI.
- def valid_group_title?(group)
- Enums::PrometheusMetric
- .custom_group_details
- .map { |_, details| details[:group_title] }
- .include?(group)
- end
-
- # All custom metrics are displayed on the system dashboard.
- # Nil is acceptable as we'll default to the system dashboard.
- def valid_dashboard?(dashboard)
- dashboard.nil? || ::Metrics::Dashboard::SystemDashboardService.matching_dashboard?(dashboard)
- end
- end
-
- # Returns a new dashboard with only the matching
- # metrics from the system dashboard, stripped of
- # group info.
- #
- # Note: This overrides the method #raw_dashboard,
- # which means the result will not be cached. This
- # is because we are inserting DB info into the
- # dashboard before post-processing. This ensures
- # we aren't acting on deleted or out-of-date metrics.
- #
- # @return [Hash]
- override :raw_dashboard
- def raw_dashboard
- panels_not_found!(identifiers) if metrics.empty?
-
- { 'panel_groups' => [{ 'panels' => panels }] }
- end
-
- private
-
- # Generated dashboard panels for each metric which
- # matches the provided input.
- #
- # As the panel is generated
- # on the fly, we're using default values for info
- # not represented in the DB.
- #
- # @return [Array<Hash>]
- def panels
- [{
- type: DEFAULT_PANEL_TYPE,
- title: title,
- y_label: y_label,
- metrics: metrics.map(&:to_metric_hash)
- }]
- end
-
- # Metrics which match the provided inputs.
- # There may be multiple metrics, but they should be
- # displayed in a single panel/chart.
- # @return [ActiveRecord::AssociationRelation<PromtheusMetric>]
- def metrics
- strong_memoize(:metrics) do
- PrometheusMetricsFinder.new(
- project: project,
- group: group_key,
- title: title,
- y_label: y_label
- ).execute
- end
- end
-
- # Returns a symbol representing the group that
- # the dashboard's group title belongs to.
- # It will be one of the keys found under
- # Enums::PrometheusMetric.custom_groups.
- #
- # @return [String]
- def group_key
- strong_memoize(:group_key) do
- Enums::PrometheusMetric
- .group_details
- .find { |_, details| details[:group_title] == group }
- .first
- .to_s
- end
- end
- end
- end
-end
diff --git a/app/services/metrics/dashboard/default_embed_service.rb b/app/services/metrics/dashboard/default_embed_service.rb
deleted file mode 100644
index 30a8150d6be..00000000000
--- a/app/services/metrics/dashboard/default_embed_service.rb
+++ /dev/null
@@ -1,69 +0,0 @@
-# frozen_string_literal: true
-
-# Responsible for returning a filtered system dashboard
-# containing only the default embedded metrics. This class
-# operates by selecting metrics directly from the system
-# dashboard.
-#
-# Why isn't this filtering in a processing stage? By filtering
-# here, we ensure the dynamically-determined dashboard is cached.
-#
-# Use Gitlab::Metrics::Dashboard::Finder to retrive dashboards.
-module Metrics
- module Dashboard
- class DefaultEmbedService < ::Metrics::Dashboard::BaseEmbedService
- # For the default filtering for embedded metrics,
- # uses the 'id' key in dashboard-yml definition for
- # identification.
- DEFAULT_EMBEDDED_METRICS_IDENTIFIERS = %w(
- system_metrics_kubernetes_container_memory_total
- system_metrics_kubernetes_container_cores_total
- ).freeze
-
- class << self
- def valid_params?(params)
- embedded?(params[:embedded])
- end
- end
-
- # Returns a new dashboard with only the matching
- # metrics from the system dashboard, stripped of groups.
- # @return [Hash]
- def get_raw_dashboard
- panels = panel_groups.each_with_object([]) do |group, panels|
- matched_panels = group['panels'].select { |panel| matching_panel?(panel) }
-
- panels.concat(matched_panels)
- end
-
- { 'panel_groups' => [{ 'panels' => panels }] }
- end
-
- private
-
- # Returns an array of the panels groups on the
- # system dashboard
- def panel_groups
- ::Metrics::Dashboard::SystemDashboardService
- .new(project, nil)
- .raw_dashboard['panel_groups']
- end
-
- # Identifies a panel as "matching" if any metric ids in
- # the panel is in the list of identifiers to collect.
- def matching_panel?(panel)
- panel['metrics'].any? do |metric|
- metric_identifiers.include?(metric['id'])
- end
- end
-
- def metric_identifiers
- DEFAULT_EMBEDDED_METRICS_IDENTIFIERS
- end
-
- def identifiers
- metric_identifiers.join('|')
- end
- end
- end
-end
diff --git a/app/services/metrics/dashboard/dynamic_embed_service.rb b/app/services/metrics/dashboard/dynamic_embed_service.rb
deleted file mode 100644
index a94538668c1..00000000000
--- a/app/services/metrics/dashboard/dynamic_embed_service.rb
+++ /dev/null
@@ -1,78 +0,0 @@
-# frozen_string_literal: true
-
-# Responsible for returning a filtered project dashboard
-# containing only the request-provided metrics. The result
-# is then cached for future requests. Metrics are identified
-# based on a combination of identifiers for now, but the ideal
-# would be similar to the approach in DefaultEmbedService, but
-# a single unique identifier is not currently available across
-# all metric types (custom, project-defined, cluster, or system).
-#
-# Use Gitlab::Metrics::Dashboard::Finder to retrive dashboards.
-module Metrics
- module Dashboard
- class DynamicEmbedService < ::Metrics::Dashboard::BaseEmbedService
- include Gitlab::Utils::StrongMemoize
-
- class << self
- # Determines whether the provided params are sufficient
- # to uniquely identify a panel from a yml-defined dashboard.
- #
- # See https://docs.gitlab.com/ee/operations/metrics/dashboards/index.html
- # for additional info on defining custom dashboards.
- def valid_params?(params)
- [
- embedded?(params[:embedded]),
- params[:group].present?,
- params[:title].present?,
- params[:y_label]
- ].all?
- end
- end
-
- # Returns a new dashboard with only the matching
- # metrics from the system dashboard, stripped of groups.
- # @return [Hash]
- def get_raw_dashboard
- not_found! if panels.empty?
-
- { 'panel_groups' => [{ 'panels' => panels }] }
- end
-
- private
-
- def panels
- strong_memoize(:panels) do
- not_found! unless base_dashboard
- not_found! unless groups = base_dashboard['panel_groups']
- not_found! unless matching_group = find_group(groups)
- not_found! unless all_panels = matching_group['panels']
-
- find_panels(all_panels)
- end
- end
-
- def base_dashboard
- strong_memoize(:base_dashboard) do
- Gitlab::Metrics::Dashboard::Finder.find_raw(project, dashboard_path: dashboard_path)
- end
- end
-
- def find_group(groups)
- groups.find do |candidate_group|
- candidate_group['group'] == group
- end
- end
-
- def find_panels(all_panels)
- all_panels.select do |panel|
- panel['title'] == title && panel['y_label'] == y_label
- end
- end
-
- def not_found!
- panels_not_found!(identifiers)
- end
- end
- end
-end
diff --git a/app/services/metrics/dashboard/gitlab_alert_embed_service.rb b/app/services/metrics/dashboard/gitlab_alert_embed_service.rb
deleted file mode 100644
index 33c93b25c71..00000000000
--- a/app/services/metrics/dashboard/gitlab_alert_embed_service.rb
+++ /dev/null
@@ -1,77 +0,0 @@
-# frozen_string_literal: true
-
-# Responsible for returning an embed containing the specified
-# metrics chart for an alert. Creates panels based on the
-# matching metric stored in the database.
-#
-# Use Gitlab::Metrics::Dashboard::Finder to retrieve dashboards.
-module Metrics
- module Dashboard
- class GitlabAlertEmbedService < ::Metrics::Dashboard::BaseEmbedService
- include Gitlab::Metrics::Dashboard::Defaults
- include Gitlab::Utils::StrongMemoize
-
- SEQUENCE = [
- STAGES::MetricEndpointInserter,
- STAGES::PanelIdsInserter
- ].freeze
-
- class << self
- # Determines whether the provided params are sufficient
- # to uniquely identify a panel composed of user-defined
- # custom metrics from the DB.
- def valid_params?(params)
- [
- embedded?(params[:embedded]),
- params[:prometheus_alert_id].is_a?(Integer)
- ].all?
- end
- end
-
- def raw_dashboard
- panels_not_found!(alert_id: alert_id) unless alert && prometheus_metric
-
- { 'panel_groups' => [{ 'panels' => [panel] }] }
- end
-
- private
-
- def allowed?
- Ability.allowed?(current_user, :read_prometheus_alerts, project)
- end
-
- def alert_id
- params[:prometheus_alert_id]
- end
-
- def alert
- strong_memoize(:alert) do
- Projects::Prometheus::AlertsFinder.new(id: alert_id).execute.first
- end
- end
-
- def process_params
- params.merge(environment: alert.environment)
- end
-
- def prometheus_metric
- strong_memoize(:prometheus_metric) do
- PrometheusMetricsFinder.new(id: alert.prometheus_metric_id).execute.first
- end
- end
-
- def panel
- {
- title: prometheus_metric.title,
- y_label: prometheus_metric.y_label,
- metrics: [prometheus_metric.to_metric_hash],
- type: DEFAULT_PANEL_TYPE
- }
- end
-
- def sequence
- SEQUENCE
- end
- end
- end
-end
diff --git a/app/services/metrics/dashboard/grafana_metric_embed_service.rb b/app/services/metrics/dashboard/grafana_metric_embed_service.rb
deleted file mode 100644
index 26ccded45f8..00000000000
--- a/app/services/metrics/dashboard/grafana_metric_embed_service.rb
+++ /dev/null
@@ -1,179 +0,0 @@
-# frozen_string_literal: true
-
-# Responsible for returning a gitlab-compatible dashboard
-# containing info based on a grafana dashboard and datasource.
-#
-# Use Gitlab::Metrics::Dashboard::Finder to retrive dashboards.
-module Metrics
- module Dashboard
- class GrafanaMetricEmbedService < ::Metrics::Dashboard::BaseEmbedService
- include ReactiveCaching
-
- SEQUENCE = [
- ::Gitlab::Metrics::Dashboard::Stages::GrafanaFormatter,
- ::Gitlab::Metrics::Dashboard::Stages::PanelIdsInserter
- ].freeze
-
- self.reactive_cache_key = ->(service) { service.cache_key }
- self.reactive_cache_lease_timeout = 30.seconds
- self.reactive_cache_refresh_interval = 30.minutes
- self.reactive_cache_lifetime = 30.minutes
- self.reactive_cache_work_type = :external_dependency
- self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) }
-
- class << self
- # Determines whether the provided params are sufficient
- # to uniquely identify a grafana dashboard.
- def valid_params?(params)
- [
- embedded?(params[:embedded]),
- params[:grafana_url]
- ].all?
- end
-
- def from_cache(project_id, user_id, grafana_url)
- project = Project.find(project_id)
- user = User.find(user_id) if user_id.present?
-
- new(project, user, grafana_url: grafana_url)
- end
- end
-
- def get_dashboard
- with_reactive_cache(*cache_key) { |result| result }
- end
-
- # Inherits the primary logic from the parent class and
- # maintains the service's API while including ReactiveCache
- def calculate_reactive_cache(*)
- # This is called with explicit parentheses to prevent
- # the params passed to #calculate_reactive_cache from
- # being passed to #get_dashboard (which accepts none)
- ::Metrics::Dashboard::BaseService
- .instance_method(:get_dashboard)
- .bind_call(self)
- end
-
- def cache_key(*args)
- [project.id, current_user&.id, grafana_url]
- end
-
- # Required for ReactiveCaching; Usage overridden by
- # self.reactive_cache_worker_finder
- def id
- nil
- end
-
- private
-
- def get_raw_dashboard
- raise MissingIntegrationError unless client
-
- grafana_dashboard = fetch_dashboard
- datasource = fetch_datasource(grafana_dashboard)
-
- params.merge!(grafana_dashboard: grafana_dashboard, datasource: datasource)
-
- {}
- end
-
- def fetch_dashboard
- uid = GrafanaUidParser.new(grafana_url, project).parse
- raise DashboardProcessingError, _('Dashboard uid not found') unless uid
-
- response = client.get_dashboard(uid: uid)
-
- parse_json(response.body)
- end
-
- def fetch_datasource(dashboard)
- name = DatasourceNameParser.new(grafana_url, dashboard).parse
- raise DashboardProcessingError, _('Datasource name not found') unless name
-
- response = client.get_datasource(name: name)
-
- parse_json(response.body)
- end
-
- def grafana_url
- params[:grafana_url]
- end
-
- def client
- project.grafana_integration&.client
- end
-
- def allowed?
- Ability.allowed?(current_user, :read_project, project)
- end
-
- def sequence
- SEQUENCE
- end
-
- def parse_json(json)
- Gitlab::Json.parse(json, symbolize_names: true)
- rescue JSON::ParserError
- raise DashboardProcessingError, _('Grafana response contains invalid json')
- end
- end
-
- # Identifies the uid of the dashboard based on url format
- class GrafanaUidParser
- def initialize(grafana_url, project)
- @grafana_url = grafana_url
- @project = project
- end
-
- def parse
- @grafana_url.match(uid_regex) { |m| m.named_captures['uid'] }
- end
-
- private
-
- # URLs are expected to look like https://domain.com/d/:uid/other/stuff
- def uid_regex
- base_url = @project.grafana_integration.grafana_url.chomp('/')
-
- %r{^(#{Regexp.escape(base_url)}\/d\/(?<uid>.+)\/)}x
- end
- end
-
- # Identifies the name of the datasource for a dashboard
- # based on the panelId query parameter found in the url.
- #
- # If no panel is specified, defaults to the first valid panel.
- class DatasourceNameParser
- def initialize(grafana_url, grafana_dashboard)
- @grafana_url = grafana_url
- @grafana_dashboard = grafana_dashboard
- end
-
- def parse
- @grafana_dashboard[:dashboard][:panels]
- .find { |panel| panel_id ? matching_panel?(panel) : valid_panel?(panel) }
- .try(:[], :datasource)
- end
-
- private
-
- def panel_id
- query_params[:panelId]
- end
-
- def query_params
- Gitlab::Metrics::Dashboard::Url.parse_query(@grafana_url)
- end
-
- def matching_panel?(panel)
- panel[:id].to_s == panel_id
- end
-
- def valid_panel?(panel)
- ::Grafana::Validator
- .new(@grafana_dashboard, nil, panel, query_params)
- .valid?
- end
- end
- end
-end
diff --git a/app/services/metrics/dashboard/panel_preview_service.rb b/app/services/metrics/dashboard/panel_preview_service.rb
deleted file mode 100644
index 260b49a5b19..00000000000
--- a/app/services/metrics/dashboard/panel_preview_service.rb
+++ /dev/null
@@ -1,55 +0,0 @@
-# frozen_string_literal: true
-
-# Ingest YAML fragment with metrics dashboard panel definition
-# https://docs.gitlab.com/ee/operations/metrics/dashboards/yaml.html#panel-panels-properties
-# process it and returns renderable json version
-module Metrics
- module Dashboard
- class PanelPreviewService
- SEQUENCE = [
- ::Gitlab::Metrics::Dashboard::Stages::CommonMetricsInserter,
- ::Gitlab::Metrics::Dashboard::Stages::MetricEndpointInserter,
- ::Gitlab::Metrics::Dashboard::Stages::PanelIdsInserter,
- ::Gitlab::Metrics::Dashboard::Stages::UrlValidator
- ].freeze
-
- HANDLED_PROCESSING_ERRORS = [
- Gitlab::Metrics::Dashboard::Errors::DashboardProcessingError,
- Gitlab::Config::Loader::Yaml::NotHashError,
- Gitlab::Config::Loader::Yaml::DataTooLargeError,
- Gitlab::Config::Loader::FormatError
- ].freeze
-
- def initialize(project, panel_yaml, environment)
- @project = project
- @panel_yaml = panel_yaml
- @environment = environment
- end
-
- def execute
- dashboard = ::Gitlab::Metrics::Dashboard::Processor.new(project, dashboard_structure, SEQUENCE, environment: environment).process
- ServiceResponse.success(payload: dashboard[:panel_groups][0][:panels][0])
- rescue *HANDLED_PROCESSING_ERRORS => error
- ServiceResponse.error(message: error.message)
- end
-
- private
-
- attr_accessor :project, :panel_yaml, :environment
-
- def dashboard_structure
- {
- panel_groups: [
- {
- panels: [panel_hash]
- }
- ]
- }
- end
-
- def panel_hash
- ::Gitlab::Config::Loader::Yaml.new(panel_yaml).load_raw!
- end
- end
- end
-end
diff --git a/app/services/metrics/dashboard/pod_dashboard_service.rb b/app/services/metrics/dashboard/pod_dashboard_service.rb
deleted file mode 100644
index c83f8618460..00000000000
--- a/app/services/metrics/dashboard/pod_dashboard_service.rb
+++ /dev/null
@@ -1,37 +0,0 @@
-# frozen_string_literal: true
-
-module Metrics
- module Dashboard
- class PodDashboardService < ::Metrics::Dashboard::PredefinedDashboardService
- DASHBOARD_PATH = 'config/prometheus/pod_metrics.yml'
- DASHBOARD_NAME = N_('K8s pod health')
-
- # SHA256 hash of dashboard content
- DASHBOARD_VERSION = '3a91b32f91b2dd3d90275333c0ea3630b3f3f37c4296ede5b5eef59bf523d66b'
-
- SEQUENCE = [
- STAGES::MetricEndpointInserter,
- STAGES::VariableEndpointInserter,
- STAGES::PanelIdsInserter
- ].freeze
-
- class << self
- def all_dashboard_paths(_project)
- [{
- path: DASHBOARD_PATH,
- display_name: _(DASHBOARD_NAME),
- default: false,
- system_dashboard: false,
- out_of_the_box_dashboard: out_of_the_box_dashboard?
- }]
- end
- end
-
- private
-
- def dashboard_version
- DASHBOARD_VERSION
- end
- end
- end
-end
diff --git a/app/services/metrics/dashboard/predefined_dashboard_service.rb b/app/services/metrics/dashboard/predefined_dashboard_service.rb
deleted file mode 100644
index abdef66c2e0..00000000000
--- a/app/services/metrics/dashboard/predefined_dashboard_service.rb
+++ /dev/null
@@ -1,63 +0,0 @@
-# frozen_string_literal: true
-
-module Metrics
- module Dashboard
- class PredefinedDashboardService < ::Metrics::Dashboard::BaseService
- # These constants should be overridden in the inheriting class. For Ex:
- # DASHBOARD_PATH = 'config/prometheus/common_metrics.yml'
- # DASHBOARD_NAME = 'Default'
- DASHBOARD_PATH = nil
- DASHBOARD_NAME = nil
-
- SEQUENCE = [
- STAGES::MetricEndpointInserter,
- STAGES::VariableEndpointInserter,
- STAGES::PanelIdsInserter
- ].freeze
-
- class << self
- def valid_params?(params)
- matching_dashboard?(params[:dashboard_path])
- end
-
- def matching_dashboard?(filepath)
- filepath == self::DASHBOARD_PATH
- end
-
- def out_of_the_box_dashboard?
- true
- end
- end
-
- # Returns an un-processed dashboard from the cache.
- def raw_dashboard
- Gitlab::Metrics::Dashboard::Cache.fetch(cache_key) { get_raw_dashboard }
- end
-
- private
-
- def dashboard_version
- raise NotImplementedError
- end
-
- def cache_key
- "metrics_dashboard_#{dashboard_path}_#{dashboard_version}"
- end
-
- def dashboard_path
- self.class::DASHBOARD_PATH
- end
-
- # Returns the base metrics shipped with every GitLab service.
- def get_raw_dashboard
- yml = File.read(Rails.root.join(dashboard_path))
-
- load_yaml(yml)
- end
-
- def sequence
- self.class::SEQUENCE
- end
- end
- end
-end
diff --git a/app/services/metrics/dashboard/system_dashboard_service.rb b/app/services/metrics/dashboard/system_dashboard_service.rb
deleted file mode 100644
index 1bd31b2ba21..00000000000
--- a/app/services/metrics/dashboard/system_dashboard_service.rb
+++ /dev/null
@@ -1,42 +0,0 @@
-# frozen_string_literal: true
-
-# Fetches the system metrics dashboard and formats the output.
-# Use Gitlab::Metrics::Dashboard::Finder to retrieve dashboards.
-module Metrics
- module Dashboard
- class SystemDashboardService < ::Metrics::Dashboard::PredefinedDashboardService
- DASHBOARD_PATH = 'config/prometheus/common_metrics.yml'
- DASHBOARD_NAME = N_('Overview')
-
- # SHA256 hash of dashboard content
- DASHBOARD_VERSION = 'ce9ae27d2913f637de851d61099bc4151583eae68b1386a2176339ef6e653223'
-
- SEQUENCE = [
- STAGES::CommonMetricsInserter,
- STAGES::CustomMetricsInserter,
- STAGES::CustomMetricsDetailsInserter,
- STAGES::MetricEndpointInserter,
- STAGES::VariableEndpointInserter,
- STAGES::PanelIdsInserter
- ].freeze
-
- class << self
- def all_dashboard_paths(_project)
- [{
- path: DASHBOARD_PATH,
- display_name: _(DASHBOARD_NAME),
- default: true,
- system_dashboard: true,
- out_of_the_box_dashboard: out_of_the_box_dashboard?
- }]
- end
- end
-
- private
-
- def dashboard_version
- DASHBOARD_VERSION
- end
- end
- end
-end
diff --git a/app/services/metrics/dashboard/transient_embed_service.rb b/app/services/metrics/dashboard/transient_embed_service.rb
deleted file mode 100644
index 29ea9909a36..00000000000
--- a/app/services/metrics/dashboard/transient_embed_service.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-
-# Acts as a pass-through to allow embeddable dashboards to be
-# generated based on external data, but still processed with the
-# required attributes that allow the FE to render them appropriately.
-#
-# Use Gitlab::Metrics::Dashboard::Finder to retrive dashboards.
-module Metrics
- module Dashboard
- class TransientEmbedService < ::Metrics::Dashboard::BaseEmbedService
- extend ::Gitlab::Utils::Override
-
- class << self
- def valid_params?(params)
- [
- embedded?(params[:embedded]),
- params[:embed_json]
- ].all?
- end
- end
-
- private
-
- override :get_raw_dashboard
- def get_raw_dashboard
- Gitlab::Json.parse(params[:embed_json])
- rescue JSON::ParserError => e
- invalid_embed_json!(e.message)
- end
-
- override :sequence
- def sequence
- [STAGES::MetricEndpointInserter]
- end
-
- override :identifiers
- def identifiers
- Digest::SHA256.hexdigest(params[:embed_json])
- end
-
- def invalid_embed_json!(message)
- raise DashboardProcessingError, _("Parsing error for param :embed_json. %{message}") % { message: message }
- end
- end
- end
-end
diff --git a/app/services/metrics/dashboard/update_dashboard_service.rb b/app/services/metrics/dashboard/update_dashboard_service.rb
deleted file mode 100644
index 0574cb15e96..00000000000
--- a/app/services/metrics/dashboard/update_dashboard_service.rb
+++ /dev/null
@@ -1,127 +0,0 @@
-# frozen_string_literal: true
-
-# Updates the content of a specified dashboard in .yml file inside `.gitlab/dashboards`
-module Metrics
- module Dashboard
- class UpdateDashboardService < ::BaseService
- include Stepable
-
- ALLOWED_FILE_TYPE = '.yml'
- USER_DASHBOARDS_DIR = ::Gitlab::Metrics::Dashboard::RepoDashboardFinder::DASHBOARD_ROOT
-
- steps :check_push_authorized,
- :check_branch_name,
- :check_file_type,
- :update_file,
- :create_merge_request
-
- def execute
- execute_steps
- end
-
- private
-
- def check_push_authorized(result)
- return error(_('You are not allowed to push into this branch. Create another branch or open a merge request.'), :forbidden) unless push_authorized?
-
- success(result)
- end
-
- def check_branch_name(result)
- return error(_('There was an error updating the dashboard, branch name is invalid.'), :bad_request) unless valid_branch_name?
- return error(_('There was an error updating the dashboard, branch named: %{branch} already exists.') % { branch: params[:branch] }, :bad_request) unless new_or_default_branch?
-
- success(result)
- end
-
- def check_file_type(result)
- return error(_('The file name should have a .yml extension'), :bad_request) unless target_file_type_valid?
-
- success(result)
- end
-
- def update_file(result)
- file_update_response = ::Files::UpdateService.new(project, current_user, dashboard_attrs).execute
-
- if file_update_response[:status] == :success
- success(result.merge(file_update_response, http_status: :created, dashboard: dashboard_details))
- else
- error(file_update_response[:message], :bad_request)
- end
- end
-
- def create_merge_request(result)
- return success(result) if project.default_branch == branch
-
- merge_request_params = {
- source_branch: branch,
- target_branch: project.default_branch,
- title: params[:commit_message]
- }
- merge_request = ::MergeRequests::CreateService.new(project: project, current_user: current_user, params: merge_request_params).execute
-
- if merge_request.persisted?
- success(result.merge(merge_request: Gitlab::UrlBuilder.build(merge_request)))
- else
- error(merge_request.errors.full_messages.join(','), :bad_request)
- end
- end
-
- def push_authorized?
- Gitlab::UserAccess.new(current_user, container: project).can_push_to_branch?(branch)
- end
-
- def valid_branch_name?
- Gitlab::GitRefValidator.validate(branch)
- end
-
- def new_or_default_branch?
- !repository.branch_exists?(branch) || project.default_branch == branch
- end
-
- def target_file_type_valid?
- File.extname(params[:file_name]) == ALLOWED_FILE_TYPE
- end
-
- def dashboard_attrs
- {
- commit_message: params[:commit_message],
- file_path: update_dashboard_path,
- file_content: update_dashboard_content,
- encoding: 'text',
- branch_name: branch,
- start_branch: repository.branch_exists?(branch) ? branch : project.default_branch
- }
- end
-
- def update_dashboard_path
- File.join(USER_DASHBOARDS_DIR, file_name)
- end
-
- def file_name
- @file_name ||= File.basename(CGI.unescape(params[:file_name]))
- end
-
- def branch
- @branch ||= params[:branch]
- end
-
- def update_dashboard_content
- ::PerformanceMonitoring::PrometheusDashboard.from_json(params[:file_content]).to_yaml
- end
-
- def repository
- @repository ||= project.repository
- end
-
- def dashboard_details
- {
- path: update_dashboard_path,
- display_name: ::Metrics::Dashboard::CustomDashboardService.name_for_path(update_dashboard_path),
- default: false,
- system_dashboard: false
- }
- end
- end
- end
-end
diff --git a/app/services/metrics/users_starred_dashboards/create_service.rb b/app/services/metrics/users_starred_dashboards/create_service.rb
deleted file mode 100644
index 0d028f120d3..00000000000
--- a/app/services/metrics/users_starred_dashboards/create_service.rb
+++ /dev/null
@@ -1,76 +0,0 @@
-# frozen_string_literal: true
-
-# Create Metrics::UsersStarredDashboard entry for given user based on matched dashboard_path, project
-module Metrics
- module UsersStarredDashboards
- class CreateService < ::BaseService
- include Stepable
-
- steps :authorize_create_action,
- :parse_dashboard_path,
- :create
-
- def initialize(user, project, dashboard_path)
- @user = user
- @project = project
- @dashboard_path = dashboard_path
- end
-
- def execute
- keys = %i[status message starred_dashboard]
- status, message, dashboards = execute_steps.values_at(*keys)
-
- if status != :success
- ServiceResponse.error(message: message)
- else
- ServiceResponse.success(payload: dashboards)
- end
- end
-
- private
-
- attr_reader :user, :project, :dashboard_path
-
- def authorize_create_action(_options)
- if Ability.allowed?(user, :create_metrics_user_starred_dashboard, project)
- success(user: user, project: project)
- else
- error(s_('MetricsUsersStarredDashboards|You are not authorized to add star to this dashboard'))
- end
- end
-
- def parse_dashboard_path(options)
- if dashboard_path_exists?
- options[:dashboard_path] = dashboard_path
- success(options)
- else
- error(s_('MetricsUsersStarredDashboards|Dashboard with requested path can not be found'))
- end
- end
-
- def create(options)
- starred_dashboard = build_starred_dashboard_from(options)
-
- if starred_dashboard.save
- success(starred_dashboard: starred_dashboard)
- else
- error(starred_dashboard.errors.messages)
- end
- end
-
- def build_starred_dashboard_from(options)
- Metrics::UsersStarredDashboard.new(
- user: options.fetch(:user),
- project: options.fetch(:project),
- dashboard_path: options.fetch(:dashboard_path)
- )
- end
-
- def dashboard_path_exists?
- Gitlab::Metrics::Dashboard::Finder
- .find_all_paths(project)
- .any? { |dashboard| dashboard[:path] == dashboard_path }
- end
- end
- end
-end
diff --git a/app/services/metrics/users_starred_dashboards/delete_service.rb b/app/services/metrics/users_starred_dashboards/delete_service.rb
deleted file mode 100644
index 229c0e8cfc0..00000000000
--- a/app/services/metrics/users_starred_dashboards/delete_service.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-# Delete all matching Metrics::UsersStarredDashboard entries for given user based on matched dashboard_path, project
-module Metrics
- module UsersStarredDashboards
- class DeleteService < ::BaseService
- def initialize(user, project, dashboard_path = nil)
- @user = user
- @project = project
- @dashboard_path = dashboard_path
- end
-
- def execute
- ServiceResponse.success(payload: { deleted_rows: starred_dashboards.delete_all })
- end
-
- private
-
- attr_reader :user, :project, :dashboard_path
-
- def starred_dashboards
- # since deleted records are scoped to their owner there is no need to
- # check if that user can delete them, also if user lost access to
- # project it shouldn't block that user from removing them
- dashboards = user.metrics_users_starred_dashboards
-
- if dashboard_path.present?
- dashboards.for_project_dashboard(project, dashboard_path)
- else
- dashboards.for_project(project)
- end
- end
- end
- end
-end
diff --git a/app/services/ml/experiment_tracking/candidate_repository.rb b/app/services/ml/experiment_tracking/candidate_repository.rb
index 2399da3e182..436f06e3ca5 100644
--- a/app/services/ml/experiment_tracking/candidate_repository.rb
+++ b/app/services/ml/experiment_tracking/candidate_repository.rb
@@ -5,7 +5,7 @@ module Ml
class CandidateRepository
attr_accessor :project, :user, :experiment, :candidate
- def initialize(project, user)
+ def initialize(project, user = nil)
@project = project
@user = user
end
@@ -103,10 +103,16 @@ module Ml
end
def candidate_name(name, tags)
- return name if name.present?
- return unless tags.present?
+ name.presence || candidate_name_from_tags(tags) || random_candidate_name
+ end
+
+ def candidate_name_from_tags(tags)
+ tags&.detect { |t| t[:key] == 'mlflow.runName' }&.dig(:value)
+ end
- tags.detect { |t| t[:key] == 'mlflow.runName' }&.dig(:value)
+ def random_candidate_name
+ parts = Array.new(3).map { FFaker::Animal.common_name.downcase.delete(' ') } << rand(10000)
+ parts.join('-').truncate(255)
end
end
end
diff --git a/app/services/ml/find_or_create_experiment_service.rb b/app/services/ml/find_or_create_experiment_service.rb
new file mode 100644
index 00000000000..1fe10c7f856
--- /dev/null
+++ b/app/services/ml/find_or_create_experiment_service.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Ml
+ class FindOrCreateExperimentService
+ def initialize(project, experiment_name, user = nil)
+ @project = project
+ @name = experiment_name
+ @user = user
+ end
+
+ def execute
+ Ml::Experiment.find_or_create(project, name, user)
+ end
+
+ private
+
+ attr_reader :project, :name, :user
+ end
+end
diff --git a/app/services/ml/find_or_create_model_service.rb b/app/services/ml/find_or_create_model_service.rb
new file mode 100644
index 00000000000..66dec7a6234
--- /dev/null
+++ b/app/services/ml/find_or_create_model_service.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Ml
+ class FindOrCreateModelService
+ def initialize(project, model_name)
+ @project = project
+ @name = model_name
+ end
+
+ def execute
+ Ml::Model.find_or_create(
+ project,
+ name,
+ Ml::FindOrCreateExperimentService.new(project, name).execute
+ )
+ end
+
+ private
+
+ attr_reader :name, :project
+ end
+end
diff --git a/app/services/ml/find_or_create_model_version_service.rb b/app/services/ml/find_or_create_model_version_service.rb
new file mode 100644
index 00000000000..1316b2546b9
--- /dev/null
+++ b/app/services/ml/find_or_create_model_version_service.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Ml
+ class FindOrCreateModelVersionService
+ def initialize(project, params = {})
+ @project = project
+ @name = params[:model_name]
+ @version = params[:version]
+ @package = params[:package]
+ end
+
+ def execute
+ model = Ml::FindOrCreateModelService.new(project, name).execute
+
+ Ml::ModelVersion.find_or_create!(model, version, package)
+ end
+
+ private
+
+ attr_reader :version, :name, :project, :package
+ end
+end
diff --git a/app/services/namespace_settings/update_service.rb b/app/services/namespace_settings/update_service.rb
index c391320db5e..92766bc0267 100644
--- a/app/services/namespace_settings/update_service.rb
+++ b/app/services/namespace_settings/update_service.rb
@@ -27,6 +27,10 @@ module NamespaceSettings
param_key: :default_branch_protection,
user_policy: :update_default_branch_protection
)
+ validate_settings_param_for_root_group(
+ param_key: :default_branch_protection_defaults,
+ user_policy: :update_default_branch_protection
+ )
handle_default_branch_protection unless settings_params[:default_branch_protection].blank?
diff --git a/app/services/namespaces/package_settings/update_service.rb b/app/services/namespaces/package_settings/update_service.rb
index 0c6fcee9113..cd5745cfec6 100644
--- a/app/services/namespaces/package_settings/update_service.rb
+++ b/app/services/namespaces/package_settings/update_service.rb
@@ -10,6 +10,8 @@ module Namespaces
generic_duplicates_allowed
generic_duplicate_exception_regex
maven_package_requests_forwarding
+ nuget_duplicates_allowed
+ nuget_duplicate_exception_regex
npm_package_requests_forwarding
pypi_package_requests_forwarding
lock_maven_package_requests_forwarding
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index b93b44ce797..648067e3452 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -345,8 +345,12 @@ class NotificationService
def review_requested_of_merge_request(merge_request, current_user, reviewer)
recipients = NotificationRecipients::BuildService.build_requested_review_recipients(merge_request, current_user, reviewer)
+ deliver_option = review_request_deliver_options(merge_request.project, reviewer)
+
recipients.each do |recipient|
- mailer.request_review_merge_request_email(recipient.user.id, merge_request.id, current_user.id, recipient.reason).deliver_later
+ mailer
+ .request_review_merge_request_email(recipient.user.id, merge_request.id, current_user.id, recipient.reason)
+ .deliver_later(deliver_option)
end
end
@@ -529,6 +533,12 @@ class NotificationService
mailer.member_access_granted_email(project_member.real_source_type, project_member.id).deliver_later
end
+ def member_about_to_expire(member)
+ return true unless member.notifiable?(:mention)
+
+ mailer.member_about_to_expire_email(member.real_source_type, member.id).deliver_later
+ end
+
# Group invite
def invite_group_member(group_member, token)
mailer.member_invited_email(group_member.real_source_type, group_member.id, token).deliver_later
@@ -723,9 +733,12 @@ class NotificationService
# Notify users on new review in system
def new_review(review)
recipients = NotificationRecipients::BuildService.build_new_review_recipients(review)
+ deliver_options = new_review_deliver_options(review)
recipients.each do |recipient|
- mailer.new_review_email(recipient.user.id, review.id).deliver_later
+ mailer
+ .new_review_email(recipient.user.id, review.id)
+ .deliver_later(deliver_options)
end
end
@@ -946,6 +959,16 @@ class NotificationService
def warn_skipping_notifications(user, object)
Gitlab::AppLogger.warn(message: "Skipping sending notifications", user: user.id, klass: object.class.to_s, object_id: object.id)
end
+
+ def new_review_deliver_options(review)
+ # Overridden in EE
+ {}
+ end
+
+ def review_request_deliver_options(project, user)
+ # Overridden in EE
+ {}
+ end
end
NotificationService.prepend_mod_with('NotificationService')
diff --git a/app/services/packages/ml_model/create_package_file_service.rb b/app/services/packages/ml_model/create_package_file_service.rb
index 574f70940fc..b1e8e814015 100644
--- a/app/services/packages/ml_model/create_package_file_service.rb
+++ b/app/services/packages/ml_model/create_package_file_service.rb
@@ -5,7 +5,10 @@ module Packages
class CreatePackageFileService < BaseService
def execute
::Packages::Package.transaction do
- create_package_file(find_or_create_package)
+ package = find_or_create_package
+ find_or_create_model_version(package)
+
+ create_package_file(package)
end
end
@@ -30,6 +33,16 @@ module Packages
package
end
+ def find_or_create_model_version(package)
+ model_version_params = {
+ model_name: package.name,
+ version: package.version,
+ package: package
+ }
+
+ Ml::FindOrCreateModelVersionService.new(project, model_version_params).execute
+ end
+
def create_package_file(package)
file_params = {
file: params[:file],
diff --git a/app/services/packages/nuget/update_package_from_metadata_service.rb b/app/services/packages/nuget/update_package_from_metadata_service.rb
index d82509fff5e..73a52ea569f 100644
--- a/app/services/packages/nuget/update_package_from_metadata_service.rb
+++ b/app/services/packages/nuget/update_package_from_metadata_service.rb
@@ -14,6 +14,7 @@ module Packages
MISSING_MATCHING_PACKAGE_ERROR_MESSAGE = 'symbol package is invalid, matching package does not exist'
InvalidMetadataError = Class.new(StandardError)
+ ZipError = Class.new(StandardError)
def initialize(package_file)
@package_file = package_file
@@ -32,6 +33,8 @@ module Packages
end
rescue ActiveRecord::RecordInvalid => e
raise InvalidMetadataError, e.message
+ rescue Zip::Error
+ raise ZipError, 'Could not open the .nupkg file'
end
private
diff --git a/app/services/packages/rubygems/process_gem_service.rb b/app/services/packages/rubygems/process_gem_service.rb
index ca4aaa8fdde..07545495f1b 100644
--- a/app/services/packages/rubygems/process_gem_service.rb
+++ b/app/services/packages/rubygems/process_gem_service.rb
@@ -9,6 +9,8 @@ module Packages
include ExclusiveLeaseGuard
ExtractionError = Class.new(StandardError)
+ InvalidMetadataError = Class.new(StandardError)
+
DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze
def initialize(package_file)
@@ -20,6 +22,9 @@ module Packages
return success if process_gem
error('Gem was not processed')
+ rescue ActiveRecord::StatementInvalid
+ # TODO: We can remove this rescue block when we fix https://gitlab.com/gitlab-org/gitlab/-/issues/415899
+ raise InvalidMetadataError, 'Invalid metadata'
end
private
diff --git a/app/services/personal_access_tokens/revoke_token_family_service.rb b/app/services/personal_access_tokens/revoke_token_family_service.rb
index 547ba6c3bdc..e96561cbc1c 100644
--- a/app/services/personal_access_tokens/revoke_token_family_service.rb
+++ b/app/services/personal_access_tokens/revoke_token_family_service.rb
@@ -7,10 +7,18 @@ module PersonalAccessTokens
end
def execute
- # Despite using #update_all, there should only be a single active token.
- # A token family is a chain of rotated tokens. Once rotated, the
- # previous token is revoked.
- pat_family.active.update_all(revoked: true)
+ # A token family is a chain of rotated tokens. Once rotated, the previous
+ # token is revoked. As a result, a single token id should be returned by
+ # this query.
+ # rubocop: disable CodeReuse/ActiveRecord
+ token_ids = pat_family.active.pluck_primary_key
+
+ # We create another query based on the previous if any id exists. An
+ # alternative is to use a single query, like
+ # `pat_family.active.update_all(...)`). However, #update_all ignores
+ # the CTE, and tries to revoke *all* active tokens.
+ PersonalAccessToken.where(id: token_ids).update_all(revoked: true) if token_ids.any?
+ # rubocop: enable CodeReuse/ActiveRecord
ServiceResponse.success
end
@@ -25,8 +33,16 @@ module PersonalAccessTokens
personal_access_token_table = Arel::Table.new(:personal_access_tokens)
cte << PersonalAccessToken
+ .select(
+ 'personal_access_tokens.id',
+ 'personal_access_tokens.revoked',
+ 'personal_access_tokens.expires_at')
.where(personal_access_token_table[:previous_personal_access_token_id].eq(token.id))
cte << PersonalAccessToken
+ .select(
+ 'personal_access_tokens.id',
+ 'personal_access_tokens.revoked',
+ 'personal_access_tokens.expires_at')
.from([personal_access_token_table, cte.table])
.where(personal_access_token_table[:previous_personal_access_token_id].eq(cte.table[:id]))
PersonalAccessToken.with.recursive(cte.to_arel).from(cte.alias_to(personal_access_token_table))
diff --git a/app/services/post_receive_service.rb b/app/services/post_receive_service.rb
index 5ab5732ecf5..7cf1855988e 100644
--- a/app/services/post_receive_service.rb
+++ b/app/services/post_receive_service.rb
@@ -87,14 +87,14 @@ class PostReceiveService
if project
scoped_messages =
- BroadcastMessage.current_banner_messages(current_path: project.full_path).select do |message|
+ System::BroadcastMessage.current_banner_messages(current_path: project.full_path).select do |message|
message.target_path.present? && message.matches_current_path(project.full_path) && message.show_in_cli?
end
banner = scoped_messages.last
end
- banner ||= BroadcastMessage.current_show_in_cli_banner_messages.last
+ banner ||= System::BroadcastMessage.current_show_in_cli_banner_messages.last
banner&.message
end
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index e37b6516d21..bca78b88630 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -223,10 +223,14 @@ module Projects
end
def save_project_and_import_data
- Project.transaction do
+ ApplicationRecord.transaction do
@project.create_or_update_import_data(data: @import_data[:data], credentials: @import_data[:credentials]) if @import_data
- if @project.save
+ # Avoid project callbacks being triggered multiple times by saving the parent first.
+ # See https://github.com/rails/rails/issues/41701.
+ Namespaces::ProjectNamespace.create_from_project!(@project) if @project.valid?
+
+ if @project.saved?
Integration.create_from_active_default_integrations(@project, :project_id)
@project.create_labels unless @project.gitlab_project_import?
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index a5c12384b59..0ae6fcb4d97 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -114,7 +114,11 @@ module Projects
# It's possible that the project was destroyed, but some after_commit
# hook failed and caused us to end up here. A destroyed model will be a frozen hash,
# which cannot be altered.
- project.update(delete_error: message, pending_delete: false) unless project.destroyed?
+ unless project.destroyed?
+ # Restrict project visibility if the parent group visibility was made more restrictive while the project was scheduled for deletion.
+ visibility_level = project.visibility_level_allowed_by_group? ? project.visibility_level : project.group.visibility_level
+ project.update(delete_error: message, pending_delete: false, visibility_level: visibility_level)
+ end
log_error("Deletion failed on #{project.full_path} with the following message: #{message}")
end
diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb
index 44cd6e9926f..458eaec4e2e 100644
--- a/app/services/projects/participants_service.rb
+++ b/app/services/projects/participants_service.rb
@@ -18,15 +18,7 @@ module Projects
end
def project_members
- @project_members ||= sorted(get_project_members)
- end
-
- def get_project_members
- members = Member.from_union([project_members_through_ancestral_groups,
- project_members_through_invited_groups,
- individual_project_members])
-
- User.id_in(members.select(:user_id))
+ @project_members ||= sorted(project.authorized_users)
end
def all_members
@@ -34,33 +26,5 @@ module Projects
[{ username: "all", name: "All Project and Group Members", count: project_members.count }]
end
-
- private
-
- def project_members_through_invited_groups
- GroupMember
- .active_without_invites_and_requests
- .with_source_id(visible_groups.self_and_ancestors.pluck_primary_key)
- .select(*GroupMember.cached_column_list)
- end
-
- def visible_groups
- visible_groups = project.invited_groups
-
- unless project.team.member?(current_user)
- visible_groups = visible_groups.public_or_visible_to_user(current_user)
- end
-
- visible_groups
- end
-
- def project_members_through_ancestral_groups
- members = project.group.present? ? project.group.members_with_parents : Member.none
- members.select(*GroupMember.cached_column_list)
- end
-
- def individual_project_members
- project.project_members.select(*GroupMember.cached_column_list)
- end
end
end
diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb
index 22a882c4648..8f1f78beb5b 100644
--- a/app/services/projects/prometheus/alerts/notify_service.rb
+++ b/app/services/projects/prometheus/alerts/notify_service.rb
@@ -80,8 +80,7 @@ module Projects
def valid_alert_manager_token?(token, integration)
valid_for_alerts_endpoint?(token, integration) ||
- valid_for_manual?(token) ||
- valid_for_cluster?(token)
+ valid_for_manual?(token)
end
def valid_for_manual?(token)
@@ -109,44 +108,6 @@ module Projects
compare_token(token, integration.token)
end
- def valid_for_cluster?(token)
- cluster_integration = find_cluster_integration(project)
- return false unless cluster_integration
-
- cluster_integration_token = cluster_integration.alert_manager_token
-
- if token
- compare_token(token, cluster_integration_token)
- else
- cluster_integration_token.nil?
- end
- end
-
- def find_cluster_integration(project)
- alert_id = gitlab_alert_id
- return unless alert_id
-
- alert = find_alert(project, alert_id)
- return unless alert
-
- cluster = alert.environment.deployment_platform&.cluster
- return unless cluster&.enabled?
- return unless cluster.integration_prometheus_available?
-
- cluster.integration_prometheus
- end
-
- def find_alert(project, metric)
- Projects::Prometheus::AlertsFinder
- .new(project: project, metric: metric)
- .execute
- .first
- end
-
- def gitlab_alert_id
- alerts&.first&.dig('labels', 'gitlab_alert_id')
- end
-
def compare_token(expected, actual)
return unless expected && actual
diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb
index 4a9d96d266c..642ec37619f 100644
--- a/app/services/projects/transfer_service.rb
+++ b/app/services/projects/transfer_service.rb
@@ -13,6 +13,14 @@ module Projects
include Gitlab::ShellAdapter
TransferError = Class.new(StandardError)
+ def log_project_transfer_success(project, new_namespace)
+ log_transfer(project, new_namespace, nil)
+ end
+
+ def log_project_transfer_error(project, new_namespace, error_message)
+ log_transfer(project, new_namespace, error_message)
+ end
+
def execute(new_namespace)
@new_namespace = new_namespace
@@ -36,10 +44,15 @@ module Projects
transfer(project)
+ log_project_transfer_success(project, @new_namespace)
+
true
rescue Projects::TransferService::TransferError => ex
project.reset
project.errors.add(:new_namespace, ex.message)
+
+ log_project_transfer_error(project, @new_namespace, ex.message)
+
false
end
@@ -47,6 +60,27 @@ module Projects
attr_reader :old_path, :new_path, :new_namespace, :old_namespace
+ def log_transfer(project, new_namespace, error_message = nil)
+ action = error_message.nil? ? "was" : "was not"
+
+ log_payload = {
+ message: "Project #{action} transferred to a new namespace",
+ project_id: project.id,
+ project_path: project.full_path,
+ project_namespace: project.namespace.full_path,
+ namespace_id: project.namespace_id,
+ new_namespace_id: new_namespace&.id,
+ new_project_namespace: new_namespace&.full_path,
+ error_message: error_message
+ }
+
+ if error_message.nil?
+ ::Gitlab::AppLogger.info(log_payload)
+ else
+ ::Gitlab::AppLogger.error(log_payload)
+ end
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def transfer(project)
@old_path = project.full_path
@@ -110,7 +144,7 @@ module Projects
update_pending_builds
- post_update_hooks(project)
+ post_update_hooks(project, @old_group)
rescue Exception # rubocop:disable Lint/RescueException
rollback_side_effects
raise
@@ -119,7 +153,7 @@ module Projects
end
# Overridden in EE
- def post_update_hooks(project)
+ def post_update_hooks(project, _old_group)
ensure_personal_project_owner_membership(project)
invalidate_personal_projects_counts
diff --git a/app/services/projects/update_repository_storage_service.rb b/app/services/projects/update_repository_storage_service.rb
index cadf3012131..f5f6bb85995 100644
--- a/app/services/projects/update_repository_storage_service.rb
+++ b/app/services/projects/update_repository_storage_service.rb
@@ -9,12 +9,20 @@ module Projects
private
def track_repository(_destination_storage_name)
- project.leave_pool_repository
+ # Connect project to pool repository from the new shard
+ project.swap_pool_repository!
+
+ # Connect project to the repository from the new shard
project.track_project_repository
+
+ # Link repository from the new shard to pool repository from the new shard
+ project.link_pool_repository if replicate_object_pool_on_move_ff_enabled?
end
def mirror_repositories
- mirror_repository(type: Gitlab::GlRepository::PROJECT) if project.repository_exists?
+ if project.repository_exists?
+ mirror_repository(type: Gitlab::GlRepository::PROJECT)
+ end
if project.wiki.repository_exists?
mirror_repository(type: Gitlab::GlRepository::WIKI)
@@ -25,6 +33,30 @@ module Projects
end
end
+ def mirror_object_pool(destination_storage_name)
+ return unless replicate_object_pool_on_move_ff_enabled?
+ return unless project.repository_exists?
+
+ pool_repository = project.pool_repository
+ return unless pool_repository
+
+ # If pool repository already exists, then we will link the moved project repository to it
+ return if pool_repository_exists_for?(shard_name: destination_storage_name, pool_repository: pool_repository)
+
+ target_pool_repository = create_pool_repository_for!(
+ shard_name: destination_storage_name,
+ pool_repository: pool_repository
+ )
+
+ checksum, new_checksum = replicate_object_pool_repository(from: pool_repository, to: target_pool_repository)
+
+ if checksum != new_checksum
+ raise Error,
+ format(s_('UpdateRepositoryStorage|Failed to verify %{type} repository checksum from %{old} to %{new}'),
+ type: 'object_pool', old: checksum, new: new_checksum)
+ end
+ end
+
def remove_old_paths
super
@@ -46,5 +78,39 @@ module Projects
).remove
end
end
+
+ def pool_repository_exists_for?(shard_name:, pool_repository:)
+ PoolRepository.by_source_project_and_shard_name(
+ pool_repository.source_project,
+ shard_name
+ ).exists?
+ end
+
+ def create_pool_repository_for!(shard_name:, pool_repository:)
+ # Set state `ready` because we manually replicate object pool
+ PoolRepository.create!(
+ shard: Shard.by_name(shard_name),
+ source_project: pool_repository.source_project,
+ disk_path: pool_repository.disk_path,
+ state: 'ready'
+ )
+ end
+
+ def replicate_object_pool_repository(from:, to:)
+ old_object_pool = from.object_pool
+ new_object_pool = to.object_pool
+
+ checksum = old_object_pool.repository.checksum
+
+ new_object_pool.repository.replicate(old_object_pool.repository)
+
+ new_checksum = new_object_pool.repository.checksum
+
+ [checksum, new_checksum]
+ end
+
+ def replicate_object_pool_on_move_ff_enabled?
+ Feature.enabled?(:replicate_object_pool_on_move, project)
+ end
end
end
diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb
index 7f25ab5883f..8639e2f833f 100644
--- a/app/services/projects/update_service.rb
+++ b/app/services/projects/update_service.rb
@@ -51,12 +51,6 @@ module Projects
private
def add_pages_unique_domain
- if Feature.disabled?(:pages_unique_domain, project)
- params[:project_setting_attributes]&.delete(:pages_unique_domain_enabled)
-
- return
- end
-
return unless params.dig(:project_setting_attributes, :pages_unique_domain_enabled)
# If the project used a unique domain once, it'll always use the same
@@ -119,7 +113,7 @@ module Projects
end
def remove_unallowed_params
- params.delete(:emails_disabled) unless can?(current_user, :set_emails_disabled, project)
+ params.delete(:emails_enabled) unless can?(current_user, :set_emails_disabled, project)
params.delete(:runner_registration_enabled) if Gitlab::CurrentSettings.valid_runner_registrars.exclude?('project')
end
diff --git a/app/services/projects/update_statistics_service.rb b/app/services/projects/update_statistics_service.rb
index 71f5a8e633d..9b979f6ed68 100644
--- a/app/services/projects/update_statistics_service.rb
+++ b/app/services/projects/update_statistics_service.rb
@@ -5,7 +5,7 @@ module Projects
include ::Gitlab::Utils::StrongMemoize
STAT_TO_CACHED_METHOD = {
- repository_size: :size,
+ repository_size: [:size, :recent_objects_size],
commit_count: :commit_count
}.freeze
@@ -37,7 +37,7 @@ module Projects
def method_caches_to_expire
strong_memoize(:method_caches_to_expire) do
- statistics.map { |stat| STAT_TO_CACHED_METHOD[stat] }.compact
+ statistics.flat_map { |stat| STAT_TO_CACHED_METHOD[stat] }.compact
end
end
diff --git a/app/services/prometheus/proxy_service.rb b/app/services/prometheus/proxy_service.rb
deleted file mode 100644
index 33635796771..00000000000
--- a/app/services/prometheus/proxy_service.rb
+++ /dev/null
@@ -1,145 +0,0 @@
-# frozen_string_literal: true
-
-module Prometheus
- class ProxyService < BaseService
- include ReactiveCaching
- include Gitlab::Utils::StrongMemoize
-
- self.reactive_cache_key = ->(service) { [] }
- self.reactive_cache_lease_timeout = 30.seconds
-
- # reactive_cache_refresh_interval should be set to a value higher than
- # reactive_cache_lifetime. If the refresh_interval is less than lifetime
- # then the ReactiveCachingWorker will re-query prometheus for this
- # PromQL query even though it's (probably) already been picked up by
- # the frontend
- # refresh_interval should be set less than lifetime only if this data
- # is expected to change *and* be fetched again by the frontend
- self.reactive_cache_refresh_interval = 90.seconds
- self.reactive_cache_lifetime = 1.minute
- self.reactive_cache_work_type = :external_dependency
- self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) }
-
- attr_accessor :proxyable, :method, :path, :params
-
- PROMETHEUS_QUERY_API = 'query'
- PROMETHEUS_QUERY_RANGE_API = 'query_range'
- PROMETHEUS_SERIES_API = 'series'
-
- PROXY_SUPPORT = {
- PROMETHEUS_QUERY_API => {
- method: ['GET'],
- params: %w(query time timeout)
- },
- PROMETHEUS_QUERY_RANGE_API => {
- method: ['GET'],
- params: %w(query start end step timeout)
- },
- PROMETHEUS_SERIES_API => {
- method: %w(GET),
- params: %w(match start end)
- }
- }.freeze
-
- def self.from_cache(proxyable_class_name, proxyable_id, method, path, params)
- proxyable_class = begin
- proxyable_class_name.constantize
- rescue NameError
- nil
- end
- return unless proxyable_class
-
- proxyable = proxyable_class.find(proxyable_id)
-
- new(proxyable, method, path, params)
- end
-
- # proxyable can be any model which responds to .prometheus_adapter
- # like Environment.
- def initialize(proxyable, method, path, params)
- @proxyable = proxyable
- @path = path
-
- # Convert ActionController::Parameters to hash because reactive_cache_worker
- # does not play nice with ActionController::Parameters.
- @params = filter_params(params, path).to_hash
-
- @method = method
- end
-
- def id
- nil
- end
-
- def execute
- return cannot_proxy_response unless can_proxy?
- return no_prometheus_response unless can_query?
-
- with_reactive_cache(*cache_key) do |result|
- result
- end
- end
-
- def calculate_reactive_cache(proxyable_class_name, proxyable_id, method, path, params)
- return no_prometheus_response unless can_query?
-
- response = prometheus_client_wrapper.proxy(path, params)
-
- success(http_status: response.code, body: response.body)
- rescue Gitlab::PrometheusClient::Error => err
- service_unavailable_response(err)
- end
-
- def cache_key
- [@proxyable.class.name, @proxyable.id, @method, @path, @params]
- end
-
- private
-
- def service_unavailable_response(exception)
- error(exception.message, :service_unavailable)
- end
-
- def no_prometheus_response
- error('No prometheus server found', :service_unavailable)
- end
-
- def cannot_proxy_response
- error('Proxy support for this API is not available currently')
- end
-
- def prometheus_adapter
- strong_memoize(:prometheus_adapter) do
- @proxyable.prometheus_adapter
- end
- end
-
- def prometheus_client_wrapper
- prometheus_adapter&.prometheus_client
- end
-
- def can_query?
- prometheus_adapter&.can_query?
- end
-
- def filter_params(params, path)
- params = substitute_params(params)
-
- params.slice(*PROXY_SUPPORT.dig(path, :params))
- end
-
- def can_proxy?
- PROXY_SUPPORT.dig(@path, :method)&.include?(@method)
- end
-
- def substitute_params(params)
- start_time = params[:start_time]
- end_time = params[:end_time]
-
- params['start'] = start_time if start_time
- params['end'] = end_time if end_time
-
- params
- end
- end
-end
diff --git a/app/services/prometheus/proxy_variable_substitution_service.rb b/app/services/prometheus/proxy_variable_substitution_service.rb
deleted file mode 100644
index 846dfeb33ce..00000000000
--- a/app/services/prometheus/proxy_variable_substitution_service.rb
+++ /dev/null
@@ -1,155 +0,0 @@
-# frozen_string_literal: true
-
-module Prometheus
- class ProxyVariableSubstitutionService < BaseService
- include Stepable
-
- VARIABLE_INTERPOLATION_REGEX = /
- {{ # Variable needs to be wrapped in these chars.
- \s* # Allow whitespace before and after the variable name.
- (?<variable> # Named capture.
- \w+ # Match one or more word characters.
- )
- \s*
- }}
- /x.freeze
-
- steps :validate_variables,
- :add_params_to_result,
- :substitute_params,
- :substitute_variables
-
- # @param environment [Environment]
- # @param params [Hash<Symbol,Any>]
- # @param params - query [String] The Prometheus query string.
- # @param params - start [String] (optional) A time string in the rfc3339 format.
- # @param params - start_time [String] (optional) A time string in the rfc3339 format.
- # @param params - end [String] (optional) A time string in the rfc3339 format.
- # @param params - end_time [String] (optional) A time string in the rfc3339 format.
- # @param params - variables [ActionController::Parameters] (optional) Variables with their values.
- # The keys in the Hash should be the name of the variable. The value should be the value of the
- # variable. Ex: `ActionController::Parameters.new(variable1: 'value 1', variable2: 'value 2').permit!`
- # @return [Prometheus::ProxyVariableSubstitutionService]
- #
- # Example:
- # Prometheus::ProxyVariableSubstitutionService.new(environment, {
- # params: {
- # start_time: '2020-07-03T06:08:36Z',
- # end_time: '2020-07-03T14:08:52Z',
- # query: 'up{instance="{{instance}}"}',
- # variables: { instance: 'srv1' }
- # }
- # })
- def initialize(environment, params = {})
- @environment = environment
- @params = params.deep_dup
- end
-
- # @return - params [Hash<Symbol,Any>] Returns a Hash containing a params key which is
- # similar to the `params` that is passed to the initialize method with 2 differences:
- # 1. Variables in the query string are substituted with their values.
- # If a variable present in the query string has no known value (values
- # are obtained from the `variables` Hash in `params` or from
- # `Gitlab::Prometheus::QueryVariables.call`), it will not be substituted.
- # 2. `start` and `end` keys are added, with their values copied from `start_time`
- # and `end_time`.
- #
- # Example output:
- #
- # {
- # params: {
- # start_time: '2020-07-03T06:08:36Z',
- # start: '2020-07-03T06:08:36Z',
- # end_time: '2020-07-03T14:08:52Z',
- # end: '2020-07-03T14:08:52Z',
- # query: 'up{instance="srv1"}',
- # variables: { instance: 'srv1' }
- # }
- # }
- def execute
- execute_steps
- end
-
- private
-
- def validate_variables(_result)
- return success unless variables
-
- unless variables.is_a?(ActionController::Parameters)
- return error(_('Optional parameter "variables" must be a Hash. Ex: variables[key1]=value1'))
- end
-
- success
- end
-
- def add_params_to_result(result)
- result[:params] = params
-
- success(result)
- end
-
- def substitute_params(result)
- start_time = result[:params][:start_time]
- end_time = result[:params][:end_time]
-
- result[:params][:start] = start_time if start_time
- result[:params][:end] = end_time if end_time
-
- success(result)
- end
-
- def substitute_variables(result)
- return success(result) unless query(result)
-
- result[:params][:query] = gsub(query(result), full_context(result))
-
- success(result)
- end
-
- def gsub(string, context)
- # Search for variables of the form `{{variable}}` in the string and replace
- # them with their value.
- string.gsub(VARIABLE_INTERPOLATION_REGEX) do |match|
- # Replace with the value of the variable, or if there is no such variable,
- # replace the invalid variable with itself. So,
- # `up{instance="{{invalid_variable}}"}` will remain
- # `up{instance="{{invalid_variable}}"}` after substitution.
- context.fetch($~[:variable], match)
- end
- end
-
- def predefined_context(result)
- Gitlab::Prometheus::QueryVariables.call(
- @environment,
- start_time: start_timestamp(result),
- end_time: end_timestamp(result)
- ).stringify_keys
- end
-
- def full_context(result)
- @full_context ||= predefined_context(result).reverse_merge(variables_hash)
- end
-
- def variables
- params[:variables]
- end
-
- def variables_hash
- variables.to_h
- end
-
- def start_timestamp(result)
- Time.rfc3339(result[:params][:start])
- rescue ArgumentError
- end
-
- def end_timestamp(result)
- Time.rfc3339(result[:params][:end])
- rescue ArgumentError
- end
-
- def query(result)
- result[:params][:query]
- end
- end
-end
diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb
index d1798ce6fc0..b5f6bff756b 100644
--- a/app/services/quick_actions/interpret_service.rb
+++ b/app/services/quick_actions/interpret_service.rb
@@ -188,8 +188,7 @@ module QuickActions
next unless definition
definition.execute(self, arg)
- # summarize_diff will be removed https://gitlab.com/gitlab-org/gitlab/-/issues/407258#note_1385269274
- usage_ping_tracking(definition.name, arg) unless definition.name == :summarize_diff
+ usage_ping_tracking(definition.name, arg)
end
end
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index 433e9b0da6d..3d413ed9f7b 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -44,6 +44,10 @@ class SearchService
project.blank? && group.blank?
end
+ def search_type
+ 'basic'
+ end
+
def show_snippets?
strong_memoize(:show_snippets) do
params[:snippets] == 'true'
diff --git a/app/services/security/ci_configuration/base_create_service.rb b/app/services/security/ci_configuration/base_create_service.rb
index b60a949fd4e..a205a68532b 100644
--- a/app/services/security/ci_configuration/base_create_service.rb
+++ b/app/services/security/ci_configuration/base_create_service.rb
@@ -2,6 +2,8 @@
module Security
module CiConfiguration
+ CiContentParseError = Class.new(StandardError)
+
class BaseCreateService
attr_reader :branch_name, :current_user, :project, :name
@@ -15,12 +17,13 @@ module Security
if project.repository.empty? && !(@params && @params[:initialize_with_sast])
docs_link = ActionController::Base.helpers.link_to _('add at least one file to the repository'),
Rails.application.routes.url_helpers.help_page_url('user/project/repository/index.md',
- anchor: 'add-files-to-a-repository'),
+ anchor: 'add-files-to-a-repository'),
target: '_blank',
rel: 'noopener noreferrer'
- raise Gitlab::Graphql::Errors::MutationError,
- Gitlab::Utils::ErrorMessage.to_user_facing(
- _(format('You must %s before using Security features.', docs_link.html_safe)).html_safe)
+
+ return ServiceResponse.error(
+ message: _(format('You must %s before using Security features.', docs_link)).html_safe
+ )
end
project.repository.add_branch(current_user, branch_name, project.default_branch)
@@ -33,6 +36,10 @@ module Security
track_event(attributes_for_commit)
ServiceResponse.success(payload: { branch: branch_name, success_path: successful_change_path })
+ rescue CiContentParseError => e
+ Gitlab::ErrorTracking.track_exception(e)
+
+ ServiceResponse.error(message: e.message)
rescue Gitlab::Git::PreReceiveError => e
ServiceResponse.error(message: e.message)
rescue StandardError
@@ -58,13 +65,12 @@ module Security
@gitlab_ci_yml ||= project.ci_config_for(root_ref)
YAML.safe_load(@gitlab_ci_yml) if @gitlab_ci_yml
rescue Psych::BadAlias
- raise Gitlab::Graphql::Errors::MutationError,
- Gitlab::Utils::ErrorMessage.to_user_facing(
- _(".gitlab-ci.yml with aliases/anchors is not supported. Please change the CI configuration manually."))
+ raise CiContentParseError, _(".gitlab-ci.yml with aliases/anchors is not supported. " \
+ "Please change the CI configuration manually.")
rescue Psych::Exception => e
Gitlab::AppLogger.error("Failed to process existing .gitlab-ci.yml: #{e.message}")
- raise Gitlab::Graphql::Errors::MutationError,
- "#{name} merge request creation mutation failed"
+
+ raise CiContentParseError, "#{name} merge request creation failed"
end
def successful_change_path
diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb
index 0527412e9bc..5c510990b2d 100644
--- a/app/services/spam/spam_action_service.rb
+++ b/app/services/spam/spam_action_service.rb
@@ -12,10 +12,9 @@ module Spam
end
def execute
- return ServiceResponse.success(message: 'Skipped spam check because spam_params was not present') unless spam_params
return ServiceResponse.success(message: 'Skipped spam check because user was not present') unless user
- if target.supports_recaptcha?
+ if target.supports_recaptcha? && spam_params
execute_with_captcha_support
else
execute_spam_check
@@ -105,8 +104,8 @@ module Spam
user_id: user.id,
title: target.spam_title,
description: target.spam_description,
- source_ip: spam_params.ip_address,
- user_agent: spam_params.user_agent,
+ source_ip: spam_params&.ip_address,
+ user_agent: spam_params&.user_agent,
noteable_type: noteable_type,
# Now, all requests are via the API, so hardcode it to true to simplify the logic and API
# of this service. See https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/2266
@@ -127,9 +126,9 @@ module Spam
}
options = {
- ip_address: spam_params.ip_address,
- user_agent: spam_params.user_agent,
- referer: spam_params.referer
+ ip_address: spam_params&.ip_address,
+ user_agent: spam_params&.user_agent,
+ referer: spam_params&.referer
}
SpamVerdictService.new(target: target,
diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb
index e0a6d58b904..639d99ad906 100644
--- a/app/services/spam/spam_verdict_service.rb
+++ b/app/services/spam/spam_verdict_service.rb
@@ -85,6 +85,10 @@ module Spam
# than the override verdict's priority value), then we don't need to override it.
return false if SUPPORTED_VERDICTS[verdict][:priority] > SUPPORTED_VERDICTS[OVERRIDE_VIA_ALLOW_POSSIBLE_SPAM][:priority]
+ allow_possible_spam?
+ end
+
+ def allow_possible_spam?
target.allow_possible_spam?(user) || user.allow_possible_spam?
end
@@ -101,3 +105,5 @@ module Spam
end
end
end
+
+Spam::SpamVerdictService.prepend_mod
diff --git a/app/services/todos/destroy/base_service.rb b/app/services/todos/destroy/base_service.rb
index 4e971246185..ed5c4df85b1 100644
--- a/app/services/todos/destroy/base_service.rb
+++ b/app/services/todos/destroy/base_service.rb
@@ -6,7 +6,10 @@ module Todos
def execute
return unless todos_to_remove?
- without_authorized(todos).delete_all
+ ::Gitlab::Database.allow_cross_joins_across_databases(url:
+ 'https://gitlab.com/gitlab-org/gitlab/-/issues/422045') do
+ without_authorized(todos).delete_all
+ end
end
private
diff --git a/app/services/todos/destroy/group_private_service.rb b/app/services/todos/destroy/group_private_service.rb
index d7ecbb952aa..60599ca9ca4 100644
--- a/app/services/todos/destroy/group_private_service.rb
+++ b/app/services/todos/destroy/group_private_service.rb
@@ -24,7 +24,10 @@ module Todos
override :authorized_users
def authorized_users
- group.direct_and_indirect_users.select(:id)
+ User.from_union([
+ group.project_users_with_descendants.select(:id),
+ group.members_with_parents.select(:user_id)
+ ], remove_duplicates: false)
end
override :todos_to_remove?
diff --git a/app/services/users/email_verification/base_service.rb b/app/services/users/email_verification/base_service.rb
index 721290fe056..174626ac2f9 100644
--- a/app/services/users/email_verification/base_service.rb
+++ b/app/services/users/email_verification/base_service.rb
@@ -21,7 +21,7 @@ module Users
end
def digest
- Devise.token_generator.digest(User, user.email, token)
+ Devise.token_generator.digest(User, user.email.downcase.strip, token)
end
end
end
diff --git a/app/services/users/email_verification/update_email_service.rb b/app/services/users/email_verification/update_email_service.rb
new file mode 100644
index 00000000000..3f9b90b2960
--- /dev/null
+++ b/app/services/users/email_verification/update_email_service.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+module Users
+ module EmailVerification
+ class UpdateEmailService
+ include ActionView::Helpers::DateHelper
+
+ RATE_LIMIT = :email_verification_code_send
+
+ def initialize(user:)
+ @user = user
+ end
+
+ def execute(email:)
+ return failure(:rate_limited) if rate_limited?
+ return failure(:already_offered) if already_offered?
+ return failure(:no_change) if no_change?(email)
+ return failure(:validation_error) unless update_email
+
+ success
+ end
+
+ private
+
+ attr_reader :user
+
+ def rate_limited?
+ Gitlab::ApplicationRateLimiter.throttled?(RATE_LIMIT, scope: user)
+ end
+
+ def already_offered?
+ user.email_reset_offered_at.present?
+ end
+
+ def no_change?(email)
+ user.email = email
+ !user.will_save_change_to_email?
+ end
+
+ def update_email
+ user.skip_confirmation_notification!
+ user.save
+ 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
+ interval = distance_of_time_in_words(Gitlab::ApplicationRateLimiter.rate_limits[RATE_LIMIT][:interval])
+ format(
+ s_("IdentityVerification|You've reached the maximum amount of tries. Wait %{interval} and try again."),
+ interval: interval
+ )
+ when :already_offered
+ s_('IdentityVerification|Email update is only offered once.')
+ when :no_change
+ s_('IdentityVerification|A code has already been sent to this email address. ' \
+ 'Check your spam folder or enter another email address.')
+ when :validation_error
+ user.errors.full_messages.join(' ')
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb
index b1ffd006795..197260a80ca 100644
--- a/app/services/users/refresh_authorized_projects_service.rb
+++ b/app/services/users/refresh_authorized_projects_service.rb
@@ -67,8 +67,10 @@ module Users
def update_authorizations(remove = [], add = [])
log_refresh_details(remove, add)
- ProjectAuthorization.delete_all_in_batches_for_user(user: user, project_ids: remove) if remove.any?
- ProjectAuthorization.insert_all_in_batches(add) if add.any?
+ ProjectAuthorizations::Changes.new do |changes|
+ changes.add(add)
+ changes.remove_projects_for_user(user, remove)
+ end.apply!
# Since we batch insert authorization rows, Rails' associations may get
# out of sync. As such we force a reload of the User object.
diff --git a/app/services/users/update_service.rb b/app/services/users/update_service.rb
index 36c41c03303..cc179ba964a 100644
--- a/app/services/users/update_service.rb
+++ b/app/services/users/update_service.rb
@@ -120,7 +120,7 @@ module Users
def after_update(user_exists)
notify_success(user_exists)
- remove_followers_and_followee! if ::Feature.enabled?(:disable_follow_users, user)
+ remove_followers_and_followee!
success
end
diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb
index 6837bc47035..5bad2a1583c 100644
--- a/app/services/web_hook_service.rb
+++ b/app/services/web_hook_service.rb
@@ -57,6 +57,11 @@ class WebHookService
end
def execute
+ if Gitlab::SilentMode.enabled?
+ log_silent_mode_enabled
+ return ServiceResponse.error(message: 'Silent mode enabled')
+ end
+
return ServiceResponse.error(message: 'Hook disabled') if disabled?
if recursion_blocked?
@@ -98,6 +103,7 @@ class WebHookService
def async_execute
Gitlab::ApplicationContext.with_context(hook.application_context) do
+ break log_silent_mode_enabled if Gitlab::SilentMode.enabled?
break log_rate_limited if rate_limit!
break log_recursion_blocked if recursion_blocked?
@@ -237,6 +243,10 @@ class WebHookService
)
end
+ def log_silent_mode_enabled
+ log_auth_error('GitLab is in silent mode')
+ end
+
def log_auth_error(message, params = {})
Gitlab::AuthLogger.error(
params.merge(
diff --git a/app/services/work_items/related_work_item_links/create_service.rb b/app/services/work_items/related_work_item_links/create_service.rb
new file mode 100644
index 00000000000..6a9ddd5c83d
--- /dev/null
+++ b/app/services/work_items/related_work_item_links/create_service.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module RelatedWorkItemLinks
+ class CreateService < IssuableLinks::CreateService
+ extend ::Gitlab::Utils::Override
+
+ def execute
+ return error(_('No matching work item found.'), 404) unless can?(current_user, :admin_work_item_link, issuable)
+
+ response = super
+
+ if response[:status] == :success
+ response[:message] = format(
+ _('Successfully linked ID(s): %{item_ids}.'),
+ item_ids: linked_ids(response[:created_references]).to_sentence
+ )
+ end
+
+ response
+ end
+
+ def linkable_issuables(work_items)
+ @linkable_issuables ||= work_items.select { |work_item| can_link_item?(work_item) }
+ end
+
+ def previous_related_issuables
+ @related_issues ||= issuable.related_issues(current_user).to_a
+ end
+
+ private
+
+ def link_class
+ WorkItems::RelatedWorkItemLink
+ end
+
+ def can_link_item?(work_item)
+ return true if can?(current_user, :admin_work_item_link, work_item)
+
+ @errors << format(
+ _("Item with ID: %{id} cannot be added. You don't have permission to perform this action."),
+ id: work_item.id
+ )
+
+ false
+ end
+
+ def linked_ids(created_links)
+ created_links.collect(&:target_id)
+ end
+
+ override :issuables_already_assigned_message
+ def issuables_already_assigned_message
+ _('Work items are already linked')
+ end
+
+ override :issuables_not_found_message
+ def issuables_not_found_message
+ _('No matching work item found. Make sure you are adding a valid ID and you have access to the item.')
+ end
+ end
+ end
+end
+
+WorkItems::RelatedWorkItemLinks::CreateService.prepend_mod_with('WorkItems::RelatedWorkItemLinks::CreateService')
diff --git a/app/validators/import/gitlab_projects/remote_file_validator.rb b/app/validators/import/gitlab_projects/remote_file_validator.rb
index 67bf102e928..c82e1e77a37 100644
--- a/app/validators/import/gitlab_projects/remote_file_validator.rb
+++ b/app/validators/import/gitlab_projects/remote_file_validator.rb
@@ -5,7 +5,6 @@ module Import
# Validates the given object's #content_type and #content_length accordingly
# with the Project Import requirements
class RemoteFileValidator < ActiveModel::Validator
- FILE_SIZE_LIMIT = 10.gigabytes
ALLOWED_CONTENT_TYPES = [
'application/gzip',
# S3 uses different file types
@@ -23,8 +22,8 @@ module Import
def validate_content_length(record)
if record.content_length.to_i <= 0
record.errors.add(:content_length, :size_too_small, file_size: humanize(1.byte))
- elsif record.content_length > FILE_SIZE_LIMIT
- record.errors.add(:content_length, :size_too_big, file_size: humanize(FILE_SIZE_LIMIT))
+ elsif file_size_limit > 0 && record.content_length > file_size_limit
+ record.errors.add(:content_length, :size_too_big, file_size: humanize(file_size_limit))
end
end
@@ -40,6 +39,10 @@ module Import
allowed: ALLOWED_CONTENT_TYPES.join(', ')
})
end
+
+ def file_size_limit
+ Gitlab::CurrentSettings.current_application_settings.max_import_remote_file_size.megabytes
+ end
end
end
end
diff --git a/app/validators/json_schemas/application_setting_database_apdex_settings.json b/app/validators/json_schemas/application_setting_database_apdex_settings.json
deleted file mode 100644
index 8b58dd44586..00000000000
--- a/app/validators/json_schemas/application_setting_database_apdex_settings.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
- "description": "Database Apdex Settings",
- "type": "object",
- "properties": {
- "prometheus_api_url": {
- "type": "string"
- },
- "apdex_sli_query": {
- "type": "object",
- "properties": {
- "main": {
- "type": "string"
- },
- "ci": {
- "type": "string"
- }
- }
- },
- "apdex_slo": {
- "type": "object",
- "properties": {
- "main": {
- "type": "number",
- "format": "float"
- },
- "ci": {
- "type": "number",
- "format": "float"
- }
- }
- }
- },
- "additionalProperties": false
-}
diff --git a/app/validators/json_schemas/application_setting_prometheus_alert_db_indicators_settings.json b/app/validators/json_schemas/application_setting_prometheus_alert_db_indicators_settings.json
new file mode 100644
index 00000000000..9b7e34ed5ea
--- /dev/null
+++ b/app/validators/json_schemas/application_setting_prometheus_alert_db_indicators_settings.json
@@ -0,0 +1,54 @@
+{
+ "description": "Prometheus Alert Based Db indicators Settings",
+ "type": "object",
+ "properties": {
+ "prometheus_api_url": {
+ "type": "string"
+ },
+ "apdex_sli_query": {
+ "type": "object",
+ "properties": {
+ "main": {
+ "type": "string"
+ },
+ "ci": {
+ "type": "string"
+ }
+ }
+ },
+ "apdex_slo": {
+ "type": "object",
+ "properties": {
+ "main": {
+ "type": "number"
+ },
+ "ci": {
+ "type": "number"
+ }
+ }
+ },
+ "wal_rate_sli_query": {
+ "type": "object",
+ "properties": {
+ "main": {
+ "type": "string"
+ },
+ "ci": {
+ "type": "string"
+ }
+ }
+ },
+ "wal_rate_slo": {
+ "type": "object",
+ "properties": {
+ "main": {
+ "type": "integer"
+ },
+ "ci": {
+ "type": "integer"
+ }
+ }
+ }
+ },
+ "additionalProperties": true
+}
diff --git a/app/validators/json_schemas/build_metadata_secrets.json b/app/validators/json_schemas/build_metadata_secrets.json
index 5dcd33a2cf0..ac34af3f107 100644
--- a/app/validators/json_schemas/build_metadata_secrets.json
+++ b/app/validators/json_schemas/build_metadata_secrets.json
@@ -24,9 +24,30 @@
},
"additionalProperties": false
},
+ "^azure_key_vault$": {
+ "type": "object",
+ "required": ["name"],
+ "properties": {
+ "name": { "type": "string" },
+ "version": { "type": ["string", "null"] }
+ },
+ "additionalProperties": false
+ },
"^file$": { "type": "boolean" },
"^token$": { "type": "string" }
},
+ "anyOf": [
+ {
+ "required": [
+ "vault"
+ ]
+ },
+ {
+ "required": [
+ "azure_key_vault"
+ ]
+ }
+ ],
"additionalProperties": false
}
}
diff --git a/app/validators/json_schemas/catalog_resource_component_inputs.json b/app/validators/json_schemas/catalog_resource_component_inputs.json
new file mode 100644
index 00000000000..014a52d4f1b
--- /dev/null
+++ b/app/validators/json_schemas/catalog_resource_component_inputs.json
@@ -0,0 +1,24 @@
+{
+ "description": "Catalog Resource Component Inputs",
+ "type": "object",
+ "patternProperties": {
+ ".*": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "patternProperties": {
+ "default": {
+ "type": [
+ "string",
+ "integer",
+ "boolean"
+ ]
+ },
+ "^type$": {
+ "type": "string"
+ }
+ }
+ }
+ }
+}
diff --git a/app/validators/json_schemas/default_branch_protection_defaults.json b/app/validators/json_schemas/default_branch_protection_defaults.json
index d93527ad0a4..02491d2708b 100644
--- a/app/validators/json_schemas/default_branch_protection_defaults.json
+++ b/app/validators/json_schemas/default_branch_protection_defaults.json
@@ -27,7 +27,11 @@
"additionalProperties": false,
"properties": {
"access_level": {
- "type": "integer"
+ "type": "integer",
+ "enum": [
+ 30,
+ 40
+ ]
}
}
}
@@ -52,7 +56,11 @@
"additionalProperties": false,
"properties": {
"access_level": {
- "type": "integer"
+ "type": "integer",
+ "enum": [
+ 30,
+ 40
+ ]
}
}
}
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 af67ed28309..d8d6af606ac 100644
--- a/app/views/admin/application_settings/_account_and_limit.html.haml
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -27,6 +27,17 @@
= f.number_field :max_import_size, class: 'form-control gl-form-input', title: _('Maximum size of import files.'), data: { toggle: 'tooltip', container: 'body' }
%span.form-text.text-muted= _('Only effective when remote storage is enabled. Set to 0 for no size limit.')
.form-group
+ = f.label :max_import_remote_file_size, s_('Import|Maximum import remote file size (MB)'), class: 'label-light'
+ = f.number_field :max_import_remote_file_size, class: 'form-control gl-form-input', title: s_('Import|Maximum remote file size for imports from external object storages. For example, AWS S3.'), data: { toggle: 'tooltip', container: 'body' }
+ %span.form-text.text-muted= _('Set to 0 for no size limit.')
+ .form-group
+ = f.label :bulk_import_max_download_file_size, s_('BulkImport|Direct transfer maximum download file size (MB)'), class: 'label-light'
+ = f.number_field :bulk_import_max_download_file_size, class: 'form-control gl-form-input', title: s_('BulkImport|Maximum download file size when importing from source GitLab instances by direct transfer.'), data: { toggle: 'tooltip', container: 'body' }
+ .form-group
+ = f.label :max_decompressed_archive_size, s_('Import|Maximum decompressed size (MiB)'), class: 'label-light'
+ = f.number_field :max_decompressed_archive_size, class: 'form-control gl-form-input', title: s_('Import|Maximum size of decompressed archive.'), data: { toggle: 'tooltip', container: 'body' }
+ %span.form-text.text-muted= _('Set to 0 for no size limit.')
+ .form-group
= f.label :session_expire_delay, _('Session duration (minutes)'), class: 'label-light'
= f.number_field :session_expire_delay, class: 'form-control gl-form-input', title: _('Maximum duration of a session.'), data: { toggle: 'tooltip', container: 'body' }
%span.form-text.text-muted#session_expire_delay_help_block= _('Restart GitLab to apply changes.')
diff --git a/app/views/admin/application_settings/_ai_access.html.haml b/app/views/admin/application_settings/_ai_access.html.haml
deleted file mode 100644
index 97f46adef51..00000000000
--- a/app/views/admin/application_settings/_ai_access.html.haml
+++ /dev/null
@@ -1,31 +0,0 @@
-- return if Gitlab.org_or_com?
-
-- expanded = integration_expanded?('ai_access')
-- token_is_present = @application_setting.ai_access_token.present?
-- token_label = token_is_present ? s_('CodeSuggestionsSM|Enter new personal access token') : s_('CodeSuggestionsSM|Personal access token')
-- token_value = token_is_present ? ApplicationSettingMaskedAttrs::MASK : ''
-
-%section.settings.no-animate#js-ai-access-settings{ class: ('expanded' if expanded) }
- .settings-header
- %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
- = s_('CodeSuggestionsSM|Code Suggestions')
- = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
- = expanded ? _('Collapse') : _('Expand')
- %p
- = code_suggestions_description
-
- .settings-content
- = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-ai-access-settings'), html: { class: 'fieldset-form', id: 'ai-access-settings' } do |f|
- = form_errors(@application_setting)
-
- %fieldset
- .form-group
- = f.gitlab_ui_checkbox_component :instance_level_code_suggestions_enabled,
- s_('CodeSuggestionsSM|Enable Code Suggestions for this instance %{beta}').html_safe % { beta: gl_badge_tag(_('Beta'), variant: :neutral, size: :sm) },
- help_text: code_suggestions_agreement
- = f.label :ai_access_token, token_label, class: 'label-bold'
- = f.password_field :ai_access_token, value: token_value, autocomplete: 'on', class: 'form-control gl-form-input', aria: { describedby: 'code_suggestions_token_explanation' }
- %p.form-text.text-muted{ id: 'code_suggestions_token_explanation' }
- = code_suggestions_token_explanation
-
- = f.submit _('Save changes'), 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 0125c83dc72..c08270a8522 100644
--- a/app/views/admin/application_settings/_ci_cd.html.haml
+++ b/app/views/admin/application_settings/_ci_cd.html.haml
@@ -62,48 +62,48 @@
= f.submit _('Save changes'), pajamas_button: true
-.settings-content
- %h4
- = s_('AdminSettings|CI/CD limits')
- %p
- = s_('AdminSettings|By default, set a limit to 0 to have no limit.')
- .scrolling-tabs-container.inner-page-scroll-tabs
- - if @plans.size > 1
- %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs.gl-mb-5
+ .gl-mt-7
+ %h4
+ = s_('AdminSettings|CI/CD limits')
+ %p
+ = s_('AdminSettings|By default, set a limit to 0 to have no limit.')
+ .scrolling-tabs-container.inner-page-scroll-tabs
+ - if @plans.size > 1
+ %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs.gl-mb-5
+ - @plans.each_with_index do |plan, index|
+ %li
+ = link_to admin_plan_limits_path(anchor: 'js-ci-cd-settings'), data: { target: "div#plan#{index}", action: "plan#{index}", toggle: 'tab'}, class: index == 0 ? 'active': '' do
+ = plan.name.capitalize
+ .tab-content.gl-tab-content
- @plans.each_with_index do |plan, index|
- %li
- = link_to admin_plan_limits_path(anchor: 'js-ci-cd-settings'), data: { target: "div#plan#{index}", action: "plan#{index}", toggle: 'tab'}, class: index == 0 ? 'active': '' do
- = plan.name.capitalize
- .tab-content.gl-tab-content
- - @plans.each_with_index do |plan, index|
- .tab-pane{ :id => "plan#{index}", class: index == 0 ? 'active': '' }
- = 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
- = f.label :ci_pipeline_size, plan_limit_setting_description(:ci_pipeline_size)
- = f.number_field :ci_pipeline_size, class: 'form-control gl-form-input'
- .form-group
- = f.label :ci_active_jobs, plan_limit_setting_description(:ci_active_jobs)
- = f.number_field :ci_active_jobs, class: 'form-control gl-form-input'
- .form-group
- = f.label :ci_project_subscriptions, plan_limit_setting_description(:ci_project_subscriptions)
- = f.number_field :ci_project_subscriptions, class: 'form-control gl-form-input'
- .form-group
- = f.label :ci_pipeline_schedules, plan_limit_setting_description(:ci_pipeline_schedules)
- = f.number_field :ci_pipeline_schedules, class: 'form-control gl-form-input'
- .form-group
- = f.label :ci_needs_size_limit, plan_limit_setting_description(:ci_needs_size_limit)
- = f.number_field :ci_needs_size_limit, class: 'form-control gl-form-input'
- .form-text.text-muted= s_('AdminSettings|This limit cannot be disabled. Set to 0 to block all DAG dependencies.')
- .form-group
- = f.label :ci_registered_group_runners, plan_limit_setting_description(:ci_registered_group_runners)
- = f.number_field :ci_registered_group_runners, class: 'form-control gl-form-input'
- .form-group
- = f.label :ci_registered_project_runners, plan_limit_setting_description(:ci_registered_project_runners)
- = f.number_field :ci_registered_project_runners, class: 'form-control gl-form-input'
- .form-group
- = f.label :pipeline_hierarchy_size, plan_limit_setting_description(:pipeline_hierarchy_size)
- = f.number_field :pipeline_hierarchy_size, class: 'form-control gl-form-input'
- = f.submit s_('AdminSettings|Save %{name} limits').html_safe % { name: plan.name.capitalize }, pajamas_button: true
+ .tab-pane{ :id => "plan#{index}", class: index == 0 ? 'active': '' }
+ = 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
+ = f.label :ci_pipeline_size, plan_limit_setting_description(:ci_pipeline_size)
+ = f.number_field :ci_pipeline_size, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :ci_active_jobs, plan_limit_setting_description(:ci_active_jobs)
+ = f.number_field :ci_active_jobs, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :ci_project_subscriptions, plan_limit_setting_description(:ci_project_subscriptions)
+ = f.number_field :ci_project_subscriptions, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :ci_pipeline_schedules, plan_limit_setting_description(:ci_pipeline_schedules)
+ = f.number_field :ci_pipeline_schedules, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :ci_needs_size_limit, plan_limit_setting_description(:ci_needs_size_limit)
+ = f.number_field :ci_needs_size_limit, class: 'form-control gl-form-input'
+ .form-text.text-muted= s_('AdminSettings|This limit cannot be disabled. Set to 0 to block all DAG dependencies.')
+ .form-group
+ = f.label :ci_registered_group_runners, plan_limit_setting_description(:ci_registered_group_runners)
+ = f.number_field :ci_registered_group_runners, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :ci_registered_project_runners, plan_limit_setting_description(:ci_registered_project_runners)
+ = f.number_field :ci_registered_project_runners, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :pipeline_hierarchy_size, plan_limit_setting_description(:pipeline_hierarchy_size)
+ = f.number_field :pipeline_hierarchy_size, class: 'form-control gl-form-input'
+ = f.submit s_('AdminSettings|Save %{name} limits').html_safe % { name: plan.name.capitalize }, pajamas_button: true
diff --git a/app/views/admin/application_settings/_diagramsnet.html.haml b/app/views/admin/application_settings/_diagramsnet.html.haml
index e493110a9dc..0cf44938881 100644
--- a/app/views/admin/application_settings/_diagramsnet.html.haml
+++ b/app/views/admin/application_settings/_diagramsnet.html.haml
@@ -5,7 +5,7 @@
= _('Diagrams.net')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Render diagrams in your documents using diagrams.net.')
= link_to _('Learn more.'), help_page_path('administration/integration/diagrams_net.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
diff --git a/app/views/admin/application_settings/_eks.html.haml b/app/views/admin/application_settings/_eks.html.haml
index 62c61ad356f..57846edde05 100644
--- a/app/views/admin/application_settings/_eks.html.haml
+++ b/app/views/admin/application_settings/_eks.html.haml
@@ -5,7 +5,7 @@
= _('Amazon EKS')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Amazon EKS integration allows you to provision EKS clusters from GitLab.')
.settings-content
diff --git a/app/views/admin/application_settings/_error_tracking.html.haml b/app/views/admin/application_settings/_error_tracking.html.haml
index b57371286d5..6754dd99bbc 100644
--- a/app/views/admin/application_settings/_error_tracking.html.haml
+++ b/app/views/admin/application_settings/_error_tracking.html.haml
@@ -6,7 +6,7 @@
= _('GitLab Error Tracking')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Allows projects to track errors using an Opstrace integration.').html_safe % { link: help_page_path('operations/error_tracking.md') }
= link_to _('Learn more.'), help_page_path('operations/error_tracking.md'), target: '_blank', rel: 'noopener noreferrer'
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 1b62083849b..463d6b24fdd 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
@@ -4,9 +4,9 @@
= s_('ExternalAuthorization|External authorization')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= s_('ExternalAuthorization|External classification policy authorization.')
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/external_authorization'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('administration/settings/external_authorization'), target: '_blank', rel: 'noopener noreferrer'
.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|
diff --git a/app/views/admin/application_settings/_floc.html.haml b/app/views/admin/application_settings/_floc.html.haml
index cb8b2d3dfcd..e1576e84e66 100644
--- a/app/views/admin/application_settings/_floc.html.haml
+++ b/app/views/admin/application_settings/_floc.html.haml
@@ -6,8 +6,8 @@
= s_('FloC|Federated Learning of Cohorts (FLoC)')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
- - floc_link_url = help_page_path('user/admin_area/settings/floc.md')
+ %p.gl-text-secondary
+ - floc_link_url = help_page_path('administration/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 }
diff --git a/app/views/admin/application_settings/_gitlab_shell_operation_limits.html.haml b/app/views/admin/application_settings/_gitlab_shell_operation_limits.html.haml
index 4bd44b922fa..64549b97bd1 100644
--- a/app/views/admin/application_settings/_gitlab_shell_operation_limits.html.haml
+++ b/app/views/admin/application_settings/_gitlab_shell_operation_limits.html.haml
@@ -4,9 +4,9 @@
= s_('ShellOperations|Git SSH operations rate limit')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= s_('ShellOperations|Limit the number of Git operations a user can perform per minute, per repository.')
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/rate_limits_on_git_ssh_operations.md'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('administration/settings/rate_limits_on_git_ssh_operations.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-gitlab-shell-operation-limits-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
diff --git a/app/views/admin/application_settings/_gitpod.html.haml b/app/views/admin/application_settings/_gitpod.html.haml
index 09817a9172f..988153d45a4 100644
--- a/app/views/admin/application_settings/_gitpod.html.haml
+++ b/app/views/admin/application_settings/_gitpod.html.haml
@@ -6,11 +6,10 @@
= _('Gitpod')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ .gl-text-secondary
#js-gitpod-settings-help-text{ data: {"message" => gitpod_enable_description, "message-url" => "https://gitpod.io/" } }
= link_to sprite_icon('question-o'), help_page_path('integration/gitpod.md'), target: '_blank', class: 'has-tooltip', title: _('More information')
-
.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)
@@ -23,7 +22,7 @@
= f.label :gitpod_url, s_('Gitpod|Gitpod URL'), class: 'label-bold'
= f.text_field :gitpod_url, class: 'form-control gl-form-input', placeholder: s_('Gitpod|https://gitpod.example.com')
.form-text.text-muted
+ - help_link = link_to('', help_page_path('integration/gitpod', anchor: 'enable-gitpod-in-your-user-settings', target: '_blank', rel: 'noopener noreferrer'))
= s_('Gitpod|The URL to your Gitpod instance configured to read your GitLab projects, such as https://gitpod.example.com.')
- - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('integration/gitpod', anchor: 'enable-gitpod-in-your-user-settings') }
- = s_('Gitpod|To use the integration, each user must also enable Gitpod on their GitLab account. %{link_start}How do I enable it?%{link_end} ').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+ = safe_format(s_('Gitpod|To use the integration, each user must also enable Gitpod on their GitLab account. %{help_link_start}How do I enable it?%{help_link_end}'), tag_pair(help_link, :help_link_start, :help_link_end))
= f.submit _('Save changes'), pajamas_button: true
diff --git a/app/views/admin/application_settings/_jira_connect.html.haml b/app/views/admin/application_settings/_jira_connect.html.haml
index 235b6855123..23ad85334cb 100644
--- a/app/views/admin/application_settings/_jira_connect.html.haml
+++ b/app/views/admin/application_settings/_jira_connect.html.haml
@@ -6,7 +6,7 @@
= s_('JiraConnect|GitLab for Jira App')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= s_('JiraConnect|Configure your Jira Connect Application ID.')
= link_to sprite_icon('question-o'),
help_page_path('integration/jira/connect-app',
@@ -18,19 +18,13 @@
.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)
-
- %fieldset
- .form-group
- = f.label :jira_connect_application_key, s_('JiraConnect|Jira Connect Application ID'), class: 'label-bold'
- = f.text_field :jira_connect_application_key, class: 'form-control gl-form-input'
-
- %fieldset
- .form-group
- = f.label :jira_connect_proxy_url, s_('JiraConnect|Jira Connect Proxy URL'), class: 'label-bold'
- = f.text_field :jira_connect_proxy_url, class: 'form-control gl-form-input'
-
- %fieldset
- .form-group
- = f.gitlab_ui_checkbox_component :jira_connect_public_key_storage_enabled, s_('JiraConnect|Enable public key storage')
+ .gl-form-group
+ = f.label :jira_connect_application_key, s_('JiraConnect|Jira Connect Application ID'), class: 'label-bold'
+ = f.text_field :jira_connect_application_key, class: 'form-control gl-form-input'
+ .gl-form-group
+ = f.label :jira_connect_proxy_url, s_('JiraConnect|Jira Connect Proxy URL'), class: 'label-bold'
+ = f.text_field :jira_connect_proxy_url, class: 'form-control gl-form-input'
+ .gl-form-group
+ = f.gitlab_ui_checkbox_component :jira_connect_public_key_storage_enabled, s_('JiraConnect|Enable public key storage')
= f.submit _('Save changes'), pajamas_button: true
diff --git a/app/views/admin/application_settings/_kroki.html.haml b/app/views/admin/application_settings/_kroki.html.haml
index e1f5802a407..4dbca235a73 100644
--- a/app/views/admin/application_settings/_kroki.html.haml
+++ b/app/views/admin/application_settings/_kroki.html.haml
@@ -5,7 +5,7 @@
= _('Kroki')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Users can render diagrams in AsciiDoc, Markdown, reStructuredText, and Textile documents using Kroki.')
= link_to _('Learn more.'), help_page_path('administration/integration/kroki.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
diff --git a/app/views/admin/application_settings/_localization.html.haml b/app/views/admin/application_settings/_localization.html.haml
index 19d321ca205..4002aa076f7 100644
--- a/app/views/admin/application_settings/_localization.html.haml
+++ b/app/views/admin/application_settings/_localization.html.haml
@@ -7,7 +7,7 @@
= f.select :first_day_of_week, first_day_of_week_choices, {}, class: 'form-control'
.form-text.text-muted
= _('Default first day of the week in calendars and date pickers.')
- = link_to _('Learn more.'), help_page_path('administration/settings/index.md', anchor: 'default-first-day-of-the-week'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('administration/settings/index.md', anchor: 'change-the-default-first-day-of-the-week'), target: '_blank', rel: 'noopener noreferrer'
.form-group
= f.label :time_tracking, _('Time tracking'), class: 'label-bold'
diff --git a/app/views/admin/application_settings/_mailgun.html.haml b/app/views/admin/application_settings/_mailgun.html.haml
index fb15f6e79a5..a01303db789 100644
--- a/app/views/admin/application_settings/_mailgun.html.haml
+++ b/app/views/admin/application_settings/_mailgun.html.haml
@@ -5,7 +5,7 @@
= _('Mailgun')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('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|
diff --git a/app/views/admin/application_settings/_package_registry.html.haml b/app/views/admin/application_settings/_package_registry.html.haml
index 66b04006beb..23251c8f5c9 100644
--- a/app/views/admin/application_settings/_package_registry.html.haml
+++ b/app/views/admin/application_settings/_package_registry.html.haml
@@ -5,52 +5,53 @@
= _('Package Registry')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= s_('PackageRegistry|Configure package forwarding and package file size limits.')
- = render_if_exists 'admin/application_settings/ee_package_registry'
-
.settings-content
- %h4
- = _('Package file size limits')
- %p
- = _('Set limit to 0 to allow any file size.')
- .scrolling-tabs-container.inner-page-scroll-tabs
- - if @plans.size > 1
- %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs.mb-3
+ = render_if_exists 'admin/application_settings/ee_package_registry'
+
+ .gl-mt-7
+ %h4
+ = _('Package file size limits')
+ %p
+ = _('Set limit to 0 to allow any file size.')
+ .scrolling-tabs-container.inner-page-scroll-tabs
+ - if @plans.size > 1
+ %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs.mb-3
+ - @plans.each_with_index do |plan, index|
+ %li
+ = link_to admin_plan_limits_path(anchor: 'js-package-settings'), data: { target: "div#plan-package#{index}", action: "plan#{index}", toggle: 'tab'}, class: index == 0 ? 'active': '' do
+ = plan.name.capitalize
+ .tab-content
- @plans.each_with_index do |plan, index|
- %li
- = link_to admin_plan_limits_path(anchor: 'js-package-settings'), data: { target: "div#plan-package#{index}", action: "plan#{index}", toggle: 'tab'}, class: index == 0 ? 'active': '' do
- = plan.name.capitalize
- .tab-content
- - @plans.each_with_index do |plan, index|
- .tab-pane{ :id => "plan-package#{index}", class: index == 0 ? 'active': '' }
- = gitlab_ui_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)
- %fieldset
- = f.hidden_field(:plan_id, value: plan.id)
- .form-group
- = f.label :conan_max_file_size, _('Maximum Conan package file size in bytes'), class: 'label-bold'
- = f.number_field :conan_max_file_size, class: 'form-control gl-form-input'
- .form-group
- = f.label :helm_max_file_size, _('Maximum Helm chart file size in bytes'), class: 'label-bold'
- = f.number_field :helm_max_file_size, class: 'form-control gl-form-input'
- .form-group
- = f.label :maven_max_file_size, _('Maximum Maven package file size in bytes'), class: 'label-bold'
- = f.number_field :maven_max_file_size, class: 'form-control gl-form-input'
- .form-group
- = f.label :npm_max_file_size, _('Maximum npm package file size in bytes'), class: 'label-bold'
- = f.number_field :npm_max_file_size, class: 'form-control gl-form-input'
- .form-group
- = f.label :nuget_max_file_size, _('Maximum NuGet package file size in bytes'), class: 'label-bold'
- = f.number_field :nuget_max_file_size, class: 'form-control gl-form-input'
- .form-group
- = f.label :pypi_max_file_size, _('Maximum PyPI package file size in bytes'), class: 'label-bold'
- = f.number_field :pypi_max_file_size, class: 'form-control gl-form-input'
- .form-group
- = f.label :terraform_module_max_file_size, _('Maximum Terraform Module package file size in bytes'), class: 'label-bold'
- = f.number_field :terraform_module_max_file_size, class: 'form-control gl-form-input'
- .form-group
- = f.label :generic_packages_max_file_size, _('Generic package file size in bytes'), class: 'label-bold'
- = f.number_field :generic_packages_max_file_size, class: 'form-control gl-form-input'
- = f.submit _('Save %{name} size limits').html_safe % { name: plan.name.capitalize }, pajamas_button: true
+ .tab-pane{ :id => "plan-package#{index}", class: index == 0 ? 'active': '' }
+ = gitlab_ui_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)
+ %fieldset
+ = f.hidden_field(:plan_id, value: plan.id)
+ .form-group
+ = f.label :conan_max_file_size, _('Maximum Conan package file size in bytes'), class: 'label-bold'
+ = f.number_field :conan_max_file_size, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :helm_max_file_size, _('Maximum Helm chart file size in bytes'), class: 'label-bold'
+ = f.number_field :helm_max_file_size, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :maven_max_file_size, _('Maximum Maven package file size in bytes'), class: 'label-bold'
+ = f.number_field :maven_max_file_size, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :npm_max_file_size, _('Maximum npm package file size in bytes'), class: 'label-bold'
+ = f.number_field :npm_max_file_size, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :nuget_max_file_size, _('Maximum NuGet package file size in bytes'), class: 'label-bold'
+ = f.number_field :nuget_max_file_size, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :pypi_max_file_size, _('Maximum PyPI package file size in bytes'), class: 'label-bold'
+ = f.number_field :pypi_max_file_size, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :terraform_module_max_file_size, _('Maximum Terraform Module package file size in bytes'), class: 'label-bold'
+ = f.number_field :terraform_module_max_file_size, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :generic_packages_max_file_size, _('Generic package file size in bytes'), class: 'label-bold'
+ = f.number_field :generic_packages_max_file_size, class: 'form-control gl-form-input'
+ = f.submit _('Save %{name} size limits').html_safe % { name: plan.name.capitalize }, pajamas_button: true
diff --git a/app/views/admin/application_settings/_plantuml.html.haml b/app/views/admin/application_settings/_plantuml.html.haml
index 42f289d87b2..a8b758f7324 100644
--- a/app/views/admin/application_settings/_plantuml.html.haml
+++ b/app/views/admin/application_settings/_plantuml.html.haml
@@ -5,7 +5,7 @@
= _('PlantUML')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Render diagrams in your documents using PlantUML.')
= link_to _('Learn more.'), help_page_path('administration/integration/plantuml.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
diff --git a/app/views/admin/application_settings/_projects_api_limits.html.haml b/app/views/admin/application_settings/_projects_api_limits.html.haml
index 4efab4d77a9..dde8ab07958 100644
--- a/app/views/admin/application_settings/_projects_api_limits.html.haml
+++ b/app/views/admin/application_settings/_projects_api_limits.html.haml
@@ -4,9 +4,9 @@
= _('Projects API rate limit')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Set the per-IP address rate limit applicable to unauthenticated requests for getting a list of projects via the API.')
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/rate_limit_on_projects_api.md'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('administration/settings/rate_limit_on_projects_api.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-projects-api-limits-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
diff --git a/app/views/admin/application_settings/_slack.html.haml b/app/views/admin/application_settings/_slack.html.haml
index e4f46fdf7f2..a4cd0a27baa 100644
--- a/app/views/admin/application_settings/_slack.html.haml
+++ b/app/views/admin/application_settings/_slack.html.haml
@@ -7,9 +7,9 @@
= s_('Integrations|GitLab for Slack app')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= s_('SlackIntegration|Configure your GitLab for Slack app.')
- = link_to(_('Learn more.'), help_page_path('user/admin_area/settings/slack_app'), target: '_blank', rel: 'noopener noreferrer')
+ = link_to(_('Learn more.'), help_page_path('administration/settings/slack_app'), target: '_blank', rel: 'noopener noreferrer')
.settings-content
- unless gitlab_com
@@ -27,7 +27,7 @@
- tag_pair_slack_apps = tag_pair(link_to('', 'https://api.slack.com/apps', target: '_blank', rel: 'noopener noreferrer'), :link_start, :link_end)
- tag_pair_strong = tag_pair(tag.strong, :strong_open, :strong_close)
= safe_format(s_('SlackIntegration|Copy the %{link_start}settings%{link_end} from %{strong_open}%{settings_heading}%{strong_close} in your GitLab for Slack app.'), tag_pair_slack_apps, tag_pair_strong, settings_heading: 'App Credentials')
- = link_to(_('Learn more.'), help_page_path('user/admin_area/settings/slack_app', anchor: 'configure-the-settings'), target: '_blank', rel: 'noopener noreferrer')
+ = link_to(_('Learn more.'), help_page_path('administration/settings/slack_app', anchor: 'configure-the-settings'), target: '_blank', rel: 'noopener noreferrer')
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-slack-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting) if expanded
%fieldset
@@ -59,7 +59,7 @@
= s_('SlackIntegration|Update your Slack app')
%p
= s_('SlackIntegration|When GitLab releases new features for the GitLab for Slack app, you might have to manually update your copy to use the new features.')
- = link_to(_('Learn more.'), help_page_path('user/admin_area/settings/slack_app', anchor: 'update-the-gitlab-for-slack-app'), target: '_blank', rel: 'noopener noreferrer')
+ = link_to(_('Learn more.'), help_page_path('administration/settings/slack_app', anchor: 'update-the-gitlab-for-slack-app'), target: '_blank', rel: 'noopener noreferrer')
%p
= render Pajamas::ButtonComponent.new(href: slack_app_manifest_download_admin_application_settings_path, icon: 'download') do
= s_("SlackIntegration|Download latest manifest file")
diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml
index 4e7d9b8ab21..6f9aad56ce8 100644
--- a/app/views/admin/application_settings/_snowplow.html.haml
+++ b/app/views/admin/application_settings/_snowplow.html.haml
@@ -5,9 +5,10 @@
= _('Snowplow')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
- - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('development/snowplow/index') }
- = 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 }
+ %p.gl-text-secondary
+ - help_link = link_to('', help_page_path('development/snowplow/index'), target: '_blank', rel: 'noopener noreferrer')
+ - snowplow_link = link_to('', 'https://snowplow.io/', target: '_blank', rel: 'noopener noreferrer')
+ = safe_format(_('Configure %{snowplow_link_start}Snowplow%{snowplow_link_end} to track events. %{help_link_start}Learn more.%{help_link_end}'), tag_pair(snowplow_link, :snowplow_link_start, :snowplow_link_end), tag_pair(help_link, :help_link_start, :help_link_end))
.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) if expanded
diff --git a/app/views/admin/application_settings/_sourcegraph.html.haml b/app/views/admin/application_settings/_sourcegraph.html.haml
index b56ca12baec..61ec841bb83 100644
--- a/app/views/admin/application_settings/_sourcegraph.html.haml
+++ b/app/views/admin/application_settings/_sourcegraph.html.haml
@@ -7,7 +7,7 @@
= _('Sourcegraph')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: 'https://sourcegraph.com/' }
- link_end = "#{sprite_icon('external-link', size: 12, css_class: 'ml-1 vertical-align-center')}</a>".html_safe
= s_('SourcegraphAdmin|Enable code intelligence powered by %{link_start}Sourcegraph%{link_end} on your GitLab instance\'s code views and merge requests.').html_safe % { link_start: link_start, link_end: link_end }
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 ed809c6db52..4521f5bc0d9 100644
--- a/app/views/admin/application_settings/_third_party_offers.html.haml
+++ b/app/views/admin/application_settings/_third_party_offers.html.haml
@@ -5,7 +5,7 @@
= _('Customer experience improvement and third-party offers')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('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|
diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml
index 91cd6fe7ca0..0455394444c 100644
--- a/app/views/admin/application_settings/_usage.html.haml
+++ b/app/views/admin/application_settings/_usage.html.haml
@@ -36,7 +36,7 @@
label_options: { id: 'service_ping_features_label' }
.form-text.gl-text-gray-500.gl-pl-6
%p.gl-mb-3= s_('AdminSettings|Registration Features include:')
- - email_from_gitlab_path = help_page_path('user/admin_area/email_from_gitlab')
+ - email_from_gitlab_path = help_page_path('administration/email_from_gitlab')
- repo_size_limit_path = help_page_path('administration/settings/account_and_limit_settings', anchor: 'repository-size-limit')
- restrict_ip_path = help_page_path('user/group/access_and_permissions', anchor: 'restrict-group-access-by-ip-address')
- email_from_gitlab_link = link_start % { url: email_from_gitlab_path }
diff --git a/app/views/admin/application_settings/appearances/_form.html.haml b/app/views/admin/application_settings/appearances/_form.html.haml
index fb5c320268e..672af002e5e 100644
--- a/app/views/admin/application_settings/appearances/_form.html.haml
+++ b/app/views/admin/application_settings/appearances/_form.html.haml
@@ -3,146 +3,143 @@
= gitlab_ui_form_for @appearance, url: admin_application_settings_appearances_path, html: { class: 'gl-mt-3' } do |f|
= form_errors(@appearance)
- .row
- .col-lg-4
- %h4.gl-mt-0= _('Navigation bar')
-
- .col-lg-8
- .form-group
- = f.label :header_logo, _('Header logo'), class: 'col-form-label gl-pt-0'
- %p
- - if @appearance.header_logo?
- = image_tag @appearance.header_logo_path, class: 'appearance-light-logo-preview'
- - if @appearance.persisted?
- %br
- = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: header_logos_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Header logo will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove header logo') } }) do
- = _('Remove header logo')
- %hr
- = f.hidden_field :header_logo_cache
- = f.file_field :header_logo, class: "", accept: 'image/*'
- .form-text.text-muted
- = _('Maximum file size is 1MB. Pages are optimized for a 24px tall header logo')
- %hr
- .row
- .col-lg-4
- %h4.gl-mt-0 Favicon
-
- .col-lg-8
- .form-group
- = f.label :favicon, _('Favicon'), class: 'col-form-label gl-pt-0'
- %p
- - if @appearance.favicon?
- = image_tag @appearance.favicon_path, class: 'appearance-light-logo-preview'
- - if @appearance.persisted?
- %br
- = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: favicon_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Favicon will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove favicon') } }) do
- = _('Remove favicon')
- %hr
- = f.hidden_field :favicon_cache
- = f.file_field :favicon, class: '', accept: 'image/*'
- .form-text.text-muted
- = _("Maximum file size is 1 MB. Image size must be 32 x 32 pixels. Allowed image formats are %{favicon_extension_allowlist}.") % { favicon_extension_allowlist: favicon_extension_allowlist }
+ .settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0= _('Navigation bar')
+
+ .form-group
+ = f.label :header_logo, _('Header logo'), class: 'col-form-label gl-pt-0'
+ %p
+ - if @appearance.header_logo?
+ = image_tag @appearance.header_logo_path, class: 'appearance-light-logo-preview'
+ - if @appearance.persisted?
+ %br
+ = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: header_logos_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Header logo will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove header logo') } }) do
+ = _('Remove header logo')
+ %hr
+ = f.hidden_field :header_logo_cache
+ = f.file_field :header_logo, class: "", accept: 'image/*'
+ .form-text.text-muted
+ = _('Maximum file size is 1MB. Pages are optimized for a 24px tall header logo')
+
+ .settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0 Favicon
+
+ .form-group
+ = f.label :favicon, _('Favicon'), class: 'col-form-label gl-pt-0'
+ %p
+ - if @appearance.favicon?
+ = image_tag @appearance.favicon_path, class: 'appearance-light-logo-preview'
+ - if @appearance.persisted?
%br
- = _("Images with incorrect dimensions are not resized automatically, and may result in unexpected behavior.")
+ = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: favicon_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Favicon will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove favicon') } }) do
+ = _('Remove favicon')
+ %hr
+ = f.hidden_field :favicon_cache
+ = f.file_field :favicon, class: '', accept: 'image/*'
+ .form-text.text-muted
+ = _("Maximum file size is 1 MB. Image size must be 32 x 32 pixels. Allowed image formats are %{favicon_extension_allowlist}.") % { favicon_extension_allowlist: favicon_extension_allowlist }
+ %br
+ = _("Images with incorrect dimensions are not resized automatically, and may result in unexpected behavior.")
= render partial: 'admin/application_settings/appearances/system_header_footer_form', locals: { form: f }
- %hr
- .row
- .col-lg-4
- %h4.gl-mt-0= _('Sign in/Sign up pages')
-
- .col-lg-8
- .form-group
- = f.label :title, class: 'col-form-label'
- = f.text_field :title, class: "form-control gl-form-input"
- .form-group
- = f.label :description, class: 'col-form-label'
- = f.text_area :description, class: "form-control gl-form-input", rows: 10
+ .settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0= _('Sign in/Sign up pages')
+
+ .form-group
+ = f.label :title, class: 'col-form-label'
+ = f.text_field :title, class: "form-control gl-form-input"
+ .form-group
+ = f.label :description, class: 'col-form-label'
+ = f.text_area :description, class: "form-control gl-form-input", rows: 10
+ .form-text.text-muted
+ = parsed_with_gfm
+ .form-group
+ = f.label :logo, class: 'col-form-label gl-pt-0'
+ %p
+ - if @appearance.logo?
+ = image_tag @appearance.logo_path, class: 'appearance-logo-preview'
+ - if @appearance.persisted?
+ %br
+ = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: logo_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Logo will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove logo') } }) do
+ = _('Remove logo')
+ %hr
+ = f.hidden_field :logo_cache
+ = f.file_field :logo, class: "", accept: 'image/*'
+ .form-text.text-muted
+ = _('Maximum file size is 1 MB. Pages are optimized for a 128x128 px logo.')
+
+ .settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0= _('Progressive Web App (PWA)')
+
+ .form-group
+ = f.label _("Name"), class: 'col-form-label'
+ = f.text_field :pwa_name, class: "form-control gl-form-input"
+ .form-group
+ = f.label _("Short name"), class: 'col-form-label'
+ = f.text_field :pwa_short_name, class: "form-control gl-form-input"
+ .form-group
+ = f.label _("Description"), class: 'col-form-label'
+ = f.text_area :pwa_description, class: "form-control gl-form-input", rows: 10
+ .form-text.text-muted
+ = parsed_with_gfm
+ .form-group
+ = f.label :pwa_icon, class: 'col-form-label gl-pt-0'
+ %p
+ - if @appearance.pwa_icon?
+ = image_tag @appearance.pwa_icon_path, class: 'appearance-pwa-icon-preview'
+ - if @appearance.persisted?
+ %br
+ = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: pwa_icon_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Icon will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove icon') } }) do
+ = _('Remove icon')
+ %hr
+ = f.hidden_field :pwa_icon_cache
+ = f.file_field :pwa_icon, class: "", accept: 'image/*'
+ .form-text.text-muted
+ = _('Maximum file size is 1MB.')
+
+ .settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0= _('New project pages')
+
+ .form-group
+ = f.label :new_project_guidelines, class: 'col-form-label'
+ %p
+ = f.text_area :new_project_guidelines, class: "form-control gl-form-input", rows: 10
.form-text.text-muted
= parsed_with_gfm
- .form-group
- = f.label :logo, class: 'col-form-label gl-pt-0'
- %p
- - if @appearance.logo?
- = image_tag @appearance.logo_path, class: 'appearance-logo-preview'
- - if @appearance.persisted?
- %br
- = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: logo_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Logo will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove logo') } }) do
- = _('Remove logo')
- %hr
- = f.hidden_field :logo_cache
- = f.file_field :logo, class: "", accept: 'image/*'
- .form-text.text-muted
- = _('Maximum file size is 1 MB. Pages are optimized for a 128x128 px logo.')
-
- %hr
- .row
- .col-lg-4
- %h4.gl-mt-0= _('Progressive Web App (PWA)')
-
- .col-lg-8
- .form-group
- = f.label _("Name"), class: 'col-form-label'
- = f.text_field :pwa_name, class: "form-control gl-form-input"
- .form-group
- = f.label _("Short name"), class: 'col-form-label'
- = f.text_field :pwa_short_name, class: "form-control gl-form-input"
- .form-group
- = f.label _("Description"), class: 'col-form-label'
- = f.text_area :pwa_description, class: "form-control gl-form-input", rows: 10
+
+ .settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0= _('Profile image guideline')
+
+ .form-group
+ = f.label :profile_image_guidelines, class: 'col-form-label'
+ %p
+ = f.text_area :profile_image_guidelines, class: "form-control gl-form-input", rows: 10
.form-text.text-muted
= parsed_with_gfm
- .form-group
- = f.label :pwa_icon, class: 'col-form-label gl-pt-0'
- %p
- - if @appearance.pwa_icon?
- = image_tag @appearance.pwa_icon_path, class: 'appearance-pwa-icon-preview'
- - if @appearance.persisted?
- %br
- = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: pwa_icon_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Icon will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove icon') } }) do
- = _('Remove icon')
- %hr
- = f.hidden_field :pwa_icon_cache
- = f.file_field :pwa_icon, class: "", accept: 'image/*'
- .form-text.text-muted
- = _('Maximum file size is 1MB.')
-
- %hr
- .row
- .col-lg-4
- %h4.gl-mt-0= _('New project pages')
-
- .col-lg-8
- .form-group
- = f.label :new_project_guidelines, class: 'col-form-label'
- %p
- = f.text_area :new_project_guidelines, class: "form-control gl-form-input", rows: 10
- .form-text.text-muted
- = parsed_with_gfm
-
- %hr
- .row
- .col-lg-4
- %h4.gl-mt-0= _('Profile image guideline')
-
- .col-lg-8
- .form-group
- = f.label :profile_image_guidelines, class: 'col-form-label'
- %p
- = f.text_area :profile_image_guidelines, class: "form-control gl-form-input", rows: 10
- .form-text.text-muted
- = parsed_with_gfm
-
- .gl-mt-3.gl-mb-3
- = f.submit _('Update appearance settings'), pajamas_button: true
- - if @appearance.persisted? || @appearance.updated_at
- .mt-4
- - if @appearance.persisted?
- Preview last save:
- = link_to _('Sign-in page'), preview_sign_in_admin_application_settings_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer'
- = link_to _('New project page'), new_project_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer'
-
- - if @appearance.updated_at
- %span.float-right
- Last edit #{time_ago_with_tooltip(@appearance.updated_at)}
+
+ - if @appearance.persisted? || @appearance.updated_at
+ .settings-section
+ - if @appearance.persisted?
+ Preview last save:
+ = link_to _('Sign-in page'), preview_sign_in_admin_application_settings_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('New project page'), new_project_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer'
+
+ - if @appearance.updated_at
+ %span.float-right
+ Last edit #{time_ago_with_tooltip(@appearance.updated_at)}
+
+ .settings-sticky-footer
+ = f.submit _('Update appearance settings'), pajamas_button: true
diff --git a/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml b/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml
index 2ca037db532..61df5f5fd0d 100644
--- a/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml
+++ b/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml
@@ -1,30 +1,29 @@
- form = local_assigns.fetch(:form)
-%hr
-.row
- .col-lg-4
- %h4.gl-mt-0
- = _('System header and footer')
+.settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = _('System header and footer')
- .col-lg-8
- .form-group
- = form.label :header_message, _('Header message'), class: 'col-form-label label-bold'
- = form.text_area :header_message, placeholder: _('State your message to activate'), class: "form-control gl-form-input js-autosize"
- .form-group
- = form.label :footer_message, _('Footer message'), class: 'col-form-label label-bold'
- = form.text_area :footer_message, placeholder: _('State your message to activate'), class: "form-control gl-form-input js-autosize"
- .form-group
- = form.gitlab_ui_checkbox_component :email_header_and_footer_enabled,
- _('Enable header and footer in emails'),
- help_text: _('Add header and footer to emails. Please note that color settings will only be applied within the application interface'),
- label_options: { class: 'gl-font-weight-bold!' }
+ .form-group
+ = form.label :header_message, _('Header message'), class: 'col-form-label label-bold'
+ = form.text_area :header_message, placeholder: _('State your message to activate'), class: "form-control gl-form-input js-autosize"
+ .form-group
+ = form.label :footer_message, _('Footer message'), class: 'col-form-label label-bold'
+ = form.text_area :footer_message, placeholder: _('State your message to activate'), class: "form-control gl-form-input js-autosize"
+ .form-group
+ = form.gitlab_ui_checkbox_component :email_header_and_footer_enabled,
+ _('Enable header and footer in emails'),
+ help_text: _('Add header and footer to emails. Please note that color settings will only be applied within the application interface'),
+ label_options: { class: 'gl-font-weight-bold!' }
- .form-group.js-toggle-colors-container
- = render Pajamas::ButtonComponent.new(variant: :link, button_options: { class: 'js-toggle-colors-link' }) do
- = _('Customize colors')
- .form-group.js-toggle-colors-container.hide
- = form.label :message_background_color, _('Background Color'), class: 'col-form-label label-bold'
- = form.color_field :message_background_color, class: "form-control gl-form-input"
- .form-group.js-toggle-colors-container.hide
- = form.label :message_font_color, _('Font Color'), class: 'col-form-label label-bold'
- = form.color_field :message_font_color, class: "form-control gl-form-input"
+ .form-group.js-toggle-colors-container
+ = render Pajamas::ButtonComponent.new(variant: :link, button_options: { class: 'js-toggle-colors-link' }) do
+ = _('Customize colors')
+ .form-group.js-toggle-colors-container.hide
+ = form.label :message_background_color, _('Background Color'), class: 'col-form-label label-bold'
+ = form.color_field :message_background_color, class: "form-control gl-form-input"
+ .form-group.js-toggle-colors-container.hide
+ = form.label :message_font_color, _('Font Color'), class: 'col-form-label label-bold'
+ = form.color_field :message_font_color, class: "form-control gl-form-input"
diff --git a/app/views/admin/application_settings/ci/_header.html.haml b/app/views/admin/application_settings/ci/_header.html.haml
index 9e8caf0e0b7..1124277d5b3 100644
--- a/app/views/admin/application_settings/ci/_header.html.haml
+++ b/app/views/admin/application_settings/ci/_header.html.haml
@@ -6,15 +6,6 @@
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
-%p
+%p.gl-text-secondary
= _('Variables store information, like passwords and secret keys, that you can use in job scripts. All projects on the instance can use these variables.')
= link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'for-an-instance'), target: '_blank', rel: 'noopener noreferrer'
-%p
- = _('Variables can be:')
-%ul
- %li
- = html_escape(_('%{code_open}Protected:%{code_close} Only exposed to protected branches or protected tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
- = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'protect-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer'
- %li
- = html_escape(_('%{code_open}Masked:%{code_close} Hidden in job logs. Must match masking requirements.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
- = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'mask-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/admin/application_settings/ci_cd.html.haml b/app/views/admin/application_settings/ci_cd.html.haml
index a9a16f72ebe..addd23688b4 100644
--- a/app/views/admin/application_settings/ci_cd.html.haml
+++ b/app/views/admin/application_settings/ci_cd.html.haml
@@ -7,10 +7,20 @@
.settings-header
= render 'admin/application_settings/ci/header', expanded: expanded_by_default?
.settings-content
+ %p
+ = _('Variables can be:')
+ %ul
+ %li
+ = html_escape(_('%{code_open}Protected:%{code_close} Only exposed to protected branches or protected tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
+ = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'protect-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer'
+ %li
+ = html_escape(_('%{code_open}Masked:%{code_close} Hidden in job logs. Must match masking requirements.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
+ = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'mask-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer'
+
- if ci_variable_protected_by_default?
- %p.settings-message.text-center
- - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/index', anchor: 'protect-a-cicd-variable') }
- = s_('Environment variables on this GitLab instance are configured to be %{link_start}protected%{link_end} by default.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+ %p.settings-message.text-center.gl-mb-0
+ - help_link = link_to('', help_page_path('ci/variables/index', anchor: 'protect-a-cicd-variable', target: '_blank', rel: 'noopener noreferrer'))
+ = safe_format(s_('Environment variables on this GitLab instance are configured to be %{help_link_start}protected%{help_link_end} by default.'), tag_pair(help_link, :help_link_start, :help_link_end))
#js-instance-variables{ data: { endpoint: admin_ci_variables_path, maskable_regex: ci_variable_maskable_regex, protected_by_default: ci_variable_protected_by_default?.to_s} }
%section.settings.as-ci-cd.no-animate#js-ci-cd-settings{ class: ('expanded' if expanded_by_default?) }
@@ -19,7 +29,7 @@
= _('Continuous Integration and Deployment')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Customize CI/CD settings, including Auto DevOps, shared runners, and job artifacts.')
= render 'ci_cd'
@@ -34,7 +44,7 @@
= _('Container Registry')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Various container registry settings.')
.settings-content
= render 'registry'
diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml
index 2d56e9dd0dd..6ae9c58ffcd 100644
--- a/app/views/admin/application_settings/general.html.haml
+++ b/app/views/admin/application_settings/general.html.haml
@@ -9,7 +9,7 @@
= _('Visibility and access controls')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Set visibility of project contents. Configure import sources and Git access protocols.')
.settings-content
= render 'visibility_and_access'
@@ -20,20 +20,18 @@
= _('Account and limit')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Set projects and maximum size limits, session duration, user options, and check feature availability for namespace plan.')
.settings-content
= render 'account_and_limit'
-= render_if_exists 'admin/application_settings/free_user_cap'
-
%section.settings.as-diff-limits.no-animate#js-merge-request-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Diff limits')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Set size limits for displaying diffs in the browser.')
.settings-content
= render 'diff_limits'
@@ -44,7 +42,7 @@
= _('Sign-up restrictions')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Configure the way a user creates a new account.')
.settings-content
= render 'signup'
@@ -55,9 +53,9 @@
= _('Sign-in restrictions')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Set sign-in restrictions for all users.')
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/sign_in_restrictions.md'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('administration/settings/sign_in_restrictions.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'signin'
@@ -67,14 +65,15 @@
= _('Terms of Service and Privacy Policy')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Add a Terms of Service agreement and Privacy Policy for users of this GitLab instance.')
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/terms.md'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('administration/settings/terms.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'terms'
= render 'admin/application_settings/external_authorization_service_form', expanded: expanded_by_default?
+= render_if_exists 'admin/application_settings/microsoft_application'
= render_if_exists 'admin/application_settings/scim'
%section.settings.as-terminal.no-animate#js-terminal-settings{ class: ('expanded' if expanded_by_default?) }
@@ -83,7 +82,7 @@
= _('Web terminal')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Set the maximum session time for a web terminal.')
= link_to _('How do I use a web terminal?'), help_page_path('ci/environments/index.md', anchor: 'web-terminals-deprecated'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
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 8bc5d5cbaa6..4739a204147 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 @@
= _('Metrics - Prometheus')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Monitor GitLab with Prometheus.')
.settings-content
= render 'prometheus'
@@ -22,7 +22,7 @@
= _('Metrics - Grafana')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Link to your Grafana instance.')
= link_to _('Learn more.'), help_page_path('administration/monitoring/performance/grafana_configuration.md'), target: '_blank', rel: 'noopener noreferrer'
@@ -35,7 +35,7 @@
= _('Profiling - Performance bar')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Enable access to the performance bar for non-administrators in a given group.')
= link_to _('Learn more.'), help_page_path('administration/monitoring/performance/performance_bar.md', anchor: 'enable-the-performance-bar-for-non-administrators'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
@@ -47,7 +47,7 @@
= _('Usage statistics')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Enable or disable version check and Service Ping.')
.settings-content
= render 'usage'
@@ -59,7 +59,7 @@
= _('Sentry')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Configure Sentry integration for error tracking')
.settings-content
= render 'sentry'
diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml
index 3b9fb930fd7..9ccfc6cbc0a 100644
--- a/app/views/admin/application_settings/network.html.haml
+++ b/app/views/admin/application_settings/network.html.haml
@@ -9,7 +9,7 @@
= _('Performance optimization')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Various settings that affect GitLab performance.')
.settings-content
= render 'performance'
@@ -20,9 +20,9 @@
= _('User and IP rate limits')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Set limits for web and API requests.')
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/user_and_ip_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('administration/settings/user_and_ip_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'ip_limits'
@@ -32,9 +32,9 @@
= _('Package registry rate limits')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Set rate limits for package registry API requests that supersede the general user and IP rate limits.')
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/package_registry_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('administration/settings/package_registry_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render partial: 'network_rate_limits', locals: { anchor: 'js-packages-limits-settings', setting_fragment: 'packages_api' }
@@ -44,7 +44,7 @@
= _('Files API Rate Limits')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Configure specific limits for Files API requests that supersede the general user and IP rate limits.')
.settings-content
= render partial: 'network_rate_limits', locals: { anchor: 'js-files-limits-settings', setting_fragment: 'files_api' }
@@ -55,7 +55,7 @@
= _('Search rate limits')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Set rate limits for searches performed by web or API requests.')
.settings-content
= render 'search_limits'
@@ -66,9 +66,9 @@
= _('Deprecated API rate limits')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Configure specific limits for deprecated API requests that supersede the general user and IP rate limits.')
- = link_to _('Which API requests are affected?'), help_page_path('user/admin_area/settings/deprecated_api_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Which API requests are affected?'), help_page_path('administration/settings/deprecated_api_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render partial: 'network_rate_limits', locals: { anchor: 'js-deprecated-limits-settings', setting_fragment: 'deprecated_api' }
@@ -78,9 +78,9 @@
= _('Git LFS Rate Limits')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Configure specific limits for Git LFS requests that supersede the general user and IP rate limits.')
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/git_lfs_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('administration/settings/git_lfs_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'git_lfs_limits'
@@ -94,7 +94,7 @@
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= s_('OutboundRequests|Allow requests to the local network from hooks and integrations.')
= link_to _('Learn more.'), help_page_path('security/webhooks.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
@@ -106,9 +106,9 @@
= _('Protected paths')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Rate limit access to specified paths.')
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/protected_paths.md'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('administration/settings/protected_paths.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'protected_paths'
@@ -119,9 +119,9 @@
= _('Issues Rate Limits')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Limit the number of issues and epics per minute a user can create through web and API requests.')
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/rate_limit_on_issues_creation.md'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('administration/settings/rate_limit_on_issues_creation.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'issue_limits'
@@ -131,9 +131,9 @@
= _('Notes rate limit')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Set the per-user rate limit for notes created by web or API requests.')
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/rate_limit_on_notes_creation.md'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('administration/settings/rate_limit_on_notes_creation.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'note_limits'
@@ -143,9 +143,9 @@
= _('Users API rate limit')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Set the per-user rate limit for getting a user by ID via the API.')
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/rate_limit_on_users_api.md'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('administration/settings/rate_limit_on_users_api.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'users_api_limits'
@@ -157,9 +157,9 @@
= _('Import and export rate limits')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Set per-user rate limits for imports and exports of projects and groups.')
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/import_export_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('administration/settings/import_export_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'import_export_limits'
@@ -169,9 +169,9 @@
= _('Pipeline creation rate limits')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Limit the number of pipeline creation requests per minute. This limit includes pipelines created through the UI, the API, and by background processing.')
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/rate_limit_on_pipelines_creation.md'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('administration/settings/rate_limit_on_pipelines_creation.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'pipeline_limits'
diff --git a/app/views/admin/application_settings/preferences.html.haml b/app/views/admin/application_settings/preferences.html.haml
index ab59e05c10f..bea399ee926 100644
--- a/app/views/admin/application_settings/preferences.html.haml
+++ b/app/views/admin/application_settings/preferences.html.haml
@@ -9,7 +9,7 @@
= _('Email')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Various email settings.')
.settings-content
= render 'email'
@@ -20,7 +20,7 @@
= _("What's new")
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _("Configure %{italic_start}What's new%{italic_end} drawer and content.").html_safe % { italic_start: '<i>'.html_safe, italic_end: '</i>'.html_safe }
.settings-content
= render 'whats_new'
@@ -31,9 +31,9 @@
= _('Sign-in and Help page')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Additional text for the sign-in and Help page.')
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/help_page.md'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('administration/settings/help_page.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'help_page'
@@ -43,7 +43,7 @@
= _('Pages')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= s_('AdminSettings|Size and domain settings for Pages static sites.')
.settings-content
= render 'pages'
@@ -54,7 +54,7 @@
= _('Polling interval multiplier')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Adjust how frequently the GitLab UI polls for updates.')
= link_to _('Learn more.'), help_page_path('administration/polling.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
@@ -66,10 +66,10 @@
= _('Gitaly timeouts')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Configure Gitaly timeouts.')
%span
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/gitaly_timeouts.md'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('administration/settings/gitaly_timeouts.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'gitaly'
@@ -79,7 +79,7 @@
= _('Localization')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Configure the default first day of the week, time tracking units, and default language.')
.settings-content
= render 'localization'
@@ -90,10 +90,10 @@
= _('Sidekiq job size limits')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Limit the size of Sidekiq jobs stored in Redis.')
%span
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/sidekiq_job_limits.md'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('administration/settings/sidekiq_job_limits.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'sidekiq_job_limits'
@@ -104,8 +104,8 @@
= s_('TerraformLimits|Terraform limits')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= s_('TerraformLimits|Limits for Terraform features')
- = link_to s_('TerraformLimits|Learn more about Terraform limits.'), help_page_path('user/admin_area/settings/terraform_limits.md'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to s_('TerraformLimits|Learn more about Terraform limits.'), help_page_path('administration/settings/terraform_limits.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'terraform_limits'
diff --git a/app/views/admin/application_settings/reporting.html.haml b/app/views/admin/application_settings/reporting.html.haml
index 6ea2fb80505..91fabb505c2 100644
--- a/app/views/admin/application_settings/reporting.html.haml
+++ b/app/views/admin/application_settings/reporting.html.haml
@@ -9,7 +9,7 @@
= _('Spam and Anti-bot Protection')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Configure CAPTCHAs, IP address limits, and other anti-spam measures.')
.settings-content
= render 'spam'
@@ -23,9 +23,9 @@
= _('Abuse reports')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Receive notification of abuse reports by email.')
- = link_to _('Learn more.'), help_page_path('user/admin_area/review_abuse_reports.md'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('administration/review_abuse_reports.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'abuse'
diff --git a/app/views/admin/application_settings/repository.html.haml b/app/views/admin/application_settings/repository.html.haml
index 1907544ea14..c7a2fca00ef 100644
--- a/app/views/admin/application_settings/repository.html.haml
+++ b/app/views/admin/application_settings/repository.html.haml
@@ -9,7 +9,7 @@
= _('Default branch')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= s_('AdminSettings|Set the initial name and protections for the default branch of new repositories created in the instance.')
.settings-content
= render 'default_branch'
@@ -20,7 +20,7 @@
= _('Repository mirroring')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? 'Collapse' : 'Expand'
- %p
+ %p.gl-text-secondary
= _('Configure repository mirroring.')
= link_to _('Learn more.'), help_page_path('user/project/repository/mirror/index.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
@@ -32,7 +32,7 @@
= _('Repository storage')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Configure repository storage.')
= link_to _('Learn more.'), help_page_path('administration/repository_storage_paths.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
@@ -44,7 +44,7 @@
= _('Repository maintenance')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
- repository_checks_link_url = help_page_path('administration/repository_checks.md')
- repository_checks_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: repository_checks_link_url }
- housekeeping_link_url = help_page_path('administration/housekeeping.md')
@@ -59,7 +59,7 @@
= _('External storage for repository static objects')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Serve repository static objects (for example, archives and blobs) from external storage.')
= link_to _('Learn more.'), help_page_path('administration/static_objects_external_storage.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
diff --git a/app/views/admin/application_settings/service_usage_data.html.haml b/app/views/admin/application_settings/service_usage_data.html.haml
index 634d006e736..9f73099465c 100644
--- a/app/views/admin/application_settings/service_usage_data.html.haml
+++ b/app/views/admin/application_settings/service_usage_data.html.haml
@@ -23,9 +23,7 @@
title: _('Service Ping payload not found in the application cache')) do |c|
- c.with_body do
- - enable_service_ping_link_url = help_page_path('administration/settings/usage_statistics', anchor: 'enable-or-disable-usage-statistics')
- - enable_service_ping_link = '<a href="%{url}">'.html_safe % { url: enable_service_ping_link_url }
- - generate_manually_link_url = help_page_path('development/internal_analytics/service_ping/troubleshooting', anchor: 'generate-service-ping')
- - generate_manually_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: generate_manually_link_url }
+ - enable_service_ping_link = link_to('', help_page_path('administration/settings/usage_statistics', anchor: 'enable-or-disable-usage-statistics'), target: '_blank', rel: 'noopener noreferrer')
+ - generate_manually_link = link_to('', help_page_path('development/internal_analytics/service_ping/troubleshooting', anchor: 'generate-service-ping'), target: '_blank', rel: 'noopener noreferrer')
- = html_escape(s_('%{enable_service_ping_link_start}Enable%{link_end} or %{generate_manually_link_start}generate%{link_end} Service Ping to preview and download service usage data payload.')) % { enable_service_ping_link_start: enable_service_ping_link, generate_manually_link_start: generate_manually_link, link_end: '</a>'.html_safe }
+ = safe_format(s_('%{enable_service_ping_link_start}Enable%{enable_service_ping_link_end} or %{generate_manually_link_start}generate%{generate_manually_link_end} Service Ping to preview and download service usage data payload.'), tag_pair(enable_service_ping_link, :enable_service_ping_link_start, :enable_service_ping_link_end), tag_pair(generate_manually_link, :generate_manually_link_start, :generate_manually_link_end))
diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml
index e32a50e252d..6846fe8f4aa 100644
--- a/app/views/admin/applications/index.html.haml
+++ b/app/views/admin/applications/index.html.haml
@@ -1,52 +1,51 @@
- page_title s_('AdminArea|Instance OAuth applications')
-%h1.page-title.gl-font-size-h-display
- = s_('AdminArea|Instance OAuth applications')
-%p.light
- - docs_link_path = help_page_path('integration/oauth_provider')
- - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer nofollow">'.html_safe % { url: docs_link_path }
- = s_('AdminArea|Manage applications for your instance that can use GitLab as an %{docs_link_start}OAuth provider%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe }
+= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper.gl-flex-direction-column
+ %h3.gl-new-card-title
+ = s_('AdminArea|Instance OAuth applications')
+ .gl-new-card-count
+ = sprite_icon('applications', css_class: 'gl-mr-2')
+ = @applications.size
+ %p.gl-new-card-description
+ - docs_link_path = help_page_path('integration/oauth_provider')
+ - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer nofollow">'.html_safe % { url: docs_link_path }
+ = s_('AdminArea|Manage applications for your instance that can use GitLab as an %{docs_link_start}OAuth provider%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe }
-- if @applications.empty?
- %section.empty-state.gl-text-center.gl-display-flex.gl-flex-direction-column
- .svg-content.svg-150
- = image_tag 'illustrations/empty-state/empty-admin-apps-md.svg', class: 'gl-max-w-full'
+ .gl-new-card-actions
+ = render Pajamas::ButtonComponent.new(size: :small, href: new_admin_application_path, button_options: { data: { qa_selector: 'new_application_button' } }) do
+ = _('Add new application')
+ - c.with_body do
+ - if @applications.empty?
+ %section.empty-state.gl-my-5.gl-text-center.gl-display-flex.gl-flex-direction-column
+ .svg-content.svg-150
+ = image_tag 'illustrations/empty-state/empty-admin-apps-md.svg', class: 'gl-max-w-full'
- .gl-max-w-full.gl-m-auto
- %h1.h4.gl-font-size-h-display= s_('AdminArea|No applications found')
- = render Pajamas::ButtonComponent.new(href: new_admin_application_path, variant: :confirm, button_options: { data: { qa_selector: 'new_application_button' } }) do
- = _('New application')
-
-- else
- %hr
- = render Pajamas::ButtonComponent.new(href: new_admin_application_path, variant: :confirm, button_options: { data: { qa_selector: 'new_application_button' } }) do
- = _('New application')
-
- .table-responsive
- %table.b-table.gl-table.gl-w-full{ role: 'table' }
- %thead
- %tr
- %th
- = _('Name')
- %th
- = _('Callback URL')
- %th
- = _('Trusted')
- %th
- = _('Confidential')
- %th
- %th
- %tbody.oauth-applications
- - @applications.each do |application|
- %tr{ id: "application_#{application.id}" }
- %td= link_to application.name, admin_application_path(application)
- %td= application.redirect_uri
- %td= application.trusted? ? _('Yes'): _('No')
- %td= application.confidential? ? _('Yes'): _('No')
- %td
- = render Pajamas::ButtonComponent.new(href: edit_admin_application_path(application), variant: :link) do
- = _('Edit')
- %td= render 'delete_form', application: application
+ .gl-max-w-full.gl-m-auto
+ %h1.h4.gl-font-size-h-display= s_('AdminArea|No applications found')
+ %p.gl-text-secondary.gl-mt-3= s_('AdminArea|Manage applications for your instance that can use GitLab as an OAuth provider, start by creating a new one above.')
+ - else
+ .table-holder
+ %table.table.b-table.gl-table.b-table-stacked-md{ role: 'table' }
+ %thead
+ %tr
+ %th= _('Name')
+ %th= _('Callback URL')
+ %th= _('Trusted')
+ %th= _('Confidential')
+ %th= _('Actions')
+ %tbody.oauth-applications
+ - @applications.each do |application|
+ %tr{ id: "application_#{application.id}" }
+ %td{ data: { label: _('Name') } }= link_to application.name, admin_application_path(application)
+ %td{ data: { label: _('Callback URL') } }= application.redirect_uri
+ %td{ data: { label: _('Trusted') } }= application.trusted? ? _('Yes'): _('No')
+ %td{ data: { label: _('Confidential') } }= application.confidential? ? _('Yes'): _('No')
+ %td{ data: { label: _('Actions') } }
+ = render Pajamas::ButtonComponent.new(href: edit_admin_application_path(application), size: :small, button_options: { class: 'gl-mr-3' }) do
+ = _('Edit')
+ = render 'delete_form', application: application
= paginate @applications, theme: 'gitlab'
diff --git a/app/views/admin/deploy_keys/new.html.haml b/app/views/admin/deploy_keys/new.html.haml
index a03d6cb5a94..3d73b255a5e 100644
--- a/app/views/admin/deploy_keys/new.html.haml
+++ b/app/views/admin/deploy_keys/new.html.haml
@@ -1,11 +1,9 @@
- page_title _('New Deploy Key')
%h1.page-title.gl-font-size-h-display= _('New public deploy key')
-%hr
-%div
- = gitlab_ui_form_for [:admin, @deploy_key], html: { class: 'deploy-key-form' } do |f|
- = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key }
- .form-actions
- = f.submit 'Create', data: { qa_selector: "add_deploy_key_button" }, pajamas_button: true
- = render Pajamas::ButtonComponent.new(href: admin_deploy_keys_path) do
- = _('Cancel')
+= gitlab_ui_form_for [:admin, @deploy_key], html: { class: 'deploy-key-form' } do |f|
+ = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key }
+ .gl-display-flex.gl-mt-6.gl-gap-3
+ = f.submit 'Create', data: { qa_selector: "add_deploy_key_button" }, pajamas_button: true
+ = render Pajamas::ButtonComponent.new(href: admin_deploy_keys_path) do
+ = _('Cancel')
diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml
index 662234bf56a..728c748d01a 100644
--- a/app/views/admin/health_check/show.html.haml
+++ b/app/views/admin/health_check/show.html.haml
@@ -12,7 +12,7 @@
= _("Reset health check access token")
%p.light
#{ _('Health information can be retrieved from the following endpoints. More information is available') }
- = link_to s_('More information is available|here'), help_page_path('user/admin_area/monitoring/health_check')
+ = link_to s_('More information is available|here'), help_page_path('administration/monitoring/health_check')
%ul
%li
%code= readiness_url(token: Gitlab::CurrentSettings.health_check_access_token)
diff --git a/app/views/admin/impersonation_tokens/index.html.haml b/app/views/admin/impersonation_tokens/index.html.haml
index 0208b8ad836..37dd0a68f47 100644
--- a/app/views/admin/impersonation_tokens/index.html.haml
+++ b/app/views/admin/impersonation_tokens/index.html.haml
@@ -6,11 +6,24 @@
= render 'admin/users/head'
-.row.gl-mt-3
- .col-lg-12
- #js-new-access-token-app{ data: { access_token_type: type } }
+#js-new-access-token-app{ data: { access_token_type: type } }
- = render 'shared/access_tokens/form',
+= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card gl-border-b-0 gl-rounded-bottom-left-none gl-rounded-bottom-right-none js-toggle-container js-token-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper.gl-flex-direction-column
+ %h3.gl-new-card-title
+ = _('Impersonation tokens')
+ .gl-new-card-count
+ = sprite_icon('token', css_class: 'gl-mr-2')
+ %span.js-token-count= @active_impersonation_tokens.size
+ .gl-new-card-description
+ = _("To see all the user's personal access tokens you must impersonate them first.")
+ .gl-new-card-actions
+ = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-toggle-button js-toggle-content', data: { testid: 'add-new-token-button' } }) do
+ = _('Add new token')
+ - c.with_body do
+ .gl-new-card-add-form.gl-mt-3.gl-display-none.js-toggle-content.js-add-new-token-form
+ = render 'shared/access_tokens/form',
ajax: true,
type: type,
title: _('Add an impersonation token'),
@@ -20,4 +33,4 @@
scopes: @scopes,
help_path: help_page_path('api/rest/index', anchor: 'impersonation-tokens')
- #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_impersonation_tokens.to_json, information: _("To see all the user's personal access tokens you must impersonate them first.") } }
+#js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_impersonation_tokens.to_json } }
diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml
index e643ec040a1..3d392a86566 100644
--- a/app/views/admin/labels/index.html.haml
+++ b/app/views/admin/labels/index.html.haml
@@ -1,20 +1,23 @@
- page_title _("Labels")
-.gl-sm-display-flex.gl-border-bottom-0.gl-mt-4.gl-lg-align-items-center
- .gl-text-gray-600.gl-flex-grow-1
- = s_('AdminLabels|Labels created here will be automatically added to new projects.')
- .nav-controls.gl-mt-2.gl-sm-mt-0.gl-display-flex.gl-align-items-center
- = render Pajamas::ButtonComponent.new(variant: :confirm,
- href: new_admin_label_path) do
- = _('New label')
-
-.labels.labels-container.admin-labels.js-admin-labels-container.gl-mt-4
- .other-labels.gl-rounded-base.gl-border.gl-bg-gray-10
- .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-top-base.gl-border-b
- %h3.card-title.h5.gl-m-0.gl-relative.gl-line-height-24
+= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card labels other-labels js-toggle-container js-admin-labels-container' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper.gl-flex-direction-column
+ %h5.gl-new-card-title
= _('Labels')
- %ul.manage-labels-list.js-other-labels.gl-px-3.gl-rounded-base
- - if @labels.present?
+ .gl-new-card-count
+ = sprite_icon('label', css_class: 'gl-mr-2')
+ %span.js-admin-labels-count= @labels.count
+ .gl-new-card-description
+ = s_('AdminLabels|Labels created here will be automatically added to new projects.')
+ .gl-new-card-actions
+ = render Pajamas::ButtonComponent.new(variant: :default,
+ size: :small,
+ href: new_admin_label_path) do
+ = _('New label')
+ - c.with_body do
+ - if @labels.present?
+ %ul.manage-labels-list.js-other-labels.gl-px-3.gl-rounded-base
= render @labels
.js-admin-labels-empty-state{ class: ('gl-display-none' if @labels.present?) }
%section.row.empty-state.gl-text-center
@@ -25,10 +28,7 @@
.gl-mx-auto.gl-my-0.gl-p-5
%h1.gl-font-size-h-display.gl-line-height-36.h4
= s_('AdminLabels|Define your default set of project labels')
- %p
+ %p.gl-text-secondary
= s_('AdminLabels|They can be used to categorize issues and merge requests.')
- .gl-display-flex.gl-flex-wrap.gl-justify-content-center
- = render Pajamas::ButtonComponent.new(href: new_admin_label_path) do
- = _('New label')
- = paginate @labels, theme: 'gitlab'
+.gl-mt-5= paginate @labels, theme: 'gitlab'
diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml
index 0637b0eae47..85dce00752b 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -81,14 +81,20 @@
- if @project.repository.exists?
%li{ class: 'gl-px-5!' }
%span.light
- = _('Gitaly storage name:')
+ = s_('ProjectSettings|Storage name:')
%strong
= @project.repository.storage
+ %br
+ %small.gl-text-secondary
+ = s_('ProjectSettings|For Gitaly, name of the storage that stores the repository. For Gitaly Cluster, name of the virtual storage that stores the repository.')
%li{ class: 'gl-px-5!' }
%span.light
- = _('Gitaly relative path:')
+ = s_('ProjectSettings|Relative path:')
%strong
= @project.repository.relative_path
+ %br
+ %small.gl-text-secondary
+ = s_('ProjectSettings|For Gitaly, location of data on the storage. For Gitaly Cluster, location of data on the virtual storage.')
%li{ class: 'gl-px-5!' }
= render 'shared/storage_counter_statistics', storage_size: @project.statistics&.storage_size, storage_details: @project.statistics
@@ -125,8 +131,10 @@
%span.light
= _('access:')
%strong
- %span{ class: visibility_level_color(@project.visibility_level) }
- = visibility_level_icon(@project.visibility_level)
+ = visibility_level_content(@project, css_class: visibility_level_color(@project.visibility_level))
+ - if @project.created_and_owned_by_banned_user? && Feature.enabled?(:hide_projects_of_banned_users)
+ = _('This project is hidden because its creator has been banned')
+ - else
= visibility_level_label(@project.visibility_level)
= render 'shared/custom_attributes', custom_attributes: @project.custom_attributes
diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml
index 472ba2f84a0..4979f7e28e7 100644
--- a/app/views/admin/users/_access_levels.html.haml
+++ b/app/views/admin/users/_access_levels.html.haml
@@ -1,49 +1,49 @@
-.gl-border-b.gl-pb-3.gl-mb-6
- .row
- .col-lg-4
- %h4.gl-mt-0
+.settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
= s_('AdminUsers|Access')
- .col-lg-8
- .form-group.gl-form-group{ role: 'group' }
- = f.label :projects_limit, class: 'gl-display-block col-form-label'
- = f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control gl-form-input'
-
- .form-group.gl-form-group{ role: 'group' }
- = f.gitlab_ui_checkbox_component :can_create_group, s_('AdminUsers|Can create group')
- = f.gitlab_ui_checkbox_component :private_profile, s_('AdminUsers|Private profile')
-
- %fieldset.form-group.gl-form-group
- %legend.col-form-label.col-form-label
- = s_('AdminUsers|Access level')
- - editing_current_user = (current_user == @user)
-
- = f.gitlab_ui_radio_component :access_level, :regular,
- s_('AdminUsers|Regular'),
- radio_options: { disabled: editing_current_user },
- help_text: s_('AdminUsers|Regular users have access to their groups and projects.')
-
- = render_if_exists 'admin/users/auditor_access_level_radio', f: f, disabled: editing_current_user
-
- - help_text = s_('AdminUsers|The user has unlimited access to all groups, projects, users, and features.')
- - help_text += ' ' + s_('AdminUsers|You cannot remove your own administrator access.') if editing_current_user
- = f.gitlab_ui_radio_component :access_level, :admin,
- s_('AdminUsers|Administrator'),
- radio_options: { disabled: editing_current_user },
- help_text: help_text
-
- .form-group.gl-form-group{ role: 'group' }
- = f.gitlab_ui_checkbox_component :external,
- s_('AdminUsers|External'),
- help_text: s_('AdminUsers|External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects, groups, or personal snippets.')
- .hidden{ data: user_internal_regex_data }
- .gl-display-flex.gl-align-items-baseline
- %row.hidden#warning_external_automatically_set
- = gl_badge_tag s_('AdminUsers|Automatically marked as default internal user'), variant: :warning
-
- .form-group.gl-form-group{ role: 'group' }
- - @user.credit_card_validation || @user.build_credit_card_validation
- = f.fields_for :credit_card_validation do |ff|
- = ff.gitlab_ui_checkbox_component :credit_card_validated_at,
- s_('AdminUsers|Validate user account'),
- help_text: s_('AdminUsers|A user can validate themselves by inputting a credit/debit card, or an admin can manually validate a user. Validated users can use free CI minutes on shared runners.'),
- checkbox_options: { checked: @user.credit_card_validated_at.present? }
+
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :projects_limit, class: 'gl-display-block col-form-label'
+ = f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control gl-form-input gl-form-input-sm'
+
+ .form-group.gl-form-group{ role: 'group' }
+ = f.gitlab_ui_checkbox_component :can_create_group, s_('AdminUsers|Can create group')
+ = f.gitlab_ui_checkbox_component :private_profile, s_('AdminUsers|Private profile')
+
+ %fieldset.form-group.gl-form-group
+ %legend.col-form-label.col-form-label
+ = s_('AdminUsers|Access level')
+ - editing_current_user = (current_user == @user)
+
+ = f.gitlab_ui_radio_component :access_level, :regular,
+ s_('AdminUsers|Regular'),
+ radio_options: { disabled: editing_current_user },
+ help_text: s_('AdminUsers|Regular users have access to their groups and projects.')
+
+ = render_if_exists 'admin/users/auditor_access_level_radio', f: f, disabled: editing_current_user
+
+ - help_text = s_('AdminUsers|The user has unlimited access to all groups, projects, users, and features.')
+ - help_text += ' ' + s_('AdminUsers|You cannot remove your own administrator access.') if editing_current_user
+ = f.gitlab_ui_radio_component :access_level, :admin,
+ s_('AdminUsers|Administrator'),
+ radio_options: { disabled: editing_current_user },
+ help_text: help_text
+
+ .form-group.gl-form-group{ role: 'group' }
+ = f.gitlab_ui_checkbox_component :external,
+ s_('AdminUsers|External'),
+ help_text: s_('AdminUsers|External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects, groups, or personal snippets.')
+ .hidden{ data: user_internal_regex_data }
+ .gl-display-flex.gl-align-items-baseline
+ %row.hidden#warning_external_automatically_set
+ = gl_badge_tag s_('AdminUsers|Automatically marked as default internal user'), variant: :warning
+
+ .form-group.gl-form-group{ role: 'group' }
+ - @user.credit_card_validation || @user.build_credit_card_validation
+ = f.fields_for :credit_card_validation do |ff|
+ = ff.gitlab_ui_checkbox_component :credit_card_validated_at,
+ s_('AdminUsers|Validate user account'),
+ help_text: s_('AdminUsers|A user can validate themselves by inputting a credit/debit card, or an admin can manually validate a user. Validated users can use free CI minutes on shared runners.'),
+ checkbox_options: { checked: @user.credit_card_validated_at.present? }
diff --git a/app/views/admin/users/_admin_notes.html.haml b/app/views/admin/users/_admin_notes.html.haml
index dce008afb26..85796246c83 100644
--- a/app/views/admin/users/_admin_notes.html.haml
+++ b/app/views/admin/users/_admin_notes.html.haml
@@ -1,9 +1,9 @@
-.gl-mb-3
- .row
- .col-lg-4
- %h4.gl-mt-0
+.settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
= _('Admin notes')
- .col-lg-8
- .form-group.gl-form-group{ role: 'group' }
- = f.label :note, s_('Admin|Note')
- = f.text_area :note, class: 'form-control gl-form-input gl-form-textarea'
+
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :note, s_('Admin|Note')
+ = f.text_area :note, class: 'form-control gl-form-input gl-form-textarea'
diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml
index 8822d52c3c0..ffe7e128d60 100644
--- a/app/views/admin/users/_form.html.haml
+++ b/app/views/admin/users/_form.html.haml
@@ -2,42 +2,42 @@
= gitlab_ui_form_for [:admin, @user], html: { class: 'fieldset-form' } do |f|
= form_errors(@user)
- .gl-border-b.gl-pb-3.gl-mb-6
- .row
- .col-lg-4
- %h4.gl-mt-0
+ .settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
= _('Account')
- .col-lg-8
- .form-group.gl-form-group{ role: 'group' }
- = f.label :name, _('Name'), class: 'gl-display-block col-form-label'
- = f.text_field :name, required: true, autocomplete: 'off', class: 'form-control gl-form-input'
-
- .form-group.gl-form-group{ role: 'group' }
- = f.label :username, _('Username'), class: 'gl-display-block col-form-label'
- = f.text_field :username, required: true, autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: false, class: 'form-control gl-form-input'
-
- .form-group.gl-form-group{ role: 'group' }
- = f.label :email, _('Email'), class: 'gl-display-block col-form-label'
- = f.text_field :email, required: true, autocomplete: 'off', class: 'form-control gl-form-input'
-
- .gl-border-b.gl-pb-3.gl-mb-6
- .row
- .col-lg-4
- %h4.gl-mt-0
+
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :name, _('Name'), class: 'gl-display-block col-form-label'
+ = f.text_field :name, required: true, autocomplete: 'off', class: 'form-control gl-form-input gl-form-input-lg'
+
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :username, _('Username'), class: 'gl-display-block col-form-label'
+ = f.text_field :username, required: true, autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: false, class: 'form-control gl-form-input gl-form-input-lg'
+
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :email, _('Email'), class: 'gl-display-block col-form-label'
+ = f.text_field :email, required: true, autocomplete: 'off', class: 'form-control gl-form-input gl-form-input-lg'
+
+ .settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
= _('Password')
- .col-lg-8
- - if @user.new_record?
- = render Pajamas::AlertComponent.new(variant: :info, dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c|
- - c.with_body do
- = s_('AdminUsers|Reset link will be generated and sent to the user. User will be forced to set the password on first sign in.')
- - else
- .form-group.gl-form-group{ role: 'group' }
- = f.label :password, _('Password'), class: 'gl-display-block col-form-label'
- = f.password_field :password, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation'
- = render_if_exists 'shared/password_requirements_list'
- .form-group.gl-form-group{ role: 'group' }
- = f.label :password_confirmation, _('Password confirmation'), class: 'gl-display-block col-form-label'
- = f.password_field :password_confirmation, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input'
+
+ - if @user.new_record?
+ = render Pajamas::AlertComponent.new(variant: :info, dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c|
+ - c.with_body do
+ = s_('AdminUsers|Reset link will be generated and sent to the user. User will be forced to set the password on first sign in.')
+ - else
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :password, _('Password'), class: 'gl-display-block col-form-label'
+ = f.password_field :password, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation gl-form-input-lg'
+ = render_if_exists 'shared/password_requirements_list'
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :password_confirmation, _('Password confirmation'), class: 'gl-display-block col-form-label'
+ = f.password_field :password_confirmation, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input gl-form-input-lg'
= render partial: 'access_levels', locals: { f: f }
@@ -45,42 +45,42 @@
= render_if_exists 'admin/users/limits', f: f
- .gl-border-b.gl-pb-6.gl-mb-6
- .row
- .col-lg-4
+ .settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
%h4.gl-mt-0
= _('Profile')
- .col-lg-8
- .form-group.gl-form-group{ role: 'group' }
- = f.label :avatar, s_('AdminUsers|Avatar'), class: 'gl-display-block col-form-label'
- = f.file_field :avatar
- .form-group.gl-form-group{ role: 'group' }
- = f.label :skype, s_('AdminUsers|Skype'), class: 'gl-display-block col-form-label'
- = f.text_field :skype, class: 'form-control gl-form-input'
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :avatar, s_('AdminUsers|Avatar'), class: 'gl-display-block col-form-label gl-form-input-lg'
+ = f.file_field :avatar
+
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :skype, s_('AdminUsers|Skype'), class: 'gl-display-block col-form-label'
+ = f.text_field :skype, class: 'form-control gl-form-input gl-form-input-lg'
- .form-group.gl-form-group{ role: 'group' }
- = f.label :linkedin, s_('AdminUsers|Linkedin'), class: 'gl-display-block col-form-label'
- = f.text_field :linkedin, class: 'form-control gl-form-input'
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :linkedin, s_('AdminUsers|Linkedin'), class: 'gl-display-block col-form-label'
+ = f.text_field :linkedin, class: 'form-control gl-form-input gl-form-input-lg'
- .form-group.gl-form-group{ role: 'group' }
- = f.label :twitter, _('Twitter'), class: 'gl-display-block col-form-label'
- = f.text_field :twitter, class: 'form-control gl-form-input'
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :twitter, _('Twitter'), class: 'gl-display-block col-form-label'
+ = f.text_field :twitter, class: 'form-control gl-form-input gl-form-input-lg'
- .form-group.gl-form-group{ role: 'group' }
- = f.label :website_url, s_('AdminUsers|Website URL'), class: 'gl-display-block col-form-label'
- = f.text_field :website_url, class: 'form-control gl-form-input'
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :website_url, s_('AdminUsers|Website URL'), class: 'gl-display-block col-form-label'
+ = f.text_field :website_url, class: 'form-control gl-form-input gl-form-input-lg'
= render_if_exists 'admin/users/custom_attributes', f: f
= render 'admin/users/admin_notes', f: f
- %div
+ .settings-sticky-footer
- if @user.new_record?
- = f.submit _('Create user'), pajamas_button: true
+ = f.submit _('Create user'), pajamas_button: true, class: 'gl-mr-3'
= render Pajamas::ButtonComponent.new(href: admin_users_path) do
= _('Cancel')
- else
- = f.submit _('Save changes'), pajamas_button: true
+ = f.submit _('Save changes'), pajamas_button: true, class: 'gl-mr-3'
= render Pajamas::ButtonComponent.new(href: admin_user_path(@user)) do
= _('Cancel')
diff --git a/app/views/ci/variables/_header.html.haml b/app/views/ci/variables/_header.html.haml
index dfcf8f39533..886edbd0687 100644
--- a/app/views/ci/variables/_header.html.haml
+++ b/app/views/ci/variables/_header.html.haml
@@ -6,5 +6,5 @@
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
-%p
+%p.gl-text-secondary
= render "ci/variables/content", entity: @entity, variable_limit: @variable_limit
diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml
index 5eed4e92386..65f9e6c2342 100644
--- a/app/views/ci/variables/_index.html.haml
+++ b/app/views/ci/variables/_index.html.haml
@@ -23,16 +23,10 @@
maskable_raw_regex: ci_variable_maskable_raw_regex,
maskable_regex: ci_variable_maskable_regex,
protected_by_default: ci_variable_protected_by_default?.to_s,
- aws_logo_svg_path: image_path('aws_logo.svg'),
- aws_tip_deploy_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'deploy-your-application-to-ecs'),
- aws_tip_commands_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'use-an-image-to-run-aws-commands'),
- aws_tip_learn_link: help_page_path('ci/cloud_deployment/index.md'),
contains_variable_reference_link: help_page_path('ci/variables/index', anchor: 'prevent-cicd-variable-expansion'),
masked_environment_variables_link: help_page_path('ci/variables/index', anchor: 'mask-a-cicd-variable'),
environment_scope_link: help_page_path('ci/environments/index', anchor: 'limit-the-environment-scope-of-a-cicd-variable') } }
- if !@group && @project.group
- .settings-header.border-top.gl-mt-6
- = render 'ci/group_variables/header'
- .settings-content.pr-0
- = render 'ci/group_variables/index'
+ = render 'ci/group_variables/header'
+ = render 'ci/group_variables/index'
diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml
index 88da252f2bb..49b9c4c9ca6 100644
--- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml
+++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml
@@ -1,11 +1,8 @@
- logo_path = local_assigns.fetch(:logo_path)
- help_path = local_assigns.fetch(:help_path)
- label = local_assigns.fetch(:label)
-- last = local_assigns.fetch(:last, false)
-- classes = ["btn btn-confirm gl-button btn-confirm-secondary gl-flex-direction-column gl-flex-basis-third "]
-- conditional_classes = [("gl-mr-5" unless last)]
-= link_to help_path, class: classes + conditional_classes do
+= render Pajamas::ButtonComponent.new(variant: :confirm, category: :secondary, href: help_path, button_options: { class: "gl-flex-direction-column gl-flex-basis-third" }) do
%span.gl-display-flex.gl-align-items-center.gl-m-3.gl-h-64
= image_tag logo_path, alt: label, class: "gl-w-15 gl-max-h-full gl-max-w-full"
%span.gl-white-space-normal
diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml
index 7039ce57bd9..49dab193da8 100644
--- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml
+++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml
@@ -9,7 +9,7 @@
.gl-py-5.gl-md-pl-5.gl-md-pr-5
%h4.gl-mb-5
= create_cluster_label
- .gl-display-flex
+ .gl-display-flex.gl-gap-5
= render partial: 'clusters/clusters/cloud_providers/cloud_provider_button',
locals: { label: eks_label, logo_path: 'illustrations/logos/amazon_eks.svg', help_path: eks_help_path }
= render partial: 'clusters/clusters/cloud_providers/cloud_provider_button',
diff --git a/app/views/clusters/clusters/user/_form.html.haml b/app/views/clusters/clusters/user/_form.html.haml
index ed169b2bfd1..4ecef4b76ce 100644
--- a/app/views/clusters/clusters/user/_form.html.haml
+++ b/app/views/clusters/clusters/user/_form.html.haml
@@ -47,7 +47,7 @@
.form-group
.form-check
- = platform_kubernetes_field.check_box :authorization_type, { data: { qa_selector: 'rbac_checkbox'}, inline: true, class: 'form-check-input' }, 'rbac', 'abac'
+ = platform_kubernetes_field.check_box :authorization_type, { inline: true, class: 'form-check-input' }, 'rbac', 'abac'
= platform_kubernetes_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold'
%small.form-text.text-muted
= '%{help_text} %{help_link}'.html_safe % { help_text: rbac_help_text, help_link: rbac_help_link }
@@ -73,4 +73,4 @@
= render('clusters/clusters/namespace', platform_field: platform_kubernetes_field)
.form-group
- = field.submit s_('ClusterIntegration|Add Kubernetes cluster'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'add_kubernetes_cluster_button' }
+ = field.submit s_('ClusterIntegration|Add Kubernetes cluster'), pajamas_button: true
diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml
index 62ca4a3bab6..2737dede0e9 100644
--- a/app/views/dashboard/_groups_head.html.haml
+++ b/app/views/dashboard/_groups_head.html.haml
@@ -4,7 +4,7 @@
.page-title-controls.gl-display-flex.gl-align-items-center.gl-gap-5
= link_to _("Explore groups"), explore_groups_path
- if current_user.can_create_group?
- = render Pajamas::ButtonComponent.new(href: new_group_path, variant: :confirm, button_options: { data: { qa_selector: "new_group_button", testid: "new-group-button" } }) do
+ = render Pajamas::ButtonComponent.new(href: new_group_path, variant: :confirm, button_options: { data: { testid: "new-group-button" } }) do
= _("New group")
.top-area.gl-py-3.gl-justify-content-end.gl-border-bottom-0
diff --git a/app/views/dashboard/projects/_starred_empty_state.html.haml b/app/views/dashboard/projects/_starred_empty_state.html.haml
index dafa3b4dc8d..f49960695f6 100644
--- a/app/views/dashboard/projects/_starred_empty_state.html.haml
+++ b/app/views/dashboard/projects/_starred_empty_state.html.haml
@@ -1,9 +1,5 @@
-.row.empty-state
- .col-12
- .svg-content.svg-150
- = image_tag 'illustrations/empty-state/empty-projects-starred-md.svg'
- .text-content
- %h4.gl-text-center
- = s_("StarredProjectsEmptyState|You don't have starred projects yet.")
- %p.gl-text-gray-500
- = s_("StarredProjectsEmptyState|Visit a project page and press on a star icon. Then, you can find the project on this page.")
+= render Pajamas::EmptyStateComponent.new(svg_path: 'illustrations/empty-state/empty-projects-starred-md.svg',
+ title: s_("StarredProjectsEmptyState|You don't have starred projects yet.")) do |c|
+
+ - c.with_description do
+ = s_("StarredProjectsEmptyState|Visit a project page and press on a star icon. Then, you can find the project on this page.")
diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml
index e20fccc218a..1cd8015934e 100644
--- a/app/views/dashboard/todos/_todo.html.haml
+++ b/app/views/dashboard/todos/_todo.html.haml
@@ -48,6 +48,7 @@
= first_line_in_markdown(todo, :body, 125, is_todo: true, project: todo.project, group: todo.group)
= render_if_exists "dashboard/todos/diff_summary", local_assigns: { todo: todo }
+ = render_if_exists "dashboard/todos/review_summary", local_assigns: { todo: todo }
.todo-timestamp.gl-white-space-nowrap.gl-sm-ml-3.gl-mt-2.gl-mb-2.gl-sm-my-0.gl-px-2.gl-sm-px-0
%span.todo-timestamp.gl-font-sm.gl-text-secondary
diff --git a/app/views/devise/confirmations/almost_there.haml b/app/views/devise/confirmations/almost_there.haml
index c22eeba2f01..1760e6e0f84 100644
--- a/app/views/devise/confirmations/almost_there.haml
+++ b/app/views/devise/confirmations/almost_there.haml
@@ -8,6 +8,8 @@
= render "layouts/bizible"
= render "layouts/google_tag_manager_body"
+= render_if_exists 'devise/shared/delete_unconfirmed_users_flash'
+
.well-confirmation.gl-text-center.gl-mb-6
%h1.gl-mt-0
= _("Almost there...")
diff --git a/app/views/devise/mailer/_confirmation_instructions_account.html.haml b/app/views/devise/mailer/_confirmation_instructions_account.html.haml
index c1655818770..ff5027b8464 100644
--- a/app/views/devise/mailer/_confirmation_instructions_account.html.haml
+++ b/app/views/devise/mailer/_confirmation_instructions_account.html.haml
@@ -1,9 +1,11 @@
- confirmation_link = confirmation_url(@resource, confirmation_token: @token)
+
- if @resource.unconfirmed_email.present? || !@resource.created_recently?
#content
= email_default_heading(@email)
%p= _('Click the link below to confirm your email address.')
#cta
+ = render_if_exists 'devise/shared/delete_unconfirmed_users'
= link_to _('Confirm your email address'), confirmation_link
- else
#content
@@ -13,4 +15,5 @@
= email_default_heading(_("Welcome, %{name}!") % { name: @resource.name })
%p= _("To get started, click the link below to confirm your account.")
#cta
+ = render_if_exists 'devise/shared/delete_unconfirmed_users'
= link_to _('Confirm your account'), confirmation_link
diff --git a/app/views/devise/mailer/_confirmation_instructions_account.text.erb b/app/views/devise/mailer/_confirmation_instructions_account.text.erb
index 7e4f38885f6..7436da66e63 100644
--- a/app/views/devise/mailer/_confirmation_instructions_account.text.erb
+++ b/app/views/devise/mailer/_confirmation_instructions_account.text.erb
@@ -10,4 +10,6 @@
<%= _('To get started, use the link below to confirm your account.') %>
<% end %>
+<%= render_if_exists 'devise/shared/delete_unconfirmed_users_text' %>
+
<%= confirmation_url(@resource, confirmation_token: @token) %>
diff --git a/app/views/devise/mailer/email_changed_gitlab_com.html.haml b/app/views/devise/mailer/email_changed_gitlab_com.html.haml
new file mode 100644
index 00000000000..de91418860d
--- /dev/null
+++ b/app/views/devise/mailer/email_changed_gitlab_com.html.haml
@@ -0,0 +1,11 @@
+= email_default_heading("Hello, #{@resource.name}!")
+
+- if @resource.try(:unconfirmed_email?)
+ %p
+ We're contacting you to notify you that your email is being changed to #{@resource.reset.unconfirmed_email}.
+- else
+ %p
+ We're contacting you to notify you that your email has been changed to #{@resource.email}.
+
+%p
+ If you did not initiate this change, please contact your group owner immediately. If you have a Premium or Ultimate tier subscription, you can also contact GitLab support.
diff --git a/app/views/devise/mailer/email_changed_gitlab_com.text.erb b/app/views/devise/mailer/email_changed_gitlab_com.text.erb
new file mode 100644
index 00000000000..c978d666180
--- /dev/null
+++ b/app/views/devise/mailer/email_changed_gitlab_com.text.erb
@@ -0,0 +1,9 @@
+Hello, <%= @resource.name %>!
+
+<% if @resource.try(:unconfirmed_email?) %>
+We're contacting you to notify you that your email is being changed to <%= @resource.reset.unconfirmed_email %>.
+<% else %>
+We're contacting you to notify you that your email has been changed to <%= @resource.email %>.
+<% end %>
+
+If you did not initiate this change, please contact your group owner immediately. If you have a Premium or Ultimate tier subscription, you can also contact GitLab support.
diff --git a/app/views/devise/sessions/_broadcast.html.haml b/app/views/devise/sessions/_broadcast.html.haml
new file mode 100644
index 00000000000..96e2e2d776d
--- /dev/null
+++ b/app/views/devise/sessions/_broadcast.html.haml
@@ -0,0 +1 @@
+= render "layouts/broadcast"
diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml
index 4825f192d4d..345a1cc0225 100644
--- a/app/views/devise/sessions/_new_base.html.haml
+++ b/app/views/devise/sessions/_new_base.html.haml
@@ -21,7 +21,8 @@
= recaptcha_tags nonce: content_security_policy_nonce
- if remember_me_enabled?
- = f.gitlab_ui_checkbox_component :remember_me, _('Remember me'), checkbox_options: { autocomplete: 'off' }
+ .form-group
+ = f.gitlab_ui_checkbox_component :remember_me, _('Remember me'), checkbox_options: { autocomplete: 'off' }
= render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { class: "js-sign-in-button #{'js-no-auto-disable' if Feature.enabled?(:arkose_labs_login_challenge)}", data: { qa_selector: 'sign_in_button', testid: 'sign-in-button' } }) do
= _('Sign in')
diff --git a/app/views/devise/sessions/email_verification.haml b/app/views/devise/sessions/email_verification.haml
index e0b5a266961..085204fb6bf 100644
--- a/app/views/devise/sessions/email_verification.haml
+++ b/app/views/devise/sessions/email_verification.haml
@@ -2,18 +2,7 @@
= render 'devise/shared/tab_single', tab_title: s_('IdentityVerification|Help us protect your account')
.login-box.gl-p-5
.login-body
- = gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: 'gl-show-field-errors' }) do |f|
- %p
- = s_("IdentityVerification|For added security, you'll need to verify your identity. We've sent a verification code to %{email}").html_safe % { email: "<strong>#{sanitize(obfuscated_email(resource.email))}</strong>".html_safe }
- %div
- = f.label :verification_token, s_('IdentityVerification|Verification code')
- = f.text_field :verification_token, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', title: s_('IdentityVerification|Please enter a valid code'), inputmode: 'numeric', maxlength: 6, pattern: '[0-9]{6}'
- %p.gl-field-error.gl-mt-2
- = resource.errors.full_messages.to_sentence
- .gl-mt-5
- = f.submit s_('IdentityVerification|Verify code'), class: 'gl-w-full', pajamas_button: true
- - unless send_rate_limited?(resource)
- = link_to s_('IdentityVerification|Resend code'), users_resend_verification_code_path, method: :post, class: 'form-control gl-button btn-link gl-mt-3 gl-mb-0'
+ .js-email-verification{ data: verification_data(resource) }
%p.gl-p-5.gl-text-secondary
- support_link_start = '<a href="https://about.gitlab.com/support/" target="_blank" rel="noopener noreferrer">'.html_safe
= s_("IdentityVerification|If you've lost access to the email associated to this account or having trouble with the code, %{link_start}here are some other steps you can take.%{link_end}").html_safe % { link_start: support_link_start, link_end: '</a>'.html_safe }
diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml
index d5f15a72c34..0fd27f7f7e7 100644
--- a/app/views/devise/sessions/new.html.haml
+++ b/app/views/devise/sessions/new.html.haml
@@ -5,8 +5,7 @@
= render "layouts/one_trust"
- content_for :sessions_broadcast do
- - unless Gitlab.com?
- = render "layouts/broadcast"
+ = render "devise/sessions/broadcast"
= render "layouts/google_tag_manager_body"
diff --git a/app/views/devise/shared/_signup_omniauth_provider_list.haml b/app/views/devise/shared/_signup_omniauth_provider_list.haml
index 60c37316c62..e8c82e456ae 100644
--- a/app/views/devise/shared/_signup_omniauth_provider_list.haml
+++ b/app/views/devise/shared/_signup_omniauth_provider_list.haml
@@ -4,7 +4,7 @@
= _("Register with:")
.gl-text-center.gl-ml-auto.gl-mr-auto
- providers.each do |provider|
- = render Pajamas::ButtonComponent.new(href: omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), method: :post, variant: :default, button_options: { class: "gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label}, id: "oauth-login-#{provider}" }) do
+ = button_to omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), class: "btn gl-button btn-default gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label }, id: "oauth-login-#{provider}" do
- if provider_has_icon?(provider)
= provider_image_tag(provider)
%span.gl-button-text
@@ -14,7 +14,7 @@
= _("Create an account using:")
.gl-display-flex.gl-justify-content-between.gl-flex-wrap
- providers.each do |provider|
- = render Pajamas::ButtonComponent.new(href: omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), method: :post, variant: :default, button_options: { class: "gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label}, id: "oauth-login-#{provider}" }) do
+ = button_to omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), class: "btn gl-button btn-default gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label }, id: "oauth-login-#{provider}" do
- if provider_has_icon?(provider)
= provider_image_tag(provider)
%span.gl-button-text
diff --git a/app/views/devise/shared/_signup_omniauth_providers.haml b/app/views/devise/shared/_signup_omniauth_providers.haml
index 5ec3c7a4150..399c23741a9 100644
--- a/app/views/devise/shared/_signup_omniauth_providers.haml
+++ b/app/views/devise/shared/_signup_omniauth_providers.haml
@@ -1,4 +1,6 @@
- if Feature.disabled?(:restyle_login_page, @project)
.omniauth-divider.gl-display-flex.gl-align-items-center
= _("or")
-= render 'devise/shared/signup_omniauth_provider_list', providers: enabled_button_based_providers, tracking_label: "free_registration"
+= render 'devise/shared/signup_omniauth_provider_list',
+ providers: enabled_button_based_providers,
+ tracking_label: ::Onboarding::Status.tracking_label[:free]
diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml
index cd0c9a016a5..db122fe82b1 100644
--- a/app/views/discussions/_diff_with_notes.html.haml
+++ b/app/views/discussions/_diff_with_notes.html.haml
@@ -3,7 +3,7 @@
- diff_data = {}
- expanded = discussion.expanded? || local_assigns.fetch(:expanded, nil)
- unless expanded
- - diff_data = { lines_path: project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion) }
+ - diff_data = { lines_path: project_discussion_path(discussion.project, discussion.noteable_collection_name, discussion.noteable, discussion) }
.diff-file.file-holder.js-lazy-load-discussion{ class: diff_file_class, data: diff_data }
.js-file-title.file-title.file-title-flex-parent
@@ -29,7 +29,8 @@
%td.line_content.js-success-lazy-load
.js-code-placeholder
%td.js-error-lazy-load-diff.hidden.diff-loading-error-block
- - button = button_tag(_("Try again"), class: "btn-link gl-button btn-link-retry gl-p-0 js-toggle-lazy-diff-retry-button")
+ - button = render Pajamas::ButtonComponent.new(variant: :link, button_options: { class: 'btn-link-retry gl-p-0 js-toggle-lazy-diff-retry-button' }) do
+ = _("Try again")
= _("Unable to load the diff. %{button_try_again}").html_safe % { button_try_again: button}
= render "discussions/diff_discussion", discussions: [discussion], expanded: true
- else
diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml
index bc69da5775f..fd5088e04b0 100644
--- a/app/views/doorkeeper/authorizations/new.html.haml
+++ b/app/views/doorkeeper/authorizations/new.html.haml
@@ -38,7 +38,8 @@
= hidden_field_tag :nonce, @pre_auth.nonce
= hidden_field_tag :code_challenge, @pre_auth.code_challenge
= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method
- = submit_tag _("Deny"), class: "btn btn-default gl-button"
+ = render Pajamas::ButtonComponent.new(type: :submit) do
+ = _("Deny")
= form_tag oauth_authorization_path, method: :post, class: 'inline' do
= hidden_field_tag :client_id, @pre_auth.client.uid
= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri
@@ -48,4 +49,7 @@
= hidden_field_tag :nonce, @pre_auth.nonce
= hidden_field_tag :code_challenge, @pre_auth.code_challenge
= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method
- = submit_tag _("Authorize"), class: "btn btn-danger gl-button gl-ml-3", data: { qa_selector: 'authorization_button' }
+ = render Pajamas::ButtonComponent.new(type: :submit,
+ variant: :danger,
+ button_options: { id: 'commit-changes', class: 'gl-ml-3', qa_selector: 'authorization_button'}) do
+ = _("Authorize")
diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml
index fac0fd3d2a4..ca7798257cb 100644
--- a/app/views/groups/_home_panel.html.haml
+++ b/app/views/groups/_home_panel.html.haml
@@ -8,7 +8,7 @@
.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')
%div
- %h1.home-panel-title.gl-font-size-h1.gl-mt-3.gl-mb-2.gl-display-flex{ itemprop: 'name' }
+ %h1.home-panel-title.gl-font-size-h1.gl-mt-3.gl-mb-2.gl-display-flex.gl-word-break-word{ itemprop: 'name' }
= @group.name
%span.visibility-icon.gl-text-secondary.has-tooltip.gl-ml-2{ data: { container: 'body' }, title: visibility_icon_description(@group) }
= visibility_level_icon(@group.visibility_level, options: {class: 'icon'})
diff --git a/app/views/groups/_import_group_from_another_instance_panel.html.haml b/app/views/groups/_import_group_from_another_instance_panel.html.haml
index 9fbb7f3c9ed..8c2434ca4a0 100644
--- a/app/views/groups/_import_group_from_another_instance_panel.html.haml
+++ b/app/views/groups/_import_group_from_another_instance_panel.html.haml
@@ -37,7 +37,7 @@
required: true,
title: s_('GroupsNew|Enter the URL for the source instance.'),
id: 'import_gitlab_url',
- data: { qa_selector: 'import_gitlab_url' }
+ data: { testid: 'import-gitlab-url' }
.form-group.gl-display-flex.gl-flex-direction-column
= f.label :bulk_import_gitlab_access_token, s_('GroupsNew|Personal access token'), for: 'import_gitlab_token'
.gl-font-weight-normal
@@ -50,6 +50,6 @@
autocomplete: 'off',
title: s_('GroupsNew|Please fill in your personal access token.'),
id: 'import_gitlab_token',
- data: { qa_selector: 'import_gitlab_token' }
+ data: { testid: 'import-gitlab-token' }
.gl-border-gray-100.gl-border-solid.gl-border-1.gl-bg-gray-10.gl-p-5
- = f.submit s_('GroupsNew|Connect instance'), disabled: bulk_imports_disabled, pajamas_button: true, data: { qa_selector: 'connect_instance_button' }
+ = f.submit s_('GroupsNew|Connect instance'), disabled: bulk_imports_disabled, pajamas_button: true, data: { testid: 'connect-instance-button' }
diff --git a/app/views/groups/_import_group_from_file_panel.html.haml b/app/views/groups/_import_group_from_file_panel.html.haml
index 91f7b574dbf..e3d54e52aab 100644
--- a/app/views/groups/_import_group_from_file_panel.html.haml
+++ b/app/views/groups/_import_group_from_file_panel.html.haml
@@ -20,6 +20,6 @@
- import_export_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path('user/group/import/index') }
= s_('GroupsNew|To import a group, navigate to the group settings for the GitLab source instance, %{link_start}generate an export file%{link_end}, and upload it here.').html_safe % { link_start: import_export_link_start, link_end: '</a>'.html_safe }
.gl-mt-3
- = render 'shared/file_picker_button', f: f, field: :file, help_text: nil, classes: 'gl-button btn-confirm-secondary gl-mr-2'
+ = render 'shared/file_picker_button', f: f, field: :file, help_text: nil
.gl-border-gray-100.gl-border-solid.gl-border-1.gl-bg-gray-10.gl-p-5
= f.submit _('Import'), pajamas_button: true
diff --git a/app/views/groups/_new_group_fields.html.haml b/app/views/groups/_new_group_fields.html.haml
index ddf6e52796f..49cc6e66ab8 100644
--- a/app/views/groups/_new_group_fields.html.haml
+++ b/app/views/groups/_new_group_fields.html.haml
@@ -30,6 +30,6 @@
= recaptcha_tags nonce: content_security_policy_nonce
.row
.col-sm-12
- = f.submit submit_label, pajamas_button: true, data: { qa_selector: 'create_group_button' }
+ = f.submit submit_label, pajamas_button: true, data: { testid: 'create-group-button' }
= render Pajamas::ButtonComponent.new(href: @parent_group || dashboard_groups_path) do
= _('Cancel')
diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml
index dedff502a87..c11154cbd75 100644
--- a/app/views/groups/edit.html.haml
+++ b/app/views/groups/edit.html.haml
@@ -11,7 +11,7 @@
= _('Naming, visibility')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= _('Collapse')
- %p
+ %p.gl-text-secondary
= _('Update your group name, description, avatar, and visibility.')
= link_to _('Learn more about groups.'), help_page_path('user/group/index')
.settings-content
@@ -23,7 +23,7 @@
= _('Permissions and group features')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Configure advanced permissions, Large File Storage, two-factor authentication, and customer relations settings.')
.settings-content
= render 'groups/settings/permissions'
@@ -38,7 +38,7 @@
= s_('GroupSettings|Badges')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= s_('GroupSettings|Customize this group\'s badges.')
= link_to s_('GroupSettings|What are badges?'), help_page_path('user/project/badges')
.settings-content
@@ -47,6 +47,7 @@
= render_if_exists 'groups/compliance_frameworks', expanded: expanded
= render_if_exists 'groups/custom_project_templates_setting'
= render_if_exists 'groups/templates_setting', expanded: expanded
+= render_if_exists 'shared/groups/max_pages_size_setting'
%section.settings.gs-advanced.no-animate#js-advanced-settings{ class: ('expanded' if expanded), data: { qa_selector: 'advanced_settings_content' } }
.settings-header
@@ -54,10 +55,7 @@
= _('Advanced')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Perform advanced options such as changing path, transferring, exporting, or removing the group.')
.settings-content
= render 'groups/settings/advanced'
-
-= render_if_exists 'shared/groups/max_pages_size_setting'
-
diff --git a/app/views/groups/packages/index.html.haml b/app/views/groups/packages/index.html.haml
index 6d0f24bf08c..4fda4e2b447 100644
--- a/app/views/groups/packages/index.html.haml
+++ b/app/views/groups/packages/index.html.haml
@@ -6,7 +6,7 @@
full_path: @group.full_path,
endpoint: group_packages_path(@group),
page_type: 'groups',
- empty_list_illustration: image_path('illustrations/no-packages.svg'),
+ empty_list_illustration: image_path('illustrations/empty-state/empty-package-md.svg'),
npm_instance_url: package_registry_instance_url(:npm),
project_list_url: '',
settings_path: show_group_package_registry_settings(@group) ? group_settings_packages_and_registries_path(@group) : '',
diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml
index ed078230349..22e9f9f5071 100644
--- a/app/views/groups/projects.html.haml
+++ b/app/views/groups/projects.html.haml
@@ -2,16 +2,20 @@
- page_title _("Projects")
- @force_desktop_expanded_sidebar = true
-= render Pajamas::CardComponent.new(card_options: { class: 'gl-mt-3 js-search-settings-section' }, header_options: { class: 'gl-display-flex' }, body_options: { class: 'gl-py-0' }) do |c|
+= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-search-settings-section' }, header_options: { class: 'gl-new-card-header gl-display-flex' }, body_options: { class: 'gl-new-card-body' }) do |c|
- c.with_header do
- .gl-flex-grow-1
- = html_escape(_("%{strong_open}%{group_name}%{strong_close} projects:")) % { strong_open: '<strong>'.html_safe, group_name: @group.name, strong_close: '</strong>'.html_safe }
- - if can? current_user, :admin_group, @group
- .controls
- = render Pajamas::ButtonComponent.new(href: new_project_path(namespace_id: @group.id), size: :small, variant: :confirm) do
+ .gl-new-card-title-wrapper
+ %h3.gl-new-card-title
+ = _('Projects')
+ .gl-new-card-count
+ = sprite_icon('project', css_class: 'gl-mr-2')
+ = @projects.size
+ .gl-new-card-actions
+ - if can? current_user, :admin_group, @group
+ = render Pajamas::ButtonComponent.new(href: new_project_path(namespace_id: @group.id), size: :small) do
= _("New project")
- c.with_body do
- %ul.content-list
+ %ul.content-list{ class: 'gl-px-3!' }
- @projects.each_with_index do |project, idx|
%li.project-row.gl-align-items-center{ class: 'gl-display-flex!', data: { qa_selector: 'project_row_container', qa_index: idx } }
.avatar-container.rect-avatar.s40.gl-flex-shrink-0
@@ -26,22 +30,22 @@
\/
%span.project-name{ data: { qa_selector: 'project_name_content', qa_project_name: project.name } }
= project.name
- %span{ class: visibility_level_color(project.visibility_level) }
- = visibility_level_icon(project.visibility_level)
+ = visibility_level_content(project, css_class: 'visibility-icon gl-text-secondary gl-ml-2', icon_css_class: 'icon')
- if project.description.present?
.description
= markdown_field(project, :description)
- .stats.gl-text-gray-500.gl-flex-shrink-0.gl-display-none.gl-sm-display-flex
+ .stats.gl-text-gray-500.gl-flex-shrink-0.gl-display-none.gl-sm-display-flex.gl-gap-3
= gl_badge_tag storage_counter(project.statistics&.storage_size)
= render 'project_badges', project: project
-
.controls.gl-flex-shrink-0.gl-ml-5
= render Pajamas::ButtonComponent.new(href: project_project_members_path(project),
- button_options: { data: { qa_selector: 'project_members_button' } }) do
- = _('Members')
+ variant: :link,
+ button_options: { class: 'gl-mr-2', data: { qa_selector: 'project_members_button' } }) do
+ = _('View members')
= render Pajamas::ButtonComponent.new(href: edit_project_path(project),
+ size: :small,
button_options: { data: { qa_selector: 'project_edit_button' } }) do
= _('Edit')
= render 'delete_project_button', project: project, data: { qa_selector: 'project_delete_button' }
diff --git a/app/views/groups/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml
index d92a6b08b60..45ee6ea6ad7 100644
--- a/app/views/groups/settings/_advanced.html.haml
+++ b/app/views/groups/settings/_advanced.html.haml
@@ -1,29 +1,32 @@
- remove_form_id = 'js-remove-group-form'
= render 'groups/settings/export', group: @group
-.sub-section
- %h4.warning-title= s_('GroupSettings|Change group URL')
- = gitlab_ui_form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f|
- = form_errors(@group)
- .form-group
- %p
- = s_("GroupSettings|Changing a group's URL can have unintended side effects.")
- = link_to _('Learn more.'), help_page_path('user/group/manage', anchor: 'change-a-groups-path'), target: '_blank', rel: 'noopener noreferrer'
+= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ %h4.gl-new-card-title.warning-title= s_('GroupSettings|Change group URL')
+ %p.gl-new-card-description
+ = s_("GroupSettings|Changing a group's URL can have unintended side effects.")
+ = link_to _('Learn more.'), help_page_path('user/group/manage', anchor: 'change-a-groups-path'), target: '_blank', rel: 'noopener noreferrer'
- .input-group.gl-field-error-anchor
- .group-root-path.input-group-prepend.has-tooltip{ title: group_path(@group), :'data-placement' => 'bottom' }
- .input-group-text
- %span>= root_url
- - if @group.parent
- %strong= @group.parent.full_path + '/'
- = f.hidden_field :parent_id
- = f.text_field :path, placeholder: 'open-source', class: 'form-control',
- autofocus: local_assigns[:autofocus] || false, required: true,
- pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS,
- title: group_url_error_message,
- maxlength: ::Namespace::URL_MAX_LENGTH,
- "data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}"
- = f.submit s_('GroupSettings|Change group URL'), class: 'btn-danger', pajamas_button: true
+ - c.with_body do
+ = gitlab_ui_form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f|
+ = form_errors(@group)
+ .form-group
+ .input-group.gl-field-error-anchor
+ .group-root-path.input-group-prepend.has-tooltip{ title: group_path(@group), :'data-placement' => 'bottom' }
+ .input-group-text
+ %span>= root_url
+ - if @group.parent
+ %strong= @group.parent.full_path + '/'
+ = f.hidden_field :parent_id
+ = f.text_field :path, placeholder: 'open-source', class: 'form-control',
+ autofocus: local_assigns[:autofocus] || false, required: true,
+ pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS,
+ title: group_url_error_message,
+ maxlength: ::Namespace::URL_MAX_LENGTH,
+ "data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}"
+ = f.submit s_('GroupSettings|Change group URL'), class: 'btn-danger', pajamas_button: true
= render 'groups/settings/transfer', group: @group
= render 'groups/settings/remove', group: @group, remove_form_id: remove_form_id
diff --git a/app/views/groups/settings/_export.html.haml b/app/views/groups/settings/_export.html.haml
index 1e80c1846a4..8eb9f8fc5f1 100644
--- a/app/views/groups/settings/_export.html.haml
+++ b/app/views/groups/settings/_export.html.haml
@@ -1,33 +1,38 @@
- group = local_assigns.fetch(:group)
-.sub-section
- %h4= s_('GroupSettings|Export group')
- %p= _('Export this group with all related data.')
- = render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_options: { class: 'gl-mb-4' }) do |c|
- - c.with_body do
- - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index', anchor: 'migrate-groups-by-direct-transfer-recommended') }
- - docs_link_end = '</a>'.html_safe
- = s_('GroupsNew|This feature is deprecated and replaced by group migration by direct transfer. %{docs_link_start}Learn more%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: docs_link_end }
- %p
- - export_information = _('After the export is complete, download the data file from a notification email or from this page. You can then import the data file from the %{strong_text_start}Create new group%{strong_text_end} page of another GitLab instance.') % { strong_text_start: '<strong>'.html_safe, strong_text_end: '</strong>'.html_safe}
- = export_information.html_safe
- = render Pajamas::AlertComponent.new(dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c|
- - c.with_body do
- %p.gl-mb-0
- %p= _('The following items will be exported:')
- %ul
- - group_export_descriptions.each do |description|
- %li= description
- %p= _('The following items will NOT be exported:')
- %ul
- %li= _('Projects')
- %li= _('Runner tokens')
- %li= _('SAML discovery tokens')
- - if group.export_file_exists?
- = render Pajamas::ButtonComponent.new(href: download_export_group_path(group), button_options: { rel: 'nofollow', data: { method: :get, qa_selector: 'download_export_link' } }) do
- = _('Download export')
- = render Pajamas::ButtonComponent.new(href: export_group_path(group), button_options: { data: { method: :post, qa_selector: 'regenerate_export_group_link' } }) do
- = _('Regenerate export')
- - else
- = render Pajamas::ButtonComponent.new(href: export_group_path(group), button_options: { data: { method: :post, qa_selector: 'export_group_link' } }) do
- = _('Export group')
+= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ %h4.gl-new-card-title= s_('GroupSettings|Export group')
+ %p.gl-new-card-description
+ = _('Export this group with all related data.')
+
+ - c.with_body do
+ = render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_options: { class: 'gl-mb-4' }) do |c|
+ - c.with_body do
+ - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index', anchor: 'migrate-groups-by-direct-transfer-recommended') }
+ - docs_link_end = '</a>'.html_safe
+ = s_('GroupsNew|This feature is deprecated and replaced by group migration by direct transfer. %{docs_link_start}Learn more%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: docs_link_end }
+ %p
+ - export_information = _('After the export is complete, download the data file from a notification email or from this page. You can then import the data file from the %{strong_text_start}Create new group%{strong_text_end} page of another GitLab instance.') % { strong_text_start: '<strong>'.html_safe, strong_text_end: '</strong>'.html_safe}
+ = export_information.html_safe
+ = render Pajamas::AlertComponent.new(dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c|
+ - c.with_body do
+ %p.gl-mb-0
+ %p= _('The following items will be exported:')
+ %ul
+ - group_export_descriptions.each do |description|
+ %li= description
+ %p= _('The following items will NOT be exported:')
+ %ul
+ %li= _('Projects')
+ %li= _('Runner tokens')
+ %li= _('SAML discovery tokens')
+ - if group.export_file_exists?
+ = render Pajamas::ButtonComponent.new(href: download_export_group_path(group), button_options: { rel: 'nofollow', data: { method: :get, qa_selector: 'download_export_link' } }) do
+ = _('Download export')
+ = render Pajamas::ButtonComponent.new(href: export_group_path(group), button_options: { data: { method: :post, qa_selector: 'regenerate_export_group_link' } }) do
+ = _('Regenerate export')
+ - else
+ = render Pajamas::ButtonComponent.new(href: export_group_path(group), button_options: { data: { method: :post, qa_selector: 'export_group_link' } }) do
+ = _('Export group')
diff --git a/app/views/groups/settings/_permanent_deletion.html.haml b/app/views/groups/settings/_permanent_deletion.html.haml
index 152cdfc1411..ae440636294 100644
--- a/app/views/groups/settings/_permanent_deletion.html.haml
+++ b/app/views/groups/settings/_permanent_deletion.html.haml
@@ -1,11 +1,15 @@
- remove_form_id = local_assigns.fetch(:remove_form_id, nil)
-.sub-section
- %h4.danger-title= _('Remove group')
- = form_tag(group, method: :delete, id: remove_form_id) do
- %p
- = _('Removing this group also removes all child projects, including archived projects, and their resources.')
- %br
- %strong= _('Removed group can not be restored!')
+= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-bg-red-50 gl-px-5 gl-py-4' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ %h4.gl-new-card-title.danger-title= _('Remove group')
- = render 'groups/settings/remove_button', group: group, remove_form_id: remove_form_id
+ - c.with_body do
+ = form_tag(group, method: :delete, id: remove_form_id) do
+ %p
+ = _('Removing this group also removes all child projects, including archived projects, and their resources.')
+ %br
+ %strong= _('Removed group can not be restored!')
+
+ = render 'groups/settings/remove_button', group: group, remove_form_id: remove_form_id
diff --git a/app/views/groups/settings/_remove_button.html.haml b/app/views/groups/settings/_remove_button.html.haml
index acf11fd8858..6085bcc149f 100644
--- a/app/views/groups/settings/_remove_button.html.haml
+++ b/app/views/groups/settings/_remove_button.html.haml
@@ -1,7 +1,7 @@
- remove_form_id = local_assigns.fetch(:remove_form_id, nil)
- if group.prevent_delete?
- = render Pajamas::AlertComponent.new(dismissible: false, alert_options: { class: 'gl-mb-5', data: { testid: 'group-has-linked-subscription-alert' }}) do |c|
+ = render Pajamas::AlertComponent.new(variant: :tip, dismissible: false, alert_options: { class: 'gl-mb-5', data: { testid: 'group-has-linked-subscription-alert' }}) do |c|
- c.with_body do
= html_escape(_("This group can't be removed because it is linked to a subscription. To remove this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/gitlab_com/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe }
diff --git a/app/views/groups/settings/_subgroup_creation_level.html.haml b/app/views/groups/settings/_subgroup_creation_level.html.haml
index d92610367ae..9f0a206312e 100644
--- a/app/views/groups/settings/_subgroup_creation_level.html.haml
+++ b/app/views/groups/settings/_subgroup_creation_level.html.haml
@@ -1,3 +1,4 @@
.form-group
= f.label s_('SubgroupCreationLevel|Roles allowed to create subgroups'), class: 'label-bold'
- = f.select :subgroup_creation_level, options_for_select(::Gitlab::Access.subgroup_creation_options, group.subgroup_creation_level), {}, class: 'form-control'
+ - ::Gitlab::Access.subgroup_creation_options.each do |label, action|
+ = f.gitlab_ui_radio_component :subgroup_creation_level, action, label
diff --git a/app/views/groups/settings/_transfer.html.haml b/app/views/groups/settings/_transfer.html.haml
index 9ebe3a740b3..368e4a981bc 100644
--- a/app/views/groups/settings/_transfer.html.haml
+++ b/app/views/groups/settings/_transfer.html.haml
@@ -1,20 +1,25 @@
- form_id = "transfer-group-form"
- initial_data = { button_text: s_('GroupSettings|Transfer group'), group_full_path: @group.full_path, group_name: @group.name, group_id: @group.id, target_form_id: form_id, is_paid_group: group.paid?.to_s }
-.sub-section{ data: { qa_selector: 'transfer_group_content' } }
- %h4.warning-title= s_('GroupSettings|Transfer group')
- %p= _('Transfer group to another parent group.')
- = form_for group, url: transfer_group_path(group), method: :put, html: { id: form_id, class: 'js-group-transfer-form' } do |f|
- %ul
- - learn_more_link = help_page_url('user/project/repository/index', anchor: 'what-happens-when-a-repository-path-changes')
- - learn_more_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: learn_more_link }
- - warning_text = s_("GroupSettings|Be careful. Changing a group's parent can have unintended side effects. %{learn_more_link_start}Learn more.%{learn_more_link_end}") % { learn_more_link_start: learn_more_link_start, learn_more_link_end: '</a>'.html_safe }
- %li= warning_text.html_safe
- %li= s_('GroupSettings|You must have the Owner role in the target group')
- %li= s_('GroupSettings|You will need to update your local repositories to point to the new location.')
- %li= s_("GroupSettings|If the parent group's visibility is lower than the group's current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.")
- - if group.paid?
- = render Pajamas::AlertComponent.new(dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c|
- - c.with_body do
- = html_escape(_("This group can't be transferred because it is linked to a subscription. To transfer this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/gitlab_com/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe }
- .js-transfer-group-form{ data: initial_data }
+= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card', data: { qa_selector: 'transfer_group_content' } }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ %h4.gl-new-card-title.warning-title= s_('GroupSettings|Transfer group')
+ %p.gl-new-card-description
+ = _('Transfer group to another parent group.')
+
+ - c.with_body do
+ = form_for group, url: transfer_group_path(group), method: :put, html: { id: form_id, class: 'js-group-transfer-form' } do |f|
+ %ul
+ - learn_more_link = help_page_url('user/project/repository/index', anchor: 'what-happens-when-a-repository-path-changes')
+ - learn_more_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: learn_more_link }
+ - warning_text = s_("GroupSettings|Be careful. Changing a group's parent can have unintended side effects. %{learn_more_link_start}Learn more.%{learn_more_link_end}") % { learn_more_link_start: learn_more_link_start, learn_more_link_end: '</a>'.html_safe }
+ %li= warning_text.html_safe
+ %li= s_('GroupSettings|You must have the Owner role in the target group')
+ %li= s_('GroupSettings|You will need to update your local repositories to point to the new location.')
+ %li= s_("GroupSettings|If the parent group's visibility is lower than the group's current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.")
+ - if group.paid?
+ = render Pajamas::AlertComponent.new(variant: :tip, dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c|
+ - c.with_body do
+ = html_escape(_("This group can't be transferred because it is linked to a subscription. To transfer this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/gitlab_com/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe }
+ .js-transfer-group-form{ data: initial_data }
diff --git a/app/views/groups/settings/access_tokens/index.html.haml b/app/views/groups/settings/access_tokens/index.html.haml
index ac3be429461..ef85eab6778 100644
--- a/app/views/groups/settings/access_tokens/index.html.haml
+++ b/app/views/groups/settings/access_tokens/index.html.haml
@@ -25,18 +25,31 @@
#js-new-access-token-app{ data: { access_token_type: type } }
- - if current_user.can?(:create_resource_access_tokens, @group)
- = render 'shared/access_tokens/form',
- ajax: true,
- type: type,
- path: group_settings_access_tokens_path(@group),
- resource: @group,
- token: @resource_access_token,
- scopes: @scopes,
- access_levels: GroupMember.access_level_roles,
- default_access_level: Gitlab::Access::GUEST,
- prefix: :resource_access_token,
- help_path: help_page_path('user/group/settings/group_access_tokens', anchor: 'scopes-for-a-group-access-token')
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card gl-border-b-0 gl-rounded-bottom-left-none gl-rounded-bottom-right-none js-toggle-container js-token-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ %h3.gl-new-card-title
+ = _('Active group access tokens')
+ .gl-new-card-count
+ = sprite_icon('token', css_class: 'gl-mr-2')
+ %span.js-token-count= @active_access_tokens.size
+ - if current_user.can?(:create_resource_access_tokens, @group)
+ .gl-new-card-actions
+ = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-toggle-button js-toggle-content', data: { testid: 'add-new-token-button' } }) do
+ = _('Add new token')
+ - c.with_body do
+ - if current_user.can?(:create_resource_access_tokens, @group)
+ .gl-new-card-add-form.gl-mt-3.gl-display-none.js-toggle-content.js-add-new-token-form
+ = render 'shared/access_tokens/form',
+ ajax: true,
+ type: type,
+ path: group_settings_access_tokens_path(@group),
+ resource: @group,
+ token: @resource_access_token,
+ scopes: @scopes,
+ access_levels: GroupMember.access_level_roles,
+ default_access_level: Gitlab::Access::GUEST,
+ prefix: :resource_access_token,
+ help_path: help_page_path('user/group/settings/group_access_tokens', anchor: 'scopes-for-a-group-access-token')
- #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This group has no active access tokens.'), show_role: true
- } }
+ #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This group has no active access tokens.'), show_role: true } }
diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml
index 7b6e50ffd36..f9ade00a300 100644
--- a/app/views/groups/settings/ci_cd/show.html.haml
+++ b/app/views/groups/settings/ci_cd/show.html.haml
@@ -14,7 +14,7 @@
= _("General pipelines")
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _("Customize your pipeline configuration.")
.settings-content
= render 'groups/settings/ci_cd/form', group: @group
@@ -31,7 +31,7 @@
= _('Runners')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _("Runners are processes that pick up and execute CI/CD jobs for GitLab.")
= link_to s_('What is GitLab Runner?'), 'https://docs.gitlab.com/runner/', target: '_blank', rel: 'noopener noreferrer'
.settings-content
@@ -43,7 +43,7 @@
= _('Auto DevOps')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
- auto_devops_url = help_page_path('topics/autodevops/index')
- quickstart_url = help_page_path('topics/autodevops/cloud_deployments/auto_devops_with_gke')
- auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url }
diff --git a/app/views/groups/settings/integrations/index.html.haml b/app/views/groups/settings/integrations/index.html.haml
index 3c1a38d9997..d0d3b1bf137 100644
--- a/app/views/groups/settings/integrations/index.html.haml
+++ b/app/views/groups/settings/integrations/index.html.haml
@@ -6,5 +6,5 @@
%h3= s_('Integrations|Group-level integration management')
- integrations_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: integrations_help_page_path }
- %p= s_("Integrations|GitLab administrators can set up integrations that all projects in a group inherit and use by default. These integrations apply to all projects that don't already use custom settings. You can override custom settings for a project if the settings are necessary at that level. Learn more about %{integrations_link_start}group-level integration management%{link_end}.").html_safe % { integrations_link_start: integrations_link_start, link_end: "</a>".html_safe }
+ %p.gl-text-secondary= s_("Integrations|GitLab administrators can set up integrations that all projects in a group inherit and use by default. These integrations apply to all projects that don't already use custom settings. You can override custom settings for a project if the settings are necessary at that level. Learn more about %{integrations_link_start}group-level integration management%{link_end}.").html_safe % { integrations_link_start: integrations_link_start, link_end: "</a>".html_safe }
= render 'shared/integrations/index', integrations: @integrations
diff --git a/app/views/groups/settings/repository/_default_branch.html.haml b/app/views/groups/settings/repository/_default_branch.html.haml
index e8aa809a6ca..b04a3fe50ae 100644
--- a/app/views/groups/settings/repository/_default_branch.html.haml
+++ b/app/views/groups/settings/repository/_default_branch.html.haml
@@ -4,7 +4,7 @@
= _('Default branch')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= 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|
diff --git a/app/views/groups/work_items/index.html.haml b/app/views/groups/work_items/index.html.haml
new file mode 100644
index 00000000000..2e3d3dda941
--- /dev/null
+++ b/app/views/groups/work_items/index.html.haml
@@ -0,0 +1,4 @@
+- page_title s_('WorkItem|Work items')
+- add_page_specific_style 'page_bundles/issuable_list'
+
+.js-work-items-list-root{ data: { full_path: @group.full_path } }
diff --git a/app/views/help/instance_configuration/_size_limits.html.haml b/app/views/help/instance_configuration/_size_limits.html.haml
index 90501450385..add484feac9 100644
--- a/app/views/help/instance_configuration/_size_limits.html.haml
+++ b/app/views/help/instance_configuration/_size_limits.html.haml
@@ -41,3 +41,9 @@
%tr
%td= _('Maximum snippet size')
%td= instance_configuration_human_size_cell(size_limits[:snippet_size_limit])
+ %tr
+ %td= s_('Import|Maximum import remote file size (MB)')
+ %td= instance_configuration_human_size_cell(size_limits[:max_import_remote_file_size])
+ %tr
+ %td= s_('BulkImport|Direct transfer maximum download file size (MB)')
+ %td= instance_configuration_human_size_cell(size_limits[:bulk_import_max_download_file_size])
diff --git a/app/views/import/bulk_imports/history.html.haml b/app/views/import/bulk_imports/history.html.haml
index 80eb0c7a764..38196f97030 100644
--- a/app/views/import/bulk_imports/history.html.haml
+++ b/app/views/import/bulk_imports/history.html.haml
@@ -3,4 +3,4 @@
- add_page_specific_style 'page_bundles/import'
- page_title _('Import history')
-#import-history-mount-element
+#import-history-mount-element{ data: { realtime_changes_path: realtime_changes_import_bulk_imports_path(format: :json) } }
diff --git a/app/views/import/fogbugz/new_user_map.html.haml b/app/views/import/fogbugz/new_user_map.html.haml
index 9d4c0f62134..6aac7aa65af 100644
--- a/app/views/import/fogbugz/new_user_map.html.haml
+++ b/app/views/import/fogbugz/new_user_map.html.haml
@@ -40,4 +40,5 @@
.js-gitlab-user{ data: { name: "users[#{id}][gitlab_user]" } }
.form-actions
- = submit_tag _('Continue to the next step'), class: 'gl-button btn btn-confirm'
+ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm) do
+ = _('Continue to the next step')
diff --git a/app/views/import/gitea/new.html.haml b/app/views/import/gitea/new.html.haml
index 4a293bb6f4e..f76e9f3f6ed 100644
--- a/app/views/import/gitea/new.html.haml
+++ b/app/views/import/gitea/new.html.haml
@@ -1,24 +1,25 @@
-- page_title _("Gitea Import")
+- page_title _("Gitea import")
- header_title _("New project"), new_project_path
- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project')
%h1.page-title.gl-font-size-h-display
= custom_icon('gitea_logo')
- = _('Import Projects from Gitea')
+ = _('Import projects from Gitea')
%p
- - link_to_personal_token = link_to(_('Personal Access Token'), 'https://docs.gitea.io/en-us/api-usage/#authentication-via-the-api')
- = _('To get started, please enter your Gitea Host URL and a %{link_to_personal_token}.').html_safe % { link_to_personal_token: link_to_personal_token }
+ - link_to_personal_token = link_to(_('personal access token'), 'https://docs.gitea.io/en-us/api-usage/#authentication-via-the-api')
+ = _('To get started, please enter your Gitea host URL and a %{link_to_personal_token}.').html_safe % { link_to_personal_token: link_to_personal_token }
= form_tag personal_access_token_import_gitea_path do
= hidden_field_tag(:namespace_id, params[:namespace_id])
.form-group.row
- = label_tag :gitea_host_url, _('Gitea Host URL'), class: 'col-form-label col-sm-2'
+ = label_tag :gitea_host_url, _('Gitea host URL'), class: 'col-form-label col-sm-2'
.col-sm-4
= text_field_tag :gitea_host_url, nil, placeholder: 'https://gitea.com', class: 'form-control gl-form-input'
.form-group.row
- = label_tag :personal_access_token, _('Personal Access Token'), class: 'col-form-label col-sm-2'
+ = label_tag :personal_access_token, _('Personal access token'), class: 'col-form-label col-sm-2'
.col-sm-4
= text_field_tag :personal_access_token, nil, class: 'form-control gl-form-input'
.form-actions
- = submit_tag _('List Your Gitea Repositories'), class: 'gl-button btn btn-confirm'
+ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm) do
+ = _('List your Gitea repositories')
diff --git a/app/views/import/gitea/status.html.haml b/app/views/import/gitea/status.html.haml
index c717d4848f4..2dde642d8f0 100644
--- a/app/views/import/gitea/status.html.haml
+++ b/app/views/import/gitea/status.html.haml
@@ -1,6 +1,6 @@
-- page_title _("Gitea Import")
+- page_title _("Gitea import")
%h1.page-title.gl-font-size-h-display
= custom_icon('gitea_logo')
- = _('Import Projects from Gitea')
+ = _('Import projects from Gitea')
= render 'import/githubish_status', provider: 'gitea', default_namespace: @namespace
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index 3bb59db32aa..95627c2884a 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -7,7 +7,7 @@
- sidebar_panel = super_sidebar_nav_panel(nav: nav, user: current_user, group: group, project: @project, current_ref: current_ref, ref_type: @ref_type, viewed_user: @user, organization: @organization)
- sidebar_data = super_sidebar_context(current_user, group: group, project: @project, panel: sidebar_panel, panel_type: nav).to_json
- %aside.js-super-sidebar.super-sidebar.super-sidebar-loading{ data: { root_path: root_path, sidebar: sidebar_data, toggle_new_nav_endpoint: profile_preferences_url, force_desktop_expanded_sidebar: @force_desktop_expanded_sidebar.to_s, command_palette: command_palette_data(project: @project).to_json } }
+ %aside.js-super-sidebar.super-sidebar.super-sidebar-loading{ data: { root_path: root_path, sidebar: sidebar_data, toggle_new_nav_endpoint: profile_preferences_path, force_desktop_expanded_sidebar: @force_desktop_expanded_sidebar.to_s, command_palette: command_palette_data(project: @project).to_json } }
- if display_whats_new?
#whats-new-app{ data: { version_digest: whats_new_version_digest } }
@@ -51,4 +51,4 @@
-# This is needed by [GitLab JH](https://gitlab.com/gitlab-jh/jh-team/gitlab-cn/-/issues/81)
= render_if_exists "shared/footer/global_footer"
-= render "layouts/nav/top_nav_responsive", class: 'layout-page' unless show_super_sidebar?
+= render "layouts/nav/top_nav_responsive", class: 'layout-page' if !show_super_sidebar? || !current_user
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 53e88d95893..28cbdf0a7a1 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -13,10 +13,13 @@
= header_message
- if show_super_sidebar? # TODO: Move this CSS to a better place
- :css
- body {
- --header-height: 0px;
- }
+ - if current_user
+ :css
+ body {
+ --header-height: 0px;
+ }
+ - else
+ = render partial: "layouts/header/super_sidebar_logged_out"
- else
= render partial: "layouts/header/default", locals: { project: @project, group: @group }
= render 'layouts/page', sidebar: sidebar, nav: nav
diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml
index c75b02aa6a6..83641fbb184 100644
--- a/app/views/layouts/group.html.haml
+++ b/app/views/layouts/group.html.haml
@@ -21,7 +21,6 @@
= render 'groups/invite_members_modal', group: @group
= dispensable_render_if_exists "shared/web_hooks/group_web_hook_disabled_alert"
-= dispensable_render_if_exists "shared/code_suggestions_alert"
= dispensable_render_if_exists "shared/code_suggestions_third_party_alert", source: @group
= dispensable_render_if_exists "shared/free_user_cap_alert", source: @group
= dispensable_render_if_exists "shared/unlimited_members_during_trial_alert", resource: @group
diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml
index e04ffc2e88a..7ce914cf660 100644
--- a/app/views/layouts/header/_current_user_dropdown.html.haml
+++ b/app/views/layouts/header/_current_user_dropdown.html.haml
@@ -11,7 +11,7 @@
%li.divider
- if can?(current_user, :update_user_status, current_user)
%li
- %button.gl-button.btn.btn-link.menu-item.js-set-status-modal-trigger{ type: 'button' }
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'menu-item js-set-status-modal-trigger' }) do
- if current_user.status&.busy? || current_user.status&.customized?
= s_('SetStatusModal|Edit status')
- else
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index 1c22a853dd0..993094c6889 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -1,19 +1,12 @@
- has_impersonation_link = header_link?(:admin_impersonation)
- user_status_data = user_status_properties(current_user)
-%header.navbar.navbar-gitlab.navbar-expand-sm.js-navbar{ data: { testid: 'navbar' } }
+%header.navbar.navbar-gitlab.navbar-expand-sm.js-navbar.legacy-top-bar{ data: { testid: 'navbar' } }
%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.gl-mr-3
- .title
- %span.gl-sr-only GitLab
- = link_to root_path, title: _('Homepage'), id: 'logo', class: 'has-tooltip', **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation_top') do
- = brand_header_logo
- .gl-display-flex.gl-align-items-center
- - if Gitlab.com_and_canary?
- = gl_badge_tag({ variant: :success, size: :sm }, { href: Gitlab::Saas.canary_toggle_com_url, data: { testid: 'canary_badge_link' }, target: :_blank, rel: 'noopener noreferrer', class: 'canary-badge' }) do
- = _('Next')
+ = render 'layouts/header/title'
- if current_user
.gl-display-none.gl-sm-display-block
@@ -92,7 +85,7 @@
- if header_link?(:todos)
= nav_link(controller: 'dashboard/todos', html_options: { class: "user-counter" }) do
= link_to dashboard_todos_path, title: _('To-Do List'), aria: { label: _('To-Do List') }, class: 'shortcuts-todos js-prefetch-document',
- data: { testid: 'todos_shortcut_button', toggle: 'tooltip', placement: 'bottom',
+ data: { testid: 'todos-shortcut-button', toggle: 'tooltip', placement: 'bottom',
track_label: 'main_navigation',
track_action: 'click_to_do_link',
track_property: 'navigation_top',
diff --git a/app/views/layouts/header/_super_sidebar_logged_out.haml b/app/views/layouts/header/_super_sidebar_logged_out.haml
new file mode 100644
index 00000000000..67322aced74
--- /dev/null
+++ b/app/views/layouts/header/_super_sidebar_logged_out.haml
@@ -0,0 +1,47 @@
+%header.navbar.navbar-gitlab.super-sidebar-logged-out{ data: { testid: 'navbar' } }
+ %a.gl-sr-only.gl-accessibility{ href: "#content-body" } Skip to content
+ .container-fluid
+ .header-content.gl-displax-flex
+ .title-container.gl-display-flex.gl-align-items-stretch.gl-pt-0.gl-mr-3
+ = render 'layouts/header/title'
+
+ %ul.nav.navbar-sub-nav.gl-align-items-center.gl-display-flex.gl-flex-direction-row.gl-flex-grow-1
+ - if Gitlab.com?
+ %li.nav-item.dropdown.gl-mr-3.gl-md-display-none
+ %button{ type: "button", data: { toggle: "dropdown" } }
+ %span.gl-sr-only
+ = _('Menu')
+ = sprite_icon('hamburger', size: 16)
+ .dropdown-menu
+ %ul
+ %li
+ = link_to Gitlab::Utils.append_path(promo_url, 'why-gitlab') do
+ = s_('LoggedOutMarketingHeader|Why GitLab')
+ %li
+ = link_to Gitlab::Utils.append_path(promo_url, 'pricing') do
+ = s_('LoggedOutMarketingHeader|Pricing')
+ %li
+ = link_to Gitlab::Utils.append_path(promo_url, 'sales') do
+ = s_('LoggedOutMarketingHeader|Contact Sales')
+ %li
+ = link_to _("Explore"), explore_root_path
+ %li.nav-item.gl-mr-3.gl-display-none.gl-md-display-inline-block
+ = link_to Gitlab::Utils.append_path(promo_url, 'why-gitlab') do
+ = s_('LoggedOutMarketingHeader|Why GitLab')
+ %li.nav-item.gl-mr-3.gl-display-none.gl-md-display-inline-block
+ = link_to Gitlab::Utils.append_path(promo_url, 'pricing') do
+ = s_('LoggedOutMarketingHeader|Pricing')
+ %li.nav-item.gl-mr-3.gl-display-none.gl-md-display-inline-block
+ = link_to Gitlab::Utils.append_path(promo_url, 'sales') do
+ = s_('LoggedOutMarketingHeader|Contact Sales')
+ %li.nav-item{ class: ('gl-display-none gl-md-display-inline-block' if Gitlab.com?) }
+ = link_to _("Explore"), explore_root_path, class: ''
+
+ - if header_link?(:sign_in)
+ %ul.nav.navbar-nav.gl-align-items-center.gl-justify-content-end.gl-flex-direction-row
+ %li.nav-item.gl-mr-3
+ = link_to _('Sign in'), new_session_path(:user, redirect_to_referer: 'yes')
+ - if allow_signup?
+ %li
+ = render Pajamas::ButtonComponent.new(href: new_user_registration_path, variant: :confirm) do
+ = _('Register')
diff --git a/app/views/layouts/header/_title.html.haml b/app/views/layouts/header/_title.html.haml
new file mode 100644
index 00000000000..0e57c6809c2
--- /dev/null
+++ b/app/views/layouts/header/_title.html.haml
@@ -0,0 +1,8 @@
+.title
+ %span.gl-sr-only GitLab
+ = link_to root_path, title: _('Homepage'), id: 'logo', class: 'has-tooltip', **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation_top') do
+ = brand_header_logo
+ .gl-display-flex.gl-align-items-center
+ - if Gitlab.com_and_canary?
+ = gl_badge_tag({ variant: :success, size: :sm }, { href: Gitlab::Saas.canary_toggle_com_url, data: { testid: 'canary_badge_link' }, target: :_blank, rel: 'noopener noreferrer', class: 'canary-badge' }) do
+ = _('Next')
diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml
index 4ecae875056..18ae3353f4d 100644
--- a/app/views/layouts/project.html.haml
+++ b/app/views/layouts/project.html.haml
@@ -23,7 +23,6 @@
= render 'projects/invite_members_modal', project: @project
= dispensable_render_if_exists "shared/web_hooks/web_hook_disabled_alert"
-= dispensable_render_if_exists "projects/code_suggestions_alert", project: @project
= dispensable_render_if_exists "projects/code_suggestions_third_party_alert", project: @project
= dispensable_render_if_exists "projects/free_user_cap_alert", project: @project
= dispensable_render_if_exists 'shared/unlimited_members_during_trial_alert', resource: @project
diff --git a/app/views/notify/changed_reviewer_of_merge_request_email.html.haml b/app/views/notify/changed_reviewer_of_merge_request_email.html.haml
index ed7a3285f45..f080a5798f1 100644
--- a/app/views/notify/changed_reviewer_of_merge_request_email.html.haml
+++ b/app/views/notify/changed_reviewer_of_merge_request_email.html.haml
@@ -1,2 +1,4 @@
+= render_if_exists 'notify/address_new_reviewer_with_diff_summary'
+
%p
= change_reviewer_notification_text(@merge_request.reviewers, @previous_reviewers, :strong)
diff --git a/app/views/notify/changed_reviewer_of_merge_request_email.text.erb b/app/views/notify/changed_reviewer_of_merge_request_email.text.erb
index b6824966bb9..8db626548d7 100644
--- a/app/views/notify/changed_reviewer_of_merge_request_email.text.erb
+++ b/app/views/notify/changed_reviewer_of_merge_request_email.text.erb
@@ -1 +1,2 @@
+<%= render_if_exists 'notify/address_new_reviewer_with_diff_summary' -%>
<%= change_reviewer_notification_text(@merge_request.reviewers, @previous_reviewers) %>
diff --git a/app/views/notify/member_about_to_expire_email.html.haml b/app/views/notify/member_about_to_expire_email.html.haml
new file mode 100644
index 00000000000..a9f92d90ae6
--- /dev/null
+++ b/app/views/notify/member_about_to_expire_email.html.haml
@@ -0,0 +1,6 @@
+= email_default_heading(say_hi(@member.user))
+
+%p
+ = member_about_to_expire_text(@member_source, @days_to_expire, format: :html)
+%p
+ = member_about_to_expire_link(@member, @member_source, format: :html)
diff --git a/app/views/notify/member_about_to_expire_email.text.erb b/app/views/notify/member_about_to_expire_email.text.erb
new file mode 100644
index 00000000000..0c6e78bf501
--- /dev/null
+++ b/app/views/notify/member_about_to_expire_email.text.erb
@@ -0,0 +1,5 @@
+<%= say_hi(@member.user) %>
+
+<%= member_about_to_expire_text(@member_source, @days_to_expire) %>
+
+<%= member_about_to_expire_link(@member, @member_source) %>
diff --git a/app/views/notify/new_review_email.html.haml b/app/views/notify/new_review_email.html.haml
index afc1bd68215..8a184aa9696 100644
--- a/app/views/notify/new_review_email.html.haml
+++ b/app/views/notify/new_review_email.html.haml
@@ -22,3 +22,4 @@
- discussion.first_note.project = @project if discussion&.first_note
- target_url = project_merge_request_url(@project, @merge_request, anchor: "note_#{note.id}")
= render 'note_email', note: note, diff_limit: 3, target_url: target_url, note_style: "border-bottom:1px solid #ededed; padding-bottom: 1em;", include_stylesheet_link: false, discussion: discussion, author: @author
+ = render_if_exists 'notify/review_summary'
diff --git a/app/views/notify/new_review_email.text.erb b/app/views/notify/new_review_email.text.erb
index 69cb33b05df..e974c8b6be8 100644
--- a/app/views/notify/new_review_email.text.erb
+++ b/app/views/notify/new_review_email.text.erb
@@ -12,3 +12,5 @@
--
<% end %>
<% end %>
+
+<%= render_if_exists 'notify/review_summary' %>
diff --git a/app/views/notify/request_review_merge_request_email.html.haml b/app/views/notify/request_review_merge_request_email.html.haml
index a8c7df79ff3..d5f5d155f3d 100644
--- a/app/views/notify/request_review_merge_request_email.html.haml
+++ b/app/views/notify/request_review_merge_request_email.html.haml
@@ -1,2 +1,3 @@
%p
= html_escape(s_('Notify|%{name} requested a new review on %{mr_link}.')) % {name: sanitize_name(@updated_by.name), mr_link: merge_request_reference_link(@merge_request).html_safe}
+ = render_if_exists 'notify/diff_summary'
diff --git a/app/views/notify/request_review_merge_request_email.text.erb b/app/views/notify/request_review_merge_request_email.text.erb
index 9ab15332c51..dc1746d3a8c 100644
--- a/app/views/notify/request_review_merge_request_email.text.erb
+++ b/app/views/notify/request_review_merge_request_email.text.erb
@@ -1 +1,2 @@
<%= sanitize_name(@updated_by.name) %> requested a new review on <%= merge_request_reference_link(@merge_request) %>.
+<%= render_if_exists 'notify/diff_summary' -%>
diff --git a/app/views/profiles/_name.html.haml b/app/views/profiles/_name.html.haml
index d798eab7635..b0d9f142d88 100644
--- a/app/views/profiles/_name.html.haml
+++ b/app/views/profiles/_name.html.haml
@@ -4,6 +4,6 @@
%small.form-text.text-gl-muted
= s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you.") % { provider_label: attribute_provider_label(:name) }
- else
- = form.text_field :name, class: 'gl-form-input form-control', required: true, title: s_("Profiles|Using emojis in names seems fun, but please try to set a status message instead")
+ = form.text_field :name, class: 'gl-form-input form-control', required: true, title: s_("Profiles|Using emoji in names seems fun, but please try to set a status message instead")
%small.form-text.text-gl-muted
= s_("Profiles|Enter your name, so people you know can recognize you.")
diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml
index 743c26260e4..6dcd661ecdb 100644
--- a/app/views/profiles/emails/index.html.haml
+++ b/app/views/profiles/emails/index.html.haml
@@ -1,66 +1,84 @@
- page_title _('Emails')
+- profile_message = _('Used for avatar detection. You can change it in your %{openingTag}profile settings%{closingTag}.') % { openingTag: "<a href='#{profile_path}' class='gl-text-blue-500!'>".html_safe, closingTag: '</a>'.html_safe}
+- notification_message = _('Used for account notifications if a %{openingTag}group-specific email address%{closingTag} is not set.') % { openingTag: "<a href='#{profile_notifications_path}' class='gl-text-blue-500!'>".html_safe, closingTag: '</a>'.html_safe}
+- public_email_message = _('Your public email will be displayed on your public profile.')
+- commit_email_message = _('Used for web based operations, such as edits and merges.')
- @force_desktop_expanded_sidebar = true
+
.settings-section.js-search-settings-section
.settings-sticky-header
.settings-sticky-header-inner
%h4.gl-my-0
- = _('Add email address')
+ = s_('Profiles|Email addresses')
%p.gl-text-secondary
- = _('Control emails linked to your account')
- %div
- = gitlab_ui_form_for 'email', url: profile_emails_path do |f|
- .form-group
- = f.label :email, _('Email'), class: 'label-bold'
- = f.text_field :email, class: 'form-control gl-form-input', data: { qa_selector: 'email_address_field' }
- .gl-mt-3
- = f.submit _('Add email address'), data: { qa_selector: 'add_email_address_button' }, pajamas_button: true
+ = s_('Profiles|Control emails linked to your account')
-.settings-section.js-search-settings-section
- .settings-sticky-header
- .settings-sticky-header-inner
- %h4.gl-my-0
- = _('Linked emails (%{email_count})') % { email_count: @emails.load.size }
- .account-well.gl-mb-3
- %ul
- %li
- - profile_message = _('Your primary email is used for avatar detection. You can change it in your %{openingTag}profile settings%{closingTag}.') % { openingTag: "<a href='#{profile_path}'>".html_safe, closingTag: '</a>'.html_safe}
- = profile_message.html_safe
- %li
- = _('Your commit email is used for web based operations, such as edits and merges.')
- %li
- - notification_message = _('Your default notification email is used for account notifications if a %{openingTag}group-specific email address%{closingTag} is not set.') % { openingTag: "<a href='#{profile_notifications_path}'>".html_safe, closingTag: '</a>'.html_safe}
- = notification_message.html_safe
- %li
- = _('Your public email will be displayed on your public profile.')
- %li
- = _('All email addresses will be used to identify your commits.')
- %ul.content-list
- %li
- = 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
- %li= s_('Profiles|Commit email')
- - if @primary_email == current_user.public_email
- %li= s_('Profiles|Public email')
- - 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.gl-flex-wrap.gl-gap-3
- %div
- = render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? }
+ .settings-section.js-search-settings-section
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ %h3.gl-new-card-title
+ = s_('Profiles|Linked emails')
+ .gl-new-card-count
+ = sprite_icon('mail', css_class: 'gl-mr-2')
+ = @emails.load.size
+ .gl-new-card-actions
+ = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: "js-toggle-button js-toggle-content", data: { testid: 'toggle_email_address_field' } }) do
+ = s_('Profiles|Add new email')
+ - c.with_body do
+ .gl-new-card-add-form.gl-m-3.gl-mb-4.gl-display-none.js-toggle-content
+ %h4.gl-mt-0
+ = s_('Profiles|Add new email')
+ = gitlab_ui_form_for 'email', url: profile_emails_path do |f|
+ .form-group
+ = f.label :email, s_('Profiles|Email address'), class: 'label-bold'
+ = f.text_field :email, class: 'form-control gl-form-input gl-form-input-xl', data: { qa_selector: 'email_address_field' }
+ .gl-mt-3
+ = f.submit s_('Profiles|Add email address'), data: { qa_selector: 'add_email_address_button' }, pajamas_button: true
+ = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'gl-ml-2 js-toggle-button' }) do
+ = _('Cancel')
+ - if @emails.any?
+ %ul.content-list
+ %li{ class: 'gl-px-5!' }
+ = render partial: 'shared/email_with_badge', locals: { email: @primary_email, verified: current_user.confirmed? }
%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-reverse.gl-gap-3
- - unless email.confirmed?
- - confirm_title = "#{email.confirmation_sent_at ? _('Resend confirmation email') : _('Send confirmation email')}"
- = link_button_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, size: :small
+ %li.gl-mt-2
+ = s_('Profiles|Primary email')
+ .gl-text-secondary.gl-font-sm= profile_message.html_safe
+ - if @primary_email == current_user.commit_email_or_default
+ %li.gl-mt-2
+ = s_('Profiles|Commit email')
+ .gl-text-secondary.gl-font-sm= commit_email_message
+ - if @primary_email == current_user.public_email
+ %li.gl-mt-2
+ = s_('Profiles|Public email')
+ .gl-text-secondary.gl-font-sm= public_email_message
+ - if @primary_email == current_user.notification_email_or_default
+ %li.gl-mt-2
+ = s_('Profiles|Default notification email')
+ .gl-text-secondary.gl-font-sm= notification_message.html_safe
+ - @emails.reject(&:user_primary_email?).each do |email|
+ %li{ class: 'gl-px-5!', data: { qa_selector: 'email_row_content' } }
+ .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? }
+ %ul
+ - if email.email == current_user.commit_email_or_default
+ %li.gl-mt-2
+ = s_('Profiles|Commit email')
+ .gl-text-secondary.gl-font-sm= commit_email_message
+ - if email.email == current_user.public_email
+ %li.gl-mt-2
+ = s_('Profiles|Public email')
+ .gl-text-secondary.gl-font-sm= public_email_message
+ - if email.email == current_user.notification_email_or_default
+ %li.gl-mt-2
+ = s_('Profiles|Default notification email')
+ .gl-text-secondary.gl-font-sm= notification_message.html_safe
+ .gl-display-flex.gl-sm-justify-content-end.gl-align-items-flex-end.gl-flex-grow-1.gl-flex-wrap-reverse.gl-gap-3
+ - unless email.confirmed?
+ - confirm_title = "#{email.confirmation_sent_at ? s_('Profiles|Resend confirmation email') : s_('Profiles|Send confirmation email')}"
+ = link_button_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, size: :small
- = link_button_to nil, profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, variant: :danger, size: :small, icon: 'remove', 'aria-label': _('Remove')
+ = link_button_to nil, profile_email_path(email), data: { confirm: _('Are you sure?'), confirm_btn_variant: 'danger', qa_selector: 'delete_email_link'}, method: :delete, size: :small, icon: 'remove', 'aria-label': _('Remove')
diff --git a/app/views/profiles/gpg_keys/_form.html.haml b/app/views/profiles/gpg_keys/_form.html.haml
index ffd8bc3de27..2bc977feb24 100644
--- a/app/views/profiles/gpg_keys/_form.html.haml
+++ b/app/views/profiles/gpg_keys/_form.html.haml
@@ -8,3 +8,5 @@
.gl-mt-3
= f.submit s_('Profiles|Add key'), pajamas_button: true
+ = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'gl-ml-2 js-toggle-button' }) do
+ = _('Cancel')
diff --git a/app/views/profiles/gpg_keys/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml
index d8b8dda29dc..f8520cb430d 100644
--- a/app/views/profiles/gpg_keys/_key.html.haml
+++ b/app/views/profiles/gpg_keys/_key.html.haml
@@ -1,24 +1,29 @@
-%li.key-list-item
- .float-left.gl-mr-3
- = sprite_icon('key', css_class: "settings-list-icon d-none d-sm-block gl-mt-4")
- .key-list-item-info
+%tr.key-list-item
+ %td{ data: { label: s_('Profiles|Key') } }
+ %div{ class: 'gl-display-flex! gl-pl-0!' }
+ = sprite_icon('key', css_class: "settings-list-icon d-none d-sm-inline gl-mr-2")
+ .gl-display-flex.gl-flex-direction-column.gl-text-truncate
+ %p.gl-text-truncate.gl-m-0
+ %code= key.fingerprint
+ - if key.subkeys.present?
+ .subkeys.gl-mt-3{ class: 'gl-text-left!' }
+ %span.gl-font-sm
+ = _('Subkeys:')
+ %ul.subkeys-list
+ - key.subkeys.each do |subkey|
+ %li
+ %p.gl-text-truncate.gl-m-0
+ %code= subkey.fingerprint
+
+ %td{ data: { label: _('Status') } }
- key.emails_with_verified_status.map do |email, verified|
- = render partial: 'shared/email_with_badge', locals: { email: email, verified: verified }
+ %div{ class: 'gl-text-left!' }
+ = render partial: 'shared/email_with_badge', locals: { email: email, verified: verified }
+
+ %td{ data: { label: _('Created') } }
+ = html_escape(s_('Created %{time_ago}')) % { time_ago: time_ago_with_tooltip(key.created_at) }
- %span.text-truncate
- %code= key.fingerprint
- - if key.subkeys.present?
- .subkeys
- %span.bold
- = _('Subkeys')
- = ':'
- %ul.subkeys-list
- - key.subkeys.each do |subkey|
- %li
- %code= subkey.fingerprint
- .float-right
- %span.key-created-at
- = html_escape(s_('Profiles|Created %{time_ago}')) % { time_ago: time_ago_with_tooltip(key.created_at) }
- = link_button_to nil, profile_gpg_key_path(key), data: { confirm: _('Are you sure? Removing this GPG key does not affect already signed commits.') }, method: :delete, class: 'gl-ml-3', variant: :danger, icon: 'remove', 'aria-label': _('Remove')
- = link_button_to revoke_profile_gpg_key_path(key), data: { confirm: _('Are you sure? All commits that were signed with this GPG key will be unverified.') }, method: :put, class: 'gl-ml-3', variant: :danger, 'aria-label': _('Revoke') do
+ %td{ class: 'gl-py-3!', data: { label: _('Actions') } }
+ = link_button_to nil, profile_gpg_key_path(key), data: { confirm: _('Are you sure? Removing this GPG key does not affect already signed commits.'), confirm_btn_variant: 'danger' }, method: :delete, class: 'has-tooltip', icon: 'remove', category: :secondary, 'title': _('Remove'), 'aria-label': _('Remove')
+ = link_button_to revoke_profile_gpg_key_path(key), data: { confirm: _('Are you sure? All commits that were signed with this GPG key will be unverified.'), confirm_btn_variant: 'danger' }, method: :put, class: 'gl-ml-3', category: :secondary, variant: :danger, 'aria-label': _('Revoke') do
= _('Revoke')
diff --git a/app/views/profiles/gpg_keys/_key_table.html.haml b/app/views/profiles/gpg_keys/_key_table.html.haml
index ebbd1c8f672..0a50ce55b50 100644
--- a/app/views/profiles/gpg_keys/_key_table.html.haml
+++ b/app/views/profiles/gpg_keys/_key_table.html.haml
@@ -1,10 +1,19 @@
- is_admin = local_assigns.fetch(:admin, false)
+- hide_class = local_assigns.fetch(:hide_class, false)
- if @gpg_keys.any?
- %ul.content-list
- = render partial: 'profiles/gpg_keys/key', collection: @gpg_keys, locals: { is_admin: is_admin }
+ .table-holder
+ %table.table.b-table.gl-table.b-table-stacked-md.gl-mt-n1.gl-mb-n2.ssh-keys-list{ data: { qa_selector: 'ssh_keys_list' } }
+ %thead.d-none.d-md-table-header-group
+ %tr
+ %th= s_('Profiles|Key')
+ %th= _('Status')
+ %th= _('Created')
+ %th= _('Actions')
+ = render partial: 'profiles/gpg_keys/key', collection: @gpg_keys, locals: { is_admin: is_admin }
+
- else
- %p.settings-message.text-center
+ %p.gl-new-card-empty.gl-px-5.gl-py-4.js-toggle-content{ class: hide_class }
- if is_admin
= _('There are no GPG keys associated with this account.')
- else
diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml
index 2dfd6c7860f..2714193d1d1 100644
--- a/app/views/profiles/gpg_keys/index.html.haml
+++ b/app/views/profiles/gpg_keys/index.html.haml
@@ -1,6 +1,8 @@
- page_title _('GPG Keys')
- add_page_specific_style 'page_bundles/profile'
- @force_desktop_expanded_sidebar = true
+- add_form_class = 'gl-display-none' if !form_errors(@gpg_key)
+- hide_class = 'gl-display-none' if form_errors(@gpg_key)
.settings-section.js-search-settings-section
.settings-sticky-header
@@ -10,17 +12,24 @@
%p.gl-text-secondary
= _('GPG keys allow you to verify signed commits.')
- %h5.gl-font-lg.gl-mt-0
- = _('Add a GPG key')
- %p
- - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/repository/gpg_signed_commits/index.md') }
- = _('Add a GPG key for secure access to GitLab. %{help_link_start}Learn more%{help_link_end}.').html_safe % {help_link_start: help_link_start, help_link_end: '</a>'.html_safe }
- = render 'form'
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ %h3.gl-new-card-title
+ = _('Your GPG keys')
+ .gl-new-card-count
+ = sprite_icon('key', css_class: 'gl-mr-2')
+ = @gpg_keys.count
+ .gl-new-card-actions
+ = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: "js-toggle-button js-toggle-content #{hide_class}" }) do
+ = _('Add new key')
+ - c.with_body do
+ .gl-new-card-add-form.gl-m-3.js-toggle-content{ class: add_form_class }
+ %h4.gl-mt-0
+ = _('Add a GPG key')
+ %p
+ - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/repository/gpg_signed_commits/index.md') }
+ = _('Add a GPG key for secure access to GitLab. %{help_link_start}Learn more%{help_link_end}.').html_safe % {help_link_start: help_link_start, help_link_end: '</a>'.html_safe }
+ = render 'form'
-.settings-section.js-search-settings-section
- .settings-sticky-header
- .settings-sticky-header-inner
- %h4.gl-my-0
- = _('Your GPG keys (%{count})') % { count: @gpg_keys.count }
- .gl-mb-3
- = render 'key_table'
+ = render 'key_table', hide_class: hide_class
diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml
index 5c4ea7b2ecb..b1df63a72ab 100644
--- a/app/views/profiles/keys/_form.html.haml
+++ b/app/views/profiles/keys/_form.html.haml
@@ -25,12 +25,13 @@
%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' }
+ .bs-callout.bs-callout-warning.gl-mt-0{ role: 'alert', aria_live: 'assertive' }
%strong= _('Oops, are you sure?')
%p= s_("Profiles|Publicly visible private SSH keys can compromise your system.")
-
= render Pajamas::ButtonComponent.new(variant: :confirm,
button_options: { class: 'js-add-ssh-key-validation-confirm-submit' }) do
= _("Yes, add it")
- .gl-mt-3
- = f.submit s_('Profiles|Add key'), class: "js-add-ssh-key-validation-original-submit", pajamas_button: true, data: { qa_selector: 'add_key_button' }
+
+ = f.submit s_('Profiles|Add key'), class: "js-add-ssh-key-validation-original-submit", pajamas_button: true, data: { qa_selector: 'add_key_button' }
+ = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'js-add-ssh-key-validation-cancel gl-ml-2 js-toggle-button' }) do
+ = _('Cancel')
diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml
index 288007ec806..7ba42274f88 100644
--- a/app/views/profiles/keys/_key.html.haml
+++ b/app/views/profiles/keys/_key.html.haml
@@ -1,43 +1,49 @@
- icon_classes = 'settings-list-icon gl-display-none gl-sm-display-block'
-%li.key-list-item
- .gl-display-flex.gl-align-items-flex-start
- .key-list-item-info.gl-w-full.float-none
- = link_to path_to_key(key, is_admin), class: "title text-3" do
- = key.title
+%tr.key-list-item
+ %td{ data: { label: _('Title'), testid: 'title' } }
+ = link_to path_to_key(key, is_admin) do
+ = key.title
- .gl-display-flex.gl-align-items-center.gl-mt-2
- - if key.valid? && !key.expired?
- = sprite_icon('key', css_class: icon_classes)
- - else
- %span.gl-display-inline-block.has-tooltip{ title: ssh_key_expiration_tooltip(key) }
- = sprite_icon('warning-solid', css_class: icon_classes)
+ %td{ data: { label: s_('Profiles|Key'), testid: 'key' } }
+ .gl-align-items-center{ class: 'gl-display-flex! gl-pl-0!' }
+ - if key.valid? && !key.expired?
+ = sprite_icon('key', css_class: icon_classes)
+ - else
+ %span.gl-display-inline-block.has-tooltip{ title: ssh_key_expiration_tooltip(key) }
+ = sprite_icon('warning-solid', css_class: icon_classes)
+ %span.gl-text-truncate.gl-sm-ml-3
+ = key.fingerprint
- %span.gl-text-truncate.gl-sm-ml-3
- = key.fingerprint
+ %td{ data: { label: s_('Profiles|Usage type'), testid: 'usage-type' } }
+ = ssh_key_usage_types.invert[key.usage_type]
- .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}
+ %td{ data: { label: s_('Profiles|Created'), testid: 'created' } }
+ = html_escape(s_('%{time_ago}')) % { time_ago: time_ago_with_tooltip(key.created_at).html_safe}
- .key-list-item-dates
- %span.last-used-at.gl-mr-3
- = s_('Profiles|Last used:')
- -# TODO: Remove this conditional when https://gitlab.com/gitlab-org/gitlab/-/issues/324764 is resolved.
- - if Feature.enabled?(:disable_ssh_key_used_tracking)
- = _('Unavailable')
- = link_to sprite_icon('question-o'), help_page_path('user/ssh.md', anchor: 'view-your-accounts-ssh-keys')
- - else
- = key.last_used_at ? time_ago_with_tooltip(key.last_used_at) : _('Never')
- %span.expires.gl-mr-3
- = key.expired? ? s_('Profiles|Expired:') : s_('Profiles|Expires:')
- = key.expires_at ? key.expires_at.to_date : _('Never')
- %span.last-used-at.gl-mr-3
- = s_('Profiles|Usage type:')
- = ssh_key_usage_types.invert[key.usage_type]
- .gl-display-flex.gl-float-right
- - if key.can_delete?
- - if key.signing? && !is_admin
- = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-confirm-modal-button', data: ssh_key_revoke_modal_data(key, revoke_profile_key_path(key)) }) do
- = _('Revoke')
- .gl-pl-3
- = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-confirm-modal-button', data: ssh_key_delete_modal_data(key, path_to_key(key, is_admin)) }) do
- = _('Remove')
+ %td{ data: { label: s_('Profiles|Last used'), testid: 'last-used' } }
+ -# TODO: Remove this conditional when https://gitlab.com/gitlab-org/gitlab/-/issues/324764 is resolved.
+ - if Feature.enabled?(:disable_ssh_key_used_tracking)
+ = _('Unavailable')
+ = link_to sprite_icon('question-o'), help_page_path('user/ssh.md', anchor: 'view-your-accounts-ssh-keys')
+ - else
+ = key.last_used_at ? time_ago_with_tooltip(key.last_used_at) : _('Never')
+
+ %td{ data: { label: s_('Profiles|Expires'), testid: 'expires' } }
+ - if key.expired?
+ %span.gl-text-red-500
+ = s_('Profiles|Expired')
+ = key.expires_at.to_date
+ - elsif key.expires_at
+ = key.expires_at.to_date
+ - else
+ = _('Never')
+
+ %td{ data: { label: _('Actions'), testid: 'actions' } }
+ %div{ class: 'gl-display-flex! gl-pl-0!' }
+ - if key.can_delete?
+ - if key.signing? && !is_admin
+ = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-confirm-modal-button', 'aria-label' => _('Revoke'), data: ssh_key_revoke_modal_data(key, revoke_profile_key_path(key)) }) do
+ = _('Revoke')
+ .gl-pl-3
+ = render Pajamas::ButtonComponent.new(size: :small, icon: 'remove', button_options: { title: _('Remove'), 'aria-label' => _('Remove'), class: 'js-confirm-modal-button', data: ssh_key_delete_modal_data(key, path_to_key(key, is_admin)) })
diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml
index f1d5a127728..d5193a424ef 100644
--- a/app/views/profiles/keys/_key_details.html.haml
+++ b/app/views/profiles/keys/_key_details.html.haml
@@ -1,45 +1,70 @@
- is_admin = defined?(admin) ? true : false
-.row.gl-mt-3
- .col-md-4
- = render Pajamas::CardComponent.new(body_options: { class: 'gl-py-0'}) do |c|
- - c.with_header do
- = _('SSH Key')
- - c.with_body do
- %ul.content-list
- %li
- %span.light= _('Title:')
- %strong= @key.title
- %li
- %span.light= s_('Profiles|Usage type:')
- %strong= ssh_key_usage_types.invert[@key.usage_type]
- %li
- %span.light= _('Created on:')
- %strong= @key.created_at.to_fs(:medium)
- %li
- %span.light= _('Expires:')
- %strong= @key.expires_at&.to_fs(:medium) || _('Never')
- %li
- %span.light= _('Last used on:')
- %strong= @key.last_used_at&.to_fs(:medium) || _('Never')
- .col-md-8
- = form_errors(@key, type: 'key') unless @key.valid?
- %pre.well-pre
- = @key.key
- = render Pajamas::CardComponent.new(body_options: { class: 'gl-py-0'}) do |c|
- - c.with_header do
- = _('Fingerprints')
- - c.with_body do
- %ul.content-list
- %li
- %span.light= 'MD5:'
- %code.key-fingerprint= @key.fingerprint
- - if @key.fingerprint_sha256.present?
- %li
- %span.light= 'SHA256:'
- %code.key-fingerprint= @key.fingerprint_sha256
+%h1.gl-font-size-h-display
+ = s_('Profiles|SSH Key: %{title}').html_safe % { title: @key.title }
- .col-md-12
- .float-right
- - if @key.can_delete?
- = render 'shared/ssh_keys/key_delete', button_data: ssh_key_delete_modal_data(@key, path_to_key(@key, is_admin))
+= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0'}) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ .gl-new-card-title
+ = _('Key details')
+ - c.with_body do
+ .table-holder
+ %table.table.b-table.gl-table.b-table-stacked-md.ssh-keys-list
+ %thead
+ %th= s_('Profiles|Usage type')
+ %th= s_('Profiles|Created')
+ %th= s_('Profiles|Last used')
+ %th= s_('Profiles|Expires')
+ %tbody
+ %tr
+ %td{ data: { label: s_('Profiles|Usage type'), testid: 'usage' } }
+ = ssh_key_usage_types.invert[@key.usage_type]
+ %td{ data: { label: s_('Profiles|Created'), testid: 'created' } }
+ = @key.created_at.to_fs(:medium)
+ %td{ data: { label: s_('Profiles|Last used'), testid: 'last-used' } }
+ = @key.last_used_at&.to_fs(:medium) || _('Never')
+ %td{ data: { label: s_('Profiles|Expires'), testid: 'expires' } }
+ - if @key.expired?
+ %span.gl-text-red-500
+ = s_('Profiles|Expired')
+ = @key.expires_at&.to_fs(:medium)
+ - elsif @key.expires_at
+ = @key.expires_at&.to_fs(:medium)
+ - else
+ = _('Never')
+
+.gl-mt-5
+ = form_errors(@key, type: 'key') unless @key.valid?
+
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0 gl-overflow-hidden'}) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ .gl-new-card-title
+ = _('SSH Key')
+ - c.with_body do
+ .gl-display-flex
+ %pre.well-pre.gl-pl-5.gl-mb-0.gl-border-0
+ = @key.key
+ = clipboard_button(title: s_('Profiles|Copy SSH key'), text: @key.key, class: 'gl-bg-gray-10 gl-px-3! gl-border-none! gl-rounded-top-left-none! gl-rounded-bottom-left-none!')
+
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0'}) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ .gl-new-card-title
+ = _('Fingerprints')
+ - c.with_body do
+ .table-holder
+ %table.table.b-table.gl-table.b-table-stacked-md
+ %tbody
+ %tr
+ %th= _('MD5')
+ %td.gl-font-monospace.key-fingerprint= @key.fingerprint
+ - if @key.fingerprint_sha256.present?
+ %tr
+ %th= _('SHA256')
+ %td.gl-font-monospace.key-fingerprint= @key.fingerprint_sha256
+
+.gl-mt-5.gl-float-right
+ - if @key.can_delete?
+ = render 'shared/ssh_keys/key_delete', button_data: ssh_key_delete_modal_data(@key, path_to_key(@key, is_admin))
diff --git a/app/views/profiles/keys/_key_table.html.haml b/app/views/profiles/keys/_key_table.html.haml
index 176d7a42002..cfe507ad65d 100644
--- a/app/views/profiles/keys/_key_table.html.haml
+++ b/app/views/profiles/keys/_key_table.html.haml
@@ -1,10 +1,21 @@
- is_admin = local_assigns.fetch(:admin, false)
+- hide_class = local_assigns.fetch(:hide_class, false)
- if @keys.any?
- %ul.content-list.ssh-keys-list{ data: { qa_selector: 'ssh_keys_list' } }
- = render partial: 'profiles/keys/key', collection: @keys, locals: { is_admin: is_admin }
+ .table-holder
+ %table.table.b-table.gl-table.b-table-stacked-md.gl-mt-n1.gl-mb-n2.ssh-keys-list{ data: { qa_selector: 'ssh_keys_list' } }
+ %thead.d-none.d-md-table-header-group
+ %tr
+ %th= _('Title')
+ %th= s_('Profiles|Key')
+ %th= s_('Profiles|Usage type')
+ %th= s_('Profiles|Created')
+ %th= s_('Profiles|Last used')
+ %th= s_('Profiles|Expires')
+ %th= _('Actions')
+ = render partial: 'profiles/keys/key', collection: @keys, locals: { is_admin: is_admin }
- else
- %p.settings-message.text-center
+ %p.gl-new-card-empty.gl-px-5.gl-py-4.js-toggle-content{ class: hide_class }
- if is_admin
= _('There are no SSH keys associated with this account.')
- else
diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml
index c2e65dcc8ef..0cd41788a53 100644
--- a/app/views/profiles/keys/index.html.haml
+++ b/app/views/profiles/keys/index.html.haml
@@ -1,6 +1,8 @@
- page_title _('SSH Keys')
- add_page_specific_style 'page_bundles/profile'
- @force_desktop_expanded_sidebar = true
+- add_form_class = 'gl-display-none' if !form_errors(@key)
+- hide_class = 'gl-display-none' if form_errors(@key)
.settings-section.js-search-settings-section
.settings-sticky-header
@@ -12,17 +14,24 @@
- config_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_instance_configuration_url }
= html_escape(s_('SSH fingerprints verify that the client is connecting to the correct host. Check the %{config_link_start}current instance configuration%{config_link_end}.')) % { config_link_start: config_link_start, config_link_end: '</a>'.html_safe }
- %h5.gl-font-lg.gl-mt-0
- = _('Add an SSH key')
- %p
- - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/ssh.md') }
- = _('Add an SSH key for secure access to GitLab. %{help_link_start}Learn more%{help_link_end}.').html_safe % {help_link_start: help_link_start, help_link_end: '</a>'.html_safe }
- = render 'form'
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ %h3.gl-new-card-title
+ = _('Your SSH keys')
+ .gl-new-card-count
+ = sprite_icon('key', css_class: 'gl-mr-2')
+ = @keys.count
+ .gl-new-card-actions
+ = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: "js-toggle-button js-toggle-content #{hide_class}" }) do
+ = _('Add new key')
+ - c.with_body do
+ .gl-new-card-add-form.gl-m-3.js-toggle-content{ class: add_form_class }
+ %h4.gl-mt-0
+ = _('Add an SSH key')
+ %p
+ - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/ssh.md') }
+ = _('Add an SSH key for secure access to GitLab. %{help_link_start}Learn more%{help_link_end}.').html_safe % {help_link_start: help_link_start, help_link_end: '</a>'.html_safe }
+ = render 'form'
-.settings-section.js-search-settings-section
- .settings-sticky-header
- .settings-sticky-header-inner
- %h4.gl-my-0
- = _('Your SSH keys (%{count})') % { count: @keys.count }
- .gl-mb-3
- = render 'key_table'
+ = render 'key_table', hide_class: hide_class
diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml
index 5020f6cbb22..c12f6907afb 100644
--- a/app/views/profiles/personal_access_tokens/index.html.haml
+++ b/app/views/profiles/personal_access_tokens/index.html.haml
@@ -16,13 +16,26 @@
#js-new-access-token-app{ data: { access_token_type: type } }
- = render 'shared/access_tokens/form',
- ajax: true,
- type: type,
- path: profile_personal_access_tokens_path,
- token: @personal_access_token,
- scopes: @scopes,
- help_path: help_page_path('user/profile/personal_access_tokens.md', anchor: 'personal-access-token-scopes')
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card gl-border-b-0 gl-rounded-bottom-left-none gl-rounded-bottom-right-none js-toggle-container js-token-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ %h3.gl-new-card-title
+ = _('Active personal access tokens')
+ .gl-new-card-count
+ = sprite_icon('token', css_class: 'gl-mr-2')
+ %span.js-token-count= @active_access_tokens.size
+ .gl-new-card-actions
+ = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-toggle-button js-toggle-content', data: { testid: 'add-new-token-button' } }) do
+ = _('Add new token')
+ - c.with_body do
+ .gl-new-card-add-form.gl-mt-3.gl-display-none.js-toggle-content.js-add-new-token-form
+ = render 'shared/access_tokens/form',
+ ajax: true,
+ type: type,
+ path: profile_personal_access_tokens_path,
+ token: @personal_access_token,
+ scopes: @scopes,
+ help_path: help_page_path('user/profile/personal_access_tokens.md', anchor: 'personal-access-token-scopes')
#js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json } }
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index e5e7c1dc3f4..681d4e087f3 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -3,6 +3,8 @@
- user_theme_id = Gitlab::Themes.for_user(@user).id
- user_color_schema_id = Gitlab::ColorSchemes.for_user(@user).id
- user_fields = { theme: user_theme_id, gitpod_enabled: @user.gitpod_enabled, sourcegraph_enabled: @user.sourcegraph_enabled }.to_json
+- fixed_help_text = s_('Preferences|Content will be a maximum of 1280 pixels wide.')
+- fluid_help_text = s_('Preferences|Content will span %{percentage} of the page width.').html_safe % { percentage: '100%' }
- @themes = Gitlab::Themes::available_themes.to_json
- data_attributes = { themes: @themes, integration_views: integration_views.to_json, user_fields: user_fields, body_classes: Gitlab::Themes.body_classes, profile_preferences_path: profile_preferences_path }
- @force_desktop_expanded_sidebar = true
@@ -11,6 +13,7 @@
= stylesheet_link_tag "themes/#{theme.css_filename}" if theme.css_filename
= gitlab_ui_form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { id: "profile-preferences-form" } do |f|
+ = render_if_exists 'profiles/preferences/code_suggestions_settings_self_assignment'
.settings-section.js-preferences-form.js-search-settings-section.application-theme#navigation-theme
.settings-sticky-header
.settings-sticky-header-inner
@@ -18,9 +21,6 @@
= s_('Preferences|Color theme')
%p.gl-text-secondary
= s_('Preferences|Customize the color of GitLab.')
- - if show_super_sidebar?
- %p
- = s_('Preferences|Note: You have the new navigation enabled, so only Dark Mode theme significantly changes GitLab\'s appearance.')
.application-theme.row
- Gitlab::Themes.each do |theme|
%label.col-6.col-sm-4.col-md-3.col-xl-2.gl-mb-5
@@ -37,7 +37,7 @@
%p.gl-text-secondary
= s_('Preferences|Customize the appearance of the syntax.')
= succeed '.' do
- = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'change-the-syntax-highlighting-theme'), target: '_blank', rel: 'noopener noreferrer'
.syntax-theme.row
- Gitlab::ColorSchemes.each do |scheme|
%label.col-6.col-sm-4.col-md-3.col-lg-auto.gl-mb-5
@@ -68,17 +68,17 @@
.form-group
= f.label :layout, class: 'label-bold' do
= s_('Preferences|Layout width')
- = f.select :layout, layout_choices, {}, class: 'gl-form-select custom-select'
- .form-text.text-muted
- = s_('Preferences|Choose between fixed (max. 1280px) and fluid (%{percentage}) application layout.').html_safe % { percentage: '100%' }
- .js-listbox-input{ data: { label: s_('Preferences|Homepage'), description: s_('Preferences|Choose what content you want to see by default on your homepage.'), name: 'user[dashboard]', items: dashboard_choices.to_json, value: current_user.dashboard, block: true.to_s, fluid_width: true.to_s } }
+ = f.gitlab_ui_radio_component :layout, layout_choices[0][1], layout_choices[0][0], help_text: fixed_help_text
+ = f.gitlab_ui_radio_component :layout, layout_choices[1][1], layout_choices[1][0], help_text: fluid_help_text
+
+ .js-listbox-input{ data: { label: s_('Preferences|Homepage'), description: s_('Preferences|Choose what content you want to see by default on your homepage.'), name: 'user[dashboard]', items: dashboard_choices.to_json, value: current_user.dashboard, block: true.to_s, toggle_class: 'gl-form-input-xl' } }
= render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific
.form-group
= f.label :project_view, class: 'label-bold' do
= s_('Preferences|Project overview content')
- = f.select :project_view, project_view_choices, {}, class: 'gl-form-select custom-select'
+ = f.select :project_view, project_view_choices, {}, class: 'gl-form-select custom-select gl-form-input-xl gl-display-block'
.form-text.text-muted
= s_('Preferences|Choose what content you want to see on a project’s overview page.')
.form-group
@@ -104,7 +104,7 @@
.form-group
= f.label :tab_width, s_('Preferences|Tab width'), class: 'label-bold'
= f.number_field :tab_width,
- class: 'form-control gl-form-input',
+ class: 'form-control gl-form-input gl-max-w-15',
min: Gitlab::TabWidth::MIN,
max: Gitlab::TabWidth::MAX,
required: true
@@ -120,7 +120,8 @@
= _('Customize language and region related settings.')
= succeed '.' do
= link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'localization'), target: '_blank', rel: 'noopener noreferrer'
- .js-listbox-input{ data: { label: _('Language'), description: s_('Preferences|This feature is experimental and translations are not yet complete.'), name: 'user[preferred_language]', items: language_choices.to_json, value: current_user.preferred_language, block: true.to_s, fluid_width: true.to_s } }
+
+ .js-listbox-input{ data: { label: _('Language'), description: s_('Preferences|This feature is experimental and translations are not yet complete.'), name: 'user[preferred_language]', items: language_choices.to_json, value: current_user.preferred_language, block: true.to_s, toggle_class: 'gl-form-input-xl' } }
%p.gl-mt-n5
= link_to help_page_url('development/i18n/translation'), class: 'text-nowrap', target: '_blank', rel: 'noopener noreferrer' do
= _("Help translate GitLab into your language")
@@ -129,7 +130,7 @@
.form-group
= f.label :first_day_of_week, class: 'label-bold' do
= _('First day of the week')
- = f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'gl-form-select custom-select'
+ = f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'gl-form-select custom-select gl-display-block gl-form-input-xl'
.settings-section.js-preferences-form.js-search-settings-section#time-preferences
.settings-sticky-header
@@ -144,19 +145,18 @@
= f.gitlab_ui_checkbox_component :time_display_relative,
s_('Preferences|Use relative times'),
help_text: s_('Preferences|For example: 30 minutes ago.')
- - if Feature.enabled?(:disable_follow_users, @user)
- .settings-section.js-preferences-form.js-search-settings-section#enabled_following
- .settings-sticky-header
- .settings-sticky-header-inner
- %h4.gl-my-0
- = s_('Preferences|Enable follow users feature')
- %p.gl-text-secondary
- = s_('Preferences|Turns on or off the ability to follow or be followed by other users.')
- = succeed '.' do
- = link_to _('Learn more'), help_page_path('user/profile/index', anchor: 'follow-users'), target: '_blank', rel: 'noopener noreferrer'
- .form-group
- = f.gitlab_ui_checkbox_component :enabled_following,
- s_('Preferences|Enable follow users')
+ .settings-section.js-preferences-form.js-search-settings-section#enabled_following
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = s_('Preferences|Enable follow users feature')
+ %p.gl-text-secondary
+ = s_('Preferences|Turns on or off the ability to follow or be followed by other users.')
+ = succeed '.' do
+ = link_to _('Learn more'), help_page_path('user/profile/index', anchor: 'follow-users'), target: '_blank', rel: 'noopener noreferrer'
+ .form-group
+ = f.gitlab_ui_checkbox_component :enabled_following,
+ s_('Preferences|Enable follow users')
= render_if_exists 'profiles/preferences/code_suggestions_settings', form: f
= render_if_exists 'profiles/preferences/zoekt_settings', form: f
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index ebdea5786f5..4da48771ba3 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -5,7 +5,7 @@
- @force_desktop_expanded_sidebar = true
- if Feature.enabled?(:edit_user_profile_vue, current_user)
- .js-user-profile
+ .js-user-profile{ data: user_profile_data(@user) }
- else
= gitlab_ui_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user js-edit-user js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f|
.settings-section.js-search-settings-section
diff --git a/app/views/projects/_deletion_failed.html.haml b/app/views/projects/_deletion_failed.html.haml
index 29551505a7e..7ddb80c90f9 100644
--- a/app/views/projects/_deletion_failed.html.haml
+++ b/app/views/projects/_deletion_failed.html.haml
@@ -5,5 +5,7 @@
dismissible: false,
alert_options: { class: 'project-deletion-failed-message' }) do |c|
- c.with_body do
- This project was scheduled for deletion, but failed with the following message:
+ = _('This project was scheduled for deletion, but failed with the following message:')
= project.delete_error
+ %br
+ = _('The project visibility may have been made more restrictive if the parent group\'s visibility changed while the deletion was scheduled.')
diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml
index 3ef2c722e98..20fb2b43c63 100644
--- a/app/views/projects/_export.html.haml
+++ b/app/views/projects/_export.html.haml
@@ -2,30 +2,35 @@
- project = local_assigns.fetch(:project)
-.sub-section{ data: { qa_selector: 'export_project_content' } }
- %h4= _('Export project')
- - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/import_export') }
- %p= _('Export this project with all its related data in order to move it to a new GitLab instance. When the exported file is ready, you can download it from this page or from the download link in the email notification you will receive. You can then import it when creating a new project. %{link_start}Learn more.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
- %p.gl-mb-0
- %p= _('The following items will be exported:')
- %ul
- - project_export_descriptions.each do |desc|
- %li= desc
- %p= _('The following items will NOT be exported:')
- %ul
- %li= _('Job logs and artifacts')
- %li= _('Container registry images')
- %li= _('CI variables')
- %li= _('Pipeline triggers')
- %li= _('Webhooks')
- %li= _('Any encrypted tokens')
- - if project.export_status == :finished
- = render Pajamas::ButtonComponent.new(href: download_export_project_path(project),
- method: :get,
- button_options: { ref: 'nofollow', download: '', data: { qa_selector: 'download_export_link' } }) do
- = _('Download export')
- = render Pajamas::ButtonComponent.new(href: generate_new_export_project_path(project), method: :post) do
- = _('Generate new export')
- - else
- = render Pajamas::ButtonComponent.new(href: export_project_path(project), method: :post, button_options: { data: { qa_selector: 'export_project_link' } }) do
- = _('Export project')
+= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card', data: { qa_selector: 'export_project_content' } }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ %h4.gl-new-card-title= _('Export project')
+
+ - c.with_body do
+ %p
+ - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/import_export') }
+ = _('Export this project with all its related data in order to move it to a new GitLab instance. When the exported file is ready, you can download it from this page or from the download link in the email notification you will receive. You can then import it when creating a new project. %{link_start}Learn more.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+ .gl-mb-0
+ %p.gl-font-weight-bold= _('The following items will be exported:')
+ %ul
+ - project_export_descriptions.each do |desc|
+ %li= desc
+ %p.gl-font-weight-bold= _('The following items will NOT be exported:')
+ %ul
+ %li= _('Job logs and artifacts')
+ %li= _('Container registry images')
+ %li= _('CI variables')
+ %li= _('Pipeline triggers')
+ %li= _('Webhooks')
+ %li= _('Any encrypted tokens')
+ - if project.export_status == :finished
+ = render Pajamas::ButtonComponent.new(href: download_export_project_path(project),
+ method: :get,
+ button_options: { ref: 'nofollow', download: '', data: { qa_selector: 'download_export_link' } }) do
+ = _('Download export')
+ = render Pajamas::ButtonComponent.new(href: generate_new_export_project_path(project), method: :post) do
+ = _('Generate new export')
+ - else
+ = render Pajamas::ButtonComponent.new(href: export_project_path(project), method: :post, button_options: { data: { qa_selector: 'export_project_link' } }) do
+ = _('Export project')
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index b5bbb57d58f..cb341ede9de 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -3,19 +3,19 @@
- ref = local_assigns.fetch(:ref) { current_ref }
- project = local_assigns.fetch(:project) { @project }
- has_project_shortcut_buttons = !current_user || current_user.project_shortcut_buttons
-- add_page_startup_api_call logs_file_project_ref_path(@project, ref, @path, format: "json", offset: 0)
+- add_page_startup_api_call logs_file_project_ref_path(@project, ref, @path, format: "json", offset: 0, ref_type: @ref_type)
- if readme_path = @project.repository.readme_path
- add_page_startup_api_call project_blob_path(@project, tree_join(@ref, readme_path), viewer: "rich", format: "json")
#tree-holder.tree-holder.clearfix.js-per-page{ data: { blame_per_page: Gitlab::Git::BlamePagination::PAGINATION_PER_PAGE } }
.info-well.gl-display-none.gl-sm-display-flex.project-last-commit.gl-flex-direction-column.gl-mt-5
- #js-last-commit.gl-m-auto
+ #js-last-commit.gl-m-auto{ data: {ref_type: @ref_type.to_s} }
= gl_loading_icon(size: 'md')
- if project.licensed_feature_available?(:code_owners)
#js-code-owners{ data: { branch: @ref, can_view_branch_rules: can_view_branch_rules?, branch_rules_path: branch_rules_path } }
.nav-block.gl-display-flex.gl-xs-flex-direction-column.gl-align-items-stretch
- = render 'projects/tree/tree_header', tree: @tree, is_project_overview: is_project_overview
+ = render 'projects/tree/tree_header', tree: @tree
- if project.forked?
#js-fork-info{ data: vue_fork_divergence_data(project, ref) }
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index 59147138834..4ac30547ce3 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -8,10 +8,9 @@
%div{ class: 'avatar-container rect-avatar s64 home-panel-avatar gl-flex-shrink-0 gl-w-11 gl-h-11 gl-mr-3! float-none' }
= project_icon(@project, alt: @project.name, class: 'avatar avatar-tile s64', width: 64, height: 64, itemprop: 'image')
%div
- %h1.home-panel-title.gl-font-size-h1.gl-mt-3.gl-mb-2.gl-display-flex{ data: { qa_selector: 'project_name_content' }, itemprop: 'name' }
+ %h1.home-panel-title.gl-font-size-h1.gl-mt-3.gl-mb-2.gl-display-flex.gl-word-break-word{ data: { qa_selector: 'project_name_content' }, itemprop: 'name' }
= @project.name
- %span.visibility-icon.gl-text-secondary.has-tooltip.gl-ml-2{ data: { container: 'body' }, title: visibility_icon_description(@project) }
- = visibility_level_icon(@project.visibility_level, options: { class: 'icon' })
+ = visibility_level_content(@project, css_class: 'visibility-icon gl-text-secondary gl-ml-2', icon_css_class: 'icon')
= render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project, additional_classes: 'gl-align-self-center gl-ml-2'
- if @project.group
= render_if_exists 'shared/tier_badge', source: @project, source_type: 'Project'
diff --git a/app/views/projects/_merge_request_settings_description_text.html.haml b/app/views/projects/_merge_request_settings_description_text.html.haml
index dc9dc92675d..123520acad8 100644
--- a/app/views/projects/_merge_request_settings_description_text.html.haml
+++ b/app/views/projects/_merge_request_settings_description_text.html.haml
@@ -1 +1 @@
-%p= s_('ProjectSettings|Choose your merge method, merge options, merge checks, and merge suggestions.')
+%p.gl-text-secondary= s_('ProjectSettings|Choose your merge method, merge options, merge checks, and merge suggestions.')
diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml
index 983b8056358..ca1fef6eb32 100644
--- a/app/views/projects/_new_project_fields.html.haml
+++ b/app/views/projects/_new_project_fields.html.haml
@@ -9,7 +9,7 @@
.form-group.gl-form-group.project-name.col-sm-12
= f.label :name, class: 'label-bold' do
%span= _("Project name")
- = f.text_field :name, placeholder: "My awesome project", class: "form-control gl-form-input input-lg", data: { qa_selector: 'project_name', track_label: "#{track_label}", track_action: "activate_form_input", track_property: "project_name", track_value: "" }, required: true, aria: { required: true }
+ = f.text_field :name, placeholder: "My awesome project", class: "form-control gl-form-input input-lg", data: { testid: 'project-name', track_label: "#{track_label}", track_action: "activate_form_input", track_property: "project_name", track_value: "" }, required: true, aria: { required: true }
%small#js-project-name-description.form-text.text-gl-muted
= s_("ProjectsNew|Must start with a lowercase or uppercase letter, digit, emoji, or underscore. Can also contain dots, pluses, dashes, or spaces.")
#js-project-name-error.gl-field-error.gl-mt-2.gl-display-none
@@ -35,7 +35,7 @@
.form-group.project-path.col-sm-6
= f.label :path, class: 'label-bold' do
%span= _("Project slug")
- = f.text_field :path, placeholder: "my-awesome-project", class: "form-control gl-form-input", required: true, aria: { required: true }, data: { qa_selector: 'project_path', username: current_user.username }
+ = f.text_field :path, placeholder: "my-awesome-project", class: "form-control gl-form-input", required: true, aria: { required: true }, data: { testid: 'project-path', username: current_user.username }
.js-group-namespace-error.form-text.gl-text-red-500.gl-display-none
= s_('ProjectsNew|Pick a group or namespace where you want to create this project.')
- if current_user.can_create_group?
@@ -59,7 +59,7 @@
class: "form-control gl-form-input",
rows: 3,
maxlength: 250,
- data: { qa_selector: 'project_description',
+ data: { testid: 'project-description',
track_label: track_label,
track_action: "activate_form_input",
track_property: "project_description" }
@@ -71,7 +71,7 @@
= f.label :visibility_level, class: 'label-bold' do
= s_('ProjectsNew|Visibility Level')
= link_to sprite_icon('question-o'), help_page_path('user/public_access'), aria: { label: 'Documentation for Visibility Level' }, target: '_blank', rel: 'noopener noreferrer'
- = render 'shared/visibility_level', f: f, visibility_level: visibility_level.to_i, can_change_visibility_level: true, form_model: @project, with_label: false, data: { qa_selector: 'visibility_radios'}
+ = render 'shared/visibility_level', f: f, visibility_level: visibility_level.to_i, can_change_visibility_level: true, form_model: @project, with_label: false, data: { testid: 'visibility-radios'}
- if !hide_init_with_readme
= f.label :project_configuration, class: 'label-bold' do
@@ -80,7 +80,7 @@
.form-group
= render Pajamas::CheckboxTagComponent.new(name: 'project[initialize_with_readme]',
checked: true,
- checkbox_options: { data: { qa_selector: 'initialize_with_readme_checkbox', track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_readme' } }) do |c|
+ checkbox_options: { data: { testid: 'initialize-with-readme-checkbox', track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_readme' } }) do |c|
- c.with_label do
= s_('ProjectsNew|Initialize repository with a README')
- c.with_help_text do
@@ -88,7 +88,7 @@
.form-group
= render Pajamas::CheckboxTagComponent.new(name: 'project[initialize_with_sast]',
- checkbox_options: { data: { qa_selector: 'initialize_with_sast_checkbox', track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' } }) do |c|
+ checkbox_options: { data: { testid: 'initialize-with-sast-checkbox', track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' } }) do |c|
- c.with_label do
= s_('ProjectsNew|Enable Static Application Security Testing (SAST)')
- c.with_help_text do
@@ -97,5 +97,5 @@
-# this partial is from JiHu, see details in https://jihulab.com/gitlab-cn/gitlab/-/merge_requests/675
= render_if_exists 'shared/other_project_options', f: f, visibility_level: visibility_level, track_label: track_label
-= f.submit _('Create project'), class: "js-create-project-button", data: { qa_selector: 'project_create_button', track_label: "#{track_label}", track_action: "click_button", track_property: "create_project", track_value: "" }, pajamas_button: true
+= f.submit _('Create project'), class: "js-create-project-button", data: { testid: 'project-create-button', track_label: "#{track_label}", track_action: "click_button", track_property: "create_project", track_value: "" }, pajamas_button: true
= link_button_to _('Cancel'), @parent_group || dashboard_groups_path, data: { track_label: "#{track_label}", track_action: "click_button", track_property: "cancel", track_value: "" }
diff --git a/app/views/projects/_remove.html.haml b/app/views/projects/_remove.html.haml
index dec3199ffe1..12b310f8ba0 100644
--- a/app/views/projects/_remove.html.haml
+++ b/app/views/projects/_remove.html.haml
@@ -3,10 +3,14 @@
- issues_count = Projects::AllIssuesCountService.new(project).count
- forks_count = Projects::ForksCountService.new(project).count
-.sub-section
- %h4.danger-title= _('Delete project')
- %p
- %strong= _('Deleting the project will delete its repository and all related resources, including issues and merge requests.')
- %p
- %strong= _('Deleted projects cannot be restored!')
- #js-project-delete-button{ data: { form_path: project_path(project), confirm_phrase: delete_confirm_phrase(project), is_fork: project.forked?.to_s, issues_count: number_with_delimiter(issues_count), merge_requests_count: number_with_delimiter(merge_requests_count), forks_count: number_with_delimiter(forks_count), stars_count: number_with_delimiter(project.star_count) } }
+= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-bg-red-50 gl-px-5 gl-py-4' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ %h4.gl-new-card-title.danger-title= _('Delete project')
+
+ - c.with_body do
+ %p
+ %strong= _('Deleting the project will delete its repository and all related resources, including issues and merge requests.')
+ %p
+ %strong= _('Deleted projects cannot be restored!')
+ #js-project-delete-button{ data: { form_path: project_path(project), confirm_phrase: delete_confirm_phrase(project), is_fork: project.forked?.to_s, issues_count: number_with_delimiter(issues_count), merge_requests_count: number_with_delimiter(merge_requests_count), forks_count: number_with_delimiter(forks_count), stars_count: number_with_delimiter(project.star_count) } }
diff --git a/app/views/projects/_remove_fork.html.haml b/app/views/projects/_remove_fork.html.haml
index 260c2b2272e..2db78d0f62a 100644
--- a/app/views/projects/_remove_fork.html.haml
+++ b/app/views/projects/_remove_fork.html.haml
@@ -1,11 +1,15 @@
- return unless @project.forked? && can?(current_user, :remove_fork_project, @project)
- remove_form_id = "js-remove-project-fork-form"
-.sub-section
- %h4.danger-title= _('Remove fork relationship')
- %p= remove_fork_project_description_message(@project)
+= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ %h4.gl-new-card-title.danger-title= _('Remove fork relationship')
+ %p.gl-new-card-description
+ = remove_fork_project_description_message(@project)
- = form_for @project, url: remove_fork_project_path(@project), method: :delete, html: { id: remove_form_id } do |f|
- %p
- %strong= _('After it is removed, the fork relationship can only be restored by using the API. This project will no longer be able to receive or send merge requests to the upstream project or other forks.')
- .js-confirm-danger{ data: remove_fork_project_confirm_json(@project, remove_form_id) }
+ - c.with_body do
+ = form_for @project, url: remove_fork_project_path(@project), method: :delete, html: { id: remove_form_id } do |f|
+ %p
+ %strong= _('After it is removed, the fork relationship can only be restored by using the API. This project will no longer be able to receive or send merge requests to the upstream project or other forks.')
+ .js-confirm-danger{ data: remove_fork_project_confirm_json(@project, remove_form_id) }
diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml
index 0a83efdb3b8..c2382a66132 100644
--- a/app/views/projects/_service_desk_settings.html.haml
+++ b/app/views/projects/_service_desk_settings.html.haml
@@ -5,7 +5,7 @@
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- link_start = "<a href='#{help_page_path('user/project/service_desk')}' target='_blank' rel='noopener noreferrer'>".html_safe
- %p= _('Enable and disable Service Desk. Some additional configuration might be required. %{link_start}Learn more%{link_end}.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+ %p.gl-text-secondary= _('Enable and disable Service Desk. Some additional configuration might be required. %{link_start}Learn more%{link_end}.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
.settings-content
- if ::Gitlab::ServiceDesk.supported?
.js-service-desk-setting-root{ data: { endpoint: project_service_desk_path(@project),
@@ -19,6 +19,7 @@
outgoing_name: "#{@project.service_desk_setting&.outgoing_name}",
project_key: "#{@project.service_desk_setting&.project_key}",
templates: available_service_desk_templates_for(@project),
- public_project: "#{@project.public?}" } }
+ public_project: "#{@project.public?}",
+ custom_email_endpoint: project_service_desk_custom_email_path(@project) } }
- elsif show_callout?('promote_service_desk_dismissed')
= render 'shared/promotions/promote_servicedesk'
diff --git a/app/views/projects/_transfer.html.haml b/app/views/projects/_transfer.html.haml
index 93fc8d12960..fe84a83c43c 100644
--- a/app/views/projects/_transfer.html.haml
+++ b/app/views/projects/_transfer.html.haml
@@ -3,21 +3,29 @@
- hidden_input_id = "new_namespace_id"
- initial_data = { button_text: s_('ProjectSettings|Transfer project'), confirm_danger_message: transfer_project_message(@project), phrase: @project.name, target_form_id: form_id, target_hidden_input_id: hidden_input_id, project_id: @project.id }
-.sub-section{ data: { qa_selector: 'transfer_project_content' } }
- %h4.danger-title= _('Transfer project')
- = form_for @project, url: transfer_project_path(@project), method: :put, html: { class: 'js-project-transfer-form', id: form_id } do |f|
- .form-group
+= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card', data: { qa_selector: 'transfer_project_content' } }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ %h4.gl-new-card-title.warning-title= _('Transfer project')
+ %p.gl-new-card-description
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'transfer-a-project-to-another-namespace') }
- %p= _("Transfer your project into another namespace. %{link_start}Learn more.%{link_end}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
- %p= _('When you transfer your project to a group, you can easily manage multiple projects, view usage quotas for storage, pipeline minutes, and users, and start a trial or upgrade to a paid tier.')
- %p
- = _("Don't have a group?")
- = link_to _('Create one'), new_group_path, target: '_blank'
- = _('Things to be aware of before transferring:')
- %ul
- %li= _("Be careful. Changing the project's namespace can have unintended side effects.")
- %li= _('You can only transfer the project to namespaces you manage.')
- %li= _('You will need to update your local repositories to point to the new location.')
- %li= _('Project visibility level will be changed to match namespace rules when transferring to a group.')
- = hidden_field_tag(hidden_input_id)
- .js-transfer-project-form{ data: initial_data }
+ = _("Transfer your project into another namespace. %{link_start}Learn more.%{link_end}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+
+ - c.with_body do
+ = form_for @project, url: transfer_project_path(@project), method: :put, html: { class: 'js-project-transfer-form', id: form_id } do |f|
+ .form-group.gl-mb-0
+ %p
+ - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'rename-a-repository') }
+ = _("A project’s repository name defines its URL (the one you use to access the project via a browser) and its place on the file disk where GitLab is installed. %{link_start}Learn more.%{link_end}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+ %p= _('When you transfer your project to a group, you can easily manage multiple projects, view usage quotas for storage, pipeline minutes, and users, and start a trial or upgrade to a paid tier.')
+ %p
+ = _("Don't have a group?")
+ = link_to _('Create one'), new_group_path, target: '_blank'
+ %p.gl-font-weight-bold= _('Things to be aware of before transferring:')
+ %ul
+ %li= _("Be careful. Changing the project's namespace can have unintended side effects.")
+ %li= _('You can only transfer the project to namespaces you manage.')
+ %li= _('You will need to update your local repositories to point to the new location.')
+ %li= _('Project visibility level will be changed to match namespace rules when transferring to a group.')
+ = hidden_field_tag(hidden_input_id)
+ .js-transfer-project-form{ data: initial_data }
diff --git a/app/views/projects/_wiki.html.haml b/app/views/projects/_wiki.html.haml
index e82e0972d82..82cfb0435c7 100644
--- a/app/views/projects/_wiki.html.haml
+++ b/app/views/projects/_wiki.html.haml
@@ -7,7 +7,7 @@
.landing{ class: [('row-content-block row p-0 align-items-center' if can_create_wiki), ('content-block' unless can_create_wiki)] }
.col-12.col-md-3.p-0
.svg-content
- = image_tag 'illustrations/wiki_login_empty.svg'
+ = image_tag 'illustrations/empty-state/empty-wiki-md.svg'
.col-12.col-md-9.text-center.text-md-left.pl-md-0.pl-sm-3.mb-4
%h4
= _("This project does not have a wiki homepage yet")
diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml
index e5566882371..543bdaf46df 100644
--- a/app/views/projects/blob/_blob.html.haml
+++ b/app/views/projects/blob/_blob.html.haml
@@ -24,7 +24,7 @@
- if !expanded
-# Data info will be removed once we migrate this to use GraphQL
-# Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/330406
- #js-view-blob-app{ data: vue_blob_app_data(project, blob, ref) }
+ #js-view-blob-app{ data: vue_blob_app_data(project, blob, ref).merge(ref_type: @ref_type.to_s) }
= gl_loading_icon(size: 'md')
- else
%article.file-holder
diff --git a/app/views/projects/blob/_breadcrumb.html.haml b/app/views/projects/blob/_breadcrumb.html.haml
index 417c11ba37a..539453bf6af 100644
--- a/app/views/projects/blob/_breadcrumb.html.haml
+++ b/app/views/projects/blob/_breadcrumb.html.haml
@@ -1,21 +1,21 @@
- blame = local_assigns.fetch(:blame, false)
.nav-block
.tree-ref-container
- .tree-ref-holder
+ .tree-ref-holder.gl-max-w-26
#js-tree-ref-switcher{ data: { project_id: @project.id, project_root_path: project_path(@project), ref: current_ref, ref_type: @ref_type.to_s } }
%ul.breadcrumb.repo-breadcrumb
%li.breadcrumb-item
- = link_to project_tree_path(@project, @ref) do
+ = link_to project_tree_path(@project, @ref, ref_type: @ref_type) do
= @project.path
- path_breadcrumbs do |title, path|
- title = truncate(title, length: 40)
%li.breadcrumb-item
- if path == @path
- = link_to project_blob_path(@project, tree_join(@ref, path)) do
+ = link_to project_blob_path(@project, tree_join(@ref, path), ref_type: @ref_type) do
%strong= title
- else
- = link_to title, project_tree_path(@project, tree_join(@ref, path))
+ = link_to title, project_tree_path(@project, tree_join(@ref, path), ref_type: @ref_type)
.tree-controls.gl-children-ml-sm-3<
= render 'projects/find_file_link'
diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml
index 68520d36858..49a29e1dcb7 100644
--- a/app/views/projects/blob/_editor.html.haml
+++ b/app/views/projects/blob/_editor.html.haml
@@ -8,23 +8,17 @@
= sprite_icon('branch', size: 12)
= ref
- if current_action?(:edit) || current_action?(:update)
- %span.float-left.gl-mr-3
- = text_field_tag 'file_path', (params[:file_path] || @path),
- class: 'form-control gl-form-input new-file-path js-file-path-name-input'
- = render 'template_selectors'
+ - input_options = { id: 'file_path', name: 'file_path', value: (params[:file_path] || @path), class: 'new-file-path js-file-path-name-input' }
+ = render 'filepath_form', input_options: input_options
- if current_action?(:new) || current_action?(:create)
- %span.float-left.gl-mr-3
- \/
- = text_field_tag 'file_name', params[:file_name], placeholder: "Filename", data: { qa_selector: 'file_name_field' },
- required: true, class: 'form-control gl-form-input new-file-name js-file-path-name-input', value: params[:file_name] || (should_suggest_gitlab_ci_yml? ? '.gitlab-ci.yml' : '')
- = render 'template_selectors'
+ - input_options = { id: 'file_name', name: 'file_name', value: params[:file_name] || (should_suggest_gitlab_ci_yml? ? '.gitlab-ci.yml' : ''), required: true, placeholder: "Filename", testid: 'file_name_field', class: 'new-file-name js-file-path-name-input' }
+ = render 'filepath_form', input_options: input_options
- if should_suggest_gitlab_ci_yml?
- .js-suggest-gitlab-ci-yml{ data: { target: '#gitlab-ci-yml-selector',
- track_label: 'suggest_gitlab_ci_yml',
- merge_request_path: params[:mr_path],
- dismiss_key: @project.id,
- human_access: human_access } }
+ .js-suggest-gitlab-ci-yml{ data: { track_label: 'suggest_gitlab_ci_yml',
+ merge_request_path: params[:mr_path],
+ dismiss_key: @project.id,
+ human_access: human_access } }
- if Feature.enabled?(:source_editor_toolbar, current_user)
#editor-toolbar
diff --git a/app/views/projects/blob/_filepath_form.html.haml b/app/views/projects/blob/_filepath_form.html.haml
new file mode 100644
index 00000000000..53c681fd264
--- /dev/null
+++ b/app/views/projects/blob/_filepath_form.html.haml
@@ -0,0 +1 @@
+= dropdown_data_attr(options: { data: { templates: { licenses: licenses_for_select(@project), gitignore_names: gitignore_names(@project), gitlab_ci_ymls: gitlab_ci_ymls(@project), dockerfile_names: dockerfile_names(@project) }, selected: params[:template], input_options: input_options }})
diff --git a/app/views/projects/blob/_pipeline_tour_success.html.haml b/app/views/projects/blob/_pipeline_tour_success.html.haml
index 0fa4a90e28b..f645d23aa1c 100644
--- a/app/views/projects/blob/_pipeline_tour_success.html.haml
+++ b/app/views/projects/blob/_pipeline_tour_success.html.haml
@@ -1,6 +1,6 @@
.js-success-pipeline-modal{ data: { 'commit-cookie': suggest_pipeline_commit_cookie_name,
'go-to-pipelines-path': project_pipelines_path(@project),
'project-merge-requests-path': project_merge_requests_path(@project),
- 'example-link': help_page_path('ci/examples/index.md', anchor: 'gitlab-cicd-examples'),
+ 'example-link': help_page_path('ci/examples/index.md'),
'code-quality-link': help_page_path('ci/testing/code_quality'),
'human-access': @project.team.human_max_access(current_user&.id) } }
diff --git a/app/views/projects/blob/_template_selectors.html.haml b/app/views/projects/blob/_template_selectors.html.haml
deleted file mode 100644
index 0bd29ceb563..00000000000
--- a/app/views/projects/blob/_template_selectors.html.haml
+++ /dev/null
@@ -1,10 +0,0 @@
-.template-selectors-menu.gl-pl-3
- .template-selector-dropdowns-wrap
- .license-selector.js-license-selector-wrap.js-template-selector-wrap.hidden
- = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-license-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: licenses_for_select(@project), project: @project.name, fullname: @project.namespace.human_name, qa_selector: 'license_dropdown' } })
- .gitignore-selector.js-gitignore-selector-wrap.js-template-selector-wrap.hidden
- = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitignore-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitignore_names(@project), qa_selector: 'gitignore_dropdown' } })
- #gitlab-ci-yml-selector.gitlab-ci-yml-selector.js-gitlab-ci-yml-selector-wrap.js-template-selector-wrap.hidden
- = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitlab-ci-yml-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls(@project), selected: params[:template], qa_selector: 'gitlab_ci_yml_dropdown' } })
- .dockerfile-selector.js-dockerfile-selector-wrap.js-template-selector-wrap.hidden
- = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-dockerfile-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: dockerfile_names(@project), qa_selector: 'dockerfile_dropdown' } })
diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml
index c8cf12c36f9..9ec824f64d4 100644
--- a/app/views/projects/blob/show.html.haml
+++ b/app/views/projects/blob/show.html.haml
@@ -4,13 +4,13 @@
- 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 })
+- add_page_startup_graphql_call('repository/blob_info', { projectPath: @project.full_path, ref: current_ref, refType: @ref_type.to_s, filePath: @blob.path, shouldFetchRawText: @blob.rendered_as_text? && !@blob.rich_viewer })
.js-signature-container{ data: { 'signatures-path': signatures_path } }
= render 'projects/last_push'
-#tree-holder.tree-holder
+#tree-holder.tree-holder.gl-pt-4
= render 'blob', blob: @blob
= render partial: 'pipeline_tour_success' if show_suggest_pipeline_creation_celebration?
diff --git a/app/views/projects/branch_defaults/_show.html.haml b/app/views/projects/branch_defaults/_show.html.haml
index 4ecbc3b7fc8..5906cd34c17 100644
--- a/app/views/projects/branch_defaults/_show.html.haml
+++ b/app/views/projects/branch_defaults/_show.html.haml
@@ -5,7 +5,7 @@
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Branch defaults')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= s_('ProjectSettings|Select the default branch for this project, and configure the template for branch names.')
.settings-content
diff --git a/app/views/projects/branch_rules/_show.html.haml b/app/views/projects/branch_rules/_show.html.haml
index 605715e2899..c16c03953c6 100644
--- a/app/views/projects/branch_rules/_show.html.haml
+++ b/app/views/projects/branch_rules/_show.html.haml
@@ -8,9 +8,9 @@
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Branch rules')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Define rules for who can push, merge, and the required approvals for each branch.')
= link_to(_('Leave feedback.'), 'https://gitlab.com/gitlab-org/gitlab/-/issues/388149', target: '_blank', rel: 'noopener noreferrer')
- .settings-content.gl-pr-0
+ .settings-content
#js-branch-rules{ data: { project_path: @project.full_path, branch_rules_path: project_settings_repository_branch_rules_path(@project), show_code_owners: show_code_owners.to_s, show_status_checks: show_status_checks.to_s, show_approvers: show_approvers.to_s } }
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index ae8d230f356..7c52350f101 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -4,10 +4,10 @@
- mr_status = merge_request_status(related_merge_request)
- is_default_branch = branch.name == @repository.root_ref
-%li{ class: "branch-item gl-display-flex! gl-align-items-center! js-branch-item js-branch-#{branch.name} gl-pl-3!", data: { name: branch.name, qa_selector: 'branch_container', qa_name: branch.name } }
+%li{ class: "branch-item gl-display-flex! gl-align-items-center! js-branch-item js-branch-#{branch.name} gl-pl-5! gl-pr-2!", data: { name: branch.name, qa_selector: 'branch_container', qa_name: branch.name } }
.branch-info
.gl-display-flex.gl-align-items-center
- = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name', data: { qa_selector: 'branch_link' } do
+ = link_to project_tree_path(@project, branch.name, ref_type: 'heads'), class: 'item-title str-truncated-100 ref-name', data: { qa_selector: 'branch_link' } do
= branch.name
= clipboard_button(text: branch.name, title: _("Copy branch name"))
- if is_default_branch
diff --git a/app/views/projects/branches/_panel.html.haml b/app/views/projects/branches/_panel.html.haml
index c01e3677c19..8ef7d435420 100644
--- a/app/views/projects/branches/_panel.html.haml
+++ b/app/views/projects/branches/_panel.html.haml
@@ -7,7 +7,7 @@
- return unless branches.any?
-= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body' }, footer_options: { class: 'gl-new-card-footer' }) do |c|
+= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0' }, footer_options: { class: 'gl-new-card-footer' }) do |c|
- c.with_header do
%h3.gl-new-card-title.h5
= panel_title
diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml
index c9dcfaff8c6..963c416ed42 100644
--- a/app/views/projects/buttons/_fork.html.haml
+++ b/app/views/projects/buttons/_fork.html.haml
@@ -1,16 +1,3 @@
- unless @project.empty_repo?
- if current_user
- .count-badge.btn-group
- - if current_user.already_forked?(@project) && current_user.forkable_namespaces.size < 2
- = link_button_to namespace_project_path(current_user, current_user.fork_of(@project)), title: s_('ProjectOverview|Go to your fork'), class: 'has-tooltip fork-btn', icon: 'fork' do
- = s_('ProjectOverview|Fork')
- - else
- - disabled_tooltip = fork_button_disabled_tooltip(@project)
- - count_class = 'disabled' unless can?(current_user, :read_code, @project)
- - button_class = 'disabled' if disabled_tooltip
-
- %span.btn-group{ class: ('has-tooltip' if disabled_tooltip), title: disabled_tooltip }
- = link_button_to new_project_fork_path(@project), class: "fork-btn #{button_class}", data: { qa_selector: 'fork_button' }, icon: 'fork' do
- = s_('ProjectOverview|Fork')
- = link_button_to project_forks_path(@project), title: n_(s_('ProjectOverview|Forks'), s_('ProjectOverview|Forks'), @project.forks_count), class: "count has-tooltip fork-count #{count_class}" do
- = @project.forks_count
+ #js-forks-button{ data: fork_button_data_attributes(@project) }
diff --git a/app/views/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml
index d00d9f62999..fffa1ff36b9 100644
--- a/app/views/projects/cleanup/_show.html.haml
+++ b/app/views/projects/cleanup/_show.html.haml
@@ -5,7 +5,7 @@
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Repository cleanup')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
- link_url = 'https://github.com/newren/git-filter-repo'
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: link_url }
- link_end = '</a>'.html_safe
@@ -21,7 +21,7 @@
.gl-mb-3
%h5.gl-mt-0
= _("Upload object map")
- %button.gl-button.btn.btn-default.js-choose-file{ type: "button" }
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-choose-file' }) do
= _("Choose a file")
%span.gl-ml-3.js-filename
= _("No file selected")
diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml
index a0f47f375f7..010f15ec6f2 100644
--- a/app/views/projects/commits/_commits.html.haml
+++ b/app/views/projects/commits/_commits.html.haml
@@ -7,7 +7,7 @@
- context_commits = @context_commits&.map { |commit| commit.present(current_user: current_user) }
- hidden = @hidden_commit_count
-- commits.chunk { |c| c.committed_date.in_time_zone.to_date }.each do |day, daily_commits|
+- commits.chunk { |commit| local_committed_date(commit, current_user) }.each do |day, daily_commits|
%li.js-commit-header.gl-py-2.gl-border-b{ data: { day: day } }
%span.day.font-weight-bold= l(day, format: '%b %d, %Y')
@@ -44,7 +44,8 @@
- if commits.size == 0 && context_commits.nil?
.commits-empty.gl-mt-6
- = custom_icon('illustration_no_commits')
+ .svg-content.svg-150
+ = image_tag('illustrations/empty-state/empty-search-md.svg')
%h4
= _('Your search didn\'t match any commits.')
%p
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index 8afc9ade3e1..1034f06f722 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -1,5 +1,6 @@
- breadcrumb_title _("Commits")
- add_page_specific_style 'page_bundles/tree'
+- add_page_specific_style 'page_bundles/merge_request'
- page_title _("Commits"), @ref
= content_for :meta_tags do
diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml
index 5b6f7c392dd..69c7a497c7d 100644
--- a/app/views/projects/compare/show.html.haml
+++ b/app/views/projects/compare/show.html.haml
@@ -1,13 +1,14 @@
- add_to_breadcrumbs s_("CompareRevisions|Compare revisions"), project_compare_index_path(@project)
- page_title "#{params[:from]} to #{params[:to]}"
+- has_diff = @commits.present? || @diffs.present? && @diffs.diff_files.present?
+-# Only show commit list in the first page
+- hide_commit_list = params[:page].present? && params[:page] != '1'
.sub-header-block.gl-border-b-0.gl-mb-0.gl-pt-4
.js-signature-container{ data: { 'signatures-path' => signatures_namespace_project_compare_index_path } }
#js-compare-selector{ data: project_compare_selector_data(@project, @merge_request, params) }
-- if @commits.present? || @diffs.present?
- -# Only show commit list in the first page
- - hide_commit_list = params[:page].present? && params[:page] != '1'
+- if has_diff
= render "projects/commits/commit_list" unless hide_commit_list
= render "projects/diffs/diffs",
diffs: @diffs,
diff --git a/app/views/projects/confluences/show.html.haml b/app/views/projects/confluences/show.html.haml
index 283408ffa63..cdf3562a8ed 100644
--- a/app/views/projects/confluences/show.html.haml
+++ b/app/views/projects/confluences/show.html.haml
@@ -1,7 +1,7 @@
- breadcrumb_title _('Confluence')
- page_title _('Confluence')
- add_page_specific_style 'page_bundles/wiki'
-= render layout: 'shared/empty_states/wikis_layout', locals: { image_path: 'illustrations/wiki_login_empty.svg' } do
+= render layout: 'shared/empty_states/wikis_layout', locals: { image_path: 'illustrations/empty-state/empty-wiki-md.svg' } do
%h4
= s_('WikiEmpty|Confluence is enabled')
%p
diff --git a/app/views/projects/deploy_keys/edit.html.haml b/app/views/projects/deploy_keys/edit.html.haml
index 997443d5fa9..0044ff4dc24 100644
--- a/app/views/projects/deploy_keys/edit.html.haml
+++ b/app/views/projects/deploy_keys/edit.html.haml
@@ -1,10 +1,8 @@
- page_title _('Edit Deploy Key')
%h1.page-title.gl-font-size-h-display= _('Edit Deploy Key')
-%hr
-%div
- = gitlab_ui_form_for [@project, @deploy_key], include_id: false, html: { class: 'js-requires-input' } do |f|
- = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key }
- .form-actions
- = f.submit _('Save changes'), pajamas_button: true
- = link_button_to _('Cancel'), project_settings_repository_path(@project)
+= gitlab_ui_form_for [@project, @deploy_key], include_id: false, html: { class: 'js-requires-input' } do |f|
+ = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key }
+ .gl-display-flex.gl-mt-6.gl-gap-3
+ = f.submit _('Save changes'), pajamas_button: true
+ = link_button_to _('Cancel'), project_settings_repository_path(@project, anchor: 'js-deploy-keys-settings')
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 98e8c2dd61b..662f1bb158d 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -17,7 +17,7 @@
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Naming, topics, avatar')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= _('Collapse')
- %p= _('Update your project name, topics, description, and avatar.')
+ %p.gl-text-secondary= _('Update your project name, topics, description, and avatar.')
.settings-content= render 'projects/settings/general'
%section.settings.sharing-permissions.no-animate#js-shared-permissions{ class: ('expanded' if expanded), data: { qa_selector: 'visibility_features_permissions_content' } }
@@ -25,7 +25,7 @@
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Visibility, project features, permissions')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p= _('Choose visibility level, enable/disable project features and their permissions, disable email notifications, and show default award emoji.')
+ %p.gl-text-secondary= _('Choose visibility level, enable/disable project features and their permissions, disable email notifications, and show default emoji reactions.')
.settings-content
= form_for @project, html: { multipart: true, class: "sharing-permissions-form", id: reduce_visibility_form_id }, authenticity_token: true do |f|
@@ -40,15 +40,13 @@
- c.with_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 }
-= render_if_exists 'projects/settings/analytics', expanded: expanded
-
%section.settings.no-animate{ class: ('expanded' if expanded), data: { qa_selector: 'badges_settings_content' } }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= s_('ProjectSettings|Badges')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= s_('ProjectSettings|Customize this project\'s badges.')
= link_to s_('ProjectSettings|What are badges?'), help_page_path('user/project/badges')
.settings-content
@@ -60,52 +58,59 @@
= render 'projects/service_desk_settings'
-= render_if_exists 'product_analytics/project_settings', expanded: expanded
-
%section.settings.advanced-settings.no-animate#js-project-advanced-settings{ class: ('expanded' if expanded), data: { qa_selector: 'advanced_settings_content' } }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Advanced')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p= s_('ProjectSettings|Housekeeping, export, archive, change path, transfer, and delete.')
+ %p.gl-text-secondary= s_('ProjectSettings|Housekeeping, export, archive, change path, transfer, and delete.')
.settings-content
= render_if_exists 'projects/settings/restore', project: @project
- .sub-section
- %h4= _('Housekeeping')
- %p
- = _('Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects.')
- = link_to _('Learn more.'), help_page_path('administration/housekeeping'), target: '_blank', rel: 'noopener noreferrer'
- = render Pajamas::ButtonComponent.new(method: :post, href: housekeeping_project_path(@project)) do
- = _('Run housekeeping')
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card gl-mt-0' }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ %h4.gl-new-card-title= _('Housekeeping')
+ %p.gl-new-card-description
+ = _('Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects.')
+ = link_to _('Learn more.'), help_page_path('administration/housekeeping'), target: '_blank', rel: 'noopener noreferrer'
- .gl-display-inline-flex
- #js-project-prune-unreachable-objects-button{ data: { prune_objects_path: housekeeping_project_path(@project, prune: true), prune_objects_doc_path: help_page_path('administration/housekeeping', anchor: 'prune-unreachable-objects') } }
+ - c.with_body do
+ .gl-display-flex.gl-flex-wrap.gl-gap-3
+ = render Pajamas::ButtonComponent.new(method: :post, href: housekeeping_project_path(@project)) do
+ = _('Run housekeeping')
+ #js-project-prune-unreachable-objects-button{ data: { prune_objects_path: housekeeping_project_path(@project, prune: true), prune_objects_doc_path: help_page_path('administration/housekeeping', anchor: 'prune-unreachable-objects') } }
= render 'export', project: @project
= render_if_exists 'projects/settings/archive'
- .sub-section.rename-repository
- %h4.warning-title= _('Change path')
- = render 'projects/errors'
- = gitlab_ui_form_for @project do |f|
- .form-group
+
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card rename-repository' }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ %h4.gl-new-card-title.warning-title= _('Change path')
+ %p.gl-new-card-description
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'rename-a-repository') }
- %p= _("A project’s repository name defines its URL (the one you use to access the project via a browser) and its place on the file disk where GitLab is installed. %{link_start}Learn more.%{link_end}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
- %ul
- %li= _("Be careful. Renaming a project's repository can have unintended side effects.")
- %li= _('You will need to update your local repositories to point to the new location.')
- - if @project.deployment_platform.present?
- %li= _('Your deployment services will be broken, you will need to manually fix the services after renaming.')
- = f.label :path, _('Path'), class: 'label-bold'
+ = _("A project’s repository name defines its URL (the one you use to access the project via a browser) and its place on the file disk where GitLab is installed. %{link_start}Learn more.%{link_end}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+
+ - c.with_body do
+ = render 'projects/errors'
+ = gitlab_ui_form_for @project do |f|
.form-group
- .input-group
- .input-group-prepend
- .input-group-text
- #{Gitlab::Utils.append_path(root_url, @project.namespace.full_path)}/
- = f.text_field :path, class: 'form-control', data: { qa_selector: 'project_path_field' }
- = f.submit _('Change path'), class: "btn-danger", data: { qa_selector: 'change_path_button' }, pajamas_button: true
+ %p
+ %span.gl-font-weight-bold= _("Be careful. Renaming a project's repository can have unintended side effects.")
+ = _('You will need to update your local repositories to point to the new location.')
+ - if @project.deployment_platform.present?
+ %p= _('Your deployment services will be broken, you will need to manually fix the services after renaming.')
+ = f.label :path, _('Path'), class: 'label-bold'
+ .form-group
+ .input-group
+ .input-group-prepend
+ .input-group-text
+ #{Gitlab::Utils.append_path(root_url, @project.namespace.full_path)}/
+ = f.text_field :path, class: 'form-control gl-form-input-xl', data: { qa_selector: 'project_path_field' }
+ = f.submit _('Change path'), class: "btn-danger", data: { qa_selector: 'change_path_button' }, pajamas_button: true
= render 'transfer', project: @project
diff --git a/app/views/projects/feature_flags/index.html.haml b/app/views/projects/feature_flags/index.html.haml
index e473a6f3cfd..ec0830ad153 100644
--- a/app/views/projects/feature_flags/index.html.haml
+++ b/app/views/projects/feature_flags/index.html.haml
@@ -3,7 +3,7 @@
#feature-flags-vue{ data: { endpoint: project_feature_flags_path(@project, format: :json),
"project-id" => @project.id,
"project-name" => @project.name,
- "error-state-svg-path" => image_path('illustrations/feature_flag.svg'),
+ "error-state-svg-path" => image_path('illustrations/empty-state/empty-feature-flag-md.svg'),
"feature-flags-help-page-path" => help_page_path("operations/feature_flags"),
"feature-flags-client-libraries-help-page-path" => help_page_path("operations/feature_flags", anchor: "choose-a-client-library"),
"feature-flags-client-example-help-page-path" => help_page_path("operations/feature_flags", anchor: "go-application-example"),
diff --git a/app/views/projects/feature_flags_user_lists/index.html.haml b/app/views/projects/feature_flags_user_lists/index.html.haml
index c0e98b27d29..2e279d71758 100644
--- a/app/views/projects/feature_flags_user_lists/index.html.haml
+++ b/app/views/projects/feature_flags_user_lists/index.html.haml
@@ -5,4 +5,4 @@
#js-user-lists{ data: { project_id: @project.id,
feature_flags_help_page_path: help_page_path("operations/feature_flags"),
new_user_list_path: can?(current_user, :create_feature_flag, @project) ? new_project_feature_flags_user_list_path(@project): nil,
- error_state_svg_path: image_path('illustrations/feature_flag.svg') } }
+ error_state_svg_path: image_path('illustrations/empty-state/empty-feature-flag-md.svg') } }
diff --git a/app/views/projects/feature_flags_user_lists/show.html.haml b/app/views/projects/feature_flags_user_lists/show.html.haml
index 5c4e93e7707..58276eedc09 100644
--- a/app/views/projects/feature_flags_user_lists/show.html.haml
+++ b/app/views/projects/feature_flags_user_lists/show.html.haml
@@ -5,4 +5,4 @@
#js-edit-user-list{ data: { project_id: @project.id,
user_list_iid: @user_list.iid,
- empty_state_path: image_path('illustrations/feature_flag.svg') } }
+ empty_state_path: image_path('illustrations/empty-state/empty-feature-flag-md.svg') } }
diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml
index 7e93e44c463..541b8c1147d 100644
--- a/app/views/projects/find_file/show.html.haml
+++ b/app/views/projects/find_file/show.html.haml
@@ -1,9 +1,9 @@
- page_title _("Find File"), @ref
- add_page_specific_style 'page_bundles/tree'
-.file-finder-holder.tree-holder.clearfix.js-file-finder{ 'data-file-find-url': "#{escape_javascript(project_files_path(@project, @ref, format: :json))}", 'data-find-tree-url': escape_javascript(project_tree_path(@project, @ref)), 'data-blob-url-template': escape_javascript(project_blob_path(@project, @ref)) }
+.file-finder-holder.tree-holder.clearfix.js-file-finder.gl-pt-4{ 'data-file-find-url': "#{escape_javascript(project_files_path(@project, @ref, format: :json))}", 'data-find-tree-url': escape_javascript(project_tree_path(@project, @ref)), 'data-blob-url-template': escape_javascript(project_blob_path(@project, @ref)) }
.nav-block.gl-xs-mr-0
- .tree-ref-holder.gl-xs-mb-3.gl-xs-w-full
+ .tree-ref-holder.gl-xs-mb-3.gl-xs-w-full.gl-max-w-26
#js-blob-ref-switcher{ data: { project_id: @project.id, ref: @ref, namespace: "/-/find_file" } }
%ul.breadcrumb.repo-breadcrumb.gl-flex-nowrap
%li.breadcrumb-item.gl-white-space-nowrap
diff --git a/app/views/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml
index 30084e3310b..2eaf89be4ef 100644
--- a/app/views/projects/hook_logs/show.html.haml
+++ b/app/views/projects/hook_logs/show.html.haml
@@ -7,7 +7,8 @@
%hr
- if @hook_log.oversize?
- = button_tag _("Resend Request"), class: "btn gl-button btn-default float-right gl-ml-3 has-tooltip", disabled: true, title: _("Request data is too large")
+ = render Pajamas::ButtonComponent.new(button_options: { class: "float-right gl-ml-3 has-tooltip", disabled: true, title: _("Request data is too large") }) do
+ = _("Resend Request")
- else
= link_button_to _("Resend Request"), @hook_log.present.retry_path, method: :post, class: 'float-right gl-ml-3'
diff --git a/app/views/projects/integrations/shimos/show.html.haml b/app/views/projects/integrations/shimos/show.html.haml
index e6cd8c15809..165e414f75b 100644
--- a/app/views/projects/integrations/shimos/show.html.haml
+++ b/app/views/projects/integrations/shimos/show.html.haml
@@ -1,7 +1,7 @@
- breadcrumb_title s_('Shimo|Shimo Workspace')
- page_title s_('Shimo|Shimo Workspace')
- add_page_specific_style 'page_bundles/wiki'
-= render layout: 'shared/empty_states/wikis_layout', locals: { image_path: 'illustrations/wiki_login_empty.svg' } do
+= render layout: 'shared/empty_states/wikis_layout', locals: { image_path: 'illustrations/empty-state/empty-wiki-md.svg' } do
%h4
= s_('Shimo|Shimo Workspace integration is enabled')
%p
diff --git a/app/views/projects/issuable/_show.html.haml b/app/views/projects/issuable/_show.html.haml
index ec233bc9aff..e502457808d 100644
--- a/app/views/projects/issuable/_show.html.haml
+++ b/app/views/projects/issuable/_show.html.haml
@@ -7,5 +7,4 @@
= render "projects/issues/service_desk/alert_moved_from_service_desk", issue: issuable
-= render 'shared/issue_type/details_header', issuable: issuable
= render 'shared/issue_type/details_content', issuable: issuable, api_awards_path: api_awards_path
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index f8f57934303..8bfad64c369 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -1,6 +1,7 @@
- page_title _('Issues')
- add_page_specific_style 'page_bundles/issuable_list'
- add_page_specific_style 'page_bundles/issues_list'
+- add_page_specific_style 'page_bundles/work_items'
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@project.name} issues")
diff --git a/app/views/projects/issues/new.html.haml b/app/views/projects/issues/new.html.haml
index d344ae6a4e6..64143502b77 100644
--- a/app/views/projects/issues/new.html.haml
+++ b/app/views/projects/issues/new.html.haml
@@ -1,3 +1,4 @@
+- add_page_specific_style 'page_bundles/merge_request'
- add_to_breadcrumbs _("Issues"), project_issues_path(@project)
- breadcrumb_title _("New")
- page_title _("New Issue")
diff --git a/app/views/projects/issues/service_desk.html.haml b/app/views/projects/issues/service_desk.html.haml
index 9793f21e4a9..2d17719a8c2 100644
--- a/app/views/projects/issues/service_desk.html.haml
+++ b/app/views/projects/issues/service_desk.html.haml
@@ -15,7 +15,7 @@
can_edit_project_settings: can?(current_user, :admin_project, @project).to_s,
service_desk_callout_svg_path: image_path('service_desk_callout.svg'),
service_desk_settings_path: edit_project_path(@project, anchor: 'js-service-desk'),
- service_desk_help_path: help_page_path('user/project/service_desk'),
+ service_desk_help_path: help_page_path('user/project/service_desk/index'),
is_service_desk_supported: Gitlab::ServiceDesk.supported?.to_s,
is_service_desk_enabled: @project.service_desk_enabled?.to_s } }
- else
diff --git a/app/views/projects/jobs/index.html.haml b/app/views/projects/jobs/index.html.haml
index d39d292fb53..0073c6b89cd 100644
--- a/app/views/projects/jobs/index.html.haml
+++ b/app/views/projects/jobs/index.html.haml
@@ -1,5 +1,6 @@
- page_title _("Jobs")
- add_page_specific_style 'page_bundles/ci_status'
+- add_page_specific_style 'page_bundles/merge_request'
- admin = local_assigns.fetch(:admin, false)
#js-jobs-table{ data: { admin: admin, full_path: @project.full_path, job_statuses: job_statuses.to_json, pipeline_editor_path: project_ci_pipeline_editor_path(@project), empty_state_svg_path: image_path('jobs-empty-state.svg') } }
diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml
index b151c355b3e..d81855b12ed 100644
--- a/app/views/projects/jobs/show.html.haml
+++ b/app/views/projects/jobs/show.html.haml
@@ -4,6 +4,7 @@
- add_page_specific_style 'page_bundles/build'
- add_page_specific_style 'page_bundles/xterm'
- add_page_specific_style 'page_bundles/ci_status'
+- add_page_specific_style 'page_bundles/merge_request'
= render_if_exists "shared/shared_runners_minutes_limit_flash_message"
diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml
index e1c904d000f..8855e8024b3 100644
--- a/app/views/projects/labels/index.html.haml
+++ b/app/views/projects/labels/index.html.haml
@@ -23,7 +23,7 @@
= _('Prioritized labels')
.gl-new-card-description
= _('Drag to reorder prioritized labels and change their relative priority.')
- .js-prioritized-labels.gl-px-3.gl-rounded-base.manage-labels-list{ data: { url: set_priorities_project_labels_path(@project), sortable: can_admin_label } }
+ .js-prioritized-labels.gl-rounded-base.manage-labels-list{ data: { url: set_priorities_project_labels_path(@project), sortable: can_admin_label } }
#js-priority-labels-empty-state.priority-labels-empty-state{ class: "#{'hidden' unless @prioritized_labels.empty? && search.blank?}" }
= render 'shared/empty_states/priority_labels'
- if @prioritized_labels.any?
@@ -37,8 +37,8 @@
.gl-new-card-header
.gl-new-card-title-wrapper
%h3.gl-new-card-title{ class: ('hide' if hide) }= _('Other labels')
- .gl-new-card-body
- .js-other-labels.manage-labels-list.gl-new-card-content
+ .gl-new-card-body.gl-px-0
+ .js-other-labels.manage-labels-list
= render partial: 'shared/label', collection: @labels, as: :label, locals: { subject: @project }
= paginate @labels, theme: 'gitlab'
diff --git a/app/views/projects/merge_requests/_page.html.haml b/app/views/projects/merge_requests/_page.html.haml
index 5ea67376a86..69e2487152e 100644
--- a/app/views/projects/merge_requests/_page.html.haml
+++ b/app/views/projects/merge_requests/_page.html.haml
@@ -106,7 +106,7 @@
- if @merge_request.can_be_cherry_picked?
= render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit
-#js-review-bar{ data: { new_comment_template_path: profile_comment_templates_path } }
+#js-review-bar{ data: review_bar_data(@merge_request, 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 } }
diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml
index 606d4e06d33..9ec4363fa9a 100644
--- a/app/views/projects/merge_requests/_widget.html.haml
+++ b/app/views/projects/merge_requests/_widget.html.haml
@@ -13,7 +13,7 @@
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.code_coverage_check_help_page_path = '#{help_page_path('ci/testing/code_coverage.md', anchor: 'coverage-check-approval-rule')}';
window.gl.mrWidgetData.security_configuration_path = '#{project_security_configuration_path(@project)}';
- window.gl.mrWidgetData.license_compliance_docs_path = '#{help_page_path('user/compliance/license_compliance/index.md')}';
+ window.gl.mrWidgetData.license_compliance_docs_path = '#{help_page_path('user/compliance/license_scanning_of_cyclonedx_files')}';
window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/approvals/rules.md', anchor: 'eligible-approvers')}';
window.gl.mrWidgetData.approvals_help_path = '#{help_page_path("user/project/merge_requests/approvals/index.md")}';
window.gl.mrWidgetData.pipelines_empty_svg_path = '#{image_path('illustrations/empty-state/empty-pipeline-md.svg')}';
diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml
index 3facca4d4f7..f8d0e2d2a15 100644
--- a/app/views/projects/merge_requests/conflicts/show.html.haml
+++ b/app/views/projects/merge_requests/conflicts/show.html.haml
@@ -3,6 +3,7 @@
- breadcrumb_title _("Merge conflicts")
- page_title _("Merge Conflicts"), "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge requests")
- add_page_specific_style 'page_bundles/merge_conflicts'
+- add_page_specific_style 'page_bundles/merge_request'
= render "projects/merge_requests/mr_title", hide_gutter_toggle: true
diff --git a/app/views/projects/merge_requests/creations/new.html.haml b/app/views/projects/merge_requests/creations/new.html.haml
index 6a8894384df..f2c2700b012 100644
--- a/app/views/projects/merge_requests/creations/new.html.haml
+++ b/app/views/projects/merge_requests/creations/new.html.haml
@@ -3,8 +3,15 @@
- page_title _("New merge request")
- add_page_specific_style 'page_bundles/pipelines'
- add_page_specific_style 'page_bundles/ci_status'
+- add_page_specific_style 'page_bundles/merge_request'
-- if @merge_request.can_be_created && !params[:change_branches]
+- conflicting_mr = @merge_request.existing_mrs_targeting_same_branch.first
+
+- if @merge_request.can_be_created && !params[:change_branches] && !conflicting_mr
= render 'new_submit'
- else
+ - if conflicting_mr
+ - link_to_mr = link_to(conflicting_mr.to_reference, project_merge_request_path(@project, conflicting_mr))
+ - flash.now[:alert] = safe_format(s_("These branches already have an open merge request: %{link_to_mr}. Select a different source or target branch."), link_to_mr: link_to_mr)
+
= render 'new_compare'
diff --git a/app/views/projects/merge_requests/diffs.html.haml b/app/views/projects/merge_requests/diffs.html.haml
index 1ef212ee5ce..03306e98407 100644
--- a/app/views/projects/merge_requests/diffs.html.haml
+++ b/app/views/projects/merge_requests/diffs.html.haml
@@ -1 +1,3 @@
+- add_page_specific_style 'page_bundles/merge_request'
+
= render 'page'
diff --git a/app/views/projects/merge_requests/edit.html.haml b/app/views/projects/merge_requests/edit.html.haml
index 77cc69f32ab..3aca4783241 100644
--- a/app/views/projects/merge_requests/edit.html.haml
+++ b/app/views/projects/merge_requests/edit.html.haml
@@ -1,3 +1,4 @@
+- add_page_specific_style 'page_bundles/merge_request'
- add_to_breadcrumbs _("Merge requests"), project_merge_requests_path(@project)
- breadcrumb_title @merge_request.to_reference
- page_title _("Edit"), "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge requests")
diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml
index 79da09c5205..e2d3e082289 100644
--- a/app/views/projects/merge_requests/index.html.haml
+++ b/app/views/projects/merge_requests/index.html.haml
@@ -6,6 +6,7 @@
- page_title _("Merge requests")
- new_merge_request_email = @project.new_issuable_address(current_user, 'merge_request')
- add_page_specific_style 'page_bundles/issuable_list'
+- add_page_specific_style 'page_bundles/merge_request'
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@project.name} merge requests")
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 1ef212ee5ce..03306e98407 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -1 +1,3 @@
+- add_page_specific_style 'page_bundles/merge_request'
+
= render 'page'
diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml
index 110bc8d82f8..a1c89a9dd30 100644
--- a/app/views/projects/mirrors/_mirror_repos.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos.html.haml
@@ -8,32 +8,49 @@
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Mirroring repositories')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _('Set up your project to automatically push and/or pull changes to/from another repository. Branches, tags, and commits will be synced automatically.')
= link_to _('How do I mirror repositories?'), help_page_path('user/project/repository/mirror/index.md'), target: '_blank', rel: 'noopener noreferrer'
- .settings-content
- - if mirror_settings_enabled
- = 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)
-
- .form-group.has-feedback
- = label_tag :url, _('Git repository URL'), class: 'label-light'
- = text_field_tag :url, nil, class: 'form-control gl-form-input js-mirror-url js-repo-url', placeholder: _('Input the remote repository URL'), required: true, pattern: "(#{protocols}):\/\/.+", autocomplete: 'new-password', data: { qa_selector: 'mirror_repository_url_field' }
-
- = render 'projects/mirrors/instructions'
-
- = render 'projects/mirrors/mirror_repos_form', f: f
- = render 'projects/mirrors/branch_filter'
-
- .panel-footer
- = f.submit _('Mirror repository'), class: 'js-mirror-submit', name: :update_remote_mirror, pajamas_button: true, data: { qa_selector: 'mirror_repository_button' }
- - else
- = render Pajamas::AlertComponent.new(dismissible: false) do |c|
- - c.with_body do
- = _('Mirror settings are only available to GitLab administrators.')
-
- = render 'projects/mirrors/mirror_repos_list'
+ .settings-content
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ %h5.gl-new-card-title
+ = _('Mirrored repositories')
+ .gl-new-card-count
+ = sprite_icon('earth', css_class: 'gl-mr-2')
+ %span.js-mirrored-repo-count
+ = mirrored_repositories_count
+ .gl-new-card-actions
+ = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: "js-toggle-button js-toggle-content", data: { testid: 'add-new-mirror' } }) do
+ = _('Add new')
+ - c.with_body do
+ - if mirror_settings_enabled
+ .gl-new-card-add-form.gl-m-3.gl-mb-4.gl-display-none.js-toggle-content
+ %h4.gl-mt-0
+ = s_('Profiles|Add new mirror repository')
+ = 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|
+ %div= form_errors(@project)
+ .form-group.has-feedback
+ = label_tag :url, _('Git repository URL'), class: 'label-light'
+ = text_field_tag :url, nil, class: 'form-control gl-form-input js-mirror-url js-repo-url', placeholder: _('Input the remote repository URL'), required: true, pattern: "(#{protocols}):\/\/.+", autocomplete: 'new-password', data: { qa_selector: 'mirror_repository_url_field' }
+
+ = render 'projects/mirrors/instructions'
+
+ = render 'projects/mirrors/mirror_repos_form', f: f
+
+ = render 'projects/mirrors/branch_filter'
+
+ = f.submit _('Mirror repository'), class: 'js-mirror-submit', name: :update_remote_mirror, pajamas_button: true, data: { qa_selector: 'mirror_repository_button' }
+
+ = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'gl-ml-2 js-toggle-button' }) do
+ = _('Cancel')
+
+ - else
+ = render Pajamas::AlertComponent.new(dismissible: false) do |c|
+ - c.with_body do
+ = _('Mirror settings are only available to GitLab administrators.')
+
+ = 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
index 185d86245c5..0debd13709d 100644
--- a/app/views/projects/mirrors/_mirror_repos_list.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos_list.html.haml
@@ -1,49 +1,41 @@
- mirror_settings_enabled = can?(current_user, :admin_remote_mirror, @project)
-.panel.panel-default
- .table-responsive
- - if !@project.mirror? && @project.remote_mirrors.count == 0
- = render Pajamas::CardComponent.new(card_options: { class: 'gl-mt-5' }) do |c|
- - c.with_header do
- %strong
- = _('Mirrored repositories') + ' (0)'
- - c.with_body do
- = _('There are currently no mirrored repositories.')
- - else
- %table.table.gl-table.gl-mt-5
- %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_container' } }
- %td{ data: { qa_selector: 'mirror_repository_url_content' } }
- = mirror.safe_url || _('Invalid URL')
- = render_if_exists 'projects/mirrors/mirror_branches_setting_badge', record: mirror
- %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_content' } }= 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_content' }, 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 rspec-delete-mirror', title: _('Remove'), data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' } })
+.table-responsive.gl-mb-0
+ - if !@project.mirror? && @project.remote_mirrors.count == 0
+ .gl-new-card-empty.gl-px-5.gl-py-4= _('There are currently no mirrored repositories.')
+ - else
+ %table.table.b-table.gl-table.b-table-stacked-md
+ %thead.d-none.d-md-table-header-group
+ %tr
+ %th= _('Repository')
+ %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_container' } }
+ %td{ data: { qa_selector: 'mirror_repository_url_content' } }
+ = mirror.safe_url || _('Invalid URL')
+ = render_if_exists 'projects/mirrors/mirror_branches_setting_badge', record: mirror
+ %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_content' } }= 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_content' }, title: html_escape(mirror.last_error.try(:strip)) }
+ %td
+ - 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 rspec-delete-mirror', title: _('Remove'), data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' } })
diff --git a/app/views/projects/ml/models/index.html.haml b/app/views/projects/ml/models/index.html.haml
index 2caba2ae9be..a1c6376e9b4 100644
--- a/app/views/projects/ml/models/index.html.haml
+++ b/app/views/projects/ml/models/index.html.haml
@@ -1,5 +1,4 @@
- breadcrumb_title s_('ModelRegistry|Model registry')
- page_title s_('ModelRegistry|Model registry')
-- presenter = ::Ml::ModelsIndexPresenter.new(@models)
-#js-index-ml-models{ data: { view_model: presenter.present } }
+= render(Projects::Ml::ModelsIndexComponent.new(models: @models))
diff --git a/app/views/projects/notes/_more_actions_dropdown.html.haml b/app/views/projects/notes/_more_actions_dropdown.html.haml
index 8c94a18e1b0..e3cc9199352 100644
--- a/app/views/projects/notes/_more_actions_dropdown.html.haml
+++ b/app/views/projects/notes/_more_actions_dropdown.html.haml
@@ -2,8 +2,7 @@
- if note_editable || !is_current_user
%div{ class: "dropdown more-actions note-actions-item gl-ml-0!" }
- = button_tag title: 'More actions', class: 'note-action-button more-actions-toggle has-tooltip btn gl-button btn-default-tertiary btn-icon', data: { toggle: 'dropdown', container: 'body', qa_selector: 'more_actions_dropdown' } do
- = sprite_icon('ellipsis_v', css_class: 'gl-button-icon gl-icon')
+ = render Pajamas::ButtonComponent.new(icon: 'ellipsis_v', category: :tertiary, button_options: { class: 'note-action-button more-actions-toggle has-tooltip', data: { title: 'More actions', toggle: 'dropdown', container: 'body', qa_selector: 'more_actions_dropdown' }})
%ul.dropdown-menu.more-actions-dropdown.dropdown-open-left
%li
= clipboard_button(text: noteable_note_url(note), title: _('Copy reference'), button_text: _('Copy link'), class: 'btn-clipboard', hide_tooltip: true, hide_button_icon: true)
diff --git a/app/views/projects/packages/infrastructure_registry/show.html.haml b/app/views/projects/packages/infrastructure_registry/show.html.haml
index 8624fdacda7..8410ac0091d 100644
--- a/app/views/projects/packages/infrastructure_registry/show.html.haml
+++ b/app/views/projects/packages/infrastructure_registry/show.html.haml
@@ -7,7 +7,7 @@
.col-12
#js-vue-packages-detail{ data: { package: package_from_presenter(@package),
can_delete: can?(current_user, :destroy_package, @project).to_s,
- svg_path: image_path('illustrations/no-packages.svg'),
+ svg_path: image_path('illustrations/empty-state/empty-package-md.svg'),
project_name: @project.name,
project_path: @project.root_ancestor.full_path,
gitlab_host: Gitlab.config.gitlab.host,
diff --git a/app/views/projects/packages/packages/index.html.haml b/app/views/projects/packages/packages/index.html.haml
index 5397828d48e..9fb265b08c2 100644
--- a/app/views/projects/packages/packages/index.html.haml
+++ b/app/views/projects/packages/packages/index.html.haml
@@ -6,7 +6,7 @@
full_path: @project.full_path,
endpoint: project_packages_path(@project),
page_type: 'projects',
- empty_list_illustration: image_path('illustrations/no-packages.svg'),
+ empty_list_illustration: image_path('illustrations/empty-state/empty-package-md.svg'),
npm_instance_url: package_registry_instance_url(:npm),
project_list_url: project_packages_path(@project),
settings_path: show_package_registry_settings(@project) ? project_settings_packages_and_registries_path(@project) : '',
diff --git a/app/views/projects/pages/_pages_settings.html.haml b/app/views/projects/pages/_pages_settings.html.haml
index 4c8ec21db39..b1ec7a362b7 100644
--- a/app/views/projects/pages/_pages_settings.html.haml
+++ b/app/views/projects/pages/_pages_settings.html.haml
@@ -1,7 +1,6 @@
-- can_edit_max_page_size=can?(current_user, :update_max_pages_size)
-- can_enforce_https_only=Gitlab.config.pages.external_http || Gitlab.config.pages.external_https
+- can_edit_max_page_size = can?(current_user, :update_max_pages_size)
+- can_enforce_https_only = Gitlab.config.pages.external_http || Gitlab.config.pages.external_https
-- return unless can_edit_max_page_size || can_enforce_https_only
= gitlab_ui_form_for @project, url: project_pages_path(@project), html: { class: 'inline', title: pages_https_only_title } do |f|
- if can_edit_max_page_size
= render_if_exists 'shared/pages/max_pages_size_input', form: f
@@ -17,14 +16,13 @@
%p.gl-pl-6
= s_("GitLabPages|When enabled, all attempts to visit your website through HTTP are automatically redirected to HTTPS using a response with status code 301. Requires a valid certificate for all domains. %{docs_link_start}Learn more.%{link_end}").html_safe % { docs_link_start: docs_link_start, link_end: link_end }
- - if Feature.enabled?(:pages_unique_domain, @project)
- .form-group
- = f.fields_for :project_setting do |settings|
- = settings.gitlab_ui_checkbox_component :pages_unique_domain_enabled,
- s_('GitLabPages|Use unique domain'),
- label_options: { class: 'label-bold' }
- %p.gl-pl-6
- = s_("GitLabPages|When enabled, a unique domain is generated to access pages.").html_safe
+ .form-group
+ = f.fields_for :project_setting do |settings|
+ = settings.gitlab_ui_checkbox_component :pages_unique_domain_enabled,
+ s_('GitLabPages|Use unique domain'),
+ label_options: { class: 'label-bold' }
+ %p.gl-pl-6
+ = s_("GitLabPages|When enabled, a unique domain is generated to access pages.").html_safe
.gl-mt-3
= f.submit s_('GitLabPages|Save changes'), pajamas_button: true
diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml
index 88a60b1fb06..5051fc6a5f5 100644
--- a/app/views/projects/pipeline_schedules/index.html.haml
+++ b/app/views/projects/pipeline_schedules/index.html.haml
@@ -2,6 +2,7 @@
- page_title _("Pipeline Schedules")
- add_page_specific_style 'page_bundles/pipeline_schedules'
- add_page_specific_style 'page_bundles/ci_status'
+- add_page_specific_style 'page_bundles/merge_request'
#pipeline-schedules-callout{ data: { docs_url: help_page_path('ci/pipelines/schedules'), illustration_url: image_path('illustrations/pipeline_schedule_callout.svg') } }
diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml
index e16a2235e53..c3d6d0c5971 100644
--- a/app/views/projects/pipelines/charts.html.haml
+++ b/app/views/projects/pipelines/charts.html.haml
@@ -1,6 +1,7 @@
- page_title _('CI/CD Analytics')
#js-project-pipelines-charts-app{ data: { project_path: @project.full_path,
+ project_id: @project.id,
should_render_dora_charts: should_render_dora_charts.to_s,
should_render_quality_summary: should_render_quality_summary.to_s,
failed_pipelines_link: project_pipelines_path(@project, page: '1', scope: 'all', status: 'failed'),
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index bdf09e5356f..435edde319b 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -6,6 +6,7 @@
- add_page_specific_style 'page_bundles/pipeline'
- add_page_specific_style 'page_bundles/reports'
- add_page_specific_style 'page_bundles/ci_status'
+- add_page_specific_style 'page_bundles/merge_request'
- add_page_startup_graphql_call('pipelines/get_pipeline_details', { projectPath: @project.full_path, iid: @pipeline.iid })
.js-pipeline-container{ data: { controller_action: "#{controller.action_name}" } }
diff --git a/app/views/projects/project_templates/_template.html.haml b/app/views/projects/project_templates/_template.html.haml
index 93c53fc99fc..a0677ee2385 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_container' } }
+.template-option.d-flex.align-items-center{ data: { testid: 'template-option-container' } }
.logo.gl-mr-3.px-1
= image_tag template.logo, size: 32, class: "btn-template-icon icon-#{template.name}"
.description
@@ -13,5 +13,5 @@
%label.btn.gl-button.btn-confirm.template-button.choose-template.gl-mb-0{ for: template.name,
'data-testid': "use_template_#{template.name}" }
%input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "template_use", track_property: template.name, track_action: "click_button", track_value: "" } }
- %span{ data: { qa_selector: 'use_template_button' } }
+ %span{ data: { testid: 'use-template-button' } }
= _("Use template")
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 d67c3dc19d7..8dcc59a09d0 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
@@ -1,23 +1,20 @@
= gitlab_ui_form_for [@project, @protected_tag], html: { class: 'new-protected-tag js-new-protected-tag' } do |f|
%input{ type: 'hidden', name: 'update_section', value: 'js-protected-tags-settings' }
- = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }) do |c|
- - c.with_header do
- = _('Protect a tag')
- - c.with_body do
- = 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
- = render partial: "projects/protected_tags/shared/dropdown", locals: { f: f }
- .form-text.text-muted
- - wildcards_url = help_page_path('user/project/protected_tags', anchor: 'wildcard-protected-tags')
- - wildcards_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wildcards_url }
- = html_escape(_("%{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}v*%{code_tag_end} or %{code_tag_start}*-release%{code_tag_end} are supported.")) % { wildcards_link_start: wildcards_link_start, wildcards_link_end: '</a>'.html_safe, code_tag_start: '<code>'.html_safe, code_tag_end: '</code>'.html_safe }
- .form-group.row
- = f.label :create_access_levels_attributes, _('Allowed to create:'), class: 'col-md-2 text-left text-md-right'
- .col-md-10
- .create_access_levels-container
- = yield :create_access_levels
+ = form_errors(@protected_tag)
+ .form-group
+ = f.label :name, _('Tag')
+ .protected-tags-dropdown
+ = render partial: "projects/protected_tags/shared/dropdown", locals: { f: f }
+ .form-text.text-muted
+ - wildcards_url = help_page_path('user/project/protected_tags', anchor: 'wildcard-protected-tags')
+ - wildcards_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wildcards_url }
+ = html_escape(_("%{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}v*%{code_tag_end} or %{code_tag_start}*-release%{code_tag_end} are supported.")) % { wildcards_link_start: wildcards_link_start, wildcards_link_end: '</a>'.html_safe, code_tag_start: '<code>'.html_safe, code_tag_end: '</code>'.html_safe }
+ .form-group
+ = f.label :create_access_levels_attributes, _('Allowed to create')
+ .create_access_levels-container
+ = yield :create_access_levels
+
+ = f.submit _('Protect'), pajamas_button: true, disabled: true, data: { qa_selector: 'protect_tag_button' }
+ = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'gl-ml-2 js-toggle-button' }) do
+ = _('Cancel')
- - c.with_footer do
- = f.submit _('Protect'), pajamas_button: true, disabled: true, data: { qa_selector: 'protect_tag_button' }
diff --git a/app/views/projects/protected_tags/shared/_dropdown.html.haml b/app/views/projects/protected_tags/shared/_dropdown.html.haml
index 9d5d649bc40..758df7b3c1e 100644
--- a/app/views/projects/protected_tags/shared/_dropdown.html.haml
+++ b/app/views/projects/protected_tags/shared/_dropdown.html.haml
@@ -1,7 +1,7 @@
= f.hidden_field(:name)
= dropdown_tag(s_('ProtectedBranch|Select tag or create wildcard'),
- options: { toggle_class: 'js-protected-tag-select js-filter-submit wide monospace',
+ options: { toggle_class: 'js-protected-tag-select js-filter-submit wide monospace gl-w-auto!',
filter: true, dropdown_class: "dropdown-menu-selectable capitalize-header git-revision-dropdown", placeholder: s_("ProtectedBranch|Search protected tags"),
footer_content: true,
data: { show_no: true, show_any: true, show_upcoming: true,
diff --git a/app/views/projects/protected_tags/shared/_index.html.haml b/app/views/projects/protected_tags/shared/_index.html.haml
index a016ccf8656..f71ecc3a7c5 100644
--- a/app/views/projects/protected_tags/shared/_index.html.haml
+++ b/app/views/projects/protected_tags/shared/_index.html.haml
@@ -6,15 +6,30 @@
= s_("ProtectedTag|Protected tags")
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= s_("ProtectedTag|Limit access to creating and updating tags.")
= link_to s_("ProtectedTag|What are protected tags?"), help_page_path("user/project/protected_tags")
.settings-content
- %p
+ %p.gl-text-secondary
= s_("ProtectedTag|By default, protected tags restrict who can modify the tag.")
= link_to s_("ProtectedTag|Learn more."), help_page_path("user/project/protected_tags", anchor: "who-can-modify-a-protected-tag")
- - if can? current_user, :admin_project, @project
- = yield :create_protected_tag
-
- = yield :tag_list
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ %h3.gl-new-card-title
+ = _('Protected tags')
+ .gl-new-card-count
+ = sprite_icon('tag', css_class: 'gl-mr-2')
+ = @protected_tags_count
+ - if can? current_user, :admin_project, @project
+ .gl-new-card-actions
+ = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: "js-toggle-button js-toggle-content" }) do
+ = _('Add tag')
+ - c.with_body do
+ - if can? current_user, :admin_project, @project
+ .gl-new-card-add-form.gl-m-3.gl-mb-4.gl-display-none.js-toggle-content
+ %h4.gl-mt-0
+ = _('Protect a tag')
+ = yield :create_protected_tag
+ = yield :tag_list
diff --git a/app/views/projects/protected_tags/shared/_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_protected_tag.html.haml
index 4fe1c8bd3cb..11e8d3a81c2 100644
--- a/app/views/projects/protected_tags/shared/_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/shared/_protected_tag.html.haml
@@ -1,10 +1,10 @@
%tr.js-protected-tag-edit-form{ data: { url: project_protected_tag_path(@project, protected_tag) } }
- %td
+ %td{ data: { label: s_('ProtectedBranch|Tag') }, class: 'gl-vertical-align-middle!' }
%span.ref-name= protected_tag.name
- if @project.root_ref?(protected_tag.name)
= gl_badge_tag s_('ProtectedTags|default'), variant: :info, class: 'gl-ml-2'
- %td
+ %td{ data: { label: s_('ProtectedBranch|Last commit') }, class: 'gl-vertical-align-middle!' }
- if protected_tag.wildcard?
- matching_tags = protected_tag.matching(repository.tag_names)
= link_to pluralize(matching_tags.count, "matching tag"), project_protected_tag_path(@project, protected_tag)
@@ -18,5 +18,5 @@
= yield
- if can? current_user, :admin_project, @project
- %td
- = link_button_to 'Unprotect', [@project, protected_tag, { update_section: 'js-protected-tags-settings' }], aria: { label: s_('ProtectedTags|Unprotect tag') }, data: { confirm: 'Tag will be writable for developers. Are you sure?', confirm_btn_variant: 'danger' }, method: :delete, variant: :danger, category: :secondary
+ %td{ data: { label: _('Actions') }, class: 'gl-vertical-align-middle!' }
+ = link_button_to 'Unprotect', [@project, protected_tag, { update_section: 'js-protected-tags-settings' }], aria: { label: s_('ProtectedTags|Unprotect tag') }, data: { confirm: 'Tag will be writable for developers. Are you sure?', confirm_btn_variant: 'danger' }, method: :delete, variant: :danger, category: :secondary, size: :small
diff --git a/app/views/projects/protected_tags/shared/_tags_list.html.haml b/app/views/projects/protected_tags/shared/_tags_list.html.haml
index 0a85a353e27..66b030a194b 100644
--- a/app/views/projects/protected_tags/shared/_tags_list.html.haml
+++ b/app/views/projects/protected_tags/shared/_tags_list.html.haml
@@ -1,31 +1,27 @@
.protected-tags-list.js-protected-tags-list
- if @protected_tags.empty?
- .card-header
- = s_('ProtectedBranch|Protected tags (%{tags_count})') % { tags_count: 0 }
- %p.settings-message.text-center
+ .gl-new-card-empty.gl-px-5.gl-py-4
= s_('ProtectedBranch|No tags are protected.')
- else
- can_admin_project = can?(current_user, :admin_project, @project)
- %table.table.table-bordered
+ %table.table.b-table.gl-table.b-table-stacked-md
%colgroup
%col{ width: "25%" }
%col{ width: "25%" }
%col{ width: "50%" }
- if can_admin_project
%col
- %thead
+ %thead.d-none.d-md-table-header-group
%tr
%th
- = s_('ProtectedBranch|Protected tags (%{tags_count})') % { tags_count: @protected_tags_count }
+ = s_('ProtectedBranch|Tag')
%th
= s_('ProtectedBranch|Last commit')
%th
= s_('ProtectedBranch|Allowed to create')
- if can_admin_project
%th
- %tbody
- %tr
- = yield
+ %tbody= yield
= paginate @protected_tags, theme: 'gitlab'
diff --git a/app/views/projects/runners/_group_runners.html.haml b/app/views/projects/runners/_group_runners.html.haml
index 32a2e36c779..2d435a7ce9d 100644
--- a/app/views/projects/runners/_group_runners.html.haml
+++ b/app/views/projects/runners/_group_runners.html.haml
@@ -27,7 +27,7 @@
- elsif @group_runners.empty?
= _('This group does not have any group runners yet.')
- - if can?(current_user, :admin_group_runners, @project.group)
+ - if can?(current_user, :register_group_runners, @project.group) || can?(current_user, :create_runner, @project.group)
- group_link_start = "<a href='#{group_runners_path(@project.group)}'>".html_safe
- group_link_end = '</a>'.html_safe
= s_("Runners|To register them, go to the %{link_start}group's Runners page%{link_end}.").html_safe % { link_start: group_link_start, link_end: group_link_end }
diff --git a/app/views/projects/settings/_archive.html.haml b/app/views/projects/settings/_archive.html.haml
index 05685c26ac5..e7da3177cde 100644
--- a/app/views/projects/settings/_archive.html.haml
+++ b/app/views/projects/settings/_archive.html.haml
@@ -1,18 +1,22 @@
- return unless can?(current_user, :archive_project, @project)
-.sub-section
- %h4.warning-title
+= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card', data: { qa_selector: 'export_project_content' } }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ %h4.gl-new-card-title.warning-title
+ - if @project.archived?
+ = _('Unarchive project')
+ - else
+ = _('Archive project')
+
+ - c.with_body do
- if @project.archived?
- = _('Unarchive project')
+ - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'unarchive-a-project') }
+ %p= _("Unarchiving the project restores its members' ability to make commits, and create issues, comments, and other entities. %{strong_start}After you unarchive the project, it displays in the search and on the dashboard.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe }
+ = render Pajamas::ButtonComponent.new(method: :post, href: unarchive_project_path(@project), variant: :confirm, button_options: { aria: { label: _('Unarchive project') }, data: { confirm: _("Are you sure that you want to unarchive this project?"), qa_selector: 'unarchive_project_link' } }) do
+ = _('Unarchive project')
- else
- = _('Archive project')
- - if @project.archived?
- - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'unarchive-a-project') }
- %p= _("Unarchiving the project restores its members' ability to make commits, and create issues, comments, and other entities. %{strong_start}After you unarchive the project, it displays in the search and on the dashboard.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe }
- = render Pajamas::ButtonComponent.new(method: :post, href: unarchive_project_path(@project), variant: :confirm, button_options: { aria: { label: _('Unarchive project') }, data: { confirm: _("Are you sure that you want to unarchive this project?"), qa_selector: 'unarchive_project_link' } }) do
- = _('Unarchive project')
- - else
- - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'archive-a-project') }
- %p= _("Archiving the project makes it entirely read-only. It is hidden from the dashboard and doesn't display in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe }
- = render Pajamas::ButtonComponent.new(method: :post, href: archive_project_path(@project), variant: :confirm, button_options: { aria: { label: _('Archive project') }, data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link', 'confirm-btn-variant': 'confirm' } }) do
- = _('Archive project')
+ - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'archive-a-project') }
+ %p= _("Archiving the project makes it entirely read-only. It is hidden from the dashboard and doesn't display in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe }
+ = render Pajamas::ButtonComponent.new(method: :post, href: archive_project_path(@project), variant: :confirm, button_options: { aria: { label: _('Archive project') }, data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link', 'confirm-btn-variant': 'confirm' } }) do
+ = _('Archive project')
diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml
index e4af6d59cad..b81c3bc9704 100644
--- a/app/views/projects/settings/access_tokens/index.html.haml
+++ b/app/views/projects/settings/access_tokens/index.html.haml
@@ -4,9 +4,11 @@
- type_plural = _('project access tokens')
- @force_desktop_expanded_sidebar = true
-.gl-mt-5.js-search-settings-section
- %h4.gl-my-0
- = page_title
+.settings-section.js-search-settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
+ %h4.gl-my-0
+ = page_title
%p.gl-text-secondary
- help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/project_access_tokens') }
- if current_user.can?(:create_resource_access_tokens, @project)
@@ -23,9 +25,21 @@
#js-new-access-token-app{ data: { access_token_type: type } }
- - if current_user.can?(:create_resource_access_tokens, @project)
- = render_if_exists 'projects/settings/access_tokens/form',
- type: type
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card gl-border-b-0 gl-rounded-bottom-left-none gl-rounded-bottom-right-none js-toggle-container js-token-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ %h3.gl-new-card-title
+ = _('Active project access tokens')
+ .gl-new-card-count
+ = sprite_icon('token', css_class: 'gl-mr-2')
+ %span.js-token-count= @active_access_tokens.size
+ - if current_user.can?(:create_resource_access_tokens, @project)
+ .gl-new-card-actions
+ = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-toggle-button js-toggle-content', data: { testid: 'add-new-token-button' } }) do
+ = _('Add new token')
+ - c.with_body do
+ - if current_user.can?(:create_resource_access_tokens, @project)
+ .gl-new-card-add-form.gl-mt-3.gl-display-none.js-toggle-content.js-add-new-token-form
+ = render_if_exists 'projects/settings/access_tokens/form', type: type
- #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This project has no active access tokens.'), show_role: true
- } }
+ #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This project has no active access tokens.'), show_role: true } }
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index 6eccbd245af..d51acc5e708 100644
--- a/app/views/projects/settings/ci_cd/_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -1,6 +1,7 @@
- help_link_public_pipelines = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'change-which-users-can-view-your-pipelines'), target: '_blank', rel: 'noopener noreferrer'
- help_link_auto_canceling = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'auto-cancel-redundant-pipelines'), target: '_blank', rel: 'noopener noreferrer'
-- help_link_skip_outdated = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'prevent-outdated-deployment-jobs'), target: '_blank', rel: 'noopener noreferrer'
+- help_link_prevent_outdated = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'prevent-outdated-deployment-jobs'), target: '_blank', rel: 'noopener noreferrer'
+- help_link_prevent_outdated_allow_rollback = link_to sprite_icon('question-o'), help_page_path('ci/environments/deployment_safety', anchor: 'job-retries-for-rollback-deployments'), target: '_blank', rel: 'noopener noreferrer'
- help_link_separated_caches = link_to sprite_icon('question-o'), help_page_path('ci/caching/index', anchor: 'cache-key-names'), target: '_blank', rel: 'noopener noreferrer'
.row.gl-mt-3
@@ -23,7 +24,12 @@
.form-group
= f.fields_for :ci_cd_settings_attributes, @project.ci_cd_settings do |form|
= form.gitlab_ui_checkbox_component :forward_deployment_enabled, _("Prevent outdated deployment jobs"),
- help_text: (_('When a deployment job is successful, prevent older deployment jobs that are still pending.') + ' ' + help_link_skip_outdated).html_safe
+ help_text: (_('When a deployment job is successful, prevent older deployment jobs that are still pending.') + ' ' + help_link_prevent_outdated).html_safe
+ .gl-pl-6
+ = f.fields_for :ci_cd_settings_attributes, @project.ci_cd_settings do |form|
+ = form.gitlab_ui_checkbox_component :forward_deployment_rollback_allowed, _("Allow job retries for rollback deployments"),
+ help_text: (_('Allow job retries even if the deployment job is outdated.') + ' ' + help_link_prevent_outdated_allow_rollback).html_safe,
+ checkbox_options: { class: 'gl-pl-6' }
.form-group
= f.gitlab_ui_checkbox_component :ci_separated_caches,
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index 007169809c9..6de39058455 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -11,7 +11,7 @@
= _("General pipelines")
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _("Customize your pipeline configuration.")
.settings-content
= render 'form'
@@ -22,7 +22,7 @@
= s_('CICD|Auto DevOps')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
- auto_devops_url = help_page_path('topics/autodevops/index')
- quickstart_url = help_page_path('topics/autodevops/cloud_deployments/auto_devops_with_gke')
- auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url }
@@ -40,7 +40,7 @@
= _("Runners")
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expand_runners ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _("Runners are processes that pick up and execute CI/CD jobs for GitLab.")
= link_to s_('What is GitLab Runner?'), 'https://docs.gitlab.com/runner/', target: '_blank', rel: 'noopener noreferrer'
.settings-content
@@ -56,7 +56,7 @@
= _("Artifacts")
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _("A job artifact is an archive of files and directories saved by a job when it finishes.")
.settings-content
#js-artifacts-settings-app{ data: { full_path: @project.full_path, help_page_path: help_page_path('ci/jobs/job_artifacts', anchor: 'keep-artifacts-from-most-recent-successful-jobs') } }
@@ -70,10 +70,10 @@
%section.settings.no-animate#js-pipeline-triggers{ class: ('expanded' if expanded) }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
- = _("Pipeline triggers")
+ = _("Pipeline trigger tokens")
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _("Trigger a pipeline for a branch or tag by generating a trigger token and using it with an API call. The token impersonates a user's project access and permissions.")
= link_to _('Learn more.'), help_page_path('ci/triggers/index'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
@@ -88,7 +88,7 @@
= _("Deploy freezes")
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
- freeze_period_docs = help_page_path('user/project/releases/index', anchor: 'prevent-unintentional-releases-by-setting-a-deploy-freeze')
- freeze_period_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: freeze_period_docs }
= html_escape(s_('DeployFreeze|Add a freeze period to prevent unintended releases during a period of time for a given environment. You must update the deployment jobs in %{filename} according to the deploy freezes added here. %{freeze_period_link_start}Learn more.%{freeze_period_link_end}')) % { freeze_period_link_start: freeze_period_link_start, freeze_period_link_end: '</a>'.html_safe, filename: tag.code('.gitlab-ci.yml') }
@@ -106,7 +106,7 @@
= _("Token Access")
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _("Control how the CI_JOB_TOKEN CI/CD variable is used for API access between projects.")
.settings-content
= render 'ci/token_access/index'
@@ -118,7 +118,7 @@
= _("Secure Files")
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= _("Use Secure Files to store files used by your pipelines such as Android keystores, or Apple provisioning profiles and signing certificates.")
= link_to _('Learn more'), help_page_path('ci/secure_files/index'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
diff --git a/app/views/projects/settings/integrations/_form.html.haml b/app/views/projects/settings/integrations/_form.html.haml
index 39dfd410727..97c7729de44 100644
--- a/app/views/projects/settings/integrations/_form.html.haml
+++ b/app/views/projects/settings/integrations/_form.html.haml
@@ -14,10 +14,10 @@
- if integration.to_param === 'slack'
= render 'shared/integrations/slack_notifications_deprecation_alert'
-%h2.gl-mb-4
+%h2.gl-mb-0.gl-display-flex.gl-align-items-center.gl-gap-3
= integration.title
- if integration.operating?
- = sprite_icon('check', css_class: 'gl-text-green-500')
+ = render Pajamas::BadgeComponent.new(s_('FeatureFlags|Active'), variant: 'success')
= render 'shared/integration_settings', integration: integration
- if lookup_context.template_exists?('show', "shared/integrations/#{integration.to_param}", true)
diff --git a/app/views/projects/settings/integrations/index.html.haml b/app/views/projects/settings/integrations/index.html.haml
index 59cdda5bb92..6c0c99543cc 100644
--- a/app/views/projects/settings/integrations/index.html.haml
+++ b/app/views/projects/settings/integrations/index.html.haml
@@ -8,5 +8,5 @@
%h3= _('Integrations')
- integrations_link_start = '<a href="%{url}">'.html_safe % { url: help_page_url('user/project/integrations/index') }
- webhooks_link_start = '<a href="%{url}">'.html_safe % { url: project_hooks_path(@project) }
- %p= _("%{integrations_link_start}Integrations%{link_end} enable you to make third-party applications part of your GitLab workflow. If the available integrations don't meet your needs, consider using a %{webhooks_link_start}webhook%{link_end}.").html_safe % { integrations_link_start: integrations_link_start, webhooks_link_start: webhooks_link_start, link_end: '</a>'.html_safe }
+ %p.gl-text-secondary= _("%{integrations_link_start}Integrations%{link_end} enable you to make third-party applications part of your GitLab workflow. If the available integrations don't meet your needs, consider using a %{webhooks_link_start}webhook%{link_end}.").html_safe % { integrations_link_start: integrations_link_start, webhooks_link_start: webhooks_link_start, link_end: '</a>'.html_safe }
= render 'shared/integrations/index', integrations: @integrations
diff --git a/app/views/projects/settings/operations/_alert_management.html.haml b/app/views/projects/settings/operations/_alert_management.html.haml
index 7433e81c11c..398c7758d66 100644
--- a/app/views/projects/settings/operations/_alert_management.html.haml
+++ b/app/views/projects/settings/operations/_alert_management.html.haml
@@ -9,7 +9,7 @@
= _('Alerts')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= _('Expand')
- %p
+ %p.gl-text-secondary
= _('Display alerts from all configured monitoring tools.')
= link_to _('Learn more.'), help_page_path('operations/incident_management/integrations.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
diff --git a/app/views/projects/settings/operations/_error_tracking.html.haml b/app/views/projects/settings/operations/_error_tracking.html.haml
index 5d89790ef9f..1cfdd7086d9 100644
--- a/app/views/projects/settings/operations/_error_tracking.html.haml
+++ b/app/views/projects/settings/operations/_error_tracking.html.haml
@@ -8,7 +8,7 @@
= _('Error tracking')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= _('Expand')
- %p
+ %p.gl-text-secondary
= _('Link Sentry to GitLab to discover and view the errors your application generates.')
= link_to _('Learn more.'), help_page_path('operations/error_tracking'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
diff --git a/app/views/projects/tracing/show.html.haml b/app/views/projects/tracing/show.html.haml
new file mode 100644
index 00000000000..4ba316a0b5c
--- /dev/null
+++ b/app/views/projects/tracing/show.html.haml
@@ -0,0 +1,5 @@
+- page_title _('Trace Details')
+- add_to_breadcrumbs _('Tracing'), project_tracing_index_path(@project)
+
+#js-tracing-details{ data: { view_model: observability_tracing_details_model(@project, @trace_id) } }
+
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index c834a0bc818..a4ed19c2fc9 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -1,5 +1,3 @@
-- is_project_overview = local_assigns.fetch(:is_project_overview, false)
-
.tree-ref-container.gl-display-flex.gl-flex-wrap.gl-gap-2.mb-2.mb-md-0
.tree-ref-holder.gl-max-w-26{ data: { qa_selector: 'ref_dropdown_container' } }
#js-tree-ref-switcher{ data: { project_id: @project.id, ref_type: @ref_type.to_s, project_root_path: project_path(@project) } }
@@ -10,7 +8,7 @@
.tree-controls
.d-block.d-sm-flex.flex-wrap.align-items-start.gl-children-ml-sm-3.gl-first-child-ml-sm-0<
= render_if_exists 'projects/tree/lock_link'
- #js-tree-history-link{ data: { history_link: project_commits_path(@project, @ref), is_project_overview: is_project_overview.to_s } }
+ #js-tree-history-link{ data: { history_link: project_commits_path(@project, @ref) } }
= render 'projects/find_file_link'
= render 'shared/web_ide_button', blob: nil
diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml
index fbbf1c04613..3c3f9eb7390 100644
--- a/app/views/projects/tree/show.html.haml
+++ b/app/views/projects/tree/show.html.haml
@@ -1,8 +1,9 @@
+- ref_type_enum_value = @ref_type&.upcase
- add_page_specific_style 'page_bundles/tree'
- current_route_path = request.fullpath.match(%r{-/tree/[^/]+/(.+$)}).to_a[1]
-- add_page_startup_graphql_call('repository/path_last_commit', { projectPath: @project.full_path, ref: current_ref, path: current_route_path || "" })
+- add_page_startup_graphql_call('repository/path_last_commit', { projectPath: @project.full_path, ref: current_ref, path: current_route_path || "", refType: ref_type_enum_value})
- add_page_startup_graphql_call('repository/permissions', { projectPath: @project.full_path })
-- add_page_startup_graphql_call('repository/files', { nextPageCursor: "", pageSize: 100, projectPath: @project.full_path, ref: current_ref, path: current_route_path || "/"})
+- add_page_startup_graphql_call('repository/files', { nextPageCursor: "", pageSize: 100, projectPath: @project.full_path, ref: current_ref, path: current_route_path || "/", refType: ref_type_enum_value})
- breadcrumb_title _("Repository")
- page_title @path.presence || _("Files"), @ref
diff --git a/app/views/projects/triggers/_form.html.haml b/app/views/projects/triggers/_form.html.haml
index b621f1ab3ed..b7e226b009c 100644
--- a/app/views/projects/triggers/_form.html.haml
+++ b/app/views/projects/triggers/_form.html.haml
@@ -1,3 +1,4 @@
+- show_cancel_button = local_assigns.fetch(:show_cancel_button, false)
= gitlab_ui_form_for [@project, @trigger], html: { class: 'gl-show-field-errors' } do |f|
= form_errors(@trigger)
@@ -9,3 +10,6 @@
= f.label :key, s_("Trigger|Description"), class: "label-bold"
= f.text_field :description, class: 'form-control gl-form-input', required: true, title: 'Trigger description is required.', placeholder: s_("Trigger|Trigger description")
= f.submit btn_text, pajamas_button: true
+ - if show_cancel_button
+ = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'gl-ml-2 js-toggle-button' }) do
+ = _('Cancel')
diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml
index b68aad24b50..7b6915b7b85 100644
--- a/app/views/projects/triggers/_index.html.haml
+++ b/app/views/projects/triggers/_index.html.haml
@@ -1,17 +1,38 @@
+- add_form_class = 'gl-display-none' if !form_errors(@trigger)
+- hide_class = 'gl-display-none' if form_errors(@trigger)
+
.row.gl-mt-3.gl-mb-3
.col-lg-12
- = render Pajamas::CardComponent.new do |c|
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header gl-flex-wrap gl-sm-flex-nowrap' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c|
- c.with_header do
- = _("Manage your project's triggers")
+ .gl-new-card-title-wrapper
+ %h5.gl-new-card-title
+ = _("Active pipeline trigger tokens")
+ .gl-new-card-count
+ = sprite_icon('token', css_class: 'gl-mr-2')
+ = @triggers.size
+ .gl-new-card-actions.gl-display-flex.gl-justify-content-end.gl-w-full.gl-sm-w-auto.gl-mt-3.gl-sm-mt-0
+ = render Pajamas::ButtonComponent.new(size: :small, category: :tertiary, button_options: { data: { testid: 'reveal-hide-values-button' } }) do
+ = _('Reveal values')
+ = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: "gl-ml-2 js-toggle-button js-toggle-content #{hide_class}" }) do
+ = _('Add new token')
+
- c.with_body do
- = render 'projects/triggers/form', btn_text: _('Add trigger')
- .gl-mb-5
+ .gl-new-card-add-form.gl-m-3.js-toggle-content{ class: add_form_class }
+ %h4.gl-mt-0
+ = _('Add new pipeline trigger token')
+ = render 'projects/triggers/form', btn_text: _('Create pipeline trigger token'), show_cancel_button: true
+
#js-ci-pipeline-triggers-list.triggers-list{ data: { triggers: @triggers_json } }
- - c.with_footer do
- %p
- = _("These examples show how to trigger this project's pipeline for a branch or tag.")
- %p.light
+ %details.gl-mt-5.gl-border.gl-rounded-base
+ %summary.gl-py-3.gl-px-5.gl-font-weight-semibold
+ = _("View trigger token usage examples")
+ .gl-p-5
+ %p.gl-text-secondary
+ = _("These examples show common methods of triggering a pipeline with a pipeline trigger token. The URL and ID for this project is prefilled.")
+
+ %p.gl-text-secondary
= _('In each example, replace %{code_start}TOKEN%{code_end} with the trigger token you generated and replace %{code_start}REF_NAME%{code_end} with the branch or tag name.').html_safe % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe }
%h5.gl-mt-3
@@ -40,10 +61,10 @@
%h5.gl-mt-3
= _('Pass job variables')
- %p.light
+ %p.gl-text-secondary
= _('To pass variables to the triggered pipeline, add %{code_start}variables[VARIABLE]=VALUE%{code_end} to the API request.').html_safe % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe }
- %p.light
+ %p.gl-text-secondary
= _('cURL:')
%pre
@@ -54,7 +75,7 @@
-F "ref=REF_NAME" \
-F "variables[RUN_NIGHTLY_BUILD]=true" \
#{builds_trigger_url(@project.id)}
- %p.light
+ %p.gl-text-secondary
= _('Webhook:')
%pre.gl-mb-0
diff --git a/app/views/projects/usage_quotas/index.html.haml b/app/views/projects/usage_quotas/index.html.haml
index aad96151678..616af3f3338 100644
--- a/app/views/projects/usage_quotas/index.html.haml
+++ b/app/views/projects/usage_quotas/index.html.haml
@@ -15,9 +15,10 @@
.row
.col-sm-12
- = s_('UsageQuota|Usage of project resources across the %{strong_start}%{project_name}%{strong_end} project').html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, project_name: @project.name } + '.'
- %a{ href: help_page_path('user/usage_quotas.md'), target: '_blank', rel: 'noopener noreferrer' }
- = s_('UsageQuota|Learn more about usage quotas') + '.'
+ %p.gl-text-secondary
+ = s_('UsageQuota|Usage of project resources across the %{strong_start}%{project_name}%{strong_end} project').html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, project_name: @project.name } + '.'
+ %a{ href: help_page_path('user/usage_quotas.md'), target: '_blank', rel: 'noopener noreferrer' }
+ = s_('UsageQuota|Learn more about usage quotas') + '.'
= gl_tabs_nav({ id: 'js-project-usage-quotas-tabs' }) do
= gl_tab_link_to '#storage-quota-tab', item_active: true do
diff --git a/app/views/protected_branches/shared/_branches_list.html.haml b/app/views/protected_branches/shared/_branches_list.html.haml
index ed2d420ffcd..1edec308283 100644
--- a/app/views/protected_branches/shared/_branches_list.html.haml
+++ b/app/views/protected_branches/shared/_branches_list.html.haml
@@ -1,12 +1,10 @@
.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 }
- %p.settings-message.text-center
- = s_("ProtectedBranch|There are currently no protected branches, protect a branch with the form above.")
+ %p.gl-new-card-empty.gl-px-5.gl-py-4.js-toggle-content
+ = s_("ProtectedBranch|There are currently no protected branches, to protect a branch start by creating a new one above.")
- else
.flash-container
- %table.table.table-bordered
+ %table.table.b-table.gl-table.b-table-stacked-md
%colgroup
%col{ width: "30%" }
%col{ width: "20%" }
@@ -34,5 +32,3 @@
%th
%tbody
= yield
-
- = paginate @protected_branches, theme: 'gitlab'
diff --git a/app/views/protected_branches/shared/_create_protected_branch.html.haml b/app/views/protected_branches/shared/_create_protected_branch.html.haml
index d97347b89de..96e6990b080 100644
--- a/app/views/protected_branches/shared/_create_protected_branch.html.haml
+++ b/app/views/protected_branches/shared/_create_protected_branch.html.haml
@@ -1,44 +1,43 @@
= gitlab_ui_form_for [protected_branch_entity, @protected_branch], html: { class: 'new-protected-branch js-new-protected-branch' } do |f|
%input{ type: 'hidden', name: 'update_section', value: 'js-protected-branches-settings' }
- = render Pajamas::CardComponent.new(card_options: { class: "gl-mb-5" }) do |c|
- - c.with_header do
- = s_("ProtectedBranch|Protect a branch")
- - c.with_body do
- = form_errors(@protected_branch)
- .form-group.row
- = f.label :name, s_('ProtectedBranch|Branch:'), class: 'col-sm-12'
- .col-sm-12
- - if protected_branch_entity.is_a?(Group)
- = f.text_field :name, placeholder: 'prod*', class: 'form-control gl-w-full! gl-form-input-lg'
- - else
- = render partial: "protected_branches/shared/dropdown", locals: { f: f, toggle_classes: 'gl-w-full! gl-form-input-lg' }
- .form-text.text-muted
- - wildcards_url = help_page_url('user/project/protected_branches', anchor: 'protect-multiple-branches-with-wildcard-rules')
- - wildcards_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wildcards_url }
- - placeholders = { wildcards_link_start: wildcards_link_start, wildcards_link_end: '</a>', code_tag_start: '<code>', code_tag_end: '</code>' }
- - if protected_branch_entity.is_a?(Group)
- = (s_("ProtectedBranch|Only %{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}*-stable%{code_tag_end} or %{code_tag_start}production/*%{code_tag_end} are supported.") % placeholders).html_safe
- - else
- = (s_("ProtectedBranch|%{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}*-stable%{code_tag_end} or %{code_tag_start}production/*%{code_tag_end} are supported.") % placeholders).html_safe
- .form-group.row
- = f.label :merge_access_levels_attributes, s_("ProtectedBranch|Allowed to merge:"), class: 'col-sm-12'
- .col-sm-12
- = yield :merge_access_levels
- .form-group.row
- = f.label :push_access_levels_attributes, s_("ProtectedBranch|Allowed to push and merge:"), class: 'col-sm-12'
- .col-sm-12
- = yield :push_access_levels
- .form-group.row
- = f.label :allow_force_push, s_("ProtectedBranch|Allowed to force push:"), class: 'col-sm-12'
- .col-sm-12
- = render Pajamas::ToggleComponent.new(classes: 'js-force-push-toggle',
- label: s_("ProtectedBranch|Allowed to force push"),
- label_position: :hidden) do
- - force_push_docs_url = help_page_url('topics/git/git_rebase', anchor: 'force-push')
- - force_push_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: force_push_docs_url }
- = (s_("ProtectedBranch|Allow all users with push access to %{tag_start}force push%{tag_end}.") % { tag_start: force_push_link_start, tag_end: '</a>' }).html_safe
- = render_if_exists 'protected_branches/ee/code_owner_approval_form', f: f, protected_branch_entity: protected_branch_entity
- - c.with_footer do
- = f.submit s_('ProtectedBranch|Protect'), disabled: true, data: { qa_selector: 'protect_button' }, pajamas_button: true
- .js-alert-protected-branch-created-container.gl-mb-5
+ = form_errors(@protected_branch)
+
+ %h4.gl-mt-0= s_("ProtectedBranch|Protect a branch")
+
+ .form-group.row
+ = f.label :name, s_('ProtectedBranch|Branch:'), class: 'col-sm-12'
+ .col-sm-12
+ - if protected_branch_entity.is_a?(Group)
+ = f.text_field :name, placeholder: 'prod*', class: 'form-control gl-w-full! gl-form-input-lg'
+ - else
+ = render partial: "protected_branches/shared/dropdown", locals: { f: f, toggle_classes: 'gl-w-full! gl-form-input-lg' }
+ .form-text.text-muted
+ - wildcards_url = help_page_url('user/project/protected_branches', anchor: 'protect-multiple-branches-with-wildcard-rules')
+ - wildcards_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wildcards_url }
+ - placeholders = { wildcards_link_start: wildcards_link_start, wildcards_link_end: '</a>', code_tag_start: '<code>', code_tag_end: '</code>' }
+ - if protected_branch_entity.is_a?(Group)
+ = (s_("ProtectedBranch|Only %{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}*-stable%{code_tag_end} or %{code_tag_start}production/*%{code_tag_end} are supported.") % placeholders).html_safe
+ - else
+ = (s_("ProtectedBranch|%{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}*-stable%{code_tag_end} or %{code_tag_start}production/*%{code_tag_end} are supported.") % placeholders).html_safe
+ .form-group.row
+ = f.label :merge_access_levels_attributes, s_("ProtectedBranch|Allowed to merge:"), class: 'col-sm-12'
+ .col-sm-12
+ = yield :merge_access_levels
+ .form-group.row
+ = f.label :push_access_levels_attributes, s_("ProtectedBranch|Allowed to push and merge:"), class: 'col-sm-12'
+ .col-sm-12
+ = yield :push_access_levels
+ .form-group.row
+ = f.label :allow_force_push, s_("ProtectedBranch|Allowed to force push:"), class: 'col-sm-12'
+ .col-sm-12
+ = render Pajamas::ToggleComponent.new(classes: 'js-force-push-toggle',
+ label: s_("ProtectedBranch|Allowed to force push"),
+ label_position: :hidden) do
+ - force_push_docs_url = help_page_url('topics/git/git_rebase', anchor: 'force-push')
+ - force_push_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: force_push_docs_url }
+ = (s_("ProtectedBranch|Allow all users with push access to %{tag_start}force push%{tag_end}.") % { tag_start: force_push_link_start, tag_end: '</a>' }).html_safe
+ = render_if_exists 'protected_branches/ee/code_owner_approval_form', f: f, protected_branch_entity: protected_branch_entity
+ = f.submit s_('ProtectedBranch|Protect'), disabled: true, data: { qa_selector: 'protect_button' }, pajamas_button: true
+ = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'gl-ml-2 js-toggle-button' }) do
+ = _('Cancel')
diff --git a/app/views/protected_branches/shared/_index.html.haml b/app/views/protected_branches/shared/_index.html.haml
index d0e21e38429..dccfefc1cb8 100644
--- a/app/views/protected_branches/shared/_index.html.haml
+++ b/app/views/protected_branches/shared/_index.html.haml
@@ -7,15 +7,31 @@
= s_("ProtectedBranch|Protected branches")
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= s_("ProtectedBranch|Keep stable branches secure and force developers to use merge requests.")
= link_to s_("ProtectedBranch|What are protected branches?"), help_page_path("user/project/protected_branches")
.settings-content
- %p
- = s_("ProtectedBranch|By default, protected branches restrict who can modify the branch.")
- = link_to s_("ProtectedBranch|Learn more."), help_page_path("user/project/protected_branches", anchor: "who-can-modify-a-protected-branch")
+ .js-alert-protected-branch-created-container.gl-mt-5
- - if can_admin_entity
- = content_for :create_protected_branch
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper.gl-justify-content-space-between
+ %h3.gl-new-card-title
+ = s_("ProtectedBranch|Protected branches")
+ .gl-new-card-count
+ = sprite_icon('branch', css_class: 'gl-mr-2')
+ %span= @protected_branches.size
+ .gl-new-card-actions
+ = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-toggle-button js-toggle-content', data: { testid: 'add-protected-branch-button' } }) do
+ = _('Add protected branch')
+ .gl-new-card-description.gl-mt-2.gl-sm-mt-0
+ = s_("ProtectedBranch|By default, protected branches restrict who can modify the branch.")
+ = link_to s_("ProtectedBranch|Learn more."), help_page_path("user/project/protected_branches", anchor: "who-can-modify-a-protected-branch")
+ - c.with_body do
+ - if can_admin_entity
+ .gl-new-card-add-form.gl-m-3.gl-display-none.js-toggle-content
+ = content_for :create_protected_branch
- = content_for :branches_list
+ = content_for :branches_list
+
+ = paginate @protected_branches, theme: 'gitlab'
diff --git a/app/views/protected_branches/shared/_protected_branch.html.haml b/app/views/protected_branches/shared/_protected_branch.html.haml
index 69969b7f848..93c84e67d81 100644
--- a/app/views/protected_branches/shared/_protected_branch.html.haml
+++ b/app/views/protected_branches/shared/_protected_branch.html.haml
@@ -3,14 +3,14 @@
- protected_branch_test_type = protected_branch.project_level? ? 'project-level' : 'group-level'
%tr.js-protected-branch-edit-form{ data: { url: url, testid: 'protected-branch', test_type: protected_branch_test_type } }
- %td
- %span.ref-name= protected_branch.name
+ %td{ class: 'gl-vertical-align-middle!', data: { label: s_("ProtectedBranch|Branch") } }
+ %div
+ %span.ref-name= protected_branch.name
- - if protected_branch.project_level?
- - if protected_branch_entity.root_ref?(protected_branch.name)
- = gl_badge_tag s_('ProtectedBranch|default'), variant: :info
+ - if protected_branch.project_level?
+ - if protected_branch_entity.root_ref?(protected_branch.name)
+ = gl_badge_tag s_('ProtectedBranch|default'), variant: :info
- %div
- if protected_branch.wildcard?
- matching_branches = protected_branch.matching(repository.branch_names)
= link_to pluralize(matching_branches.count, "matching branch"), namespace_project_protected_branch_path(@project.namespace, @project, protected_branch)
@@ -22,9 +22,9 @@
= render_if_exists 'protected_branches/ee/code_owner_approval_table', can_update: local_assigns[:can_update], protected_branch: protected_branch, protected_branch_entity: protected_branch_entity
- if can_admin_entity
- %td.text-right{ data: { testid: 'protected-branch-action' } }
+ %td.text-right{ data: { label: _('Actions'), testid: 'protected-branch-action' } }
- if local_assigns[:is_inherited]
%span.has-tooltip{ data: { container: 'body' }, title: s_('ProtectedBranch|Inherited - This setting can be changed at the group level'), 'aria-hidden': 'true' }
= sprite_icon 'lock'
- else
- = link_button_to s_('ProtectedBranch|Unprotect'), [protected_branch_entity, protected_branch, { update_section: 'js-protected-branches-settings' }], disabled: local_assigns[:disabled], aria: { label: s_('ProtectedBranch|Unprotect branch') }, data: { confirm: s_('ProtectedBranch|Branch will be writable for developers. Are you sure?'), confirm_btn_variant: 'danger' }, method: :delete, variant: :danger, size: :small
+ = link_button_to s_('ProtectedBranch|Unprotect'), [protected_branch_entity, protected_branch, { update_section: 'js-protected-branches-settings' }], disabled: local_assigns[:disabled], aria: { label: s_('ProtectedBranch|Unprotect branch') }, data: { confirm: s_('ProtectedBranch|Branch will be writable for developers. Are you sure?'), confirm_btn_variant: 'danger' }, method: :delete, variant: :danger, category: :secondary, size: :small
diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb
index 65501b27451..e780b13de6e 100644
--- a/app/views/pwa/manifest.json.erb
+++ b/app/views/pwa/manifest.json.erb
@@ -1,3 +1,4 @@
+<%- cache current_appearance do %>
{
"name": "<%= appearance_pwa_name %>",
"short_name": "<%= appearance_pwa_short_name %>",
@@ -33,3 +34,4 @@
}
<% end -%>]
}
+<% end %>
diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml
index 934f59ea586..16ca829a6d4 100644
--- a/app/views/search/show.html.haml
+++ b/app/views/search/show.html.haml
@@ -8,7 +8,7 @@
= hidden_field_tag :project_id, params[:project_id]
- group_attributes = @group&.attributes&.slice('id', 'name')&.merge(full_name: @group&.full_name)
- project_attributes = @project&.attributes&.slice('id', 'namespace_id', 'name')&.merge(name_with_namespace: @project&.name_with_namespace)
-- search_bar_classes = 'search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4'
+- search_bar_classes = !show_super_sidebar? ? 'search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4' : ''
- if @search_results && !(@search_results.respond_to?(:failed?) && @search_results.failed?)
- if @search_service_presenter.without_count?
diff --git a/app/views/shared/_email_with_badge.html.haml b/app/views/shared/_email_with_badge.html.haml
index 5013d8e439a..14201c0d23a 100644
--- a/app/views/shared/_email_with_badge.html.haml
+++ b/app/views/shared/_email_with_badge.html.haml
@@ -1,6 +1,7 @@
- variant = verified ? :success : :danger
- text = verified ? _('Verified') : _('Unverified')
-%span.gl-mr-3
- = email
-= gl_badge_tag text, { variant: variant }
+- if email
+ %span.gl-mr-2
+ = email
+= gl_badge_tag text, { variant: variant, size: :sm }
diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml
index 1aac7af443f..bb21c4a28fd 100644
--- a/app/views/shared/_label.html.haml
+++ b/app/views/shared/_label.html.haml
@@ -6,8 +6,8 @@
- toggle_subscription_path = toggle_subscription_label_path(label, @project) if current_user
- tooltip_title = label_status_tooltip(label, status) if status
-%li.label-list-item.gl-list-style-none.gl-py-3{ id: label_css_id, data: { id: label.id } }
- .label-content.gl-px-3.gl-py-2.gl-rounded-base{ class: "#{ 'gl-py-3' if force_priority }" }
+%li.label-list-item.gl-list-style-none{ id: label_css_id, data: { id: label.id } }
+ .label-content.gl-pl-5.gl-pr-3.gl-py-4.gl-rounded-base{ class: "#{ 'gl-py-3' if force_priority }" }
= render "shared/label_row", label: label, force_priority: force_priority
%ul.label-actions-list
- if can?(current_user, :admin_label, @project)
diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml
index 19489981d94..5058455dcd7 100644
--- a/app/views/shared/_label_row.html.haml
+++ b/app/views/shared/_label_row.html.haml
@@ -4,7 +4,7 @@
- show_label_merge_requests_link = subject_or_group_defined && show_label_issuables_link?(label, :merge_requests)
.label-name.gl-flex-shrink-0.gl-mr-5
- = render_label(label, tooltip: false)
+ = render_label(label, link: '#', tooltip: true, tooltip_shows_title: true)
- if show_labels_full_path?(@project, @group)
.gl-mt-2
= render 'shared/label_full_path', label: label
diff --git a/app/views/shared/_no_password.html.haml b/app/views/shared/_no_password.html.haml
index e0d385024cd..1f6f41187fc 100644
--- a/app/views/shared/_no_password.html.haml
+++ b/app/views/shared/_no_password.html.haml
@@ -5,5 +5,5 @@
- c.with_body do
= no_password_message
- c.with_actions do
- = link_to _('Remind later'), '#', class: 'js-hide-no-password-message gl-alert-action btn btn-confirm btn-md gl-button'
- = link_to _("Don't show again"), profile_path(user: { hide_no_password: true }), method: :put, role: 'button', class: 'gl-alert-action btn btn-default btn-md gl-button'
+ = link_button_to _('Remind later'), '#', class: 'js-hide-no-password-message gl-alert-action', variant: :confirm
+ = link_button_to _("Don't show again"), profile_path(user: { hide_no_password: true }), method: :put, role: 'button', class: 'gl-alert-action'
diff --git a/app/views/shared/_service_ping_consent.html.haml b/app/views/shared/_service_ping_consent.html.haml
index e0313710736..cfc0afb4646 100644
--- a/app/views/shared/_service_ping_consent.html.haml
+++ b/app/views/shared/_service_ping_consent.html.haml
@@ -1,7 +1,7 @@
- if session[:ask_for_usage_stats_consent]
= render Pajamas::AlertComponent.new(alert_options: { class: 'service-ping-consent-message' }) do |c|
- c.with_body do
- - docs_link = link_to '', help_page_path('user/admin_area/settings/usage_statistics.md'), class: 'gl-link'
+ - docs_link = link_to '', help_page_path('administration/settings/usage_statistics.md'), class: 'gl-link'
- settings_link = link_to '', metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), class: 'gl-link'
= safe_format s_('ServicePing|To help improve GitLab, we would like to periodically %{link_start}collect usage information%{link_end}.'), tag_pair(docs_link, :link_start, :link_end)
= safe_format s_('ServicePing|This can be changed at any time in %{link_start}your settings%{link_end}.'), tag_pair(settings_link, :link_start, :link_end)
diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml
index 54af364aca3..e46da882e83 100644
--- a/app/views/shared/access_tokens/_form.html.haml
+++ b/app/views/shared/access_tokens/_form.html.haml
@@ -7,7 +7,7 @@
- access_levels = local_assigns.fetch(:access_levels, false)
- default_access_level = local_assigns.fetch(:default_access_level, false)
-%h5.gl-font-lg.gl-mt-0
+%h4.gl-mt-0
= title
= 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|
@@ -15,9 +15,13 @@
.form-group
= f.label :name, s_('AccessTokens|Token name'), class: 'label-bold'
- - resource_type = resource.is_a?(Group) ? "group" : "project"
- = f.text_field :name, class: 'form-control gl-form-input gl-max-w-80', required: true, data: { qa_selector: 'access_token_name_field' }, :'aria-describedby' => 'access_token_help_text'
- %span.form-text.text-muted#access_token_help_text= s_("AccessTokens|For example, the application using the token or the purpose of the token. Do not give sensitive information for the name of the token, as it will be visible to all %{resource_type} members.") % { resource_type: resource_type }
+ = f.text_field :name, class: 'form-control gl-form-input gl-form-input-xl', required: true, data: { qa_selector: 'access_token_name_field' }, :'aria-describedby' => 'access_token_help_text'
+ %span.form-text.text-muted#access_token_help_text
+ - if resource
+ - resource_type = resource.is_a?(Group) ? "group" : "project"
+ = s_("AccessTokens|For example, the application using the token or the purpose of the token. Do not give sensitive information for the name of the token, as it will be visible to all %{resource_type} members.") % { resource_type: resource_type }
+ - else
+ = s_("AccessTokens|For example, the application using the token or the purpose of the token.")
.js-access-tokens-expires-at{ data: expires_at_field_data }
= f.text_field :expires_at, class: 'gl-datepicker-input form-control gl-form-input', placeholder: 'YYYY-MM-DD', autocomplete: 'off', data: { js_name: 'expiresAt' }
@@ -39,3 +43,5 @@
.gl-mt-3
= f.submit s_('AccessTokens|Create %{type}') % { type: type }, data: { qa_selector: 'create_token_button' }, pajamas_button: true
+ = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'gl-ml-2 js-toggle-button' }) do
+ = _('Cancel')
diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml
index 584d0758c76..bbaf5bf9627 100644
--- a/app/views/shared/deploy_keys/_form.html.haml
+++ b/app/views/shared/deploy_keys/_form.html.haml
@@ -5,37 +5,34 @@
= form_errors(deploy_key)
.form-group
- = form.label :title, class: 'col-form-label col-sm-2'
- .col-sm-10= form.text_field :title, class: 'form-control gl-form-input', data: { qa_selector: 'deploy_key_title_field' }, readonly: ('readonly' unless can?(current_user, :update_deploy_key, deploy_key))
+ = form.label :title
+ = form.text_field :title, class: 'form-control gl-form-input', data: { testid: 'deploy-key-title-field' }, readonly: ('readonly' unless can?(current_user, :update_deploy_key, deploy_key))
-.form-group
- - if deploy_key.new_record?
- = form.label :key, class: 'col-form-label col-sm-2'
- .col-sm-10
- %p.light
- - link_start = "<a href='#{help_page_path('user/ssh')}' target='_blank' rel='noreferrer noopener'>".html_safe
- - link_end = '</a>'
- = _('Paste a public key here. %{link_start}How do I generate it?%{link_end}').html_safe % { link_start: link_start, link_end: link_end.html_safe }
- = form.text_area :key, class: 'form-control gl-form-input thin_area', rows: 5, data: { qa_selector: 'deploy_key_field' }
- - else
- - if deploy_key.fingerprint_sha256.present?
- = form.label :fingerprint, _('Fingerprint (SHA256)'), class: 'col-form-label col-sm-2'
- .col-sm-10
- = form.text_field :fingerprint_sha256, class: 'form-control gl-form-input', readonly: 'readonly'
- - if deploy_key.fingerprint.present?
- = form.label :fingerprint, _('Fingerprint (MD5)'), class: 'col-form-label col-sm-2'
- .col-sm-10
- = form.text_field :fingerprint, class: 'form-control gl-form-input', readonly: 'readonly'
+- if deploy_key.new_record?
+ .form-group
+ = form.label :key
+
+ %p.gl-text-secondary
+ - link_start = "<a href='#{help_page_path('user/ssh')}' target='_blank' rel='noreferrer noopener'>".html_safe
+ - link_end = '</a>'
+ = _('Paste a public key here. %{link_start}How do I generate it?%{link_end}').html_safe % { link_start: link_start, link_end: link_end.html_safe }
+ = form.text_area :key, class: 'form-control gl-form-input thin_area', rows: 5, data: { testid: 'deploy-key-field' }
+- else
+ - if deploy_key.fingerprint_sha256.present?
+ .form-group
+ = form.label :fingerprint, _('Fingerprint (SHA256)')
+ = form.text_field :fingerprint_sha256, class: 'form-control gl-form-input', readonly: 'readonly'
+ - if deploy_key.fingerprint.present?
+ .form-group
+ = form.label :fingerprint, _('Fingerprint (MD5)')
+ = form.text_field :fingerprint, class: 'form-control gl-form-input', readonly: 'readonly'
.form-group
- .col-sm-10
- = form.label :expires_at, _('Expiration date (optional)'), class: 'label-bold'
- = form.text_field :expires_at, class: 'form-control gl-form-input', readonly: 'readonly'
+ = form.label :expires_at, _('Expiration date (optional)'), class: 'label-bold'
+ = form.text_field :expires_at, class: 'form-control gl-form-input', readonly: 'readonly'
- if deploy_keys_project.present?
= form.fields_for :deploy_keys_projects, deploy_keys_project do |deploy_keys_project_form|
.form-group
- .col-form-label.col-sm-2
- .col-sm-10
- = deploy_keys_project_form.gitlab_ui_checkbox_component :can_push, _('Grant write permissions to this key'),
- help_text: _('Allow this key to push to this repository')
+ = deploy_keys_project_form.gitlab_ui_checkbox_component :can_push, _('Grant write permissions to this key'),
+ help_text: _('Allow this key to push to this repository')
diff --git a/app/views/shared/deploy_keys/_index.html.haml b/app/views/shared/deploy_keys/_index.html.haml
index 1cd2a590653..650e50e0312 100644
--- a/app/views/shared/deploy_keys/_index.html.haml
+++ b/app/views/shared/deploy_keys/_index.html.haml
@@ -1,14 +1,16 @@
- expanded = expanded_by_default?
-%section.rspec-deploy-keys-settings.settings.no-animate#js-deploy-keys-settings{ class: ('expanded' if expanded), data: { qa_selector: 'deploy_keys_settings_content' } }
+%section.rspec-deploy-keys-settings.settings.no-animate#js-deploy-keys-settings{ class: ('expanded' if expanded), data: { testid: 'deploy-keys-settings-content' } }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Deploy keys')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/deploy_keys/index') }
= _("Add deploy keys to grant read/write access to this repository. %{link_start}What are deploy keys?%{link_end}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
.settings-content
- %h5.gl-mt-0
- = render @deploy_keys.form_partial_path
- %hr
- #js-deploy-keys{ data: { endpoint: project_deploy_keys_path(@project), project_id: @project.id } }
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-toggle-container' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c|
+ - c.with_body do
+ .gl-new-card-add-form.gl-m-3.gl-display-none.js-toggle-content
+ = render @deploy_keys.form_partial_path
+
+ #js-deploy-keys{ data: { endpoint: project_deploy_keys_path(@project), project_id: @project.id } }
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 c9e17b18264..c633088b26a 100644
--- a/app/views/shared/deploy_keys/_project_group_form.html.haml
+++ b/app/views/shared/deploy_keys/_project_group_form.html.haml
@@ -1,11 +1,15 @@
= 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)
+
+ .form-group.row
+ %h4.gl-my-0= s_('DeployKeys|Add new deploy 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' }
+ = f.text_field :title, class: 'form-control gl-form-input', required: true, data: { testid: 'deploy-key-title-field' }
.form-group.row
= f.label :key, class: "label-bold"
- = f.text_area :key, class: 'form-control gl-form-input', rows: 5, required: true, data: { qa_selector: 'deploy_key_field' }
+ = f.text_area :key, class: 'form-control gl-form-input', rows: 5, required: true, data: { testid: 'deploy-key-field' }
.form-group.row
%p.light.gl-mb-0
= _('Paste a public key here.')
@@ -17,8 +21,10 @@
help_text: _('Allow this key to push to this repository')
.form-group.row
= f.label :expires_at, _('Expiration date (optional)'), class: 'label-bold'
- = f.gitlab_ui_datepicker :expires_at, data: { qa_selector: 'deploy_key_expires_at_field' }, value: f.object.expires_at
+ = f.gitlab_ui_datepicker :expires_at, data: { testid: 'deploy-key-expires-at-field' }, value: f.object.expires_at
%p.form-text.text-muted= ssh_key_expires_field_description
- .form-group.row
- = f.submit _("Add key"), data: { qa_selector: "add_deploy_key_button"}, pajamas_button: true
+ .form-group.row.gl-mb-0
+ = f.submit _("Add key"), data: { testid: "add-deploy-key-button"}, pajamas_button: true
+ = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'gl-ml-3 js-toggle-button' }) do
+ = _('Cancel')
diff --git a/app/views/shared/deploy_tokens/_index.html.haml b/app/views/shared/deploy_tokens/_index.html.haml
index e5f1fd99125..ccffc3ec923 100644
--- a/app/views/shared/deploy_tokens/_index.html.haml
+++ b/app/views/shared/deploy_tokens/_index.html.haml
@@ -5,16 +5,8 @@
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= s_('DeployTokens|Deploy tokens')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p
+ %p.gl-text-secondary
= description
.settings-content
- #js-new-deploy-token{ data: {
- container_registry_enabled: container_registry_enabled?(group_or_project),
- packages_registry_enabled: packages_registry_enabled?(group_or_project),
- create_new_token_path: create_deploy_token_path(group_or_project),
- token_type: group_or_project.is_a?(Group) ? 'group' : 'project',
- deploy_tokens_help_url: help_page_path('user/project/deploy_tokens/index.md')
- }
- }
- %hr
+ #new-deploy-token-alert
= render 'shared/deploy_tokens/table', group_or_project: group_or_project, active_tokens: @deploy_tokens
diff --git a/app/views/shared/deploy_tokens/_table.html.haml b/app/views/shared/deploy_tokens/_table.html.haml
index 3827ecf73a4..3b351387d41 100644
--- a/app/views/shared/deploy_tokens/_table.html.haml
+++ b/app/views/shared/deploy_tokens/_table.html.haml
@@ -1,32 +1,54 @@
-%h5= s_("DeployTokens|Active Deploy Tokens (%{active_tokens})") % { active_tokens: active_tokens.length }
-
-- if active_tokens.present?
- .table-responsive.deploy-tokens
- %table.table
- %thead
- %tr
- %th= s_('DeployTokens|Name')
- %th= s_('DeployTokens|Username')
- %th= s_('DeployTokens|Created')
- %th= s_('DeployTokens|Expires')
- %th= s_('DeployTokens|Scopes')
- %th
- %tbody
- - active_tokens.each do |token|
+= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ %h5.gl-new-card-title
+ = s_("DeployTokens|Active deploy tokens")
+ .gl-new-card-count
+ = sprite_icon('token', css_class: 'gl-mr-2')
+ = active_tokens.length
+ .gl-new-card-actions
+ = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: "js-toggle-button js-toggle-content" }) do
+ = _('Add token')
+ - c.with_body do
+ .gl-new-card-add-form.gl-m-3.gl-mb-4.gl-display-none.js-toggle-content
+ #js-new-deploy-token{ data: {
+ container_registry_enabled: container_registry_enabled?(group_or_project),
+ packages_registry_enabled: packages_registry_enabled?(group_or_project),
+ create_new_token_path: create_deploy_token_path(group_or_project),
+ token_type: group_or_project.is_a?(Group) ? 'group' : 'project',
+ deploy_tokens_help_url: help_page_path('user/project/deploy_tokens/index.md')
+ }
+ }
+ - if active_tokens.present?
+ %table.table.b-table.gl-table.b-table-stacked-md
+ %thead
%tr
- %td= token.name
- %td= token.username
- %td= token.created_at.to_date.to_fs(:medium)
- %td
- - if token.expires?
- %span{ class: ('text-warning' if token.expires_soon?) }
- = time_ago_with_tooltip(token.expires_at)
- - else
- %span.token-never-expires-label= _('Never')
- %td= token.scopes.present? ? token.scopes.join(', ') : _('no scopes selected')
- %td
- .js-deploy-token-revoke-button{ data: deploy_token_revoke_button_data(token: token, group_or_project: group_or_project) }
+ %th= s_('DeployTokens|Name')
+ %th= s_('DeployTokens|Username')
+ %th= s_('DeployTokens|Created')
+ %th= s_('DeployTokens|Expires')
+ %th= s_('DeployTokens|Scopes')
+ %th
+ %tbody
+ - active_tokens.each do |token|
+ %tr
+ %td{ data: { label: _('Name') }, class: 'gl-vertical-align-middle!' }
+ = token.name
+ %td{ data: { label: _('Username') }, class: 'gl-vertical-align-middle!' }
+ = token.username
+ %td{ data: { label: _('Created') }, class: 'gl-vertical-align-middle!' }
+ = token.created_at.to_date.to_fs(:medium)
+ %td{ data: { label: _('Expires') }, class: 'gl-vertical-align-middle!' }
+ - if token.expires?
+ %span{ class: ('text-warning' if token.expires_soon?) }
+ = time_ago_with_tooltip(token.expires_at)
+ - else
+ %span.token-never-expires-label= _('Never')
+ %td{ data: { label: _('Scopes') }, class: 'gl-vertical-align-middle!' }
+ = token.scopes.present? ? token.scopes.join(', ') : _('no scopes selected')
+ %td{ data: { label: _('Actions') }, class: 'gl-vertical-align-middle!' }
+ .js-deploy-token-revoke-button{ data: deploy_token_revoke_button_data(token: token, group_or_project: group_or_project) }
-- else
- .settings-message.text-center
- = s_('DeployTokens|This %{entity_type} has no active Deploy Tokens.') % { entity_type: group_or_project.class.name.downcase }
+ - else
+ .gl-new-card-empty.gl-px-5.gl-py-4
+ = s_('DeployTokens|This %{entity_type} has no active deploy tokens.') % { entity_type: group_or_project.class.name.downcase }
diff --git a/app/views/shared/doorkeeper/applications/_delete_form.html.haml b/app/views/shared/doorkeeper/applications/_delete_form.html.haml
index 512daf7b96b..6b80b42f918 100644
--- a/app/views/shared/doorkeeper/applications/_delete_form.html.haml
+++ b/app/views/shared/doorkeeper/applications/_delete_form.html.haml
@@ -1,10 +1,7 @@
-- submit_btn_css ||= 'btn btn-danger btn-md gl-button btn-danger-secondary'
= form_tag path do
%input{ :name => "_method", :type => "hidden", :value => "delete" }
- if defined? small
- = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, icon: 'remove', button_options: { class: submit_btn_css, data: { confirm: _("Are you sure?"), confirm_btn_variant: "danger" } }) do
- %span.sr-only
- = _('Destroy')
+ = render Pajamas::ButtonComponent.new(type: :submit, category: :tertiary, icon: 'remove', button_options: { 'class': 'has-tooltip', 'title': _('Destroy'), aria: { label: _('Destroy') }, data: { confirm: _("Are you sure?"), confirm_btn_variant: "danger" } })
- else
- = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { class: submit_btn_css, aria: { label: _('Destroy') }, data: { confirm: _("Are you sure?"), confirm_btn_variant: "danger" } }) do
+ = render Pajamas::ButtonComponent.new(type: :submit, category: :primary, variant: :danger, button_options: { aria: { label: _('Destroy') }, data: { confirm: _("Are you sure?"), confirm_btn_variant: "danger" } }) do
= _('Destroy')
diff --git a/app/views/shared/doorkeeper/applications/_form.html.haml b/app/views/shared/doorkeeper/applications/_form.html.haml
index ae539c46cf1..f24606317ff 100644
--- a/app/views/shared/doorkeeper/applications/_form.html.haml
+++ b/app/views/shared/doorkeeper/applications/_form.html.haml
@@ -1,3 +1,5 @@
+- show_cancel_button = local_assigns.fetch(:cancel, false)
+
= gitlab_ui_form_for @application, url: url, html: { role: 'form', class: 'doorkeeper-app-form gl-max-w-80' } do |f|
= form_errors(@application)
@@ -22,3 +24,6 @@
.gl-mt-3
= f.submit _('Save application'), pajamas_button: true
+ - if show_cancel_button
+ = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'js-webhook-edit-close gl-ml-2 js-toggle-button' }) do
+ = _('Cancel')
diff --git a/app/views/shared/doorkeeper/applications/_index.html.haml b/app/views/shared/doorkeeper/applications/_index.html.haml
index bf78f275d65..f28cc64b969 100644
--- a/app/views/shared/doorkeeper/applications/_index.html.haml
+++ b/app/views/shared/doorkeeper/applications/_index.html.haml
@@ -1,4 +1,6 @@
- @force_desktop_expanded_sidebar = true
+- add_form_class = 'gl-display-none' if !form_errors(@application)
+- hide_class = 'gl-display-none' if form_errors(@application)
.settings-section.js-search-settings-section
.settings-sticky-header
@@ -14,73 +16,92 @@
- else
= _("Manage applications that you've authorized to use your account.")
- if oauth_applications_enabled
- %h5.gl-mt-0
- = _('Add new application')
- .gl-border-b.gl-pb-6
- = render 'shared/doorkeeper/applications/form', url: form_url
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card oauth-applications js-toggle-container' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ %h3.gl-new-card-title
+ = _('Your applications')
+ .gl-new-card-count
+ = sprite_icon('applications', css_class: 'gl-mr-2')
+ = @applications.size
+ .gl-new-card-actions
+ = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: "js-toggle-button js-toggle-content #{hide_class}" }) do
+ = _('Add new application')
+ - c.with_body do
+ .gl-new-card-add-form.gl-m-3.js-toggle-content{ class: add_form_class }
+ %h4.gl-mt-0
+ = _('Add new application')
+ = render 'shared/doorkeeper/applications/form', url: form_url, cancel: true
+
+ - if @applications.any?
+ .table-holder
+ %table.table.b-table.gl-table.b-table-stacked-sm.gl-mt-n1.gl-mb-n2
+ %thead.d-none.d-md-table-header-group
+ %tr
+ %th= _('Name')
+ %th= _('Callback URL')
+ %th= _('Clients')
+ %th.last-heading
+ %tbody
+ - @applications.each do |application|
+ %tr{ id: "application_#{application.id}" }
+ %td{ data: { label: _('Name') } }
+ = link_to application.name, application_url.call(application)
+ %td{ data: { label: _('Callback URL') } }
+ - application.redirect_uri.split.each do |uri|
+ = uri
+ %td{ data: { label: _('Clients') } }
+ = application.access_tokens.count
+ %td{ class: 'gl-py-3!', data: { label: _('Actions') } }
+ %div{ class: 'gl-display-flex! gl-pl-0!' }
+ = render Pajamas::ButtonComponent.new(category: :tertiary, href: edit_application_url.call(application), icon: 'pencil', button_options: { class: 'has-tooltip gl-mr-3', 'title': _('Edit'), 'aria-label': _('Edit') })
+ = render 'shared/doorkeeper/applications/delete_form', path: application_url.call(application), small: true
+ - else
+ .gl-new-card-empty.gl-px-5.gl-py-4.js-toggle-content
+ = _("You don't have any applications.")
- else
.bs-callout.bs-callout-disabled
- = _('Adding new applications is disabled in your GitLab instance. Please contact your GitLab administrator to get the permission')
- - if oauth_applications_enabled
- .oauth-applications.gl-pt-6
- %h5.gl-mt-0
- = _("Your applications (%{size})") % { size: @applications.size }
- - if @applications.any?
- .table-responsive
- %table.table
- %thead
- %tr
- %th= _('Name')
- %th= _('Callback URL')
- %th= _('Clients')
- %th.last-heading
- %tbody
- - @applications.each do |application|
- %tr{ id: "application_#{application.id}" }
- %td= link_to application.name, application_url.call(application)
- %td
- - application.redirect_uri.split.each do |uri|
- %div= uri
- %td= application.access_tokens.count
- %td.gl-display-flex
- = link_button_to nil, edit_application_url.call(application), class: 'gl-mr-3', icon: 'pencil', 'aria-label': _('Edit')
- = render 'shared/doorkeeper/applications/delete_form', path: application_url.call(application), small: true
- - else
- .settings-message
- = _("You don't have any applications")
- - if oauth_authorized_applications_enabled
- .oauth-authorized-applications.gl-mt-4
- - if oauth_applications_enabled
- %h5.gl-mt-0
- = _("Authorized applications (%{size})") % { size: @authorized_tokens.size }
+ = _('Adding new applications is disabled in your GitLab instance. Please contact your GitLab administrator to get the permission.')
- - if @authorized_tokens.any?
- .table-responsive
- %table.table.table-striped
- %thead
- %tr
- %th= _('Name')
- %th= _('Authorized At')
- %th= _('Scope')
- %th
- %tbody
- - @authorized_tokens.each do |token|
- %tr{ id: ("application_#{token.application.id}" if token.application) }
- %td
- - if token.application
- = token.application.name
- - else
- = _('Anonymous')
- .form-text.text-muted
- %em= _("Authorization was granted by entering your username and password in the application.")
- %td= token.created_at
- %td= token.scopes
- %td
- - if token.application
- = render 'doorkeeper/authorized_applications/delete_form', application: token.application
- - else
- = render 'doorkeeper/authorized_applications/delete_form', token: token
- - else
- .settings-message
- = _("You don't have any authorized applications")
+ - if oauth_authorized_applications_enabled
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card oauth-authorized-applications' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c|
+ - c.with_header do
+ .gl-new-card-title-wrapper
+ %h3.gl-new-card-title
+ = _('Authorized applications')
+ .gl-new-card-count
+ = sprite_icon('applications', css_class: 'gl-mr-2')
+ = @authorized_tokens.size
+ - c.with_body do
+ - if @authorized_tokens.any?
+ .table-holder
+ %table.table.b-table.gl-table.b-table-stacked-sm.gl-mt-n1.gl-mb-n2
+ %thead.d-none.d-md-table-header-group
+ %tr
+ %th= _('Name')
+ %th= _('Authorized At')
+ %th= _('Scope')
+ %th
+ %tbody
+ - @authorized_tokens.each do |token|
+ %tr{ id: ("application_#{token.application.id}" if token.application) }
+ %td{ data: { label: _('Name') } }
+ - if token.application
+ = token.application.name
+ - else
+ = _('Anonymous')
+ .form-text.text-muted
+ %em= _("Authorization was granted by entering your username and password in the application.")
+ %td{ data: { label: _('Authorized At') } }
+ = token.created_at
+ %td{ data: { label: _('Scope') } }
+ = token.scopes
+ %td{ class: 'gl-py-3!', data: { label: _('Actions') } }
+ - if token.application
+ = render 'doorkeeper/authorized_applications/delete_form', application: token.application
+ - else
+ = render 'doorkeeper/authorized_applications/delete_form', token: token
+ - else
+ .gl-new-card-empty.gl-px-5.gl-py-4{ class: hide_class }
+ = _("You don't have any authorized applications.")
diff --git a/app/views/shared/doorkeeper/applications/_show.html.haml b/app/views/shared/doorkeeper/applications/_show.html.haml
index b075cece877..4101a456f32 100644
--- a/app/views/shared/doorkeeper/applications/_show.html.haml
+++ b/app/views/shared/doorkeeper/applications/_show.html.haml
@@ -40,7 +40,7 @@
= render "shared/tokens/scopes_list", token: @application
-.form-actions.gl-display-flex.gl-justify-content-space-between
+.gl-display-flex.gl-justify-content-space-between
%div
- if @created
= link_button_to _('Continue'), index_path, class: 'gl-mr-3', variant: :confirm
diff --git a/app/views/shared/empty_states/_milestones.html.haml b/app/views/shared/empty_states/_milestones.html.haml
index 0d7dbd1415b..d88baab3011 100644
--- a/app/views/shared/empty_states/_milestones.html.haml
+++ b/app/views/shared/empty_states/_milestones.html.haml
@@ -3,8 +3,8 @@
.row.empty-state
.col-12
- .svg-content
- = image_tag 'illustrations/milestone_burndown_chart.svg'
+ .svg-content.svg-150
+ = image_tag 'illustrations/empty-state/empty-milestone-md.svg'
.col-12
.text-content.text-center
%h4= s_('Milestones|Use milestones to track issues and merge requests over a fixed period of time')
diff --git a/app/views/shared/empty_states/_milestones_tab.html.haml b/app/views/shared/empty_states/_milestones_tab.html.haml
index 52df30434b4..081a260971c 100644
--- a/app/views/shared/empty_states/_milestones_tab.html.haml
+++ b/app/views/shared/empty_states/_milestones_tab.html.haml
@@ -4,8 +4,8 @@
.row.empty-state
.col-12
- .svg-content
- = image_tag 'illustrations/milestone_burndown_chart.svg'
+ .svg-content.svg-150
+ = image_tag 'illustrations/empty-state/empty-milestone-md.svg'
.col-12
.text-content
- if closed_tab_selected
diff --git a/app/views/shared/empty_states/_profile_tabs.html.haml b/app/views/shared/empty_states/_profile_tabs.html.haml
index ba5fbd90528..d71ed963fd1 100644
--- a/app/views/shared/empty_states/_profile_tabs.html.haml
+++ b/app/views/shared/empty_states/_profile_tabs.html.haml
@@ -12,10 +12,10 @@
- if current_user_empty_message_description.present?
%p= current_user_empty_message_description
- - if secondary_button_link.present?
- = link_button_to secondary_button_label, secondary_button_link, variant: :confirm, category: :secondary
-
- if primary_button_link.present?
= link_button_to primary_button_label, primary_button_link, variant: :confirm
+
+ - if secondary_button_link.present?
+ = link_button_to secondary_button_label, secondary_button_link, variant: :confirm, category: :secondary
- else
%h5= visitor_empty_message
diff --git a/app/views/shared/empty_states/_wikis.html.haml b/app/views/shared/empty_states/_wikis.html.haml
index 9e628a1f409..567c4a2d444 100644
--- a/app/views/shared/empty_states/_wikis.html.haml
+++ b/app/views/shared/empty_states/_wikis.html.haml
@@ -6,21 +6,21 @@
- create_path = wiki_page_path(@wiki, params[:id], view: 'create')
- create_link = link_button_to s_('WikiEmpty|Create your first page'), create_path, title: s_('WikiEmpty|Create your first page'), data: { qa_selector: 'create_first_page_link' }, variant: :confirm
- = render layout: layout_path, locals: { image_path: 'illustrations/wiki_login_empty.svg' } do
+ = render layout: layout_path, locals: { image_path: 'illustrations/empty-state/empty-wiki-md.svg' } do
%h4.text-left
= messages.dig(:writable, :title)
%p.text-left
= messages.dig(:writable, :body)
= create_link
- if show_enable_confluence_integration?(@wiki.container)
- = link_to s_('WikiEmpty|Enable the Confluence Wiki integration'),
+ = link_button_to s_('WikiEmpty|Enable the Confluence Wiki integration'),
edit_project_settings_integration_path(@project, :confluence),
- class: 'btn gl-button', title: s_('WikiEmpty|Enable the Confluence Wiki integration')
+ title: s_('WikiEmpty|Enable the Confluence Wiki integration')
- elsif @project && can?(current_user, :read_issue, @project)
- issues_link = link_to s_('WikiEmptyIssueMessage|issue tracker'), project_issues_path(@project)
- = render layout: layout_path, locals: { image_path: 'illustrations/wiki_logout_empty.svg' } do
+ = render layout: layout_path, locals: { image_path: 'illustrations/empty-state/empty-wiki-md.svg' } do
%h4
= messages.dig(:issuable, :title)
%p.text-left
@@ -29,7 +29,7 @@
= link_button_to s_('WikiEmpty|Suggest wiki improvement'), new_project_issue_path(@project), title: s_('WikiEmptyIssueMessage|Suggest wiki improvement'), variant: :confirm
- else
- = render layout: layout_path, locals: { image_path: 'illustrations/wiki_logout_empty.svg' } do
+ = render layout: layout_path, locals: { image_path: 'illustrations/empty-state/empty-wiki-md.svg' } do
%h4
= messages.dig(:readonly, :title)
%p
diff --git a/app/views/shared/empty_states/_wikis_layout.html.haml b/app/views/shared/empty_states/_wikis_layout.html.haml
index 0b7034838ed..03054c959fd 100644
--- a/app/views/shared/empty_states/_wikis_layout.html.haml
+++ b/app/views/shared/empty_states/_wikis_layout.html.haml
@@ -1,6 +1,6 @@
.row.empty-state.empty-state-wiki
.col-12
- .svg-content{ data: { qa_selector: 'svg_content' } }
+ .svg-content.svg-150{ data: { qa_selector: 'svg_content' } }
= image_tag image_path
.col-12
.text-content.text-center
diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
index b125fe34464..8df13ec83fe 100644
--- a/app/views/shared/issuable/_bulk_update_sidebar.html.haml
+++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
@@ -44,6 +44,10 @@
.js-subscriptions-dropdown
- if is_issue
.block
+ .title
+ = _('Confidentiality')
+ .js-confidentiality-dropdown
+ .block
.js-move-issues{ data: move_data }
= hidden_field_tag "update[issuable_ids]", []
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index fadaeafeaf6..46710081307 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -42,7 +42,7 @@
.js-sidebar-labels-widget-root{ data: sidebar_labels_data(issuable_sidebar, @project) }
- if issuable_sidebar[:supports_milestone]
- .block.milestone{ :class => ("gl-border-b-0!" if in_group_context_with_iterations), data: { qa_selector: 'milestone_block', testid: 'sidebar-milestones' } }
+ .block.milestone{ data: { qa_selector: 'milestone_block', testid: 'sidebar-milestones' } }
.js-sidebar-milestone-widget-root{ data: { can_edit: can_edit_issuable.to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid] } }
- if in_group_context_with_iterations
diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml
index 01f1dbdb3cf..34815026ff2 100644
--- a/app/views/shared/issuable/form/_branch_chooser.html.haml
+++ b/app/views/shared/issuable/form/_branch_chooser.html.haml
@@ -11,9 +11,11 @@
- vis1020 = _('This merge request is from an internal project to a public project.')
- i18n = { '010' => vis010, '020' => vis020, '1020' => vis1020 }
-- source_level = @merge_request.source_project.visibility_level
-- source_visibility = @merge_request.source_project.visibility
-- target_level = @merge_request.target_project.visibility_level
+- source_project = @merge_request.source_project
+- target_project = @merge_request.target_project
+- source_level = source_project.visibility_level
+- source_visibility = source_project.visibility
+- target_level = target_project.visibility_level
- visibilityMismatchString = i18n["#{source_level}#{target_level}"]
@@ -39,10 +41,18 @@
= 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|
- c.with_body do
= visibilityMismatchString
%br
= _('Review the target project before submitting to avoid exposing %{source} changes.') % { source: source_visibility }
+- elsif target_level > Gitlab::VisibilityLevel::PRIVATE
+ - source_access_level = source_project.project_feature.repository_access_level
+ - target_access_level = target_project.project_feature.repository_access_level
+ - if source_access_level < target_access_level
+ = render Pajamas::AlertComponent.new(title: _('Should these changes be private?'), variant: :warning, dismissible: false, alert_options: { class: 'gl-mb-4' }) do |c|
+ - c.with_body do
+ - warning_message = html_escape(_("Project %{code_open}%{source_project}%{code_close} has more restricted access settings than %{code_open}%{target_project}%{code_close}. To avoid exposing private changes, make sure you're submitting changes to the correct project."))
+ = warning_message % {code_open: '<code>'.html_safe, code_close: '</code>'.html_safe, source_project: source_project.name_with_namespace, target_project: target_project.name_with_namespace}
+
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 bd9afc3ce69..d25ef3f4e83 100644
--- a/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml
+++ b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml
@@ -8,4 +8,4 @@
= hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil, data: { meta: '' }
= dropdown_tag(users_dropdown_label(issuable.assignees), options: assignees_dropdown_options(issuable.to_ability_name))
- = link_to _('Assign to me'), '#', class: "assign-to-me-link gl-white-space-nowrap gl-pl-4 #{'hide' if issuable.assignees.include?(current_user)}", data: { qa_selector: 'assign_to_me_link' }
+ = link_to _('Assign to me'), '#', class: "assign-to-me-link gl-white-space-nowrap gl-md-pl-3 #{'hide' if issuable.assignees.include?(current_user)}", data: { qa_selector: 'assign_to_me_link' }
diff --git a/app/views/shared/issue_type/_details_content.html.haml b/app/views/shared/issue_type/_details_content.html.haml
index 40a02fddbf3..249e296b41a 100644
--- a/app/views/shared/issue_type/_details_content.html.haml
+++ b/app/views/shared/issue_type/_details_content.html.haml
@@ -4,6 +4,7 @@
.issue-details.issuable-details.js-issue-details
.detail-page-description.content-block.js-detail-page-description.gl-pt-3.gl-pb-0.gl-border-none
#js-issuable-app{ data: { initial: issuable_initial_data(issuable).to_json,
+ header_actions_data: issue_header_actions_data(@project, issuable, current_user, @issuable_sidebar).to_json,
issuable_id: issuable.id,
full_path: @project.full_path,
register_path: new_user_registration_path(redirect_to_referer: 'yes'),
diff --git a/app/views/shared/issue_type/_details_header.html.haml b/app/views/shared/issue_type/_details_header.html.haml
index 4997d429587..558287480e1 100644
--- a/app/views/shared/issue_type/_details_header.html.haml
+++ b/app/views/shared/issue_type/_details_header.html.haml
@@ -16,6 +16,6 @@
#js-issuable-header-warnings{ data: { hidden: issue_hidden?(issuable).to_s } }
= issuable_meta(issuable, @project)
- = render Pajamas::ButtonComponent.new(href: '#', icon: 'chevron-double-lg-left', button_options: { class: 'gl-float-right gl-display-block gl-sm-display-none! gutter-toggle issuable-gutter-toggle js-sidebar-toggle' })
+ = render Pajamas::ButtonComponent.new(href: '#', icon: 'chevron-double-lg-left', button_options: { class: 'gl-ml-auto gl-display-block gl-sm-display-none! js-sidebar-toggle' })
.js-issue-header-actions{ data: issue_header_actions_data(@project, issuable, current_user, @issuable_sidebar) }
diff --git a/app/views/shared/nav/_your_work_scope_header.html.haml b/app/views/shared/nav/_your_work_scope_header.html.haml
index 86172fb14ed..cdd0be3c682 100644
--- a/app/views/shared/nav/_your_work_scope_header.html.haml
+++ b/app/views/shared/nav/_your_work_scope_header.html.haml
@@ -1,5 +1,5 @@
%li.context-header
- = link_to root_url, title: _('Your work'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do
+ = link_to root_path, title: _('Your work'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do
%span.avatar-container.icon-avatar.rect-avatar.s32
= sprite_icon('work', size: 18)
%span.sidebar-context-title
diff --git a/app/views/shared/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml
index 3e880a36e29..bbcd072c762 100644
--- a/app/views/shared/notes/_comment_button.html.haml
+++ b/app/views/shared/notes/_comment_button.html.haml
@@ -1,4 +1,5 @@
- noteable_name = @note.noteable.human_class_name
.js-comment-type-dropdown.float-left.gl-sm-mr-3{ data: { noteable_name: noteable_name } }
- %input.btn.gl-button.btn-confirm.js-comment-button.js-comment-submit-button{ type: 'submit', value: _('Comment'), data: { qa_selector: 'comment_button' } }
+ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { class: 'js-comment-button js-comment-submit-button', value: _('Comment'), data: { qa_selector: 'comment_button' }}) do
+ = _('Comment')
diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml
index 23ce38d50e0..ab8d4bba8ac 100644
--- a/app/views/shared/notes/_hints.html.haml
+++ b/app/views/shared/notes/_hints.html.haml
@@ -16,15 +16,15 @@
= sprite_icon('paperclip', css_class: 'gl-icon gl-vertical-align-text-bottom')
%span.uploading-error-message
-# Populated by app/assets/javascripts/dropzone_input.js
- %button.btn.gl-button.btn-link.gl-vertical-align-baseline.retry-uploading-link
+ = render Pajamas::ButtonComponent.new(variant: :link, button_options: { class: 'retry-uploading-link' }) do
%span.gl-button-text
= _("Try again")
= _("or")
- %button.btn.gl-button.btn-link.attach-new-file.markdown-selector.gl-vertical-align-baseline
+ = render Pajamas::ButtonComponent.new(variant: :link, button_options: { class: 'attach-new-file markdown-selector' }) do
%span.gl-button-text
= _("attach a new file")
= _(".")
- %button.btn.gl-button.btn-link.button-cancel-uploading-files.gl-vertical-align-baseline.hide
+ = render Pajamas::ButtonComponent.new(variant: :link, button_options: { class: 'button-cancel-uploading-files hide' }) do
%span.gl-button-text
= _("Cancel")
diff --git a/app/views/shared/packages/_no_packages.html.haml b/app/views/shared/packages/_no_packages.html.haml
index ae5c2cfd378..7cc8110fb6b 100644
--- a/app/views/shared/packages/_no_packages.html.haml
+++ b/app/views/shared/packages/_no_packages.html.haml
@@ -1,4 +1,5 @@
-.svg-content= image_tag 'illustrations/no-packages.svg'
+.svg-content.svg-150
+ = image_tag 'illustrations/empty-state/empty-package-md.svg'
.text-content
%h4.text-center= _('There are no packages yet')
%p
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index e09736cad6c..95188cefdd1 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -35,8 +35,7 @@
%span.project-name<
= project.name
- %span.gl-mr-3.has-tooltip{ data: { container: 'body', placement: 'top' }, title: visibility_icon_description(project) }
- = visibility_level_icon(project.visibility_level)
+ = visibility_level_content(project, css_class: 'gl-mr-3')
- if explore_projects_tab? && project_license_name(project)
%span.gl-display-inline-flex.gl-align-items-center.gl-mr-3
diff --git a/app/views/shared/ssh_keys/_key_delete.html.haml b/app/views/shared/ssh_keys/_key_delete.html.haml
index 80cd23989a0..c969520d7e9 100644
--- a/app/views/shared/ssh_keys/_key_delete.html.haml
+++ b/app/views/shared/ssh_keys/_key_delete.html.haml
@@ -1,5 +1,4 @@
- category = local_assigns[:category] || :primary
-.gl-p-2
- = render Pajamas::ButtonComponent.new(variant: :danger, category: category, button_options: { class: 'js-confirm-modal-button', data: button_data }) do
- = _('Delete')
+= render Pajamas::ButtonComponent.new(variant: :danger, category: category, button_options: { class: 'js-confirm-modal-button', data: button_data }) do
+ = _('Delete')
diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml
index a0e55cd5723..f040ea8e542 100644
--- a/app/views/shared/web_hooks/_form.html.haml
+++ b/app/views/shared/web_hooks/_form.html.haml
@@ -3,7 +3,7 @@
.js-vue-webhook-form{ data: webhook_form_data(hook) }
.form-group
= form.label :token, s_('Webhooks|Secret token'), class: 'label-bold'
- = form.password_field :token, value: hook.masked_token, autocomplete: 'new-password', class: 'form-control gl-form-input gl-max-w-48'
+ = form.password_field :token, value: hook.masked_token, autocomplete: 'new-password', class: 'form-control gl-form-input gl-form-input-xl'
%p.form-text.text-muted
- code_start = '<code>'.html_safe
- code_end = '</code>'.html_safe
diff --git a/app/views/shared/web_hooks/_hook.html.haml b/app/views/shared/web_hooks/_hook.html.haml
index 50ce6552616..9b84222e920 100644
--- a/app/views/shared/web_hooks/_hook.html.haml
+++ b/app/views/shared/web_hooks/_hook.html.haml
@@ -2,8 +2,8 @@
- sslBadgeText = _('SSL Verification:') + ' ' + sslStatus
%li.label-list-item
- .gl-display-flex.lgl-align-items-center.row.gl-mx-n1
- .col-md-8.col-lg-7.gl-px-3
+ .gl-display-flex.lgl-align-items-center.row.gl-mx-0
+ .col-md-8.col-lg-7.gl-px-5
.light-header.gl-mb-2
= hook.url
- if hook.rate_limited?
@@ -19,7 +19,7 @@
= gl_badge_tag(integration_webhook_event_human_name(trigger), size: :sm)
= gl_badge_tag(sslBadgeText, size: :sm)
- .col-md-4.col-lg-5.gl-mt-2.gl-px-3.gl-gap-3.gl-display-flex.gl-md-justify-content-end.gl-align-items-baseline
+ .col-md-4.col-lg-5.gl-mt-2.gl-px-5.gl-gap-3.gl-display-flex.gl-md-justify-content-end.gl-align-items-baseline
= render 'shared/web_hooks/test_button', hook: hook, size: 'small'
= render Pajamas::ButtonComponent.new(href: edit_hook_path(hook), size: :small) do
= _('Edit')
diff --git a/app/views/shared/web_hooks/_index.html.haml b/app/views/shared/web_hooks/_index.html.haml
index 0ea6a0307ba..ccd86937e4f 100644
--- a/app/views/shared/web_hooks/_index.html.haml
+++ b/app/views/shared/web_hooks/_index.html.haml
@@ -1,4 +1,4 @@
-= render Pajamas::CardComponent.new(card_options: { id: 'webhooks-index', class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header'}, body_options: { class: 'gl-new-card-body'}) do |c|
+= render Pajamas::CardComponent.new(card_options: { id: 'webhooks-index', class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header'}, body_options: { class: 'gl-new-card-body gl-px-0'}) do |c|
- c.with_header do
.gl-new-card-title-wrapper
%h3.gl-new-card-title
@@ -9,16 +9,15 @@
= render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-toggle-button js-toggle-content' }) do
= _('Add new webhook')
- c.with_body do
- .gl-new-card-content
- = gitlab_ui_form_for @hook, as: :hook, url: url, html: { class: 'js-webhook-form gl-new-card-add-form gl-mb-3 gl-display-none js-toggle-content' } do |f|
- = render partial: partial, locals: { form: f, hook: @hook }
- = f.submit _('Add webhook'), pajamas_button: true, data: { qa_selector: "create_webhook_button" }
- = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'js-webhook-edit-close gl-ml-2 js-toggle-button' }) do
- = _('Cancel')
- - if hooks.any?
- %ul.content-list{ class: 'gl-my-n3!' }
- - hooks.each do |hook|
- = render 'shared/web_hooks/hook', hook: hook
- - else
- %p.gl-new-card-empty.gl-text-center
- = _('No webhooks enabled. Select trigger events above.')
+ = gitlab_ui_form_for @hook, as: :hook, url: url, html: { class: 'js-webhook-form gl-new-card-add-form gl-m-3 gl-display-none js-toggle-content' } do |f|
+ = render partial: partial, locals: { form: f, hook: @hook }
+ = f.submit _('Add webhook'), pajamas_button: true, data: { qa_selector: "create_webhook_button" }
+ = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'js-webhook-edit-close gl-ml-2 js-toggle-button' }) do
+ = _('Cancel')
+ - if hooks.any?
+ %ul.content-list
+ - hooks.each do |hook|
+ = render 'shared/web_hooks/hook', hook: hook
+ - else
+ %p.gl-new-card-empty.gl-text-center
+ = _('No webhooks enabled. Select trigger events above.')
diff --git a/app/views/shared/wikis/show.html.haml b/app/views/shared/wikis/show.html.haml
index 28699ca27f3..be1f43f44de 100644
--- a/app/views/shared/wikis/show.html.haml
+++ b/app/views/shared/wikis/show.html.haml
@@ -12,6 +12,8 @@
.nav-controls.pb-md-3.pb-lg-0
= render 'shared/wikis/main_links'
+ - if Feature.enabled?(:print_wiki, current_user)
+ #js-export-actions{ data: { options: { target: '.js-wiki-page-content', title: @page.human_title, stylesheet: [stylesheet_path('application')] }.to_json } }
- if @page.historical?
= render Pajamas::AlertComponent.new(variant: :warning,
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index 380d6aacb84..e2ddbb90213 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -26,7 +26,7 @@
= s_("UserProfile|Edit profile")
= render 'users/view_gpg_keys'
= render 'users/view_user_in_admin_area'
- .js-user-profile-actions{ data: { user_id: @user.id } }
+ .js-user-profile-actions{ data: user_profile_actions_data(@user) }
- else
= render layout: 'users/cover_controls' do
- if @user == current_user
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 6f6fd9ddb65..1664add1ac9 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -219,6 +219,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: cronjob:batched_git_ref_updates_cleanup_scheduler
+ :worker_name: BatchedGitRefUpdates::CleanupSchedulerWorker
+ :feature_category: :gitaly
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:bulk_imports_stuck_import
:worker_name: BulkImports::StuckImportWorker
:feature_category: :importers
@@ -552,6 +561,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: cronjob:members_expiring
+ :worker_name: Members::ExpiringWorker
+ :feature_category: :system_access
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: false
+ :tags: []
- :name: cronjob:metrics_global_metrics_update
:worker_name: Metrics::GlobalMetricsUpdateWorker
:feature_category: :metrics
@@ -660,6 +678,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: cronjob:pause_control_resume
+ :worker_name: PauseControl::ResumeWorker
+ :feature_category: :global_search
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:personal_access_tokens_expired_notification
:worker_name: PersonalAccessTokens::ExpiredNotificationWorker
:feature_category: :system_access
@@ -795,6 +822,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: cronjob:service_desk_custom_email_verification_cleanup
+ :worker_name: ServiceDesk::CustomEmailVerificationCleanupWorker
+ :feature_category: :service_desk
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:ssh_keys_expired_notification
:worker_name: SshKeys::ExpiredNotificationWorker
:feature_category: :compliance_management
@@ -2298,6 +2334,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: batched_git_ref_updates_project_cleanup
+ :worker_name: BatchedGitRefUpdates::ProjectCleanupWorker
+ :feature_category: :gitaly
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: bitbucket_server_import_advance_stage
:worker_name: Gitlab::BitbucketServerImport::AdvanceStageWorker
:feature_category: :importers
@@ -2438,7 +2483,7 @@
:feature_category: :importers
:has_external_dependencies: true
:urgency: :low
- :resource_boundary: :unknown
+ :resource_boundary: :memory
:weight: 1
:idempotent: false
:tags: []
@@ -2523,6 +2568,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: click_house_events_sync
+ :worker_name: ClickHouse::EventsSyncWorker
+ :feature_category: :value_stream_management
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: counters_cleanup_refresh
:worker_name: Counters::CleanupRefreshWorker
:feature_category: :not_owned
@@ -2685,6 +2739,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: environments_stop_job_success
+ :worker_name: Environments::StopJobSuccessWorker
+ :feature_category: :continuous_delivery
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: error_tracking_issue_link
:worker_name: ErrorTrackingIssueLinkWorker
:feature_category: :error_tracking
@@ -2955,6 +3018,15 @@
:weight: 2
:idempotent:
:tags: []
+- :name: members_expiring_email_notification
+ :worker_name: Members::ExpiringEmailNotificationWorker
+ :feature_category: :system_access
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: merge
:worker_name: MergeWorker
:feature_category: :source_code_management
diff --git a/app/workers/batched_git_ref_updates/cleanup_scheduler_worker.rb b/app/workers/batched_git_ref_updates/cleanup_scheduler_worker.rb
new file mode 100644
index 00000000000..9c50e319be0
--- /dev/null
+++ b/app/workers/batched_git_ref_updates/cleanup_scheduler_worker.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module BatchedGitRefUpdates
+ class CleanupSchedulerWorker
+ include ApplicationWorker
+ # Ignore RuboCop as the context is added in the service
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+
+ idempotent!
+ data_consistency :sticky
+
+ feature_category :gitaly
+
+ def perform
+ stats = CleanupSchedulerService.new.execute
+
+ log_extra_metadata_on_done(:stats, stats)
+ end
+ end
+end
diff --git a/app/workers/batched_git_ref_updates/project_cleanup_worker.rb b/app/workers/batched_git_ref_updates/project_cleanup_worker.rb
new file mode 100644
index 00000000000..b2b1df29430
--- /dev/null
+++ b/app/workers/batched_git_ref_updates/project_cleanup_worker.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module BatchedGitRefUpdates
+ class ProjectCleanupWorker
+ include ApplicationWorker
+
+ idempotent!
+ data_consistency :delayed
+
+ feature_category :gitaly
+
+ def perform(project_id)
+ stats = ProjectCleanupService.new(project_id).execute
+
+ log_extra_metadata_on_done(:stats, stats)
+ end
+ end
+end
diff --git a/app/workers/build_success_worker.rb b/app/workers/build_success_worker.rb
index 247105d2a1a..f5baa220715 100644
--- a/app/workers/build_success_worker.rb
+++ b/app/workers/build_success_worker.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+# Deprecated and will be removed in 17.0.
+# Use `Environments::StopJobSuccessWorker` instead.
class BuildSuccessWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
diff --git a/app/workers/bulk_imports/pipeline_batch_worker.rb b/app/workers/bulk_imports/pipeline_batch_worker.rb
index 378eff99b52..634d7ed3c87 100644
--- a/app/workers/bulk_imports/pipeline_batch_worker.rb
+++ b/app/workers/bulk_imports/pipeline_batch_worker.rb
@@ -9,6 +9,7 @@ module BulkImports
feature_category :importers
sidekiq_options retry: false, dead: false
worker_has_external_dependencies!
+ worker_resource_boundary :memory
def perform(batch_id)
@batch = ::BulkImports::BatchTracker.find(batch_id)
diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb
index e0db18cb987..098e167ac29 100644
--- a/app/workers/bulk_imports/pipeline_worker.rb
+++ b/app/workers/bulk_imports/pipeline_worker.rb
@@ -42,7 +42,6 @@ module BulkImports
def run
return skip_tracker if entity.failed?
- raise(Pipeline::ExpiredError, 'Pipeline timeout') if job_timeout?
raise(Pipeline::FailedError, "Export from source instance failed: #{export_status.error}") if export_failed?
raise(Pipeline::ExpiredError, 'Empty export status on source instance') if empty_export_timeout?
@@ -181,12 +180,6 @@ module BulkImports
"gitlab:bulk_imports:pipeline_worker:#{pipeline_tracker.id}"
end
- def job_timeout?
- return false unless file_extraction_pipeline?
-
- time_since_tracker_created > Pipeline::NDJSON_EXPORT_TIMEOUT
- end
-
def enqueue_batches
1.upto(export_status.batches_count) do |batch_number|
batch = pipeline_tracker.batches.find_or_create_by!(batch_number: batch_number) # rubocop:disable CodeReuse/ActiveRecord
diff --git a/app/workers/ci/initial_pipeline_process_worker.rb b/app/workers/ci/initial_pipeline_process_worker.rb
index 52a4f075cf0..067dbb7492f 100644
--- a/app/workers/ci/initial_pipeline_process_worker.rb
+++ b/app/workers/ci/initial_pipeline_process_worker.rb
@@ -32,7 +32,7 @@ module Ci
end
def create_deployment(build)
- ::Deployments::CreateForBuildService.new.execute(build)
+ ::Deployments::CreateForJobService.new.execute(build)
end
end
end
diff --git a/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb b/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb
index 2a1f492cacb..2bebfdf9114 100644
--- a/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb
+++ b/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb
@@ -3,14 +3,16 @@
module Ci
class PipelineSuccessUnlockArtifactsWorker
include ApplicationWorker
+ include PipelineBackgroundQueue
data_consistency :always
sidekiq_options retry: 3
- include PipelineBackgroundQueue
idempotent!
+ defer_on_database_health_signal :gitlab_ci, [:ci_job_artifacts]
+
def perform(pipeline_id)
::Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline|
# TODO: Move this check inside the Ci::UnlockArtifactsService
diff --git a/app/workers/click_house/events_sync_worker.rb b/app/workers/click_house/events_sync_worker.rb
new file mode 100644
index 00000000000..054e7763297
--- /dev/null
+++ b/app/workers/click_house/events_sync_worker.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module ClickHouse
+ class EventsSyncWorker
+ include ApplicationWorker
+ include Gitlab::ExclusiveLeaseHelpers
+
+ idempotent!
+ data_consistency :delayed
+ worker_has_external_dependencies! # the worker interacts with a ClickHouse database
+ feature_category :value_stream_management
+
+ # the job is scheduled every 3 minutes and we will allow maximum 2.5 minutes runtime
+ MAX_TTL = 2.5.minutes.to_i
+
+ def perform
+ unless enabled?
+ log_extra_metadata_on_done(:result, { status: :disabled })
+
+ return
+ end
+
+ metadata = { status: :processed }
+
+ # Prevent parallel jobs
+ begin
+ in_lock(self.class.to_s, ttl: MAX_TTL, retries: 0) do
+ true
+ end
+
+ rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError
+ # Skip retrying, just let the next worker to start after a few minutes
+ metadata = { status: :skipped }
+ end
+
+ log_extra_metadata_on_done(:result, metadata)
+ end
+
+ private
+
+ def enabled?
+ ClickHouse::Client.configuration.databases[:main].present? && Feature.enabled?(:event_sync_worker_for_click_house)
+ end
+ end
+end
diff --git a/app/workers/clusters/agents/notify_git_push_worker.rb b/app/workers/clusters/agents/notify_git_push_worker.rb
index d2994bb9144..db1de0b3518 100644
--- a/app/workers/clusters/agents/notify_git_push_worker.rb
+++ b/app/workers/clusters/agents/notify_git_push_worker.rb
@@ -14,7 +14,6 @@ module Clusters
def perform(project_id)
return unless project = ::Project.find_by_id(project_id)
- return unless Feature.enabled?(:notify_kas_on_git_push, project)
Gitlab::Kas::Client.new.send_git_push_event(project: project)
end
diff --git a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb
index 772388ffc9e..b40914770b5 100644
--- a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb
+++ b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb
@@ -5,10 +5,15 @@ module Gitlab
# Module that provides methods shared by the various workers used for
# importing GitHub projects.
module ReschedulingMethods
+ extend ActiveSupport::Concern
include JobDelayCalculator
ENQUEUED_JOB_COUNT = 'github-importer/enqueued_job_count/%{project}/%{collection}'
+ included do
+ loggable_arguments 2
+ end
+
# project_id - The ID of the GitLab project to import the note into.
# hash - A Hash containing the details of the GitHub object to import.
# notify_key - The Redis key to notify upon completion, if any.
diff --git a/app/workers/concerns/packages/error_handling.rb b/app/workers/concerns/packages/error_handling.rb
new file mode 100644
index 00000000000..26948d39912
--- /dev/null
+++ b/app/workers/concerns/packages/error_handling.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module Packages
+ module ErrorHandling
+ extend ActiveSupport::Concern
+
+ DEFAULT_STATUS_MESSAGE = 'Unexpected error'
+
+ CONTROLLED_ERRORS = [
+ ArgumentError,
+ ActiveRecord::RecordInvalid,
+ ::Packages::Helm::ExtractFileMetadataService::ExtractionError,
+ ::Packages::Nuget::ExtractMetadataFileService::ExtractionError,
+ ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError,
+ ::Packages::Nuget::UpdatePackageFromMetadataService::ZipError,
+ ::Packages::Rubygems::ProcessGemService::ExtractionError,
+ ::Packages::Rubygems::ProcessGemService::InvalidMetadataError
+ ].freeze
+
+ def process_package_file_error(package_file:, exception:, extra_log_payload: {})
+ log_payload = {
+ project_id: package_file.project_id,
+ package_file_id: package_file.id
+ }.merge(extra_log_payload)
+ Gitlab::ErrorTracking.log_exception(exception, **log_payload)
+
+ package_file.package.update_columns(
+ status: :error,
+ status_message: truncated_status_message(exception)
+ )
+ end
+
+ private
+
+ def controlled_error?(exception)
+ CONTROLLED_ERRORS.include?(exception.class)
+ end
+
+ def truncated_status_message(exception)
+ status_message = exception.message if controlled_error?(exception)
+
+ # Do not save the exception message in case it contains confidential data
+ status_message ||= "#{DEFAULT_STATUS_MESSAGE}: #{exception.class}"
+
+ status_message.truncate(::Packages::Package::STATUS_MESSAGE_MAX_LENGTH)
+ end
+ end
+end
diff --git a/app/workers/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb
index c260e06607c..02eda924b71 100644
--- a/app/workers/concerns/worker_attributes.rb
+++ b/app/workers/concerns/worker_attributes.rb
@@ -151,6 +151,10 @@ module WorkerAttributes
set_class_attribute(:weight, value)
end
+ def pause_control(value)
+ ::Gitlab::SidekiqMiddleware::PauseControl::WorkersMap.set_strategy_for(strategy: value, worker: self)
+ end
+
def get_weight
get_class_attribute(:weight) ||
NAMESPACE_WEIGHTS[queue_namespace] ||
@@ -193,10 +197,10 @@ module WorkerAttributes
!!get_class_attribute(:big_payload)
end
- def defer_on_database_health_signal(gitlab_schema, delay_by = DEFAULT_DEFER_DELAY, tables = [])
+ def defer_on_database_health_signal(gitlab_schema, tables = [], delay_by = DEFAULT_DEFER_DELAY)
set_class_attribute(
:database_health_check_attrs,
- { gitlab_schema: gitlab_schema, delay_by: delay_by, tables: tables }
+ { gitlab_schema: gitlab_schema, tables: tables, delay_by: delay_by }
)
end
diff --git a/app/workers/environments/stop_job_success_worker.rb b/app/workers/environments/stop_job_success_worker.rb
new file mode 100644
index 00000000000..cc7d83512f3
--- /dev/null
+++ b/app/workers/environments/stop_job_success_worker.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Environments
+ class StopJobSuccessWorker
+ include ApplicationWorker
+
+ data_consistency :delayed
+ idempotent!
+ feature_category :continuous_delivery
+
+ def perform(job_id, _params = {})
+ Ci::Build.find_by_id(job_id).try do |build|
+ stop_environment(build) if build.stops_environment? && build.stop_action_successful?
+ end
+ end
+
+ private
+
+ def stop_environment(build)
+ build.persisted_environment.fire_state_event(:stop_complete)
+ end
+ end
+end
diff --git a/app/workers/integrations/group_mention_worker.rb b/app/workers/integrations/group_mention_worker.rb
index 6cde1657ccd..cbf70dc5c6a 100644
--- a/app/workers/integrations/group_mention_worker.rb
+++ b/app/workers/integrations/group_mention_worker.rb
@@ -22,19 +22,19 @@ module Integrations
mentionable = case mentionable_type
when 'Issue'
- Issue.find(mentionable_id)
+ Issue.find_by_id(mentionable_id)
when 'MergeRequest'
- MergeRequest.find(mentionable_id)
+ MergeRequest.find_by_id(mentionable_id)
+ else
+ Sidekiq.logger.error(
+ message: 'Integrations::GroupMentionWorker: mentionable not supported',
+ mentionable_type: mentionable_type,
+ mentionable_id: mentionable_id
+ )
+ nil
end
- if mentionable.nil?
- Sidekiq.logger.error(
- message: 'Integrations::GroupMentionWorker: mentionable not supported',
- mentionable_type: mentionable_type,
- mentionable_id: mentionable_id
- )
- return
- end
+ return if mentionable.nil?
Integrations::GroupMentionService.new(mentionable, hook_data: hook_data, is_confidential: is_confidential).execute
end
diff --git a/app/workers/members/expiring_email_notification_worker.rb b/app/workers/members/expiring_email_notification_worker.rb
new file mode 100644
index 00000000000..1d0a6eb254a
--- /dev/null
+++ b/app/workers/members/expiring_email_notification_worker.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Members
+ class ExpiringEmailNotificationWorker # rubocop:disable Scalability/CronWorkerContext
+ include ApplicationWorker
+
+ data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency
+ feature_category :system_access
+ urgency :low
+ idempotent!
+
+ def perform(member_id)
+ notification_service = NotificationService.new
+ member = ::Member.find_by_id(member_id)
+
+ return unless member
+ return unless Feature.enabled?(:member_expiring_email_notification, member.source.root_ancestor)
+ return if member.expiry_notified_at.present?
+
+ with_context(user: member.user) do
+ notification_service.member_about_to_expire(member)
+ Gitlab::AppLogger.info(message: "Notifying user about expiring membership", member_id: member.id)
+
+ member.update(expiry_notified_at: Time.current)
+ end
+ end
+ end
+end
diff --git a/app/workers/members/expiring_worker.rb b/app/workers/members/expiring_worker.rb
new file mode 100644
index 00000000000..0d631af3a7c
--- /dev/null
+++ b/app/workers/members/expiring_worker.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Members
+ class ExpiringWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ # rubocop:disable Scalability/CronWorkerContext
+ # This worker does not perform work scoped to a context
+ include CronjobQueue
+ # rubocop:enable Scalability/CronWorkerContext
+
+ data_consistency :sticky
+ feature_category :system_access
+ urgency :low
+
+ BATCH_LIMIT = 500
+
+ def perform
+ return unless Feature.enabled?(:member_expiring_email_notification)
+
+ limit_date = Member::DAYS_TO_EXPIRE.days.from_now.to_date
+
+ expiring_members = Member.active.where(users: { user_type: :human }).expiring_and_not_notified(limit_date) # rubocop: disable CodeReuse/ActiveRecord
+
+ expiring_members.each_batch(of: BATCH_LIMIT) do |members|
+ members.pluck_primary_key.each do |member_id|
+ Members::ExpiringEmailNotificationWorker.perform_async(member_id)
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/merge_requests/mergeability_check_batch_worker.rb b/app/workers/merge_requests/mergeability_check_batch_worker.rb
index f48e9c234ab..e95c3952c8c 100644
--- a/app/workers/merge_requests/mergeability_check_batch_worker.rb
+++ b/app/workers/merge_requests/mergeability_check_batch_worker.rb
@@ -40,8 +40,7 @@ module MergeRequests
private
def merge_status_recheck_not_allowed?(merge_request, user)
- ::Feature.enabled?(:restrict_merge_status_recheck, merge_request.project) &&
- !Ability.allowed?(user, :update_merge_request, merge_request.project)
+ !Ability.allowed?(user, :update_merge_request, merge_request.project)
end
end
end
diff --git a/app/workers/packages/debian/process_package_file_worker.rb b/app/workers/packages/debian/process_package_file_worker.rb
index 0e21e98d182..843560d4334 100644
--- a/app/workers/packages/debian/process_package_file_worker.rb
+++ b/app/workers/packages/debian/process_package_file_worker.rb
@@ -5,6 +5,7 @@ module Packages
class ProcessPackageFileWorker
include ApplicationWorker
include Gitlab::Utils::StrongMemoize
+ include ::Packages::ErrorHandling
data_consistency :always
@@ -24,11 +25,16 @@ module Packages
return unless package_file.debian_file_metadatum&.unknown?
::Packages::Debian::ProcessPackageFileService.new(package_file, distribution_name, component_name).execute
- rescue StandardError => e
- Gitlab::ErrorTracking.log_exception(e, package_file_id: @package_file_id,
- distribution_name: @distribution_name, component_name: @component_name)
+ rescue StandardError => exception
package_file.update_column(:status, :error)
- package_file.package.update_column(:status, :error)
+ process_package_file_error(
+ package_file: package_file,
+ exception: exception,
+ extra_log_payload: {
+ distribution_name: @distribution_name,
+ component_name: @component_name
+ }
+ )
end
private
diff --git a/app/workers/packages/helm/extraction_worker.rb b/app/workers/packages/helm/extraction_worker.rb
index 0ba2d149f77..ca043c5c8c7 100644
--- a/app/workers/packages/helm/extraction_worker.rb
+++ b/app/workers/packages/helm/extraction_worker.rb
@@ -4,6 +4,7 @@ module Packages
module Helm
class ExtractionWorker
include ApplicationWorker
+ include ::Packages::ErrorHandling
data_consistency :always
@@ -19,10 +20,11 @@ module Packages
return unless package_file && !package_file.package.default?
::Packages::Helm::ProcessFileService.new(channel, package_file).execute
-
- rescue StandardError => e
- Gitlab::ErrorTracking.log_exception(e, project_id: package_file.project_id)
- package_file.package.update_column(:status, :error)
+ rescue StandardError => exception
+ process_package_file_error(
+ package_file: package_file,
+ exception: exception
+ )
end
end
end
diff --git a/app/workers/packages/nuget/extraction_worker.rb b/app/workers/packages/nuget/extraction_worker.rb
index b8e00621aa1..55aca0beb03 100644
--- a/app/workers/packages/nuget/extraction_worker.rb
+++ b/app/workers/packages/nuget/extraction_worker.rb
@@ -4,6 +4,7 @@ module Packages
module Nuget
class ExtractionWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
+ include ::Packages::ErrorHandling
data_consistency :always
@@ -18,10 +19,11 @@ module Packages
return unless package_file
::Packages::Nuget::UpdatePackageFromMetadataService.new(package_file).execute
-
- rescue StandardError => e
- Gitlab::ErrorTracking.log_exception(e, project_id: package_file.project_id)
- package_file.package.update_column(:status, :error)
+ rescue StandardError => exception
+ process_package_file_error(
+ package_file: package_file,
+ exception: exception
+ )
end
end
end
diff --git a/app/workers/packages/rubygems/extraction_worker.rb b/app/workers/packages/rubygems/extraction_worker.rb
index dbaf9bc35a9..7076fdb3b90 100644
--- a/app/workers/packages/rubygems/extraction_worker.rb
+++ b/app/workers/packages/rubygems/extraction_worker.rb
@@ -4,6 +4,7 @@ module Packages
module Rubygems
class ExtractionWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
+ include ::Packages::ErrorHandling
data_consistency :always
@@ -19,10 +20,11 @@ module Packages
return unless package_file
::Packages::Rubygems::ProcessGemService.new(package_file).execute
-
- rescue StandardError => e
- Gitlab::ErrorTracking.log_exception(e, project_id: package_file.project_id)
- package_file.package.update_column(:status, :error)
+ rescue StandardError => exception
+ process_package_file_error(
+ package_file: package_file,
+ exception: exception
+ )
end
end
end
diff --git a/app/workers/pause_control/resume_worker.rb b/app/workers/pause_control/resume_worker.rb
new file mode 100644
index 00000000000..98725c0b6f2
--- /dev/null
+++ b/app/workers/pause_control/resume_worker.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module PauseControl
+ class ResumeWorker
+ include ApplicationWorker
+ # There is no onward scheduling and this cron handles work from across the
+ # application, so there's no useful context to add.
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+
+ RESCHEDULE_DELAY = 1.second
+
+ feature_category :global_search
+ data_consistency :sticky
+ idempotent!
+ urgency :low
+
+ def perform
+ reschedule_job = false
+
+ pause_strategies_workers.each do |strategy, workers|
+ strategy_klass = Gitlab::SidekiqMiddleware::PauseControl.for(strategy)
+
+ next if strategy_klass.should_pause?
+
+ workers.each do |worker|
+ next unless jobs_in_the_queue?(worker)
+
+ queue_size = resume_processing!(worker)
+ reschedule_job = true if queue_size.to_i > 0
+ end
+ end
+
+ self.class.perform_in(RESCHEDULE_DELAY) if reschedule_job
+ end
+
+ private
+
+ def jobs_in_the_queue?(worker)
+ Gitlab::SidekiqMiddleware::PauseControl::PauseControlService.has_jobs_in_waiting_queue?(worker.to_s)
+ end
+
+ def resume_processing!(worker)
+ Gitlab::SidekiqMiddleware::PauseControl::PauseControlService.resume_processing!(worker.to_s)
+ end
+
+ def pause_strategies_workers
+ Gitlab::SidekiqMiddleware::PauseControl::WorkersMap.workers || []
+ end
+ end
+end
diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb
index 708dd3433cb..cc72704d8c9 100644
--- a/app/workers/process_commit_worker.rb
+++ b/app/workers/process_commit_worker.rb
@@ -19,6 +19,7 @@ class ProcessCommitWorker
weight 3
idempotent!
loggable_arguments 2, 3
+ deduplicate :until_executed, feature_flag: :deduplicate_process_commit_worker
# project_id - The ID of the project this commit belongs to.
# user_id - The ID of the user that pushed the commit.
diff --git a/app/workers/service_desk/custom_email_verification_cleanup_worker.rb b/app/workers/service_desk/custom_email_verification_cleanup_worker.rb
new file mode 100644
index 00000000000..6434b9b09bb
--- /dev/null
+++ b/app/workers/service_desk/custom_email_verification_cleanup_worker.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module ServiceDesk
+ # Marks custom email verifications as failed when
+ # verification has started and timeframe to ingest
+ # the verification email has closed.
+ #
+ # This ensures we can finish the verification process and send verification result emails
+ # even when we did not receive any verification email.
+ class CustomEmailVerificationCleanupWorker
+ include ApplicationWorker
+ include CronjobQueue
+
+ idempotent!
+
+ data_consistency :sticky
+ feature_category :service_desk
+
+ def perform
+ # Limit ensures we have 50ms per verification before another job gets scheduled.
+ collection = CustomEmailVerification.started.overdue.limit(2_400)
+
+ collection.find_each do |verification|
+ with_context(project: verification.project) do
+ CustomEmailVerifications::UpdateService.new(
+ project: verification.project,
+ current_user: nil,
+ params: {
+ mail: nil
+ }
+ ).execute
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/users/deactivate_dormant_users_worker.rb b/app/workers/users/deactivate_dormant_users_worker.rb
index d024109e754..87566bff467 100644
--- a/app/workers/users/deactivate_dormant_users_worker.rb
+++ b/app/workers/users/deactivate_dormant_users_worker.rb
@@ -15,16 +15,21 @@ module Users
return unless ::Gitlab::CurrentSettings.current_application_settings.deactivate_dormant_users
- deactivate_users(User.dormant)
- deactivate_users(User.with_no_activity)
+ admin_bot = User.admin_bot
+ return unless admin_bot
+
+ deactivate_users(User.dormant, admin_bot)
+ deactivate_users(User.with_no_activity, admin_bot)
end
private
- def deactivate_users(scope)
+ def deactivate_users(scope, admin_bot)
with_context(caller_id: self.class.name.to_s) do
scope.each_batch do |batch|
- batch.each(&:deactivate)
+ batch.each do |user|
+ Users::DeactivateService.new(admin_bot).execute(user)
+ end
end
end
end
diff --git a/app/workers/web_hook_worker.rb b/app/workers/web_hook_worker.rb
index 043a16e3527..cea0816f5a6 100644
--- a/app/workers/web_hook_worker.rb
+++ b/app/workers/web_hook_worker.rb
@@ -24,7 +24,10 @@ class WebHookWorker
# present in the request header so the hook can pass this same header value in its request.
Gitlab::WebHooks::RecursionDetection.set_request_uuid(params[:recursion_detection_request_uuid])
- WebHookService.new(hook, data, hook_name, jid).execute
+ WebHookService.new(hook, data, hook_name, jid).execute.tap do |response|
+ log_extra_metadata_on_done(:response_status, response.status)
+ log_extra_metadata_on_done(:http_status, response[:http_status])
+ end
end
end
# rubocop:enable Scalability/IdempotentWorker