summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-12-20 13:37:47 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-12-20 13:37:47 +0000
commitaee0a117a889461ce8ced6fcf73207fe017f1d99 (patch)
tree891d9ef189227a8445d83f35c1b0fc99573f4380 /app
parent8d46af3258650d305f53b819eabf7ab18d22f59e (diff)
downloadgitlab-ce-aee0a117a889461ce8ced6fcf73207fe017f1d99.tar.gz
Add latest changes from gitlab-org/gitlab@14-6-stable-eev14.6.0-rc42
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/logos/jira-gray.svg1
-rw-r--r--app/assets/images/logos/shimo.svg1
-rw-r--r--app/assets/javascripts/access_tokens/components/token.vue55
-rw-r--r--app/assets/javascripts/access_tokens/components/tokens_app.vue111
-rw-r--r--app/assets/javascripts/access_tokens/constants.js4
-rw-r--r--app/assets/javascripts/access_tokens/index.js30
-rw-r--r--app/assets/javascripts/admin/deploy_keys/components/table.vue213
-rw-r--r--app/assets/javascripts/admin/users/components/actions/activate.vue21
-rw-r--r--app/assets/javascripts/admin/users/components/actions/approve.vue20
-rw-r--r--app/assets/javascripts/admin/users/components/actions/ban.vue19
-rw-r--r--app/assets/javascripts/admin/users/components/actions/block.vue19
-rw-r--r--app/assets/javascripts/admin/users/components/actions/deactivate.vue19
-rw-r--r--app/assets/javascripts/admin/users/components/actions/reject.vue19
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unban.vue19
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unblock.vue20
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unlock.vue19
-rw-r--r--app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue13
-rw-r--r--app/assets/javascripts/admin/users/components/user_actions.vue2
-rw-r--r--app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql1
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql2
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql2
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql2
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql2
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/queries/get_http_integration.query.graphql1
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql1
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/queries/parse_sample_payload.query.graphql1
-rw-r--r--app/assets/javascripts/analytics/devops_reports/components/devops_score.vue8
-rw-r--r--app/assets/javascripts/analytics/shared/graphql/projects.query.graphql1
-rw-r--r--app/assets/javascripts/api.js7
-rw-r--r--app/assets/javascripts/api/packages_api.js32
-rw-r--r--app/assets/javascripts/artifacts_settings/graphql/queries/get_keep_latest_artifact_project_setting.query.graphql1
-rw-r--r--app/assets/javascripts/batch_comments/components/draft_note.vue2
-rw-r--r--app/assets/javascripts/behaviors/copy_code.js66
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji.js2
-rw-r--r--app/assets/javascripts/behaviors/index.js1
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js9
-rw-r--r--app/assets/javascripts/blob/components/blob_content.vue2
-rw-r--r--app/assets/javascripts/blob/pdf/pdf_viewer.vue8
-rw-r--r--app/assets/javascripts/blob/pipeline_tour_success_modal.vue2
-rw-r--r--app/assets/javascripts/blob_edit/edit_blob.js43
-rw-r--r--app/assets/javascripts/boards/boards_util.js12
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_filtered_search.vue103
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue16
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue39
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue3
-rw-r--r--app/assets/javascripts/boards/components/issue_board_filtered_search.vue85
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue173
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue75
-rw-r--r--app/assets/javascripts/boards/constants.js14
-rw-r--r--app/assets/javascripts/boards/graphql/board_labels.query.graphql2
-rw-r--r--app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql2
-rw-r--r--app/assets/javascripts/boards/graphql/board_list_shared.fragment.graphql1
-rw-r--r--app/assets/javascripts/boards/graphql/board_list_update.mutation.graphql2
-rw-r--r--app/assets/javascripts/boards/graphql/board_lists.query.graphql8
-rw-r--r--app/assets/javascripts/boards/graphql/board_lists_deferred.query.graphql6
-rw-r--r--app/assets/javascripts/boards/graphql/group_board.query.graphql1
-rw-r--r--app/assets/javascripts/boards/graphql/group_board_members.query.graphql1
-rw-r--r--app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql1
-rw-r--r--app/assets/javascripts/boards/graphql/group_boards.query.graphql1
-rw-r--r--app/assets/javascripts/boards/graphql/group_projects.query.graphql1
-rw-r--r--app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql11
-rw-r--r--app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql1
-rw-r--r--app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql1
-rw-r--r--app/assets/javascripts/boards/graphql/lists_issues.query.graphql6
-rw-r--r--app/assets/javascripts/boards/graphql/project_board.query.graphql1
-rw-r--r--app/assets/javascripts/boards/graphql/project_board_members.query.graphql1
-rw-r--r--app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql1
-rw-r--r--app/assets/javascripts/boards/graphql/project_boards.query.graphql1
-rw-r--r--app/assets/javascripts/boards/graphql/project_milestones.query.graphql1
-rw-r--r--app/assets/javascripts/boards/index.js3
-rw-r--r--app/assets/javascripts/boards/mount_filtered_search_issue_boards.js3
-rw-r--r--app/assets/javascripts/boards/stores/actions.js133
-rw-r--r--app/assets/javascripts/ci_lint/components/ci_lint.vue2
-rw-r--r--app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue2
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue42
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue48
-rw-r--r--app/assets/javascripts/ci_variable_list/constants.js3
-rw-r--r--app/assets/javascripts/clusters/agents/components/activity_events_list.vue176
-rw-r--r--app/assets/javascripts/clusters/agents/components/activity_history_item.vue79
-rw-r--r--app/assets/javascripts/clusters/agents/components/show.vue16
-rw-r--r--app/assets/javascripts/clusters/agents/components/token_table.vue13
-rw-r--r--app/assets/javascripts/clusters/agents/constants.js37
-rw-r--r--app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql2
-rw-r--r--app/assets/javascripts/clusters/agents/graphql/queries/get_agent_activity_events.query.graphql25
-rw-r--r--app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql2
-rw-r--r--app/assets/javascripts/clusters/agents/index.js3
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_empty_state.vue67
-rw-r--r--app/assets/javascripts/clusters_list/components/agents.vue9
-rw-r--r--app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue30
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_empty_state.vue89
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_main_view.vue19
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_view_all.vue33
-rw-r--r--app/assets/javascripts/clusters_list/components/install_agent_modal.vue325
-rw-r--r--app/assets/javascripts/clusters_list/constants.js147
-rw-r--r--app/assets/javascripts/clusters_list/graphql/cache_update.js84
-rw-r--r--app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql1
-rw-r--r--app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql2
-rw-r--r--app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql2
-rw-r--r--app/assets/javascripts/code_navigation/components/doc_line.vue1
-rw-r--r--app/assets/javascripts/content_editor/extensions/attachment.js8
-rw-r--r--app/assets/javascripts/content_editor/extensions/audio.js8
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/division.js14
-rw-r--r--app/assets/javascripts/content_editor/extensions/footnote_definition.js21
-rw-r--r--app/assets/javascripts/content_editor/extensions/footnote_reference.js37
-rw-r--r--app/assets/javascripts/content_editor/extensions/footnotes_section.js19
-rw-r--r--app/assets/javascripts/content_editor/extensions/html_marks.js9
-rw-r--r--app/assets/javascripts/content_editor/extensions/image.js8
-rw-r--r--app/assets/javascripts/content_editor/extensions/inline_diff.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/link.js9
-rw-r--r--app/assets/javascripts/content_editor/extensions/task_item.js8
-rw-r--r--app/assets/javascripts/content_editor/extensions/video.js10
-rw-r--r--app/assets/javascripts/content_editor/extensions/word_break.js10
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js6
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js23
-rw-r--r--app/assets/javascripts/crm/components/contact_form.vue224
-rw-r--r--app/assets/javascripts/crm/components/contacts_root.vue129
-rw-r--r--app/assets/javascripts/crm/components/new_organization_form.vue164
-rw-r--r--app/assets/javascripts/crm/components/organizations_root.vue91
-rw-r--r--app/assets/javascripts/crm/components/queries/create_contact.mutation.graphql10
-rw-r--r--app/assets/javascripts/crm/components/queries/create_organization.mutation.graphql10
-rw-r--r--app/assets/javascripts/crm/components/queries/crm_contact_fields.fragment.graphql14
-rw-r--r--app/assets/javascripts/crm/components/queries/crm_organization_fields.fragment.graphql7
-rw-r--r--app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql15
-rw-r--r--app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql8
-rw-r--r--app/assets/javascripts/crm/components/queries/update_contact.mutation.graphql10
-rw-r--r--app/assets/javascripts/crm/constants.js3
-rw-r--r--app/assets/javascripts/crm/contacts_bundle.js16
-rw-r--r--app/assets/javascripts/crm/organizations_bundle.js16
-rw-r--r--app/assets/javascripts/crm/routes.js16
-rw-r--r--app/assets/javascripts/delete_label_modal.js16
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_discussion.vue12
-rw-r--r--app/assets/javascripts/design_management/components/design_overlay.vue8
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/discussion_resolved_status.fragment.graphql2
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql2
-rw-r--r--app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql2
-rw-r--r--app/assets/javascripts/diffs/components/app.vue4
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_row.vue10
-rw-r--r--app/assets/javascripts/diffs/components/diff_view.vue3
-rw-r--r--app/assets/javascripts/diffs/store/modules/diff_state.js1
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js2
-rw-r--r--app/assets/javascripts/diffs/utils/discussions.js76
-rw-r--r--app/assets/javascripts/dropzone_input.js8
-rw-r--r--app/assets/javascripts/editor/constants.js4
-rw-r--r--app/assets/javascripts/editor/extensions/example_source_editor_extension.js14
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js47
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_extension_base.js111
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js16
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js330
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js167
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_webide_ext.js272
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js279
-rw-r--r--app/assets/javascripts/editor/source_editor.js179
-rw-r--r--app/assets/javascripts/editor/source_editor_extension.js2
-rw-r--r--app/assets/javascripts/editor/source_editor_instance.js76
-rw-r--r--app/assets/javascripts/emoji/constants.js3
-rw-r--r--app/assets/javascripts/emoji/index.js29
-rw-r--r--app/assets/javascripts/environments/components/confirm_rollback_modal.vue41
-rw-r--r--app/assets/javascripts/environments/components/delete_environment_modal.vue31
-rw-r--r--app/assets/javascripts/environments/components/enable_review_app_modal.vue12
-rw-r--r--app/assets/javascripts/environments/components/environment_delete.vue23
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue2
-rw-r--r--app/assets/javascripts/environments/components/environment_rollback.vue34
-rw-r--r--app/assets/javascripts/environments/components/new_environment_folder.vue27
-rw-r--r--app/assets/javascripts/environments/components/new_environments_app.vue180
-rw-r--r--app/assets/javascripts/environments/graphql/client.js16
-rw-r--r--app/assets/javascripts/environments/graphql/mutations/set_environment_to_delete.mutation.graphql3
-rw-r--r--app/assets/javascripts/environments/graphql/mutations/set_environment_to_rollback.mutation.graphql3
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environmentApp.query.graphql8
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql9
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environment_to_delete.query.graphql7
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environment_to_rollback.query.graphql7
-rw-r--r--app/assets/javascripts/environments/graphql/queries/page_info.query.graphql8
-rw-r--r--app/assets/javascripts/environments/graphql/queries/poll_interval.query.graphql3
-rw-r--r--app/assets/javascripts/environments/graphql/resolvers.js123
-rw-r--r--app/assets/javascripts/environments/graphql/typedefs.graphql46
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details.vue2
-rw-r--r--app/assets/javascripts/error_tracking/queries/details.query.graphql1
-rw-r--r--app/assets/javascripts/experimentation/utils.js27
-rw-r--r--app/assets/javascripts/feature_flags/components/form.vue7
-rw-r--r--app/assets/javascripts/filtered_search/available_dropdown_mappings.js2
-rw-r--r--app/assets/javascripts/filtered_search/constants.js7
-rw-r--r--app/assets/javascripts/flash.js8
-rw-r--r--app/assets/javascripts/google_cloud/components/app.vue55
-rw-r--r--app/assets/javascripts/google_cloud/components/errors/gcp_error.vue29
-rw-r--r--app/assets/javascripts/google_cloud/components/errors/no_gcp_projects.vue26
-rw-r--r--app/assets/javascripts/google_cloud/components/home.vue41
-rw-r--r--app/assets/javascripts/google_cloud/components/service_accounts_form.vue70
-rw-r--r--app/assets/javascripts/google_cloud/components/service_accounts_list.vue (renamed from app/assets/javascripts/google_cloud/components/service_accounts.vue)0
-rw-r--r--app/assets/javascripts/google_cloud/index.js13
-rw-r--r--app/assets/javascripts/graphql_shared/constants.js6
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql1
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql1
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql2
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql1
-rw-r--r--app/assets/javascripts/graphql_shared/mutations/create_merge_request.mutation.graphql1
-rw-r--r--app/assets/javascripts/graphql_shared/queries/alert_details.query.graphql1
-rw-r--r--app/assets/javascripts/graphql_shared/queries/get_alerts.query.graphql1
-rw-r--r--app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql1
-rw-r--r--app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql1
-rw-r--r--app/assets/javascripts/graphql_shared/queries/users_search.query.graphql2
-rw-r--r--app/assets/javascripts/header_search/components/app.vue138
-rw-r--r--app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue31
-rw-r--r--app/assets/javascripts/header_search/components/header_search_default_items.vue24
-rw-r--r--app/assets/javascripts/header_search/components/header_search_scoped_items.vue38
-rw-r--r--app/assets/javascripts/header_search/constants.js26
-rw-r--r--app/assets/javascripts/header_search/store/actions.js4
-rw-r--r--app/assets/javascripts/header_search/store/getters.js107
-rw-r--r--app/assets/javascripts/header_search/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/header_search/store/mutations.js7
-rw-r--r--app/assets/javascripts/ide/components/activity_bar.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide_tree_list.vue14
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/index.vue2
-rw-r--r--app/assets/javascripts/ide/components/pipelines/empty_state.vue35
-rw-r--r--app/assets/javascripts/ide/components/pipelines/list.vue28
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue39
-rw-r--r--app/assets/javascripts/ide/constants.js6
-rw-r--r--app/assets/javascripts/ide/ide_router.js69
-rw-r--r--app/assets/javascripts/ide/index.js11
-rw-r--r--app/assets/javascripts/ide/lib/themes/monokai.js2
-rw-r--r--app/assets/javascripts/ide/lib/themes/none.js1
-rw-r--r--app/assets/javascripts/ide/lib/themes/solarized_dark.js2
-rw-r--r--app/assets/javascripts/ide/lib/themes/solarized_light.js2
-rw-r--r--app/assets/javascripts/ide/lib/themes/white.js1
-rw-r--r--app/assets/javascripts/ide/queries/ide_project.fragment.graphql1
-rw-r--r--app/assets/javascripts/ide/services/index.js34
-rw-r--r--app/assets/javascripts/ide/stores/actions/project.js57
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/ide/stores/mutations/project.js12
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue2
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table.vue135
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue126
-rw-r--r--app/assets/javascripts/import_entities/import_groups/constants.js4
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js31
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_progress.fragment.graphql1
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql1
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql9
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js9
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql16
-rw-r--r--app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql1
-rw-r--r--app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql1
-rw-r--r--app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql4
-rw-r--r--app/assets/javascripts/init_confirm_danger.js2
-rw-r--r--app/assets/javascripts/init_issuable_sidebar.js16
-rw-r--r--app/assets/javascripts/init_labels.js19
-rw-r--r--app/assets/javascripts/integrations/constants.js4
-rw-r--r--app/assets/javascripts/integrations/edit/api.js9
-rw-r--r--app/assets/javascripts/integrations/edit/components/active_checkbox.vue11
-rw-r--r--app/assets/javascripts/integrations/edit/components/confirmation_modal.vue9
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue129
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue17
-rw-r--r--app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue8
-rw-r--r--app/assets/javascripts/integrations/edit/index.js22
-rw-r--r--app/assets/javascripts/integrations/edit/store/actions.js29
-rw-r--r--app/assets/javascripts/integrations/edit/store/getters.js2
-rw-r--r--app/assets/javascripts/integrations/edit/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/integrations/edit/store/mutations.js6
-rw-r--r--app/assets/javascripts/integrations/edit/store/state.js1
-rw-r--r--app/assets/javascripts/integrations/integration_settings_form.js151
-rw-r--r--app/assets/javascripts/integrations/overrides/components/integration_overrides.vue40
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue69
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_trigger.vue15
-rw-r--r--app/assets/javascripts/invite_members/constants.js12
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_modal.js2
-rw-r--r--app/assets/javascripts/issuable/bulk_update_sidebar/components/status_select.vue (renamed from app/assets/javascripts/issuable_bulk_update_sidebar/components/status_select.vue)0
-rw-r--r--app/assets/javascripts/issuable/bulk_update_sidebar/constants.js (renamed from app/assets/javascripts/issuable_bulk_update_sidebar/constants.js)0
-rw-r--r--app/assets/javascripts/issuable/bulk_update_sidebar/init_issue_status_select.js (renamed from app/assets/javascripts/issuable_bulk_update_sidebar/init_issue_status_select.js)0
-rw-r--r--app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_actions.js (renamed from app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_actions.js)2
-rw-r--r--app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js (renamed from app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_sidebar.js)12
-rw-r--r--app/assets/javascripts/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar.js (renamed from app/assets/javascripts/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar.js)0
-rw-r--r--app/assets/javascripts/issuable/bulk_update_sidebar/subscription_select.js (renamed from app/assets/javascripts/issuable_bulk_update_sidebar/subscription_select.js)0
-rw-r--r--app/assets/javascripts/issuable/components/issuable_by_email.vue3
-rw-r--r--app/assets/javascripts/issuable/components/issuable_header_warnings.vue (renamed from app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue)0
-rw-r--r--app/assets/javascripts/issuable/components/issue_assignees.vue (renamed from app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue)0
-rw-r--r--app/assets/javascripts/issuable/components/issue_milestone.vue (renamed from app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue)0
-rw-r--r--app/assets/javascripts/issuable/components/related_issuable_item.vue (renamed from app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue)4
-rw-r--r--app/assets/javascripts/issuable/constants.js5
-rw-r--r--app/assets/javascripts/issuable/index.js116
-rw-r--r--app/assets/javascripts/issuable/init_csv_import_export_buttons.js48
-rw-r--r--app/assets/javascripts/issuable/init_issuable_by_email.js35
-rw-r--r--app/assets/javascripts/issuable/issuable_context.js (renamed from app/assets/javascripts/issuable_context.js)4
-rw-r--r--app/assets/javascripts/issuable/issuable_form.js (renamed from app/assets/javascripts/issuable_form.js)16
-rw-r--r--app/assets/javascripts/issuable/issuable_template_selector.js (renamed from app/assets/javascripts/templates/issuable_template_selector.js)6
-rw-r--r--app/assets/javascripts/issuable/issuable_template_selectors.js (renamed from app/assets/javascripts/templates/issuable_template_selectors.js)5
-rw-r--r--app/assets/javascripts/issuable/mixins/related_issuable_mixin.js (renamed from app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js)0
-rw-r--r--app/assets/javascripts/issuable_index.js7
-rw-r--r--app/assets/javascripts/issuable_type_selector/index.js16
-rw-r--r--app/assets/javascripts/issues/constants.js25
-rw-r--r--app/assets/javascripts/issues/filtered_search_service_desk.js (renamed from app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js)0
-rw-r--r--app/assets/javascripts/issues/form.js (renamed from app/assets/javascripts/pages/projects/issues/form.js)15
-rw-r--r--app/assets/javascripts/issues/init_filtered_search_service_desk.js11
-rw-r--r--app/assets/javascripts/issues/issue.js (renamed from app/assets/javascripts/issue.js)12
-rw-r--r--app/assets/javascripts/issues/manual_ordering.js (renamed from app/assets/javascripts/manual_ordering.js)0
-rw-r--r--app/assets/javascripts/issues/new/components/title_suggestions.vue (renamed from app/assets/javascripts/issuable_suggestions/components/app.vue)8
-rw-r--r--app/assets/javascripts/issues/new/components/title_suggestions_item.vue (renamed from app/assets/javascripts/issuable_suggestions/components/item.vue)0
-rw-r--r--app/assets/javascripts/issues/new/components/type_popover.vue (renamed from app/assets/javascripts/issuable_type_selector/components/info_popover.vue)4
-rw-r--r--app/assets/javascripts/issues/new/index.js (renamed from app/assets/javascripts/issuable_suggestions/index.js)32
-rw-r--r--app/assets/javascripts/issues/new/queries/issues.query.graphql (renamed from app/assets/javascripts/issuable_suggestions/queries/issues.query.graphql)3
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue (renamed from app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue)4
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/index.js (renamed from app/assets/javascripts/related_merge_requests/index.js)0
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/store/actions.js (renamed from app/assets/javascripts/related_merge_requests/store/actions.js)0
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/store/index.js (renamed from app/assets/javascripts/related_merge_requests/store/index.js)0
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/store/mutation_types.js (renamed from app/assets/javascripts/related_merge_requests/store/mutation_types.js)0
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/store/mutations.js (renamed from app/assets/javascripts/related_merge_requests/store/mutations.js)0
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/store/state.js (renamed from app/assets/javascripts/related_merge_requests/store/state.js)0
-rw-r--r--app/assets/javascripts/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue (renamed from app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue)0
-rw-r--r--app/assets/javascripts/issues/sentry_error_stack_trace/index.js (renamed from app/assets/javascripts/sentry_error_stack_trace/index.js)0
-rw-r--r--app/assets/javascripts/issues/show.js (renamed from app/assets/javascripts/pages/projects/issues/show.js)19
-rw-r--r--app/assets/javascripts/issues/show/components/app.vue (renamed from app/assets/javascripts/issue_show/components/app.vue)33
-rw-r--r--app/assets/javascripts/issues/show/components/delete_issue_modal.vue71
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue (renamed from app/assets/javascripts/issue_show/components/description.vue)4
-rw-r--r--app/assets/javascripts/issues/show/components/edit_actions.vue (renamed from app/assets/javascripts/issue_show/components/edit_actions.vue)53
-rw-r--r--app/assets/javascripts/issues/show/components/edited.vue (renamed from app/assets/javascripts/issue_show/components/edited.vue)2
-rw-r--r--app/assets/javascripts/issues/show/components/fields/description.vue (renamed from app/assets/javascripts/issue_show/components/fields/description.vue)0
-rw-r--r--app/assets/javascripts/issues/show/components/fields/description_template.vue (renamed from app/assets/javascripts/issue_show/components/fields/description_template.vue)2
-rw-r--r--app/assets/javascripts/issues/show/components/fields/title.vue (renamed from app/assets/javascripts/issue_show/components/fields/title.vue)0
-rw-r--r--app/assets/javascripts/issues/show/components/fields/type.vue (renamed from app/assets/javascripts/issue_show/components/fields/type.vue)0
-rw-r--r--app/assets/javascripts/issues/show/components/form.vue (renamed from app/assets/javascripts/issue_show/components/form.vue)7
-rw-r--r--app/assets/javascripts/issues/show/components/header_actions.vue (renamed from app/assets/javascripts/issue_show/components/header_actions.vue)96
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql (renamed from app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql)1
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/highlight_bar.vue (renamed from app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue)2
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue (renamed from app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue)2
-rw-r--r--app/assets/javascripts/issues/show/components/locked_warning.vue (renamed from app/assets/javascripts/issue_show/components/locked_warning.vue)0
-rw-r--r--app/assets/javascripts/issues/show/components/pinned_links.vue (renamed from app/assets/javascripts/issue_show/components/pinned_links.vue)0
-rw-r--r--app/assets/javascripts/issues/show/components/title.vue (renamed from app/assets/javascripts/issue_show/components/title.vue)0
-rw-r--r--app/assets/javascripts/issues/show/constants.js (renamed from app/assets/javascripts/issue_show/constants.js)24
-rw-r--r--app/assets/javascripts/issues/show/event_hub.js (renamed from app/assets/javascripts/issuable_show/event_hub.js)0
-rw-r--r--app/assets/javascripts/issues/show/graphql.js (renamed from app/assets/javascripts/issue_show/graphql.js)0
-rw-r--r--app/assets/javascripts/issues/show/incident.js (renamed from app/assets/javascripts/issue_show/incident.js)2
-rw-r--r--app/assets/javascripts/issues/show/issue.js (renamed from app/assets/javascripts/issue_show/issue.js)3
-rw-r--r--app/assets/javascripts/issues/show/mixins/animate.js (renamed from app/assets/javascripts/issue_show/mixins/animate.js)0
-rw-r--r--app/assets/javascripts/issues/show/mixins/update.js (renamed from app/assets/javascripts/issue_show/mixins/update.js)0
-rw-r--r--app/assets/javascripts/issues/show/queries/get_issue_state.query.graphql (renamed from app/assets/javascripts/issue_show/queries/get_issue_state.query.graphql)0
-rw-r--r--app/assets/javascripts/issues/show/queries/promote_to_epic.mutation.graphql (renamed from app/assets/javascripts/issue_show/queries/promote_to_epic.mutation.graphql)1
-rw-r--r--app/assets/javascripts/issues/show/queries/update_issue.mutation.graphql (renamed from app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql)0
-rw-r--r--app/assets/javascripts/issues/show/queries/update_issue_state.mutation.graphql (renamed from app/assets/javascripts/issue_show/queries/update_issue_state.mutation.graphql)0
-rw-r--r--app/assets/javascripts/issues/show/services/index.js (renamed from app/assets/javascripts/issue_show/services/index.js)2
-rw-r--r--app/assets/javascripts/issues/show/stores/index.js (renamed from app/assets/javascripts/issue_show/stores/index.js)0
-rw-r--r--app/assets/javascripts/issues/show/utils/parse_data.js (renamed from app/assets/javascripts/issue_show/utils/parse_data.js)0
-rw-r--r--app/assets/javascripts/issues/show/utils/update_description.js (renamed from app/assets/javascripts/issue_show/utils/update_description.js)0
-rw-r--r--app/assets/javascripts/issues_list/components/issuable.vue2
-rw-r--r--app/assets/javascripts/issues_list/components/issuables_list_app.vue12
-rw-r--r--app/assets/javascripts/issues_list/components/issue_card_time_info.vue18
-rw-r--r--app/assets/javascripts/issues_list/components/issues_list_app.vue171
-rw-r--r--app/assets/javascripts/issues_list/constants.js59
-rw-r--r--app/assets/javascripts/issues_list/index.js6
-rw-r--r--app/assets/javascripts/issues_list/queries/get_issues.query.graphql2
-rw-r--r--app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql2
-rw-r--r--app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql3
-rw-r--r--app/assets/javascripts/issues_list/queries/issue.fragment.graphql1
-rw-r--r--app/assets/javascripts/issues_list/queries/iteration.fragment.graphql10
-rw-r--r--app/assets/javascripts/issues_list/queries/search_iterations.query.graphql18
-rw-r--r--app/assets/javascripts/issues_list/queries/search_labels.query.graphql2
-rw-r--r--app/assets/javascripts/issues_list/queries/search_milestones.query.graphql2
-rw-r--r--app/assets/javascripts/issues_list/queries/search_projects.query.graphql1
-rw-r--r--app/assets/javascripts/issues_list/queries/search_users.query.graphql4
-rw-r--r--app/assets/javascripts/issues_list/utils.js3
-rw-r--r--app/assets/javascripts/jira_connect/branches/graphql/queries/get_project.query.graphql1
-rw-r--r--app/assets/javascripts/jira_connect/branches/index.js2
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/app.vue4
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue67
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/index.js21
-rw-r--r--app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql1
-rw-r--r--app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql1
-rw-r--r--app/assets/javascripts/jira_import/queries/search_project_members.query.graphql2
-rw-r--r--app/assets/javascripts/jobs/bridge/app.vue20
-rw-r--r--app/assets/javascripts/jobs/bridge/components/constants.js1
-rw-r--r--app/assets/javascripts/jobs/bridge/components/empty_state.vue45
-rw-r--r--app/assets/javascripts/jobs/bridge/components/sidebar.vue98
-rw-r--r--app/assets/javascripts/jobs/components/job_log_controllers.vue2
-rw-r--r--app/assets/javascripts/jobs/components/sidebar.vue2
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_job_details_container.vue5
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/actions_cell.vue16
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/pipeline_cell.vue10
-rw-r--r--app/assets/javascripts/jobs/components/table/constants.js67
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql7
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table.vue69
-rw-r--r--app/assets/javascripts/jobs/index.js39
-rw-r--r--app/assets/javascripts/labels/components/delete_label_modal.vue (renamed from app/assets/javascripts/vue_shared/components/delete_label_modal.vue)0
-rw-r--r--app/assets/javascripts/labels/components/promote_label_modal.vue (renamed from app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue)0
-rw-r--r--app/assets/javascripts/labels/create_label_dropdown.js (renamed from app/assets/javascripts/create_label.js)4
-rw-r--r--app/assets/javascripts/labels/event_hub.js (renamed from app/assets/javascripts/issue_show/event_hub.js)0
-rw-r--r--app/assets/javascripts/labels/group_label_subscription.js (renamed from app/assets/javascripts/group_label_subscription.js)4
-rw-r--r--app/assets/javascripts/labels/index.js137
-rw-r--r--app/assets/javascripts/labels/label_manager.js (renamed from app/assets/javascripts/label_manager.js)6
-rw-r--r--app/assets/javascripts/labels/labels.js (renamed from app/assets/javascripts/labels.js)0
-rw-r--r--app/assets/javascripts/labels/labels_select.js (renamed from app/assets/javascripts/labels_select.js)12
-rw-r--r--app/assets/javascripts/labels/project_label_subscription.js (renamed from app/assets/javascripts/project_label_subscription.js)6
-rw-r--r--app/assets/javascripts/lib/dompurify.js2
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js11
-rw-r--r--app/assets/javascripts/lib/utils/constants.js1
-rw-r--r--app/assets/javascripts/lib/utils/dom_utils.js14
-rw-r--r--app/assets/javascripts/lib/utils/intersection_observer.js28
-rw-r--r--app/assets/javascripts/lib/utils/navigation_utility.js39
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js2
-rw-r--r--app/assets/javascripts/main.js40
-rw-r--r--app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue1
-rw-r--r--app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue2
-rw-r--r--app/assets/javascripts/members/components/action_buttons/remove_member_button.vue16
-rw-r--r--app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue18
-rw-r--r--app/assets/javascripts/members/components/table/member_action_buttons.vue5
-rw-r--r--app/assets/javascripts/members/components/table/members_table.vue5
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js2
-rw-r--r--app/assets/javascripts/milestone.js49
-rw-r--r--app/assets/javascripts/milestones/components/delete_milestone_modal.vue (renamed from app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue)0
-rw-r--r--app/assets/javascripts/milestones/components/promote_milestone_modal.vue (renamed from app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue)0
-rw-r--r--app/assets/javascripts/milestones/event_hub.js (renamed from app/assets/javascripts/pages/milestones/shared/event_hub.js)0
-rw-r--r--app/assets/javascripts/milestones/index.js (renamed from app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js)52
-rw-r--r--app/assets/javascripts/milestones/milestone.js49
-rw-r--r--app/assets/javascripts/milestones/milestone_select.js (renamed from app/assets/javascripts/milestone_select.js)6
-rw-r--r--app/assets/javascripts/milestones/utils.js (renamed from app/assets/javascripts/milestones/milestone_utils.js)0
-rw-r--r--app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql1
-rw-r--r--app/assets/javascripts/mr_popover/components/mr_popover.vue11
-rw-r--r--app/assets/javascripts/mr_popover/queries/merge_request.query.graphql5
-rw-r--r--app/assets/javascripts/network/branch_graph.js10
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue26
-rw-r--r--app/assets/javascripts/notes/components/diff_with_note.vue14
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter.vue3
-rw-r--r--app/assets/javascripts/notes/components/discussion_locked_widget.vue12
-rw-r--r--app/assets/javascripts/notes/components/discussion_notes.vue90
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue2
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue18
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue15
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue8
-rw-r--r--app/assets/javascripts/notes/components/sidebar_subscription.vue2
-rw-r--r--app/assets/javascripts/notes/i18n.js16
-rw-r--r--app/assets/javascripts/notes/stores/actions.js3
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js4
-rw-r--r--app/assets/javascripts/packages/list/packages_list_app_bundle.js23
-rw-r--r--app/assets/javascripts/packages/shared/constants.js49
-rw-r--r--app/assets/javascripts/packages/shared/utils.js43
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue86
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql2
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql1
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql1
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue6
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue17
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql2
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue23
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue (renamed from app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue)4
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue (renamed from app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue (renamed from app/assets/javascripts/packages/list/components/packages_list.vue)20
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue (renamed from app/assets/javascripts/packages/list/components/packages_list_app.vue)15
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/constants.js (renamed from app/assets/javascripts/packages/list/constants.js)50
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js (renamed from app/assets/javascripts/packages/list/stores/actions.js)2
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/getters.js (renamed from app/assets/javascripts/packages/list/stores/getters.js)2
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/index.js (renamed from app/assets/javascripts/packages/list/stores/index.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/mutation_types.js (renamed from app/assets/javascripts/packages/list/stores/mutation_types.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/mutations.js (renamed from app/assets/javascripts/packages/list/stores/mutations.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/state.js (renamed from app/assets/javascripts/packages/list/stores/state.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/utils.js (renamed from app/assets/javascripts/packages/list/utils.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js7
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/constants.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/infrastructure_icon_and_name.vue (renamed from app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue (renamed from app/assets/javascripts/packages/shared/components/package_list_row.vue)33
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue4
-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/details/version_row.vue4
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue17
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue3
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/constants.js24
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql6
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql7
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/index.js30
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/index.vue5
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/list.js24
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue (renamed from app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue)28
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/router.js21
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/bundle.js3
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue123
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue4
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue7
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_image_ttl_group_policy.mutation.graphql11
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql5
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js5
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js12
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql1
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/package_icon_and_name.vue (renamed from app/assets/javascripts/packages/shared/components/package_icon_and_name.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/package_path.vue (renamed from app/assets/javascripts/packages/shared/components/package_path.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/package_tags.vue (renamed from app/assets/javascripts/packages/shared/components/package_tags.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/packages_list_loader.vue (renamed from app/assets/javascripts/packages/shared/components/packages_list_loader.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/publish_method.vue (renamed from app/assets/javascripts/packages/shared/components/publish_method.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue124
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/constants.js36
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/utils.js10
-rw-r--r--app/assets/javascripts/pages/admin/integrations/edit/index.js18
-rw-r--r--app/assets/javascripts/pages/admin/labels/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/labels/index/index.js24
-rw-r--r--app/assets/javascripts/pages/admin/labels/new/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/services/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/services/index/index.js4
-rw-r--r--app/assets/javascripts/pages/constants.js6
-rw-r--r--app/assets/javascripts/pages/dashboard/issues/index.js4
-rw-r--r--app/assets/javascripts/pages/dashboard/merge_requests/index.js2
-rw-r--r--app/assets/javascripts/pages/dashboard/milestones/show/index.js2
-rw-r--r--app/assets/javascripts/pages/dashboard/projects/index/components/customize_homepage_banner.vue102
-rw-r--r--app/assets/javascripts/pages/dashboard/projects/index/index.js2
-rw-r--r--app/assets/javascripts/pages/dashboard/projects/index/init_customize_homepage_banner.js16
-rw-r--r--app/assets/javascripts/pages/groups/issues/index.js6
-rw-r--r--app/assets/javascripts/pages/groups/labels/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/labels/index/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/labels/new/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/merge_requests/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/milestones/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/milestones/new/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/milestones/show/index.js5
-rw-r--r--app/assets/javascripts/pages/groups/packages/index/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/settings/integrations/edit/index.js10
-rw-r--r--app/assets/javascripts/pages/help/ui/index.js3
-rw-r--r--app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue3
-rw-r--r--app/assets/javascripts/pages/milestones/shared/index.js7
-rw-r--r--app/assets/javascripts/pages/milestones/shared/init_milestones_show.js11
-rw-r--r--app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js19
-rw-r--r--app/assets/javascripts/pages/profiles/personal_access_tokens/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/constants.js4
-rw-r--r--app/assets/javascripts/pages/projects/edit/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/incidents/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/issues/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/issues/index/index.js13
-rw-r--r--app/assets/javascripts/pages/projects/issues/new/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/issues/service_desk/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/issues/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/labels/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/labels/index/index.js82
-rw-r--r--app/assets/javascripts/pages/projects/labels/new/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue53
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue49
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/index/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/index/index.js12
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js8
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js2
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/queries/get_state.query.graphql2
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/milestones/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/milestones/index/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/milestones/new/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/milestones/show/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/packages/packages/index/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/path_locks/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/services/edit/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/usage_quotas/index.js23
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue70
-rw-r--r--app/assets/javascripts/pdf/index.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue6
-rw-r--r--app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue30
-rw-r--r--app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue10
-rw-r--r--app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue41
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue28
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue7
-rw-r--r--app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue8
-rw-r--r--app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue5
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql (renamed from app/assets/javascripts/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql)0
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_app_status.mutation.graphql (renamed from app/assets/javascripts/pipeline_editor/graphql/mutations/update_app_status.mutation.graphql)0
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql (renamed from app/assets/javascripts/pipeline_editor/graphql/mutations/update_current_branch.mutation.graphql)0
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_last_commit_branch.mutation.graphql (renamed from app/assets/javascripts/pipeline_editor/graphql/mutations/update_last_commit_branch.mutation.graphql)0
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql (renamed from app/assets/javascripts/pipeline_editor/graphql/mutations/update_pipeline_etag.mutation.graphql)0
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql3
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.query.graphql (renamed from app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql)1
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.query.graphql (renamed from app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.graphql)2
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.query.graphql (renamed from app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql)0
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.graphql3
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.query.graphql5
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.graphql3
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.query.graphql7
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql6
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.graphql3
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql5
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/get_starter_template.query.graphql1
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql2
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/pipeline.query.graphql (renamed from app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql)3
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/resolvers.js46
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/typedefs.graphql22
-rw-r--r--app/assets/javascripts/pipeline_editor/index.js38
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue67
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue39
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue8
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue8
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/jobs_app.vue121
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue8
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql5
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql2
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql70
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js12
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_jobs.js34
-rw-r--r--app/assets/javascripts/projects/commit/constants.js2
-rw-r--r--app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql6
-rw-r--r--app/assets/javascripts/projects/new/components/new_project_url_select.vue34
-rw-r--r--app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql1
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/app.vue25
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue17
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql1
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql1
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/index.js5
-rw-r--r--app/assets/javascripts/projects/settings/components/transfer_project_form.vue63
-rw-r--r--app/assets/javascripts/projects/settings/init_transfer_project_form.js53
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue201
-rw-r--r--app/assets/javascripts/projects/storage_counter/components/app.vue106
-rw-r--r--app/assets/javascripts/projects/storage_counter/components/storage_table.vue88
-rw-r--r--app/assets/javascripts/projects/storage_counter/components/storage_type_icon.vue35
-rw-r--r--app/assets/javascripts/projects/storage_counter/constants.js61
-rw-r--r--app/assets/javascripts/projects/storage_counter/index.js51
-rw-r--r--app/assets/javascripts/projects/storage_counter/queries/project_storage.query.graphql16
-rw-r--r--app/assets/javascripts/projects/storage_counter/utils.js36
-rw-r--r--app/assets/javascripts/related_issues/components/issue_token.vue2
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_list.vue2
-rw-r--r--app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql3
-rw-r--r--app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql1
-rw-r--r--app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql1
-rw-r--r--app/assets/javascripts/releases/graphql/queries/one_release.query.graphql1
-rw-r--r--app/assets/javascripts/releases/graphql/queries/one_release_for_editing.query.graphql1
-rw-r--r--app/assets/javascripts/repository/commits_service.js11
-rw-r--r--app/assets/javascripts/repository/components/blob_button_group.vue9
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue15
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/index.js16
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue50
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/text_viewer.vue25
-rw-r--r--app/assets/javascripts/repository/components/delete_blob_modal.vue14
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue2
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue16
-rw-r--r--app/assets/javascripts/repository/components/tree_content.vue21
-rw-r--r--app/assets/javascripts/repository/components/upload_blob_modal.vue4
-rw-r--r--app/assets/javascripts/repository/constants.js7
-rw-r--r--app/assets/javascripts/repository/index.js2
-rw-r--r--app/assets/javascripts/repository/mutations/lock_path.mutation.graphql1
-rw-r--r--app/assets/javascripts/repository/pages/tree.vue2
-rw-r--r--app/assets/javascripts/repository/queries/blob_info.query.graphql7
-rw-r--r--app/assets/javascripts/right_sidebar.js9
-rw-r--r--app/assets/javascripts/runner/admin_runners/admin_runners_app.vue18
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_actions_cell.vue83
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_status_cell.vue12
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_summary_cell.vue2
-rw-r--r--app/assets/javascripts/runner/components/runner_delete_modal.vue51
-rw-r--r--app/assets/javascripts/runner/components/runner_filtered_search_bar.vue32
-rw-r--r--app/assets/javascripts/runner/components/runner_list.vue36
-rw-r--r--app/assets/javascripts/runner/components/runner_status_badge.vue (renamed from app/assets/javascripts/runner/components/runner_contacted_state_badge.vue)35
-rw-r--r--app/assets/javascripts/runner/components/search_tokens/status_token_config.js2
-rw-r--r--app/assets/javascripts/runner/components/search_tokens/tag_token.vue1
-rw-r--r--app/assets/javascripts/runner/components/stat/runner_online_stat.vue17
-rw-r--r--app/assets/javascripts/runner/constants.js17
-rw-r--r--app/assets/javascripts/runner/graphql/get_group_runners.query.graphql1
-rw-r--r--app/assets/javascripts/runner/graphql/get_runner.query.graphql2
-rw-r--r--app/assets/javascripts/runner/graphql/runner_node.fragment.graphql3
-rw-r--r--app/assets/javascripts/runner/graphql/runner_update.mutation.graphql2
-rw-r--r--app/assets/javascripts/runner/group_runners/group_runners_app.vue10
-rw-r--r--app/assets/javascripts/security_configuration/components/app.vue17
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js71
-rw-r--r--app/assets/javascripts/security_configuration/components/training_provider_list.vue61
-rw-r--r--app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql9
-rw-r--r--app/assets/javascripts/security_configuration/index.js31
-rw-r--r--app/assets/javascripts/security_configuration/utils.js11
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue11
-rw-r--r--app/assets/javascripts/shared/milestones/form.js22
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/attention_requested_toggle.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue131
-rw-r--r--app/assets/javascripts/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql7
-rw-r--r--app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.fragment.graphql17
-rw-r--r--app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql9
-rw-r--r--app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue13
-rw-r--r--app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue192
-rw-r--r--app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql1
-rw-r--r--app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql1
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql1
-rw-r--r--app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue14
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue71
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue2
-rw-r--r--app/assets/javascripts/sidebar/constants.js35
-rw-r--r--app/assets/javascripts/sidebar/graphql.js2
-rw-r--r--app/assets/javascripts/sidebar/mount_milestone_sidebar.js2
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js70
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_participants.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_reference.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_todo.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_reference.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_todo.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/project_milestones.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql4
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js14
-rw-r--r--app/assets/javascripts/snippets/components/edit.vue2
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_view.vue4
-rw-r--r--app/assets/javascripts/snippets/components/snippet_header.vue2
-rw-r--r--app/assets/javascripts/snippets/fragments/project.fragment.graphql6
-rw-r--r--app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql1
-rw-r--r--app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql1
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql1
-rw-r--r--app/assets/javascripts/tabs/constants.js20
-rw-r--r--app/assets/javascripts/tabs/index.js239
-rw-r--r--app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql6
-rw-r--r--app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql3
-rw-r--r--app/assets/javascripts/test_utils/simulate_drag.js42
-rw-r--r--app/assets/javascripts/token_access/graphql/queries/get_ci_job_token_scope.query.graphql1
-rw-r--r--app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql2
-rw-r--r--app/assets/javascripts/ui_development_kit.js28
-rw-r--r--app/assets/javascripts/user_lists/components/add_user_modal.vue2
-rw-r--r--app/assets/javascripts/user_lists/components/user_list.vue2
-rw-r--r--app/assets/javascripts/vue_alerts.js28
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue171
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js62
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue87
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/constants.js15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/issues.js18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/issues.query.graphql1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/permissions.query.graphql2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.fragment.graphql1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_failed.query.graphql2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/draft.query.graphql2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/missing_branch.query.graphql2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/rebase.query.graphql2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/toggle_draft.mutation.graphql1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js1
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue9
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue3
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/components/chronic_duration_input.vue133
-rw-r--r--app/assets/javascripts/vue_shared/components/clipboard_button.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/commit.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_modal.vue72
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_modal_eventhub.js5
-rw-r--r--app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue (renamed from app/assets/javascripts/design_management/components/design_note_pin.vue)22
-rw-r--r--app/assets/javascripts/vue_shared/components/dismissible_alert.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/dom_element_listener.vue28
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js113
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js15
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql15
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql16
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue42
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue129
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue138
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue66
-rw-r--r--app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js27
-rw-r--r--app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue127
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js22
-rw-r--r--app/assets/javascripts/vue_shared/components/line_numbers.vue57
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue18
-rw-r--r--app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue93
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js40
-rw-r--r--app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue (renamed from app/assets/javascripts/import_entities/components/pagination_bar.vue)29
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/list_item.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/metadata_item.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue101
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue45
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue55
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql4
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/components/source_editor.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer.vue88
-rw-r--r--app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.stories.js38
-rw-r--r--app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.vue148
-rw-r--r--app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue69
-rw-r--r--app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js88
-rw-r--r--app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue85
-rw-r--r--app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue (renamed from app/assets/javascripts/issuable_create/components/issuable_create_root.vue)0
-rw-r--r--app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue (renamed from app/assets/javascripts/issuable_create/components/issuable_form.vue)0
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue (renamed from app/assets/javascripts/issuable_list/components/issuable_bulk_edit_sidebar.vue)0
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue (renamed from app/assets/javascripts/issuable_list/components/issuable_item.vue)12
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue (renamed from app/assets/javascripts/issuable_list/components/issuable_list_root.vue)12
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue (renamed from app/assets/javascripts/issuable_list/components/issuable_tabs.vue)4
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/constants.js (renamed from app/assets/javascripts/issuable_list/constants.js)0
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue (renamed from app/assets/javascripts/issuable_show/components/issuable_body.vue)0
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue (renamed from app/assets/javascripts/issuable_show/components/issuable_description.vue)0
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_discussion.vue (renamed from app/assets/javascripts/issuable_show/components/issuable_discussion.vue)0
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue (renamed from app/assets/javascripts/issuable_show/components/issuable_edit_form.vue)0
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue (renamed from app/assets/javascripts/issuable_show/components/issuable_header.vue)0
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue (renamed from app/assets/javascripts/issuable_show/components/issuable_show_root.vue)4
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue (renamed from app/assets/javascripts/issuable_show/components/issuable_title.vue)0
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/constants.js (renamed from app/assets/javascripts/issuable_show/constants.js)0
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/event_hub.js (renamed from app/assets/javascripts/pages/projects/labels/event_hub.js)0
-rw-r--r--app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue (renamed from app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue)0
-rw-r--r--app/assets/javascripts/vue_shared/issuable/sidebar/constants.js (renamed from app/assets/javascripts/issuable_sidebar/constants.js)0
-rw-r--r--app/assets/javascripts/vue_shared/mixins/issuable.js14
-rw-r--r--app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue2
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql2
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql3
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql2
-rw-r--r--app/assets/javascripts/work_items/components/item_title.vue71
-rw-r--r--app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql18
-rw-r--r--app/assets/javascripts/work_items/graphql/fragmentTypes.json2
-rw-r--r--app/assets/javascripts/work_items/graphql/provider.js20
-rw-r--r--app/assets/javascripts/work_items/graphql/resolvers.js58
-rw-r--r--app/assets/javascripts/work_items/graphql/typedefs.graphql52
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql18
-rw-r--r--app/assets/javascripts/work_items/graphql/widget.fragment.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.query.graphql2
-rw-r--r--app/assets/javascripts/work_items/pages/create_work_item.vue71
-rw-r--r--app/assets/javascripts/work_items/pages/work_item_root.vue38
-rw-r--r--app/assets/javascripts/work_items/router/routes.js7
-rw-r--r--app/assets/stylesheets/components/design_management/design.scss9
-rw-r--r--app/assets/stylesheets/framework/buttons.scss14
-rw-r--r--app/assets/stylesheets/framework/common.scss7
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss17
-rw-r--r--app/assets/stylesheets/framework/files.scss33
-rw-r--r--app/assets/stylesheets/framework/header.scss2
-rw-r--r--app/assets/stylesheets/framework/layout.scss27
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss4
-rw-r--r--app/assets/stylesheets/framework/snippets.scss5
-rw-r--r--app/assets/stylesheets/framework/typography.scss6
-rw-r--r--app/assets/stylesheets/highlight/common.scss8
-rw-r--r--app/assets/stylesheets/highlight/themes/dark.scss13
-rw-r--r--app/assets/stylesheets/highlight/themes/monokai.scss11
-rw-r--r--app/assets/stylesheets/highlight/themes/none.scss11
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-dark.scss11
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-light.scss11
-rw-r--r--app/assets/stylesheets/highlight/white_base.scss11
-rw-r--r--app/assets/stylesheets/lazy_bundles/select2_overrides.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/_pipeline_mixins.scss6
-rw-r--r--app/assets/stylesheets/page_bundles/ide.scss5
-rw-r--r--app/assets/stylesheets/page_bundles/import.scss35
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/pipeline.scss11
-rw-r--r--app/assets/stylesheets/pages/clusters.scss26
-rw-r--r--app/assets/stylesheets/pages/commits.scss1
-rw-r--r--app/assets/stylesheets/pages/issuable.scss10
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss13
-rw-r--r--app/assets/stylesheets/pages/profile.scss10
-rw-r--r--app/assets/stylesheets/pages/search.scss45
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss71
-rw-r--r--app/assets/stylesheets/startup/startup-general.scss67
-rw-r--r--app/assets/stylesheets/themes/dark_mode_overrides.scss4
-rw-r--r--app/assets/stylesheets/themes/theme_helper.scss1
-rw-r--r--app/assets/stylesheets/utilities.scss18
-rw-r--r--app/controllers/admin/plan_limits_controller.rb1
-rw-r--r--app/controllers/admin/version_check_controller.rb12
-rw-r--r--app/controllers/application_controller.rb18
-rw-r--r--app/controllers/autocomplete_controller.rb2
-rw-r--r--app/controllers/clusters/clusters_controller.rb8
-rw-r--r--app/controllers/concerns/authenticates_with_two_factor.rb4
-rw-r--r--app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb2
-rw-r--r--app/controllers/concerns/check_rate_limit.rb22
-rw-r--r--app/controllers/concerns/cycle_analytics_params.rb1
-rw-r--r--app/controllers/concerns/dependency_proxy/group_access.rb4
-rw-r--r--app/controllers/concerns/integrations/actions.rb2
-rw-r--r--app/controllers/concerns/integrations/hooks_execution.rb12
-rw-r--r--app/controllers/concerns/issuable_actions.rb1
-rw-r--r--app/controllers/concerns/membership_actions.rb1
-rw-r--r--app/controllers/concerns/notes_actions.rb9
-rw-r--r--app/controllers/concerns/one_trust_csp.rb4
-rw-r--r--app/controllers/concerns/preview_markdown.rb2
-rw-r--r--app/controllers/concerns/snippets/blobs_actions.rb2
-rw-r--r--app/controllers/concerns/sourcegraph_decorator.rb2
-rw-r--r--app/controllers/concerns/wiki_actions.rb13
-rw-r--r--app/controllers/confirmations_controller.rb1
-rw-r--r--app/controllers/dashboard_controller.rb2
-rw-r--r--app/controllers/google_api/authorizations_controller.rb35
-rw-r--r--app/controllers/graphql_controller.rb6
-rw-r--r--app/controllers/groups/autocomplete_sources_controller.rb2
-rw-r--r--app/controllers/groups/boards_controller.rb1
-rw-r--r--app/controllers/groups/crm/contacts_controller.rb21
-rw-r--r--app/controllers/groups/crm/organizations_controller.rb17
-rw-r--r--app/controllers/groups/crm_controller.rb30
-rw-r--r--app/controllers/groups/dependency_proxies_controller.rb19
-rw-r--r--app/controllers/groups/dependency_proxy_for_containers_controller.rb2
-rw-r--r--app/controllers/groups/variables_controller.rb2
-rw-r--r--app/controllers/groups_controller.rb21
-rw-r--r--app/controllers/help_controller.rb4
-rw-r--r--app/controllers/import/base_controller.rb16
-rw-r--r--app/controllers/import/bulk_imports_controller.rb8
-rw-r--r--app/controllers/import/gitlab_groups_controller.rb11
-rw-r--r--app/controllers/invites_controller.rb7
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb10
-rw-r--r--app/controllers/profiles/emails_controller.rb16
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb6
-rw-r--r--app/controllers/profiles_controller.rb2
-rw-r--r--app/controllers/projects/autocomplete_sources_controller.rb2
-rw-r--r--app/controllers/projects/blame_controller.rb2
-rw-r--r--app/controllers/projects/blob_controller.rb6
-rw-r--r--app/controllers/projects/boards_controller.rb1
-rw-r--r--app/controllers/projects/ci/lints_controller.rb1
-rw-r--r--app/controllers/projects/ci/pipeline_editor_controller.rb4
-rw-r--r--app/controllers/projects/forks_controller.rb2
-rw-r--r--app/controllers/projects/google_cloud/base_controller.rb26
-rw-r--r--app/controllers/projects/google_cloud/service_accounts_controller.rb85
-rw-r--r--app/controllers/projects/google_cloud_controller.rb28
-rw-r--r--app/controllers/projects/hooks_controller.rb2
-rw-r--r--app/controllers/projects/integrations/shimos_controller.rb19
-rw-r--r--app/controllers/projects/issues_controller.rb34
-rw-r--r--app/controllers/projects/jobs_controller.rb8
-rw-r--r--app/controllers/projects/learn_gitlab_controller.rb10
-rw-r--r--app/controllers/projects/merge_requests/conflicts_controller.rb6
-rw-r--r--app/controllers/projects/merge_requests/content_controller.rb5
-rw-r--r--app/controllers/projects/merge_requests/creations_controller.rb9
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb8
-rw-r--r--app/controllers/projects/merge_requests/drafts_controller.rb9
-rw-r--r--app/controllers/projects/merge_requests_controller.rb35
-rw-r--r--app/controllers/projects/notes_controller.rb2
-rw-r--r--app/controllers/projects/pipeline_schedules_controller.rb10
-rw-r--r--app/controllers/projects/pipelines_controller.rb25
-rw-r--r--app/controllers/projects/prometheus/alerts_controller.rb6
-rw-r--r--app/controllers/projects/raw_controller.rb20
-rw-r--r--app/controllers/projects/repositories_controller.rb16
-rw-r--r--app/controllers/projects/runners_controller.rb2
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb2
-rw-r--r--app/controllers/projects/tree_controller.rb2
-rw-r--r--app/controllers/projects/usage_quotas_controller.rb9
-rw-r--r--app/controllers/projects/variables_controller.rb2
-rw-r--r--app/controllers/projects_controller.rb25
-rw-r--r--app/controllers/registrations/welcome_controller.rb6
-rw-r--r--app/controllers/registrations_controller.rb3
-rw-r--r--app/controllers/repositories/git_http_client_controller.rb13
-rw-r--r--app/controllers/repositories/lfs_api_controller.rb33
-rw-r--r--app/controllers/search_controller.rb35
-rw-r--r--app/controllers/sessions_controller.rb8
-rw-r--r--app/controllers/user_callouts_controller.rb29
-rw-r--r--app/controllers/users/callouts_controller.rb31
-rw-r--r--app/controllers/users/group_callouts_controller.rb2
-rw-r--r--app/controllers/users/terms_controller.rb1
-rw-r--r--app/experiments/application_experiment.rb32
-rw-r--r--app/experiments/combined_registration_experiment.rb6
-rw-r--r--app/experiments/new_project_readme_content_experiment.rb2
-rw-r--r--app/experiments/new_project_sast_enabled_experiment.rb3
-rw-r--r--app/finders/ci/auth_job_finder.rb2
-rw-r--r--app/finders/ci/runners_finder.rb5
-rw-r--r--app/finders/environments/environments_by_deployments_finder.rb21
-rw-r--r--app/finders/group_descendants_finder.rb18
-rw-r--r--app/finders/groups_finder.rb21
-rw-r--r--app/finders/issuable_finder.rb16
-rw-r--r--app/finders/issuables/crm_contact_filter.rb20
-rw-r--r--app/finders/issuables/crm_organization_filter.rb21
-rw-r--r--app/finders/merge_requests_finder.rb11
-rw-r--r--app/finders/packages/build_infos_finder.rb68
-rw-r--r--app/finders/packages/group_packages_finder.rb12
-rw-r--r--app/finders/personal_projects_finder.rb1
-rw-r--r--app/finders/user_group_notification_settings_finder.rb7
-rw-r--r--app/graphql/graphql_triggers.rb4
-rw-r--r--app/graphql/mutations/issues/set_crm_contacts.rb18
-rw-r--r--app/graphql/mutations/jira_import/start.rb6
-rw-r--r--app/graphql/mutations/merge_requests/accept.rb6
-rw-r--r--app/graphql/mutations/user_callouts/create.rb2
-rw-r--r--app/graphql/queries/container_registry/get_container_repositories.query.graphql2
-rw-r--r--app/graphql/queries/design_management/design_permissions.query.graphql1
-rw-r--r--app/graphql/queries/design_management/get_design_list.query.graphql1
-rw-r--r--app/graphql/queries/epic/epic_children.query.graphql132
-rw-r--r--app/graphql/queries/epic/epic_details.query.graphql3
-rw-r--r--app/graphql/queries/pipelines/get_pipeline_details.query.graphql13
-rw-r--r--app/graphql/queries/releases/all_releases.query.graphql4
-rw-r--r--app/graphql/queries/repository/path_last_commit.query.graphql6
-rw-r--r--app/graphql/queries/snippet/project_permissions.query.graphql1
-rw-r--r--app/graphql/queries/snippet/snippet.query.graphql1
-rw-r--r--app/graphql/queries/snippet/user_permissions.query.graphql1
-rw-r--r--app/graphql/resolvers/base_issues_resolver.rb6
-rw-r--r--app/graphql/resolvers/ci/jobs_resolver.rb2
-rw-r--r--app/graphql/resolvers/ci/runner_status_resolver.rb24
-rw-r--r--app/graphql/resolvers/ci/runners_resolver.rb5
-rw-r--r--app/graphql/resolvers/clusters/agent_activity_events_resolver.rb25
-rw-r--r--app/graphql/resolvers/clusters/agents_resolver.rb5
-rw-r--r--app/graphql/resolvers/container_repository_tags_resolver.rb49
-rw-r--r--app/graphql/resolvers/design_management/designs_resolver.rb10
-rw-r--r--app/graphql/resolvers/design_management/version/design_at_version_resolver.rb8
-rw-r--r--app/graphql/resolvers/kas/agent_configurations_resolver.rb2
-rw-r--r--app/graphql/resolvers/package_pipelines_resolver.rb57
-rw-r--r--app/graphql/resolvers/project_jobs_resolver.rb1
-rw-r--r--app/graphql/resolvers/project_pipeline_resolver.rb7
-rw-r--r--app/graphql/resolvers/project_pipelines_resolver.rb2
-rw-r--r--app/graphql/resolvers/snippets/blobs_resolver.rb2
-rw-r--r--app/graphql/resolvers/users/participants_resolver.rb13
-rw-r--r--app/graphql/types/base_edge.rb7
-rw-r--r--app/graphql/types/base_field.rb2
-rw-r--r--app/graphql/types/base_object.rb1
-rw-r--r--app/graphql/types/boards/board_issue_input_type.rb4
-rw-r--r--app/graphql/types/ci/build_need_type.rb2
-rw-r--r--app/graphql/types/ci/job_need_union.rb21
-rw-r--r--app/graphql/types/ci/job_type.rb28
-rw-r--r--app/graphql/types/ci/pipeline_type.rb10
-rw-r--r--app/graphql/types/ci/runner_status_enum.rb49
-rw-r--r--app/graphql/types/ci/runner_type.rb9
-rw-r--r--app/graphql/types/ci/runner_web_url_edge.rb2
-rw-r--r--app/graphql/types/ci/stage_type.rb7
-rw-r--r--app/graphql/types/ci/test_case_type.rb2
-rw-r--r--app/graphql/types/ci/test_report_total_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.rb38
-rw-r--r--app/graphql/types/clusters/agent_type.rb6
-rw-r--r--app/graphql/types/container_repository_details_type.rb9
-rw-r--r--app/graphql/types/container_repository_tags_sort_enum.rb11
-rw-r--r--app/graphql/types/issue_type.rb3
-rw-r--r--app/graphql/types/issue_type_enum.rb2
-rw-r--r--app/graphql/types/merge_request_connection_type.rb2
-rw-r--r--app/graphql/types/merge_request_type.rb10
-rw-r--r--app/graphql/types/namespace_type.rb3
-rw-r--r--app/graphql/types/notes/note_type.rb4
-rw-r--r--app/graphql/types/packages/package_details_type.rb7
-rw-r--r--app/graphql/types/packages/package_type.rb3
-rw-r--r--app/graphql/types/project_statistics_type.rb20
-rw-r--r--app/graphql/types/project_type.rb7
-rw-r--r--app/graphql/types/repository/blob_type.rb9
-rw-r--r--app/graphql/types/root_storage_statistics_type.rb18
-rw-r--r--app/graphql/types/subscription_type.rb3
-rw-r--r--app/graphql/types/user_callout_feature_name_enum.rb2
-rw-r--r--app/helpers/access_tokens_helper.rb23
-rw-r--r--app/helpers/application_helper.rb4
-rw-r--r--app/helpers/auth_helper.rb34
-rw-r--r--app/helpers/badges_helper.rb99
-rw-r--r--app/helpers/blame_helper.rb15
-rw-r--r--app/helpers/boards_helper.rb9
-rw-r--r--app/helpers/ci/jobs_helper.rb7
-rw-r--r--app/helpers/ci/runners_helper.rb2
-rw-r--r--app/helpers/diff_helper.rb2
-rw-r--r--app/helpers/export_helper.rb2
-rw-r--r--app/helpers/form_helper.rb8
-rw-r--r--app/helpers/ide_helper.rb2
-rw-r--r--app/helpers/integrations_helper.rb54
-rw-r--r--app/helpers/invite_members_helper.rb36
-rw-r--r--app/helpers/issuables_description_templates_helper.rb2
-rw-r--r--app/helpers/issuables_helper.rb11
-rw-r--r--app/helpers/issues_helper.rb4
-rw-r--r--app/helpers/jira_connect_helper.rb3
-rw-r--r--app/helpers/learn_gitlab_helper.rb7
-rw-r--r--app/helpers/markup_helper.rb2
-rw-r--r--app/helpers/merge_requests_helper.rb2
-rw-r--r--app/helpers/namespaces_helper.rb18
-rw-r--r--app/helpers/nav/new_dropdown_helper.rb11
-rw-r--r--app/helpers/notifications_helper.rb1
-rw-r--r--app/helpers/notify_helper.rb17
-rw-r--r--app/helpers/operations_helper.rb2
-rw-r--r--app/helpers/packages_helper.rb11
-rw-r--r--app/helpers/profiles_helper.rb5
-rw-r--r--app/helpers/projects/cluster_agents_helper.rb3
-rw-r--r--app/helpers/projects_helper.rb19
-rw-r--r--app/helpers/routing/graphql_helper.rb4
-rw-r--r--app/helpers/routing/pseudonymization_helper.rb11
-rw-r--r--app/helpers/sorting_helper.rb25
-rw-r--r--app/helpers/system_note_helper.rb4
-rw-r--r--app/helpers/tab_helper.rb36
-rw-r--r--app/helpers/time_zone_helper.rb5
-rw-r--r--app/helpers/tree_helper.rb2
-rw-r--r--app/helpers/user_callouts_helper.rb98
-rw-r--r--app/helpers/users/callouts_helper.rb71
-rw-r--r--app/helpers/users/group_callouts_helper.rb32
-rw-r--r--app/helpers/version_check_helper.rb2
-rw-r--r--app/helpers/x509_helper.rb2
-rw-r--r--app/mailers/emails/in_product_marketing.rb4
-rw-r--r--app/mailers/emails/issues.rb28
-rw-r--r--app/mailers/emails/members.rb29
-rw-r--r--app/mailers/emails/merge_requests.rb47
-rw-r--r--app/mailers/emails/notes.rb15
-rw-r--r--app/mailers/emails/projects.rb8
-rw-r--r--app/mailers/emails/releases.rb5
-rw-r--r--app/models/active_session.rb174
-rw-r--r--app/models/analytics/cycle_analytics/project_stage.rb6
-rw-r--r--app/models/application_record.rb4
-rw-r--r--app/models/application_setting.rb8
-rw-r--r--app/models/application_setting_implementation.rb8
-rw-r--r--app/models/bulk_imports/entity.rb30
-rw-r--r--app/models/bulk_imports/file_transfer/base_config.rb23
-rw-r--r--app/models/bulk_imports/file_transfer/project_config.rb6
-rw-r--r--app/models/bulk_imports/tracker.rb13
-rw-r--r--app/models/chat_name.rb4
-rw-r--r--app/models/ci/build.rb27
-rw-r--r--app/models/ci/job_artifact.rb9
-rw-r--r--app/models/ci/namespace_mirror.rb37
-rw-r--r--app/models/ci/pending_build.rb12
-rw-r--r--app/models/ci/pipeline.rb62
-rw-r--r--app/models/ci/project_mirror.rb16
-rw-r--r--app/models/ci/runner.rb77
-rw-r--r--app/models/ci/runner_namespace.rb1
-rw-r--r--app/models/ci/runner_project.rb1
-rw-r--r--app/models/ci/stage.rb1
-rw-r--r--app/models/clusters/agent.rb8
-rw-r--r--app/models/clusters/agent_token.rb17
-rw-r--r--app/models/clusters/agents/activity_event.rb37
-rw-r--r--app/models/clusters/applications/runner.rb31
-rw-r--r--app/models/clusters/platforms/kubernetes.rb19
-rw-r--r--app/models/commit.rb38
-rw-r--r--app/models/commit_signatures/gpg_signature.rb53
-rw-r--r--app/models/commit_signatures/x509_commit_signature.rb16
-rw-r--r--app/models/commit_status.rb26
-rw-r--r--app/models/concerns/after_commit_queue.rb50
-rw-r--r--app/models/concerns/calloutable.rb15
-rw-r--r--app/models/concerns/ci/contextable.rb27
-rw-r--r--app/models/concerns/commit_signature.rb50
-rw-r--r--app/models/concerns/diff_positionable_note.rb9
-rw-r--r--app/models/concerns/enums/ci/commit_status.rb1
-rw-r--r--app/models/concerns/import_state/sidekiq_job_tracker.rb2
-rw-r--r--app/models/concerns/incident_management/escalatable.rb2
-rw-r--r--app/models/concerns/issuable.rb18
-rw-r--r--app/models/concerns/loose_foreign_key.rb79
-rw-r--r--app/models/concerns/merge_request_reviewer_state.rb6
-rw-r--r--app/models/concerns/packages/debian/component_file.rb8
-rw-r--r--app/models/concerns/participable.rb18
-rw-r--r--app/models/concerns/partitioned_table.rb3
-rw-r--r--app/models/concerns/relative_positioning.rb18
-rw-r--r--app/models/concerns/resolvable_discussion.rb5
-rw-r--r--app/models/concerns/sha_attribute.rb5
-rw-r--r--app/models/concerns/token_authenticatable_strategies/encryption_helper.rb2
-rw-r--r--app/models/concerns/transactions.rb12
-rw-r--r--app/models/container_repository.rb11
-rw-r--r--app/models/context_commits_diff.rb1
-rw-r--r--app/models/customer_relations/contact.rb7
-rw-r--r--app/models/customer_relations/issue_contact.rb8
-rw-r--r--app/models/deployment.rb16
-rw-r--r--app/models/dev_ops_report/metric.rb14
-rw-r--r--app/models/environment.rb2
-rw-r--r--app/models/error_tracking/error_event.rb3
-rw-r--r--app/models/event.rb9
-rw-r--r--app/models/gpg_key.rb6
-rw-r--r--app/models/gpg_signature.rb86
-rw-r--r--app/models/group.rb10
-rw-r--r--app/models/hooks/project_hook.rb11
-rw-r--r--app/models/hooks/web_hook.rb32
-rw-r--r--app/models/incident_management/issuable_escalation_status.rb3
-rw-r--r--app/models/instance_configuration.rb1
-rw-r--r--app/models/integration.rb6
-rw-r--r--app/models/integrations/base_issue_tracker.rb2
-rw-r--r--app/models/integrations/jira.rb54
-rw-r--r--app/models/integrations/shimo.rb13
-rw-r--r--app/models/issue.rb43
-rw-r--r--app/models/issue/email.rb10
-rw-r--r--app/models/lfs_object.rb1
-rw-r--r--app/models/lfs_objects_project.rb12
-rw-r--r--app/models/loose_foreign_keys/deleted_record.rb36
-rw-r--r--app/models/member.rb17
-rw-r--r--app/models/members/group_member.rb11
-rw-r--r--app/models/members/project_member.rb11
-rw-r--r--app/models/members_preloader.rb2
-rw-r--r--app/models/merge_request.rb39
-rw-r--r--app/models/merge_request_assignee.rb6
-rw-r--r--app/models/merge_request_diff.rb2
-rw-r--r--app/models/merge_request_reviewer.rb6
-rw-r--r--app/models/namespace.rb26
-rw-r--r--app/models/namespaces/project_namespace.rb4
-rw-r--r--app/models/namespaces/sync_event.rb16
-rw-r--r--app/models/namespaces/traversal/linear.rb38
-rw-r--r--app/models/namespaces/traversal/linear_scopes.rb29
-rw-r--r--app/models/namespaces/traversal/recursive.rb1
-rw-r--r--app/models/namespaces/user_namespace.rb2
-rw-r--r--app/models/note.rb11
-rw-r--r--app/models/notification_reason.rb2
-rw-r--r--app/models/packages/build_info.rb6
-rw-r--r--app/models/packages/conan/metadatum.rb27
-rw-r--r--app/models/postgresql/replication_slot.rb2
-rw-r--r--app/models/preloaders/group_policy_preloader.rb7
-rw-r--r--app/models/preloaders/group_root_ancestor_preloader.rb32
-rw-r--r--app/models/project.rb75
-rw-r--r--app/models/project_authorization.rb20
-rw-r--r--app/models/project_feature.rb46
-rw-r--r--app/models/project_setting.rb1
-rw-r--r--app/models/projects/sync_event.rb16
-rw-r--r--app/models/repository.rb2
-rw-r--r--app/models/serverless/domain.rb2
-rw-r--r--app/models/snippet.rb169
-rw-r--r--app/models/system_note_metadata.rb1
-rw-r--r--app/models/todo.rb1
-rw-r--r--app/models/u2f_registration.rb6
-rw-r--r--app/models/user.rb54
-rw-r--r--app/models/user_callout.rb47
-rw-r--r--app/models/user_detail.rb3
-rw-r--r--app/models/users/callout.rb51
-rw-r--r--app/models/users/calloutable.rb17
-rw-r--r--app/models/users/group_callout.rb2
-rw-r--r--app/models/wiki_page.rb2
-rw-r--r--app/models/work_item/type.rb7
-rw-r--r--app/models/x509_certificate.rb2
-rw-r--r--app/models/x509_commit_signature.rb48
-rw-r--r--app/policies/clusters/agents/activity_event_policy.rb11
-rw-r--r--app/policies/group_policy.rb16
-rw-r--r--app/policies/namespace_policy.rb3
-rw-r--r--app/policies/namespaces/group_project_namespace_shared_policy.rb9
-rw-r--r--app/policies/namespaces/project_namespace_policy.rb2
-rw-r--r--app/policies/namespaces/user_namespace_policy.rb5
-rw-r--r--app/presenters/blob_presenter.rb53
-rw-r--r--app/presenters/ci/pipeline_presenter.rb16
-rw-r--r--app/presenters/commit_status_presenter.rb3
-rw-r--r--app/presenters/merge_request_presenter.rb11
-rw-r--r--app/presenters/packages/npm/package_presenter.rb10
-rw-r--r--app/presenters/projects/security/configuration_presenter.rb98
-rw-r--r--app/presenters/prometheus_alert_presenter.rb2
-rw-r--r--app/presenters/snippet_blob_presenter.rb2
-rw-r--r--app/serializers/analytics/cycle_analytics/stage_entity.rb16
-rw-r--r--app/serializers/build_details_entity.rb6
-rw-r--r--app/serializers/build_serializer.rb10
-rw-r--r--app/serializers/ci/job_entity.rb96
-rw-r--r--app/serializers/ci/job_serializer.rb12
-rw-r--r--app/serializers/ci/pipeline_entity.rb4
-rw-r--r--app/serializers/deployment_entity.rb10
-rw-r--r--app/serializers/deployment_serializer.rb2
-rw-r--r--app/serializers/diff_file_entity.rb2
-rw-r--r--app/serializers/job_entity.rb94
-rw-r--r--app/serializers/job_group_entity.rb2
-rw-r--r--app/serializers/member_entity.rb6
-rw-r--r--app/serializers/merge_request_widget_entity.rb2
-rw-r--r--app/serializers/merge_requests/pipeline_entity.rb4
-rw-r--r--app/serializers/note_entity.rb2
-rw-r--r--app/serializers/stage_entity.rb4
-rw-r--r--app/services/admin/propagate_service_template.rb11
-rw-r--r--app/services/audit_event_service.rb11
-rw-r--r--app/services/auth/container_registry_authentication_service.rb2
-rw-r--r--app/services/authorized_project_update/find_records_due_for_refresh_service.rb6
-rw-r--r--app/services/authorized_project_update/project_group_link_create_service.rb12
-rw-r--r--app/services/authorized_project_update/project_recalculate_service.rb12
-rw-r--r--app/services/auto_merge/base_service.rb2
-rw-r--r--app/services/bulk_imports/create_service.rb2
-rw-r--r--app/services/bulk_imports/relation_export_service.rb2
-rw-r--r--app/services/bulk_imports/tree_export_service.rb8
-rw-r--r--app/services/bulk_imports/uploads_export_service.rb3
-rw-r--r--app/services/ci/create_pipeline_service.rb40
-rw-r--r--app/services/ci/expire_pipeline_cache_service.rb27
-rw-r--r--app/services/ci/job_artifacts/destroy_all_expired_service.rb40
-rw-r--r--app/services/ci/parse_dotenv_artifact_service.rb8
-rw-r--r--app/services/ci/pipeline_processing/atomic_processing_service.rb4
-rw-r--r--app/services/ci/play_build_service.rb2
-rw-r--r--app/services/ci/process_sync_events_service.rb58
-rw-r--r--app/services/ci/queue/build_queue_service.rb10
-rw-r--r--app/services/ci/queue/builds_table_strategy.rb10
-rw-r--r--app/services/ci/queue/pending_builds_strategy.rb18
-rw-r--r--app/services/ci/register_job_service.rb9
-rw-r--r--app/services/ci/retry_build_service.rb38
-rw-r--r--app/services/ci/stuck_builds/drop_pending_service.rb11
-rw-r--r--app/services/ci/update_build_queue_service.rb12
-rw-r--r--app/services/ci/update_build_state_service.rb5
-rw-r--r--app/services/ci/update_pending_build_service.rb4
-rw-r--r--app/services/clusters/agent_tokens/create_service.rb12
-rw-r--r--app/services/clusters/cleanup/project_namespace_service.rb6
-rw-r--r--app/services/clusters/cleanup/service_account_service.rb4
-rw-r--r--app/services/cohorts_service.rb2
-rw-r--r--app/services/concerns/admin/propagate_service.rb37
-rw-r--r--app/services/concerns/audit_event_save_type.rb26
-rw-r--r--app/services/concerns/protected_ref_name_sanitizer.rb12
-rw-r--r--app/services/dependency_proxy/find_cached_manifest_service.rb (renamed from app/services/dependency_proxy/find_or_create_manifest_service.rb)27
-rw-r--r--app/services/dependency_proxy/pull_manifest_service.rb47
-rw-r--r--app/services/deployments/older_deployments_drop_service.rb2
-rw-r--r--app/services/events/destroy_service.rb21
-rw-r--r--app/services/feature_flags/base_service.rb1
-rw-r--r--app/services/git/branch_hooks_service.rb4
-rw-r--r--app/services/git/branch_push_service.rb8
-rw-r--r--app/services/git/process_ref_changes_service.rb9
-rw-r--r--app/services/google_cloud/service_accounts_service.rb25
-rw-r--r--app/services/gravatar_service.rb2
-rw-r--r--app/services/groups/transfer_service.rb12
-rw-r--r--app/services/groups/update_shared_runners_service.rb16
-rw-r--r--app/services/import/github_service.rb11
-rw-r--r--app/services/incident_management/issuable_escalation_statuses/create_service.rb36
-rw-r--r--app/services/integrations/propagate_service.rb (renamed from app/services/admin/propagate_integration_service.rb)30
-rw-r--r--app/services/integrations/propagate_template_service.rb10
-rw-r--r--app/services/issuable_base_service.rb19
-rw-r--r--app/services/issues/base_service.rb2
-rw-r--r--app/services/issues/create_service.rb7
-rw-r--r--app/services/issues/set_crm_contacts_service.rb81
-rw-r--r--app/services/loose_foreign_keys/batch_cleaner_service.rb41
-rw-r--r--app/services/loose_foreign_keys/cleaner_service.rb30
-rw-r--r--app/services/loose_foreign_keys/process_deleted_records_service.rb33
-rw-r--r--app/services/members/create_service.rb12
-rw-r--r--app/services/members/creator_service.rb1
-rw-r--r--app/services/merge_requests/after_create_service.rb27
-rw-r--r--app/services/merge_requests/approval_service.rb1
-rw-r--r--app/services/merge_requests/base_service.rb14
-rw-r--r--app/services/merge_requests/bulk_remove_attention_requested_service.rb22
-rw-r--r--app/services/merge_requests/close_service.rb1
-rw-r--r--app/services/merge_requests/create_pipeline_service.rb2
-rw-r--r--app/services/merge_requests/handle_assignees_change_service.rb2
-rw-r--r--app/services/merge_requests/outdated_discussion_diff_lines_service.rb22
-rw-r--r--app/services/merge_requests/post_merge_service.rb1
-rw-r--r--app/services/merge_requests/rebase_service.rb18
-rw-r--r--app/services/merge_requests/remove_attention_requested_service.rb41
-rw-r--r--app/services/merge_requests/resolved_discussion_notification_service.rb1
-rw-r--r--app/services/merge_requests/squash_service.rb6
-rw-r--r--app/services/merge_requests/toggle_attention_requested_service.rb16
-rw-r--r--app/services/namespaces/invite_team_email_service.rb3
-rw-r--r--app/services/notification_recipients/build_service.rb4
-rw-r--r--app/services/notification_recipients/builder/attention_requested.rb23
-rw-r--r--app/services/notification_service.rb8
-rw-r--r--app/services/packages/debian/generate_distribution_service.rb2
-rw-r--r--app/services/packages/npm/create_package_service.rb4
-rw-r--r--app/services/pages/zip_directory_service.rb4
-rw-r--r--app/services/projects/destroy_service.rb26
-rw-r--r--app/services/projects/prometheus/alerts/create_service.rb2
-rw-r--r--app/services/projects/prometheus/alerts/destroy_service.rb2
-rw-r--r--app/services/projects/prometheus/alerts/update_service.rb2
-rw-r--r--app/services/projects/transfer_service.rb14
-rw-r--r--app/services/protected_branches/base_service.rb17
-rw-r--r--app/services/protected_tags/base_service.rb16
-rw-r--r--app/services/protected_tags/create_service.rb4
-rw-r--r--app/services/protected_tags/update_service.rb4
-rw-r--r--app/services/repositories/changelog_service.rb12
-rw-r--r--app/services/search_service.rb62
-rw-r--r--app/services/service_ping/devops_report_service.rb26
-rw-r--r--app/services/service_ping/submit_service.rb40
-rw-r--r--app/services/system_note_service.rb20
-rw-r--r--app/services/system_notes/issuables_service.rb101
-rw-r--r--app/services/todos/destroy/private_features_service.rb46
-rw-r--r--app/services/todos/destroy/unauthorized_features_service.rb43
-rw-r--r--app/services/users/dismiss_callout_service.rb (renamed from app/services/users/dismiss_user_callout_service.rb)2
-rw-r--r--app/services/users/dismiss_group_callout_service.rb2
-rw-r--r--app/services/users/refresh_authorized_projects_service.rb8
-rw-r--r--app/services/verify_pages_domain_service.rb3
-rw-r--r--app/uploaders/lfs_object_uploader.rb2
-rw-r--r--app/validators/json_schemas/error_tracking_event_payload.json2
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml1
-rw-r--r--app/views/admin/application_settings/_package_registry.html.haml3
-rw-r--r--app/views/admin/application_settings/_snowplow.html.haml6
-rw-r--r--app/views/admin/application_settings/network.html.haml21
-rw-r--r--app/views/admin/dashboard/_security_newsletter_callout.html.haml2
-rw-r--r--app/views/admin/dashboard/index.html.haml2
-rw-r--r--app/views/admin/groups/show.html.haml14
-rw-r--r--app/views/admin/hooks/edit.html.haml2
-rw-r--r--app/views/admin/labels/index.html.haml24
-rw-r--r--app/views/admin/projects/_archived.html.haml3
-rw-r--r--app/views/admin/projects/_projects.html.haml3
-rw-r--r--app/views/admin/projects/show.html.haml4
-rw-r--r--app/views/admin/users/_access_levels.html.haml5
-rw-r--r--app/views/admin/users/_user_detail.html.haml20
-rw-r--r--app/views/admin/users/projects.html.haml4
-rw-r--r--app/views/admin/users/show.html.haml2
-rw-r--r--app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml2
-rw-r--r--app/views/clusters/clusters/show.html.haml5
-rw-r--r--app/views/devise/confirmations/almost_there.haml2
-rw-r--r--app/views/devise/sessions/new.html.haml2
-rw-r--r--app/views/devise/shared/_tab_single.html.haml5
-rw-r--r--app/views/groups/_import_group_from_file_panel.html.haml14
-rw-r--r--app/views/groups/_invite_members_side_nav_link.html.haml1
-rw-r--r--app/views/groups/_personalize.html.haml2
-rw-r--r--app/views/groups/_project_badges.html.haml2
-rw-r--r--app/views/groups/crm/contacts.html.haml4
-rw-r--r--app/views/groups/crm/contacts/index.html.haml4
-rw-r--r--app/views/groups/crm/organizations.html.haml4
-rw-r--r--app/views/groups/crm/organizations/index.html.haml4
-rw-r--r--app/views/groups/packages/index.html.haml6
-rw-r--r--app/views/groups/projects.html.haml5
-rw-r--r--app/views/groups/registry/repositories/index.html.haml4
-rw-r--r--app/views/groups/runners/_group_runners.html.haml2
-rw-r--r--app/views/groups/settings/_advanced.html.haml5
-rw-r--r--app/views/groups/settings/_export.html.haml14
-rw-r--r--app/views/groups/settings/_transfer.html.haml11
-rw-r--r--app/views/groups/settings/ci_cd/_auto_devops_form.html.haml2
-rw-r--r--app/views/groups/settings/packages_and_registries/show.html.haml3
-rw-r--r--app/views/groups/show.html.haml2
-rw-r--r--app/views/help/instance_configuration/_package_registry.html.haml4
-rw-r--r--app/views/import/bitbucket/deploy_key.js.haml3
-rw-r--r--app/views/jira_connect/subscriptions/index.html.haml7
-rw-r--r--app/views/layouts/_flash.html.haml4
-rw-r--r--app/views/layouts/_google_tag_manager_body.html.haml2
-rw-r--r--app/views/layouts/_google_tag_manager_head.html.haml15
-rw-r--r--app/views/layouts/_page.html.haml2
-rw-r--r--app/views/layouts/_snowplow.html.haml6
-rw-r--r--app/views/layouts/header/_current_user_dropdown.html.haml2
-rw-r--r--app/views/layouts/header/_default.html.haml16
-rw-r--r--app/views/layouts/header/_help_dropdown.html.haml2
-rw-r--r--app/views/layouts/header/_registration_enabled_callout.html.haml2
-rw-r--r--app/views/layouts/in_product_marketing_mailer.html.haml194
-rw-r--r--app/views/notify/_note_email.html.haml2
-rw-r--r--app/views/notify/account_validation_email.html.haml16
-rw-r--r--app/views/notify/account_validation_email.text.erb15
-rw-r--r--app/views/notify/attention_requested_merge_request_email.html.haml2
-rw-r--r--app/views/notify/attention_requested_merge_request_email.text.erb1
-rw-r--r--app/views/notify/in_product_marketing_email.html.haml286
-rw-r--r--app/views/notify/issue_due_email.html.haml2
-rw-r--r--app/views/notify/member_invited_email.html.haml20
-rw-r--r--app/views/notify/new_issue_email.html.haml2
-rw-r--r--app/views/notify/new_merge_request_email.html.haml2
-rw-r--r--app/views/notify/new_release_email.html.haml2
-rw-r--r--app/views/notify/service_desk_new_note_email.html.haml2
-rw-r--r--app/views/profiles/accounts/_providers.html.haml7
-rw-r--r--app/views/profiles/keys/_form.html.haml5
-rw-r--r--app/views/profiles/notifications/_group_settings.html.haml2
-rw-r--r--app/views/profiles/personal_access_tokens/index.html.haml116
-rw-r--r--app/views/profiles/show.html.haml2
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml2
-rw-r--r--app/views/projects/_files.html.haml5
-rw-r--r--app/views/projects/_invite_members_side_nav_link.html.haml1
-rw-r--r--app/views/projects/_merge_request_merge_commit_template.html.haml4
-rw-r--r--app/views/projects/_merge_request_settings.html.haml2
-rw-r--r--app/views/projects/_merge_request_squash_commit_template.html.haml16
-rw-r--r--app/views/projects/_new_project_fields.html.haml23
-rw-r--r--app/views/projects/_project_templates.html.haml2
-rw-r--r--app/views/projects/_remove_fork.html.haml5
-rw-r--r--app/views/projects/_transfer.html.haml10
-rw-r--r--app/views/projects/blame/show.html.haml2
-rw-r--r--app/views/projects/blob/_content.html.haml1
-rw-r--r--app/views/projects/blob/_header.html.haml1
-rw-r--r--app/views/projects/blob/show.html.haml4
-rw-r--r--app/views/projects/branches/index.html.haml2
-rw-r--r--app/views/projects/buttons/_dropdown.html.haml42
-rw-r--r--app/views/projects/buttons/_fork.html.haml14
-rw-r--r--app/views/projects/ci/builds/_build.html.haml11
-rw-r--r--app/views/projects/commit/pipelines.html.haml1
-rw-r--r--app/views/projects/compare/index.html.haml2
-rw-r--r--app/views/projects/default_branch/_show.html.haml2
-rw-r--r--app/views/projects/diffs/_content.html.haml1
-rw-r--r--app/views/projects/diffs/_text_file.html.haml2
-rw-r--r--app/views/projects/feature_flags/new.html.haml4
-rw-r--r--app/views/projects/forks/index.html.haml44
-rw-r--r--app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml6
-rw-r--r--app/views/projects/google_cloud/errors/gcp_error.html.haml6
-rw-r--r--app/views/projects/google_cloud/errors/no_gcp_projects.html.haml6
-rw-r--r--app/views/projects/google_cloud/service_accounts/index.html.haml8
-rw-r--r--app/views/projects/hooks/edit.html.haml4
-rw-r--r--app/views/projects/integrations/shimos/show.html.haml10
-rw-r--r--app/views/projects/issues/_issue.html.haml8
-rw-r--r--app/views/projects/issues/_new_branch.html.haml2
-rw-r--r--app/views/projects/issues/_related_branches.html.haml2
-rw-r--r--app/views/projects/issues/index.html.haml2
-rw-r--r--app/views/projects/jobs/show.html.haml5
-rw-r--r--app/views/projects/learn_gitlab/index.html.haml3
-rw-r--r--app/views/projects/merge_requests/_merge_request.html.haml5
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml2
-rw-r--r--app/views/projects/merge_requests/creations/_new_submit.html.haml6
-rw-r--r--app/views/projects/merge_requests/index.html.haml2
-rw-r--r--app/views/projects/merge_requests/invalid.html.haml29
-rw-r--r--app/views/projects/merge_requests/show.html.haml8
-rw-r--r--app/views/projects/mirrors/_disabled_mirror_badge.html.haml3
-rw-r--r--app/views/projects/mirrors/_regenerate_public_ssh_key_confirm_modal.html.haml13
-rw-r--r--app/views/projects/network/show.json.erb2
-rw-r--r--app/views/projects/packages/packages/index.html.haml6
-rw-r--r--app/views/projects/pages_domains/_dns.html.haml2
-rw-r--r--app/views/projects/pipeline_schedules/edit.html.haml2
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml31
-rw-r--r--app/views/projects/pipelines/charts.html.haml5
-rw-r--r--app/views/projects/pipelines/show.html.haml3
-rw-r--r--app/views/projects/protected_branches/shared/_matching_branch.html.haml2
-rw-r--r--app/views/projects/protected_tags/shared/_matching_tag.html.haml2
-rw-r--r--app/views/projects/protected_tags/shared/_protected_tag.html.haml2
-rw-r--r--app/views/projects/registry/repositories/index.html.haml4
-rw-r--r--app/views/projects/remove_fork.js.haml2
-rw-r--r--app/views/projects/runners/_group_runners.html.haml2
-rw-r--r--app/views/projects/runners/_runner.html.haml3
-rw-r--r--app/views/projects/services/_form.html.haml2
-rw-r--r--app/views/projects/services/prometheus/_custom_metrics.html.haml4
-rw-r--r--app/views/projects/services/prometheus/_metrics.html.haml4
-rw-r--r--app/views/projects/settings/access_tokens/index.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/_autodevops_form.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml23
-rw-r--r--app/views/projects/show.html.haml1
-rw-r--r--app/views/projects/starrers/_starrer.html.haml2
-rw-r--r--app/views/projects/tags/_tag.html.haml13
-rw-r--r--app/views/projects/tags/show.html.haml3
-rw-r--r--app/views/projects/tracings/show.html.haml10
-rw-r--r--app/views/projects/transfer.js.haml2
-rw-r--r--app/views/projects/usage_quotas/index.html.haml2
-rw-r--r--app/views/root/index.html.haml10
-rw-r--r--app/views/search/results/_blob.html.haml3
-rw-r--r--app/views/search/results/_blob_data.html.haml2
-rw-r--r--app/views/search/results/_blob_highlight.html.haml22
-rw-r--r--app/views/search/results/_issuable.html.haml2
-rw-r--r--app/views/shared/_email_with_badge.html.haml9
-rw-r--r--app/views/shared/_flash_user_callout.html.haml2
-rw-r--r--app/views/shared/_milestone_expired.html.haml6
-rw-r--r--app/views/shared/_milestones_filter.html.haml11
-rw-r--r--app/views/shared/_registration_features_discovery_message.html.haml9
-rw-r--r--app/views/shared/_service_settings.html.haml4
-rw-r--r--app/views/shared/_two_factor_auth_recovery_settings_check.html.haml2
-rw-r--r--app/views/shared/builds/_tabs.html.haml14
-rw-r--r--app/views/shared/doorkeeper/applications/_form.html.haml5
-rw-r--r--app/views/shared/doorkeeper/applications/_index.html.haml2
-rw-r--r--app/views/shared/form_elements/_description.html.haml2
-rw-r--r--app/views/shared/integrations/_form.html.haml2
-rw-r--r--app/views/shared/integrations/_index.html.haml2
-rw-r--r--app/views/shared/integrations/_tabs.html.haml2
-rw-r--r--app/views/shared/integrations/edit.html.haml2
-rw-r--r--app/views/shared/integrations/overrides.html.haml2
-rw-r--r--app/views/shared/issuable/_form.html.haml2
-rw-r--r--app/views/shared/issuable/_milestone_dropdown.html.haml12
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml6
-rw-r--r--app/views/shared/issuable/form/_default_templates.html.haml7
-rw-r--r--app/views/shared/issuable/form/_metadata.html.haml12
-rw-r--r--app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml2
-rw-r--r--app/views/shared/issuable/form/_title.html.haml2
-rw-r--r--app/views/shared/issuable/form/_type_selector.html.haml2
-rw-r--r--app/views/shared/members/_filter_2fa_dropdown.html.haml11
-rw-r--r--app/views/shared/members/_requests.html.haml2
-rw-r--r--app/views/shared/milestones/_milestone.html.haml6
-rw-r--r--app/views/shared/milestones/_sidebar.html.haml4
-rw-r--r--app/views/shared/milestones/_tabs.html.haml30
-rw-r--r--app/views/shared/nav/_scope_menu.html.haml2
-rw-r--r--app/views/shared/nav/_sidebar_hidden_menu_item.html.haml2
-rw-r--r--app/views/shared/nav/_sidebar_menu.html.haml2
-rw-r--r--app/views/shared/nav/_sidebar_menu_item.html.haml2
-rw-r--r--app/views/shared/projects/_archived.html.haml3
-rw-r--r--app/views/shared/projects/_project.html.haml2
-rw-r--r--app/views/shared/projects/_topics.html.haml17
-rw-r--r--app/views/shared/runners/_runner_description.html.haml4
-rw-r--r--app/views/shared/runners/_runner_details.html.haml3
-rw-r--r--app/views/shared/runners/_runner_type_badge.html.haml9
-rw-r--r--app/views/shared/snippets/_embed.html.haml2
-rw-r--r--app/views/shared/web_hooks/_hook.html.haml10
-rw-r--r--app/views/shared/web_hooks/_hook_errors.html.haml41
-rw-r--r--app/views/shared/web_hooks/_title_and_docs.html.haml2
-rw-r--r--app/views/shared/wikis/_wiki_content.html.haml2
-rw-r--r--app/views/shared/wikis/show.html.haml3
-rw-r--r--app/views/snippets/_snippets_scope_menu.html.haml41
-rw-r--r--app/views/users/_overview.html.haml4
-rw-r--r--app/views/users/calendar_activities.html.haml2
-rw-r--r--app/views/users/show.html.haml3
-rw-r--r--app/views/users/terms/index.html.haml27
-rw-r--r--app/workers/all_queues.yml51
-rw-r--r--app/workers/background_migration/single_database_worker.rb148
-rw-r--r--app/workers/background_migration_worker.rb117
-rw-r--r--app/workers/bulk_imports/entity_worker.rb5
-rw-r--r--app/workers/bulk_imports/pipeline_worker.rb4
-rw-r--r--app/workers/ci/create_downstream_pipeline_worker.rb1
-rw-r--r--app/workers/ci/pending_builds/update_group_worker.rb19
-rw-r--r--app/workers/ci/pending_builds/update_project_worker.rb19
-rw-r--r--app/workers/ci/pipeline_artifacts/create_quality_report_worker.rb2
-rw-r--r--app/workers/concerns/application_worker.rb4
-rw-r--r--app/workers/expire_job_cache_worker.rb15
-rw-r--r--app/workers/issuable_export_csv_worker.rb2
-rw-r--r--app/workers/issue_placement_worker.rb7
-rw-r--r--app/workers/issue_rebalancing_worker.rb3
-rw-r--r--app/workers/issues/rebalancing_worker.rb1
-rw-r--r--app/workers/issues/reschedule_stuck_issue_rebalances_worker.rb4
-rw-r--r--app/workers/namespaces/process_sync_events_worker.rb22
-rw-r--r--app/workers/projects/process_sync_events_worker.rb22
-rw-r--r--app/workers/propagate_integration_worker.rb2
-rw-r--r--app/workers/propagate_service_template_worker.rb29
-rw-r--r--app/workers/purge_dependency_proxy_cache_worker.rb11
-rw-r--r--app/workers/todos_destroyer/private_features_worker.rb2
1585 files changed, 17657 insertions, 10360 deletions
diff --git a/app/assets/images/logos/jira-gray.svg b/app/assets/images/logos/jira-gray.svg
deleted file mode 100644
index 0e7069f2bd2..00000000000
--- a/app/assets/images/logos/jira-gray.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg id="Logos" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80" height="80" viewBox="0 0 80 80"><defs><style>.cls-1{fill:#7a869a;}.cls-2{fill:url(#linear-gradient);}.cls-3{fill:url(#linear-gradient-2);}</style><linearGradient id="linear-gradient" x1="38.11" y1="18.54" x2="23.17" y2="33.48" gradientUnits="userSpaceOnUse"><stop offset="0.18" stop-color="#344563"/><stop offset="1" stop-color="#7a869a"/></linearGradient><linearGradient id="linear-gradient-2" x1="42.07" y1="61.47" x2="56.98" y2="46.55" xlink:href="#linear-gradient"/></defs><title>jira software-icon-gradient-neutral</title><path class="cls-1" d="M74.18,38,43,6.9l-3-3h0L16.58,27.32h0L5.86,38a2.86,2.86,0,0,0,0,4.05L27.28,63.51,40,76.25,63.47,52.81l.36-.36L74.18,42.09A2.86,2.86,0,0,0,74.18,38ZM40,50.77l-10.7-10.7L40,29.37l10.7,10.7Z"/><path class="cls-2" d="M40,29.37A18,18,0,0,1,40,4L16.54,27.37,29.28,40.11,40,29.37Z"/><path class="cls-3" d="M50.75,40,40,50.77a18,18,0,0,1,0,25.48h0L63.5,52.78Z"/></svg>
diff --git a/app/assets/images/logos/shimo.svg b/app/assets/images/logos/shimo.svg
new file mode 100644
index 00000000000..65bd1cc7167
--- /dev/null
+++ b/app/assets/images/logos/shimo.svg
@@ -0,0 +1 @@
+<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><path d="m7.99985 15.9997c4.41815 0 7.99985-3.5817 7.99985-7.99985 0-4.4182-3.5817-7.99985-7.99985-7.99985-4.4182 0-7.99985 3.58165-7.99985 7.99985 0 4.41815 3.58165 7.99985 7.99985 7.99985z" fill="#3f464a"/><g fill="#fff"><path d="m10.1501 3.13098c.0077.00805.017.01641.025.02601l.2988.34857c.0099.01099.0189.0228.0269.03529.0048.00966.0075.0202.0081.03096.0001.00853-.0027.01685-.0079.02359-.0053.00675-.0126.01153-.0209.01355-.0147.00206-.0295.0032-.0443.00341l-.70117.00991c-.24229.0035-.48457.0067-.72686.00959-.22104.00248-.44207.00372-.6631.00681-.20401.00248-.40801.0065-.61171.00991-.01269 0-.02538 0-.03807.00248-.00527 0-.01115.00959-.00867.01393.00455.00951.00972.01871.01548.02755.0552.07835.12273.14725.19998.204.0356.02662.07368.04984.11051.07523.01242.00804.02403.01727.03468.02755.00948.01029.01475.02377.01475.03777 0 .01399-.00527.02747-.01475.03776-.00419.00471-.00886.00896-.01394.0127-.01157.00943-.02097.02126-.02755.03467-.06748.11309-.13507.2266-.20276.34052-.10524.17487-.22146.34289-.34796.50305-.01089.01016-.01917.0228-.02415.03684-.00017.00237-.00017.00475 0 .00712.00497.00357.01026.00668.01579.00928.06192.0257.126.05077.18884.07678.01379.0055.02849.00834.04334.00836h.05758 1.5045.05108c.00857-.00025.01691-.00276.0242-.00727.00728-.00451.01325-.01087.01728-.01843.00743-.01284.01395-.02618.0195-.03993.02167-.0483.04303-.09721.0647-.1455.00487-.01181.01089-.02311.01796-.03374.00581-.00902.01482-.01551.02521-.01815.0104-.00264.02141-.00125.03082.00391l.02786.01548.33527.19688c.01106.00633.02144.0138.03095.02229.00321.00283.00551.00654.00661.01067s.00096.0085-.00042.01255c-.0034.0099-.00804.0195-.01238.0291-.0291.06191-.05696.12382-.08823.18388-.02023.03975-.03033.08388-.02941.12847v1.02003c0 .09999.00001.19998-.00247.29966-.00185.09338-.01054.1865-.02601.27861-.00686.04206-.01721.08348-.03095.12382-.02828.08507-.07696.16191-.14178.22382-.05123.04802-.10725.09065-.16717.12723-.01271.00783-.0275.01161-.04241.01084-.00639-.00011-.01258-.0022-.01773-.00596-.00516-.00377-.00902-.00904-.01106-.01509-.00248-.00805-.00403-.01641-.00588-.02477-.00918-.0392-.02055-.07785-.03406-.11578-.01986-.05201-.05688-.0957-.10494-.12382-.00743-.00402-.0157-.00625-.02415-.0065-.01485-.00078-.02972-.00078-.04457 0-.15912.00774-.31855.00743-.47798.00866l-.75875.00589c-.0743 0-.14859.00216-.22289.00402-.01637-.00159-.03289.00009-.0486.00495-.00376.00242-.00682.00578-.00888.00974-.00207.00397-.00306.0084-.00289.01286-.00557.03096-.0099.06192-.0164.09287-.01604.07268-.04132.14301-.07523.20927-.05921.11305-.13198.21846-.2167.3139-.0047.00582-.01055.01061-.01719.01407-.00663.00346-.01391.00551-.02137.00603-.00747.00053-.01496-.00049-.02201-.00299s-.01352-.00643-.01899-.01154c-.00892-.00618-.01504-.01564-.01702-.02631-.00186-.01023-.0031-.02057-.00372-.03096 0-.02321 0-.04674 0-.06996v-2.37841c0-.01702 0-.03436 0-.05107 0-.01672.00589-.03096-.0099-.04087-.01393.00279-.01951.01517-.02724.02477-.18029.22601-.37688.43853-.58818.63585-.29527.27883-.61431.53136-.95348.75473-.01764.01176-.0356.02321-.05386.03405-.00952.00504-.02063.00615-.03096.00309-.00515-.00121-.0099-.00373-.01381-.00731-.0039-.00357-.00682-.00809-.00848-.01312-.00253-.00507-.00335-.01082-.00234-.0164.001-.00558.00378-.01068.00792-.01455.01393-.01301.02848-.02539.04334-.03746.11722-.09494.23124-.19369.34207-.29626.3874-.36168.72258-.77551.99588-1.2296.16487-.27522.31301-.56013.44361-.85317.08668-.19162.16717-.38572.2458-.58075.02446-.06191.01207-.06191-.04055-.06191l-.34424.00898c-.20401.00557-.40771.01052-.61171.01671-.13157.00434-.26313.01084-.39501.01672-.01482.00051-.02963-.00115-.04396-.00495-.01457-.00344-.02781-.01109-.03808-.02198-.00588-.00619-.01145-.01239-.01702-.01889-.04768-.05407-.09525-.10845-.14271-.16314-.0068-.00812-.01271-.01694-.01765-.02631 0-.00217.00186-.00991.00372-.01022.0126-.00195.02532-.00299.03807-.00309h.31236.88629l.79033-.00248c.25715 0 .5143-.00093.77145-.00279l1.01383-.00247c.01268-.00078.0254-.00078.03808 0 .01271.00099.02548-.00098.03729-.00576.01182-.00478.02237-.01224.03081-.02179.08947-.08421.18017-.16779.27087-.25168.0105-.00929.0189-.0226.0378-.02446zm-2.54778 3.05791v.8476c0 .02105 0 .04241.00217.06191.00051.00421.00239.00813.00536.01115s.00685.00498.01105.00557c.01888 0 .03808.00185.05727.00185l1.44011-.00433h.03096c.01172.00058.02344-.00133.03438-.0056.01093-.00427.02085-.0108.02908-.01917.0125-.01146.02409-.02388.03467-.03714.03005-.03864.05291-.08237.06749-.12909.01811-.05894.02678-.12038.02569-.18203 0-.46745-.00062-.93489-.00186-1.40234 0-.01703 0-.03405 0-.05077-.0006-.00849-.00247-.01684-.00557-.02476-.00288-.0103-.0089-.01945-.01722-.02616-.00833-.00672-.01854-.01067-.02921-.0113-.01053 0-.02136 0-.03096 0-.10835 0-.2167-.00341-.32505-.00341-.32731 0-.65442 0-.98133 0-.08699 0-.17429.00403-.26127.00557-.01487.00085-.02941.00469-.04276.01129-.01335.00659-.02524.01581-.03495.0271-.01269.013-.0065.03096-.00712.04891-.00062.01796 0 .03096 0 .04458z"/><path d="m6.35047 8.7363c.01814-.00207.03651.00027.05356.00681.09287.02662.18574.05293.28046.08017.0365.01068.07435.016.11238.01579l1.99857.00186h.71695c.08978 0 .06904.0096.11269-.06377.02352-.04025.04674-.08049.07027-.12042.00261-.0047.00697-.00817.01212-.00968.00516-.00151.0107-.00092.01543.00163l.02786.01455c.0904.05077.18079.10154.27364.14766.0369.01827.0585.03096.0273.08389-.0217.03622-.0412.07399-.06195.11021-.01119.01807-.01659.03914-.01548.06037v.03095.686.03808c-.00102.00948-.00015.01906.00256.0282s.00721.01765.01323.02504c.00273.00326.00511.00679.00712.01053.00362.00653.00542.01385.00552.02129 0 .00745-.0018.01479-.00528.02136-.0035.00657-.00858.01217-.01479.01629-.0062.00412-.01333.00663-.02074.00731-.01681.00092-.03366.00092-.05046 0-.22413-.00155-.44857-.00403-.67269-.00496-.26221 0-.52441 0-.78661 0-.01889 0-.03808 0-.05696.00279-.00413.00087-.0079.00296-.01082.00599-.00293.00304-.00488.00688-.00559.01104v.01888.27864c-.00041.0103.00042.0207.00247.0309.00102.0029.00266.0055.00479.0077.00214.0022.00473.0039.0076.005.00802.0025.01639.0035.02476.0031h.91385.07058c.01269-.0118.02538-.0226.03715-.0343l.14704-.1496c.00743-.0074.01517-.0145.02291-.0216.00324-.0027.00729-.0041.01145-.0041.00417 0 .00821.0014.01146.0041.00495.0039.00971.0082.01424.0127l.26003.2513c.00873.0091.0168.0189.02415.0291.00302.0056.00381.0121.0022.0183-.00161.0061-.00549.0114-.01087.0148-.00971.005-.02017.0082-.03096.0096-.01888.0016-.03807 0-.05696 0l-1.36395.0062c-.10401 0-.09287-.0096-.09287.091v.2477c.00004.0104.00097.0207.00279.031.00141.0039.0043.0071.00804.0089.00382.0021.00805.0033.01239.0034h.04426l.69158.0034c.18822 0 .37643.0028.56465.0031.08699 0 .06625.0081.12878-.0489.05325-.0483.10587-.0972.15881-.1458.00864-.0105.02037-.018.03347-.0216.0063.0043.0124.0089.0182.0139.091.0913.1824.1826.2731.2743.0072.0075.0134.0159.0185.025.0016.0034.0025.007.0025.0107.0001.0037-.0007.0074-.0022.0108-.0015.0033-.0037.0063-.0065.0088-.0027.0024-.006.0043-.0095.0053-.0124.0025-.0249.0038-.0375.0041-.0551 0-.1099 0-.165 0-.36591-.0016-.73182-.0041-1.09742-.005-.82552-.0019-1.65041-.0032-2.47469-.004h-.35507c-.01084 0-.02136 0-.03096 0-.01145.0007-.02292-.0011-.03361-.0053s-.02034-.0106-.0283-.0189l-.10773-.1077c-.00121-.0016-.00194-.0035-.00212-.0055s.0002-.004.0011-.0058.00228-.0034.00399-.0044c.00171-.0011.00369-.0016.0057-.0016h.03807l.99619.0059.88815.0065h.05696c.00411-.0009.00789-.0029.01091-.0058s.00514-.0066.00612-.0107c0-.0065.00186-.0126.00186-.0188 0-.1016 0-.2031 0-.3047.00035-.0084-.00091-.0168-.00372-.0247-.00117-.0028-.00298-.0053-.00529-.0073s-.00506-.0034-.00802-.0042c-.02064-.0018-.04128-.0028-.06191-.0028-.20308 0-.40616 0-.60892 0-.22227 0-.44434-.0006-.66619-.0018-.00867 0-.01703 0-.02539 0-.01271.0007-.02541-.0016-.03715-.0065s-.02222-.0124-.03064-.022c-.03251-.0331-.06563-.0659-.09875-.0987-.00253-.0018-.00437-.0045-.00521-.0074-.00085-.003-.00065-.0062.00056-.0091.0031-.0068.00991-.0065.0161-.0065.13745 0 .2749 0 .41234.0016l1.04046.0093h.06346c.00406-.0008.00782-.0027.01088-.0055.00305-.0028.00529-.0063.00646-.0103.00128-.0083.00211-.0167.00247-.0251v-.27859c-.00036-.0084-.00119-.01677-.00247-.02508-.00083-.00414-.00291-.00793-.00595-.01087-.00304-.00293-.00691-.00486-.01108-.00554-.01269 0-.02538-.00185-.03808-.00185-.09503 0-.19038 0-.28542.00185-.29409.0031-.58817 0-.88195 0-.01672 0-.03375 0-.05077 0-.00839.00074-.01641.00373-.02322.00867-.01149.00973-.02244.02006-.03282.03096-.06408.05885-.13032.11455-.19936.16715-.01486.0118-.03095.0226-.04612.0334-.00516.0037-.01065.0068-.01641.0093-.00493.002-.01022.0027-.01549.0023s-.01038-.002-.01496-.0046c-.00457-.0026-.0085-.0063-.01148-.0106-.00298-.0044-.00494-.0094-.00574-.0146-.00155-.0254-.00186-.0508-.00186-.0762 0-.24515 0-.49053 0-.73612 0-.18388 0-.36787 0-.55196-.00287-.02176-.00329-.04377-.00124-.06562zm1.01724 1.09277h.53865.05696c.01888 0 .02693-.00805.02786-.02663.00093-.01857 0-.02941 0-.04426 0-.24085 0-.48169 0-.72253 0-.01703 0-.03375 0-.05077.00002-.00528-.00194-.01037-.0055-.01427-.00355-.0039-.00844-.00632-.01369-.00678-.0192 0-.03808 0-.05696 0h-1.10454-.04427c-.00315-.00036-.00635-.00003-.00937.00095-.00301.00098-.00579.00259-.00813.00473-.00235.00214-.00421.00476-.00546.00767-.00125.00292-.00187.00607-.0018.00925v.05696.70364.04427c.00019.00632.00071.01262.00154.01888.00054.00416.00234.00806.00516.01116.00283.0031.00654.00526.01063.00618.01888 0 .03808.00217.05696.00247zm1.6144-.86679h-.53401c-.07615 0-.07089-.00402-.07089.07337v.71571.04427c0 .02384.00774.03096.02941.03096h.25353c.00527.00006.01048-.00111.01521-.00342.00473-.00232.00885-.0057.01204-.00989l.00774-.00991c.08256-.11785.15112-.2449.20431-.3786.03491-.09285.06447-.18763.08854-.28387.00588-.0226.01083-.0452.01764-.06749.00366-.00982.00866-.0191.01486-.02755.00162-.00133.00353-.00225.00558-.00268s.00417-.00036.00618.00021c.00774.00295.01496.00713.02137.01238.05386.04736.10773.09534.16128.14302.03096.02662.06037.05293.08977.08018.01063.01026.02008.02168.02818.03405.00342.00436.00543.00966.00576.01519s-.00103.01103-.00391.01576c-.03387.06352-.07661.12189-.12692.17336-.03634.03538-.07502.06826-.11578.09844-.09345.06928-.19451.12765-.30121.17398-.01733.00774-.03436.0161-.05139.02446-.00136.00132-.00223.00307-.00247.00495 0 .00495.00186.00743.00743.00774s.01671 0 .02507 0h.75473c.01052 0 .02105 0 .03095-.00186.00413-.00058.00797-.00245.01097-.00534s.00501-.00666.00575-.01076c0-.02105.00217-.0421.00217-.06191 0-.23465 0-.4693 0-.70395 0-.01486 0-.02941 0-.04427 0-.0065 0-.01269-.00155-.01889-.00041-.00417-.00218-.00809-.00503-.01117-.00285-.00307-.00663-.00513-.01076-.00585-.01888 0-.03807-.00186-.05696-.00186z"/><path d="m9.7848 12.8222c.01332-.0133.02849-.0278.04304-.043.07181-.0749.14333-.1498.21666-.2241.0115-.0118.0208-.0273.0378-.031.0102.003.0192.0093.0254.018.1187.1238.237.2476.3551.3715.0056.0062.0108.0128.0154.0198.0021.0036.0034.0076.0036.0118.0003.0042-.0004.0083-.0019.0122-.0016.0038-.0041.0073-.0072.01-.0031.0028-.0068.0048-.0109.0059-.0062.0014-.0125.0023-.0189.0028h-.0507-.53403-2.46788c-.32133 0-.64277.0005-.9643.0015-.12909 0-.25787.0019-.38696.0028-.00836 0-.01703 0-.02539 0-.01167.0002-.02325-.0021-.03393-.0068-.01069-.0047-.02023-.0117-.02798-.0204-.03529-.0393-.0712-.078-.10649-.1173-.0009-.0018-.00137-.0037-.00137-.0056s.00047-.0039.00137-.0056c.00095-.0018.00231-.0033.00398-.0045s.00359-.002.00561-.0023c.01025-.0015.0206-.0022.03096-.0022h.29192c.12383 0 .24518.0016.36808.0019h.73584.64081c.01888 0 .03777 0 .05665-.0022.00412-.0007.0079-.0028.01084-.0057.00293-.003.00488-.0069.00557-.011 0-.0087.00155-.017.00186-.0254 0-.1238 0-.2486 0-.3742-.00017-.0105-.00142-.0208-.00372-.031 0-.0034-.00526-.0065-.00867-.0084-.00357-.0021-.0076-.0034-.01176-.0037-.01486 0-.03096 0-.04458 0-.25157.0023-.50315.0047-.75473.0072-.24332.0024-.48633.0058-.72965.0089h-.09504c-.00851.0005-.01703-.0008-.02503-.0038-.00799-.003-.01527-.0076-.0214-.0135-.04056-.0427-.07987-.0873-.11919-.1313-.00035-.0018-.00025-.0038.00029-.0056s.0015-.0035.00281-.0049c.00357-.0023.00782-.0034.01207-.0031h.05696 1.66208c.08544 0 .07987.0065.07987-.0777v-.5736c-.00217-.0216-.00017-.0433.00588-.0641.00849 0 .01698.0006.02538.0019l.36313.0882c.00433 0 .00804.0028.01207.0037.03436.0102.03808.0207.01641.048l-.03529.0446c-.00161.0026-.00306.0052-.00434.008v.0483.4059.0445c0 .0205.01238.0251.0291.026.01672.001.02539 0 .03808 0l1.10361.0019c.06625 0 .05263.0049.09968-.0406.05944-.0572.11826-.1151.17738-.1727.00898-.0084.01889-.0161.03096-.0251.01084.0093.02074.017.02941.026.08565.0877.17117.1757.25667.2638.0045.0044.0089.009.013.0139.0056.0048.0097.0113.0114.0185.0018.0073.0013.0149-.0015.0218-.003.0055-.0075.0102-.0129.0134-.0055.0031-.0118.0047-.0181.0045-.017 0-.0337 0-.0508 0l-.34884.0025-1.28781.0117c-.01671 0-.03374.0019-.05046.0028-.00407.0011-.00776.0032-.01062.0063s-.00476.0069-.00547.0111c-.00108.0083-.0017.0167-.00186.025v.3808.0189c.00025.0053.00238.0104.00601.0143.00362.0039.00851.0064.0138.0071.01239 0 .02508 0 .03777.0018h.13342l1.1355.0028c.02548.0022.05113.0016.07646-.0019z"/><path d="m10.1624 11.4382c0 .0068 0 .0173-.0034.0275-.0169.096-.0571.1864-.117.2632-.0199.0247-.04399.0456-.0712.0619-.01287.0073-.02646.0132-.04055.0176-.0171.0058-.03544.0069-.05309.0031-.01765-.0037-.03396-.0122-.04721-.0244-.02256-.0192-.04081-.0429-.05356-.0697-.00959-.0189-.01764-.0384-.02631-.0576-.03797-.087-.08086-.1719-.12847-.2541-.03249-.0568-.06912-.1111-.10958-.1625-.03991-.052-.08703-.0979-.13993-.1366-.00681-.0049-.01331-.0105-.01981-.0158-.0065-.0052-.00836-.0185-.0034-.0232.0041-.0044.00954-.0073.01547-.0083.01256-.0007.02514.0003.03746.0028.20435.0435.40349.1087.59405.1944.02293.0108.04583.0216.06813.0337.0148.008.0289.0173.0421.0279.0178.0144.0322.0328.0419.0536.0098.0208.0147.0435.0144.0665z"/><path d="m6.52884 11.8564c-.03474 0-.0692-.0061-.10185-.0179-.05603-.0186-.11082-.0406-.16593-.0619-.02512-.0109-.0466-.0287-.06191-.0514-.01313-.0178-.02058-.0392-.02135-.0613s.00516-.0439.01701-.0625c.01012-.0162.02313-.0304.03839-.0418.01362-.0102.02755-.0195.04148-.0294.06718-.0474.13374-.0957.1972-.1483.04368-.0362.08504-.075.12382-.1164.06313-.0654.11482-.141.15293-.2235.00464-.0096.00898-.0192.01393-.0285.00199-.0037.005-.0068.00867-.0089s.00786-.0031.01207-.0029c.00475.0022.0088.0056.0117.0099s.00453.0094.00471.0146v.0189c-.01207.1154-.02291.2309-.03715.3461-.01021.0817-.02414.1628-.03777.2439-.00493.027-.01207.0535-.02136.0793-.00757.0215-.01721.0422-.02879.0619-.01414.0258-.03538.0471-.06121.0613s-.05518.0208-.08459.0188z"/><path d="m8.77719 11.8183c-.01359 0-.02704-.0027-.03954-.008-.0125-.0054-.0238-.0131-.03321-.0229-.02061-.0215-.03597-.0475-.04489-.0759-.00619-.0176-.00991-.0365-.01486-.0548-.02659-.1042-.06042-.2065-.10123-.3061-.0195-.0469-.04204-.0924-.06749-.1362-.02726-.0477-.06067-.0917-.09937-.1307-.00743-.0074-.01398-.0158-.01951-.0247-.00151-.0038-.00151-.008 0-.0118.00118-.0029.00314-.0054.00565-.0073.00251-.0018.00548-.0029.00859-.0032.01082-.0005.02155.0021.03096.0074.13998.0538.27557.1184.40554.1932.05665.0331.11176.0684.16501.1065.01937.0129.03687.0284.052.0461.01496.0165.0256.0364.031.058.00541.0216.00539.0442-.00004.0658-.00271.0124-.00622.0246-.01052.0365-.0316.0787-.07999.1495-.14179.2074-.02664.0243-.05838.0423-.09287.0527-.01098.0033-.02214.006-.03343.008z"/><path d="m7.56893 11.8338c-.01297-.0002-.02575-.0032-.03742-.0088-.01166-.0057-.02193-.0139-.03006-.024-.01559-.0174-.02731-.0378-.03436-.0601-.00634-.0203-.01161-.041-.01579-.0619-.02196-.105-.05092-.2084-.08668-.3095-.01697-.0478-.03692-.0944-.05975-.1397-.02626-.053-.05981-.102-.09968-.1458-.0061-.0058-.01132-.0124-.01548-.0198-.00121-.0028-.00184-.0058-.00184-.0088s.00063-.006.00184-.0088c.00185-.0025.00417-.0045.00683-.0061.00266-.0015.0056-.0025.00865-.0029.0063-.0001.01251.0015.01796.0046.19823.0843.38537.1925.55722.3223.01674.0129.03266.0269.04767.0418.01878.0188.0319.0425.03787.0684.00596.0259.00453.0529-.00413.078-.00208.0082-.00488.0162-.00835.0239-.03509.0797-.08805.1504-.15479.2064-.02157.0169-.04544.0307-.07089.0409-.0188.007-.03876.0103-.05882.0099z"/><path d="m7.43301 9.75413c-.01598.00163-.0321-.0009-.04681-.00736-.0147-.00645-.02748-.01661-.03708-.02948-.01864-.02588-.03123-.0556-.03684-.08699-.01649-.08282-.03715-.16476-.06192-.24549-.01723-.0545-.03791-.10786-.06191-.15974-.02747-.06198-.06456-.11924-.1099-.16964-.00517-.00655-.00983-.01348-.01393-.02074-.00074-.00302-.00048-.00619.00075-.00904s.00336-.00522.00606-.00675c.0038-.00173.0079-.00268.01207-.00279.0083.00128.01641.00357.02415.00681.17685.07642.34421.17314.49872.28821.02291.01553.04371.03395.06191.0548.01255.0144.02159.0315.02642.04998.00483.01847.00533.03781.00145.05651-.00242.01464-.00647.02897-.01208.04272-.03176.07417-.08042.1399-.14209.19193-.01827.01444-.03836.02641-.05975.0356-.01563.0066-.03228.01048-.04922.01146z"/></g></svg>
diff --git a/app/assets/javascripts/access_tokens/components/token.vue b/app/assets/javascripts/access_tokens/components/token.vue
new file mode 100644
index 00000000000..3954e541fe0
--- /dev/null
+++ b/app/assets/javascripts/access_tokens/components/token.vue
@@ -0,0 +1,55 @@
+<script>
+import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue';
+
+export default {
+ components: { InputCopyToggleVisibility },
+ props: {
+ token: {
+ type: String,
+ required: true,
+ },
+ inputId: {
+ type: String,
+ required: true,
+ },
+ inputLabel: {
+ type: String,
+ required: true,
+ },
+ copyButtonTitle: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ formInputGroupProps() {
+ return { id: this.inputId };
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="row">
+ <div class="col-lg-12">
+ <hr />
+ </div>
+ <div class="col-lg-4">
+ <h4 class="gl-mt-0"><slot name="title"></slot></h4>
+ <slot name="description"></slot>
+ </div>
+ <div class="col-lg-8">
+ <input-copy-toggle-visibility
+ :label="inputLabel"
+ :label-for="inputId"
+ :form-input-group-props="formInputGroupProps"
+ :value="token"
+ :copy-button-title="copyButtonTitle"
+ >
+ <template #description>
+ <slot name="input-description"></slot>
+ </template>
+ </input-copy-toggle-visibility>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/access_tokens/components/tokens_app.vue b/app/assets/javascripts/access_tokens/components/tokens_app.vue
new file mode 100644
index 00000000000..755991f64e0
--- /dev/null
+++ b/app/assets/javascripts/access_tokens/components/tokens_app.vue
@@ -0,0 +1,111 @@
+<script>
+import { GlSprintf, GlLink } from '@gitlab/ui';
+import { pickBy } from 'lodash';
+
+import { s__ } from '~/locale';
+
+import { FEED_TOKEN, INCOMING_EMAIL_TOKEN, STATIC_OBJECT_TOKEN } from '../constants';
+import Token from './token.vue';
+
+export default {
+ i18n: {
+ canNotAccessOtherData: s__('AccessTokens|It cannot be used to access any other data.'),
+ [FEED_TOKEN]: {
+ label: s__('AccessTokens|Feed token'),
+ copyButtonTitle: s__('AccessTokens|Copy feed token'),
+ description: s__(
+ 'AccessTokens|Your feed token authenticates you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar. It is visible in those feed URLs.',
+ ),
+ inputDescription: s__(
+ 'AccessTokens|Keep this token secret. Anyone who has it can read activity and issue RSS feeds or your calendar feed as if they were you. If that happens, %{linkStart}reset this token%{linkEnd}.',
+ ),
+ resetConfirmMessage: s__(
+ 'AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working.',
+ ),
+ },
+ [INCOMING_EMAIL_TOKEN]: {
+ label: s__('AccessTokens|Incoming email token'),
+ copyButtonTitle: s__('AccessTokens|Copy incoming email token'),
+ description: s__(
+ 'AccessTokens|Your incoming email token authenticates you when you create a new issue by email, and is included in your personal project-specific email addresses.',
+ ),
+ inputDescription: s__(
+ 'AccessTokens|Keep this token secret. Anyone who has it can create issues as if they were you. If that happens, %{linkStart}reset this token%{linkEnd}.',
+ ),
+ resetConfirmMessage: s__(
+ 'AccessTokens|Are you sure? Any issue email addresses currently in use will stop working.',
+ ),
+ },
+ [STATIC_OBJECT_TOKEN]: {
+ label: s__('AccessTokens|Static object token'),
+ copyButtonTitle: s__('AccessTokens|Copy static object token'),
+ description: s__(
+ 'AccessTokens|Your static object token authenticates you when repository static objects (such as archives or blobs) are served from an external storage.',
+ ),
+ inputDescription: s__(
+ 'AccessTokens|Keep this token secret. Anyone who has it can access repository static objects as if they were you. If that ever happens, %{linkStart}reset this token%{linkEnd}.',
+ ),
+ resetConfirmMessage: s__('AccessTokens|Are you sure?'),
+ },
+ },
+ htmlAttributes: {
+ [FEED_TOKEN]: {
+ inputId: 'feed_token',
+ containerTestId: 'feed-token-container',
+ },
+ [INCOMING_EMAIL_TOKEN]: {
+ inputId: 'incoming_email_token',
+ containerTestId: 'incoming-email-token-container',
+ },
+ [STATIC_OBJECT_TOKEN]: {
+ inputId: 'static_object_token',
+ containerTestId: 'static-object-token-container',
+ },
+ },
+ components: { Token, GlSprintf, GlLink },
+ inject: ['tokenTypes'],
+ computed: {
+ enabledTokenTypes() {
+ return pickBy(this.tokenTypes, (tokenData, tokenType) => {
+ return (
+ tokenData?.enabled &&
+ this.$options.i18n[tokenType] &&
+ this.$options.htmlAttributes[tokenType]
+ );
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <token
+ v-for="(tokenData, tokenType) in enabledTokenTypes"
+ :key="tokenType"
+ :token="tokenData.token"
+ :input-id="$options.htmlAttributes[tokenType].inputId"
+ :input-label="$options.i18n[tokenType].label"
+ :copy-button-title="$options.i18n[tokenType].copyButtonTitle"
+ :data-testid="$options.htmlAttributes[tokenType].containerTestId"
+ >
+ <template #title>{{ $options.i18n[tokenType].label }}</template>
+ <template #description>
+ <p>{{ $options.i18n[tokenType].description }}</p>
+ <p>{{ $options.i18n.canNotAccessOtherData }}</p>
+ </template>
+ <template #input-description>
+ <gl-sprintf :message="$options.i18n[tokenType].inputDescription">
+ <template #link="{ content }">
+ <gl-link
+ :href="tokenData.resetPath"
+ :data-confirm="$options.i18n[tokenType].resetConfirmMessage"
+ data-method="put"
+ >{{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </template>
+ </token>
+ </div>
+</template>
diff --git a/app/assets/javascripts/access_tokens/constants.js b/app/assets/javascripts/access_tokens/constants.js
new file mode 100644
index 00000000000..6188c6d1bb5
--- /dev/null
+++ b/app/assets/javascripts/access_tokens/constants.js
@@ -0,0 +1,4 @@
+// Token types
+export const FEED_TOKEN = 'feedToken';
+export const INCOMING_EMAIL_TOKEN = 'incomingEmailToken';
+export const STATIC_OBJECT_TOKEN = 'staticObjectToken';
diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js
index 7f5f0403de6..9a1e7d877f8 100644
--- a/app/assets/javascripts/access_tokens/index.js
+++ b/app/assets/javascripts/access_tokens/index.js
@@ -1,9 +1,13 @@
import Vue from 'vue';
+
import createFlash from '~/flash';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { parseRailsFormFields } from '~/lib/utils/forms';
import { __ } from '~/locale';
import ExpiresAtField from './components/expires_at_field.vue';
+import TokensApp from './components/tokens_app.vue';
+import { FEED_TOKEN, INCOMING_EMAIL_TOKEN, STATIC_OBJECT_TOKEN } from './constants';
export const initExpiresAtField = () => {
const el = document.querySelector('.js-access-tokens-expires-at');
@@ -81,3 +85,29 @@ export const initProjectsField = () => {
return null;
};
+
+export const initTokensApp = () => {
+ const el = document.getElementById('js-tokens-app');
+
+ if (!el) return false;
+
+ const tokensData = convertObjectPropsToCamelCase(JSON.parse(el.dataset.tokensData), {
+ deep: true,
+ });
+
+ const tokenTypes = {
+ [FEED_TOKEN]: tokensData[FEED_TOKEN],
+ [INCOMING_EMAIL_TOKEN]: tokensData[INCOMING_EMAIL_TOKEN],
+ [STATIC_OBJECT_TOKEN]: tokensData[STATIC_OBJECT_TOKEN],
+ };
+
+ return new Vue({
+ el,
+ provide: {
+ tokenTypes,
+ },
+ render(createElement) {
+ return createElement(TokensApp);
+ },
+ });
+};
diff --git a/app/assets/javascripts/admin/deploy_keys/components/table.vue b/app/assets/javascripts/admin/deploy_keys/components/table.vue
index 97a5a2f2f32..29e8b9a724e 100644
--- a/app/assets/javascripts/admin/deploy_keys/components/table.vue
+++ b/app/assets/javascripts/admin/deploy_keys/components/table.vue
@@ -1,13 +1,33 @@
<script>
-import { GlTable, GlButton } from '@gitlab/ui';
+import { GlTable, GlButton, GlPagination, GlLoadingIcon, GlEmptyState, GlModal } from '@gitlab/ui';
import { __ } from '~/locale';
+import Api, { DEFAULT_PER_PAGE } from '~/api';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { cleanLeadingSeparator } from '~/lib/utils/url_utility';
+import createFlash from '~/flash';
+import csrf from '~/lib/utils/csrf';
export default {
name: 'DeployKeysTable',
i18n: {
pageTitle: __('Public deploy keys'),
newDeployKeyButtonText: __('New deploy key'),
+ emptyStateTitle: __('No public deploy keys'),
+ emptyStateDescription: __(
+ 'Deploy keys grant read/write access to all repositories in your instance',
+ ),
+ delete: __('Delete deploy key'),
+ edit: __('Edit deploy key'),
+ pagination: {
+ next: __('Next'),
+ prev: __('Prev'),
+ },
+ modal: {
+ title: __('Are you sure?'),
+ body: __('Are you sure you want to delete this deploy key?'),
+ },
+ apiErrorMessage: __('An error occurred fetching the public deploy keys. Please try again.'),
},
fields: [
{
@@ -29,13 +49,118 @@ export default {
{
key: 'actions',
label: __('Actions'),
+ tdClass: 'gl-lg-w-1px gl-white-space-nowrap',
+ thClass: 'gl-lg-w-1px gl-white-space-nowrap',
},
],
+ modal: {
+ id: 'delete-deploy-key-modal',
+ actionPrimary: {
+ text: __('Delete'),
+ attributes: {
+ variant: 'danger',
+ },
+ },
+ actionSecondary: {
+ text: __('Cancel'),
+ attributes: {
+ variant: 'default',
+ },
+ },
+ },
+ csrf,
+ DEFAULT_PER_PAGE,
components: {
GlTable,
GlButton,
+ GlPagination,
+ TimeAgoTooltip,
+ GlLoadingIcon,
+ GlEmptyState,
+ GlModal,
},
inject: ['editPath', 'deletePath', 'createPath', 'emptyStateSvgPath'],
+ data() {
+ return {
+ page: 1,
+ totalItems: 0,
+ loading: false,
+ items: [],
+ deployKeyToDelete: null,
+ };
+ },
+ computed: {
+ shouldShowTable() {
+ return this.totalItems !== 0 || this.loading;
+ },
+ isModalVisible() {
+ return this.deployKeyToDelete !== null;
+ },
+ deleteAction() {
+ return this.deployKeyToDelete === null
+ ? null
+ : this.deletePath.replace(':id', this.deployKeyToDelete);
+ },
+ },
+ watch: {
+ page(newPage) {
+ this.fetchDeployKeys(newPage);
+ },
+ },
+ mounted() {
+ this.fetchDeployKeys();
+ },
+ methods: {
+ editHref(id) {
+ return this.editPath.replace(':id', id);
+ },
+ projectHref(project) {
+ return `/${cleanLeadingSeparator(project.path_with_namespace)}`;
+ },
+ async fetchDeployKeys(page) {
+ this.loading = true;
+ try {
+ const { headers, data: items } = await Api.deployKeys({
+ page,
+ public: true,
+ });
+
+ if (this.totalItems === 0) {
+ this.totalItems = parseInt(headers?.['x-total'], 10) || 0;
+ }
+
+ this.items = items.map(
+ ({ id, title, fingerprint, projects_with_write_access, created_at }) => ({
+ id,
+ title,
+ fingerprint,
+ projects: projects_with_write_access,
+ created: created_at,
+ }),
+ );
+ } catch (error) {
+ createFlash({
+ message: this.$options.i18n.apiErrorMessage,
+ captureError: true,
+ error,
+ });
+
+ this.totalItems = 0;
+
+ this.items = [];
+ }
+ this.loading = false;
+ },
+ handleDeleteClick(id) {
+ this.deployKeyToDelete = id;
+ },
+ handleModalHide() {
+ this.deployKeyToDelete = null;
+ },
+ handleModalPrimary() {
+ this.$refs.modalForm.submit();
+ },
+ },
};
</script>
@@ -45,10 +170,92 @@ export default {
<h4 class="gl-m-0">
{{ $options.i18n.pageTitle }}
</h4>
- <gl-button variant="confirm" :href="createPath">{{
+ <gl-button variant="confirm" :href="createPath" data-testid="new-deploy-key-button">{{
$options.i18n.newDeployKeyButtonText
}}</gl-button>
</div>
- <gl-table :fields="$options.fields" data-testid="deploy-keys-list" />
+ <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>
+
+ <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)="{ item: { fingerprint } }">
+ <code>{{ fingerprint }}</code>
+ </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 #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>
+ <gl-empty-state
+ v-else
+ :svg-path="emptyStateSvgPath"
+ :title="$options.i18n.emptyStateTitle"
+ :description="$options.i18n.emptyStateDescription"
+ :primary-button-text="$options.i18n.newDeployKeyButtonText"
+ :primary-button-link="createPath"
+ />
+ <gl-modal
+ :modal-id="$options.modal.id"
+ :visible="isModalVisible"
+ :title="$options.i18n.modal.title"
+ :action-primary="$options.modal.actionPrimary"
+ :action-secondary="$options.modal.actionSecondary"
+ size="sm"
+ @hide="handleModalHide"
+ @primary="handleModalPrimary"
+ >
+ <form ref="modalForm" :action="deleteAction" method="post">
+ <input type="hidden" name="_method" value="delete" />
+ <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
+ </form>
+ {{ $options.i18n.modal.body }}
+ </gl-modal>
</div>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/activate.vue b/app/assets/javascripts/admin/users/components/actions/activate.vue
index 74e9c60a57b..3a54035c587 100644
--- a/app/assets/javascripts/admin/users/components/actions/activate.vue
+++ b/app/assets/javascripts/admin/users/components/actions/activate.vue
@@ -1,6 +1,7 @@
<script>
import { GlDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
+import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922
@@ -26,16 +27,15 @@ export default {
required: true,
},
},
- computed: {
- modalAttributes() {
- return {
- 'data-path': this.path,
- 'data-method': 'put',
- 'data-modal-attributes': JSON.stringify({
+ methods: {
+ onClick() {
+ eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, {
+ path: this.path,
+ method: 'put',
+ modalAttributes: {
title: sprintf(s__('AdminUsers|Activate user %{username}?'), {
username: this.username,
}),
- messageHtml,
actionCancel: {
text: __('Cancel'),
},
@@ -43,15 +43,16 @@ export default {
text: I18N_USER_ACTIONS.activate,
attributes: [{ variant: 'confirm' }],
},
- }),
- };
+ messageHtml,
+ },
+ });
},
},
};
</script>
<template>
- <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
+ <gl-dropdown-item @click="onClick">
<slot></slot>
</gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/approve.vue b/app/assets/javascripts/admin/users/components/actions/approve.vue
index 77a9be8eec2..5a8c675822d 100644
--- a/app/assets/javascripts/admin/users/components/actions/approve.vue
+++ b/app/assets/javascripts/admin/users/components/actions/approve.vue
@@ -1,6 +1,7 @@
<script>
import { GlDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
+import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922
@@ -28,12 +29,12 @@ export default {
required: true,
},
},
- computed: {
- attributes() {
- return {
- 'data-path': this.path,
- 'data-method': 'put',
- 'data-modal-attributes': JSON.stringify({
+ methods: {
+ onClick() {
+ eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, {
+ path: this.path,
+ method: 'put',
+ modalAttributes: {
title: sprintf(s__('AdminUsers|Approve user %{username}?'), {
username: this.username,
}),
@@ -45,16 +46,15 @@ export default {
attributes: [{ variant: 'confirm', 'data-qa-selector': 'approve_user_confirm_button' }],
},
messageHtml,
- }),
- 'data-qa-selector': 'approve_user_button',
- };
+ },
+ });
},
},
};
</script>
<template>
- <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...attributes }">
+ <gl-dropdown-item data-qa-selector="approve_user_button" @click="onClick">
<slot></slot>
</gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/ban.vue b/app/assets/javascripts/admin/users/components/actions/ban.vue
index e5ab0f9123f..55938832dce 100644
--- a/app/assets/javascripts/admin/users/components/actions/ban.vue
+++ b/app/assets/javascripts/admin/users/components/actions/ban.vue
@@ -2,6 +2,7 @@
import { GlDropdownItem } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { sprintf, s__, __ } from '~/locale';
+import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922
@@ -39,12 +40,12 @@ export default {
required: true,
},
},
- computed: {
- modalAttributes() {
- return {
- 'data-path': this.path,
- 'data-method': 'put',
- 'data-modal-attributes': JSON.stringify({
+ methods: {
+ onClick() {
+ eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, {
+ path: this.path,
+ method: 'put',
+ modalAttributes: {
title: sprintf(s__('AdminUsers|Ban user %{username}?'), {
username: this.username,
}),
@@ -56,15 +57,15 @@ export default {
attributes: [{ variant: 'confirm' }],
},
messageHtml,
- }),
- };
+ },
+ });
},
},
};
</script>
<template>
- <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
+ <gl-dropdown-item @click="onClick">
<slot></slot>
</gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/block.vue b/app/assets/javascripts/admin/users/components/actions/block.vue
index 03557008a89..d25dd400f9b 100644
--- a/app/assets/javascripts/admin/users/components/actions/block.vue
+++ b/app/assets/javascripts/admin/users/components/actions/block.vue
@@ -1,6 +1,7 @@
<script>
import { GlDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
+import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922
@@ -29,12 +30,12 @@ export default {
required: true,
},
},
- computed: {
- modalAttributes() {
- return {
- 'data-path': this.path,
- 'data-method': 'put',
- 'data-modal-attributes': JSON.stringify({
+ methods: {
+ onClick() {
+ eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, {
+ path: this.path,
+ method: 'put',
+ modalAttributes: {
title: sprintf(s__('AdminUsers|Block user %{username}?'), { username: this.username }),
actionCancel: {
text: __('Cancel'),
@@ -44,15 +45,15 @@ export default {
attributes: [{ variant: 'confirm' }],
},
messageHtml,
- }),
- };
+ },
+ });
},
},
};
</script>
<template>
- <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
+ <gl-dropdown-item @click="onClick">
<slot></slot>
</gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/deactivate.vue b/app/assets/javascripts/admin/users/components/actions/deactivate.vue
index 640c8fefc20..c85f3f01675 100644
--- a/app/assets/javascripts/admin/users/components/actions/deactivate.vue
+++ b/app/assets/javascripts/admin/users/components/actions/deactivate.vue
@@ -1,6 +1,7 @@
<script>
import { GlDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
+import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922
@@ -36,12 +37,12 @@ export default {
required: true,
},
},
- computed: {
- modalAttributes() {
- return {
- 'data-path': this.path,
- 'data-method': 'put',
- 'data-modal-attributes': JSON.stringify({
+ methods: {
+ onClick() {
+ eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, {
+ path: this.path,
+ method: 'put',
+ modalAttributes: {
title: sprintf(s__('AdminUsers|Deactivate user %{username}?'), {
username: this.username,
}),
@@ -53,15 +54,15 @@ export default {
attributes: [{ variant: 'confirm' }],
},
messageHtml,
- }),
- };
+ },
+ });
},
},
};
</script>
<template>
- <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
+ <gl-dropdown-item @click="onClick">
<slot></slot>
</gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/reject.vue b/app/assets/javascripts/admin/users/components/actions/reject.vue
index 901306455fa..bac08de1d5e 100644
--- a/app/assets/javascripts/admin/users/components/actions/reject.vue
+++ b/app/assets/javascripts/admin/users/components/actions/reject.vue
@@ -2,6 +2,7 @@
import { GlDropdownItem } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { sprintf, s__, __ } from '~/locale';
+import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922
@@ -39,12 +40,12 @@ export default {
required: true,
},
},
- computed: {
- modalAttributes() {
- return {
- 'data-path': this.path,
- 'data-method': 'delete',
- 'data-modal-attributes': JSON.stringify({
+ methods: {
+ onClick() {
+ eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, {
+ path: this.path,
+ method: 'delete',
+ modalAttributes: {
title: sprintf(s__('AdminUsers|Reject user %{username}?'), {
username: this.username,
}),
@@ -56,15 +57,15 @@ export default {
attributes: [{ variant: 'danger' }],
},
messageHtml,
- }),
- };
+ },
+ });
},
},
};
</script>
<template>
- <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
+ <gl-dropdown-item @click="onClick">
<slot></slot>
</gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/unban.vue b/app/assets/javascripts/admin/users/components/actions/unban.vue
index 8083e26177e..beede2d37d7 100644
--- a/app/assets/javascripts/admin/users/components/actions/unban.vue
+++ b/app/assets/javascripts/admin/users/components/actions/unban.vue
@@ -1,6 +1,7 @@
<script>
import { GlDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
+import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922
@@ -22,12 +23,12 @@ export default {
required: true,
},
},
- computed: {
- modalAttributes() {
- return {
- 'data-path': this.path,
- 'data-method': 'put',
- 'data-modal-attributes': JSON.stringify({
+ methods: {
+ onClick() {
+ eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, {
+ path: this.path,
+ method: 'put',
+ modalAttributes: {
title: sprintf(s__('AdminUsers|Unban user %{username}?'), {
username: this.username,
}),
@@ -39,15 +40,15 @@ export default {
attributes: [{ variant: 'confirm' }],
},
messageHtml,
- }),
- };
+ },
+ });
},
},
};
</script>
<template>
- <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
+ <gl-dropdown-item @click="onClick">
<slot></slot>
</gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/unblock.vue b/app/assets/javascripts/admin/users/components/actions/unblock.vue
index 7de6653e0cd..720f2efd932 100644
--- a/app/assets/javascripts/admin/users/components/actions/unblock.vue
+++ b/app/assets/javascripts/admin/users/components/actions/unblock.vue
@@ -1,6 +1,7 @@
<script>
import { GlDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
+import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
export default {
@@ -17,12 +18,13 @@ export default {
required: true,
},
},
- computed: {
- modalAttributes() {
- return {
- 'data-path': this.path,
- 'data-method': 'put',
- 'data-modal-attributes': JSON.stringify({
+
+ methods: {
+ onClick() {
+ eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, {
+ path: this.path,
+ method: 'put',
+ modalAttributes: {
title: sprintf(s__('AdminUsers|Unblock user %{username}?'), { username: this.username }),
message: s__('AdminUsers|You can always block their account again if needed.'),
actionCancel: {
@@ -32,15 +34,15 @@ export default {
text: I18N_USER_ACTIONS.unblock,
attributes: [{ variant: 'confirm' }],
},
- }),
- };
+ },
+ });
},
},
};
</script>
<template>
- <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
+ <gl-dropdown-item @click="onClick">
<slot></slot>
</gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/unlock.vue b/app/assets/javascripts/admin/users/components/actions/unlock.vue
index 10d4fb06d61..55ea3e0aba7 100644
--- a/app/assets/javascripts/admin/users/components/actions/unlock.vue
+++ b/app/assets/javascripts/admin/users/components/actions/unlock.vue
@@ -1,6 +1,7 @@
<script>
import { GlDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
+import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
export default {
@@ -17,12 +18,12 @@ export default {
required: true,
},
},
- computed: {
- modalAttributes() {
- return {
- 'data-path': this.path,
- 'data-method': 'put',
- 'data-modal-attributes': JSON.stringify({
+ methods: {
+ onClick() {
+ eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, {
+ path: this.path,
+ method: 'put',
+ modalAttributes: {
title: sprintf(s__('AdminUsers|Unlock user %{username}?'), { username: this.username }),
message: __('Are you sure?'),
actionCancel: {
@@ -32,15 +33,15 @@ export default {
text: I18N_USER_ACTIONS.unlock,
attributes: [{ variant: 'confirm' }],
},
- }),
- };
+ },
+ });
},
},
};
</script>
<template>
- <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
+ <gl-dropdown-item @click="onClick">
<slot></slot>
</gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue
index e949498c55b..d7c08096376 100644
--- a/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue
+++ b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue
@@ -57,14 +57,17 @@ export default {
};
},
computed: {
+ trimmedUsername() {
+ return this.username.trim();
+ },
modalTitle() {
- return sprintf(this.title, { username: this.username }, false);
+ return sprintf(this.title, { username: this.trimmedUsername }, false);
},
secondaryButtonLabel() {
return s__('AdminUsers|Block user');
},
canSubmit() {
- return this.enteredUsername === this.username;
+ return this.enteredUsername === this.trimmedUsername;
},
obstacles() {
try {
@@ -104,7 +107,7 @@ export default {
<p>
<gl-sprintf :message="content">
<template #username>
- <strong>{{ username }}</strong>
+ <strong>{{ trimmedUsername }}</strong>
</template>
<template #strong="props">
<strong>{{ props.content }}</strong>
@@ -115,13 +118,13 @@ export default {
<user-deletion-obstacles-list
v-if="obstacles.length"
:obstacles="obstacles"
- :user-name="username"
+ :user-name="trimmedUsername"
/>
<p>
<gl-sprintf :message="s__('AdminUsers|To confirm, type %{username}')">
<template #username>
- <code class="gl-white-space-pre-wrap">{{ username }}</code>
+ <code class="gl-white-space-pre-wrap">{{ trimmedUsername }}</code>
</template>
</gl-sprintf>
</p>
diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue
index 4f4e2947341..567d7151847 100644
--- a/app/assets/javascripts/admin/users/components/user_actions.vue
+++ b/app/assets/javascripts/admin/users/components/user_actions.vue
@@ -112,7 +112,7 @@ export default {
right
:text="$options.i18n.userAdministration"
:text-sr-only="!showButtonLabels"
- icon="settings"
+ icon="ellipsis_h"
data-qa-selector="user_actions_dropdown_toggle"
:data-qa-username="user.username"
>
diff --git a/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql
index 40ec4c56171..0f9075c58bf 100644
--- a/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql
+++ b/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql
@@ -1,5 +1,6 @@
query getAlertsCount($searchTerm: String, $projectPath: ID!, $assigneeUsername: String = "") {
project(fullPath: $projectPath) {
+ id
alertManagementAlertStatusCounts(search: $searchTerm, assigneeUsername: $assigneeUsername) {
all
open
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql
index babcdea935d..d4f4f244759 100644
--- a/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql
@@ -3,6 +3,8 @@
mutation createHttpIntegration($projectPath: ID!, $name: String!, $active: Boolean!) {
httpIntegrationCreate(input: { projectPath: $projectPath, name: $name, active: $active }) {
errors
+ # We have ID in a deeply nested fragment
+ # eslint-disable-next-line @graphql-eslint/require-id-when-available
integration {
...HttpIntegrationItem
}
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql
index a3a50651fd0..caa258e0848 100644
--- a/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql
@@ -3,6 +3,8 @@
mutation destroyHttpIntegration($id: ID!) {
httpIntegrationDestroy(input: { id: $id }) {
errors
+ # We have ID in a deeply nested fragment
+ # eslint-disable-next-line @graphql-eslint/require-id-when-available
integration {
...HttpIntegrationItem
}
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql
index c0754d8e32b..2f30f9abb5c 100644
--- a/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql
@@ -3,6 +3,8 @@
mutation resetHttpIntegrationToken($id: ID!) {
httpIntegrationResetToken(input: { id: $id }) {
errors
+ # We have ID in a deeply nested fragment
+ # eslint-disable-next-line @graphql-eslint/require-id-when-available
integration {
...HttpIntegrationItem
}
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql
index 37df9ec25eb..2cf56613673 100644
--- a/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql
@@ -3,6 +3,8 @@
mutation updateHttpIntegration($id: ID!, $name: String!, $active: Boolean!) {
httpIntegrationUpdate(input: { id: $id, name: $name, active: $active }) {
errors
+ # We have ID in a deeply nested fragment
+ # eslint-disable-next-line @graphql-eslint/require-id-when-available
integration {
...HttpIntegrationItem
}
diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integration.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integration.query.graphql
index d20a8b8334b..7299e6836d4 100644
--- a/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integration.query.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integration.query.graphql
@@ -2,6 +2,7 @@
query getHttpIntegration($projectPath: ID!, $id: ID) {
project(fullPath: $projectPath) {
+ id
alertManagementHttpIntegrations(id: $id) {
nodes {
...HttpIntegrationPayloadData
diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql
index 228dd5fb176..3cd3f2d92f8 100644
--- a/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql
@@ -2,6 +2,7 @@
query getIntegrations($projectPath: ID!) {
project(fullPath: $projectPath) {
+ id
alertManagementIntegrations {
nodes {
...IntegrationItem
diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/parse_sample_payload.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/parse_sample_payload.query.graphql
index 159b2661f0b..15df4a08cc2 100644
--- a/app/assets/javascripts/alerts_settings/graphql/queries/parse_sample_payload.query.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/queries/parse_sample_payload.query.graphql
@@ -1,5 +1,6 @@
query parsePayloadFields($projectPath: ID!, $payload: String!) {
project(fullPath: $projectPath) {
+ id
alertManagementPayloadFields(payloadExample: $payload) {
path
label
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 238081cc3c0..5a394059931 100644
--- a/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue
+++ b/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue
@@ -1,5 +1,5 @@
<script>
-import { GlBadge, GlTable, GlLink, GlEmptyState } from '@gitlab/ui';
+import { GlBadge, GlTableLite, GlLink, GlEmptyState } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import { helpPagePath } from '~/helpers/help_page_helper';
import { sprintf, s__ } from '~/locale';
@@ -13,7 +13,7 @@ const defaultHeaderAttrs = {
export default {
components: {
GlBadge,
- GlTable,
+ GlTableLite,
GlSingleStat,
GlLink,
GlEmptyState,
@@ -94,7 +94,7 @@ export default {
:meta-text="devopsScoreMetrics.averageScore.scoreLevel.label"
:variant="devopsScoreMetrics.averageScore.scoreLevel.variant"
/>
- <gl-table
+ <gl-table-lite
:fields="$options.tableHeaderFields"
:items="devopsScoreMetrics.cards"
thead-class="gl-border-t-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"
@@ -108,7 +108,7 @@ export default {
}}</gl-badge>
</div>
</template>
- </gl-table>
+ </gl-table-lite>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql b/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql
index b870ed4dcbf..ea2f911fb54 100644
--- a/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql
+++ b/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql
@@ -5,6 +5,7 @@ query analyticsGetGroupProjects(
$includeSubgroups: Boolean = false
) {
group(fullPath: $groupFullPath) {
+ id
projects(
search: $search
first: $first
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index adf3e122a64..8c996b448aa 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -91,6 +91,7 @@ const Api = {
projectNotificationSettingsPath: '/api/:version/projects/:id/notification_settings',
groupNotificationSettingsPath: '/api/:version/groups/:id/notification_settings',
notificationSettingsPath: '/api/:version/notification_settings',
+ deployKeysPath: '/api/:version/deploy_keys',
group(groupId, callback = () => {}) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
@@ -950,6 +951,12 @@ const Api = {
return axios.delete(url);
},
+ deployKeys(params = {}) {
+ const url = Api.buildUrl(this.deployKeysPath);
+
+ return axios.get(url, { params: { per_page: DEFAULT_PER_PAGE, ...params } });
+ },
+
async updateNotificationSettings(projectId, groupId, data = {}) {
let url = Api.buildUrl(this.notificationSettingsPath);
diff --git a/app/assets/javascripts/api/packages_api.js b/app/assets/javascripts/api/packages_api.js
new file mode 100644
index 00000000000..47f51c7e80e
--- /dev/null
+++ b/app/assets/javascripts/api/packages_api.js
@@ -0,0 +1,32 @@
+import axios from '../lib/utils/axios_utils';
+import { buildApiUrl } from './api_utils';
+
+const PUBLISH_PACKAGE_PATH =
+ '/api/:version/projects/:id/packages/generic/:package_name/:package_version/:file_name';
+
+export function publishPackage(
+ { projectPath, name, version, fileName, files },
+ options,
+ axiosOptions = {},
+) {
+ const url = buildApiUrl(PUBLISH_PACKAGE_PATH)
+ .replace(':id', encodeURIComponent(projectPath))
+ .replace(':package_name', name)
+ .replace(':package_version', version)
+ .replace(':file_name', fileName);
+
+ const defaults = {
+ status: 'default',
+ };
+
+ const formData = new FormData();
+ formData.append('file', files[0]);
+
+ return axios.put(url, formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ params: Object.assign(defaults, options),
+ ...axiosOptions,
+ });
+}
diff --git a/app/assets/javascripts/artifacts_settings/graphql/queries/get_keep_latest_artifact_project_setting.query.graphql b/app/assets/javascripts/artifacts_settings/graphql/queries/get_keep_latest_artifact_project_setting.query.graphql
index 7486512c57c..91fa468fc8c 100644
--- a/app/assets/javascripts/artifacts_settings/graphql/queries/get_keep_latest_artifact_project_setting.query.graphql
+++ b/app/assets/javascripts/artifacts_settings/graphql/queries/get_keep_latest_artifact_project_setting.query.graphql
@@ -1,5 +1,6 @@
query getKeepLatestArtifactProjectSetting($fullPath: ID!) {
project(fullPath: $fullPath) {
+ id
ciCdSettings {
keepLatestArtifact
}
diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue
index 918519f386b..a218624f2d4 100644
--- a/app/assets/javascripts/batch_comments/components/draft_note.vue
+++ b/app/assets/javascripts/batch_comments/components/draft_note.vue
@@ -76,7 +76,7 @@ export default {
},
},
safeHtmlConfig: {
- ADD_TAGS: ['use', 'gl-emoji'],
+ ADD_TAGS: ['use', 'gl-emoji', 'copy-code'],
},
};
</script>
diff --git a/app/assets/javascripts/behaviors/copy_code.js b/app/assets/javascripts/behaviors/copy_code.js
new file mode 100644
index 00000000000..a6e203ea5a2
--- /dev/null
+++ b/app/assets/javascripts/behaviors/copy_code.js
@@ -0,0 +1,66 @@
+import { uniqueId } from 'lodash';
+import { __ } from '~/locale';
+import { spriteIcon } from '~/lib/utils/common_utils';
+import { setAttributes } from '~/lib/utils/dom_utils';
+
+class CopyCodeButton extends HTMLElement {
+ connectedCallback() {
+ this.for = uniqueId('code-');
+
+ this.parentNode.querySelector('pre').setAttribute('id', this.for);
+
+ this.appendChild(this.createButton());
+ }
+
+ createButton() {
+ const button = document.createElement('button');
+
+ setAttributes(button, {
+ type: 'button',
+ class: 'btn btn-default btn-md gl-button btn-icon has-tooltip',
+ 'data-title': __('Copy to clipboard'),
+ 'data-clipboard-target': `pre#${this.for}`,
+ });
+
+ button.innerHTML = spriteIcon('copy-to-clipboard');
+
+ return button;
+ }
+}
+
+function addCodeButton() {
+ [...document.querySelectorAll('pre.code.js-syntax-highlight')]
+ .filter((el) => !el.closest('.js-markdown-code'))
+ .forEach((el) => {
+ const copyCodeEl = document.createElement('copy-code');
+ copyCodeEl.setAttribute('for', uniqueId('code-'));
+
+ const wrapper = document.createElement('div');
+ wrapper.className = 'gl-relative markdown-code-block js-markdown-code';
+ wrapper.appendChild(el.cloneNode(true));
+ wrapper.appendChild(copyCodeEl);
+
+ el.parentNode.insertBefore(wrapper, el);
+
+ el.remove();
+ });
+}
+
+export const initCopyCodeButton = (selector = '#content-body') => {
+ if (!customElements.get('copy-code')) {
+ customElements.define('copy-code', CopyCodeButton);
+ }
+
+ const el = document.querySelector(selector);
+
+ if (!el) return () => {};
+
+ const observer = new MutationObserver(() => addCodeButton());
+
+ observer.observe(document.querySelector(selector), {
+ childList: true,
+ subtree: true,
+ });
+
+ return () => observer.disconnect();
+};
diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js
index ef445548e6e..8fe90b6bb15 100644
--- a/app/assets/javascripts/behaviors/gl_emoji.js
+++ b/app/assets/javascripts/behaviors/gl_emoji.js
@@ -33,7 +33,7 @@ class GlEmoji extends HTMLElement {
this.dataset.unicodeVersion = unicodeVersion;
emojiUnicode = emojiInfo.e;
- this.innerHTML = emojiInfo.e;
+ this.textContent = emojiInfo.e;
this.title = emojiInfo.d;
}
diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js
index bfd025e8dab..30160248a77 100644
--- a/app/assets/javascripts/behaviors/index.js
+++ b/app/assets/javascripts/behaviors/index.js
@@ -1,6 +1,5 @@
import $ from 'jquery';
import './autosize';
-import './bind_in_out';
import './markdown/render_gfm';
import initCollapseSidebarOnWindowResize from './collapse_sidebar_on_window_resize';
import initCopyToClipboard from './copy_to_clipboard';
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
index c2908133fd0..e58c51104c5 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
@@ -3,6 +3,7 @@ import Mousetrap from 'mousetrap';
import { clickCopyToClipboardButton } from '~/behaviors/copy_to_clipboard';
import { getSelectedFragment } from '~/lib/utils/common_utils';
import { isElementVisible } from '~/lib/utils/dom_utils';
+import { DEBOUNCE_DROPDOWN_DELAY } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import Sidebar from '../../right_sidebar';
import { CopyAsGFM } from '../markdown/copy_as_gfm';
import {
@@ -114,6 +115,14 @@ export default class ShortcutsIssuable extends Shortcuts {
static openSidebarDropdown(name) {
Sidebar.instance.openDropdown(name);
+ // Wait for the sidebar to trigger('click') open
+ // so it doesn't cause our dropdown to close preemptively
+ setTimeout(() => {
+ const editBtn =
+ document.querySelector(`.block.${name} .shortcut-sidebar-dropdown-toggle`) ||
+ document.querySelector(`.block.${name} .edit-link`);
+ editBtn.click();
+ }, DEBOUNCE_DROPDOWN_DELAY);
return false;
}
diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue
index e3e43ea3a0e..9832ebbea5c 100644
--- a/app/assets/javascripts/blob/components/blob_content.vue
+++ b/app/assets/javascripts/blob/components/blob_content.vue
@@ -86,7 +86,7 @@ export default {
:file-name="blob.name"
:type="activeViewer.fileType"
:hide-line-numbers="hideLineNumbers"
- data-qa-selector="file_content"
+ data-qa-selector="blob_viewer_file_content"
/>
</template>
</div>
diff --git a/app/assets/javascripts/blob/pdf/pdf_viewer.vue b/app/assets/javascripts/blob/pdf/pdf_viewer.vue
index 96d6f500960..a1a62abeb6f 100644
--- a/app/assets/javascripts/blob/pdf/pdf_viewer.vue
+++ b/app/assets/javascripts/blob/pdf/pdf_viewer.vue
@@ -38,7 +38,13 @@ export default {
<div v-if="loading && !error" class="text-center loading">
<gl-loading-icon class="mt-5" size="lg" />
</div>
- <pdf-lab v-if="!loadError" :pdf="pdf" @pdflabload="onLoad" @pdflaberror="onError" />
+ <pdf-lab
+ v-if="!loadError"
+ :pdf="pdf"
+ @pdflabload="onLoad"
+ @pdflaberror="onError"
+ v-on="$listeners"
+ />
<p v-if="error" class="text-center">
<span v-if="loadError" ref="loadError">
{{ __('An error occurred while loading the file. Please try again later.') }}
diff --git a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue
index e75aa523ed0..47a0c4ba2d1 100644
--- a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue
+++ b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue
@@ -71,7 +71,7 @@ export default {
i18n: {
modalTitle: __("That's it, well done!"),
pipelinesButton: s__('MR widget|See your pipeline in action'),
- mergeRequestButton: s__('MR widget|Back to the Merge request'),
+ mergeRequestButton: s__('MR widget|Back to the merge request'),
bodyMessage: s__(
`MR widget|The pipeline will test your code on every commit. A %{codeQualityLinkStart}code quality report%{codeQualityLinkEnd} will appear in your merge requests to warn you about potential code degradations.`,
),
diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js
index 118cef59d5a..ee2f6cfb46c 100644
--- a/app/assets/javascripts/blob_edit/edit_blob.js
+++ b/app/assets/javascripts/blob_edit/edit_blob.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext';
import SourceEditor from '~/editor/source_editor';
import { getBlobLanguage } from '~/editor/utils';
@@ -26,23 +27,29 @@ export default class EditBlob {
this.editor.focus();
}
- fetchMarkdownExtension() {
- import('~/editor/extensions/source_editor_markdown_ext')
- .then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => {
- this.editor.use(
- new MarkdownExtension({
- instance: this.editor,
- previewMarkdownPath: this.options.previewMarkdownPath,
- }),
- );
- this.hasMarkdownExtension = true;
- addEditorMarkdownListeners(this.editor);
- })
- .catch((e) =>
- createFlash({
- message: `${BLOB_EDITOR_ERROR}: ${e}`,
- }),
- );
+ async fetchMarkdownExtension() {
+ try {
+ const [
+ { EditorMarkdownExtension: MarkdownExtension },
+ { EditorMarkdownPreviewExtension: MarkdownLivePreview },
+ ] = await Promise.all([
+ import('~/editor/extensions/source_editor_markdown_ext'),
+ import('~/editor/extensions/source_editor_markdown_livepreview_ext'),
+ ]);
+ this.editor.use([
+ { definition: MarkdownExtension },
+ {
+ definition: MarkdownLivePreview,
+ setupOptions: { previewMarkdownPath: this.options.previewMarkdownPath },
+ },
+ ]);
+ } catch (e) {
+ createFlash({
+ message: `${BLOB_EDITOR_ERROR}: ${e}`,
+ });
+ }
+ this.hasMarkdownExtension = true;
+ addEditorMarkdownListeners(this.editor);
}
configureMonacoEditor() {
@@ -60,7 +67,7 @@ export default class EditBlob {
blobPath: fileNameEl.value,
blobContent: editorEl.innerText,
});
- this.editor.use(new FileTemplateExtension({ instance: this.editor }));
+ this.editor.use([{ definition: SourceEditorExtension }, { definition: FileTemplateExtension }]);
fileNameEl.addEventListener('change', () => {
this.editor.updateModelLanguage(fileNameEl.value);
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
index e6c91c7ac1f..7e4d3ebb686 100644
--- a/app/assets/javascripts/boards/boards_util.js
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -1,6 +1,6 @@
import { sortBy, cloneDeep } from 'lodash';
import { isGid } from '~/graphql_shared/utils';
-import { ListType, MilestoneIDs } from './constants';
+import { ListType, MilestoneIDs, AssigneeFilterType, MilestoneFilterType } from './constants';
export function getMilestone() {
return null;
@@ -186,6 +186,7 @@ export function isListDraggable(list) {
export const FiltersInfo = {
assigneeUsername: {
negatedSupport: true,
+ remap: (k, v) => (v === AssigneeFilterType.any ? 'assigneeWildcardId' : k),
},
assigneeId: {
// assigneeId should be renamed to assigneeWildcardId.
@@ -204,6 +205,11 @@ export const FiltersInfo = {
},
milestoneTitle: {
negatedSupport: true,
+ remap: (k, v) => (Object.values(MilestoneFilterType).includes(v) ? 'milestoneWildcardId' : k),
+ },
+ milestoneWildcardId: {
+ negatedSupport: true,
+ transform: (val) => val.toUpperCase(),
},
myReactionEmoji: {
negatedSupport: true,
@@ -214,6 +220,10 @@ export const FiltersInfo = {
types: {
negatedSupport: true,
},
+ confidential: {
+ negatedSupport: false,
+ transform: (val) => val === 'yes',
+ },
search: {
negatedSupport: false,
},
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index b6ccc6a00fe..ea80496c3f5 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -13,7 +13,7 @@ import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { updateHistory } from '~/lib/utils/url_utility';
import { sprintf, __, n__ } from '~/locale';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import { ListType } from '../constants';
import eventHub from '../eventhub';
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
index 54668c9e88e..f89f8e5feb8 100644
--- a/app/assets/javascripts/boards/components/board_content_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -4,7 +4,6 @@ import { MountingPortal } from 'portal-vue';
import { mapState, mapActions, mapGetters } from 'vuex';
import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue';
import { __, sprintf } from '~/locale';
-import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue';
import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
import { ISSUABLE } from '~/boards/constants';
@@ -26,7 +25,6 @@ export default {
SidebarDateWidget,
SidebarConfidentialityWidget,
BoardSidebarTimeTracker,
- BoardSidebarLabelsSelect,
SidebarLabelsWidget,
SidebarSubscriptionsWidget,
SidebarDropdownWidget,
@@ -210,7 +208,6 @@ export default {
data-testid="sidebar-due-date"
/>
<sidebar-labels-widget
- v-if="glFeatures.labelsWidget"
class="block labels"
data-testid="sidebar-labels"
:iid="activeBoardItem.iid"
@@ -230,7 +227,6 @@ export default {
>
{{ __('None') }}
</sidebar-labels-widget>
- <board-sidebar-labels-select v-else class="block labels" />
<sidebar-weight-widget
v-if="weightFeatureAvailable"
:iid="activeBoardItem.iid"
diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue
index 6e6ada2d109..09ec385bbba 100644
--- a/app/assets/javascripts/boards/components/board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/board_filtered_search.vue
@@ -1,7 +1,7 @@
<script>
import { pickBy, isEmpty } from 'lodash';
import { mapActions } from 'vuex';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
@@ -39,30 +39,33 @@ export default {
assigneeUsername,
search,
milestoneTitle,
+ iterationId,
types,
weight,
epicId,
myReactionEmoji,
+ releaseTag,
+ confidential,
} = this.filterParams;
const filteredSearchValue = [];
if (authorUsername) {
filteredSearchValue.push({
- type: 'author_username',
+ type: 'author',
value: { data: authorUsername, operator: '=' },
});
}
if (assigneeUsername) {
filteredSearchValue.push({
- type: 'assignee_username',
+ type: 'assignee',
value: { data: assigneeUsername, operator: '=' },
});
}
if (types) {
filteredSearchValue.push({
- type: 'types',
+ type: 'type',
value: { data: types, operator: '=' },
});
}
@@ -70,7 +73,7 @@ export default {
if (labelName?.length) {
filteredSearchValue.push(
...labelName.map((label) => ({
- type: 'label_name',
+ type: 'label',
value: { data: label, operator: '=' },
})),
);
@@ -78,11 +81,18 @@ export default {
if (milestoneTitle) {
filteredSearchValue.push({
- type: 'milestone_title',
+ type: 'milestone',
value: { data: milestoneTitle, operator: '=' },
});
}
+ if (iterationId) {
+ filteredSearchValue.push({
+ type: 'iteration',
+ value: { data: iterationId, operator: '=' },
+ });
+ }
+
if (weight) {
filteredSearchValue.push({
type: 'weight',
@@ -92,32 +102,53 @@ export default {
if (myReactionEmoji) {
filteredSearchValue.push({
- type: 'my_reaction_emoji',
+ type: 'my-reaction',
value: { data: myReactionEmoji, operator: '=' },
});
}
+ if (releaseTag) {
+ filteredSearchValue.push({
+ type: 'release',
+ value: { data: releaseTag, operator: '=' },
+ });
+ }
+
+ if (confidential !== undefined) {
+ filteredSearchValue.push({
+ type: 'confidential',
+ value: { data: confidential },
+ });
+ }
+
if (epicId) {
filteredSearchValue.push({
- type: 'epic_id',
+ type: 'epic',
value: { data: epicId, operator: '=' },
});
}
if (this.filterParams['not[authorUsername]']) {
filteredSearchValue.push({
- type: 'author_username',
+ type: 'author',
value: { data: this.filterParams['not[authorUsername]'], operator: '!=' },
});
}
if (this.filterParams['not[milestoneTitle]']) {
filteredSearchValue.push({
- type: 'milestone_title',
+ type: 'milestone',
value: { data: this.filterParams['not[milestoneTitle]'], operator: '!=' },
});
}
+ if (this.filterParams['not[iteration_id]']) {
+ filteredSearchValue.push({
+ type: 'iteration_id',
+ value: { data: this.filterParams['not[iteration_id]'], operator: '!=' },
+ });
+ }
+
if (this.filterParams['not[weight]']) {
filteredSearchValue.push({
type: 'weight',
@@ -127,7 +158,7 @@ export default {
if (this.filterParams['not[assigneeUsername]']) {
filteredSearchValue.push({
- type: 'assignee_username',
+ type: 'assignee',
value: { data: this.filterParams['not[assigneeUsername]'], operator: '!=' },
});
}
@@ -135,7 +166,7 @@ export default {
if (this.filterParams['not[labelName]']) {
filteredSearchValue.push(
...this.filterParams['not[labelName]'].map((label) => ({
- type: 'label_name',
+ type: 'label',
value: { data: label, operator: '!=' },
})),
);
@@ -143,25 +174,32 @@ export default {
if (this.filterParams['not[types]']) {
filteredSearchValue.push({
- type: 'types',
+ type: 'type',
value: { data: this.filterParams['not[types]'], operator: '!=' },
});
}
if (this.filterParams['not[epicId]']) {
filteredSearchValue.push({
- type: 'epic_id',
+ type: 'epic',
value: { data: this.filterParams['not[epicId]'], operator: '!=' },
});
}
if (this.filterParams['not[myReactionEmoji]']) {
filteredSearchValue.push({
- type: 'my_reaction_emoji',
+ type: 'my-reaction',
value: { data: this.filterParams['not[myReactionEmoji]'], operator: '!=' },
});
}
+ if (this.filterParams['not[releaseTag]']) {
+ filteredSearchValue.push({
+ type: 'release',
+ value: { data: this.filterParams['not[releaseTag]'], operator: '!=' },
+ });
+ }
+
if (search) {
filteredSearchValue.push(search);
}
@@ -179,8 +217,10 @@ export default {
weight,
epicId,
myReactionEmoji,
+ iterationId,
+ releaseTag,
+ confidential,
} = this.filterParams;
-
let notParams = {};
if (Object.prototype.hasOwnProperty.call(this.filterParams, 'not')) {
@@ -194,6 +234,8 @@ export default {
'not[weight]': this.filterParams.not.weight,
'not[epic_id]': this.filterParams.not.epicId,
'not[my_reaction_emoji]': this.filterParams.not.myReactionEmoji,
+ 'not[iteration_id]': this.filterParams.not.iterationId,
+ 'not[release_tag]': this.filterParams.not.releaseTag,
},
undefined,
);
@@ -205,11 +247,14 @@ export default {
'label_name[]': labelName,
assignee_username: assigneeUsername,
milestone_title: milestoneTitle,
+ iteration_id: iterationId,
search,
types,
weight,
- epic_id: getIdFromGraphQLId(epicId),
+ epic_id: isGid(epicId) ? getIdFromGraphQLId(epicId) : epicId,
my_reaction_emoji: myReactionEmoji,
+ release_tag: releaseTag,
+ confidential,
};
},
},
@@ -246,30 +291,39 @@ export default {
filters.forEach((filter) => {
switch (filter.type) {
- case 'author_username':
+ case 'author':
filterParams.authorUsername = filter.value.data;
break;
- case 'assignee_username':
+ case 'assignee':
filterParams.assigneeUsername = filter.value.data;
break;
- case 'types':
+ case 'type':
filterParams.types = filter.value.data;
break;
- case 'label_name':
+ case 'label':
labels.push(filter.value.data);
break;
- case 'milestone_title':
+ case 'milestone':
filterParams.milestoneTitle = filter.value.data;
break;
+ case 'iteration':
+ filterParams.iterationId = filter.value.data;
+ break;
case 'weight':
filterParams.weight = filter.value.data;
break;
- case 'epic_id':
+ case 'epic':
filterParams.epicId = filter.value.data;
break;
- case 'my_reaction_emoji':
+ case 'my-reaction':
filterParams.myReactionEmoji = filter.value.data;
break;
+ case 'release':
+ filterParams.releaseTag = filter.value.data;
+ break;
+ case 'confidential':
+ filterParams.confidential = filter.value.data;
+ break;
case 'filtered-search-term':
if (filter.value.data) plainText.push(filter.value.data);
break;
@@ -285,6 +339,7 @@ export default {
if (plainText.length) {
filterParams.search = plainText.join(' ');
}
+
return filterParams;
},
},
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index 47dffc985aa..e4c3c3206a8 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -6,6 +6,7 @@ import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_opt
import { sprintf, __ } from '~/locale';
import defaultSortableConfig from '~/sortable/sortable_config';
import Tracking from '~/tracking';
+import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql';
import { toggleFormEventPrefix, DraggableItemTypes } from '../constants';
import eventHub from '../eventhub';
import BoardCard from './board_card.vue';
@@ -50,11 +51,22 @@ export default {
showEpicForm: false,
};
},
+ apollo: {
+ boardList: {
+ query: listQuery,
+ variables() {
+ return {
+ id: this.list.id,
+ filters: this.filterParams,
+ };
+ },
+ },
+ },
computed: {
- ...mapState(['pageInfoByListId', 'listsFlags']),
+ ...mapState(['pageInfoByListId', 'listsFlags', 'filterParams']),
...mapGetters(['isEpicBoard']),
listItemsCount() {
- return this.isEpicBoard ? this.list.epicsCount : this.list.issuesCount;
+ return this.isEpicBoard ? this.list.epicsCount : this.boardList?.issuesCount;
},
paginatedIssueText() {
return sprintf(__('Showing %{pageSize} of %{total} %{issuableType}'), {
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index e985a368e64..19004518edf 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -17,6 +17,7 @@ import sidebarEventHub from '~/sidebar/event_hub';
import Tracking from '~/tracking';
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 AccessorUtilities from '../../lib/utils/accessor';
import { inactiveId, LIST, ListType, toggleFormEventPrefix } from '../constants';
import eventHub from '../eventhub';
@@ -74,7 +75,7 @@ export default {
},
},
computed: {
- ...mapState(['activeId']),
+ ...mapState(['activeId', 'filterParams']),
...mapGetters(['isEpicBoard', 'isSwimlanesOn']),
isLoggedIn() {
return Boolean(this.currentUserId);
@@ -119,14 +120,11 @@ export default {
}
return false;
},
- itemsCount() {
- return this.list.issuesCount;
- },
countIcon() {
return 'issues';
},
itemsTooltipLabel() {
- return n__(`%d issue`, `%d issues`, this.itemsCount);
+ return n__(`%d issue`, `%d issues`, this.boardLists?.issuesCount);
},
chevronTooltip() {
return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse;
@@ -158,6 +156,23 @@ export default {
userCanDrag() {
return !this.disabled && isListDraggable(this.list);
},
+ isLoading() {
+ return this.$apollo.queries.boardList.loading;
+ },
+ },
+ apollo: {
+ boardList: {
+ query: listQuery,
+ variables() {
+ return {
+ id: this.list.id,
+ filters: this.filterParams,
+ };
+ },
+ skip() {
+ return this.isEpicBoard;
+ },
+ },
},
created() {
const localCollapsed = parseBoolean(localStorage.getItem(`${this.uniqueKey}.collapsed`));
@@ -375,10 +390,10 @@ export default {
</gl-sprintf>
</div>
<div v-else>• {{ itemsTooltipLabel }}</div>
- <div v-if="weightFeatureAvailable">
+ <div v-if="weightFeatureAvailable && !isLoading">
<gl-sprintf :message="__('%{totalWeight} total weight')">
- <template #totalWeight>{{ list.totalWeight }}</template>
+ <template #totalWeight>{{ boardList.totalWeight }}</template>
</gl-sprintf>
</div>
</gl-tooltip>
@@ -396,14 +411,18 @@ export default {
<gl-tooltip :target="() => $refs.itemCount" :title="itemsTooltipLabel" />
<span ref="itemCount" class="gl-display-inline-flex gl-align-items-center">
<gl-icon class="gl-mr-2" :name="countIcon" />
- <item-count :items-size="itemsCount" :max-issue-count="list.maxIssueCount" />
+ <item-count
+ v-if="!isLoading"
+ :items-size="isEpicBoard ? list.epicsCount : boardList.issuesCount"
+ :max-issue-count="list.maxIssueCount"
+ />
</span>
<!-- EE start -->
- <template v-if="weightFeatureAvailable && !isEpicBoard">
+ <template v-if="weightFeatureAvailable && !isEpicBoard && !isLoading">
<gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" />
<span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3">
<gl-icon class="gl-mr-2" name="weight" />
- {{ list.totalWeight }}
+ {{ boardList.totalWeight }}
</span>
</template>
<!-- EE end -->
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index 71facba1378..69343cd78d8 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -349,6 +349,9 @@ export default {
v-if="showCreate"
v-gl-modal-directive="'board-config-modal'"
data-qa-selector="create_new_board_button"
+ data-track-action="click_button"
+ data-track-label="create_new_board"
+ data-track-property="dropdown"
@click.prevent="showPage('new')"
>
{{ s__('IssueBoards|Create new board') }}
diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
index bdb9c2be836..7fc87f9f672 100644
--- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
@@ -2,22 +2,25 @@
import { GlFilteredSearchToken } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { mapActions } from 'vuex';
+import { orderBy } from 'lodash';
import BoardFilteredSearch from 'ee_else_ce/boards/components/board_filtered_search.vue';
import { BoardType } from '~/boards/constants';
import axios from '~/lib/utils/axios_utils';
+import { joinPaths } from '~/lib/utils/url_utility';
import issueBoardFilters from '~/boards/issue_board_filters';
import { TYPE_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import {
- DEFAULT_MILESTONES_GRAPHQL,
TOKEN_TITLE_MY_REACTION,
+ OPERATOR_IS_AND_IS_NOT,
+ OPERATOR_IS_ONLY,
} from '~/vue_shared/components/filtered_search_bar/constants';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
-import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue';
+import ReleaseToken from '~/vue_shared/components/filtered_search_bar/tokens/release_token.vue';
export default {
types: {
@@ -34,12 +37,11 @@ export default {
incident: __('Incident'),
issue: __('Issue'),
milestone: __('Milestone'),
- weight: __('Weight'),
- is: __('is'),
- isNot: __('is not'),
+ release: __('Release'),
+ confidential: __('Confidential'),
},
components: { BoardFilteredSearch },
- inject: ['isSignedIn'],
+ inject: ['isSignedIn', 'releasesFetchPath'],
props: {
fullPath: {
type: String,
@@ -62,15 +64,14 @@ export default {
tokensCE() {
const {
label,
- is,
- isNot,
author,
assignee,
issue,
incident,
type,
milestone,
- weight,
+ release,
+ confidential,
} = this.$options.i18n;
const { types } = this.$options;
const { fetchAuthors, fetchLabels } = issueBoardFilters(
@@ -79,15 +80,12 @@ export default {
this.boardType,
);
- return [
+ const tokens = [
{
icon: 'user',
title: assignee,
- type: 'assignee_username',
- operators: [
- { value: '=', description: is },
- { value: '!=', description: isNot },
- ],
+ type: 'assignee',
+ operators: OPERATOR_IS_AND_IS_NOT,
token: AuthorToken,
unique: true,
fetchAuthors,
@@ -96,11 +94,8 @@ export default {
{
icon: 'pencil',
title: author,
- type: 'author_username',
- operators: [
- { value: '=', description: is },
- { value: '!=', description: isNot },
- ],
+ type: 'author',
+ operators: OPERATOR_IS_AND_IS_NOT,
symbol: '@',
token: AuthorToken,
unique: true,
@@ -110,11 +105,8 @@ export default {
{
icon: 'labels',
title: label,
- type: 'label_name',
- operators: [
- { value: '=', description: is },
- { value: '!=', description: isNot },
- ],
+ type: 'label',
+ operators: OPERATOR_IS_AND_IS_NOT,
token: LabelToken,
unique: false,
symbol: '~',
@@ -123,7 +115,7 @@ export default {
...(this.isSignedIn
? [
{
- type: 'my_reaction_emoji',
+ type: 'my-reaction',
title: TOKEN_TITLE_MY_REACTION,
icon: 'thumb-up',
token: EmojiToken,
@@ -144,22 +136,33 @@ export default {
});
},
},
+ {
+ type: 'confidential',
+ icon: 'eye-slash',
+ title: confidential,
+ unique: true,
+ token: GlFilteredSearchToken,
+ operators: OPERATOR_IS_ONLY,
+ options: [
+ { icon: 'eye-slash', value: 'yes', title: __('Yes') },
+ { icon: 'eye', value: 'no', title: __('No') },
+ ],
+ },
]
: []),
{
- type: 'milestone_title',
+ type: 'milestone',
title: milestone,
icon: 'clock',
symbol: '%',
token: MilestoneToken,
unique: true,
- defaultMilestones: DEFAULT_MILESTONES_GRAPHQL,
fetchMilestones: this.fetchMilestones,
},
{
icon: 'issues',
title: type,
- type: 'types',
+ type: 'type',
token: GlFilteredSearchToken,
unique: true,
options: [
@@ -168,13 +171,27 @@ export default {
],
},
{
- type: 'weight',
- title: weight,
- icon: 'weight',
- token: WeightToken,
- unique: true,
+ type: 'release',
+ title: release,
+ icon: 'rocket',
+ token: ReleaseToken,
+ fetchReleases: (search) => {
+ // TODO: Switch to GraphQL query when backend is ready: https://gitlab.com/gitlab-org/gitlab/-/issues/337686
+ return axios
+ .get(joinPaths(gon.relative_url_root, this.releasesFetchPath))
+ .then(({ data }) => {
+ if (search) {
+ return fuzzaldrinPlus.filter(data, search, {
+ key: ['tag'],
+ });
+ }
+ return data;
+ });
+ },
},
];
+
+ return orderBy(tokens, ['title']);
},
tokens() {
return this.tokensCE;
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue
deleted file mode 100644
index ec53947fd5f..00000000000
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue
+++ /dev/null
@@ -1,173 +0,0 @@
-<script>
-import { GlLabel } from '@gitlab/ui';
-import { mapGetters, mapActions } from 'vuex';
-import Api from '~/api';
-import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { isScopedLabel } from '~/lib/utils/common_utils';
-import { mergeUrlParams } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
-import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
-
-export default {
- components: {
- BoardEditableItem,
- LabelsSelect,
- GlLabel,
- },
- inject: {
- labelsFetchPath: {
- default: null,
- },
- labelsManagePath: {},
- labelsFilterBasePath: {},
- },
- data() {
- return {
- loading: false,
- oldIid: null,
- isEditing: false,
- };
- },
- computed: {
- ...mapGetters(['activeBoardItem', 'projectPathForActiveIssue']),
- selectedLabels() {
- const { labels = [] } = this.activeBoardItem;
-
- return labels.map((label) => ({
- ...label,
- id: getIdFromGraphQLId(label.id),
- }));
- },
- issueLabels() {
- const { labels = [] } = this.activeBoardItem;
-
- return labels.map((label) => ({
- ...label,
- scoped: isScopedLabel(label),
- }));
- },
- fetchPath() {
- /*
- Labels fetched in epic boards are always group-level labels
- and the correct path are passed from the backend (injected through labelsFetchPath)
-
- For issue boards, we should always include project-level labels and use a different endpoint.
- (it requires knowing the project path of a selected issue.)
-
- Note 1. that we will be using GraphQL to fetch labels when we create a labels select widget.
- And this component will be removed _wholesale_ https://gitlab.com/gitlab-org/gitlab/-/issues/300653.
-
- Note 2. Moreover, 'fetchPath' needs to be used as a key for 'labels-select' component to force updates.
- 'labels-select' has its own vuex store and initializes the passed props as states
- and these states aren't reactively bound to the passed props.
- */
-
- const projectLabelsFetchPath = mergeUrlParams(
- { include_ancestor_groups: true },
- Api.buildUrl(Api.projectLabelsPath).replace(
- ':namespace_path/:project_path',
- this.projectPathForActiveIssue,
- ),
- );
-
- return this.labelsFetchPath || projectLabelsFetchPath;
- },
- },
- watch: {
- activeBoardItem(_, oldVal) {
- if (this.isEditing) {
- this.oldIid = oldVal.iid;
- } else {
- this.oldIid = null;
- }
- },
- },
- methods: {
- ...mapActions(['setActiveBoardItemLabels', 'setError']),
- async setLabels(payload) {
- this.loading = true;
- this.$refs.sidebarItem.collapse();
-
- try {
- const addLabelIds = payload.filter((label) => label.set).map((label) => label.id);
- const removeLabelIds = payload.filter((label) => !label.set).map((label) => label.id);
-
- const input = {
- addLabelIds,
- removeLabelIds,
- projectPath: this.projectPathForActiveIssue,
- iid: this.oldIid,
- };
- await this.setActiveBoardItemLabels(input);
- this.oldIid = null;
- } catch (e) {
- this.setError({ error: e, message: __('An error occurred while updating labels.') });
- } finally {
- this.loading = false;
- }
- },
- async removeLabel(id) {
- this.loading = true;
-
- try {
- const removeLabelIds = [getIdFromGraphQLId(id)];
- const input = { removeLabelIds, projectPath: this.projectPathForActiveIssue };
- await this.setActiveBoardItemLabels(input);
- } catch (e) {
- this.setError({ error: e, message: __('An error occurred when removing the label.') });
- } finally {
- this.loading = false;
- }
- },
- },
-};
-</script>
-
-<template>
- <board-editable-item
- ref="sidebarItem"
- :title="__('Labels')"
- :loading="loading"
- data-testid="sidebar-labels"
- @open="isEditing = true"
- @close="isEditing = false"
- >
- <template #collapsed>
- <gl-label
- v-for="label in issueLabels"
- :key="label.id"
- :background-color="label.color"
- :title="label.title"
- :description="label.description"
- :scoped="label.scoped"
- :show-close-button="true"
- :disabled="loading"
- class="gl-mr-2 gl-mb-2"
- @close="removeLabel(label.id)"
- />
- </template>
- <template #default="{ edit }">
- <labels-select
- ref="labelsSelect"
- :key="fetchPath"
- :allow-label-edit="false"
- :allow-label-create="false"
- :allow-multiselect="true"
- :allow-scoped-labels="true"
- :selected-labels="selectedLabels"
- :labels-fetch-path="fetchPath"
- :labels-manage-path="labelsManagePath"
- :labels-filter-base-path="labelsFilterBasePath"
- :labels-list-title="__('Select label')"
- :dropdown-button-text="__('Choose labels')"
- :is-editing="edit"
- variant="sidebar"
- class="gl-display-block labels gl-w-full"
- @updateSelectedLabels="setLabels"
- >
- {{ __('None') }}
- </labels-select>
- </template>
- </board-editable-item>
-</template>
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue
deleted file mode 100644
index 4f5c55d0c5d..00000000000
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue
+++ /dev/null
@@ -1,75 +0,0 @@
-<script>
-import { GlToggle } from '@gitlab/ui';
-import { mapGetters, mapActions } from 'vuex';
-import { __, s__ } from '~/locale';
-
-export default {
- i18n: {
- header: {
- title: __('Notifications'),
- /* Any change to subscribeDisabledDescription
- must be reflected in app/helpers/notifications_helper.rb */
- subscribeDisabledDescription: __(
- 'Notifications have been disabled by the project or group owner',
- ),
- },
- updateSubscribedErrorMessage: s__(
- 'IssueBoards|An error occurred while setting notifications status. Please try again.',
- ),
- },
- components: {
- GlToggle,
- },
- inject: ['emailsDisabled'],
- data() {
- return {
- loading: false,
- };
- },
- computed: {
- ...mapGetters(['activeBoardItem', 'projectPathForActiveIssue', 'isEpicBoard']),
- isEmailsDisabled() {
- return this.isEpicBoard ? this.emailsDisabled : this.activeBoardItem.emailsDisabled;
- },
- notificationText() {
- return this.isEmailsDisabled
- ? this.$options.i18n.header.subscribeDisabledDescription
- : this.$options.i18n.header.title;
- },
- },
- methods: {
- ...mapActions(['setActiveItemSubscribed', 'setError']),
- async handleToggleSubscription() {
- this.loading = true;
- try {
- await this.setActiveItemSubscribed({
- subscribed: !this.activeBoardItem.subscribed,
- projectPath: this.projectPathForActiveIssue,
- });
- } catch (error) {
- this.setError({ error, message: this.$options.i18n.updateSubscribedErrorMessage });
- } finally {
- this.loading = false;
- }
- },
- },
-};
-</script>
-
-<template>
- <div
- class="gl-display-flex gl-align-items-center gl-justify-content-space-between"
- data-testid="sidebar-notifications"
- >
- <span data-testid="notification-header-text"> {{ notificationText }} </span>
- <gl-toggle
- v-if="!isEmailsDisabled"
- :value="activeBoardItem.subscribed"
- :is-loading="loading"
- :label="$options.i18n.header.title"
- label-position="hidden"
- data-testid="notification-subscribe-toggle"
- @change="handleToggleSubscription"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index 391e0d1fb0a..851b5eca40d 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -104,8 +104,10 @@ export const FilterFields = {
'assigneeUsername',
'assigneeWildcardId',
'authorUsername',
+ 'confidential',
'labelName',
'milestoneTitle',
+ 'milestoneWildcardId',
'myReactionEmoji',
'releaseTag',
'search',
@@ -114,6 +116,18 @@ export const FilterFields = {
],
};
+/* eslint-disable @gitlab/require-i18n-strings */
+export const AssigneeFilterType = {
+ any: 'Any',
+};
+
+export const MilestoneFilterType = {
+ any: 'Any',
+ none: 'None',
+ started: 'Started',
+ upcoming: 'Upcoming',
+};
+
export const DraggableItemTypes = {
card: 'card',
list: 'list',
diff --git a/app/assets/javascripts/boards/graphql/board_labels.query.graphql b/app/assets/javascripts/boards/graphql/board_labels.query.graphql
index b19a24e8808..525a4863379 100644
--- a/app/assets/javascripts/boards/graphql/board_labels.query.graphql
+++ b/app/assets/javascripts/boards/graphql/board_labels.query.graphql
@@ -7,6 +7,7 @@ query BoardLabels(
$isProject: Boolean = false
) {
group(fullPath: $fullPath) @include(if: $isGroup) {
+ id
labels(searchTerm: $searchTerm, onlyGroupLabels: true, includeAncestorGroups: true) {
nodes {
...Label
@@ -14,6 +15,7 @@ query BoardLabels(
}
}
project(fullPath: $fullPath) @include(if: $isProject) {
+ id
labels(searchTerm: $searchTerm, includeAncestorGroups: true) {
nodes {
...Label
diff --git a/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql
index 0e1d11727cf..81cc7b4d246 100644
--- a/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql
@@ -2,6 +2,8 @@
mutation createBoardList($boardId: BoardID!, $backlog: Boolean, $labelId: LabelID) {
boardListCreate(input: { boardId: $boardId, backlog: $backlog, labelId: $labelId }) {
+ # We have ID in a deeply nested fragment
+ # eslint-disable-next-line @graphql-eslint/require-id-when-available
list {
...BoardListFragment
}
diff --git a/app/assets/javascripts/boards/graphql/board_list_shared.fragment.graphql b/app/assets/javascripts/boards/graphql/board_list_shared.fragment.graphql
index d85b736720b..5b532906f6a 100644
--- a/app/assets/javascripts/boards/graphql/board_list_shared.fragment.graphql
+++ b/app/assets/javascripts/boards/graphql/board_list_shared.fragment.graphql
@@ -4,7 +4,6 @@ fragment BoardListShared on BoardList {
position
listType
collapsed
- issuesCount
label {
id
title
diff --git a/app/assets/javascripts/boards/graphql/board_list_update.mutation.graphql b/app/assets/javascripts/boards/graphql/board_list_update.mutation.graphql
index b474c9acb93..7ea0e2f915a 100644
--- a/app/assets/javascripts/boards/graphql/board_list_update.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/board_list_update.mutation.graphql
@@ -2,6 +2,8 @@
mutation UpdateBoardList($listId: ID!, $position: Int, $collapsed: Boolean) {
updateBoardList(input: { listId: $listId, position: $position, collapsed: $collapsed }) {
+ # We have ID in a deeply nested fragment
+ # eslint-disable-next-line @graphql-eslint/require-id-when-available
list {
...BoardListFragment
}
diff --git a/app/assets/javascripts/boards/graphql/board_lists.query.graphql b/app/assets/javascripts/boards/graphql/board_lists.query.graphql
index 47e87907d76..e6e98864aad 100644
--- a/app/assets/javascripts/boards/graphql/board_lists.query.graphql
+++ b/app/assets/javascripts/boards/graphql/board_lists.query.graphql
@@ -8,9 +8,13 @@ query BoardLists(
$isProject: Boolean = false
) {
group(fullPath: $fullPath) @include(if: $isGroup) {
+ id
board(id: $boardId) {
+ id
hideBacklogList
lists(issueFilters: $filters) {
+ # We have ID in a deeply nested fragment
+ # eslint-disable-next-line @graphql-eslint/require-id-when-available
nodes {
...BoardListFragment
}
@@ -18,9 +22,13 @@ query BoardLists(
}
}
project(fullPath: $fullPath) @include(if: $isProject) {
+ id
board(id: $boardId) {
+ id
hideBacklogList
lists(issueFilters: $filters) {
+ # We have ID in a deeply nested fragment
+ # eslint-disable-next-line @graphql-eslint/require-id-when-available
nodes {
...BoardListFragment
}
diff --git a/app/assets/javascripts/boards/graphql/board_lists_deferred.query.graphql b/app/assets/javascripts/boards/graphql/board_lists_deferred.query.graphql
new file mode 100644
index 00000000000..bae3220dfad
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/board_lists_deferred.query.graphql
@@ -0,0 +1,6 @@
+query BoardList($id: ID!, $filters: BoardIssueInput) {
+ boardList(id: $id, issueFilters: $filters) {
+ id
+ issuesCount
+ }
+}
diff --git a/app/assets/javascripts/boards/graphql/group_board.query.graphql b/app/assets/javascripts/boards/graphql/group_board.query.graphql
index 77c8e0378f0..8d87b83da96 100644
--- a/app/assets/javascripts/boards/graphql/group_board.query.graphql
+++ b/app/assets/javascripts/boards/graphql/group_board.query.graphql
@@ -2,6 +2,7 @@
query GroupBoard($fullPath: ID!, $boardId: ID!) {
workspace: group(fullPath: $fullPath) {
+ id
board(id: $boardId) {
...BoardScopeFragment
}
diff --git a/app/assets/javascripts/boards/graphql/group_board_members.query.graphql b/app/assets/javascripts/boards/graphql/group_board_members.query.graphql
index d3251c2aa12..aec674eb006 100644
--- a/app/assets/javascripts/boards/graphql/group_board_members.query.graphql
+++ b/app/assets/javascripts/boards/graphql/group_board_members.query.graphql
@@ -3,6 +3,7 @@
query GroupBoardMembers($fullPath: ID!, $search: String) {
workspace: group(fullPath: $fullPath) {
__typename
+ id
assignees: groupMembers(search: $search, relations: [DIRECT, DESCENDANTS, INHERITED]) {
__typename
nodes {
diff --git a/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql b/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql
index 73aa9137dec..0963b3fbfaa 100644
--- a/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql
+++ b/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql
@@ -1,5 +1,6 @@
query GroupBoardMilestones($fullPath: ID!, $searchTerm: String) {
group(fullPath: $fullPath) {
+ id
milestones(includeAncestors: true, searchTitle: $searchTerm) {
nodes {
id
diff --git a/app/assets/javascripts/boards/graphql/group_boards.query.graphql b/app/assets/javascripts/boards/graphql/group_boards.query.graphql
index feafd6ae10d..0823c4f5a83 100644
--- a/app/assets/javascripts/boards/graphql/group_boards.query.graphql
+++ b/app/assets/javascripts/boards/graphql/group_boards.query.graphql
@@ -2,6 +2,7 @@
query group_boards($fullPath: ID!) {
group(fullPath: $fullPath) {
+ id
boards {
edges {
node {
diff --git a/app/assets/javascripts/boards/graphql/group_projects.query.graphql b/app/assets/javascripts/boards/graphql/group_projects.query.graphql
index c5732bbaff3..0da14d0b872 100644
--- a/app/assets/javascripts/boards/graphql/group_projects.query.graphql
+++ b/app/assets/javascripts/boards/graphql/group_projects.query.graphql
@@ -2,6 +2,7 @@
query boardsGetGroupProjects($fullPath: ID!, $search: String, $after: String) {
group(fullPath: $fullPath) {
+ id
projects(search: $search, after: $after, first: 100, includeSubgroups: true) {
nodes {
id
diff --git a/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql
index 70eb1dfbf7e..c9c5d744371 100644
--- a/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql
@@ -1,13 +1,12 @@
+#import "~/graphql_shared/fragments/label.fragment.graphql"
+
mutation issueSetLabels($input: UpdateIssueInput!) {
- updateIssue(input: $input) {
- issue {
+ updateIssuableLabels: updateIssue(input: $input) {
+ issuable: issue {
id
labels {
nodes {
- id
- title
- color
- description
+ ...Label
}
}
}
diff --git a/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql
index bfb87758e17..c130a64cac4 100644
--- a/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql
@@ -1,6 +1,7 @@
mutation issueSetSubscription($input: IssueSetSubscriptionInput!) {
updateIssuableSubscription: issueSetSubscription(input: $input) {
issue {
+ id
subscribed
}
errors
diff --git a/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql
index 6ad12d982e0..147cf040a85 100644
--- a/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql
@@ -1,6 +1,7 @@
mutation issueSetTitle($input: UpdateIssueInput!) {
updateIssuableTitle: updateIssue(input: $input) {
issue {
+ id
title
}
errors
diff --git a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql
index 9f93bc6d5bf..105f2931caa 100644
--- a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql
+++ b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql
@@ -1,6 +1,6 @@
#import "ee_else_ce/boards/graphql/issue.fragment.graphql"
-query BoardListEE(
+query BoardListsEE(
$fullPath: ID!
$boardId: ID!
$id: ID
@@ -11,7 +11,9 @@ query BoardListEE(
$first: Int
) {
group(fullPath: $fullPath) @include(if: $isGroup) {
+ id
board(id: $boardId) {
+ id
lists(id: $id, issueFilters: $filters) {
nodes {
id
@@ -33,7 +35,9 @@ query BoardListEE(
}
}
project(fullPath: $fullPath) @include(if: $isProject) {
+ id
board(id: $boardId) {
+ id
lists(id: $id, issueFilters: $filters) {
nodes {
id
diff --git a/app/assets/javascripts/boards/graphql/project_board.query.graphql b/app/assets/javascripts/boards/graphql/project_board.query.graphql
index 6e4cd6bed57..8246d615a6a 100644
--- a/app/assets/javascripts/boards/graphql/project_board.query.graphql
+++ b/app/assets/javascripts/boards/graphql/project_board.query.graphql
@@ -2,6 +2,7 @@
query ProjectBoard($fullPath: ID!, $boardId: ID!) {
workspace: project(fullPath: $fullPath) {
+ id
board(id: $boardId) {
...BoardScopeFragment
}
diff --git a/app/assets/javascripts/boards/graphql/project_board_members.query.graphql b/app/assets/javascripts/boards/graphql/project_board_members.query.graphql
index fc6cc6b832c..45bec5e574b 100644
--- a/app/assets/javascripts/boards/graphql/project_board_members.query.graphql
+++ b/app/assets/javascripts/boards/graphql/project_board_members.query.graphql
@@ -3,6 +3,7 @@
query ProjectBoardMembers($fullPath: ID!, $search: String) {
workspace: project(fullPath: $fullPath) {
__typename
+ id
assignees: projectMembers(search: $search) {
__typename
nodes {
diff --git a/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql b/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql
index 8dd4d256caa..e456823d78a 100644
--- a/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql
+++ b/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql
@@ -1,5 +1,6 @@
query ProjectBoardMilestones($fullPath: ID!, $searchTerm: String) {
project(fullPath: $fullPath) {
+ id
milestones(searchTitle: $searchTerm, includeAncestors: true) {
nodes {
id
diff --git a/app/assets/javascripts/boards/graphql/project_boards.query.graphql b/app/assets/javascripts/boards/graphql/project_boards.query.graphql
index f98d25ba671..b8879bc260c 100644
--- a/app/assets/javascripts/boards/graphql/project_boards.query.graphql
+++ b/app/assets/javascripts/boards/graphql/project_boards.query.graphql
@@ -2,6 +2,7 @@
query project_boards($fullPath: ID!) {
project(fullPath: $fullPath) {
+ id
boards {
edges {
node {
diff --git a/app/assets/javascripts/boards/graphql/project_milestones.query.graphql b/app/assets/javascripts/boards/graphql/project_milestones.query.graphql
index 61c9ddded9b..4c952096d76 100644
--- a/app/assets/javascripts/boards/graphql/project_milestones.query.graphql
+++ b/app/assets/javascripts/boards/graphql/project_milestones.query.graphql
@@ -5,6 +5,7 @@ query boardProjectMilestones(
$searchTitle: String
) {
project(fullPath: $fullPath) {
+ id
milestones(state: $state, includeAncestors: $includeAncestors, searchTitle: $searchTitle) {
edges {
node {
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index 6fa8dd63245..ded3bfded86 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -110,7 +110,8 @@ export default () => {
});
if (gon?.features?.issueBoardsFilteredSearch) {
- initBoardsFilteredSearch(apolloProvider, isLoggedIn());
+ const { releasesFetchPath } = $boardApp.dataset;
+ initBoardsFilteredSearch(apolloProvider, isLoggedIn(), releasesFetchPath);
}
mountBoardApp($boardApp);
diff --git a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js
index 1ea74d5685c..a8ade58e316 100644
--- a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js
+++ b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js
@@ -4,7 +4,7 @@ import store from '~/boards/stores';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { queryToObject } from '~/lib/utils/url_utility';
-export default (apolloProvider, isSignedIn) => {
+export default (apolloProvider, isSignedIn, releasesFetchPath) => {
const el = document.getElementById('js-issue-board-filtered-search');
const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true });
@@ -21,6 +21,7 @@ export default (apolloProvider, isSignedIn) => {
provide: {
initialFilterParams,
isSignedIn,
+ releasesFetchPath,
},
store, // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/324094
apolloProvider,
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index 3a96e535cf7..1ebfcfc331b 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -16,30 +16,30 @@ import {
ListTypeTitles,
DraggableItemTypes,
} from 'ee_else_ce/boards/constants';
-import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql';
-import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import { queryToObject } from '~/lib/utils/url_utility';
-import { s__ } from '~/locale';
import {
+ formatIssueInput,
formatBoardLists,
formatListIssues,
formatListsPageInfo,
formatIssue,
- formatIssueInput,
updateListPosition,
moveItemListHelper,
getMoveData,
FiltersInfo,
filterVariables,
-} from '../boards_util';
+} from 'ee_else_ce/boards/boards_util';
+import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql';
+import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql';
+import totalCountAndWeightQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { queryToObject } from '~/lib/utils/url_utility';
+import { s__ } from '~/locale';
import { gqlClient } from '../graphql';
import boardLabelsQuery from '../graphql/board_labels.query.graphql';
import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql';
import groupProjectsQuery from '../graphql/group_projects.query.graphql';
import issueCreateMutation from '../graphql/issue_create.mutation.graphql';
-import issueSetLabelsMutation from '../graphql/issue_set_labels.mutation.graphql';
import listsIssuesQuery from '../graphql/lists_issues.query.graphql';
import projectBoardMilestonesQuery from '../graphql/project_board_milestones.query.graphql';
@@ -373,7 +373,6 @@ export default {
commit(types.REQUEST_ITEMS_FOR_LIST, { listId, fetchNext });
const { fullPath, fullBoardId, boardType, filterParams } = state;
-
const variables = {
fullPath,
boardId: fullBoardId,
@@ -503,9 +502,10 @@ export default {
updateIssueOrder: async ({ commit, dispatch, state }, { moveData, mutationVariables = {} }) => {
try {
- const { itemId, fromListId, toListId, moveBeforeId, moveAfterId } = moveData;
+ const { itemId, fromListId, toListId, moveBeforeId, moveAfterId, itemNotInToList } = moveData;
const {
fullBoardId,
+ filterParams,
boardItems: {
[itemId]: { iid, referencePath },
},
@@ -524,6 +524,67 @@ export default {
// 'mutationVariables' allows EE code to pass in extra parameters.
...mutationVariables,
},
+ update(
+ cache,
+ {
+ data: {
+ issueMoveList: {
+ issue: { weight },
+ },
+ },
+ },
+ ) {
+ if (fromListId === toListId) return;
+
+ const updateFromList = () => {
+ const fromList = cache.readQuery({
+ query: totalCountAndWeightQuery,
+ variables: { id: fromListId, filters: filterParams },
+ });
+
+ const updatedFromList = {
+ boardList: {
+ __typename: 'BoardList',
+ id: fromList.boardList.id,
+ issuesCount: fromList.boardList.issuesCount - 1,
+ totalWeight: fromList.boardList.totalWeight - Number(weight),
+ },
+ };
+
+ cache.writeQuery({
+ query: totalCountAndWeightQuery,
+ variables: { id: fromListId, filters: filterParams },
+ data: updatedFromList,
+ });
+ };
+
+ const updateToList = () => {
+ if (!itemNotInToList) return;
+
+ const toList = cache.readQuery({
+ query: totalCountAndWeightQuery,
+ variables: { id: toListId, filters: filterParams },
+ });
+
+ const updatedToList = {
+ boardList: {
+ __typename: 'BoardList',
+ id: toList.boardList.id,
+ issuesCount: toList.boardList.issuesCount + 1,
+ totalWeight: toList.boardList.totalWeight + Number(weight),
+ },
+ };
+
+ cache.writeQuery({
+ query: totalCountAndWeightQuery,
+ variables: { id: toListId, filters: filterParams },
+ data: updatedToList,
+ });
+ };
+
+ updateFromList();
+ updateToList();
+ },
});
if (data?.issueMoveList?.errors.length || !data.issueMoveList) {
@@ -567,7 +628,7 @@ export default {
},
addListNewIssue: (
- { state: { boardConfig, boardType, fullPath }, dispatch, commit },
+ { state: { boardConfig, boardType, fullPath, filterParams }, dispatch, commit },
{ issueInput, list, placeholderId = `tmp-${new Date().getTime()}` },
) => {
const input = formatIssueInput(issueInput, boardConfig);
@@ -583,6 +644,27 @@ export default {
.mutate({
mutation: issueCreateMutation,
variables: { input },
+ update(cache) {
+ const fromList = cache.readQuery({
+ query: totalCountAndWeightQuery,
+ variables: { id: list.id, filters: filterParams },
+ });
+
+ const updatedList = {
+ boardList: {
+ __typename: 'BoardList',
+ id: fromList.boardList.id,
+ issuesCount: fromList.boardList.issuesCount + 1,
+ totalWeight: fromList.boardList.totalWeight,
+ },
+ };
+
+ cache.writeQuery({
+ query: totalCountAndWeightQuery,
+ variables: { id: list.id, filters: filterParams },
+ data: updatedList,
+ });
+ },
})
.then(({ data }) => {
if (data.createIssue.errors.length) {
@@ -610,33 +692,6 @@ export default {
setActiveIssueLabels: async ({ commit, getters }, input) => {
const { activeBoardItem } = getters;
- if (!gon.features?.labelsWidget) {
- const { data } = await gqlClient.mutate({
- mutation: issueSetLabelsMutation,
- variables: {
- input: {
- iid: input.iid || String(activeBoardItem.iid),
- labelIds: input.labelsId ?? undefined,
- addLabelIds: input.addLabelIds ?? [],
- removeLabelIds: input.removeLabelIds ?? [],
- projectPath: input.projectPath,
- },
- },
- });
-
- if (data.updateIssue?.errors?.length > 0) {
- throw new Error(data.updateIssue.errors);
- }
-
- commit(types.UPDATE_BOARD_ITEM_BY_ID, {
- itemId: data.updateIssue?.issue?.id || activeBoardItem.id,
- prop: 'labels',
- value: data.updateIssue?.issue?.labels.nodes,
- });
-
- return;
- }
-
let labels = input?.labels || [];
if (input.removeLabelIds) {
labels = activeBoardItem.labels.filter(
diff --git a/app/assets/javascripts/ci_lint/components/ci_lint.vue b/app/assets/javascripts/ci_lint/components/ci_lint.vue
index bc8a1f05ef5..d541e89756a 100644
--- a/app/assets/javascripts/ci_lint/components/ci_lint.vue
+++ b/app/assets/javascripts/ci_lint/components/ci_lint.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton, GlFormCheckbox, GlIcon, GlLink, GlAlert } from '@gitlab/ui';
import CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue';
-import lintCiMutation from '~/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql';
+import lintCiMutation from '~/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql';
import SourceEditor from '~/vue_shared/components/source_editor.vue';
export default {
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 77ec1f1af47..4ab9b36058d 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
@@ -3,7 +3,7 @@ import { GlTable, GlButton, GlBadge, GlTooltipDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
export default {
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
index 8e527e2bff6..e630ce71bd3 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
@@ -17,6 +17,7 @@ import {
import Cookies from 'js-cookie';
import { mapActions, mapState } from 'vuex';
import { __ } from '~/locale';
+import Tracking from '~/tracking';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { mapComputed } from '~/vuex_shared/bindings';
import {
@@ -25,10 +26,14 @@ import {
AWS_TIP_DISMISSED_COOKIE_NAME,
AWS_TIP_MESSAGE,
CONTAINS_VARIABLE_REFERENCE_MESSAGE,
+ EVENT_LABEL,
+ EVENT_ACTION,
} from '../constants';
import CiEnvironmentsDropdown from './ci_environments_dropdown.vue';
import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens';
+const trackingMixin = Tracking.mixin({ label: EVENT_LABEL });
+
export default {
modalId: ADD_CI_VARIABLE_MODAL_ID,
tokens: awsTokens,
@@ -51,10 +56,11 @@ export default {
GlModal,
GlSprintf,
},
- mixins: [glFeatureFlagsMixin()],
+ mixins: [glFeatureFlagsMixin(), trackingMixin],
data() {
return {
isTipDismissed: Cookies.get(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true',
+ validationErrorEventProperty: '',
};
},
computed: {
@@ -147,6 +153,14 @@ export default {
return this.variable.secret_value === '' || (this.tokenValidationState && this.maskedState);
},
},
+ watch: {
+ variable: {
+ handler() {
+ this.trackVariableValidationErrors();
+ },
+ deep: true,
+ },
+ },
methods: {
...mapActions([
'addVariable',
@@ -179,6 +193,7 @@ export default {
this.clearModal();
this.resetSelectedEnvironment();
+ this.resetValidationErrorEvents();
},
updateOrAddVariable() {
if (this.variableBeingEdited) {
@@ -193,6 +208,31 @@ export default {
this.setVariableProtected();
}
},
+ trackVariableValidationErrors() {
+ const property = this.getTrackingErrorProperty();
+ if (!this.validationErrorEventProperty && property) {
+ this.track(EVENT_ACTION, { property });
+ this.validationErrorEventProperty = property;
+ }
+ },
+ getTrackingErrorProperty() {
+ let property;
+ if (this.variable.secret_value?.length && !property) {
+ if (this.displayMaskedError && this.maskableRegex?.length) {
+ const supportedChars = this.maskableRegex.replace('^', '').replace(/{(\d,)}\$/, '');
+ const regex = new RegExp(supportedChars, 'g');
+ property = this.variable.secret_value.replace(regex, '');
+ }
+ if (this.containsVariableReference) {
+ property = '$';
+ }
+ }
+
+ return property;
+ },
+ resetValidationErrorEvents() {
+ this.validationErrorEventProperty = '';
+ },
},
};
</script>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
index b959d97daea..9c0ffab7f6b 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTable, GlButton, GlModalDirective, GlIcon } from '@gitlab/ui';
+import { GlTable, GlButton, GlModalDirective, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { s__, __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -59,6 +59,7 @@ export default {
},
directives: {
GlModalDirective,
+ GlTooltip: GlTooltipDirective,
},
mixins: [glFeatureFlagsMixin()],
computed: {
@@ -102,27 +103,38 @@ export default {
<col v-for="field in scope.fields" :key="field.key" :style="field.customStyle" />
</template>
<template #cell(key)="{ item }">
- <div class="d-flex truncated-container">
- <span :id="`ci-variable-key-${item.id}`" class="d-inline-block mw-100 text-truncate">{{
- item.key
- }}</span>
- <ci-variable-popover
- :target="`ci-variable-key-${item.id}`"
- :value="item.key"
- :tooltip-text="__('Copy key')"
+ <div class="gl-display-flex truncated-container gl-align-items-center">
+ <span
+ :id="`ci-variable-key-${item.id}`"
+ class="gl-display-inline-block gl-max-w-full gl-text-truncate"
+ >{{ item.key }}</span
+ >
+ <gl-button
+ v-gl-tooltip
+ category="tertiary"
+ icon="copy-to-clipboard"
+ :title="__('Copy key')"
+ :data-clipboard-text="item.key"
+ :aria-label="__('Copy to clipboard')"
/>
</div>
</template>
<template #cell(value)="{ item }">
- <span v-if="valuesHidden">*********************</span>
- <div v-else class="d-flex truncated-container">
- <span :id="`ci-variable-value-${item.id}`" class="d-inline-block mw-100 text-truncate">{{
- item.value
- }}</span>
- <ci-variable-popover
- :target="`ci-variable-value-${item.id}`"
- :value="item.value"
- :tooltip-text="__('Copy value')"
+ <div class="gl-display-flex gl-align-items-center truncated-container">
+ <span v-if="valuesHidden">*********************</span>
+ <span
+ v-else
+ :id="`ci-variable-value-${item.id}`"
+ class="gl-display-inline-block gl-max-w-full gl-text-truncate"
+ >{{ item.value }}</span
+ >
+ <gl-button
+ v-gl-tooltip
+ category="tertiary"
+ icon="copy-to-clipboard"
+ :title="__('Copy value')"
+ :data-clipboard-text="item.value"
+ :aria-label="__('Copy to clipboard')"
/>
</div>
</template>
diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js
index 4ebbf05814b..663a912883b 100644
--- a/app/assets/javascripts/ci_variable_list/constants.js
+++ b/app/assets/javascripts/ci_variable_list/constants.js
@@ -19,6 +19,9 @@ 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 EVENT_LABEL = 'ci_variable_modal';
+export const EVENT_ACTION = 'validation_error';
+
// AWS TOKEN CONSTANTS
export const AWS_ACCESS_KEY_ID = 'AWS_ACCESS_KEY_ID';
export const AWS_DEFAULT_REGION = 'AWS_DEFAULT_REGION';
diff --git a/app/assets/javascripts/clusters/agents/components/activity_events_list.vue b/app/assets/javascripts/clusters/agents/components/activity_events_list.vue
new file mode 100644
index 00000000000..6567ce203bc
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/components/activity_events_list.vue
@@ -0,0 +1,176 @@
+<script>
+import {
+ GlLoadingIcon,
+ GlEmptyState,
+ GlLink,
+ GlIcon,
+ GlAlert,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { n__, s__, __ } from '~/locale';
+import { formatDate, getDayDifference, isToday } from '~/lib/utils/datetime_utility';
+import { EVENTS_STORED_DAYS } from '../constants';
+import getAgentActivityEventsQuery from '../graphql/queries/get_agent_activity_events.query.graphql';
+import ActivityHistoryItem from './activity_history_item.vue';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ GlEmptyState,
+ GlAlert,
+ GlLink,
+ GlIcon,
+ ActivityHistoryItem,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ i18n: {
+ emptyText: s__(
+ 'ClusterAgents|See Agent activity updates such as tokens created or revoked and clusters connected or not connected.',
+ ),
+ emptyTooltip: s__('ClusterAgents|What is GitLab Agent activity?'),
+ error: s__(
+ 'ClusterAgents|An error occurred while retrieving GitLab Agent activity. Reload the page to try again.',
+ ),
+ today: __('Today'),
+ yesterday: __('Yesterday'),
+ },
+ emptyHelpLink: helpPagePath('user/clusters/agent/install/index', {
+ anchor: 'view-agent-activity',
+ }),
+ borderClasses: 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100',
+ apollo: {
+ agentEvents: {
+ query: getAgentActivityEventsQuery,
+ variables() {
+ return {
+ agentName: this.agentName,
+ projectPath: this.projectPath,
+ };
+ },
+ update: (data) => data?.project?.clusterAgent?.activityEvents?.nodes,
+ error() {
+ this.isError = true;
+ },
+ },
+ },
+ inject: ['agentName', 'projectPath', 'activityEmptyStateImage'],
+ data() {
+ return {
+ isError: false,
+ };
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.agentEvents?.loading;
+ },
+ emptyStateTitle() {
+ return n__(
+ "ClusterAgents|There's no activity from the past day",
+ "ClusterAgents|There's no activity from the past %d days",
+ EVENTS_STORED_DAYS,
+ );
+ },
+ eventsList() {
+ const list = this.agentEvents;
+ const listByDates = {};
+
+ if (!list?.length) {
+ return listByDates;
+ }
+
+ list.forEach((event) => {
+ const dateName = this.getFormattedDate(event.recordedAt);
+ if (!listByDates[dateName]) {
+ listByDates[dateName] = [];
+ }
+ listByDates[dateName].push(event);
+ });
+
+ return listByDates;
+ },
+ hasEvents() {
+ return Object.keys(this.eventsList).length;
+ },
+ },
+ methods: {
+ isYesterday(date) {
+ const today = new Date();
+ return getDayDifference(today, date) === -1;
+ },
+ getFormattedDate(dateString) {
+ const date = new Date(dateString);
+ let dateName;
+ if (isToday(date)) {
+ dateName = this.$options.i18n.today;
+ } else if (this.isYesterday(date)) {
+ dateName = this.$options.i18n.yesterday;
+ } else {
+ dateName = formatDate(date, 'yyyy-mm-dd');
+ }
+ return dateName;
+ },
+ isLast(dateEvents, idx) {
+ return idx === dateEvents.length - 1;
+ },
+ getBodyClasses(dateEvents, idx) {
+ return !this.isLast(dateEvents, idx) ? this.$options.borderClasses : '';
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-loading-icon v-if="isLoading" size="md" />
+
+ <div v-else-if="hasEvents">
+ <div
+ v-for="(dateEvents, key) in eventsList"
+ :key="key"
+ class="agent-activity-list issuable-discussion"
+ >
+ <h4
+ class="gl-pb-4 gl-ml-5"
+ :class="$options.borderClasses"
+ data-testid="activity-section-title"
+ >
+ {{ key }}
+ </h4>
+
+ <ul class="notes main-notes-list timeline">
+ <activity-history-item
+ v-for="(event, idx) in dateEvents"
+ :key="idx"
+ :event="event"
+ :body-class="getBodyClasses(dateEvents, idx)"
+ />
+ </ul>
+ </div>
+ </div>
+
+ <gl-alert v-else-if="isError" variant="danger" :dismissible="false" class="gl-mt-3">
+ {{ $options.i18n.error }}
+ </gl-alert>
+
+ <gl-empty-state
+ v-else
+ :title="emptyStateTitle"
+ :svg-path="activityEmptyStateImage"
+ :svg-height="150"
+ >
+ <template #description
+ >{{ $options.i18n.emptyText }}
+ <gl-link
+ v-gl-tooltip
+ :href="$options.emptyHelpLink"
+ :title="$options.i18n.emptyTooltip"
+ :aria-label="$options.i18n.emptyTooltip"
+ ><gl-icon name="question" :size="14"
+ /></gl-link>
+ </template>
+ </gl-empty-state>
+ </div>
+</template>
diff --git a/app/assets/javascripts/clusters/agents/components/activity_history_item.vue b/app/assets/javascripts/clusters/agents/components/activity_history_item.vue
new file mode 100644
index 00000000000..7792d89a575
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/components/activity_history_item.vue
@@ -0,0 +1,79 @@
+<script>
+import { GlLink, GlIcon, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import HistoryItem from '~/vue_shared/components/registry/history_item.vue';
+import { EVENT_DETAILS, DEFAULT_ICON } from '../constants';
+
+export default {
+ i18n: {
+ defaultBodyText: s__('ClusterAgents|Event occurred'),
+ },
+ components: {
+ GlLink,
+ GlIcon,
+ GlSprintf,
+ TimeAgoTooltip,
+ HistoryItem,
+ },
+ props: {
+ event: {
+ required: true,
+ type: Object,
+ },
+ bodyClass: {
+ required: false,
+ default: '',
+ type: String,
+ },
+ },
+ computed: {
+ eventDetails() {
+ const defaultEvent = {
+ eventTypeIcon: DEFAULT_ICON,
+ title: this.event.kind,
+ body: this.$options.i18n.defaultBodyText,
+ };
+
+ const eventDetails = EVENT_DETAILS[this.event.kind] || defaultEvent;
+ const { eventTypeIcon, title, body, titleIcon } = eventDetails;
+ const resultEvent = { ...this.event, eventTypeIcon, title, body, titleIcon };
+
+ return resultEvent;
+ },
+ },
+};
+</script>
+<template>
+ <history-item :icon="eventDetails.eventTypeIcon" class="gl-my-0! gl-pr-0!">
+ <strong>
+ <gl-sprintf :message="eventDetails.title"
+ ><template v-if="eventDetails.titleIcon" #titleIcon
+ ><gl-icon
+ class="gl-mr-2"
+ :name="eventDetails.titleIcon.name"
+ :size="12"
+ :class="eventDetails.titleIcon.class"
+ />
+ </template>
+ <template #tokenName>{{ eventDetails.agentToken.name }}</template></gl-sprintf
+ >
+ </strong>
+
+ <template #body>
+ <p class="gl-mt-2 gl-mb-0 gl-pb-2" :class="bodyClass">
+ <gl-sprintf :message="eventDetails.body">
+ <template #userName>
+ <span class="gl-font-weight-bold">{{ eventDetails.user.name }}</span>
+ <gl-link :href="eventDetails.user.webUrl">@{{ eventDetails.user.username }}</gl-link>
+ </template>
+
+ <template #strong="{ content }">
+ <span class="gl-font-weight-bold"> {{ content }} </span>
+ </template>
+ </gl-sprintf>
+ <time-ago-tooltip :time="eventDetails.recordedAt" />
+ </p>
+ </template>
+ </history-item>
+</template>
diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue
index afbba9d1f7c..9109c010500 100644
--- a/app/assets/javascripts/clusters/agents/components/show.vue
+++ b/app/assets/javascripts/clusters/agents/components/show.vue
@@ -8,11 +8,12 @@ import {
GlTab,
GlTabs,
} from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { MAX_LIST_COUNT } from '../constants';
import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql';
import TokenTable from './token_table.vue';
+import ActivityEvents from './activity_events_list.vue';
export default {
i18n: {
@@ -20,6 +21,7 @@ export default {
loadingError: s__('ClusterAgents|An error occurred while loading your agent'),
tokens: s__('ClusterAgents|Access tokens'),
unknownUser: s__('ClusterAgents|Unknown user'),
+ activity: __('Activity'),
},
apollo: {
clusterAgent: {
@@ -47,6 +49,7 @@ export default {
GlTabs,
TimeAgoTooltip,
TokenTable,
+ ActivityEvents,
},
props: {
agentName: {
@@ -127,9 +130,14 @@ export default {
</gl-sprintf>
</p>
- <gl-tabs>
+ <gl-tabs sync-active-tab-with-query-params lazy>
+ <gl-tab :title="$options.i18n.activity" query-param-value="activity">
+ <activity-events :agent-name="agentName" :project-path="projectPath" />
+ </gl-tab>
+
<slot name="ee-security-tab"></slot>
- <gl-tab>
+
+ <gl-tab query-param-value="tokens">
<template #title>
<span data-testid="cluster-agent-token-count">
{{ $options.i18n.tokens }}
@@ -143,7 +151,7 @@ export default {
<gl-loading-icon v-if="isLoading" size="md" class="gl-m-3" />
<div v-else>
- <TokenTable :tokens="tokens" />
+ <token-table :tokens="tokens" />
<div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5">
<gl-keyset-pagination v-bind="tokenPageInfo" @prev="prevPage" @next="nextPage" />
diff --git a/app/assets/javascripts/clusters/agents/components/token_table.vue b/app/assets/javascripts/clusters/agents/components/token_table.vue
index 70ed2566134..019fac531d1 100644
--- a/app/assets/javascripts/clusters/agents/components/token_table.vue
+++ b/app/assets/javascripts/clusters/agents/components/token_table.vue
@@ -62,8 +62,8 @@ export default {
];
},
learnMoreUrl() {
- return helpPagePath('user/clusters/agent/index.md', {
- anchor: 'create-an-agent-record-in-gitlab',
+ return helpPagePath('user/clusters/agent/install/index', {
+ anchor: 'register-an-agent-with-gitlab',
});
},
},
@@ -83,7 +83,14 @@ export default {
</gl-link>
</div>
- <gl-table :items="tokens" :fields="fields" fixed stacked="md">
+ <gl-table
+ :items="tokens"
+ :fields="fields"
+ fixed
+ stacked="md"
+ head-variant="white"
+ thead-class="gl-border-b-solid gl-border-b-2 gl-border-b-gray-100"
+ >
<template #cell(lastUsed)="{ item }">
<time-ago-tooltip v-if="item.lastUsedAt" :time="item.lastUsedAt" />
<span v-else>{{ $options.i18n.neverUsed }}</span>
diff --git a/app/assets/javascripts/clusters/agents/constants.js b/app/assets/javascripts/clusters/agents/constants.js
index bbc4630f83b..315c7662755 100644
--- a/app/assets/javascripts/clusters/agents/constants.js
+++ b/app/assets/javascripts/clusters/agents/constants.js
@@ -1 +1,38 @@
+import { s__ } from '~/locale';
+
export const MAX_LIST_COUNT = 25;
+
+export const EVENTS_STORED_DAYS = 7;
+
+export const EVENT_DETAILS = {
+ token_created: {
+ eventTypeIcon: 'token',
+ title: s__('ClusterAgents|%{tokenName} created'),
+ body: s__('ClusterAgents|Token created by %{userName}'),
+ },
+ token_revoked: {
+ eventTypeIcon: 'token',
+ title: s__('ClusterAgents|%{tokenName} revoked'),
+ body: s__('ClusterAgents|Token revoked by %{userName}'),
+ },
+ agent_connected: {
+ eventTypeIcon: 'connected',
+ title: s__('ClusterAgents|%{titleIcon}Connected'),
+ body: s__('ClusterAgents|Agent %{strongStart}connected%{strongEnd}'),
+ titleIcon: {
+ name: 'status-success',
+ class: 'text-success-500',
+ },
+ },
+ agent_disconnected: {
+ eventTypeIcon: 'connected',
+ title: s__('ClusterAgents|%{titleIcon}Not connected'),
+ body: s__('ClusterAgents|Agent %{strongStart}disconnected%{strongEnd}'),
+ titleIcon: {
+ name: 'severity-critical',
+ class: 'text-danger-800',
+ },
+ },
+};
+
+export const DEFAULT_ICON = 'token';
diff --git a/app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql b/app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql
index 1e9187e8ad1..7deb057ede9 100644
--- a/app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql
+++ b/app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql
@@ -4,8 +4,8 @@ fragment Token on ClusterAgentToken {
description
lastUsedAt
name
-
createdByUser {
+ id
name
}
}
diff --git a/app/assets/javascripts/clusters/agents/graphql/queries/get_agent_activity_events.query.graphql b/app/assets/javascripts/clusters/agents/graphql/queries/get_agent_activity_events.query.graphql
new file mode 100644
index 00000000000..0d7ff029387
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/graphql/queries/get_agent_activity_events.query.graphql
@@ -0,0 +1,25 @@
+query getAgentActivityEvents($projectPath: ID!, $agentName: String!) {
+ project(fullPath: $projectPath) {
+ id
+ clusterAgent(name: $agentName) {
+ id
+ activityEvents {
+ nodes {
+ kind
+ level
+ recordedAt
+ agentToken {
+ id
+ name
+ }
+ user {
+ id
+ name
+ username
+ webUrl
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql
index d01db8f0a6a..3662e925261 100644
--- a/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql
+++ b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql
@@ -10,11 +10,13 @@ query getClusterAgent(
$beforeToken: String
) {
project(fullPath: $projectPath) {
+ id
clusterAgent(name: $agentName) {
id
createdAt
createdByUser {
+ id
name
}
diff --git a/app/assets/javascripts/clusters/agents/index.js b/app/assets/javascripts/clusters/agents/index.js
index 426d8d83847..5796c9e308d 100644
--- a/app/assets/javascripts/clusters/agents/index.js
+++ b/app/assets/javascripts/clusters/agents/index.js
@@ -13,11 +13,12 @@ export default () => {
}
const defaultClient = createDefaultClient();
- const { agentName, projectPath } = el.dataset;
+ const { agentName, projectPath, activityEmptyStateImage } = el.dataset;
return new Vue({
el,
apolloProvider: new VueApollo({ defaultClient }),
+ provide: { agentName, projectPath, activityEmptyStateImage },
render(createElement) {
return createElement(AgentShowPage, {
props: {
diff --git a/app/assets/javascripts/clusters_list/components/agent_empty_state.vue b/app/assets/javascripts/clusters_list/components/agent_empty_state.vue
index af44a23b4b3..f54f7b11414 100644
--- a/app/assets/javascripts/clusters_list/components/agent_empty_state.vue
+++ b/app/assets/javascripts/clusters_list/components/agent_empty_state.vue
@@ -1,107 +1,54 @@
<script>
-import { GlButton, GlEmptyState, GlLink, GlSprintf, GlAlert, GlModalDirective } from '@gitlab/ui';
+import { GlButton, GlEmptyState, GlLink, GlSprintf, GlModalDirective } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { INSTALL_AGENT_MODAL_ID, I18N_AGENTS_EMPTY_STATE } from '../constants';
export default {
i18n: I18N_AGENTS_EMPTY_STATE,
modalId: INSTALL_AGENT_MODAL_ID,
- multipleClustersDocsUrl: helpPagePath('user/project/clusters/multiple_kubernetes_clusters'),
- installDocsUrl: helpPagePath('administration/clusters/kas'),
- getStartedDocsUrl: helpPagePath('user/clusters/agent/index', {
- anchor: 'define-a-configuration-repository',
- }),
+ agentDocsUrl: helpPagePath('user/clusters/agent/index'),
components: {
GlButton,
GlEmptyState,
GlLink,
GlSprintf,
- GlAlert,
},
directives: {
GlModalDirective,
},
- inject: ['emptyStateImage', 'projectPath'],
+ inject: ['emptyStateImage'],
props: {
- hasConfigurations: {
- type: Boolean,
- required: true,
- },
isChildComponent: {
default: false,
required: false,
type: Boolean,
},
},
- computed: {
- repositoryPath() {
- return `/${this.projectPath}`;
- },
- },
};
</script>
<template>
<gl-empty-state :svg-path="emptyStateImage" title="" class="agents-empty-state">
<template #description>
- <p class="mw-460 gl-mx-auto gl-text-left">
- {{ $options.i18n.introText }}
- </p>
- <p class="mw-460 gl-mx-auto gl-text-left">
- <gl-sprintf :message="$options.i18n.multipleClustersText">
+ <p class="gl-text-left">
+ <gl-sprintf :message="$options.i18n.introText">
<template #link="{ content }">
- <gl-link
- :href="$options.multipleClustersDocsUrl"
- target="_blank"
- data-testid="multiple-clusters-docs-link"
- >
+ <gl-link :href="$options.agentDocsUrl">
{{ content }}
</gl-link>
</template>
</gl-sprintf>
</p>
-
- <p class="mw-460 gl-mx-auto">
- <gl-link :href="$options.installDocsUrl" target="_blank" data-testid="install-docs-link">
- {{ $options.i18n.learnMoreText }}
- </gl-link>
- </p>
-
- <gl-alert
- v-if="!hasConfigurations"
- variant="warning"
- class="gl-mb-5 text-left"
- :dismissible="false"
- >
- {{ $options.i18n.warningText }}
-
- <template #actions>
- <gl-button
- category="primary"
- variant="info"
- :href="$options.getStartedDocsUrl"
- target="_blank"
- class="gl-ml-0!"
- >
- {{ $options.i18n.readMoreText }}
- </gl-button>
- <gl-button category="secondary" variant="info" :href="repositoryPath">
- {{ $options.i18n.repositoryButtonText }}
- </gl-button>
- </template>
- </gl-alert>
</template>
<template #actions>
<gl-button
v-if="!isChildComponent"
v-gl-modal-directive="$options.modalId"
- :disabled="!hasConfigurations"
- data-testid="integration-primary-button"
category="primary"
variant="confirm"
>
- {{ $options.i18n.primaryButtonText }}
+ {{ $options.i18n.buttonText }}
</gl-button>
</template>
</gl-empty-state>
diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue
index fb5cf7d1206..45108a28e37 100644
--- a/app/assets/javascripts/clusters_list/components/agents.vue
+++ b/app/assets/javascripts/clusters_list/components/agents.vue
@@ -86,9 +86,6 @@ export default {
treePageInfo() {
return this.agents?.project?.repository?.tree?.trees?.pageInfo || {};
},
- hasConfigurations() {
- return Boolean(this.agents?.project?.repository?.tree?.trees?.nodes?.length);
- },
},
methods: {
reloadAgents() {
@@ -161,11 +158,7 @@ export default {
</div>
</div>
- <agent-empty-state
- v-else
- :has-configurations="hasConfigurations"
- :is-child-component="isChildComponent"
- />
+ <agent-empty-state v-else :is-child-component="isChildComponent" />
</section>
<gl-alert v-else variant="danger" :dismissible="false">
diff --git a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue
index 9fb020d2f4f..1630d0d5c92 100644
--- a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue
+++ b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue
@@ -1,7 +1,6 @@
<script>
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { I18N_AVAILABLE_AGENTS_DROPDOWN } from '../constants';
-import agentConfigurations from '../graphql/queries/agent_configurations.query.graphql';
export default {
name: 'AvailableAgentsDropdown',
@@ -10,36 +9,22 @@ export default {
GlDropdown,
GlDropdownItem,
},
- inject: ['projectPath'],
props: {
isRegistering: {
required: true,
type: Boolean,
},
- },
- apollo: {
- agents: {
- query: agentConfigurations,
- variables() {
- return {
- projectPath: this.projectPath,
- };
- },
- update(data) {
- this.populateAvailableAgents(data);
- },
+ availableAgents: {
+ required: true,
+ type: Array,
},
},
data() {
return {
- availableAgents: [],
selectedAgent: null,
};
},
computed: {
- isLoading() {
- return this.$apollo.queries.agents.loading;
- },
dropdownText() {
if (this.isRegistering) {
return this.$options.i18n.registeringAgent;
@@ -58,18 +43,11 @@ export default {
isSelected(agent) {
return this.selectedAgent === agent;
},
- populateAvailableAgents(data) {
- const installedAgents = data?.project?.clusterAgents?.nodes.map((agent) => agent.name) ?? [];
- const configuredAgents =
- data?.project?.agentConfigurations?.nodes.map((config) => config.agentName) ?? [];
-
- this.availableAgents = configuredAgents.filter((agent) => !installedAgents.includes(agent));
- },
},
};
</script>
<template>
- <gl-dropdown :text="dropdownText" :loading="isLoading || isRegistering">
+ <gl-dropdown :text="dropdownText" :loading="isRegistering">
<gl-dropdown-item
v-for="agent in availableAgents"
:key="agent"
diff --git a/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue b/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue
index 3879af6e9cb..ce601de57bd 100644
--- a/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue
@@ -1,5 +1,5 @@
<script>
-import { GlEmptyState, GlButton, GlLink, GlSprintf } from '@gitlab/ui';
+import { GlEmptyState, GlButton, GlLink, GlSprintf, GlAlert } from '@gitlab/ui';
import { mapState } from 'vuex';
import { helpPagePath } from '~/helpers/help_page_helper';
import { I18N_CLUSTERS_EMPTY_STATE } from '../constants';
@@ -11,6 +11,7 @@ export default {
GlButton,
GlLink,
GlSprintf,
+ GlAlert,
},
inject: ['emptyStateHelpText', 'clustersEmptyStateImage', 'newClusterPath'],
props: {
@@ -20,8 +21,11 @@ export default {
type: Boolean,
},
},
- learnMoreHelpUrl: helpPagePath('user/project/clusters/index'),
- multipleClustersHelpUrl: helpPagePath('user/project/clusters/multiple_kubernetes_clusters'),
+ clustersHelpUrl: helpPagePath('user/infrastructure/clusters/index', {
+ anchor: 'certificate-based-kubernetes-integration-deprecated',
+ }),
+ blogPostUrl:
+ 'https://about.gitlab.com/blog/2021/11/15/deprecating-the-cert-based-kubernetes-integration/',
computed: {
...mapState(['canAddCluster']),
},
@@ -29,48 +33,45 @@ export default {
</script>
<template>
- <gl-empty-state :svg-path="clustersEmptyStateImage" title="">
- <template #description>
- <p class="gl-text-left">
- {{ $options.i18n.description }}
- </p>
- <p class="gl-text-left">
- <gl-sprintf :message="$options.i18n.multipleClustersText">
- <template #link="{ content }">
- <gl-link
- :href="$options.multipleClustersHelpUrl"
- target="_blank"
- data-testid="multiple-clusters-docs-link"
- >
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </p>
+ <div>
+ <gl-empty-state :svg-path="clustersEmptyStateImage" title="">
+ <template #description>
+ <p class="gl-text-left">
+ <gl-sprintf :message="$options.i18n.introText">
+ <template #link="{ content }">
+ <gl-link :href="$options.clustersHelpUrl">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
- <p v-if="emptyStateHelpText" data-testid="clusters-empty-state-text">
- {{ emptyStateHelpText }}
- </p>
+ <p v-if="emptyStateHelpText" data-testid="clusters-empty-state-text">
+ {{ emptyStateHelpText }}
+ </p>
+ </template>
- <p>
- <gl-link :href="$options.learnMoreHelpUrl" target="_blank" data-testid="clusters-docs-link">
- {{ $options.i18n.learnMoreLinkText }}
- </gl-link>
- </p>
- </template>
+ <template #actions>
+ <gl-button
+ v-if="!isChildComponent"
+ data-testid="integration-primary-button"
+ data-qa-selector="add_kubernetes_cluster_link"
+ category="primary"
+ variant="confirm"
+ :disabled="!canAddCluster"
+ :href="newClusterPath"
+ >
+ {{ $options.i18n.buttonText }}
+ </gl-button>
+ </template>
+ </gl-empty-state>
- <template #actions>
- <gl-button
- v-if="!isChildComponent"
- data-testid="integration-primary-button"
- data-qa-selector="add_kubernetes_cluster_link"
- category="primary"
- variant="confirm"
- :disabled="!canAddCluster"
- :href="newClusterPath"
- >
- {{ $options.i18n.buttonText }}
- </gl-button>
- </template>
- </gl-empty-state>
+ <gl-alert variant="warning" :dismissible="false">
+ <gl-sprintf :message="$options.i18n.alertText">
+ <template #link="{ content }">
+ <gl-link :href="$options.blogPostUrl" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ </div>
</template>
diff --git a/app/assets/javascripts/clusters_list/components/clusters_main_view.vue b/app/assets/javascripts/clusters_list/components/clusters_main_view.vue
index 9e03093aa67..7dd5ece9b8e 100644
--- a/app/assets/javascripts/clusters_list/components/clusters_main_view.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters_main_view.vue
@@ -1,12 +1,22 @@
<script>
import { GlTabs, GlTab } from '@gitlab/ui';
-import { CLUSTERS_TABS, MAX_CLUSTERS_LIST, MAX_LIST_COUNT, AGENT } from '../constants';
+import Tracking from '~/tracking';
+import {
+ CLUSTERS_TABS,
+ MAX_CLUSTERS_LIST,
+ MAX_LIST_COUNT,
+ AGENT,
+ EVENT_LABEL_TABS,
+ EVENT_ACTIONS_CHANGE,
+} from '../constants';
import Agents from './agents.vue';
import InstallAgentModal from './install_agent_modal.vue';
import ClustersActions from './clusters_actions.vue';
import Clusters from './clusters.vue';
import ClustersViewAll from './clusters_view_all.vue';
+const trackingMixin = Tracking.mixin({ label: EVENT_LABEL_TABS });
+
export default {
components: {
GlTabs,
@@ -18,6 +28,7 @@ export default {
InstallAgentModal,
},
CLUSTERS_TABS,
+ mixins: [trackingMixin],
props: {
defaultBranchName: {
default: '.noBranch',
@@ -34,9 +45,12 @@ export default {
methods: {
onTabChange(tabName) {
this.selectedTabIndex = CLUSTERS_TABS.findIndex((tab) => tab.queryParamValue === tabName);
-
this.maxAgents = tabName === AGENT ? MAX_LIST_COUNT : MAX_CLUSTERS_LIST;
},
+ trackTabChange(tab) {
+ const tabName = CLUSTERS_TABS[tab].queryParamValue;
+ this.track(EVENT_ACTIONS_CHANGE, { property: tabName });
+ },
},
};
</script>
@@ -47,6 +61,7 @@ export default {
sync-active-tab-with-query-params
nav-class="gl-flex-grow-1 gl-align-items-center"
lazy
+ @input="trackTabChange"
>
<gl-tab
v-for="(tab, idx) in $options.CLUSTERS_TABS"
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 285876e57d8..0e312d21e4e 100644
--- a/app/assets/javascripts/clusters_list/components/clusters_view_all.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue
@@ -34,10 +34,12 @@ export default {
directives: {
GlModalDirective,
},
- AGENT_CARD_INFO,
- CERTIFICATE_BASED_CARD_INFO,
MAX_CLUSTERS_LIST,
INSTALL_AGENT_MODAL_ID,
+ i18n: {
+ agent: AGENT_CARD_INFO,
+ certificate: CERTIFICATE_BASED_CARD_INFO,
+ },
inject: ['addClusterPath'],
props: {
defaultBranchName: {
@@ -122,21 +124,21 @@ export default {
</gl-sprintf>
</h3>
- <gl-badge id="clusters-recommended-badge" size="md" variant="info">{{
- $options.AGENT_CARD_INFO.tooltip.label
+ <gl-badge id="clusters-recommended-badge" variant="info">{{
+ $options.i18n.agent.tooltip.label
}}</gl-badge>
<gl-popover
target="clusters-recommended-badge"
container="viewport"
placement="bottom"
- :title="$options.AGENT_CARD_INFO.tooltip.title"
+ :title="$options.i18n.agent.tooltip.title"
>
<p class="gl-mb-0">
- <gl-sprintf :message="$options.AGENT_CARD_INFO.tooltip.text">
+ <gl-sprintf :message="$options.i18n.agent.tooltip.text">
<template #link="{ content }">
<gl-link
- :href="$options.AGENT_CARD_INFO.tooltip.link"
+ :href="$options.i18n.agent.tooltip.link"
target="_blank"
class="gl-font-sm"
>
@@ -159,9 +161,9 @@ export default {
<gl-link
v-if="totalAgents"
data-testid="agents-tab-footer-link"
- :href="`?tab=${$options.AGENT_CARD_INFO.tabName}`"
- @click="changeTab($event, $options.AGENT_CARD_INFO.tabName)"
- ><gl-sprintf :message="$options.AGENT_CARD_INFO.footerText"
+ :href="`?tab=${$options.i18n.agent.tabName}`"
+ @click="changeTab($event, $options.i18n.agent.tabName)"
+ ><gl-sprintf :message="$options.i18n.agent.footerText"
><template #number>{{ cardFooterNumber(totalAgents) }}</template></gl-sprintf
></gl-link
><gl-button
@@ -169,7 +171,7 @@ export default {
class="gl-ml-4"
category="secondary"
variant="confirm"
- >{{ $options.AGENT_CARD_INFO.actionText }}</gl-button
+ >{{ $options.i18n.agent.actionText }}</gl-button
>
</template>
</gl-card>
@@ -190,6 +192,7 @@ export default {
<template #total>{{ clustersCardTitle.total }}</template>
</gl-sprintf>
</h3>
+ <gl-badge variant="warning">{{ $options.i18n.certificate.badgeText }}</gl-badge>
</template>
<clusters :limit="$options.MAX_CLUSTERS_LIST" :is-child-component="true" />
@@ -198,9 +201,9 @@ export default {
<gl-link
v-if="totalClusters"
data-testid="clusters-tab-footer-link"
- :href="`?tab=${$options.CERTIFICATE_BASED_CARD_INFO.tabName}`"
- @click="changeTab($event, $options.CERTIFICATE_BASED_CARD_INFO.tabName)"
- ><gl-sprintf :message="$options.CERTIFICATE_BASED_CARD_INFO.footerText"
+ :href="`?tab=${$options.i18n.certificate.tabName}`"
+ @click="changeTab($event, $options.i18n.certificate.tabName)"
+ ><gl-sprintf :message="$options.i18n.certificate.footerText"
><template #number>{{ cardFooterNumber(totalClusters) }}</template></gl-sprintf
></gl-link
><gl-button
@@ -209,7 +212,7 @@ export default {
variant="confirm"
class="gl-ml-4"
:href="addClusterPath"
- >{{ $options.CERTIFICATE_BASED_CARD_INFO.actionText }}</gl-button
+ >{{ $options.i18n.certificate.actionText }}</gl-button
>
</template>
</gl-card>
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 6eb2e85ecea..5eef76252bd 100644
--- a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
+++ b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
@@ -9,22 +9,48 @@ import {
GlSprintf,
} from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
-import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
import CodeBlock from '~/vue_shared/components/code_block.vue';
+import Tracking from '~/tracking';
import { generateAgentRegistrationCommand } from '../clusters_util';
-import { INSTALL_AGENT_MODAL_ID, I18N_INSTALL_AGENT_MODAL } from '../constants';
-import { addAgentToStore } from '../graphql/cache_update';
+import {
+ INSTALL_AGENT_MODAL_ID,
+ I18N_AGENT_MODAL,
+ KAS_DISABLED_ERROR,
+ EVENT_LABEL_MODAL,
+ EVENT_ACTIONS_OPEN,
+ EVENT_ACTIONS_SELECT,
+ EVENT_ACTIONS_CLICK,
+ MODAL_TYPE_EMPTY,
+ MODAL_TYPE_REGISTER,
+} from '../constants';
+import { addAgentToStore, addAgentConfigToStore } from '../graphql/cache_update';
import createAgent from '../graphql/mutations/create_agent.mutation.graphql';
import createAgentToken from '../graphql/mutations/create_agent_token.mutation.graphql';
import getAgentsQuery from '../graphql/queries/get_agents.query.graphql';
+import agentConfigurations from '../graphql/queries/agent_configurations.query.graphql';
import AvailableAgentsDropdown from './available_agents_dropdown.vue';
+const trackingMixin = Tracking.mixin({ label: EVENT_LABEL_MODAL });
+
export default {
modalId: INSTALL_AGENT_MODAL_ID,
- i18n: I18N_INSTALL_AGENT_MODAL,
+ EVENT_ACTIONS_OPEN,
+ EVENT_ACTIONS_CLICK,
+ EVENT_LABEL_MODAL,
+ basicInstallPath: helpPagePath('user/clusters/agent/install/index', {
+ anchor: 'install-the-agent-into-the-cluster',
+ }),
+ advancedInstallPath: helpPagePath('user/clusters/agent/install/index', {
+ anchor: 'advanced-installation',
+ }),
+ enableKasPath: helpPagePath('administration/clusters/kas'),
+ installAgentPath: helpPagePath('user/clusters/agent/install/index'),
+ registerAgentPath: helpPagePath('user/clusters/agent/install/index', {
+ anchor: 'register-an-agent-with-gitlab',
+ }),
components: {
AvailableAgentsDropdown,
- ClipboardButton,
CodeBlock,
GlAlert,
GlButton,
@@ -33,8 +59,10 @@ export default {
GlLink,
GlModal,
GlSprintf,
+ ModalCopyButton,
},
- inject: ['projectPath', 'kasAddress'],
+ mixins: [trackingMixin],
+ inject: ['projectPath', 'kasAddress', 'emptyStateImage'],
props: {
defaultBranchName: {
default: '.noBranch',
@@ -46,6 +74,22 @@ export default {
type: Number,
},
},
+ apollo: {
+ agents: {
+ query: agentConfigurations,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ };
+ },
+ update(data) {
+ this.populateAvailableAgents(data);
+ },
+ error(error) {
+ this.kasDisabled = error?.message?.indexOf(KAS_DISABLED_ERROR) >= 0;
+ },
+ },
+ },
data() {
return {
registering: false,
@@ -53,6 +97,8 @@ export default {
agentToken: null,
error: null,
clusterAgent: null,
+ availableAgents: [],
+ kasDisabled: false,
};
},
computed: {
@@ -63,19 +109,11 @@ export default {
return !this.registering && this.agentName !== null;
},
canCancel() {
- return !this.registered && !this.registering;
+ return !this.registered && !this.registering && this.isAgentRegistrationModal;
},
agentRegistrationCommand() {
return generateAgentRegistrationCommand(this.agentToken, this.kasAddress);
},
- basicInstallPath() {
- return helpPagePath('user/clusters/agent/install/index', {
- anchor: 'install-the-agent-into-the-cluster',
- });
- },
- advancedInstallPath() {
- return helpPagePath('user/clusters/agent/install/index', { anchor: 'advanced-installation' });
- },
getAgentsQueryVariables() {
return {
defaultBranchName: this.defaultBranchName,
@@ -84,10 +122,31 @@ export default {
projectPath: this.projectPath,
};
},
+ i18n() {
+ return I18N_AGENT_MODAL[this.modalType];
+ },
+ repositoryPath() {
+ return `/${this.projectPath}`;
+ },
+ modalType() {
+ return !this.availableAgents?.length && !this.registered
+ ? MODAL_TYPE_EMPTY
+ : MODAL_TYPE_REGISTER;
+ },
+ modalSize() {
+ return this.isEmptyStateModal ? 'sm' : 'md';
+ },
+ isEmptyStateModal() {
+ return this.modalType === MODAL_TYPE_EMPTY;
+ },
+ isAgentRegistrationModal() {
+ return this.modalType === MODAL_TYPE_REGISTER;
+ },
},
methods: {
setAgentName(name) {
this.agentName = name;
+ this.track(EVENT_ACTIONS_SELECT);
},
closeModal() {
this.$refs.modal.hide();
@@ -96,8 +155,16 @@ export default {
this.registering = false;
this.agentName = null;
this.agentToken = null;
+ this.clusterAgent = null;
this.error = null;
},
+ populateAvailableAgents(data) {
+ const installedAgents = data?.project?.clusterAgents?.nodes.map((agent) => agent.name) ?? [];
+ const configuredAgents =
+ data?.project?.agentConfigurations?.nodes.map((config) => config.agentName) ?? [];
+
+ this.availableAgents = configuredAgents.filter((agent) => !installedAgents.includes(agent));
+ },
createAgentMutation() {
return this.$apollo
.mutate({
@@ -117,7 +184,9 @@ export default {
);
},
})
- .then(({ data: { createClusterAgent } }) => createClusterAgent);
+ .then(({ data: { createClusterAgent } }) => {
+ return createClusterAgent;
+ });
},
createAgentTokenMutation(agendId) {
return this.$apollo
@@ -129,6 +198,17 @@ export default {
name: this.agentName,
},
},
+ update: (store, { data: { clusterAgentTokenCreate } }) => {
+ addAgentConfigToStore(
+ store,
+ clusterAgentTokenCreate,
+ this.clusterAgent,
+ agentConfigurations,
+ {
+ projectPath: this.projectPath,
+ },
+ );
+ },
})
.then(({ data: { clusterAgentTokenCreate } }) => clusterAgentTokenCreate);
},
@@ -158,7 +238,7 @@ export default {
if (error) {
this.error = error.message;
} else {
- this.error = this.$options.i18n.unknownError;
+ this.error = this.i18n.unknownError;
}
} finally {
this.registering = false;
@@ -172,115 +252,172 @@ export default {
<gl-modal
ref="modal"
:modal-id="$options.modalId"
- :title="$options.i18n.modalTitle"
+ :title="i18n.modalTitle"
+ :size="modalSize"
static
lazy
@hidden="resetModal"
+ @show="track($options.EVENT_ACTIONS_OPEN, { property: modalType })"
>
- <template v-if="!registered">
- <p>
- <strong>{{ $options.i18n.selectAgentTitle }}</strong>
- </p>
+ <template v-if="isAgentRegistrationModal">
+ <template v-if="!registered">
+ <p>
+ <strong>{{ i18n.selectAgentTitle }}</strong>
+ </p>
- <p>
- <gl-sprintf :message="$options.i18n.selectAgentBody">
- <template #link="{ content }">
- <gl-link :href="basicInstallPath" target="_blank"> {{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </p>
+ <p class="gl-mb-0">{{ i18n.selectAgentBody }}</p>
+ <p>
+ <gl-link :href="$options.registerAgentPath"> {{ i18n.learnMoreLink }}</gl-link>
+ </p>
- <form>
- <gl-form-group label-for="agent-name">
- <available-agents-dropdown
- class="gl-w-70p"
- :is-registering="registering"
- @agentSelected="setAgentName"
- />
- </gl-form-group>
- </form>
+ <form>
+ <gl-form-group label-for="agent-name">
+ <available-agents-dropdown
+ class="gl-w-70p"
+ :is-registering="registering"
+ :available-agents="availableAgents"
+ @agentSelected="setAgentName"
+ />
+ </gl-form-group>
+ </form>
- <p v-if="error">
- <gl-alert
- :title="$options.i18n.registrationErrorTitle"
- variant="danger"
- :dismissible="false"
- >
- {{ error }}
- </gl-alert>
- </p>
- </template>
+ <p v-if="error">
+ <gl-alert :title="i18n.registrationErrorTitle" variant="danger" :dismissible="false">
+ {{ error }}
+ </gl-alert>
+ </p>
+ </template>
- <template v-else>
- <p>
- <strong>{{ $options.i18n.tokenTitle }}</strong>
- </p>
+ <template v-else>
+ <p>
+ <strong>{{ i18n.tokenTitle }}</strong>
+ </p>
- <p>
- <gl-sprintf :message="$options.i18n.tokenBody">
- <template #link="{ content }">
- <gl-link :href="basicInstallPath" target="_blank"> {{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </p>
+ <p>
+ <gl-sprintf :message="i18n.tokenBody">
+ <template #link="{ content }">
+ <gl-link :href="$options.basicInstallPath" target="_blank"> {{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
- <p>
- <gl-alert
- :title="$options.i18n.tokenSingleUseWarningTitle"
- variant="warning"
- :dismissible="false"
- >
- {{ $options.i18n.tokenSingleUseWarningBody }}
- </gl-alert>
- </p>
+ <p>
+ <gl-alert :title="i18n.tokenSingleUseWarningTitle" variant="warning" :dismissible="false">
+ {{ i18n.tokenSingleUseWarningBody }}
+ </gl-alert>
+ </p>
- <p>
- <gl-form-input-group readonly :value="agentToken" :select-on-click="true">
- <template #append>
- <clipboard-button :text="agentToken" :title="$options.i18n.copyToken" />
- </template>
- </gl-form-input-group>
- </p>
+ <p>
+ <gl-form-input-group readonly :value="agentToken" :select-on-click="true">
+ <template #append>
+ <modal-copy-button
+ :text="agentToken"
+ :title="i18n.copyToken"
+ :modal-id="$options.modalId"
+ />
+ </template>
+ </gl-form-input-group>
+ </p>
- <p>
- <strong>{{ $options.i18n.basicInstallTitle }}</strong>
- </p>
+ <p>
+ <strong>{{ i18n.basicInstallTitle }}</strong>
+ </p>
- <p>
- {{ $options.i18n.basicInstallBody }}
- </p>
+ <p>
+ {{ i18n.basicInstallBody }}
+ </p>
- <p>
- <code-block :code="agentRegistrationCommand" />
- </p>
+ <p>
+ <code-block :code="agentRegistrationCommand" />
+ </p>
+
+ <p>
+ <strong>{{ i18n.advancedInstallTitle }}</strong>
+ </p>
+
+ <p>
+ <gl-sprintf :message="i18n.advancedInstallBody">
+ <template #link="{ content }">
+ <gl-link :href="$options.advancedInstallPath" target="_blank"> {{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </template>
+ </template>
+
+ <template v-else>
+ <div class="gl-text-center gl-mb-5">
+ <img :alt="i18n.altText" :src="emptyStateImage" height="100" />
+ </div>
<p>
- <strong>{{ $options.i18n.advancedInstallTitle }}</strong>
+ <gl-sprintf :message="i18n.modalBody">
+ <template #link="{ content }">
+ <gl-link :href="$options.installAgentPath"> {{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
</p>
- <p>
- <gl-sprintf :message="$options.i18n.advancedInstallBody">
+ <p v-if="kasDisabled">
+ <gl-sprintf :message="i18n.enableKasText">
<template #link="{ content }">
- <gl-link :href="advancedInstallPath" target="_blank"> {{ content }}</gl-link>
+ <gl-link :href="$options.enableKasPath"> {{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
</template>
<template #modal-footer>
- <gl-button v-if="canCancel" @click="closeModal">{{ $options.i18n.cancel }} </gl-button>
-
- <gl-button v-if="registered" variant="confirm" category="primary" @click="closeModal"
- >{{ $options.i18n.close }}
+ <gl-button
+ v-if="registered"
+ variant="confirm"
+ category="primary"
+ :data-track-action="$options.EVENT_ACTIONS_CLICK"
+ :data-track-label="$options.EVENT_LABEL_MODAL"
+ data-track-property="close"
+ @click="closeModal"
+ >{{ i18n.close }}
</gl-button>
<gl-button
- v-else
+ v-else-if="isAgentRegistrationModal"
:disabled="!nextButtonDisabled"
variant="confirm"
category="primary"
+ :data-track-action="$options.EVENT_ACTIONS_CLICK"
+ :data-track-label="$options.EVENT_LABEL_MODAL"
+ data-track-property="register"
@click="registerAgent"
- >{{ $options.i18n.registerAgentButton }}
+ >{{ i18n.registerAgentButton }}
+ </gl-button>
+
+ <gl-button
+ v-if="canCancel"
+ :data-track-action="$options.EVENT_ACTIONS_CLICK"
+ :data-track-label="$options.EVENT_LABEL_MODAL"
+ data-track-property="cancel"
+ @click="closeModal"
+ >{{ i18n.cancel }}
+ </gl-button>
+
+ <gl-button
+ v-if="isEmptyStateModal"
+ :href="repositoryPath"
+ variant="confirm"
+ category="secondary"
+ data-testid="agent-secondary-button"
+ >{{ i18n.secondaryButton }}
+ </gl-button>
+
+ <gl-button
+ v-if="isEmptyStateModal"
+ variant="confirm"
+ category="primary"
+ :data-track-action="$options.EVENT_ACTIONS_CLICK"
+ :data-track-label="$options.EVENT_LABEL_MODAL"
+ data-track-property="done"
+ @click="closeModal"
+ >{{ i18n.done }}
</gl-button>
</template>
</gl-modal>
diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js
index 9fefdf450c4..9b52df74fc5 100644
--- a/app/assets/javascripts/clusters_list/constants.js
+++ b/app/assets/javascripts/clusters_list/constants.js
@@ -64,47 +64,63 @@ export const STATUSES = {
creating: { title: __('Creating') },
};
-export const I18N_INSTALL_AGENT_MODAL = {
- registerAgentButton: s__('ClusterAgents|Register Agent'),
- close: __('Close'),
- cancel: __('Cancel'),
-
- modalTitle: s__('ClusterAgents|Install new Agent'),
-
- selectAgentTitle: s__('ClusterAgents|Select which Agent you want to install'),
- selectAgentBody: s__(
- `ClusterAgents|Select the Agent you want to register with GitLab and install on your cluster. To learn more about the Kubernetes Agent registration process %{linkStart}go to the documentation%{linkEnd}.`,
- ),
+export const I18N_AGENT_MODAL = {
+ agent_registration: {
+ registerAgentButton: s__('ClusterAgents|Register'),
+ close: __('Close'),
+ cancel: __('Cancel'),
+
+ modalTitle: s__('ClusterAgents|Connect a cluster through the Agent'),
+ selectAgentTitle: s__('ClusterAgents|Select an agent to register with GitLab'),
+ selectAgentBody: s__(
+ 'ClusterAgents|Register an agent to generate a token that will be used to install the agent on your cluster in the next step.',
+ ),
+ learnMoreLink: s__('ClusterAgents|How to register an agent?'),
- copyToken: s__('ClusterAgents|Copy token'),
- tokenTitle: s__('ClusterAgents|Registration token'),
- tokenBody: s__(
- `ClusterAgents|The registration token will be used to connect the Agent on your cluster to GitLab. To learn more about the registration tokens and how they are used %{linkStart}go to the documentation%{linkEnd}.`,
- ),
+ copyToken: s__('ClusterAgents|Copy token'),
+ tokenTitle: s__('ClusterAgents|Registration token'),
+ tokenBody: s__(
+ `ClusterAgents|The registration token will be used to connect the agent on your cluster to GitLab. %{linkStart}What are registration tokens?%{linkEnd}`,
+ ),
- tokenSingleUseWarningTitle: s__(
- 'ClusterAgents|The token value will not be shown again after you close this window.',
- ),
- tokenSingleUseWarningBody: s__(
- `ClusterAgents|The recommended installation method provided below includes the token. If you want to follow the alternative installation method provided in the docs make sure you save the token value before you close the window.`,
- ),
+ tokenSingleUseWarningTitle: s__(
+ 'ClusterAgents|You cannot see this token again after you close this window.',
+ ),
+ tokenSingleUseWarningBody: s__(
+ `ClusterAgents|The recommended installation method includes the token. If you want to follow the advanced installation method provided in the docs, make sure you save the token value before you close this window.`,
+ ),
- basicInstallTitle: s__('ClusterAgents|Recommended installation method'),
- basicInstallBody: __(
- `Open a CLI and connect to the cluster you want to install the Agent in. Use this installation method to minimize any manual steps. The token is already included in the command.`,
- ),
+ basicInstallTitle: s__('ClusterAgents|Recommended installation method'),
+ basicInstallBody: __(
+ `Open a CLI and connect to the cluster you want to install the agent in. Use this installation method to minimize any manual steps. The token is already included in the command.`,
+ ),
- advancedInstallTitle: s__('ClusterAgents|Alternative installation methods'),
- advancedInstallBody: s__(
- 'ClusterAgents|For alternative installation methods %{linkStart}go to the documentation%{linkEnd}.',
- ),
+ advancedInstallTitle: s__('ClusterAgents|Advanced installation methods'),
+ advancedInstallBody: s__(
+ 'ClusterAgents|For the advanced installation method %{linkStart}see the documentation%{linkEnd}.',
+ ),
- registrationErrorTitle: __('Failed to register Agent'),
- unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'),
+ registrationErrorTitle: s__('ClusterAgents|Failed to register an agent'),
+ unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'),
+ },
+ empty_state: {
+ modalTitle: s__('ClusterAgents|Connect your cluster through the Agent'),
+ modalBody: s__(
+ "ClusterAgents|To install a new agent, first add the agent's configuration file to this repository. %{linkStart}What's the agent's configuration file?%{linkEnd}",
+ ),
+ enableKasText: s__(
+ "ClusterAgents|Your instance doesn't have the %{linkStart}GitLab Agent Server (KAS)%{linkEnd} set up. Ask a GitLab Administrator to install it.",
+ ),
+ altText: s__('ClusterAgents|GitLab Agent for Kubernetes'),
+ secondaryButton: s__('ClusterAgents|Go to the repository files'),
+ done: __('Cancel'),
+ },
};
+export const KAS_DISABLED_ERROR = 'Gitlab::Kas::Client::ConfigurationError';
+
export const I18N_AVAILABLE_AGENTS_DROPDOWN = {
- selectAgent: s__('ClusterAgents|Select an Agent'),
+ selectAgent: s__('ClusterAgents|Select an agent'),
registeringAgent: s__('ClusterAgents|Registering Agent'),
};
@@ -125,7 +141,7 @@ export const AGENT_STATUSES = {
title: s__('ClusterAgents|Agent might not be connected to GitLab'),
body: sprintf(
s__(
- 'ClusterAgents|The Agent has not been connected in a long time. There might be a connectivity issue. Last contact was %{timeAgo}.',
+ 'ClusterAgents|The agent has not been connected in a long time. There might be a connectivity issue. Last contact was %{timeAgo}.',
),
),
},
@@ -143,55 +159,48 @@ export const AGENT_STATUSES = {
export const I18N_AGENTS_EMPTY_STATE = {
introText: s__(
- 'ClusterAgents|Use GitLab Agents to more securely integrate with your clusters to deploy your applications, run your pipelines, use review apps and much more.',
- ),
- multipleClustersText: s__(
- 'ClusterAgents|If you are setting up multiple clusters and are using Auto DevOps, %{linkStart}read about using multiple Kubernetes clusters first.%{linkEnd}',
- ),
- learnMoreText: s__('ClusterAgents|Learn more about the GitLab Kubernetes Agent.'),
- warningText: s__(
- 'ClusterAgents|To install an Agent you should create an agent directory in the Repository first. We recommend that you add the Agent configuration to the directory before you start the installation process.',
+ 'ClusterIntegration|Use the %{linkStart}GitLab Agent%{linkEnd} to safely connect your Kubernetes clusters to GitLab. You can deploy your applications, run your pipelines, use Review Apps, and much more.',
),
- readMoreText: s__('ClusterAgents|Read more about getting started'),
- repositoryButtonText: s__('ClusterAgents|Go to the repository'),
- primaryButtonText: s__('ClusterAgents|Connect with a GitLab Agent'),
+ buttonText: s__('ClusterAgents|Connect with the GitLab Agent'),
};
export const I18N_CLUSTERS_EMPTY_STATE = {
- description: s__(
- 'ClusterIntegration|Use certificates to integrate with your clusters to deploy your applications, run your pipelines, use review apps and much more in an easy way.',
- ),
- multipleClustersText: s__(
- 'ClusterIntegration|If you are setting up multiple clusters and are using Auto DevOps, %{linkStart}read about using multiple Kubernetes clusters first.%{linkEnd}',
+ introText: s__(
+ 'ClusterIntegration|Connect your cluster to GitLab through %{linkStart}cluster certificates%{linkEnd}.',
),
- learnMoreLinkText: s__('ClusterIntegration|Learn more about the GitLab managed clusters'),
buttonText: s__('ClusterIntegration|Connect with a certificate'),
+ alertText: s__(
+ 'ClusterIntegration|The certificate-based method to connect clusters to GitLab was %{linkStart}deprecated%{linkEnd} in GitLab 14.5.',
+ ),
};
export const AGENT_CARD_INFO = {
tabName: 'agent',
- title: sprintf(s__('ClusterAgents|%{number} of %{total} Agent based integrations')),
- emptyTitle: s__('ClusterAgents|No Agent based integrations'),
+ title: sprintf(s__('ClusterAgents|%{number} of %{total} agents')),
+ emptyTitle: s__('ClusterAgents|No agents'),
tooltip: {
label: s__('ClusterAgents|Recommended'),
- title: s__('ClusterAgents|GitLab Agents'),
+ title: s__('ClusterAgents|GitLab Agent'),
text: sprintf(
s__(
- 'ClusterAgents|GitLab Agents provide an increased level of security when integrating with clusters. %{linkStart}Learn more about the GitLab Kubernetes Agent.%{linkEnd}',
+ 'ClusterAgents|The GitLab Agent provides an increased level of security when connecting Kubernetes clusters to GitLab. %{linkStart}Learn more about the GitLab Agent.%{linkEnd}',
),
),
link: helpPagePath('user/clusters/agent/index'),
},
- actionText: s__('ClusterAgents|Install new Agent'),
- footerText: sprintf(s__('ClusterAgents|View all %{number} Agent based integrations')),
+ actionText: s__('ClusterAgents|Install a new agent'),
+ footerText: sprintf(s__('ClusterAgents|View all %{number} agents')),
};
export const CERTIFICATE_BASED_CARD_INFO = {
tabName: 'certificate_based',
- title: sprintf(s__('ClusterAgents|%{number} of %{total} Certificate based integrations')),
- emptyTitle: s__('ClusterAgents|No Certificate based integrations'),
+ title: sprintf(
+ s__('ClusterAgents|%{number} of %{total} clusters connected through cluster certificates'),
+ ),
+ emptyTitle: s__('ClusterAgents|No clusters connected through cluster certificates'),
actionText: s__('ClusterAgents|Connect existing cluster'),
- footerText: sprintf(s__('ClusterAgents|View all %{number} Certificate based integrations')),
+ footerText: sprintf(s__('ClusterAgents|View all %{number} clusters')),
+ badgeText: s__('ClusterAgents|Deprecated'),
};
export const MAX_CLUSTERS_LIST = 6;
@@ -208,7 +217,7 @@ export const CLUSTERS_TABS = [
queryParamValue: 'agent',
},
{
- title: s__('ClusterAgents|Certificate based'),
+ title: s__('ClusterAgents|Certificate'),
component: 'clusters',
queryParamValue: 'certificate_based',
},
@@ -216,10 +225,20 @@ export const CLUSTERS_TABS = [
export const CLUSTERS_ACTIONS = {
actionsButton: s__('ClusterAgents|Actions'),
- createNewCluster: s__('ClusterAgents|Create new cluster'),
- connectWithAgent: s__('ClusterAgents|Connect with Agent'),
- connectExistingCluster: s__('ClusterAgents|Connect with certificate'),
+ createNewCluster: s__('ClusterAgents|Create a new cluster'),
+ connectWithAgent: s__('ClusterAgents|Connect with the Agent'),
+ connectExistingCluster: s__('ClusterAgents|Connect with a certificate'),
};
export const AGENT = 'agent';
export const CERTIFICATE_BASED = 'certificate_based';
+
+export const EVENT_LABEL_MODAL = 'agent_registration_modal';
+export const EVENT_LABEL_TABS = 'kubernetes_section_tabs';
+export const EVENT_ACTIONS_OPEN = 'open_modal';
+export const EVENT_ACTIONS_SELECT = 'select_agent';
+export const EVENT_ACTIONS_CLICK = 'click_button';
+export const EVENT_ACTIONS_CHANGE = 'change_tab';
+
+export const MODAL_TYPE_EMPTY = 'empty_state';
+export const MODAL_TYPE_REGISTER = 'agent_registration';
diff --git a/app/assets/javascripts/clusters_list/graphql/cache_update.js b/app/assets/javascripts/clusters_list/graphql/cache_update.js
index dd633820952..4d12bc8151c 100644
--- a/app/assets/javascripts/clusters_list/graphql/cache_update.js
+++ b/app/assets/javascripts/clusters_list/graphql/cache_update.js
@@ -1,29 +1,65 @@
import produce from 'immer';
import { getAgentConfigPath } from '../clusters_util';
+export const hasErrors = ({ errors = [] }) => errors?.length;
+
export function addAgentToStore(store, createClusterAgent, query, variables) {
- const { clusterAgent } = createClusterAgent;
- const sourceData = store.readQuery({
- query,
- variables,
- });
-
- const data = produce(sourceData, (draftData) => {
- const configuration = {
- name: clusterAgent.name,
- path: getAgentConfigPath(clusterAgent.name),
- webPath: clusterAgent.webPath,
- __typename: 'TreeEntry',
- };
-
- draftData.project.clusterAgents.nodes.push(clusterAgent);
- draftData.project.clusterAgents.count += 1;
- draftData.project.repository.tree.trees.nodes.push(configuration);
- });
-
- store.writeQuery({
- query,
- variables,
- data,
- });
+ if (!hasErrors(createClusterAgent)) {
+ const { clusterAgent } = createClusterAgent;
+ const sourceData = store.readQuery({
+ query,
+ variables,
+ });
+
+ const data = produce(sourceData, (draftData) => {
+ const configuration = {
+ id: clusterAgent.id,
+ name: clusterAgent.name,
+ path: getAgentConfigPath(clusterAgent.name),
+ webPath: clusterAgent.webPath,
+ __typename: 'TreeEntry',
+ };
+
+ draftData.project.clusterAgents.nodes.push(clusterAgent);
+ draftData.project.clusterAgents.count += 1;
+ draftData.project.repository.tree.trees.nodes.push(configuration);
+ });
+
+ store.writeQuery({
+ query,
+ variables,
+ data,
+ });
+ }
+}
+
+export function addAgentConfigToStore(
+ store,
+ clusterAgentTokenCreate,
+ clusterAgent,
+ query,
+ variables,
+) {
+ if (!hasErrors(clusterAgentTokenCreate)) {
+ const sourceData = store.readQuery({
+ query,
+ variables,
+ });
+
+ const data = produce(sourceData, (draftData) => {
+ const configuration = {
+ agentName: clusterAgent.name,
+ __typename: 'AgentConfiguration',
+ };
+
+ draftData.project.clusterAgents.nodes.push(clusterAgent);
+ draftData.project.agentConfigurations.nodes.push(configuration);
+ });
+
+ store.writeQuery({
+ query,
+ variables,
+ data,
+ });
+ }
}
diff --git a/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql b/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql
index 9b40260471c..cd46dfee170 100644
--- a/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql
+++ b/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql
@@ -4,6 +4,7 @@ fragment ClusterAgentFragment on ClusterAgent {
webPath
tokens {
nodes {
+ id
lastUsedAt
}
}
diff --git a/app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql b/app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql
index 40b61337024..9a24cec5a9c 100644
--- a/app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql
+++ b/app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql
@@ -1,5 +1,6 @@
query agentConfigurations($projectPath: ID!) {
project(fullPath: $projectPath) {
+ id
agentConfigurations {
nodes {
agentName
@@ -8,6 +9,7 @@ query agentConfigurations($projectPath: ID!) {
clusterAgents {
nodes {
+ id
name
}
}
diff --git a/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql
index 47b25988877..f8efb6683f6 100644
--- a/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql
+++ b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql
@@ -12,6 +12,7 @@ query getAgents(
$beforeTree: String
) {
project(fullPath: $projectPath) {
+ id
clusterAgents(first: $first, last: $last, before: $beforeAgent, after: $afterAgent) {
nodes {
...ClusterAgentFragment
@@ -28,6 +29,7 @@ query getAgents(
tree(path: ".gitlab/agents", ref: $defaultBranchName) {
trees(first: $first, last: $last, after: $afterTree, before: $beforeTree) {
nodes {
+ id
name
path
webPath
diff --git a/app/assets/javascripts/code_navigation/components/doc_line.vue b/app/assets/javascripts/code_navigation/components/doc_line.vue
index 69d398893d9..4d44c984833 100644
--- a/app/assets/javascripts/code_navigation/components/doc_line.vue
+++ b/app/assets/javascripts/code_navigation/components/doc_line.vue
@@ -18,5 +18,6 @@ export default {
<span v-for="(token, tokenIndex) in tokens" :key="tokenIndex" :class="token.class">{{
token.value
}}</span>
+ <br />
</span>
</template>
diff --git a/app/assets/javascripts/content_editor/extensions/attachment.js b/app/assets/javascripts/content_editor/extensions/attachment.js
index 29ee282f2d2..72df1d071d1 100644
--- a/app/assets/javascripts/content_editor/extensions/attachment.js
+++ b/app/assets/javascripts/content_editor/extensions/attachment.js
@@ -5,9 +5,11 @@ import { handleFileEvent } from '../services/upload_helpers';
export default Extension.create({
name: 'attachment',
- defaultOptions: {
- uploadsPath: null,
- renderMarkdown: null,
+ addOptions() {
+ return {
+ uploadsPath: null,
+ renderMarkdown: null,
+ };
},
addCommands() {
diff --git a/app/assets/javascripts/content_editor/extensions/audio.js b/app/assets/javascripts/content_editor/extensions/audio.js
index 25d4068c93f..ea48ee0cee0 100644
--- a/app/assets/javascripts/content_editor/extensions/audio.js
+++ b/app/assets/javascripts/content_editor/extensions/audio.js
@@ -2,8 +2,10 @@ import Playable from './playable';
export default Playable.extend({
name: 'audio',
- defaultOptions: {
- ...Playable.options,
- mediaType: 'audio',
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ mediaType: 'audio',
+ };
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
index 1ed1ab0315f..ea51bee3ba9 100644
--- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -4,6 +4,8 @@ import * as lowlight from 'lowlight';
const extractLanguage = (element) => element.getAttribute('lang');
export default CodeBlockLowlight.extend({
+ isolating: true,
+
addAttributes() {
return {
language: {
@@ -17,7 +19,7 @@ export default CodeBlockLowlight.extend({
};
},
renderHTML({ HTMLAttributes }) {
- return ['pre', HTMLAttributes, ['code', {}, 0]];
+ return ['div', ['pre', HTMLAttributes, ['code', {}, 0]]];
},
}).configure({
lowlight,
diff --git a/app/assets/javascripts/content_editor/extensions/division.js b/app/assets/javascripts/content_editor/extensions/division.js
index c70d1700941..566ed85acf3 100644
--- a/app/assets/javascripts/content_editor/extensions/division.js
+++ b/app/assets/javascripts/content_editor/extensions/division.js
@@ -1,12 +1,26 @@
import { Node } from '@tiptap/core';
import { PARSE_HTML_PRIORITY_LOWEST } from '../constants';
+const getDiv = (element) => {
+ if (element.nodeName === 'DIV') return element;
+ return element.querySelector('div');
+};
+
export default Node.create({
name: 'division',
content: 'block*',
group: 'block',
defining: true,
+ addAttributes() {
+ return {
+ className: {
+ default: null,
+ parseHTML: (element) => getDiv(element).className || null,
+ },
+ };
+ },
+
parseHTML() {
return [{ tag: 'div', priority: PARSE_HTML_PRIORITY_LOWEST }];
},
diff --git a/app/assets/javascripts/content_editor/extensions/footnote_definition.js b/app/assets/javascripts/content_editor/extensions/footnote_definition.js
new file mode 100644
index 00000000000..dbab0de3421
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/footnote_definition.js
@@ -0,0 +1,21 @@
+import { mergeAttributes, Node } from '@tiptap/core';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+
+export default Node.create({
+ name: 'footnoteDefinition',
+
+ content: 'paragraph',
+
+ group: 'block',
+
+ parseHTML() {
+ return [
+ { tag: 'section.footnotes li' },
+ { tag: '.footnote-backref', priority: PARSE_HTML_PRIORITY_HIGHEST, ignore: true },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['li', mergeAttributes(HTMLAttributes), 0];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/footnote_reference.js b/app/assets/javascripts/content_editor/extensions/footnote_reference.js
new file mode 100644
index 00000000000..1ac8016f774
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/footnote_reference.js
@@ -0,0 +1,37 @@
+import { Node, mergeAttributes } from '@tiptap/core';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+
+export default Node.create({
+ name: 'footnoteReference',
+
+ inline: true,
+
+ group: 'inline',
+
+ atom: true,
+
+ draggable: true,
+
+ selectable: true,
+
+ addAttributes() {
+ return {
+ footnoteId: {
+ default: null,
+ parseHTML: (element) => element.querySelector('a').getAttribute('id'),
+ },
+ footnoteNumber: {
+ default: null,
+ parseHTML: (element) => element.textContent,
+ },
+ };
+ },
+
+ parseHTML() {
+ return [{ tag: 'sup.footnote-ref', priority: PARSE_HTML_PRIORITY_HIGHEST }];
+ },
+
+ renderHTML({ HTMLAttributes: { footnoteNumber, footnoteId, ...HTMLAttributes } }) {
+ return ['sup', mergeAttributes(HTMLAttributes), footnoteNumber];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/footnotes_section.js b/app/assets/javascripts/content_editor/extensions/footnotes_section.js
new file mode 100644
index 00000000000..914a8934734
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/footnotes_section.js
@@ -0,0 +1,19 @@
+import { mergeAttributes, Node } from '@tiptap/core';
+
+export default Node.create({
+ name: 'footnotesSection',
+
+ content: 'footnoteDefinition+',
+
+ group: 'block',
+
+ isolating: true,
+
+ parseHTML() {
+ return [{ tag: 'section.footnotes > ol' }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['ol', mergeAttributes(HTMLAttributes, { class: 'footnotes gl-font-sm' }), 0];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/html_marks.js b/app/assets/javascripts/content_editor/extensions/html_marks.js
index 3abf0e3eee2..9579f3b06f6 100644
--- a/app/assets/javascripts/content_editor/extensions/html_marks.js
+++ b/app/assets/javascripts/content_editor/extensions/html_marks.js
@@ -31,13 +31,12 @@ const attrs = {
export default marks.map((name) =>
Mark.create({
name,
-
inclusive: false,
-
- defaultOptions: {
- HTMLAttributes: {},
+ addOptions() {
+ return {
+ HTMLAttributes: {},
+ };
},
-
addAttributes() {
return (attrs[name] || []).reduce(
(acc, attr) => ({
diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js
index 837fab0585f..d7fb617f7ee 100644
--- a/app/assets/javascripts/content_editor/extensions/image.js
+++ b/app/assets/javascripts/content_editor/extensions/image.js
@@ -7,9 +7,11 @@ const resolveImageEl = (element) =>
element.nodeName === 'IMG' ? element : element.querySelector('img');
export default Image.extend({
- defaultOptions: {
- ...Image.options,
- inline: true,
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ inline: true,
+ };
},
addAttributes() {
return {
diff --git a/app/assets/javascripts/content_editor/extensions/inline_diff.js b/app/assets/javascripts/content_editor/extensions/inline_diff.js
index 22bb1ac072e..f76943a0669 100644
--- a/app/assets/javascripts/content_editor/extensions/inline_diff.js
+++ b/app/assets/javascripts/content_editor/extensions/inline_diff.js
@@ -3,8 +3,10 @@ import { Mark, markInputRule, mergeAttributes } from '@tiptap/core';
export default Mark.create({
name: 'inlineDiff',
- defaultOptions: {
- HTMLAttributes: {},
+ addOptions() {
+ return {
+ HTMLAttributes: {},
+ };
},
addAttributes() {
diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js
index 27bc05dce6f..f9b12f631fe 100644
--- a/app/assets/javascripts/content_editor/extensions/link.js
+++ b/app/assets/javascripts/content_editor/extensions/link.js
@@ -18,10 +18,13 @@ export const extractHrefFromMarkdownLink = (match) => {
};
export default Link.extend({
- defaultOptions: {
- ...Link.options,
- openOnClick: false,
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ openOnClick: false,
+ };
},
+
addInputRules() {
const markdownLinkSyntaxInputRuleRegExp = /(?:^|\s)\[([\w|\s|-]+)\]\((?<href>.+?)\)$/gm;
const urlSyntaxRegExp = /(?:^|\s)(?<href>(?:https?:\/\/|www\.)[\S]+)(?:\s|\n)$/gim;
diff --git a/app/assets/javascripts/content_editor/extensions/task_item.js b/app/assets/javascripts/content_editor/extensions/task_item.js
index 9b050edcb28..6efef3f8198 100644
--- a/app/assets/javascripts/content_editor/extensions/task_item.js
+++ b/app/assets/javascripts/content_editor/extensions/task_item.js
@@ -2,9 +2,11 @@ import { TaskItem } from '@tiptap/extension-task-item';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
export default TaskItem.extend({
- defaultOptions: {
- nested: true,
- HTMLAttributes: {},
+ addOptions() {
+ return {
+ nested: true,
+ HTMLAttributes: {},
+ };
},
addAttributes() {
diff --git a/app/assets/javascripts/content_editor/extensions/video.js b/app/assets/javascripts/content_editor/extensions/video.js
index 9923b7c04cd..312e8cd5ff6 100644
--- a/app/assets/javascripts/content_editor/extensions/video.js
+++ b/app/assets/javascripts/content_editor/extensions/video.js
@@ -2,9 +2,11 @@ import Playable from './playable';
export default Playable.extend({
name: 'video',
- defaultOptions: {
- ...Playable.options,
- mediaType: 'video',
- extraElementAttrs: { width: '400' },
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ mediaType: 'video',
+ extraElementAttrs: { width: '400' },
+ };
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/word_break.js b/app/assets/javascripts/content_editor/extensions/word_break.js
index fa7e02f8cc8..457b7c36564 100644
--- a/app/assets/javascripts/content_editor/extensions/word_break.js
+++ b/app/assets/javascripts/content_editor/extensions/word_break.js
@@ -7,10 +7,12 @@ export default Node.create({
selectable: false,
atom: true,
- defaultOptions: {
- HTMLAttributes: {
- class: 'gl-display-inline-flex gl-px-1 gl-bg-blue-100 gl-rounded-base gl-font-sm',
- },
+ addOptions() {
+ return {
+ HTMLAttributes: {
+ class: 'gl-display-inline-flex gl-px-1 gl-bg-blue-100 gl-rounded-base gl-font-sm',
+ },
+ };
},
parseHTML() {
diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js
index 385f1c63801..f451357e211 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -19,6 +19,9 @@ import Dropcursor from '../extensions/dropcursor';
import Emoji from '../extensions/emoji';
import Figure from '../extensions/figure';
import FigureCaption from '../extensions/figure_caption';
+import FootnoteDefinition from '../extensions/footnote_definition';
+import FootnoteReference from '../extensions/footnote_reference';
+import FootnotesSection from '../extensions/footnotes_section';
import Frontmatter from '../extensions/frontmatter';
import Gapcursor from '../extensions/gapcursor';
import HardBreak from '../extensions/hard_break';
@@ -94,6 +97,9 @@ export const createContentEditor = ({
Emoji,
Figure,
FigureCaption,
+ FootnoteDefinition,
+ FootnoteReference,
+ FootnotesSection,
Frontmatter,
Gapcursor,
HardBreak,
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index 0dd3cb5b73f..278ef326c7a 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -17,6 +17,9 @@ import Division from '../extensions/division';
import Emoji from '../extensions/emoji';
import Figure from '../extensions/figure';
import FigureCaption from '../extensions/figure_caption';
+import FootnotesSection from '../extensions/footnotes_section';
+import FootnoteDefinition from '../extensions/footnote_definition';
+import FootnoteReference from '../extensions/footnote_reference';
import Frontmatter from '../extensions/frontmatter';
import HardBreak from '../extensions/hard_break';
import Heading from '../extensions/heading';
@@ -135,7 +138,16 @@ const defaultSerializerConfig = {
state.write('```');
state.closeBlock(node);
},
- [Division.name]: renderHTMLNode('div'),
+ [Division.name]: (state, node) => {
+ if (node.attrs.className?.includes('js-markdown-code')) {
+ state.renderInline(node);
+ } else {
+ const newNode = node;
+ delete newNode.attrs.className;
+
+ renderHTMLNode('div')(state, newNode);
+ }
+ },
[DescriptionList.name]: renderHTMLNode('dl', true),
[DescriptionItem.name]: (state, node, parent, index) => {
if (index === 1) state.ensureNewLine();
@@ -156,6 +168,15 @@ const defaultSerializerConfig = {
state.write(`:${name}:`);
},
+ [FootnoteDefinition.name]: (state, node) => {
+ state.renderInline(node);
+ },
+ [FootnoteReference.name]: (state, node) => {
+ state.write(`[^${node.attrs.footnoteNumber}]`);
+ },
+ [FootnotesSection.name]: (state, node) => {
+ state.renderList(node, '', (index) => `[^${index + 1}]: `);
+ },
[Frontmatter.name]: (state, node) => {
const { language } = node.attrs;
const syntax = {
diff --git a/app/assets/javascripts/crm/components/contact_form.vue b/app/assets/javascripts/crm/components/contact_form.vue
new file mode 100644
index 00000000000..81ae5c246be
--- /dev/null
+++ b/app/assets/javascripts/crm/components/contact_form.vue
@@ -0,0 +1,224 @@
+<script>
+import { GlAlert, GlButton, GlDrawer, GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { produce } from 'immer';
+import { __, s__ } from '~/locale';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_GROUP } from '~/graphql_shared/constants';
+import createContactMutation from './queries/create_contact.mutation.graphql';
+import updateContactMutation from './queries/update_contact.mutation.graphql';
+import getGroupContactsQuery from './queries/get_group_contacts.query.graphql';
+
+export default {
+ components: {
+ GlAlert,
+ GlButton,
+ GlDrawer,
+ GlFormGroup,
+ GlFormInput,
+ },
+ inject: ['groupFullPath', 'groupId'],
+ props: {
+ drawerOpen: {
+ type: Boolean,
+ required: true,
+ },
+ contact: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ },
+ data() {
+ return {
+ firstName: '',
+ lastName: '',
+ phone: '',
+ email: '',
+ description: '',
+ submitting: false,
+ errorMessages: [],
+ };
+ },
+ computed: {
+ invalid() {
+ const { firstName, lastName, email } = this;
+
+ return firstName.trim() === '' || lastName.trim() === '' || email.trim() === '';
+ },
+ editMode() {
+ return Boolean(this.contact);
+ },
+ title() {
+ return this.editMode ? this.$options.i18n.editTitle : this.$options.i18n.newTitle;
+ },
+ buttonLabel() {
+ return this.editMode
+ ? this.$options.i18n.editButtonLabel
+ : this.$options.i18n.createButtonLabel;
+ },
+ mutation() {
+ return this.editMode ? updateContactMutation : createContactMutation;
+ },
+ variables() {
+ const { contact, firstName, lastName, phone, email, description, editMode, groupId } = this;
+
+ const variables = {
+ input: {
+ firstName,
+ lastName,
+ phone,
+ email,
+ description,
+ },
+ };
+
+ if (editMode) {
+ variables.input.id = contact.id;
+ } else {
+ variables.input.groupId = convertToGraphQLId(TYPE_GROUP, groupId);
+ }
+
+ return variables;
+ },
+ },
+ mounted() {
+ if (this.editMode) {
+ const { contact } = this;
+
+ this.firstName = contact.firstName || '';
+ this.lastName = contact.lastName || '';
+ this.phone = contact.phone || '';
+ this.email = contact.email || '';
+ this.description = contact.description || '';
+ }
+ },
+ methods: {
+ save() {
+ const { mutation, variables, updateCache, close } = this;
+
+ this.submitting = true;
+
+ return this.$apollo
+ .mutate({
+ mutation,
+ variables,
+ update: updateCache,
+ })
+ .then(({ data }) => {
+ if (
+ data.customerRelationsContactCreate?.errors.length === 0 ||
+ data.customerRelationsContactUpdate?.errors.length === 0
+ ) {
+ close(true);
+ }
+
+ this.submitting = false;
+ })
+ .catch(() => {
+ this.errorMessages = [this.$options.i18n.somethingWentWrong];
+ this.submitting = false;
+ });
+ },
+ close(success) {
+ this.$emit('close', success);
+ },
+ updateCache(store, { data }) {
+ const mutationData =
+ data.customerRelationsContactCreate || data.customerRelationsContactUpdate;
+
+ if (mutationData?.errors.length > 0) {
+ this.errorMessages = mutationData.errors;
+ return;
+ }
+
+ const queryArgs = {
+ query: getGroupContactsQuery,
+ variables: { groupFullPath: this.groupFullPath },
+ };
+
+ const sourceData = store.readQuery(queryArgs);
+
+ queryArgs.data = produce(sourceData, (draftState) => {
+ draftState.group.contacts.nodes = [
+ ...sourceData.group.contacts.nodes.filter(({ id }) => id !== this.contact?.id),
+ mutationData.contact,
+ ];
+ });
+
+ store.writeQuery(queryArgs);
+ },
+ getDrawerHeaderHeight() {
+ const wrapperEl = document.querySelector('.content-wrapper');
+
+ if (wrapperEl) {
+ return `${wrapperEl.offsetTop}px`;
+ }
+
+ return '';
+ },
+ },
+ i18n: {
+ createButtonLabel: s__('Crm|Create new contact'),
+ editButtonLabel: __('Save changes'),
+ cancel: __('Cancel'),
+ firstName: s__('Crm|First name'),
+ lastName: s__('Crm|Last name'),
+ email: s__('Crm|Email'),
+ phone: s__('Crm|Phone number (optional)'),
+ description: s__('Crm|Description (optional)'),
+ newTitle: s__('Crm|New contact'),
+ editTitle: s__('Crm|Edit contact'),
+ somethingWentWrong: __('Something went wrong. Please try again.'),
+ },
+};
+</script>
+
+<template>
+ <gl-drawer
+ class="gl-drawer-responsive"
+ :open="drawerOpen"
+ :header-height="getDrawerHeaderHeight()"
+ @close="close(false)"
+ >
+ <template #title>
+ <h3>{{ title }}</h3>
+ </template>
+ <gl-alert v-if="errorMessages.length" variant="danger" @dismiss="errorMessages = []">
+ <ul class="gl-mb-0! gl-ml-5">
+ <li v-for="error in errorMessages" :key="error">
+ {{ error }}
+ </li>
+ </ul>
+ </gl-alert>
+ <form @submit.prevent="save">
+ <gl-form-group :label="$options.i18n.firstName" label-for="contact-first-name">
+ <gl-form-input id="contact-first-name" v-model="firstName" />
+ </gl-form-group>
+ <gl-form-group :label="$options.i18n.lastName" label-for="contact-last-name">
+ <gl-form-input id="contact-last-name" v-model="lastName" />
+ </gl-form-group>
+ <gl-form-group :label="$options.i18n.email" label-for="contact-email">
+ <gl-form-input id="contact-email" v-model="email" />
+ </gl-form-group>
+ <gl-form-group :label="$options.i18n.phone" label-for="contact-phone">
+ <gl-form-input id="contact-phone" v-model="phone" />
+ </gl-form-group>
+ <gl-form-group :label="$options.i18n.description" label-for="contact-description">
+ <gl-form-input id="contact-description" v-model="description" />
+ </gl-form-group>
+ <span class="gl-float-right">
+ <gl-button data-testid="cancel-button" @click="close(false)">
+ {{ $options.i18n.cancel }}
+ </gl-button>
+ <gl-button
+ variant="confirm"
+ :disabled="invalid"
+ :loading="submitting"
+ data-testid="save-contact-button"
+ type="submit"
+ >{{ buttonLabel }}</gl-button
+ >
+ </span>
+ </form>
+ </gl-drawer>
+</template>
diff --git a/app/assets/javascripts/crm/components/contacts_root.vue b/app/assets/javascripts/crm/components/contacts_root.vue
index 83c02f7d5fe..178ce84c64d 100644
--- a/app/assets/javascripts/crm/components/contacts_root.vue
+++ b/app/assets/javascripts/crm/components/contacts_root.vue
@@ -1,17 +1,30 @@
<script>
-import { GlLoadingIcon, GlTable } from '@gitlab/ui';
-import createFlash from '~/flash';
+import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui';
+import { parseBoolean } from '~/lib/utils/common_utils';
import { s__, __ } from '~/locale';
+import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_CRM_CONTACT } from '~/graphql_shared/constants';
+import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from '../constants';
import getGroupContactsQuery from './queries/get_group_contacts.query.graphql';
+import ContactForm from './contact_form.vue';
export default {
components: {
+ GlAlert,
+ GlButton,
GlLoadingIcon,
GlTable,
+ ContactForm,
},
- inject: ['groupFullPath'],
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: ['groupFullPath', 'groupIssuesPath', 'canAdminCrmContact'],
data() {
- return { contacts: [] };
+ return {
+ contacts: [],
+ error: false,
+ };
},
apollo: {
contacts: {
@@ -26,12 +39,8 @@ export default {
update(data) {
return this.extractContacts(data);
},
- error(error) {
- createFlash({
- message: __('Something went wrong. Please try again.'),
- error,
- captureError: true,
- });
+ error() {
+ this.error = true;
},
},
},
@@ -39,12 +48,51 @@ export default {
isLoading() {
return this.$apollo.queries.contacts.loading;
},
+ showNewForm() {
+ return this.$route.name === NEW_ROUTE_NAME;
+ },
+ showEditForm() {
+ return !this.isLoading && this.$route.name === EDIT_ROUTE_NAME;
+ },
+ canAdmin() {
+ return parseBoolean(this.canAdminCrmContact);
+ },
+ editingContact() {
+ return this.contacts.find(
+ (contact) => contact.id === convertToGraphQLId(TYPE_CRM_CONTACT, this.$route.params.id),
+ );
+ },
},
methods: {
extractContacts(data) {
const contacts = data?.group?.contacts?.nodes || [];
return contacts.slice().sort((a, b) => a.firstName.localeCompare(b.firstName));
},
+ displayNewForm() {
+ if (this.showNewForm) return;
+
+ this.$router.push({ name: NEW_ROUTE_NAME });
+ },
+ hideNewForm(success) {
+ if (success) this.$toast.show(s__('Crm|Contact has been added'));
+
+ this.$router.replace({ name: INDEX_ROUTE_NAME });
+ },
+ hideEditForm(success) {
+ if (success) this.$toast.show(s__('Crm|Contact has been updated'));
+
+ this.editingContactId = 0;
+ this.$router.replace({ name: INDEX_ROUTE_NAME });
+ },
+ getIssuesPath(path, value) {
+ return `${path}?scope=all&state=opened&crm_contact_id=${value}`;
+ },
+ edit(value) {
+ if (this.showEditForm) return;
+
+ this.editingContactId = value;
+ this.$router.push({ name: EDIT_ROUTE_NAME, params: { id: value } });
+ },
},
fields: [
{ key: 'firstName', sortable: true },
@@ -59,22 +107,81 @@ export default {
},
sortable: true,
},
+ {
+ key: 'id',
+ label: '',
+ formatter: (id) => {
+ return getIdFromGraphQLId(id);
+ },
+ },
],
i18n: {
emptyText: s__('Crm|No contacts found'),
+ issuesButtonLabel: __('View issues'),
+ editButtonLabel: __('Edit'),
+ title: s__('Crm|Customer Relations Contacts'),
+ newContact: s__('Crm|New contact'),
+ errorText: __('Something went wrong. Please try again.'),
},
};
</script>
<template>
<div>
+ <gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="error = false">
+ {{ $options.i18n.errorText }}
+ </gl-alert>
+ <div
+ class="gl-display-flex gl-align-items-baseline gl-flex-direction-row gl-justify-content-space-between gl-mt-6"
+ >
+ <h2 class="gl-font-size-h2 gl-my-0">
+ {{ $options.i18n.title }}
+ </h2>
+ <div class="gl-display-none gl-md-display-flex gl-align-items-center gl-justify-content-end">
+ <gl-button
+ v-if="canAdmin"
+ variant="confirm"
+ data-testid="new-contact-button"
+ @click="displayNewForm"
+ >
+ {{ $options.i18n.newContact }}
+ </gl-button>
+ </div>
+ </div>
+ <contact-form v-if="showNewForm" :drawer-open="showNewForm" @close="hideNewForm" />
+ <contact-form
+ v-if="showEditForm"
+ :contact="editingContact"
+ :drawer-open="showEditForm"
+ @close="hideEditForm"
+ />
<gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" />
<gl-table
v-else
+ class="gl-mt-5"
:items="contacts"
:fields="$options.fields"
:empty-text="$options.i18n.emptyText"
show-empty
- />
+ >
+ <template #cell(id)="data">
+ <gl-button
+ v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel"
+ class="gl-mr-3"
+ data-testid="issues-link"
+ icon="issues"
+ :aria-label="$options.i18n.issuesButtonLabel"
+ :href="getIssuesPath(groupIssuesPath, data.value)"
+ />
+ <gl-button
+ v-if="canAdmin"
+ v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel"
+ data-testid="edit-contact-button"
+ icon="pencil"
+ :aria-label="$options.i18n.editButtonLabel"
+ @click="edit(data.value)"
+ />
+ </template>
+ </gl-table>
</div>
</template>
diff --git a/app/assets/javascripts/crm/components/new_organization_form.vue b/app/assets/javascripts/crm/components/new_organization_form.vue
new file mode 100644
index 00000000000..3b11edc6935
--- /dev/null
+++ b/app/assets/javascripts/crm/components/new_organization_form.vue
@@ -0,0 +1,164 @@
+<script>
+import { GlAlert, GlButton, GlDrawer, GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { produce } from 'immer';
+import { __, s__ } from '~/locale';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_GROUP } from '~/graphql_shared/constants';
+import createOrganization from './queries/create_organization.mutation.graphql';
+import getGroupOrganizationsQuery from './queries/get_group_organizations.query.graphql';
+
+export default {
+ components: {
+ GlAlert,
+ GlButton,
+ GlDrawer,
+ GlFormGroup,
+ GlFormInput,
+ },
+ inject: ['groupFullPath', 'groupId'],
+ props: {
+ drawerOpen: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ name: '',
+ defaultRate: null,
+ description: '',
+ submitting: false,
+ errorMessages: [],
+ };
+ },
+ computed: {
+ invalid() {
+ return this.name.trim() === '';
+ },
+ },
+ methods: {
+ save() {
+ this.submitting = true;
+ return this.$apollo
+ .mutate({
+ mutation: createOrganization,
+ variables: {
+ input: {
+ groupId: convertToGraphQLId(TYPE_GROUP, this.groupId),
+ name: this.name,
+ defaultRate: this.defaultRate ? parseFloat(this.defaultRate) : null,
+ description: this.description,
+ },
+ },
+ update: this.updateCache,
+ })
+ .then(({ data }) => {
+ if (data.customerRelationsOrganizationCreate.errors.length === 0) this.close(true);
+
+ this.submitting = false;
+ })
+ .catch(() => {
+ this.errorMessages = [this.$options.i18n.somethingWentWrong];
+ this.submitting = false;
+ });
+ },
+ close(success) {
+ this.$emit('close', success);
+ },
+ updateCache(store, { data: { customerRelationsOrganizationCreate } }) {
+ if (customerRelationsOrganizationCreate.errors.length > 0) {
+ this.errorMessages = customerRelationsOrganizationCreate.errors;
+ return;
+ }
+
+ const variables = {
+ groupFullPath: this.groupFullPath,
+ };
+ const sourceData = store.readQuery({
+ query: getGroupOrganizationsQuery,
+ variables,
+ });
+
+ const data = produce(sourceData, (draftState) => {
+ draftState.group.organizations.nodes = [
+ ...sourceData.group.organizations.nodes,
+ customerRelationsOrganizationCreate.organization,
+ ];
+ });
+
+ store.writeQuery({
+ query: getGroupOrganizationsQuery,
+ variables,
+ data,
+ });
+ },
+ getDrawerHeaderHeight() {
+ const wrapperEl = document.querySelector('.content-wrapper');
+
+ if (wrapperEl) {
+ return `${wrapperEl.offsetTop}px`;
+ }
+
+ return '';
+ },
+ },
+ i18n: {
+ buttonLabel: s__('Crm|Create organization'),
+ cancel: __('Cancel'),
+ name: __('Name'),
+ defaultRate: s__('Crm|Default rate (optional)'),
+ description: __('Description (optional)'),
+ title: s__('Crm|New Organization'),
+ somethingWentWrong: __('Something went wrong. Please try again.'),
+ },
+};
+</script>
+
+<template>
+ <gl-drawer
+ class="gl-drawer-responsive"
+ :open="drawerOpen"
+ :header-height="getDrawerHeaderHeight()"
+ @close="close(false)"
+ >
+ <template #title>
+ <h4>{{ $options.i18n.title }}</h4>
+ </template>
+ <gl-alert v-if="errorMessages.length" variant="danger" @dismiss="errorMessages = []">
+ <ul class="gl-mb-0! gl-ml-5">
+ <li v-for="error in errorMessages" :key="error">
+ {{ error }}
+ </li>
+ </ul>
+ </gl-alert>
+ <form @submit.prevent="save">
+ <gl-form-group :label="$options.i18n.name" label-for="organization-name">
+ <gl-form-input id="organization-name" v-model="name" />
+ </gl-form-group>
+ <gl-form-group :label="$options.i18n.defaultRate" label-for="organization-default-rate">
+ <gl-form-input
+ id="organization-default-rate"
+ v-model="defaultRate"
+ type="number"
+ step="0.01"
+ />
+ </gl-form-group>
+ <gl-form-group :label="$options.i18n.description" label-for="organization-description">
+ <gl-form-input id="organization-description" v-model="description" />
+ </gl-form-group>
+ <span class="gl-float-right">
+ <gl-button data-testid="cancel-button" @click="close(false)">
+ {{ $options.i18n.cancel }}
+ </gl-button>
+ <gl-button
+ variant="confirm"
+ :disabled="invalid"
+ :loading="submitting"
+ data-testid="create-new-organization-button"
+ type="submit"
+ >{{ $options.i18n.buttonLabel }}</gl-button
+ >
+ </span>
+ </form>
+ </gl-drawer>
+</template>
diff --git a/app/assets/javascripts/crm/components/organizations_root.vue b/app/assets/javascripts/crm/components/organizations_root.vue
index 98b45d0a042..9370c6377e9 100644
--- a/app/assets/javascripts/crm/components/organizations_root.vue
+++ b/app/assets/javascripts/crm/components/organizations_root.vue
@@ -1,17 +1,29 @@
<script>
-import { GlLoadingIcon, GlTable } from '@gitlab/ui';
-import createFlash from '~/flash';
+import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui';
+import { parseBoolean } from '~/lib/utils/common_utils';
import { s__, __ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME } from '../constants';
import getGroupOrganizationsQuery from './queries/get_group_organizations.query.graphql';
+import NewOrganizationForm from './new_organization_form.vue';
export default {
components: {
+ GlAlert,
+ GlButton,
GlLoadingIcon,
GlTable,
+ NewOrganizationForm,
},
- inject: ['groupFullPath'],
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: ['canAdminCrmOrganization', 'groupFullPath', 'groupIssuesPath'],
data() {
- return { organizations: [] };
+ return {
+ error: false,
+ organizations: [],
+ };
},
apollo: {
organizations: {
@@ -26,12 +38,8 @@ export default {
update(data) {
return this.extractOrganizations(data);
},
- error(error) {
- createFlash({
- message: __('Something went wrong. Please try again.'),
- error,
- captureError: true,
- });
+ error() {
+ this.error = true;
},
},
},
@@ -39,33 +47,94 @@ export default {
isLoading() {
return this.$apollo.queries.organizations.loading;
},
+ showNewForm() {
+ return this.$route.name === NEW_ROUTE_NAME;
+ },
+ canCreateNew() {
+ return parseBoolean(this.canAdminCrmOrganization);
+ },
},
methods: {
extractOrganizations(data) {
const organizations = data?.group?.organizations?.nodes || [];
return organizations.slice().sort((a, b) => a.name.localeCompare(b.name));
},
+ getIssuesPath(path, value) {
+ return `${path}?scope=all&state=opened&crm_organization_id=${value}`;
+ },
+ displayNewForm() {
+ if (this.showNewForm) return;
+
+ this.$router.push({ name: NEW_ROUTE_NAME });
+ },
+ hideNewForm(success) {
+ if (success) this.$toast.show(this.$options.i18n.organizationAdded);
+
+ this.$router.replace({ name: INDEX_ROUTE_NAME });
+ },
},
fields: [
{ key: 'name', sortable: true },
{ key: 'defaultRate', sortable: true },
{ key: 'description', sortable: true },
+ {
+ key: 'id',
+ label: __('Issues'),
+ formatter: (id) => {
+ return getIdFromGraphQLId(id);
+ },
+ },
],
i18n: {
emptyText: s__('Crm|No organizations found'),
+ issuesButtonLabel: __('View issues'),
+ title: s__('Crm|Customer Relations Organizations'),
+ newOrganization: s__('Crm|New organization'),
+ errorText: __('Something went wrong. Please try again.'),
+ organizationAdded: s__('Crm|Organization has been added'),
},
};
</script>
<template>
<div>
+ <gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="error = false">
+ {{ $options.i18n.errorText }}
+ </gl-alert>
+ <div
+ class="gl-display-flex gl-align-items-baseline gl-flex-direction-row gl-justify-content-space-between gl-mt-6"
+ >
+ <h2 class="gl-font-size-h2 gl-my-0">
+ {{ $options.i18n.title }}
+ </h2>
+ <div
+ v-if="canCreateNew"
+ class="gl-display-none gl-md-display-flex gl-align-items-center gl-justify-content-end"
+ >
+ <gl-button variant="confirm" data-testid="new-organization-button" @click="displayNewForm">
+ {{ $options.i18n.newOrganization }}
+ </gl-button>
+ </div>
+ </div>
+ <new-organization-form v-if="showNewForm" :drawer-open="showNewForm" @close="hideNewForm" />
<gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" />
<gl-table
v-else
+ class="gl-mt-5"
:items="organizations"
:fields="$options.fields"
:empty-text="$options.i18n.emptyText"
show-empty
- />
+ >
+ <template #cell(id)="data">
+ <gl-button
+ v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel"
+ data-testid="issues-link"
+ icon="issues"
+ :aria-label="$options.i18n.issuesButtonLabel"
+ :href="getIssuesPath(groupIssuesPath, data.value)"
+ />
+ </template>
+ </gl-table>
</div>
</template>
diff --git a/app/assets/javascripts/crm/components/queries/create_contact.mutation.graphql b/app/assets/javascripts/crm/components/queries/create_contact.mutation.graphql
new file mode 100644
index 00000000000..e0192459609
--- /dev/null
+++ b/app/assets/javascripts/crm/components/queries/create_contact.mutation.graphql
@@ -0,0 +1,10 @@
+#import "./crm_contact_fields.fragment.graphql"
+
+mutation createContact($input: CustomerRelationsContactCreateInput!) {
+ customerRelationsContactCreate(input: $input) {
+ contact {
+ ...ContactFragment
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/crm/components/queries/create_organization.mutation.graphql b/app/assets/javascripts/crm/components/queries/create_organization.mutation.graphql
new file mode 100644
index 00000000000..2cc7e53ee9b
--- /dev/null
+++ b/app/assets/javascripts/crm/components/queries/create_organization.mutation.graphql
@@ -0,0 +1,10 @@
+#import "./crm_organization_fields.fragment.graphql"
+
+mutation createOrganization($input: CustomerRelationsOrganizationCreateInput!) {
+ customerRelationsOrganizationCreate(input: $input) {
+ organization {
+ ...OrganizationFragment
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/crm/components/queries/crm_contact_fields.fragment.graphql b/app/assets/javascripts/crm/components/queries/crm_contact_fields.fragment.graphql
new file mode 100644
index 00000000000..cef4083b446
--- /dev/null
+++ b/app/assets/javascripts/crm/components/queries/crm_contact_fields.fragment.graphql
@@ -0,0 +1,14 @@
+fragment ContactFragment on CustomerRelationsContact {
+ __typename
+ id
+ firstName
+ lastName
+ email
+ phone
+ description
+ organization {
+ __typename
+ id
+ name
+ }
+}
diff --git a/app/assets/javascripts/crm/components/queries/crm_organization_fields.fragment.graphql b/app/assets/javascripts/crm/components/queries/crm_organization_fields.fragment.graphql
new file mode 100644
index 00000000000..4adc5742d3a
--- /dev/null
+++ b/app/assets/javascripts/crm/components/queries/crm_organization_fields.fragment.graphql
@@ -0,0 +1,7 @@
+fragment OrganizationFragment on CustomerRelationsOrganization {
+ __typename
+ id
+ name
+ defaultRate
+ description
+}
diff --git a/app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql b/app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql
index f6acd258585..2a8150e42e3 100644
--- a/app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql
+++ b/app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql
@@ -1,21 +1,12 @@
+#import "./crm_contact_fields.fragment.graphql"
+
query contacts($groupFullPath: ID!) {
group(fullPath: $groupFullPath) {
__typename
id
contacts {
nodes {
- __typename
- id
- firstName
- lastName
- email
- phone
- description
- organization {
- __typename
- id
- name
- }
+ ...ContactFragment
}
}
}
diff --git a/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql b/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql
index 7c4ec6ec585..e8d8109431e 100644
--- a/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql
+++ b/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql
@@ -1,14 +1,12 @@
+#import "./crm_organization_fields.fragment.graphql"
+
query organizations($groupFullPath: ID!) {
group(fullPath: $groupFullPath) {
__typename
id
organizations {
nodes {
- __typename
- id
- name
- defaultRate
- description
+ ...OrganizationFragment
}
}
}
diff --git a/app/assets/javascripts/crm/components/queries/update_contact.mutation.graphql b/app/assets/javascripts/crm/components/queries/update_contact.mutation.graphql
new file mode 100644
index 00000000000..f55f6a10e0a
--- /dev/null
+++ b/app/assets/javascripts/crm/components/queries/update_contact.mutation.graphql
@@ -0,0 +1,10 @@
+#import "./crm_contact_fields.fragment.graphql"
+
+mutation updateContact($input: CustomerRelationsContactUpdateInput!) {
+ customerRelationsContactUpdate(input: $input) {
+ contact {
+ ...ContactFragment
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/crm/constants.js b/app/assets/javascripts/crm/constants.js
new file mode 100644
index 00000000000..3b085837aea
--- /dev/null
+++ b/app/assets/javascripts/crm/constants.js
@@ -0,0 +1,3 @@
+export const INDEX_ROUTE_NAME = 'index';
+export const NEW_ROUTE_NAME = 'new';
+export const EDIT_ROUTE_NAME = 'edit';
diff --git a/app/assets/javascripts/crm/contacts_bundle.js b/app/assets/javascripts/crm/contacts_bundle.js
index 6438953596e..f49ec64210f 100644
--- a/app/assets/javascripts/crm/contacts_bundle.js
+++ b/app/assets/javascripts/crm/contacts_bundle.js
@@ -1,9 +1,14 @@
+import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import VueRouter from 'vue-router';
import createDefaultClient from '~/lib/graphql';
import CrmContactsRoot from './components/contacts_root.vue';
+import routes from './routes';
Vue.use(VueApollo);
+Vue.use(VueRouter);
+Vue.use(GlToast);
export default () => {
const el = document.getElementById('js-crm-contacts-app');
@@ -16,10 +21,19 @@ export default () => {
return false;
}
+ const { basePath, groupFullPath, groupIssuesPath, canAdminCrmContact, groupId } = el.dataset;
+
+ const router = new VueRouter({
+ base: basePath,
+ mode: 'history',
+ routes,
+ });
+
return new Vue({
el,
+ router,
apolloProvider,
- provide: { groupFullPath: el.dataset.groupFullPath },
+ provide: { groupFullPath, groupIssuesPath, canAdminCrmContact, groupId },
render(createElement) {
return createElement(CrmContactsRoot);
},
diff --git a/app/assets/javascripts/crm/organizations_bundle.js b/app/assets/javascripts/crm/organizations_bundle.js
index ac9990b9fb4..828d7cd426c 100644
--- a/app/assets/javascripts/crm/organizations_bundle.js
+++ b/app/assets/javascripts/crm/organizations_bundle.js
@@ -1,9 +1,14 @@
+import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import VueRouter from 'vue-router';
import createDefaultClient from '~/lib/graphql';
import CrmOrganizationsRoot from './components/organizations_root.vue';
+import routes from './routes';
Vue.use(VueApollo);
+Vue.use(VueRouter);
+Vue.use(GlToast);
export default () => {
const el = document.getElementById('js-crm-organizations-app');
@@ -16,10 +21,19 @@ export default () => {
return false;
}
+ const { basePath, canAdminCrmOrganization, groupFullPath, groupId, groupIssuesPath } = el.dataset;
+
+ const router = new VueRouter({
+ base: basePath,
+ mode: 'history',
+ routes,
+ });
+
return new Vue({
el,
+ router,
apolloProvider,
- provide: { groupFullPath: el.dataset.groupFullPath },
+ provide: { canAdminCrmOrganization, groupFullPath, groupId, groupIssuesPath },
render(createElement) {
return createElement(CrmOrganizationsRoot);
},
diff --git a/app/assets/javascripts/crm/routes.js b/app/assets/javascripts/crm/routes.js
new file mode 100644
index 00000000000..12aa17d73b6
--- /dev/null
+++ b/app/assets/javascripts/crm/routes.js
@@ -0,0 +1,16 @@
+import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from './constants';
+
+export default [
+ {
+ name: INDEX_ROUTE_NAME,
+ path: '/',
+ },
+ {
+ name: NEW_ROUTE_NAME,
+ path: '/new',
+ },
+ {
+ name: EDIT_ROUTE_NAME,
+ path: '/:id/edit',
+ },
+];
diff --git a/app/assets/javascripts/delete_label_modal.js b/app/assets/javascripts/delete_label_modal.js
deleted file mode 100644
index cf7c9e7734f..00000000000
--- a/app/assets/javascripts/delete_label_modal.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import Vue from 'vue';
-import DeleteLabelModal from '~/vue_shared/components/delete_label_modal.vue';
-
-const mountDeleteLabelModal = (optionalProps) =>
- new Vue({
- render(h) {
- return h(DeleteLabelModal, {
- props: {
- selector: '.js-delete-label-modal-button',
- ...optionalProps,
- },
- });
- },
- }).$mount();
-
-export default (optionalProps = {}) => mountDeleteLabelModal(optionalProps);
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
index 813f87452d8..10976202d06 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
@@ -1,11 +1,12 @@
<script>
-import { GlTooltipDirective, GlIcon, GlLoadingIcon, GlLink, GlBadge } from '@gitlab/ui';
+import { GlTooltipDirective, GlIcon, GlLoadingIcon, GlLink } from '@gitlab/ui';
import { ApolloMutation } from 'vue-apollo';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
import { updateGlobalTodoCount } from '~/vue_shared/components/sidebar/todo_toggle/utils';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue';
import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants';
import createNoteMutation from '../../graphql/mutations/create_note.mutation.graphql';
import toggleResolveDiscussionMutation from '../../graphql/mutations/toggle_resolve_discussion.mutation.graphql';
@@ -30,7 +31,7 @@ export default {
GlLink,
ToggleRepliesWidget,
TimeAgoTooltip,
- GlBadge,
+ DesignNotePin,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -213,12 +214,7 @@ export default {
<template>
<div class="design-discussion-wrapper">
- <gl-badge
- class="gl-display-flex gl-align-items-center gl-justify-content-center gl-cursor-pointer"
- :class="{ resolved: discussion.resolved }"
- >
- {{ discussion.index }}
- </gl-badge>
+ <design-note-pin :is-resolved="discussion.resolved" :label="discussion.index" />
<ul
class="design-discussion bordered-box gl-relative gl-p-0 gl-list-style-none"
data-qa-selector="design_discussion_content"
diff --git a/app/assets/javascripts/design_management/components/design_overlay.vue b/app/assets/javascripts/design_management/components/design_overlay.vue
index 7815a57ce18..b058709b316 100644
--- a/app/assets/javascripts/design_management/components/design_overlay.vue
+++ b/app/assets/javascripts/design_management/components/design_overlay.vue
@@ -1,9 +1,9 @@
<script>
import { __ } from '~/locale';
+import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue';
import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants';
import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql';
import activeDiscussionQuery from '../graphql/queries/active_discussion.query.graphql';
-import DesignNotePin from './design_note_pin.vue';
export default {
name: 'DesignOverlay',
@@ -251,9 +251,6 @@ export default {
!discussionNotes.some(({ id }) => id === this.activeDiscussion.id)
);
},
- designPinClass(note) {
- return { inactive: this.isNoteInactive(note), resolved: note.resolved };
- },
},
i18n: {
newCommentButtonLabel: __('Add comment to design'),
@@ -287,7 +284,8 @@ export default {
? getNotePositionStyle(movingNoteNewPosition)
: getNotePositionStyle(note.position)
"
- :class="designPinClass(note)"
+ :is-inactive="isNoteInactive(note)"
+ :is-resolved="note.resolved"
@mousedown.stop="onNoteMousedown($event, note)"
@mouseup.stop="onNoteMouseup(note)"
/>
diff --git a/app/assets/javascripts/design_management/graphql/fragments/discussion_resolved_status.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/discussion_resolved_status.fragment.graphql
index 7483b508721..9ad85017921 100644
--- a/app/assets/javascripts/design_management/graphql/fragments/discussion_resolved_status.fragment.graphql
+++ b/app/assets/javascripts/design_management/graphql/fragments/discussion_resolved_status.fragment.graphql
@@ -1,8 +1,10 @@
fragment ResolvedStatus on Discussion {
+ id
resolvable
resolved
resolvedAt
resolvedBy {
+ id
name
webUrl
}
diff --git a/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql
index 111f5ac18a7..34d683ac1ee 100644
--- a/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql
+++ b/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql
@@ -3,6 +3,7 @@
mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) {
designManagementUpload(input: { projectPath: $projectPath, iid: $iid, files: $files }) {
+ # eslint-disable-next-line @graphql-eslint/require-id-when-available
designs {
...DesignItem
versions {
@@ -14,6 +15,7 @@ mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) {
}
}
skippedDesigns {
+ id
filename
}
errors
diff --git a/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql
index 99a61191c6e..a5394457f73 100644
--- a/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql
+++ b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql
@@ -10,8 +10,10 @@ query getDesign(
project(fullPath: $fullPath) {
id
issue(iid: $iid) {
+ id
designCollection {
designs(atVersion: $atVersion, filenames: $filenames) {
+ # eslint-disable-next-line @graphql-eslint/require-id-when-available
nodes {
...DesignItem
issue {
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index f405b82b05b..66d06a3a1b6 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -44,7 +44,6 @@ import {
TRACKING_MULTIPLE_FILES_MODE,
} from '../constants';
-import { discussionIntersectionObserverHandlerFactory } from '../utils/discussions';
import diffsEventHub from '../event_hub';
import { reviewStatuses } from '../utils/file_reviews';
import { diffsApp } from '../utils/performance';
@@ -87,9 +86,6 @@ export default {
ALERT_MERGE_CONFLICT,
ALERT_COLLAPSED_FILES,
},
- provide: {
- discussionObserverHandler: discussionIntersectionObserverHandlerFactory(),
- },
props: {
endpoint: {
type: String,
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index d09cc064b2c..4e77bf81c1e 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -10,6 +10,7 @@ import {
import { escape } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
import { IdState } from 'vendor/vue-virtual-scroller';
+import DiffContent from 'jh_else_ce/diffs/components/diff_content.vue';
import createFlash from '~/flash';
import { hasDiff } from '~/helpers/diffs_helper';
import { diffViewerErrors } from '~/ide/constants';
@@ -28,7 +29,6 @@ import {
import eventHub from '../event_hub';
import { DIFF_FILE, GENERIC_ERROR, CONFLICT_TEXT } from '../i18n';
import { collapsedType, getShortShaFromFile } from '../utils/diff_file';
-import DiffContent from './diff_content.vue';
import DiffFileHeader from './diff_file_header.vue';
export default {
diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue
index 4e33a02ca0e..4893803a3b6 100644
--- a/app/assets/javascripts/diffs/components/diff_row.vue
+++ b/app/assets/javascripts/diffs/components/diff_row.vue
@@ -42,6 +42,11 @@ export default {
required: false,
default: false,
},
+ coverageLoaded: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
inline: {
type: Boolean,
required: false,
@@ -83,14 +88,15 @@ export default {
if (!props.inline || !props.line.left) return {};
return props.fileLineCoverage(props.filePath, props.line.left.new_line);
},
- (props) => [props.inline, props.filePath, props.line.left?.new_line].join(':'),
+ (props) =>
+ [props.inline, props.filePath, props.line.left?.new_line, props.coverageLoaded].join(':'),
),
coverageStateRight: memoize(
(props) => {
if (!props.line.right) return {};
return props.fileLineCoverage(props.filePath, props.line.right.new_line);
},
- (props) => [props.line.right?.new_line, props.filePath].join(':'),
+ (props) => [props.line.right?.new_line, props.filePath, props.coverageLoaded].join(':'),
),
showCodequalityLeft: memoize(
(props) => {
diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue
index 55c796182ee..8562a1d44e7 100644
--- a/app/assets/javascripts/diffs/components/diff_view.vue
+++ b/app/assets/javascripts/diffs/components/diff_view.vue
@@ -52,7 +52,7 @@ export default {
},
computed: {
...mapGetters('diffs', ['commitId', 'fileLineCoverage']),
- ...mapState('diffs', ['codequalityDiff', 'highlightedRow']),
+ ...mapState('diffs', ['codequalityDiff', 'highlightedRow', 'coverageLoaded']),
...mapState({
selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition,
selectedCommentPositionHover: ({ notes }) => notes.selectedCommentPositionHover,
@@ -180,6 +180,7 @@ export default {
:index="index"
:is-highlighted="isHighlighted(line)"
:file-line-coverage="fileLineCoverage"
+ :coverage-loaded="coverageLoaded"
@showCommentForm="(code) => singleLineComment(code, line)"
@setHighlightedRow="setHighlightedRow"
@toggleLineDiscussions="
diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js
index a5b1a577a78..5f66360a040 100644
--- a/app/assets/javascripts/diffs/store/modules/diff_state.js
+++ b/app/assets/javascripts/diffs/store/modules/diff_state.js
@@ -21,6 +21,7 @@ export default () => ({
startVersion: null, // Null unless a target diff is selected for comparison that is not the "base" diff
diffFiles: [],
coverageFiles: {},
+ coverageLoaded: false,
mergeRequestDiffs: [],
mergeRequestDiff: null,
diffViewType: getViewTypeFromQueryString() || viewTypeFromCookie || defaultViewType,
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index 4a9df0eafcc..fb35114c0a9 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -86,7 +86,7 @@ export default {
},
[types.SET_COVERAGE_DATA](state, coverageFiles) {
- Object.assign(state, { coverageFiles });
+ Object.assign(state, { coverageFiles, coverageLoaded: true });
},
[types.RENDER_FILE](state, file) {
diff --git a/app/assets/javascripts/diffs/utils/discussions.js b/app/assets/javascripts/diffs/utils/discussions.js
deleted file mode 100644
index c404705d209..00000000000
--- a/app/assets/javascripts/diffs/utils/discussions.js
+++ /dev/null
@@ -1,76 +0,0 @@
-function normalize(processable) {
- const { entry } = processable;
- const offset = entry.rootBounds.bottom - entry.boundingClientRect.top;
- const direction =
- offset < 0 ? 'Up' : 'Down'; /* eslint-disable-line @gitlab/require-i18n-strings */
-
- return {
- ...processable,
- entry: {
- time: entry.time,
- type: entry.isIntersecting ? 'intersection' : `scroll${direction}`,
- },
- };
-}
-
-function sort({ entry: alpha }, { entry: beta }) {
- const diff = alpha.time - beta.time;
- let order = 0;
-
- if (diff < 0) {
- order = -1;
- } else if (diff > 0) {
- order = 1;
- } else if (alpha.type === 'intersection' && beta.type === 'scrollUp') {
- order = 2;
- } else if (alpha.type === 'scrollUp' && beta.type === 'intersection') {
- order = -2;
- }
-
- return order;
-}
-
-function filter(entry) {
- return entry.type !== 'scrollDown';
-}
-
-export function discussionIntersectionObserverHandlerFactory() {
- let unprocessed = [];
- let timer = null;
-
- return (processable) => {
- unprocessed.push(processable);
-
- if (timer) {
- clearTimeout(timer);
- }
-
- timer = setTimeout(() => {
- unprocessed
- .map(normalize)
- .filter(filter)
- .sort(sort)
- .forEach((discussionObservationContainer) => {
- const {
- entry: { type },
- currentDiscussion,
- isFirstUnresolved,
- isDiffsPage,
- functions: { setCurrentDiscussionId, getPreviousUnresolvedDiscussionId },
- } = discussionObservationContainer;
-
- if (type === 'intersection') {
- setCurrentDiscussionId(currentDiscussion.id);
- } else if (type === 'scrollUp') {
- setCurrentDiscussionId(
- isFirstUnresolved
- ? null
- : getPreviousUnresolvedDiscussionId(currentDiscussion.id, isDiffsPage),
- );
- }
- });
-
- unprocessed = [];
- }, 0);
- };
-}
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index f404fa4e0e8..7c7127dfa44 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -44,6 +44,7 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) {
let addFileToForm;
let updateAttachingMessage;
let uploadFile;
+ let hasPlainText;
formTextarea.wrap('<div class="div-dropzone"></div>');
formTextarea.on('paste', (event) => handlePaste(event));
@@ -184,7 +185,7 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) {
event.preventDefault();
const text = converter.convertToTableMarkdown();
pasteText(text);
- } else {
+ } else if (!hasPlainText(pasteEvent)) {
const fileList = [...clipboardData.files];
fileList.forEach((file) => {
if (file.type.indexOf('image') !== -1) {
@@ -203,6 +204,11 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) {
}
};
+ hasPlainText = (data) => {
+ const clipboardDataList = [...data.clipboardData.items];
+ return clipboardDataList.some((item) => item.type === 'text/plain');
+ };
+
pasteText = (text, shouldPad) => {
let formattedText = text;
if (shouldPad) {
diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js
index e855e304d27..2ae9c377683 100644
--- a/app/assets/javascripts/editor/constants.js
+++ b/app/assets/javascripts/editor/constants.js
@@ -42,6 +42,10 @@ export const EDITOR_EXTENSION_STORE_IS_MISSING_ERROR = s__(
// EXTENSIONS' CONSTANTS
//
+// Source Editor Base Extension
+export const EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS = 'link-anchor';
+export const EXTENSION_BASE_LINE_NUMBERS_CLASS = 'line-numbers';
+
// For CI config schemas the filename must match
// '*.gitlab-ci.yml' regardless of project configuration.
// https://gitlab.com/gitlab-org/gitlab/-/issues/293641
diff --git a/app/assets/javascripts/editor/extensions/example_source_editor_extension.js b/app/assets/javascripts/editor/extensions/example_source_editor_extension.js
index 119a2aea9eb..52e2bb0b5ff 100644
--- a/app/assets/javascripts/editor/extensions/example_source_editor_extension.js
+++ b/app/assets/javascripts/editor/extensions/example_source_editor_extension.js
@@ -7,6 +7,16 @@
export class MyFancyExtension {
/**
+ * A required getter returning the extension's name
+ * We have to provide it for every extension instead of relying on the built-in
+ * `name` prop because the prop does not survive the webpack's minification
+ * and the name mangling.
+ * @returns {string}
+ */
+ static get extensionName() {
+ return 'MyFancyExtension';
+ }
+ /**
* THE LIFE-CYCLE CALLBACKS
*/
@@ -16,11 +26,11 @@ export class MyFancyExtension {
* actions, keystrokes, update options, etc.
* Is called only once before the extension gets registered
*
- * @param { Object } [setupOptions] The setupOptions object
* @param { Object } [instance] The Source Editor instance
+ * @param { Object } [setupOptions] The setupOptions object
*/
// eslint-disable-next-line class-methods-use-this,no-unused-vars
- onSetup(setupOptions, instance) {}
+ onSetup(instance, setupOptions) {}
/**
* The first thing called after the extension is
diff --git a/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js
index 7069568275d..0290bb84b5f 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js
@@ -1,32 +1,27 @@
import ciSchemaPath from '~/editor/schema/ci.json';
import { registerSchema } from '~/ide/utils';
-import { SourceEditorExtension } from './source_editor_extension_base';
-export class CiSchemaExtension extends SourceEditorExtension {
- /**
- * Registers a syntax schema to the editor based on project
- * identifier and commit.
- *
- * The schema is added to the file that is currently edited
- * in the editor.
- *
- * @param {Object} opts
- * @param {String} opts.projectNamespace
- * @param {String} opts.projectPath
- * @param {String?} opts.ref - Current ref. Defaults to main
- */
- registerCiSchema() {
- // In order for workers loaded from `data://` as the
- // ones loaded by monaco editor, we use absolute URLs
- // to fetch schema files, hence the `gon.gitlab_url`
- // reference. This prevents error:
- // "Failed to execute 'fetch' on 'WorkerGlobalScope'"
- const absoluteSchemaUrl = gon.gitlab_url + ciSchemaPath;
- const modelFileName = this.getModel().uri.path.split('/').pop();
+export class CiSchemaExtension {
+ static get extensionName() {
+ return 'CiSchema';
+ }
+ // eslint-disable-next-line class-methods-use-this
+ provides() {
+ return {
+ registerCiSchema: (instance) => {
+ // In order for workers loaded from `data://` as the
+ // ones loaded by monaco editor, we use absolute URLs
+ // to fetch schema files, hence the `gon.gitlab_url`
+ // reference. This prevents error:
+ // "Failed to execute 'fetch' on 'WorkerGlobalScope'"
+ const absoluteSchemaUrl = gon.gitlab_url + ciSchemaPath;
+ const modelFileName = instance.getModel().uri.path.split('/').pop();
- registerSchema({
- uri: absoluteSchemaUrl,
- fileMatch: [modelFileName],
- });
+ registerSchema({
+ uri: absoluteSchemaUrl,
+ fileMatch: [modelFileName],
+ });
+ },
+ };
}
}
diff --git a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js
index 03c68fed3b1..3aa19df964c 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js
@@ -1,13 +1,16 @@
import { Range } from 'monaco-editor';
-import { waitForCSSLoaded } from '~/helpers/startup_css_helper';
-import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION, EDITOR_TYPE_CODE } from '../constants';
+import {
+ EDITOR_TYPE_CODE,
+ EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS,
+ EXTENSION_BASE_LINE_NUMBERS_CLASS,
+} from '../constants';
const hashRegexp = new RegExp('#?L', 'g');
const createAnchor = (href) => {
const fragment = new DocumentFragment();
const el = document.createElement('a');
- el.classList.add('link-anchor');
+ el.classList.add(EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS);
el.href = href;
fragment.appendChild(el);
el.addEventListener('contextmenu', (e) => {
@@ -17,38 +20,46 @@ const createAnchor = (href) => {
};
export class SourceEditorExtension {
- constructor({ instance, ...options } = {}) {
- if (instance) {
- Object.assign(instance, options);
- SourceEditorExtension.highlightLines(instance);
- if (instance.getEditorType && instance.getEditorType() === EDITOR_TYPE_CODE) {
- SourceEditorExtension.setupLineLinking(instance);
- }
- SourceEditorExtension.deferRerender(instance);
- } else if (Object.entries(options).length) {
- throw new Error(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION);
+ static get extensionName() {
+ return 'BaseExtension';
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ onUse(instance) {
+ SourceEditorExtension.highlightLines(instance);
+ if (instance.getEditorType && instance.getEditorType() === EDITOR_TYPE_CODE) {
+ SourceEditorExtension.setupLineLinking(instance);
}
}
- static deferRerender(instance) {
- waitForCSSLoaded(() => {
- instance.layout();
- });
+ static onMouseMoveHandler(e) {
+ const target = e.target.element;
+ if (target.classList.contains(EXTENSION_BASE_LINE_NUMBERS_CLASS)) {
+ const lineNum = e.target.position.lineNumber;
+ const hrefAttr = `#L${lineNum}`;
+ let lineLink = target.querySelector('a');
+ if (!lineLink) {
+ lineLink = createAnchor(hrefAttr);
+ target.appendChild(lineLink);
+ }
+ }
}
- static removeHighlights(instance) {
- Object.assign(instance, {
- lineDecorations: instance.deltaDecorations(instance.lineDecorations || [], []),
+ static setupLineLinking(instance) {
+ instance.onMouseMove(SourceEditorExtension.onMouseMoveHandler);
+ instance.onMouseDown((e) => {
+ const isCorrectAnchor = e.target.element.classList.contains(
+ EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS,
+ );
+ if (!isCorrectAnchor) {
+ return;
+ }
+ if (instance.lineDecorations) {
+ instance.deltaDecorations(instance.lineDecorations, []);
+ }
});
}
- /**
- * Returns a function that can only be invoked once between
- * each browser screen repaint.
- * @param {Object} instance - The Source Editor instance
- * @param {Array} bounds - The [start, end] array with start
- * and end coordinates for highlighting
- */
static highlightLines(instance, bounds = null) {
const [start, end] =
bounds && Array.isArray(bounds)
@@ -74,29 +85,29 @@ export class SourceEditorExtension {
}
}
- static onMouseMoveHandler(e) {
- const target = e.target.element;
- if (target.classList.contains('line-numbers')) {
- const lineNum = e.target.position.lineNumber;
- const hrefAttr = `#L${lineNum}`;
- let el = target.querySelector('a');
- if (!el) {
- el = createAnchor(hrefAttr);
- target.appendChild(el);
- }
- }
- }
+ // eslint-disable-next-line class-methods-use-this
+ provides() {
+ return {
+ /**
+ * Removes existing line decorations and updates the reference on the instance
+ * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
+ */
+ removeHighlights: (instance) => {
+ Object.assign(instance, {
+ lineDecorations: instance.deltaDecorations(instance.lineDecorations || [], []),
+ });
+ },
- static setupLineLinking(instance) {
- instance.onMouseMove(SourceEditorExtension.onMouseMoveHandler);
- instance.onMouseDown((e) => {
- const isCorrectAnchor = e.target.element.classList.contains('link-anchor');
- if (!isCorrectAnchor) {
- return;
- }
- if (instance.lineDecorations) {
- instance.deltaDecorations(instance.lineDecorations, []);
- }
- });
+ /**
+ * Returns a function that can only be invoked once between
+ * each browser screen repaint.
+ * @param {Array} bounds - The [start, end] array with start
+ * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
+ * and end coordinates for highlighting
+ */
+ highlightLines(instance, bounds = null) {
+ SourceEditorExtension.highlightLines(instance, bounds);
+ },
+ };
}
}
diff --git a/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js b/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js
index 397e090ed30..ba4980896e5 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js
@@ -1,8 +1,16 @@
import { Position } from 'monaco-editor';
-import { SourceEditorExtension } from './source_editor_extension_base';
-export class FileTemplateExtension extends SourceEditorExtension {
- navigateFileStart() {
- this.setPosition(new Position(1, 1));
+export class FileTemplateExtension {
+ static get extensionName() {
+ return 'FileTemplate';
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ provides() {
+ return {
+ navigateFileStart: (instance) => {
+ instance.setPosition(new Position(1, 1));
+ },
+ };
}
}
diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js
index 57de21c933e..a16fe93026e 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js
@@ -1,248 +1,102 @@
-import { debounce } from 'lodash';
-import { BLOB_PREVIEW_ERROR } from '~/blob_edit/constants';
-import createFlash from '~/flash';
-import { sanitize } from '~/lib/dompurify';
-import axios from '~/lib/utils/axios_utils';
-import { __ } from '~/locale';
-import syntaxHighlight from '~/syntax_highlight';
-import {
- EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS,
- EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
- EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
- EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS,
- EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY,
-} from '../constants';
-import { SourceEditorExtension } from './source_editor_extension_base';
-
-const getPreview = (text, previewMarkdownPath) => {
- return axios
- .post(previewMarkdownPath, {
- text,
- })
- .then(({ data }) => {
- return data.body;
- });
-};
-
-const setupDomElement = ({ injectToEl = null } = {}) => {
- const previewEl = document.createElement('div');
- previewEl.classList.add(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS);
- previewEl.style.display = 'none';
- if (injectToEl) {
- injectToEl.appendChild(previewEl);
+export class EditorMarkdownExtension {
+ static get extensionName() {
+ return 'EditorMarkdown';
}
- return previewEl;
-};
-export class EditorMarkdownExtension extends SourceEditorExtension {
- constructor({ instance, previewMarkdownPath, ...args } = {}) {
- super({ instance, ...args });
- Object.assign(instance, {
- previewMarkdownPath,
- preview: {
- el: undefined,
- action: undefined,
- shown: false,
- modelChangeListener: undefined,
+ // eslint-disable-next-line class-methods-use-this
+ provides() {
+ return {
+ getSelectedText: (instance, selection = instance.getSelection()) => {
+ const { startLineNumber, endLineNumber, startColumn, endColumn } = selection;
+ const valArray = instance.getValue().split('\n');
+ let text = '';
+ if (startLineNumber === endLineNumber) {
+ text = valArray[startLineNumber - 1].slice(startColumn - 1, endColumn - 1);
+ } else {
+ const startLineText = valArray[startLineNumber - 1].slice(startColumn - 1);
+ const endLineText = valArray[endLineNumber - 1].slice(0, endColumn - 1);
+
+ for (let i = startLineNumber, k = endLineNumber - 1; i < k; i += 1) {
+ text += `${valArray[i]}`;
+ if (i !== k - 1) text += `\n`;
+ }
+ text = text
+ ? [startLineText, text, endLineText].join('\n')
+ : [startLineText, endLineText].join('\n');
+ }
+ return text;
},
- });
- this.setupPreviewAction.call(instance);
-
- instance.getModel().onDidChangeLanguage(({ newLanguage, oldLanguage } = {}) => {
- if (newLanguage === 'markdown' && oldLanguage !== newLanguage) {
- instance.setupPreviewAction();
- } else {
- instance.cleanup();
- }
- });
-
- instance.onDidChangeModel(() => {
- const model = instance.getModel();
- if (model) {
- const { language } = model.getLanguageIdentifier();
- instance.cleanup();
- if (language === 'markdown') {
- instance.setupPreviewAction();
+ replaceSelectedText: (instance, text, select) => {
+ const forceMoveMarkers = !select;
+ instance.executeEdits('', [{ range: instance.getSelection(), text, forceMoveMarkers }]);
+ },
+ moveCursor: (instance, dx = 0, dy = 0) => {
+ const pos = instance.getPosition();
+ pos.column += dx;
+ pos.lineNumber += dy;
+ instance.setPosition(pos);
+ },
+ /**
+ * Adjust existing selection to select text within the original selection.
+ * - If `selectedText` is not supplied, we fetch selected text with
+ *
+ * ALGORITHM:
+ *
+ * MULTI-LINE SELECTION
+ * 1. Find line that contains `toSelect` text.
+ * 2. Using the index of this line and the position of `toSelect` text in it,
+ * construct:
+ * * newStartLineNumber
+ * * newStartColumn
+ *
+ * SINGLE-LINE SELECTION
+ * 1. Use `startLineNumber` from the current selection as `newStartLineNumber`
+ * 2. Find the position of `toSelect` text in it to get `newStartColumn`
+ *
+ * 3. `newEndLineNumber` — Since this method is supposed to be used with
+ * markdown decorators that are pretty short, the `newEndLineNumber` is
+ * suggested to be assumed the same as the startLine.
+ * 4. `newEndColumn` — pretty obvious
+ * 5. Adjust the start and end positions of the current selection
+ * 6. Re-set selection on the instance
+ *
+ * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance. Is passed automatically.
+ * @param {string} toSelect - New text to select within current selection.
+ * @param {string} selectedText - Currently selected text. It's just a
+ * shortcut: If it's not supplied, we fetch selected text from the instance
+ */
+ selectWithinSelection: (instance, toSelect, selectedText) => {
+ const currentSelection = instance.getSelection();
+ if (currentSelection.isEmpty() || !toSelect) {
+ return;
+ }
+ const text = selectedText || instance.getSelectedText(currentSelection);
+ let lineShift;
+ let newStartLineNumber;
+ let newStartColumn;
+
+ const textLines = text.split('\n');
+
+ if (textLines.length > 1) {
+ // Multi-line selection
+ lineShift = textLines.findIndex((line) => line.indexOf(toSelect) !== -1);
+ newStartLineNumber = currentSelection.startLineNumber + lineShift;
+ newStartColumn = textLines[lineShift].indexOf(toSelect) + 1;
+ } else {
+ // Single-line selection
+ newStartLineNumber = currentSelection.startLineNumber;
+ newStartColumn = currentSelection.startColumn + text.indexOf(toSelect);
}
- }
- });
- }
-
- static togglePreviewLayout() {
- const { width, height } = this.getLayoutInfo();
- const newWidth = this.preview.shown
- ? width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH
- : width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
- this.layout({ width: newWidth, height });
- }
-
- static togglePreviewPanel() {
- const parentEl = this.getDomNode().parentElement;
- const { el: previewEl } = this.preview;
- parentEl.classList.toggle(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS);
-
- if (previewEl.style.display === 'none') {
- // Show the preview panel
- this.fetchPreview();
- } else {
- // Hide the preview panel
- previewEl.style.display = 'none';
- }
- }
-
- cleanup() {
- if (this.preview.modelChangeListener) {
- this.preview.modelChangeListener.dispose();
- }
- this.preview.action.dispose();
- if (this.preview.shown) {
- EditorMarkdownExtension.togglePreviewPanel.call(this);
- EditorMarkdownExtension.togglePreviewLayout.call(this);
- }
- this.preview.shown = false;
- }
-
- fetchPreview() {
- const { el: previewEl } = this.preview;
- getPreview(this.getValue(), this.previewMarkdownPath)
- .then((data) => {
- previewEl.innerHTML = sanitize(data);
- syntaxHighlight(previewEl.querySelectorAll('.js-syntax-highlight'));
- previewEl.style.display = 'block';
- })
- .catch(() => createFlash(BLOB_PREVIEW_ERROR));
- }
- setupPreviewAction() {
- if (this.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)) return;
+ const newEndLineNumber = newStartLineNumber;
+ const newEndColumn = newStartColumn + toSelect.length;
- this.preview.action = this.addAction({
- id: EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
- label: __('Preview Markdown'),
- keybindings: [
- // eslint-disable-next-line no-bitwise,no-undef
- monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_P),
- ],
- contextMenuGroupId: 'navigation',
- contextMenuOrder: 1.5,
+ const newSelection = currentSelection
+ .setStartPosition(newStartLineNumber, newStartColumn)
+ .setEndPosition(newEndLineNumber, newEndColumn);
- // Method that will be executed when the action is triggered.
- // @param ed The editor instance is passed in as a convenience
- run(instance) {
- instance.togglePreview();
+ instance.setSelection(newSelection);
},
- });
- }
-
- togglePreview() {
- if (!this.preview?.el) {
- this.preview.el = setupDomElement({ injectToEl: this.getDomNode().parentElement });
- }
- EditorMarkdownExtension.togglePreviewLayout.call(this);
- EditorMarkdownExtension.togglePreviewPanel.call(this);
-
- if (!this.preview?.shown) {
- this.preview.modelChangeListener = this.onDidChangeModelContent(
- debounce(this.fetchPreview.bind(this), EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY),
- );
- } else {
- this.preview.modelChangeListener.dispose();
- }
-
- this.preview.shown = !this.preview?.shown;
- }
-
- getSelectedText(selection = this.getSelection()) {
- const { startLineNumber, endLineNumber, startColumn, endColumn } = selection;
- const valArray = this.getValue().split('\n');
- let text = '';
- if (startLineNumber === endLineNumber) {
- text = valArray[startLineNumber - 1].slice(startColumn - 1, endColumn - 1);
- } else {
- const startLineText = valArray[startLineNumber - 1].slice(startColumn - 1);
- const endLineText = valArray[endLineNumber - 1].slice(0, endColumn - 1);
-
- for (let i = startLineNumber, k = endLineNumber - 1; i < k; i += 1) {
- text += `${valArray[i]}`;
- if (i !== k - 1) text += `\n`;
- }
- text = text
- ? [startLineText, text, endLineText].join('\n')
- : [startLineText, endLineText].join('\n');
- }
- return text;
- }
-
- replaceSelectedText(text, select = undefined) {
- const forceMoveMarkers = !select;
- this.executeEdits('', [{ range: this.getSelection(), text, forceMoveMarkers }]);
- }
-
- moveCursor(dx = 0, dy = 0) {
- const pos = this.getPosition();
- pos.column += dx;
- pos.lineNumber += dy;
- this.setPosition(pos);
- }
-
- /**
- * Adjust existing selection to select text within the original selection.
- * - If `selectedText` is not supplied, we fetch selected text with
- *
- * ALGORITHM:
- *
- * MULTI-LINE SELECTION
- * 1. Find line that contains `toSelect` text.
- * 2. Using the index of this line and the position of `toSelect` text in it,
- * construct:
- * * newStartLineNumber
- * * newStartColumn
- *
- * SINGLE-LINE SELECTION
- * 1. Use `startLineNumber` from the current selection as `newStartLineNumber`
- * 2. Find the position of `toSelect` text in it to get `newStartColumn`
- *
- * 3. `newEndLineNumber` — Since this method is supposed to be used with
- * markdown decorators that are pretty short, the `newEndLineNumber` is
- * suggested to be assumed the same as the startLine.
- * 4. `newEndColumn` — pretty obvious
- * 5. Adjust the start and end positions of the current selection
- * 6. Re-set selection on the instance
- *
- * @param {string} toSelect - New text to select within current selection.
- * @param {string} selectedText - Currently selected text. It's just a
- * shortcut: If it's not supplied, we fetch selected text from the instance
- */
- selectWithinSelection(toSelect, selectedText) {
- const currentSelection = this.getSelection();
- if (currentSelection.isEmpty() || !toSelect) {
- return;
- }
- const text = selectedText || this.getSelectedText(currentSelection);
- let lineShift;
- let newStartLineNumber;
- let newStartColumn;
-
- const textLines = text.split('\n');
-
- if (textLines.length > 1) {
- // Multi-line selection
- lineShift = textLines.findIndex((line) => line.indexOf(toSelect) !== -1);
- newStartLineNumber = currentSelection.startLineNumber + lineShift;
- newStartColumn = textLines[lineShift].indexOf(toSelect) + 1;
- } else {
- // Single-line selection
- newStartLineNumber = currentSelection.startLineNumber;
- newStartColumn = currentSelection.startColumn + text.indexOf(toSelect);
- }
-
- const newEndLineNumber = newStartLineNumber;
- const newEndColumn = newStartColumn + toSelect.length;
-
- const newSelection = currentSelection
- .setStartPosition(newStartLineNumber, newStartColumn)
- .setEndPosition(newEndLineNumber, newEndColumn);
-
- this.setSelection(newSelection);
+ };
}
}
diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js
new file mode 100644
index 00000000000..9d53268c340
--- /dev/null
+++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js
@@ -0,0 +1,167 @@
+import { debounce } from 'lodash';
+import { BLOB_PREVIEW_ERROR } from '~/blob_edit/constants';
+import createFlash from '~/flash';
+import { sanitize } from '~/lib/dompurify';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import syntaxHighlight from '~/syntax_highlight';
+import {
+ EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS,
+ EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
+ EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
+ EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS,
+ EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY,
+} from '../constants';
+
+const fetchPreview = (text, previewMarkdownPath) => {
+ return axios
+ .post(previewMarkdownPath, {
+ text,
+ })
+ .then(({ data }) => {
+ return data.body;
+ });
+};
+
+const setupDomElement = ({ injectToEl = null } = {}) => {
+ const previewEl = document.createElement('div');
+ previewEl.classList.add(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS);
+ previewEl.style.display = 'none';
+ if (injectToEl) {
+ injectToEl.appendChild(previewEl);
+ }
+ return previewEl;
+};
+
+export class EditorMarkdownPreviewExtension {
+ static get extensionName() {
+ return 'EditorMarkdownPreview';
+ }
+
+ onSetup(instance, setupOptions) {
+ this.preview = {
+ el: undefined,
+ action: undefined,
+ shown: false,
+ modelChangeListener: undefined,
+ path: setupOptions.previewMarkdownPath,
+ };
+ this.setupPreviewAction(instance);
+
+ instance.getModel().onDidChangeLanguage(({ newLanguage, oldLanguage } = {}) => {
+ if (newLanguage === 'markdown' && oldLanguage !== newLanguage) {
+ instance.setupPreviewAction();
+ } else {
+ instance.cleanup();
+ }
+ });
+
+ instance.onDidChangeModel(() => {
+ const model = instance.getModel();
+ if (model) {
+ const { language } = model.getLanguageIdentifier();
+ instance.cleanup();
+ if (language === 'markdown') {
+ instance.setupPreviewAction();
+ }
+ }
+ });
+ }
+
+ togglePreviewLayout(instance) {
+ const { width, height } = instance.getLayoutInfo();
+ const newWidth = this.preview.shown
+ ? width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH
+ : width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
+ instance.layout({ width: newWidth, height });
+ }
+
+ togglePreviewPanel(instance) {
+ const parentEl = instance.getDomNode().parentElement;
+ const { el: previewEl } = this.preview;
+ parentEl.classList.toggle(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS);
+
+ if (previewEl.style.display === 'none') {
+ // Show the preview panel
+ this.fetchPreview(instance);
+ } else {
+ // Hide the preview panel
+ previewEl.style.display = 'none';
+ }
+ }
+
+ fetchPreview(instance) {
+ const { el: previewEl } = this.preview;
+ fetchPreview(instance.getValue(), this.preview.path)
+ .then((data) => {
+ previewEl.innerHTML = sanitize(data);
+ syntaxHighlight(previewEl.querySelectorAll('.js-syntax-highlight'));
+ previewEl.style.display = 'block';
+ })
+ .catch(() => createFlash(BLOB_PREVIEW_ERROR));
+ }
+
+ setupPreviewAction(instance) {
+ if (instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)) return;
+
+ this.preview.action = instance.addAction({
+ id: EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
+ label: __('Preview Markdown'),
+ keybindings: [
+ // eslint-disable-next-line no-bitwise,no-undef
+ monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_P),
+ ],
+ contextMenuGroupId: 'navigation',
+ contextMenuOrder: 1.5,
+
+ // Method that will be executed when the action is triggered.
+ // @param ed The editor instance is passed in as a convenience
+ run(inst) {
+ inst.togglePreview();
+ },
+ });
+ }
+
+ provides() {
+ return {
+ markdownPreview: this.preview,
+
+ cleanup: (instance) => {
+ if (this.preview.modelChangeListener) {
+ this.preview.modelChangeListener.dispose();
+ }
+ this.preview.action.dispose();
+ if (this.preview.shown) {
+ this.togglePreviewPanel(instance);
+ this.togglePreviewLayout(instance);
+ }
+ this.preview.shown = false;
+ },
+
+ fetchPreview: (instance) => this.fetchPreview(instance),
+
+ setupPreviewAction: (instance) => this.setupPreviewAction(instance),
+
+ togglePreview: (instance) => {
+ if (!this.preview?.el) {
+ this.preview.el = setupDomElement({ injectToEl: instance.getDomNode().parentElement });
+ }
+ this.togglePreviewLayout(instance);
+ this.togglePreviewPanel(instance);
+
+ if (!this.preview?.shown) {
+ this.preview.modelChangeListener = instance.onDidChangeModelContent(
+ debounce(
+ this.fetchPreview.bind(this, instance),
+ EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY,
+ ),
+ );
+ } else {
+ this.preview.modelChangeListener.dispose();
+ }
+
+ this.preview.shown = !this.preview?.shown;
+ },
+ };
+ }
+}
diff --git a/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js b/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js
index 98e05489c1c..4e8c11bac54 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js
@@ -1,7 +1,15 @@
+/**
+ * A WebIDE Extension options for Source Editor
+ * @typedef {Object} WebIDEExtensionOptions
+ * @property {Object} modelManager The root manager for WebIDE models
+ * @property {Object} store The state store for communication
+ * @property {Object} file
+ * @property {Object} options The Monaco editor options
+ */
+
import { debounce } from 'lodash';
import { KeyCode, KeyMod, Range } from 'monaco-editor';
import { EDITOR_TYPE_DIFF } from '~/editor/constants';
-import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
import Disposable from '~/ide/lib/common/disposable';
import { editorOptions } from '~/ide/lib/editor_options';
import keymap from '~/ide/lib/keymap.json';
@@ -11,154 +19,168 @@ const isDiffEditorType = (instance) => {
};
export const UPDATE_DIMENSIONS_DELAY = 200;
+const defaultOptions = {
+ modelManager: undefined,
+ store: undefined,
+ file: undefined,
+ options: {},
+};
-export class EditorWebIdeExtension extends SourceEditorExtension {
- constructor({ instance, modelManager, ...options } = {}) {
- super({
- instance,
- ...options,
- modelManager,
- disposable: new Disposable(),
- debouncedUpdate: debounce(() => {
- instance.updateDimensions();
- }, UPDATE_DIMENSIONS_DELAY),
- });
-
- window.addEventListener('resize', instance.debouncedUpdate, false);
-
- instance.onDidDispose(() => {
- window.removeEventListener('resize', instance.debouncedUpdate);
-
- // catch any potential errors with disposing the error
- // this is mainly for tests caused by elements not existing
- try {
- instance.disposable.dispose();
- } catch (e) {
- if (process.env.NODE_ENV !== 'test') {
- // eslint-disable-next-line no-console
- console.error(e);
- }
- }
- });
+const addActions = (instance, store) => {
+ const getKeyCode = (key) => {
+ const monacoKeyMod = key.indexOf('KEY_') === 0;
- EditorWebIdeExtension.addActions(instance);
- }
+ return monacoKeyMod ? KeyCode[key] : KeyMod[key];
+ };
- static addActions(instance) {
- const { store } = instance;
- const getKeyCode = (key) => {
- const monacoKeyMod = key.indexOf('KEY_') === 0;
+ keymap.forEach((command) => {
+ const { bindings, id, label, action } = command;
- return monacoKeyMod ? KeyCode[key] : KeyMod[key];
- };
+ const keybindings = bindings.map((binding) => {
+ const keys = binding.split('+');
- keymap.forEach((command) => {
- const { bindings, id, label, action } = command;
-
- const keybindings = bindings.map((binding) => {
- const keys = binding.split('+');
-
- // eslint-disable-next-line no-bitwise
- return keys.length > 1 ? getKeyCode(keys[0]) | getKeyCode(keys[1]) : getKeyCode(keys[0]);
- });
-
- instance.addAction({
- id,
- label,
- keybindings,
- run() {
- store.dispatch(action.name, action.params);
- return null;
- },
- });
+ // eslint-disable-next-line no-bitwise
+ return keys.length > 1 ? getKeyCode(keys[0]) | getKeyCode(keys[1]) : getKeyCode(keys[0]);
});
- }
-
- createModel(file, head = null) {
- return this.modelManager.addModel(file, head);
- }
-
- attachModel(model) {
- if (isDiffEditorType(this)) {
- this.setModel({
- original: model.getOriginalModel(),
- modified: model.getModel(),
- });
- return;
- }
-
- this.setModel(model.getModel());
+ instance.addAction({
+ id,
+ label,
+ keybindings,
+ run() {
+ store.dispatch(action.name, action.params);
+ return null;
+ },
+ });
+ });
+};
- this.updateOptions(
- editorOptions.reduce((acc, obj) => {
- Object.keys(obj).forEach((key) => {
- Object.assign(acc, {
- [key]: obj[key](model),
- });
- });
- return acc;
- }, {}),
- );
- }
+const renderSideBySide = (domElement) => {
+ return domElement.offsetWidth >= 700;
+};
- attachMergeRequestModel(model) {
- this.setModel({
- original: model.getBaseModel(),
- modified: model.getModel(),
+const updateInstanceDimensions = (instance) => {
+ instance.layout();
+ if (isDiffEditorType(instance)) {
+ instance.updateOptions({
+ renderSideBySide: renderSideBySide(instance.getDomNode()),
});
}
+};
- updateDimensions() {
- this.layout();
- this.updateDiffView();
+export class EditorWebIdeExtension {
+ static get extensionName() {
+ return 'EditorWebIde';
}
- setPos({ lineNumber, column }) {
- this.revealPositionInCenter({
- lineNumber,
- column,
- });
- this.setPosition({
- lineNumber,
- column,
- });
+ /**
+ * Set up the WebIDE extension for Source Editor
+ * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
+ * @param {WebIDEExtensionOptions} setupOptions
+ */
+ onSetup(instance, setupOptions = defaultOptions) {
+ this.modelManager = setupOptions.modelManager;
+ this.store = setupOptions.store;
+ this.file = setupOptions.file;
+ this.options = setupOptions.options;
+
+ this.disposable = new Disposable();
+ this.debouncedUpdate = debounce(() => {
+ updateInstanceDimensions(instance);
+ }, UPDATE_DIMENSIONS_DELAY);
+
+ addActions(instance, setupOptions.store);
}
- onPositionChange(cb) {
- if (!this.onDidChangeCursorPosition) {
- return;
- }
+ onUse(instance) {
+ window.addEventListener('resize', this.debouncedUpdate, false);
- this.disposable.add(this.onDidChangeCursorPosition((e) => cb(this, e)));
+ instance.onDidDispose(() => {
+ this.onUnuse();
+ });
}
- updateDiffView() {
- if (!isDiffEditorType(this)) {
- return;
+ onUnuse() {
+ window.removeEventListener('resize', this.debouncedUpdate);
+
+ // catch any potential errors with disposing the error
+ // this is mainly for tests caused by elements not existing
+ try {
+ this.disposable.dispose();
+ } catch (e) {
+ if (process.env.NODE_ENV !== 'test') {
+ // eslint-disable-next-line no-console
+ console.error(e);
+ }
}
-
- this.updateOptions({
- renderSideBySide: EditorWebIdeExtension.renderSideBySide(this.getDomNode()),
- });
}
- replaceSelectedText(text) {
- let selection = this.getSelection();
- const range = new Range(
- selection.startLineNumber,
- selection.startColumn,
- selection.endLineNumber,
- selection.endColumn,
- );
+ provides() {
+ return {
+ createModel: (instance, file, head = null) => {
+ return this.modelManager.addModel(file, head);
+ },
+ attachModel: (instance, model) => {
+ if (isDiffEditorType(instance)) {
+ instance.setModel({
+ original: model.getOriginalModel(),
+ modified: model.getModel(),
+ });
- this.executeEdits('', [{ range, text }]);
+ return;
+ }
- selection = this.getSelection();
- this.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn });
- }
+ instance.setModel(model.getModel());
+
+ instance.updateOptions(
+ editorOptions.reduce((acc, obj) => {
+ Object.keys(obj).forEach((key) => {
+ Object.assign(acc, {
+ [key]: obj[key](model),
+ });
+ });
+ return acc;
+ }, {}),
+ );
+ },
+ attachMergeRequestModel: (instance, model) => {
+ instance.setModel({
+ original: model.getBaseModel(),
+ modified: model.getModel(),
+ });
+ },
+ updateDimensions: (instance) => updateInstanceDimensions(instance),
+ setPos: (instance, { lineNumber, column }) => {
+ instance.revealPositionInCenter({
+ lineNumber,
+ column,
+ });
+ instance.setPosition({
+ lineNumber,
+ column,
+ });
+ },
+ onPositionChange: (instance, cb) => {
+ if (typeof instance.onDidChangeCursorPosition !== 'function') {
+ return;
+ }
- static renderSideBySide(domElement) {
- return domElement.offsetWidth >= 700;
+ this.disposable.add(instance.onDidChangeCursorPosition((e) => cb(instance, e)));
+ },
+ replaceSelectedText: (instance, text) => {
+ let selection = instance.getSelection();
+ const range = new Range(
+ selection.startLineNumber,
+ selection.startColumn,
+ selection.endLineNumber,
+ selection.endColumn,
+ );
+
+ instance.executeEdits('', [{ range, text }]);
+
+ selection = instance.getSelection();
+ instance.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn });
+ },
+ };
}
}
diff --git a/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js b/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js
index 212e09c8724..05ce617ca7c 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js
@@ -1,50 +1,46 @@
+/**
+ * A Yaml Editor Extension options for Source Editor
+ * @typedef {Object} YamlEditorExtensionOptions
+ * @property { boolean } enableComments Convert model nodes with the comment
+ * pattern to comments?
+ * @property { string } highlightPath Add a line highlight to the
+ * node specified by this e.g. `"foo.bar[0]"`
+ * @property { * } model Any JS Object that will be stringified and used as the
+ * editor's value. Equivalent to using `setDataModel()`
+ * @property options SourceEditorExtension Options
+ */
+
import { toPath } from 'lodash';
import { parseDocument, Document, visit, isScalar, isCollection, isMap } from 'yaml';
import { findPair } from 'yaml/util';
-import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
-export class YamlEditorExtension extends SourceEditorExtension {
+export class YamlEditorExtension {
+ static get extensionName() {
+ return 'YamlEditor';
+ }
+
/**
* Extends the source editor with capabilities for yaml files.
*
- * @param { Instance } instance Source Editor Instance
- * @param { boolean } enableComments Convert model nodes with the comment
- * pattern to comments?
- * @param { string } highlightPath Add a line highlight to the
- * node specified by this e.g. `"foo.bar[0]"`
- * @param { * } model Any JS Object that will be stringified and used as the
- * editor's value. Equivalent to using `setDataModel()`
- * @param options SourceEditorExtension Options
+ * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
+ * @param {YamlEditorExtensionOptions} setupOptions
*/
- constructor({
- instance,
- enableComments = false,
- highlightPath = null,
- model = null,
- ...options
- } = {}) {
- super({
- instance,
- options: {
- ...options,
- enableComments,
- highlightPath,
- },
- });
+ onSetup(instance, setupOptions = {}) {
+ const { enableComments = false, highlightPath = null, model = null } = setupOptions;
+ this.enableComments = enableComments;
+ this.highlightPath = highlightPath;
+ this.model = model;
if (model) {
- YamlEditorExtension.initFromModel(instance, model);
+ this.initFromModel(instance, model);
}
instance.onDidChangeModelContent(() => instance.onUpdate());
}
- /**
- * @private
- */
- static initFromModel(instance, model) {
+ initFromModel(instance, model) {
const doc = new Document(model);
- if (instance.options.enableComments) {
+ if (this.enableComments) {
YamlEditorExtension.transformComments(doc);
}
instance.setValue(doc.toString());
@@ -160,110 +156,13 @@ export class YamlEditorExtension extends SourceEditorExtension {
return doc;
}
- /**
- * Get the editor's value parsed as a `Document` as defined by the `yaml`
- * package
- * @returns {Document}
- */
- getDoc() {
- return parseDocument(this.getValue());
- }
-
- /**
- * Accepts a `Document` as defined by the `yaml` package and
- * sets the Editor's value to a stringified version of it.
- * @param { Document } doc
- */
- setDoc(doc) {
- if (this.options.enableComments) {
- YamlEditorExtension.transformComments(doc);
- }
-
- if (!this.getValue()) {
- this.setValue(doc.toString());
- } else {
- this.updateValue(doc.toString());
- }
- }
-
- /**
- * Returns the parsed value of the Editor's content as JS.
- * @returns {*}
- */
- getDataModel() {
- return this.getDoc().toJS();
- }
-
- /**
- * Accepts any JS Object and sets the Editor's value to a stringified version
- * of that value.
- *
- * @param value
- */
- setDataModel(value) {
- this.setDoc(new Document(value));
- }
-
- /**
- * Method to be executed when the Editor's <TextModel> was updated
- */
- onUpdate() {
- if (this.options.highlightPath) {
- this.highlight(this.options.highlightPath);
- }
- }
-
- /**
- * Set the editors content to the input without recreating the content model.
- *
- * @param blob
- */
- updateValue(blob) {
- // Using applyEdits() instead of setValue() ensures that tokens such as
- // highlighted lines aren't deleted/recreated which causes a flicker.
- const model = this.getModel();
- model.applyEdits([
- {
- // A nice improvement would be to replace getFullModelRange() with
- // a range of the actual diff, avoiding re-formatting the document,
- // but that's something for a later iteration.
- range: model.getFullModelRange(),
- text: blob,
- },
- ]);
- }
-
- /**
- * Add a line highlight style to the node specified by the path.
- *
- * @param {string|null|false} path A path to a node of the Editor's value,
- * e.g. `"foo.bar[0]"`. If the value is falsy, this will remove all
- * highlights.
- */
- highlight(path) {
- if (this.options.highlightPath === path) return;
- if (!path) {
- SourceEditorExtension.removeHighlights(this);
- } else {
- const res = this.locate(path);
- SourceEditorExtension.highlightLines(this, res);
- }
- this.options.highlightPath = path || null;
+ static getDoc(instance) {
+ return parseDocument(instance.getValue());
}
- /**
- * Return the line numbers of a certain node identified by `path` within
- * the yaml.
- *
- * @param {string} path A path to a node, eg. `foo.bar[0]`
- * @returns {number[]} Array following the schema `[firstLine, lastLine]`
- * (both inclusive)
- *
- * @throws {Error} Will throw if the path is not found inside the document
- */
- locate(path) {
+ static locate(instance, path) {
if (!path) throw Error(`No path provided.`);
- const blob = this.getValue();
+ const blob = instance.getValue();
const doc = parseDocument(blob);
const pathArray = toPath(path);
@@ -290,4 +189,120 @@ export class YamlEditorExtension extends SourceEditorExtension {
const endLine = (endSlice.match(/\n/g) || []).length;
return [startLine, endLine];
}
+
+ setDoc(instance, doc) {
+ if (this.enableComments) {
+ YamlEditorExtension.transformComments(doc);
+ }
+
+ if (!instance.getValue()) {
+ instance.setValue(doc.toString());
+ } else {
+ instance.updateValue(doc.toString());
+ }
+ }
+
+ highlight(instance, path) {
+ // IMPORTANT
+ // removeHighlight and highlightLines both come from
+ // SourceEditorExtension. So it has to be installed prior to this extension
+ if (this.highlightPath === path) return;
+ if (!path) {
+ instance.removeHighlights();
+ } else {
+ const res = YamlEditorExtension.locate(instance, path);
+ instance.highlightLines(res);
+ }
+ this.highlightPath = path || null;
+ }
+
+ provides() {
+ return {
+ /**
+ * Get the editor's value parsed as a `Document` as defined by the `yaml`
+ * package
+ * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
+ * @returns {Document}
+ */
+ getDoc: (instance) => YamlEditorExtension.getDoc(instance),
+
+ /**
+ * Accepts a `Document` as defined by the `yaml` package and
+ * sets the Editor's value to a stringified version of it.
+ * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
+ * @param { Document } doc
+ */
+ setDoc: (instance, doc) => this.setDoc(instance, doc),
+
+ /**
+ * Returns the parsed value of the Editor's content as JS.
+ * @returns {*}
+ */
+ getDataModel: (instance) => YamlEditorExtension.getDoc(instance).toJS(),
+
+ /**
+ * Accepts any JS Object and sets the Editor's value to a stringified version
+ * of that value.
+ *
+ * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
+ * @param value
+ */
+ setDataModel: (instance, value) => this.setDoc(instance, new Document(value)),
+
+ /**
+ * Method to be executed when the Editor's <TextModel> was updated
+ */
+ onUpdate: (instance) => {
+ if (this.highlightPath) {
+ this.highlight(instance, this.highlightPath);
+ }
+ },
+
+ /**
+ * Set the editors content to the input without recreating the content model.
+ *
+ * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
+ * @param blob
+ */
+ updateValue: (instance, blob) => {
+ // Using applyEdits() instead of setValue() ensures that tokens such as
+ // highlighted lines aren't deleted/recreated which causes a flicker.
+ const model = instance.getModel();
+ model.applyEdits([
+ {
+ // A nice improvement would be to replace getFullModelRange() with
+ // a range of the actual diff, avoiding re-formatting the document,
+ // but that's something for a later iteration.
+ range: model.getFullModelRange(),
+ text: blob,
+ },
+ ]);
+ },
+
+ /**
+ * Add a line highlight style to the node specified by the path.
+ *
+ * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
+ * @param {string|null|false} path A path to a node of the Editor's value,
+ * e.g. `"foo.bar[0]"`. If the value is falsy, this will remove all
+ * highlights.
+ */
+ highlight: (instance, path) => this.highlight(instance, path),
+
+ /**
+ * Return the line numbers of a certain node identified by `path` within
+ * the yaml.
+ *
+ * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
+ * @param {string} path A path to a node, eg. `foo.bar[0]`
+ * @returns {number[]} Array following the schema `[firstLine, lastLine]`
+ * (both inclusive)
+ *
+ * @throws {Error} Will throw if the path is not found inside the document
+ */
+ locate: (instance, path) => YamlEditorExtension.locate(instance, path),
+
+ initFromModel: (instance, model) => this.initFromModel(instance, model),
+ };
+ }
}
diff --git a/app/assets/javascripts/editor/source_editor.js b/app/assets/javascripts/editor/source_editor.js
index 81ddf8d77fa..57e2b0da565 100644
--- a/app/assets/javascripts/editor/source_editor.js
+++ b/app/assets/javascripts/editor/source_editor.js
@@ -1,4 +1,5 @@
import { editor as monacoEditor, Uri } from 'monaco-editor';
+import { waitForCSSLoaded } from '~/helpers/startup_css_helper';
import { defaultEditorOptions } from '~/ide/lib/editor_options';
import languages from '~/ide/lib/languages';
import { registerLanguages } from '~/ide/utils';
@@ -11,10 +12,39 @@ import {
EDITOR_TYPE_DIFF,
} from './constants';
import { clearDomElement, setupEditorTheme, getBlobLanguage } from './utils';
+import EditorInstance from './source_editor_instance';
+
+const instanceRemoveFromRegistry = (editor, instance) => {
+ const index = editor.instances.findIndex((inst) => inst === instance);
+ editor.instances.splice(index, 1);
+};
+
+const instanceDisposeModels = (editor, instance, model) => {
+ const instanceModel = instance.getModel() || model;
+ if (!instanceModel) {
+ return;
+ }
+ if (instance.getEditorType() === EDITOR_TYPE_DIFF) {
+ const { original, modified } = instanceModel;
+ if (original) {
+ original.dispose();
+ }
+ if (modified) {
+ modified.dispose();
+ }
+ } else {
+ instanceModel.dispose();
+ }
+};
export default class SourceEditor {
+ /**
+ * Constructs a global editor.
+ * @param {Object} options - Monaco config options used to create the editor
+ */
constructor(options = {}) {
this.instances = [];
+ this.extensionsStore = new Map();
this.options = {
extraEditorClassName: 'gl-source-editor',
...defaultEditorOptions,
@@ -26,39 +56,6 @@ export default class SourceEditor {
registerLanguages(...languages);
}
- static pushToImportsArray(arr, toImport) {
- arr.push(import(toImport));
- }
-
- static loadExtensions(extensions) {
- if (!extensions) {
- return Promise.resolve();
- }
- const promises = [];
- const extensionsArray = typeof extensions === 'string' ? extensions.split(',') : extensions;
-
- extensionsArray.forEach((ext) => {
- const prefix = ext.includes('/') ? '' : 'editor/';
- const trimmedExt = ext.replace(/^\//, '').trim();
- SourceEditor.pushToImportsArray(promises, `~/${prefix}${trimmedExt}`);
- });
-
- return Promise.all(promises);
- }
-
- static mixIntoInstance(source, inst) {
- if (!inst) {
- return;
- }
- const isClassInstance = source.constructor.prototype !== Object.prototype;
- const sanitizedSource = isClassInstance ? source.constructor.prototype : source;
- Object.getOwnPropertyNames(sanitizedSource).forEach((prop) => {
- if (prop !== 'constructor') {
- Object.assign(inst, { [prop]: source[prop] });
- }
- });
- }
-
static prepareInstance(el) {
if (!el) {
throw new Error(SOURCE_EDITOR_INSTANCE_ERROR_NO_EL);
@@ -71,23 +68,6 @@ export default class SourceEditor {
});
}
- static manageDefaultExtensions(instance, el, extensions) {
- SourceEditor.loadExtensions(extensions, instance)
- .then((modules) => {
- if (modules) {
- modules.forEach((module) => {
- instance.use(module.default);
- });
- }
- })
- .then(() => {
- el.dispatchEvent(new Event(EDITOR_READY_EVENT));
- })
- .catch((e) => {
- throw e;
- });
- }
-
static createEditorModel({
blobPath,
blobContent,
@@ -115,71 +95,17 @@ export default class SourceEditor {
return diffModel;
}
- static convertMonacoToELInstance = (inst) => {
- const sourceEditorInstanceAPI = {
- updateModelLanguage: (path) => {
- return SourceEditor.instanceUpdateLanguage(inst, path);
- },
- use: (exts = []) => {
- return SourceEditor.instanceApplyExtension(inst, exts);
- },
- };
- const handler = {
- get(target, prop, receiver) {
- if (Reflect.has(sourceEditorInstanceAPI, prop)) {
- return sourceEditorInstanceAPI[prop];
- }
- return Reflect.get(target, prop, receiver);
- },
- };
- return new Proxy(inst, handler);
- };
-
- static instanceUpdateLanguage(inst, path) {
- const lang = getBlobLanguage(path);
- const model = inst.getModel();
- return monacoEditor.setModelLanguage(model, lang);
- }
-
- static instanceApplyExtension(inst, exts = []) {
- const extensions = [].concat(exts);
- extensions.forEach((extension) => {
- SourceEditor.mixIntoInstance(extension, inst);
- });
- return inst;
- }
-
- static instanceRemoveFromRegistry(editor, instance) {
- const index = editor.instances.findIndex((inst) => inst === instance);
- editor.instances.splice(index, 1);
- }
-
- static instanceDisposeModels(editor, instance, model) {
- const instanceModel = instance.getModel() || model;
- if (!instanceModel) {
- return;
- }
- if (instance.getEditorType() === EDITOR_TYPE_DIFF) {
- const { original, modified } = instanceModel;
- if (original) {
- original.dispose();
- }
- if (modified) {
- modified.dispose();
- }
- } else {
- instanceModel.dispose();
- }
- }
-
/**
- * Creates a monaco instance with the given options.
- *
- * @param {Object} options Options used to initialize monaco.
- * @param {Element} options.el The element which will be used to create the monacoEditor.
+ * Creates a Source Editor Instance with the given options.
+ * @param {Object} options Options used to initialize the instance.
+ * @param {Element} options.el The element to attach the instance for.
* @param {string} options.blobPath The path used as the URI of the model. Monaco uses the extension of this path to determine the language.
* @param {string} options.blobContent The content to initialize the monacoEditor.
+ * @param {string} options.blobOriginalContent The original blob's content. Is used when creating a Diff Instance.
* @param {string} options.blobGlobalId This is used to help globally identify monaco instances that are created with the same blobPath.
+ * @param {Boolean} options.isDiff Flag to enable creation of a Diff Instance?
+ * @param {...*} options.instanceOptions Configuration options used to instantiate an instance.
+ * @returns {EditorInstance}
*/
createInstance({
el = undefined,
@@ -187,20 +113,24 @@ export default class SourceEditor {
blobContent = '',
blobOriginalContent = '',
blobGlobalId = uuids()[0],
- extensions = [],
isDiff = false,
...instanceOptions
} = {}) {
SourceEditor.prepareInstance(el);
const createEditorFn = isDiff ? 'createDiffEditor' : 'create';
- const instance = SourceEditor.convertMonacoToELInstance(
+ const instance = new EditorInstance(
monacoEditor[createEditorFn].call(this, el, {
...this.options,
...instanceOptions,
}),
+ this.extensionsStore,
);
+ waitForCSSLoaded(() => {
+ instance.layout();
+ });
+
let model;
if (instanceOptions.model !== null) {
model = SourceEditor.createEditorModel({
@@ -214,16 +144,20 @@ export default class SourceEditor {
}
instance.onDidDispose(() => {
- SourceEditor.instanceRemoveFromRegistry(this, instance);
- SourceEditor.instanceDisposeModels(this, instance, model);
+ instanceRemoveFromRegistry(this, instance);
+ instanceDisposeModels(this, instance, model);
});
- SourceEditor.manageDefaultExtensions(instance, el, extensions);
-
this.instances.push(instance);
+ el.dispatchEvent(new CustomEvent(EDITOR_READY_EVENT, { instance }));
return instance;
}
+ /**
+ * Create a Diff Instance
+ * @param {Object} args Options to be passed further down to createInstance() with the same signature
+ * @returns {EditorInstance}
+ */
createDiffInstance(args) {
return this.createInstance({
...args,
@@ -231,14 +165,11 @@ export default class SourceEditor {
});
}
+ /**
+ * Dispose global editor
+ * Automatically disposes all the instances registered for this editor
+ */
dispose() {
this.instances.forEach((instance) => instance.dispose());
}
-
- use(exts) {
- this.instances.forEach((inst) => {
- inst.use(exts);
- });
- return this;
- }
}
diff --git a/app/assets/javascripts/editor/source_editor_extension.js b/app/assets/javascripts/editor/source_editor_extension.js
index f6bc62a1c09..6d47e1e2248 100644
--- a/app/assets/javascripts/editor/source_editor_extension.js
+++ b/app/assets/javascripts/editor/source_editor_extension.js
@@ -5,10 +5,10 @@ export default class EditorExtension {
if (typeof definition !== 'function') {
throw new Error(EDITOR_EXTENSION_DEFINITION_ERROR);
}
- this.name = definition.name; // both class- and fn-based extensions have a name
this.setupOptions = setupOptions;
// eslint-disable-next-line new-cap
this.obj = new definition();
+ this.extensionName = definition.extensionName || this.obj.extensionName; // both class- and fn-based extensions have a name
}
get api() {
diff --git a/app/assets/javascripts/editor/source_editor_instance.js b/app/assets/javascripts/editor/source_editor_instance.js
index e0ca4ea518b..8372a59964b 100644
--- a/app/assets/javascripts/editor/source_editor_instance.js
+++ b/app/assets/javascripts/editor/source_editor_instance.js
@@ -13,7 +13,7 @@
* A Source Editor Extension
* @typedef {Object} SourceEditorExtension
* @property {Object} obj
- * @property {string} name
+ * @property {string} extensionName
* @property {Object} api
*/
@@ -43,12 +43,12 @@ const utils = {
}
},
- getStoredExtension: (extensionsStore, name) => {
+ getStoredExtension: (extensionsStore, extensionName) => {
if (!extensionsStore) {
logError(EDITOR_EXTENSION_STORE_IS_MISSING_ERROR);
return undefined;
}
- return extensionsStore.get(name);
+ return extensionsStore.get(extensionName);
},
};
@@ -73,32 +73,18 @@ export default class EditorInstance {
if (methodExtension) {
const extension = extensionsStore.get(methodExtension);
- return (...args) => {
- return extension.api[prop].call(seInstance, ...args, receiver);
- };
+ if (typeof extension.api[prop] === 'function') {
+ return extension.api[prop].bind(extension.obj, receiver);
+ }
+
+ return extension.api[prop];
}
return Reflect.get(seInstance[prop] ? seInstance : target, prop, receiver);
},
- set(target, prop, value) {
- Object.assign(seInstance, {
- [prop]: value,
- });
- return true;
- },
};
const instProxy = new Proxy(rootInstance, getHandler);
- /**
- * Main entry point to apply an extension to the instance
- * @param {SourceEditorExtensionDefinition}
- */
- this.use = EditorInstance.useUnuse.bind(instProxy, extensionsStore, this.useExtension);
-
- /**
- * Main entry point to un-use an extension and remove it from the instance
- * @param {SourceEditorExtension}
- */
- this.unuse = EditorInstance.useUnuse.bind(instProxy, extensionsStore, this.unuseExtension);
+ this.dispatchExtAction = EditorInstance.useUnuse.bind(instProxy, extensionsStore);
return instProxy;
}
@@ -143,7 +129,7 @@ export default class EditorInstance {
}
// Existing Extension Path
- const existingExt = utils.getStoredExtension(extensionsStore, definition.name);
+ const existingExt = utils.getStoredExtension(extensionsStore, definition.extensionName);
if (existingExt) {
if (isEqual(extension.setupOptions, existingExt.setupOptions)) {
return existingExt;
@@ -155,7 +141,7 @@ export default class EditorInstance {
const extensionInstance = new EditorExtension(extension);
const { setupOptions, obj: extensionObj } = extensionInstance;
if (extensionObj.onSetup) {
- extensionObj.onSetup(setupOptions, this);
+ extensionObj.onSetup(this, setupOptions);
}
if (extensionsStore) {
this.registerExtension(extensionInstance, extensionsStore);
@@ -170,14 +156,14 @@ export default class EditorInstance {
* @param {Map} extensionsStore - The global registry for the extension instances
*/
registerExtension(extension, extensionsStore) {
- const { name } = extension;
+ const { extensionName } = extension;
const hasExtensionRegistered =
- extensionsStore.has(name) &&
- isEqual(extension.setupOptions, extensionsStore.get(name).setupOptions);
+ extensionsStore.has(extensionName) &&
+ isEqual(extension.setupOptions, extensionsStore.get(extensionName).setupOptions);
if (hasExtensionRegistered) {
return;
}
- extensionsStore.set(name, extension);
+ extensionsStore.set(extensionName, extension);
const { obj: extensionObj } = extension;
if (extensionObj.onUse) {
extensionObj.onUse(this);
@@ -189,7 +175,7 @@ export default class EditorInstance {
* @param {SourceEditorExtension} extension - Instance of Source Editor extension
*/
registerExtensionMethods(extension) {
- const { api, name } = extension;
+ const { api, extensionName } = extension;
if (!api) {
return;
@@ -199,7 +185,7 @@ export default class EditorInstance {
if (this[prop]) {
logError(sprintf(EDITOR_EXTENSION_NAMING_CONFLICT_ERROR, { prop }));
} else {
- this.methods[prop] = name;
+ this.methods[prop] = extensionName;
}
}, this);
}
@@ -217,10 +203,10 @@ export default class EditorInstance {
if (!extension) {
throw new Error(EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR);
}
- const { name } = extension;
- const existingExt = utils.getStoredExtension(extensionsStore, name);
+ const { extensionName } = extension;
+ const existingExt = utils.getStoredExtension(extensionsStore, extensionName);
if (!existingExt) {
- throw new Error(sprintf(EDITOR_EXTENSION_NOT_REGISTERED_ERROR, { name }));
+ throw new Error(sprintf(EDITOR_EXTENSION_NOT_REGISTERED_ERROR, { extensionName }));
}
const { obj: extensionObj } = existingExt;
if (extensionObj.onBeforeUnuse) {
@@ -237,12 +223,12 @@ export default class EditorInstance {
* @param {SourceEditorExtension} extension - Instance of Source Editor extension to un-use
*/
unregisterExtensionMethods(extension) {
- const { api, name } = extension;
+ const { api, extensionName } = extension;
if (!api) {
return;
}
Object.keys(api).forEach((method) => {
- utils.removeExtFromMethod(method, name, this.methods);
+ utils.removeExtFromMethod(method, extensionName, this.methods);
});
}
@@ -262,6 +248,24 @@ export default class EditorInstance {
}
/**
+ * Main entry point to apply an extension to the instance
+ * @param {SourceEditorExtensionDefinition[]|SourceEditorExtensionDefinition} extDefs - The extension(s) to use
+ * @returns {EditorExtension|*}
+ */
+ use(extDefs) {
+ return this.dispatchExtAction(this.useExtension, extDefs);
+ }
+
+ /**
+ * Main entry point to remove an extension to the instance
+ * @param {SourceEditorExtension[]|SourceEditorExtension} exts -
+ * @returns {*}
+ */
+ unuse(exts) {
+ return this.dispatchExtAction(this.unuseExtension, exts);
+ }
+
+ /**
* Get the methods returned by extensions.
* @returns {Array}
*/
diff --git a/app/assets/javascripts/emoji/constants.js b/app/assets/javascripts/emoji/constants.js
index e9f2272e759..a6eb4256561 100644
--- a/app/assets/javascripts/emoji/constants.js
+++ b/app/assets/javascripts/emoji/constants.js
@@ -16,3 +16,6 @@ export const CATEGORY_ICON_MAP = {
export const EMOJIS_PER_ROW = 9;
export const EMOJI_ROW_HEIGHT = 34;
export const CATEGORY_ROW_HEIGHT = 37;
+
+export const CACHE_VERSION_KEY = 'gl-emoji-map-version';
+export const CACHE_KEY = 'gl-emoji-map';
diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js
index 478e3f6aed9..b507792cc91 100644
--- a/app/assets/javascripts/emoji/index.js
+++ b/app/assets/javascripts/emoji/index.js
@@ -1,26 +1,31 @@
import { escape, minBy } from 'lodash';
+import emojiRegexFactory from 'emoji-regex';
import emojiAliases from 'emojis/aliases.json';
-import { sanitize } from '~/lib/dompurify';
import AccessorUtilities from '../lib/utils/accessor';
import axios from '../lib/utils/axios_utils';
-import { CATEGORY_ICON_MAP, FREQUENTLY_USED_KEY } from './constants';
+import { CACHE_KEY, CACHE_VERSION_KEY, CATEGORY_ICON_MAP, FREQUENTLY_USED_KEY } from './constants';
let emojiMap = null;
let validEmojiNames = null;
export const FALLBACK_EMOJI_KEY = 'grey_question';
// Keep the version in sync with `lib/gitlab/emoji.rb`
-export const EMOJI_VERSION = '1';
+export const EMOJI_VERSION = '2';
const isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage();
async function loadEmoji() {
if (
isLocalStorageAvailable &&
- window.localStorage.getItem('gl-emoji-map-version') === EMOJI_VERSION &&
- window.localStorage.getItem('gl-emoji-map')
+ window.localStorage.getItem(CACHE_VERSION_KEY) === EMOJI_VERSION &&
+ window.localStorage.getItem(CACHE_KEY)
) {
- return JSON.parse(window.localStorage.getItem('gl-emoji-map'));
+ const emojis = JSON.parse(window.localStorage.getItem(CACHE_KEY));
+ // Workaround because the pride flag is broken in EMOJI_VERSION = '1'
+ if (emojis.gay_pride_flag) {
+ emojis.gay_pride_flag.e = '🏳️‍🌈';
+ }
+ return emojis;
}
// We load the JSON file direct from the server
@@ -29,15 +34,19 @@ async function loadEmoji() {
const { data } = await axios.get(
`${gon.relative_url_root || ''}/-/emojis/${EMOJI_VERSION}/emojis.json`,
);
- window.localStorage.setItem('gl-emoji-map-version', EMOJI_VERSION);
- window.localStorage.setItem('gl-emoji-map', JSON.stringify(data));
+ window.localStorage.setItem(CACHE_VERSION_KEY, EMOJI_VERSION);
+ window.localStorage.setItem(CACHE_KEY, JSON.stringify(data));
return data;
}
async function loadEmojiWithNames() {
- return Object.entries(await loadEmoji()).reduce((acc, [key, value]) => {
- acc[key] = { ...value, name: key, e: sanitize(value.e) };
+ const emojiRegex = emojiRegexFactory();
+ return Object.entries(await loadEmoji()).reduce((acc, [key, value]) => {
+ // Filter out entries which aren't emojis
+ if (value.e.match(emojiRegex)?.[0] === value.e) {
+ acc[key] = { ...value, name: key };
+ }
return acc;
}, {});
}
diff --git a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
index 4783b92942c..0e556f093e2 100644
--- a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
+++ b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
@@ -7,6 +7,7 @@ import { escape } from 'lodash';
import csrf from '~/lib/utils/csrf';
import { __, s__, sprintf } from '~/locale';
+import rollbackEnvironment from '../graphql/mutations/rollback_environment.mutation.graphql';
import eventHub from '../event_hub';
export default {
@@ -40,10 +41,15 @@ export default {
required: false,
default: null,
},
+ graphql: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
modalTitle() {
- const title = this.environment.isLastDeployment
+ const title = this.isLastDeployment
? s__('Environments|Re-deploy environment %{name}?')
: s__('Environments|Rollback environment %{name}?');
@@ -53,6 +59,11 @@ export default {
},
commitShortSha() {
if (this.hasMultipleCommits) {
+ if (this.graphql) {
+ const { lastDeployment } = this.environment;
+ return this.commitData(lastDeployment, 'shortId');
+ }
+
const { last_deployment } = this.environment;
return this.commitData(last_deployment, 'short_id');
}
@@ -61,6 +72,11 @@ export default {
},
commitUrl() {
if (this.hasMultipleCommits) {
+ if (this.graphql) {
+ const { lastDeployment } = this.environment;
+ return this.commitData(lastDeployment, 'commitPath');
+ }
+
const { last_deployment } = this.environment;
return this.commitData(last_deployment, 'commit_path');
}
@@ -68,9 +84,7 @@ export default {
return this.environment.commitUrl;
},
modalActionText() {
- return this.environment.isLastDeployment
- ? s__('Environments|Re-deploy')
- : s__('Environments|Rollback');
+ return this.isLastDeployment ? s__('Environments|Re-deploy') : s__('Environments|Rollback');
},
primaryProps() {
let attributes = [{ variant: 'danger' }];
@@ -84,20 +98,27 @@ export default {
attributes,
};
},
+ isLastDeployment() {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return this.environment?.isLastDeployment || this.environment?.lastDeployment?.['last?'];
+ },
},
methods: {
handleChange(event) {
this.$emit('change', event);
},
onOk() {
- eventHub.$emit('rollbackEnvironment', this.environment);
+ if (this.graphql) {
+ this.$apollo.mutate({
+ mutation: rollbackEnvironment,
+ variables: { environment: this.environment },
+ });
+ } else {
+ eventHub.$emit('rollbackEnvironment', this.environment);
+ }
},
commitData(lastDeployment, key) {
- if (lastDeployment && lastDeployment.commit) {
- return lastDeployment.commit[key];
- }
-
- return '';
+ return lastDeployment?.commit?.[key] ?? '';
},
},
csrf,
diff --git a/app/assets/javascripts/environments/components/delete_environment_modal.vue b/app/assets/javascripts/environments/components/delete_environment_modal.vue
index 26ec882472b..d3d4c7d23d8 100644
--- a/app/assets/javascripts/environments/components/delete_environment_modal.vue
+++ b/app/assets/javascripts/environments/components/delete_environment_modal.vue
@@ -1,7 +1,9 @@
<script>
import { GlTooltipDirective, GlModal } from '@gitlab/ui';
+import createFlash from '~/flash';
import { __, s__, sprintf } from '~/locale';
import eventHub from '../event_hub';
+import deleteEnvironmentMutation from '../graphql/mutations/delete_environment.mutation.graphql';
export default {
id: 'delete-environment-modal',
@@ -17,6 +19,11 @@ export default {
type: Object,
required: true,
},
+ graphql: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
primaryProps() {
@@ -49,7 +56,29 @@ export default {
},
methods: {
onSubmit() {
- eventHub.$emit('deleteEnvironment', this.environment);
+ if (this.graphql) {
+ this.$apollo
+ .mutate({
+ mutation: deleteEnvironmentMutation,
+ variables: { environment: this.environment },
+ })
+ .then(([message]) => {
+ if (message) {
+ createFlash({ message });
+ }
+ })
+ .catch((error) =>
+ createFlash({
+ message: s__(
+ 'Environments|An error occurred while deleting the environment. Check if the environment stopped; if not, stop it and try again.',
+ ),
+ error,
+ captureError: true,
+ }),
+ );
+ } else {
+ eventHub.$emit('deleteEnvironment', this.environment);
+ }
},
},
};
diff --git a/app/assets/javascripts/environments/components/enable_review_app_modal.vue b/app/assets/javascripts/environments/components/enable_review_app_modal.vue
index d770a2302e8..b757c55bfdb 100644
--- a/app/assets/javascripts/environments/components/enable_review_app_modal.vue
+++ b/app/assets/javascripts/environments/components/enable_review_app_modal.vue
@@ -12,11 +12,20 @@ export default {
ModalCopyButton,
},
inject: ['defaultBranchName'],
+ model: {
+ prop: 'visible',
+ event: 'change',
+ },
props: {
modalId: {
type: String,
required: true,
},
+ visible: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
instructionText: {
step1: s__(
@@ -57,12 +66,15 @@ export default {
</script>
<template>
<gl-modal
+ :visible="visible"
:modal-id="modalId"
:title="$options.modalInfo.title"
+ static
size="lg"
ok-only
ok-variant="light"
:ok-title="$options.modalInfo.closeText"
+ @change="$emit('change', $event)"
>
<p>
<gl-sprintf :message="$options.instructionText.step1">
diff --git a/app/assets/javascripts/environments/components/environment_delete.vue b/app/assets/javascripts/environments/components/environment_delete.vue
index 8609503e486..63169b790c7 100644
--- a/app/assets/javascripts/environments/components/environment_delete.vue
+++ b/app/assets/javascripts/environments/components/environment_delete.vue
@@ -7,6 +7,7 @@
import { GlDropdownItem, GlModalDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
+import setEnvironmentToDelete from '../graphql/mutations/set_environment_to_delete.mutation.graphql';
export default {
components: {
@@ -20,6 +21,11 @@ export default {
type: Object,
required: true,
},
+ graphql: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -30,14 +36,25 @@ export default {
title: s__('Environments|Delete environment'),
},
mounted() {
- eventHub.$on('deleteEnvironment', this.onDeleteEnvironment);
+ if (!this.graphql) {
+ eventHub.$on('deleteEnvironment', this.onDeleteEnvironment);
+ }
},
beforeDestroy() {
- eventHub.$off('deleteEnvironment', this.onDeleteEnvironment);
+ if (!this.graphql) {
+ eventHub.$off('deleteEnvironment', this.onDeleteEnvironment);
+ }
},
methods: {
onClick() {
- eventHub.$emit('requestDeleteEnvironment', this.environment);
+ if (this.graphql) {
+ this.$apollo.mutate({
+ mutation: setEnvironmentToDelete,
+ variables: { environment: this.environment },
+ });
+ } else {
+ eventHub.$emit('requestDeleteEnvironment', this.environment);
+ }
},
onDeleteEnvironment(environment) {
if (this.environment.id === environment.id) {
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index db01d455b2b..be9bfb50de5 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -5,7 +5,7 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { __, s__, sprintf } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import CommitComponent from '~/vue_shared/components/commit.vue';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import eventHub from '../event_hub';
diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue
index 00497b3c683..f7f0cf4cb8d 100644
--- a/app/assets/javascripts/environments/components/environment_rollback.vue
+++ b/app/assets/javascripts/environments/components/environment_rollback.vue
@@ -8,6 +8,7 @@
import { GlModalDirective, GlDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
+import setEnvironmentToRollback from '../graphql/mutations/set_environment_to_rollback.mutation.graphql';
export default {
components: {
@@ -32,11 +33,12 @@ export default {
type: String,
required: true,
},
- },
- data() {
- return {
- isLoading: false,
- };
+
+ graphql: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
@@ -49,16 +51,18 @@ export default {
methods: {
onClick() {
- eventHub.$emit('requestRollbackEnvironment', {
- ...this.environment,
- retryUrl: this.retryUrl,
- isLastDeployment: this.isLastDeployment,
- });
- eventHub.$on('rollbackEnvironment', (environment) => {
- if (environment.id === this.environment.id) {
- this.isLoading = true;
- }
- });
+ if (this.graphql) {
+ this.$apollo.mutate({
+ mutation: setEnvironmentToRollback,
+ variables: { environment: this.environment },
+ });
+ } else {
+ eventHub.$emit('requestRollbackEnvironment', {
+ ...this.environment,
+ retryUrl: this.retryUrl,
+ isLastDeployment: this.isLastDeployment,
+ });
+ }
},
},
};
diff --git a/app/assets/javascripts/environments/components/new_environment_folder.vue b/app/assets/javascripts/environments/components/new_environment_folder.vue
index 0615bdef537..fe3d6f1e8ca 100644
--- a/app/assets/javascripts/environments/components/new_environment_folder.vue
+++ b/app/assets/javascripts/environments/components/new_environment_folder.vue
@@ -1,9 +1,11 @@
<script>
-import { GlCollapse, GlIcon, GlBadge, GlLink } from '@gitlab/ui';
+import { GlButton, GlCollapse, GlIcon, GlBadge, GlLink } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
import folderQuery from '../graphql/queries/folder.query.graphql';
export default {
components: {
+ GlButton,
GlCollapse,
GlIcon,
GlBadge,
@@ -26,12 +28,20 @@ export default {
},
},
},
+ i18n: {
+ collapse: __('Collapse'),
+ expand: __('Expand'),
+ link: s__('Environments|Show all'),
+ },
computed: {
icons() {
return this.visible
? { caret: 'angle-down', folder: 'folder-open' }
: { caret: 'angle-right', folder: 'folder-o' };
},
+ label() {
+ return this.visible ? this.$options.i18n.collapse : this.$options.i18n.expand;
+ },
count() {
return this.folder?.availableCount ?? 0;
},
@@ -51,18 +61,21 @@ export default {
</script>
<template>
<div class="gl-border-b-solid gl-border-gray-100 gl-border-1 gl-px-3 gl-pt-3 gl-pb-5">
- <div class="gl-w-full gl-display-flex gl-align-items-center" @click="toggleCollapse">
- <gl-icon
- class="gl-mr-2 gl-fill-current-color gl-text-gray-500"
- :name="icons.caret"
- :size="12"
+ <div class="gl-w-full gl-display-flex gl-align-items-center">
+ <gl-button
+ class="gl-mr-4 gl-fill-current-color gl-text-gray-500"
+ :aria-label="label"
+ :icon="icons.caret"
+ size="small"
+ category="tertiary"
+ @click="toggleCollapse"
/>
<gl-icon class="gl-mr-2 gl-fill-current-color gl-text-gray-500" :name="icons.folder" />
<div class="gl-mr-2 gl-text-gray-500" :class="folderClass">
{{ nestedEnvironment.name }}
</div>
<gl-badge size="sm" class="gl-mr-auto">{{ count }}</gl-badge>
- <gl-link v-if="visible" :href="folderPath">{{ s__('Environments|Show all') }}</gl-link>
+ <gl-link v-if="visible" :href="folderPath">{{ $options.i18n.link }}</gl-link>
</div>
<gl-collapse :visible="visible" />
</div>
diff --git a/app/assets/javascripts/environments/components/new_environments_app.vue b/app/assets/javascripts/environments/components/new_environments_app.vue
index a5526f9cd71..8d94e7021ca 100644
--- a/app/assets/javascripts/environments/components/new_environments_app.vue
+++ b/app/assets/javascripts/environments/components/new_environments_app.vue
@@ -1,47 +1,205 @@
<script>
-import { GlBadge, GlTab, GlTabs } from '@gitlab/ui';
-import environmentAppQuery from '../graphql/queries/environmentApp.query.graphql';
+import { GlBadge, GlPagination, GlTab, GlTabs } from '@gitlab/ui';
+import { s__, __, sprintf } from '~/locale';
+import { updateHistory, setUrlParams, queryToObject } from '~/lib/utils/url_utility';
+import environmentAppQuery from '../graphql/queries/environment_app.query.graphql';
+import pollIntervalQuery from '../graphql/queries/poll_interval.query.graphql';
+import pageInfoQuery from '../graphql/queries/page_info.query.graphql';
import EnvironmentFolder from './new_environment_folder.vue';
+import EnableReviewAppModal from './enable_review_app_modal.vue';
export default {
components: {
EnvironmentFolder,
+ EnableReviewAppModal,
GlBadge,
+ GlPagination,
GlTab,
GlTabs,
},
apollo: {
environmentApp: {
query: environmentAppQuery,
+ variables() {
+ return {
+ scope: this.scope,
+ page: this.page ?? 1,
+ };
+ },
+ pollInterval() {
+ return this.interval;
+ },
},
+ interval: {
+ query: pollIntervalQuery,
+ },
+ pageInfo: {
+ query: pageInfoQuery,
+ },
+ },
+ inject: ['newEnvironmentPath', 'canCreateEnvironment'],
+ i18n: {
+ newEnvironmentButtonLabel: s__('Environments|New environment'),
+ reviewAppButtonLabel: s__('Environments|Enable review app'),
+ available: __('Available'),
+ stopped: __('Stopped'),
+ prevPage: __('Go to previous page'),
+ nextPage: __('Go to next page'),
+ next: __('Next'),
+ prev: __('Prev'),
+ goto: (page) => sprintf(__('Go to page %{page}'), { page }),
+ },
+ modalId: 'enable-review-app-info',
+ data() {
+ const { page = '1', scope = 'available' } = queryToObject(window.location.search);
+ return {
+ interval: undefined,
+ isReviewAppModalVisible: false,
+ page: parseInt(page, 10),
+ scope,
+ };
},
computed: {
+ canSetupReviewApp() {
+ return this.environmentApp?.reviewApp?.canSetupReviewApp;
+ },
folders() {
return this.environmentApp?.environments.filter((e) => e.size > 1) ?? [];
},
availableCount() {
return this.environmentApp?.availableCount;
},
+ addEnvironment() {
+ if (!this.canCreateEnvironment) {
+ return null;
+ }
+
+ return {
+ text: this.$options.i18n.newEnvironmentButtonLabel,
+ attributes: {
+ href: this.newEnvironmentPath,
+ category: 'primary',
+ variant: 'confirm',
+ },
+ };
+ },
+ openReviewAppModal() {
+ if (!this.canSetupReviewApp) {
+ return null;
+ }
+
+ return {
+ text: this.$options.i18n.reviewAppButtonLabel,
+ attributes: {
+ category: 'secondary',
+ variant: 'confirm',
+ },
+ };
+ },
+ stoppedCount() {
+ return this.environmentApp?.stoppedCount;
+ },
+ totalItems() {
+ return this.pageInfo?.total;
+ },
+ itemsPerPage() {
+ return this.pageInfo?.perPage;
+ },
+ },
+ mounted() {
+ window.addEventListener('popstate', this.syncPageFromQueryParams);
+ },
+ destroyed() {
+ window.removeEventListener('popstate', this.syncPageFromQueryParams);
+ this.$apollo.queries.environmentApp.stopPolling();
+ },
+ methods: {
+ showReviewAppModal() {
+ this.isReviewAppModalVisible = true;
+ },
+ setScope(scope) {
+ this.scope = scope;
+ this.resetPolling();
+ },
+ movePage(direction) {
+ this.moveToPage(this.pageInfo[`${direction}Page`]);
+ },
+ moveToPage(page) {
+ this.page = page;
+ updateHistory({
+ url: setUrlParams({ page: this.page }),
+ title: document.title,
+ });
+ this.resetPolling();
+ },
+ syncPageFromQueryParams() {
+ const { page = '1' } = queryToObject(window.location.search);
+ this.page = parseInt(page, 10);
+ },
+ resetPolling() {
+ this.$apollo.queries.environmentApp.stopPolling();
+ this.$nextTick(() => {
+ if (this.interval) {
+ this.$apollo.queries.environmentApp.startPolling(this.interval);
+ } else {
+ this.$apollo.queries.environmentApp.refetch({ scope: this.scope, page: this.page });
+ }
+ });
+ },
},
};
</script>
<template>
<div>
- <gl-tabs>
- <gl-tab>
+ <enable-review-app-modal
+ v-if="canSetupReviewApp"
+ v-model="isReviewAppModalVisible"
+ :modal-id="$options.modalId"
+ data-testid="enable-review-app-modal"
+ />
+ <gl-tabs
+ :action-secondary="addEnvironment"
+ :action-primary="openReviewAppModal"
+ sync-active-tab-with-query-params
+ query-param-name="scope"
+ @primary="showReviewAppModal"
+ >
+ <gl-tab query-param-value="available" @click="setScope('available')">
<template #title>
- <span>{{ __('Available') }}</span>
+ <span>{{ $options.i18n.available }}</span>
<gl-badge size="sm" class="gl-tab-counter-badge">
{{ availableCount }}
</gl-badge>
</template>
- <environment-folder
- v-for="folder in folders"
- :key="folder.name"
- class="gl-mb-3"
- :nested-environment="folder"
- />
+ </gl-tab>
+ <gl-tab query-param-value="stopped" @click="setScope('stopped')">
+ <template #title>
+ <span>{{ $options.i18n.stopped }}</span>
+ <gl-badge size="sm" class="gl-tab-counter-badge">
+ {{ stoppedCount }}
+ </gl-badge>
+ </template>
</gl-tab>
</gl-tabs>
+ <environment-folder
+ v-for="folder in folders"
+ :key="folder.name"
+ class="gl-mb-3"
+ :nested-environment="folder"
+ />
+ <gl-pagination
+ align="center"
+ :total-items="totalItems"
+ :per-page="itemsPerPage"
+ :value="page"
+ :next="$options.i18n.next"
+ :prev="$options.i18n.prev"
+ :label-previous-page="$options.prevPage"
+ :label-next-page="$options.nextPage"
+ :label-page="$options.goto"
+ @next="movePage('next')"
+ @previous="movePage('previous')"
+ @input="moveToPage"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js
index c734c2fba0c..64b18c2003b 100644
--- a/app/assets/javascripts/environments/graphql/client.js
+++ b/app/assets/javascripts/environments/graphql/client.js
@@ -1,6 +1,7 @@
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import environmentApp from './queries/environmentApp.query.graphql';
+import environmentApp from './queries/environment_app.query.graphql';
+import pageInfoQuery from './queries/page_info.query.graphql';
import { resolvers } from './resolvers';
import typeDefs from './typedefs.graphql';
@@ -19,6 +20,19 @@ export const apolloProvider = (endpoint) => {
stoppedCount: 0,
},
});
+
+ cache.writeQuery({
+ query: pageInfoQuery,
+ data: {
+ pageInfo: {
+ total: 0,
+ perPage: 20,
+ nextPage: 0,
+ previousPage: 0,
+ __typename: 'LocalPageInfo',
+ },
+ },
+ });
return new VueApollo({
defaultClient,
});
diff --git a/app/assets/javascripts/environments/graphql/mutations/set_environment_to_delete.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/set_environment_to_delete.mutation.graphql
new file mode 100644
index 00000000000..ea72067bd37
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/mutations/set_environment_to_delete.mutation.graphql
@@ -0,0 +1,3 @@
+mutation SetEnvironmentToDelete($environment: Environment) {
+ setEnvironmentToDelete(environment: $environment) @client
+}
diff --git a/app/assets/javascripts/environments/graphql/mutations/set_environment_to_rollback.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/set_environment_to_rollback.mutation.graphql
new file mode 100644
index 00000000000..aba978ed79e
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/mutations/set_environment_to_rollback.mutation.graphql
@@ -0,0 +1,3 @@
+mutation SetEnvironmentToRollback($environment: Environment) {
+ setEnvironmentToRollback(environment: $environment) @client
+}
diff --git a/app/assets/javascripts/environments/graphql/queries/environmentApp.query.graphql b/app/assets/javascripts/environments/graphql/queries/environmentApp.query.graphql
deleted file mode 100644
index faa76c0a42c..00000000000
--- a/app/assets/javascripts/environments/graphql/queries/environmentApp.query.graphql
+++ /dev/null
@@ -1,8 +0,0 @@
-query getEnvironmentApp {
- environmentApp @client {
- availableCount
- environments
- reviewApp
- stoppedCount
- }
-}
diff --git a/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql
new file mode 100644
index 00000000000..2c17c42dd6d
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql
@@ -0,0 +1,9 @@
+query getEnvironmentApp($page: Int, $scope: String) {
+ environmentApp(page: $page, scope: $scope) @client {
+ availableCount
+ stoppedCount
+ environments
+ reviewApp
+ stoppedCount
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/queries/environment_to_delete.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_to_delete.query.graphql
new file mode 100644
index 00000000000..5d39de8a0f1
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/environment_to_delete.query.graphql
@@ -0,0 +1,7 @@
+query environmentToDelete {
+ environmentToDelete @client {
+ id
+ name
+ deletePath
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/queries/environment_to_rollback.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_to_rollback.query.graphql
new file mode 100644
index 00000000000..f7586e27665
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/environment_to_rollback.query.graphql
@@ -0,0 +1,7 @@
+query environmentToRollback {
+ environmentToRollback @client {
+ id
+ name
+ lastDeployment
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/queries/page_info.query.graphql b/app/assets/javascripts/environments/graphql/queries/page_info.query.graphql
new file mode 100644
index 00000000000..d77ca05d46f
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/page_info.query.graphql
@@ -0,0 +1,8 @@
+query getPageInfo {
+ pageInfo @client {
+ total
+ perPage
+ nextPage
+ previousPage
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/queries/poll_interval.query.graphql b/app/assets/javascripts/environments/graphql/queries/poll_interval.query.graphql
new file mode 100644
index 00000000000..28afc30a0dd
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/poll_interval.query.graphql
@@ -0,0 +1,3 @@
+query pollInterval {
+ interval @client
+}
diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js
index 8322b806370..9ebbc0ad1f8 100644
--- a/app/assets/javascripts/environments/graphql/resolvers.js
+++ b/app/assets/javascripts/environments/graphql/resolvers.js
@@ -1,5 +1,20 @@
import axios from '~/lib/utils/axios_utils';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_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 environmentToDeleteQuery from './queries/environment_to_delete.query.graphql';
+import pageInfoQuery from './queries/page_info.query.graphql';
+
+const buildErrors = (errors = []) => ({
+ errors,
+ __typename: 'LocalEnvironmentErrors',
+});
const mapNestedEnvironment = (env) => ({
...convertObjectPropsToCamelCase(env, { deep: true }),
@@ -12,17 +27,34 @@ const mapEnvironment = (env) => ({
export const resolvers = (endpoint) => ({
Query: {
- environmentApp() {
- return axios.get(endpoint, { params: { nested: true } }).then((res) => ({
- availableCount: res.data.available_count,
- environments: res.data.environments.map(mapNestedEnvironment),
- reviewApp: {
- ...convertObjectPropsToCamelCase(res.data.review_app),
- __typename: 'ReviewApp',
- },
- stoppedCount: res.data.stopped_count,
- __typename: 'LocalEnvironmentApp',
- }));
+ environmentApp(_context, { page, scope }, { cache }) {
+ return axios.get(endpoint, { params: { nested: true, page, scope } }).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',
+ },
+ stoppedCount: res.data.stopped_count,
+ __typename: 'LocalEnvironmentApp',
+ };
+ });
},
folder(_, { environment: { folderPath } }) {
return axios.get(folderPath, { params: { per_page: 3 } }).then((res) => ({
@@ -32,19 +64,72 @@ export const resolvers = (endpoint) => ({
__typename: 'LocalEnvironmentFolder',
}));
},
+ isLastDeployment(_, { environment }) {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return environment?.lastDeployment?.['last?'];
+ },
},
- Mutations: {
- stopEnvironment(_, { environment: { stopPath } }) {
- return axios.post(stopPath);
+ Mutation: {
+ stopEnvironment(_, { environment }) {
+ return axios
+ .post(environment.stopPath)
+ .then(() => buildErrors())
+ .catch(() => {
+ return buildErrors([
+ s__('Environments|An error occurred while stopping the environment, please try again'),
+ ]);
+ });
},
deleteEnvironment(_, { environment: { deletePath } }) {
- return axios.delete(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',
+ ),
+ ]);
+ });
+ },
+ setEnvironmentToDelete(_, { environment }, { client }) {
+ client.writeQuery({
+ query: environmentToDeleteQuery,
+ data: { environmentToDelete: environment },
+ });
},
- rollbackEnvironment(_, { environment: { retryUrl } }) {
- return axios.post(retryUrl);
+ setEnvironmentToRollback(_, { environment }, { client }) {
+ client.writeQuery({
+ query: environmentToRollbackQuery,
+ data: { environmentToRollback: environment },
+ });
},
cancelAutoStop(_, { environment: { autoStopPath } }) {
- return axios.post(autoStopPath);
+ return axios
+ .post(autoStopPath)
+ .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/typedefs.graphql b/app/assets/javascripts/environments/graphql/typedefs.graphql
index 49ea719449e..4a3abb0e89f 100644
--- a/app/assets/javascripts/environments/graphql/typedefs.graphql
+++ b/app/assets/javascripts/environments/graphql/typedefs.graphql
@@ -9,12 +9,29 @@ type LocalEnvironment {
autoStopPath: String
}
+input LocalEnvironmentInput {
+ id: Int!
+ globalId: ID!
+ name: String!
+ folderPath: String
+ stopPath: String
+ deletePath: String
+ retryUrl: String
+ autoStopPath: String
+}
+
type NestedLocalEnvironment {
name: String!
size: Int!
latest: LocalEnvironment!
}
+input NestedLocalEnvironmentInput {
+ name: String!
+ size: Int!
+ latest: LocalEnvironmentInput!
+}
+
type LocalEnvironmentFolder {
environments: [LocalEnvironment!]!
availableCount: Int!
@@ -33,3 +50,32 @@ type LocalEnvironmentApp {
environments: [NestedLocalEnvironment!]!
reviewApp: ReviewApp!
}
+
+type LocalErrors {
+ errors: [String!]!
+}
+
+type LocalPageInfo {
+ total: Int!
+ perPage: Int!
+ nextPage: Int!
+ previousPage: Int!
+}
+
+extend type Query {
+ environmentApp(page: Int, scope: String): LocalEnvironmentApp
+ folder(environment: NestedLocalEnvironmentInput): LocalEnvironmentFolder
+ environmentToDelete: LocalEnvironment
+ pageInfo: LocalPageInfo
+ environmentToRollback: LocalEnvironment
+ isLastDeployment: Boolean
+}
+
+extend type Mutation {
+ stopEnvironment(environment: LocalEnvironmentInput): LocalErrors
+ deleteEnvironment(environment: LocalEnvironmentInput): LocalErrors
+ rollbackEnvironment(environment: LocalEnvironmentInput): LocalErrors
+ cancelAutoStop(environment: LocalEnvironmentInput): LocalErrors
+ setEnvironmentToDelete(environment: LocalEnvironmentInput): LocalErrors
+ setEnvironmentToRollback(environment: LocalEnvironmentInput): LocalErrors
+}
diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue
index 4adbf5362b7..e00fec6fddf 100644
--- a/app/assets/javascripts/error_tracking/components/error_details.vue
+++ b/app/assets/javascripts/error_tracking/components/error_details.vue
@@ -17,7 +17,7 @@ import createFlash from '~/flash';
import { __, sprintf, n__ } from '~/locale';
import Tracking from '~/tracking';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
import query from '../queries/details.query.graphql';
import {
diff --git a/app/assets/javascripts/error_tracking/queries/details.query.graphql b/app/assets/javascripts/error_tracking/queries/details.query.graphql
index af386528f00..f70e09d76f7 100644
--- a/app/assets/javascripts/error_tracking/queries/details.query.graphql
+++ b/app/assets/javascripts/error_tracking/queries/details.query.graphql
@@ -1,5 +1,6 @@
query errorDetails($fullPath: ID!, $errorId: ID!) {
project(fullPath: $fullPath) {
+ id
sentryErrors {
detailedError(id: $errorId) {
id
diff --git a/app/assets/javascripts/experimentation/utils.js b/app/assets/javascripts/experimentation/utils.js
index dcb6a8e20a3..69fa7adc653 100644
--- a/app/assets/javascripts/experimentation/utils.js
+++ b/app/assets/javascripts/experimentation/utils.js
@@ -1,5 +1,5 @@
-// This file only applies to use of experiments through https://gitlab.com/gitlab-org/gitlab-experiment
-import { get } from 'lodash';
+// This file only applies to use of experiments through https://gitlab.com/gitlab-org/ruby/gems/gitlab-experiment
+import { get, mapValues, pick } from 'lodash';
import { DEFAULT_VARIANT, CANDIDATE_VARIANT, TRACKING_CONTEXT_SCHEMA } from './constants';
function getExperimentsData() {
@@ -8,19 +8,18 @@ function getExperimentsData() {
// Pull from preferred window.gl.experiments
const experimentsFromGl = get(window, ['gl', 'experiments'], {});
- return { ...experimentsFromGon, ...experimentsFromGl };
-}
-
-function convertExperimentDataToExperimentContext(experimentData) {
- // Bandaid to allow-list only the properties which the current gitlab_experiment context schema suppports.
+ // Bandaid to allow-list only the properties which the current gitlab_experiment
+ // context schema suppports, since we most often use this data to create that
+ // Snowplow context.
// See TRACKING_CONTEXT_SCHEMA for current version (1-0-0)
// https://gitlab.com/gitlab-org/iglu/-/blob/master/public/schemas/com.gitlab/gitlab_experiment/jsonschema/1-0-0
- const { experiment: experimentName, key, variant, migration_keys } = experimentData;
+ return mapValues({ ...experimentsFromGon, ...experimentsFromGl }, (xp) => {
+ return pick(xp, ['experiment', 'key', 'variant', 'migration_keys']);
+ });
+}
- return {
- schema: TRACKING_CONTEXT_SCHEMA,
- data: { experiment: experimentName, key, variant, migration_keys },
- };
+function createGitlabExperimentContext(experimentData) {
+ return { schema: TRACKING_CONTEXT_SCHEMA, data: experimentData };
}
export function getExperimentData(experimentName) {
@@ -28,10 +27,10 @@ export function getExperimentData(experimentName) {
}
export function getAllExperimentContexts() {
- return Object.values(getExperimentsData()).map(convertExperimentDataToExperimentContext);
+ return Object.values(getExperimentsData()).map(createGitlabExperimentContext);
}
-export function isExperimentVariant(experimentName, variantName) {
+export function isExperimentVariant(experimentName, variantName = CANDIDATE_VARIANT) {
return getExperimentData(experimentName)?.variant === variantName;
}
diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue
index 29e82289107..26da0d56f9a 100644
--- a/app/assets/javascripts/feature_flags/components/form.vue
+++ b/app/assets/javascripts/feature_flags/components/form.vue
@@ -142,7 +142,14 @@ export default {
return !this.$options.rolloutPercentageRegex.test(percentage);
}),
onFormStrategyChange(strategy, index) {
+ const currentUserListId = this.filteredStrategies[index]?.userList?.id;
+ const newUserListId = strategy?.userList?.id;
+
Object.assign(this.filteredStrategies[index], strategy);
+
+ if (currentUserListId !== newUserListId) {
+ this.formStrategies = [...this.formStrategies];
+ }
},
},
};
diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
index e0281b8f443..3cd4d48a4a3 100644
--- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
+++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
@@ -1,4 +1,4 @@
-import { sortMilestonesByDueDate } from '~/milestones/milestone_utils';
+import { sortMilestonesByDueDate } from '~/milestones/utils';
import { mergeUrlParams } from '../lib/utils/url_utility';
import DropdownAjaxFilter from './dropdown_ajax_filter';
import DropdownEmoji from './dropdown_emoji';
diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js
index 08736b09407..e2d6936acbd 100644
--- a/app/assets/javascripts/filtered_search/constants.js
+++ b/app/assets/javascripts/filtered_search/constants.js
@@ -11,3 +11,10 @@ export const FILTER_TYPE = {
};
export const MAX_HISTORY_SIZE = 5;
+
+export const FILTERED_SEARCH = {
+ MERGE_REQUESTS: 'merge_requests',
+ ISSUES: 'issues',
+ ADMIN_RUNNERS: 'admin/runners',
+ GROUP_RUNNERS_ANCHOR: 'runners-settings',
+};
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index 1287a7ed746..f0ef55f73eb 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -62,7 +62,7 @@ const createFlashEl = (message, type) => `
</div>
`;
-const removeFlashClickListener = (flashEl, fadeTransition) => {
+const addDismissFlashClickListener = (flashEl, fadeTransition) => {
// There are some flash elements which do not have a closeEl.
// https://gitlab.com/gitlab-org/gitlab/blob/763426ef344488972eb63ea5be8744e0f8459e6b/ee/app/views/layouts/header/_read_only_banner.html.haml
getCloseEl(flashEl)?.addEventListener('click', () => hideFlash(flashEl, fadeTransition));
@@ -113,7 +113,7 @@ const createFlash = function createFlash({
}
}
- removeFlashClickListener(flashEl, fadeTransition);
+ addDismissFlashClickListener(flashEl, fadeTransition);
flashContainer.classList.add('gl-display-block');
@@ -130,10 +130,8 @@ const createFlash = function createFlash({
export {
createFlash as default,
- createFlashEl,
- createAction,
hideFlash,
- removeFlashClickListener,
+ addDismissFlashClickListener,
FLASH_TYPES,
FLASH_CLOSED_EVENT,
};
diff --git a/app/assets/javascripts/google_cloud/components/app.vue b/app/assets/javascripts/google_cloud/components/app.vue
index 1e5be9df019..64784755b66 100644
--- a/app/assets/javascripts/google_cloud/components/app.vue
+++ b/app/assets/javascripts/google_cloud/components/app.vue
@@ -1,22 +1,42 @@
<script>
-import { GlTab, GlTabs } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+import Home from './home.vue';
import IncubationBanner from './incubation_banner.vue';
-import ServiceAccounts from './service_accounts.vue';
+import ServiceAccountsForm from './service_accounts_form.vue';
+import NoGcpProjects from './errors/no_gcp_projects.vue';
+import GcpError from './errors/gcp_error.vue';
+
+const SCREEN_GCP_ERROR = 'gcp_error';
+const SCREEN_HOME = 'home';
+const SCREEN_NO_GCP_PROJECTS = 'no_gcp_projects';
+const SCREEN_SERVICE_ACCOUNTS_FORM = 'service_accounts_form';
export default {
- components: { GlTab, GlTabs, IncubationBanner, ServiceAccounts },
+ components: {
+ IncubationBanner,
+ },
+ inheritAttrs: false,
props: {
- serviceAccounts: {
- type: Array,
+ screen: {
required: true,
- },
- createServiceAccountUrl: {
type: String,
- required: true,
},
- emptyIllustrationUrl: {
- type: String,
- required: true,
+ },
+ computed: {
+ mainComponent() {
+ switch (this.screen) {
+ case SCREEN_HOME:
+ return Home;
+ case SCREEN_GCP_ERROR:
+ return GcpError;
+ case SCREEN_NO_GCP_PROJECTS:
+ return NoGcpProjects;
+ case SCREEN_SERVICE_ACCOUNTS_FORM:
+ return ServiceAccountsForm;
+ default:
+ throw new Error(__('Unknown screen'));
+ }
},
},
methods: {
@@ -34,17 +54,6 @@ export default {
:report-bug-url="feedbackUrl('report_bug')"
:feature-request-url="feedbackUrl('feature_request')"
/>
- <gl-tabs>
- <gl-tab :title="__('Configuration')">
- <service-accounts
- class="gl-mx-3"
- :list="serviceAccounts"
- :create-url="createServiceAccountUrl"
- :empty-illustration-url="emptyIllustrationUrl"
- />
- </gl-tab>
- <gl-tab :title="__('Deployments')" disabled />
- <gl-tab :title="__('Services')" disabled />
- </gl-tabs>
+ <component :is="mainComponent" v-bind="$attrs" />
</div>
</template>
diff --git a/app/assets/javascripts/google_cloud/components/errors/gcp_error.vue b/app/assets/javascripts/google_cloud/components/errors/gcp_error.vue
new file mode 100644
index 00000000000..90aa0e1ae68
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/components/errors/gcp_error.vue
@@ -0,0 +1,29 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: { GlAlert },
+ props: {
+ error: {
+ type: String,
+ required: true,
+ },
+ },
+ i18n: {
+ title: __('Google Cloud project misconfigured'),
+ description: __(
+ 'GitLab and Google Cloud configuration seems to be incomplete. This probably can be fixed by your GitLab administration team. You may share these logs with them:',
+ ),
+ },
+};
+</script>
+
+<template>
+ <gl-alert :dismissible="false" variant="warning" :title="$options.i18n.title">
+ {{ $options.i18n.description }}
+ <blockquote>
+ <code>{{ error }}</code>
+ </blockquote>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/google_cloud/components/errors/no_gcp_projects.vue b/app/assets/javascripts/google_cloud/components/errors/no_gcp_projects.vue
new file mode 100644
index 00000000000..da229ac3f0e
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/components/errors/no_gcp_projects.vue
@@ -0,0 +1,26 @@
+<script>
+import { GlAlert, GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: { GlAlert, GlButton },
+ i18n: {
+ title: __('Google Cloud project required'),
+ description: __(
+ 'You do not have any Google Cloud projects. Please create a Google Cloud project and then reload this page.',
+ ),
+ createLabel: __('Create Google Cloud project'),
+ },
+};
+</script>
+
+<template>
+ <gl-alert :dismissible="false" variant="warning" :title="$options.i18n.title">
+ {{ $options.i18n.description }}
+ <template #actions>
+ <gl-button href="https://console.cloud.google.com/projectcreate" target="_blank">
+ {{ $options.i18n.createLabel }}
+ </gl-button>
+ </template>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/google_cloud/components/home.vue b/app/assets/javascripts/google_cloud/components/home.vue
new file mode 100644
index 00000000000..05f39de66ee
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/components/home.vue
@@ -0,0 +1,41 @@
+<script>
+import { GlTabs, GlTab } from '@gitlab/ui';
+import ServiceAccountsList from './service_accounts_list.vue';
+
+export default {
+ components: {
+ GlTabs,
+ GlTab,
+ ServiceAccountsList,
+ },
+ props: {
+ serviceAccounts: {
+ type: Array,
+ required: true,
+ },
+ createServiceAccountUrl: {
+ type: String,
+ required: true,
+ },
+ emptyIllustrationUrl: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-tabs>
+ <gl-tab :title="__('Configuration')">
+ <service-accounts-list
+ class="gl-mx-4"
+ :list="serviceAccounts"
+ :create-url="createServiceAccountUrl"
+ :empty-illustration-url="emptyIllustrationUrl"
+ />
+ </gl-tab>
+ <gl-tab :title="__('Deployments')" disabled />
+ <gl-tab :title="__('Services')" disabled />
+ </gl-tabs>
+</template>
diff --git a/app/assets/javascripts/google_cloud/components/service_accounts_form.vue b/app/assets/javascripts/google_cloud/components/service_accounts_form.vue
new file mode 100644
index 00000000000..e7a09668473
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/components/service_accounts_form.vue
@@ -0,0 +1,70 @@
+<script>
+import { GlButton, GlFormGroup, GlFormSelect } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: { GlButton, GlFormGroup, GlFormSelect },
+ props: {
+ gcpProjects: { required: true, type: Array },
+ environments: { required: true, type: Array },
+ cancelPath: { required: true, type: String },
+ },
+ i18n: {
+ title: __('Create service account'),
+ gcpProjectLabel: __('Google Cloud project'),
+ gcpProjectDescription: __(
+ 'New service account is generated for the selected Google Cloud project',
+ ),
+ environmentLabel: __('Environment'),
+ environmentDescription: __('Generated service account is linked to the selected environment'),
+ submitLabel: __('Create service account'),
+ cancelLabel: __('Cancel'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <header class="gl-my-5 gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid">
+ <h2 class="gl-font-size-h1">{{ $options.i18n.title }}</h2>
+ </header>
+ <gl-form-group
+ label-for="gcp_project"
+ :label="$options.i18n.gcpProjectLabel"
+ :description="$options.i18n.gcpProjectDescription"
+ >
+ <gl-form-select id="gcp_project" name="gcp_project" required>
+ <option
+ v-for="gcpProject in gcpProjects"
+ :key="gcpProject.project_id"
+ :value="gcpProject.project_id"
+ >
+ {{ gcpProject.name }}
+ </option>
+ </gl-form-select>
+ </gl-form-group>
+ <gl-form-group
+ label-for="environment"
+ :label="$options.i18n.environmentLabel"
+ :description="$options.i18n.environmentDescription"
+ >
+ <gl-form-select id="environment" name="environment" required>
+ <option value="*">{{ __('All') }}</option>
+ <option
+ v-for="environment in environments"
+ :key="environment.name"
+ :value="environment.name"
+ >
+ {{ environment.name }}
+ </option>
+ </gl-form-select>
+ </gl-form-group>
+
+ <div class="form-actions row">
+ <gl-button type="submit" category="primary" variant="confirm">
+ {{ $options.i18n.submitLabel }}
+ </gl-button>
+ <gl-button class="gl-ml-1" :href="cancelPath">{{ $options.i18n.cancelLabel }}</gl-button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/components/service_accounts.vue b/app/assets/javascripts/google_cloud/components/service_accounts_list.vue
index b70b25a5dc3..b70b25a5dc3 100644
--- a/app/assets/javascripts/google_cloud/components/service_accounts.vue
+++ b/app/assets/javascripts/google_cloud/components/service_accounts_list.vue
diff --git a/app/assets/javascripts/google_cloud/index.js b/app/assets/javascripts/google_cloud/index.js
index a156a632e9a..ab9e8227812 100644
--- a/app/assets/javascripts/google_cloud/index.js
+++ b/app/assets/javascripts/google_cloud/index.js
@@ -1,11 +1,12 @@
import Vue from 'vue';
import App from './components/app.vue';
-const elementRenderer = (element, props = {}) => (createElement) =>
- createElement(element, { props });
-
export default () => {
- const root = document.querySelector('#js-google-cloud');
- const props = JSON.parse(root.getAttribute('data'));
- return new Vue({ el: root, render: elementRenderer(App, props) });
+ const root = '#js-google-cloud';
+ const element = document.querySelector(root);
+ const { screen, ...attrs } = JSON.parse(element.getAttribute('data'));
+ return new Vue({
+ el: element,
+ render: (createElement) => createElement(App, { props: { screen }, attrs }),
+ });
};
diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js
index 692de9dcb88..3b36c3e6ac5 100644
--- a/app/assets/javascripts/graphql_shared/constants.js
+++ b/app/assets/javascripts/graphql_shared/constants.js
@@ -1,6 +1,8 @@
export const MINIMUM_SEARCH_LENGTH = 3;
export const TYPE_CI_RUNNER = 'Ci::Runner';
+export const TYPE_CRM_CONTACT = 'CustomerRelations::Contact';
+export const TYPE_DISCUSSION = 'Discussion';
export const TYPE_EPIC = 'Epic';
export const TYPE_GROUP = 'Group';
export const TYPE_ISSUE = 'Issue';
@@ -8,10 +10,10 @@ export const TYPE_ITERATION = 'Iteration';
export const TYPE_ITERATIONS_CADENCE = 'Iterations::Cadence';
export const TYPE_MERGE_REQUEST = 'MergeRequest';
export const TYPE_MILESTONE = 'Milestone';
+export const TYPE_NOTE = 'Note';
+export const TYPE_PACKAGES_PACKAGE = 'Packages::Package';
export const TYPE_PROJECT = 'Project';
export const TYPE_SCANNER_PROFILE = 'DastScannerProfile';
export const TYPE_SITE_PROFILE = 'DastSiteProfile';
export const TYPE_USER = 'User';
export const TYPE_VULNERABILITY = 'Vulnerability';
-export const TYPE_NOTE = 'Note';
-export const TYPE_DISCUSSION = 'Discussion';
diff --git a/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql
index 2c771c32e16..64f547f933a 100644
--- a/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql
+++ b/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql
@@ -6,6 +6,7 @@ fragment AlertListItem on AlertManagementAlert {
startedAt
eventCount
issue {
+ id
iid
state
title
diff --git a/app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql
index 9a9ae369519..794fe0a6151 100644
--- a/app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql
+++ b/app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql
@@ -12,6 +12,7 @@ fragment AlertDetailItem on AlertManagementAlert {
endedAt
hosts
environment {
+ id
name
path
}
diff --git a/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql
index 3551394ff97..78b2cd34a5c 100644
--- a/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql
+++ b/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql
@@ -1,10 +1,12 @@
fragment TimelogFragment on Timelog {
timeSpent
user {
+ id
name
}
spentAt
note {
+ id
body
}
summary
diff --git a/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql
index 0b451262b5a..429993b37bf 100644
--- a/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql
+++ b/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql
@@ -1,3 +1,4 @@
+# eslint-disable-next-line @graphql-eslint/require-id-when-available
fragment UserAvailability on User {
status {
availability
diff --git a/app/assets/javascripts/graphql_shared/mutations/create_merge_request.mutation.graphql b/app/assets/javascripts/graphql_shared/mutations/create_merge_request.mutation.graphql
index 79c56448b3f..2adaf24ed34 100644
--- a/app/assets/javascripts/graphql_shared/mutations/create_merge_request.mutation.graphql
+++ b/app/assets/javascripts/graphql_shared/mutations/create_merge_request.mutation.graphql
@@ -1,6 +1,7 @@
mutation createMergeRequest($input: MergeRequestCreateInput!) {
mergeRequestCreate(input: $input) {
mergeRequest {
+ id
iid
}
errors
diff --git a/app/assets/javascripts/graphql_shared/queries/alert_details.query.graphql b/app/assets/javascripts/graphql_shared/queries/alert_details.query.graphql
index 5ee2cf7ca44..8debc6113d1 100644
--- a/app/assets/javascripts/graphql_shared/queries/alert_details.query.graphql
+++ b/app/assets/javascripts/graphql_shared/queries/alert_details.query.graphql
@@ -2,6 +2,7 @@
query alertDetails($fullPath: ID!, $alertId: String) {
project(fullPath: $fullPath) {
+ id
alertManagementAlerts(iid: $alertId) {
nodes {
...AlertDetailItem
diff --git a/app/assets/javascripts/graphql_shared/queries/get_alerts.query.graphql b/app/assets/javascripts/graphql_shared/queries/get_alerts.query.graphql
index 095e4fe29df..9ffa0bad9ad 100644
--- a/app/assets/javascripts/graphql_shared/queries/get_alerts.query.graphql
+++ b/app/assets/javascripts/graphql_shared/queries/get_alerts.query.graphql
@@ -14,6 +14,7 @@ query getAlerts(
$domain: AlertManagementDomainFilter = operations
) {
project(fullPath: $projectPath) {
+ id
alertManagementAlerts(
search: $searchTerm
assigneeUsername: $assigneeUsername
diff --git a/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql
index c5f99a1657e..7c88e494a2e 100644
--- a/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql
+++ b/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql
@@ -6,6 +6,7 @@ query groupUsersSearch($search: String!, $fullPath: ID!) {
id
users: groupMembers(search: $search, relations: [DIRECT, DESCENDANTS, INHERITED]) {
nodes {
+ id
user {
...User
...UserAvailability
diff --git a/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql
index 62ce27815c7..ef3070d3437 100644
--- a/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql
+++ b/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql
@@ -3,6 +3,7 @@ query searchProjectMembers($fullPath: ID!, $search: String) {
id
projectMembers(search: $search) {
nodes {
+ id
user {
id
name
diff --git a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
index d04a49f8b3a..bb34e4032f4 100644
--- a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
+++ b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
@@ -3,8 +3,10 @@
query projectUsersSearch($search: String!, $fullPath: ID!) {
workspace: project(fullPath: $fullPath) {
+ id
users: projectMembers(search: $search, relations: [DIRECT, INHERITED, INVITED_GROUPS]) {
nodes {
+ id
user {
...User
...UserAvailability
diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue
index c6590fd8eb3..edc6573a489 100644
--- a/app/assets/javascripts/header_search/components/app.vue
+++ b/app/assets/javascripts/header_search/components/app.vue
@@ -1,8 +1,17 @@
<script>
import { GlSearchBoxByType, GlOutsideDirective as Outside } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
+import { debounce } from 'lodash';
import { visitUrl } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import { s__, sprintf } from '~/locale';
+import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
+import {
+ FIRST_DROPDOWN_INDEX,
+ SEARCH_BOX_INDEX,
+ SEARCH_INPUT_DESCRIPTION,
+ SEARCH_RESULTS_DESCRIPTION,
+} from '../constants';
import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue';
import HeaderSearchDefaultItems from './header_search_default_items.vue';
import HeaderSearchScopedItems from './header_search_scoped_items.vue';
@@ -10,7 +19,21 @@ import HeaderSearchScopedItems from './header_search_scoped_items.vue';
export default {
name: 'HeaderSearchApp',
i18n: {
- searchPlaceholder: __('Search or jump to...'),
+ searchPlaceholder: s__('GlobalSearch|Search or jump to...'),
+ searchAria: s__('GlobalSearch|Search GitLab'),
+ searchInputDescribeByNoDropdown: s__(
+ 'GlobalSearch|Type and press the enter key to submit search.',
+ ),
+ searchInputDescribeByWithDropdown: s__(
+ 'GlobalSearch|Type for new suggestions to appear below.',
+ ),
+ searchDescribedByDefault: s__(
+ 'GlobalSearch|%{count} default results provided. Use the up and down arrow keys to navigate search results list.',
+ ),
+ searchDescribedByUpdated: s__(
+ 'GlobalSearch|Results updated. %{count} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.',
+ ),
+ searchResultsLoading: s__('GlobalSearch|Search results are loading'),
},
directives: { Outside },
components: {
@@ -18,15 +41,17 @@ export default {
HeaderSearchDefaultItems,
HeaderSearchScopedItems,
HeaderSearchAutocompleteItems,
+ DropdownKeyboardNavigation,
},
data() {
return {
showDropdown: false,
+ currentFocusIndex: SEARCH_BOX_INDEX,
};
},
computed: {
- ...mapState(['search']),
- ...mapGetters(['searchQuery']),
+ ...mapState(['search', 'loading']),
+ ...mapGetters(['searchQuery', 'searchOptions']),
searchText: {
get() {
return this.search;
@@ -35,15 +60,55 @@ export default {
this.setSearch(value);
},
},
+ currentFocusedOption() {
+ return this.searchOptions[this.currentFocusIndex];
+ },
+ currentFocusedId() {
+ return this.currentFocusedOption?.html_id;
+ },
+ isLoggedIn() {
+ return gon?.current_username;
+ },
showSearchDropdown() {
- return this.showDropdown && gon?.current_username;
+ return this.showDropdown && this.isLoggedIn;
},
showDefaultItems() {
return !this.searchText;
},
+ defaultIndex() {
+ if (this.showDefaultItems) {
+ return SEARCH_BOX_INDEX;
+ }
+
+ return FIRST_DROPDOWN_INDEX;
+ },
+ searchInputDescribeBy() {
+ if (this.isLoggedIn) {
+ return this.$options.i18n.searchInputDescribeByWithDropdown;
+ }
+
+ return this.$options.i18n.searchInputDescribeByNoDropdown;
+ },
+ dropdownResultsDescription() {
+ if (!this.showSearchDropdown) {
+ return ''; // This allows aria-live to see register an update when the dropdown is shown
+ }
+
+ if (this.showDefaultItems) {
+ return sprintf(this.$options.i18n.searchDescribedByDefault, {
+ count: this.searchOptions.length,
+ });
+ }
+
+ return this.loading
+ ? this.$options.i18n.searchResultsLoading
+ : sprintf(this.$options.i18n.searchDescribedByUpdated, {
+ count: this.searchOptions.length,
+ });
+ },
},
methods: {
- ...mapActions(['setSearch', 'fetchAutocompleteOptions']),
+ ...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']),
openDropdown() {
this.showDropdown = true;
},
@@ -51,44 +116,77 @@ export default {
this.showDropdown = false;
},
submitSearch() {
- return visitUrl(this.searchQuery);
+ return visitUrl(this.currentFocusedOption?.url || this.searchQuery);
},
- getAutocompleteOptions(searchTerm) {
+ getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) {
if (!searchTerm) {
- return;
+ this.clearAutocomplete();
+ } else {
+ this.fetchAutocompleteOptions();
}
-
- this.fetchAutocompleteOptions();
- },
+ }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
},
+ SEARCH_BOX_INDEX,
+ SEARCH_INPUT_DESCRIPTION,
+ SEARCH_RESULTS_DESCRIPTION,
};
</script>
<template>
- <section v-outside="closeDropdown" class="header-search gl-relative">
+ <form
+ v-outside="closeDropdown"
+ role="search"
+ :aria-label="$options.i18n.searchAria"
+ class="header-search gl-relative"
+ >
<gl-search-box-by-type
+ id="search"
v-model="searchText"
- :debounce="500"
+ role="searchbox"
+ class="gl-z-index-1"
autocomplete="off"
:placeholder="$options.i18n.searchPlaceholder"
+ :aria-activedescendant="currentFocusedId"
+ :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION"
@focus="openDropdown"
@click="openDropdown"
@input="getAutocompleteOptions"
- @keydown.enter="submitSearch"
- @keydown.esc="closeDropdown"
+ @keydown.enter.stop.prevent="submitSearch"
/>
+ <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">{{
+ searchInputDescribeBy
+ }}</span>
+ <span
+ role="region"
+ :data-testid="$options.SEARCH_RESULTS_DESCRIPTION"
+ class="gl-sr-only"
+ aria-live="polite"
+ aria-atomic="true"
+ >
+ {{ dropdownResultsDescription }}
+ </span>
<div
v-if="showSearchDropdown"
data-testid="header-search-dropdown-menu"
class="header-search-dropdown-menu gl-absolute gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0"
>
<div class="header-search-dropdown-content gl-overflow-y-auto gl-py-2">
- <header-search-default-items v-if="showDefaultItems" />
+ <dropdown-keyboard-navigation
+ v-model="currentFocusIndex"
+ :max="searchOptions.length - 1"
+ :min="$options.SEARCH_BOX_INDEX"
+ :default-index="defaultIndex"
+ @tab="closeDropdown"
+ />
+ <header-search-default-items
+ v-if="showDefaultItems"
+ :current-focused-option="currentFocusedOption"
+ />
<template v-else>
- <header-search-scoped-items />
- <header-search-autocomplete-items />
+ <header-search-scoped-items :current-focused-option="currentFocusedOption" />
+ <header-search-autocomplete-items :current-focused-option="currentFocusedOption" />
</template>
</div>
</div>
- </section>
+ </form>
</template>
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 9bea2b280f7..9f4f4768247 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
@@ -23,10 +23,26 @@ export default {
directives: {
SafeHtml,
},
+ props: {
+ currentFocusedOption: {
+ type: Object,
+ required: false,
+ default: () => null,
+ },
+ },
computed: {
...mapState(['search', 'loading']),
...mapGetters(['autocompleteGroupedSearchOptions']),
},
+ watch: {
+ currentFocusedOption() {
+ const focusedElement = this.$refs[this.currentFocusedOption?.html_id]?.[0]?.$el;
+
+ if (focusedElement) {
+ focusedElement.scrollIntoView(false);
+ }
+ },
+ },
methods: {
highlightedName(val) {
return highlight(val, this.search);
@@ -38,6 +54,9 @@ export default {
return SMALL_AVATAR_PX;
},
+ isOptionFocused(data) {
+ return this.currentFocusedOption?.html_id === data.html_id;
+ },
},
};
</script>
@@ -49,13 +68,17 @@ export default {
<gl-dropdown-divider />
<gl-dropdown-section-header>{{ option.category }}</gl-dropdown-section-header>
<gl-dropdown-item
- v-for="(data, index) in option.data"
- :id="`autocomplete-${option.category}-${index}`"
- :key="index"
+ v-for="data in option.data"
+ :id="data.html_id"
+ :ref="data.html_id"
+ :key="data.html_id"
+ :class="{ 'gl-bg-gray-50': isOptionFocused(data) }"
+ :aria-selected="isOptionFocused(data)"
+ :aria-label="data.label"
tabindex="-1"
:href="data.url"
>
- <div class="gl-display-flex gl-align-items-center">
+ <div class="gl-display-flex gl-align-items-center" aria-hidden="true">
<gl-avatar
v-if="data.avatar_url !== undefined"
:src="data.avatar_url"
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 2871937ed3a..53e63bc6cca 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
@@ -12,6 +12,13 @@ export default {
GlDropdownSectionHeader,
GlDropdownItem,
},
+ props: {
+ currentFocusedOption: {
+ type: Object,
+ required: false,
+ default: () => null,
+ },
+ },
computed: {
...mapState(['searchContext']),
...mapGetters(['defaultSearchOptions']),
@@ -23,6 +30,11 @@ export default {
);
},
},
+ methods: {
+ isOptionFocused(option) {
+ return this.currentFocusedOption?.html_id === option.html_id;
+ },
+ },
};
</script>
@@ -30,13 +42,17 @@ export default {
<div>
<gl-dropdown-section-header>{{ sectionHeader }}</gl-dropdown-section-header>
<gl-dropdown-item
- v-for="(option, index) in defaultSearchOptions"
- :id="`default-${index}`"
- :key="index"
+ v-for="option in defaultSearchOptions"
+ :id="option.html_id"
+ :ref="option.html_id"
+ :key="option.html_id"
+ :class="{ 'gl-bg-gray-50': isOptionFocused(option) }"
+ :aria-selected="isOptionFocused(option)"
+ :aria-label="option.title"
tabindex="-1"
:href="option.url"
>
- {{ option.title }}
+ <span aria-hidden="true">{{ option.title }}</span>
</gl-dropdown-item>
</div>
</template>
diff --git a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue
index 645eba05148..3aebee71509 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,31 +1,57 @@
<script>
import { GlDropdownItem } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
+import { __, sprintf } from '~/locale';
export default {
name: 'HeaderSearchScopedItems',
components: {
GlDropdownItem,
},
+ props: {
+ currentFocusedOption: {
+ type: Object,
+ required: false,
+ default: () => null,
+ },
+ },
computed: {
...mapState(['search']),
...mapGetters(['scopedSearchOptions']),
},
+ methods: {
+ isOptionFocused(option) {
+ return this.currentFocusedOption?.html_id === option.html_id;
+ },
+ ariaLabel(option) {
+ return sprintf(__('%{search} %{description} %{scope}'), {
+ search: this.search,
+ description: option.description,
+ scope: option.scope || '',
+ });
+ },
+ },
};
</script>
<template>
<div>
<gl-dropdown-item
- v-for="(option, index) in scopedSearchOptions"
- :id="`scoped-${index}`"
- :key="index"
+ v-for="option in scopedSearchOptions"
+ :id="option.html_id"
+ :ref="option.html_id"
+ :key="option.html_id"
+ :class="{ 'gl-bg-gray-50': isOptionFocused(option) }"
+ :aria-selected="isOptionFocused(option)"
+ :aria-label="ariaLabel(option)"
tabindex="-1"
:href="option.url"
>
- "<span class="gl-font-weight-bold">{{ search }}</span
- >" {{ option.description }}
- <span v-if="option.scope" class="gl-font-style-italic">{{ option.scope }}</span>
+ <span aria-hidden="true">
+ "<span class="gl-font-weight-bold">{{ search }}</span
+ >" {{ option.description }}
+ <span v-if="option.scope" class="gl-font-style-italic">{{ option.scope }}</span>
+ </span>
</gl-dropdown-item>
</div>
</template>
diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js
index 2fadb1bd1ee..b2e45fcd648 100644
--- a/app/assets/javascripts/header_search/constants.js
+++ b/app/assets/javascripts/header_search/constants.js
@@ -1,20 +1,20 @@
-import { __ } from '~/locale';
+import { s__ } from '~/locale';
-export const MSG_ISSUES_ASSIGNED_TO_ME = __('Issues assigned to me');
+export const MSG_ISSUES_ASSIGNED_TO_ME = s__('GlobalSearch|Issues assigned to me');
-export const MSG_ISSUES_IVE_CREATED = __("Issues I've created");
+export const MSG_ISSUES_IVE_CREATED = s__("GlobalSearch|Issues I've created");
-export const MSG_MR_ASSIGNED_TO_ME = __('Merge requests assigned to me');
+export const MSG_MR_ASSIGNED_TO_ME = s__('GlobalSearch|Merge requests assigned to me');
-export const MSG_MR_IM_REVIEWER = __("Merge requests that I'm a reviewer");
+export const MSG_MR_IM_REVIEWER = s__("GlobalSearch|Merge requests that I'm a reviewer");
-export const MSG_MR_IVE_CREATED = __("Merge requests I've created");
+export const MSG_MR_IVE_CREATED = s__("GlobalSearch|Merge requests I've created");
-export const MSG_IN_ALL_GITLAB = __('in all GitLab');
+export const MSG_IN_ALL_GITLAB = s__('GlobalSearch|in all GitLab');
-export const MSG_IN_GROUP = __('in group');
+export const MSG_IN_GROUP = s__('GlobalSearch|in group');
-export const MSG_IN_PROJECT = __('in project');
+export const MSG_IN_PROJECT = s__('GlobalSearch|in project');
export const GROUPS_CATEGORY = 'Groups';
@@ -23,3 +23,11 @@ export const PROJECTS_CATEGORY = 'Projects';
export const LARGE_AVATAR_PX = 32;
export const SMALL_AVATAR_PX = 16;
+
+export const FIRST_DROPDOWN_INDEX = 0;
+
+export const SEARCH_BOX_INDEX = -1;
+
+export const SEARCH_INPUT_DESCRIPTION = 'search-input-description';
+
+export const SEARCH_RESULTS_DESCRIPTION = 'search-results-description';
diff --git a/app/assets/javascripts/header_search/store/actions.js b/app/assets/javascripts/header_search/store/actions.js
index 2c3b1bd4c0f..0ba956f3ed1 100644
--- a/app/assets/javascripts/header_search/store/actions.js
+++ b/app/assets/javascripts/header_search/store/actions.js
@@ -14,6 +14,10 @@ export const fetchAutocompleteOptions = ({ commit, getters }) => {
});
};
+export const clearAutocomplete = ({ commit }) => {
+ commit(types.CLEAR_AUTOCOMPLETE);
+};
+
export const setSearch = ({ commit }, value) => {
commit(types.SET_SEARCH, value);
};
diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js
index 3f4e231ca55..a1348a8aa3f 100644
--- a/app/assets/javascripts/header_search/store/getters.js
+++ b/app/assets/javascripts/header_search/store/getters.js
@@ -1,3 +1,4 @@
+import { omitBy, isNil } from 'lodash';
import { objectToQuery } from '~/lib/utils/url_utility';
import {
@@ -12,23 +13,29 @@ import {
} from '../constants';
export const searchQuery = (state) => {
- const query = {
- search: state.search,
- nav_source: 'navbar',
- project_id: state.searchContext.project?.id,
- group_id: state.searchContext.group?.id,
- scope: state.searchContext.scope,
- };
+ const query = omitBy(
+ {
+ search: state.search,
+ nav_source: 'navbar',
+ project_id: state.searchContext.project?.id,
+ group_id: state.searchContext.group?.id,
+ scope: state.searchContext?.scope,
+ },
+ isNil,
+ );
return `${state.searchPath}?${objectToQuery(query)}`;
};
export const autocompleteQuery = (state) => {
- const query = {
- term: state.search,
- project_id: state.searchContext.project?.id,
- project_ref: state.searchContext.ref,
- };
+ const query = omitBy(
+ {
+ term: state.search,
+ project_id: state.searchContext.project?.id,
+ project_ref: state.searchContext?.ref,
+ },
+ isNil,
+ );
return `${state.autocompletePath}?${objectToQuery(query)}`;
};
@@ -54,22 +61,27 @@ export const defaultSearchOptions = (state, getters) => {
return [
{
+ html_id: 'default-issues-assigned',
title: MSG_ISSUES_ASSIGNED_TO_ME,
url: `${getters.scopedIssuesPath}/?assignee_username=${userName}`,
},
{
+ html_id: 'default-issues-created',
title: MSG_ISSUES_IVE_CREATED,
url: `${getters.scopedIssuesPath}/?author_username=${userName}`,
},
{
+ html_id: 'default-mrs-assigned',
title: MSG_MR_ASSIGNED_TO_ME,
url: `${getters.scopedMRPath}/?assignee_username=${userName}`,
},
{
+ html_id: 'default-mrs-reviewer',
title: MSG_MR_IM_REVIEWER,
url: `${getters.scopedMRPath}/?reviewer_username=${userName}`,
},
{
+ html_id: 'default-mrs-created',
title: MSG_MR_IVE_CREATED,
url: `${getters.scopedMRPath}/?author_username=${userName}`,
},
@@ -77,42 +89,43 @@ export const defaultSearchOptions = (state, getters) => {
};
export const projectUrl = (state) => {
- if (!state.searchContext.project || !state.searchContext.group) {
- return null;
- }
-
- const query = {
- search: state.search,
- nav_source: 'navbar',
- project_id: state.searchContext.project.id,
- group_id: state.searchContext.group.id,
- scope: state.searchContext.scope,
- };
+ const query = omitBy(
+ {
+ search: state.search,
+ nav_source: 'navbar',
+ project_id: state.searchContext?.project?.id,
+ group_id: state.searchContext?.group?.id,
+ scope: state.searchContext?.scope,
+ },
+ isNil,
+ );
return `${state.searchPath}?${objectToQuery(query)}`;
};
export const groupUrl = (state) => {
- if (!state.searchContext.group) {
- return null;
- }
-
- const query = {
- search: state.search,
- nav_source: 'navbar',
- group_id: state.searchContext.group.id,
- scope: state.searchContext.scope,
- };
+ const query = omitBy(
+ {
+ search: state.search,
+ nav_source: 'navbar',
+ group_id: state.searchContext?.group?.id,
+ scope: state.searchContext?.scope,
+ },
+ isNil,
+ );
return `${state.searchPath}?${objectToQuery(query)}`;
};
export const allUrl = (state) => {
- const query = {
- search: state.search,
- nav_source: 'navbar',
- scope: state.searchContext.scope,
- };
+ const query = omitBy(
+ {
+ search: state.search,
+ nav_source: 'navbar',
+ scope: state.searchContext?.scope,
+ },
+ isNil,
+ );
return `${state.searchPath}?${objectToQuery(query)}`;
};
@@ -122,6 +135,7 @@ export const scopedSearchOptions = (state, getters) => {
if (state.searchContext.project) {
options.push({
+ html_id: 'scoped-in-project',
scope: state.searchContext.project.name,
description: MSG_IN_PROJECT,
url: getters.projectUrl,
@@ -130,6 +144,7 @@ export const scopedSearchOptions = (state, getters) => {
if (state.searchContext.group) {
options.push({
+ html_id: 'scoped-in-group',
scope: state.searchContext.group.name,
description: MSG_IN_GROUP,
url: getters.groupUrl,
@@ -137,6 +152,7 @@ export const scopedSearchOptions = (state, getters) => {
}
options.push({
+ html_id: 'scoped-in-all',
description: MSG_IN_ALL_GITLAB,
url: getters.allUrl,
});
@@ -165,3 +181,18 @@ export const autocompleteGroupedSearchOptions = (state) => {
return results;
};
+
+export const searchOptions = (state, getters) => {
+ if (!state.search) {
+ return getters.defaultSearchOptions;
+ }
+
+ const sortedAutocompleteOptions = Object.values(getters.autocompleteGroupedSearchOptions).reduce(
+ (options, group) => {
+ return [...options, ...group.data];
+ },
+ [],
+ );
+
+ return getters.scopedSearchOptions.concat(sortedAutocompleteOptions);
+};
diff --git a/app/assets/javascripts/header_search/store/mutation_types.js b/app/assets/javascripts/header_search/store/mutation_types.js
index a2358621ce6..6e65345757f 100644
--- a/app/assets/javascripts/header_search/store/mutation_types.js
+++ b/app/assets/javascripts/header_search/store/mutation_types.js
@@ -1,5 +1,6 @@
export const REQUEST_AUTOCOMPLETE = 'REQUEST_AUTOCOMPLETE';
export const RECEIVE_AUTOCOMPLETE_SUCCESS = 'RECEIVE_AUTOCOMPLETE_SUCCESS';
export const RECEIVE_AUTOCOMPLETE_ERROR = 'RECEIVE_AUTOCOMPLETE_ERROR';
+export const CLEAR_AUTOCOMPLETE = 'CLEAR_AUTOCOMPLETE';
export const SET_SEARCH = 'SET_SEARCH';
diff --git a/app/assets/javascripts/header_search/store/mutations.js b/app/assets/javascripts/header_search/store/mutations.js
index 175b5406540..26b4a8854fe 100644
--- a/app/assets/javascripts/header_search/store/mutations.js
+++ b/app/assets/javascripts/header_search/store/mutations.js
@@ -7,12 +7,17 @@ export default {
},
[types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) {
state.loading = false;
- state.autocompleteOptions = data;
+ state.autocompleteOptions = data.map((d, i) => {
+ return { html_id: `autocomplete-${d.category}-${i}`, ...d };
+ });
},
[types.RECEIVE_AUTOCOMPLETE_ERROR](state) {
state.loading = false;
state.autocompleteOptions = [];
},
+ [types.CLEAR_AUTOCOMPLETE](state) {
+ state.autocompleteOptions = [];
+ },
[types.SET_SEARCH](state, value) {
state.search = value;
},
diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue
index c71d911adfb..846b4d92724 100644
--- a/app/assets/javascripts/ide/components/activity_bar.vue
+++ b/app/assets/javascripts/ide/components/activity_bar.vue
@@ -63,7 +63,7 @@ export default {
class="ide-sidebar-link js-ide-review-mode"
@click.prevent="changedActivityView($event, $options.leftSidebarViews.review.name)"
>
- <gl-icon name="file-modified" />
+ <gl-icon name="review-list" />
</button>
</li>
<li>
diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue
index b987adc8bae..0fc7337ad26 100644
--- a/app/assets/javascripts/ide/components/ide_tree_list.vue
+++ b/app/assets/javascripts/ide/components/ide_tree_list.vue
@@ -29,14 +29,20 @@ export default {
},
},
watch: {
- showLoading(newVal) {
- if (!newVal) {
- this.$emit('tree-ready');
- }
+ showLoading() {
+ this.notifyTreeReady();
},
},
+ mounted() {
+ this.notifyTreeReady();
+ },
methods: {
...mapActions(['toggleTreeOpen']),
+ notifyTreeReady() {
+ if (!this.showLoading) {
+ this.$emit('tree-ready');
+ }
+ },
clickedFile() {
performanceMarkAndMeasure({ mark: WEBIDE_MARK_FILE_CLICKED });
},
diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue
index bdd201aac1b..87b60eca73c 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/index.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue
@@ -67,7 +67,7 @@ export default {
data-qa-selector="dropdown_button"
@click.stop="openDropdown()"
>
- <gl-icon name="ellipsis_v" /> <gl-icon name="chevron-down" />
+ <gl-icon name="ellipsis_v" />
</button>
<ul ref="dropdownMenu" class="dropdown-menu dropdown-menu-right">
<template v-if="type === 'tree'">
diff --git a/app/assets/javascripts/ide/components/pipelines/empty_state.vue b/app/assets/javascripts/ide/components/pipelines/empty_state.vue
new file mode 100644
index 00000000000..194deb2ece0
--- /dev/null
+++ b/app/assets/javascripts/ide/components/pipelines/empty_state.vue
@@ -0,0 +1,35 @@
+<script>
+import { GlEmptyState } from '@gitlab/ui';
+import { mapState } from 'vuex';
+import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+export default {
+ components: {
+ GlEmptyState,
+ },
+ computed: {
+ ...mapState(['pipelinesEmptyStateSvgPath']),
+ ciHelpPagePath() {
+ return helpPagePath('ci/quick_start/index.md');
+ },
+ },
+ i18n: {
+ title: s__('Pipelines|Build with confidence'),
+ description: s__(`Pipelines|GitLab CI/CD can automatically build,
+ test, and deploy your code. Let GitLab take care of time
+ consuming tasks, so you can spend more time creating.`),
+ primaryButtonText: s__('Pipelines|Get started with GitLab CI/CD'),
+ },
+};
+</script>
+
+<template>
+ <gl-empty-state
+ :title="$options.i18n.title"
+ :svg-path="pipelinesEmptyStateSvgPath"
+ :description="$options.i18n.description"
+ :primary-button-text="$options.i18n.primaryButtonText"
+ :primary-button-link="ciHelpPagePath"
+ />
+</template>
diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue
index e1caf1ba44a..7f513afe82e 100644
--- a/app/assets/javascripts/ide/components/pipelines/list.vue
+++ b/app/assets/javascripts/ide/components/pipelines/list.vue
@@ -11,10 +11,17 @@ import {
import { escape } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
import IDEServices from '~/ide/services';
-import { sprintf, __ } from '../../../locale';
-import EmptyState from '../../../pipelines/components/pipelines_list/empty_state.vue';
-import CiIcon from '../../../vue_shared/components/ci_icon.vue';
+import { sprintf, __ } from '~/locale';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
import JobsList from '../jobs/list.vue';
+import EmptyState from './empty_state.vue';
+
+const CLASSES_FLEX_VERTICAL_CENTER = [
+ 'gl-h-full',
+ 'gl-display-flex',
+ 'gl-flex-direction-column',
+ 'gl-justify-content-center',
+];
export default {
components: {
@@ -32,7 +39,6 @@ export default {
SafeHtml,
},
computed: {
- ...mapState(['pipelinesEmptyStateSvgPath']),
...mapGetters(['currentProject']),
...mapGetters('pipelines', ['jobsCount', 'failedJobsCount', 'failedStages', 'pipelineFailed']),
...mapState('pipelines', [
@@ -63,12 +69,15 @@ export default {
methods: {
...mapActions('pipelines', ['fetchLatestPipeline']),
},
+ CLASSES_FLEX_VERTICAL_CENTER,
};
</script>
<template>
<div class="ide-pipeline">
- <gl-loading-icon v-if="showLoadingIcon" size="lg" class="gl-mt-3" />
+ <div v-if="showLoadingIcon" :class="$options.CLASSES_FLEX_VERTICAL_CENTER">
+ <gl-loading-icon size="lg" />
+ </div>
<template v-else-if="hasLoadedPipeline">
<header v-if="latestPipeline" class="ide-tree-header ide-pipeline-header">
<ci-icon :status="latestPipeline.details.status" :size="24" class="d-flex" />
@@ -83,12 +92,9 @@ export default {
</a>
</span>
</header>
- <empty-state
- v-if="!latestPipeline"
- :empty-state-svg-path="pipelinesEmptyStateSvgPath"
- :can-set-ci="true"
- class="gl-p-5"
- />
+ <div v-if="!latestPipeline" :class="$options.CLASSES_FLEX_VERTICAL_CENTER">
+ <empty-state />
+ </div>
<gl-alert
v-else-if="latestPipeline.yamlError"
variant="danger"
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index 2bf99550bf2..05493db1dff 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -7,6 +7,7 @@ import {
EDITOR_CODE_INSTANCE_FN,
EDITOR_DIFF_INSTANCE_FN,
} from '~/editor/constants';
+import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext';
import SourceEditor from '~/editor/source_editor';
import createFlash from '~/flash';
@@ -302,30 +303,32 @@ export default {
...instanceOptions,
...this.editorOptions,
});
-
- this.editor.use(
- new EditorWebIdeExtension({
- instance: this.editor,
- modelManager: this.modelManager,
- store: this.$store,
- file: this.file,
- options: this.editorOptions,
- }),
- );
+ this.editor.use([
+ {
+ definition: SourceEditorExtension,
+ },
+ {
+ definition: EditorWebIdeExtension,
+ setupOptions: {
+ modelManager: this.modelManager,
+ store: this.$store,
+ file: this.file,
+ options: this.editorOptions,
+ },
+ },
+ ]);
if (
this.fileType === MARKDOWN_FILE_TYPE &&
this.editor?.getEditorType() === EDITOR_TYPE_CODE &&
this.previewMarkdownPath
) {
- import('~/editor/extensions/source_editor_markdown_ext')
- .then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => {
- this.editor.use(
- new MarkdownExtension({
- instance: this.editor,
- previewMarkdownPath: this.previewMarkdownPath,
- }),
- );
+ import('~/editor/extensions/source_editor_markdown_livepreview_ext')
+ .then(({ EditorMarkdownPreviewExtension: MarkdownLivePreview }) => {
+ this.editor.use({
+ definition: MarkdownLivePreview,
+ setupOptions: { previewMarkdownPath: this.previewMarkdownPath },
+ });
})
.catch((e) =>
createFlash({
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index 706d98fdb90..775b6906498 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -76,15 +76,15 @@ export const stageKeys = {
export const commitItemIconMap = {
addition: {
icon: 'file-addition',
- class: 'ide-file-addition',
+ class: 'file-addition ide-file-addition',
},
modified: {
icon: 'file-modified',
- class: 'ide-file-modified',
+ class: 'file-modified ide-file-modified',
},
deleted: {
icon: 'file-deletion',
- class: 'ide-file-deletion',
+ class: 'file-deletion ide-file-deletion',
},
};
diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js
index 27cedd80347..1fc447886bb 100644
--- a/app/assets/javascripts/ide/ide_router.js
+++ b/app/assets/javascripts/ide/ide_router.js
@@ -1,8 +1,6 @@
import Vue from 'vue';
-import createFlash from '~/flash';
import IdeRouter from '~/ide/ide_router_extension';
import { joinPaths } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
import {
WEBIDE_MARK_FETCH_PROJECT_DATA_START,
WEBIDE_MARK_FETCH_PROJECT_DATA_FINISH,
@@ -75,49 +73,34 @@ export const createRouter = (store, defaultBranch) => {
router.beforeEach((to, from, next) => {
if (to.params.namespace && to.params.project) {
- performanceMarkAndMeasure({ mark: WEBIDE_MARK_FETCH_PROJECT_DATA_START });
- store
- .dispatch('getProjectData', {
- namespace: to.params.namespace,
- projectId: to.params.project,
- })
- .then(() => {
- const basePath = to.params.pathMatch || '';
- const projectId = `${to.params.namespace}/${to.params.project}`;
- const branchId = to.params.branchid;
- const mergeRequestId = to.params.mrid;
+ const basePath = to.params.pathMatch || '';
+ const projectId = `${to.params.namespace}/${to.params.project}`;
+ const branchId = to.params.branchid;
+ const mergeRequestId = to.params.mrid;
- if (branchId) {
- performanceMarkAndMeasure({
- mark: WEBIDE_MARK_FETCH_PROJECT_DATA_FINISH,
- measures: [
- {
- name: WEBIDE_MEASURE_FETCH_PROJECT_DATA,
- start: WEBIDE_MARK_FETCH_PROJECT_DATA_START,
- },
- ],
- });
- store.dispatch('openBranch', {
- projectId,
- branchId,
- basePath,
- });
- } else if (mergeRequestId) {
- store.dispatch('openMergeRequest', {
- projectId,
- mergeRequestId,
- targetProjectId: to.query.target_project,
- });
- }
- })
- .catch((e) => {
- createFlash({
- message: __('Error while loading the project data. Please try again.'),
- fadeTransition: false,
- addBodyClass: true,
- });
- throw e;
+ performanceMarkAndMeasure({ mark: WEBIDE_MARK_FETCH_PROJECT_DATA_START });
+ if (branchId) {
+ performanceMarkAndMeasure({
+ mark: WEBIDE_MARK_FETCH_PROJECT_DATA_FINISH,
+ measures: [
+ {
+ name: WEBIDE_MEASURE_FETCH_PROJECT_DATA,
+ start: WEBIDE_MARK_FETCH_PROJECT_DATA_START,
+ },
+ ],
+ });
+ store.dispatch('openBranch', {
+ projectId,
+ branchId,
+ basePath,
+ });
+ } else if (mergeRequestId) {
+ store.dispatch('openMergeRequest', {
+ projectId,
+ mergeRequestId,
+ targetProjectId: to.query.target_project,
});
+ }
}
next();
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index bdffed70882..df643675357 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -34,11 +34,18 @@ Vue.use(PerformancePlugin, {
* @param {extendStoreCallback} options.extendStore -
* Function that receives the default store and returns an extended one.
*/
-export function initIde(el, options = {}) {
+export const initIde = (el, options = {}) => {
if (!el) return null;
const { rootComponent = ide, extendStore = identity } = options;
+
const store = createStore();
+ const project = JSON.parse(el.dataset.project);
+ store.dispatch('setProject', { project });
+
+ // fire and forget fetching non-critical project info
+ store.dispatch('fetchProjectPermissions');
+
const router = createRouter(store, el.dataset.defaultBranch || DEFAULT_BRANCH);
return new Vue({
@@ -77,7 +84,7 @@ export function initIde(el, options = {}) {
return createElement(rootComponent);
},
});
-}
+};
/**
* Start the IDE.
diff --git a/app/assets/javascripts/ide/lib/themes/monokai.js b/app/assets/javascripts/ide/lib/themes/monokai.js
index d7636574754..36fa5039be7 100644
--- a/app/assets/javascripts/ide/lib/themes/monokai.js
+++ b/app/assets/javascripts/ide/lib/themes/monokai.js
@@ -162,8 +162,8 @@ export default {
'editor.selectionBackground': '#49483E',
'editor.lineHighlightBackground': '#3E3D32',
'editorCursor.foreground': '#F8F8F0',
- 'editorWhitespace.foreground': '#3B3A32',
'editorIndentGuide.activeBackground': '#9D550FB0',
'editor.selectionHighlightBorder': '#222218',
+ 'editorWhitespace.foreground': '#75715e',
},
};
diff --git a/app/assets/javascripts/ide/lib/themes/none.js b/app/assets/javascripts/ide/lib/themes/none.js
index 8e722c4ff88..0842bc04cff 100644
--- a/app/assets/javascripts/ide/lib/themes/none.js
+++ b/app/assets/javascripts/ide/lib/themes/none.js
@@ -13,5 +13,6 @@ export default {
'diffEditor.insertedTextBackground': '#a0f5b420',
'diffEditor.removedTextBackground': '#f9d7dc20',
'editorIndentGuide.activeBackground': '#cccccc',
+ 'editorSuggestWidget.focusHighlightForeground': '#96D8FD',
},
};
diff --git a/app/assets/javascripts/ide/lib/themes/solarized_dark.js b/app/assets/javascripts/ide/lib/themes/solarized_dark.js
index 3c9414b9dc9..8ae609285ac 100644
--- a/app/assets/javascripts/ide/lib/themes/solarized_dark.js
+++ b/app/assets/javascripts/ide/lib/themes/solarized_dark.js
@@ -1105,6 +1105,6 @@ export default {
'editor.selectionBackground': '#073642',
'editor.lineHighlightBackground': '#073642',
'editorCursor.foreground': '#819090',
- 'editorWhitespace.foreground': '#073642',
+ 'editorWhitespace.foreground': '#586e75',
},
};
diff --git a/app/assets/javascripts/ide/lib/themes/solarized_light.js b/app/assets/javascripts/ide/lib/themes/solarized_light.js
index b7bfcf33b0f..2c9f3d904f1 100644
--- a/app/assets/javascripts/ide/lib/themes/solarized_light.js
+++ b/app/assets/javascripts/ide/lib/themes/solarized_light.js
@@ -1096,6 +1096,6 @@ export default {
'editor.selectionBackground': '#EEE8D5',
'editor.lineHighlightBackground': '#EEE8D5',
'editorCursor.foreground': '#000000',
- 'editorWhitespace.foreground': '#EAE3C9',
+ 'editorWhitespace.foreground': '#93a1a1',
},
};
diff --git a/app/assets/javascripts/ide/lib/themes/white.js b/app/assets/javascripts/ide/lib/themes/white.js
index f06458d8a16..69c63c82021 100644
--- a/app/assets/javascripts/ide/lib/themes/white.js
+++ b/app/assets/javascripts/ide/lib/themes/white.js
@@ -142,5 +142,6 @@ export default {
'diffEditor.insertedTextBackground': '#a0f5b420',
'diffEditor.removedTextBackground': '#f9d7dc20',
'editorIndentGuide.activeBackground': '#cccccc',
+ 'editorSuggestWidget.focusHighlightForeground': '#96D8FD',
},
};
diff --git a/app/assets/javascripts/ide/queries/ide_project.fragment.graphql b/app/assets/javascripts/ide/queries/ide_project.fragment.graphql
index c107f2376f9..a0b520858e6 100644
--- a/app/assets/javascripts/ide/queries/ide_project.fragment.graphql
+++ b/app/assets/javascripts/ide/queries/ide_project.fragment.graphql
@@ -1,4 +1,5 @@
fragment IdeProject on Project {
+ id
userPermissions {
createMergeRequestIn
readMergeRequest
diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js
index ef4f47f226a..805476c71bc 100644
--- a/app/assets/javascripts/ide/services/index.js
+++ b/app/assets/javascripts/ide/services/index.js
@@ -1,19 +1,12 @@
-import getIdeProject from 'ee_else_ce/ide/queries/get_ide_project.query.graphql';
import Api from '~/api';
+import getIdeProject from 'ee_else_ce/ide/queries/get_ide_project.query.graphql';
import dismissUserCallout from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import axios from '~/lib/utils/axios_utils';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
-import ciConfig from '~/pipeline_editor/graphql/queries/ci_config.graphql';
+import ciConfig from '~/pipeline_editor/graphql/queries/ci_config.query.graphql';
import { query, mutate } from './gql';
-const fetchApiProjectData = (projectPath) => Api.project(projectPath).then(({ data }) => data);
-
-const fetchGqlProjectData = (projectPath) =>
- query({
- query: getIdeProject,
- variables: { projectPath },
- }).then(({ data }) => data.project);
-
export default {
getFileData(endpoint) {
return axios.get(endpoint, {
@@ -61,18 +54,6 @@ export default {
)
.then(({ data }) => data);
},
- getProjectData(namespace, project) {
- const projectPath = `${namespace}/${project}`;
-
- return Promise.all([fetchApiProjectData(projectPath), fetchGqlProjectData(projectPath)]).then(
- ([apiProjectData, gqlProjectData]) => ({
- data: {
- ...apiProjectData,
- ...gqlProjectData,
- },
- }),
- );
- },
getProjectMergeRequests(projectId, params = {}) {
return Api.projectMergeRequests(projectId, params);
},
@@ -115,4 +96,13 @@ export default {
variables: { input: { featureName: name } },
}).then(({ data }) => data);
},
+ getProjectPermissionsData(projectPath) {
+ return query({
+ query: getIdeProject,
+ variables: { projectPath },
+ }).then(({ data }) => ({
+ ...data.project,
+ id: getIdFromGraphQLId(data.project.id),
+ }));
+ },
};
diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js
index 93ad19ba81e..0ec808339fb 100644
--- a/app/assets/javascripts/ide/stores/actions/project.js
+++ b/app/assets/javascripts/ide/stores/actions/project.js
@@ -1,35 +1,44 @@
import { escape } from 'lodash';
import createFlash from '~/flash';
import { __, sprintf } from '~/locale';
+import { logError } from '~/lib/logger';
import api from '../../../api';
import service from '../../services';
import * as types from '../mutation_types';
-export const getProjectData = ({ commit, state }, { namespace, projectId, force = false } = {}) =>
- new Promise((resolve, reject) => {
- if (!state.projects[`${namespace}/${projectId}`] || force) {
- commit(types.TOGGLE_LOADING, { entry: state });
- service
- .getProjectData(namespace, projectId)
- .then((res) => res.data)
- .then((data) => {
- commit(types.TOGGLE_LOADING, { entry: state });
- commit(types.SET_PROJECT, { projectPath: `${namespace}/${projectId}`, project: data });
- commit(types.SET_CURRENT_PROJECT, `${namespace}/${projectId}`);
- resolve(data);
- })
- .catch(() => {
- createFlash({
- message: __('Error loading project data. Please try again.'),
- fadeTransition: false,
- addBodyClass: true,
- });
- reject(new Error(`Project not loaded ${namespace}/${projectId}`));
- });
- } else {
- resolve(state.projects[`${namespace}/${projectId}`]);
- }
+const ERROR_LOADING_PROJECT = __('Error loading project data. Please try again.');
+
+const errorFetchingData = (e) => {
+ logError(ERROR_LOADING_PROJECT, e);
+
+ createFlash({
+ message: ERROR_LOADING_PROJECT,
+ fadeTransition: false,
+ addBodyClass: true,
});
+};
+
+export const setProject = ({ commit }, { project } = {}) => {
+ if (!project) {
+ return;
+ }
+ const projectPath = project.path_with_namespace;
+ commit(types.SET_PROJECT, { projectPath, project });
+ commit(types.SET_CURRENT_PROJECT, projectPath);
+};
+
+export const fetchProjectPermissions = ({ commit, state }) => {
+ const projectPath = state.currentProjectId;
+ if (!projectPath) {
+ return undefined;
+ }
+ return service
+ .getProjectPermissionsData(projectPath)
+ .then((permissions) => {
+ commit(types.UPDATE_PROJECT, { projectPath, props: permissions });
+ })
+ .catch(errorFetchingData);
+};
export const refreshLastCommitData = ({ commit }, { projectId, branchId } = {}) =>
service
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index 77755b179ef..13f338c4a48 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -8,6 +8,7 @@ export const SET_LINKS = 'SET_LINKS';
// Project Mutation Types
export const SET_PROJECT = 'SET_PROJECT';
export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT';
+export const UPDATE_PROJECT = 'UPDATE_PROJECT';
export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE';
// Merge request mutation types
diff --git a/app/assets/javascripts/ide/stores/mutations/project.js b/app/assets/javascripts/ide/stores/mutations/project.js
index 034fdad4305..9f65d3a543e 100644
--- a/app/assets/javascripts/ide/stores/mutations/project.js
+++ b/app/assets/javascripts/ide/stores/mutations/project.js
@@ -1,3 +1,4 @@
+import Vue from 'vue';
import * as types from '../mutation_types';
export default {
@@ -24,4 +25,15 @@ export default {
empty_repo: value,
});
},
+ [types.UPDATE_PROJECT](state, { projectPath, props }) {
+ const project = state.projects[projectPath];
+
+ if (!project || !props) {
+ return;
+ }
+
+ Object.keys(props).forEach((key) => {
+ Vue.set(project, key, props[key]);
+ });
+ },
};
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 e004bc35087..deaf2654424 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
@@ -44,7 +44,7 @@ export default {
:size="16"
name="information-o"
:title="
- s__('BulkImports|Re-import creates a new group. It does not sync with the existing group.')
+ s__('BulkImport|Re-import creates a new group. It does not sync with the existing group.')
"
class="gl-ml-3"
/>
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
index ec6025c84bb..028197ec9b1 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
@@ -1,9 +1,8 @@
<script>
import {
+ GlAlert,
GlButton,
GlEmptyState,
- GlDropdown,
- GlDropdownItem,
GlIcon,
GlLink,
GlLoadingIcon,
@@ -14,8 +13,8 @@ import {
} from '@gitlab/ui';
import { debounce } from 'lodash';
import createFlash from '~/flash';
-import { s__, __, n__ } from '~/locale';
-import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
+import { s__, __, n__, sprintf } from '~/locale';
+import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
import { getGroupPathAvailability } from '~/rest_api';
import axios from '~/lib/utils/axios_utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
@@ -42,10 +41,9 @@ const DEFAULT_TD_CLASSES = 'gl-vertical-align-top!';
export default {
components: {
+ GlAlert,
GlButton,
GlEmptyState,
- GlDropdown,
- GlDropdownItem,
GlIcon,
GlLink,
GlLoadingIcon,
@@ -57,7 +55,7 @@ export default {
ImportTargetCell,
ImportStatusCell,
ImportActionsCell,
- PaginationLinks,
+ PaginationBar,
},
props: {
@@ -83,6 +81,7 @@ export default {
selectedGroupsIds: [],
pendingGroupsIds: [],
importTargets: {},
+ unavailableFeaturesAlertVisible: true,
};
},
@@ -170,7 +169,7 @@ export default {
},
availableGroupsForImport() {
- return this.groupsTableData.filter((g) => g.flags.isAvailableForImport && g.flags.isInvalid);
+ return this.groupsTableData.filter((g) => g.flags.isAvailableForImport && !g.flags.isInvalid);
},
humanizedTotal() {
@@ -204,6 +203,23 @@ export default {
return { start, end, total };
},
+
+ unavailableFeatures() {
+ if (!this.hasGroups) {
+ return [];
+ }
+
+ return Object.entries(this.bulkImportSourceGroups.versionValidation.features)
+ .filter(([, { available }]) => available === false)
+ .map(([k, v]) => ({ title: i18n.features[k] || k, version: v.minVersion }));
+ },
+
+ unavailableFeaturesAlertTitle() {
+ return sprintf(s__('BulkImport| %{host} is running outdated GitLab version (v%{version})'), {
+ host: this.sourceUrl,
+ version: this.bulkImportSourceGroups.versionValidation.features.sourceInstanceVersion,
+ });
+ },
},
watch: {
@@ -314,9 +330,8 @@ export default {
variables: { importRequests },
});
} catch (error) {
- const message = error?.networkError?.response?.data?.error ?? i18n.ERROR_IMPORT;
createFlash({
- message,
+ message: i18n.ERROR_IMPORT,
captureError: true,
error,
});
@@ -476,6 +491,38 @@ export default {
<img :src="$options.gitlabLogo" class="gl-w-6 gl-h-6 gl-mb-2 gl-display-inline gl-mr-2" />
{{ s__('BulkImport|Import groups from GitLab') }}
</h1>
+ <gl-alert
+ v-if="unavailableFeatures.length > 0 && unavailableFeaturesAlertVisible"
+ variant="warning"
+ :title="unavailableFeaturesAlertTitle"
+ @dismiss="unavailableFeaturesAlertVisible = false"
+ >
+ <gl-sprintf
+ :message="
+ s__(
+ 'BulkImport|Following data will not be migrated: %{bullets} Contact system administrator of %{host} to upgrade GitLab if you need this data in your migration',
+ )
+ "
+ >
+ <template #host>
+ <gl-link :href="sourceUrl" target="_blank">
+ {{ sourceUrl }}<gl-icon name="external-link" class="vertical-align-middle" />
+ </gl-link>
+ </template>
+ <template #bullets>
+ <ul>
+ <li v-for="feature in unavailableFeatures" :key="feature.title">
+ <gl-sprintf :message="s__('BulkImport|%{feature} (require v%{version})')">
+ <template #feature>{{ feature.title }}</template>
+ <template #version>
+ <strong>{{ feature.version }}</strong>
+ </template>
+ </gl-sprintf>
+ </li>
+ </ul>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
<div
class="gl-py-5 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex"
>
@@ -495,7 +542,7 @@ export default {
</template>
<template #link>
<gl-link :href="sourceUrl" target="_blank">
- {{ sourceUrl }} <gl-icon name="external-link" class="vertical-align-middle" />
+ {{ sourceUrl }}<gl-icon name="external-link" class="vertical-align-middle" />
</gl-link>
</template>
</gl-sprintf>
@@ -521,13 +568,15 @@ export default {
/>
<template v-else>
<div
- class="gl-bg-gray-10 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-p-4 gl-display-flex gl-align-items-center"
+ class="gl-bg-gray-10 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-px-4 gl-display-flex gl-align-items-center import-table-bar"
>
- <gl-sprintf :message="__('%{count} selected')">
- <template #count>
- {{ selectedGroupsIds.length }}
- </template>
- </gl-sprintf>
+ <span data-test-id="selection-count">
+ <gl-sprintf :message="__('%{count} selected')">
+ <template #count>
+ {{ selectedGroupsIds.length }}
+ </template>
+ </gl-sprintf>
+ </span>
<gl-button
category="primary"
variant="confirm"
@@ -539,7 +588,7 @@ export default {
</div>
<gl-table
ref="table"
- class="gl-w-full"
+ class="gl-w-full import-table"
data-qa-selector="import_table"
:tbody-tr-class="rowClasses"
:tbody-tr-attr="qaRowAttributes"
@@ -599,49 +648,13 @@ export default {
/>
</template>
</gl-table>
- <div v-if="hasGroups" class="gl-display-flex gl-mt-3 gl-align-items-center">
- <pagination-links
- :change="setPage"
- :page-info="bulkImportSourceGroups.pageInfo"
- class="gl-m-0"
- />
- <gl-dropdown category="tertiary" :aria-label="__('Page size')" class="gl-ml-auto">
- <template #button-content>
- <span class="font-weight-bold">
- <gl-sprintf :message="__('%{count} items per page')">
- <template #count>
- {{ perPage }}
- </template>
- </gl-sprintf>
- </span>
- <gl-icon class="gl-button-icon dropdown-chevron" name="chevron-down" />
- </template>
- <gl-dropdown-item
- v-for="size in $options.PAGE_SIZES"
- :key="size"
- @click="setPageSize(size)"
- >
- <gl-sprintf :message="__('%{count} items per page')">
- <template #count>
- {{ size }}
- </template>
- </gl-sprintf>
- </gl-dropdown-item>
- </gl-dropdown>
- <div class="gl-ml-2">
- <gl-sprintf :message="s__('BulkImport|Showing %{start}-%{end} of %{total}')">
- <template #start>
- {{ paginationInfo.start }}
- </template>
- <template #end>
- {{ paginationInfo.end }}
- </template>
- <template #total>
- {{ humanizedTotal }}
- </template>
- </gl-sprintf>
- </div>
- </div>
+ <pagination-bar
+ v-if="hasGroups"
+ :page-info="bulkImportSourceGroups.pageInfo"
+ class="gl-mt-3"
+ @set-page="setPage"
+ @set-page-size="setPageSize"
+ />
</template>
</template>
</div>
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue
index ca9ae9447d0..344a6e45370 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue
@@ -32,72 +32,84 @@ export default {
fullPath() {
return this.group.importTarget.targetNamespace.fullPath || s__('BulkImport|No parent');
},
- invalidNameValidationMessage() {
- return getInvalidNameValidationMessage(this.group.importTarget);
+ validationMessage() {
+ return (
+ this.group.progress?.message || getInvalidNameValidationMessage(this.group.importTarget)
+ );
+ },
+ validNameState() {
+ // bootstrap-vue requires null for "indifferent" state, if we return true
+ // this will highlight field in green like "passed validation"
+ return this.group.flags.isInvalid && this.group.flags.isAvailableForImport ? false : null;
},
},
};
</script>
<template>
- <div class="gl-display-flex gl-align-items-stretch">
- <import-group-dropdown
- #default="{ namespaces }"
- :text="fullPath"
- :disabled="!group.flags.isAvailableForImport"
- :namespaces="availableNamespaces"
- toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
- class="gl-h-7 gl-flex-grow-1"
- data-qa-selector="target_namespace_selector_dropdown"
- >
- <gl-dropdown-item @click="$emit('update-target-namespace', { fullPath: '', id: null })">{{
- s__('BulkImport|No parent')
- }}</gl-dropdown-item>
- <template v-if="namespaces.length">
- <gl-dropdown-divider />
- <gl-dropdown-section-header>
- {{ s__('BulkImport|Existing 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="$emit('update-target-namespace', ns)"
- >
- {{ ns.fullPath }}
- </gl-dropdown-item>
- </template>
- </import-group-dropdown>
- <div
- class="gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1 gl-bg-gray-10"
- :class="{
- 'gl-text-gray-400 gl-border-gray-100': !group.flags.isAvailableForImport,
- 'gl-border-gray-200': group.flags.isAvailableForImport,
- }"
- >
- /
- </div>
- <div class="gl-flex-grow-1">
- <gl-form-input
- class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
+ <div>
+ <div class="gl-display-flex gl-align-items-stretch">
+ <import-group-dropdown
+ #default="{ namespaces }"
+ :text="fullPath"
+ :disabled="!group.flags.isAvailableForImport"
+ :namespaces="availableNamespaces"
+ toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
+ class="gl-h-7 gl-flex-grow-1"
+ data-qa-selector="target_namespace_selector_dropdown"
+ >
+ <gl-dropdown-item @click="$emit('update-target-namespace', { fullPath: '', id: null })">{{
+ s__('BulkImport|No parent')
+ }}</gl-dropdown-item>
+ <template v-if="namespaces.length">
+ <gl-dropdown-divider />
+ <gl-dropdown-section-header>
+ {{ s__('BulkImport|Existing 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="$emit('update-target-namespace', ns)"
+ >
+ {{ ns.fullPath }}
+ </gl-dropdown-item>
+ </template>
+ </import-group-dropdown>
+ <div
+ class="gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1 gl-bg-gray-10"
:class="{
- 'gl-inset-border-1-gray-200!': group.flags.isAvailableForImport,
- 'gl-inset-border-1-gray-100!': !group.flags.isAvailableForImport,
- 'is-invalid': group.flags.isInvalid && group.flags.isAvailableForImport,
+ 'gl-text-gray-400 gl-border-gray-100': !group.flags.isAvailableForImport,
+ 'gl-border-gray-200': group.flags.isAvailableForImport,
}"
- debounce="500"
- :disabled="!group.flags.isAvailableForImport"
- :value="group.importTarget.newName"
- :aria-label="__('New name')"
- @input="$emit('update-new-name', $event)"
- />
- <p
- v-if="group.flags.isAvailableForImport && group.flags.isInvalid"
- class="gl-text-red-500 gl-m-0 gl-mt-2"
>
- {{ invalidNameValidationMessage }}
- </p>
+ /
+ </div>
+ <div class="gl-flex-grow-1">
+ <gl-form-input
+ class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
+ :class="{
+ 'gl-inset-border-1-gray-200!':
+ group.flags.isAvailableForImport && !group.flags.isInvalid,
+ 'gl-inset-border-1-gray-100!':
+ !group.flags.isAvailableForImport && !group.flags.isInvalid,
+ }"
+ debounce="500"
+ :disabled="!group.flags.isAvailableForImport"
+ :value="group.importTarget.newName"
+ :aria-label="__('New name')"
+ :state="validNameState"
+ @input="$emit('update-new-name', $event)"
+ />
+ </div>
+ </div>
+ <div
+ v-if="group.flags.isAvailableForImport && (group.flags.isInvalid || validationMessage)"
+ class="gl-text-red-500 gl-m-0 gl-mt-2"
+ role="alert"
+ >
+ {{ validationMessage }}
</div>
</div>
</template>
diff --git a/app/assets/javascripts/import_entities/import_groups/constants.js b/app/assets/javascripts/import_entities/import_groups/constants.js
index aa9cf3897e6..ac1466238d0 100644
--- a/app/assets/javascripts/import_entities/import_groups/constants.js
+++ b/app/assets/javascripts/import_entities/import_groups/constants.js
@@ -11,6 +11,10 @@ export const i18n = {
),
ERROR_IMPORT: s__('BulkImport|Importing the group failed.'),
ERROR_IMPORT_COMPLETED: s__('BulkImport|Import is finished. Pick another name for re-import'),
+
+ features: {
+ projectMigration: __('projects'),
+ },
};
export const NEW_NAME_FIELD = 'newName';
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
index bce6e7bcb1f..36da996ea17 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
@@ -14,6 +14,9 @@ export const clientTypenames = {
BulkImportPageInfo: 'ClientBulkImportPageInfo',
BulkImportTarget: 'ClientBulkImportTarget',
BulkImportProgress: 'ClientBulkImportProgress',
+ BulkImportVersionValidation: 'ClientBulkImportVersionValidation',
+ BulkImportVersionValidationFeature: 'ClientBulkImportVersionValidationFeature',
+ BulkImportVersionValidationFeatures: 'ClientBulkImportVersionValidationFeatures',
};
function makeLastImportTarget(data) {
@@ -92,6 +95,18 @@ export function createResolvers({ endpoints }) {
__typename: clientTypenames.BulkImportPageInfo,
...pagination,
},
+ versionValidation: {
+ __typename: clientTypenames.BulkImportVersionValidation,
+ features: {
+ __typename: clientTypenames.BulkImportVersionValidationFeatures,
+ sourceInstanceVersion: data.version_validation.features.source_instance_version,
+ projectMigration: {
+ __typename: clientTypenames.BulkImportVersionValidationFeature,
+ available: data.version_validation.features.project_migration.available,
+ minVersion: data.version_validation.features.project_migration.min_version,
+ },
+ },
+ },
};
return response;
},
@@ -142,9 +157,7 @@ export function createResolvers({ endpoints }) {
};
});
- const {
- data: { id: jobId },
- } = await axios.post(endpoints.createBulkImport, {
+ const { data: originalResponse } = await axios.post(endpoints.createBulkImport, {
bulk_import: importOperations.map((op) => ({
source_type: 'group_entity',
source_full_path: op.group.fullPath,
@@ -153,15 +166,21 @@ export function createResolvers({ endpoints }) {
})),
});
- return importOperations.map((op) => {
+ const responses = Array.isArray(originalResponse)
+ ? originalResponse
+ : [{ success: true, id: originalResponse.id }];
+
+ return importOperations.map((op, idx) => {
+ const response = responses[idx];
const lastImportTarget = {
targetNamespace: op.targetNamespace,
newName: op.newName,
};
const progress = {
- id: jobId,
- status: STATUSES.CREATED,
+ id: response.id || `local-${Date.now()}-${idx}`,
+ status: response.success ? STATUSES.CREATED : STATUSES.FAILED,
+ message: response.message || null,
};
localStorageCache.set(op.group.webUrl, { progress, lastImportTarget });
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_progress.fragment.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_progress.fragment.graphql
index 2d60bf82d65..33c564f36a8 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_progress.fragment.graphql
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_progress.fragment.graphql
@@ -1,4 +1,5 @@
fragment BulkImportSourceGroupProgress on ClientBulkImportProgress {
id
status
+ message
}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql
index 75215471d0f..39289887b75 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql
@@ -9,6 +9,7 @@ mutation importGroups($importRequests: [ImportGroupInput!]!) {
progress {
id
status
+ message
}
}
}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql
index 28dfefdf8a7..ace8bffc012 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql
@@ -11,5 +11,14 @@ query bulkImportSourceGroups($page: Int = 1, $perPage: Int = 20, $filter: String
total
totalPages
}
+ versionValidation {
+ features {
+ sourceInstanceVersion
+ projectMigration {
+ available
+ minVersion
+ }
+ }
+ }
}
}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js
index 09bc7b33692..1aad22f0f3f 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js
@@ -22,7 +22,14 @@ export class LocalStorageCache {
loadCacheFromStorage() {
try {
- return JSON.parse(this.storage.getItem(KEY)) ?? {};
+ const storage = JSON.parse(this.storage.getItem(KEY)) ?? {};
+ Object.values(storage).forEach((entry) => {
+ if (entry.progress && !('message' in entry.progress)) {
+ // eslint-disable-next-line no-param-reassign
+ entry.progress.message = '';
+ }
+ });
+ return storage;
} catch {
return {};
}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql
index b8dd79a5000..c48e22a7717 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql
@@ -11,11 +11,13 @@ type ClientBulkImportTarget {
type ClientBulkImportSourceGroupConnection {
nodes: [ClientBulkImportSourceGroup!]!
pageInfo: ClientBulkImportPageInfo!
+ versionValidation: ClientBulkImportVersionValidation!
}
type ClientBulkImportProgress {
id: ID!
status: String!
+ message: String
}
type ClientBulkImportValidationError {
@@ -45,6 +47,20 @@ type ClientBulkImportNamespaceSuggestion {
suggestions: [String!]!
}
+type ClientBulkImportVersionValidation {
+ features: ClientBulkImportVersionValidationFeatures!
+}
+
+type ClientBulkImportVersionValidationFeatures {
+ project_migration: ClientBulkImportVersionValidationFeature!
+ sourceInstanceVersion: String!
+}
+
+type ClientBulkImportVersionValidationFeature {
+ available: Boolean!
+ min_version: String!
+}
+
extend type Query {
bulkImportSourceGroups(
page: Int!
diff --git a/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql b/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql
index eb2dde14464..faa68d37088 100644
--- a/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql
+++ b/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql
@@ -1,3 +1,4 @@
+# eslint-disable-next-line @graphql-eslint/require-id-when-available
fragment IncidentFields on Issue {
severity
}
diff --git a/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql b/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql
index 4e44a506c4f..fda8a65d4a4 100644
--- a/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql
+++ b/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql
@@ -6,6 +6,7 @@ query getIncidentsCountByStatus(
$assigneeUsername: String = ""
) {
project(fullPath: $projectPath) {
+ id
issueStatusCounts(
search: $searchTerm
types: $issueTypes
diff --git a/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql b/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql
index f97664a3b77..1e18d89b656 100644
--- a/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql
+++ b/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql
@@ -14,6 +14,7 @@ query getIncidents(
$assigneeUsername: String = ""
) {
project(fullPath: $projectPath) {
+ id
issues(
search: $searchTerm
types: $issueTypes
@@ -27,18 +28,21 @@ query getIncidents(
before: $prevPageCursor
) {
nodes {
+ id
iid
title
createdAt
state
labels {
nodes {
+ id
title
color
}
}
assignees {
nodes {
+ id
name
username
avatarUrl
diff --git a/app/assets/javascripts/init_confirm_danger.js b/app/assets/javascripts/init_confirm_danger.js
index d3d32c8be54..a8833a17467 100644
--- a/app/assets/javascripts/init_confirm_danger.js
+++ b/app/assets/javascripts/init_confirm_danger.js
@@ -10,6 +10,7 @@ export default () => {
removeFormId = null,
phrase,
buttonText,
+ buttonClass = '',
buttonTestid = null,
confirmDangerMessage,
disabled = false,
@@ -25,6 +26,7 @@ export default () => {
props: {
phrase,
buttonText,
+ buttonClass,
buttonTestid,
disabled: parseBoolean(disabled),
},
diff --git a/app/assets/javascripts/init_issuable_sidebar.js b/app/assets/javascripts/init_issuable_sidebar.js
deleted file mode 100644
index 7a70d893008..00000000000
--- a/app/assets/javascripts/init_issuable_sidebar.js
+++ /dev/null
@@ -1,16 +0,0 @@
-/* eslint-disable no-new */
-
-import { getSidebarOptions } from '~/sidebar/mount_sidebar';
-import IssuableContext from './issuable_context';
-import Sidebar from './right_sidebar';
-
-export default () => {
- const sidebarOptEl = document.querySelector('.js-sidebar-options');
-
- if (!sidebarOptEl) return;
-
- const sidebarOptions = getSidebarOptions(sidebarOptEl);
-
- new IssuableContext(sidebarOptions.currentUser);
- Sidebar.initialize();
-};
diff --git a/app/assets/javascripts/init_labels.js b/app/assets/javascripts/init_labels.js
deleted file mode 100644
index 10bfbf7960c..00000000000
--- a/app/assets/javascripts/init_labels.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import $ from 'jquery';
-import GroupLabelSubscription from './group_label_subscription';
-import LabelManager from './label_manager';
-import ProjectLabelSubscription from './project_label_subscription';
-
-export default () => {
- if ($('.prioritized-labels').length) {
- new LabelManager(); // eslint-disable-line no-new
- }
- $('.label-subscription').each((i, el) => {
- const $el = $(el);
-
- if ($el.find('.dropdown-group-label').length) {
- new GroupLabelSubscription($el); // eslint-disable-line no-new
- } else {
- new ProjectLabelSubscription($el); // eslint-disable-line no-new
- }
- });
-};
diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js
index d214ee4ded6..84656bd41bb 100644
--- a/app/assets/javascripts/integrations/constants.js
+++ b/app/assets/javascripts/integrations/constants.js
@@ -1,9 +1,5 @@
import { s__, __ } from '~/locale';
-export const TEST_INTEGRATION_EVENT = 'testIntegration';
-export const SAVE_INTEGRATION_EVENT = 'saveIntegration';
-export const GET_JIRA_ISSUE_TYPES_EVENT = 'getJiraIssueTypes';
-export const TOGGLE_INTEGRATION_EVENT = 'toggleIntegration';
export const VALIDATE_INTEGRATION_FORM_EVENT = 'validateIntegrationForm';
export const integrationLevels = {
diff --git a/app/assets/javascripts/integrations/edit/api.js b/app/assets/javascripts/integrations/edit/api.js
new file mode 100644
index 00000000000..7bce5604f9d
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/api.js
@@ -0,0 +1,9 @@
+import axios from '~/lib/utils/axios_utils';
+
+/**
+ * Test the validity of [integrationFormData].
+ * @return Promise<{ issuetypes: []String }> - issuetypes contains valid Jira issue types.
+ */
+export const testIntegrationSettings = (testPath, integrationFormData) => {
+ return axios.put(testPath, integrationFormData);
+};
diff --git a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
index 9804a9e15f6..5ddf3aeb639 100644
--- a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
+++ b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
@@ -1,8 +1,6 @@
<script>
import { GlFormGroup, GlFormCheckbox } from '@gitlab/ui';
import { mapGetters } from 'vuex';
-import { TOGGLE_INTEGRATION_EVENT } from '~/integrations/constants';
-import eventHub from '../event_hub';
export default {
name: 'ActiveCheckbox',
@@ -20,14 +18,11 @@ export default {
},
mounted() {
this.activated = this.propsSource.initialActivated;
- // Initialize view
- this.$nextTick(() => {
- this.onChange(this.activated);
- });
+ this.onChange(this.activated);
},
methods: {
- onChange(e) {
- eventHub.$emit(TOGGLE_INTEGRATION_EVENT, e);
+ onChange(isChecked) {
+ this.$emit('toggle-integration-active', isChecked);
},
},
};
diff --git a/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue
index 89f7e3b7a89..bc6aa231a93 100644
--- a/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue
+++ b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue
@@ -1,22 +1,17 @@
<script>
import { GlModal } from '@gitlab/ui';
-import { mapGetters } from 'vuex';
import { __ } from '~/locale';
export default {
components: {
GlModal,
},
+
computed: {
- ...mapGetters(['isDisabled']),
primaryProps() {
return {
text: __('Save'),
- attributes: [
- { variant: 'confirm' },
- { category: 'primary' },
- { disabled: this.isDisabled },
- ],
+ attributes: [{ variant: 'confirm' }, { category: 'primary' }],
};
},
cancelProps() {
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index ba1aeb28616..e570a468944 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -1,14 +1,17 @@
<script>
import { GlButton, GlModalDirective, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { mapState, mapActions, mapGetters } from 'vuex';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
- TEST_INTEGRATION_EVENT,
- SAVE_INTEGRATION_EVENT,
+ VALIDATE_INTEGRATION_FORM_EVENT,
+ I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE,
+ I18N_DEFAULT_ERROR_MESSAGE,
+ I18N_SUCCESSFUL_CONNECTION_MESSAGE,
integrationLevels,
} from '~/integrations/constants';
import eventHub from '../event_hub';
-
+import { testIntegrationSettings } from '../api';
import ActiveCheckbox from './active_checkbox.vue';
import ConfirmationModal from './confirmation_modal.vue';
import DynamicField from './dynamic_field.vue';
@@ -37,22 +40,26 @@ export default {
},
mixins: [glFeatureFlagsMixin()],
props: {
+ formSelector: {
+ type: String,
+ required: true,
+ },
helpHtml: {
type: String,
required: false,
default: '',
},
},
+ data() {
+ return {
+ integrationActive: false,
+ isTesting: false,
+ isSaving: false,
+ };
+ },
computed: {
- ...mapGetters(['currentKey', 'propsSource', 'isDisabled']),
- ...mapState([
- 'defaultState',
- 'customState',
- 'override',
- 'isSaving',
- 'isTesting',
- 'isResetting',
- ]),
+ ...mapGetters(['currentKey', 'propsSource']),
+ ...mapState(['defaultState', 'customState', 'override', 'isResetting']),
isEditable() {
return this.propsSource.editable;
},
@@ -65,29 +72,81 @@ export default {
this.customState.integrationLevel === integrationLevels.GROUP
);
},
- showReset() {
+ showResetButton() {
return this.isInstanceOrGroupLevel && this.propsSource.resetPath;
},
+ showTestButton() {
+ return this.propsSource.canTest;
+ },
+ disableButtons() {
+ return Boolean(this.isSaving || this.isResetting || this.isTesting);
+ },
+ },
+ mounted() {
+ // this form element is defined in Haml
+ this.form = document.querySelector(this.formSelector);
},
methods: {
- ...mapActions([
- 'setOverride',
- 'setIsSaving',
- 'setIsTesting',
- 'setIsResetting',
- 'fetchResetIntegration',
- ]),
+ ...mapActions(['setOverride', 'fetchResetIntegration', 'requestJiraIssueTypes']),
onSaveClick() {
- this.setIsSaving(true);
- eventHub.$emit(SAVE_INTEGRATION_EVENT);
+ this.isSaving = true;
+
+ if (this.integrationActive && !this.form.checkValidity()) {
+ this.isSaving = false;
+ eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT);
+ return;
+ }
+
+ this.form.submit();
},
onTestClick() {
- this.setIsTesting(true);
- eventHub.$emit(TEST_INTEGRATION_EVENT);
+ this.isTesting = true;
+
+ if (!this.form.checkValidity()) {
+ eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT);
+ return;
+ }
+
+ testIntegrationSettings(this.propsSource.testPath, this.getFormData())
+ .then(({ data: { error, message = I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE } }) => {
+ if (error) {
+ eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT);
+ this.$toast.show(message);
+ return;
+ }
+
+ this.$toast.show(I18N_SUCCESSFUL_CONNECTION_MESSAGE);
+ })
+ .catch((error) => {
+ this.$toast.show(I18N_DEFAULT_ERROR_MESSAGE);
+ Sentry.captureException(error);
+ })
+ .finally(() => {
+ this.isTesting = false;
+ });
},
onResetClick() {
this.fetchResetIntegration();
},
+ onRequestJiraIssueTypes() {
+ this.requestJiraIssueTypes(this.getFormData());
+ },
+ getFormData() {
+ return new FormData(this.form);
+ },
+ onToggleIntegrationState(integrationActive) {
+ this.integrationActive = integrationActive;
+ if (!this.form) {
+ return;
+ }
+
+ // If integration will be active, enable form validation.
+ if (integrationActive) {
+ this.form.removeAttribute('novalidate');
+ } else {
+ this.form.setAttribute('novalidate', true);
+ }
+ },
},
helpHtmlConfig: {
ADD_ATTR: ['target'], // allow external links, can be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1427 is implemented
@@ -114,7 +173,11 @@ export default {
<!-- helpHtml is trusted input -->
<div v-if="helpHtml" v-safe-html:[$options.helpHtmlConfig]="helpHtml"></div>
- <active-checkbox v-if="propsSource.showActive" :key="`${currentKey}-active-checkbox`" />
+ <active-checkbox
+ v-if="propsSource.showActive"
+ :key="`${currentKey}-active-checkbox`"
+ @toggle-integration-active="onToggleIntegrationState"
+ />
<jira-trigger-fields
v-if="isJira"
:key="`${currentKey}-jira-trigger-fields`"
@@ -135,6 +198,7 @@ export default {
v-if="isJira && !isInstanceOrGroupLevel"
:key="`${currentKey}-jira-issues-fields`"
v-bind="propsSource.jiraIssuesProps"
+ @request-jira-issue-types="onRequestJiraIssueTypes"
/>
<div v-if="isEditable" class="footer-block row-content-block">
<template v-if="isInstanceOrGroupLevel">
@@ -143,7 +207,7 @@ export default {
category="primary"
variant="confirm"
:loading="isSaving"
- :disabled="isDisabled"
+ :disabled="disableButtons"
data-qa-selector="save_changes_button"
>
{{ __('Save changes') }}
@@ -156,7 +220,8 @@ export default {
variant="confirm"
type="submit"
:loading="isSaving"
- :disabled="isDisabled"
+ :disabled="disableButtons"
+ data-testid="save-button"
data-qa-selector="save_changes_button"
@click.prevent="onSaveClick"
>
@@ -164,24 +229,24 @@ export default {
</gl-button>
<gl-button
- v-if="propsSource.canTest"
+ v-if="showTestButton"
category="secondary"
variant="confirm"
:loading="isTesting"
- :disabled="isDisabled"
- :href="propsSource.testPath"
+ :disabled="disableButtons"
+ data-testid="test-button"
@click.prevent="onTestClick"
>
{{ __('Test settings') }}
</gl-button>
- <template v-if="showReset">
+ <template v-if="showResetButton">
<gl-button
v-gl-modal.confirmResetIntegration
category="secondary"
variant="confirm"
:loading="isResetting"
- :disabled="isDisabled"
+ :disabled="disableButtons"
data-testid="reset-button"
>
{{ __('Reset') }}
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 7cbfb35aeaa..99498501f6c 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
@@ -1,10 +1,7 @@
<script>
import { GlFormGroup, GlFormCheckbox, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui';
import { mapGetters } from 'vuex';
-import {
- VALIDATE_INTEGRATION_FORM_EVENT,
- GET_JIRA_ISSUE_TYPES_EVENT,
-} from '~/integrations/constants';
+import { VALIDATE_INTEGRATION_FORM_EVENT } from '~/integrations/constants';
import { s__, __ } from '~/locale';
import eventHub from '../event_hub';
import JiraUpgradeCta from './jira_upgrade_cta.vue';
@@ -91,9 +88,6 @@ export default {
validateForm() {
this.validated = true;
},
- getJiraIssueTypes() {
- eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT);
- },
},
i18n: {
sectionTitle: s__('JiraService|View Jira issues in GitLab'),
@@ -123,7 +117,11 @@ export default {
</p>
<template v-if="showJiraIssuesIntegration">
<input name="service[issues_enabled]" type="hidden" :value="enableJiraIssues || false" />
- <gl-form-checkbox v-model="enableJiraIssues" :disabled="isInheriting">
+ <gl-form-checkbox
+ v-model="enableJiraIssues"
+ :disabled="isInheriting"
+ data-qa-selector="service_jira_issues_enabled_checkbox"
+ >
{{ $options.i18n.enableCheckboxLabel }}
<template #help>
{{ $options.i18n.enableCheckboxHelp }}
@@ -136,7 +134,7 @@ export default {
:initial-issue-type-id="initialVulnerabilitiesIssuetype"
:show-full-feature="showJiraVulnerabilitiesIntegration"
data-testid="jira-for-vulnerabilities"
- @request-get-issue-types="getJiraIssueTypes"
+ @request-jira-issue-types="$emit('request-jira-issue-types')"
/>
<jira-upgrade-cta
v-if="!showJiraVulnerabilitiesIntegration"
@@ -168,6 +166,7 @@ export default {
id="service_project_key"
v-model="projectKey"
name="service[project_key]"
+ data-qa-selector="service_jira_project_key_field"
:placeholder="$options.i18n.projectKeyPlaceholder"
:required="enableJiraIssues"
:state="validProjectKey"
diff --git a/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue b/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue
index 9472a3eeafe..5a445235219 100644
--- a/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue
+++ b/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue
@@ -1,6 +1,5 @@
<script>
import { GlModal } from '@gitlab/ui';
-import { mapGetters } from 'vuex';
import { __ } from '~/locale';
@@ -9,15 +8,10 @@ export default {
GlModal,
},
computed: {
- ...mapGetters(['isDisabled']),
primaryProps() {
return {
text: __('Reset'),
- attributes: [
- { variant: 'warning' },
- { category: 'primary' },
- { disabled: this.isDisabled },
- ],
+ attributes: [{ variant: 'warning' }, { category: 'primary' }],
};
},
cancelProps() {
diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js
index 792e7d8e85e..9c9e3edbeb8 100644
--- a/app/assets/javascripts/integrations/edit/index.js
+++ b/app/assets/javascripts/integrations/edit/index.js
@@ -85,35 +85,39 @@ function parseDatasetToProps(data) {
};
}
-export default (el, defaultEl) => {
- if (!el) {
+export default function initIntegrationSettingsForm(formSelector) {
+ const customSettingsEl = document.querySelector('.js-vue-integration-settings');
+ const defaultSettingsEl = document.querySelector('.js-vue-default-integration-settings');
+
+ if (!customSettingsEl) {
return null;
}
- const props = parseDatasetToProps(el.dataset);
+ const customSettingsProps = parseDatasetToProps(customSettingsEl.dataset);
const initialState = {
defaultState: null,
- customState: props,
+ customState: customSettingsProps,
};
- if (defaultEl) {
- initialState.defaultState = Object.freeze(parseDatasetToProps(defaultEl.dataset));
+ if (defaultSettingsEl) {
+ initialState.defaultState = Object.freeze(parseDatasetToProps(defaultSettingsEl.dataset));
}
// Here, we capture the "helpHtml", so we can pass it to the Vue component
// to position it where ever it wants.
// Because this node is a _child_ of `el`, it will be removed when the Vue component is mounted,
// so we don't need to manually remove it.
- const helpHtml = el.querySelector('.js-integration-help-html')?.innerHTML;
+ const helpHtml = customSettingsEl.querySelector('.js-integration-help-html')?.innerHTML;
return new Vue({
- el,
+ el: customSettingsEl,
store: createStore(initialState),
render(createElement) {
return createElement(IntegrationForm, {
props: {
helpHtml,
+ formSelector,
},
});
},
});
-};
+}
diff --git a/app/assets/javascripts/integrations/edit/store/actions.js b/app/assets/javascripts/integrations/edit/store/actions.js
index 400397c050c..97565a3a69c 100644
--- a/app/assets/javascripts/integrations/edit/store/actions.js
+++ b/app/assets/javascripts/integrations/edit/store/actions.js
@@ -1,10 +1,15 @@
import axios from 'axios';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
+import {
+ VALIDATE_INTEGRATION_FORM_EVENT,
+ I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE,
+ I18N_DEFAULT_ERROR_MESSAGE,
+} from '~/integrations/constants';
+import { testIntegrationSettings } from '../api';
+import eventHub from '../event_hub';
import * as types from './mutation_types';
export const setOverride = ({ commit }, override) => commit(types.SET_OVERRIDE, override);
-export const setIsSaving = ({ commit }, isSaving) => commit(types.SET_IS_SAVING, isSaving);
-export const setIsTesting = ({ commit }, isTesting) => commit(types.SET_IS_TESTING, isTesting);
export const setIsResetting = ({ commit }, isResetting) =>
commit(types.SET_IS_RESETTING, isResetting);
@@ -27,10 +32,28 @@ export const fetchResetIntegration = ({ dispatch, getters }) => {
.catch(() => dispatch('receiveResetIntegrationError'));
};
-export const requestJiraIssueTypes = ({ commit }) => {
+export const requestJiraIssueTypes = ({ commit, dispatch, getters }, formData) => {
commit(types.SET_JIRA_ISSUE_TYPES_ERROR_MESSAGE, '');
commit(types.SET_IS_LOADING_JIRA_ISSUE_TYPES, true);
+
+ return testIntegrationSettings(getters.propsSource.testPath, formData)
+ .then(
+ ({
+ data: { issuetypes, error, message = I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE },
+ }) => {
+ if (error || !issuetypes?.length) {
+ eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT);
+ throw new Error(message);
+ }
+
+ dispatch('receiveJiraIssueTypesSuccess', issuetypes);
+ },
+ )
+ .catch(({ message = I18N_DEFAULT_ERROR_MESSAGE }) => {
+ dispatch('receiveJiraIssueTypesError', message);
+ });
};
+
export const receiveJiraIssueTypesSuccess = ({ commit }, issueTypes = []) => {
commit(types.SET_IS_LOADING_JIRA_ISSUE_TYPES, false);
commit(types.SET_JIRA_ISSUE_TYPES, issueTypes);
diff --git a/app/assets/javascripts/integrations/edit/store/getters.js b/app/assets/javascripts/integrations/edit/store/getters.js
index 39e14de2d0d..b79132128cc 100644
--- a/app/assets/javascripts/integrations/edit/store/getters.js
+++ b/app/assets/javascripts/integrations/edit/store/getters.js
@@ -1,7 +1,5 @@
export const isInheriting = (state) => (state.defaultState === null ? false : !state.override);
-export const isDisabled = (state) => state.isSaving || state.isTesting || state.isResetting;
-
export const propsSource = (state, getters) =>
getters.isInheriting ? state.defaultState : state.customState;
diff --git a/app/assets/javascripts/integrations/edit/store/mutation_types.js b/app/assets/javascripts/integrations/edit/store/mutation_types.js
index c681056a515..ddf6bef7554 100644
--- a/app/assets/javascripts/integrations/edit/store/mutation_types.js
+++ b/app/assets/javascripts/integrations/edit/store/mutation_types.js
@@ -1,6 +1,4 @@
export const SET_OVERRIDE = 'SET_OVERRIDE';
-export const SET_IS_SAVING = 'SET_IS_SAVING';
-export const SET_IS_TESTING = 'SET_IS_TESTING';
export const SET_IS_RESETTING = 'SET_IS_RESETTING';
export const SET_IS_LOADING_JIRA_ISSUE_TYPES = 'SET_IS_LOADING_JIRA_ISSUE_TYPES';
diff --git a/app/assets/javascripts/integrations/edit/store/mutations.js b/app/assets/javascripts/integrations/edit/store/mutations.js
index 279df1b9266..e7e312ce650 100644
--- a/app/assets/javascripts/integrations/edit/store/mutations.js
+++ b/app/assets/javascripts/integrations/edit/store/mutations.js
@@ -4,12 +4,6 @@ export default {
[types.SET_OVERRIDE](state, override) {
state.override = override;
},
- [types.SET_IS_SAVING](state, isSaving) {
- state.isSaving = isSaving;
- },
- [types.SET_IS_TESTING](state, isTesting) {
- state.isTesting = isTesting;
- },
[types.SET_IS_RESETTING](state, isResetting) {
state.isResetting = isResetting;
},
diff --git a/app/assets/javascripts/integrations/edit/store/state.js b/app/assets/javascripts/integrations/edit/store/state.js
index 1c0b274e4ef..3d40d1b90d5 100644
--- a/app/assets/javascripts/integrations/edit/store/state.js
+++ b/app/assets/javascripts/integrations/edit/store/state.js
@@ -6,7 +6,6 @@ export default ({ defaultState = null, customState = {} } = {}) => {
defaultState,
customState,
isSaving: false,
- isTesting: false,
isResetting: false,
isLoadingJiraIssueTypes: false,
loadingJiraIssueTypesErrorMessage: '',
diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js
deleted file mode 100644
index f519fc87c46..00000000000
--- a/app/assets/javascripts/integrations/integration_settings_form.js
+++ /dev/null
@@ -1,151 +0,0 @@
-import { delay } from 'lodash';
-import toast from '~/vue_shared/plugins/global_toast';
-import axios from '../lib/utils/axios_utils';
-import initForm from './edit';
-import eventHub from './edit/event_hub';
-import {
- TEST_INTEGRATION_EVENT,
- SAVE_INTEGRATION_EVENT,
- GET_JIRA_ISSUE_TYPES_EVENT,
- TOGGLE_INTEGRATION_EVENT,
- VALIDATE_INTEGRATION_FORM_EVENT,
- I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE,
- I18N_DEFAULT_ERROR_MESSAGE,
- I18N_SUCCESSFUL_CONNECTION_MESSAGE,
-} from './constants';
-
-export default class IntegrationSettingsForm {
- constructor(formSelector) {
- this.$form = document.querySelector(formSelector);
- this.formActive = false;
-
- this.vue = null;
-
- // Form Metadata
- this.testEndPoint = this.$form.dataset.testUrl;
- }
-
- init() {
- // Init Vue component
- this.vue = initForm(
- document.querySelector('.js-vue-integration-settings'),
- document.querySelector('.js-vue-default-integration-settings'),
- );
- eventHub.$on(TOGGLE_INTEGRATION_EVENT, (active) => {
- this.formActive = active;
- this.toggleServiceState();
- });
- eventHub.$on(TEST_INTEGRATION_EVENT, () => {
- this.testIntegration();
- });
- eventHub.$on(SAVE_INTEGRATION_EVENT, () => {
- this.saveIntegration();
- });
- eventHub.$on(GET_JIRA_ISSUE_TYPES_EVENT, () => {
- this.getJiraIssueTypes(new FormData(this.$form));
- });
- }
-
- saveIntegration() {
- // Save Service if not active and check the following if active;
- // 1) If form contents are valid
- // 2) If this service can be saved
- // If both conditions are true, we override form submission
- // and save the service using provided configuration.
- const formValid = this.$form.checkValidity() || this.formActive === false;
-
- if (formValid) {
- delay(() => {
- this.$form.submit();
- }, 100);
- } else {
- eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT);
- this.vue.$store.dispatch('setIsSaving', false);
- }
- }
-
- testIntegration() {
- // Service was marked active so now we check;
- // 1) If form contents are valid
- // 2) If this service can be tested
- // If both conditions are true, we override form submission
- // and test the service using provided configuration.
- if (this.$form.checkValidity()) {
- this.testSettings(new FormData(this.$form));
- } else {
- eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT);
- this.vue.$store.dispatch('setIsTesting', false);
- }
- }
-
- /**
- * Change Form's validation enforcement based on service status (active/inactive)
- */
- toggleServiceState() {
- if (this.formActive) {
- this.$form.removeAttribute('novalidate');
- } else if (!this.$form.getAttribute('novalidate')) {
- this.$form.setAttribute('novalidate', 'novalidate');
- }
- }
-
- /**
- * Get a list of Jira issue types for the currently configured project
- *
- * @param {string} formData - URL encoded string containing the form data
- *
- * @return {Promise}
- */
- getJiraIssueTypes(formData) {
- const {
- $store: { dispatch },
- } = this.vue;
-
- dispatch('requestJiraIssueTypes');
-
- return this.fetchTestSettings(formData)
- .then(
- ({
- data: { issuetypes, error, message = I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE },
- }) => {
- if (error || !issuetypes?.length) {
- eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT);
- throw new Error(message);
- }
-
- dispatch('receiveJiraIssueTypesSuccess', issuetypes);
- },
- )
- .catch(({ message = I18N_DEFAULT_ERROR_MESSAGE }) => {
- dispatch('receiveJiraIssueTypesError', message);
- });
- }
-
- /**
- * Send request to the test endpoint which checks if the current config is valid
- */
- fetchTestSettings(formData) {
- return axios.put(this.testEndPoint, formData);
- }
-
- /**
- * Test Integration config
- */
- testSettings(formData) {
- return this.fetchTestSettings(formData)
- .then(({ data }) => {
- if (data.error) {
- toast(`${data.message} ${data.service_response}`);
- } else {
- this.vue.$store.dispatch('receiveJiraIssueTypesSuccess', data.issuetypes);
- toast(I18N_SUCCESSFUL_CONNECTION_MESSAGE);
- }
- })
- .catch(() => {
- toast(I18N_DEFAULT_ERROR_MESSAGE);
- })
- .finally(() => {
- this.vue.$store.dispatch('setIsTesting', false);
- });
- }
-}
diff --git a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue
index 85018f133cb..3fc554c5371 100644
--- a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue
+++ b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue
@@ -6,8 +6,12 @@ import { DEFAULT_PER_PAGE } from '~/api';
import { fetchOverrides } from '~/integrations/overrides/api';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { truncateNamespace } from '~/lib/utils/text_utility';
+import { getParameterByName } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
+import UrlSync from '~/vue_shared/components/url_sync.vue';
+
+const DEFAULT_PAGE = 1;
export default {
name: 'IntegrationOverrides',
@@ -18,6 +22,7 @@ export default {
GlTable,
GlAlert,
ProjectAvatar,
+ UrlSync,
},
props: {
overridesPath: {
@@ -35,7 +40,7 @@ export default {
return {
isLoading: true,
overrides: [],
- page: 1,
+ page: DEFAULT_PAGE,
totalItems: 0,
errorMessage: null,
};
@@ -44,12 +49,21 @@ export default {
showPagination() {
return this.totalItems > this.$options.DEFAULT_PER_PAGE && this.overrides.length > 0;
},
+ query() {
+ return {
+ page: this.page,
+ };
+ },
},
- mounted() {
- this.loadOverrides();
+ created() {
+ const initialPage = this.getInitialPage();
+ this.loadOverrides(initialPage);
},
methods: {
- loadOverrides(page = this.page) {
+ getInitialPage() {
+ return getParameterByName('page') ?? DEFAULT_PAGE;
+ },
+ loadOverrides(page) {
this.isLoading = true;
this.errorMessage = null;
@@ -119,14 +133,16 @@ export default {
</template>
</gl-table>
<div class="gl-display-flex gl-justify-content-center gl-mt-5">
- <gl-pagination
- v-if="showPagination"
- :per-page="$options.DEFAULT_PER_PAGE"
- :total-items="totalItems"
- :value="page"
- :disabled="isLoading"
- @input="loadOverrides"
- />
+ <template v-if="showPagination">
+ <gl-pagination
+ :per-page="$options.DEFAULT_PER_PAGE"
+ :total-items="totalItems"
+ :value="page"
+ :disabled="isLoading"
+ @input="loadOverrides"
+ />
+ <url-sync :query="query" />
+ </template>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
index cf4f434a7a8..91a139a5105 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -20,12 +20,11 @@ import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import { getParameterValues } from '~/lib/utils/url_utility';
import { sprintf } from '~/locale';
import {
- INVITE_MEMBERS_IN_COMMENT,
GROUP_FILTERS,
USERS_FILTER_ALL,
- MEMBER_AREAS_OF_FOCUS,
INVITE_MEMBERS_FOR_TASK,
MODAL_LABELS,
+ LEARN_GITLAB,
} from '../constants';
import eventHub from '../event_hub';
import {
@@ -100,14 +99,6 @@ export default {
type: String,
required: true,
},
- areasOfFocusOptions: {
- type: Array,
- required: true,
- },
- noSelectionAreasOfFocus: {
- type: Array,
- required: true,
- },
tasksToBeDoneOptions: {
type: Array,
required: true,
@@ -125,7 +116,6 @@ export default {
inviteeType: 'members',
newUsersToInvite: [],
selectedDate: undefined,
- selectedAreasOfFocus: [],
selectedTasksToBeDone: [],
selectedTaskProject: this.projects[0],
groupToBeSharedWith: {},
@@ -181,16 +171,6 @@ export default {
this.newUsersToInvite.length === 0 && Object.keys(this.groupToBeSharedWith).length === 0
);
},
- areasOfFocusEnabled() {
- return !this.tasksToBeDoneEnabled && this.areasOfFocusOptions.length !== 0;
- },
- areasOfFocusForPost() {
- if (this.selectedAreasOfFocus.length === 0 && this.areasOfFocusEnabled) {
- return this.noSelectionAreasOfFocus;
- }
-
- return this.selectedAreasOfFocus;
- },
errorFieldDescription() {
if (this.inviteeType === 'group') {
return '';
@@ -200,7 +180,8 @@ export default {
},
tasksToBeDoneEnabled() {
return (
- getParameterValues('open_modal')[0] === 'invite_members_for_task' &&
+ (getParameterValues('open_modal')[0] === 'invite_members_for_task' ||
+ this.isOnLearnGitlab) &&
this.tasksToBeDoneOptions.length
);
},
@@ -221,11 +202,16 @@ export default {
? this.selectedTaskProject.id
: '';
},
+ isOnLearnGitlab() {
+ return this.source === LEARN_GITLAB;
+ },
},
mounted() {
eventHub.$on('openModal', (options) => {
this.openModal(options);
- this.trackEvent(MEMBER_AREAS_OF_FOCUS.name, MEMBER_AREAS_OF_FOCUS.view);
+ if (this.isOnLearnGitlab) {
+ this.trackEvent(INVITE_MEMBERS_FOR_TASK.name, this.source);
+ }
});
if (this.tasksToBeDoneEnabled) {
@@ -267,13 +253,6 @@ export default {
this.submitInviteMembers();
}
},
- trackInvite() {
- if (this.source === INVITE_MEMBERS_IN_COMMENT) {
- this.trackEvent(INVITE_MEMBERS_IN_COMMENT, 'comment_invite_success');
- }
-
- this.trackEvent(MEMBER_AREAS_OF_FOCUS.name, MEMBER_AREAS_OF_FOCUS.submit);
- },
trackinviteMembersForTask() {
const label = 'selected_tasks_to_be_done';
const property = this.selectedTasksToBeDone.join(',');
@@ -287,7 +266,6 @@ export default {
this.newUsersToInvite = [];
this.groupToBeSharedWith = {};
this.invalidFeedbackMessage = '';
- this.selectedAreasOfFocus = [];
this.selectedTasksToBeDone = [];
[this.selectedTaskProject] = this.projects;
},
@@ -303,7 +281,7 @@ export default {
: Api.groupShareWithGroup.bind(Api);
apiShareWithGroup(this.id, this.shareWithGroupPostData(this.groupToBeSharedWith.id))
- .then(this.showToastMessageSuccess)
+ .then(this.showSuccessMessage)
.catch(this.showInvalidFeedbackMessage);
},
submitInviteMembers() {
@@ -328,11 +306,10 @@ export default {
promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById)));
}
- this.trackInvite();
this.trackinviteMembersForTask();
Promise.all(promises)
- .then(this.conditionallyShowToastSuccess)
+ .then(this.conditionallyShowSuccessMessage)
.catch(this.showInvalidFeedbackMessage);
},
inviteByEmailPostData(usersToInviteByEmail) {
@@ -341,7 +318,6 @@ export default {
email: usersToInviteByEmail,
access_level: this.selectedAccessLevel,
invite_source: this.source,
- areas_of_focus: this.areasOfFocusForPost,
tasks_to_be_done: this.tasksToBeDoneForPost,
tasks_project_id: this.tasksProjectForPost,
};
@@ -352,7 +328,6 @@ export default {
user_id: usersToAddById,
access_level: this.selectedAccessLevel,
invite_source: this.source,
- areas_of_focus: this.areasOfFocusForPost,
tasks_to_be_done: this.tasksToBeDoneForPost,
tasks_project_id: this.tasksProjectForPost,
};
@@ -364,11 +339,11 @@ export default {
group_access: this.selectedAccessLevel,
};
},
- conditionallyShowToastSuccess(response) {
+ conditionallyShowSuccessMessage(response) {
const message = this.unescapeMsg(responseMessageFromSuccess(response));
if (message === '') {
- this.showToastMessageSuccess();
+ this.showSuccessMessage();
return;
}
@@ -376,8 +351,12 @@ export default {
this.invalidFeedbackMessage = message;
this.isLoading = false;
},
- showToastMessageSuccess() {
- this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
+ showSuccessMessage() {
+ if (this.isOnLearnGitlab) {
+ eventHub.$emit('showSuccessfulInvitationsAlert');
+ } else {
+ this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
+ }
this.closeModal();
},
showInvalidFeedbackMessage(response) {
@@ -504,16 +483,6 @@ export default {
</template>
</gl-datepicker>
</div>
- <div v-if="areasOfFocusEnabled">
- <label class="gl-mt-5">
- {{ $options.labels.areasOfFocusLabel }}
- </label>
- <gl-form-checkbox-group
- v-model="selectedAreasOfFocus"
- :options="areasOfFocusOptions"
- data-testid="area-of-focus-checks"
- />
- </div>
<div v-if="showTasksToBeDone" data-testid="invite-members-modal-tasks-to-be-done">
<label class="gl-mt-5">
{{ $options.labels.members.tasksToBeDone.title }}
diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
index bf3250f63a5..7dd74f8803a 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
@@ -1,6 +1,5 @@
<script>
import { GlButton, GlLink, GlIcon } from '@gitlab/ui';
-import ExperimentTracking from '~/experimentation/experiment_tracking';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
import { TRIGGER_ELEMENT_BUTTON, TRIGGER_ELEMENT_SIDE_NAV } from '../constants';
@@ -32,11 +31,6 @@ export default {
type: String,
required: true,
},
- trackExperiment: {
- type: String,
- required: false,
- default: undefined,
- },
triggerElement: {
type: String,
required: false,
@@ -72,9 +66,6 @@ export default {
return baseAttributes;
},
},
- mounted() {
- this.trackExperimentOnShow();
- },
methods: {
checkTrigger(targetTriggerElement) {
return this.triggerElement === targetTriggerElement;
@@ -82,12 +73,6 @@ export default {
openModal() {
eventHub.$emit('openModal', { inviteeType: 'members', source: this.triggerSource });
},
- trackExperimentOnShow() {
- if (this.trackExperiment) {
- const tracking = new ExperimentTracking(this.trackExperiment);
- tracking.event('comment_invite_shown');
- }
- },
},
TRIGGER_ELEMENT_BUTTON,
TRIGGER_ELEMENT_SIDE_NAV,
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index 59d4c2f3077..ec59b3909fe 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -2,12 +2,6 @@ import { __, s__ } from '~/locale';
export const SEARCH_DELAY = 200;
-export const INVITE_MEMBERS_IN_COMMENT = 'invite_members_in_comment';
-export const MEMBER_AREAS_OF_FOCUS = {
- name: 'member_areas_of_focus',
- view: 'view',
- submit: 'submit',
-};
export const INVITE_MEMBERS_FOR_TASK = {
minimum_access_level: 30,
name: 'invite_members_for_task',
@@ -77,9 +71,6 @@ export const READ_MORE_TEXT = s__(
export const INVITE_BUTTON_TEXT = s__('InviteMembersModal|Invite');
export const CANCEL_BUTTON_TEXT = s__('InviteMembersModal|Cancel');
export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team members');
-export const AREAS_OF_FOCUS_LABEL = s__(
- 'InviteMembersModal|What would you like new member(s) to focus on? (optional)',
-);
export const MODAL_LABELS = {
members: {
@@ -142,5 +133,6 @@ export const MODAL_LABELS = {
inviteButtonText: INVITE_BUTTON_TEXT,
cancelButtonText: CANCEL_BUTTON_TEXT,
headerCloseLabel: HEADER_CLOSE_LABEL,
- areasOfFocusLabel: AREAS_OF_FOCUS_LABEL,
};
+
+export const LEARN_GITLAB = 'learn_gitlab';
diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js
index fc657a064dd..2cc056f2ddb 100644
--- a/app/assets/javascripts/invite_members/init_invite_members_modal.js
+++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js
@@ -40,10 +40,8 @@ export default function initInviteMembersModal() {
defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10),
groupSelectFilter: el.dataset.groupsFilter,
groupSelectParentId: parseInt(el.dataset.parentId, 10),
- areasOfFocusOptions: JSON.parse(el.dataset.areasOfFocusOptions),
tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions || '[]'),
projects: JSON.parse(el.dataset.projects || '[]'),
- noSelectionAreasOfFocus: JSON.parse(el.dataset.noSelectionAreasOfFocus),
usersFilter: el.dataset.usersFilter,
filterId: parseInt(el.dataset.filterId, 10),
},
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/components/status_select.vue b/app/assets/javascripts/issuable/bulk_update_sidebar/components/status_select.vue
index 9509399e91d..9509399e91d 100644
--- a/app/assets/javascripts/issuable_bulk_update_sidebar/components/status_select.vue
+++ b/app/assets/javascripts/issuable/bulk_update_sidebar/components/status_select.vue
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/constants.js b/app/assets/javascripts/issuable/bulk_update_sidebar/constants.js
index ad15b25f9cf..ad15b25f9cf 100644
--- a/app/assets/javascripts/issuable_bulk_update_sidebar/constants.js
+++ b/app/assets/javascripts/issuable/bulk_update_sidebar/constants.js
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/init_issue_status_select.js b/app/assets/javascripts/issuable/bulk_update_sidebar/init_issue_status_select.js
index 43179a86d70..43179a86d70 100644
--- a/app/assets/javascripts/issuable_bulk_update_sidebar/init_issue_status_select.js
+++ b/app/assets/javascripts/issuable/bulk_update_sidebar/init_issue_status_select.js
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_actions.js
index 463e0e5837e..14824820c0d 100644
--- a/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_actions.js
+++ b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_actions.js
@@ -115,7 +115,7 @@ export default {
});
// Add uniqueIds to add it as argument for _.intersection
labelIds.unshift(uniqueIds);
- // Return IDs that are present but not in all selected issueables
+ // Return IDs that are present but not in all selected issuables
return uniqueIds.filter((x) => !intersection.apply(this, labelIds).includes(x));
},
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js
index a9d4548f8cf..1eb3ffc9808 100644
--- a/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_sidebar.js
+++ b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js
@@ -3,9 +3,9 @@
import $ from 'jquery';
import { property } from 'lodash';
-import issueableEventHub from '~/issues_list/eventhub';
-import LabelsSelect from '~/labels_select';
-import MilestoneSelect from '~/milestone_select';
+import issuableEventHub from '~/issues_list/eventhub';
+import LabelsSelect from '~/labels/labels_select';
+import MilestoneSelect from '~/milestones/milestone_select';
import initIssueStatusSelect from './init_issue_status_select';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
import subscriptionSelect from './subscription_select';
@@ -50,8 +50,8 @@ export default class IssuableBulkUpdateSidebar {
// The event hub connects this bulk update logic with `issues_list_app.vue`.
// We can remove it once we've refactored the issues list page bulk edit sidebar to Vue.
// https://gitlab.com/gitlab-org/gitlab/-/issues/325874
- issueableEventHub.$on('issuables:enableBulkEdit', () => this.toggleBulkEdit(null, true));
- issueableEventHub.$on('issuables:updateBulkEdit', () => this.updateFormState());
+ issuableEventHub.$on('issuables:enableBulkEdit', () => this.toggleBulkEdit(null, true));
+ issuableEventHub.$on('issuables:updateBulkEdit', () => this.updateFormState());
}
initDropdowns() {
@@ -110,7 +110,7 @@ export default class IssuableBulkUpdateSidebar {
toggleBulkEdit(e, enable) {
e?.preventDefault();
- issueableEventHub.$emit('issuables:toggleBulkEdit', enable);
+ issuableEventHub.$emit('issuables:toggleBulkEdit', enable);
this.toggleSidebarDisplay(enable);
this.toggleBulkEditButtonDisabled(enable);
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar.js b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar.js
index 179c2b83c6c..179c2b83c6c 100644
--- a/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar.js
+++ b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar.js
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/subscription_select.js b/app/assets/javascripts/issuable/bulk_update_sidebar/subscription_select.js
index b12ac776b4f..b12ac776b4f 100644
--- a/app/assets/javascripts/issuable_bulk_update_sidebar/subscription_select.js
+++ b/app/assets/javascripts/issuable/bulk_update_sidebar/subscription_select.js
diff --git a/app/assets/javascripts/issuable/components/issuable_by_email.vue b/app/assets/javascripts/issuable/components/issuable_by_email.vue
index 799d2bdc9e2..512fa6f8c68 100644
--- a/app/assets/javascripts/issuable/components/issuable_by_email.vue
+++ b/app/assets/javascripts/issuable/components/issuable_by_email.vue
@@ -54,8 +54,7 @@ export default {
data() {
return {
email: this.initialEmail,
- // eslint-disable-next-line @gitlab/require-i18n-strings
- issuableName: this.issuableType === 'issue' ? 'issue' : 'merge request',
+ issuableName: this.issuableType === 'issue' ? __('issue') : __('merge request'),
};
},
computed: {
diff --git a/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
index 82223ab9ef4..82223ab9ef4 100644
--- a/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue
+++ b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/issuable/components/issue_assignees.vue
index 5955f31fc70..5955f31fc70 100644
--- a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
+++ b/app/assets/javascripts/issuable/components/issue_assignees.vue
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue b/app/assets/javascripts/issuable/components/issue_milestone.vue
index 6a0c21602bd..6a0c21602bd 100644
--- a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue
+++ b/app/assets/javascripts/issuable/components/issue_milestone.vue
diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue
index 8aeff9257a5..2bb0e3c80f9 100644
--- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
+++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue
@@ -9,8 +9,8 @@ import {
} from '@gitlab/ui';
import IssueDueDate from '~/boards/components/issue_due_date.vue';
import { sprintf } from '~/locale';
-import relatedIssuableMixin from '../../mixins/related_issuable_mixin';
-import CiIcon from '../ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import relatedIssuableMixin from '../mixins/related_issuable_mixin';
import IssueAssignees from './issue_assignees.vue';
import IssueMilestone from './issue_milestone.vue';
diff --git a/app/assets/javascripts/issuable/constants.js b/app/assets/javascripts/issuable/constants.js
index 9344f4a7c9a..5327f251fda 100644
--- a/app/assets/javascripts/issuable/constants.js
+++ b/app/assets/javascripts/issuable/constants.js
@@ -4,3 +4,8 @@ export const ISSUABLE_TYPE = {
issues: 'issues',
mergeRequests: 'merge-requests',
};
+
+export const ISSUABLE_INDEX = {
+ ISSUE: 'issue_',
+ MERGE_REQUEST: 'merge_request_',
+};
diff --git a/app/assets/javascripts/issuable/index.js b/app/assets/javascripts/issuable/index.js
new file mode 100644
index 00000000000..072422944f5
--- /dev/null
+++ b/app/assets/javascripts/issuable/index.js
@@ -0,0 +1,116 @@
+import { GlToast } from '@gitlab/ui';
+import Vue from 'vue';
+import IssuableContext from '~/issuable/issuable_context';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import Sidebar from '~/right_sidebar';
+import { getSidebarOptions } from '~/sidebar/mount_sidebar';
+import CsvImportExportButtons from './components/csv_import_export_buttons.vue';
+import IssuableByEmail from './components/issuable_by_email.vue';
+import IssuableHeaderWarnings from './components/issuable_header_warnings.vue';
+
+export function initCsvImportExportButtons() {
+ const el = document.querySelector('.js-csv-import-export-buttons');
+
+ if (!el) return null;
+
+ const {
+ showExportButton,
+ showImportButton,
+ issuableType,
+ issuableCount,
+ email,
+ exportCsvPath,
+ importCsvIssuesPath,
+ containerClass,
+ canEdit,
+ projectImportJiraPath,
+ maxAttachmentSize,
+ showLabel,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ provide: {
+ showExportButton: parseBoolean(showExportButton),
+ showImportButton: parseBoolean(showImportButton),
+ issuableType,
+ email,
+ importCsvIssuesPath,
+ containerClass,
+ canEdit: parseBoolean(canEdit),
+ projectImportJiraPath,
+ maxAttachmentSize,
+ showLabel,
+ },
+ render(h) {
+ return h(CsvImportExportButtons, {
+ props: {
+ exportCsvPath,
+ issuableCount: parseInt(issuableCount, 10),
+ },
+ });
+ },
+ });
+}
+
+export function initIssuableByEmail() {
+ Vue.use(GlToast);
+
+ const el = document.querySelector('.js-issuable-by-email');
+
+ if (!el) return null;
+
+ const {
+ initialEmail,
+ issuableType,
+ emailsHelpPagePath,
+ quickActionsHelpPath,
+ markdownHelpPath,
+ resetPath,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ provide: {
+ initialEmail,
+ issuableType,
+ emailsHelpPagePath,
+ quickActionsHelpPath,
+ markdownHelpPath,
+ resetPath,
+ },
+ render(h) {
+ return h(IssuableByEmail);
+ },
+ });
+}
+
+export function initIssuableHeaderWarnings(store) {
+ const el = document.getElementById('js-issuable-header-warnings');
+
+ if (!el) {
+ return false;
+ }
+
+ const { hidden } = el.dataset;
+
+ return new Vue({
+ el,
+ store,
+ provide: { hidden: parseBoolean(hidden) },
+ render(createElement) {
+ return createElement(IssuableHeaderWarnings);
+ },
+ });
+}
+
+export function initIssuableSidebar() {
+ const sidebarOptEl = document.querySelector('.js-sidebar-options');
+
+ if (!sidebarOptEl) return;
+
+ const sidebarOptions = getSidebarOptions(sidebarOptEl);
+
+ new IssuableContext(sidebarOptions.currentUser); // eslint-disable-line no-new
+ Sidebar.initialize();
+}
diff --git a/app/assets/javascripts/issuable/init_csv_import_export_buttons.js b/app/assets/javascripts/issuable/init_csv_import_export_buttons.js
deleted file mode 100644
index 83163e3c478..00000000000
--- a/app/assets/javascripts/issuable/init_csv_import_export_buttons.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import Vue from 'vue';
-import { parseBoolean } from '~/lib/utils/common_utils';
-import CsvImportExportButtons from './components/csv_import_export_buttons.vue';
-
-export default () => {
- const el = document.querySelector('.js-csv-import-export-buttons');
-
- if (!el) return null;
-
- const {
- showExportButton,
- showImportButton,
- issuableType,
- issuableCount,
- email,
- exportCsvPath,
- importCsvIssuesPath,
- containerClass,
- canEdit,
- projectImportJiraPath,
- maxAttachmentSize,
- showLabel,
- } = el.dataset;
-
- return new Vue({
- el,
- provide: {
- showExportButton: parseBoolean(showExportButton),
- showImportButton: parseBoolean(showImportButton),
- issuableType,
- email,
- importCsvIssuesPath,
- containerClass,
- canEdit: parseBoolean(canEdit),
- projectImportJiraPath,
- maxAttachmentSize,
- showLabel,
- },
- render(h) {
- return h(CsvImportExportButtons, {
- props: {
- exportCsvPath,
- issuableCount: parseInt(issuableCount, 10),
- },
- });
- },
- });
-};
diff --git a/app/assets/javascripts/issuable/init_issuable_by_email.js b/app/assets/javascripts/issuable/init_issuable_by_email.js
deleted file mode 100644
index 984b826234c..00000000000
--- a/app/assets/javascripts/issuable/init_issuable_by_email.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import { GlToast } from '@gitlab/ui';
-import Vue from 'vue';
-import IssuableByEmail from './components/issuable_by_email.vue';
-
-Vue.use(GlToast);
-
-export default () => {
- const el = document.querySelector('.js-issueable-by-email');
-
- if (!el) return null;
-
- const {
- initialEmail,
- issuableType,
- emailsHelpPagePath,
- quickActionsHelpPath,
- markdownHelpPath,
- resetPath,
- } = el.dataset;
-
- return new Vue({
- el,
- provide: {
- initialEmail,
- issuableType,
- emailsHelpPagePath,
- quickActionsHelpPath,
- markdownHelpPath,
- resetPath,
- },
- render(h) {
- return h(IssuableByEmail);
- },
- });
-};
diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable/issuable_context.js
index 51b5237a339..453305dd6e0 100644
--- a/app/assets/javascripts/issuable_context.js
+++ b/app/assets/javascripts/issuable/issuable_context.js
@@ -1,8 +1,8 @@
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import $ from 'jquery';
import Cookies from 'js-cookie';
-import { loadCSSFile } from './lib/utils/css_utils';
-import UsersSelect from './users_select';
+import { loadCSSFile } from '~/lib/utils/css_utils';
+import UsersSelect from '~/users_select';
export default class IssuableContext {
constructor(currentUser) {
diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js
index bafc26befda..91f47a86cb7 100644
--- a/app/assets/javascripts/issuable_form.js
+++ b/app/assets/javascripts/issuable/issuable_form.js
@@ -1,14 +1,14 @@
import $ from 'jquery';
import Pikaday from 'pikaday';
import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
-import Autosave from './autosave';
-import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select';
-import { loadCSSFile } from './lib/utils/css_utils';
-import { parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility';
-import { select2AxiosTransport } from './lib/utils/select2_utils';
-import { queryToObject, objectToQuery } from './lib/utils/url_utility';
-import UsersSelect from './users_select';
-import ZenMode from './zen_mode';
+import Autosave from '~/autosave';
+import AutoWidthDropdownSelect from '~/issuable/auto_width_dropdown_select';
+import { loadCSSFile } from '~/lib/utils/css_utils';
+import { parsePikadayDate, pikadayToString } from '~/lib/utils/datetime_utility';
+import { select2AxiosTransport } from '~/lib/utils/select2_utils';
+import { queryToObject, objectToQuery } from '~/lib/utils/url_utility';
+import UsersSelect from '~/users_select';
+import ZenMode from '~/zen_mode';
const MR_SOURCE_BRANCH = 'merge_request[source_branch]';
const MR_TARGET_BRANCH = 'merge_request[target_branch]';
diff --git a/app/assets/javascripts/templates/issuable_template_selector.js b/app/assets/javascripts/issuable/issuable_template_selector.js
index 1bb5e214c2e..cce903d388d 100644
--- a/app/assets/javascripts/templates/issuable_template_selector.js
+++ b/app/assets/javascripts/issuable/issuable_template_selector.js
@@ -1,9 +1,7 @@
-/* eslint-disable no-useless-return */
-
import $ from 'jquery';
+import TemplateSelector from '~/blob/template_selector';
import { __ } from '~/locale';
import Api from '../api';
-import TemplateSelector from '../blob/template_selector';
export default class IssuableTemplateSelector extends TemplateSelector {
constructor(...args) {
@@ -109,7 +107,5 @@ export default class IssuableTemplateSelector extends TemplateSelector {
} else {
this.setEditorContent(this.currentTemplate, { skipFocus: false });
}
-
- return;
}
}
diff --git a/app/assets/javascripts/templates/issuable_template_selectors.js b/app/assets/javascripts/issuable/issuable_template_selectors.js
index 443b3084113..92f825e55d3 100644
--- a/app/assets/javascripts/templates/issuable_template_selectors.js
+++ b/app/assets/javascripts/issuable/issuable_template_selectors.js
@@ -1,5 +1,3 @@
-/* eslint-disable no-new, class-methods-use-this */
-
import $ from 'jquery';
import IssuableTemplateSelector from './issuable_template_selector';
@@ -10,6 +8,8 @@ export default class IssuableTemplateSelectors {
this.$dropdowns.each((i, dropdown) => {
const $dropdown = $(dropdown);
+
+ // eslint-disable-next-line no-new
new IssuableTemplateSelector({
pattern: /(\.md)/,
data: $dropdown.data('data'),
@@ -21,6 +21,7 @@ export default class IssuableTemplateSelectors {
});
}
+ // eslint-disable-next-line class-methods-use-this
initEditor() {
const editor = $('.markdown-area');
// Proxy ace-editor's .setValue to jQuery's .val
diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js
index 4a6edae0c06..4a6edae0c06 100644
--- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
+++ b/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js
diff --git a/app/assets/javascripts/issuable_index.js b/app/assets/javascripts/issuable_index.js
deleted file mode 100644
index 5a57da292a0..00000000000
--- a/app/assets/javascripts/issuable_index.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import issuableInitBulkUpdateSidebar from '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar';
-
-export default class IssuableIndex {
- constructor(pagePrefix = 'issuable_') {
- issuableInitBulkUpdateSidebar.init(pagePrefix);
- }
-}
diff --git a/app/assets/javascripts/issuable_type_selector/index.js b/app/assets/javascripts/issuable_type_selector/index.js
deleted file mode 100644
index 433a62d1ae8..00000000000
--- a/app/assets/javascripts/issuable_type_selector/index.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import Vue from 'vue';
-import InfoPopover from './components/info_popover.vue';
-
-export default function initIssuableTypeSelector() {
- const el = document.getElementById('js-type-popover');
-
- return new Vue({
- el,
- components: {
- InfoPopover,
- },
- render(h) {
- return h(InfoPopover);
- },
- });
-}
diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js
new file mode 100644
index 00000000000..b7b123dfd5f
--- /dev/null
+++ b/app/assets/javascripts/issues/constants.js
@@ -0,0 +1,25 @@
+import { __ } from '~/locale';
+
+export const IssuableStatus = {
+ Closed: 'closed',
+ Open: 'opened',
+ Reopened: 'reopened',
+};
+
+export const IssuableStatusText = {
+ [IssuableStatus.Closed]: __('Closed'),
+ [IssuableStatus.Open]: __('Open'),
+ [IssuableStatus.Reopened]: __('Open'),
+};
+
+export const IssuableType = {
+ Issue: 'issue',
+ Epic: 'epic',
+ MergeRequest: 'merge_request',
+ Alert: 'alert',
+};
+
+export const WorkspaceType = {
+ project: 'project',
+ group: 'group',
+};
diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js b/app/assets/javascripts/issues/filtered_search_service_desk.js
index bec207aa439..bec207aa439 100644
--- a/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js
+++ b/app/assets/javascripts/issues/filtered_search_service_desk.js
diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/issues/form.js
index c0da0069a99..33371d065f9 100644
--- a/app/assets/javascripts/pages/projects/issues/form.js
+++ b/app/assets/javascripts/issues/form.js
@@ -1,14 +1,13 @@
/* eslint-disable no-new */
import $ from 'jquery';
-import IssuableForm from 'ee_else_ce/issuable_form';
+import IssuableForm from 'ee_else_ce/issuable/issuable_form';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import GLForm from '~/gl_form';
-import initSuggestions from '~/issuable_suggestions';
-import initIssuableTypeSelector from '~/issuable_type_selector';
-import LabelsSelect from '~/labels_select';
-import MilestoneSelect from '~/milestone_select';
-import IssuableTemplateSelectors from '~/templates/issuable_template_selectors';
+import { initTitleSuggestions, initTypePopover } from '~/issues/new';
+import LabelsSelect from '~/labels/labels_select';
+import MilestoneSelect from '~/milestones/milestone_select';
+import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
export default () => {
new ShortcutsNavigation();
@@ -20,6 +19,6 @@ export default () => {
warnTemplateOverride: true,
});
- initSuggestions();
- initIssuableTypeSelector();
+ initTitleSuggestions();
+ initTypePopover();
};
diff --git a/app/assets/javascripts/issues/init_filtered_search_service_desk.js b/app/assets/javascripts/issues/init_filtered_search_service_desk.js
new file mode 100644
index 00000000000..1901802c11c
--- /dev/null
+++ b/app/assets/javascripts/issues/init_filtered_search_service_desk.js
@@ -0,0 +1,11 @@
+import FilteredSearchServiceDesk from './filtered_search_service_desk';
+
+export function initFilteredSearchServiceDesk() {
+ if (document.querySelector('.filtered-search')) {
+ const supportBotData = JSON.parse(
+ document.querySelector('.js-service-desk-issues').dataset.supportBot,
+ );
+ const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData);
+ filteredSearchManager.setup();
+ }
+}
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issues/issue.js
index 1e053d7daaa..c471875654b 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issues/issue.js
@@ -1,11 +1,11 @@
import $ from 'jquery';
import { joinPaths } from '~/lib/utils/url_utility';
-import CreateMergeRequestDropdown from './create_merge_request_dropdown';
-import createFlash from './flash';
-import { EVENT_ISSUABLE_VUE_APP_CHANGE } from './issuable/constants';
-import axios from './lib/utils/axios_utils';
-import { addDelimiter } from './lib/utils/text_utility';
-import { __ } from './locale';
+import CreateMergeRequestDropdown from '~/create_merge_request_dropdown';
+import createFlash from '~/flash';
+import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
+import axios from '~/lib/utils/axios_utils';
+import { addDelimiter } from '~/lib/utils/text_utility';
+import { __ } from '~/locale';
export default class Issue {
constructor() {
diff --git a/app/assets/javascripts/manual_ordering.js b/app/assets/javascripts/issues/manual_ordering.js
index 9613246d6a6..9613246d6a6 100644
--- a/app/assets/javascripts/manual_ordering.js
+++ b/app/assets/javascripts/issues/manual_ordering.js
diff --git a/app/assets/javascripts/issuable_suggestions/components/app.vue b/app/assets/javascripts/issues/new/components/title_suggestions.vue
index 48a5e220abf..0a9cdb12519 100644
--- a/app/assets/javascripts/issuable_suggestions/components/app.vue
+++ b/app/assets/javascripts/issues/new/components/title_suggestions.vue
@@ -2,12 +2,12 @@
import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import query from '../queries/issues.query.graphql';
-import Suggestion from './item.vue';
+import TitleSuggestionsItem from './title_suggestions_item.vue';
export default {
components: {
- Suggestion,
GlIcon,
+ TitleSuggestionsItem,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -66,7 +66,7 @@ export default {
</script>
<template>
- <div v-show="showSuggestions" class="form-group row issuable-suggestions">
+ <div v-show="showSuggestions" class="form-group row">
<div v-once class="col-form-label col-sm-2 pt-0">
{{ __('Similar issues') }}
<gl-icon
@@ -86,7 +86,7 @@ export default {
'gl-mb-3': index !== issues.length - 1,
}"
>
- <suggestion :suggestion="suggestion" />
+ <title-suggestions-item :suggestion="suggestion" />
</li>
</ul>
</div>
diff --git a/app/assets/javascripts/issuable_suggestions/components/item.vue b/app/assets/javascripts/issues/new/components/title_suggestions_item.vue
index a01f4f747b9..a01f4f747b9 100644
--- a/app/assets/javascripts/issuable_suggestions/components/item.vue
+++ b/app/assets/javascripts/issues/new/components/title_suggestions_item.vue
diff --git a/app/assets/javascripts/issuable_type_selector/components/info_popover.vue b/app/assets/javascripts/issues/new/components/type_popover.vue
index 3a20ccba814..a70e79b70f9 100644
--- a/app/assets/javascripts/issuable_type_selector/components/info_popover.vue
+++ b/app/assets/javascripts/issues/new/components/type_popover.vue
@@ -19,9 +19,9 @@ export default {
<template>
<span id="popovercontainer">
- <gl-icon id="issuable-type-info" name="question-o" class="gl-ml-5 gl-text-gray-500" />
+ <gl-icon id="issue-type-info" name="question-o" class="gl-ml-5 gl-text-gray-500" />
<gl-popover
- target="issuable-type-info"
+ target="issue-type-info"
container="popovercontainer"
:title="$options.i18n.issueTypes"
triggers="focus hover"
diff --git a/app/assets/javascripts/issuable_suggestions/index.js b/app/assets/javascripts/issues/new/index.js
index 8f7f317d6b4..59a7cbec627 100644
--- a/app/assets/javascripts/issuable_suggestions/index.js
+++ b/app/assets/javascripts/issues/new/index.js
@@ -1,14 +1,19 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import App from './components/app.vue';
+import TitleSuggestions from './components/title_suggestions.vue';
+import TypePopover from './components/type_popover.vue';
-Vue.use(VueApollo);
+export function initTitleSuggestions() {
+ Vue.use(VueApollo);
-export default function initIssuableSuggestions() {
const el = document.getElementById('js-suggestions');
const issueTitle = document.getElementById('issue_title');
- const { projectPath } = el.dataset;
+
+ if (!el) {
+ return undefined;
+ }
+
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
@@ -26,13 +31,26 @@ export default function initIssuableSuggestions() {
this.search = issueTitle.value;
});
},
- render(h) {
- return h(App, {
+ render(createElement) {
+ return createElement(TitleSuggestions, {
props: {
- projectPath,
+ projectPath: el.dataset.projectPath,
search: this.search,
},
});
},
});
}
+
+export function initTypePopover() {
+ const el = document.getElementById('js-type-popover');
+
+ if (!el) {
+ return undefined;
+ }
+
+ return new Vue({
+ el,
+ render: (createElement) => createElement(TypePopover),
+ });
+}
diff --git a/app/assets/javascripts/issuable_suggestions/queries/issues.query.graphql b/app/assets/javascripts/issues/new/queries/issues.query.graphql
index 2384b381344..dc0757b141f 100644
--- a/app/assets/javascripts/issuable_suggestions/queries/issues.query.graphql
+++ b/app/assets/javascripts/issues/new/queries/issues.query.graphql
@@ -1,8 +1,10 @@
query issueSuggestion($fullPath: ID!, $search: String) {
project(fullPath: $fullPath) {
+ id
issues(search: $search, sort: updated_desc, first: 5) {
edges {
node {
+ id
iid
title
confidential
@@ -14,6 +16,7 @@ query issueSuggestion($fullPath: ID!, $search: String) {
createdAt
updatedAt
author {
+ id
name
username
avatarUrl
diff --git a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
index 50835142d28..1d48446b083 100644
--- a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
+++ b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
@@ -2,8 +2,8 @@
import { GlLink, GlLoadingIcon, GlIcon } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { sprintf, __, n__ } from '~/locale';
-import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue';
-import { parseIssuableData } from '../../issue_show/utils/parse_data';
+import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue';
+import { parseIssuableData } from '~/issues/show/utils/parse_data';
export default {
name: 'RelatedMergeRequests',
diff --git a/app/assets/javascripts/related_merge_requests/index.js b/app/assets/javascripts/issues/related_merge_requests/index.js
index ce33cf7df1d..ce33cf7df1d 100644
--- a/app/assets/javascripts/related_merge_requests/index.js
+++ b/app/assets/javascripts/issues/related_merge_requests/index.js
diff --git a/app/assets/javascripts/related_merge_requests/store/actions.js b/app/assets/javascripts/issues/related_merge_requests/store/actions.js
index 94abb50de89..94abb50de89 100644
--- a/app/assets/javascripts/related_merge_requests/store/actions.js
+++ b/app/assets/javascripts/issues/related_merge_requests/store/actions.js
diff --git a/app/assets/javascripts/related_merge_requests/store/index.js b/app/assets/javascripts/issues/related_merge_requests/store/index.js
index 925cc36cd76..925cc36cd76 100644
--- a/app/assets/javascripts/related_merge_requests/store/index.js
+++ b/app/assets/javascripts/issues/related_merge_requests/store/index.js
diff --git a/app/assets/javascripts/related_merge_requests/store/mutation_types.js b/app/assets/javascripts/issues/related_merge_requests/store/mutation_types.js
index 31d4fe032e1..31d4fe032e1 100644
--- a/app/assets/javascripts/related_merge_requests/store/mutation_types.js
+++ b/app/assets/javascripts/issues/related_merge_requests/store/mutation_types.js
diff --git a/app/assets/javascripts/related_merge_requests/store/mutations.js b/app/assets/javascripts/issues/related_merge_requests/store/mutations.js
index 11ca28a5fb9..11ca28a5fb9 100644
--- a/app/assets/javascripts/related_merge_requests/store/mutations.js
+++ b/app/assets/javascripts/issues/related_merge_requests/store/mutations.js
diff --git a/app/assets/javascripts/related_merge_requests/store/state.js b/app/assets/javascripts/issues/related_merge_requests/store/state.js
index bc3468a025b..bc3468a025b 100644
--- a/app/assets/javascripts/related_merge_requests/store/state.js
+++ b/app/assets/javascripts/issues/related_merge_requests/store/state.js
diff --git a/app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue b/app/assets/javascripts/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue
index 1530e9a15b5..1530e9a15b5 100644
--- a/app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue
+++ b/app/assets/javascripts/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue
diff --git a/app/assets/javascripts/sentry_error_stack_trace/index.js b/app/assets/javascripts/issues/sentry_error_stack_trace/index.js
index 8e9ee25e7a8..8e9ee25e7a8 100644
--- a/app/assets/javascripts/sentry_error_stack_trace/index.js
+++ b/app/assets/javascripts/issues/sentry_error_stack_trace/index.js
diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/issues/show.js
index 24aa2f0da13..e43e56d7b4e 100644
--- a/app/assets/javascripts/pages/projects/issues/show.js
+++ b/app/assets/javascripts/issues/show.js
@@ -1,16 +1,15 @@
import loadAwardsHandler from '~/awards_handler';
import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
-import initIssuableSidebar from '~/init_issuable_sidebar';
-import { IssuableType } from '~/issuable_show/constants';
-import Issue from '~/issue';
-import { initIncidentApp, initIncidentHeaderActions } from '~/issue_show/incident';
-import { initIssuableApp, initIssueHeaderActions } from '~/issue_show/issue';
-import { parseIssuableData } from '~/issue_show/utils/parse_data';
+import { initIssuableHeaderWarnings, initIssuableSidebar } from '~/issuable';
+import { IssuableType } from '~/vue_shared/issuable/show/constants';
+import Issue from '~/issues/issue';
+import { initIncidentApp, initIncidentHeaderActions } from '~/issues/show/incident';
+import { initIssuableApp, initIssueHeaderActions } from '~/issues/show/issue';
+import { parseIssuableData } from '~/issues/show/utils/parse_data';
import initNotesApp from '~/notes';
import { store } from '~/notes/stores';
-import initRelatedMergeRequestsApp from '~/related_merge_requests';
-import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace';
-import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning';
+import initRelatedMergeRequestsApp from '~/issues/related_merge_requests';
+import initSentryErrorStackTraceApp from '~/issues/sentry_error_stack_trace';
import ZenMode from '~/zen_mode';
export default function initShowIssue() {
@@ -33,7 +32,7 @@ export default function initShowIssue() {
break;
}
- initIssuableHeaderWarning(store);
+ initIssuableHeaderWarnings(store);
initSentryErrorStackTraceApp();
initRelatedMergeRequestsApp();
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue
index d3b58ed3012..eeaf865a35f 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issues/show/components/app.vue
@@ -2,18 +2,11 @@
import { GlIcon, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui';
import Visibility from 'visibilityjs';
import createFlash from '~/flash';
+import { IssuableStatus, IssuableStatusText, IssuableType } from '~/issues/constants';
import Poll from '~/lib/utils/poll';
import { visitUrl } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
-import {
- IssuableStatus,
- IssuableStatusText,
- IssuableType,
- IssueTypePath,
- IncidentTypePath,
- IncidentType,
- POLLING_DELAY,
-} from '../constants';
+import { IssueTypePath, IncidentTypePath, IncidentType, POLLING_DELAY } from '../constants';
import eventHub from '../event_hub';
import getIssueStateQuery from '../queries/get_issue_state.query.graphql';
import Service from '../services/index';
@@ -296,13 +289,11 @@ export default {
window.addEventListener('beforeunload', this.handleBeforeUnloadEvent);
- eventHub.$on('delete.issuable', this.deleteIssuable);
eventHub.$on('update.issuable', this.updateIssuable);
eventHub.$on('close.form', this.closeForm);
eventHub.$on('open.form', this.openForm);
},
beforeDestroy() {
- eventHub.$off('delete.issuable', this.deleteIssuable);
eventHub.$off('update.issuable', this.updateIssuable);
eventHub.$off('close.form', this.closeForm);
eventHub.$off('open.form', this.openForm);
@@ -425,25 +416,6 @@ export default {
});
},
- deleteIssuable(payload) {
- return this.service
- .deleteIssuable(payload)
- .then((res) => res.data)
- .then((data) => {
- // Stop the poll so we don't get 404's with the issuable not existing
- this.poll.stop();
-
- visitUrl(data.web_url);
- })
- .catch(() => {
- createFlash({
- message: sprintf(__('Error deleting %{issuableType}'), {
- issuableType: this.issuableType,
- }),
- });
- });
- },
-
hideStickyHeader() {
this.isStickyHeaderShowing = false;
},
@@ -482,6 +454,7 @@ export default {
<div>
<div v-if="canUpdate && showForm">
<form-component
+ :endpoint="endpoint"
:form-state="formState"
:initial-description-text="initialDescriptionText"
:can-destroy="canDestroy"
diff --git a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue
new file mode 100644
index 00000000000..26862346b86
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue
@@ -0,0 +1,71 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+import csrf from '~/lib/utils/csrf';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+import { __, sprintf } from '~/locale';
+
+export default {
+ actionCancel: { text: __('Cancel') },
+ csrf,
+ components: {
+ GlModal,
+ },
+ props: {
+ issuePath: {
+ type: String,
+ required: true,
+ },
+ issueType: {
+ type: String,
+ required: true,
+ },
+ modalId: {
+ type: String,
+ required: true,
+ },
+ title: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ actionPrimary() {
+ return {
+ attributes: { variant: 'danger' },
+ text: this.title,
+ };
+ },
+ bodyText() {
+ return this.issueType.toLowerCase() === 'epic'
+ ? __('Delete this epic and all descendants?')
+ : sprintf(__('%{issuableType} will be removed! Are you sure?'), {
+ issuableType: capitalizeFirstCharacter(this.issueType),
+ });
+ },
+ },
+ methods: {
+ submitForm() {
+ this.$emit('delete');
+ this.$refs.form.submit();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ :action-cancel="$options.actionCancel"
+ :action-primary="actionPrimary"
+ :modal-id="modalId"
+ size="sm"
+ :title="title"
+ @primary="submitForm"
+ >
+ <form ref="form" :action="issuePath" method="post">
+ <input type="hidden" name="_method" value="delete" />
+ <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
+ <input type="hidden" name="destroy_confirm" value="true" />
+ {{ bodyText }}
+ </form>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue
index 9dc122d426c..7be4c13f544 100644
--- a/app/assets/javascripts/issue_show/components/description.vue
+++ b/app/assets/javascripts/issues/show/components/description.vue
@@ -3,7 +3,7 @@ import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import $ from 'jquery';
import createFlash from '~/flash';
import { __, sprintf } from '~/locale';
-import TaskList from '../../task_list';
+import TaskList from '~/task_list';
import animateMixin from '../mixins/animate';
export default {
@@ -133,7 +133,7 @@ export default {
}
},
},
- safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji', 'copy-code'] },
};
</script>
diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issues/show/components/edit_actions.vue
index 5b7d232fde7..4daf6f2b61b 100644
--- a/app/assets/javascripts/issue_show/components/edit_actions.vue
+++ b/app/assets/javascripts/issues/show/components/edit_actions.vue
@@ -1,10 +1,12 @@
<script>
-import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui';
+import { GlButton, GlModalDirective } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { __, sprintf } from '~/locale';
+import Tracking from '~/tracking';
import eventHub from '../event_hub';
import updateMixin from '../mixins/update';
import getIssueStateQuery from '../queries/get_issue_state.query.graphql';
+import DeleteIssueModal from './delete_issue_modal.vue';
const issuableTypes = {
issue: __('Issue'),
@@ -12,20 +14,26 @@ const issuableTypes = {
incident: __('Incident'),
};
+const trackingMixin = Tracking.mixin({ label: 'delete_issue' });
+
export default {
components: {
+ DeleteIssueModal,
GlButton,
- GlModal,
},
directives: {
GlModal: GlModalDirective,
},
- mixins: [updateMixin],
+ mixins: [trackingMixin, updateMixin],
props: {
canDestroy: {
type: Boolean,
required: true,
},
+ endpoint: {
+ required: true,
+ type: String,
+ },
formState: {
type: Object,
required: true,
@@ -65,27 +73,9 @@ export default {
issuableType: this.typeToShow.toLowerCase(),
});
},
- deleteIssuableModalText() {
- return this.issuableType === 'epic'
- ? __('Delete this epic and all descendants?')
- : sprintf(__('%{issuableType} will be removed! Are you sure?'), {
- issuableType: this.typeToShow,
- });
- },
isSubmitEnabled() {
return this.formState.title.trim() !== '';
},
- modalActionProps() {
- return {
- primary: {
- text: this.deleteIssuableButtonText,
- attributes: [{ variant: 'danger' }, { loading: this.deleteLoading }],
- },
- cancel: {
- text: __('Cancel'),
- },
- };
- },
shouldShowDeleteButton() {
return this.canDestroy && this.showDeleteButton;
},
@@ -101,7 +91,7 @@ export default {
},
deleteIssuable() {
this.deleteLoading = true;
- eventHub.$emit('delete.issuable', { destroy_confirm: true });
+ eventHub.$emit('delete.issuable');
},
},
};
@@ -135,22 +125,17 @@ export default {
variant="danger"
class="qa-delete-button"
data-testid="issuable-delete-button"
+ @click="track('click_button')"
>
{{ deleteIssuableButtonText }}
</gl-button>
- <gl-modal
- ref="removeModal"
+ <delete-issue-modal
+ :issue-path="endpoint"
+ :issue-type="typeToShow"
:modal-id="modalId"
- size="sm"
- :action-primary="modalActionProps.primary"
- :action-cancel="modalActionProps.cancel"
- @primary="deleteIssuable"
- >
- <template #modal-title>{{ deleteIssuableButtonText }}</template>
- <div>
- <p class="gl-mb-1">{{ deleteIssuableModalText }}</p>
- </div>
- </gl-modal>
+ :title="deleteIssuableButtonText"
+ @delete="deleteIssuable"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/issue_show/components/edited.vue b/app/assets/javascripts/issues/show/components/edited.vue
index 64f61a1b88e..0da1900a6d0 100644
--- a/app/assets/javascripts/issue_show/components/edited.vue
+++ b/app/assets/javascripts/issues/show/components/edited.vue
@@ -1,6 +1,6 @@
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
-import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
+import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
components: {
diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue
index 5476a1ef897..5476a1ef897 100644
--- a/app/assets/javascripts/issue_show/components/fields/description.vue
+++ b/app/assets/javascripts/issues/show/components/fields/description.vue
diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issues/show/components/fields/description_template.vue
index 35e7860cd9b..9ce49b65a1a 100644
--- a/app/assets/javascripts/issue_show/components/fields/description_template.vue
+++ b/app/assets/javascripts/issues/show/components/fields/description_template.vue
@@ -1,7 +1,7 @@
<script>
import { GlIcon } from '@gitlab/ui';
import $ from 'jquery';
-import IssuableTemplateSelectors from '../../../templates/issuable_template_selectors';
+import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
export default {
components: {
diff --git a/app/assets/javascripts/issue_show/components/fields/title.vue b/app/assets/javascripts/issues/show/components/fields/title.vue
index a73926575d0..a73926575d0 100644
--- a/app/assets/javascripts/issue_show/components/fields/title.vue
+++ b/app/assets/javascripts/issues/show/components/fields/title.vue
diff --git a/app/assets/javascripts/issue_show/components/fields/type.vue b/app/assets/javascripts/issues/show/components/fields/type.vue
index 9110a6924b4..9110a6924b4 100644
--- a/app/assets/javascripts/issue_show/components/fields/type.vue
+++ b/app/assets/javascripts/issues/show/components/fields/type.vue
diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue
index 001e8abb941..6447ec85b4e 100644
--- a/app/assets/javascripts/issue_show/components/form.vue
+++ b/app/assets/javascripts/issues/show/components/form.vue
@@ -2,7 +2,7 @@
import { GlAlert } from '@gitlab/ui';
import $ from 'jquery';
import Autosave from '~/autosave';
-import { IssuableType } from '~/issue_show/constants';
+import { IssuableType } from '~/issues/constants';
import eventHub from '../event_hub';
import EditActions from './edit_actions.vue';
import DescriptionField from './fields/description.vue';
@@ -26,6 +26,10 @@ export default {
type: Boolean,
required: true,
},
+ endpoint: {
+ type: String,
+ required: true,
+ },
formState: {
type: Object,
required: true,
@@ -213,6 +217,7 @@ export default {
:enable-autocomplete="enableAutocomplete"
/>
<edit-actions
+ :endpoint="endpoint"
:form-state="formState"
:can-destroy="canDestroy"
:show-delete-button="showDeleteButton"
diff --git a/app/assets/javascripts/issue_show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue
index 2c314ce1c3f..700ef92a0f3 100644
--- a/app/assets/javascripts/issue_show/components/header_actions.vue
+++ b/app/assets/javascripts/issues/show/components/header_actions.vue
@@ -1,31 +1,38 @@
<script>
-import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlModal } from '@gitlab/ui';
+import {
+ GlButton,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlLink,
+ GlModal,
+ GlModalDirective,
+} from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import createFlash, { FLASH_TYPES } from '~/flash';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
-import { IssuableType } from '~/issuable_show/constants';
-import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants';
+import { IssuableType } from '~/vue_shared/issuable/show/constants';
+import { IssuableStatus } from '~/issues/constants';
+import { IssueStateEvent } from '~/issues/show/constants';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { visitUrl } from '~/lib/utils/url_utility';
-import { __, sprintf } from '~/locale';
+import { s__, __, sprintf } from '~/locale';
import eventHub from '~/notes/event_hub';
+import Tracking from '~/tracking';
import promoteToEpicMutation from '../queries/promote_to_epic.mutation.graphql';
import updateIssueMutation from '../queries/update_issue.mutation.graphql';
+import DeleteIssueModal from './delete_issue_modal.vue';
+
+const trackingMixin = Tracking.mixin({ label: 'delete_issue' });
export default {
- components: {
- GlButton,
- GlDropdown,
- GlDropdownItem,
- GlLink,
- GlModal,
- },
actionCancel: {
text: __('Cancel'),
},
actionPrimary: {
text: __('Yes, close issue'),
},
+ deleteModalId: 'delete-modal-id',
i18n: {
promoteErrorMessage: __(
'Something went wrong while promoting the issue to an epic. Please try again.',
@@ -34,10 +41,26 @@ export default {
'The issue was successfully promoted to an epic. Redirecting to epic...',
),
},
+ components: {
+ DeleteIssueModal,
+ GlButton,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlLink,
+ GlModal,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ mixins: [trackingMixin],
inject: {
canCreateIssue: {
default: false,
},
+ canDestroyIssue: {
+ default: false,
+ },
canPromoteToEpic: {
default: false,
},
@@ -56,6 +79,9 @@ export default {
isIssueAuthor: {
default: false,
},
+ issuePath: {
+ default: '',
+ },
issueType: {
default: IssuableType.Issue,
},
@@ -78,10 +104,21 @@ export default {
isClosed() {
return this.openState === IssuableStatus.Closed;
},
+ issueTypeText() {
+ const issueTypeTexts = {
+ [IssuableType.Issue]: s__('HeaderAction|issue'),
+ [IssuableType.Incident]: s__('HeaderAction|incident'),
+ };
+
+ return issueTypeTexts[this.issueType] ?? this.issueType;
+ },
buttonText() {
return this.isClosed
- ? sprintf(__('Reopen %{issueType}'), { issueType: this.issueType })
- : sprintf(__('Close %{issueType}'), { issueType: this.issueType });
+ ? sprintf(__('Reopen %{issueType}'), { issueType: this.issueTypeText })
+ : sprintf(__('Close %{issueType}'), { issueType: this.issueTypeText });
+ },
+ deleteButtonText() {
+ return sprintf(__('Delete %{issuableType}'), { issuableType: this.issueTypeText });
},
qaSelector() {
return this.isClosed ? 'reopen_issue_button' : 'close_issue_button';
@@ -132,8 +169,7 @@ export default {
})
.then(({ data }) => {
if (data.updateIssue.errors.length) {
- createFlash({ message: data.updateIssue.errors.join('. ') });
- return;
+ throw new Error();
}
const payload = {
@@ -166,8 +202,7 @@ export default {
})
.then(({ data }) => {
if (data.promoteToEpic.errors.length) {
- createFlash({ message: data.promoteToEpic.errors.join('; ') });
- return;
+ throw new Error();
}
createFlash({
@@ -219,6 +254,16 @@ export default {
>
{{ __('Submit as spam') }}
</gl-dropdown-item>
+ <template v-if="canDestroyIssue">
+ <gl-dropdown-divider />
+ <gl-dropdown-item
+ v-gl-modal="$options.deleteModalId"
+ variant="danger"
+ @click="track('click_dropdown')"
+ >
+ {{ deleteButtonText }}
+ </gl-dropdown-item>
+ </template>
</gl-dropdown>
<gl-button
@@ -262,6 +307,16 @@ export default {
>
{{ __('Submit as spam') }}
</gl-dropdown-item>
+ <template v-if="canDestroyIssue">
+ <gl-dropdown-divider />
+ <gl-dropdown-item
+ v-gl-modal="$options.deleteModalId"
+ variant="danger"
+ @click="track('click_dropdown')"
+ >
+ {{ deleteButtonText }}
+ </gl-dropdown-item>
+ </template>
</gl-dropdown>
<gl-modal
@@ -279,5 +334,12 @@ export default {
</li>
</ul>
</gl-modal>
+
+ <delete-issue-modal
+ :issue-path="issuePath"
+ :issue-type="issueType"
+ :modal-id="$options.deleteModalId"
+ :title="deleteButtonText"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql
index 938b90b3f7c..d88633f2ae9 100644
--- a/app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql
+++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql
@@ -1,5 +1,6 @@
query getAlert($iid: String!, $fullPath: ID!) {
project(fullPath: $fullPath) {
+ id
issue(iid: $iid) {
id
alertManagementAlert {
diff --git a/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue b/app/assets/javascripts/issues/show/components/incidents/highlight_bar.vue
index 96f187f26dd..d509f0dbc09 100644
--- a/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/highlight_bar.vue
@@ -5,7 +5,7 @@ import { formatDate } from '~/lib/utils/datetime_utility';
export default {
components: {
GlLink,
- IncidentSla: () => import('ee_component/issue_show/components/incidents/incident_sla.vue'),
+ IncidentSla: () => import('ee_component/issues/show/components/incidents/incident_sla.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
diff --git a/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
index 84107d3eaca..4790062ab7d 100644
--- a/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
@@ -16,7 +16,7 @@ export default {
GlTab,
GlTabs,
HighlightBar,
- MetricsTab: () => import('ee_component/issue_show/components/incidents/metrics_tab.vue'),
+ MetricsTab: () => import('ee_component/issues/show/components/incidents/metrics_tab.vue'),
},
inject: ['fullPath', 'iid', 'uploadMetricsFeatureAvailable'],
apollo: {
diff --git a/app/assets/javascripts/issue_show/components/locked_warning.vue b/app/assets/javascripts/issues/show/components/locked_warning.vue
index 4b99888ae73..4b99888ae73 100644
--- a/app/assets/javascripts/issue_show/components/locked_warning.vue
+++ b/app/assets/javascripts/issues/show/components/locked_warning.vue
diff --git a/app/assets/javascripts/issue_show/components/pinned_links.vue b/app/assets/javascripts/issues/show/components/pinned_links.vue
index d38189307bd..d38189307bd 100644
--- a/app/assets/javascripts/issue_show/components/pinned_links.vue
+++ b/app/assets/javascripts/issues/show/components/pinned_links.vue
diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issues/show/components/title.vue
index 5e92211685a..5e92211685a 100644
--- a/app/assets/javascripts/issue_show/components/title.vue
+++ b/app/assets/javascripts/issues/show/components/title.vue
diff --git a/app/assets/javascripts/issue_show/constants.js b/app/assets/javascripts/issues/show/constants.js
index ef9699deb42..35f3bcdad70 100644
--- a/app/assets/javascripts/issue_show/constants.js
+++ b/app/assets/javascripts/issues/show/constants.js
@@ -1,24 +1,5 @@
import { __ } from '~/locale';
-export const IssuableStatus = {
- Closed: 'closed',
- Open: 'opened',
- Reopened: 'reopened',
-};
-
-export const IssuableStatusText = {
- [IssuableStatus.Closed]: __('Closed'),
- [IssuableStatus.Open]: __('Open'),
- [IssuableStatus.Reopened]: __('Open'),
-};
-
-export const IssuableType = {
- Issue: 'issue',
- Epic: 'epic',
- MergeRequest: 'merge_request',
- Alert: 'alert',
-};
-
export const IssueStateEvent = {
Close: 'CLOSE',
Reopen: 'REOPEN',
@@ -39,8 +20,3 @@ export const IncidentType = 'incident';
export const issueState = { issueType: undefined, isDirty: false };
export const POLLING_DELAY = 2000;
-
-export const WorkspaceType = {
- project: 'project',
- group: 'group',
-};
diff --git a/app/assets/javascripts/issuable_show/event_hub.js b/app/assets/javascripts/issues/show/event_hub.js
index e31806ad199..e31806ad199 100644
--- a/app/assets/javascripts/issuable_show/event_hub.js
+++ b/app/assets/javascripts/issues/show/event_hub.js
diff --git a/app/assets/javascripts/issue_show/graphql.js b/app/assets/javascripts/issues/show/graphql.js
index 5b8630f7d63..5b8630f7d63 100644
--- a/app/assets/javascripts/issue_show/graphql.js
+++ b/app/assets/javascripts/issues/show/graphql.js
diff --git a/app/assets/javascripts/issue_show/incident.js b/app/assets/javascripts/issues/show/incident.js
index 3aff2d9c54a..a260c31e1da 100644
--- a/app/assets/javascripts/issue_show/incident.js
+++ b/app/assets/javascripts/issues/show/incident.js
@@ -81,12 +81,14 @@ export function initIncidentHeaderActions(store) {
store,
provide: {
canCreateIssue: parseBoolean(el.dataset.canCreateIncident),
+ canDestroyIssue: parseBoolean(el.dataset.canDestroyIssue),
canPromoteToEpic: parseBoolean(el.dataset.canPromoteToEpic),
canReopenIssue: parseBoolean(el.dataset.canReopenIssue),
canReportSpam: parseBoolean(el.dataset.canReportSpam),
canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue),
iid: el.dataset.iid,
isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor),
+ issuePath: el.dataset.issuePath,
issueType: el.dataset.issueType,
newIssuePath: el.dataset.newIssuePath,
projectPath: el.dataset.projectPath,
diff --git a/app/assets/javascripts/issue_show/issue.js b/app/assets/javascripts/issues/show/issue.js
index 25cc51478ff..60e90934af8 100644
--- a/app/assets/javascripts/issue_show/issue.js
+++ b/app/assets/javascripts/issues/show/issue.js
@@ -44,6 +44,7 @@ export function initIssuableApp(issuableData, store) {
isConfidential: this.getNoteableData?.confidential,
isLocked: this.getNoteableData?.discussion_locked,
issuableStatus: this.getNoteableData?.state,
+ id: this.getNoteableData?.id,
},
});
},
@@ -65,12 +66,14 @@ export function initIssueHeaderActions(store) {
store,
provide: {
canCreateIssue: parseBoolean(el.dataset.canCreateIssue),
+ canDestroyIssue: parseBoolean(el.dataset.canDestroyIssue),
canPromoteToEpic: parseBoolean(el.dataset.canPromoteToEpic),
canReopenIssue: parseBoolean(el.dataset.canReopenIssue),
canReportSpam: parseBoolean(el.dataset.canReportSpam),
canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue),
iid: el.dataset.iid,
isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor),
+ issuePath: el.dataset.issuePath,
issueType: el.dataset.issueType,
newIssuePath: el.dataset.newIssuePath,
projectPath: el.dataset.projectPath,
diff --git a/app/assets/javascripts/issue_show/mixins/animate.js b/app/assets/javascripts/issues/show/mixins/animate.js
index 4816393da1f..4816393da1f 100644
--- a/app/assets/javascripts/issue_show/mixins/animate.js
+++ b/app/assets/javascripts/issues/show/mixins/animate.js
diff --git a/app/assets/javascripts/issue_show/mixins/update.js b/app/assets/javascripts/issues/show/mixins/update.js
index 72be65b426f..72be65b426f 100644
--- a/app/assets/javascripts/issue_show/mixins/update.js
+++ b/app/assets/javascripts/issues/show/mixins/update.js
diff --git a/app/assets/javascripts/issue_show/queries/get_issue_state.query.graphql b/app/assets/javascripts/issues/show/queries/get_issue_state.query.graphql
index 33b737d2315..33b737d2315 100644
--- a/app/assets/javascripts/issue_show/queries/get_issue_state.query.graphql
+++ b/app/assets/javascripts/issues/show/queries/get_issue_state.query.graphql
diff --git a/app/assets/javascripts/issue_show/queries/promote_to_epic.mutation.graphql b/app/assets/javascripts/issues/show/queries/promote_to_epic.mutation.graphql
index 12d05af0f5e..e3e3a2bc667 100644
--- a/app/assets/javascripts/issue_show/queries/promote_to_epic.mutation.graphql
+++ b/app/assets/javascripts/issues/show/queries/promote_to_epic.mutation.graphql
@@ -1,6 +1,7 @@
mutation promoteToEpic($input: PromoteToEpicInput!) {
promoteToEpic(input: $input) {
epic {
+ id
webPath
}
errors
diff --git a/app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql b/app/assets/javascripts/issues/show/queries/update_issue.mutation.graphql
index ec8d8f32d8b..ec8d8f32d8b 100644
--- a/app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql
+++ b/app/assets/javascripts/issues/show/queries/update_issue.mutation.graphql
diff --git a/app/assets/javascripts/issue_show/queries/update_issue_state.mutation.graphql b/app/assets/javascripts/issues/show/queries/update_issue_state.mutation.graphql
index d91ca746066..d91ca746066 100644
--- a/app/assets/javascripts/issue_show/queries/update_issue_state.mutation.graphql
+++ b/app/assets/javascripts/issues/show/queries/update_issue_state.mutation.graphql
diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issues/show/services/index.js
index b1deeaae0fc..dba07f623f9 100644
--- a/app/assets/javascripts/issue_show/services/index.js
+++ b/app/assets/javascripts/issues/show/services/index.js
@@ -1,4 +1,4 @@
-import axios from '../../lib/utils/axios_utils';
+import axios from '~/lib/utils/axios_utils';
export default class Service {
constructor(endpoint) {
diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issues/show/stores/index.js
index a50913d3455..a50913d3455 100644
--- a/app/assets/javascripts/issue_show/stores/index.js
+++ b/app/assets/javascripts/issues/show/stores/index.js
diff --git a/app/assets/javascripts/issue_show/utils/parse_data.js b/app/assets/javascripts/issues/show/utils/parse_data.js
index f1e6bd2419a..f1e6bd2419a 100644
--- a/app/assets/javascripts/issue_show/utils/parse_data.js
+++ b/app/assets/javascripts/issues/show/utils/parse_data.js
diff --git a/app/assets/javascripts/issue_show/utils/update_description.js b/app/assets/javascripts/issues/show/utils/update_description.js
index c5811290e61..c5811290e61 100644
--- a/app/assets/javascripts/issue_show/utils/update_description.js
+++ b/app/assets/javascripts/issues/show/utils/update_description.js
diff --git a/app/assets/javascripts/issues_list/components/issuable.vue b/app/assets/javascripts/issues_list/components/issuable.vue
index 6dc7460b037..6476d5be38c 100644
--- a/app/assets/javascripts/issues_list/components/issuable.vue
+++ b/app/assets/javascripts/issues_list/components/issuable.vue
@@ -28,7 +28,7 @@ import { convertToCamelCase } from '~/lib/utils/text_utility';
import { mergeUrlParams, setUrlFragment, isExternal } from '~/lib/utils/url_utility';
import { sprintf, __ } from '~/locale';
import initUserPopovers from '~/user_popovers';
-import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
+import IssueAssignees from '~/issuable/components/issue_assignees.vue';
export default {
i18n: {
diff --git a/app/assets/javascripts/issues_list/components/issuables_list_app.vue b/app/assets/javascripts/issues_list/components/issuables_list_app.vue
index 62b52afdaca..71136bf0159 100644
--- a/app/assets/javascripts/issues_list/components/issuables_list_app.vue
+++ b/app/assets/javascripts/issues_list/components/issuables_list_app.vue
@@ -11,7 +11,7 @@ import axios from '~/lib/utils/axios_utils';
import { scrollToElement, historyPushState } from '~/lib/utils/common_utils';
import { setUrlParams, queryToObject, getParameterByName } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-import initManualOrdering from '~/manual_ordering';
+import initManualOrdering from '~/issues/manual_ordering';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import {
sortOrderMap,
@@ -21,12 +21,12 @@ import {
PAGE_SIZE_MANUAL,
LOADING_LIST_ITEMS_LENGTH,
} from '../constants';
-import issueableEventHub from '../eventhub';
+import issuableEventHub from '../eventhub';
import { emptyStateHelper } from '../service_desk_helper';
import Issuable from './issuable.vue';
/**
- * @deprecated Use app/assets/javascripts/issuable_list/components/issuable_list_root.vue instead
+ * @deprecated Use app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue instead
*/
export default {
LOADING_LIST_ITEMS_LENGTH,
@@ -192,7 +192,7 @@ export default {
// We need to call nextTick here to wait for all of the boxes to be checked and rendered
// before we query the dom in issuable_bulk_update_actions.js.
this.$nextTick(() => {
- issueableEventHub.$emit('issuables:updateBulkEdit');
+ issuableEventHub.$emit('issuables:updateBulkEdit');
});
},
issuables() {
@@ -203,7 +203,7 @@ export default {
},
mounted() {
if (this.canBulkEdit) {
- this.unsubscribeToggleBulkEdit = issueableEventHub.$on('issuables:toggleBulkEdit', (val) => {
+ this.unsubscribeToggleBulkEdit = issuableEventHub.$on('issuables:toggleBulkEdit', (val) => {
this.isBulkEditing = val;
});
}
@@ -211,7 +211,7 @@ export default {
},
beforeDestroy() {
// eslint-disable-next-line @gitlab/no-global-event-off
- issueableEventHub.$off('issuables:toggleBulkEdit');
+ issuableEventHub.$off('issuables:toggleBulkEdit');
},
methods: {
isSelected(issuableId) {
diff --git a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue
index 4a2f7861492..aece7372182 100644
--- a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue
+++ b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue
@@ -7,25 +7,16 @@ import {
isInPast,
isToday,
} from '~/lib/utils/datetime_utility';
-import { convertToCamelCase } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
export default {
components: {
GlLink,
GlIcon,
- IssueHealthStatus: () =>
- import('ee_component/related_items_tree/components/issue_health_status.vue'),
- WeightCount: () => import('ee_component/issues/components/weight_count.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
},
- inject: {
- hasIssuableHealthStatusFeature: {
- default: false,
- },
- },
props: {
issue: {
type: Object,
@@ -54,12 +45,6 @@ export default {
timeEstimate() {
return this.issue.humanTimeEstimate || this.issue.timeStats?.humanTimeEstimate;
},
- showHealthStatus() {
- return this.hasIssuableHealthStatusFeature && this.issue.healthStatus;
- },
- healthStatus() {
- return convertToCamelCase(this.issue.healthStatus);
- },
},
methods: {
milestoneRemainingTime(dueDate, startDate) {
@@ -114,7 +99,6 @@ export default {
<gl-icon name="timer" />
{{ timeEstimate }}
</span>
- <weight-count class="issuable-weight gl-mr-3" :weight="issue.weight" />
- <issue-health-status v-if="showHealthStatus" :health-status="healthStatus" />
+ <slot></slot>
</span>
</template>
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 7f2082e5b90..6ced1080b71 100644
--- a/app/assets/javascripts/issues_list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue
@@ -8,17 +8,20 @@ import {
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import { orderBy } from 'lodash';
import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql';
import getIssuesCountsQuery from 'ee_else_ce/issues_list/queries/get_issues_counts.query.graphql';
-import createFlash from '~/flash';
+import IssueCardTimeInfo from 'ee_else_ce/issues_list/components/issue_card_time_info.vue';
+import createFlash, { FLASH_TYPES } from '~/flash';
import { TYPE_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { ITEM_TYPE } from '~/groups/constants';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
-import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
-import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants';
+import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
+import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants';
import {
CREATED_DESC,
i18n,
@@ -31,14 +34,11 @@ import {
TOKEN_TYPE_ASSIGNEE,
TOKEN_TYPE_AUTHOR,
TOKEN_TYPE_CONFIDENTIAL,
- TOKEN_TYPE_EPIC,
- TOKEN_TYPE_ITERATION,
TOKEN_TYPE_LABEL,
TOKEN_TYPE_MILESTONE,
TOKEN_TYPE_MY_REACTION,
TOKEN_TYPE_RELEASE,
TOKEN_TYPE_TYPE,
- TOKEN_TYPE_WEIGHT,
UPDATED_DESC,
urlSortParams,
} from '~/issues_list/constants';
@@ -61,39 +61,29 @@ import {
TOKEN_TITLE_ASSIGNEE,
TOKEN_TITLE_AUTHOR,
TOKEN_TITLE_CONFIDENTIAL,
- TOKEN_TITLE_EPIC,
- TOKEN_TITLE_ITERATION,
TOKEN_TITLE_LABEL,
TOKEN_TITLE_MILESTONE,
TOKEN_TITLE_MY_REACTION,
TOKEN_TITLE_RELEASE,
TOKEN_TITLE_TYPE,
- TOKEN_TITLE_WEIGHT,
} from '~/vue_shared/components/filtered_search_bar/constants';
import eventHub from '../eventhub';
import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql';
-import searchIterationsQuery from '../queries/search_iterations.query.graphql';
import searchLabelsQuery from '../queries/search_labels.query.graphql';
import searchMilestonesQuery from '../queries/search_milestones.query.graphql';
import searchUsersQuery from '../queries/search_users.query.graphql';
-import IssueCardTimeInfo from './issue_card_time_info.vue';
import NewIssueDropdown from './new_issue_dropdown.vue';
const AuthorToken = () =>
import('~/vue_shared/components/filtered_search_bar/tokens/author_token.vue');
const EmojiToken = () =>
import('~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue');
-const EpicToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue');
-const IterationToken = () =>
- import('~/vue_shared/components/filtered_search_bar/tokens/iteration_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');
-const WeightToken = () =>
- import('~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue');
export default {
i18n,
@@ -109,7 +99,6 @@ export default {
IssuableList,
IssueCardTimeInfo,
NewIssueDropdown,
- BlockingIssuesCount: () => import('ee_component/issues/components/blocking_issues_count.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -133,9 +122,6 @@ export default {
fullPath: {
default: '',
},
- groupPath: {
- default: '',
- },
hasAnyIssues: {
default: false,
},
@@ -148,15 +134,18 @@ export default {
hasIssueWeightsFeature: {
default: false,
},
- hasIterationsFeature: {
- default: false,
- },
hasMultipleIssueAssigneesFeature: {
default: false,
},
initialEmail: {
default: '',
},
+ isAnonymousSearchDisabled: {
+ default: false,
+ },
+ isIssueRepositioningDisabled: {
+ default: false,
+ },
isProject: {
default: false,
},
@@ -182,21 +171,43 @@ export default {
default: '',
},
},
+ props: {
+ eeSearchTokens: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
data() {
const state = getParameterByName(PARAM_STATE);
- const sortKey = getSortKey(getParameterByName(PARAM_SORT));
const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC;
+ let sortKey = getSortKey(getParameterByName(PARAM_SORT)) || defaultSortKey;
+
+ if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) {
+ this.showIssueRepositioningMessage();
+ sortKey = defaultSortKey;
+ }
+
+ const isSearchDisabled =
+ this.isAnonymousSearchDisabled &&
+ !this.isSignedIn &&
+ window.location.search.includes('search=');
+
+ if (isSearchDisabled) {
+ this.showAnonymousSearchingMessage();
+ }
return {
dueDateFilter: getDueDateValue(getParameterByName(PARAM_DUE_DATE)),
exportCsvPathWithQuery: this.getExportCsvPathWithQuery(),
- filterTokens: getFilterTokens(window.location.search),
+ filterTokens: isSearchDisabled ? [] : getFilterTokens(window.location.search),
issues: [],
issuesCounts: {},
+ issuesError: null,
pageInfo: {},
pageParams: getInitialPageParams(sortKey),
showBulkEditSidebar: false,
- sortKey: sortKey || defaultSortKey,
+ sortKey,
state: state || IssuableStates.Opened,
};
},
@@ -214,7 +225,8 @@ export default {
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
},
error(error) {
- createFlash({ message: this.$options.i18n.errorFetchingIssues, captureError: true, error });
+ this.issuesError = this.$options.i18n.errorFetchingIssues;
+ Sentry.captureException(error);
},
skip() {
return !this.hasAnyIssues;
@@ -230,7 +242,8 @@ export default {
return data[this.namespace] ?? {};
},
error(error) {
- createFlash({ message: this.$options.i18n.errorFetchingCounts, captureError: true, error });
+ this.issuesError = this.$options.i18n.errorFetchingCounts;
+ Sentry.captureException(error);
},
skip() {
return !this.hasAnyIssues;
@@ -306,6 +319,7 @@ export default {
unique: true,
defaultAuthors: [],
fetchAuthors: this.fetchUsers,
+ recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-author`,
preloadedAuthors,
},
{
@@ -317,6 +331,7 @@ export default {
unique: !this.hasMultipleIssueAssigneesFeature,
defaultAuthors: DEFAULT_NONE_ANY,
fetchAuthors: this.fetchUsers,
+ recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-assignee`,
preloadedAuthors,
},
{
@@ -325,6 +340,7 @@ export default {
icon: 'clock',
token: MilestoneToken,
fetchMilestones: this.fetchMilestones,
+ recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-milestone`,
},
{
type: TOKEN_TYPE_LABEL,
@@ -333,6 +349,7 @@ export default {
token: LabelToken,
defaultLabels: DEFAULT_NONE_ANY,
fetchLabels: this.fetchLabels,
+ recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-label`,
},
{
type: TOKEN_TYPE_TYPE,
@@ -354,6 +371,7 @@ export default {
icon: 'rocket',
token: ReleaseToken,
fetchReleases: this.fetchReleases,
+ recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-release`,
});
}
@@ -365,6 +383,7 @@ export default {
token: EmojiToken,
unique: true,
fetchEmojis: this.fetchEmojis,
+ recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-my_reaction`,
});
tokens.push({
@@ -381,42 +400,13 @@ export default {
});
}
- if (this.hasIterationsFeature) {
- tokens.push({
- type: TOKEN_TYPE_ITERATION,
- title: TOKEN_TITLE_ITERATION,
- icon: 'iteration',
- token: IterationToken,
- fetchIterations: this.fetchIterations,
- });
+ if (this.eeSearchTokens.length) {
+ tokens.push(...this.eeSearchTokens);
}
- if (this.groupPath) {
- tokens.push({
- type: TOKEN_TYPE_EPIC,
- title: TOKEN_TITLE_EPIC,
- icon: 'epic',
- token: EpicToken,
- unique: true,
- symbol: '&',
- idProperty: 'id',
- useIdValue: true,
- recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-epic_id`,
- fullPath: this.groupPath,
- });
- }
+ tokens.sort((a, b) => a.title.localeCompare(b.title));
- if (this.hasIssueWeightsFeature) {
- tokens.push({
- type: TOKEN_TYPE_WEIGHT,
- title: TOKEN_TITLE_WEIGHT,
- icon: 'weight',
- token: WeightToken,
- unique: true,
- });
- }
-
- return tokens;
+ return orderBy(tokens, ['title']);
},
showPaginationControls() {
return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage);
@@ -481,7 +471,12 @@ export default {
query: searchLabelsQuery,
variables: { fullPath: this.fullPath, search, isProject: this.isProject },
})
- .then(({ data }) => data[this.namespace]?.labels.nodes);
+ .then(({ data }) => data[this.namespace]?.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())),
+ );
},
fetchMilestones(search) {
return this.$apollo
@@ -491,20 +486,6 @@ export default {
})
.then(({ data }) => data[this.namespace]?.milestones.nodes);
},
- fetchIterations(search) {
- const id = Number(search);
- const variables =
- !search || Number.isNaN(id)
- ? { fullPath: this.fullPath, search, isProject: this.isProject }
- : { fullPath: this.fullPath, id, isProject: this.isProject };
-
- return this.$apollo
- .query({
- query: searchIterationsQuery,
- variables,
- })
- .then(({ data }) => data[this.namespace]?.iterations.nodes);
- },
fetchUsers(search) {
return this.$apollo
.query({
@@ -537,7 +518,7 @@ export default {
async handleBulkUpdateClick() {
if (!this.hasInitBulkEdit) {
const initBulkUpdateSidebar = await import(
- '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar'
+ '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar'
);
initBulkUpdateSidebar.default.init('issuable_');
@@ -556,7 +537,14 @@ export default {
}
this.state = state;
},
+ handleDismissAlert() {
+ this.issuesError = null;
+ },
handleFilter(filter) {
+ if (this.isAnonymousSearchDisabled && !this.isSignedIn) {
+ this.showAnonymousSearchingMessage();
+ return;
+ }
this.pageParams = getInitialPageParams(this.sortKey);
this.filterTokens = filter;
},
@@ -607,15 +595,33 @@ export default {
});
})
.catch((error) => {
- createFlash({ message: this.$options.i18n.reorderError, captureError: true, error });
+ this.issuesError = this.$options.i18n.reorderError;
+ Sentry.captureException(error);
});
},
handleSort(sortKey) {
+ if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) {
+ this.showIssueRepositioningMessage();
+ return;
+ }
+
if (this.sortKey !== sortKey) {
this.pageParams = getInitialPageParams(sortKey);
}
this.sortKey = sortKey;
},
+ showAnonymousSearchingMessage() {
+ createFlash({
+ message: this.$options.i18n.anonymousSearchingMessage,
+ type: FLASH_TYPES.NOTICE,
+ });
+ },
+ showIssueRepositioningMessage() {
+ createFlash({
+ message: this.$options.i18n.issueRepositioningMessage,
+ type: FLASH_TYPES.NOTICE,
+ });
+ },
toggleBulkEditSidebar(showBulkEditSidebar) {
this.showBulkEditSidebar = showBulkEditSidebar;
},
@@ -634,6 +640,7 @@ export default {
:sort-options="sortOptions"
:initial-sort-by="sortKey"
:issuables="issues"
+ :error="issuesError"
label-filter-param="label_name"
:tabs="$options.IssuableListTabs"
:current-tab="state"
@@ -647,6 +654,7 @@ export default {
:has-previous-page="pageInfo.hasPreviousPage"
:url-params="urlParams"
@click-tab="handleClickTab"
+ @dismiss-alert="handleDismissAlert"
@filter="handleFilter"
@next-page="handleNextPage"
@previous-page="handlePreviousPage"
@@ -727,12 +735,7 @@ export default {
<gl-icon name="thumb-down" />
{{ issuable.downvotes }}
</li>
- <blocking-issues-count
- class="blocking-issues gl-display-none gl-sm-display-block"
- :blocking-issues-count="issuable.blockingCount"
- :is-list-item="true"
- data-testid="blocking-issues"
- />
+ <slot :issuable="issuable"></slot>
</template>
<template #empty-state>
diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues_list/constants.js
index da9b96d0e22..c9eaf0b9908 100644
--- a/app/assets/javascripts/issues_list/constants.js
+++ b/app/assets/javascripts/issues_list/constants.js
@@ -66,6 +66,7 @@ export const availableSortOptionsJira = [
];
export const i18n = {
+ anonymousSearchingMessage: __('You must sign in to search for specific terms.'),
calendarLabel: __('Subscribe to calendar'),
closed: __('CLOSED'),
closedMoved: __('CLOSED (MOVED)'),
@@ -75,6 +76,9 @@ export const i18n = {
editIssues: __('Edit issues'),
errorFetchingCounts: __('An error occurred while getting issue counts'),
errorFetchingIssues: __('An error occurred while loading issues'),
+ issueRepositioningMessage: __(
+ 'Issues are being rebalanced at the moment, so manual reordering is disabled.',
+ ),
jiraIntegrationMessage: s__(
'JiraService|%{jiraDocsLinkStart}Enable the Jira integration%{jiraDocsLinkEnd} to view your Jira issues in GitLab.',
),
@@ -133,6 +137,7 @@ export const DUE_DATE_VALUES = [
DUE_DATE_NEXT_MONTH_AND_PREVIOUS_TWO_WEEKS,
];
+export const BLOCKING_ISSUES_ASC = 'BLOCKING_ISSUES_ASC';
export const BLOCKING_ISSUES_DESC = 'BLOCKING_ISSUES_DESC';
export const CREATED_ASC = 'CREATED_ASC';
export const CREATED_DESC = 'CREATED_DESC';
@@ -154,42 +159,28 @@ export const UPDATED_DESC = 'UPDATED_DESC';
export const WEIGHT_ASC = 'WEIGHT_ASC';
export const WEIGHT_DESC = 'WEIGHT_DESC';
-const PRIORITY_ASC_SORT = 'priority_asc';
-const CREATED_DATE_SORT = 'created_date';
-const CREATED_ASC_SORT = 'created_asc';
-const UPDATED_DESC_SORT = 'updated_desc';
-const UPDATED_ASC_SORT = 'updated_asc';
-const MILESTONE_SORT = 'milestone';
-const MILESTONE_DUE_DESC_SORT = 'milestone_due_desc';
-const DUE_DATE_DESC_SORT = 'due_date_desc';
-const LABEL_PRIORITY_ASC_SORT = 'label_priority_asc';
-const POPULARITY_ASC_SORT = 'popularity_asc';
-const WEIGHT_DESC_SORT = 'weight_desc';
-const BLOCKING_ISSUES_DESC_SORT = 'blocking_issues_desc';
-const TITLE_ASC_SORT = 'title_asc';
-const TITLE_DESC_SORT = 'title_desc';
-
export const urlSortParams = {
- [PRIORITY_ASC]: PRIORITY_ASC_SORT,
- [PRIORITY_DESC]: PRIORITY,
- [CREATED_ASC]: CREATED_ASC_SORT,
- [CREATED_DESC]: CREATED_DATE_SORT,
- [UPDATED_ASC]: UPDATED_ASC_SORT,
- [UPDATED_DESC]: UPDATED_DESC_SORT,
- [MILESTONE_DUE_ASC]: MILESTONE_SORT,
- [MILESTONE_DUE_DESC]: MILESTONE_DUE_DESC_SORT,
- [DUE_DATE_ASC]: DUE_DATE,
- [DUE_DATE_DESC]: DUE_DATE_DESC_SORT,
- [POPULARITY_ASC]: POPULARITY_ASC_SORT,
- [POPULARITY_DESC]: POPULARITY,
- [LABEL_PRIORITY_ASC]: LABEL_PRIORITY_ASC_SORT,
- [LABEL_PRIORITY_DESC]: LABEL_PRIORITY,
+ [PRIORITY_ASC]: 'priority',
+ [PRIORITY_DESC]: 'priority_desc',
+ [CREATED_ASC]: 'created_asc',
+ [CREATED_DESC]: 'created_date',
+ [UPDATED_ASC]: 'updated_asc',
+ [UPDATED_DESC]: 'updated_desc',
+ [MILESTONE_DUE_ASC]: 'milestone',
+ [MILESTONE_DUE_DESC]: 'milestone_due_desc',
+ [DUE_DATE_ASC]: 'due_date',
+ [DUE_DATE_DESC]: 'due_date_desc',
+ [POPULARITY_ASC]: 'popularity_asc',
+ [POPULARITY_DESC]: 'popularity',
+ [LABEL_PRIORITY_ASC]: 'label_priority',
+ [LABEL_PRIORITY_DESC]: 'label_priority_desc',
[RELATIVE_POSITION_ASC]: RELATIVE_POSITION,
- [WEIGHT_ASC]: WEIGHT,
- [WEIGHT_DESC]: WEIGHT_DESC_SORT,
- [BLOCKING_ISSUES_DESC]: BLOCKING_ISSUES_DESC_SORT,
- [TITLE_ASC]: TITLE_ASC_SORT,
- [TITLE_DESC]: TITLE_DESC_SORT,
+ [WEIGHT_ASC]: 'weight',
+ [WEIGHT_DESC]: 'weight_desc',
+ [BLOCKING_ISSUES_ASC]: 'blocking_issues_asc',
+ [BLOCKING_ISSUES_DESC]: 'blocking_issues_desc',
+ [TITLE_ASC]: 'title_asc',
+ [TITLE_DESC]: 'title_desc',
};
export const MAX_LIST_SIZE = 10;
diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js
index 59034964afb..9d2ec8b32d2 100644
--- a/app/assets/javascripts/issues_list/index.js
+++ b/app/assets/javascripts/issues_list/index.js
@@ -2,7 +2,7 @@ import produce from 'immer';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql';
-import IssuesListApp from '~/issues_list/components/issues_list_app.vue';
+import IssuesListApp from 'ee_else_ce/issues_list/components/issues_list_app.vue';
import createDefaultClient from '~/lib/graphql';
import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import IssuablesListApp from './components/issuables_list_app.vue';
@@ -129,6 +129,8 @@ export function mountIssuesListApp() {
hasMultipleIssueAssigneesFeature,
importCsvIssuesPath,
initialEmail,
+ isAnonymousSearchDisabled,
+ isIssueRepositioningDisabled,
isProject,
isSignedIn,
jiraIntegrationPath,
@@ -161,6 +163,8 @@ export function mountIssuesListApp() {
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
hasIterationsFeature: parseBoolean(hasIterationsFeature),
hasMultipleIssueAssigneesFeature: parseBoolean(hasMultipleIssueAssigneesFeature),
+ isAnonymousSearchDisabled: parseBoolean(isAnonymousSearchDisabled),
+ isIssueRepositioningDisabled: parseBoolean(isIssueRepositioningDisabled),
isProject: parseBoolean(isProject),
isSignedIn: parseBoolean(isSignedIn),
jiraIntegrationPath,
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 9866efbcecc..be8deb3fe97 100644
--- a/app/assets/javascripts/issues_list/queries/get_issues.query.graphql
+++ b/app/assets/javascripts/issues_list/queries/get_issues.query.graphql
@@ -26,6 +26,7 @@ query getIssues(
$lastPageSize: Int
) {
group(fullPath: $fullPath) @skip(if: $isProject) {
+ id
issues(
includeSubgroups: true
search: $search
@@ -56,6 +57,7 @@ query getIssues(
}
}
project(fullPath: $fullPath) @include(if: $isProject) {
+ id
issues(
search: $search
sort: $sort
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 5e755ec5870..1a345fd2877 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
@@ -16,6 +16,7 @@ query getIssuesCount(
$not: NegatedIssueFilterInput
) {
group(fullPath: $fullPath) @skip(if: $isProject) {
+ id
openedIssues: issues(
includeSubgroups: true
state: opened
@@ -69,6 +70,7 @@ query getIssuesCount(
}
}
project(fullPath: $fullPath) @include(if: $isProject) {
+ id
openedIssues: issues(
state: opened
search: $search
diff --git a/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql
index 8c95e6114d3..a53dba8c7c8 100644
--- a/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql
+++ b/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql
@@ -1,9 +1,12 @@
query getIssuesListDetails($fullPath: ID!) {
project(fullPath: $fullPath) {
+ id
issues {
nodes {
+ id
labels {
nodes {
+ id
title
color
}
diff --git a/app/assets/javascripts/issues_list/queries/issue.fragment.graphql b/app/assets/javascripts/issues_list/queries/issue.fragment.graphql
index 9c46cb3ef64..07dae3fd756 100644
--- a/app/assets/javascripts/issues_list/queries/issue.fragment.graphql
+++ b/app/assets/javascripts/issues_list/queries/issue.fragment.graphql
@@ -6,6 +6,7 @@ fragment IssueFragment on Issue {
createdAt
downvotes
dueDate
+ hidden
humanTimeEstimate
mergeRequestsCount
moved
diff --git a/app/assets/javascripts/issues_list/queries/iteration.fragment.graphql b/app/assets/javascripts/issues_list/queries/iteration.fragment.graphql
deleted file mode 100644
index 4f7217be7f7..00000000000
--- a/app/assets/javascripts/issues_list/queries/iteration.fragment.graphql
+++ /dev/null
@@ -1,10 +0,0 @@
-fragment Iteration on Iteration {
- id
- title
- startDate
- dueDate
- iterationCadence {
- id
- title
- }
-}
diff --git a/app/assets/javascripts/issues_list/queries/search_iterations.query.graphql b/app/assets/javascripts/issues_list/queries/search_iterations.query.graphql
deleted file mode 100644
index 93600c62905..00000000000
--- a/app/assets/javascripts/issues_list/queries/search_iterations.query.graphql
+++ /dev/null
@@ -1,18 +0,0 @@
-#import "./iteration.fragment.graphql"
-
-query searchIterations($fullPath: ID!, $search: String, $id: ID, $isProject: Boolean = false) {
- group(fullPath: $fullPath) @skip(if: $isProject) {
- iterations(title: $search, id: $id, includeAncestors: true) {
- nodes {
- ...Iteration
- }
- }
- }
- project(fullPath: $fullPath) @include(if: $isProject) {
- iterations(title: $search, id: $id, includeAncestors: true) {
- nodes {
- ...Iteration
- }
- }
- }
-}
diff --git a/app/assets/javascripts/issues_list/queries/search_labels.query.graphql b/app/assets/javascripts/issues_list/queries/search_labels.query.graphql
index 1515bd91da3..44b57317161 100644
--- a/app/assets/javascripts/issues_list/queries/search_labels.query.graphql
+++ b/app/assets/javascripts/issues_list/queries/search_labels.query.graphql
@@ -2,6 +2,7 @@
query searchLabels($fullPath: ID!, $search: String, $isProject: Boolean = false) {
group(fullPath: $fullPath) @skip(if: $isProject) {
+ id
labels(searchTerm: $search, includeAncestorGroups: true, includeDescendantGroups: true) {
nodes {
...Label
@@ -9,6 +10,7 @@ query searchLabels($fullPath: ID!, $search: String, $isProject: Boolean = false)
}
}
project(fullPath: $fullPath) @include(if: $isProject) {
+ id
labels(searchTerm: $search, includeAncestorGroups: true) {
nodes {
...Label
diff --git a/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql b/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql
index 8c6c50e9dc2..e7eb08104a6 100644
--- a/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql
+++ b/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql
@@ -2,6 +2,7 @@
query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = false) {
group(fullPath: $fullPath) @skip(if: $isProject) {
+ id
milestones(searchTitle: $search, includeAncestors: true, includeDescendants: true) {
nodes {
...Milestone
@@ -9,6 +10,7 @@ query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = fa
}
}
project(fullPath: $fullPath) @include(if: $isProject) {
+ id
milestones(searchTitle: $search, includeAncestors: true) {
nodes {
...Milestone
diff --git a/app/assets/javascripts/issues_list/queries/search_projects.query.graphql b/app/assets/javascripts/issues_list/queries/search_projects.query.graphql
index 75463f643a2..bd2f9bc2340 100644
--- a/app/assets/javascripts/issues_list/queries/search_projects.query.graphql
+++ b/app/assets/javascripts/issues_list/queries/search_projects.query.graphql
@@ -1,5 +1,6 @@
query searchProjects($fullPath: ID!, $search: String) {
group(fullPath: $fullPath) {
+ id
projects(search: $search, includeSubgroups: true) {
nodes {
id
diff --git a/app/assets/javascripts/issues_list/queries/search_users.query.graphql b/app/assets/javascripts/issues_list/queries/search_users.query.graphql
index 0211fc66235..92517ad35d0 100644
--- a/app/assets/javascripts/issues_list/queries/search_users.query.graphql
+++ b/app/assets/javascripts/issues_list/queries/search_users.query.graphql
@@ -2,8 +2,10 @@
query searchUsers($fullPath: ID!, $search: String, $isProject: Boolean = false) {
group(fullPath: $fullPath) @skip(if: $isProject) {
+ id
groupMembers(search: $search) {
nodes {
+ id
user {
...User
}
@@ -11,8 +13,10 @@ query searchUsers($fullPath: ID!, $search: String, $isProject: Boolean = false)
}
}
project(fullPath: $fullPath) @include(if: $isProject) {
+ id
projectMembers(search: $search) {
nodes {
+ id
user {
...User
}
diff --git a/app/assets/javascripts/issues_list/utils.js b/app/assets/javascripts/issues_list/utils.js
index 0e57e2bff83..99946e4e851 100644
--- a/app/assets/javascripts/issues_list/utils.js
+++ b/app/assets/javascripts/issues_list/utils.js
@@ -1,5 +1,6 @@
import {
API_PARAM,
+ BLOCKING_ISSUES_ASC,
BLOCKING_ISSUES_DESC,
CREATED_ASC,
CREATED_DESC,
@@ -143,7 +144,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature)
id: sortOptions.length + 1,
title: __('Blocking'),
sortDirection: {
- ascending: BLOCKING_ISSUES_DESC,
+ ascending: BLOCKING_ISSUES_ASC,
descending: BLOCKING_ISSUES_DESC,
},
});
diff --git a/app/assets/javascripts/jira_connect/branches/graphql/queries/get_project.query.graphql b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_project.query.graphql
index f3428e816d7..df72a1ca6e6 100644
--- a/app/assets/javascripts/jira_connect/branches/graphql/queries/get_project.query.graphql
+++ b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_project.query.graphql
@@ -5,6 +5,7 @@ query getProject(
$branchNamesSearchPattern: String!
) {
project(fullPath: $projectPath) {
+ id
repository {
branchNames(
limit: $branchNamesLimit
diff --git a/app/assets/javascripts/jira_connect/branches/index.js b/app/assets/javascripts/jira_connect/branches/index.js
index 04510fcff4b..a9a56a6362e 100644
--- a/app/assets/javascripts/jira_connect/branches/index.js
+++ b/app/assets/javascripts/jira_connect/branches/index.js
@@ -5,7 +5,7 @@ import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
-export default async function initJiraConnectBranches() {
+export default function initJiraConnectBranches() {
const el = document.querySelector('.js-jira-connect-create-branch');
if (!el) {
return null;
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
index c0504cbb645..7fd4cc38f11 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
@@ -7,6 +7,7 @@ import { SET_ALERT } from '../store/mutation_types';
import SubscriptionsList from './subscriptions_list.vue';
import AddNamespaceButton from './add_namespace_button.vue';
import SignInButton from './sign_in_button.vue';
+import UserLink from './user_link.vue';
export default {
name: 'JiraConnectApp',
@@ -18,6 +19,7 @@ export default {
SubscriptionsList,
AddNamespaceButton,
SignInButton,
+ UserLink,
},
inject: {
usersPath: {
@@ -74,6 +76,8 @@ export default {
</template>
</gl-alert>
+ <user-link :user-signed-in="userSignedIn" :has-subscriptions="hasSubscriptions" />
+
<h2 class="gl-text-center gl-mb-7">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2>
<div class="jira-connect-app-body gl-mx-auto gl-px-5 gl-mb-7">
<template v-if="hasSubscriptions">
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue b/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue
new file mode 100644
index 00000000000..fad3d2616d8
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue
@@ -0,0 +1,67 @@
+<script>
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils';
+
+export default {
+ components: {
+ GlLink,
+ GlSprintf,
+ },
+ inject: {
+ usersPath: {
+ default: '',
+ },
+ gitlabUserPath: {
+ default: '',
+ },
+ },
+ props: {
+ userSignedIn: {
+ type: Boolean,
+ required: true,
+ },
+ hasSubscriptions: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ signInURL: '',
+ };
+ },
+ computed: {
+ gitlabUserHandle() {
+ return `@${gon.current_username}`;
+ },
+ },
+ async created() {
+ this.signInURL = await getGitlabSignInURL(this.usersPath);
+ },
+ i18n: {
+ signInText: __('Sign in to GitLab'),
+ signedInAsUserText: __('Signed in to GitLab as %{user_link}'),
+ },
+};
+</script>
+<template>
+ <div class="jira-connect-user gl-font-base">
+ <gl-sprintf v-if="userSignedIn" :message="$options.i18n.signedInAsUserText">
+ <template #user_link>
+ <gl-link data-testid="gitlab-user-link" :href="gitlabUserPath" target="_blank">
+ {{ gitlabUserHandle }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+
+ <gl-link
+ v-else-if="hasSubscriptions"
+ data-testid="sign-in-link"
+ :href="signInURL"
+ target="_blank"
+ >
+ {{ $options.i18n.signInText }}
+ </gl-link>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/index.js b/app/assets/javascripts/jira_connect/subscriptions/index.js
index 8a7a80d885d..cd1fc1d4455 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/index.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/index.js
@@ -7,25 +7,11 @@ import Translate from '~/vue_shared/translate';
import JiraConnectApp from './components/app.vue';
import createStore from './store';
-import { getGitlabSignInURL, sizeToParent } from './utils';
+import { sizeToParent } from './utils';
const store = createStore();
-/**
- * Add `return_to` query param to all HAML-defined GitLab sign in links.
- */
-const updateSignInLinks = async () => {
- await Promise.all(
- Array.from(document.querySelectorAll('.js-jira-connect-sign-in')).map(async (el) => {
- const updatedLink = await getGitlabSignInURL(el.getAttribute('href'));
- el.setAttribute('href', updatedLink);
- }),
- );
-};
-
-export async function initJiraConnect() {
- await updateSignInLinks();
-
+export function initJiraConnect() {
const el = document.querySelector('.js-jira-connect-app');
if (!el) {
return null;
@@ -35,7 +21,7 @@ export async function initJiraConnect() {
Vue.use(Translate);
Vue.use(GlFeatureFlagsPlugin);
- const { groupsPath, subscriptions, subscriptionsPath, usersPath } = el.dataset;
+ const { groupsPath, subscriptions, subscriptionsPath, usersPath, gitlabUserPath } = el.dataset;
sizeToParent();
return new Vue({
@@ -46,6 +32,7 @@ export async function initJiraConnect() {
subscriptions: JSON.parse(subscriptions),
subscriptionsPath,
usersPath,
+ gitlabUserPath,
},
render(createElement) {
return createElement(JiraConnectApp);
diff --git a/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql
index 6fec07cc6f8..4c26399e16b 100644
--- a/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql
+++ b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql
@@ -2,6 +2,7 @@
query getJiraImportDetails($fullPath: ID!) {
project(fullPath: $fullPath) {
+ id
jiraImportStatus
jiraImports {
nodes {
diff --git a/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql b/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql
index fde2ebeff91..fe797879d07 100644
--- a/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql
+++ b/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql
@@ -2,6 +2,7 @@ fragment JiraImport on JiraImport {
jiraProjectKey
scheduledAt
scheduledBy {
+ id
name
}
}
diff --git a/app/assets/javascripts/jira_import/queries/search_project_members.query.graphql b/app/assets/javascripts/jira_import/queries/search_project_members.query.graphql
index 6ea8963e6a6..7666fa3bd97 100644
--- a/app/assets/javascripts/jira_import/queries/search_project_members.query.graphql
+++ b/app/assets/javascripts/jira_import/queries/search_project_members.query.graphql
@@ -1,7 +1,9 @@
query jiraSearchProjectMembers($fullPath: ID!, $search: String) {
project(fullPath: $fullPath) {
+ id
projectMembers(search: $search) {
nodes {
+ id
user {
id
name
diff --git a/app/assets/javascripts/jobs/bridge/app.vue b/app/assets/javascripts/jobs/bridge/app.vue
new file mode 100644
index 00000000000..67c22712776
--- /dev/null
+++ b/app/assets/javascripts/jobs/bridge/app.vue
@@ -0,0 +1,20 @@
+<script>
+import BridgeEmptyState from './components/empty_state.vue';
+import BridgeSidebar from './components/sidebar.vue';
+
+export default {
+ name: 'BridgePageApp',
+ components: {
+ BridgeEmptyState,
+ BridgeSidebar,
+ },
+};
+</script>
+<template>
+ <div>
+ <!-- TODO: get job details and show CI header -->
+ <!-- TODO: add downstream pipeline path -->
+ <bridge-empty-state downstream-pipeline-path="#" />
+ <bridge-sidebar />
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/bridge/components/constants.js b/app/assets/javascripts/jobs/bridge/components/constants.js
new file mode 100644
index 00000000000..33310b3157a
--- /dev/null
+++ b/app/assets/javascripts/jobs/bridge/components/constants.js
@@ -0,0 +1 @@
+export const SIDEBAR_COLLAPSE_BREAKPOINTS = ['xs', 'sm'];
diff --git a/app/assets/javascripts/jobs/bridge/components/empty_state.vue b/app/assets/javascripts/jobs/bridge/components/empty_state.vue
new file mode 100644
index 00000000000..bd07d863719
--- /dev/null
+++ b/app/assets/javascripts/jobs/bridge/components/empty_state.vue
@@ -0,0 +1,45 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ name: 'BridgeEmptyState',
+ i18n: {
+ title: __('This job triggers a downstream pipeline'),
+ linkBtnText: __('View downstream pipeline'),
+ },
+ components: {
+ GlButton,
+ },
+ inject: {
+ emptyStateIllustrationPath: {
+ type: String,
+ require: true,
+ },
+ },
+ props: {
+ downstreamPipelinePath: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11">
+ <img :src="emptyStateIllustrationPath" />
+ <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1>
+ <gl-button
+ v-if="downstreamPipelinePath"
+ class="gl-mt-3"
+ category="secondary"
+ variant="confirm"
+ size="medium"
+ :href="downstreamPipelinePath"
+ >
+ {{ $options.i18n.linkBtnText }}
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/bridge/components/sidebar.vue b/app/assets/javascripts/jobs/bridge/components/sidebar.vue
new file mode 100644
index 00000000000..68b767408f0
--- /dev/null
+++ b/app/assets/javascripts/jobs/bridge/components/sidebar.vue
@@ -0,0 +1,98 @@
+<script>
+import { GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
+import { __ } from '~/locale';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
+import { JOB_SIDEBAR } from '../../constants';
+import { SIDEBAR_COLLAPSE_BREAKPOINTS } from './constants';
+
+export default {
+ styles: {
+ top: '75px',
+ width: '290px',
+ },
+ name: 'BridgeSidebar',
+ i18n: {
+ ...JOB_SIDEBAR,
+ retryButton: __('Retry'),
+ retryTriggerJob: __('Retry the trigger job'),
+ retryDownstreamPipeline: __('Retry the downstream pipeline'),
+ },
+ borderTopClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100'],
+ components: {
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ TooltipOnTruncate,
+ },
+ inject: {
+ buildName: {
+ type: String,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ isSidebarExpanded: true,
+ };
+ },
+ created() {
+ window.addEventListener('resize', this.onResize);
+ },
+ mounted() {
+ this.onResize();
+ },
+ methods: {
+ toggleSidebar() {
+ this.isSidebarExpanded = !this.isSidebarExpanded;
+ },
+ onResize() {
+ const breakpoint = bp.getBreakpointSize();
+ if (SIDEBAR_COLLAPSE_BREAKPOINTS.includes(breakpoint)) {
+ this.isSidebarExpanded = false;
+ } else if (!this.isSidebarExpanded) {
+ this.isSidebarExpanded = true;
+ }
+ },
+ },
+};
+</script>
+<template>
+ <aside
+ class="gl-fixed gl-right-0 gl-px-5 gl-bg-gray-10 gl-h-full gl-border-l-solid gl-border-1 gl-border-gray-100 gl-z-index-200 gl-overflow-hidden"
+ :style="this.$options.styles"
+ :class="{
+ 'gl-display-none': !isSidebarExpanded,
+ }"
+ >
+ <div class="gl-py-5 gl-display-flex gl-align-items-center">
+ <tooltip-on-truncate :title="buildName" truncate-target="child"
+ ><h4 class="gl-mb-0 gl-mr-2 gl-text-truncate">
+ {{ buildName }}
+ </h4>
+ </tooltip-on-truncate>
+ <!-- TODO: implement retry actions -->
+ <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right">
+ <gl-dropdown
+ :text="$options.i18n.retryButton"
+ category="primary"
+ variant="confirm"
+ right
+ size="medium"
+ >
+ <gl-dropdown-item>{{ $options.i18n.retryTriggerJob }}</gl-dropdown-item>
+ <gl-dropdown-item>{{ $options.i18n.retryDownstreamPipeline }}</gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+ <gl-button
+ :aria-label="$options.i18n.toggleSidebar"
+ data-testid="sidebar-expansion-toggle"
+ category="tertiary"
+ class="gl-md-display-none gl-ml-2"
+ icon="chevron-double-lg-right"
+ @click="toggleSidebar"
+ />
+ </div>
+ <!-- TODO: get job details and show commit block, stage dropdown, jobs list -->
+ </aside>
+</template>
diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue
index 6105299e15c..97141a27a5e 100644
--- a/app/assets/javascripts/jobs/components/job_log_controllers.vue
+++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue
@@ -5,7 +5,7 @@ import { __, s__, sprintf } from '~/locale';
export default {
i18n: {
- eraseLogButtonLabel: s__('Job|Erase job log'),
+ eraseLogButtonLabel: s__('Job|Erase job log and artifacts'),
scrollToBottomButtonLabel: s__('Job|Scroll to bottom'),
scrollToTopButtonLabel: s__('Job|Scroll to top'),
showRawButtonLabel: s__('Job|Show complete raw'),
diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue
index 1b50006239c..9aa1503c7c3 100644
--- a/app/assets/javascripts/jobs/components/sidebar.vue
+++ b/app/assets/javascripts/jobs/components/sidebar.vue
@@ -2,7 +2,7 @@
import { GlButton, GlIcon } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import { JOB_SIDEBAR } from '../constants';
import ArtifactsBlock from './artifacts_block.vue';
import CommitBlock from './commit_block.vue';
diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
index d90377029c5..5451cd21c14 100644
--- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
+++ b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
@@ -20,6 +20,9 @@ export default {
duration() {
return timeIntervalInWords(this.job.duration);
},
+ durationTitle() {
+ return this.job.finished_at ? __('Duration') : __('Elapsed time');
+ },
erasedAt() {
return this.timeFormatted(this.job.erased_at);
},
@@ -76,7 +79,7 @@ export default {
<template>
<div v-if="shouldRenderBlock">
- <detail-row v-if="job.duration" :value="duration" title="Duration" />
+ <detail-row v-if="job.duration" :value="duration" :title="durationTitle" />
<detail-row
v-if="job.finished_at"
:value="finishedAt"
diff --git a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
index 51251c0cacc..7dfa963a857 100644
--- a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
+++ b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
@@ -12,6 +12,7 @@ import {
JOB_SCHEDULED,
PLAY_JOB_CONFIRMATION_MESSAGE,
RUN_JOB_NOW_HEADER_TITLE,
+ FILE_TYPE_ARCHIVE,
} from '../constants';
import eventHub from '../event_hub';
import cancelJobMutation from '../graphql/mutations/job_cancel.mutation.graphql';
@@ -58,12 +59,21 @@ export default {
},
},
computed: {
+ hasArtifacts() {
+ return this.job.artifacts.nodes.find((artifact) => artifact.fileType === FILE_TYPE_ARCHIVE);
+ },
artifactDownloadPath() {
- return this.job.artifacts?.nodes[0]?.downloadPath;
+ return this.hasArtifacts.downloadPath;
},
canReadJob() {
return this.job.userPermissions?.readBuild;
},
+ canUpdateJob() {
+ return this.job.userPermissions?.updateBuild;
+ },
+ canReadArtifacts() {
+ return this.job.userPermissions?.readJobArtifacts;
+ },
isActive() {
return this.job.active;
},
@@ -86,7 +96,7 @@ export default {
return this.job.detailedStatus?.action?.method;
},
shouldDisplayArtifacts() {
- return this.job.userPermissions?.readJobArtifacts && this.job.artifacts?.nodes.length > 0;
+ return this.canReadArtifacts && this.hasArtifacts;
},
},
methods: {
@@ -139,7 +149,7 @@ export default {
<template>
<gl-button-group>
- <template v-if="canReadJob">
+ <template v-if="canReadJob && canUpdateJob">
<gl-button
v-if="isActive"
data-testid="cancel-button"
diff --git a/app/assets/javascripts/jobs/components/table/cells/pipeline_cell.vue b/app/assets/javascripts/jobs/components/table/cells/pipeline_cell.vue
index 71f9397f5f5..1a6d1a341b0 100644
--- a/app/assets/javascripts/jobs/components/table/cells/pipeline_cell.vue
+++ b/app/assets/javascripts/jobs/components/table/cells/pipeline_cell.vue
@@ -35,10 +35,12 @@ export default {
</script>
<template>
- <div class="gl-text-truncate">
- <gl-link class="gl-text-gray-500!" :href="pipelinePath" data-testid="pipeline-id">
- {{ pipelineId }}
- </gl-link>
+ <div>
+ <div class="gl-text-truncate">
+ <gl-link class="gl-text-gray-500!" :href="pipelinePath" data-testid="pipeline-id">
+ {{ pipelineId }}
+ </gl-link>
+ </div>
<div>
<span>{{ __('created by') }}</span>
<gl-link v-if="showAvatar" :href="userPath" data-testid="pipeline-user-link">
diff --git a/app/assets/javascripts/jobs/components/table/constants.js b/app/assets/javascripts/jobs/components/table/constants.js
index e5d1bc01cbf..962979ba573 100644
--- a/app/assets/javascripts/jobs/components/table/constants.js
+++ b/app/assets/javascripts/jobs/components/table/constants.js
@@ -1,4 +1,5 @@
import { s__, __ } from '~/locale';
+import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants';
export const GRAPHQL_PAGE_SIZE = 30;
@@ -17,6 +18,9 @@ export const DEFAULT = 'default';
/* Job Status Constants */
export const JOB_SCHEDULED = 'SCHEDULED';
+/* Artifact file types */
+export const FILE_TYPE_ARCHIVE = 'ARCHIVE';
+
/* i18n */
export const ACTIONS_DOWNLOAD_ARTIFACTS = __('Download artifacts');
export const ACTIONS_START_NOW = s__('DelayedJobs|Start now');
@@ -30,3 +34,66 @@ export const PLAY_JOB_CONFIRMATION_MESSAGE = s__(
`DelayedJobs|Are you sure you want to run %{job_name} immediately? This job will run automatically after its timer finishes.`,
);
export const RUN_JOB_NOW_HEADER_TITLE = s__('DelayedJobs|Run the delayed job now?');
+
+/* Table constants */
+
+const defaultTableClasses = {
+ tdClass: 'gl-p-5!',
+ thClass: DEFAULT_TH_CLASSES,
+};
+// eslint-disable-next-line @gitlab/require-i18n-strings
+const coverageTdClasses = `${defaultTableClasses.tdClass} gl-display-none! gl-lg-display-table-cell!`;
+
+export const DEFAULT_FIELDS = [
+ {
+ key: 'status',
+ label: __('Status'),
+ ...defaultTableClasses,
+ columnClass: 'gl-w-10p',
+ },
+ {
+ key: 'job',
+ label: __('Job'),
+ ...defaultTableClasses,
+ columnClass: 'gl-w-20p',
+ },
+ {
+ key: 'pipeline',
+ label: __('Pipeline'),
+ ...defaultTableClasses,
+ columnClass: 'gl-w-10p',
+ },
+ {
+ key: 'stage',
+ label: __('Stage'),
+ ...defaultTableClasses,
+ columnClass: 'gl-w-10p',
+ },
+ {
+ key: 'name',
+ label: __('Name'),
+ ...defaultTableClasses,
+ columnClass: 'gl-w-15p',
+ },
+ {
+ key: 'duration',
+ label: __('Duration'),
+ ...defaultTableClasses,
+ columnClass: 'gl-w-15p',
+ },
+ {
+ key: 'coverage',
+ label: __('Coverage'),
+ tdClass: coverageTdClasses,
+ thClass: defaultTableClasses.thClass,
+ columnClass: 'gl-w-10p',
+ },
+ {
+ key: 'actions',
+ label: '',
+ ...defaultTableClasses,
+ columnClass: 'gl-w-10p',
+ },
+];
+
+export const JOBS_TAB_FIELDS = DEFAULT_FIELDS.filter((field) => field.key !== 'pipeline');
diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
index c8763d4767e..88937185a8c 100644
--- a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
+++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
@@ -7,6 +7,7 @@ query getJobs(
$statuses: [CiJobStatus!]
) {
project(fullPath: $fullPath) {
+ id
jobs(after: $after, before: $before, first: $first, last: $last, statuses: $statuses) {
pageInfo {
endCursor
@@ -18,6 +19,7 @@ query getJobs(
artifacts {
nodes {
downloadPath
+ fileType
}
}
allowFailure
@@ -27,6 +29,7 @@ query getJobs(
triggered
createdByTag
detailedStatus {
+ id
detailsPath
group
icon
@@ -34,6 +37,7 @@ query getJobs(
text
tooltip
action {
+ id
buttonTitle
icon
method
@@ -51,11 +55,13 @@ query getJobs(
id
path
user {
+ id
webPath
avatarUrl
}
}
stage {
+ id
name
}
name
@@ -70,6 +76,7 @@ query getJobs(
userPermissions {
readBuild
readJobArtifacts
+ updateBuild
}
}
}
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table.vue b/app/assets/javascripts/jobs/components/table/jobs_table.vue
index 298c99c4162..f513d2090fa 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table.vue
@@ -1,75 +1,17 @@
<script>
import { GlTable } from '@gitlab/ui';
-import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants';
-import { s__, __ } from '~/locale';
+import { s__ } from '~/locale';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
import ActionsCell from './cells/actions_cell.vue';
import DurationCell from './cells/duration_cell.vue';
import JobCell from './cells/job_cell.vue';
import PipelineCell from './cells/pipeline_cell.vue';
-
-const defaultTableClasses = {
- tdClass: 'gl-p-5!',
- thClass: DEFAULT_TH_CLASSES,
-};
-// eslint-disable-next-line @gitlab/require-i18n-strings
-const coverageTdClasses = `${defaultTableClasses.tdClass} gl-display-none! gl-lg-display-table-cell!`;
+import { DEFAULT_FIELDS } from './constants';
export default {
i18n: {
emptyText: s__('Jobs|No jobs to show'),
},
- fields: [
- {
- key: 'status',
- label: __('Status'),
- ...defaultTableClasses,
- columnClass: 'gl-w-10p',
- },
- {
- key: 'job',
- label: __('Job'),
- ...defaultTableClasses,
- columnClass: 'gl-w-20p',
- },
- {
- key: 'pipeline',
- label: __('Pipeline'),
- ...defaultTableClasses,
- columnClass: 'gl-w-10p',
- },
- {
- key: 'stage',
- label: __('Stage'),
- ...defaultTableClasses,
- columnClass: 'gl-w-10p',
- },
- {
- key: 'name',
- label: __('Name'),
- ...defaultTableClasses,
- columnClass: 'gl-w-15p',
- },
- {
- key: 'duration',
- label: __('Duration'),
- ...defaultTableClasses,
- columnClass: 'gl-w-15p',
- },
- {
- key: 'coverage',
- label: __('Coverage'),
- tdClass: coverageTdClasses,
- thClass: defaultTableClasses.thClass,
- columnClass: 'gl-w-10p',
- },
- {
- key: 'actions',
- label: '',
- ...defaultTableClasses,
- columnClass: 'gl-w-10p',
- },
- ],
components: {
ActionsCell,
CiBadge,
@@ -83,6 +25,11 @@ export default {
type: Array,
required: true,
},
+ tableFields: {
+ type: Array,
+ required: false,
+ default: () => DEFAULT_FIELDS,
+ },
},
methods: {
formatCoverage(coverage) {
@@ -95,7 +42,7 @@ export default {
<template>
<gl-table
:items="jobs"
- :fields="$options.fields"
+ :fields="tableFields"
:tbody-tr-attr="{ 'data-testid': 'jobs-table-row' }"
:empty-text="$options.i18n.emptyText"
show-empty
diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js
index 1fb6a6f9850..e078a6c2319 100644
--- a/app/assets/javascripts/jobs/index.js
+++ b/app/assets/javascripts/jobs/index.js
@@ -1,10 +1,11 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import BridgeApp from './bridge/app.vue';
import JobApp from './components/job_app.vue';
import createStore from './store';
-export default () => {
- const element = document.getElementById('js-job-vue-app');
-
+const initializeJobPage = (element) => {
const store = createStore();
// Let's start initializing the store (i.e. fetching data) right away
@@ -51,3 +52,35 @@ export default () => {
},
});
};
+
+const initializeBridgePage = (el) => {
+ const { buildName, emptyStateIllustrationPath } = el.dataset;
+
+ Vue.use(VueApollo);
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ provide: {
+ buildName,
+ emptyStateIllustrationPath,
+ },
+ render(h) {
+ return h(BridgeApp);
+ },
+ });
+};
+
+export default () => {
+ const jobElement = document.getElementById('js-job-page');
+ const bridgeElement = document.getElementById('js-bridge-page');
+
+ if (jobElement) {
+ initializeJobPage(jobElement);
+ } else {
+ initializeBridgePage(bridgeElement);
+ }
+};
diff --git a/app/assets/javascripts/vue_shared/components/delete_label_modal.vue b/app/assets/javascripts/labels/components/delete_label_modal.vue
index 1ff0938d086..1ff0938d086 100644
--- a/app/assets/javascripts/vue_shared/components/delete_label_modal.vue
+++ b/app/assets/javascripts/labels/components/delete_label_modal.vue
diff --git a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue b/app/assets/javascripts/labels/components/promote_label_modal.vue
index e708cd32fff..e708cd32fff 100644
--- a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue
+++ b/app/assets/javascripts/labels/components/promote_label_modal.vue
diff --git a/app/assets/javascripts/create_label.js b/app/assets/javascripts/labels/create_label_dropdown.js
index 07fe2c7e01f..8c166158a44 100644
--- a/app/assets/javascripts/create_label.js
+++ b/app/assets/javascripts/labels/create_label_dropdown.js
@@ -1,8 +1,8 @@
/* eslint-disable func-names */
import $ from 'jquery';
-import Api from './api';
-import { humanize } from './lib/utils/text_utility';
+import Api from '~/api';
+import { humanize } from '~/lib/utils/text_utility';
export default class CreateLabelDropdown {
constructor($el, namespacePath, projectPath) {
diff --git a/app/assets/javascripts/issue_show/event_hub.js b/app/assets/javascripts/labels/event_hub.js
index e31806ad199..e31806ad199 100644
--- a/app/assets/javascripts/issue_show/event_hub.js
+++ b/app/assets/javascripts/labels/event_hub.js
diff --git a/app/assets/javascripts/group_label_subscription.js b/app/assets/javascripts/labels/group_label_subscription.js
index 378259eb9c8..ea69e6585e6 100644
--- a/app/assets/javascripts/group_label_subscription.js
+++ b/app/assets/javascripts/labels/group_label_subscription.js
@@ -1,8 +1,8 @@
import $ from 'jquery';
import { __ } from '~/locale';
import { fixTitle, hide } from '~/tooltips';
-import createFlash from './flash';
-import axios from './lib/utils/axios_utils';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
const tooltipTitles = {
group: __('Unsubscribe at group level'),
diff --git a/app/assets/javascripts/labels/index.js b/app/assets/javascripts/labels/index.js
new file mode 100644
index 00000000000..22a9c0a89c0
--- /dev/null
+++ b/app/assets/javascripts/labels/index.js
@@ -0,0 +1,137 @@
+import $ from 'jquery';
+import Vue from 'vue';
+import { BV_SHOW_MODAL } from '~/lib/utils/constants';
+import Translate from '~/vue_shared/translate';
+import DeleteLabelModal from './components/delete_label_modal.vue';
+import PromoteLabelModal from './components/promote_label_modal.vue';
+import eventHub from './event_hub';
+import GroupLabelSubscription from './group_label_subscription';
+import LabelManager from './label_manager';
+import ProjectLabelSubscription from './project_label_subscription';
+
+export function initDeleteLabelModal(optionalProps = {}) {
+ new Vue({
+ render(h) {
+ return h(DeleteLabelModal, {
+ props: {
+ selector: '.js-delete-label-modal-button',
+ ...optionalProps,
+ },
+ });
+ },
+ }).$mount();
+}
+
+export function initLabels() {
+ if ($('.prioritized-labels').length) {
+ new LabelManager(); // eslint-disable-line no-new
+ }
+ $('.label-subscription').each((i, el) => {
+ const $el = $(el);
+
+ if ($el.find('.dropdown-group-label').length) {
+ new GroupLabelSubscription($el); // eslint-disable-line no-new
+ } else {
+ new ProjectLabelSubscription($el); // eslint-disable-line no-new
+ }
+ });
+}
+
+export function initLabelIndex() {
+ Vue.use(Translate);
+
+ initLabels();
+ initDeleteLabelModal();
+
+ const onRequestFinished = ({ labelUrl, successful }) => {
+ const button = document.querySelector(
+ `.js-promote-project-label-button[data-url="${labelUrl}"]`,
+ );
+
+ if (!successful) {
+ button.removeAttribute('disabled');
+ }
+ };
+
+ const onRequestStarted = (labelUrl) => {
+ const button = document.querySelector(
+ `.js-promote-project-label-button[data-url="${labelUrl}"]`,
+ );
+ button.setAttribute('disabled', '');
+ eventHub.$once('promoteLabelModal.requestFinished', onRequestFinished);
+ };
+
+ const promoteLabelButtons = document.querySelectorAll('.js-promote-project-label-button');
+
+ return new Vue({
+ el: '#js-promote-label-modal',
+ data() {
+ return {
+ modalProps: {
+ labelTitle: '',
+ labelColor: '',
+ labelTextColor: '',
+ url: '',
+ groupName: '',
+ },
+ };
+ },
+ mounted() {
+ eventHub.$on('promoteLabelModal.props', this.setModalProps);
+ eventHub.$emit('promoteLabelModal.mounted');
+
+ promoteLabelButtons.forEach((button) => {
+ button.removeAttribute('disabled');
+ button.addEventListener('click', () => {
+ this.$root.$emit(BV_SHOW_MODAL, 'promote-label-modal');
+ eventHub.$once('promoteLabelModal.requestStarted', onRequestStarted);
+
+ this.setModalProps({
+ labelTitle: button.dataset.labelTitle,
+ labelColor: button.dataset.labelColor,
+ labelTextColor: button.dataset.labelTextColor,
+ url: button.dataset.url,
+ groupName: button.dataset.groupName,
+ });
+ });
+ });
+ },
+ beforeDestroy() {
+ eventHub.$off('promoteLabelModal.props', this.setModalProps);
+ },
+ methods: {
+ setModalProps(modalProps) {
+ this.modalProps = modalProps;
+ },
+ },
+ render(createElement) {
+ return createElement(PromoteLabelModal, {
+ props: this.modalProps,
+ });
+ },
+ });
+}
+
+export function initAdminLabels() {
+ const labelsContainer = document.querySelector('.js-admin-labels-container');
+ const pagination = labelsContainer?.querySelector('.gl-pagination');
+ const emptyState = document.querySelector('.js-admin-labels-empty-state');
+
+ function removeLabelSuccessCallback() {
+ this.closest('li').classList.add('gl-display-none!');
+
+ const labelsCount = document.querySelectorAll(
+ 'ul.manage-labels-list li:not(.gl-display-none\\!)',
+ ).length;
+
+ // display the empty state if there are no more labels
+ if (labelsCount < 1 && !pagination && emptyState) {
+ emptyState.classList.remove('gl-display-none');
+ labelsContainer.classList.add('gl-display-none');
+ }
+ }
+
+ document.querySelectorAll('.js-remove-label').forEach((row) => {
+ row.addEventListener('ajax:success', removeLabelSuccessCallback);
+ });
+}
diff --git a/app/assets/javascripts/label_manager.js b/app/assets/javascripts/labels/label_manager.js
index e0068edbb9b..1927ac6e1ec 100644
--- a/app/assets/javascripts/label_manager.js
+++ b/app/assets/javascripts/labels/label_manager.js
@@ -3,9 +3,9 @@
import $ from 'jquery';
import Sortable from 'sortablejs';
import { dispose } from '~/tooltips';
-import createFlash from './flash';
-import axios from './lib/utils/axios_utils';
-import { __ } from './locale';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
export default class LabelManager {
constructor({ togglePriorityButton, prioritizedLabels, otherLabels } = {}) {
diff --git a/app/assets/javascripts/labels.js b/app/assets/javascripts/labels/labels.js
index cd8cf0d354c..cd8cf0d354c 100644
--- a/app/assets/javascripts/labels.js
+++ b/app/assets/javascripts/labels/labels.js
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels/labels_select.js
index 68019a35dbb..9d8ee165df2 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels/labels_select.js
@@ -4,12 +4,12 @@
import $ from 'jquery';
import { difference, isEqual, escape, sortBy, template, union } from 'lodash';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import IssuableBulkUpdateActions from '~/issuable_bulk_update_sidebar/issuable_bulk_update_actions';
+import IssuableBulkUpdateActions from '~/issuable/bulk_update_sidebar/issuable_bulk_update_actions';
import { isScopedLabel } from '~/lib/utils/common_utils';
-import CreateLabelDropdown from './create_label';
-import createFlash from './flash';
-import axios from './lib/utils/axios_utils';
-import { sprintf, __ } from './locale';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { sprintf, __ } from '~/locale';
+import CreateLabelDropdown from './create_label_dropdown';
export default class LabelsSelect {
constructor(els, options = {}) {
@@ -101,7 +101,7 @@ export default class LabelsSelect {
if (IS_EE) {
/**
* For Scoped labels, the last label selected with the
- * same key will be applied to the current issueable.
+ * same key will be applied to the current issuable.
*
* If these are the labels - priority::1, priority::2; and if
* we apply them in the same order, only priority::2 will stick
diff --git a/app/assets/javascripts/project_label_subscription.js b/app/assets/javascripts/labels/project_label_subscription.js
index f7804c2faa4..b2612e9ede0 100644
--- a/app/assets/javascripts/project_label_subscription.js
+++ b/app/assets/javascripts/labels/project_label_subscription.js
@@ -1,8 +1,8 @@
import $ from 'jquery';
import { fixTitle } from '~/tooltips';
-import createFlash from './flash';
-import axios from './lib/utils/axios_utils';
-import { __ } from './locale';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
const tooltipTitles = {
group: {
diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js
index 47ede8cb1bb..47568f0ecff 100644
--- a/app/assets/javascripts/lib/dompurify.js
+++ b/app/assets/javascripts/lib/dompurify.js
@@ -3,7 +3,7 @@ import { getNormalizedURL, getBaseURL, relativePathToAbsolute } from '~/lib/util
const defaultConfig = {
// Safely allow SVG <use> tags
- ADD_TAGS: ['use', 'gl-emoji'],
+ ADD_TAGS: ['use', 'gl-emoji', 'copy-code'],
// Prevent possible XSS attacks with data-* attributes used by @rails/ujs
// See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1421
FORBID_ATTR: ['data-remote', 'data-url', 'data-type', 'data-method'],
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index a82dad7e2c9..7235b38848c 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -735,3 +735,14 @@ export const isFeatureFlagEnabled = (flag) => window.gon.features?.[flag];
export const convertArrayToCamelCase = (array) => array.map((i) => convertToCamelCase(i));
export const isLoggedIn = () => Boolean(window.gon?.current_user_id);
+
+/**
+ * This method takes in array of objects with snake_case
+ * property names and returns a new array of objects with
+ * camelCase property names
+ *
+ * @param {Array[Object]} array - Array to be converted
+ * @returns {Array[Object]} Converted array
+ */
+export const convertArrayOfObjectsToCamelCase = (array) =>
+ array.map((o) => convertObjectPropsToCamelCase(o));
diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js
index 36c6545164e..a108b02bcbf 100644
--- a/app/assets/javascripts/lib/utils/constants.js
+++ b/app/assets/javascripts/lib/utils/constants.js
@@ -17,6 +17,7 @@ export const BV_HIDE_MODAL = 'bv::hide::modal';
export const BV_HIDE_TOOLTIP = 'bv::hide::tooltip';
export const BV_DROPDOWN_SHOW = 'bv::dropdown::show';
export const BV_DROPDOWN_HIDE = 'bv::dropdown::hide';
+export const BV_COLLAPSE_STATE = 'bv::collapse::state';
export const DEFAULT_TH_CLASSES =
'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!';
diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js
index f7687a929de..b52a736f153 100644
--- a/app/assets/javascripts/lib/utils/dom_utils.js
+++ b/app/assets/javascripts/lib/utils/dom_utils.js
@@ -89,3 +89,17 @@ export const getParents = (element) => {
return parents;
};
+
+/**
+ * This method takes a HTML element and an object of attributes
+ * to save repeated calls to `setAttribute` when multiple
+ * attributes need to be set.
+ *
+ * @param {HTMLElement} el
+ * @param {Object} attributes
+ */
+export const setAttributes = (el, attributes) => {
+ Object.keys(attributes).forEach((key) => {
+ el.setAttribute(key, attributes[key]);
+ });
+};
diff --git a/app/assets/javascripts/lib/utils/intersection_observer.js b/app/assets/javascripts/lib/utils/intersection_observer.js
new file mode 100644
index 00000000000..0959df9a186
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/intersection_observer.js
@@ -0,0 +1,28 @@
+import { memoize } from 'lodash';
+
+import { uuids } from './uuids';
+
+export const create = memoize((options = {}) => {
+ const id = uuids()[0];
+
+ return {
+ id,
+ observer: new IntersectionObserver((entries) => {
+ entries.forEach((entry) => {
+ entry.target.dispatchEvent(
+ new CustomEvent(`IntersectionUpdate`, { detail: { entry, observer: id } }),
+ );
+
+ if (entry.isIntersecting) {
+ entry.target.dispatchEvent(
+ new CustomEvent(`IntersectionAppear`, { detail: { observer: id } }),
+ );
+ } else {
+ entry.target.dispatchEvent(
+ new CustomEvent(`IntersectionDisappear`, { detail: { observer: id } }),
+ );
+ }
+ });
+ }, options),
+ };
+});
diff --git a/app/assets/javascripts/lib/utils/navigation_utility.js b/app/assets/javascripts/lib/utils/navigation_utility.js
index 1579b225e44..029e9f5fd9f 100644
--- a/app/assets/javascripts/lib/utils/navigation_utility.js
+++ b/app/assets/javascripts/lib/utils/navigation_utility.js
@@ -13,3 +13,42 @@ export default function findAndFollowLink(selector) {
visitUrl(link);
}
}
+
+export function prefetchDocument(url) {
+ const newPrefetchLink = document.createElement('link');
+ newPrefetchLink.rel = 'prefetch';
+ newPrefetchLink.href = url;
+ newPrefetchLink.setAttribute('as', 'document');
+ document.head.appendChild(newPrefetchLink);
+}
+
+export function initPrefetchLinks(selector) {
+ document.querySelectorAll(selector).forEach((el) => {
+ let mouseOverTimer;
+
+ const mouseOutHandler = () => {
+ if (mouseOverTimer) {
+ clearTimeout(mouseOverTimer);
+ mouseOverTimer = undefined;
+ }
+ };
+
+ const mouseOverHandler = () => {
+ el.addEventListener('mouseout', mouseOutHandler, { once: true, passive: true });
+
+ mouseOverTimer = setTimeout(() => {
+ if (el.href) prefetchDocument(el.href);
+
+ // Only execute once
+ el.removeEventListener('mouseover', mouseOverHandler, true);
+
+ mouseOverTimer = undefined;
+ }, 100);
+ };
+
+ el.addEventListener('mouseover', mouseOverHandler, {
+ capture: true,
+ passive: true,
+ });
+ });
+}
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index e53a39cde06..12462a2575e 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -1,6 +1,6 @@
export const DASH_SCOPE = '-';
-const PATH_SEPARATOR = '/';
+export const PATH_SEPARATOR = '/';
const PATH_SEPARATOR_LEADING_REGEX = new RegExp(`^${PATH_SEPARATOR}+`);
const PATH_SEPARATOR_ENDING_REGEX = new RegExp(`${PATH_SEPARATOR}+$`);
const SHA_REGEX = /[\da-f]{40}/gi;
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index e422d9b1a32..e221a54d9c6 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -14,9 +14,9 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { initRails } from '~/lib/utils/rails_ujs';
import * as popovers from '~/popovers';
import * as tooltips from '~/tooltips';
-import { initHeaderSearchApp } from '~/header_search';
+import { initPrefetchLinks } from '~/lib/utils/navigation_utility';
import initAlertHandler from './alert_handler';
-import { removeFlashClickListener } from './flash';
+import { addDismissFlashClickListener } from './flash';
import initTodoToggle from './header';
import initLayoutNav from './layout_nav';
import { logHelloDeferred } from './lib/logger/hello_deferred';
@@ -36,6 +36,7 @@ import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
import initBroadcastNotifications from './broadcast_notification';
import { initTopNav } from './nav';
+import { initCopyCodeButton } from './behaviors/copy_code';
import 'ee_else_ce/main_ee';
import 'jh_else_ce/main_jh';
@@ -90,6 +91,7 @@ function deferredInitialisation() {
initTopNav();
initBreadcrumbs();
initTodoToggle();
+ initPrefetchLinks('.js-prefetch-document');
initLogoAnimation();
initServicePingConsent();
initUserPopovers();
@@ -97,25 +99,31 @@ function deferredInitialisation() {
initPersistentUserCallouts();
initDefaultTrackers();
initFeatureHighlight();
-
- if (gon.features?.newHeaderSearch) {
- initHeaderSearchApp();
- } else {
- const search = document.querySelector('#search');
- if (search) {
- search.addEventListener(
- 'focus',
- () => {
+ initCopyCodeButton();
+
+ const search = document.querySelector('#search');
+ if (search) {
+ search.addEventListener(
+ 'focus',
+ () => {
+ if (gon.features?.newHeaderSearch) {
+ import(/* webpackChunkName: 'globalSearch' */ '~/header_search')
+ .then(async ({ initHeaderSearchApp }) => {
+ await initHeaderSearchApp();
+ document.querySelector('#search').focus();
+ })
+ .catch(() => {});
+ } else {
import(/* webpackChunkName: 'globalSearch' */ './search_autocomplete')
.then(({ default: initSearchAutocomplete }) => {
const searchDropdown = initSearchAutocomplete();
searchDropdown.onSearchInputFocus();
})
.catch(() => {});
- },
- { once: true },
- );
- }
+ }
+ },
+ { once: true },
+ );
}
addSelectOnFocusBehaviour('.js-select-on-focus');
@@ -259,7 +267,7 @@ if (flashContainer && flashContainer.children.length) {
flashContainer
.querySelectorAll('.flash-alert, .flash-notice, .flash-success')
.forEach((flashEl) => {
- removeFlashClickListener(flashEl);
+ addDismissFlashClickListener(flashEl);
});
}
diff --git a/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue
index 35966be7363..d092283338c 100644
--- a/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue
+++ b/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue
@@ -53,6 +53,7 @@ export default {
:title="s__('Member|Deny access')"
:is-access-request="true"
icon="close"
+ button-category="primary"
/>
</div>
</action-button-group>
diff --git a/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue
index 91062c222f4..ab9abfd38c6 100644
--- a/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue
+++ b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue
@@ -41,6 +41,8 @@ export default {
<remove-member-button
:member-id="member.id"
:message="message"
+ icon="remove"
+ button-category="primary"
:title="s__('Member|Revoke invite')"
is-invite
/>
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 69137ce615b..01606d07554 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
@@ -30,7 +30,17 @@ export default {
icon: {
type: String,
required: false,
- default: 'remove',
+ default: undefined,
+ },
+ buttonText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ buttonCategory: {
+ type: String,
+ required: false,
+ default: 'secondary',
},
isAccessRequest: {
type: Boolean,
@@ -79,10 +89,12 @@ export default {
<gl-button
v-gl-tooltip
variant="danger"
+ :category="buttonCategory"
:title="title"
:aria-label="title"
:icon="icon"
data-qa-selector="delete_member_button"
@click="showRemoveMemberModal(modalData)"
- />
+ ><template v-if="buttonText">{{ buttonText }}</template></gl-button
+ >
</template>
diff --git a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
index 44d658c90a0..594da7f68cc 100644
--- a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
+++ b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
@@ -1,5 +1,5 @@
<script>
-import { s__, sprintf } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils';
import ActionButtonGroup from './action_button_group.vue';
import LeaveButton from './leave_button.vue';
@@ -23,6 +23,10 @@ export default {
type: Boolean,
required: true,
},
+ isInvitedUser: {
+ type: Boolean,
+ required: true,
+ },
permissions: {
type: Object,
required: true,
@@ -56,6 +60,15 @@ export default {
obstacles: parseUserDeletionObstacles(this.member.user),
};
},
+ removeMemberButtonText() {
+ return this.isInvitedUser ? null : __('Remove user');
+ },
+ removeMemberButtonIcon() {
+ return this.isInvitedUser ? 'remove' : '';
+ },
+ removeMemberButtonCategory() {
+ return this.isInvitedUser ? 'primary' : 'secondary';
+ },
},
};
</script>
@@ -70,6 +83,9 @@ export default {
:member-type="member.type"
:user-deletion-obstacles="userDeletionObstaclesUserData"
:message="message"
+ :icon="removeMemberButtonIcon"
+ :button-text="removeMemberButtonText"
+ :button-category="removeMemberButtonCategory"
:title="s__('Member|Remove member')"
/>
</div>
diff --git a/app/assets/javascripts/members/components/table/member_action_buttons.vue b/app/assets/javascripts/members/components/table/member_action_buttons.vue
index 6f15f079d2d..971b1a8435e 100644
--- a/app/assets/javascripts/members/components/table/member_action_buttons.vue
+++ b/app/assets/javascripts/members/components/table/member_action_buttons.vue
@@ -30,6 +30,10 @@ export default {
type: Boolean,
required: true,
},
+ isInvitedUser: {
+ type: Boolean,
+ required: true,
+ },
},
computed: {
actionButtonComponent() {
@@ -53,5 +57,6 @@ export default {
:member="member"
:permissions="permissions"
:is-current-user="isCurrentUser"
+ :is-invited-user="isInvitedUser"
/>
</template>
diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue
index 202f3aa89e1..de733ae75df 100644
--- a/app/assets/javascripts/members/components/table/members_table.vue
+++ b/app/assets/javascripts/members/components/table/members_table.vue
@@ -8,6 +8,7 @@ import initUserPopovers from '~/user_popovers';
import {
FIELDS,
ACTIVE_TAB_QUERY_PARAM_NAME,
+ TAB_QUERY_PARAM_VALUES,
MEMBER_STATE_AWAITING,
USER_STATE_BLOCKED_PENDING_APPROVAL,
BADGE_LABELS_PENDING_OWNER_APPROVAL,
@@ -82,6 +83,9 @@ export default {
return paramName && currentPage && perPage && totalItems;
},
+ isInvitedUser() {
+ return this.tabQueryParamValue === TAB_QUERY_PARAM_VALUES.invite;
+ },
},
mounted() {
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
@@ -275,6 +279,7 @@ export default {
<member-action-buttons
:member-type="memberType"
:is-current-user="isCurrentUser"
+ :is-invited-user="isInvitedUser"
:permissions="permissions"
:member="member"
/>
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
index cf02c6fbd6b..8c96f8a017e 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
+++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import initIssuableSidebar from '../init_issuable_sidebar';
+import { initIssuableSidebar } from '~/issuable';
import MergeConflictsResolverApp from './merge_conflict_resolver_app.vue';
import { createStore } from './store';
diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js
deleted file mode 100644
index b4e53c1fab6..00000000000
--- a/app/assets/javascripts/milestone.js
+++ /dev/null
@@ -1,49 +0,0 @@
-import $ from 'jquery';
-import createFlash from './flash';
-import axios from './lib/utils/axios_utils';
-import { __ } from './locale';
-
-export default class Milestone {
- constructor() {
- this.bindTabsSwitching();
- this.loadInitialTab();
- }
-
- bindTabsSwitching() {
- return $('a[data-toggle="tab"]').on('show.bs.tab', (e) => {
- const $target = $(e.target);
-
- window.location.hash = $target.attr('href');
- this.loadTab($target);
- });
- }
-
- loadInitialTab() {
- const $target = $(`.js-milestone-tabs a:not(.active)[href="${window.location.hash}"]`);
-
- if ($target.length) {
- $target.tab('show');
- } else {
- this.loadTab($('.js-milestone-tabs a.active'));
- }
- }
- // eslint-disable-next-line class-methods-use-this
- loadTab($target) {
- const endpoint = $target.data('endpoint');
- const tabElId = $target.attr('href');
-
- if (endpoint && !$target.hasClass('is-loaded')) {
- axios
- .get(endpoint)
- .then(({ data }) => {
- $(tabElId).html(data.html);
- $target.addClass('is-loaded');
- })
- .catch(() =>
- createFlash({
- message: __('Error loading milestone tab'),
- }),
- );
- }
- }
-}
diff --git a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue
index 34f9fe778ea..34f9fe778ea 100644
--- a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue
+++ b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue
diff --git a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue b/app/assets/javascripts/milestones/components/promote_milestone_modal.vue
index b41611001ab..b41611001ab 100644
--- a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
+++ b/app/assets/javascripts/milestones/components/promote_milestone_modal.vue
diff --git a/app/assets/javascripts/pages/milestones/shared/event_hub.js b/app/assets/javascripts/milestones/event_hub.js
index e31806ad199..e31806ad199 100644
--- a/app/assets/javascripts/pages/milestones/shared/event_hub.js
+++ b/app/assets/javascripts/milestones/event_hub.js
diff --git a/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js b/app/assets/javascripts/milestones/index.js
index 3aeff2db2e0..2ca5f104b4f 100644
--- a/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js
+++ b/app/assets/javascripts/milestones/index.js
@@ -1,10 +1,58 @@
+import $ from 'jquery';
import Vue from 'vue';
+import initDatePicker from '~/behaviors/date_picker';
+import GLForm from '~/gl_form';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
+import Milestone from '~/milestones/milestone';
+import Sidebar from '~/right_sidebar';
+import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar';
import Translate from '~/vue_shared/translate';
+import ZenMode from '~/zen_mode';
import DeleteMilestoneModal from './components/delete_milestone_modal.vue';
+import PromoteMilestoneModal from './components/promote_milestone_modal.vue';
import eventHub from './event_hub';
-export default () => {
+export function initForm(initGFM = true) {
+ new ZenMode(); // eslint-disable-line no-new
+ initDatePicker();
+
+ // eslint-disable-next-line no-new
+ new GLForm($('.milestone-form'), {
+ emojis: true,
+ members: initGFM,
+ issues: initGFM,
+ mergeRequests: initGFM,
+ epics: initGFM,
+ milestones: initGFM,
+ labels: initGFM,
+ snippets: initGFM,
+ vulnerabilities: initGFM,
+ });
+}
+
+export function initShow() {
+ new Milestone(); // eslint-disable-line no-new
+ new Sidebar(); // eslint-disable-line no-new
+ new MountMilestoneSidebar(); // eslint-disable-line no-new
+}
+
+export function initPromoteMilestoneModal() {
+ Vue.use(Translate);
+
+ const promoteMilestoneModal = document.getElementById('promote-milestone-modal');
+ if (!promoteMilestoneModal) {
+ return null;
+ }
+
+ return new Vue({
+ el: promoteMilestoneModal,
+ render(createElement) {
+ return createElement(PromoteMilestoneModal);
+ },
+ });
+}
+
+export function initDeleteMilestoneModal() {
Vue.use(Translate);
const onRequestFinished = ({ milestoneUrl, successful }) => {
@@ -72,4 +120,4 @@ export default () => {
});
},
});
-};
+}
diff --git a/app/assets/javascripts/milestones/milestone.js b/app/assets/javascripts/milestones/milestone.js
new file mode 100644
index 00000000000..05102f73f92
--- /dev/null
+++ b/app/assets/javascripts/milestones/milestone.js
@@ -0,0 +1,49 @@
+import createFlash from '~/flash';
+import { sanitize } from '~/lib/dompurify';
+import axios from '~/lib/utils/axios_utils';
+import { historyPushState } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+import { GlTabsBehavior, TAB_SHOWN_EVENT } from '~/tabs';
+
+export default class Milestone {
+ constructor() {
+ this.tabsEl = document.querySelector('.js-milestone-tabs');
+ this.glTabs = new GlTabsBehavior(this.tabsEl);
+ this.loadedTabs = new WeakSet();
+
+ this.bindTabsSwitching();
+ this.loadInitialTab();
+ }
+
+ bindTabsSwitching() {
+ this.tabsEl.addEventListener(TAB_SHOWN_EVENT, (event) => {
+ const tab = event.target;
+ const { activeTabPanel } = event.detail;
+ historyPushState(tab.getAttribute('href'));
+ this.loadTab(tab, activeTabPanel);
+ });
+ }
+
+ loadInitialTab() {
+ const tab = this.tabsEl.querySelector(`a[href="${window.location.hash}"]`);
+ this.glTabs.activateTab(tab || this.glTabs.activeTab);
+ }
+ loadTab(tab, tabPanel) {
+ const { endpoint } = tab.dataset;
+
+ if (endpoint && !this.loadedTabs.has(tab)) {
+ axios
+ .get(endpoint)
+ .then(({ data }) => {
+ // eslint-disable-next-line no-param-reassign
+ tabPanel.innerHTML = sanitize(data.html);
+ this.loadedTabs.add(tab);
+ })
+ .catch(() =>
+ createFlash({
+ message: __('Error loading milestone tab'),
+ }),
+ );
+ }
+ }
+}
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestones/milestone_select.js
index aa8a40b6a87..c95ec3dd10b 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestones/milestone_select.js
@@ -6,9 +6,9 @@ import { template, escape } from 'lodash';
import Api from '~/api';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { __, sprintf } from '~/locale';
-import { sortMilestonesByDueDate } from '~/milestones/milestone_utils';
-import axios from './lib/utils/axios_utils';
-import { timeFor, parsePikadayDate, dateInWords } from './lib/utils/datetime_utility';
+import { sortMilestonesByDueDate } from '~/milestones/utils';
+import axios from '~/lib/utils/axios_utils';
+import { timeFor, parsePikadayDate, dateInWords } from '~/lib/utils/datetime_utility';
export default class MilestoneSelect {
constructor(currentProject, els, options = {}) {
diff --git a/app/assets/javascripts/milestones/milestone_utils.js b/app/assets/javascripts/milestones/utils.js
index 3ae5e676138..3ae5e676138 100644
--- a/app/assets/javascripts/milestones/milestone_utils.js
+++ b/app/assets/javascripts/milestones/utils.js
diff --git a/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql b/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql
index 302383512d3..a61d601cd34 100644
--- a/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql
+++ b/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql
@@ -7,6 +7,7 @@ query getDashboardValidationWarnings(
id
environments(name: $environmentName) {
nodes {
+ id
name
metricsDashboard(path: $dashboardPath) {
path
diff --git a/app/assets/javascripts/mr_popover/components/mr_popover.vue b/app/assets/javascripts/mr_popover/components/mr_popover.vue
index 791fdf7660f..d99a3adb358 100644
--- a/app/assets/javascripts/mr_popover/components/mr_popover.vue
+++ b/app/assets/javascripts/mr_popover/components/mr_popover.vue
@@ -65,6 +65,9 @@ export default {
return humanMRStates.open;
}
},
+ title() {
+ return this.mergeRequest?.title || this.mergeRequestTitle;
+ },
showDetails() {
return Object.keys(this.mergeRequest).length > 0;
},
@@ -89,7 +92,7 @@ export default {
<template>
<gl-popover :target="target" boundary="viewport" placement="top" show>
<div class="mr-popover">
- <div v-if="$apollo.loading">
+ <div v-if="$apollo.queries.mergeRequest.loading">
<gl-skeleton-loading :lines="1" class="animation-container-small mt-1" />
</div>
<div v-else-if="showDetails" class="d-flex align-items-center justify-content-between">
@@ -97,13 +100,13 @@ export default {
<div :class="`issuable-status-box status-box ${statusBoxClass}`">
{{ stateHumanName }}
</div>
- <span class="text-secondary">Opened <time v-text="formattedTime"></time></span>
+ <span class="gl-text-secondary">Opened <time v-text="formattedTime"></time></span>
</div>
<ci-icon v-if="detailedStatus" :status="detailedStatus" />
</div>
- <h5 class="my-2">{{ mergeRequestTitle }}</h5>
+ <h5 v-if="!$apollo.queries.mergeRequest.loading" class="my-2">{{ title }}</h5>
<!-- eslint-disable @gitlab/vue-require-i18n-strings -->
- <div class="text-secondary">
+ <div class="gl-text-secondary">
{{ `${projectPath}!${mergeRequestIID}` }}
</div>
<!-- eslint-enable @gitlab/vue-require-i18n-strings -->
diff --git a/app/assets/javascripts/mr_popover/queries/merge_request.query.graphql b/app/assets/javascripts/mr_popover/queries/merge_request.query.graphql
index 37d4bc88a69..b3e5d89d495 100644
--- a/app/assets/javascripts/mr_popover/queries/merge_request.query.graphql
+++ b/app/assets/javascripts/mr_popover/queries/merge_request.query.graphql
@@ -1,10 +1,15 @@
query mergeRequest($projectPath: ID!, $mergeRequestIID: String!) {
project(fullPath: $projectPath) {
+ id
mergeRequest(iid: $mergeRequestIID) {
+ id
+ title
createdAt
state
headPipeline {
+ id
detailedStatus {
+ id
icon
group
}
diff --git a/app/assets/javascripts/network/branch_graph.js b/app/assets/javascripts/network/branch_graph.js
index 54fe9d19002..71894b4ff3e 100644
--- a/app/assets/javascripts/network/branch_graph.js
+++ b/app/assets/javascripts/network/branch_graph.js
@@ -98,6 +98,7 @@ export default class BranchGraph {
let len = 0;
let cuday = 0;
let cumonth = '';
+ let cuyear = '';
const { r } = this;
r.rect(0, 0, 40, this.barHeight).attr({
fill: '#222',
@@ -108,24 +109,21 @@ export default class BranchGraph {
const ref = this.days;
for (mm = 0, len = ref.length; mm < len; mm += 1) {
const day = ref[mm];
- if (cuday !== day[0] || cumonth !== day[1]) {
+ if (cuday !== day[0] || cumonth !== day[1] || cuyear !== day[2]) {
// Dates
r.text(55, this.offsetY + this.unitTime * mm, day[0]).attr({
font: '12px Monaco, monospace',
fill: '#BBB',
});
- [cuday] = day;
}
- if (cumonth !== day[1]) {
+ if (cumonth !== day[1] || cuyear !== day[2]) {
// Months
r.text(20, this.offsetY + this.unitTime * mm, day[1]).attr({
font: '12px Monaco, monospace',
fill: '#EEE',
});
-
- // eslint-disable-next-line prefer-destructuring
- cumonth = day[1];
}
+ [cuday, cumonth, cuyear] = day;
}
this.renderPartialGraph();
return this.bindEvents();
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 4e31fdcd4f0..996c008b881 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -11,7 +11,6 @@ import httpStatusCodes from '~/lib/utils/http_status';
import {
capitalizeFirstCharacter,
convertToCamelCase,
- splitCamelCase,
slugifyWithUnderscore,
} from '~/lib/utils/text_utility';
import { sprintf } from '~/locale';
@@ -77,7 +76,15 @@ export default {
]),
...mapState(['isToggleStateButtonLoading']),
noteableDisplayName() {
- return splitCamelCase(this.noteableType).toLowerCase();
+ const displayNameMap = {
+ [constants.ISSUE_NOTEABLE_TYPE]: this.$options.i18n.issue,
+ [constants.EPIC_NOTEABLE_TYPE]: this.$options.i18n.epic,
+ [constants.MERGE_REQUEST_NOTEABLE_TYPE]: this.$options.i18n.mergeRequest,
+ };
+
+ const noteableTypeKey =
+ constants.NOTEABLE_TYPE_MAPPING[this.noteableType] || constants.ISSUE_NOTEABLE_TYPE;
+ return displayNameMap[noteableTypeKey];
},
isLoggedIn() {
return this.getUserData.id;
@@ -103,15 +110,13 @@ export default {
const openOrClose = this.isOpen ? 'close' : 'reopen';
if (this.note.length) {
- return sprintf(this.$options.i18n.actionButtonWithNote, {
+ return sprintf(this.$options.i18n.actionButton.withNote[openOrClose], {
actionText: this.commentButtonTitle,
- openOrClose,
noteable: this.noteableDisplayName,
});
}
- return sprintf(this.$options.i18n.actionButton, {
- openOrClose: capitalizeFirstCharacter(openOrClose),
+ return sprintf(this.$options.i18n.actionButton.withoutNote[openOrClose], {
noteable: this.noteableDisplayName,
});
},
@@ -151,13 +156,8 @@ export default {
draftEndpoint() {
return this.getNotesData.draftsPath;
},
- issuableTypeTitle() {
- return this.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE
- ? this.$options.i18n.mergeRequest
- : this.$options.i18n.issue;
- },
isIssue() {
- return this.noteableDisplayName === constants.ISSUE_NOTEABLE_TYPE;
+ return constants.NOTEABLE_TYPE_MAPPING[this.noteableType] === constants.ISSUE_NOTEABLE_TYPE;
},
trackingLabel() {
return slugifyWithUnderscore(`${this.commentButtonTitle} button`);
@@ -329,7 +329,7 @@ export default {
<template>
<div>
<note-signed-out-widget v-if="!isLoggedIn" />
- <discussion-locked-widget v-else-if="!canCreateNote" :issuable-type="issuableTypeTitle" />
+ <discussion-locked-widget v-else-if="!canCreateNote" :issuable-type="noteableDisplayName" />
<ul v-else-if="canCreateNote" class="notes notes-form timeline">
<timeline-entry-item class="note-form">
<gl-alert
diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue
index b04aa74d46e..b2d5910fd3f 100644
--- a/app/assets/javascripts/notes/components/diff_with_note.vue
+++ b/app/assets/javascripts/notes/components/diff_with_note.vue
@@ -1,5 +1,8 @@
<script>
-import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
+import {
+ GlDeprecatedSkeletonLoading as GlSkeletonLoading,
+ GlSafeHtmlDirective as SafeHtml,
+} from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue';
@@ -17,6 +20,9 @@ export default {
DiffViewer,
ImageDiffOverlay,
},
+ directives: {
+ SafeHtml,
+ },
props: {
discussion: {
type: Object,
@@ -92,11 +98,7 @@ export default {
>
<td :class="line.type" class="diff-line-num old_line">{{ line.old_line }}</td>
<td :class="line.type" class="diff-line-num new_line">{{ line.new_line }}</td>
- <td
- :class="line.type"
- class="line_content"
- v-html="trimChar(line.rich_text) /* eslint-disable-line vue/no-v-html */"
- ></td>
+ <td v-safe-html="trimChar(line.rich_text)" :class="line.type" class="line_content"></td>
</tr>
</template>
<tr v-if="!hasTruncatedDiffLines" class="line_holder line-holder-placeholder">
diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue
index 88f053aed67..102afaf308f 100644
--- a/app/assets/javascripts/notes/components/discussion_filter.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter.vue
@@ -39,7 +39,7 @@ export default {
};
},
computed: {
- ...mapGetters(['getNotesDataByProp', 'timelineEnabled']),
+ ...mapGetters(['getNotesDataByProp', 'timelineEnabled', 'isLoading']),
currentFilter() {
if (!this.currentValue) return this.filters[0];
return this.filters.find((filter) => filter.value === this.currentValue);
@@ -119,6 +119,7 @@ export default {
class="gl-mr-3 full-width-mobile discussion-filter-container js-discussion-filter-container"
data-qa-selector="discussion_filter_dropdown"
:text="currentFilter.title"
+ :disabled="isLoading"
>
<div v-for="filter in filters" :key="filter.value" class="dropdown-item-wrapper">
<gl-dropdown-item
diff --git a/app/assets/javascripts/notes/components/discussion_locked_widget.vue b/app/assets/javascripts/notes/components/discussion_locked_widget.vue
index 2f215e36d5b..8ac3f6bea68 100644
--- a/app/assets/javascripts/notes/components/discussion_locked_widget.vue
+++ b/app/assets/javascripts/notes/components/discussion_locked_widget.vue
@@ -1,7 +1,6 @@
<script>
import { GlLink, GlIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
-import Issuable from '~/vue_shared/mixins/issuable';
import issuableStateMixin from '../mixins/issuable_state';
export default {
@@ -9,8 +8,17 @@ export default {
GlIcon,
GlLink,
},
- mixins: [Issuable, issuableStateMixin],
+ mixins: [issuableStateMixin],
+ props: {
+ issuableType: {
+ required: true,
+ type: String,
+ },
+ },
computed: {
+ issuableDisplayName() {
+ return this.issuableType.replace(/_/g, ' ');
+ },
projectArchivedWarning() {
return __('This project is archived and cannot be commented on.');
},
diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue
index d1df4eb848b..6fcfa66ea49 100644
--- a/app/assets/javascripts/notes/components/discussion_notes.vue
+++ b/app/assets/javascripts/notes/components/discussion_notes.vue
@@ -1,6 +1,5 @@
<script>
import { mapGetters, mapActions } from 'vuex';
-import { GlIntersectionObserver } from '@gitlab/ui';
import { __ } from '~/locale';
import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue';
import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue';
@@ -17,9 +16,7 @@ export default {
ToggleRepliesWidget,
NoteEditedText,
DiscussionNotesRepliesWrapper,
- GlIntersectionObserver,
},
- inject: ['discussionObserverHandler'],
props: {
discussion: {
type: Object,
@@ -57,11 +54,7 @@ export default {
},
},
computed: {
- ...mapGetters([
- 'userCanReply',
- 'previousUnresolvedDiscussionId',
- 'firstUnresolvedDiscussionId',
- ]),
+ ...mapGetters(['userCanReply']),
hasReplies() {
return Boolean(this.replies.length);
},
@@ -84,20 +77,9 @@ export default {
url: this.discussion.discussion_path,
};
},
- isFirstUnresolved() {
- return this.firstUnresolvedDiscussionId === this.discussion.id;
- },
- },
- observerOptions: {
- threshold: 0,
- rootMargin: '0px 0px -50% 0px',
},
methods: {
- ...mapActions([
- 'toggleDiscussion',
- 'setSelectedCommentPositionHover',
- 'setCurrentDiscussionId',
- ]),
+ ...mapActions(['toggleDiscussion', 'setSelectedCommentPositionHover']),
componentName(note) {
if (note.isPlaceholderNote) {
if (note.placeholderType === SYSTEM_NOTE) {
@@ -128,18 +110,6 @@ export default {
this.setSelectedCommentPositionHover();
}
},
- observerTriggered(entry) {
- this.discussionObserverHandler({
- entry,
- isFirstUnresolved: this.isFirstUnresolved,
- currentDiscussion: { ...this.discussion },
- isDiffsPage: !this.isOverviewTab,
- functions: {
- setCurrentDiscussionId: this.setCurrentDiscussionId,
- getPreviousUnresolvedDiscussionId: this.previousUnresolvedDiscussionId,
- },
- });
- },
},
};
</script>
@@ -152,35 +122,33 @@ export default {
@mouseleave="handleMouseLeave(discussion)"
>
<template v-if="shouldGroupReplies">
- <gl-intersection-observer :options="$options.observerOptions" @update="observerTriggered">
- <component
- :is="componentName(firstNote)"
- :note="componentData(firstNote)"
- :line="line || diffLine"
- :discussion-file="discussion.diff_file"
- :commit="commit"
- :help-page-path="helpPagePath"
- :show-reply-button="userCanReply"
- :discussion-root="true"
- :discussion-resolve-path="discussion.resolve_path"
- :is-overview-tab="isOverviewTab"
- @handleDeleteNote="$emit('deleteNote')"
- @startReplying="$emit('startReplying')"
- >
- <template #discussion-resolved-text>
- <note-edited-text
- v-if="discussion.resolved"
- :edited-at="discussion.resolved_at"
- :edited-by="discussion.resolved_by"
- :action-text="resolvedText"
- class-name="discussion-headline-light js-discussion-headline discussion-resolved-text"
- />
- </template>
- <template #avatar-badge>
- <slot name="avatar-badge"></slot>
- </template>
- </component>
- </gl-intersection-observer>
+ <component
+ :is="componentName(firstNote)"
+ :note="componentData(firstNote)"
+ :line="line || diffLine"
+ :discussion-file="discussion.diff_file"
+ :commit="commit"
+ :help-page-path="helpPagePath"
+ :show-reply-button="userCanReply"
+ :discussion-root="true"
+ :discussion-resolve-path="discussion.resolve_path"
+ :is-overview-tab="isOverviewTab"
+ @handleDeleteNote="$emit('deleteNote')"
+ @startReplying="$emit('startReplying')"
+ >
+ <template #discussion-resolved-text>
+ <note-edited-text
+ v-if="discussion.resolved"
+ :edited-at="discussion.resolved_at"
+ :edited-by="discussion.resolved_by"
+ :action-text="resolvedText"
+ class-name="discussion-headline-light js-discussion-headline discussion-resolved-text"
+ />
+ </template>
+ <template #avatar-badge>
+ <slot name="avatar-badge"></slot>
+ </template>
+ </component>
<discussion-notes-replies-wrapper :is-diff-discussion="discussion.diff_discussion">
<toggle-replies-widget
v-if="hasReplies"
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index c09582d6287..f465ad23a06 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -149,7 +149,7 @@ export default {
},
},
safeHtmlConfig: {
- ADD_TAGS: ['use', 'gl-emoji'],
+ ADD_TAGS: ['use', 'gl-emoji', 'copy-code'],
},
};
</script>
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 77f796fe8b0..8e32c3b3073 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -223,17 +223,20 @@ export default {
})
.catch((err) => {
this.removePlaceholderNotes();
- const msg = __(
- 'Your comment could not be submitted! Please check your network connection and try again.',
- );
- createFlash({
- message: msg,
- parent: this.$el,
- });
+ this.handleSaveError(err); // The 'err' parameter is being used in JH, don't remove it
this.$refs.noteForm.note = noteText;
callback(err);
});
},
+ handleSaveError() {
+ const msg = __(
+ 'Your comment could not be submitted! Please check your network connection and try again.',
+ );
+ createFlash({
+ message: msg,
+ parent: this.$el,
+ });
+ },
deleteNoteHandler(note) {
this.$emit('noteDeleted', this.discussion, note);
},
@@ -280,6 +283,7 @@ export default {
v-if="showDraft(discussion.reply_id)"
:key="`draft_${discussion.id}`"
:draft="draftForDiscussion(discussion.reply_id)"
+ :line="line"
/>
<div
v-else-if="canShowReplyActions && showReplies"
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index e35d8d94289..3250a4818c7 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -331,17 +331,20 @@ export default {
this.isEditing = true;
this.setSelectedCommentPositionHover();
this.$nextTick(() => {
- const msg = __('Something went wrong while editing your comment. Please try again.');
- createFlash({
- message: msg,
- parent: this.$el,
- });
+ this.handleUpdateError(response); // The 'response' parameter is being used in JH, don't remove it
this.recoverNoteContent(noteText);
callback();
});
}
});
},
+ handleUpdateError() {
+ const msg = __('Something went wrong while editing your comment. Please try again.');
+ createFlash({
+ message: msg,
+ parent: this.$el,
+ });
+ },
formCancelHandler({ shouldConfirm, isDirty }) {
if (shouldConfirm && isDirty) {
// eslint-disable-next-line no-alert
@@ -388,7 +391,7 @@ export default {
<div
v-if="showMultiLineComment"
data-testid="multiline-comment"
- class="gl-mb-3 gl-text-gray-500 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3"
+ class="gl-mb-5 gl-text-gray-500 gl-border-gray-100 gl-border-b-solid gl-border-b-1 gl-pb-4"
>
<gl-sprintf :message="__('Comment on lines %{startLine} to %{endLine}')">
<template #startLine>
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 3ab3e7a20d4..c4924cd41f5 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -8,7 +8,6 @@ import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item
import OrderedLayout from '~/vue_shared/components/ordered_layout.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import draftNote from '../../batch_comments/components/draft_note.vue';
-import { discussionIntersectionObserverHandlerFactory } from '../../diffs/utils/discussions';
import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility';
import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
@@ -39,9 +38,6 @@ export default {
TimelineEntryItem,
},
mixins: [glFeatureFlagsMixin()],
- provide: {
- discussionObserverHandler: discussionIntersectionObserverHandlerFactory(),
- },
props: {
noteableData: {
type: Object,
@@ -108,6 +104,10 @@ export default {
});
}
+ if (this.sortDirDesc) {
+ return skeletonNotes.concat(this.discussions);
+ }
+
return this.discussions.concat(skeletonNotes);
},
canReply() {
diff --git a/app/assets/javascripts/notes/components/sidebar_subscription.vue b/app/assets/javascripts/notes/components/sidebar_subscription.vue
index 047c04c8482..52dadc7b4c3 100644
--- a/app/assets/javascripts/notes/components/sidebar_subscription.vue
+++ b/app/assets/javascripts/notes/components/sidebar_subscription.vue
@@ -1,6 +1,6 @@
<script>
import { mapActions } from 'vuex';
-import { IssuableType } from '~/issue_show/constants';
+import { IssuableType } from '~/issues/constants';
import { fetchPolicies } from '~/lib/graphql';
import { confidentialityQueries } from '~/sidebar/constants';
import { defaultClient as gqlClient } from '~/sidebar/graphql';
diff --git a/app/assets/javascripts/notes/i18n.js b/app/assets/javascripts/notes/i18n.js
index 1ffb94d11ad..951fa9733d4 100644
--- a/app/assets/javascripts/notes/i18n.js
+++ b/app/assets/javascripts/notes/i18n.js
@@ -9,15 +9,27 @@ export const COMMENT_FORM = {
issue: __('issue'),
startThread: __('Start thread'),
mergeRequest: __('merge request'),
+ epic: __('epic'),
bodyPlaceholder: __('Write a comment or drag your files here…'),
confidential: s__('Notes|Make this comment confidential'),
- confidentialVisibility: s__('Notes|Confidential comments are only visible to project members'),
+ confidentialVisibility: s__(
+ 'Notes|Confidential comments are only visible to members with the role of Reporter or higher',
+ ),
discussionThatNeedsResolution: __(
'Discuss a specific suggestion or question that needs to be resolved.',
),
discussion: __('Discuss a specific suggestion or question.'),
actionButtonWithNote: __('%{actionText} & %{openOrClose} %{noteable}'),
- actionButton: __('%{openOrClose} %{noteable}'),
+ actionButton: {
+ withNote: {
+ reopen: __('%{actionText} & reopen %{noteable}'),
+ close: __('%{actionText} & close %{noteable}'),
+ },
+ withoutNote: {
+ reopen: __('Reopen %{noteable}'),
+ close: __('Close %{noteable}'),
+ },
+ },
submitButton: {
startThread: __('Start thread'),
comment: __('Comment'),
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index c862a29ad9c..50b05ea9d69 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -601,7 +601,8 @@ export const setLoadingState = ({ commit }, data) => {
commit(types.SET_NOTES_LOADING_STATE, data);
};
-export const filterDiscussion = ({ dispatch }, { path, filter, persistFilter }) => {
+export const filterDiscussion = ({ commit, dispatch }, { path, filter, persistFilter }) => {
+ commit(types.CLEAR_DISCUSSIONS);
dispatch('setLoadingState', true);
dispatch('fetchDiscussions', { path, filter, persistFilter })
.then(() => {
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index fcd2846ff0d..ebda08a3d62 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -1,6 +1,7 @@
export const ADD_NEW_NOTE = 'ADD_NEW_NOTE';
export const ADD_NEW_REPLY_TO_DISCUSSION = 'ADD_NEW_REPLY_TO_DISCUSSION';
export const ADD_OR_UPDATE_DISCUSSIONS = 'ADD_OR_UPDATE_DISCUSSIONS';
+export const CLEAR_DISCUSSIONS = 'CLEAR_DISCUSSIONS';
export const DELETE_NOTE = 'DELETE_NOTE';
export const REMOVE_PLACEHOLDER_NOTES = 'REMOVE_PLACEHOLDER_NOTES';
export const SET_NOTES_DATA = 'SET_NOTES_DATA';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index 1a99750ddb3..ba19ecd0c04 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -129,6 +129,10 @@ export default {
Object.assign(state, { userData: data });
},
+ [types.CLEAR_DISCUSSIONS](state) {
+ state.discussions = [];
+ },
+
[types.ADD_OR_UPDATE_DISCUSSIONS](state, discussionsData) {
discussionsData.forEach((d) => {
const discussion = { ...d };
diff --git a/app/assets/javascripts/packages/list/packages_list_app_bundle.js b/app/assets/javascripts/packages/list/packages_list_app_bundle.js
deleted file mode 100644
index 2911cf70a33..00000000000
--- a/app/assets/javascripts/packages/list/packages_list_app_bundle.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import Vue from 'vue';
-import Translate from '~/vue_shared/translate';
-import PackagesListApp from './components/packages_list_app.vue';
-import { createStore } from './stores';
-
-Vue.use(Translate);
-
-export default () => {
- const el = document.getElementById('js-vue-packages-list');
- const store = createStore();
- store.dispatch('setInitialState', el.dataset);
-
- return new Vue({
- el,
- store,
- components: {
- PackagesListApp,
- },
- render(createElement) {
- return createElement('packages-list-app');
- },
- });
-};
diff --git a/app/assets/javascripts/packages/shared/constants.js b/app/assets/javascripts/packages/shared/constants.js
deleted file mode 100644
index c284b8358b4..00000000000
--- a/app/assets/javascripts/packages/shared/constants.js
+++ /dev/null
@@ -1,49 +0,0 @@
-import { s__ } from '~/locale';
-
-export const PackageType = {
- CONAN: 'conan',
- MAVEN: 'maven',
- NPM: 'npm',
- NUGET: 'nuget',
- PYPI: 'pypi',
- COMPOSER: 'composer',
- RUBYGEMS: 'rubygems',
- GENERIC: 'generic',
- DEBIAN: 'debian',
- HELM: 'helm',
-};
-
-// we want this separated from the main dictionary to avoid it being pulled in the search of package
-export const TERRAFORM_PACKAGE_TYPE = 'terraform_module';
-
-export const TrackingActions = {
- DELETE_PACKAGE: 'delete_package',
- REQUEST_DELETE_PACKAGE: 'request_delete_package',
- CANCEL_DELETE_PACKAGE: 'cancel_delete_package',
- PULL_PACKAGE: 'pull_package',
- DELETE_PACKAGE_FILE: 'delete_package_file',
- REQUEST_DELETE_PACKAGE_FILE: 'request_delete_package_file',
- CANCEL_DELETE_PACKAGE_FILE: 'cancel_delete_package_file',
-};
-
-export const TrackingCategories = {
- [PackageType.MAVEN]: 'MavenPackages',
- [PackageType.NPM]: 'NpmPackages',
- [PackageType.CONAN]: 'ConanPackages',
-};
-
-export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert';
-export const DELETE_PACKAGE_ERROR_MESSAGE = s__(
- 'PackageRegistry|Something went wrong while deleting the package.',
-);
-export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__(
- 'PackageRegistry|Something went wrong while deleting the package file.',
-);
-export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__(
- 'PackageRegistry|Package file deleted successfully',
-);
-
-export const PACKAGE_ERROR_STATUS = 'error';
-export const PACKAGE_DEFAULT_STATUS = 'default';
-export const PACKAGE_HIDDEN_STATUS = 'hidden';
-export const PACKAGE_PROCESSING_STATUS = 'processing';
diff --git a/app/assets/javascripts/packages/shared/utils.js b/app/assets/javascripts/packages/shared/utils.js
deleted file mode 100644
index 7e86e5b2991..00000000000
--- a/app/assets/javascripts/packages/shared/utils.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import { s__ } from '~/locale';
-import { PackageType, TrackingCategories } from './constants';
-
-export const packageTypeToTrackCategory = (type) =>
- // eslint-disable-next-line @gitlab/require-i18n-strings
- `UI::${TrackingCategories[type]}`;
-
-export const beautifyPath = (path) => (path ? path.split('/').join(' / ') : '');
-
-export const getPackageTypeLabel = (packageType) => {
- switch (packageType) {
- case PackageType.CONAN:
- return s__('PackageRegistry|Conan');
- case PackageType.MAVEN:
- return s__('PackageRegistry|Maven');
- case PackageType.NPM:
- return s__('PackageRegistry|npm');
- case PackageType.NUGET:
- return s__('PackageRegistry|NuGet');
- case PackageType.PYPI:
- return s__('PackageRegistry|PyPI');
- case PackageType.RUBYGEMS:
- return s__('PackageRegistry|RubyGems');
- case PackageType.COMPOSER:
- return s__('PackageRegistry|Composer');
- case PackageType.GENERIC:
- return s__('PackageRegistry|Generic');
- case PackageType.DEBIAN:
- return s__('PackageRegistry|Debian');
- case PackageType.HELM:
- return s__('PackageRegistry|Helm');
- default:
- return null;
- }
-};
-
-export const getCommitLink = ({ project_path: projectPath, pipeline = {} }, isGroup = false) => {
- if (isGroup) {
- return `/${projectPath}/commit/${pipeline.sha}`;
- }
-
- return `../commit/${pipeline.sha}`;
-};
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue
index f857c96c9d1..7a8a1bbcf09 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue
@@ -82,6 +82,7 @@ export default {
ref="deleteModal"
modal-id="delete-tag-modal"
ok-variant="danger"
+ size="sm"
:action-primary="{
text: __('Delete'),
attributes: [{ variant: 'danger' }, { disabled: disablePrimaryButton }],
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 e9e36151fe6..d988ad8d8ca 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
@@ -46,7 +46,6 @@ export default {
data() {
return {
containerRepository: {},
- fetchTagsCount: false,
};
},
apollo: {
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 3e19a646f53..2d32295b537 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
@@ -1,7 +1,8 @@
<script>
-import { GlButton, GlKeysetPagination } from '@gitlab/ui';
import createFlash from '~/flash';
+import { n__ } from '~/locale';
import { joinPaths } from '~/lib/utils/url_utility';
+import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
import {
REMOVE_TAGS_BUTTON_TITLE,
TAGS_LIST_TITLE,
@@ -16,11 +17,10 @@ import TagsLoader from './tags_loader.vue';
export default {
name: 'TagsList',
components: {
- GlButton,
- GlKeysetPagination,
TagsListRow,
EmptyState,
TagsLoader,
+ RegistryList,
},
inject: ['config'],
props: {
@@ -61,11 +61,13 @@ export default {
},
data() {
return {
- selectedItems: {},
containerRepository: {},
};
},
computed: {
+ listTitle() {
+ return n__('%d tag', '%d tags', this.tags.length);
+ },
tags() {
return this.containerRepository?.tags?.nodes || [];
},
@@ -78,18 +80,9 @@ export default {
first: GRAPHQL_PAGE_SIZE,
};
},
- hasSelectedItems() {
- return this.tags.some((tag) => this.selectedItems[tag.name]);
- },
showMultiDeleteButton() {
return this.tags.some((tag) => tag.canDelete) && !this.isMobile;
},
- multiDeleteButtonIsDisabled() {
- return !this.hasSelectedItems || this.disabled;
- },
- showPagination() {
- return this.tagsPageInfo.hasPreviousPage || this.tagsPageInfo.hasNextPage;
- },
hasNoTags() {
return this.tags.length === 0;
},
@@ -98,19 +91,13 @@ export default {
},
},
methods: {
- updateSelectedItems(name) {
- this.$set(this.selectedItems, name, !this.selectedItems[name]);
- },
- mapTagsToBeDleeted(items) {
- return this.tags.filter((tag) => items[tag.name]);
- },
fetchNextPage() {
this.$apollo.queries.containerRepository.fetchMore({
variables: {
after: this.tagsPageInfo?.endCursor,
first: GRAPHQL_PAGE_SIZE,
},
- updateQuery(previousResult, { fetchMoreResult }) {
+ updateQuery(_, { fetchMoreResult }) {
return fetchMoreResult;
},
});
@@ -122,7 +109,7 @@ export default {
before: this.tagsPageInfo?.startCursor,
last: GRAPHQL_PAGE_SIZE,
},
- updateQuery(previousResult, { fetchMoreResult }) {
+ updateQuery(_, { fetchMoreResult }) {
return fetchMoreResult;
},
});
@@ -137,42 +124,27 @@ export default {
<template v-else>
<empty-state v-if="hasNoTags" :no-containers-image="config.noContainersImage" />
<template v-else>
- <div class="gl-display-flex gl-justify-content-space-between gl-mb-3">
- <h5 data-testid="list-title">
- {{ $options.i18n.TAGS_LIST_TITLE }}
- </h5>
-
- <gl-button
- v-if="showMultiDeleteButton"
- :disabled="multiDeleteButtonIsDisabled"
- category="secondary"
- variant="danger"
- @click="$emit('delete', mapTagsToBeDleeted(selectedItems))"
- >
- {{ $options.i18n.REMOVE_TAGS_BUTTON_TITLE }}
- </gl-button>
- </div>
- <tags-list-row
- v-for="(tag, index) in tags"
- :key="tag.path"
- :tag="tag"
- :first="index === 0"
- :selected="selectedItems[tag.name]"
- :is-mobile="isMobile"
- :disabled="disabled"
- @select="updateSelectedItems(tag.name)"
- @delete="$emit('delete', mapTagsToBeDleeted({ [tag.name]: true }))"
- />
- <div class="gl-display-flex gl-justify-content-center">
- <gl-keyset-pagination
- v-if="showPagination"
- :has-next-page="tagsPageInfo.hasNextPage"
- :has-previous-page="tagsPageInfo.hasPreviousPage"
- class="gl-mt-3"
- @prev="fetchPreviousPage"
- @next="fetchNextPage"
- />
- </div>
+ <registry-list
+ :title="listTitle"
+ :pagination="tagsPageInfo"
+ :items="tags"
+ id-property="name"
+ @prev-page="fetchPreviousPage"
+ @next-page="fetchNextPage"
+ @delete="$emit('delete', $event)"
+ >
+ <template #default="{ selectItem, isSelected, item, first }">
+ <tags-list-row
+ :tag="item"
+ :first="first"
+ :selected="isSelected(item)"
+ :is-mobile="isMobile"
+ :disabled="disabled"
+ @select="selectItem(item)"
+ @delete="$emit('delete', [item])"
+ />
+ </template>
+ </registry-list>
</template>
</template>
</div>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql
index 01cb7fa1cab..bc34e9b5ef2 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql
@@ -9,6 +9,7 @@ query getContainerRepositoriesDetails(
$sort: ContainerRepositorySort
) {
project(fullPath: $fullPath) @skip(if: $isGroupPage) {
+ id
containerRepositories(
name: $name
after: $after
@@ -24,6 +25,7 @@ query getContainerRepositoriesDetails(
}
}
group(fullPath: $fullPath) @include(if: $isGroupPage) {
+ id
containerRepositories(
name: $name
after: $after
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql
index b5a99fd9ac1..916740f41b8 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql
@@ -11,6 +11,7 @@ query getContainerRepositoryDetails($id: ID!) {
expirationPolicyStartedAt
expirationPolicyCleanupStatus
project {
+ id
visibility
path
containerExpirationPolicy {
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql
index a703c2dd0ac..502382010f9 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql
@@ -9,6 +9,7 @@ query getContainerRepositoryTags(
) {
containerRepository(id: $id) {
id
+ tagsCount
tags(after: $after, before: $before, first: $first, last: $last) {
nodes {
digest
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 feabc4f770b..bc6e3091f0e 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
@@ -25,9 +25,11 @@ import {
UNFINISHED_STATUS,
MISSING_OR_DELETED_IMAGE_BREADCRUMB,
ROOT_IMAGE_TEXT,
+ GRAPHQL_PAGE_SIZE,
} from '../constants/index';
import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.mutation.graphql';
import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql';
+import getContainerRepositoryTagsQuery from '../graphql/queries/get_container_repository_tags.query.graphql';
export default {
name: 'RegistryDetailsPage',
@@ -133,8 +135,8 @@ export default {
awaitRefetchQueries: true,
refetchQueries: [
{
- query: getContainerRepositoryDetailsQuery,
- variables: this.queryVariables,
+ query: getContainerRepositoryTagsQuery,
+ variables: { ...this.queryVariables, first: GRAPHQL_PAGE_SIZE },
},
],
});
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 73b957f42f2..3274de05803 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
@@ -388,6 +388,7 @@ export default {
<template #default="{ doDelete }">
<gl-modal
ref="deleteModal"
+ size="sm"
modal-id="delete-image-modal"
:action-primary="{ text: __('Remove'), attributes: { variant: 'danger' } }"
@primary="doDelete"
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 71e8cf4f634..eb112238c11 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
@@ -1,11 +1,11 @@
<script>
import {
GlAlert,
+ GlEmptyState,
GlFormGroup,
GlFormInputGroup,
GlSkeletonLoader,
GlSprintf,
- GlEmptyState,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -36,15 +36,15 @@ export default {
proxyNotAvailableText: s__(
'DependencyProxy|Dependency Proxy feature is limited to public groups for now.',
),
- proxyDisabledText: s__(
- 'DependencyProxy|Dependency Proxy disabled. To enable it, contact the group owner.',
- ),
proxyImagePrefix: s__('DependencyProxy|Dependency Proxy image prefix'),
copyImagePrefixText: s__('DependencyProxy|Copy prefix'),
blobCountAndSize: s__('DependencyProxy|Contains %{count} blobs of images (%{size})'),
pageTitle: s__('DependencyProxy|Dependency Proxy'),
noManifestTitle: s__('DependencyProxy|There are no images in the cache'),
},
+ links: {
+ DEPENDENCY_PROXY_DOCS_PATH,
+ },
data() {
return {
group: {},
@@ -70,9 +70,7 @@ export default {
},
];
},
- dependencyProxyEnabled() {
- return this.group?.dependencyProxySetting?.enabled;
- },
+
queryVariables() {
return { fullPath: this.groupPath, first: GRAPHQL_PAGE_SIZE };
},
@@ -122,7 +120,7 @@ export default {
<gl-skeleton-loader v-else-if="$apollo.queries.group.loading" />
- <div v-else-if="dependencyProxyEnabled" data-testid="main-area">
+ <div v-else data-testid="main-area">
<gl-form-group :label="$options.i18n.proxyImagePrefix">
<gl-form-input-group
readonly
@@ -161,8 +159,5 @@ export default {
:title="$options.i18n.noManifestTitle"
/>
</div>
- <gl-alert v-else :dismissible="false" data-testid="proxy-disabled">
- {{ $options.i18n.proxyDisabledText }}
- </gl-alert>
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql
index 63d5469c955..9241dccb2d5 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql
@@ -8,6 +8,7 @@ query getDependencyProxyDetails(
$before: String
) {
group(fullPath: $fullPath) {
+ id
dependencyProxyBlobCount
dependencyProxyTotalSize
dependencyProxyImagePrefix
@@ -16,6 +17,7 @@ query getDependencyProxyDetails(
}
dependencyProxyManifests(after: $after, before: $before, first: $first, last: $last) {
nodes {
+ id
createdAt
imageName
}
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 6016757c1b9..f198d2e1bfa 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
@@ -16,10 +16,13 @@ import { s__, __ } from '~/locale';
import TerraformTitle from '~/packages_and_registries/infrastructure_registry/details/components/details_title.vue';
import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue';
import Tracking from '~/tracking';
-import PackageListRow from '~/packages/shared/components/package_list_row.vue';
-import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue';
-import { TrackingActions, SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
-import { packageTypeToTrackCategory } from '~/packages/shared/utils';
+import PackageListRow from '~/packages_and_registries/infrastructure_registry/shared/package_list_row.vue';
+import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
+import {
+ TRACKING_ACTIONS,
+ SHOW_DELETE_SUCCESS_ALERT,
+} from '~/packages_and_registries/shared/constants';
+import { TRACK_CATEGORY } from '~/packages_and_registries/infrastructure_registry/shared/constants';
import PackageFiles from './package_files.vue';
import PackageHistory from './package_history.vue';
@@ -44,7 +47,7 @@ export default {
GlModal: GlModalDirective,
},
mixins: [Tracking.mixin()],
- trackingActions: { ...TrackingActions },
+ trackingActions: { ...TRACKING_ACTIONS },
data() {
return {
fileToDelete: null,
@@ -68,7 +71,7 @@ export default {
},
tracking() {
return {
- category: packageTypeToTrackCategory(this.packageEntity.package_type),
+ category: TRACK_CATEGORY,
};
},
hasVersions() {
@@ -86,7 +89,7 @@ export default {
}
},
async confirmPackageDeletion() {
- this.track(TrackingActions.DELETE_PACKAGE);
+ this.track(TRACKING_ACTIONS.DELETE_PACKAGE);
await this.deletePackage();
const returnTo =
!this.groupListUrl || document.referrer.includes(this.projectName)
@@ -96,12 +99,12 @@ export default {
window.location.replace(`${returnTo}?${modalQuery}`);
},
handleFileDelete(file) {
- this.track(TrackingActions.REQUEST_DELETE_PACKAGE_FILE);
+ this.track(TRACKING_ACTIONS.REQUEST_DELETE_PACKAGE_FILE);
this.fileToDelete = { ...file };
this.$refs.deleteFileModal.show();
},
confirmFileDelete() {
- this.track(TrackingActions.DELETE_PACKAGE_FILE);
+ this.track(TRACKING_ACTIONS.DELETE_PACKAGE_FILE);
this.deletePackageFile(this.fileToDelete.id);
this.fileToDelete = null;
},
@@ -203,6 +206,7 @@ export default {
<gl-modal
ref="deleteModal"
+ size="sm"
modal-id="delete-modal"
:action-primary="$options.modal.packageDeletePrimaryAction"
:action-cancel="$options.modal.cancelAction"
@@ -223,6 +227,7 @@ export default {
<gl-modal
ref="deleteFileModal"
+ size="sm"
modal-id="delete-file-modal"
:action-primary="$options.modal.fileDeletePrimaryAction"
:action-cancel="$options.modal.cancelAction"
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js
index a03fa8d9d63..26d4aa13715 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js
@@ -4,7 +4,7 @@ import {
DELETE_PACKAGE_ERROR_MESSAGE,
DELETE_PACKAGE_FILE_ERROR_MESSAGE,
DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
-} from '~/packages/shared/constants';
+} from '~/packages_and_registries/shared/constants';
import { FETCH_PACKAGE_VERSIONS_ERROR } from '../constants';
import * as types from './mutation_types';
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue
index 4928da862ea..c611f92036d 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue
@@ -1,7 +1,7 @@
<script>
import { mapState, mapActions } from 'vuex';
-import { LIST_KEY_PACKAGE_TYPE } from '~/packages/list/constants';
-import { sortableFields } from '~/packages/list/utils';
+import { LIST_KEY_PACKAGE_TYPE } from '~/packages_and_registries/infrastructure_registry/list/constants';
+import { sortableFields } from '~/packages_and_registries/infrastructure_registry/list/utils';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue
index 2a479c65d0c..2a479c65d0c 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue
diff --git a/app/assets/javascripts/packages/list/components/packages_list.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue
index 23ba070aa26..a5f367bc1f6 100644
--- a/app/assets/javascripts/packages/list/components/packages_list.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue
@@ -3,10 +3,10 @@ import { GlPagination, GlModal, GlSprintf } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
-import PackagesListRow from '../../shared/components/package_list_row.vue';
-import PackagesListLoader from '../../shared/components/packages_list_loader.vue';
-import { TrackingActions } from '../../shared/constants';
-import { packageTypeToTrackCategory } from '../../shared/utils';
+import PackagesListRow from '~/packages_and_registries/infrastructure_registry/shared/package_list_row.vue';
+import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
+import { TRACKING_ACTIONS } from '~/packages_and_registries/shared/constants';
+import { TRACK_CATEGORY } from '~/packages_and_registries/infrastructure_registry/shared/constants';
export default {
components: {
@@ -49,27 +49,24 @@ export default {
return this.itemToBeDeleted?.name ?? '';
},
tracking() {
- const category = this.itemToBeDeleted
- ? packageTypeToTrackCategory(this.itemToBeDeleted.package_type)
- : undefined;
return {
- category,
+ category: TRACK_CATEGORY,
};
},
},
methods: {
setItemToBeDeleted(item) {
this.itemToBeDeleted = { ...item };
- this.track(TrackingActions.REQUEST_DELETE_PACKAGE);
+ this.track(TRACKING_ACTIONS.REQUEST_DELETE_PACKAGE);
this.$refs.packageListDeleteModal.show();
},
deleteItemConfirmation() {
this.$emit('package:delete', this.itemToBeDeleted);
- this.track(TrackingActions.DELETE_PACKAGE);
+ this.track(TRACKING_ACTIONS.DELETE_PACKAGE);
this.itemToBeDeleted = null;
},
deleteItemCanceled() {
- this.track(TrackingActions.CANCEL_DELETE_PACKAGE);
+ this.track(TRACKING_ACTIONS.CANCEL_DELETE_PACKAGE);
this.itemToBeDeleted = null;
},
},
@@ -111,6 +108,7 @@ export default {
<gl-modal
ref="packageListDeleteModal"
+ size="sm"
modal-id="confirm-delete-pacakge"
ok-variant="danger"
@ok="deleteItemConfirmation"
diff --git a/app/assets/javascripts/packages/list/components/packages_list_app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue
index 31d90fa4dee..462618a7f12 100644
--- a/app/assets/javascripts/packages/list/components/packages_list_app.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue
@@ -4,13 +4,16 @@ import { mapActions, mapState } from 'vuex';
import createFlash from '~/flash';
import { historyReplaceState } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
-import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
-import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
+import {
+ SHOW_DELETE_SUCCESS_ALERT,
+ FILTERED_SEARCH_TERM,
+} from '~/packages_and_registries/shared/constants';
+
import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
-import InfrastructureTitle from '~/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue';
-import InfrastructureSearch from '~/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue';
-import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants';
-import PackageList from './packages_list.vue';
+import InfrastructureTitle from '~/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue';
+import InfrastructureSearch from '~/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue';
+import PackageList from '~/packages_and_registries/infrastructure_registry/list/components/packages_list.vue';
+import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages_and_registries/infrastructure_registry/list/constants';
export default {
components: {
diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/constants.js
index 4f5071e784b..7af3fc1c2db 100644
--- a/app/assets/javascripts/packages/list/constants.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/constants.js
@@ -1,10 +1,8 @@
-import { __, s__ } from '~/locale';
-import { PackageType } from '../shared/constants';
+import { __ } from '~/locale';
export const FETCH_PACKAGES_LIST_ERROR_MESSAGE = __(
'Something went wrong while fetching the packages list.',
);
-export const FETCH_PACKAGE_ERROR_MESSAGE = __('Something went wrong while fetching the package.');
export const DELETE_PACKAGE_SUCCESS_MESSAGE = __('Package deleted successfully');
export const DEFAULT_PAGE = 1;
@@ -17,14 +15,12 @@ export const LIST_KEY_PROJECT = 'project_path';
export const LIST_KEY_VERSION = 'version';
export const LIST_KEY_PACKAGE_TYPE = 'type';
export const LIST_KEY_CREATED_AT = 'created_at';
-export const LIST_KEY_ACTIONS = 'actions';
export const LIST_LABEL_NAME = __('Name');
export const LIST_LABEL_PROJECT = __('Project');
export const LIST_LABEL_VERSION = __('Version');
export const LIST_LABEL_PACKAGE_TYPE = __('Type');
export const LIST_LABEL_CREATED_AT = __('Published');
-export const LIST_LABEL_ACTIONS = '';
// The following is not translated because it is used to build a JavaScript exception error message
export const MISSING_DELETE_PATH_ERROR = 'Missing delete_api_path link';
@@ -52,48 +48,4 @@ export const SORT_FIELDS = [
},
];
-export const PACKAGE_TYPES = [
- {
- title: s__('PackageRegistry|Composer'),
- type: PackageType.COMPOSER,
- },
- {
- title: s__('PackageRegistry|Conan'),
- type: PackageType.CONAN,
- },
- {
- title: s__('PackageRegistry|Generic'),
- type: PackageType.GENERIC,
- },
-
- {
- title: s__('PackageRegistry|Maven'),
- type: PackageType.MAVEN,
- },
- {
- title: s__('PackageRegistry|npm'),
- type: PackageType.NPM,
- },
- {
- title: s__('PackageRegistry|NuGet'),
- type: PackageType.NUGET,
- },
- {
- title: s__('PackageRegistry|PyPI'),
- type: PackageType.PYPI,
- },
- {
- title: s__('PackageRegistry|RubyGems'),
- type: PackageType.RUBYGEMS,
- },
- {
- title: s__('PackageRegistry|Debian'),
- type: PackageType.DEBIAN,
- },
- {
- title: s__('PackageRegistry|Helm'),
- type: PackageType.HELM,
- },
-];
-
export const TERRAFORM_SEARCH_TYPE = Object.freeze({ value: { data: 'terraform_module' } });
diff --git a/app/assets/javascripts/packages/list/stores/actions.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js
index 81f587971c2..488860e5bc2 100644
--- a/app/assets/javascripts/packages/list/stores/actions.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js
@@ -1,7 +1,7 @@
import Api from '~/api';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
-import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages/shared/constants';
+import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages_and_registries/shared/constants';
import {
FETCH_PACKAGES_LIST_ERROR_MESSAGE,
DELETE_PACKAGE_SUCCESS_MESSAGE,
diff --git a/app/assets/javascripts/packages/list/stores/getters.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/getters.js
index 482c111b58b..5989303280e 100644
--- a/app/assets/javascripts/packages/list/stores/getters.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/getters.js
@@ -1,4 +1,4 @@
-import { beautifyPath } from '../../shared/utils';
+import { beautifyPath } from '~/packages_and_registries/shared/utils';
import { LIST_KEY_PROJECT } from '../constants';
export default (state) =>
diff --git a/app/assets/javascripts/packages/list/stores/index.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/index.js
index 1d6a4bf831d..1d6a4bf831d 100644
--- a/app/assets/javascripts/packages/list/stores/index.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/index.js
diff --git a/app/assets/javascripts/packages/list/stores/mutation_types.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/mutation_types.js
index 561ad97f7e3..561ad97f7e3 100644
--- a/app/assets/javascripts/packages/list/stores/mutation_types.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/mutation_types.js
diff --git a/app/assets/javascripts/packages/list/stores/mutations.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/mutations.js
index 98165e581b0..98165e581b0 100644
--- a/app/assets/javascripts/packages/list/stores/mutations.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/mutations.js
diff --git a/app/assets/javascripts/packages/list/stores/state.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/state.js
index 60f02eddc9f..60f02eddc9f 100644
--- a/app/assets/javascripts/packages/list/stores/state.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/state.js
diff --git a/app/assets/javascripts/packages/list/utils.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/utils.js
index 537b30d2ca4..537b30d2ca4 100644
--- a/app/assets/javascripts/packages/list/utils.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/utils.js
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js
index 7e6e98f4fb5..1467218dd41 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import { s__ } from '~/locale';
-import PackagesListApp from '~/packages/list/components/packages_list_app.vue';
-import { createStore } from '~/packages/list/stores';
+import PackagesListApp from '~/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue';
+import { createStore } from '~/packages_and_registries/infrastructure_registry/list/stores';
import Translate from '~/vue_shared/translate';
Vue.use(Translate);
@@ -18,9 +18,6 @@ export default () => {
PackagesListApp,
},
provide: {
- titleComponent: 'InfrastructureTitle',
- searchComponent: 'InfrastructureSearch',
- iconComponent: 'InfrastructureIconAndName',
emptyPageTitle: s__('InfrastructureRegistry|You have no Terraform modules in your project'),
noResultsText: s__(
'InfrastructureRegistry|Terraform modules are the main way to package and reuse resource configurations with Terraform. Learn more about how to %{noPackagesLinkStart}create Terraform modules%{noPackagesLinkEnd} in GitLab.',
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/constants.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/constants.js
new file mode 100644
index 00000000000..ab52ec01d40
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/constants.js
@@ -0,0 +1 @@
+export const TRACK_CATEGORY = 'UI::TerraformPackages';
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/infrastructure_icon_and_name.vue
index 3100a1a7296..3100a1a7296 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/infrastructure_icon_and_name.vue
diff --git a/app/assets/javascripts/packages/shared/components/package_list_row.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue
index eee0e470c7b..3c6b8344c34 100644
--- a/app/assets/javascripts/packages/shared/components/package_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue
@@ -3,11 +3,14 @@ import { GlButton, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gi
import { s__ } from '~/locale';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
-import { PACKAGE_ERROR_STATUS, PACKAGE_DEFAULT_STATUS } from '../constants';
-import { getPackageTypeLabel } from '../utils';
-import PackagePath from './package_path.vue';
-import PackageTags from './package_tags.vue';
-import PublishMethod from './publish_method.vue';
+import {
+ PACKAGE_ERROR_STATUS,
+ PACKAGE_DEFAULT_STATUS,
+} from '~/packages_and_registries/shared/constants';
+import PackagePath from '~/packages_and_registries/shared/components/package_path.vue';
+import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue';
+import PublishMethod from '~/packages_and_registries/shared/components/publish_method.vue';
+import InfrastructureIconAndName from '~/packages_and_registries/infrastructure_registry/shared/infrastructure_icon_and_name.vue';
export default {
name: 'PackageListRow',
@@ -20,23 +23,12 @@ export default {
PackagePath,
PublishMethod,
ListItem,
- PackageIconAndName: () =>
- import(/* webpackChunkName: 'package_registry_components' */ './package_icon_and_name.vue'),
- InfrastructureIconAndName: () =>
- import(
- /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue'
- ),
+ InfrastructureIconAndName,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [timeagoMixin],
- inject: {
- iconComponent: {
- from: 'iconComponent',
- default: 'PackageIconAndName',
- },
- },
props: {
packageEntity: {
type: Object,
@@ -63,9 +55,6 @@ export default {
},
},
computed: {
- packageType() {
- return getPackageTypeLabel(this.packageEntity.package_type);
- },
hasPipeline() {
return Boolean(this.packageEntity.pipeline);
},
@@ -130,9 +119,7 @@ export default {
</gl-sprintf>
</div>
- <component :is="iconComponent" v-if="showPackageType">
- {{ packageType }}
- </component>
+ <infrastructure-icon-and-name v-if="showPackageType" />
<package-path
v-if="hasProjectLink"
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue
index bcbeec72961..d49c1be5202 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue
@@ -15,7 +15,7 @@ import { convertToGraphQLId } from '~/graphql_shared/utils';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { objectToQuery } from '~/lib/utils/url_utility';
import { s__, __ } from '~/locale';
-import { packageTypeToTrackCategory } from '~/packages/shared/utils';
+import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils';
import AdditionalMetadata from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue';
import DependencyRow from '~/packages_and_registries/package_registry/components/details/dependency_row.vue';
import InstallationCommands from '~/packages_and_registries/package_registry/components/details/installation_commands.vue';
@@ -304,6 +304,7 @@ export default {
<template #default="{ deletePackage }">
<gl-modal
ref="deleteModal"
+ size="sm"
modal-id="delete-modal"
data-testid="delete-modal"
:action-primary="$options.modal.packageDeletePrimaryAction"
@@ -327,6 +328,7 @@ export default {
<gl-modal
ref="deleteFileModal"
+ size="sm"
modal-id="delete-file-modal"
:action-primary="$options.modal.fileDeletePrimaryAction"
:action-cancel="$options.modal.cancelAction"
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 44d7807639d..118c509828c 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
@@ -3,7 +3,7 @@ import { GlIcon, GlSprintf, GlBadge, GlResizeObserverDirective } from '@gitlab/u
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { __ } from '~/locale';
-import PackageTags from '~/packages/shared/components/package_tags.vue';
+import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue';
import { PACKAGE_TYPE_NUGET } from '~/packages_and_registries/package_registry/constants';
import { getPackageTypeLabel } from '~/packages_and_registries/package_registry/utils';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
index d218a405af6..1afd1b69db0 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
@@ -1,8 +1,8 @@
<script>
import { GlLink, GlSprintf, GlTruncate } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import PackageTags from '~/packages/shared/components/package_tags.vue';
-import PublishMethod from '~/packages/shared/components/publish_method.vue';
+import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue';
+import PublishMethod from '~/packages_and_registries/shared/components/publish_method.vue';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { PACKAGE_DEFAULT_STATUS } from '../../constants';
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 195ff7af583..6fd96c0654f 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,16 +1,16 @@
<script>
import { GlButton, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import {
PACKAGE_ERROR_STATUS,
PACKAGE_DEFAULT_STATUS,
} from '~/packages_and_registries/package_registry/constants';
-import { getPackageTypeLabel } from '~/packages/shared/utils';
-import PackagePath from '~/packages/shared/components/package_path.vue';
-import PackageTags from '~/packages/shared/components/package_tags.vue';
+import { getPackageTypeLabel } from '~/packages_and_registries/package_registry/utils';
+import PackagePath from '~/packages_and_registries/shared/components/package_path.vue';
+import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue';
import PublishMethod from '~/packages_and_registries/package_registry/components/list/publish_method.vue';
-import PackageIconAndName from '~/packages/shared/components/package_icon_and_name.vue';
+import PackageIconAndName from '~/packages_and_registries/shared/components/package_icon_and_name.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -40,7 +40,7 @@ export default {
},
computed: {
packageType() {
- return getPackageTypeLabel(this.packageEntity.packageType.toLowerCase());
+ return getPackageTypeLabel(this.packageEntity.packageType);
},
packageLink() {
const { project, id } = this.packageEntity;
@@ -64,6 +64,7 @@ export default {
},
i18n: {
erroredPackageText: s__('PackageRegistry|Invalid Package: failed metadata extraction'),
+ createdAt: __('Created %{timestamp}'),
},
};
</script>
@@ -127,8 +128,8 @@ export default {
</template>
<template #right-secondary>
- <span>
- <gl-sprintf :message="__('Created %{timestamp}')">
+ <span data-testid="created-date">
+ <gl-sprintf :message="$options.i18n.createdAt">
<template #timestamp>
<timeago-tooltip :time="packageEntity.createdAt" />
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
index 2a946544c2f..298ed9bccdb 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
@@ -2,7 +2,7 @@
import { GlModal, GlSprintf, GlKeysetPagination } from '@gitlab/ui';
import { s__ } from '~/locale';
import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue';
-import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue';
+import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
import {
DELETE_PACKAGE_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_TRACKING_ACTION,
@@ -124,6 +124,7 @@ export default {
<gl-modal
v-model="showDeleteModal"
modal-id="confirm-delete-pacakge"
+ size="sm"
ok-variant="danger"
@ok="deleteItemConfirmation"
@cancel="deleteItemCanceled"
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
index 9fd8880861c..ab6541e4264 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
@@ -1,4 +1,15 @@
import { s__, __ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+export {
+ DELETE_PACKAGE_TRACKING_ACTION,
+ REQUEST_DELETE_PACKAGE_TRACKING_ACTION,
+ CANCEL_DELETE_PACKAGE_TRACKING_ACTION,
+ PULL_PACKAGE_TRACKING_ACTION,
+ DELETE_PACKAGE_FILE_TRACKING_ACTION,
+ REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION,
+ CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
+} from '~/packages_and_registries/shared/constants';
export const PACKAGE_TYPE_CONAN = 'CONAN';
export const PACKAGE_TYPE_MAVEN = 'MAVEN';
@@ -11,14 +22,6 @@ export const PACKAGE_TYPE_GENERIC = 'GENERIC';
export const PACKAGE_TYPE_DEBIAN = 'DEBIAN';
export const PACKAGE_TYPE_HELM = 'HELM';
-export const DELETE_PACKAGE_TRACKING_ACTION = 'delete_package';
-export const REQUEST_DELETE_PACKAGE_TRACKING_ACTION = 'request_delete_package';
-export const CANCEL_DELETE_PACKAGE_TRACKING_ACTION = 'cancel_delete_package';
-export const PULL_PACKAGE_TRACKING_ACTION = 'pull_package';
-export const DELETE_PACKAGE_FILE_TRACKING_ACTION = 'delete_package_file';
-export const REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'request_delete_package_file';
-export const CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'cancel_delete_package_file';
-
export const TRACKING_LABEL_CODE_INSTRUCTION = 'code_instruction';
export const TRACKING_LABEL_CONAN_INSTALLATION = 'conan_installation';
export const TRACKING_LABEL_MAVEN_INSTALLATION = 'maven_installation';
@@ -134,3 +137,8 @@ export const PACKAGE_TYPES = [
s__('PackageRegistry|Debian'),
s__('PackageRegistry|Helm'),
];
+
+// links
+
+export const EMPTY_LIST_HELP_URL = helpPagePath('user/packages/package_registry/index');
+export const PACKAGE_HELP_URL = helpPagePath('user/packages/index');
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql
index aaf0eb54aff..66315fda9e9 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql
@@ -7,20 +7,24 @@ fragment PackageData on Package {
status
tags {
nodes {
+ id
name
}
}
- pipelines {
+ pipelines(last: 1) {
nodes {
+ id
sha
ref
commitPath
user {
+ id
name
}
}
}
project {
+ id
fullPath
webUrl
}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql
index 14aa14e9822..08ea0938a59 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql
@@ -8,6 +8,7 @@ query getPackageDetails($id: ID!) {
updatedAt
status
project {
+ id
path
}
tags(first: 10) {
@@ -25,9 +26,11 @@ query getPackageDetails($id: ID!) {
commitPath
path
user {
+ id
name
}
project {
+ id
name
webUrl
}
@@ -86,15 +89,18 @@ query getPackageDetails($id: ID!) {
}
}
... on PypiMetadata {
+ id
requiredPython
}
... on ConanMetadata {
+ id
packageChannel
packageUsername
recipe
recipePath
}
... on MavenMetadata {
+ id
appName
appGroup
appVersion
@@ -102,6 +108,7 @@ query getPackageDetails($id: ID!) {
}
... on NugetMetadata {
+ id
iconUrl
licenseUrl
projectUrl
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql
index e3115365f8b..4b913590949 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql
@@ -14,6 +14,7 @@ query getPackages(
$before: String
) {
project(fullPath: $fullPath) @skip(if: $isGroupPage) {
+ id
packages(
sort: $sort
packageName: $packageName
@@ -33,6 +34,7 @@ query getPackages(
}
}
group(fullPath: $fullPath) @include(if: $isGroupPage) {
+ id
packages(
sort: $groupSort
packageName: $packageName
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/index.js b/app/assets/javascripts/packages_and_registries/package_registry/index.js
new file mode 100644
index 00000000000..7ec931ff9a0
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/index.js
@@ -0,0 +1,30 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import { apolloProvider } from '~/packages_and_registries/package_registry/graphql/index';
+import PackageRegistry from '~/packages_and_registries/package_registry/pages/index.vue';
+import createRouter from './router';
+
+Vue.use(Translate);
+
+export default () => {
+ const el = document.getElementById('js-vue-packages-list');
+ const { endpoint, resourceId, fullPath, pageType, emptyListIllustration } = el.dataset;
+ const router = createRouter(endpoint);
+
+ const isGroupPage = pageType === 'groups';
+
+ return new Vue({
+ el,
+ router,
+ apolloProvider,
+ provide: {
+ resourceId,
+ fullPath,
+ emptyListIllustration,
+ isGroupPage,
+ },
+ render(createElement) {
+ return createElement(PackageRegistry);
+ },
+ });
+};
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
new file mode 100644
index 00000000000..a14d0c32cbe
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/index.vue
@@ -0,0 +1,5 @@
+<template>
+ <div>
+ <router-view />
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.js b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.js
deleted file mode 100644
index d797a0a5327..00000000000
--- a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import Vue from 'vue';
-import Translate from '~/vue_shared/translate';
-import { apolloProvider } from '~/packages_and_registries/package_registry/graphql/index';
-import PackagesListApp from '../components/list/app.vue';
-
-Vue.use(Translate);
-
-export default () => {
- const el = document.getElementById('js-vue-packages-list');
-
- const isGroupPage = el.dataset.pageType === 'groups';
-
- return new Vue({
- el,
- apolloProvider,
- provide: {
- ...el.dataset,
- isGroupPage,
- },
- render(createElement) {
- return createElement(PackagesListApp);
- },
- });
-};
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue
index 11eeaf933ff..38df701157a 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue
@@ -3,19 +3,21 @@ import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import createFlash from '~/flash';
import { historyReplaceState } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
-import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
+import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages_and_registries/shared/constants';
import {
PROJECT_RESOURCE_TYPE,
GROUP_RESOURCE_TYPE,
GRAPHQL_PAGE_SIZE,
DELETE_PACKAGE_SUCCESS_MESSAGE,
+ EMPTY_LIST_HELP_URL,
+ PACKAGE_HELP_URL,
} from '~/packages_and_registries/package_registry/constants';
import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql';
import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue';
-import PackageTitle from './package_title.vue';
-import PackageSearch from './package_search.vue';
-import PackageList from './packages_list.vue';
+import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue';
+import PackageSearch from '~/packages_and_registries/package_registry/components/list/package_search.vue';
+import PackageList from '~/packages_and_registries/package_registry/components/list/packages_list.vue';
export default {
components: {
@@ -27,13 +29,7 @@ export default {
PackageSearch,
DeletePackage,
},
- inject: [
- 'packageHelpUrl',
- 'emptyListIllustration',
- 'emptyListHelpUrl',
- 'isGroupPage',
- 'fullPath',
- ],
+ inject: ['emptyListIllustration', 'isGroupPage', 'fullPath'],
data() {
return {
packages: {},
@@ -156,12 +152,16 @@ export default {
'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.',
),
},
+ links: {
+ EMPTY_LIST_HELP_URL,
+ PACKAGE_HELP_URL,
+ },
};
</script>
<template>
<div>
- <package-title :help-url="packageHelpUrl" :count="packagesCount" />
+ <package-title :help-url="$options.links.PACKAGE_HELP_URL" :count="packagesCount" />
<package-search @update="handleSearchUpdate" />
<delete-package
@@ -185,7 +185,9 @@ export default {
<gl-sprintf v-if="hasFilters" :message="$options.i18n.widenFilters" />
<gl-sprintf v-else :message="$options.i18n.noResultsText">
<template #noPackagesLink="{ content }">
- <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link>
+ <gl-link :href="$options.links.EMPTY_LIST_HELP_URL" target="_blank">{{
+ content
+ }}</gl-link>
</template>
</gl-sprintf>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/router.js b/app/assets/javascripts/packages_and_registries/package_registry/router.js
new file mode 100644
index 00000000000..ea5b740e879
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/router.js
@@ -0,0 +1,21 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+import List from '~/packages_and_registries/package_registry/pages/list.vue';
+
+Vue.use(VueRouter);
+
+export default function createRouter(base) {
+ const router = new VueRouter({
+ base,
+ mode: 'history',
+ routes: [
+ {
+ name: 'list',
+ path: '/',
+ component: List,
+ },
+ ],
+ });
+
+ return router;
+}
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/bundle.js b/app/assets/javascripts/packages_and_registries/settings/group/bundle.js
index 9b5a0d221b8..85a7aeb5561 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/bundle.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/bundle.js
@@ -18,9 +18,10 @@ export default () => {
el,
apolloProvider,
provide: {
+ groupPath: el.dataset.groupPath,
+ groupDependencyProxyPath: el.dataset.groupDependencyProxyPath,
defaultExpanded: parseBoolean(el.dataset.defaultExpanded),
dependencyProxyAvailable: parseBoolean(el.dataset.dependencyProxyAvailable),
- groupPath: el.dataset.groupPath,
},
render(createElement) {
return createElement(SettingsApp);
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue
index 5815c6393a7..fd62fe144b2 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue
@@ -2,9 +2,14 @@
import { GlToggle, GlSprintf, GlLink } from '@gitlab/ui';
import { s__ } from '~/locale';
import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
+import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue';
import updateDependencyProxySettings from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql';
+import updateDependencyProxyImageTtlGroupPolicy from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_image_ttl_group_policy.mutation.graphql';
import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update';
-import { updateGroupDependencyProxySettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses';
+import {
+ updateGroupDependencyProxySettingsOptimisticResponse,
+ updateDependencyProxyImageTtlGroupPolicyOptimisticResponse,
+} from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses';
import {
DEPENDENCY_PROXY_HEADER,
@@ -19,21 +24,34 @@ export default {
GlSprintf,
GlLink,
SettingsBlock,
+ SettingsTitles,
},
i18n: {
DEPENDENCY_PROXY_HEADER,
DEPENDENCY_PROXY_SETTINGS_DESCRIPTION,
- label: s__('DependencyProxy|Enable Proxy'),
+ enabledProxyLabel: s__('DependencyProxy|Enable Dependency Proxy'),
+ enabledProxyHelpText: s__(
+ 'DependencyProxy|To see the image prefix and what is in the cache, visit the %{linkStart}Dependency Proxy%{linkEnd}',
+ ),
+ storageSettingsTitle: s__('DependencyProxy|Storage settings'),
+ ttlPolicyEnabledLabel: s__('DependencyProxy|Clear the Dependency Proxy cache automatically'),
+ ttlPolicyEnabledHelpText: s__(
+ 'DependencyProxy|When enabled, images older than 90 days will be removed from the cache.',
+ ),
},
links: {
DEPENDENCY_PROXY_DOCS_PATH,
},
- inject: ['defaultExpanded', 'groupPath'],
+ inject: ['defaultExpanded', 'groupPath', 'groupDependencyProxyPath'],
props: {
dependencyProxySettings: {
type: Object,
required: true,
},
+ dependencyProxyImageTtlPolicy: {
+ type: Object,
+ required: true,
+ },
isLoading: {
type: Boolean,
required: false,
@@ -49,26 +67,35 @@ export default {
this.updateSettings({ enabled });
},
},
+ ttlEnabled: {
+ get() {
+ return this.dependencyProxyImageTtlPolicy.enabled;
+ },
+ set(enabled) {
+ const payload = {
+ enabled,
+ ttl: 90, // hardocded TTL for the MVC version
+ };
+ this.updateDependencyProxyImageTtlGroupPolicy(payload);
+ },
+ },
+ helpText() {
+ return this.enabled ? this.$options.i18n.enabledProxyHelpText : '';
+ },
},
methods: {
- async updateSettings(payload) {
+ mutationVariables(payload) {
+ return {
+ input: {
+ groupPath: this.groupPath,
+ ...payload,
+ },
+ };
+ },
+ async executeMutation(config, resource) {
try {
- const { data } = await this.$apollo.mutate({
- mutation: updateDependencyProxySettings,
- variables: {
- input: {
- groupPath: this.groupPath,
- ...payload,
- },
- },
- update: updateGroupPackageSettings(this.groupPath),
- optimisticResponse: updateGroupDependencyProxySettingsOptimisticResponse({
- ...this.dependencyProxySettings,
- ...payload,
- }),
- });
-
- if (data.updateDependencyProxySettings?.errors?.length > 0) {
+ const { data } = await this.$apollo.mutate(config);
+ if (data[resource]?.errors.length > 0) {
throw new Error();
} else {
this.$emit('success');
@@ -77,6 +104,32 @@ export default {
this.$emit('error');
}
},
+ async updateSettings(payload) {
+ const apolloConfig = {
+ mutation: updateDependencyProxySettings,
+ variables: this.mutationVariables(payload),
+ update: updateGroupPackageSettings(this.groupPath),
+ optimisticResponse: updateGroupDependencyProxySettingsOptimisticResponse({
+ ...this.dependencyProxySettings,
+ ...payload,
+ }),
+ };
+
+ this.executeMutation(apolloConfig, 'updateDependencyProxySettings');
+ },
+ async updateDependencyProxyImageTtlGroupPolicy(payload) {
+ const apolloConfig = {
+ mutation: updateDependencyProxyImageTtlGroupPolicy,
+ variables: this.mutationVariables(payload),
+ update: updateGroupPackageSettings(this.groupPath),
+ optimisticResponse: updateDependencyProxyImageTtlGroupPolicyOptimisticResponse({
+ ...this.dependencyProxyImageTtlPolicy,
+ ...payload,
+ }),
+ };
+
+ this.executeMutation(apolloConfig, 'updateDependencyProxyImageTtlGroupPolicy');
+ },
},
};
</script>
@@ -91,7 +144,11 @@ export default {
<span data-testid="description">
<gl-sprintf :message="$options.i18n.DEPENDENCY_PROXY_SETTINGS_DESCRIPTION">
<template #docLink="{ content }">
- <gl-link :href="$options.links.DEPENDENCY_PROXY_DOCS_PATH">{{ content }}</gl-link>
+ <gl-link
+ data-testid="description-link"
+ :href="$options.links.DEPENDENCY_PROXY_DOCS_PATH"
+ >{{ content }}</gl-link
+ >
</template>
</gl-sprintf>
</span>
@@ -101,9 +158,31 @@ export default {
<gl-toggle
v-model="enabled"
:disabled="isLoading"
- :label="$options.i18n.label"
+ :label="$options.i18n.enabledProxyLabel"
+ :help="helpText"
data-qa-selector="dependency_proxy_setting_toggle"
data-testid="dependency-proxy-setting-toggle"
+ >
+ <template #help>
+ <span class="gl-overflow-break-word gl-max-w-100vw gl-display-inline-block">
+ <gl-sprintf :message="$options.i18n.enabledProxyHelpText">
+ <template #link="{ content }">
+ <gl-link data-testid="toggle-help-link" :href="groupDependencyProxyPath">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+ </gl-toggle>
+
+ <settings-titles :title="$options.i18n.storageSettingsTitle" class="gl-my-6" />
+ <gl-toggle
+ v-model="ttlEnabled"
+ :disabled="isLoading"
+ :label="$options.i18n.ttlPolicyEnabledLabel"
+ :help="$options.i18n.ttlPolicyEnabledHelpText"
+ data-testid="dependency-proxy-ttl-policies-toggle"
/>
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
index b45cedcdd66..64c12b4be6a 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
@@ -37,6 +37,9 @@ export default {
dependencyProxySettings() {
return this.group?.dependencyProxySetting || {};
},
+ dependencyProxyImageTtlPolicy() {
+ return this.group?.dependencyProxyImageTtlPolicy || {};
+ },
isLoading() {
return this.$apollo.queries.group.loading;
},
@@ -82,6 +85,7 @@ export default {
<dependency-proxy-settings
v-if="dependencyProxyAvailable"
:dependency-proxy-settings="dependencyProxySettings"
+ :dependency-proxy-image-ttl-policy="dependencyProxyImageTtlPolicy"
:is-loading="isLoading"
@success="handleSuccess"
@error="handleError"
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue
index 3f0ab7686e5..1e93875c1e3 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue
@@ -8,7 +8,8 @@ export default {
},
subTitle: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
},
};
@@ -16,10 +17,10 @@ export default {
<template>
<div>
- <h5 class="gl-border-b-solid gl-border-b-1 gl-border-gray-200">
+ <h5 class="gl-border-b-solid gl-border-b-1 gl-border-gray-200 gl-pb-3">
{{ title }}
</h5>
- <p>{{ subTitle }}</p>
+ <p v-if="subTitle">{{ subTitle }}</p>
<slot></slot>
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_image_ttl_group_policy.mutation.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_image_ttl_group_policy.mutation.graphql
new file mode 100644
index 00000000000..81250f52dfb
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_image_ttl_group_policy.mutation.graphql
@@ -0,0 +1,11 @@
+mutation updateDependencyProxyImageTtlGroupPolicy(
+ $input: UpdateDependencyProxyImageTtlGroupPolicyInput!
+) {
+ updateDependencyProxyImageTtlGroupPolicy(input: $input) {
+ dependencyProxyImageTtlPolicy {
+ enabled
+ ttl
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql
index d3edebfbe20..404d9d26d49 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql
@@ -1,8 +1,13 @@
query getGroupPackagesSettings($fullPath: ID!) {
group(fullPath: $fullPath) {
+ id
dependencyProxySetting {
enabled
}
+ dependencyProxyImageTtlPolicy {
+ ttl
+ enabled
+ }
packageSettings {
mavenDuplicatesAllowed
mavenDuplicateExceptionRegex
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js
index fe94203f51b..c7b0899fa4c 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js
@@ -19,6 +19,11 @@ export const updateGroupPackageSettings = (fullPath) => (client, { data: updated
...updatedData.updateDependencyProxySettings.dependencyProxySetting,
};
}
+ if (updatedData.updateDependencyProxyImageTtlGroupPolicy) {
+ draftState.group.dependencyProxyImageTtlPolicy = {
+ ...updatedData.updateDependencyProxyImageTtlGroupPolicy.dependencyProxyImageTtlPolicy,
+ };
+ }
});
client.writeQuery({
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js
index a30d8ca0b81..92f6e117911 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js
@@ -21,3 +21,15 @@ export const updateGroupDependencyProxySettingsOptimisticResponse = (changes) =>
},
},
});
+
+export const updateDependencyProxyImageTtlGroupPolicyOptimisticResponse = (changes) => ({
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ __typename: 'Mutation',
+ updateDependencyProxyImageTtlGroupPolicy: {
+ __typename: 'UpdateDependencyProxyImageTtlGroupPolicyPayload',
+ errors: [],
+ dependencyProxyImageTtlPolicy: {
+ ...changes,
+ },
+ },
+});
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql
index c171be0ad07..6a862da92df 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql
@@ -2,6 +2,7 @@
query getProjectExpirationPolicy($projectPath: ID!) {
project(fullPath: $projectPath) {
+ id
containerExpirationPolicy {
...ContainerExpirationPolicyFields
}
diff --git a/app/assets/javascripts/packages/shared/components/package_icon_and_name.vue b/app/assets/javascripts/packages_and_registries/shared/components/package_icon_and_name.vue
index 105f7bbe132..105f7bbe132 100644
--- a/app/assets/javascripts/packages/shared/components/package_icon_and_name.vue
+++ b/app/assets/javascripts/packages_and_registries/shared/components/package_icon_and_name.vue
diff --git a/app/assets/javascripts/packages/shared/components/package_path.vue b/app/assets/javascripts/packages_and_registries/shared/components/package_path.vue
index 6fb001e5e92..6fb001e5e92 100644
--- a/app/assets/javascripts/packages/shared/components/package_path.vue
+++ b/app/assets/javascripts/packages_and_registries/shared/components/package_path.vue
diff --git a/app/assets/javascripts/packages/shared/components/package_tags.vue b/app/assets/javascripts/packages_and_registries/shared/components/package_tags.vue
index 5ec950e4d45..5ec950e4d45 100644
--- a/app/assets/javascripts/packages/shared/components/package_tags.vue
+++ b/app/assets/javascripts/packages_and_registries/shared/components/package_tags.vue
diff --git a/app/assets/javascripts/packages/shared/components/packages_list_loader.vue b/app/assets/javascripts/packages_and_registries/shared/components/packages_list_loader.vue
index cf555f46f8c..cf555f46f8c 100644
--- a/app/assets/javascripts/packages/shared/components/packages_list_loader.vue
+++ b/app/assets/javascripts/packages_and_registries/shared/components/packages_list_loader.vue
diff --git a/app/assets/javascripts/packages/shared/components/publish_method.vue b/app/assets/javascripts/packages_and_registries/shared/components/publish_method.vue
index 8a66a33f2ab..8a66a33f2ab 100644
--- a/app/assets/javascripts/packages/shared/components/publish_method.vue
+++ b/app/assets/javascripts/packages_and_registries/shared/components/publish_method.vue
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
new file mode 100644
index 00000000000..79381f82009
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue
@@ -0,0 +1,124 @@
+<script>
+import { GlButton, GlFormCheckbox, GlKeysetPagination } from '@gitlab/ui';
+import { filter } from 'lodash';
+import { __ } from '~/locale';
+
+export default {
+ name: 'RegistryList',
+ components: {
+ GlButton,
+ GlFormCheckbox,
+ GlKeysetPagination,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ hiddenDelete: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ pagination: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ items: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ idProperty: {
+ type: String,
+ required: false,
+ default: 'id',
+ },
+ },
+ data() {
+ return {
+ selectedReferences: {},
+ };
+ },
+ computed: {
+ showPagination() {
+ return this.pagination.hasPreviousPage || this.pagination.hasNextPage;
+ },
+ disableDeleteButton() {
+ return this.isLoading || filter(this.selectedReferences).length === 0;
+ },
+ selectedItems() {
+ return this.items.filter(this.isSelected);
+ },
+ selectAll: {
+ get() {
+ return this.items.every(this.isSelected);
+ },
+ set(value) {
+ this.items.forEach((item) => {
+ const id = item[this.idProperty];
+ this.$set(this.selectedReferences, id, value);
+ });
+ },
+ },
+ },
+ methods: {
+ selectItem(item) {
+ const id = item[this.idProperty];
+ this.$set(this.selectedReferences, id, !this.selectedReferences[id]);
+ },
+ isSelected(item) {
+ const id = item[this.idProperty];
+ return this.selectedReferences[id];
+ },
+ },
+ i18n: {
+ deleteSelected: __('Delete Selected'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="gl-display-flex gl-justify-content-space-between gl-mb-3 gl-align-items-center">
+ <gl-form-checkbox v-if="!hiddenDelete" v-model="selectAll" class="gl-ml-2 gl-pt-2">
+ <span class="gl-font-weight-bold">{{ title }}</span>
+ </gl-form-checkbox>
+
+ <gl-button
+ v-if="!hiddenDelete"
+ :disabled="disableDeleteButton"
+ category="secondary"
+ variant="danger"
+ @click="$emit('delete', selectedItems)"
+ >
+ {{ $options.i18n.deleteSelected }}
+ </gl-button>
+ </div>
+
+ <div v-for="(item, index) in items" :key="index">
+ <slot
+ :select-item="selectItem"
+ :is-selected="isSelected"
+ :item="item"
+ :first="index === 0"
+ ></slot>
+ </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')"
+ @next="$emit('next-page')"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/shared/constants.js b/app/assets/javascripts/packages_and_registries/shared/constants.js
index 7d2971bd8c7..afc72a2c627 100644
--- a/app/assets/javascripts/packages_and_registries/shared/constants.js
+++ b/app/assets/javascripts/packages_and_registries/shared/constants.js
@@ -1,3 +1,39 @@
+import { s__ } from '~/locale';
+
export const FILTERED_SEARCH_TERM = 'filtered-search-term';
export const FILTERED_SEARCH_TYPE = 'type';
export const HISTORY_PIPELINES_LIMIT = 5;
+
+export const DELETE_PACKAGE_TRACKING_ACTION = 'delete_package';
+export const REQUEST_DELETE_PACKAGE_TRACKING_ACTION = 'request_delete_package';
+export const CANCEL_DELETE_PACKAGE_TRACKING_ACTION = 'cancel_delete_package';
+export const PULL_PACKAGE_TRACKING_ACTION = 'pull_package';
+export const DELETE_PACKAGE_FILE_TRACKING_ACTION = 'delete_package_file';
+export const REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'request_delete_package_file';
+export const CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'cancel_delete_package_file';
+
+export const TRACKING_ACTIONS = {
+ DELETE_PACKAGE: DELETE_PACKAGE_TRACKING_ACTION,
+ REQUEST_DELETE_PACKAGE: REQUEST_DELETE_PACKAGE_TRACKING_ACTION,
+ CANCEL_DELETE_PACKAGE: CANCEL_DELETE_PACKAGE_TRACKING_ACTION,
+ PULL_PACKAGE: PULL_PACKAGE_TRACKING_ACTION,
+ DELETE_PACKAGE_FILE: DELETE_PACKAGE_FILE_TRACKING_ACTION,
+ REQUEST_DELETE_PACKAGE_FILE: REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION,
+ CANCEL_DELETE_PACKAGE_FILE: CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
+};
+
+export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert';
+export const DELETE_PACKAGE_ERROR_MESSAGE = s__(
+ 'PackageRegistry|Something went wrong while deleting the package.',
+);
+export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__(
+ 'PackageRegistry|Something went wrong while deleting the package file.',
+);
+export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__(
+ 'PackageRegistry|Package file deleted successfully',
+);
+
+export const PACKAGE_ERROR_STATUS = 'error';
+export const PACKAGE_DEFAULT_STATUS = 'default';
+export const PACKAGE_HIDDEN_STATUS = 'hidden';
+export const PACKAGE_PROCESSING_STATUS = 'processing';
diff --git a/app/assets/javascripts/packages_and_registries/shared/utils.js b/app/assets/javascripts/packages_and_registries/shared/utils.js
index 93eb90535d1..cf18f655e79 100644
--- a/app/assets/javascripts/packages_and_registries/shared/utils.js
+++ b/app/assets/javascripts/packages_and_registries/shared/utils.js
@@ -28,3 +28,13 @@ export const extractFilterAndSorting = (queryObject) => {
}
return { filters, sorting };
};
+
+export const beautifyPath = (path) => (path ? path.split('/').join(' / ') : '');
+
+export const getCommitLink = ({ project_path: projectPath, pipeline = {} }, isGroup = false) => {
+ if (isGroup) {
+ return `/${projectPath}/commit/${pipeline.sha}`;
+ }
+
+ return `../commit/${pipeline.sha}`;
+};
diff --git a/app/assets/javascripts/pages/admin/integrations/edit/index.js b/app/assets/javascripts/pages/admin/integrations/edit/index.js
index 8002fa8bf78..8485b460261 100644
--- a/app/assets/javascripts/pages/admin/integrations/edit/index.js
+++ b/app/assets/javascripts/pages/admin/integrations/edit/index.js
@@ -1,15 +1,11 @@
-import IntegrationSettingsForm from '~/integrations/integration_settings_form';
+import initIntegrationSettingsForm from '~/integrations/edit';
import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics';
-function initIntegrations() {
- const prometheusSettingsWrapper = document.querySelector('.js-prometheus-metrics-monitoring');
- const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
- integrationSettingsForm.init();
+initIntegrationSettingsForm('.js-integration-settings-form');
- if (prometheusSettingsWrapper) {
- const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
- prometheusMetrics.loadActiveMetrics();
- }
+const prometheusSettingsSelector = '.js-prometheus-metrics-monitoring';
+const prometheusSettingsWrapper = document.querySelector(prometheusSettingsSelector);
+if (prometheusSettingsWrapper) {
+ const prometheusMetrics = new PrometheusMetrics(prometheusSettingsSelector);
+ prometheusMetrics.loadActiveMetrics();
}
-
-initIntegrations();
diff --git a/app/assets/javascripts/pages/admin/labels/edit/index.js b/app/assets/javascripts/pages/admin/labels/edit/index.js
index f7c25347e75..a3b9c43388a 100644
--- a/app/assets/javascripts/pages/admin/labels/edit/index.js
+++ b/app/assets/javascripts/pages/admin/labels/edit/index.js
@@ -1,3 +1,3 @@
-import Labels from '../../../../labels';
+import Labels from '~/labels/labels';
new Labels(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/admin/labels/index/index.js b/app/assets/javascripts/pages/admin/labels/index/index.js
index 0ceab3b922f..132fe5ce8fc 100644
--- a/app/assets/javascripts/pages/admin/labels/index/index.js
+++ b/app/assets/javascripts/pages/admin/labels/index/index.js
@@ -1,23 +1,3 @@
-function initLabels() {
- const pagination = document.querySelector('.labels .gl-pagination');
- const emptyState = document.querySelector('.labels .nothing-here-block.hidden');
+import { initAdminLabels } from '~/labels';
- function removeLabelSuccessCallback() {
- this.closest('li').classList.add('gl-display-none!');
-
- const labelsCount = document.querySelectorAll(
- 'ul.manage-labels-list li:not(.gl-display-none\\!)',
- ).length;
-
- // display the empty state if there are no more labels
- if (labelsCount < 1 && !pagination && emptyState) {
- emptyState.classList.remove('hidden');
- }
- }
-
- document.querySelectorAll('.js-remove-label').forEach((row) => {
- row.addEventListener('ajax:success', removeLabelSuccessCallback);
- });
-}
-
-initLabels();
+initAdminLabels();
diff --git a/app/assets/javascripts/pages/admin/labels/new/index.js b/app/assets/javascripts/pages/admin/labels/new/index.js
index f7c25347e75..a3b9c43388a 100644
--- a/app/assets/javascripts/pages/admin/labels/new/index.js
+++ b/app/assets/javascripts/pages/admin/labels/new/index.js
@@ -1,3 +1,3 @@
-import Labels from '../../../../labels';
+import Labels from '~/labels/labels';
new Labels(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/admin/services/edit/index.js b/app/assets/javascripts/pages/admin/services/edit/index.js
deleted file mode 100644
index b8080ddff77..00000000000
--- a/app/assets/javascripts/pages/admin/services/edit/index.js
+++ /dev/null
@@ -1,4 +0,0 @@
-import IntegrationSettingsForm from '~/integrations/integration_settings_form';
-
-const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
-integrationSettingsForm.init();
diff --git a/app/assets/javascripts/pages/admin/services/index/index.js b/app/assets/javascripts/pages/admin/services/index/index.js
deleted file mode 100644
index b695cf70c5d..00000000000
--- a/app/assets/javascripts/pages/admin/services/index/index.js
+++ /dev/null
@@ -1,4 +0,0 @@
-import PersistentUserCallout from '~/persistent_user_callout';
-
-const callout = document.querySelector('.js-service-templates-deprecated');
-PersistentUserCallout.factory(callout);
diff --git a/app/assets/javascripts/pages/constants.js b/app/assets/javascripts/pages/constants.js
deleted file mode 100644
index a9773807212..00000000000
--- a/app/assets/javascripts/pages/constants.js
+++ /dev/null
@@ -1,6 +0,0 @@
-export const FILTERED_SEARCH = {
- MERGE_REQUESTS: 'merge_requests',
- ISSUES: 'issues',
- ADMIN_RUNNERS: 'admin/runners',
- GROUP_RUNNERS_ANCHOR: 'runners-settings',
-};
diff --git a/app/assets/javascripts/pages/dashboard/issues/index.js b/app/assets/javascripts/pages/dashboard/issues/index.js
index 3e09b1796b1..d0903ad53bc 100644
--- a/app/assets/javascripts/pages/dashboard/issues/index.js
+++ b/app/assets/javascripts/pages/dashboard/issues/index.js
@@ -1,6 +1,6 @@
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
-import initManualOrdering from '~/manual_ordering';
-import { FILTERED_SEARCH } from '~/pages/constants';
+import initManualOrdering from '~/issues/manual_ordering';
+import { FILTERED_SEARCH } from '~/filtered_search/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import projectSelect from '~/project_select';
diff --git a/app/assets/javascripts/pages/dashboard/merge_requests/index.js b/app/assets/javascripts/pages/dashboard/merge_requests/index.js
index 6c134e4fad6..1350837476b 100644
--- a/app/assets/javascripts/pages/dashboard/merge_requests/index.js
+++ b/app/assets/javascripts/pages/dashboard/merge_requests/index.js
@@ -1,6 +1,6 @@
import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
-import { FILTERED_SEARCH } from '~/pages/constants';
+import { FILTERED_SEARCH } from '~/filtered_search/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import projectSelect from '~/project_select';
diff --git a/app/assets/javascripts/pages/dashboard/milestones/show/index.js b/app/assets/javascripts/pages/dashboard/milestones/show/index.js
index 1f3e458fe17..d1ff7ec336c 100644
--- a/app/assets/javascripts/pages/dashboard/milestones/show/index.js
+++ b/app/assets/javascripts/pages/dashboard/milestones/show/index.js
@@ -1,4 +1,4 @@
-import Milestone from '~/milestone';
+import Milestone from '~/milestones/milestone';
import Sidebar from '~/right_sidebar';
import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar';
diff --git a/app/assets/javascripts/pages/dashboard/projects/index/components/customize_homepage_banner.vue b/app/assets/javascripts/pages/dashboard/projects/index/components/customize_homepage_banner.vue
deleted file mode 100644
index 99461475af0..00000000000
--- a/app/assets/javascripts/pages/dashboard/projects/index/components/customize_homepage_banner.vue
+++ /dev/null
@@ -1,102 +0,0 @@
-<script>
-import { GlBanner } from '@gitlab/ui';
-import axios from '~/lib/utils/axios_utils';
-import { s__ } from '~/locale';
-import Tracking from '~/tracking';
-
-const trackingMixin = Tracking.mixin();
-
-export default {
- components: {
- GlBanner,
- },
- mixins: [trackingMixin],
- inject: {
- svgPath: {
- default: '',
- },
- preferencesBehaviorPath: {
- default: '',
- },
- calloutsPath: {
- default: '',
- },
- calloutsFeatureId: {
- default: '',
- },
- trackLabel: {
- default: '',
- },
- },
- i18n: {
- title: s__('CustomizeHomepageBanner|Do you want to customize this page?'),
- body: s__(
- 'CustomizeHomepageBanner|This page shows a list of your projects by default but it can be changed to show projects\' activity, groups, your to-do list, assigned issues, assigned merge requests, and more. You can change this under "Homepage content" in your preferences',
- ),
- button_text: s__('CustomizeHomepageBanner|Go to preferences'),
- },
- data() {
- return {
- visible: true,
- tracking: {
- label: this.trackLabel,
- },
- };
- },
- created() {
- this.$nextTick(() => {
- this.addTrackingAttributesToButton();
- });
- },
- mounted() {
- this.trackOnShow();
- },
- methods: {
- handleClose() {
- axios
- .post(this.calloutsPath, {
- feature_name: this.calloutsFeatureId,
- })
- .catch((e) => {
- // eslint-disable-next-line @gitlab/require-i18n-strings, no-console
- console.error('Failed to dismiss banner.', e);
- });
-
- this.visible = false;
- this.track('click_dismiss');
- },
- trackOnShow() {
- if (this.visible) this.track('show_home_page_banner');
- },
- addTrackingAttributesToButton() {
- // we can't directly add these on the button like we need to due to
- // button not being modifiable currently
- // https://gitlab.com/gitlab-org/gitlab-ui/-/blob/9209ec424e5cca14bc8a1b5c9fa12636d8c83dad/src/components/base/banner/banner.vue#L60
- const button = this.$refs.banner.$el.querySelector(
- `[href='${this.preferencesBehaviorPath}']`,
- );
-
- if (button) {
- button.setAttribute('data-track-action', 'click_go_to_preferences');
- button.setAttribute('data-track-label', this.trackLabel);
- }
- },
- },
-};
-</script>
-
-<template>
- <gl-banner
- v-if="visible"
- ref="banner"
- :title="$options.i18n.title"
- :button-text="$options.i18n.button_text"
- :button-link="preferencesBehaviorPath"
- :svg-path="svgPath"
- @close="handleClose"
- >
- <p>
- {{ $options.i18n.body }}
- </p>
- </gl-banner>
-</template>
diff --git a/app/assets/javascripts/pages/dashboard/projects/index/index.js b/app/assets/javascripts/pages/dashboard/projects/index/index.js
index c34d15b869a..6c9378b7231 100644
--- a/app/assets/javascripts/pages/dashboard/projects/index/index.js
+++ b/app/assets/javascripts/pages/dashboard/projects/index/index.js
@@ -1,5 +1,3 @@
import ProjectsList from '~/projects_list';
-import initCustomizeHomepageBanner from './init_customize_homepage_banner';
new ProjectsList(); // eslint-disable-line no-new
-initCustomizeHomepageBanner();
diff --git a/app/assets/javascripts/pages/dashboard/projects/index/init_customize_homepage_banner.js b/app/assets/javascripts/pages/dashboard/projects/index/init_customize_homepage_banner.js
deleted file mode 100644
index 8cdcd3134ee..00000000000
--- a/app/assets/javascripts/pages/dashboard/projects/index/init_customize_homepage_banner.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import Vue from 'vue';
-import CustomizeHomepageBanner from './components/customize_homepage_banner.vue';
-
-export default () => {
- const el = document.querySelector('.js-customize-homepage-banner');
-
- if (!el) {
- return false;
- }
-
- return new Vue({
- el,
- provide: { ...el.dataset },
- render: (createElement) => createElement(CustomizeHomepageBanner),
- });
-};
diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js
index 8c9f23732aa..966d55e5587 100644
--- a/app/assets/javascripts/pages/groups/issues/index.js
+++ b/app/assets/javascripts/pages/groups/issues/index.js
@@ -1,8 +1,8 @@
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
-import issuableInitBulkUpdateSidebar from '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar';
+import issuableInitBulkUpdateSidebar from '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar';
import { mountIssuablesListApp, mountIssuesListApp } from '~/issues_list';
-import initManualOrdering from '~/manual_ordering';
-import { FILTERED_SEARCH } from '~/pages/constants';
+import initManualOrdering from '~/issues/manual_ordering';
+import { FILTERED_SEARCH } from '~/filtered_search/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import projectSelect from '~/project_select';
diff --git a/app/assets/javascripts/pages/groups/labels/edit/index.js b/app/assets/javascripts/pages/groups/labels/edit/index.js
index 2e8308fe084..e4e377f62fc 100644
--- a/app/assets/javascripts/pages/groups/labels/edit/index.js
+++ b/app/assets/javascripts/pages/groups/labels/edit/index.js
@@ -1,4 +1,4 @@
-import Labels from 'ee_else_ce/labels';
+import Labels from 'ee_else_ce/labels/labels';
// eslint-disable-next-line no-new
new Labels();
diff --git a/app/assets/javascripts/pages/groups/labels/index/index.js b/app/assets/javascripts/pages/groups/labels/index/index.js
index 95c2c7cd7d0..bf670e8576f 100644
--- a/app/assets/javascripts/pages/groups/labels/index/index.js
+++ b/app/assets/javascripts/pages/groups/labels/index/index.js
@@ -1,5 +1,4 @@
-import initDeleteLabelModal from '~/delete_label_modal';
-import initLabels from '~/init_labels';
+import { initDeleteLabelModal, initLabels } from '~/labels';
initLabels();
initDeleteLabelModal();
diff --git a/app/assets/javascripts/pages/groups/labels/new/index.js b/app/assets/javascripts/pages/groups/labels/new/index.js
index 2e8308fe084..e4e377f62fc 100644
--- a/app/assets/javascripts/pages/groups/labels/new/index.js
+++ b/app/assets/javascripts/pages/groups/labels/new/index.js
@@ -1,4 +1,4 @@
-import Labels from 'ee_else_ce/labels';
+import Labels from 'ee_else_ce/labels/labels';
// eslint-disable-next-line no-new
new Labels();
diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js
index 02a0a50f984..cb38ee1c6e0 100644
--- a/app/assets/javascripts/pages/groups/merge_requests/index.js
+++ b/app/assets/javascripts/pages/groups/merge_requests/index.js
@@ -1,7 +1,7 @@
import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
-import issuableInitBulkUpdateSidebar from '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar';
-import { FILTERED_SEARCH } from '~/pages/constants';
+import issuableInitBulkUpdateSidebar from '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar';
+import { FILTERED_SEARCH } from '~/filtered_search/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import projectSelect from '~/project_select';
diff --git a/app/assets/javascripts/pages/groups/milestones/edit/index.js b/app/assets/javascripts/pages/groups/milestones/edit/index.js
index 4f8514a9a1d..7fda129a85d 100644
--- a/app/assets/javascripts/pages/groups/milestones/edit/index.js
+++ b/app/assets/javascripts/pages/groups/milestones/edit/index.js
@@ -1,3 +1,3 @@
-import initForm from '~/shared/milestones/form';
+import { initForm } from '~/milestones';
initForm();
diff --git a/app/assets/javascripts/pages/groups/milestones/new/index.js b/app/assets/javascripts/pages/groups/milestones/new/index.js
index 4f8514a9a1d..7fda129a85d 100644
--- a/app/assets/javascripts/pages/groups/milestones/new/index.js
+++ b/app/assets/javascripts/pages/groups/milestones/new/index.js
@@ -1,3 +1,3 @@
-import initForm from '~/shared/milestones/form';
+import { initForm } from '~/milestones';
initForm();
diff --git a/app/assets/javascripts/pages/groups/milestones/show/index.js b/app/assets/javascripts/pages/groups/milestones/show/index.js
index 914e2831185..f2ab5d78374 100644
--- a/app/assets/javascripts/pages/groups/milestones/show/index.js
+++ b/app/assets/javascripts/pages/groups/milestones/show/index.js
@@ -1,5 +1,4 @@
-import initDeleteMilestoneModal from '~/pages/milestones/shared/delete_milestone_modal_init';
-import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show';
+import { initDeleteMilestoneModal, initShow } from '~/milestones';
-initMilestonesShow();
+initShow();
initDeleteMilestoneModal();
diff --git a/app/assets/javascripts/pages/groups/packages/index/index.js b/app/assets/javascripts/pages/groups/packages/index/index.js
index f9eecff4ac4..174973a9fad 100644
--- a/app/assets/javascripts/pages/groups/packages/index/index.js
+++ b/app/assets/javascripts/pages/groups/packages/index/index.js
@@ -1,3 +1,3 @@
-import packageList from '~/packages_and_registries/package_registry/pages/list';
+import packageApp from '~/packages_and_registries/package_registry/index';
-packageList();
+packageApp();
diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
index a8d7a83cdd6..5d8ee146e62 100644
--- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
@@ -1,7 +1,7 @@
import initVariableList from '~/ci_variable_list';
import GroupRunnersFilteredSearchTokenKeys from '~/filtered_search/group_runners_filtered_search_token_keys';
import initSharedRunnersForm from '~/group_settings/mount_shared_runners';
-import { FILTERED_SEARCH } from '~/pages/constants';
+import { FILTERED_SEARCH } from '~/filtered_search/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import { initRunnerAwsDeployments } from '~/pages/shared/mount_runner_aws_deployments';
import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
diff --git a/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js b/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js
index a8698e10c57..8485b460261 100644
--- a/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js
+++ b/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js
@@ -1,11 +1,11 @@
-import IntegrationSettingsForm from '~/integrations/integration_settings_form';
+import initIntegrationSettingsForm from '~/integrations/edit';
import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics';
-const prometheusSettingsWrapper = document.querySelector('.js-prometheus-metrics-monitoring');
-const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
-integrationSettingsForm.init();
+initIntegrationSettingsForm('.js-integration-settings-form');
+const prometheusSettingsSelector = '.js-prometheus-metrics-monitoring';
+const prometheusSettingsWrapper = document.querySelector(prometheusSettingsSelector);
if (prometheusSettingsWrapper) {
- const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
+ const prometheusMetrics = new PrometheusMetrics(prometheusSettingsSelector);
prometheusMetrics.loadActiveMetrics();
}
diff --git a/app/assets/javascripts/pages/help/ui/index.js b/app/assets/javascripts/pages/help/ui/index.js
deleted file mode 100644
index 9ccc9123506..00000000000
--- a/app/assets/javascripts/pages/help/ui/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import initUIKit from '~/ui_development_kit';
-
-initUIKit();
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 ec3cf4a8a92..0ec382983a5 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
@@ -7,7 +7,7 @@ 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 PaginationBar from '~/import_entities/components/pagination_bar.vue';
+import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { DEFAULT_ERROR } from '../utils/error_messages';
@@ -166,7 +166,6 @@ export default {
</gl-table>
<pagination-bar
:page-info="pageInfo"
- :items-count="historyItems.length"
class="gl-m-0 gl-mt-3"
@set-page="paginationConfig.page = $event"
@set-page-size="paginationConfig.perPage = $event"
diff --git a/app/assets/javascripts/pages/milestones/shared/index.js b/app/assets/javascripts/pages/milestones/shared/index.js
deleted file mode 100644
index dabfe32848b..00000000000
--- a/app/assets/javascripts/pages/milestones/shared/index.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import initDeleteMilestoneModal from './delete_milestone_modal_init';
-import initPromoteMilestoneModal from './promote_milestone_modal_init';
-
-export default () => {
- initDeleteMilestoneModal();
- initPromoteMilestoneModal();
-};
diff --git a/app/assets/javascripts/pages/milestones/shared/init_milestones_show.js b/app/assets/javascripts/pages/milestones/shared/init_milestones_show.js
deleted file mode 100644
index b2a896a3265..00000000000
--- a/app/assets/javascripts/pages/milestones/shared/init_milestones_show.js
+++ /dev/null
@@ -1,11 +0,0 @@
-/* eslint-disable no-new */
-
-import Milestone from '~/milestone';
-import Sidebar from '~/right_sidebar';
-import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar';
-
-export default () => {
- new Milestone();
- new Sidebar();
- new MountMilestoneSidebar();
-};
diff --git a/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js b/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js
deleted file mode 100644
index 5472b8c684f..00000000000
--- a/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import Vue from 'vue';
-import Translate from '~/vue_shared/translate';
-import PromoteMilestoneModal from './components/promote_milestone_modal.vue';
-
-Vue.use(Translate);
-
-export default () => {
- const promoteMilestoneModal = document.getElementById('promote-milestone-modal');
- if (!promoteMilestoneModal) {
- return null;
- }
-
- return new Vue({
- el: promoteMilestoneModal,
- render(createElement) {
- return createElement(PromoteMilestoneModal);
- },
- });
-};
diff --git a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
index fdbfc35456f..37e9b7e99d4 100644
--- a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
+++ b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
@@ -1,4 +1,5 @@
-import { initExpiresAtField, initProjectsField } from '~/access_tokens';
+import { initExpiresAtField, initProjectsField, initTokensApp } from '~/access_tokens';
initExpiresAtField();
initProjectsField();
+initTokensApp();
diff --git a/app/assets/javascripts/pages/projects/constants.js b/app/assets/javascripts/pages/projects/constants.js
deleted file mode 100644
index 8dc765e5d10..00000000000
--- a/app/assets/javascripts/pages/projects/constants.js
+++ /dev/null
@@ -1,4 +0,0 @@
-export const ISSUABLE_INDEX = {
- MERGE_REQUEST: 'merge_request_',
- ISSUE: 'issue_',
-};
diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js
index f4beefea90c..100ca5b36d9 100644
--- a/app/assets/javascripts/pages/projects/edit/index.js
+++ b/app/assets/javascripts/pages/projects/edit/index.js
@@ -1,13 +1,14 @@
import { PROJECT_BADGE } from '~/badges/constants';
import initLegacyConfirmDangerModal from '~/confirm_danger_modal';
+import initConfirmDanger from '~/init_confirm_danger';
import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory';
import initFilePickers from '~/file_pickers';
import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
import initProjectDeleteButton from '~/projects/project_delete_button';
import initServiceDesk from '~/projects/settings_service_desk';
+import initTransferProjectForm from '~/projects/settings/init_transfer_project_form';
import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
-import setupTransferEdit from '~/transfer_edit';
import UserCallout from '~/user_callout';
import initTopicsTokenSelector from '~/projects/settings/topics';
import initProjectPermissionsSettings from '../shared/permissions';
@@ -15,6 +16,7 @@ import initProjectLoadingSpinner from '../shared/save_project_loader';
initFilePickers();
initLegacyConfirmDangerModal();
+initConfirmDanger();
initSettingsPanels();
initProjectDeleteButton();
mountBadgeSettings(PROJECT_BADGE);
@@ -24,7 +26,7 @@ initServiceDesk();
initProjectLoadingSpinner();
initProjectPermissionsSettings();
-setupTransferEdit('.js-project-transfer-form', 'select.select2');
+initTransferProjectForm();
dirtySubmitFactory(document.querySelectorAll('.js-general-settings-form, .js-mr-settings-form'));
diff --git a/app/assets/javascripts/pages/projects/incidents/show/index.js b/app/assets/javascripts/pages/projects/incidents/show/index.js
index a75b68873ef..4633eaef8f9 100644
--- a/app/assets/javascripts/pages/projects/incidents/show/index.js
+++ b/app/assets/javascripts/pages/projects/incidents/show/index.js
@@ -1,6 +1,6 @@
import initRelatedIssues from '~/related_issues';
import initSidebarBundle from '~/sidebar/sidebar_bundle';
-import initShow from '../../issues/show';
+import initShow from '~/issues/show';
initShow();
initSidebarBundle();
diff --git a/app/assets/javascripts/pages/projects/issues/edit/index.js b/app/assets/javascripts/pages/projects/issues/edit/index.js
index 48afd2142ee..aa00d1f58bd 100644
--- a/app/assets/javascripts/pages/projects/issues/edit/index.js
+++ b/app/assets/javascripts/pages/projects/issues/edit/index.js
@@ -1,3 +1,3 @@
-import initForm from 'ee_else_ce/pages/projects/issues/form';
+import initForm from 'ee_else_ce/issues/form';
initForm();
diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js
index 8cd703133f5..e937713044c 100644
--- a/app/assets/javascripts/pages/projects/issues/index/index.js
+++ b/app/assets/javascripts/pages/projects/issues/index/index.js
@@ -1,12 +1,11 @@
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
-import initCsvImportExportButtons from '~/issuable/init_csv_import_export_buttons';
-import initIssuableByEmail from '~/issuable/init_issuable_by_email';
-import IssuableIndex from '~/issuable_index';
+import { initCsvImportExportButtons, initIssuableByEmail } from '~/issuable';
+import issuableInitBulkUpdateSidebar from '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar';
import { mountIssuablesListApp, mountIssuesListApp, mountJiraIssuesListApp } from '~/issues_list';
-import initManualOrdering from '~/manual_ordering';
-import { FILTERED_SEARCH } from '~/pages/constants';
-import { ISSUABLE_INDEX } from '~/pages/projects/constants';
+import initManualOrdering from '~/issues/manual_ordering';
+import { FILTERED_SEARCH } from '~/filtered_search/constants';
+import { ISSUABLE_INDEX } from '~/issuable/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import UsersSelect from '~/users_select';
@@ -21,7 +20,7 @@ if (gon.features?.vueIssuesList) {
useDefaultState: true,
});
- new IssuableIndex(ISSUABLE_INDEX.ISSUE); // eslint-disable-line no-new
+ issuableInitBulkUpdateSidebar.init(ISSUABLE_INDEX.ISSUE);
new UsersSelect(); // eslint-disable-line no-new
initCsvImportExportButtons();
diff --git a/app/assets/javascripts/pages/projects/issues/new/index.js b/app/assets/javascripts/pages/projects/issues/new/index.js
index 48afd2142ee..aa00d1f58bd 100644
--- a/app/assets/javascripts/pages/projects/issues/new/index.js
+++ b/app/assets/javascripts/pages/projects/issues/new/index.js
@@ -1,3 +1,3 @@
-import initForm from 'ee_else_ce/pages/projects/issues/form';
+import initForm from 'ee_else_ce/issues/form';
initForm();
diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/index.js b/app/assets/javascripts/pages/projects/issues/service_desk/index.js
index d906c579697..69639d17f8a 100644
--- a/app/assets/javascripts/pages/projects/issues/service_desk/index.js
+++ b/app/assets/javascripts/pages/projects/issues/service_desk/index.js
@@ -1,14 +1,7 @@
import { mountIssuablesListApp } from '~/issues_list';
-import FilteredSearchServiceDesk from './filtered_search';
+import { initFilteredSearchServiceDesk } from '~/issues/init_filtered_search_service_desk';
-const supportBotData = JSON.parse(
- document.querySelector('.js-service-desk-issues').dataset.supportBot,
-);
-
-if (document.querySelector('.filtered-search')) {
- const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData);
- filteredSearchManager.setup();
-}
+initFilteredSearchServiceDesk();
if (gon.features?.vueIssuablesList) {
mountIssuablesListApp();
diff --git a/app/assets/javascripts/pages/projects/issues/show/index.js b/app/assets/javascripts/pages/projects/issues/show/index.js
index 1282d2aa303..d0b1942f2a4 100644
--- a/app/assets/javascripts/pages/projects/issues/show/index.js
+++ b/app/assets/javascripts/pages/projects/issues/show/index.js
@@ -1,7 +1,7 @@
import { store } from '~/notes/stores';
import initRelatedIssues from '~/related_issues';
import initSidebarBundle from '~/sidebar/sidebar_bundle';
-import initShow from '../show';
+import initShow from '~/issues/show';
initShow();
initSidebarBundle(store);
diff --git a/app/assets/javascripts/pages/projects/labels/edit/index.js b/app/assets/javascripts/pages/projects/labels/edit/index.js
index 3b7562deed9..c4d7af39767 100644
--- a/app/assets/javascripts/pages/projects/labels/edit/index.js
+++ b/app/assets/javascripts/pages/projects/labels/edit/index.js
@@ -1,3 +1,3 @@
-import Labels from 'ee_else_ce/labels';
+import Labels from 'ee_else_ce/labels/labels';
new Labels(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/labels/index/index.js b/app/assets/javascripts/pages/projects/labels/index/index.js
index 94ab0d64de4..1f8ff7e0bb1 100644
--- a/app/assets/javascripts/pages/projects/labels/index/index.js
+++ b/app/assets/javascripts/pages/projects/labels/index/index.js
@@ -1,83 +1,3 @@
-import Vue from 'vue';
-import initDeleteLabelModal from '~/delete_label_modal';
-import initLabels from '~/init_labels';
-import { BV_SHOW_MODAL } from '~/lib/utils/constants';
-import Translate from '~/vue_shared/translate';
-import PromoteLabelModal from '../components/promote_label_modal.vue';
-import eventHub from '../event_hub';
+import { initLabelIndex } from '~/labels';
-Vue.use(Translate);
-
-const initLabelIndex = () => {
- initLabels();
- initDeleteLabelModal();
-
- const onRequestFinished = ({ labelUrl, successful }) => {
- const button = document.querySelector(
- `.js-promote-project-label-button[data-url="${labelUrl}"]`,
- );
-
- if (!successful) {
- button.removeAttribute('disabled');
- }
- };
-
- const onRequestStarted = (labelUrl) => {
- const button = document.querySelector(
- `.js-promote-project-label-button[data-url="${labelUrl}"]`,
- );
- button.setAttribute('disabled', '');
- eventHub.$once('promoteLabelModal.requestFinished', onRequestFinished);
- };
-
- const promoteLabelButtons = document.querySelectorAll('.js-promote-project-label-button');
-
- return new Vue({
- el: '#js-promote-label-modal',
- data() {
- return {
- modalProps: {
- labelTitle: '',
- labelColor: '',
- labelTextColor: '',
- url: '',
- groupName: '',
- },
- };
- },
- mounted() {
- eventHub.$on('promoteLabelModal.props', this.setModalProps);
- eventHub.$emit('promoteLabelModal.mounted');
-
- promoteLabelButtons.forEach((button) => {
- button.removeAttribute('disabled');
- button.addEventListener('click', () => {
- this.$root.$emit(BV_SHOW_MODAL, 'promote-label-modal');
- eventHub.$once('promoteLabelModal.requestStarted', onRequestStarted);
-
- this.setModalProps({
- labelTitle: button.dataset.labelTitle,
- labelColor: button.dataset.labelColor,
- labelTextColor: button.dataset.labelTextColor,
- url: button.dataset.url,
- groupName: button.dataset.groupName,
- });
- });
- });
- },
- beforeDestroy() {
- eventHub.$off('promoteLabelModal.props', this.setModalProps);
- },
- methods: {
- setModalProps(modalProps) {
- this.modalProps = modalProps;
- },
- },
- render(createElement) {
- return createElement(PromoteLabelModal, {
- props: this.modalProps,
- });
- },
- });
-};
initLabelIndex();
diff --git a/app/assets/javascripts/pages/projects/labels/new/index.js b/app/assets/javascripts/pages/projects/labels/new/index.js
index 2e8308fe084..e4e377f62fc 100644
--- a/app/assets/javascripts/pages/projects/labels/new/index.js
+++ b/app/assets/javascripts/pages/projects/labels/new/index.js
@@ -1,4 +1,4 @@
-import Labels from 'ee_else_ce/labels';
+import Labels from 'ee_else_ce/labels/labels';
// eslint-disable-next-line no-new
new Labels();
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue
index 95afcb6bda8..42c40cda601 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue
@@ -1,18 +1,21 @@
<script>
-import { GlProgressBar, GlSprintf } from '@gitlab/ui';
+import { GlProgressBar, GlSprintf, GlAlert } from '@gitlab/ui';
import eventHub from '~/invite_members/event_hub';
import { s__ } from '~/locale';
import { ACTION_LABELS, ACTION_SECTIONS } from '../constants';
import LearnGitlabSectionCard from './learn_gitlab_section_card.vue';
export default {
- components: { GlProgressBar, GlSprintf, LearnGitlabSectionCard },
+ components: { GlProgressBar, GlSprintf, GlAlert, LearnGitlabSectionCard },
i18n: {
title: s__('LearnGitLab|Learn GitLab'),
description: s__(
'LearnGitLab|Ready to get started with GitLab? Follow these steps to set up your workspace, plan and commit changes, and deploy your project.',
),
percentageCompleted: s__(`LearnGitLab|%{percentage}%{percentSymbol} completed`),
+ successfulInvitations: s__(
+ "LearnGitLab|Your team is growing! You've successfully invited new team members to the %{projectName} project.",
+ ),
},
props: {
actions: {
@@ -28,12 +31,22 @@ export default {
required: false,
default: false,
},
+ project: {
+ required: true,
+ type: Object,
+ },
+ },
+ data() {
+ return {
+ showSuccessfulInvitationsAlert: false,
+ actionsData: this.actions,
+ };
},
maxValue: Object.keys(ACTION_LABELS).length,
actionSections: Object.keys(ACTION_SECTIONS),
computed: {
progressValue() {
- return Object.values(this.actions).filter((a) => a.completed).length;
+ return Object.values(this.actionsData).filter((a) => a.completed).length;
},
progressPercentage() {
return Math.round((this.progressValue / this.$options.maxValue) * 100);
@@ -43,14 +56,23 @@ export default {
if (this.inviteMembersOpen) {
this.openInviteMembersModal('celebrate');
}
+
+ eventHub.$on('showSuccessfulInvitationsAlert', this.handleShowSuccessfulInvitationsAlert);
+ },
+ beforeDestroy() {
+ eventHub.$off('showSuccessfulInvitationsAlert', this.handleShowSuccessfulInvitationsAlert);
},
methods: {
openInviteMembersModal(mode) {
eventHub.$emit('openModal', { mode, inviteeType: 'members', source: 'learn-gitlab' });
},
+ handleShowSuccessfulInvitationsAlert() {
+ this.showSuccessfulInvitationsAlert = true;
+ this.markActionAsCompleted('userAdded');
+ },
actionsFor(section) {
const actions = Object.fromEntries(
- Object.entries(this.actions).filter(
+ Object.entries(this.actionsData).filter(
([action]) => ACTION_LABELS[action].section === section,
),
);
@@ -59,11 +81,34 @@ export default {
svgFor(section) {
return this.sections[section].svg;
},
+ markActionAsCompleted(completedAction) {
+ Object.keys(this.actionsData).forEach((action) => {
+ if (action === completedAction) {
+ this.actionsData[action].completed = true;
+ this.modifySidebarPercentage();
+ }
+ });
+ },
+ modifySidebarPercentage() {
+ const el = document.querySelector('.sidebar-top-level-items .active .count');
+ el.textContent = `${this.progressPercentage}%`;
+ },
},
};
</script>
<template>
<div>
+ <gl-alert
+ v-if="showSuccessfulInvitationsAlert"
+ class="gl-mt-5"
+ @dismiss="showSuccessfulInvitationsAlert = false"
+ >
+ <gl-sprintf :message="$options.i18n.successfulInvitations">
+ <template #projectName>
+ <strong>{{ project.name }}</strong>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
<div class="row">
<div class="gl-mb-7 gl-ml-5">
<h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
index 0995947f3e7..3a401f5cb31 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
@@ -1,5 +1,7 @@
<script>
import { GlLink, GlIcon } from '@gitlab/ui';
+import { isExperimentVariant } from '~/experimentation/utils';
+import eventHub from '~/invite_members/event_hub';
import { s__ } from '~/locale';
import { ACTION_LABELS } from '../constants';
@@ -24,6 +26,20 @@ export default {
trialOnly() {
return ACTION_LABELS[this.action].trialRequired;
},
+ showInviteModalLink() {
+ return (
+ this.action === 'userAdded' && isExperimentVariant('invite_for_help_continuous_onboarding')
+ );
+ },
+ },
+ methods: {
+ openModal() {
+ eventHub.$emit('openModal', {
+ inviteeType: 'members',
+ source: 'learn_gitlab',
+ tasksToBeDoneEnabled: true,
+ });
+ },
},
};
</script>
@@ -33,18 +49,27 @@ export default {
<gl-icon name="check-circle-filled" :size="16" data-testid="completed-icon" />
{{ $options.i18n.ACTION_LABELS[action].title }}
</span>
- <span v-else>
- <gl-link
- target="_blank"
- :href="value.url"
- data-track-action="click_link"
- :data-track-label="$options.i18n.ACTION_LABELS[action].title"
- data-track-property="Growth::Conversion::Experiment::LearnGitLab"
- data-track-experiment="change_continuous_onboarding_link_urls"
- >
- {{ $options.i18n.ACTION_LABELS[action].title }}
- </gl-link>
- </span>
+ <gl-link
+ v-else-if="showInviteModalLink"
+ data-track-action="click_link"
+ :data-track-label="$options.i18n.ACTION_LABELS[action].title"
+ data-track-property="Growth::Activation::Experiment::InviteForHelpContinuousOnboarding"
+ data-testid="invite-for-help-continuous-onboarding-experiment-link"
+ @click="openModal"
+ >
+ {{ $options.i18n.ACTION_LABELS[action].title }}
+ </gl-link>
+ <gl-link
+ v-else
+ target="_blank"
+ :href="value.url"
+ data-track-action="click_link"
+ :data-track-label="$options.i18n.ACTION_LABELS[action].title"
+ data-track-property="Growth::Conversion::Experiment::LearnGitLab"
+ data-track-experiment="change_continuous_onboarding_link_urls"
+ >
+ {{ $options.i18n.ACTION_LABELS[action].title }}
+ </gl-link>
<span v-if="trialOnly" class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only">
- {{ $options.i18n.trialOnly }}
</span>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js
index ea9eec2595f..1f91cc46946 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js
@@ -12,17 +12,18 @@ function initLearnGitlab() {
const actions = convertObjectPropsToCamelCase(JSON.parse(el.dataset.actions));
const sections = convertObjectPropsToCamelCase(JSON.parse(el.dataset.sections));
+ const project = convertObjectPropsToCamelCase(JSON.parse(el.dataset.project));
const { inviteMembersOpen } = el.dataset;
return new Vue({
el,
render(createElement) {
return createElement(LearnGitlab, {
- props: { actions, sections, inviteMembersOpen },
+ props: { actions, sections, project, inviteMembersOpen },
});
},
});
}
-initInviteMembersModal();
initLearnGitlab();
+initInviteMembersModal();
diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
index d279086df7b..acd1731a700 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
@@ -1,17 +1,17 @@
import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
-import initCsvImportExportButtons from '~/issuable/init_csv_import_export_buttons';
-import initIssuableByEmail from '~/issuable/init_issuable_by_email';
-import IssuableIndex from '~/issuable_index';
-import { FILTERED_SEARCH } from '~/pages/constants';
-import { ISSUABLE_INDEX } from '~/pages/projects/constants';
+import { initCsvImportExportButtons, initIssuableByEmail } from '~/issuable';
+import issuableInitBulkUpdateSidebar from '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar';
+import { FILTERED_SEARCH } from '~/filtered_search/constants';
+import { ISSUABLE_INDEX } from '~/issuable/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import UsersSelect from '~/users_select';
-new IssuableIndex(ISSUABLE_INDEX.MERGE_REQUEST); // eslint-disable-line no-new
+issuableInitBulkUpdateSidebar.init(ISSUABLE_INDEX.MERGE_REQUEST);
addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
+IssuableFilteredSearchTokenKeys.removeTokensForKeys('iteration');
initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS,
diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
index 7d5719cf8a8..ebf7c266482 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
@@ -1,13 +1,13 @@
/* eslint-disable no-new */
import $ from 'jquery';
-import IssuableForm from 'ee_else_ce/issuable_form';
+import IssuableForm from 'ee_else_ce/issuable/issuable_form';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import Diff from '~/diff';
import GLForm from '~/gl_form';
-import LabelsSelect from '~/labels_select';
-import MilestoneSelect from '~/milestone_select';
-import IssuableTemplateSelectors from '~/templates/issuable_template_selectors';
+import LabelsSelect from '~/labels/labels_select';
+import MilestoneSelect from '~/milestones/milestone_select';
+import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
export default () => {
new Diff();
diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
index 99094617b0a..c548ea9bb80 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
@@ -3,7 +3,7 @@ import VueApollo from 'vue-apollo';
import loadAwardsHandler from '~/awards_handler';
import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import { initPipelineCountListener } from '~/commit/pipelines/utils';
-import initIssuableSidebar from '~/init_issuable_sidebar';
+import { initIssuableSidebar } from '~/issuable';
import StatusBox from '~/issuable/components/status_box.vue';
import createDefaultClient from '~/lib/graphql';
import initSourcegraph from '~/sourcegraph';
diff --git a/app/assets/javascripts/pages/projects/merge_requests/queries/get_state.query.graphql b/app/assets/javascripts/pages/projects/merge_requests/queries/get_state.query.graphql
index b5a82b9428e..1edb37a228d 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/queries/get_state.query.graphql
+++ b/app/assets/javascripts/pages/projects/merge_requests/queries/get_state.query.graphql
@@ -1,6 +1,8 @@
query getMergeRequestState($projectPath: ID!, $iid: String!) {
workspace: project(fullPath: $projectPath) {
+ id
issuable: mergeRequest(iid: $iid) {
+ id
state
}
}
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 25dede33880..7f49eb60c5c 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
@@ -1,8 +1,8 @@
import { initReviewBar } from '~/batch_comments';
+import { initIssuableHeaderWarnings } from '~/issuable';
import initMrNotes from '~/mr_notes';
import store from '~/mr_notes/stores';
import initSidebarBundle from '~/sidebar/sidebar_bundle';
-import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning';
import initShow from '../init_merge_request_show';
initMrNotes();
@@ -11,5 +11,5 @@ initShow();
requestIdleCallback(() => {
initSidebarBundle(store);
initReviewBar();
- initIssuableHeaderWarning(store);
+ initIssuableHeaderWarnings(store);
});
diff --git a/app/assets/javascripts/pages/projects/milestones/edit/index.js b/app/assets/javascripts/pages/projects/milestones/edit/index.js
index 4f8514a9a1d..7fda129a85d 100644
--- a/app/assets/javascripts/pages/projects/milestones/edit/index.js
+++ b/app/assets/javascripts/pages/projects/milestones/edit/index.js
@@ -1,3 +1,3 @@
-import initForm from '~/shared/milestones/form';
+import { initForm } from '~/milestones';
initForm();
diff --git a/app/assets/javascripts/pages/projects/milestones/index/index.js b/app/assets/javascripts/pages/projects/milestones/index/index.js
index 150b506b121..ef1c9ab83db 100644
--- a/app/assets/javascripts/pages/projects/milestones/index/index.js
+++ b/app/assets/javascripts/pages/projects/milestones/index/index.js
@@ -1,3 +1,4 @@
-import milestones from '~/pages/milestones/shared';
+import { initDeleteMilestoneModal, initPromoteMilestoneModal } from '~/milestones';
-milestones();
+initDeleteMilestoneModal();
+initPromoteMilestoneModal();
diff --git a/app/assets/javascripts/pages/projects/milestones/new/index.js b/app/assets/javascripts/pages/projects/milestones/new/index.js
index 4f8514a9a1d..7fda129a85d 100644
--- a/app/assets/javascripts/pages/projects/milestones/new/index.js
+++ b/app/assets/javascripts/pages/projects/milestones/new/index.js
@@ -1,3 +1,3 @@
-import initForm from '~/shared/milestones/form';
+import { initForm } from '~/milestones';
initForm();
diff --git a/app/assets/javascripts/pages/projects/milestones/show/index.js b/app/assets/javascripts/pages/projects/milestones/show/index.js
index 3c755e9b98c..16aac7748da 100644
--- a/app/assets/javascripts/pages/projects/milestones/show/index.js
+++ b/app/assets/javascripts/pages/projects/milestones/show/index.js
@@ -1,5 +1,5 @@
-import milestones from '~/pages/milestones/shared';
-import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show';
+import { initDeleteMilestoneModal, initPromoteMilestoneModal, initShow } from '~/milestones';
-initMilestonesShow();
-milestones();
+initShow();
+initDeleteMilestoneModal();
+initPromoteMilestoneModal();
diff --git a/app/assets/javascripts/pages/projects/packages/packages/index/index.js b/app/assets/javascripts/pages/projects/packages/packages/index/index.js
index f9eecff4ac4..174973a9fad 100644
--- a/app/assets/javascripts/pages/projects/packages/packages/index/index.js
+++ b/app/assets/javascripts/pages/projects/packages/packages/index/index.js
@@ -1,3 +1,3 @@
-import packageList from '~/packages_and_registries/package_registry/pages/list';
+import packageApp from '~/packages_and_registries/package_registry/index';
-packageList();
+packageApp();
diff --git a/app/assets/javascripts/pages/projects/path_locks/index.js b/app/assets/javascripts/pages/projects/path_locks/index.js
deleted file mode 100644
index e5ab5d43bbf..00000000000
--- a/app/assets/javascripts/pages/projects/path_locks/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
-
-document.addEventListener('DOMContentLoaded', initDeprecatedRemoveRowBehavior);
diff --git a/app/assets/javascripts/pages/projects/services/edit/index.js b/app/assets/javascripts/pages/projects/services/edit/index.js
index 03ffc323fc0..a2b18d86240 100644
--- a/app/assets/javascripts/pages/projects/services/edit/index.js
+++ b/app/assets/javascripts/pages/projects/services/edit/index.js
@@ -1,9 +1,8 @@
-import IntegrationSettingsForm from '~/integrations/integration_settings_form';
+import initIntegrationSettingsForm from '~/integrations/edit';
import PrometheusAlerts from '~/prometheus_alerts';
import CustomMetrics from '~/prometheus_metrics/custom_metrics';
-const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
-integrationSettingsForm.init();
+initIntegrationSettingsForm('.js-integration-settings-form');
const prometheusSettingsSelector = '.js-prometheus-metrics-monitoring';
const prometheusSettingsWrapper = document.querySelector(prometheusSettingsSelector);
diff --git a/app/assets/javascripts/pages/projects/usage_quotas/index.js b/app/assets/javascripts/pages/projects/usage_quotas/index.js
deleted file mode 100644
index 9cd80b85c8a..00000000000
--- a/app/assets/javascripts/pages/projects/usage_quotas/index.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs';
-import storageCounter from '~/projects/storage_counter';
-import initSearchSettings from '~/search_settings';
-
-const initLinkedTabs = () => {
- if (!document.querySelector('.js-usage-quota-tabs')) {
- return false;
- }
-
- return new LinkedTabs({
- defaultAction: '#storage-quota-tab',
- parentEl: '.js-usage-quota-tabs',
- hashedTabs: true,
- });
-};
-
-const initVueApp = () => {
- storageCounter('js-project-storage-count-app');
-};
-
-initVueApp();
-initLinkedTabs();
-initSearchSettings();
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
index 6f19a9f4379..b29e9455755 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -15,6 +15,7 @@ import { setUrlFragment } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale';
import Tracking from '~/tracking';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
CONTENT_EDITOR_LOADED_ACTION,
SAVED_USING_CONTENT_EDITOR_ACTION,
@@ -46,7 +47,7 @@ export default {
newPage: s__(
'WikiPage|Tip: You can specify the full path for the new file. We will automatically create any missing directories.',
),
- moreInformation: s__('WikiPage|More Information.'),
+ learnMore: s__('WikiPage|Learn more.'),
},
},
format: {
@@ -104,6 +105,8 @@ export default {
newPage: s__('WikiPage|Create page'),
},
cancel: s__('WikiPage|Cancel'),
+ editSourceButtonText: s__('WikiPage|Edit source'),
+ editRichTextButtonText: s__('WikiPage|Edit rich text'),
},
contentEditorFeedbackIssue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/332629',
components: {
@@ -123,7 +126,7 @@ export default {
directives: {
GlModalDirective,
},
- mixins: [trackingMixin],
+ mixins: [trackingMixin, glFeatureFlagMixin()],
inject: ['formatOptions', 'pageInfo'],
data() {
return {
@@ -131,7 +134,6 @@ export default {
format: this.pageInfo.format || 'markdown',
content: this.pageInfo.content || '',
isContentEditorAlertDismissed: false,
- isContentEditorLoading: true,
useContentEditor: false,
commitMessage: '',
isDirty: false,
@@ -164,6 +166,11 @@ export default {
linkExample() {
return MARKDOWN_LINK_TEXT[this.format];
},
+ toggleEditingModeButtonText() {
+ return this.isContentEditorActive
+ ? this.$options.i18n.editSourceButtonText
+ : this.$options.i18n.editRichTextButtonText;
+ },
submitButtonText() {
return this.pageInfo.persisted
? this.$options.i18n.submitButton.existingPage
@@ -188,7 +195,23 @@ export default {
return this.format === 'markdown';
},
showContentEditorAlert() {
- return this.isMarkdownFormat && !this.useContentEditor && !this.isContentEditorAlertDismissed;
+ return (
+ !this.glFeatures.wikiSwitchBetweenContentEditorRawMarkdown &&
+ this.isMarkdownFormat &&
+ !this.useContentEditor &&
+ !this.isContentEditorAlertDismissed
+ );
+ },
+ showSwitchEditingModeButton() {
+ return this.glFeatures.wikiSwitchBetweenContentEditorRawMarkdown && this.isMarkdownFormat;
+ },
+ displayWikiSpecificMarkdownHelp() {
+ return !this.isContentEditorActive;
+ },
+ displaySwitchBackToClassicEditorMessage() {
+ return (
+ !this.glFeatures.wikiSwitchBetweenContentEditorRawMarkdown && this.isContentEditorActive
+ );
},
disableSubmitButton() {
return this.noContent || !this.title || this.contentEditorRenderFailed;
@@ -212,6 +235,14 @@ export default {
.then(({ data }) => data.body);
},
+ toggleEditingMode() {
+ if (this.useContentEditor) {
+ this.content = this.contentEditor.getSerializedContent();
+ }
+
+ this.useContentEditor = !this.useContentEditor;
+ },
+
async handleFormSubmit(e) {
e.preventDefault();
@@ -311,8 +342,11 @@ export default {
trackWikiFormat() {
this.track(WIKI_FORMAT_UPDATED_ACTION, {
label: WIKI_FORMAT_LABEL,
- value: this.format,
- extra: { project_path: this.pageInfo.path, old_format: this.pageInfo.format },
+ extra: {
+ project_path: this.pageInfo.path,
+ old_format: this.pageInfo.format,
+ value: this.format,
+ },
});
},
@@ -371,10 +405,9 @@ export default {
<span class="gl-display-inline-block gl-max-w-full gl-mt-2 gl-text-gray-600">
<gl-icon class="gl-mr-n1" name="bulb" />
{{ titleHelpText }}
- <gl-link :href="helpPath" target="_blank"
- ><gl-icon name="question-o" />
- {{ $options.i18n.title.helpText.moreInformation }}</gl-link
- >
+ <gl-link :href="helpPath" target="_blank">
+ {{ $options.i18n.title.helpText.learnMore }}
+ </gl-link>
</span>
</div>
</div>
@@ -405,6 +438,19 @@ export default {
}}</label>
</div>
<div class="col-sm-10">
+ <div
+ v-if="showSwitchEditingModeButton"
+ class="gl-display-flex gl-justify-content-end gl-mb-3"
+ >
+ <gl-button
+ data-testid="toggle-editing-mode-button"
+ data-qa-selector="editing_mode_button"
+ :data-qa-mode="toggleEditingModeButtonText"
+ variant="link"
+ @click="toggleEditingMode"
+ >{{ toggleEditingModeButtonText }}</gl-button
+ >
+ </div>
<gl-alert
v-if="showContentEditorAlert"
class="gl-mb-6"
@@ -498,7 +544,7 @@ export default {
<div class="error-alert"></div>
<div class="form-text gl-text-gray-600">
- <gl-sprintf v-if="!isContentEditorActive" :message="$options.i18n.linksHelpText">
+ <gl-sprintf v-if="displayWikiSpecificMarkdownHelp" :message="$options.i18n.linksHelpText">
<template #linkExample
><code>{{ linkExample }}</code></template
>
@@ -513,7 +559,7 @@ export default {
></template
>
</gl-sprintf>
- <span v-else>
+ <span v-if="displaySwitchBackToClassicEditorMessage">
{{ $options.i18n.contentEditor.switchToOldEditor.helpText }}
<gl-button variant="link" @click="confirmSwitchToOldEditor">{{
$options.i18n.contentEditor.switchToOldEditor.label
diff --git a/app/assets/javascripts/pdf/index.vue b/app/assets/javascripts/pdf/index.vue
index 6a64538abfe..644eccc0232 100644
--- a/app/assets/javascripts/pdf/index.vue
+++ b/app/assets/javascripts/pdf/index.vue
@@ -45,7 +45,7 @@ export default {
.promise.then(this.renderPages)
.then((pages) => {
this.pages = pages;
- this.$emit('pdflabload');
+ this.$emit('pdflabload', pages.length);
})
.catch((error) => {
this.$emit('pdflaberror', error);
diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
index 905a5f2d271..9f82d4a5395 100644
--- a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
+++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
@@ -73,7 +73,7 @@ export default {
});
},
onReset() {
- this.$emit('cancel');
+ this.$emit('resetContent');
},
scrollIntoView() {
this.$el.scrollIntoView({ behavior: 'smooth' });
@@ -86,7 +86,7 @@ export default {
startMergeRequest: __('Start a %{new_merge_request} with these changes'),
newMergeRequest: __('new merge request'),
commitChanges: __('Commit changes'),
- cancel: __('Cancel'),
+ resetContent: __('Reset'),
},
};
</script>
@@ -148,7 +148,7 @@ export default {
{{ $options.i18n.commitChanges }}
</gl-button>
<gl-button type="reset" category="secondary" class="gl-mr-3">
- {{ $options.i18n.cancel }}
+ {{ $options.i18n.resetContent }}
</gl-button>
</div>
</gl-form>
diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
index 14c11099756..54c9688d88f 100644
--- a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
+++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
@@ -8,10 +8,10 @@ import {
COMMIT_SUCCESS,
} from '../../constants';
import commitCIFile from '../../graphql/mutations/commit_ci_file.mutation.graphql';
-import updateCurrentBranchMutation from '../../graphql/mutations/update_current_branch.mutation.graphql';
-import updateLastCommitBranchMutation from '../../graphql/mutations/update_last_commit_branch.mutation.graphql';
-import updatePipelineEtag from '../../graphql/mutations/update_pipeline_etag.mutation.graphql';
-import getCurrentBranch from '../../graphql/queries/client/current_branch.graphql';
+import updateCurrentBranchMutation from '../../graphql/mutations/client/update_current_branch.mutation.graphql';
+import updateLastCommitBranchMutation from '../../graphql/mutations/client/update_last_commit_branch.mutation.graphql';
+import updatePipelineEtag from '../../graphql/mutations/client/update_pipeline_etag.mutation.graphql';
+import getCurrentBranch from '../../graphql/queries/client/current_branch.query.graphql';
import CommitForm from './commit_form.vue';
@@ -60,6 +60,9 @@ export default {
apollo: {
currentBranch: {
query: getCurrentBranch,
+ update(data) {
+ return data.workBranches.current.name;
+ },
},
},
computed: {
@@ -87,7 +90,7 @@ export default {
try {
const {
data: {
- commitCreate: { errors },
+ commitCreate: { errors, commitPipelinePath: pipelineEtag },
},
} = await this.$apollo.mutate({
mutation: commitCIFile,
@@ -101,14 +104,12 @@ export default {
content: this.ciFileContent,
lastCommitId: this.commitSha,
},
- update(_, { data }) {
- const pipelineEtag = data?.commitCreate?.commit?.commitPipelinePath;
- if (pipelineEtag) {
- this.$apollo.mutate({ mutation: updatePipelineEtag, variables: pipelineEtag });
- }
- },
});
+ if (pipelineEtag) {
+ this.updatePipelineEtag(pipelineEtag);
+ }
+
if (errors?.length) {
this.$emit('showError', { type: COMMIT_FAILURE, reasons: errors });
} else if (openMergeRequest) {
@@ -127,9 +128,6 @@ export default {
this.isSaving = false;
}
},
- onCommitCancel() {
- this.$emit('resetContent');
- },
updateCurrentBranch(currentBranch) {
this.$apollo.mutate({
mutation: updateCurrentBranchMutation,
@@ -142,6 +140,9 @@ export default {
variables: { lastCommitBranch },
});
},
+ updatePipelineEtag(pipelineEtag) {
+ this.$apollo.mutate({ mutation: updatePipelineEtag, variables: { pipelineEtag } });
+ },
},
};
</script>
@@ -153,7 +154,6 @@ export default {
:is-saving="isSaving"
:scroll-to-commit-form="scrollToCommitForm"
v-on="$listeners"
- @cancel="onCommitCancel"
@submit="onCommitSubmit"
/>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue
index d7594fb318a..7bc096ce2c8 100644
--- a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue
+++ b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue
@@ -90,7 +90,7 @@ export default {
<local-storage-sync v-model="isExpanded" :storage-key="$options.localDrawerKey" as-json>
<aside
aria-live="polite"
- class="gl-fixed gl-right-0 gl-bg-gray-10 gl-shadow-drawer gl-transition-property-width gl-transition-duration-medium gl-border-l-solid gl-border-1 gl-border-gray-100 gl-h-full gl-z-index-3 gl-overflow-y-auto"
+ class="gl-fixed gl-right-0 gl-bg-gray-10 gl-shadow-drawer gl-transition-property-width gl-transition-duration-medium gl-border-l-solid gl-border-1 gl-border-gray-100 gl-h-full gl-z-index-200 gl-overflow-y-auto"
:style="rootStyle"
>
<gl-button
@@ -98,6 +98,7 @@ export default {
class="gl-w-full gl-h-9 gl-rounded-0! gl-border-none! gl-border-b-solid! gl-border-1! gl-border-gray-100 gl-text-decoration-none! gl-outline-0! gl-display-flex"
:class="buttonClass"
:title="__('Toggle sidebar')"
+ data-qa-selector="toggle_sidebar_collapse_button"
@click="toggleDrawer"
>
<span v-if="isExpanded" class="gl-text-gray-500 gl-mr-3" data-testid="collapse-text">
@@ -105,7 +106,12 @@ export default {
</span>
<gl-icon data-testid="toggle-icon" :name="buttonIconName" />
</gl-button>
- <div v-if="isExpanded" class="gl-h-full gl-p-5" data-testid="drawer-content">
+ <div
+ v-if="isExpanded"
+ class="gl-h-full gl-p-5"
+ data-testid="drawer-content"
+ data-qa-selector="drawer_content"
+ >
<getting-started-card class="gl-mb-4" />
<first-pipeline-card class="gl-mb-4" />
<visualize-and-lint-card class="gl-mb-4" />
diff --git a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
index 7b8e97b573e..92fa411d5af 100644
--- a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
+++ b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
@@ -19,7 +19,7 @@ export default {
if (this.glFeatures.schemaLinting) {
const editorInstance = this.$refs.editor.getEditor();
- editorInstance.use(new CiSchemaExtension({ instance: editorInstance }));
+ editorInstance.use({ definition: CiSchemaExtension });
editorInstance.registerCiSchema();
}
},
diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
index baf1d17b233..4f79a81d539 100644
--- a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
+++ b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
@@ -18,10 +18,10 @@ import {
BRANCH_SEARCH_DEBOUNCE,
DEFAULT_FAILURE,
} from '~/pipeline_editor/constants';
-import updateCurrentBranchMutation from '~/pipeline_editor/graphql/mutations/update_current_branch.mutation.graphql';
-import getAvailableBranchesQuery from '~/pipeline_editor/graphql/queries/available_branches.graphql';
-import getCurrentBranchQuery from '~/pipeline_editor/graphql/queries/client/current_branch.graphql';
-import getLastCommitBranchQuery from '~/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql';
+import updateCurrentBranchMutation from '~/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql';
+import getAvailableBranchesQuery from '~/pipeline_editor/graphql/queries/available_branches.query.graphql';
+import getCurrentBranch from '~/pipeline_editor/graphql/queries/client/current_branch.query.graphql';
+import getLastCommitBranch from '~/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql';
export default {
i18n: {
@@ -61,8 +61,8 @@ export default {
},
data() {
return {
- branchSelected: null,
availableBranches: [],
+ branchSelected: null,
filteredBranches: [],
isSearchingBranches: false,
pageLimit: this.paginationLimit,
@@ -93,15 +93,25 @@ export default {
},
},
currentBranch: {
- query: getCurrentBranchQuery,
+ query: getCurrentBranch,
+ update(data) {
+ return data.workBranches.current.name;
+ },
},
lastCommitBranch: {
- query: getLastCommitBranchQuery,
- result({ data: { lastCommitBranch } }) {
- if (lastCommitBranch === '' || this.availableBranches.includes(lastCommitBranch)) {
- return;
+ query: getLastCommitBranch,
+ update(data) {
+ return data.workBranches.lastCommit.name;
+ },
+ result({ data }) {
+ if (data) {
+ const { name: lastCommitBranch } = data.workBranches.lastCommit;
+ if (lastCommitBranch === '' || this.availableBranches.includes(lastCommitBranch)) {
+ return;
+ }
+
+ this.availableBranches.unshift(lastCommitBranch);
}
- this.availableBranches.unshift(lastCommitBranch);
},
},
},
@@ -109,12 +119,12 @@ export default {
branches() {
return this.searchTerm.length > 0 ? this.filteredBranches : this.availableBranches;
},
- isBranchesLoading() {
- return this.$apollo.queries.availableBranches.loading || this.isSearchingBranches;
- },
enableBranchSwitcher() {
return this.branches.length > 0 || this.searchTerm.length > 0;
},
+ isBranchesLoading() {
+ return this.$apollo.queries.availableBranches.loading || this.isSearchingBranches;
+ },
},
watch: {
shouldLoadNewBranch(flag) {
@@ -247,6 +257,7 @@ export default {
<gl-infinite-scroll
:fetched-items="branches.length"
:max-list-height="250"
+ data-qa-selector="branch_menu_container"
@bottomReached="fetchNextBranches"
>
<template #items>
@@ -255,7 +266,7 @@ export default {
:key="branch"
:is-checked="currentBranch === branch"
:is-check-item="true"
- data-qa-selector="menu_branch_button"
+ data-qa-selector="branch_menu_item_button"
@click="selectBranch(branch)"
>
{{ branch }}
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
index 6fe1459c80c..16ad648afca 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
@@ -3,8 +3,8 @@ import { GlButton, GlIcon, GlLink, GlLoadingIcon, GlSprintf, GlTooltipDirective
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { truncateSha } from '~/lib/utils/text_utility';
import { s__ } from '~/locale';
-import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql';
-import getPipelineEtag from '~/pipeline_editor/graphql/queries/client/pipeline_etag.graphql';
+import getPipelineQuery from '~/pipeline_editor/graphql/queries/pipeline.query.graphql';
+import getPipelineEtag from '~/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql';
import {
getQueryHeaders,
toggleQueryPollingByVisibility,
@@ -21,9 +21,6 @@ export const i18n = {
),
viewBtn: s__('Pipeline|View pipeline'),
viewCommit: s__('Pipeline|View commit'),
- pipelineNotTriggeredMsg: s__(
- 'Pipeline|No pipeline was triggered for the latest changes due to the current CI/CD configuration.',
- ),
};
export default {
@@ -51,6 +48,9 @@ export default {
apollo: {
pipelineEtag: {
query: getPipelineEtag,
+ update(data) {
+ return data.etags.pipeline;
+ },
},
pipeline: {
context() {
@@ -79,22 +79,16 @@ export default {
result(res) {
if (res.data?.project?.pipeline) {
this.hasError = false;
- } else {
- this.hasError = true;
- this.pipelineNotTriggered = true;
}
},
error() {
this.hasError = true;
- this.networkError = true;
},
pollInterval: POLL_INTERVAL,
},
},
data() {
return {
- networkError: false,
- pipelineNotTriggered: false,
hasError: false,
};
},
@@ -148,16 +142,8 @@ export default {
</div>
</template>
<template v-else-if="hasError">
- <div v-if="networkError">
- <gl-icon class="gl-mr-auto" name="warning-solid" />
- <span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span>
- </div>
- <div v-else>
- <gl-icon class="gl-mr-auto" name="information-o" />
- <span data-testid="pipeline-not-triggered-error-msg">
- {{ $options.i18n.pipelineNotTriggeredMsg }}
- </span>
- </div>
+ <gl-icon class="gl-mr-auto" name="warning-solid" />
+ <span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span>
</template>
<template v-else>
<div class="gl-text-truncate gl-md-max-w-50p gl-mr-1">
diff --git a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
index 611b78b3c5e..833d784f940 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
@@ -1,8 +1,8 @@
<script>
import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
-import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.graphql';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.query.graphql';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import {
EDITOR_APP_STATUS_EMPTY,
EDITOR_APP_STATUS_LOADING,
@@ -43,6 +43,9 @@ export default {
apollo: {
appStatus: {
query: getAppStatus,
+ update(data) {
+ return data.app.status;
+ },
},
},
computed: {
diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue
index 7f6dce05b6e..13e254f138a 100644
--- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue
+++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAlert, GlLink, GlSprintf, GlTable } from '@gitlab/ui';
+import { GlAlert, GlLink, GlSprintf, GlTableLite } from '@gitlab/ui';
import { __ } from '~/locale';
import CiLintResultsParam from './ci_lint_results_param.vue';
import CiLintResultsValue from './ci_lint_results_value.vue';
@@ -36,7 +36,7 @@ export default {
GlAlert,
GlLink,
GlSprintf,
- GlTable,
+ GlTableLite,
CiLintWarnings,
CiLintResultsValue,
CiLintResultsParam,
@@ -129,7 +129,7 @@ export default {
@dismiss="isWarningDismissed = true"
/>
- <gl-table
+ <gl-table-lite
v-if="shouldShowTable"
:items="jobs"
:fields="$options.fields"
@@ -142,6 +142,6 @@ export default {
<template #cell(value)="{ item }">
<ci-lint-results-value :item="item" :dry-run="dryRun" />
</template>
- </gl-table>
+ </gl-table-lite>
</div>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
index 0cd0d17d944..3f50a1225d8 100644
--- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
+++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
@@ -17,7 +17,7 @@ import {
TABS_INDEX,
VISUALIZE_TAB,
} from '../constants';
-import getAppStatus from '../graphql/queries/client/app_status.graphql';
+import getAppStatus from '../graphql/queries/client/app_status.query.graphql';
import CiConfigMergedPreview from './editor/ci_config_merged_preview.vue';
import CiEditorHeader from './editor/ci_editor_header.vue';
import TextEditor from './editor/text_editor.vue';
@@ -91,6 +91,9 @@ export default {
apollo: {
appStatus: {
query: getAppStatus,
+ update(data) {
+ return data.app.status;
+ },
},
},
computed: {
diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql
index 5091d63111f..5091d63111f 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql
diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_app_status.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_app_status.mutation.graphql
index 7487e328668..7487e328668 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_app_status.mutation.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_app_status.mutation.graphql
diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_current_branch.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql
index b722c147f5f..b722c147f5f 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_current_branch.mutation.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql
diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_last_commit_branch.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_last_commit_branch.mutation.graphql
index 9561312f2b6..9561312f2b6 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_last_commit_branch.mutation.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_last_commit_branch.mutation.graphql
diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_pipeline_etag.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql
index 9025f00b343..9025f00b343 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_pipeline_etag.mutation.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql
diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql
index 94e6facabfd..77a3cdf586c 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql
@@ -19,7 +19,10 @@ mutation commitCIFile(
]
}
) {
+ __typename
commit {
+ __typename
+ id
sha
}
commitPipelinePath
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.query.graphql
index 46e9b108b41..359b4a846c7 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.query.graphql
@@ -5,6 +5,7 @@ query getAvailableBranches(
$searchPattern: String!
) {
project(fullPath: $projectFullPath) {
+ id
repository {
branchNames(limit: $limit, offset: $offset, searchPattern: $searchPattern)
}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.query.graphql
index 5500244b430..5928d90f7c4 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.query.graphql
@@ -1,8 +1,10 @@
query getBlobContent($projectPath: ID!, $path: String!, $ref: String) {
project(fullPath: $projectPath) {
+ id
repository {
blobs(paths: [$path], ref: $ref) {
nodes {
+ id
rawBlob
}
}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.query.graphql
index df7de6a1f54..df7de6a1f54 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.query.graphql
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.graphql
deleted file mode 100644
index 938f36c7d5c..00000000000
--- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.graphql
+++ /dev/null
@@ -1,3 +0,0 @@
-query getAppStatus {
- appStatus @client
-}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.query.graphql
new file mode 100644
index 00000000000..0df8cafa3cb
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.query.graphql
@@ -0,0 +1,5 @@
+query getAppStatus {
+ app @client {
+ status
+ }
+}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.graphql
deleted file mode 100644
index acd46013f5b..00000000000
--- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.graphql
+++ /dev/null
@@ -1,3 +0,0 @@
-query getCurrentBranch {
- currentBranch @client
-}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.query.graphql
new file mode 100644
index 00000000000..1f4f9d26f24
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.query.graphql
@@ -0,0 +1,7 @@
+query getCurrentBranch {
+ workBranches @client {
+ current {
+ name
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql
index e8a32d728d5..a83129759de 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql
@@ -1,3 +1,7 @@
query getLastCommitBranchQuery {
- lastCommitBranch @client
+ workBranches @client {
+ lastCommit {
+ name
+ }
+ }
}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.graphql
deleted file mode 100644
index b9946a9e233..00000000000
--- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.graphql
+++ /dev/null
@@ -1,3 +0,0 @@
-query getPipelineEtag {
- pipelineEtag @client
-}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql
new file mode 100644
index 00000000000..8df6e74a5d9
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql
@@ -0,0 +1,5 @@
+query getPipelineEtag {
+ etags @client {
+ pipeline
+ }
+}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/get_starter_template.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/get_starter_template.query.graphql
index 88825718f7b..a34c8f365f4 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/queries/get_starter_template.query.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/get_starter_template.query.graphql
@@ -1,5 +1,6 @@
query getTemplate($projectPath: ID!, $templateName: String!) {
project(fullPath: $projectPath) {
+ id
ciTemplate(name: $templateName) {
content
}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql
index 02d49507947..d62fda40237 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql
@@ -1,8 +1,10 @@
query getLatestCommitSha($projectPath: ID!, $ref: String) {
project(fullPath: $projectPath) {
+ id
repository {
tree(ref: $ref) {
lastCommit {
+ id
sha
}
}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/pipeline.query.graphql
index 34e98ae3eb3..021b858d72e 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/pipeline.query.graphql
@@ -1,14 +1,17 @@
query getPipeline($fullPath: ID!, $sha: String!) {
project(fullPath: $fullPath) {
+ id
pipeline(sha: $sha) {
id
iid
status
commit {
+ id
title
webPath
}
detailedStatus {
+ id
detailsPath
icon
group
diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
index e4965e00af3..fa1c70c1994 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
+++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
@@ -1,8 +1,8 @@
import axios from '~/lib/utils/axios_utils';
-import getAppStatus from './queries/client/app_status.graphql';
-import getCurrentBranchQuery from './queries/client/current_branch.graphql';
-import getLastCommitBranchQuery from './queries/client/last_commit_branch.query.graphql';
-import getPipelineEtag from './queries/client/pipeline_etag.graphql';
+import getAppStatus from './queries/client/app_status.query.graphql';
+import getCurrentBranch from './queries/client/current_branch.query.graphql';
+import getLastCommitBranch from './queries/client/last_commit_branch.query.graphql';
+import getPipelineEtag from './queries/client/pipeline_etag.query.graphql';
export const resolvers = {
Mutation: {
@@ -35,25 +35,51 @@ export const resolvers = {
updateAppStatus: (_, { appStatus }, { cache }) => {
cache.writeQuery({
query: getAppStatus,
- data: { appStatus },
+ data: {
+ app: {
+ __typename: 'PipelineEditorApp',
+ status: appStatus,
+ },
+ },
});
},
updateCurrentBranch: (_, { currentBranch }, { cache }) => {
cache.writeQuery({
- query: getCurrentBranchQuery,
- data: { currentBranch },
+ query: getCurrentBranch,
+ data: {
+ workBranches: {
+ __typename: 'BranchList',
+ current: {
+ __typename: 'WorkBranch',
+ name: currentBranch,
+ },
+ },
+ },
});
},
updateLastCommitBranch: (_, { lastCommitBranch }, { cache }) => {
cache.writeQuery({
- query: getLastCommitBranchQuery,
- data: { lastCommitBranch },
+ query: getLastCommitBranch,
+ data: {
+ workBranches: {
+ __typename: 'BranchList',
+ lastCommit: {
+ __typename: 'WorkBranch',
+ name: lastCommitBranch,
+ },
+ },
+ },
});
},
updatePipelineEtag: (_, { pipelineEtag }, { cache }) => {
cache.writeQuery({
query: getPipelineEtag,
- data: { pipelineEtag },
+ data: {
+ etags: {
+ __typename: 'EtagValues',
+ pipeline: pipelineEtag,
+ },
+ },
});
},
},
diff --git a/app/assets/javascripts/pipeline_editor/graphql/typedefs.graphql b/app/assets/javascripts/pipeline_editor/graphql/typedefs.graphql
index f4f65262158..508ff22c46e 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/typedefs.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/typedefs.graphql
@@ -1,7 +1,23 @@
-type BlobContent {
- rawData: String!
+type PipelineEditorApp {
+ status: String!
+}
+
+type BranchList {
+ current: WorkBranch!
+ lastCommit: WorkBranch!
+}
+
+type EtagValues {
+ pipeline: String!
+}
+
+type WorkBranch {
+ name: String!
+ commit: String
}
extend type Query {
- blobContent: BlobContent
+ app: PipelineEditorApp
+ etags: EtagValues
+ workBranches: BranchList
}
diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js
index 4f7f2743aca..ee93e327b76 100644
--- a/app/assets/javascripts/pipeline_editor/index.js
+++ b/app/assets/javascripts/pipeline_editor/index.js
@@ -5,10 +5,10 @@ import createDefaultClient from '~/lib/graphql';
import { resetServiceWorkersPublicPath } from '../lib/utils/webpack';
import { EDITOR_APP_STATUS_LOADING } from './constants';
import { CODE_SNIPPET_SOURCE_SETTINGS } from './components/code_snippet_alert/constants';
-import getCurrentBranch from './graphql/queries/client/current_branch.graphql';
-import getAppStatus from './graphql/queries/client/app_status.graphql';
-import getLastCommitBranchQuery from './graphql/queries/client/last_commit_branch.query.graphql';
-import getPipelineEtag from './graphql/queries/client/pipeline_etag.graphql';
+import getCurrentBranch from './graphql/queries/client/current_branch.query.graphql';
+import getAppStatus from './graphql/queries/client/app_status.query.graphql';
+import getLastCommitBranch from './graphql/queries/client/last_commit_branch.query.graphql';
+import getPipelineEtag from './graphql/queries/client/pipeline_etag.query.graphql';
import { resolvers } from './graphql/resolvers';
import typeDefs from './graphql/typedefs.graphql';
import PipelineEditorApp from './pipeline_editor_app.vue';
@@ -68,28 +68,46 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
cache.writeQuery({
query: getAppStatus,
data: {
- appStatus: EDITOR_APP_STATUS_LOADING,
+ app: {
+ __typename: 'PipelineEditorApp',
+ status: EDITOR_APP_STATUS_LOADING,
+ },
},
});
cache.writeQuery({
query: getCurrentBranch,
data: {
- currentBranch: initialBranchName || defaultBranch,
+ workBranches: {
+ __typename: 'BranchList',
+ current: {
+ __typename: 'WorkBranch',
+ name: initialBranchName || defaultBranch,
+ },
+ },
},
});
cache.writeQuery({
- query: getPipelineEtag,
+ query: getLastCommitBranch,
data: {
- pipelineEtag,
+ workBranches: {
+ __typename: 'BranchList',
+ lastCommit: {
+ __typename: 'WorkBranch',
+ name: '',
+ },
+ },
},
});
cache.writeQuery({
- query: getLastCommitBranchQuery,
+ query: getPipelineEtag,
data: {
- lastCommitBranch: '',
+ etags: {
+ __typename: 'EtagValues',
+ pipeline: pipelineEtag,
+ },
},
});
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
index 68db5d8078f..e397054f06a 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
@@ -1,8 +1,8 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlModal } from '@gitlab/ui';
import { fetchPolicies } from '~/lib/graphql';
import { queryToObject } from '~/lib/utils/url_utility';
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils';
@@ -17,11 +17,11 @@ import {
LOAD_FAILURE_UNKNOWN,
STARTER_TEMPLATE_NAME,
} from './constants';
-import updateAppStatus from './graphql/mutations/update_app_status.mutation.graphql';
-import getBlobContent from './graphql/queries/blob_content.graphql';
-import getCiConfigData from './graphql/queries/ci_config.graphql';
-import getAppStatus from './graphql/queries/client/app_status.graphql';
-import getCurrentBranch from './graphql/queries/client/current_branch.graphql';
+import updateAppStatus from './graphql/mutations/client/update_app_status.mutation.graphql';
+import getBlobContent from './graphql/queries/blob_content.query.graphql';
+import getCiConfigData from './graphql/queries/ci_config.query.graphql';
+import getAppStatus from './graphql/queries/client/app_status.query.graphql';
+import getCurrentBranch from './graphql/queries/client/current_branch.query.graphql';
import getTemplate from './graphql/queries/get_starter_template.query.graphql';
import getLatestCommitShaQuery from './graphql/queries/latest_commit_sha.query.graphql';
import PipelineEditorHome from './pipeline_editor_home.vue';
@@ -30,6 +30,7 @@ export default {
components: {
ConfirmUnsavedChangesDialog,
GlLoadingIcon,
+ GlModal,
PipelineEditorEmptyState,
PipelineEditorHome,
PipelineEditorMessages,
@@ -54,6 +55,7 @@ export default {
lastCommittedContent: '',
shouldSkipStartScreen: false,
showFailure: false,
+ showResetComfirmationModal: false,
showStartScreen: false,
showSuccess: false,
starterTemplate: '',
@@ -158,6 +160,9 @@ export default {
},
appStatus: {
query: getAppStatus,
+ update(data) {
+ return data.app.status;
+ },
},
commitSha: {
query: getLatestCommitShaQuery,
@@ -182,6 +187,9 @@ export default {
},
currentBranch: {
query: getCurrentBranch,
+ update(data) {
+ return data.workBranches.current.name;
+ },
},
starterTemplate: {
query: getTemplate,
@@ -220,9 +228,18 @@ export default {
},
},
i18n: {
- tabEdit: s__('Pipelines|Edit'),
- tabGraph: s__('Pipelines|Visualize'),
- tabLint: s__('Pipelines|Lint'),
+ resetModal: {
+ actionPrimary: {
+ text: __('Reset file'),
+ },
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ body: s__(
+ 'Pipeline Editor|Are you sure you want to reset the file to its last committed version?',
+ ),
+ title: __('Discard changes'),
+ },
},
watch: {
isEmpty(flag) {
@@ -242,15 +259,24 @@ export default {
hideSuccess() {
this.showSuccess = false;
},
+ confirmReset() {
+ if (this.hasUnsavedChanges) {
+ this.showResetComfirmationModal = true;
+ }
+ },
async refetchContent() {
this.$apollo.queries.initialCiFileContent.skip = false;
await this.$apollo.queries.initialCiFileContent.refetch();
},
reportFailure(type, reasons = []) {
- window.scrollTo({ top: 0, behavior: 'smooth' });
- this.showFailure = true;
- this.failureType = type;
- this.failureReasons = reasons;
+ const isCurrentFailure = this.failureType === type && this.failureReasons[0] === reasons[0];
+
+ if (!isCurrentFailure) {
+ this.showFailure = true;
+ this.failureType = type;
+ this.failureReasons = reasons;
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ }
},
reportSuccess(type) {
window.scrollTo({ top: 0, behavior: 'smooth' });
@@ -258,6 +284,7 @@ export default {
this.successType = type;
},
resetContent() {
+ this.showResetComfirmationModal = false;
this.currentCiFileContent = this.lastCommittedContent;
},
setAppStatus(appStatus) {
@@ -331,12 +358,22 @@ export default {
:has-unsaved-changes="hasUnsavedChanges"
:is-new-ci-config-file="isNewCiConfigFile"
@commit="updateOnCommit"
- @resetContent="resetContent"
+ @resetContent="confirmReset"
@showError="showErrorAlert"
@refetchContent="refetchContent"
@updateCiConfig="updateCiConfig"
@updateCommitSha="updateCommitSha"
/>
+ <gl-modal
+ v-model="showResetComfirmationModal"
+ modal-id="reset-content"
+ :title="$options.i18n.resetModal.title"
+ :action-cancel="$options.i18n.resetModal.actionCancel"
+ :action-primary="$options.i18n.resetModal.actionPrimary"
+ @primary="resetContent"
+ >
+ {{ $options.i18n.resetModal.body }}
+ </gl-modal>
<confirm-unsaved-changes-dialog :has-unsaved-changes="hasUnsavedChanges" />
</div>
</div>
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
index 3c78b655dc7..1920fed84ec 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAlert, GlLoadingIcon, GlSegmentedControl, GlToggle } from '@gitlab/ui';
+import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon, GlToggle } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { STAGE_VIEW, LAYER_VIEW } from './constants';
@@ -7,8 +7,9 @@ export default {
name: 'GraphViewSelector',
components: {
GlAlert,
+ GlButton,
+ GlButtonGroup,
GlLoadingIcon,
- GlSegmentedControl,
GlToggle,
},
props: {
@@ -96,6 +97,9 @@ export default {
this.hoverTipDismissed = true;
this.$emit('dismissHoverTip');
},
+ isCurrentType(type) {
+ return this.segmentSelectedType === type;
+ },
/*
In both toggle methods, we use setTimeout so that the loading indicator displays,
then the work is done to update the DOM. The process is:
@@ -110,11 +114,14 @@ export default {
See https://www.hesselinkwebdesign.nl/2019/nexttick-vs-settimeout-in-vue/ for more details.
*/
- toggleView(type) {
- this.isSwitcherLoading = true;
- setTimeout(() => {
- this.$emit('updateViewType', type);
- });
+ setViewType(type) {
+ if (!this.isCurrentType(type)) {
+ this.isSwitcherLoading = true;
+ this.segmentSelectedType = type;
+ setTimeout(() => {
+ this.$emit('updateViewType', type);
+ });
+ }
},
toggleShowLinksActive(val) {
this.isToggleLoading = true;
@@ -136,14 +143,16 @@ export default {
size="lg"
/>
<span class="gl-font-weight-bold">{{ $options.i18n.viewLabelText }}</span>
- <gl-segmented-control
- v-model="segmentSelectedType"
- :options="viewTypesList"
- :disabled="isSwitcherLoading"
- data-testid="pipeline-view-selector"
- class="gl-mx-4"
- @input="toggleView"
- />
+ <gl-button-group class="gl-mx-4">
+ <gl-button
+ v-for="viewType in viewTypesList"
+ :key="viewType.value"
+ :selected="isCurrentType(viewType.value)"
+ @click="setViewType(viewType.value)"
+ >
+ {{ viewType.text }}
+ </gl-button>
+ </gl-button-group>
<div v-if="showLinksToggle" class="gl-display-flex gl-align-items-center">
<gl-toggle
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 6f4360649ff..12c3f9a7f40 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
@@ -67,7 +67,7 @@ export default {
:class="cssClassJobName"
class="dropdown-menu-toggle gl-pipeline-job-width! gl-pr-4!"
>
- <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between">
+ <div class="gl-display-flex gl-align-items-stretch gl-justify-content-space-between">
<job-item
:type="$options.jobItemTypes.jobDropdown"
:group-tooltip="tooltipText"
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index 0216b2717ed..ee58dcc4882 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -203,7 +203,7 @@ export default {
<template>
<div
:id="computedJobId"
- class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between gl-w-full"
+ class="ci-job-component gl-display-flex gl-justify-content-space-between gl-pipeline-job-width"
data-qa-selector="job_item_container"
>
<component
@@ -223,12 +223,12 @@ export default {
>
<div class="ci-job-name-component gl-display-flex gl-align-items-center">
<ci-icon :size="24" :status="job.status" class="gl-line-height-0" />
- <div class="gl-pl-3 gl-display-flex gl-flex-direction-column gl-w-full">
- <div class="gl-text-truncate gl-w-70p gl-line-height-normal">{{ job.name }}</div>
+ <div class="gl-pl-3 gl-pr-3 gl-display-flex gl-flex-direction-column gl-pipeline-job-width">
+ <div class="gl-text-truncate gl-pr-9 gl-line-height-normal">{{ job.name }}</div>
<div
v-if="showStageName"
data-testid="stage-name-in-job"
- class="gl-text-truncate gl-w-70p gl-font-sm gl-text-gray-500 gl-line-height-normal"
+ class="gl-text-truncate gl-pr-9 gl-font-sm gl-text-gray-500 gl-line-height-normal"
>
{{ stageName }}
</div>
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
index be47799868b..e0c1dcc5be5 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
@@ -124,7 +124,7 @@ export default {
<div
ref="linkedPipeline"
v-gl-tooltip
- class="gl-pipeline-job-width"
+ class="gl-downstream-pipeline-job-width"
:title="tooltipText"
data-qa-selector="child_pipeline"
@mouseover="onDownstreamHovered"
@@ -134,7 +134,7 @@ export default {
class="gl-relative gl-bg-white gl-p-3 gl-border-solid gl-border-gray-100 gl-border-1"
:class="{ 'gl-pl-9': isUpstream }"
>
- <div class="gl-display-flex">
+ <div class="gl-display-flex gl-pr-7 gl-pipeline-job-width">
<ci-status
v-if="!pipelineIsLoading"
:status="pipelineStatus"
@@ -142,7 +142,9 @@ export default {
css-classes="gl-top-0 gl-pr-2"
/>
<div v-else class="gl-pr-2"><gl-loading-icon size="sm" inline /></div>
- <div class="gl-display-flex gl-flex-direction-column gl-w-13">
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-pipeline-job-width gl-text-truncate"
+ >
<span class="gl-text-truncate" data-testid="downstream-title">
{{ downstreamTitle }}
</span>
diff --git a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
new file mode 100644
index 00000000000..ffac8206b58
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
@@ -0,0 +1,121 @@
+<script>
+import { GlIntersectionObserver, GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui';
+import produce from 'immer';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+import eventHub from '~/jobs/components/table/event_hub';
+import JobsTable from '~/jobs/components/table/jobs_table.vue';
+import { JOBS_TAB_FIELDS } from '~/jobs/components/table/constants';
+import getPipelineJobs from '../../graphql/queries/get_pipeline_jobs.query.graphql';
+
+export default {
+ fields: JOBS_TAB_FIELDS,
+ components: {
+ GlIntersectionObserver,
+ GlLoadingIcon,
+ GlSkeletonLoader,
+ JobsTable,
+ },
+ inject: {
+ fullPath: {
+ default: '',
+ },
+ pipelineIid: {
+ default: '',
+ },
+ },
+ apollo: {
+ jobs: {
+ query: getPipelineJobs,
+ variables() {
+ return {
+ ...this.queryVariables,
+ };
+ },
+ update(data) {
+ return data.project?.pipeline?.jobs?.nodes || [];
+ },
+ result({ data }) {
+ this.jobsPageInfo = data.project?.pipeline?.jobs?.pageInfo || {};
+ },
+ error() {
+ createFlash({ message: __('An error occured while fetching the pipelines jobs.') });
+ },
+ },
+ },
+ data() {
+ return {
+ jobs: [],
+ jobsPageInfo: {},
+ firstLoad: true,
+ };
+ },
+ computed: {
+ queryVariables() {
+ return {
+ fullPath: this.fullPath,
+ iid: this.pipelineIid,
+ };
+ },
+ },
+ mounted() {
+ eventHub.$on('jobActionPerformed', this.handleJobAction);
+ },
+ beforeDestroy() {
+ eventHub.$off('jobActionPerformed', this.handleJobAction);
+ },
+ methods: {
+ handleJobAction() {
+ this.firstLoad = true;
+
+ this.$apollo.queries.jobs.refetch();
+ },
+ fetchMoreJobs() {
+ this.firstLoad = false;
+
+ this.$apollo.queries.jobs.fetchMore({
+ variables: {
+ ...this.queryVariables,
+ after: this.jobsPageInfo.endCursor,
+ },
+ updateQuery: (previousResult, { fetchMoreResult }) => {
+ const results = produce(fetchMoreResult, (draftData) => {
+ draftData.project.pipeline.jobs.nodes = [
+ ...previousResult.project.pipeline.jobs.nodes,
+ ...draftData.project.pipeline.jobs.nodes,
+ ];
+ });
+ return results;
+ },
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div v-if="$apollo.loading && firstLoad" class="gl-mt-5">
+ <gl-skeleton-loader :width="1248" :height="73">
+ <circle cx="748.031" cy="37.7193" r="15.0307" />
+ <circle cx="787.241" cy="37.7193" r="15.0307" />
+ <circle cx="827.759" cy="37.7193" r="15.0307" />
+ <circle cx="866.969" cy="37.7193" r="15.0307" />
+ <circle cx="380" cy="37" r="18" />
+ <rect x="432" y="19" width="126.587" height="15" />
+ <rect x="432" y="41" width="247" height="15" />
+ <rect x="158" y="19" width="86.1" height="15" />
+ <rect x="158" y="41" width="168" height="15" />
+ <rect x="22" y="19" width="96" height="36" />
+ <rect x="924" y="30" width="96" height="15" />
+ <rect x="1057" y="20" width="166" height="35" />
+ </gl-skeleton-loader>
+ </div>
+
+ <jobs-table v-else :jobs="jobs" :table-fields="$options.fields" />
+
+ <gl-intersection-observer v-if="jobsPageInfo.hasNextPage" @appear="fetchMoreJobs">
+ <gl-loading-icon v-if="$apollo.loading" size="md" />
+ </gl-intersection-observer>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
index 836333c8bde..793e343a02a 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
@@ -1,5 +1,5 @@
<script>
-import tooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import tooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
export default {
components: {
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
index 78771b6a072..64210576b29 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
@@ -25,7 +25,7 @@ export default {
// The max width and the width make sure the ellipsis to work and the min width
// is for when there is less text than the stage column width (which the width 100% does not fix)
jobWrapperClasses:
- 'gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8 gl-min-w-full gl-max-w-15',
+ 'gl-display-flex gl-flex-direction-column gl-align-items-stretch gl-w-full gl-px-8 gl-min-w-full gl-max-w-15',
props: {
pipelineData: {
required: true,
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue
index 367a18af248..e485b38ce11 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue
@@ -1,6 +1,6 @@
<script>
import { capitalize, escape } from 'lodash';
-import tooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import tooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
export default {
components: {
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue
index 7552ddb61dc..afcb04cd7eb 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue
@@ -15,7 +15,7 @@
import { GlDropdown, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
-import { __ } from '~/locale';
+import { __, sprintf } from '~/locale';
import eventHub from '../../event_hub';
import JobItem from './job_item.vue';
@@ -98,6 +98,9 @@ export default {
// warn the pipelines table to update
this.$emit('pipelineActionRequestComplete');
},
+ stageAriaLabel(title) {
+ return sprintf(__('View Stage: %{title}'), { title });
+ },
},
};
</script>
@@ -106,9 +109,10 @@ export default {
<gl-dropdown
ref="dropdown"
v-gl-tooltip.hover.ds0
+ v-gl-tooltip="stage.title"
data-testid="mini-pipeline-graph-dropdown"
- :title="stage.title"
variant="link"
+ :aria-label="stageAriaLabel(stage.title)"
:lazy="true"
:popper-opts="{ placement: 'bottom' }"
:toggle-class="['mini-pipeline-graph-dropdown-toggle', triggerButtonClass]"
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql
index 887c217da41..2a0b13dd0cc 100644
--- a/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql
@@ -1,19 +1,24 @@
query getDagVisData($projectPath: ID!, $iid: ID!) {
project(fullPath: $projectPath) {
+ id
pipeline(iid: $iid) {
id
stages {
nodes {
+ id
name
groups {
nodes {
+ id
name
size
jobs {
nodes {
+ id
name
needs {
nodes {
+ id
name
}
}
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql
index 8fcae9dbad8..47bc167ca52 100644
--- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql
@@ -1,5 +1,6 @@
query getPipelineHeaderData($fullPath: ID!, $iid: ID!) {
project(fullPath: $fullPath) {
+ id
pipeline(iid: $iid) {
id
iid
@@ -11,6 +12,7 @@ query getPipelineHeaderData($fullPath: ID!, $iid: ID!) {
updatePipeline
}
detailedStatus {
+ id
detailsPath
icon
group
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql
new file mode 100644
index 00000000000..5fe47e09d9c
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql
@@ -0,0 +1,70 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+
+query getPipelineJobs($fullPath: ID!, $iid: ID!, $after: String) {
+ project(fullPath: $fullPath) {
+ id
+ pipeline(iid: $iid) {
+ id
+ jobs(after: $after, first: 20) {
+ pageInfo {
+ ...PageInfo
+ }
+ nodes {
+ artifacts {
+ nodes {
+ downloadPath
+ fileType
+ }
+ }
+ allowFailure
+ status
+ scheduledAt
+ manualJob
+ triggered
+ createdByTag
+ detailedStatus {
+ id
+ detailsPath
+ group
+ icon
+ label
+ text
+ tooltip
+ action {
+ id
+ buttonTitle
+ icon
+ method
+ path
+ title
+ }
+ }
+ id
+ refName
+ refPath
+ tags
+ shortSha
+ commitPath
+ stage {
+ id
+ name
+ }
+ name
+ duration
+ finishedAt
+ coverage
+ retryable
+ playable
+ cancelable
+ active
+ stuck
+ userPermissions {
+ readBuild
+ readJobArtifacts
+ updateBuild
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index ee9560e36c4..ae8b2503c79 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -3,6 +3,7 @@ import { __ } from '~/locale';
import createDagApp from './pipeline_details_dag';
import { createPipelinesDetailApp } from './pipeline_details_graph';
import { createPipelineHeaderApp } from './pipeline_details_header';
+import { createPipelineJobsApp } from './pipeline_details_jobs';
import { apolloProvider } from './pipeline_shared_client';
import { createTestDetails } from './pipeline_test_details';
@@ -11,6 +12,7 @@ const SELECTORS = {
PIPELINE_GRAPH: '#js-pipeline-graph-vue',
PIPELINE_HEADER: '#js-pipeline-header-vue',
PIPELINE_TESTS: '#js-pipeline-tests-detail',
+ PIPELINE_JOBS: '#js-pipeline-jobs-vue',
};
export default async function initPipelineDetailsBundle() {
@@ -55,4 +57,14 @@ export default async function initPipelineDetailsBundle() {
message: __('An error occurred while loading the Test Reports tab.'),
});
}
+
+ try {
+ if (gon.features?.jobsTabVue) {
+ createPipelineJobsApp(SELECTORS.PIPELINE_JOBS);
+ }
+ } catch {
+ createFlash({
+ message: __('An error occurred while loading the Jobs tab.'),
+ });
+ }
}
diff --git a/app/assets/javascripts/pipelines/pipeline_details_jobs.js b/app/assets/javascripts/pipelines/pipeline_details_jobs.js
new file mode 100644
index 00000000000..a1294a484f0
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_details_jobs.js
@@ -0,0 +1,34 @@
+import { GlToast } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import JobsApp from './components/jobs/jobs_app.vue';
+
+Vue.use(VueApollo);
+Vue.use(GlToast);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
+export const createPipelineJobsApp = (selector) => {
+ const containerEl = document.querySelector(selector);
+
+ if (!containerEl) {
+ return false;
+ }
+
+ const { fullPath, pipelineIid } = containerEl.dataset;
+
+ return new Vue({
+ el: containerEl,
+ apolloProvider,
+ provide: {
+ fullPath,
+ pipelineIid,
+ },
+ render(createElement) {
+ return createElement(JobsApp);
+ },
+ });
+};
diff --git a/app/assets/javascripts/projects/commit/constants.js b/app/assets/javascripts/projects/commit/constants.js
index d553bca360e..eb3673461bd 100644
--- a/app/assets/javascripts/projects/commit/constants.js
+++ b/app/assets/javascripts/projects/commit/constants.js
@@ -11,7 +11,7 @@ export const I18N_MODAL = {
'ChangeTypeAction|Your changes will be committed to %{branchName} because a merge request is open.',
),
branchInFork: s__(
- 'ChangeTypeAction|A new branch will be created in your fork and a new merge request will be started.',
+ 'ChangeTypeAction|GitLab will create a branch in your fork and start a merge request.',
),
newMergeRequest: __('new merge request'),
actionCancelText: __('Cancel'),
diff --git a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql b/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql
index ee18c70b6fd..c6a0d48626a 100644
--- a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql
+++ b/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql
@@ -1,15 +1,19 @@
query getLinkedPipelines($fullPath: ID!, $iid: ID!) {
project(fullPath: $fullPath) {
+ id
pipeline(iid: $iid) {
+ id
path
downstream {
nodes {
id
path
project {
+ id
name
}
detailedStatus {
+ id
group
icon
label
@@ -20,9 +24,11 @@ query getLinkedPipelines($fullPath: ID!, $iid: ID!) {
id
path
project {
+ id
name
}
detailedStatus {
+ id
group
icon
label
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 e0ba60074af..f4a21c6057c 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
@@ -8,7 +8,7 @@ import {
GlDropdownSectionHeader,
GlSearchBoxByType,
} from '@gitlab/ui';
-import { joinPaths } from '~/lib/utils/url_utility';
+import { joinPaths, PATH_SEPARATOR } from '~/lib/utils/url_utility';
import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import Tracking from '~/tracking';
@@ -36,7 +36,9 @@ export default {
};
},
skip() {
- return this.search.length > 0 && this.search.length < MINIMUM_SEARCH_LENGTH;
+ const hasNotEnoughSearchCharacters =
+ this.search.length > 0 && this.search.length < MINIMUM_SEARCH_LENGTH;
+ return this.shouldSkipQuery || hasNotEnoughSearchCharacters;
},
debounce: DEBOUNCE_DELAY,
},
@@ -52,7 +54,7 @@ export default {
data() {
return {
currentUser: {},
- groupToFilterBy: undefined,
+ groupPathToFilterBy: undefined,
search: '',
selectedNamespace: this.namespaceId
? {
@@ -63,6 +65,7 @@ export default {
id: this.userNamespaceId,
fullPath: this.userNamespaceFullPath,
},
+ shouldSkipQuery: true,
};
},
computed: {
@@ -73,10 +76,8 @@ export default {
return this.currentUser.namespace || {};
},
filteredGroups() {
- return this.groupToFilterBy
- ? this.userGroups.filter((group) =>
- group.fullPath.startsWith(this.groupToFilterBy.fullPath),
- )
+ return this.groupPathToFilterBy
+ ? this.userGroups.filter((group) => group.fullPath.startsWith(this.groupPathToFilterBy))
: this.userGroups;
},
hasGroupMatches() {
@@ -85,7 +86,7 @@ export default {
hasNamespaceMatches() {
return (
this.userNamespace.fullPath?.toLowerCase().includes(this.search.toLowerCase()) &&
- !this.groupToFilterBy
+ !this.groupPathToFilterBy
);
},
hasNoMatches() {
@@ -99,7 +100,10 @@ export default {
eventHub.$off('select-template', this.handleSelectTemplate);
},
methods: {
- focusInput() {
+ handleDropdownShown() {
+ if (this.shouldSkipQuery) {
+ this.shouldSkipQuery = false;
+ }
this.$refs.search.focusInput();
},
handleDropdownItemClick(namespace) {
@@ -111,13 +115,9 @@ export default {
});
this.setNamespace(namespace);
},
- handleSelectTemplate(groupId) {
- this.groupToFilterBy = this.userGroups.find(
- (group) => getIdFromGraphQLId(group.id) === groupId,
- );
- if (this.groupToFilterBy) {
- this.setNamespace(this.groupToFilterBy);
- }
+ handleSelectTemplate(id, fullPath) {
+ this.groupPathToFilterBy = fullPath.split(PATH_SEPARATOR).shift();
+ this.setNamespace({ id, fullPath });
},
setNamespace({ id, fullPath }) {
this.selectedNamespace = {
@@ -137,7 +137,7 @@ export default {
toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20"
data-qa-selector="select_namespace_dropdown"
@show="track('activate_form_input', { label: trackLabel, property: 'project_path' })"
- @shown="focusInput"
+ @shown="handleDropdownShown"
>
<gl-search-box-by-type
ref="search"
diff --git a/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql b/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql
index 74febec5a51..568e05d1966 100644
--- a/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql
+++ b/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql
@@ -1,5 +1,6 @@
query searchNamespacesWhereUserCanCreateProjects($search: String) {
currentUser {
+ id
groups(permissionScope: CREATE_PROJECTS, search: $search) {
nodes {
id
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
index 7379d5caed7..d4b1f7e57d8 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
@@ -1,5 +1,6 @@
<script>
import { GlTabs, GlTab } from '@gitlab/ui';
+import API from '~/api';
import { mergeUrlParams, updateHistory, getParameterValues } from '~/lib/utils/url_utility';
import PipelineCharts from './pipeline_charts.vue';
@@ -13,6 +14,9 @@ export default {
LeadTimeCharts: () => import('ee_component/dora/components/lead_time_charts.vue'),
ProjectQualitySummary: () => import('ee_component/project_quality_summary/app.vue'),
},
+ piplelinesTabEvent: 'p_analytics_ci_cd_pipelines',
+ deploymentFrequencyTabEvent: 'p_analytics_ci_cd_deployment_frequency',
+ leadTimeTabEvent: 'p_analytics_ci_cd_lead_time',
inject: {
shouldRenderDoraCharts: {
type: Boolean,
@@ -60,20 +64,35 @@ export default {
updateHistory({ url: path, title: window.title });
}
},
+ trackTabClick(tab) {
+ API.trackRedisHllUserEvent(tab);
+ },
},
};
</script>
<template>
<div>
<gl-tabs v-if="charts.length > 1" :value="selectedTab" @input="onTabChange">
- <gl-tab :title="__('Pipelines')">
+ <gl-tab
+ :title="__('Pipelines')"
+ data-testid="pipelines-tab"
+ @click="trackTabClick($options.piplelinesTabEvent)"
+ >
<pipeline-charts />
</gl-tab>
<template v-if="shouldRenderDoraCharts">
- <gl-tab :title="__('Deployment frequency')">
+ <gl-tab
+ :title="__('Deployment frequency')"
+ data-testid="deployment-frequency-tab"
+ @click="trackTabClick($options.deploymentFrequencyTabEvent)"
+ >
<deployment-frequency-charts />
</gl-tab>
- <gl-tab :title="__('Lead time')">
+ <gl-tab
+ :title="__('Lead time')"
+ data-testid="lead-time-tab"
+ @click="trackTabClick($options.leadTimeTabEvent)"
+ >
<lead-time-charts />
</gl-tab>
</template>
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue b/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue
index 7bc3b787f75..5383a6cdddf 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue
@@ -1,10 +1,19 @@
<script>
+import { GlLink } from '@gitlab/ui';
import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format';
import { s__, n__ } from '~/locale';
const defaultPrecision = 2;
export default {
+ components: {
+ GlLink,
+ },
+ inject: {
+ failedPipelinesLink: {
+ default: '',
+ },
+ },
props: {
counts: {
type: Object,
@@ -27,6 +36,7 @@ export default {
{
title: s__('PipelineCharts|Failed:'),
value: n__('1 pipeline', '%d pipelines', this.counts.failed),
+ link: this.failedPipelinesLink,
},
{
title: s__('PipelineCharts|Success ratio:'),
@@ -39,10 +49,13 @@ export default {
</script>
<template>
<ul>
- <template v-for="({ title, value }, index) in statistics">
+ <template v-for="({ title, value, link }, index) in statistics">
<li :key="index">
<span>{{ title }}</span>
- <strong>{{ value }}</strong>
+ <gl-link v-if="link" :href="link">
+ {{ value }}
+ </gl-link>
+ <strong v-else>{{ value }}</strong>
</li>
</template>
</ul>
diff --git a/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql b/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql
index d68df689f5f..ac7fe51384c 100644
--- a/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql
+++ b/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql
@@ -1,5 +1,6 @@
query getPipelineCountByStatus($projectPath: ID!) {
project(fullPath: $projectPath) {
+ id
totalPipelines: pipelines {
count
}
diff --git a/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql b/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql
index 18b645f8831..46e8a6dc87d 100644
--- a/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql
+++ b/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql
@@ -1,5 +1,6 @@
query getProjectPipelineStatistics($projectPath: ID!) {
project(fullPath: $projectPath) {
+ id
pipelineAnalytics {
weekPipelinesTotals
weekPipelinesLabels
diff --git a/app/assets/javascripts/projects/pipelines/charts/index.js b/app/assets/javascripts/projects/pipelines/charts/index.js
index 003b61d94b1..94d32609e5d 100644
--- a/app/assets/javascripts/projects/pipelines/charts/index.js
+++ b/app/assets/javascripts/projects/pipelines/charts/index.js
@@ -11,7 +11,7 @@ const apolloProvider = new VueApollo({
});
const mountPipelineChartsApp = (el) => {
- const { projectPath } = el.dataset;
+ const { projectPath, failedPipelinesLink, coverageChartPath, defaultBranch } = el.dataset;
const shouldRenderDoraCharts = parseBoolean(el.dataset.shouldRenderDoraCharts);
const shouldRenderQualitySummary = parseBoolean(el.dataset.shouldRenderQualitySummary);
@@ -25,8 +25,11 @@ const mountPipelineChartsApp = (el) => {
apolloProvider,
provide: {
projectPath,
+ failedPipelinesLink,
shouldRenderDoraCharts,
shouldRenderQualitySummary,
+ coverageChartPath,
+ defaultBranch,
},
render: (createElement) => createElement(ProjectPipelinesCharts, {}),
});
diff --git a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue
new file mode 100644
index 00000000000..b98e1101884
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue
@@ -0,0 +1,63 @@
+<script>
+import { GlFormGroup } from '@gitlab/ui';
+import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select.vue';
+import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue';
+
+export default {
+ name: 'TransferProjectForm',
+ components: {
+ GlFormGroup,
+ NamespaceSelect,
+ ConfirmDanger,
+ },
+ props: {
+ namespaces: {
+ type: Object,
+ required: true,
+ },
+ confirmationPhrase: {
+ type: String,
+ required: true,
+ },
+ confirmButtonText: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return { selectedNamespace: null };
+ },
+ computed: {
+ hasSelectedNamespace() {
+ return Boolean(this.selectedNamespace?.id);
+ },
+ },
+ methods: {
+ handleSelect(selectedNamespace) {
+ this.selectedNamespace = selectedNamespace;
+ this.$emit('selectNamespace', selectedNamespace.id);
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-form-group>
+ <namespace-select
+ class="qa-namespaces-list"
+ data-testid="transfer-project-namespace"
+ :full-width="true"
+ :data="namespaces"
+ :selected-namespace="selectedNamespace"
+ @select="handleSelect"
+ />
+ </gl-form-group>
+ <confirm-danger
+ button-class="qa-transfer-button"
+ :disabled="!hasSelectedNamespace"
+ :phrase="confirmationPhrase"
+ :button-text="confirmButtonText"
+ @confirm="$emit('confirm')"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/settings/init_transfer_project_form.js b/app/assets/javascripts/projects/settings/init_transfer_project_form.js
new file mode 100644
index 00000000000..47b49031dc9
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/init_transfer_project_form.js
@@ -0,0 +1,53 @@
+import Vue from 'vue';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import TransferProjectForm from './components/transfer_project_form.vue';
+
+const prepareNamespaces = (rawNamespaces = '') => {
+ const data = JSON.parse(rawNamespaces);
+ return {
+ group: data?.group.map(convertObjectPropsToCamelCase),
+ user: data?.user.map(convertObjectPropsToCamelCase),
+ };
+};
+
+export default () => {
+ const el = document.querySelector('.js-transfer-project-form');
+ if (!el) {
+ return false;
+ }
+
+ const {
+ targetFormId = null,
+ targetHiddenInputId = null,
+ buttonText: confirmButtonText = '',
+ phrase: confirmationPhrase = '',
+ confirmDangerMessage = '',
+ namespaces = '',
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ provide: {
+ confirmDangerMessage,
+ },
+ render(createElement) {
+ return createElement(TransferProjectForm, {
+ props: {
+ confirmButtonText,
+ confirmationPhrase,
+ namespaces: prepareNamespaces(namespaces),
+ },
+ on: {
+ selectNamespace: (id) => {
+ if (targetHiddenInputId && document.getElementById(targetHiddenInputId)) {
+ document.getElementById(targetHiddenInputId).value = id;
+ }
+ },
+ confirm: () => {
+ if (targetFormId) document.getElementById(targetFormId)?.submit();
+ },
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
index b8053bf9ab5..e5ddfe82e3b 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
@@ -1,5 +1,15 @@
<script>
-import { GlButton, GlToggle, GlLoadingIcon, GlSprintf, GlFormInput, GlLink } from '@gitlab/ui';
+import {
+ GlButton,
+ GlToggle,
+ GlLoadingIcon,
+ GlSprintf,
+ GlFormInputGroup,
+ GlFormGroup,
+ GlFormInput,
+ GlLink,
+} from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ServiceDeskTemplateDropdown from './service_desk_template_dropdown.vue';
@@ -15,6 +25,8 @@ export default {
GlLoadingIcon,
GlSprintf,
GlFormInput,
+ GlFormGroup,
+ GlFormInputGroup,
GlLink,
ServiceDeskTemplateDropdown,
},
@@ -88,6 +100,16 @@ export default {
hasCustomEmail() {
return this.customEmail && this.customEmail !== this.incomingEmail;
},
+ emailSuffixHelpUrl() {
+ return helpPagePath('user/project/service_desk.html', {
+ anchor: 'configuring-a-custom-email-address-suffix',
+ });
+ },
+ customEmailAddressHelpUrl() {
+ return helpPagePath('user/project/service_desk.html', {
+ anchor: 'using-a-custom-email-address',
+ });
+ },
},
methods: {
onCheckboxToggle(isChecked) {
@@ -132,101 +154,122 @@ export default {
</label>
<div v-if="isEnabled" class="row mt-3">
<div class="col-md-9 mb-0">
- <strong
- id="incoming-email-describer"
- class="gl-display-block gl-mb-1"
- data-testid="incoming-email-describer"
+ <gl-form-group
+ :label="__('Email address to use for Support Desk')"
+ label-for="incoming-email"
+ data-testid="incoming-email-label"
>
- {{ __('Email address to use for Support Desk') }}
- </strong>
- <template v-if="email">
- <div class="input-group">
- <input
+ <gl-form-input-group v-if="email">
+ <gl-form-input
+ id="incoming-email"
ref="service-desk-incoming-email"
type="text"
- class="form-control"
data-testid="incoming-email"
:placeholder="__('Incoming email')"
:aria-label="__('Incoming email')"
aria-describedby="incoming-email-describer"
:value="email"
- disabled="true"
+ :disabled="true"
/>
- <div class="input-group-append">
+ <template #append>
<clipboard-button :title="__('Copy')" :text="email" css-class="input-group-text" />
- </div>
- </div>
- <span v-if="hasCustomEmail" class="form-text text-muted">
- <gl-sprintf :message="__('Emails sent to %{email} are also supported.')">
- <template #email>
- <code>{{ incomingEmail }}</code>
+ </template>
+ </gl-form-input-group>
+ <template v-if="email && hasCustomEmail" #description>
+ <span class="gl-mt-2 d-inline-block">
+ <gl-sprintf :message="__('Emails sent to %{email} are also supported.')">
+ <template #email>
+ <code>{{ incomingEmail }}</code>
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+ <template v-if="!email">
+ <gl-loading-icon size="sm" :inline="true" />
+ <span class="sr-only">{{ __('Fetching incoming email') }}</span>
+ </template>
+ </gl-form-group>
+
+ <gl-form-group :label="__('Email address suffix')" :state="!projectKeyError">
+ <gl-form-input
+ v-if="hasProjectKeySupport"
+ id="service-desk-project-suffix"
+ v-model.trim="projectKey"
+ data-testid="project-suffix"
+ @blur="validateProjectKey"
+ />
+
+ <template v-if="hasProjectKeySupport" #description>
+ <gl-sprintf
+ :message="
+ __('Add a suffix to Service Desk email address. %{linkStart}Learn more.%{linkEnd}')
+ "
+ >
+ <template #link="{ content }">
+ <gl-link
+ :href="emailSuffixHelpUrl"
+ target="_blank"
+ class="gl-text-blue-600 font-size-inherit"
+ >{{ content }}
+ </gl-link>
</template>
</gl-sprintf>
- </span>
- </template>
- <template v-else>
- <gl-loading-icon size="sm" :inline="true" />
- <span class="sr-only">{{ __('Fetching incoming email') }}</span>
- </template>
+ </template>
+ <template v-else #description>
+ <gl-sprintf
+ :message="
+ __(
+ 'To add a custom suffix, set up a Service Desk email address. %{linkStart}Learn more.%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link
+ :href="customEmailAddressHelpUrl"
+ target="_blank"
+ class="gl-text-blue-600 font-size-inherit"
+ >{{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+
+ <template v-if="hasProjectKeySupport && projectKeyError" #invalid-feedback>
+ {{ projectKeyError }}
+ </template>
+ </gl-form-group>
- <label for="service-desk-project-suffix" class="mt-3">
- {{ __('Project name suffix') }}
- </label>
- <gl-form-input
- v-if="hasProjectKeySupport"
- id="service-desk-project-suffix"
- v-model.trim="projectKey"
- data-testid="project-suffix"
- class="form-control"
+ <gl-form-group
+ :label="__('Template to append to all Service Desk issues')"
:state="!projectKeyError"
- @blur="validateProjectKey"
- />
- <span v-if="hasProjectKeySupport && projectKeyError" class="form-text text-danger">
- {{ projectKeyError }}
- </span>
- <span
- v-if="hasProjectKeySupport"
- class="form-text text-muted"
- :class="{ 'gl-mt-2!': hasProjectKeySupport && projectKeyError }"
+ class="mt-3"
>
- {{ __('A string appended to the project path to form the Service Desk email address.') }}
- </span>
- <span v-else class="form-text text-muted">
- <gl-sprintf
- :message="
- __(
- 'To add a custom suffix, set up a Service Desk email address. %{linkStart}Learn more.%{linkEnd}',
- )
- "
- >
- <template #link="{ content }">
- <gl-link
- href="https://docs.gitlab.com/ee/user/project/service_desk.html#using-a-custom-email-address"
- target="_blank"
- class="gl-text-blue-600 font-size-inherit"
- >{{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </span>
+ <service-desk-template-dropdown
+ :selected-template="selectedTemplate"
+ :selected-file-template-project-id="selectedFileTemplateProjectId"
+ :templates="templates"
+ @change="templateChange"
+ />
+ </gl-form-group>
+
+ <gl-form-group
+ :label="__('Email display name')"
+ label-for="service-desk-email-from-name"
+ :state="!projectKeyError"
+ class="mt-3"
+ >
+ <gl-form-input
+ v-if="hasProjectKeySupport"
+ id="service-desk-email-from-name"
+ v-model.trim="outgoingName"
+ data-testid="email-from-name"
+ />
- <label for="service-desk-template-select" class="mt-3">
- {{ __('Template to append to all Service Desk issues') }}
- </label>
- <service-desk-template-dropdown
- :selected-template="selectedTemplate"
- :selected-file-template-project-id="selectedFileTemplateProjectId"
- :templates="templates"
- @change="templateChange"
- />
+ <template v-if="hasProjectKeySupport" #description>
+ {{ __('Emails sent from Service Desk have this name.') }}
+ </template>
+ </gl-form-group>
- <label for="service-desk-email-from-name" class="mt-3">
- {{ __('Email display name') }}
- </label>
- <input id="service-desk-email-from-name" v-model.trim="outgoingName" class="form-control" />
- <span class="form-text text-muted">
- {{ __('Emails sent from Service Desk have this name.') }}
- </span>
<div class="gl-display-flex gl-justify-content-end">
<gl-button
variant="success"
diff --git a/app/assets/javascripts/projects/storage_counter/components/app.vue b/app/assets/javascripts/projects/storage_counter/components/app.vue
deleted file mode 100644
index 1a911ea3d9b..00000000000
--- a/app/assets/javascripts/projects/storage_counter/components/app.vue
+++ /dev/null
@@ -1,106 +0,0 @@
-<script>
-import { GlAlert, GlLink, GlLoadingIcon } from '@gitlab/ui';
-import { sprintf } from '~/locale';
-import UsageGraph from '~/vue_shared/components/storage_counter/usage_graph.vue';
-import {
- ERROR_MESSAGE,
- LEARN_MORE_LABEL,
- USAGE_QUOTAS_LABEL,
- TOTAL_USAGE_TITLE,
- TOTAL_USAGE_SUBTITLE,
- TOTAL_USAGE_DEFAULT_TEXT,
- HELP_LINK_ARIA_LABEL,
-} from '../constants';
-import getProjectStorageCount from '../queries/project_storage.query.graphql';
-import { parseGetProjectStorageResults } from '../utils';
-import StorageTable from './storage_table.vue';
-
-export default {
- name: 'StorageCounterApp',
- components: {
- GlAlert,
- GlLink,
- GlLoadingIcon,
- StorageTable,
- UsageGraph,
- },
- inject: ['projectPath', 'helpLinks'],
- apollo: {
- project: {
- query: getProjectStorageCount,
- variables() {
- return {
- fullPath: this.projectPath,
- };
- },
- update(data) {
- return parseGetProjectStorageResults(data, this.helpLinks);
- },
- error() {
- this.error = ERROR_MESSAGE;
- },
- },
- },
- data() {
- return {
- project: {},
- error: '',
- };
- },
- computed: {
- totalUsage() {
- return this.project?.storage?.totalUsage || TOTAL_USAGE_DEFAULT_TEXT;
- },
- storageTypes() {
- return this.project?.storage?.storageTypes || [];
- },
- },
- methods: {
- clearError() {
- this.error = '';
- },
- helpLinkAriaLabel(linkTitle) {
- return sprintf(HELP_LINK_ARIA_LABEL, {
- linkTitle,
- });
- },
- },
- LEARN_MORE_LABEL,
- USAGE_QUOTAS_LABEL,
- TOTAL_USAGE_TITLE,
- TOTAL_USAGE_SUBTITLE,
-};
-</script>
-<template>
- <gl-loading-icon v-if="$apollo.queries.project.loading" class="gl-mt-5" size="md" />
- <gl-alert v-else-if="error" variant="danger" @dismiss="clearError">
- {{ error }}
- </gl-alert>
- <div v-else>
- <div class="gl-pt-5 gl-px-3">
- <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center">
- <div>
- <p class="gl-m-0 gl-font-lg gl-font-weight-bold">{{ $options.TOTAL_USAGE_TITLE }}</p>
- <p class="gl-m-0 gl-text-gray-400">
- {{ $options.TOTAL_USAGE_SUBTITLE }}
- <gl-link
- :href="helpLinks.usageQuotasHelpPagePath"
- target="_blank"
- :aria-label="helpLinkAriaLabel($options.USAGE_QUOTAS_LABEL)"
- data-testid="usage-quotas-help-link"
- >
- {{ $options.LEARN_MORE_LABEL }}
- </gl-link>
- </p>
- </div>
- <p class="gl-m-0 gl-font-size-h-display gl-font-weight-bold" data-testid="total-usage">
- {{ totalUsage }}
- </p>
- </div>
- </div>
- <div v-if="project.statistics" class="gl-w-full">
- <usage-graph :root-storage-statistics="project.statistics" :limit="0" />
- </div>
- <storage-table :storage-types="storageTypes" />
- </div>
-</template>
diff --git a/app/assets/javascripts/projects/storage_counter/components/storage_table.vue b/app/assets/javascripts/projects/storage_counter/components/storage_table.vue
deleted file mode 100644
index a42a9711572..00000000000
--- a/app/assets/javascripts/projects/storage_counter/components/storage_table.vue
+++ /dev/null
@@ -1,88 +0,0 @@
-<script>
-import { GlLink, GlIcon, GlTableLite as GlTable, GlSprintf } from '@gitlab/ui';
-import { numberToHumanSize } from '~/lib/utils/number_utils';
-import { thWidthClass } from '~/lib/utils/table_utility';
-import { sprintf } from '~/locale';
-import { PROJECT_TABLE_LABELS, HELP_LINK_ARIA_LABEL } from '../constants';
-import StorageTypeIcon from './storage_type_icon.vue';
-
-export default {
- name: 'StorageTable',
- components: {
- GlLink,
- GlIcon,
- GlTable,
- GlSprintf,
- StorageTypeIcon,
- },
- props: {
- storageTypes: {
- type: Array,
- required: true,
- },
- },
- methods: {
- helpLinkAriaLabel(linkTitle) {
- return sprintf(HELP_LINK_ARIA_LABEL, {
- linkTitle,
- });
- },
- },
- projectTableFields: [
- {
- key: 'storageType',
- label: PROJECT_TABLE_LABELS.STORAGE_TYPE,
- thClass: thWidthClass(90),
- sortable: true,
- },
- {
- key: 'value',
- label: PROJECT_TABLE_LABELS.VALUE,
- thClass: thWidthClass(10),
- sortable: true,
- formatter: (value) => {
- return numberToHumanSize(value, 1);
- },
- },
- ],
-};
-</script>
-<template>
- <gl-table :items="storageTypes" :fields="$options.projectTableFields">
- <template #cell(storageType)="{ item }">
- <div class="gl-display-flex gl-flex-direction-row">
- <storage-type-icon
- :name="item.storageType.id"
- :data-testid="`${item.storageType.id}-icon`"
- />
- <div>
- <p class="gl-font-weight-bold gl-mb-0" :data-testid="`${item.storageType.id}-name`">
- {{ item.storageType.name }}
- <gl-link
- v-if="item.storageType.helpPath"
- :href="item.storageType.helpPath"
- target="_blank"
- :aria-label="helpLinkAriaLabel(item.storageType.name)"
- :data-testid="`${item.storageType.id}-help-link`"
- >
- <gl-icon name="question" :size="12" />
- </gl-link>
- </p>
- <p class="gl-mb-0" :data-testid="`${item.storageType.id}-description`">
- {{ item.storageType.description }}
- </p>
- <p v-if="item.storageType.warningMessage" class="gl-mb-0 gl-font-sm">
- <gl-icon name="warning" :size="12" />
- <gl-sprintf :message="item.storageType.warningMessage">
- <template #warningLink="{ content }">
- <gl-link :href="item.storageType.warningLink" target="_blank" class="gl-font-sm">{{
- content
- }}</gl-link>
- </template>
- </gl-sprintf>
- </p>
- </div>
- </div>
- </template>
- </gl-table>
-</template>
diff --git a/app/assets/javascripts/projects/storage_counter/components/storage_type_icon.vue b/app/assets/javascripts/projects/storage_counter/components/storage_type_icon.vue
deleted file mode 100644
index bc7cd42df1e..00000000000
--- a/app/assets/javascripts/projects/storage_counter/components/storage_type_icon.vue
+++ /dev/null
@@ -1,35 +0,0 @@
-<script>
-import { GlIcon } from '@gitlab/ui';
-
-export default {
- components: { GlIcon },
- props: {
- name: {
- type: String,
- required: false,
- default: '',
- },
- },
- methods: {
- iconName(storageTypeName) {
- const defaultStorageTypeIcon = 'disk';
- const storageTypeIconMap = {
- lfsObjectsSize: 'doc-image',
- snippetsSize: 'snippet',
- uploadsSize: 'upload',
- repositorySize: 'infrastructure-registry',
- packagesSize: 'package',
- };
-
- return storageTypeIconMap[`${storageTypeName}`] ?? defaultStorageTypeIcon;
- },
- },
-};
-</script>
-<template>
- <span
- class="gl-display-inline-flex gl-align-items-flex-start gl-justify-content-center gl-min-w-8 gl-pr-2 gl-pt-1"
- >
- <gl-icon :name="iconName(name)" :size="16" class="gl-mt-1" />
- </span>
-</template>
diff --git a/app/assets/javascripts/projects/storage_counter/constants.js b/app/assets/javascripts/projects/storage_counter/constants.js
deleted file mode 100644
index df4b1800dff..00000000000
--- a/app/assets/javascripts/projects/storage_counter/constants.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import { s__, __ } from '~/locale';
-
-export const PROJECT_STORAGE_TYPES = [
- {
- id: 'buildArtifactsSize',
- name: s__('UsageQuota|Artifacts'),
- description: s__('UsageQuota|Pipeline artifacts and job artifacts, created with CI/CD.'),
- warningMessage: s__(
- 'UsageQuota|Because of a known issue, the artifact total for some projects may be incorrect. For more details, read %{warningLinkStart}the epic%{warningLinkEnd}.',
- ),
- warningLink: 'https://gitlab.com/groups/gitlab-org/-/epics/5380',
- },
- {
- id: 'lfsObjectsSize',
- name: s__('UsageQuota|LFS storage'),
- description: s__('UsageQuota|Audio samples, videos, datasets, and graphics.'),
- },
- {
- id: 'packagesSize',
- name: s__('UsageQuota|Packages'),
- description: s__('UsageQuota|Code packages and container images.'),
- },
- {
- id: 'repositorySize',
- name: s__('UsageQuota|Repository'),
- description: s__('UsageQuota|Git repository.'),
- },
- {
- id: 'snippetsSize',
- name: s__('UsageQuota|Snippets'),
- description: s__('UsageQuota|Shared bits of code and text.'),
- },
- {
- id: 'uploadsSize',
- name: s__('UsageQuota|Uploads'),
- description: s__('UsageQuota|File attachments and smaller design graphics.'),
- },
- {
- id: 'wikiSize',
- name: s__('UsageQuota|Wiki'),
- description: s__('UsageQuota|Wiki content.'),
- },
-];
-
-export const PROJECT_TABLE_LABELS = {
- STORAGE_TYPE: s__('UsageQuota|Storage type'),
- VALUE: s__('UsageQuota|Usage'),
-};
-
-export const ERROR_MESSAGE = s__(
- 'UsageQuota|Something went wrong while fetching project storage statistics',
-);
-
-export const LEARN_MORE_LABEL = __('Learn more.');
-export const USAGE_QUOTAS_LABEL = s__('UsageQuota|Usage Quotas');
-export const HELP_LINK_ARIA_LABEL = s__('UsageQuota|%{linkTitle} help link');
-export const TOTAL_USAGE_DEFAULT_TEXT = __('N/A');
-export const TOTAL_USAGE_TITLE = s__('UsageQuota|Usage breakdown');
-export const TOTAL_USAGE_SUBTITLE = s__(
- 'UsageQuota|Includes artifacts, repositories, wiki, uploads, and other items.',
-);
diff --git a/app/assets/javascripts/projects/storage_counter/index.js b/app/assets/javascripts/projects/storage_counter/index.js
deleted file mode 100644
index 15796bc1870..00000000000
--- a/app/assets/javascripts/projects/storage_counter/index.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import createDefaultClient from '~/lib/graphql';
-import StorageCounterApp from './components/app.vue';
-
-Vue.use(VueApollo);
-
-export default (containerId = 'js-project-storage-count-app') => {
- const el = document.getElementById(containerId);
-
- if (!el) {
- return false;
- }
-
- const {
- projectPath,
- usageQuotasHelpPagePath,
- buildArtifactsHelpPagePath,
- lfsObjectsHelpPagePath,
- packagesHelpPagePath,
- repositoryHelpPagePath,
- snippetsHelpPagePath,
- uploadsHelpPagePath,
- wikiHelpPagePath,
- } = el.dataset;
-
- const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
- });
-
- return new Vue({
- el,
- apolloProvider,
- provide: {
- projectPath,
- helpLinks: {
- usageQuotasHelpPagePath,
- buildArtifactsHelpPagePath,
- lfsObjectsHelpPagePath,
- packagesHelpPagePath,
- repositoryHelpPagePath,
- snippetsHelpPagePath,
- uploadsHelpPagePath,
- wikiHelpPagePath,
- },
- },
- render(createElement) {
- return createElement(StorageCounterApp);
- },
- });
-};
diff --git a/app/assets/javascripts/projects/storage_counter/queries/project_storage.query.graphql b/app/assets/javascripts/projects/storage_counter/queries/project_storage.query.graphql
deleted file mode 100644
index a4f2c529522..00000000000
--- a/app/assets/javascripts/projects/storage_counter/queries/project_storage.query.graphql
+++ /dev/null
@@ -1,16 +0,0 @@
-query getProjectStorageCount($fullPath: ID!) {
- project(fullPath: $fullPath) {
- id
- statistics {
- buildArtifactsSize
- pipelineArtifactsSize
- lfsObjectsSize
- packagesSize
- repositorySize
- snippetsSize
- storageSize
- uploadsSize
- wikiSize
- }
- }
-}
diff --git a/app/assets/javascripts/projects/storage_counter/utils.js b/app/assets/javascripts/projects/storage_counter/utils.js
deleted file mode 100644
index 9fca9d88f46..00000000000
--- a/app/assets/javascripts/projects/storage_counter/utils.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import { numberToHumanSize } from '~/lib/utils/number_utils';
-import { PROJECT_STORAGE_TYPES } from './constants';
-
-/**
- * This method parses the results from `getProjectStorageCount` call.
- *
- * @param {Object} data graphql result
- * @returns {Object}
- */
-export const parseGetProjectStorageResults = (data, helpLinks) => {
- const projectStatistics = data?.project?.statistics;
- if (!projectStatistics) {
- return {};
- }
- const { storageSize, ...storageStatistics } = projectStatistics;
- const storageTypes = PROJECT_STORAGE_TYPES.reduce((types, currentType) => {
- const helpPathKey = currentType.id.replace(`Size`, `HelpPagePath`);
- const helpPath = helpLinks[helpPathKey];
-
- return types.concat({
- storageType: {
- ...currentType,
- helpPath,
- },
- value: storageStatistics[currentType.id],
- });
- }, []);
-
- return {
- storage: {
- totalUsage: numberToHumanSize(storageSize, 1),
- storageTypes,
- },
- statistics: projectStatistics,
- };
-};
diff --git a/app/assets/javascripts/related_issues/components/issue_token.vue b/app/assets/javascripts/related_issues/components/issue_token.vue
index abbd612d3ec..61bd2bf5e8e 100644
--- a/app/assets/javascripts/related_issues/components/issue_token.vue
+++ b/app/assets/javascripts/related_issues/components/issue_token.vue
@@ -1,7 +1,7 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
-import relatedIssuableMixin from '~/vue_shared/mixins/related_issuable_mixin';
+import relatedIssuableMixin from '~/issuable/mixins/related_issuable_mixin';
export default {
name: 'IssueToken',
diff --git a/app/assets/javascripts/related_issues/components/related_issues_list.vue b/app/assets/javascripts/related_issues/components/related_issues_list.vue
index a21e294a34a..58138655241 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_list.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue
@@ -2,7 +2,7 @@
import { GlLoadingIcon } from '@gitlab/ui';
import Sortable from 'sortablejs';
import sortableConfig from '~/sortable/sortable_config';
-import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue';
+import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue';
export default {
name: 'RelatedIssuesList',
diff --git a/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql
index 3a927dfc756..8a5613c75d2 100644
--- a/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql
+++ b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql
@@ -35,6 +35,7 @@ fragment Release on Release {
__typename
nodes {
__typename
+ id
filepath
collectedAt
sha
@@ -52,12 +53,14 @@ fragment Release on Release {
}
commit {
__typename
+ id
sha
webUrl
title
}
author {
__typename
+ id
webUrl
avatarUrl
username
diff --git a/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql
index 75a73acb9ae..1823a327350 100644
--- a/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql
+++ b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql
@@ -18,6 +18,7 @@ fragment ReleaseForEditing on Release {
}
milestones {
nodes {
+ id
title
}
}
diff --git a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql
index c69481150e0..7f67f7d11a3 100644
--- a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql
+++ b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql
@@ -16,6 +16,7 @@ query allReleasesDeprecated(
) {
project(fullPath: $fullPath) {
__typename
+ id
releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) {
__typename
nodes {
diff --git a/app/assets/javascripts/releases/graphql/queries/one_release.query.graphql b/app/assets/javascripts/releases/graphql/queries/one_release.query.graphql
index c80d6e753ab..dab92d5d41c 100644
--- a/app/assets/javascripts/releases/graphql/queries/one_release.query.graphql
+++ b/app/assets/javascripts/releases/graphql/queries/one_release.query.graphql
@@ -2,6 +2,7 @@
query oneRelease($fullPath: ID!, $tagName: String!) {
project(fullPath: $fullPath) {
+ id
release(tagName: $tagName) {
...Release
}
diff --git a/app/assets/javascripts/releases/graphql/queries/one_release_for_editing.query.graphql b/app/assets/javascripts/releases/graphql/queries/one_release_for_editing.query.graphql
index 767ba4aeca0..962d554303a 100644
--- a/app/assets/javascripts/releases/graphql/queries/one_release_for_editing.query.graphql
+++ b/app/assets/javascripts/releases/graphql/queries/one_release_for_editing.query.graphql
@@ -2,6 +2,7 @@
query oneReleaseForEditing($fullPath: ID!, $tagName: String!) {
project(fullPath: $fullPath) {
+ id
release(tagName: $tagName) {
...ReleaseForEditing
}
diff --git a/app/assets/javascripts/repository/commits_service.js b/app/assets/javascripts/repository/commits_service.js
index 504efaea8cc..5fd9cfd4e53 100644
--- a/app/assets/javascripts/repository/commits_service.js
+++ b/app/assets/javascripts/repository/commits_service.js
@@ -52,14 +52,9 @@ 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);
- addRequestedOffset(offset + i);
- });
+ Array.from(Array(COMMIT_BATCH_SIZE)).forEach((_, i) => addRequestedOffset(offset + i));
- // Since a user could scroll either up or down, we want to support lazy loading in both directions
- const commitsBatchUp = await fetchData(projectPath, path, ref, offset - COMMIT_BATCH_SIZE);
- const commitsBatchDown = await fetchData(projectPath, path, ref, offset);
+ const commits = await fetchData(projectPath, path, ref, offset);
- return commitsBatchUp.concat(commitsBatchDown);
+ 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 4e7ca7b17e4..6f540bf8ece 100644
--- a/app/assets/javascripts/repository/components/blob_button_group.vue
+++ b/app/assets/javascripts/repository/components/blob_button_group.vue
@@ -53,6 +53,10 @@ export default {
type: Boolean,
required: true,
},
+ canPushToBranch: {
+ type: Boolean,
+ required: true,
+ },
emptyRepo: {
type: Boolean,
required: true,
@@ -83,6 +87,9 @@ export default {
deleteModalTitle() {
return sprintf(__('Delete %{name}'), { name: this.name });
},
+ lockBtnQASelector() {
+ return this.canLock ? 'lock_button' : 'disabled_lock_button';
+ },
},
};
</script>
@@ -98,6 +105,7 @@ export default {
:is-locked="isLocked"
:can-lock="canLock"
data-testid="lock"
+ :data-qa-selector="lockBtnQASelector"
/>
<gl-button v-gl-modal="replaceModalId" data-testid="replace">
{{ $options.i18n.replace }}
@@ -125,6 +133,7 @@ export default {
:target-branch="targetBranch || ref"
:original-branch="originalBranch || ref"
:can-push-code="canPushCode"
+ :can-push-to-branch="canPushToBranch"
:empty-repo="emptyRepo"
/>
</div>
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index 2cc5a8a79d2..f3fa4526999 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -106,6 +106,7 @@ export default {
ideForkAndEditPath: '',
storedExternally: false,
canModifyBlob: false,
+ canCurrentUserPushToBranch: false,
rawPath: '',
externalStorageUrl: '',
replacePath: '',
@@ -156,11 +157,18 @@ export default {
},
canLock() {
const { pushCode, downloadCode } = this.project.userPermissions;
+ const currentUsername = window.gon?.current_username;
+
+ if (this.pathLockedByUser && this.pathLockedByUser.username !== currentUsername) {
+ return false;
+ }
return pushCode && downloadCode;
},
- isLocked() {
- return this.project.pathLocks.nodes.some((node) => node.path === this.path);
+ pathLockedByUser() {
+ const pathLock = this.project.pathLocks.nodes.find((node) => node.path === this.path);
+
+ return pathLock ? pathLock.user : null;
},
showForkSuggestion() {
const { createMergeRequestIn, forkProject } = this.project.userPermissions;
@@ -266,9 +274,10 @@ export default {
:replace-path="blobInfo.replacePath"
:delete-path="blobInfo.webPath"
:can-push-code="project.userPermissions.pushCode"
+ :can-push-to-branch="blobInfo.canCurrentUserPushToBranch"
:empty-repo="project.repository.empty"
:project-path="projectPath"
- :is-locked="isLocked"
+ :is-locked="Boolean(pathLockedByUser)"
:can-lock="canLock"
/>
</template>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js
index c5209d97abb..8f6f2d15215 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/index.js
+++ b/app/assets/javascripts/repository/components/blob_viewers/index.js
@@ -3,8 +3,11 @@ export const loadViewer = (type) => {
case 'empty':
return () => import(/* webpackChunkName: 'blob_empty_viewer' */ './empty_viewer.vue');
case 'text':
- return gon.features.refactorTextViewer
- ? () => import(/* webpackChunkName: 'blob_text_viewer' */ './text_viewer.vue')
+ return gon.features.highlightJs
+ ? () =>
+ import(
+ /* webpackChunkName: 'blob_text_viewer' */ '~/vue_shared/components/source_viewer.vue'
+ )
: null;
case 'download':
return () => import(/* webpackChunkName: 'blob_download_viewer' */ './download_viewer.vue');
@@ -12,6 +15,8 @@ export const loadViewer = (type) => {
return () => import(/* webpackChunkName: 'blob_image_viewer' */ './image_viewer.vue');
case 'video':
return () => import(/* webpackChunkName: 'blob_video_viewer' */ './video_viewer.vue');
+ case 'pdf':
+ return () => import(/* webpackChunkName: 'blob_pdf_viewer' */ './pdf_viewer.vue');
default:
return null;
}
@@ -21,8 +26,7 @@ export const viewerProps = (type, blob) => {
return {
text: {
content: blob.rawTextBlob,
- fileName: blob.name,
- readOnly: true,
+ autoDetect: true, // We'll eventually disable autoDetect and pass the language explicitly to reduce the footprint (https://gitlab.com/gitlab-org/gitlab/-/issues/348145)
},
download: {
fileName: blob.name,
@@ -36,5 +40,9 @@ export const viewerProps = (type, blob) => {
video: {
url: blob.rawPath,
},
+ pdf: {
+ url: blob.rawPath,
+ fileSize: blob.rawSize,
+ },
}[type];
};
diff --git a/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue
new file mode 100644
index 00000000000..803a357df52
--- /dev/null
+++ b/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue
@@ -0,0 +1,50 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import PdfViewer from '~/blob/pdf/pdf_viewer.vue';
+import { __ } from '~/locale';
+import { PDF_MAX_FILE_SIZE, PDF_MAX_PAGE_LIMIT } from '../../constants';
+
+export default {
+ components: { GlButton, PdfViewer },
+ i18n: {
+ tooLargeDescription: __('This PDF is too large to display. Please download to view.'),
+ tooLargeButtonText: __('Download PDF'),
+ },
+ props: {
+ url: {
+ type: String,
+ required: true,
+ },
+ fileSize: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return { totalPages: 0 };
+ },
+ computed: {
+ tooLargeToDisplay() {
+ return this.fileSize > PDF_MAX_FILE_SIZE || this.totalPages > PDF_MAX_PAGE_LIMIT;
+ },
+ },
+ methods: {
+ handleOnLoad(totalPages) {
+ this.totalPages = totalPages;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <pdf-viewer v-if="!tooLargeToDisplay" :pdf="url" @pdflabload="handleOnLoad" />
+
+ <div v-else class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-p-5">
+ <p>{{ $options.i18n.tooLargeDescription }}</p>
+
+ <gl-button icon="download" category="secondary" variant="confirm" :href="url" download>{{
+ $options.i18n.tooLargeButtonText
+ }}</gl-button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/text_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/text_viewer.vue
deleted file mode 100644
index 57fc979a56e..00000000000
--- a/app/assets/javascripts/repository/components/blob_viewers/text_viewer.vue
+++ /dev/null
@@ -1,25 +0,0 @@
-<script>
-export default {
- components: {
- SourceEditor: () =>
- import(/* webpackChunkName: 'SourceEditor' */ '~/vue_shared/components/source_editor.vue'),
- },
- props: {
- content: {
- type: String,
- required: true,
- },
- fileName: {
- type: String,
- required: true,
- },
- readOnly: {
- type: Boolean,
- required: true,
- },
- },
-};
-</script>
-<template>
- <source-editor :value="content" :file-name="fileName" :editor-options="{ readOnly }" />
-</template>
diff --git a/app/assets/javascripts/repository/components/delete_blob_modal.vue b/app/assets/javascripts/repository/components/delete_blob_modal.vue
index 4a8cedb60b4..0d3dc06c2c8 100644
--- a/app/assets/javascripts/repository/components/delete_blob_modal.vue
+++ b/app/assets/javascripts/repository/components/delete_blob_modal.vue
@@ -71,6 +71,10 @@ export default {
type: Boolean,
required: true,
},
+ canPushToBranch: {
+ type: Boolean,
+ required: true,
+ },
emptyRepo: {
type: Boolean,
required: true,
@@ -176,9 +180,12 @@ export default {
</template>
<template v-else>
<input type="hidden" name="original_branch" :value="originalBranch" />
- <!-- Once "push to branch" permission is made available, will need to add to conditional
- Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/335462 -->
- <input v-if="createNewMr" type="hidden" name="create_merge_request" value="1" />
+ <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"
@@ -188,6 +195,7 @@ export default {
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
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index 62066973ee6..43e114a91d3 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -111,7 +111,7 @@ export default {
</script>
<template>
- <div class="info-well d-none d-sm-flex project-last-commit commit p-3">
+ <div class="well-segment commit gl-p-5 gl-w-full">
<gl-loading-icon v-if="isLoading" size="md" color="dark" class="m-auto" />
<template v-else-if="commit">
<user-avatar-link
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index bd06c064ab7..8fcec5fb893 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -13,7 +13,7 @@ import {
import { escapeRegExp } from 'lodash';
import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql';
import { escapeFileUrl } from '~/lib/utils/url_utility';
-import { TREE_PAGE_SIZE } from '~/repository/constants';
+import { 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';
@@ -128,6 +128,7 @@ export default {
return {
commit: null,
hasRowAppeared: false,
+ delayedRowAppear: null,
};
},
computed: {
@@ -202,14 +203,19 @@ export default {
rowAppeared() {
this.hasRowAppeared = true;
+ if (this.commitInfo) {
+ return;
+ }
+
if (this.glFeatures.lazyLoadCommits) {
- this.$emit('row-appear', {
- rowNumber: this.rowNumber,
- hasCommit: Boolean(this.commitInfo),
- });
+ this.delayedRowAppear = setTimeout(
+ () => this.$emit('row-appear', this.rowNumber),
+ ROW_APPEAR_DELAY,
+ );
}
},
rowDisappeared() {
+ clearTimeout(this.delayedRowAppear);
this.hasRowAppeared = false;
},
},
diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue
index ffe8d5531f8..130ebf77361 100644
--- a/app/assets/javascripts/repository/components/tree_content.vue
+++ b/app/assets/javascripts/repository/components/tree_content.vue
@@ -3,7 +3,12 @@ import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.g
import createFlash from '~/flash';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __ } from '../../locale';
-import { TREE_PAGE_SIZE, TREE_INITIAL_FETCH_COUNT, TREE_PAGE_LIMIT } from '../constants';
+import {
+ TREE_PAGE_SIZE,
+ TREE_INITIAL_FETCH_COUNT,
+ TREE_PAGE_LIMIT,
+ COMMIT_BATCH_SIZE,
+} from '../constants';
import getRefMixin from '../mixins/get_ref';
import projectPathQuery from '../queries/project_path.query.graphql';
import { readmeFile } from '../utils/readme';
@@ -151,11 +156,19 @@ export default {
.concat(data.trees.pageInfo, data.submodules.pageInfo, data.blobs.pageInfo)
.find(({ hasNextPage }) => hasNextPage);
},
- loadCommitData({ rowNumber = 0, hasCommit } = {}) {
- if (!this.glFeatures.lazyLoadCommits || hasCommit || isRequested(rowNumber)) {
+ handleRowAppear(rowNumber) {
+ if (!this.glFeatures.lazyLoadCommits || isRequested(rowNumber)) {
return;
}
+ // Since a user could scroll either up or down, we want to support lazy loading in both directions
+ this.loadCommitData(rowNumber);
+
+ if (rowNumber - COMMIT_BATCH_SIZE >= 0) {
+ this.loadCommitData(rowNumber - COMMIT_BATCH_SIZE);
+ }
+ },
+ loadCommitData(rowNumber) {
loadCommits(this.projectPath, this.path, this.ref, rowNumber)
.then(this.setCommitData)
.catch(() => {});
@@ -182,7 +195,7 @@ export default {
:has-more="hasShowMore"
:commits="commits"
@showMore="handleShowMore"
- @row-appear="loadCommitData"
+ @row-appear="handleRowAppear"
/>
<file-preview v-if="readme" :blob="readme" />
</div>
diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue
index 11e5b5608cb..b56c9ce5247 100644
--- a/app/assets/javascripts/repository/components/upload_blob_modal.vue
+++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue
@@ -24,10 +24,10 @@ import {
} from '../constants';
const PRIMARY_OPTIONS_TEXT = __('Upload file');
-const MODAL_TITLE = __('Upload New File');
+const MODAL_TITLE = __('Upload new file');
const REMOVE_FILE_TEXT = __('Remove file');
const NEW_BRANCH_IN_FORK = __(
- 'A new branch will be created in your fork and a new merge request will be started.',
+ 'GitLab will create a branch in your fork and start a merge request.',
);
const ERROR_MESSAGE = __('Error uploading file. Please try again.');
diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js
index 152fabbd7cc..d01757d6141 100644
--- a/app/assets/javascripts/repository/constants.js
+++ b/app/assets/javascripts/repository/constants.js
@@ -11,7 +11,7 @@ export const COMMIT_LABEL = __('Commit message');
export const TARGET_BRANCH_LABEL = __('Target branch');
export const TOGGLE_CREATE_MR_LABEL = __('Start a new merge request with these changes');
export const NEW_BRANCH_IN_FORK = __(
- 'A new branch will be created in your fork and a new merge request will be started.',
+ 'GitLab will create a branch in your fork and start a merge request.',
);
export const COMMIT_MESSAGE_SUBJECT_MAX_LENGTH = 52;
@@ -20,3 +20,8 @@ export const COMMIT_MESSAGE_BODY_MAX_LENGTH = 72;
export const LIMITED_CONTAINER_WIDTH_CLASS = 'limit-container-width';
export const I18N_COMMIT_DATA_FETCH_ERROR = __('An error occurred while fetching commit data.');
+
+export const PDF_MAX_FILE_SIZE = 10000000; // 10 MB
+export const PDF_MAX_PAGE_LIMIT = 50;
+
+export const ROW_APPEAR_DELAY = 150;
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 45e026ad695..197b19387cf 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -188,5 +188,5 @@ export default function setupVueRepositoryList() {
},
});
- return { router, data: dataset };
+ return { router, data: dataset, apolloProvider, projectPath };
}
diff --git a/app/assets/javascripts/repository/mutations/lock_path.mutation.graphql b/app/assets/javascripts/repository/mutations/lock_path.mutation.graphql
index eaebc4ddf17..0851564bb24 100644
--- a/app/assets/javascripts/repository/mutations/lock_path.mutation.graphql
+++ b/app/assets/javascripts/repository/mutations/lock_path.mutation.graphql
@@ -4,6 +4,7 @@ mutation toggleLock($projectPath: ID!, $filePath: String!, $lock: Boolean!) {
id
pathLocks {
nodes {
+ id
path
}
}
diff --git a/app/assets/javascripts/repository/pages/tree.vue b/app/assets/javascripts/repository/pages/tree.vue
index cbdc62624d4..6bf674eb3f1 100644
--- a/app/assets/javascripts/repository/pages/tree.vue
+++ b/app/assets/javascripts/repository/pages/tree.vue
@@ -1,5 +1,5 @@
<script>
-import TreeContent from '../components/tree_content.vue';
+import TreeContent from 'jh_else_ce/repository/components/tree_content.vue';
import preloadMixin from '../mixins/preload';
import { updateElementsVisibility } from '../utils/dom';
diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql
index cf3892802fd..45d1ba80917 100644
--- a/app/assets/javascripts/repository/queries/blob_info.query.graphql
+++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql
@@ -9,13 +9,19 @@ query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) {
}
pathLocks {
nodes {
+ id
path
+ user {
+ id
+ username
+ }
}
}
repository {
empty
blobs(paths: [$filePath], ref: $ref) {
nodes {
+ id
webPath
name
size
@@ -28,6 +34,7 @@ query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) {
forkAndEditPath
ideForkAndEditPath
canModifyBlob
+ canCurrentUserPushToBranch
storedExternally
rawPath
replacePath
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 3c8533dd06d..ee9533bbec3 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -3,7 +3,6 @@
import $ from 'jquery';
import Cookies from 'js-cookie';
import { hide, fixTitle } from '~/tooltips';
-import { DEBOUNCE_DROPDOWN_DELAY } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import createFlash from './flash';
import axios from './lib/utils/axios_utils';
import { sprintf, s__, __ } from './locale';
@@ -127,14 +126,6 @@ Sidebar.prototype.openDropdown = function (blockOrName) {
this.setCollapseAfterUpdate($block);
this.toggleSidebar('open');
}
-
- // Wait for the sidebar to trigger('click') open
- // so it doesn't cause our dropdown to close preemptively
- setTimeout(() => {
- if (!gon.features?.labelsWidget && !$block.hasClass('labels-select-wrapper')) {
- $block.find('.js-sidebar-dropdown-toggle').trigger('click');
- }
- }, DEBOUNCE_DROPDOWN_DELAY);
};
Sidebar.prototype.setCollapseAfterUpdate = function ($block) {
diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
index 3edb658eaf5..f8220553db6 100644
--- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
@@ -3,12 +3,12 @@ import { GlBadge, GlLink } from '@gitlab/ui';
import createFlash from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
import { updateHistory } from '~/lib/utils/url_utility';
-import { sprintf, __ } from '~/locale';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerName from '../components/runner_name.vue';
+import RunnerOnlineStat from '../components/stat/runner_online_stat.vue';
import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeTabs from '../components/runner_type_tabs.vue';
@@ -38,6 +38,7 @@ export default {
RunnerFilteredSearchBar,
RunnerList,
RunnerName,
+ RunnerOnlineStat,
RunnerPagination,
RunnerTypeTabs,
},
@@ -110,17 +111,12 @@ export default {
noRunnersFound() {
return !this.runnersLoading && !this.runners.items.length;
},
- activeRunnersMessage() {
- return sprintf(__('Runners currently online: %{active_runners_count}'), {
- active_runners_count: this.activeRunnersCount,
- });
- },
searchTokens() {
return [
statusTokenConfig,
{
...tagTokenConfig,
- recentTokenValuesStorageKey: `${this.$options.filteredSearchNamespace}-recent-tags`,
+ recentSuggestionsStorageKey: `${this.$options.filteredSearchNamespace}-recent-tags`,
},
];
},
@@ -165,6 +161,8 @@ export default {
</script>
<template>
<div>
+ <runner-online-stat class="gl-py-6 gl-px-5" :value="activeRunnersCount" />
+
<div
class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0"
>
@@ -194,11 +192,7 @@ export default {
v-model="search"
:tokens="searchTokens"
:namespace="$options.filteredSearchNamespace"
- >
- <template #runner-count>
- {{ activeRunnersMessage }}
- </template>
- </runner-filtered-search-bar>
+ />
<div v-if="noRunnersFound" class="gl-text-center gl-p-5">
{{ __('No runners found') }}
diff --git a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
index c4bddb7b398..33f7a67aba4 100644
--- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
@@ -1,27 +1,29 @@
<script>
-import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui';
+import { GlButton, GlButtonGroup, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
import createFlash from '~/flash';
-import { __, s__ } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql';
import runnerActionsUpdateMutation from '~/runner/graphql/runner_actions_update.mutation.graphql';
import { captureException } from '~/runner/sentry_utils';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import RunnerDeleteModal from '../runner_delete_modal.vue';
-const i18n = {
- I18N_EDIT: __('Edit'),
- I18N_PAUSE: __('Pause'),
- I18N_RESUME: __('Resume'),
- I18N_REMOVE: __('Remove'),
- I18N_REMOVE_CONFIRMATION: s__('Runners|Are you sure you want to delete this runner?'),
-};
+const I18N_EDIT = __('Edit');
+const I18N_PAUSE = __('Pause');
+const I18N_RESUME = __('Resume');
+const I18N_DELETE = s__('Runners|Delete runner');
+const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted');
export default {
name: 'RunnerActionsCell',
components: {
GlButton,
GlButtonGroup,
+ RunnerDeleteModal,
},
directives: {
GlTooltip: GlTooltipDirective,
+ GlModal: GlModalDirective,
},
props: {
runner: {
@@ -48,21 +50,29 @@ export default {
// mouseout listeners don't run leaving the tooltip stuck
return '';
}
- return this.isActive ? i18n.I18N_PAUSE : i18n.I18N_RESUME;
+ return this.isActive ? I18N_PAUSE : I18N_RESUME;
},
deleteTitle() {
- // Prevent a "sticky" tooltip: If element gets removed,
- // mouseout listeners don't run and leaving the tooltip stuck
- return this.deleting ? '' : i18n.I18N_REMOVE;
+ if (this.deleting) {
+ // Prevent a "sticky" tooltip: If this button is disabled,
+ // mouseout listeners don't run leaving the tooltip stuck
+ return '';
+ }
+ return I18N_DELETE;
+ },
+ runnerId() {
+ return getIdFromGraphQLId(this.runner.id);
+ },
+ runnerName() {
+ return `#${this.runnerId} (${this.runner.shortSha})`;
+ },
+ runnerDeleteModalId() {
+ return `delete-runner-modal-${this.runnerId}`;
},
},
methods: {
async onToggleActive() {
this.updating = true;
- // TODO In HAML iteration we had a confirmation modal via:
- // data-confirm="_('Are you sure?')"
- // this may not have to ported, this is an easily reversible operation
-
try {
const toggledActive = !this.runner.active;
@@ -91,12 +101,8 @@ export default {
},
async onDelete() {
- // TODO Replace confirmation with gl-modal
- // eslint-disable-next-line no-alert
- if (!window.confirm(i18n.I18N_REMOVE_CONFIRMATION)) {
- return;
- }
-
+ // Deleting stays "true" until this row is removed,
+ // should only change back if the operation fails.
this.deleting = true;
try {
const {
@@ -115,11 +121,13 @@ export default {
});
if (errors && errors.length) {
throw new Error(errors.join(' '));
+ } else {
+ // Use $root to have the toast message stay after this element is removed
+ this.$root.$toast?.show(sprintf(I18N_DELETED_TOAST, { name: this.runnerName }));
}
} catch (e) {
- this.onError(e);
- } finally {
this.deleting = false;
+ this.onError(e);
}
},
@@ -133,14 +141,15 @@ export default {
captureException({ error, component: this.$options.name });
},
},
- i18n,
+ I18N_EDIT,
+ I18N_DELETE,
};
</script>
<template>
<gl-button-group>
<!--
- This button appears for administratos: those with
+ This button appears for administrators: those with
access to the adminUrl. More advanced permissions policies
will allow more granular permissions.
@@ -148,16 +157,14 @@ export default {
-->
<gl-button
v-if="runner.adminUrl"
- v-gl-tooltip.hover.viewport
+ v-gl-tooltip.hover.viewport="$options.I18N_EDIT"
:href="runner.adminUrl"
- :title="$options.i18n.I18N_EDIT"
- :aria-label="$options.i18n.I18N_EDIT"
+ :aria-label="$options.I18N_EDIT"
icon="pencil"
data-testid="edit-runner"
/>
<gl-button
- v-gl-tooltip.hover.viewport
- :title="toggleActiveTitle"
+ v-gl-tooltip.hover.viewport="toggleActiveTitle"
:aria-label="toggleActiveTitle"
:icon="toggleActiveIcon"
:loading="updating"
@@ -165,14 +172,20 @@ export default {
@click="onToggleActive"
/>
<gl-button
- v-gl-tooltip.hover.viewport
- :title="deleteTitle"
+ v-gl-tooltip.hover.viewport="deleteTitle"
+ v-gl-modal="runnerDeleteModalId"
:aria-label="deleteTitle"
icon="close"
:loading="deleting"
variant="danger"
data-testid="delete-runner"
- @click="onDelete"
+ />
+
+ <runner-delete-modal
+ :ref="runnerDeleteModalId"
+ :modal-id="runnerDeleteModalId"
+ :runner-name="runnerName"
+ @primary="onDelete"
/>
</gl-button-group>
</template>
diff --git a/app/assets/javascripts/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/runner/components/cells/runner_status_cell.vue
index 9ba1192bc8c..473cd7e9794 100644
--- a/app/assets/javascripts/runner/components/cells/runner_status_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_status_cell.vue
@@ -1,14 +1,12 @@
<script>
import { GlTooltipDirective } from '@gitlab/ui';
-import RunnerContactedStateBadge from '../runner_contacted_state_badge.vue';
+import RunnerStatusBadge from '../runner_status_badge.vue';
import RunnerPausedBadge from '../runner_paused_badge.vue';
-import { I18N_LOCKED_RUNNER_DESCRIPTION, I18N_PAUSED_RUNNER_DESCRIPTION } from '../../constants';
-
export default {
components: {
- RunnerContactedStateBadge,
+ RunnerStatusBadge,
RunnerPausedBadge,
},
directives: {
@@ -25,16 +23,12 @@ export default {
return !this.runner.active;
},
},
- i18n: {
- I18N_LOCKED_RUNNER_DESCRIPTION,
- I18N_PAUSED_RUNNER_DESCRIPTION,
- },
};
</script>
<template>
<div>
- <runner-contacted-state-badge :runner="runner" size="sm" />
+ <runner-status-badge :runner="runner" size="sm" />
<runner-paused-badge v-if="paused" size="sm" />
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue
index 3b476997915..937ec631633 100644
--- a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue
@@ -1,7 +1,7 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import RunnerName from '../runner_name.vue';
import RunnerTypeBadge from '../runner_type_badge.vue';
diff --git a/app/assets/javascripts/runner/components/runner_delete_modal.vue b/app/assets/javascripts/runner/components/runner_delete_modal.vue
new file mode 100644
index 00000000000..8be216a7eb5
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_delete_modal.vue
@@ -0,0 +1,51 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+import { __, s__, sprintf } from '~/locale';
+
+const I18N_TITLE = s__('Runners|Delete runner %{name}?');
+const I18N_BODY = s__(
+ 'Runners|The runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?',
+);
+const I18N_PRIMARY = s__('Runners|Delete runner');
+const I18N_CANCEL = __('Cancel');
+
+export default {
+ components: {
+ GlModal,
+ },
+ props: {
+ runnerName: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ title() {
+ return sprintf(I18N_TITLE, { name: this.runnerName });
+ },
+ },
+ methods: {
+ onPrimary() {
+ this.$refs.modal.hide();
+ },
+ },
+ actionPrimary: { text: I18N_PRIMARY, attributes: { variant: 'danger' } },
+ actionCancel: { text: I18N_CANCEL },
+ I18N_BODY,
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="modal"
+ size="sm"
+ :title="title"
+ :action-primary="$options.actionPrimary"
+ :action-cancel="$options.actionCancel"
+ v-bind="$attrs"
+ v-on="$listeners"
+ @primary="onPrimary"
+ >
+ {{ $options.I18N_BODY }}
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
index a9dfec35479..f0f8bbdf5df 100644
--- a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
+++ b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
@@ -76,24 +76,18 @@ export default {
};
</script>
<template>
- <div
+ <filtered-search
class="gl-bg-gray-10 gl-p-5 gl-border-solid gl-border-gray-100 gl-border-0 gl-border-t-1 gl-border-b-1"
- >
- <filtered-search
- v-bind="$attrs"
- :namespace="namespace"
- recent-searches-storage-key="runners-search"
- :sort-options="$options.sortOptions"
- :initial-filter-value="initialFilterValue"
- :tokens="tokens"
- :initial-sort-by="initialSortBy"
- :search-input-placeholder="__('Search or filter results...')"
- data-testid="runners-filtered-search"
- @onFilter="onFilter"
- @onSort="onSort"
- />
- <div class="gl-text-right" data-testid="runner-count">
- <slot name="runner-count"></slot>
- </div>
- </div>
+ v-bind="$attrs"
+ :namespace="namespace"
+ recent-searches-storage-key="runners-search"
+ :sort-options="$options.sortOptions"
+ :initial-filter-value="initialFilterValue"
+ :tokens="tokens"
+ :initial-sort-by="initialSortBy"
+ :search-input-placeholder="__('Search or filter results...')"
+ data-testid="runners-filtered-search"
+ @onFilter="onFilter"
+ @onSort="onSort"
+ />
</template>
diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue
index f8dbc469c22..023308dbac2 100644
--- a/app/assets/javascripts/runner/components/runner_list.vue
+++ b/app/assets/javascripts/runner/components/runner_list.vue
@@ -1,27 +1,26 @@
<script>
import { GlTable, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { __, s__ } from '~/locale';
+import { formatNumber, __, s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import { RUNNER_JOB_COUNT_LIMIT } from '../constants';
import RunnerActionsCell from './cells/runner_actions_cell.vue';
import RunnerSummaryCell from './cells/runner_summary_cell.vue';
import RunnerStatusCell from './cells/runner_status_cell.vue';
import RunnerTags from './runner_tags.vue';
-const tableField = ({ key, label = '', width = 10 }) => {
+const tableField = ({ key, label = '', thClasses = [] }) => {
return {
key,
label,
thClass: [
- `gl-w-${width}p`,
'gl-bg-transparent!',
'gl-border-b-solid!',
'gl-border-b-gray-100!',
- 'gl-py-5!',
- 'gl-px-0!',
'gl-border-b-1!',
+ ...thClasses,
],
- tdClass: ['gl-py-5!', 'gl-px-1!'],
tdAttr: {
'data-testid': `td-${key}`,
},
@@ -32,6 +31,7 @@ export default {
components: {
GlTable,
GlSkeletonLoader,
+ TooltipOnTruncate,
TimeAgo,
RunnerActionsCell,
RunnerSummaryCell,
@@ -53,6 +53,12 @@ export default {
},
},
methods: {
+ formatJobCount(jobCount) {
+ if (jobCount > RUNNER_JOB_COUNT_LIMIT) {
+ return `${formatNumber(RUNNER_JOB_COUNT_LIMIT)}+`;
+ }
+ return formatNumber(jobCount);
+ },
runnerTrAttr(runner) {
if (runner) {
return {
@@ -64,10 +70,11 @@ export default {
},
fields: [
tableField({ key: 'status', label: s__('Runners|Status') }),
- tableField({ key: 'summary', label: s__('Runners|Runner ID'), width: 30 }),
+ tableField({ key: 'summary', label: s__('Runners|Runner ID'), thClasses: ['gl-lg-w-25p'] }),
tableField({ key: 'version', label: __('Version') }),
tableField({ key: 'ipAddress', label: __('IP Address') }),
- tableField({ key: 'tagList', label: __('Tags'), width: 20 }),
+ tableField({ key: 'jobCount', label: __('Jobs') }),
+ tableField({ key: 'tagList', label: __('Tags'), thClasses: ['gl-lg-w-25p'] }),
tableField({ key: 'contactedAt', label: __('Last contact') }),
tableField({ key: 'actions', label: '' }),
],
@@ -82,6 +89,7 @@ export default {
:tbody-tr-attr="runnerTrAttr"
data-testid="runner-list"
stacked="md"
+ primary-key="id"
fixed
>
<template v-if="!runners.length" #table-busy>
@@ -101,11 +109,19 @@ export default {
</template>
<template #cell(version)="{ item: { version } }">
- {{ version }}
+ <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="version">
+ {{ version }}
+ </tooltip-on-truncate>
</template>
<template #cell(ipAddress)="{ item: { ipAddress } }">
- {{ ipAddress }}
+ <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="ipAddress">
+ {{ ipAddress }}
+ </tooltip-on-truncate>
+ </template>
+
+ <template #cell(jobCount)="{ item: { jobCount } }">
+ {{ formatJobCount(jobCount) }}
</template>
<template #cell(tagList)="{ item: { tagList } }">
diff --git a/app/assets/javascripts/runner/components/runner_contacted_state_badge.vue b/app/assets/javascripts/runner/components/runner_status_badge.vue
index b4727f832f8..0823876a187 100644
--- a/app/assets/javascripts/runner/components/runner_contacted_state_badge.vue
+++ b/app/assets/javascripts/runner/components/runner_status_badge.vue
@@ -1,14 +1,17 @@
<script>
import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
-import { s__, sprintf } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
import { getTimeago } from '~/lib/utils/datetime_utility';
import {
- I18N_ONLINE_RUNNER_DESCRIPTION,
- I18N_OFFLINE_RUNNER_DESCRIPTION,
+ I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION,
I18N_NOT_CONNECTED_RUNNER_DESCRIPTION,
+ I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION,
+ I18N_STALE_RUNNER_DESCRIPTION,
STATUS_ONLINE,
- STATUS_OFFLINE,
STATUS_NOT_CONNECTED,
+ STATUS_NEVER_CONTACTED,
+ STATUS_OFFLINE,
+ STATUS_STALE,
} from '../constants';
export default {
@@ -29,31 +32,39 @@ export default {
if (this.runner.contactedAt) {
return getTimeago().format(this.runner.contactedAt);
}
- return null;
+ // Prevent "just now" from being rendered, in case data is missing.
+ return __('n/a');
},
badge() {
- switch (this.runner.status) {
+ switch (this.runner?.status) {
case STATUS_ONLINE:
return {
variant: 'success',
label: s__('Runners|online'),
- tooltip: sprintf(I18N_ONLINE_RUNNER_DESCRIPTION, {
+ tooltip: sprintf(I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION, {
timeAgo: this.contactedAtTimeAgo,
}),
};
+ case STATUS_NOT_CONNECTED:
+ case STATUS_NEVER_CONTACTED:
+ return {
+ variant: 'muted',
+ label: s__('Runners|not connected'),
+ tooltip: I18N_NOT_CONNECTED_RUNNER_DESCRIPTION,
+ };
case STATUS_OFFLINE:
return {
variant: 'muted',
label: s__('Runners|offline'),
- tooltip: sprintf(I18N_OFFLINE_RUNNER_DESCRIPTION, {
+ tooltip: sprintf(I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION, {
timeAgo: this.contactedAtTimeAgo,
}),
};
- case STATUS_NOT_CONNECTED:
+ case STATUS_STALE:
return {
- variant: 'muted',
- label: s__('Runners|not connected'),
- tooltip: I18N_NOT_CONNECTED_RUNNER_DESCRIPTION,
+ variant: 'warning',
+ label: s__('Runners|stale'),
+ tooltip: I18N_STALE_RUNNER_DESCRIPTION,
};
default:
return null;
diff --git a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js
index 9963048ae1d..4b356fa47ed 100644
--- a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js
+++ b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js
@@ -7,6 +7,7 @@ import {
STATUS_ONLINE,
STATUS_OFFLINE,
STATUS_NOT_CONNECTED,
+ STATUS_STALE,
PARAM_KEY_STATUS,
} from '../../constants';
@@ -16,6 +17,7 @@ const options = [
{ value: STATUS_ONLINE, title: s__('Runners|Online') },
{ value: STATUS_OFFLINE, title: s__('Runners|Offline') },
{ value: STATUS_NOT_CONNECTED, title: s__('Runners|Not connected') },
+ { value: STATUS_STALE, title: s__('Runners|Stale') },
];
export const statusTokenConfig = {
diff --git a/app/assets/javascripts/runner/components/search_tokens/tag_token.vue b/app/assets/javascripts/runner/components/search_tokens/tag_token.vue
index ab67ac608e2..7461308ab91 100644
--- a/app/assets/javascripts/runner/components/search_tokens/tag_token.vue
+++ b/app/assets/javascripts/runner/components/search_tokens/tag_token.vue
@@ -68,7 +68,6 @@ export default {
:config="config"
:suggestions-loading="loading"
:suggestions="tags"
- :recent-suggestions-storage-key="config.recentTokenValuesStorageKey"
@fetch-suggestions="fetchTags"
v-on="$listeners"
>
diff --git a/app/assets/javascripts/runner/components/stat/runner_online_stat.vue b/app/assets/javascripts/runner/components/stat/runner_online_stat.vue
new file mode 100644
index 00000000000..b92b9badef0
--- /dev/null
+++ b/app/assets/javascripts/runner/components/stat/runner_online_stat.vue
@@ -0,0 +1,17 @@
+<script>
+import { GlSingleStat } from '@gitlab/ui/dist/charts';
+
+export default {
+ components: {
+ GlSingleStat,
+ },
+};
+</script>
+<template>
+ <gl-single-stat
+ v-bind="$attrs"
+ variant="success"
+ :title="s__('Runners|Online Runners')"
+ :meta-text="s__('Runners|online')"
+ />
+</template>
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js
index 3952e2398e0..355f3054917 100644
--- a/app/assets/javascripts/runner/constants.js
+++ b/app/assets/javascripts/runner/constants.js
@@ -1,6 +1,7 @@
import { s__ } from '~/locale';
export const RUNNER_PAGE_SIZE = 20;
+export const RUNNER_JOB_COUNT_LIMIT = 1000;
export const GROUP_RUNNER_COUNT_LIMIT = 1000;
export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.');
@@ -14,15 +15,18 @@ export const I18N_GROUP_RUNNER_DESCRIPTION = s__(
export const I18N_PROJECT_RUNNER_DESCRIPTION = s__('Runners|Associated with one or more projects');
// Status
-export const I18N_ONLINE_RUNNER_DESCRIPTION = s__(
+export const I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION = s__(
'Runners|Runner is online; last contact was %{timeAgo}',
);
-export const I18N_OFFLINE_RUNNER_DESCRIPTION = s__(
- 'Runners|No recent contact from this runner; last contact was %{timeAgo}',
-);
export const I18N_NOT_CONNECTED_RUNNER_DESCRIPTION = s__(
'Runners|This runner has never connected to this instance',
);
+export const I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION = s__(
+ 'Runners|No recent contact from this runner; last contact was %{timeAgo}',
+);
+export const I18N_STALE_RUNNER_DESCRIPTION = s__(
+ 'Runners|No contact from this runner in over 3 months',
+);
export const I18N_LOCKED_RUNNER_DESCRIPTION = s__('Runners|You cannot assign to other projects');
export const I18N_PAUSED_RUNNER_DESCRIPTION = s__('Runners|Not available to run jobs');
@@ -54,9 +58,12 @@ export const PROJECT_TYPE = 'PROJECT_TYPE';
export const STATUS_ACTIVE = 'ACTIVE';
export const STATUS_PAUSED = 'PAUSED';
+
export const STATUS_ONLINE = 'ONLINE';
-export const STATUS_OFFLINE = 'OFFLINE';
export const STATUS_NOT_CONNECTED = 'NOT_CONNECTED';
+export const STATUS_NEVER_CONTACTED = 'NEVER_CONTACTED';
+export const STATUS_OFFLINE = 'OFFLINE';
+export const STATUS_STALE = 'STALE';
// CiRunnerAccessLevel
diff --git a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql b/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql
index 3e5109b1ac4..6da9e276f74 100644
--- a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql
@@ -13,6 +13,7 @@ query getGroupRunners(
$sort: CiRunnerSort
) {
group(fullPath: $groupFullPath) {
+ id
runners(
membership: DESCENDANTS
before: $before
diff --git a/app/assets/javascripts/runner/graphql/get_runner.query.graphql b/app/assets/javascripts/runner/graphql/get_runner.query.graphql
index c294cb9bf22..59c55eae060 100644
--- a/app/assets/javascripts/runner/graphql/get_runner.query.graphql
+++ b/app/assets/javascripts/runner/graphql/get_runner.query.graphql
@@ -1,6 +1,8 @@
#import "ee_else_ce/runner/graphql/runner_details.fragment.graphql"
query getRunner($id: CiRunnerID!) {
+ # We have an id in deeply nested fragment
+ # eslint-disable-next-line @graphql-eslint/require-id-when-available
runner(id: $id) {
...RunnerDetails
}
diff --git a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql
index 98f2dab26ca..169f6ffd2ea 100644
--- a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql
@@ -8,7 +8,8 @@ fragment RunnerNode on CiRunner {
ipAddress
active
locked
+ jobCount
tagList
contactedAt
- status
+ status(legacyMode: null)
}
diff --git a/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql b/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql
index ea622fd4958..8d1b75828be 100644
--- a/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql
+++ b/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql
@@ -5,6 +5,8 @@
mutation runnerUpdate($input: RunnerUpdateInput!) {
runnerUpdate(input: $input) {
+ # We have an id in deep nested fragment
+ # eslint-disable-next-line @graphql-eslint/require-id-when-available
runner {
...RunnerDetails
}
diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
index c3dfa885f27..a58a53a6a0d 100644
--- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
@@ -9,6 +9,7 @@ import RegistrationDropdown from '../components/registration/registration_dropdo
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerName from '../components/runner_name.vue';
+import RunnerOnlineStat from '../components/stat/runner_online_stat.vue';
import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeTabs from '../components/runner_type_tabs.vue';
@@ -35,6 +36,7 @@ export default {
RunnerFilteredSearchBar,
RunnerList,
RunnerName,
+ RunnerOnlineStat,
RunnerPagination,
RunnerTypeTabs,
},
@@ -145,6 +147,8 @@ export default {
<template>
<div>
+ <runner-online-stat class="gl-py-6 gl-px-5" :value="groupRunnersCount" />
+
<div class="gl-display-flex gl-align-items-center">
<runner-type-tabs
v-model="search"
@@ -164,11 +168,7 @@ export default {
v-model="search"
:tokens="searchTokens"
:namespace="filteredSearchNamespace"
- >
- <template #runner-count>
- {{ runnerCountMessage }}
- </template>
- </runner-filtered-search-bar>
+ />
<div v-if="noRunnersFound" class="gl-text-center gl-p-5">
{{ __('No runners found') }}
diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue
index bc13150c99c..75d2b324623 100644
--- a/app/assets/javascripts/security_configuration/components/app.vue
+++ b/app/assets/javascripts/security_configuration/components/app.vue
@@ -1,12 +1,14 @@
<script>
import { GlTab, GlTabs, GlSprintf, GlLink, GlAlert } from '@gitlab/ui';
import { __, s__ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
import AutoDevOpsAlert from './auto_dev_ops_alert.vue';
import AutoDevOpsEnabledAlert from './auto_dev_ops_enabled_alert.vue';
import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY } from './constants';
import FeatureCard from './feature_card.vue';
+import TrainingProviderList from './training_provider_list.vue';
import SectionLayout from './section_layout.vue';
import UpgradeBanner from './upgrade_banner.vue';
@@ -23,6 +25,8 @@ export const i18n = {
any subsequent feature branch you create will include the scan.`,
),
securityConfiguration: __('Security Configuration'),
+ vulnerabilityManagement: s__('SecurityConfiguration|Vulnerability Management'),
+ securityTraining: s__('SecurityConfiguration|Security training'),
};
export default {
@@ -40,7 +44,9 @@ export default {
SectionLayout,
UpgradeBanner,
UserCalloutDismisser,
+ TrainingProviderList,
},
+ mixins: [glFeatureFlagsMixin()],
inject: ['projectPath'],
props: {
augmentedSecurityFeatures: {
@@ -231,6 +237,17 @@ export default {
</template>
</section-layout>
</gl-tab>
+ <gl-tab
+ v-if="glFeatures.secureVulnerabilityTraining"
+ data-testid="vulnerability-management-tab"
+ :title="$options.i18n.vulnerabilityManagement"
+ >
+ <section-layout :heading="$options.i18n.securityTraining">
+ <template #features>
+ <training-provider-list />
+ </template>
+ </section-layout>
+ </gl-tab>
</gl-tabs>
</article>
</template>
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index 9c80506549e..dd8ba72ad1f 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -22,6 +22,7 @@ import configureSecretDetectionMutation from '../graphql/configure_secret_detect
/**
* Translations & helpPagePaths for Security Configuration Page
+ * Make sure to add new scanner translations to the SCANNER_NAMES_MAP below.
*/
export const SAST_NAME = __('Static Application Security Testing (SAST)');
@@ -138,6 +139,18 @@ export const LICENSE_COMPLIANCE_HELP_PATH = helpPagePath(
'user/compliance/license_compliance/index',
);
+export const SCANNER_NAMES_MAP = {
+ SAST: SAST_SHORT_NAME,
+ SAST_IAC: SAST_IAC_NAME,
+ DAST: DAST_SHORT_NAME,
+ API_FUZZING: API_FUZZING_NAME,
+ CONTAINER_SCANNING: CONTAINER_SCANNING_NAME,
+ CLUSTER_IMAGE_SCANNING: CLUSTER_IMAGE_SCANNING_NAME,
+ COVERAGE_FUZZING: COVERAGE_FUZZING_NAME,
+ SECRET_DETECTION: SECRET_DETECTION_NAME,
+ DEPENDENCY_SCANNING: DEPENDENCY_SCANNING_NAME,
+};
+
export const securityFeatures = [
{
name: SAST_NAME,
@@ -156,27 +169,23 @@ export const securityFeatures = [
// https://gitlab.com/gitlab-org/gitlab/-/issues/331621
canEnableByMergeRequest: true,
},
- ...(gon?.features?.configureIacScanningViaMr
- ? [
- {
- name: SAST_IAC_NAME,
- shortName: SAST_IAC_SHORT_NAME,
- description: SAST_IAC_DESCRIPTION,
- helpPath: SAST_IAC_HELP_PATH,
- configurationHelpPath: SAST_IAC_CONFIG_HELP_PATH,
- type: REPORT_TYPE_SAST_IAC,
+ {
+ name: SAST_IAC_NAME,
+ shortName: SAST_IAC_SHORT_NAME,
+ description: SAST_IAC_DESCRIPTION,
+ helpPath: SAST_IAC_HELP_PATH,
+ configurationHelpPath: SAST_IAC_CONFIG_HELP_PATH,
+ type: REPORT_TYPE_SAST_IAC,
- // This field is currently hardcoded because SAST IaC is always available.
- // It will eventually come from the Backend, the progress is tracked in
- // https://gitlab.com/gitlab-org/gitlab/-/issues/331622
- available: true,
+ // This field is currently hardcoded because SAST IaC is always available.
+ // It will eventually come from the Backend, the progress is tracked in
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/331622
+ available: true,
- // This field will eventually come from the backend, the progress is
- // tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/331621
- canEnableByMergeRequest: true,
- },
- ]
- : []),
+ // This field will eventually come from the backend, the progress is
+ // tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/331621
+ canEnableByMergeRequest: true,
+ },
{
name: DAST_NAME,
shortName: DAST_SHORT_NAME,
@@ -278,21 +287,17 @@ export const featureToMutationMap = {
},
}),
},
- ...(gon?.features?.configureIacScanningViaMr
- ? {
- [REPORT_TYPE_SAST_IAC]: {
- mutationId: 'configureSastIac',
- getMutationPayload: (projectPath) => ({
- mutation: configureSastIacMutation,
- variables: {
- input: {
- projectPath,
- },
- },
- }),
+ [REPORT_TYPE_SAST_IAC]: {
+ mutationId: 'configureSastIac',
+ getMutationPayload: (projectPath) => ({
+ mutation: configureSastIacMutation,
+ variables: {
+ input: {
+ projectPath,
},
- }
- : {}),
+ },
+ }),
+ },
[REPORT_TYPE_SECRET_DETECTION]: {
mutationId: 'configureSecretDetection',
getMutationPayload: (projectPath) => ({
diff --git a/app/assets/javascripts/security_configuration/components/training_provider_list.vue b/app/assets/javascripts/security_configuration/components/training_provider_list.vue
new file mode 100644
index 00000000000..509377a63e8
--- /dev/null
+++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue
@@ -0,0 +1,61 @@
+<script>
+import { GlCard, GlToggle, GlLink, GlSkeletonLoader } from '@gitlab/ui';
+import securityTrainingProvidersQuery from '../graphql/security_training_providers.query.graphql';
+
+export default {
+ components: {
+ GlCard,
+ GlToggle,
+ GlLink,
+ GlSkeletonLoader,
+ },
+ apollo: {
+ securityTrainingProviders: {
+ query: securityTrainingProvidersQuery,
+ },
+ },
+ data() {
+ return {
+ securityTrainingProviders: [],
+ };
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.securityTrainingProviders.loading;
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ v-if="isLoading"
+ class="gl-bg-white gl-py-6 gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100"
+ >
+ <gl-skeleton-loader :width="350" :height="44">
+ <rect width="200" height="8" x="10" y="0" rx="4" />
+ <rect width="300" height="8" x="10" y="15" rx="4" />
+ <rect width="100" height="8" x="10" y="35" rx="4" />
+ </gl-skeleton-loader>
+ </div>
+ <ul v-else class="gl-list-style-none gl-m-0 gl-p-0">
+ <li
+ v-for="{ id, isEnabled, name, description, url } in securityTrainingProviders"
+ :key="id"
+ class="gl-mb-6"
+ >
+ <gl-card>
+ <div class="gl-display-flex">
+ <gl-toggle :value="isEnabled" :label="__('Training mode')" label-position="hidden" />
+ <div class="gl-ml-5">
+ <h3 class="gl-font-lg gl-m-0 gl-mb-2">{{ name }}</h3>
+ <p>
+ {{ description }}
+ <gl-link :href="url" target="_blank">{{ __('Learn more.') }}</gl-link>
+ </p>
+ </div>
+ </div>
+ </gl-card>
+ </li>
+ </ul>
+</template>
diff --git a/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql b/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql
new file mode 100644
index 00000000000..e0c5715ba8e
--- /dev/null
+++ b/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql
@@ -0,0 +1,9 @@
+query Query {
+ securityTrainingProviders @client {
+ name
+ id
+ description
+ isEnabled
+ url
+ }
+}
diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js
index a8623b468f2..c86ff1a58f2 100644
--- a/app/assets/javascripts/security_configuration/index.js
+++ b/app/assets/javascripts/security_configuration/index.js
@@ -2,10 +2,39 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBooleanDataAttributes } from '~/lib/utils/dom_utils';
+import { __ } from '~/locale';
import SecurityConfigurationApp from './components/app.vue';
import { securityFeatures, complianceFeatures } from './components/constants';
import { augmentFeatures } from './utils';
+// Note: this is behind a feature flag and only a placeholder
+// until the actual GraphQL fields have been added
+// https://gitlab.com/gitlab-org/gi tlab/-/issues/346480
+export const tempResolvers = {
+ Query: {
+ securityTrainingProviders() {
+ return [
+ {
+ __typename: 'SecurityTrainingProvider',
+ id: 101,
+ name: __('Kontra'),
+ description: __('Interactive developer security education.'),
+ url: 'https://application.security/',
+ isEnabled: false,
+ },
+ {
+ __typename: 'SecurityTrainingProvider',
+ id: 102,
+ name: __('SecureCodeWarrior'),
+ description: __('Security training with guide and learning pathways.'),
+ url: 'https://www.securecodewarrior.com/',
+ isEnabled: true,
+ },
+ ];
+ },
+ },
+};
+
export const initSecurityConfiguration = (el) => {
if (!el) {
return null;
@@ -14,7 +43,7 @@ export const initSecurityConfiguration = (el) => {
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient(tempResolvers),
});
const {
diff --git a/app/assets/javascripts/security_configuration/utils.js b/app/assets/javascripts/security_configuration/utils.js
index ec6b93c6193..47231497b8f 100644
--- a/app/assets/javascripts/security_configuration/utils.js
+++ b/app/assets/javascripts/security_configuration/utils.js
@@ -1,4 +1,5 @@
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { SCANNER_NAMES_MAP } from '~/security_configuration/components/constants';
export const augmentFeatures = (securityFeatures, complianceFeatures, features = []) => {
const featuresByType = features.reduce((acc, feature) => {
@@ -24,3 +25,13 @@ export const augmentFeatures = (securityFeatures, complianceFeatures, features =
augmentedComplianceFeatures: complianceFeatures.map((feature) => augmentFeature(feature)),
};
};
+
+/**
+ * Converts a list of security scanner IDs (such as SAST_IAC) into a list of their translated
+ * names defined in the SCANNER_NAMES_MAP constant (eg. IaC Scanning).
+ *
+ * @param {String[]} scannerNames
+ * @returns {String[]}
+ */
+export const translateScannerNames = (scannerNames = []) =>
+ scannerNames.map((scannerName) => SCANNER_NAMES_MAP[scannerName] || scannerName);
diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
index 0021fe909e5..e41f3aa5c9d 100644
--- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
+++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
@@ -236,6 +236,8 @@ export default {
},
statusTimeRanges,
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
+ actionPrimary: { text: s__('SetStatusModal|Set status') },
+ actionSecondary: { text: s__('SetStatusModal|Remove status') },
};
</script>
@@ -243,14 +245,13 @@ export default {
<gl-modal
:title="s__('SetStatusModal|Set a status')"
:modal-id="modalId"
- :ok-title="s__('SetStatusModal|Set status')"
- :cancel-title="s__('SetStatusModal|Remove status')"
- ok-variant="success"
+ :action-primary="$options.actionPrimary"
+ :action-secondary="$options.actionSecondary"
modal-class="set-user-status-modal"
@shown="setupEmojiListAndAutocomplete"
@hide="hideEmojiMenu"
- @ok="setStatus"
- @cancel="removeStatus"
+ @primary="setStatus"
+ @secondary="removeStatus"
>
<div>
<input
diff --git a/app/assets/javascripts/shared/milestones/form.js b/app/assets/javascripts/shared/milestones/form.js
deleted file mode 100644
index 3ca9288b156..00000000000
--- a/app/assets/javascripts/shared/milestones/form.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import $ from 'jquery';
-import initDatePicker from '~/behaviors/date_picker';
-import GLForm from '../../gl_form';
-import ZenMode from '../../zen_mode';
-
-export default (initGFM = true) => {
- new ZenMode(); // eslint-disable-line no-new
- initDatePicker();
-
- // eslint-disable-next-line no-new
- new GLForm($('.milestone-form'), {
- emojis: true,
- members: initGFM,
- issues: initGFM,
- mergeRequests: initGFM,
- epics: initGFM,
- milestones: initGFM,
- labels: initGFM,
- snippets: initGFM,
- vulnerabilities: initGFM,
- });
-};
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
index e7ef731eed8..2387fe64b8f 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
@@ -1,7 +1,7 @@
<script>
import produce from 'immer';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { IssuableType } from '~/issue_show/constants';
+import { IssuableType } from '~/issues/constants';
import { assigneesQueries } from '~/sidebar/constants';
export default {
diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
index 20667e695ce..6a74ab83c22 100644
--- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
@@ -110,7 +110,7 @@ export default {
<template>
<div
v-gl-tooltip="tooltipOptions"
- :class="{ 'multiple-users': hasMoreThanOneAssignee }"
+ :class="{ 'multiple-users gl-relative': hasMoreThanOneAssignee }"
:title="tooltipTitle"
class="sidebar-collapsed-icon sidebar-collapsed-user"
>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
index 1b28ba2afd1..5b4dc20e9c8 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
@@ -3,7 +3,7 @@ import { GlDropdownItem } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import Vue from 'vue';
import createFlash from '~/flash';
-import { IssuableType } from '~/issue_show/constants';
+import { IssuableType } from '~/issues/constants';
import { __, n__ } from '~/locale';
import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
index 8d5c3b2def3..a27dbee31ec 100644
--- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -1,6 +1,6 @@
<script>
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { IssuableType } from '~/issue_show/constants';
+import { IssuableType } from '~/issues/constants';
import { __, sprintf } from '~/locale';
import AttentionRequestedToggle from '../attention_requested_toggle.vue';
import AssigneeAvatarLink from './assignee_avatar_link.vue';
diff --git a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue
index 38ba468d197..42e56906e2c 100644
--- a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue
+++ b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue
@@ -64,7 +64,7 @@ export default {
<gl-button
:loading="loading"
:variant="user.attention_requested ? 'warning' : 'default'"
- :icon="user.attention_requested ? 'star' : 'star-o'"
+ :icon="user.attention_requested ? 'attention-solid' : 'attention'"
:aria-label="tooltipTitle"
size="small"
category="tertiary"
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
index 1fb4bd26533..209d1cca360 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
@@ -1,7 +1,7 @@
<script>
import { GlSprintf, GlButton } from '@gitlab/ui';
import createFlash from '~/flash';
-import { IssuableType } from '~/issue_show/constants';
+import { IssuableType } from '~/issues/constants';
import { __, sprintf } from '~/locale';
import { confidentialityQueries } from '~/sidebar/constants';
diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue
new file mode 100644
index 00000000000..6d4da104952
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue
@@ -0,0 +1,131 @@
+<script>
+import { GlIcon, GlPopover, GlTooltipDirective } from '@gitlab/ui';
+import { __, n__, sprintf } from '~/locale';
+import createFlash from '~/flash';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_ISSUE } from '~/graphql_shared/constants';
+import getIssueCrmContactsQuery from './queries/get_issue_crm_contacts.query.graphql';
+import issueCrmContactsSubscription from './queries/issue_crm_contacts.subscription.graphql';
+
+export default {
+ components: {
+ GlIcon,
+ GlPopover,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ issueId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ contacts: [],
+ };
+ },
+ apollo: {
+ contacts: {
+ query: getIssueCrmContactsQuery,
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return data?.issue?.customerRelationsContacts?.nodes;
+ },
+ error(error) {
+ createFlash({
+ message: __('Something went wrong trying to load issue contacts.'),
+ error,
+ captureError: true,
+ });
+ },
+ subscribeToMore: {
+ document: issueCrmContactsSubscription,
+ variables() {
+ return this.queryVariables;
+ },
+ updateQuery(prev, { subscriptionData }) {
+ const draftData = subscriptionData?.data?.issueCrmContactsUpdated;
+ if (prev && draftData) return { issue: draftData };
+ return prev;
+ },
+ },
+ },
+ },
+ computed: {
+ shouldShowContacts() {
+ return this.contacts?.length;
+ },
+ queryVariables() {
+ return { id: convertToGraphQLId(TYPE_ISSUE, this.issueId) };
+ },
+ contactsLabel() {
+ return sprintf(n__('%{count} contact', '%{count} contacts', this.contactCount), {
+ count: this.contactCount,
+ });
+ },
+ contactCount() {
+ return this.contacts?.length || 0;
+ },
+ },
+ methods: {
+ shouldShowPopover(contact) {
+ return this.popOverData(contact).length > 0;
+ },
+ divider(index) {
+ if (index < this.contactCount - 1) return ',';
+ return '';
+ },
+ popOverData(contact) {
+ return [contact.organization?.name, contact.email, contact.phone, contact.description].filter(
+ Boolean,
+ );
+ },
+ },
+ i18n: {
+ help: __('Work in progress- click here to find out more'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div v-gl-tooltip.left.viewport :title="contactsLabel" class="sidebar-collapsed-icon">
+ <gl-icon name="users" />
+ <span> {{ contactCount }} </span>
+ </div>
+ <div
+ v-gl-tooltip.left.viewport="$options.i18n.help"
+ class="hide-collapsed help-button float-right"
+ >
+ <a href="https://gitlab.com/gitlab-org/gitlab/-/issues/2256"><gl-icon name="question-o" /></a>
+ </div>
+ <div class="title hide-collapsed gl-mb-2 gl-line-height-20">
+ {{ contactsLabel }}
+ </div>
+ <div class="hide-collapsed gl-display-flex gl-flex-wrap">
+ <div
+ v-for="(contact, index) in contacts"
+ :id="`contact_container_${index}`"
+ :key="index"
+ class="gl-pr-2"
+ >
+ <span :id="`contact_${index}`" class="gl-font-weight-bold"
+ >{{ contact.firstName }} {{ contact.lastName }}{{ divider(index) }}</span
+ >
+ <gl-popover
+ v-if="shouldShowPopover(contact)"
+ :target="`contact_${index}`"
+ :container="`contact_container_${index}`"
+ triggers="hover focus"
+ placement="top"
+ >
+ <div v-for="row in popOverData(contact)" :key="row">{{ row }}</div>
+ </gl-popover>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql b/app/assets/javascripts/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql
new file mode 100644
index 00000000000..30a0af10d56
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql
@@ -0,0 +1,7 @@
+#import "./issue_crm_contacts.fragment.graphql"
+
+query issueCrmContacts($id: IssueID!) {
+ issue(id: $id) {
+ ...CrmContacts
+ }
+}
diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.fragment.graphql b/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.fragment.graphql
new file mode 100644
index 00000000000..750e1f1d1af
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.fragment.graphql
@@ -0,0 +1,17 @@
+fragment CrmContacts on Issue {
+ id
+ customerRelationsContacts {
+ nodes {
+ id
+ firstName
+ lastName
+ email
+ phone
+ description
+ organization {
+ id
+ name
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql b/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql
new file mode 100644
index 00000000000..f3b6e4ec06f
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql
@@ -0,0 +1,9 @@
+#import "./issue_crm_contacts.fragment.graphql"
+
+subscription issueCrmContactsUpdated($id: IssuableID!) {
+ issueCrmContactsUpdated(issuableId: $id) {
+ ... on Issue {
+ ...CrmContacts
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
index 1ff24dec884..404bcc3122a 100644
--- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
+++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
@@ -1,7 +1,7 @@
<script>
import { GlIcon, GlDatepicker, GlTooltipDirective, GlLink, GlPopover } from '@gitlab/ui';
import createFlash from '~/flash';
-import { IssuableType } from '~/issue_show/constants';
+import { IssuableType } from '~/issues/constants';
import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility';
import { __, sprintf } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
@@ -124,6 +124,9 @@ export default {
isLoading() {
return this.$apollo.queries.issuable.loading || this.loading;
},
+ initialLoading() {
+ return this.$apollo.queries.issuable.loading;
+ },
hasDate() {
return this.dateValue !== null;
},
@@ -151,7 +154,7 @@ export default {
};
},
dataTestId() {
- return this.dateType === dateTypes.start ? 'start-date' : 'due-date';
+ return this.dateType === dateTypes.start ? 'sidebar-start-date' : 'sidebar-due-date';
},
},
methods: {
@@ -266,15 +269,15 @@ export default {
</gl-popover>
</template>
<template #collapsed>
- <div v-gl-tooltip :title="dateLabel" class="sidebar-collapsed-icon">
+ <div v-gl-tooltip.viewport.left :title="dateLabel" class="sidebar-collapsed-icon">
<gl-icon :size="16" name="calendar" />
<span class="collapse-truncated-title">{{ formattedDate }}</span>
</div>
<sidebar-inherit-date
- v-if="canInherit"
+ v-if="canInherit && !initialLoading"
:issuable="issuable"
- :is-loading="isLoading"
:date-type="dateType"
+ :is-loading="isLoading"
@reset-date="setDate(null)"
@set-date="setFixedDate"
/>
diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue b/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue
index b6bfacb2e47..77f8e125dce 100644
--- a/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue
+++ b/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue
@@ -17,8 +17,9 @@ export default {
type: Object,
},
isLoading: {
- required: true,
+ required: false,
type: Boolean,
+ default: false,
},
dateType: {
type: String,
@@ -31,6 +32,7 @@ export default {
return this.issuable?.[dateFields[this.dateType].isDateFixed] || false;
},
set(fixed) {
+ if (fixed === this.issuable[dateFields[this.dateType].isDateFixed]) return;
this.$emit('set-date', fixed);
},
},
diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
deleted file mode 100644
index 5cd4a1a5192..00000000000
--- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
+++ /dev/null
@@ -1,192 +0,0 @@
-<script>
-import $ from 'jquery';
-import { camelCase, difference, union } from 'lodash';
-import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql';
-import createFlash from '~/flash';
-import { getIdFromGraphQLId, MutationOperationMode } from '~/graphql_shared/utils';
-import { IssuableType } from '~/issue_show/constants';
-import { __ } from '~/locale';
-import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql';
-import { toLabelGid } from '~/sidebar/utils';
-import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
-import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
-import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
-import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-
-const mutationMap = {
- [IssuableType.Issue]: {
- mutation: updateIssueLabelsMutation,
- mutationName: 'updateIssue',
- },
- [IssuableType.MergeRequest]: {
- mutation: updateMergeRequestLabelsMutation,
- mutationName: 'mergeRequestSetLabels',
- },
-};
-
-export default {
- components: {
- LabelsSelect,
- LabelsSelectWidget,
- },
- variant: DropdownVariant.Sidebar,
- mixins: [glFeatureFlagMixin()],
- inject: [
- 'allowLabelCreate',
- 'allowLabelEdit',
- 'allowScopedLabels',
- 'iid',
- 'fullPath',
- 'initiallySelectedLabels',
- 'issuableType',
- 'labelsFetchPath',
- 'labelsManagePath',
- 'projectIssuesPath',
- 'projectPath',
- ],
- data() {
- return {
- isLabelsSelectInProgress: false,
- selectedLabels: this.initiallySelectedLabels,
- LabelType,
- };
- },
- methods: {
- handleDropdownClose() {
- $(this.$el).trigger('hidden.gl.dropdown');
- },
- getUpdateVariables(labels) {
- let labelIds = [];
-
- if (this.glFeatures.labelsWidget) {
- labelIds = labels.map(({ id }) => toLabelGid(id));
- } else {
- const currentLabelIds = this.selectedLabels.map((label) => label.id);
- const userAddedLabelIds = labels.filter((label) => label.set).map((label) => label.id);
- const userRemovedLabelIds = labels.filter((label) => !label.set).map((label) => label.id);
-
- labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds).map(
- toLabelGid,
- );
- }
-
- switch (this.issuableType) {
- case IssuableType.Issue:
- return {
- iid: this.iid,
- projectPath: this.projectPath,
- labelIds,
- };
- case IssuableType.MergeRequest:
- return {
- iid: this.iid,
- labelIds,
- operationMode: MutationOperationMode.Replace,
- projectPath: this.projectPath,
- };
- default:
- return {};
- }
- },
- handleUpdateSelectedLabels(dropdownLabels) {
- this.updateSelectedLabels(this.getUpdateVariables(dropdownLabels));
- },
- getRemoveVariables(labelId) {
- switch (this.issuableType) {
- case IssuableType.Issue:
- return {
- iid: this.iid,
- projectPath: this.projectPath,
- removeLabelIds: [labelId],
- };
- case IssuableType.MergeRequest:
- return {
- iid: this.iid,
- labelIds: [toLabelGid(labelId)],
- operationMode: MutationOperationMode.Remove,
- projectPath: this.projectPath,
- };
- default:
- return {};
- }
- },
- handleLabelRemove(labelId) {
- this.updateSelectedLabels(this.getRemoveVariables(labelId));
- },
- updateSelectedLabels(inputVariables) {
- this.isLabelsSelectInProgress = true;
-
- this.$apollo
- .mutate({
- mutation: mutationMap[this.issuableType].mutation,
- variables: { input: inputVariables },
- })
- .then(({ data }) => {
- const { mutationName } = mutationMap[this.issuableType];
-
- if (data[mutationName]?.errors?.length) {
- throw new Error();
- }
-
- const issuableType = camelCase(this.issuableType);
- this.selectedLabels = data[mutationName]?.[issuableType]?.labels?.nodes?.map((label) => ({
- ...label,
- id: getIdFromGraphQLId(label.id),
- }));
- })
- .catch(() => createFlash({ message: __('An error occurred while updating labels.') }))
- .finally(() => {
- this.isLabelsSelectInProgress = false;
- });
- },
- },
-};
-</script>
-
-<template>
- <labels-select-widget
- v-if="glFeatures.labelsWidget"
- class="block labels js-labels-block"
- :iid="iid"
- :full-path="fullPath"
- :allow-label-remove="allowLabelEdit"
- :allow-multiselect="true"
- :footer-create-label-title="__('Create project label')"
- :footer-manage-label-title="__('Manage project labels')"
- :labels-create-title="__('Create project label')"
- :labels-filter-base-path="projectIssuesPath"
- :variant="$options.variant"
- :issuable-type="issuableType"
- workspace-type="project"
- :attr-workspace-path="fullPath"
- :label-create-type="LabelType.project"
- data-qa-selector="labels_block"
- >
- {{ __('None') }}
- </labels-select-widget>
- <labels-select
- v-else
- class="block labels js-labels-block"
- :allow-label-remove="allowLabelEdit"
- :allow-label-create="allowLabelCreate"
- :allow-label-edit="allowLabelEdit"
- :allow-multiselect="true"
- :allow-scoped-labels="allowScopedLabels"
- :footer-create-label-title="__('Create project label')"
- :footer-manage-label-title="__('Manage project labels')"
- :labels-create-title="__('Create project label')"
- :labels-fetch-path="labelsFetchPath"
- :labels-filter-base-path="projectIssuesPath"
- :labels-manage-path="labelsManagePath"
- :labels-select-in-progress="isLabelsSelectInProgress"
- :selected-labels="selectedLabels"
- :variant="$options.sidebar"
- data-qa-selector="labels_block"
- @onDropdownClose="handleDropdownClose"
- @onLabelRemove="handleLabelRemove"
- @updateSelectedLabels="handleUpdateSelectedLabels"
- >
- {{ __('None') }}
- </labels-select>
-</template>
diff --git a/app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql b/app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql
index 2a1bcdf7136..cb9ee6abc9b 100644
--- a/app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql
+++ b/app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql
@@ -1,6 +1,7 @@
mutation updateIssueLocked($input: IssueSetLockedInput!) {
issueSetLocked(input: $input) {
issue {
+ id
discussionLocked
}
errors
diff --git a/app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql b/app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql
index 8590c8e71a6..11eb3611006 100644
--- a/app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql
+++ b/app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql
@@ -1,6 +1,7 @@
mutation updateMergeRequestLocked($input: MergeRequestSetLockedInput!) {
mergeRequestSetLocked(input: $input) {
mergeRequest {
+ id
discussionLocked
}
errors
diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue
index 9554a98121f..60d8fb4d408 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue
@@ -89,7 +89,7 @@ export default {
<template>
<div
v-gl-tooltip="tooltipOptions"
- :class="{ 'multiple-users': hasMoreThanOneReviewer }"
+ :class="{ 'multiple-users gl-relative': hasMoreThanOneReviewer }"
:title="tooltipTitle"
class="sidebar-collapsed-icon sidebar-collapsed-user"
>
diff --git a/app/assets/javascripts/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql b/app/assets/javascripts/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql
index 750e757971f..c9d36dfdb67 100644
--- a/app/assets/javascripts/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql
+++ b/app/assets/javascripts/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql
@@ -3,6 +3,7 @@ mutation updateIssuableSeverity($projectPath: ID!, $severity: IssuableSeverity!,
errors
issue {
iid
+ id
severity
}
}
diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
index 5dc93476120..86e46016534 100644
--- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
+++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
@@ -5,7 +5,7 @@ import {
GlLoadingIcon,
GlTooltip,
GlSprintf,
- GlLink,
+ GlButton,
} from '@gitlab/ui';
import createFlash from '~/flash';
import { INCIDENT_SEVERITY, ISSUABLE_TYPES, I18N } from './constants';
@@ -20,7 +20,7 @@ export default {
GlSprintf,
GlDropdown,
GlDropdownItem,
- GlLink,
+ GlButton,
SeverityToken,
},
inject: ['canUpdate'],
@@ -150,23 +150,25 @@ export default {
<div class="hide-collapsed">
<p
- class="gl-line-height-20 gl-mb-0 gl-text-gray-900 gl-display-flex gl-justify-content-space-between"
+ class="gl-line-height-20 gl-mb-2 gl-text-gray-900 gl-display-flex gl-justify-content-space-between"
>
{{ $options.i18n.SEVERITY }}
- <gl-link
+ <gl-button
v-if="canUpdate"
+ category="tertiary"
+ size="small"
data-testid="editButton"
- href="#"
@click="toggleFormDropdown"
@keydown.esc="hideDropdown"
>
{{ $options.i18n.EDIT }}
- </gl-link>
+ </gl-button>
</p>
<gl-dropdown
:class="dropdownClass"
block
+ :header-text="__('Assign severity')"
:text="selectedItem.label"
toggle-class="dropdown-menu-toggle gl-mb-2"
@keydown.esc.native="hideDropdown"
diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
index 0ba8c4f8907..da792b3a2aa 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
@@ -12,11 +12,12 @@ import {
} from '@gitlab/ui';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { IssuableType } from '~/issue_show/constants';
+import { IssuableType } from '~/issues/constants';
import { timeFor } from '~/lib/utils/datetime_utility';
-import { __, s__, sprintf } from '~/locale';
+import { __ } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import {
+ dropdowni18nText,
Tracking,
IssuableAttributeState,
IssuableAttributeType,
@@ -24,14 +25,11 @@ import {
noAttributeId,
defaultEpicSort,
epicIidPattern,
-} from '~/sidebar/constants';
+} from 'ee_else_ce/sidebar/constants';
export default {
noAttributeId,
- IssuableAttributeState,
- issuableAttributesQueries,
i18n: {
- [IssuableAttributeType.Milestone]: __('Milestone'),
expired: __('(expired)'),
none: __('None'),
},
@@ -53,14 +51,24 @@ export default {
isClassicSidebar: {
default: false,
},
+ issuableAttributesQueries: {
+ default: issuableAttributesQueries,
+ },
+ issuableAttributesState: {
+ default: IssuableAttributeState,
+ },
+ widgetTitleText: {
+ default: {
+ [IssuableAttributeType.Milestone]: __('Milestone'),
+ expired: __('(expired)'),
+ none: __('None'),
+ },
+ },
},
props: {
issuableAttribute: {
type: String,
required: true,
- validator(value) {
- return [IssuableAttributeType.Milestone].includes(value);
- },
},
workspacePath: {
required: true,
@@ -132,13 +140,13 @@ export default {
return {
fullPath: this.attrWorkspacePath,
title: this.searchTerm,
- state: this.$options.IssuableAttributeState[this.issuableAttribute],
+ state: this.issuableAttributesState[this.issuableAttribute],
};
}
const variables = {
fullPath: this.attrWorkspacePath,
- state: this.$options.IssuableAttributeState[this.issuableAttribute],
+ state: this.issuableAttributesState[this.issuableAttribute],
sort: defaultEpicSort,
};
@@ -180,7 +188,7 @@ export default {
},
computed: {
issuableAttributeQuery() {
- return this.$options.issuableAttributesQueries[this.issuableAttribute];
+ return this.issuableAttributesQueries[this.issuableAttribute];
},
attributeTitle() {
return this.currentAttribute?.title || this.i18n.noAttribute;
@@ -189,9 +197,7 @@ export default {
return this.currentAttribute?.webUrl;
},
dropdownText() {
- return this.currentAttribute
- ? this.currentAttribute?.title
- : this.$options.i18n[this.issuableAttribute];
+ return this.currentAttribute ? this.currentAttribute?.title : this.attributeTypeTitle;
},
loading() {
return this.$apollo.queries.currentAttribute.loading;
@@ -200,7 +206,7 @@ export default {
return this.attributesList.length === 0;
},
attributeTypeTitle() {
- return this.$options.i18n[this.issuableAttribute];
+ return this.widgetTitleText[this.issuableAttribute];
},
attributeTypeIcon() {
return this.icon || this.issuableAttribute;
@@ -209,37 +215,10 @@ export default {
return timeFor(this.currentAttribute?.dueDate);
},
i18n() {
- return {
- noAttribute: sprintf(s__('DropdownWidget|No %{issuableAttribute}'), {
- issuableAttribute: this.issuableAttribute,
- }),
- assignAttribute: sprintf(s__('DropdownWidget|Assign %{issuableAttribute}'), {
- issuableAttribute: this.issuableAttribute,
- }),
- noAttributesFound: sprintf(s__('DropdownWidget|No %{issuableAttribute} found'), {
- issuableAttribute: this.issuableAttribute,
- }),
- updateError: sprintf(
- s__(
- 'DropdownWidget|Failed to set %{issuableAttribute} on this %{issuableType}. Please try again.',
- ),
- { issuableAttribute: this.issuableAttribute, issuableType: this.issuableType },
- ),
- listFetchError: sprintf(
- s__(
- 'DropdownWidget|Failed to fetch the %{issuableAttribute} for this %{issuableType}. Please try again.',
- ),
- { issuableAttribute: this.issuableAttribute, issuableType: this.issuableType },
- ),
- currentFetchError: sprintf(
- s__(
- 'DropdownWidget|An error occurred while fetching the assigned %{issuableAttribute} of the selected %{issuableType}.',
- ),
- { issuableAttribute: this.issuableAttribute, issuableType: this.issuableType },
- ),
- };
+ return dropdowni18nText(this.issuableAttribute, this.issuableType);
},
isEpic() {
+ // MV to EE https://gitlab.com/gitlab-org/gitlab/-/issues/345311
return this.issuableAttribute === IssuableType.Epic;
},
},
@@ -252,7 +231,7 @@ export default {
const selectedAttribute =
Boolean(attributeId) && this.attributesList.find((p) => p.id === attributeId);
- this.selectedTitle = selectedAttribute ? selectedAttribute.title : this.$options.i18n.none;
+ this.selectedTitle = selectedAttribute ? selectedAttribute.title : this.widgetTitleText.none;
const { current } = this.issuableAttributeQuery;
const { mutation } = current[this.issuableType];
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
index bc7e377a966..701833c4e95 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
@@ -1,7 +1,7 @@
<script>
import { GlIcon, GlLoadingIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui';
import createFlash from '~/flash';
-import { IssuableType } from '~/issue_show/constants';
+import { IssuableType } from '~/issues/constants';
import { isLoggedIn } from '~/lib/utils/common_utils';
import { __, sprintf } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
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 9a9d03353dc..91c67a03dfb 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon, GlLink, GlModal, GlModalDirective, GlLoadingIcon } from '@gitlab/ui';
-import { IssuableType } from '~/issue_show/constants';
+import { IssuableType } from '~/issues/constants';
import { s__, __ } from '~/locale';
import { timeTrackingQueries } from '~/sidebar/constants';
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
index ac34a75ac5c..0238fb8e8d5 100644
--- a/app/assets/javascripts/sidebar/constants.js
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -1,5 +1,6 @@
+import { s__, sprintf } from '~/locale';
import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql';
-import { IssuableType, WorkspaceType } from '~/issue_show/constants';
+import { IssuableType, WorkspaceType } from '~/issues/constants';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql';
import epicDueDateQuery from '~/sidebar/queries/epic_due_date.query.graphql';
@@ -272,3 +273,35 @@ export const todoMutations = {
[TodoMutationTypes.Create]: todoCreateMutation,
[TodoMutationTypes.MarkDone]: todoMarkDoneMutation,
};
+
+export function dropdowni18nText(issuableAttribute, issuableType) {
+ return {
+ noAttribute: sprintf(s__('DropdownWidget|No %{issuableAttribute}'), {
+ issuableAttribute,
+ }),
+ assignAttribute: sprintf(s__('DropdownWidget|Assign %{issuableAttribute}'), {
+ issuableAttribute,
+ }),
+ noAttributesFound: sprintf(s__('DropdownWidget|No %{issuableAttribute} found'), {
+ issuableAttribute,
+ }),
+ updateError: sprintf(
+ s__(
+ 'DropdownWidget|Failed to set %{issuableAttribute} on this %{issuableType}. Please try again.',
+ ),
+ { issuableAttribute, issuableType },
+ ),
+ listFetchError: sprintf(
+ s__(
+ 'DropdownWidget|Failed to fetch the %{issuableAttribute} for this %{issuableType}. Please try again.',
+ ),
+ { issuableAttribute, issuableType },
+ ),
+ currentFetchError: sprintf(
+ s__(
+ 'DropdownWidget|An error occurred while fetching the assigned %{issuableAttribute} of the selected %{issuableType}.',
+ ),
+ { issuableAttribute, issuableType },
+ ),
+ };
+}
diff --git a/app/assets/javascripts/sidebar/graphql.js b/app/assets/javascripts/sidebar/graphql.js
index 6a670db2d38..5b2ce3fe446 100644
--- a/app/assets/javascripts/sidebar/graphql.js
+++ b/app/assets/javascripts/sidebar/graphql.js
@@ -1,7 +1,7 @@
import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import produce from 'immer';
import VueApollo from 'vue-apollo';
-import getIssueStateQuery from '~/issue_show/queries/get_issue_state.query.graphql';
+import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql';
import createDefaultClient from '~/lib/graphql';
import introspectionQueryResultData from './fragmentTypes.json';
diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
index 270b22fcdf9..1947c4801db 100644
--- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import { IssuableType } from '~/issue_show/constants';
+import { IssuableType } from '~/issues/constants';
import { parseBoolean } from '~/lib/utils/common_utils';
import timeTracker from './components/time_tracking/time_tracker.vue';
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 898be4a97ce..cbe40d0bfbe 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -5,13 +5,14 @@ import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
-import { IssuableType } from '~/issue_show/constants';
+import { IssuableType } from '~/issues/constants';
import {
isInIssuePage,
isInDesignPage,
isInIncidentPage,
parseBoolean,
} from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
import CollapsedAssigneeList from '~/sidebar/components/assignees/collapsed_assignee_list.vue';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
@@ -23,10 +24,11 @@ import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_wid
import { apolloProvider } from '~/sidebar/graphql';
import trackShowInviteMemberLink from '~/sidebar/track_invite_members';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
+import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
+import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import Translate from '../vue_shared/translate';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue';
-import SidebarLabels from './components/labels/sidebar_labels.vue';
import IssuableLockForm from './components/lock/issuable_lock_form.vue';
import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue';
import SidebarSeverity from './components/severity/sidebar_severity.vue';
@@ -34,6 +36,7 @@ import SidebarSubscriptionsWidget from './components/subscriptions/sidebar_subsc
import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue';
import { IssuableAttributeType } from './constants';
import SidebarMoveIssue from './lib/sidebar_move_issue';
+import CrmContacts from './components/crm_contacts/crm_contacts.vue';
Vue.use(Translate);
Vue.use(VueApollo);
@@ -205,6 +208,28 @@ function mountReviewersComponent(mediator) {
}
}
+function mountCrmContactsComponent() {
+ const el = document.getElementById('js-issue-crm-contacts');
+
+ if (!el) return;
+
+ const { issueId } = el.dataset;
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ apolloProvider,
+ components: {
+ CrmContacts,
+ },
+ render: (createElement) =>
+ createElement('crm-contacts', {
+ props: {
+ issueId,
+ },
+ }),
+ });
+}
+
function mountMilestoneSelect() {
const el = document.querySelector('.js-milestone-select');
@@ -241,7 +266,6 @@ function mountMilestoneSelect() {
export function mountSidebarLabels() {
const el = document.querySelector('.js-sidebar-labels');
- const { fullPath } = getSidebarOptions();
if (!el) {
return false;
@@ -250,22 +274,43 @@ export function mountSidebarLabels() {
return new Vue({
el,
apolloProvider,
+
+ components: {
+ LabelsSelectWidget,
+ },
provide: {
...el.dataset,
- fullPath,
+ canUpdate: parseBoolean(el.dataset.canEdit),
allowLabelCreate: parseBoolean(el.dataset.allowLabelCreate),
allowLabelEdit: parseBoolean(el.dataset.canEdit),
allowScopedLabels: parseBoolean(el.dataset.allowScopedLabels),
- initiallySelectedLabels: JSON.parse(el.dataset.selectedLabels),
- variant: DropdownVariant.Sidebar,
- canUpdate: parseBoolean(el.dataset.canEdit),
isClassicSidebar: true,
- issuableType:
- isInIssuePage() || isInIncidentPage() || isInDesignPage()
- ? IssuableType.Issue
- : IssuableType.MergeRequest,
},
- render: (createElement) => createElement(SidebarLabels),
+ render: (createElement) =>
+ createElement('labels-select-widget', {
+ props: {
+ iid: String(el.dataset.iid),
+ fullPath: el.dataset.projectPath,
+ allowLabelRemove: parseBoolean(el.dataset.canEdit),
+ allowMultiselect: true,
+ footerCreateLabelTitle: __('Create project label'),
+ footerManageLabelTitle: __('Manage project labels'),
+ labelsCreateTitle: __('Create project label'),
+ labelsFilterBasePath: el.dataset.projectIssuesPath,
+ variant: DropdownVariant.Sidebar,
+ issuableType:
+ isInIssuePage() || isInIncidentPage() || isInDesignPage()
+ ? IssuableType.Issue
+ : IssuableType.MergeRequest,
+ workspaceType: 'project',
+ attrWorkspacePath: el.dataset.projectPath,
+ labelCreateType: LabelType.project,
+ },
+ class: ['block labels js-labels-block'],
+ scopedSlots: {
+ default: () => __('None'),
+ },
+ }),
});
}
@@ -535,6 +580,7 @@ export function mountSidebar(mediator, store) {
mountAssigneesComponentDeprecated(mediator);
}
mountReviewersComponent(mediator);
+ mountCrmContactsComponent();
mountSidebarLabels();
mountMilestoneSelect();
mountConfidentialComponent(mediator);
diff --git a/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql b/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql
index 7a1fdb40e93..4998b2af666 100644
--- a/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql
@@ -1,6 +1,7 @@
query epicConfidential($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
__typename
+ id
issuable: epic(iid: $iid) {
__typename
id
diff --git a/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql b/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql
index f60f44abebd..00529042e92 100644
--- a/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql
@@ -1,6 +1,7 @@
query epicDueDate($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
__typename
+ id
issuable: epic(iid: $iid) {
__typename
id
diff --git a/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql b/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql
index fbebc50ab08..dada7ffc034 100644
--- a/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql
@@ -4,6 +4,7 @@
query epicParticipants($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
__typename
+ id
issuable: epic(iid: $iid) {
__typename
id
diff --git a/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql b/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql
index bd10f09aed8..f35ca896ef8 100644
--- a/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql
@@ -1,6 +1,7 @@
query epicReference($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
__typename
+ id
issuable: epic(iid: $iid) {
__typename
id
diff --git a/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql b/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql
index c6c24fd3d95..85fc7de8d02 100644
--- a/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql
@@ -1,6 +1,7 @@
query epicStartDate($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
__typename
+ id
issuable: epic(iid: $iid) {
__typename
id
diff --git a/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql
index 9f1967e1685..a8fe6b8ddc3 100644
--- a/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql
@@ -1,6 +1,7 @@
query epicSubscribed($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
__typename
+ id
emailsDisabled
issuable: epic(iid: $iid) {
__typename
diff --git a/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql b/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql
index 1e6f9bad5b2..b0ba724e727 100644
--- a/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql
@@ -1,6 +1,7 @@
query epicTodos($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
__typename
+ id
issuable: epic(iid: $iid) {
__typename
id
diff --git a/app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql b/app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql
index 47ce094418c..a58a04d87c4 100644
--- a/app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql
+++ b/app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql
@@ -3,6 +3,7 @@
subscription issuableAssigneesUpdated($issuableId: IssuableID!) {
issuableAssigneesUpdated(issuableId: $issuableId) {
... on Issue {
+ id
assignees {
nodes {
...User
diff --git a/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql b/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql
index 92cabf46af7..e578cf3bda5 100644
--- a/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql
@@ -1,6 +1,7 @@
query issueConfidential($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
__typename
+ id
issuable: issue(iid: $iid) {
__typename
id
diff --git a/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql b/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql
index 6d3f782bd0a..48cbff252b3 100644
--- a/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql
@@ -1,6 +1,7 @@
query issueDueDate($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
__typename
+ id
issuable: issue(iid: $iid) {
__typename
id
diff --git a/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql b/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql
index db4f58a4f69..c3128d6d961 100644
--- a/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql
@@ -1,5 +1,6 @@
query issueReference($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
+ id
__typename
issuable: issue(iid: $iid) {
__typename
diff --git a/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql
index 7d38b5d3bd8..e2722fc86a4 100644
--- a/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql
@@ -1,6 +1,7 @@
query issueSubscribed($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
__typename
+ id
issuable: issue(iid: $iid) {
__typename
id
diff --git a/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql b/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql
index 7ac989b5c63..059361dd370 100644
--- a/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql
@@ -1,6 +1,7 @@
query issueTimeTracking($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
__typename
+ id
issuable: issue(iid: $iid) {
__typename
id
diff --git a/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql b/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql
index 783d36352fe..5cd5d81c439 100644
--- a/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql
@@ -1,6 +1,7 @@
query issueTodos($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
__typename
+ id
issuable: issue(iid: $iid) {
__typename
id
diff --git a/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql
index 5c0edf5acee..b0a16677cf2 100644
--- a/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql
@@ -3,6 +3,7 @@
query mergeRequestMilestone($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
__typename
+ id
issuable: mergeRequest(iid: $iid) {
__typename
id
diff --git a/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql
index 7979a1ccb3e..7c78f812b67 100644
--- a/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql
@@ -1,6 +1,7 @@
query mergeRequestReference($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
__typename
+ id
issuable: mergeRequest(iid: $iid) {
__typename
id
diff --git a/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql
index 3b54a2e529b..d5e27ca7b69 100644
--- a/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql
@@ -1,6 +1,7 @@
query mergeRequestSubscribed($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
__typename
+ id
issuable: mergeRequest(iid: $iid) {
__typename
id
diff --git a/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql
index b1ab1bcbe87..d480ff3d5ba 100644
--- a/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql
@@ -1,6 +1,7 @@
query mergeRequestTimeTracking($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
__typename
+ id
issuable: mergeRequest(iid: $iid) {
__typename
id
diff --git a/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql
index 93a1c9ea925..65b9ef45260 100644
--- a/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql
@@ -1,6 +1,7 @@
query mergeRequestTodos($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
__typename
+ id
issuable: mergeRequest(iid: $iid) {
__typename
id
diff --git a/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql b/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql
index 2bc42a0b011..c7f3adc9aca 100644
--- a/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql
@@ -3,6 +3,7 @@
query projectIssueMilestone($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
__typename
+ id
issuable: issue(iid: $iid) {
__typename
id
diff --git a/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql b/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql
index a3ab1ebc872..d9eab18628d 100644
--- a/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql
@@ -3,6 +3,7 @@
query projectMilestones($fullPath: ID!, $title: String, $state: MilestoneStateEnum) {
workspace: project(fullPath: $fullPath) {
__typename
+ id
attributes: milestones(
searchTitle: $title
state: $state
diff --git a/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql b/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql
index dd85eb1631b..90d1a7794ea 100644
--- a/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql
@@ -1,6 +1,8 @@
query sidebarDetails($fullPath: ID!, $iid: String!) {
project(fullPath: $fullPath) {
+ id
issue(iid: $iid) {
+ id
iid
}
}
diff --git a/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql b/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql
index 02498b18832..0505f88773d 100644
--- a/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql
@@ -1,6 +1,8 @@
query mergeRequestSidebarDetails($fullPath: ID!, $iid: String!) {
project(fullPath: $fullPath) {
+ id
mergeRequest(iid: $iid) {
+ id
iid # currently unused.
}
}
diff --git a/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql
index 2e6bc8c36ba..809cb2c9f76 100644
--- a/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql
@@ -1,6 +1,7 @@
mutation updateEpicTitle($input: UpdateEpicInput!) {
updateIssuableTitle: updateEpic(input: $input) {
epic {
+ id
title
}
errors
diff --git a/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql
index 016c31ea096..a48c9e96fc2 100644
--- a/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql
@@ -1,7 +1,7 @@
mutation mergeRequestSetLabels($input: MergeRequestSetLabelsInput!) {
- mergeRequestSetLabels(input: $input) {
+ updateIssuableLabels: mergeRequestSetLabels(input: $input) {
errors
- mergeRequest {
+ issuable: mergeRequest {
id
labels {
nodes {
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index 86580744ccc..a49ddac8c89 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -79,6 +79,20 @@ export default class SidebarMediator {
}),
);
} else {
+ const currentUserId = gon.current_user_id;
+
+ if (currentUserId !== user.id) {
+ const currentUserReviewerOrAssignee = isReviewer
+ ? this.store.findReviewer({ id: currentUserId })
+ : this.store.findAssignee({ id: currentUserId });
+
+ if (currentUserReviewerOrAssignee?.attention_requested) {
+ // Update current users attention_requested state
+ this.store.updateReviewer(currentUserId, 'attention_requested');
+ this.store.updateAssignee(currentUserId, 'attention_requested');
+ }
+ }
+
toast(sprintf(__('Requested attention from @%{username}'), { username: user.username }));
}
diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue
index f07fb9d926a..e3aa29d5f89 100644
--- a/app/assets/javascripts/snippets/components/edit.vue
+++ b/app/assets/javascripts/snippets/components/edit.vue
@@ -230,7 +230,7 @@ export default {
<gl-button
category="primary"
type="submit"
- variant="success"
+ variant="confirm"
:disabled="updatePrevented"
data-qa-selector="submit_button"
data-testid="snippet-submit-btn"
diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue
index 8481ac2b9c9..86cbc2c31b3 100644
--- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue
+++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue
@@ -90,7 +90,7 @@ export default {
};
</script>
<template>
- <article class="file-holder snippet-file-content">
+ <figure class="file-holder snippet-file-content" :aria-label="__('Code snippet')">
<blob-header
:blob="blob"
:active-viewer-type="viewer.type"
@@ -105,5 +105,5 @@ export default {
@[$options.BLOB_RENDER_EVENT_LOAD]="forceQuery"
@[$options.BLOB_RENDER_EVENT_SHOW_SOURCE]="switchViewer"
/>
- </article>
+ </figure>
</template>
diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue
index a5c98a7ad90..9b24c8afe37 100644
--- a/app/assets/javascripts/snippets/components/snippet_header.vue
+++ b/app/assets/javascripts/snippets/components/snippet_header.vue
@@ -113,7 +113,7 @@ export default {
href: this.snippet.project
? joinPaths(this.snippet.project.webUrl, '-/snippets/new')
: joinPaths('/', gon.relative_url_root, '/-/snippets/new'),
- variant: 'success',
+ variant: 'confirm',
category: 'secondary',
},
{
diff --git a/app/assets/javascripts/snippets/fragments/project.fragment.graphql b/app/assets/javascripts/snippets/fragments/project.fragment.graphql
deleted file mode 100644
index 64bb2315c1b..00000000000
--- a/app/assets/javascripts/snippets/fragments/project.fragment.graphql
+++ /dev/null
@@ -1,6 +0,0 @@
-fragment SnippetProject on Snippet {
- project {
- fullPath
- webUrl
- }
-}
diff --git a/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql
index f688868d1b9..8640c4725f4 100644
--- a/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql
+++ b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql
@@ -2,6 +2,7 @@ mutation CreateSnippet($input: CreateSnippetInput!) {
createSnippet(input: $input) {
errors
snippet {
+ id
webUrl
}
}
diff --git a/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql
index 548725f7357..99242c5d500 100644
--- a/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql
+++ b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql
@@ -2,6 +2,7 @@ mutation UpdateSnippet($input: UpdateSnippetInput!) {
updateSnippet(input: $input) {
errors
snippet {
+ id
webUrl
}
}
diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql
index cfe30c601ed..c8c4195e1cd 100644
--- a/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql
+++ b/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql
@@ -1,5 +1,6 @@
query sourceContent($project: ID!, $sourcePath: String!) {
project(fullPath: $project) {
+ id
fullPath
file(path: $sourcePath) @client {
title
diff --git a/app/assets/javascripts/tabs/constants.js b/app/assets/javascripts/tabs/constants.js
new file mode 100644
index 00000000000..3b84d7394d4
--- /dev/null
+++ b/app/assets/javascripts/tabs/constants.js
@@ -0,0 +1,20 @@
+export const ACTIVE_TAB_CLASSES = Object.freeze([
+ 'active',
+ 'gl-tab-nav-item-active',
+ 'gl-tab-nav-item-active-indigo',
+]);
+
+export const ACTIVE_PANEL_CLASS = 'active';
+
+export const KEY_CODE_LEFT = 'ArrowLeft';
+export const KEY_CODE_UP = 'ArrowUp';
+export const KEY_CODE_RIGHT = 'ArrowRight';
+export const KEY_CODE_DOWN = 'ArrowDown';
+
+export const ATTR_ARIA_CONTROLS = 'aria-controls';
+export const ATTR_ARIA_LABELLEDBY = 'aria-labelledby';
+export const ATTR_ARIA_SELECTED = 'aria-selected';
+export const ATTR_ROLE = 'role';
+export const ATTR_TABINDEX = 'tabindex';
+
+export const TAB_SHOWN_EVENT = 'gl-tab-shown';
diff --git a/app/assets/javascripts/tabs/index.js b/app/assets/javascripts/tabs/index.js
new file mode 100644
index 00000000000..44937e593e0
--- /dev/null
+++ b/app/assets/javascripts/tabs/index.js
@@ -0,0 +1,239 @@
+import { uniqueId } from 'lodash';
+import {
+ ACTIVE_TAB_CLASSES,
+ ATTR_ROLE,
+ ATTR_ARIA_CONTROLS,
+ ATTR_TABINDEX,
+ ATTR_ARIA_SELECTED,
+ ATTR_ARIA_LABELLEDBY,
+ ACTIVE_PANEL_CLASS,
+ KEY_CODE_LEFT,
+ KEY_CODE_UP,
+ KEY_CODE_RIGHT,
+ KEY_CODE_DOWN,
+ TAB_SHOWN_EVENT,
+} from './constants';
+
+export { TAB_SHOWN_EVENT };
+
+/**
+ * The `GlTabsBehavior` class adds interactivity to tabs created by the `gl_tabs_nav` and
+ * `gl_tab_link_to` Rails helpers.
+ *
+ * Example using `href` references:
+ *
+ * ```haml
+ * = gl_tabs_nav({ class: 'js-my-tabs' }) do
+ * = gl_tab_link_to '#foo', item_active: true do
+ * = _('Foo')
+ * = gl_tab_link_to '#bar' do
+ * = _('Bar')
+ *
+ * .tab-content
+ * .tab-pane.active#foo
+ * .tab-pane#bar
+ * ```
+ *
+ * ```javascript
+ * import { GlTabsBehavior } from '~/tabs';
+ *
+ * const glTabs = new GlTabsBehavior(document.querySelector('.js-my-tabs'));
+ * ```
+ *
+ * Example using `aria-controls` references:
+ *
+ * ```haml
+ * = gl_tabs_nav({ class: 'js-my-tabs' }) do
+ * = gl_tab_link_to '#', item_active: true, 'aria-controls': 'foo' do
+ * = _('Foo')
+ * = gl_tab_link_to '#', 'aria-controls': 'bar' do
+ * = _('Bar')
+ *
+ * .tab-content
+ * .tab-pane.active#foo
+ * .tab-pane#bar
+ * ```
+ *
+ * ```javascript
+ * import { GlTabsBehavior } from '~/tabs';
+ *
+ * const glTabs = new GlTabsBehavior(document.querySelector('.js-my-tabs'));
+ * ```
+ *
+ * `GlTabsBehavior` can be used to replace Bootstrap tab implementations that cannot
+ * easily be rewritten in Vue.
+ *
+ * NOTE: Do *not* use `GlTabsBehavior` with markup generated by other means, as it may not
+ * work correctly.
+ *
+ * Tab panels must exist somewhere in the page for the tabs to control. Tab panels
+ * must:
+ * - be immediate children of a `.tab-content` element
+ * - have the `tab-pane` class
+ * - if the panel is active, have the `active` class
+ * - have a unique `id` attribute
+ *
+ * In order to associate tabs with panels, the tabs must reference their panel's
+ * `id` by having one of the following attributes:
+ * - `href`, e.g., `href="#the-panel-id"` (note the leading `#` in the value)
+ * - `aria-controls`, e.g., `aria-controls="the-panel-id"` (no leading `#`)
+ *
+ * Exactly one tab/panel must be active in the original markup.
+ *
+ * Call the `destroy` method on an instance to remove event listeners that were
+ * added during construction. Other DOM mutations (like ARIA attributes) are
+ * _not_ reverted.
+ */
+export class GlTabsBehavior {
+ /**
+ * Create a GlTabsBehavior instance.
+ *
+ * @param {HTMLElement} el The element created by the Rails `gl_tabs_nav` helper.
+ */
+ constructor(el) {
+ if (!el) {
+ throw new Error('Cannot instantiate GlTabsBehavior without an element');
+ }
+
+ this.destroyFns = [];
+ this.tabList = el;
+ this.tabs = this.getTabs();
+ this.activeTab = null;
+
+ this.setAccessibilityAttrs();
+ this.bindEvents();
+ }
+
+ setAccessibilityAttrs() {
+ this.tabList.setAttribute(ATTR_ROLE, 'tablist');
+ this.tabs.forEach((tab) => {
+ if (!tab.hasAttribute('id')) {
+ tab.setAttribute('id', uniqueId('gl_tab_nav__tab_'));
+ }
+
+ if (!this.activeTab && tab.classList.contains(ACTIVE_TAB_CLASSES[0])) {
+ this.activeTab = tab;
+ tab.setAttribute(ATTR_ARIA_SELECTED, 'true');
+ tab.removeAttribute(ATTR_TABINDEX);
+ } else {
+ tab.setAttribute(ATTR_ARIA_SELECTED, 'false');
+ tab.setAttribute(ATTR_TABINDEX, '-1');
+ }
+
+ tab.setAttribute(ATTR_ROLE, 'tab');
+ tab.closest('.nav-item').setAttribute(ATTR_ROLE, 'presentation');
+
+ const tabPanel = this.getPanelForTab(tab);
+ if (!tab.hasAttribute(ATTR_ARIA_CONTROLS)) {
+ tab.setAttribute(ATTR_ARIA_CONTROLS, tabPanel.id);
+ }
+
+ tabPanel.setAttribute(ATTR_ROLE, 'tabpanel');
+ tabPanel.setAttribute(ATTR_ARIA_LABELLEDBY, tab.id);
+ });
+ }
+
+ bindEvents() {
+ this.tabs.forEach((tab) => {
+ this.bindEvent(tab, 'click', (event) => {
+ event.preventDefault();
+
+ if (tab !== this.activeTab) {
+ this.activateTab(tab);
+ }
+ });
+
+ this.bindEvent(tab, 'keydown', (event) => {
+ const { code } = event;
+ if (code === KEY_CODE_UP || code === KEY_CODE_LEFT) {
+ event.preventDefault();
+ this.activatePreviousTab();
+ } else if (code === KEY_CODE_DOWN || code === KEY_CODE_RIGHT) {
+ event.preventDefault();
+ this.activateNextTab();
+ }
+ });
+ });
+ }
+
+ bindEvent(el, ...args) {
+ el.addEventListener(...args);
+
+ this.destroyFns.push(() => {
+ el.removeEventListener(...args);
+ });
+ }
+
+ activatePreviousTab() {
+ const currentTabIndex = this.tabs.indexOf(this.activeTab);
+
+ if (currentTabIndex <= 0) return;
+
+ const previousTab = this.tabs[currentTabIndex - 1];
+ this.activateTab(previousTab);
+ previousTab.focus();
+ }
+
+ activateNextTab() {
+ const currentTabIndex = this.tabs.indexOf(this.activeTab);
+
+ if (currentTabIndex >= this.tabs.length - 1) return;
+
+ const nextTab = this.tabs[currentTabIndex + 1];
+ this.activateTab(nextTab);
+ nextTab.focus();
+ }
+
+ getTabs() {
+ return Array.from(this.tabList.querySelectorAll('.gl-tab-nav-item'));
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ getPanelForTab(tab) {
+ const ariaControls = tab.getAttribute(ATTR_ARIA_CONTROLS);
+
+ if (ariaControls) {
+ return document.querySelector(`#${ariaControls}`);
+ }
+
+ return document.querySelector(tab.getAttribute('href'));
+ }
+
+ activateTab(tabToActivate) {
+ // Deactivate active tab first
+ this.activeTab.setAttribute(ATTR_ARIA_SELECTED, 'false');
+ this.activeTab.setAttribute(ATTR_TABINDEX, '-1');
+ this.activeTab.classList.remove(...ACTIVE_TAB_CLASSES);
+
+ const activePanel = this.getPanelForTab(this.activeTab);
+ activePanel.classList.remove(ACTIVE_PANEL_CLASS);
+
+ // Now activate the given tab/panel
+ tabToActivate.setAttribute(ATTR_ARIA_SELECTED, 'true');
+ tabToActivate.removeAttribute(ATTR_TABINDEX);
+ tabToActivate.classList.add(...ACTIVE_TAB_CLASSES);
+
+ const tabPanel = this.getPanelForTab(tabToActivate);
+ tabPanel.classList.add(ACTIVE_PANEL_CLASS);
+
+ this.activeTab = tabToActivate;
+
+ this.dispatchTabShown(tabToActivate, tabPanel);
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ dispatchTabShown(tab, activeTabPanel) {
+ const event = new CustomEvent(TAB_SHOWN_EVENT, {
+ bubbles: true,
+ detail: {
+ activeTabPanel,
+ },
+ });
+
+ tab.dispatchEvent(event);
+ }
+
+ destroy() {
+ this.destroyFns.forEach((destroy) => destroy());
+ }
+}
diff --git a/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql b/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql
index 70ba5c960be..bb1e7195b17 100644
--- a/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql
+++ b/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql
@@ -1,23 +1,23 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
fragment StateVersion on TerraformStateVersion {
+ id
downloadPath
serial
updatedAt
-
createdByUser {
...User
}
-
job {
+ id
detailedStatus {
+ id
detailsPath
group
icon
label
text
}
-
pipeline {
id
path
diff --git a/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql b/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql
index 9453e32b1b5..4d26ea88ddf 100644
--- a/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql
+++ b/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql
@@ -3,13 +3,12 @@
query getStates($projectPath: ID!, $first: Int, $last: Int, $before: String, $after: String) {
project(fullPath: $projectPath) {
+ id
terraformStates(first: $first, last: $last, before: $before, after: $after) {
count
-
nodes {
...State
}
-
pageInfo {
...PageInfo
}
diff --git a/app/assets/javascripts/test_utils/simulate_drag.js b/app/assets/javascripts/test_utils/simulate_drag.js
index 321315d531b..4f3f1365f4a 100644
--- a/app/assets/javascripts/test_utils/simulate_drag.js
+++ b/app/assets/javascripts/test_utils/simulate_drag.js
@@ -122,7 +122,6 @@ export default function simulateDrag(options) {
const firstRect = getRect(firstEl);
const lastRect = getRect(lastEl);
- const startTime = new Date().getTime();
const duration = options.duration || 1000;
simulateEvent(fromEl, 'pointerdown', {
@@ -140,8 +139,28 @@ export default function simulateDrag(options) {
toRect.cy = lastRect.y + lastRect.h + 50;
}
- const dragInterval = setInterval(() => {
- const progress = (new Date().getTime() - startTime) / duration;
+ let startTime;
+
+ // Called within dragFn when the drag should finish
+ const finishFn = () => {
+ if (options.ondragend) options.ondragend();
+
+ if (options.performDrop) {
+ simulateEvent(toEl, 'mouseup');
+ }
+
+ window.SIMULATE_DRAG_ACTIVE = 0;
+ };
+
+ const dragFn = (timestamp) => {
+ if (!startTime) {
+ startTime = timestamp;
+ }
+
+ const elapsed = timestamp - startTime;
+
+ // Make sure that progress maxes at 1
+ const progress = Math.min(elapsed / duration, 1);
const x = fromRect.cx + (toRect.cx - fromRect.cx) * progress;
const y = fromRect.cy + (toRect.cy - fromRect.cy + options.extraHeight) * progress;
const overEl = fromEl.ownerDocument.elementFromPoint(x, y);
@@ -152,16 +171,15 @@ export default function simulateDrag(options) {
});
if (progress >= 1) {
- if (options.ondragend) options.ondragend();
-
- if (options.performDrop) {
- simulateEvent(toEl, 'mouseup');
- }
-
- clearInterval(dragInterval);
- window.SIMULATE_DRAG_ACTIVE = 0;
+ // finish on next frame, so we can pause in the correct position for a frame
+ requestAnimationFrame(finishFn);
+ } else {
+ requestAnimationFrame(dragFn);
}
- }, 100);
+ };
+
+ // Start the drag animation
+ requestAnimationFrame(dragFn);
return {
target: fromEl,
diff --git a/app/assets/javascripts/token_access/graphql/queries/get_ci_job_token_scope.query.graphql b/app/assets/javascripts/token_access/graphql/queries/get_ci_job_token_scope.query.graphql
index d4f559c3701..0e5334b468f 100644
--- a/app/assets/javascripts/token_access/graphql/queries/get_ci_job_token_scope.query.graphql
+++ b/app/assets/javascripts/token_access/graphql/queries/get_ci_job_token_scope.query.graphql
@@ -1,5 +1,6 @@
query getCIJobTokenScope($fullPath: ID!) {
project(fullPath: $fullPath) {
+ id
ciCdSettings {
jobTokenScopeEnabled
}
diff --git a/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql b/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql
index bec0710a1dd..664991bc110 100644
--- a/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql
+++ b/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql
@@ -1,8 +1,10 @@
query getProjectsWithCIJobTokenScope($fullPath: ID!) {
project(fullPath: $fullPath) {
+ id
ciJobTokenScope {
projects {
nodes {
+ id
name
fullPath
}
diff --git a/app/assets/javascripts/ui_development_kit.js b/app/assets/javascripts/ui_development_kit.js
deleted file mode 100644
index 1a3fd6c77ed..00000000000
--- a/app/assets/javascripts/ui_development_kit.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import $ from 'jquery';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import Api from './api';
-
-export default () => {
- initDeprecatedJQueryDropdown($('#js-project-dropdown'), {
- data: (term, callback) => {
- Api.projects(
- term,
- {
- order_by: 'last_activity_at',
- },
- (data) => {
- callback(data);
- },
- );
- },
- text: (project) => project.name_with_namespace || project.name,
- selectable: true,
- fieldName: 'author_id',
- filterable: true,
- search: {
- fields: ['name_with_namespace'],
- },
- id: (data) => data.id,
- isSelected: (data) => data.id === 2,
- });
-};
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 a8dde1f681e..e982d10f63b 100644
--- a/app/assets/javascripts/user_lists/components/add_user_modal.vue
+++ b/app/assets/javascripts/user_lists/components/add_user_modal.vue
@@ -19,7 +19,7 @@ export default {
modalOptions: {
actionPrimary: {
text: s__('UserLists|Add'),
- attributes: [{ 'data-testid': 'confirm-add-user-ids' }],
+ attributes: [{ 'data-testid': 'confirm-add-user-ids', variant: 'confirm' }],
},
actionCancel: {
text: s__('UserLists|Cancel'),
diff --git a/app/assets/javascripts/user_lists/components/user_list.vue b/app/assets/javascripts/user_lists/components/user_list.vue
index 4cf3f3010b9..e86b3f81daa 100644
--- a/app/assets/javascripts/user_lists/components/user_list.vue
+++ b/app/assets/javascripts/user_lists/components/user_list.vue
@@ -105,7 +105,7 @@ export default {
<gl-button
v-gl-modal="$options.ADD_USER_MODAL_ID"
data-testid="add-users"
- variant="success"
+ variant="confirm"
>
{{ $options.translations.addUserButtonLabel }}
</gl-button>
diff --git a/app/assets/javascripts/vue_alerts.js b/app/assets/javascripts/vue_alerts.js
index abc1dd75645..b44f787cf30 100644
--- a/app/assets/javascripts/vue_alerts.js
+++ b/app/assets/javascripts/vue_alerts.js
@@ -1,7 +1,17 @@
import Vue from 'vue';
+import Cookies from 'js-cookie';
import { parseBoolean } from '~/lib/utils/common_utils';
import DismissibleAlert from '~/vue_shared/components/dismissible_alert.vue';
+const getCookieExpirationPeriod = (expirationPeriod) => {
+ const defaultExpirationPeriod = 30;
+ const alertExpirationPeriod = Number(expirationPeriod);
+
+ return !expirationPeriod || Number.isNaN(alertExpirationPeriod)
+ ? defaultExpirationPeriod
+ : alertExpirationPeriod;
+};
+
const mountVueAlert = (el) => {
const props = {
html: el.innerHTML,
@@ -10,11 +20,25 @@ const mountVueAlert = (el) => {
...el.dataset,
dismissible: parseBoolean(el.dataset.dismissible),
};
+ const { dismissCookieName, dismissCookieExpire } = el.dataset;
return new Vue({
el,
- render(h) {
- return h(DismissibleAlert, { props, attrs });
+ render(createElement) {
+ return createElement(DismissibleAlert, {
+ props,
+ attrs,
+ on: {
+ alertDismissed() {
+ if (!dismissCookieName) {
+ return;
+ }
+ Cookies.set(dismissCookieName, true, {
+ expires: getCookieExpirationPeriod(dismissCookieExpire),
+ });
+ },
+ },
+ });
},
});
};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
index f4f611dfd1b..e115710b5d1 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
@@ -1,7 +1,7 @@
<script>
import { GlLink, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import {
MANUAL_DEPLOY,
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 6f10f788952..549cf64fb08 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
@@ -9,17 +9,20 @@ import {
GlIntersectionObserver,
} from '@gitlab/ui';
import { once } from 'lodash';
+import * as Sentry from '@sentry/browser';
import api from '~/api';
import { sprintf, s__, __ } from '~/locale';
import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
-import { EXTENSION_ICON_CLASS } from '../../constants';
+import { EXTENSION_ICON_CLASS, EXTENSION_ICONS } from '../../constants';
import StatusIcon from './status_icon.vue';
import Actions from './actions.vue';
+import { generateText } from './utils';
export const LOADING_STATES = {
collapsedLoading: 'collapsedLoading',
collapsedError: 'collapsedError',
expandedLoading: 'expandedLoading',
+ expandedError: 'expandedError',
};
export default {
@@ -40,8 +43,8 @@ export default {
data() {
return {
loadingState: LOADING_STATES.collapsedLoading,
- collapsedData: null,
- fullData: null,
+ collapsedData: {},
+ fullData: [],
isCollapsed: true,
showFade: false,
};
@@ -53,6 +56,9 @@ export default {
widgetLoadingText() {
return this.$options.i18n?.loading || __('Loading...');
},
+ widgetErrorText() {
+ return this.$options.i18n?.error || __('Failed to load');
+ },
isLoadingSummary() {
return this.loadingState === LOADING_STATES.collapsedLoading;
},
@@ -60,11 +66,16 @@ export default {
return this.loadingState === LOADING_STATES.expandedLoading;
},
isCollapsible() {
- if (this.isLoadingSummary) {
- return false;
- }
-
- return true;
+ return !this.isLoadingSummary && this.loadingState !== LOADING_STATES.collapsedError;
+ },
+ hasFullData() {
+ return this.fullData.length > 0;
+ },
+ hasFetchError() {
+ return (
+ this.loadingState === LOADING_STATES.collapsedError ||
+ this.loadingState === LOADING_STATES.expandedError
+ );
},
collapseButtonLabel() {
return sprintf(
@@ -75,6 +86,7 @@ export default {
);
},
statusIconName() {
+ if (this.hasFetchError) return EXTENSION_ICONS.error;
if (this.isLoadingSummary) return null;
return this.statusIcon(this.collapsedData);
@@ -82,6 +94,20 @@ export default {
tertiaryActionsButtons() {
return this.tertiaryButtons ? this.tertiaryButtons() : undefined;
},
+ hydratedSummary() {
+ const structuredOutput = this.summary(this.collapsedData);
+ const summary = {
+ subject: generateText(
+ typeof structuredOutput === 'string' ? structuredOutput : structuredOutput.subject,
+ ),
+ };
+
+ if (structuredOutput.meta) {
+ summary.meta = generateText(structuredOutput.meta);
+ }
+
+ return summary;
+ },
},
watch: {
isCollapsed(newVal) {
@@ -93,15 +119,7 @@ export default {
},
},
mounted() {
- this.fetchCollapsedData(this.$props)
- .then((data) => {
- this.collapsedData = data;
- this.loadingState = null;
- })
- .catch((e) => {
- this.loadingState = LOADING_STATES.collapsedError;
- throw e;
- });
+ this.loadCollapsedData();
},
methods: {
triggerRedisTracking: once(function triggerRedisTracking() {
@@ -114,8 +132,22 @@ export default {
this.triggerRedisTracking();
},
+ loadCollapsedData() {
+ this.loadingState = LOADING_STATES.collapsedLoading;
+
+ this.fetchCollapsedData(this.$props)
+ .then((data) => {
+ this.collapsedData = data;
+ this.loadingState = null;
+ })
+ .catch((e) => {
+ this.loadingState = LOADING_STATES.collapsedError;
+
+ Sentry.captureException(e);
+ });
+ },
loadAllData() {
- if (this.fullData) return;
+ if (this.hasFullData) return;
this.loadingState = LOADING_STATES.expandedLoading;
@@ -125,10 +157,14 @@ export default {
this.fullData = data;
})
.catch((e) => {
- this.loadingState = null;
- throw e;
+ this.loadingState = LOADING_STATES.expandedError;
+
+ Sentry.captureException(e);
});
},
+ isArray(arr) {
+ return Array.isArray(arr);
+ },
appear(index) {
if (index === this.fullData.length - 1) {
this.showFade = false;
@@ -139,6 +175,7 @@ export default {
this.showFade = true;
}
},
+ generateText,
},
EXTENSION_ICON_CLASS,
};
@@ -153,20 +190,29 @@ export default {
:icon-name="statusIconName"
/>
<div
- class="media-body gl-display-flex gl-flex-direction-row!"
+ class="media-body gl-display-flex gl-flex-direction-row! gl-align-self-center"
data-testid="widget-extension-top-level"
>
<div class="gl-flex-grow-1">
<template v-if="isLoadingSummary">{{ widgetLoadingText }}</template>
- <div v-else v-safe-html="summary(collapsedData)"></div>
+ <template v-else-if="hasFetchError">{{ widgetErrorText }}</template>
+ <div v-else>
+ <span v-safe-html="hydratedSummary.subject"></span>
+ <template v-if="hydratedSummary.meta">
+ <br />
+ <span v-safe-html="hydratedSummary.meta" class="gl-font-sm"></span>
+ </template>
+ </div>
</div>
<actions
:widget="$options.label || $options.name"
:tertiary-buttons="tertiaryActionsButtons"
/>
- <div class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6">
+ <div
+ v-if="isCollapsible"
+ class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6"
+ >
<gl-button
- v-if="isCollapsible"
v-gl-tooltip
:title="collapseButtonLabel"
:aria-expanded="`${!isCollapsed}`"
@@ -189,7 +235,7 @@ export default {
<gl-loading-icon size="sm" inline /> {{ __('Loading...') }}
</div>
<smart-virtual-list
- v-else-if="fullData"
+ v-else-if="hasFullData"
:length="fullData.length"
:remain="20"
:size="32"
@@ -203,37 +249,64 @@ export default {
:class="{
'gl-border-b-solid gl-border-b-1 gl-border-gray-100': index !== fullData.length - 1,
}"
- class="gl-display-flex gl-align-items-center gl-py-3 gl-pl-7"
+ class="gl-py-3 gl-pl-7"
data-testid="extension-list-item"
>
- <status-icon v-if="data.icon" :icon-name="data.icon.name" :size="12" class="gl-pl-0" />
- <gl-intersection-observer
- :options="{ rootMargin: '100px', thresholds: 0.1 }"
- class="gl-flex-wrap gl-display-flex gl-w-full"
- @appear="appear(index)"
- @disappear="disappear(index)"
- >
- <div
- v-safe-html="data.text"
- class="gl-mr-4 gl-display-flex gl-align-items-center"
- ></div>
- <div v-if="data.link">
- <gl-link :href="data.link.href">{{ data.link.text }}</gl-link>
+ <div class="gl-w-full">
+ <div v-if="data.header" class="gl-mb-2">
+ <template v-if="isArray(data.header)">
+ <component
+ :is="headerI === 0 ? 'strong' : 'span'"
+ v-for="(header, headerI) in data.header"
+ :key="headerI"
+ v-safe-html="generateText(header)"
+ class="gl-display-block"
+ />
+ </template>
+ <strong v-else v-safe-html="generateText(data.header)"></strong>
+ </div>
+ <div class="gl-display-flex">
+ <status-icon
+ v-if="data.icon"
+ :icon-name="data.icon.name"
+ :size="12"
+ class="gl-pl-0"
+ />
+ <gl-intersection-observer
+ :options="{ rootMargin: '100px', thresholds: 0.1 }"
+ class="gl-w-full"
+ @appear="appear(index)"
+ @disappear="disappear(index)"
+ >
+ <div class="gl-flex-wrap gl-display-flex gl-w-full">
+ <div class="gl-mr-4 gl-display-flex gl-align-items-center">
+ <p v-safe-html="generateText(data.text)" class="gl-m-0"></p>
+ </div>
+ <div v-if="data.link">
+ <gl-link :href="data.link.href">{{ data.link.text }}</gl-link>
+ </div>
+ <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'">
+ {{ data.badge.text }}
+ </gl-badge>
+ <actions
+ :widget="$options.label || $options.name"
+ :tertiary-buttons="data.actions"
+ class="gl-ml-auto"
+ />
+ </div>
+ <p
+ v-if="data.subtext"
+ v-safe-html="generateText(data.subtext)"
+ class="gl-m-0 gl-font-sm"
+ ></p>
+ </gl-intersection-observer>
</div>
- <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'">
- {{ data.badge.text }}
- </gl-badge>
- <actions
- :widget="$options.label || $options.name"
- :tertiary-buttons="data.actions"
- class="gl-ml-auto"
- />
- </gl-intersection-observer>
+ </div>
</li>
</smart-virtual-list>
<div
:class="{ show: showFade }"
- class="fade mr-extenson-scrim gl-absolute gl-left-0 gl-bottom-0 gl-w-full gl-h-7"
+ class="fade mr-extenson-scrim gl-absolute gl-left-0 gl-bottom-0 gl-w-full gl-h-7 gl-pointer-events-none"
></div>
</div>
</section>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js
new file mode 100644
index 00000000000..8ba13cf8252
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js
@@ -0,0 +1,62 @@
+const TEXT_STYLES = {
+ success: {
+ start: '%{success_start}',
+ end: '%{success_end}',
+ },
+ danger: {
+ start: '%{danger_start}',
+ end: '%{danger_end}',
+ },
+ critical: {
+ start: '%{critical_start}',
+ end: '%{critical_end}',
+ },
+ same: {
+ start: '%{same_start}',
+ end: '%{same_end}',
+ },
+ strong: {
+ start: '%{strong_start}',
+ end: '%{strong_end}',
+ },
+ small: {
+ start: '%{small_start}',
+ end: '%{small_end}',
+ },
+};
+
+const getStartTag = (tag) => TEXT_STYLES[tag].start;
+const textStyleTags = {
+ [getStartTag('success')]: '<span class="gl-font-weight-bold gl-text-green-500">',
+ [getStartTag('danger')]: '<span class="gl-font-weight-bold gl-text-red-500">',
+ [getStartTag('critical')]: '<span class="gl-font-weight-bold gl-text-red-800">',
+ [getStartTag('same')]: '<span class="gl-font-weight-bold gl-text-gray-700">',
+ [getStartTag('strong')]: '<span class="gl-font-weight-bold">',
+ [getStartTag('small')]: '<span class="gl-font-sm">',
+};
+
+export const generateText = (text) => {
+ if (typeof text !== 'string') return null;
+
+ return text
+ .replace(
+ new RegExp(
+ `(${Object.values(TEXT_STYLES)
+ .reduce((acc, i) => [...acc, ...Object.values(i)], [])
+ .join('|')})`,
+ 'gi',
+ ),
+ (replace) => {
+ const replacement = textStyleTags[replace];
+
+ // If the replacement tag ends with a `_end` then we can just return `</span>`
+ // unless we have a replacement, for cases were we want to change the HTML tag
+ if (!replacement && replace.endsWith('_end}')) {
+ return '</span>';
+ }
+
+ return replacement;
+ },
+ )
+ .replace(/%{([a-z]|_)+}/g, ''); // Filter out any tags we don't know about
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
index 9070cb1fe65..235a200b747 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
@@ -13,7 +13,7 @@ import {
import { constructWebIDEPath } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import clipboardButton from '~/vue_shared/components/clipboard_button.vue';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import WebIdeLink from '~/vue_shared/components/web_ide_link.vue';
import MrWidgetHowToMergeModal from './mr_widget_how_to_merge_modal.vue';
import MrWidgetIcon from './mr_widget_icon.vue';
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 f7c952f9ef6..c0b80eef082 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
@@ -15,7 +15,7 @@ import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mi
import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import { MT_MERGE_STRATEGY } from '../constants';
export default {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
index c314261d3f5..730d11b1208 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
@@ -1,9 +1,13 @@
<script>
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { s__, n__ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
name: 'MRWidgetRelatedLinks',
+ directives: {
+ SafeHtml,
+ },
mixins: [glFeatureFlagMixin()],
props: {
relatedLinks: {
@@ -43,14 +47,14 @@ export default {
:class="{ 'gl-display-line gl-m-0': glFeatures.restructuredMrWidget }"
>
{{ closesText }}
- <span v-html="relatedLinks.closing /* eslint-disable-line vue/no-v-html */"></span>
+ <span v-safe-html="relatedLinks.closing"></span>
</p>
<p
v-if="relatedLinks.mentioned"
:class="{ 'gl-display-line gl-m-0': glFeatures.restructuredMrWidget }"
>
{{ n__('mrWidget|Mentions issue', 'mrWidget|Mentions issues', relatedLinks.mentionedCount) }}
- <span v-html="relatedLinks.mentioned /* eslint-disable-line vue/no-v-html */"></span>
+ <span v-safe-html="relatedLinks.mentioned"></span>
</p>
<p
v-if="relatedLinks.assignToMe && showAssignToMe"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue
index 3eda2828e97..18761d04c2e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue
@@ -41,7 +41,6 @@ export default {
rows="7"
@input="$emit('input', $event.target.value)"
></textarea>
- <slot name="text-muted"></slot>
</div>
</li>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
index 503ddf8a396..ce572f8b0bf 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
@@ -9,7 +9,7 @@ export default {
pipelineFailed: s__(
'mrWidget|The pipeline for this merge request did not complete. Push a new commit to fix the failure.',
),
- approvalNeeded: s__('mrWidget|You can only merge once this merge request is approved.'),
+ approvalNeeded: s__('mrWidget|Merge blocked: this merge request must be approved.'),
unresolvedDiscussions: s__('mrWidget|Merge blocked: all threads must be resolved.'),
},
components: {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
index 29c26f4fb3e..13b1e49f44e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
@@ -20,7 +20,7 @@ export default {
</div>
<div class="media-body">
<span class="bold">
- {{ s__('mrWidget|This project is archived, write access has been disabled') }}
+ {{ s__('mrWidget|Merge unavailable: merge requests are read-only on archived projects.') }}
</span>
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
index 1596f852b74..7a002d41ac0 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
@@ -117,11 +117,12 @@ export default {
</span>
<template v-else>
<span class="bold">
- {{ s__('mrWidget|There are merge conflicts') }}<span v-if="!canMerge">.</span>
+ {{ s__('mrWidget|Merge blocked: merge conflicts must be resolved.') }}
<span v-if="!canMerge">
{{
- s__(`mrWidget|Resolve these conflicts or ask someone
- with write access to this repository to merge it locally`)
+ s__(
+ `mrWidget|Users who can write to the source or target branches can resolve the conflicts.`,
+ )
}}
</span>
</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
index 9f2870d8d69..01e8303f513 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
@@ -121,9 +121,6 @@ export default {
if (res.merge_error && res.merge_error.length) {
this.rebasingError = res.merge_error;
- createFlash({
- message: __('Something went wrong. Please try again.'),
- });
}
eventHub.$emit('MRWidgetRebaseSuccess');
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
index d2cc99302a9..8830128b7d6 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
@@ -181,9 +181,16 @@ export default {
return this.mr.canRemoveSourceBranch;
},
commitTemplateHelpPage() {
- return helpPagePath('user/project/merge_requests/commit_templates.md', {
- anchor: 'merge-commit-message-template',
- });
+ return helpPagePath('user/project/merge_requests/commit_templates.md');
+ },
+ commitTemplateHintText() {
+ if (this.shouldShowSquashEdit && this.shouldShowMergeEdit) {
+ return this.$options.i18n.mergeAndSquashCommitTemplatesHintText;
+ }
+ if (this.shouldShowSquashEdit) {
+ return this.$options.i18n.squashCommitTemplateHintText;
+ }
+ return this.$options.i18n.mergeCommitTemplateHintText;
},
commits() {
if (this.glFeatures.mergeRequestWidgetGraphql) {
@@ -287,7 +294,7 @@ export default {
return false;
}
- return enableSquashBeforeMerge && this.commitsCount > 1;
+ return enableSquashBeforeMerge;
},
shouldShowMergeControls() {
if (this.glFeatures.restructuredMrWidget) {
@@ -509,6 +516,12 @@ export default {
mergeCommitTemplateHintText: s__(
'mrWidget|To change this default message, edit the template for merge commit messages. %{linkStart}Learn more.%{linkEnd}',
),
+ squashCommitTemplateHintText: s__(
+ 'mrWidget|To change this default message, edit the template for squash commit messages. %{linkStart}Learn more.%{linkEnd}',
+ ),
+ mergeAndSquashCommitTemplatesHintText: s__(
+ 'mrWidget|To change these default messages, edit the templates for both the merge and squash commit messages. %{linkStart}Learn more.%{linkEnd}',
+ ),
},
};
</script>
@@ -590,13 +603,7 @@ export default {
:class="{ 'gl-w-full gl-order-n1 gl-mb-5': glFeatures.restructuredMrWidget }"
class="gl-display-flex gl-align-items-center gl-flex-wrap"
>
- <merge-train-helper-icon
- v-if="shouldRenderMergeTrainHelperIcon"
- :merge-train-when-pipeline-succeeds-docs-path="
- mr.mergeTrainWhenPipelineSucceedsDocsPath
- "
- class="gl-mx-3"
- />
+ <merge-train-helper-icon v-if="shouldRenderMergeTrainHelperIcon" class="gl-mx-3" />
<gl-form-checkbox
v-if="canRemoveSourceBranch"
@@ -680,23 +687,22 @@ export default {
:label="__('Merge commit message')"
input-id="merge-message-edit"
class="gl-m-0! gl-p-0!"
- >
- <template #text-muted>
- <p class="form-text text-muted">
- <gl-sprintf :message="$options.i18n.mergeCommitTemplateHintText">
- <template #link="{ content }">
- <gl-link
- :href="commitTemplateHelpPage"
- class="inline-link"
- target="_blank"
- >
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </p>
- </template>
- </commit-edit>
+ />
+ <li class="gl-m-0! gl-p-0!">
+ <p class="form-text text-muted">
+ <gl-sprintf :message="commitTemplateHintText">
+ <template #link="{ content }">
+ <gl-link
+ :href="commitTemplateHelpPage"
+ class="inline-link"
+ target="_blank"
+ >
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </li>
</ul>
</div>
<div
@@ -798,19 +804,18 @@ export default {
v-model="commitMessage"
:label="__('Merge commit message')"
input-id="merge-message-edit"
- >
- <template #text-muted>
- <p class="form-text text-muted">
- <gl-sprintf :message="$options.i18n.mergeCommitTemplateHintText">
- <template #link="{ content }">
- <gl-link :href="commitTemplateHelpPage" class="inline-link" target="_blank">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </p>
- </template>
- </commit-edit>
+ />
+ <li>
+ <p class="form-text text-muted">
+ <gl-sprintf :message="commitTemplateHintText">
+ <template #link="{ content }">
+ <gl-link :href="commitTemplateHelpPage" class="inline-link" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </li>
</ul>
</commits-header>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
index fa4f8b76cb9..ba831a33b73 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
@@ -165,13 +165,12 @@ export default {
<div class="mr-widget-body media">
<status-icon :show-disabled-button="canUpdate" status="warning" />
<div class="media-body">
- <div class="gl-ml-3 float-left">
+ <div class="float-left">
<span class="gl-font-weight-bold">
- {{ __('This merge request is still a draft.') }}
+ {{
+ __("Merge blocked: merge request must be marked as ready. It's still marked as draft.")
+ }}
</span>
- <span class="gl-display-block text-muted">{{
- __("Draft merge requests can't be merged.")
- }}</span>
</div>
<gl-button
v-if="canUpdate"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue b/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue
index 87a310efe78..1e5f7361966 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue
@@ -20,8 +20,8 @@ export default {
'Terraform|Reported Resource Changes: %{addNum} to add, %{changeNum} to change, %{deleteNum} to delete',
),
generationErrored: s__('Terraform|Generating the report caused an error.'),
- namedReportFailed: s__('Terraform|The report %{name} failed to generate.'),
- namedReportGenerated: s__('Terraform|The report %{name} was generated in your pipelines.'),
+ namedReportFailed: s__('Terraform|The job %{name} failed to generate a report.'),
+ namedReportGenerated: s__('Terraform|The job %{name} generated a report.'),
reportFailed: s__('Terraform|A report failed to generate.'),
reportGenerated: s__('Terraform|A report was generated in your pipelines.'),
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js
index d0c6cf12e25..2edccce7f4e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/constants.js
@@ -50,6 +50,18 @@ export const MERGE_ACTIVE_STATUS_PHRASES = [
message: s__('mrWidget|Merging! This is going to be great…'),
emoji: 'heart_eyes',
},
+ {
+ message: s__('mrWidget|Merging! Lift-off in 5… 4… 3…'),
+ emoji: 'rocket',
+ },
+ {
+ message: s__('mrWidget|Merging! The changes are leaving the station…'),
+ emoji: 'bullettrain_front',
+ },
+ {
+ message: s__('mrWidget|Merging! Take a deep breath and relax…'),
+ emoji: 'sunglasses',
+ },
];
const STATE_MACHINE = {
@@ -146,4 +158,7 @@ export const EXTENSION_ICON_CLASS = {
severityUnknown: 'gl-text-gray-400',
};
+export const EXTENSION_SUMMARY_FAILED_CLASS = 'gl-text-red-500';
+export const EXTENSION_SUMMARY_NEUTRAL_CLASS = 'gl-text-gray-700';
+
export { STATE_MACHINE };
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
index 9cbc0b0e5d1..ba3336df2eb 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
@@ -2,6 +2,7 @@
import { EXTENSION_ICONS } from '../constants';
import issuesCollapsedQuery from './issues_collapsed.query.graphql';
import issuesQuery from './issues.query.graphql';
+import { n__, sprintf } from '~/locale';
export default {
// Give the extension a name
@@ -20,7 +21,14 @@ export default {
// Small summary text to be displayed in the collapsed state
// Receives the collapsed data as an argument
summary(count) {
- return 'Summary text<br/>Second line';
+ return sprintf(
+ n__(
+ 'ciReport|Load performance test metrics detected %{strong_start}%{changesFound}%{strong_end} change',
+ 'ciReport|Load performance test metrics detected %{strong_start}%{changesFound}%{strong_end} changes',
+ changesFound,
+ ),
+ { changesFound },
+ );
},
// Status icon to be used next to the summary text
// Receives the collapsed data as an argument
@@ -57,9 +65,13 @@ export default {
.query({ query: issuesQuery, variables: { projectPath: targetProjectFullPath } })
.then(({ data }) => {
// Return some transformed data to be rendered in the expanded state
- return data.project.issues.nodes.map((issue) => ({
+ return data.project.issues.nodes.map((issue, i) => ({
id: issue.id, // Required: The ID of the object
- text: issue.title, // Required: The text to get used on each row
+ header: ['New', 'This is an %{strong_start}issue%{strong_end} row'],
+ text:
+ '%{critical_start}1 Critical%{critical_end}, %{danger_start}1 High%{danger_end}, and %{strong_start}1 Other%{strong_end}. %{small_start}Some smaller text%{small_end}', // Required: The text to get used on each row
+ subtext:
+ 'Reported resource changes: %{strong_start}2%{strong_end} to add, 0 to change, 0 to delete', // Optional: The sub-text to get displayed below each rows main content
// Icon to get rendered on the side of each row
icon: {
// Required: Name maps to an icon in GitLabs SVG
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.query.graphql b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.query.graphql
index 690f571c083..5c54560bd02 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.query.graphql
@@ -1,5 +1,6 @@
query getAllIssues($projectPath: ID!) {
project(fullPath: $projectPath) {
+ id
issues {
nodes {
id
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql b/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql
index da1cace4598..bf278e1ea85 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql
@@ -1,5 +1,6 @@
query getProjectIssues($projectPath: ID!) {
project(fullPath: $projectPath) {
+ id
issues {
count
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
index 83789f10285..fa618756bb5 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
@@ -1,6 +1,8 @@
import { __ } from '~/locale';
-export const MERGE_DISABLED_TEXT = __('You can only merge once the items above are resolved.');
+export const MERGE_DISABLED_TEXT = __(
+ 'Merge blocked: all merge request dependencies must be merged or closed.',
+);
export const MERGE_DISABLED_SKIPPED_PIPELINE_TEXT = __(
"Merge blocked: pipeline must succeed. It's waiting for a manual job to continue.",
);
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
index bfb1517be81..0b8396b4461 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
@@ -1,9 +1,11 @@
query getState($projectPath: ID!, $iid: String!) {
project(fullPath: $projectPath) {
+ id
archived
onlyAllowMergeIfPipelineSucceeds
mergeRequest(iid: $iid) {
+ id
autoMergeEnabled
commitCount
conflicts
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/permissions.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/permissions.query.graphql
index ae2a67440fe..7ca3ff39fbe 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/permissions.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/permissions.query.graphql
@@ -1,6 +1,8 @@
query userPermissionsQuery($projectPath: ID!, $iid: String!) {
project(fullPath: $projectPath) {
+ id
mergeRequest(iid: $iid) {
+ id
userPermissions {
canMerge
pushToSourceBranch
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.fragment.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.fragment.graphql
index ad715599eb1..fc25e699e39 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.fragment.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.fragment.graphql
@@ -1,4 +1,5 @@
fragment autoMergeEnabled on MergeRequest {
+ id
autoMergeStrategy
mergeUser {
id
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql
index e0215fbd969..2d79d35cf24 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql
@@ -2,6 +2,7 @@
query autoMergeEnabled($projectPath: ID!, $iid: String!) {
project(fullPath: $projectPath) {
+ id
mergeRequest(iid: $iid) {
...autoMergeEnabled
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_failed.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_failed.query.graphql
index 2fe0d174b67..da8aeab9dcb 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_failed.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_failed.query.graphql
@@ -1,6 +1,8 @@
query autoMergeFailedQuery($projectPath: ID!, $iid: String!) {
project(fullPath: $projectPath) {
+ id
mergeRequest(iid: $iid) {
+ id
mergeError
}
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql
index e66ac01ab12..faf21b28f86 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql
@@ -1,6 +1,8 @@
query workInProgress($projectPath: ID!, $iid: String!) {
project(fullPath: $projectPath) {
+ id
mergeRequest(iid: $iid) {
+ id
shouldBeRebased
sourceBranchProtected
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/draft.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/draft.query.graphql
index 0983c28448e..54f2233439f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/draft.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/draft.query.graphql
@@ -1,6 +1,8 @@
query mrUserPermission($projectPath: ID!, $iid: String!) {
project(fullPath: $projectPath) {
+ id
mergeRequest(iid: $iid) {
+ id
userPermissions {
updateMergeRequest
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/missing_branch.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/missing_branch.query.graphql
index ea95218aec6..4d87d55f671 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/missing_branch.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/missing_branch.query.graphql
@@ -1,6 +1,8 @@
query missingBranchQuery($projectPath: ID!, $iid: String!) {
project(fullPath: $projectPath) {
+ id
mergeRequest(iid: $iid) {
+ id
sourceBranchExists
}
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql
index 21c3ffd8321..73c9e77b7bc 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql
@@ -1,6 +1,8 @@
query getReadyToMergeStatus($projectPath: ID!, $iid: String!) {
project(fullPath: $projectPath) {
+ id
mergeRequest(iid: $iid) {
+ id
userPermissions {
canMerge
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql
index b2a1be5c5a9..d85794f7245 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql
@@ -1,8 +1,10 @@
fragment ReadyToMerge on Project {
+ id
onlyAllowMergeIfPipelineSucceeds
mergeRequestsFfOnlyEnabled
squashReadOnly
mergeRequest(iid: $iid) {
+ id
autoMergeEnabled
shouldRemoveSourceBranch
forceRemoveSourceBranch
@@ -26,6 +28,7 @@ fragment ReadyToMerge on Project {
mergeError
commitsWithoutMergeCommits {
nodes {
+ id
sha
shortId
title
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/rebase.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/rebase.query.graphql
index a8c7d2610bf..283177267d4 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/rebase.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/rebase.query.graphql
@@ -1,6 +1,8 @@
query rebaseQuery($projectPath: ID!, $iid: String!) {
project(fullPath: $projectPath) {
+ id
mergeRequest(iid: $iid) {
+ id
rebaseInProgress
targetBranch
userPermissions {
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/toggle_draft.mutation.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/toggle_draft.mutation.graphql
index 200fb1b7ca5..022629bb802 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/toggle_draft.mutation.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/toggle_draft.mutation.graphql
@@ -1,6 +1,7 @@
mutation toggleDraftStatus($projectPath: ID!, $iid: String!, $draft: Boolean!) {
mergeRequestSetDraft(input: { projectPath: $projectPath, iid: $iid, draft: $draft }) {
mergeRequest {
+ id
mergeableDiscussionsState
title
draft
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 10a2907c81a..57af869a0ba 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
@@ -59,7 +59,6 @@ export default class MergeRequestStore {
this.sourceBranch = data.source_branch;
this.sourceBranchProtected = data.source_branch_protected;
this.conflictsDocsPath = data.conflicts_docs_path;
- this.mergeTrainWhenPipelineSucceedsDocsPath = data.merge_train_when_pipeline_succeeds_docs_path;
this.commitMessage = data.default_merge_commit_message;
this.shortMergeCommitSha = data.short_merged_commit_sha;
this.mergeCommitSha = data.merged_commit_sha;
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue
index c24318cb9ad..489d4afa41f 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue
@@ -220,16 +220,17 @@ export default {
class="gl-text-gray-900 gl-mb-2 gl-line-height-20 gl-display-flex gl-justify-content-space-between"
>
{{ __('Assignee') }}
- <a
+ <gl-button
v-if="isEditable"
ref="editButton"
- class="btn-link"
- href="#"
+ category="tertiary"
+ size="small"
+ class="gl-text-black-normal!"
@click="toggleFormDropdown"
@keydown.esc="hideDropdown"
>
{{ __('Edit') }}
- </a>
+ </gl-button>
</p>
<gl-dropdown
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue
index eaa5fc5af04..c512585b980 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue
@@ -100,7 +100,8 @@ export default {
<gl-button
v-if="isEditable"
class="gl-text-black-normal!"
- variant="link"
+ category="tertiary"
+ size="small"
@click="toggleFormDropdown"
@keydown.esc="hideDropdown"
>
diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql
index f0095abfca1..0460d250f75 100644
--- a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql
+++ b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql
@@ -2,6 +2,7 @@ mutation createAlertIssue($projectPath: ID!, $iid: String!) {
createAlertIssue(input: { iid: $iid, projectPath: $projectPath }) {
errors
issue {
+ id
iid
webUrl
}
diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql
index 0c26fcc0ab2..0ea209ffd39 100644
--- a/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql
+++ b/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql
@@ -3,6 +3,7 @@
query alertDetailsAssignees($fullPath: ID!, $alertId: String) {
project(fullPath: $fullPath) {
+ id
alertManagementAlerts(iid: $alertId) {
nodes {
...AlertDetailItem
diff --git a/app/assets/javascripts/vue_shared/components/chronic_duration_input.vue b/app/assets/javascripts/vue_shared/components/chronic_duration_input.vue
new file mode 100644
index 00000000000..ffbcdefc924
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/chronic_duration_input.vue
@@ -0,0 +1,133 @@
+<script>
+import * as Sentry from '@sentry/browser';
+import { GlFormInput } from '@gitlab/ui';
+import {
+ DurationParseError,
+ outputChronicDuration,
+ parseChronicDuration,
+} from '~/chronic_duration';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlFormInput,
+ },
+ model: {
+ prop: 'value',
+ event: 'change',
+ },
+ props: {
+ value: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ name: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ integerRequired: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ data() {
+ return {
+ numberData: this.value,
+ humanReadableData: this.convertDuration(this.value),
+ isValueValid: this.value === null ? null : true,
+ };
+ },
+ computed: {
+ numberValue: {
+ get() {
+ return this.numberData;
+ },
+ set(value) {
+ if (this.numberData !== value) {
+ this.numberData = value;
+ this.humanReadableData = this.convertDuration(value);
+ this.isValueValid = value === null ? null : true;
+ }
+ this.emitEvents();
+ },
+ },
+ humanReadableValue: {
+ get() {
+ return this.humanReadableData;
+ },
+ set(value) {
+ this.humanReadableData = value;
+ try {
+ if (value === '') {
+ this.numberData = null;
+ this.isValueValid = null;
+ } else {
+ this.numberData = parseChronicDuration(value, {
+ keepZero: true,
+ raiseExceptions: true,
+ });
+ this.isValueValid = true;
+ }
+ } catch (e) {
+ if (e instanceof DurationParseError) {
+ this.isValueValid = false;
+ } else {
+ Sentry.captureException(e);
+ }
+ }
+ this.emitEvents(true);
+ },
+ },
+ isValidDecimal() {
+ return !this.integerRequired || this.numberData === null || Number.isInteger(this.numberData);
+ },
+ feedback() {
+ if (this.isValueValid === false) {
+ return this.$options.i18n.INVALID_INPUT_FEEDBACK;
+ }
+ if (!this.isValidDecimal) {
+ return this.$options.i18n.INVALID_DECIMAL_FEEDBACK;
+ }
+ return '';
+ },
+ },
+ i18n: {
+ INVALID_INPUT_FEEDBACK: __('Please enter a valid time interval'),
+ INVALID_DECIMAL_FEEDBACK: __('An integer value is required for seconds'),
+ },
+ watch: {
+ value() {
+ this.numberValue = this.value;
+ },
+ },
+ mounted() {
+ this.emitEvents();
+ },
+ methods: {
+ convertDuration(value) {
+ return value === null ? '' : outputChronicDuration(value);
+ },
+ emitEvents(emitChange = false) {
+ if (emitChange && this.isValueValid !== false && this.isValidDecimal) {
+ this.$emit('change', this.numberData);
+ }
+ const { feedback } = this;
+ this.$refs.text.$el.setCustomValidity(feedback);
+ this.$refs.hidden.setCustomValidity(feedback);
+ this.$emit('valid', {
+ valid: this.isValueValid && this.isValidDecimal,
+ feedback,
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-form-input ref="text" v-bind="$attrs" v-model="humanReadableValue" />
+ <input ref="hidden" type="hidden" :name="name" :value="numberValue" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
index fe329b18f30..400be3ef688 100644
--- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue
+++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
@@ -66,6 +66,11 @@ export default {
required: false,
default: 'medium',
},
+ variant: {
+ type: String,
+ required: false,
+ default: 'default',
+ },
},
computed: {
clipboardText() {
@@ -92,6 +97,7 @@ export default {
:size="size"
icon="copy-to-clipboard"
:aria-label="__('Copy this value')"
+ :variant="variant"
v-on="$listeners"
>
<slot></slot>
diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue
index 5f50a699034..ebbc1bfb037 100644
--- a/app/assets/javascripts/vue_shared/components/commit.vue
+++ b/app/assets/javascripts/vue_shared/components/commit.vue
@@ -2,7 +2,7 @@
import { GlTooltipDirective, GlLink, GlIcon } from '@gitlab/ui';
import { isString, isEmpty } from 'lodash';
import { __, sprintf } from '~/locale';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import UserAvatarLink from './user_avatar/user_avatar_link.vue';
export default {
diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue
index 4c07cf44fed..f93415ced45 100644
--- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue
+++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue
@@ -26,6 +26,11 @@ export default {
type: String,
required: true,
},
+ buttonClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
buttonTestid: {
type: String,
required: false,
@@ -39,7 +44,7 @@ export default {
<div>
<gl-button
v-gl-modal="$options.modalId"
- class="gl-button"
+ :class="buttonClass"
variant="danger"
:disabled="disabled"
:data-testid="buttonTestid"
diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue
index 30c96daf7e3..5bbe44b20b3 100644
--- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue
@@ -47,7 +47,7 @@ export default {
actionPrimary() {
return {
text: this.confirmButtonText,
- attributes: [{ variant: 'danger', disabled: !this.isValid }],
+ attributes: [{ variant: 'danger', disabled: !this.isValid, class: 'qa-confirm-button' }],
};
},
},
@@ -95,7 +95,7 @@ export default {
<gl-form-input
id="confirm_name_input"
v-model="confirmationPhrase"
- class="form-control"
+ class="form-control qa-confirm-input"
data-testid="confirm-danger-input"
type="text"
/>
diff --git a/app/assets/javascripts/vue_shared/components/confirm_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_modal.vue
index 7c1d3772acd..72504e5bc50 100644
--- a/app/assets/javascripts/vue_shared/components/confirm_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/confirm_modal.vue
@@ -2,10 +2,13 @@
import { GlModal, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import csrf from '~/lib/utils/csrf';
+import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from './confirm_modal_eventhub';
+import DomElementListener from './dom_element_listener.vue';
export default {
components: {
GlModal,
+ DomElementListener,
},
directives: {
SafeHtml,
@@ -30,18 +33,35 @@ export default {
};
},
mounted() {
- document.querySelectorAll(this.selector).forEach((button) => {
- button.addEventListener('click', (e) => {
- e.preventDefault();
-
- this.path = button.dataset.path;
- this.method = button.dataset.method;
- this.modalAttributes = JSON.parse(button.dataset.modalAttributes);
- this.openModal();
- });
- });
+ eventHub.$on(EVENT_OPEN_CONFIRM_MODAL, this.onOpenEvent);
+ },
+ destroyed() {
+ eventHub.$off(EVENT_OPEN_CONFIRM_MODAL, this.onOpenEvent);
},
methods: {
+ onButtonPress(e) {
+ const element = e.currentTarget;
+
+ if (!element.dataset.path) {
+ return;
+ }
+
+ const modalAttributes = element.dataset.modalAttributes
+ ? JSON.parse(element.dataset.modalAttributes)
+ : {};
+
+ this.onOpenEvent({
+ path: element.dataset.path,
+ method: element.dataset.method,
+ modalAttributes,
+ });
+ },
+ onOpenEvent({ path, method, modalAttributes }) {
+ this.path = path;
+ this.method = method;
+ this.modalAttributes = modalAttributes;
+ this.openModal();
+ },
openModal() {
this.$refs.modal.show();
},
@@ -61,21 +81,23 @@ export default {
</script>
<template>
- <gl-modal
- ref="modal"
- :modal-id="modalId"
- v-bind="modalAttributes"
- @primary="submitModal"
- @cancel="closeModal"
- >
- <form ref="form" :action="path" method="post">
- <!-- Rails workaround for <form method="delete" />
+ <dom-element-listener :selector="selector" @click.prevent="onButtonPress">
+ <gl-modal
+ ref="modal"
+ :modal-id="modalId"
+ v-bind="modalAttributes"
+ @primary="submitModal"
+ @cancel="closeModal"
+ >
+ <form ref="form" :action="path" method="post">
+ <!-- Rails workaround for <form method="delete" />
https://github.com/rails/rails/blob/master/actionview/app/assets/javascripts/rails-ujs/features/method.coffee
-->
- <input type="hidden" name="_method" :value="method" />
- <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
- <div v-if="modalAttributes.messageHtml" v-safe-html="modalAttributes.messageHtml"></div>
- <div v-else>{{ modalAttributes.message }}</div>
- </form>
- </gl-modal>
+ <input type="hidden" name="_method" :value="method" />
+ <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
+ <div v-if="modalAttributes.messageHtml" v-safe-html="modalAttributes.messageHtml"></div>
+ <div v-else>{{ modalAttributes.message }}</div>
+ </form>
+ </gl-modal>
+ </dom-element-listener>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/confirm_modal_eventhub.js b/app/assets/javascripts/vue_shared/components/confirm_modal_eventhub.js
new file mode 100644
index 00000000000..f8d9d410ace
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/confirm_modal_eventhub.js
@@ -0,0 +1,5 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
+
+export const EVENT_OPEN_CONFIRM_MODAL = Symbol('OPEN');
diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
index 1a96cabf755..e546ca57c5e 100644
--- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
+++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
@@ -3,7 +3,7 @@ import { GlIcon, GlButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitl
import { convertToFixedRange, isEqualTimeRanges, findTimeRange } from '~/lib/utils/datetime_range';
import { __, sprintf } from '~/locale';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import DateTimePickerInput from './date_time_picker_input.vue';
import {
defaultTimeRanges,
diff --git a/app/assets/javascripts/design_management/components/design_note_pin.vue b/app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue
index 320e0654aab..cb038a8c4e1 100644
--- a/app/assets/javascripts/design_management/components/design_note_pin.vue
+++ b/app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue
@@ -10,13 +10,24 @@ export default {
props: {
position: {
type: Object,
- required: true,
+ required: false,
+ default: null,
},
label: {
type: Number,
required: false,
default: null,
},
+ isResolved: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isInactive: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
isNewNote() {
@@ -36,10 +47,13 @@ export default {
:style="position"
:aria-label="pinLabel"
:class="{
- 'btn-transparent comment-indicator gl-p-0': isNewNote,
- 'js-image-badge badge badge-pill': !isNewNote,
+ 'btn-transparent comment-indicator': isNewNote,
+ 'js-image-badge design-note-pin': !isNewNote,
+ resolved: isResolved,
+ inactive: isInactive,
+ 'gl-absolute': position,
}"
- class="gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-font-lg gl-outline-0!"
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-font-sm"
type="button"
@mousedown="$emit('mousedown', $event)"
@mouseup="$emit('mouseup', $event)"
diff --git a/app/assets/javascripts/vue_shared/components/dismissible_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_alert.vue
index 52371e42ba1..0621ec14c6c 100644
--- a/app/assets/javascripts/vue_shared/components/dismissible_alert.vue
+++ b/app/assets/javascripts/vue_shared/components/dismissible_alert.vue
@@ -24,6 +24,7 @@ export default {
methods: {
dismiss() {
this.isDismissed = true;
+ this.$emit('alertDismissed');
},
},
};
diff --git a/app/assets/javascripts/vue_shared/components/dom_element_listener.vue b/app/assets/javascripts/vue_shared/components/dom_element_listener.vue
new file mode 100644
index 00000000000..ca427ed4897
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/dom_element_listener.vue
@@ -0,0 +1,28 @@
+<script>
+export default {
+ props: {
+ selector: {
+ type: String,
+ required: true,
+ },
+ },
+ mounted() {
+ this.disposables = Array.from(document.querySelectorAll(this.selector)).flatMap((button) => {
+ return Object.entries(this.$listeners).map(([key, value]) => {
+ button.addEventListener(key, value);
+ return () => {
+ button.removeEventListener(key, value);
+ };
+ });
+ });
+ },
+ destroyed() {
+ this.disposables.forEach((x) => {
+ x();
+ });
+ },
+ render() {
+ return this.$slots.default;
+ },
+};
+</script>
diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
index e1e71639115..8686d317c8a 100644
--- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
+++ b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
@@ -6,15 +6,10 @@ const fileExtensionIcons = {
jade: 'pug',
pug: 'pug',
md: 'markdown',
- 'md.rendered': 'markdown',
markdown: 'markdown',
- 'markdown.rendered': 'markdown',
mdown: 'markdown',
- 'mdown.rendered': 'markdown',
mkd: 'markdown',
- 'mkd.rendered': 'markdown',
mkdn: 'markdown',
- 'mkdn.rendered': 'markdown',
rst: 'markdown',
blink: 'blink',
css: 'css',
@@ -23,7 +18,6 @@ const fileExtensionIcons = {
less: 'less',
json: 'json',
yaml: 'yaml',
- 'YAML-tmLanguage': 'yaml',
yml: 'yaml',
xml: 'xml',
plist: 'xml',
@@ -85,10 +79,7 @@ const fileExtensionIcons = {
props: 'settings',
toml: 'settings',
prefs: 'settings',
- 'sln.dotsettings': 'settings',
- 'sln.dotsettings.user': 'settings',
ts: 'typescript',
- 'd.ts': 'typescript-def',
marko: 'markojs',
pdf: 'pdf',
xlsx: 'table',
@@ -99,7 +90,6 @@ const fileExtensionIcons = {
vscodeignore: 'vscode',
vsixmanifest: 'vscode',
vsix: 'vscode',
- 'code-workplace': 'vscode',
suo: 'visualstudio',
sln: 'visualstudio',
csproj: 'visualstudio',
@@ -118,7 +108,6 @@ const fileExtensionIcons = {
xz: 'zip',
bzip2: 'zip',
gzip: 'zip',
- '7z': 'zip',
rar: 'zip',
tgz: 'zip',
exe: 'exe',
@@ -129,7 +118,6 @@ const fileExtensionIcons = {
c: 'c',
m: 'c',
h: 'h',
- 'c++': 'cpp',
cc: 'cpp',
cpp: 'cpp',
mm: 'cpp',
@@ -231,7 +219,6 @@ const fileExtensionIcons = {
m2v: 'movie',
vdi: 'virtual',
vbox: 'virtual',
- 'vbox-prev': 'virtual',
ics: 'email',
mp3: 'music',
flac: 'music',
@@ -277,44 +264,12 @@ const fileExtensionIcons = {
ml: 'ocaml',
mli: 'ocaml',
cmx: 'ocaml',
- 'js.map': 'javascript-map',
- 'css.map': 'css-map',
lock: 'lock',
hbs: 'handlebars',
mustache: 'handlebars',
pl: 'perl',
pm: 'perl',
hx: 'haxe',
- 'spec.ts': 'test-ts',
- 'test.ts': 'test-ts',
- 'ts.snap': 'test-ts',
- 'spec.tsx': 'test-jsx',
- 'test.tsx': 'test-jsx',
- 'tsx.snap': 'test-jsx',
- 'spec.jsx': 'test-jsx',
- 'test.jsx': 'test-jsx',
- 'jsx.snap': 'test-jsx',
- 'spec.js': 'test-js',
- 'test.js': 'test-js',
- 'js.snap': 'test-js',
- 'routing.ts': 'angular-routing',
- 'routing.js': 'angular-routing',
- 'module.ts': 'angular',
- 'module.js': 'angular',
- 'ng-template': 'angular',
- 'component.ts': 'angular-component',
- 'component.js': 'angular-component',
- 'guard.ts': 'angular-guard',
- 'guard.js': 'angular-guard',
- 'service.ts': 'angular-service',
- 'service.js': 'angular-service',
- 'pipe.ts': 'angular-pipe',
- 'pipe.js': 'angular-pipe',
- 'filter.js': 'angular-pipe',
- 'directive.ts': 'angular-directive',
- 'directive.js': 'angular-directive',
- 'resolver.ts': 'angular-resolver',
- 'resolver.js': 'angular-resolver',
pp: 'puppet',
ex: 'elixir',
exs: 'elixir',
@@ -345,11 +300,8 @@ const fileExtensionIcons = {
haml: 'haml',
yang: 'yang',
tf: 'terraform',
- 'tf.json': 'terraform',
tfvars: 'terraform',
tfstate: 'terraform',
- 'blade.php': 'laravel',
- 'inky.php': 'laravel',
applescript: 'applescript',
cake: 'cake',
feature: 'cucumber',
@@ -376,16 +328,68 @@ const fileExtensionIcons = {
kv: 'kivy',
graphcool: 'graphcool',
sbt: 'sbt',
+ cr: 'crystal',
+ cu: 'cuda',
+ cuh: 'cuda',
+ log: 'log',
+};
+
+const twoFileExtensionIcons = {
+ 'gradle.kts': 'gradle',
+ 'md.rendered': 'markdown',
+ 'markdown.rendered': 'markdown',
+ 'mdown.rendered': 'markdown',
+ 'mkd.rendered': 'markdown',
+ 'mkdn.rendered': 'markdown',
+ 'YAML-tmLanguage': 'yaml',
+ 'sln.dotsettings': 'settings',
+ 'sln.dotsettings.user': 'settings',
+ 'd.ts': 'typescript-def',
+ 'code-workplace': 'vscode',
+ '7z': 'zip',
+ 'c++': 'cpp',
+ 'vbox-prev': 'virtual',
+ 'js.map': 'javascript-map',
+ 'css.map': 'css-map',
+ 'spec.ts': 'test-ts',
+ 'test.ts': 'test-ts',
+ 'ts.snap': 'test-ts',
+ 'spec.tsx': 'test-jsx',
+ 'test.tsx': 'test-jsx',
+ 'tsx.snap': 'test-jsx',
+ 'spec.jsx': 'test-jsx',
+ 'test.jsx': 'test-jsx',
+ 'jsx.snap': 'test-jsx',
+ 'spec.js': 'test-js',
+ 'test.js': 'test-js',
+ 'js.snap': 'test-js',
+ 'routing.ts': 'angular-routing',
+ 'routing.js': 'angular-routing',
+ 'module.ts': 'angular',
+ 'module.js': 'angular',
+ 'ng-template': 'angular',
+ 'component.ts': 'angular-component',
+ 'component.js': 'angular-component',
+ 'guard.ts': 'angular-guard',
+ 'guard.js': 'angular-guard',
+ 'service.ts': 'angular-service',
+ 'service.js': 'angular-service',
+ 'pipe.ts': 'angular-pipe',
+ 'pipe.js': 'angular-pipe',
+ 'filter.js': 'angular-pipe',
+ 'directive.ts': 'angular-directive',
+ 'directive.js': 'angular-directive',
+ 'resolver.ts': 'angular-resolver',
+ 'resolver.js': 'angular-resolver',
+ 'tf.json': 'terraform',
+ 'blade.php': 'laravel',
+ 'inky.php': 'laravel',
'reducer.ts': 'ngrx-reducer',
'rootReducer.ts': 'ngrx-reducer',
'state.ts': 'ngrx-state',
'actions.ts': 'ngrx-actions',
'effects.ts': 'ngrx-effects',
- cr: 'crystal',
'drone.yml': 'drone',
- cu: 'cuda',
- cuh: 'cuda',
- log: 'log',
};
const fileNameIcons = {
@@ -598,6 +602,9 @@ const fileNameIcons = {
export default function getIconForFile(name) {
return (
- fileNameIcons[name] || fileExtensionIcons[name ? name.split('.').pop().toLowerCase() : ''] || ''
+ fileNameIcons[name] ||
+ twoFileExtensionIcons[name ? name.split('.').slice(-2).join('.') : ''] ||
+ fileExtensionIcons[name ? name.split('.').pop().toLowerCase() : ''] ||
+ ''
);
}
diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue
index 0b0a416b7ef..2227047a909 100644
--- a/app/assets/javascripts/vue_shared/components/file_row.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row.vue
@@ -146,6 +146,7 @@ export default {
ref="textOutput"
:style="levelIndentation"
class="file-row-name"
+ :title="file.name"
data-qa-selector="file_name_content"
:data-qa-file-name="file.name"
data-testid="file-row-name-container"
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 d9290e86bca..810d9f782b9 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
@@ -2,7 +2,6 @@ import { __ } from '~/locale';
export const DEBOUNCE_DELAY = 200;
export const MAX_RECENT_TOKENS_SIZE = 3;
-export const WEIGHT_TOKEN_SUGGESTIONS_SIZE = 21;
export const FILTER_NONE = 'None';
export const FILTER_ANY = 'Any';
@@ -24,22 +23,11 @@ export const DEFAULT_LABEL_NONE = { value: FILTER_NONE, text: __('None'), title:
export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __('Any'), title: __('Any') };
export const DEFAULT_NONE_ANY = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY];
-export const DEFAULT_ITERATIONS = DEFAULT_NONE_ANY.concat([
- { value: FILTER_CURRENT, text: __('Current') },
-]);
-
export const DEFAULT_MILESTONES = DEFAULT_NONE_ANY.concat([
{ value: FILTER_UPCOMING, text: __('Upcoming'), title: __('Upcoming') },
{ value: FILTER_STARTED, text: __('Started'), title: __('Started') },
]);
-export const DEFAULT_MILESTONES_GRAPHQL = [
- { value: 'any', text: __('Any'), title: __('Any') },
- { value: 'none', text: __('None'), title: __('None') },
- { value: '#upcoming', text: __('Upcoming'), title: __('Upcoming') },
- { value: '#started', text: __('Started'), title: __('Started') },
-];
-
export const SortDirection = {
descending: 'descending',
ascending: 'ascending',
@@ -56,6 +44,3 @@ export const TOKEN_TITLE_TYPE = __('Type');
export const TOKEN_TITLE_RELEASE = __('Release');
export const TOKEN_TITLE_MY_REACTION = __('My-Reaction');
export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential');
-export const TOKEN_TITLE_ITERATION = __('Iteration');
-export const TOKEN_TITLE_EPIC = __('Epic');
-export const TOKEN_TITLE_WEIGHT = __('Weight');
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql
deleted file mode 100644
index 9e9bda8ad3e..00000000000
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql
+++ /dev/null
@@ -1,15 +0,0 @@
-fragment EpicNode on Epic {
- id
- iid
- group {
- fullPath
- }
- title
- state
- reference
- referencePath: reference(full: true)
- webPath
- webUrl
- createdAt
- closedAt
-}
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql
deleted file mode 100644
index 4bb4b586fc9..00000000000
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql
+++ /dev/null
@@ -1,16 +0,0 @@
-#import "./epic.fragment.graphql"
-
-query searchEpics($fullPath: ID!, $search: String, $state: EpicState) {
- group(fullPath: $fullPath) {
- epics(
- search: $search
- state: $state
- includeAncestorGroups: true
- includeDescendantGroups: false
- ) {
- nodes {
- ...EpicNode
- }
- }
- }
-}
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
index b3b3d5c88c6..06478a89721 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
@@ -87,7 +87,6 @@ export default {
:get-active-token-value="getActiveAuthor"
:default-suggestions="defaultAuthors"
:preloaded-suggestions="preloadedAuthors"
- :recent-suggestions-storage-key="config.recentSuggestionsStorageKey"
@fetch-suggestions="fetchAuthors"
v-on="$listeners"
>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
index cee7c40aa83..bbc1888bc0b 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
@@ -4,12 +4,17 @@ import {
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlDropdownSectionHeader,
+ GlDropdownText,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { DEBOUNCE_DELAY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants';
-import { getRecentlyUsedSuggestions, setTokenValueToRecentlyUsed } from '../filtered_search_utils';
+import {
+ getRecentlyUsedSuggestions,
+ setTokenValueToRecentlyUsed,
+ stripQuotes,
+} from '../filtered_search_utils';
export default {
components: {
@@ -17,6 +22,7 @@ export default {
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlDropdownSectionHeader,
+ GlDropdownText,
GlLoadingIcon,
},
props: {
@@ -57,11 +63,6 @@ export default {
required: false,
default: () => [],
},
- recentSuggestionsStorageKey: {
- type: String,
- required: false,
- default: '',
- },
valueIdentifier: {
type: String,
required: false,
@@ -76,14 +77,14 @@ export default {
data() {
return {
searchKey: '',
- recentSuggestions: this.recentSuggestionsStorageKey
- ? getRecentlyUsedSuggestions(this.recentSuggestionsStorageKey)
+ recentSuggestions: this.config.recentSuggestionsStorageKey
+ ? getRecentlyUsedSuggestions(this.config.recentSuggestionsStorageKey)
: [],
};
},
computed: {
isRecentSuggestionsEnabled() {
- return Boolean(this.recentSuggestionsStorageKey);
+ return Boolean(this.config.recentSuggestionsStorageKey);
},
recentTokenIds() {
return this.recentSuggestions.map((tokenValue) => tokenValue[this.valueIdentifier]);
@@ -119,6 +120,9 @@ export default {
showDefaultSuggestions() {
return this.availableDefaultSuggestions.length > 0;
},
+ showNoMatchesText() {
+ return this.searchKey && !this.availableSuggestions.length;
+ },
showRecentSuggestions() {
return (
this.isRecentSuggestionsEnabled && this.recentSuggestions.length > 0 && !this.searchKey
@@ -163,11 +167,20 @@ export default {
this.searchKey = data;
if (!this.suggestionsLoading && !this.activeTokenValue) {
- const search = this.searchTerm ? this.searchTerm : data;
+ let search = this.searchTerm ? this.searchTerm : data;
+
+ if (search.startsWith('"') && search.endsWith('"')) {
+ search = stripQuotes(search);
+ } else if (search.startsWith('"')) {
+ search = search.slice(1, search.length);
+ }
+
this.$emit('fetch-suggestions', search);
}
}, DEBOUNCE_DELAY),
- handleTokenValueSelected(activeTokenValue) {
+ handleTokenValueSelected(selectedValue) {
+ const activeTokenValue = this.getActiveTokenValue(this.suggestions, selectedValue);
+
// Make sure that;
// 1. Recently used values feature is enabled
// 2. User has actually selected a value
@@ -177,7 +190,7 @@ export default {
activeTokenValue &&
!this.preloadedTokenIds.includes(activeTokenValue[this.valueIdentifier])
) {
- setTokenValueToRecentlyUsed(this.recentSuggestionsStorageKey, activeTokenValue);
+ setTokenValueToRecentlyUsed(this.config.recentSuggestionsStorageKey, activeTokenValue);
}
},
},
@@ -192,7 +205,7 @@ export default {
v-bind="$attrs"
v-on="$listeners"
@input="handleInput"
- @select="handleTokenValueSelected(activeTokenValue)"
+ @select="handleTokenValueSelected"
>
<template #view-token="viewTokenProps">
<slot name="view-token" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot>
@@ -222,6 +235,9 @@ export default {
:suggestions="preloadedSuggestions"
></slot>
<gl-loading-icon v-if="suggestionsLoading" size="sm" />
+ <gl-dropdown-text v-else-if="showNoMatchesText">
+ {{ __('No matches found') }}
+ </gl-dropdown-text>
<template v-else>
<slot name="suggestions-list" :suggestions="availableSuggestions"></slot>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
deleted file mode 100644
index 9c2f5306654..00000000000
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
+++ /dev/null
@@ -1,129 +0,0 @@
-<script>
-import { GlFilteredSearchSuggestion } from '@gitlab/ui';
-import createFlash from '~/flash';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { __ } from '~/locale';
-import { DEFAULT_NONE_ANY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants';
-import searchEpicsQuery from '../queries/search_epics.query.graphql';
-
-import BaseToken from './base_token.vue';
-
-export default {
- prefix: '&',
- separator: '::',
- components: {
- BaseToken,
- GlFilteredSearchSuggestion,
- },
- props: {
- config: {
- type: Object,
- required: true,
- },
- value: {
- type: Object,
- required: true,
- },
- active: {
- type: Boolean,
- required: true,
- },
- },
- data() {
- return {
- epics: this.config.initialEpics || [],
- loading: false,
- };
- },
- computed: {
- idProperty() {
- return this.config.idProperty || 'iid';
- },
- currentValue() {
- const epicIid = Number(this.value.data);
- if (epicIid) {
- return epicIid;
- }
- return this.value.data;
- },
- defaultEpics() {
- return this.config.defaultEpics || DEFAULT_NONE_ANY;
- },
- availableDefaultEpics() {
- if (this.value.operator === OPERATOR_IS_NOT) {
- return this.defaultEpics.filter(
- (suggestion) => !FILTER_NONE_ANY.includes(suggestion.value),
- );
- }
- return this.defaultEpics;
- },
- },
- methods: {
- fetchEpics(search = '') {
- return this.$apollo
- .query({
- query: searchEpicsQuery,
- variables: { fullPath: this.config.fullPath, search },
- })
- .then(({ data }) => data.group?.epics.nodes);
- },
- fetchEpicsBySearchTerm(search) {
- this.loading = true;
- this.fetchEpics(search)
- .then((response) => {
- this.epics = Array.isArray(response) ? response : response?.data;
- })
- .catch(() => createFlash({ message: __('There was a problem fetching epics.') }))
- .finally(() => {
- this.loading = false;
- });
- },
- getActiveEpic(epics, data) {
- if (data && epics.length) {
- return epics.find((epic) => this.getValue(epic) === data);
- }
- return undefined;
- },
- getValue(epic) {
- return this.getEpicIdProperty(epic).toString();
- },
- displayValue(epic) {
- return `${this.$options.prefix}${this.getEpicIdProperty(epic)}${this.$options.separator}${
- epic?.title
- }`;
- },
- getEpicIdProperty(epic) {
- return getIdFromGraphQLId(epic[this.idProperty]);
- },
- },
-};
-</script>
-
-<template>
- <base-token
- :config="config"
- :value="value"
- :active="active"
- :suggestions-loading="loading"
- :suggestions="epics"
- :get-active-token-value="getActiveEpic"
- :default-suggestions="availableDefaultEpics"
- :recent-suggestions-storage-key="config.recentSuggestionsStorageKey"
- search-by="title"
- @fetch-suggestions="fetchEpicsBySearchTerm"
- v-on="$listeners"
- >
- <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
- {{ activeTokenValue ? displayValue(activeTokenValue) : inputValue }}
- </template>
- <template #suggestions-list="{ suggestions }">
- <gl-filtered-search-suggestion
- v-for="epic in suggestions"
- :key="epic.id"
- :value="getValue(epic)"
- >
- {{ epic.title }}
- </gl-filtered-search-suggestion>
- </template>
- </base-token>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue
deleted file mode 100644
index aff93ebc9c0..00000000000
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue
+++ /dev/null
@@ -1,138 +0,0 @@
-<script>
-import { GlDropdownDivider, GlDropdownSectionHeader, GlFilteredSearchSuggestion } from '@gitlab/ui';
-import createFlash from '~/flash';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { __ } from '~/locale';
-import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
-import { formatDate } from '~/lib/utils/datetime_utility';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { DEFAULT_ITERATIONS } from '../constants';
-
-export default {
- components: {
- BaseToken,
- GlDropdownDivider,
- GlDropdownSectionHeader,
- GlFilteredSearchSuggestion,
- },
- mixins: [glFeatureFlagMixin()],
- props: {
- active: {
- type: Boolean,
- required: true,
- },
- config: {
- type: Object,
- required: true,
- },
- value: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- iterations: this.config.initialIterations || [],
- loading: false,
- };
- },
- computed: {
- defaultIterations() {
- return this.config.defaultIterations || DEFAULT_ITERATIONS;
- },
- },
- methods: {
- getActiveIteration(iterations, data) {
- return iterations.find((iteration) => this.getValue(iteration) === data);
- },
- groupIterationsByCadence(iterations) {
- const cadences = [];
- iterations.forEach((iteration) => {
- if (!iteration.iterationCadence) {
- return;
- }
- const { title } = iteration.iterationCadence;
- const cadenceIteration = {
- id: iteration.id,
- title: iteration.title,
- period: this.getIterationPeriod(iteration),
- };
- const cadence = cadences.find((cad) => cad.title === title);
- if (cadence) {
- cadence.iterations.push(cadenceIteration);
- } else {
- cadences.push({ title, iterations: [cadenceIteration] });
- }
- });
- return cadences;
- },
- fetchIterations(searchTerm) {
- this.loading = true;
- this.config
- .fetchIterations(searchTerm)
- .then((response) => {
- this.iterations = Array.isArray(response) ? response : response.data;
- })
- .catch(() => {
- createFlash({ message: __('There was a problem fetching iterations.') });
- })
- .finally(() => {
- this.loading = false;
- });
- },
- getValue(iteration) {
- return String(getIdFromGraphQLId(iteration.id));
- },
- /**
- * TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/344619
- * This method also exists as a utility function in ee/../iterations/utils.js
- * Remove the duplication when iteration token is moved to EE.
- */
- getIterationPeriod({ startDate, dueDate }) {
- const start = formatDate(startDate, 'mmm d, yyyy', true);
- const due = formatDate(dueDate, 'mmm d, yyyy', true);
- return `${start} - ${due}`;
- },
- },
-};
-</script>
-
-<template>
- <base-token
- :active="active"
- :config="config"
- :value="value"
- :default-suggestions="defaultIterations"
- :suggestions="iterations"
- :suggestions-loading="loading"
- :get-active-token-value="getActiveIteration"
- @fetch-suggestions="fetchIterations"
- v-on="$listeners"
- >
- <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
- {{ activeTokenValue ? activeTokenValue.title : inputValue }}
- </template>
- <template #suggestions-list="{ suggestions }">
- <template v-for="(cadence, index) in groupIterationsByCadence(suggestions)">
- <gl-dropdown-divider v-if="index !== 0" :key="index" />
- <gl-dropdown-section-header
- :key="cadence.title"
- class="gl-overflow-hidden"
- :title="cadence.title"
- >
- {{ cadence.title }}
- </gl-dropdown-section-header>
- <gl-filtered-search-suggestion
- v-for="iteration in cadence.iterations"
- :key="iteration.id"
- :value="getValue(iteration)"
- >
- {{ iteration.title }}
- <div v-if="glFeatures.iterationCadences" class="gl-text-gray-400">
- {{ iteration.period }}
- </div>
- </gl-filtered-search-suggestion>
- </template>
- </template>
- </base-token>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
index c31f3a25fb1..3f7a8920f48 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
@@ -104,7 +104,6 @@ export default {
:suggestions="labels"
:get-active-token-value="getActiveLabel"
:default-suggestions="defaultLabels"
- :recent-suggestions-storage-key="config.recentSuggestionsStorageKey"
@fetch-suggestions="fetchLabels"
v-on="$listeners"
>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
index 523438f459c..0d3394788fa 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
@@ -2,7 +2,7 @@
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
import createFlash from '~/flash';
import { __ } from '~/locale';
-import { sortMilestonesByDueDate } from '~/milestones/milestone_utils';
+import { sortMilestonesByDueDate } from '~/milestones/utils';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import { DEFAULT_MILESTONES } from '../constants';
import { stripQuotes } from '../filtered_search_utils';
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue
deleted file mode 100644
index 280fb234576..00000000000
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue
+++ /dev/null
@@ -1,66 +0,0 @@
-<script>
-import { GlFilteredSearchSuggestion } from '@gitlab/ui';
-import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
-import { DEFAULT_NONE_ANY, WEIGHT_TOKEN_SUGGESTIONS_SIZE } from '../constants';
-
-const weights = Array.from(Array(WEIGHT_TOKEN_SUGGESTIONS_SIZE), (_, index) => index.toString());
-
-export default {
- components: {
- BaseToken,
- GlFilteredSearchSuggestion,
- },
- props: {
- active: {
- type: Boolean,
- required: true,
- },
- config: {
- type: Object,
- required: true,
- },
- value: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- weights,
- };
- },
- computed: {
- defaultWeights() {
- return this.config.defaultWeights || DEFAULT_NONE_ANY;
- },
- },
- methods: {
- getActiveWeight(weightSuggestions, data) {
- return weightSuggestions.find((weight) => weight === data);
- },
- updateWeights(searchTerm) {
- const weight = parseInt(searchTerm, 10);
- this.weights = Number.isNaN(weight) ? weights : [String(weight)];
- },
- },
-};
-</script>
-
-<template>
- <base-token
- :active="active"
- :config="config"
- :value="value"
- :default-suggestions="defaultWeights"
- :suggestions="weights"
- :get-active-token-value="getActiveWeight"
- @fetch-suggestions="updateWeights"
- v-on="$listeners"
- >
- <template #suggestions-list="{ suggestions }">
- <gl-filtered-search-suggestion v-for="weight of suggestions" :key="weight" :value="weight">
- {{ weight }}
- </gl-filtered-search-suggestion>
- </template>
- </base-token>
-</template>
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
new file mode 100644
index 00000000000..cdd7a074f34
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js
@@ -0,0 +1,27 @@
+import InputCopyToggleVisibility from './input_copy_toggle_visibility.vue';
+
+export default {
+ component: InputCopyToggleVisibility,
+ title: 'vue_shared/components/form/input_copy_toggle_visibility',
+};
+
+const defaultProps = {
+ value: 'hR8x1fuJbzwu5uFKLf9e',
+ formInputGroupProps: { class: 'gl-form-input-xl' },
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { InputCopyToggleVisibility },
+ props: Object.keys(argTypes),
+ template: `<input-copy-toggle-visibility
+ :value="value"
+ :initial-visibility="initialVisibility"
+ :show-toggle-visibility-button="showToggleVisibilityButton"
+ :show-copy-button="showCopyButton"
+ :form-input-group-props="formInputGroupProps"
+ :copy-button-title="copyButtonTitle"
+ />`,
+});
+
+export const Default = Template.bind({});
+Default.args = defaultProps;
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
new file mode 100644
index 00000000000..06949b59823
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
@@ -0,0 +1,127 @@
+<script>
+import { GlFormInputGroup, GlFormGroup, GlButton, GlTooltipDirective } from '@gitlab/ui';
+
+import { __ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+
+export default {
+ name: 'InputCopyToggleVisibility',
+ i18n: {
+ toggleVisibilityLabelHide: __('Click to hide'),
+ toggleVisibilityLabelReveal: __('Click to reveal'),
+ },
+ components: {
+ GlFormInputGroup,
+ GlFormGroup,
+ GlButton,
+ ClipboardButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ initialVisibility: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showToggleVisibilityButton: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ showCopyButton: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ copyButtonTitle: {
+ type: String,
+ required: false,
+ default: __('Copy'),
+ },
+ formInputGroupProps: {
+ type: Object,
+ required: false,
+ default() {
+ return {};
+ },
+ },
+ },
+ data() {
+ return {
+ valueIsVisible: this.initialVisibility,
+ };
+ },
+ computed: {
+ toggleVisibilityLabel() {
+ return this.valueIsVisible
+ ? this.$options.i18n.toggleVisibilityLabelHide
+ : this.$options.i18n.toggleVisibilityLabelReveal;
+ },
+ toggleVisibilityIcon() {
+ return this.valueIsVisible ? 'eye-slash' : 'eye';
+ },
+ computedValueIsVisible() {
+ return !this.showToggleVisibilityButton || this.valueIsVisible;
+ },
+ displayedValue() {
+ return this.computedValueIsVisible ? this.value : '*'.repeat(this.value.length || 20);
+ },
+ },
+ methods: {
+ handleToggleVisibilityButtonClick() {
+ this.valueIsVisible = !this.valueIsVisible;
+
+ this.$emit('visibility-change', this.valueIsVisible);
+ },
+ handleCopyButtonClick() {
+ this.$emit('copy');
+ },
+ handleFormInputCopy(event) {
+ if (this.computedValueIsVisible) {
+ return;
+ }
+
+ event.clipboardData.setData('text/plain', this.value);
+ event.preventDefault();
+ },
+ },
+};
+</script>
+<template>
+ <gl-form-group v-bind="$attrs">
+ <gl-form-input-group
+ :value="displayedValue"
+ input-class="gl-font-monospace! gl-cursor-default!"
+ select-on-click
+ readonly
+ v-bind="formInputGroupProps"
+ @copy="handleFormInputCopy"
+ >
+ <template v-if="showToggleVisibilityButton || showCopyButton" #append>
+ <gl-button
+ v-if="showToggleVisibilityButton"
+ v-gl-tooltip.hover="toggleVisibilityLabel"
+ :aria-label="toggleVisibilityLabel"
+ :icon="toggleVisibilityIcon"
+ @click="handleToggleVisibilityButtonClick"
+ />
+ <clipboard-button
+ v-if="showCopyButton"
+ :text="value"
+ :title="copyButtonTitle"
+ @click="handleCopyButtonClick"
+ />
+ </template>
+ </gl-form-input-group>
+ <template v-for="slot in Object.keys($slots)" #[slot]>
+ <slot :name="slot"></slot>
+ </template>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
index 6ace0bd88f8..9bff469b670 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -5,6 +5,7 @@ import {
GlSafeHtmlDirective,
GlAvatarLink,
GlAvatarLabeled,
+ GlTooltip,
} from '@gitlab/ui';
import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { glEmojiTag } from '../../emoji';
@@ -26,6 +27,7 @@ export default {
GlButton,
GlAvatarLink,
GlAvatarLabeled,
+ GlTooltip,
},
directives: {
GlTooltip: GlTooltipDirective,
diff --git a/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js b/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js
deleted file mode 100644
index 28aa93d6680..00000000000
--- a/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import Vue from 'vue';
-import { parseBoolean } from '~/lib/utils/common_utils';
-import IssuableHeaderWarnings from './issuable_header_warnings.vue';
-
-export default function issuableHeaderWarnings(store) {
- const el = document.getElementById('js-issuable-header-warnings');
-
- if (!el) {
- return false;
- }
-
- const { hidden } = el.dataset;
-
- return new Vue({
- el,
- store,
- provide: { hidden: parseBoolean(hidden) },
- render(createElement) {
- return createElement(IssuableHeaderWarnings);
- },
- });
-}
diff --git a/app/assets/javascripts/vue_shared/components/line_numbers.vue b/app/assets/javascripts/vue_shared/components/line_numbers.vue
new file mode 100644
index 00000000000..7e17cca3dcc
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/line_numbers.vue
@@ -0,0 +1,57 @@
+<script>
+import { GlIcon, GlLink } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlIcon,
+ GlLink,
+ },
+ props: {
+ lines: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ currentlyHighlightedLine: null,
+ };
+ },
+ mounted() {
+ this.scrollToLine();
+ },
+ methods: {
+ scrollToLine(hash = window.location.hash) {
+ const lineToHighlight = hash && this.$el.querySelector(hash);
+
+ if (!lineToHighlight) {
+ return;
+ }
+
+ if (this.currentlyHighlightedLine) {
+ this.currentlyHighlightedLine.classList.remove('hll');
+ }
+
+ lineToHighlight.classList.add('hll');
+ this.currentlyHighlightedLine = lineToHighlight;
+ lineToHighlight.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ },
+ },
+};
+</script>
+<template>
+ <div class="line-numbers">
+ <gl-link
+ v-for="line in lines"
+ :id="`L${line}`"
+ :key="line"
+ class="diff-line-num"
+ :href="`#L${line}`"
+ :data-line-number="line"
+ @click="scrollToLine(`#L${line}`)"
+ >
+ <gl-icon :size="12" name="link" />
+ {{ line }}
+ </gl-link>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index e36cfb3b275..2f6776f835e 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -165,6 +165,6 @@ export default {
<template>
<div>
<div class="flash-container js-suggestions-flash"></div>
- <div v-show="isRendered" ref="container" v-safe-html="noteHtml" class="md"></div>
+ <div v-show="isRendered" ref="container" v-safe-html="noteHtml" class="md suggestions"></div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index 912aa8ce294..f1c293c87f4 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -1,18 +1,13 @@
<script>
import { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon } from '@gitlab/ui';
-import { isExperimentVariant } from '~/experimentation/utils';
-import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
-import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants';
export default {
- inviteMembersInComment: INVITE_MEMBERS_IN_COMMENT,
components: {
GlButton,
GlLink,
GlLoadingIcon,
GlSprintf,
GlIcon,
- InviteMembersTrigger,
},
props: {
markdownDocsPath: {
@@ -34,9 +29,6 @@ export default {
hasQuickActionsDocsPath() {
return this.quickActionsDocsPath !== '';
},
- inviteCommentEnabled() {
- return isExperimentVariant(INVITE_MEMBERS_IN_COMMENT, 'invite_member_link');
- },
},
};
</script>
@@ -67,16 +59,6 @@ export default {
</template>
</div>
<span v-if="canAttachFile" class="uploading-container">
- <invite-members-trigger
- v-if="inviteCommentEnabled"
- classes="gl-mr-3 gl-vertical-align-text-bottom"
- :display-text="s__('InviteMember|Invite Member')"
- icon="assignee"
- variant="link"
- :track-experiment="$options.inviteMembersInComment"
- :trigger-source="$options.inviteMembersInComment"
- data-track-action="comment_invite_click"
- />
<span class="uploading-progress-container hide">
<gl-icon name="media" />
<span class="attaching-file-message"></span>
diff --git a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue
new file mode 100644
index 00000000000..7d2af7983d1
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue
@@ -0,0 +1,93 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export const i18n = {
+ DEFAULT_TEXT: __('Select a new namespace'),
+ GROUPS: __('Groups'),
+ USERS: __('Users'),
+};
+
+const filterByName = (data, searchTerm = '') =>
+ data.filter((d) => d.humanName.toLowerCase().includes(searchTerm));
+
+export default {
+ name: 'NamespaceSelect',
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlSearchBoxByType,
+ },
+ props: {
+ data: {
+ type: Object,
+ required: true,
+ },
+ fullWidth: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ searchTerm: '',
+ selectedNamespace: null,
+ };
+ },
+ computed: {
+ hasUserNamespaces() {
+ return this.data.user?.length;
+ },
+ hasGroupNamespaces() {
+ return this.data.group?.length;
+ },
+ filteredGroupNamespaces() {
+ if (!this.hasGroupNamespaces) return [];
+ return filterByName(this.data.group, this.searchTerm);
+ },
+ filteredUserNamespaces() {
+ if (!this.hasUserNamespaces) return [];
+ return filterByName(this.data.user, this.searchTerm);
+ },
+ selectedNamespaceText() {
+ return this.selectedNamespace?.humanName || this.$options.i18n.DEFAULT_TEXT;
+ },
+ },
+ methods: {
+ handleSelect(item) {
+ this.selectedNamespace = item;
+ this.$emit('select', item);
+ },
+ },
+ i18n,
+};
+</script>
+<template>
+ <gl-dropdown :text="selectedNamespaceText" :block="fullWidth">
+ <template #header>
+ <gl-search-box-by-type v-model.trim="searchTerm" />
+ </template>
+ <div v-if="hasGroupNamespaces" class="qa-namespaces-list-groups">
+ <gl-dropdown-section-header>{{ $options.i18n.GROUPS }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="item in filteredGroupNamespaces"
+ :key="item.id"
+ class="qa-namespaces-list-item"
+ @click="handleSelect(item)"
+ >{{ item.humanName }}</gl-dropdown-item
+ >
+ </div>
+ <div v-if="hasUserNamespaces" class="qa-namespaces-list-users">
+ <gl-dropdown-section-header>{{ $options.i18n.USERS }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="item in filteredUserNamespaces"
+ :key="item.id"
+ class="qa-namespaces-list-item"
+ @click="handleSelect(item)"
+ >{{ item.humanName }}</gl-dropdown-item
+ >
+ </div>
+ </gl-dropdown>
+</template>
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 9ea14ed506c..624dbcc6d8e 100644
--- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
@@ -39,6 +39,11 @@ export default {
required: false,
default: null,
},
+ isOverviewTab: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
...mapGetters(['getUserData']),
@@ -46,9 +51,10 @@ export default {
return renderMarkdown(this.note.body);
},
avatarSize() {
- if (this.line) {
- return 16;
+ if (this.line && !this.isOverviewTab) {
+ return 24;
}
+
return 40;
},
},
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 8877cfa39fb..1963d1aa7fe 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -141,6 +141,7 @@ export default {
variant="link"
:icon="descriptionVersionToggleIcon"
data-testid="compare-btn"
+ class="gl-vertical-align-text-bottom"
@click="toggleDescriptionVersion"
>{{ __('Compare with previous version') }}</gl-button
>
@@ -149,6 +150,7 @@ export default {
:icon="showLines ? 'chevron-up' : 'chevron-down'"
variant="link"
data-testid="outdated-lines-change-btn"
+ class="gl-vertical-align-text-bottom"
@click="toggleDiff"
>
{{ __('Compare changes') }}
diff --git a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js
new file mode 100644
index 00000000000..e31446f4bb8
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js
@@ -0,0 +1,40 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+import PaginationBar from './pagination_bar.vue';
+
+export default {
+ component: PaginationBar,
+ title: 'vue_shared/components/pagination_bar/pagination_bar',
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { PaginationBar },
+ props: Object.keys(argTypes),
+ template: `<pagination-bar v-bind="$props" v-on="{ 'set-page-size': setPageSize, 'set-page': setPage }" />`,
+});
+
+export const Default = Template.bind({});
+
+Default.args = {
+ pageInfo: {
+ perPage: 20,
+ page: 2,
+ total: 83,
+ totalPages: 5,
+ },
+ pageSizes: [20, 50, 100],
+};
+
+Default.argTypes = {
+ pageInfo: {
+ description: 'Page info object',
+ control: { type: 'object' },
+ },
+ pageSizes: {
+ description: 'Array of possible page sizes',
+ control: { type: 'array' },
+ },
+
+ // events
+ setPageSize: { action: 'set-page-size' },
+ setPage: { action: 'set-page' },
+};
diff --git a/app/assets/javascripts/import_entities/components/pagination_bar.vue b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue
index 33bd3e08bb1..b4d565991f5 100644
--- a/app/assets/javascripts/import_entities/components/pagination_bar.vue
+++ b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue
@@ -23,10 +23,6 @@ export default {
type: Array,
default: () => DEFAULT_PAGE_SIZES,
},
- itemsCount: {
- required: true,
- type: Number,
- },
},
computed: {
@@ -35,9 +31,10 @@ export default {
},
paginationInfo() {
- const { page, perPage } = this.pageInfo;
+ const { page, perPage, totalPages, total } = this.pageInfo;
+ const itemsCount = page === totalPages ? total - (page - 1) * perPage : perPage;
const start = (page - 1) * perPage + 1;
- const end = start + this.itemsCount - 1;
+ const end = start + itemsCount - 1;
return { start, end };
},
@@ -45,8 +42,24 @@ export default {
methods: {
setPage(page) {
+ // eslint-disable-next-line spaced-comment
+ /**
+ * Emitted when selected page is updated
+ *
+ * @event set-page
+ **/
this.$emit('set-page', page);
},
+
+ setPageSize(pageSize) {
+ // eslint-disable-next-line spaced-comment
+ /**
+ * Emitted when page size is updated
+ *
+ * @event set-page-size
+ **/
+ this.$emit('set-page-size', pageSize);
+ },
},
};
</script>
@@ -54,7 +67,7 @@ export default {
<template>
<div class="gl-display-flex gl-align-items-center">
<pagination-links :change="setPage" :page-info="pageInfo" class="gl-m-0" />
- <gl-dropdown category="tertiary" class="gl-ml-auto">
+ <gl-dropdown category="tertiary" class="gl-ml-auto" data-testid="page-size">
<template #button-content>
<span class="gl-font-weight-bold">
<gl-sprintf :message="__('%{count} items per page')">
@@ -65,7 +78,7 @@ export default {
</span>
<gl-icon class="gl-button-icon dropdown-chevron" name="chevron-down" />
</template>
- <gl-dropdown-item v-for="size in pageSizes" :key="size" @click="$emit('set-page-size', size)">
+ <gl-dropdown-item v-for="size in pageSizes" :key="size" @click="setPageSize(size)">
<gl-sprintf :message="__('%{count} items per page')">
<template #count>
{{ size }}
diff --git a/app/assets/javascripts/vue_shared/components/registry/list_item.vue b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
index 933a215112b..6bb321713d5 100644
--- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
@@ -54,10 +54,10 @@ export default {
class="gl-display-flex gl-flex-direction-column gl-border-b-solid gl-border-t-solid gl-border-t-1 gl-border-b-1"
:class="optionalClasses"
>
- <div class="gl-display-flex gl-align-items-center gl-py-3 gl-px-5">
+ <div class="gl-display-flex gl-align-items-center gl-py-3">
<div
v-if="$slots['left-action']"
- class="gl-w-7 gl-display-none gl-sm-display-flex gl-justify-content-start gl-pl-2"
+ class="gl-w-7 gl-display-flex gl-justify-content-start gl-pl-2"
>
<slot name="left-action"></slot>
</div>
@@ -105,7 +105,7 @@ export default {
</div>
<div
v-if="$slots['right-action']"
- class="gl-w-9 gl-display-none gl-sm-display-flex gl-justify-content-end gl-pr-1"
+ class="gl-w-9 gl-display-flex gl-justify-content-end gl-pr-1"
>
<slot name="right-action"></slot>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/registry/metadata_item.vue b/app/assets/javascripts/vue_shared/components/registry/metadata_item.vue
index 93396219a54..4c2816b63b2 100644
--- a/app/assets/javascripts/vue_shared/components/registry/metadata_item.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/metadata_item.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
export default {
name: 'MetadataItem',
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue
deleted file mode 100644
index a1dca65a423..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue
+++ /dev/null
@@ -1,101 +0,0 @@
-<script>
-import { dateInWords, timeFor } from '~/lib/utils/datetime_utility';
-import { __ } from '~/locale';
-import timeagoMixin from '~/vue_shared/mixins/timeago';
-import collapsedCalendarIcon from './collapsed_calendar_icon.vue';
-
-export default {
- name: 'SidebarCollapsedGroupedDatePicker',
- components: {
- collapsedCalendarIcon,
- },
- mixins: [timeagoMixin],
- props: {
- collapsed: {
- type: Boolean,
- required: false,
- default: true,
- },
- minDate: {
- type: Date,
- required: false,
- default: null,
- },
- maxDate: {
- type: Date,
- required: false,
- default: null,
- },
- disableClickableIcons: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- computed: {
- hasMinAndMaxDates() {
- return this.minDate && this.maxDate;
- },
- hasNoMinAndMaxDates() {
- return !this.minDate && !this.maxDate;
- },
- showMinDateBlock() {
- return this.minDate || this.hasNoMinAndMaxDates;
- },
- showFromText() {
- return !this.maxDate && this.minDate;
- },
- iconClass() {
- const disabledClass = this.disableClickableIcons ? 'disabled' : '';
- return `sidebar-collapsed-icon calendar-icon ${disabledClass}`;
- },
- },
- methods: {
- toggleSidebar() {
- this.$emit('toggleCollapse');
- },
- dateText(dateType = 'min') {
- const date = this[`${dateType}Date`];
- const dateWords = dateInWords(date, true);
- const parsedDateWords = dateWords ? dateWords.replace(',', '') : dateWords;
-
- return date ? parsedDateWords : __('None');
- },
- tooltipText(dateType = 'min') {
- const defaultText = dateType === 'min' ? __('Start date') : __('Due date');
- const date = this[`${dateType}Date`];
- const timeAgo = dateType === 'min' ? this.timeFormatted(date) : timeFor(date);
- const dateText = date ? [this.dateText(dateType), `(${timeAgo})`].join(' ') : '';
-
- if (date) {
- return [defaultText, dateText].join('<br />');
- }
- return __('Start and due date');
- },
- },
-};
-</script>
-
-<template>
- <div class="block sidebar-grouped-item gl-cursor-pointer" role="button" @click="toggleSidebar">
- <collapsed-calendar-icon
- v-if="showMinDateBlock"
- :container-class="iconClass"
- :tooltip-text="tooltipText('min')"
- >
- <span class="sidebar-collapsed-value">
- <span v-if="showFromText">{{ __('From') }}</span> <span>{{ dateText('min') }}</span>
- </span>
- </collapsed-calendar-icon>
- <div v-if="hasMinAndMaxDates" class="text-center sidebar-collapsed-divider">-</div>
- <collapsed-calendar-icon
- v-if="maxDate"
- :container-class="iconClass"
- :tooltip-text="tooltipText('max')"
- >
- <span class="sidebar-collapsed-value">
- <span v-if="!minDate">{{ __('Until') }}</span> <span>{{ dateText('max') }}</span>
- </span>
- </collapsed-calendar-icon>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
index 4234bc72f3a..7e259cb8b96 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
@@ -179,6 +179,8 @@ export default {
document.addEventListener('mousedown', this.handleDocumentMousedown);
document.addEventListener('click', this.handleDocumentClick);
+
+ this.updateLabelsSetState();
},
beforeDestroy() {
document.removeEventListener('mousedown', this.handleDocumentMousedown);
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
index f7485de0342..13a6dd43207 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
@@ -172,6 +172,13 @@ export default {
showDropdown() {
this.$refs.dropdown.show();
},
+ clearSearch() {
+ if (!this.allowMultiselect || this.isStandalone) {
+ return;
+ }
+ this.searchKey = '';
+ this.setFocus();
+ },
},
};
</script>
@@ -188,12 +195,12 @@ export default {
>
<template #header>
<dropdown-header
- v-if="!isStandalone"
ref="header"
- v-model="searchKey"
+ :search-key="searchKey"
:labels-create-title="labelsCreateTitle"
:labels-list-title="labelsListTitle"
:show-dropdown-contents-create-view="showDropdownContentsCreateView"
+ :is-standalone="isStandalone"
@toggleDropdownContentsCreateView="toggleDropdownContent"
@closeDropdown="$emit('closeDropdown')"
@input="debouncedSearchKeyUpdate"
@@ -210,6 +217,7 @@ export default {
:attr-workspace-path="attrWorkspacePath"
:label-create-type="labelCreateType"
@hideCreateView="toggleDropdownContent"
+ @input="clearSearch"
/>
</template>
<template #footer>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue
index 10064b01648..7a0f20b0c83 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue
@@ -6,9 +6,6 @@ export default {
GlButton,
GlSearchBoxByType,
},
- model: {
- prop: 'searchKey',
- },
props: {
labelsCreateTitle: {
type: String,
@@ -31,6 +28,11 @@ export default {
type: String,
required: true,
},
+ isStandalone: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
dropdownTitle() {
@@ -47,7 +49,11 @@ export default {
<template>
<div data-testid="dropdown-header">
- <div class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!">
+ <div
+ v-if="!isStandalone"
+ class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
+ data-testid="dropdown-header-title"
+ >
<gl-button
v-if="showDropdownContentsCreateView"
:aria-label="__('Go back')"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue
index aed5bc303ee..57ee816c4c7 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue
@@ -1,10 +1,15 @@
<script>
-import { GlLabel } from '@gitlab/ui';
+import { GlIcon, GlLabel, GlTooltipDirective } from '@gitlab/ui';
import { sortBy } from 'lodash';
import { isScopedLabel } from '~/lib/utils/common_utils';
+import { s__, sprintf } from '~/locale';
export default {
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
components: {
+ GlIcon,
GlLabel,
},
inject: ['allowScopedLabels'],
@@ -35,6 +40,23 @@ export default {
sortedSelectedLabels() {
return sortBy(this.selectedLabels, (label) => (isScopedLabel(label) ? 0 : 1));
},
+ labelsList() {
+ const labelsString = this.selectedLabels.length
+ ? this.selectedLabels
+ .slice(0, 5)
+ .map((label) => label.title)
+ .join(', ')
+ : s__('LabelSelect|Labels');
+
+ if (this.selectedLabels.length > 5) {
+ return sprintf(s__('LabelSelect|%{labelsString}, and %{remainingLabelCount} more'), {
+ labelsString,
+ remainingLabelCount: this.selectedLabels.length - 5,
+ });
+ }
+
+ return labelsString;
+ },
},
methods: {
labelFilterUrl(label) {
@@ -48,6 +70,9 @@ export default {
removeLabel(labelId) {
this.$emit('onLabelRemove', labelId);
},
+ handleCollapsedClick() {
+ this.$emit('onCollapsedValueClick');
+ },
},
};
</script>
@@ -57,16 +82,30 @@ export default {
:class="{
'has-labels': selectedLabels.length,
}"
- class="hide-collapsed value issuable-show-labels js-value"
+ class="value issuable-show-labels js-value"
data-testid="value-wrapper"
>
- <span v-if="!selectedLabels.length" class="text-secondary" data-testid="empty-placeholder">
+ <div
+ v-gl-tooltip.left.viewport
+ :title="labelsList"
+ class="sidebar-collapsed-icon"
+ @click="handleCollapsedClick"
+ >
+ <gl-icon name="labels" />
+ <span class="gl-font-base gl-line-height-24">{{ selectedLabels.length }}</span>
+ </div>
+ <span
+ v-if="!selectedLabels.length"
+ class="text-secondary hide-collapsed"
+ data-testid="empty-placeholder"
+ >
<slot></slot>
</span>
<template v-else>
<gl-label
v-for="label in sortedSelectedLabels"
:key="label.id"
+ class="hide-collapsed"
data-qa-selector="selected_label_content"
:data-qa-label-name="label.title"
:title="label.title"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue
deleted file mode 100644
index 122250d1ce7..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue
+++ /dev/null
@@ -1,55 +0,0 @@
-<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { s__, sprintf } from '~/locale';
-
-export default {
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- components: {
- GlIcon,
- },
- props: {
- labels: {
- type: Array,
- required: true,
- },
- },
- computed: {
- labelsList() {
- const labelsString = this.labels.length
- ? this.labels
- .slice(0, 5)
- .map((label) => label.title)
- .join(', ')
- : s__('LabelSelect|Labels');
-
- if (this.labels.length > 5) {
- return sprintf(s__('LabelSelect|%{labelsString}, and %{remainingLabelCount} more'), {
- labelsString,
- remainingLabelCount: this.labels.length - 5,
- });
- }
-
- return labelsString;
- },
- },
- methods: {
- handleClick() {
- this.$emit('onValueClick');
- },
- },
-};
-</script>
-
-<template>
- <div
- v-gl-tooltip.left.viewport
- :title="labelsList"
- class="sidebar-collapsed-icon"
- @click="handleClick"
- >
- <gl-icon name="labels" />
- <span>{{ labels.length }}</span>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql
index c130cc426dc..c442c17eb88 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql
@@ -2,6 +2,7 @@
query epicLabels($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
+ id
issuable: epic(iid: $iid) {
id
labels {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql
index 45fcb50732e..cb054e2968f 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql
@@ -1,8 +1,8 @@
#import "~/graphql_shared/fragments/label.fragment.graphql"
mutation updateEpicLabels($input: UpdateEpicInput!) {
- updateEpic(input: $input) {
- epic {
+ updateIssuableLabels: updateEpic(input: $input) {
+ issuable: epic {
id
labels {
nodes {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql
index e471d279b24..2904857270e 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql
@@ -2,6 +2,7 @@
query issueLabels($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
+ id
issuable: issue(iid: $iid) {
id
labels {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql
index dd80e89c8a7..e0cdfd91658 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql
@@ -2,6 +2,7 @@
query mergeRequestLabels($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
+ id
issuable: mergeRequest(iid: $iid) {
id
labels {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
index 97a65c13933..3adda69b892 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
@@ -2,14 +2,13 @@
import { debounce } from 'lodash';
import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils';
import createFlash from '~/flash';
-import { IssuableType } from '~/issue_show/constants';
+import { IssuableType } from '~/issues/constants';
import { __ } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { issuableLabelsQueries } from '~/sidebar/constants';
import { DEBOUNCE_DROPDOWN_DELAY, DropdownVariant } from './constants';
import DropdownContents from './dropdown_contents.vue';
import DropdownValue from './dropdown_value.vue';
-import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
import {
isDropdownVariantSidebar,
isDropdownVariantStandalone,
@@ -20,7 +19,6 @@ export default {
components: {
DropdownValue,
DropdownContents,
- DropdownValueCollapsed,
SidebarEditableItem,
},
inject: {
@@ -225,15 +223,13 @@ export default {
variables: { input: inputVariables },
})
.then(({ data }) => {
- const { mutationName } = issuableLabelsQueries[this.issuableType];
-
- if (data[mutationName]?.errors?.length) {
+ if (data.updateIssuableLabels?.errors?.length) {
throw new Error();
}
this.$emit('updateSelectedLabels', {
- id: data[mutationName]?.[this.issuableType]?.id,
- labels: data[mutationName]?.[this.issuableType]?.labels?.nodes,
+ id: data.updateIssuableLabels?.issuable?.id,
+ labels: data.updateIssuableLabels?.issuable?.labels?.nodes,
});
})
.catch((error) =>
@@ -288,18 +284,14 @@ export default {
<template>
<div
- class="labels-select-wrapper position-relative"
+ class="labels-select-wrapper gl-relative"
:class="{
'is-standalone': isDropdownVariantStandalone(variant),
'is-embedded': isDropdownVariantEmbedded(variant),
}"
+ data-qa-selector="labels_block"
>
<template v-if="isDropdownVariantSidebar(variant)">
- <dropdown-value-collapsed
- ref="dropdownButtonCollapsed"
- :labels="issuableLabels"
- @onValueClick="handleCollapsedValueClick"
- />
<sidebar-editable-item
ref="editable"
:title="__('Labels')"
@@ -315,6 +307,7 @@ export default {
:labels-filter-base-path="labelsFilterBasePath"
:labels-filter-param="labelsFilterParam"
@onLabelRemove="handleLabelRemove"
+ @onCollapsedValueClick="handleCollapsedValueClick"
>
<slot></slot>
</dropdown-value>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql
index d99fc125012..bb6c7181e5c 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql
@@ -7,6 +7,7 @@ query alertAssignees(
$iid: String!
) {
workspace: project(fullPath: $fullPath) {
+ id
issuable: alertManagementAlert(domain: $domain, iid: $iid) {
iid
assignees {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql
index 93b9833bb7d..be270e440ed 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql
@@ -4,6 +4,7 @@
query issueAssignees($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
__typename
+ id
issuable: issue(iid: $iid) {
__typename
id
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
index 48787305459..96a40e597ee 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
@@ -4,6 +4,7 @@
query issueParticipants($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
__typename
+ id
issuable: issue(iid: $iid) {
__typename
id
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
index 53f7381760e..81e19e48d75 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
@@ -3,6 +3,7 @@
query getMrAssignees($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
+ id
issuable: mergeRequest(iid: $iid) {
id
assignees {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
index 6adbd4098f2..3496d5f4a2e 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
@@ -3,6 +3,7 @@
query getMrParticipants($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
+ id
issuable: mergeRequest(iid: $iid) {
id
participants {
diff --git a/app/assets/javascripts/vue_shared/components/source_editor.vue b/app/assets/javascripts/vue_shared/components/source_editor.vue
index fdf0c9baee3..8a0fef36079 100644
--- a/app/assets/javascripts/vue_shared/components/source_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/source_editor.vue
@@ -96,6 +96,7 @@ export default {
:id="`source-editor-${fileGlobalId}`"
ref="editor"
data-editor-loading
+ data-qa-selector="source_editor_container"
@[$options.readyEvent]="$emit($options.readyEvent)"
>
<pre class="editor-loading-content">{{ value }}</pre>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer.vue
new file mode 100644
index 00000000000..8f0d051543f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer.vue
@@ -0,0 +1,88 @@
+<script>
+import { GlSafeHtmlDirective } from '@gitlab/ui';
+import LineNumbers from '~/vue_shared/components/line_numbers.vue';
+
+export default {
+ components: {
+ LineNumbers,
+ },
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ props: {
+ content: {
+ type: String,
+ required: true,
+ },
+ language: {
+ type: String,
+ required: false,
+ default: 'plaintext',
+ },
+ autoDetect: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ languageDefinition: null,
+ hljs: null,
+ };
+ },
+ computed: {
+ lineNumbers() {
+ return this.content.split('\n').length;
+ },
+ highlightedContent() {
+ let highlightedContent;
+
+ if (this.hljs) {
+ if (this.autoDetect) {
+ highlightedContent = this.hljs.highlightAuto(this.content).value;
+ } else if (this.languageDefinition) {
+ highlightedContent = this.hljs.highlight(this.content, { language: this.language }).value;
+ }
+ }
+
+ return highlightedContent;
+ },
+ },
+ async mounted() {
+ this.hljs = await this.loadHighlightJS();
+
+ if (!this.autoDetect) {
+ this.languageDefinition = await this.loadLanguage();
+ }
+ },
+ methods: {
+ loadHighlightJS() {
+ // With auto-detect enabled we load all common languages else we load only the core (smallest footprint)
+ return this.autoDetect ? import('highlight.js/lib/common') : import('highlight.js/lib/core');
+ },
+ async loadLanguage() {
+ let languageDefinition;
+
+ try {
+ languageDefinition = await import(`highlight.js/lib/languages/${this.language}`);
+ this.hljs.registerLanguage(this.language, languageDefinition.default);
+ } catch (message) {
+ this.$emit('error', message);
+ }
+
+ return languageDefinition;
+ },
+ },
+ userColorScheme: window.gon.user_color_scheme,
+};
+</script>
+<template>
+ <div class="file-content code" :class="$options.userColorScheme">
+ <line-numbers :lines="lineNumbers" />
+ <pre
+ class="code gl-pl-3!"
+ ><code v-safe-html="highlightedContent" class="gl-white-space-pre-wrap!"></code>
+ </pre>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.stories.js b/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.stories.js
deleted file mode 100644
index 00aa5519ec6..00000000000
--- a/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.stories.js
+++ /dev/null
@@ -1,38 +0,0 @@
-/* eslint-disable @gitlab/require-i18n-strings */
-import '@gitlab/ui/dist/utility_classes.css';
-import UsageGraph from './usage_graph.vue';
-
-export default {
- component: UsageGraph,
- title: 'vue_shared/components/storage_counter/usage_graph',
-};
-
-const Template = (args, { argTypes }) => ({
- components: { UsageGraph },
- props: Object.keys(argTypes),
- template: '<usage-graph v-bind="$props" />',
-});
-
-export const Default = Template.bind({});
-Default.argTypes = {
- rootStorageStatistics: {
- description: 'The statistics object with all its fields',
- type: { name: 'object', required: true },
- defaultValue: {
- buildArtifactsSize: 400000,
- pipelineArtifactsSize: 38000,
- lfsObjectsSize: 4800000,
- packagesSize: 3800000,
- repositorySize: 39000000,
- snippetsSize: 2000112,
- storageSize: 39930000,
- uploadsSize: 7000,
- wikiSize: 300000,
- },
- },
- limit: {
- description:
- 'When a limit is set, users will see how much of their storage usage (limit) is used. In case the limit is 0 or the current usage exceeds the limit, it just renders the distribution',
- defaultValue: 0,
- },
-};
diff --git a/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.vue b/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.vue
deleted file mode 100644
index c33d065ff4b..00000000000
--- a/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.vue
+++ /dev/null
@@ -1,148 +0,0 @@
-<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { numberToHumanSize } from '~/lib/utils/number_utils';
-import { s__ } from '~/locale';
-
-export default {
- components: {
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- rootStorageStatistics: {
- required: true,
- type: Object,
- },
- limit: {
- required: true,
- type: Number,
- },
- },
- computed: {
- storageTypes() {
- const {
- buildArtifactsSize,
- pipelineArtifactsSize,
- lfsObjectsSize,
- packagesSize,
- repositorySize,
- storageSize,
- wikiSize,
- snippetsSize,
- uploadsSize,
- } = this.rootStorageStatistics;
- const artifactsSize = buildArtifactsSize + pipelineArtifactsSize;
-
- if (storageSize === 0) {
- return null;
- }
-
- return [
- {
- name: s__('UsageQuota|Repositories'),
- style: this.usageStyle(this.barRatio(repositorySize)),
- class: 'gl-bg-data-viz-blue-500',
- size: repositorySize,
- },
- {
- name: s__('UsageQuota|LFS Objects'),
- style: this.usageStyle(this.barRatio(lfsObjectsSize)),
- class: 'gl-bg-data-viz-orange-600',
- size: lfsObjectsSize,
- },
- {
- name: s__('UsageQuota|Packages'),
- style: this.usageStyle(this.barRatio(packagesSize)),
- class: 'gl-bg-data-viz-aqua-500',
- size: packagesSize,
- },
- {
- name: s__('UsageQuota|Artifacts'),
- style: this.usageStyle(this.barRatio(artifactsSize)),
- class: 'gl-bg-data-viz-green-600',
- size: artifactsSize,
- tooltip: s__('UsageQuota|Artifacts is a sum of build and pipeline artifacts.'),
- },
- {
- name: s__('UsageQuota|Wikis'),
- style: this.usageStyle(this.barRatio(wikiSize)),
- class: 'gl-bg-data-viz-magenta-500',
- size: wikiSize,
- },
- {
- name: s__('UsageQuota|Snippets'),
- style: this.usageStyle(this.barRatio(snippetsSize)),
- class: 'gl-bg-data-viz-orange-800',
- size: snippetsSize,
- },
- {
- name: s__('UsageQuota|Uploads'),
- style: this.usageStyle(this.barRatio(uploadsSize)),
- class: 'gl-bg-data-viz-aqua-700',
- size: uploadsSize,
- },
- ]
- .filter((data) => data.size !== 0)
- .sort((a, b) => b.size - a.size);
- },
- },
- methods: {
- formatSize(size) {
- return numberToHumanSize(size);
- },
- usageStyle(ratio) {
- return { flex: ratio };
- },
- barRatio(size) {
- let max = this.rootStorageStatistics.storageSize;
-
- if (this.limit !== 0 && max <= this.limit) {
- max = this.limit;
- }
-
- return size / max;
- },
- },
-};
-</script>
-<template>
- <div v-if="storageTypes" class="gl-display-flex gl-flex-direction-column w-100">
- <div class="gl-h-6 gl-my-5 gl-bg-gray-50 gl-rounded-base gl-display-flex">
- <div
- v-for="storageType in storageTypes"
- :key="storageType.name"
- class="storage-type-usage gl-h-full gl-display-inline-block"
- :class="storageType.class"
- :style="storageType.style"
- data-testid="storage-type-usage"
- ></div>
- </div>
- <div class="row py-0">
- <div
- v-for="storageType in storageTypes"
- :key="storageType.name"
- class="col-md-auto gl-display-flex gl-align-items-center"
- data-testid="storage-type-legend"
- >
- <div class="gl-h-2 gl-w-5 gl-mr-2 gl-display-inline-block" :class="storageType.class"></div>
- <span class="gl-mr-2 gl-font-weight-bold gl-font-sm">
- {{ storageType.name }}
- </span>
- <span class="gl-text-gray-500 gl-font-sm">
- {{ formatSize(storageType.size) }}
- </span>
- <span
- v-if="storageType.tooltip"
- v-gl-tooltip
- :title="storageType.tooltip"
- :aria-label="storageType.tooltip"
- class="gl-ml-2"
- >
- <gl-icon name="question" :size="12" />
- </span>
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue
deleted file mode 100644
index c5fdb5fc242..00000000000
--- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue
+++ /dev/null
@@ -1,69 +0,0 @@
-<script>
-import { GlTooltipDirective as GlTooltip } from '@gitlab/ui';
-import { isFunction } from 'lodash';
-import { hasHorizontalOverflow } from '~/lib/utils/dom_utils';
-
-export default {
- directives: {
- GlTooltip,
- },
- props: {
- title: {
- type: String,
- required: false,
- default: '',
- },
- placement: {
- type: String,
- required: false,
- default: 'top',
- },
- truncateTarget: {
- type: [String, Function],
- required: false,
- default: '',
- },
- },
- data() {
- return {
- showTooltip: false,
- };
- },
- watch: {
- title() {
- // Wait on $nextTick in case of slot width changes
- this.$nextTick(this.updateTooltip);
- },
- },
- mounted() {
- this.updateTooltip();
- },
- methods: {
- selectTarget() {
- if (isFunction(this.truncateTarget)) {
- return this.truncateTarget(this.$el);
- } else if (this.truncateTarget === 'child') {
- return this.$el.childNodes[0];
- }
-
- return this.$el;
- },
- updateTooltip() {
- const target = this.selectTarget();
- this.showTooltip = hasHorizontalOverflow(target);
- },
- },
-};
-</script>
-
-<template>
- <span
- v-if="showTooltip"
- v-gl-tooltip="{ placement }"
- :title="title"
- class="js-show-tooltip gl-min-w-0"
- >
- <slot></slot>
- </span>
- <span v-else class="gl-min-w-0"> <slot></slot> </span>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js
new file mode 100644
index 00000000000..f27901a30a9
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js
@@ -0,0 +1,88 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+import TooltipOnTruncate from './tooltip_on_truncate.vue';
+
+const defaultWidth = '250px';
+
+export default {
+ component: TooltipOnTruncate,
+ title: 'vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue',
+};
+
+const createStory = ({ ...options }) => {
+ return (_, { argTypes }) => {
+ const comp = {
+ components: { TooltipOnTruncate },
+ props: Object.keys(argTypes),
+ template: `
+ <div class="gl-bg-blue-50" :style="{ width }">
+ <tooltip-on-truncate :title="title" :placement="placement" class="gl-display-block gl-text-truncate">
+ {{title}}
+ </tooltip-on-truncate>
+ </div>
+ `,
+ ...options,
+ };
+
+ return comp;
+ };
+};
+
+export const Default = createStory();
+Default.args = {
+ width: defaultWidth,
+ title: 'Hover on this text to see the content in a tooltip.',
+};
+
+export const NoOverflow = createStory();
+NoOverflow.args = {
+ width: defaultWidth,
+ title: "Short text doesn't need a tooltip.",
+};
+
+export const Placement = createStory();
+Placement.args = {
+ width: defaultWidth,
+ title: 'Use `placement="right"` to display this tooltip at the right.',
+ placement: 'right',
+};
+
+const TIMEOUT_S = 3;
+
+export const LiveUpdates = createStory({
+ props: ['width', 'placement'],
+ data() {
+ return {
+ title: `(loading in ${TIMEOUT_S}s)`,
+ };
+ },
+ mounted() {
+ setTimeout(() => {
+ this.title = 'Content updated! The content is now overflowing so we use a tooltip!';
+ }, TIMEOUT_S * 1000);
+ },
+});
+LiveUpdates.args = {
+ width: defaultWidth,
+};
+LiveUpdates.argTypes = {
+ title: {
+ control: false,
+ },
+};
+
+export const TruncateTarget = createStory({
+ template: `
+ <div class="gl-bg-black" :style="{ width }">
+ <tooltip-on-truncate class="gl-display-flex" :truncate-target="truncateTarget" :title="title">
+ <div class="gl-m-5 gl-bg-blue-50 gl-text-truncate">
+ {{ title }}
+ </div>
+ </tooltip-on-truncate>
+ </div>
+ `,
+});
+TruncateTarget.args = {
+ width: defaultWidth,
+ truncateTarget: 'child',
+ title: 'Wrap in container and use `truncate-target="child"` prop.',
+};
diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue
new file mode 100644
index 00000000000..09414e679bb
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue
@@ -0,0 +1,85 @@
+<script>
+import { GlTooltipDirective, GlResizeObserverDirective } from '@gitlab/ui';
+import { isFunction, debounce } from 'lodash';
+import { hasHorizontalOverflow } from '~/lib/utils/dom_utils';
+
+const UPDATE_TOOLTIP_DEBOUNCED_WAIT_MS = 300;
+
+export default {
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ GlResizeObserver: GlResizeObserverDirective,
+ },
+ props: {
+ title: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ placement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ truncateTarget: {
+ type: [String, Function],
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ tooltipDisabled: true,
+ };
+ },
+ computed: {
+ classes() {
+ if (this.tooltipDisabled) {
+ return '';
+ }
+ return 'js-show-tooltip';
+ },
+ tooltip() {
+ return {
+ title: this.title,
+ placement: this.placement,
+ disabled: this.tooltipDisabled,
+ };
+ },
+ },
+ watch: {
+ title() {
+ // Wait on $nextTick in case the slot width changes
+ this.$nextTick(this.updateTooltip);
+ },
+ },
+ created() {
+ this.updateTooltipDebounced = debounce(this.updateTooltip, UPDATE_TOOLTIP_DEBOUNCED_WAIT_MS);
+ },
+ mounted() {
+ this.updateTooltip();
+ },
+ methods: {
+ selectTarget() {
+ if (isFunction(this.truncateTarget)) {
+ return this.truncateTarget(this.$el);
+ } else if (this.truncateTarget === 'child') {
+ return this.$el.childNodes[0];
+ }
+ return this.$el;
+ },
+ updateTooltip() {
+ this.tooltipDisabled = !hasHorizontalOverflow(this.selectTarget());
+ },
+ onResize() {
+ this.updateTooltipDebounced();
+ },
+ },
+};
+</script>
+
+<template>
+ <span v-gl-tooltip="tooltip" v-gl-resize-observer="onResize" :class="classes" class="gl-min-w-0">
+ <slot></slot>
+ </span>
+</template>
diff --git a/app/assets/javascripts/issuable_create/components/issuable_create_root.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue
index f4cbaba9313..f4cbaba9313 100644
--- a/app/assets/javascripts/issuable_create/components/issuable_create_root.vue
+++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue
diff --git a/app/assets/javascripts/issuable_create/components/issuable_form.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue
index c216a05bdb0..c216a05bdb0 100644
--- a/app/assets/javascripts/issuable_create/components/issuable_form.vue
+++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue
diff --git a/app/assets/javascripts/issuable_list/components/issuable_bulk_edit_sidebar.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue
index 5ca9e50d854..5ca9e50d854 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_bulk_edit_sidebar.vue
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue
diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
index ab04c6a38a5..0bb0e0d9fb0 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_item.vue
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
@@ -6,7 +6,7 @@ import { isScopedLabel } from '~/lib/utils/common_utils';
import { differenceInSeconds, getTimeago, SECONDS_IN_DAY } from '~/lib/utils/datetime_utility';
import { isExternal, setUrlFragment } from '~/lib/utils/url_utility';
import { __, n__, sprintf } from '~/locale';
-import IssuableAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
+import IssuableAssignees from '~/issuable/components/issue_assignees.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
@@ -166,6 +166,7 @@ export default {
class="issue gl-display-flex! gl-px-5!"
:class="{ closed: issuable.closedAt, today: createdInPastDay }"
:data-labels="labelIdsString"
+ :data-qa-issue-id="issuableId"
>
<gl-form-checkbox
v-if="showCheckbox"
@@ -185,6 +186,13 @@ export default {
:title="__('Confidential')"
:aria-label="__('Confidential')"
/>
+ <gl-icon
+ v-if="issuable.hidden"
+ v-gl-tooltip
+ name="spam"
+ :title="__('This issue is hidden because its author has been banned')"
+ :aria-label="__('Hidden')"
+ />
<gl-link class="issue-title-text" dir="auto" :href="webUrl" v-bind="issuableTitleProps">
{{ issuable.title }}
<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" />
@@ -202,7 +210,7 @@ export default {
<span v-else data-testid="issuable-reference" class="issuable-reference">
{{ reference }}
</span>
- <span class="gl-display-none gl-sm-display-inline-block">
+ <span class="gl-display-none gl-sm-display-inline">
<span aria-hidden="true">&middot;</span>
<span class="issuable-authored gl-mr-3">
<gl-sprintf :message="__('created %{timeAgo} by %{author}')">
diff --git a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
index c1082987146..2f8401b45f0 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
@@ -1,5 +1,5 @@
<script>
-import { GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui';
+import { GlAlert, GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
@@ -19,6 +19,7 @@ export default {
tag: 'ul',
},
components: {
+ GlAlert,
GlKeysetPagination,
GlSkeletonLoading,
IssuableTabs,
@@ -156,6 +157,11 @@ export default {
required: false,
default: false,
},
+ error: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -272,10 +278,12 @@ export default {
:show-checkbox="showBulkEditSidebar"
:checkbox-checked="allIssuablesChecked"
class="gl-flex-grow-1 gl-border-t-none row-content-block"
+ data-qa-selector="issuable_search_container"
@checked-input="handleAllIssuablesCheckedInput"
@onFilter="$emit('filter', $event)"
@onSort="$emit('sort', $event)"
/>
+ <gl-alert v-if="error" variant="danger" @dismiss="$emit('dismiss-alert')">{{ error }}</gl-alert>
<issuable-bulk-edit-sidebar :expanded="showBulkEditSidebar">
<template #bulk-edit-actions>
<slot name="bulk-edit-actions" :checked-issuables="bulkEditIssuables"></slot>
@@ -302,6 +310,8 @@ export default {
v-for="issuable in issuables"
:key="issuableId(issuable)"
:class="{ 'gl-cursor-grab': isManualOrdering }"
+ data-qa-selector="issuable_container"
+ :data-qa-issuable-title="issuable.title"
:issuable-symbol="issuableSymbol"
:issuable="issuable"
:enable-label-permalinks="enableLabelPermalinks"
diff --git a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue
index 96b07031a11..3ff87ba3c4f 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue
@@ -46,7 +46,9 @@ export default {
@click="$emit('click', tab.name)"
>
<template #title>
- <span :title="tab.titleTooltip">{{ tab.title }}</span>
+ <span :title="tab.titleTooltip" :data-qa-selector="`${tab.name}_issuables_tab`">
+ {{ tab.title }}
+ </span>
<gl-badge
v-if="tabCounts && isTabCountNumeric(tab)"
variant="muted"
diff --git a/app/assets/javascripts/issuable_list/constants.js b/app/assets/javascripts/vue_shared/issuable/list/constants.js
index 773ad0f8e93..773ad0f8e93 100644
--- a/app/assets/javascripts/issuable_list/constants.js
+++ b/app/assets/javascripts/vue_shared/issuable/list/constants.js
diff --git a/app/assets/javascripts/issuable_show/components/issuable_body.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue
index 05dc1650379..05dc1650379 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_body.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue
diff --git a/app/assets/javascripts/issuable_show/components/issuable_description.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue
index f57b5b2deb4..f57b5b2deb4 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_description.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue
diff --git a/app/assets/javascripts/issuable_show/components/issuable_discussion.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_discussion.vue
index 5858af6cc51..5858af6cc51 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_discussion.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_discussion.vue
diff --git a/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue
index 33dca3e9332..33dca3e9332 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue
diff --git a/app/assets/javascripts/issuable_show/components/issuable_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
index d7da533d055..d7da533d055 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_header.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
diff --git a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue
index 011db52cbe3..8849af2a52e 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue
@@ -1,5 +1,5 @@
<script>
-import IssuableSidebar from '~/issuable_sidebar/components/issuable_sidebar_root.vue';
+import IssuableSidebar from '~/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue';
import IssuableBody from './issuable_body.vue';
import IssuableDiscussion from './issuable_discussion.vue';
@@ -100,7 +100,7 @@ export default {
</script>
<template>
- <div class="issuable-show-container">
+ <div class="issuable-show-container" data-qa-selector="issuable_show_container">
<issuable-header
:status-badge-class="statusBadgeClass"
:status-icon="statusIcon"
diff --git a/app/assets/javascripts/issuable_show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
index b96ce0c43f7..b96ce0c43f7 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_title.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
diff --git a/app/assets/javascripts/issuable_show/constants.js b/app/assets/javascripts/vue_shared/issuable/show/constants.js
index 346f45c7d90..346f45c7d90 100644
--- a/app/assets/javascripts/issuable_show/constants.js
+++ b/app/assets/javascripts/vue_shared/issuable/show/constants.js
diff --git a/app/assets/javascripts/pages/projects/labels/event_hub.js b/app/assets/javascripts/vue_shared/issuable/show/event_hub.js
index e31806ad199..e31806ad199 100644
--- a/app/assets/javascripts/pages/projects/labels/event_hub.js
+++ b/app/assets/javascripts/vue_shared/issuable/show/event_hub.js
diff --git a/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue b/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue
index 99dcccd12ed..99dcccd12ed 100644
--- a/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue
+++ b/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue
diff --git a/app/assets/javascripts/issuable_sidebar/constants.js b/app/assets/javascripts/vue_shared/issuable/sidebar/constants.js
index 4f4b6341a1c..4f4b6341a1c 100644
--- a/app/assets/javascripts/issuable_sidebar/constants.js
+++ b/app/assets/javascripts/vue_shared/issuable/sidebar/constants.js
diff --git a/app/assets/javascripts/vue_shared/mixins/issuable.js b/app/assets/javascripts/vue_shared/mixins/issuable.js
deleted file mode 100644
index fab0919d96e..00000000000
--- a/app/assets/javascripts/vue_shared/mixins/issuable.js
+++ /dev/null
@@ -1,14 +0,0 @@
-export default {
- props: {
- issuableType: {
- required: true,
- type: String,
- },
- },
-
- computed: {
- issuableDisplayName() {
- return this.issuableType.replace(/_/g, ' ');
- },
- },
-};
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 42272c222fc..d1630c9ac13 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
@@ -85,7 +85,7 @@ export default {
);
},
i18n: {
- buttonLabel: s__('SecurityConfiguration|Configure via Merge Request'),
+ buttonLabel: s__('SecurityConfiguration|Configure with a merge request'),
noSuccessPathError: s__(
'SecurityConfiguration|%{featureName} merge request creation mutation failed',
),
diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql
index ae77a2ce5e4..829b9d9f9d8 100644
--- a/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql
+++ b/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql
@@ -1,6 +1,8 @@
fragment JobArtifacts on Pipeline {
+ id
jobs(securityReportTypes: $reportTypes) {
nodes {
+ id
name
artifacts {
nodes {
diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql
index 4ce13827da2..2e80db30e9a 100644
--- a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql
+++ b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql
@@ -4,11 +4,14 @@ query securityReportDownloadPaths(
$reportTypes: [SecurityReportTypeEnum!]
) {
project(fullPath: $projectPath) {
+ id
mergeRequest(iid: $iid) {
+ id
headPipeline {
id
jobs(securityReportTypes: $reportTypes) {
nodes {
+ id
name
artifacts {
nodes {
diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql
index e1f3c55a886..e4f0c392b91 100644
--- a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql
+++ b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql
@@ -2,8 +2,8 @@
query getPipelineCorpuses($projectPath: ID!, $iid: ID, $reportTypes: [SecurityReportTypeEnum!]) {
project(fullPath: $projectPath) {
+ id
pipeline(iid: $iid) {
- id
...JobArtifacts
}
}
diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue
new file mode 100644
index 00000000000..5e9e50a94f0
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/item_title.vue
@@ -0,0 +1,71 @@
+<script>
+import { escape } from 'lodash';
+import { __ } from '~/locale';
+
+export default {
+ props: {
+ initialTitle: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ placeholder: {
+ type: String,
+ required: false,
+ default: __('Add a title...'),
+ },
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ title: this.initialTitle,
+ };
+ },
+ methods: {
+ getSanitizedTitle(inputEl) {
+ const { innerText } = inputEl;
+ return escape(innerText);
+ },
+ handleBlur({ target }) {
+ this.$emit('title-changed', this.getSanitizedTitle(target));
+ },
+ handleInput({ target }) {
+ this.$emit('title-input', this.getSanitizedTitle(target));
+ },
+ handleSubmit() {
+ this.$refs.titleEl.blur();
+ },
+ },
+};
+</script>
+
+<template>
+ <h2
+ class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5 gl-display-inline-block"
+ :class="{ 'gl-cursor-not-allowed': disabled }"
+ data-testid="title"
+ aria-labelledby="item-title"
+ >
+ <span
+ id="item-title"
+ ref="titleEl"
+ role="textbox"
+ :aria-label="__('Title')"
+ :data-placeholder="placeholder"
+ :contenteditable="!disabled"
+ class="gl-pseudo-placeholder"
+ @blur="handleBlur"
+ @keyup="handleInput"
+ @keydown.enter.exact="handleSubmit"
+ @keydown.ctrl.u.prevent
+ @keydown.meta.u.prevent
+ @keydown.ctrl.b.prevent
+ @keydown.meta.b.prevent
+ >{{ title }}</span
+ >
+ </h2>
+</template>
diff --git a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql
new file mode 100644
index 00000000000..2f302dae7d7
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql
@@ -0,0 +1,18 @@
+#import './widget.fragment.graphql'
+
+mutation createWorkItem($input: LocalCreateWorkItemInput) {
+ localCreateWorkItem(input: $input) @client {
+ workItem {
+ id
+ type
+ widgets {
+ nodes {
+ ...WidgetBase
+ ... on LocalTitleWidget {
+ contentText
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/fragmentTypes.json b/app/assets/javascripts/work_items/graphql/fragmentTypes.json
index c048ac34ac0..3b837e84ee9 100644
--- a/app/assets/javascripts/work_items/graphql/fragmentTypes.json
+++ b/app/assets/javascripts/work_items/graphql/fragmentTypes.json
@@ -1 +1 @@
-{"__schema":{"types":[{"kind":"INTERFACE","name":"WorkItemWidget","possibleTypes":[{"name":"TitleWidget"}]}]}}
+{"__schema":{"types":[{"kind":"INTERFACE","name":"LocalWorkItemWidget","possibleTypes":[{"name":"LocalTitleWidget"}]}]}}
diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js
index 083735336ce..fb536a425c0 100644
--- a/app/assets/javascripts/work_items/graphql/provider.js
+++ b/app/assets/javascripts/work_items/graphql/provider.js
@@ -4,6 +4,7 @@ import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import createDefaultClient from '~/lib/graphql';
import workItemQuery from './work_item.query.graphql';
import introspectionQueryResultData from './fragmentTypes.json';
+import { resolvers } from './resolvers';
import typeDefs from './typedefs.graphql';
const fragmentMatcher = new IntrospectionFragmentMatcher({
@@ -13,15 +14,12 @@ const fragmentMatcher = new IntrospectionFragmentMatcher({
export function createApolloProvider() {
Vue.use(VueApollo);
- const defaultClient = createDefaultClient(
- {},
- {
- cacheConfig: {
- fragmentMatcher,
- },
- typeDefs,
+ const defaultClient = createDefaultClient(resolvers, {
+ cacheConfig: {
+ fragmentMatcher,
},
- );
+ typeDefs,
+ });
defaultClient.cache.writeQuery({
query: workItemQuery,
@@ -30,14 +28,14 @@ export function createApolloProvider() {
},
data: {
workItem: {
- __typename: 'WorkItem',
+ __typename: 'LocalWorkItem',
id: '1',
type: 'FEATURE',
widgets: {
- __typename: 'WorkItemWidgetConnection',
+ __typename: 'LocalWorkItemWidgetConnection',
nodes: [
{
- __typename: 'TitleWidget',
+ __typename: 'LocalTitleWidget',
type: 'TITLE',
enabled: true,
// eslint-disable-next-line @gitlab/require-i18n-strings
diff --git a/app/assets/javascripts/work_items/graphql/resolvers.js b/app/assets/javascripts/work_items/graphql/resolvers.js
index e69de29bb2d..63d5234d083 100644
--- a/app/assets/javascripts/work_items/graphql/resolvers.js
+++ b/app/assets/javascripts/work_items/graphql/resolvers.js
@@ -0,0 +1,58 @@
+import { uuids } from '~/lib/utils/uuids';
+import workItemQuery from './work_item.query.graphql';
+
+export const resolvers = {
+ Mutation: {
+ localCreateWorkItem(_, { input }, { cache }) {
+ const id = uuids()[0];
+ const workItem = {
+ __typename: 'LocalWorkItem',
+ type: 'FEATURE',
+ id,
+ widgets: {
+ __typename: 'LocalWorkItemWidgetConnection',
+ nodes: [
+ {
+ __typename: 'LocalTitleWidget',
+ type: 'TITLE',
+ enabled: true,
+ contentText: input.title,
+ },
+ ],
+ },
+ };
+
+ cache.writeQuery({ query: workItemQuery, variables: { id }, data: { workItem } });
+
+ return {
+ __typename: 'LocalCreateWorkItemPayload',
+ workItem,
+ };
+ },
+
+ localUpdateWorkItem(_, { input }, { cache }) {
+ const workItemTitle = {
+ __typename: 'LocalTitleWidget',
+ type: 'TITLE',
+ enabled: true,
+ contentText: input.title,
+ };
+ const workItem = {
+ __typename: 'LocalWorkItem',
+ type: 'FEATURE',
+ id: input.id,
+ widgets: {
+ __typename: 'LocalWorkItemWidgetConnection',
+ nodes: [workItemTitle],
+ },
+ };
+
+ cache.writeQuery({ query: workItemQuery, variables: { id: input.id }, data: { workItem } });
+
+ return {
+ __typename: 'LocalUpdateWorkItemPayload',
+ workItem,
+ };
+ },
+ },
+};
diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql
index 4a6e4aeed60..177eea00322 100644
--- a/app/assets/javascripts/work_items/graphql/typedefs.graphql
+++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql
@@ -1,38 +1,60 @@
-enum WorkItemType {
+enum LocalWorkItemType {
FEATURE
}
-enum WidgetType {
+enum LocalWidgetType {
TITLE
}
-interface WorkItemWidget {
- type: WidgetType!
+interface LocalWorkItemWidget {
+ type: LocalWidgetType!
}
# Replicating Relay connection type for client schema
-type WorkItemWidgetEdge {
+type LocalWorkItemWidgetEdge {
cursor: String!
- node: WorkItemWidget
+ node: LocalWorkItemWidget
}
-type WorkItemWidgetConnection {
- edges: [WorkItemWidgetEdge]
- nodes: [WorkItemWidget]
+type LocalWorkItemWidgetConnection {
+ edges: [LocalWorkItemWidgetEdge]
+ nodes: [LocalWorkItemWidget]
pageInfo: PageInfo!
}
-type TitleWidget implements WorkItemWidget {
- type: WidgetType!
+type LocalTitleWidget implements LocalWorkItemWidget {
+ type: LocalWidgetType!
contentText: String!
}
-type WorkItem {
+type LocalWorkItem {
id: ID!
- type: WorkItemType!
- widgets: [WorkItemWidgetConnection]
+ type: LocalWorkItemType!
+ widgets: [LocalWorkItemWidgetConnection]
+}
+
+input LocalCreateWorkItemInput {
+ title: String!
+}
+
+input LocalUpdateWorkItemInput {
+ id: ID!
+ title: String
+}
+
+type LocalCreateWorkItemPayload {
+ workItem: LocalWorkItem!
+}
+
+type LocalUpdateWorkItemPayload {
+ workItem: LocalWorkItem!
}
extend type Query {
- workItem(id: ID!): WorkItem!
+ workItem(id: ID!): LocalWorkItem!
+}
+
+extend type Mutation {
+ localCreateWorkItem(input: LocalCreateWorkItemInput!): LocalCreateWorkItemPayload!
+ localUpdateWorkItem(input: LocalUpdateWorkItemInput!): LocalUpdateWorkItemPayload!
}
diff --git a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
new file mode 100644
index 00000000000..f0563f099b2
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
@@ -0,0 +1,18 @@
+#import './widget.fragment.graphql'
+
+mutation updateWorkItem($input: LocalUpdateWorkItemInput) {
+ localUpdateWorkItem(input: $input) @client {
+ workItem {
+ id
+ type
+ widgets {
+ nodes {
+ ...WidgetBase
+ ... on LocalTitleWidget {
+ contentText
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/widget.fragment.graphql b/app/assets/javascripts/work_items/graphql/widget.fragment.graphql
index d7608c26052..154367dc0d8 100644
--- a/app/assets/javascripts/work_items/graphql/widget.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/widget.fragment.graphql
@@ -1,3 +1,3 @@
-fragment WidgetBase on WorkItemWidget {
+fragment WidgetBase on LocalWorkItemWidget {
type
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql
index 549e4f8c65a..9f173f7c302 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql
@@ -7,7 +7,7 @@ query WorkItem($id: ID!) {
widgets {
nodes {
...WidgetBase
- ... on TitleWidget {
+ ... on LocalTitleWidget {
contentText
}
}
diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue
new file mode 100644
index 00000000000..12bad5606d4
--- /dev/null
+++ b/app/assets/javascripts/work_items/pages/create_work_item.vue
@@ -0,0 +1,71 @@
+<script>
+import { GlButton, GlAlert } from '@gitlab/ui';
+import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql';
+
+import ItemTitle from '../components/item_title.vue';
+
+export default {
+ components: {
+ GlButton,
+ GlAlert,
+ ItemTitle,
+ },
+ data() {
+ return {
+ title: '',
+ error: false,
+ };
+ },
+ methods: {
+ async createWorkItem() {
+ try {
+ const response = await this.$apollo.mutate({
+ mutation: createWorkItemMutation,
+ variables: {
+ input: {
+ title: this.title,
+ },
+ },
+ });
+
+ const {
+ data: {
+ localCreateWorkItem: {
+ workItem: { id },
+ },
+ },
+ } = response;
+ this.$router.push({ name: 'workItem', params: { id } });
+ } catch {
+ this.error = true;
+ }
+ },
+ handleTitleInput(title) {
+ this.title = title;
+ },
+ },
+};
+</script>
+
+<template>
+ <form @submit.prevent="createWorkItem">
+ <gl-alert v-if="error" variant="danger" @dismiss="error = false">{{
+ __('Something went wrong when creating a work item. Please try again')
+ }}</gl-alert>
+ <item-title data-testid="title-input" @title-input="handleTitleInput" />
+ <div class="gl-bg-gray-10 gl-py-5 gl-px-6">
+ <gl-button
+ variant="confirm"
+ :disabled="title.length === 0"
+ class="gl-mr-3"
+ data-testid="create-button"
+ type="submit"
+ >
+ {{ __('Create') }}
+ </gl-button>
+ <gl-button type="button" data-testid="cancel-button" @click="$router.go(-1)">
+ {{ __('Cancel') }}
+ </gl-button>
+ </div>
+ </form>
+</template>
diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue
index 493ee0aba01..479274baf3a 100644
--- a/app/assets/javascripts/work_items/pages/work_item_root.vue
+++ b/app/assets/javascripts/work_items/pages/work_item_root.vue
@@ -1,8 +1,16 @@
<script>
+import { GlAlert } from '@gitlab/ui';
import workItemQuery from '../graphql/work_item.query.graphql';
+import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import { widgetTypes } from '../constants';
+import ItemTitle from '../components/item_title.vue';
+
export default {
+ components: {
+ ItemTitle,
+ GlAlert,
+ },
props: {
id: {
type: String,
@@ -12,6 +20,7 @@ export default {
data() {
return {
workItem: null,
+ error: false,
};
},
apollo: {
@@ -29,20 +38,39 @@ export default {
return this.workItem?.widgets?.nodes?.find((widget) => widget.type === widgetTypes.title);
},
},
+ methods: {
+ async updateWorkItem(title) {
+ try {
+ await this.$apollo.mutate({
+ mutation: updateWorkItemMutation,
+ variables: {
+ input: {
+ id: this.id,
+ title,
+ },
+ },
+ });
+ } catch {
+ this.error = true;
+ }
+ },
+ },
};
</script>
<template>
<section>
+ <gl-alert v-if="error" variant="danger" @dismiss="error = false">{{
+ __('Something went wrong while updating work item. Please try again')
+ }}</gl-alert>
<!-- Title widget placeholder -->
<div>
- <h2
+ <item-title
v-if="titleWidgetData"
- class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5"
+ :initial-title="titleWidgetData.contentText"
data-testid="title"
- >
- {{ titleWidgetData.contentText }}
- </h2>
+ @title-changed="updateWorkItem"
+ />
</div>
</section>
</template>
diff --git a/app/assets/javascripts/work_items/router/routes.js b/app/assets/javascripts/work_items/router/routes.js
index a3cf44ad4ca..95772bbd026 100644
--- a/app/assets/javascripts/work_items/router/routes.js
+++ b/app/assets/javascripts/work_items/router/routes.js
@@ -1,7 +1,12 @@
export const routes = [
{
+ path: '/new',
+ name: 'createWorkItem',
+ component: () => import('../pages/create_work_item.vue'),
+ },
+ {
path: '/:id',
- name: 'work_item',
+ name: 'workItem',
component: () => import('../pages/work_item_root.vue'),
props: true,
},
diff --git a/app/assets/stylesheets/components/design_management/design.scss b/app/assets/stylesheets/components/design_management/design.scss
index a3cbdb9ae86..377d5130571 100644
--- a/app/assets/stylesheets/components/design_management/design.scss
+++ b/app/assets/stylesheets/components/design_management/design.scss
@@ -12,7 +12,7 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
top: 35px;
}
- .badge.badge-pill {
+ .design-note-pin {
display: flex;
height: $design-pin-diameter;
width: $design-pin-diameter;
@@ -23,6 +23,7 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
border-radius: 50%;
z-index: 1;
padding: 0;
+ border: 0;
&.resolved {
background-color: $gray-500;
@@ -34,7 +35,7 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
}
.comment-indicator,
- .frame .badge.badge-pill {
+ .frame .design-note-pin {
&:active {
cursor: grabbing;
}
@@ -43,7 +44,7 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
/**
* Design pin that overlays the design
*/
- .frame .badge.badge-pill {
+ .frame .design-note-pin {
box-shadow: 0 2px 4px $t-gray-a-08, 0 0 1px $t-gray-a-24;
border: $white 2px solid;
will-change: transform, box-shadow, opacity;
@@ -114,7 +115,7 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
}
}
- .badge.badge-pill {
+ .design-note-pin {
margin-left: $gl-padding;
}
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index e458dfd5316..e0e9043ae24 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -451,3 +451,17 @@ fieldset[disabled] .btn,
box-shadow: none;
border-width: 1px;
}
+
+copy-code {
+ @include gl-absolute;
+ @include gl-transition-medium;
+ @include gl-opacity-0;
+
+ top: 7px;
+ right: $input-horizontal-padding;
+
+ .markdown-code-block:hover &,
+ &:focus-within {
+ @include gl-opacity-10;
+ }
+}
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 354d2737894..36a0d3ca3ca 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -479,6 +479,13 @@ img.emoji {
border-top: 1px solid $border-color;
}
+.gl-pseudo-placeholder:empty::before {
+ content: attr(data-placeholder);
+ font-weight: $gl-font-weight-normal;
+ color: $gl-text-color-secondary;
+ cursor: text;
+}
+
/**
🚨 Do not use these classes — they clash with the Gitlab UI design system and will be removed. 🚨
See https://gitlab.com/gitlab-org/gitlab/issues/36857 for more details.
diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
index fa1892903a3..345c180d164 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -81,7 +81,6 @@
@include gl-px-0;
@include gl-pb-2;
@include gl-pt-0;
- min-width: 150px;
background-color: $gray-10;
box-shadow: 0 $gl-spacing-scale-2 $gl-spacing-scale-5 $t-gray-a-24, 0 0 $gl-spacing-scale-1 $t-gray-a-24;
border-style: none;
@@ -309,6 +308,10 @@
}
a.has-sub-items + .sidebar-sub-level-items {
+ @include media-breakpoint-up(sm) {
+ min-width: 150px;
+ }
+
.fly-out-top-item {
@include fly-out-top-item($has-sub-items: true);
}
@@ -373,6 +376,18 @@
}
}
}
+
+ li > a.gl-link {
+ // undo gl-link text items for things in the sidebar - including sub menus
+ // defined in https://gitlab.com/gitlab-org/gitlab-ui/-/blob/5431e0ca5149d4e02e3d5d617d194ac9609bb82d/src/components/base/link/link.scss
+ @include gl-text-body;
+
+ &:active,
+ &:focus,
+ &:focus:active {
+ @include gl-text-decoration-none;
+ }
+ }
}
.sidebar-sub-level-items {
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 8f65f349cf9..9209a0c2173 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -151,7 +151,17 @@
margin: 0;
}
+ //
+ // IMPORTANT PERFORMANCE OPTIMIZATION
+ //
+ // When viewinng a blame with many commits a lot of content is rendered on the page.
+ // content-visibility rule below ensure that we only render what is visible to the user,
+ // thus reducing TBT in the browser.
+ // Grid is used instead of table layout because content-visibility performs better with it.
tr {
+ content-visibility: auto;
+ display: grid;
+ grid-template-columns: 400px max-content auto;
border-bottom: 1px solid $gray-darker;
&:last-child {
@@ -201,6 +211,10 @@
&.lines {
padding: 0;
}
+
+ .code {
+ height: 100%;
+ }
}
@for $i from 0 through 5 {
@@ -222,25 +236,6 @@
color: $gray-900;
}
}
-
- //
- // IMPORTANT PERFORMANCE OPTIMIZATION
- //
- // When viewinng a blame with many commits a lot of content is rendered on the page.
- // content-visibility rules below ensure that we only render what is visible to the user, thus reducing TBT in the browser.
- .commit {
- content-visibility: auto;
- contain-intrinsic-size: 1px 3em;
- }
-
- code .line {
- content-visibility: auto;
- contain-intrinsic-size: 1px 1.1875rem;
- }
-
- .line-numbers {
- content-visibility: auto;
- }
}
&.logs {
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index ae46ff33ec0..44b099fc873 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -523,9 +523,9 @@ $top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important
background-color: $orange-300;
height: 12px;
width: 12px;
- margin-top: -15px;
pointer-events: none;
visibility: hidden;
+ top: 3px;
}
.with-notifications .notification-dot {
diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss
index e00bb83362a..f79dc38f2f7 100644
--- a/app/assets/stylesheets/framework/layout.scss
+++ b/app/assets/stylesheets/framework/layout.scss
@@ -174,3 +174,30 @@ body {
min-height: 0;
}
}
+
+.gl-drawer-responsive {
+ // Both width & min-width
+ // are defined as per Pajamas
+ // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/44902#note_429056182
+ width: 28%;
+ min-width: 400px;
+ padding-left: $gl-padding;
+ padding-right: $gl-padding;
+ box-shadow: none;
+ background-color: $gray-10;
+ border-left: 1px solid $gray-100;
+
+ @include media-breakpoint-down(sm) {
+ min-width: unset;
+ width: 100%;
+ }
+
+ // These overrides should not happen here,
+ // we should ideally have support for custom
+ // header and body classes in `GlDrawer`.
+ .gl-drawer-header,
+ .gl-drawer-body > * {
+ padding-left: 0;
+ padding-right: 0;
+ }
+}
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index 9b04b9a2612..c6e52c13e83 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -139,6 +139,10 @@
font-family: $monospace-font !important;
}
+.suggestions.md > .markdown-code-block {
+ @include gl-static;
+}
+
.md-suggestion-header {
height: $suggestion-header-height;
display: flex;
diff --git a/app/assets/stylesheets/framework/snippets.scss b/app/assets/stylesheets/framework/snippets.scss
index c59e70c80df..39786aa0138 100644
--- a/app/assets/stylesheets/framework/snippets.scss
+++ b/app/assets/stylesheets/framework/snippets.scss
@@ -16,6 +16,11 @@
.snippet-file-content {
border-radius: 3px;
+ .file-content {
+ max-height: 500px;
+ overflow-y: auto;
+ }
+
+ .snippet-file-content {
@include gl-mt-5;
}
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index cb36c4e5767..16ff4b81f95 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -625,6 +625,7 @@ body {
/** CODE **/
pre {
+ @include gl-relative;
font-family: $monospace-font;
display: block;
padding: $gl-padding-8 $input-horizontal-padding;
@@ -636,6 +637,11 @@ pre {
background-color: $gray-light;
border: 1px solid $gray-100;
border-radius: $border-radius-small;
+
+ // Select only code elements that will have the copy code button
+ .markdown-code-block & {
+ padding: $input-horizontal-padding;
+ }
}
code {
diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss
index fb4266a2f41..97dd7edef13 100644
--- a/app/assets/stylesheets/highlight/common.scss
+++ b/app/assets/stylesheets/highlight/common.scss
@@ -45,12 +45,12 @@
}
}
-@mixin line-number-hover($color) {
- background-color: $color;
- border-color: darken($color, 5%);
+@mixin line-number-hover {
+ background-color: $purple-100;
+ border-color: $purple-200;
a {
- color: darken($color, 15%);
+ color: $gray-600;
}
}
diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss
index 2d180f49f97..0b696f1be60 100644
--- a/app/assets/stylesheets/highlight/themes/dark.scss
+++ b/app/assets/stylesheets/highlight/themes/dark.scss
@@ -22,7 +22,6 @@ $dark-highlight-bg: #ffe792;
$dark-highlight-color: $black;
$dark-pre-hll-bg: #373b41;
$dark-hll-bg: #373b41;
-$dark-over-bg: #9f9ab5;
$dark-expanded-bg: #3e3e3e;
$dark-coverage: #b3e841;
$dark-no-coverage: #ff4f33;
@@ -93,9 +92,10 @@ $dark-il: #de935f;
.file-line-num {
@include line-number-link($dark-line-num-color);
}
-
+
.line-numbers,
- .diff-line-num {
+ .diff-line-num,
+ .code-search-line {
background-color: $dark-main-bg;
}
@@ -169,16 +169,17 @@ $dark-il: #de935f;
}
.diff-grid-left:hover,
- .diff-grid-right:hover {
+ .diff-grid-right:hover,
+ &.code-search-line:hover {
.diff-line-num:not(.empty-cell) {
- @include line-number-hover($dark-over-bg);
+ @include line-number-hover;
}
}
.diff-line-num {
&.is-over,
&.hll:not(.empty-cell).is-over {
- @include line-number-hover($dark-over-bg);
+ @include line-number-hover;
}
}
diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss
index c0931188cc3..ae72c0b6bf4 100644
--- a/app/assets/stylesheets/highlight/themes/monokai.scss
+++ b/app/assets/stylesheets/highlight/themes/monokai.scss
@@ -15,7 +15,6 @@ $monokai-line-empty-bg: #49483e;
$monokai-line-empty-border: darken($monokai-line-empty-bg, 15%);
$monokai-diff-border: #808080;
$monokai-highlight-bg: #ffe792;
-$monokai-over-bg: #9f9ab5;
$monokai-expanded-bg: #3e3e3e;
$monokai-coverage: #a6e22e;
$monokai-no-coverage: #fd971f;
@@ -96,7 +95,8 @@ $monokai-gh: #75715e;
}
.line-numbers,
- .diff-line-num {
+ .diff-line-num,
+ .code-search-line {
background-color: $monokai-bg;
}
@@ -170,16 +170,17 @@ $monokai-gh: #75715e;
}
.diff-grid-left:hover,
- .diff-grid-right:hover {
+ .diff-grid-right:hover,
+ &.code-search-line:hover {
.diff-line-num:not(.empty-cell) {
- @include line-number-hover($monokai-over-bg);
+ @include line-number-hover;
}
}
.diff-line-num {
&.is-over,
&.hll:not(.empty-cell).is-over {
- @include line-number-hover($monokai-over-bg);
+ @include line-number-hover;
}
}
diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss
index ef7eb244b61..913b289d808 100644
--- a/app/assets/stylesheets/highlight/themes/none.scss
+++ b/app/assets/stylesheets/highlight/themes/none.scss
@@ -16,7 +16,8 @@
}
.line-numbers,
- .diff-line-num {
+ .diff-line-num,
+ .code-search-line {
background-color: $gray-light;
}
@@ -43,7 +44,6 @@
}
// Diff line
- $none-over-bg: #ded7fc;
$none-expanded-border: #e0e0e0;
$none-expanded-bg: #e0e0e0;
@@ -67,9 +67,10 @@
}
.diff-grid-left:hover,
- .diff-grid-right:hover {
+ .diff-grid-right:hover,
+ &.code-search-line:hover {
.diff-line-num:not(.empty-cell) {
- @include line-number-hover($none-over-bg);
+ @include line-number-hover;
}
}
@@ -88,7 +89,7 @@
&.is-over,
&.hll:not(.empty-cell).is-over {
- @include line-number-hover($none-over-bg);
+ @include line-number-hover;
}
&.hll:not(.empty-cell) {
diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
index 8f09a178af1..eee699ca4c2 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
@@ -19,7 +19,6 @@ $solarized-dark-line-color-new: #5a766c;
$solarized-dark-line-color-old: #7a6c71;
$solarized-dark-highlight: #094554;
$solarized-dark-hll-bg: #174652;
-$solarized-dark-over-bg: #9f9ab5;
$solarized-dark-expanded-bg: #010d10;
$solarized-dark-coverage: #859900;
$solarized-dark-no-coverage: #cb4b16;
@@ -99,7 +98,8 @@ $solarized-dark-il: #2aa198;
}
.line-numbers,
- .diff-line-num {
+ .diff-line-num,
+ .code-search-line {
background-color: $solarized-dark-line-bg;
}
@@ -149,9 +149,10 @@ $solarized-dark-il: #2aa198;
}
.diff-grid-left:hover,
- .diff-grid-right:hover {
+ .diff-grid-right:hover,
+ &.code-search-line:hover {
.diff-line-num:not(.empty-cell) {
- @include line-number-hover($solarized-dark-over-bg);
+ @include line-number-hover;
}
}
@@ -182,7 +183,7 @@ $solarized-dark-il: #2aa198;
.diff-line-num {
&.is-over,
&.hll:not(.empty-cell).is-over {
- @include line-number-hover($solarized-dark-over-bg);
+ @include line-number-hover;
}
}
diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss
index 747cc639f91..8c5e1f7318b 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-light.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss
@@ -20,7 +20,6 @@ $solarized-light-line-color-new: #a1a080;
$solarized-light-line-color-old: #ad9186;
$solarized-light-highlight: #eee8d5;
$solarized-light-hll-bg: #ddd8c5;
-$solarized-light-over-bg: #ded7fc;
$solarized-light-expanded-border: #d2cdbd;
$solarized-light-expanded-bg: #ece6d4;
$solarized-light-coverage: #859900;
@@ -106,7 +105,8 @@ $solarized-light-il: #2aa198;
}
.line-numbers,
- .diff-line-num {
+ .diff-line-num,
+ .code-search-line {
background-color: $solarized-light-line-bg;
}
@@ -169,9 +169,10 @@ $solarized-light-il: #2aa198;
}
.diff-grid-left:hover,
- .diff-grid-right:hover {
+ .diff-grid-right:hover,
+ &.code-search-line:hover {
.diff-line-num:not(.empty-cell) {
- @include line-number-hover($solarized-light-over-bg);
+ @include line-number-hover;
}
}
@@ -190,7 +191,7 @@ $solarized-light-il: #2aa198;
.diff-line-num {
&.is-over,
&.hll:not(.empty-cell).is-over {
- @include line-number-hover($solarized-light-over-bg);
+ @include line-number-hover;
}
}
diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss
index 86b01926dd7..c0f8475323a 100644
--- a/app/assets/stylesheets/highlight/white_base.scss
+++ b/app/assets/stylesheets/highlight/white_base.scss
@@ -9,7 +9,6 @@ $white-code-color: $gl-text-color;
$white-highlight: #fafe3d;
$white-pre-hll-bg: #f8eec7;
$white-hll-bg: #f8f8f8;
-$white-over-bg: #ded7fc;
$white-expanded-border: #e0e0e0;
$white-expanded-bg: #f7f7f7;
$white-c: #998;
@@ -83,7 +82,8 @@ $white-gc-bg: #eaf2f5;
}
.line-numbers,
-.diff-line-num {
+.diff-line-num,
+.code-search-line {
background-color: $gray-light;
&.conflict_marker,
@@ -129,9 +129,10 @@ pre.code,
}
.diff-grid-left:hover,
- .diff-grid-right:hover {
+ .diff-grid-right:hover,
+ &.code-search-line:hover {
.diff-line-num:not(.empty-cell):not(.conflict_marker_their):not(.conflict_marker_our) {
- @include line-number-hover($white-over-bg);
+ @include line-number-hover;
}
}
@@ -156,7 +157,7 @@ pre.code,
&.is-over,
&.hll:not(.empty-cell).is-over {
- @include line-number-hover($white-over-bg);
+ @include line-number-hover;
}
&.hll:not(.empty-cell) {
diff --git a/app/assets/stylesheets/lazy_bundles/select2_overrides.scss b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss
index 3e5271f84d5..2c5ea8347ae 100644
--- a/app/assets/stylesheets/lazy_bundles/select2_overrides.scss
+++ b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss
@@ -326,10 +326,6 @@
}
}
-.transfer-project .select2-container {
- min-width: 200px;
-}
-
.right-sidebar {
.block {
.select2-container span {
diff --git a/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss b/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss
index 8794acd3c78..ee777820b81 100644
--- a/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss
+++ b/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss
@@ -113,8 +113,8 @@
- mini graph in Commit widget pipeline
*/
@mixin pipeline-graph-dropdown-menu() {
- width: 240px;
- max-width: 240px;
+ width: auto;
+ max-width: 400px;
// override dropdown.scss
&.dropdown-menu li button,
@@ -185,8 +185,6 @@
}
.ci-status-icon {
- @include gl-mr-3;
-
position: relative;
> svg {
diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss
index 47580e37eca..d37171bc75e 100644
--- a/app/assets/stylesheets/page_bundles/ide.scss
+++ b/app/assets/stylesheets/page_bundles/ide.scss
@@ -904,6 +904,7 @@ $ide-commit-header-height: 48px;
.sidebar-context-title {
white-space: nowrap;
display: block;
+ color: var(--ide-text-color, $gl-text-color);
&.text-secondary {
font-weight: normal;
@@ -964,6 +965,10 @@ $ide-commit-header-height: 48px;
margin: 0;
}
}
+
+ .gl-tab-content {
+ color: var(--ide-text-color, $gl-text-color);
+ }
}
.ide-pipeline-header {
diff --git a/app/assets/stylesheets/page_bundles/import.scss b/app/assets/stylesheets/page_bundles/import.scss
index c74b5460e1a..79468ce62ce 100644
--- a/app/assets/stylesheets/page_bundles/import.scss
+++ b/app/assets/stylesheets/page_bundles/import.scss
@@ -1,12 +1,5 @@
@import 'mixins_and_variables_and_functions';
-// Fixing double scrollbar issue
-// See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1156 and
-// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/54837
-.import-entities-namespace-dropdown.show.dropdown .dropdown-menu {
- max-height: initial;
-}
-
.import-jobs-to-col {
width: 39%;
}
@@ -38,3 +31,31 @@
box-shadow: inset 0 0 0 1px var(--gray-200, $gray-200);
}
}
+
+$import-bar-height: $gl-spacing-scale-11;
+
+.import-table-bar {
+ @include gl-sticky;
+ height: $import-bar-height;
+ top: $header-height;
+ z-index: 3;
+
+ html.with-performance-bar & {
+ top: $header-height + $performance-bar-height;
+ }
+}
+
+.import-table {
+ border-collapse: separate;
+
+ thead {
+ @include gl-sticky;
+ background-color: var(--gray-10, $gray-10);
+ top: calc(#{$header-height} + #{$import-bar-height});
+ z-index: 3;
+
+ html.with-performance-bar & {
+ top: calc(#{$header-height + $performance-bar-height} + #{$import-bar-height});
+ }
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index 7d1230b0225..02113fe8b58 100644
--- a/app/assets/stylesheets/page_bundles/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -45,7 +45,7 @@
top: calc(#{$top-pos} + var(--system-header-height, 0px) + var(--performance-bar-height, 0px));
// stylelint-disable-next-line length-zero-no-unit
max-height: calc(100vh - #{$top-pos} - var(--system-header-height, 0px) - var(--performance-bar-height, 0px) - var(--review-bar-height, 0px));
- z-index: 202;
+ z-index: 205;
.drag-handle {
bottom: 16px;
diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss
index c8b1b6cf9aa..a9d353a0444 100644
--- a/app/assets/stylesheets/page_bundles/pipeline.scss
+++ b/app/assets/stylesheets/page_bundles/pipeline.scss
@@ -129,11 +129,17 @@
}
.gl-pipeline-job-width {
- width: 186px;
+ width: 100%;
+ max-width: 400px;
}
.gl-pipeline-job-width\! {
- width: 186px !important;
+ width: 100% !important;
+ max-width: 400px !important;
+}
+
+.gl-downstream-pipeline-job-width {
+ width: 240px;
}
.gl-linked-pipeline-padding {
@@ -199,7 +205,6 @@
.big-pipeline-graph-dropdown-menu {
@include pipeline-graph-dropdown-menu();
- width: 195px;
min-width: 195px;
left: 100%;
top: -10px;
diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss
index b450bca4f41..5a091c14e53 100644
--- a/app/assets/stylesheets/pages/clusters.scss
+++ b/app/assets/stylesheets/pages/clusters.scss
@@ -7,20 +7,6 @@
}
}
- .agents-empty-state {
- .text-content {
- @include gl-max-w-full;
- @include media-breakpoint-up(lg) {
- max-width: 70%;
- }
- }
-
- .gl-alert-actions {
- @include gl-mt-0;
- @include gl-flex-wrap;
- }
- }
-
.gl-card-body {
@include media-breakpoint-up(sm) {
@include gl-pt-2;
@@ -40,3 +26,15 @@
}
}
}
+
+.agent-activity-list {
+ .system-note .timeline-entry-inner {
+ .timeline-icon {
+ @include gl-mt-1;
+ }
+ }
+
+ &.issuable-discussion .main-notes-list::before {
+ @include gl-top-3;
+ }
+}
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index 7f35b8fab43..cc8ea1493fc 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -149,7 +149,6 @@
.commit-content {
padding-right: 10px;
white-space: normal;
- overflow: hidden;
.commit-title {
display: flex;
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index cf5e93e94a2..8600a4059d8 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -456,12 +456,6 @@
}
.multiple-users {
- position: relative;
- height: 24px;
- margin-bottom: 17px;
- margin-top: 4px;
- padding-bottom: 4px;
-
.btn-link {
padding: 0;
border: 0;
@@ -875,10 +869,6 @@
}
}
-.issuable-suggestions svg {
- vertical-align: sub;
-}
-
.suggestion-footer {
font-size: 12px;
line-height: 15px;
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 3b86750c6ca..a4b8e912614 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -947,19 +947,6 @@ $tabs-holder-z-index: 250;
color: $gray-500;
line-height: initial;
}
-
- // GlDropdown mini pipeline (Vue)
- // As the `mini-pipeline-item` mixin specificity is lower
- // than the toggle of dropdown with 'variant="link"' we add
- // classes ".gl-button.btn-link" to make it more specific
- // and avoid having the size overriden
- //
- // See https://gitlab.com/gitlab-org/gitlab/-/issues/320737
- button.gl-button.btn-link.mini-pipeline-graph-dropdown-toggle,
- .stage-cell button.gl-button.btn-link.mini-pipeline-graph-dropdown-toggle svg {
- height: $ci-action-icon-size-lg;
- width: $ci-action-icon-size-lg;
- }
}
.merge-request-details .file-finder-overlay.diff-file-finder {
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index af9f10c9a26..a7ed7172f5f 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -329,16 +329,6 @@ table.u2f-registrations {
}
}
-.email-badge {
- display: inline;
- margin-right: $gl-padding / 2;
-
- .email-badge-email {
- display: inline;
- margin-right: $gl-padding / 4;
- }
-}
-
.edit-user {
svg {
fill: $gl-text-color-secondary;
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index 2e6c6a021f8..4c31cc6e111 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -321,6 +321,51 @@ input[type='checkbox']:hover {
}
}
+// This overrides parts of the Project File View CSS
+// We leverage most of the styling but broke off
+// from how we were doing it in `shared/file_highlight`
+#search-blob-content {
+ .line_holder {
+ pre {
+ padding: 0; // This overrides the existing style that will add space between each line.
+ }
+
+ svg {
+ float: none; // We have more than one icon on this implementation and don't want to float them.
+ margin: 0; // We will manage the margin with GitLab UI utility classes
+ }
+
+ .line-numbers {
+ padding: 0; // This overrides the existing style that will add space between each line.
+ min-width: 6.5rem; // Ensure our numbers fit
+
+ .diff-line-num {
+ a {
+ transition: none; // There will be a hover transition from theme, blue, darkened
+ }
+ }
+ }
+
+ &:hover {
+ svg {
+ visibility: visible; // We want to show the icons when the any part of the line is hovered
+ }
+ }
+
+ // The icons only appear on hover
+ // So on mobile we can hide them and retake the space for the code blob
+ @include media-breakpoint-down(sm) {
+ svg {
+ display: none;
+ }
+
+ .line-numbers {
+ min-width: 4rem;
+ }
+ }
+ }
+}
+
// Disable webkit input icons, link to solution: https://stackoverflow.com/questions/9421551/how-do-i-remove-all-default-webkit-search-field-styling
/* stylelint-disable property-no-vendor-prefix */
input[type='search']::-webkit-search-decoration,
diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss
index efa4b04ee62..c9ff8205142 100644
--- a/app/assets/stylesheets/startup/startup-dark.scss
+++ b/app/assets/stylesheets/startup/startup-dark.scss
@@ -407,6 +407,34 @@ h1 {
.gl-form-input.form-control::placeholder {
color: #868686;
}
+.gl-icon {
+ fill: currentColor;
+}
+.gl-icon.s12 {
+ width: 12px;
+ height: 12px;
+}
+.gl-icon.s16 {
+ width: 16px;
+ height: 16px;
+}
+.gl-icon.s32 {
+ width: 32px;
+ height: 32px;
+}
+.gl-link {
+ font-size: 0.875rem;
+ color: #428fdc;
+}
+.gl-link:active {
+ color: #9dc7f1;
+}
+.gl-link:active {
+ text-decoration: underline;
+ box-shadow: 0 0 0 1px rgba(51, 51, 51, 0.4),
+ 0 0 0 4px rgba(66, 143, 220, 0.48);
+ outline: none;
+}
.gl-button {
display: inline-flex;
}
@@ -439,6 +467,29 @@ h1 {
outline: none;
background-color: #404040;
}
+.gl-button.gl-button.btn-default:active .gl-icon,
+.gl-button.gl-button.btn-default.active .gl-icon {
+ color: #fafafa;
+}
+.gl-button.gl-button.btn-default .gl-icon {
+ color: #999;
+}
+.gl-search-box-by-type-search-icon {
+ margin: 0.5rem;
+ color: #999;
+ width: 1rem;
+ position: absolute;
+}
+.gl-search-box-by-type {
+ display: flex;
+ position: relative;
+}
+.gl-search-box-by-type-input,
+.gl-search-box-by-type-input.gl-form-input {
+ height: 2rem;
+ padding-right: 2rem;
+ padding-left: 1.75rem;
+}
body,
.form-control,
.search form {
@@ -912,9 +963,9 @@ input {
background-color: #9e5400;
height: 12px;
width: 12px;
- margin-top: -15px;
pointer-events: none;
visibility: hidden;
+ top: 3px;
}
.top-nav-toggle .dropdown-icon {
margin-right: 0.5rem;
@@ -1139,6 +1190,11 @@ input {
border-right: 0.25rem solid #fff;
border-right-color: var(--black, #fff);
}
+@media (min-width: 576px) {
+ .nav-sidebar a.has-sub-items + .sidebar-sub-level-items {
+ min-width: 150px;
+ }
+}
.nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item {
display: none;
}
@@ -1329,6 +1385,12 @@ input {
font-weight: 400;
color: #9dc7f1;
}
+.sidebar-top-level-items li > a.gl-link {
+ color: #fafafa;
+}
+.sidebar-top-level-items li > a.gl-link:active {
+ text-decoration: none;
+}
.sidebar-sub-level-items {
padding-top: 0;
padding-bottom: 0;
@@ -1770,6 +1832,7 @@ body.gl-dark .header-search svg {
body.gl-dark .header-search input {
background-color: transparent;
color: rgba(250, 250, 250, 0.8);
+ box-shadow: inset 0 0 0 1px rgba(250, 250, 250, 0.4);
}
body.gl-dark .header-search input::placeholder {
color: rgba(250, 250, 250, 0.8);
@@ -1826,6 +1889,9 @@ body.gl-dark .navbar-gitlab .search form:active {
background-color: var(--gray-100);
box-shadow: inset 0 0 0 1px var(--blue-200);
}
+body.gl-dark .navbar-gitlab .search form .search-input {
+ color: var(--gl-text-color);
+}
body.gl-dark {
--gray-10: #1f1f1f;
@@ -1948,6 +2014,9 @@ body.gl-dark {
display: block;
}
}
+.gl-relative {
+ position: relative;
+}
.gl-absolute {
position: absolute;
}
diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss
index 977f994dc78..a57202515ad 100644
--- a/app/assets/stylesheets/startup/startup-general.scss
+++ b/app/assets/stylesheets/startup/startup-general.scss
@@ -388,6 +388,34 @@ h1 {
.gl-form-input.form-control::placeholder {
color: #868686;
}
+.gl-icon {
+ fill: currentColor;
+}
+.gl-icon.s12 {
+ width: 12px;
+ height: 12px;
+}
+.gl-icon.s16 {
+ width: 16px;
+ height: 16px;
+}
+.gl-icon.s32 {
+ width: 32px;
+ height: 32px;
+}
+.gl-link {
+ font-size: 0.875rem;
+ color: #1f75cb;
+}
+.gl-link:active {
+ color: #0b5cad;
+}
+.gl-link:active {
+ text-decoration: underline;
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.4),
+ 0 0 0 4px rgba(31, 117, 203, 0.48);
+ outline: none;
+}
.gl-button {
display: inline-flex;
}
@@ -420,6 +448,29 @@ h1 {
outline: none;
background-color: #dbdbdb;
}
+.gl-button.gl-button.btn-default:active .gl-icon,
+.gl-button.gl-button.btn-default.active .gl-icon {
+ color: #303030;
+}
+.gl-button.gl-button.btn-default .gl-icon {
+ color: #666;
+}
+.gl-search-box-by-type-search-icon {
+ margin: 0.5rem;
+ color: #666;
+ width: 1rem;
+ position: absolute;
+}
+.gl-search-box-by-type {
+ display: flex;
+ position: relative;
+}
+.gl-search-box-by-type-input,
+.gl-search-box-by-type-input.gl-form-input {
+ height: 2rem;
+ padding-right: 2rem;
+ padding-left: 1.75rem;
+}
body,
.form-control,
.search form {
@@ -893,9 +944,9 @@ input {
background-color: #d99530;
height: 12px;
width: 12px;
- margin-top: -15px;
pointer-events: none;
visibility: hidden;
+ top: 3px;
}
.top-nav-toggle .dropdown-icon {
margin-right: 0.5rem;
@@ -1120,6 +1171,11 @@ input {
border-right: 0.25rem solid #000;
border-right-color: var(--black, #000);
}
+@media (min-width: 576px) {
+ .nav-sidebar a.has-sub-items + .sidebar-sub-level-items {
+ min-width: 150px;
+ }
+}
.nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item {
display: none;
}
@@ -1310,6 +1366,12 @@ input {
font-weight: 400;
color: #0b5cad;
}
+.sidebar-top-level-items li > a.gl-link {
+ color: #303030;
+}
+.sidebar-top-level-items li > a.gl-link:active {
+ text-decoration: none;
+}
.sidebar-sub-level-items {
padding-top: 0;
padding-bottom: 0;
@@ -1626,6 +1688,9 @@ svg.s16 {
display: block;
}
}
+.gl-relative {
+ position: relative;
+}
.gl-absolute {
position: absolute;
}
diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss
index 2b5751cab36..bb9a9cf0497 100644
--- a/app/assets/stylesheets/themes/dark_mode_overrides.scss
+++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss
@@ -122,6 +122,10 @@ body.gl-dark {
background-color: var(--gray-100);
box-shadow: inset 0 0 0 1px var(--blue-200);
}
+
+ .search-input {
+ color: var(--gl-text-color);
+ }
}
}
}
diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss
index 1332686a906..e119af716a6 100644
--- a/app/assets/stylesheets/themes/theme_helper.scss
+++ b/app/assets/stylesheets/themes/theme_helper.scss
@@ -154,6 +154,7 @@
input {
background-color: transparent;
color: rgba($search-and-nav-links, 0.8);
+ box-shadow: inset 0 0 0 1px rgba($search-and-nav-links, 0.4);
&::placeholder {
color: rgba($search-and-nav-links, 0.8);
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index 7e46f16e1d0..2623de80fe9 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -226,6 +226,16 @@ $gl-line-height-42: px-to-rem(42px);
max-height: none !important;
}
+// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1655
+.gl-max-w-62 {
+ max-width: $grid-size * 62;
+}
+
+// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1655
+.gl-max-w-26 {
+ max-width: $grid-size * 26;
+}
+
.gl-max-w-50p {
max-width: 50%;
}
@@ -291,9 +301,9 @@ $gl-line-height-42: px-to-rem(42px);
@include gl-focus($gl-border-size-1, $gray-900, true);
}
-// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2476
-.gl-md-max-w-50p {
- @include gl-media-breakpoint-up(md) {
- max-width: 50%;
+// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1637
+.gl-lg-w-25p {
+ @include gl-media-breakpoint-up(lg) {
+ width: 25%;
}
}
diff --git a/app/controllers/admin/plan_limits_controller.rb b/app/controllers/admin/plan_limits_controller.rb
index 88bc5ea0198..420fd93fad5 100644
--- a/app/controllers/admin/plan_limits_controller.rb
+++ b/app/controllers/admin/plan_limits_controller.rb
@@ -31,6 +31,7 @@ class Admin::PlanLimitsController < Admin::ApplicationController
params.require(:plan_limits).permit(%i[
plan_id
conan_max_file_size
+ helm_max_file_size
maven_max_file_size
npm_max_file_size
nuget_max_file_size
diff --git a/app/controllers/admin/version_check_controller.rb b/app/controllers/admin/version_check_controller.rb
new file mode 100644
index 00000000000..dde1a7abafa
--- /dev/null
+++ b/app/controllers/admin/version_check_controller.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class Admin::VersionCheckController < Admin::ApplicationController
+ feature_category :not_owned
+
+ def version_check
+ response = VersionCheck.new.response
+
+ expires_in 1.minute if response
+ render json: response
+ end
+end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 3af1afab06e..d3ecbdcc1f6 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -23,6 +23,7 @@ class ApplicationController < ActionController::Base
include Gitlab::Utils::StrongMemoize
include ::Gitlab::EndpointAttributes
include FlocOptOut
+ include CheckRateLimit
before_action :authenticate_user!, except: [:route_not_found]
before_action :enforce_terms!, if: :should_enforce_terms?
@@ -66,10 +67,6 @@ class ApplicationController < ActionController::Base
:manifest_import_enabled?, :phabricator_import_enabled?,
:masked_page_url
- # Adds `no-store` to the DEFAULT_CACHE_CONTROL, to prevent security
- # concerns due to caching private data.
- DEFAULT_GITLAB_CACHE_CONTROL = "#{ActionDispatch::Http::Cache::Response::DEFAULT_CACHE_CONTROL}, no-store"
-
def self.endpoint_id_for_action(action_name)
"#{self.name}##{action_name}"
end
@@ -283,10 +280,7 @@ class ApplicationController < ActionController::Base
end
def default_cache_headers
- if current_user
- headers['Cache-Control'] = default_cache_control
- headers['Pragma'] = 'no-cache' # HTTP 1.0 compatibility
- end
+ headers['Pragma'] = 'no-cache' # HTTP 1.0 compatibility
end
def stream_csv_headers(csv_filename)
@@ -297,14 +291,6 @@ class ApplicationController < ActionController::Base
headers['Content-Disposition'] = "attachment; filename=\"#{csv_filename}\""
end
- def default_cache_control
- if request.xhr?
- ActionDispatch::Http::Cache::Response::DEFAULT_CACHE_CONTROL
- else
- DEFAULT_GITLAB_CACHE_CONTROL
- end
- end
-
def validate_user_service_ticket!
return unless signed_in? && session[:service_tickets]
diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb
index 5cb5690d72d..c32a7f10aa4 100644
--- a/app/controllers/autocomplete_controller.rb
+++ b/app/controllers/autocomplete_controller.rb
@@ -9,6 +9,8 @@ class AutocompleteController < ApplicationController
feature_category :code_review, [:merge_request_target_branches]
feature_category :continuous_delivery, [:deploy_keys_with_owners]
+ urgency :low, [:merge_request_target_branches]
+
def users
group = Autocomplete::GroupFinder
.new(current_user, project, params)
diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb
index 32de9e69c85..15a261f572a 100644
--- a/app/controllers/clusters/clusters_controller.rb
+++ b/app/controllers/clusters/clusters_controller.rb
@@ -280,7 +280,10 @@ class Clusters::ClustersController < Clusters::BaseController
end
def generate_gcp_authorize_url
- state = generate_session_key_redirect(clusterable.new_path(provider: :gcp).to_s)
+ new_path = clusterable.new_path(provider: :gcp).to_s
+ error_path = @project ? project_clusters_path(@project) : new_path
+
+ state = generate_session_key_redirect(new_path, error_path)
@authorize_url = GoogleApi::CloudPlatform::Client.new(
nil, callback_google_api_auth_url,
@@ -339,9 +342,10 @@ class Clusters::ClustersController < Clusters::BaseController
session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at]
end
- def generate_session_key_redirect(uri)
+ def generate_session_key_redirect(uri, error_uri)
GoogleApi::CloudPlatform::Client.new_session_key_for_redirect_uri do |key|
session[key] = uri
+ session[:error_uri] = error_uri
end
end
diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb
index da5b7ccfbf0..14dcec33545 100644
--- a/app/controllers/concerns/authenticates_with_two_factor.rb
+++ b/app/controllers/concerns/authenticates_with_two_factor.rb
@@ -23,9 +23,9 @@ module AuthenticatesWithTwoFactor
session[:otp_user_id] = user.id
session[:user_password_hash] = Digest::SHA256.hexdigest(user.encrypted_password)
- push_frontend_feature_flag(:webauthn)
+ push_frontend_feature_flag(:webauthn, default_enabled: :yaml)
- if user.two_factor_webauthn_enabled?
+ if Feature.enabled?(:webauthn, default_enabled: :yaml)
setup_webauthn_authentication(user)
else
setup_u2f_authentication(user)
diff --git a/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb b/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb
index 574fc6c0f37..05be04059fd 100644
--- a/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb
+++ b/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb
@@ -11,7 +11,7 @@ module AuthenticatesWithTwoFactorForAdminMode
return handle_locked_user(user) unless user.can?(:log_in)
session[:otp_user_id] = user.id
- push_frontend_feature_flag(:webauthn)
+ push_frontend_feature_flag(:webauthn, default_enabled: :yaml)
if user.two_factor_webauthn_enabled?
setup_webauthn_authentication(user)
diff --git a/app/controllers/concerns/check_rate_limit.rb b/app/controllers/concerns/check_rate_limit.rb
index c4de3315e22..5ccdf843525 100644
--- a/app/controllers/concerns/check_rate_limit.rb
+++ b/app/controllers/concerns/check_rate_limit.rb
@@ -5,19 +5,27 @@
# Controller concern that checks if the rate limit for a given action is throttled by calling the
# Gitlab::ApplicationRateLimiter class. If the action is throttled for the current user, the request
# will be logged and an error message will be rendered with a Too Many Requests response status.
+# See lib/api/helpers/rate_limiter.rb for API version
module CheckRateLimit
- def check_rate_limit(key)
- return unless rate_limiter.throttled?(key, scope: current_user, users_allowlist: rate_limit_users_allowlist)
+ def check_rate_limit!(key, scope:, redirect_back: false, **options)
+ return unless rate_limiter.throttled?(key, scope: scope, **options)
rate_limiter.log_request(request, "#{key}_request_limit".to_sym, current_user)
- render plain: _('This endpoint has been requested too many times. Try again later.'), status: :too_many_requests
+
+ return yield if block_given?
+
+ message = _('This endpoint has been requested too many times. Try again later.')
+
+ if redirect_back
+ redirect_back_or_default(options: { alert: message })
+ else
+ render plain: message, status: :too_many_requests
+ end
end
+ private
+
def rate_limiter
::Gitlab::ApplicationRateLimiter
end
-
- def rate_limit_users_allowlist
- Gitlab::CurrentSettings.current_application_settings.notes_create_limit_allowlist
- end
end
diff --git a/app/controllers/concerns/cycle_analytics_params.rb b/app/controllers/concerns/cycle_analytics_params.rb
index 626093b4588..70bcefe339c 100644
--- a/app/controllers/concerns/cycle_analytics_params.rb
+++ b/app/controllers/concerns/cycle_analytics_params.rb
@@ -23,6 +23,7 @@ module CycleAnalyticsParams
opts[:from] = params[:from] || start_date(params)
opts[:to] = params[:to] if params[:to]
opts[:end_event_filter] = params[:end_event_filter] if params[:end_event_filter]
+ opts[:use_aggregated_data_collector] = params[:use_aggregated_data_collector] if params[:use_aggregated_data_collector]
opts.merge!(params.slice(*::Gitlab::Analytics::CycleAnalytics::RequestParams::FINDER_PARAM_NAMES))
opts.merge!(date_range(params))
end
diff --git a/app/controllers/concerns/dependency_proxy/group_access.rb b/app/controllers/concerns/dependency_proxy/group_access.rb
index 07aca72b22f..44611641529 100644
--- a/app/controllers/concerns/dependency_proxy/group_access.rb
+++ b/app/controllers/concerns/dependency_proxy/group_access.rb
@@ -5,13 +5,13 @@ module DependencyProxy
extend ActiveSupport::Concern
included do
- before_action :verify_dependency_proxy_enabled!
+ before_action :verify_dependency_proxy_available!
before_action :authorize_read_dependency_proxy!
end
private
- def verify_dependency_proxy_enabled!
+ def verify_dependency_proxy_available!
render_404 unless group&.dependency_proxy_feature_available?
end
diff --git a/app/controllers/concerns/integrations/actions.rb b/app/controllers/concerns/integrations/actions.rb
index 6490742c0f8..1f788860c8f 100644
--- a/app/controllers/concerns/integrations/actions.rb
+++ b/app/controllers/concerns/integrations/actions.rb
@@ -23,7 +23,7 @@ module Integrations::Actions
format.html do
if saved
PropagateIntegrationWorker.perform_async(integration.id)
- redirect_to scoped_edit_integration_path(integration), notice: success_message
+ redirect_to scoped_edit_integration_path(integration, project: integration.project, group: integration.group), notice: success_message
else
render 'shared/integrations/edit'
end
diff --git a/app/controllers/concerns/integrations/hooks_execution.rb b/app/controllers/concerns/integrations/hooks_execution.rb
index af039057a9c..6a9d3d51f9b 100644
--- a/app/controllers/concerns/integrations/hooks_execution.rb
+++ b/app/controllers/concerns/integrations/hooks_execution.rb
@@ -32,16 +32,4 @@ module Integrations::HooksExecution
flash[:alert] = "Hook execution failed: #{message}"
end
end
-
- def create_rate_limit(key, scope)
- if rate_limiter.throttled?(key, scope: [scope, current_user])
- rate_limiter.log_request(request, "#{key}_request_limit".to_sym, current_user)
-
- render plain: _('This endpoint has been requested too many times. Try again later.'), status: :too_many_requests
- end
- end
-
- def rate_limiter
- ::Gitlab::ApplicationRateLimiter
- end
end
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index 2d7fbb78209..bac9732018c 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -17,6 +17,7 @@ module IssuableActions
def show
respond_to do |format|
format.html do
+ @show_crm_contacts = issuable.is_a?(Issue) && can?(current_user, :read_crm_contact, issuable.project.group) # rubocop:disable Gitlab/ModuleWithInstanceVariables
@issuable_sidebar = serializer.represent(issuable, serializer: 'sidebar') # rubocop:disable Gitlab/ModuleWithInstanceVariables
render 'show'
end
diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb
index 8fd4e98d557..f716c1f6c2f 100644
--- a/app/controllers/concerns/membership_actions.rb
+++ b/app/controllers/concerns/membership_actions.rb
@@ -26,7 +26,6 @@ module MembershipActions
member_data = if member.expires?
{
- expires_in: helpers.distance_of_time_in_words_to_now(member.expires_at),
expires_soon: member.expires_soon?,
expires_at_formatted: member.expires_at.to_time.in_time_zone.to_s(:medium)
}
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index c2ee735a2b5..8410a8779f6 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -3,7 +3,6 @@
module NotesActions
include RendersNotes
include Gitlab::Utils::StrongMemoize
- include CheckRateLimit
extend ActiveSupport::Concern
# last_fetched_at is an integer number of microseconds, which is the same
@@ -16,7 +15,11 @@ module NotesActions
before_action :require_noteable!, only: [:index, :create]
before_action :authorize_admin_note!, only: [:update, :destroy]
before_action :note_project, only: [:create]
- before_action -> { check_rate_limit(:notes_create) }, only: [:create]
+ before_action -> {
+ check_rate_limit!(:notes_create,
+ scope: current_user,
+ users_allowlist: Gitlab::CurrentSettings.current_application_settings.notes_create_limit_allowlist)
+ }, only: [:create]
end
def index
@@ -341,3 +344,5 @@ module NotesActions
noteable.discussions_rendered_on_frontend?
end
end
+
+NotesActions.prepend_mod_with('NotesActions')
diff --git a/app/controllers/concerns/one_trust_csp.rb b/app/controllers/concerns/one_trust_csp.rb
index fbd44f52590..cd35eeb587c 100644
--- a/app/controllers/concerns/one_trust_csp.rb
+++ b/app/controllers/concerns/one_trust_csp.rb
@@ -8,11 +8,11 @@ module OneTrustCSP
next unless helpers.one_trust_enabled? || policy.directives.present?
default_script_src = policy.directives['script-src'] || policy.directives['default-src']
- script_src_values = Array.wrap(default_script_src) | ["'unsafe-eval'", 'https://cdn.cookielaw.org https://*.onetrust.com']
+ script_src_values = Array.wrap(default_script_src) | ["'unsafe-eval'", 'https://cdn.cookielaw.org', 'https://*.onetrust.com']
policy.script_src(*script_src_values)
default_connect_src = policy.directives['connect-src'] || policy.directives['default-src']
- connect_src_values = Array.wrap(default_connect_src) | ['https://cdn.cookielaw.org']
+ connect_src_values = Array.wrap(default_connect_src) | ['https://cdn.cookielaw.org', 'https://*.onetrust.com']
policy.connect_src(*connect_src_values)
end
end
diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb
index 2916762e31f..1d2f9e31c46 100644
--- a/app/controllers/concerns/preview_markdown.rb
+++ b/app/controllers/concerns/preview_markdown.rb
@@ -21,7 +21,7 @@ module PreviewMarkdown
def projects_filter_params
{
- issuable_state_filter_enabled: true,
+ issuable_reference_expansion_enabled: true,
suggestions_filter_enabled: params[:preview_suggestions].present?
}
end
diff --git a/app/controllers/concerns/snippets/blobs_actions.rb b/app/controllers/concerns/snippets/blobs_actions.rb
index db56ce8f193..b510594ad63 100644
--- a/app/controllers/concerns/snippets/blobs_actions.rb
+++ b/app/controllers/concerns/snippets/blobs_actions.rb
@@ -51,3 +51,5 @@ module Snippets::BlobsActions
params[:snippet_id]
end
end
+
+Snippets::BlobsActions.prepend_mod
diff --git a/app/controllers/concerns/sourcegraph_decorator.rb b/app/controllers/concerns/sourcegraph_decorator.rb
index 5ef09b9221f..061990a4361 100644
--- a/app/controllers/concerns/sourcegraph_decorator.rb
+++ b/app/controllers/concerns/sourcegraph_decorator.rb
@@ -11,7 +11,7 @@ module SourcegraphDecorator
next unless Gitlab::CurrentSettings.sourcegraph_enabled
default_connect_src = p.directives['connect-src'] || p.directives['default-src']
- connect_src_values = Array.wrap(default_connect_src) | [Gitlab::CurrentSettings.sourcegraph_url]
+ connect_src_values = Array.wrap(default_connect_src) | [Gitlab::Utils.append_path(Gitlab::CurrentSettings.sourcegraph_url, '.api/')]
p.connect_src(*connect_src_values)
end
end
diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb
index 848b7ee44c5..714a6f280f3 100644
--- a/app/controllers/concerns/wiki_actions.rb
+++ b/app/controllers/concerns/wiki_actions.rb
@@ -21,6 +21,10 @@ module WikiActions
before_action :load_sidebar, except: [:pages]
before_action :set_content_class
+ before_action do
+ push_frontend_feature_flag(:wiki_switch_between_content_editor_raw_markdown, @group, default_enabled: :yaml)
+ end
+
before_action only: [:show, :edit, :update] do
@valid_encoding = valid_encoding?
end
@@ -79,7 +83,8 @@ module WikiActions
render 'shared/wikis/show'
elsif file_blob
- send_blob(wiki.repository, file_blob)
+ # This is needed by [GitLab JH](https://gitlab.com/gitlab-jh/gitlab/-/issues/247)
+ send_wiki_file_blob(wiki, file_blob)
elsif show_create_form?
# Assign a title to the WikiPage unless `id` is a randomly generated slug from #new
title = params[:id] unless params[:random_title].present?
@@ -301,4 +306,10 @@ module WikiActions
view: diff_view
}
end
+
+ def send_wiki_file_blob(wiki, file_blob)
+ send_blob(wiki.repository, file_blob)
+ end
end
+
+WikiActions.prepend_mod
diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb
index 6725e19df25..dd30d688fa8 100644
--- a/app/controllers/confirmations_controller.rb
+++ b/app/controllers/confirmations_controller.rb
@@ -3,6 +3,7 @@
class ConfirmationsController < Devise::ConfirmationsController
include AcceptsPendingInvitations
include GitlabRecaptcha
+ include OneTrustCSP
prepend_before_action :check_recaptcha, only: :create
before_action :load_recaptcha, only: :new
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 8d7686a95fb..2ecd17db487 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -18,6 +18,8 @@ class DashboardController < Dashboard::ApplicationController
feature_category :team_planning, [:issues, :issues_calendar]
feature_category :code_review, [:merge_requests]
+ urgency :low, [:merge_requests]
+
def activity
respond_to do |format|
format.html
diff --git a/app/controllers/google_api/authorizations_controller.rb b/app/controllers/google_api/authorizations_controller.rb
index 76a1c43dfa3..b9c5e87c69c 100644
--- a/app/controllers/google_api/authorizations_controller.rb
+++ b/app/controllers/google_api/authorizations_controller.rb
@@ -8,19 +8,36 @@ module GoogleApi
feature_category :kubernetes_management
+ ##
+ # handle the response from google after the user
+ # goes through authentication and authorization process
def callback
- token, expires_at = GoogleApi::CloudPlatform::Client
- .new(nil, callback_google_api_auth_url)
- .get_token(params[:code])
-
- session[GoogleApi::CloudPlatform::Client.session_key_for_token] = token
- session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] =
- expires_at.to_s
-
+ redirect_uri = redirect_uri_from_session
+ ##
+ # when the user declines authorizations
+ # `error` param is returned
+ if params[:error]
+ flash[:alert] = _('Google Cloud authorizations required')
+ redirect_uri = session[:error_uri]
+ ##
+ # on success, the `code` param is returned
+ elsif params[:code]
+ token, expires_at = GoogleApi::CloudPlatform::Client
+ .new(nil, callback_google_api_auth_url)
+ .get_token(params[:code])
+
+ session[GoogleApi::CloudPlatform::Client.session_key_for_token] = token
+ session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] = expires_at.to_s
+ redirect_uri = redirect_uri_from_session
+ end
+ ##
+ # or google may just timeout
rescue ::Faraday::TimeoutError, ::Faraday::ConnectionFailed
flash[:alert] = _('Timeout connecting to the Google API. Please try again.')
+ ##
+ # regardless, we redirect the user appropriately
ensure
- redirect_to redirect_uri_from_session
+ redirect_to redirect_uri
end
private
diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb
index 899fa614949..f48d03869a4 100644
--- a/app/controllers/graphql_controller.rb
+++ b/app/controllers/graphql_controller.rb
@@ -50,6 +50,8 @@ class GraphqlController < ApplicationController
end
rescue_from StandardError do |exception|
+ @exception_object = exception
+
log_exception(exception)
if Rails.env.test? || Rails.env.development?
@@ -197,7 +199,9 @@ class GraphqlController < ApplicationController
# Merging to :metadata will ensure these are logged as top level keys
payload[:metadata] ||= {}
- payload[:metadata].merge!(graphql: logs)
+ payload[:metadata][:graphql] = logs
+
+ payload[:exception_object] = @exception_object if @exception_object
end
def logs
diff --git a/app/controllers/groups/autocomplete_sources_controller.rb b/app/controllers/groups/autocomplete_sources_controller.rb
index 82f8854bd2b..17cdcd9cb9b 100644
--- a/app/controllers/groups/autocomplete_sources_controller.rb
+++ b/app/controllers/groups/autocomplete_sources_controller.rb
@@ -5,6 +5,8 @@ class Groups::AutocompleteSourcesController < Groups::ApplicationController
feature_category :team_planning, [:issues, :labels, :milestones, :commands]
feature_category :code_review, [:merge_requests]
+ urgency :low, [:merge_requests]
+
def members
render json: ::Groups::ParticipantsService.new(@group, current_user).execute(target)
end
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
index 3152c4d733f..3fbcb2fd7aa 100644
--- a/app/controllers/groups/boards_controller.rb
+++ b/app/controllers/groups/boards_controller.rb
@@ -11,7 +11,6 @@ class Groups::BoardsController < Groups::ApplicationController
push_frontend_feature_flag(:board_multi_select, group, default_enabled: :yaml)
push_frontend_feature_flag(:swimlanes_buffered_rendering, group, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, group, default_enabled: :yaml)
- push_frontend_feature_flag(:labels_widget, group, default_enabled: :yaml)
experiment(:prominent_create_board_btn, subject: current_user) do |e|
e.use { }
e.try { }
diff --git a/app/controllers/groups/crm/contacts_controller.rb b/app/controllers/groups/crm/contacts_controller.rb
new file mode 100644
index 00000000000..f00f4d1df25
--- /dev/null
+++ b/app/controllers/groups/crm/contacts_controller.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class Groups::Crm::ContactsController < Groups::ApplicationController
+ feature_category :team_planning
+
+ before_action :authorize_read_crm_contact!
+
+ def new
+ render action: "index"
+ end
+
+ def edit
+ render action: "index"
+ end
+
+ private
+
+ def authorize_read_crm_contact!
+ render_404 unless can?(current_user, :read_crm_contact, group)
+ end
+end
diff --git a/app/controllers/groups/crm/organizations_controller.rb b/app/controllers/groups/crm/organizations_controller.rb
new file mode 100644
index 00000000000..ab720f490be
--- /dev/null
+++ b/app/controllers/groups/crm/organizations_controller.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class Groups::Crm::OrganizationsController < Groups::ApplicationController
+ feature_category :team_planning
+
+ before_action :authorize_read_crm_organization!
+
+ def new
+ render action: "index"
+ end
+
+ private
+
+ def authorize_read_crm_organization!
+ render_404 unless can?(current_user, :read_crm_organization, group)
+ end
+end
diff --git a/app/controllers/groups/crm_controller.rb b/app/controllers/groups/crm_controller.rb
deleted file mode 100644
index 40661b09be6..00000000000
--- a/app/controllers/groups/crm_controller.rb
+++ /dev/null
@@ -1,30 +0,0 @@
-# frozen_string_literal: true
-
-class Groups::CrmController < Groups::ApplicationController
- feature_category :team_planning
-
- before_action :authorize_read_crm_contact!, only: [:contacts]
- before_action :authorize_read_crm_organization!, only: [:organizations]
-
- def contacts
- respond_to do |format|
- format.html
- end
- end
-
- def organizations
- respond_to do |format|
- format.html
- end
- end
-
- private
-
- def authorize_read_crm_contact!
- render_404 unless can?(current_user, :read_crm_contact, group)
- end
-
- def authorize_read_crm_organization!
- render_404 unless can?(current_user, :read_crm_organization, group)
- end
-end
diff --git a/app/controllers/groups/dependency_proxies_controller.rb b/app/controllers/groups/dependency_proxies_controller.rb
index b037aa52939..2e120de435e 100644
--- a/app/controllers/groups/dependency_proxies_controller.rb
+++ b/app/controllers/groups/dependency_proxies_controller.rb
@@ -5,30 +5,19 @@ module Groups
include ::DependencyProxy::GroupAccess
before_action :authorize_admin_dependency_proxy!, only: :update
- before_action :dependency_proxy
+ before_action :verify_dependency_proxy_enabled!
feature_category :package_registry
- def show
- @blobs_count = group.dependency_proxy_blobs.count
- @blobs_total_size = group.dependency_proxy_blobs.total_size
- end
-
- def update
- dependency_proxy.update(dependency_proxy_params)
-
- redirect_to group_dependency_proxy_path(group)
- end
-
private
def dependency_proxy
@dependency_proxy ||=
- group.dependency_proxy_setting || group.create_dependency_proxy_setting
+ group.dependency_proxy_setting || group.create_dependency_proxy_setting!
end
- def dependency_proxy_params
- params.require(:dependency_proxy_group_setting).permit(:enabled)
+ def verify_dependency_proxy_enabled!
+ render_404 unless dependency_proxy.enabled?
end
end
end
diff --git a/app/controllers/groups/dependency_proxy_for_containers_controller.rb b/app/controllers/groups/dependency_proxy_for_containers_controller.rb
index fc930ffebbd..171314b5f26 100644
--- a/app/controllers/groups/dependency_proxy_for_containers_controller.rb
+++ b/app/controllers/groups/dependency_proxy_for_containers_controller.rb
@@ -19,7 +19,7 @@ class Groups::DependencyProxyForContainersController < ::Groups::DependencyProxy
feature_category :dependency_proxy
def manifest
- result = DependencyProxy::FindOrCreateManifestService.new(group, image, tag, token).execute
+ result = DependencyProxy::FindCachedManifestService.new(group, image, tag, token).execute
if result[:status] == :success
if result[:manifest]
diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb
index 9dbbd385ea8..1e23db9f32b 100644
--- a/app/controllers/groups/variables_controller.rb
+++ b/app/controllers/groups/variables_controller.rb
@@ -8,6 +8,8 @@ module Groups
feature_category :pipeline_authoring
+ urgency :low, [:show]
+
def show
respond_to do |format|
format.json do
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 6ae711a6e14..62336c7eede 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -37,7 +37,7 @@ class GroupsController < Groups::ApplicationController
push_frontend_feature_flag(:iteration_cadences, @group, default_enabled: :yaml)
end
- before_action :export_rate_limit, only: [:export, :download_export]
+ before_action :check_export_rate_limit!, only: [:export, :download_export]
helper_method :captcha_required?
@@ -59,6 +59,9 @@ class GroupsController < Groups::ApplicationController
feature_category :projects, [:projects]
feature_category :importers, [:export, :download_export]
+ urgency :high, [:unfoldered_environment_names]
+ urgency :low, [:merge_requests]
+
def index
redirect_to(current_user ? dashboard_groups_path : explore_groups_path)
end
@@ -92,7 +95,6 @@ class GroupsController < Groups::ApplicationController
if @group.import_state&.in_progress?
redirect_to group_import_path(@group)
else
- publish_invite_members_for_task_experiment
render_show_html
end
end
@@ -312,16 +314,12 @@ class GroupsController < Groups::ApplicationController
url_for(safe_params)
end
- def export_rate_limit
+ def check_export_rate_limit!
prefixed_action = "group_#{params[:action]}".to_sym
scope = params[:action] == :download_export ? @group : nil
- if Gitlab::ApplicationRateLimiter.throttled?(prefixed_action, scope: [current_user, scope].compact)
- Gitlab::ApplicationRateLimiter.log_request(request, "#{prefixed_action}_request_limit".to_sym, current_user)
-
- render plain: _('This endpoint has been requested too many times. Try again later.'), status: :too_many_requests
- end
+ check_rate_limit!(prefixed_action, scope: [current_user, scope].compact)
end
def ensure_export_enabled
@@ -380,13 +378,6 @@ class GroupsController < Groups::ApplicationController
def captcha_required?
captcha_enabled? && !params[:parent_id]
end
-
- def publish_invite_members_for_task_experiment
- return unless params[:open_modal] == 'invite_members_for_task'
- return unless current_user&.can?(:admin_group_member, @group)
-
- experiment(:invite_members_for_task, namespace: @group).publish_to_client
- end
end
GroupsController.prepend_mod_with('GroupsController')
diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb
index e0020c22145..f267d383804 100644
--- a/app/controllers/help_controller.rb
+++ b/app/controllers/help_controller.rb
@@ -59,10 +59,6 @@ class HelpController < ApplicationController
@instance_configuration = InstanceConfiguration.new
end
- def ui
- @user = User.new(id: 0, name: 'John Doe', username: '@johndoe')
- end
-
private
def path_params
diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb
index 53856e4575b..7ad3a2ee358 100644
--- a/app/controllers/import/base_controller.rb
+++ b/app/controllers/import/base_controller.rb
@@ -3,7 +3,7 @@
class Import::BaseController < ApplicationController
include ActionView::Helpers::SanitizeHelper
- before_action :import_rate_limit, only: [:create]
+ before_action -> { check_rate_limit!(:project_import, scope: [current_user, :project_import], redirect_back: true) }, only: [:create]
feature_category :importers
def status
@@ -98,18 +98,4 @@ class Import::BaseController < ApplicationController
def project_save_error(project)
project.errors.full_messages.join(', ')
end
-
- def import_rate_limit
- key = "project_import".to_sym
-
- if rate_limiter.throttled?(key, scope: [current_user, key])
- rate_limiter.log_request(request, "#{key}_request_limit".to_sym, current_user)
-
- redirect_back_or_default(options: { alert: _('This endpoint has been requested too many times. Try again later.') })
- end
- end
-
- def rate_limiter
- ::Gitlab::ApplicationRateLimiter
- end
end
diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb
index bec26cb547d..f26c06b7e37 100644
--- a/app/controllers/import/bulk_imports_controller.rb
+++ b/app/controllers/import/bulk_imports_controller.rb
@@ -40,13 +40,9 @@ class Import::BulkImportsController < ApplicationController
end
def create
- response = ::BulkImports::CreateService.new(current_user, create_params, credentials).execute
+ responses = create_params.map { |entry| ::BulkImports::CreateService.new(current_user, entry, credentials).execute }
- if response.success?
- render json: response.payload.to_json(only: [:id])
- else
- render json: { error: response.message }, status: response.http_status
- end
+ render json: responses.map { |response| { success: response.success?, id: response.payload[:id], message: response.message } }
end
def realtime_changes
diff --git a/app/controllers/import/gitlab_groups_controller.rb b/app/controllers/import/gitlab_groups_controller.rb
index 503b10f766b..aca71f6d57a 100644
--- a/app/controllers/import/gitlab_groups_controller.rb
+++ b/app/controllers/import/gitlab_groups_controller.rb
@@ -4,7 +4,7 @@ class Import::GitlabGroupsController < ApplicationController
include WorkhorseAuthorization
before_action :ensure_group_import_enabled
- before_action :import_rate_limit, only: %i[create]
+ before_action :check_import_rate_limit!, only: %i[create]
feature_category :importers
@@ -55,12 +55,9 @@ class Import::GitlabGroupsController < ApplicationController
render_404 unless Feature.enabled?(:group_import_export, @group, default_enabled: true)
end
- def import_rate_limit
- if Gitlab::ApplicationRateLimiter.throttled?(:group_import, scope: current_user)
- Gitlab::ApplicationRateLimiter.log_request(request, :group_import_request_limit, current_user)
-
- flash[:alert] = _('This endpoint has been requested too many times. Try again later.')
- redirect_to new_group_path
+ def check_import_rate_limit!
+ check_rate_limit!(:group_import, scope: current_user) do
+ redirect_to new_group_path, alert: _('This endpoint has been requested too many times. Try again later.')
end
end
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index d4b1306cc5e..2a7f2d42e2a 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -77,12 +77,6 @@ class InvitesController < ApplicationController
def track_invite_join_click
return unless member && initial_invite_email?
- if params[:experiment_name] == 'invite_email_preview_text'
- experiment(:invite_email_preview_text, actor: member).track(:join_clicked)
- elsif params[:experiment_name] == 'invite_email_from'
- experiment(:invite_email_from, actor: member).track(:join_clicked)
- end
-
Gitlab::Tracking.event(self.class.name, 'join_clicked', label: 'invite_email', property: member.id.to_s)
end
@@ -104,7 +98,6 @@ class InvitesController < ApplicationController
session[:invite_email] = member.invite_email
session[:originating_member_id] = member.id if initial_invite_email?
- session[:invite_email_experiment_name] = params[:experiment_name] if initial_invite_email? && params[:experiment_name]
end
def initial_invite_email?
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index 9d7a1712698..dc5b22e1606 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -9,7 +9,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
after_action :verify_known_sign_in
- protect_from_forgery except: [:kerberos, :saml, :cas3, :failure], with: :exception, prepend: true
+ protect_from_forgery except: [:kerberos, :saml, :cas3, :failure] + AuthHelper.saml_providers, with: :exception, prepend: true
feature_category :authentication_and_authorization
@@ -162,6 +162,10 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
user = auth_user.find_and_update!
if auth_user.valid_sign_in?
+ # In this case the `#current_user` would not be set. So we can't fetch it
+ # from that in `#context_user`. Pushing it manually here makes the information
+ # available in the logs for this request.
+ Gitlab::ApplicationContext.push(user: user)
log_audit_event(user, with: oauth['provider'])
set_remember_me(user)
@@ -287,10 +291,6 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
def fail_admin_mode_invalid_credentials
redirect_to new_admin_session_path, alert: _('Invalid login or password')
end
-
- def context_user
- current_user
- end
end
OmniauthCallbacksController.prepend_mod_with('OmniauthCallbacksController')
diff --git a/app/controllers/profiles/emails_controller.rb b/app/controllers/profiles/emails_controller.rb
index 6e5b18cb885..be2cb270a19 100644
--- a/app/controllers/profiles/emails_controller.rb
+++ b/app/controllers/profiles/emails_controller.rb
@@ -2,8 +2,10 @@
class Profiles::EmailsController < Profiles::ApplicationController
before_action :find_email, only: [:destroy, :resend_confirmation_instructions]
- before_action -> { rate_limit!(:profile_add_new_email) }, only: [:create]
- before_action -> { rate_limit!(:profile_resend_email_confirmation) }, only: [:resend_confirmation_instructions]
+ before_action -> { check_rate_limit!(:profile_add_new_email, scope: current_user, redirect_back: true) },
+ only: [:create]
+ before_action -> { check_rate_limit!(:profile_resend_email_confirmation, scope: current_user, redirect_back: true) },
+ only: [:resend_confirmation_instructions]
feature_category :users
@@ -42,16 +44,6 @@ class Profiles::EmailsController < Profiles::ApplicationController
private
- def rate_limit!(action)
- rate_limiter = ::Gitlab::ApplicationRateLimiter
-
- if rate_limiter.throttled?(action, scope: current_user)
- rate_limiter.log_request(request, action, current_user)
-
- redirect_back_or_default(options: { alert: _('This action has been performed too many times. Try again later.') })
- end
- end
-
def email_params
params.require(:email).permit(:email)
end
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
index e607346b40e..77fae34e2d2 100644
--- a/app/controllers/profiles/two_factor_auths_controller.rb
+++ b/app/controllers/profiles/two_factor_auths_controller.rb
@@ -8,7 +8,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
helper_method :current_password_required?
before_action do
- push_frontend_feature_flag(:webauthn)
+ push_frontend_feature_flag(:webauthn, default_enabled: :yaml)
end
feature_category :authentication_and_authorization
@@ -44,7 +44,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
@qr_code = build_qr_code
@account_string = account_string
- if Feature.enabled?(:webauthn)
+ if Feature.enabled?(:webauthn, default_enabled: :yaml)
setup_webauthn_registration
else
setup_u2f_registration
@@ -69,7 +69,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
@error = { message: _('Invalid pin code.') }
@qr_code = build_qr_code
- if Feature.enabled?(:webauthn)
+ if Feature.enabled?(:webauthn, default_enabled: :yaml)
setup_webauthn_registration
else
setup_u2f_registration
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
index 6330a6aa107..e6b80f90dca 100644
--- a/app/controllers/profiles_controller.rb
+++ b/app/controllers/profiles_controller.rb
@@ -8,7 +8,7 @@ class ProfilesController < Profiles::ApplicationController
before_action :authorize_change_username!, only: :update_username
skip_before_action :require_email, only: [:show, :update]
before_action do
- push_frontend_feature_flag(:webauthn)
+ push_frontend_feature_flag(:webauthn, default_enabled: :yaml)
end
feature_category :users
diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb
index 0d5f64c739c..cf432cfb429 100644
--- a/app/controllers/projects/autocomplete_sources_controller.rb
+++ b/app/controllers/projects/autocomplete_sources_controller.rb
@@ -8,6 +8,8 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
feature_category :users, [:members]
feature_category :snippets, [:snippets]
+ urgency :low, [:merge_requests]
+
def members
render json: ::Projects::ParticipantsService.new(@project, current_user).execute(target)
end
diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb
index 0f87690bba5..57a06f26f8c 100644
--- a/app/controllers/projects/blame_controller.rb
+++ b/app/controllers/projects/blame_controller.rb
@@ -27,3 +27,5 @@ class Projects::BlameController < Projects::ApplicationController
@blame = Gitlab::View::Presenter::Factory.new(@blame, project: @project, path: @path).fabricate!
end
end
+
+Projects::BlameController.prepend_mod
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index cd50c8cf5b1..b30ef7506aa 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -44,7 +44,7 @@ class Projects::BlobController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml)
- push_frontend_feature_flag(:refactor_text_viewer, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:highlight_js, @project, default_enabled: :yaml)
push_frontend_feature_flag(:consolidated_edit_button, @project, default_enabled: :yaml)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
end
@@ -99,7 +99,7 @@ class Projects::BlobController < Projects::ApplicationController
@content = params[:content]
@blob.load_all_data!
diffy = Diffy::Diff.new(@blob.data, @content, diff: '-U 3', include_diff_info: true)
- diff_lines = diffy.diff.scan(/.*\n/)[2..-1]
+ diff_lines = diffy.diff.scan(/.*\n/)[2..]
diff_lines = Gitlab::Diff::Parser.new.parse(diff_lines).to_a
@diff_lines = Gitlab::Diff::Highlight.new(diff_lines, repository: @repository).highlight
@@ -298,3 +298,5 @@ class Projects::BlobController < Projects::ApplicationController
experiment(:code_quality_walkthrough, namespace: @project.root_ancestor).track(:commit_created)
end
end
+
+Projects::BlobController.prepend_mod
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
index 7354c2c71ac..81ad6243efe 100644
--- a/app/controllers/projects/boards_controller.rb
+++ b/app/controllers/projects/boards_controller.rb
@@ -11,7 +11,6 @@ class Projects::BoardsController < Projects::ApplicationController
push_frontend_feature_flag(:issue_boards_filtered_search, project, default_enabled: :yaml)
push_frontend_feature_flag(:board_multi_select, project, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml)
- push_frontend_feature_flag(:labels_widget, project, default_enabled: :yaml)
experiment(:prominent_create_board_btn, subject: current_user) do |e|
e.use { }
e.try { }
diff --git a/app/controllers/projects/ci/lints_controller.rb b/app/controllers/projects/ci/lints_controller.rb
index 9dc3194df85..7ef5016ac00 100644
--- a/app/controllers/projects/ci/lints_controller.rb
+++ b/app/controllers/projects/ci/lints_controller.rb
@@ -6,6 +6,7 @@ class Projects::Ci::LintsController < Projects::ApplicationController
feature_category :pipeline_authoring
respond_to :json, only: [:create]
+ urgency :low, [:create]
def show
end
diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb
index 600516f95a2..6f12e3940dd 100644
--- a/app/controllers/projects/ci/pipeline_editor_controller.rb
+++ b/app/controllers/projects/ci/pipeline_editor_controller.rb
@@ -9,6 +9,8 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController
feature_category :pipeline_authoring
+ urgency :low, [:show]
+
def show
end
@@ -21,7 +23,7 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController
def setup_walkthrough_experiment
experiment(:pipeline_editor_walkthrough, namespace: @project.namespace, sticky_to: current_user) do |e|
e.candidate {}
- e.record!
+ e.publish_to_database
end
end
end
diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb
index 5154f145b46..ba83f8dad35 100644
--- a/app/controllers/projects/forks_controller.rb
+++ b/app/controllers/projects/forks_controller.rb
@@ -22,6 +22,8 @@ class Projects::ForksController < Projects::ApplicationController
end
def index
+ @sort = params[:sort]
+
@total_forks_count = project.forks.size
@public_forks_count = project.forks.public_only.size
@private_forks_count = @total_forks_count - project.forks.public_and_internal_only.size
diff --git a/app/controllers/projects/google_cloud/base_controller.rb b/app/controllers/projects/google_cloud/base_controller.rb
new file mode 100644
index 00000000000..aff305ab7d6
--- /dev/null
+++ b/app/controllers/projects/google_cloud/base_controller.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class Projects::GoogleCloud::BaseController < Projects::ApplicationController
+ feature_category :five_minute_production_app
+
+ before_action :admin_project_google_cloud!
+ before_action :google_oauth2_enabled!
+ before_action :feature_flag_enabled!
+
+ private
+
+ def admin_project_google_cloud!
+ access_denied! unless can?(current_user, :admin_project_google_cloud, project)
+ end
+
+ def google_oauth2_enabled!
+ config = Gitlab::Auth::OAuth::Provider.config_for('google_oauth2')
+ if config.app_id.blank? || config.app_secret.blank?
+ access_denied! 'This GitLab instance not configured for Google Oauth2.'
+ end
+ end
+
+ def feature_flag_enabled!
+ access_denied! unless Feature.enabled?(:incubation_5mp_google_cloud, project)
+ end
+end
diff --git a/app/controllers/projects/google_cloud/service_accounts_controller.rb b/app/controllers/projects/google_cloud/service_accounts_controller.rb
new file mode 100644
index 00000000000..a69a744154c
--- /dev/null
+++ b/app/controllers/projects/google_cloud/service_accounts_controller.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud::BaseController
+ before_action :validate_gcp_token!
+
+ def index
+ @google_cloud_path = project_google_cloud_index_path(project)
+ google_api_client = GoogleApi::CloudPlatform::Client.new(token_in_session, nil)
+ gcp_projects = google_api_client.list_projects
+
+ if gcp_projects.empty?
+ @js_data = { screen: 'no_gcp_projects' }.to_json
+ render status: :unauthorized, template: 'projects/google_cloud/errors/no_gcp_projects'
+ else
+ @js_data = {
+ screen: 'service_accounts_form',
+ gcpProjects: gcp_projects,
+ environments: project.environments,
+ cancelPath: project_google_cloud_index_path(project)
+ }.to_json
+ end
+ rescue Google::Apis::ClientError => error
+ handle_gcp_error(error, project)
+ end
+
+ def create
+ google_api_client = GoogleApi::CloudPlatform::Client.new(token_in_session, nil)
+ service_accounts_service = GoogleCloud::ServiceAccountsService.new(project)
+ gcp_project = params[:gcp_project]
+ environment = params[:environment]
+ generated_name = "GitLab :: #{@project.name} :: #{environment}"
+ generated_desc = "GitLab generated service account for project '#{@project.name}' and environment '#{environment}'"
+
+ service_account = google_api_client.create_service_account(gcp_project, generated_name, generated_desc)
+ service_account_key = google_api_client.create_service_account_key(gcp_project, service_account.unique_id)
+
+ service_accounts_service.add_for_project(
+ environment,
+ service_account.project_id,
+ service_account.to_json,
+ service_account_key.to_json
+ )
+
+ redirect_to project_google_cloud_index_path(project), notice: _('Service account generated successfully')
+ rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => error
+ handle_gcp_error(error, project)
+ end
+
+ private
+
+ def validate_gcp_token!
+ is_token_valid = GoogleApi::CloudPlatform::Client.new(token_in_session, nil)
+ .validate_token(expires_at_in_session)
+
+ return if is_token_valid
+
+ return_url = project_google_cloud_index_path(project)
+ state = generate_session_key_redirect(request.url, return_url)
+ @authorize_url = GoogleApi::CloudPlatform::Client.new(nil,
+ callback_google_api_auth_url,
+ state: state).authorize_url
+ redirect_to @authorize_url
+ end
+
+ def generate_session_key_redirect(uri, error_uri)
+ GoogleApi::CloudPlatform::Client.new_session_key_for_redirect_uri do |key|
+ session[key] = uri
+ session[:error_uri] = error_uri
+ end
+ end
+
+ def token_in_session
+ session[GoogleApi::CloudPlatform::Client.session_key_for_token]
+ end
+
+ def expires_at_in_session
+ session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at]
+ end
+
+ def handle_gcp_error(error, project)
+ Gitlab::ErrorTracking.track_exception(error, project_id: project.id)
+ @js_data = { screen: 'gcp_error', error: error.to_s }.to_json
+ render status: :unauthorized, template: 'projects/google_cloud/errors/gcp_error'
+ end
+end
diff --git a/app/controllers/projects/google_cloud_controller.rb b/app/controllers/projects/google_cloud_controller.rb
index 7257ed1ef6f..1fa8ae60376 100644
--- a/app/controllers/projects/google_cloud_controller.rb
+++ b/app/controllers/projects/google_cloud_controller.rb
@@ -1,34 +1,12 @@
# frozen_string_literal: true
-class Projects::GoogleCloudController < Projects::ApplicationController
- feature_category :google_cloud
-
- before_action :admin_project_google_cloud?
- before_action :google_oauth2_enabled?
- before_action :feature_flag_enabled?
-
+class Projects::GoogleCloudController < Projects::GoogleCloud::BaseController
def index
@js_data = {
+ screen: 'home',
serviceAccounts: GoogleCloud::ServiceAccountsService.new(project).find_for_project,
- createServiceAccountUrl: '#mocked-url-create-service',
+ createServiceAccountUrl: project_google_cloud_service_accounts_path(project),
emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg')
}.to_json
end
-
- private
-
- def admin_project_google_cloud?
- access_denied! unless can?(current_user, :admin_project_google_cloud, project)
- end
-
- def google_oauth2_enabled?
- config = Gitlab::Auth::OAuth::Provider.config_for('google_oauth2')
- if config.app_id.blank? || config.app_secret.blank?
- access_denied! 'This GitLab instance not configured for Google Oauth2.'
- end
- end
-
- def feature_flag_enabled?
- access_denied! unless Feature.enabled?(:incubation_5mp_google_cloud)
- end
end
diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb
index c79e5a8cc85..99eba32e00f 100644
--- a/app/controllers/projects/hooks_controller.rb
+++ b/app/controllers/projects/hooks_controller.rb
@@ -6,7 +6,7 @@ class Projects::HooksController < Projects::ApplicationController
# Authorize
before_action :authorize_admin_project!
before_action :hook_logs, only: :edit
- before_action -> { create_rate_limit(:project_testing_hook, @project) }, only: :test
+ before_action -> { check_rate_limit!(:project_testing_hook, scope: [@project, current_user]) }, only: :test
respond_to :html
diff --git a/app/controllers/projects/integrations/shimos_controller.rb b/app/controllers/projects/integrations/shimos_controller.rb
new file mode 100644
index 00000000000..827dbb8f3f9
--- /dev/null
+++ b/app/controllers/projects/integrations/shimos_controller.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Projects
+ module Integrations
+ class ShimosController < Projects::ApplicationController
+ feature_category :integrations
+
+ before_action :ensure_renderable
+
+ def show; end
+
+ private
+
+ def ensure_renderable
+ render_404 unless Feature.enabled?(:shimo_integration, project) && project.has_shimo? && project.shimo_integration&.render?
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 853e9c7ccdd..fc67cd98d15 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -10,7 +10,7 @@ class Projects::IssuesController < Projects::ApplicationController
include RecordUserLastActivity
ISSUES_EXCEPT_ACTIONS = %i[index calendar new create bulk_update import_csv export_csv service_desk].freeze
- SET_ISSUEABLES_INDEX_ONLY_ACTIONS = %i[index calendar service_desk].freeze
+ SET_ISSUABLES_INDEX_ONLY_ACTIONS = %i[index calendar service_desk].freeze
prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) }
prepend_before_action(only: [:calendar]) { authenticate_sessionless_user!(:ics) }
@@ -22,7 +22,7 @@ class Projects::IssuesController < Projects::ApplicationController
before_action :issue, unless: ->(c) { ISSUES_EXCEPT_ACTIONS.include?(c.action_name.to_sym) }
after_action :log_issue_show, unless: ->(c) { ISSUES_EXCEPT_ACTIONS.include?(c.action_name.to_sym) }
- before_action :set_issuables_index, if: ->(c) { SET_ISSUEABLES_INDEX_ONLY_ACTIONS.include?(c.action_name.to_sym) }
+ before_action :set_issuables_index, if: ->(c) { SET_ISSUABLES_INDEX_ONLY_ACTIONS.include?(c.action_name.to_sym) }
# Allow write(create) issue
before_action :authorize_create_issue!, only: [:new, :create]
@@ -37,7 +37,9 @@ class Projects::IssuesController < Projects::ApplicationController
before_action :authorize_download_code!, only: [:related_branches]
# Limit the amount of issues created per minute
- before_action :create_rate_limit, only: [:create], if: -> { Feature.disabled?('rate_limited_service_issues_create', project, default_enabled: :yaml) }
+ before_action -> { check_rate_limit!(:issues_create, scope: [@project, @current_user])},
+ only: [:create],
+ if: -> { Feature.disabled?('rate_limited_service_issues_create', project, default_enabled: :yaml) }
before_action do
push_frontend_feature_flag(:tribute_autocomplete, @project)
@@ -49,19 +51,9 @@ class Projects::IssuesController < Projects::ApplicationController
before_action only: :show do
push_frontend_feature_flag(:real_time_issue_sidebar, @project, default_enabled: :yaml)
- push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:confidential_notes, project&.group, default_enabled: :yaml)
push_frontend_feature_flag(:issue_assignees_widget, @project, default_enabled: :yaml)
- push_frontend_feature_flag(:labels_widget, @project, default_enabled: :yaml)
push_frontend_feature_flag(:paginated_issue_discussions, @project, default_enabled: :yaml)
-
- experiment(:invite_members_in_comment, namespace: @project.root_ancestor) do |experiment_instance|
- experiment_instance.exclude! unless helpers.can_admin_project_member?(@project)
-
- experiment_instance.use {}
- experiment_instance.try(:invite_member_link) {}
-
- experiment_instance.track(:view, property: @project.root_ancestor.id.to_s)
- end
end
around_action :allow_gitaly_ref_name_caching, only: [:discussions]
@@ -373,20 +365,6 @@ class Projects::IssuesController < Projects::ApplicationController
project_compare_path(project, from: project.default_branch, to: branch[:name])
end
- def create_rate_limit
- key = :issues_create
-
- if rate_limiter.throttled?(key, scope: [@project, @current_user])
- rate_limiter.log_request(request, "#{key}_request_limit".to_sym, current_user)
-
- render plain: _('This endpoint has been requested too many times. Try again later.'), status: :too_many_requests
- end
- end
-
- def rate_limiter
- ::Gitlab::ApplicationRateLimiter
- end
-
def service_desk?
action_name == 'service_desk'
end
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index 81b8da9cba3..fa7c62c34dd 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -4,8 +4,8 @@ class Projects::JobsController < Projects::ApplicationController
include SendFileUpload
include ContinueParams
- before_action :find_job_as_build, except: [:index, :play]
- before_action :find_job_as_processable, only: [:play]
+ before_action :find_job_as_build, except: [:index, :play, :show]
+ before_action :find_job_as_processable, only: [:play, :show]
before_action :authorize_read_build_trace!, only: [:trace, :raw]
before_action :authorize_read_build!
before_action :authorize_update_build!,
@@ -42,7 +42,7 @@ class Projects::JobsController < Projects::ApplicationController
format.json do
Gitlab::PollingInterval.set_header(response, interval: 10_000)
- render json: BuildSerializer
+ render json: Ci::JobSerializer
.new(project: @project, current_user: @current_user)
.represent(@build.present(current_user: current_user), {}, BuildDetailsEntity)
end
@@ -118,7 +118,7 @@ class Projects::JobsController < Projects::ApplicationController
end
def status
- render json: BuildSerializer
+ render json: Ci::JobSerializer
.new(project: @project, current_user: @current_user)
.represent_status(@build.present(current_user: current_user))
end
diff --git a/app/controllers/projects/learn_gitlab_controller.rb b/app/controllers/projects/learn_gitlab_controller.rb
index 91a43c5f03f..177533b89c8 100644
--- a/app/controllers/projects/learn_gitlab_controller.rb
+++ b/app/controllers/projects/learn_gitlab_controller.rb
@@ -3,6 +3,7 @@
class Projects::LearnGitlabController < Projects::ApplicationController
before_action :authenticate_user!
before_action :check_experiment_enabled?
+ before_action :enable_invite_for_help_continuous_onboarding_experiment
feature_category :users
@@ -14,4 +15,13 @@ class Projects::LearnGitlabController < Projects::ApplicationController
def check_experiment_enabled?
return access_denied! unless helpers.learn_gitlab_enabled?(project)
end
+
+ def enable_invite_for_help_continuous_onboarding_experiment
+ return unless current_user.can?(:admin_group_member, project.namespace)
+
+ experiment(:invite_for_help_continuous_onboarding, namespace: project.namespace) do |e|
+ e.candidate {}
+ e.publish_to_database
+ end
+ end
end
diff --git a/app/controllers/projects/merge_requests/conflicts_controller.rb b/app/controllers/projects/merge_requests/conflicts_controller.rb
index a8038878504..76a233afa13 100644
--- a/app/controllers/projects/merge_requests/conflicts_controller.rb
+++ b/app/controllers/projects/merge_requests/conflicts_controller.rb
@@ -5,6 +5,12 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap
before_action :authorize_can_resolve_conflicts!
+ urgency :low, [
+ :show,
+ :conflict_for_path,
+ :resolve_conflicts
+ ]
+
def show
respond_to do |format|
format.html do
diff --git a/app/controllers/projects/merge_requests/content_controller.rb b/app/controllers/projects/merge_requests/content_controller.rb
index 399745151b1..588fc85ff77 100644
--- a/app/controllers/projects/merge_requests/content_controller.rb
+++ b/app/controllers/projects/merge_requests/content_controller.rb
@@ -13,6 +13,11 @@ class Projects::MergeRequests::ContentController < Projects::MergeRequests::Appl
FAST_POLLING_INTERVAL = 10.seconds.in_milliseconds
SLOW_POLLING_INTERVAL = 5.minutes.in_milliseconds
+ urgency :low, [
+ :widget,
+ :cached_widget
+ ]
+
def widget
respond_to do |format|
format.json do
diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb
index ecc5ad1f84e..beb179f584b 100644
--- a/app/controllers/projects/merge_requests/creations_controller.rb
+++ b/app/controllers/projects/merge_requests/creations_controller.rb
@@ -10,6 +10,15 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
before_action :apply_diff_view_cookie!, only: [:diffs, :diff_for_path]
before_action :build_merge_request, except: [:create]
+ urgency :low, [
+ :new,
+ :create,
+ :pipelines,
+ :diffs,
+ :branch_from,
+ :branch_to
+ ]
+
def new
define_new_vars
end
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index 1188aec24a8..32ca7d779d2 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -14,6 +14,13 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
after_action :track_viewed_diffs_events, only: [:diffs_batch]
+ urgency :low, [
+ :show,
+ :diff_for_path,
+ :diffs_batch,
+ :diffs_metadata
+ ]
+
def show
render_diffs
end
@@ -36,6 +43,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
options = {
environment: environment,
merge_request: @merge_request,
+ commit: commit,
diff_view: diff_view,
merge_ref_head_diff: render_merge_ref_head_diff?,
pagination_data: diffs.pagination_data,
diff --git a/app/controllers/projects/merge_requests/drafts_controller.rb b/app/controllers/projects/merge_requests/drafts_controller.rb
index ca3f36cafe1..645720a0889 100644
--- a/app/controllers/projects/merge_requests/drafts_controller.rb
+++ b/app/controllers/projects/merge_requests/drafts_controller.rb
@@ -9,6 +9,13 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli
before_action :authorize_admin_draft!, only: [:update, :destroy]
before_action :authorize_admin_draft!, if: -> { action_name == 'publish' && params[:id].present? }
+ urgency :low, [
+ :create,
+ :update,
+ :destroy,
+ :publish
+ ]
+
def index
drafts = prepare_notes_for_rendering(draft_notes)
render json: DraftNoteSerializer.new(current_user: current_user).represent(drafts)
@@ -110,7 +117,7 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli
def render_draft_note(note)
params = { target_id: merge_request.id, target_type: 'MergeRequest', text: note.note }
result = PreviewMarkdownService.new(@project, current_user, params).execute
- markdown_params = { markdown_engine: result[:markdown_engine], issuable_state_filter_enabled: true }
+ markdown_params = { markdown_engine: result[:markdown_engine], issuable_reference_expansion_enabled: true }
note.rendered_note = view_context.markdown(result[:text], markdown_params)
note.users_referenced = result[:users]
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 6c5a8aa0610..7133233f083 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -42,21 +42,11 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:restructured_mr_widget, project, default_enabled: :yaml)
push_frontend_feature_flag(:mr_changes_fluid_layout, project, default_enabled: :yaml)
push_frontend_feature_flag(:mr_attention_requests, project, default_enabled: :yaml)
- push_frontend_feature_flag(:labels_widget, project, default_enabled: :yaml)
# Usage data feature flags
push_frontend_feature_flag(:users_expanding_widgets_usage_data, @project, default_enabled: :yaml)
push_frontend_feature_flag(:diff_settings_usage_data, default_enabled: :yaml)
push_frontend_feature_flag(:diff_searching_usage_data, @project, default_enabled: :yaml)
-
- experiment(:invite_members_in_comment, namespace: @project.root_ancestor) do |experiment_instance|
- experiment_instance.exclude! unless helpers.can_admin_project_member?(@project)
-
- experiment_instance.use {}
- experiment_instance.try(:invite_member_link) {}
-
- experiment_instance.track(:view, property: @project.root_ancestor.id.to_s)
- end
end
before_action do
@@ -74,15 +64,28 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
:show, :toggle_award_emoji, :toggle_subscription, :update
]
- feature_category :code_testing, [
- :test_reports, :coverage_reports, :codequality_reports,
- :codequality_mr_diff_reports
- ]
-
+ feature_category :code_testing, [:test_reports, :coverage_reports]
+ feature_category :code_quality, [:codequality_reports, :codequality_mr_diff_reports]
feature_category :accessibility_testing, [:accessibility_reports]
feature_category :infrastructure_as_code, [:terraform_reports]
feature_category :continuous_integration, [:pipeline_status, :pipelines, :exposed_artifacts]
+ urgency :high, [:export_csv]
+ urgency :low, [
+ :index,
+ :show,
+ :commits,
+ :bulk_update,
+ :edit,
+ :update,
+ :cancel_auto_merge,
+ :merge,
+ :ci_environments_status,
+ :destroy,
+ :rebase,
+ :discussions
+ ]
+
def index
@merge_requests = @issuables
@@ -286,7 +289,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
if merge_request.errors.present?
render json: @merge_request.errors, status: :bad_request
else
- render json: serializer.represent(@merge_request, serializer: 'basic')
+ render json: serializer.represent(@merge_request, serializer: params[:serializer] || 'basic')
end
end
end
diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb
index e8057308386..7322e08e62e 100644
--- a/app/controllers/projects/notes_controller.rb
+++ b/app/controllers/projects/notes_controller.rb
@@ -57,7 +57,7 @@ class Projects::NotesController < Projects::ApplicationController
def outdated_line_change
diff_lines = Rails.cache.fetch(['note', note.id, 'oudated_line_change'], expires_in: 7.days) do
- ::MergeRequests::OutdatedDiscussionDiffLinesService.new(project: @project, note: note).execute.to_json
+ ::MergeRequests::OutdatedDiscussionDiffLinesService.new(project: note.noteable.source_project, note: note).execute.to_json
end
render json: diff_lines
diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb
index 4af7508b935..ac94cc001dd 100644
--- a/app/controllers/projects/pipeline_schedules_controller.rb
+++ b/app/controllers/projects/pipeline_schedules_controller.rb
@@ -3,7 +3,7 @@
class Projects::PipelineSchedulesController < Projects::ApplicationController
before_action :schedule, except: [:index, :new, :create]
- before_action :play_rate_limit, only: [:play]
+ before_action :check_play_rate_limit!, only: [:play]
before_action :authorize_play_pipeline_schedule!, only: [:play]
before_action :authorize_read_pipeline_schedule!
before_action :authorize_create_pipeline_schedule!, only: [:new, :create]
@@ -81,19 +81,15 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
private
- def play_rate_limit
+ def check_play_rate_limit!
return unless current_user
- if rate_limiter.throttled?(:play_pipeline_schedule, scope: [current_user, schedule])
+ check_rate_limit!(:play_pipeline_schedule, scope: [current_user, schedule]) do
flash[:alert] = _('You cannot play this scheduled pipeline at the moment. Please wait a minute.')
redirect_to pipeline_schedules_path(@project)
end
end
- def rate_limiter
- ::Gitlab::ApplicationRateLimiter
- end
-
def schedule
@schedule ||= project.pipeline_schedules.find(params[:id])
end
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index a2312484a9b..71dc67bb6dc 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -14,13 +14,22 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
before_action :ensure_pipeline, only: [:show, :downloadable_artifacts]
+ before_action do
+ push_frontend_feature_flag(:jobs_tab_vue, @project, default_enabled: :yaml)
+ end
+
# Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/225596
before_action :redirect_for_legacy_scope_filter, only: [:index], if: -> { request.format.html? }
around_action :allow_gitaly_ref_name_caching, only: [:index, :show]
+ # Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/345074
track_redis_hll_event :charts, name: 'p_analytics_pipelines'
+ track_redis_hll_event :charts, name: 'p_analytics_ci_cd_pipelines', if: -> { should_track_ci_cd_pipelines? }
+ track_redis_hll_event :charts, name: 'p_analytics_ci_cd_deployment_frequency', if: -> { should_track_ci_cd_deployment_frequency? }
+ track_redis_hll_event :charts, name: 'p_analytics_ci_cd_lead_time', if: -> { should_track_ci_cd_lead_time? }
+
wrap_parameters Ci::Pipeline
POLLING_INTERVAL = 10_000
@@ -307,7 +316,7 @@ class Projects::PipelinesController < Projects::ApplicationController
e.control {}
e.candidate {}
- e.record!
+ e.publish_to_database
end
end
@@ -320,9 +329,21 @@ class Projects::PipelinesController < Projects::ApplicationController
e.control {}
e.candidate {}
- e.record!
+ e.publish_to_database
end
end
+
+ def should_track_ci_cd_pipelines?
+ params[:chart].blank? || params[:chart] == 'pipelines'
+ end
+
+ def should_track_ci_cd_deployment_frequency?
+ params[:chart] == 'deployment-frequency'
+ end
+
+ def should_track_ci_cd_lead_time?
+ params[:chart] == 'lead-time'
+ end
end
Projects::PipelinesController.prepend_mod_with('Projects::PipelinesController')
diff --git a/app/controllers/projects/prometheus/alerts_controller.rb b/app/controllers/projects/prometheus/alerts_controller.rb
index 312919831d4..7aebff13278 100644
--- a/app/controllers/projects/prometheus/alerts_controller.rb
+++ b/app/controllers/projects/prometheus/alerts_controller.rb
@@ -82,17 +82,17 @@ module Projects
def create_service
Projects::Prometheus::Alerts::CreateService
- .new(project, current_user, alerts_params)
+ .new(project: project, current_user: current_user, params: alerts_params)
end
def update_service
Projects::Prometheus::Alerts::UpdateService
- .new(project, current_user, alerts_params)
+ .new(project: project, current_user: current_user, params: alerts_params)
end
def destroy_service
Projects::Prometheus::Alerts::DestroyService
- .new(project, current_user, nil)
+ .new(project: project, current_user: current_user, params: nil)
end
def schedule_prometheus_update!
diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb
index e86d2490282..9707b70f26f 100644
--- a/app/controllers/projects/raw_controller.rb
+++ b/app/controllers/projects/raw_controller.rb
@@ -13,7 +13,7 @@ class Projects::RawController < Projects::ApplicationController
before_action :set_ref_and_path
before_action :require_non_empty_project
before_action :authorize_download_code!
- before_action :show_rate_limit, only: [:show], unless: :external_storage_request?
+ before_action :check_show_rate_limit!, only: [:show], unless: :external_storage_request?
before_action :redirect_to_external_storage, only: :show, if: :static_objects_external_storage_enabled?
feature_category :source_code_management
@@ -33,21 +33,11 @@ class Projects::RawController < Projects::ApplicationController
@ref, @path = extract_ref(get_id)
end
- def show_rate_limit
- if rate_limiter.throttled?(:show_raw_controller, scope: [@project, @path], threshold: raw_blob_request_limit)
- rate_limiter.log_request(request, :raw_blob_request_limit, current_user)
-
+ def check_show_rate_limit!
+ check_rate_limit!(:raw_blob, scope: [@project, @path]) do
render plain: _('You cannot access the raw file. Please wait a minute.'), status: :too_many_requests
end
end
-
- def rate_limiter
- ::Gitlab::ApplicationRateLimiter
- end
-
- def raw_blob_request_limit
- Gitlab::CurrentSettings
- .current_application_settings
- .raw_blob_request_limit
- end
end
+
+Projects::RawController.prepend_mod
diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb
index 8beebb52980..77826a2f789 100644
--- a/app/controllers/projects/repositories_controller.rb
+++ b/app/controllers/projects/repositories_controller.rb
@@ -3,16 +3,16 @@
class Projects::RepositoriesController < Projects::ApplicationController
include ExtractsPath
include StaticObjectExternalStorage
- include Gitlab::RateLimitHelpers
include HotlinkInterceptor
+ include Gitlab::RepositoryArchiveRateLimiter
prepend_before_action(only: [:archive]) { authenticate_sessionless_user!(:archive) }
skip_before_action :default_cache_headers, only: :archive
# Authorize
+ before_action :check_archive_rate_limiting!, only: :archive
before_action :require_non_empty_project, except: :create
- before_action :archive_rate_limit!, only: :archive
before_action :intercept_hotlinking!, only: :archive
before_action :assign_archive_vars, only: :archive
before_action :assign_append_sha, only: :archive
@@ -42,12 +42,6 @@ class Projects::RepositoriesController < Projects::ApplicationController
private
- def archive_rate_limit!
- if archive_rate_limit_reached?(current_user, @project)
- render plain: ::Gitlab::RateLimitHelpers::ARCHIVE_RATE_LIMIT_REACHED_MESSAGE, status: :too_many_requests
- end
- end
-
def repo_params
@repo_params ||= { ref: @ref, path: params[:path], format: params[:format], append_sha: @append_sha }
end
@@ -125,6 +119,12 @@ class Projects::RepositoriesController < Projects::ApplicationController
[path, nil]
end
end
+
+ def check_archive_rate_limiting!
+ check_archive_rate_limit!(current_user, @project) do
+ render(plain: _('This archive has been requested too many times. Try again later.'), status: :too_many_requests)
+ end
+ end
end
Projects::RepositoriesController.prepend_mod_with('Projects::RepositoriesController')
diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb
index e841c3e3d49..62a9f8a4625 100644
--- a/app/controllers/projects/runners_controller.rb
+++ b/app/controllers/projects/runners_controller.rb
@@ -4,8 +4,6 @@ class Projects::RunnersController < Projects::ApplicationController
before_action :authorize_admin_build!
before_action :runner, only: [:edit, :update, :destroy, :pause, :resume, :show]
- layout 'project_settings'
-
feature_category :runner
def index
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index 4fe37352995..ef6c10d43cd 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -9,10 +9,10 @@ module Projects
layout 'project_settings'
before_action :authorize_admin_pipeline!
+ before_action :check_builds_available!
before_action :define_variables
before_action do
push_frontend_feature_flag(:ajax_new_deploy_token, @project)
- push_frontend_feature_flag(:ci_scoped_job_token, @project, default_enabled: :yaml)
end
helper_method :highlight_badge
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index f8f2c1f0836..660ebcc30d3 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -60,3 +60,5 @@ class Projects::TreeController < Projects::ApplicationController
}
end
end
+
+Projects::TreeController.prepend_mod
diff --git a/app/controllers/projects/usage_quotas_controller.rb b/app/controllers/projects/usage_quotas_controller.rb
index b319e427eaa..680874ffee4 100644
--- a/app/controllers/projects/usage_quotas_controller.rb
+++ b/app/controllers/projects/usage_quotas_controller.rb
@@ -9,14 +9,5 @@ class Projects::UsageQuotasController < Projects::ApplicationController
def index
@hide_search_settings = true
- @storage_app_data = {
- project_path: @project.full_path,
- usage_quotas_help_page_path: help_page_path('user/usage_quotas'),
- build_artifacts_help_page_path: help_page_path('ci/pipelines/job_artifacts', anchor: 'when-job-artifacts-are-deleted'),
- packages_help_page_path: help_page_path('user/packages/package_registry/index.md', anchor: 'delete-a-package'),
- repository_help_page_path: help_page_path('user/project/repository/reducing_the_repo_size_using_git'),
- snippets_help_page_path: help_page_path('user/snippets', anchor: 'reduce-snippets-repository-size'),
- wiki_help_page_path: help_page_path('administration/wikis/index.md', anchor: 'reduce-wiki-repository-size')
- }
end
end
diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb
index f93c75a203e..e7bccf5a243 100644
--- a/app/controllers/projects/variables_controller.rb
+++ b/app/controllers/projects/variables_controller.rb
@@ -5,6 +5,8 @@ class Projects::VariablesController < Projects::ApplicationController
feature_category :pipeline_authoring
+ urgency :low, [:show, :update]
+
def show
respond_to do |format|
format.json do
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 5b17b75a963..04dde5ef7b2 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -30,14 +30,15 @@ class ProjectsController < Projects::ApplicationController
before_action :event_filter, only: [:show, :activity]
# Project Export Rate Limit
- before_action :export_rate_limit, only: [:export, :download_export, :generate_new_export]
+ before_action :check_export_rate_limit!, only: [:export, :download_export, :generate_new_export]
before_action do
push_frontend_feature_flag(:lazy_load_commits, @project, default_enabled: :yaml)
push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml)
- push_frontend_feature_flag(:refactor_text_viewer, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:highlight_js, @project, default_enabled: :yaml)
push_frontend_feature_flag(:increase_page_size_exponentially, @project, default_enabled: :yaml)
push_frontend_feature_flag(:new_dir_modal, @project, default_enabled: :yaml)
+ push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks)
end
layout :determine_layout
@@ -51,7 +52,9 @@ class ProjectsController < Projects::ApplicationController
feature_category :team_planning, [:preview_markdown, :new_issuable_address]
feature_category :importers, [:export, :remove_export, :generate_new_export, :download_export]
feature_category :code_review, [:unfoldered_environment_names]
+
urgency :low, [:refs]
+ urgency :high, [:unfoldered_environment_names]
def index
redirect_to(current_user ? root_path : explore_root_path)
@@ -116,7 +119,10 @@ class ProjectsController < Projects::ApplicationController
if @project.errors[:new_namespace].present?
flash[:alert] = @project.errors[:new_namespace].first
+ return redirect_to edit_project_path(@project)
end
+
+ redirect_to edit_project_path(@project)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -126,6 +132,8 @@ class ProjectsController < Projects::ApplicationController
if ::Projects::UnlinkForkService.new(@project, current_user).execute
flash[:notice] = _('The fork relationship has been removed.')
end
+
+ redirect_to edit_project_path(@project)
end
def activity
@@ -452,6 +460,7 @@ class ProjectsController < Projects::ApplicationController
:packages_enabled,
:service_desk_enabled,
:merge_commit_template,
+ :squash_commit_template,
project_setting_attributes: project_setting_attributes
] + [project_feature_attributes: project_feature_attributes]
end
@@ -535,20 +544,12 @@ class ProjectsController < Projects::ApplicationController
@project = @project.present(current_user: current_user)
end
- def export_rate_limit
+ def check_export_rate_limit!
prefixed_action = "project_#{params[:action]}".to_sym
project_scope = params[:action] == 'download_export' ? @project : nil
- if rate_limiter.throttled?(prefixed_action, scope: [current_user, project_scope].compact)
- rate_limiter.log_request(request, "#{prefixed_action}_request_limit".to_sym, current_user)
-
- render plain: _('This endpoint has been requested too many times. Try again later.'), status: :too_many_requests
- end
- end
-
- def rate_limiter
- ::Gitlab::ApplicationRateLimiter
+ check_rate_limit!(prefixed_action, scope: [current_user, project_scope].compact)
end
def render_edit
diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb
index 39d3125a4a3..41fd1b7a1e6 100644
--- a/app/controllers/registrations/welcome_controller.rb
+++ b/app/controllers/registrations/welcome_controller.rb
@@ -72,16 +72,10 @@ module Registrations
end
def show_tasks_to_be_done?
- return unless experiment(:invite_members_for_task).enabled?
-
MemberTask.for_members(current_user.members).exists?
end
# overridden in EE
- def trial_params
- end
-
- # overridden in EE
def update_success_path
end
end
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 450c12a233b..ed3facd72c5 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -210,9 +210,6 @@ class RegistrationsController < Devise::RegistrationsController
return unless member
- experiment_name = session.delete(:invite_email_experiment_name)
- experiment(:invite_email_preview_text, actor: member).track(:accepted) if experiment_name == 'invite_email_preview_text'
- experiment(:invite_email_from, actor: member).track(:accepted) if experiment_name == 'invite_email_from'
Gitlab::Tracking.event(self.class.name, 'accepted', label: 'invite_email', property: member.id.to_s)
end
diff --git a/app/controllers/repositories/git_http_client_controller.rb b/app/controllers/repositories/git_http_client_controller.rb
index b3adda8c633..c002c9b83f9 100644
--- a/app/controllers/repositories/git_http_client_controller.rb
+++ b/app/controllers/repositories/git_http_client_controller.rb
@@ -8,12 +8,9 @@ module Repositories
attr_reader :authentication_result, :redirected_path
- delegate :actor, :authentication_abilities, to: :authentication_result, allow_nil: true
+ delegate :authentication_abilities, to: :authentication_result, allow_nil: true
delegate :type, to: :authentication_result, allow_nil: true, prefix: :auth_result
- alias_method :user, :actor
- alias_method :authenticated_user, :actor
-
# Git clients will not know what authenticity token to send along
skip_around_action :set_session_storage
skip_before_action :verify_authenticity_token
@@ -22,8 +19,16 @@ module Repositories
feature_category :source_code_management
+ def authenticated_user
+ authentication_result&.user || authentication_result&.deploy_token
+ end
+
private
+ def user
+ authenticated_user
+ end
+
def download_request?
raise NotImplementedError
end
diff --git a/app/controllers/repositories/lfs_api_controller.rb b/app/controllers/repositories/lfs_api_controller.rb
index 30cafb6747e..d93d88c9e64 100644
--- a/app/controllers/repositories/lfs_api_controller.rb
+++ b/app/controllers/repositories/lfs_api_controller.rb
@@ -76,7 +76,10 @@ module Repositories
existing_oids = project.lfs_objects_oids(oids: objects_oids)
objects.each do |object|
- object[:actions] = upload_actions(object) unless existing_oids.include?(object[:oid])
+ next if existing_oids.include?(object[:oid])
+ next if should_auto_link? && oids_from_fork.include?(object[:oid]) && link_to_project!(object)
+
+ object[:actions] = upload_actions(object)
end
objects
@@ -150,6 +153,34 @@ module Repositories
Gitlab::LfsToken.new(user).basic_encoding
end
+
+ def should_auto_link?
+ return false unless Feature.enabled?(:lfs_auto_link_fork_source, project)
+ return false unless project.forked?
+
+ # Sanity check in case for some reason the user doesn't have access to the parent
+ can?(user, :download_code, project.fork_source)
+ end
+
+ def oids_from_fork
+ @oids_from_fork ||= project.lfs_objects_oids_from_fork_source(oids: objects_oids)
+ end
+
+ def link_to_project!(object)
+ lfs_object = LfsObject.for_oid_and_size(object[:oid], object[:size])
+
+ return unless lfs_object
+
+ LfsObjectsProject.link_to_project!(lfs_object, project)
+
+ Gitlab::AppJsonLogger.info(message: "LFS object auto-linked to forked project",
+ lfs_object_oid: lfs_object.oid,
+ lfs_object_size: lfs_object.size,
+ source_project_id: project.fork_source.id,
+ source_project_path: project.fork_source.full_path,
+ target_project_id: project.project_id,
+ target_project_path: project.full_path)
+ end
end
end
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 0e285dae089..99a6dfa811e 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -5,14 +5,13 @@ class SearchController < ApplicationController
include SearchHelper
include RedisTracking
- RESCUE_FROM_TIMEOUT_ACTIONS = [:count, :show].freeze
+ RESCUE_FROM_TIMEOUT_ACTIONS = [:count, :show, :autocomplete].freeze
track_redis_hll_event :show, name: 'i_search_total'
around_action :allow_gitaly_ref_name_caching
before_action :block_anonymous_global_searches, :check_scope_global_search_enabled, except: :opensearch
- before_action :strip_surrounding_whitespace_from_search, except: :opensearch
skip_before_action :authenticate_user!
requires_cross_project_access if: -> do
search_term_present = params[:search].present? || params[:term].present?
@@ -74,11 +73,7 @@ class SearchController < ApplicationController
def autocomplete
term = params[:term]
- if params[:project_id].present?
- @project = Project.find_by(id: params[:project_id])
- @project = nil unless can?(current_user, :read_project, @project)
- end
-
+ @project = search_service.project
@ref = params[:project_ref] if params[:project_ref].present?
render json: search_autocomplete_opts(term).to_json
@@ -97,12 +92,12 @@ class SearchController < ApplicationController
def search_term_valid?
unless search_service.valid_query_length?
- flash[:alert] = t('errors.messages.search_chars_too_long', count: SearchService::SEARCH_CHAR_LIMIT)
+ flash[:alert] = t('errors.messages.search_chars_too_long', count: Gitlab::Search::Params::SEARCH_CHAR_LIMIT)
return false
end
unless search_service.valid_terms_count?
- flash[:alert] = t('errors.messages.search_terms_too_long', count: SearchService::SEARCH_TERM_LIMIT)
+ flash[:alert] = t('errors.messages.search_terms_too_long', count: Gitlab::Search::Params::SEARCH_TERM_LIMIT)
return false
end
@@ -147,10 +142,15 @@ class SearchController < ApplicationController
payload[:metadata]['meta.search.filters.confidential'] = params[:confidential]
payload[:metadata]['meta.search.filters.state'] = params[:state]
payload[:metadata]['meta.search.force_search_results'] = params[:force_search_results]
+
+ if search_service.abuse_detected?
+ payload[:metadata]['abuse.confidence'] = Gitlab::Abuse.confidence(:certain)
+ payload[:metadata]['abuse.messages'] = search_service.abuse_messages
+ end
end
def block_anonymous_global_searches
- return if params[:project_id].present? || params[:group_id].present?
+ return unless search_service.global_search?
return if current_user
return unless ::Feature.enabled?(:block_anonymous_global_searches, type: :ops)
@@ -160,7 +160,7 @@ class SearchController < ApplicationController
end
def check_scope_global_search_enabled
- return if params[:project_id].present? || params[:group_id].present?
+ return unless search_service.global_search?
search_allowed = case params[:scope]
when 'blobs'
@@ -189,20 +189,15 @@ class SearchController < ApplicationController
@timeout = true
- if count_action_name?
+ case action_name.to_sym
+ when :count
render json: {}, status: :request_timeout
+ when :autocomplete
+ render json: [], status: :request_timeout
else
render status: :request_timeout
end
end
-
- def count_action_name?
- action_name.to_sym == :count
- end
-
- def strip_surrounding_whitespace_from_search
- %i(term search).each { |param| params[param]&.strip! }
- end
end
SearchController.prepend_mod_with('SearchController')
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index bbd7e5d5725..7e8e3ea8789 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -32,7 +32,7 @@ class SessionsController < Devise::SessionsController
before_action :load_recaptcha
before_action :set_invite_params, only: [:new]
before_action do
- push_frontend_feature_flag(:webauthn)
+ push_frontend_feature_flag(:webauthn, default_enabled: :yaml)
end
after_action :log_failed_login, if: :action_new_and_failed_login?
@@ -84,6 +84,8 @@ class SessionsController < Devise::SessionsController
end
def destroy
+ headers['Clear-Site-Data'] = '"*"'
+
Gitlab::AppLogger.info("User Logout: username=#{current_user.username} ip=#{request.remote_ip}")
super
# hide the signed_out notice
@@ -303,9 +305,9 @@ class SessionsController < Devise::SessionsController
def authentication_method
if user_params[:otp_attempt]
AuthenticationEvent::TWO_FACTOR
- elsif user_params[:device_response] && Feature.enabled?(:webauthn)
+ elsif user_params[:device_response] && Feature.enabled?(:webauthn, default_enabled: :yaml)
AuthenticationEvent::TWO_FACTOR_WEBAUTHN
- elsif user_params[:device_response] && !Feature.enabled?(:webauthn)
+ elsif user_params[:device_response] && !Feature.enabled?(:webauthn, default_enabled: :yaml)
AuthenticationEvent::TWO_FACTOR_U2F
else
AuthenticationEvent::STANDARD
diff --git a/app/controllers/user_callouts_controller.rb b/app/controllers/user_callouts_controller.rb
deleted file mode 100644
index f52a09adf5a..00000000000
--- a/app/controllers/user_callouts_controller.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# frozen_string_literal: true
-
-class UserCalloutsController < ApplicationController
- feature_category :navigation
-
- def create
- if callout.persisted?
- respond_to do |format|
- format.json { head :ok }
- end
- else
- respond_to do |format|
- format.json { head :bad_request }
- end
- end
- end
-
- private
-
- def callout
- Users::DismissUserCalloutService.new(
- container: nil, current_user: current_user, params: { feature_name: feature_name }
- ).execute
- end
-
- def feature_name
- params.require(:feature_name)
- end
-end
diff --git a/app/controllers/users/callouts_controller.rb b/app/controllers/users/callouts_controller.rb
new file mode 100644
index 00000000000..fe308d9dd1e
--- /dev/null
+++ b/app/controllers/users/callouts_controller.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Users
+ class CalloutsController < ApplicationController
+ feature_category :navigation
+
+ def create
+ if callout.persisted?
+ respond_to do |format|
+ format.json { head :ok }
+ end
+ else
+ respond_to do |format|
+ format.json { head :bad_request }
+ end
+ end
+ end
+
+ private
+
+ def callout
+ Users::DismissCalloutService.new(
+ container: nil, current_user: current_user, params: { feature_name: feature_name }
+ ).execute
+ end
+
+ def feature_name
+ params.require(:feature_name)
+ end
+ end
+end
diff --git a/app/controllers/users/group_callouts_controller.rb b/app/controllers/users/group_callouts_controller.rb
index cc27452e6a3..abca12ccea7 100644
--- a/app/controllers/users/group_callouts_controller.rb
+++ b/app/controllers/users/group_callouts_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Users
- class GroupCalloutsController < UserCalloutsController
+ class GroupCalloutsController < Users::CalloutsController
private
def callout
diff --git a/app/controllers/users/terms_controller.rb b/app/controllers/users/terms_controller.rb
index 7fbf0faa68b..f0d95b56d33 100644
--- a/app/controllers/users/terms_controller.rb
+++ b/app/controllers/users/terms_controller.rb
@@ -3,6 +3,7 @@
module Users
class TermsController < ApplicationController
include InternalRedirect
+ include OneTrustCSP
skip_before_action :authenticate_user!, only: [:index]
skip_before_action :enforce_terms!
diff --git a/app/experiments/application_experiment.rb b/app/experiments/application_experiment.rb
index 37d87baf30b..859716b4739 100644
--- a/app/experiments/application_experiment.rb
+++ b/app/experiments/application_experiment.rb
@@ -13,7 +13,6 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp
super
publish_to_client
- publish_to_database if @record
end
def publish_to_client
@@ -25,6 +24,8 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp
end
def publish_to_database
+ ActiveSupport::Deprecation.warn('publish_to_database is deprecated and should not be used for reporting anymore')
+
return unless should_track?
# if the context contains a namespace, group, project, user, or actor
@@ -32,18 +33,18 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp
subject = value[:namespace] || value[:group] || value[:project] || value[:user] || value[:actor]
return unless ExperimentSubject.valid_subject?(subject)
- variant = :experimental if @variant_name != :control
- Experiment.add_subject(name, variant: variant || :control, subject: subject)
- end
-
- def record!
- @record = true
+ variant_name = :experimental if variant&.name != 'control'
+ Experiment.add_subject(name, variant: variant_name || :control, subject: subject)
end
def control_behavior
# define a default nil control behavior so we can omit it when not needed
end
+ def track(action, **event_args)
+ super(action, **tracking_context.merge(event_args))
+ end
+
# TODO: remove
# This is deprecated logic as of v0.6.0 and should eventually be removed, but
# needs to stay intact for actively running experiments. The new strategy
@@ -57,8 +58,25 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp
Digest::MD5.hexdigest(ingredients.join('|'))
end
+ def nest_experiment(other)
+ instance_exec(:nested, { label: other.name }, &Configuration.tracking_behavior)
+ end
+
private
+ def tracking_context
+ {
+ namespace: context.try(:namespace) || context.try(:group),
+ project: context.try(:project),
+ user: user_or_actor
+ }.compact || {}
+ end
+
+ def user_or_actor
+ actor = context.try(:actor)
+ actor.respond_to?(:id) ? actor : context.try(:user)
+ end
+
def feature_flag_name
name.tr('/', '_')
end
diff --git a/app/experiments/combined_registration_experiment.rb b/app/experiments/combined_registration_experiment.rb
index da699449d77..08c015838db 100644
--- a/app/experiments/combined_registration_experiment.rb
+++ b/app/experiments/combined_registration_experiment.rb
@@ -7,14 +7,12 @@ class CombinedRegistrationExperiment < ApplicationExperiment # rubocop:disable G
super(source, 'force_company_trial')
end
- def redirect_path(trial_params)
- @trial_params = trial_params
-
+ def redirect_path
run
end
def control_behavior
- new_users_sign_up_group_path(@trial_params)
+ new_users_sign_up_group_path
end
def candidate_behavior
diff --git a/app/experiments/new_project_readme_content_experiment.rb b/app/experiments/new_project_readme_content_experiment.rb
index d9f0fb3b93e..1de7632268d 100644
--- a/app/experiments/new_project_readme_content_experiment.rb
+++ b/app/experiments/new_project_readme_content_experiment.rb
@@ -6,7 +6,7 @@ class NewProjectReadmeContentExperiment < ApplicationExperiment # rubocop:disabl
def run_with(project, variant: nil)
@project = project
- record!
+ publish_to_database
run(variant)
end
diff --git a/app/experiments/new_project_sast_enabled_experiment.rb b/app/experiments/new_project_sast_enabled_experiment.rb
index 1ab86d70134..b7b4552f0cc 100644
--- a/app/experiments/new_project_sast_enabled_experiment.rb
+++ b/app/experiments/new_project_sast_enabled_experiment.rb
@@ -12,4 +12,7 @@ class NewProjectSastEnabledExperiment < ApplicationExperiment # rubocop:disable
def free_indicator_behavior
end
+
+ def unchecked_candidate_behavior
+ end
end
diff --git a/app/finders/ci/auth_job_finder.rb b/app/finders/ci/auth_job_finder.rb
index d207a522aa8..2dbdcb3c472 100644
--- a/app/finders/ci/auth_job_finder.rb
+++ b/app/finders/ci/auth_job_finder.rb
@@ -16,7 +16,7 @@ module Ci
validate_job!(job)
- if job.user && Feature.enabled?(:ci_scoped_job_token, job.project, default_enabled: :yaml)
+ if job.user
job.user.set_ci_job_token_scope!(job)
end
end
diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb
index 8bc2a47a024..5d597f94f72 100644
--- a/app/finders/ci/runners_finder.rb
+++ b/app/finders/ci/runners_finder.rb
@@ -15,6 +15,7 @@ module Ci
def execute
search!
+ filter_by_active!
filter_by_status!
filter_by_runner_type!
filter_by_tag_list!
@@ -60,6 +61,10 @@ module Ci
end
end
+ def filter_by_active!
+ @runners = @runners.active(@params[:active]) if @params.include?(:active)
+ end
+
def filter_by_status!
filter_by!(:status_status, Ci::Runner::AVAILABLE_STATUSES)
end
diff --git a/app/finders/environments/environments_by_deployments_finder.rb b/app/finders/environments/environments_by_deployments_finder.rb
index a41cfcb37e4..2716c80ea6e 100644
--- a/app/finders/environments/environments_by_deployments_finder.rb
+++ b/app/finders/environments/environments_by_deployments_finder.rb
@@ -12,29 +12,18 @@ module Environments
# rubocop: disable CodeReuse/ActiveRecord
def execute
- deployments = project.deployments
deployments =
if ref
deployments_query = params[:with_tags] ? 'ref = :ref OR tag IS TRUE' : 'ref = :ref'
- deployments.where(deployments_query, ref: ref.to_s)
+ Deployment.where(deployments_query, ref: ref.to_s)
elsif commit
- deployments.where(sha: commit.sha)
+ Deployment.where(sha: commit.sha)
else
- deployments.none
+ Deployment.none
end
- environments =
- if Feature.enabled?(:environments_by_deployments_finder_exists_optimization, default_enabled: :yaml)
- project.environments.available
- .where('EXISTS (?)', deployments.where('environment_id = environments.id'))
- else
- environment_ids = deployments
- .group(:environment_id)
- .select(:environment_id)
-
- project.environments.available
- .where(id: environment_ids)
- end
+ environments = project.environments.available
+ .where('EXISTS (?)', deployments.where('environment_id = environments.id'))
if params[:find_latest]
find_one(environments.order_by_last_deployed_at_desc)
diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb
index 18ccea330af..7974710e67b 100644
--- a/app/finders/group_descendants_finder.rb
+++ b/app/finders/group_descendants_finder.rb
@@ -87,9 +87,13 @@ class GroupDescendantsFinder
visible_to_user = visible_to_user.or(authorized_to_user)
end
- hierarchy_for_parent
- .descendants
- .where(visible_to_user)
+ group_to_query = if Feature.enabled?(:linear_group_descendants_finder, current_user, default_enabled: :yaml)
+ parent_group
+ else
+ hierarchy_for_parent
+ end
+
+ group_to_query.descendants.where(visible_to_user)
# rubocop: enable CodeReuse/Finder
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -155,7 +159,13 @@ class GroupDescendantsFinder
# rubocop: disable CodeReuse/ActiveRecord
def projects_matching_filter
# rubocop: disable CodeReuse/Finder
- projects_nested_in_group = Project.where(namespace_id: hierarchy_for_parent.base_and_descendants.select(:id))
+ objects_in_hierarchy = if Feature.enabled?(:linear_group_descendants_finder, current_user, default_enabled: :yaml)
+ parent_group.self_and_descendants.as_ids
+ else
+ hierarchy_for_parent.base_and_descendants.select(:id)
+ end
+
+ projects_nested_in_group = Project.where(namespace_id: objects_in_hierarchy)
params_with_search = params.merge(search: params[:filter])
ProjectsFinder.new(params: params_with_search,
diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb
index 7ea3362fba1..7e3cdd79a4c 100644
--- a/app/finders/groups_finder.rb
+++ b/app/finders/groups_finder.rb
@@ -52,7 +52,16 @@ class GroupsFinder < UnionFinder
return [Group.all] if current_user&.can_read_all_resources? && all_available?
groups = []
- groups << Gitlab::ObjectHierarchy.new(groups_for_ancestors, groups_for_descendants).all_objects if current_user
+
+ if current_user
+ if Feature.enabled?(:use_traversal_ids_groups_finder, default_enabled: :yaml)
+ groups << current_user.authorized_groups.self_and_ancestors
+ groups << current_user.groups.self_and_descendants
+ else
+ groups << Gitlab::ObjectHierarchy.new(groups_for_ancestors, groups_for_descendants).all_objects
+ end
+ end
+
groups << Group.unscoped.public_to_user(current_user) if include_public_groups?
groups << Group.none if groups.empty?
groups
@@ -72,9 +81,13 @@ class GroupsFinder < UnionFinder
.groups
.where('members.access_level >= ?', params[:min_access_level])
- Gitlab::ObjectHierarchy
- .new(groups)
- .base_and_descendants
+ if Feature.enabled?(:use_traversal_ids_groups_finder, default_enabled: :yaml)
+ groups.self_and_descendants
+ else
+ Gitlab::ObjectHierarchy
+ .new(groups)
+ .base_and_descendants
+ end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 7b0cd17a761..3e436f30971 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -35,6 +35,8 @@
# updated_before: datetime
# attempt_group_search_optimizations: boolean
# attempt_project_search_optimizations: boolean
+# crm_contact_id: integer
+# crm_organization_id: integer
#
class IssuableFinder
prepend FinderWithCrossProjectAccess
@@ -59,6 +61,8 @@ class IssuableFinder
assignee_username
author_id
author_username
+ crm_contact_id
+ crm_organization_id
label_name
milestone_title
release_tag
@@ -138,7 +142,9 @@ class IssuableFinder
items = by_milestone(items)
items = by_release(items)
items = by_label(items)
- by_my_reaction_emoji(items)
+ items = by_my_reaction_emoji(items)
+ items = by_crm_contact(items)
+ by_crm_organization(items)
end
def should_filter_negated_args?
@@ -463,6 +469,14 @@ class IssuableFinder
params[:non_archived].present? ? items.non_archived : items
end
+ def by_crm_contact(items)
+ Issuables::CrmContactFilter.new(params: original_params).filter(items)
+ end
+
+ def by_crm_organization(items)
+ Issuables::CrmOrganizationFilter.new(params: original_params).filter(items)
+ end
+
def or_filters_enabled?
strong_memoize(:or_filters_enabled) do
Feature.enabled?(:or_issuable_queries, feature_flag_scope, default_enabled: :yaml)
diff --git a/app/finders/issuables/crm_contact_filter.rb b/app/finders/issuables/crm_contact_filter.rb
new file mode 100644
index 00000000000..bea5f7d2bfa
--- /dev/null
+++ b/app/finders/issuables/crm_contact_filter.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Issuables
+ class CrmContactFilter < BaseFilter
+ def filter(issuables)
+ by_crm_contact(issuables)
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def by_crm_contact(issuables)
+ return issuables if params[:crm_contact_id].blank?
+
+ condition = CustomerRelations::IssueContact
+ .where(contact_id: params[:crm_contact_id])
+ .where(Arel.sql("issue_id = issues.id"))
+ issuables.where(condition.arel.exists)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+end
diff --git a/app/finders/issuables/crm_organization_filter.rb b/app/finders/issuables/crm_organization_filter.rb
new file mode 100644
index 00000000000..f746049c405
--- /dev/null
+++ b/app/finders/issuables/crm_organization_filter.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Issuables
+ class CrmOrganizationFilter < BaseFilter
+ def filter(issuables)
+ by_crm_organization(issuables)
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def by_crm_organization(issuables)
+ return issuables if params[:crm_organization_id].blank?
+
+ condition = CustomerRelations::IssueContact
+ .joins(:contact)
+ .where(contact: { organization_id: params[:crm_organization_id] })
+ .where(Arel.sql("issue_id = issues.id"))
+ issuables.where(condition.arel.exists)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+end
diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb
index 13696add965..ba709d3bdfc 100644
--- a/app/finders/merge_requests_finder.rb
+++ b/app/finders/merge_requests_finder.rb
@@ -174,8 +174,8 @@ class MergeRequestsFinder < IssuableFinder
def by_deployments(items)
env = params[:environment]
- before = params[:deployed_before]
- after = params[:deployed_after]
+ before = parse_datetime(params[:deployed_before])
+ after = parse_datetime(params[:deployed_after])
id = params[:deployment_id]
return items if !env && !before && !after && !id
@@ -218,6 +218,13 @@ class MergeRequestsFinder < IssuableFinder
items.none
end
end
+
+ def parse_datetime(input)
+ # To work around http://www.ruby-lang.org/en/news/2021/11/15/date-parsing-method-regexp-dos-cve-2021-41817/
+ DateTime.parse(input.byteslice(0, 128)) if input
+ rescue Date::Error
+ nil
+ end
end
MergeRequestsFinder.prepend_mod_with('MergeRequestsFinder')
diff --git a/app/finders/packages/build_infos_finder.rb b/app/finders/packages/build_infos_finder.rb
new file mode 100644
index 00000000000..92ad5888eb9
--- /dev/null
+++ b/app/finders/packages/build_infos_finder.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+module Packages
+ class BuildInfosFinder
+ MAX_PAGE_SIZE = 100
+
+ def initialize(package, params)
+ @package = package
+ @params = params
+ end
+
+ def execute
+ build_infos = @package.build_infos.without_empty_pipelines
+ build_infos = apply_order(build_infos)
+ build_infos = apply_limit(build_infos)
+ apply_cursor(build_infos)
+ end
+
+ private
+
+ def apply_order(build_infos)
+ order_direction = :desc
+ order_direction = :asc if last
+
+ build_infos.order_by_pipeline_id(order_direction)
+ end
+
+ def apply_limit(build_infos)
+ limit = [first, last, max_page_size, MAX_PAGE_SIZE].compact.min
+ limit += 1 if support_next_page
+ build_infos.limit(limit)
+ end
+
+ def apply_cursor(build_infos)
+ if before
+ build_infos.with_pipeline_id_greater_than(before)
+ elsif after
+ build_infos.with_pipeline_id_less_than(after)
+ else
+ build_infos
+ end
+ end
+
+ def first
+ @params[:first]
+ end
+
+ def last
+ @params[:last]
+ end
+
+ def max_page_size
+ @params[:max_page_size]
+ end
+
+ def before
+ @params[:before]
+ end
+
+ def after
+ @params[:after]
+ end
+
+ def support_next_page
+ @params[:support_next_page]
+ end
+ end
+end
diff --git a/app/finders/packages/group_packages_finder.rb b/app/finders/packages/group_packages_finder.rb
index 2a62dd5c0e5..23b0e71d836 100644
--- a/app/finders/packages/group_packages_finder.rb
+++ b/app/finders/packages/group_packages_finder.rb
@@ -40,10 +40,14 @@ module Packages
# access to packages is ruled by:
# - project is public or the current user has access to it with at least the reporter level
# - the repository feature is available to the current_user
- ::Project
- .in_namespace(groups)
- .public_or_visible_to_user(current_user, Gitlab::Access::REPORTER)
- .with_feature_available_for_user(:repository, current_user)
+ if current_user.is_a?(DeployToken)
+ current_user.accessible_projects
+ else
+ ::Project
+ .in_namespace(groups)
+ .public_or_visible_to_user(current_user, Gitlab::Access::REPORTER)
+ .with_feature_available_for_user(:repository, current_user)
+ end
end
def groups
diff --git a/app/finders/personal_projects_finder.rb b/app/finders/personal_projects_finder.rb
index e7094d73905..a01465a64d2 100644
--- a/app/finders/personal_projects_finder.rb
+++ b/app/finders/personal_projects_finder.rb
@@ -28,6 +28,7 @@ class PersonalProjectsFinder < UnionFinder
private
def all_projects(current_user)
+ return [@user.personal_projects] if current_user && current_user.can_read_all_resources?
return [projects_with_min_access_level(current_user)] if current_user && min_access_level?
projects = []
diff --git a/app/finders/user_group_notification_settings_finder.rb b/app/finders/user_group_notification_settings_finder.rb
index 4ad9d1d7bf4..c2af581dd14 100644
--- a/app/finders/user_group_notification_settings_finder.rb
+++ b/app/finders/user_group_notification_settings_finder.rb
@@ -8,7 +8,12 @@ class UserGroupNotificationSettingsFinder
def execute
# rubocop: disable CodeReuse/ActiveRecord
- groups_with_ancestors = Gitlab::ObjectHierarchy.new(Group.where(id: groups.select(:id))).base_and_ancestors
+ selected_groups = Group.where(id: groups.select(:id))
+ groups_with_ancestors = if Feature.enabled?(:linear_user_group_notification_settings_finder_ancestors_scopes, user, default_enabled: :yaml)
+ selected_groups.self_and_ancestors
+ else
+ Gitlab::ObjectHierarchy.new(selected_groups).base_and_ancestors
+ end
# rubocop: enable CodeReuse/ActiveRecord
@loaded_groups_with_ancestors = groups_with_ancestors.index_by(&:id)
diff --git a/app/graphql/graphql_triggers.rb b/app/graphql/graphql_triggers.rb
index 671c7c2cd25..290cd4d7146 100644
--- a/app/graphql/graphql_triggers.rb
+++ b/app/graphql/graphql_triggers.rb
@@ -4,4 +4,8 @@ module GraphqlTriggers
def self.issuable_assignees_updated(issuable)
GitlabSchema.subscriptions.trigger('issuableAssigneesUpdated', { issuable_id: issuable.to_gid }, issuable)
end
+
+ def self.issue_crm_contacts_updated(issue)
+ GitlabSchema.subscriptions.trigger('issueCrmContactsUpdated', { issuable_id: issue.to_gid }, issue)
+ end
end
diff --git a/app/graphql/mutations/issues/set_crm_contacts.rb b/app/graphql/mutations/issues/set_crm_contacts.rb
index 7a9e6237eaa..4e49a45d52a 100644
--- a/app/graphql/mutations/issues/set_crm_contacts.rb
+++ b/app/graphql/mutations/issues/set_crm_contacts.rb
@@ -5,7 +5,7 @@ module Mutations
class SetCrmContacts < Base
graphql_name 'IssueSetCrmContacts'
- argument :crm_contact_ids,
+ argument :contact_ids,
[::Types::GlobalIDType[::CustomerRelations::Contact]],
required: true,
description: 'Customer relations contact IDs to set. Replaces existing contacts by default.'
@@ -15,27 +15,27 @@ module Mutations
required: false,
description: 'Changes the operation mode. Defaults to REPLACE.'
- def resolve(project_path:, iid:, crm_contact_ids:, operation_mode: Types::MutationOperationModeEnum.enum[:replace])
+ def resolve(project_path:, iid:, contact_ids:, operation_mode: Types::MutationOperationModeEnum.enum[:replace])
issue = authorized_find!(project_path: project_path, iid: iid)
project = issue.project
raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless Feature.enabled?(:customer_relations, project.group, default_enabled: :yaml)
- crm_contact_ids = crm_contact_ids.compact.map do |crm_contact_id|
- raise Gitlab::Graphql::Errors::ArgumentError, "Contact #{crm_contact_id} is invalid." unless crm_contact_id.respond_to?(:model_id)
+ contact_ids = contact_ids.compact.map do |contact_id|
+ raise Gitlab::Graphql::Errors::ArgumentError, "Contact #{contact_id} is invalid." unless contact_id.respond_to?(:model_id)
- crm_contact_id.model_id.to_i
+ contact_id.model_id.to_i
end
attribute_name = case operation_mode
when Types::MutationOperationModeEnum.enum[:append]
- :add_crm_contact_ids
+ :add_ids
when Types::MutationOperationModeEnum.enum[:remove]
- :remove_crm_contact_ids
+ :remove_ids
else
- :crm_contact_ids
+ :replace_ids
end
- response = ::Issues::SetCrmContactsService.new(project: project, current_user: current_user, params: { attribute_name => crm_contact_ids })
+ response = ::Issues::SetCrmContactsService.new(project: project, current_user: current_user, params: { attribute_name => contact_ids })
.execute(issue)
{
diff --git a/app/graphql/mutations/jira_import/start.rb b/app/graphql/mutations/jira_import/start.rb
index 143a9558e38..4929d6f394a 100644
--- a/app/graphql/mutations/jira_import/start.rb
+++ b/app/graphql/mutations/jira_import/start.rb
@@ -14,15 +14,15 @@ module Mutations
null: true,
description: 'Jira import data after mutation.'
- argument :project_path, GraphQL::Types::ID,
- required: true,
- description: 'Project to import the Jira project into.'
argument :jira_project_key, GraphQL::Types::String,
required: true,
description: 'Project key of the importer Jira project.'
argument :jira_project_name, GraphQL::Types::String,
required: false,
description: 'Project name of the importer Jira project.'
+ argument :project_path, GraphQL::Types::ID,
+ required: true,
+ description: 'Project to import the Jira project into.'
argument :users_mapping,
[Types::JiraUsersMappingInputType],
required: false,
diff --git a/app/graphql/mutations/merge_requests/accept.rb b/app/graphql/mutations/merge_requests/accept.rb
index d16b2327f2d..7ce850901af 100644
--- a/app/graphql/mutations/merge_requests/accept.rb
+++ b/app/graphql/mutations/merge_requests/accept.rb
@@ -26,12 +26,12 @@ module Mutations
argument :commit_message, ::GraphQL::Types::String,
required: false,
description: 'Custom merge commit message.'
- argument :squash_commit_message, ::GraphQL::Types::String,
- required: false,
- description: 'Custom squash commit message (if squash is true).'
argument :sha, ::GraphQL::Types::String,
required: true,
description: 'HEAD SHA at the time when this merge was requested.'
+ argument :squash_commit_message, ::GraphQL::Types::String,
+ required: false,
+ description: 'Custom squash commit message (if squash is true).'
argument :should_remove_source_branch, ::GraphQL::Types::Boolean,
required: false,
diff --git a/app/graphql/mutations/user_callouts/create.rb b/app/graphql/mutations/user_callouts/create.rb
index ff6e5cd28dd..1be99ea0ecd 100644
--- a/app/graphql/mutations/user_callouts/create.rb
+++ b/app/graphql/mutations/user_callouts/create.rb
@@ -15,7 +15,7 @@ module Mutations
description: 'User callout dismissed.'
def resolve(feature_name:)
- callout = Users::DismissUserCalloutService.new(
+ callout = Users::DismissCalloutService.new(
container: nil, current_user: current_user, params: { feature_name: feature_name }
).execute
errors = errors_on_object(callout)
diff --git a/app/graphql/queries/container_registry/get_container_repositories.query.graphql b/app/graphql/queries/container_registry/get_container_repositories.query.graphql
index df0b590acac..40e2934a038 100644
--- a/app/graphql/queries/container_registry/get_container_repositories.query.graphql
+++ b/app/graphql/queries/container_registry/get_container_repositories.query.graphql
@@ -10,6 +10,7 @@ query getProjectContainerRepositories(
) {
project(fullPath: $fullPath) @skip(if: $isGroupPage) {
__typename
+ id
containerRepositoriesCount
containerRepositories(
name: $name
@@ -43,6 +44,7 @@ query getProjectContainerRepositories(
}
group(fullPath: $fullPath) @include(if: $isGroupPage) {
__typename
+ id
containerRepositoriesCount
containerRepositories(
name: $name
diff --git a/app/graphql/queries/design_management/design_permissions.query.graphql b/app/graphql/queries/design_management/design_permissions.query.graphql
index 55dfa35129c..a81afd47625 100644
--- a/app/graphql/queries/design_management/design_permissions.query.graphql
+++ b/app/graphql/queries/design_management/design_permissions.query.graphql
@@ -4,6 +4,7 @@ query permissions($fullPath: ID!, $iid: String!) {
id
issue(iid: $iid) {
__typename
+ id
userPermissions {
__typename
createDesign
diff --git a/app/graphql/queries/design_management/get_design_list.query.graphql b/app/graphql/queries/design_management/get_design_list.query.graphql
index 01503a9572f..f0caa7c5b4c 100644
--- a/app/graphql/queries/design_management/get_design_list.query.graphql
+++ b/app/graphql/queries/design_management/get_design_list.query.graphql
@@ -4,6 +4,7 @@ query getDesignList($fullPath: ID!, $iid: String!, $atVersion: ID) {
id
issue(iid: $iid) {
__typename
+ id
designCollection {
__typename
copyState
diff --git a/app/graphql/queries/epic/epic_children.query.graphql b/app/graphql/queries/epic/epic_children.query.graphql
deleted file mode 100644
index be82813dddb..00000000000
--- a/app/graphql/queries/epic/epic_children.query.graphql
+++ /dev/null
@@ -1,132 +0,0 @@
-fragment PageInfo on PageInfo {
- hasNextPage
- hasPreviousPage
- startCursor
- endCursor
-}
-
-fragment RelatedTreeBaseEpic on Epic {
- id
- iid
- title
- webPath
- relativePosition
- userPermissions {
- __typename
- adminEpic
- createEpic
- }
- descendantWeightSum {
- closedIssues
- openedIssues
- }
- descendantCounts {
- __typename
- openedEpics
- closedEpics
- openedIssues
- closedIssues
- }
- healthStatus {
- __typename
- issuesAtRisk
- issuesOnTrack
- issuesNeedingAttention
- }
-}
-
-fragment EpicNode on Epic {
- ...RelatedTreeBaseEpic
- state
- reference(full: true)
- relationPath
- createdAt
- closedAt
- confidential
- hasChildren
- hasIssues
- group {
- __typename
- fullPath
- }
-}
-
-query childItems(
- $fullPath: ID!
- $iid: ID
- $pageSize: Int = 100
- $epicEndCursor: String = ""
- $issueEndCursor: String = ""
-) {
- group(fullPath: $fullPath) {
- __typename
- id
- path
- fullPath
- epic(iid: $iid) {
- __typename
- ...RelatedTreeBaseEpic
- children(first: $pageSize, after: $epicEndCursor) {
- __typename
- edges {
- __typename
- node {
- __typename
- ...EpicNode
- }
- }
- pageInfo {
- __typename
- ...PageInfo
- }
- }
- issues(first: $pageSize, after: $issueEndCursor) {
- __typename
- edges {
- __typename
- node {
- __typename
- iid
- epicIssueId
- title
- blocked
- closedAt
- state
- createdAt
- confidential
- dueDate
- weight
- webPath
- reference(full: true)
- relationPath
- relativePosition
- assignees {
- __typename
- edges {
- __typename
- node {
- __typename
- webUrl
- name
- username
- avatarUrl
- }
- }
- }
- milestone {
- __typename
- title
- startDate
- dueDate
- }
- healthStatus
- }
- }
- pageInfo {
- __typename
- ...PageInfo
- }
- }
- }
- }
-}
diff --git a/app/graphql/queries/epic/epic_details.query.graphql b/app/graphql/queries/epic/epic_details.query.graphql
index 406d630b180..eb4757a845a 100644
--- a/app/graphql/queries/epic/epic_details.query.graphql
+++ b/app/graphql/queries/epic/epic_details.query.graphql
@@ -1,14 +1,17 @@
query epicDetails($fullPath: ID!, $iid: ID!) {
group(fullPath: $fullPath) {
__typename
+ id
epic(iid: $iid) {
__typename
+ id
participants {
__typename
edges {
__typename
node {
__typename
+ id
name
avatarUrl
webUrl
diff --git a/app/graphql/queries/pipelines/get_pipeline_details.query.graphql b/app/graphql/queries/pipelines/get_pipeline_details.query.graphql
index 4e4caa1e27c..dd5c9e07488 100644
--- a/app/graphql/queries/pipelines/get_pipeline_details.query.graphql
+++ b/app/graphql/queries/pipelines/get_pipeline_details.query.graphql
@@ -5,16 +5,19 @@ fragment LinkedPipelineData on Pipeline {
path
status: detailedStatus {
__typename
+ id
group
label
icon
}
sourceJob {
__typename
+ id
name
}
project {
__typename
+ id
name
fullPath
}
@@ -23,6 +26,7 @@ fragment LinkedPipelineData on Pipeline {
query getPipelineDetails($projectPath: ID!, $iid: ID!) {
project(fullPath: $projectPath) {
__typename
+ id
pipeline(iid: $iid) {
__typename
id
@@ -45,11 +49,14 @@ query getPipelineDetails($projectPath: ID!, $iid: ID!) {
__typename
nodes {
__typename
+ id
name
status: detailedStatus {
__typename
+ id
action {
__typename
+ id
icon
path
title
@@ -59,8 +66,10 @@ query getPipelineDetails($projectPath: ID!, $iid: ID!) {
__typename
nodes {
__typename
+ id
status: detailedStatus {
__typename
+ id
label
group
icon
@@ -71,17 +80,20 @@ query getPipelineDetails($projectPath: ID!, $iid: ID!) {
__typename
nodes {
__typename
+ id
name
scheduledAt
needs {
__typename
nodes {
__typename
+ id
name
}
}
status: detailedStatus {
__typename
+ id
icon
tooltip
hasDetails
@@ -89,6 +101,7 @@ query getPipelineDetails($projectPath: ID!, $iid: ID!) {
group
action {
__typename
+ id
buttonTitle
icon
path
diff --git a/app/graphql/queries/releases/all_releases.query.graphql b/app/graphql/queries/releases/all_releases.query.graphql
index ab8cbcb8aa3..150f59832f3 100644
--- a/app/graphql/queries/releases/all_releases.query.graphql
+++ b/app/graphql/queries/releases/all_releases.query.graphql
@@ -11,6 +11,7 @@ query allReleases(
) {
project(fullPath: $fullPath) {
__typename
+ id
releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) {
__typename
nodes {
@@ -50,6 +51,7 @@ query allReleases(
__typename
nodes {
__typename
+ id
filepath
collectedAt
sha
@@ -67,12 +69,14 @@ query allReleases(
}
commit {
__typename
+ id
sha
webUrl
title
}
author {
__typename
+ id
webUrl
avatarUrl
username
diff --git a/app/graphql/queries/repository/path_last_commit.query.graphql b/app/graphql/queries/repository/path_last_commit.query.graphql
index b5c5f653429..bcb07ae3182 100644
--- a/app/graphql/queries/repository/path_last_commit.query.graphql
+++ b/app/graphql/queries/repository/path_last_commit.query.graphql
@@ -1,13 +1,14 @@
query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) {
project(fullPath: $projectPath) {
- id
__typename
+ id
repository {
__typename
tree(path: $path, ref: $ref) {
__typename
lastCommit {
__typename
+ id
sha
title
titleHtml
@@ -19,6 +20,7 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) {
authorGravatar
author {
__typename
+ id
name
avatarUrl
webPath
@@ -30,8 +32,10 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) {
__typename
node {
__typename
+ id
detailedStatus {
__typename
+ id
detailsPath
icon
tooltip
diff --git a/app/graphql/queries/snippet/project_permissions.query.graphql b/app/graphql/queries/snippet/project_permissions.query.graphql
index 0c38e4f8a07..e0de79f4449 100644
--- a/app/graphql/queries/snippet/project_permissions.query.graphql
+++ b/app/graphql/queries/snippet/project_permissions.query.graphql
@@ -1,6 +1,7 @@
query CanCreateProjectSnippet($fullPath: ID!) {
project(fullPath: $fullPath) {
__typename
+ id
userPermissions {
__typename
createSnippet
diff --git a/app/graphql/queries/snippet/snippet.query.graphql b/app/graphql/queries/snippet/snippet.query.graphql
index ebfc135c51c..24b268ec853 100644
--- a/app/graphql/queries/snippet/snippet.query.graphql
+++ b/app/graphql/queries/snippet/snippet.query.graphql
@@ -49,6 +49,7 @@ query GetSnippetQuery($ids: [SnippetID!]) {
}
project {
__typename
+ id
fullPath
webUrl
}
diff --git a/app/graphql/queries/snippet/user_permissions.query.graphql b/app/graphql/queries/snippet/user_permissions.query.graphql
index a4914189807..4d131c48feb 100644
--- a/app/graphql/queries/snippet/user_permissions.query.graphql
+++ b/app/graphql/queries/snippet/user_permissions.query.graphql
@@ -1,6 +1,7 @@
query CanCreatePersonalSnippet {
currentUser {
__typename
+ id
userPermissions {
__typename
createSnippet
diff --git a/app/graphql/resolvers/base_issues_resolver.rb b/app/graphql/resolvers/base_issues_resolver.rb
index 54ebb697cb2..dca93444907 100644
--- a/app/graphql/resolvers/base_issues_resolver.rb
+++ b/app/graphql/resolvers/base_issues_resolver.rb
@@ -4,13 +4,13 @@ module Resolvers
class BaseIssuesResolver < BaseResolver
prepend IssueResolverArguments
- argument :state, Types::IssuableStateEnum,
- required: false,
- description: 'Current state of this issue.'
argument :sort, Types::IssueSortEnum,
description: 'Sort issues by this criteria.',
required: false,
default_value: :created_desc
+ argument :state, Types::IssuableStateEnum,
+ required: false,
+ description: 'Current state of this issue.'
type Types::IssueType.connection_type, null: true
diff --git a/app/graphql/resolvers/ci/jobs_resolver.rb b/app/graphql/resolvers/ci/jobs_resolver.rb
index 5ae9e721cc8..df138a15538 100644
--- a/app/graphql/resolvers/ci/jobs_resolver.rb
+++ b/app/graphql/resolvers/ci/jobs_resolver.rb
@@ -29,7 +29,7 @@ module Resolvers
job_types: security_report_types
).execute
else
- pipeline.statuses
+ pipeline.statuses_order_id_desc
end
end
end
diff --git a/app/graphql/resolvers/ci/runner_status_resolver.rb b/app/graphql/resolvers/ci/runner_status_resolver.rb
new file mode 100644
index 00000000000..d916a8a13f0
--- /dev/null
+++ b/app/graphql/resolvers/ci/runner_status_resolver.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Ci
+ # NOTE: This class was introduced to allow modifying the meaning of certain values in RunnerStatusEnum
+ # while preserving backward compatibility. It can be removed in 15.0 once the API has stabilized.
+ class RunnerStatusResolver < BaseResolver
+ type Types::Ci::RunnerStatusEnum, null: false
+
+ alias_method :runner, :object
+
+ argument :legacy_mode,
+ type: GraphQL::Types::String,
+ default_value: '14.5',
+ required: false,
+ description: 'Compatibility mode. A null value turns off compatibility mode.',
+ deprecated: { reason: 'Will be removed in 15.0. From that release onward, the field will behave as if legacyMode is null', milestone: '14.6' }
+
+ def resolve(legacy_mode:, **args)
+ runner.status(legacy_mode)
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/ci/runners_resolver.rb b/app/graphql/resolvers/ci/runners_resolver.rb
index 07105701daa..9848b5a503f 100644
--- a/app/graphql/resolvers/ci/runners_resolver.rb
+++ b/app/graphql/resolvers/ci/runners_resolver.rb
@@ -7,6 +7,10 @@ module Resolvers
type Types::Ci::RunnerType.connection_type, null: true
+ argument :active, ::GraphQL::Types::Boolean,
+ required: false,
+ description: 'Filter runners by active (true) or paused (false) status.'
+
argument :status, ::Types::Ci::RunnerStatusEnum,
required: false,
description: 'Filter runners by status.'
@@ -38,6 +42,7 @@ module Resolvers
def runners_finder_params(params)
{
+ active: params[:active],
status_status: params[:status]&.to_s,
type_type: params[:type],
tag_name: params[:tag_list],
diff --git a/app/graphql/resolvers/clusters/agent_activity_events_resolver.rb b/app/graphql/resolvers/clusters/agent_activity_events_resolver.rb
new file mode 100644
index 00000000000..b6fec3d3772
--- /dev/null
+++ b/app/graphql/resolvers/clusters/agent_activity_events_resolver.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Clusters
+ class AgentActivityEventsResolver < BaseResolver
+ type Types::Clusters::AgentActivityEventType, null: true
+
+ alias_method :agent, :object
+
+ delegate :project, to: :agent
+
+ def resolve(**args)
+ return ::Clusters::Agents::ActivityEvent.none unless can_view_activity_events?
+
+ agent.activity_events
+ end
+
+ private
+
+ def can_view_activity_events?
+ current_user.can?(:admin_cluster, project)
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/clusters/agents_resolver.rb b/app/graphql/resolvers/clusters/agents_resolver.rb
index 9b8cea52e3b..5ad66ed7cdd 100644
--- a/app/graphql/resolvers/clusters/agents_resolver.rb
+++ b/app/graphql/resolvers/clusters/agents_resolver.rb
@@ -28,7 +28,10 @@ module Resolvers
private
def preloads
- { tokens: :last_used_agent_tokens }
+ {
+ activity_events: { activity_events: [:user, agent_token: :agent] },
+ tokens: :last_used_agent_tokens
+ }
end
end
end
diff --git a/app/graphql/resolvers/container_repository_tags_resolver.rb b/app/graphql/resolvers/container_repository_tags_resolver.rb
new file mode 100644
index 00000000000..55a83dd49da
--- /dev/null
+++ b/app/graphql/resolvers/container_repository_tags_resolver.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class ContainerRepositoryTagsResolver < BaseResolver
+ type Types::ContainerRepositoryTagType.connection_type, null: true
+
+ argument :sort, Types::ContainerRepositoryTagsSortEnum,
+ description: 'Sort tags by these criteria.',
+ required: false,
+ default_value: nil
+
+ argument :name, GraphQL::Types::String,
+ description: 'Search by tag name.',
+ required: false,
+ default_value: nil
+
+ def resolve(sort:, **filters)
+ result = tags
+
+ if filters[:name]
+ result = tags.filter do |tag|
+ tag.name.include?(filters[:name])
+ end
+ end
+
+ result = sort_tags(result, sort) if sort
+ result
+ end
+
+ private
+
+ def sort_tags(to_be_sorted, sort)
+ raise StandardError unless Types::ContainerRepositoryTagsSortEnum.enum.include?(sort)
+
+ sort_value, _, direction = sort.to_s.rpartition('_')
+
+ sorted = to_be_sorted.sort_by(&sort_value.to_sym)
+ return sorted.reverse if direction == 'desc'
+
+ sorted
+ end
+
+ def tags
+ object.tags
+ rescue Faraday::Error
+ raise ::Gitlab::Graphql::Errors::ResourceNotAvailable, "Can't connect to the Container Registry. If this error persists, please review the troubleshooting documentation."
+ end
+ end
+end
diff --git a/app/graphql/resolvers/design_management/designs_resolver.rb b/app/graphql/resolvers/design_management/designs_resolver.rb
index dec778fac80..a62ef6d76e5 100644
--- a/app/graphql/resolvers/design_management/designs_resolver.rb
+++ b/app/graphql/resolvers/design_management/designs_resolver.rb
@@ -8,16 +8,16 @@ module Resolvers
type ::Types::DesignManagement::DesignType.connection_type, null: true
- argument :ids, [DesignID],
+ argument :at_version, VersionID,
required: false,
- description: 'Filters designs by their ID.'
+ description: 'Filters designs to only those that existed at the version. ' \
+ 'If argument is omitted or nil then all designs will reflect the latest version'
argument :filenames, [GraphQL::Types::String],
required: false,
description: 'Filters designs by their filename.'
- argument :at_version, VersionID,
+ argument :ids, [DesignID],
required: false,
- description: 'Filters designs to only those that existed at the version. ' \
- 'If argument is omitted or nil then all designs will reflect the latest version'
+ description: 'Filters designs by their ID.'
def self.single
::Resolvers::DesignManagement::DesignResolver
diff --git a/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb b/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb
index d879c1434dc..76e365c40b1 100644
--- a/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb
+++ b/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb
@@ -16,16 +16,16 @@ module Resolvers
authorize :read_design
- argument :id, DesignAtVersionID,
- required: false,
- as: :design_at_version_id,
- description: 'ID of the DesignAtVersion.'
argument :design_id, DesignID,
required: false,
description: 'ID of a specific design.'
argument :filename, GraphQL::Types::String,
required: false,
description: 'Filename of a specific design.'
+ argument :id, DesignAtVersionID,
+ required: false,
+ as: :design_at_version_id,
+ description: 'ID of the DesignAtVersion.'
def self.single
self
diff --git a/app/graphql/resolvers/kas/agent_configurations_resolver.rb b/app/graphql/resolvers/kas/agent_configurations_resolver.rb
index 238dae0bf12..a1b1d3bfe4c 100644
--- a/app/graphql/resolvers/kas/agent_configurations_resolver.rb
+++ b/app/graphql/resolvers/kas/agent_configurations_resolver.rb
@@ -14,7 +14,7 @@ module Resolvers
return [] unless can_read_agent_configuration?
kas_client.list_agent_config_files(project: project)
- rescue GRPC::BadStatus => e
+ rescue GRPC::BadStatus, Gitlab::Kas::Client::ConfigurationError => e
raise Gitlab::Graphql::Errors::ResourceNotAvailable, e.class.name
end
diff --git a/app/graphql/resolvers/package_pipelines_resolver.rb b/app/graphql/resolvers/package_pipelines_resolver.rb
new file mode 100644
index 00000000000..59a1cd173a4
--- /dev/null
+++ b/app/graphql/resolvers/package_pipelines_resolver.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class PackagePipelinesResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type Types::Ci::PipelineType.connection_type, null: true
+ extension Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension
+
+ authorizes_object!
+ authorize :read_pipeline
+
+ alias_method :package, :object
+
+ def resolve(first: nil, last: nil, after: nil, before: nil, lookahead:)
+ finder = ::Packages::BuildInfosFinder.new(
+ package,
+ first: first,
+ last: last,
+ after: decode_cursor(after),
+ before: decode_cursor(before),
+ max_page_size: context.schema.default_max_page_size,
+ support_next_page: lookahead.selects?(:page_info)
+ )
+
+ build_infos = finder.execute
+
+ # this .pluck_pipeline_ids can load max 101 pipelines ids
+ ::Ci::Pipeline.id_in(build_infos.pluck_pipeline_ids)
+ end
+
+ # we manage the pagination manually, so opt out of the connection field extension
+ def self.field_options
+ super.merge(
+ connection: false,
+ extras: [:lookahead]
+ )
+ end
+
+ private
+
+ def decode_cursor(encoded)
+ return unless encoded
+
+ decoded = Gitlab::Json.parse(context.schema.cursor_encoder.decode(encoded, nonce: true))
+ id_from_cursor(decoded)
+ rescue JSON::ParserError
+ raise Gitlab::Graphql::Errors::ArgumentError, "Please provide a valid cursor"
+ end
+
+ def id_from_cursor(cursor)
+ cursor&.fetch('id')
+ rescue KeyError
+ raise Gitlab::Graphql::Errors::ArgumentError, "Please provide a valid cursor"
+ end
+ end
+end
diff --git a/app/graphql/resolvers/project_jobs_resolver.rb b/app/graphql/resolvers/project_jobs_resolver.rb
index 75068014242..8a2693ee46b 100644
--- a/app/graphql/resolvers/project_jobs_resolver.rb
+++ b/app/graphql/resolvers/project_jobs_resolver.rb
@@ -33,6 +33,7 @@ module Resolvers
def preloads
{
+ previous_stage_jobs_and_needs: [:needs, :pipeline],
artifacts: [:job_artifacts],
pipeline: [:user]
}
diff --git a/app/graphql/resolvers/project_pipeline_resolver.rb b/app/graphql/resolvers/project_pipeline_resolver.rb
index 5acd7f95606..ea733ab08ad 100644
--- a/app/graphql/resolvers/project_pipeline_resolver.rb
+++ b/app/graphql/resolvers/project_pipeline_resolver.rb
@@ -24,7 +24,6 @@ module Resolvers
super
end
- # the preloads are defined on ee/app/graphql/ee/resolvers/project_pipeline_resolver.rb
def resolve(iid: nil, sha: nil, **args)
self.lookahead = args.delete(:lookahead)
@@ -42,5 +41,11 @@ module Resolvers
end
end
end
+
+ def unconditional_includes
+ [
+ { statuses: [:needs] }
+ ]
+ end
end
end
diff --git a/app/graphql/resolvers/project_pipelines_resolver.rb b/app/graphql/resolvers/project_pipelines_resolver.rb
index 5a1e92efc96..47a8b028d4d 100644
--- a/app/graphql/resolvers/project_pipelines_resolver.rb
+++ b/app/graphql/resolvers/project_pipelines_resolver.rb
@@ -18,7 +18,7 @@ module Resolvers
def preloads
{
- jobs: [:statuses],
+ jobs: { statuses_order_id_desc: [:needs] },
upstream: [:triggered_by_pipeline],
downstream: [:triggered_pipelines]
}
diff --git a/app/graphql/resolvers/snippets/blobs_resolver.rb b/app/graphql/resolvers/snippets/blobs_resolver.rb
index 00f41517422..cbbc65d7263 100644
--- a/app/graphql/resolvers/snippets/blobs_resolver.rb
+++ b/app/graphql/resolvers/snippets/blobs_resolver.rb
@@ -35,3 +35,5 @@ module Resolvers
end
end
end
+
+Resolvers::Snippets::BlobsResolver.prepend_mod
diff --git a/app/graphql/resolvers/users/participants_resolver.rb b/app/graphql/resolvers/users/participants_resolver.rb
new file mode 100644
index 00000000000..9e87b60fa34
--- /dev/null
+++ b/app/graphql/resolvers/users/participants_resolver.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Users
+ class ParticipantsResolver < BaseResolver
+ type Types::UserType.connection_type, null: true
+
+ def resolve(**args)
+ object.visible_participants(current_user)
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/base_edge.rb b/app/graphql/types/base_edge.rb
new file mode 100644
index 00000000000..f4409c983f8
--- /dev/null
+++ b/app/graphql/types/base_edge.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Types
+ class BaseEdge < GraphQL::Types::Relay::BaseEdge
+ field_class Types::BaseField
+ end
+end
diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb
index 93e17ea6dfc..75909592c6c 100644
--- a/app/graphql/types/base_field.rb
+++ b/app/graphql/types/base_field.rb
@@ -78,6 +78,8 @@ module Types
attr_reader :feature_flag
def field_authorized?(object, ctx)
+ object = object.node if object.is_a?(GraphQL::Pagination::Connection::Edge)
+
authorization.ok?(object, ctx[:current_user])
end
diff --git a/app/graphql/types/base_object.rb b/app/graphql/types/base_object.rb
index cd677e50d28..b5797f07aa6 100644
--- a/app/graphql/types/base_object.rb
+++ b/app/graphql/types/base_object.rb
@@ -7,6 +7,7 @@ module Types
prepend Gitlab::Graphql::MarkdownField
field_class Types::BaseField
+ edge_type_class Types::BaseEdge
def self.accepts(*types)
@accepts ||= []
diff --git a/app/graphql/types/boards/board_issue_input_type.rb b/app/graphql/types/boards/board_issue_input_type.rb
index b4dbe87e32d..afa66c1c510 100644
--- a/app/graphql/types/boards/board_issue_input_type.rb
+++ b/app/graphql/types/boards/board_issue_input_type.rb
@@ -17,6 +17,10 @@ module Types
argument :assignee_wildcard_id, ::Types::Boards::AssigneeWildcardIdEnum,
required: false,
description: 'Filter by assignee wildcard. Incompatible with assigneeUsername.'
+
+ argument :confidential, GraphQL::Types::Boolean,
+ required: false,
+ description: 'Filter by confidentiality.'
end
end
end
diff --git a/app/graphql/types/ci/build_need_type.rb b/app/graphql/types/ci/build_need_type.rb
index 7bd12c99a08..b71d10c4c06 100644
--- a/app/graphql/types/ci/build_need_type.rb
+++ b/app/graphql/types/ci/build_need_type.rb
@@ -8,7 +8,7 @@ module Types
graphql_name 'CiBuildNeed'
field :id, GraphQL::Types::ID, null: false,
- description: 'ID of the job we need to complete.'
+ description: 'ID of the BuildNeed.'
field :name, GraphQL::Types::String, null: true,
description: 'Name of the job we need to complete.'
end
diff --git a/app/graphql/types/ci/job_need_union.rb b/app/graphql/types/ci/job_need_union.rb
new file mode 100644
index 00000000000..59608a6a312
--- /dev/null
+++ b/app/graphql/types/ci/job_need_union.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ class JobNeedUnion < GraphQL::Schema::Union
+ TypeNotSupportedError = Class.new(StandardError)
+
+ possible_types Types::Ci::JobType, Types::Ci::BuildNeedType
+
+ def self.resolve_type(object, context)
+ if object.is_a?(::Ci::BuildNeed)
+ Types::Ci::BuildNeedType
+ elsif object.is_a?(CommitStatus)
+ Types::Ci::JobType
+ else
+ raise TypeNotSupportedError
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb
index 48bd91bfc5b..928ca2f597d 100644
--- a/app/graphql/types/ci/job_type.rb
+++ b/app/graphql/types/ci/job_type.rb
@@ -50,6 +50,8 @@ module Types
null: true,
description: 'How long the job was enqueued before starting.'
+ field :previous_stage_jobs_or_needs, Types::Ci::JobNeedUnion.connection_type, null: true,
+ description: 'Jobs that must complete before the job runs. Returns `BuildNeed`, which is the needed jobs if the job uses the `needs` keyword, or the previous stage jobs otherwise.'
field :detailed_status, Types::Ci::DetailedStatusType, null: true,
description: 'Detailed status of the job.'
field :artifacts, Types::Ci::JobArtifactType.connection_type, null: true,
@@ -74,7 +76,7 @@ module Types
description: 'Indicates the job is active.'
field :stuck, GraphQL::Types::Boolean, null: false, method: :stuck?,
description: 'Indicates the job is stuck.'
- field :coverage, GraphQL::FLOAT_TYPE, null: true,
+ field :coverage, GraphQL::Types::Float, null: true,
description: 'Coverage level of the job.'
field :created_by_tag, GraphQL::Types::Boolean, null: false,
description: 'Whether the job was created by a tag.'
@@ -101,6 +103,30 @@ module Types
end
end
+ def previous_stage_jobs_or_needs
+ if object.scheduling_type == 'stage'
+ Gitlab::Graphql::Lazy.with_value(previous_stage_jobs) do |jobs|
+ jobs
+ end
+ else
+ object.needs
+ end
+ end
+
+ def previous_stage_jobs
+ BatchLoader::GraphQL.for([object.pipeline, object.stage_idx - 1]).batch(default_value: []) do |tuples, loader|
+ tuples.group_by(&:first).each do |pipeline, keys|
+ positions = keys.map(&:second)
+
+ stages = pipeline.stages.by_position(positions)
+
+ stages.each do |stage|
+ loader.call([pipeline, stage.position], stage.latest_statuses)
+ end
+ end
+ end
+ end
+
def stage
::Gitlab::Graphql::Lazy.with_value(pipeline) do |pl|
BatchLoader::GraphQL.for([pl, object.stage]).batch do |ids, loader|
diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb
index da2f11be9e2..c8ac31bce4d 100644
--- a/app/graphql/types/ci/pipeline_type.rb
+++ b/app/graphql/types/ci/pipeline_type.rb
@@ -45,7 +45,7 @@ module Types
field :queued_duration, Types::DurationType, null: true,
description: 'How long the pipeline was queued before starting.'
- field :coverage, GraphQL::FLOAT_TYPE, null: true,
+ field :coverage, GraphQL::Types::Float, null: true,
description: 'Coverage percentage.'
field :created_at, Types::TimeType, null: false,
@@ -66,7 +66,7 @@ module Types
field :stages,
type: Types::Ci::StageType.connection_type,
null: true,
- authorize: :read_commit_status,
+ authorize: :read_build,
description: 'Stages of the pipeline.',
extras: [:lookahead],
resolver: Resolvers::Ci::PipelineStagesResolver
@@ -89,14 +89,14 @@ module Types
field :jobs,
::Types::Ci::JobType.connection_type,
null: true,
- authorize: :read_commit_status,
+ authorize: :read_build,
description: 'Jobs belonging to the pipeline.',
resolver: ::Resolvers::Ci::JobsResolver
field :job,
type: ::Types::Ci::JobType,
null: true,
- authorize: :read_commit_status,
+ authorize: :read_build,
description: 'Specific job in this pipeline, either by name or ID.' do
argument :id,
type: ::Types::GlobalIDType[::CommitStatus],
@@ -116,7 +116,7 @@ module Types
field :source_job,
type: Types::Ci::JobType,
null: true,
- authorize: :read_commit_status,
+ authorize: :read_build,
description: 'Job where pipeline was triggered from.'
field :downstream, Types::Ci::PipelineType.connection_type, null: true,
diff --git a/app/graphql/types/ci/runner_status_enum.rb b/app/graphql/types/ci/runner_status_enum.rb
index 8501ce20204..dd056191ceb 100644
--- a/app/graphql/types/ci/runner_status_enum.rb
+++ b/app/graphql/types/ci/runner_status_enum.rb
@@ -5,24 +5,37 @@ module Types
class RunnerStatusEnum < BaseEnum
graphql_name 'CiRunnerStatus'
- ::Ci::Runner::AVAILABLE_STATUSES.each do |status|
- description = case status
- when 'active'
- "A runner that is not paused."
- when 'online'
- "A runner that contacted this instance within the last #{::Ci::Runner::ONLINE_CONTACT_TIMEOUT.inspect}."
- when 'offline'
- "A runner that has not contacted this instance within the last #{::Ci::Runner::ONLINE_CONTACT_TIMEOUT.inspect}."
- when 'not_connected'
- "A runner that has never contacted this instance."
- else
- "A runner that is #{status.to_s.tr('_', ' ')}."
- end
-
- value status.to_s.upcase,
- description: description,
- value: status.to_sym
- end
+ value 'ACTIVE',
+ description: 'Runner that is not paused.',
+ deprecated: { reason: 'Use CiRunnerType.active instead', milestone: '14.6' },
+ value: :active
+
+ value 'PAUSED',
+ description: 'Runner that is paused.',
+ deprecated: { reason: 'Use CiRunnerType.active instead', milestone: '14.6' },
+ value: :paused
+
+ value 'ONLINE',
+ description: "Runner that contacted this instance within the last #{::Ci::Runner::ONLINE_CONTACT_TIMEOUT.inspect}.",
+ value: :online
+
+ value 'OFFLINE',
+ description: "Runner that has not contacted this instance within the last #{::Ci::Runner::ONLINE_CONTACT_TIMEOUT.inspect}.",
+ deprecated: { reason: 'This field will have a slightly different scope starting in 15.0, with STALE being returned after a certain period offline', milestone: '14.6' },
+ value: :offline
+
+ value 'STALE',
+ description: "Runner that has not contacted this instance within the last #{::Ci::Runner::STALE_TIMEOUT.inspect}. Only available if legacyMode is null. Will be a possible return value starting in 15.0.",
+ value: :stale
+
+ value 'NOT_CONNECTED',
+ description: 'Runner that has never contacted this instance.',
+ deprecated: { reason: "Use NEVER_CONTACTED instead. NEVER_CONTACTED will have a slightly different scope starting in 15.0, with STALE being returned instead after #{::Ci::Runner::STALE_TIMEOUT.inspect} of no contact", milestone: '14.6' },
+ value: :not_connected
+
+ value 'NEVER_CONTACTED',
+ description: 'Runner that has never contacted this instance. Set legacyMode to null to utilize this value. Will replace NOT_CONNECTED starting in 15.0.',
+ value: :never_contacted
end
end
end
diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb
index 9bf98aa7e86..d37cca0927f 100644
--- a/app/graphql/types/ci/runner_type.rb
+++ b/app/graphql/types/ci/runner_type.rb
@@ -27,8 +27,11 @@ module Types
description: 'Access level of the runner.'
field :active, GraphQL::Types::Boolean, null: false,
description: 'Indicates the runner is allowed to receive jobs.'
- field :status, ::Types::Ci::RunnerStatusEnum, null: false,
- description: 'Status of the runner.'
+ field :status,
+ Types::Ci::RunnerStatusEnum,
+ null: false,
+ description: 'Status of the runner.',
+ resolver: ::Resolvers::Ci::RunnerStatusResolver
field :version, GraphQL::Types::String, null: true,
description: 'Version of the runner.'
field :short_sha, GraphQL::Types::String, null: true,
@@ -50,7 +53,7 @@ module Types
field :job_count, GraphQL::Types::Int, null: true,
description: "Number of jobs processed by the runner (limited to #{JOB_COUNT_LIMIT}, plus one to indicate that more items exist)."
field :admin_url, GraphQL::Types::String, null: true,
- description: 'Admin URL of the runner. Only available for adminstrators.'
+ description: 'Admin URL of the runner. Only available for administrators.'
def job_count
# We limit to 1 above the JOB_COUNT_LIMIT to indicate that more items exist after JOB_COUNT_LIMIT
diff --git a/app/graphql/types/ci/runner_web_url_edge.rb b/app/graphql/types/ci/runner_web_url_edge.rb
index 3b9fdfd1571..368e16f972c 100644
--- a/app/graphql/types/ci/runner_web_url_edge.rb
+++ b/app/graphql/types/ci/runner_web_url_edge.rb
@@ -3,7 +3,7 @@
module Types
module Ci
# rubocop: disable Graphql/AuthorizeTypes
- class RunnerWebUrlEdge < GraphQL::Types::Relay::BaseEdge
+ class RunnerWebUrlEdge < ::Types::BaseEdge
include FindClosest
field :web_url, GraphQL::Types::String, null: true,
diff --git a/app/graphql/types/ci/stage_type.rb b/app/graphql/types/ci/stage_type.rb
index c0d931b3d31..70e78e391a7 100644
--- a/app/graphql/types/ci/stage_type.rb
+++ b/app/graphql/types/ci/stage_type.rb
@@ -4,7 +4,7 @@ module Types
module Ci
class StageType < BaseObject
graphql_name 'CiStage'
- authorize :read_commit_status
+ authorize :read_build
field :id, GraphQL::Types::ID, null: false,
description: 'ID of the stage.'
@@ -31,7 +31,10 @@ module Types
BatchLoader::GraphQL.for(key).batch(default_value: []) do |keys, loader|
by_pipeline = keys.group_by(&:pipeline)
- include_needs = keys.any? { |k| k.requires?(%i[nodes jobs nodes needs]) }
+ include_needs = keys.any? do |k|
+ k.requires?(%i[nodes jobs nodes needs]) ||
+ k.requires?(%i[nodes jobs nodes previousStageJobsAndNeeds])
+ end
by_pipeline.each do |pl, key_group|
project = pl.project
diff --git a/app/graphql/types/ci/test_case_type.rb b/app/graphql/types/ci/test_case_type.rb
index 9ec5daa44ea..6e5f55aa3ed 100644
--- a/app/graphql/types/ci/test_case_type.rb
+++ b/app/graphql/types/ci/test_case_type.rb
@@ -18,7 +18,7 @@ module Types
field :classname, GraphQL::Types::String, null: true,
description: 'Classname of the test case.'
- field :execution_time, GraphQL::FLOAT_TYPE, null: true,
+ field :execution_time, GraphQL::Types::Float, null: true,
description: 'Test case execution time in seconds.'
field :file, GraphQL::Types::String, null: true,
diff --git a/app/graphql/types/ci/test_report_total_type.rb b/app/graphql/types/ci/test_report_total_type.rb
index aa07a391519..48aea1257c5 100644
--- a/app/graphql/types/ci/test_report_total_type.rb
+++ b/app/graphql/types/ci/test_report_total_type.rb
@@ -7,7 +7,7 @@ module Types
graphql_name 'TestReportTotal'
description 'Total test report statistics.'
- field :time, GraphQL::FLOAT_TYPE, null: true,
+ field :time, GraphQL::Types::Float, null: true,
description: 'Total duration of the tests.'
field :count, GraphQL::Types::Int, null: true,
diff --git a/app/graphql/types/ci/test_suite_summary_type.rb b/app/graphql/types/ci/test_suite_summary_type.rb
index 3db2d80d591..ec7b852213b 100644
--- a/app/graphql/types/ci/test_suite_summary_type.rb
+++ b/app/graphql/types/ci/test_suite_summary_type.rb
@@ -12,7 +12,7 @@ module Types
field :name, GraphQL::Types::String, null: true,
description: 'Name of the test suite.'
- field :total_time, GraphQL::FLOAT_TYPE, null: true,
+ field :total_time, GraphQL::Types::Float, null: true,
description: 'Total duration of the tests in the test suite.'
field :total_count, GraphQL::Types::Int, null: true,
diff --git a/app/graphql/types/ci/test_suite_type.rb b/app/graphql/types/ci/test_suite_type.rb
index f9f37d4045e..7ce479632cc 100644
--- a/app/graphql/types/ci/test_suite_type.rb
+++ b/app/graphql/types/ci/test_suite_type.rb
@@ -12,7 +12,7 @@ module Types
field :name, GraphQL::Types::String, null: true,
description: 'Name of the test suite.'
- field :total_time, GraphQL::FLOAT_TYPE, null: true,
+ field :total_time, GraphQL::Types::Float, null: true,
description: 'Total duration of the tests in the test suite.'
field :total_count, GraphQL::Types::Int, null: true,
diff --git a/app/graphql/types/clusters/agent_activity_event_type.rb b/app/graphql/types/clusters/agent_activity_event_type.rb
new file mode 100644
index 00000000000..79a9fd70505
--- /dev/null
+++ b/app/graphql/types/clusters/agent_activity_event_type.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Types
+ module Clusters
+ class AgentActivityEventType < BaseObject
+ graphql_name 'ClusterAgentActivityEvent'
+
+ authorize :admin_cluster
+
+ connection_type_class(Types::CountableConnectionType)
+
+ field :recorded_at,
+ Types::TimeType,
+ null: true,
+ description: 'Timestamp the event was recorded.'
+
+ field :kind,
+ GraphQL::Types::String,
+ null: true,
+ description: 'Type of event.'
+
+ field :level,
+ GraphQL::Types::String,
+ null: true,
+ description: 'Severity of the event.'
+
+ field :user,
+ Types::UserType,
+ null: true,
+ description: 'User associated with the event.'
+
+ field :agent_token,
+ Types::Clusters::AgentTokenType,
+ null: true,
+ description: 'Agent token associated with the event.'
+ end
+ end
+end
diff --git a/app/graphql/types/clusters/agent_type.rb b/app/graphql/types/clusters/agent_type.rb
index ce748f6e8ae..89316ed4728 100644
--- a/app/graphql/types/clusters/agent_type.rb
+++ b/app/graphql/types/clusters/agent_type.rb
@@ -55,6 +55,12 @@ module Types
complexity: 5,
resolver: ::Resolvers::Kas::AgentConnectionsResolver
+ field :activity_events,
+ Types::Clusters::AgentActivityEventType.connection_type,
+ null: true,
+ description: 'Recent activity for the cluster agent.',
+ resolver: Resolvers::Clusters::AgentActivityEventsResolver
+
def project
Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find
end
diff --git a/app/graphql/types/container_repository_details_type.rb b/app/graphql/types/container_repository_details_type.rb
index 8190cc9bc25..e713aaebe36 100644
--- a/app/graphql/types/container_repository_details_type.rb
+++ b/app/graphql/types/container_repository_details_type.rb
@@ -12,16 +12,11 @@ module Types
Types::ContainerRepositoryTagType.connection_type,
null: true,
description: 'Tags of the container repository.',
- max_page_size: 20
+ max_page_size: 20,
+ resolver: Resolvers::ContainerRepositoryTagsResolver
def can_delete
Ability.allowed?(current_user, :destroy_container_image, object)
end
-
- def tags
- object.tags
- rescue Faraday::Error
- raise ::Gitlab::Graphql::Errors::ResourceNotAvailable, 'We are having trouble connecting to the Container Registry. If this error persists, please review the troubleshooting documentation.'
- end
end
end
diff --git a/app/graphql/types/container_repository_tags_sort_enum.rb b/app/graphql/types/container_repository_tags_sort_enum.rb
new file mode 100644
index 00000000000..253cffd9a8c
--- /dev/null
+++ b/app/graphql/types/container_repository_tags_sort_enum.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Types
+ class ContainerRepositoryTagsSortEnum < BaseEnum
+ graphql_name 'ContainerRepositoryTagSort'
+ description 'Values for sorting tags'
+
+ value 'NAME_ASC', 'Ordered by name in ascending order.', value: :name_asc
+ value 'NAME_DESC', 'Ordered by name in descending order.', value: :name_desc
+ end
+end
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index 3b0f93d8dc1..498569f11ca 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -80,7 +80,8 @@ module Types
description: 'Relative position of the issue (used for positioning in epic tree and issue boards).'
field :participants, Types::UserType.connection_type, null: true, complexity: 5,
- description: 'List of participants in the issue.'
+ description: 'List of participants in the issue.',
+ resolver: Resolvers::Users::ParticipantsResolver
field :emails_disabled, GraphQL::Types::Boolean, null: false,
method: :project_emails_disabled?,
description: 'Indicates if a project has email notifications disabled: `true` if email notifications are disabled.'
diff --git a/app/graphql/types/issue_type_enum.rb b/app/graphql/types/issue_type_enum.rb
index 6999ea270a2..0cfba6bbbd0 100644
--- a/app/graphql/types/issue_type_enum.rb
+++ b/app/graphql/types/issue_type_enum.rb
@@ -5,7 +5,7 @@ module Types
graphql_name 'IssueType'
description 'Issue type'
- ::WorkItem::Type.base_types.keys.each do |issue_type|
+ ::WorkItem::Type.allowed_types_for_issues.each do |issue_type|
value issue_type.upcase, value: issue_type, description: "#{issue_type.titleize} issue type"
end
end
diff --git a/app/graphql/types/merge_request_connection_type.rb b/app/graphql/types/merge_request_connection_type.rb
index d009b67bc0f..9596c812c69 100644
--- a/app/graphql/types/merge_request_connection_type.rb
+++ b/app/graphql/types/merge_request_connection_type.rb
@@ -3,7 +3,7 @@
module Types
# rubocop: disable Graphql/AuthorizeTypes
class MergeRequestConnectionType < Types::CountableConnectionType
- field :total_time_to_merge, GraphQL::FLOAT_TYPE, null: true,
+ field :total_time_to_merge, GraphQL::Types::Float, null: true,
description: 'Total sum of time to merge, in seconds, for the collection of merge requests.'
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index a0f00ddc3c6..0672ec6f0f8 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -21,10 +21,8 @@ module Types
description: 'Internal ID of the merge request.'
field :title, GraphQL::Types::String, null: false,
description: 'Title of the merge request.'
- markdown_field :title_html, null: true
field :description, GraphQL::Types::String, null: true,
description: 'Description of the merge request (Markdown rendered as HTML for caching).'
- markdown_field :description_html, null: true
field :state, MergeRequestStateEnum, null: false,
description: 'State of the merge request.'
field :created_at, Types::TimeType, null: false,
@@ -96,7 +94,7 @@ module Types
description: 'Rebase commit SHA of the merge request.'
field :rebase_in_progress, GraphQL::Types::Boolean, method: :rebase_in_progress?, null: false, calls_gitaly: true,
description: 'Indicates if there is a rebase currently in progress for the merge request.'
- field :default_merge_commit_message, GraphQL::Types::String, null: true,
+ field :default_merge_commit_message, GraphQL::Types::String, null: true, calls_gitaly: true,
description: 'Default merge commit message of the merge request.'
field :default_merge_commit_message_with_description, GraphQL::Types::String, null: true,
description: 'Default merge commit message of the merge request with description. Will have the same value as `defaultMergeCommitMessage` when project has `mergeCommitTemplate` set.',
@@ -148,7 +146,8 @@ module Types
field :author, Types::UserType, null: true,
description: 'User who created this merge request.'
field :participants, Types::UserType.connection_type, null: true, complexity: 15,
- description: 'Participants in the merge request. This includes the author, assignees, reviewers, and users mentioned in notes.'
+ description: 'Participants in the merge request. This includes the author, assignees, reviewers, and users mentioned in notes.',
+ resolver: Resolvers::Users::ParticipantsResolver
field :subscribed, GraphQL::Types::Boolean, method: :subscribed?, null: false, complexity: 5,
description: 'Indicates if the currently logged in user is subscribed to this merge request.'
field :labels, Types::LabelType.connection_type, null: true, complexity: 5,
@@ -201,6 +200,9 @@ module Types
field :timelogs, Types::TimelogType.connection_type, null: false,
description: 'Timelogs on the merge request.'
+ markdown_field :title_html, null: true
+ markdown_field :description_html, null: true
+
def approved_by
object.approved_by_users
end
diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb
index 3c5994ac559..ba90fb06cb2 100644
--- a/app/graphql/types/namespace_type.rb
+++ b/app/graphql/types/namespace_type.rb
@@ -20,7 +20,6 @@ module Types
field :description, GraphQL::Types::String, null: true,
description: 'Description of the namespace.'
- markdown_field :description_html, null: true
field :visibility, GraphQL::Types::String, null: true,
description: 'Visibility of the namespace.'
@@ -47,6 +46,8 @@ module Types
null: true,
description: "Shared runners availability for the namespace and its descendants."
+ markdown_field :description_html, null: true
+
def root_storage_statistics
Gitlab::Graphql::Loaders::BatchRootStorageStatisticsLoader.new(object.id).find
end
diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb
index da6ea83401d..7314c137010 100644
--- a/app/graphql/types/notes/note_type.rb
+++ b/app/graphql/types/notes/note_type.rb
@@ -33,8 +33,6 @@ module Types
method: :note,
description: 'Content of the note.'
- markdown_field :body_html, null: true, method: :note
-
field :created_at, Types::TimeType, null: false,
description: 'Timestamp of the note creation.'
field :updated_at, Types::TimeType, null: false,
@@ -50,6 +48,8 @@ module Types
null: true,
description: 'URL to view this Note in the Web UI.'
+ markdown_field :body_html, null: true, method: :note
+
def url
::Gitlab::UrlBuilder.build(object)
end
diff --git a/app/graphql/types/packages/package_details_type.rb b/app/graphql/types/packages/package_details_type.rb
index 59a4885e87e..5ac80860fe2 100644
--- a/app/graphql/types/packages/package_details_type.rb
+++ b/app/graphql/types/packages/package_details_type.rb
@@ -14,6 +14,13 @@ module Types
field :dependency_links, Types::Packages::PackageDependencyLinkType.connection_type, null: true, description: 'Dependency link.'
+ # this is an override of Types::Packages::PackageType.pipelines
+ # in order to use a custom resolver: Resolvers::PackagePipelinesResolver
+ field :pipelines,
+ resolver: Resolvers::PackagePipelinesResolver,
+ description: 'Pipelines that built the package.',
+ deprecated: { reason: 'Due to scalability concerns, this field is going to be removed', milestone: '14.6' }
+
def versions
object.versions
end
diff --git a/app/graphql/types/packages/package_type.rb b/app/graphql/types/packages/package_type.rb
index 9851c6aec7e..d1312cb963d 100644
--- a/app/graphql/types/packages/package_type.rb
+++ b/app/graphql/types/packages/package_type.rb
@@ -21,7 +21,8 @@ module Types
field :tags, Types::Packages::PackageTagType.connection_type, null: true, description: 'Package tags.'
field :project, Types::ProjectType, null: false, description: 'Project where the package is stored.'
field :pipelines, Types::Ci::PipelineType.connection_type, null: true,
- description: 'Pipelines that built the package.'
+ description: 'Pipelines that built the package.',
+ deprecated: { reason: 'Due to scalability concerns, this field is going to be removed', milestone: '14.6' }
field :metadata, Types::Packages::MetadataType, null: true,
description: 'Package metadata.'
field :versions, ::Types::Packages::PackageType.connection_type, null: true,
diff --git a/app/graphql/types/project_statistics_type.rb b/app/graphql/types/project_statistics_type.rb
index 60a3d5ce06b..ab2b9c2a3af 100644
--- a/app/graphql/types/project_statistics_type.rb
+++ b/app/graphql/types/project_statistics_type.rb
@@ -6,26 +6,26 @@ module Types
authorize :read_statistics
- field :commit_count, GraphQL::FLOAT_TYPE, null: false,
+ field :commit_count, GraphQL::Types::Float, null: false,
description: 'Commit count of the project.'
- field :storage_size, GraphQL::FLOAT_TYPE, null: false,
+ field :storage_size, GraphQL::Types::Float, null: false,
description: 'Storage size of the project in bytes.'
- field :repository_size, GraphQL::FLOAT_TYPE, null: false,
+ field :repository_size, GraphQL::Types::Float, null: false,
description: 'Repository size of the project in bytes.'
- field :lfs_objects_size, GraphQL::FLOAT_TYPE, null: false,
+ field :lfs_objects_size, GraphQL::Types::Float, null: false,
description: 'Large File Storage (LFS) object size of the project in bytes.'
- field :build_artifacts_size, GraphQL::FLOAT_TYPE, null: false,
+ field :build_artifacts_size, GraphQL::Types::Float, null: false,
description: 'Build artifacts size of the project in bytes.'
- field :packages_size, GraphQL::FLOAT_TYPE, null: false,
+ field :packages_size, GraphQL::Types::Float, null: false,
description: 'Packages size of the project in bytes.'
- field :wiki_size, GraphQL::FLOAT_TYPE, null: true,
+ field :wiki_size, GraphQL::Types::Float, null: true,
description: 'Wiki size of the project in bytes.'
- field :snippets_size, GraphQL::FLOAT_TYPE, null: true,
+ field :snippets_size, GraphQL::Types::Float, null: true,
description: 'Snippets size of the project in bytes.'
- field :pipeline_artifacts_size, GraphQL::FLOAT_TYPE, null: true,
+ field :pipeline_artifacts_size, GraphQL::Types::Float, null: true,
description: 'CI Pipeline artifacts size in bytes.'
- field :uploads_size, GraphQL::FLOAT_TYPE, null: true,
+ field :uploads_size, GraphQL::Types::Float, null: true,
description: 'Uploads size of the project in bytes.'
end
end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index b6cb9cd3302..3d2ee47a499 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -194,7 +194,7 @@ module Types
field :jobs,
type: Types::Ci::JobType.connection_type,
null: true,
- authorize: :read_commit_status,
+ authorize: :read_build,
description: 'Jobs of a project. This field can only be resolved for one project in any single request.',
resolver: Resolvers::ProjectJobsResolver
@@ -386,6 +386,11 @@ module Types
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.'
+
def label(title:)
BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args|
LabelsFinder
diff --git a/app/graphql/types/repository/blob_type.rb b/app/graphql/types/repository/blob_type.rb
index 104171e6772..3265c14bdca 100644
--- a/app/graphql/types/repository/blob_type.rb
+++ b/app/graphql/types/repository/blob_type.rb
@@ -71,6 +71,10 @@ module Types
field :pipeline_editor_path, GraphQL::Types::String, null: true,
description: 'Web path to edit .gitlab-ci.yml file.'
+ field :code_owners, [Types::UserType], null: true,
+ description: 'List of code owners for the blob.',
+ calls_gitaly: true
+
field :file_type, GraphQL::Types::String, null: true,
description: 'Expected format of the blob based on the extension.'
@@ -91,6 +95,9 @@ module Types
calls_gitaly: true,
description: 'Whether the current user can modify the blob.'
+ field :can_current_user_push_to_branch, GraphQL::Types::Boolean, null: true, method: :can_current_user_push_to_branch?,
+ description: 'Whether the current user can push to the branch.'
+
def raw_text_blob
object.data unless object.binary?
end
@@ -101,3 +108,5 @@ module Types
end
end
end
+
+Types::Repository::BlobType.prepend_mod_with('Types::Repository::BlobType')
diff --git a/app/graphql/types/root_storage_statistics_type.rb b/app/graphql/types/root_storage_statistics_type.rb
index 47ca195cc4b..88dc6036bfd 100644
--- a/app/graphql/types/root_storage_statistics_type.rb
+++ b/app/graphql/types/root_storage_statistics_type.rb
@@ -6,14 +6,14 @@ module Types
authorize :read_statistics
- field :storage_size, GraphQL::FLOAT_TYPE, null: false, description: 'Total storage in bytes.'
- field :repository_size, GraphQL::FLOAT_TYPE, null: false, description: 'Git repository size in bytes.'
- field :lfs_objects_size, GraphQL::FLOAT_TYPE, null: false, description: 'LFS objects size in bytes.'
- field :build_artifacts_size, GraphQL::FLOAT_TYPE, null: false, description: 'CI artifacts size in bytes.'
- field :packages_size, GraphQL::FLOAT_TYPE, null: false, description: 'Packages size in bytes.'
- field :wiki_size, GraphQL::FLOAT_TYPE, null: false, description: 'Wiki size in bytes.'
- field :snippets_size, GraphQL::FLOAT_TYPE, null: false, description: 'Snippets size in bytes.'
- field :pipeline_artifacts_size, GraphQL::FLOAT_TYPE, null: false, description: 'CI pipeline artifacts size in bytes.'
- field :uploads_size, GraphQL::FLOAT_TYPE, null: false, description: 'Uploads size in bytes.'
+ field :storage_size, GraphQL::Types::Float, null: false, description: 'Total storage in bytes.'
+ field :repository_size, GraphQL::Types::Float, null: false, description: 'Git repository size in bytes.'
+ field :lfs_objects_size, GraphQL::Types::Float, null: false, description: 'LFS objects size in bytes.'
+ field :build_artifacts_size, GraphQL::Types::Float, null: false, description: 'CI artifacts size in bytes.'
+ field :packages_size, GraphQL::Types::Float, null: false, description: 'Packages size in bytes.'
+ field :wiki_size, GraphQL::Types::Float, null: false, description: 'Wiki size in bytes.'
+ field :snippets_size, GraphQL::Types::Float, null: false, description: 'Snippets size in bytes.'
+ field :pipeline_artifacts_size, GraphQL::Types::Float, null: false, description: 'CI pipeline artifacts size in bytes.'
+ field :uploads_size, GraphQL::Types::Float, null: false, description: 'Uploads size in bytes.'
end
end
diff --git a/app/graphql/types/subscription_type.rb b/app/graphql/types/subscription_type.rb
index 5356a998f0d..3629edb5b33 100644
--- a/app/graphql/types/subscription_type.rb
+++ b/app/graphql/types/subscription_type.rb
@@ -6,5 +6,8 @@ module Types
field :issuable_assignees_updated, subscription: Subscriptions::IssuableUpdated, null: true,
description: 'Triggered when the assignees of an issuable are updated.'
+
+ field :issue_crm_contacts_updated, subscription: Subscriptions::IssuableUpdated, null: true,
+ description: 'Triggered when the crm contacts of an issuable are updated.'
end
end
diff --git a/app/graphql/types/user_callout_feature_name_enum.rb b/app/graphql/types/user_callout_feature_name_enum.rb
index 410ca5e1c95..bcb49a709ed 100644
--- a/app/graphql/types/user_callout_feature_name_enum.rb
+++ b/app/graphql/types/user_callout_feature_name_enum.rb
@@ -5,7 +5,7 @@ module Types
graphql_name 'UserCalloutFeatureNameEnum'
description 'Name of the feature that the callout is for.'
- ::UserCallout.feature_names.keys.each do |feature_name|
+ ::Users::Callout.feature_names.keys.each do |feature_name|
value feature_name.upcase, value: feature_name, description: "Callout feature name for #{feature_name}."
end
end
diff --git a/app/helpers/access_tokens_helper.rb b/app/helpers/access_tokens_helper.rb
index 877ad6db576..1d38262159f 100644
--- a/app/helpers/access_tokens_helper.rb
+++ b/app/helpers/access_tokens_helper.rb
@@ -1,7 +1,30 @@
# frozen_string_literal: true
module AccessTokensHelper
+ include AccountsHelper
+ include ApplicationHelper
+
def scope_description(prefix)
prefix == :project_access_token ? [:doorkeeper, :project_access_token_scope_desc] : [:doorkeeper, :scope_desc]
end
+
+ def tokens_app_data
+ {
+ feed_token: {
+ enabled: !Gitlab::CurrentSettings.disable_feed_token,
+ token: current_user.feed_token,
+ reset_path: reset_feed_token_profile_path
+ },
+ incoming_email_token: {
+ enabled: incoming_email_token_enabled?,
+ token: current_user.enabled_incoming_email_token,
+ reset_path: reset_incoming_email_token_profile_path
+ },
+ static_object_token: {
+ enabled: static_objects_external_storage_enabled?,
+ token: current_user.enabled_static_object_token,
+ reset_path: reset_static_object_token_profile_path
+ }
+ }.to_json
+ end
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 58f933a7fe0..02a87979f40 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -206,10 +206,6 @@ module ApplicationHelper
'https://' + promo_host
end
- def contact_sales_url
- promo_url + '/sales'
- end
-
def support_url
Gitlab::CurrentSettings.current_application_settings.help_page_support_url.presence || promo_url + '/getting-help/'
end
diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb
index 6fe92a5a978..c1a74382d46 100644
--- a/app/helpers/auth_helper.rb
+++ b/app/helpers/auth_helper.rb
@@ -86,6 +86,17 @@ module AuthHelper
auth_providers.select { |provider| form_based_provider?(provider) }
end
+ def saml_providers
+ auth_providers.select { |provider| auth_strategy_class(provider) == 'OmniAuth::Strategies::SAML' }
+ end
+
+ def auth_strategy_class(provider)
+ config = Gitlab::Auth::OAuth::Provider.config_for(provider)
+ return if config.nil? || config['args'].blank?
+
+ config.args['strategy_class']
+ end
+
def any_form_based_providers_enabled?
form_based_providers.any? { |provider| form_enabled_for_sign_in?(provider) }
end
@@ -164,10 +175,25 @@ module AuthHelper
end
def google_tag_manager_enabled?
- Gitlab.com? &&
- extra_config.has_key?('google_tag_manager_id') &&
- extra_config.google_tag_manager_id.present? &&
- !current_user
+ return false unless Gitlab.dev_env_or_com?
+
+ has_config_key = if Feature.enabled?(:gtm_nonce, type: :ops)
+ extra_config.has_key?('google_tag_manager_nonce_id') &&
+ extra_config.google_tag_manager_nonce_id.present?
+ else
+ extra_config.has_key?('google_tag_manager_id') &&
+ extra_config.google_tag_manager_id.present?
+ end
+
+ has_config_key && !current_user
+ end
+
+ def google_tag_manager_id
+ return unless google_tag_manager_enabled?
+
+ return extra_config.google_tag_manager_nonce_id if Feature.enabled?(:gtm_nonce, type: :ops)
+
+ extra_config.google_tag_manager_id
end
def auth_app_owner_text(owner)
diff --git a/app/helpers/badges_helper.rb b/app/helpers/badges_helper.rb
new file mode 100644
index 00000000000..a03f7f4097a
--- /dev/null
+++ b/app/helpers/badges_helper.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+module BadgesHelper
+ VARIANT_CLASSES = {
+ muted: "badge-muted",
+ neutral: "badge-neutral",
+ info: "badge-info",
+ success: "badge-success",
+ warning: "badge-warning",
+ danger: "badge-danger"
+ }.tap { |hash| hash.default = hash.fetch(:muted) } .freeze
+
+ SIZE_CLASSES = {
+ sm: "sm",
+ md: "md",
+ lg: "lg"
+ }.tap { |hash| hash.default = hash.fetch(:md) } .freeze
+
+ GL_BADGE_CLASSES = %w[gl-badge badge badge-pill].freeze
+
+ GL_ICON_CLASSES = %w[gl-icon gl-badge-icon].freeze
+
+ # Creates a GitLab UI badge.
+ #
+ # Examples:
+ # # Plain text badge
+ # gl_badge_tag("foo")
+ #
+ # # Danger variant
+ # gl_badge_tag("foo", variant: :danger)
+ #
+ # # Small size
+ # gl_badge_tag("foo", size: :sm)
+ #
+ # # With icon
+ # gl_badge_tag("foo", icon: "question-o")
+ #
+ # # Icon-only
+ # gl_badge_tag("foo", icon: "question-o", icon_only: true)
+ #
+ # # Badge link
+ # gl_badge_tag("foo", nil, href: some_path)
+ #
+ # # Custom classes
+ # gl_badge_tag("foo", nil, class: "foo-bar")
+ #
+ # # Block content
+ # gl_badge_tag({ variant: :danger }, { class: "foo-bar" }) do
+ # "foo"
+ # end
+ #
+ # For accessibility, ensure that the given text or block is non-empty.
+ #
+ # See also https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/base-badge--default.
+ def gl_badge_tag(*args, &block)
+ if block_given?
+ build_gl_badge_tag(capture(&block), *args)
+ else
+ build_gl_badge_tag(*args)
+ end
+ end
+
+ private
+
+ def build_gl_badge_tag(content, options = nil, html_options = nil)
+ options ||= {}
+ html_options ||= {}
+
+ icon_only = options[:icon_only]
+ variant_class = VARIANT_CLASSES[options.fetch(:variant, :muted)]
+ size_class = SIZE_CLASSES[options.fetch(:size, :md)]
+
+ html_options = html_options.merge(
+ class: [
+ *GL_BADGE_CLASSES,
+ variant_class,
+ size_class,
+ *html_options[:class]
+ ]
+ )
+
+ if icon_only
+ html_options['aria-label'] = content
+ html_options['role'] = 'img'
+ end
+
+ if options[:icon]
+ icon_classes = GL_ICON_CLASSES.dup
+ icon_classes << "gl-mr-2" unless icon_only
+ icon = sprite_icon(options[:icon], css_class: icon_classes.join(' '))
+
+ content = icon_only ? icon : icon + content
+ end
+
+ tag = html_options[:href].nil? ? :span : :a
+
+ content_tag(tag, content, html_options)
+ end
+end
diff --git a/app/helpers/blame_helper.rb b/app/helpers/blame_helper.rb
index 82c74e2416d..5117f7c6d9c 100644
--- a/app/helpers/blame_helper.rb
+++ b/app/helpers/blame_helper.rb
@@ -1,6 +1,13 @@
# frozen_string_literal: true
module BlameHelper
+ BODY_FONT_SIZE = "0.875rem"
+ COMMIT_LINE_HEIGHT = 3 # 150% * 2 lines of text
+ COMMIT_PADDING = "10px" # 5px from both top and bottom
+ COMMIT_BLOCK_HEIGHT_EXP = "(#{BODY_FONT_SIZE} * #{COMMIT_LINE_HEIGHT}) + #{COMMIT_PADDING}"
+ CODE_LINE_HEIGHT = 1.1875
+ CODE_PADDING = "20px" # 10px from both top and bottom
+
def age_map_duration(blame_groups, project)
now = Time.zone.now
start_date = blame_groups.map { |blame_group| blame_group[:commit].committed_date }
@@ -24,4 +31,12 @@ module BlameHelper
"blame-commit-age-#{age_group}"
end
end
+
+ def intrinsic_row_css(line_count)
+ # using rems here because the size of the row depends on the text size
+ # which can be customized via user agent styles and browser preferences
+ total_line_height_exp = "#{line_count * CODE_LINE_HEIGHT}rem + #{CODE_PADDING}"
+ row_height_exp = line_count == 1 ? COMMIT_BLOCK_HEIGHT_EXP : total_line_height_exp
+ "contain-intrinsic-size: 1px calc(#{row_height_exp})"
+ end
end
diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb
index c26a73028b9..57da04b38cc 100644
--- a/app/helpers/boards_helper.rb
+++ b/app/helpers/boards_helper.rb
@@ -23,6 +23,7 @@ module BoardsHelper
labels_filter_base_path: build_issue_link_base,
labels_fetch_path: labels_fetch_path,
labels_manage_path: labels_manage_path,
+ releases_fetch_path: releases_fetch_path,
board_type: board.to_type
}
end
@@ -65,6 +66,14 @@ module BoardsHelper
end
end
+ def releases_fetch_path
+ if board.group_board?
+ group_releases_path(@group)
+ else
+ project_releases_path(@project)
+ end
+ end
+
def board_base_url
if board.group_board?
group_boards_url(@group)
diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb
index d02fe3f20b0..c7f40decae8 100644
--- a/app/helpers/ci/jobs_helper.rb
+++ b/app/helpers/ci/jobs_helper.rb
@@ -19,6 +19,13 @@ module Ci
}
end
+ def bridge_data(build)
+ {
+ "build_name" => build.name,
+ "empty-state-illustration-path" => image_path('illustrations/job-trigger-md.svg')
+ }
+ end
+
def job_counts
{
"all" => limited_counter_with_delimiter(@all_builds),
diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb
index 17057505173..8f219656b71 100644
--- a/app/helpers/ci/runners_helper.rb
+++ b/app/helpers/ci/runners_helper.rb
@@ -23,7 +23,7 @@ module Ci
icon = 'status-paused'
span_class = 'gl-text-gray-600'
end
- when :not_connected
+ when :not_connected, :never_contacted
title = s_("Runners|New runner, has not connected yet")
icon = 'warning-solid'
when :offline
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index ca5fe38576e..2b5f726dad1 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -283,7 +283,7 @@ module DiffHelper
return path unless path.size > max && max > 3
- "...#{path[-(max - 3)..-1]}"
+ "...#{path[-(max - 3)..]}"
end
def code_navigation_path(diffs)
diff --git a/app/helpers/export_helper.rb b/app/helpers/export_helper.rb
index 92d06471384..2699681fed7 100644
--- a/app/helpers/export_helper.rb
+++ b/app/helpers/export_helper.rb
@@ -18,7 +18,7 @@ module ExportHelper
[
_('Milestones'),
_('Labels'),
- _('Boards and Board Lists'),
+ _('Boards and board lists'),
_('Badges'),
_('Subgroups')
]
diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb
index 9b4d0c0b9b3..3a5dcb4e664 100644
--- a/app/helpers/form_helper.rb
+++ b/app/helpers/form_helper.rb
@@ -35,7 +35,7 @@ module FormHelper
def assignees_dropdown_options(issuable_type)
dropdown_data = {
toggle_class: 'js-user-search js-assignee-search js-multiselect js-save-user-data',
- title: 'Select assignee',
+ title: _('Select assignee'),
filter: true,
dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee',
placeholder: _('Search users'),
@@ -45,9 +45,9 @@ module FormHelper
current_user: true,
project_id: (@target_project || @project)&.id,
field_name: "#{issuable_type}[assignee_ids][]",
- default_label: 'Unassigned',
+ default_label: _('Unassigned'),
'max-select': 1,
- 'dropdown-header': 'Assignee',
+ 'dropdown-header': _('Assignee'),
multi_select: true,
'input-meta': 'name',
'always-show-selectbox': true,
@@ -123,7 +123,7 @@ module FormHelper
def multiple_assignees_dropdown_options(options)
new_options = options.dup
- new_options[:title] = 'Select assignee(s)'
+ new_options[:title] = _('Select assignee(s)')
new_options[:data][:'dropdown-header'] = 'Assignee(s)'
new_options[:data].delete(:'max-select')
diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb
index 09ff57e2baf..4d81aeca37a 100644
--- a/app/helpers/ide_helper.rb
+++ b/app/helpers/ide_helper.rb
@@ -29,7 +29,7 @@ module IdeHelper
def convert_to_project_entity_json(project)
return unless project
- API::Entities::Project.represent(project).to_json
+ API::Entities::Project.represent(project, current_user: current_user).to_json
end
def enable_environments_guidance?
diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb
index bb4a7fef6be..c5e767c6f64 100644
--- a/app/helpers/integrations_helper.rb
+++ b/app/helpers/integrations_helper.rb
@@ -17,31 +17,31 @@ module IntegrationsHelper
"#{event}_events"
end
- def scoped_integrations_path
- if @project.present?
- project_settings_integrations_path(@project)
- elsif @group.present?
- group_settings_integrations_path(@group)
+ def scoped_integrations_path(project: nil, group: nil)
+ if project.present?
+ project_settings_integrations_path(project)
+ elsif group.present?
+ group_settings_integrations_path(group)
else
integrations_admin_application_settings_path
end
end
- def scoped_integration_path(integration)
- if @project.present?
- project_service_path(@project, integration)
- elsif @group.present?
- group_settings_integration_path(@group, integration)
+ def scoped_integration_path(integration, project: nil, group: nil)
+ if project.present?
+ project_service_path(project, integration)
+ elsif group.present?
+ group_settings_integration_path(group, integration)
else
admin_application_settings_integration_path(integration)
end
end
- def scoped_edit_integration_path(integration)
- if @project.present?
- edit_project_service_path(@project, integration)
- elsif @group.present?
- edit_group_settings_integration_path(@group, integration)
+ def scoped_edit_integration_path(integration, project: nil, group: nil)
+ if project.present?
+ edit_project_service_path(project, integration)
+ elsif group.present?
+ edit_group_settings_integration_path(group, integration)
else
edit_admin_application_settings_integration_path(integration)
end
@@ -51,11 +51,11 @@ module IntegrationsHelper
overrides_admin_application_settings_integration_path(integration, options)
end
- def scoped_test_integration_path(integration)
- if @project.present?
- test_project_service_path(@project, integration)
- elsif @group.present?
- test_group_settings_integration_path(@group, integration)
+ def scoped_test_integration_path(integration, project: nil, group: nil)
+ if project.present?
+ test_project_service_path(project, integration)
+ elsif group.present?
+ test_group_settings_integration_path(group, integration)
else
test_admin_application_settings_integration_path(integration)
end
@@ -71,7 +71,7 @@ module IntegrationsHelper
end
end
- def integration_form_data(integration, group: nil)
+ def integration_form_data(integration, project: nil, group: nil)
form_data = {
id: integration.id,
show_active: integration.show_active_box?.to_s,
@@ -87,9 +87,9 @@ module IntegrationsHelper
inherit_from_id: integration.inherit_from_id,
integration_level: integration_level(integration),
editable: integration.editable?.to_s,
- cancel_path: scoped_integrations_path,
+ cancel_path: scoped_integrations_path(project: project, group: group),
can_test: integration.testable?.to_s,
- test_path: scoped_test_integration_path(integration),
+ test_path: scoped_test_integration_path(integration, project: project, group: group),
reset_path: scoped_reset_integration_path(integration, group: group)
}
@@ -107,9 +107,9 @@ module IntegrationsHelper
}
end
- def integration_list_data(integrations)
+ def integration_list_data(integrations, group: nil, project: nil)
{
- integrations: integrations.map { |i| serialize_integration(i) }.to_json
+ integrations: integrations.map { |i| serialize_integration(i, group: group, project: project) }.to_json
}
end
@@ -215,13 +215,13 @@ module IntegrationsHelper
end
end
- def serialize_integration(integration)
+ def serialize_integration(integration, group: nil, project: nil)
{
active: integration.operating?,
title: integration.title,
description: integration.description,
updated_at: integration.updated_at,
- edit_path: scoped_edit_integration_path(integration),
+ edit_path: scoped_edit_integration_path(integration, group: group, project: project),
name: integration.to_param
}
end
diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb
index 01ae0ce4f31..8b26b646fdd 100644
--- a/app/helpers/invite_members_helper.rb
+++ b/app/helpers/invite_members_helper.rb
@@ -35,14 +35,7 @@ module InviteMembersHelper
default_access_level: Gitlab::Access::GUEST
}
- experiment(:member_areas_of_focus, user: current_user) do |e|
- e.publish_to_database
-
- e.control { dataset.merge!(areas_of_focus_options: [], no_selection_areas_of_focus: []) }
- e.candidate { dataset.merge!(areas_of_focus_options: member_areas_of_focus_options.to_json, no_selection_areas_of_focus: ['no_selection']) }
- end
-
- if show_invite_members_for_task?
+ if show_invite_members_for_task?(source)
dataset.merge!(
tasks_to_be_done_options: tasks_to_be_done_options.to_json,
projects: projects_for_source(source).to_json,
@@ -55,35 +48,16 @@ module InviteMembersHelper
private
- def member_areas_of_focus_options
- [
- {
- value: 'Contribute to the codebase', text: s_('InviteMembersModal|Contribute to the codebase')
- },
- {
- value: 'Collaborate on open issues and merge requests', text: s_('InviteMembersModal|Collaborate on open issues and merge requests')
- },
- {
- value: 'Configure CI/CD', text: s_('InviteMembersModal|Configure CI/CD')
- },
- {
- value: 'Configure security features', text: s_('InviteMembersModal|Configure security features')
- },
- {
- value: 'Other', text: s_('InviteMembersModal|Other')
- }
- ]
- end
-
# Overridden in EE
def users_filter_data(group)
{}
end
- def show_invite_members_for_task?
- return unless current_user && experiment(:invite_members_for_task).enabled?
+ def show_invite_members_for_task?(source)
+ return unless current_user
- params[:open_modal] == 'invite_members_for_task'
+ invite_for_help_continuous_onboarding = source.is_a?(Project) && experiment(:invite_for_help_continuous_onboarding, namespace: source.namespace).variant.name == 'candidate'
+ params[:open_modal] == 'invite_members_for_task' || invite_for_help_continuous_onboarding
end
def tasks_to_be_done_options
diff --git a/app/helpers/issuables_description_templates_helper.rb b/app/helpers/issuables_description_templates_helper.rb
index 6b546d5c6fc..6c23f888823 100644
--- a/app/helpers/issuables_description_templates_helper.rb
+++ b/app/helpers/issuables_description_templates_helper.rb
@@ -6,7 +6,7 @@ module IssuablesDescriptionTemplatesHelper
def template_dropdown_tag(issuable, &block)
selected_template = selected_template(issuable)
- title = selected_template || "Choose a template"
+ title = selected_template || _('Choose a template')
options = {
toggle_class: 'js-issuable-selector',
title: title,
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 07f5adae272..53a7487741e 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -80,7 +80,7 @@ module IssuablesHelper
def users_dropdown_label(selected_users)
case selected_users.length
when 0
- "Unassigned"
+ _('Unassigned')
when 1
selected_users[0].name
else
@@ -133,7 +133,7 @@ module IssuablesHelper
end
# rubocop: enable CodeReuse/ActiveRecord
- def milestone_dropdown_label(milestone_title, default_label = "Milestone")
+ def milestone_dropdown_label(milestone_title, default_label = _('Milestone'))
title =
case milestone_title
when Milestone::Upcoming.name then Milestone::Upcoming.title
@@ -188,7 +188,12 @@ module IssuablesHelper
end
def issuables_state_counter_text(issuable_type, state, display_count)
- titles = { opened: "Open" }
+ titles = {
+ opened: _("Open"),
+ closed: _("Closed"),
+ merged: _("Merged"),
+ all: _("All")
+ }
state_title = titles[state] || state.to_s.humanize
html = content_tag(:span, state_title)
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index a88ca6f6b11..cddf740a0e6 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -193,11 +193,13 @@ module IssuesHelper
{
can_create_issue: show_new_issue_link?(project).to_s,
can_create_incident: create_issue_type_allowed?(project, :incident).to_s,
+ can_destroy_issue: can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable).to_s,
can_reopen_issue: can?(current_user, :reopen_issue, issuable).to_s,
can_report_spam: issuable.submittable_as_spam_by?(current_user).to_s,
can_update_issue: can?(current_user, :update_issue, issuable).to_s,
iid: issuable.iid,
is_issue_author: (issuable.author == current_user).to_s,
+ issue_path: issuable_path(issuable),
issue_type: issuable_display_type(issuable),
new_issue_path: new_project_issue_path(project, new_issuable_params),
project_path: project.full_path,
@@ -212,6 +214,8 @@ module IssuesHelper
calendar_path: url_for(safe_params.merge(calendar_url_options)),
empty_state_svg_path: image_path('illustrations/issues.svg'),
full_path: namespace.full_path,
+ is_anonymous_search_disabled: Feature.enabled?(:disable_anonymous_search, type: :ops).to_s,
+ is_issue_repositioning_disabled: issue_repositioning_disabled?.to_s,
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)),
diff --git a/app/helpers/jira_connect_helper.rb b/app/helpers/jira_connect_helper.rb
index 475469a6df9..9a0f0944fd1 100644
--- a/app/helpers/jira_connect_helper.rb
+++ b/app/helpers/jira_connect_helper.rb
@@ -8,7 +8,8 @@ module JiraConnectHelper
groups_path: api_v4_groups_path(params: { min_access_level: Gitlab::Access::MAINTAINER, skip_groups: skip_groups }),
subscriptions: subscriptions.map { |s| serialize_subscription(s) }.to_json,
subscriptions_path: jira_connect_subscriptions_path,
- users_path: current_user ? nil : jira_connect_users_path
+ users_path: current_user ? nil : jira_connect_users_path, # users_path is used to determine if user is signed in
+ gitlab_user_path: current_user ? user_path(current_user) : nil
}
end
diff --git a/app/helpers/learn_gitlab_helper.rb b/app/helpers/learn_gitlab_helper.rb
index 08a30c4d53b..7f8f6d77ff4 100644
--- a/app/helpers/learn_gitlab_helper.rb
+++ b/app/helpers/learn_gitlab_helper.rb
@@ -10,7 +10,8 @@ module LearnGitlabHelper
def learn_gitlab_data(project)
{
actions: onboarding_actions_data(project).to_json,
- sections: onboarding_sections_data.to_json
+ sections: onboarding_sections_data.to_json,
+ project: onboarding_project_data(project).to_json
}
end
@@ -56,6 +57,10 @@ module LearnGitlabHelper
}
end
+ def onboarding_project_data(project)
+ { name: project.name }
+ end
+
def action_urls
LearnGitlab::Onboarding::ACTION_ISSUE_IDS.transform_values { |id| project_issue_url(learn_gitlab_project, id) }
.merge(LearnGitlab::Onboarding::ACTION_DOC_URLS)
diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb
index f185d6cd002..f16d9f6325b 100644
--- a/app/helpers/markup_helper.rb
+++ b/app/helpers/markup_helper.rb
@@ -181,7 +181,7 @@ module MarkupHelper
wiki: wiki,
repository: wiki.repository,
page_slug: wiki_page.slug,
- issuable_state_filter_enabled: true
+ issuable_reference_expansion_enabled: true
).merge(render_wiki_content_context_container(wiki))
end
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index d5d692f2d6e..abb7128470f 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -182,7 +182,7 @@ module MergeRequestsHelper
project_path: project_path(merge_request.project),
changes_empty_state_illustration: image_path('illustrations/merge_request_changes_empty.svg'),
is_fluid_layout: fluid_layout.to_s,
- dismiss_endpoint: user_callouts_path,
+ dismiss_endpoint: callouts_path,
show_suggest_popover: show_suggest_popover?.to_s,
show_whitespace_default: @show_whitespace_default.to_s,
file_by_file_default: @file_by_file_default.to_s,
diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb
index 106df168080..6acec417a75 100644
--- a/app/helpers/namespaces_helper.rb
+++ b/app/helpers/namespaces_helper.rb
@@ -88,6 +88,13 @@ module NamespacesHelper
group.namespace_settings.public_send(method_name, **args) # rubocop:disable GitlabSecurity/PublicSend
end
+ def namespaces_as_json(selected = :current_user)
+ {
+ group: formatted_namespaces(current_user.manageable_groups_with_routes),
+ user: formatted_namespaces([current_user.namespace])
+ }.to_json
+ end
+
private
# Many importers create a temporary Group, so use the real
@@ -119,6 +126,17 @@ module NamespacesHelper
[group_label.camelize, elements]
end
+
+ def formatted_namespaces(namespaces)
+ namespaces.sort_by(&:human_name).map! do |n|
+ {
+ id: n.id,
+ display_path: n.full_path,
+ human_name: n.human_name,
+ name: n.name
+ }
+ end
+ end
end
NamespacesHelper.prepend_mod_with('NamespacesHelper')
diff --git a/app/helpers/nav/new_dropdown_helper.rb b/app/helpers/nav/new_dropdown_helper.rb
index e7d69c38a54..715a5a02b50 100644
--- a/app/helpers/nav/new_dropdown_helper.rb
+++ b/app/helpers/nav/new_dropdown_helper.rb
@@ -50,7 +50,7 @@ module Nav
menu_items.push(create_epic_menu_item(group))
- if Gitlab::Experimentation.active?(:invite_members_new_dropdown) && can?(current_user, :admin_group_member, group)
+ if can?(current_user, :admin_group_member, group)
menu_items.push(
invite_members_menu_item(
href: group_group_members_path(group)
@@ -101,7 +101,7 @@ module Nav
)
end
- if Gitlab::Experimentation.active?(:invite_members_new_dropdown) && can_admin_project_member?(project)
+ if can_admin_project_member?(project)
menu_items.push(
invite_members_menu_item(
href: project_project_members_path(project)
@@ -161,12 +161,11 @@ module Nav
::Gitlab::Nav::TopNavMenuItem.build(
id: 'invite',
title: s_('InviteMember|Invite members'),
- emoji: ('shaking_hands' if experiment_enabled?(:invite_members_new_dropdown)),
+ emoji: 'shaking_hands',
href: href,
data: {
- track_action: 'click_link',
- track_label: tracking_label,
- track_property: experiment_tracking_category_and_group(:invite_members_new_dropdown)
+ track_action: 'click_link_invite_members',
+ track_label: 'plus_menu_dropdown'
}
)
end
diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb
index 9db28b54fe9..ddaef4652b4 100644
--- a/app/helpers/notifications_helper.rb
+++ b/app/helpers/notifications_helper.rb
@@ -67,7 +67,6 @@ module NotificationsHelper
when :custom
_('You will only receive notifications for the events you choose')
when :owner_disabled
- # Any change must be reflected in board_sidebar_subscription.vue
_('Notifications have been disabled by the project or group owner')
end
end
diff --git a/app/helpers/notify_helper.rb b/app/helpers/notify_helper.rb
index ed96f3cef4f..c0ba93f4a30 100644
--- a/app/helpers/notify_helper.rb
+++ b/app/helpers/notify_helper.rb
@@ -20,21 +20,4 @@ module NotifyHelper
(source.description || default_description).truncate(200, separator: ' ')
end
-
- def invited_join_url(token, member)
- additional_params = { invite_type: Emails::Members::INITIAL_INVITE }
-
- # order important below to our scheduled testing of these
- # `from` experiment will be after the `text` on, but we may not cleanup
- # from the `text` one by the time we run the `from` experiment,
- # therefore we want to support `text` being fully enabled
- # but if `from` is also enabled, then we only care about `from`
- if experiment(:invite_email_from, actor: member).enabled?
- additional_params[:experiment_name] = 'invite_email_from'
- elsif experiment(:invite_email_preview_text, actor: member).enabled?
- additional_params[:experiment_name] = 'invite_email_preview_text'
- end
-
- invite_url(token, additional_params)
- end
end
diff --git a/app/helpers/operations_helper.rb b/app/helpers/operations_helper.rb
index 5d2f225edcf..baeb9a477c3 100644
--- a/app/helpers/operations_helper.rb
+++ b/app/helpers/operations_helper.rb
@@ -16,7 +16,7 @@ module OperationsHelper
{
'prometheus_activated' => prometheus_integration.manual_configuration?.to_s,
- 'prometheus_form_path' => scoped_integration_path(prometheus_integration),
+ 'prometheus_form_path' => scoped_integration_path(prometheus_integration, project: prometheus_integration.project, group: prometheus_integration.group),
'prometheus_reset_key_path' => reset_alerting_token_project_settings_operations_path(@project),
'prometheus_authorization_key' => @project.alerting_setting&.token,
'prometheus_api_url' => prometheus_integration.api_url,
diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb
index c69d9eb1326..66f80e7eeb8 100644
--- a/app/helpers/packages_helper.rb
+++ b/app/helpers/packages_helper.rb
@@ -38,17 +38,6 @@ module PackagesHelper
"#{Gitlab.config.gitlab.host}/#{group_id}"
end
- def packages_list_data(type, resource)
- {
- resource_id: resource.id,
- full_path: resource.full_path,
- page_type: type,
- empty_list_help_url: help_page_path('user/packages/package_registry/index'),
- empty_list_illustration: image_path('illustrations/no-packages.svg'),
- package_help_url: help_page_path('user/packages/index')
- }
- end
-
def track_package_event(event_name, scope, **args)
::Packages::CreateEventService.new(nil, current_user, event_name: event_name, scope: scope).execute
category = args.delete(:category) || self.class.name
diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb
index 09fc1ab9d50..0d514773891 100644
--- a/app/helpers/profiles_helper.rb
+++ b/app/helpers/profiles_helper.rb
@@ -61,6 +61,11 @@ module ProfilesHelper
def ssh_key_expires_field_description
s_('Profiles|Key can still be used after expiration.')
end
+
+ # Overridden in EE::ProfilesHelper#ssh_key_expiration_policy_enabled?
+ def ssh_key_expiration_policy_enabled?
+ false
+ end
end
ProfilesHelper.prepend_mod
diff --git a/app/helpers/projects/cluster_agents_helper.rb b/app/helpers/projects/cluster_agents_helper.rb
index 20fa721cc3b..aeeab250c7a 100644
--- a/app/helpers/projects/cluster_agents_helper.rb
+++ b/app/helpers/projects/cluster_agents_helper.rb
@@ -4,7 +4,8 @@ module Projects::ClusterAgentsHelper
def js_cluster_agent_details_data(agent_name, project)
{
agent_name: agent_name,
- project_path: project.full_path
+ project_path: project.full_path,
+ activity_empty_state_image: image_path('illustrations/empty-state/empty-state-agents.svg')
}
end
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 8366b25d2bc..827d2cb7164 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -120,6 +120,15 @@ module ProjectsHelper
{ project_full_name: project.full_name }
end
+ def remove_fork_project_confirm_json(project, remove_form_id)
+ {
+ remove_form_id: remove_form_id,
+ button_text: _('Remove fork relationship'),
+ confirm_danger_message: remove_fork_project_warning_message(project),
+ phrase: @project.path
+ }
+ end
+
def visible_fork_source(project)
project.fork_source if project.fork_source && can?(current_user, :read_project, project.fork_source)
end
@@ -405,6 +414,16 @@ module ProjectsHelper
project.path_with_namespace
end
+ def fork_button_disabled_tooltip(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')
+ end
+ end
+
private
def tab_ability_map
diff --git a/app/helpers/routing/graphql_helper.rb b/app/helpers/routing/graphql_helper.rb
index beefbb9b387..2e1d084e3cc 100644
--- a/app/helpers/routing/graphql_helper.rb
+++ b/app/helpers/routing/graphql_helper.rb
@@ -9,5 +9,9 @@ module Routing
def graphql_etag_pipeline_sha_path(sha)
[api_graphql_path, "pipelines/sha/#{sha}"].join(':')
end
+
+ def graphql_etag_project_on_demand_scan_counts_path(project)
+ [api_graphql_path, "on_demand_scan/counts/#{project.full_path}"].join(':')
+ end
end
end
diff --git a/app/helpers/routing/pseudonymization_helper.rb b/app/helpers/routing/pseudonymization_helper.rb
index ac30669dc83..fd9907edc37 100644
--- a/app/helpers/routing/pseudonymization_helper.rb
+++ b/app/helpers/routing/pseudonymization_helper.rb
@@ -3,7 +3,10 @@
module Routing
module PseudonymizationHelper
class MaskHelper
- QUERY_PARAMS_TO_NOT_MASK = %w[].freeze
+ QUERY_PARAMS_TO_NOT_MASK = %w[
+ scope
+ state
+ ].freeze
def initialize(request_object, group, project)
@request = request_object
@@ -69,12 +72,10 @@ module Routing
end
end
- def masked_page_url
+ def masked_page_url(group:, project:)
return unless Feature.enabled?(:mask_page_urls, type: :ops)
- current_group = group if defined?(group)
- current_project = project if defined?(project)
- mask_helper = MaskHelper.new(request, current_group, current_project)
+ mask_helper = MaskHelper.new(request, group, project)
mask_helper.mask_params
# We rescue all exception for time being till we test this helper extensively.
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index b28e5ff39b2..fb30e8ca059 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -70,6 +70,15 @@ module SortingHelper
options
end
+ def forks_sort_options_hash
+ {
+ sort_value_recently_created => sort_title_created_date,
+ sort_value_oldest_created => sort_title_created_date,
+ sort_value_latest_activity => sort_title_latest_activity,
+ sort_value_oldest_activity => sort_title_latest_activity
+ }
+ end
+
def projects_sort_option_titles
# Only used for the project filter search bar
projects_sort_options_hash.merge({
@@ -93,6 +102,15 @@ module SortingHelper
}
end
+ def forks_reverse_sort_options_hash
+ {
+ sort_value_recently_created => sort_value_oldest_created,
+ sort_value_oldest_created => sort_value_recently_created,
+ sort_value_latest_activity => sort_value_oldest_activity,
+ sort_value_oldest_activity => sort_value_latest_activity
+ }
+ end
+
def groups_sort_options_hash
{
sort_value_name => sort_title_name,
@@ -303,6 +321,13 @@ module SortingHelper
sort_direction_button(url, reverse_sort, sort_value)
end
+
+ def forks_sort_direction_button(sort_value, without = [:state, :scope, :label_name, :milestone_id, :assignee_id, :author_id])
+ reverse_sort = forks_reverse_sort_options_hash[sort_value]
+ url = page_filter_path(sort: reverse_sort, without: without)
+
+ sort_direction_button(url, reverse_sort, sort_value)
+ end
end
SortingHelper.prepend_mod_with('SortingHelper')
diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb
index 1d8b657025c..f2e1d158c2d 100644
--- a/app/helpers/system_note_helper.rb
+++ b/app/helpers/system_note_helper.rb
@@ -40,7 +40,9 @@ module SystemNoteHelper
'new_alert_added' => 'warning',
'severity' => 'information-o',
'cloned' => 'documents',
- 'issue_type' => 'pencil-square'
+ 'issue_type' => 'pencil-square',
+ 'attention_requested' => 'user',
+ 'attention_request_removed' => 'user'
}.freeze
def system_note_icon_name(note)
diff --git a/app/helpers/tab_helper.rb b/app/helpers/tab_helper.rb
index e53e35baac3..2efc3f27dc7 100644
--- a/app/helpers/tab_helper.rb
+++ b/app/helpers/tab_helper.rb
@@ -14,8 +14,7 @@ module TabHelper
gl_tabs_classes = %w[nav gl-tabs-nav]
html_options = html_options.merge(
- class: [*html_options[:class], gl_tabs_classes].join(' '),
- role: 'tablist'
+ class: [*html_options[:class], gl_tabs_classes].join(' ')
)
content = capture(&block) if block_given?
@@ -54,7 +53,7 @@ module TabHelper
extra_tab_classes = html_options.delete(:tab_class)
tab_class = %w[nav-item].push(*extra_tab_classes)
- content_tag(:li, class: tab_class, role: 'presentation') do
+ content_tag(:li, class: tab_class) do
if block_given?
link_to(options, html_options, &block)
else
@@ -63,6 +62,19 @@ module TabHelper
end
end
+ # Creates a <gl-badge> for use inside tabs.
+ #
+ # html_options - The html_options hash (default: {})
+ def gl_tab_counter_badge(count, html_options = {})
+ gl_badge_tag(
+ count,
+ { size: :sm },
+ html_options.merge(
+ class: ['gl-tab-counter-badge', *html_options[:class]]
+ )
+ )
+ end
+
# Navigation link helper
#
# Returns an `li` element with an 'active' class if the supplied
@@ -150,7 +162,7 @@ module TabHelper
action = options.delete(:action)
route_matches_paths?(options.delete(:path)) ||
- route_matches_pages?(options.delete(:page)) ||
+ route_matches_page_without_exclusion?(options.delete(:page), options.delete(:exclude_page)) ||
route_matches_controllers_and_or_actions?(controller, action)
end
@@ -175,6 +187,13 @@ module TabHelper
end
end
+ def route_matches_page_without_exclusion?(pages, exclude_page)
+ return false unless route_matches_pages?(pages)
+ return true unless exclude_page.present?
+
+ !route_matches_pages?(exclude_page)
+ end
+
def route_matches_pages?(pages)
Array(pages).compact.any? do |single_page|
# We need to distinguish between Hash argument and other types of
@@ -211,12 +230,3 @@ module TabHelper
current_page?(options)
end
end
-
-def gl_tab_counter_badge(count, html_options = {})
- badge_classes = %w[badge badge-muted badge-pill gl-badge sm gl-tab-counter-badge]
- content_tag(:span,
- count,
- class: [*html_options[:class], badge_classes].join(' '),
- data: html_options[:data]
- )
-end
diff --git a/app/helpers/time_zone_helper.rb b/app/helpers/time_zone_helper.rb
index db355f5ff65..d16f13304e5 100644
--- a/app/helpers/time_zone_helper.rb
+++ b/app/helpers/time_zone_helper.rb
@@ -3,6 +3,7 @@
module TimeZoneHelper
TIME_ZONE_FORMAT_ATTRS = {
short: %i[identifier name offset],
+ abbr: %i[identifier abbr],
full: %i[identifier name abbr offset formatted_offset]
}.freeze
private_constant :TIME_ZONE_FORMAT_ATTRS
@@ -32,7 +33,7 @@ module TimeZoneHelper
end
end
- def local_time_instance(timezone)
+ def local_timezone_instance(timezone)
return Time.zone if timezone.blank?
ActiveSupport::TimeZone.new(timezone) || Time.zone
@@ -41,7 +42,7 @@ module TimeZoneHelper
def local_time(timezone)
return if timezone.blank?
- time_zone_instance = local_time_instance(timezone)
+ time_zone_instance = local_timezone_instance(timezone)
time_zone_instance.now.strftime("%-l:%M %p")
end
end
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index d1f33f99ad0..d089b540282 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -81,7 +81,7 @@ module TreeHelper
end
def commit_in_fork_help
- _("A new branch will be created in your fork and a new merge request will be started.")
+ _("GitLab will create a branch in your fork and start a merge request.")
end
def commit_in_single_accessible_branch
diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb
deleted file mode 100644
index d8e69145c40..00000000000
--- a/app/helpers/user_callouts_helper.rb
+++ /dev/null
@@ -1,98 +0,0 @@
-# frozen_string_literal: true
-
-module UserCalloutsHelper
- GKE_CLUSTER_INTEGRATION = 'gke_cluster_integration'
- GCP_SIGNUP_OFFER = 'gcp_signup_offer'
- SUGGEST_POPOVER_DISMISSED = 'suggest_popover_dismissed'
- TABS_POSITION_HIGHLIGHT = 'tabs_position_highlight'
- CUSTOMIZE_HOMEPAGE = 'customize_homepage'
- FEATURE_FLAGS_NEW_VERSION = 'feature_flags_new_version'
- REGISTRATION_ENABLED_CALLOUT = 'registration_enabled_callout'
- UNFINISHED_TAG_CLEANUP_CALLOUT = 'unfinished_tag_cleanup_callout'
- INVITE_MEMBERS_BANNER = 'invite_members_banner'
- SECURITY_NEWSLETTER_CALLOUT = 'security_newsletter_callout'
-
- def show_gke_cluster_integration_callout?(project)
- active_nav_link?(controller: sidebar_operations_paths) &&
- can?(current_user, :create_cluster, project) &&
- !user_dismissed?(GKE_CLUSTER_INTEGRATION)
- end
-
- def show_gcp_signup_offer?
- !user_dismissed?(GCP_SIGNUP_OFFER)
- end
-
- def render_flash_user_callout(flash_type, message, feature_name)
- render 'shared/flash_user_callout', flash_type: flash_type, message: message, feature_name: feature_name
- end
-
- def render_dashboard_ultimate_trial(user)
- end
-
- def render_two_factor_auth_recovery_settings_check
- end
-
- def show_suggest_popover?
- !user_dismissed?(SUGGEST_POPOVER_DISMISSED)
- end
-
- def show_customize_homepage_banner?
- current_user.default_dashboard? && !user_dismissed?(CUSTOMIZE_HOMEPAGE)
- end
-
- def show_feature_flags_new_version?
- !user_dismissed?(FEATURE_FLAGS_NEW_VERSION)
- end
-
- def show_unfinished_tag_cleanup_callout?
- !user_dismissed?(UNFINISHED_TAG_CLEANUP_CALLOUT)
- end
-
- def show_registration_enabled_user_callout?
- !Gitlab.com? &&
- current_user&.admin? &&
- signup_enabled? &&
- !user_dismissed?(REGISTRATION_ENABLED_CALLOUT)
- end
-
- def dismiss_two_factor_auth_recovery_settings_check
- end
-
- def show_invite_banner?(group)
- Ability.allowed?(current_user, :admin_group, group) &&
- !just_created? &&
- !user_dismissed_for_group(INVITE_MEMBERS_BANNER, group) &&
- !multiple_members?(group)
- end
-
- def show_security_newsletter_user_callout?
- current_user&.admin? &&
- !user_dismissed?(SECURITY_NEWSLETTER_CALLOUT)
- end
-
- private
-
- def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil)
- return false unless current_user
-
- current_user.dismissed_callout?(feature_name: feature_name, ignore_dismissal_earlier_than: ignore_dismissal_earlier_than)
- end
-
- def user_dismissed_for_group(feature_name, group, ignore_dismissal_earlier_than = nil)
- return false unless current_user
-
- current_user.dismissed_callout_for_group?(feature_name: feature_name,
- group: group,
- ignore_dismissal_earlier_than: ignore_dismissal_earlier_than)
- end
-
- def just_created?
- flash[:notice]&.include?('successfully created')
- end
-
- def multiple_members?(group)
- group.member_count > 1 || group.members_with_parents.count > 1
- end
-end
-
-UserCalloutsHelper.prepend_mod
diff --git a/app/helpers/users/callouts_helper.rb b/app/helpers/users/callouts_helper.rb
new file mode 100644
index 00000000000..32b0d7b3fe3
--- /dev/null
+++ b/app/helpers/users/callouts_helper.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+module Users
+ module CalloutsHelper
+ GKE_CLUSTER_INTEGRATION = 'gke_cluster_integration'
+ GCP_SIGNUP_OFFER = 'gcp_signup_offer'
+ SUGGEST_POPOVER_DISMISSED = 'suggest_popover_dismissed'
+ TABS_POSITION_HIGHLIGHT = 'tabs_position_highlight'
+ FEATURE_FLAGS_NEW_VERSION = 'feature_flags_new_version'
+ REGISTRATION_ENABLED_CALLOUT = 'registration_enabled_callout'
+ UNFINISHED_TAG_CLEANUP_CALLOUT = 'unfinished_tag_cleanup_callout'
+ SECURITY_NEWSLETTER_CALLOUT = 'security_newsletter_callout'
+
+ def show_gke_cluster_integration_callout?(project)
+ active_nav_link?(controller: sidebar_operations_paths) &&
+ can?(current_user, :create_cluster, project) &&
+ !user_dismissed?(GKE_CLUSTER_INTEGRATION)
+ end
+
+ def show_gcp_signup_offer?
+ !user_dismissed?(GCP_SIGNUP_OFFER)
+ end
+
+ def render_flash_user_callout(flash_type, message, feature_name)
+ render 'shared/flash_user_callout', flash_type: flash_type, message: message, feature_name: feature_name
+ end
+
+ def render_dashboard_ultimate_trial(user)
+ end
+
+ def render_two_factor_auth_recovery_settings_check
+ end
+
+ def show_suggest_popover?
+ !user_dismissed?(SUGGEST_POPOVER_DISMISSED)
+ end
+
+ def show_feature_flags_new_version?
+ !user_dismissed?(FEATURE_FLAGS_NEW_VERSION)
+ end
+
+ def show_unfinished_tag_cleanup_callout?
+ !user_dismissed?(UNFINISHED_TAG_CLEANUP_CALLOUT)
+ end
+
+ def show_registration_enabled_user_callout?
+ !Gitlab.com? &&
+ current_user&.admin? &&
+ signup_enabled? &&
+ !user_dismissed?(REGISTRATION_ENABLED_CALLOUT)
+ end
+
+ def dismiss_two_factor_auth_recovery_settings_check
+ end
+
+ def show_security_newsletter_user_callout?
+ current_user&.admin? &&
+ !user_dismissed?(SECURITY_NEWSLETTER_CALLOUT)
+ end
+
+ private
+
+ def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil)
+ return false unless current_user
+
+ current_user.dismissed_callout?(feature_name: feature_name, ignore_dismissal_earlier_than: ignore_dismissal_earlier_than)
+ end
+ end
+end
+
+Users::CalloutsHelper.prepend_mod
diff --git a/app/helpers/users/group_callouts_helper.rb b/app/helpers/users/group_callouts_helper.rb
new file mode 100644
index 00000000000..b66c7f9f821
--- /dev/null
+++ b/app/helpers/users/group_callouts_helper.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Users
+ module GroupCalloutsHelper
+ INVITE_MEMBERS_BANNER = 'invite_members_banner'
+
+ def show_invite_banner?(group)
+ Ability.allowed?(current_user, :admin_group, group) &&
+ !just_created? &&
+ !user_dismissed_for_group(INVITE_MEMBERS_BANNER, group) &&
+ !multiple_members?(group)
+ end
+
+ private
+
+ def user_dismissed_for_group(feature_name, group, ignore_dismissal_earlier_than = nil)
+ return false unless current_user
+
+ current_user.dismissed_callout_for_group?(feature_name: feature_name,
+ group: group,
+ ignore_dismissal_earlier_than: ignore_dismissal_earlier_than)
+ end
+
+ def just_created?
+ flash[:notice]&.include?('successfully created')
+ end
+
+ def multiple_members?(group)
+ group.member_count > 1 || group.members_with_parents.count > 1
+ end
+ end
+end
diff --git a/app/helpers/version_check_helper.rb b/app/helpers/version_check_helper.rb
index f8d7264d4cc..7875b9e4a28 100644
--- a/app/helpers/version_check_helper.rb
+++ b/app/helpers/version_check_helper.rb
@@ -6,7 +6,7 @@ module VersionCheckHelper
return unless Gitlab::CurrentSettings.version_check_enabled
return if User.single_user&.requires_usage_stats_consent?
- image_tag VersionCheck.url, class: 'js-version-status-badge'
+ image_tag VersionCheck.image_url, class: 'js-version-status-badge'
end
def link_to_version
diff --git a/app/helpers/x509_helper.rb b/app/helpers/x509_helper.rb
index 4afc5643af4..1a9dbefceef 100644
--- a/app/helpers/x509_helper.rb
+++ b/app/helpers/x509_helper.rb
@@ -18,6 +18,6 @@ module X509Helper
end
def x509_signature?(sig)
- sig.is_a?(X509CommitSignature) || sig.is_a?(Gitlab::X509::Signature)
+ sig.is_a?(CommitSignatures::X509CommitSignature) || sig.is_a?(Gitlab::X509::Signature)
end
end
diff --git a/app/mailers/emails/in_product_marketing.rb b/app/mailers/emails/in_product_marketing.rb
index e745cd51a55..317e1545350 100644
--- a/app/mailers/emails/in_product_marketing.rb
+++ b/app/mailers/emails/in_product_marketing.rb
@@ -29,7 +29,7 @@ module Emails
format.html do
@message.format = :html
- render layout: nil
+ render layout: 'in_product_marketing_mailer'
end
format.text do
@@ -41,3 +41,5 @@ module Emails
end
end
end
+
+Emails::InProductMarketing.prepend_mod
diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb
index 51c4779d8cf..bbc4be3b324 100644
--- a/app/mailers/emails/issues.rb
+++ b/app/mailers/emails/issues.rb
@@ -5,18 +5,18 @@ module Emails
def new_issue_email(recipient_id, issue_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
- mail_new_thread(@issue, issue_thread_options(@issue.author_id, recipient_id, reason))
+ mail_new_thread(@issue, issue_thread_options(@issue.author_id, reason))
end
def issue_due_email(recipient_id, issue_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
- mail_answer_thread(@issue, issue_thread_options(@issue.author_id, recipient_id, reason))
+ mail_answer_thread(@issue, issue_thread_options(@issue.author_id, reason))
end
def new_mention_in_issue_email(recipient_id, issue_id, updated_by_user_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
- mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason))
+ mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, reason))
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -26,7 +26,7 @@ module Emails
@previous_assignees = []
@previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any?
- mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason))
+ mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, reason))
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -34,9 +34,8 @@ module Emails
setup_issue_mail(issue_id, recipient_id, closed_via: closed_via)
@updated_by = User.find(updated_by_user_id)
- @recipient = User.find(recipient_id)
- mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason))
+ mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, reason))
end
def relabeled_issue_email(recipient_id, issue_id, label_names, updated_by_user_id, reason = nil)
@@ -44,13 +43,13 @@ module Emails
@label_names = label_names
@labels_url = project_labels_url(@project)
- mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason))
+ mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, reason))
end
def removed_milestone_issue_email(recipient_id, issue_id, updated_by_user_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
- mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason))
+ mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, reason))
end
def changed_milestone_issue_email(recipient_id, issue_id, milestone, updated_by_user_id, reason = nil)
@@ -58,7 +57,7 @@ module Emails
@milestone = milestone
@milestone_url = milestone_url(@milestone)
- mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason).merge({
+ mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, reason).merge({
template_name: 'changed_milestone_email'
}))
end
@@ -68,7 +67,7 @@ module Emails
@issue_status = status
@updated_by = User.find(updated_by_user_id)
- mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason))
+ mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, reason))
end
def issue_moved_email(recipient, issue, new_issue, updated_by_user, reason = nil)
@@ -77,7 +76,7 @@ module Emails
@new_issue = new_issue
@new_project = new_issue.project
@can_access_project = recipient.can?(:read_project, @new_project)
- mail_answer_thread(issue, issue_thread_options(updated_by_user.id, recipient.id, reason))
+ mail_answer_thread(issue, issue_thread_options(updated_by_user.id, reason))
end
def issue_cloned_email(recipient, issue, new_issue, updated_by_user, reason = nil)
@@ -87,7 +86,7 @@ module Emails
@issue = issue
@new_issue = new_issue
@can_access_project = recipient.can?(:read_project, @new_issue.project)
- mail_answer_thread(issue, issue_thread_options(updated_by_user.id, recipient.id, reason))
+ mail_answer_thread(issue, issue_thread_options(updated_by_user.id, reason))
end
def import_issues_csv_email(user_id, project_id, results)
@@ -124,14 +123,15 @@ module Emails
@project = @issue.project
@target_url = project_issue_url(@project, @issue)
@closed_via = closed_via
+ @recipient = User.find(recipient_id)
@sent_notification = SentNotification.record(@issue, recipient_id, reply_key)
end
- def issue_thread_options(sender_id, recipient_id, reason)
+ def issue_thread_options(sender_id, reason)
{
from: sender(sender_id),
- to: User.find(recipient_id).notification_email_for(@project.group),
+ to: @recipient.notification_email_for(@project.group),
subject: subject("#{@issue.title} (##{@issue.iid})"),
'X-GitLab-NotificationReason' => reason
}
diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb
index 8a9ed557cc6..ef2220751bf 100644
--- a/app/mailers/emails/members.rb
+++ b/app/mailers/emails/members.rb
@@ -61,7 +61,7 @@ module Emails
Gitlab::Tracking.event(self.class.name, 'invite_email_sent', label: 'invite_email', property: member_id.to_s)
- mail(to: member.invite_email, subject: invite_email_subject, **invite_email_headers.merge(additional_invite_settings)) do |format|
+ mail(to: member.invite_email, subject: invite_email_subject, **invite_email_headers) do |format|
format.html { render layout: 'unknown_user_mailer' }
format.text { render layout: 'unknown_user_mailer' }
end
@@ -151,17 +151,7 @@ module Emails
def invite_email_subject
if member.created_by
- experiment(:invite_email_from, actor: member) do |experiment_instance|
- experiment_instance.use do
- subject(s_("MemberInviteEmail|%{member_name} invited you to join GitLab") % { member_name: member.created_by.name })
- end
-
- experiment_instance.candidate do
- subject(s_("MemberInviteEmail|I've invited you to join me in GitLab"))
- end
-
- experiment_instance.run
- end
+ subject(s_("MemberInviteEmail|%{member_name} invited you to join GitLab") % { member_name: member.created_by.name })
else
subject(s_("MemberInviteEmail|Invitation to join the %{project_or_group} %{project_or_group_name}") % { project_or_group: member_source.human_name, project_or_group_name: member_source.model_name.singular })
end
@@ -178,21 +168,6 @@ module Emails
end
end
- def additional_invite_settings
- return {} unless member.created_by
-
- experiment(:invite_email_from, actor: member) do |experiment_instance|
- experiment_instance.use { {} }
- experiment_instance.candidate do
- {
- from: "#{member.created_by.name} <#{member.created_by.email}>"
- }
- end
-
- experiment_instance.run
- end
- end
-
def member_exists?
Gitlab::AppLogger.info("Tried to send an email invitation for a deleted group. Member id: #{@member_id}") if member.blank?
member.present?
diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb
index 2746b8b7188..d2e710cc329 100644
--- a/app/mailers/emails/merge_requests.rb
+++ b/app/mailers/emails/merge_requests.rb
@@ -5,13 +5,13 @@ module Emails
def new_merge_request_email(recipient_id, merge_request_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id, present: true)
- mail_new_thread(@merge_request, merge_request_thread_options(@merge_request.author_id, recipient_id, reason))
+ mail_new_thread(@merge_request, merge_request_thread_options(@merge_request.author_id, reason))
end
def new_mention_in_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id, present: true)
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason))
end
def push_to_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil, new_commits: [], existing_commits: [])
@@ -20,7 +20,7 @@ module Emails
@existing_commits = existing_commits
@updated_by_user = User.find(updated_by_user_id)
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason))
end
def change_in_merge_request_draft_status_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil)
@@ -28,7 +28,7 @@ module Emails
@updated_by_user = User.find(updated_by_user_id)
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason))
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -38,7 +38,7 @@ module Emails
@previous_assignees = []
@previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any?
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason))
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -49,7 +49,7 @@ module Emails
@previous_reviewers = []
@previous_reviewers = User.where(id: previous_reviewer_ids) if previous_reviewer_ids.any?
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason))
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -58,13 +58,13 @@ module Emails
@label_names = label_names
@labels_url = project_labels_url(@project)
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason))
end
def removed_milestone_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason))
end
def changed_milestone_merge_request_email(recipient_id, merge_request_id, milestone, updated_by_user_id, reason = nil)
@@ -72,7 +72,7 @@ module Emails
@milestone = milestone
@milestone_url = milestone_url(@milestone)
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason).merge({
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason).merge({
template_name: 'changed_milestone_email'
}))
end
@@ -81,20 +81,27 @@ module Emails
setup_merge_request_mail(merge_request_id, recipient_id)
@updated_by = User.find(updated_by_user_id)
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason))
end
def merged_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason: nil, closed_via: nil)
setup_merge_request_mail(merge_request_id, recipient_id)
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason))
end
def request_review_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
@updated_by = User.find(updated_by_user_id)
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason))
+ end
+
+ def attention_requested_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil)
+ setup_merge_request_mail(merge_request_id, recipient_id)
+
+ @updated_by = User.find(updated_by_user_id)
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason))
end
def merge_request_status_email(recipient_id, merge_request_id, status, updated_by_user_id, reason = nil)
@@ -102,27 +109,27 @@ module Emails
@mr_status = status
@updated_by = User.find(updated_by_user_id)
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason))
end
def merge_request_unmergeable_email(recipient_id, merge_request_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
- mail_answer_thread(@merge_request, merge_request_thread_options(@merge_request.author_id, recipient_id, reason))
+ mail_answer_thread(@merge_request, merge_request_thread_options(@merge_request.author_id, reason))
end
def resolved_all_discussions_email(recipient_id, merge_request_id, resolved_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
@resolved_by = User.find(resolved_by_user_id)
- mail_answer_thread(@merge_request, merge_request_thread_options(resolved_by_user_id, recipient_id, reason))
+ mail_answer_thread(@merge_request, merge_request_thread_options(resolved_by_user_id, reason))
end
def merge_when_pipeline_succeeds_email(recipient_id, merge_request_id, mwps_set_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
@mwps_set_by = ::User.find(mwps_set_by_user_id)
- mail_answer_thread(@merge_request, merge_request_thread_options(mwps_set_by_user_id, recipient_id, reason))
+ mail_answer_thread(@merge_request, merge_request_thread_options(mwps_set_by_user_id, reason))
end
def merge_requests_csv_email(user, project, csv_data, export_status)
@@ -147,19 +154,19 @@ module Emails
@merge_request = MergeRequest.find(merge_request_id)
@project = @merge_request.project
@target_url = project_merge_request_url(@project, @merge_request)
+ @recipient = User.find(recipient_id)
if present
- recipient = User.find(recipient_id)
- @mr_presenter = @merge_request.present(current_user: recipient)
+ @mr_presenter = @merge_request.present(current_user: @recipient)
end
@sent_notification = SentNotification.record(@merge_request, recipient_id, reply_key)
end
- def merge_request_thread_options(sender_id, recipient_id, reason = nil)
+ def merge_request_thread_options(sender_id, reason = nil)
{
from: sender(sender_id),
- to: User.find(recipient_id).notification_email_for(@project.group),
+ to: @recipient.notification_email_for(@project.group),
subject: subject("#{@merge_request.title} (#{@merge_request.to_reference})"),
'X-GitLab-NotificationReason' => reason
}
diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb
index 587c1479286..1e254a32885 100644
--- a/app/mailers/emails/notes.rb
+++ b/app/mailers/emails/notes.rb
@@ -7,7 +7,7 @@ module Emails
@commit = @note.noteable
@target_url = project_commit_url(*note_target_url_options)
- mail_answer_note_thread(@commit, @note, note_thread_options(recipient_id, reason))
+ mail_answer_note_thread(@commit, @note, note_thread_options(reason))
end
def note_issue_email(recipient_id, note_id, reason = nil)
@@ -15,7 +15,7 @@ module Emails
@issue = @note.noteable
@target_url = project_issue_url(*note_target_url_options)
- mail_answer_note_thread(@issue, @note, note_thread_options(recipient_id, reason))
+ mail_answer_note_thread(@issue, @note, note_thread_options(reason))
end
def note_merge_request_email(recipient_id, note_id, reason = nil)
@@ -23,7 +23,7 @@ module Emails
@merge_request = @note.noteable
@target_url = project_merge_request_url(*note_target_url_options)
- mail_answer_note_thread(@merge_request, @note, note_thread_options(recipient_id, reason))
+ mail_answer_note_thread(@merge_request, @note, note_thread_options(reason))
end
def note_snippet_email(recipient_id, note_id, reason = nil)
@@ -37,7 +37,7 @@ module Emails
@target_url = gitlab_snippet_url(@note.noteable)
end
- mail_answer_note_thread(@snippet, @note, note_thread_options(recipient_id, reason))
+ mail_answer_note_thread(@snippet, @note, note_thread_options(reason))
end
def note_design_email(recipient_id, note_id, reason = nil)
@@ -49,7 +49,7 @@ module Emails
design.issue,
note_target_url_query_params.merge(vueroute: design.filename)
)
- mail_answer_note_thread(design, @note, note_thread_options(recipient_id, reason))
+ mail_answer_note_thread(design, @note, note_thread_options(reason))
end
private
@@ -62,10 +62,10 @@ module Emails
{ anchor: "note_#{@note.id}" }
end
- def note_thread_options(recipient_id, reason)
+ def note_thread_options(reason)
{
from: sender(@note.author_id),
- to: User.find(recipient_id).notification_email_for(@project&.group || @group),
+ to: @recipient.notification_email_for(@project&.group || @group),
subject: subject("#{@note.noteable.title} (#{@note.noteable.reference_link_text})"),
'X-GitLab-NotificationReason' => reason
}
@@ -76,6 +76,7 @@ module Emails
@note = note_id.is_a?(Note) ? note_id : Note.find(note_id)
@project = @note.project
@group = @note.noteable.try(:group)
+ @recipient = User.find(recipient_id)
if (@project || @group) && @note.persisted?
@sent_notification = SentNotification.record_note(@note, recipient_id, reply_key)
diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb
index 06ba16f9724..14c724b5b91 100644
--- a/app/mailers/emails/projects.rb
+++ b/app/mailers/emails/projects.rb
@@ -59,6 +59,7 @@ module Emails
def prometheus_alert_fired_email(project, user, alert)
@project = project
@alert = alert.present
+ @incident = alert.issue
add_project_headers
add_alert_headers
@@ -80,11 +81,10 @@ module Emails
end
def add_incident_headers
- incident = @alert.issue
- return unless incident
+ return unless @incident
- headers['X-GitLab-Incident-ID'] = incident.id
- headers['X-GitLab-Incident-IID'] = incident.iid
+ headers['X-GitLab-Incident-ID'] = @incident.id
+ headers['X-GitLab-Incident-IID'] = @incident.iid
end
end
end
diff --git a/app/mailers/emails/releases.rb b/app/mailers/emails/releases.rb
index c9c77ab9333..4875abafe8d 100644
--- a/app/mailers/emails/releases.rb
+++ b/app/mailers/emails/releases.rb
@@ -9,11 +9,10 @@ module Emails
namespace_id: @project.namespace,
project_id: @project
)
-
- user = User.find(user_id)
+ @recipient = User.find(user_id)
mail(
- to: user.notification_email_for(@project.group),
+ to: @recipient.notification_email_for(@project.group),
subject: subject(release_email_subject)
)
end
diff --git a/app/models/active_session.rb b/app/models/active_session.rb
index a0e74c7f48e..0094d98fb73 100644
--- a/app/models/active_session.rb
+++ b/app/models/active_session.rb
@@ -4,7 +4,7 @@
#
# The raw session information is stored by the Rails session store
# (config/initializers/session_store.rb). These entries are accessible by the
-# rack_key_name class method and consistute the base of the session data
+# rack_key_name class method and constitute the base of the session data
# entries. All other entries in the session store can be traced back to these
# entries.
#
@@ -21,14 +21,24 @@
#
class ActiveSession
include ActiveModel::Model
+ include ::Gitlab::Redis::SessionsStoreHelper
SESSION_BATCH_SIZE = 200
ALLOWED_NUMBER_OF_ACTIVE_SESSIONS = 100
- attr_accessor :created_at, :updated_at,
- :ip_address, :browser, :os,
- :device_name, :device_type,
- :is_impersonated, :session_id, :session_private_id
+ attr_accessor :ip_address, :browser, :os,
+ :device_name, :device_type,
+ :is_impersonated, :session_id, :session_private_id
+
+ attr_reader :created_at, :updated_at
+
+ def created_at=(time)
+ @created_at = time.is_a?(String) ? Time.zone.parse(time) : time
+ end
+
+ def updated_at=(time)
+ @updated_at = time.is_a?(String) ? Time.zone.parse(time) : time
+ end
def current?(rack_session)
return false if session_private_id.nil? || rack_session.id.nil?
@@ -38,15 +48,29 @@ class ActiveSession
session_private_id == rack_session.id.private_id
end
+ def eql?(other)
+ other.is_a?(self.class) && id == other.id
+ end
+ alias_method :==, :eql?
+
+ def id
+ session_private_id.presence || session_id
+ end
+
+ def ids
+ [session_private_id, session_id].compact
+ end
+
def human_device_type
device_type&.titleize
end
def self.set(user, request)
- Gitlab::Redis::SharedState.with do |redis|
+ redis_store_class.with do |redis|
session_private_id = request.session.id.private_id
client = DeviceDetector.new(request.user_agent)
timestamp = Time.current
+ expiry = Settings.gitlab['session_expire_delay'] * 60
active_user_session = new(
ip_address: request.remote_ip,
@@ -63,7 +87,14 @@ class ActiveSession
redis.pipelined do
redis.setex(
key_name(user.id, session_private_id),
- Settings.gitlab['session_expire_delay'] * 60,
+ expiry,
+ active_user_session.dump
+ )
+
+ # Deprecated legacy format - temporary to support mixed deployments
+ redis.setex(
+ key_name_v1(user.id, session_private_id),
+ expiry,
Marshal.dump(active_user_session)
)
@@ -76,7 +107,7 @@ class ActiveSession
end
def self.list(user)
- Gitlab::Redis::SharedState.with do |redis|
+ redis_store_class.with do |redis|
cleaned_up_lookup_entries(redis, user).map do |raw_session|
load_raw_session(raw_session)
end
@@ -84,14 +115,17 @@ class ActiveSession
end
def self.cleanup(user)
- Gitlab::Redis::SharedState.with do |redis|
+ redis_store_class.with do |redis|
clean_up_old_sessions(redis, user)
cleaned_up_lookup_entries(redis, user)
end
end
def self.destroy_sessions(redis, user, session_ids)
+ return if session_ids.empty?
+
key_names = session_ids.map { |session_id| key_name(user.id, session_id) }
+ key_names += session_ids.map { |session_id| key_name_v1(user.id, session_id) }
redis.srem(lookup_key_name(user.id), session_ids)
@@ -104,7 +138,7 @@ class ActiveSession
def self.destroy_session(user, session_id)
return unless session_id
- Gitlab::Redis::SharedState.with do |redis|
+ redis_store_class.with do |redis|
destroy_sessions(redis, user, [session_id].compact)
end
end
@@ -113,26 +147,31 @@ class ActiveSession
sessions = not_impersonated(user)
sessions.reject! { |session| session.current?(current_rack_session) } if current_rack_session
- Gitlab::Redis::SharedState.with do |redis|
- session_ids = (sessions.map(&:session_id) | sessions.map(&:session_private_id)).compact
+ redis_store_class.with do |redis|
+ session_ids = sessions.flat_map(&:ids)
destroy_sessions(redis, user, session_ids) if session_ids.any?
end
end
- def self.not_impersonated(user)
+ private_class_method def self.not_impersonated(user)
list(user).reject(&:is_impersonated)
end
- def self.rack_key_name(session_id)
- "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}"
+ private_class_method def self.rack_key_name(session_id)
+ "#{Gitlab::Redis::Sessions::SESSION_NAMESPACE}:#{session_id}"
end
def self.key_name(user_id, session_id = '*')
- "#{Gitlab::Redis::SharedState::USER_SESSIONS_NAMESPACE}:#{user_id}:#{session_id}"
+ "#{Gitlab::Redis::Sessions::USER_SESSIONS_NAMESPACE}::v2:#{user_id}:#{session_id}"
+ end
+
+ # Deprecated
+ def self.key_name_v1(user_id, session_id = '*')
+ "#{Gitlab::Redis::Sessions::USER_SESSIONS_NAMESPACE}:#{user_id}:#{session_id}"
end
def self.lookup_key_name(user_id)
- "#{Gitlab::Redis::SharedState::USER_SESSIONS_LOOKUP_NAMESPACE}:#{user_id}"
+ "#{Gitlab::Redis::Sessions::USER_SESSIONS_LOOKUP_NAMESPACE}:#{user_id}"
end
def self.list_sessions(user)
@@ -143,7 +182,7 @@ class ActiveSession
#
# Returns an array of strings
def self.session_ids_for_user(user_id)
- Gitlab::Redis::SharedState.with do |redis|
+ redis_store_class.with do |redis|
redis.smembers(lookup_key_name(user_id))
end
end
@@ -156,7 +195,7 @@ class ActiveSession
def self.sessions_from_ids(session_ids)
return [] if session_ids.empty?
- Gitlab::Redis::SharedState.with do |redis|
+ redis_store_class.with do |redis|
session_keys = rack_session_keys(session_ids)
session_keys.each_slice(SESSION_BATCH_SIZE).flat_map do |session_keys_batch|
@@ -169,71 +208,102 @@ class ActiveSession
end
end
- # Deserializes a session Hash object from Redis.
- #
+ def dump
+ "v2:#{Gitlab::Json.dump(self)}"
+ end
+
+ # Private:
+
# raw_session - Raw bytes from Redis
#
- # Returns an ActiveSession object
- def self.load_raw_session(raw_session)
- # rubocop:disable Security/MarshalLoad
- Marshal.load(raw_session)
- # rubocop:enable Security/MarshalLoad
+ # Returns an instance of this class
+ private_class_method def self.load_raw_session(raw_session)
+ return unless raw_session
+
+ if raw_session.start_with?('v2:')
+ session_data = Gitlab::Json.parse(raw_session[3..]).symbolize_keys
+ new(**session_data)
+ else
+ # Deprecated legacy format. To be removed in 15.0
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/30516
+ # Explanation of why this Marshal.load call is OK:
+ # https://gitlab.com/gitlab-com/gl-security/appsec/appsec-reviews/-/issues/124#note_744576714
+ # rubocop:disable Security/MarshalLoad
+ Marshal.load(raw_session)
+ # rubocop:enable Security/MarshalLoad
+ end
end
- def self.rack_session_keys(rack_session_ids)
- rack_session_ids.map { |session_id| rack_key_name(session_id)}
+ private_class_method def self.rack_session_keys(rack_session_ids)
+ rack_session_ids.map { |session_id| rack_key_name(session_id) }
end
- def self.raw_active_session_entries(redis, session_ids, user_id)
- return [] if session_ids.empty?
+ private_class_method def self.raw_active_session_entries(redis, session_ids, user_id)
+ return {} if session_ids.empty?
+
+ found = Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
+ entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) }
+ session_ids.zip(redis.mget(entry_keys)).to_h
+ end
- entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) }
+ found.compact!
+ missing = session_ids - found.keys
+ return found if missing.empty?
- Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
- redis.mget(entry_keys)
+ fallbacks = Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
+ entry_keys = missing.map { |session_id| key_name_v1(user_id, session_id) }
+ missing.zip(redis.mget(entry_keys)).to_h
end
+
+ fallbacks.merge(found.compact)
end
- def self.active_session_entries(session_ids, user_id, redis)
+ private_class_method def self.active_session_entries(session_ids, user_id, redis)
return [] if session_ids.empty?
- entry_keys = raw_active_session_entries(redis, session_ids, user_id)
-
- entry_keys.compact.map do |raw_session|
- load_raw_session(raw_session)
- end
+ raw_active_session_entries(redis, session_ids, user_id)
+ .values
+ .compact
+ .map { load_raw_session(_1) }
end
- def self.clean_up_old_sessions(redis, user)
+ private_class_method def self.clean_up_old_sessions(redis, user)
session_ids = session_ids_for_user(user.id)
return if session_ids.count <= ALLOWED_NUMBER_OF_ACTIVE_SESSIONS
- # remove sessions if there are more than ALLOWED_NUMBER_OF_ACTIVE_SESSIONS.
sessions = active_session_entries(session_ids, user.id, redis)
- sessions.sort_by! {|session| session.updated_at }.reverse!
- destroyable_sessions = sessions.drop(ALLOWED_NUMBER_OF_ACTIVE_SESSIONS)
- destroyable_session_ids = destroyable_sessions.flat_map { |session| [session.session_id, session.session_private_id] }.compact
- destroy_sessions(redis, user, destroyable_session_ids) if destroyable_session_ids.any?
+ sessions.sort_by!(&:updated_at).reverse!
+
+ # remove sessions if there are more than ALLOWED_NUMBER_OF_ACTIVE_SESSIONS.
+ destroyable_session_ids = sessions
+ .drop(ALLOWED_NUMBER_OF_ACTIVE_SESSIONS)
+ .flat_map(&:ids)
+
+ destroy_sessions(redis, user, destroyable_session_ids)
end
# Cleans up the lookup set by removing any session IDs that are no longer present.
#
# Returns an array of marshalled ActiveModel objects that are still active.
- def self.cleaned_up_lookup_entries(redis, user)
+ # Records removed keys in the optional `removed` argument array.
+ def self.cleaned_up_lookup_entries(redis, user, removed = [])
+ lookup_key = lookup_key_name(user.id)
session_ids = session_ids_for_user(user.id)
- entries = raw_active_session_entries(redis, session_ids, user.id)
+ session_ids_and_entries = raw_active_session_entries(redis, session_ids, user.id)
# remove expired keys.
# only the single key entries are automatically expired by redis, the
# lookup entries in the set need to be removed manually.
- session_ids_and_entries = session_ids.zip(entries)
- redis.pipelined do
- session_ids_and_entries.reject { |_session_id, entry| entry }.each do |session_id, _entry|
- redis.srem(lookup_key_name(user.id), session_id)
+ redis.pipelined do |pipeline|
+ session_ids_and_entries.each do |session_id, entry|
+ next if entry
+
+ pipeline.srem(lookup_key, session_id)
+ removed << session_id
end
end
- entries.compact
+ session_ids_and_entries.values.compact
end
end
diff --git a/app/models/analytics/cycle_analytics/project_stage.rb b/app/models/analytics/cycle_analytics/project_stage.rb
index e8b03fa066a..8d3a032812e 100644
--- a/app/models/analytics/cycle_analytics/project_stage.rb
+++ b/app/models/analytics/cycle_analytics/project_stage.rb
@@ -26,6 +26,12 @@ module Analytics
:project_id
end
+ def self.distinct_stages_within_hierarchy(group)
+ with_preloaded_labels
+ .where(project_id: group.all_projects.select(:id))
+ .select("DISTINCT ON(stage_event_hash_id) #{quoted_table_name}.*")
+ end
+
private
# Project should belong to a group when the stage has Label based events since only GroupLabels are allowed.
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
index bcd8bdd6638..b64e6c59817 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -7,6 +7,10 @@ class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
+ # We should avoid using pluck https://docs.gitlab.com/ee/development/sql.html#plucking-ids
+ # but, if we are going to use it, let's try and limit the number of records
+ MAX_PLUCK = 1_000
+
alias_method :reset, :reload
def self.without_order
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index af5796d682f..65472615f42 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -21,7 +21,7 @@ class ApplicationSetting < ApplicationRecord
add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption) ? :optional : :required }
add_authentication_token_field :health_check_access_token
- add_authentication_token_field :static_objects_external_storage_auth_token
+ add_authentication_token_field :static_objects_external_storage_auth_token, encrypted: :optional
belongs_to :self_monitoring_project, class_name: "Project", foreign_key: 'instance_administration_project_id'
belongs_to :push_rule
@@ -144,10 +144,6 @@ class ApplicationSetting < ApplicationRecord
length: { maximum: 2000, message: _('is too long (maximum is %{count} characters)') },
allow_blank: true
- validates :spam_check_api_key,
- presence: true,
- if: :spam_check_endpoint_enabled
-
validates :unique_ips_limit_per_user,
numericality: { greater_than_or_equal_to: 1 },
presence: true,
@@ -410,7 +406,7 @@ class ApplicationSetting < ApplicationRecord
if: :external_authorization_service_enabled
validates :spam_check_endpoint_url,
- addressable_url: { schemes: %w(grpc) }, allow_blank: true
+ addressable_url: { schemes: %w(tls grpc) }, allow_blank: true
validates :spam_check_endpoint_url,
presence: true,
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index 54ec8b2c3e4..5e20aac3b92 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -363,6 +363,14 @@ module ApplicationSettingImplementation
super(levels&.map { |level| Gitlab::VisibilityLevel.level_value(level) })
end
+ def static_objects_external_storage_auth_token=(token)
+ if token.present?
+ set_static_objects_external_storage_auth_token(token)
+ else
+ self.static_objects_external_storage_auth_token_encrypted = nil
+ end
+ end
+
def performance_bar_allowed_group
Group.find_by_id(performance_bar_allowed_group_id)
end
diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb
index 2368be6196c..38b7da76306 100644
--- a/app/models/bulk_imports/entity.rb
+++ b/app/models/bulk_imports/entity.rb
@@ -20,8 +20,6 @@
class BulkImports::Entity < ApplicationRecord
self.table_name = 'bulk_import_entities'
- EXPORT_RELATIONS_URL = '/%{resource}/%{full_path}/export_relations'
-
belongs_to :bulk_import, optional: false
belongs_to :parent, class_name: 'BulkImports::Entity', optional: true
@@ -104,18 +102,42 @@ class BulkImports::Entity < ApplicationRecord
end
end
+ def entity_type
+ source_type.gsub('_entity', '')
+ end
+
def pluralized_name
- source_type.gsub('_entity', '').pluralize
+ entity_type.pluralize
+ end
+
+ def base_resource_url_path
+ "/#{pluralized_name}/#{encoded_source_full_path}"
end
def export_relations_url_path
- @export_relations_url_path ||= EXPORT_RELATIONS_URL % { resource: pluralized_name, full_path: encoded_source_full_path }
+ "#{base_resource_url_path}/export_relations"
end
def relation_download_url_path(relation)
"#{export_relations_url_path}/download?relation=#{relation}"
end
+ def wikis_url_path
+ "#{base_resource_url_path}/wikis"
+ end
+
+ def project?
+ source_type == 'project_entity'
+ end
+
+ def group?
+ source_type == 'group_entity'
+ end
+
+ def update_service
+ "::#{pluralized_name.capitalize}::UpdateService".constantize
+ end
+
private
def validate_parent_is_a_group
diff --git a/app/models/bulk_imports/file_transfer/base_config.rb b/app/models/bulk_imports/file_transfer/base_config.rb
index 4d370315ad5..036d511bc59 100644
--- a/app/models/bulk_imports/file_transfer/base_config.rb
+++ b/app/models/bulk_imports/file_transfer/base_config.rb
@@ -5,6 +5,9 @@ module BulkImports
class BaseConfig
include Gitlab::Utils::StrongMemoize
+ UPLOADS_RELATION = 'uploads'
+ SELF_RELATION = 'self'
+
def initialize(portable)
@portable = portable
end
@@ -26,7 +29,11 @@ module BulkImports
end
def portable_relations
- tree_relations + file_relations - skipped_relations
+ tree_relations + file_relations + self_relation - skipped_relations
+ end
+
+ def self_relation?(relation)
+ relation == SELF_RELATION
end
def tree_relation?(relation)
@@ -43,6 +50,10 @@ module BulkImports
portable_tree[:include].find { |include| include[relation.to_sym] }
end
+ def portable_relations_tree
+ @portable_relations_tree ||= attributes_finder.find_relations_tree(portable_class_sym).deep_stringify_keys
+ end
+
private
attr_reader :portable
@@ -65,10 +76,6 @@ module BulkImports
@portable_class_sym ||= portable_class.to_s.demodulize.underscore.to_sym
end
- def portable_relations_tree
- @portable_relations_tree ||= attributes_finder.find_relations_tree(portable_class_sym).deep_stringify_keys
- end
-
def import_export_yaml
raise NotImplementedError
end
@@ -78,12 +85,16 @@ module BulkImports
end
def file_relations
- []
+ [UPLOADS_RELATION]
end
def skipped_relations
[]
end
+
+ def self_relation
+ [SELF_RELATION]
+ end
end
end
end
diff --git a/app/models/bulk_imports/file_transfer/project_config.rb b/app/models/bulk_imports/file_transfer/project_config.rb
index 9a0434da08a..fdfb0dd0186 100644
--- a/app/models/bulk_imports/file_transfer/project_config.rb
+++ b/app/models/bulk_imports/file_transfer/project_config.rb
@@ -3,8 +3,6 @@
module BulkImports
module FileTransfer
class ProjectConfig < BaseConfig
- UPLOADS_RELATION = 'uploads'
-
SKIPPED_RELATIONS = %w(
project_members
group_members
@@ -14,10 +12,6 @@ module BulkImports
::Gitlab::ImportExport.config_file
end
- def file_relations
- [UPLOADS_RELATION]
- end
-
def skipped_relations
SKIPPED_RELATIONS
end
diff --git a/app/models/bulk_imports/tracker.rb b/app/models/bulk_imports/tracker.rb
index 9de3239ee0f..cfe33c013ba 100644
--- a/app/models/bulk_imports/tracker.rb
+++ b/app/models/bulk_imports/tracker.rb
@@ -29,7 +29,7 @@ class BulkImports::Tracker < ApplicationRecord
def self.stage_running?(entity_id, stage)
where(stage: stage, bulk_import_entity_id: entity_id)
- .with_status(:created, :started)
+ .with_status(:created, :enqueued, :started)
.exists?
end
@@ -45,15 +45,24 @@ class BulkImports::Tracker < ApplicationRecord
state :created, value: 0
state :started, value: 1
state :finished, value: 2
+ state :enqueued, value: 3
state :failed, value: -1
state :skipped, value: -2
event :start do
- transition created: :started
+ transition enqueued: :started
# To avoid errors when re-starting a pipeline in case of network errors
transition started: :started
end
+ event :retry do
+ transition started: :enqueued
+ end
+
+ event :enqueue do
+ transition created: :enqueued
+ end
+
event :finish do
transition started: :finished
transition failed: :failed
diff --git a/app/models/chat_name.rb b/app/models/chat_name.rb
index da7312df18b..ff3f2663b73 100644
--- a/app/models/chat_name.rb
+++ b/app/models/chat_name.rb
@@ -1,8 +1,6 @@
# frozen_string_literal: true
class ChatName < ApplicationRecord
- include LooseForeignKey
-
LAST_USED_AT_INTERVAL = 1.hour
belongs_to :integration, foreign_key: :service_id
@@ -16,8 +14,6 @@ class ChatName < ApplicationRecord
validates :user_id, uniqueness: { scope: [:service_id] }
validates :chat_id, uniqueness: { scope: [:service_id, :team_id] }
- loose_foreign_key :ci_pipeline_chat_data, :chat_name_id, on_delete: :async_delete
-
# Updates the "last_used_timestamp" but only if it wasn't already updated
# recently.
#
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 3fdc44bccf3..428e440afba 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -10,6 +10,7 @@ module Ci
include Presentable
include Importable
include Ci::HasRef
+ extend ::Gitlab::Utils::Override
BuildArchivedError = Class.new(StandardError)
@@ -58,7 +59,7 @@ module Ci
has_one :runner_session, class_name: 'Ci::BuildRunnerSession', validate: true, inverse_of: :build
has_one :trace_metadata, class_name: 'Ci::BuildTraceMetadata', inverse_of: :build
- has_many :terraform_state_versions, class_name: 'Terraform::StateVersion', dependent: :nullify, inverse_of: :build, foreign_key: :ci_build_id # rubocop:disable Cop/ActiveRecordDependent
+ has_many :terraform_state_versions, class_name: 'Terraform::StateVersion', inverse_of: :build, foreign_key: :ci_build_id
accepts_nested_attributes_for :runner_session, update_only: true
accepts_nested_attributes_for :job_variables
@@ -164,6 +165,7 @@ module Ci
scope :with_artifacts_not_expired, -> { with_downloadable_artifacts.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.current) }
scope :with_expired_artifacts, -> { with_downloadable_artifacts.where('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]) }
@@ -188,8 +190,6 @@ module Ci
scope :without_coverage, -> { where(coverage: nil) }
scope :with_coverage_regex, -> { where.not(coverage_regex: nil) }
- scope :for_project, -> (project_id) { where(project_id: project_id) }
-
acts_as_taggable
add_authentication_token_field :token, encrypted: :required
@@ -286,6 +286,7 @@ module Ci
build.run_after_commit do
BuildQueueWorker.perform_async(id)
+ BuildHooksWorker.perform_async(id)
end
end
@@ -451,7 +452,7 @@ module Ci
end
def retryable?
- return false if retried? || archived?
+ return false if retried? || archived? || deployment_rejected?
success? || failed? || canceled?
end
@@ -722,6 +723,14 @@ module Ci
self.token && ActiveSupport::SecurityUtils.secure_compare(token, self.token)
end
+ # acts_as_taggable uses this method create/remove tags with contexts
+ # defined by taggings and to get those contexts it executes a query.
+ # We don't use any other contexts except `tags`, so we don't need it.
+ override :custom_contexts
+ def custom_contexts
+ []
+ end
+
def tag_list
if tags.loaded?
tags.map(&:name)
@@ -1074,6 +1083,16 @@ module Ci
runner&.instance_type?
end
+ def job_variables_attributes
+ strong_memoize(:job_variables_attributes) do
+ job_variables.internal_source.map do |variable|
+ variable.attributes.except('id', 'job_id', 'encrypted_value', 'encrypted_value_iv').tap do |attrs|
+ attrs[:value] = variable.value
+ end
+ end
+ end
+ end
+
protected
def run_status_commit_hooks!
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index ec1137920ef..e6dd62fab34 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -2,6 +2,7 @@
module Ci
class JobArtifact < Ci::ApplicationRecord
+ include IgnorableColumns
include AfterCommitQueue
include ObjectStorage::BackgroundMove
include UpdateProjectStatistics
@@ -120,6 +121,9 @@ module Ci
belongs_to :project
belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id
+ # We will start using this column once we complete https://gitlab.com/gitlab-org/gitlab/-/issues/285597
+ ignore_column :original_filename, remove_with: '14.7', remove_after: '2022-11-22'
+
mount_file_store_uploader JobArtifactUploader
skip_callback :save, :after, :store_file!, if: :store_after_commit?
@@ -133,6 +137,7 @@ module Ci
scope :not_expired, -> { where('expire_at IS NULL OR expire_at > ?', Time.current) }
scope :for_sha, ->(sha, project_id) { joins(job: :pipeline).where(ci_pipelines: { sha: sha, project_id: project_id }) }
+ scope :for_job_ids, ->(job_ids) { where(job_id: job_ids) }
scope :for_job_name, ->(name) { joins(:job).where(ci_builds: { name: name }) }
scope :with_job, -> { joins(:job).includes(:job) }
@@ -266,6 +271,10 @@ module Ci
self.where(project: project).sum(:size)
end
+ def self.distinct_job_ids
+ distinct.pluck(:job_id)
+ end
+
##
# FastDestroyAll concerns
# rubocop: disable CodeReuse/ServiceClass
diff --git a/app/models/ci/namespace_mirror.rb b/app/models/ci/namespace_mirror.rb
new file mode 100644
index 00000000000..8a4be3139e8
--- /dev/null
+++ b/app/models/ci/namespace_mirror.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Ci
+ # This model represents a record in a shadow table of the main database's namespaces table.
+ # It allows us to navigate the namespace hierarchy on the ci database without resorting to a JOIN.
+ class NamespaceMirror < ApplicationRecord
+ belongs_to :namespace
+
+ scope :contains_namespace, -> (id) do
+ where('traversal_ids @> ARRAY[?]::int[]', id)
+ end
+
+ class << self
+ def sync!(event)
+ namespace = event.namespace
+ traversal_ids = namespace.self_and_ancestor_ids(hierarchy_order: :desc)
+
+ upsert({ namespace_id: event.namespace_id, traversal_ids: traversal_ids },
+ unique_by: :namespace_id)
+
+ # It won't be necessary once we remove `sync_traversal_ids`.
+ # More info: https://gitlab.com/gitlab-org/gitlab/-/issues/347541
+ sync_children_namespaces!(event.namespace_id, traversal_ids)
+ end
+
+ private
+
+ def sync_children_namespaces!(namespace_id, traversal_ids)
+ contains_namespace(namespace_id)
+ .where.not(namespace_id: namespace_id)
+ .update_all(
+ "traversal_ids = ARRAY[#{sanitize_sql(traversal_ids.join(','))}]::int[] || traversal_ids[array_position(traversal_ids, #{sanitize_sql(namespace_id)}) + 1:]"
+ )
+ end
+ end
+ end
+end
diff --git a/app/models/ci/pending_build.rb b/app/models/ci/pending_build.rb
index ccad6290fac..41dc74ef050 100644
--- a/app/models/ci/pending_build.rb
+++ b/app/models/ci/pending_build.rb
@@ -30,6 +30,10 @@ module Ci
self.upsert(entry.attributes.compact, returning: %w[build_id], unique_by: :build_id)
end
+ def maintain_denormalized_data?
+ ::Feature.enabled?(:ci_pending_builds_maintain_denormalized_data, default_enabled: :yaml)
+ end
+
private
def args_from_build(build)
@@ -42,15 +46,9 @@ module Ci
namespace: project.namespace
}
- if Feature.enabled?(:ci_pending_builds_maintain_tags_data, type: :development, default_enabled: :yaml)
+ if maintain_denormalized_data?
args.store(:tag_ids, build.tags_ids)
- end
-
- if Feature.enabled?(:ci_pending_builds_maintain_shared_runners_data, type: :development, default_enabled: :yaml)
args.store(:instance_runners_enabled, shared_runners_enabled?(project))
- end
-
- if Feature.enabled?(:ci_pending_builds_maintain_namespace_traversal_ids, type: :development, default_enabled: :yaml)
args.store(:namespace_traversal_ids, project.namespace.traversal_ids) if group_runners_enabled?(project)
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index a29aa756e38..a90bd739741 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -63,6 +63,7 @@ module Ci
has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
has_many :latest_statuses_ordered_by_stage, -> { latest.order(:stage_idx, :stage) }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
has_many :latest_statuses, -> { latest }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
+ has_many :statuses_order_id_desc, -> { order_id_desc }, class_name: 'CommitStatus', foreign_key: :commit_id
has_many :processables, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline
has_many :bridges, class_name: 'Ci::Bridge', foreign_key: :commit_id, inverse_of: :pipeline
has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline
@@ -82,8 +83,6 @@ module Ci
# Merge requests for which the current pipeline is running against
# the merge request's latest commit.
has_many :merge_requests_as_head_pipeline, foreign_key: "head_pipeline_id", class_name: 'MergeRequest'
- has_many :package_build_infos, class_name: 'Packages::BuildInfo', dependent: :nullify, inverse_of: :pipeline # rubocop:disable Cop/ActiveRecordDependent
- has_many :package_file_build_infos, class_name: 'Packages::PackageFileBuildInfo', dependent: :nullify, inverse_of: :pipeline # rubocop:disable Cop/ActiveRecordDependent
has_many :pending_builds, -> { pending }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline
has_many :failed_builds, -> { latest.failed }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline
has_many :retryable_builds, -> { latest.failed_or_canceled.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline
@@ -236,7 +235,18 @@ module Ci
pipeline.run_after_commit do
PipelineHooksWorker.perform_async(pipeline.id)
- ExpirePipelineCacheWorker.perform_async(pipeline.id)
+
+ if pipeline.project.jira_subscription_exists?
+ # Passing the seq-id ensures this is idempotent
+ seq_id = ::Atlassian::JiraConnect::Client.generate_update_sequence_id
+ ::JiraConnect::SyncBuildsWorker.perform_async(pipeline.id, seq_id)
+ end
+
+ if Feature.enabled?(:expire_job_and_pipeline_cache_synchronously, pipeline.project, default_enabled: :yaml)
+ Ci::ExpirePipelineCacheService.new.execute(pipeline) # rubocop: disable CodeReuse/ServiceClass
+ else
+ ExpirePipelineCacheWorker.perform_async(pipeline.id)
+ end
end
end
@@ -271,14 +281,6 @@ module Ci
end
end
- after_transition any => any do |pipeline|
- pipeline.run_after_commit do
- # Passing the seq-id ensures this is idempotent
- seq_id = ::Atlassian::JiraConnect::Client.generate_update_sequence_id
- ::JiraConnect::SyncBuildsWorker.perform_async(pipeline.id, seq_id)
- end
- end
-
after_transition any => ::Ci::Pipeline.completed_statuses do |pipeline|
pipeline.run_after_commit do
::Ci::TestFailureHistoryService.new(pipeline).async.perform_if_needed # rubocop: disable CodeReuse/ServiceClass
@@ -643,7 +645,7 @@ module Ci
def coverage
coverage_array = latest_statuses.map(&:coverage).compact
if coverage_array.size >= 1
- '%.2f' % (coverage_array.reduce(:+) / coverage_array.size)
+ coverage_array.reduce(:+) / coverage_array.size
end
end
@@ -947,22 +949,16 @@ module Ci
end
def environments_in_self_and_descendants
- if ::Feature.enabled?(:avoid_cross_joins_environments_in_self_and_descendants, default_enabled: :yaml)
- # We limit to 100 unique environments for application safety.
- # See: https://gitlab.com/gitlab-org/gitlab/-/issues/340781#note_699114700
- expanded_environment_names =
- builds_in_self_and_descendants.joins(:metadata)
- .where.not('ci_builds_metadata.expanded_environment_name' => nil)
- .distinct('ci_builds_metadata.expanded_environment_name')
- .limit(100)
- .pluck(:expanded_environment_name)
-
- Environment.where(project: project, name: expanded_environment_names)
- else
- environment_ids = self_and_descendants.joins(:deployments).select(:'deployments.environment_id')
+ # We limit to 100 unique environments for application safety.
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/340781#note_699114700
+ expanded_environment_names =
+ builds_in_self_and_descendants.joins(:metadata)
+ .where.not('ci_builds_metadata.expanded_environment_name' => nil)
+ .distinct('ci_builds_metadata.expanded_environment_name')
+ .limit(100)
+ .pluck(:expanded_environment_name)
- Environment.where(id: environment_ids)
- end
+ Environment.where(project: project, name: expanded_environment_names).with_deployment(sha)
end
# With multi-project and parent-child pipelines
@@ -1276,18 +1272,18 @@ module Ci
self.builds.latest.build_matchers(project)
end
- def predefined_vars_in_builder_enabled?
- strong_memoize(:predefined_vars_in_builder_enabled) do
- Feature.enabled?(:ci_predefined_vars_in_builder, project, default_enabled: :yaml)
- end
- end
-
def authorized_cluster_agents
strong_memoize(:authorized_cluster_agents) do
::Clusters::AgentAuthorizationsFinder.new(project).execute.map(&:agent)
end
end
+ def create_deployment_in_separate_transaction?
+ strong_memoize(:create_deployment_in_separate_transaction) do
+ ::Feature.enabled?(:create_deployment_in_separate_transaction, project, default_enabled: :yaml)
+ end
+ end
+
private
def add_message(severity, content)
diff --git a/app/models/ci/project_mirror.rb b/app/models/ci/project_mirror.rb
new file mode 100644
index 00000000000..d6aaa3f50c1
--- /dev/null
+++ b/app/models/ci/project_mirror.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Ci
+ # This model represents a shadow table of the main database's projects table.
+ # It allows us to navigate the project and namespace hierarchy on the ci database.
+ class ProjectMirror < ApplicationRecord
+ belongs_to :project
+
+ class << self
+ def sync!(event)
+ upsert({ project_id: event.project_id, namespace_id: event.project.namespace_id },
+ unique_by: :project_id)
+ end
+ end
+ end
+end
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 8a3025e5608..a80fd02080f 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -12,7 +12,6 @@ module Ci
include Gitlab::Utils::StrongMemoize
include TaggableQueries
include Presentable
- include LooseForeignKey
add_authentication_token_field :token, encrypted: :optional
@@ -27,6 +26,21 @@ module Ci
project_type: 3
}
+ enum executor_type: {
+ unknown: 0,
+ custom: 1,
+ shell: 2,
+ docker: 3,
+ docker_windows: 4,
+ docker_ssh: 5,
+ ssh: 6,
+ parallels: 7,
+ virtualbox: 8,
+ docker_machine: 9,
+ docker_ssh_machine: 10,
+ kubernetes: 11
+ }, _suffix: true
+
# This `ONLINE_CONTACT_TIMEOUT` needs to be larger than
# `RUNNER_QUEUE_EXPIRY_TIME+UPDATE_CONTACT_COLUMN_EVERY`
#
@@ -40,9 +54,12 @@ module Ci
# The `UPDATE_CONTACT_COLUMN_EVERY` defines how often the Runner DB entry can be updated
UPDATE_CONTACT_COLUMN_EVERY = (40.minutes..55.minutes).freeze
+ # The `STALE_TIMEOUT` constant defines the how far past the last contact or creation date a runner will be considered stale
+ STALE_TIMEOUT = 3.months
+
AVAILABLE_TYPES_LEGACY = %w[specific shared].freeze
AVAILABLE_TYPES = runner_types.keys.freeze
- AVAILABLE_STATUSES = %w[active paused online offline not_connected].freeze
+ AVAILABLE_STATUSES = %w[active paused online offline not_connected never_contacted stale].freeze # TODO: Remove in %15.0: active, paused, not_connected. Relevant issues: https://gitlab.com/gitlab-org/gitlab/-/issues/347303, https://gitlab.com/gitlab-org/gitlab/-/issues/347305, https://gitlab.com/gitlab-org/gitlab/-/issues/344648
AVAILABLE_SCOPES = (AVAILABLE_TYPES_LEGACY + AVAILABLE_TYPES + AVAILABLE_STATUSES).freeze
FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze
@@ -58,12 +75,14 @@ module Ci
before_save :ensure_token
- scope :active, -> { where(active: true) }
- scope :paused, -> { where(active: false) }
+ scope :active, -> (value = true) { where(active: value) }
+ scope :paused, -> { active(false) }
scope :online, -> { where('contacted_at > ?', online_contact_time_deadline) }
- scope :recent, -> { where('ci_runners.created_at > :date OR ci_runners.contacted_at > :date', date: 3.months.ago) }
+ scope :recent, -> { where('ci_runners.created_at >= :date OR ci_runners.contacted_at >= :date', date: stale_deadline) }
+ scope :stale, -> { where('ci_runners.created_at < :date AND (ci_runners.contacted_at IS NULL OR ci_runners.contacted_at < :date)', date: stale_deadline) }
scope :offline, -> { where(arel_table[:contacted_at].lteq(online_contact_time_deadline)) }
- scope :not_connected, -> { where(contacted_at: nil) }
+ scope :not_connected, -> { where(contacted_at: nil) } # TODO: Remove in 15.0
+ scope :never_contacted, -> { where(contacted_at: nil) }
scope :ordered, -> { order(id: :desc) }
scope :with_recent_runner_queue, -> { where('contacted_at > ?', recent_queue_deadline) }
@@ -78,10 +97,7 @@ module Ci
scope :belonging_to_group, -> (group_id, include_ancestors: false) {
groups = ::Group.where(id: group_id)
-
- if include_ancestors
- groups = Gitlab::ObjectHierarchy.new(groups).base_and_ancestors
- end
+ groups = groups.self_and_ancestors if include_ancestors
joins(:runner_namespaces)
.where(ci_runner_namespaces: { namespace_id: groups })
@@ -102,10 +118,9 @@ module Ci
scope :belonging_to_parent_group_of_project, -> (project_id) {
project_groups = ::Group.joins(:projects).where(projects: { id: project_id })
- hierarchy_groups = Gitlab::ObjectHierarchy.new(project_groups).base_and_ancestors
joins(:groups)
- .where(namespaces: { id: hierarchy_groups })
+ .where(namespaces: { id: project_groups.self_and_ancestors.as_ids })
.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336433')
}
@@ -152,7 +167,7 @@ module Ci
after_destroy :cleanup_runner_queue
- cached_attr_reader :version, :revision, :platform, :architecture, :ip_address, :contacted_at
+ cached_attr_reader :version, :revision, :platform, :architecture, :ip_address, :contacted_at, :executor_type
chronic_duration_attr :maximum_timeout_human_readable, :maximum_timeout,
error_message: 'Maximum job timeout has a value which could not be accepted'
@@ -168,8 +183,6 @@ module Ci
validates :config, json_schema: { filename: 'ci_runner_config' }
- loose_foreign_key :clusters_applications_runners, :runner_id, on_delete: :async_nullify
-
# Searches for runners matching the given query.
#
# This method uses ILIKE on PostgreSQL for the description field and performs a full match on tokens.
@@ -185,6 +198,10 @@ module Ci
ONLINE_CONTACT_TIMEOUT.ago
end
+ def self.stale_deadline
+ STALE_TIMEOUT.ago
+ end
+
def self.recent_queue_deadline
# we add queue expiry + online
# - contacted_at can be updated at any time within this interval
@@ -273,8 +290,17 @@ module Ci
contacted_at && contacted_at > self.class.online_contact_time_deadline
end
- def status
- return :not_connected unless contacted_at
+ def stale?
+ return false unless created_at
+
+ [created_at, contacted_at].compact.max < self.class.stale_deadline
+ end
+
+ def status(legacy_mode = nil)
+ return deprecated_rest_status if legacy_mode == '14.5'
+
+ return :stale if stale?
+ return :never_contacted unless contacted_at
online? ? :online : :offline
end
@@ -387,8 +413,9 @@ module Ci
# database after heartbeat write happens.
#
::Gitlab::Database::LoadBalancing::Session.without_sticky_writes do
- values = values&.slice(:version, :revision, :platform, :architecture, :ip_address, :config) || {}
+ values = values&.slice(:version, :revision, :platform, :architecture, :ip_address, :config, :executor) || {}
values[:contacted_at] = Time.current
+ values[:executor_type] = EXECUTOR_NAME_TO_TYPES.fetch(values.delete(:executor), :unknown)
cache_attributes(values)
@@ -413,6 +440,20 @@ module Ci
private
+ EXECUTOR_NAME_TO_TYPES = {
+ 'custom' => :custom,
+ 'shell' => :shell,
+ 'docker' => :docker,
+ 'docker-windows' => :docker_windows,
+ 'docker-ssh' => :docker_ssh,
+ 'ssh' => :ssh,
+ 'parallels' => :parallels,
+ 'virtualbox' => :virtualbox,
+ 'docker+machine' => :docker_machine,
+ 'docker-ssh+machine' => :docker_ssh_machine,
+ 'kubernetes' => :kubernetes
+ }.freeze
+
def cleanup_runner_queue
Gitlab::Redis::SharedState.with do |redis|
redis.del(runner_queue_key)
diff --git a/app/models/ci/runner_namespace.rb b/app/models/ci/runner_namespace.rb
index 52a31863fb2..82390ccc538 100644
--- a/app/models/ci/runner_namespace.rb
+++ b/app/models/ci/runner_namespace.rb
@@ -7,7 +7,6 @@ module Ci
self.limit_name = 'ci_registered_group_runners'
self.limit_scope = :group
self.limit_relation = :recent_runners
- self.limit_feature_flag_for_override = :ci_runner_limits_override
belongs_to :runner, inverse_of: :runner_namespaces
belongs_to :namespace, inverse_of: :runner_namespaces, class_name: '::Namespace'
diff --git a/app/models/ci/runner_project.rb b/app/models/ci/runner_project.rb
index 148a29a0f8b..42c24c8c8d1 100644
--- a/app/models/ci/runner_project.rb
+++ b/app/models/ci/runner_project.rb
@@ -7,7 +7,6 @@ module Ci
self.limit_name = 'ci_registered_project_runners'
self.limit_scope = :project
self.limit_relation = :recent_runners
- self.limit_feature_flag_for_override = :ci_runner_limits_override
belongs_to :runner, inverse_of: :runner_projects
belongs_to :project, inverse_of: :runner_projects
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index e2b15497638..8c4e97ac840 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -22,6 +22,7 @@ module Ci
scope :ordered, -> { order(position: :asc) }
scope :in_pipelines, ->(pipelines) { where(pipeline: pipelines) }
scope :by_name, ->(names) { where(name: names) }
+ scope :by_position, ->(positions) { where(position: positions) }
with_options unless: :importing? do
validates :project, presence: true
diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb
index cf6d95fc6df..98490a13351 100644
--- a/app/models/clusters/agent.rb
+++ b/app/models/clusters/agent.rb
@@ -4,6 +4,8 @@ module Clusters
class Agent < ApplicationRecord
self.table_name = 'cluster_agents'
+ INACTIVE_AFTER = 1.hour.freeze
+
belongs_to :created_by_user, class_name: 'User', optional: true
belongs_to :project, class_name: '::Project' # Otherwise, it will load ::Clusters::Project
@@ -16,6 +18,8 @@ module Clusters
has_many :project_authorizations, class_name: 'Clusters::Agents::ProjectAuthorization'
has_many :authorized_projects, class_name: '::Project', through: :project_authorizations, source: :project
+ has_many :activity_events, -> { in_timeline_order }, class_name: 'Clusters::Agents::ActivityEvent', inverse_of: :agent
+
scope :ordered_by_name, -> { order(:name) }
scope :with_name, -> (name) { where(name: name) }
@@ -31,5 +35,9 @@ module Clusters
def has_access_to?(requested_project)
requested_project == project
end
+
+ def active?
+ agent_tokens.where("last_used_at > ?", INACTIVE_AFTER.ago).exists?
+ end
end
end
diff --git a/app/models/clusters/agent_token.rb b/app/models/clusters/agent_token.rb
index 27a3cd8d13d..87dba50cd69 100644
--- a/app/models/clusters/agent_token.rb
+++ b/app/models/clusters/agent_token.rb
@@ -28,8 +28,12 @@ module Clusters
cache_attributes(track_values)
- # Use update_column so updated_at is skipped
- update_columns(track_values) if can_update_track_values?
+ if can_update_track_values?
+ log_activity_event!(track_values[:last_used_at]) unless agent.active?
+
+ # Use update_column so updated_at is skipped
+ update_columns(track_values)
+ end
end
private
@@ -44,5 +48,14 @@ module Clusters
real_last_used_at.nil? ||
(Time.current - real_last_used_at) >= last_used_at_max_age
end
+
+ def log_activity_event!(recorded_at)
+ agent.activity_events.create!(
+ kind: :agent_connected,
+ level: :info,
+ recorded_at: recorded_at,
+ agent_token: self
+ )
+ end
end
end
diff --git a/app/models/clusters/agents/activity_event.rb b/app/models/clusters/agents/activity_event.rb
new file mode 100644
index 00000000000..5d9c885c923
--- /dev/null
+++ b/app/models/clusters/agents/activity_event.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ class ActivityEvent < ApplicationRecord
+ include NullifyIfBlank
+
+ self.table_name = 'agent_activity_events'
+
+ belongs_to :agent, class_name: 'Clusters::Agent', optional: false
+ belongs_to :user
+ belongs_to :agent_token, class_name: 'Clusters::AgentToken'
+
+ scope :in_timeline_order, -> { order(recorded_at: :desc, id: :desc) }
+
+ validates :recorded_at, :kind, :level, presence: true
+
+ nullify_if_blank :detail
+
+ enum kind: {
+ token_created: 0,
+ token_revoked: 1,
+ agent_connected: 2,
+ agent_disconnected: 3
+ }, _prefix: true
+
+ enum level: {
+ debug: 0,
+ info: 1,
+ warn: 2,
+ error: 3,
+ fatal: 4,
+ unknown: 5
+ }, _prefix: true
+ end
+ end
+end
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index 59a9251d6b7..b57a24dead0 100644
--- a/app/models/clusters/applications/runner.rb
+++ b/app/models/clusters/applications/runner.rb
@@ -3,7 +3,7 @@
module Clusters
module Applications
class Runner < ApplicationRecord
- VERSION = '0.34.0'
+ VERSION = '0.35.0'
self.table_name = 'clusters_applications_runners'
@@ -50,34 +50,6 @@ module Clusters
private
- def ensure_runner
- runner || create_and_assign_runner
- end
-
- def create_and_assign_runner
- transaction do
- Ci::Runner.create!(runner_create_params).tap do |runner|
- update!(runner_id: runner.id)
- end
- end
- end
-
- def runner_create_params
- attributes = {
- name: 'kubernetes-cluster',
- runner_type: cluster.cluster_type,
- tag_list: %w[kubernetes cluster]
- }
-
- if cluster.group_type?
- attributes[:runner_namespaces] = [::Ci::RunnerNamespace.new(namespace: group)]
- elsif cluster.project_type?
- attributes[:runner_projects] = [::Ci::RunnerProject.new(project: project)]
- end
-
- attributes
- end
-
def gitlab_url
Gitlab::Routing.url_helpers.root_url(only_path: false)
end
@@ -85,7 +57,6 @@ module Clusters
def specification
{
"gitlabUrl" => gitlab_url,
- "runnerToken" => ensure_runner.token,
"runners" => { "privileged" => privileged }
}
end
diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb
index 7ec614b048c..1bd8e8b44cb 100644
--- a/app/models/clusters/platforms/kubernetes.rb
+++ b/app/models/clusters/platforms/kubernetes.rb
@@ -50,12 +50,6 @@ module Clusters
alias_attribute :ca_pem, :ca_cert
- delegate :enabled?, to: :cluster, allow_nil: true
- delegate :provided_by_user?, to: :cluster, allow_nil: true
- delegate :allow_user_defined_namespace?, to: :cluster, allow_nil: true
-
- alias_method :active?, :enabled?
-
enum_with_nil authorization_type: {
unknown_authorization: nil,
rbac: 1,
@@ -66,6 +60,19 @@ module Clusters
nullify_if_blank :namespace
+ def enabled?
+ !!cluster&.enabled?
+ end
+ alias_method :active?, :enabled?
+
+ def provided_by_user?
+ !!cluster&.provided_by_user?
+ end
+
+ def allow_user_defined_namespace?
+ !!cluster&.allow_user_defined_namespace?
+ end
+
def predefined_variables(project:, environment_name:, kubernetes_namespace: nil)
Gitlab::Ci::Variables::Collection.new.tap do |variables|
variables.append(key: 'KUBE_URL', value: api_url)
diff --git a/app/models/commit.rb b/app/models/commit.rb
index 553681ee960..f0c5f3c2d12 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -84,43 +84,27 @@ class Commit
sha[0..MIN_SHA_LENGTH]
end
- def diff_safe_lines(project: nil)
- diff_safe_max_lines(project: project)
+ def diff_max_files
+ Gitlab::CurrentSettings.diff_max_files
end
- def diff_max_files(project: nil)
- if Feature.enabled?(:increased_diff_limits, project)
- 3000
- elsif Feature.enabled?(:configurable_diff_limits, project)
- Gitlab::CurrentSettings.diff_max_files
- else
- 1000
- end
- end
-
- def diff_max_lines(project: nil)
- if Feature.enabled?(:increased_diff_limits, project)
- 100000
- elsif Feature.enabled?(:configurable_diff_limits, project)
- Gitlab::CurrentSettings.diff_max_lines
- else
- 50000
- end
+ def diff_max_lines
+ Gitlab::CurrentSettings.diff_max_lines
end
- def max_diff_options(project: nil)
+ def max_diff_options
{
- max_files: diff_max_files(project: project),
- max_lines: diff_max_lines(project: project)
+ max_files: diff_max_files,
+ max_lines: diff_max_lines
}
end
- def diff_safe_max_files(project: nil)
- diff_max_files(project: project) / DIFF_SAFE_LIMIT_FACTOR
+ def diff_safe_max_files
+ diff_max_files / DIFF_SAFE_LIMIT_FACTOR
end
- def diff_safe_max_lines(project: nil)
- diff_max_lines(project: project) / DIFF_SAFE_LIMIT_FACTOR
+ def diff_safe_max_lines
+ diff_max_lines / DIFF_SAFE_LIMIT_FACTOR
end
def from_hash(hash, container)
diff --git a/app/models/commit_signatures/gpg_signature.rb b/app/models/commit_signatures/gpg_signature.rb
new file mode 100644
index 00000000000..1ce76b53da4
--- /dev/null
+++ b/app/models/commit_signatures/gpg_signature.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+module CommitSignatures
+ class GpgSignature < ApplicationRecord
+ include CommitSignature
+
+ sha_attribute :gpg_key_primary_keyid
+
+ belongs_to :gpg_key
+ belongs_to :gpg_key_subkey
+
+ validates :gpg_key_primary_keyid, presence: true
+
+ def self.with_key_and_subkeys(gpg_key)
+ subkey_ids = gpg_key.subkeys.pluck(:id)
+
+ where(
+ arel_table[:gpg_key_id].eq(gpg_key.id).or(
+ arel_table[:gpg_key_subkey_id].in(subkey_ids)
+ )
+ )
+ end
+
+ def gpg_key=(model)
+ case model
+ when GpgKey
+ super
+ when GpgKeySubkey
+ self.gpg_key_subkey = model
+ when NilClass
+ super
+ self.gpg_key_subkey = nil
+ end
+ end
+
+ def gpg_key
+ if gpg_key_id
+ super
+ elsif gpg_key_subkey_id
+ gpg_key_subkey
+ end
+ end
+
+ def gpg_key_primary_keyid
+ super&.upcase
+ end
+
+ def gpg_commit
+ return unless commit
+
+ Gitlab::Gpg::Commit.new(commit)
+ end
+ end
+end
diff --git a/app/models/commit_signatures/x509_commit_signature.rb b/app/models/commit_signatures/x509_commit_signature.rb
new file mode 100644
index 00000000000..2cbb331dd7e
--- /dev/null
+++ b/app/models/commit_signatures/x509_commit_signature.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+module CommitSignatures
+ class X509CommitSignature < ApplicationRecord
+ include CommitSignature
+
+ belongs_to :x509_certificate, class_name: 'X509Certificate', foreign_key: 'x509_certificate_id', optional: false
+
+ validates :x509_certificate_id, presence: true
+
+ def x509_commit
+ return unless commit
+
+ Gitlab::X509::Commit.new(commit)
+ end
+ end
+end
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index d75f7984e2c..d6a2f62ca9b 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -53,15 +53,13 @@ class CommitStatus < Ci::ApplicationRecord
scope :before_stage, -> (index) { where('stage_idx < ?', index) }
scope :for_stage, -> (index) { where(stage_idx: index) }
scope :after_stage, -> (index) { where('stage_idx > ?', index) }
+ scope :for_project, -> (project_id) { where(project_id: project_id) }
scope :for_ref, -> (ref) { where(ref: ref) }
scope :by_name, -> (name) { where(name: name) }
scope :in_pipelines, ->(pipelines) { where(pipeline: pipelines) }
scope :with_pipeline, -> { joins(:pipeline) }
scope :updated_at_before, ->(date) { where('ci_builds.updated_at < ?', date) }
scope :created_at_before, ->(date) { where('ci_builds.created_at < ?', date) }
- scope :updated_before, ->(lookback:, timeout:) {
- where('(ci_builds.created_at BETWEEN ? AND ?) AND (ci_builds.updated_at BETWEEN ? AND ?)', lookback, timeout, lookback, timeout)
- }
scope :scheduled_at_before, ->(date) {
where('ci_builds.scheduled_at IS NOT NULL AND ci_builds.scheduled_at < ?', date)
}
@@ -71,7 +69,8 @@ class CommitStatus < Ci::ApplicationRecord
# Pluck is used to split this query. Splitting the query is required for database decomposition for `ci_*` tables.
# https://docs.gitlab.com/ee/development/database/transaction_guidelines.html#database-decomposition-and-sharding
project_ids = Project.where_full_path_in(Array(paths)).pluck(:id)
- where(project: project_ids)
+
+ for_project(project_ids)
end
scope :with_preloads, -> do
@@ -147,7 +146,7 @@ class CommitStatus < Ci::ApplicationRecord
end
event :drop do
- transition [:created, :waiting_for_resource, :preparing, :pending, :running, :scheduled] => :failed
+ transition [:created, :waiting_for_resource, :preparing, :pending, :running, :manual, :scheduled] => :failed
end
event :success do
@@ -191,7 +190,12 @@ class CommitStatus < Ci::ApplicationRecord
commit_status.run_after_commit do
PipelineProcessWorker.perform_async(pipeline_id) unless transition_options[:skip_pipeline_processing]
- ExpireJobCacheWorker.perform_async(id)
+
+ if Feature.enabled?(:expire_job_and_pipeline_cache_synchronously, project, default_enabled: :yaml)
+ expire_etag_cache!
+ else
+ ExpireJobCacheWorker.perform_async(id)
+ end
end
end
@@ -217,6 +221,10 @@ class CommitStatus < Ci::ApplicationRecord
false
end
+ def self.bulk_insert_tags!(statuses, tag_list_by_build)
+ Gitlab::Ci::Tags::BulkInsert.new(statuses, tag_list_by_build).insert!
+ end
+
def locking_enabled?
will_save_change_to_status?
end
@@ -300,6 +308,12 @@ class CommitStatus < Ci::ApplicationRecord
.update_all(retried: true, processed: true)
end
+ def expire_etag_cache!
+ job_path = Gitlab::Routing.url_helpers.project_build_path(project, id, format: :json)
+
+ Gitlab::EtagCaching::Store.new.touch(job_path)
+ end
+
private
def unrecoverable_failure?
diff --git a/app/models/concerns/after_commit_queue.rb b/app/models/concerns/after_commit_queue.rb
new file mode 100644
index 00000000000..7f525bec9e9
--- /dev/null
+++ b/app/models/concerns/after_commit_queue.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module AfterCommitQueue
+ extend ActiveSupport::Concern
+
+ included do
+ after_commit :_run_after_commit_queue
+ after_rollback :_clear_after_commit_queue
+ end
+
+ def run_after_commit(&block)
+ _after_commit_queue << block if block
+
+ true
+ end
+
+ def run_after_commit_or_now(&block)
+ if self.class.inside_transaction?
+ if connection.current_transaction.records&.include?(self)
+ run_after_commit(&block)
+ else
+ # If the current transaction does not include this record, we can run
+ # the block now, even if it queues a Sidekiq job.
+ Sidekiq::Worker.skipping_transaction_check do
+ instance_eval(&block)
+ end
+ end
+ else
+ instance_eval(&block)
+ end
+
+ true
+ end
+
+ protected
+
+ def _run_after_commit_queue
+ while action = _after_commit_queue.pop
+ self.instance_eval(&action)
+ end
+ end
+
+ def _after_commit_queue
+ @after_commit_queue ||= []
+ end
+
+ def _clear_after_commit_queue
+ _after_commit_queue.clear
+ end
+end
diff --git a/app/models/concerns/calloutable.rb b/app/models/concerns/calloutable.rb
deleted file mode 100644
index 8b9cfae6a32..00000000000
--- a/app/models/concerns/calloutable.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-# frozen_string_literal: true
-
-module Calloutable
- extend ActiveSupport::Concern
-
- included do
- belongs_to :user
-
- validates :user, presence: true
- end
-
- def dismissed_after?(dismissed_after)
- dismissed_at > dismissed_after
- end
-end
diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb
index a9589cea5e9..12ddbc2cc40 100644
--- a/app/models/concerns/ci/contextable.rb
+++ b/app/models/concerns/ci/contextable.rb
@@ -13,7 +13,6 @@ module Ci
track_duration do
variables = pipeline.variables_builder.scoped_variables(self, environment: environment, dependencies: dependencies)
- variables.concat(predefined_variables) unless pipeline.predefined_vars_in_builder_enabled?
variables.concat(project.predefined_variables)
variables.concat(pipeline.predefined_variables)
variables.concat(runner.predefined_variables) if runnable? && runner
@@ -71,24 +70,6 @@ module Ci
end
end
- def predefined_variables
- Gitlab::Ci::Variables::Collection.new.tap do |variables|
- variables.append(key: 'CI_JOB_NAME', value: name)
- variables.append(key: 'CI_JOB_STAGE', value: stage)
- variables.append(key: 'CI_JOB_MANUAL', value: 'true') if action?
- variables.append(key: 'CI_PIPELINE_TRIGGERED', value: 'true') if trigger_request
-
- variables.append(key: 'CI_NODE_INDEX', value: self.options[:instance].to_s) if self.options&.include?(:instance)
- variables.append(key: 'CI_NODE_TOTAL', value: ci_node_total_value.to_s)
-
- # legacy variables
- variables.append(key: 'CI_BUILD_NAME', value: name)
- variables.append(key: 'CI_BUILD_STAGE', value: stage)
- variables.append(key: 'CI_BUILD_TRIGGERED', value: 'true') if trigger_request
- variables.append(key: 'CI_BUILD_MANUAL', value: 'true') if action?
- end
- end
-
def kubernetes_variables
::Gitlab::Ci::Variables::Collection.new.tap do |collection|
# Should get merged with the cluster kubeconfig in deployment_variables, see
@@ -123,13 +104,5 @@ module Ci
def secret_project_variables(environment: expanded_environment_name)
project.ci_variables_for(ref: git_ref, environment: environment)
end
-
- private
-
- def ci_node_total_value
- parallel = self.options&.dig(:parallel)
- parallel = parallel.dig(:total) if parallel.is_a?(Hash)
- parallel || 1
- end
end
end
diff --git a/app/models/concerns/commit_signature.rb b/app/models/concerns/commit_signature.rb
new file mode 100644
index 00000000000..5bdfa9a2966
--- /dev/null
+++ b/app/models/concerns/commit_signature.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+module CommitSignature
+ extend ActiveSupport::Concern
+
+ included do
+ include ShaAttribute
+
+ sha_attribute :commit_sha
+
+ enum verification_status: {
+ unverified: 0,
+ verified: 1,
+ same_user_different_email: 2,
+ other_user: 3,
+ unverified_key: 4,
+ unknown_key: 5,
+ multiple_signatures: 6
+ }
+
+ belongs_to :project, class_name: 'Project', foreign_key: 'project_id', optional: false
+
+ validates :commit_sha, presence: true
+ validates :project_id, presence: true
+
+ scope :by_commit_sha, ->(shas) { where(commit_sha: shas) }
+ end
+
+ class_methods do
+ def safe_create!(attributes)
+ create_with(attributes)
+ .safe_find_or_create_by!(commit_sha: attributes[:commit_sha])
+ end
+
+ # Find commits that are lacking a signature in the database at present
+ def unsigned_commit_shas(commit_shas)
+ return [] if commit_shas.empty?
+
+ signed = by_commit_sha(commit_shas).pluck(:commit_sha)
+ commit_shas - signed
+ end
+ end
+
+ def commit
+ project.commit(commit_sha)
+ end
+
+ def user
+ commit.committer
+ end
+end
diff --git a/app/models/concerns/diff_positionable_note.rb b/app/models/concerns/diff_positionable_note.rb
index b13ca4bf06e..051158e5de5 100644
--- a/app/models/concerns/diff_positionable_note.rb
+++ b/app/models/concerns/diff_positionable_note.rb
@@ -3,7 +3,6 @@ module DiffPositionableNote
extend ActiveSupport::Concern
included do
- delegate :on_text?, :on_image?, to: :position, allow_nil: true
before_validation :set_original_position, on: :create
before_validation :update_position, on: :create, if: :on_text?, unless: :importing?
@@ -34,6 +33,14 @@ module DiffPositionableNote
end
end
+ def on_text?
+ !!position&.on_text?
+ end
+
+ def on_image?
+ !!position&.on_image?
+ end
+
def supported?
for_commit? || self.noteable.has_complete_diff_refs?
end
diff --git a/app/models/concerns/enums/ci/commit_status.rb b/app/models/concerns/enums/ci/commit_status.rb
index 1b4cc14f4a2..312b88a4d6d 100644
--- a/app/models/concerns/enums/ci/commit_status.rb
+++ b/app/models/concerns/enums/ci/commit_status.rb
@@ -28,6 +28,7 @@ module Enums
trace_size_exceeded: 19,
builds_disabled: 20,
environment_creation_failure: 21,
+ deployment_rejected: 22,
insufficient_bridge_permissions: 1_001,
downstream_bridge_project_not_found: 1_002,
invalid_bridge_trigger: 1_003,
diff --git a/app/models/concerns/import_state/sidekiq_job_tracker.rb b/app/models/concerns/import_state/sidekiq_job_tracker.rb
index b7d0ed0f51b..340bf4279bc 100644
--- a/app/models/concerns/import_state/sidekiq_job_tracker.rb
+++ b/app/models/concerns/import_state/sidekiq_job_tracker.rb
@@ -15,7 +15,7 @@ module ImportState
def refresh_jid_expiration
return unless jid
- Gitlab::SidekiqStatus.set(jid, Gitlab::Import::StuckImportJob::IMPORT_JOBS_EXPIRATION)
+ Gitlab::SidekiqStatus.set(jid, Gitlab::Import::StuckImportJob::IMPORT_JOBS_EXPIRATION, value: 2)
end
def self.jid_by(project_id:, status:)
diff --git a/app/models/concerns/incident_management/escalatable.rb b/app/models/concerns/incident_management/escalatable.rb
index 78dce63f59e..81eef50603a 100644
--- a/app/models/concerns/incident_management/escalatable.rb
+++ b/app/models/concerns/incident_management/escalatable.rb
@@ -102,3 +102,5 @@ module IncidentManagement
end
end
end
+
+::IncidentManagement::Escalatable.prepend_mod
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 4273eb331a1..dcd80201d3f 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -43,7 +43,7 @@ module Issuable
included do
cache_markdown_field :title, pipeline: :single_line
- cache_markdown_field :description, issuable_state_filter_enabled: true
+ cache_markdown_field :description, issuable_reference_expansion_enabled: true
redact_field :description
@@ -61,6 +61,16 @@ module Issuable
# We check first if we're loaded to not load unnecessarily.
loaded? && to_a.all? { |note| note.association(:award_emoji).loaded? }
end
+
+ def projects_loaded?
+ # We check first if we're loaded to not load unnecessarily.
+ loaded? && to_a.all? { |note| note.association(:project).loaded? }
+ end
+
+ def system_note_metadata_loaded?
+ # We check first if we're loaded to not load unnecessarily.
+ loaded? && to_a.all? { |note| note.association(:system_note_metadata).loaded? }
+ end
end
has_many :note_authors, -> { distinct }, through: :notes, source: :author
@@ -183,6 +193,10 @@ module Issuable
incident?
end
+ def supports_escalation?
+ incident?
+ end
+
def incident?
is_a?(Issue) && super
end
@@ -524,6 +538,8 @@ module Issuable
includes = []
includes << :author unless notes.authors_loaded?
includes << :award_emoji unless notes.award_emojis_loaded?
+ includes << :project unless notes.projects_loaded?
+ includes << :system_note_metadata unless notes.system_note_metadata_loaded?
if includes.any?
notes.includes(includes)
diff --git a/app/models/concerns/loose_foreign_key.rb b/app/models/concerns/loose_foreign_key.rb
deleted file mode 100644
index 102292672b3..00000000000
--- a/app/models/concerns/loose_foreign_key.rb
+++ /dev/null
@@ -1,79 +0,0 @@
-# frozen_string_literal: true
-
-module LooseForeignKey
- extend ActiveSupport::Concern
-
- # This concern adds loose foreign key support to ActiveRecord models.
- # Loose foreign keys allow delayed processing of associated database records
- # with similar guarantees than a database foreign key.
- #
- # Prerequisites:
- #
- # To start using the concern, you'll need to install a database trigger to the parent
- # table in a standard DB migration (not post-migration).
- #
- # > track_record_deletions(:projects)
- #
- # Usage:
- #
- # > class Ci::Build < ApplicationRecord
- # >
- # > loose_foreign_key :security_scans, :build_id, on_delete: :async_delete
- # >
- # > # associations can be still defined, the dependent options is no longer necessary:
- # > has_many :security_scans, class_name: 'Security::Scan'
- # >
- # > end
- #
- # Options for on_delete:
- #
- # - :async_delete - deletes the children rows via an asynchronous process.
- # - :async_nullify - sets the foreign key column to null via an asynchronous process.
- #
- # How it works:
- #
- # When adding loose foreign key support to the table, a DELETE trigger is installed
- # which tracks the record deletions (stores primary key value of the deleted row) in
- # a database table.
- #
- # These deletion records are processed asynchronously and records are cleaned up
- # according to the loose foreign key definitions described in the model.
- #
- # The cleanup happens in batches, which reduces the likelyhood of statement timeouts.
- #
- # When all associations related to the deleted record are cleaned up, the record itself
- # is deleted.
- included do
- class_attribute :loose_foreign_key_definitions, default: []
- end
-
- class_methods do
- def loose_foreign_key(to_table, column, options)
- symbolized_options = options.symbolize_keys
-
- unless base_class?
- raise <<~MSG
- loose_foreign_key can be only used on base classes, inherited classes are not supported.
- Please define the loose_foreign_key on the #{base_class.name} class.
- MSG
- end
-
- on_delete_options = %i[async_delete async_nullify]
-
- unless on_delete_options.include?(symbolized_options[:on_delete]&.to_sym)
- raise "Invalid on_delete option given: #{symbolized_options[:on_delete]}. Valid options: #{on_delete_options.join(', ')}"
- end
-
- definition = ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(
- table_name.to_s,
- to_table.to_s,
- {
- column: column.to_s,
- on_delete: symbolized_options[:on_delete].to_sym
- }
- )
-
- self.loose_foreign_key_definitions += [definition]
- end
- end
-end
diff --git a/app/models/concerns/merge_request_reviewer_state.rb b/app/models/concerns/merge_request_reviewer_state.rb
index 216a3a0bd64..5859f43a70c 100644
--- a/app/models/concerns/merge_request_reviewer_state.rb
+++ b/app/models/concerns/merge_request_reviewer_state.rb
@@ -15,11 +15,5 @@ module MergeRequestReviewerState
inclusion: { in: self.states.keys }
after_initialize :set_state, unless: :persisted?
-
- def set_state
- if Feature.enabled?(:mr_attention_requests, self.merge_request&.project, default_enabled: :yaml)
- self.state = :attention_requested
- end
- end
end
end
diff --git a/app/models/concerns/packages/debian/component_file.rb b/app/models/concerns/packages/debian/component_file.rb
index 9cf66c756a0..77409549e85 100644
--- a/app/models/concerns/packages/debian/component_file.rb
+++ b/app/models/concerns/packages/debian/component_file.rb
@@ -20,13 +20,13 @@ module Packages
belongs_to :component, class_name: "Packages::Debian::#{container_type.capitalize}Component", inverse_of: :files
belongs_to :architecture, class_name: "Packages::Debian::#{container_type.capitalize}Architecture", inverse_of: :files, optional: true
- enum file_type: { packages: 1, source: 2, di_packages: 3 }
+ enum file_type: { packages: 1, sources: 2, di_packages: 3 }
enum compression_type: { gz: 1, bz2: 2, xz: 3 }
validates :component, presence: true
validates :file_type, presence: true
- validates :architecture, presence: true, unless: :source?
- validates :architecture, absence: true, if: :source?
+ validates :architecture, presence: true, unless: :sources?
+ validates :architecture, absence: true, if: :sources?
validates :file, length: { minimum: 0, allow_nil: false }
validates :size, presence: true
validates :file_store, presence: true
@@ -81,7 +81,7 @@ module Packages
case file_type
when 'packages'
"#{component.name}/binary-#{architecture.name}/#{file_name}#{extension}"
- when 'source'
+ when 'sources'
"#{component.name}/source/#{file_name}#{extension}"
when 'di_packages'
"#{component.name}/debian-installer/binary-#{architecture.name}/#{file_name}#{extension}"
diff --git a/app/models/concerns/participable.rb b/app/models/concerns/participable.rb
index 25410a859e9..1663aa6c886 100644
--- a/app/models/concerns/participable.rb
+++ b/app/models/concerns/participable.rb
@@ -60,6 +60,15 @@ module Participable
filtered_participants_hash[user]
end
+ # Returns only participants visible for the user
+ #
+ # Returns an Array of User instances.
+ def visible_participants(user)
+ return participants(user) unless Feature.enabled?(:verify_participants_access, project, default_enabled: :yaml)
+
+ filter_by_ability(raw_participants(user, verify_access: true))
+ end
+
# Checks if the user is a participant in a discussion.
#
# This method processes attributes of objects in breadth-first order.
@@ -84,8 +93,7 @@ module Participable
end
end
- def raw_participants(current_user = nil)
- current_user ||= author
+ def raw_participants(current_user = nil, verify_access: false)
ext = Gitlab::ReferenceExtractor.new(project, current_user)
participants = Set.new
process = [self]
@@ -97,6 +105,8 @@ module Participable
when User
participants << source
when Participable
+ next unless !verify_access || source_visible_to_user?(source, current_user)
+
source.class.participant_attrs.each do |attr|
if attr.respond_to?(:call)
source.instance_exec(current_user, ext, &attr)
@@ -116,6 +126,10 @@ module Participable
participants.merge(ext.users)
end
+ def source_visible_to_user?(source, user)
+ Ability.allowed?(user, "read_#{source.model_name.element}".to_sym, source)
+ end
+
def filter_by_ability(participants)
case self
when PersonalSnippet
diff --git a/app/models/concerns/partitioned_table.rb b/app/models/concerns/partitioned_table.rb
index 23d2d00b346..f95f9dd8ad7 100644
--- a/app/models/concerns/partitioned_table.rb
+++ b/app/models/concerns/partitioned_table.rb
@@ -7,7 +7,8 @@ module PartitionedTable
attr_reader :partitioning_strategy
PARTITIONING_STRATEGIES = {
- monthly: Gitlab::Database::Partitioning::MonthlyStrategy
+ monthly: Gitlab::Database::Partitioning::MonthlyStrategy,
+ sliding_list: Gitlab::Database::Partitioning::SlidingListStrategy
}.freeze
def partitioned_by(partitioning_key, strategy:, **kwargs)
diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb
index c32e499c329..9069d3088cd 100644
--- a/app/models/concerns/relative_positioning.rb
+++ b/app/models/concerns/relative_positioning.rb
@@ -168,6 +168,24 @@ module RelativePositioning
self.relative_position = MIN_POSITION
end
+ def next_object_by_relative_position(ignoring: nil, order: :asc)
+ relation = relative_positioning_scoped_items(ignoring: ignoring).reorder(relative_position: order)
+
+ relation = if order == :asc
+ relation.where(self.class.arel_table[:relative_position].gt(relative_position))
+ else
+ relation.where(self.class.arel_table[:relative_position].lt(relative_position))
+ end
+
+ relation.first
+ end
+
+ def relative_positioning_scoped_items(ignoring: nil)
+ relation = self.class.relative_positioning_query_base(self)
+ relation = exclude_self(relation, excluded: ignoring) if ignoring.present?
+ relation
+ end
+
# This method is used during rebalancing - override it to customise the update
# logic:
def update_relative_siblings(relation, range, delta)
diff --git a/app/models/concerns/resolvable_discussion.rb b/app/models/concerns/resolvable_discussion.rb
index 60e1dde17b9..aae338e9759 100644
--- a/app/models/concerns/resolvable_discussion.rb
+++ b/app/models/concerns/resolvable_discussion.rb
@@ -30,11 +30,14 @@ module ResolvableDiscussion
delegate :resolved_at,
:resolved_by,
- :resolved_by_push?,
to: :last_resolved_note,
allow_nil: true
end
+ def resolved_by_push?
+ !!last_resolved_note&.resolved_by_push?
+ end
+
def resolvable?
strong_memoize(:resolvable) do
potentially_resolvable? && notes.any?(&:resolvable?)
diff --git a/app/models/concerns/sha_attribute.rb b/app/models/concerns/sha_attribute.rb
index ba7c6c0cd8b..e49f4d03bda 100644
--- a/app/models/concerns/sha_attribute.rb
+++ b/app/models/concerns/sha_attribute.rb
@@ -3,11 +3,14 @@
module ShaAttribute
extend ActiveSupport::Concern
+ # Needed for the database method
+ include DatabaseReflection
+
class_methods do
def sha_attribute(name)
return if ENV['STATIC_VERIFICATION']
- validate_binary_column_exists!(name) if Rails.env.development?
+ validate_binary_column_exists!(name) if Rails.env.development? || Rails.env.test?
attribute(name, Gitlab::Database::ShaAttribute.new)
end
diff --git a/app/models/concerns/token_authenticatable_strategies/encryption_helper.rb b/app/models/concerns/token_authenticatable_strategies/encryption_helper.rb
index 3be82ed72d3..447521ad8c1 100644
--- a/app/models/concerns/token_authenticatable_strategies/encryption_helper.rb
+++ b/app/models/concerns/token_authenticatable_strategies/encryption_helper.rb
@@ -11,7 +11,7 @@ module TokenAuthenticatableStrategies
# The pattern of the token is "#{DYNAMIC_NONCE_IDENTIFIER}#{token}#{iv_of_12_characters}"
if token.start_with?(DYNAMIC_NONCE_IDENTIFIER) && token.size > NONCE_SIZE + DYNAMIC_NONCE_IDENTIFIER.size
token_to_decrypt = token[1...-NONCE_SIZE]
- iv = token[-NONCE_SIZE..-1]
+ iv = token[-NONCE_SIZE..]
Gitlab::CryptoHelper.aes256_gcm_decrypt(token_to_decrypt, nonce: iv)
else
diff --git a/app/models/concerns/transactions.rb b/app/models/concerns/transactions.rb
index a186ebc8475..1c9bd8274f5 100644
--- a/app/models/concerns/transactions.rb
+++ b/app/models/concerns/transactions.rb
@@ -8,7 +8,7 @@ module Transactions
# transaction. Handles special cases when running inside a test environment,
# where tests may be wrapped in transactions
def inside_transaction?
- base = Rails.env.test? ? @open_transactions_baseline.to_i : 0
+ base = Rails.env.test? ? open_transactions_baseline.to_i : 0
connection.open_transactions > base
end
@@ -24,5 +24,15 @@ module Transactions
def reset_open_transactions_baseline
@open_transactions_baseline = 0
end
+
+ def open_transactions_baseline
+ return unless Rails.env.test?
+
+ if @open_transactions_baseline.nil?
+ return self == ApplicationRecord ? nil : superclass.open_transactions_baseline
+ end
+
+ @open_transactions_baseline
+ end
end
end
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index 8e130998f11..c914819f79d 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -145,9 +145,14 @@ class ContainerRepository < ApplicationRecord
name: path.repository_name)
end
- def self.create_from_path!(path)
- safe_find_or_create_by!(project: path.repository_project,
- name: path.repository_name)
+ def self.find_or_create_from_path(path)
+ repository = safe_find_or_create_by(
+ project: path.repository_project,
+ name: path.repository_name
+ )
+ return repository if repository.persisted?
+
+ find_by_path!(path)
end
def self.build_root_repository(project)
diff --git a/app/models/context_commits_diff.rb b/app/models/context_commits_diff.rb
index fe1a72b79f2..3d25b60678a 100644
--- a/app/models/context_commits_diff.rb
+++ b/app/models/context_commits_diff.rb
@@ -3,6 +3,7 @@
class ContextCommitsDiff
include ActsAsPaginatedDiff
+ delegate :head, :base, to: :compare
attr_reader :merge_request
def initialize(merge_request)
diff --git a/app/models/customer_relations/contact.rb b/app/models/customer_relations/contact.rb
index 5898bc3412f..d8669f1f4c2 100644
--- a/app/models/customer_relations/contact.rb
+++ b/app/models/customer_relations/contact.rb
@@ -25,6 +25,13 @@ class CustomerRelations::Contact < ApplicationRecord
validates :description, length: { maximum: 1024 }
validate :validate_email_format
+ def self.find_ids_by_emails(group_id, emails)
+ raise ArgumentError, "Cannot lookup more than #{MAX_PLUCK} emails" if emails.length > MAX_PLUCK
+
+ where(group_id: group_id, email: emails)
+ .pluck(:id)
+ end
+
private
def validate_email_format
diff --git a/app/models/customer_relations/issue_contact.rb b/app/models/customer_relations/issue_contact.rb
index 98faf8d6644..78f662b6a58 100644
--- a/app/models/customer_relations/issue_contact.rb
+++ b/app/models/customer_relations/issue_contact.rb
@@ -8,6 +8,14 @@ class CustomerRelations::IssueContact < ApplicationRecord
validate :contact_belongs_to_issue_group
+ def self.find_contact_ids_by_emails(issue_id, emails)
+ raise ArgumentError, "Cannot lookup more than #{MAX_PLUCK} emails" if emails.length > MAX_PLUCK
+
+ joins(:contact)
+ .where(issue_id: issue_id, customer_relations_contacts: { email: emails })
+ .pluck(:contact_id)
+ end
+
private
def contact_belongs_to_issue_group
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index ade19ce02a8..4c60ce57f49 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -46,9 +46,10 @@ class Deployment < ApplicationRecord
scope :for_project, -> (project_id) { where(project_id: project_id) }
scope :for_projects, -> (projects) { where(project: projects) }
- scope :visible, -> { where(status: %i[running success failed canceled]) }
+ scope :visible, -> { where(status: %i[running success failed canceled blocked]) }
scope :stoppable, -> { where.not(on_stop: nil).where.not(deployable_id: nil).success }
scope :active, -> { where(status: %i[created running]) }
+ scope :upcoming, -> { where(status: %i[blocked running]) }
scope :older_than, -> (deployment) { where('deployments.id < ?', deployment.id) }
scope :with_api_entity_associations, -> { preload({ deployable: { runner: [], tags: [], user: [], job_artifacts_archive: [] } }) }
@@ -64,6 +65,10 @@ class Deployment < ApplicationRecord
transition created: :running
end
+ event :block do
+ transition created: :blocked
+ end
+
event :succeed do
transition any - [:success] => :success
end
@@ -119,6 +124,8 @@ class Deployment < ApplicationRecord
next if transition.loopback?
deployment.run_after_commit do
+ next unless deployment.project.jira_subscription_exists?
+
::JiraConnect::SyncDeploymentsWorker.perform_async(id)
end
end
@@ -126,6 +133,8 @@ class Deployment < ApplicationRecord
after_create unless: :importing? do |deployment|
run_after_commit do
+ next unless deployment.project.jira_subscription_exists?
+
::JiraConnect::SyncDeploymentsWorker.perform_async(deployment.id)
end
end
@@ -136,7 +145,8 @@ class Deployment < ApplicationRecord
success: 2,
failed: 3,
canceled: 4,
- skipped: 5
+ skipped: 5,
+ blocked: 6
}
def self.archivables_in(project, limit:)
@@ -387,6 +397,8 @@ class Deployment < ApplicationRecord
cancel!
when 'skipped'
skip!
+ when 'blocked'
+ block!
else
raise ArgumentError, "The status #{status.inspect} is invalid"
end
diff --git a/app/models/dev_ops_report/metric.rb b/app/models/dev_ops_report/metric.rb
index 14eff725433..d30e869b155 100644
--- a/app/models/dev_ops_report/metric.rb
+++ b/app/models/dev_ops_report/metric.rb
@@ -6,6 +6,20 @@ module DevOpsReport
self.table_name = 'conversational_development_index_metrics'
+ METRICS = %w[leader_issues instance_issues percentage_issues leader_notes instance_notes
+ percentage_notes leader_milestones instance_milestones percentage_milestones
+ leader_boards instance_boards percentage_boards leader_merge_requests
+ instance_merge_requests percentage_merge_requests leader_ci_pipelines
+ instance_ci_pipelines percentage_ci_pipelines leader_environments instance_environments
+ percentage_environments leader_deployments instance_deployments percentage_deployments
+ leader_projects_prometheus_active instance_projects_prometheus_active
+ percentage_projects_prometheus_active leader_service_desk_issues instance_service_desk_issues
+ percentage_service_desk_issues].freeze
+
+ METRICS.each do |metric_name|
+ validates metric_name, presence: true, numericality: { greater_than_or_equal_to: 0 }
+ end
+
def instance_score(feature)
self["instance_#{feature}"]
end
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 2618991c9e5..a830c04f291 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -31,7 +31,7 @@ class Environment < ApplicationRecord
has_one :last_visible_deployable, through: :last_visible_deployment, source: 'deployable', source_type: 'CommitStatus', disable_joins: true
has_one :last_visible_pipeline, through: :last_visible_deployable, source: 'pipeline', disable_joins: true
- has_one :upcoming_deployment, -> { running.distinct_on_environment }, class_name: 'Deployment', inverse_of: :environment
+ has_one :upcoming_deployment, -> { upcoming.distinct_on_environment }, class_name: 'Deployment', inverse_of: :environment
has_one :latest_opened_most_severe_alert, -> { order_severity_with_open_prometheus_alert }, class_name: 'AlertManagement::Alert', inverse_of: :environment
before_validation :generate_slug, if: ->(env) { env.slug.blank? }
diff --git a/app/models/error_tracking/error_event.rb b/app/models/error_tracking/error_event.rb
index 0b638f65768..18c1467e6f6 100644
--- a/app/models/error_tracking/error_event.rb
+++ b/app/models/error_tracking/error_event.rb
@@ -3,6 +3,9 @@
class ErrorTracking::ErrorEvent < ApplicationRecord
belongs_to :error, counter_cache: :events_count
+ # Scrub null bytes
+ attribute :payload, Gitlab::Database::Type::JsonPgSafe.new
+
validates :payload, json_schema: { filename: 'error_tracking_event_payload' }
validates :error, presence: true
diff --git a/app/models/event.rb b/app/models/event.rb
index f6174589a84..409bc66c66c 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -130,10 +130,11 @@ class Event < ApplicationRecord
# Update Gitlab::ContributionsCalendar#activity_dates if this changes
def contributions
- where("action = ? OR (target_type IN (?) AND action IN (?)) OR (target_type = ? AND action = ?)",
- actions[:pushed],
- %w(MergeRequest Issue), [actions[:created], actions[:closed], actions[:merged]],
- "Note", actions[:commented])
+ where(
+ 'action IN (?) OR (target_type IN (?) AND action IN (?))',
+ [actions[:pushed], actions[:commented]],
+ %w(MergeRequest Issue), [actions[:created], actions[:closed], actions[:merged]]
+ )
end
def limit_recent(limit = 20, offset = nil)
diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb
index 0cb3662368c..a56e28859c9 100644
--- a/app/models/gpg_key.rb
+++ b/app/models/gpg_key.rb
@@ -92,13 +92,13 @@ class GpgKey < ApplicationRecord
end
def revoke
- GpgSignature
+ CommitSignatures::GpgSignature
.with_key_and_subkeys(self)
- .where.not(verification_status: GpgSignature.verification_statuses[:unknown_key])
+ .where.not(verification_status: CommitSignatures::GpgSignature.verification_statuses[:unknown_key])
.update_all(
gpg_key_id: nil,
gpg_key_subkey_id: nil,
- verification_status: GpgSignature.verification_statuses[:unknown_key],
+ verification_status: CommitSignatures::GpgSignature.verification_statuses[:unknown_key],
updated_at: Time.zone.now
)
diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb
deleted file mode 100644
index 2775b520b2f..00000000000
--- a/app/models/gpg_signature.rb
+++ /dev/null
@@ -1,86 +0,0 @@
-# frozen_string_literal: true
-
-class GpgSignature < ApplicationRecord
- include ShaAttribute
-
- sha_attribute :commit_sha
- sha_attribute :gpg_key_primary_keyid
-
- enum verification_status: {
- unverified: 0,
- verified: 1,
- same_user_different_email: 2,
- other_user: 3,
- unverified_key: 4,
- unknown_key: 5,
- multiple_signatures: 6
- }
-
- belongs_to :project
- belongs_to :gpg_key
- belongs_to :gpg_key_subkey
-
- validates :commit_sha, presence: true
- validates :project_id, presence: true
- validates :gpg_key_primary_keyid, presence: true
-
- scope :by_commit_sha, ->(shas) { where(commit_sha: shas) }
-
- def self.with_key_and_subkeys(gpg_key)
- subkey_ids = gpg_key.subkeys.pluck(:id)
-
- where(
- arel_table[:gpg_key_id].eq(gpg_key.id).or(
- arel_table[:gpg_key_subkey_id].in(subkey_ids)
- )
- )
- end
-
- def self.safe_create!(attributes)
- create_with(attributes)
- .safe_find_or_create_by!(commit_sha: attributes[:commit_sha])
- end
-
- # Find commits that are lacking a signature in the database at present
- def self.unsigned_commit_shas(commit_shas)
- return [] if commit_shas.empty?
-
- signed = GpgSignature.where(commit_sha: commit_shas).pluck(:commit_sha)
-
- commit_shas - signed
- end
-
- def gpg_key=(model)
- case model
- when GpgKey
- super
- when GpgKeySubkey
- self.gpg_key_subkey = model
- when NilClass
- super
- self.gpg_key_subkey = nil
- end
- end
-
- def gpg_key
- if gpg_key_id
- super
- elsif gpg_key_subkey_id
- gpg_key_subkey
- end
- end
-
- def gpg_key_primary_keyid
- super&.upcase
- end
-
- def commit
- project.commit(commit_sha)
- end
-
- def gpg_commit
- return unless commit
-
- Gitlab::Gpg::Commit.new(commit)
- end
-end
diff --git a/app/models/group.rb b/app/models/group.rb
index 2dd20300ad2..f51782785f9 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -852,15 +852,7 @@ class Group < Namespace
end
def self.groups_including_descendants_by(group_ids)
- groups = Group.where(id: group_ids)
-
- if Feature.enabled?(:linear_group_including_descendants_by, default_enabled: :yaml)
- groups.self_and_descendants
- else
- Gitlab::ObjectHierarchy
- .new(groups)
- .base_and_descendants
- end
+ Group.where(id: group_ids).self_and_descendants
end
def disable_shared_runners!
diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb
index d1584a62bfb..16b95d2a2b9 100644
--- a/app/models/hooks/project_hook.rb
+++ b/app/models/hooks/project_hook.rb
@@ -31,10 +31,6 @@ class ProjectHook < WebHook
_('Webhooks')
end
- def web_hooks_disable_failed?
- Feature.enabled?(:web_hooks_disable_failed, project)
- end
-
override :rate_limit
def rate_limit
project.actual_limits.limit_for(:web_hook_calls)
@@ -44,6 +40,13 @@ class ProjectHook < WebHook
def application_context
super.merge(project: project)
end
+
+ private
+
+ override :web_hooks_disable_failed?
+ def web_hooks_disable_failed?
+ Feature.enabled?(:web_hooks_disable_failed, project)
+ end
end
ProjectHook.prepend_mod_with('ProjectHook')
diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb
index cb5c1ac48cd..e8a55abfc8f 100644
--- a/app/models/hooks/web_hook.rb
+++ b/app/models/hooks/web_hook.rb
@@ -34,9 +34,19 @@ class WebHook < ApplicationRecord
end
def executable?
- return true unless web_hooks_disable_failed?
+ !temporarily_disabled? && !permanently_disabled?
+ end
+
+ def temporarily_disabled?
+ return false unless web_hooks_disable_failed?
+
+ disabled_until.present? && disabled_until >= Time.current
+ end
+
+ def permanently_disabled?
+ return false unless web_hooks_disable_failed?
- recent_failures <= FAILURE_THRESHOLD && (disabled_until.nil? || disabled_until < Time.current)
+ recent_failures > FAILURE_THRESHOLD
end
# rubocop: disable CodeReuse/ServiceClass
@@ -69,6 +79,8 @@ class WebHook < ApplicationRecord
end
def disable!
+ return if permanently_disabled?
+
update_attribute(:recent_failures, FAILURE_THRESHOLD + 1)
end
@@ -80,7 +92,7 @@ class WebHook < ApplicationRecord
end
def backoff!
- return if backoff_count >= MAX_FAILURES && disabled_until && disabled_until > Time.current
+ return if permanently_disabled? || (backoff_count >= MAX_FAILURES && temporarily_disabled?)
assign_attributes(disabled_until: next_backoff.from_now, backoff_count: backoff_count.succ.clamp(0, MAX_FAILURES))
save(validate: false)
@@ -93,7 +105,19 @@ class WebHook < ApplicationRecord
save(validate: false)
end
- # Overridden in ProjectHook and GroupHook, other webhooks are not rate-limited.
+ # @return [Boolean] Whether or not the WebHook is currently throttled.
+ def rate_limited?
+ return false unless rate_limit
+
+ Gitlab::ApplicationRateLimiter.peek(
+ :web_hook_calls,
+ scope: [self],
+ threshold: rate_limit
+ )
+ end
+
+ # Threshold for the rate-limit.
+ # Overridden in ProjectHook and GroupHook, other WebHooks are not rate-limited.
def rate_limit
nil
end
diff --git a/app/models/incident_management/issuable_escalation_status.rb b/app/models/incident_management/issuable_escalation_status.rb
index 88aef104d88..fc881e62efd 100644
--- a/app/models/incident_management/issuable_escalation_status.rb
+++ b/app/models/incident_management/issuable_escalation_status.rb
@@ -7,8 +7,11 @@ module IncidentManagement
self.table_name = 'incident_management_issuable_escalation_statuses'
belongs_to :issue
+ has_one :project, through: :issue, inverse_of: :incident_management_issuable_escalation_status
validates :issue, presence: true, uniqueness: true
+
+ delegate :project, to: :issue
end
end
diff --git a/app/models/instance_configuration.rb b/app/models/instance_configuration.rb
index 0bf9e805aa8..bbddc18103a 100644
--- a/app/models/instance_configuration.rb
+++ b/app/models/instance_configuration.rb
@@ -62,6 +62,7 @@ class InstanceConfiguration
def plan_file_size_limits(plan)
{
conan: plan.actual_limits[:conan_max_file_size],
+ helm: plan.actual_limits[:helm_max_file_size],
maven: plan.actual_limits[:maven_max_file_size],
npm: plan.actual_limits[:npm_max_file_size],
nuget: plan.actual_limits[:nuget_max_file_size],
diff --git a/app/models/integration.rb b/app/models/integration.rb
index d3059fa6d4a..29d96650a81 100644
--- a/app/models/integration.rb
+++ b/app/models/integration.rb
@@ -14,11 +14,13 @@ class Integration < ApplicationRecord
asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker datadog discord
drone_ci emails_on_push ewm external_wiki flowdock hangouts_chat irker jira
mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email
- pivotaltracker prometheus pushover redmine shimo slack slack_slash_commands teamcity unify_circuit webex_teams youtrack zentao
+ pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack zentao
].freeze
+ # TODO Shimo is temporary disabled on group and instance-levels.
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/345677
PROJECT_SPECIFIC_INTEGRATION_NAMES = %w[
- jenkins
+ jenkins shimo
].freeze
# Fake integrations to help with local development.
diff --git a/app/models/integrations/base_issue_tracker.rb b/app/models/integrations/base_issue_tracker.rb
index 3fd67205e92..42a6a3a19c8 100644
--- a/app/models/integrations/base_issue_tracker.rb
+++ b/app/models/integrations/base_issue_tracker.rb
@@ -128,7 +128,7 @@ module Integrations
false
end
- def create_cross_reference_note(mentioned, noteable, author)
+ def create_cross_reference_note(external_issue, mentioned_in, author)
# implement inside child
end
diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb
index 42c291abf55..d46299de1be 100644
--- a/app/models/integrations/jira.rb
+++ b/app/models/integrations/jira.rb
@@ -234,19 +234,19 @@ module Integrations
end
override :create_cross_reference_note
- def create_cross_reference_note(mentioned, noteable, author)
- unless can_cross_reference?(noteable)
- return s_("JiraService|Events for %{noteable_model_name} are disabled.") % { noteable_model_name: noteable.model_name.plural.humanize(capitalize: false) }
+ def create_cross_reference_note(external_issue, mentioned_in, author)
+ unless can_cross_reference?(mentioned_in)
+ return s_("JiraService|Events for %{noteable_model_name} are disabled.") % { noteable_model_name: mentioned_in.model_name.plural.humanize(capitalize: false) }
end
- jira_issue = find_issue(mentioned.id)
+ jira_issue = find_issue(external_issue.id)
return unless jira_issue.present?
- noteable_id = noteable.respond_to?(:iid) ? noteable.iid : noteable.id
- noteable_type = noteable_name(noteable)
- entity_url = build_entity_url(noteable_type, noteable_id)
- entity_meta = build_entity_meta(noteable)
+ mentioned_in_id = mentioned_in.respond_to?(:iid) ? mentioned_in.iid : mentioned_in.id
+ mentioned_in_type = mentionable_name(mentioned_in)
+ entity_url = build_entity_url(mentioned_in_type, mentioned_in_id)
+ entity_meta = build_entity_meta(mentioned_in)
data = {
user: {
@@ -259,9 +259,9 @@ module Integrations
},
entity: {
id: entity_meta[:id],
- name: noteable_type.humanize.downcase,
+ name: mentioned_in_type.humanize.downcase,
url: entity_url,
- title: noteable.title,
+ title: mentioned_in.title,
description: entity_meta[:description],
branch: entity_meta[:branch]
}
@@ -302,11 +302,11 @@ module Integrations
private
- def branch_name(noteable)
+ def branch_name(commit)
if Feature.enabled?(:jira_use_first_ref_by_oid, project, default_enabled: :yaml)
- noteable.first_ref_by_oid(project.repository)
+ commit.first_ref_by_oid(project.repository)
else
- noteable.ref_names(project.repository).first
+ commit.ref_names(project.repository).first
end
end
@@ -316,8 +316,8 @@ module Integrations
end
end
- def can_cross_reference?(noteable)
- case noteable
+ def can_cross_reference?(mentioned_in)
+ case mentioned_in
when Commit then commit_events
when MergeRequest then merge_requests_events
else true
@@ -487,36 +487,36 @@ module Integrations
"#{Settings.gitlab.base_url.chomp("/")}#{resource}"
end
- def build_entity_url(noteable_type, entity_id)
+ def build_entity_url(entity_type, entity_id)
polymorphic_url(
[
self.project,
- noteable_type.to_sym
+ entity_type.to_sym
],
id: entity_id,
host: Settings.gitlab.base_url
)
end
- def build_entity_meta(noteable)
- if noteable.is_a?(Commit)
+ def build_entity_meta(entity)
+ if entity.is_a?(Commit)
{
- id: noteable.short_id,
- description: noteable.safe_message,
- branch: branch_name(noteable)
+ id: entity.short_id,
+ description: entity.safe_message,
+ branch: branch_name(entity)
}
- elsif noteable.is_a?(MergeRequest)
+ elsif entity.is_a?(MergeRequest)
{
- id: noteable.to_reference,
- branch: noteable.source_branch
+ id: entity.to_reference,
+ branch: entity.source_branch
}
else
{}
end
end
- def noteable_name(noteable)
- name = noteable.model_name.singular
+ def mentionable_name(mentionable)
+ name = mentionable.model_name.singular
# ProjectSnippet inherits from Snippet class so it causes
# routing error building the URL.
diff --git a/app/models/integrations/shimo.rb b/app/models/integrations/shimo.rb
index 4f42fda2577..0e1023bb7a7 100644
--- a/app/models/integrations/shimo.rb
+++ b/app/models/integrations/shimo.rb
@@ -5,7 +5,11 @@ module Integrations
prop_accessor :external_wiki_url
validates :external_wiki_url, presence: true, public_url: true, if: :activated?
+ after_commit :cache_project_has_shimo
+
def render?
+ return false unless Feature.enabled?(:shimo_integration, project)
+
valid? && activated?
end
@@ -43,5 +47,14 @@ module Integrations
}
]
end
+
+ private
+
+ def cache_project_has_shimo
+ return unless project && !project.destroyed?
+
+ project.project_setting.save! unless project.project_setting.persisted?
+ project.project_setting.update_column(:has_shimo, activated?)
+ end
end
end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 47dc084d69c..537e16e5cc3 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -63,6 +63,7 @@ class Issue < ApplicationRecord
has_many :issue_assignees
has_many :issue_email_participants
+ has_one :email
has_many :assignees, class_name: "User", through: :issue_assignees
has_many :zoom_meetings
has_many :user_mentions, class_name: "IssueUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
@@ -228,9 +229,37 @@ class Issue < ApplicationRecord
end
end
+ def next_object_by_relative_position(ignoring: nil, order: :asc)
+ return super unless Feature.enabled?(:optimized_issue_neighbor_queries, project, default_enabled: :yaml)
+
+ array_mapping_scope = -> (id_expression) do
+ relation = Issue.where(Issue.arel_table[:project_id].eq(id_expression))
+
+ if order == :asc
+ relation.where(Issue.arel_table[:relative_position].gt(relative_position))
+ else
+ relation.where(Issue.arel_table[:relative_position].lt(relative_position))
+ end
+ end
+
+ relation = Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder.new(
+ scope: Issue.order(relative_position: order, id: order),
+ array_scope: relative_positioning_parent_projects,
+ array_mapping_scope: array_mapping_scope,
+ finder_query: -> (_, id_expression) { Issue.where(Issue.arel_table[:id].eq(id_expression)) }
+ ).execute
+
+ relation = exclude_self(relation, excluded: ignoring) if ignoring.present?
+
+ relation.take
+ end
+
+ def relative_positioning_parent_projects
+ project.group&.root_ancestor&.all_projects&.select(:id) || Project.id_in(project).select(:id)
+ end
+
def self.relative_positioning_query_base(issue)
- projects = issue.project.group&.root_ancestor&.all_projects || issue.project
- in_projects(projects)
+ in_projects(issue.relative_positioning_parent_projects)
end
def self.relative_positioning_parent_column
@@ -433,8 +462,6 @@ class Issue < ApplicationRecord
# Returns `true` if the current issue can be viewed by either a logged in User
# or an anonymous user.
def visible_to_user?(user = nil)
- return false unless project && project.feature_available?(:issues, user)
-
return publicly_visible? unless user
return false unless readable_by?(user)
@@ -562,10 +589,10 @@ class Issue < ApplicationRecord
project.team.member?(user, Gitlab::Access::REPORTER)
elsif hidden?
false
+ elsif project.public? || (project.internal? && !user.external?)
+ project.feature_available?(:issues, user)
else
- project.public? ||
- project.internal? && !user.external? ||
- project.team.member?(user)
+ project.team.member?(user)
end
end
@@ -604,7 +631,7 @@ class Issue < ApplicationRecord
def could_not_move(exception)
# Symptom of running out of space - schedule rebalancing
- IssueRebalancingWorker.perform_async(nil, *project.self_or_root_group_ids)
+ Issues::RebalancingWorker.perform_async(nil, *project.self_or_root_group_ids)
end
end
diff --git a/app/models/issue/email.rb b/app/models/issue/email.rb
new file mode 100644
index 00000000000..730fda5cdb4
--- /dev/null
+++ b/app/models/issue/email.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class Issue::Email < ApplicationRecord
+ self.table_name = 'issue_emails'
+
+ belongs_to :issue
+
+ validates :email_message_id, uniqueness: true, presence: true, length: { maximum: 1000 }
+ validates :issue, presence: true, uniqueness: true
+end
diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb
index 9765ac6f2e9..caeffae7bda 100644
--- a/app/models/lfs_object.rb
+++ b/app/models/lfs_object.rb
@@ -13,6 +13,7 @@ class LfsObject < ApplicationRecord
scope :with_files_stored_locally, -> { where(file_store: LfsObjectUploader::Store::LOCAL) }
scope :with_files_stored_remotely, -> { where(file_store: LfsObjectUploader::Store::REMOTE) }
scope :for_oids, -> (oids) { where(oid: oids) }
+ scope :for_oid_and_size, -> (oid, size) { find_by(oid: oid, size: size) }
validates :oid, presence: true, uniqueness: true
diff --git a/app/models/lfs_objects_project.rb b/app/models/lfs_objects_project.rb
index e5632ff2842..bf6d1394569 100644
--- a/app/models/lfs_objects_project.rb
+++ b/app/models/lfs_objects_project.rb
@@ -21,9 +21,19 @@ class LfsObjectsProject < ApplicationRecord
scope :project_id_in, ->(ids) { where(project_id: ids) }
scope :lfs_object_in, -> (lfs_objects) { where(lfs_object: lfs_objects) }
+ def self.link_to_project!(lfs_object, project)
+ # We can't use an upsert here because there is no uniqueness constraint:
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/347466
+ self.safe_find_or_create_by!(lfs_object_id: lfs_object.id, project_id: project.id) # rubocop:disable Performance/ActiveRecordSubtransactionMethods
+ end
+
+ def self.update_statistics_for_project_id(project_id)
+ ProjectCacheWorker.perform_async(project_id, [], [:lfs_objects_size]) # rubocop:disable CodeReuse/Worker
+ end
+
private
def update_project_statistics
- ProjectCacheWorker.perform_async(project_id, [], [:lfs_objects_size])
+ self.class.update_statistics_for_project_id(project_id)
end
end
diff --git a/app/models/loose_foreign_keys/deleted_record.rb b/app/models/loose_foreign_keys/deleted_record.rb
index c3b3e76f67b..0fbdd2d8a5b 100644
--- a/app/models/loose_foreign_keys/deleted_record.rb
+++ b/app/models/loose_foreign_keys/deleted_record.rb
@@ -1,15 +1,45 @@
# frozen_string_literal: true
class LooseForeignKeys::DeletedRecord < ApplicationRecord
+ PARTITION_DURATION = 1.day
+
+ include PartitionedTable
+
self.primary_key = :id
+ self.ignored_columns = %i[partition]
+
+ partitioned_by :partition, strategy: :sliding_list,
+ next_partition_if: -> (active_partition) do
+ return false if Feature.disabled?(:lfk_automatic_partition_creation, default_enabled: :yaml)
+
+ oldest_record_in_partition = LooseForeignKeys::DeletedRecord
+ .select(:id, :created_at)
+ .for_partition(active_partition)
+ .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
+ return false if Feature.disabled?(:lfk_automatic_partition_dropping, default_enabled: :yaml)
+
+ !LooseForeignKeys::DeletedRecord
+ .for_partition(partition)
+ .status_pending
+ .exists?
+ end
scope :for_table, -> (table) { where(fully_qualified_table_name: table) }
+ scope :for_partition, -> (partition) { where(partition: partition) }
scope :consume_order, -> { order(:partition, :consume_after, :id) }
enum status: { pending: 1, processed: 2 }, _prefix: :status
def self.load_batch_for_table(table, batch_size)
- for_table(table)
+ # 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)
@@ -20,9 +50,9 @@ class LooseForeignKeys::DeletedRecord < ApplicationRecord
# Run a query for each partition to optimize the row lookup by primary key (partition, id)
update_count = 0
- all_records.group_by(&:partition).each do |partition, records_within_partition|
+ all_records.group_by(&:partition_number).each do |partition, records_within_partition|
update_count += status_pending
- .where(partition: partition)
+ .for_partition(partition)
.where(id: records_within_partition.pluck(:id))
.update_all(status: :processed)
end
diff --git a/app/models/member.rb b/app/models/member.rb
index 11f67a77ee2..90fb281abf4 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -25,7 +25,7 @@ class Member < ApplicationRecord
belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
has_one :member_task
- delegate :name, :username, :email, to: :user, prefix: true
+ delegate :name, :username, :email, :last_activity_on, to: :user, prefix: true
delegate :tasks_to_be_done, to: :member_task, allow_nil: true
validates :expires_at, allow_blank: true, future_date: true
@@ -52,6 +52,7 @@ class Member < ApplicationRecord
message: _('project bots cannot be added to other groups / projects')
},
if: :project_bot?
+ validate :access_level_inclusion
scope :with_invited_user_state, -> do
joins('LEFT JOIN users as invited_user ON invited_user.email = members.invite_email')
@@ -382,6 +383,12 @@ class Member < ApplicationRecord
private
+ def access_level_inclusion
+ return if access_level.in?(Gitlab::Access.all_values)
+
+ errors.add(:access_level, "is not included in the list")
+ end
+
def send_invite
# override in subclass
end
@@ -417,11 +424,9 @@ class Member < ApplicationRecord
def after_accept_invite
post_create_hook
- if experiment(:invite_members_for_task).enabled?
- run_after_commit_or_now do
- if member_task
- TasksToBeDone::CreateWorker.perform_async(member_task.id, created_by_id, [user_id.to_i])
- end
+ run_after_commit_or_now do
+ if member_task
+ TasksToBeDone::CreateWorker.perform_async(member_task.id, created_by_id, [user_id.to_i])
end
end
end
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index 9062a405218..1ad4cb6d368 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -6,6 +6,7 @@ class GroupMember < Member
include CreatedAtFilterable
SOURCE_TYPE = 'Namespace'
+ SOURCE_TYPE_FORMAT = /\ANamespace\z/.freeze
belongs_to :group, foreign_key: 'source_id'
alias_attribute :namespace_id, :source_id
@@ -13,9 +14,7 @@ class GroupMember < Member
# Make sure group member points only to group as it source
default_value_for :source_type, SOURCE_TYPE
- validates :source_type, format: { with: /\ANamespace\z/ }
- validates :access_level, presence: true
- validate :access_level_inclusion
+ validates :source_type, format: { with: SOURCE_TYPE_FORMAT }
default_scope { where(source_type: SOURCE_TYPE) } # rubocop:disable Cop/DefaultScope
@@ -65,12 +64,6 @@ class GroupMember < Member
super
end
- def access_level_inclusion
- return if access_level.in?(Gitlab::Access.all_values)
-
- errors.add(:access_level, "is not included in the list")
- end
-
def send_invite
run_after_commit_or_now { notification_service.invite_group_member(self, @raw_invite_token) }
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index 89b72508e84..6fc665cb87a 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -3,6 +3,7 @@
class ProjectMember < Member
extend ::Gitlab::Utils::Override
SOURCE_TYPE = 'Project'
+ SOURCE_TYPE_FORMAT = /\AProject\z/.freeze
belongs_to :project, foreign_key: 'source_id'
@@ -10,8 +11,7 @@ class ProjectMember < Member
# Make sure project member points only to project as it source
default_value_for :source_type, SOURCE_TYPE
- validates :source_type, format: { with: /\AProject\z/ }
- validates :access_level, inclusion: { in: Gitlab::Access.values }
+ validates :source_type, format: { with: SOURCE_TYPE_FORMAT }
default_scope { where(source_type: SOURCE_TYPE) } # rubocop:disable Cop/DefaultScope
scope :in_project, ->(project) { where(source_id: project.id) }
@@ -92,6 +92,13 @@ class ProjectMember < Member
private
+ override :access_level_inclusion
+ def access_level_inclusion
+ return if access_level.in?(Gitlab::Access.values)
+
+ errors.add(:access_level, "is not included in the list")
+ end
+
override :refresh_member_authorized_projects
def refresh_member_authorized_projects(blocking:)
return unless user
diff --git a/app/models/members_preloader.rb b/app/models/members_preloader.rb
index ba7e4b39989..8b8eca54550 100644
--- a/app/models/members_preloader.rb
+++ b/app/models/members_preloader.rb
@@ -13,7 +13,7 @@ class MembersPreloader
ActiveRecord::Associations::Preloader.new.preload(members, :created_by)
ActiveRecord::Associations::Preloader.new.preload(members, user: :status)
ActiveRecord::Associations::Preloader.new.preload(members, user: :u2f_registrations)
- ActiveRecord::Associations::Preloader.new.preload(members, user: :webauthn_registrations) if Feature.enabled?(:webauthn)
+ ActiveRecord::Associations::Preloader.new.preload(members, user: :webauthn_registrations) if Feature.enabled?(:webauthn, default_enabled: :yaml)
end
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 0cd8f12088c..f88aee38d67 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -506,12 +506,12 @@ class MergeRequest < ApplicationRecord
def self.reference_pattern
@reference_pattern ||= %r{
(#{Project.reference_pattern})?
- #{Regexp.escape(reference_prefix)}(?<merge_request>\d+)
+ #{Regexp.escape(reference_prefix)}#{Gitlab::Regex.merge_request}
}x
end
def self.link_reference_pattern
- @link_reference_pattern ||= super("merge_requests", /(?<merge_request>\d+)/)
+ @link_reference_pattern ||= super("merge_requests", Gitlab::Regex.merge_request)
end
def self.reference_valid?(reference)
@@ -768,7 +768,7 @@ class MergeRequest < ApplicationRecord
def diff_size
# Calling `merge_request_diff.diffs.real_size` will also perform
# highlighting, which we don't need here.
- merge_request_diff&.real_size || diff_stats&.real_size(project: project) || diffs.real_size
+ merge_request_diff&.real_size || diff_stats&.real_size || diffs.real_size
end
def modified_paths(past_merge_request_diff: nil, fallback_on_overflow: false)
@@ -1317,7 +1317,7 @@ class MergeRequest < ApplicationRecord
def default_merge_commit_message(include_description: false)
if self.target_project.merge_commit_template.present? && !include_description
- return ::Gitlab::MergeRequests::MergeCommitMessage.new(merge_request: self).message
+ return ::Gitlab::MergeRequests::CommitMessageGenerator.new(merge_request: self).merge_message
end
closes_issues_references = visible_closing_issues_for.map do |issue|
@@ -1340,6 +1340,10 @@ class MergeRequest < ApplicationRecord
end
def default_squash_commit_message
+ if self.target_project.squash_commit_template.present?
+ return ::Gitlab::MergeRequests::CommitMessageGenerator.new(merge_request: self).squash_message
+ end
+
title
end
@@ -1798,7 +1802,7 @@ class MergeRequest < ApplicationRecord
def pipeline_coverage_delta
if base_pipeline&.coverage && head_pipeline&.coverage
- '%.2f' % (head_pipeline.coverage.to_f - base_pipeline.coverage.to_f)
+ head_pipeline.coverage - base_pipeline.coverage
end
end
@@ -1880,30 +1884,7 @@ class MergeRequest < ApplicationRecord
override :ensure_metrics
def ensure_metrics
- if Feature.enabled?(:use_upsert_query_for_mr_metrics, default_enabled: :yaml)
- MergeRequest::Metrics.record!(self)
- else
- # Backward compatibility: some merge request metrics records will not have target_project_id filled in.
- # In that case the first `safe_find_or_create_by` will return false.
- # The second finder call will be eliminated in https://gitlab.com/gitlab-org/gitlab/-/issues/233507
- metrics_record = MergeRequest::Metrics.safe_find_or_create_by(merge_request_id: id, target_project_id: target_project_id) || MergeRequest::Metrics.safe_find_or_create_by(merge_request_id: id)
-
- metrics_record.tap do |metrics_record|
- # Make sure we refresh the loaded association object with the newly created/loaded item.
- # This is needed in order to have the exact functionality than before.
- #
- # Example:
- #
- # merge_request.metrics.destroy
- # merge_request.ensure_metrics
- # merge_request.metrics # should return the metrics record and not nil
- # merge_request.metrics.merge_request # should return the same MR record
-
- metrics_record.target_project_id = target_project_id
- metrics_record.association(:merge_request).target = self
- association(:metrics).target = metrics_record
- end
- end
+ MergeRequest::Metrics.record!(self)
end
def allows_reviewers?
diff --git a/app/models/merge_request_assignee.rb b/app/models/merge_request_assignee.rb
index fd8e5860040..77b46fa50f4 100644
--- a/app/models/merge_request_assignee.rb
+++ b/app/models/merge_request_assignee.rb
@@ -10,6 +10,12 @@ class MergeRequestAssignee < ApplicationRecord
scope :in_projects, ->(project_ids) { joins(:merge_request).where(merge_requests: { target_project_id: project_ids }) }
+ def set_state
+ if Feature.enabled?(:mr_attention_requests, self.merge_request&.project, default_enabled: :yaml)
+ self.state = MergeRequestReviewer.find_by(user_id: self.user_id, merge_request_id: self.merge_request_id)&.state || :attention_requested
+ end
+ end
+
def cache_key
[model_name.cache_key, id, state, assignee.cache_key]
end
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index 2516ff05bda..87afb7a489a 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -719,7 +719,7 @@ class MergeRequestDiff < ApplicationRecord
if compare.commits.empty?
new_attributes[:state] = :empty
else
- diff_collection = compare.diffs(Commit.max_diff_options(project: merge_request.project))
+ diff_collection = compare.diffs(Commit.max_diff_options)
new_attributes[:real_size] = diff_collection.real_size
if diff_collection.any?
diff --git a/app/models/merge_request_reviewer.rb b/app/models/merge_request_reviewer.rb
index 4abf0fa09f0..8c75fb2e4e6 100644
--- a/app/models/merge_request_reviewer.rb
+++ b/app/models/merge_request_reviewer.rb
@@ -6,6 +6,12 @@ class MergeRequestReviewer < ApplicationRecord
belongs_to :merge_request
belongs_to :reviewer, class_name: 'User', foreign_key: :user_id, inverse_of: :merge_request_reviewers
+ def set_state
+ if Feature.enabled?(:mr_attention_requests, self.merge_request&.project, default_enabled: :yaml)
+ self.state = MergeRequestAssignee.find_by(user_id: self.user_id, merge_request_id: self.merge_request_id)&.state || :attention_requested
+ end
+ end
+
def cache_key
[model_name.cache_key, id, state, reviewer.cache_key]
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 353a896b3fe..4b1cf2fa217 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -51,9 +51,7 @@ class Namespace < ApplicationRecord
# This should _not_ be `inverse_of: :namespace`, because that would also set
# `user.namespace` when this user creates a group with themselves as `owner`.
- # TODO: can this be moved into the UserNamespace class?
- # evaluate in issue https://gitlab.com/gitlab-org/gitlab/-/issues/341070
- belongs_to :owner, class_name: "User"
+ belongs_to :owner, class_name: 'User'
belongs_to :parent, class_name: "Namespace"
has_many :children, -> { where(type: Group.sti_name) }, class_name: "Namespace", foreign_key: :parent_id
@@ -66,6 +64,9 @@ class Namespace < ApplicationRecord
has_one :admin_note, inverse_of: :namespace
accepts_nested_attributes_for :admin_note, update_only: true
+ has_one :ci_namespace_mirror, class_name: 'Ci::NamespaceMirror'
+ has_many :sync_events, class_name: 'Namespaces::SyncEvent'
+
validates :owner, presence: true, if: ->(n) { n.owner_required? }
validates :name,
presence: true,
@@ -96,7 +97,7 @@ class Namespace < ApplicationRecord
validates :max_artifacts_size, numericality: { only_integer: true, greater_than: 0, allow_nil: true }
- validate :validate_parent_type, if: -> { Feature.enabled?(:validate_namespace_parent_type, default_enabled: :yaml) }
+ validate :validate_parent_type
# ProjectNamespaces excluded as they are not meant to appear in the group hierarchy at the moment.
validate :nesting_level_allowed, unless: -> { project_namespace? }
@@ -106,6 +107,8 @@ class Namespace < ApplicationRecord
delegate :name, to: :owner, allow_nil: true, prefix: true
delegate :avatar_url, to: :owner, allow_nil: true
+ after_save :schedule_sync_event_worker, if: -> { saved_change_to_id? || saved_change_to_parent_id? }
+
after_commit :refresh_access_of_projects_invited_groups, on: :update, if: -> { previous_changes.key?('share_with_group_lock') }
before_create :sync_share_with_group_lock_with_parent
@@ -122,12 +125,8 @@ class Namespace < ApplicationRecord
saved_change_to_name? || saved_change_to_path? || saved_change_to_parent_id?
}
- # TODO: change to `type: Namespaces::UserNamespace.sti_name` when
- # working on issue https://gitlab.com/gitlab-org/gitlab/-/issues/341070
- scope :user_namespaces, -> { where(type: [nil, Namespaces::UserNamespace.sti_name]) }
- # TODO: this can be simplified with `type != 'Project'` when working on issue
- # https://gitlab.com/gitlab-org/gitlab/-/issues/341070
- scope :without_project_namespaces, -> { where(Namespace.arel_table[:type].is_distinct_from(Namespaces::ProjectNamespace.sti_name)) }
+ scope :user_namespaces, -> { where(type: Namespaces::UserNamespace.sti_name) }
+ scope :without_project_namespaces, -> { where(Namespace.arel_table[:type].not_eq(Namespaces::ProjectNamespace.sti_name)) }
scope :sort_by_type, -> { order(Gitlab::Database.nulls_first_order(:type)) }
scope :include_route, -> { includes(:route) }
scope :by_parent, -> (parent) { where(parent_id: parent) }
@@ -615,6 +614,13 @@ class Namespace < ApplicationRecord
def enforce_minimum_path_length?
path_changed? && !project_namespace?
end
+
+ # SyncEvents are created by PG triggers (with the function `insert_namespaces_sync_event`)
+ def schedule_sync_event_worker
+ run_after_commit do
+ Namespaces::SyncEvent.enqueue_worker
+ end
+ end
end
Namespace.prepend_mod_with('Namespace')
diff --git a/app/models/namespaces/project_namespace.rb b/app/models/namespaces/project_namespace.rb
index 22ec550dee2..fbd87e3232d 100644
--- a/app/models/namespaces/project_namespace.rb
+++ b/app/models/namespaces/project_namespace.rb
@@ -7,5 +7,9 @@ module Namespaces
def self.sti_name
'Project'
end
+
+ def self.polymorphic_name
+ 'Namespaces::ProjectNamespace'
+ end
end
end
diff --git a/app/models/namespaces/sync_event.rb b/app/models/namespaces/sync_event.rb
new file mode 100644
index 00000000000..8534d8afb8c
--- /dev/null
+++ b/app/models/namespaces/sync_event.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+# This model serves to keep track of changes to the namespaces table in the main database, and allowing to safely
+# replicate these changes to other databases.
+class Namespaces::SyncEvent < ApplicationRecord
+ self.table_name = 'namespaces_sync_events'
+
+ belongs_to :namespace
+
+ scope :preload_synced_relation, -> { preload(:namespace) }
+ scope :order_by_id_asc, -> { order(id: :asc) }
+
+ def self.enqueue_worker
+ ::Namespaces::ProcessSyncEventsWorker.perform_async # rubocop:disable CodeReuse/Worker
+ end
+end
diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb
index 1736fe82ca5..5a5f2a5d063 100644
--- a/app/models/namespaces/traversal/linear.rb
+++ b/app/models/namespaces/traversal/linear.rb
@@ -64,6 +64,13 @@ module Namespaces
traversal_ids.present?
end
+ def use_traversal_ids_for_ancestors_upto?
+ return false unless use_traversal_ids?
+ return false unless Feature.enabled?(:use_traversal_ids_for_ancestors_upto, root_ancestor, default_enabled: :yaml)
+
+ traversal_ids.present?
+ end
+
def use_traversal_ids_for_root_ancestor?
return false unless Feature.enabled?(:use_traversal_ids_for_root_ancestor, default_enabled: :yaml)
@@ -114,6 +121,35 @@ module Namespaces
hierarchy_order == :desc ? traversal_ids[0..-2] : traversal_ids[0..-2].reverse
end
+ # Returns all ancestors upto but excluding the top.
+ # When no top is given, all ancestors are returned.
+ # When top is not found, returns all ancestors.
+ #
+ # This copies the behavior of the recursive method. We will deprecate
+ # this behavior soon.
+ def ancestors_upto(top = nil, hierarchy_order: nil)
+ return super unless use_traversal_ids_for_ancestors_upto?
+
+ # We can't use a default value in the method definition above because
+ # we need to preserve those specific parameters for super.
+ hierarchy_order ||= :desc
+
+ # Get all ancestor IDs inclusively between top and our parent.
+ top_index = top ? traversal_ids.find_index(top.id) : 0
+ ids = traversal_ids[top_index...-1]
+ ids_string = ids.map { |id| Integer(id) }.join(',')
+
+ # WITH ORDINALITY lets us order the result to match traversal_ids order.
+ from_sql = <<~SQL
+ unnest(ARRAY[#{ids_string}]::bigint[]) WITH ORDINALITY AS ancestors(id, ord)
+ INNER JOIN namespaces ON namespaces.id = ancestors.id
+ SQL
+
+ self.class
+ .from(Arel.sql(from_sql))
+ .order('ancestors.ord': hierarchy_order)
+ end
+
def self_and_ancestors(hierarchy_order: nil)
return super unless use_traversal_ids_for_ancestors?
@@ -168,7 +204,7 @@ module Namespaces
end
if bottom
- skope = skope.where(id: bottom.traversal_ids[0..-1])
+ skope = skope.where(id: bottom.traversal_ids)
end
# The original `with_depth` attribute in ObjectHierarchy increments as you
diff --git a/app/models/namespaces/traversal/linear_scopes.rb b/app/models/namespaces/traversal/linear_scopes.rb
index f5c44171c42..0dfb7320461 100644
--- a/app/models/namespaces/traversal/linear_scopes.rb
+++ b/app/models/namespaces/traversal/linear_scopes.rb
@@ -105,27 +105,32 @@ module Namespaces
:traversal_ids,
'LEAD (namespaces.traversal_ids, 1) OVER (ORDER BY namespaces.traversal_ids ASC) next_traversal_ids'
)
- cte = Gitlab::SQL::CTE.new(:base_cte, base)
+ base_cte = Gitlab::SQL::CTE.new(:descendants_base_cte, base)
namespaces = Arel::Table.new(:namespaces)
- records = unscoped
- .with(cte.to_arel)
- .from([cte.table, namespaces])
# Bound the search space to ourselves (optional) and descendants.
#
# WHERE (base_cte.next_traversal_ids IS NULL OR base_cte.next_traversal_ids > namespaces.traversal_ids)
# AND next_traversal_ids_sibling(base_cte.traversal_ids) > namespaces.traversal_ids
- records = records
- .where(cte.table[:next_traversal_ids].eq(nil).or(cte.table[:next_traversal_ids].gt(namespaces[:traversal_ids])))
- .where(next_sibling_func(cte.table[:traversal_ids]).gt(namespaces[:traversal_ids]))
+ records = unscoped
+ .from([base_cte.table, namespaces])
+ .where(base_cte.table[:next_traversal_ids].eq(nil).or(base_cte.table[:next_traversal_ids].gt(namespaces[:traversal_ids])))
+ .where(next_sibling_func(base_cte.table[:traversal_ids]).gt(namespaces[:traversal_ids]))
# AND base_cte.traversal_ids <= namespaces.traversal_ids
- if include_self
- records.where(cte.table[:traversal_ids].lteq(namespaces[:traversal_ids]))
- else
- records.where(cte.table[:traversal_ids].lt(namespaces[:traversal_ids]))
- end
+ records = if include_self
+ records.where(base_cte.table[:traversal_ids].lteq(namespaces[:traversal_ids]))
+ else
+ records.where(base_cte.table[:traversal_ids].lt(namespaces[:traversal_ids]))
+ end
+
+ records_cte = Gitlab::SQL::CTE.new(:descendants_cte, records)
+
+ unscoped
+ .unscope(where: [:type])
+ .with(base_cte.to_arel, records_cte.to_arel)
+ .from(records_cte.alias_to(namespaces))
end
def next_sibling_func(*args)
diff --git a/app/models/namespaces/traversal/recursive.rb b/app/models/namespaces/traversal/recursive.rb
index 8d2c5d3be5a..53eac27aa54 100644
--- a/app/models/namespaces/traversal/recursive.rb
+++ b/app/models/namespaces/traversal/recursive.rb
@@ -46,6 +46,7 @@ module Namespaces
object_hierarchy(self.class.where(id: id))
.ancestors(upto: top, hierarchy_order: hierarchy_order)
end
+ alias_method :recursive_ancestors_upto, :ancestors_upto
def self_and_ancestors(hierarchy_order: nil)
return self.class.where(id: id) unless parent_id
diff --git a/app/models/namespaces/user_namespace.rb b/app/models/namespaces/user_namespace.rb
index d4d7d352e71..14b867b2607 100644
--- a/app/models/namespaces/user_namespace.rb
+++ b/app/models/namespaces/user_namespace.rb
@@ -1,7 +1,5 @@
# frozen_string_literal: true
-# TODO: currently not created/mapped in the database, will be done in another issue
-# https://gitlab.com/gitlab-org/gitlab/-/issues/341070
module Namespaces
####################################################################
# PLEASE DO NOT OVERRIDE METHODS IN THIS CLASS!
diff --git a/app/models/note.rb b/app/models/note.rb
index cb285028203..a143c21c0f9 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -23,7 +23,7 @@ class Note < ApplicationRecord
include FromUnion
include Sortable
- cache_markdown_field :note, pipeline: :note, issuable_state_filter_enabled: true
+ cache_markdown_field :note, pipeline: :note, issuable_reference_expansion_enabled: true
redact_field :note
@@ -603,6 +603,15 @@ class Note < ApplicationRecord
})
end
+ def show_outdated_changes?
+ return false unless for_merge_request?
+ return false unless Feature.enabled?(:display_outdated_line_diff, noteable.source_project, default_enabled: :yaml)
+ return false unless system?
+ return false unless change_position&.line_range
+
+ change_position.line_range["end"] || change_position.line_range["start"]
+ end
+
private
def system_note_viewable_by?(user)
diff --git a/app/models/notification_reason.rb b/app/models/notification_reason.rb
index c227626af9e..3713be6cb91 100644
--- a/app/models/notification_reason.rb
+++ b/app/models/notification_reason.rb
@@ -6,6 +6,7 @@ class NotificationReason
OWN_ACTIVITY = 'own_activity'
ASSIGNED = 'assigned'
REVIEW_REQUESTED = 'review_requested'
+ ATTENTION_REQUESTED = 'attention_requested'
MENTIONED = 'mentioned'
SUBSCRIBED = 'subscribed'
@@ -14,6 +15,7 @@ class NotificationReason
OWN_ACTIVITY,
ASSIGNED,
REVIEW_REQUESTED,
+ ATTENTION_REQUESTED,
MENTIONED,
SUBSCRIBED
].freeze
diff --git a/app/models/packages/build_info.rb b/app/models/packages/build_info.rb
index 1b0f0ed8ffd..38245bef7a5 100644
--- a/app/models/packages/build_info.rb
+++ b/app/models/packages/build_info.rb
@@ -3,4 +3,10 @@
class Packages::BuildInfo < ApplicationRecord
belongs_to :package, inverse_of: :build_infos
belongs_to :pipeline, class_name: 'Ci::Pipeline'
+
+ scope :pluck_pipeline_ids, -> { pluck(:pipeline_id) }
+ scope :without_empty_pipelines, -> { where.not(pipeline_id: nil) }
+ scope :order_by_pipeline_id, -> (direction) { order(pipeline_id: direction) }
+ scope :with_pipeline_id_less_than, -> (pipeline_id) { where("pipeline_id < ?", pipeline_id) }
+ scope :with_pipeline_id_greater_than, -> (pipeline_id) { where("pipeline_id > ?", pipeline_id) }
end
diff --git a/app/models/packages/conan/metadatum.rb b/app/models/packages/conan/metadatum.rb
index 7ec2641177a..58af34879af 100644
--- a/app/models/packages/conan/metadatum.rb
+++ b/app/models/packages/conan/metadatum.rb
@@ -1,19 +1,19 @@
# frozen_string_literal: true
class Packages::Conan::Metadatum < ApplicationRecord
+ NONE_VALUE = '_'
+
belongs_to :package, -> { where(package_type: :conan) }, inverse_of: :conan_metadatum
validates :package, presence: true
validates :package_username,
- presence: true,
- format: { with: Gitlab::Regex.conan_recipe_component_regex }
-
- validates :package_channel,
- presence: true,
- format: { with: Gitlab::Regex.conan_recipe_component_regex }
+ :package_channel,
+ presence: true,
+ format: { with: Gitlab::Regex.conan_recipe_user_channel_regex }
validate :conan_package_type
+ validate :username_channel_none_values
def recipe
"#{package.name}/#{package.version}@#{package_username}/#{package_channel}"
@@ -31,6 +31,15 @@ class Packages::Conan::Metadatum < ApplicationRecord
package_username.tr('+', '/')
end
+ def self.validate_username_and_channel(username, channel)
+ return if (username != NONE_VALUE && channel != NONE_VALUE) ||
+ (username == NONE_VALUE && channel == NONE_VALUE)
+
+ none_field = username == NONE_VALUE ? :username : :channel
+
+ yield(none_field)
+ end
+
private
def conan_package_type
@@ -38,4 +47,10 @@ class Packages::Conan::Metadatum < ApplicationRecord
errors.add(:base, _('Package type must be Conan'))
end
end
+
+ def username_channel_none_values
+ self.class.validate_username_and_channel(package_username, package_channel) do |none_field|
+ errors.add("package_#{none_field}".to_sym, _("can't be solely blank"))
+ end
+ end
end
diff --git a/app/models/postgresql/replication_slot.rb b/app/models/postgresql/replication_slot.rb
index 1a4d3bd5794..1c38edcca61 100644
--- a/app/models/postgresql/replication_slot.rb
+++ b/app/models/postgresql/replication_slot.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Postgresql
- class ReplicationSlot < ApplicationRecord
+ class ReplicationSlot < Gitlab::Database::SharedModel
self.table_name = 'pg_replication_slots'
# Returns true if there are any replication slots in use.
diff --git a/app/models/preloaders/group_policy_preloader.rb b/app/models/preloaders/group_policy_preloader.rb
index 95d6e0b5c1f..44030140ce3 100644
--- a/app/models/preloaders/group_policy_preloader.rb
+++ b/app/models/preloaders/group_policy_preloader.rb
@@ -8,15 +8,12 @@ module Preloaders
end
def execute
- Preloaders::UserMaxAccessLevelInGroupsPreloader.new(@groups, @current_user).execute
- Preloaders::GroupRootAncestorPreloader.new(@groups, root_ancestor_preloads).execute
+ Preloaders::UserMaxAccessLevelInGroupsPreloader.new(groups, current_user).execute
end
private
- def root_ancestor_preloads
- []
- end
+ attr_reader :groups, :current_user
end
end
diff --git a/app/models/preloaders/group_root_ancestor_preloader.rb b/app/models/preloaders/group_root_ancestor_preloader.rb
deleted file mode 100644
index 3ca713d9635..00000000000
--- a/app/models/preloaders/group_root_ancestor_preloader.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-# frozen_string_literal: true
-
-module Preloaders
- class GroupRootAncestorPreloader
- def initialize(groups, root_ancestor_preloads = [])
- @groups = groups
- @root_ancestor_preloads = root_ancestor_preloads
- end
-
- def execute
- return unless ::Feature.enabled?(:use_traversal_ids, default_enabled: :yaml)
-
- # type == 'Group' condition located on subquery to prevent a filter in the query
- root_query = Namespace.joins("INNER JOIN (#{join_sql}) as root_query ON root_query.root_id = namespaces.id")
- .select('namespaces.*, root_query.id as source_id')
-
- root_query = root_query.preload(*@root_ancestor_preloads) if @root_ancestor_preloads.any?
-
- root_ancestors_by_id = root_query.group_by(&:source_id)
-
- @groups.each do |group|
- group.root_ancestor = root_ancestors_by_id[group.id].first
- end
- end
-
- private
-
- def join_sql
- Group.select('id, traversal_ids[1] as root_id').where(id: @groups.map(&:id)).to_sql
- end
- end
-end
diff --git a/app/models/project.rb b/app/models/project.rb
index 45999da7839..a751e8adeb0 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -102,6 +102,8 @@ class Project < ApplicationRecord
after_save :update_project_statistics, if: :saved_change_to_namespace_id?
+ after_save :schedule_sync_event_worker, if: -> { saved_change_to_id? || saved_change_to_namespace_id? }
+
after_save :create_import_state, if: ->(project) { project.import? && project.import_state.nil? }
after_save :save_topics
@@ -394,6 +396,9 @@ class Project < ApplicationRecord
has_many :timelogs
+ has_one :ci_project_mirror, class_name: 'Ci::ProjectMirror'
+ has_many :sync_events, class_name: 'Projects::SyncEvent'
+
accepts_nested_attributes_for :variables, allow_destroy: true
accepts_nested_attributes_for :project_feature, update_only: true
accepts_nested_attributes_for :project_setting, update_only: true
@@ -449,10 +454,11 @@ class Project < ApplicationRecord
delegate :restrict_user_defined_variables, :restrict_user_defined_variables=, to: :ci_cd_settings, allow_nil: true
delegate :actual_limits, :actual_plan_name, to: :namespace, allow_nil: true
delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?,
- :allow_merge_on_skipped_pipeline=, :has_confluence?,
+ :allow_merge_on_skipped_pipeline=, :has_confluence?, :has_shimo?,
to: :project_setting
delegate :active?, to: :prometheus_integration, allow_nil: true, prefix: true
delegate :merge_commit_template, :merge_commit_template=, to: :project_setting, allow_nil: true
+ delegate :squash_commit_template, :squash_commit_template=, to: :project_setting, allow_nil: true
delegate :log_jira_dvcs_integration_usage, :jira_dvcs_server_last_sync_at, :jira_dvcs_cloud_last_sync_at, to: :feature_usage
@@ -477,7 +483,8 @@ class Project < ApplicationRecord
validates :project_feature, presence: true
validates :namespace, presence: true
- validates :project_namespace, presence: true, if: -> { self.namespace && self.root_namespace.project_namespace_creation_enabled? }
+ validates :project_namespace, presence: true, on: :create, if: -> { self.namespace && self.root_namespace.project_namespace_creation_enabled? }
+ validates :project_namespace, presence: true, on: :update, if: -> { self.project_namespace_id_changed?(to: nil) }
validates :name, uniqueness: { scope: :namespace_id }
validates :import_url, public_url: { schemes: ->(project) { project.persisted? ? VALID_MIRROR_PROTOCOLS : VALID_IMPORT_PROTOCOLS },
ports: ->(project) { project.persisted? ? VALID_MIRROR_PORTS : VALID_IMPORT_PORTS },
@@ -575,18 +582,12 @@ class Project < ApplicationRecord
.where('rs.path LIKE ?', "#{sanitize_sql_like(path)}/%")
end
- # "enabled" here means "not disabled". It includes private features!
scope :with_feature_enabled, ->(feature) {
- access_level_attribute = ProjectFeature.arel_table[ProjectFeature.access_level_attribute(feature)]
- enabled_feature = access_level_attribute.gt(ProjectFeature::DISABLED).or(access_level_attribute.eq(nil))
-
- with_project_feature.where(enabled_feature)
+ with_project_feature.merge(ProjectFeature.with_feature_enabled(feature))
}
- # Picks a feature where the level is exactly that given.
scope :with_feature_access_level, ->(feature, level) {
- access_level_attribute = ProjectFeature.access_level_attribute(feature)
- with_project_feature.where(project_features: { access_level_attribute => level })
+ with_project_feature.merge(ProjectFeature.with_feature_access_level(feature, level))
}
# Picks projects which use the given programming language
@@ -687,37 +688,8 @@ class Project < ApplicationRecord
end
end
- # project features may be "disabled", "internal", "enabled" or "public". If "internal",
- # they are only available to team members. This scope returns projects where
- # the feature is either public, enabled, or internal with permission for the user.
- # Note: this scope doesn't enforce that the user has access to the projects, it just checks
- # that the user has access to the feature. It's important to use this scope with others
- # that checks project authorizations first (e.g. `filter_by_feature_visibility`).
- #
- # This method uses an optimised version of `with_feature_access_level` for
- # logged in users to more efficiently get private projects with the given
- # feature.
def self.with_feature_available_for_user(feature, user)
- visible = [ProjectFeature::ENABLED, ProjectFeature::PUBLIC]
-
- if user&.can_read_all_resources?
- with_feature_enabled(feature)
- elsif user
- min_access_level = ProjectFeature.required_minimum_access_level(feature)
- column = ProjectFeature.quoted_access_level_column(feature)
-
- with_project_feature
- .where("#{column} IS NULL OR #{column} IN (:public_visible) OR (#{column} = :private_visible AND EXISTS (:authorizations))",
- {
- public_visible: visible,
- private_visible: ProjectFeature::PRIVATE,
- authorizations: user.authorizations_for_projects(min_access_level: min_access_level)
- })
- else
- # This has to be added to include features whose value is nil in the db
- visible << nil
- with_feature_access_level(feature, visible)
- end
+ with_project_feature.merge(ProjectFeature.with_feature_available_for_user(feature, user))
end
def self.projects_user_can(projects, user, action)
@@ -1469,7 +1441,9 @@ class Project < ApplicationRecord
end
def disabled_integrations
- [:shimo]
+ disabled_integrations = []
+ disabled_integrations << 'shimo' unless Feature.enabled?(:shimo_integration, self)
+ disabled_integrations
end
def find_or_initialize_integration(name)
@@ -1600,6 +1574,12 @@ class Project < ApplicationRecord
oids(lfs_objects, oids: oids)
end
+ def lfs_objects_oids_from_fork_source(oids: [])
+ return [] unless forked?
+
+ oids(fork_source.lfs_objects, oids: oids)
+ end
+
def personal?
!group
end
@@ -2747,6 +2727,12 @@ class Project < ApplicationRecord
end
end
+ def remove_project_authorizations(user_ids, per_batch = 1000)
+ user_ids.each_slice(per_batch) do |user_ids_batch|
+ project_authorizations.where(user_id: user_ids_batch).delete_all
+ end
+ end
+
private
# overridden in EE
@@ -2957,6 +2943,13 @@ class Project < ApplicationRecord
project_namespace.shared_runners_enabled = shared_runners_enabled
project_namespace.visibility_level = visibility_level
end
+
+ # SyncEvents are created by PG triggers (with the function `insert_projects_sync_event`)
+ def schedule_sync_event_worker
+ run_after_commit do
+ Projects::SyncEvent.enqueue_worker
+ end
+ end
end
Project.prepend_mod_with('Project')
diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb
index fed19a37a16..c76332b21cd 100644
--- a/app/models/project_authorization.rb
+++ b/app/models/project_authorization.rb
@@ -17,20 +17,6 @@ class ProjectAuthorization < ApplicationRecord
.group(:project_id)
end
- def self.insert_authorizations(rows, per_batch = 1000)
- rows.each_slice(per_batch) do |slice|
- tuples = slice.map do |tuple|
- tuple.map { |value| connection.quote(value) }
- end
-
- connection.execute <<-EOF.strip_heredoc
- INSERT INTO project_authorizations (user_id, project_id, access_level)
- VALUES #{tuples.map { |tuple| "(#{tuple.join(', ')})" }.join(', ')}
- ON CONFLICT DO NOTHING
- EOF
- end
- end
-
# This method overrides its ActiveRecord's version in order to work correctly
# with composite primary keys and fix the tests for Rails 6.1
#
@@ -39,6 +25,12 @@ 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 = 1000)
+ attributes.each_slice(per_batch) do |attributes_batch|
+ insert_all(attributes_batch)
+ end
+ end
end
ProjectAuthorization.prepend_mod_with('ProjectAuthorization')
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index 676c28d5e1b..0d3e50837ab 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -83,6 +83,52 @@ class ProjectFeature < ApplicationRecord
end
end
+ # "enabled" here means "not disabled". It includes private features!
+ scope :with_feature_enabled, ->(feature) {
+ feature_access_level_attribute = arel_table[access_level_attribute(feature)]
+ enabled_feature = feature_access_level_attribute.gt(DISABLED).or(feature_access_level_attribute.eq(nil))
+
+ where(enabled_feature)
+ }
+
+ # Picks a feature where the level is exactly that given.
+ scope :with_feature_access_level, ->(feature, level) {
+ feature_access_level_attribute = access_level_attribute(feature)
+ where(project_features: { feature_access_level_attribute => level })
+ }
+
+ # project features may be "disabled", "internal", "enabled" or "public". If "internal",
+ # they are only available to team members. This scope returns features where
+ # the feature is either public, enabled, or internal with permission for the user.
+ # Note: this scope doesn't enforce that the user has access to the projects, it just checks
+ # that the user has access to the feature. It's important to use this scope with others
+ # that checks project authorizations first (e.g. `filter_by_feature_visibility`).
+ #
+ # This method uses an optimised version of `with_feature_access_level` for
+ # logged in users to more efficiently get private projects with the given
+ # feature.
+ def self.with_feature_available_for_user(feature, user)
+ visible = [ENABLED, PUBLIC]
+
+ if user&.can_read_all_resources?
+ with_feature_enabled(feature)
+ elsif user
+ min_access_level = required_minimum_access_level(feature)
+ column = quoted_access_level_column(feature)
+
+ where("#{column} IS NULL OR #{column} IN (:public_visible) OR (#{column} = :private_visible AND EXISTS (:authorizations))",
+ {
+ public_visible: visible,
+ private_visible: PRIVATE,
+ authorizations: user.authorizations_for_projects(min_access_level: min_access_level, related_project_column: 'project_features.project_id')
+ })
+ else
+ # This has to be added to include features whose value is nil in the db
+ visible << nil
+ with_feature_access_level(feature, visible)
+ end
+ end
+
def public_pages?
return true unless Gitlab.config.pages.access_control
diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb
index 6c8d2226bc9..fc834286876 100644
--- a/app/models/project_setting.rb
+++ b/app/models/project_setting.rb
@@ -13,6 +13,7 @@ class ProjectSetting < ApplicationRecord
self.primary_key = :project_id
validates :merge_commit_template, length: { maximum: 500 }
+ validates :squash_commit_template, length: { maximum: 500 }
def squash_enabled_by_default?
%w[always default_on].include?(squash_option)
diff --git a/app/models/projects/sync_event.rb b/app/models/projects/sync_event.rb
new file mode 100644
index 00000000000..5221b00c55f
--- /dev/null
+++ b/app/models/projects/sync_event.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+# This model serves to keep track of changes to the namespaces table in the main database as they relate to projects,
+# allowing to safely replicate changes to other databases.
+class Projects::SyncEvent < ApplicationRecord
+ self.table_name = 'projects_sync_events'
+
+ belongs_to :project
+
+ scope :preload_synced_relation, -> { preload(:project) }
+ scope :order_by_id_asc, -> { order(id: :asc) }
+
+ def self.enqueue_worker
+ ::Projects::ProcessSyncEventsWorker.perform_async # rubocop:disable CodeReuse/Worker
+ end
+end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 47482f04bca..645cc9773bd 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -519,6 +519,8 @@ class Repository
raw_repository.batch_blobs(items, blob_size_limit: blob_size_limit).map do |blob|
Blob.decorate(blob, container)
end
+ rescue Gitlab::Git::Repository::NoRepository
+ []
end
def root_ref
diff --git a/app/models/serverless/domain.rb b/app/models/serverless/domain.rb
index 2fef3b66b08..164f93afa9a 100644
--- a/app/models/serverless/domain.rb
+++ b/app/models/serverless/domain.rb
@@ -37,7 +37,7 @@ module Serverless
'a1',
serverless_domain_cluster.uuid[2..-3],
'f2',
- serverless_domain_cluster.uuid[-2..-1]
+ serverless_domain_cluster.uuid[-2..]
].join
end
end
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index dd76f2c3c84..6a8123b3c08 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -98,87 +98,115 @@ class Snippet < ApplicationRecord
mode: :per_attribute_iv,
algorithm: 'aes-256-cbc'
- def self.with_optional_visibility(value = nil)
- if value
- where(visibility_level: value)
- else
- all
+ class << self
+ # Searches for snippets with a matching title, description or file name.
+ #
+ # This method uses ILIKE on PostgreSQL.
+ #
+ # query - The search query as a String.
+ #
+ # Returns an ActiveRecord::Relation.
+ def search(query)
+ fuzzy_search(query, [:title, :description, :file_name])
end
- end
- def self.only_personal_snippets
- where(project_id: nil)
- end
+ def parent_class
+ ::Project
+ end
- def self.only_project_snippets
- where.not(project_id: nil)
- end
+ def sanitized_file_name(file_name)
+ file_name.gsub(/[^a-zA-Z0-9_\-\.]+/, '')
+ end
- def self.only_include_projects_visible_to(current_user = nil)
- levels = Gitlab::VisibilityLevel.levels_for_user(current_user)
+ def with_optional_visibility(value = nil)
+ if value
+ where(visibility_level: value)
+ else
+ all
+ end
+ end
- joins(:project).where(projects: { visibility_level: levels })
- end
+ def only_personal_snippets
+ where(project_id: nil)
+ end
- def self.only_include_projects_with_snippets_enabled(include_private: false)
- column = ProjectFeature.access_level_attribute(:snippets)
- levels = [ProjectFeature::ENABLED, ProjectFeature::PUBLIC]
+ def only_project_snippets
+ where.not(project_id: nil)
+ end
- levels << ProjectFeature::PRIVATE if include_private
+ def only_include_projects_visible_to(current_user = nil)
+ levels = Gitlab::VisibilityLevel.levels_for_user(current_user)
- joins(project: :project_feature)
- .where(project_features: { column => levels })
- end
+ joins(:project).where(projects: { visibility_level: levels })
+ end
- def self.only_include_authorized_projects(current_user)
- where(
- 'EXISTS (?)',
- ProjectAuthorization
- .select(1)
- .where('project_id = snippets.project_id')
- .where(user_id: current_user.id)
- )
- end
+ def only_include_projects_with_snippets_enabled(include_private: false)
+ column = ProjectFeature.access_level_attribute(:snippets)
+ levels = [ProjectFeature::ENABLED, ProjectFeature::PUBLIC]
- def self.for_project_with_user(project, user = nil)
- return none unless project.snippets_visible?(user)
+ levels << ProjectFeature::PRIVATE if include_private
- if user && project.team.member?(user)
- project.snippets
- else
- project.snippets.public_to_user(user)
+ joins(project: :project_feature)
+ .where(project_features: { column => levels })
end
- end
- def self.visible_to_or_authored_by(user)
- query = where(visibility_level: Gitlab::VisibilityLevel.levels_for_user(user))
- query.or(where(author_id: user.id))
- end
+ def only_include_authorized_projects(current_user)
+ where(
+ 'EXISTS (?)',
+ ProjectAuthorization
+ .select(1)
+ .where('project_id = snippets.project_id')
+ .where(user_id: current_user.id)
+ )
+ end
- def self.reference_prefix
- '$'
- end
+ def for_project_with_user(project, user = nil)
+ return none unless project.snippets_visible?(user)
+
+ if user && project.team.member?(user)
+ project.snippets
+ else
+ project.snippets.public_to_user(user)
+ end
+ end
- # Pattern used to extract `$123` snippet references from text
- #
- # This pattern supports cross-project references.
- def self.reference_pattern
- @reference_pattern ||= %r{
+ def visible_to_or_authored_by(user)
+ query = where(visibility_level: Gitlab::VisibilityLevel.levels_for_user(user))
+ query.or(where(author_id: user.id))
+ end
+
+ def reference_prefix
+ '$'
+ end
+
+ # Pattern used to extract `$123` snippet references from text
+ #
+ # This pattern supports cross-project references.
+ def reference_pattern
+ @reference_pattern ||= %r{
(#{Project.reference_pattern})?
#{Regexp.escape(reference_prefix)}(?<snippet>\d+)
}x
- end
+ end
- def self.link_reference_pattern
- @link_reference_pattern ||= super("snippets", /(?<snippet>\d+)/)
- end
+ def link_reference_pattern
+ @link_reference_pattern ||= super("snippets", /(?<snippet>\d+)/)
+ end
- def self.find_by_id_and_project(id:, project:)
- Snippet.find_by(id: id, project: project)
- end
+ def find_by_id_and_project(id:, project:)
+ Snippet.find_by(id: id, project: project)
+ end
+
+ def find_by_project_title_trunc_created_at(project, title, created_at)
+ where(project: project, title: title)
+ .find_by(
+ "date_trunc('second', created_at at time zone :tz) at time zone :tz = :created_at",
+ tz: created_at.zone, created_at: created_at)
+ end
- def self.max_file_limit
- MAX_FILE_COUNT
+ def max_file_limit
+ MAX_FILE_COUNT
+ end
end
def initialize(attributes = {})
@@ -230,10 +258,6 @@ class Snippet < ApplicationRecord
super.to_s
end
- def self.sanitized_file_name(file_name)
- file_name.gsub(/[^a-zA-Z0-9_\-\.]+/, '')
- end
-
def visibility_level_field
:visibility_level
end
@@ -371,23 +395,6 @@ class Snippet < ApplicationRecord
def multiple_files?
list_files.size > 1
end
-
- class << self
- # Searches for snippets with a matching title, description or file name.
- #
- # This method uses ILIKE on PostgreSQL.
- #
- # query - The search query as a String.
- #
- # Returns an ActiveRecord::Relation.
- def search(query)
- fuzzy_search(query, [:title, :description, :file_name])
- end
-
- def parent_class
- ::Project
- end
- end
end
Snippet.prepend_mod_with('Snippet')
diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb
index 749b9dce97c..7b13109dbc4 100644
--- a/app/models/system_note_metadata.rb
+++ b/app/models/system_note_metadata.rb
@@ -24,6 +24,7 @@ class SystemNoteMetadata < ApplicationRecord
opened closed merged duplicate locked unlocked outdated reviewer
tag due_date pinned_embed cherry_pick health_status approved unapproved
status alert_issue_added relate unrelate new_alert_added severity
+ attention_requested attention_request_removed
].freeze
validates :note, presence: true, unless: :importing?
diff --git a/app/models/todo.rb b/app/models/todo.rb
index cfcb2201b80..dc436570f52 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -3,6 +3,7 @@
class Todo < ApplicationRecord
include Sortable
include FromUnion
+ include EachBatch
# Time to wait for todos being removed when not visible for user anymore.
# Prevents TODOs being removed by mistake, for example, removing access from a user
diff --git a/app/models/u2f_registration.rb b/app/models/u2f_registration.rb
index 65dc7a47533..7c01aa7a420 100644
--- a/app/models/u2f_registration.rb
+++ b/app/models/u2f_registration.rb
@@ -12,11 +12,7 @@ class U2fRegistration < ApplicationRecord
converter = Gitlab::Auth::U2fWebauthnConverter.new(self)
WebauthnRegistration.create!(converter.convert)
rescue StandardError => ex
- Gitlab::AppJsonLogger.error(
- event: 'u2f_migration',
- error: ex.class.name,
- backtrace: ::Gitlab::BacktraceCleaner.clean_backtrace(ex.backtrace),
- message: "U2F to WebAuthn conversion failed")
+ Gitlab::ErrorTracking.track_exception(ex, u2f_registration_id: self.id)
end
def update_webauthn_registration
diff --git a/app/models/user.rb b/app/models/user.rb
index 3ab5b7ee364..a39da30220a 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -27,6 +27,7 @@ class User < ApplicationRecord
include HasUserType
include Gitlab::Auth::Otp::Fortinet
include RestrictedSignup
+ include StripAttribute
DEFAULT_NOTIFICATION_LEVEL = :participating
@@ -112,10 +113,8 @@ class User < ApplicationRecord
#
# Namespace for personal projects
- # TODO: change to `:namespace, -> { where(type: Namespaces::UserNamespace.sti_name}, class_name: 'Namespaces::UserNamespace'...`
- # when working on issue https://gitlab.com/gitlab-org/gitlab/-/issues/341070
has_one :namespace,
- -> { where(type: [nil, Namespaces::UserNamespace.sti_name]) },
+ -> { where(type: Namespaces::UserNamespace.sti_name) },
dependent: :destroy, # rubocop:disable Cop/ActiveRecordDependent
foreign_key: :owner_id,
inverse_of: :owner,
@@ -189,8 +188,8 @@ class User < ApplicationRecord
has_one :abuse_report, dependent: :destroy, foreign_key: :user_id # rubocop:disable Cop/ActiveRecordDependent
has_many :reported_abuse_reports, dependent: :destroy, foreign_key: :reporter_id, class_name: "AbuseReport" # rubocop:disable Cop/ActiveRecordDependent
has_many :spam_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- has_many :builds, dependent: :nullify, class_name: 'Ci::Build' # rubocop:disable Cop/ActiveRecordDependent
- has_many :pipelines, dependent: :nullify, class_name: 'Ci::Pipeline' # rubocop:disable Cop/ActiveRecordDependent
+ has_many :builds, class_name: 'Ci::Build'
+ has_many :pipelines, class_name: 'Ci::Pipeline'
has_many :todos
has_many :notification_settings
has_many :award_emoji, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -206,7 +205,7 @@ class User < ApplicationRecord
has_many :bulk_imports
has_many :custom_attributes, class_name: 'UserCustomAttribute'
- has_many :callouts, class_name: 'UserCallout'
+ has_many :callouts, class_name: 'Users::Callout'
has_many :group_callouts, class_name: 'Users::GroupCallout'
has_many :term_agreements
belongs_to :accepted_term, class_name: 'ApplicationSetting::Term'
@@ -391,8 +390,10 @@ class User < ApplicationRecord
# this state transition object in order to do a rollback.
# For this reason the tradeoff is to disable this cop.
after_transition any => :blocked do |user|
- Ci::DropPipelineService.new.execute_async_for_all(user.pipelines, :user_blocked, user)
- Ci::DisableUserPipelineSchedulesService.new.execute(user)
+ user.run_after_commit do
+ Ci::DropPipelineService.new.execute_async_for_all(user.pipelines, :user_blocked, user)
+ Ci::DisableUserPipelineSchedulesService.new.execute(user)
+ end
end
after_transition any => :deactivated do |user|
@@ -466,6 +467,8 @@ class User < ApplicationRecord
scope :by_provider_and_extern_uid, ->(provider, extern_uid) { joins(:identities).merge(Identity.with_extern_uid(provider, extern_uid)) }
scope :get_ids_by_username, -> (username) { where(username: username).pluck(:id) }
+ strip_attributes! :name
+
def preferred_language
read_attribute('preferred_language') ||
I18n.default_locale.to_s.presence_in(Gitlab::I18n.available_locales) ||
@@ -844,10 +847,6 @@ class User < ApplicationRecord
# Instance methods
#
- def default_dashboard?
- dashboard == self.class.column_defaults['dashboard']
- end
-
def full_path
username
end
@@ -915,6 +914,8 @@ class User < ApplicationRecord
end
def two_factor_u2f_enabled?
+ return false if Feature.enabled?(:webauthn, default_enabled: :yaml)
+
if u2f_registrations.loaded?
u2f_registrations.any?
else
@@ -927,7 +928,7 @@ class User < ApplicationRecord
end
def two_factor_webauthn_enabled?
- return false unless Feature.enabled?(:webauthn)
+ return false unless Feature.enabled?(:webauthn, default_enabled: :yaml)
(webauthn_registrations.loaded? && webauthn_registrations.any?) || (!webauthn_registrations.loaded? && webauthn_registrations.exists?)
end
@@ -989,11 +990,7 @@ class User < ApplicationRecord
# Returns the groups a user is a member of, either directly or through a parent group
def membership_groups
- if Feature.enabled?(:linear_user_membership_groups, self, default_enabled: :yaml)
- groups.self_and_descendants
- else
- Gitlab::ObjectHierarchy.new(groups).base_and_descendants
- end
+ groups.self_and_descendants
end
# Returns a relation of groups the user has access to, including their parent
@@ -1615,7 +1612,7 @@ class User < ApplicationRecord
.select('ci_runners.*')
group_runners = Ci::RunnerNamespace
- .where(namespace_id: Gitlab::ObjectHierarchy.new(owned_groups).base_and_descendants.select(:id))
+ .where(namespace_id: owned_groups.self_and_descendant_ids)
.joins(:runner)
.select('ci_runners.*')
@@ -1796,7 +1793,7 @@ class User < ApplicationRecord
# we do this on read since migrating all existing users is not a feasible
# solution.
def feed_token
- Gitlab::CurrentSettings.disable_feed_token ? nil : ensure_feed_token!
+ ensure_feed_token! unless Gitlab::CurrentSettings.disable_feed_token
end
# Each existing user needs to have a `static_object_token`.
@@ -1806,6 +1803,14 @@ class User < ApplicationRecord
ensure_static_object_token!
end
+ def enabled_static_object_token
+ static_object_token if Gitlab::CurrentSettings.static_objects_external_storage_enabled?
+ end
+
+ def enabled_incoming_email_token
+ incoming_email_token if Gitlab::IncomingEmail.supports_issue_creation?
+ end
+
def sync_attribute?(attribute)
return true if ldap_user? && attribute == :email
@@ -1949,7 +1954,7 @@ class User < ApplicationRecord
end
def find_or_initialize_callout(feature_name)
- callouts.find_or_initialize_by(feature_name: ::UserCallout.feature_names[feature_name])
+ callouts.find_or_initialize_by(feature_name: ::Users::Callout.feature_names[feature_name])
end
def find_or_initialize_group_callout(feature_name, group_id)
@@ -2160,12 +2165,7 @@ class User < ApplicationRecord
project_creation_levels << nil
end
- if Feature.enabled?(:linear_user_groups_with_developer_maintainer_project_access, self, default_enabled: :yaml)
- developer_groups.self_and_descendants.where(project_creation_level: project_creation_levels)
- else
- developer_groups_hierarchy = ::Gitlab::ObjectHierarchy.new(developer_groups).base_and_descendants
- ::Group.where(id: developer_groups_hierarchy.select(:id), project_creation_level: project_creation_levels)
- end
+ developer_groups.self_and_descendants.where(project_creation_level: project_creation_levels)
end
def no_recent_activity?
diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb
deleted file mode 100644
index b990aedd4f8..00000000000
--- a/app/models/user_callout.rb
+++ /dev/null
@@ -1,47 +0,0 @@
-# frozen_string_literal: true
-
-class UserCallout < ApplicationRecord
- include Calloutable
-
- enum feature_name: {
- gke_cluster_integration: 1,
- gcp_signup_offer: 2,
- cluster_security_warning: 3,
- ultimate_trial: 4, # EE-only
- geo_enable_hashed_storage: 5, # EE-only
- geo_migrate_hashed_storage: 6, # EE-only
- canary_deployment: 7, # EE-only
- gold_trial_billings: 8, # EE-only
- suggest_popover_dismissed: 9,
- tabs_position_highlight: 10,
- threat_monitoring_info: 11, # EE-only
- two_factor_auth_recovery_settings_check: 12, # EE-only
- web_ide_alert_dismissed: 16, # no longer in use
- active_user_count_threshold: 18, # EE-only
- buy_pipeline_minutes_notification_dot: 19, # EE-only
- personal_access_token_expiry: 21, # EE-only
- suggest_pipeline: 22,
- customize_homepage: 23,
- feature_flags_new_version: 24,
- registration_enabled_callout: 25,
- new_user_signups_cap_reached: 26, # EE-only
- unfinished_tag_cleanup_callout: 27,
- eoa_bronze_plan_banner: 28, # EE-only
- pipeline_needs_banner: 29,
- pipeline_needs_hover_tip: 30,
- web_ide_ci_environments_guidance: 31,
- security_configuration_upgrade_banner: 32,
- cloud_licensing_subscription_activation_banner: 33, # EE-only
- trial_status_reminder_d14: 34, # EE-only
- trial_status_reminder_d3: 35, # EE-only
- security_configuration_devops_alert: 36, # EE-only
- profile_personal_access_token_expiry: 37, # EE-only
- terraform_notification_dismissed: 38,
- security_newsletter_callout: 39
- }
-
- validates :feature_name,
- presence: true,
- uniqueness: { scope: :user_id },
- inclusion: { in: UserCallout.feature_names.keys }
-end
diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb
index 6b0ed89c683..3787ad1c380 100644
--- a/app/models/user_detail.rb
+++ b/app/models/user_detail.rb
@@ -2,9 +2,6 @@
class UserDetail < ApplicationRecord
extend ::Gitlab::Utils::Override
- include IgnorableColumns
-
- ignore_columns %i[bio_html cached_markdown_version], remove_with: '14.5', remove_after: '2021-10-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/users/callout.rb b/app/models/users/callout.rb
new file mode 100644
index 00000000000..9ce0beed3b3
--- /dev/null
+++ b/app/models/users/callout.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module Users
+ class Callout < ApplicationRecord
+ include Users::Calloutable
+
+ self.table_name = 'user_callouts'
+
+ enum feature_name: {
+ gke_cluster_integration: 1,
+ gcp_signup_offer: 2,
+ cluster_security_warning: 3,
+ ultimate_trial: 4, # EE-only
+ geo_enable_hashed_storage: 5, # EE-only
+ geo_migrate_hashed_storage: 6, # EE-only
+ canary_deployment: 7, # EE-only
+ gold_trial_billings: 8, # EE-only
+ suggest_popover_dismissed: 9,
+ tabs_position_highlight: 10,
+ threat_monitoring_info: 11, # EE-only
+ two_factor_auth_recovery_settings_check: 12, # EE-only
+ web_ide_alert_dismissed: 16, # no longer in use
+ active_user_count_threshold: 18, # EE-only
+ buy_pipeline_minutes_notification_dot: 19, # EE-only
+ personal_access_token_expiry: 21, # EE-only
+ suggest_pipeline: 22,
+ feature_flags_new_version: 24,
+ registration_enabled_callout: 25,
+ new_user_signups_cap_reached: 26, # EE-only
+ unfinished_tag_cleanup_callout: 27,
+ eoa_bronze_plan_banner: 28, # EE-only
+ pipeline_needs_banner: 29,
+ pipeline_needs_hover_tip: 30,
+ web_ide_ci_environments_guidance: 31,
+ security_configuration_upgrade_banner: 32,
+ cloud_licensing_subscription_activation_banner: 33, # EE-only
+ trial_status_reminder_d14: 34, # EE-only
+ trial_status_reminder_d3: 35, # EE-only
+ security_configuration_devops_alert: 36, # EE-only
+ profile_personal_access_token_expiry: 37, # EE-only
+ terraform_notification_dismissed: 38,
+ security_newsletter_callout: 39,
+ verification_reminder: 40 # EE-only
+ }
+
+ validates :feature_name,
+ presence: true,
+ uniqueness: { scope: :user_id },
+ inclusion: { in: Users::Callout.feature_names.keys }
+ end
+end
diff --git a/app/models/users/calloutable.rb b/app/models/users/calloutable.rb
new file mode 100644
index 00000000000..280a819e4d5
--- /dev/null
+++ b/app/models/users/calloutable.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Users
+ module Calloutable
+ extend ActiveSupport::Concern
+
+ included do
+ belongs_to :user
+
+ validates :user, presence: true
+ end
+
+ def dismissed_after?(dismissed_after)
+ dismissed_at > dismissed_after
+ end
+ end
+end
diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb
index 540d1a1d242..da9b95fd718 100644
--- a/app/models/users/group_callout.rb
+++ b/app/models/users/group_callout.rb
@@ -2,7 +2,7 @@
module Users
class GroupCallout < ApplicationRecord
- include Calloutable
+ include Users::Calloutable
self.table_name = 'user_group_callouts'
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index 25438581f2f..3dbbbcdfe23 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -338,7 +338,7 @@ class WikiPage
current_dirname = File.dirname(title)
if persisted?
- return title[1..-1] if current_dirname == '/'
+ return title[1..] if current_dirname == '/'
return File.join([directory.presence, title].compact) if current_dirname == '.'
end
diff --git a/app/models/work_item/type.rb b/app/models/work_item/type.rb
index 7038beadd62..3acb9c0011c 100644
--- a/app/models/work_item/type.rb
+++ b/app/models/work_item/type.rb
@@ -15,7 +15,8 @@ class WorkItem::Type < ApplicationRecord
issue: { name: 'Issue', icon_name: 'issue-type-issue', enum_value: 0 },
incident: { name: 'Incident', icon_name: 'issue-type-incident', enum_value: 1 },
test_case: { name: 'Test Case', icon_name: 'issue-type-test-case', enum_value: 2 }, ## EE-only
- requirement: { name: 'Requirement', icon_name: 'issue-type-requirements', enum_value: 3 } ## EE-only
+ requirement: { name: 'Requirement', icon_name: 'issue-type-requirements', enum_value: 3 }, ## EE-only
+ task: { name: 'Task', icon_name: 'issue-type-task', enum_value: 4 }
}.freeze
cache_markdown_field :description, pipeline: :single_line
@@ -42,6 +43,10 @@ class WorkItem::Type < ApplicationRecord
default_by_type(:issue)
end
+ def self.allowed_types_for_issues
+ base_types.keys.excluding('task')
+ end
+
private
def strip_whitespace
diff --git a/app/models/x509_certificate.rb b/app/models/x509_certificate.rb
index 428fd336a32..2c1d0110b7c 100644
--- a/app/models/x509_certificate.rb
+++ b/app/models/x509_certificate.rb
@@ -13,7 +13,7 @@ class X509Certificate < ApplicationRecord
belongs_to :x509_issuer, class_name: 'X509Issuer', foreign_key: 'x509_issuer_id', optional: false
- has_many :x509_commit_signatures, inverse_of: 'x509_certificate'
+ has_many :x509_commit_signatures, class_name: 'CommitSignatures::X509CommitSignature', inverse_of: 'x509_certificate'
# rfc 5280 - 4.2.1.2 Subject Key Identifier
validates :subject_key_identifier, presence: true, format: { with: /\A(\h{2}:){19}\h{2}\z/ }
diff --git a/app/models/x509_commit_signature.rb b/app/models/x509_commit_signature.rb
deleted file mode 100644
index 57d809f7cfb..00000000000
--- a/app/models/x509_commit_signature.rb
+++ /dev/null
@@ -1,48 +0,0 @@
-# frozen_string_literal: true
-
-class X509CommitSignature < ApplicationRecord
- include ShaAttribute
-
- sha_attribute :commit_sha
-
- enum verification_status: {
- unverified: 0,
- verified: 1
- }
-
- belongs_to :project, class_name: 'Project', foreign_key: 'project_id', optional: false
- belongs_to :x509_certificate, class_name: 'X509Certificate', foreign_key: 'x509_certificate_id', optional: false
-
- validates :commit_sha, presence: true
- validates :project_id, presence: true
- validates :x509_certificate_id, presence: true
-
- scope :by_commit_sha, ->(shas) { where(commit_sha: shas) }
-
- def self.safe_create!(attributes)
- create_with(attributes)
- .safe_find_or_create_by!(commit_sha: attributes[:commit_sha])
- end
-
- # Find commits that are lacking a signature in the database at present
- def self.unsigned_commit_shas(commit_shas)
- return [] if commit_shas.empty?
-
- signed = by_commit_sha(commit_shas).pluck(:commit_sha)
- commit_shas - signed
- end
-
- def commit
- project.commit(commit_sha)
- end
-
- def x509_commit
- return unless commit
-
- Gitlab::X509::Commit.new(commit)
- end
-
- def user
- commit.committer
- end
-end
diff --git a/app/policies/clusters/agents/activity_event_policy.rb b/app/policies/clusters/agents/activity_event_policy.rb
new file mode 100644
index 00000000000..25fe1570b4b
--- /dev/null
+++ b/app/policies/clusters/agents/activity_event_policy.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ class ActivityEventPolicy < BasePolicy
+ alias_method :event, :subject
+
+ delegate { event.agent }
+ end
+ end
+end
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 833d5b9bd34..5c4990ffd9b 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class GroupPolicy < BasePolicy
+class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
include FindGroupProjects
desc "Group is public"
@@ -77,6 +77,11 @@ class GroupPolicy < BasePolicy
condition(:crm_enabled, score: 0, scope: :subject) { Feature.enabled?(:customer_relations, @subject) }
+ with_scope :subject
+ condition(:group_runner_registration_allowed, score: 0, scope: :subject) do
+ Feature.disabled?(:runner_registration_control) || Gitlab::CurrentSettings.valid_runner_registrars.include?('group')
+ end
+
rule { can?(:read_group) & design_management_enabled }.policy do
enable :read_design_activity
end
@@ -157,6 +162,7 @@ class GroupPolicy < BasePolicy
enable :destroy_package
enable :create_projects
enable :admin_pipeline
+ enable :admin_group_runners
enable :admin_build
enable :read_cluster
enable :add_cluster
@@ -199,6 +205,10 @@ class GroupPolicy < BasePolicy
enable :read_nested_project_resources
end
+ rule { can?(:admin_group_runners) }.policy do
+ enable :register_group_runners
+ end
+
rule { owner }.enable :create_subgroup
rule { maintainer & maintainer_can_create_group }.enable :create_subgroup
@@ -261,6 +271,10 @@ class GroupPolicy < BasePolicy
prevent :admin_crm_organization
end
+ rule { ~group_runner_registration_allowed }.policy do
+ prevent :register_group_runners
+ end
+
def access_level(for_any_session: false)
return GroupMember::NO_ACCESS if @user.nil?
return GroupMember::NO_ACCESS unless user_is_user?
diff --git a/app/policies/namespace_policy.rb b/app/policies/namespace_policy.rb
index 0cf1bcb9737..33c90d49f68 100644
--- a/app/policies/namespace_policy.rb
+++ b/app/policies/namespace_policy.rb
@@ -1,9 +1,10 @@
# frozen_string_literal: true
-class NamespacePolicy < ::Namespaces::UserNamespacePolicy
+class NamespacePolicy < BasePolicy
# NamespacePolicy has been traditionally for user namespaces.
# So these policies have been moved into Namespaces::UserNamespacePolicy.
# Once the user namespace conversion is complete, we can look at
# either removing this file or locating common namespace policy items
# here.
+ # See https://gitlab.com/groups/gitlab-org/-/epics/6689 for details
end
diff --git a/app/policies/namespaces/group_project_namespace_shared_policy.rb b/app/policies/namespaces/group_project_namespace_shared_policy.rb
new file mode 100644
index 00000000000..1ed9f05306f
--- /dev/null
+++ b/app/policies/namespaces/group_project_namespace_shared_policy.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Namespaces
+ class GroupProjectNamespaceSharedPolicy < ::NamespacePolicy
+ # Nothing here at the moment, but as we move policies from ProjectPolicy to ProjectNamespacePolicy,
+ # anything common with GroupPolicy but not with UserNamespacePolicy can go in here.
+ # See https://gitlab.com/groups/gitlab-org/-/epics/6689
+ end
+end
diff --git a/app/policies/namespaces/project_namespace_policy.rb b/app/policies/namespaces/project_namespace_policy.rb
index bc08a7a45ed..33aadc7c411 100644
--- a/app/policies/namespaces/project_namespace_policy.rb
+++ b/app/policies/namespaces/project_namespace_policy.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Namespaces
- class ProjectNamespacePolicy < BasePolicy
+ class ProjectNamespacePolicy < Namespaces::GroupProjectNamespaceSharedPolicy
# For now users are not granted any permissions on project namespace
# as it's completely hidden to them. When we start using project
# namespaces in queries, we will have to extend this policy.
diff --git a/app/policies/namespaces/user_namespace_policy.rb b/app/policies/namespaces/user_namespace_policy.rb
index f8b285e5312..09b0f5d608d 100644
--- a/app/policies/namespaces/user_namespace_policy.rb
+++ b/app/policies/namespaces/user_namespace_policy.rb
@@ -1,10 +1,9 @@
# frozen_string_literal: true
module Namespaces
- class UserNamespacePolicy < BasePolicy
+ class UserNamespacePolicy < ::NamespacePolicy
rule { anonymous }.prevent_all
- condition(:personal_project, scope: :subject) { @subject.kind == 'user' }
condition(:can_create_personal_project, scope: :user) { @user.can_create_project? }
condition(:owner) { @subject.owner == @user }
@@ -19,7 +18,7 @@ module Namespaces
enable :read_package_settings
end
- rule { personal_project & ~can_create_personal_project }.prevent :create_projects
+ rule { ~can_create_personal_project }.prevent :create_projects
rule { (owner | admin) & can?(:create_projects) }.enable :transfer_projects
end
diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb
index 5835a77d0b9..3bd92ebc942 100644
--- a/app/presenters/blob_presenter.rb
+++ b/app/presenters/blob_presenter.rb
@@ -15,19 +15,8 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
Gitlab::Highlight.highlight(
blob.path,
- limited_blob_data(to: to),
- language: language,
- plain: plain
- )
- end
-
- def highlight_transformed(plain: nil)
- load_all_blob_data
-
- Gitlab::Highlight.highlight(
- blob.path,
- transformed_blob_data,
- language: transformed_blob_language,
+ blob_data(to),
+ language: blob_language,
plain: plain
)
end
@@ -38,6 +27,14 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
highlight(plain: false)
end
+ def blob_data(to)
+ @_blob_data ||= Gitlab::Diff::CustomDiff.transformed_blob_data(blob) || limited_blob_data(to: to)
+ end
+
+ def blob_language
+ @_blob_language ||= Gitlab::Diff::CustomDiff.transformed_blob_language(blob) || language
+ end
+
def raw_plain_data
blob.data unless blob.binary?
end
@@ -66,6 +63,11 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
project_ci_pipeline_editor_path(project, branch_name: blob.commit_id) if can_collaborate_with_project?(project) && blob.path == project.ci_config_path_or_default
end
+ # Will be overridden in EE
+ def code_owners
+ []
+ end
+
def fork_and_edit_path
fork_path_for_current_user(project, edit_blob_path)
end
@@ -78,6 +80,12 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
super(blob, project, blob.commit_id)
end
+ def can_current_user_push_to_branch?
+ return false unless current_user && project.repository.branch_exists?(blob.commit_id)
+
+ user_access(project).can_push_to_branch?(blob.commit_id)
+ end
+
def ide_edit_path
super(project, blob.commit_id, blob.path)
end
@@ -123,21 +131,6 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
def language
blob.language_from_gitattributes
end
-
- def transformed_blob_language
- @transformed_blob_language ||= blob.path.ends_with?('.ipynb') ? 'md' : language
- end
-
- def transformed_blob_data
- @transformed_blob ||= if blob.path.ends_with?('.ipynb') && blob.transformed_for_diff
- IpynbDiff.transform(blob.data,
- raise_errors: true,
- options: { include_metadata: false, cell_decorator: :percent })
- end
-
- @transformed_blob ||= blob.data
- rescue IpynbDiff::InvalidNotebookError => e
- Gitlab::ErrorTracking.log_exception(e)
- blob.data
- end
end
+
+BlobPresenter.prepend_mod_with('BlobPresenter')
diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb
index e0cb899c9d3..7f5dffadcfb 100644
--- a/app/presenters/ci/pipeline_presenter.rb
+++ b/app/presenters/ci/pipeline_presenter.rb
@@ -3,7 +3,6 @@
module Ci
class PipelinePresenter < Gitlab::View::Presenter::Delegated
include Gitlab::Utils::StrongMemoize
- include ActionView::Helpers::UrlHelper
delegator_override_with Gitlab::Utils::StrongMemoize # TODO: Remove `Gitlab::Utils::StrongMemoize` inclusion as it's duplicate
delegator_override_with ActionView::Helpers::TagHelper # TODO: Remove `ActionView::Helpers::UrlHelper` inclusion as it overrides `Ci::Pipeline#tag`
@@ -62,6 +61,13 @@ module Ci
localized_names.fetch(pipeline.merge_request_event_type, s_('Pipeline|Pipeline'))
end
+ delegator_override :coverage
+ def coverage
+ return unless pipeline.coverage.present?
+
+ '%.2f' % pipeline.coverage
+ end
+
def ref_text
if pipeline.detached_merge_request_pipeline?
_("for %{link_to_merge_request} with %{link_to_merge_request_source_branch}")
@@ -101,7 +107,7 @@ module Ci
end
def link_to_pipeline_ref
- link_to(pipeline.ref,
+ ApplicationController.helpers.link_to(pipeline.ref,
project_commits_path(pipeline.project, pipeline.ref),
class: "ref-name")
end
@@ -109,7 +115,7 @@ module Ci
def link_to_merge_request
return unless merge_request_presenter
- link_to(merge_request_presenter.to_reference,
+ ApplicationController.helpers.link_to(merge_request_presenter.to_reference,
project_merge_request_path(merge_request_presenter.project, merge_request_presenter),
class: 'mr-iid')
end
@@ -136,7 +142,7 @@ module Ci
private
def plain_ref_name
- content_tag(:span, pipeline.ref, class: 'ref-name')
+ ApplicationController.helpers.content_tag(:span, pipeline.ref, class: 'ref-name')
end
def merge_request_presenter
@@ -153,7 +159,7 @@ module Ci
all_related_merge_requests.first(limit).map do |merge_request|
mr_path = project_merge_request_path(merge_request.project, merge_request)
- link_to "#{merge_request.to_reference} #{merge_request.title}", mr_path, class: 'mr-iid'
+ ApplicationController.helpers.link_to "#{merge_request.to_reference} #{merge_request.title}", mr_path, class: 'mr-iid'
end
end
diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb
index 7919e501bf0..250715d7c9c 100644
--- a/app/presenters/commit_status_presenter.rb
+++ b/app/presenters/commit_status_presenter.rb
@@ -29,7 +29,8 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated
no_matching_runner: 'No matching runner available',
trace_size_exceeded: 'The job log size limit was reached',
builds_disabled: 'The CI/CD is disabled for this project',
- environment_creation_failure: 'This job could not be executed because it would create an environment with an invalid parameter.'
+ environment_creation_failure: 'This job could not be executed because it would create an environment with an invalid parameter.',
+ deployment_rejected: 'This deployment job was rejected.'
}.freeze
TROUBLESHOOTING_DOC = {
diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb
index d19d4964524..eeb94a8e657 100644
--- a/app/presenters/merge_request_presenter.rb
+++ b/app/presenters/merge_request_presenter.rb
@@ -136,7 +136,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
pipeline: :gfm,
author: author,
project: project,
- issuable_state_filter_enabled: true
+ issuable_reference_expansion_enabled: true
)
end
@@ -146,7 +146,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
pipeline: :gfm,
author: author,
project: project,
- issuable_state_filter_enabled: true
+ issuable_reference_expansion_enabled: true
)
end
@@ -254,6 +254,13 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
end
end
+ delegator_override :pipeline_coverage_delta
+ def pipeline_coverage_delta
+ return unless merge_request.pipeline_coverage_delta.present?
+
+ '%.2f' % merge_request.pipeline_coverage_delta
+ end
+
private
def cached_can_be_reverted?
diff --git a/app/presenters/packages/npm/package_presenter.rb b/app/presenters/packages/npm/package_presenter.rb
index 9e3308c2573..c30dfa6196b 100644
--- a/app/presenters/packages/npm/package_presenter.rb
+++ b/app/presenters/packages/npm/package_presenter.rb
@@ -12,10 +12,9 @@ module Packages
attr_reader :name, :packages
- def initialize(name, packages, include_metadata: false)
+ def initialize(name, packages)
@name = name
@packages = packages
- @include_metadata = include_metadata
end
def versions
@@ -24,10 +23,7 @@ module Packages
packages.each_batch do |relation|
batched_packages = relation.including_dependency_links
.preload_files
-
- if @include_metadata
- batched_packages = batched_packages.preload_npm_metadatum
- end
+ .preload_npm_metadatum
batched_packages.each do |package|
package_file = package.package_files.last
@@ -92,8 +88,6 @@ module Packages
end
def abbreviated_package_json(package)
- return {} unless @include_metadata
-
json = package.npm_metadatum&.package_json || {}
json.slice(*PACKAGE_JSON_ALLOWED_FIELDS)
end
diff --git a/app/presenters/projects/security/configuration_presenter.rb b/app/presenters/projects/security/configuration_presenter.rb
new file mode 100644
index 00000000000..89fca1a451a
--- /dev/null
+++ b/app/presenters/projects/security/configuration_presenter.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+module Projects
+ module Security
+ class ConfigurationPresenter < Gitlab::View::Presenter::Delegated
+ include AutoDevopsHelper
+ include ::Security::LatestPipelineInformation
+
+ presents ::Project, as: :project
+
+ def to_h
+ {
+ auto_devops_enabled: auto_devops_source?,
+ auto_devops_help_page_path: help_page_path('topics/autodevops/index'),
+ auto_devops_path: auto_devops_settings_path(project),
+ can_enable_auto_devops: can_enable_auto_devops?,
+ features: features,
+ help_page_path: help_page_path('user/application_security/index'),
+ latest_pipeline_path: latest_pipeline_path,
+ # TODO: gitlab_ci_present will incorrectly report `false` if the CI/CD configuration file name
+ # has been customized and a file with the given custom name exists in the repo. This edge case
+ # will be addressed in https://gitlab.com/gitlab-org/gitlab/-/issues/342465
+ gitlab_ci_present: project.repository.gitlab_ci_yml.present?,
+ gitlab_ci_history_path: gitlab_ci_history_path,
+ auto_fix_enabled: autofix_enabled,
+ can_toggle_auto_fix_settings: can_toggle_autofix,
+ auto_fix_user_path: auto_fix_user_path
+ }
+ end
+
+ def to_html_data_attribute
+ data = to_h
+ data[:features] = data[:features].to_json
+ data[:auto_fix_enabled] = data[:auto_fix_enabled].to_json
+
+ data
+ end
+
+ private
+
+ def autofix_enabled; end
+
+ def auto_fix_user_path; end
+
+ def can_enable_auto_devops?
+ feature_available?(:builds, current_user) &&
+ can?(current_user, :admin_project, self) &&
+ !archived?
+ end
+
+ def can_toggle_autofix; end
+
+ def gitlab_ci_history_path
+ return '' if project.empty_repo?
+
+ gitlab_ci = ::Gitlab::FileDetector::PATTERNS[:gitlab_ci]
+ ::Gitlab::Routing.url_helpers.project_blame_path(project, File.join(project.default_branch_or_main, gitlab_ci))
+ end
+
+ def features
+ scans = scan_types.map do |scan_type|
+ scan(scan_type, configured: scanner_enabled?(scan_type))
+ end
+
+ # These scans are "fake" (non job) entries. Add them manually.
+ scans << scan(:corpus_management, configured: true)
+ scans << scan(:dast_profiles, configured: true)
+ end
+
+ def latest_pipeline_path
+ return help_page_path('ci/pipelines') unless latest_default_branch_pipeline
+
+ project_pipeline_path(self, latest_default_branch_pipeline)
+ end
+
+ def scan(type, configured: false)
+ scan = ::Gitlab::Security::ScanConfiguration.new(project: project, type: type, configured: configured)
+
+ {
+ type: scan.type,
+ configured: scan.configured?,
+ configuration_path: scan.configuration_path,
+ available: scan.available?
+ }
+ end
+
+ def scan_types
+ ::Security::SecurityJobsFinder.allowed_job_types + ::Security::LicenseComplianceJobsFinder.allowed_job_types
+ end
+
+ def project_settings
+ project.security_setting
+ end
+ end
+ end
+end
+
+Projects::Security::ConfigurationPresenter.prepend_mod_with('Projects::Security::ConfigurationPresenter')
diff --git a/app/presenters/prometheus_alert_presenter.rb b/app/presenters/prometheus_alert_presenter.rb
index 714329ede71..776e2baebdd 100644
--- a/app/presenters/prometheus_alert_presenter.rb
+++ b/app/presenters/prometheus_alert_presenter.rb
@@ -1,8 +1,6 @@
# frozen_string_literal: true
class PrometheusAlertPresenter < Gitlab::View::Presenter::Delegated
- include ActionView::Helpers::UrlHelper
-
presents ::PrometheusAlert, as: :prometheus_alert
def humanized_text
diff --git a/app/presenters/snippet_blob_presenter.rb b/app/presenters/snippet_blob_presenter.rb
index 4072696eb89..026d442291c 100644
--- a/app/presenters/snippet_blob_presenter.rb
+++ b/app/presenters/snippet_blob_presenter.rb
@@ -57,3 +57,5 @@ class SnippetBlobPresenter < BlobPresenter
gitlab_raw_snippet_blob_url(snippet, blob.path, only_path: only_path)
end
end
+
+SnippetBlobPresenter.prepend_mod
diff --git a/app/serializers/analytics/cycle_analytics/stage_entity.rb b/app/serializers/analytics/cycle_analytics/stage_entity.rb
index c5cc8c89fb7..cfbf6f60e38 100644
--- a/app/serializers/analytics/cycle_analytics/stage_entity.rb
+++ b/app/serializers/analytics/cycle_analytics/stage_entity.rb
@@ -3,6 +3,10 @@
module Analytics
module CycleAnalytics
class StageEntity < Grape::Entity
+ include ActionView::Context
+ include LabelsHelper
+ include ActionView::Helpers::TagHelper
+
expose :title
expose :hidden
expose :legend
@@ -43,10 +47,20 @@ module Analytics
html_description(object.end_event)
end
+ # Avoid including ActionView::Helpers::UrlHelper
+ def link_to(*args)
+ ActionController::Base.helpers.link_to(*args)
+ end
+
private
def html_description(event)
- Banzai::Renderer.render(event.markdown_description, { group: object.group, project: nil })
+ options = {}
+ if event.label_based?
+ options[:label_html] = render_label(event.label, link: '', small: true, tooltip: true)
+ end
+
+ content_tag(:p) { event.html_description(options).html_safe }
end
end
end
diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb
index 4615f471639..9fd35faf0b7 100644
--- a/app/serializers/build_details_entity.rb
+++ b/app/serializers/build_details_entity.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
-class BuildDetailsEntity < JobEntity
- expose :coverage, :erased_at, :duration
+class BuildDetailsEntity < Ci::JobEntity
+ expose :coverage, :erased_at, :finished_at, :duration
expose :tag_list, as: :tags
expose :has_trace?, as: :has_trace
expose :stage
@@ -109,6 +109,8 @@ class BuildDetailsEntity < JobEntity
private
+ alias_method :build, :object
+
def build_failed_issue_options
{ title: "Job Failed ##{build.id}",
description: "Job [##{build.id}](#{project_job_url(project, build)}) failed for #{build.sha}:\n" }
diff --git a/app/serializers/build_serializer.rb b/app/serializers/build_serializer.rb
deleted file mode 100644
index 0649fdad6a8..00000000000
--- a/app/serializers/build_serializer.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-# frozen_string_literal: true
-
-class BuildSerializer < BaseSerializer
- entity JobEntity
-
- def represent_status(resource)
- data = represent(resource, { only: [:status] })
- data.fetch(:status, {})
- end
-end
diff --git a/app/serializers/ci/job_entity.rb b/app/serializers/ci/job_entity.rb
new file mode 100644
index 00000000000..fca3dec74d4
--- /dev/null
+++ b/app/serializers/ci/job_entity.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+module Ci
+ class JobEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id
+ expose :name
+
+ expose :started?, as: :started
+ expose :complete?, as: :complete
+ expose :archived?, as: :archived
+
+ # bridge jobs don't have build details pages
+ expose :build_path, if: ->(job) { !job.is_a?(Ci::Bridge) } do |job|
+ job_path(job)
+ end
+
+ expose :retry_path, if: -> (*) { retryable? } do |job|
+ path_to(:retry_namespace_project_job, job)
+ end
+
+ expose :cancel_path, if: -> (*) { cancelable? } do |job|
+ path_to(
+ :cancel_namespace_project_job,
+ job,
+ { continue: { to: job_path(job) } }
+ )
+ end
+
+ expose :play_path, if: -> (*) { playable? } do |job|
+ path_to(:play_namespace_project_job, job)
+ end
+
+ expose :unschedule_path, if: -> (*) { scheduled? } do |job|
+ path_to(:unschedule_namespace_project_job, job)
+ end
+
+ expose :playable?, as: :playable
+ expose :scheduled?, as: :scheduled
+ expose :scheduled_at, if: -> (*) { scheduled? }
+ expose :created_at
+ expose :updated_at
+ expose :detailed_status, as: :status, with: DetailedStatusEntity
+ expose :callout_message, if: -> (*) { failed? && !job.script_failure? }
+ expose :recoverable, if: -> (*) { failed? }
+
+ private
+
+ alias_method :job, :object
+
+ def cancelable?
+ job.cancelable? && can?(request.current_user, :update_build, job)
+ end
+
+ def retryable?
+ job.retryable? && can?(request.current_user, :update_build, job)
+ end
+
+ def playable?
+ job.playable? && can?(request.current_user, :update_build, job)
+ end
+
+ def scheduled?
+ job.scheduled?
+ end
+
+ def detailed_status
+ job.detailed_status(request.current_user)
+ end
+
+ def path_to(route, job, params = {})
+ send("#{route}_path", job.project.namespace, job.project, job, params) # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ def job_path(job)
+ job.target_url || path_to(:namespace_project_job, job)
+ end
+
+ def failed?
+ job.failed?
+ end
+
+ def callout_message
+ job_presenter.callout_failure_message
+ end
+
+ def recoverable
+ job_presenter.recoverable?
+ end
+
+ def job_presenter
+ @job_presenter ||= job.present
+ end
+ end
+end
diff --git a/app/serializers/ci/job_serializer.rb b/app/serializers/ci/job_serializer.rb
new file mode 100644
index 00000000000..01f9e223943
--- /dev/null
+++ b/app/serializers/ci/job_serializer.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Ci
+ class JobSerializer < BaseSerializer
+ entity Ci::JobEntity
+
+ def represent_status(resource)
+ data = represent(resource, { only: [:status] })
+ data.fetch(:status, {})
+ end
+ end
+end
diff --git a/app/serializers/ci/pipeline_entity.rb b/app/serializers/ci/pipeline_entity.rb
index a5af543f49f..20aeb978520 100644
--- a/app/serializers/ci/pipeline_entity.rb
+++ b/app/serializers/ci/pipeline_entity.rb
@@ -4,7 +4,7 @@ class Ci::PipelineEntity < Grape::Entity
include RequestAwareEntity
include Gitlab::Utils::StrongMemoize
- delegate :name, :failure_reason, to: :presented_pipeline
+ delegate :name, :failure_reason, :coverage, to: :presented_pipeline
expose :id
expose :iid
@@ -82,7 +82,7 @@ class Ci::PipelineEntity < Grape::Entity
project_pipeline_path(pipeline.project, pipeline)
end
- expose :failed_builds, if: -> (*) { can_retry? }, using: JobEntity do |pipeline|
+ expose :failed_builds, if: -> (*) { can_retry? }, using: Ci::JobEntity do |pipeline|
pipeline.failed_builds.each do |build|
build.project = pipeline.project
end
diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb
index 08a939e86c5..7a2fba73f3a 100644
--- a/app/serializers/deployment_entity.rb
+++ b/app/serializers/deployment_entity.rb
@@ -22,12 +22,14 @@ class DeploymentEntity < Grape::Entity
expose :deployed_at
expose :tag
expose :last?
+ expose :last?, as: :is_last
+
expose :deployed_by, as: :user, using: UserEntity
expose :deployable, if: -> (deployment) { deployment.deployable.present? } do |deployment, opts|
deployment.deployable.yield_self do |deployable|
if include_details?
- JobEntity.represent(deployable, opts)
+ Ci::JobEntity.represent(deployable, opts)
elsif can_read_deployables?
{ name: deployable.name,
build_path: project_job_path(deployable.project, deployable) }
@@ -36,10 +38,10 @@ class DeploymentEntity < Grape::Entity
end
expose :commit, using: CommitEntity, if: -> (*) { include_details? }
- expose :manual_actions, using: JobEntity, if: -> (*) { include_details? && can_create_deployment? }
- expose :scheduled_actions, using: JobEntity, if: -> (*) { include_details? && can_create_deployment? }
+ 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|
- JobEntity.represent(deployment.playable_build, options.merge(only: [:play_path, :retry_path]))
+ Ci::JobEntity.represent(deployment.playable_build, options.merge(only: [:play_path, :retry_path]))
end
expose :cluster do |deployment, options|
diff --git a/app/serializers/deployment_serializer.rb b/app/serializers/deployment_serializer.rb
index b48037dd53f..7710efed3ab 100644
--- a/app/serializers/deployment_serializer.rb
+++ b/app/serializers/deployment_serializer.rb
@@ -4,7 +4,7 @@ class DeploymentSerializer < BaseSerializer
entity DeploymentEntity
def represent_concise(resource, opts = {})
- opts[:only] = [:iid, :id, :sha, :created_at, :deployed_at, :tag, :last?, :id, ref: [:name]]
+ opts[:only] = [:iid, :id, :sha, :created_at, :deployed_at, :tag, :last?, :is_last, :id, ref: [:name]]
represent(resource, opts)
end
end
diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb
index 7eca56b2f48..ef856ee0116 100644
--- a/app/serializers/diff_file_entity.rb
+++ b/app/serializers/diff_file_entity.rb
@@ -90,3 +90,5 @@ class DiffFileEntity < DiffFileBaseEntity
options.fetch(:diff_view, :inline).to_sym
end
end
+
+DiffFileEntity.prepend_mod
diff --git a/app/serializers/job_entity.rb b/app/serializers/job_entity.rb
deleted file mode 100644
index eb8622edb38..00000000000
--- a/app/serializers/job_entity.rb
+++ /dev/null
@@ -1,94 +0,0 @@
-# frozen_string_literal: true
-
-class JobEntity < Grape::Entity
- include RequestAwareEntity
-
- expose :id
- expose :name
-
- expose :started?, as: :started
- expose :complete?, as: :complete
- expose :archived?, as: :archived
-
- # bridge jobs don't have build detail pages
- expose :build_path, if: ->(build) { !build.is_a?(Ci::Bridge) } do |build|
- build_path(build)
- end
-
- expose :retry_path, if: -> (*) { retryable? } do |build|
- path_to(:retry_namespace_project_job, build)
- end
-
- expose :cancel_path, if: -> (*) { cancelable? } do |build|
- path_to(
- :cancel_namespace_project_job,
- build,
- { continue: { to: build_path(build) } }
- )
- end
-
- expose :play_path, if: -> (*) { playable? } do |build|
- path_to(:play_namespace_project_job, build)
- end
-
- expose :unschedule_path, if: -> (*) { scheduled? } do |build|
- path_to(:unschedule_namespace_project_job, build)
- end
-
- expose :playable?, as: :playable
- expose :scheduled?, as: :scheduled
- expose :scheduled_at, if: -> (*) { scheduled? }
- expose :created_at
- expose :updated_at
- expose :detailed_status, as: :status, with: DetailedStatusEntity
- expose :callout_message, if: -> (*) { failed? && !build.script_failure? }
- expose :recoverable, if: -> (*) { failed? }
-
- private
-
- alias_method :build, :object
-
- def cancelable?
- build.cancelable? && can?(request.current_user, :update_build, build)
- end
-
- def retryable?
- build.retryable? && can?(request.current_user, :update_build, build)
- end
-
- def playable?
- build.playable? && can?(request.current_user, :update_build, build)
- end
-
- def scheduled?
- build.scheduled?
- end
-
- def detailed_status
- build.detailed_status(request.current_user)
- end
-
- def path_to(route, build, params = {})
- send("#{route}_path", build.project.namespace, build.project, build, params) # rubocop:disable GitlabSecurity/PublicSend
- end
-
- def build_path(build)
- build.target_url || path_to(:namespace_project_job, build)
- end
-
- def failed?
- build.failed?
- end
-
- def callout_message
- build_presenter.callout_failure_message
- end
-
- def recoverable
- build_presenter.recoverable?
- end
-
- def build_presenter
- @build_presenter ||= build.present
- end
-end
diff --git a/app/serializers/job_group_entity.rb b/app/serializers/job_group_entity.rb
index 0db7624b3f7..3597d5531fa 100644
--- a/app/serializers/job_group_entity.rb
+++ b/app/serializers/job_group_entity.rb
@@ -6,7 +6,7 @@ class JobGroupEntity < Grape::Entity
expose :name
expose :size
expose :detailed_status, as: :status, with: DetailedStatusEntity
- expose :jobs, with: JobEntity
+ expose :jobs, with: Ci::JobEntity
private
diff --git a/app/serializers/member_entity.rb b/app/serializers/member_entity.rb
index d7221109ecb..f2f97f560e0 100644
--- a/app/serializers/member_entity.rb
+++ b/app/serializers/member_entity.rb
@@ -63,6 +63,12 @@ class MemberEntity < Grape::Entity
member.respond_to?(:invited_user_state) ? member.invited_user_state : ""
end
end
+
+ private
+
+ def current_user
+ options[:current_user]
+ end
end
MemberEntity.prepend_mod_with('MemberEntity')
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index bd60d60c8db..b9c71e6d97b 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -73,7 +73,7 @@ class MergeRequestWidgetEntity < Grape::Entity
end
expose :user_callouts_path do |_merge_request|
- user_callouts_path
+ callouts_path
end
expose :suggest_pipeline_feature_id do |_merge_request|
diff --git a/app/serializers/merge_requests/pipeline_entity.rb b/app/serializers/merge_requests/pipeline_entity.rb
index bdf5cb160b2..f4fb01604d0 100644
--- a/app/serializers/merge_requests/pipeline_entity.rb
+++ b/app/serializers/merge_requests/pipeline_entity.rb
@@ -43,7 +43,9 @@ class MergeRequests::PipelineEntity < Grape::Entity
# Coverage isn't always necessary (e.g. when displaying project pipelines in
# the UI). Instead of creating an entirely different entity we just allow the
# disabling of this specific field whenever necessary.
- expose :coverage, unless: proc { options[:disable_coverage] }
+ expose :coverage, unless: proc { options[:disable_coverage] } do |pipeline|
+ pipeline.present.coverage
+ end
expose :ref do
expose :branch?, as: :branch
diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb
index 8f189f14dea..58ad5812801 100644
--- a/app/serializers/note_entity.rb
+++ b/app/serializers/note_entity.rb
@@ -51,7 +51,7 @@ class NoteEntity < API::Entities::Note
SystemNoteHelper.system_note_icon_name(note)
end
- expose :outdated_line_change_path, if: -> (note, _) { note.system? && note.change_position&.line_range && Feature.enabled?(:display_outdated_line_diff, note.project, default_enabled: :yaml) } do |note|
+ expose :outdated_line_change_path, if: -> (note, _) { note.show_outdated_changes? } do |note|
outdated_line_change_namespace_project_note_path(namespace_id: note.project.namespace, project_id: note.project, id: note)
end
diff --git a/app/serializers/stage_entity.rb b/app/serializers/stage_entity.rb
index 0aadcd01a43..548ff577863 100644
--- a/app/serializers/stage_entity.rb
+++ b/app/serializers/stage_entity.rb
@@ -15,13 +15,13 @@ class StageEntity < Grape::Entity
expose :latest_statuses,
if: -> (_, opts) { opts[:details] },
- with: JobEntity do |stage|
+ with: Ci::JobEntity do |stage|
latest_statuses
end
expose :retried,
if: -> (_, opts) { opts[:retried] },
- with: JobEntity do |stage|
+ with: Ci::JobEntity do |stage|
retried_statuses
end
diff --git a/app/services/admin/propagate_service_template.rb b/app/services/admin/propagate_service_template.rb
deleted file mode 100644
index c251537c479..00000000000
--- a/app/services/admin/propagate_service_template.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-module Admin
- class PropagateServiceTemplate
- include PropagateService
-
- def propagate
- # TODO: Remove this as part of https://gitlab.com/gitlab-org/gitlab/-/issues/335178
- end
- end
-end
diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb
index 563d4a924fc..1426bf25a00 100644
--- a/app/services/audit_event_service.rb
+++ b/app/services/audit_event_service.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class AuditEventService
+ include AuditEventSaveType
+
# Instantiates a new service
#
# @param [User] author the user who authors the change
@@ -10,13 +12,16 @@ class AuditEventService
# - Group: events are visible at Group and Instance level
# - User: events are visible at Instance level
# @param [Hash] details extra data of audit event
+ # @param [Symbol] save_type the type to save the event
+ # Can be selected from the following, :database, :stream, :database_and_stream .
#
# @return [AuditEventService]
- def initialize(author, entity, details = {})
+ def initialize(author, entity, details = {}, save_type = :database_and_stream)
@author = build_author(author)
@entity = entity
@details = details
@ip_address = resolve_ip_address(@author)
+ @save_type = save_type
end
# Builds the @details attribute for authentication
@@ -133,8 +138,8 @@ class AuditEventService
end
def save_or_track(event)
- event.save!
- stream_event_to_external_destinations(event)
+ event.save! if should_save_database?(@save_type)
+ stream_event_to_external_destinations(event) if should_save_stream?(@save_type)
rescue StandardError => e
Gitlab::ErrorTracking.track_exception(e, audit_event_type: event.class.to_s)
end
diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb
index bc734465750..ea4723c9e28 100644
--- a/app/services/auth/container_registry_authentication_service.rb
+++ b/app/services/auth/container_registry_authentication_service.rb
@@ -156,7 +156,7 @@ module Auth
return if path.has_repository?
return unless actions.include?('push')
- ContainerRepository.create_from_path!(path)
+ ContainerRepository.find_or_create_from_path(path)
end
# Overridden in EE
diff --git a/app/services/authorized_project_update/find_records_due_for_refresh_service.rb b/app/services/authorized_project_update/find_records_due_for_refresh_service.rb
index c4b18a26d0e..3a2251f15cc 100644
--- a/app/services/authorized_project_update/find_records_due_for_refresh_service.rb
+++ b/app/services/authorized_project_update/find_records_due_for_refresh_service.rb
@@ -47,7 +47,11 @@ module AuthorizedProjectUpdate
missing_auth_found_callback.call(project_id, level)
end
- array << [user.id, project_id, level]
+ array << {
+ user_id: user.id,
+ project_id: project_id,
+ access_level: level
+ }
end
end
diff --git a/app/services/authorized_project_update/project_group_link_create_service.rb b/app/services/authorized_project_update/project_group_link_create_service.rb
index e9e7c56d7c7..10cf4c50569 100644
--- a/app/services/authorized_project_update/project_group_link_create_service.rb
+++ b/app/services/authorized_project_update/project_group_link_create_service.rb
@@ -65,16 +65,8 @@ module AuthorizedProjectUpdate
end
def update_authorizations(user_ids_to_delete, authorizations_to_create)
- ProjectAuthorization.transaction do
- if user_ids_to_delete.any?
- ProjectAuthorization.where(project_id: project.id, user_id: user_ids_to_delete) # rubocop: disable CodeReuse/ActiveRecord
- .delete_all
- end
-
- if authorizations_to_create.any?
- ProjectAuthorization.insert_all(authorizations_to_create)
- end
- end
+ project.remove_project_authorizations(user_ids_to_delete) if user_ids_to_delete.any?
+ ProjectAuthorization.insert_all_in_batches(authorizations_to_create) if authorizations_to_create.any?
end
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 d70d0efc2af..17ba48cffcd 100644
--- a/app/services/authorized_project_update/project_recalculate_service.rb
+++ b/app/services/authorized_project_update/project_recalculate_service.rb
@@ -64,16 +64,8 @@ module AuthorizedProjectUpdate
end
def refresh_authorizations
- ProjectAuthorization.transaction do
- if user_ids_to_remove.any?
- ProjectAuthorization.where(project_id: project.id, user_id: user_ids_to_remove) # rubocop: disable CodeReuse/ActiveRecord
- .delete_all
- end
-
- if authorizations_to_create.any?
- ProjectAuthorization.insert_all(authorizations_to_create)
- end
- end
+ project.remove_project_authorizations(user_ids_to_remove) if user_ids_to_remove.any?
+ ProjectAuthorization.insert_all_in_batches(authorizations_to_create) if authorizations_to_create.any?
end
def apply_scopes(project_authorizations)
diff --git a/app/services/auto_merge/base_service.rb b/app/services/auto_merge/base_service.rb
index e756e8c14d8..da80211f9bb 100644
--- a/app/services/auto_merge/base_service.rb
+++ b/app/services/auto_merge/base_service.rb
@@ -64,7 +64,7 @@ module AutoMerge
# NOTE: This method is to be removed when `disallow_to_create_merge_request_pipelines_in_target_project`
# feature flag is removed.
def self.can_add_to_merge_train?(merge_request)
- if Gitlab::Ci::Features.disallow_to_create_merge_request_pipelines_in_target_project?(merge_request.target_project)
+ if ::Feature.enabled?(:ci_disallow_to_create_merge_request_pipelines_in_target_project, merge_request.target_project)
merge_request.for_same_project?
else
true
diff --git a/app/services/bulk_imports/create_service.rb b/app/services/bulk_imports/create_service.rb
index c1becbb5609..cbf2b34b33c 100644
--- a/app/services/bulk_imports/create_service.rb
+++ b/app/services/bulk_imports/create_service.rb
@@ -59,7 +59,7 @@ module BulkImports
)
bulk_import.create_configuration!(credentials.slice(:url, :access_token))
- params.each do |entity|
+ Array.wrap(params).each do |entity|
BulkImports::Entity.create!(
bulk_import: bulk_import,
source_type: entity[:source_type],
diff --git a/app/services/bulk_imports/relation_export_service.rb b/app/services/bulk_imports/relation_export_service.rb
index 4718b3914b2..14f073120c5 100644
--- a/app/services/bulk_imports/relation_export_service.rb
+++ b/app/services/bulk_imports/relation_export_service.rb
@@ -59,7 +59,7 @@ module BulkImports
end
def export_service
- @export_service ||= if config.tree_relation?(relation)
+ @export_service ||= if config.tree_relation?(relation) || config.self_relation?(relation)
TreeExportService.new(portable, config.export_path, relation)
elsif config.file_relation?(relation)
FileExportService.new(portable, config.export_path, relation)
diff --git a/app/services/bulk_imports/tree_export_service.rb b/app/services/bulk_imports/tree_export_service.rb
index b8e7ac4574b..8e885e590d1 100644
--- a/app/services/bulk_imports/tree_export_service.rb
+++ b/app/services/bulk_imports/tree_export_service.rb
@@ -10,6 +10,8 @@ module BulkImports
end
def execute
+ return serializer.serialize_root(config.class::SELF_RELATION) if self_relation?
+
relation_definition = config.tree_relation_definition_for(relation)
raise BulkImports::Error, 'Unsupported relation export type' unless relation_definition
@@ -18,6 +20,8 @@ module BulkImports
end
def exported_filename
+ return "#{relation}.json" if self_relation?
+
"#{relation}.ndjson"
end
@@ -39,5 +43,9 @@ module BulkImports
def json_writer
::Gitlab::ImportExport::Json::NdjsonWriter.new(export_path)
end
+
+ def self_relation?
+ relation == config.class::SELF_RELATION
+ end
end
end
diff --git a/app/services/bulk_imports/uploads_export_service.rb b/app/services/bulk_imports/uploads_export_service.rb
index 32cc48c152c..7f5ee7b8624 100644
--- a/app/services/bulk_imports/uploads_export_service.rb
+++ b/app/services/bulk_imports/uploads_export_service.rb
@@ -5,6 +5,7 @@ module BulkImports
include Gitlab::ImportExport::CommandLineUtil
BATCH_SIZE = 100
+ AVATAR_PATH = 'avatar'
def initialize(portable, export_path)
@portable = portable
@@ -34,7 +35,7 @@ module BulkImports
def export_subdir_path(upload)
subdir = if upload.path == avatar_path
- 'avatar'
+ AVATAR_PATH
else
upload.try(:secret).to_s
end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index 540e8f7b970..c1f35afba40 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -2,10 +2,14 @@
module Ci
class CreatePipelineService < BaseService
- attr_reader :pipeline
+ attr_reader :pipeline, :logger
CreateError = Class.new(StandardError)
+ LOG_MAX_DURATION_THRESHOLD = 3.seconds
+ LOG_MAX_PIPELINE_SIZE = 2_000
+ LOG_MAX_CREATION_THRESHOLD = 20.seconds
+
SEQUENCE = [Gitlab::Ci::Pipeline::Chain::Build,
Gitlab::Ci::Pipeline::Chain::Build::Associations,
Gitlab::Ci::Pipeline::Chain::Validate::Abilities,
@@ -24,7 +28,10 @@ module Ci
Gitlab::Ci::Pipeline::Chain::Validate::External,
Gitlab::Ci::Pipeline::Chain::Populate,
Gitlab::Ci::Pipeline::Chain::StopDryRun,
+ Gitlab::Ci::Pipeline::Chain::EnsureEnvironments,
+ Gitlab::Ci::Pipeline::Chain::EnsureResourceGroups,
Gitlab::Ci::Pipeline::Chain::Create,
+ Gitlab::Ci::Pipeline::Chain::CreateDeployments,
Gitlab::Ci::Pipeline::Chain::CreateCrossDatabaseAssociations,
Gitlab::Ci::Pipeline::Chain::Limit::Activity,
Gitlab::Ci::Pipeline::Chain::Limit::JobActivity,
@@ -53,6 +60,7 @@ module Ci
# @return [Ci::Pipeline] The created Ci::Pipeline object.
# rubocop: disable Metrics/ParameterLists
def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, external_pull_request: nil, bridge: nil, **options, &block)
+ @logger = build_logger
@pipeline = Ci::Pipeline.new
command = Gitlab::Ci::Pipeline::Chain::Command.new(
@@ -76,6 +84,7 @@ module Ci
push_options: params[:push_options] || {},
chat_data: params[:chat_data],
bridge: bridge,
+ logger: @logger,
**extra_options(**options))
# Ensure we never persist the pipeline when dry_run: true
@@ -98,6 +107,9 @@ module Ci
else
ServiceResponse.success(payload: pipeline)
end
+
+ ensure
+ @logger.commit(pipeline: pipeline, caller: self.class.name)
end
# rubocop: enable Metrics/ParameterLists
@@ -135,6 +147,32 @@ module Ci
def extra_options(content: nil, dry_run: false)
{ content: content, dry_run: dry_run }
end
+
+ def build_logger
+ Gitlab::Ci::Pipeline::Logger.new(project: project) do |l|
+ l.log_when do |observations|
+ observations.any? do |name, values|
+ values.any? &&
+ name.to_s.end_with?('duration_s') &&
+ values.max >= LOG_MAX_DURATION_THRESHOLD
+ end
+ end
+
+ l.log_when do |observations|
+ values = observations['pipeline_size_count']
+ next false if values.empty?
+
+ values.max >= LOG_MAX_PIPELINE_SIZE
+ end
+
+ l.log_when do |observations|
+ values = observations['pipeline_creation_duration_s']
+ next false if values.empty?
+
+ values.max >= LOG_MAX_CREATION_THRESHOLD
+ end
+ end
+ end
end
end
diff --git a/app/services/ci/expire_pipeline_cache_service.rb b/app/services/ci/expire_pipeline_cache_service.rb
index 48a6344f576..8622b1a5863 100644
--- a/app/services/ci/expire_pipeline_cache_service.rb
+++ b/app/services/ci/expire_pipeline_cache_service.rb
@@ -60,6 +60,10 @@ module Ci
url_helpers.graphql_etag_pipeline_sha_path(sha)
end
+ def graphql_project_on_demand_scan_counts_path(project)
+ url_helpers.graphql_etag_project_on_demand_scan_counts_path(project)
+ end
+
# Updates ETag caches of a pipeline.
#
# This logic resides in a separate method so that EE can more easily extend
@@ -70,18 +74,25 @@ module Ci
def update_etag_cache(pipeline, store)
project = pipeline.project
- store.touch(project_pipelines_path(project))
- store.touch(commit_pipelines_path(project, pipeline.commit)) unless pipeline.commit.nil?
- store.touch(new_merge_request_pipelines_path(project))
+ etag_paths = [
+ project_pipelines_path(project),
+ new_merge_request_pipelines_path(project),
+ graphql_project_on_demand_scan_counts_path(project)
+ ]
+
+ etag_paths << commit_pipelines_path(project, pipeline.commit) unless pipeline.commit.nil?
+
each_pipelines_merge_request_path(pipeline) do |path|
- store.touch(path)
+ etag_paths << path
end
- pipeline.self_with_upstreams_and_downstreams.each do |relative_pipeline|
- store.touch(project_pipeline_path(relative_pipeline.project, relative_pipeline))
- store.touch(graphql_pipeline_path(relative_pipeline))
- store.touch(graphql_pipeline_sha_path(relative_pipeline.sha))
+ pipeline.self_with_upstreams_and_downstreams.includes(project: [:route, { namespace: :route }]).each do |relative_pipeline| # rubocop: disable CodeReuse/ActiveRecord
+ etag_paths << project_pipeline_path(relative_pipeline.project, relative_pipeline)
+ etag_paths << graphql_pipeline_path(relative_pipeline)
+ etag_paths << graphql_pipeline_sha_path(relative_pipeline.sha)
end
+
+ store.touch(*etag_paths)
end
def url_helpers
diff --git a/app/services/ci/job_artifacts/destroy_all_expired_service.rb b/app/services/ci/job_artifacts/destroy_all_expired_service.rb
index e4f65736a58..7fa56677a0c 100644
--- a/app/services/ci/job_artifacts/destroy_all_expired_service.rb
+++ b/app/services/ci/job_artifacts/destroy_all_expired_service.rb
@@ -14,6 +14,7 @@ module Ci
def initialize
@removed_artifacts_count = 0
+ @start_at = Time.current
end
##
@@ -25,9 +26,9 @@ module Ci
def execute
in_lock(EXCLUSIVE_LOCK_KEY, ttl: LOCK_TIMEOUT, retries: 1) do
if ::Feature.enabled?(:ci_destroy_unlocked_job_artifacts)
- destroy_unlocked_job_artifacts(Time.current)
+ destroy_unlocked_job_artifacts
else
- destroy_job_artifacts_with_slow_iteration(Time.current)
+ destroy_job_artifacts_with_slow_iteration
end
end
@@ -36,16 +37,37 @@ module Ci
private
- def destroy_unlocked_job_artifacts(start_at)
+ def destroy_unlocked_job_artifacts
loop_until(timeout: LOOP_TIMEOUT, limit: LOOP_LIMIT) do
- artifacts = Ci::JobArtifact.expired_before(start_at).artifact_unlocked.limit(BATCH_SIZE)
+ artifacts = Ci::JobArtifact.expired_before(@start_at).artifact_unlocked.limit(BATCH_SIZE)
service_response = destroy_batch(artifacts)
@removed_artifacts_count += service_response[:destroyed_artifacts_count]
+
+ update_locked_status_on_unknown_artifacts if service_response[:destroyed_artifacts_count] == 0
+
+ # Return a truthy value here to prevent exiting #loop_until
+ @removed_artifacts_count
end
end
- def destroy_job_artifacts_with_slow_iteration(start_at)
- Ci::JobArtifact.expired_before(start_at).each_batch(of: BATCH_SIZE, column: :expire_at, order: :desc) do |relation, index|
+ def update_locked_status_on_unknown_artifacts
+ build_ids = Ci::JobArtifact.expired_before(@start_at).artifact_unknown.limit(BATCH_SIZE).distinct_job_ids
+
+ return unless build_ids.present?
+
+ locked_pipeline_build_ids = ::Ci::Build.with_pipeline_locked_artifacts.id_in(build_ids).pluck_primary_key
+ unlocked_pipeline_build_ids = build_ids - locked_pipeline_build_ids
+
+ update_unknown_artifacts(locked_pipeline_build_ids, Ci::JobArtifact.lockeds[:artifacts_locked])
+ update_unknown_artifacts(unlocked_pipeline_build_ids, Ci::JobArtifact.lockeds[:unlocked])
+ end
+
+ def update_unknown_artifacts(build_ids, locked_value)
+ Ci::JobArtifact.for_job_ids(build_ids).update_all(locked: locked_value) if build_ids.any?
+ end
+
+ def destroy_job_artifacts_with_slow_iteration
+ Ci::JobArtifact.expired_before(@start_at).each_batch(of: BATCH_SIZE, column: :expire_at, order: :desc) do |relation, index|
# For performance reasons, join with ci_pipelines after the batch is queried.
# See: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/47496
artifacts = relation.unlocked
@@ -53,7 +75,7 @@ module Ci
service_response = destroy_batch(artifacts)
@removed_artifacts_count += service_response[:destroyed_artifacts_count]
- break if loop_timeout?(start_at)
+ break if loop_timeout?
break if index >= LOOP_LIMIT
end
end
@@ -62,8 +84,8 @@ module Ci
Ci::JobArtifacts::DestroyBatchService.new(artifacts).execute
end
- def loop_timeout?(start_at)
- Time.current > start_at + LOOP_TIMEOUT
+ def loop_timeout?
+ Time.current > @start_at + LOOP_TIMEOUT
end
end
end
diff --git a/app/services/ci/parse_dotenv_artifact_service.rb b/app/services/ci/parse_dotenv_artifact_service.rb
index 725ecbcce5d..40e2cd82b4f 100644
--- a/app/services/ci/parse_dotenv_artifact_service.rb
+++ b/app/services/ci/parse_dotenv_artifact_service.rb
@@ -14,7 +14,7 @@ module Ci
Ci::JobVariable.bulk_insert!(variables)
success
- rescue SizeLimitError, ParserError, ActiveRecord::RecordInvalid => error
+ rescue SizeLimitError, ParserError, ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => error
Gitlab::ErrorTracking.track_exception(error, job_id: artifact.job_id)
error(error.message, :bad_request)
end
@@ -33,13 +33,13 @@ module Ci
end
def parse!(artifact)
- variables = []
+ variables = {}
artifact.each_blob do |blob|
blob.each_line do |line|
key, value = scan_line!(line)
- variables << Ci::JobVariable.new(job_id: artifact.job_id,
+ variables[key] = Ci::JobVariable.new(job_id: artifact.job_id,
source: :dotenv, key: key, value: value)
end
end
@@ -49,7 +49,7 @@ module Ci
"Dotenv files cannot have more than #{dotenv_variable_limit} variables"
end
- variables
+ variables.values
end
def scan_line!(line)
diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb
index 236d660d829..d8ce063ffb4 100644
--- a/app/services/ci/pipeline_processing/atomic_processing_service.rb
+++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb
@@ -36,6 +36,10 @@ module Ci
update_pipeline!
update_statuses_processed!
+ if Feature.enabled?(:expire_job_and_pipeline_cache_synchronously, pipeline.project, default_enabled: :yaml)
+ Ci::ExpirePipelineCacheService.new.execute(pipeline)
+ end
+
true
end
diff --git a/app/services/ci/play_build_service.rb b/app/services/ci/play_build_service.rb
index c1cf06a4631..e2673c763f3 100644
--- a/app/services/ci/play_build_service.rb
+++ b/app/services/ci/play_build_service.rb
@@ -9,7 +9,7 @@ module Ci
#
if build.enqueue
build.tap do |build|
- build.update(user: current_user, job_variables_attributes: job_variables_attributes || [])
+ build.update!(user: current_user, job_variables_attributes: job_variables_attributes || [])
AfterRequeueJobService.new(project, current_user).execute(build)
end
diff --git a/app/services/ci/process_sync_events_service.rb b/app/services/ci/process_sync_events_service.rb
new file mode 100644
index 00000000000..6be8c41dc6a
--- /dev/null
+++ b/app/services/ci/process_sync_events_service.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module Ci
+ class ProcessSyncEventsService
+ include Gitlab::Utils::StrongMemoize
+ include ExclusiveLeaseGuard
+
+ BATCH_SIZE = 1000
+
+ def initialize(sync_event_class, sync_class)
+ @sync_event_class = sync_event_class
+ @sync_class = sync_class
+ end
+
+ def execute
+ return unless ::Feature.enabled?(:ci_namespace_project_mirrors, default_enabled: :yaml)
+
+ # preventing parallel processing over the same event table
+ try_obtain_lease { process_events }
+
+ enqueue_worker_if_there_still_event
+ end
+
+ private
+
+ def process_events
+ events = @sync_event_class.preload_synced_relation.first(BATCH_SIZE)
+
+ return if events.empty?
+
+ first = events.first
+ last_processed = nil
+
+ begin
+ events.each do |event|
+ @sync_class.sync!(event)
+
+ last_processed = event
+ end
+ ensure
+ # remove events till the one that was last succesfully processed
+ @sync_event_class.id_in(first.id..last_processed.id).delete_all if last_processed
+ end
+ end
+
+ def enqueue_worker_if_there_still_event
+ @sync_event_class.enqueue_worker if @sync_event_class.exists?
+ end
+
+ def lease_key
+ "#{super}::#{@sync_event_class}"
+ end
+
+ def lease_timeout
+ 1.minute
+ end
+ end
+end
diff --git a/app/services/ci/queue/build_queue_service.rb b/app/services/ci/queue/build_queue_service.rb
index 3c886cb023f..9f476c8a785 100644
--- a/app/services/ci/queue/build_queue_service.rb
+++ b/app/services/ci/queue/build_queue_service.rb
@@ -24,7 +24,7 @@ module Ci
# rubocop:disable CodeReuse/ActiveRecord
def builds_for_group_runner
- if strategy.use_denormalized_namespace_traversal_ids?
+ if strategy.use_denormalized_data_strategy?
strategy.builds_for_group_runner
else
# Workaround for weird Rails bug, that makes `runner.groups.to_sql` to return `runner_id = NULL`
@@ -89,11 +89,9 @@ module Ci
end
def runner_projects_relation
- if ::Feature.enabled?(:ci_pending_builds_project_runners_decoupling, runner, default_enabled: :yaml)
- runner.runner_projects.select('"ci_runner_projects"."project_id"::bigint')
- else
- runner.projects.without_deleted.with_builds_enabled
- end
+ runner
+ .runner_projects
+ .select('"ci_runner_projects"."project_id"::bigint')
end
end
end
diff --git a/app/services/ci/queue/builds_table_strategy.rb b/app/services/ci/queue/builds_table_strategy.rb
index ac449a5289e..237dd510d50 100644
--- a/app/services/ci/queue/builds_table_strategy.rb
+++ b/app/services/ci/queue/builds_table_strategy.rb
@@ -57,15 +57,7 @@ module Ci
relation.pluck(:id)
end
- def use_denormalized_shared_runners_data?
- false
- end
-
- def use_denormalized_minutes_data?
- false
- end
-
- def use_denormalized_namespace_traversal_ids?
+ def use_denormalized_data_strategy?
false
end
diff --git a/app/services/ci/queue/pending_builds_strategy.rb b/app/services/ci/queue/pending_builds_strategy.rb
index 7a913e47df4..47158b8ea1d 100644
--- a/app/services/ci/queue/pending_builds_strategy.rb
+++ b/app/services/ci/queue/pending_builds_strategy.rb
@@ -23,7 +23,7 @@ module Ci
end
def builds_matching_tag_ids(relation, ids)
- if ::Feature.enabled?(:ci_queueing_denormalize_tags_information, runner, default_enabled: :yaml)
+ if use_denormalized_data_strategy?
relation.for_tags(runner.tags_ids)
else
relation.merge(CommitStatus.matches_tag_ids(ids, table: 'ci_pending_builds', column: 'build_id'))
@@ -31,7 +31,7 @@ module Ci
end
def builds_with_any_tags(relation)
- if ::Feature.enabled?(:ci_queueing_denormalize_tags_information, runner, default_enabled: :yaml)
+ if use_denormalized_data_strategy?
relation.where('cardinality(tag_ids) > 0')
else
relation.merge(CommitStatus.with_any_tags(table: 'ci_pending_builds', column: 'build_id'))
@@ -50,22 +50,14 @@ module Ci
relation.pluck(:build_id)
end
- def use_denormalized_shared_runners_data?
- ::Feature.enabled?(:ci_queueing_denormalize_shared_runners_information, runner, type: :development, default_enabled: :yaml)
- end
-
- def use_denormalized_minutes_data?
- ::Feature.enabled?(:ci_queueing_denormalize_ci_minutes_information, runner, type: :development, default_enabled: :yaml)
- end
-
- def use_denormalized_namespace_traversal_ids?
- ::Feature.enabled?(:ci_queueing_denormalize_namespace_traversal_ids, runner, type: :development, default_enabled: :yaml)
+ def use_denormalized_data_strategy?
+ ::Feature.enabled?(:ci_queuing_use_denormalized_data_strategy, default_enabled: :yaml)
end
private
def builds_available_for_shared_runners
- if use_denormalized_shared_runners_data?
+ if use_denormalized_data_strategy?
new_builds.with_instance_runners
else
new_builds
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index 67ef4f10709..e0f0f8f58b8 100644
--- a/app/services/ci/register_job_service.rb
+++ b/app/services/ci/register_job_service.rb
@@ -269,14 +269,7 @@ module Ci
{
missing_dependency_failure: -> (build, _) { !build.has_valid_build_dependencies? },
runner_unsupported: -> (build, params) { !build.supported_runner?(params.dig(:info, :features)) },
- archived_failure: -> (build, _) { build.archived? }
- }.merge(builds_enabled_checks)
- end
-
- def builds_enabled_checks
- return {} unless ::Feature.enabled?(:ci_queueing_builds_enabled_checks, runner, default_enabled: :yaml)
-
- {
+ archived_failure: -> (build, _) { build.archived? },
project_deleted: -> (build, _) { build.project.pending_delete? },
builds_disabled: -> (build, _) { !build.project.builds_enabled? }
}
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index ebb07de9d29..89fe4ff9f60 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -2,12 +2,14 @@
module Ci
class RetryBuildService < ::BaseService
+ include Gitlab::Utils::StrongMemoize
+
def self.clone_accessors
%i[pipeline project ref tag options name
allow_failure stage stage_id stage_idx trigger_request
yaml_variables when environment coverage_regex
description tag_list protected needs_attributes
- resource_group scheduling_type].freeze
+ job_variables_attributes resource_group scheduling_type].freeze
end
def self.extra_accessors
@@ -45,6 +47,11 @@ module Ci
job.save!
end
end
+
+ if create_deployment_in_separate_transaction?
+ clone_deployment!(new_build, build)
+ end
+
build.reset # refresh the data to get new values of `retried` and `processed`.
new_build
@@ -63,7 +70,9 @@ module Ci
def clone_build(build)
project.builds.new(build_attributes(build)).tap do |new_build|
- new_build.assign_attributes(deployment_attributes_for(new_build, build))
+ unless create_deployment_in_separate_transaction?
+ new_build.assign_attributes(deployment_attributes_for(new_build, build))
+ end
end
end
@@ -72,6 +81,11 @@ module Ci
[attribute, build.public_send(attribute)] # rubocop:disable GitlabSecurity/PublicSend
end
+ if create_deployment_in_separate_transaction? && build.persisted_environment.present?
+ attributes[:metadata_attributes] ||= {}
+ attributes[:metadata_attributes][:expanded_environment_name] = build.expanded_environment_name
+ end
+
attributes[:user] = current_user
attributes
end
@@ -80,6 +94,26 @@ module Ci
::Gitlab::Ci::Pipeline::Seed::Build
.deployment_attributes_for(new_build, old_build.persisted_environment)
end
+
+ def clone_deployment!(new_build, old_build)
+ return unless old_build.deployment.present?
+
+ # We should clone the previous deployment attributes instead of initializing
+ # new object with `Seed::Deployment`.
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/347206
+ deployment = ::Gitlab::Ci::Pipeline::Seed::Deployment
+ .new(new_build, old_build.persisted_environment).to_resource
+
+ return unless deployment
+
+ new_build.create_deployment!(deployment.attributes)
+ end
+
+ def create_deployment_in_separate_transaction?
+ strong_memoize(:create_deployment_in_separate_transaction) do
+ ::Feature.enabled?(:create_deployment_in_separate_transaction, project, default_enabled: :yaml)
+ end
+ end
end
end
diff --git a/app/services/ci/stuck_builds/drop_pending_service.rb b/app/services/ci/stuck_builds/drop_pending_service.rb
index 4653e701973..dddd1cfb781 100644
--- a/app/services/ci/stuck_builds/drop_pending_service.rb
+++ b/app/services/ci/stuck_builds/drop_pending_service.rb
@@ -7,7 +7,6 @@ module Ci
BUILD_PENDING_OUTDATED_TIMEOUT = 1.day
BUILD_PENDING_STUCK_TIMEOUT = 1.hour
- BUILD_LOOKBACK = 5.days
def execute
Gitlab::AppLogger.info "#{self.class}: Cleaning pending timed-out builds"
@@ -30,11 +29,11 @@ module Ci
# because we want to force the query planner to use the
# `ci_builds_gitlab_monitor_metrics` index all the time.
def pending_builds(timeout)
- if Feature.enabled?(:ci_new_query_for_pending_stuck_jobs)
- Ci::Build.pending.created_at_before(timeout).updated_at_before(timeout).order(created_at: :asc, project_id: :asc)
- else
- Ci::Build.pending.updated_before(lookback: BUILD_LOOKBACK.ago, timeout: timeout)
- end
+ Ci::Build
+ .pending
+ .created_at_before(timeout)
+ .updated_at_before(timeout)
+ .order(created_at: :asc, project_id: :asc)
end
# rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/services/ci/update_build_queue_service.rb b/app/services/ci/update_build_queue_service.rb
index c1cbf031ca1..146239bb7e5 100644
--- a/app/services/ci/update_build_queue_service.rb
+++ b/app/services/ci/update_build_queue_service.rb
@@ -14,7 +14,7 @@ module Ci
# Add a build to the pending builds queue
#
def push(build, transition)
- return unless maintain_pending_builds_queue?(build)
+ return unless maintain_pending_builds_queue?
raise InvalidQueueTransition unless transition.to == 'pending'
@@ -33,7 +33,7 @@ module Ci
# Remove a build from the pending builds queue
#
def pop(build, transition)
- return unless maintain_pending_builds_queue?(build)
+ return unless maintain_pending_builds_queue?
raise InvalidQueueTransition unless transition.from == 'pending'
@@ -52,7 +52,7 @@ module Ci
# Add shared runner build tracking entry (used for queuing).
#
def track(build, transition)
- return unless Feature.enabled?(:ci_track_shared_runner_builds, build.project, default_enabled: :yaml)
+ return unless maintain_pending_builds_queue?
return unless build.shared_runner_build?
raise InvalidQueueTransition unless transition.to == 'running'
@@ -73,7 +73,7 @@ module Ci
# queuing).
#
def untrack(build, transition)
- return unless Feature.enabled?(:ci_untrack_shared_runner_builds, build.project, default_enabled: :yaml)
+ return unless maintain_pending_builds_queue?
return unless build.shared_runner_build?
raise InvalidQueueTransition unless transition.from == 'running'
@@ -113,8 +113,8 @@ module Ci
end
end
- def maintain_pending_builds_queue?(build)
- Feature.enabled?(:ci_pending_builds_queue_maintain, build.project, default_enabled: :yaml)
+ def maintain_pending_builds_queue?
+ ::Ci::PendingBuild.maintain_denormalized_data?
end
end
end
diff --git a/app/services/ci/update_build_state_service.rb b/app/services/ci/update_build_state_service.rb
index 826d9a2eda3..9df36b86404 100644
--- a/app/services/ci/update_build_state_service.rb
+++ b/app/services/ci/update_build_state_service.rb
@@ -216,11 +216,12 @@ module Ci
end
def chunks_migration_enabled?
- ::Gitlab::Ci::Features.accept_trace?(build.project)
+ ::Feature.enabled?(:ci_enable_live_trace, build.project) &&
+ ::Feature.enabled?(:ci_accept_trace, build.project, type: :ops, default_enabled: true)
end
def log_invalid_chunks?
- ::Gitlab::Ci::Features.log_invalid_trace_chunks?(build.project)
+ ::Feature.enabled?(:ci_trace_log_invalid_chunks, build.project, type: :ops, default_enabled: false)
end
end
end
diff --git a/app/services/ci/update_pending_build_service.rb b/app/services/ci/update_pending_build_service.rb
index d546dbcfe3d..733b684bcc6 100644
--- a/app/services/ci/update_pending_build_service.rb
+++ b/app/services/ci/update_pending_build_service.rb
@@ -9,13 +9,13 @@ module Ci
def initialize(model, update_params)
@model = model
- @update_params = update_params
+ @update_params = update_params.symbolize_keys
validations!
end
def execute
- return unless ::Feature.enabled?(:ci_pending_builds_maintain_shared_runners_data, @model, default_enabled: :yaml)
+ return unless ::Ci::PendingBuild.maintain_denormalized_data?
@model.pending_builds.each_batch do |relation|
relation.update_all(@update_params)
diff --git a/app/services/clusters/agent_tokens/create_service.rb b/app/services/clusters/agent_tokens/create_service.rb
index ae2617f510b..5b8a0e46a6c 100644
--- a/app/services/clusters/agent_tokens/create_service.rb
+++ b/app/services/clusters/agent_tokens/create_service.rb
@@ -11,6 +11,8 @@ module Clusters
token = ::Clusters::AgentToken.new(filtered_params.merge(created_by_user: current_user))
if token.save
+ log_activity_event!(token)
+
ServiceResponse.success(payload: { secret: token.token, token: token })
else
ServiceResponse.error(message: token.errors.full_messages)
@@ -26,6 +28,16 @@ module Clusters
def filtered_params
params.slice(*ALLOWED_PARAMS)
end
+
+ def log_activity_event!(token)
+ token.agent.activity_events.create!(
+ kind: :token_created,
+ level: :info,
+ recorded_at: token.created_at,
+ user: current_user,
+ agent_token: token
+ )
+ end
end
end
end
diff --git a/app/services/clusters/cleanup/project_namespace_service.rb b/app/services/clusters/cleanup/project_namespace_service.rb
index 0173f93f625..80192aa14ab 100644
--- a/app/services/clusters/cleanup/project_namespace_service.rb
+++ b/app/services/clusters/cleanup/project_namespace_service.rb
@@ -26,8 +26,10 @@ module Clusters
begin
kubeclient_delete_namespace(kubernetes_namespace)
- rescue Kubeclient::HttpError
- next
+ rescue Kubeclient::HttpError => e
+ # unauthorized, forbidden: GitLab's access has been revoked
+ # certificate verify failed: Cluster is probably gone forever
+ raise unless e.message =~ /unauthorized|forbidden|certificate verify failed/i
end
kubernetes_namespace.destroy!
diff --git a/app/services/clusters/cleanup/service_account_service.rb b/app/services/clusters/cleanup/service_account_service.rb
index 53f968cd409..dce41d2a39c 100644
--- a/app/services/clusters/cleanup/service_account_service.rb
+++ b/app/services/clusters/cleanup/service_account_service.rb
@@ -24,6 +24,10 @@ module Clusters
# The resources have already been deleted, possibly on a previous attempt that timed out
rescue Gitlab::UrlBlocker::BlockedUrlError
# User gave an invalid cluster from the start, or deleted the endpoint before this job ran
+ rescue Kubeclient::HttpError => e
+ # unauthorized, forbidden: GitLab's access has been revoked
+ # certificate verify failed: Cluster is probably gone forever
+ raise unless e.message =~ /unauthorized|forbidden|certificate verify failed/i
end
end
end
diff --git a/app/services/cohorts_service.rb b/app/services/cohorts_service.rb
index 7bc3b267a12..1b1598b301c 100644
--- a/app/services/cohorts_service.rb
+++ b/app/services/cohorts_service.rb
@@ -38,7 +38,7 @@ class CohortsService
{
registration_month: registration_month,
- activity_months: activity_months[1..-1],
+ activity_months: activity_months[1..],
total: activity_months.first[:total],
inactive: inactive
}
diff --git a/app/services/concerns/admin/propagate_service.rb b/app/services/concerns/admin/propagate_service.rb
deleted file mode 100644
index 03e422aec54..00000000000
--- a/app/services/concerns/admin/propagate_service.rb
+++ /dev/null
@@ -1,37 +0,0 @@
-# frozen_string_literal: true
-
-module Admin
- module PropagateService
- extend ActiveSupport::Concern
-
- BATCH_SIZE = 10_000
-
- class_methods do
- def propagate(integration)
- new(integration).propagate
- end
- end
-
- def initialize(integration)
- @integration = integration
- end
-
- private
-
- attr_reader :integration
-
- def create_integration_for_projects_without_integration
- propagate_integrations(
- Project.without_integration(integration),
- PropagateIntegrationProjectWorker
- )
- end
-
- def propagate_integrations(relation, worker_class)
- relation.each_batch(of: BATCH_SIZE) do |records|
- min_id, max_id = records.pick("MIN(#{relation.table_name}.id), MAX(#{relation.table_name}.id)")
- worker_class.perform_async(integration.id, min_id, max_id)
- end
- end
- end
-end
diff --git a/app/services/concerns/audit_event_save_type.rb b/app/services/concerns/audit_event_save_type.rb
new file mode 100644
index 00000000000..6696e4adae7
--- /dev/null
+++ b/app/services/concerns/audit_event_save_type.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module AuditEventSaveType
+ SAVE_TYPES = {
+ database: 0b01,
+ stream: 0b10,
+ database_and_stream: 0b11
+ }.freeze
+
+ # def should_save_stream?(type)
+ # def should_save_database?(type)
+ [:database, :stream].each do |type|
+ define_method("should_save_#{type}?") do |param_type|
+ return false unless save_type_valid?(param_type)
+
+ # If the current type does not support query, the result of the `&` operation is 0 .
+ SAVE_TYPES[param_type] & SAVE_TYPES[type] != 0
+ end
+ end
+
+ private
+
+ def save_type_valid?(type)
+ SAVE_TYPES.key?(type)
+ end
+end
diff --git a/app/services/concerns/protected_ref_name_sanitizer.rb b/app/services/concerns/protected_ref_name_sanitizer.rb
new file mode 100644
index 00000000000..3966c410fec
--- /dev/null
+++ b/app/services/concerns/protected_ref_name_sanitizer.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module ProtectedRefNameSanitizer
+ def sanitize_name(name)
+ name = CGI.unescapeHTML(name)
+ name = Sanitize.fragment(name)
+
+ # Sanitize.fragment escapes HTML chars, so unescape again to allow names
+ # like `feature->master`
+ CGI.unescapeHTML(name)
+ end
+end
diff --git a/app/services/dependency_proxy/find_or_create_manifest_service.rb b/app/services/dependency_proxy/find_cached_manifest_service.rb
index aeb62be9f3a..faf0402edaa 100644
--- a/app/services/dependency_proxy/find_or_create_manifest_service.rb
+++ b/app/services/dependency_proxy/find_cached_manifest_service.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module DependencyProxy
- class FindOrCreateManifestService < DependencyProxy::BaseService
+ class FindCachedManifestService < DependencyProxy::BaseService
def initialize(group, image, tag, token)
@group = group
@image = image
@@ -20,36 +20,13 @@ module DependencyProxy
return respond if cached_manifest_matches?(head_result)
- if Feature.enabled?(:dependency_proxy_manifest_workhorse, @group, default_enabled: :yaml)
- success(manifest: nil, from_cache: false)
- else
- pull_new_manifest
- respond(from_cache: false)
- end
+ success(manifest: nil, from_cache: false)
rescue Timeout::Error, *Gitlab::HTTP::HTTP_ERRORS
respond
end
private
- def pull_new_manifest
- DependencyProxy::PullManifestService.new(@image, @tag, @token).execute_with_manifest do |new_manifest|
- params = {
- file_name: @file_name,
- content_type: new_manifest[:content_type],
- digest: new_manifest[:digest],
- file: new_manifest[:file],
- size: new_manifest[:file].size
- }
-
- if @manifest
- @manifest.update!(params)
- else
- @manifest = @group.dependency_proxy_manifests.create!(params)
- end
- end
- end
-
def cached_manifest_matches?(head_result)
return false if head_result[:status] == :error
diff --git a/app/services/dependency_proxy/pull_manifest_service.rb b/app/services/dependency_proxy/pull_manifest_service.rb
deleted file mode 100644
index e8f0ad6374a..00000000000
--- a/app/services/dependency_proxy/pull_manifest_service.rb
+++ /dev/null
@@ -1,47 +0,0 @@
-# frozen_string_literal: true
-
-module DependencyProxy
- class PullManifestService < DependencyProxy::BaseService
- def initialize(image, tag, token)
- @image = image
- @tag = tag
- @token = token
- end
-
- def execute_with_manifest
- raise ArgumentError, 'Block must be provided' unless block_given?
-
- response = Gitlab::HTTP.get(manifest_url, headers: auth_headers.merge(Accept: ::ContainerRegistry::Client::ACCEPTED_TYPES.join(',')))
-
- if response.success?
- file = Tempfile.new
-
- begin
- file.write(response.body)
- file.flush
-
- yield(
- success(
- file: file,
- digest: response.headers[DependencyProxy::Manifest::DIGEST_HEADER],
- content_type: response.headers['content-type']
- )
- )
- ensure
- file.close
- file.unlink
- end
- else
- yield(error(response.body, response.code))
- end
- rescue Timeout::Error => exception
- error(exception.message, 599)
- end
-
- private
-
- def manifest_url
- registry.manifest_url(@image, @tag)
- end
- end
-end
diff --git a/app/services/deployments/older_deployments_drop_service.rb b/app/services/deployments/older_deployments_drop_service.rb
index 504b55b99ac..15384fb0db1 100644
--- a/app/services/deployments/older_deployments_drop_service.rb
+++ b/app/services/deployments/older_deployments_drop_service.rb
@@ -12,6 +12,8 @@ module Deployments
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
diff --git a/app/services/events/destroy_service.rb b/app/services/events/destroy_service.rb
new file mode 100644
index 00000000000..fdb718f0fcb
--- /dev/null
+++ b/app/services/events/destroy_service.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Events
+ class DestroyService
+ def initialize(project)
+ @project = project
+ end
+
+ def execute
+ project.events.all.delete_all
+
+ ServiceResponse.success(message: 'Events were deleted.')
+ rescue StandardError
+ ServiceResponse.error(message: 'Failed to remove events.')
+ end
+
+ private
+
+ attr_reader :project
+ end
+end
diff --git a/app/services/feature_flags/base_service.rb b/app/services/feature_flags/base_service.rb
index ca0b6b89199..86dc6188f0a 100644
--- a/app/services/feature_flags/base_service.rb
+++ b/app/services/feature_flags/base_service.rb
@@ -43,6 +43,7 @@ module FeatureFlags
def sync_to_jira(feature_flag)
return unless feature_flag.present?
+ return unless project.jira_subscription_exists?
seq_id = ::Atlassian::JiraConnect::Client.generate_update_sequence_id
feature_flag.run_after_commit do
diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb
index 9b113be5465..aa471d3a69f 100644
--- a/app/services/git/branch_hooks_service.rb
+++ b/app/services/git/branch_hooks_service.rb
@@ -157,11 +157,11 @@ module Git
end
def unsigned_x509_shas(commits)
- X509CommitSignature.unsigned_commit_shas(commits.map(&:sha))
+ CommitSignatures::X509CommitSignature.unsigned_commit_shas(commits.map(&:sha))
end
def unsigned_gpg_shas(commits)
- GpgSignature.unsigned_commit_shas(commits.map(&:sha))
+ CommitSignatures::GpgSignature.unsigned_commit_shas(commits.map(&:sha))
end
def enqueue_update_signatures
diff --git a/app/services/git/branch_push_service.rb b/app/services/git/branch_push_service.rb
index 5bf39d98fa3..13223872e4f 100644
--- a/app/services/git/branch_push_service.rb
+++ b/app/services/git/branch_push_service.rb
@@ -26,7 +26,6 @@ module Git
enqueue_detect_repository_languages
execute_related_hooks
- perform_housekeeping
stop_environments
unlock_artifacts
@@ -71,13 +70,6 @@ module Git
BranchHooksService.new(project, current_user, params).execute
end
- def perform_housekeeping
- housekeeping = Repositories::HousekeepingService.new(project)
- housekeeping.increment!
- housekeeping.execute if housekeeping.needed?
- rescue Repositories::HousekeepingService::LeaseTaken
- end
-
def removing_branch?
Gitlab::Git.blank_ref?(newrev)
end
diff --git a/app/services/git/process_ref_changes_service.rb b/app/services/git/process_ref_changes_service.rb
index da05f18b5ac..d4081fc149b 100644
--- a/app/services/git/process_ref_changes_service.rb
+++ b/app/services/git/process_ref_changes_service.rb
@@ -9,6 +9,8 @@ module Git
process_changes_by_action(:branch, changes.branch_changes)
process_changes_by_action(:tag, changes.tag_changes)
+
+ perform_housekeeping
end
private
@@ -83,5 +85,12 @@ module Git
MergeRequests::PushedBranchesService.new(project: project, current_user: current_user, params: { changes: changes }).execute
end
+
+ def perform_housekeeping
+ housekeeping = Repositories::HousekeepingService.new(project)
+ housekeeping.increment!
+ housekeeping.execute if housekeeping.needed?
+ rescue Repositories::HousekeepingService::LeaseTaken
+ end
end
end
diff --git a/app/services/google_cloud/service_accounts_service.rb b/app/services/google_cloud/service_accounts_service.rb
index 29ed69693b0..a512b27493d 100644
--- a/app/services/google_cloud/service_accounts_service.rb
+++ b/app/services/google_cloud/service_accounts_service.rb
@@ -27,6 +27,24 @@ module GoogleCloud
end
end
+ def add_for_project(environment, gcp_project_id, service_account, service_account_key)
+ project_var_create_or_replace(
+ environment,
+ 'GCP_PROJECT_ID',
+ gcp_project_id
+ )
+ project_var_create_or_replace(
+ environment,
+ 'GCP_SERVICE_ACCOUNT',
+ service_account
+ )
+ project_var_create_or_replace(
+ environment,
+ 'GCP_SERVICE_ACCOUNT_KEY',
+ service_account_key
+ )
+ end
+
private
def group_vars_by_environment
@@ -36,5 +54,12 @@ module GoogleCloud
grouped[variable.environment_scope][variable.key] = variable.value
end
end
+
+ def project_var_create_or_replace(environment_scope, key, value)
+ params = { key: key, filter: { environment_scope: environment_scope } }
+ existing_variable = ::Ci::VariablesFinder.new(@project, params).execute.first
+ existing_variable.destroy if existing_variable
+ @project.variables.create!(key: key, value: value, environment_scope: environment_scope, protected: true)
+ end
end
end
diff --git a/app/services/gravatar_service.rb b/app/services/gravatar_service.rb
index 2a7a5dae291..a689b088854 100644
--- a/app/services/gravatar_service.rb
+++ b/app/services/gravatar_service.rb
@@ -8,7 +8,7 @@ class GravatarService
return unless identifier
hash = Digest::MD5.hexdigest(identifier.strip.downcase)
- size = 40 unless size && size > 0
+ size = Groups::GroupMembersHelper::AVATAR_SIZE unless size && size > 0
sprintf gravatar_url,
hash: hash,
diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb
index cd89eb799dc..10ff4961faf 100644
--- a/app/services/groups/transfer_service.rb
+++ b/app/services/groups/transfer_service.rb
@@ -29,11 +29,11 @@ module Groups
update_group_attributes
ensure_ownership
update_integrations
- update_pending_builds!
end
post_update_hooks(@updated_project_ids)
propagate_integrations
+ update_pending_builds
true
end
@@ -228,13 +228,15 @@ module Groups
end
end
- def update_pending_builds!
- update_params = {
+ def update_pending_builds
+ ::Ci::PendingBuilds::UpdateGroupWorker.perform_async(group.id, pending_builds_params)
+ end
+
+ def pending_builds_params
+ {
namespace_traversal_ids: group.traversal_ids,
namespace_id: group.id
}
-
- ::Ci::UpdatePendingBuildService.new(group, update_params).execute
end
end
end
diff --git a/app/services/groups/update_shared_runners_service.rb b/app/services/groups/update_shared_runners_service.rb
index eb6b46a5613..c09dce0761f 100644
--- a/app/services/groups/update_shared_runners_service.rb
+++ b/app/services/groups/update_shared_runners_service.rb
@@ -8,7 +8,7 @@ module Groups
validate_params
update_shared_runners
- update_pending_builds!
+ update_pending_builds_async
success
@@ -28,12 +28,18 @@ module Groups
group.update_shared_runners_setting!(params[:shared_runners_setting])
end
- def update_pending_builds!
- return unless group.previous_changes.include?('shared_runners_enabled')
+ def update_pending_builds?
+ group.previous_changes.include?('shared_runners_enabled')
+ end
+
+ def update_pending_builds_async
+ return unless update_pending_builds?
- update_params = { instance_runners_enabled: group.shared_runners_enabled }
+ group.run_after_commit_or_now do |group|
+ pending_builds_params = { instance_runners_enabled: group.shared_runners_enabled }
- ::Ci::UpdatePendingBuildService.new(group, update_params).execute
+ ::Ci::UpdatePendingBuildService.new(group, pending_builds_params).execute
+ end
end
end
end
diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb
index 2aaab88e778..061543b5885 100644
--- a/app/services/import/github_service.rb
+++ b/app/services/import/github_service.rb
@@ -10,7 +10,7 @@ module Import
def execute(access_params, provider)
if blocked_url?
- return log_and_return_error("Invalid URL: #{url}", :bad_request)
+ return log_and_return_error("Invalid URL: #{url}", _("Invalid URL: %{url}") % { url: url }, :bad_request)
end
unless authorized?
@@ -119,6 +119,15 @@ module Import
error(_('Import failed due to a GitHub error: %{original}') % { original: exception.response_body }, :unprocessable_entity)
end
+
+ def log_and_return_error(message, translated_message, http_status)
+ Gitlab::GithubImport::Logger.error(
+ message: 'Error while attempting to import from GitHub',
+ error: message
+ )
+
+ error(translated_message, http_status)
+ end
end
end
diff --git a/app/services/incident_management/issuable_escalation_statuses/create_service.rb b/app/services/incident_management/issuable_escalation_statuses/create_service.rb
new file mode 100644
index 00000000000..e28debf0fa3
--- /dev/null
+++ b/app/services/incident_management/issuable_escalation_statuses/create_service.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module IncidentManagement
+ module IssuableEscalationStatuses
+ class CreateService < BaseService
+ def initialize(issue)
+ @issue = issue
+ @alert = issue.alert_management_alert
+ end
+
+ def execute
+ escalation_status = ::IncidentManagement::IssuableEscalationStatus.new(issue: issue, **alert_params)
+
+ if escalation_status.save
+ ServiceResponse.success(payload: { escalation_status: escalation_status })
+ else
+ ServiceResponse.error(message: escalation_status.errors&.full_messages)
+ end
+ end
+
+ private
+
+ attr_reader :issue, :alert
+
+ def alert_params
+ return {} unless alert
+
+ {
+ status_event: alert.status_event_for(alert.status_name)
+ }
+ end
+ end
+ end
+end
+
+IncidentManagement::IssuableEscalationStatuses::CreateService.prepend_mod
diff --git a/app/services/admin/propagate_integration_service.rb b/app/services/integrations/propagate_service.rb
index f7a4bf1a9f9..6d27929d2d0 100644
--- a/app/services/admin/propagate_integration_service.rb
+++ b/app/services/integrations/propagate_service.rb
@@ -1,8 +1,12 @@
# frozen_string_literal: true
-module Admin
- class PropagateIntegrationService
- include PropagateService
+module Integrations
+ class PropagateService
+ BATCH_SIZE = 10_000
+
+ def initialize(integration)
+ @integration = integration
+ end
def propagate
if integration.instance_level?
@@ -16,8 +20,21 @@ module Admin
end
end
+ def self.propagate(integration)
+ new(integration).propagate
+ end
+
private
+ attr_reader :integration
+
+ def create_integration_for_projects_without_integration
+ propagate_integrations(
+ Project.without_integration(integration),
+ PropagateIntegrationProjectWorker
+ )
+ end
+
def update_inherited_integrations
propagate_integrations(
Integration.by_type(integration.type).inherit_from_id(integration.id),
@@ -52,5 +69,12 @@ module Admin
PropagateIntegrationProjectWorker
)
end
+
+ def propagate_integrations(relation, worker_class)
+ relation.each_batch(of: BATCH_SIZE) do |records|
+ min_id, max_id = records.pick("MIN(#{relation.table_name}.id), MAX(#{relation.table_name}.id)")
+ worker_class.perform_async(integration.id, min_id, max_id)
+ end
+ end
end
end
diff --git a/app/services/integrations/propagate_template_service.rb b/app/services/integrations/propagate_template_service.rb
new file mode 100644
index 00000000000..85a82ba4c8e
--- /dev/null
+++ b/app/services/integrations/propagate_template_service.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Integrations
+ # TODO: Remove this as part of https://gitlab.com/gitlab-org/gitlab/-/issues/335178
+ class PropagateTemplateService
+ def self.propagate(_integration)
+ # no-op
+ end
+ end
+end
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 2daf098b94a..1d1d9b6bec7 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -56,6 +56,8 @@ class IssuableBaseService < ::BaseProjectService
# confidential attribute is a special type of metadata and needs to be allowed to be set
# by non-members on issues in public projects so that security issues can be reported as confidential.
params.delete(:confidential) unless can?(current_user, :set_confidentiality, issuable)
+ params.delete(:add_contacts) unless can?(current_user, :set_issue_crm_contacts, issuable)
+ params.delete(:remove_contacts) unless can?(current_user, :set_issue_crm_contacts, issuable)
filter_assignees(issuable)
filter_milestone
@@ -206,6 +208,9 @@ class IssuableBaseService < ::BaseProjectService
params[:assignee_ids] = process_assignee_ids(params, extra_assignee_ids: issuable.assignee_ids.to_a)
end
+ params.delete(:remove_contacts)
+ add_crm_contact_emails = params.delete(:add_contacts)
+
issuable.assign_attributes(allowed_create_params(params))
before_create(issuable)
@@ -219,6 +224,7 @@ class IssuableBaseService < ::BaseProjectService
handle_changes(issuable, { params: params })
after_create(issuable)
+ set_crm_contacts(issuable, add_crm_contact_emails)
execute_hooks(issuable)
users_to_invalidate = issuable.allows_reviewers? ? issuable.assignees | issuable.reviewers : issuable.assignees
@@ -229,6 +235,12 @@ class IssuableBaseService < ::BaseProjectService
issuable
end
+ def set_crm_contacts(issuable, add_crm_contact_emails, remove_crm_contact_emails = [])
+ return unless add_crm_contact_emails.present? || remove_crm_contact_emails.present?
+
+ ::Issues::SetCrmContactsService.new(project: project, current_user: current_user, params: { add_emails: add_crm_contact_emails, remove_emails: remove_crm_contact_emails }).execute(issuable)
+ end
+
def before_create(issuable)
# To be overridden by subclasses
end
@@ -254,6 +266,7 @@ class IssuableBaseService < ::BaseProjectService
assign_requested_labels(issuable)
assign_requested_assignees(issuable)
+ assign_requested_crm_contacts(issuable)
if issuable.changed? || params.present?
issuable.assign_attributes(allowed_update_params(params))
@@ -414,6 +427,12 @@ class IssuableBaseService < ::BaseProjectService
issuable.touch
end
+ def assign_requested_crm_contacts(issuable)
+ add_crm_contact_emails = params.delete(:add_contacts)
+ remove_crm_contact_emails = params.delete(:remove_contacts)
+ set_crm_contacts(issuable, add_crm_contact_emails, remove_crm_contact_emails)
+ end
+
def assign_requested_assignees(issuable)
return if issuable.is_a?(Epic)
diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb
index efb5de5b17c..577f7dd1e3a 100644
--- a/app/services/issues/base_service.rb
+++ b/app/services/issues/base_service.rb
@@ -30,7 +30,7 @@ module Issues
gates = [issue.project, issue.project.group].compact
return unless gates.any? { |gate| Feature.enabled?(:rebalance_issues, gate) }
- IssueRebalancingWorker.perform_async(nil, *issue.project.self_or_root_group_ids)
+ Issues::RebalancingWorker.perform_async(nil, *issue.project.self_or_root_group_ids)
end
private
diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb
index fa8d380404b..79b59eee5e1 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -41,7 +41,7 @@ module Issues
user = current_user
issue.run_after_commit do
NewIssueWorker.perform_async(issue.id, user.id)
- IssuePlacementWorker.perform_async(nil, issue.project_id)
+ Issues::PlacementWorker.perform_async(nil, issue.project_id)
Namespaces::OnboardingIssueCreatedWorker.perform_async(issue.namespace.id)
end
end
@@ -50,6 +50,7 @@ module Issues
def after_create(issue)
user_agent_detail_service.create
resolve_discussions_with_issue(issue)
+ create_escalation_status(issue)
super
end
@@ -80,6 +81,10 @@ module Issues
attr_reader :spam_params
+ def create_escalation_status(issue)
+ ::IncidentManagement::IssuableEscalationStatuses::CreateService.new(issue).execute if issue.supports_escalation?
+ end
+
def user_agent_detail_service
UserAgentDetailService.new(spammable: @issue, spam_params: spam_params)
end
diff --git a/app/services/issues/set_crm_contacts_service.rb b/app/services/issues/set_crm_contacts_service.rb
index 13fe30b5ac8..c435ab81b4d 100644
--- a/app/services/issues/set_crm_contacts_service.rb
+++ b/app/services/issues/set_crm_contacts_service.rb
@@ -2,10 +2,9 @@
module Issues
class SetCrmContactsService < ::BaseProjectService
- attr_accessor :issue, :errors
-
MAX_ADDITIONAL_CONTACTS = 6
+ # Replacing contacts by email is not currently supported
def execute(issue)
@issue = issue
@errors = []
@@ -13,33 +12,49 @@ module Issues
return error_no_permissions unless allowed?
return error_invalid_params unless valid_params?
- determine_changes if params[:crm_contact_ids]
-
+ @existing_ids = issue.customer_relations_contact_ids
+ determine_changes if params[:replace_ids].present?
return error_too_many if too_many?
- add_contacts if params[:add_crm_contact_ids]
- remove_contacts if params[:remove_crm_contact_ids]
+ add if params[:add_ids].present?
+ remove if params[:remove_ids].present?
+
+ add_by_email if params[:add_emails].present?
+ remove_by_email if params[:remove_emails].present?
if issue.valid?
+ GraphqlTriggers.issue_crm_contacts_updated(issue)
+ issue.touch
ServiceResponse.success(payload: issue)
else
# The default error isn't very helpful: "Issue customer relations contacts is invalid"
issue.errors.delete(:issue_customer_relations_contacts)
issue.errors.add(:issue_customer_relations_contacts, errors.to_sentence)
- ServiceResponse.error(payload: issue, message: issue.errors.full_messages)
+ ServiceResponse.error(payload: issue, message: issue.errors.full_messages.to_sentence)
end
end
private
+ attr_accessor :issue, :errors, :existing_ids
+
def determine_changes
- existing_contact_ids = issue.issue_customer_relations_contacts.map(&:contact_id)
- params[:add_crm_contact_ids] = params[:crm_contact_ids] - existing_contact_ids
- params[:remove_crm_contact_ids] = existing_contact_ids - params[:crm_contact_ids]
+ params[:add_ids] = params[:replace_ids] - existing_ids
+ params[:remove_ids] = existing_ids - params[:replace_ids]
+ end
+
+ def add
+ add_by_id(params[:add_ids])
+ end
+
+ def add_by_email
+ contact_ids = ::CustomerRelations::Contact.find_ids_by_emails(project_group.id, params[:add_emails])
+ add_by_id(contact_ids)
end
- def add_contacts
- params[:add_crm_contact_ids].uniq.each do |contact_id|
+ def add_by_id(contact_ids)
+ contact_ids -= existing_ids
+ contact_ids.uniq.each do |contact_id|
issue_contact = issue.issue_customer_relations_contacts.create(contact_id: contact_id)
unless issue_contact.persisted?
@@ -49,9 +64,19 @@ module Issues
end
end
- def remove_contacts
+ def remove
+ remove_by_id(params[:remove_ids])
+ end
+
+ def remove_by_email
+ contact_ids = ::CustomerRelations::IssueContact.find_contact_ids_by_emails(issue.id, params[:remove_emails])
+ remove_by_id(contact_ids)
+ end
+
+ def remove_by_id(contact_ids)
+ contact_ids &= existing_ids
issue.issue_customer_relations_contacts
- .where(contact_id: params[:remove_crm_contact_ids]) # rubocop: disable CodeReuse/ActiveRecord
+ .where(contact_id: contact_ids) # rubocop: disable CodeReuse/ActiveRecord
.delete_all
end
@@ -64,27 +89,43 @@ module Issues
end
def set_present?
- params[:crm_contact_ids].present?
+ params[:replace_ids].present?
end
def add_or_remove_present?
- params[:add_crm_contact_ids].present? || params[:remove_crm_contact_ids].present?
+ add_present? || remove_present?
+ end
+
+ def add_present?
+ params[:add_ids].present? || params[:add_emails].present?
+ end
+
+ def remove_present?
+ params[:remove_ids].present? || params[:remove_emails].present?
end
def too_many?
- params[:add_crm_contact_ids] && params[:add_crm_contact_ids].length > MAX_ADDITIONAL_CONTACTS
+ too_many_ids? || too_many_emails?
+ end
+
+ def too_many_ids?
+ params[:add_ids] && params[:add_ids].length > MAX_ADDITIONAL_CONTACTS
+ end
+
+ def too_many_emails?
+ params[:add_emails] && params[:add_emails].length > MAX_ADDITIONAL_CONTACTS
end
def error_no_permissions
- ServiceResponse.error(message: ['You have insufficient permissions to set customer relations contacts for this issue'])
+ ServiceResponse.error(message: _('You have insufficient permissions to set customer relations contacts for this issue'))
end
def error_invalid_params
- ServiceResponse.error(message: ['You cannot combine crm_contact_ids with add_crm_contact_ids or remove_crm_contact_ids'])
+ ServiceResponse.error(message: _('You cannot combine replace_ids with add_ids or remove_ids'))
end
def error_too_many
- ServiceResponse.error(payload: issue, message: ["You can only add up to #{MAX_ADDITIONAL_CONTACTS} contacts at one time"])
+ ServiceResponse.error(payload: issue, message: _("You can only add up to %{max_contacts} contacts at one time" % { max_contacts: MAX_ADDITIONAL_CONTACTS }))
end
end
end
diff --git a/app/services/loose_foreign_keys/batch_cleaner_service.rb b/app/services/loose_foreign_keys/batch_cleaner_service.rb
index 06c05e8ff54..de52cbba576 100644
--- a/app/services/loose_foreign_keys/batch_cleaner_service.rb
+++ b/app/services/loose_foreign_keys/batch_cleaner_service.rb
@@ -2,11 +2,11 @@
module LooseForeignKeys
class BatchCleanerService
- def initialize(parent_klass:, deleted_parent_records:, modification_tracker: LooseForeignKeys::ModificationTracker.new, models_by_table_name:)
- @parent_klass = parent_klass
+ def initialize(parent_table:, loose_foreign_key_definitions:, deleted_parent_records:, modification_tracker: LooseForeignKeys::ModificationTracker.new)
+ @parent_table = parent_table
+ @loose_foreign_key_definitions = loose_foreign_key_definitions
@deleted_parent_records = deleted_parent_records
@modification_tracker = modification_tracker
- @models_by_table_name = models_by_table_name
@deleted_records_counter = Gitlab::Metrics.counter(
:loose_foreign_key_processed_deleted_records,
'The number of processed loose foreign key deleted records'
@@ -14,11 +14,11 @@ module LooseForeignKeys
end
def execute
- parent_klass.loose_foreign_key_definitions.each do |foreign_key_definition|
- run_cleaner_service(foreign_key_definition, with_skip_locked: true)
+ loose_foreign_key_definitions.each do |loose_foreign_key_definition|
+ run_cleaner_service(loose_foreign_key_definition, with_skip_locked: true)
break if modification_tracker.over_limit?
- run_cleaner_service(foreign_key_definition, with_skip_locked: false)
+ run_cleaner_service(loose_foreign_key_definition, with_skip_locked: false)
break if modification_tracker.over_limit?
end
@@ -27,12 +27,12 @@ module LooseForeignKeys
# At this point, all associations are cleaned up, we can update the status of the parent records
update_count = LooseForeignKeys::DeletedRecord.mark_records_processed(deleted_parent_records)
- deleted_records_counter.increment({ table: parent_klass.table_name, db_config_name: LooseForeignKeys::DeletedRecord.connection.pool.db_config.name }, update_count)
+ deleted_records_counter.increment({ table: parent_table, db_config_name: LooseForeignKeys::DeletedRecord.connection.pool.db_config.name }, update_count)
end
private
- attr_reader :parent_klass, :deleted_parent_records, :modification_tracker, :models_by_table_name, :deleted_records_counter
+ attr_reader :parent_table, :loose_foreign_key_definitions, :deleted_parent_records, :modification_tracker, :deleted_records_counter
def record_result(cleaner, result)
if cleaner.async_delete?
@@ -42,19 +42,22 @@ module LooseForeignKeys
end
end
- def run_cleaner_service(foreign_key_definition, with_skip_locked:)
- cleaner = CleanerService.new(
- model: models_by_table_name.fetch(foreign_key_definition.to_table),
- foreign_key_definition: foreign_key_definition,
- deleted_parent_records: deleted_parent_records,
- with_skip_locked: with_skip_locked
- )
+ def run_cleaner_service(loose_foreign_key_definition, with_skip_locked:)
+ base_models_for_gitlab_schema = Gitlab::Database.schemas_to_base_models.fetch(loose_foreign_key_definition.options[:gitlab_schema])
+ base_models_for_gitlab_schema.each do |base_model|
+ cleaner = CleanerService.new(
+ loose_foreign_key_definition: loose_foreign_key_definition,
+ connection: base_model.connection,
+ deleted_parent_records: deleted_parent_records,
+ with_skip_locked: with_skip_locked
+ )
- loop do
- result = cleaner.execute
- record_result(cleaner, result)
+ loop do
+ result = cleaner.execute
+ record_result(cleaner, result)
- break if modification_tracker.over_limit? || result[:affected_rows] == 0
+ break if modification_tracker.over_limit? || result[:affected_rows] == 0
+ end
end
end
end
diff --git a/app/services/loose_foreign_keys/cleaner_service.rb b/app/services/loose_foreign_keys/cleaner_service.rb
index 8fe053e2edf..44a922aad87 100644
--- a/app/services/loose_foreign_keys/cleaner_service.rb
+++ b/app/services/loose_foreign_keys/cleaner_service.rb
@@ -6,11 +6,9 @@ module LooseForeignKeys
DELETE_LIMIT = 1000
UPDATE_LIMIT = 500
- delegate :connection, to: :model
-
- def initialize(model:, foreign_key_definition:, deleted_parent_records:, with_skip_locked: false)
- @model = model
- @foreign_key_definition = foreign_key_definition
+ def initialize(loose_foreign_key_definition:, connection:, deleted_parent_records:, with_skip_locked: false)
+ @loose_foreign_key_definition = loose_foreign_key_definition
+ @connection = connection
@deleted_parent_records = deleted_parent_records
@with_skip_locked = with_skip_locked
end
@@ -18,20 +16,20 @@ module LooseForeignKeys
def execute
result = connection.execute(build_query)
- { affected_rows: result.cmd_tuples, table: foreign_key_definition.to_table }
+ { affected_rows: result.cmd_tuples, table: loose_foreign_key_definition.from_table }
end
def async_delete?
- foreign_key_definition.on_delete == :async_delete
+ loose_foreign_key_definition.on_delete == :async_delete
end
def async_nullify?
- foreign_key_definition.on_delete == :async_nullify
+ loose_foreign_key_definition.on_delete == :async_nullify
end
private
- attr_reader :model, :foreign_key_definition, :deleted_parent_records, :with_skip_locked
+ attr_reader :loose_foreign_key_definition, :connection, :deleted_parent_records, :with_skip_locked
def build_query
query = if async_delete?
@@ -39,10 +37,10 @@ module LooseForeignKeys
elsif async_nullify?
update_query
else
- raise "Invalid on_delete argument: #{foreign_key_definition.on_delete}"
+ raise "Invalid on_delete argument: #{loose_foreign_key_definition.on_delete}"
end
- unless query.include?(%{"#{foreign_key_definition.column}" IN (})
+ unless query.include?(%{"#{loose_foreign_key_definition.column}" IN (})
raise("FATAL: foreign key condition is missing from the generated query: #{query}")
end
@@ -50,15 +48,15 @@ module LooseForeignKeys
end
def arel_table
- @arel_table ||= model.arel_table
+ @arel_table ||= Arel::Table.new(loose_foreign_key_definition.from_table)
end
def primary_keys
- @primary_keys ||= connection.primary_keys(model.table_name).map { |key| arel_table[key] }
+ @primary_keys ||= connection.primary_keys(loose_foreign_key_definition.from_table).map { |key| arel_table[key] }
end
def quoted_table_name
- @quoted_table_name ||= Arel.sql(connection.quote_table_name(model.table_name))
+ @quoted_table_name ||= Arel.sql(connection.quote_table_name(loose_foreign_key_definition.from_table))
end
def delete_query
@@ -71,7 +69,7 @@ module LooseForeignKeys
def update_query
query = Arel::UpdateManager.new
query.table(quoted_table_name)
- query.set([[arel_table[foreign_key_definition.column], nil]])
+ query.set([[arel_table[loose_foreign_key_definition.column], nil]])
add_in_query_with_limit(query, UPDATE_LIMIT)
end
@@ -88,7 +86,7 @@ module LooseForeignKeys
def in_query_with_limit(limit)
in_query = Arel::SelectManager.new
in_query.from(quoted_table_name)
- in_query.where(arel_table[foreign_key_definition.column].in(deleted_parent_records.map(&:primary_key_value)))
+ in_query.where(arel_table[loose_foreign_key_definition.column].in(deleted_parent_records.map(&:primary_key_value)))
in_query.projections = primary_keys
in_query.take(limit)
in_query.lock(Arel.sql('FOR UPDATE SKIP LOCKED')) if with_skip_locked
diff --git a/app/services/loose_foreign_keys/process_deleted_records_service.rb b/app/services/loose_foreign_keys/process_deleted_records_service.rb
index 735fc8a2415..025829aa774 100644
--- a/app/services/loose_foreign_keys/process_deleted_records_service.rb
+++ b/app/services/loose_foreign_keys/process_deleted_records_service.rb
@@ -21,13 +21,16 @@ module LooseForeignKeys
break if modification_tracker.over_limit?
- model = find_parent_model!(table)
+ loose_foreign_key_definitions = Gitlab::Database::LooseForeignKeys.definitions_by_table[table]
+
+ next if loose_foreign_key_definitions.empty?
LooseForeignKeys::BatchCleanerService
- .new(parent_klass: model,
- deleted_parent_records: records,
- modification_tracker: modification_tracker,
- models_by_table_name: models_by_table_name)
+ .new(
+ parent_table: table,
+ loose_foreign_key_definitions: loose_foreign_key_definitions,
+ deleted_parent_records: records,
+ modification_tracker: modification_tracker)
.execute
break if modification_tracker.over_limit?
@@ -45,30 +48,12 @@ module LooseForeignKeys
LooseForeignKeys::DeletedRecord.load_batch_for_table(fully_qualified_table_name, BATCH_SIZE)
end
- def find_parent_model!(table)
- models_by_table_name.fetch(table)
- end
-
def current_schema
@current_schema = connection.current_schema
end
def tracked_tables
- @tracked_tables ||= models_by_table_name
- .select { |table_name, model| model.respond_to?(:loose_foreign_key_definitions) }
- .keys
- end
-
- def models_by_table_name
- @models_by_table_name ||= begin
- all_models
- .select(&:base_class?)
- .index_by(&:table_name)
- end
- end
-
- def all_models
- ApplicationRecord.descendants
+ @tracked_tables ||= Gitlab::Database::LooseForeignKeys.definitions_by_table.keys
end
end
end
diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb
index cb905e01613..acd00d0d1ec 100644
--- a/app/services/members/create_service.rb
+++ b/app/services/members/create_service.rb
@@ -92,7 +92,6 @@ module Members
super
track_invite_source(member)
- track_areas_of_focus(member)
end
def track_invite_source(member)
@@ -110,14 +109,7 @@ module Members
member.invite? ? 'net_new_user' : 'existing_user'
end
- def track_areas_of_focus(member)
- areas_of_focus.each do |area_of_focus|
- Gitlab::Tracking.event(self.class.name, 'area_of_focus', label: area_of_focus, property: member.id.to_s)
- end
- end
-
def create_tasks_to_be_done
- return unless experiment(:invite_members_for_task).enabled?
return if params[:tasks_to_be_done].blank? || params[:tasks_project_id].blank?
valid_members = members.select { |member| member.valid? && member.member_task.valid? }
@@ -129,10 +121,6 @@ module Members
TasksToBeDone::CreateWorker.perform_async(member_task.id, current_user.id, valid_members.map(&:user_id))
end
- def areas_of_focus
- params[:areas_of_focus] || []
- end
-
def user_limit
limit = params.fetch(:limit, DEFAULT_INVITE_LIMIT)
diff --git a/app/services/members/creator_service.rb b/app/services/members/creator_service.rb
index f2c8a6f20a1..e766a7e9044 100644
--- a/app/services/members/creator_service.rb
+++ b/app/services/members/creator_service.rb
@@ -65,7 +65,6 @@ module Members
end
def create_member_task
- return unless experiment(:invite_members_for_task).enabled?
return unless member.persisted?
return if member_task_attributes.value?(nil)
diff --git a/app/services/merge_requests/after_create_service.rb b/app/services/merge_requests/after_create_service.rb
index 77564521d45..d2c83f82ff8 100644
--- a/app/services/merge_requests/after_create_service.rb
+++ b/app/services/merge_requests/after_create_service.rb
@@ -2,13 +2,22 @@
module MergeRequests
class AfterCreateService < MergeRequests::BaseService
+ include Gitlab::Utils::StrongMemoize
+
def execute(merge_request)
+ prepare_for_mergeability(merge_request) if early_prepare_for_mergeability?(merge_request)
prepare_merge_request(merge_request)
- merge_request.mark_as_unchecked if merge_request.preparing?
+ mark_as_unchecked(merge_request) unless early_prepare_for_mergeability?(merge_request)
end
private
+ def prepare_for_mergeability(merge_request)
+ create_pipeline_for(merge_request, current_user)
+ merge_request.update_head_pipeline
+ mark_as_unchecked(merge_request)
+ end
+
def prepare_merge_request(merge_request)
event_service.open_mr(merge_request, current_user)
@@ -17,8 +26,10 @@ module MergeRequests
notification_service.new_merge_request(merge_request, current_user)
- create_pipeline_for(merge_request, current_user)
- merge_request.update_head_pipeline
+ unless early_prepare_for_mergeability?(merge_request)
+ create_pipeline_for(merge_request, current_user)
+ merge_request.update_head_pipeline
+ end
merge_request.diffs(include_stats: false).write_cache
merge_request.create_cross_references!(current_user)
@@ -37,6 +48,16 @@ module MergeRequests
def link_lfs_objects(merge_request)
LinkLfsObjectsService.new(project: merge_request.target_project).execute(merge_request)
end
+
+ def early_prepare_for_mergeability?(merge_request)
+ strong_memoize("early_prepare_for_mergeability_#{merge_request.target_project_id}".to_sym) do
+ Feature.enabled?(:early_prepare_for_mergeability, merge_request.target_project)
+ end
+ end
+
+ def mark_as_unchecked(merge_request)
+ merge_request.mark_as_unchecked if merge_request.preparing?
+ end
end
end
diff --git a/app/services/merge_requests/approval_service.rb b/app/services/merge_requests/approval_service.rb
index 62e599e3e27..3f39b2742c6 100644
--- a/app/services/merge_requests/approval_service.rb
+++ b/app/services/merge_requests/approval_service.rb
@@ -14,6 +14,7 @@ module MergeRequests
create_approval_note(merge_request)
mark_pending_todos_as_done(merge_request)
execute_approval_hooks(merge_request, current_user)
+ remove_attention_requested(merge_request, current_user)
merge_request_activity_counter.track_approve_mr_action(user: current_user)
success
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index 0a652c58aab..d744881549a 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -58,6 +58,8 @@ module MergeRequests
new_reviewers = merge_request.reviewers - old_reviewers
merge_request_activity_counter.track_users_review_requested(users: new_reviewers)
merge_request_activity_counter.track_reviewers_changed_action(user: current_user)
+
+ remove_attention_requested(merge_request, current_user)
end
def cleanup_environments(merge_request)
@@ -238,6 +240,18 @@ module MergeRequests
Milestones::MergeRequestsCountService.new(milestone).delete_cache
end
+
+ def remove_all_attention_requests(merge_request)
+ return unless merge_request.attention_requested_enabled?
+
+ ::MergeRequests::BulkRemoveAttentionRequestedService.new(project: merge_request.project, current_user: current_user, merge_request: merge_request).execute
+ end
+
+ def remove_attention_requested(merge_request, user)
+ return unless merge_request.attention_requested_enabled?
+
+ ::MergeRequests::RemoveAttentionRequestedService.new(project: merge_request.project, current_user: current_user, merge_request: merge_request, user: user).execute
+ end
end
end
diff --git a/app/services/merge_requests/bulk_remove_attention_requested_service.rb b/app/services/merge_requests/bulk_remove_attention_requested_service.rb
new file mode 100644
index 00000000000..dd2ff741ba6
--- /dev/null
+++ b/app/services/merge_requests/bulk_remove_attention_requested_service.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class BulkRemoveAttentionRequestedService < MergeRequests::BaseService
+ attr_accessor :merge_request
+
+ def initialize(project:, current_user:, merge_request:)
+ super(project: project, current_user: current_user)
+
+ @merge_request = merge_request
+ end
+
+ def execute
+ return error("Invalid permissions") unless can?(current_user, :update_merge_request, merge_request)
+
+ merge_request.merge_request_assignees.update_all(state: :reviewed)
+ merge_request.merge_request_reviewers.update_all(state: :reviewed)
+
+ success
+ end
+ end
+end
diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb
index f83b14c7269..e9b253129b4 100644
--- a/app/services/merge_requests/close_service.rb
+++ b/app/services/merge_requests/close_service.rb
@@ -17,6 +17,7 @@ module MergeRequests
create_note(merge_request)
notification_service.async.close_mr(merge_request, current_user)
todo_service.close_merge_request(merge_request, current_user)
+ remove_all_attention_requests(merge_request)
execute_hooks(merge_request, 'close')
invalidate_cache_counts(merge_request, users: merge_request.assignees | merge_request.reviewers)
merge_request.update_project_counter_caches
diff --git a/app/services/merge_requests/create_pipeline_service.rb b/app/services/merge_requests/create_pipeline_service.rb
index 6b032545230..9d7f8393ba5 100644
--- a/app/services/merge_requests/create_pipeline_service.rb
+++ b/app/services/merge_requests/create_pipeline_service.rb
@@ -48,7 +48,7 @@ module MergeRequests
end
def can_create_pipeline_in_target_project?(merge_request)
- if Gitlab::Ci::Features.disallow_to_create_merge_request_pipelines_in_target_project?(merge_request.target_project)
+ if ::Feature.enabled?(:ci_disallow_to_create_merge_request_pipelines_in_target_project, merge_request.target_project)
merge_request.for_same_project?
else
can?(current_user, :create_pipeline, merge_request.target_project) &&
diff --git a/app/services/merge_requests/handle_assignees_change_service.rb b/app/services/merge_requests/handle_assignees_change_service.rb
index 87cd6544406..1d9f7ab59f4 100644
--- a/app/services/merge_requests/handle_assignees_change_service.rb
+++ b/app/services/merge_requests/handle_assignees_change_service.rb
@@ -22,6 +22,8 @@ module MergeRequests
merge_request_activity_counter.track_assignees_changed_action(user: current_user)
execute_assignees_hooks(merge_request, old_assignees) if options[:execute_hooks]
+
+ remove_attention_requested(merge_request, current_user)
end
private
diff --git a/app/services/merge_requests/outdated_discussion_diff_lines_service.rb b/app/services/merge_requests/outdated_discussion_diff_lines_service.rb
index a2de5a32963..a3d94e888df 100644
--- a/app/services/merge_requests/outdated_discussion_diff_lines_service.rb
+++ b/app/services/merge_requests/outdated_discussion_diff_lines_service.rb
@@ -14,13 +14,23 @@ module MergeRequests
end
def execute
- end_position = position.line_range["end"]
- diff_line_index = diff_lines.find_index do |l|
- if end_position["new_line"]
- l.new_line == end_position["new_line"]
- elsif end_position["old_line"]
- l.old_line == end_position["old_line"]
+ line_position = position.line_range["end"] || position.line_range["start"]
+ found_line = false
+ diff_line_index = -1
+ diff_lines.each_with_index do |l, i|
+ if found_line
+ if !l.type
+ break
+ elsif l.type == 'new'
+ diff_line_index = i
+ break
+ end
+ else
+ # Find the old line
+ found_line = l.old_line == line_position["new_line"]
end
+
+ diff_line_index = i
end
initial_line_index = [diff_line_index - OVERFLOW_LINES_COUNT, 0].max
last_line_index = [diff_line_index + OVERFLOW_LINES_COUNT, diff_lines.length].min
diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb
index ea3071b3c2d..e475b57e4a2 100644
--- a/app/services/merge_requests/post_merge_service.rb
+++ b/app/services/merge_requests/post_merge_service.rb
@@ -28,6 +28,7 @@ module MergeRequests
notification_service.merge_mr(merge_request, current_user)
invalidate_cache_counts(merge_request, users: merge_request.assignees | merge_request.reviewers)
merge_request.update_project_counter_caches
+ remove_all_attention_requests(merge_request)
delete_non_latest_diffs(merge_request)
cancel_review_app_jobs!(merge_request)
cleanup_environments(merge_request)
diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb
index 9423194c01d..d1f45b4b49c 100644
--- a/app/services/merge_requests/rebase_service.rb
+++ b/app/services/merge_requests/rebase_service.rb
@@ -4,7 +4,7 @@ module MergeRequests
class RebaseService < MergeRequests::BaseService
REBASE_ERROR = 'Rebase failed. Please rebase locally'
- attr_reader :merge_request
+ attr_reader :merge_request, :rebase_error
def execute(merge_request, skip_ci: false)
@merge_request = merge_request
@@ -13,7 +13,7 @@ module MergeRequests
if rebase
success
else
- error(REBASE_ERROR)
+ error(rebase_error)
end
end
@@ -22,11 +22,23 @@ module MergeRequests
true
rescue StandardError => e
- log_error(exception: e, message: REBASE_ERROR, save_message_on_model: true)
+ set_rebase_error(e)
+ log_error(exception: e, message: rebase_error, save_message_on_model: true)
false
ensure
merge_request.update_column(:rebase_jid, nil)
end
+
+ private
+
+ def set_rebase_error(exception)
+ @rebase_error =
+ if exception.is_a?(Gitlab::Git::PreReceiveError)
+ "Something went wrong during the rebase pre-receive hook: #{exception.message}."
+ else
+ REBASE_ERROR
+ end
+ end
end
end
diff --git a/app/services/merge_requests/remove_attention_requested_service.rb b/app/services/merge_requests/remove_attention_requested_service.rb
new file mode 100644
index 00000000000..b727c24415e
--- /dev/null
+++ b/app/services/merge_requests/remove_attention_requested_service.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class RemoveAttentionRequestedService < MergeRequests::BaseService
+ attr_accessor :merge_request, :user
+
+ def initialize(project:, current_user:, merge_request:, user:)
+ super(project: project, current_user: current_user)
+
+ @merge_request = merge_request
+ @user = user
+ end
+
+ def execute
+ return error("Invalid permissions") unless can?(current_user, :update_merge_request, merge_request)
+
+ if reviewer || assignee
+ update_state(reviewer)
+ update_state(assignee)
+
+ success
+ else
+ error("User is not a reviewer or assignee of the merge request")
+ end
+ end
+
+ private
+
+ def assignee
+ merge_request.find_assignee(user)
+ end
+
+ def reviewer
+ merge_request.find_reviewer(user)
+ end
+
+ def update_state(reviewer_or_assignee)
+ reviewer_or_assignee&.update(state: :reviewed)
+ end
+ end
+end
diff --git a/app/services/merge_requests/resolved_discussion_notification_service.rb b/app/services/merge_requests/resolved_discussion_notification_service.rb
index 03ded1512f9..6afd760386e 100644
--- a/app/services/merge_requests/resolved_discussion_notification_service.rb
+++ b/app/services/merge_requests/resolved_discussion_notification_service.rb
@@ -6,6 +6,7 @@ module MergeRequests
return unless merge_request.discussions_resolved?
SystemNoteService.resolve_all_discussions(merge_request, project, current_user)
+ execute_hooks(merge_request, 'update')
notification_service.async.resolve_all_discussions(merge_request, current_user)
end
end
diff --git a/app/services/merge_requests/squash_service.rb b/app/services/merge_requests/squash_service.rb
index 102f78c6a9b..0600fd1d740 100644
--- a/app/services/merge_requests/squash_service.rb
+++ b/app/services/merge_requests/squash_service.rb
@@ -5,7 +5,7 @@ module MergeRequests
def execute
# If performing a squash would result in no change, then
# immediately return a success message without performing a squash
- if merge_request.commits_count < 2 && message.nil?
+ if merge_request.commits_count == 1 && message == merge_request.first_commit.safe_message
return success(squash_sha: merge_request.diff_head_sha)
end
@@ -17,7 +17,7 @@ module MergeRequests
private
def squash!
- squash_sha = repository.squash(current_user, merge_request, message || merge_request.default_squash_commit_message)
+ squash_sha = repository.squash(current_user, merge_request, message)
success(squash_sha: squash_sha)
rescue StandardError => e
@@ -39,7 +39,7 @@ module MergeRequests
end
def message
- params[:squash_commit_message].presence
+ params[:squash_commit_message].presence || merge_request.default_squash_commit_message
end
end
end
diff --git a/app/services/merge_requests/toggle_attention_requested_service.rb b/app/services/merge_requests/toggle_attention_requested_service.rb
index 66c5d6fce5d..d9f81ac310f 100644
--- a/app/services/merge_requests/toggle_attention_requested_service.rb
+++ b/app/services/merge_requests/toggle_attention_requested_service.rb
@@ -19,7 +19,14 @@ module MergeRequests
update_state(assignee)
if reviewer&.attention_requested? || assignee&.attention_requested?
+ create_attention_request_note
notity_user
+
+ if current_user.id != user.id
+ remove_attention_requested(merge_request, current_user)
+ end
+ else
+ create_remove_attention_request_note
end
success
@@ -31,9 +38,18 @@ module MergeRequests
private
def notity_user
+ notification_service.async.attention_requested_of_merge_request(merge_request, current_user, user)
todo_service.create_attention_requested_todo(merge_request, current_user, user)
end
+ def create_attention_request_note
+ SystemNoteService.request_attention(merge_request, merge_request.project, current_user, user)
+ end
+
+ def create_remove_attention_request_note
+ SystemNoteService.remove_attention_request(merge_request, merge_request.project, current_user, user)
+ end
+
def assignee
merge_request.find_assignee(user)
end
diff --git a/app/services/namespaces/invite_team_email_service.rb b/app/services/namespaces/invite_team_email_service.rb
index 45975d1953a..78edc205990 100644
--- a/app/services/namespaces/invite_team_email_service.rb
+++ b/app/services/namespaces/invite_team_email_service.rb
@@ -29,13 +29,12 @@ module Namespaces
return if email_for_track_sent_to_user?
experiment(:invite_team_email, group: group) do |e|
+ e.publish_to_database
e.candidate do
send_email(user, group)
sent_email_records.add(user, track, series)
sent_email_records.save!
end
-
- e.record!
end
end
diff --git a/app/services/notification_recipients/build_service.rb b/app/services/notification_recipients/build_service.rb
index 52070abbad7..aeb859af4d9 100644
--- a/app/services/notification_recipients/build_service.rb
+++ b/app/services/notification_recipients/build_service.rb
@@ -40,5 +40,9 @@ module NotificationRecipients
def self.build_requested_review_recipients(*args)
::NotificationRecipients::Builder::RequestReview.new(*args).notification_recipients
end
+
+ def self.build_attention_requested_recipients(*args)
+ ::NotificationRecipients::Builder::AttentionRequested.new(*args).notification_recipients
+ end
end
end
diff --git a/app/services/notification_recipients/builder/attention_requested.rb b/app/services/notification_recipients/builder/attention_requested.rb
new file mode 100644
index 00000000000..cdc371fcece
--- /dev/null
+++ b/app/services/notification_recipients/builder/attention_requested.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module NotificationRecipients
+ module Builder
+ class AttentionRequested < Base
+ attr_reader :merge_request, :current_user, :user
+
+ def initialize(merge_request, current_user, user)
+ @merge_request = merge_request
+ @current_user = current_user
+ @user = user
+ end
+
+ def target
+ merge_request
+ end
+
+ def build!
+ add_recipients(user, :mention, NotificationReason::ATTENTION_REQUESTED)
+ end
+ end
+ end
+end
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index 6ad3a74b85d..5b1733422d0 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -301,6 +301,14 @@ class NotificationService
end
end
+ def attention_requested_of_merge_request(merge_request, current_user, user)
+ recipients = NotificationRecipients::BuildService.build_attention_requested_recipients(merge_request, current_user, user)
+
+ recipients.each do |recipient|
+ mailer.attention_requested_merge_request_email(recipient.user.id, merge_request.id, current_user.id, recipient.reason).deliver_later
+ end
+ end
+
# When we add labels to a merge request we should send an email to:
#
# * watchers of the mr's labels
diff --git a/app/services/packages/debian/generate_distribution_service.rb b/app/services/packages/debian/generate_distribution_service.rb
index 74b07e05aa6..33bf877a153 100644
--- a/app/services/packages/debian/generate_distribution_service.rb
+++ b/app/services/packages/debian/generate_distribution_service.rb
@@ -91,7 +91,7 @@ module Packages
generate_component_file(component, :packages, architecture, :deb)
generate_component_file(component, :di_packages, architecture, :udeb)
end
- generate_component_file(component, :source, nil, :dsc)
+ generate_component_file(component, :sources, nil, :dsc)
end
end
diff --git a/app/services/packages/npm/create_package_service.rb b/app/services/packages/npm/create_package_service.rb
index ae9c92a3d3a..655616c3a28 100644
--- a/app/services/packages/npm/create_package_service.rb
+++ b/app/services/packages/npm/create_package_service.rb
@@ -23,9 +23,7 @@ module Packages
::Packages::CreateDependencyService.new(package, package_dependencies).execute
::Packages::Npm::CreateTagService.new(package, dist_tag).execute
- if Feature.enabled?(:packages_npm_abbreviated_metadata, project, default_enabled: :yaml)
- package.create_npm_metadatum!(package_json: package_json)
- end
+ package.create_npm_metadatum!(package_json: package_json)
package
end
diff --git a/app/services/pages/zip_directory_service.rb b/app/services/pages/zip_directory_service.rb
index 895614a84a0..c9029b9666a 100644
--- a/app/services/pages/zip_directory_service.rb
+++ b/app/services/pages/zip_directory_service.rb
@@ -25,7 +25,9 @@ module Pages
FileUtils.rm_f(output_file)
entries_count = 0
- ::Zip::File.open(output_file, ::Zip::File::CREATE) do |zipfile|
+ # Since we're writing not reading here, we can safely silence the cop.
+ # It currently cannot discern between opening for reading or writing.
+ ::Zip::File.open(output_file, ::Zip::File::CREATE) do |zipfile| # rubocop:disable Performance/Rubyzip
write_entry(zipfile, PUBLIC_DIR)
entries_count = zipfile.entries.count
end
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index b7ed9202b01..aef92b8adee 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -28,9 +28,7 @@ module Projects
# Git data (e.g. a list of branch names).
flush_caches(project)
- if Feature.enabled?(:abort_deleted_project_pipelines, default_enabled: :yaml)
- ::Ci::AbortPipelinesService.new.execute(project.all_pipelines, :project_deleted)
- end
+ ::Ci::AbortPipelinesService.new.execute(project.all_pipelines, :project_deleted)
Projects::UnlinkForkService.new(project, current_user).execute
@@ -75,6 +73,18 @@ module Projects
response.success?
end
+ def destroy_events!
+ unless remove_events
+ raise_error(s_('DeleteProject|Failed to remove events. Please try again or contact administrator.'))
+ end
+ end
+
+ def remove_events
+ response = ::Events::DestroyService.new(project).execute
+
+ response.success?
+ end
+
def remove_repository(repository)
return true unless repository
@@ -117,14 +127,10 @@ module Projects
log_destroy_event
trash_relation_repositories!
trash_project_repositories!
+ destroy_events!
destroy_web_hooks!
destroy_project_bots!
-
- if ::Feature.enabled?(:ci_optimize_project_records_destruction, project, default_enabled: :yaml) &&
- Feature.enabled?(:abort_deleted_project_pipelines, default_enabled: :yaml)
-
- destroy_ci_records!
- end
+ destroy_ci_records!
# Rails attempts to load all related records into memory before
# destroying: https://github.com/rails/rails/issues/22510
@@ -150,7 +156,7 @@ module Projects
::Ci::DestroyPipelineService.new(project, current_user).execute(pipeline)
end
- deleted_count = project.commit_statuses.delete_all
+ deleted_count = ::CommitStatus.for_project(project).delete_all
Gitlab::AppLogger.info(
class: 'Projects::DestroyService',
diff --git a/app/services/projects/prometheus/alerts/create_service.rb b/app/services/projects/prometheus/alerts/create_service.rb
index dc0cacf49f3..0d7d8ab1a62 100644
--- a/app/services/projects/prometheus/alerts/create_service.rb
+++ b/app/services/projects/prometheus/alerts/create_service.rb
@@ -3,7 +3,7 @@
module Projects
module Prometheus
module Alerts
- class CreateService < BaseService
+ class CreateService < BaseProjectService
include AlertParams
def execute
diff --git a/app/services/projects/prometheus/alerts/destroy_service.rb b/app/services/projects/prometheus/alerts/destroy_service.rb
index 14e88a2e356..243b12eb654 100644
--- a/app/services/projects/prometheus/alerts/destroy_service.rb
+++ b/app/services/projects/prometheus/alerts/destroy_service.rb
@@ -3,7 +3,7 @@
module Projects
module Prometheus
module Alerts
- class DestroyService < BaseService
+ class DestroyService < BaseProjectService
def execute(alert)
alert.destroy
end
diff --git a/app/services/projects/prometheus/alerts/update_service.rb b/app/services/projects/prometheus/alerts/update_service.rb
index a0c8a5ccc2d..1802f35dae9 100644
--- a/app/services/projects/prometheus/alerts/update_service.rb
+++ b/app/services/projects/prometheus/alerts/update_service.rb
@@ -3,7 +3,7 @@
module Projects
module Prometheus
module Alerts
- class UpdateService < BaseService
+ class UpdateService < BaseProjectService
include AlertParams
def execute(alert)
diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb
index a69e6488ebc..17da77fe950 100644
--- a/app/services/projects/transfer_service.rb
+++ b/app/services/projects/transfer_service.rb
@@ -104,10 +104,10 @@ module Projects
update_repository_configuration(@new_path)
execute_system_hooks
-
- update_pending_builds!
end
+ update_pending_builds
+
post_update_hooks(project)
rescue Exception # rubocop:disable Lint/RescueException
rollback_side_effects
@@ -244,13 +244,15 @@ module Projects
Integration.create_from_active_default_integrations(project, :project_id)
end
- def update_pending_builds!
- update_params = {
+ def update_pending_builds
+ ::Ci::PendingBuilds::UpdateProjectWorker.perform_async(project.id, pending_builds_params)
+ end
+
+ def pending_builds_params
+ {
namespace_id: new_namespace.id,
namespace_traversal_ids: new_namespace.traversal_ids
}
-
- ::Ci::UpdatePendingBuildService.new(project, update_params).execute
end
end
end
diff --git a/app/services/protected_branches/base_service.rb b/app/services/protected_branches/base_service.rb
index df801311aaf..1ab3ccfcaae 100644
--- a/app/services/protected_branches/base_service.rb
+++ b/app/services/protected_branches/base_service.rb
@@ -2,6 +2,8 @@
module ProtectedBranches
class BaseService < ::BaseService
+ include ProtectedRefNameSanitizer
+
# current_user - The user that performs the action
# params - A hash of parameters
def initialize(project, current_user = nil, params = {})
@@ -14,22 +16,13 @@ module ProtectedBranches
# overridden in EE::ProtectedBranches module
end
+ private
+
def filtered_params
return unless params
- params[:name] = sanitize_branch_name(params[:name]) if params[:name].present?
+ params[:name] = sanitize_name(params[:name]) if params[:name].present?
params
end
-
- private
-
- def sanitize_branch_name(name)
- name = CGI.unescapeHTML(name)
- name = Sanitize.fragment(name)
-
- # Sanitize.fragment escapes HTML chars, so unescape again to allow names
- # like `feature->master`
- CGI.unescapeHTML(name)
- end
end
end
diff --git a/app/services/protected_tags/base_service.rb b/app/services/protected_tags/base_service.rb
new file mode 100644
index 00000000000..e0181815f0f
--- /dev/null
+++ b/app/services/protected_tags/base_service.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module ProtectedTags
+ class BaseService < ::BaseService
+ include ProtectedRefNameSanitizer
+
+ private
+
+ def filtered_params
+ return unless params
+
+ params[:name] = sanitize_name(params[:name]) if params[:name].present?
+ params
+ end
+ end
+end
diff --git a/app/services/protected_tags/create_service.rb b/app/services/protected_tags/create_service.rb
index 9aff55986b2..7d2b583a295 100644
--- a/app/services/protected_tags/create_service.rb
+++ b/app/services/protected_tags/create_service.rb
@@ -1,13 +1,13 @@
# frozen_string_literal: true
module ProtectedTags
- class CreateService < BaseService
+ class CreateService < ProtectedTags::BaseService
attr_reader :protected_tag
def execute
raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project)
- project.protected_tags.create(params)
+ project.protected_tags.create(filtered_params)
end
end
end
diff --git a/app/services/protected_tags/update_service.rb b/app/services/protected_tags/update_service.rb
index 3eb5f4955ee..e337ec39898 100644
--- a/app/services/protected_tags/update_service.rb
+++ b/app/services/protected_tags/update_service.rb
@@ -1,11 +1,11 @@
# frozen_string_literal: true
module ProtectedTags
- class UpdateService < BaseService
+ class UpdateService < ProtectedTags::BaseService
def execute(protected_tag)
raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project)
- protected_tag.update(params)
+ protected_tag.update(filtered_params)
protected_tag
end
end
diff --git a/app/services/repositories/changelog_service.rb b/app/services/repositories/changelog_service.rb
index 96db00fbc1b..eafd9d7a55e 100644
--- a/app/services/repositories/changelog_service.rb
+++ b/app/services/repositories/changelog_service.rb
@@ -60,7 +60,7 @@ module Repositories
end
# rubocop: enable Metrics/ParameterLists
- def execute
+ def execute(commit_to_changelog: true)
config = Gitlab::Changelog::Config.from_git(@project, @user)
from = start_of_commit_range(config)
@@ -93,9 +93,13 @@ module Repositories
end
end
- Gitlab::Changelog::Committer
- .new(@project, @user)
- .commit(release: release, file: @file, branch: @branch, message: @message)
+ if commit_to_changelog
+ Gitlab::Changelog::Committer
+ .new(@project, @user)
+ .commit(release: release, file: @file, branch: @branch, message: @message)
+ else
+ Gitlab::Changelog::Generator.new.add(release)
+ end
end
def start_of_commit_range(config)
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index 4ba1b3ade86..171d52c328d 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -2,42 +2,35 @@
class SearchService
include Gitlab::Allowable
+ include Gitlab::Utils::StrongMemoize
- SEARCH_TERM_LIMIT = 64
- SEARCH_CHAR_LIMIT = 4096
DEFAULT_PER_PAGE = Gitlab::SearchResults::DEFAULT_PER_PAGE
MAX_PER_PAGE = 200
def initialize(current_user, params = {})
@current_user = current_user
- @params = params.dup
+ @params = Gitlab::Search::Params.new(params, detect_abuse: prevent_abusive_searches?)
end
# rubocop: disable CodeReuse/ActiveRecord
def project
- return @project if defined?(@project)
-
- @project =
- if params[:project_id].present?
+ strong_memoize(:project) do
+ if params[:project_id].present? && valid_request?
the_project = Project.find_by(id: params[:project_id])
can?(current_user, :read_project, the_project) ? the_project : nil
- else
- nil
end
+ end
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def group
- return @group if defined?(@group)
-
- @group =
- if params[:group_id].present?
+ strong_memoize(:group) do
+ if params[:group_id].present? && valid_request?
the_group = Group.find_by(id: params[:group_id])
can?(current_user, :read_group, the_group) ? the_group : nil
- else
- nil
end
+ end
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -45,24 +38,23 @@ class SearchService
# overridden in EE
end
+ def global_search?
+ project.blank? && group.blank?
+ end
+
def show_snippets?
return @show_snippets if defined?(@show_snippets)
@show_snippets = params[:snippets] == 'true'
end
- def valid_query_length?
- params[:search].length <= SEARCH_CHAR_LIMIT
- end
-
- def valid_terms_count?
- params[:search].split.count { |word| word.length >= 3 } <= SEARCH_TERM_LIMIT
- end
-
delegate :scope, to: :search_service
+ delegate :valid_terms_count?, :valid_query_length?, to: :params
def search_results
- @search_results ||= search_service.execute
+ strong_memoize(:search_results) do
+ abuse_detected? ? Gitlab::EmptySearchResults.new : search_service.execute
+ end
end
def search_objects(preload_method = nil)
@@ -79,8 +71,30 @@ class SearchService
search_results.aggregations(scope)
end
+ def abuse_detected?
+ strong_memoize(:abuse_detected) do
+ params.abusive?
+ end
+ end
+
+ def abuse_messages
+ return [] unless params.abusive?
+
+ params.abuse_detection.errors.full_messages
+ end
+
+ def valid_request?
+ strong_memoize(:valid_request) do
+ params.valid?
+ end
+ end
+
private
+ def prevent_abusive_searches?
+ Feature.enabled?(:prevent_abusive_searches, current_user)
+ end
+
def page
[1, params[:page].to_i].max
end
diff --git a/app/services/service_ping/devops_report_service.rb b/app/services/service_ping/devops_report_service.rb
new file mode 100644
index 00000000000..3b8f5dfdb82
--- /dev/null
+++ b/app/services/service_ping/devops_report_service.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module ServicePing
+ class DevopsReportService
+ def initialize(data)
+ @data = data
+ end
+
+ def execute
+ # `conv_index` was previously named `dev_ops_score` in
+ # version-gitlab-com, so we check both for backwards compatibility.
+ metrics = @data['conv_index'] || @data['dev_ops_score']
+
+ # Do not attempt to save a report for the first Service Ping
+ # response for a given GitLab instance, which comes without
+ # metrics.
+ return if metrics.keys == ['usage_data_id']
+
+ report = DevOpsReport::Metric.create(
+ metrics.slice(*DevOpsReport::Metric::METRICS)
+ )
+
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(ActiveRecord::RecordInvalid.new(report)) unless report.persisted?
+ end
+ end
+end
diff --git a/app/services/service_ping/submit_service.rb b/app/services/service_ping/submit_service.rb
index 63e01603d47..d3d9dcecb2b 100644
--- a/app/services/service_ping/submit_service.rb
+++ b/app/services/service_ping/submit_service.rb
@@ -6,29 +6,23 @@ module ServicePing
STAGING_BASE_URL = 'https://gitlab-services-version-gitlab-com-staging.gs-staging.gitlab.org'
USAGE_DATA_PATH = 'usage_data'
- METRICS = %w[leader_issues instance_issues percentage_issues leader_notes instance_notes
- percentage_notes leader_milestones instance_milestones percentage_milestones
- leader_boards instance_boards percentage_boards leader_merge_requests
- instance_merge_requests percentage_merge_requests leader_ci_pipelines
- instance_ci_pipelines percentage_ci_pipelines leader_environments instance_environments
- percentage_environments leader_deployments instance_deployments percentage_deployments
- leader_projects_prometheus_active instance_projects_prometheus_active
- percentage_projects_prometheus_active leader_service_desk_issues instance_service_desk_issues
- percentage_service_desk_issues].freeze
-
SubmissionError = Class.new(StandardError)
+ def initialize(skip_db_write: false)
+ @skip_db_write = skip_db_write
+ end
+
def execute
return unless ServicePing::ServicePingSettings.product_intelligence_enabled?
begin
usage_data = BuildPayloadService.new.execute
- raw_usage_data, response = submit_usage_data_payload(usage_data)
+ response = submit_usage_data_payload(usage_data)
rescue StandardError
return unless Gitlab::CurrentSettings.usage_ping_enabled?
usage_data = Gitlab::UsageData.data(force_refresh: true)
- raw_usage_data, response = submit_usage_data_payload(usage_data)
+ response = submit_usage_data_payload(usage_data)
end
version_usage_data_id = response.dig('conv_index', 'usage_data_id') || response.dig('dev_ops_score', 'usage_data_id')
@@ -37,9 +31,11 @@ module ServicePing
raise SubmissionError, "Invalid usage_data_id in response: #{version_usage_data_id}"
end
- raw_usage_data.update_version_metadata!(usage_data_id: version_usage_data_id)
-
- store_metrics(response)
+ unless @skip_db_write
+ raw_usage_data = save_raw_usage_data(usage_data)
+ raw_usage_data.update_version_metadata!(usage_data_id: version_usage_data_id)
+ DevopsReportService.new(response).execute
+ end
end
def url
@@ -60,13 +56,11 @@ module ServicePing
def submit_usage_data_payload(usage_data)
raise SubmissionError, 'Usage data is blank' if usage_data.blank?
- raw_usage_data = save_raw_usage_data(usage_data)
-
response = submit_payload(usage_data)
raise SubmissionError, "Unsuccessful response code: #{response.code}" unless response.success?
- [raw_usage_data, response]
+ response
end
def save_raw_usage_data(usage_data)
@@ -75,16 +69,6 @@ module ServicePing
end
end
- def store_metrics(response)
- metrics = response['conv_index'] || response['dev_ops_score'] # leaving dev_ops_score here, as the response data comes from the gitlab-version-com
-
- return unless metrics.except('usage_data_id').present?
-
- DevOpsReport::Metric.create!(
- metrics.slice(*METRICS)
- )
- end
-
# See https://gitlab.com/gitlab-org/gitlab/-/issues/233615 for details
def base_url
Rails.env.production? ? PRODUCTION_BASE_URL : STAGING_BASE_URL
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index dc5cf0fe554..0d13c73d49d 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -115,6 +115,14 @@ module SystemNoteService
::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).change_status(status, source)
end
+ def request_attention(noteable, project, author, user)
+ ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).request_attention(user)
+ end
+
+ def remove_attention_request(noteable, project, author, user)
+ ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).remove_attention_request(user)
+ end
+
# Called when 'merge when pipeline succeeds' is executed
def merge_when_pipeline_succeeds(noteable, project, author, sha)
::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author).merge_when_pipeline_succeeds(sha)
@@ -213,12 +221,12 @@ module SystemNoteService
::SystemNotes::MergeRequestsService.new(noteable: issue, project: project, author: author).new_merge_request(merge_request)
end
- def cross_reference(noteable, mentioner, author)
- ::SystemNotes::IssuablesService.new(noteable: noteable, author: author).cross_reference(mentioner)
+ def cross_reference(mentioned, mentioned_in, author)
+ ::SystemNotes::IssuablesService.new(noteable: mentioned, author: author).cross_reference(mentioned_in)
end
- def cross_reference_exists?(noteable, mentioner)
- ::SystemNotes::IssuablesService.new(noteable: noteable).cross_reference_exists?(mentioner)
+ def cross_reference_exists?(mentioned, mentioned_in)
+ ::SystemNotes::IssuablesService.new(noteable: mentioned).cross_reference_exists?(mentioned_in)
end
def change_task_status(noteable, project, author, new_task)
@@ -249,8 +257,8 @@ module SystemNoteService
::SystemNotes::IssuablesService.new(noteable: issuable, project: issuable.project, author: author).discussion_lock
end
- def cross_reference_disallowed?(noteable, mentioner)
- ::SystemNotes::IssuablesService.new(noteable: noteable).cross_reference_disallowed?(mentioner)
+ def cross_reference_disallowed?(mentioned, mentioned_in)
+ ::SystemNotes::IssuablesService.new(noteable: mentioned).cross_reference_disallowed?(mentioned_in)
end
def zoom_link_added(issue, project, author)
diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb
index 94629ae7609..d33dcd65589 100644
--- a/app/services/system_notes/issuables_service.rb
+++ b/app/services/system_notes/issuables_service.rb
@@ -154,9 +154,8 @@ module SystemNotes
create_note(NoteSummary.new(noteable, project, author, body, action: 'description'))
end
- # Called when a Mentionable references a Noteable
- #
- # mentioner - Mentionable object
+ # Called when a Mentionable (the `mentioned_in`) references another Mentionable (the `mentioned`,
+ # passed to this service as `noteable`).
#
# Example Note text:
#
@@ -168,19 +167,20 @@ module SystemNotes
#
# See cross_reference_note_content.
#
- # Returns the created Note object
- def cross_reference(mentioner)
- return if cross_reference_disallowed?(mentioner)
+ # @param mentioned_in [Mentionable]
+ # @return [Note]
+ def cross_reference(mentioned_in)
+ return if cross_reference_disallowed?(mentioned_in)
- gfm_reference = mentioner.gfm_reference(noteable.project || noteable.group)
+ gfm_reference = mentioned_in.gfm_reference(noteable.project || noteable.group)
body = cross_reference_note_content(gfm_reference)
if noteable.is_a?(ExternalIssue)
Integrations::CreateExternalCrossReferenceWorker.perform_async(
noteable.project_id,
noteable.id,
- mentioner.class.name,
- mentioner.id,
+ mentioned_in.class.name,
+ mentioned_in.id,
author.id
)
else
@@ -195,15 +195,14 @@ module SystemNotes
# in a merge request. Additionally, it prevents the creation of references to
# external issues (which would fail).
#
- # mentioner - Mentionable object
- #
- # Returns Boolean
- def cross_reference_disallowed?(mentioner)
+ # @param mentioned_in [Mentionable]
+ # @return [Boolean]
+ def cross_reference_disallowed?(mentioned_in)
return true if noteable.is_a?(ExternalIssue) && !noteable.project&.external_references_supported?
- return false unless mentioner.is_a?(MergeRequest)
+ return false unless mentioned_in.is_a?(MergeRequest)
return false unless noteable.is_a?(Commit)
- mentioner.commits.include?(noteable)
+ mentioned_in.commits.include?(noteable)
end
# Called when the status of a Task has changed
@@ -309,38 +308,49 @@ module SystemNotes
create_resource_state_event(status: status, mentionable_source: source)
end
- # Check if a cross reference to a noteable from a mentioner already exists
+ # Check if a cross reference to a Mentionable from the `mentioned_in` Mentionable
+ # already exists.
#
# This method is used to prevent multiple notes being created for a mention
- # when a issue is updated, for example. The method also calls notes_for_mentioner
- # to check if the mentioner is a commit, and return matches only on commit hash
+ # when a issue is updated, for example. The method also calls `existing_mentions_for`
+ # to check if the mention is in a commit, and return matches only on commit hash
# instead of project + commit, to avoid repeated mentions from forks.
#
- # mentioner - Mentionable object
- #
- # Returns Boolean
- def cross_reference_exists?(mentioner)
+ # @param mentioned_in [Mentionable]
+ # @return [Boolean]
+ def cross_reference_exists?(mentioned_in)
notes = noteable.notes.system
- notes_for_mentioner(mentioner, noteable, notes).exists?
+ existing_mentions_for(mentioned_in, noteable, notes).exists?
end
- # Called when a Noteable has been marked as a duplicate of another Issue
+ # Called when a user's attention has been requested for a Notable
#
- # canonical_issue - Issue that this is a duplicate of
+ # user - User's whos attention has been requested
#
# Example Note text:
#
- # "marked this issue as a duplicate of #1234"
- #
- # "marked this issue as a duplicate of other_project#5678"
+ # "requested attention from @eli.wisoky"
#
# Returns the created Note object
- def mark_duplicate_issue(canonical_issue)
- body = "marked this issue as a duplicate of #{canonical_issue.to_reference(project)}"
+ def request_attention(user)
+ body = "requested attention from #{user.to_reference}"
- issue_activity_counter.track_issue_marked_as_duplicate_action(author: author) if noteable.is_a?(Issue)
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'attention_requested'))
+ end
- create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate'))
+ # Called when a user's attention request has been removed for a Notable
+ #
+ # user - User's whos attention request has been removed
+ #
+ # Example Note text:
+ #
+ # "removed attention request from @eli.wisoky"
+ #
+ # Returns the created Note object
+ def remove_attention_request(user)
+ body = "removed attention request from #{user.to_reference}"
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'attention_request_removed'))
end
# Called when a Noteable has been marked as the canonical Issue of a duplicate
@@ -359,6 +369,25 @@ module SystemNotes
create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate'))
end
+ # Called when a Noteable has been marked as a duplicate of another Issue
+ #
+ # canonical_issue - Issue that this is a duplicate of
+ #
+ # Example Note text:
+ #
+ # "marked this issue as a duplicate of #1234"
+ #
+ # "marked this issue as a duplicate of other_project#5678"
+ #
+ # Returns the created Note object
+ def mark_duplicate_issue(canonical_issue)
+ body = "marked this issue as a duplicate of #{canonical_issue.to_reference(project)}"
+
+ issue_activity_counter.track_issue_marked_as_duplicate_action(author: author) if noteable.is_a?(Issue)
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate'))
+ end
+
def add_email_participants(body)
create_note(NoteSummary.new(noteable, project, author, body))
end
@@ -398,12 +427,12 @@ module SystemNotes
"#{self.class.cross_reference_note_prefix}#{gfm_reference}"
end
- def notes_for_mentioner(mentioner, noteable, notes)
- if mentioner.is_a?(Commit)
- text = "#{self.class.cross_reference_note_prefix}%#{mentioner.to_reference(nil)}"
+ def existing_mentions_for(mentioned_in, noteable, notes)
+ if mentioned_in.is_a?(Commit)
+ text = "#{self.class.cross_reference_note_prefix}%#{mentioned_in.to_reference(nil)}"
notes.like_note_or_capitalized_note(text)
else
- gfm_reference = mentioner.gfm_reference(noteable.project || noteable.group)
+ gfm_reference = mentioned_in.gfm_reference(noteable.project || noteable.group)
text = cross_reference_note_content(gfm_reference)
notes.for_note_or_capitalized_note(text)
end
diff --git a/app/services/todos/destroy/private_features_service.rb b/app/services/todos/destroy/private_features_service.rb
deleted file mode 100644
index 44c3ff231f8..00000000000
--- a/app/services/todos/destroy/private_features_service.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-
-module Todos
- module Destroy
- class PrivateFeaturesService < ::Todos::Destroy::BaseService
- attr_reader :project_ids, :user_id
-
- def initialize(project_ids, user_id = nil)
- @project_ids = project_ids
- @user_id = user_id
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def execute
- ProjectFeature.where(project_id: project_ids).each do |project_features|
- target_types = []
- target_types << Issue.name if private?(project_features.issues_access_level)
- target_types << MergeRequest.name if private?(project_features.merge_requests_access_level)
- target_types << Commit.name if private?(project_features.repository_access_level)
-
- next if target_types.empty?
-
- remove_todos(project_features.project_id, target_types)
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- private
-
- def private?(feature_level)
- feature_level == ProjectFeature::PRIVATE
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def remove_todos(project_id, target_types)
- items = Todo.where(project_id: project_id)
- items = items.where(user_id: user_id) if user_id
-
- items.where.not(user_id: authorized_users)
- .where(target_type: target_types)
- .delete_all
- end
- # rubocop: enable CodeReuse/ActiveRecord
- end
- end
-end
diff --git a/app/services/todos/destroy/unauthorized_features_service.rb b/app/services/todos/destroy/unauthorized_features_service.rb
new file mode 100644
index 00000000000..513def10575
--- /dev/null
+++ b/app/services/todos/destroy/unauthorized_features_service.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Todos
+ module Destroy
+ class UnauthorizedFeaturesService < ::Todos::Destroy::BaseService
+ attr_reader :project_id, :user_id
+
+ BATCH_SIZE = 1000
+
+ def initialize(project_id, user_id = nil)
+ @project_id = project_id
+ @user_id = user_id
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def execute
+ return if user_id && authorized_users.where(user_id: user_id).exists?
+
+ related_todos.each_batch(of: BATCH_SIZE) do |batch|
+ pending_delete = without_authorized(batch).includes(:target, :user).reject do |todo|
+ Ability.allowed?(todo.user, :read_todo, todo, scope: :user)
+ end
+ Todo.where(id: pending_delete).delete_all if pending_delete.present?
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ private
+
+ def related_todos
+ base_scope = Todo.for_project(project_id)
+ base_scope = base_scope.for_user(user_id) if user_id
+ base_scope
+ end
+
+ # Compatibility for #authorized_users in this class we always work
+ # with 1 project for queries efficiency
+ def project_ids
+ [project_id]
+ end
+ end
+ end
+end
diff --git a/app/services/users/dismiss_user_callout_service.rb b/app/services/users/dismiss_callout_service.rb
index 96f3f3acb57..4324e6232c2 100644
--- a/app/services/users/dismiss_user_callout_service.rb
+++ b/app/services/users/dismiss_callout_service.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Users
- class DismissUserCalloutService < BaseContainerService
+ class DismissCalloutService < BaseContainerService
def execute
callout.tap do |record|
record.update(dismissed_at: Time.current) if record.valid?
diff --git a/app/services/users/dismiss_group_callout_service.rb b/app/services/users/dismiss_group_callout_service.rb
index 8afee6a8187..f482142b911 100644
--- a/app/services/users/dismiss_group_callout_service.rb
+++ b/app/services/users/dismiss_group_callout_service.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Users
- class DismissGroupCalloutService < DismissUserCalloutService
+ class DismissGroupCalloutService < DismissCalloutService
private
def callout
diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb
index 2d9766c3c56..fe61335f3ed 100644
--- a/app/services/users/refresh_authorized_projects_service.rb
+++ b/app/services/users/refresh_authorized_projects_service.rb
@@ -63,12 +63,12 @@ module Users
# Updates the list of authorizations for the current user.
#
# remove - The IDs of the authorization rows to remove.
- # add - Rows to insert in the form `[user id, project id, access level]`
+ # add - Rows to insert in the form `[{ user_id: user_id, project_id: project_id, access_level: access_level}, ...]`
def update_authorizations(remove = [], add = [])
log_refresh_details(remove, add)
- user.remove_project_authorizations(remove) unless remove.empty?
- ProjectAuthorization.insert_authorizations(add) unless add.empty?
+ user.remove_project_authorizations(remove) if remove.any?
+ ProjectAuthorization.insert_all_in_batches(add) if add.any?
# Since we batch insert authorization rows, Rails' associations may get
# out of sync. As such we force a reload of the User object.
@@ -88,7 +88,7 @@ module Users
# most often there's only a few entries in remove and add, but limit it to the first 5
# entries to avoid flooding the logs
'authorized_projects_refresh.rows_deleted_slice': remove.first(5),
- 'authorized_projects_refresh.rows_added_slice': add.first(5))
+ 'authorized_projects_refresh.rows_added_slice': add.first(5).map(&:values))
end
end
end
diff --git a/app/services/verify_pages_domain_service.rb b/app/services/verify_pages_domain_service.rb
index eab1e91dc89..408ee429a74 100644
--- a/app/services/verify_pages_domain_service.rb
+++ b/app/services/verify_pages_domain_service.rb
@@ -85,7 +85,8 @@ class VerifyPagesDomainService < BaseService
end
def check(domain_name, resolver)
- records = parse(txt_records(domain_name, resolver))
+ # Append '.' to domain_name, indicating absolute FQDN
+ records = parse(txt_records(domain_name + '.', resolver))
records.any? do |record|
record == domain.keyed_verification_code || record == domain.verification_code
diff --git a/app/uploaders/lfs_object_uploader.rb b/app/uploaders/lfs_object_uploader.rb
index 0a966f3d44f..027857500f4 100644
--- a/app/uploaders/lfs_object_uploader.rb
+++ b/app/uploaders/lfs_object_uploader.rb
@@ -9,7 +9,7 @@ class LfsObjectUploader < GitlabUploader
alias_method :upload, :model
def filename
- model.oid[4..-1]
+ model.oid[4..]
end
def store_dir
diff --git a/app/validators/json_schemas/error_tracking_event_payload.json b/app/validators/json_schemas/error_tracking_event_payload.json
index 73ff71043ce..1497a05a68f 100644
--- a/app/validators/json_schemas/error_tracking_event_payload.json
+++ b/app/validators/json_schemas/error_tracking_event_payload.json
@@ -57,7 +57,7 @@
"type": "array"
},
"context_line": {
- "type": "string"
+ "type": ["string", "null"]
},
"post_context": {
"type": "array"
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 19c38d7be62..65882491575 100644
--- a/app/views/admin/application_settings/_account_and_limit.html.haml
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -32,6 +32,7 @@
= render_if_exists 'admin/application_settings/git_two_factor_session_expiry', form: f
= render_if_exists 'admin/application_settings/personal_access_token_expiration_policy', form: f
= render_if_exists 'admin/application_settings/enforce_pat_expiration', form: f
+ = render_if_exists 'admin/application_settings/ssh_key_expiration_policy', form: f
= render_if_exists 'admin/application_settings/enforce_ssh_key_expiration', form: f
.form-group
diff --git a/app/views/admin/application_settings/_package_registry.html.haml b/app/views/admin/application_settings/_package_registry.html.haml
index 7cdadaaf37b..398e63cdfdc 100644
--- a/app/views/admin/application_settings/_package_registry.html.haml
+++ b/app/views/admin/application_settings/_package_registry.html.haml
@@ -33,6 +33,9 @@
= 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
diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml
index 756c0e770a6..f7a6a26c645 100644
--- a/app/views/admin/application_settings/_snowplow.html.haml
+++ b/app/views/admin/application_settings/_snowplow.html.haml
@@ -1,5 +1,5 @@
- expanded = integration_expanded?('snowplow_')
-%section.settings.as-snowplow.no-animate#js-snowplow-settings{ class: ('expanded' if expanded) }
+%section.settings.as-snowplow.no-animate#js-snowplow-settings{ class: ('expanded' if expanded), data: { qa_selector: 'snowplow_settings_content' } }
.settings-header
%h4
= _('Snowplow')
@@ -15,7 +15,7 @@
%fieldset
.form-group
.form-check
- = f.check_box :snowplow_enabled, class: 'form-check-input'
+ = f.check_box :snowplow_enabled, class: 'form-check-input', data: { qa_selector: 'snowplow_enabled_checkbox' }
= f.label :snowplow_enabled, _('Enable Snowplow tracking'), class: 'form-check-label'
.form-group
= f.label :snowplow_collector_hostname, _('Collector hostname'), class: 'label-light'
@@ -33,4 +33,4 @@
.form-text.text-muted
= _('The Snowplow cookie domain.')
- = f.submit _('Save changes'), class: 'gl-button btn btn-confirm'
+ = f.submit _('Save changes'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml
index 3a053205725..61a2f97764f 100644
--- a/app/views/admin/application_settings/network.html.haml
+++ b/app/views/admin/application_settings/network.html.haml
@@ -37,17 +37,16 @@
.settings-content
= render partial: 'network_rate_limits', locals: { anchor: 'js-packages-limits-settings', setting_fragment: 'packages_api' }
-- if Feature.enabled?(:files_api_throttling, default_enabled: :yaml)
- %section.settings.as-files-limits.no-animate#js-files-limits-settings{ class: ('expanded' if expanded_by_default?) }
- .settings-header
- %h4
- = _('Files API Rate Limits')
- %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
- = expanded_by_default? ? _('Collapse') : _('Expand')
- %p
- = _('Configure specific limits for Files API requests that supersede the general user and IP rate limits.')
- .settings-content
- = render partial: 'network_rate_limits', locals: { anchor: 'js-files-limits-settings', setting_fragment: 'files_api' }
+%section.settings.as-files-limits.no-animate#js-files-limits-settings{ class: ('expanded' if expanded_by_default?) }
+ .settings-header
+ %h4
+ = _('Files API Rate Limits')
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded_by_default? ? _('Collapse') : _('Expand')
+ %p
+ = _('Configure specific limits for Files API requests that supersede the general user and IP rate limits.')
+ .settings-content
+ = render partial: 'network_rate_limits', locals: { anchor: 'js-files-limits-settings', setting_fragment: 'files_api' }
%section.settings.as-deprecated-limits.no-animate#js-deprecated-limits-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
diff --git a/app/views/admin/dashboard/_security_newsletter_callout.html.haml b/app/views/admin/dashboard/_security_newsletter_callout.html.haml
index ece0f7ca4d9..3aba91e8765 100644
--- a/app/views/admin/dashboard/_security_newsletter_callout.html.haml
+++ b/app/views/admin/dashboard/_security_newsletter_callout.html.haml
@@ -5,7 +5,7 @@
variant: :tip,
alert_class: 'js-security-newsletter-callout',
is_contained: true,
- alert_data: { feature_id: UserCalloutsHelper::SECURITY_NEWSLETTER_CALLOUT, dismiss_endpoint: user_callouts_path, defer_links: 'true' },
+ alert_data: { feature_id: Users::CalloutsHelper::SECURITY_NEWSLETTER_CALLOUT, dismiss_endpoint: callouts_path, defer_links: 'true' },
close_button_data: { testid: 'close-security-newsletter-callout' } do
.gl-alert-body
= s_('AdminArea|Sign up for the GitLab Security Newsletter to get notified for security updates.')
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 4197d5b961f..801b903395a 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -3,6 +3,8 @@
- billable_users_url = help_page_path('subscriptions/self_managed/index', anchor: 'billable-users')
- billable_users_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer nofollow">'.html_safe % { url: billable_users_url }
+= render_if_exists 'shared/manual_renewal_banner'
+= render_if_exists 'shared/manual_quarterly_reconciliation_banner'
= render_if_exists 'shared/qrtly_reconciliation_alert'
= render 'admin/dashboard/security_newsletter_callout'
diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml
index ae809f01592..09f2d431197 100644
--- a/app/views/admin/groups/show.html.haml
+++ b/app/views/admin/groups/show.html.haml
@@ -70,15 +70,13 @@
.card
.card-header
= _('Projects')
- %span.badge.badge-pill
- #{@group.projects.count}
+ = gl_badge_tag @group.projects.count
%ul.content-list
- @projects.each do |project|
%li
%strong
= link_to project.full_name, [:admin, project]
- %span.badge.badge-pill
- = storage_counter(project.statistics.storage_size)
+ = gl_badge_tag storage_counter(project.statistics.storage_size)
%span.float-right.light
%span.monospace= project.full_path + '.git'
- unless @projects.size < Kaminari.config.default_per_page
@@ -90,15 +88,13 @@
.card
.card-header
= _('Projects shared with %{group_name}') % { group_name: @group.name }
- %span.badge.badge-pill
- #{shared_projects.size}
+ = gl_badge_tag shared_projects.size
%ul.content-list
- shared_projects.each do |project|
%li
%strong
= link_to project.full_name, [:admin, project]
- %span.badge.badge-pill
- = storage_counter(project.statistics.storage_size)
+ = gl_badge_tag storage_counter(project.statistics.storage_size)
%span.float-right.light
%span.monospace= project.full_path + '.git'
@@ -126,7 +122,7 @@
.card
.card-header
= html_escape(_("%{group_name} group members")) % { group_name: "<strong>#{html_escape(@group.name)}</strong>".html_safe }
- %span.badge.badge-pill= @group.users_count
+ = gl_badge_tag @group.users_count
= render 'shared/members/manage_access_button', path: group_group_members_path(@group)
%ul.content-list.group-users-list.content-list.members-list
= render partial: 'shared/members/member',
diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml
index 93038e63a2e..9c258e10008 100644
--- a/app/views/admin/hooks/edit.html.haml
+++ b/app/views/admin/hooks/edit.html.haml
@@ -1,6 +1,8 @@
- add_to_breadcrumbs @hook.pluralized_name, admin_hooks_path
- page_title _('Edit System Hook')
+= render 'shared/web_hooks/hook_errors', hook: @hook
+
.row.gl-mt-3
.col-lg-3
= render 'shared/web_hooks/title_and_docs', hook: @hook
diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml
index 6007d891aad..66fd18e1b76 100644
--- a/app/views/admin/labels/index.html.haml
+++ b/app/views/admin/labels/index.html.haml
@@ -6,13 +6,27 @@
%h3.page-title
= _('Labels')
%hr
-
-.labels.labels-container.admin-labels.gl-bg-gray-10.gl-border-solid.gl-border-1.gl-border-gray-100
- - if @labels.present?
+- if @labels.present?
+ .labels.labels-container.admin-labels.js-admin-labels-container.gl-bg-gray-10.gl-border-solid.gl-border-1.gl-border-gray-100
%ul.manage-labels-list
= render @labels
= paginate @labels, theme: 'gitlab'
- .nothing-here-block{ class: ('hidden' if @labels.present?) }
- = _('There are no labels yet')
+.js-admin-labels-empty-state{ class: ('gl-display-none' if @labels.present?) }
+ %section.row.empty-state.gl-text-center
+ .col-12
+ .svg-content
+ = image_tag 'illustrations/labels.svg'
+ .col-12
+ .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.gl-mb-0
+ = s_('AdminLabels|Labels created here will be automatically added to new projects.')
+ %p
+ = s_('AdminLabels|They can be used to categorize issues and merge requests.')
+ .gl-display-flex.gl-flex-wrap.gl-justify-content-center
+ = link_to new_admin_label_path, class: "btn gl-mb-3 btn-confirm btn-md gl-button gl-mx-2" do
+ %span.gl-button-text
+ = _('New label')
diff --git a/app/views/admin/projects/_archived.html.haml b/app/views/admin/projects/_archived.html.haml
index 8b4d5806c47..3d79cc7ca71 100644
--- a/app/views/admin/projects/_archived.html.haml
+++ b/app/views/admin/projects/_archived.html.haml
@@ -1,3 +1,2 @@
- if project.archived
- %span.badge.badge-warning
- = _('archived')
+ = gl_badge_tag _('archived'), variant: :warning
diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml
index 6f7cea85ed1..f56b77813b5 100644
--- a/app/views/admin/projects/_projects.html.haml
+++ b/app/views/admin/projects/_projects.html.haml
@@ -9,8 +9,7 @@
= s_('AdminProjects|Delete')
.stats
- %span.badge.badge-pill
- = storage_counter(project.statistics&.storage_size)
+ = gl_badge_tag storage_counter(project.statistics&.storage_size)
= render_if_exists 'admin/projects/archived', project: project
.title
= link_to(admin_project_path(project)) do
diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml
index 3069aab2710..ee2e63353f0 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -178,7 +178,7 @@
.card-header
%strong= @group.name
= _('group members')
- %span.badge.badge-pill= @group_members.size
+ = gl_badge_tag @group_members.size
= render 'shared/members/manage_access_button', path: group_group_members_path(@group)
%ul.content-list.members-list
= render partial: 'shared/members/member',
@@ -195,7 +195,7 @@
.card-header
%strong= @project.name
= _('project members')
- %span.badge.badge-pill= @project.users.size
+ = gl_badge_tag @project.users.size
= render 'shared/members/manage_access_button', path: project_project_members_path(@project)
%ul.content-list.project_members.members-list
= render partial: 'shared/members/member',
diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml
index 6a5f07dd2db..5977de7c84c 100644
--- a/app/views/admin/users/_access_levels.html.haml
+++ b/app/views/admin/users/_access_levels.html.haml
@@ -43,9 +43,8 @@
= s_('AdminUsers|External')
%p.light.gl-pl-2
= 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.')
- %row.hidden#warning_external_automatically_set.hidden
- .badge.badge-warning.text-white
- = s_('AdminUsers|Automatically marked as default internal user')
+ %row.hidden#warning_external_automatically_set
+ = gl_badge_tag s_('AdminUsers|Automatically marked as default internal user'), variant: :warning
.form-group.row
- @user.credit_card_validation || @user.build_credit_card_validation
diff --git a/app/views/admin/users/_user_detail.html.haml b/app/views/admin/users/_user_detail.html.haml
deleted file mode 100644
index 05e387e6479..00000000000
--- a/app/views/admin/users/_user_detail.html.haml
+++ /dev/null
@@ -1,20 +0,0 @@
-.flex-list
- .flex-row
- = image_tag avatar_icon_for_user(user), class: 'avatar s32 d-none d-md-flex', alt: _('Avatar for %{name}') % { name: sanitize_name(user.name) }
- .row-main-content
- .row-title.str-truncated-100
- = image_tag avatar_icon_for_user(user), class: 'avatar s16 d-xs-flex d-md-none mr-1 gl-mt-2', alt: _('Avatar for %{name}') % { name: sanitize_name(user.name) }
- = link_to user.name, admin_user_path(user), class: 'text-plain js-user-link', data: { user_id: user.id, qa_selector: 'username_link' }
-
- = render 'admin/users/user_listing_note', user: user
-
- - user_badges_in_admin_section(user).each do |badge|
- - css_badge = "badge gl-badge sm badge-pill badge-#{badge[:variant]}" if badge[:variant].present?
- %span.px-1.py-1
- %span{ class: css_badge }
- = badge[:text]
-
- .row-second-line.str-truncated-100
- = mail_to user.email, user.email, class: 'text-secondary'
- - unless Feature.disabled?(:security_auto_fix) || !user.internal? || user.website_url.blank?
- = link_to "(#{_('more information')})", user.website_url
diff --git a/app/views/admin/users/projects.html.haml b/app/views/admin/users/projects.html.haml
index 8c56e888dcc..b47ed38f65f 100644
--- a/app/views/admin/users/projects.html.haml
+++ b/app/views/admin/users/projects.html.haml
@@ -17,7 +17,7 @@
%span.light.vertical-align-middle= group_member.human_access
- unless group_member.owner?
= link_to group_group_member_path(group, group_member), data: { confirm: remove_member_message(group_member), testid: 'remove-user' }, method: :delete, remote: true, class: "btn btn-sm btn-danger gl-button btn-icon gl-ml-3", title: _('Remove user from group') do
- = sprite_icon('close', size: 16, css_class: 'gl-icon')
+ = sprite_icon('remove', size: 16, css_class: 'gl-icon')
.row
.col-md-6
@@ -47,6 +47,6 @@
- if member.respond_to? :project
= link_to project_project_member_path(project, member), data: { confirm: remove_member_message(member) }, remote: true, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon gl-ml-3", title: _('Remove user from project') do
- = sprite_icon('close', size: 16, css_class: 'gl-icon')
+ = sprite_icon('remove', size: 16, css_class: 'gl-icon')
= render partial: 'admin/users/modals'
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index 2a9b4694e7b..bdc5bdabb21 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -35,7 +35,7 @@
%span.light= _('Email:')
%strong
= render partial: 'shared/email_with_badge', locals: { email: mail_to(@user.email), verified: @user.confirmed? }
- - @user.emails.each do |email|
+ - @user.emails.reject(&:user_primary_email?).each do |email|
%li
%span.light= _('Secondary email:')
%strong
diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
index 81f4be9fce5..9d249931a34 100644
--- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
+++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
@@ -1,5 +1,5 @@
- link = link_to(s_('ClusterIntegration|sign up'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer')
-.gcp-signup-offer.gl-alert.gl-alert-info.gl-my-3{ role: 'alert', data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } }
+.gcp-signup-offer.gl-alert.gl-alert-info.gl-my-3{ role: 'alert', data: { feature_id: Users::CalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: callouts_path } }
.gl-alert-container
%button.js-close.btn.gl-dismiss-btn.btn-default.btn-sm.gl-button.btn-default-tertiary.btn-icon{ type: 'button', 'aria-label' => _('Dismiss') }
= sprite_icon('close', size: 16, css_class: 'gl-icon')
diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml
index e4c8f225ed2..a6efe597f0c 100644
--- a/app/views/clusters/clusters/show.html.haml
+++ b/app/views/clusters/clusters/show.html.haml
@@ -24,10 +24,9 @@
.js-serverless-survey-banner{ data: { user_name: current_user.name, user_email: current_user.email } }
- %h4.gl-my-5
+ %h4.gl-my-5.gl-display-flex.gl-align-items-center
= @cluster.name
- %span.badge.badge-info.badge-pill.gl-badge.md.gl-vertical-align-middle
- = cluster_type_label(@cluster.cluster_type)
+ = gl_badge_tag cluster_type_label(@cluster.cluster_type), { variant: :info }, { class: 'gl-ml-3' }
= render 'banner'
diff --git a/app/views/devise/confirmations/almost_there.haml b/app/views/devise/confirmations/almost_there.haml
index 9fb0fb734f9..892ef730884 100644
--- a/app/views/devise/confirmations/almost_there.haml
+++ b/app/views/devise/confirmations/almost_there.haml
@@ -1,6 +1,8 @@
- user_email = "(#{params[:email]})" if params[:email].present?
- request_link_start = '<a href="%{new_user_confirmation_path}">'.html_safe % { new_user_confirmation_path: new_user_confirmation_path }
- request_link_end = '</a>'.html_safe
+- content_for :page_specific_javascripts do
+ = render "layouts/one_trust"
.well-confirmation.gl-text-center.gl-mb-6
%h1.gl-mt-0
diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml
index da6232b2a2b..175b45dbbfa 100644
--- a/app/views/devise/sessions/new.html.haml
+++ b/app/views/devise/sessions/new.html.haml
@@ -1,8 +1,6 @@
- page_title _("Sign in")
- content_for :page_specific_javascripts do
- = render "layouts/google_tag_manager_head"
= render "layouts/one_trust"
-= render "layouts/google_tag_manager_body"
#signin-container
- if any_form_based_providers_enabled?
diff --git a/app/views/devise/shared/_tab_single.html.haml b/app/views/devise/shared/_tab_single.html.haml
index 5683b4207b4..1b5a932a09a 100644
--- a/app/views/devise/shared/_tab_single.html.haml
+++ b/app/views/devise/shared/_tab_single.html.haml
@@ -1,3 +1,2 @@
-%ul.nav-links.new-session-tabs.single-tab.nav-tabs.nav
- %li.nav-item
- %a.nav-link.active= tab_title
+= gl_tabs_nav({ class: 'new-session-tabs gl-border-0' }) do
+ = gl_tab_link_to tab_title, '#', { item_active: true, class: 'gl-cursor-default!', tab_class: 'gl-bg-transparent!', tabindex: '-1' }
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 8d6e043ebf7..0644910dd3e 100644
--- a/app/views/groups/_import_group_from_file_panel.html.haml
+++ b/app/views/groups/_import_group_from_file_panel.html.haml
@@ -6,9 +6,17 @@
.gl-border-l-solid.gl-border-r-solid.gl-border-gray-100.gl-border-1.gl-p-5
%h4
= _('Import group from file')
- %p
- = s_('GroupsNew|Provide credentials for another instance of GitLab to import your groups directly.')
- .form-group.gl-display-flex.gl-flex-direction-column
+ .gl-alert.gl-alert-warning{ role: 'alert' }
+ = sprite_icon('warning', css_class: 'gl-icon s16 gl-alert-icon gl-alert-icon-no-title')
+ .gl-alert-body
+ - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index.md') }
+ - link_end = '</a>'.html_safe
+ = s_('GroupsNew|This feature is deprecated and replaced by %{docs_link_start}Group Migration%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: link_end }
+ - if Feature.enabled?(:bulk_import, default_enabled: :yaml)
+ - enable_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index.md', anchor: 'enable-or-disable-gitlab-group-migration') }
+ = s_('GroupsNew|Ask your administrator to %{enable_link_start}enable%{enable_link_end} Group Migration.').html_safe % { enable_link_start: enable_link_start, enable_link_end: link_end }
+
+ .form-group.gl-display-flex.gl-flex-direction-column.gl-mt-5
= f.label :name, _('New group name'), for: 'import_group_name'
= f.text_field :name, placeholder: s_('GroupsNew|My Awesome Group'), class: 'js-autofill-group-name gl-form-input col-xs-12 col-sm-8',
required: true,
diff --git a/app/views/groups/_invite_members_side_nav_link.html.haml b/app/views/groups/_invite_members_side_nav_link.html.haml
index bccfa9897da..3046669b53b 100644
--- a/app/views/groups/_invite_members_side_nav_link.html.haml
+++ b/app/views/groups/_invite_members_side_nav_link.html.haml
@@ -1,5 +1,4 @@
.js-invite-members-trigger{ data: { trigger_source: 'group-side-nav',
- classes: 'gl-text-decoration-none! gl-shadow-none! gl-text-body!',
icon: 'users',
display_text: title,
trigger_element: 'side-nav'} }
diff --git a/app/views/groups/_personalize.html.haml b/app/views/groups/_personalize.html.haml
index 5ecb0017cd8..07b3b29c20c 100644
--- a/app/views/groups/_personalize.html.haml
+++ b/app/views/groups/_personalize.html.haml
@@ -15,7 +15,7 @@
= f.label :setup_for_company, _('Who will be using this group?')
.gl-display-flex.gl-flex-direction-column.gl-lg-flex-direction-row
.gl-flex-grow-1.gl-display-flex.gl-align-items-center
- = f.radio_button :setup_for_company, true, checked: true
+ = f.radio_button :setup_for_company, true
= f.label :setup_for_company, _('My company or team'), class: 'gl-font-weight-normal gl-mb-0 gl-ml-2', value: 'true'
.gl-flex-grow-1.gl-display-flex.gl-align-items-center
= f.radio_button :setup_for_company, false
diff --git a/app/views/groups/_project_badges.html.haml b/app/views/groups/_project_badges.html.haml
index 1f7895e216c..4ea193b08c2 100644
--- a/app/views/groups/_project_badges.html.haml
+++ b/app/views/groups/_project_badges.html.haml
@@ -1,2 +1,2 @@
- if project.archived
- %span.badge.badge-warning.badge-pill.gl-badge.md= _('archived')
+ = gl_badge_tag _('archived'), variant: :warning, size: :md
diff --git a/app/views/groups/crm/contacts.html.haml b/app/views/groups/crm/contacts.html.haml
deleted file mode 100644
index c452a969d17..00000000000
--- a/app/views/groups/crm/contacts.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-- breadcrumb_title _('Customer Relations Contacts')
-- page_title _('Customer Relations Contacts')
-
-#js-crm-contacts-app{ data: { group_full_path: @group.full_path } }
diff --git a/app/views/groups/crm/contacts/index.html.haml b/app/views/groups/crm/contacts/index.html.haml
new file mode 100644
index 00000000000..81293937f77
--- /dev/null
+++ b/app/views/groups/crm/contacts/index.html.haml
@@ -0,0 +1,4 @@
+- breadcrumb_title _('Customer Relations Contacts')
+- page_title _('Customer Relations Contacts')
+
+#js-crm-contacts-app{ data: { group_full_path: @group.full_path, group_issues_path: issues_group_path(@group), group_id: @group.id, can_admin_crm_contact: can?(current_user, :admin_crm_contact, @group).to_s, base_path: group_crm_contacts_path(@group) } }
diff --git a/app/views/groups/crm/organizations.html.haml b/app/views/groups/crm/organizations.html.haml
deleted file mode 100644
index e83dab9fda6..00000000000
--- a/app/views/groups/crm/organizations.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-- breadcrumb_title _('Customer Relations Organizations')
-- page_title _('Customer Relations Organizations')
-
-#js-crm-organizations-app{ data: { group_full_path: @group.full_path } }
diff --git a/app/views/groups/crm/organizations/index.html.haml b/app/views/groups/crm/organizations/index.html.haml
new file mode 100644
index 00000000000..1647805b976
--- /dev/null
+++ b/app/views/groups/crm/organizations/index.html.haml
@@ -0,0 +1,4 @@
+- breadcrumb_title _('Customer Relations Organizations')
+- page_title _('Customer Relations Organizations')
+
+#js-crm-organizations-app{ data: { base_path: group_crm_organizations_path(@group), can_admin_crm_organization: can?(current_user, :admin_crm_organization, @group).to_s, group_full_path: @group.full_path, group_id: @group.id, group_issues_path: issues_group_path(@group) } }
diff --git a/app/views/groups/packages/index.html.haml b/app/views/groups/packages/index.html.haml
index 7910217c939..d56a806f082 100644
--- a/app/views/groups/packages/index.html.haml
+++ b/app/views/groups/packages/index.html.haml
@@ -3,4 +3,8 @@
.row
.col-12
- #js-vue-packages-list{ data: packages_list_data('groups', @group) }
+ #js-vue-packages-list{ data: { resource_id: @group.id,
+ full_path: @group.full_path,
+ endpoint: group_packages_path(@group),
+ page_type: 'groups',
+ empty_list_illustration: image_path('illustrations/no-packages.svg'), } }
diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml
index 9dbf60b119c..3507f4574ab 100644
--- a/app/views/groups/projects.html.haml
+++ b/app/views/groups/projects.html.haml
@@ -18,12 +18,11 @@
= render 'delete_project_button', project: project
.stats
- %span.badge.badge-pill
- = storage_counter(project.statistics&.storage_size)
+ = gl_badge_tag storage_counter(project.statistics&.storage_size)
= render 'project_badges', project: project
.title
- = link_to(project_path(project)) do
+ = link_to project_path(project), class: 'js-prefetch-document' do
.dash-project-avatar
.avatar-container.rect-avatar.s40
= project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40)
diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml
index 2901c8fa46b..f6d05959d2e 100644
--- a/app/views/groups/registry/repositories/index.html.haml
+++ b/app/views/groups/registry/repositories/index.html.haml
@@ -18,6 +18,6 @@
"gid_prefix": container_repository_gid_prefix,
connection_error: (!!@connection_error).to_s,
invalid_path_error: (!!@invalid_path_error).to_s,
- user_callouts_path: user_callouts_path,
- user_callout_id: UserCalloutsHelper::UNFINISHED_TAG_CLEANUP_CALLOUT,
+ user_callouts_path: callouts_path,
+ user_callout_id: Users::CalloutsHelper::UNFINISHED_TAG_CLEANUP_CALLOUT,
show_unfinished_tag_cleanup_callout: show_unfinished_tag_cleanup_callout?.to_s } }
diff --git a/app/views/groups/runners/_group_runners.html.haml b/app/views/groups/runners/_group_runners.html.haml
index 1cccce9f59a..e7cfc87ac88 100644
--- a/app/views/groups/runners/_group_runners.html.haml
+++ b/app/views/groups/runners/_group_runners.html.haml
@@ -10,7 +10,7 @@
= _('These runners are shared across projects in this group.')
= _('Group runners can be managed with the %{link}.').html_safe % { link: link }
- - if can?(current_user, :admin_pipeline, @group) && valid_runner_registrars.include?('group')
+ - if can?(current_user, :register_group_runners, @group)
- if params[:ci_runner_templates]
%hr
= render partial: 'ci/runner/setup_runner_in_aws',
diff --git a/app/views/groups/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml
index a82f7803b44..ebeec2ee95a 100644
--- a/app/views/groups/settings/_advanced.html.haml
+++ b/app/views/groups/settings/_advanced.html.haml
@@ -7,9 +7,8 @@
= form_errors(@group)
.form-group
%p
- = s_('GroupSettings|Changing group URL can have unintended side effects.')
- = succeed '.' do
- = link_to _('Learn more'), help_page_path('user/group/index', anchor: 'change-a-groups-path'), target: '_blank'
+ = s_("GroupSettings|Changing a group's URL can have unintended side effects.")
+ = link_to _('Learn more.'), help_page_path('user/group/index', 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' }
diff --git a/app/views/groups/settings/_export.html.haml b/app/views/groups/settings/_export.html.haml
index f818f45cf53..ff00ff1f6e8 100644
--- a/app/views/groups/settings/_export.html.haml
+++ b/app/views/groups/settings/_export.html.haml
@@ -4,8 +4,17 @@
.sub-section
%h4= s_('GroupSettings|Export group')
- %p= _('Export this group with all related data to a new GitLab instance. Once complete, you can import the data file from the "New Group" page.')
-
+ %p= _('Export this group with all related data.')
+ .gl-alert.gl-alert-warning.gl-mb-4{ role: 'alert' }
+ = sprite_icon('warning', css_class: 'gl-icon s16 gl-alert-icon gl-alert-icon-no-title')
+ .gl-alert-body
+ - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index.md') }
+ - docs_link_end = '</a>'.html_safe
+ = s_('GroupsNew|This feature is deprecated and replaced by %{docs_link_start}Group Migration%{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
+ = link_to _('Learn more.'), help_page_path('user/group/settings/import_export.md'), target: '_blank', rel: 'noopener noreferrer'
.bs-callout.bs-callout-info
%p.gl-mb-0
%p= _('The following items will be exported:')
@@ -17,7 +26,6 @@
%li= _('Projects')
%li= _('Runner tokens')
%li= _('SAML discovery tokens')
- %p= _('Once the exported file is ready, you will receive a notification email with a download link, or you can download it from this page.')
- if group.export_file_exists?
= link_to _('Regenerate export'), export_group_path(group),
method: :post, class: 'btn gl-button btn-default', data: { qa_selector: 'regenerate_export_group_link' }
diff --git a/app/views/groups/settings/_transfer.html.haml b/app/views/groups/settings/_transfer.html.haml
index b2379d77314..59d52e99dec 100644
--- a/app/views/groups/settings/_transfer.html.haml
+++ b/app/views/groups/settings/_transfer.html.haml
@@ -1,18 +1,19 @@
.sub-section
%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: { class: 'js-group-transfer-form' } do |f|
- .form-group
- = dropdown_tag('Select parent group', options: { toggle_class: 'js-groups-dropdown', title: 'Parent Group', filter: true, dropdown_class: 'dropdown-open-top dropdown-group-transfer', placeholder: 'Search groups', disabled: group.paid?, data: { data: parent_group_options(group), qa_selector: 'select_group_dropdown' } })
- = hidden_field_tag 'new_parent_group_id'
%ul
- - side_effects_link_start = '<a href="https://docs.gitlab.com/ee/user/project/index.html#redirects-when-changing-repository-paths" target="_blank">'.html_safe
- - warning_text = s_("GroupSettings|Be careful. Changing a group's parent can have unintended %{side_effects_link_start}side effects%{side_effects_link_end}.") % { side_effects_link_start: side_effects_link_start, side_effects_link_end: '</a>'.html_safe }
+ - learn_more_link_start = '<a href="https://docs.gitlab.com/ee/user/project/index.html#redirects-when-changing-repository-paths" target="_blank" rel="noopener noreferrer">'.html_safe
+ - 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 can only transfer the group to a group you manage.')
%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 current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.")
+ .form-group
+ = dropdown_tag(s_('GroupSettings|Select parent group'), options: { toggle_class: 'js-groups-dropdown', title: s_('GroupSettings|Parent Group'), filter: true, dropdown_class: 'dropdown-open-top dropdown-group-transfer', placeholder: s_('GroupSettings|Search groups'), disabled: group.paid?, data: { data: parent_group_options(group), qa_selector: 'select_group_dropdown' } })
+ = hidden_field_tag 'new_parent_group_id'
- if group.paid?
.gl-alert.gl-alert-info.gl-mb-5
= sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
diff --git a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
index dedb87c51ef..32da444d058 100644
--- a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
+++ b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
@@ -8,7 +8,7 @@
= f.check_box :auto_devops_enabled, class: 'form-check-input', checked: group.auto_devops_enabled?
= f.label :auto_devops_enabled, class: 'form-check-label' do
%strong= s_('GroupSettings|Default to Auto DevOps pipeline for all projects within this group')
- %span.badge.badge-info#auto-devops-badge= badge_for_auto_devops_scope(group)
+ = gl_badge_tag badge_for_auto_devops_scope(group), variant: :info
.form-text.text-muted
= s_('GroupSettings|The Auto DevOps pipeline runs if no alternative CI configuration file is found.')
= link_to _('Learn more.'), help_page_path('topics/autodevops/index.md'), target: '_blank'
diff --git a/app/views/groups/settings/packages_and_registries/show.html.haml b/app/views/groups/settings/packages_and_registries/show.html.haml
index 7be6dc73c49..78ce981eb07 100644
--- a/app/views/groups/settings/packages_and_registries/show.html.haml
+++ b/app/views/groups/settings/packages_and_registries/show.html.haml
@@ -5,4 +5,5 @@
%section#js-packages-and-registries-settings{ data: { default_expanded: expanded_by_default?.to_s,
group_path: @group.full_path,
- dependency_proxy_available: dependency_proxy_available.to_s } }
+ dependency_proxy_available: dependency_proxy_available.to_s,
+ group_dependency_proxy_path: group_dependency_proxy_path(@group) } }
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index ed3f2b0c6db..bb409190dd8 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -15,7 +15,7 @@
track_label: 'invite_members_banner',
invite_members_path: group_group_members_path(@group),
callouts_path: group_callouts_path,
- callouts_feature_id: UserCalloutsHelper::INVITE_MEMBERS_BANNER,
+ callouts_feature_id: Users::GroupCalloutsHelper::INVITE_MEMBERS_BANNER,
group_id: @group.id } }
= render 'groups/invite_members_modal', group: @group
diff --git a/app/views/help/instance_configuration/_package_registry.html.haml b/app/views/help/instance_configuration/_package_registry.html.haml
index 38202b8d6e6..84b8accfebb 100644
--- a/app/views/help/instance_configuration/_package_registry.html.haml
+++ b/app/views/help/instance_configuration/_package_registry.html.haml
@@ -23,6 +23,10 @@
- package_file_size_limits.each_value do |limits|
%td= instance_configuration_human_size_cell(limits[:conan])
%tr
+ %td= 'Helm'
+ - package_file_size_limits.each_value do |limits|
+ %td= instance_configuration_human_size_cell(limits[:helm])
+ %tr
%td= 'Maven'
- package_file_size_limits.each_value do |limits|
%td= instance_configuration_human_size_cell(limits[:maven])
diff --git a/app/views/import/bitbucket/deploy_key.js.haml b/app/views/import/bitbucket/deploy_key.js.haml
deleted file mode 100644
index 99e8ac1afa1..00000000000
--- a/app/views/import/bitbucket/deploy_key.js.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-:plain
- job = $("tr#repo_#{@repo_id}")
- job.find(".import-actions").html("<p class='alert alert-danger'>#{_('Access denied! Please verify you can add deploy keys to this repository.')}</p>")
diff --git a/app/views/jira_connect/subscriptions/index.html.haml b/app/views/jira_connect/subscriptions/index.html.haml
index be2be7288f8..d92c30c8840 100644
--- a/app/views/jira_connect/subscriptions/index.html.haml
+++ b/app/views/jira_connect/subscriptions/index.html.haml
@@ -1,13 +1,6 @@
%header.jira-connect-header.gl-display-flex.gl-align-items-center.gl-justify-content-center.gl-px-5.gl-border-b-solid.gl-border-b-gray-100.gl-border-b-1.gl-bg-white
= link_to brand_header_logo, Gitlab.config.gitlab.url, target: '_blank', rel: 'noopener noreferrer'
-.jira-connect-user.gl-font-base
- - if current_user
- - user_link = link_to(current_user.to_reference, jira_connect_users_path, target: '_blank', rel: 'noopener noreferrer', class: 'js-jira-connect-sign-in')
- = _('Signed in to GitLab as %{user_link}').html_safe % { user_link: user_link }
- - elsif @subscriptions.present?
- = link_to _('Sign in to GitLab'), jira_connect_users_path, target: '_blank', rel: 'noopener noreferrer', class: 'js-jira-connect-sign-in'
-
%main.jira-connect-app.gl-px-5.gl-pt-7.gl-mx-auto
.js-jira-connect-app{ data: jira_connect_app_data(@subscriptions) }
diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml
index a302fa605e7..dded5ba76b0 100644
--- a/app/views/layouts/_flash.html.haml
+++ b/app/views/layouts/_flash.html.haml
@@ -1,9 +1,11 @@
--# We currently only support `alert`, `notice`, `success`, 'toast'
+-# We currently only support `alert`, `notice`, `success`, 'toast', and 'raw'
- icons = {'alert' => 'error', 'notice' => 'information-o', 'success' => 'check-circle'}
.flash-container.flash-container-page.sticky{ data: { qa_selector: 'flash_container' } }
- flash.each do |key, value|
- if key == 'toast' && value
.js-toast-message{ data: { message: value } }
+ - elsif key == 'raw' && value
+ = value
- elsif value == I18n.t('devise.failure.unconfirmed')
= render 'shared/confirm_your_email_alert'
- elsif value
diff --git a/app/views/layouts/_google_tag_manager_body.html.haml b/app/views/layouts/_google_tag_manager_body.html.haml
index d62e52dc91b..98d7bf5d138 100644
--- a/app/views/layouts/_google_tag_manager_body.html.haml
+++ b/app/views/layouts/_google_tag_manager_body.html.haml
@@ -1,4 +1,4 @@
- return unless google_tag_manager_enabled?
-<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=#{extra_config.google_tag_manager_id}"
+<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=#{google_tag_manager_id}"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
diff --git a/app/views/layouts/_google_tag_manager_head.html.haml b/app/views/layouts/_google_tag_manager_head.html.haml
index 48eb9e40cc4..25af51ca9cb 100644
--- a/app/views/layouts/_google_tag_manager_head.html.haml
+++ b/app/views/layouts/_google_tag_manager_head.html.haml
@@ -1,8 +1,19 @@
-- if google_tag_manager_enabled?
+- return unless google_tag_manager_enabled?
+
+- if Feature.enabled?(:gtm_nonce, type: :ops)
+ = javascript_tag nonce: content_security_policy_nonce do
+ :plain
+ (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
+ new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
+ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
+ 'https://www.googletagmanager.com/gtm.js?id='+i+dl;j.setAttribute('nonce',
+ '#{content_security_policy_nonce}');f.parentNode.insertBefore(j,f);
+ })(window,document,'script','dataLayer','#{google_tag_manager_id}');
+- else
= javascript_tag do
:plain
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
- })(window,document,'script','dataLayer','#{extra_config.google_tag_manager_id}');
+ })(window,document,'script','dataLayer','#{google_tag_manager_id}');
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index dff1b5e3d04..3e875a0eb24 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -3,6 +3,7 @@
= render "layouts/nav/sidebar/#{nav}"
.content-wrapper.content-wrapper-margin{ class: "#{@content_wrapper_class}" }
.mobile-overlay
+ = render_if_exists 'layouts/header/verification_reminder'
= yield :group_invite_members_banner
.alert-wrapper.gl-force-block-formatting-context
= render 'shared/outdated_browser'
@@ -20,7 +21,6 @@
= render_if_exists "shared/namespace_user_cap_reached_alert"
= render_if_exists "shared/new_user_signups_cap_reached_alert"
= yield :page_level_alert
- = yield :customize_homepage_banner
- unless @hide_breadcrumbs
= render "layouts/nav/breadcrumbs"
%div{ class: "#{(container_class unless @no_container)} #{@content_class}" }
diff --git a/app/views/layouts/_snowplow.html.haml b/app/views/layouts/_snowplow.html.haml
index 7e242fb4a8e..8e9a5ea9406 100644
--- a/app/views/layouts/_snowplow.html.haml
+++ b/app/views/layouts/_snowplow.html.haml
@@ -1,5 +1,7 @@
- return unless Gitlab::Tracking.enabled?
+- namespace = @group || @project&.namespace
+
= javascript_tag do
:plain
;(function(p,l,o,w,i,n,g){if(!p[i]){p.GlobalSnowplowNamespace=p.GlobalSnowplowNamespace||[];
@@ -10,6 +12,6 @@
window.snowplowOptions = #{Gitlab::Tracking.options(@group).to_json}
gl = window.gl || {};
- gl.snowplowStandardContext = #{Gitlab::Tracking::StandardContext.new(namespace: @group || @project&.namespace,
+ gl.snowplowStandardContext = #{Gitlab::Tracking::StandardContext.new(namespace: namespace,
project: @project, user: current_user).to_context.to_json.to_json}
- gl.snowplowPseudonymizedPageUrl = #{masked_page_url.to_json};
+ gl.snowplowPseudonymizedPageUrl = #{masked_page_url(group: namespace, project: @project).to_json};
diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml
index 6bb51b01c13..daa48980c5b 100644
--- a/app/views/layouts/header/_current_user_dropdown.html.haml
+++ b/app/views/layouts/header/_current_user_dropdown.html.haml
@@ -45,7 +45,7 @@
= render 'shared/user_dropdown_instance_review'
- if Gitlab.com_but_not_canary?
%li.d-md-none
- = link_to _("Switch to GitLab Next"), "https://next.gitlab.com/"
+ = link_to _("Switch to GitLab Next"), Gitlab::Saas.canary_toggle_com_url
- if current_user_menu?(:sign_out)
%li.divider
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index 83e8ff79aec..69b8518ef33 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -15,7 +15,7 @@
%span.logo-text.d-none.d-lg-block.gl-ml-3
= logo_text
- if Gitlab.com_and_canary?
- = link_to 'https://next.gitlab.com', class: 'canary-badge bg-transparent', data: { qa_selector: 'canary_badge_link' }, target: :_blank, rel: :_noopener do
+ = link_to Gitlab::Saas.canary_toggle_com_url, class: 'canary-badge bg-transparent', data: { qa_selector: 'canary_badge_link' }, target: :_blank, rel: 'noopener noreferrer' do
%span.gl-badge.gl-bg-green-500.gl-text-white.gl-rounded-pill.gl-font-weight-bold.gl-py-1
= _('Next')
@@ -36,7 +36,9 @@
'issues-path' => issues_dashboard_path,
'mr-path' => merge_requests_dashboard_path,
'autocomplete-path' => search_autocomplete_path } }
- %input{ type: "text", placeholder: _('Search or jump to...'), class: 'form-control gl-form-input' }
+ .gl-search-box-by-type
+ = sprite_icon('search', css_class: 'gl-search-box-by-type-search-icon gl-icon')
+ %input{ type: "text", placeholder: _('Search or jump to...'), class: 'form-control gl-form-input gl-search-box-by-type-input', id: 'search', autocomplete: 'off' }
- else
= render 'layouts/search'
%li.nav-item{ class: 'd-none d-sm-inline-block d-lg-none' }
@@ -44,7 +46,7 @@
= sprite_icon(search_menu_item.fetch(:icon))
- if header_link?(:issues)
= nav_link(path: 'dashboard#issues', html_options: { class: "user-counter" }) do
- = link_to assigned_issues_dashboard_path, title: _('Issues'), class: 'dashboard-shortcuts-issues', aria: { label: _('Issues') },
+ = link_to assigned_issues_dashboard_path, title: _('Issues'), class: 'dashboard-shortcuts-issues js-prefetch-document', aria: { label: _('Issues') },
data: { qa_selector: 'issues_shortcut_button', toggle: 'tooltip', placement: 'bottom',
track_label: 'main_navigation',
track_action: 'click_issues_link',
@@ -73,18 +75,18 @@
%li.dropdown-header
= _('Merge requests')
%li
- = link_to assigned_mrs_dashboard_path, class: 'gl-display-flex! gl-align-items-center' do
+ = link_to assigned_mrs_dashboard_path, class: 'gl-display-flex! gl-align-items-center js-prefetch-document' do
= _('Assigned to you')
%span.badge.gl-badge.badge-pill.badge-muted.merge-request-badge.gl-ml-auto.js-assigned-mr-count{ class: "" }
= user_merge_requests_counts[:assigned]
%li
- = link_to reviewer_mrs_dashboard_path, class: 'gl-display-flex! gl-align-items-center' do
+ = link_to reviewer_mrs_dashboard_path, class: 'gl-display-flex! gl-align-items-center js-prefetch-document' do
= _('Review requests for you')
%span.badge.gl-badge.badge-pill.badge-muted.merge-request-badge.gl-ml-auto.js-reviewer-mr-count{ class: "" }
= user_merge_requests_counts[:review_requested]
- 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',
+ = link_to dashboard_todos_path, title: _('To-Do List'), aria: { label: _('To-Do List') }, class: 'shortcuts-todos js-prefetch-document',
data: { qa_selector: 'todos_shortcut_button', toggle: 'tooltip', placement: 'bottom',
track_label: 'main_navigation',
track_action: 'click_to_do_link',
@@ -94,7 +96,7 @@
%span.badge.badge-pill.todos-count.js-todos-count{ class: ('hidden' if todos_pending_count == 0) }
= todos_count_format(todos_pending_count)
%li.nav-item.header-help.dropdown.d-none.d-md-block{ **tracking_attrs('main_navigation', 'click_question_mark_link', 'navigation') }
- = link_to help_path, class: 'header-help-dropdown-toggle', data: { toggle: "dropdown" } do
+ = link_to help_path, class: 'header-help-dropdown-toggle gl-relative', data: { toggle: "dropdown" } do
%span.gl-sr-only
= s_('Nav|Help')
= sprite_icon('question-o')
diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml
index a03cd4cd68f..e2c7781da54 100644
--- a/app/views/layouts/header/_help_dropdown.html.haml
+++ b/app/views/layouts/header/_help_dropdown.html.haml
@@ -20,4 +20,4 @@
= render 'shared/user_dropdown_instance_review'
- if Gitlab.com_but_not_canary?
%li
- = link_to _("Switch to GitLab Next"), "https://next.gitlab.com/"
+ = link_to _("Switch to GitLab Next"), Gitlab::Saas.canary_toggle_com_url
diff --git a/app/views/layouts/header/_registration_enabled_callout.html.haml b/app/views/layouts/header/_registration_enabled_callout.html.haml
index 25a7f7ba9d7..90f3ac61614 100644
--- a/app/views/layouts/header/_registration_enabled_callout.html.haml
+++ b/app/views/layouts/header/_registration_enabled_callout.html.haml
@@ -4,7 +4,7 @@
title: _('Open registration is enabled on your instance.'),
variant: :warning,
alert_class: 'js-registration-enabled-callout',
- alert_data: { feature_id: UserCalloutsHelper::REGISTRATION_ENABLED_CALLOUT, dismiss_endpoint: user_callouts_path },
+ alert_data: { feature_id: Users::CalloutsHelper::REGISTRATION_ENABLED_CALLOUT, dismiss_endpoint: callouts_path },
close_button_data: { testid: 'close-registration-enabled-callout' } do
.gl-alert-body
= html_escape(_('%{anchorOpen}Learn more%{anchorClose} about how you can customize / disable registration on your instance.')) % { anchorOpen: "<a href=\"#{help_page_path('user/admin_area/settings/sign_up_restrictions')}\" class=\"gl-link\">".html_safe, anchorClose: '</a>'.html_safe }
diff --git a/app/views/layouts/in_product_marketing_mailer.html.haml b/app/views/layouts/in_product_marketing_mailer.html.haml
new file mode 100644
index 00000000000..679a2d4b8b3
--- /dev/null
+++ b/app/views/layouts/in_product_marketing_mailer.html.haml
@@ -0,0 +1,194 @@
+!!!
+%html{ lang: "en" }
+ %head
+ %meta{ content: "text/html; charset=utf-8", "http-equiv" => "Content-Type" }
+ %meta{ content: "width=device-width, initial-scale=1", name: "viewport" }
+ %link{ href: "https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,600", rel: "stylesheet", type: "text/css", data: { premailer: 'ignore' } }
+ %title= message.subject
+ :css
+ /* CLIENT-SPECIFIC STYLES */
+ body,
+ table,
+ td,
+ a {
+ -webkit-text-size-adjust: 100%;
+ -ms-text-size-adjust: 100%;
+ }
+
+ table,
+ td {
+ mso-table-lspace: 0pt;
+ mso-table-rspace: 0pt;
+ }
+
+ img {
+ -ms-interpolation-mode: bicubic;
+ }
+
+ /* RESET STYLES */
+ img {
+ border: 0;
+ height: auto;
+ line-height: 100%;
+ outline: none;
+ text-decoration: none;
+ }
+
+ table {
+ border-collapse: collapse !important;
+ }
+
+ body {
+ height: 100% !important;
+ margin: 0 !important;
+ padding: 0 !important;
+ width: 100% !important;
+ background-color: #ffffff;
+ color: #424242;
+ }
+
+ a {
+ color: #6b4fbb;
+ text-decoration: underline;
+ }
+
+ .cta_link a {
+ font-size: 24px;
+ font-family: 'Source Sans Pro', helvetica, arial, sans-serif;
+ text-decoration: none;
+ display: inline-block;
+ }
+
+ .cta_link_primary a {
+ color: #ffffff;
+ border-radius: 5px;
+ background-color: #6e49cb;
+ border-top: 15px solid #6e49cb;
+ border-bottom: 15px solid #6e49cb;
+ border-right: 40px solid #6e49cb;
+ border-left: 40px solid #6e49cb;
+ }
+
+ .cta_link_secondary a {
+ color: #6e49cb;
+ padding: 25px 40px 15px;
+ }
+
+ .footernav {
+ display: inline !important;
+ }
+
+ .footernav a {
+ color: #6e49cb;
+ }
+
+ .address {
+ margin: 0;
+ font-size: 16px;
+ line-height: 26px;
+ }
+
+ :css
+ /* iOS BLUE LINKS */
+ a[x-apple-data-detectors] {
+ color: inherit !important;
+ text-decoration: none !important;
+ font-size: inherit !important;
+ font-family: inherit !important;
+ font-weight: inherit !important;
+ line-height: inherit !important;
+ }
+ /[if gte mso 9]
+ <xml>
+ <o:OfficeDocumentSettings>
+ <o:AllowPNG/>
+ <o:PixelsPerInch>96</o:PixelsPerInch>
+ </o:OfficeDocumentSettings>
+ </xml>
+ /[if (mso)|(mso 16)]
+ <style type="text/css">
+ body, table, td, a, span { font-family: Arial, Helvetica, sans-serif !important; }
+ </style>
+ :css
+ @media only screen and (max-width: 595px) {
+
+ .wrapper {
+ width: 100% !important;
+ margin: 0 auto !important;
+ padding: 0 !important;
+ }
+
+ p,
+ li {
+ font-size: 18px !important;
+ line-height: 26px !important;
+ }
+
+ .stack {
+ width: 100% !important;
+ }
+
+ .stack-mobile-padding {
+ width: 100% !important;
+ margin-top: 20px !important;
+ }
+
+ .callout {
+ padding-bottom: 20px !important;
+ }
+
+ .redbutton {
+ text-align: center;
+ }
+
+ .stack33 {
+ display: block !important;
+ width: 100% !important;
+ max-width: 100% !important;
+ direction: ltr !important;
+ text-align: center !important;
+ }
+ }
+
+ @media only screen and (max-width: 480px) {
+ u~div {
+ width: 100vw !important;
+ }
+
+ div>u~div {
+ width: 100% !important;
+ }
+ }
+ %body#body{ width: "100%" }
+ %table{ border: "0", cellpadding: "0", cellspacing: "0", role: "presentation", width: "100%" }
+ %tr
+ %td{ align: "center", style: "padding: 0px;" }
+ %table.wrapper{ border: "0", cellpadding: "0", cellspacing: "0", role: "presentation", width: "600" }
+ %tr
+ %td{ style: "padding: 0px;" }
+ #main-story.mktEditable{ mktoname: "main-story" }
+ %table{ border: "0", cellpadding: "0", cellspacing: "0", role: "presentation", width: "100%" }
+ %tr
+ %td{ align: "left", style: "padding: 0 20px;" }
+ = about_link('mailers/in_product_marketing/gitlab-logo-gray-rgb.png', 200)
+ %tr
+ %td{ "aria-hidden" => "true", height: "30", style: "font-size: 0; line-height: 0;" }
+
+ = yield
+
+ %tr{ style: "background-color: #ffffff;" }
+ %td{ align: "center", style: "padding:75px 20px 25px;" }
+ = about_link('gitlab_logo.png', 80)
+ %tr{ style: "background-color: #ffffff;" }
+ %td{ align: "center", style: "padding:0px ;" }
+ %tr{ style: "background-color: #ffffff;" }
+ %td{ align: "center", style: "padding:0px 10px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; " }
+ %span.footernav{ style: "color: #6e49cb; font-size: 16px; line-height: 26px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" }
+ = @message.footer_links.join('&nbsp;' * 3 + '|' + '&nbsp;' * 4).html_safe
+ %tr{ style: "background-color:#ffffff;" }
+ %td{ align: "center", style: "padding: 40px 30px 20px 30px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" }
+ .address= @message.address
+ %tr{ style: "background-color: #ffffff;" }
+ %td{ align: "left", style: "padding:20px 30px 20px 30px;" }
+ %span.footernav{ style: "color: #6e49cb; font-size: 14px; line-height: 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#424242;" }
+ = @message.unsubscribe.html_safe
diff --git a/app/views/notify/_note_email.html.haml b/app/views/notify/_note_email.html.haml
index 2cef6f97d48..ae9c8554e73 100644
--- a/app/views/notify/_note_email.html.haml
+++ b/app/views/notify/_note_email.html.haml
@@ -34,4 +34,4 @@
email: true }
%div{ style: note_style }
- = markdown(note.note, pipeline: :email, author: note.author)
+ = markdown(note.note, pipeline: :email, author: note.author, current_user: @recipient, issuable_reference_expansion_enabled: true)
diff --git a/app/views/notify/account_validation_email.html.haml b/app/views/notify/account_validation_email.html.haml
new file mode 100644
index 00000000000..02256443430
--- /dev/null
+++ b/app/views/notify/account_validation_email.html.haml
@@ -0,0 +1,16 @@
+%tr
+ %td{ bgcolor: "#ffffff", height: "auto", style: "max-width: 600px; width: 100%; text-align: center; height: 200px; padding: 25px 15px; mso-line-height-rule: exactly; min-height: 40px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;", valign: "middle", width: "100%" }
+ = inline_image_link(@message.logo_path, { width: '150', style: 'width: 150px;' })
+ %h1{ style: "font-size: 40px; line-height: 46x; color: #000000; padding: 20px 0 0 0; font-weight: normal;" }
+ = @message.title
+%tr
+ %td{ style: "padding: 10px 20px 30px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 18px; line-height: 24px;" }
+ %p{ style: "margin: 0 0 20px 0;" }
+ = @message.body_line1.html_safe
+ - @message.body_line2&.tap do |line|
+ %p{ style: "margin: 0 0 20px 0;" }
+ = line.html_safe
+%tr
+ %td{ align: "center", style: "padding: 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" }
+ .cta_link.cta_link_primary= @message.cta_link
+ .cta_link.cta_link_secondary= @message.cta2_link
diff --git a/app/views/notify/account_validation_email.text.erb b/app/views/notify/account_validation_email.text.erb
new file mode 100644
index 00000000000..c167eff9803
--- /dev/null
+++ b/app/views/notify/account_validation_email.text.erb
@@ -0,0 +1,15 @@
+<%= @message.title %>
+
+<%= @message.body_line1 %>
+
+<%= @message.body_line2 %>
+
+<%= @message.cta_link %>
+
+<%= @message.cta2_link %>
+
+<%= @message.footer_links %>
+
+<%= @message.address %>
+
+<%= @message.unsubscribe %>
diff --git a/app/views/notify/attention_requested_merge_request_email.html.haml b/app/views/notify/attention_requested_merge_request_email.html.haml
new file mode 100644
index 00000000000..af42f180ae7
--- /dev/null
+++ b/app/views/notify/attention_requested_merge_request_email.html.haml
@@ -0,0 +1,2 @@
+%p
+ #{sanitize_name(@updated_by.name)} requested your attention on #{merge_request_reference_link(@merge_request)}.
diff --git a/app/views/notify/attention_requested_merge_request_email.text.erb b/app/views/notify/attention_requested_merge_request_email.text.erb
new file mode 100644
index 00000000000..97b1d4a824b
--- /dev/null
+++ b/app/views/notify/attention_requested_merge_request_email.text.erb
@@ -0,0 +1 @@
+<%= sanitize_name(@updated_by.name) %> requested your attention on <%= merge_request_reference_link(@merge_request) %>.
diff --git a/app/views/notify/in_product_marketing_email.html.haml b/app/views/notify/in_product_marketing_email.html.haml
index a85fa7c519f..a88d581c5de 100644
--- a/app/views/notify/in_product_marketing_email.html.haml
+++ b/app/views/notify/in_product_marketing_email.html.haml
@@ -1,235 +1,51 @@
-!!!
-%html{ lang: "en" }
- %head
- %meta{ content: "text/html; charset=utf-8", "http-equiv" => "Content-Type" }
- %meta{ content: "width=device-width, initial-scale=1", name: "viewport" }
- %link{ href: "https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,600", rel: "stylesheet", type: "text/css", data: { premailer: 'ignore' } }
- %title= message.subject
- :css
- /* CLIENT-SPECIFIC STYLES */
- body,
- table,
- td,
- a {
- -webkit-text-size-adjust: 100%;
- -ms-text-size-adjust: 100%;
- }
-
- table,
- td {
- mso-table-lspace: 0pt;
- mso-table-rspace: 0pt;
- }
-
- img {
- -ms-interpolation-mode: bicubic;
- }
-
- /* RESET STYLES */
- img {
- border: 0;
- height: auto;
- line-height: 100%;
- outline: none;
- text-decoration: none;
- }
-
- table {
- border-collapse: collapse !important;
- }
-
- body {
- height: 100% !important;
- margin: 0 !important;
- padding: 0 !important;
- width: 100% !important;
- background-color: #ffffff;
- color: #424242;
- }
-
- a {
- color: #6b4fbb;
- text-decoration: underline;
- }
-
- .cta_link a {
- font-size: 24px;
- font-family: 'Source Sans Pro', helvetica, arial, sans-serif;
- color: #ffffff;
- text-decoration: none;
- border-radius: 5px;
- -webkit-border-radius: 5px;
- background-color: #6e49cb;
- border-top: 15px solid #6e49cb;
- border-bottom: 15px solid #6e49cb;
- border-right: 40px solid #6e49cb;
- border-left: 40px solid #6e49cb;
- display: inline-block;
- }
-
- .footernav {
- display: inline !important;
- }
-
- .footernav a {
- color: #6e49cb;
- }
-
- .address {
- margin: 0;
- font-size: 16px;
- line-height: 26px;
- }
-
- :css
- /* iOS BLUE LINKS */
- a[x-apple-data-detectors] {
- color: inherit !important;
- text-decoration: none !important;
- font-size: inherit !important;
- font-family: inherit !important;
- font-weight: inherit !important;
- line-height: inherit !important;
- }
- /[if gte mso 9]
- <xml>
- <o:OfficeDocumentSettings>
- <o:AllowPNG/>
- <o:PixelsPerInch>96</o:PixelsPerInch>
- </o:OfficeDocumentSettings>
- </xml>
- /[if (mso)|(mso 16)]
- <style type="text/css">
- body, table, td, a, span { font-family: Arial, Helvetica, sans-serif !important; }
- </style>
- :css
- @media only screen and (max-width: 595px) {
-
- .wrapper {
- width: 100% !important;
- margin: 0 auto !important;
- padding: 0 !important;
- }
-
- p,
- li {
- font-size: 18px !important;
- line-height: 26px !important;
- }
-
- .stack {
- width: 100% !important;
- }
-
- .stack-mobile-padding {
- width: 100% !important;
- margin-top: 20px !important;
- }
-
- .callout {
- padding-bottom: 20px !important;
- }
-
- .redbutton {
- text-align: center;
- }
-
- .stack33 {
- display: block !important;
- width: 100% !important;
- max-width: 100% !important;
- direction: ltr !important;
- text-align: center !important;
- }
- }
-
- @media only screen and (max-width: 480px) {
- u~div {
- width: 100vw !important;
- }
-
- div>u~div {
- width: 100% !important;
- }
- }
- %body#body{ width: "100%" }
- %table{ border: "0", cellpadding: "0", cellspacing: "0", role: "presentation", width: "100%" }
- %tr
- %td{ align: "center", style: "padding: 0px;" }
- %table.wrapper{ border: "0", cellpadding: "0", cellspacing: "0", role: "presentation", width: "600" }
- %tr
- %td{ style: "padding: 0px;" }
- #main-story.mktEditable{ mktoname: "main-story" }
- %table{ border: "0", cellpadding: "0", cellspacing: "0", role: "presentation", width: "100%" }
- %tr
- %td{ align: "left", style: "padding: 0 20px;" }
- = about_link('mailers/in_product_marketing/gitlab-logo-gray-rgb.png', 200)
- %tr
- %td{ "aria-hidden" => "true", height: "30", style: "font-size: 0; line-height: 0;" }
- - if @message.series?
- %tr{ style: "background-color: #ffffff;" }
- %td{ style: "color: #424242; padding: 10px 30px; text-align: center; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;font-size: 16px; line-height: 22px; border: 1px solid #dddddd" }
- %p
- = @message.progress.html_safe
- %tr
- %td{ bgcolor: "#ffffff", height: "auto", style: "max-width: 600px; width: 100%; text-align: center; height: 200px; padding: 25px 15px; mso-line-height-rule: exactly; min-height: 40px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;", valign: "middle", width: "100%" }
- = inline_image_link(@message.logo_path, { width: '150', style: 'width: 150px;' })
- %h1{ style: "font-size: 40px; line-height: 46x; color: #000000; padding: 20px 0 0 0; font-weight: normal;" }
- = @message.title
- %h2{ style: "font-size: 28px; line-height: 34px; color: #000000; padding: 0; font-weight: 400;" }
- = @message.subtitle
- %tr
- %td{ style: "padding: 10px 20px 30px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 18px; line-height: 24px;" }
- %p{ style: "margin: 0 0 20px 0;" }
- = @message.body_line1.html_safe
- - @message.body_line2&.tap do |line|
- %p{ style: "margin: 0 0 20px 0;" }
- = line.html_safe
- - if @message.cta_text
- %tr
- %td{ align: "center", style: "padding: 10px 20px 80px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" }
- .cta_link= @message.cta_link
- - else
- %tr
- %td{ style: "padding: 10px 20px 10px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 16px; line-height: 20px;" }
- %table{ border: "0", cellpadding: "0", cellspacing: "0", width: "100%", style: "width: 100%; min-width: 100%;" }
- %tr
- %td{ width: "50%", style: "width: 50%; min-width: 50%; color: #000000; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; font-size: 16px; line-height: 100%; padding-bottom: 16px; text-align: left;", align: "left" }
- = @message.feedback_ratings(1)
- %td{ width: "50%", style: "width: 50%; min-width: 50%; color: #000000; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; font-size: 16px; line-height: 100%; padding-bottom: 16px; text-align: right;", align: "right" }
- = @message.feedback_ratings(5)
- %tr
- %td{ align: "center", style: "padding: 10px 1px 30px 1px;" }
- %table{ align: "center", cellpadding: "5", cellspacing: "0", width: "100%", style: "width: 100%; min-width: 100%; border: 1px solid #dae0ea; border-radius: 0; min-width: 100%; text-align: center; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; font-size: 16px;" }
- %tr
- - (1..5).each do |rating|
- %td{ height: "54", style: "border-left: 1px solid #dae0ea; padding-bottom: 0; width: 9% !important;", width: "9%" }
- %a{ href: @message.feedback_link(rating), style: "color: #424242; display: block; text-decoration: none;" }
- %span{ height: "54", style: "display: block; font-size: 18px; height: 22px; line-height: 22px; padding: 16px 0; width: 100%; text-decoration: none;" }
- = rating
- %tr
- %td{ style: "padding: 10px 20px 30px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 18px; line-height: 24px;" }
- %p{ style: "margin: 0 0 50px 0;" }
- = @message.feedback_thanks
- - if @message.invite_members?
- %tr
- %td{ align: "center", style: "padding: 0 20px 80px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" }
- = @message.invite_text
- %br
- = @message.invite_link
- %tr{ style: "background-color: #ffffff;" }
- %td{ align: "center", style: "padding:75px 20px 25px;" }
- = about_link('gitlab_logo.png', 80)
- %tr{ style: "background-color: #ffffff;" }
- %td{ align: "center", style: "padding:0px ;" }
- %tr{ style: "background-color: #ffffff;" }
- %td{ align: "center", style: "padding:0px 10px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; " }
- %span.footernav{ style: "color: #6e49cb; font-size: 16px; line-height: 26px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" }
- = @message.footer_links.join('&nbsp;' * 3 + '|' + '&nbsp;' * 4).html_safe
- %tr{ style: "background-color:#ffffff;" }
- %td{ align: "center", style: "padding: 40px 30px 20px 30px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" }
- .address= @message.address
- %tr{ style: "background-color: #ffffff;" }
- %td{ align: "left", style: "padding:20px 30px 20px 30px;" }
- %span.footernav{ style: "color: #6e49cb; font-size: 14px; line-height: 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#424242;" }
- = @message.unsubscribe.html_safe
+- if @message.series?
+ %tr{ style: "background-color: #ffffff;" }
+ %td{ style: "color: #424242; padding: 10px 30px; text-align: center; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;font-size: 16px; line-height: 22px; border: 1px solid #dddddd" }
+ %p
+ = @message.progress.html_safe
+%tr
+ %td{ bgcolor: "#ffffff", height: "auto", style: "max-width: 600px; width: 100%; text-align: center; height: 200px; padding: 25px 15px; mso-line-height-rule: exactly; min-height: 40px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;", valign: "middle", width: "100%" }
+ = inline_image_link(@message.logo_path, { width: '150', style: 'width: 150px;' })
+ %h1{ style: "font-size: 40px; line-height: 46x; color: #000000; padding: 20px 0 0 0; font-weight: normal;" }
+ = @message.title
+ %h2{ style: "font-size: 28px; line-height: 34px; color: #000000; padding: 0; font-weight: 400;" }
+ = @message.subtitle
+%tr
+ %td{ style: "padding: 10px 20px 30px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 18px; line-height: 24px;" }
+ %p{ style: "margin: 0 0 20px 0;" }
+ = @message.body_line1.html_safe
+ - @message.body_line2&.tap do |line|
+ %p{ style: "margin: 0 0 20px 0;" }
+ = line.html_safe
+- if @message.cta_text
+ %tr
+ %td{ align: "center", style: "padding: 10px 20px 80px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" }
+ .cta_link.cta_link_primary= @message.cta_link
+- else
+ %tr
+ %td{ style: "padding: 10px 20px 10px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 16px; line-height: 20px;" }
+ %table{ border: "0", cellpadding: "0", cellspacing: "0", width: "100%", style: "width: 100%; min-width: 100%;" }
+ %tr
+ %td{ width: "50%", style: "width: 50%; min-width: 50%; color: #000000; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; font-size: 16px; line-height: 100%; padding-bottom: 16px; text-align: left;", align: "left" }
+ = @message.feedback_ratings(1)
+ %td{ width: "50%", style: "width: 50%; min-width: 50%; color: #000000; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; font-size: 16px; line-height: 100%; padding-bottom: 16px; text-align: right;", align: "right" }
+ = @message.feedback_ratings(5)
+ %tr
+ %td{ align: "center", style: "padding: 10px 1px 30px 1px;" }
+ %table{ align: "center", cellpadding: "5", cellspacing: "0", width: "100%", style: "width: 100%; min-width: 100%; border: 1px solid #dae0ea; border-radius: 0; min-width: 100%; text-align: center; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; font-size: 16px;" }
+ %tr
+ - (1..5).each do |rating|
+ %td{ height: "54", style: "border-left: 1px solid #dae0ea; padding-bottom: 0; width: 9% !important;", width: "9%" }
+ %a{ href: @message.feedback_link(rating), style: "color: #424242; display: block; text-decoration: none;" }
+ %span{ height: "54", style: "display: block; font-size: 18px; height: 22px; line-height: 22px; padding: 16px 0; width: 100%; text-decoration: none;" }
+ = rating
+ %tr
+ %td{ style: "padding: 10px 20px 30px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 18px; line-height: 24px;" }
+ %p{ style: "margin: 0 0 50px 0;" }
+ = @message.feedback_thanks
+- if @message.invite_members?
+ %tr
+ %td{ align: "center", style: "padding: 0 20px 80px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" }
+ = @message.invite_text
+ %br
+ = @message.invite_link
diff --git a/app/views/notify/issue_due_email.html.haml b/app/views/notify/issue_due_email.html.haml
index adb9da05694..c9cd9c32b54 100644
--- a/app/views/notify/issue_due_email.html.haml
+++ b/app/views/notify/issue_due_email.html.haml
@@ -9,4 +9,4 @@
- if @issue.description
%div
- = markdown(@issue.description, pipeline: :email, author: @issue.author)
+ = markdown(@issue.description, pipeline: :email, author: @issue.author, current_user: @recipient, issuable_reference_expansion_enabled: true)
diff --git a/app/views/notify/member_invited_email.html.haml b/app/views/notify/member_invited_email.html.haml
index 1d1f696e1b2..6d5207510da 100644
--- a/app/views/notify/member_invited_email.html.haml
+++ b/app/views/notify/member_invited_email.html.haml
@@ -6,17 +6,15 @@
role: member.human_access.downcase }
- join_text = s_('InviteEmail|Join now')
- inviter_name = member.created_by.name if member.created_by
+- join_url = invite_url(@token, invite_type: Emails::Members::INITIAL_INVITE)
-- experiment(:invite_email_preview_text, actor: member) do |experiment_instance|
- - experiment_instance.use {}
- - experiment_instance.candidate do
- = content_for :preview_text do
- %div{ style: "display:none;font-size:1px;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;" }
- - if member.created_by
- = s_('InviteEmail|Join your team on GitLab! %{inviter} invited you to %{project_or_group_name}') % { inviter: inviter_name, project_or_group_name: placeholders[:project_or_group_name] }
- - else
- = s_('InviteEmail|Join your team on GitLab! You are invited to %{project_or_group_name}') % { project_or_group_name: placeholders[:project_or_group_name] }
- = gmail_goto_action(join_text, invited_join_url(@token, member))
+= content_for :preview_text do
+ %div{ style: "display:none;font-size:1px;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;" }
+ - if member.created_by
+ = s_('InviteEmail|Join your team on GitLab! %{inviter} invited you to %{project_or_group_name}') % { inviter: inviter_name, project_or_group_name: placeholders[:project_or_group_name] }
+ - else
+ = s_('InviteEmail|Join your team on GitLab! You are invited to %{project_or_group_name}') % { project_or_group_name: placeholders[:project_or_group_name] }
+ = gmail_goto_action(join_text, join_url)
%tr
%td.text-content{ colspan: 2 }
@@ -32,7 +30,7 @@
- else
= html_escape(s_("InviteEmail|You are invited to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders
%p.invite-actions
- = link_to join_text, invited_join_url(@token, member), class: 'invite-btn-join'
+ = link_to join_text, join_url, class: 'invite-btn-join'
%tr.border-top
%td.text-content.mailer-align-left.half-width
%h4
diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml
index 3219ee34736..439604a950a 100644
--- a/app/views/notify/new_issue_email.html.haml
+++ b/app/views/notify/new_issue_email.html.haml
@@ -8,4 +8,4 @@
- if @issue.description
%div
- = markdown(@issue.description, pipeline: :email, author: @issue.author)
+ = markdown(@issue.description, pipeline: :email, author: @issue.author, current_user: @recipient, issuable_reference_expansion_enabled: true)
diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml
index c8a0a6591a6..54fb6573c26 100644
--- a/app/views/notify/new_merge_request_email.html.haml
+++ b/app/views/notify/new_merge_request_email.html.haml
@@ -16,4 +16,4 @@
- if @merge_request.description
%div
- = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author)
+ = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author, current_user: @recipient, issuable_reference_expansion_enabled: true)
diff --git a/app/views/notify/new_release_email.html.haml b/app/views/notify/new_release_email.html.haml
index 9cef4cd85cd..1cd3a2340c6 100644
--- a/app/views/notify/new_release_email.html.haml
+++ b/app/views/notify/new_release_email.html.haml
@@ -15,4 +15,4 @@
%p
%h4= _("Release notes:")
- = markdown(@release.description, pipeline: :email, author: @release.author)
+ = markdown(@release.description, pipeline: :email, author: @release.author, current_user: @recipient)
diff --git a/app/views/notify/service_desk_new_note_email.html.haml b/app/views/notify/service_desk_new_note_email.html.haml
index 824b4ab712e..186bdf133e3 100644
--- a/app/views/notify/service_desk_new_note_email.html.haml
+++ b/app/views/notify/service_desk_new_note_email.html.haml
@@ -2,4 +2,4 @@
%div
= _("%{author_link} wrote:").html_safe % { author_link: link_to(@note.author_name, user_url(@note.author)) }
%div
- = markdown(@note.note, pipeline: :email, author: @note.author)
+ = markdown(@note.note, pipeline: :email, author: @note.author, issuable_reference_expansion_enabled: true)
diff --git a/app/views/profiles/accounts/_providers.html.haml b/app/views/profiles/accounts/_providers.html.haml
index 5c0044ed825..73a437a0702 100644
--- a/app/views/profiles/accounts/_providers.html.haml
+++ b/app/views/profiles/accounts/_providers.html.haml
@@ -6,11 +6,13 @@
- providers.each do |provider|
- unlink_allowed = unlink_provider_allowed?(provider)
- link_allowed = link_provider_allowed?(provider)
+ - has_icon = provider_has_icon?(provider)
- if unlink_allowed || link_allowed
- if auth_active?(provider)
- if unlink_allowed
= link_to unlink_profile_account_path(provider: provider), method: :delete, class: button_class do
- .social-provider-btn-image.gl-button-icon= provider_image_tag(provider)
+ - if has_icon
+ .social-provider-btn-image.gl-button-icon= provider_image_tag(provider)
.gl-button-text
= s_('Profiles|Disconnect %{provider}') % { provider: label_for_provider(provider) }
- else
@@ -19,7 +21,8 @@
= s_('Profiles|%{provider} Active') % { provider: label_for_provider(provider) }
- elsif link_allowed
= link_to omniauth_authorize_path(:user, provider), method: :post, class: button_class do
- .social-provider-btn-image.gl-button-icon= provider_image_tag(provider)
+ - if has_icon
+ .social-provider-btn-image.gl-button-icon= provider_image_tag(provider)
.gl-button-text
= s_('Profiles|Connect %{provider}') % { provider: label_for_provider(provider) }
= render_if_exists 'profiles/accounts/group_saml_unlink_buttons', group_saml_identities: group_saml_identities
diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml
index 74b48115d0e..2b3109225a8 100644
--- a/app/views/profiles/keys/_form.html.haml
+++ b/app/views/profiles/keys/_form.html.haml
@@ -1,3 +1,4 @@
+- max_date = ::Gitlab::CurrentSettings.max_ssh_key_lifetime_from_now.to_date if ssh_key_expiration_policy_enabled?
%div
= form_for [:profile, @key], html: { class: 'js-requires-input' } do |f|
= form_errors(@key)
@@ -13,8 +14,8 @@
%p.form-text.text-muted= s_('Profiles|Give your individual key a title. This will be publicly visible.')
.col.form-group
- = f.label :expires_at, s_('Profiles|Expires at'), class: 'label-bold'
- = f.date_field :expires_at, class: "form-control input-lg", min: Date.tomorrow, data: { qa_selector: 'key_expiry_date_field' }
+ = f.label :expires_at, s_('Profiles|Expiration date'), class: 'label-bold'
+ = f.date_field :expires_at, class: "form-control input-lg", min: Date.tomorrow, max: max_date, data: { qa_selector: 'key_expiry_date_field' }
%p.form-text.text-muted{ data: { qa_selector: 'key_expiry_date_field_description' } }= ssh_key_expires_field_description
.js-add-ssh-key-validation-warning.hide
diff --git a/app/views/profiles/notifications/_group_settings.html.haml b/app/views/profiles/notifications/_group_settings.html.haml
index 82083af9ff1..23fce8e04b6 100644
--- a/app/views/profiles/notifications/_group_settings.html.haml
+++ b/app/views/profiles/notifications/_group_settings.html.haml
@@ -13,5 +13,5 @@
.js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(setting).to_json, notification_level: setting.level, group_id: group.id, container_class: 'gl-mr-3', show_label: "true" } }
.table-section.section-30
- = form_for setting, url: profile_notifications_group_path(group), method: :put, html: { class: 'update-notifications gl-display-flex' } do |f|
+ = form_for setting, url: profile_group_notifications_path(group), method: :put, html: { class: 'update-notifications gl-display-flex' } do |f|
= f.select :notification_email, @user.public_verified_emails, { include_blank: 'Global notification email' }, class: 'select2 js-group-notification-email'
diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml
index 7c1f28345fd..a8275576327 100644
--- a/app/views/profiles/personal_access_tokens/index.html.haml
+++ b/app/views/profiles/personal_access_tokens/index.html.haml
@@ -32,62 +32,64 @@
type_plural: type_plural,
active_tokens: @active_personal_access_tokens,
revoke_route_helper: ->(token) { revoke_profile_personal_access_token_path(token) }
+- if Feature.enabled?(:hide_access_tokens, default_enabled: :yaml)
+ #js-tokens-app{ data: { tokens_data: tokens_app_data } }
+- else
+ - unless Gitlab::CurrentSettings.disable_feed_token
+ .col-lg-12
+ %hr
+ .row.gl-mt-3.js-search-settings-section
+ .col-lg-4.profile-settings-sidebar
+ %h4.gl-mt-0
+ = s_('AccessTokens|Feed token')
+ %p
+ = s_('AccessTokens|Your feed token authenticates you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar. It is visible in those feed URLs.')
+ %p
+ = s_('AccessTokens|It cannot be used to access any other data.')
+ .col-lg-8.feed-token-reset
+ = label_tag :feed_token, s_('AccessTokens|Feed token'), class: 'label-bold'
+ = text_field_tag :feed_token, current_user.feed_token, class: 'form-control gl-form-input js-select-on-focus', readonly: true
+ %p.form-text.text-muted
+ - reset_link = link_to s_('AccessTokens|reset this token'), [:reset, :feed_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working.'), testid: :reset_feed_token_link }
+ - reset_message = s_('AccessTokens|Keep this token secret. Anyone who has it can read activity and issue RSS feeds or your calendar feed as if they were you. If that happens, %{link_reset_it}.') % { link_reset_it: reset_link }
+ = reset_message.html_safe
-- unless Gitlab::CurrentSettings.disable_feed_token
- .col-lg-12
- %hr
- .row.gl-mt-3.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0
- = s_('AccessTokens|Feed token')
- %p
- = s_('AccessTokens|Your feed token authenticates you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar. It is visible in those feed URLs.')
- %p
- = s_('AccessTokens|It cannot be used to access any other data.')
- .col-lg-8.feed-token-reset
- = label_tag :feed_token, s_('AccessTokens|Feed token'), class: 'label-bold'
- = text_field_tag :feed_token, current_user.feed_token, class: 'form-control gl-form-input js-select-on-focus', readonly: true
- %p.form-text.text-muted
- - reset_link = link_to s_('AccessTokens|reset this token'), [:reset, :feed_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working.'), testid: :reset_feed_token_link }
- - reset_message = s_('AccessTokens|Keep this token secret. Anyone who has it can read activity and issue RSS feeds or your calendar feed as if they were you. If that happens, %{link_reset_it}.') % { link_reset_it: reset_link }
- = reset_message.html_safe
+ - if incoming_email_token_enabled?
+ .col-lg-12
+ %hr
+ .row.gl-mt-3.js-search-settings-section
+ .col-lg-4.profile-settings-sidebar
+ %h4.gl-mt-0
+ = s_('AccessTokens|Incoming email token')
+ %p
+ = s_('AccessTokens|Your incoming email token authenticates you when you create a new issue by email, and is included in your personal project-specific email addresses.')
+ %p
+ = s_('AccessTokens|It cannot be used to access any other data.')
+ .col-lg-8.incoming-email-token-reset
+ = label_tag :incoming_email_token, s_('AccessTokens|Incoming email token'), class: 'label-bold'
+ = text_field_tag :incoming_email_token, current_user.incoming_email_token, class: 'form-control gl-form-input js-select-on-focus', readonly: true
+ %p.form-text.text-muted
+ - reset_link = link_to s_('AccessTokens|reset this token'), [:reset, :incoming_email_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any issue email addresses currently in use will stop working.'), testid: :reset_email_token_link }
+ - reset_message = s_('AccessTokens|Keep this token secret. Anyone who has it can create issues as if they were you. If that happens, %{link_reset_it}.') % { link_reset_it: reset_link }
+ = reset_message.html_safe
-- if incoming_email_token_enabled?
- .col-lg-12
- %hr
- .row.gl-mt-3.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0
- = s_('AccessTokens|Incoming email token')
- %p
- = s_('AccessTokens|Your incoming email token authenticates you when you create a new issue by email, and is included in your personal project-specific email addresses.')
- %p
- = s_('AccessTokens|It cannot be used to access any other data.')
- .col-lg-8.incoming-email-token-reset
- = label_tag :incoming_email_token, s_('AccessTokens|Incoming email token'), class: 'label-bold'
- = text_field_tag :incoming_email_token, current_user.incoming_email_token, class: 'form-control gl-form-input js-select-on-focus', readonly: true
- %p.form-text.text-muted
- - reset_link = link_to s_('AccessTokens|reset this token'), [:reset, :incoming_email_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any issue email addresses currently in use will stop working.'), testid: :reset_email_token_link }
- - reset_message = s_('AccessTokens|Keep this token secret. Anyone who has it can create issues as if they were you. If that happens, %{link_reset_it}.') % { link_reset_it: reset_link }
- = reset_message.html_safe
-
-- if static_objects_external_storage_enabled?
- .col-lg-12
- %hr
- .row.gl-mt-3.js-search-settings-section
- .col-lg-4
- %h4.gl-mt-0
- = s_('AccessTokens|Static object token')
- %p
- = s_('AccessTokens|Your static object token authenticates you when repository static objects (such as archives or blobs) are served from an external storage.')
- %p
- = s_('AccessTokens|It cannot be used to access any other data.')
- .col-lg-8
- = label_tag :static_object_token, s_('AccessTokens|Static object token'), class: "label-bold"
- = text_field_tag :static_object_token, current_user.static_object_token, class: 'form-control gl-form-input', readonly: true, onclick: 'this.select()'
- %p.form-text.text-muted
- - reset_link = url_for [:reset, :static_object_token, :profile]
- - reset_link_start = '<a data-confirm="%{confirm}" rel="nofollow" data-method="put" href="%{url}">'.html_safe % { confirm: s_('AccessTokens|Are you sure?'), url: reset_link }
- - reset_link_end = '</a>'.html_safe
- - reset_message = s_('AccessTokens|Keep this token secret. Anyone who has it can access repository static objects as if they were you. If that ever happens, %{reset_link_start}reset this token%{reset_link_end}.') % { reset_link_start: reset_link_start, reset_link_end: reset_link_end }
- = reset_message.html_safe
+ - if static_objects_external_storage_enabled?
+ .col-lg-12
+ %hr
+ .row.gl-mt-3.js-search-settings-section
+ .col-lg-4
+ %h4.gl-mt-0
+ = s_('AccessTokens|Static object token')
+ %p
+ = s_('AccessTokens|Your static object token authenticates you when repository static objects (such as archives or blobs) are served from an external storage.')
+ %p
+ = s_('AccessTokens|It cannot be used to access any other data.')
+ .col-lg-8
+ = label_tag :static_object_token, s_('AccessTokens|Static object token'), class: "label-bold"
+ = text_field_tag :static_object_token, current_user.static_object_token, class: 'form-control gl-form-input', readonly: true, onclick: 'this.select()'
+ %p.form-text.text-muted
+ - reset_link = url_for [:reset, :static_object_token, :profile]
+ - reset_link_start = '<a data-confirm="%{confirm}" rel="nofollow" data-method="put" href="%{url}">'.html_safe % { confirm: s_('AccessTokens|Are you sure?'), url: reset_link }
+ - reset_link_end = '</a>'.html_safe
+ - reset_message = s_('AccessTokens|Keep this token secret. Anyone who has it can access repository static objects as if they were you. If that ever happens, %{reset_link_start}reset this token%{reset_link_end}.') % { reset_link_start: reset_link_start, reset_link_end: reset_link_end }
+ = reset_message.html_safe
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index b1470520eea..f3993ad8c33 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -116,7 +116,7 @@
%h5= _('Private profile')
.checkbox-icon-inline-wrapper
- private_profile_label = capture do
- = s_("Profiles|Don't display activity-related personal information on your profiles")
+ = s_("Profiles|Don't display activity-related personal information on your profile")
= f.check_box :private_profile, label: private_profile_label, inline: true, wrapper_class: 'mr-0'
= link_to sprite_icon('question-o'), help_page_path('user/profile/index.md', anchor: 'make-your-user-profile-page-private')
%h5= s_("Profiles|Private contributions")
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 0eae3c95bf6..aae6212f964 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -2,7 +2,7 @@
- page_title _('Two-Factor Authentication'), _('Account')
- add_to_breadcrumbs _('Account'), profile_account_path
- @content_class = "limit-container-width" unless fluid_layout
-- webauthn_enabled = Feature.enabled?(:webauthn)
+- webauthn_enabled = Feature.enabled?(:webauthn, default_enabled: :yaml)
.js-two-factor-auth{ 'data-two-factor-skippable' => "#{two_factor_skippable?}", 'data-two_factor_skip_url' => skip_profile_two_factor_auth_path }
.row.gl-mt-3
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index cdcc98552f9..2f4a61865f8 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -10,10 +10,11 @@
.nav-block.gl-display-flex.gl-xs-flex-direction-column.gl-align-items-stretch
= render 'projects/tree/tree_header', tree: @tree
- #js-last-commit
- .info-well.gl-display-none.gl-sm-display-flex.project-last-commit
+ .info-well.gl-display-none.gl-sm-display-flex.project-last-commit.gl-flex-direction-column
+ #js-last-commit.gl-m-auto
.gl-spinner-container.m-auto
= loading_icon(size: 'md', color: 'dark', css_class: 'align-text-bottom')
+ #js-code-owners
- if is_project_overview
.project-buttons.gl-mb-3.js-show-on-project-root
diff --git a/app/views/projects/_invite_members_side_nav_link.html.haml b/app/views/projects/_invite_members_side_nav_link.html.haml
index ea6174d19f0..fae681b1a71 100644
--- a/app/views/projects/_invite_members_side_nav_link.html.haml
+++ b/app/views/projects/_invite_members_side_nav_link.html.haml
@@ -1,5 +1,4 @@
.js-invite-members-trigger{ data: { trigger_source: 'project-side-nav',
- classes: 'gl-text-decoration-none! gl-shadow-none! gl-text-body!',
icon: 'users',
display_text: title,
trigger_element: 'side-nav'} }
diff --git a/app/views/projects/_merge_request_merge_commit_template.html.haml b/app/views/projects/_merge_request_merge_commit_template.html.haml
index 185b730e0bb..869d2d5d9ec 100644
--- a/app/views/projects/_merge_request_merge_commit_template.html.haml
+++ b/app/views/projects/_merge_request_merge_commit_template.html.haml
@@ -3,7 +3,7 @@
.form-group
%b= s_('ProjectSettings|Merge commit message template')
%p.text-secondary
- - configure_the_merge_commit_message_help_link_url = help_page_path('user/project/merge_requests/commit_templates.md', anchor: 'merge-commit-message-template')
+ - configure_the_merge_commit_message_help_link_url = help_page_path('user/project/merge_requests/commit_templates.md')
- configure_the_merge_commit_message_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: configure_the_merge_commit_message_help_link_url }
= s_('ProjectSettings|The commit message used when merging, if the merge method creates a merge commit. %{link_start}Learn more about syntax and variables.%{link_end}').html_safe % { link_start: configure_the_merge_commit_message_help_link_start, link_end: '</a>'.html_safe }
.mb-2
@@ -12,6 +12,6 @@
%p.form-text.text-muted
= s_('ProjectSettings|Maximum 500 characters.')
= s_('ProjectSettings|Supported variables:')
- - Gitlab::MergeRequests::MergeCommitMessage::PLACEHOLDERS.keys.each do |placeholder|
+ - Gitlab::MergeRequests::CommitMessageGenerator::PLACEHOLDERS.keys.each do |placeholder|
%code
= "%{#{placeholder}}".html_safe
diff --git a/app/views/projects/_merge_request_settings.html.haml b/app/views/projects/_merge_request_settings.html.haml
index c5a25bec6eb..728ff597860 100644
--- a/app/views/projects/_merge_request_settings.html.haml
+++ b/app/views/projects/_merge_request_settings.html.haml
@@ -12,5 +12,7 @@
= render 'projects/merge_request_merge_commit_template', project: @project, form: form
+= render 'projects/merge_request_squash_commit_template', project: @project, form: form
+
- if @project.forked?
= render 'projects/merge_request_target_project_settings', project: @project, form: form
diff --git a/app/views/projects/_merge_request_squash_commit_template.html.haml b/app/views/projects/_merge_request_squash_commit_template.html.haml
new file mode 100644
index 00000000000..81e4bbed166
--- /dev/null
+++ b/app/views/projects/_merge_request_squash_commit_template.html.haml
@@ -0,0 +1,16 @@
+- form = local_assigns.fetch(:form)
+
+.form-group
+ %b= s_('ProjectSettings|Squash commit message template')
+ %p.text-secondary
+ - configure_the_squash_commit_message_help_link_url = help_page_path('user/project/merge_requests/commit_templates.md')
+ - configure_the_squash_commit_message_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: configure_the_squash_commit_message_help_link_url }
+ = s_('ProjectSettings|The commit message used when squashing commits. %{link_start}Learn more about syntax and variables.%{link_end}').html_safe % { link_start: configure_the_squash_commit_message_help_link_start, link_end: '</a>'.html_safe }
+ .mb-2
+ = form.text_area :squash_commit_template, class: 'form-control gl-form-input', rows: 8, maxlength: 500, placeholder: '%{title}'
+ %p.form-text.text-muted
+ = s_('ProjectSettings|Maximum 500 characters.')
+ = s_('ProjectSettings|Supported variables:')
+ - Gitlab::MergeRequests::CommitMessageGenerator::PLACEHOLDERS.keys.each do |placeholder|
+ %code
+ = "%{#{placeholder}}".html_safe
diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml
index c21240b340c..6fc78003df4 100644
--- a/app/views/projects/_new_project_fields.html.haml
+++ b/app/views/projects/_new_project_fields.html.haml
@@ -49,7 +49,7 @@
.gl-alert.gl-alert-success.gl-mb-4.gl-display-none.js-user-readme-repo
= sprite_icon('check-circle', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
.gl-alert-body
- - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/profile/index', anchor: 'user-profile-readme') }
+ - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/profile/index', anchor: 'add-details-to-your-profile-with-a-readme') }
= html_escape(_('%{project_path} is a project that you can use to add a README to your GitLab profile. Create a public project and initialize the repository with a README to get started. %{help_link_start}Learn more.%{help_link_end}')) % { project_path: "<strong>#{current_user.username} / #{current_user.username}</strong>".html_safe, help_link_start: help_link_start, help_link_end: '</a>'.html_safe }
.form-group
@@ -74,7 +74,7 @@
= s_('ProjectsNew|Allows you to immediately clone this project’s repository. Skip this if you plan to push up an existing repository.')
- experiment(:new_project_sast_enabled, user: current_user) do |e|
- - e.try do
+ - e.try(:candidate) do
.form-group
.form-check.gl-mb-3
= check_box_tag 'project[initialize_with_sast]', '1', true, class: 'form-check-input', data: { qa_selector: 'initialize_with_sast_checkbox', track_experiment: e.name, track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' }
@@ -83,6 +83,15 @@
.form-text.text-muted
= s_('ProjectsNew|Analyze your source code for known security vulnerabilities.')
= link_to _('Learn more.'), help_page_path('user/application_security/sast/index'), target: '_blank', rel: 'noopener noreferrer', data: { track_action: 'followed', track_experiment: e.name }
+ - e.try(:unchecked_candidate) do
+ .form-group
+ .form-check.gl-mb-3
+ = check_box_tag 'project[initialize_with_sast]', '1', false, class: 'form-check-input', data: { qa_selector: 'initialize_with_sast_checkbox', track_experiment: e.name, track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' }
+ = label_tag 'project[initialize_with_sast]', class: 'form-check-label' do
+ = s_('ProjectsNew|Enable Static Application Security Testing (SAST)')
+ .form-text.text-muted
+ = s_('ProjectsNew|Analyze your source code for known security vulnerabilities.')
+ = link_to _('Learn more.'), help_page_path('user/application_security/sast/index'), target: '_blank', rel: 'noopener noreferrer', data: { track_action: 'followed', track_experiment: e.name }
- e.try(:free_indicator) do
.form-group
.form-check.gl-mb-3
@@ -93,6 +102,16 @@
.form-text.text-muted
= s_('ProjectsNew|Analyze your source code for known security vulnerabilities.')
= link_to _('Learn more.'), help_page_path('user/application_security/sast/index'), target: '_blank', rel: 'noopener noreferrer', data: { track_action: 'followed', track_experiment: e.name }
+ - e.try(:unchecked_free_indicator) do
+ .form-group
+ .form-check.gl-mb-3
+ = check_box_tag 'project[initialize_with_sast]', '1', false, class: 'form-check-input', data: { qa_selector: 'initialize_with_sast_checkbox', track_experiment: e.name, track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' }
+ = label_tag 'project[initialize_with_sast]', class: 'form-check-label' do
+ = s_('ProjectsNew|Enable Static Application Security Testing (SAST)')
+ %span.badge.badge-info.badge-pill.gl-badge.sm= _('Free')
+ .form-text.text-muted
+ = s_('ProjectsNew|Analyze your source code for known security vulnerabilities.')
+ = link_to _('Learn more.'), help_page_path('user/application_security/sast/index'), target: '_blank', rel: 'noopener noreferrer', data: { track_action: 'followed', track_experiment: e.name }
= f.submit _('Create project'), class: "btn gl-button btn-confirm", data: { track_label: "#{track_label}", track_action: "click_button", track_property: "create_project", track_value: "" }
= link_to _('Cancel'), dashboard_projects_path, class: 'btn gl-button btn-default btn-cancel', data: { track_label: "#{track_label}", track_action: "click_button", track_property: "cancel", track_value: "" }
diff --git a/app/views/projects/_project_templates.html.haml b/app/views/projects/_project_templates.html.haml
index 949397755ba..68489fba06c 100644
--- a/app/views/projects/_project_templates.html.haml
+++ b/app/views/projects/_project_templates.html.haml
@@ -5,7 +5,7 @@
%li.built-in-tab
%a.nav-link.active{ href: "#built-in", data: { toggle: 'tab'} }
= _('Built-in')
- %span.badge.badge-pill= Gitlab::ProjectTemplate.all.count + Gitlab::SampleDataTemplate.all.count
+ = gl_tab_counter_badge Gitlab::ProjectTemplate.all.count + Gitlab::SampleDataTemplate.all.count
.tab-content
.project-templates-buttons.import-buttons.tab-pane.active#built-in
diff --git a/app/views/projects/_remove_fork.html.haml b/app/views/projects/_remove_fork.html.haml
index 92eb29dc407..bb51aa86170 100644
--- a/app/views/projects/_remove_fork.html.haml
+++ b/app/views/projects/_remove_fork.html.haml
@@ -1,11 +1,12 @@
- 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)
- = form_for @project, url: remove_fork_project_path(@project), method: :delete, remote: true, html: { class: 'transfer-project' } do |f|
+ = form_for @project, url: remove_fork_project_path(@project), method: :delete, html: { id: remove_form_id } do |f|
%p
%strong= _('Once removed, the fork relationship cannot be restored. This project will no longer be able to receive or send merge requests to the source project or other forks.')
= link_to _('Learn more.'), help_page_path('user/project/settings/index', anchor: 'removing-a-fork-relationship'), target: '_blank', rel: 'noopener noreferrer'
- = button_to _('Remove fork relationship'), '#', class: "gl-button btn btn-danger js-legacy-confirm-danger", data: { "confirm-danger-message" => remove_fork_project_warning_message(@project) }
+ .js-confirm-danger{ data: remove_fork_project_confirm_json(@project, remove_form_id) }
diff --git a/app/views/projects/_transfer.html.haml b/app/views/projects/_transfer.html.haml
index e48008e1cc6..9f9daa7ec6f 100644
--- a/app/views/projects/_transfer.html.haml
+++ b/app/views/projects/_transfer.html.haml
@@ -1,8 +1,11 @@
- return unless can?(current_user, :change_namespace, @project)
+- form_id = "transfer-project-form"
+- hidden_input_id = "new_namespace_id"
+- initial_data = { namespaces: namespaces_as_json, 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 }
.sub-section
%h4.danger-title= _('Transfer project')
- = form_for @project, url: transfer_project_path(@project), method: :put, remote: true, html: { class: 'js-project-transfer-form' } do |f|
+ = form_for @project, url: transfer_project_path(@project), method: :put, html: { class: 'js-project-transfer-form', id: form_id } do |f|
.form-group
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'transferring-an-existing-project-into-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 }
@@ -11,7 +14,6 @@
%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)
= label_tag :new_namespace_id, _('Select a new namespace'), class: 'gl-font-weight-bold'
- .form-group
- = select_tag :new_namespace_id, namespaces_options(nil), include_blank: true, class: 'select2'
- = f.submit 'Transfer project', class: "gl-button btn btn-danger js-legacy-confirm-danger qa-transfer-button", data: { "confirm-danger-message" => transfer_project_message(@project) }
+ .js-transfer-project-form{ data: initial_data }
diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml
index 704576619a7..ae8f89bf16a 100644
--- a/app/views/projects/blame/show.html.haml
+++ b/app/views/projects/blame/show.html.haml
@@ -27,7 +27,7 @@
- commit_data = @blame.commit_data(blame_group[:commit])
- line_count = blame_group[:lines].count
- %tr
+ %tr{ style: intrinsic_row_css(line_count) }
%td.blame-commit{ class: commit_data.age_map_class }
.commit
= commit_data.author_avatar
diff --git a/app/views/projects/blob/_content.html.haml b/app/views/projects/blob/_content.html.haml
index 7afbd85cd6d..0031be36098 100644
--- a/app/views/projects/blob/_content.html.haml
+++ b/app/views/projects/blob/_content.html.haml
@@ -1,3 +1,4 @@
+- blob = local_assigns.fetch(:blob, nil)
- simple_viewer = blob.simple_viewer
- rich_viewer = blob.rich_viewer
- rich_viewer_active = rich_viewer && params[:viewer] != 'simple'
diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml
index dad4ea205b4..74df53a8d15 100644
--- a/app/views/projects/blob/_header.html.haml
+++ b/app/views/projects/blob/_header.html.haml
@@ -1,4 +1,5 @@
- blame = local_assigns.fetch(:blame, false)
+- blob = local_assigns.fetch(:blob, nil)
.js-file-title.file-title-flex-parent
= render 'projects/blob/header_content', blob: blob
diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml
index 168b240c657..d4e7ee90a84 100644
--- a/app/views/projects/blob/show.html.haml
+++ b/app/views/projects/blob/show.html.haml
@@ -14,8 +14,8 @@
- if can_modify_blob?(@blob)
= render 'projects/blob/remove'
- - title = "Replace #{@blob.name}"
- = render 'projects/blob/upload', title: title, placeholder: title, button_title: 'Replace file', form_path: project_update_blob_path(@project, @id), method: :put
+ - title = _("Replace %{blob_name}") % { blob_name: @blob.name }
+ = render 'projects/blob/upload', title: title, placeholder: title, button_title: _('Replace file'), form_path: project_update_blob_path(@project, @id), method: :put
= render partial: 'pipeline_tour_success' if show_suggest_pipeline_creation_celebration?
= render 'shared/web_ide_path'
diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml
index 6733db69c34..1c543d47ecf 100644
--- a/app/views/projects/branches/index.html.haml
+++ b/app/views/projects/branches/index.html.haml
@@ -42,7 +42,7 @@
%ul.content-list.all-branches
- @branches.each do |branch|
= render "projects/branches/branch", branch: branch, merged: @merged_branch_names.include?(branch.name), commit_status: @branch_pipeline_statuses[branch.name], show_commit_status: @branch_pipeline_statuses.any?
- - if Feature.enabled?(:branches_pagination_without_count, @project, default_enabled: :yaml)
+ - if Feature.enabled?(:branch_list_keyset_pagination, @project, default_enabled: :yaml)
= render('kaminari/gitlab/without_count', previous_path: @prev_path, next_path: @next_path)
- else
= paginate @branches, theme: 'gitlab'
diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml
deleted file mode 100644
index 12ce4667e1a..00000000000
--- a/app/views/projects/buttons/_dropdown.html.haml
+++ /dev/null
@@ -1,42 +0,0 @@
-- can_create_issue = show_new_issue_link?(@project)
-- can_create_project_snippet = can?(current_user, :create_snippet, @project)
-- can_push_code = can?(current_user, :push_code, @project)
-- create_mr_from_new_fork = can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project)
-- merge_project = merge_request_source_project_for_project(@project)
-
-- show_menu = can_create_issue || can_create_project_snippet || can_push_code || create_mr_from_new_fork || merge_project
-
-- if show_menu
- .project-action-button.dropdown.inline<
- %a.btn.btn-default.gl-button.dropdown-toggle.has-tooltip.qa-create-new-dropdown{ href: '#', title: _('Create new...'), 'data-toggle' => 'dropdown', 'data-container' => 'body', 'aria-label' => _('Create new...'), 'data-display' => 'static' }
- = sprite_icon('plus', css_class: 'gl-icon')
- = sprite_icon("chevron-down", css_class: 'gl-icon')
- %ul.dropdown-menu.dropdown-menu-right.project-home-dropdown
- - if can_create_issue || merge_project || can_create_project_snippet
- %li.dropdown-header= _('This project')
-
- - if can_create_issue
- %li= link_to _('New issue'), new_project_issue_path(@project)
-
- - if merge_project
- %li= link_to _('New merge request'), project_new_merge_request_path(merge_project)
-
- - if can_create_project_snippet
- %li= link_to _('New snippet'), new_project_snippet_path(@project)
-
- - if can_push_code
- %li.dropdown-header= _('This repository')
-
- - if can_push_code
- %li.qa-new-file-option= link_to _('New file'), project_new_blob_path(@project, @project.default_branch_or_main)
- - unless @project.empty_repo?
- %li= link_to _('New branch'), new_project_branch_path(@project)
- %li= link_to _('New tag'), new_project_tag_path(@project)
- - elsif can_collaborate_with_project?(@project)
- %li= link_to _('New file'), project_new_blob_path(@project, @project.default_branch_or_main)
- - elsif create_mr_from_new_fork
- - continue_params = { to: project_new_blob_path(@project, @project.default_branch_or_main),
- notice: edit_in_new_fork_notice,
- notice_now: edit_in_new_fork_notice_now }
- - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params)
- %li= link_to _('New file'), fork_path, method: :post
diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml
index e4ec2e44298..3cec7fd9eb8 100644
--- a/app/views/projects/buttons/_fork.html.haml
+++ b/app/views/projects/buttons/_fork.html.haml
@@ -1,16 +1,18 @@
- unless @project.empty_repo?
- - if current_user && can?(current_user, :fork_project, @project)
+ - if current_user
.count-badge.btn-group
- if current_user.already_forked?(@project) && current_user.manageable_namespaces.size < 2
= link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: s_('ProjectOverview|Go to your fork'), class: 'gl-button btn btn-default btn-sm has-tooltip fork-btn' do
= sprite_icon('fork', css_class: 'icon')
%span= s_('ProjectOverview|Fork')
- else
- - can_create_fork = current_user.can?(:create_fork)
- - disabled_fork_tooltip = s_('ProjectOverview|You have reached your project limit')
- %span.btn-group.has-tooltip{ title: (disabled_fork_tooltip unless can_create_fork) }
- = link_to new_project_fork_path(@project), class: "gl-button btn btn-default btn-sm fork-btn #{' disabled' unless can_create_fork }", 'aria-label' => (disabled_fork_tooltip unless can_create_fork) do
+ - disabled_tooltip = fork_button_disabled_tooltip(@project)
+ - count_class = 'disabled' unless can?(current_user, :download_code, @project)
+ - button_class = 'disabled' if disabled_tooltip
+
+ %span.btn-group{ class: ('has-tooltip' if disabled_tooltip), title: disabled_tooltip }
+ = link_to new_project_fork_path(@project), class: "gl-button btn btn-default btn-sm fork-btn #{button_class}" do
= sprite_icon('fork', css_class: 'icon')
%span= s_('ProjectOverview|Fork')
- = link_to project_forks_path(@project), title: n_(s_('ProjectOverview|Forks'), s_('ProjectOverview|Forks'), @project.forks_count), class: 'gl-button btn btn-default btn-sm count has-tooltip' do
+ = link_to project_forks_path(@project), title: n_(s_('ProjectOverview|Forks'), s_('ProjectOverview|Forks'), @project.forks_count), class: "gl-button btn btn-default btn-sm count has-tooltip #{count_class}" do
= @project.forks_count
diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml
index 437529c3608..c33b9b538f3 100644
--- a/app/views/projects/ci/builds/_build.html.haml
+++ b/app/views/projects/ci/builds/_build.html.haml
@@ -50,16 +50,15 @@
.label-container
- if job.tags.any?
- job.tags.each do |tag|
- %span.badge.badge-pill.gl-badge.sm.badge-primary
- = tag
+ = gl_badge_tag tag, variant: :info, size: :sm
- if job.try(:trigger_request)
- %span.badge.badge-pill.gl-badge.sm.badge-info= _('triggered')
+ = gl_badge_tag _('triggered'), variant: :info, size: :sm
- if job.try(:allow_failure) && !job.success?
- %span.badge.badge-pill.gl-badge.sm.badge-warning= _('allowed to fail')
+ = gl_badge_tag _('allowed to fail'), variant: :warning, size: :sm
- if job.schedulable?
- %span.badge.badge-pill.gl-badge.sm.badge-info= s_('DelayedJobs|delayed')
+ = gl_badge_tag s_('DelayedJobs|delayed'), variant: :info, size: :sm
- elsif job.action?
- %span.badge.badge-pill.gl-badge.sm.badge-info= _('manual')
+ = gl_badge_tag _('manual'), variant: :info, size: :sm
- if pipeline_link
%td
diff --git a/app/views/projects/commit/pipelines.html.haml b/app/views/projects/commit/pipelines.html.haml
index cd49dd899a0..a46421120cd 100644
--- a/app/views/projects/commit/pipelines.html.haml
+++ b/app/views/projects/commit/pipelines.html.haml
@@ -1,5 +1,6 @@
- page_title _('Pipelines'), "#{@commit.title} (#{@commit.short_id})", _('Commits')
- add_page_specific_style 'page_bundles/pipelines'
+- add_page_specific_style 'page_bundles/ci_status'
= render 'commit_box'
= render 'ci_menu'
diff --git a/app/views/projects/compare/index.html.haml b/app/views/projects/compare/index.html.haml
index 426d022da26..8ca41941e07 100644
--- a/app/views/projects/compare/index.html.haml
+++ b/app/views/projects/compare/index.html.haml
@@ -8,7 +8,7 @@
%code.ref-name= @project.default_branch_or_main
- example_sha = capture do
%code.ref-name 4eedf23
- = html_escape(_("Choose a branch/tag (e.g. %{branch}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request.")) % { branch: example_branch.html_safe, sha: example_sha.html_safe }
+ = html_escape(_("To see what's changed or create a merge request, choose a branch or tag (like %{branch}), or enter a commit (like %{sha}).")) % { branch: example_branch.html_safe, sha: example_sha.html_safe }
%br
= html_escape(_("Changes are shown as if the %{b_open}source%{b_close} revision was being merged into the %{b_open}target%{b_close} revision.")) % { b_open: '<b>'.html_safe, b_close: '</b>'.html_safe }
diff --git a/app/views/projects/default_branch/_show.html.haml b/app/views/projects/default_branch/_show.html.haml
index 68ca318e88c..e5f911d6f8b 100644
--- a/app/views/projects/default_branch/_show.html.haml
+++ b/app/views/projects/default_branch/_show.html.haml
@@ -26,6 +26,6 @@
%strong= _("Auto-close referenced issues on default branch")
.form-text.text-muted
= _("When merge requests and commits in the default branch close, any issues they reference also close.")
- = link_to sprite_icon('question-o'), help_page_path('user/project/issues/managing_issues.md', anchor: 'disabling-automatic-issue-closing'), target: '_blank'
+ = link_to sprite_icon('question-o'), help_page_path('user/project/issues/managing_issues.md', anchor: 'closing-issues-automatically'), target: '_blank'
= f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/projects/diffs/_content.html.haml b/app/views/projects/diffs/_content.html.haml
index 590fcdb0234..718f129cba8 100644
--- a/app/views/projects/diffs/_content.html.haml
+++ b/app/views/projects/diffs/_content.html.haml
@@ -1,2 +1,3 @@
+- diff_file = local_assigns.fetch(:diff_file, nil)
.diff-content
= render 'projects/diffs/viewer', viewer: diff_file.viewer
diff --git a/app/views/projects/diffs/_text_file.html.haml b/app/views/projects/diffs/_text_file.html.haml
index 5a7830e306a..bf946b0ce73 100644
--- a/app/views/projects/diffs/_text_file.html.haml
+++ b/app/views/projects/diffs/_text_file.html.haml
@@ -1,4 +1,4 @@
-- too_big = diff_file.diff_lines.count > Commit.diff_safe_lines(project: @project)
+- too_big = diff_file.diff_lines.count > Commit.diff_safe_max_lines
- if too_big
.suppressed-container
%a.show-suppressed-diff.cursor-pointer.js-show-suppressed-diff= _("Changes suppressed. Click to show.")
diff --git a/app/views/projects/feature_flags/new.html.haml b/app/views/projects/feature_flags/new.html.haml
index 097475d2928..9fef9864475 100644
--- a/app/views/projects/feature_flags/new.html.haml
+++ b/app/views/projects/feature_flags/new.html.haml
@@ -6,8 +6,8 @@
#js-new-feature-flag{ data: { endpoint: project_feature_flags_path(@project, format: :json),
feature_flags_path: project_feature_flags_path(@project),
environments_endpoint: search_project_environments_path(@project, format: :json),
- user_callouts_path: user_callouts_path,
- user_callout_id: UserCalloutsHelper::FEATURE_FLAGS_NEW_VERSION,
+ user_callouts_path: callouts_path,
+ user_callout_id: Users::CalloutsHelper::FEATURE_FLAGS_NEW_VERSION,
show_user_callout: show_feature_flags_new_version?.to_s,
strategy_type_docs_page_path: help_page_path('operations/feature_flags', anchor: 'feature-flag-strategies'),
environments_scope_docs_path: help_page_path('ci/environments/index.md', anchor: 'scope-environments-with-specs'),
diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml
index ba4e40a8675..d6f421e8ad6 100644
--- a/app/views/projects/forks/index.html.haml
+++ b/app/views/projects/forks/index.html.haml
@@ -1,40 +1,36 @@
+- sort_value = @sort || sort_value_recently_created
+- sort_title = forks_sort_options_hash[sort_value]
+
.top-area
.nav-text
- full_count_title = "#{@public_forks_count} public, #{@internal_forks_count} internal, and #{@private_forks_count} private"
#{pluralize(@total_forks_count, 'fork')}: #{full_count_title}
- .nav-controls
- = form_tag request.original_url, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f|
- = search_field_tag :filter_projects, nil, placeholder: _('Search forks'), class: 'projects-list-filter project-filter-form-field form-control input-short',
+ .gl-display-flex.gl-sm-flex-direction-column.gl-md-align-items-center
+ = form_tag request.original_url, method: :get, class: 'project-filter-form gl-display-flex gl-mt-3 gl-md-mt-0', id: 'project-filter-form' do |f|
+ = search_field_tag :filter_projects, nil, placeholder: _('Search forks'), class: 'projects-list-filter project-filter-form-field form-control input-short gl-flex-grow-1',
spellcheck: false, data: { 'filter-selector' => 'span.namespace-name' }
- .dropdown
- %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
- %span.light= _("sort:")
- - if @sort.present?
- = sort_options_hash[@sort]
- - else
- = sort_title_recently_created
- = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3')
- %ul.dropdown-menu.dropdown-menu-right
- %li
- - excluded_filters = [:state, :scope, :label_name, :milestone_id, :assignee_id, :author_id]
- = link_to page_filter_path(sort: sort_value_recently_created, without: excluded_filters) do
- = sort_title_recently_created
- = link_to page_filter_path(sort: sort_value_oldest_created, without: excluded_filters) do
- = sort_title_oldest_created
- = link_to page_filter_path(sort: sort_value_recently_updated, without: excluded_filters) do
- = sort_title_recently_updated
- = link_to page_filter_path(sort: sort_value_oldest_updated, without: excluded_filters) do
- = sort_title_oldest_updated
+ .dropdown.gl-display-inline.gl-md-ml-3.issue-sort-dropdown.gl-mt-3.gl-md-mt-0
+ .btn-group{ role: 'group' }
+ .btn-group{ role: 'group' }
+ %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'gl-button btn btn-default' }
+ = sort_title
+ = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3")
+ %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort
+ %li
+ - excluded_filters = [:state, :scope, :label_name, :milestone_id, :assignee_id, :author_id]
+ = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_recently_created, without: excluded_filters), sort_title)
+ = sortable_item(sort_title_latest_activity, page_filter_path(sort: sort_value_latest_activity, without: excluded_filters), sort_title)
+ = forks_sort_direction_button(sort_value)
- if current_user && can?(current_user, :fork_project, @project)
- if current_user.already_forked?(@project) && current_user.manageable_namespaces.size < 2
- = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: _('Go to your fork'), class: 'btn gl-button btn-confirm' do
+ = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: _('Go to your fork'), class: 'btn gl-button btn-confirm gl-md-ml-3' do
= sprite_icon('fork', size: 12)
%span= _('Fork')
- else
- = link_to new_project_fork_path(@project), title: _("Fork project"), class: 'btn gl-button btn-confirm' do
+ = link_to new_project_fork_path(@project), title: _("Fork project"), class: 'btn gl-button btn-confirm gl-md-ml-3 gl-mt-3 gl-md-mt-0' do
= sprite_icon('fork', size: 12)
%span= _('Fork')
diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml
index 2627552058b..9e3d9b4258a 100644
--- a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml
+++ b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml
@@ -9,6 +9,9 @@
%td.status
= render 'ci/status/badge', status: generic_commit_status.detailed_status(current_user)
+ %td
+ = generic_commit_status.name
+
%td.generic-commit-status-link
- if can?(current_user, :read_commit_status, generic_commit_status) && generic_commit_status.target_url
= link_to generic_commit_status.target_url do
@@ -66,9 +69,6 @@
= generic_commit_status.stage
%td
- = generic_commit_status.name
-
- %td
- if generic_commit_status.duration
%p.duration
= custom_icon("icon_timer")
diff --git a/app/views/projects/google_cloud/errors/gcp_error.html.haml b/app/views/projects/google_cloud/errors/gcp_error.html.haml
new file mode 100644
index 00000000000..69e481501d5
--- /dev/null
+++ b/app/views/projects/google_cloud/errors/gcp_error.html.haml
@@ -0,0 +1,6 @@
+- breadcrumb_title _('Google Cloud')
+- page_title _('Google Cloud')
+
+- @content_class = "limit-container-width" unless fluid_layout
+
+#js-google-cloud{ data: @js_data }
diff --git a/app/views/projects/google_cloud/errors/no_gcp_projects.html.haml b/app/views/projects/google_cloud/errors/no_gcp_projects.html.haml
new file mode 100644
index 00000000000..69e481501d5
--- /dev/null
+++ b/app/views/projects/google_cloud/errors/no_gcp_projects.html.haml
@@ -0,0 +1,6 @@
+- breadcrumb_title _('Google Cloud')
+- page_title _('Google Cloud')
+
+- @content_class = "limit-container-width" unless fluid_layout
+
+#js-google-cloud{ data: @js_data }
diff --git a/app/views/projects/google_cloud/service_accounts/index.html.haml b/app/views/projects/google_cloud/service_accounts/index.html.haml
new file mode 100644
index 00000000000..9b82bc0acb5
--- /dev/null
+++ b/app/views/projects/google_cloud/service_accounts/index.html.haml
@@ -0,0 +1,8 @@
+- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path
+- breadcrumb_title _('Service Account')
+- page_title _('Service Account')
+
+- @content_class = "limit-container-width" unless fluid_layout
+
+= form_tag project_google_cloud_service_accounts_path(@project), method: 'post' do
+ #js-google-cloud{ data: @js_data }
diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml
index e8ea4ad90dc..7d696a988d4 100644
--- a/app/views/projects/hooks/edit.html.haml
+++ b/app/views/projects/hooks/edit.html.haml
@@ -1,7 +1,9 @@
- @content_class = 'limit-container-width' unless fluid_layout
-- add_to_breadcrumbs _('Webhook Settings'), namespace_project_hooks_path
+- add_to_breadcrumbs _('Webhook Settings'), project_hooks_path(@project)
- page_title _('Webhook')
+= render 'shared/web_hooks/hook_errors', hook: @hook
+
.row.gl-mt-3
.col-lg-3
= render 'shared/web_hooks/title_and_docs', hook: @hook
diff --git a/app/views/projects/integrations/shimos/show.html.haml b/app/views/projects/integrations/shimos/show.html.haml
new file mode 100644
index 00000000000..92b9e03d5bd
--- /dev/null
+++ b/app/views/projects/integrations/shimos/show.html.haml
@@ -0,0 +1,10 @@
+- 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
+ %h4
+ = s_('Shimo|Shimo Workspace integration is enabled')
+ %p
+ = s_("Shimo|You've enabled the Shimo Workspace integration. You can view your wiki directly in Shimo.")
+ = link_to @project.shimo_integration.external_wiki_url, target: '_blank', rel: 'noopener noreferrer', class: 'gl-button btn btn-confirm', title: s_('Shimo|Go to Shimo Workspace') do
+ = s_('Shimo|Go to Shimo Workspace')
diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml
index 2de2c2cba6c..2dc21685057 100644
--- a/app/views/projects/issues/_issue.html.haml
+++ b/app/views/projects/issues/_issue.html.haml
@@ -13,7 +13,7 @@
%span.has-tooltip{ title: _('Confidential') }
= confidential_icon(issue)
= hidden_issue_icon(issue)
- = link_to issue.title, issue_path(issue)
+ = link_to issue.title, issue_path(issue), class: 'js-prefetch-document'
= render_if_exists 'projects/issues/subepic_flag', issue: issue
- if issue.tasks?
%span.task-status.d-none.d-sm-inline-block
@@ -25,10 +25,10 @@
#{issuable_reference(issue)}
%span.issuable-authored.d-none.d-sm-inline-block
&middot;
- created #{time_ago_with_tooltip(issue.created_at, placement: 'bottom')} by
- if issue.service_desk_reply_to
- #{issue.service_desk_reply_to} via
- #{link_to_member(@project, issue.author, avatar: false)}
+ #{_('created %{timeAgoString} by %{email} via %{user}').html_safe % { timeAgoString: time_ago_with_tooltip(issue.created_at, placement: 'bottom'), email: issue.service_desk_reply_to, user: link_to_member(@project, issue.author, avatar: false) }}
+ - else
+ #{s_('IssueList|created %{timeAgoString} by %{user}').html_safe % { timeAgoString: time_ago_with_tooltip(issue.created_at, placement: 'bottom'), user: link_to_member(@project, issue.author, avatar: false) }}
= render_if_exists 'shared/issuable/gitlab_team_member_badge', author: issue.author
- if issue.milestone
%span.issuable-milestone.d-none.d-sm-inline-block
diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml
index 07fec195899..1cf0551535b 100644
--- a/app/views/projects/issues/_new_branch.html.haml
+++ b/app/views/projects/issues/_new_branch.html.haml
@@ -1,7 +1,7 @@
- if can?(current_user, :push_code, @project)
- can_create_merge_request = can?(current_user, :create_merge_request_in, @project)
- data_action = can_create_merge_request ? 'create-mr' : 'create-branch'
- - value = can_create_merge_request ? 'Create merge request' : 'Create branch'
+ - value = can_create_merge_request ? _('Create merge request') : _('Create branch')
- value = can_create_confidential_merge_request? ? _('Create confidential merge request') : value
- create_mr_text = can_create_confidential_merge_request? ? _('Create confidential merge request') : _('Create merge request')
diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml
index c47257eec4a..310a0c1a61e 100644
--- a/app/views/projects/issues/_related_branches.html.haml
+++ b/app/views/projects/issues/_related_branches.html.haml
@@ -1,7 +1,7 @@
- if @related_branches.any?
%h2.gl-font-lg
= pluralize(@related_branches.size, 'Related Branch')
- %ul.unstyled-list.related-merge-requests
+ %ul.related-merge-requests.gl-pl-0
- @related_branches.each do |branch|
%li.gl-display-flex.gl-align-items-center
- if branch[:pipeline_status].present?
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index 53c2052bfab..10c48177ae4 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -30,7 +30,7 @@
= render 'issues'
- if new_issue_email
.gl-text-center.gl-pt-5.gl-pb-7
- .js-issueable-by-email{ data: { initial_email: new_issue_email, issuable_type: issuable_type, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), quick_actions_help_path: help_page_path('user/project/quick_actions'), markdown_help_path: help_page_path('user/markdown'), reset_path: new_issuable_address_project_path(@project, issuable_type: issuable_type) } }
+ .js-issuable-by-email{ data: { initial_email: new_issue_email, issuable_type: issuable_type, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), quick_actions_help_path: help_page_path('user/project/quick_actions'), markdown_help_path: help_page_path('user/markdown'), reset_path: new_issuable_address_project_path(@project, issuable_type: issuable_type) } }
- else
- new_project_issue_button_path = @project.archived? ? false : new_project_issue_path(@project)
= render 'shared/empty_states/issues', new_project_issue_button_path: new_project_issue_button_path, show_import_button: true
diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml
index 44336b95e0f..7af825b2819 100644
--- a/app/views/projects/jobs/show.html.haml
+++ b/app/views/projects/jobs/show.html.haml
@@ -7,4 +7,7 @@
= render_if_exists "shared/shared_runners_minutes_limit_flash_message"
-#js-job-vue-app{ data: jobs_data }
+- if @build.is_a? ::Ci::Build
+ #js-job-page{ data: jobs_data }
+- else
+ #js-bridge-page{ data: bridge_data(@build) }
diff --git a/app/views/projects/learn_gitlab/index.html.haml b/app/views/projects/learn_gitlab/index.html.haml
index 9b17be99da0..6bca145dc18 100644
--- a/app/views/projects/learn_gitlab/index.html.haml
+++ b/app/views/projects/learn_gitlab/index.html.haml
@@ -4,9 +4,10 @@
- data = learn_gitlab_data(@project)
- invite_members_open = session.delete(:confetti_post_signup)
+= render 'projects/invite_members_modal', project: @project
+
- experiment(:confetti_post_signup, actor: current_user) do |e|
- e.control do
#js-learn-gitlab-app{ data: data }
- e.candidate do
- = render 'projects/invite_members_modal', project: @project
#js-learn-gitlab-app{ data: data.merge(invite_members_open: invite_members_open) }
diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml
index 3e2c5f088f7..98d2928fc97 100644
--- a/app/views/projects/merge_requests/_merge_request.html.haml
+++ b/app/views/projects/merge_requests/_merge_request.html.haml
@@ -9,7 +9,7 @@
.issuable-main-info
.merge-request-title.title
%span.merge-request-title-text.js-onboarding-mr-item
- = link_to merge_request.title, merge_request_path(merge_request)
+ = link_to merge_request.title, merge_request_path(merge_request), class: 'js-prefetch-document'
- if merge_request.tasks?
%span.task-status.d-none.d-sm-inline-block
&nbsp;
@@ -20,8 +20,7 @@
#{issuable_reference(merge_request)}
%span.issuable-authored.d-none.d-sm-inline-block
&middot;
- created #{time_ago_with_tooltip(merge_request.created_at, placement: 'bottom')}
- by #{link_to_member(@project, merge_request.author, avatar: false)}
+ #{s_('IssueList|created %{timeAgoString} by %{user}').html_safe % { timeAgoString: time_ago_with_tooltip(merge_request.created_at, placement: 'bottom'), user: link_to_member(@project, merge_request.author, avatar: false) }}
= render_if_exists 'shared/issuable/gitlab_team_member_badge', author: merge_request.author
- if merge_request.milestone
%span.issuable-milestone.d-none.d-sm-inline-block
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
index 0e8de3c2bb8..9d5d1de1005 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -4,7 +4,7 @@
- are_close_and_open_buttons_hidden = merge_request_button_hidden?(@merge_request, true) && merge_request_button_hidden?(@merge_request, false)
- cache_key = [@project, @merge_request, can_update_merge_request, can_reopen_merge_request, are_close_and_open_buttons_hidden]
-= cache_if(Feature.enabled?(:cached_mr_title, @project, default_enabled: :yaml), cache_key, expires_in: 1.day) do
+= cache(cache_key, expires_in: 1.day) do
- if @merge_request.closed_or_merged_without_fork?
.gl-alert.gl-alert-danger.gl-mb-5
.gl-alert-container
diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml
index eb5d052ec19..0036f1b4bde 100644
--- a/app/views/projects/merge_requests/creations/_new_submit.html.haml
+++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml
@@ -26,16 +26,16 @@
%li.commits-tab.new-tab
= link_to url_for(safe_params), data: {target: 'div#commits', action: 'new', toggle: 'tabvue'} do
Commits
- %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @total_commit_count
+ = gl_badge_tag @total_commit_count, { size: :sm }, { class: 'gl-tab-counter-badge' }
- if @pipelines.any?
%li.builds-tab
= link_to url_for(safe_params.merge(action: 'pipelines')), data: {target: 'div#pipelines', action: 'pipelines', toggle: 'tabvue'} do
Pipelines
- %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @pipelines.size
+ = gl_badge_tag @pipelines.size, { size: :sm }, { class: 'gl-tab-counter-badge' }
%li.diffs-tab
= link_to url_for(safe_params.merge(action: 'diffs')), data: {target: 'div#diffs', action: 'diffs', toggle: 'tabvue', qa_selector: 'diffs_tab'} do
Changes
- %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @merge_request.diff_size
+ = gl_badge_tag @merge_request.diff_size, { size: :sm }, { class: 'gl-tab-counter-badge' }
#diff-notes-app.tab-content
#new.commits.tab-pane.active
diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml
index 41c6696789d..a3f40207d20 100644
--- a/app/views/projects/merge_requests/index.html.haml
+++ b/app/views/projects/merge_requests/index.html.haml
@@ -26,6 +26,6 @@
= render 'merge_requests', new_merge_request_path: new_merge_request_path
- if new_merge_request_email
.gl-text-center.gl-pt-5.gl-pb-7
- .js-issueable-by-email{ data: { initial_email: new_merge_request_email, issuable_type: issuable_type, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), quick_actions_help_path: help_page_path('user/project/quick_actions'), markdown_help_path: help_page_path('user/markdown'), reset_path: new_issuable_address_project_path(@project, issuable_type: issuable_type) } }
+ .js-issuable-by-email{ data: { initial_email: new_merge_request_email, issuable_type: issuable_type, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), quick_actions_help_path: help_page_path('user/project/quick_actions'), markdown_help_path: help_page_path('user/markdown'), reset_path: new_issuable_address_project_path(@project, issuable_type: issuable_type) } }
- else
= render 'shared/empty_states/merge_requests', button_path: new_merge_request_path
diff --git a/app/views/projects/merge_requests/invalid.html.haml b/app/views/projects/merge_requests/invalid.html.haml
index fd1b2328a98..eb8de425f61 100644
--- a/app/views/projects/merge_requests/invalid.html.haml
+++ b/app/views/projects/merge_requests/invalid.html.haml
@@ -1,7 +1,12 @@
- page_title "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge requests")
-- badge_css_classes = "badge gl-text-white"
-- badge_info_css_classes = "#{badge_css_classes} badge-info"
-- badge_inverse_css_classes = "#{badge_css_classes} badge-inverse"
+
+- badge_start = '<span class="badge badge-pill gl-badge sm badge-info">'.html_safe
+- badge_end = '</span>'.html_safe
+
+- err_fork_project_removed = s_("MergeRequest|Can't show this merge request because the fork project was deleted.")
+- err_source_branch = s_("MergeRequest|Can't show this merge request because the source branch %{badge_start}%{source_branch}%{badge_end} is missing from project %{badge_start}%{project_path}%{badge_end}. Close this merge request or update the source branch.")
+- err_target_branch = s_("MergeRequest|Can't show this merge request because the target branch %{badge_start}%{target_branch}%{badge_end} is missing from project %{badge_start}%{project_path}%{badge_end}. Close this merge request or update the target branch.")
+- err_internal = s_("MergeRequest|Can't show this merge request because of an internal error. Contact your administrator.")
.merge-request
= render "projects/merge_requests/mr_title"
@@ -11,20 +16,12 @@
.gl-alert-container
= sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
.gl-alert-content{ role: 'alert' }
- %p
- We cannot render this merge request properly because
+ .gl-alert-body
- if @merge_request.for_fork? && !@merge_request.source_project
- fork project was removed
+ = err_fork_project_removed
- elsif !@merge_request.source_branch_exists?
- %span{ class: badge_inverse_css_classes }= @merge_request.source_branch
- does not exist in
- %span{ class: badge_info_css_classes }= @merge_request.source_project_path
+ = err_source_branch.html_safe % { badge_start: badge_start, badge_end: badge_end, source_branch: @merge_request.source_branch, project_path: @merge_request.source_project_path }
- elsif !@merge_request.target_branch_exists?
- %span{ class: badge_inverse_css_classes }= @merge_request.target_branch
- does not exist in
- %span{ class: badge_info_css_classes }= @merge_request.target_project_path
+ = err_target_branch.html_safe % { badge_start: badge_start, badge_end: badge_end, target_branch: @merge_request.target_branch, project_path: @merge_request.source_project_path }
- else
- of internal error
-
- %strong
- Please close merge request or change branches with existing one
+ = err_internal
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 2154ef6b596..eb1c9712c52 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -25,21 +25,21 @@
= render "projects/merge_requests/tabs/tab", class: "notes-tab", qa_selector: "notes_tab" do
= tab_link_for @merge_request, :show, force_link: @commit.present? do
= _("Overview")
- %span.badge.badge-pill.gl-badge.badge-muted.sm= @merge_request.related_notes.user.count
+ = gl_badge_tag @merge_request.related_notes.user.count, { size: :sm }
- if @merge_request.source_project
= render "projects/merge_requests/tabs/tab", name: "commits", class: "commits-tab", qa_selector: "commits_tab" do
= tab_link_for @merge_request, :commits do
= _("Commits")
- %span.badge.badge-pill.gl-badge.badge-muted.sm= @commits_count
+ = gl_badge_tag @commits_count, { size: :sm }
- if @number_of_pipelines.nonzero?
= render "projects/merge_requests/tabs/tab", name: "pipelines", class: "pipelines-tab" do
= tab_link_for @merge_request, :pipelines do
= _("Pipelines")
- %span.badge.badge-pill.gl-badge.badge-muted.sm.js-pipelines-mr-count= @number_of_pipelines
+ = gl_badge_tag @number_of_pipelines, { size: :sm }, { class: 'js-pipelines-mr-count' }
= render "projects/merge_requests/tabs/tab", name: "diffs", class: "diffs-tab", id: "diffs-tab", qa_selector: "diffs_tab" do
= tab_link_for @merge_request, :diffs do
= _("Changes")
- %span.badge.badge-pill.gl-badge.badge-muted.sm= @diffs_count
+ = gl_badge_tag @diffs_count, { size: :sm }
.d-flex.flex-wrap.align-items-center.justify-content-lg-end
#js-vue-discussion-counter
diff --git a/app/views/projects/mirrors/_disabled_mirror_badge.html.haml b/app/views/projects/mirrors/_disabled_mirror_badge.html.haml
index 9c11b650f75..86e54acecc4 100644
--- a/app/views/projects/mirrors/_disabled_mirror_badge.html.haml
+++ b/app/views/projects/mirrors/_disabled_mirror_badge.html.haml
@@ -1 +1,2 @@
-.badge.badge-warning.qa-disabled-mirror-badge.rspec-disabled-mirror-badge{ data: { toggle: 'tooltip', html: 'true' }, title: _('Disabled mirrors can only be enabled by instance owners. It is recommended that you delete them.') }= _('Disabled')
+%span.qa-disabled-mirror-badge.rspec-disabled-mirror-badge{ data: { toggle: 'tooltip', html: 'true' }, title: _('Disabled mirrors can only be enabled by instance owners. It is recommended that you delete them.') }
+ = gl_badge_tag _('Disabled'), variant: :warning
diff --git a/app/views/projects/mirrors/_regenerate_public_ssh_key_confirm_modal.html.haml b/app/views/projects/mirrors/_regenerate_public_ssh_key_confirm_modal.html.haml
deleted file mode 100644
index e6f3060af3e..00000000000
--- a/app/views/projects/mirrors/_regenerate_public_ssh_key_confirm_modal.html.haml
+++ /dev/null
@@ -1,13 +0,0 @@
-.modal.js-regenerate-public-ssh-key-confirm-modal{ tabindex: -1 }
- .modal-dialog
- .modal-content
- .modal-header
- %h3.modal-title.page-title
- Regenerate public SSH key?
- %button.close.js-cancel{ type: 'button', 'data-dismiss': 'modal', 'aria-label' => _('Close') }
- %span{ 'aria-hidden': true } &times;
- .modal-body
- %p= _('Are you sure you want to regenerate the public key? You will have to update the public key on the remote server before mirroring will work again.')
- .form-actions.modal-footer
- = button_tag _('Cancel'), type: 'button', class: 'btn gl-button js-cancel'
- = button_tag _('Regenerate key'), type: 'button', class: 'btn gl-button btn-inverted btn-warning js-confirm'
diff --git a/app/views/projects/network/show.json.erb b/app/views/projects/network/show.json.erb
index a146d137c55..93b3c9911e2 100644
--- a/app/views/projects/network/show.json.erb
+++ b/app/views/projects/network/show.json.erb
@@ -2,7 +2,7 @@
<%= raw(
{
- days: @graph.days.compact.map { |d| [d.day, d.strftime("%b")] },
+ days: @graph.days.compact.map { |d| [d.day, d.strftime("%b"), d.year] },
commits: @graph.commits.map do |c|
{
parents: parents_zip_spaces(c.parents(@graph.map), c.parent_spaces),
diff --git a/app/views/projects/packages/packages/index.html.haml b/app/views/projects/packages/packages/index.html.haml
index 0d5350ab62b..c67b06218e2 100644
--- a/app/views/projects/packages/packages/index.html.haml
+++ b/app/views/projects/packages/packages/index.html.haml
@@ -3,4 +3,8 @@
.row
.col-12
- #js-vue-packages-list{ data: packages_list_data('projects', @project) }
+ #js-vue-packages-list{ data: { resource_id: @project.id,
+ full_path: @project.full_path,
+ endpoint: project_packages_path(@project),
+ page_type: 'projects',
+ empty_list_illustration: image_path('illustrations/no-packages.svg'), } }
diff --git a/app/views/projects/pages_domains/_dns.html.haml b/app/views/projects/pages_domains/_dns.html.haml
index 267317196f8..2732463020e 100644
--- a/app/views/projects/pages_domains/_dns.html.haml
+++ b/app/views/projects/pages_domains/_dns.html.haml
@@ -1,5 +1,5 @@
- verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled?
-- dns_record = "#{domain_presenter.domain} CNAME #{domain_presenter.project.pages_subdomain}.#{Settings.pages.host}."
+- dns_record = "#{domain_presenter.domain} ALIAS #{domain_presenter.project.pages_subdomain}.#{Settings.pages.host}."
.form-group.border-section
.row
diff --git a/app/views/projects/pipeline_schedules/edit.html.haml b/app/views/projects/pipeline_schedules/edit.html.haml
index 29896500ea1..51f0c58330d 100644
--- a/app/views/projects/pipeline_schedules/edit.html.haml
+++ b/app/views/projects/pipeline_schedules/edit.html.haml
@@ -4,7 +4,7 @@
- add_page_specific_style 'page_bundles/pipeline_schedules'
%h3.page-title
- = _("Edit Pipeline Schedule %{id}") % { id: @schedule.id }
+ = _("Edit Pipeline Schedule")
%hr
= render "form"
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index 93afddce779..e844a3d4779 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -11,16 +11,16 @@
%li.js-builds-tab-link
= link_to builds_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do
= _('Jobs')
- %span.badge.badge-pill.gl-badge.badge-muted.sm.js-builds-counter= pipeline.total_size
+ = gl_badge_tag @pipeline.total_size, { size: :sm }, { class: 'js-builds-counter' }
- if @pipeline.failed_builds.present?
%li.js-failures-tab-link
= link_to failures_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do
= _('Failed Jobs')
- %span.badge.badge-pill.gl-badge.badge-muted.sm.js-failures-counter= @pipeline.failed_builds.count
+ = gl_badge_tag @pipeline.failed_builds.count, { size: :sm }, { class: 'js-failures-counter' }
%li.js-tests-tab-link
= link_to test_report_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-tests', action: 'test_report', toggle: 'tab' }, class: 'test-tab' do
= s_('TestReports|Tests')
- %span.badge.badge-pill.gl-badge.badge-muted.sm.js-test-report-badge-counter= @pipeline.test_report_summary.total[:count]
+ = gl_badge_tag @pipeline.test_report_summary.total[:count], { size: :sm }, { class: 'js-test-report-badge-counter' }
= render_if_exists "projects/pipelines/tabs_holder", pipeline: @pipeline, project: @project
.tab-content
@@ -29,17 +29,20 @@
#js-tab-builds.tab-pane
- if stages.present?
- .table-holder.pipeline-holder
- %table.table.ci-table.pipeline
- %thead
- %tr
- %th= _('Status')
- %th= _('Name')
- %th= _('Job ID')
- %th
- %th= _('Coverage')
- %th
- = render partial: "projects/stage/stage", collection: stages, as: :stage
+ - if Feature.enabled?(:jobs_tab_vue, @project, default_enabled: :yaml)
+ #js-pipeline-jobs-vue{ data: { full_path: @project.full_path, pipeline_iid: @pipeline.iid } }
+ - else
+ .table-holder.pipeline-holder
+ %table.table.ci-table.pipeline
+ %thead
+ %tr
+ %th= _('Status')
+ %th= _('Name')
+ %th= _('Job ID')
+ %th
+ %th= _('Coverage')
+ %th
+ = render partial: "projects/stage/stage", collection: stages, as: :stage
- if @pipeline.failed_builds.present?
#js-tab-failures.build-failures.tab-pane.build-page
diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml
index 21e871246d7..547e2c8a7f4 100644
--- a/app/views/projects/pipelines/charts.html.haml
+++ b/app/views/projects/pipelines/charts.html.haml
@@ -2,4 +2,7 @@
#js-project-pipelines-charts-app{ data: { project_path: @project.full_path,
should_render_dora_charts: should_render_dora_charts.to_s,
- should_render_quality_summary: should_render_quality_summary.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'),
+ coverage_chart_path: charts_project_graph_path(@project, @project.default_branch),
+ default_branch: @project.default_branch } }
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index c911fc8a203..ac5d34bfd44 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -10,8 +10,7 @@
.js-pipeline-container{ data: { controller_action: "#{controller.action_name}" } }
#js-pipeline-header-vue.pipeline-header-container{ data: { full_path: @project.full_path, pipeline_iid: @pipeline.iid, pipeline_id: @pipeline.id, pipelines_path: project_pipelines_path(@project) } }
- - if Gitlab.com? && show_cc_validation_alert?(@pipeline)
- #js-cc-validation-required-alert
+ = render_if_exists 'projects/pipelines/cc_validation_required_alert', pipeline: @pipeline
- if @pipeline.commit.present?
= render "projects/pipelines/info", commit: @pipeline.commit
diff --git a/app/views/projects/protected_branches/shared/_matching_branch.html.haml b/app/views/projects/protected_branches/shared/_matching_branch.html.haml
index 9145be5d2f2..1a2ec38fae9 100644
--- a/app/views/projects/protected_branches/shared/_matching_branch.html.haml
+++ b/app/views/projects/protected_branches/shared/_matching_branch.html.haml
@@ -3,7 +3,7 @@
= link_to matching_branch.name, project_ref_path(@project, matching_branch.name), class: 'ref-name'
- if @project.root_ref?(matching_branch.name)
- %span.badge.badge-info.gl-ml-2 default
+ = gl_badge_tag s_('ProtectedBranch|default'), { variant: :info }, { class: 'gl-ml-2' }
%td
- commit = @project.commit(matching_branch.name)
= link_to(commit.short_id, project_commit_path(@project, commit.id), class: 'commit-sha')
diff --git a/app/views/projects/protected_tags/shared/_matching_tag.html.haml b/app/views/projects/protected_tags/shared/_matching_tag.html.haml
index bf030d36cd6..3b6a6bd876f 100644
--- a/app/views/projects/protected_tags/shared/_matching_tag.html.haml
+++ b/app/views/projects/protected_tags/shared/_matching_tag.html.haml
@@ -3,7 +3,7 @@
= link_to matching_tag.name, project_ref_path(@project, matching_tag.name), class: 'ref-name'
- if @project.root_ref?(matching_tag.name)
- %span.badge.badge-info.gl-ml-2 default
+ = gl_badge_tag s_('ProtectedTag|default'), { variant: :info }, { class: 'gl-ml-2' }
%td
- commit = @project.commit(matching_tag.name)
= link_to(commit.short_id, project_commit_path(@project, commit.id), class: 'commit-sha')
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 972c96dc882..70357f39e44 100644
--- a/app/views/projects/protected_tags/shared/_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/shared/_protected_tag.html.haml
@@ -3,7 +3,7 @@
%span.ref-name= protected_tag.name
- if @project.root_ref?(protected_tag.name)
- %span.badge.badge-info.gl-ml-2 default
+ = gl_badge_tag s_('ProtectedTags|default'), variant: :info, class: 'gl-ml-2'
%td
- if protected_tag.wildcard?
- matching_tags = protected_tag.matching(repository.tags)
diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml
index cfdbf3410b1..03927cd3bfa 100644
--- a/app/views/projects/registry/repositories/index.html.haml
+++ b/app/views/projects/registry/repositories/index.html.haml
@@ -22,6 +22,6 @@
"cleanup_policies_settings_path": project_settings_packages_and_registries_path(@project),
connection_error: (!!@connection_error).to_s,
invalid_path_error: (!!@invalid_path_error).to_s,
- user_callouts_path: user_callouts_path,
- user_callout_id: UserCalloutsHelper::UNFINISHED_TAG_CLEANUP_CALLOUT,
+ user_callouts_path: callouts_path,
+ user_callout_id: Users::CalloutsHelper::UNFINISHED_TAG_CLEANUP_CALLOUT,
show_unfinished_tag_cleanup_callout: show_unfinished_tag_cleanup_callout?.to_s, } }
diff --git a/app/views/projects/remove_fork.js.haml b/app/views/projects/remove_fork.js.haml
deleted file mode 100644
index 6d083c5c516..00000000000
--- a/app/views/projects/remove_fork.js.haml
+++ /dev/null
@@ -1,2 +0,0 @@
-:plain
- location.href = "#{edit_project_path(@project)}";
diff --git a/app/views/projects/runners/_group_runners.html.haml b/app/views/projects/runners/_group_runners.html.haml
index 5d737bb3901..183e747afdd 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_pipeline, @project.group)
+ - if can?(current_user, :admin_group_runners, @project.group)
- group_link = link_to _("group's CI/CD settings."), group_settings_ci_cd_path(@project.group)
= _('Group maintainers can register group runners in the %{link}').html_safe % { link: group_link }
- else
diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml
index bf2e746b4a4..28e5618f8b0 100644
--- a/app/views/projects/runners/_runner.html.haml
+++ b/app/views/projects/runners/_runner.html.haml
@@ -36,5 +36,4 @@
- if runner.tags.present?
.gl-my-2
- runner.tags.map(&:name).sort.each do |tag|
- %span.badge.gl-badge.sm.badge-pill.badge-primary
- = tag
+ = gl_badge_tag tag, variant: :info, size: :sm
diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml
index 724684c9a0a..dbc204ff9bf 100644
--- a/app/views/projects/services/_form.html.haml
+++ b/app/views/projects/services/_form.html.haml
@@ -6,7 +6,7 @@
- if integration.operating?
= sprite_icon('check', css_class: 'gl-text-green-500')
-= form_for(integration, as: :service, url: scoped_integration_path(integration), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'test-url' => test_project_service_path(@project, integration) } }) do |form|
+= form_for(integration, as: :service, url: scoped_integration_path(integration, project: @project, group: @group), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'test-url' => test_project_service_path(@project, integration) } }) do |form|
= render 'shared/service_settings', form: form, integration: integration
%input{ id: 'services_redirect_to', type: 'hidden', name: 'redirect_to', value: request.referer }
diff --git a/app/views/projects/services/prometheus/_custom_metrics.html.haml b/app/views/projects/services/prometheus/_custom_metrics.html.haml
index 724950bcb39..4586ee844c0 100644
--- a/app/views/projects/services/prometheus/_custom_metrics.html.haml
+++ b/app/views/projects/services/prometheus/_custom_metrics.html.haml
@@ -10,9 +10,7 @@
.card-header
%strong
= s_('PrometheusService|Custom metrics')
- -# haml-lint:disable NoPlainNodes
- %span.badge.badge-pill.js-custom-monitored-count 0
- -# haml-lint:enable NoPlainNodes
+ = gl_badge_tag 0, nil, class: 'js-custom-monitored-count'
= link_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(project), class: 'btn gl-button btn-confirm gl-ml-auto js-new-metric-button hidden', data: { qa_selector: 'new_metric_button' }
.card-body
.flash-container.hidden
diff --git a/app/views/projects/services/prometheus/_metrics.html.haml b/app/views/projects/services/prometheus/_metrics.html.haml
index 09fe77b8a9c..0d41584652f 100644
--- a/app/views/projects/services/prometheus/_metrics.html.haml
+++ b/app/views/projects/services/prometheus/_metrics.html.haml
@@ -12,7 +12,7 @@
.card-header
%strong
= s_('PrometheusService|Common metrics')
- %span.badge.badge-pill.js-monitored-count 0
+ = gl_badge_tag 0, nil, class: 'js-monitored-count'
.card-body
.loading-metrics.js-loading-metrics
%p.m-3
@@ -28,7 +28,7 @@
= sprite_icon('chevron-lg-right', css_class: 'panel-toggle js-panel-toggle-right' )
= sprite_icon('chevron-lg-down', css_class: 'panel-toggle js-panel-toggle-down hidden' )
= s_('PrometheusService|Missing environment variable')
- %span.badge.badge-pill.js-env-var-count 0
+ = gl_badge_tag 0, nil, class: 'js-env-var-count'
.card-body.hidden
.flash-container
.flash-notice
diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml
index 52ef2e7d1ee..4e946050881 100644
--- a/app/views/projects/settings/access_tokens/index.html.haml
+++ b/app/views/projects/settings/access_tokens/index.html.haml
@@ -39,7 +39,7 @@
access_levels: ProjectMember.access_level_roles,
default_access_level: Gitlab::Access::MAINTAINER,
prefix: :project_access_token,
- help_path: help_page_path('user/project/settings/project_access_tokens', anchor: 'limiting-scopes-of-a-project-access-token')
+ help_path: help_page_path('user/project/settings/project_access_tokens', anchor: 'scopes-for-a-project-access-token')
= render 'shared/access_tokens/table',
active_tokens: @active_project_access_tokens,
diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
index 8563f28eb33..e200635ba82 100644
--- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
@@ -23,7 +23,7 @@
= form.label :enabled, class: 'form-check-label' do
%strong= s_('CICD|Default to Auto DevOps pipeline')
- if auto_devops_enabled
- %span.badge.badge-info.js-instance-default-badge= badge_for_auto_devops_scope(@project)
+ = gl_badge_tag badge_for_auto_devops_scope(@project), { variant: :info }, { class: 'js-instance-default-badge'}
.form-text.text-muted
= s_('CICD|The Auto DevOps pipeline runs if no alternative CI configuration file is found.')
= link_to _('Learn more.'), help_page_path('topics/autodevops/index.md'), target: '_blank'
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index 75bd985560b..f342728feee 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -96,15 +96,14 @@
.settings-content
= render 'ci/deploy_freeze/index'
-- if Feature.enabled?(:ci_scoped_job_token, @project, default_enabled: :yaml)
- %section.settings.no-animate#js-token-access{ class: ('expanded' if expanded) }
- .settings-header
- %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
- = _("Token Access")
- %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
- = expanded ? _('Collapse') : _('Expand')
- %p
- = _("Control which projects can be accessed by API requests authenticated with this project's CI_JOB_TOKEN CI/CD variable. It is a security risk to disable this feature, because unauthorized projects might attempt to retrieve an active token and access the API.")
- = link_to _('Learn more'), help_page_path('ci/jobs/ci_job_token'), target: '_blank', rel: 'noopener noreferrer'
- .settings-content
- = render 'ci/token_access/index'
+%section.settings.no-animate#js-token-access{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
+ = _("Token Access")
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded ? _('Collapse') : _('Expand')
+ %p
+ = _("Control which projects can be accessed by API requests authenticated with this project's CI_JOB_TOKEN CI/CD variable. It is a security risk to disable this feature, because unauthorized projects might attempt to retrieve an active token and access the API.")
+ = link_to _('Learn more'), help_page_path('ci/jobs/ci_job_token'), target: '_blank', rel: 'noopener noreferrer'
+ .settings-content
+ = render 'ci/token_access/index'
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index 1cbb061784e..d840ea01b89 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -19,7 +19,6 @@
= render "archived_notice", project: @project
= render_if_exists "projects/marked_for_deletion_notice", project: @project
= render_if_exists "projects/ancestor_group_marked_for_deletion_notice", project: @project
-= render_if_exists 'projects/sast_entry_points', project: @project
- view_path = @project.default_view
diff --git a/app/views/projects/starrers/_starrer.html.haml b/app/views/projects/starrers/_starrer.html.haml
index 28ec1ed206a..e24276fcaea 100644
--- a/app/views/projects/starrers/_starrer.html.haml
+++ b/app/views/projects/starrers/_starrer.html.haml
@@ -13,7 +13,7 @@
%span.cgray= starrer.user.to_reference
- if starrer.user == current_user
- %span.badge-pill.badge-success.gl-badge.gl-ml-2.sm= _("It's you")
+ = gl_badge_tag _("It's you"), variant: :success, size: :sm, class: 'gl-ml-2'
.block-truncated
= time_ago_with_tooltip(starrer.starred_since)
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index 83a3cac487f..0ee3b89b629 100644
--- a/app/views/projects/tags/_tag.html.haml
+++ b/app/views/projects/tags/_tag.html.haml
@@ -2,18 +2,13 @@
- release = @releases.find { |release| release.tag == tag.name }
- commit_status = @tag_pipeline_statuses[tag.name] unless @tag_pipeline_statuses.nil?
-%li.flex-row.js-tag-list{ class: "gl-white-space-normal!" }
+%li.flex-row.js-tag-list{ class: "gl-white-space-normal! gl-align-items-flex-start!" }
.row-main-content
= sprite_icon('tag')
= link_to tag.name, project_tag_path(@project, tag.name), class: 'item-title ref-name'
- if protected_tag?(@project, tag)
- %span.badge.badge-success.gl-ml-2.gl-badge.sm.badge-pill
- = s_('TagsPage|protected')
-
- - if tag.message.present?
- &nbsp;
- = strip_signature(tag.message)
+ = gl_badge_tag s_('TagsPage|protected'), variant: :success, size: :sm, class: 'gl-ml-2'
- if commit
.block-truncated
@@ -28,6 +23,10 @@
= _("Release")
= link_to release.name, project_releases_path(@project, anchor: release.tag), class: 'gl-text-blue-600!'
+ - if tag.message.present?
+ %pre.wrap
+ = strip_signature(tag.message)
+
.row-fixed-content.controls.flex-row
- if tag.has_signature?
= render partial: 'projects/commit/signature', object: tag.signature
diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml
index b3a75494ccc..c1b78d3258d 100644
--- a/app/views/projects/tags/show.html.haml
+++ b/app/views/projects/tags/show.html.haml
@@ -13,8 +13,7 @@
= sprite_icon('tag')
= @tag.name
- if protected_tag?(@project, @tag)
- %span.badge.badge-success
- = s_('TagsPage|protected')
+ = gl_badge_tag s_('TagsPage|protected'), variant: :success
- if user
= link_to user_path(user) do
diff --git a/app/views/projects/tracings/show.html.haml b/app/views/projects/tracings/show.html.haml
index 21c1d02d92e..813908e5a57 100644
--- a/app/views/projects/tracings/show.html.haml
+++ b/app/views/projects/tracings/show.html.haml
@@ -3,10 +3,12 @@
- if @project.tracing_external_url.present?
%h3.page-title= _('Tracing')
- .gl-alert.gl-alert-info.alert.flex-alert
- = sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- .alert-message
- = _("Your password isn't required to view this page. If a password or any other personal details are requested, please contact your administrator to report abuse.")
+ .gl-alert.gl-alert-info.gl-mb-5
+ .gl-alert-container
+ = sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ .gl-alert-content
+ .gl-alert-body
+ = _("Your password isn't required to view this page. If a password or any other personal details are requested, please contact your administrator to report abuse.")
- jaeger_link = link_to('Jaeger tracing', 'https://www.jaegertracing.io/', target: "_blank", rel: "noreferrer")
%p.light= _("GitLab uses %{jaeger_link} to monitor distributed systems.").html_safe % { jaeger_link: jaeger_link }
diff --git a/app/views/projects/transfer.js.haml b/app/views/projects/transfer.js.haml
deleted file mode 100644
index 6d083c5c516..00000000000
--- a/app/views/projects/transfer.js.haml
+++ /dev/null
@@ -1,2 +0,0 @@
-:plain
- location.href = "#{edit_project_path(@project)}";
diff --git a/app/views/projects/usage_quotas/index.html.haml b/app/views/projects/usage_quotas/index.html.haml
index 6c7cccfb9b1..de1135cf928 100644
--- a/app/views/projects/usage_quotas/index.html.haml
+++ b/app/views/projects/usage_quotas/index.html.haml
@@ -16,4 +16,4 @@
= s_('UsageQuota|Storage')
.tab-content
.tab-pane#storage-quota-tab
- #js-project-storage-count-app{ data: @storage_app_data }
+ #js-project-storage-count-app{ data: { project_path: @project.full_path } }
diff --git a/app/views/root/index.html.haml b/app/views/root/index.html.haml
deleted file mode 100644
index 97dd8e133f5..00000000000
--- a/app/views/root/index.html.haml
+++ /dev/null
@@ -1,10 +0,0 @@
-- if show_customize_homepage_banner?
- = content_for :customize_homepage_banner do
- .gl-display-none.gl-md-display-block{ class: "gl-pt-6! gl-pb-2! #{(container_class unless @no_container)} #{@content_class}" }
- .js-customize-homepage-banner{ data: { svg_path: image_path('illustrations/monitoring/getting_started.svg'),
- preferences_behavior_path: profile_preferences_path(anchor: 'behavior'),
- callouts_path: user_callouts_path,
- callouts_feature_id: UserCalloutsHelper::CUSTOMIZE_HOMEPAGE,
- track_label: 'home_page' } }
-
-= render template: 'dashboard/projects/index'
diff --git a/app/views/search/results/_blob.html.haml b/app/views/search/results/_blob.html.haml
index aeb37022f99..3681f823ef5 100644
--- a/app/views/search/results/_blob.html.haml
+++ b/app/views/search/results/_blob.html.haml
@@ -1,5 +1,6 @@
- project = blob.project
- return unless project
- blob_link = project_blob_path(project, tree_join(repository_ref(project), blob.path))
+- blame_link = project_blame_path(project, tree_join(repository_ref(project), blob.path))
-= render partial: 'search/results/blob_data', locals: { blob: blob, project: project, path: blob.path, blob_link: blob_link }
+= render partial: 'search/results/blob_data', locals: { blob: blob, project: project, path: blob.path, blob_link: blob_link, blame_link: blame_link }
diff --git a/app/views/search/results/_blob_data.html.haml b/app/views/search/results/_blob_data.html.haml
index 88a2ab4bb42..c42367f45c5 100644
--- a/app/views/search/results/_blob_data.html.haml
+++ b/app/views/search/results/_blob_data.html.haml
@@ -9,7 +9,7 @@
- if blob.data
- if blob.data.size > 0
.file-content.code.term{ data: { qa_selector: 'file_text_content' } }
- = render 'shared/file_highlight', blob: blob, first_line_number: blob.startline, blob_link: blob_link, highlight_line: blob.highlight_line
+ = render 'search/results/blob_highlight', blob: blob, first_line_number: blob.startline, blob_link: blob_link, blame_link: blame_link, highlight_line: blob.highlight_line
- else
.file-content.code
.nothing-here-block
diff --git a/app/views/search/results/_blob_highlight.html.haml b/app/views/search/results/_blob_highlight.html.haml
new file mode 100644
index 00000000000..de1fa9a7fd5
--- /dev/null
+++ b/app/views/search/results/_blob_highlight.html.haml
@@ -0,0 +1,22 @@
+- offset = defined?(first_line_number) ? first_line_number : 1
+- highlight = defined?(highlight_line) && highlight_line ? highlight_line - offset : nil
+
+#search-blob-content.file-content.code.js-syntax-highlight{ class: 'gl-py-3!' }
+ - if blob.present?
+ .blob-content{ data: { blob_id: blob.id, path: blob.path, highlight_line: highlight, qa_selector: 'file_content' } }
+ - blob.present.highlight.lines.each_with_index do |line, index|
+ - i = index + offset
+ .line_holder.code-search-line
+ .line-numbers
+ .gl-display-flex
+ %span.diff-line-num.gl-pl-3
+ %a.has-tooltip{ href: "#{blame_link}#L#{i}", id: "blame-L#{i}", 'data-line-number' => i, title: _('View blame') }
+ = sprite_icon('git')
+ %span.diff-line-num.flex-grow-1.gl-pr-3
+ %a{ href: "#{blob_link}#L#{i}", id: "blob-L#{i}", 'data-line-number' => i, class: 'gl-display-flex! gl-align-items-center gl-justify-content-end' }
+ = sprite_icon('link', css_class: 'gl-ml-3! gl-mr-1!')
+ = i
+ %pre.code.highlight
+ %code
+ = line.html_safe
+
diff --git a/app/views/search/results/_issuable.html.haml b/app/views/search/results/_issuable.html.haml
index 41058034d6f..36458a909fc 100644
--- a/app/views/search/results/_issuable.html.haml
+++ b/app/views/search/results/_issuable.html.haml
@@ -1,7 +1,7 @@
%div{ class: 'search-result-row gl-display-flex gl-sm-flex-direction-row gl-flex-direction-column gl-align-items-center gl-pb-3! gl-mt-5 gl-mb-0!' }
.col-sm-9
%span.gl-display-flex.gl-align-items-center
- %span.badge.badge-pill.gl-badge.sm{ class: "badge-#{issuable_state_to_badge_class(issuable)}" }= issuable_state_text(issuable)
+ = gl_badge_tag issuable_state_text(issuable), variant: issuable_state_to_badge_class(issuable), size: :sm
= sprite_icon('eye-slash', css_class: 'gl-text-gray-500 gl-ml-2') if issuable.respond_to?(:confidential?) && issuable.confidential?
= link_to issuable_path(issuable), data: { track_action: 'click_text', track_label: "#{issuable.class.name.downcase}_title", track_property: 'search_result' }, class: 'gl-w-full' do
%span.term.str-truncated.gl-font-weight-bold.gl-ml-2= issuable.title
diff --git a/app/views/shared/_email_with_badge.html.haml b/app/views/shared/_email_with_badge.html.haml
index 8b9ca966ed6..5d837657943 100644
--- a/app/views/shared/_email_with_badge.html.haml
+++ b/app/views/shared/_email_with_badge.html.haml
@@ -1,8 +1,5 @@
-- css_classes = %w(badge gl-badge)
-- css_classes << (verified ? 'badge-success': 'badge-danger')
+- variant = verified ? :success : :danger
- text = verified ? _('Verified') : _('Unverified')
-.email-badge
- .email-badge-email= email
- %div{ class: css_classes }
- = text
+= email
+= gl_badge_tag text, { variant: variant }, { class: 'gl-ml-3' }
diff --git a/app/views/shared/_flash_user_callout.html.haml b/app/views/shared/_flash_user_callout.html.haml
index d8032ac521d..7b2d59407b4 100644
--- a/app/views/shared/_flash_user_callout.html.haml
+++ b/app/views/shared/_flash_user_callout.html.haml
@@ -1,4 +1,4 @@
-- callout_data = { uid: "callout_feature_#{feature_name}_dismissed", feature_id: feature_name, dismiss_endpoint: user_callouts_path }
+- callout_data = { uid: "callout_feature_#{feature_name}_dismissed", feature_id: feature_name, dismiss_endpoint: callouts_path }
- extra_flash_class = local_assigns.fetch(:extra_flash_class, nil)
.flash-container.flash-container-page.user-callout{ data: callout_data }
diff --git a/app/views/shared/_milestone_expired.html.haml b/app/views/shared/_milestone_expired.html.haml
index 925344ab2f7..8c84f96932c 100644
--- a/app/views/shared/_milestone_expired.html.haml
+++ b/app/views/shared/_milestone_expired.html.haml
@@ -1,6 +1,6 @@
- if milestone.expired? && !milestone.closed?
- .gl-badge.badge-warning.badge-pill.gl-mb-2= _('Expired')
+ = gl_badge_tag _('Expired'), { variant: :warning }, { class: "gl-mb-2" }
- if milestone.upcoming?
- .gl-badge.badge-primary.badge-pill.gl-mb-2= _('Upcoming')
+ = gl_badge_tag _('Upcoming'), { variant: :info }, { class: "gl-mb-2" }
- if milestone.closed?
- .gl-badge.badge-danger.badge-pill.gl-mb-2= _('Closed')
+ = gl_badge_tag _('Closed'), { variant: :danger }, { class: "gl-mb-2" }
diff --git a/app/views/shared/_milestones_filter.html.haml b/app/views/shared/_milestones_filter.html.haml
index 117ed212fd9..ef41dc9bb79 100644
--- a/app/views/shared/_milestones_filter.html.haml
+++ b/app/views/shared/_milestones_filter.html.haml
@@ -1,15 +1,12 @@
-- count_badge_classes = 'badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm gl-display-none gl-sm-display-inline-flex'
+- count_badge_classes = 'gl-display-none gl-sm-display-inline-flex'
= gl_tabs_nav( {class: 'gl-border-b-0 gl-flex-grow-1', data: { testid: 'milestones-filter' } } ) do
= gl_tab_link_to milestones_filter_path(state: 'opened'), { item_active: params[:state].blank? || params[:state] == 'opened' } do
= _('Open')
- %span{ class: count_badge_classes }
- = counts[:opened]
+ = gl_tab_counter_badge counts[:opened], { class: count_badge_classes }
= gl_tab_link_to milestones_filter_path(state: 'closed', sort: 'due_date_desc'), { item_active: params[:state] == 'closed' } do
= _('Closed')
- %span{ class: count_badge_classes }
- = counts[:closed]
+ = gl_tab_counter_badge counts[:closed], { class: count_badge_classes }
= gl_tab_link_to milestones_filter_path(state: 'all', sort: 'due_date_desc'), { item_active: params[:state] == 'all' } do
= _('All')
- %span{ class: count_badge_classes }
- = counts[:all]
+ = gl_tab_counter_badge counts[:all], { class: count_badge_classes }
diff --git a/app/views/shared/_registration_features_discovery_message.html.haml b/app/views/shared/_registration_features_discovery_message.html.haml
new file mode 100644
index 00000000000..8bcd826d8c0
--- /dev/null
+++ b/app/views/shared/_registration_features_discovery_message.html.haml
@@ -0,0 +1,9 @@
+- license = local_assigns.fetch(:license)
+- registration_features_docs_path = help_page_path('development/service_ping/index.md', anchor: 'registration-features-program')
+- service_ping_settings_path = metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings')
+
+%div
+ %span= s_('RegistrationFeatures|Want to use this feature for free?')
+ - if license.present?
+ = link_to s_('RegistrationFeatures|Enable Service Ping and register for this feature.'), service_ping_settings_path
+ = sprintf(s_('RegistrationFeatures|Read more about the %{linkStart}Registration Features Program%{linkEnd}.') , { linkStart: "<a href=\"#{registration_features_docs_path}\" target=\"_blank\">", linkEnd: "</a>", }).html_safe
diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml
index c70fce7a38f..adacaeadfab 100644
--- a/app/views/shared/_service_settings.html.haml
+++ b/app/views/shared/_service_settings.html.haml
@@ -2,8 +2,8 @@
.service-settings
- if @default_integration
- .js-vue-default-integration-settings{ data: integration_form_data(@default_integration, group: @group) }
- .js-vue-integration-settings{ data: integration_form_data(integration, group: @group) }
+ .js-vue-default-integration-settings{ data: integration_form_data(@default_integration, group: @group, project: @project) }
+ .js-vue-integration-settings{ data: integration_form_data(integration, group: @group, project: @project) }
.js-integration-help-html.gl-display-none
-# All content below will be repositioned in Vue
- if lookup_context.template_exists?('help', "projects/services/#{integration.to_param}", true)
diff --git a/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml b/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml
index d4764d1a5d9..e7239661313 100644
--- a/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml
+++ b/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml
@@ -1,7 +1,7 @@
= render 'shared/global_alert',
variant: :warning,
alert_class: 'js-recovery-settings-callout',
- alert_data: { feature_id: UserCalloutsHelper::TWO_FACTOR_AUTH_RECOVERY_SETTINGS_CHECK, dismiss_endpoint: user_callouts_path, defer_links: 'true' },
+ alert_data: { feature_id: Users::CalloutsHelper::TWO_FACTOR_AUTH_RECOVERY_SETTINGS_CHECK, dismiss_endpoint: callouts_path, defer_links: 'true' },
close_button_data: { testid: 'close-account-recovery-regular-check-callout' } do
.gl-alert-body
= s_('Profiles|Ensure you have two-factor authentication recovery codes stored in a safe place.')
diff --git a/app/views/shared/builds/_tabs.html.haml b/app/views/shared/builds/_tabs.html.haml
index 498e9cc33ce..3bbd7a32bda 100644
--- a/app/views/shared/builds/_tabs.html.haml
+++ b/app/views/shared/builds/_tabs.html.haml
@@ -1,19 +1,15 @@
-- count_badge_classes = 'badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm gl-display-none gl-sm-display-inline-flex'
+- count_badge_classes = 'gl-display-none gl-sm-display-inline-flex'
= gl_tabs_nav( {class: 'gl-border-b-0 gl-flex-grow-1', data: { testid: 'jobs-tabs' } } ) do
= gl_tab_link_to build_path_proc.call(nil), { item_active: scope.nil? } do
= _('All')
- %span{ class: count_badge_classes }
- = limited_counter_with_delimiter(all_builds)
+ = gl_tab_counter_badge(limited_counter_with_delimiter(all_builds), { class: count_badge_classes })
= gl_tab_link_to build_path_proc.call('pending'), { item_active: scope == 'pending' } do
= _('Pending')
- %span{ class: count_badge_classes }
- = limited_counter_with_delimiter(all_builds.pending)
+ = gl_tab_counter_badge(limited_counter_with_delimiter(all_builds.pending), { class: count_badge_classes })
= gl_tab_link_to build_path_proc.call('running'), { item_active: scope == 'running' } do
= _('Running')
- %span{ class: count_badge_classes }
- = limited_counter_with_delimiter(all_builds.running)
+ = gl_tab_counter_badge(limited_counter_with_delimiter(all_builds.running), { class: count_badge_classes })
= gl_tab_link_to build_path_proc.call('finished'), { item_active: scope == 'finished' } do
= _('Finished')
- %span{ class: count_badge_classes }
- = limited_counter_with_delimiter(all_builds.finished)
+ = gl_tab_counter_badge(limited_counter_with_delimiter(all_builds.finished), { class: count_badge_classes })
diff --git a/app/views/shared/doorkeeper/applications/_form.html.haml b/app/views/shared/doorkeeper/applications/_form.html.haml
index 180c658dbdc..adfd7ea98b7 100644
--- a/app/views/shared/doorkeeper/applications/_form.html.haml
+++ b/app/views/shared/doorkeeper/applications/_form.html.haml
@@ -16,13 +16,14 @@
= f.check_box :confidential, class: 'form-check-input'
= f.label :confidential, class: 'label-bold form-check-label'
%span.form-text.text-muted
- = _('The application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential.')
+ = _('Enable only for confidential applications exclusively used by a trusted backend server that can securely store the client secret. Do not enable for native-mobile, single-page, or other JavaScript applications because they cannot keep the client secret confidential.')
.form-group.form-check
= f.check_box :expire_access_tokens, class: 'form-check-input'
= f.label :expire_access_tokens, class: 'label-bold form-check-label'
%span.form-text.text-muted
- = _('Access tokens expire after 2 hours. A refresh token may be used at any time to generate a new access token. Non-expiring access tokens are deprecated. Clear this setting to enable backward compatibility.')
+ = _('Enable access tokens to expire after 2 hours. If disabled, tokens do not expire.')
+ = link_to _('Learn more.'), help_page_path('integration/oauth_provider.md', anchor: 'expiring-access-tokens'), target: '_blank', rel: 'noopener noreferrer'
.form-group
= f.label :scopes, class: 'label-bold'
diff --git a/app/views/shared/doorkeeper/applications/_index.html.haml b/app/views/shared/doorkeeper/applications/_index.html.haml
index 8ccb4bcdbe0..0359c28794c 100644
--- a/app/views/shared/doorkeeper/applications/_index.html.haml
+++ b/app/views/shared/doorkeeper/applications/_index.html.haml
@@ -9,7 +9,7 @@
- if oauth_authorized_applications_enabled
= _("Manage applications that can use GitLab as an OAuth provider, and applications that you've authorized to use your account.")
- else
- = _("Manage applications that can use GitLab as an OAuth provider.")
+ = _("Manage applications that use GitLab as an OAuth provider.")
- else
= _("Manage applications that you've authorized to use your account.")
.col-lg-8
diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml
index f8942dddfb4..94818c13f76 100644
--- a/app/views/shared/form_elements/_description.html.haml
+++ b/app/views/shared/form_elements/_description.html.haml
@@ -7,7 +7,7 @@
- preview_url = preview_markdown_path(project, target_type: model.class.name)
.form-group.row.detail-page-description
- = form.label :description, 'Description', class: 'col-form-label col-sm-2'
+ = form.label :description, _('Description'), class: 'col-form-label col-sm-2'
.col-sm-10
- if model.is_a?(MergeRequest)
= hidden_field_tag :merge_request_diff_head_sha, model.diff_head_sha
diff --git a/app/views/shared/integrations/_form.html.haml b/app/views/shared/integrations/_form.html.haml
index 35f302a28a6..89c127408e1 100644
--- a/app/views/shared/integrations/_form.html.haml
+++ b/app/views/shared/integrations/_form.html.haml
@@ -1,4 +1,4 @@
- integration = local_assigns.fetch(:integration)
-= form_for integration, as: :service, url: scoped_integration_path(integration), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'test-url' => scoped_test_integration_path(integration) } } do |form|
+= form_for integration, as: :service, url: scoped_integration_path(integration, group: @group), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'test-url' => scoped_test_integration_path(integration, group: @group) } } do |form|
= render 'shared/service_settings', form: form, integration: integration
diff --git a/app/views/shared/integrations/_index.html.haml b/app/views/shared/integrations/_index.html.haml
index 39365280e71..872fc90f6db 100644
--- a/app/views/shared/integrations/_index.html.haml
+++ b/app/views/shared/integrations/_index.html.haml
@@ -1 +1 @@
-.js-integrations-list{ data: integration_list_data(integrations) }
+.js-integrations-list{ data: integration_list_data(integrations, group: @group, project: @project) }
diff --git a/app/views/shared/integrations/_tabs.html.haml b/app/views/shared/integrations/_tabs.html.haml
index d6ca0bd7d1e..781db59592e 100644
--- a/app/views/shared/integrations/_tabs.html.haml
+++ b/app/views/shared/integrations/_tabs.html.haml
@@ -2,7 +2,7 @@
.tabs.gl-tabs
%div
= gl_tabs_nav({ class: 'gl-mb-5' }) do
- = gl_tab_link_to _('Settings'), scoped_edit_integration_path(integration)
+ = gl_tab_link_to _('Settings'), scoped_edit_integration_path(integration, project: @project, group: @group)
= gl_tab_link_to s_('Integrations|Projects using custom settings'), scoped_overrides_integration_path(integration)
= yield
diff --git a/app/views/shared/integrations/edit.html.haml b/app/views/shared/integrations/edit.html.haml
index 02cb94e3555..acb0c7ee52e 100644
--- a/app/views/shared/integrations/edit.html.haml
+++ b/app/views/shared/integrations/edit.html.haml
@@ -1,4 +1,4 @@
-- add_to_breadcrumbs _('Integrations'), scoped_integrations_path
+- add_to_breadcrumbs _('Integrations'), scoped_integrations_path(project: @project, group: @group)
- breadcrumb_title @integration.title
- page_title @integration.title, _('Integrations')
- @content_class = 'limit-container-width' unless fluid_layout
diff --git a/app/views/shared/integrations/overrides.html.haml b/app/views/shared/integrations/overrides.html.haml
index dc87fae704c..b8585fdef1f 100644
--- a/app/views/shared/integrations/overrides.html.haml
+++ b/app/views/shared/integrations/overrides.html.haml
@@ -1,4 +1,4 @@
-- add_to_breadcrumbs _('Integrations'), scoped_integrations_path
+- add_to_breadcrumbs _('Integrations'), scoped_integrations_path(project: @project, group: @group)
- breadcrumb_title @integration.title
- page_title @integration.title, _('Integrations')
- @content_class = 'limit-container-width' unless fluid_layout
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index cb03bd4c473..4024c5b77f6 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -64,7 +64,7 @@
for this project.
- if issuable.new_record?
- = form.submit "Create #{issuable.class.model_name.human.downcase}", class: 'gl-button btn btn-confirm gl-mr-2', data: { qa_selector: 'issuable_create_button' }
+ = form.submit "#{_('Create')} #{issuable.class.model_name.human.downcase}", class: 'gl-button btn btn-confirm gl-mr-2', data: { qa_selector: 'issuable_create_button' }
- else
= form.submit _('Save changes'), class: 'gl-button btn btn-confirm gl-mr-2'
diff --git a/app/views/shared/issuable/_milestone_dropdown.html.haml b/app/views/shared/issuable/_milestone_dropdown.html.haml
index f58156b7c08..dc713337747 100644
--- a/app/views/shared/issuable/_milestone_dropdown.html.haml
+++ b/app/views/shared/issuable/_milestone_dropdown.html.haml
@@ -4,20 +4,20 @@
- selected = local_assigns.fetch(:selected, nil)
- selected_text = selected.try(:title) || params[:milestone_title]
-- dropdown_title = local_assigns.fetch(:dropdown_title, "Filter by milestone")
+- dropdown_title = local_assigns.fetch(:dropdown_title, _('Filter by milestone'))
- if selected.present? || params[:milestone_title].present?
= hidden_field_tag(name, name == :milestone_title ? selected_text : selected.id)
= dropdown_tag(milestone_dropdown_label(selected_text), options: { title: dropdown_title, toggle_class: "qa-issuable-milestone-dropdown js-milestone-select js-filter-submit #{extra_class}", filter: true, dropdown_class: "qa-issuable-dropdown-menu-milestone dropdown-menu-selectable dropdown-menu-milestone",
- placeholder: "Search milestones", footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, show_started: show_started, field_name: name, selected: selected_text, project_id: project.try(:id), default_label: "Milestone" } }) do
+ placeholder: _('Search milestones'), footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, show_started: show_started, field_name: name, selected: selected_text, project_id: project.try(:id), default_label: _('Milestone') } }) do
- if project
%ul.dropdown-footer-list
- if can? current_user, :admin_milestone, project
%li
- = link_to new_project_milestone_path(project), title: "New Milestone" do
- Create new
+ = link_to new_project_milestone_path(project), title: _('New Milestone') do
+ = _('Create new')
%li
= link_to project_milestones_path(project) do
- if can? current_user, :admin_milestone, project
- Manage milestones
+ = _('Manage milestones')
- else
- View milestones
+ = _('View milestones')
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 62539bfeffd..9a703b9d355 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -29,13 +29,17 @@
= render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type
- if issuable_sidebar[:supports_milestone]
- .block.milestone{ :class => ("gl-border-b-0!" if in_group_context_with_iterations), data: { qa_selector: 'milestone_block' } }
+ .block.milestone{ :class => ("gl-border-b-0!" if in_group_context_with_iterations), data: { qa_selector: 'milestone_block', testid: 'sidebar-milestones' } }
.js-milestone-select{ 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
.block{ class: 'gl-pt-0! gl-collapse-empty', data: { qa_selector: 'iteration_container', testid: 'iteration_container' } }<
= render_if_exists 'shared/issuable/iteration_select', can_edit: can_edit_issuable.to_s, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type
+ - if @show_crm_contacts
+ .block.contact
+ #js-issue-crm-contacts{ data: { issue_id: issuable_sidebar[:id] } }
+
- if issuable_sidebar[:supports_time_tracking]
#issuable-time-tracker.block
// Fallback while content is loading
diff --git a/app/views/shared/issuable/form/_default_templates.html.haml b/app/views/shared/issuable/form/_default_templates.html.haml
index 3dc244677e2..50f30e58b35 100644
--- a/app/views/shared/issuable/form/_default_templates.html.haml
+++ b/app/views/shared/issuable/form/_default_templates.html.haml
@@ -1,4 +1,5 @@
%p.form-text.text-muted
- Add
- = link_to 'description templates', help_page_path('user/project/description_templates')
- to help your contributors communicate effectively!
+ - template_link_url = help_page_path('user/project/description_templates')
+ - template_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: template_link_url }
+ = s_('Promotions|Add %{link_start} description templates %{link_end} to help your contributors to communicate effectively!').html_safe % { link_start: template_link_start, link_end: '</a>'.html_safe }
+
diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml
index 2f05d272ca3..9e42c528a11 100644
--- a/app/views/shared/issuable/form/_metadata.html.haml
+++ b/app/views/shared/issuable/form/_metadata.html.haml
@@ -10,7 +10,7 @@
.form-check
= form.check_box :confidential, class: 'form-check-input'
= form.label :confidential, class: 'form-check-label' do
- This issue is confidential and should only be visible to team members with at least Reporter access.
+ #{_('This issue is confidential and should only be visible to team members with at least Reporter access.')}
- if can?(current_user, :"set_#{issuable.to_ability_name}_metadata", issuable)
%hr
@@ -27,13 +27,13 @@
- if issuable.supports_milestone?
.form-group.row.issue-milestone
- = form.label :milestone_id, "Milestone", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}"
+ = form.label :milestone_id, _('Milestone'), class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}"
.col-sm-10{ class: ("col-md-8" if has_due_date) }
.issuable-form-select-holder
- = render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, show_started: false, extra_class: "qa-issuable-milestone-dropdown js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: "Select milestone"
+ = render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, show_started: false, extra_class: "qa-issuable-milestone-dropdown js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: _('Select milestone')
.form-group.row
- = form.label :label_ids, "Labels", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}"
+ = form.label :label_ids, _('Labels'), class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}"
= form.hidden_field :label_ids, multiple: true, value: ''
.col-sm-10{ class: "#{"col-md-8" if has_due_date}" }
.issuable-form-select-holder
@@ -45,7 +45,7 @@
.col-lg-6
= render_if_exists "shared/issuable/form/weight", issuable: issuable, form: form
.form-group.row
- = form.label :due_date, "Due date", class: "col-form-label col-md-2 col-lg-4"
+ = form.label :due_date, _('Due date'), class: "col-form-label col-md-2 col-lg-4"
.col-8
.issuable-form-select-holder
- = form.text_field :due_date, id: "issuable-due-date", class: "datepicker form-control", placeholder: "Select due date", autocomplete: 'off'
+ = form.text_field :due_date, id: "issuable-due-date", class: "datepicker form-control", placeholder: _('Select due date'), autocomplete: 'off'
diff --git a/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml
index b437ee1ec5f..781ee8b5f80 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 qa-assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}"
+ = link_to _('Assign to me'), '#', class: "assign-to-me-link qa-assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}"
diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml
index 561ca0afd60..c0a6322eb1b 100644
--- a/app/views/shared/issuable/form/_title.html.haml
+++ b/app/views/shared/issuable/form/_title.html.haml
@@ -5,7 +5,7 @@
- div_class = no_issuable_templates ? 'col-sm-10' : 'col-sm-7 col-lg-8'
- toggle_wip_link_start = '<a href="" class="js-toggle-wip">'
- toggle_wip_link_end = '</a>'
-- add_wip_text = (_('%{link_start}Start the title with %{draft_snippet}%{link_end} to prevent a merge request that is a work in progress from being merged before it\'s ready.') % { link_start: toggle_wip_link_start, link_end: toggle_wip_link_end, draft_snippet: '<code>Draft:</code>'.html_safe } ).html_safe
+- add_wip_text = (_('%{link_start}Start the title with %{draft_snippet}%{link_end} to prevent a merge request draft from merging before it\'s ready.') % { link_start: toggle_wip_link_start, link_end: toggle_wip_link_end, draft_snippet: '<code>Draft:</code>'.html_safe } ).html_safe
- remove_wip_text = (_('%{link_start}Remove the %{draft_snippet} prefix%{link_end} from the title to allow this merge request to be merged when it\'s ready.' ) % { link_start: toggle_wip_link_start, link_end: toggle_wip_link_end, draft_snippet: '<code>Draft</code>'.html_safe } ).html_safe
%div{ class: div_class }
diff --git a/app/views/shared/issuable/form/_type_selector.html.haml b/app/views/shared/issuable/form/_type_selector.html.haml
index ae0fe54de4f..0d86aa8c8e7 100644
--- a/app/views/shared/issuable/form/_type_selector.html.haml
+++ b/app/views/shared/issuable/form/_type_selector.html.haml
@@ -1,7 +1,7 @@
- return unless issuable.supports_issue_type? && can?(current_user, :create_issue, @project)
.form-group.row.gl-mb-0
- = form.label :type, 'Type', class: 'col-form-label col-sm-2'
+ = form.label :type, _('Type'), class: 'col-form-label col-sm-2'
.col-sm-10
.gl-display-flex.gl-align-items-center
.issuable-form-select-holder.selectbox.form-group.gl-mb-0
diff --git a/app/views/shared/members/_filter_2fa_dropdown.html.haml b/app/views/shared/members/_filter_2fa_dropdown.html.haml
deleted file mode 100644
index 8187a9bde15..00000000000
--- a/app/views/shared/members/_filter_2fa_dropdown.html.haml
+++ /dev/null
@@ -1,11 +0,0 @@
-- filter = params[:two_factor] || 'everyone'
-- filter_options = { 'everyone' => _('Everyone'), 'enabled' => _('Enabled'), 'disabled' => _('Disabled') }
-.dropdown.inline.member-filter-2fa-dropdown{ data: { testid: 'member-filter-2fa-dropdown' } }
- = dropdown_toggle(filter_options[filter], { toggle: 'dropdown', testid: 'dropdown-toggle' })
- %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable
- %li.dropdown-header
- = _("Filter by two-factor authentication")
- - filter_options.each do |value, title|
- %li
- = link_to filter_group_project_member_path(two_factor: value), class: ("is-active" if filter == value) do
- = title
diff --git a/app/views/shared/members/_requests.html.haml b/app/views/shared/members/_requests.html.haml
index 8b0a85656dc..ec08dde37bf 100644
--- a/app/views/shared/members/_requests.html.haml
+++ b/app/views/shared/members/_requests.html.haml
@@ -9,7 +9,7 @@
.card-header
= _("Users requesting access to")
%strong= membership_source.name
- %span.badge.badge-pill= requesters.size
+ = gl_badge_tag requesters.size
= render 'shared/members/manage_access_button', path: membership_source.is_a?(Project) ? project_project_members_path(@project, tab: 'access_requests') : group_group_members_path(@group, tab: 'access_requests')
%ul.content-list.members-list
= render partial: 'shared/members/member',
diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml
index 44934a12559..4e06b7902bd 100644
--- a/app/views/shared/milestones/_milestone.html.haml
+++ b/app/views/shared/milestones/_milestone.html.haml
@@ -33,11 +33,9 @@
%div
= render('shared/milestone_expired', milestone: milestone)
- if milestone.group_milestone?
- .gl-badge.badge-info.badge-pill
- = milestone.group.full_name
+ = gl_badge_tag milestone.group.full_name, variant: :info
- if milestone.project_milestone?
- .gl-badge.badge-muted.badge-pill
- = milestone.project.full_name
+ = gl_badge_tag milestone.project.full_name, variant: :muted
.col-sm-4.milestone-progress
= milestone_progress_bar(milestone)
diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index c66ba5ba2e1..a1e94172ec3 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -79,7 +79,7 @@
%span= milestone.issues_visible_to_user(current_user).count
.title.hide-collapsed
= s_('MilestoneSidebar|Issues')
- %span.badge.badge-muted.badge-pill.gl-badge.sm= milestone.issues_visible_to_user(current_user).count
+ = gl_badge_tag milestone.issues_visible_to_user(current_user).count, variant: :muted, size: :sm
- if show_new_issue_link?(project)
= link_to new_project_issue_path(project, issue: { milestone_id: milestone.id }), class: "float-right", title: s_('MilestoneSidebar|New Issue') do
= s_('MilestoneSidebar|New issue')
@@ -111,7 +111,7 @@
%span= milestone.merge_requests.count
.title.hide-collapsed
= s_('MilestoneSidebar|Merge requests')
- %span.badge.badge-muted.badge-pill.gl-badge.sm= milestone.merge_requests.count
+ = gl_badge_tag milestone.merge_requests.count, variant: :muted, size: :sm
.value.hide-collapsed.bold
- if !project || can?(current_user, :read_merge_request, project)
%span.milestone-stat
diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml
index 3524a1b17ea..8c49977fe82 100644
--- a/app/views/shared/milestones/_tabs.html.haml
+++ b/app/views/shared/milestones/_tabs.html.haml
@@ -3,24 +3,20 @@
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
.fade-left= sprite_icon('chevron-lg-left', size: 12)
.fade-right= sprite_icon('chevron-lg-right', size: 12)
- %ul.nav-links.scrolling-tabs.js-milestone-tabs.nav.nav-tabs
- %li.nav-item
- = link_to '#tab-issues', class: 'nav-link active', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'issues', show_project_name: show_project_name) } do
- = _('Issues')
- %span.badge.badge-pill= milestone.issues_visible_to_user(current_user).size
+ = gl_tabs_nav({ class: %w[scrolling-tabs js-milestone-tabs] }) do
+ = gl_tab_link_to '#tab-issues', item_active: true, data: { endpoint: milestone_tab_path(milestone, 'issues', show_project_name: show_project_name) } do
+ = _('Issues')
+ = gl_tab_counter_badge milestone.issues_visible_to_user(current_user).size
- if milestone.merge_requests_enabled?
- %li.nav-item
- = link_to '#tab-merge-requests', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'merge_requests', show_project_name: show_project_name) } do
- = _('Merge requests')
- %span.badge.badge-pill= milestone.merge_requests_visible_to_user(current_user).size
- %li.nav-item
- = link_to '#tab-participants', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'participants') } do
- = _('Participants')
- %span.badge.badge-pill= milestone.issue_participants_visible_by_user(current_user).count
- %li.nav-item
- = link_to '#tab-labels', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'labels') } do
- = _('Labels')
- %span.badge.badge-pill= milestone.issue_labels_visible_by_user(current_user).count
+ = gl_tab_link_to '#tab-merge-requests', data: { endpoint: milestone_tab_path(milestone, 'merge_requests', show_project_name: show_project_name) } do
+ = _('Merge requests')
+ = gl_tab_counter_badge milestone.merge_requests_visible_to_user(current_user).size
+ = gl_tab_link_to '#tab-participants', data: { endpoint: milestone_tab_path(milestone, 'participants') } do
+ = _('Participants')
+ = gl_tab_counter_badge milestone.issue_participants_visible_by_user(current_user).count
+ = gl_tab_link_to '#tab-labels', data: { endpoint: milestone_tab_path(milestone, 'labels') } do
+ = _('Labels')
+ = gl_tab_counter_badge milestone.issue_labels_visible_by_user(current_user).count
.tab-content.milestone-content
.tab-pane.active#tab-issues
diff --git a/app/views/shared/nav/_scope_menu.html.haml b/app/views/shared/nav/_scope_menu.html.haml
index 1a7089fb570..4e570086bf8 100644
--- a/app/views/shared/nav/_scope_menu.html.haml
+++ b/app/views/shared/nav/_scope_menu.html.haml
@@ -1,5 +1,5 @@
= nav_link(**scope_menu.active_routes, html_options: scope_menu.nav_link_html_options) do
- = link_to scope_menu.link, **scope_menu.container_html_options, data: { qa_selector: 'sidebar_menu_link', qa_menu_item: scope_qa_menu_item(scope_menu.container) } do
+ = link_to scope_menu.link, **scope_menu.link_html_options, data: { qa_selector: 'sidebar_menu_link', qa_menu_item: scope_qa_menu_item(scope_menu.container) } do
%span{ class: scope_avatar_classes(scope_menu.container) }
= source_icon(scope_menu.container, alt: scope_menu.title, class: ['avatar', 'avatar-tile', 's32'], width: 32, height: 32)
%span.sidebar-context-title
diff --git a/app/views/shared/nav/_sidebar_hidden_menu_item.html.haml b/app/views/shared/nav/_sidebar_hidden_menu_item.html.haml
index 953f7a8ae60..d0ae5e99707 100644
--- a/app/views/shared/nav/_sidebar_hidden_menu_item.html.haml
+++ b/app/views/shared/nav/_sidebar_hidden_menu_item.html.haml
@@ -1,3 +1,3 @@
%li.hidden
- = link_to sidebar_hidden_menu_item.link, **sidebar_hidden_menu_item.container_html_options do
+ = link_to sidebar_hidden_menu_item.link, **sidebar_hidden_menu_item.link_html_options do
= sidebar_hidden_menu_item.title
diff --git a/app/views/shared/nav/_sidebar_menu.html.haml b/app/views/shared/nav/_sidebar_menu.html.haml
index 3f71368aff3..4c4ceb9ea70 100644
--- a/app/views/shared/nav/_sidebar_menu.html.haml
+++ b/app/views/shared/nav/_sidebar_menu.html.haml
@@ -2,7 +2,7 @@
- if sidebar_menu.menu_with_partial?
= render_if_exists sidebar_menu.menu_partial, **sidebar_menu.menu_partial_options
- else
- = link_to sidebar_menu.link, **sidebar_menu.container_html_options, data: { qa_selector: 'sidebar_menu_link', qa_menu_item: sidebar_menu.title } do
+ = link_to sidebar_menu.link, **sidebar_menu.link_html_options, data: { qa_selector: 'sidebar_menu_link', qa_menu_item: sidebar_menu.title } do
- if sidebar_menu.icon_or_image?
%span.nav-icon-container
- if sidebar_menu.image_path
diff --git a/app/views/shared/nav/_sidebar_menu_item.html.haml b/app/views/shared/nav/_sidebar_menu_item.html.haml
index 674ce593ee2..5452cd486da 100644
--- a/app/views/shared/nav/_sidebar_menu_item.html.haml
+++ b/app/views/shared/nav/_sidebar_menu_item.html.haml
@@ -1,5 +1,5 @@
= nav_link(**sidebar_menu_item.active_routes, html_options: sidebar_menu_item.nav_link_html_options) do
- = link_to sidebar_menu_item.link, **sidebar_menu_item.container_html_options, data: { qa_selector: 'sidebar_menu_item_link', qa_menu_item: sidebar_menu_item.title } do
+ = link_to sidebar_menu_item.link, **sidebar_menu_item.link_html_options, data: { qa_selector: 'sidebar_menu_item_link', qa_menu_item: sidebar_menu_item.title } do
%span
= sidebar_menu_item.title
- if sidebar_menu_item.sprite_icon
diff --git a/app/views/shared/projects/_archived.html.haml b/app/views/shared/projects/_archived.html.haml
index f24fe3a8b89..018bf137cc6 100644
--- a/app/views/shared/projects/_archived.html.haml
+++ b/app/views/shared/projects/_archived.html.haml
@@ -1,3 +1,2 @@
- if project.archived
- %span.d-flex.badge-pill.gl-badge.badge-warning.gl-ml-3
- = _('archived')
+ = gl_badge_tag _('archived'), { variant: :warning }, { class: 'gl-display-flex gl-ml-3' }
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index 2136d287f53..ae264f2188f 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -31,7 +31,7 @@
.flex-wrapper
.d-flex.align-items-center.flex-wrap.project-title
%h2.d-flex.gl-mt-3
- = link_to project_path(project), class: 'text-plain' do
+ = link_to project_path(project), class: 'text-plain js-prefetch-document' do
%span.project-full-name.gl-mr-3><
%span.namespace-name
- if project.namespace && !skip_namespace
diff --git a/app/views/shared/projects/_topics.html.haml b/app/views/shared/projects/_topics.html.haml
index e303f05c5df..b7df369327c 100644
--- a/app/views/shared/projects/_topics.html.haml
+++ b/app/views/shared/projects/_topics.html.haml
@@ -1,6 +1,5 @@
- cache_enabled = false unless local_assigns[:cache_enabled] == true
- max_project_topic_length = 15
-- project_topics_classes = "badge badge-pill badge-secondary gl-mr-2"
- if project.topics.present?
= cache_if(cache_enabled, [project, :topic_list], expires_in: 1.day) do
@@ -10,11 +9,11 @@
- project.topics_to_show.each do |topic|
- explore_project_topic_path = topic_explore_projects_path(topic_name: topic)
- if topic.length > max_project_topic_length
- %a{ class: "#{ project_topics_classes } str-truncated-30 has-tooltip", data: { container: "body" }, title: topic, href: explore_project_topic_path, itemprop: 'keywords' }
- = truncate(topic, length: max_project_topic_length)
+ %a.gl-mr-3.has-tooltip{ data: { container: "body" }, title: topic, href: explore_project_topic_path, itemprop: 'keywords' }
+ = gl_badge_tag truncate(topic, length: max_project_topic_length)
- else
- %a{ class: project_topics_classes, href: explore_project_topic_path, itemprop: 'keywords' }
- = topic
+ %a.gl-mr-3{ href: explore_project_topic_path, itemprop: 'keywords' }
+ = gl_badge_tag topic
- if project.has_extra_topics?
- title = _('More topics')
@@ -23,10 +22,10 @@
- project.topics_not_shown.each do |topic|
- explore_project_topic_path = topic_explore_projects_path(topic_name: topic)
- if topic.length > max_project_topic_length
- %a{ class: "#{ project_topics_classes } gl-mb-3 str-truncated has-tooltip", data: { container: "body" }, title: topic, href: explore_project_topic_path, itemprop: 'keywords' }
- = truncate(topic, length: max_project_topic_length)
+ %a.gl-mr-3.gl-mb-3.has-tooltip{ data: { container: "body" }, title: topic, href: explore_project_topic_path, itemprop: 'keywords' }
+ = gl_badge_tag truncate(topic, length: max_project_topic_length)
- else
- %a{ class: "#{ project_topics_classes } gl-mb-3", href: explore_project_topic_path, itemprop: 'keywords' }
- = topic
+ %a.gl-mr-3.gl-mb-3{ href: explore_project_topic_path, itemprop: 'keywords' }
+ = gl_badge_tag topic
.text-nowrap{ role: 'button', tabindex: 0, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } }
= _("+ %{count} more") % { count: project.count_of_extra_topics_not_shown }
diff --git a/app/views/shared/runners/_runner_description.html.haml b/app/views/shared/runners/_runner_description.html.haml
index 6a65145d42b..436dbfd2b49 100644
--- a/app/views/shared/runners/_runner_description.html.haml
+++ b/app/views/shared/runners/_runner_description.html.haml
@@ -5,8 +5,8 @@
%div
%ul
%li
- %span.badge.badge-pill.gl-badge.sm.badge-success active
+ = gl_badge_tag s_("Runners|active"), variant: :success, size: :sm
= _('- Available to run jobs.')
%li
- %span.badge.badge-pill.gl-badge.sm.badge-danger paused
+ = gl_badge_tag s_("Runners|paused"), variant: :danger, size: :sm
= _('- Not available to run jobs.')
diff --git a/app/views/shared/runners/_runner_details.html.haml b/app/views/shared/runners/_runner_details.html.haml
index a7b2947057d..7a35b1cec0a 100644
--- a/app/views/shared/runners/_runner_details.html.haml
+++ b/app/views/shared/runners/_runner_details.html.haml
@@ -28,8 +28,7 @@
%td= s_('Runners|Tags')
%td
- runner.tag_list.sort.each do |tag|
- %span.badge.badge-primary
- = tag
+ = gl_badge_tag tag, variant: :info
%tr
%td= s_('Runners|Name')
%td= runner.name
diff --git a/app/views/shared/runners/_runner_type_badge.html.haml b/app/views/shared/runners/_runner_type_badge.html.haml
index e0318006f09..c6a18c804da 100644
--- a/app/views/shared/runners/_runner_type_badge.html.haml
+++ b/app/views/shared/runners/_runner_type_badge.html.haml
@@ -1,10 +1,7 @@
- if runner.instance_type?
- %span.badge.badge-pill.gl-badge.badge-success
- = s_('Runners|shared')
+ = gl_badge_tag s_('Runners|shared'), variant: :success
- elsif runner.group_type?
- %span.badge.badge-pill.gl-badge.badge-success
- = s_('Runners|group')
+ = gl_badge_tag s_('Runners|group'), variant: :success
- else
- %span.badge.badge-pill.gl-badge.badge-info
- = s_('Runners|specific')
+ = gl_badge_tag s_('Runners|specific'), variant: :info
diff --git a/app/views/shared/snippets/_embed.html.haml b/app/views/shared/snippets/_embed.html.haml
index b5abd00b8fd..5744fc9fba6 100644
--- a/app/views/shared/snippets/_embed.html.haml
+++ b/app/views/shared/snippets/_embed.html.haml
@@ -17,5 +17,5 @@
= embedded_raw_snippet_button(@snippet, blob)
= embedded_snippet_download_button(@snippet, blob)
- %article.file-holder.snippet-file-content
+ %figure.file-holder.snippet-file-content{ "aria-label" => _('Code snippet') }
= render 'projects/blob/viewer', viewer: blob.simple_viewer, load_async: false, external_embed: true
diff --git a/app/views/shared/web_hooks/_hook.html.haml b/app/views/shared/web_hooks/_hook.html.haml
index fd124c2967d..45baa7e2184 100644
--- a/app/views/shared/web_hooks/_hook.html.haml
+++ b/app/views/shared/web_hooks/_hook.html.haml
@@ -1,7 +1,15 @@
%li
.row
.col-md-8.col-lg-7
- %strong.light-header= hook.url
+ %strong.light-header
+ = hook.url
+ - if hook.rate_limited?
+ %span.gl-badge.badge-danger.badge-pill.sm= _('Disabled')
+ - elsif hook.permanently_disabled?
+ %span.gl-badge.badge-danger.badge-pill.sm= s_('Webhooks|Failed to connect')
+ - elsif hook.temporarily_disabled?
+ %span.gl-badge.badge-warning.badge-pill.sm= s_('Webhooks|Fails to connect')
+
%div
- hook.class.triggers.each_value do |trigger|
- if hook.public_send(trigger)
diff --git a/app/views/shared/web_hooks/_hook_errors.html.haml b/app/views/shared/web_hooks/_hook_errors.html.haml
new file mode 100644
index 00000000000..23010b8349c
--- /dev/null
+++ b/app/views/shared/web_hooks/_hook_errors.html.haml
@@ -0,0 +1,41 @@
+- strong_start = '<strong>'.html_safe
+- strong_end = '</strong>'.html_safe
+- link_start = '<a href="%{url}">'.html_safe
+- link_end = '</a>'.html_safe
+
+- if hook.rate_limited?
+ - support_path = 'https://support.gitlab.com/hc/en-us/requests/new'
+ - placeholders = { strong_start: strong_start,
+ strong_end: strong_end,
+ limit: hook.rate_limit,
+ support_link_start: link_start % { url: support_path },
+ support_link_end: link_end }
+ = render 'shared/global_alert',
+ title: s_('Webhooks|Webhook was automatically disabled'),
+ variant: :danger,
+ is_contained: true,
+ close_button_class: 'js-close' do
+ .gl-alert-body
+ = s_('Webhooks|The webhook was triggered more than %{limit} times per minute and is now disabled. To re-enable this webhook, fix the problems shown in %{strong_start}Recent events%{strong_end}, then re-test your settings. %{support_link_start}Contact Support%{support_link_end} if you need help re-enabling your webhook.').html_safe % placeholders
+- elsif hook.permanently_disabled?
+ = render 'shared/global_alert',
+ title: s_('Webhooks|Webhook failed to connect'),
+ variant: :danger,
+ is_contained: true,
+ close_button_class: 'js-close' do
+ .gl-alert-body
+ = s_('Webhooks|The webhook failed to connect, and is disabled. To re-enable it, check %{strong_start}Recent events%{strong_end} for error details, then test your settings below.').html_safe % { strong_start: strong_start, strong_end: strong_end }
+- elsif hook.temporarily_disabled?
+ - help_path = help_page_path('user/project/integrations/webhooks', anchor: 'webhook-fails-or-multiple-webhook-requests-are-triggered')
+ - placeholders = { strong_start: strong_start,
+ strong_end: strong_end,
+ retry_time: time_interval_in_words(hook.disabled_until - Time.now),
+ help_link_start: link_start % { url: help_path },
+ help_link_end: link_end }
+ = render 'shared/global_alert',
+ title: s_('Webhooks|Webhook fails to connect'),
+ variant: :warning,
+ is_contained: true,
+ close_button_class: 'js-close' do
+ .gl-alert-body
+ = s_('Webhooks|The webhook %{help_link_start}failed to connect%{help_link_end}, and will retry in %{retry_time}. To re-enable it, check %{strong_start}Recent events%{strong_end} for error details, then test your settings below.').html_safe % placeholders
diff --git a/app/views/shared/web_hooks/_title_and_docs.html.haml b/app/views/shared/web_hooks/_title_and_docs.html.haml
index f00f3473efa..c220b46f70f 100644
--- a/app/views/shared/web_hooks/_title_and_docs.html.haml
+++ b/app/views/shared/web_hooks/_title_and_docs.html.haml
@@ -4,7 +4,7 @@
= page_title
- if @project
- - integrations_link_start = '<a href="%{url}">'.html_safe % { url: scoped_integrations_path }
+ - integrations_link_start = '<a href="%{url}">'.html_safe % { url: scoped_integrations_path(project: @project) }
%p= _("%{webhooks_link_start}%{webhook_type}%{link_end} enable you to send notifications to web applications in response to events in a group or project. We recommend using an %{integrations_link_start}integration%{link_end} in preference to a webhook.").html_safe % { webhooks_link_start: webhooks_link_start, webhook_type: hook.pluralized_name, integrations_link_start: integrations_link_start, link_end: '</a>'.html_safe }
- else
%p= _("%{webhooks_link_start}%{webhook_type}%{link_end} enable you to send notifications to web applications in response to events in a group or project.").html_safe % { webhooks_link_start: webhooks_link_start, webhook_type: hook.pluralized_name, link_end: '</a>'.html_safe }
diff --git a/app/views/shared/wikis/_wiki_content.html.haml b/app/views/shared/wikis/_wiki_content.html.haml
new file mode 100644
index 00000000000..42e8037bb0f
--- /dev/null
+++ b/app/views/shared/wikis/_wiki_content.html.haml
@@ -0,0 +1,2 @@
+.js-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki_page_content', tracking_context: wiki_page_tracking_context(@page).to_json } }
+ = render_wiki_content(@page)
diff --git a/app/views/shared/wikis/show.html.haml b/app/views/shared/wikis/show.html.haml
index 8a5cd94bde9..e6980aae3e1 100644
--- a/app/views/shared/wikis/show.html.haml
+++ b/app/views/shared/wikis/show.html.haml
@@ -26,7 +26,6 @@
%div
- if can?(current_user, :create_wiki, @wiki.container) && @page.latest? && @valid_encoding
= link_to sprite_icon('pencil', css_class: 'gl-icon'), wiki_page_path(@wiki, @page, action: :edit), title: 'Edit', role: "button", class: 'btn gl-button btn-icon btn-default js-wiki-edit', data: { qa_selector: 'edit_page_button', testid: 'wiki_edit_button' }
- .js-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki_page_content', tracking_context: wiki_page_tracking_context(@page).to_json } }
- = render_wiki_content(@page)
+ = render 'shared/wikis/wiki_content'
= render 'shared/wikis/sidebar'
diff --git a/app/views/snippets/_snippets_scope_menu.html.haml b/app/views/snippets/_snippets_scope_menu.html.haml
index ac6dac8b322..35d21e45c47 100644
--- a/app/views/snippets/_snippets_scope_menu.html.haml
+++ b/app/views/snippets/_snippets_scope_menu.html.haml
@@ -1,31 +1,18 @@
- subject = local_assigns.fetch(:subject, current_user)
- include_private = local_assigns.fetch(:include_private, false)
+- params[:scope] ||= []
-.nav-links.snippet-scope-menu.mobile-separator.nav.nav-tabs
- %li{ class: active_when(params[:scope].nil?) }
- = link_to subject_snippets_path(subject) do
- = _("All")
- %span.badge.badge-muted.badge-pill.gl-badge.sm
- - if include_private
- = counts[:total]
- - else
- = counts[:are_public_or_internal]
-
+= gl_tabs_nav({ class: 'js-snippets-nav-tabs gl-border-b-0 gl-overflow-x-auto gl-flex-grow-1 gl-flex-nowrap' }) do
+ = gl_tab_link_to subject_snippets_path(subject), { item_active: params[:scope].empty? } do
+ = _('All')
+ = gl_tab_counter_badge(include_private ? counts[:total] : counts[:are_public_or_internal])
- if include_private
- %li{ class: active_when(params[:scope] == "are_private") }
- = link_to subject_snippets_path(subject, scope: 'are_private') do
- = _("Private")
- %span.badge.badge-muted.badge-pill.gl-badge.sm
- = counts[:are_private]
-
- %li{ class: active_when(params[:scope] == "are_internal") }
- = link_to subject_snippets_path(subject, scope: 'are_internal') do
- = _("Internal")
- %span.badge.badge-muted.badge-pill.gl-badge.sm
- = counts[:are_internal]
-
- %li{ class: active_when(params[:scope] == "are_public") }
- = link_to subject_snippets_path(subject, scope: 'are_public') do
- = _("Public")
- %span.badge.badge-muted.badge-pill.gl-badge.sm
- = counts[:are_public]
+ = gl_tab_link_to subject_snippets_path(subject, scope: 'are_private') do
+ = _('Private')
+ = gl_tab_counter_badge(counts[:are_private])
+ = gl_tab_link_to subject_snippets_path(subject, scope: 'are_internal') do
+ = _('Internal')
+ = gl_tab_counter_badge(counts[:are_internal])
+ = gl_tab_link_to subject_snippets_path(subject, scope: 'are_public') do
+ = _('Public')
+ = gl_tab_counter_badge(counts[:are_public])
diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml
index 0d904de9372..7e745efd069 100644
--- a/app/views/users/_overview.html.haml
+++ b/app/views/users/_overview.html.haml
@@ -2,14 +2,14 @@
.row.d-none.d-sm-flex
.col-12.calendar-block.gl-my-3
- .user-calendar.light{ data: { calendar_path: user_calendar_path(@user, :json), calendar_activities_path: user_calendar_activities_path, utc_offset: local_time_instance(@user.timezone).now.utc_offset } }
+ .user-calendar.light{ data: { calendar_path: user_calendar_path(@user, :json), calendar_activities_path: user_calendar_activities_path, utc_offset: local_timezone_instance(@user.timezone).now.utc_offset } }
.gl-spinner.gl-spinner-md.gl-my-8
.user-calendar-error.invisible
= _('There was an error loading users activity calendar.')
%a.js-retry-load{ href: '#' }
= s_('UserProfile|Retry')
.user-calendar-activities
-- if @user.user_readme
+- if @user.user_readme&.rich_viewer
.row.justify-content-center
.col-12.col-md-10.col-lg-8.gl-my-6
.gl-display-flex
diff --git a/app/views/users/calendar_activities.html.haml b/app/views/users/calendar_activities.html.haml
index cffeb55597f..8da1aa09215 100644
--- a/app/views/users/calendar_activities.html.haml
+++ b/app/views/users/calendar_activities.html.haml
@@ -7,7 +7,7 @@
%li
%span.light.js-localtime{ :data => { :datetime => event.created_at.utc.strftime('%Y-%m-%dT%H:%M:%SZ'), :toggle => 'tooltip', :placement => 'top' } }
= sprite_icon('clock', css_class: 'gl-vertical-align-text-bottom')
- = event.created_at.to_time.in_time_zone(@user.timezone).strftime('%-I:%M%P')
+ = event.created_at.to_time.in_time_zone(local_timezone_instance(@user.timezone)).strftime('%-I:%M%P')
- if event.visible_to_user?(current_user)
- if event.push_action?
#{event.action_name} #{event.ref_type}
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index 522f0f771cd..ca276519758 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -2,8 +2,7 @@
- @hide_breadcrumbs = true
- @no_container = true
- page_title user_display_name(@user)
-- page_description @user.bio
-- header_title @user.name, user_path(@user)
+- page_description @user.bio unless @user.blocked? || !@user.confirmed?
- page_itemtype 'http://schema.org/Person'
- link_classes = "flex-grow-1 mx-1 "
diff --git a/app/views/users/terms/index.html.haml b/app/views/users/terms/index.html.haml
index 92095e78f69..c461250fc9b 100644
--- a/app/views/users/terms/index.html.haml
+++ b/app/views/users/terms/index.html.haml
@@ -1,23 +1,6 @@
-- redirect_params = { redirect: @redirect } if @redirect
-- accept_term_link = accept_term_path(@term, redirect_params)
+- content_for :page_specific_javascripts do
+ = render "layouts/google_tag_manager_head"
+ = render "layouts/one_trust"
+= render "layouts/google_tag_manager_body"
-- if Feature.enabled?(:terms_of_service_vue, current_user, default_enabled: :yaml)
- #js-terms-of-service{ data: { terms_data: terms_data(@term, @redirect) } }
-- else
- .card-body.rendered-terms{ data: { qa_selector: 'terms_content' } }
- = markdown_field(@term, :terms)
- - if current_user
- = render_if_exists 'devise/shared/form_phone_verification', accept_term_link: accept_term_link, inline: true
- .card-footer.footer-block.clearfix
- - if can?(current_user, :accept_terms, @term)
- .float-right
- = button_to accept_term_link, class: 'gl-button btn btn-confirm gl-ml-3', data: { qa_selector: 'accept_terms_button' } do
- = _('Accept terms')
- - else
- .float-right
- = link_to root_path, class: 'gl-button btn btn-confirm gl-ml-3' do
- = _('Continue')
- - if can?(current_user, :decline_terms, @term)
- .float-right
- = button_to decline_term_path(@term, redirect_params), class: 'gl-button btn btn-default gl-ml-3' do
- = _('Decline and sign out')
+#js-terms-of-service{ data: { terms_data: terms_data(@term, @redirect) } }
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 699744b355c..e5ac9da37c6 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -1447,6 +1447,24 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: pipeline_background:ci_pending_builds_update_group
+ :worker_name: Ci::PendingBuilds::UpdateGroupWorker
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
+- :name: pipeline_background:ci_pending_builds_update_project
+ :worker_name: Ci::PendingBuilds::UpdateProjectWorker
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: pipeline_background:ci_pipeline_artifacts_coverage_report
:worker_name: Ci::PipelineArtifacts::CoverageReportWorker
:feature_category: :code_testing
@@ -1458,7 +1476,7 @@
:tags: []
- :name: pipeline_background:ci_pipeline_artifacts_create_quality_report
:worker_name: Ci::PipelineArtifacts::CreateQualityReportWorker
- :feature_category: :code_testing
+ :feature_category: :code_quality
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
@@ -1559,7 +1577,7 @@
:worker_name: Ci::CreateDownstreamPipelineWorker
:feature_category: :continuous_integration
:has_external_dependencies:
- :urgency: :low
+ :urgency: :high
:resource_boundary: :cpu
:weight: 3
:idempotent:
@@ -1913,7 +1931,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent: true
:tags: []
- :name: bulk_imports_export_request
:worker_name: BulkImports::ExportRequestWorker
@@ -2474,6 +2492,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: namespaces_process_sync_events
+ :worker_name: Namespaces::ProcessSyncEventsWorker
+ :feature_category: :sharding
+ :has_external_dependencies:
+ :urgency: :high
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: new_issue
:worker_name: NewIssueWorker
:feature_category: :team_planning
@@ -2645,6 +2672,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: projects_process_sync_events
+ :worker_name: Projects::ProcessSyncEventsWorker
+ :feature_category: :sharding
+ :has_external_dependencies:
+ :urgency: :high
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: projects_schedule_bulk_repository_shard_moves
:worker_name: Projects::ScheduleBulkRepositoryShardMovesWorker
:feature_category: :gitaly
@@ -2717,15 +2753,6 @@
:weight: 1
:idempotent: true
:tags: []
-- :name: propagate_service_template
- :worker_name: PropagateServiceTemplateWorker
- :feature_category: :integrations
- :has_external_dependencies:
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 1
- :idempotent:
- :tags: []
- :name: reactive_caching
:worker_name: ReactiveCachingWorker
:feature_category: :not_owned
diff --git a/app/workers/background_migration/single_database_worker.rb b/app/workers/background_migration/single_database_worker.rb
new file mode 100644
index 00000000000..b6661d4fd14
--- /dev/null
+++ b/app/workers/background_migration/single_database_worker.rb
@@ -0,0 +1,148 @@
+# frozen_string_literal: true
+
+module BackgroundMigration
+ module SingleDatabaseWorker
+ extend ActiveSupport::Concern
+
+ include ApplicationWorker
+
+ MAX_LEASE_ATTEMPTS = 5
+
+ included do
+ data_consistency :always
+
+ sidekiq_options retry: 3
+
+ feature_category :database
+ urgency :throttled
+ loggable_arguments 0, 1
+ end
+
+ class_methods do
+ # The minimum amount of time between processing two jobs of the same migration
+ # class.
+ #
+ # This interval is set to 2 or 5 minutes so autovacuuming and other
+ # maintenance related tasks have plenty of time to clean up after a migration
+ # has been performed.
+ def minimum_interval
+ 2.minutes.to_i
+ end
+
+ def tracking_database
+ raise NotImplementedError, "#{self.name} does not implement #{__method__}"
+ end
+
+ def unhealthy_metric_name
+ raise NotImplementedError, "#{self.name} does not implement #{__method__}"
+ end
+ end
+
+ # Performs the background migration.
+ #
+ # See Gitlab::BackgroundMigration.perform for more information.
+ #
+ # class_name - The class name of the background migration to run.
+ # arguments - The arguments to pass to the migration class.
+ # lease_attempts - The number of times we will try to obtain an exclusive
+ # lease on the class before giving up. See MR for more discussion.
+ # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45298#note_434304956
+ def perform(class_name, arguments = [], lease_attempts = MAX_LEASE_ATTEMPTS)
+ job_coordinator.with_shared_connection do
+ perform_with_connection(class_name, arguments, lease_attempts)
+ end
+ end
+
+ private
+
+ def job_coordinator
+ @job_coordinator ||= Gitlab::BackgroundMigration.coordinator_for_database(self.class.tracking_database)
+ end
+
+ def perform_with_connection(class_name, arguments, lease_attempts)
+ with_context(caller_id: class_name.to_s) do
+ retried = lease_attempts != MAX_LEASE_ATTEMPTS
+ attempts_left = lease_attempts - 1
+ should_perform, ttl = perform_and_ttl(class_name, attempts_left, retried)
+
+ break if should_perform.nil?
+
+ if should_perform
+ job_coordinator.perform(class_name, arguments)
+ else
+ # If the lease could not be obtained this means either another process is
+ # running a migration of this class or we ran one recently. In this case
+ # we'll reschedule the job in such a way that it is picked up again around
+ # the time the lease expires.
+ self.class
+ .perform_in(ttl || self.class.minimum_interval, class_name, arguments, attempts_left)
+ end
+ end
+ end
+
+ def perform_and_ttl(class_name, attempts_left, retried)
+ # In test environments `perform_in` will run right away. This can then
+ # lead to stack level errors in the above `#perform`. To work around this
+ # we'll just perform the migration right away in the test environment.
+ return [true, nil] if always_perform?
+
+ lease = lease_for(class_name, retried)
+ lease_obtained = !!lease.try_obtain
+ healthy_db = healthy_database?
+ perform = lease_obtained && healthy_db
+
+ database_unhealthy_counter.increment if lease_obtained && !healthy_db
+
+ # When the DB is unhealthy or the lease can't be obtained after several tries,
+ # then give up on the job and log a warning. Otherwise we could end up in
+ # an infinite rescheduling loop. Jobs can be tracked in the database with the
+ # use of Gitlab::Database::BackgroundMigrationJob
+ if !perform && attempts_left < 0
+ msg = if !lease_obtained
+ 'Job could not get an exclusive lease after several tries. Giving up.'
+ else
+ 'Database was unhealthy after several tries. Giving up.'
+ end
+
+ Sidekiq.logger.warn(class: class_name, message: msg, job_id: jid)
+
+ return [nil, nil]
+ end
+
+ [perform, lease.ttl]
+ end
+
+ def lease_for(class_name, retried)
+ Gitlab::ExclusiveLease
+ .new(lease_key_for(class_name, retried), timeout: self.class.minimum_interval)
+ end
+
+ def lease_key_for(class_name, retried)
+ key = "#{self.class.name}:#{class_name}"
+ # We use a different exclusive lock key for retried jobs to allow them running concurrently with the scheduled jobs.
+ # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68763 for more information.
+ key += ":retried" if retried
+ key
+ end
+
+ def always_perform?
+ Rails.env.test?
+ end
+
+ # Returns true if the database is healthy enough to allow the migration to be
+ # performed.
+ #
+ # class_name - The name of the background migration that we might want to
+ # run.
+ def healthy_database?
+ !Postgresql::ReplicationSlot.lag_too_great?
+ end
+
+ def database_unhealthy_counter
+ Gitlab::Metrics.counter(
+ self.class.unhealthy_metric_name,
+ 'The number of times a background migration is rescheduled because the database is unhealthy.'
+ )
+ end
+ end
+end
diff --git a/app/workers/background_migration_worker.rb b/app/workers/background_migration_worker.rb
index b771ab4d4e7..dea0d467eca 100644
--- a/app/workers/background_migration_worker.rb
+++ b/app/workers/background_migration_worker.rb
@@ -1,120 +1,13 @@
# frozen_string_literal: true
class BackgroundMigrationWorker # rubocop:disable Scalability/IdempotentWorker
- include ApplicationWorker
+ include BackgroundMigration::SingleDatabaseWorker
- MAX_LEASE_ATTEMPTS = 5
-
- data_consistency :always
-
- sidekiq_options retry: 3
-
- feature_category :database
- urgency :throttled
- loggable_arguments 0, 1
-
- # The minimum amount of time between processing two jobs of the same migration
- # class.
- #
- # This interval is set to 2 or 5 minutes so autovacuuming and other
- # maintenance related tasks have plenty of time to clean up after a migration
- # has been performed.
- def self.minimum_interval
- 2.minutes.to_i
- end
-
- # Performs the background migration.
- #
- # See Gitlab::BackgroundMigration.perform for more information.
- #
- # class_name - The class name of the background migration to run.
- # arguments - The arguments to pass to the migration class.
- # lease_attempts - The number of times we will try to obtain an exclusive
- # lease on the class before giving up. See MR for more discussion.
- # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45298#note_434304956
- def perform(class_name, arguments = [], lease_attempts = MAX_LEASE_ATTEMPTS)
- with_context(caller_id: class_name.to_s) do
- retried = lease_attempts != MAX_LEASE_ATTEMPTS
- attempts_left = lease_attempts - 1
- should_perform, ttl = perform_and_ttl(class_name, attempts_left, retried)
-
- break if should_perform.nil?
-
- if should_perform
- Gitlab::BackgroundMigration.perform(class_name, arguments)
- else
- # If the lease could not be obtained this means either another process is
- # running a migration of this class or we ran one recently. In this case
- # we'll reschedule the job in such a way that it is picked up again around
- # the time the lease expires.
- self.class
- .perform_in(ttl || self.class.minimum_interval, class_name, arguments, attempts_left)
- end
- end
- end
-
- def perform_and_ttl(class_name, attempts_left, retried)
- # In test environments `perform_in` will run right away. This can then
- # lead to stack level errors in the above `#perform`. To work around this
- # we'll just perform the migration right away in the test environment.
- return [true, nil] if always_perform?
-
- lease = lease_for(class_name, retried)
- lease_obtained = !!lease.try_obtain
- healthy_db = healthy_database?
- perform = lease_obtained && healthy_db
-
- database_unhealthy_counter.increment if lease_obtained && !healthy_db
-
- # When the DB is unhealthy or the lease can't be obtained after several tries,
- # then give up on the job and log a warning. Otherwise we could end up in
- # an infinite rescheduling loop. Jobs can be tracked in the database with the
- # use of Gitlab::Database::BackgroundMigrationJob
- if !perform && attempts_left < 0
- msg = if !lease_obtained
- 'Job could not get an exclusive lease after several tries. Giving up.'
- else
- 'Database was unhealthy after several tries. Giving up.'
- end
-
- Sidekiq.logger.warn(class: class_name, message: msg, job_id: jid)
-
- return [nil, nil]
- end
-
- [perform, lease.ttl]
- end
-
- def lease_for(class_name, retried)
- Gitlab::ExclusiveLease
- .new(lease_key_for(class_name, retried), timeout: self.class.minimum_interval)
- end
-
- def lease_key_for(class_name, retried)
- key = "#{self.class.name}:#{class_name}"
- # We use a different exclusive lock key for retried jobs to allow them running concurrently with the scheduled jobs.
- # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68763 for more information.
- key += ":retried" if retried
- key
- end
-
- def always_perform?
- Rails.env.test?
- end
-
- # Returns true if the database is healthy enough to allow the migration to be
- # performed.
- #
- # class_name - The name of the background migration that we might want to
- # run.
- def healthy_database?
- !Postgresql::ReplicationSlot.lag_too_great?
+ def self.tracking_database
+ @tracking_database ||= Gitlab::BackgroundMigration::DEFAULT_TRACKING_DATABASE
end
- def database_unhealthy_counter
- Gitlab::Metrics.counter(
- :background_migration_database_health_reschedules,
- 'The number of times a background migration is rescheduled because the database is unhealthy.'
- )
+ def self.unhealthy_metric_name
+ @unhealthy_metric_name ||= :background_migration_database_health_reschedules
end
end
diff --git a/app/workers/bulk_imports/entity_worker.rb b/app/workers/bulk_imports/entity_worker.rb
index 5c04cdc96a0..70d6626df91 100644
--- a/app/workers/bulk_imports/entity_worker.rb
+++ b/app/workers/bulk_imports/entity_worker.rb
@@ -12,6 +12,9 @@ module BulkImports
worker_has_external_dependencies!
+ idempotent!
+ deduplicate :until_executed, including_scheduled: true
+
def perform(entity_id, current_stage = nil)
return if stage_running?(entity_id, current_stage)
@@ -48,7 +51,7 @@ module BulkImports
end
def next_pipeline_trackers_for(entity_id)
- BulkImports::Tracker.next_pipeline_trackers_for(entity_id)
+ BulkImports::Tracker.next_pipeline_trackers_for(entity_id).update(status_event: 'enqueue')
end
def logger
diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb
index 35633b55489..8e5d7013c2c 100644
--- a/app/workers/bulk_imports/pipeline_worker.rb
+++ b/app/workers/bulk_imports/pipeline_worker.rb
@@ -16,7 +16,7 @@ module BulkImports
def perform(pipeline_tracker_id, stage, entity_id)
pipeline_tracker = ::BulkImports::Tracker
- .with_status(:created, :started)
+ .with_status(:enqueued)
.find_by_id(pipeline_tracker_id)
if pipeline_tracker.present?
@@ -68,6 +68,8 @@ module BulkImports
message: "Retrying error: #{e.message}"
)
+ pipeline_tracker.update!(status_event: 'retry', jid: jid)
+
reenqueue(pipeline_tracker, delay: e.retry_delay)
else
fail_tracker(pipeline_tracker, e)
diff --git a/app/workers/ci/create_downstream_pipeline_worker.rb b/app/workers/ci/create_downstream_pipeline_worker.rb
index 6d4cd2539c1..747cb088272 100644
--- a/app/workers/ci/create_downstream_pipeline_worker.rb
+++ b/app/workers/ci/create_downstream_pipeline_worker.rb
@@ -7,6 +7,7 @@ module Ci
sidekiq_options retry: 3
worker_resource_boundary :cpu
+ urgency :high
def perform(bridge_id)
::Ci::Bridge.find_by_id(bridge_id).try do |bridge|
diff --git a/app/workers/ci/pending_builds/update_group_worker.rb b/app/workers/ci/pending_builds/update_group_worker.rb
new file mode 100644
index 00000000000..3ee3a9116d8
--- /dev/null
+++ b/app/workers/ci/pending_builds/update_group_worker.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Ci
+ module PendingBuilds
+ class UpdateGroupWorker
+ include ApplicationWorker
+ include PipelineBackgroundQueue
+
+ data_consistency :always
+ idempotent!
+
+ def perform(group_id, update_params)
+ ::Group.find_by_id(group_id).try do |group|
+ ::Ci::UpdatePendingBuildService.new(group, update_params).execute
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/ci/pending_builds/update_project_worker.rb b/app/workers/ci/pending_builds/update_project_worker.rb
new file mode 100644
index 00000000000..bac0316c80b
--- /dev/null
+++ b/app/workers/ci/pending_builds/update_project_worker.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Ci
+ module PendingBuilds
+ class UpdateProjectWorker
+ include ApplicationWorker
+ include PipelineBackgroundQueue
+
+ data_consistency :always
+ idempotent!
+
+ def perform(project_id, update_params)
+ ::Project.find_by_id(project_id).try do |project|
+ ::Ci::UpdatePendingBuildService.new(project, update_params).execute
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/ci/pipeline_artifacts/create_quality_report_worker.rb b/app/workers/ci/pipeline_artifacts/create_quality_report_worker.rb
index bb0a81a0a17..dc7e8f888c6 100644
--- a/app/workers/ci/pipeline_artifacts/create_quality_report_worker.rb
+++ b/app/workers/ci/pipeline_artifacts/create_quality_report_worker.rb
@@ -10,7 +10,7 @@ module Ci
sidekiq_options retry: 3
queue_namespace :pipeline_background
- feature_category :code_testing
+ feature_category :code_quality
idempotent!
diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb
index 03a0b5fae00..d0b09c15289 100644
--- a/app/workers/concerns/application_worker.rb
+++ b/app/workers/concerns/application_worker.rb
@@ -93,9 +93,11 @@ module ApplicationWorker
end
def perform_async(*args)
+ return super if Gitlab::Database::LoadBalancing.primary_only?
+
# Worker execution for workers with data_consistency set to :delayed or :sticky
# will be delayed to give replication enough time to complete
- if utilizes_load_balancing_capabilities?
+ if utilizes_load_balancing_capabilities? && Feature.disabled?(:skip_scheduling_workers_for_replicas, default_enabled: :yaml)
perform_in(delay_interval, *args)
else
super
diff --git a/app/workers/expire_job_cache_worker.rb b/app/workers/expire_job_cache_worker.rb
index 3c5a7717d70..49f0222e9c9 100644
--- a/app/workers/expire_job_cache_worker.rb
+++ b/app/workers/expire_job_cache_worker.rb
@@ -15,19 +15,10 @@ class ExpireJobCacheWorker # rubocop:disable Scalability/IdempotentWorker
idempotent!
def perform(job_id)
- job = CommitStatus.preload(:pipeline, :project).find_by_id(job_id) # rubocop: disable CodeReuse/ActiveRecord
+ job = CommitStatus.find_by_id(job_id)
return unless job
- pipeline = job.pipeline
- project = job.project
-
- Gitlab::EtagCaching::Store.new.touch(project_job_path(project, job))
- ExpirePipelineCacheWorker.perform_async(pipeline.id)
- end
-
- private
-
- def project_job_path(project, job)
- Gitlab::Routing.url_helpers.project_build_path(project, job.id, format: :json)
+ job.expire_etag_cache!
+ ExpirePipelineCacheWorker.perform_async(job.pipeline_id)
end
end
diff --git a/app/workers/issuable_export_csv_worker.rb b/app/workers/issuable_export_csv_worker.rb
index 9d543a21dc3..ffa0ed68fc7 100644
--- a/app/workers/issuable_export_csv_worker.rb
+++ b/app/workers/issuable_export_csv_worker.rb
@@ -41,7 +41,7 @@ class IssuableExportCsvWorker # rubocop:disable Scalability/IdempotentWorker
def parse_params(params, project_id)
params
- .symbolize_keys
+ .with_indifferent_access
.except(:sort)
.merge(project_id: project_id)
end
diff --git a/app/workers/issue_placement_worker.rb b/app/workers/issue_placement_worker.rb
index cfd72b90a42..26dec221f45 100644
--- a/app/workers/issue_placement_worker.rb
+++ b/app/workers/issue_placement_worker.rb
@@ -1,5 +1,8 @@
# frozen_string_literal: true
+# DEPRECATED. Will be removed in 14.7 https://gitlab.com/gitlab-org/gitlab/-/merge_requests/72803
+# Please use Issues::PlacementWorker instead
+#
# todo: remove this worker and it's queue definition from all_queues after Issues::PlacementWorker is deployed
# We want to keep it for one release in case some jobs are already scheduled in the old queue so we need the worker
# to be available to finish those. All new jobs will be queued into the new queue.
@@ -43,10 +46,10 @@ class IssuePlacementWorker
Issue.move_nulls_to_end(to_place)
Issues::BaseService.new(project: nil).rebalance_if_needed(to_place.max_by(&:relative_position))
- IssuePlacementWorker.perform_async(nil, leftover.project_id) if leftover.present?
+ Issues::PlacementWorker.perform_async(nil, leftover.project_id) if leftover.present?
rescue RelativePositioning::NoSpaceLeft => e
Gitlab::ErrorTracking.log_exception(e, issue_id: issue_id, project_id: project_id)
- IssueRebalancingWorker.perform_async(nil, *root_namespace_id_to_rebalance(issue, project_id))
+ Issues::RebalancingWorker.perform_async(nil, *root_namespace_id_to_rebalance(issue, project_id))
end
def find_issue(issue_id, project_id)
diff --git a/app/workers/issue_rebalancing_worker.rb b/app/workers/issue_rebalancing_worker.rb
index a43e76feae4..73edb2eb653 100644
--- a/app/workers/issue_rebalancing_worker.rb
+++ b/app/workers/issue_rebalancing_worker.rb
@@ -1,5 +1,8 @@
# frozen_string_literal: true
+# DEPRECATED. Will be removed in 14.7 https://gitlab.com/gitlab-org/gitlab/-/merge_requests/72803
+# Please use Issues::RebalancingWorker instead
+#
# todo: remove this worker and it's queue definition from all_queues after Issue::RebalancingWorker is released.
# We want to keep it for one release in case some jobs are already scheduled in the old queue so we need the worker
# to be available to finish those. All new jobs will be queued into the new queue.
diff --git a/app/workers/issues/rebalancing_worker.rb b/app/workers/issues/rebalancing_worker.rb
index 466617d9fa1..8de0588a2a1 100644
--- a/app/workers/issues/rebalancing_worker.rb
+++ b/app/workers/issues/rebalancing_worker.rb
@@ -17,6 +17,7 @@ module Issues
# we need to have exactly one of the project_id and root_namespace_id params be non-nil
raise ArgumentError, "Expected only one of the params project_id: #{project_id} and root_namespace_id: #{root_namespace_id}" if project_id && root_namespace_id
return if project_id.nil? && root_namespace_id.nil?
+ return if ::Gitlab::Issues::Rebalancing::State.rebalance_recently_finished?(project_id, root_namespace_id)
# pull the projects collection to be rebalanced either the project if namespace is not a group(i.e. user namesapce)
# or the root namespace, this also makes the worker backward compatible with previous version where a project_id was
diff --git a/app/workers/issues/reschedule_stuck_issue_rebalances_worker.rb b/app/workers/issues/reschedule_stuck_issue_rebalances_worker.rb
index d1759589cc0..77cedae558b 100644
--- a/app/workers/issues/reschedule_stuck_issue_rebalances_worker.rb
+++ b/app/workers/issues/reschedule_stuck_issue_rebalances_worker.rb
@@ -20,13 +20,13 @@ module Issues
namespaces = Namespace.id_in(namespace_ids)
projects = Project.id_in(project_ids)
- IssueRebalancingWorker.bulk_perform_async_with_contexts(
+ Issues::RebalancingWorker.bulk_perform_async_with_contexts(
namespaces,
arguments_proc: -> (namespace) { [nil, nil, namespace.id] },
context_proc: -> (namespace) { { namespace: namespace } }
)
- IssueRebalancingWorker.bulk_perform_async_with_contexts(
+ Issues::RebalancingWorker.bulk_perform_async_with_contexts(
projects,
arguments_proc: -> (project) { [nil, project.id, nil] },
context_proc: -> (project) { { project: project } }
diff --git a/app/workers/namespaces/process_sync_events_worker.rb b/app/workers/namespaces/process_sync_events_worker.rb
new file mode 100644
index 00000000000..f3c4f5bebb1
--- /dev/null
+++ b/app/workers/namespaces/process_sync_events_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Namespaces
+ # This worker can be called multiple times at the same time but only one of them can
+ # process events at a time. This is ensured by `try_obtain_lease` in `Ci::ProcessSyncEventsService`.
+ # `until_executing` here is to reduce redundant worker enqueuing.
+ class ProcessSyncEventsWorker
+ include ApplicationWorker
+
+ data_consistency :always
+
+ feature_category :sharding
+ urgency :high
+
+ idempotent!
+ deduplicate :until_executing
+
+ def perform
+ ::Ci::ProcessSyncEventsService.new(::Namespaces::SyncEvent, ::Ci::NamespaceMirror).execute
+ end
+ end
+end
diff --git a/app/workers/projects/process_sync_events_worker.rb b/app/workers/projects/process_sync_events_worker.rb
new file mode 100644
index 00000000000..b7c4b4de3d0
--- /dev/null
+++ b/app/workers/projects/process_sync_events_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Projects
+ # This worker can be called multiple times at the same time but only one of them can
+ # process events at a time. This is ensured by `try_obtain_lease` in `Ci::ProcessSyncEventsService`.
+ # `until_executing` here is to reduce redundant worker enqueuing.
+ class ProcessSyncEventsWorker
+ include ApplicationWorker
+
+ data_consistency :always
+
+ feature_category :sharding
+ urgency :high
+
+ idempotent!
+ deduplicate :until_executing
+
+ def perform
+ ::Ci::ProcessSyncEventsService.new(::Projects::SyncEvent, ::Ci::ProjectMirror).execute
+ end
+ end
+end
diff --git a/app/workers/propagate_integration_worker.rb b/app/workers/propagate_integration_worker.rb
index 9d21d92b6e3..099f423dc0f 100644
--- a/app/workers/propagate_integration_worker.rb
+++ b/app/workers/propagate_integration_worker.rb
@@ -12,6 +12,6 @@ class PropagateIntegrationWorker
idempotent!
def perform(integration_id)
- Admin::PropagateIntegrationService.propagate(Integration.find(integration_id))
+ ::Integrations::PropagateService.propagate(Integration.find(integration_id))
end
end
diff --git a/app/workers/propagate_service_template_worker.rb b/app/workers/propagate_service_template_worker.rb
deleted file mode 100644
index 908f867279f..00000000000
--- a/app/workers/propagate_service_template_worker.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# frozen_string_literal: true
-
-# No longer in use https://gitlab.com/groups/gitlab-org/-/epics/5672
-# To be removed https://gitlab.com/gitlab-org/gitlab/-/issues/335178
-class PropagateServiceTemplateWorker # rubocop:disable Scalability/IdempotentWorker
- include ApplicationWorker
-
- data_consistency :always
-
- sidekiq_options retry: 3
-
- feature_category :integrations
-
- LEASE_TIMEOUT = 4.hours.to_i
-
- def perform(template_id)
- return unless try_obtain_lease_for(template_id)
-
- Admin::PropagateServiceTemplate.propagate(Integration.find_by_id(template_id))
- end
-
- private
-
- def try_obtain_lease_for(template_id)
- Gitlab::ExclusiveLease
- .new("propagate_service_template_worker:#{template_id}", timeout: LEASE_TIMEOUT)
- .try_obtain
- end
-end
diff --git a/app/workers/purge_dependency_proxy_cache_worker.rb b/app/workers/purge_dependency_proxy_cache_worker.rb
index db43e4adf20..615fa81f28e 100644
--- a/app/workers/purge_dependency_proxy_cache_worker.rb
+++ b/app/workers/purge_dependency_proxy_cache_worker.rb
@@ -12,14 +12,21 @@ class PurgeDependencyProxyCacheWorker
queue_namespace :dependency_proxy
feature_category :dependency_proxy
+ UPDATE_BATCH_SIZE = 100
+
def perform(current_user_id, group_id)
@current_user = User.find_by_id(current_user_id)
@group = Group.find_by_id(group_id)
return unless valid?
- @group.dependency_proxy_blobs.destroy_all # rubocop:disable Cop/DestroyAll
- @group.dependency_proxy_manifests.destroy_all # rubocop:disable Cop/DestroyAll
+ @group.dependency_proxy_blobs.each_batch(of: UPDATE_BATCH_SIZE) do |batch|
+ batch.update_all(status: :expired)
+ end
+
+ @group.dependency_proxy_manifests.each_batch(of: UPDATE_BATCH_SIZE) do |batch|
+ batch.update_all(status: :expired)
+ end
end
private
diff --git a/app/workers/todos_destroyer/private_features_worker.rb b/app/workers/todos_destroyer/private_features_worker.rb
index 150e1c8a50e..09e81216aab 100644
--- a/app/workers/todos_destroyer/private_features_worker.rb
+++ b/app/workers/todos_destroyer/private_features_worker.rb
@@ -10,7 +10,7 @@ module TodosDestroyer
include TodosDestroyerQueue
def perform(project_id, user_id = nil)
- ::Todos::Destroy::PrivateFeaturesService.new(project_id, user_id).execute
+ ::Todos::Destroy::UnauthorizedFeaturesService.new(project_id, user_id).execute
end
end
end