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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts')
-rw-r--r--app/assets/javascripts/access_tokens/components/access_token_table_app.vue5
-rw-r--r--app/assets/javascripts/access_tokens/components/new_access_token_app.vue2
-rw-r--r--app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue6
-rw-r--r--app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue2
-rw-r--r--app/assets/javascripts/add_context_commits_modal/index.js4
-rw-r--r--app/assets/javascripts/admin/deploy_keys/components/table.vue22
-rw-r--r--app/assets/javascripts/admin/statistics_panel/components/app.vue29
-rw-r--r--app/assets/javascripts/admin/users/components/user_actions.vue14
-rw-r--r--app/assets/javascripts/analytics/devops_reports/components/devops_score.vue2
-rw-r--r--app/assets/javascripts/analytics/shared/constants.js28
-rw-r--r--app/assets/javascripts/batch_comments/components/draft_note.vue9
-rw-r--r--app/assets/javascripts/batch_comments/components/preview_dropdown.vue13
-rw-r--r--app/assets/javascripts/batch_comments/components/submit_dropdown.vue20
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js6
-rw-r--r--app/assets/javascripts/blob/3d_viewer/index.js4
-rw-r--r--app/assets/javascripts/blob/3d_viewer/mesh_object.js4
-rw-r--r--app/assets/javascripts/blob/stl_viewer.js8
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue27
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue3
-rw-r--r--app/assets/javascripts/boards/components/toggle_focus.vue2
-rw-r--r--app/assets/javascripts/ci_secure_files/components/secure_files_list.vue161
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_table.vue4
-rw-r--r--app/assets/javascripts/clusters_list/constants.js6
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue20
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor_provider.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/editor_state_observer.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue53
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_table_button.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/top_toolbar.vue54
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/division.js31
-rw-r--r--app/assets/javascripts/content_editor/extensions/html_nodes.js25
-rw-r--r--app/assets/javascripts/content_editor/extensions/sourcemap.js10
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js4
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js4
-rw-r--r--app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js6
-rw-r--r--app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js222
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js18
-rw-r--r--app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js45
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js10
-rw-r--r--app/assets/javascripts/deploy_keys/components/key.vue18
-rw-r--r--app/assets/javascripts/deprecated_notes.js10
-rw-r--r--app/assets/javascripts/design_management/components/design_sidebar.vue78
-rw-r--r--app/assets/javascripts/design_management/constants.js2
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue47
-rw-r--r--app/assets/javascripts/design_management/utils/error_messages.js14
-rw-r--r--app/assets/javascripts/diff.js58
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue4
-rw-r--r--app/assets/javascripts/diffs/components/diff_code_quality.vue56
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_row.vue6
-rw-r--r--app/assets/javascripts/diffs/components/diff_view.vue63
-rw-r--r--app/assets/javascripts/diffs/components/tree_list.vue18
-rw-r--r--app/assets/javascripts/diffs/store/actions.js4
-rw-r--r--app/assets/javascripts/editor/schema/ci.json27
-rw-r--r--app/assets/javascripts/editor/source_editor_instance.js1
-rw-r--r--app/assets/javascripts/emoji/awards_app/store/actions.js2
-rw-r--r--app/assets/javascripts/environments/components/canary_update_modal.vue2
-rw-r--r--app/assets/javascripts/environments/components/confirm_rollback_modal.vue42
-rw-r--r--app/assets/javascripts/environments/components/deploy_board.vue6
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue2
-rw-r--r--app/assets/javascripts/environments/stores/environments_store.js2
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue10
-rw-r--r--app/assets/javascripts/feature_flags/components/strategies/default.vue2
-rw-r--r--app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue84
-rw-r--r--app/assets/javascripts/gitlab_pages/queries/mark_onboarding_complete.graphql6
-rw-r--r--app/assets/javascripts/google_cloud/components/app.vue63
-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/google_cloud_menu.vue85
-rw-r--r--app/assets/javascripts/google_cloud/components/home.vue81
-rw-r--r--app/assets/javascripts/google_cloud/components/incubation_banner.vue28
-rw-r--r--app/assets/javascripts/google_cloud/configuration/index.js11
-rw-r--r--app/assets/javascripts/google_cloud/configuration/panel.vue88
-rw-r--r--app/assets/javascripts/google_cloud/databases/cloudsql/create_instance_form.vue132
-rw-r--r--app/assets/javascripts/google_cloud/databases/cloudsql/instance_table.vue75
-rw-r--r--app/assets/javascripts/google_cloud/databases/index.js11
-rw-r--r--app/assets/javascripts/google_cloud/databases/panel.vue38
-rw-r--r--app/assets/javascripts/google_cloud/databases/service_table.vue221
-rw-r--r--app/assets/javascripts/google_cloud/deployments/index.js11
-rw-r--r--app/assets/javascripts/google_cloud/deployments/panel.vue50
-rw-r--r--app/assets/javascripts/google_cloud/deployments/service_table.vue (renamed from app/assets/javascripts/google_cloud/components/deployments_service_table.vue)0
-rw-r--r--app/assets/javascripts/google_cloud/gcp_regions/form.vue (renamed from app/assets/javascripts/google_cloud/components/gcp_regions_form.vue)0
-rw-r--r--app/assets/javascripts/google_cloud/gcp_regions/index.js11
-rw-r--r--app/assets/javascripts/google_cloud/gcp_regions/list.vue (renamed from app/assets/javascripts/google_cloud/components/gcp_regions_list.vue)0
-rw-r--r--app/assets/javascripts/google_cloud/index.js12
-rw-r--r--app/assets/javascripts/google_cloud/service_accounts/form.vue (renamed from app/assets/javascripts/google_cloud/components/service_accounts_form.vue)0
-rw-r--r--app/assets/javascripts/google_cloud/service_accounts/index.js11
-rw-r--r--app/assets/javascripts/google_cloud/service_accounts/list.vue (renamed from app/assets/javascripts/google_cloud/components/service_accounts_list.vue)0
-rw-r--r--app/assets/javascripts/google_tag_manager/index.js8
-rw-r--r--app/assets/javascripts/graphql_shared/possible_types.json4
-rw-r--r--app/assets/javascripts/graphql_shared/queries/current_user.query.graphql7
-rw-r--r--app/assets/javascripts/graphql_shared/queries/get_users_by_usernames.query.graphql9
-rw-r--r--app/assets/javascripts/graphql_shared/queries/users_search_all.query.graphql9
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue86
-rw-r--r--app/assets/javascripts/groups/components/group_name_and_path.vue6
-rw-r--r--app/assets/javascripts/groups/constants.js22
-rw-r--r--app/assets/javascripts/groups/index.js2
-rw-r--r--app/assets/javascripts/header.js19
-rw-r--r--app/assets/javascripts/header_search/components/app.vue111
-rw-r--r--app/assets/javascripts/header_search/components/header_search_scoped_items.vue44
-rw-r--r--app/assets/javascripts/header_search/constants.js22
-rw-r--r--app/assets/javascripts/header_search/init.js53
-rw-r--r--app/assets/javascripts/header_search/store/getters.js19
-rw-r--r--app/assets/javascripts/helpers/help_page_helper.js7
-rw-r--r--app/assets/javascripts/ide/components/ide_tree.vue10
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue24
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue47
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table.vue15
-rw-r--r--app/assets/javascripts/import_entities/import_groups/index.js4
-rw-r--r--app/assets/javascripts/init_confirm_danger.js6
-rw-r--r--app/assets/javascripts/integrations/constants.js10
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue26
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/configuration.vue1
-rw-r--r--app/assets/javascripts/integrations/edit/components/trigger_fields.vue12
-rw-r--r--app/assets/javascripts/invite_members/components/group_select.vue1
-rw-r--r--app/assets/javascripts/invite_members/components/import_project_members_modal.vue (renamed from app/assets/javascripts/invite_members/components/import_a_project_modal.vue)122
-rw-r--r--app/assets/javascripts/invite_members/components/import_project_members_trigger.vue34
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue64
-rw-r--r--app/assets/javascripts/invite_members/components/invite_modal_base.vue8
-rw-r--r--app/assets/javascripts/invite_members/components/members_token_select.vue31
-rw-r--r--app/assets/javascripts/invite_members/constants.js4
-rw-r--r--app/assets/javascripts/invite_members/init_import_a_project_modal.js23
-rw-r--r--app/assets/javascripts/invite_members/init_import_project_members_modal.js23
-rw-r--r--app/assets/javascripts/invite_members/init_import_project_members_trigger.js20
-rw-r--r--app/assets/javascripts/invite_members/utils/member_utils.js4
-rw-r--r--app/assets/javascripts/invite_members/utils/response_message_parser.js31
-rw-r--r--app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js13
-rw-r--r--app/assets/javascripts/issuable/components/related_issuable_item.vue2
-rw-r--r--app/assets/javascripts/issuable/issuable_form.js5
-rw-r--r--app/assets/javascripts/issues/create_merge_request_dropdown.js43
-rw-r--r--app/assets/javascripts/issues/index.js2
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue52
-rw-r--r--app/assets/javascripts/issues/list/constants.js4
-rw-r--r--app/assets/javascripts/issues/list/queries/issue.fragment.graphql1
-rw-r--r--app/assets/javascripts/issues/list/utils.js25
-rw-r--r--app/assets/javascripts/issues/new/components/type_popover.vue5
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue118
-rw-r--r--app/assets/javascripts/issues/show/components/edit_actions.vue3
-rw-r--r--app/assets/javascripts/issues/show/components/fields/description.vue3
-rw-r--r--app/assets/javascripts/issues/show/components/fields/title.vue2
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/constants.js26
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql13
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/graphql/queries/delete_timeline_event.mutation.graphql8
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql6
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue2
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue266
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue44
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue37
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue36
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/utils.js14
-rw-r--r--app/assets/javascripts/issues/show/components/title.vue5
-rw-r--r--app/assets/javascripts/issues/show/index.js1
-rw-r--r--app/assets/javascripts/jobs/bridge/app.vue118
-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.vue105
-rw-r--r--app/assets/javascripts/jobs/bridge/graphql/queries/pipeline.query.graphql70
-rw-r--r--app/assets/javascripts/jobs/components/job_app.vue12
-rw-r--r--app/assets/javascripts/jobs/components/job_log_controllers.vue95
-rw-r--r--app/assets/javascripts/jobs/components/log/collapsible_section.vue39
-rw-r--r--app/assets/javascripts/jobs/components/log/line.vue43
-rw-r--r--app/assets/javascripts/jobs/components/log/line_number.vue6
-rw-r--r--app/assets/javascripts/jobs/components/log/log.vue16
-rw-r--r--app/assets/javascripts/jobs/components/sidebar.vue2
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_job_details_container.vue10
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/actions_cell.vue2
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql4
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table.vue1
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_app.vue4
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue3
-rw-r--r--app/assets/javascripts/jobs/constants.js2
-rw-r--r--app/assets/javascripts/jobs/index.js44
-rw-r--r--app/assets/javascripts/jobs/store/mutations.js27
-rw-r--r--app/assets/javascripts/jobs/store/state.js3
-rw-r--r--app/assets/javascripts/jobs/store/utils.js80
-rw-r--r--app/assets/javascripts/lib/dompurify.js1
-rw-r--r--app/assets/javascripts/lib/gfm/index.js40
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js12
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js14
-rw-r--r--app/assets/javascripts/linked_resources/index.js28
-rw-r--r--app/assets/javascripts/logs/components/environment_logs.vue280
-rw-r--r--app/assets/javascripts/logs/components/log_advanced_filters.vue99
-rw-r--r--app/assets/javascripts/logs/components/log_control_buttons.vue95
-rw-r--r--app/assets/javascripts/logs/components/log_simple_filters.vue68
-rw-r--r--app/assets/javascripts/logs/components/tokens/token_with_loading_state.vue30
-rw-r--r--app/assets/javascripts/logs/constants.js16
-rw-r--r--app/assets/javascripts/logs/index.js24
-rw-r--r--app/assets/javascripts/logs/logs_tracking_helper.js18
-rw-r--r--app/assets/javascripts/logs/stores/actions.js174
-rw-r--r--app/assets/javascripts/logs/stores/getters.js14
-rw-r--r--app/assets/javascripts/logs/stores/index.js23
-rw-r--r--app/assets/javascripts/logs/stores/mutation_types.js26
-rw-r--r--app/assets/javascripts/logs/stores/mutations.js110
-rw-r--r--app/assets/javascripts/logs/stores/state.js56
-rw-r--r--app/assets/javascripts/logs/utils.js4
-rw-r--r--app/assets/javascripts/main.js36
-rw-r--r--app/assets/javascripts/members/components/members_tabs.vue85
-rw-r--r--app/assets/javascripts/members/components/table/member_avatar.vue8
-rw-r--r--app/assets/javascripts/members/components/table/members_table_cell.vue2
-rw-r--r--app/assets/javascripts/members/constants.js6
-rw-r--r--app/assets/javascripts/members/index.js2
-rw-r--r--app/assets/javascripts/merge_request_tabs.js8
-rw-r--r--app/assets/javascripts/milestones/components/delete_milestone_modal.vue33
-rw-r--r--app/assets/javascripts/milestones/milestone.js12
-rw-r--r--app/assets/javascripts/mirrors/ssh_mirror.js2
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue4
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_header.vue8
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_panel.vue38
-rw-r--r--app/assets/javascripts/monitoring/constants.js2
-rw-r--r--app/assets/javascripts/monitoring/stores/state.js1
-rw-r--r--app/assets/javascripts/monitoring/utils.js2
-rw-r--r--app/assets/javascripts/mr_notes/stores/index.js14
-rw-r--r--app/assets/javascripts/new_branch_form.js22
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue5
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter_note.vue4
-rw-r--r--app/assets/javascripts/notes/components/discussion_navigator.vue2
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue58
-rw-r--r--app/assets/javascripts/notes/components/toggle_replies_widget.vue93
-rw-r--r--app/assets/javascripts/notes/constants.js4
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js2
-rw-r--r--app/assets/javascripts/notes/stores/actions.js9
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue3
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue10
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue52
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js3
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js7
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue3
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/file_sha.vue20
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue24
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/constants.js7
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue6
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/bundle.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue18
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue65
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue28
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/constants.js15
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue10
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue68
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue137
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue10
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/constants.js25
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/packages_cleanup_policy.fragment.graphql4
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql10
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql10
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js4
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/utils.js9
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue17
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js2
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue17
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js4
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/todos.js36
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js10
-rw-r--r--app/assets/javascripts/pages/groups/runners/index/index.js (renamed from app/assets/javascripts/pages/groups/runners/index.js)0
-rw-r--r--app/assets/javascripts/pages/groups/runners/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/branches/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue2
-rw-r--r--app/assets/javascripts/pages/projects/google_cloud/configuration/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/google_cloud/databases/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/google_cloud/deployments/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/google_cloud/gcp_regions/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/google_cloud/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/google_cloud/service_accounts/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/jobs/index/index.js22
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/included_in_trial_indicator.vue15
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue8
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue70
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue73
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js58
-rw-r--r--app/assets/javascripts/pages/projects/logs/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js2
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js10
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue31
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/constants.js8
-rw-r--r--app/assets/javascripts/pages/projects/work_items/index.js2
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue2
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue286
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue9
-rw-r--r--app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue32
-rw-r--r--app/assets/javascripts/pipeline_editor/components/popovers/validate_pipeline_popover.vue72
-rw-r--r--app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue27
-rw-r--r--app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue252
-rw-r--r--app/assets/javascripts/pipeline_editor/constants.js2
-rw-r--r--app/assets/javascripts/pipeline_editor/index.js4
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue1
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/input_wrapper.vue6
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/step.vue7
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue80
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/wrapper.vue1
-rw-r--r--app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue1
-rw-r--r--app/assets/javascripts/pipeline_wizard/templates/.gitkeep0
-rw-r--r--app/assets/javascripts/pipeline_wizard/templates/pages.yml53
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue37
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue22
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/performance_insights_modal.vue168
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue15
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_reports.vue17
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue10
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue2
-rw-r--r--app/assets/javascripts/pipelines/constants.js2
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_performance_insights.query.graphql28
-rw-r--r--app/assets/javascripts/pipelines/pipeline_tabs.js3
-rw-r--r--app/assets/javascripts/pipelines/pipeline_test_details.js13
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/constants.js2
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/index.js12
-rw-r--r--app/assets/javascripts/pipelines/utils.js21
-rw-r--r--app/assets/javascripts/profile/account/index.js2
-rw-r--r--app/assets/javascripts/profile/profile.js11
-rw-r--r--app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue5
-rw-r--r--app/assets/javascripts/projects/commit_box/info/init_details_button.js8
-rw-r--r--app/assets/javascripts/projects/commits/components/author_select.vue2
-rw-r--r--app/assets/javascripts/projects/compare/components/app.vue12
-rw-r--r--app/assets/javascripts/projects/new/components/app.vue2
-rw-r--r--app/assets/javascripts/projects/new/components/new_project_url_select.vue30
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/app.vue17
-rw-r--r--app/assets/javascripts/projects/project_new.js1
-rw-r--r--app/assets/javascripts/projects/project_visibility.js4
-rw-r--r--app/assets/javascripts/projects/settings/access_dropdown.js4
-rw-r--r--app/assets/javascripts/projects/settings/components/access_dropdown.vue7
-rw-r--r--app/assets/javascripts/projects/star.js34
-rw-r--r--app/assets/javascripts/related_issues/components/add_issuable_form.vue6
-rw-r--r--app/assets/javascripts/releases/components/app_edit_new.vue44
-rw-r--r--app/assets/javascripts/releases/components/app_index.vue18
-rw-r--r--app/assets/javascripts/releases/components/confirm_delete_modal.vue77
-rw-r--r--app/assets/javascripts/releases/components/release_block_footer.vue30
-rw-r--r--app/assets/javascripts/releases/components/tag_field.vue4
-rw-r--r--app/assets/javascripts/releases/components/tag_field_new.vue15
-rw-r--r--app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql2
-rw-r--r--app/assets/javascripts/releases/graphql/mutations/delete_release.mutation.graphql5
-rw-r--r--app/assets/javascripts/releases/mount_edit.js2
-rw-r--r--app/assets/javascripts/releases/mount_index.js2
-rw-r--r--app/assets/javascripts/releases/mount_new.js2
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/actions.js45
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/getters.js16
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js2
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/mutations.js9
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/state.js9
-rw-r--r--app/assets/javascripts/releases/util.js8
-rw-r--r--app/assets/javascripts/reports/components/summary_row.vue2
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue9
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue9
-rw-r--r--app/assets/javascripts/repository/components/table/index.vue8
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue12
-rw-r--r--app/assets/javascripts/repository/constants.js1
-rw-r--r--app/assets/javascripts/repository/graphql.js3
-rw-r--r--app/assets/javascripts/repository/index.js4
-rw-r--r--app/assets/javascripts/repository/log_tree.js4
-rw-r--r--app/assets/javascripts/repository/queries/commit.fragment.graphql1
-rw-r--r--app/assets/javascripts/repository/queries/commit.query.graphql4
-rw-r--r--app/assets/javascripts/repository/utils/commit.js1
-rw-r--r--app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue45
-rw-r--r--app/assets/javascripts/runner/admin_runners/admin_runners_app.vue154
-rw-r--r--app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue36
-rw-r--r--app/assets/javascripts/runner/components/runner_detail.vue1
-rw-r--r--app/assets/javascripts/runner/components/runner_details.vue111
-rw-r--r--app/assets/javascripts/runner/components/runner_filtered_search_bar.vue12
-rw-r--r--app/assets/javascripts/runner/components/runner_type_tabs.vue46
-rw-r--r--app/assets/javascripts/runner/components/search_tokens/paused_token_config.js2
-rw-r--r--app/assets/javascripts/runner/components/search_tokens/status_token_config.js2
-rw-r--r--app/assets/javascripts/runner/components/search_tokens/upgrade_status_token_config.js2
-rw-r--r--app/assets/javascripts/runner/components/stat/runner_count.vue103
-rw-r--r--app/assets/javascripts/runner/components/stat/runner_stats.vue56
-rw-r--r--app/assets/javascripts/runner/constants.js1
-rw-r--r--app/assets/javascripts/runner/graphql/list/all_runners.query.graphql (renamed from app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql)2
-rw-r--r--app/assets/javascripts/runner/graphql/list/all_runners_count.query.graphql (renamed from app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql)2
-rw-r--r--app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue34
-rw-r--r--app/assets/javascripts/runner/group_runner_show/index.js8
-rw-r--r--app/assets/javascripts/runner/group_runners/group_runners_app.vue122
-rw-r--r--app/assets/javascripts/security_configuration/components/app.vue4
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js2
-rw-r--r--app/assets/javascripts/security_configuration/components/training_provider_list.vue26
-rw-r--r--app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql8
-rw-r--r--app/assets/javascripts/self_monitor/components/self_monitor_form.vue4
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue163
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue14
-rw-r--r--app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue13
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue5
-rw-r--r--app/assets/javascripts/sidebar/graphql.js3
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js6
-rw-r--r--app/assets/javascripts/sidebar/services/sidebar_service.js1
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js2
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js1
-rw-r--r--app/assets/javascripts/surveys/components/satisfaction_rate.vue71
-rw-r--r--app/assets/javascripts/surveys/merge_request_experience/app.js52
-rw-r--r--app/assets/javascripts/surveys/merge_request_experience/app.vue169
-rw-r--r--app/assets/javascripts/surveys/merge_request_experience/index.js23
-rw-r--r--app/assets/javascripts/tabs/constants.js3
-rw-r--r--app/assets/javascripts/tabs/index.js23
-rw-r--r--app/assets/javascripts/terms/components/app.vue10
-rw-r--r--app/assets/javascripts/user_popovers.js2
-rw-r--r--app/assets/javascripts/users_select/index.js30
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue41
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue48
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue11
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue16
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/issues.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js62
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js14
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql2
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/color_item.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue31
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/constants.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents.vue17
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_value.vue18
-rw-r--r--app/assets/javascripts/vue_shared/components/deployment_instance.vue18
-rw-r--r--app/assets/javascripts/vue_shared/components/dom_element_listener.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_contact.fragment.graphql6
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_organization.fragment.graphql4
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_contacts.query.graphql28
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_organizations.query.graphql28
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue131
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue125
-rw-r--r--app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/help_popover.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/local_storage_sync.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/page_size_selector.vue37
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/list_item.vue29
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/title_area.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/epics_select/epics_select_bundle.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/health_status_select/health_status_bundle.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/iterations_dropdown_bundle.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/slot_switch.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/constants.js11
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js6
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js25
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js15
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js46
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue33
-rw-r--r--app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue32
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue90
-rw-r--r--app/assets/javascripts/vue_shared/components/vuex_module_provider.vue2
-rw-r--r--app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue3
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue20
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue53
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue6
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue3
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue10
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue5
-rw-r--r--app/assets/javascripts/work_items/components/item_state.vue2
-rw-r--r--app/assets/javascripts/work_items/components/item_title.vue11
-rw-r--r--app/assets/javascripts/work_items/components/work_item_actions.vue8
-rw-r--r--app/assets/javascripts/work_items/components/work_item_assignees.vue235
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description.vue70
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue138
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail_modal.vue8
-rw-r--r--app/assets/javascripts/work_items/components/work_item_information.vue57
-rw-r--r--app/assets/javascripts/work_items/components/work_item_labels.vue246
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/index.js6
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue51
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue116
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue101
-rw-r--r--app/assets/javascripts/work_items/components/work_item_weight.vue128
-rw-r--r--app/assets/javascripts/work_items/constants.js6
-rw-r--r--app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql13
-rw-r--r--app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql8
-rw-r--r--app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/project_work_items.query.graphql14
-rw-r--r--app/assets/javascripts/work_items/graphql/provider.js58
-rw-r--r--app/assets/javascripts/work_items/graphql/typedefs.graphql15
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql1
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql5
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.fragment.graphql26
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.query.graphql10
-rw-r--r--app/assets/javascripts/work_items/index.js1
-rw-r--r--app/assets/javascripts/work_items/pages/create_work_item.vue37
493 files changed, 8450 insertions, 4571 deletions
diff --git a/app/assets/javascripts/access_tokens/components/access_token_table_app.vue b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue
index 944a2ef7f64..59f0e0dd17d 100644
--- a/app/assets/javascripts/access_tokens/components/access_token_table_app.vue
+++ b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue
@@ -138,10 +138,9 @@ export default {
}}</span>
</template>
- <template #cell(action)="{ item: { revokePath, expiresAt } }">
+ <template #cell(action)="{ item: { revokePath } }">
<gl-button
- variant="danger"
- :category="expiresAt ? 'primary' : 'secondary'"
+ category="tertiary"
:aria-label="$options.i18n.revokeButton"
:data-confirm="modalMessage"
data-confirm-btn-variant="danger"
diff --git a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
index 904052688f3..e111ae91e5c 100644
--- a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
+++ b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
@@ -117,7 +117,7 @@ export default {
<template v-if="errors">
<gl-alert :title="alertDangerTitle" variant="danger" @dismiss="errors = null">
- <ul class="m-0">
+ <ul class="gl-m-0">
<li v-for="error in errors" :key="error">
{{ error }}
</li>
diff --git a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue
index 78a575ffe96..a58b6e62254 100644
--- a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue
+++ b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue
@@ -37,11 +37,11 @@ export default {
<gl-button
:class="[
{
- 'ml-3': !contextCommitsEmpty,
- 'mt-3': !commitsEmpty && contextCommitsEmpty,
+ 'gl-ml-5': !contextCommitsEmpty,
+ 'gl-mt-5': !commitsEmpty && contextCommitsEmpty,
},
]"
- :variant="commitsEmpty ? 'info' : 'default'"
+ :variant="commitsEmpty ? 'confirm' : 'default'"
@click="openModal"
>
{{ buttonText }}
diff --git a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue
index 96584080d0f..8ad218ab97b 100644
--- a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue
+++ b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue
@@ -244,7 +244,7 @@ export default {
</template>
</gl-sprintf>
</template>
- <div class="mt-2">
+ <div class="gl-mt-3">
<gl-search-box-by-type
ref="searchInput"
:placeholder="__(`Search by commit title or SHA`)"
diff --git a/app/assets/javascripts/add_context_commits_modal/index.js b/app/assets/javascripts/add_context_commits_modal/index.js
index 697d32664e8..110677781a7 100644
--- a/app/assets/javascripts/add_context_commits_modal/index.js
+++ b/app/assets/javascripts/add_context_commits_modal/index.js
@@ -8,7 +8,7 @@ export default function initAddContextCommitsTriggers() {
const addContextCommitsModalTriggerEl = document.querySelector('.add-review-item-modal-trigger');
const addContextCommitsModalWrapperEl = document.querySelector('.add-review-item-modal-wrapper');
- if (addContextCommitsModalTriggerEl || addContextCommitsModalWrapperEl) {
+ if (addContextCommitsModalTriggerEl) {
// eslint-disable-next-line no-new
new Vue({
el: addContextCommitsModalTriggerEl,
@@ -28,7 +28,9 @@ export default function initAddContextCommitsTriggers() {
});
},
});
+ }
+ if (addContextCommitsModalWrapperEl) {
const store = createStore();
// eslint-disable-next-line no-new
diff --git a/app/assets/javascripts/admin/deploy_keys/components/table.vue b/app/assets/javascripts/admin/deploy_keys/components/table.vue
index 29e8b9a724e..46e7ac3cf28 100644
--- a/app/assets/javascripts/admin/deploy_keys/components/table.vue
+++ b/app/assets/javascripts/admin/deploy_keys/components/table.vue
@@ -35,8 +35,12 @@ export default {
label: __('Title'),
},
{
+ key: 'fingerprint_sha256',
+ label: __('Fingerprint (SHA256)'),
+ },
+ {
key: 'fingerprint',
- label: __('Fingerprint'),
+ label: __('Fingerprint (MD5)'),
},
{
key: 'projects',
@@ -130,10 +134,18 @@ export default {
}
this.items = items.map(
- ({ id, title, fingerprint, projects_with_write_access, created_at }) => ({
+ ({
id,
title,
fingerprint,
+ fingerprint_sha256,
+ projects_with_write_access,
+ created_at,
+ }) => ({
+ id,
+ title,
+ fingerprint,
+ fingerprint_sha256,
projects: projects_with_write_access,
created: created_at,
}),
@@ -196,8 +208,12 @@ export default {
>
</template>
+ <template #cell(fingerprint_sha256)="{ item: { fingerprint_sha256 } }">
+ <span v-if="fingerprint_sha256" class="monospace">{{ fingerprint_sha256 }}</span>
+ </template>
+
<template #cell(fingerprint)="{ item: { fingerprint } }">
- <code>{{ fingerprint }}</code>
+ <span v-if="fingerprint" class="monospace">{{ fingerprint }}</span>
</template>
<template #cell(created)="{ item: { created } }">
diff --git a/app/assets/javascripts/admin/statistics_panel/components/app.vue b/app/assets/javascripts/admin/statistics_panel/components/app.vue
index f250bdae4f5..347d5f0229c 100644
--- a/app/assets/javascripts/admin/statistics_panel/components/app.vue
+++ b/app/assets/javascripts/admin/statistics_panel/components/app.vue
@@ -1,10 +1,11 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlCard, GlLoadingIcon } from '@gitlab/ui';
import { mapState, mapGetters, mapActions } from 'vuex';
import statisticsLabels from '../constants';
export default {
components: {
+ GlCard,
GlLoadingIcon,
},
data() {
@@ -26,20 +27,14 @@ export default {
</script>
<template>
- <div class="gl-card">
- <div class="gl-card-body">
- <h4>{{ __('Statistics') }}</h4>
- <gl-loading-icon v-if="isLoading" size="lg" class="my-3" />
- <template v-else>
- <p
- v-for="statistic in getStatistics(statisticsLabels)"
- :key="statistic.key"
- class="js-stats"
- >
- {{ statistic.label }}
- <span class="light float-right">{{ statistic.value }}</span>
- </p>
- </template>
- </div>
- </div>
+ <gl-card>
+ <h4>{{ __('Statistics') }}</h4>
+ <gl-loading-icon v-if="isLoading" size="lg" class="my-3" />
+ <template v-else>
+ <p v-for="statistic in getStatistics(statisticsLabels)" :key="statistic.key" class="js-stats">
+ {{ statistic.label }}
+ <span class="light float-right">{{ statistic.value }}</span>
+ </p>
+ </template>
+ </gl-card>
</template>
diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue
index 40e5f8d9d70..691a292673c 100644
--- a/app/assets/javascripts/admin/users/components/user_actions.vue
+++ b/app/assets/javascripts/admin/users/components/user_actions.vue
@@ -94,13 +94,13 @@ export default {
:data-testid="`user-actions-${user.id}`"
>
<div v-if="hasEditAction" class="gl-p-2">
- <gl-button v-if="showButtonLabels" v-bind="editButtonAttrs">{{
+ <gl-button v-if="showButtonLabels" v-bind="editButtonAttrs" icon="pencil-square">{{
$options.i18n.edit
}}</gl-button>
<gl-button
v-else
v-gl-tooltip="$options.i18n.edit"
- icon="pencil"
+ icon="pencil-square"
v-bind="editButtonAttrs"
:aria-label="$options.i18n.edit"
/>
@@ -108,18 +108,12 @@ export default {
<div v-if="hasDropdownActions" class="gl-p-2">
<gl-dropdown
- v-gl-tooltip="$options.i18n.userAdministration"
+ :text="$options.i18n.userAdministration"
data-testid="dropdown-toggle"
- icon="ellipsis_v"
data-qa-selector="user_actions_dropdown_toggle"
:data-qa-username="user.username"
- no-caret
- right
+ left
>
- <gl-dropdown-section-header>{{
- $options.i18n.userAdministration
- }}</gl-dropdown-section-header>
-
<template v-for="action in dropdownSafeActions">
<component
:is="getActionComponent(action)"
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 5a394059931..fd966425920 100644
--- a/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue
+++ b/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue
@@ -40,7 +40,7 @@ export default {
return this.devopsScoreMetrics.averageScore === undefined;
},
},
- devopsReportDocsPath: helpPagePath('user/admin_area/analytics/dev_ops_report'),
+ devopsReportDocsPath: helpPagePath('user/admin_area/analytics/dev_ops_reports'),
tableHeaderFields: [
{
key: 'title',
diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js
index 38d05552783..e1bc59b36ef 100644
--- a/app/assets/javascripts/analytics/shared/constants.js
+++ b/app/assets/javascripts/analytics/shared/constants.js
@@ -16,60 +16,32 @@ export const dateFormats = {
// Some content is duplicated due to backward compatibility.
// It will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/350614 in 14.9
export const METRICS_POPOVER_CONTENT = {
- 'lead-time': {
- description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'),
- },
lead_time: {
description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'),
},
- 'cycle-time': {
- description: s__(
- "ValueStreamAnalytics|Median time from the earliest commit of a linked issue's merge request to when that issue is closed.",
- ),
- },
cycle_time: {
description: s__(
"ValueStreamAnalytics|Median time from the earliest commit of a linked issue's merge request to when that issue is closed.",
),
},
- 'lead-time-for-changes': {
- description: s__(
- 'ValueStreamAnalytics|Median time between merge request merge and deployment to a production environment for all MRs deployed in the given time period.',
- ),
- },
lead_time_for_changes: {
description: s__(
'ValueStreamAnalytics|Median time between merge request merge and deployment to a production environment for all MRs deployed in the given time period.',
),
},
issues: { description: s__('ValueStreamAnalytics|Number of new issues created.') },
- 'new-issue': { description: s__('ValueStreamAnalytics|Number of new issues created.') },
- 'new-issues': { description: s__('ValueStreamAnalytics|Number of new issues created.') },
deploys: { description: s__('ValueStreamAnalytics|Total number of deploys to production.') },
- 'deployment-frequency': {
- description: s__('ValueStreamAnalytics|Average number of deployments to production per day.'),
- },
deployment_frequency: {
description: s__('ValueStreamAnalytics|Average number of deployments to production per day.'),
},
commits: {
description: s__('ValueStreamAnalytics|Number of commits pushed to the default branch'),
},
- 'time-to-restore-service': {
- description: s__(
- 'ValueStreamAnalytics|Median time an incident was open on a production environment in the given time period.',
- ),
- },
time_to_restore_service: {
description: s__(
'ValueStreamAnalytics|Median time an incident was open on a production environment in the given time period.',
),
},
- 'change-failure-rate': {
- description: s__(
- 'ValueStreamAnalytics|Percentage of deployments that cause an incident in production.',
- ),
- },
change_failure_rate: {
description: s__(
'ValueStreamAnalytics|Percentage of deployments that cause an incident in production.',
diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue
index 2b1ab911fbe..300a81caa5c 100644
--- a/app/assets/javascripts/batch_comments/components/draft_note.vue
+++ b/app/assets/javascripts/batch_comments/components/draft_note.vue
@@ -1,6 +1,7 @@
<script>
import { GlButton, GlSafeHtmlDirective, GlBadge } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import NoteableNote from '~/notes/components/noteable_note.vue';
import PublishButton from './publish_button.vue';
@@ -14,6 +15,7 @@ export default {
directives: {
SafeHtml: GlSafeHtmlDirective,
},
+ mixins: [glFeatureFlagMixin()],
props: {
draft: {
type: Object,
@@ -92,6 +94,7 @@ export default {
:note="draft"
:line="line"
:discussion-root="true"
+ :class="{ 'gl-mb-0!': glFeatures.mrReviewSubmitComment }"
class="draft-note"
@handleEdit="handleEditing"
@cancelForm="handleNotEditing"
@@ -113,7 +116,11 @@ export default {
class="referenced-commands draft-note-commands"
></div>
- <p class="draft-note-actions d-flex" data-qa-selector="draft_note_content">
+ <p
+ v-if="!glFeatures.mrReviewSubmitComment"
+ class="draft-note-actions d-flex"
+ data-qa-selector="draft_note_content"
+ >
<publish-button
:show-count="true"
:should-publish="false"
diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
index f839056daf8..ba5cc0d1a76 100644
--- a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
@@ -2,6 +2,7 @@
import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { setUrlParams, visitUrl } from '~/lib/utils/url_utility';
import PreviewItem from './preview_item.vue';
import DraftsCount from './drafts_count.vue';
@@ -17,6 +18,7 @@ export default {
computed: {
...mapState('diffs', ['viewDiffsFileByFile']),
...mapGetters('batchComments', ['draftsCount', 'sortedDrafts']),
+ ...mapGetters(['getNoteableData']),
},
methods: {
...mapActions('diffs', ['setCurrentFileHash']),
@@ -24,12 +26,21 @@ export default {
isLast(index) {
return index === this.sortedDrafts.length - 1;
},
+ isOnLatestDiff(draft) {
+ return draft.position?.head_sha === this.getNoteableData.diff_head_sha;
+ },
async onClickDraft(draft) {
if (this.viewDiffsFileByFile && draft.file_hash) {
await this.setCurrentFileHash(draft.file_hash);
}
- await this.scrollToDraft(draft);
+ if (draft.position && !this.isOnLatestDiff(draft)) {
+ const url = new URL(setUrlParams({ commit_id: draft.position.head_sha }));
+ url.hash = `note_${draft.id}`;
+ visitUrl(url.toString());
+ } else {
+ await this.scrollToDraft(draft);
+ }
},
},
};
diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
index 5f4a1e44ea3..b070848cae9 100644
--- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
@@ -22,6 +22,18 @@ export default {
computed: {
...mapGetters(['getNotesData', 'getNoteableData', 'noteableType', 'getCurrentUserLastNote']),
},
+ mounted() {
+ // We override the Bootstrap Vue click outside behaviour
+ // to allow for clicking in the autocomplete dropdowns
+ // without this override the submit dropdown will close
+ // whenever a item in the autocomplete dropdown is clicked
+ const originalClickOutHandler = this.$refs.dropdown.$refs.dropdown.clickOutHandler;
+ this.$refs.dropdown.$refs.dropdown.clickOutHandler = (e) => {
+ if (!e.target.closest('.atwho-container')) {
+ originalClickOutHandler(e);
+ }
+ };
+ },
methods: {
...mapActions('batchComments', ['publishReview']),
async submitReview() {
@@ -52,7 +64,13 @@ export default {
</script>
<template>
- <gl-dropdown right class="submit-review-dropdown" variant="info" category="secondary">
+ <gl-dropdown
+ ref="dropdown"
+ right
+ class="submit-review-dropdown"
+ variant="info"
+ category="secondary"
+ >
<template #button-content>
{{ __('Finish review') }}
<gl-icon class="dropdown-chevron" name="chevron-up" />
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
index 908cbfd6dc8..a44b9827fe9 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
@@ -138,6 +138,12 @@ export const scrollToDraft = ({ dispatch, rootGetters }, draft) => {
window.mrTabs.tabShown(tab);
}
+ const { file_path: filePath } = draft;
+
+ if (filePath) {
+ dispatch('diffs/setFileCollapsedAutomatically', { filePath, collapsed: false }, { root: true });
+ }
+
if (discussion) {
dispatch('expandDiscussion', { discussionId: discussion.id }, { root: true });
}
diff --git a/app/assets/javascripts/blob/3d_viewer/index.js b/app/assets/javascripts/blob/3d_viewer/index.js
index fd064e7ca8f..d4efe409fef 100644
--- a/app/assets/javascripts/blob/3d_viewer/index.js
+++ b/app/assets/javascripts/blob/3d_viewer/index.js
@@ -98,9 +98,9 @@ export default class Renderer {
requestAnimationFrame(this.renderWrapper);
}
- changeObjectMaterials(type) {
+ changeObjectMaterials(material) {
this.objects.forEach((obj) => {
- obj.changeMaterial(type);
+ obj.changeMaterial(material);
});
}
diff --git a/app/assets/javascripts/blob/3d_viewer/mesh_object.js b/app/assets/javascripts/blob/3d_viewer/mesh_object.js
index cb7fcff8674..c55a9ca8926 100644
--- a/app/assets/javascripts/blob/3d_viewer/mesh_object.js
+++ b/app/assets/javascripts/blob/3d_viewer/mesh_object.js
@@ -30,7 +30,7 @@ export default class MeshObject extends Mesh {
}
}
- changeMaterial(type) {
- this.material = materials[type];
+ changeMaterial(materialKey) {
+ this.material = materials[materialKey];
}
}
diff --git a/app/assets/javascripts/blob/stl_viewer.js b/app/assets/javascripts/blob/stl_viewer.js
index 0ea623a705a..768bbce9c57 100644
--- a/app/assets/javascripts/blob/stl_viewer.js
+++ b/app/assets/javascripts/blob/stl_viewer.js
@@ -5,15 +5,15 @@ export default () => {
[].slice.call(document.querySelectorAll('.js-material-changer')).forEach((el) => {
el.addEventListener('click', (e) => {
- const { target } = e;
+ const { currentTarget } = e;
e.preventDefault();
document.querySelector('.js-material-changer.selected').classList.remove('selected');
- target.classList.add('selected');
- target.blur();
+ currentTarget.classList.add('selected');
+ currentTarget.blur();
- viewer.changeObjectMaterials(target.dataset.type);
+ viewer.changeObjectMaterials(currentTarget.dataset.material);
});
});
};
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue
index dc821cb9f58..3638fdd2ca5 100644
--- a/app/assets/javascripts/boards/components/board_card.vue
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -48,6 +48,15 @@ export default {
isDraggable() {
return !this.disabled && this.item.id && !this.item.isLoading;
},
+ cardStyle() {
+ return this.isColorful && this.item.color ? { borderColor: this.item.color } : '';
+ },
+ isColorful() {
+ return gon?.features?.epicColorHighlight;
+ },
+ colorClass() {
+ return this.isColorful ? 'gl-pl-4 gl-border-l-solid gl-border-4' : '';
+ },
},
methods: {
...mapActions(['toggleBoardItemMultiSelection', 'toggleBoardItem']),
@@ -70,17 +79,21 @@ export default {
<template>
<li
data-qa-selector="board_card"
- :class="{
- 'multi-select': multiSelectVisible,
- 'gl-cursor-grab': isDraggable,
- 'is-disabled': isDisabled,
- 'is-active': isActive,
- 'gl-cursor-not-allowed gl-bg-gray-10': item.isLoading,
- }"
+ :class="[
+ {
+ 'multi-select': multiSelectVisible,
+ 'gl-cursor-grab': isDraggable,
+ 'is-disabled': isDisabled,
+ 'is-active': isActive,
+ 'gl-cursor-not-allowed gl-bg-gray-10': item.isLoading,
+ },
+ colorClass,
+ ]"
:index="index"
:data-item-id="item.id"
:data-item-iid="item.iid"
:data-item-path="item.referencePath"
+ :style="cardStyle"
data-testid="board_card"
class="board-card gl-p-5 gl-rounded-base"
@click="toggleIssue($event)"
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index 98ce1ac7f97..a632f5ae0ed 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -243,6 +243,7 @@ export default {
:description="label.description"
size="sm"
:scoped="showScopedLabel(label)"
+ target="#"
@click="filterByLabel(label)"
/>
</template>
@@ -253,7 +254,7 @@ export default {
<div
class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden"
>
- <gl-loading-icon v-if="item.isLoading" size="lg" class="mt-3" />
+ <gl-loading-icon v-if="item.isLoading" size="lg" class="gl-mt-5" />
<span
v-if="item.referencePath"
class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3"
diff --git a/app/assets/javascripts/boards/components/toggle_focus.vue b/app/assets/javascripts/boards/components/toggle_focus.vue
index 71612e0742f..990a6fa63d4 100644
--- a/app/assets/javascripts/boards/components/toggle_focus.vue
+++ b/app/assets/javascripts/boards/components/toggle_focus.vue
@@ -20,7 +20,7 @@ export default {
hide(this.$refs.toggleFocusModeButton);
const issueBoardsContent = document.querySelector('.content-wrapper > .js-focus-mode-board');
- issueBoardsContent.classList.toggle('is-focused');
+ issueBoardsContent?.classList.toggle('is-focused');
this.isFullscreen = !this.isFullscreen;
},
diff --git a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue
index ebcc4b85ac4..9d8cb40b60a 100644
--- a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue
+++ b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue
@@ -3,7 +3,6 @@ import {
GlAlert,
GlButton,
GlIcon,
- GlLink,
GlLoadingIcon,
GlModal,
GlModalDirective,
@@ -14,9 +13,9 @@ import {
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import Api, { DEFAULT_PER_PAGE } from '~/api';
-import { helpPagePath } from '~/helpers/help_page_helper';
import httpStatusCodes from '~/lib/utils/http_status';
import { __, s__, sprintf } from '~/locale';
+import Tracking from '~/tracking';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
@@ -24,7 +23,6 @@ export default {
GlAlert,
GlButton,
GlIcon,
- GlLink,
GlLoadingIcon,
GlModal,
GlPagination,
@@ -36,22 +34,18 @@ export default {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
+ mixins: [Tracking.mixin()],
inject: ['projectId', 'admin', 'fileSizeLimit'],
- docsLink: helpPagePath('ci/secure_files/index'),
DEFAULT_PER_PAGE,
i18n: {
deleteLabel: __('Delete File'),
uploadLabel: __('Upload File'),
uploadingLabel: __('Uploading...'),
+ noFilesMessage: __('There are no secure files yet.'),
pagination: {
next: __('Next'),
prev: __('Prev'),
},
- title: __('Secure Files'),
- overviewMessage: __(
- 'Use Secure Files to store files used by your pipelines such as Android keystores, or Apple provisioning profiles and signing certificates.',
- ),
- moreInformation: __('More information'),
uploadErrorMessages: {
duplicate: __('A file with this name already exists.'),
tooLarge: __('File too large. Secure Files must be less than %{limit} MB.'),
@@ -79,12 +73,12 @@ export default {
fields: [
{
key: 'name',
- label: __('Filename'),
+ label: __('File name'),
tdClass: 'gl-vertical-align-middle!',
},
{
key: 'created_at',
- label: __('Uploaded'),
+ label: __('Uploaded date'),
tdClass: 'gl-vertical-align-middle!',
},
{
@@ -113,6 +107,8 @@ export default {
try {
await Api.deleteProjectSecureFile(this.projectId, secureFileId);
this.getProjectSecureFiles();
+
+ this.track('delete_secure_file');
} catch (error) {
Sentry.captureException(error);
this.error = true;
@@ -129,6 +125,7 @@ export default {
this.loading = false;
this.uploading = false;
+ this.track('render_secure_files_list');
},
async uploadSecureFile() {
this.error = null;
@@ -137,6 +134,7 @@ export default {
try {
await Api.uploadProjectSecureFile(this.projectId, this.uploadFormData(file));
this.getProjectSecureFiles();
+ this.track('upload_secure_file');
} catch (error) {
this.error = true;
this.errorMessage = this.formattedErrorMessage(error);
@@ -157,7 +155,7 @@ export default {
}
return message;
},
- loadFileSelctor() {
+ loadFileSelector() {
this.$refs.fileUpload.click();
},
setDeleteModalData(secureFile) {
@@ -177,91 +175,74 @@ export default {
<template>
<div>
- <gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="error = null">
- {{ errorMessage }}
- </gl-alert>
- <div class="row">
- <div class="col-md-12 col-lg-6 gl-display-flex">
- <div class="gl-flex-direction-column gl-flex-wrap">
- <h1 class="gl-font-size-h1 gl-mt-3 gl-mb-0">
- {{ $options.i18n.title }}
- </h1>
- </div>
- </div>
+ <div class="ci-secure-files-table">
+ <gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="error = null">
+ {{ errorMessage }}
+ </gl-alert>
+
+ <gl-table
+ :busy="loading"
+ :fields="fields"
+ :items="projectSecureFiles"
+ tbody-tr-class="js-ci-secure-files-row"
+ data-qa-selector="ci_secure_files_table_content"
+ sort-by="key"
+ sort-direction="asc"
+ stacked="lg"
+ table-class="text-secondary"
+ show-empty
+ sort-icon-left
+ no-sort-reset
+ :empty-text="$options.i18n.noFilesMessage"
+ >
+ <template #table-busy>
+ <gl-loading-icon size="lg" class="gl-my-5" />
+ </template>
+
+ <template #cell(name)="{ item }">
+ {{ item.name }}
+ </template>
- <div class="col-md-12 col-lg-6">
- <div class="gl-display-flex gl-flex-wrap gl-justify-content-end">
- <gl-button v-if="admin" class="gl-mt-3" variant="confirm" @click="loadFileSelctor">
- <span v-if="uploading">
- <gl-loading-icon size="sm" class="gl-my-5" inline />
- {{ $options.i18n.uploadingLabel }}
- </span>
- <span v-else>
- <gl-icon name="upload" class="gl-mr-2" /> {{ $options.i18n.uploadLabel }}
- </span>
- </gl-button>
- <input
- id="file-upload"
- ref="fileUpload"
- type="file"
- class="hidden"
- data-qa-selector="file_upload_field"
- @change="uploadSecureFile"
+ <template #cell(created_at)="{ item }">
+ <timeago-tooltip :time="item.created_at" />
+ </template>
+
+ <template #cell(actions)="{ item }">
+ <gl-button
+ v-if="admin"
+ v-gl-modal="$options.deleteModalId"
+ v-gl-tooltip.hover.top="$options.i18n.deleteLabel"
+ category="secondary"
+ variant="danger"
+ icon="remove"
+ :aria-label="$options.i18n.deleteLabel"
+ data-testid="delete-button"
+ @click="setDeleteModalData(item)"
/>
- </div>
- </div>
+ </template>
+ </gl-table>
</div>
- <div class="row">
- <div class="col-md-12 col-lg-12 gl-my-4">
- <span data-testid="info-message">
- {{ $options.i18n.overviewMessage }}
- <gl-link :href="$options.docsLink" target="_blank">{{
- $options.i18n.moreInformation
- }}</gl-link>
+ <div class="gl-display-flex gl-mt-5">
+ <gl-button v-if="admin" variant="confirm" @click="loadFileSelector">
+ <span v-if="uploading">
+ <gl-loading-icon class="gl-my-5" inline />
+ {{ $options.i18n.uploadingLabel }}
+ </span>
+ <span v-else>
+ <gl-icon name="upload" class="gl-mr-2" /> {{ $options.i18n.uploadLabel }}
</span>
- </div>
+ </gl-button>
+ <input
+ id="file-upload"
+ ref="fileUpload"
+ type="file"
+ class="hidden"
+ data-qa-selector="file_upload_field"
+ @change="uploadSecureFile"
+ />
</div>
- <gl-table
- :busy="loading"
- :fields="fields"
- :items="projectSecureFiles"
- tbody-tr-class="js-ci-secure-files-row"
- data-qa-selector="ci_secure_files_table_content"
- sort-by="key"
- sort-direction="asc"
- stacked="lg"
- table-class="text-secondary"
- show-empty
- sort-icon-left
- no-sort-reset
- >
- <template #table-busy>
- <gl-loading-icon size="lg" class="gl-my-5" />
- </template>
-
- <template #cell(name)="{ item }">
- {{ item.name }}
- </template>
-
- <template #cell(created_at)="{ item }">
- <timeago-tooltip :time="item.created_at" />
- </template>
-
- <template #cell(actions)="{ item }">
- <gl-button
- v-if="admin"
- v-gl-modal="$options.deleteModalId"
- v-gl-tooltip.hover.top="$options.i18n.deleteLabel"
- variant="danger"
- icon="remove"
- :aria-label="$options.i18n.deleteLabel"
- @click="setDeleteModalData(item)"
- />
- </template>
- </gl-table>
-
<gl-pagination
v-if="!loading"
v-model="page"
diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue
index 496baf8cb08..e0e3b961c51 100644
--- a/app/assets/javascripts/clusters_list/components/agent_table.vue
+++ b/app/assets/javascripts/clusters_list/components/agent_table.vue
@@ -58,7 +58,7 @@ export default {
},
computed: {
fields() {
- const tdClass = 'gl-py-5!';
+ const tdClass = 'gl-pt-3! gl-pb-4! gl-vertical-align-middle!';
return [
{
key: 'name',
@@ -184,7 +184,7 @@ export default {
data-testid="cluster-agent-connection-status"
>
<span :class="$options.AGENT_STATUSES[item.status].class" class="gl-mr-3">
- <gl-icon :name="$options.AGENT_STATUSES[item.status].icon" :size="12" /></span
+ <gl-icon :name="$options.AGENT_STATUSES[item.status].icon" :size="16" /></span
>{{ $options.AGENT_STATUSES[item.status].name }}
</span>
<gl-tooltip v-if="item.status === 'active'" :target="getStatusCellId(item)" placement="right">
diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js
index 10e71513065..7bc8a1a7304 100644
--- a/app/assets/javascripts/clusters_list/constants.js
+++ b/app/assets/javascripts/clusters_list/constants.js
@@ -145,8 +145,8 @@ export const AGENT_STATUSES = {
},
inactive: {
name: s__('ClusterAgents|Not connected'),
- icon: 'severity-critical',
- class: 'text-danger-800',
+ icon: 'status-alert',
+ class: 'text-danger-500',
tooltip: {
title: s__('ClusterAgents|Agent might not be connected to GitLab'),
body: sprintf(
@@ -159,7 +159,7 @@ export const AGENT_STATUSES = {
unused: {
name: s__('ClusterAgents|Never connected'),
icon: 'status-neutral',
- class: 'text-secondary-400',
+ class: 'text-secondary-500',
tooltip: {
title: s__('ClusterAgents|Agent never connected to GitLab'),
body: s__('ClusterAgents|Make sure you are using a valid token.'),
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue b/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue
index e35fbf14de5..f0726ff3e63 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue
@@ -91,6 +91,26 @@ export default {
@execute="trackToolbarControlExecution"
/>
<toolbar-button
+ data-testid="superscript"
+ content-type="superscript"
+ icon-name="superscript"
+ editor-command="toggleSuperscript"
+ category="tertiary"
+ size="medium"
+ :label="__('Superscript')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="subscript"
+ content-type="subscript"
+ icon-name="subscript"
+ editor-command="toggleSubscript"
+ category="tertiary"
+ size="medium"
+ :label="__('Subscript')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
data-testid="link"
content-type="link"
icon-name="link"
diff --git a/app/assets/javascripts/content_editor/components/content_editor_provider.vue b/app/assets/javascripts/content_editor/components/content_editor_provider.vue
index cba3b627390..5dcff1f6295 100644
--- a/app/assets/javascripts/content_editor/components/content_editor_provider.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor_provider.vue
@@ -19,7 +19,7 @@ export default {
},
},
render() {
- return this.$slots.default;
+ return this.$scopedSlots.default?.();
},
};
</script>
diff --git a/app/assets/javascripts/content_editor/components/editor_state_observer.vue b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
index 02de6470cf2..252f69f7a5d 100644
--- a/app/assets/javascripts/content_editor/components/editor_state_observer.vue
+++ b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
@@ -58,7 +58,7 @@ export default {
},
},
render() {
- return this.$slots.default;
+ return this.$scopedSlots.default?.();
},
};
</script>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
index ecde593147c..6e4cde5dad6 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
@@ -10,9 +10,31 @@ export default {
GlTooltip,
},
inject: ['tiptapEditor'],
+ data() {
+ return {
+ isActive: {},
+ };
+ },
methods: {
- execute(contentType, attrs) {
- this.tiptapEditor.chain().focus().setNode(contentType, attrs).run();
+ insert(contentType, ...args) {
+ this.tiptapEditor
+ .chain()
+ .focus()
+ .setNode(contentType, ...args)
+ .run();
+
+ this.$emit('execute', { contentType });
+ },
+
+ insertList(listType, listItemType) {
+ if (!this.tiptapEditor.isActive(listType))
+ this.tiptapEditor.chain().focus().toggleList(listType, listItemType).run();
+
+ this.$emit('execute', { contentType: listType });
+ },
+
+ execute(command, contentType) {
+ this.tiptapEditor.chain().focus()[command]().run();
this.$emit('execute', { contentType });
},
@@ -20,15 +42,30 @@ export default {
};
</script>
<template>
- <gl-dropdown size="small" category="tertiary" icon="plus">
- <gl-dropdown-item @click="execute('diagram', { language: 'mermaid' })">
- {{ __('Mermaid diagram') }}
+ <gl-dropdown size="small" category="tertiary" icon="plus" class="content-editor-dropdown" right>
+ <gl-dropdown-item @click="insert('codeBlock')">
+ {{ __('Code block') }}
</gl-dropdown-item>
- <gl-dropdown-item @click="execute('diagram', { language: 'plantuml' })">
- {{ __('PlantUML diagram') }}
+ <gl-dropdown-item @click="insertList('details', 'detailsContent')">
+ {{ __('Details block') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item class="gl-sm-display-none!" @click="insertList('bulletList', 'listItem')">
+ {{ __('Bullet list') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item class="gl-sm-display-none!" @click="insertList('orderedList', 'listItem')">
+ {{ __('Ordered list') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item class="gl-sm-display-none!" @click="insertList('taskList', 'taskItem')">
+ {{ __('Task list') }}
</gl-dropdown-item>
- <gl-dropdown-item @click="execute('horizontalRule')">
+ <gl-dropdown-item @click="execute('setHorizontalRule', 'horizontalRule')">
{{ __('Horizontal rule') }}
</gl-dropdown-item>
+ <gl-dropdown-item @click="insert('diagram', { language: 'mermaid' })">
+ {{ __('Mermaid diagram') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item @click="insert('diagram', { language: 'plantuml' })">
+ {{ __('PlantUML diagram') }}
+ </gl-dropdown-item>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
index 46db806da94..18928acef3c 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
@@ -62,7 +62,7 @@ export default {
};
</script>
<template>
- <gl-dropdown size="small" category="tertiary" icon="table" class="table-dropdown">
+ <gl-dropdown size="small" category="tertiary" icon="table" class="content-editor-dropdown" right>
<gl-dropdown-form class="gl-px-3!">
<div v-for="r of list(maxRows)" :key="r" class="gl-display-flex">
<gl-button
diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue
index b652e634b0c..65d71814268 100644
--- a/app/assets/javascripts/content_editor/components/top_toolbar.vue
+++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue
@@ -51,12 +51,12 @@ export default {
@execute="trackToolbarControlExecution"
/>
<toolbar-button
- data-testid="strike"
- content-type="strike"
- icon-name="strikethrough"
+ data-testid="blockquote"
+ content-type="blockquote"
+ icon-name="quote"
class="gl-mx-2"
- editor-command="toggleStrike"
- :label="__('Strikethrough')"
+ editor-command="toggleBlockquote"
+ :label="__('Insert a quote')"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
@@ -69,34 +69,11 @@ export default {
@execute="trackToolbarControlExecution"
/>
<toolbar-link-button data-testid="link" @execute="trackToolbarControlExecution" />
- <toolbar-image-button
- ref="imageButton"
- data-testid="image"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="blockquote"
- content-type="blockquote"
- icon-name="quote"
- class="gl-mx-2"
- editor-command="toggleBlockquote"
- :label="__('Insert a quote')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="code-block"
- content-type="codeBlock"
- icon-name="doc-code"
- class="gl-mx-2"
- editor-command="toggleCodeBlock"
- :label="__('Insert a code block')"
- @execute="trackToolbarControlExecution"
- />
<toolbar-button
data-testid="bullet-list"
content-type="bulletList"
icon-name="list-bulleted"
- class="gl-mx-2"
+ class="gl-mx-2 gl-display-none gl-sm-display-inline"
editor-command="toggleBulletList"
:label="__('Add a bullet list')"
@execute="trackToolbarControlExecution"
@@ -105,18 +82,23 @@ export default {
data-testid="ordered-list"
content-type="orderedList"
icon-name="list-numbered"
- class="gl-mx-2"
+ class="gl-mx-2 gl-display-none gl-sm-display-inline"
editor-command="toggleOrderedList"
:label="__('Add a numbered list')"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
- data-testid="details"
- content-type="details"
- icon-name="details-block"
- class="gl-mx-2"
- editor-command="toggleDetails"
- :label="__('Add a collapsible section')"
+ data-testid="task-list"
+ content-type="taskList"
+ icon-name="list-task"
+ class="gl-mx-2 gl-display-none gl-sm-display-inline"
+ editor-command="toggleTaskList"
+ :label="__('Add a task list')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-image-button
+ ref="imageButton"
+ data-testid="image"
@execute="trackToolbarControlExecution"
/>
<toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" />
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 61f6a233694..edf8b3d3a0b 100644
--- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -42,11 +42,14 @@ export default CodeBlockLowlight.extend({
},
parseHTML() {
return [
- ...(this.parent?.() || []),
{
tag: 'div.markdown-code-block',
skip: true,
},
+ {
+ tag: 'pre.js-syntax-highlight',
+ preserveWhitespace: 'full',
+ },
];
},
renderHTML({ HTMLAttributes }) {
diff --git a/app/assets/javascripts/content_editor/extensions/division.js b/app/assets/javascripts/content_editor/extensions/division.js
deleted file mode 100644
index 566ed85acf3..00000000000
--- a/app/assets/javascripts/content_editor/extensions/division.js
+++ /dev/null
@@ -1,31 +0,0 @@
-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 }];
- },
-
- renderHTML({ HTMLAttributes }) {
- return ['div', HTMLAttributes, 0];
- },
-});
diff --git a/app/assets/javascripts/content_editor/extensions/html_nodes.js b/app/assets/javascripts/content_editor/extensions/html_nodes.js
new file mode 100644
index 00000000000..23409354814
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/html_nodes.js
@@ -0,0 +1,25 @@
+import { Node } from '@tiptap/core';
+import { PARSE_HTML_PRIORITY_LOWEST } from '../constants';
+
+const tags = ['div', 'pre'];
+
+const createHtmlNodeExtension = (tagName) =>
+ Node.create({
+ name: tagName,
+ content: 'block*',
+ group: 'block',
+ defining: true,
+ addOptions() {
+ return {
+ tagName,
+ };
+ },
+ parseHTML() {
+ return [{ tag: tagName, priority: PARSE_HTML_PRIORITY_LOWEST }];
+ },
+ renderHTML({ HTMLAttributes }) {
+ return [tagName, HTMLAttributes, 0];
+ },
+ });
+
+export default tags.map(createHtmlNodeExtension);
diff --git a/app/assets/javascripts/content_editor/extensions/sourcemap.js b/app/assets/javascripts/content_editor/extensions/sourcemap.js
index 87118074462..618f17b1c5e 100644
--- a/app/assets/javascripts/content_editor/extensions/sourcemap.js
+++ b/app/assets/javascripts/content_editor/extensions/sourcemap.js
@@ -9,6 +9,7 @@ import FootnoteDefinition from './footnote_definition';
import Heading from './heading';
import HardBreak from './hard_break';
import HorizontalRule from './horizontal_rule';
+import HTMLNodes from './html_nodes';
import Image from './image';
import Italic from './italic';
import Link from './link';
@@ -51,13 +52,22 @@ export default Extension.create({
TableCell.name,
TableHeader.name,
TableRow.name,
+ ...HTMLNodes.map((htmlNode) => htmlNode.name),
],
attributes: {
+ /**
+ * The reason to add a function that returns an empty
+ * string in these attributes is indicate that these
+ * attributes shouldn’t be rendered in the ProseMirror
+ * view.
+ */
sourceMarkdown: {
default: null,
+ renderHTML: () => '',
},
sourceMapKey: {
default: null,
+ renderHTML: () => '',
},
},
},
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js
index 06757e7a280..867bf0b4d55 100644
--- a/app/assets/javascripts/content_editor/services/content_editor.js
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -39,12 +39,12 @@ export class ContentEditor {
this._eventHub.dispose();
}
- deserialize(serializedContent) {
+ deserialize(markdown) {
const { _tiptapEditor: editor, _deserializer: deserializer } = this;
return deserializer.deserialize({
schema: editor.schema,
- content: serializedContent,
+ markdown,
});
}
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 15aac3d86e5..c5cfa9a4285 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -16,7 +16,6 @@ import DescriptionList from '../extensions/description_list';
import Details from '../extensions/details';
import DetailsContent from '../extensions/details_content';
import Diagram from '../extensions/diagram';
-import Division from '../extensions/division';
import Document from '../extensions/document';
import Dropcursor from '../extensions/dropcursor';
import Emoji from '../extensions/emoji';
@@ -32,6 +31,7 @@ import Heading from '../extensions/heading';
import History from '../extensions/history';
import HorizontalRule from '../extensions/horizontal_rule';
import HTMLMarks from '../extensions/html_marks';
+import HTMLNodes from '../extensions/html_nodes';
import Image from '../extensions/image';
import InlineDiff from '../extensions/inline_diff';
import Italic from '../extensions/italic';
@@ -103,7 +103,6 @@ export const createContentEditor = ({
DetailsContent,
Document,
Diagram,
- Division,
Dropcursor,
Emoji,
Figure,
@@ -118,6 +117,7 @@ export const createContentEditor = ({
History,
HorizontalRule,
...HTMLMarks,
+ ...HTMLNodes,
Image,
InlineDiff,
Italic,
diff --git a/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js
index dcd56e55268..fa46bd9ff81 100644
--- a/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js
+++ b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js
@@ -16,8 +16,8 @@ export default ({ render }) => {
* document. The dom property contains the HTML generated from the Markdown Source.
*/
return {
- deserialize: async ({ schema, content }) => {
- const html = await render(content);
+ deserialize: async ({ schema, markdown }) => {
+ const html = await render(markdown);
if (!html) return {};
@@ -25,7 +25,7 @@ export default ({ render }) => {
const { body } = parser.parseFromString(html, 'text/html');
// append original source as a comment that nodes can access
- body.append(document.createComment(content));
+ body.append(document.createComment(markdown));
return { document: ProseMirrorDOMParser.fromSchema(schema).parse(body) };
},
diff --git a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js
index 2c462cdde91..312ab88de4a 100644
--- a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js
+++ b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js
@@ -21,9 +21,10 @@
import { Mark } from 'prosemirror-model';
import { visitParents, SKIP } from 'unist-util-visit-parents';
-import { toString } from 'hast-util-to-string';
import { isFunction, isString, noop } from 'lodash';
+const NO_ATTRIBUTES = {};
+
/**
* Merges two ProseMirror text nodes if both text nodes
* have the same set of marks.
@@ -51,7 +52,7 @@ function maybeMerge(a, b) {
* Hast node documentation: https://github.com/syntax-tree/hast
*
* @param {HastNode} hastNode A Hast node
- * @param {String} source Markdown source file
+ * @param {String} markdown Markdown source file
*
* @returns It returns an object with the following attributes:
*
@@ -60,13 +61,13 @@ function maybeMerge(a, b) {
* - sourceMarkdown: A node’s original Markdown source extrated
* from the Markdown source file.
*/
-function createSourceMapAttributes(hastNode, source) {
+function createSourceMapAttributes(hastNode, markdown) {
const { position } = hastNode;
return position && position.end
? {
sourceMapKey: `${position.start.offset}:${position.end.offset}`,
- sourceMarkdown: source.substring(position.start.offset, position.end.offset),
+ sourceMarkdown: markdown.substring(position.start.offset, position.end.offset),
}
: {};
}
@@ -82,16 +83,16 @@ function createSourceMapAttributes(hastNode, source) {
* @param {*} proseMirrorNodeSpec ProseMirror node spec object
* @param {HastNode} hastNode A hast node
* @param {Array<HastNode>} hastParents All the ancestors of the hastNode
- * @param {String} source Markdown source file’s content
+ * @param {String} markdown Markdown source file’s content
*
* @returns An object that contains a ProseMirror node’s attributes
*/
-function getAttrs(proseMirrorNodeSpec, hastNode, hastParents, source) {
+function getAttrs(proseMirrorNodeSpec, hastNode, hastParents, markdown) {
const { getAttrs: specGetAttrs } = proseMirrorNodeSpec;
return {
- ...createSourceMapAttributes(hastNode, source),
- ...(isFunction(specGetAttrs) ? specGetAttrs(hastNode, hastParents, source) : {}),
+ ...createSourceMapAttributes(hastNode, markdown),
+ ...(isFunction(specGetAttrs) ? specGetAttrs(hastNode, hastParents, markdown) : {}),
};
}
@@ -136,6 +137,10 @@ class HastToProseMirrorConverterState {
return this.stack[this.stack.length - 1];
}
+ get topNode() {
+ return this.findInStack((item) => item.type === 'node');
+ }
+
/**
* Detects if the node stack is empty
*/
@@ -177,7 +182,7 @@ class HastToProseMirrorConverterState {
*/
addText(schema, text) {
if (!text) return;
- const nodes = this.top.content;
+ const nodes = this.topNode?.content;
const last = nodes[nodes.length - 1];
const node = schema.text(text, this.marks);
const merged = maybeMerge(last, node);
@@ -187,57 +192,92 @@ class HastToProseMirrorConverterState {
} else {
nodes.push(node);
}
-
- this.closeMarks();
}
/**
* Adds a mark to the set of marks stored temporarily
- * until addText is called.
- * @param {*} markType
- * @param {*} attrs
+ * until an inline node is created.
+ * @param {https://prosemirror.net/docs/ref/#model.MarkType} schemaType Mark schema type
+ * @param {https://github.com/syntax-tree/hast#nodes} hastNode AST node that the mark is based on
+ * @param {Object} attrs Mark attributes
+ * @param {Object} factorySpec Specifications on how th mark should be created
*/
- openMark(markType, attrs) {
- this.marks = markType.create(attrs).addToSet(this.marks);
+ openMark(schemaType, hastNode, attrs, factorySpec) {
+ const mark = schemaType.create(attrs);
+ this.stack.push({
+ type: 'mark',
+ mark,
+ attrs,
+ hastNode,
+ factorySpec,
+ });
+
+ this.marks = mark.addToSet(this.marks);
}
/**
- * Empties the temporary Mark set.
+ * Removes a mark from the list of active marks that
+ * are applied to inline nodes.
*/
- closeMarks() {
- this.marks = Mark.none;
+ closeMark() {
+ const { mark } = this.stack.pop();
+
+ this.marks = mark.removeFromSet(this.marks);
}
/**
* Adds a node to the stack data structure.
*
- * @param {Schema.NodeType} type ProseMirror Schema for the node
- * @param {HastNode} hastNode Hast node from which the ProseMirror node will be created
+ * @param {https://prosemirror.net/docs/ref/#model.NodeType} schemaType ProseMirror Schema for the node
+ * @param {https://github.com/syntax-tree/hast#nodes} hastNode Hast node from which the ProseMirror node will be created
* @param {*} attrs Node’s attributes
* @param {*} factorySpec The factory spec used to create the node factory
*/
- openNode(type, hastNode, attrs, factorySpec) {
- this.stack.push({ type, attrs, content: [], hastNode, factorySpec });
+ openNode(schemaType, hastNode, attrs, factorySpec) {
+ this.stack.push({
+ type: 'node',
+ schemaType,
+ attrs,
+ content: [],
+ hastNode,
+ factorySpec,
+ });
}
/**
* Removes the top ProseMirror node from the
* conversion stack and adds the node to the
* previous element.
- * @returns
*/
closeNode() {
- const { type, attrs, content } = this.stack.pop();
- const node = type.createAndFill(attrs, content);
-
- if (!node) return null;
-
- if (this.marks.length) {
- this.marks = Mark.none;
+ const { schemaType, attrs, content, factorySpec } = this.stack.pop();
+ const node =
+ factorySpec.type === 'inline' && this.marks.length
+ ? schemaType.createAndFill(attrs, content, this.marks)
+ : schemaType.createAndFill(attrs, content);
+
+ if (!node) {
+ /*
+ When the node returned by `createAndFill` is null is because the `content` passed as a parameter
+ doesn’t conform with the document schema. We are handling the most likely scenario here that happens
+ when a paragraph is inside another paragraph.
+
+ This scenario happens when the converter encounters a mark wrapping one or more paragraphs.
+ In this case, the converter will wrap the mark in a paragraph as well because ProseMirror does
+ not allow marks wrapping block nodes or being direct children of certain nodes like the root nodes
+ or list items.
+ */
+ if (
+ schemaType.name === 'paragraph' &&
+ content.some((child) => child.type.name === 'paragraph')
+ ) {
+ this.topNode.content.push(...content);
+ }
+ return null;
}
if (!this.empty) {
- this.top.content.push(node);
+ this.topNode.content.push(node);
}
return node;
@@ -245,9 +285,27 @@ class HastToProseMirrorConverterState {
closeUntil(hastNode) {
while (hastNode !== this.top?.hastNode) {
- this.closeNode();
+ if (this.top.type === 'node') {
+ this.closeNode();
+ } else {
+ this.closeMark();
+ }
}
}
+
+ buildDoc() {
+ let doc;
+
+ do {
+ if (this.top.type === 'node') {
+ doc = this.closeNode();
+ } else {
+ this.closeMark();
+ }
+ } while (!this.empty);
+
+ return doc;
+ }
}
/**
@@ -260,20 +318,21 @@ class HastToProseMirrorConverterState {
* @param {model.ProseMirrorSchema} schema A ProseMirror schema used to create the
* ProseMirror nodes and marks.
* @param {Object} proseMirrorFactorySpecs ProseMirror nodes factory specifications.
- * @param {String} source Markdown source file’s content
+ * @param {String} markdown Markdown source file’s content
*
* @returns An object that contains ProseMirror node factories
*/
-const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) => {
+const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, markdown) => {
const factories = {
root: {
selector: 'root',
wrapInParagraph: true,
- handle: (state, hastNode) => state.openNode(schema.topNodeType, hastNode, {}, {}),
+ handle: (state, hastNode) =>
+ state.openNode(schema.topNodeType, hastNode, NO_ATTRIBUTES, factories.root),
},
text: {
selector: 'text',
- handle: (state, hastNode) => {
+ handle: (state, hastNode, parent) => {
const found = state.findInStack((node) => isFunction(node.factorySpec.processText));
const { value: text } = hastNode;
@@ -281,17 +340,14 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source)
return;
}
+ state.closeUntil(parent);
state.addText(schema, found ? found.factorySpec.processText(text) : text);
},
},
};
for (const [proseMirrorName, factorySpec] of Object.entries(proseMirrorFactorySpecs)) {
const factory = {
- selector: factorySpec.selector,
- skipChildren: factorySpec.skipChildren,
- processText: factorySpec.processText,
- parent: factorySpec.parent,
- wrapInParagraph: factorySpec.wrapInParagraph,
+ ...factorySpec,
};
if (factorySpec.type === 'block') {
@@ -299,48 +355,22 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source)
const nodeType = schema.nodeType(proseMirrorName);
state.closeUntil(parent);
- state.openNode(
- nodeType,
- hastNode,
- getAttrs(factorySpec, hastNode, parent, source),
- factorySpec,
- );
-
- /**
- * If a getContent function is provided, we immediately close
- * the node to delegate content processing to this function.
- * */
- if (isFunction(factorySpec.getContent)) {
- state.addText(
- schema,
- factorySpec.getContent({ hastNode, hastNodeText: toString(hastNode) }),
- );
- state.closeNode();
- }
+ state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory);
};
- } else if (factorySpec.type === 'inline') {
+ } else if (factory.type === 'inline') {
const nodeType = schema.nodeType(proseMirrorName);
factory.handle = (state, hastNode, parent) => {
state.closeUntil(parent);
- state.openNode(
- nodeType,
- hastNode,
- getAttrs(factorySpec, hastNode, parent, source),
- factorySpec,
- );
+ state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory);
// Inline nodes do not have children therefore they are immediately closed
state.closeNode();
};
- } else if (factorySpec.type === 'mark') {
+ } else if (factory.type === 'mark') {
const markType = schema.marks[proseMirrorName];
factory.handle = (state, hastNode, parent) => {
- state.openMark(markType, getAttrs(factorySpec, hastNode, parent, source));
-
- if (factorySpec.inlineContent) {
- state.addText(schema, hastNode.value);
- }
+ state.openMark(markType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory);
};
- } else if (factorySpec.type === 'ignore') {
+ } else if (factory.type === 'ignore') {
factory.handle = noop;
} else {
throw new RangeError(
@@ -371,7 +401,7 @@ const findParent = (ancestors, parent) => {
return ancestors[ancestors.length - 1];
};
-const calcTextNodePosition = (textNode) => {
+const resolveNodePosition = (textNode) => {
const { position, value, type } = textNode;
if (type !== 'text' || (!position.start && !position.end) || (position.start && position.end)) {
@@ -414,11 +444,14 @@ const wrapInlineElements = (nodes, wrappableTags) =>
nodes.reduce((children, child) => {
const previous = children[children.length - 1];
- if (child.type !== 'text' && !wrappableTags.includes(child.tagName)) {
+ if (
+ child.type === 'comment' ||
+ (child.type !== 'text' && !wrappableTags.includes(child.tagName))
+ ) {
return [...children, child];
}
- const wrapperExists = previous?.properties.wrapper;
+ const wrapperExists = previous?.properties?.wrapper;
if (wrapperExists) {
const wrapper = previous;
@@ -432,7 +465,7 @@ const wrapInlineElements = (nodes, wrappableTags) =>
const wrapper = {
type: 'element',
tagName: 'p',
- position: calcTextNodePosition(child),
+ position: resolveNodePosition(child),
children: [child],
properties: { wrapper: true },
};
@@ -528,19 +561,6 @@ const wrapInlineElements = (nodes, wrappableTags) =>
* it allows applying a processing function to that text. This is useful when
* you can transform the text node, i.e trim(), substring(), etc.
*
- * **skipChildren**
- *
- * Skips a hast node’s children while traversing the tree.
- *
- * **getContent**
- *
- * Allows to pass a custom function that returns the content of a block node. The
- * Content is limited to a single text node therefore the function should return
- * a String value.
- *
- * Use this property along skipChildren to provide custom processing of child nodes
- * for a block node.
- *
* **parent**
*
* Specifies what is the node’s parent. This is useful when the node’s parent is not
@@ -561,20 +581,16 @@ export const createProseMirrorDocFromMdastTree = ({
factorySpecs,
wrappableTags,
tree,
- source,
+ markdown,
}) => {
- const proseMirrorNodeFactories = createProseMirrorNodeFactories(schema, factorySpecs, source);
+ const proseMirrorNodeFactories = createProseMirrorNodeFactories(schema, factorySpecs, markdown);
const state = new HastToProseMirrorConverterState();
visitParents(tree, (hastNode, ancestors) => {
const factory = findFactory(hastNode, ancestors, proseMirrorNodeFactories);
if (!factory) {
- throw new Error(
- `Hast node of type "${
- hastNode.tagName || hastNode.type
- }" not supported by this converter. Please, provide an specification.`,
- );
+ return SKIP;
}
const parent = findParent(ancestors, factory.parent);
@@ -595,14 +611,8 @@ export const createProseMirrorDocFromMdastTree = ({
factory.handle(state, hastNode, parent);
- return factory.skipChildren === true ? SKIP : true;
+ return true;
});
- let doc;
-
- do {
- doc = state.closeNode();
- } while (!state.empty);
-
- return doc;
+ return state.buildDoc();
};
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index 2d33a16f1a5..c1c7af6b1af 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -12,7 +12,6 @@ import DescriptionItem from '../extensions/description_item';
import DescriptionList from '../extensions/description_list';
import Details from '../extensions/details';
import DetailsContent from '../extensions/details_content';
-import Division from '../extensions/division';
import Diagram from '../extensions/diagram';
import Emoji from '../extensions/emoji';
import Figure from '../extensions/figure';
@@ -24,6 +23,7 @@ import HardBreak from '../extensions/hard_break';
import Heading from '../extensions/heading';
import HorizontalRule from '../extensions/horizontal_rule';
import HTMLMarks from '../extensions/html_marks';
+import HTMLNodes from '../extensions/html_nodes';
import Image from '../extensions/image';
import InlineDiff from '../extensions/inline_diff';
import Italic from '../extensions/italic';
@@ -123,16 +123,6 @@ const defaultSerializerConfig = {
[BulletList.name]: preserveUnchanged(renderBulletList),
[CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock),
[Diagram.name]: renderCodeBlock,
- [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();
@@ -206,6 +196,12 @@ const defaultSerializerConfig = {
[Text.name]: defaultMarkdownSerializer.nodes.text,
[Video.name]: renderPlayable,
[WordBreak.name]: (state) => state.write('<wbr>'),
+ ...HTMLNodes.reduce((serializers, htmlNode) => {
+ return {
+ ...serializers,
+ [htmlNode.name]: (state, node) => renderHTMLNode(htmlNode.options.tagName)(state, node),
+ };
+ }, {}),
},
};
diff --git a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js
index da10c684b0b..8e2c066e011 100644
--- a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js
+++ b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js
@@ -1,11 +1,10 @@
-import { isString } from 'lodash';
import { render } from '~/lib/gfm';
import { createProseMirrorDocFromMdastTree } from './hast_to_prosemirror_converter';
const wrappableTags = ['img', 'br', 'code', 'i', 'em', 'b', 'strong', 'a', 'strike', 's', 'del'];
const isTaskItem = (hastNode) => {
- const { className } = hastNode.properties;
+ const className = hastNode.properties?.className;
return (
hastNode.tagName === 'li' && Array.isArray(className) && className.includes('task-list-item')
@@ -23,16 +22,16 @@ const factorySpecs = {
listItem: {
type: 'block',
wrapInParagraph: true,
- selector: (hastNode) => hastNode.tagName === 'li' && !hastNode.properties.className,
+ selector: (hastNode) => hastNode.tagName === 'li' && !hastNode.properties?.className,
processText: (text) => text.trimRight(),
},
orderedList: {
type: 'block',
- selector: (hastNode) => hastNode.tagName === 'ol' && !hastNode.properties.className,
+ selector: (hastNode) => hastNode.tagName === 'ol' && !hastNode.properties?.className,
},
bulletList: {
type: 'block',
- selector: (hastNode) => hastNode.tagName === 'ul' && !hastNode.properties.className,
+ selector: (hastNode) => hastNode.tagName === 'ul' && !hastNode.properties?.className,
},
heading: {
type: 'block',
@@ -45,15 +44,8 @@ const factorySpecs = {
},
codeBlock: {
type: 'block',
- skipChildren: true,
- selector: 'pre',
- getContent: ({ hastNodeText }) => hastNodeText.replace(/\n$/, ''),
- getAttrs: (hastNode) => {
- const languageClass = hastNode.children[0]?.properties.className?.[0];
- const language = isString(languageClass) ? languageClass.replace('language-', '') : null;
-
- return { language };
- },
+ selector: 'codeblock',
+ getAttrs: (hastNode) => ({ ...hastNode.properties }),
},
horizontalRule: {
type: 'block',
@@ -62,7 +54,7 @@ const factorySpecs = {
taskList: {
type: 'block',
selector: (hastNode) => {
- const { className } = hastNode.properties;
+ const className = hastNode.properties?.className;
return (
['ul', 'ol'].includes(hastNode.tagName) &&
@@ -88,6 +80,11 @@ const factorySpecs = {
selector: (hastNode, ancestors) =>
hastNode.tagName === 'input' && isTaskItem(ancestors[ancestors.length - 1]),
},
+ div: {
+ type: 'block',
+ selector: 'div',
+ wrapInParagraph: true,
+ },
table: {
type: 'block',
selector: 'table',
@@ -118,6 +115,11 @@ const factorySpecs = {
selector: 'footnotedefinition',
getAttrs: (hastNode) => hastNode.properties,
},
+ pre: {
+ type: 'block',
+ selector: 'pre',
+ wrapInParagraph: true,
+ },
image: {
type: 'inline',
selector: 'img',
@@ -160,11 +162,19 @@ const factorySpecs = {
type: 'mark',
selector: (hastNode) => ['strike', 's', 'del'].includes(hastNode.tagName),
},
+ /* TODO
+ * Implement proper editing support for HTML comments in the Content Editor
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/342173
+ */
+ comment: {
+ type: 'ignore',
+ selector: (hastNode) => hastNode.type === 'comment',
+ },
};
export default () => {
return {
- deserialize: async ({ schema, content: markdown }) => {
+ deserialize: async ({ schema, markdown }) => {
const document = await render({
markdown,
renderer: (tree) =>
@@ -173,8 +183,9 @@ export default () => {
factorySpecs,
tree,
wrappableTags,
- source: markdown,
+ markdown,
}),
+ skipRendering: ['footnoteReference', 'footnoteDefinition', 'code'],
});
return { document };
diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js
index 88f5192af77..7d5e718b41c 100644
--- a/app/assets/javascripts/content_editor/services/serialization_helpers.js
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -5,6 +5,8 @@ const defaultAttrs = {
th: { colspan: 1, rowspan: 1, colwidth: null },
};
+const defaultIgnoreAttrs = ['sourceMarkdown', 'sourceMapKey'];
+
const ignoreAttrs = {
dd: ['isTerm'],
dt: ['isTerm'],
@@ -101,13 +103,17 @@ function htmlEncode(str = '') {
.replace(/"/g, '&#34;');
}
+const shouldIgnoreAttr = (tagName, attrKey, attrValue) =>
+ ignoreAttrs[tagName]?.includes(attrKey) ||
+ defaultIgnoreAttrs.includes(attrKey) ||
+ defaultAttrs[tagName]?.[attrKey] === attrValue;
+
export function openTag(tagName, attrs) {
let str = `<${tagName}`;
str += Object.entries(attrs || {})
.map(([key, value]) => {
- if ((ignoreAttrs[tagName] || []).includes(key) || defaultAttrs[tagName]?.[key] === value)
- return '';
+ if (shouldIgnoreAttr(tagName, key, value)) return '';
return ` ${key}="${htmlEncode(value?.toString())}"`;
})
diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue
index 8a7d3430063..d811bb3b0bf 100644
--- a/app/assets/javascripts/deploy_keys/components/key.vue
+++ b/app/assets/javascripts/deploy_keys/components/key.vue
@@ -115,10 +115,20 @@ export default {
<div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Deploy key') }}</div>
<div class="table-mobile-content" data-qa-selector="key_container">
<strong class="title" data-qa-selector="key_title_content"> {{ deployKey.title }} </strong>
- <div class="fingerprint" data-qa-selector="key_md5_fingerprint_content">
- {{ __('MD5') }}:{{ deployKey.fingerprint }}
- </div>
- <div class="fingerprint">{{ __('SHA256') }}:{{ deployKey.fingerprint_sha256 }}</div>
+ <dl>
+ <dt>{{ __('SHA256') }}</dt>
+ <dd class="fingerprint" data-qa-selector="key_sha256_fingerprint_content">
+ {{ deployKey.fingerprint_sha256 }}
+ </dd>
+ <template v-if="deployKey.fingerprint">
+ <dt>
+ {{ __('MD5') }}
+ </dt>
+ <dd class="fingerprint" data-qa-selector="key_md5_fingerprint_content">
+ {{ deployKey.fingerprint }}
+ </dd>
+ </template>
+ </dl>
</div>
</div>
<div class="table-section section-30 section-wrap">
diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js
index 73d872cf962..2ac62b9b927 100644
--- a/app/assets/javascripts/deprecated_notes.js
+++ b/app/assets/javascripts/deprecated_notes.js
@@ -1,4 +1,4 @@
-/* eslint-disable no-restricted-properties, camelcase,
+/* eslint-disable camelcase,
no-unused-expressions, default-case,
consistent-return, no-param-reassign,
no-shadow, no-useless-escape,
@@ -10,7 +10,7 @@ class-methods-use-this */
deprecated_notes_spec.js is the spec for the legacy, jQuery notes application. It has nothing to do with the new, fancy Vue notes app.
*/
-import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
+import { GlSkeletonLoader } from '@gitlab/ui';
import Autosize from 'autosize';
import $ from 'jquery';
import { escape, uniqueId } from 'lodash';
@@ -357,7 +357,7 @@ export default class Notes {
if (shouldReset == null) {
shouldReset = true;
}
- const nthInterval = this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1);
+ const nthInterval = this.basePollingInterval * 2 ** (this.maxPollingSteps - 1);
if (shouldReset) {
this.pollingInterval = this.basePollingInterval;
} else if (this.pollingInterval < nthInterval) {
@@ -1233,10 +1233,10 @@ export default class Notes {
new Vue({
el,
components: {
- GlSkeletonLoading,
+ GlSkeletonLoader,
},
render(createElement) {
- return createElement('gl-skeleton-loading');
+ return createElement('gl-skeleton-loader');
},
});
}
diff --git a/app/assets/javascripts/design_management/components/design_sidebar.vue b/app/assets/javascripts/design_management/components/design_sidebar.vue
index 8a6dd17a25b..24cc93f5eaf 100644
--- a/app/assets/javascripts/design_management/components/design_sidebar.vue
+++ b/app/assets/javascripts/design_management/components/design_sidebar.vue
@@ -1,6 +1,6 @@
<script>
-import { GlCollapse, GlButton, GlPopover, GlSkeletonLoader } from '@gitlab/ui';
-import { getCookie, setCookie, parseBoolean, isLoggedIn } from '~/lib/utils/common_utils';
+import { GlAccordion, GlAccordionItem, GlSkeletonLoader } from '@gitlab/ui';
+import { isLoggedIn } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import Participants from '~/sidebar/components/participants/participants.vue';
@@ -17,9 +17,8 @@ export default {
DesignDiscussion,
DesignNoteSignedOut,
Participants,
- GlCollapse,
- GlButton,
- GlPopover,
+ GlAccordion,
+ GlAccordionItem,
GlSkeletonLoader,
DesignTodoButton,
},
@@ -58,7 +57,7 @@ export default {
},
data() {
return {
- isResolvedCommentsPopoverHidden: parseBoolean(getCookie(this.$options.cookieKey)),
+ isResolvedDiscussionsExpanded: this.resolvedDiscussionsExpanded,
discussionWithOpenForm: '',
isLoggedIn: isLoggedIn(),
};
@@ -79,18 +78,22 @@ export default {
resolvedDiscussions() {
return this.discussions.filter((discussion) => discussion.resolved);
},
+ hasResolvedDiscussions() {
+ return this.resolvedDiscussions.length > 0;
+ },
+ resolvedDiscussionsTitle() {
+ return `${this.$options.i18n.resolveCommentsToggleText} (${this.resolvedDiscussions.length})`;
+ },
unresolvedDiscussions() {
return this.discussions.filter((discussion) => !discussion.resolved);
},
- resolvedCommentsToggleIcon() {
- return this.resolvedDiscussionsExpanded ? 'chevron-down' : 'chevron-right';
- },
},
watch: {
- isResolvedCommentsPopoverHidden(newVal) {
- if (!newVal) {
- this.$refs.resolvedComments.scrollIntoView();
- }
+ resolvedDiscussionsExpanded(resolvedDiscussionsExpanded) {
+ this.isResolvedDiscussionsExpanded = resolvedDiscussionsExpanded;
+ },
+ isResolvedDiscussionsExpanded() {
+ this.$emit('toggleResolvedComments');
},
},
mounted() {
@@ -100,8 +103,6 @@ export default {
},
methods: {
handleSidebarClick() {
- this.isResolvedCommentsPopoverHidden = true;
- setCookie(this.$options.cookieKey, 'true', { expires: 365 * 10 });
this.updateActiveDiscussion();
},
updateActiveDiscussion(id) {
@@ -121,8 +122,9 @@ export default {
this.discussionWithOpenForm = id;
},
},
- resolveCommentsToggleText: s__('DesignManagement|Resolved Comments'),
- cookieKey: 'hide_design_resolved_comments_popover',
+ i18n: {
+ resolveCommentsToggleText: s__('DesignManagement|Resolved Comments'),
+ },
};
</script>
@@ -181,40 +183,12 @@ export default {
@click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
@open-form="updateDiscussionWithOpenForm"
/>
- <template v-if="resolvedDiscussions.length > 0">
- <gl-button
- id="resolved-comments"
- ref="resolvedComments"
- data-testid="resolved-comments"
- :icon="resolvedCommentsToggleIcon"
- variant="link"
- class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-mb-4"
- @click="$emit('toggleResolvedComments')"
- >{{ $options.resolveCommentsToggleText }} ({{ resolvedDiscussions.length }})
- </gl-button>
- <gl-popover
- v-if="!isResolvedCommentsPopoverHidden"
- :show="!isResolvedCommentsPopoverHidden"
- target="resolved-comments"
- container="popovercontainer"
- placement="top"
- :title="s__('DesignManagement|Resolved Comments')"
+ <gl-accordion v-if="hasResolvedDiscussions" :header-level="3" class="gl-mb-5">
+ <gl-accordion-item
+ v-model="isResolvedDiscussionsExpanded"
+ :title="resolvedDiscussionsTitle"
+ header-class="gl-mb-5!"
>
- <p>
- {{
- s__(
- 'DesignManagement|Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below',
- )
- }}
- </p>
- <a
- href="https://docs.gitlab.com/ee/user/project/issues/design_management.html#resolve-design-threads"
- rel="noopener noreferrer"
- target="_blank"
- >{{ s__('DesignManagement|Learn more about resolving comments') }}</a
- >
- </gl-popover>
- <gl-collapse :visible="resolvedDiscussionsExpanded" class="gl-mt-3">
<design-discussion
v-for="discussion in resolvedDiscussions"
:key="discussion.id"
@@ -232,8 +206,8 @@ export default {
@open-form="updateDiscussionWithOpenForm"
@click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
/>
- </gl-collapse>
- </template>
+ </gl-accordion-item>
+ </gl-accordion>
<slot name="reply-form"></slot>
</template>
</div>
diff --git a/app/assets/javascripts/design_management/constants.js b/app/assets/javascripts/design_management/constants.js
index 92928ca429f..afe621ac3c5 100644
--- a/app/assets/javascripts/design_management/constants.js
+++ b/app/assets/javascripts/design_management/constants.js
@@ -12,3 +12,5 @@ export const ACTIVE_DISCUSSION_SOURCE_TYPES = {
};
export const DESIGN_DETAIL_LAYOUT_CLASSLIST = ['design-detail-layout', 'overflow-hidden', 'm-0'];
+
+export const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index f81d4f6662f..51983b19677 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -4,16 +4,15 @@ import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import VueDraggable from 'vuedraggable';
import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql';
import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
-import createFlash, { FLASH_TYPES } from '~/flash';
import { getFilename, validateImageName } from '~/lib/utils/file_upload';
-import { __, s__, sprintf } from '~/locale';
+import { __, s__ } from '~/locale';
import DesignDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
import DeleteButton from '../components/delete_button.vue';
import DesignDestroyer from '../components/design_destroyer.vue';
import Design from '../components/list/item.vue';
import UploadButton from '../components/upload/button.vue';
import DesignVersionDropdown from '../components/upload/design_version_dropdown.vue';
-import { VALID_DESIGN_FILE_MIMETYPE } from '../constants';
+import { MAXIMUM_FILE_UPLOAD_LIMIT, VALID_DESIGN_FILE_MIMETYPE } from '../constants';
import moveDesignMutation from '../graphql/mutations/move_design.mutation.graphql';
import uploadDesignMutation from '../graphql/mutations/upload_design.mutation.graphql';
import allDesignsMixin from '../mixins/all_designs';
@@ -35,11 +34,10 @@ import {
UPLOAD_DESIGN_INVALID_FILETYPE_ERROR,
designUploadSkippedWarning,
designDeletionError,
+ MAXIMUM_FILE_UPLOAD_LIMIT_REACHED,
} from '../utils/error_messages';
import { trackDesignCreate, trackDesignUpdate } from '../utils/tracking';
-const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
-
export default {
components: {
GlLoadingIcon,
@@ -87,6 +85,7 @@ export default {
isDraggingDesign: false,
reorderedDesigns: null,
isReorderingInProgress: false,
+ uploadError: null,
};
},
computed: {
@@ -159,16 +158,7 @@ export default {
if (!this.canCreateDesign) return false;
if (files.length > MAXIMUM_FILE_UPLOAD_LIMIT) {
- createFlash({
- message: sprintf(
- s__(
- 'DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again.',
- ),
- {
- upload_limit: MAXIMUM_FILE_UPLOAD_LIMIT,
- },
- ),
- });
+ this.uploadError = MAXIMUM_FILE_UPLOAD_LIMIT_REACHED;
return false;
}
@@ -206,7 +196,7 @@ export default {
const skippedFiles = res?.data?.designManagementUpload?.skippedDesigns || [];
const skippedWarningMessage = designUploadSkippedWarning(this.filesToBeSaved, skippedFiles);
if (skippedWarningMessage) {
- createFlash({ message: skippedWarningMessage, types: FLASH_TYPES.WARNING });
+ this.uploadError = skippedWarningMessage;
}
// if this upload resulted in a new version being created, redirect user to the latest version
@@ -229,7 +219,7 @@ export default {
},
onUploadDesignError() {
this.resetFilesToBeSaved();
- createFlash({ message: UPLOAD_DESIGN_ERROR });
+ this.uploadError = UPLOAD_DESIGN_ERROR;
},
changeSelectedDesigns(filename) {
if (this.isDesignSelected(filename)) {
@@ -260,21 +250,21 @@ export default {
},
onDesignDeleteError() {
const errorMessage = designDeletionError(this.selectedDesigns.length);
- createFlash({ message: errorMessage });
+ this.uploadError = errorMessage;
},
onDesignDropzoneError() {
- createFlash({ message: UPLOAD_DESIGN_INVALID_FILETYPE_ERROR });
+ this.uploadError = UPLOAD_DESIGN_INVALID_FILETYPE_ERROR;
},
onExistingDesignDropzoneChange(files, existingDesignFilename) {
const filesArr = Array.from(files);
if (filesArr.length > 1) {
- createFlash({ message: EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE });
+ this.uploadError = EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE;
return;
}
if (!filesArr.some(({ name }) => existingDesignFilename === name)) {
- createFlash({ message: EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE });
+ this.uploadError = EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE;
return;
}
@@ -329,7 +319,7 @@ export default {
optimisticResponse: moveDesignOptimisticResponse(this.reorderedDesigns),
})
.catch(() => {
- createFlash({ message: MOVE_DESIGN_ERROR });
+ this.uploadError = MOVE_DESIGN_ERROR;
})
.finally(() => {
this.isReorderingInProgress = false;
@@ -338,6 +328,9 @@ export default {
onDesignMove(designs) {
this.reorderedDesigns = designs;
},
+ unsetUpdateError() {
+ this.uploadError = null;
+ },
},
dragOptions: {
animation: 200,
@@ -356,6 +349,15 @@ export default {
@mouseenter="toggleOnPasteListener"
@mouseleave="toggleOffPasteListener"
>
+ <gl-alert
+ v-if="uploadError"
+ variant="danger"
+ class="gl-mb-3"
+ data-testid="design-update-alert"
+ @dismiss="unsetUpdateError"
+ >
+ {{ uploadError }}
+ </gl-alert>
<header
v-if="showToolbar"
class="gl-display-flex gl-my-0 gl-text-gray-900"
@@ -371,6 +373,7 @@ export default {
<div
v-show="hasDesigns"
class="qa-selector-toolbar gl-display-flex gl-align-items-center gl-my-2"
+ data-testid="design-selector-toolbar"
>
<gl-button
v-if="isLatestVersion"
diff --git a/app/assets/javascripts/design_management/utils/error_messages.js b/app/assets/javascripts/design_management/utils/error_messages.js
index 981b50329b2..42f752efc9e 100644
--- a/app/assets/javascripts/design_management/utils/error_messages.js
+++ b/app/assets/javascripts/design_management/utils/error_messages.js
@@ -1,4 +1,5 @@
import { __, s__, n__, sprintf } from '~/locale';
+import { MAXIMUM_FILE_UPLOAD_LIMIT } from '../constants';
export const ADD_DISCUSSION_COMMENT_ERROR = s__(
'DesignManagement|Could not add a new comment. Please try again.',
@@ -27,11 +28,11 @@ export const DESIGN_NOT_FOUND_ERROR = __('Could not find design.');
export const DESIGN_VERSION_NOT_EXIST_ERROR = __('Requested design version does not exist.');
export const EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE = __(
- 'You can only upload one design when dropping onto an existing design.',
+ 'Your update failed. You can only upload one design when dropping onto an existing design.',
);
export const EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE = __(
- 'You must upload a file with the same file name when dropping onto an existing design.',
+ 'Your update failed. You must upload a file with the same file name when dropping onto an existing design.',
);
export const MOVE_DESIGN_ERROR = __(
@@ -122,3 +123,12 @@ export const designUploadSkippedWarning = (uploadedDesigns, skippedFiles) => {
return someDesignsSkippedMessage(skippedFiles);
};
+
+export const MAXIMUM_FILE_UPLOAD_LIMIT_REACHED = sprintf(
+ s__(
+ 'DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again.',
+ ),
+ {
+ upload_limit: MAXIMUM_FILE_UPLOAD_LIMIT,
+ },
+);
diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js
index 9f3fb715150..8388458b11c 100644
--- a/app/assets/javascripts/diff.js
+++ b/app/assets/javascripts/diff.js
@@ -101,6 +101,7 @@ export default class Diff {
const clickTarget = $('.js-file-title, .click-to-expand', diffFile);
diffFile.data('singleFileDiff').toggleDiff(clickTarget, () => {
this.highlightSelectedLine();
+ this.prepareRenderedDiff();
if (cb) cb();
});
} else if (cb) {
@@ -156,20 +157,22 @@ export default class Diff {
}
prepareRenderedDiff() {
- const $elements = $('[data-diff-toggle-entity]');
-
- if ($elements.length === 0) return;
-
+ const allElements = this.elementsForRenderedDiff();
const diff = this;
- const elements = $elements.toArray().map(this.formatElementToObject).reduce(merge);
+ for (const [fileHash, fileElements] of Object.entries(allElements)) {
+ // eslint-disable no-param-reassign
+ fileElements.rawButton.onclick = () => {
+ diff.showRawViewer(fileHash, diff.elementsForRenderedDiff()[fileHash]);
+ };
- Object.values(elements).forEach((e) => {
- e.toShowBtn.onclick = () => diff.showOneHideAnother('rendered', e); // eslint-disable-line no-param-reassign
- e.toHideBtn.onclick = () => diff.showOneHideAnother('raw', e); // eslint-disable-line no-param-reassign
+ fileElements.renderedButton.onclick = () => {
+ diff.showRenderedViewer(fileHash, diff.elementsForRenderedDiff()[fileHash]);
+ };
+ // eslint-enable no-param-reassign
- diff.showOneHideAnother('rendered', e);
- });
+ diff.showRenderedViewer(fileHash, fileElements);
+ }
}
formatElementToObject = (element) => {
@@ -179,18 +182,33 @@ export default class Diff {
return { [key]: { [name]: element } };
};
- showOneHideAnother = (mode, elements) => {
- let { toShowBtn, toHideBtn, toShow, toHide } = elements;
+ elementsForRenderedDiff = () => {
+ const $elements = $('[data-diff-toggle-entity]');
+
+ if ($elements.length === 0) return {};
- if (mode === 'raw') {
- [toShowBtn, toHideBtn] = [toHideBtn, toShowBtn];
- [toShow, toHide] = [toHide, toShow];
- }
+ const diff = this;
+
+ return $elements.toArray().map(diff.formatElementToObject).reduce(merge);
+ };
+
+ showRawViewer = (fileHash, elements) => {
+ if (elements === undefined) return;
+
+ elements.rawButton.classList.add('selected');
+ elements.renderedButton.classList.remove('selected');
+
+ elements.renderedViewer.classList.add('hidden');
+ elements.rawViewer.classList.remove('hidden');
+ };
+
+ showRenderedViewer = (fileHash, elements) => {
+ if (elements === undefined) return;
- toShowBtn.classList.add('selected');
- toHideBtn.classList.remove('selected');
+ elements.rawButton.classList.remove('selected');
+ elements.rawViewer.classList.add('hidden');
- toHide.classList.add('hidden');
- toShow.classList.remove('hidden');
+ elements.renderedButton.classList.add('selected');
+ elements.renderedViewer.classList.remove('hidden');
};
}
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index 54b648e8d03..ad163a2a615 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -134,7 +134,9 @@ export default {
class="avatar-cell d-none d-sm-block"
/>
</div>
- <div class="commit-detail flex-list">
+ <div
+ class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-flex-grow-1 gl-min-w-0"
+ >
<div class="commit-content" data-qa-selector="commit_content">
<a
v-safe-html:[$options.safeHtmlConfig]="commit.title_html"
diff --git a/app/assets/javascripts/diffs/components/diff_code_quality.vue b/app/assets/javascripts/diffs/components/diff_code_quality.vue
new file mode 100644
index 00000000000..f339b108a11
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_code_quality.vue
@@ -0,0 +1,56 @@
+<script>
+import { GlButton, GlIcon } from '@gitlab/ui';
+import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/reports/codequality_report/constants';
+
+export default {
+ components: { GlButton, GlIcon },
+ props: {
+ line: {
+ type: Number,
+ required: true,
+ },
+ codeQuality: {
+ type: Array,
+ required: true,
+ },
+ },
+ methods: {
+ severityClass(severity) {
+ return SEVERITY_CLASSES[severity] || SEVERITY_CLASSES.unknown;
+ },
+ severityIcon(severity) {
+ return SEVERITY_ICONS[severity] || SEVERITY_ICONS.unknown;
+ },
+ },
+};
+</script>
+
+<template>
+ <div data-testid="diff-codequality" class="gl-relative">
+ <ul
+ class="gl-list-style-none gl-mb-0 gl-p-0 codequality-findings-list gl-border-top-1 gl-border-bottom-1 gl-bg-gray-10"
+ >
+ <li
+ v-for="finding in codeQuality"
+ :key="finding.description"
+ class="gl-pt-1 gl-pb-1 gl-pl-3 gl-border-solid gl-border-bottom-0 gl-border-right-0 gl-border-1 gl-border-gray-100"
+ >
+ <gl-icon
+ :size="12"
+ :name="severityIcon(finding.severity)"
+ :class="severityClass(finding.severity)"
+ class="codequality-severity-icon"
+ />
+ {{ finding.description }}
+ </li>
+ </ul>
+ <gl-button
+ data-testid="diff-codequality-close"
+ category="tertiary"
+ size="small"
+ icon="close"
+ class="gl-absolute gl-right-2 gl-top-2"
+ @click="$emit('hideCodeQualityFindings', line)"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
index bfe35e9346d..70071a3ff53 100644
--- a/app/assets/javascripts/diffs/components/diff_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -80,7 +80,7 @@ export default {
return this.getUserData;
},
mappedLines() {
- // TODO: Do this data generation when we recieve a response to save a computed property being created
+ // TODO: Do this data generation when we receive a response to save a computed property being created
return this.diffLines(this.diffFile).map(mapParallel(this)) || [];
},
},
diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
index ebc68bafb9a..467a0f8d2db 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -206,6 +206,7 @@ export default {
);
},
updateStartLine(line) {
+ this.commentLineStart = line;
this.lines.start = line;
},
},
@@ -216,7 +217,6 @@ export default {
<div class="content discussion-form discussion-form-container discussion-notes">
<div class="gl-mb-3 gl-text-gray-500 gl-pb-3">
<multiline-comment-form
- v-model="commentLineStart"
:line="line"
:line-range="lines"
:comment-line-options="commentLineOptions"
diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue
index 1b07b00d725..63c5aedd7ce 100644
--- a/app/assets/javascripts/diffs/components/diff_row.vue
+++ b/app/assets/javascripts/diffs/components/diff_row.vue
@@ -274,6 +274,9 @@ export default {
v-if="$options.showCodequalityLeft(props)"
:codequality="props.line.left.codequality"
:file-path="props.filePath"
+ @showCodeQualityFindings="
+ listeners.toggleCodeQualityFindings(props.line.left.codequality[0].line)
+ "
/>
</div>
<div
@@ -395,6 +398,9 @@ export default {
:codequality="props.line.right.codequality"
:file-path="props.filePath"
data-testid="codeQualityIcon"
+ @showCodeQualityFindings="
+ listeners.toggleCodeQualityFindings(props.line.right.codequality[0].line)
+ "
/>
</div>
<div
diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue
index d740d5adcb6..ad406947561 100644
--- a/app/assets/javascripts/diffs/components/diff_view.vue
+++ b/app/assets/javascripts/diffs/components/diff_view.vue
@@ -2,12 +2,14 @@
import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { mapGetters, mapState, mapActions } from 'vuex';
import { IdState } from 'vendor/vue-virtual-scroller';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import DraftNote from '~/batch_comments/components/draft_note.vue';
import draftCommentsMixin from '~/diffs/mixins/draft_comments';
import { getCommentedLines } from '~/notes/components/multiline_comment_utils';
import { hide } from '~/tooltips';
import { pickDirection } from '../utils/diff_line';
import DiffCommentCell from './diff_comment_cell.vue';
+import DiffCodeQuality from './diff_code_quality.vue';
import DiffExpansionCell from './diff_expansion_cell.vue';
import DiffRow from './diff_row.vue';
import { isHighlighted } from './diff_row_utils';
@@ -17,12 +19,17 @@ export default {
DiffExpansionCell,
DiffRow,
DiffCommentCell,
+ DiffCodeQuality,
DraftNote,
},
directives: {
SafeHtml,
},
- mixins: [draftCommentsMixin, IdState({ idProp: (vm) => vm.diffFile.file_hash })],
+ mixins: [
+ draftCommentsMixin,
+ IdState({ idProp: (vm) => vm.diffFile.file_hash }),
+ glFeatureFlagsMixin(),
+ ],
props: {
diffFile: {
type: Object,
@@ -43,6 +50,11 @@ export default {
default: false,
},
},
+ data() {
+ return {
+ codeQualityExpandedLines: [],
+ };
+ },
idState() {
return {
dragStart: null,
@@ -84,6 +96,23 @@ export default {
}
this.idState.dragStart = line;
},
+ parseCodeQuality(line) {
+ return (line.left ?? line.right)?.codequality;
+ },
+
+ hideCodeQualityFindings(line) {
+ const index = this.codeQualityExpandedLines.indexOf(line);
+ if (index > -1) {
+ this.codeQualityExpandedLines.splice(index, 1);
+ }
+ },
+ toggleCodeQualityFindings(line) {
+ if (!this.codeQualityExpandedLines.includes(line)) {
+ this.codeQualityExpandedLines.push(line);
+ } else {
+ this.hideCodeQualityFindings(line);
+ }
+ },
onDragOver(line) {
if (line.chunk !== this.idState.dragStart.chunk) return;
@@ -125,15 +154,16 @@ export default {
},
handleParallelLineMouseDown(e) {
const line = e.target.closest('.diff-td');
- const table = line.closest('.diff-table');
-
- table.classList.remove('left-side-selected', 'right-side-selected');
- const [lineClass] = ['left-side', 'right-side'].filter((name) =>
- line.classList.contains(name),
- );
+ if (line) {
+ const table = line.closest('.diff-table');
+ table.classList.remove('left-side-selected', 'right-side-selected');
+ const [lineClass] = ['left-side', 'right-side'].filter((name) =>
+ line.classList.contains(name),
+ );
- if (lineClass) {
- table.classList.add(`${lineClass}-selected`);
+ if (lineClass) {
+ table.classList.add(`${lineClass}-selected`);
+ }
}
},
getCountBetweenIndex(index) {
@@ -148,6 +178,9 @@ export default {
Number(this.diffLines[index - 1].left.new_line)
);
},
+ getCodeQualityLine(line) {
+ return this.parseCodeQuality(line)?.[0]?.line;
+ },
},
userColorScheme: window.gon.user_color_scheme,
};
@@ -190,6 +223,7 @@ export default {
:coverage-loaded="coverageLoaded"
@showCommentForm="(code) => singleLineComment(code, line)"
@setHighlightedRow="setHighlightedRow"
+ @toggleCodeQualityFindings="toggleCodeQualityFindings"
@toggleLineDiscussions="
({ lineCode, expanded }) =>
toggleLineDiscussions({ lineCode, fileHash: diffFile.file_hash, expanded })
@@ -198,6 +232,17 @@ export default {
@startdragging="onStartDragging"
@stopdragging="onStopDragging"
/>
+
+ <diff-code-quality
+ v-if="
+ glFeatures.refactorCodeQualityInlineFindings &&
+ codeQualityExpandedLines.includes(getCodeQualityLine(line))
+ "
+ :key="line.line_code"
+ :line="getCodeQualityLine(line)"
+ :code-quality="parseCodeQuality(line)"
+ @hideCodeQualityFindings="hideCodeQualityFindings"
+ />
<div
v-if="line.renderCommentRow"
:key="`dcr-${line.line_code || index}`"
diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue
index 85e4199d1c1..ffbea854001 100644
--- a/app/assets/javascripts/diffs/components/tree_list.vue
+++ b/app/assets/javascripts/diffs/components/tree_list.vue
@@ -1,6 +1,7 @@
<script>
import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
+import micromatch from 'micromatch';
import { s__, sprintf } from '~/locale';
import FileTree from '~/vue_shared/components/file_tree.vue';
import DiffFileRow from './diff_file_row.vue';
@@ -28,14 +29,24 @@ export default {
...mapState('diffs', ['tree', 'renderTreeList', 'currentDiffFileId', 'viewedDiffFileIds']),
...mapGetters('diffs', ['allBlobs']),
filteredTreeList() {
- const search = this.search.toLowerCase().trim();
+ let search = this.search.toLowerCase().trim();
if (search === '') {
return this.renderTreeList ? this.tree : this.allBlobs;
}
+ const searchSplit = search.split(',').filter((t) => t);
+
+ if (searchSplit.length > 1) {
+ search = `(${searchSplit.map((s) => s.replace(/(^ +| +$)/g, '')).join('|')})`;
+ } else {
+ [search] = searchSplit;
+ }
+
return this.allBlobs.reduce((acc, folder) => {
- const tree = folder.tree.filter((f) => f.path.toLowerCase().indexOf(search) >= 0);
+ const tree = folder.tree.filter((f) =>
+ micromatch.contains(f.path, search, { nocase: true }),
+ );
if (tree.length) {
return acc.concat({
@@ -54,7 +65,7 @@ export default {
this.search = '';
},
},
- searchPlaceholder: sprintf(s__('MergeRequest|Search files (%{modifier_key}P)'), {
+ searchPlaceholder: sprintf(s__('MergeRequest|Search (e.g. *.vue) (%{modifier_key}P)'), {
modifier_key: /Mac/i.test(navigator.userAgent) ? '⌘' : 'Ctrl+',
}),
DiffFileRow,
@@ -74,6 +85,7 @@ export default {
type="search"
name="diff-tree-search"
class="form-control"
+ data-testid="diff-tree-search"
/>
<button
v-show="search"
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index d5cd4af4d06..ace507f601a 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -744,6 +744,10 @@ export const setFileCollapsedByUser = ({ commit }, { filePath, collapsed }) => {
commit(types.SET_FILE_COLLAPSED, { filePath, collapsed, trigger: DIFF_FILE_MANUAL_COLLAPSE });
};
+export const setFileCollapsedAutomatically = ({ commit }, { filePath, collapsed }) => {
+ commit(types.SET_FILE_COLLAPSED, { filePath, collapsed, trigger: DIFF_FILE_AUTOMATIC_COLLAPSE });
+};
+
export const setSuggestPopoverDismissed = ({ commit, state }) =>
axios
.post(state.dismissEndpoint, {
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
index c8015f884b7..e8b96c25965 100644
--- a/app/assets/javascripts/editor/schema/ci.json
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -436,6 +436,33 @@
"type": "string"
}
},
+ "pull_policy": {
+ "markdownDescription": "Specifies how to pull the image in Runner. It can be one of `always`, `never` or `if-not-present`. The default value is `always`. [Learn more](https://docs.gitlab.com/ee/ci/yaml/#servicepull_policy).",
+ "default": "always",
+ "oneOf": [
+ {
+ "type": "string",
+ "enum": [
+ "always",
+ "never",
+ "if-not-present"
+ ]
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "always",
+ "never",
+ "if-not-present"
+ ]
+ },
+ "minItems": 1,
+ "uniqueItems": true
+ }
+ ]
+ },
"command": {
"type": "array",
"description": "Command or script that should be used as the container's command. It will be translated to arguments passed to Docker after the image's name. The syntax is similar to Dockerfile's CMD directive, where each shell token is a separate string in the array.",
diff --git a/app/assets/javascripts/editor/source_editor_instance.js b/app/assets/javascripts/editor/source_editor_instance.js
index 95a43c2b2d0..fb5d5414ca4 100644
--- a/app/assets/javascripts/editor/source_editor_instance.js
+++ b/app/assets/javascripts/editor/source_editor_instance.js
@@ -90,6 +90,7 @@ export default class EditorInstance {
this.dispatchExtAction = EditorInstance.useUnuse.bind(instProxy, extensionsStore);
+ // eslint-disable-next-line no-constructor-return
return instProxy;
}
diff --git a/app/assets/javascripts/emoji/awards_app/store/actions.js b/app/assets/javascripts/emoji/awards_app/store/actions.js
index f83bfe614dd..427a504e038 100644
--- a/app/assets/javascripts/emoji/awards_app/store/actions.js
+++ b/app/assets/javascripts/emoji/awards_app/store/actions.js
@@ -14,8 +14,6 @@ import {
export const setInitialData = ({ commit }, data) => commit(SET_INITIAL_DATA, data);
export const fetchAwards = async ({ commit, dispatch, state }, page = '1') => {
- if (!window.gon?.current_user_id) return;
-
try {
const { data, headers } = await axios.get(joinPaths(gon.relative_url_root || '', state.path), {
params: { per_page: 100, page },
diff --git a/app/assets/javascripts/environments/components/canary_update_modal.vue b/app/assets/javascripts/environments/components/canary_update_modal.vue
index fd4885a9dbd..cacd868bed0 100644
--- a/app/assets/javascripts/environments/components/canary_update_modal.vue
+++ b/app/assets/javascripts/environments/components/canary_update_modal.vue
@@ -42,7 +42,7 @@ export default {
modalId: CANARY_UPDATE_MODAL,
actionPrimary: {
text: s__('CanaryIngress|Change ratio'),
- attributes: [{ variant: 'info' }],
+ attributes: [{ variant: 'confirm' }],
},
actionCancel: { text: __('Cancel') },
static: true,
diff --git a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
index ce919f73858..8259574f8e3 100644
--- a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
+++ b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
@@ -6,6 +6,7 @@ import { GlModal, GlSprintf, GlLink } from '@gitlab/ui';
import { escape } from 'lodash';
import csrf from '~/lib/utils/csrf';
import { __, s__, sprintf } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
import rollbackEnvironment from '../graphql/mutations/rollback_environment.mutation.graphql';
import eventHub from '../event_hub';
@@ -84,7 +85,9 @@ export default {
return this.environment.commitUrl;
},
modalActionText() {
- return this.isLastDeployment ? s__('Environments|Re-deploy') : s__('Environments|Rollback');
+ return this.isLastDeployment
+ ? s__('Environments|Re-deploy environment')
+ : s__('Environments|Rollback environment');
},
primaryProps() {
let attributes = [{ variant: 'danger' }];
@@ -101,6 +104,15 @@ export default {
isLastDeployment() {
return this.environment?.isLastDeployment || this.environment?.lastDeployment?.isLast;
},
+ modalBodyText() {
+ return this.isLastDeployment
+ ? s__(
+ 'Environments|This action will %{docsStart}retry the latest deployment%{docsEnd} with the commit %{commitId}, for this environment. Are you sure you want to continue?',
+ )
+ : s__(
+ 'Environments|This action will %{docsStart}roll back this environment%{docsEnd} to a previously successful deployment for commit %{commitId}. Are you sure you want to continue?',
+ );
+ },
},
methods: {
handleChange(event) {
@@ -125,6 +137,7 @@ export default {
text: __('Cancel'),
attributes: [{ variant: 'danger' }],
},
+ docsPath: helpPagePath('ci/environments/index.md', { anchor: 'retry-or-roll-back-a-deployment' }),
};
</script>
<template>
@@ -137,33 +150,14 @@ export default {
@ok="onOk"
@change="handleChange"
>
- <gl-sprintf
- v-if="environment.isLastDeployment"
- :message="
- s__(
- 'Environments|This action will relaunch the job for commit %{linkStart}%{commitId}%{linkEnd}, putting the environment in a previous version. Are you sure you want to continue?',
- )
- "
- >
- <template #link>
+ <gl-sprintf :message="modalBodyText">
+ <template #commitId>
<gl-link :href="commitUrl" target="_blank" class="commit-sha mr-0">{{
commitShortSha
}}</gl-link>
</template>
- </gl-sprintf>
- <gl-sprintf
- v-else
- :message="
- s__(
- 'Environments|This action will run the job defined by %{name} for commit %{linkStart}%{commitId}%{linkEnd} putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?',
- )
- "
- >
- <template #name>{{ environment.name }}</template>
- <template #link>
- <gl-link :href="commitUrl" target="_blank" class="commit-sha mr-0">{{
- commitShortSha
- }}</gl-link>
+ <template #docs="{ content }">
+ <gl-link :href="$options.docsLink" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</gl-modal>
diff --git a/app/assets/javascripts/environments/components/deploy_board.vue b/app/assets/javascripts/environments/components/deploy_board.vue
index 8a379ebdf66..7a2c9a8600e 100644
--- a/app/assets/javascripts/environments/components/deploy_board.vue
+++ b/app/assets/javascripts/environments/components/deploy_board.vue
@@ -51,11 +51,6 @@ export default {
type: Boolean,
required: true,
},
- logsPath: {
- type: String,
- required: false,
- default: '',
- },
graphql: {
type: Boolean,
required: false,
@@ -186,7 +181,6 @@ export default {
:status="instance.status"
:tooltip-text="instance.tooltip"
:pod-name="podName(instance)"
- :logs-path="logsPath"
:stable="instance.stable"
/>
</template>
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index 895a6cf2ccb..b47086a19da 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -164,7 +164,6 @@ export default {
:deploy-board-data="model.deployBoardData"
:is-loading="model.isLoadingDeployBoard"
:is-empty="model.isEmptyDeployBoard"
- :logs-path="model.logs_path"
@changeCanaryWeight="changeCanaryWeight(model, $event)"
/>
</div>
@@ -199,7 +198,6 @@ export default {
:deploy-board-data="child.deployBoardData"
:is-loading="child.isLoadingDeployBoard"
:is-empty="child.isEmptyDeployBoard"
- :logs-path="child.logs_path"
@changeCanaryWeight="changeCanaryWeight(child, $event)"
/>
</div>
diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js
index a67e44b3348..4d70e29a684 100644
--- a/app/assets/javascripts/environments/stores/environments_store.js
+++ b/app/assets/javascripts/environments/stores/environments_store.js
@@ -15,8 +15,6 @@ export default class EnvironmentsStore {
this.state.availableCounter = 0;
this.state.paginationInformation = {};
this.state.reviewAppDetails = {};
-
- return this;
}
/**
diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
index d29d5aa0671..a07428dafea 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -150,6 +150,12 @@ export default {
paginationRequired() {
return !isEmpty(this.pagination);
},
+ previousPage() {
+ return this.pagination.previous ? this.$options.PREV_PAGE : null;
+ },
+ nextPage() {
+ return this.pagination.next ? this.$options.NEXT_PAGE : null;
+ },
errorTrackingHelpUrl() {
return helpPagePath('operations/error_tracking');
},
@@ -430,8 +436,8 @@ export default {
<gl-pagination
v-show="!loading"
v-if="paginationRequired"
- :prev-page="$options.PREV_PAGE"
- :next-page="$options.NEXT_PAGE"
+ :prev-page="previousPage"
+ :next-page="nextPage"
:value="pageValue"
align="center"
@input="goToPage"
diff --git a/app/assets/javascripts/feature_flags/components/strategies/default.vue b/app/assets/javascripts/feature_flags/components/strategies/default.vue
index cb8ffbddfbd..04190d7bfda 100644
--- a/app/assets/javascripts/feature_flags/components/strategies/default.vue
+++ b/app/assets/javascripts/feature_flags/components/strategies/default.vue
@@ -4,7 +4,7 @@ export default {
this.$emit('change', { parameters: {} });
},
render() {
- return this.$slots.default;
+ return this.$scopedSlots.default?.();
},
};
</script>
diff --git a/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue b/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue
new file mode 100644
index 00000000000..f17a05999b0
--- /dev/null
+++ b/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue
@@ -0,0 +1,84 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { captureException } from '@sentry/browser';
+import PipelineWizard from '~/pipeline_wizard/pipeline_wizard.vue';
+import PagesWizardTemplate from '~/pipeline_wizard/templates/pages.yml';
+import { logError } from '~/lib/logger';
+import { s__ } from '~/locale';
+import { redirectTo } from '~/lib/utils/url_utility';
+import pagesMarkOnboardingComplete from '../queries/mark_onboarding_complete.graphql';
+
+export const i18n = {
+ loadingMessage: s__('GitLabPages|Updating your Pages configuration...'),
+};
+
+export default {
+ name: 'PagesPipelineWizard',
+ i18n,
+ PagesWizardTemplate,
+ components: {
+ PipelineWizard,
+ GlLoadingIcon,
+ },
+ props: {
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ defaultBranch: {
+ type: String,
+ required: true,
+ },
+ redirectToWhenDone: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ loading: false,
+ };
+ },
+ methods: {
+ async updateOnboardingState() {
+ try {
+ await this.$apollo.mutate({
+ mutation: pagesMarkOnboardingComplete,
+ variables: {
+ input: { projectPath: this.projectPath },
+ },
+ });
+ } catch (e) {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ logError('Updating the pages onboarding state failed', e);
+ captureException(e);
+ }
+ },
+ async onDone() {
+ this.loading = true;
+ await this.updateOnboardingState();
+ redirectTo(this.redirectToWhenDone);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div
+ v-if="loading"
+ class="gl-p-3 gl-rounded-base gl-text-center"
+ data-testid="onboarding-mutation-loading"
+ >
+ <gl-loading-icon />
+ {{ $options.i18n.loadingMessage }}
+ </div>
+ <pipeline-wizard
+ v-else
+ :template="$options.PagesWizardTemplate"
+ :project-path="projectPath"
+ :default-branch="defaultBranch"
+ @done="onDone"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/gitlab_pages/queries/mark_onboarding_complete.graphql b/app/assets/javascripts/gitlab_pages/queries/mark_onboarding_complete.graphql
new file mode 100644
index 00000000000..abedd54b079
--- /dev/null
+++ b/app/assets/javascripts/gitlab_pages/queries/mark_onboarding_complete.graphql
@@ -0,0 +1,6 @@
+mutation pagesMarkOnboardingComplete($input: PagesMarkOnboardingCompleteInput!) {
+ pagesMarkOnboardingComplete(input: $input) {
+ onboardingComplete
+ errors
+ }
+}
diff --git a/app/assets/javascripts/google_cloud/components/app.vue b/app/assets/javascripts/google_cloud/components/app.vue
deleted file mode 100644
index b3d773e6bee..00000000000
--- a/app/assets/javascripts/google_cloud/components/app.vue
+++ /dev/null
@@ -1,63 +0,0 @@
-<script>
-import { __ } from '~/locale';
-
-import Home from './home.vue';
-import IncubationBanner from './incubation_banner.vue';
-import ServiceAccountsForm from './service_accounts_form.vue';
-import GcpRegionsForm from './gcp_regions_form.vue';
-import NoGcpProjects from './errors/no_gcp_projects.vue';
-import GcpError from './errors/gcp_error.vue';
-
-const SCREEN_GCP_ERROR = 'gcp_error';
-const SCREEN_HOME = 'home';
-const SCREEN_NO_GCP_PROJECTS = 'no_gcp_projects';
-const SCREEN_SERVICE_ACCOUNTS_FORM = 'service_accounts_form';
-const SCREEN_GCP_REGIONS_FORM = 'gcp_regions_form';
-
-export default {
- components: {
- IncubationBanner,
- },
- inheritAttrs: false,
- props: {
- screen: {
- required: true,
- type: String,
- },
- },
- 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;
- case SCREEN_GCP_REGIONS_FORM:
- return GcpRegionsForm;
- default:
- throw new Error(__('Unknown screen'));
- }
- },
- },
- methods: {
- feedbackUrl(template) {
- return `https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/feedback/-/issues/new?issuable_template=${template}`;
- },
- },
-};
-</script>
-
-<template>
- <div>
- <incubation-banner
- :share-feedback-url="feedbackUrl('general_feedback')"
- :report-bug-url="feedbackUrl('report_bug')"
- :feature-request-url="feedbackUrl('feature_request')"
- />
- <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
deleted file mode 100644
index 90aa0e1ae68..00000000000
--- a/app/assets/javascripts/google_cloud/components/errors/gcp_error.vue
+++ /dev/null
@@ -1,29 +0,0 @@
-<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
deleted file mode 100644
index da229ac3f0e..00000000000
--- a/app/assets/javascripts/google_cloud/components/errors/no_gcp_projects.vue
+++ /dev/null
@@ -1,26 +0,0 @@
-<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/google_cloud_menu.vue b/app/assets/javascripts/google_cloud/components/google_cloud_menu.vue
new file mode 100644
index 00000000000..d6b7c702b54
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/components/google_cloud_menu.vue
@@ -0,0 +1,85 @@
+<script>
+import { s__ } from '~/locale';
+
+const CONFIGURATION_KEY = 'configuration';
+const DEPLOYMENTS_KEY = 'deployments';
+const DATABASES_KEY = 'databases';
+
+const i18n = {
+ configuration: { title: s__('CloudSeed|Configuration') },
+ deployments: { title: s__('CloudSeed|Deployments') },
+ databases: { title: s__('CloudSeed|Databases') },
+};
+
+export default {
+ props: {
+ active: {
+ type: String,
+ required: true,
+ },
+ configurationUrl: {
+ type: String,
+ required: true,
+ },
+ deploymentsUrl: {
+ type: String,
+ required: true,
+ },
+ databasesUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ isConfigurationActive() {
+ return this.active === CONFIGURATION_KEY;
+ },
+ isDeploymentsActive() {
+ return this.active === DEPLOYMENTS_KEY;
+ },
+ isDatabasesActive() {
+ return this.active === DATABASES_KEY;
+ },
+ },
+ i18n,
+};
+</script>
+<template>
+ <div class="tabs gl-tabs">
+ <ul role="tablist" class="nav gl-tabs-nav">
+ <li role="presentation" class="nav-item">
+ <a
+ data-testid="configurationLink"
+ role="tab"
+ :href="configurationUrl"
+ class="nav-link gl-tab-nav-item"
+ :class="{ 'gl-tab-nav-item-active': isConfigurationActive }"
+ >
+ {{ $options.i18n.configuration.title }}</a
+ >
+ </li>
+ <li role="presentation" class="nav-item">
+ <a
+ data-testid="deploymentsLink"
+ role="tab"
+ :href="deploymentsUrl"
+ class="nav-link gl-tab-nav-item"
+ :class="{ 'gl-tab-nav-item-active': isDeploymentsActive }"
+ >
+ {{ $options.i18n.deployments.title }}
+ </a>
+ </li>
+ <li role="presentation" class="nav-item">
+ <a
+ data-testid="databasesLink"
+ role="tab"
+ :href="databasesUrl"
+ class="nav-link gl-tab-nav-item"
+ :class="{ 'gl-tab-nav-item-active': isDatabasesActive }"
+ >
+ {{ $options.i18n.databases.title }}
+ </a>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/components/home.vue b/app/assets/javascripts/google_cloud/components/home.vue
deleted file mode 100644
index e41337e2679..00000000000
--- a/app/assets/javascripts/google_cloud/components/home.vue
+++ /dev/null
@@ -1,81 +0,0 @@
-<script>
-import { GlTabs, GlTab } from '@gitlab/ui';
-import DeploymentsServiceTable from './deployments_service_table.vue';
-import RevokeOauth from './revoke_oauth.vue';
-import ServiceAccountsList from './service_accounts_list.vue';
-import GcpRegionsList from './gcp_regions_list.vue';
-
-export default {
- components: {
- GlTabs,
- GlTab,
- DeploymentsServiceTable,
- RevokeOauth,
- ServiceAccountsList,
- GcpRegionsList,
- },
- props: {
- serviceAccounts: {
- type: Array,
- required: true,
- },
- createServiceAccountUrl: {
- type: String,
- required: true,
- },
- configureGcpRegionsUrl: {
- type: String,
- required: true,
- },
- emptyIllustrationUrl: {
- type: String,
- required: true,
- },
- enableCloudRunUrl: {
- type: String,
- required: true,
- },
- enableCloudStorageUrl: {
- type: String,
- required: true,
- },
- gcpRegions: {
- type: Array,
- required: true,
- },
- revokeOauthUrl: {
- 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"
- />
- <hr />
- <gcp-regions-list
- class="gl-mx-4"
- :empty-illustration-url="emptyIllustrationUrl"
- :create-url="configureGcpRegionsUrl"
- :list="gcpRegions"
- />
- <hr v-if="revokeOauthUrl" />
- <revoke-oauth v-if="revokeOauthUrl" :url="revokeOauthUrl" />
- </gl-tab>
- <gl-tab :title="__('Deployments')">
- <deployments-service-table
- :cloud-run-url="enableCloudRunUrl"
- :cloud-storage-url="enableCloudStorageUrl"
- />
- </gl-tab>
- <gl-tab :title="__('Services')" disabled />
- </gl-tabs>
-</template>
diff --git a/app/assets/javascripts/google_cloud/components/incubation_banner.vue b/app/assets/javascripts/google_cloud/components/incubation_banner.vue
index 652b8c1aecb..128b3dcb1d9 100644
--- a/app/assets/javascripts/google_cloud/components/incubation_banner.vue
+++ b/app/assets/javascripts/google_cloud/components/incubation_banner.vue
@@ -1,22 +1,20 @@
<script>
import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
+const FEATURE_REQUEST_KEY = 'feature_request';
+const REPORT_BUG_KEY = 'report_bug';
+const GENERAL_FEEDBACK_KEY = 'general_feedback';
+
export default {
components: { GlAlert, GlLink, GlSprintf },
- props: {
- shareFeedbackUrl: {
- required: true,
- type: String,
- },
- reportBugUrl: {
- required: true,
- type: String,
- },
- featureRequestUrl: {
- required: true,
- type: String,
+ methods: {
+ feedbackUrl(template) {
+ return `https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/feedback/-/issues/new?issuable_template=${template}`;
},
},
+ FEATURE_REQUEST_KEY,
+ REPORT_BUG_KEY,
+ GENERAL_FEEDBACK_KEY,
};
</script>
@@ -31,13 +29,13 @@ export default {
"
>
<template #featureLink="{ content }">
- <gl-link :href="featureRequestUrl">{{ content }}</gl-link>
+ <gl-link :href="feedbackUrl($options.FEATURE_REQUEST_KEY)">{{ content }}</gl-link>
</template>
<template #bugLink="{ content }">
- <gl-link :href="reportBugUrl">{{ content }}</gl-link>
+ <gl-link :href="feedbackUrl($options.REPORT_BUG_KEY)">{{ content }}</gl-link>
</template>
<template #feedbackLink="{ content }">
- <gl-link :href="shareFeedbackUrl">{{ content }}</gl-link>
+ <gl-link :href="feedbackUrl($options.GENERAL_FEEDBACK_KEY)">{{ content }}</gl-link>
</template>
</gl-sprintf>
</gl-alert>
diff --git a/app/assets/javascripts/google_cloud/configuration/index.js b/app/assets/javascripts/google_cloud/configuration/index.js
new file mode 100644
index 00000000000..580315588d0
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/configuration/index.js
@@ -0,0 +1,11 @@
+import Vue from 'vue';
+import Panel from './panel.vue';
+
+export default (containerId = '#js-google-cloud-configuration') => {
+ const element = document.querySelector(containerId);
+ const { ...attrs } = JSON.parse(element.getAttribute('data'));
+ return new Vue({
+ el: element,
+ render: (createElement) => createElement(Panel, { attrs }),
+ });
+};
diff --git a/app/assets/javascripts/google_cloud/configuration/panel.vue b/app/assets/javascripts/google_cloud/configuration/panel.vue
new file mode 100644
index 00000000000..ee046eb1988
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/configuration/panel.vue
@@ -0,0 +1,88 @@
+<script>
+import GcpRegionsList from '../gcp_regions/list.vue';
+import GoogleCloudMenu from '../components/google_cloud_menu.vue';
+import IncubationBanner from '../components/incubation_banner.vue';
+import RevokeOauth from '../components/revoke_oauth.vue';
+import ServiceAccountsList from '../service_accounts/list.vue';
+
+export default {
+ components: {
+ GcpRegionsList,
+ GoogleCloudMenu,
+ IncubationBanner,
+ RevokeOauth,
+ ServiceAccountsList,
+ },
+ props: {
+ configurationUrl: {
+ type: String,
+ required: true,
+ },
+ deploymentsUrl: {
+ type: String,
+ required: true,
+ },
+ databasesUrl: {
+ type: String,
+ required: true,
+ },
+ serviceAccounts: {
+ type: Array,
+ required: true,
+ },
+ createServiceAccountUrl: {
+ type: String,
+ required: true,
+ },
+ emptyIllustrationUrl: {
+ type: String,
+ required: true,
+ },
+ configureGcpRegionsUrl: {
+ type: String,
+ required: true,
+ },
+ gcpRegions: {
+ type: Array,
+ required: true,
+ },
+ revokeOauthUrl: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <incubation-banner />
+
+ <google-cloud-menu
+ active="configuration"
+ :configuration-url="configurationUrl"
+ :deployments-url="deploymentsUrl"
+ :databases-url="databasesUrl"
+ />
+
+ <service-accounts-list
+ class="gl-mx-4"
+ :list="serviceAccounts"
+ :create-url="createServiceAccountUrl"
+ :empty-illustration-url="emptyIllustrationUrl"
+ />
+
+ <hr />
+
+ <gcp-regions-list
+ class="gl-mx-4"
+ :empty-illustration-url="emptyIllustrationUrl"
+ :create-url="configureGcpRegionsUrl"
+ :list="gcpRegions"
+ />
+
+ <hr v-if="revokeOauthUrl" />
+
+ <revoke-oauth v-if="revokeOauthUrl" :url="revokeOauthUrl" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/databases/cloudsql/create_instance_form.vue b/app/assets/javascripts/google_cloud/databases/cloudsql/create_instance_form.vue
new file mode 100644
index 00000000000..0ac561b6132
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/databases/cloudsql/create_instance_form.vue
@@ -0,0 +1,132 @@
+<script>
+import { GlButton, GlFormCheckbox, GlFormGroup, GlFormSelect } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+const i18n = {
+ gcpProjectLabel: s__('CloudSeed|Google Cloud project'),
+ gcpProjectDescription: s__(
+ 'CloudSeed|Database instance is generated within the selected Google Cloud project',
+ ),
+ refsLabel: s__('CloudSeed|Refs'),
+ refsDescription: s__(
+ 'CloudSeed|Generated database instance is linked to the selected branch or tag',
+ ),
+ databaseVersionLabel: s__('CloudSeed|Database version'),
+ tierLabel: s__('CloudSeed|Machine type'),
+ tierDescription: s__('CloudSeed|Determines memory and virtual cores available to your instance'),
+ checkboxLabel: s__(
+ 'CloudSeed|I accept Google Cloud pricing and responsibilities involved with managing database instances',
+ ),
+ cancelLabel: s__('CloudSeed|Cancel'),
+ submitLabel: s__('CloudSeed|Create instance'),
+ all: s__('CloudSeed|All'),
+};
+
+export default {
+ ALL_REFS: '*',
+ components: {
+ GlButton,
+ GlFormCheckbox,
+ GlFormGroup,
+ GlFormSelect,
+ },
+ props: {
+ cancelPath: { required: true, type: String },
+ gcpProjects: { required: true, type: Array },
+ refs: { required: true, type: Array },
+ formTitle: { required: true, type: String },
+ formDescription: { required: true, type: String },
+ databaseVersions: { required: true, type: Array },
+ tiers: { required: true, type: Array },
+ },
+ i18n,
+};
+</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">{{ formTitle }}</h2>
+ <p>{{ formDescription }}</p>
+ </header>
+
+ <gl-form-group
+ data-testid="form_group_gcp_project"
+ label-for="gcp_project"
+ :label="$options.i18n.gcpProjectLabel"
+ :description="$options.i18n.gcpProjectDescription"
+ >
+ <gl-form-select id="gcp_project" data-testid="select_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
+ data-testid="form_group_environments"
+ label-for="ref"
+ :label="$options.i18n.refsLabel"
+ :description="$options.i18n.refsDescription"
+ >
+ <gl-form-select id="ref" data-testid="select_environments" name="ref" required>
+ <option :value="$options.ALL_REFS">{{ $options.i18n.all }}</option>
+ <option v-for="ref in refs" :key="ref" :value="ref">
+ {{ ref }}
+ </option>
+ </gl-form-select>
+ </gl-form-group>
+
+ <gl-form-group
+ data-testid="form_group_tier"
+ label-for="tier"
+ :label="$options.i18n.tierLabel"
+ :description="$options.i18n.tierDescription"
+ >
+ <gl-form-select id="tier" data-testid="select_tier" name="tier" required>
+ <option v-for="tier in tiers" :key="tier.value" :value="tier.value">
+ {{ tier.label }}
+ </option>
+ </gl-form-select>
+ </gl-form-group>
+
+ <gl-form-group
+ data-testid="form_group_database_version"
+ label-for="database-version"
+ :label="$options.i18n.databaseVersionLabel"
+ >
+ <gl-form-select
+ id="database-version"
+ data-testid="select_database_version"
+ name="database_version"
+ required
+ >
+ <option
+ v-for="databaseVersion in databaseVersions"
+ :key="databaseVersion.value"
+ :value="databaseVersion.value"
+ >
+ {{ databaseVersion.label }}
+ </option>
+ </gl-form-select>
+ </gl-form-group>
+
+ <gl-form-group>
+ <gl-form-checkbox name="confirmation" required>
+ {{ $options.i18n.checkboxLabel }}
+ </gl-form-checkbox>
+ </gl-form-group>
+
+ <div class="form-actions row">
+ <gl-button type="submit" category="primary" variant="confirm" data-testid="submit-button">
+ {{ $options.i18n.submitLabel }}
+ </gl-button>
+ <gl-button class="gl-ml-1" :href="cancelPath" data-testid="cancel-button">{{
+ $options.i18n.cancelLabel
+ }}</gl-button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/databases/cloudsql/instance_table.vue b/app/assets/javascripts/google_cloud/databases/cloudsql/instance_table.vue
new file mode 100644
index 00000000000..823895214df
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/databases/cloudsql/instance_table.vue
@@ -0,0 +1,75 @@
+<script>
+import { GlEmptyState, GlLink, GlTable } from '@gitlab/ui';
+import { encodeSaferUrl, setUrlParams } from '~/lib/utils/url_utility';
+import { s__ } from '~/locale';
+
+const i18n = {
+ noInstancesTitle: s__('CloudSeed|No instances'),
+ noInstancesDescription: s__('CloudSeed|There are no instances to display.'),
+ title: s__('CloudSeed|Instances'),
+ description: s__('CloudSeed|Database instances associated with this project'),
+};
+
+export default {
+ components: { GlEmptyState, GlLink, GlTable },
+ props: {
+ cloudsqlInstances: {
+ type: Array,
+ required: true,
+ },
+ emptyIllustrationUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ tableData() {
+ return this.cloudsqlInstances.filter((instance) => instance.instance_name);
+ },
+ },
+ methods: {
+ gcpProjectUrl(id) {
+ return setUrlParams({ project: id }, 'https://console.cloud.google.com/sql/instances');
+ },
+ instanceUrl(name, id) {
+ const saferName = encodeSaferUrl(name);
+
+ return setUrlParams(
+ { project: id },
+ `https://console.cloud.google.com/sql/instances/${saferName}/overview`,
+ );
+ },
+ },
+ fields: [
+ { key: 'ref', label: s__('CloudSeed|Environment') },
+ { key: 'gcp_project', label: s__('CloudSeed|Google Cloud Project') },
+ { key: 'instance_name', label: s__('CloudSeed|CloudSQL Instance') },
+ { key: 'version', label: s__('CloudSeed|Version') },
+ ],
+ i18n,
+};
+</script>
+
+<template>
+ <div class="gl-mx-3">
+ <gl-empty-state
+ v-if="tableData.length === 0"
+ :title="$options.i18n.noInstancesTitle"
+ :description="$options.i18n.noInstancesDescription"
+ :svg-path="emptyIllustrationUrl"
+ />
+
+ <div v-else>
+ <h2 class="gl-font-size-h2">{{ $options.i18n.title }}</h2>
+ <p>{{ $options.i18n.description }}</p>
+ <gl-table :fields="$options.fields" :items="tableData">
+ <template #cell(gcp_project)="{ value }">
+ <gl-link :href="gcpProjectUrl(value)">{{ value }}</gl-link>
+ </template>
+ <template #cell(instance_name)="{ item: { instance_name, gcp_project } }">
+ <a :href="instanceUrl(instance_name, gcp_project)">{{ instance_name }}</a>
+ </template>
+ </gl-table>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/databases/index.js b/app/assets/javascripts/google_cloud/databases/index.js
new file mode 100644
index 00000000000..e240a1116e8
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/databases/index.js
@@ -0,0 +1,11 @@
+import Vue from 'vue';
+import Panel from './panel.vue';
+
+export default (containerId = '#js-google-cloud-databases') => {
+ const element = document.querySelector(containerId);
+ const { ...attrs } = JSON.parse(element.getAttribute('data'));
+ return new Vue({
+ el: element,
+ render: (createElement) => createElement(Panel, { attrs }),
+ });
+};
diff --git a/app/assets/javascripts/google_cloud/databases/panel.vue b/app/assets/javascripts/google_cloud/databases/panel.vue
new file mode 100644
index 00000000000..e2f18c286a5
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/databases/panel.vue
@@ -0,0 +1,38 @@
+<script>
+import GoogleCloudMenu from '../components/google_cloud_menu.vue';
+import IncubationBanner from '../components/incubation_banner.vue';
+
+export default {
+ components: {
+ IncubationBanner,
+ GoogleCloudMenu,
+ },
+ props: {
+ configurationUrl: {
+ type: String,
+ required: true,
+ },
+ deploymentsUrl: {
+ type: String,
+ required: true,
+ },
+ databasesUrl: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <incubation-banner />
+
+ <google-cloud-menu
+ active="databases"
+ :configuration-url="configurationUrl"
+ :deployments-url="deploymentsUrl"
+ :databases-url="databasesUrl"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/databases/service_table.vue b/app/assets/javascripts/google_cloud/databases/service_table.vue
new file mode 100644
index 00000000000..80bd6ef28fb
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/databases/service_table.vue
@@ -0,0 +1,221 @@
+<script>
+import { GlAlert, GlButton, GlLink, GlSprintf, GlTable } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { s__ } from '~/locale';
+
+const KEY_CLOUDSQL_POSTGRES = 'cloudsql-postgres';
+const KEY_CLOUDSQL_MYSQL = 'cloudsql-mysql';
+const KEY_CLOUDSQL_SQLSERVER = 'cloudsql-sqlserver';
+const KEY_ALLOYDB_POSTGRES = 'alloydb-postgres';
+const KEY_MEMORYSTORE_REDIS = 'memorystore-redis';
+const KEY_FIRESTORE = 'firestore';
+
+const i18n = {
+ columnService: s__('CloudSeed|Service'),
+ columnDescription: s__('CloudSeed|Description'),
+ cloudsqlPostgresTitle: s__('CloudSeed|Cloud SQL for Postgres'),
+ cloudsqlPostgresDescription: s__(
+ 'CloudSeed|Fully managed relational database service for PostgreSQL',
+ ),
+ cloudsqlMysqlTitle: s__('CloudSeed|Cloud SQL for MySQL'),
+ cloudsqlMysqlDescription: s__('CloudSeed|Fully managed relational database service for MySQL'),
+ cloudsqlSqlserverTitle: s__('CloudSeed|Cloud SQL for SQL Server'),
+ cloudsqlSqlserverDescription: s__(
+ 'CloudSeed|Fully managed relational database service for SQL Server',
+ ),
+ alloydbPostgresTitle: s__('CloudSeed|AlloyDB for Postgres'),
+ alloydbPostgresDescription: s__(
+ 'CloudSeed|Fully managed PostgreSQL-compatible service for high-demand workloads',
+ ),
+ memorystoreRedisTitle: s__('CloudSeed|Memorystore for Redis'),
+ memorystoreRedisDescription: s__(
+ 'CloudSeed|Scalable, secure, and highly available in-memory service for Redis',
+ ),
+ firestoreTitle: s__('CloudSeed|Cloud Firestore'),
+ firestoreDescription: s__(
+ 'CloudSeed|Flexible, scalable NoSQL cloud database for client- and server-side development',
+ ),
+ createInstance: s__('CloudSeed|Create instance'),
+ createCluster: s__('CloudSeed|Create cluster'),
+ createDatabase: s__('CloudSeed|Create database'),
+ title: s__('CloudSeed|Services'),
+ description: s__('CloudSeed|Available database services through which instances may be created'),
+ pricingAlert: s__(
+ 'CloudSeed|Learn more about pricing for %{cloudsqlPricingStart}Cloud SQL%{cloudsqlPricingEnd}, %{alloydbPricingStart}Alloy DB%{alloydbPricingEnd}, %{memorystorePricingStart}Memorystore%{memorystorePricingEnd} and %{firestorePricingStart}Firestore%{firestorePricingEnd}.',
+ ),
+ secretManagersDescription: s__(
+ 'CloudSeed|Enhance security by storing database variables in secret managers - learn more about %{docLinkStart}secret management with GitLab%{docLinkEnd}',
+ ),
+};
+
+const helpUrlSecrets = helpPagePath('ee/ci/secrets');
+
+export default {
+ components: { GlAlert, GlButton, GlLink, GlSprintf, GlTable },
+ props: {
+ cloudsqlPostgresUrl: {
+ type: String,
+ required: true,
+ },
+ cloudsqlMysqlUrl: {
+ type: String,
+ required: true,
+ },
+ cloudsqlSqlserverUrl: {
+ type: String,
+ required: true,
+ },
+ alloydbPostgresUrl: {
+ type: String,
+ required: true,
+ },
+ memorystoreRedisUrl: {
+ type: String,
+ required: true,
+ },
+ firestoreUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ actionUrl(key) {
+ switch (key) {
+ case KEY_CLOUDSQL_POSTGRES:
+ return this.cloudsqlPostgresUrl;
+ case KEY_CLOUDSQL_MYSQL:
+ return this.cloudsqlMysqlUrl;
+ case KEY_CLOUDSQL_SQLSERVER:
+ return this.cloudsqlSqlserverUrl;
+ case KEY_ALLOYDB_POSTGRES:
+ return this.alloydbPostgresUrl;
+ case KEY_MEMORYSTORE_REDIS:
+ return this.memorystoreRedisUrl;
+ case KEY_FIRESTORE:
+ return this.firestoreUrl;
+ default:
+ return '#';
+ }
+ },
+ },
+ fields: [
+ { key: 'title', label: i18n.columnService },
+ { key: 'description', label: i18n.columnDescription },
+ { key: 'action', label: '' },
+ ],
+ items: [
+ {
+ title: i18n.cloudsqlPostgresTitle,
+ description: i18n.cloudsqlPostgresDescription,
+ action: {
+ key: KEY_CLOUDSQL_POSTGRES,
+ title: i18n.createInstance,
+ testId: 'button-cloudsql-postgres',
+ },
+ },
+ {
+ title: i18n.cloudsqlMysqlTitle,
+ description: i18n.cloudsqlMysqlDescription,
+ action: {
+ disabled: false,
+ key: KEY_CLOUDSQL_MYSQL,
+ title: i18n.createInstance,
+ testId: 'button-cloudsql-mysql',
+ },
+ },
+ {
+ title: i18n.cloudsqlSqlserverTitle,
+ description: i18n.cloudsqlSqlserverDescription,
+ action: {
+ disabled: false,
+ key: KEY_CLOUDSQL_SQLSERVER,
+ title: i18n.createInstance,
+ testId: 'button-cloudsql-sqlserver',
+ },
+ },
+ {
+ title: i18n.alloydbPostgresTitle,
+ description: i18n.alloydbPostgresDescription,
+ action: {
+ disabled: true,
+ key: KEY_ALLOYDB_POSTGRES,
+ title: i18n.createCluster,
+ testId: 'button-alloydb-postgres',
+ },
+ },
+ {
+ title: i18n.memorystoreRedisTitle,
+ description: i18n.memorystoreRedisDescription,
+ action: {
+ disabled: true,
+ key: KEY_MEMORYSTORE_REDIS,
+ title: i18n.createInstance,
+ testId: 'button-memorystore-redis',
+ },
+ },
+ {
+ title: i18n.firestoreTitle,
+ description: i18n.firestoreDescription,
+ action: {
+ disabled: true,
+ key: KEY_FIRESTORE,
+ title: i18n.createDatabase,
+ testId: 'button-firestore',
+ },
+ },
+ ],
+ helpUrlSecrets,
+ i18n,
+};
+</script>
+
+<template>
+ <div class="gl-mx-3">
+ <h2 class="gl-font-size-h2">{{ $options.i18n.title }}</h2>
+ <p>{{ $options.i18n.description }}</p>
+
+ <gl-table :fields="$options.fields" :items="$options.items">
+ <template #cell(action)="{ value }">
+ <gl-button
+ block
+ :disabled="value.disabled"
+ :href="actionUrl(value.key)"
+ :data-testid="value.testId"
+ category="secondary"
+ variant="confirm"
+ >
+ {{ value.title }}
+ </gl-button>
+ </template>
+ </gl-table>
+
+ <gl-alert class="gl-mt-5" :dismissible="false" variant="tip">
+ <gl-sprintf :message="$options.i18n.pricingAlert">
+ <template #cloudsqlPricing="{ content }">
+ <gl-link href="https://cloud.google.com/sql/pricing">{{ content }}</gl-link>
+ </template>
+ <template #alloydbPricing="{ content }">
+ <gl-link href="https://cloud.google.com/alloydb/pricing">{{ content }}</gl-link>
+ </template>
+ <template #memorystorePricing="{ content }">
+ <gl-link href="https://cloud.google.com/memorystore/docs/redis/pricing">{{
+ content
+ }}</gl-link>
+ </template>
+ <template #firestorePricing="{ content }">
+ <gl-link href="https://cloud.google.com/firestore/pricing">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+
+ <gl-alert class="gl-mt-5" :dismissible="false" variant="tip">
+ <gl-sprintf :message="$options.i18n.secretManagersDescription">
+ <template #docLink="{ content }">
+ <gl-link :href="$options.helpUrlSecrets">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/deployments/index.js b/app/assets/javascripts/google_cloud/deployments/index.js
new file mode 100644
index 00000000000..fcbb2209c40
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/deployments/index.js
@@ -0,0 +1,11 @@
+import Vue from 'vue';
+import Panel from './panel.vue';
+
+export default (containerId = '#js-google-cloud-deployments') => {
+ const element = document.querySelector(containerId);
+ const { ...attrs } = JSON.parse(element.getAttribute('data'));
+ return new Vue({
+ el: element,
+ render: (createElement) => createElement(Panel, { attrs }),
+ });
+};
diff --git a/app/assets/javascripts/google_cloud/deployments/panel.vue b/app/assets/javascripts/google_cloud/deployments/panel.vue
new file mode 100644
index 00000000000..89db132ad5e
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/deployments/panel.vue
@@ -0,0 +1,50 @@
+<script>
+import GoogleCloudMenu from '../components/google_cloud_menu.vue';
+import IncubationBanner from '../components/incubation_banner.vue';
+import ServiceTable from './service_table.vue';
+
+export default {
+ components: {
+ ServiceTable,
+ IncubationBanner,
+ GoogleCloudMenu,
+ },
+ props: {
+ configurationUrl: {
+ type: String,
+ required: true,
+ },
+ deploymentsUrl: {
+ type: String,
+ required: true,
+ },
+ databasesUrl: {
+ type: String,
+ required: true,
+ },
+ enableCloudRunUrl: {
+ type: String,
+ required: true,
+ },
+ enableCloudStorageUrl: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <incubation-banner />
+
+ <google-cloud-menu
+ active="deployments"
+ :configuration-url="configurationUrl"
+ :deployments-url="deploymentsUrl"
+ :databases-url="databasesUrl"
+ />
+
+ <service-table :cloud-run-url="enableCloudRunUrl" :cloud-storage-url="enableCloudStorageUrl" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/components/deployments_service_table.vue b/app/assets/javascripts/google_cloud/deployments/service_table.vue
index 26c9fd14dc6..26c9fd14dc6 100644
--- a/app/assets/javascripts/google_cloud/components/deployments_service_table.vue
+++ b/app/assets/javascripts/google_cloud/deployments/service_table.vue
diff --git a/app/assets/javascripts/google_cloud/components/gcp_regions_form.vue b/app/assets/javascripts/google_cloud/gcp_regions/form.vue
index 23011e5a5b0..23011e5a5b0 100644
--- a/app/assets/javascripts/google_cloud/components/gcp_regions_form.vue
+++ b/app/assets/javascripts/google_cloud/gcp_regions/form.vue
diff --git a/app/assets/javascripts/google_cloud/gcp_regions/index.js b/app/assets/javascripts/google_cloud/gcp_regions/index.js
new file mode 100644
index 00000000000..da37c612805
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/gcp_regions/index.js
@@ -0,0 +1,11 @@
+import Vue from 'vue';
+import Form from './form.vue';
+
+export default (containerId = '#js-google-cloud-gcp-regions') => {
+ const element = document.querySelector(containerId);
+ const { ...attrs } = JSON.parse(element.getAttribute('data'));
+ return new Vue({
+ el: element,
+ render: (createElement) => createElement(Form, { attrs }),
+ });
+};
diff --git a/app/assets/javascripts/google_cloud/components/gcp_regions_list.vue b/app/assets/javascripts/google_cloud/gcp_regions/list.vue
index 5d403d5cd65..5d403d5cd65 100644
--- a/app/assets/javascripts/google_cloud/components/gcp_regions_list.vue
+++ b/app/assets/javascripts/google_cloud/gcp_regions/list.vue
diff --git a/app/assets/javascripts/google_cloud/index.js b/app/assets/javascripts/google_cloud/index.js
deleted file mode 100644
index ab9e8227812..00000000000
--- a/app/assets/javascripts/google_cloud/index.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import Vue from 'vue';
-import App from './components/app.vue';
-
-export default () => {
- 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/google_cloud/components/service_accounts_form.vue b/app/assets/javascripts/google_cloud/service_accounts/form.vue
index faec94e735b..faec94e735b 100644
--- a/app/assets/javascripts/google_cloud/components/service_accounts_form.vue
+++ b/app/assets/javascripts/google_cloud/service_accounts/form.vue
diff --git a/app/assets/javascripts/google_cloud/service_accounts/index.js b/app/assets/javascripts/google_cloud/service_accounts/index.js
new file mode 100644
index 00000000000..5207b44deac
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/service_accounts/index.js
@@ -0,0 +1,11 @@
+import Vue from 'vue';
+import Form from './form.vue';
+
+export default (containerId = '#js-google-cloud-service-accounts') => {
+ const element = document.querySelector(containerId);
+ const { ...attrs } = JSON.parse(element.getAttribute('data'));
+ return new Vue({
+ el: element,
+ render: (createElement) => createElement(Form, { attrs }),
+ });
+};
diff --git a/app/assets/javascripts/google_cloud/components/service_accounts_list.vue b/app/assets/javascripts/google_cloud/service_accounts/list.vue
index 4b580c594f5..4b580c594f5 100644
--- a/app/assets/javascripts/google_cloud/components/service_accounts_list.vue
+++ b/app/assets/javascripts/google_cloud/service_accounts/list.vue
diff --git a/app/assets/javascripts/google_tag_manager/index.js b/app/assets/javascripts/google_tag_manager/index.js
index 2969121bf06..c8204f397ff 100644
--- a/app/assets/javascripts/google_tag_manager/index.js
+++ b/app/assets/javascripts/google_tag_manager/index.js
@@ -176,6 +176,14 @@ export const trackSaasTrialGetStarted = () => {
});
};
+export const trackTrialAcceptTerms = () => {
+ if (!isSupported()) {
+ return;
+ }
+
+ pushEvent('saasTrialAcceptTerms');
+};
+
export const trackCheckout = (selectedPlan, quantity) => {
if (!isSupported()) {
return;
diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json
index 50b40526ee0..45c5cca68cc 100644
--- a/app/assets/javascripts/graphql_shared/possible_types.json
+++ b/app/assets/javascripts/graphql_shared/possible_types.json
@@ -131,7 +131,9 @@
"VulnerabilityLocationSecretDetection"
],
"WorkItemWidget": [
+ "WorkItemWidgetAssignees",
"WorkItemWidgetDescription",
- "WorkItemWidgetHierarchy"
+ "WorkItemWidgetHierarchy",
+ "WorkItemWidgetWeight"
]
}
diff --git a/app/assets/javascripts/graphql_shared/queries/current_user.query.graphql b/app/assets/javascripts/graphql_shared/queries/current_user.query.graphql
new file mode 100644
index 00000000000..93335c93c1d
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/queries/current_user.query.graphql
@@ -0,0 +1,7 @@
+#import "../fragments/user.fragment.graphql"
+
+query currentUser {
+ currentUser {
+ ...User
+ }
+}
diff --git a/app/assets/javascripts/graphql_shared/queries/get_users_by_usernames.query.graphql b/app/assets/javascripts/graphql_shared/queries/get_users_by_usernames.query.graphql
new file mode 100644
index 00000000000..07398867544
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/queries/get_users_by_usernames.query.graphql
@@ -0,0 +1,9 @@
+#import "../fragments/user.fragment.graphql"
+
+query getUsersByUsernames($usernames: [String!]) {
+ users(usernames: $usernames) {
+ nodes {
+ ...User
+ }
+ }
+}
diff --git a/app/assets/javascripts/graphql_shared/queries/users_search_all.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_search_all.query.graphql
new file mode 100644
index 00000000000..9c75df84e78
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/queries/users_search_all.query.graphql
@@ -0,0 +1,9 @@
+#import "../fragments/user.fragment.graphql"
+
+query searchAllUsers($search: String!, $first: Int = null) {
+ users(search: $search, first: $first) {
+ nodes {
+ ...User
+ }
+ }
+}
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 2241d57f96f..7345afb8545 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -4,13 +4,24 @@ import {
GlLoadingIcon,
GlBadge,
GlIcon,
+ GlLabel,
+ GlButton,
+ GlPopover,
+ GlLink,
GlTooltipDirective,
GlSafeHtmlDirective,
} from '@gitlab/ui';
import { visitUrl } from '~/lib/utils/url_utility';
import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
-import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '../constants';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { __ } from '~/locale';
+import {
+ VISIBILITY_TYPE_ICON,
+ GROUP_VISIBILITY_TYPE,
+ ITEM_TYPE,
+ VISIBILITY_PRIVATE,
+} from '../constants';
import eventHub from '../event_hub';
import itemActions from './item_actions.vue';
@@ -28,16 +39,17 @@ export default {
GlBadge,
GlLoadingIcon,
GlIcon,
+ GlLabel,
+ GlButton,
+ GlPopover,
+ GlLink,
UserAccessRoleBadge,
- ComplianceFrameworkLabel: () =>
- import(
- 'ee_component/vue_shared/components/compliance_framework_label/compliance_framework_label.vue'
- ),
itemCaret,
itemTypeIcon,
itemActions,
itemStats,
},
+ inject: ['currentGroupVisibility'],
props: {
parentGroup: {
type: Object,
@@ -58,6 +70,9 @@ export default {
groupDomId() {
return `group-${this.group.id}`;
},
+ itemTestId() {
+ return `group-overview-item-${this.group.id}`;
+ },
rowClass() {
return {
'is-open': this.group.isOpen,
@@ -76,10 +91,10 @@ export default {
return Boolean(this.group.complianceFramework?.name);
},
isGroup() {
- return this.group.type === 'group';
+ return this.group.type === ITEM_TYPE.GROUP;
},
isGroupPendingRemoval() {
- return this.group.type === 'group' && this.group.pendingRemoval;
+ return this.group.type === ITEM_TYPE.GROUP && this.group.pendingRemoval;
},
visibilityIcon() {
return VISIBILITY_TYPE_ICON[this.group.visibility];
@@ -96,6 +111,13 @@ export default {
showActionsMenu() {
return this.isGroup && (this.group.canEdit || this.group.canRemove || this.group.canLeave);
},
+ shouldShowVisibilityWarning() {
+ return (
+ this.action === 'shared' &&
+ this.currentGroupVisibility === VISIBILITY_PRIVATE &&
+ this.group.visibility !== VISIBILITY_PRIVATE
+ );
+ },
},
methods: {
onClickRowGroup(e) {
@@ -112,6 +134,17 @@ export default {
}
},
},
+ i18n: {
+ popoverTitle: __('Less restrictive visibility'),
+ popoverBody: __('Project visibility level is less restrictive than the group settings.'),
+ learnMore: __('Learn more'),
+ },
+ shareProjectsWithGroupsHelpPagePath: helpPagePath(
+ 'user/project/members/share_project_with_groups',
+ {
+ anchor: 'share-a-public-project-with-private-group',
+ },
+ ),
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
AVATAR_SHAPE_OPTION_RECT,
};
@@ -120,6 +153,7 @@ export default {
<template>
<li
:id="groupDomId"
+ :data-testid="itemTestId"
:class="rowClass"
class="group-row"
:itemprop="microdata.itemprop"
@@ -165,7 +199,7 @@ export default {
data-testid="group-name"
:href="group.relativePath"
:title="group.fullName"
- class="no-expand gl-mr-3 gl-mt-3 gl-text-gray-900!"
+ class="no-expand gl-mr-3 gl-text-gray-900!"
:itemprop="microdata.nameItemprop"
>
{{
@@ -176,20 +210,44 @@ export default {
</a>
<gl-icon
v-gl-tooltip.hover.bottom
- class="gl-display-inline-flex gl-align-items-center gl-mr-3 gl-mt-3 gl-text-gray-500"
+ class="gl-display-inline-flex gl-align-items-center gl-mr-3 gl-text-gray-500"
:name="visibilityIcon"
:title="visibilityTooltip"
data-testid="group-visibility-icon"
/>
- <user-access-role-badge v-if="group.permission" class="gl-mt-3">
+ <template v-if="shouldShowVisibilityWarning">
+ <gl-button
+ ref="visibilityWarningButton"
+ class="gl-p-1! gl-bg-transparent! gl-mr-3"
+ category="tertiary"
+ icon="warning"
+ :aria-label="$options.i18n.popoverTitle"
+ @click.stop
+ />
+ <gl-popover
+ :target="() => $refs.visibilityWarningButton.$el"
+ :title="$options.i18n.popoverTitle"
+ triggers="hover focus"
+ >
+ {{ $options.i18n.popoverBody }}
+ <div class="gl-mt-3">
+ <gl-link
+ class="gl-font-sm"
+ :href="$options.shareProjectsWithGroupsHelpPagePath"
+ >{{ $options.i18n.learnMore }}</gl-link
+ >
+ </div>
+ </gl-popover>
+ </template>
+ <user-access-role-badge v-if="group.permission" class="gl-mr-3">
{{ group.permission }}
</user-access-role-badge>
- <compliance-framework-label
+ <gl-label
v-if="hasComplianceFramework"
- class="gl-mt-3"
- :name="complianceFramework.name"
- :color="complianceFramework.color"
+ :title="complianceFramework.name"
+ :background-color="complianceFramework.color"
:description="complianceFramework.description"
+ size="sm"
/>
</div>
<div v-if="group.description" class="description">
diff --git a/app/assets/javascripts/groups/components/group_name_and_path.vue b/app/assets/javascripts/groups/components/group_name_and_path.vue
index f9bd8701199..983535d3e9c 100644
--- a/app/assets/javascripts/groups/components/group_name_and_path.vue
+++ b/app/assets/javascripts/groups/components/group_name_and_path.vue
@@ -133,6 +133,8 @@ export default {
signal: this.activeApiRequestAbortController.signal,
});
+ this.apiLoading = false;
+
if (exists) {
if (suggests.length) {
return Promise.resolve({ exists, suggests });
@@ -148,14 +150,14 @@ export default {
return Promise.resolve({ exists, suggests });
} catch (error) {
if (!axios.isCancel(error)) {
+ this.apiLoading = false;
+
createAlert({
message: this.$options.i18n.apiErrorMessage,
});
}
return Promise.reject();
- } finally {
- this.apiLoading = false;
}
},
handlePathInput(value) {
diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js
index cacba2dfd23..29981d09155 100644
--- a/app/assets/javascripts/groups/constants.js
+++ b/app/assets/javascripts/groups/constants.js
@@ -28,28 +28,32 @@ export const ITEM_TYPE = {
GROUP: 'group',
};
+export const VISIBILITY_PUBLIC = 'public';
+export const VISIBILITY_INTERNAL = 'internal';
+export const VISIBILITY_PRIVATE = 'private';
+
export const GROUP_VISIBILITY_TYPE = {
- public: __(
+ [VISIBILITY_PUBLIC]: __(
'Public - The group and any public projects can be viewed without any authentication.',
),
- internal: __(
+ [VISIBILITY_INTERNAL]: __(
'Internal - The group and any internal projects can be viewed by any logged in user except external users.',
),
- private: __('Private - The group and its projects can only be viewed by members.'),
+ [VISIBILITY_PRIVATE]: __('Private - The group and its projects can only be viewed by members.'),
};
export const PROJECT_VISIBILITY_TYPE = {
- public: __('Public - The project can be accessed without any authentication.'),
- internal: __(
+ [VISIBILITY_PUBLIC]: __('Public - The project can be accessed without any authentication.'),
+ [VISIBILITY_INTERNAL]: __(
'Internal - The project can be accessed by any logged in user except external users.',
),
- private: __(
+ [VISIBILITY_PRIVATE]: __(
'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.',
),
};
export const VISIBILITY_TYPE_ICON = {
- public: 'earth',
- internal: 'shield',
- private: 'lock',
+ [VISIBILITY_PUBLIC]: 'earth',
+ [VISIBILITY_INTERNAL]: 'shield',
+ [VISIBILITY_PRIVATE]: 'lock',
};
diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js
index dfcee80aec7..a502fcd31ad 100644
--- a/app/assets/javascripts/groups/index.js
+++ b/app/assets/javascripts/groups/index.js
@@ -55,6 +55,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
renderEmptyState,
canCreateSubgroups,
canCreateProjects,
+ currentGroupVisibility,
},
} = this.$options.el;
@@ -67,6 +68,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
renderEmptyState: parseBoolean(renderEmptyState),
canCreateSubgroups: parseBoolean(canCreateSubgroups),
canCreateProjects: parseBoolean(canCreateProjects),
+ currentGroupVisibility,
};
},
data() {
diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js
index 360a8d3bf8d..9b6113c7444 100644
--- a/app/assets/javascripts/header.js
+++ b/app/assets/javascripts/header.js
@@ -1,4 +1,3 @@
-import $ from 'jquery';
import Vue from 'vue';
import { highCountTrim } from '~/lib/utils/text_utility';
import Tracking from '~/tracking';
@@ -12,12 +11,18 @@ import Translate from '~/vue_shared/translate';
* @param {String} count
*/
export default function initTodoToggle() {
- $(document).on('todo:toggle', (e, count) => {
- const updatedCount = count || e?.detail?.count || 0;
- const $todoPendingCount = $('.js-todos-count');
+ document.addEventListener('todo:toggle', (e) => {
+ const updatedCount = e.detail.count || 0;
+ const todoPendingCount = document.querySelector('.js-todos-count');
- $todoPendingCount.text(highCountTrim(updatedCount));
- $todoPendingCount.toggleClass('hidden', updatedCount === 0);
+ if (todoPendingCount) {
+ todoPendingCount.textContent = highCountTrim(updatedCount);
+ if (updatedCount === 0) {
+ todoPendingCount.classList.add('hidden');
+ } else {
+ todoPendingCount.classList.remove('hidden');
+ }
+ }
});
}
@@ -85,7 +90,7 @@ function initStatusTriggers() {
function trackShowUserDropdownLink(trackEvent, elToTrack, el) {
const { trackLabel, trackProperty } = elToTrack.dataset;
- $(el).on('shown.bs.dropdown', () => {
+ el.addEventListener('shown.bs.dropdown', () => {
Tracking.event(document.body.dataset.page, trackEvent, {
label: trackLabel,
property: trackProperty,
diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue
index adf304aebc7..0c4f9640972 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 {
+ GlSearchBoxByType,
+ GlOutsideDirective as Outside,
+ GlIcon,
+ GlToken,
+ GlSafeHtmlDirective as SafeHtml,
+ GlTooltipDirective,
+ GlResizeObserverDirective,
+} from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import { debounce } from 'lodash';
import { visitUrl } from '~/lib/utils/url_utility';
+import { truncate } from '~/lib/utils/text_utility';
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';
@@ -12,6 +21,8 @@ import {
SEARCH_INPUT_DESCRIPTION,
SEARCH_RESULTS_DESCRIPTION,
SEARCH_SHORTCUTS_MIN_CHARACTERS,
+ SCOPE_TOKEN_MAX_LENGTH,
+ INPUT_FIELD_PADDING,
} from '../constants';
import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue';
import HeaderSearchDefaultItems from './header_search_default_items.vue';
@@ -34,14 +45,22 @@ export default {
'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'),
+ searchResultsScope: s__('GlobalSearch|in %{scope}'),
+ kbdHelp: sprintf(
+ s__('GlobalSearch|Use the shortcut key %{kbdOpen}/%{kbdClose} to start a search'),
+ { kbdOpen: '<kbd>', kbdClose: '</kbd>' },
+ false,
+ ),
},
- directives: { Outside },
+ directives: { SafeHtml, Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective },
components: {
GlSearchBoxByType,
HeaderSearchDefaultItems,
HeaderSearchScopedItems,
HeaderSearchAutocompleteItems,
DropdownKeyboardNavigation,
+ GlIcon,
+ GlToken,
},
data() {
return {
@@ -50,8 +69,8 @@ export default {
};
},
computed: {
- ...mapState(['search', 'loading']),
- ...mapGetters(['searchQuery', 'searchOptions', 'autocompleteGroupedSearchOptions']),
+ ...mapState(['search', 'loading', 'searchContext']),
+ ...mapGetters(['searchQuery', 'searchOptions']),
searchText: {
get() {
return this.search;
@@ -70,16 +89,17 @@ export default {
return Boolean(gon?.current_username);
},
showSearchDropdown() {
- const hasResultsUnderMinCharacters =
- this.searchText?.length === 1 ? this?.autocompleteGroupedSearchOptions?.length > 0 : true;
+ if (!this.showDropdown || !this.isLoggedIn) {
+ return false;
+ }
- return this.showDropdown && this.isLoggedIn && hasResultsUnderMinCharacters;
+ return this.searchOptions?.length > 0;
},
showDefaultItems() {
return !this.searchText;
},
- showShortcuts() {
- return this.searchText && this.searchText?.length >= SEARCH_SHORTCUTS_MIN_CHARACTERS;
+ showScopes() {
+ return this.searchText?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS;
},
defaultIndex() {
if (this.showDefaultItems) {
@@ -88,11 +108,11 @@ export default {
return FIRST_DROPDOWN_INDEX;
},
+
searchInputDescribeBy() {
if (this.isLoggedIn) {
return this.$options.i18n.searchInputDescribeByWithDropdown;
}
-
return this.$options.i18n.searchInputDescribeByNoDropdown;
},
dropdownResultsDescription() {
@@ -112,8 +132,26 @@ export default {
count: this.searchOptions.length,
});
},
- headerSearchActivityDescriptor() {
- return this.showDropdown ? 'is-active' : 'is-not-active';
+ searchBarStateIndicator() {
+ const hasIcon =
+ this.searchContext?.project || this.searchContext?.group ? 'has-icon' : 'has-no-icon';
+ const isSearching = this.showScopes ? 'is-searching' : 'is-not-searching';
+ const isActive = this.showSearchDropdown ? 'is-active' : 'is-not-active';
+ return `${isActive} ${isSearching} ${hasIcon}`;
+ },
+ searchBarItem() {
+ return this.searchOptions?.[0];
+ },
+ infieldHelpContent() {
+ return this.searchBarItem?.scope || this.searchBarItem?.description;
+ },
+ infieldHelpIcon() {
+ return this.searchBarItem?.icon;
+ },
+ scopeTokenTitle() {
+ return sprintf(this.$options.i18n.searchResultsScope, {
+ scope: this.infieldHelpContent,
+ });
},
},
methods: {
@@ -127,6 +165,9 @@ export default {
this.$emit('toggleDropdown', this.showDropdown);
},
submitSearch() {
+ if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS && this.currentFocusIndex < 0) {
+ return null;
+ }
return visitUrl(this.currentFocusedOption?.url || this.searchQuery);
},
getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) {
@@ -136,8 +177,19 @@ export default {
this.fetchAutocompleteOptions();
}
}, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
+ getTruncatedScope(scope) {
+ return truncate(scope, SCOPE_TOKEN_MAX_LENGTH);
+ },
+ observeTokenWidth({ contentRect: { width } }) {
+ const inputField = this.$refs?.searchInputBox?.$el?.querySelector('input');
+ if (!inputField) {
+ return;
+ }
+ inputField.style.paddingRight = `${width + INPUT_FIELD_PADDING}px`;
+ },
},
SEARCH_BOX_INDEX,
+ FIRST_DROPDOWN_INDEX,
SEARCH_INPUT_DESCRIPTION,
SEARCH_RESULTS_DESCRIPTION,
};
@@ -149,10 +201,12 @@ export default {
role="search"
:aria-label="$options.i18n.searchGitlab"
class="header-search gl-relative gl-rounded-base gl-w-full"
- :class="headerSearchActivityDescriptor"
+ :class="searchBarStateIndicator"
+ data-testid="header-search-form"
>
<gl-search-box-by-type
id="search"
+ ref="searchInputBox"
v-model="searchText"
role="searchbox"
class="gl-z-index-1"
@@ -165,7 +219,34 @@ export default {
@click="openDropdown"
@input="getAutocompleteOptions"
@keydown.enter.stop.prevent="submitSearch"
+ @keydown.esc.stop.prevent="closeDropdown"
/>
+ <gl-token
+ v-if="showScopes"
+ v-gl-resize-observer-directive="observeTokenWidth"
+ class="in-search-scope-help"
+ :view-only="true"
+ :title="scopeTokenTitle"
+ ><gl-icon
+ v-if="infieldHelpIcon"
+ class="gl-mr-2"
+ :aria-label="infieldHelpContent"
+ :name="infieldHelpIcon"
+ :size="16"
+ />{{
+ getTruncatedScope(
+ sprintf($options.i18n.searchResultsScope, {
+ scope: infieldHelpContent,
+ }),
+ )
+ }}
+ </gl-token>
+ <kbd
+ v-gl-tooltip.bottom.hover.html
+ class="gl-absolute gl-right-3 gl-top-0 gl-z-index-1 keyboard-shortcut-helper"
+ :title="$options.i18n.kbdHelp"
+ >/</kbd
+ >
<span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">{{
searchInputDescribeBy
}}</span>
@@ -187,7 +268,7 @@ export default {
<dropdown-keyboard-navigation
v-model="currentFocusIndex"
:max="searchOptions.length - 1"
- :min="$options.SEARCH_BOX_INDEX"
+ :min="$options.FIRST_DROPDOWN_INDEX"
:default-index="defaultIndex"
@tab="closeDropdown"
/>
@@ -197,7 +278,7 @@ export default {
/>
<template v-else>
<header-search-scoped-items
- v-if="showShortcuts"
+ v-if="showScopes"
:current-focused-option="currentFocusedOption"
/>
<header-search-autocomplete-items :current-focused-option="currentFocusedOption" />
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 34d1bd71399..f5be1bcb786 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,13 +1,16 @@
<script>
-import { GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
+import { GlDropdownItem, GlIcon, GlToken } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
-import { __, sprintf } from '~/locale';
+import { s__, sprintf } from '~/locale';
+import { truncate } from '~/lib/utils/text_utility';
+import { SCOPE_TOKEN_MAX_LENGTH } from '../constants';
export default {
name: 'HeaderSearchScopedItems',
components: {
GlDropdownItem,
- GlDropdownDivider,
+ GlIcon,
+ GlToken,
},
props: {
currentFocusedOption: {
@@ -25,12 +28,21 @@ export default {
return this.currentFocusedOption?.html_id === option.html_id;
},
ariaLabel(option) {
- return sprintf(__('%{search} %{description} %{scope}'), {
+ return sprintf(s__('GlobalSearch| %{search} %{description} %{scope}'), {
search: this.search,
- description: option.description,
+ description: option.description || option.icon,
scope: option.scope || '',
});
},
+ titleLabel(option) {
+ return sprintf(s__('GlobalSearch|in %{scope}'), {
+ search: this.search,
+ scope: option.scope || option.description,
+ });
+ },
+ getTruncatedScope(scope) {
+ return truncate(scope, SCOPE_TOKEN_MAX_LENGTH);
+ },
},
};
</script>
@@ -42,18 +54,30 @@ export default {
:id="option.html_id"
:ref="option.html_id"
:key="option.html_id"
+ class="gl-max-w-full"
:class="{ 'gl-bg-gray-50': isOptionFocused(option) }"
:aria-selected="isOptionFocused(option)"
:aria-label="ariaLabel(option)"
tabindex="-1"
:href="option.url"
+ :title="titleLabel(option)"
>
- <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
+ ref="token-text-content"
+ class="gl-display-flex gl-justify-content-start search-text-content gl-line-height-24 gl-align-items-start gl-flex-direction-row gl-w-full"
+ >
+ <gl-icon name="search" class="gl-flex-shrink-0 gl-mr-2 gl-relative gl-pt-2" />
+ <span class="gl-flex-grow-1 gl-relative">
+ <gl-token
+ class="in-dropdown-scope-help has-icon gl-flex-shrink-0 gl-relative gl-white-space-nowrap gl-float-right gl-mr-n3!"
+ :view-only="true"
+ >
+ <gl-icon v-if="option.icon" :name="option.icon" class="gl-mr-2" />
+ <span>{{ getTruncatedScope(titleLabel(option)) }}</span>
+ </gl-token>
+ {{ search }}
+ </span>
</span>
</gl-dropdown-item>
- <gl-dropdown-divider v-if="autocompleteGroupedSearchOptions.length > 0" />
</div>
</template>
diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js
index 045a552efb0..a026386b2bd 100644
--- a/app/assets/javascripts/header_search/constants.js
+++ b/app/assets/javascripts/header_search/constants.js
@@ -10,15 +10,21 @@ export const MSG_MR_IM_REVIEWER = s__("GlobalSearch|Merge requests that I'm a re
export const MSG_MR_IVE_CREATED = s__("GlobalSearch|Merge requests I've created");
-export const MSG_IN_ALL_GITLAB = s__('GlobalSearch|in all GitLab');
+export const MSG_IN_ALL_GITLAB = s__('GlobalSearch|all GitLab');
-export const MSG_IN_GROUP = s__('GlobalSearch|in group');
+export const MSG_IN_GROUP = s__('GlobalSearch|group');
-export const MSG_IN_PROJECT = s__('GlobalSearch|in project');
+export const MSG_IN_PROJECT = s__('GlobalSearch|project');
-export const GROUPS_CATEGORY = 'Groups';
+export const ICON_PROJECT = 'project';
-export const PROJECTS_CATEGORY = 'Projects';
+export const ICON_GROUP = 'group';
+
+export const ICON_SUBGROUP = 'subgroup';
+
+export const GROUPS_CATEGORY = s__('GlobalSearch|Groups');
+
+export const PROJECTS_CATEGORY = s__('GlobalSearch|Projects');
export const ISSUES_CATEGORY = 'Recent issues';
@@ -39,3 +45,9 @@ export const SEARCH_SHORTCUTS_MIN_CHARACTERS = 2;
export const SEARCH_INPUT_DESCRIPTION = 'search-input-description';
export const SEARCH_RESULTS_DESCRIPTION = 'search-results-description';
+
+export const SCOPE_TOKEN_MAX_LENGTH = 36;
+
+export const INPUT_FIELD_PADDING = 52;
+
+export const HEADER_INIT_EVENTS = ['input', 'focus'];
diff --git a/app/assets/javascripts/header_search/init.js b/app/assets/javascripts/header_search/init.js
new file mode 100644
index 00000000000..4e9404007ec
--- /dev/null
+++ b/app/assets/javascripts/header_search/init.js
@@ -0,0 +1,53 @@
+import * as Sentry from '@sentry/browser';
+import { HEADER_INIT_EVENTS } from './constants';
+
+async function eventHandler(callback = () => {}) {
+ if (this.newHeaderSearchFeatureFlag) {
+ const { initHeaderSearchApp } = await import(
+ /* webpackChunkName: 'globalSearch' */ '~/header_search'
+ ).catch((error) => Sentry.captureException(error));
+
+ // In case the user started searching before we bootstrapped,
+ // let's pass the search along.
+ const initialSearchValue = this.searchInputBox.value;
+ initHeaderSearchApp(initialSearchValue);
+
+ // this is new #search input element. We need to re-find it.
+ // And re-focus in it.
+ document.querySelector('#search').focus();
+ callback();
+ return;
+ }
+
+ const { default: initSearchAutocomplete } = await import(
+ /* webpackChunkName: 'globalSearch' */ '../search_autocomplete'
+ ).catch((error) => Sentry.captureException(error));
+
+ const searchDropdown = initSearchAutocomplete();
+ searchDropdown.onSearchInputFocus();
+ callback();
+}
+
+function cleanEventListeners() {
+ HEADER_INIT_EVENTS.forEach((eventType) => {
+ document.querySelector('#search').removeEventListener(eventType, eventHandler);
+ });
+}
+
+function initHeaderSearch() {
+ const searchInputBox = document.querySelector('#search');
+
+ HEADER_INIT_EVENTS.forEach((eventType) => {
+ searchInputBox?.addEventListener(
+ eventType,
+ eventHandler.bind(
+ { searchInputBox, newHeaderSearchFeatureFlag: gon?.features?.newHeaderSearch },
+ cleanEventListeners,
+ ),
+ { once: true },
+ );
+ });
+}
+
+export default initHeaderSearch;
+export { eventHandler, cleanEventListeners };
diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js
index 7d08aa859fb..da7bccd35c0 100644
--- a/app/assets/javascripts/header_search/store/getters.js
+++ b/app/assets/javascripts/header_search/store/getters.js
@@ -7,9 +7,13 @@ import {
MSG_MR_ASSIGNED_TO_ME,
MSG_MR_IM_REVIEWER,
MSG_MR_IVE_CREATED,
- MSG_IN_PROJECT,
- MSG_IN_GROUP,
+ ICON_GROUP,
+ ICON_SUBGROUP,
+ ICON_PROJECT,
MSG_IN_ALL_GITLAB,
+ PROJECTS_CATEGORY,
+ GROUPS_CATEGORY,
+ SEARCH_SHORTCUTS_MIN_CHARACTERS,
} from '../constants';
export const searchQuery = (state) => {
@@ -149,7 +153,8 @@ export const scopedSearchOptions = (state, getters) => {
options.push({
html_id: 'scoped-in-project',
scope: state.searchContext.project?.name || '',
- description: MSG_IN_PROJECT,
+ scopeCategory: PROJECTS_CATEGORY,
+ icon: ICON_PROJECT,
url: getters.projectUrl,
});
}
@@ -158,7 +163,8 @@ export const scopedSearchOptions = (state, getters) => {
options.push({
html_id: 'scoped-in-group',
scope: state.searchContext.group?.name || '',
- description: MSG_IN_GROUP,
+ scopeCategory: GROUPS_CATEGORY,
+ icon: state.searchContext.group?.full_name?.includes('/') ? ICON_SUBGROUP : ICON_GROUP,
url: getters.groupUrl,
});
}
@@ -190,6 +196,7 @@ export const autocompleteGroupedSearchOptions = (state) => {
results.push(groupedOptions[option.category]);
}
});
+
return results;
};
@@ -205,5 +212,9 @@ export const searchOptions = (state, getters) => {
[],
);
+ if (state.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS) {
+ return sortedAutocompleteOptions;
+ }
+
return getters.scopedSearchOptions.concat(sortedAutocompleteOptions);
};
diff --git a/app/assets/javascripts/helpers/help_page_helper.js b/app/assets/javascripts/helpers/help_page_helper.js
index 0e824548646..21d27b5fea9 100644
--- a/app/assets/javascripts/helpers/help_page_helper.js
+++ b/app/assets/javascripts/helpers/help_page_helper.js
@@ -7,9 +7,10 @@ const HELP_PAGE_URL_ROOT = '/help/';
*
* This is designed to mirror the Ruby `help_page_path` helper function, so that
* the two can be used interchangeably.
- * @param {String} path - Path to doc file relative to the doc/ directory in the GitLab repository.
- * Optionally, including `.md` or `.html` prefix
- * @param {String} options.anchor - Name of the anchor to scroll to on the documentation page.
+ * @param {string} path - Path to doc file relative to the doc/ directory in the GitLab repository.
+ * Optionally, including `.md` or `.html` prefix
+ * @param {object} [options]
+ * @param {string} [options.anchor] - Name of the anchor to scroll to on the documentation page.
*/
export const helpPagePath = (path, { anchor = '' } = {}) => {
let helpPath = joinPaths(gon.relative_url_root || '/', HELP_PAGE_URL_ROOT, path);
diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue
index 2df998d7518..6998f8ef0c4 100644
--- a/app/assets/javascripts/ide/components/ide_tree.vue
+++ b/app/assets/javascripts/ide/components/ide_tree.vue
@@ -54,25 +54,25 @@ export default {
<ide-tree-list @tree-ready="$emit('tree-ready')">
<template #header>
{{ __('Edit') }}
- <div class="ide-tree-actions ml-auto d-flex" data-testid="ide-root-actions">
+ <div class="ide-tree-actions gl-ml-auto gl-display-flex" data-testid="ide-root-actions">
<new-entry-button
:label="__('New file')"
:show-label="false"
- class="d-flex border-0 p-0 mr-3"
+ class="gl-display-flex gl-border-0 gl-p-0 gl-mr-5"
icon="doc-new"
data-qa-selector="new_file_button"
@click="createNewFile()"
/>
<upload
:show-label="false"
- class="d-flex mr-3"
- button-css-classes="border-0 p-0"
+ class="gl-display-flex gl-mr-5"
+ button-css-classes="gl-border-0 gl-p-0"
@create="createTempEntry"
/>
<new-entry-button
:label="__('New directory')"
:show-label="false"
- class="d-flex border-0 p-0"
+ class="gl-display-flex gl-border-0 gl-p-0"
icon="folder-new"
data-qa-selector="new_directory_button"
@click="createNewFolder()"
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index e3c230f7660..d6207d4a557 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -68,6 +68,10 @@ export default {
},
methods: {
...mapActions(['createTempEntry', 'renameEntry']),
+ submitAndClose() {
+ this.submitForm();
+ this.close();
+ },
submitForm() {
this.entryName = trimPathComponents(this.entryName);
@@ -161,15 +165,17 @@ export default {
<div class="form-group row">
<label class="label-bold col-form-label col-sm-2"> {{ __('Name') }} </label>
<div class="col-sm-10">
- <input
- ref="fieldName"
- v-model.trim="entryName"
- type="text"
- class="form-control"
- data-testid="file-name-field"
- data-qa-selector="file_name_field"
- :placeholder="placeholder"
- />
+ <form data-testid="file-name-form" @submit.prevent="submitAndClose">
+ <input
+ ref="fieldName"
+ v-model.trim="entryName"
+ type="text"
+ class="form-control"
+ data-testid="file-name-field"
+ data-qa-selector="file_name_field"
+ :placeholder="placeholder"
+ />
+ </form>
<ul v-if="isCreatingNewFile" class="file-templates gl-mt-3 list-inline qa-template-list">
<li v-for="(template, index) in templateTypes" :key="index" class="list-inline-item">
<gl-button
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index d71ac766933..a1396995a3b 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -1,4 +1,5 @@
<script>
+import { GlTabs, GlTab } from '@gitlab/ui';
import { debounce } from 'lodash';
import { mapState, mapGetters, mapActions } from 'vuex';
import {
@@ -45,6 +46,8 @@ const MARKDOWN_FILE_TYPE = 'markdown';
export default {
name: 'RepoEditor',
components: {
+ GlTabs,
+ GlTab,
FileAlert,
ContentViewer,
DiffViewer,
@@ -121,16 +124,6 @@ export default {
isPreviewViewMode() {
return this.fileEditor.viewMode === FILE_VIEW_MODE_PREVIEW;
},
- editTabCSS() {
- return {
- active: this.isEditorViewMode,
- };
- },
- previewTabCSS() {
- return {
- active: this.isPreviewViewMode,
- };
- },
showEditor() {
return !this.shouldHideEditor && this.isEditorViewMode;
},
@@ -487,28 +480,18 @@ export default {
<template>
<div id="ide" class="blob-viewer-container blob-editor-container">
- <div v-if="showTabs" class="ide-mode-tabs clearfix">
- <ul class="nav-links float-left border-bottom-0">
- <li :class="editTabCSS">
- <a
- href="javascript:void(0);"
- role="button"
- data-testid="edit-tab"
- @click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })"
- >{{ __('Edit') }}</a
- >
- </li>
- <li :class="previewTabCSS">
- <a
- href="javascript:void(0);"
- role="button"
- data-testid="preview-tab"
- @click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })"
- >{{ previewMode.previewTitle }}</a
- >
- </li>
- </ul>
- </div>
+ <gl-tabs v-if="showTabs" content-class="gl-display-none">
+ <gl-tab
+ :title="__('Edit')"
+ data-testid="edit-tab"
+ @click="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })"
+ />
+ <gl-tab
+ :title="previewMode.previewTitle"
+ data-testid="preview-tab"
+ @click="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })"
+ />
+ </gl-tabs>
<file-alert v-if="alertKey" :alert-key="alertKey" />
<file-templates-bar v-else-if="showFileTemplatesBar(file.name)" />
<div
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 6b96fa7c45c..98ee858ca91 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
@@ -78,6 +78,11 @@ export default {
type: String,
required: true,
},
+ defaultTargetNamespace: {
+ type: Number,
+ required: false,
+ default: null,
+ },
},
data() {
@@ -433,7 +438,15 @@ export default {
return this.importTargets[group.id];
}
- const defaultTargetNamespace = this.availableNamespaces[0] ?? ROOT_NAMESPACE;
+ // If we've reached this Vue application we have at least one potential import destination
+ const defaultTargetNamespace =
+ // first option: namespace id was explicitly provided
+ this.availableNamespaces.find((ns) => ns.id === this.defaultTargetNamespace) ??
+ // second option: first available namespace
+ this.availableNamespaces[0] ??
+ // last resort: if no namespaces are available - suggest creating new namespace at root
+ ROOT_NAMESPACE;
+
let importTarget;
if (group.lastImportTarget) {
const targetNamespace = [ROOT_NAMESPACE, ...this.availableNamespaces].find(
diff --git a/app/assets/javascripts/import_entities/import_groups/index.js b/app/assets/javascripts/import_entities/import_groups/index.js
index 02af0db7f9a..5d7e7911f5a 100644
--- a/app/assets/javascripts/import_entities/import_groups/index.js
+++ b/app/assets/javascripts/import_entities/import_groups/index.js
@@ -15,9 +15,10 @@ export function mountImportGroupsApp(mountElement) {
availableNamespacesPath,
createBulkImportPath,
jobsPath,
+ historyPath,
+ defaultTargetNamespace,
sourceUrl,
groupPathRegex,
- historyPath,
} = mountElement.dataset;
const apolloProvider = new VueApollo({
defaultClient: createApolloClient({
@@ -40,6 +41,7 @@ export function mountImportGroupsApp(mountElement) {
jobsPath,
groupPathRegex: new RegExp(`^(${groupPathRegex})$`),
historyPath,
+ defaultTargetNamespace: parseInt(defaultTargetNamespace, 10) || null,
},
});
},
diff --git a/app/assets/javascripts/init_confirm_danger.js b/app/assets/javascripts/init_confirm_danger.js
index 98bfa48740c..6c6cadedf00 100644
--- a/app/assets/javascripts/init_confirm_danger.js
+++ b/app/assets/javascripts/init_confirm_danger.js
@@ -12,11 +12,11 @@ export default () => {
phrase,
buttonText,
buttonClass = '',
- buttonTestid = null,
- buttonVariant = null,
+ buttonTestid,
+ buttonVariant,
confirmDangerMessage,
confirmButtonText = null,
- disabled = false,
+ disabled,
additionalInformation,
htmlConfirmationMessage,
} = el.dataset;
diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js
index e4f6e931ec0..437bcc39886 100644
--- a/app/assets/javascripts/integrations/constants.js
+++ b/app/assets/javascripts/integrations/constants.js
@@ -18,7 +18,7 @@ export const overrideDropdownDescriptions = {
};
export const I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE = s__(
- 'Integrations|Connection failed. Please check your settings.',
+ 'Integrations|Connection failed. Check your integration settings.',
);
export const I18N_DEFAULT_ERROR_MESSAGE = __('Something went wrong on our end.');
export const I18N_SUCCESSFUL_CONNECTION_MESSAGE = s__('Integrations|Connection successful.');
@@ -83,3 +83,11 @@ export const billingPlanNames = {
[billingPlans.PREMIUM]: s__('BillingPlans|Premium'),
[billingPlans.ULTIMATE]: s__('BillingPlans|Ultimate'),
};
+
+const INTEGRATION_TYPE_SLACK = 'slack';
+const INTEGRATION_TYPE_MATTERMOST = 'mattermost';
+
+export const placeholderForType = {
+ [INTEGRATION_TYPE_SLACK]: __('#general, #development'),
+ [INTEGRATION_TYPE_MATTERMOST]: __('my-channel'),
+};
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index 9307d7c2d3d..f1f574c6424 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -140,15 +140,24 @@ export default {
this.isTesting = true;
testIntegrationSettings(this.propsSource.testPath, this.getFormData())
- .then(({ data: { error, message = I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE } }) => {
- if (error) {
- this.setIsValidated();
- this.$toast.show(message);
- return;
- }
+ .then(
+ ({
+ data: {
+ error,
+ message = I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE,
+ service_response: serviceResponse,
+ },
+ }) => {
+ if (error) {
+ const errorMessage = serviceResponse ? [message, serviceResponse].join(' ') : message;
+ this.setIsValidated();
+ this.$toast.show(errorMessage);
+ return;
+ }
- this.$toast.show(I18N_SUCCESSFUL_CONNECTION_MESSAGE);
- })
+ this.$toast.show(I18N_SUCCESSFUL_CONNECTION_MESSAGE);
+ },
+ )
.catch((error) => {
this.$toast.show(I18N_DEFAULT_ERROR_MESSAGE);
Sentry.captureException(error);
@@ -284,6 +293,7 @@ export default {
:key="`${currentKey}-${field.name}`"
v-bind="field"
:is-validated="isValidated"
+ :data-qa-selector="`${field.name}_div`"
/>
</div>
</div>
diff --git a/app/assets/javascripts/integrations/edit/components/sections/configuration.vue b/app/assets/javascripts/integrations/edit/components/sections/configuration.vue
index 9e1ad24ae9f..b8fd8995744 100644
--- a/app/assets/javascripts/integrations/edit/components/sections/configuration.vue
+++ b/app/assets/javascripts/integrations/edit/components/sections/configuration.vue
@@ -33,6 +33,7 @@ export default {
:key="`${currentKey}-${field.name}`"
v-bind="field"
:is-validated="isValidated"
+ :data-qa-selector="`${field.name}_div`"
/>
</div>
</template>
diff --git a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
index 92042a5c981..67647cadf19 100644
--- a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
@@ -1,17 +1,7 @@
<script>
import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui';
import { mapGetters } from 'vuex';
-import { __ } from '~/locale';
-
-const typeWithPlaceholder = {
- SLACK: 'slack',
- MATTERMOST: 'mattermost',
-};
-
-const placeholderForType = {
- [typeWithPlaceholder.SLACK]: __('#general, #development'),
- [typeWithPlaceholder.MATTERMOST]: __('my-channel'),
-};
+import { placeholderForType } from 'jh_else_ce/integrations/constants';
export default {
name: 'TriggerFields',
diff --git a/app/assets/javascripts/invite_members/components/group_select.vue b/app/assets/javascripts/invite_members/components/group_select.vue
index fc14b2eba6a..e7f5211dc25 100644
--- a/app/assets/javascripts/invite_members/components/group_select.vue
+++ b/app/assets/javascripts/invite_members/components/group_select.vue
@@ -136,6 +136,7 @@ export default {
v-for="group in groups"
:key="group.id"
:name="group.name"
+ data-qa-selector="group_select_dropdown_item"
@click="selectGroup(group)"
>
<gl-avatar-labeled
diff --git a/app/assets/javascripts/invite_members/components/import_a_project_modal.vue b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue
index fb6c376cfe6..31b7fd4cc42 100644
--- a/app/assets/javascripts/invite_members/components/import_a_project_modal.vue
+++ b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue
@@ -1,21 +1,20 @@
<script>
-import { GlButton, GlFormGroup, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
+import { GlFormGroup, GlModal, GlSprintf } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { importProjectMembers } from '~/api/projects_api';
+import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import { s__, __, sprintf } from '~/locale';
+import eventHub from '../event_hub';
import ProjectSelect from './project_select.vue';
export default {
+ name: 'ImportProjectMembersModal',
components: {
- GlButton,
GlFormGroup,
GlModal,
GlSprintf,
ProjectSelect,
},
- directives: {
- GlModal: GlModalDirective,
- },
props: {
projectId: {
type: String,
@@ -45,8 +44,33 @@ export default {
validationState() {
return this.invalidFeedbackMessage === '' ? null : false;
},
+ actionPrimary() {
+ return {
+ text: this.$options.i18n.modalPrimaryButton,
+ attributes: {
+ variant: 'confirm',
+ disabled: this.importDisabled,
+ loading: this.isLoading,
+ },
+ };
+ },
+ actionCancel() {
+ return { text: this.$options.i18n.modalCancelButton };
+ },
+ },
+ mounted() {
+ eventHub.$on('openProjectMembersModal', () => {
+ this.openModal();
+ });
},
methods: {
+ openModal() {
+ this.$root.$emit(BV_SHOW_MODAL, this.$options.modalId);
+ },
+ resetFields() {
+ this.invalidFeedbackMessage = '';
+ this.projectToBeImported = {};
+ },
submitImport() {
this.isLoading = true;
return importProjectMembers(this.projectId, this.projectToBeImported.id)
@@ -57,11 +81,6 @@ export default {
this.projectToBeImported = {};
});
},
- closeModal() {
- this.invalidFeedbackMessage = '';
-
- this.$refs.modal.hide();
- },
showToastMessage() {
this.$toast.show(this.$options.i18n.successMessage, this.$options.toastOptions);
@@ -79,7 +98,6 @@ export default {
};
},
i18n: {
- buttonText: s__('ImportAProjectModal|Import from a project'),
projectLabel: __('Project'),
modalTitle: s__('ImportAProjectModal|Import members from another project'),
modalIntro: s__(
@@ -95,63 +113,37 @@ export default {
},
projectSelectLabelId: 'project-select',
modalId: uniqueId('import-a-project-modal-'),
- formClasses: 'gl-mt-3 gl-sm-w-auto gl-w-full',
- buttonClasses: 'gl-w-full',
};
</script>
<template>
- <form :class="$options.formClasses">
- <gl-button v-gl-modal="$options.modalId" :class="$options.buttonClasses" variant="default">{{
- $options.i18n.buttonText
- }}</gl-button>
-
- <gl-modal
- ref="modal"
- :modal-id="$options.modalId"
- size="sm"
- :title="$options.i18n.modalTitle"
- ok-variant="danger"
- footer-class="gl-bg-gray-10 gl-p-5"
+ <gl-modal
+ ref="modal"
+ :modal-id="$options.modalId"
+ size="sm"
+ :title="$options.i18n.modalTitle"
+ :action-primary="actionPrimary"
+ :action-cancel="actionCancel"
+ @primary="submitImport"
+ @hidden="resetFields"
+ >
+ <p ref="modalIntro">
+ <gl-sprintf :message="modalIntro">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ <gl-form-group
+ :invalid-feedback="invalidFeedbackMessage"
+ :state="validationState"
+ data-testid="form-group"
>
- <div>
- <p ref="modalIntro">
- <gl-sprintf :message="modalIntro">
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- </p>
- <gl-form-group
- :invalid-feedback="invalidFeedbackMessage"
- :state="validationState"
- data-testid="form-group"
- >
- <label :id="$options.projectSelectLabelId" class="col-form-label">{{
- $options.i18n.projectLabel
- }}</label>
- <project-select v-model="projectToBeImported" />
- </gl-form-group>
- <p>{{ $options.i18n.modalHelpText }}</p>
- </div>
- <template #modal-footer>
- <div
- class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0"
- >
- <gl-button data-testid="cancel-button" @click="closeModal">
- {{ $options.i18n.modalCancelButton }}
- </gl-button>
- <div class="gl-mr-3"></div>
- <gl-button
- :disabled="importDisabled"
- :loading="isLoading"
- variant="confirm"
- data-testid="import-button"
- @click="submitImport"
- >{{ $options.i18n.modalPrimaryButton }}</gl-button
- >
- </div>
- </template>
- </gl-modal>
- </form>
+ <label :id="$options.projectSelectLabelId" class="col-form-label">{{
+ $options.i18n.projectLabel
+ }}</label>
+ <project-select v-model="projectToBeImported" />
+ </gl-form-group>
+ <p>{{ $options.i18n.modalHelpText }}</p>
+ </gl-modal>
</template>
diff --git a/app/assets/javascripts/invite_members/components/import_project_members_trigger.vue b/app/assets/javascripts/invite_members/components/import_project_members_trigger.vue
new file mode 100644
index 00000000000..5781abb41b7
--- /dev/null
+++ b/app/assets/javascripts/invite_members/components/import_project_members_trigger.vue
@@ -0,0 +1,34 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import eventHub from '../event_hub';
+
+export default {
+ components: {
+ GlButton,
+ },
+ props: {
+ displayText: {
+ type: String,
+ required: false,
+ default: s__('ImportAProjectModal|Import from a project'),
+ },
+ classes: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ methods: {
+ openModal() {
+ eventHub.$emit('openProjectMembersModal');
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button :class="classes" @click="openModal">
+ {{ displayText }}
+ </gl-button>
+</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 d597c7e53bb..b71cfbb6112 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -7,12 +7,13 @@ import {
GlSprintf,
GlFormCheckboxGroup,
} from '@gitlab/ui';
-import { partition, isString, uniqueId } from 'lodash';
+import { partition, isString, uniqueId, isEmpty } from 'lodash';
import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue';
import Api from '~/api';
import ExperimentTracking from '~/experimentation/experiment_tracking';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { getParameterValues } from '~/lib/utils/url_utility';
+import { n__ } from '~/locale';
import {
CLOSE_TO_LIMIT_COUNT,
USERS_FILTER_ALL,
@@ -21,7 +22,8 @@ import {
LEARN_GITLAB,
} from '../constants';
import eventHub from '../event_hub';
-import { responseMessageFromSuccess } from '../utils/response_message_parser';
+import { responseFromSuccess } from '../utils/response_message_parser';
+import { memberName } from '../utils/member_utils';
import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message';
import ModalConfetti from './confetti.vue';
import MembersTokenSelect from './members_token_select.vue';
@@ -101,6 +103,7 @@ export default {
isLoading: false,
modalId: uniqueId('invite-members-modal-'),
newUsersToInvite: [],
+ invalidMembers: {},
selectedTasksToBeDone: [],
selectedTaskProject: this.projects[0],
source: 'unknown',
@@ -125,6 +128,16 @@ export default {
inviteDisabled() {
return this.newUsersToInvite.length === 0;
},
+ hasInvalidMembers() {
+ return !isEmpty(this.invalidMembers);
+ },
+ memberErrorTitle() {
+ return n__(
+ "InviteMembersModal|The following member couldn't be invited",
+ "InviteMembersModal|The following %d members couldn't be invited",
+ Object.keys(this.invalidMembers).length,
+ );
+ },
tasksToBeDoneEnabled() {
return (
(getParameterValues('open_modal')[0] === 'invite_members_for_task' ||
@@ -218,7 +231,7 @@ export default {
},
sendInvite({ accessLevel, expiresAt }) {
this.isLoading = true;
- this.invalidFeedbackMessage = '';
+ this.clearValidation();
const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
@@ -242,12 +255,10 @@ export default {
...userId,
})
.then((response) => {
- const message = responseMessageFromSuccess(response);
+ const { error, message } = responseFromSuccess(response);
- if (message) {
- this.showInvalidFeedbackMessage({
- response: { data: { message } },
- });
+ if (error) {
+ this.showMemberErrors(message);
} else {
this.showSuccessMessage();
}
@@ -257,6 +268,13 @@ export default {
this.isLoading = false;
});
},
+ showMemberErrors(message) {
+ this.invalidMembers = message;
+ },
+ tokenName(username) {
+ // initial token creation hits this and nothing is found... so safe navigation
+ return this.newUsersToInvite.find((member) => memberName(member) === username)?.name;
+ },
trackinviteMembersForTask() {
const label = 'selected_tasks_to_be_done';
const property = this.selectedTasksToBeDone.join(',');
@@ -264,8 +282,8 @@ export default {
tracking.event(INVITE_MEMBERS_FOR_TASK.submit);
},
resetFields() {
+ this.clearValidation();
this.isLoading = false;
- this.invalidFeedbackMessage = '';
this.newUsersToInvite = [];
this.selectedTasksToBeDone = [];
[this.selectedTaskProject] = this.projects;
@@ -287,6 +305,11 @@ export default {
},
clearValidation() {
this.invalidFeedbackMessage = '';
+ this.invalidMembers = {};
+ },
+ removeToken(token) {
+ delete this.invalidMembers[memberName(token)];
+ this.invalidMembers = { ...this.invalidMembers };
},
},
labels: MEMBER_MODAL_LABELS,
@@ -324,23 +347,40 @@ export default {
<modal-confetti v-if="isCelebration" />
</template>
- <template #user-limit-notification>
+ <template #alert>
+ <gl-alert
+ v-if="hasInvalidMembers"
+ variant="danger"
+ :dismissible="false"
+ :title="memberErrorTitle"
+ data-testid="alert-member-error"
+ >
+ {{ $options.labels.memberErrorListText }}
+ <ul class="gl-pl-5">
+ <li v-for="(error, member) in invalidMembers" :key="member">
+ <strong>{{ tokenName(member) }}:</strong> {{ error }}
+ </li>
+ </ul>
+ </gl-alert>
<user-limit-notification
+ v-else
:close-to-limit="closeToLimit"
:reached-limit="reachedLimit"
:users-limit-dataset="usersLimitDataset"
/>
</template>
- <template #select="{ validationState, labelId }">
+ <template #select="{ exceptionState, labelId }">
<members-token-select
v-model="newUsersToInvite"
class="gl-mb-2"
- :validation-state="validationState"
+ :exception-state="exceptionState"
:aria-labelledby="labelId"
:users-filter="usersFilter"
:filter-id="filterId"
+ :invalid-members="invalidMembers"
@clear="clearValidation"
+ @token-remove="removeToken"
/>
</template>
<template #form-after>
diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
index 90d266c3155..f917ebc35c2 100644
--- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue
+++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
@@ -159,7 +159,7 @@ export default {
introText() {
return sprintf(this.labelIntroText, { name: this.name });
},
- validationState() {
+ exceptionState() {
return this.invalidFeedbackMessage ? false : null;
},
selectLabelId() {
@@ -306,11 +306,11 @@ export default {
<slot name="intro-text-after"></slot>
</div>
- <slot name="user-limit-notification"></slot>
+ <slot name="alert"></slot>
<gl-form-group
:invalid-feedback="invalidFeedbackMessage"
- :state="validationState"
+ :state="exceptionState"
data-testid="members-form-group"
>
<template #description>
@@ -320,7 +320,7 @@ export default {
<label :id="selectLabelId" :class="selectLabelClass">{{ labelSearchField }}</label>
<gl-form-input v-if="reachedLimit" data-testid="disabled-input" disabled />
- <slot v-else name="select" v-bind="{ validationState, labelId: selectLabelId }"></slot>
+ <slot v-else name="select" v-bind="{ exceptionState, labelId: selectLabelId }"></slot>
</gl-form-group>
<template v-if="!reachedLimit">
diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue
index 30c9294344e..b2bcb9a5906 100644
--- a/app/assets/javascripts/invite_members/components/members_token_select.vue
+++ b/app/assets/javascripts/invite_members/components/members_token_select.vue
@@ -3,6 +3,7 @@ import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlIcon, GlSprintf } from '@
import { debounce } from 'lodash';
import { __ } from '~/locale';
import { getUsers } from '~/rest_api';
+import { memberName } from '../utils/member_utils';
import { SEARCH_DELAY, USERS_FILTER_ALL, USERS_FILTER_SAML_PROVIDER_ID } from '../constants';
export default {
@@ -23,7 +24,7 @@ export default {
type: String,
required: true,
},
- validationState: {
+ exceptionState: {
type: Boolean,
required: false,
default: false,
@@ -38,6 +39,10 @@ export default {
required: false,
default: null,
},
+ invalidMembers: {
+ type: Object,
+ required: true,
+ },
},
data() {
return {
@@ -109,13 +114,18 @@ export default {
this.hasBeenFocused = true;
},
- handleTokenRemove() {
+ handleTokenRemove(value) {
if (this.selectedTokens.length) {
+ this.$emit('token-remove', value);
+
return;
}
this.$emit('clear');
},
+ hasError(token) {
+ return Object.keys(this.invalidMembers).includes(memberName(token));
+ },
},
defaultQueryOptions: { without_project_bots: true, active: true },
i18n: {
@@ -127,7 +137,7 @@ export default {
<template>
<gl-token-selector
v-model="selectedTokens"
- :state="validationState"
+ :state="exceptionState"
:dropdown-items="users"
:loading="loading"
:allow-user-defined-tokens="emailIsValid"
@@ -145,8 +155,19 @@ export default {
@token-remove="handleTokenRemove"
>
<template #token-content="{ token }">
- <gl-icon v-if="validationState === false" name="error" :size="16" class="gl-mr-2" />
- <gl-avatar v-else-if="token.avatar_url" :src="token.avatar_url" :size="16" />
+ <gl-icon
+ v-if="hasError(token)"
+ name="error"
+ :size="16"
+ class="gl-mr-2"
+ :data-testid="`error-icon-${token.id}`"
+ />
+ <gl-avatar
+ v-else-if="token.avatar_url"
+ :src="token.avatar_url"
+ :size="16"
+ data-testid="token-avatar"
+ />
{{ token.name }}
</template>
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index beb8f5b5aab..6141e5e9e0b 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -74,6 +74,9 @@ export const INVITE_BUTTON_TEXT_DISABLED = s__('InviteMembersModal|Manage member
export const CANCEL_BUTTON_TEXT = s__('InviteMembersModal|Cancel');
export const CANCEL_BUTTON_TEXT_DISABLED = s__('InviteMembersModal|Explore paid plans');
export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team members');
+export const MEMBER_ERROR_LIST_TEXT = s__(
+ 'InviteMembersModal|Review the invite errors and try again:',
+);
export const MEMBER_MODAL_LABELS = {
modal: {
@@ -109,6 +112,7 @@ export const MEMBER_MODAL_LABELS = {
title: MEMBERS_TASKS_PROJECTS_TITLE,
},
toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL,
+ memberErrorListText: MEMBER_ERROR_LIST_TEXT,
};
export const GROUP_MODAL_LABELS = {
diff --git a/app/assets/javascripts/invite_members/init_import_a_project_modal.js b/app/assets/javascripts/invite_members/init_import_a_project_modal.js
deleted file mode 100644
index 954347467de..00000000000
--- a/app/assets/javascripts/invite_members/init_import_a_project_modal.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import Vue from 'vue';
-import ImportAProjectModal from '~/invite_members/components/import_a_project_modal.vue';
-
-export default function initImportAProjectModal() {
- const el = document.querySelector('.js-import-a-project-modal');
-
- if (!el) {
- return false;
- }
-
- const { projectId, projectName } = el.dataset;
-
- return new Vue({
- el,
- render: (createElement) =>
- createElement(ImportAProjectModal, {
- props: {
- projectId,
- projectName,
- },
- }),
- });
-}
diff --git a/app/assets/javascripts/invite_members/init_import_project_members_modal.js b/app/assets/javascripts/invite_members/init_import_project_members_modal.js
new file mode 100644
index 00000000000..daaa1315884
--- /dev/null
+++ b/app/assets/javascripts/invite_members/init_import_project_members_modal.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import ImportProjectMembersModal from '~/invite_members/components/import_project_members_modal.vue';
+
+export default function initImportProjectMembersModal() {
+ const el = document.querySelector('.js-import-project-members-modal');
+
+ if (!el) {
+ return false;
+ }
+
+ const { projectId, projectName } = el.dataset;
+
+ return new Vue({
+ el,
+ render: (createElement) =>
+ createElement(ImportProjectMembersModal, {
+ props: {
+ projectId,
+ projectName,
+ },
+ }),
+ });
+}
diff --git a/app/assets/javascripts/invite_members/init_import_project_members_trigger.js b/app/assets/javascripts/invite_members/init_import_project_members_trigger.js
new file mode 100644
index 00000000000..66a9bf118d2
--- /dev/null
+++ b/app/assets/javascripts/invite_members/init_import_project_members_trigger.js
@@ -0,0 +1,20 @@
+import Vue from 'vue';
+import ImportProjectMembersTrigger from '~/invite_members/components/import_project_members_trigger.vue';
+
+export default function initImportProjectMembersTrigger() {
+ const el = document.querySelector('.js-import-project-members-trigger');
+
+ if (!el) {
+ return false;
+ }
+
+ return new Vue({
+ el,
+ render: (createElement) =>
+ createElement(ImportProjectMembersTrigger, {
+ props: {
+ ...el.dataset,
+ },
+ }),
+ });
+}
diff --git a/app/assets/javascripts/invite_members/utils/member_utils.js b/app/assets/javascripts/invite_members/utils/member_utils.js
new file mode 100644
index 00000000000..d85162626f1
--- /dev/null
+++ b/app/assets/javascripts/invite_members/utils/member_utils.js
@@ -0,0 +1,4 @@
+export function memberName(member) {
+ // user defined tokens(invites by email) will have email in `name` and will not contain `username`
+ return member.username || member.name;
+}
diff --git a/app/assets/javascripts/invite_members/utils/response_message_parser.js b/app/assets/javascripts/invite_members/utils/response_message_parser.js
index db8ac303dc4..6e6431b89d9 100644
--- a/app/assets/javascripts/invite_members/utils/response_message_parser.js
+++ b/app/assets/javascripts/invite_members/utils/response_message_parser.js
@@ -1,15 +1,4 @@
-import { isString } from 'lodash';
-
-function responseKeyedMessageParsed(keyedMessage) {
- try {
- const keys = Object.keys(keyedMessage);
- const msg = keyedMessage[keys[0]];
-
- return msg;
- } catch {
- return '';
- }
-}
+import { isString, isArray } from 'lodash';
export function responseMessageFromError(response) {
if (!response?.response?.data) {
@@ -23,9 +12,9 @@ export function responseMessageFromError(response) {
return data.error || data.message?.error || data.message || '';
}
-export function responseMessageFromSuccess(response) {
+export function responseFromSuccess(response) {
if (!response?.data) {
- return '';
+ return { error: false };
}
const { data } = response;
@@ -34,11 +23,19 @@ export function responseMessageFromSuccess(response) {
const { message } = data;
if (isString(message)) {
- return message;
+ return { message, error: true };
+ }
+
+ if (isArray(message)) {
+ return { message: message[0], error: true };
}
+ // we assume object now with our keyed format
+ return { message: { ...message }, error: true };
+ }
- return responseKeyedMessageParsed(message);
+ if (data.error) {
+ return { message: data.error, error: true };
}
- return data.error || '';
+ return { error: false };
}
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 d46354e240a..8a55176fed0 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
@@ -54,24 +54,23 @@ export default class IssuableBulkUpdateSidebar {
new MilestoneSelect();
subscriptionSelect();
+ // Checking IS_EE and using ee_else_ce is odd, but we do it here to satisfy
+ // the import/no-unresolved lint rule when FOSS_ONLY=1, even though at
+ // runtime this block won't execute.
if (IS_EE) {
- import('ee/vue_shared/components/sidebar/health_status_select/health_status_bundle')
+ import('ee_else_ce/vue_shared/components/sidebar/health_status_select/health_status_bundle')
.then(({ default: HealthStatusSelect }) => {
HealthStatusSelect();
})
.catch(() => {});
- }
- if (IS_EE) {
- import('ee/vue_shared/components/sidebar/epics_select/epics_select_bundle')
+ import('ee_else_ce/vue_shared/components/sidebar/epics_select/epics_select_bundle')
.then(({ default: EpicSelect }) => {
EpicSelect();
})
.catch(() => {});
- }
- if (IS_EE) {
- import('ee/vue_shared/components/sidebar/iterations_dropdown_bundle')
+ import('ee_else_ce/vue_shared/components/sidebar/iterations_dropdown_bundle')
.then(({ default: iterationsDropdown }) => {
iterationsDropdown();
})
diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue
index e6379b35f7a..a505a988360 100644
--- a/app/assets/javascripts/issuable/components/related_issuable_item.vue
+++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue
@@ -84,7 +84,7 @@ export default {
<gl-icon
v-if="hasState"
ref="iconElementXL"
- class="mr-2 d-block"
+ class="gl-mr-3"
:class="iconClasses"
:name="iconName"
:title="stateTitle"
diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js
index 38453072af8..cc2608b5c62 100644
--- a/app/assets/javascripts/issuable/issuable_form.js
+++ b/app/assets/javascripts/issuable/issuable_form.js
@@ -68,8 +68,7 @@ export default class IssuableForm {
this.gfmAutoComplete = new GfmAutoComplete(
gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources,
).setup();
- const autoAssignToMe = form.get(0).id === 'new_merge_request';
- this.usersSelect = new UsersSelect(undefined, undefined, { autoAssignToMe });
+ this.usersSelect = new UsersSelect();
this.reviewersSelect = new UsersSelect(undefined, '.js-reviewer-search');
this.zenMode = new ZenMode();
@@ -82,7 +81,7 @@ export default class IssuableForm {
this.initAutosave();
this.form.on('submit', this.handleSubmit);
- this.form.on('click', '.btn-cancel', this.resetAutosave);
+ this.form.on('click', '.btn-cancel, .js-reset-autosave', this.resetAutosave);
this.form.find('.js-unwrap-on-load').unwrap();
this.initWip();
diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js
index edf3789e6dc..92ff7f21eff 100644
--- a/app/assets/javascripts/issues/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js
@@ -169,28 +169,27 @@ export default class CreateMergeRequestDropdown {
}
createMergeRequest() {
- return new Promise(() => {
- this.isCreatingMergeRequest = true;
- return this.createBranch(false)
- .then(() => api.trackRedisHllUserEvent('i_code_review_user_create_mr_from_issue'))
- .then(() => {
- let path = canCreateConfidentialMergeRequest()
- ? this.createMrPath.replace(
- this.projectPath,
- confidentialMergeRequestState.selectedProject.pathWithNamespace,
- )
- : this.createMrPath;
- path = mergeUrlParams(
- {
- 'merge_request[target_branch]': this.refInput.value,
- 'merge_request[source_branch]': this.branchInput.value,
- },
- path,
- );
-
- window.location.href = path;
- });
- });
+ this.isCreatingMergeRequest = true;
+
+ return this.createBranch(false)
+ .then(() => api.trackRedisHllUserEvent('i_code_review_user_create_mr_from_issue'))
+ .then(() => {
+ let path = canCreateConfidentialMergeRequest()
+ ? this.createMrPath.replace(
+ this.projectPath,
+ confidentialMergeRequestState.selectedProject.pathWithNamespace,
+ )
+ : this.createMrPath;
+ path = mergeUrlParams(
+ {
+ 'merge_request[target_branch]': this.refInput.value,
+ 'merge_request[source_branch]': this.branchInput.value,
+ },
+ path,
+ );
+
+ window.location.href = path;
+ });
}
disable() {
diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js
index 67c6c723dcc..380bb5f5346 100644
--- a/app/assets/javascripts/issues/index.js
+++ b/app/assets/javascripts/issues/index.js
@@ -23,6 +23,7 @@ import initNotesApp from '~/notes';
import { store } from '~/notes/stores';
import ZenMode from '~/zen_mode';
import initAwardsApp from '~/emoji/awards_app';
+import initLinkedResources from '~/linked_resources';
import FilteredSearchServiceDesk from './filtered_search_service_desk';
export function initFilteredSearchServiceDesk() {
@@ -59,6 +60,7 @@ export function initShow() {
if (issueType === IssueType.Incident) {
initIncidentApp({ ...issuableData, issuableId: el.dataset.issuableId });
initHeaderActions(store, IssueType.Incident);
+ initLinkedResources();
initRelatedIssues(IssueType.Incident);
} else {
initIssueApp(issuableData, store);
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 fa56c0183b2..f567b0f1d68 100644
--- a/app/assets/javascripts/issues/list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -70,6 +70,7 @@ import {
UPDATED_DESC,
urlSortParams,
} from '../constants';
+
import eventHub from '../eventhub';
import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql';
import searchLabelsQuery from '../queries/search_labels.query.graphql';
@@ -98,6 +99,10 @@ 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 CrmContactToken = () =>
+ import('~/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue');
+const CrmOrganizationToken = () =>
+ import('~/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue');
export default {
i18n,
@@ -168,6 +173,7 @@ export default {
showBulkEditSidebar: false,
sortKey: CREATED_DESC,
state: IssuableStates.Opened,
+ pageSize: PAGE_SIZE,
};
},
apollo: {
@@ -383,7 +389,11 @@ export default {
type: TOKEN_TYPE_CONTACT,
title: TOKEN_TITLE_CONTACT,
icon: 'user',
- token: GlFilteredSearchToken,
+ token: CrmContactToken,
+ fullPath: this.fullPath,
+ isProject: this.isProject,
+ defaultContacts: DEFAULT_NONE_ANY,
+ recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-crm-contacts`,
operators: OPERATOR_IS_ONLY,
unique: true,
});
@@ -394,7 +404,11 @@ export default {
type: TOKEN_TYPE_ORGANIZATION,
title: TOKEN_TITLE_ORGANIZATION,
icon: 'users',
- token: GlFilteredSearchToken,
+ token: CrmOrganizationToken,
+ fullPath: this.fullPath,
+ isProject: this.isProject,
+ defaultOrganizations: DEFAULT_NONE_ANY,
+ recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-crm-organizations`,
operators: OPERATOR_IS_ONLY,
unique: true,
});
@@ -411,6 +425,10 @@ export default {
showPaginationControls() {
return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage);
},
+ showPageSizeControls() {
+ /** only show page size controls when the tab count is greater than the default/minimum page size control i.e 20 in this case */
+ return this.currentTabCount > PAGE_SIZE;
+ },
sortOptions() {
return getSortOptions(this.hasIssueWeightsFeature, this.hasBlockedIssuesFeature);
},
@@ -433,8 +451,8 @@ export default {
...this.urlFilterParams,
first_page_size: this.pageParams.firstPageSize,
last_page_size: this.pageParams.lastPageSize,
- page_after: this.pageParams.afterCursor,
- page_before: this.pageParams.beforeCursor,
+ page_after: this.pageParams.afterCursor ?? undefined,
+ page_before: this.pageParams.beforeCursor ?? undefined,
};
},
},
@@ -543,7 +561,7 @@ export default {
},
handleClickTab(state) {
if (this.state !== state) {
- this.pageParams = getInitialPageParams(this.sortKey);
+ this.pageParams = getInitialPageParams(this.pageSize);
}
this.state = state;
@@ -558,7 +576,7 @@ export default {
return;
}
- this.pageParams = getInitialPageParams(this.sortKey);
+ this.pageParams = getInitialPageParams(this.pageSize);
this.filterTokens = filter;
this.$router.push({ query: this.urlParams });
@@ -566,7 +584,7 @@ export default {
handleNextPage() {
this.pageParams = {
afterCursor: this.pageInfo.endCursor,
- firstPageSize: PAGE_SIZE,
+ firstPageSize: this.pageSize,
};
scrollUp();
@@ -575,7 +593,7 @@ export default {
handlePreviousPage() {
this.pageParams = {
beforeCursor: this.pageInfo.startCursor,
- lastPageSize: PAGE_SIZE,
+ lastPageSize: this.pageSize,
};
scrollUp();
@@ -624,7 +642,7 @@ export default {
}
if (this.sortKey !== sortKey) {
- this.pageParams = getInitialPageParams(sortKey);
+ this.pageParams = getInitialPageParams(this.pageSize);
}
this.sortKey = sortKey;
@@ -664,6 +682,17 @@ export default {
toggleBulkEditSidebar(showBulkEditSidebar) {
this.showBulkEditSidebar = showBulkEditSidebar;
},
+ handlePageSizeChange(newPageSize) {
+ /** make sure the page number is preserved so that the current context is not lost* */
+ const lastPageSize = getParameterByName(PARAM_LAST_PAGE_SIZE);
+ const pageNumberSize = lastPageSize ? 'lastPageSize' : 'firstPageSize';
+ /** depending upon what page or page size we are dynamically set pageParams * */
+ this.pageParams[pageNumberSize] = newPageSize;
+ this.pageSize = newPageSize;
+ scrollUp();
+
+ this.$router.push({ query: this.urlParams });
+ },
updateData(sortValue) {
const firstPageSize = getParameterByName(PARAM_FIRST_PAGE_SIZE);
const lastPageSize = getParameterByName(PARAM_LAST_PAGE_SIZE);
@@ -696,7 +725,7 @@ export default {
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
this.filterTokens = isSearchDisabled ? [] : getFilterTokens(window.location.search);
this.pageParams = getInitialPageParams(
- sortKey,
+ this.pageSize,
isPositiveInteger(firstPageSize) ? parseInt(firstPageSize, 10) : undefined,
isPositiveInteger(lastPageSize) ? parseInt(lastPageSize, 10) : undefined,
pageAfter,
@@ -732,8 +761,10 @@ export default {
:is-manual-ordering="isManualOrdering"
:show-bulk-edit-sidebar="showBulkEditSidebar"
:show-pagination-controls="showPaginationControls"
+ :default-page-size="pageSize"
sync-filter-and-sort
use-keyset-pagination
+ :show-page-size-change-controls="showPageSizeControls"
:has-next-page="pageInfo.hasNextPage"
:has-previous-page="pageInfo.hasPreviousPage"
@click-tab="handleClickTab"
@@ -744,6 +775,7 @@ export default {
@reorder="handleReorder"
@sort="handleSort"
@update-legacy-bulk-edit="handleUpdateLegacyBulkEdit"
+ @page-size-change="handlePageSizeChange"
>
<template #nav-actions>
<gl-button
diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js
index 74f801f685c..a921eb62e26 100644
--- a/app/assets/javascripts/issues/list/constants.js
+++ b/app/assets/javascripts/issues/list/constants.js
@@ -90,6 +90,8 @@ export const UPDATED_ASC = 'UPDATED_ASC';
export const UPDATED_DESC = 'UPDATED_DESC';
export const WEIGHT_ASC = 'WEIGHT_ASC';
export const WEIGHT_DESC = 'WEIGHT_DESC';
+export const CLOSED_ASC = 'CLOSED_AT_ASC';
+export const CLOSED_DESC = 'CLOSED_AT_DESC';
export const urlSortParams = {
[PRIORITY_ASC]: 'priority',
@@ -98,6 +100,8 @@ export const urlSortParams = {
[CREATED_DESC]: 'created_date',
[UPDATED_ASC]: 'updated_asc',
[UPDATED_DESC]: 'updated_desc',
+ [CLOSED_ASC]: 'closed_asc',
+ [CLOSED_DESC]: 'closed_desc',
[MILESTONE_DUE_ASC]: 'milestone',
[MILESTONE_DUE_DESC]: 'milestone_due_desc',
[DUE_DATE_ASC]: 'due_date',
diff --git a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
index 73a13cea94a..35762120f71 100644
--- a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
+++ b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
@@ -13,6 +13,7 @@ fragment IssueFragment on Issue {
state
title
updatedAt
+ closedAt
upvotes
userDiscussionsCount @include(if: $isSignedIn)
webPath
diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js
index dfdc6e27f0d..f02c7a23f51 100644
--- a/app/assets/javascripts/issues/list/utils.js
+++ b/app/assets/javascripts/issues/list/utils.js
@@ -21,7 +21,6 @@ import {
MILESTONE_DUE_DESC,
NORMAL_FILTER,
PAGE_SIZE,
- PAGE_SIZE_MANUAL,
PARAM_ASSIGNEE_ID,
POPULARITY_ASC,
POPULARITY_DESC,
@@ -44,11 +43,13 @@ import {
urlSortParams,
WEIGHT_ASC,
WEIGHT_DESC,
+ CLOSED_ASC,
+ CLOSED_DESC,
} from './constants';
export const getInitialPageParams = (
- sortKey,
- firstPageSize = sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE,
+ pageSize,
+ firstPageSize = pageSize ?? PAGE_SIZE,
lastPageSize,
afterCursor,
beforeCursor,
@@ -92,6 +93,14 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature)
},
{
id: 4,
+ title: __('Closed date'),
+ sortDirection: {
+ ascending: CLOSED_ASC,
+ descending: CLOSED_DESC,
+ },
+ },
+ {
+ id: 5,
title: __('Milestone due date'),
sortDirection: {
ascending: MILESTONE_DUE_ASC,
@@ -99,7 +108,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature)
},
},
{
- id: 5,
+ id: 6,
title: __('Due date'),
sortDirection: {
ascending: DUE_DATE_ASC,
@@ -107,7 +116,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature)
},
},
{
- id: 6,
+ id: 7,
title: __('Popularity'),
sortDirection: {
ascending: POPULARITY_ASC,
@@ -115,7 +124,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature)
},
},
{
- id: 7,
+ id: 8,
title: __('Label priority'),
sortDirection: {
ascending: LABEL_PRIORITY_ASC,
@@ -123,7 +132,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature)
},
},
{
- id: 8,
+ id: 9,
title: __('Manual'),
sortDirection: {
ascending: RELATIVE_POSITION_ASC,
@@ -131,7 +140,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature)
},
},
{
- id: 9,
+ id: 10,
title: __('Title'),
sortDirection: {
ascending: TITLE_ASC,
diff --git a/app/assets/javascripts/issues/new/components/type_popover.vue b/app/assets/javascripts/issues/new/components/type_popover.vue
index a70e79b70f9..9c43e527f8b 100644
--- a/app/assets/javascripts/issues/new/components/type_popover.vue
+++ b/app/assets/javascripts/issues/new/components/type_popover.vue
@@ -18,8 +18,9 @@ export default {
</script>
<template>
- <span id="popovercontainer">
- <gl-icon id="issue-type-info" name="question-o" class="gl-ml-5 gl-text-gray-500" />
+ <span id="popovercontainer" class="gl-ml-2">
+ <gl-icon id="issue-type-info" name="question-o" class="gl-text-blue-600" />
+
<gl-popover
target="issue-type-info"
container="popovercontainer"
diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue
index 892c631f8ea..449da394841 100644
--- a/app/assets/javascripts/issues/show/components/description.vue
+++ b/app/assets/javascripts/issues/show/components/description.vue
@@ -1,11 +1,5 @@
<script>
-import {
- GlSafeHtmlDirective as SafeHtml,
- GlModal,
- GlToast,
- GlTooltip,
- GlModalDirective,
-} from '@gitlab/ui';
+import { GlSafeHtmlDirective as SafeHtml, GlToast, GlTooltip, GlModalDirective } from '@gitlab/ui';
import $ from 'jquery';
import Sortable from 'sortablejs';
import Vue from 'vue';
@@ -20,11 +14,16 @@ import { getSortableDefaultOptions, isDragging } from '~/sortable/utils';
import TaskList from '~/task_list';
import Tracking from '~/tracking';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
+import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
+import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_item_from_task.mutation.graphql';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
-import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
-import CreateWorkItem from '~/work_items/pages/create_work_item.vue';
+import {
+ TRACKING_CATEGORY_SHOW,
+ TASK_TYPE_NAME,
+ WIDGET_TYPE_DESCRIPTION,
+} from '~/work_items/constants';
import animateMixin from '../mixins/animate';
import { convertDescriptionWithNewSort } from '../utils';
@@ -40,12 +39,11 @@ export default {
GlModal: GlModalDirective,
},
components: {
- GlModal,
- CreateWorkItem,
GlTooltip,
WorkItemDetailModal,
},
mixins: [animateMixin, glFeatureFlagMixin(), Tracking.mixin()],
+ inject: ['fullPath'],
props: {
canUpdate: {
type: Boolean,
@@ -103,6 +101,7 @@ export default {
workItemId: isPositiveInteger(workItemId)
? convertToGraphQLId(TYPE_WORK_ITEM, workItemId)
: undefined,
+ workItemTypes: [],
};
},
apollo: {
@@ -117,11 +116,28 @@ export default {
return !this.workItemId || !this.workItemsEnabled;
},
},
+ workItemTypes: {
+ query: projectWorkItemTypesQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ };
+ },
+ update(data) {
+ return data.workspace?.workItemTypes?.nodes;
+ },
+ skip() {
+ return !this.workItemsEnabled;
+ },
+ },
},
computed: {
workItemsEnabled() {
return this.glFeatures.workItems;
},
+ taskWorkItemType() {
+ return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id;
+ },
issueGid() {
return this.issueId ? convertToGraphQLId(TYPE_WORK_ITEM, this.issueId) : null;
},
@@ -344,8 +360,8 @@ export default {
<use href="${gon.sprite_icons}#doc-new"></use>
</svg>
`;
- button.setAttribute('aria-label', s__('WorkItem|Convert to work item'));
- button.addEventListener('click', () => this.openCreateTaskModal(button));
+ button.setAttribute('aria-label', s__('WorkItem|Create task'));
+ button.addEventListener('click', () => this.handleCreateTask(button));
this.insertButtonNextToTaskText(item, button);
});
},
@@ -386,17 +402,11 @@ export default {
lineNumberEnd: lineNumbers[1],
};
},
- openCreateTaskModal(el) {
- this.setActiveTask(el);
- this.$refs.modal.show();
- },
- closeCreateTaskModal() {
- this.$refs.modal.hide();
- },
openWorkItemDetailModal(el) {
if (!el) {
return;
}
+
this.setActiveTask(el);
this.$refs.detailsModal.show();
},
@@ -404,13 +414,58 @@ export default {
this.workItemId = undefined;
this.updateWorkItemIdUrlQuery(undefined);
},
- handleCreateTask(description) {
- this.$emit('updateDescription', description);
- this.closeCreateTaskModal();
+ async handleCreateTask(el) {
+ this.setActiveTask(el);
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: createWorkItemFromTaskMutation,
+ variables: {
+ input: {
+ id: this.issueGid,
+ workItemData: {
+ lockVersion: this.lockVersion,
+ title: this.activeTask.title,
+ lineNumberStart: Number(this.activeTask.lineNumberStart),
+ lineNumberEnd: Number(this.activeTask.lineNumberEnd),
+ workItemTypeId: this.taskWorkItemType,
+ },
+ },
+ },
+ update(store, { data: { workItemCreateFromTask } }) {
+ const { newWorkItem } = workItemCreateFromTask;
+
+ store.writeQuery({
+ query: workItemQuery,
+ variables: {
+ id: newWorkItem.id,
+ },
+ data: {
+ workItem: newWorkItem,
+ },
+ });
+ },
+ });
+
+ const { workItem, newWorkItem } = data.workItemCreateFromTask;
+
+ const updatedDescription = workItem?.widgets?.find(
+ (widget) => widget.type === WIDGET_TYPE_DESCRIPTION,
+ )?.descriptionHtml;
+
+ this.$emit('updateDescription', updatedDescription);
+ this.workItemId = newWorkItem.id;
+ this.openWorkItemDetailModal(el);
+ } catch (error) {
+ createFlash({
+ message: s__('WorkItem|Something went wrong when creating a work item. Please try again'),
+ error,
+ captureError: true,
+ });
+ }
},
handleDeleteTask(description) {
this.$emit('updateDescription', description);
- this.$toast.show(s__('WorkItem|Work item deleted'));
+ this.$toast.show(s__('WorkItem|Task deleted'));
},
updateWorkItemIdUrlQuery(workItemId) {
updateHistory({
@@ -452,19 +507,6 @@ export default {
data-testid="textarea"
>
</textarea>
-
- <gl-modal ref="modal" size="lg" modal-id="create-task-modal" hide-footer body-class="gl-p-0!">
- <create-work-item
- is-modal
- :initial-title="activeTask.title"
- :issue-gid="issueGid"
- :lock-version="lockVersion"
- :line-number-start="activeTask.lineNumberStart"
- :line-number-end="activeTask.lineNumberEnd"
- @closeModal="closeCreateTaskModal"
- @onCreate="handleCreateTask"
- />
- </gl-modal>
<work-item-detail-modal
ref="detailsModal"
:can-update="canUpdate"
@@ -478,7 +520,7 @@ export default {
/>
<template v-if="workItemsEnabled">
<gl-tooltip v-for="item in taskButtons" :key="item" :target="item">
- {{ s__('WorkItem|Convert to work item') }}
+ {{ s__('WorkItem|Create task') }}
</gl-tooltip>
</template>
</div>
diff --git a/app/assets/javascripts/issues/show/components/edit_actions.vue b/app/assets/javascripts/issues/show/components/edit_actions.vue
index 9b31014c1ba..358b53bd131 100644
--- a/app/assets/javascripts/issues/show/components/edit_actions.vue
+++ b/app/assets/javascripts/issues/show/components/edit_actions.vue
@@ -105,7 +105,7 @@ export default {
:disabled="formState.updateLoading || !isSubmitEnabled"
category="primary"
variant="confirm"
- class="qa-save-button gl-mr-3"
+ class="gl-mr-3"
data-testid="issuable-save-button"
type="submit"
@click.prevent="updateIssuable"
@@ -123,7 +123,6 @@ export default {
:disabled="deleteLoading"
category="secondary"
variant="danger"
- class="qa-delete-button"
data-testid="issuable-delete-button"
@click="track('click_button')"
>
diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue
index 0bb5e7cb2ee..f45af47374a 100644
--- a/app/assets/javascripts/issues/show/components/fields/description.vue
+++ b/app/assets/javascripts/issues/show/components/fields/description.vue
@@ -59,7 +59,8 @@ export default {
id="issue-description"
ref="textarea"
:value="value"
- class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ data-qa-selector="description_field"
dir="auto"
data-supports-quick-actions="true"
:aria-label="__('Description')"
diff --git a/app/assets/javascripts/issues/show/components/fields/title.vue b/app/assets/javascripts/issues/show/components/fields/title.vue
index 594d1a65700..58d32256da4 100644
--- a/app/assets/javascripts/issues/show/components/fields/title.vue
+++ b/app/assets/javascripts/issues/show/components/fields/title.vue
@@ -19,7 +19,7 @@ export default {
id="issuable-title"
ref="input"
:value="value"
- class="form-control qa-title-input gl-border-gray-200"
+ class="form-control gl-border-gray-200"
dir="auto"
type="text"
:placeholder="__('Title')"
diff --git a/app/assets/javascripts/issues/show/components/incidents/constants.js b/app/assets/javascripts/issues/show/components/incidents/constants.js
new file mode 100644
index 00000000000..9fc5027d457
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/constants.js
@@ -0,0 +1,26 @@
+import { s__ } from '~/locale';
+
+export const timelineTabI18n = Object.freeze({
+ title: s__('Incident|Timeline'),
+ emptyDescription: s__('Incident|No timeline items have been added yet.'),
+ addEventButton: s__('Incident|Add new timeline event'),
+});
+
+export const timelineFormI18n = Object.freeze({
+ createError: s__('Incident|Error creating incident timeline event: %{error}'),
+ createErrorGeneric: s__(
+ 'Incident|Something went wrong while creating the incident timeline event.',
+ ),
+ areaPlaceholder: s__('Incident|Timeline text...'),
+ saveAndAdd: s__('Incident|Save and add another event'),
+ areaLabel: s__('Incident|Timeline text'),
+});
+
+export const timelineListI18n = Object.freeze({
+ deleteButton: s__('Incident|Delete event'),
+ deleteError: s__('Incident|Error deleting incident timeline event: %{error}'),
+ deleteErrorGeneric: s__(
+ 'Incident|Something went wrong while deleting the incident timeline event.',
+ ),
+ deleteModal: s__('Incident|Are you sure you want to delete this event?'),
+});
diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql
new file mode 100644
index 00000000000..f1fc27dcb2a
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql
@@ -0,0 +1,13 @@
+mutation CreateTimelineEvent($input: TimelineEventCreateInput!) {
+ timelineEventCreate(input: $input) {
+ timelineEvent {
+ id
+ note
+ noteHtml
+ action
+ occurredAt
+ createdAt
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/delete_timeline_event.mutation.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/delete_timeline_event.mutation.graphql
new file mode 100644
index 00000000000..78babf9d62e
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/delete_timeline_event.mutation.graphql
@@ -0,0 +1,8 @@
+mutation DestroyTimelineEvent($input: TimelineEventDestroyInput!) {
+ timelineEventDestroy(input: $input) {
+ timelineEvent {
+ id
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql
index 7e049d98c1a..bc4e8414bfc 100644
--- a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql
+++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql
@@ -4,17 +4,11 @@ query GetTimelineEvents($fullPath: ID!, $incidentId: IssueID!) {
incidentManagementTimelineEvents(incidentId: $incidentId) {
nodes {
id
- author {
- id
- name
- username
- }
note
noteHtml
action
occurredAt
createdAt
- updatedAt
}
}
}
diff --git a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
index 6fdce6045f2..dd84a1d7d67 100644
--- a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
@@ -101,7 +101,7 @@ export default {
>
<gl-tab :title="s__('Incident|Summary')">
<highlight-bar :alert="alert" />
- <description-component v-bind="$attrs" />
+ <description-component v-bind="$attrs" v-on="$listeners" />
</gl-tab>
<incident-metric-tab />
<gl-tab
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue
new file mode 100644
index 00000000000..36ec6362a22
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue
@@ -0,0 +1,266 @@
+<script>
+import { GlDatepicker, GlFormInput, GlFormGroup, GlButton, GlIcon } from '@gitlab/ui';
+import { produce } from 'immer';
+import { sortBy } from 'lodash';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_ISSUE } from '~/graphql_shared/constants';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import { createAlert } from '~/flash';
+import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
+import { sprintf } from '~/locale';
+import { getUtcShiftedDateNow } from './utils';
+import { timelineFormI18n } from './constants';
+
+import CreateTimelineEvent from './graphql/queries/create_timeline_event.mutation.graphql';
+import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql';
+
+export default {
+ name: 'IncidentTimelineEventForm',
+ restrictedToolBarItems: [
+ 'quote',
+ 'strikethrough',
+ 'bullet-list',
+ 'numbered-list',
+ 'task-list',
+ 'collapsible-section',
+ 'table',
+ 'full-screen',
+ ],
+ components: {
+ MarkdownField,
+ GlDatepicker,
+ GlFormInput,
+ GlFormGroup,
+ GlButton,
+ GlIcon,
+ },
+ i18n: timelineFormI18n,
+ directives: {
+ autofocusonshow,
+ },
+ inject: ['fullPath', 'issuableId'],
+ props: {
+ hasTimelineEvents: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ // Create shifted date to force the datepicker to format in UTC
+ const utcShiftedDate = getUtcShiftedDateNow();
+ return {
+ currentDate: utcShiftedDate,
+ currentHour: utcShiftedDate.getHours(),
+ currentMinute: utcShiftedDate.getMinutes(),
+ timelineText: '',
+ createTimelineEventActive: false,
+ datepickerTextInput: null,
+ };
+ },
+ methods: {
+ clear() {
+ const utcShiftedDate = getUtcShiftedDateNow();
+ this.currentDate = utcShiftedDate;
+ this.currentHour = utcShiftedDate.getHours();
+ this.currentMinute = utcShiftedDate.getMinutes();
+ },
+ hideIncidentTimelineEventForm() {
+ this.$emit('hide-incident-timeline-event-form');
+ },
+ focusDate() {
+ this.$refs.datepicker.$el.focus();
+ },
+ updateCache(store, { data }) {
+ const { timelineEvent: event, errors } = data?.timelineEventCreate || {};
+
+ if (errors.length) {
+ return;
+ }
+
+ const variables = {
+ incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId),
+ fullPath: this.fullPath,
+ };
+
+ const sourceData = store.readQuery({
+ query: getTimelineEvents,
+ variables,
+ });
+
+ const newData = produce(sourceData, (draftData) => {
+ const { nodes: draftEventList } = draftData.project.incidentManagementTimelineEvents;
+ draftEventList.push(event);
+ // ISOStrings sort correctly in lexical order
+ const sortedEvents = sortBy(draftEventList, 'occurredAt');
+ draftData.project.incidentManagementTimelineEvents.nodes = sortedEvents;
+ });
+
+ store.writeQuery({
+ query: getTimelineEvents,
+ variables,
+ data: newData,
+ });
+ },
+ createIncidentTimelineEvent(addOneEvent) {
+ this.createTimelineEventActive = true;
+ return this.$apollo
+ .mutate({
+ mutation: CreateTimelineEvent,
+ variables: {
+ input: {
+ incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId),
+ note: this.timelineText,
+ occurredAt: this.createDateString(),
+ },
+ },
+ update: this.updateCache,
+ })
+ .then(({ data = {} }) => {
+ const errors = data.timelineEventCreate?.errors;
+ if (errors.length) {
+ createAlert({
+ message: sprintf(this.$options.i18n.createError, { error: errors.join('. ') }, false),
+ });
+ }
+ })
+ .catch((error) => {
+ createAlert({
+ message: this.$options.i18n.createErrorGeneric,
+ captureError: true,
+ error,
+ });
+ })
+ .finally(() => {
+ this.createTimelineEventActive = false;
+ this.timelineText = '';
+ if (addOneEvent) {
+ this.hideIncidentTimelineEventForm();
+ }
+ });
+ },
+ createDateString() {
+ const [years, months, days] = this.datepickerTextInput.split('-');
+ const utcDate = new Date(
+ Date.UTC(years, months - 1, days, this.currentHour, this.currentMinute),
+ );
+ return utcDate.toISOString();
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="gl-relative gl-display-flex gl-align-items-center"
+ :class="{ 'timeline-entry-vertical-line': hasTimelineEvents }"
+ >
+ <div
+ v-if="hasTimelineEvents"
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-z-index-1"
+ >
+ <gl-icon name="comment" class="note-icon" />
+ </div>
+ <form class="gl-flex-grow-1 gl-border-gray-50" :class="{ 'gl-border-t': hasTimelineEvents }">
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row datetime-picker"
+ >
+ <gl-form-group :label="__('Date')" class="gl-mt-5 gl-mr-5">
+ <gl-datepicker id="incident-date" #default="{ formattedDate }" v-model="currentDate">
+ <gl-form-input
+ id="incident-date"
+ ref="datepicker"
+ v-model="datepickerTextInput"
+ data-testid="input-datepicker"
+ class="gl-datepicker-input gl-pr-7!"
+ :value="formattedDate"
+ :placeholder="__('YYYY-MM-DD')"
+ @keydown.enter="onKeydown"
+ />
+ </gl-datepicker>
+ </gl-form-group>
+ <div class="gl-display-flex gl-mt-5">
+ <gl-form-group :label="__('Time')">
+ <div class="gl-display-flex">
+ <label label-for="timeline-input-hours" class="sr-only"></label>
+ <gl-form-input
+ id="timeline-input-hours"
+ v-model="currentHour"
+ data-testid="input-hours"
+ size="xs"
+ type="number"
+ min="00"
+ max="23"
+ />
+ <label label-for="timeline-input-minutes" class="sr-only"></label>
+ <gl-form-input
+ id="timeline-input-minutes"
+ v-model="currentMinute"
+ class="gl-ml-3"
+ data-testid="input-minutes"
+ size="xs"
+ type="number"
+ min="00"
+ max="59"
+ />
+ </div>
+ </gl-form-group>
+ <p class="gl-ml-3 gl-align-self-end gl-line-height-32">{{ __('UTC') }}</p>
+ </div>
+ </div>
+ <div class="common-note-form">
+ <gl-form-group class="gl-mb-3" :label="$options.i18n.areaLabel">
+ <markdown-field
+ :can-attach-file="false"
+ :add-spacing-classes="false"
+ :show-comment-tool-bar="false"
+ :textarea-value="timelineText"
+ :restricted-tool-bar-items="$options.restrictedToolBarItems"
+ markdown-docs-path=""
+ :enable-preview="false"
+ class="bordered-box gl-mt-0"
+ >
+ <template #textarea>
+ <textarea
+ v-model="timelineText"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ dir="auto"
+ data-supports-quick-actions="false"
+ :aria-label="__('Description')"
+ :placeholder="$options.i18n.areaPlaceholder"
+ >
+ </textarea>
+ </template>
+ </markdown-field>
+ </gl-form-group>
+ </div>
+ <gl-form-group class="gl-mb-0">
+ <gl-button
+ variant="confirm"
+ category="primary"
+ class="gl-mr-3"
+ :loading="createTimelineEventActive"
+ @click="createIncidentTimelineEvent(true)"
+ >
+ {{ __('Save') }}
+ </gl-button>
+ <gl-button
+ variant="confirm"
+ category="secondary"
+ class="gl-mr-3 gl-ml-n2"
+ :loading="createTimelineEventActive"
+ @click="createIncidentTimelineEvent(false)"
+ >
+ {{ $options.i18n.saveAndAdd }}
+ </gl-button>
+ <gl-button
+ class="gl-ml-n2"
+ :disabled="createTimelineEventActive"
+ @click="hideIncidentTimelineEventForm"
+ >
+ {{ __('Cancel') }}
+ </gl-button>
+ <div class="gl-border-b gl-pt-5"></div>
+ </gl-form-group>
+ </form>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
index a6e58ee0bdc..519c0d402a0 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
@@ -1,9 +1,16 @@
<script>
import { formatDate } from '~/lib/utils/datetime_utility';
+import { createAlert } from '~/flash';
+import { sprintf } from '~/locale';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
import IncidentTimelineEventListItem from './timeline_events_list_item.vue';
+import deleteTimelineEvent from './graphql/queries/delete_timeline_event.mutation.graphql';
+import { timelineListI18n } from './constants';
export default {
name: 'IncidentTimelineEventList',
+ i18n: timelineListI18n,
components: {
IncidentTimelineEventListItem,
},
@@ -43,6 +50,41 @@ export default {
}
return eventIndex === events.length - 1;
},
+ handleDelete: ignoreWhilePending(async function handleDelete(event) {
+ const msg = this.$options.i18n.deleteModal;
+
+ const confirmed = await confirmAction(msg, {
+ primaryBtnVariant: 'danger',
+ primaryBtnText: this.$options.i18n.deleteButton,
+ });
+
+ if (!confirmed) {
+ return;
+ }
+
+ try {
+ const result = await this.$apollo.mutate({
+ mutation: deleteTimelineEvent,
+ variables: {
+ input: {
+ id: event.id,
+ },
+ },
+ update: (cache) => {
+ const cacheId = cache.identify(event);
+ cache.evict({ id: cacheId });
+ },
+ });
+ const { errors } = result.data.timelineEventDestroy;
+ if (errors?.length) {
+ createAlert({
+ message: sprintf(this.$options.i18n.deleteError, { error: errors.join('. ') }, false),
+ });
+ }
+ } catch (error) {
+ createAlert({ message: this.$options.i18n.deleteErrorGeneric, captureError: true, error });
+ }
+ }),
},
};
</script>
@@ -65,7 +107,7 @@ export default {
:occurred-at="event.occurredAt"
:note-html="event.noteHtml"
:is-last-item="isLastItem(dateGroupedEvents, groupIndex, events, eventIndex)"
- data-testid="timeline-event"
+ @delete="handleDelete(event)"
/>
</ul>
</div>
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue
index fef9bf713b7..62ccd696ef6 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon, GlSafeHtmlDirective, GlSprintf } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlIcon, GlSafeHtmlDirective, GlSprintf } from '@gitlab/ui';
import { formatDate } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
import { getEventIcon } from './utils';
@@ -7,15 +7,20 @@ import { getEventIcon } from './utils';
export default {
name: 'IncidentTimelineEventListItem',
i18n: {
+ delete: __('Delete'),
+ moreActions: __('More actions'),
timeUTC: __('%{time} UTC'),
},
components: {
+ GlDropdown,
+ GlDropdownItem,
GlIcon,
GlSprintf,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
},
+ inject: ['canUpdate'],
props: {
isLastItem: {
type: Boolean,
@@ -55,16 +60,32 @@ export default {
<gl-icon :name="getEventIcon(action)" class="note-icon" />
</div>
<div
- class="timeline-event-note gl-w-full"
+ class="timeline-event-note gl-w-full gl-display-flex gl-flex-direction-row"
:class="{ 'gl-pb-3 gl-border-gray-50 gl-border-1 gl-border-b-solid': !isLastItem }"
data-testid="event-text-container"
>
- <strong class="gl-font-lg" data-testid="event-time">
- <gl-sprintf :message="$options.i18n.timeUTC">
- <template #time>{{ time }}</template>
- </gl-sprintf>
- </strong>
- <div v-safe-html="noteHtml"></div>
+ <div>
+ <strong class="gl-font-lg" data-testid="event-time">
+ <gl-sprintf :message="$options.i18n.timeUTC">
+ <template #time>{{ time }}</template>
+ </gl-sprintf>
+ </strong>
+ <div v-safe-html="noteHtml"></div>
+ </div>
+ <gl-dropdown
+ v-if="canUpdate"
+ right
+ class="event-note-actions gl-ml-auto gl-align-self-center"
+ icon="ellipsis_v"
+ text-sr-only
+ :text="$options.i18n.moreActions"
+ category="tertiary"
+ no-caret
+ >
+ <gl-dropdown-item @click="$emit('delete')">
+ {{ $options.i18n.delete }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
</div>
</li>
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue
index 400e1f0b725..e1946ef4d07 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue
@@ -1,23 +1,29 @@
<script>
-import { GlEmptyState, GlLoadingIcon, GlTab } from '@gitlab/ui';
+import { GlButton, GlEmptyState, GlLoadingIcon, GlTab } from '@gitlab/ui';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPE_ISSUE } from '~/graphql_shared/constants';
import { fetchPolicies } from '~/lib/graphql';
import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql';
import { displayAndLogError } from './utils';
+import { timelineTabI18n } from './constants';
+import IncidentTimelineEventForm from './timeline_events_form.vue';
import IncidentTimelineEventsList from './timeline_events_list.vue';
export default {
components: {
+ GlButton,
GlEmptyState,
GlLoadingIcon,
GlTab,
+ IncidentTimelineEventForm,
IncidentTimelineEventsList,
},
- inject: ['fullPath', 'issuableId'],
+ i18n: timelineTabI18n,
+ inject: ['canUpdate', 'fullPath', 'issuableId'],
data() {
return {
+ isEventFormVisible: false,
timelineEvents: [],
};
},
@@ -50,21 +56,43 @@ export default {
return !this.timelineEventLoading && !this.hasTimelineEvents;
},
},
+ methods: {
+ hideEventForm() {
+ this.isEventFormVisible = false;
+ },
+ async showEventForm() {
+ this.$refs.eventForm.clear();
+ this.isEventFormVisible = true;
+ await this.$nextTick();
+ this.$refs.eventForm.focusDate();
+ },
+ },
};
</script>
<template>
- <gl-tab :title="s__('Incident|Timeline')">
+ <gl-tab :title="$options.i18n.title">
<gl-loading-icon v-if="timelineEventLoading" size="lg" color="dark" class="gl-mt-5" />
<gl-empty-state
v-else-if="showEmptyState"
:compact="true"
- :description="s__('Incident|No timeline items have been added yet.')"
+ :description="$options.i18n.emptyDescription"
/>
<incident-timeline-events-list
v-if="hasTimelineEvents"
:timeline-event-loading="timelineEventLoading"
:timeline-events="timelineEvents"
/>
+ <incident-timeline-event-form
+ v-show="isEventFormVisible"
+ ref="eventForm"
+ :has-timeline-events="hasTimelineEvents"
+ class="timeline-event-note timeline-event-note-form"
+ :class="{ 'gl-pl-0': !hasTimelineEvents }"
+ @hide-incident-timeline-event-form="hideEventForm"
+ />
+ <gl-button v-if="canUpdate" variant="default" class="gl-mb-3 gl-mt-7" @click="showEventForm">
+ {{ $options.i18n.addEventButton }}
+ </gl-button>
</gl-tab>
</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/utils.js b/app/assets/javascripts/issues/show/components/incidents/utils.js
index 8b5a2ec4031..256e3025f19 100644
--- a/app/assets/javascripts/issues/show/components/incidents/utils.js
+++ b/app/assets/javascripts/issues/show/components/incidents/utils.js
@@ -10,9 +10,23 @@ export const displayAndLogError = (error) =>
const EVENT_ICONS = {
comment: 'comment',
+ issues: 'issues',
+ status: 'status',
default: 'comment',
};
export const getEventIcon = (actionName) => {
return EVENT_ICONS[actionName] ?? EVENT_ICONS.default;
};
+
+/**
+ * Returns a date shifted by the current timezone offset. Allows
+ * date.getHours() and similar to return UTC values.
+ *
+ * @returns {Date}
+ */
+export const getUtcShiftedDateNow = () => {
+ const date = new Date();
+ date.setMinutes(date.getMinutes() + date.getTimezoneOffset());
+ return date;
+};
diff --git a/app/assets/javascripts/issues/show/components/title.vue b/app/assets/javascripts/issues/show/components/title.vue
index 7f67b31b122..307d9f9f69a 100644
--- a/app/assets/javascripts/issues/show/components/title.vue
+++ b/app/assets/javascripts/issues/show/components/title.vue
@@ -74,14 +74,15 @@ export default {
'issue-realtime-pre-pulse': preAnimation,
'issue-realtime-trigger-pulse': pulseAnimation,
}"
- class="title qa-title gl-font-size-h-display"
+ class="title gl-font-size-h-display"
+ data-qa-selector="title_content"
dir="auto"
></h1>
<gl-button
v-if="showInlineEditButton && canUpdate"
v-gl-tooltip.bottom
icon="pencil"
- class="btn-edit js-issuable-edit qa-edit-button"
+ class="btn-edit js-issuable-edit"
:title="$options.i18n.editTitleAndDescription"
:aria-label="$options.i18n.editTitleAndDescription"
@click="edit"
diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js
index 5bdad010af7..459a3804837 100644
--- a/app/assets/javascripts/issues/show/index.js
+++ b/app/assets/javascripts/issues/show/index.js
@@ -63,6 +63,7 @@ export function initIncidentApp(issueData = {}) {
return createElement(IssueApp, {
props: {
...issueData,
+ issueId: Number(issuableId),
issuableStatus: state,
descriptionComponent: IncidentTabs,
showTitleBorder: false,
diff --git a/app/assets/javascripts/jobs/bridge/app.vue b/app/assets/javascripts/jobs/bridge/app.vue
deleted file mode 100644
index c639e49083b..00000000000
--- a/app/assets/javascripts/jobs/bridge/app.vue
+++ /dev/null
@@ -1,118 +0,0 @@
-<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { __, sprintf } from '~/locale';
-import CiHeader from '~/vue_shared/components/header_ci_component.vue';
-import getPipelineQuery from './graphql/queries/pipeline.query.graphql';
-import BridgeEmptyState from './components/empty_state.vue';
-import BridgeSidebar from './components/sidebar.vue';
-import { SIDEBAR_COLLAPSE_BREAKPOINTS } from './components/constants';
-
-export default {
- name: 'BridgePageApp',
- components: {
- BridgeEmptyState,
- BridgeSidebar,
- CiHeader,
- GlLoadingIcon,
- },
- inject: ['buildId', 'projectFullPath', 'pipelineIid'],
- apollo: {
- pipeline: {
- query: getPipelineQuery,
- variables() {
- return {
- fullPath: this.projectFullPath,
- iid: this.pipelineIid,
- };
- },
- update(data) {
- if (!data?.project?.pipeline) {
- return null;
- }
-
- const { pipeline } = data.project;
- const stages = pipeline?.stages.edges.map((edge) => edge.node) || [];
- const jobs = stages.map((stage) => stage.jobs.nodes).flat();
-
- return {
- ...pipeline,
- commit: {
- ...pipeline.commit,
- commit_path: pipeline.commit.webPath,
- short_id: pipeline.commit.shortId,
- },
- id: getIdFromGraphQLId(pipeline.id),
- jobs,
- stages,
- };
- },
- },
- },
- data() {
- return {
- isSidebarExpanded: true,
- pipeline: {},
- };
- },
- computed: {
- bridgeJob() {
- return (
- this.pipeline.jobs?.filter(
- (job) => getIdFromGraphQLId(job.id) === Number(this.buildId),
- )[0] || {}
- );
- },
- bridgeName() {
- return sprintf(__('Job %{jobName}'), { jobName: this.bridgeJob.name });
- },
- isPipelineLoading() {
- return this.$apollo.queries.pipeline.loading;
- },
- },
- 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>
- <div>
- <gl-loading-icon v-if="isPipelineLoading" size="lg" class="gl-mt-4" />
- <div v-else>
- <ci-header
- class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
- :status="bridgeJob.detailedStatus"
- :time="bridgeJob.createdAt"
- :user="pipeline.user"
- :has-sidebar-button="true"
- :item-name="bridgeName"
- @clickedSidebarButton="toggleSidebar"
- />
- <bridge-empty-state :downstream-pipeline-path="bridgeJob.downstreamPipeline.path" />
- <bridge-sidebar
- v-if="isSidebarExpanded"
- :bridge-job="bridgeJob"
- :commit="pipeline.commit"
- :is-sidebar-expanded="isSidebarExpanded"
- @toggleSidebar="toggleSidebar"
- />
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/jobs/bridge/components/constants.js b/app/assets/javascripts/jobs/bridge/components/constants.js
deleted file mode 100644
index 33310b3157a..00000000000
--- a/app/assets/javascripts/jobs/bridge/components/constants.js
+++ /dev/null
@@ -1 +0,0 @@
-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
deleted file mode 100644
index bd07d863719..00000000000
--- a/app/assets/javascripts/jobs/bridge/components/empty_state.vue
+++ /dev/null
@@ -1,45 +0,0 @@
-<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
deleted file mode 100644
index 3ba07cf55d1..00000000000
--- a/app/assets/javascripts/jobs/bridge/components/sidebar.vue
+++ /dev/null
@@ -1,105 +0,0 @@
-<script>
-import { GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { __ } from '~/locale';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
-import { JOB_SIDEBAR } from '../../constants';
-import CommitBlock from '../../components/commit_block.vue';
-
-export default {
- styles: {
- width: '290px',
- },
- name: 'BridgeSidebar',
- i18n: {
- ...JOB_SIDEBAR,
- retryButton: __('Retry'),
- retryTriggerJob: __('Retry the trigger job'),
- retryDownstreamPipeline: __('Retry the downstream pipeline'),
- },
- sectionClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100', 'gl-py-5'],
- components: {
- CommitBlock,
- GlButton,
- GlDropdown,
- GlDropdownItem,
- TooltipOnTruncate,
- },
- mixins: [glFeatureFlagsMixin()],
- props: {
- bridgeJob: {
- type: Object,
- required: true,
- },
- commit: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- topPosition: 0,
- };
- },
- computed: {
- rootStyle() {
- return { ...this.$options.styles, top: `${this.topPosition}px` };
- },
- },
- mounted() {
- this.setTopPosition();
- },
- methods: {
- onSidebarButtonClick() {
- this.$emit('toggleSidebar');
- },
- setTopPosition() {
- const navbarEl = document.querySelector('.js-navbar');
-
- if (navbarEl) {
- this.topPosition = navbarEl.getBoundingClientRect().bottom;
- }
- },
- },
-};
-</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="rootStyle"
- >
- <div class="gl-py-5 gl-display-flex gl-align-items-center">
- <tooltip-on-truncate :title="bridgeJob.name" truncate-target="child"
- ><h4 class="gl-mb-0 gl-mr-2 gl-text-truncate">
- {{ bridgeJob.name }}
- </h4>
- </tooltip-on-truncate>
- <!-- TODO: implement retry actions -->
- <div
- v-if="glFeatures.triggerJobRetryAction"
- 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="onSidebarButtonClick"
- />
- </div>
- <commit-block :commit="commit" :class="$options.sectionClass" />
- <!-- TODO: show stage dropdown, jobs list -->
- </aside>
-</template>
diff --git a/app/assets/javascripts/jobs/bridge/graphql/queries/pipeline.query.graphql b/app/assets/javascripts/jobs/bridge/graphql/queries/pipeline.query.graphql
deleted file mode 100644
index 338ca9f16c7..00000000000
--- a/app/assets/javascripts/jobs/bridge/graphql/queries/pipeline.query.graphql
+++ /dev/null
@@ -1,70 +0,0 @@
-query getPipelineData($fullPath: ID!, $iid: ID!) {
- project(fullPath: $fullPath) {
- id
- pipeline(iid: $iid) {
- id
- iid
- path
- sha
- ref
- refPath
- commit {
- id
- shortId
- title
- webPath
- }
- detailedStatus {
- id
- icon
- group
- }
- stages {
- edges {
- node {
- id
- name
- jobs {
- nodes {
- id
- createdAt
- name
- scheduledAt
- startedAt
- status
- triggered
- detailedStatus {
- id
- detailsPath
- icon
- group
- text
- tooltip
- }
- downstreamPipeline {
- id
- path
- }
- stage {
- id
- name
- }
- }
- }
- }
- }
- }
- user {
- id
- avatarUrl
- name
- username
- webPath
- webUrl
- status {
- message
- }
- }
- }
- }
-}
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue
index 396b015ad83..f9e6c64aad1 100644
--- a/app/assets/javascripts/jobs/components/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job_app.vue
@@ -68,6 +68,11 @@ export default {
default: null,
},
},
+ data() {
+ return {
+ searchResults: [],
+ };
+ },
computed: {
...mapState([
'isLoading',
@@ -184,6 +189,9 @@ export default {
this.throttled();
},
+ setSearchResults(searchResults) {
+ this.searchResults = searchResults;
+ },
},
};
</script>
@@ -279,10 +287,12 @@ export default {
:is-scroll-top-disabled="isScrollTopDisabled"
:is-job-log-size-visible="isJobLogSizeVisible"
:is-scrolling-down="isScrollingDown"
+ :job-log="jobLog"
@scrollJobLogTop="scrollTop"
@scrollJobLogBottom="scrollBottom"
+ @searchResults="setSearchResults"
/>
- <log :job-log="jobLog" :is-complete="isJobLogComplete" />
+ <log :job-log="jobLog" :is-complete="isJobLogComplete" :search-results="searchResults" />
</div>
<!-- EO job log -->
diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue
index eb6a284dfaf..5e89dd5acc2 100644
--- a/app/assets/javascripts/jobs/components/job_log_controllers.vue
+++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue
@@ -1,21 +1,34 @@
<script>
-import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui';
+import { GlTooltipDirective, GlLink, GlButton, GlSearchBoxByClick } from '@gitlab/ui';
+import { scrollToElement } from '~/lib/utils/common_utils';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { __, s__, sprintf } from '~/locale';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
i18n: {
scrollToBottomButtonLabel: s__('Job|Scroll to bottom'),
scrollToTopButtonLabel: s__('Job|Scroll to top'),
showRawButtonLabel: s__('Job|Show complete raw'),
+ searchPlaceholder: s__('Job|Search job log'),
+ noResults: s__('Job|No search results found'),
+ searchPopoverTitle: s__('Job|Job log search'),
+ searchPopoverDescription: s__(
+ 'Job|Search for substrings in your job log output. Currently search is only supported for the visible job log output, not for any log output that is truncated due to size.',
+ ),
+ logLineNumberNotFound: s__('Job|We could not find this element'),
},
components: {
GlLink,
GlButton,
+ GlSearchBoxByClick,
+ HelpPopover,
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagMixin()],
props: {
size: {
type: Number,
@@ -42,6 +55,16 @@ export default {
type: Boolean,
required: true,
},
+ jobLog: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ searchTerm: '',
+ searchResults: [],
+ };
},
computed: {
jobLogSize() {
@@ -49,6 +72,9 @@ export default {
size: numberToHumanSize(this.size),
});
},
+ showJobLogSearch() {
+ return this.glFeatures.jobLogSearch;
+ },
},
methods: {
handleScrollToTop() {
@@ -57,6 +83,54 @@ export default {
handleScrollToBottom() {
this.$emit('scrollJobLogBottom');
},
+ searchJobLog() {
+ this.searchResults = [];
+
+ if (!this.searchTerm) return;
+
+ const compactedLog = [];
+
+ this.jobLog.forEach((obj) => {
+ if (obj.lines && obj.lines.length > 0) {
+ compactedLog.push(...obj.lines);
+ }
+
+ if (!obj.lines && obj.content.length > 0) {
+ compactedLog.push(obj);
+ }
+ });
+
+ compactedLog.forEach((line) => {
+ const lineText = line.content[0].text;
+
+ if (lineText.toLocaleLowerCase().includes(this.searchTerm.toLocaleLowerCase())) {
+ this.searchResults.push(line);
+ }
+ });
+
+ if (this.searchResults.length > 0) {
+ this.$emit('searchResults', this.searchResults);
+
+ // BE returns zero based index, we need to add one to match the line numbers in the DOM
+ const firstSearchResult = `#L${this.searchResults[0].lineNumber + 1}`;
+ const logLine = document.querySelector(`.js-line ${firstSearchResult}`);
+
+ if (logLine) {
+ setTimeout(() => scrollToElement(logLine));
+
+ const message = sprintf(s__('Job|%{searchLength} results found for %{searchTerm}'), {
+ searchLength: this.searchResults.length,
+ searchTerm: this.searchTerm,
+ });
+
+ this.$toast.show(message);
+ } else {
+ this.$toast.show(this.$options.i18n.logLineNumberNotFound);
+ }
+ } else {
+ this.$toast.show(this.$options.i18n.noResults);
+ }
+ },
},
};
</script>
@@ -81,6 +155,25 @@ export default {
<!-- eo truncate information -->
<div class="controllers gl-float-right">
+ <template v-if="showJobLogSearch">
+ <gl-search-box-by-click
+ v-model="searchTerm"
+ class="gl-mr-3"
+ :placeholder="$options.i18n.searchPlaceholder"
+ data-testid="job-log-search-box"
+ @clear="$emit('searchResults', [])"
+ @submit="searchJobLog"
+ />
+
+ <help-popover class="gl-mr-3">
+ <template #title>{{ $options.i18n.searchPopoverTitle }}</template>
+
+ <p class="gl-mb-0">
+ {{ $options.i18n.searchPopoverDescription }}
+ </p>
+ </help-popover>
+ </template>
+
<!-- links -->
<gl-button
v-if="rawPath"
diff --git a/app/assets/javascripts/jobs/components/log/collapsible_section.vue b/app/assets/javascripts/jobs/components/log/collapsible_section.vue
index 757b2e458e9..13716b4d391 100644
--- a/app/assets/javascripts/jobs/components/log/collapsible_section.vue
+++ b/app/assets/javascripts/jobs/components/log/collapsible_section.vue
@@ -1,6 +1,4 @@
<script>
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF } from '../../constants';
import LogLine from './line.vue';
import LogLineHeader from './line_header.vue';
@@ -9,9 +7,7 @@ export default {
components: {
LogLine,
LogLineHeader,
- CollapsibleLogSection: () => import('./collapsible_section.vue'),
},
- mixins: [glFeatureFlagsMixin()],
props: {
section: {
type: Object,
@@ -21,14 +17,16 @@ export default {
type: String,
required: true,
},
+ searchResults: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
computed: {
badgeDuration() {
return this.section.line && this.section.line.section_duration;
},
- infinitelyCollapsibleSectionsFlag() {
- return this.glFeatures?.[INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF];
- },
},
methods: {
handleOnClickCollapsibleLine(section) {
@@ -47,26 +45,13 @@ export default {
@toggleLine="handleOnClickCollapsibleLine(section)"
/>
<template v-if="!section.isClosed">
- <template v-if="infinitelyCollapsibleSectionsFlag">
- <template v-for="line in section.lines">
- <collapsible-log-section
- v-if="line.isHeader"
- :key="line.line.offset"
- :section="line"
- :job-log-endpoint="jobLogEndpoint"
- @onClickCollapsibleLine="handleOnClickCollapsibleLine"
- />
- <log-line v-else :key="line.offset" :line="line" :path="jobLogEndpoint" />
- </template>
- </template>
- <template v-else>
- <log-line
- v-for="line in section.lines"
- :key="line.offset"
- :line="line"
- :path="jobLogEndpoint"
- />
- </template>
+ <log-line
+ v-for="line in section.lines"
+ :key="line.offset"
+ :line="line"
+ :path="jobLogEndpoint"
+ :search-results="searchResults"
+ />
</template>
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/log/line.vue b/app/assets/javascripts/jobs/components/log/line.vue
index 2d9714cd06b..36b350f4d64 100644
--- a/app/assets/javascripts/jobs/components/log/line.vue
+++ b/app/assets/javascripts/jobs/components/log/line.vue
@@ -14,9 +14,14 @@ export default {
type: String,
required: true,
},
+ searchResults: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
render(h, { props }) {
- const { line, path } = props;
+ const { line, path, searchResults } = props;
const chars = line.content.map((content) => {
return h(
@@ -46,15 +51,33 @@ export default {
);
});
- return h('div', { class: 'js-line log-line' }, [
- h(LineNumber, {
- props: {
- lineNumber: line.lineNumber,
- path,
- },
- }),
- ...chars,
- ]);
+ let applyHighlight = false;
+
+ if (searchResults.length > 0) {
+ const linesToHighlight = searchResults.map((searchResultLine) => searchResultLine.lineNumber);
+
+ linesToHighlight.forEach((num) => {
+ if (num === line.lineNumber) {
+ applyHighlight = true;
+ }
+ });
+ }
+
+ return h(
+ 'div',
+ {
+ class: ['js-line', 'log-line', applyHighlight ? 'gl-bg-gray-500' : ''],
+ },
+ [
+ h(LineNumber, {
+ props: {
+ lineNumber: line.lineNumber,
+ path,
+ },
+ }),
+ ...chars,
+ ],
+ );
},
};
</script>
diff --git a/app/assets/javascripts/jobs/components/log/line_number.vue b/app/assets/javascripts/jobs/components/log/line_number.vue
index c8ceac2c7ff..7ca9154d2fe 100644
--- a/app/assets/javascripts/jobs/components/log/line_number.vue
+++ b/app/assets/javascripts/jobs/components/log/line_number.vue
@@ -1,6 +1,4 @@
<script>
-import { INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF } from '../../constants';
-
export default {
functional: true,
props: {
@@ -16,9 +14,7 @@ export default {
render(h, { props }) {
const { lineNumber, path } = props;
- const parsedLineNumber = gon.features?.[INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF]
- ? lineNumber
- : lineNumber + 1;
+ const parsedLineNumber = lineNumber + 1;
const lineId = `L${parsedLineNumber}`;
const lineHref = `${path}#${lineId}`;
diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue
index ef95d79b8ab..9647582b81d 100644
--- a/app/assets/javascripts/jobs/components/log/log.vue
+++ b/app/assets/javascripts/jobs/components/log/log.vue
@@ -8,6 +8,13 @@ export default {
CollapsibleLogSection,
LogLine,
},
+ props: {
+ searchResults: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
computed: {
...mapState([
'jobLogEndpoint',
@@ -56,9 +63,16 @@ export default {
:key="`collapsible-${index}`"
:section="section"
:job-log-endpoint="jobLogEndpoint"
+ :search-results="searchResults"
@onClickCollapsibleLine="handleOnClickCollapsibleLine"
/>
- <log-line v-else :key="section.offset" :line="section" :path="jobLogEndpoint" />
+ <log-line
+ v-else
+ :key="section.offset"
+ :line="section"
+ :path="jobLogEndpoint"
+ :search-results="searchResults"
+ />
</template>
<div v-if="!isJobLogComplete" class="js-log-animation loader-animation pt-3 pl-3">
diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue
index cc099dba72f..a42e45ee7e4 100644
--- a/app/assets/javascripts/jobs/components/sidebar.vue
+++ b/app/assets/javascripts/jobs/components/sidebar.vue
@@ -89,7 +89,7 @@ export default {
<div class="blocks-container">
<div class="gl-py-5 gl-display-flex gl-align-items-center">
<tooltip-on-truncate :title="job.name" truncate-target="child"
- ><h4 class="my-0 mr-2 gl-text-truncate">
+ ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate">
{{ job.name }}
</h4>
</tooltip-on-truncate>
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 2ba531c9e95..15c4e503685 100644
--- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
+++ b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
@@ -42,14 +42,11 @@ export default {
this.job.duration ||
this.job.finished_at ||
this.job.erased_at ||
- this.job.queued ||
+ this.job.queued_duration ||
this.job.runner ||
this.job.coverage,
);
},
- queued() {
- return timeIntervalInWords(this.job.queued);
- },
runnerHelpUrl() {
return helpPagePath('ci/runners/configure_runners.html', {
anchor: 'set-maximum-job-timeout-for-a-runner',
@@ -60,6 +57,9 @@ export default {
return `#${id} (${token}) ${description}`;
},
+ queuedDuration() {
+ return timeIntervalInWords(this.job.queued_duration);
+ },
shouldRenderBlock() {
return Boolean(this.hasAnyDetail || this.hasTimeout || this.hasTags);
},
@@ -98,7 +98,7 @@ export default {
:title="$options.i18n.FINISHED"
/>
<detail-row v-if="job.erased_at" :value="erasedAt" :title="$options.i18n.ERASED" />
- <detail-row v-if="job.queued" :value="queued" :title="$options.i18n.QUEUED" />
+ <detail-row v-if="job.queued_duration" :value="queuedDuration" :title="$options.i18n.QUEUED" />
<detail-row
v-if="hasTimeout"
:help-url="runnerHelpUrl"
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 02aeb46a22b..6f351d91165 100644
--- a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
+++ b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
@@ -222,7 +222,7 @@ export default {
/>
<gl-button
v-else-if="isRetryable"
- icon="repeat"
+ icon="retry"
:title="$options.ACTIONS_RETRY"
:aria-label="$options.ACTIONS_RETRY"
:method="currentJobMethod"
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 f3ca958b3ca..5b1032c6448 100644
--- a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
+++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
@@ -1,8 +1,8 @@
-query getJobs($fullPath: ID!, $after: String, $statuses: [CiJobStatus!]) {
+query getJobs($fullPath: ID!, $after: String, $first: Int = 30, $statuses: [CiJobStatus!]) {
project(fullPath: $fullPath) {
id
__typename
- jobs(after: $after, first: 30, statuses: $statuses) {
+ jobs(after: $after, first: $first, statuses: $statuses) {
count
pageInfo {
endCursor
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table.vue b/app/assets/javascripts/jobs/components/table/jobs_table.vue
index f513d2090fa..d8c5c292f52 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table.vue
@@ -45,6 +45,7 @@ export default {
:fields="tableFields"
:tbody-tr-attr="{ 'data-testid': 'jobs-table-row' }"
:empty-text="$options.i18n.emptyText"
+ data-testid="jobs-table"
show-empty
stacked="lg"
fixed
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
index 1ac1a2d68e2..b3db5a94ac5 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
@@ -2,7 +2,6 @@
import { GlAlert, GlSkeletonLoader, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import createFlash from '~/flash';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import JobsFilteredSearch from '../filtered_search/jobs_filtered_search.vue';
import eventHub from './event_hub';
import GetJobs from './graphql/queries/get_jobs.query.graphql';
@@ -28,7 +27,6 @@ export default {
GlIntersectionObserver,
GlLoadingIcon,
},
- mixins: [glFeatureFlagMixin()],
inject: {
fullPath: {
default: '',
@@ -93,7 +91,7 @@ export default {
return this.loading && !this.showLoadingSpinner;
},
showFilteredSearch() {
- return this.glFeatures?.jobsTableVueSearch && !this.scope;
+ return !this.scope;
},
jobsCount() {
return this.jobs.count;
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue
index 27e3b8028b7..68c6c669a1a 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue
@@ -1,6 +1,7 @@
<script>
import { GlBadge, GlTab, GlTabs, GlLoadingIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
+import { limitedCounterWithDelimiter } from '~/lib/utils/text_utility';
export default {
components: {
@@ -29,7 +30,7 @@ export default {
return [
{
text: s__('Jobs|All'),
- count: this.allJobsCount,
+ count: limitedCounterWithDelimiter(this.allJobsCount),
scope: null,
testId: 'jobs-all-tab',
showBadge: true,
diff --git a/app/assets/javascripts/jobs/constants.js b/app/assets/javascripts/jobs/constants.js
index 97f31eee57c..3040d4e2379 100644
--- a/app/assets/javascripts/jobs/constants.js
+++ b/app/assets/javascripts/jobs/constants.js
@@ -24,5 +24,3 @@ export const JOB_RETRY_FORWARD_DEPLOYMENT_MODAL = {
};
export const SUCCESS_STATUS = 'SUCCESS';
-
-export const INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF = 'infinitelyCollapsibleSections';
diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js
index 26dd38bbe08..5c63ad96ad0 100644
--- a/app/assets/javascripts/jobs/index.js
+++ b/app/assets/javascripts/jobs/index.js
@@ -1,10 +1,10 @@
+import { GlToast } from '@gitlab/ui';
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';
+Vue.use(GlToast);
+
const initializeJobPage = (element) => {
const store = createStore();
@@ -51,43 +51,7 @@ const initializeJobPage = (element) => {
});
};
-const initializeBridgePage = (el) => {
- const {
- buildId,
- downstreamPipelinePath,
- emptyStateIllustrationPath,
- pipelineIid,
- projectFullPath,
- } = el.dataset;
-
- Vue.use(VueApollo);
- const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
- });
-
- return new Vue({
- el,
- apolloProvider,
- provide: {
- buildId,
- downstreamPipelinePath,
- emptyStateIllustrationPath,
- pipelineIid,
- projectFullPath,
- },
- 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);
- }
+ initializeJobPage(jobElement);
};
diff --git a/app/assets/javascripts/jobs/store/mutations.js b/app/assets/javascripts/jobs/store/mutations.js
index eda2ee0349a..87c00ad4d70 100644
--- a/app/assets/javascripts/jobs/store/mutations.js
+++ b/app/assets/javascripts/jobs/store/mutations.js
@@ -1,7 +1,6 @@
import Vue from 'vue';
-import { INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF } from '../constants';
import * as types from './mutation_types';
-import { logLinesParser, logLinesParserLegacy, updateIncrementalJobLog } from './utils';
+import { logLinesParser, updateIncrementalJobLog } from './utils';
export default {
[types.SET_JOB_ENDPOINT](state, endpoint) {
@@ -21,26 +20,12 @@ export default {
},
[types.RECEIVE_JOB_LOG_SUCCESS](state, log = {}) {
- const infinitelyCollapsibleSectionsFlag =
- gon.features?.[INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF];
if (log.state) {
state.jobLogState = log.state;
}
if (log.append) {
- if (infinitelyCollapsibleSectionsFlag) {
- if (log.lines) {
- const parsedResult = logLinesParser(
- log.lines,
- state.auxiliaryPartialJobLogHelpers,
- state.jobLog,
- );
- state.jobLog = parsedResult.parsedLines;
- state.auxiliaryPartialJobLogHelpers = parsedResult.auxiliaryPartialJobLogHelpers;
- }
- } else {
- state.jobLog = log.lines ? updateIncrementalJobLog(log.lines, state.jobLog) : state.jobLog;
- }
+ state.jobLog = log.lines ? updateIncrementalJobLog(log.lines, state.jobLog) : state.jobLog;
state.jobLogSize += log.size;
} else {
@@ -49,13 +34,7 @@ export default {
// html or size. We keep the old value otherwise these
// will be set to `null`
- if (infinitelyCollapsibleSectionsFlag) {
- const parsedResult = logLinesParser(log.lines);
- state.jobLog = parsedResult.parsedLines;
- state.auxiliaryPartialJobLogHelpers = parsedResult.auxiliaryPartialJobLogHelpers;
- } else {
- state.jobLog = log.lines ? logLinesParserLegacy(log.lines) : state.jobLog;
- }
+ state.jobLog = log.lines ? logLinesParser(log.lines) : state.jobLog;
state.jobLogSize = log.size || state.jobLogSize;
}
diff --git a/app/assets/javascripts/jobs/store/state.js b/app/assets/javascripts/jobs/store/state.js
index a1ba64aa71e..dfff65c364d 100644
--- a/app/assets/javascripts/jobs/store/state.js
+++ b/app/assets/javascripts/jobs/store/state.js
@@ -30,7 +30,4 @@ export default () => ({
selectedStage: '',
stages: [],
jobs: [],
-
- // to parse partial logs
- auxiliaryPartialJobLogHelpers: {},
});
diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js
index 7dfe24afa23..a7b95154c1b 100644
--- a/app/assets/javascripts/jobs/store/utils.js
+++ b/app/assets/javascripts/jobs/store/utils.js
@@ -104,7 +104,7 @@ export const getIncrementalLineNumber = (acc) => {
* @param Array accumulator
* @returns Array parsed log lines
*/
-export const logLinesParserLegacy = (lines = [], accumulator = []) =>
+export const logLinesParser = (lines = [], accumulator = []) =>
lines.reduce(
(acc, line, index) => {
const lineNumber = accumulator.length > 0 ? getIncrementalLineNumber(acc) : index;
@@ -131,82 +131,6 @@ export const logLinesParserLegacy = (lines = [], accumulator = []) =>
[...accumulator],
);
-export const logLinesParser = (lines = [], previousJobLogState = {}, prevParsedLines = []) => {
- let currentLineCount = previousJobLogState?.prevLineCount ?? 0;
- let currentHeader = previousJobLogState?.currentHeader;
- let isPreviousLineHeader = previousJobLogState?.isPreviousLineHeader ?? false;
- const parsedLines = prevParsedLines.length > 0 ? prevParsedLines : [];
- const sectionsQueue = previousJobLogState?.sectionsQueue ?? [];
-
- for (let i = 0; i < lines.length; i += 1) {
- const line = lines[i];
- // First run we can use the current index, later runs we have to retrieve the last number of lines
- currentLineCount = previousJobLogState?.prevLineCount ? currentLineCount + 1 : i + 1;
-
- if (line.section_header && !isPreviousLineHeader) {
- // If there's no previous line header that means we're at the root of the log
-
- isPreviousLineHeader = true;
- parsedLines.push(parseHeaderLine(line, currentLineCount));
- currentHeader = { index: parsedLines.length - 1 };
- } else if (line.section_header && isPreviousLineHeader) {
- // If there's a current section, we can't push to the parsedLines array
- sectionsQueue.push(currentHeader);
- currentHeader = parseHeaderLine(line, currentLineCount); // Let's parse the incoming header line
- } else if (line.section && !line.section_duration) {
- // We're inside a collapsible section and want to parse a standard line
- if (currentHeader?.index) {
- // If the current section header is only an index, add the line as part of the lines
- // array of the current collapsible section
- parsedLines[currentHeader.index].lines.push(parseLine(line, currentLineCount));
- } else {
- // Otherwise add it to the innermost collapsible section lines array
- currentHeader.lines.push(parseLine(line, currentLineCount));
- }
- } else if (line.section && line.section_duration) {
- // NOTE: This marks the end of a section_header
- const previousSection = sectionsQueue.pop();
-
- // Add the duration to section header
- // If at the root, just push the end to the current parsedLine,
- // otherwise, push it to the previous sections queue
- if (currentHeader?.index) {
- parsedLines[currentHeader.index].line.section_duration = line.section_duration;
- isPreviousLineHeader = false;
- currentHeader = null;
- } else if (currentHeader?.isHeader) {
- currentHeader.line.section_duration = line.section_duration;
-
- if (previousSection && previousSection?.index) {
- // Is the previous section on root?
- parsedLines[previousSection.index].lines.push(currentHeader);
- } else if (previousSection && !previousSection?.index) {
- previousSection.lines.push(currentHeader);
- }
-
- currentHeader = previousSection;
- } else {
- // On older job logs, there's no `section_header: true` response, it's just an object
- // with the `section_duration` and `section` props, so we just parse it
- // as a standard line
- parsedLines.push(parseLine(line, currentLineCount));
- }
- } else {
- parsedLines.push(parseLine(line, currentLineCount));
- }
- }
-
- return {
- parsedLines,
- auxiliaryPartialJobLogHelpers: {
- isPreviousLineHeader,
- currentHeader,
- sectionsQueue,
- prevLineCount: currentLineCount,
- },
- };
-};
-
/**
* Finds the repeated offset, removes the old one
*
@@ -253,5 +177,5 @@ export const findOffsetAndRemove = (newLog = [], oldParsed = []) => {
export const updateIncrementalJobLog = (newLog = [], oldParsed = []) => {
const parsedLog = findOffsetAndRemove(newLog, oldParsed);
- return logLinesParserLegacy(newLog, parsedLog);
+ return logLinesParser(newLog, parsedLog);
};
diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js
index 4959550e273..a01c6df0003 100644
--- a/app/assets/javascripts/lib/dompurify.js
+++ b/app/assets/javascripts/lib/dompurify.js
@@ -8,6 +8,7 @@ const defaultConfig = {
// See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1421
FORBID_ATTR: ['data-remote', 'data-url', 'data-type', 'data-method'],
FORBID_TAGS: ['style', 'mstyle'],
+ ALLOW_UNKNOWN_PROTOCOLS: true,
};
// Only icons urls from `gon` are allowed
diff --git a/app/assets/javascripts/lib/gfm/index.js b/app/assets/javascripts/lib/gfm/index.js
index b4f941294de..92118c8929f 100644
--- a/app/assets/javascripts/lib/gfm/index.js
+++ b/app/assets/javascripts/lib/gfm/index.js
@@ -1,30 +1,34 @@
+import { pick } from 'lodash';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import remarkRehype, { all } from 'remark-rehype';
import rehypeRaw from 'rehype-raw';
-const createParser = () => {
+const skipRenderingHandlers = {
+ footnoteReference: (h, node) =>
+ h(node.position, 'footnoteReference', { identifier: node.identifier, label: node.label }, []),
+ footnoteDefinition: (h, node) =>
+ h(
+ node.position,
+ 'footnoteDefinition',
+ { identifier: node.identifier, label: node.label },
+ all(h, node),
+ ),
+ code: (h, node) =>
+ h(node.position, 'codeBlock', { language: node.lang, meta: node.meta }, [
+ { type: 'text', value: node.value },
+ ]),
+};
+
+const createParser = ({ skipRendering = [] }) => {
return unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, {
allowDangerousHtml: true,
handlers: {
- footnoteReference: (h, node) =>
- h(
- node.position,
- 'footnoteReference',
- { identifier: node.identifier, label: node.label },
- [],
- ),
- footnoteDefinition: (h, node) =>
- h(
- node.position,
- 'footnoteDefinition',
- { identifier: node.identifier, label: node.label },
- all(h, node),
- ),
+ ...pick(skipRenderingHandlers, skipRendering),
},
})
.use(rehypeRaw);
@@ -54,8 +58,10 @@ const compilerFactory = (renderer) =>
* @returns {Promise<any>} Returns a promise with the result of rendering
* the MDast tree
*/
-export const render = async ({ markdown, renderer }) => {
- const { result } = await createParser().use(compilerFactory(renderer)).process(markdown);
+export const render = async ({ markdown, renderer, skipRendering = [] }) => {
+ const { result } = await createParser({ skipRendering })
+ .use(compilerFactory(renderer))
+ .process(markdown);
return result;
};
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 1ed0cc3130b..7925a10344a 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -11,6 +11,8 @@ import { convertToCamelCase, convertToSnakeCase } from './text_utility';
import { isObject } from './type_utility';
import { getLocationHash } from './url_utility';
+export const NO_SCROLL_TO_HASH_CLASS = 'js-no-scroll-to-hash';
+
export const getPagePath = (index = 0) => {
const { page = '' } = document.body.dataset;
return page.split(':')[index];
@@ -68,6 +70,10 @@ export const handleLocationHash = () => {
hash = decodeURIComponent(hash);
const target = document.getElementById(hash) || document.getElementById(`user-content-${hash}`);
+
+ // Allow targets to opt out of scroll behavior
+ if (target?.classList.contains(NO_SCROLL_TO_HASH_CLASS)) return;
+
const fixedTabs = document.querySelector('.js-tabs-affix');
const fixedDiffStats = document.querySelector('.js-diff-files-changed');
const fixedNav = document.querySelector('.navbar-gitlab');
@@ -585,8 +591,7 @@ export const addSelectOnFocusBehaviour = (selector = '.js-select-on-focus') => {
* @param {Number} precision
*/
export const roundOffFloat = (number, precision = 0) => {
- // eslint-disable-next-line no-restricted-properties
- const multiplier = Math.pow(10, precision);
+ const multiplier = 10 ** precision;
return Math.round(number * multiplier) / multiplier;
};
@@ -616,8 +621,7 @@ export const roundToNearestHalf = (num) => Math.round(num * 2).toFixed() / 2;
* @param {Number} precision
*/
export const roundDownFloat = (number, precision = 0) => {
- // eslint-disable-next-line no-restricted-properties
- const multiplier = Math.pow(10, precision);
+ const multiplier = 10 ** precision;
return Math.floor(number * multiplier) / multiplier;
};
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index dad9cbcb6f6..7b00995b2e5 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -498,3 +498,17 @@ export const markdownConfig = {
* escaped to `'fix-'\''bug-behavior'\'''`.
*/
export const escapeShellString = (str) => `'${str.replace(allSingleQuotes, () => "'\\''")}'`;
+
+/**
+ * Adds plus character as delimiter for count
+ * if count is greater than limit of 1000
+ * FE creation of `app/helpers/numbers_helper.rb`
+ *
+ * @param {Number} count
+ * @return {Number|String}
+ */
+export const limitedCounterWithDelimiter = (count) => {
+ const limit = 1000;
+
+ return count > limit ? '1,000+' : count;
+};
diff --git a/app/assets/javascripts/linked_resources/index.js b/app/assets/javascripts/linked_resources/index.js
new file mode 100644
index 00000000000..244adca86c9
--- /dev/null
+++ b/app/assets/javascripts/linked_resources/index.js
@@ -0,0 +1,28 @@
+import Vue from 'vue';
+import ResourceLinksBlock from 'ee_component/linked_resources/components/resource_links_block.vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+
+export default function initLinkedResources() {
+ const linkedResourcesRootElement = document.querySelector('.js-linked-resources-root');
+
+ if (linkedResourcesRootElement) {
+ const { issuableId, canAddResourceLinks, helpPath } = linkedResourcesRootElement.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: linkedResourcesRootElement,
+ name: 'LinkedResourcesRoot',
+ components: {
+ resourceLinksBlock: ResourceLinksBlock,
+ },
+ render: (createElement) =>
+ createElement('resource-links-block', {
+ props: {
+ issuableId,
+ helpPath,
+ canAddResourceLinks: parseBoolean(canAddResourceLinks),
+ },
+ }),
+ });
+ }
+}
diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue
deleted file mode 100644
index 609592edc3b..00000000000
--- a/app/assets/javascripts/logs/components/environment_logs.vue
+++ /dev/null
@@ -1,280 +0,0 @@
-<script>
-import {
- GlSprintf,
- GlAlert,
- GlLink,
- GlDropdown,
- GlDropdownSectionHeader,
- GlDropdownItem,
- GlInfiniteScroll,
-} from '@gitlab/ui';
-import { throttle } from 'lodash';
-import { mapActions, mapState, mapGetters } from 'vuex';
-
-import { timeRangeFromUrl } from '~/monitoring/utils';
-import { defaultTimeRange } from '~/vue_shared/constants';
-import { formatDate } from '../utils';
-import LogAdvancedFilters from './log_advanced_filters.vue';
-import LogControlButtons from './log_control_buttons.vue';
-import LogSimpleFilters from './log_simple_filters.vue';
-
-export default {
- components: {
- GlSprintf,
- GlLink,
- GlAlert,
- GlDropdown,
- GlDropdownSectionHeader,
- GlDropdownItem,
- GlInfiniteScroll,
- LogSimpleFilters,
- LogAdvancedFilters,
- LogControlButtons,
- },
- props: {
- environmentName: {
- type: String,
- required: false,
- default: '',
- },
- currentPodName: {
- type: [String, null],
- required: false,
- default: null,
- },
- environmentsPath: {
- type: String,
- required: false,
- default: '',
- },
- clusterApplicationsDocumentationPath: {
- type: String,
- required: true,
- },
- clustersPath: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- isElasticStackCalloutDismissed: false,
- scrollDownButtonDisabled: true,
- isDeprecationNoticeDismissed: false,
- };
- },
- computed: {
- ...mapState('environmentLogs', ['environments', 'timeRange', 'logs', 'pods']),
- ...mapGetters('environmentLogs', ['trace', 'showAdvancedFilters']),
-
- showLoader() {
- return this.logs.isLoading;
- },
- shouldShowElasticStackCallout() {
- return !(
- this.environments.isLoading ||
- this.isElasticStackCalloutDismissed ||
- this.showAdvancedFilters
- );
- },
- },
- mounted() {
- this.setInitData({
- timeRange: timeRangeFromUrl() || defaultTimeRange,
- environmentName: this.environmentName,
- podName: this.currentPodName,
- });
-
- this.fetchEnvironments(this.environmentsPath);
- },
- methods: {
- ...mapActions('environmentLogs', [
- 'setInitData',
- 'showEnvironment',
- 'fetchEnvironments',
- 'refreshPodLogs',
- 'fetchMoreLogsPrepend',
- 'dismissRequestEnvironmentsError',
- 'dismissInvalidTimeRangeWarning',
- 'dismissRequestLogsError',
- ]),
-
- isCurrentEnvironment(envName) {
- return envName === this.environments.current;
- },
- topReached() {
- if (!this.logs.isLoading) {
- this.fetchMoreLogsPrepend();
- }
- },
- scrollDown() {
- this.$refs.infiniteScroll.scrollDown();
- },
- scroll: throttle(function scrollThrottled({ target = {} }) {
- const { scrollTop = 0, clientHeight = 0, scrollHeight = 0 } = target;
- this.scrollDownButtonDisabled = scrollTop + clientHeight === scrollHeight;
- }, 200),
- formatDate,
- },
-};
-</script>
-<template>
- <div class="environment-logs-viewer d-flex flex-column py-3">
- <gl-alert
- v-if="shouldShowElasticStackCallout"
- ref="elasticsearchNotice"
- class="mb-3"
- @dismiss="isElasticStackCalloutDismissed = true"
- >
- {{
- s__(
- 'Environments|Install Elastic Stack on your cluster to enable advanced querying capabilities such as full text search.',
- )
- }}
- <a :href="clusterApplicationsDocumentationPath">
- <strong>
- {{ __('View Documentation') }}
- </strong>
- </a>
- </gl-alert>
- <gl-alert
- v-if="environments.fetchError"
- class="mb-3"
- variant="danger"
- @dismiss="dismissRequestEnvironmentsError"
- >
- {{ s__('Metrics|There was an error fetching the environments data, please try again') }}
- </gl-alert>
- <gl-alert
- v-if="timeRange.invalidWarning"
- class="mb-3"
- variant="warning"
- @dismiss="dismissInvalidTimeRangeWarning"
- >
- {{ s__('Metrics|Invalid time range, please verify.') }}
- </gl-alert>
- <gl-alert
- v-if="!isDeprecationNoticeDismissed"
- :title="s__('Deprecations|Feature deprecation and removal')"
- class="mb-3"
- variant="danger"
- @dismiss="isDeprecationNoticeDismissed = true"
- >
- <gl-sprintf
- :message="
- s__(
- 'Deprecations|The logs and tracing features were deprecated in GitLab 14.7 and are %{epicStart} scheduled for removal %{epicEnd} in GitLab 15.0.',
- )
- "
- >
- <template #epic="{ content }">
- <gl-link href="https://gitlab.com/groups/gitlab-org/-/epics/7188" target="_blank">{{
- content
- }}</gl-link>
- </template>
- </gl-sprintf>
-
- <gl-sprintf
- :message="
- s__(
- 'Deprecations|For information on a possible replacement %{epicStart} learn more about Opstrace %{epicEnd}.',
- )
- "
- >
- <template #epic="{ content }">
- <gl-link href="https://gitlab.com/groups/gitlab-org/-/epics/6976" target="_blank">{{
- content
- }}</gl-link>
- </template>
- </gl-sprintf>
- </gl-alert>
- <gl-alert
- v-if="logs.fetchError"
- class="mb-3"
- variant="danger"
- @dismiss="dismissRequestLogsError"
- >
- {{ s__('Environments|There was an error fetching the logs. Please try again.') }}
- </gl-alert>
-
- <div class="top-bar d-md-flex border bg-secondary-50 pt-2 pr-1 pb-0 pl-2">
- <div class="flex-grow-0">
- <gl-dropdown
- id="environments-dropdown"
- :text="environments.current"
- :disabled="environments.isLoading"
- class="gl-mr-3 gl-mb-3 gl-display-flex gl-md-display-block js-environments-dropdown"
- >
- <gl-dropdown-section-header>
- {{ s__('Environments|Environments') }}
- </gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="env in environments.options"
- :key="env.id"
- :is-check-item="true"
- :is-checked="isCurrentEnvironment(env.name)"
- @click="showEnvironment(env.name)"
- >
- {{ env.name }}
- </gl-dropdown-item>
- </gl-dropdown>
- </div>
-
- <log-advanced-filters
- v-if="showAdvancedFilters"
- ref="log-advanced-filters"
- class="d-md-flex flex-grow-1 min-width-0"
- :disabled="environments.isLoading"
- />
- <log-simple-filters
- v-else
- ref="log-simple-filters"
- class="d-md-flex flex-grow-1 min-width-0"
- :disabled="environments.isLoading"
- />
-
- <log-control-buttons
- ref="scrollButtons"
- class="flex-grow-0 pr-2 mb-2 controllers gl-display-inline-flex"
- :scroll-down-button-disabled="scrollDownButtonDisabled"
- @refresh="refreshPodLogs()"
- @scrollDown="scrollDown"
- />
- </div>
-
- <gl-infinite-scroll
- ref="infiniteScroll"
- class="log-lines overflow-auto flex-grow-1 min-height-0"
- :fetched-items="logs.lines.length"
- @topReached="topReached"
- @scroll="scroll"
- >
- <template #items>
- <pre
- ref="logTrace"
- class="build-log"
- ><code class="bash js-build-output"><div v-if="showLoader" class="build-loader-animation js-build-loader-animation">
- <div class="dot"></div>
- <div class="dot"></div>
- <div class="dot"></div>
- </div>{{trace}}
- </code></pre>
- </template>
- <template #default><div></div></template>
- </gl-infinite-scroll>
-
- <div ref="logFooter" class="py-2 px-3 text-white bg-secondary-900">
- <gl-sprintf :message="s__('Environments|Logs from %{start} to %{end}.')">
- <template #start>{{ formatDate(timeRange.current.start) }}</template>
- <template #end>{{ formatDate(timeRange.current.end) }}</template>
- </gl-sprintf>
- <gl-sprintf
- v-if="!logs.isComplete"
- :message="s__('Environments|Currently showing %{fetched} results.')"
- >
- <template #fetched>{{ logs.lines.length }}</template>
- </gl-sprintf>
- <template v-else> {{ s__('Environments|Currently showing all results.') }}</template>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/logs/components/log_advanced_filters.vue b/app/assets/javascripts/logs/components/log_advanced_filters.vue
deleted file mode 100644
index c6d7c9ad1dc..00000000000
--- a/app/assets/javascripts/logs/components/log_advanced_filters.vue
+++ /dev/null
@@ -1,99 +0,0 @@
-<script>
-import { GlFilteredSearch } from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
-import { s__ } from '~/locale';
-import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
-import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
-import { timeRanges } from '~/vue_shared/constants';
-import { TOKEN_TYPE_POD_NAME } from '../constants';
-import TokenWithLoadingState from './tokens/token_with_loading_state.vue';
-
-export default {
- components: {
- GlFilteredSearch,
- DateTimePicker,
- },
- props: {
- disabled: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- timeRanges,
- };
- },
- computed: {
- ...mapState('environmentLogs', ['timeRange', 'pods', 'logs']),
-
- timeRangeModel: {
- get() {
- return this.timeRange.selected;
- },
- set(val) {
- this.setTimeRange(val);
- },
- },
- /**
- * Token options.
- *
- * Returns null when no pods are present, so suggestions are displayed in the token
- */
- podOptions() {
- if (this.pods.options.length) {
- return this.pods.options.map((podName) => ({ value: podName, title: podName }));
- }
- return null;
- },
-
- tokens() {
- return [
- {
- icon: 'pod',
- type: TOKEN_TYPE_POD_NAME,
- title: s__('Environments|Pod name'),
- token: TokenWithLoadingState,
- operators: OPERATOR_IS_ONLY,
- unique: true,
- options: this.podOptions,
- loading: this.logs.isLoading,
- noOptionsText: s__('Environments|No pods to display'),
- },
- ];
- },
- },
- methods: {
- ...mapActions('environmentLogs', ['showFilteredLogs', 'setTimeRange']),
-
- filteredSearchSubmit(filters) {
- this.showFilteredLogs(filters);
- },
- },
-};
-</script>
-<template>
- <div>
- <div class="mb-2 pr-2 flex-grow-1 min-width-0">
- <gl-filtered-search
- :placeholder="__('Search')"
- :clear-button-title="__('Clear')"
- :close-button-title="__('Close')"
- class="gl-h-32"
- :disabled="disabled || logs.isLoading"
- :available-tokens="tokens"
- @submit="filteredSearchSubmit"
- />
- </div>
-
- <date-time-picker
- ref="dateTimePicker"
- v-model="timeRangeModel"
- :disabled="disabled"
- :options="timeRanges"
- class="mb-2 gl-h-32 pr-2 d-block date-time-picker-wrapper"
- right
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/logs/components/log_control_buttons.vue b/app/assets/javascripts/logs/components/log_control_buttons.vue
deleted file mode 100644
index e44b5394fa1..00000000000
--- a/app/assets/javascripts/logs/components/log_control_buttons.vue
+++ /dev/null
@@ -1,95 +0,0 @@
-<script>
-import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-
-export default {
- components: {
- GlButton,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- scrollUpButtonDisabled: {
- type: Boolean,
- required: false,
- default: false,
- },
- scrollDownButtonDisabled: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- scrollUpAvailable: Boolean(this.$listeners.scrollUp),
- scrollDownAvailable: Boolean(this.$listeners.scrollDown),
- };
- },
- methods: {
- handleRefreshClick() {
- this.$emit('refresh');
- },
- handleScrollUp() {
- this.$emit('scrollUp');
- },
- handleScrollDown() {
- this.$emit('scrollDown');
- },
- },
-};
-</script>
-
-<template>
- <div>
- <div
- v-if="scrollUpAvailable"
- v-gl-tooltip
- class="controllers-buttons"
- :title="__('Scroll to top')"
- aria-labelledby="scroll-to-top"
- >
- <gl-button
- id="scroll-to-top"
- class="js-scroll-to-top gl-mr-2 btn-blank"
- :aria-label="__('Scroll to top')"
- :disabled="scrollUpButtonDisabled"
- icon="scroll_up"
- category="primary"
- variant="default"
- @click="handleScrollUp()"
- />
- </div>
- <div
- v-if="scrollDownAvailable"
- v-gl-tooltip
- :disabled="scrollUpButtonDisabled"
- class="controllers-buttons"
- :title="__('Scroll to bottom')"
- aria-labelledby="scroll-to-bottom"
- >
- <gl-button
- id="scroll-to-bottom"
- class="js-scroll-to-bottom gl-mr-2 btn-blank"
- :aria-label="__('Scroll to bottom')"
- :v-if="scrollDownAvailable"
- :disabled="scrollDownButtonDisabled"
- icon="scroll_down"
- category="primary"
- variant="default"
- @click="handleScrollDown()"
- />
- </div>
- <gl-button
- id="refresh-log"
- v-gl-tooltip
- class="js-refresh-log"
- :title="__('Refresh')"
- :aria-label="__('Refresh')"
- icon="retry"
- category="primary"
- variant="default"
- @click="handleRefreshClick"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/logs/components/log_simple_filters.vue b/app/assets/javascripts/logs/components/log_simple_filters.vue
deleted file mode 100644
index 55bdd5f0088..00000000000
--- a/app/assets/javascripts/logs/components/log_simple_filters.vue
+++ /dev/null
@@ -1,68 +0,0 @@
-<script>
-import { GlDropdown, GlDropdownSectionHeader, GlDropdownItem } from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
-import { s__ } from '~/locale';
-
-export default {
- components: {
- GlDropdown,
- GlDropdownSectionHeader,
- GlDropdownItem,
- },
- props: {
- disabled: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- searchQuery: '',
- };
- },
- computed: {
- ...mapState('environmentLogs', ['pods']),
-
- podDropdownText() {
- return this.pods.current || s__('Environments|No pod selected');
- },
- },
- methods: {
- ...mapActions('environmentLogs', ['showPodLogs']),
- isCurrentPod(podName) {
- return podName === this.pods.current;
- },
- },
-};
-</script>
-<template>
- <div>
- <gl-dropdown
- ref="podsDropdown"
- :text="podDropdownText"
- :disabled="disabled"
- class="gl-mr-3 gl-mb-3 gl-display-flex gl-md-display-block qa-pods-dropdown"
- >
- <gl-dropdown-section-header>
- {{ s__('Environments|Select pod') }}
- </gl-dropdown-section-header>
-
- <gl-dropdown-item v-if="!pods.options.length" disabled>
- <span ref="noPodsMsg" class="text-muted">
- {{ s__('Environments|No pods to display') }}
- </span>
- </gl-dropdown-item>
- <gl-dropdown-item
- v-for="podName in pods.options"
- :key="podName"
- :is-check-item="true"
- :is-checked="isCurrentPod(podName)"
- class="text-nowrap"
- @click="showPodLogs(podName)"
- >
- {{ podName }}
- </gl-dropdown-item>
- </gl-dropdown>
- </div>
-</template>
diff --git a/app/assets/javascripts/logs/components/tokens/token_with_loading_state.vue b/app/assets/javascripts/logs/components/tokens/token_with_loading_state.vue
deleted file mode 100644
index 4e672c1d121..00000000000
--- a/app/assets/javascripts/logs/components/tokens/token_with_loading_state.vue
+++ /dev/null
@@ -1,30 +0,0 @@
-<script>
-import { GlFilteredSearchToken, GlLoadingIcon } from '@gitlab/ui';
-
-export default {
- components: {
- GlFilteredSearchToken,
- GlLoadingIcon,
- },
- inheritAttrs: false,
- props: {
- config: {
- type: Object,
- required: true,
- },
- },
-};
-</script>
-
-<template>
- <gl-filtered-search-token :config="config" v-bind="{ ...$attrs }" v-on="$listeners">
- <template #suggestions>
- <div class="m-1">
- <gl-loading-icon v-if="config.loading" size="sm" />
- <div v-else class="py-1 px-2 text-muted">
- {{ config.noOptionsText }}
- </div>
- </div>
- </template>
- </gl-filtered-search-token>
-</template>
diff --git a/app/assets/javascripts/logs/constants.js b/app/assets/javascripts/logs/constants.js
deleted file mode 100644
index abc4d6679a0..00000000000
--- a/app/assets/javascripts/logs/constants.js
+++ /dev/null
@@ -1,16 +0,0 @@
-export const dateFormatMask = 'mmm dd HH:MM:ss.l';
-
-export const TOKEN_TYPE_POD_NAME = 'TOKEN_TYPE_POD_NAME';
-
-export const tracking = {
- USED_SEARCH_BAR: 'used_search_bar',
- POD_LOG_CHANGED: 'pod_log_changed',
- TIME_RANGE_SET: 'time_range_set',
- ENVIRONMENT_SELECTED: 'environment_selected',
- REFRESH_POD_LOGS: 'refresh_pod_logs',
- MANAGED_APP_SELECTED: 'managed_app_selected',
-};
-
-export const logExplorerOptions = {
- environments: 'environments',
-};
diff --git a/app/assets/javascripts/logs/index.js b/app/assets/javascripts/logs/index.js
deleted file mode 100644
index 70dbffdc3dd..00000000000
--- a/app/assets/javascripts/logs/index.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import Vue from 'vue';
-import { getParameterValues } from '~/lib/utils/url_utility';
-import LogViewer from './components/environment_logs.vue';
-import store from './stores';
-
-export default (props = {}) => {
- const el = document.getElementById('environment-logs');
- const [currentPodName] = getParameterValues('pod_name');
-
- // eslint-disable-next-line no-new
- new Vue({
- el,
- store,
- render(createElement) {
- return createElement(LogViewer, {
- props: {
- ...el.dataset,
- currentPodName,
- ...props,
- },
- });
- },
- });
-};
diff --git a/app/assets/javascripts/logs/logs_tracking_helper.js b/app/assets/javascripts/logs/logs_tracking_helper.js
deleted file mode 100644
index 26043d646b0..00000000000
--- a/app/assets/javascripts/logs/logs_tracking_helper.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import Tracking from '~/tracking';
-
-/**
- * The value of 1 in count, means there was one action performed
- * related to the tracked action, in either of the following categories
- * 1. Refreshing the logs
- * 2. Select an environment
- * 3. Change the time range
- * 4. Use the search bar
- */
-const trackLogs = (label) =>
- Tracking.event(document.body.dataset.page, 'logs_view', {
- label,
- property: 'count',
- value: 1,
- });
-
-export default trackLogs;
diff --git a/app/assets/javascripts/logs/stores/actions.js b/app/assets/javascripts/logs/stores/actions.js
deleted file mode 100644
index 56b832de9b8..00000000000
--- a/app/assets/javascripts/logs/stores/actions.js
+++ /dev/null
@@ -1,174 +0,0 @@
-import axios from '~/lib/utils/axios_utils';
-import { backOff } from '~/lib/utils/common_utils';
-import { convertToFixedRange } from '~/lib/utils/datetime_range';
-import httpStatusCodes from '~/lib/utils/http_status';
-import { TOKEN_TYPE_POD_NAME, tracking, logExplorerOptions } from '../constants';
-import trackLogs from '../logs_tracking_helper';
-
-import * as types from './mutation_types';
-
-const requestUntilData = (url, params) =>
- backOff((next, stop) => {
- axios
- .get(url, { params })
- .then((res) => {
- if (res.status === httpStatusCodes.ACCEPTED) {
- next();
- return;
- }
- stop(res);
- })
- .catch((err) => {
- stop(err);
- });
- });
-
-const requestLogsUntilData = ({ commit, state }) => {
- const params = {};
- const type = logExplorerOptions.environments;
- const selectedObj = state[type].options.find(({ name }) => name === state[type].current);
- const path = selectedObj.logs_api_path;
-
- if (state.pods.current) {
- params.pod_name = state.pods.current;
- }
- if (state.search) {
- params.search = state.search;
- }
- if (state.timeRange.current) {
- try {
- const { start, end } = convertToFixedRange(state.timeRange.current);
- params.start_time = start;
- params.end_time = end;
- } catch {
- commit(types.SHOW_TIME_RANGE_INVALID_WARNING);
- }
- }
- if (state.logs.cursor) {
- params.cursor = state.logs.cursor;
- }
-
- return requestUntilData(path, params);
-};
-
-/**
- * Converts filters emitted by the component, e.g. a filterered-search
- * to parameters to be applied to the filters of the store
- * @param {Array} filters - List of strings or objects to filter by.
- * @returns {Object} - An object with `search` and `podName` keys.
- */
-const filtersToParams = (filters = []) => {
- // Strings become part of the `search`
- const search = filters
- .filter((f) => typeof f === 'string')
- .join(' ')
- .trim();
-
- // null podName to show all pods
- const podName = filters.find((f) => f?.type === TOKEN_TYPE_POD_NAME)?.value?.data ?? null;
-
- return { search, podName };
-};
-
-export const setInitData = ({ commit }, { timeRange, environmentName, podName }) => {
- commit(types.SET_TIME_RANGE, timeRange);
- commit(types.SET_PROJECT_ENVIRONMENT, environmentName);
- commit(types.SET_CURRENT_POD_NAME, podName);
-};
-
-export const showFilteredLogs = ({ dispatch, commit }, filters = []) => {
- const { podName, search } = filtersToParams(filters);
-
- commit(types.SET_CURRENT_POD_NAME, podName);
- commit(types.SET_SEARCH, search);
-
- dispatch('fetchLogs', tracking.USED_SEARCH_BAR);
-};
-
-export const showPodLogs = ({ dispatch, commit }, podName) => {
- commit(types.SET_CURRENT_POD_NAME, podName);
- dispatch('fetchLogs', tracking.POD_LOG_CHANGED);
-};
-
-export const setTimeRange = ({ dispatch, commit }, timeRange) => {
- commit(types.SET_TIME_RANGE, timeRange);
- dispatch('fetchLogs', tracking.TIME_RANGE_SET);
-};
-
-export const showEnvironment = ({ dispatch, commit }, environmentName) => {
- commit(types.SET_PROJECT_ENVIRONMENT, environmentName);
- dispatch('fetchLogs', tracking.ENVIRONMENT_SELECTED);
-};
-
-export const refreshPodLogs = ({ dispatch, commit }) => {
- commit(types.REFRESH_POD_LOGS);
- dispatch('fetchLogs', tracking.REFRESH_POD_LOGS);
-};
-
-/**
- * Fetch environments data and initial logs
- * @param {Object} store
- * @param {String} environmentsPath
- */
-export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => {
- commit(types.REQUEST_ENVIRONMENTS_DATA);
-
- return axios
- .get(environmentsPath)
- .then(({ data }) => {
- commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data);
- dispatch('fetchLogs', tracking.ENVIRONMENT_SELECTED);
- })
- .catch(() => {
- commit(types.RECEIVE_ENVIRONMENTS_DATA_ERROR);
- });
-};
-
-export const fetchLogs = ({ commit, state }, trackingLabel) => {
- commit(types.REQUEST_LOGS_DATA);
-
- return requestLogsUntilData({ commit, state })
- .then(({ data }) => {
- const { pod_name, pods, logs, cursor } = data;
- if (logs && logs.length > 0) {
- trackLogs(trackingLabel);
- }
- commit(types.RECEIVE_LOGS_DATA_SUCCESS, { logs, cursor });
- commit(types.SET_CURRENT_POD_NAME, pod_name);
- commit(types.RECEIVE_PODS_DATA_SUCCESS, pods);
- })
- .catch(() => {
- commit(types.RECEIVE_PODS_DATA_ERROR);
- commit(types.RECEIVE_LOGS_DATA_ERROR);
- });
-};
-
-export const fetchMoreLogsPrepend = ({ commit, state }) => {
- if (state.logs.isComplete) {
- // return when all logs are loaded
- return Promise.resolve();
- }
-
- commit(types.REQUEST_LOGS_DATA_PREPEND);
-
- return requestLogsUntilData({ commit, state })
- .then(({ data }) => {
- const { logs, cursor } = data;
- commit(types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS, { logs, cursor });
- })
- .catch(() => {
- commit(types.RECEIVE_LOGS_DATA_PREPEND_ERROR);
- });
-};
-
-export const dismissRequestEnvironmentsError = ({ commit }) => {
- commit(types.HIDE_REQUEST_ENVIRONMENTS_ERROR);
-};
-
-export const dismissRequestLogsError = ({ commit }) => {
- commit(types.HIDE_REQUEST_LOGS_ERROR);
-};
-
-export const dismissInvalidTimeRangeWarning = ({ commit }) => {
- commit(types.HIDE_TIME_RANGE_INVALID_WARNING);
-};
diff --git a/app/assets/javascripts/logs/stores/getters.js b/app/assets/javascripts/logs/stores/getters.js
deleted file mode 100644
index bf71cfd8eb2..00000000000
--- a/app/assets/javascripts/logs/stores/getters.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { formatDate } from '../utils';
-
-const mapTrace = ({ timestamp = null, pod = '', message = '' }) =>
- [timestamp ? formatDate(timestamp) : '', pod, message].join(' | ');
-
-export const trace = (state) => state.logs.lines.map(mapTrace).join('\n');
-
-export const showAdvancedFilters = (state) => {
- const environment = state.environments.options.find(
- ({ name }) => name === state.environments.current,
- );
-
- return Boolean(environment?.enable_advanced_logs_querying);
-};
diff --git a/app/assets/javascripts/logs/stores/index.js b/app/assets/javascripts/logs/stores/index.js
deleted file mode 100644
index d16941ddf93..00000000000
--- a/app/assets/javascripts/logs/stores/index.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import Vue from 'vue';
-import Vuex from 'vuex';
-import * as actions from './actions';
-import * as getters from './getters';
-import mutations from './mutations';
-import state from './state';
-
-Vue.use(Vuex);
-
-export const createStore = () =>
- new Vuex.Store({
- modules: {
- environmentLogs: {
- namespaced: true,
- actions,
- mutations,
- state: state(),
- getters,
- },
- },
- });
-
-export default createStore;
diff --git a/app/assets/javascripts/logs/stores/mutation_types.js b/app/assets/javascripts/logs/stores/mutation_types.js
deleted file mode 100644
index c1ed65ff48b..00000000000
--- a/app/assets/javascripts/logs/stores/mutation_types.js
+++ /dev/null
@@ -1,26 +0,0 @@
-export const SET_PROJECT_ENVIRONMENT = 'SET_PROJECT_ENVIRONMENT';
-export const SET_SEARCH = 'SET_SEARCH';
-export const SET_MANAGED_APP = 'SET_MANAGED_APP';
-
-export const SET_TIME_RANGE = 'SET_TIME_RANGE';
-export const SHOW_TIME_RANGE_INVALID_WARNING = 'SHOW_TIME_RANGE_INVALID_WARNING';
-export const HIDE_TIME_RANGE_INVALID_WARNING = 'HIDE_TIME_RANGE_INVALID_WARNING';
-
-export const SET_CURRENT_POD_NAME = 'SET_CURRENT_POD_NAME';
-
-export const REQUEST_ENVIRONMENTS_DATA = 'REQUEST_ENVIRONMENTS_DATA';
-export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCCESS';
-export const RECEIVE_ENVIRONMENTS_DATA_ERROR = 'RECEIVE_ENVIRONMENTS_DATA_ERROR';
-export const HIDE_REQUEST_ENVIRONMENTS_ERROR = 'HIDE_REQUEST_ENVIRONMENTS_ERROR';
-
-export const REQUEST_LOGS_DATA = 'REQUEST_LOGS_DATA';
-export const RECEIVE_LOGS_DATA_SUCCESS = 'RECEIVE_LOGS_DATA_SUCCESS';
-export const RECEIVE_LOGS_DATA_ERROR = 'RECEIVE_LOGS_DATA_ERROR';
-export const REQUEST_LOGS_DATA_PREPEND = 'REQUEST_LOGS_DATA_PREPEND';
-export const RECEIVE_LOGS_DATA_PREPEND_SUCCESS = 'RECEIVE_LOGS_DATA_PREPEND_SUCCESS';
-export const RECEIVE_LOGS_DATA_PREPEND_ERROR = 'RECEIVE_LOGS_DATA_PREPEND_ERROR';
-export const HIDE_REQUEST_LOGS_ERROR = 'HIDE_REQUEST_LOGS_ERROR';
-export const REFRESH_POD_LOGS = 'REFRESH_POD_LOGS';
-
-export const RECEIVE_PODS_DATA_SUCCESS = 'RECEIVE_PODS_DATA_SUCCESS';
-export const RECEIVE_PODS_DATA_ERROR = 'RECEIVE_PODS_DATA_ERROR';
diff --git a/app/assets/javascripts/logs/stores/mutations.js b/app/assets/javascripts/logs/stores/mutations.js
deleted file mode 100644
index 6736d7204b4..00000000000
--- a/app/assets/javascripts/logs/stores/mutations.js
+++ /dev/null
@@ -1,110 +0,0 @@
-import { convertToFixedRange } from '~/lib/utils/datetime_range';
-import * as types from './mutation_types';
-
-const mapLine = ({ timestamp, pod, message }) => ({
- timestamp,
- pod,
- message,
-});
-
-export default {
- // Search Data
- [types.SET_SEARCH](state, searchQuery) {
- state.search = searchQuery;
- },
-
- // Time Range Data
- [types.SET_TIME_RANGE](state, timeRange) {
- state.timeRange.selected = timeRange;
- state.timeRange.current = convertToFixedRange(timeRange);
- },
- [types.SHOW_TIME_RANGE_INVALID_WARNING](state) {
- state.timeRange.invalidWarning = true;
- },
- [types.HIDE_TIME_RANGE_INVALID_WARNING](state) {
- state.timeRange.invalidWarning = false;
- },
-
- // Environments Data
- [types.SET_PROJECT_ENVIRONMENT](state, environmentName) {
- state.environments.current = environmentName;
-
- // Clear current pod options
- state.pods.current = null;
- state.pods.options = [];
- },
- [types.REQUEST_ENVIRONMENTS_DATA](state) {
- state.environments.options = [];
- state.environments.isLoading = true;
- },
- [types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS](state, environmentOptions) {
- state.environments.options = environmentOptions;
- state.environments.isLoading = false;
- },
- [types.RECEIVE_ENVIRONMENTS_DATA_ERROR](state) {
- state.environments.options = [];
- state.environments.isLoading = false;
- state.environments.fetchError = true;
- },
- [types.HIDE_REQUEST_ENVIRONMENTS_ERROR](state) {
- state.environments.fetchError = false;
- },
-
- // Logs data
- [types.REQUEST_LOGS_DATA](state) {
- state.timeRange.current = convertToFixedRange(state.timeRange.selected);
-
- state.logs.lines = [];
- state.logs.isLoading = true;
-
- // start pagination from the beginning
- state.logs.cursor = null;
- state.logs.isComplete = false;
- },
- [types.RECEIVE_LOGS_DATA_SUCCESS](state, { logs = [], cursor }) {
- state.logs.lines = logs.map(mapLine);
- state.logs.isLoading = false;
- state.logs.cursor = cursor;
-
- if (!cursor) {
- state.logs.isComplete = true;
- }
- },
- [types.RECEIVE_LOGS_DATA_ERROR](state) {
- state.logs.lines = [];
- state.logs.isLoading = false;
- state.logs.fetchError = true;
- },
-
- [types.REQUEST_LOGS_DATA_PREPEND](state) {
- state.logs.isLoading = true;
- },
- [types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, { logs = [], cursor }) {
- const lines = logs.map(mapLine);
- state.logs.lines = lines.concat(state.logs.lines);
- state.logs.isLoading = false;
- state.logs.cursor = cursor;
-
- if (!cursor) {
- state.logs.isComplete = true;
- }
- },
- [types.RECEIVE_LOGS_DATA_PREPEND_ERROR](state) {
- state.logs.isLoading = false;
- state.logs.fetchError = true;
- },
- [types.HIDE_REQUEST_LOGS_ERROR](state) {
- state.logs.fetchError = false;
- },
-
- // Pods data
- [types.SET_CURRENT_POD_NAME](state, podName) {
- state.pods.current = podName;
- },
- [types.RECEIVE_PODS_DATA_SUCCESS](state, podOptions) {
- state.pods.options = podOptions;
- },
- [types.RECEIVE_PODS_DATA_ERROR](state) {
- state.pods.options = [];
- },
-};
diff --git a/app/assets/javascripts/logs/stores/state.js b/app/assets/javascripts/logs/stores/state.js
deleted file mode 100644
index ee17e8ecef2..00000000000
--- a/app/assets/javascripts/logs/stores/state.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import { convertToFixedRange } from '~/lib/utils/datetime_range';
-import { timeRanges, defaultTimeRange } from '~/vue_shared/constants';
-
-export default () => ({
- /**
- * Full text search
- */
- search: '',
-
- /**
- * Time range (Show last)
- */
- timeRange: {
- options: timeRanges,
- // Selected time range, can be fixed or relative
- selected: defaultTimeRange,
- // Current time range, must be fixed
- current: convertToFixedRange(defaultTimeRange),
-
- invalidWarning: false,
- },
-
- /**
- * Environments list information
- */
- environments: {
- options: [],
- isLoading: false,
- current: null,
- fetchError: false,
- },
-
- /**
- * Jobs with logs
- */
- logs: {
- lines: [],
- isLoading: false,
- /**
- * Logs `cursor` represents the current pagination position,
- * Should be sent in next batch (page) of logs to be fetched
- */
- cursor: null,
- isComplete: false,
-
- fetchError: false,
- },
-
- /**
- * Pods list information
- */
- pods: {
- options: [],
- current: null,
- },
-});
diff --git a/app/assets/javascripts/logs/utils.js b/app/assets/javascripts/logs/utils.js
deleted file mode 100644
index 74c2f8a68f8..00000000000
--- a/app/assets/javascripts/logs/utils.js
+++ /dev/null
@@ -1,4 +0,0 @@
-import dateFormat from 'dateformat';
-import { dateFormatMask } from './constants';
-
-export const formatDate = (timestamp) => dateFormat(timestamp, dateFormatMask);
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index e3e8efdd771..349a28ace52 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -36,6 +36,7 @@ import initUserPopovers from './user_popovers';
import initBroadcastNotifications from './broadcast_notification';
import { initTopNav } from './nav';
import { initCopyCodeButton } from './behaviors/copy_code';
+import initHeaderSearch from './header_search/init';
import 'ee_else_ce/main_ee';
import 'jh_else_ce/main_jh';
@@ -53,7 +54,7 @@ window.gl = window.gl || {};
// inject test utilities if necessary
if (process.env.NODE_ENV !== 'production' && gon?.test_env) {
- import(/* webpackMode: "eager" */ './test_utils/');
+ import(/* webpackMode: "eager" */ './test_utils');
}
document.addEventListener('beforeunload', () => {
@@ -115,34 +116,6 @@ function deferredInitialisation() {
);
}
- const searchInputBox = document.querySelector('#search');
- if (searchInputBox) {
- searchInputBox.addEventListener(
- 'focus',
- () => {
- if (gon.features?.newHeaderSearch) {
- import(/* webpackChunkName: 'globalSearch' */ '~/header_search')
- .then(async ({ initHeaderSearchApp }) => {
- // In case the user started searching before we bootstrapped, let's pass the search along.
- const initialSearchValue = searchInputBox.value;
- await initHeaderSearchApp(initialSearchValue);
- // this is new #search input element. We need to re-find it.
- document.querySelector('#search').focus();
- })
- .catch(() => {});
- } else {
- import(/* webpackChunkName: 'globalSearch' */ './search_autocomplete')
- .then(({ default: initSearchAutocomplete }) => {
- const searchDropdown = initSearchAutocomplete();
- searchDropdown.onSearchInputFocus();
- })
- .catch(() => {});
- }
- },
- { once: true },
- );
- }
-
addSelectOnFocusBehaviour('.js-select-on-focus');
const glTooltipDelay = localStorage.getItem('gl-tooltip-delay');
@@ -169,6 +142,11 @@ function deferredInitialisation() {
}
}
+// header search vue component bootstrap
+// loading this inside requestIdleCallback is causing issues
+// see https://gitlab.com/gitlab-org/gitlab/-/issues/365746
+initHeaderSearch();
+
const $body = $('body');
const $document = $(document);
const bootstrapBreakpoint = bp.getBreakpointSize();
diff --git a/app/assets/javascripts/members/components/members_tabs.vue b/app/assets/javascripts/members/components/members_tabs.vue
index 98995730df4..b824a013f3b 100644
--- a/app/assets/javascripts/members/components/members_tabs.vue
+++ b/app/assets/javascripts/members/components/members_tabs.vue
@@ -1,40 +1,48 @@
<script>
import { GlTabs, GlTab, GlBadge, GlButton } from '@gitlab/ui';
import { mapState } from 'vuex';
-import { queryToObject } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-import { MEMBER_TYPES, TAB_QUERY_PARAM_VALUES, ACTIVE_TAB_QUERY_PARAM_NAME } from '../constants';
+import { queryToObject } from '~/lib/utils/url_utility';
+import {
+ MEMBER_TYPES,
+ ACTIVE_TAB_QUERY_PARAM_NAME,
+ TAB_QUERY_PARAM_VALUES,
+ EE_TABS,
+} from 'ee_else_ce/members/constants';
import MembersApp from './app.vue';
const countComputed = (state, namespace) => state[namespace]?.pagination?.totalItems || 0;
+export const TABS = [
+ {
+ namespace: MEMBER_TYPES.user,
+ title: __('Members'),
+ },
+ {
+ namespace: MEMBER_TYPES.group,
+ title: __('Groups'),
+ attrs: { 'data-qa-selector': 'groups_list_tab' },
+ queryParamValue: TAB_QUERY_PARAM_VALUES.group,
+ },
+ {
+ namespace: MEMBER_TYPES.invite,
+ title: __('Invited'),
+ canManageMembersPermissionsRequired: true,
+ queryParamValue: TAB_QUERY_PARAM_VALUES.invite,
+ },
+ {
+ namespace: MEMBER_TYPES.accessRequest,
+ title: __('Access requests'),
+ canManageMembersPermissionsRequired: true,
+ queryParamValue: TAB_QUERY_PARAM_VALUES.accessRequest,
+ },
+ ...EE_TABS,
+];
+
export default {
name: 'MembersTabs',
ACTIVE_TAB_QUERY_PARAM_NAME,
- TABS: [
- {
- namespace: MEMBER_TYPES.user,
- title: __('Members'),
- },
- {
- namespace: MEMBER_TYPES.group,
- title: __('Groups'),
- attrs: { 'data-qa-selector': 'groups_list_tab' },
- queryParamValue: TAB_QUERY_PARAM_VALUES.group,
- },
- {
- namespace: MEMBER_TYPES.invite,
- title: __('Invited'),
- canManageMembersPermissionsRequired: true,
- queryParamValue: TAB_QUERY_PARAM_VALUES.invite,
- },
- {
- namespace: MEMBER_TYPES.accessRequest,
- title: __('Access requests'),
- canManageMembersPermissionsRequired: true,
- queryParamValue: TAB_QUERY_PARAM_VALUES.accessRequest,
- },
- ],
+ TABS,
components: { MembersApp, GlTabs, GlTab, GlBadge, GlButton },
inject: ['canManageMembers', 'canExportMembers', 'exportCsvPath'],
data() {
@@ -43,20 +51,17 @@ export default {
};
},
computed: {
- ...mapState({
- userCount(state) {
- return countComputed(state, MEMBER_TYPES.user);
- },
- groupCount(state) {
- return countComputed(state, MEMBER_TYPES.group);
- },
- inviteCount(state) {
- return countComputed(state, MEMBER_TYPES.invite);
- },
- accessRequestCount(state) {
- return countComputed(state, MEMBER_TYPES.accessRequest);
- },
- }),
+ ...mapState(
+ Object.values(MEMBER_TYPES).reduce((getters, memberType) => {
+ return {
+ ...getters,
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ [`${memberType}Count`](state) {
+ return countComputed(state, memberType);
+ },
+ };
+ }, {}),
+ ),
urlParams() {
return Object.keys(queryToObject(window.location.search, { gatherArrays: true }));
},
diff --git a/app/assets/javascripts/members/components/table/member_avatar.vue b/app/assets/javascripts/members/components/table/member_avatar.vue
index 92b757ffcba..966eb90e402 100644
--- a/app/assets/javascripts/members/components/table/member_avatar.vue
+++ b/app/assets/javascripts/members/components/table/member_avatar.vue
@@ -6,7 +6,13 @@ import UserAvatar from '../avatars/user_avatar.vue';
export default {
name: 'MemberAvatar',
- components: { UserAvatar, InviteAvatar, GroupAvatar, AccessRequestAvatar: UserAvatar },
+ components: {
+ UserAvatar,
+ InviteAvatar,
+ GroupAvatar,
+ AccessRequestAvatar: UserAvatar,
+ BannedAvatar: UserAvatar,
+ },
props: {
memberType: {
type: String,
diff --git a/app/assets/javascripts/members/components/table/members_table_cell.vue b/app/assets/javascripts/members/components/table/members_table_cell.vue
index 3436bcab2fc..51eff428d63 100644
--- a/app/assets/javascripts/members/components/table/members_table_cell.vue
+++ b/app/assets/javascripts/members/components/table/members_table_cell.vue
@@ -1,5 +1,5 @@
<script>
-import { MEMBER_TYPES } from '../../constants';
+import { MEMBER_TYPES } from 'ee_else_ce/members/constants';
import {
isGroup,
isDirectMember,
diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js
index 8c40cc3f29d..2fe816c7ea2 100644
--- a/app/assets/javascripts/members/constants.js
+++ b/app/assets/javascripts/members/constants.js
@@ -3,6 +3,12 @@ import { GlFilteredSearchToken } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
+// Overridden in EE
+export const EE_APP_OPTIONS = {};
+
+// Overridden in EE
+export const EE_TABS = [];
+
export const FIELD_KEY_ACCOUNT = 'account';
export const FIELD_KEY_SOURCE = 'source';
export const FIELD_KEY_GRANTED = 'granted';
diff --git a/app/assets/javascripts/members/index.js b/app/assets/javascripts/members/index.js
index 0df876cabd7..34660f8f499 100644
--- a/app/assets/javascripts/members/index.js
+++ b/app/assets/javascripts/members/index.js
@@ -2,8 +2,8 @@ import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import Vuex from 'vuex';
import { parseDataAttributes } from '~/members/utils';
+import { MEMBER_TYPES } from 'ee_else_ce/members/constants';
import MembersTabs from './components/members_tabs.vue';
-import { MEMBER_TYPES } from './constants';
import membersStore from './store';
export const initMembersApp = (el, options) => {
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 94041d77bb0..ed2e6a5af58 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -177,7 +177,7 @@ export default class MergeRequestTabs {
this.peek = document.getElementById('js-peek');
this.sidebar = document.querySelector('.js-right-sidebar');
this.pageLayout = document.querySelector('.layout-page');
- this.expandSidebar = document.querySelector('.js-expand-sidebar');
+ this.expandSidebar = document.querySelectorAll('.js-expand-sidebar, .js-sidebar-toggle');
this.paddingTop = 16;
this.scrollPositions = {};
@@ -282,7 +282,11 @@ export default class MergeRequestTabs {
const tab = this.mergeRequestTabs.querySelector(`.${action}-tab`);
if (tab) tab.classList.add('active');
- this.expandSidebar?.classList.toggle('gl-display-none!', action !== 'show');
+ if (window.gon?.features?.movedMrSidebar) {
+ this.expandSidebar?.forEach((el) =>
+ el.classList.toggle('gl-display-none!', action !== 'show'),
+ );
+ }
if (action === 'commits') {
this.loadCommits(href);
diff --git a/app/assets/javascripts/milestones/components/delete_milestone_modal.vue b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue
index 34f9fe778ea..3a13c123d77 100644
--- a/app/assets/javascripts/milestones/components/delete_milestone_modal.vue
+++ b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue
@@ -1,6 +1,6 @@
<script>
-import { GlSafeHtmlDirective as SafeHtml, GlModal } from '@gitlab/ui';
-import createFlash from '~/flash';
+import { GlSprintf, GlModal } from '@gitlab/ui';
+import { createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { redirectTo } from '~/lib/utils/url_utility';
@@ -10,9 +10,7 @@ import eventHub from '../event_hub';
export default {
components: {
GlModal,
- },
- directives: {
- SafeHtml,
+ GlSprintf,
},
props: {
issueCount: {
@@ -38,20 +36,10 @@ export default {
},
computed: {
text() {
- const milestoneTitle = sprintf('<strong>%{milestoneTitle}</strong>', {
- milestoneTitle: this.milestoneTitle,
- });
-
if (this.issueCount === 0 && this.mergeRequestCount === 0) {
- return sprintf(
- s__(`Milestones|
+ return s__(`Milestones|
You’re about to permanently delete the milestone %{milestoneTitle}.
-This milestone is not currently used in any issues or merge requests.`),
- {
- milestoneTitle,
- },
- false,
- );
+This milestone is not currently used in any issues or merge requests.`);
}
return sprintf(
@@ -59,7 +47,6 @@ This milestone is not currently used in any issues or merge requests.`),
You’re about to permanently delete the milestone %{milestoneTitle} and remove it from %{issuesWithCount} and %{mergeRequestsWithCount}.
Once deleted, it cannot be undone or recovered.`),
{
- milestoneTitle,
issuesWithCount: n__('%d issue', '%d issues', this.issueCount),
mergeRequestsWithCount: n__(
'%d merge request',
@@ -98,13 +85,13 @@ Once deleted, it cannot be undone or recovered.`),
});
if (error.response && error.response.status === 404) {
- createFlash({
+ createAlert({
message: sprintf(s__('Milestones|Milestone %{milestoneTitle} was not found'), {
milestoneTitle: this.milestoneTitle,
}),
});
} else {
- createFlash({
+ createAlert({
message: sprintf(s__('Milestones|Failed to delete milestone %{milestoneTitle}'), {
milestoneTitle: this.milestoneTitle,
}),
@@ -132,6 +119,10 @@ Once deleted, it cannot be undone or recovered.`),
:action-cancel="$options.cancelProps"
@primary="onSubmit"
>
- <p v-safe-html="text"></p>
+ <gl-sprintf :message="text">
+ <template #milestoneTitle>
+ <strong>{{ milestoneTitle }}</strong>
+ </template>
+ </gl-sprintf>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/milestones/milestone.js b/app/assets/javascripts/milestones/milestone.js
index 05102f73f92..8f2721c2a5b 100644
--- a/app/assets/javascripts/milestones/milestone.js
+++ b/app/assets/javascripts/milestones/milestone.js
@@ -1,33 +1,27 @@
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';
+import { GlTabsBehavior, TAB_SHOWN_EVENT, HISTORY_TYPE_HASH } 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();
+ // eslint-disable-next-line no-new
+ new GlTabsBehavior(this.tabsEl, { history: HISTORY_TYPE_HASH });
}
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;
diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js
index e375435436e..eb7c43034a4 100644
--- a/app/assets/javascripts/mirrors/ssh_mirror.js
+++ b/app/assets/javascripts/mirrors/ssh_mirror.js
@@ -163,7 +163,7 @@ export default class SSHMirror {
const $fingerprintsList = this.$hostKeysInformation.find('.js-fingerprints-list');
let fingerprints = '';
sshHostKeys.fingerprints.forEach((fingerprint) => {
- const escFingerprints = escape(fingerprint.fingerprint);
+ const escFingerprints = escape(fingerprint.fingerprint_sha256 || fingerprint.fingerprint);
fingerprints += `<code>${escFingerprints}</code>`;
});
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 70e253508ce..250d4b3c55f 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -358,10 +358,6 @@ export default {
actionToRun = 'onExpandFromKeyboardShortcut';
break;
- case keyboardShortcutKeys.VISIT_LOGS:
- actionToRun = 'visitLogsPageFromKeyboardShortcut';
- break;
-
case keyboardShortcutKeys.SHOW_ALERT:
actionToRun = 'showAlertModalFromKeyboardShortcut';
break;
diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue
index f18290e7048..3338635bf96 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_header.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue
@@ -170,7 +170,7 @@ export default {
<template>
<div ref="prometheusGraphsHeader">
- <div class="mb-2 mr-2 d-flex d-sm-block">
+ <div class="gl-mb-3 gl-mr-3 gl-display-flex gl-sm-display-block">
<dashboards-dropdown
id="monitor-dashboards-dropdown"
data-qa-selector="dashboards_filter_dropdown"
@@ -240,7 +240,7 @@ export default {
<div class="flex-grow-1"></div>
<div class="d-sm-flex">
- <div v-if="showRearrangePanelsBtn" class="mb-2 mr-2 d-flex">
+ <div v-if="showRearrangePanelsBtn" class="gl-mb-3 gl-mr-3 gl-display-flex">
<gl-button
:pressed="isRearrangingPanels"
variant="default"
@@ -253,7 +253,7 @@ export default {
<div
v-if="externalDashboardUrl && externalDashboardUrl.length"
- class="mb-2 mr-2 d-flex d-sm-block"
+ class="gl-mb-3 gl-mr-3 gl-display-flex gl-sm-display-block"
>
<gl-button
class="flex-grow-1 js-external-dashboard-link"
@@ -280,7 +280,7 @@ export default {
<template v-if="shouldShowSettingsButton">
<span aria-hidden="true" class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block"></span>
- <div class="mb-2 mr-2 d-flex d-sm-block">
+ <div class="gl-mb-3 gl-mr-3 d-flex d-sm-block">
<gl-button
v-gl-tooltip
data-testid="metrics-settings-button"
diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
index ff8ccded83b..7e7dcef7639 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
@@ -15,15 +15,13 @@ import {
} from '@gitlab/ui';
import { mapState } from 'vuex';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
-import invalidUrl from '~/lib/utils/invalid_url';
-import { relativePathToAbsolute, getBaseURL, visitUrl, isSafeURL } from '~/lib/utils/url_utility';
+import { isSafeURL } from '~/lib/utils/url_utility';
import { __, n__ } from '~/locale';
import TrackEventDirective from '~/vue_shared/directives/track_event';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { panelTypes } from '../constants';
import { graphDataToCsv } from '../csv_export';
-import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils';
+import { downloadCSVOptions, generateLinkToChartOptions } from '../utils';
import MonitorAnomalyChart from './charts/anomaly.vue';
import MonitorBarChart from './charts/bar.vue';
import MonitorColumnChart from './charts/column.vue';
@@ -58,7 +56,6 @@ export default {
GlTooltip: GlTooltipDirective,
TrackEvent: TrackEventDirective,
},
- mixins: [glFeatureFlagsMixin()],
props: {
clipboardText: {
type: String,
@@ -106,9 +103,6 @@ export default {
projectPath(state) {
return state[this.namespace].projectPath;
},
- logsPath(state) {
- return state[this.namespace].logsPath;
- },
timeRange(state) {
return state[this.namespace].timeRange;
},
@@ -142,17 +136,6 @@ export default {
const metrics = this.graphData?.metrics || [];
return metrics.some(({ loading }) => loading);
},
- logsPathWithTimeRange() {
- if (!this.glFeatures.monitorLogging) {
- return null;
- }
- const timeRange = this.zoomedTimeRange || this.timeRange;
-
- if (this.logsPath && this.logsPath !== invalidUrl && timeRange) {
- return timeRangeToUrl(timeRange, this.logsPath);
- }
- return null;
- },
csvText() {
if (this.graphData) {
return graphDataToCsv(this.graphData);
@@ -278,16 +261,6 @@ export default {
safeUrl(url) {
return isSafeURL(url) ? url : '#';
},
- visitLogsPage() {
- if (this.logsPathWithTimeRange) {
- visitUrl(relativePathToAbsolute(this.logsPathWithTimeRange, getBaseURL()));
- }
- },
- visitLogsPageFromKeyboardShortcut() {
- if (this.isContextualMenuShown) {
- this.visitLogsPage();
- }
- },
downloadCsvFromKeyboardShortcut() {
if (this.csvText && this.isContextualMenuShown) {
this.$refs.downloadCsvLink.$el.firstChild.click();
@@ -351,13 +324,6 @@ export default {
>
{{ editCustomMetricLinkText }}
</gl-dropdown-item>
- <gl-dropdown-item
- v-if="logsPathWithTimeRange"
- ref="viewLogsLink"
- :href="logsPathWithTimeRange"
- >
- {{ s__('Metrics|View logs') }}
- </gl-dropdown-item>
<gl-dropdown-item
v-if="csvText"
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
index 060ed896d7c..1b506c6564b 100644
--- a/app/assets/javascripts/monitoring/constants.js
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -172,7 +172,6 @@ export const endpointKeys = [
'dashboardsEndpoint',
'currentDashboard',
'projectPath',
- 'logsPath',
];
/**
@@ -271,7 +270,6 @@ export const VARIABLE_PREFIX = 'var-';
export const keyboardShortcutKeys = {
EXPAND: 'e',
- VISIT_LOGS: 'l',
SHOW_ALERT: 'a',
DOWNLOAD_CSV: 'd',
CHART_COPY: 'c',
diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js
index 3883fa3380d..e513b575475 100644
--- a/app/assets/javascripts/monitoring/stores/state.js
+++ b/app/assets/javascripts/monitoring/stores/state.js
@@ -83,7 +83,6 @@ export default () => ({
externalDashboardUrl: '',
projectPath: null,
operationsSettingsPath: '',
- logsPath: invalidUrl,
addDashboardDocumentationPath: '',
// static paths
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
index 221f28e923b..fd8749625da 100644
--- a/app/assets/javascripts/monitoring/utils.js
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -29,7 +29,6 @@ export const stateAndPropsFromDataset = (dataset = {}) => {
canAccessOperationsSettings,
operationsSettingsPath,
projectPath,
- logsPath,
externalDashboardUrl,
currentEnvironmentName,
customDashboardBasePath,
@@ -53,7 +52,6 @@ export const stateAndPropsFromDataset = (dataset = {}) => {
canAccessOperationsSettings,
operationsSettingsPath,
projectPath,
- logsPath,
externalDashboardUrl,
currentEnvironmentName,
customDashboardBasePath,
diff --git a/app/assets/javascripts/mr_notes/stores/index.js b/app/assets/javascripts/mr_notes/stores/index.js
index 4a0602ad512..7527c685c71 100644
--- a/app/assets/javascripts/mr_notes/stores/index.js
+++ b/app/assets/javascripts/mr_notes/stores/index.js
@@ -7,14 +7,16 @@ import mrPageModule from './modules';
Vue.use(Vuex);
+export const createModules = () => ({
+ page: mrPageModule(),
+ notes: notesModule(),
+ diffs: diffsModule(),
+ batchComments: batchCommentsModule(),
+});
+
export const createStore = () =>
new Vuex.Store({
- modules: {
- page: mrPageModule(),
- notes: notesModule(),
- diffs: diffsModule(),
- batchComments: batchCommentsModule(),
- },
+ modules: createModules(),
});
export default createStore();
diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js
index d93db7399e6..ef36e58374c 100644
--- a/app/assets/javascripts/new_branch_form.js
+++ b/app/assets/javascripts/new_branch_form.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, consistent-return, no-return-assign, @gitlab/require-i18n-strings */
+/* eslint-disable func-names, no-return-assign, @gitlab/require-i18n-strings */
import $ from 'jquery';
import RefSelectDropdown from './ref_select_dropdown';
@@ -6,9 +6,9 @@ import RefSelectDropdown from './ref_select_dropdown';
export default class NewBranchForm {
constructor(form, availableRefs) {
this.validate = this.validate.bind(this);
- this.branchNameError = form.find('.js-branch-name-error');
- this.name = form.find('.js-branch-name');
- this.ref = form.find('#ref');
+ this.branchNameError = form.querySelector('.js-branch-name-error');
+ this.name = form.querySelector('.js-branch-name');
+ this.ref = form.querySelector('#ref');
new RefSelectDropdown($('.js-branch-select'), availableRefs); // eslint-disable-line no-new
this.setupRestrictions();
this.addBinding();
@@ -16,12 +16,13 @@ export default class NewBranchForm {
}
addBinding() {
- return this.name.on('blur', this.validate);
+ this.name.addEventListener('blur', this.validate);
}
init() {
- if (this.name.length && this.name.val().length > 0) {
- return this.name.trigger('blur');
+ if (this.name != null && this.name.value.length > 0) {
+ const event = new CustomEvent('blur');
+ this.name.dispatchEvent(event);
}
}
@@ -52,7 +53,7 @@ export default class NewBranchForm {
validate() {
const { indexOf } = [];
- this.branchNameError.empty();
+ this.branchNameError.innerHTML = '';
const unique = function (values, value) {
if (indexOf.call(values, value) === -1) {
values.push(value);
@@ -73,7 +74,7 @@ export default class NewBranchForm {
return `${restriction.prefix} ${formatted.join(restriction.conjunction)}`;
};
const validator = (errors, restriction) => {
- const matched = this.name.val().match(restriction.pattern);
+ const matched = this.name.value.match(restriction.pattern);
if (matched) {
return errors.concat(formatter(matched.reduce(unique, []), restriction));
}
@@ -81,8 +82,7 @@ export default class NewBranchForm {
};
const errors = this.restrictions.reduce(validator, []);
if (errors.length > 0) {
- const errorMessage = $('<span/>').text(errors.join(', '));
- return this.branchNameError.append(errorMessage);
+ this.branchNameError.textContent = errors.join(', ');
}
}
}
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index e7ac27c5e3e..bd5945a951b 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -172,9 +172,6 @@ export default {
trackingLabel() {
return slugifyWithUnderscore(`${this.commentButtonTitle} button`);
},
- internalNotesEnabled() {
- return Boolean(this.glFeatures.confidentialNotes);
- },
disableSubmitButton() {
return this.note.length === 0 || this.isSubmitting;
},
@@ -414,7 +411,7 @@ export default {
</template>
<template v-else>
<gl-form-checkbox
- v-if="internalNotesEnabled && canSetInternalNote"
+ v-if="canSetInternalNote"
v-model="noteIsInternal"
class="gl-mb-2"
data-testid="internal-note-checkbox"
diff --git a/app/assets/javascripts/notes/components/discussion_filter_note.vue b/app/assets/javascripts/notes/components/discussion_filter_note.vue
index 83326279423..61af0b06535 100644
--- a/app/assets/javascripts/notes/components/discussion_filter_note.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter_note.vue
@@ -39,8 +39,8 @@ export default {
</template>
</gl-sprintf>
</div>
- <div class="discussion-filter-actions mt-2">
- <gl-button variant="default" @click="selectFilter(0)">
+ <div class="discussion-filter-actions gl-mt-3 gl-display-flex">
+ <gl-button variant="default" class="gl-mr-3" @click="selectFilter(0)">
{{ __('Show all activity') }}
</gl-button>
<gl-button variant="default" @click="selectFilter(1)">
diff --git a/app/assets/javascripts/notes/components/discussion_navigator.vue b/app/assets/javascripts/notes/components/discussion_navigator.vue
index 7e8bb75902b..c1e39f31bbb 100644
--- a/app/assets/javascripts/notes/components/discussion_navigator.vue
+++ b/app/assets/javascripts/notes/components/discussion_navigator.vue
@@ -25,7 +25,7 @@ export default {
eventHub.$off('jumpToFirstUnresolvedDiscussion', this.jumpToFirstUnresolvedDiscussion);
},
render() {
- return this.$slots.default;
+ return this.$scopedSlots.default?.();
},
};
</script>
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index af0c1e9619e..095ab5ddb0f 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -1,5 +1,5 @@
<script>
-import { GlSprintf, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import { GlSprintf, GlSafeHtmlDirective as SafeHtml, GlAvatarLink, GlAvatar } from '@gitlab/ui';
import $ from 'jquery';
import { escape, isEmpty } from 'lodash';
import { mapGetters, mapActions } from 'vuex';
@@ -11,7 +11,6 @@ import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
import { truncateSha } from '~/lib/utils/text_utility';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import { __, s__, sprintf } from '~/locale';
-import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
@@ -31,11 +30,12 @@ export default {
name: 'NoteableNote',
components: {
GlSprintf,
- userAvatarLink,
noteHeader,
noteActions,
NoteBody,
TimelineEntryItem,
+ GlAvatarLink,
+ GlAvatar,
},
directives: {
SafeHtml,
@@ -196,13 +196,11 @@ export default {
return fileResolvedFromAvailableSource || null;
},
- avatarSize() {
- // Use a different size if shown on a Merge Request Diff
- if (this.line && !this.isOverviewTab) {
- return 24;
- }
-
- return 40;
+ isMRDiffView() {
+ return this.line && !this.isOverviewTab;
+ },
+ authorAvatarAdaptiveSize() {
+ return { default: 24, md: 32 };
},
},
created() {
@@ -261,7 +259,7 @@ export default {
});
const confirmed = await confirmAction(msg, {
primaryBtnVariant: 'danger',
- primaryBtnText: this.note.confidential ? __('Delete Internal Note') : __('Delete Comment'),
+ primaryBtnText: this.note.confidential ? __('Delete internal note') : __('Delete comment'),
});
if (confirmed) {
@@ -428,19 +426,33 @@ export default {
</template>
</gl-sprintf>
</div>
- <div class="timeline-icon">
- <user-avatar-link
- :link-href="author.path"
- :img-src="author.avatar_url"
- :img-alt="author.name"
- :img-size="avatarSize"
- lazy
- >
- <template #avatar-badge>
- <slot name="avatar-badge"></slot>
- </template>
- </user-avatar-link>
+
+ <div v-if="isMRDiffView" class="gl-float-left gl-mt-n1 gl-mr-3">
+ <gl-avatar-link :href="author.path">
+ <gl-avatar
+ :src="author.avatar_url"
+ :entity-name="author.username"
+ :alt="author.name"
+ :size="24"
+ />
+
+ <slot name="avatar-badge"></slot>
+ </gl-avatar-link>
+ </div>
+
+ <div v-else class="gl-float-left gl-pl-3 gl-mr-3 gl-md-pl-2 gl-md-pr-2">
+ <gl-avatar-link :href="author.path">
+ <gl-avatar
+ :src="author.avatar_url"
+ :entity-name="author.username"
+ :alt="author.name"
+ :size="authorAvatarAdaptiveSize"
+ />
+
+ <slot name="avatar-badge"></slot>
+ </gl-avatar-link>
</div>
+
<div class="timeline-content">
<div class="note-header">
<note-header
diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
index 8cd4477a3bb..2bd3488ae1b 100644
--- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue
+++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
@@ -1,13 +1,20 @@
<script>
-import { GlButton, GlIcon } from '@gitlab/ui';
+import { GlButton, GlLink, GlSprintf } from '@gitlab/ui';
import { uniqBy } from 'lodash';
+import { s__ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
export default {
+ i18n: {
+ collapseReplies: s__('Notes|Collapse replies'),
+ expandReplies: s__('Notes|Expand replies'),
+ lastReplyBy: s__('Notes|Last reply by %{name}'),
+ },
components: {
GlButton,
- GlIcon,
+ GlLink,
+ GlSprintf,
UserAvatarLink,
TimeAgoTooltip,
},
@@ -28,63 +35,83 @@ export default {
uniqueAuthors() {
const authors = this.replies.map((reply) => reply.author || {});
- return uniqBy(authors, (author) => author.username);
+ return uniqBy(authors, 'username');
+ },
+ liClasses() {
+ return this.collapsed
+ ? 'gl-text-gray-500 gl-rounded-bottom-left-base gl-rounded-bottom-right-base'
+ : 'gl-border-b';
},
- className() {
- return this.collapsed ? 'collapsed' : 'expanded';
+ buttonIcon() {
+ return this.collapsed ? 'chevron-right' : 'chevron-down';
+ },
+ buttonLabel() {
+ return this.collapsed ? this.$options.i18n.expandReplies : this.$options.i18n.collapseReplies;
},
},
methods: {
toggle() {
+ this.$refs.toggle.$el.focus();
this.$emit('toggle');
},
},
- ICON_CLASS: 'gl-mr-3 gl-cursor-pointer',
};
</script>
<template>
<li
- :class="className"
- class="replies-toggle js-toggle-replies gl-display-flex! gl-align-items-center gl-flex-wrap"
+ :class="liClasses"
+ class="gl-display-flex! gl-align-items-center gl-flex-wrap gl-bg-gray-10 gl-py-3 gl-px-5 gl-border-t"
>
+ <gl-button
+ ref="toggle"
+ class="gl-my-2 gl-mr-3 gl-p-0!"
+ category="tertiary"
+ :icon="buttonIcon"
+ :aria-label="buttonLabel"
+ @click="toggle"
+ />
<template v-if="collapsed">
- <gl-icon :class="$options.ICON_CLASS" name="chevron-right" @click.native="toggle" />
- <div>
- <user-avatar-link
- v-for="author in uniqueAuthors"
- :key="author.username"
- :link-href="author.path"
- :img-alt="author.name"
- :img-src="author.avatar_url"
- :img-size="24"
- :tooltip-text="author.name"
- tooltip-placement="bottom"
- />
- </div>
+ <user-avatar-link
+ v-for="author in uniqueAuthors"
+ :key="author.username"
+ class="gl-mr-3"
+ :link-href="author.path"
+ :img-alt="author.name"
+ img-css-classes="gl-mr-0!"
+ :img-src="author.avatar_url"
+ :img-size="24"
+ :tooltip-text="author.name"
+ tooltip-placement="bottom"
+ />
<gl-button
- class="js-replies-text gl-mr-2"
- category="tertiary"
+ class="gl-mr-2"
variant="link"
data-qa-selector="expand_replies_button"
@click="toggle"
>
- {{ replies.length }} {{ n__('reply', 'replies', replies.length) }}
+ {{ n__('%d reply', '%d replies', replies.length) }}
</gl-button>
- {{ __('Last reply by') }}
- <a :href="lastReply.author.path" class="btn btn-link author-link gl-mx-2 gl-button">
- {{ lastReply.author.name }}
- </a>
+ <gl-sprintf :message="$options.i18n.lastReplyBy">
+ <template #name>
+ <gl-link
+ :href="lastReply.author.path"
+ class="gl-text-body! gl-text-decoration-none! gl-mx-2"
+ >
+ {{ lastReply.author.name }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
<time-ago-tooltip :time="lastReply.created_at" tooltip-placement="bottom" />
</template>
- <div
+ <gl-button
v-else
- class="collapse-replies-btn js-collapse-replies gl-display-flex align-items-center"
+ class="gl-text-body! gl-text-decoration-none!"
+ variant="link"
data-qa-selector="collapse_replies_button"
@click="toggle"
>
- <gl-icon :class="$options.ICON_CLASS" name="chevron-down" />
- <span class="gl-cursor-pointer">{{ s__('Notes|Collapse replies') }}</span>
- </div>
+ {{ $options.i18n.collapseReplies }}
+ </gl-button>
</li>
</template>
diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js
index b8575016762..3317f4e2383 100644
--- a/app/assets/javascripts/notes/constants.js
+++ b/app/assets/javascripts/notes/constants.js
@@ -10,8 +10,8 @@ export const OPENED = 'opened';
export const REOPENED = 'reopened';
export const CLOSED = 'closed';
export const MERGED = 'merged';
-export const ISSUE_NOTEABLE_TYPE = 'issue';
-export const EPIC_NOTEABLE_TYPE = 'epic';
+export const ISSUE_NOTEABLE_TYPE = 'Issue';
+export const EPIC_NOTEABLE_TYPE = 'Epic';
export const MERGE_REQUEST_NOTEABLE_TYPE = 'MergeRequest';
export const UNRESOLVE_NOTE_METHOD_NAME = 'delete';
export const RESOLVE_NOTE_METHOD_NAME = 'post';
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index 754a534e055..45df91796fc 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -41,8 +41,6 @@ function updateUrlWithNoteId(noteId) {
// Unmask the note's ID
note?.setAttribute('id', `note_${noteId}`);
- } else if (noteId) {
- updateHistory(newHistoryEntry);
}
}
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 57bb9e295f9..82417c9134b 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -83,14 +83,17 @@ export const setExpandDiscussions = ({ commit }, { discussionIds, expanded }) =>
commit(types.SET_EXPAND_DISCUSSIONS, { discussionIds, expanded });
};
-export const fetchDiscussions = ({ commit, dispatch }, { path, filter, persistFilter }) => {
+export const fetchDiscussions = (
+ { commit, dispatch, getters },
+ { path, filter, persistFilter },
+) => {
const config =
filter !== undefined
? { params: { notes_filter: filter, persist_filter: persistFilter } }
: null;
if (
- window.gon?.features?.paginatedIssueDiscussions ||
+ getters.noteableType === constants.ISSUE_NOTEABLE_TYPE ||
window.gon?.features?.paginatedMrDiscussions
) {
return dispatch('fetchDiscussionsBatch', { path, config, perPage: 20 });
@@ -114,7 +117,7 @@ export const fetchDiscussionsBatch = ({ commit, dispatch }, { path, config, curs
return axios.get(path, { params }).then(({ data, headers }) => {
commit(types.ADD_OR_UPDATE_DISCUSSIONS, data);
- if (headers['x-next-page-cursor']) {
+ if (headers && headers['x-next-page-cursor']) {
const nextConfig = { ...config };
if (config?.params?.persist_filter) {
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 ab0418388cd..5d77ff9dc0d 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
@@ -20,7 +20,6 @@ import {
UNSCHEDULED_STATUS,
SCHEDULED_STATUS,
ONGOING_STATUS,
- ROOT_IMAGE_TEXT,
ROOT_IMAGE_TOOLTIP,
} from '../../constants/index';
@@ -100,7 +99,7 @@ export default {
return !this.imageDetails.name ? ROOT_IMAGE_TOOLTIP : '';
},
imageName() {
- return this.imageDetails.name || ROOT_IMAGE_TEXT;
+ return this.imageDetails.name || this.imageDetails.project?.path;
},
formattedSize() {
const { size } = this.imageDetails;
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue
index 56da8e88b7a..bfa99c01c3f 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue
@@ -1,4 +1,5 @@
<script>
+import { uniqueId } from 'lodash';
import { GlIcon, GlPopover, GlLink, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { timeTilRun } from '../../utils';
@@ -43,6 +44,11 @@ export default {
CLEANUP_STATUS_UNFINISHED,
PARTIAL_CLEANUP_CONTINUE_MESSAGE,
},
+ data() {
+ return {
+ iconId: uniqueId('status-info-'),
+ };
+ },
computed: {
showStatus() {
return this.status !== UNSCHEDULED_STATUS;
@@ -85,14 +91,14 @@ export default {
</span>
<gl-icon
v-if="failedDelete"
- id="status-info"
+ :id="iconId"
:size="14"
class="gl-text-gray-500"
data-testid="extra-info"
name="information-o"
/>
<gl-popover
- target="status-info"
+ :target="iconId"
container="status-popover-container"
v-bind="$options.statusPopoverOptions"
>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
index e67d77210bb..aecc0bf92ea 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
@@ -1,10 +1,12 @@
<script>
-import { GlTooltipDirective, GlIcon, GlSprintf, GlSkeletonLoader } from '@gitlab/ui';
+import { GlTooltipDirective, GlIcon, GlSprintf, GlSkeletonLoader, GlButton } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { n__ } from '~/locale';
-
+import Tracking from '~/tracking';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import { joinPaths } from '~/lib/utils/url_utility';
import {
LIST_DELETE_BUTTON_DISABLED,
LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION,
@@ -13,8 +15,10 @@ import {
IMAGE_DELETE_SCHEDULED_STATUS,
IMAGE_FAILED_DELETED_STATUS,
IMAGE_MIGRATING_STATE,
- ROOT_IMAGE_TEXT,
COPY_IMAGE_PATH_TITLE,
+ IMAGE_FULL_PATH_LABEL,
+ TRACKING_ACTION_CLICK_SHOW_FULL_PATH,
+ TRACKING_LABEL_REGISTRY_IMAGE_LIST,
} from '../../constants/index';
import DeleteButton from '../delete_button.vue';
import CleanupStatus from './cleanup_status.vue';
@@ -25,6 +29,7 @@ export default {
ClipboardButton,
DeleteButton,
GlSprintf,
+ GlButton,
GlIcon,
ListItem,
GlSkeletonLoader,
@@ -33,6 +38,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [Tracking.mixin(), glFeatureFlagsMixin()],
inject: ['config'],
props: {
item: {
@@ -54,6 +60,12 @@ export default {
REMOVE_REPOSITORY_LABEL,
ROW_SCHEDULED_FOR_DELETION,
COPY_IMAGE_PATH_TITLE,
+ IMAGE_FULL_PATH_LABEL,
+ },
+ data() {
+ return {
+ showFullPath: false,
+ };
},
computed: {
disabledDelete() {
@@ -79,7 +91,17 @@ export default {
);
},
imageName() {
- return this.item.name ? this.item.path : `${this.item.path}/ ${ROOT_IMAGE_TEXT}`;
+ if (this.glFeatures.containerRegistryShowShortenedPath) {
+ if (this.showFullPath) {
+ return this.item.path;
+ }
+ const projectPath = this.item?.project?.path ?? '';
+ if (this.item.name) {
+ return joinPaths(projectPath, this.item.name);
+ }
+ return projectPath;
+ }
+ return this.item.path;
},
routerLinkEvent() {
return this.deleting ? '' : 'click';
@@ -90,6 +112,15 @@ export default {
: LIST_DELETE_BUTTON_DISABLED;
},
},
+ methods: {
+ hideButton() {
+ this.showFullPath = true;
+ this.$refs.imageName.$el.focus();
+ this.track(TRACKING_ACTION_CLICK_SHOW_FULL_PATH, {
+ label: TRACKING_LABEL_REGISTRY_IMAGE_LIST,
+ });
+ },
+ },
};
</script>
@@ -104,7 +135,20 @@ export default {
:disabled="deleting"
>
<template #left-primary>
+ <gl-button
+ v-if="glFeatures.containerRegistryShowShortenedPath && !showFullPath"
+ v-gl-tooltip="{
+ placement: 'top',
+ title: $options.i18n.IMAGE_FULL_PATH_LABEL,
+ }"
+ icon="ellipsis_h"
+ size="small"
+ class="gl-mr-2"
+ :aria-label="$options.i18n.IMAGE_FULL_PATH_LABEL"
+ @click="hideButton"
+ />
<router-link
+ ref="imageName"
class="gl-text-body gl-font-weight-bold"
data-testid="details-link"
data-qa-selector="registry_image_content"
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js
index 17adaec7a7d..67ad281b835 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js
@@ -1,6 +1,5 @@
-import { s__, __ } from '~/locale';
+import { __ } from '~/locale';
-export const ROOT_IMAGE_TEXT = s__('ContainerRegistry|Root image');
export const MORE_ACTIONS_TEXT = __('More actions');
export const NAME_SORT_FIELD = { orderBy: 'NAME', label: __('Name') };
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
index c6a7591e0d9..020d78ad364 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
@@ -43,6 +43,13 @@ export const EMPTY_RESULT_MESSAGE = s__(
export const COPY_IMAGE_PATH_TITLE = s__('ContainerRegistry|Copy image path');
+export const IMAGE_FULL_PATH_LABEL = s__('ContainerRegistry|Show full path');
+
+// Tracking
+
+export const TRACKING_LABEL_REGISTRY_IMAGE_LIST = 'registry_image_list';
+export const TRACKING_ACTION_CLICK_SHOW_FULL_PATH = 'click_show_full_path';
+
// Parameters
export const IMAGE_DELETE_SCHEDULED_STATUS = 'DELETE_SCHEDULED';
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 71a85d8885e..9ebbdfa920d 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
@@ -24,7 +24,6 @@ import {
FETCH_IMAGES_LIST_ERROR_MESSAGE,
UNFINISHED_STATUS,
MISSING_OR_DELETED_IMAGE_BREADCRUMB,
- ROOT_IMAGE_TEXT,
GRAPHQL_PAGE_SIZE,
MISSING_OR_DELETED_IMAGE_TITLE,
MISSING_OR_DELETED_IMAGE_MESSAGE,
@@ -111,7 +110,7 @@ export default {
methods: {
updateBreadcrumb() {
const name = this.containerRepository?.id
- ? this.containerRepository?.name || ROOT_IMAGE_TEXT
+ ? this.containerRepository?.name || this.containerRepository?.project?.path
: MISSING_OR_DELETED_IMAGE_BREADCRUMB;
this.breadCrumbState.updateName(name);
},
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/file_sha.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/file_sha.vue
index a25839be7e1..b91af19d623 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/file_sha.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/file_sha.vue
@@ -2,6 +2,12 @@
import { s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
+import Tracking from '~/tracking';
+import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils';
+import {
+ TRACKING_ACTION_COPY_PACKAGE_ASSET_SHA,
+ TRACKING_LABEL_PACKAGE_ASSET,
+} from '~/packages_and_registries/package_registry/constants';
export default {
name: 'FileSha',
@@ -9,6 +15,7 @@ export default {
DetailsRow,
ClipboardButton,
},
+ mixins: [Tracking.mixin()],
props: {
sha: {
type: String,
@@ -22,6 +29,18 @@ export default {
i18n: {
copyButtonTitle: s__('PackageRegistry|Copy SHA'),
},
+ computed: {
+ tracking() {
+ return {
+ category: packageTypeToTrackCategory(this.packageType),
+ };
+ },
+ },
+ methods: {
+ copySha() {
+ this.track(TRACKING_ACTION_COPY_PACKAGE_ASSET_SHA, { label: TRACKING_LABEL_PACKAGE_ASSET });
+ },
+ },
};
</script>
@@ -35,6 +54,7 @@ export default {
:title="$options.i18n.copyButtonTitle"
category="tertiary"
size="small"
+ @click="copySha"
/>
</div>
</details-row>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue
index 9e700a5236f..a049b0eff8d 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue
@@ -5,8 +5,13 @@ import { numberToHumanSize } from '~/lib/utils/number_utils';
import { __ } from '~/locale';
import FileSha from '~/packages_and_registries/package_registry/components/details/file_sha.vue';
import Tracking from '~/tracking';
+import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import {
+ TRACKING_LABEL_PACKAGE_ASSET,
+ TRACKING_ACTION_EXPAND_PACKAGE_ASSET,
+} from '~/packages_and_registries/package_registry/constants';
export default {
name: 'PackageFiles',
@@ -76,6 +81,11 @@ export default {
},
].filter((c) => !c.hide);
},
+ tracking() {
+ return {
+ category: packageTypeToTrackCategory(this.packageType),
+ };
+ },
},
methods: {
formatSize(size) {
@@ -84,6 +94,11 @@ export default {
hasDetails(item) {
return item.fileSha256 || item.fileMd5 || item.fileSha1;
},
+ trackToggleDetails(detailsShowing) {
+ if (!detailsShowing) {
+ this.track(TRACKING_ACTION_EXPAND_PACKAGE_ASSET, { label: TRACKING_LABEL_PACKAGE_ASSET });
+ }
+ },
},
i18n: {
deleteFile: __('Delete file'),
@@ -106,7 +121,10 @@ export default {
:aria-label="detailsShowing ? __('Collapse') : __('Expand')"
category="tertiary"
size="small"
- @click="toggleDetails"
+ @click="
+ toggleDetails();
+ trackToggleDetails(detailsShowing);
+ "
/>
<gl-link
:href="item.downloadPath"
@@ -129,8 +147,8 @@ export default {
:href="item.pipeline.commitPath"
class="gl-text-gray-500"
data-testid="commit-link"
- >{{ item.pipeline.sha }}</gl-link
- >
+ >{{ item.pipeline.sha }}
+ </gl-link>
</template>
<template #cell(created)="{ item }">
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 3c090951b7d..cea053992f8 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
@@ -9,6 +9,7 @@ export {
DELETE_PACKAGE_FILE_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
+ DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
} from '~/packages_and_registries/shared/constants';
export const PACKAGE_TYPE_CONAN = 'CONAN';
@@ -62,6 +63,12 @@ export const TRACKING_ACTION_COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND =
export const TRACKING_ACTION_COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND =
'copy_composer_package_include_command';
+export const TRACKING_LABEL_PACKAGE_ASSET = 'package_assets';
+
+export const TRACKING_ACTION_DOWNLOAD_PACKAGE_ASSET = 'download_package_asset';
+export const TRACKING_ACTION_EXPAND_PACKAGE_ASSET = 'expand_package_asset';
+export const TRACKING_ACTION_COPY_PACKAGE_ASSET_SHA = 'copy_package_asset_sha';
+
export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert';
export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting the package file.',
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue
index 768c8d6478b..29438fba86b 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue
@@ -33,7 +33,6 @@ import {
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,
@@ -41,6 +40,7 @@ import {
FETCH_PACKAGE_DETAILS_ERROR_MESSAGE,
DELETE_PACKAGE_FILE_ERROR_MESSAGE,
DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
+ DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
} from '~/packages_and_registries/package_registry/constants';
import destroyPackageFileMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql';
@@ -76,10 +76,10 @@ export default {
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,
+ DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
},
data() {
return {
@@ -288,7 +288,7 @@ export default {
v-if="showFiles"
:can-delete="packageEntity.canDestroy"
:package-files="packageFiles"
- @download-file="track($options.trackingActions.PULL_PACKAGE)"
+ @download-file="track($options.trackingActions.DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION)"
@delete-file="handleFileDelete"
/>
</gl-tab>
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 482a3ef2ead..3689199751d 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/bundle.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/bundle.js
@@ -1,7 +1,6 @@
import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
-import { parseBoolean } from '~/lib/utils/common_utils';
import Translate from '~/vue_shared/translate';
import SettingsApp from './components/group_settings_app.vue';
import { apolloProvider } from './graphql';
@@ -20,7 +19,6 @@ export default () => {
provide: {
groupPath: el.dataset.groupPath,
groupDependencyProxyPath: el.dataset.groupDependencyProxyPath,
- defaultExpanded: parseBoolean(el.dataset.defaultExpanded),
},
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 130d6977936..59d4f5e24d0 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
@@ -1,8 +1,7 @@
<script>
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 SettingsBlock from '~/packages_and_registries/shared/components/settings_block.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';
@@ -13,6 +12,7 @@ import {
import {
DEPENDENCY_PROXY_HEADER,
+ DEPENDENCY_PROXY_DESCRIPTION,
DEPENDENCY_PROXY_DOCS_PATH,
} from '~/packages_and_registries/settings/group/constants';
@@ -23,15 +23,14 @@ export default {
GlSprintf,
GlLink,
SettingsBlock,
- SettingsTitles,
},
i18n: {
DEPENDENCY_PROXY_HEADER,
+ DEPENDENCY_PROXY_DESCRIPTION,
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.',
@@ -40,7 +39,7 @@ export default {
links: {
DEPENDENCY_PROXY_DOCS_PATH,
},
- inject: ['defaultExpanded', 'groupPath', 'groupDependencyProxyPath'],
+ inject: ['groupPath', 'groupDependencyProxyPath'],
props: {
dependencyProxySettings: {
type: Object,
@@ -130,11 +129,9 @@ export default {
</script>
<template>
- <settings-block
- :default-expanded="defaultExpanded"
- data-qa-selector="dependency_proxy_settings_content"
- >
+ <settings-block data-qa-selector="dependency_proxy_settings_content">
<template #title> {{ $options.i18n.DEPENDENCY_PROXY_HEADER }} </template>
+ <template #description> {{ $options.i18n.DEPENDENCY_PROXY_DESCRIPTION }} </template>
<template #default>
<div>
<gl-toggle
@@ -156,13 +153,12 @@ export default {
</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"
+ class="gl-mt-6"
data-testid="dependency-proxy-ttl-policies-toggle"
/>
</div>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue
index b0088838acc..51a97aead49 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue
@@ -1,11 +1,9 @@
<script>
-import { GlSprintf, GlToggle, GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { GlToggle, GlFormGroup, GlFormInput } from '@gitlab/ui';
import { isEqual } from 'lodash';
import {
DUPLICATES_TOGGLE_LABEL,
- DUPLICATES_ALLOWED_DISABLED,
- DUPLICATES_ALLOWED_ENABLED,
DUPLICATES_SETTING_EXCEPTION_TITLE,
DUPLICATES_SETTINGS_EXCEPTION_LEGEND,
} from '~/packages_and_registries/settings/group/constants';
@@ -18,7 +16,6 @@ export default {
DUPLICATES_SETTINGS_EXCEPTION_LEGEND,
},
components: {
- GlSprintf,
GlToggle,
GlFormGroup,
GlFormInput,
@@ -63,9 +60,6 @@ export default {
},
},
computed: {
- enabledButtonLabel() {
- return this.duplicatesAllowed ? DUPLICATES_ALLOWED_ENABLED : DUPLICATES_ALLOWED_DISABLED;
- },
isExceptionRegexValid() {
return !this.duplicateExceptionRegexError;
},
@@ -80,41 +74,30 @@ export default {
<template>
<form>
- <div class="gl-display-flex">
- <gl-toggle
- :data-qa-selector="toggleQaSelector"
- :label="$options.i18n.DUPLICATES_TOGGLE_LABEL"
- label-position="hidden"
- :value="duplicatesAllowed"
+ <gl-toggle
+ :data-qa-selector="toggleQaSelector"
+ :label="$options.i18n.DUPLICATES_TOGGLE_LABEL"
+ :value="!duplicatesAllowed"
+ :disabled="loading"
+ @change="update(modelNames.allowed, !$event)"
+ />
+ <gl-form-group
+ v-if="!duplicatesAllowed"
+ class="gl-mt-4"
+ :label="$options.i18n.DUPLICATES_SETTING_EXCEPTION_TITLE"
+ label-size="sm"
+ :state="isExceptionRegexValid"
+ :invalid-feedback="duplicateExceptionRegexError"
+ :description="$options.i18n.DUPLICATES_SETTINGS_EXCEPTION_LEGEND"
+ label-for="maven-duplicated-settings-regex-input"
+ >
+ <gl-form-input
+ id="maven-duplicated-settings-regex-input"
:disabled="loading"
- @change="update(modelNames.allowed, $event)"
+ size="lg"
+ :value="duplicateExceptionRegex"
+ @change="update(modelNames.exception, $event)"
/>
- <div class="gl-ml-5">
- <div data-testid="toggle-label" :data-qa-selector="labelQaSelector">
- <gl-sprintf :message="enabledButtonLabel">
- <template #bold="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- </div>
- <gl-form-group
- v-if="!duplicatesAllowed"
- class="gl-mt-4"
- :label="$options.i18n.DUPLICATES_SETTING_EXCEPTION_TITLE"
- label-size="sm"
- :state="isExceptionRegexValid"
- :invalid-feedback="duplicateExceptionRegexError"
- :description="$options.i18n.DUPLICATES_SETTINGS_EXCEPTION_LEGEND"
- label-for="maven-duplicated-settings-regex-input"
- >
- <gl-form-input
- id="maven-duplicated-settings-regex-input"
- :disabled="loading"
- :value="duplicateExceptionRegex"
- @change="update(modelNames.exception, $event)"
- />
- </gl-form-group>
- </div>
- </div>
+ </gl-form-group>
</form>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue
index b7e88945dbd..abb9f02d290 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue
@@ -1,17 +1,15 @@
<script>
-import { GlSprintf, GlLink } from '@gitlab/ui';
import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue';
import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue';
import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue';
import {
PACKAGE_SETTINGS_HEADER,
PACKAGE_SETTINGS_DESCRIPTION,
- PACKAGES_DOCS_PATH,
} from '~/packages_and_registries/settings/group/constants';
import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql';
import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update';
import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses';
-import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
+import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue';
export default {
name: 'PackageSettings',
@@ -19,18 +17,13 @@ export default {
PACKAGE_SETTINGS_HEADER,
PACKAGE_SETTINGS_DESCRIPTION,
},
- links: {
- PACKAGES_DOCS_PATH,
- },
components: {
- GlSprintf,
- GlLink,
SettingsBlock,
MavenSettings,
GenericSettings,
DuplicatesSettings,
},
- inject: ['defaultExpanded', 'groupPath'],
+ inject: ['groupPath'],
props: {
packageSettings: {
type: Object,
@@ -91,20 +84,11 @@ export default {
</script>
<template>
- <settings-block
- :default-expanded="defaultExpanded"
- data-qa-selector="package_registry_settings_content"
- >
+ <settings-block data-qa-selector="package_registry_settings_content">
<template #title> {{ $options.i18n.PACKAGE_SETTINGS_HEADER }}</template>
<template #description>
<span data-testid="description">
- <gl-sprintf :message="$options.i18n.PACKAGE_SETTINGS_DESCRIPTION">
- <template #link="{ content }">
- <gl-link :href="$options.links.PACKAGES_DOCS_PATH" target="_blank">{{
- content
- }}</gl-link>
- </template>
- </gl-sprintf>
+ {{ $options.i18n.PACKAGE_SETTINGS_DESCRIPTION }}
</span>
</template>
<template #default>
@@ -116,8 +100,8 @@ export default {
:duplicate-exception-regex-error="errors.mavenDuplicateExceptionRegex"
:model-names="modelNames"
:loading="isLoading"
- toggle-qa-selector="allow_duplicates_toggle"
- label-qa-selector="allow_duplicates_label"
+ toggle-qa-selector="reject_duplicates_toggle"
+ label-qa-selector="reject_duplicates_label"
@update="updateSettings"
/>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
index 0249b475e46..34764663892 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
@@ -1,17 +1,13 @@
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__, __ } from '~/locale';
-export const PACKAGE_SETTINGS_HEADER = s__('PackageRegistry|Package Registry');
+export const PACKAGE_SETTINGS_HEADER = s__('PackageRegistry|Duplicate packages');
export const PACKAGE_SETTINGS_DESCRIPTION = s__(
- 'PackageRegistry|Use GitLab as a private registry for common package formats. %{linkStart}Learn more.%{linkEnd}',
+ 'PackageRegistry|Allow packages with the same name and version to be uploaded to the registry. The newest version of a package is always used when installing.',
);
-export const DUPLICATES_TOGGLE_LABEL = s__('PackageRegistry|Allow duplicates');
-export const DUPLICATES_ALLOWED_DISABLED = s__(
- 'PackageRegistry|%{boldStart}Do not allow duplicates%{boldEnd} - Reject packages with the same name and version.',
-);
-export const DUPLICATES_ALLOWED_ENABLED = s__(
- 'PackageRegistry|%{boldStart}Allow duplicates%{boldEnd} - Accept packages with the same name and version.',
+export const DUPLICATES_TOGGLE_LABEL = s__(
+ 'PackageRegistry|Reject packages with the same name and version',
);
export const DUPLICATES_SETTING_EXCEPTION_TITLE = __('Exceptions');
export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__(
@@ -19,6 +15,9 @@ export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__(
);
export const DEPENDENCY_PROXY_HEADER = s__('DependencyProxy|Dependency Proxy');
+export const DEPENDENCY_PROXY_DESCRIPTION = s__(
+ 'DependencyProxy|Enable the Dependency Proxy and settings for clearing the cache.',
+);
// Parameters
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue
index fdc7bd39780..90a18d5cf5a 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue
@@ -81,7 +81,7 @@ export default {
</script>
<template>
- <settings-block :collapsible="false">
+ <settings-block data-testid="container-expiration-policy-project-settings">
<template #title> {{ $options.i18n.CONTAINER_CLEANUP_POLICY_TITLE }}</template>
<template #description>
<span>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue
index d75fb31fd98..7682754fdcb 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue
@@ -30,6 +30,11 @@ export default {
type: String,
required: true,
},
+ description: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
};
</script>
@@ -46,5 +51,10 @@ export default {
{{ option.label }}
</option>
</gl-form-select>
+ <template v-if="description" #description>
+ <span data-testid="description" class="gl-text-gray-400">
+ {{ description }}
+ </span>
+ </template>
</gl-form-group>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue
new file mode 100644
index 00000000000..1170407a349
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue
@@ -0,0 +1,68 @@
+<script>
+import { GlAlert, GlSprintf } from '@gitlab/ui';
+import {
+ FETCH_SETTINGS_ERROR_MESSAGE,
+ PACKAGES_CLEANUP_POLICY_TITLE,
+ PACKAGES_CLEANUP_POLICY_DESCRIPTION,
+} from '~/packages_and_registries/settings/project/constants';
+import packagesCleanupPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql';
+import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
+
+import PackagesCleanupPolicyForm from './packages_cleanup_policy_form.vue';
+
+export default {
+ components: {
+ SettingsBlock,
+ GlAlert,
+ GlSprintf,
+ PackagesCleanupPolicyForm,
+ },
+ inject: ['projectPath'],
+ i18n: {
+ FETCH_SETTINGS_ERROR_MESSAGE,
+ PACKAGES_CLEANUP_POLICY_TITLE,
+ PACKAGES_CLEANUP_POLICY_DESCRIPTION,
+ },
+ apollo: {
+ packagesCleanupPolicy: {
+ query: packagesCleanupPolicyQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ };
+ },
+ update: (data) => data.project?.packagesCleanupPolicy || {},
+ error(e) {
+ this.fetchSettingsError = e;
+ },
+ },
+ },
+ data() {
+ return {
+ fetchSettingsError: false,
+ packagesCleanupPolicy: {},
+ };
+ },
+};
+</script>
+
+<template>
+ <settings-block>
+ <template #title> {{ $options.i18n.PACKAGES_CLEANUP_POLICY_TITLE }}</template>
+ <template #description>
+ <span data-testid="description">
+ <gl-sprintf :message="$options.i18n.PACKAGES_CLEANUP_POLICY_DESCRIPTION" />
+ </span>
+ </template>
+ <template #default>
+ <gl-alert v-if="fetchSettingsError" variant="warning" :dismissible="false">
+ <gl-sprintf :message="$options.i18n.FETCH_SETTINGS_ERROR_MESSAGE" />
+ </gl-alert>
+ <packages-cleanup-policy-form
+ v-else
+ v-model="packagesCleanupPolicy"
+ :is-loading="$apollo.queries.packagesCleanupPolicy.loading"
+ />
+ </template>
+ </settings-block>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue
new file mode 100644
index 00000000000..b1751d5174a
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue
@@ -0,0 +1,137 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import {
+ UPDATE_SETTINGS_ERROR_MESSAGE,
+ UPDATE_SETTINGS_SUCCESS_MESSAGE,
+ SET_CLEANUP_POLICY_BUTTON,
+ KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION,
+ KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME,
+ KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL,
+} from '~/packages_and_registries/settings/project/constants';
+import updatePackagesCleanupPolicyMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql';
+import { formOptionsGenerator } from '~/packages_and_registries/settings/project/utils';
+import Tracking from '~/tracking';
+import ExpirationDropdown from './expiration_dropdown.vue';
+
+export default {
+ components: {
+ GlButton,
+ ExpirationDropdown,
+ },
+ mixins: [Tracking.mixin()],
+ inject: ['projectPath'],
+ props: {
+ value: {
+ type: Object,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ formOptions: formOptionsGenerator(),
+ i18n: {
+ KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL,
+ KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION,
+ SET_CLEANUP_POLICY_BUTTON,
+ },
+ data() {
+ return {
+ tracking: {
+ label: 'packages_cleanup_policies',
+ },
+ mutationLoading: false,
+ };
+ },
+ computed: {
+ prefilledForm() {
+ return {
+ ...this.value,
+ keepNDuplicatedPackageFiles: this.findDefaultOption(
+ KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME,
+ ),
+ };
+ },
+ showLoadingIcon() {
+ return this.isLoading || this.mutationLoading;
+ },
+ isSubmitButtonDisabled() {
+ return this.showLoadingIcon;
+ },
+ isFieldDisabled() {
+ return this.showLoadingIcon;
+ },
+ mutationVariables() {
+ return {
+ projectPath: this.projectPath,
+ keepNDuplicatedPackageFiles: this.prefilledForm.keepNDuplicatedPackageFiles,
+ };
+ },
+ },
+ methods: {
+ findDefaultOption(option) {
+ return this.value[option] || this.$options.formOptions[option].find((f) => f.default)?.key;
+ },
+ submit() {
+ this.track('submit_packages_cleanup_form');
+ this.mutationLoading = true;
+ return this.$apollo
+ .mutate({
+ mutation: updatePackagesCleanupPolicyMutation,
+ variables: {
+ input: this.mutationVariables,
+ },
+ })
+ .then(({ data }) => {
+ const [errorMessage] = data?.updatePackagesCleanupPolicy?.errors ?? [];
+ if (errorMessage) {
+ throw errorMessage;
+ } else {
+ this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE);
+ }
+ })
+ .catch(() => {
+ this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE);
+ })
+ .finally(() => {
+ this.mutationLoading = false;
+ });
+ },
+ onModelChange(newValue, model) {
+ this.$emit('input', { ...this.value, [model]: newValue });
+ },
+ },
+};
+</script>
+
+<template>
+ <form ref="form-element" @submit.prevent="submit">
+ <div class="gl-md-max-w-50p">
+ <expiration-dropdown
+ v-model="prefilledForm.keepNDuplicatedPackageFiles"
+ :disabled="isFieldDisabled"
+ :form-options="$options.formOptions.keepNDuplicatedPackageFiles"
+ :label="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL"
+ :description="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION"
+ name="keep-n-duplicated-package-files"
+ data-testid="keep-n-duplicated-package-files-dropdown"
+ @input="onModelChange($event, 'keepNDuplicatedPackageFiles')"
+ />
+ </div>
+ <div class="gl-mt-7 gl-display-flex gl-align-items-center">
+ <gl-button
+ data-testid="save-button"
+ type="submit"
+ :disabled="isSubmitButtonDisabled"
+ :loading="showLoadingIcon"
+ category="primary"
+ variant="confirm"
+ class="js-no-auto-disable gl-mr-4"
+ >
+ {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }}
+ </gl-button>
+ </div>
+ </form>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
index 95af19e6d85..710cfe7b1eb 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
@@ -1,15 +1,19 @@
<script>
import ContainerExpirationPolicy from './container_expiration_policy.vue';
+import PackagesCleanupPolicy from './packages_cleanup_policy.vue';
export default {
components: {
ContainerExpirationPolicy,
+ PackagesCleanupPolicy,
},
+ inject: ['showContainerRegistrySettings', 'showPackageRegistrySettings'],
};
</script>
<template>
- <section data-testid="registry-settings-app">
- <container-expiration-policy />
- </section>
+ <div>
+ <packages-cleanup-policy v-if="showPackageRegistrySettings" />
+ <container-expiration-policy v-if="showContainerRegistrySettings" />
+ </div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
index 40f980d15fb..948520151ce 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
@@ -55,6 +55,31 @@ export const EXPIRATION_POLICY_FOOTER_NOTE = s__(
'ContainerRegistry|Note: Any policy update will result in a change to the scheduled run date and time',
);
+export const PACKAGES_CLEANUP_POLICY_TITLE = s__(
+ 'PackageRegistry|Manage storage used by package assets',
+);
+export const PACKAGES_CLEANUP_POLICY_DESCRIPTION = s__(
+ 'PackageRegistry|When a package with same name and version is uploaded to the registry, more assets are added to the package. To save storage space, keep only the most recent assets.',
+);
+export const KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL = s__(
+ 'PackageRegistry|Number of duplicate assets to keep',
+);
+export const KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION = s__(
+ 'PackageRegistry|Examples of assets include .pom & .jar files',
+);
+
+export const KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME = 'keepNDuplicatedPackageFiles';
+
+export const KEEP_N_DUPLICATED_PACKAGE_FILES_OPTIONS = [
+ { key: 'ONE_PACKAGE_FILE', label: 1, default: false },
+ { key: 'TEN_PACKAGE_FILES', label: 10, default: false },
+ { key: 'TWENTY_PACKAGE_FILES', label: 20, default: false },
+ { key: 'THIRTY_PACKAGE_FILES', label: 30, default: false },
+ { key: 'FORTY_PACKAGE_FILES', label: 40, default: false },
+ { key: 'FIFTY_PACKAGE_FILES', label: 50, default: false },
+ { key: 'ALL_PACKAGE_FILES', label: __('All'), default: true },
+];
+
export const KEEP_N_OPTIONS = [
{ key: 'ONE_TAG', variable: 1, default: false },
{ key: 'FIVE_TAGS', variable: 5, default: false },
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/packages_cleanup_policy.fragment.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/packages_cleanup_policy.fragment.graphql
new file mode 100644
index 00000000000..a77ede37884
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/packages_cleanup_policy.fragment.graphql
@@ -0,0 +1,4 @@
+fragment PackagesCleanupPolicyFields on PackagesCleanupPolicy {
+ keepNDuplicatedPackageFiles
+ nextRunAt
+}
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql
new file mode 100644
index 00000000000..31cdd67e881
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/packages_cleanup_policy.fragment.graphql"
+
+mutation updatePackagesCleanupPolicy($input: UpdatePackagesCleanupPolicyInput!) {
+ updatePackagesCleanupPolicy(input: $input) {
+ packagesCleanupPolicy {
+ ...PackagesCleanupPolicyFields
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql
new file mode 100644
index 00000000000..0e9af253f2c
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/packages_cleanup_policy.fragment.graphql"
+
+query getProjectPackagesCleanupPolicy($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ id
+ packagesCleanupPolicy {
+ ...PackagesCleanupPolicyFields
+ }
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js
index 17c33073668..daf1da6eac8 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js
@@ -20,6 +20,8 @@ export default () => {
adminSettingsPath,
tagsRegexHelpPagePath,
helpPagePath,
+ showContainerRegistrySettings,
+ showPackageRegistrySettings,
} = el.dataset;
return new Vue({
el,
@@ -34,6 +36,8 @@ export default () => {
adminSettingsPath,
tagsRegexHelpPagePath,
helpPagePath,
+ showContainerRegistrySettings: parseBoolean(showContainerRegistrySettings),
+ showPackageRegistrySettings: parseBoolean(showPackageRegistrySettings),
},
render(createElement) {
return createElement('registry-settings-app', {});
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/utils.js b/app/assets/javascripts/packages_and_registries/settings/project/utils.js
index b577a051862..847965454e9 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/utils.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/utils.js
@@ -1,5 +1,11 @@
import { n__ } from '~/locale';
-import { KEEP_N_OPTIONS, CADENCE_OPTIONS, OLDER_THAN_OPTIONS } from './constants';
+import {
+ KEEP_N_OPTIONS,
+ CADENCE_OPTIONS,
+ OLDER_THAN_OPTIONS,
+ KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME,
+ KEEP_N_DUPLICATED_PACKAGE_FILES_OPTIONS,
+} from './constants';
export const findDefaultOption = (options) => {
const item = options.find((o) => o.default);
@@ -25,5 +31,6 @@ export const formOptionsGenerator = () => {
olderThan: optionLabelGenerator(OLDER_THAN_OPTIONS, olderThanTranslationGenerator),
cadence: CADENCE_OPTIONS,
keepN: optionLabelGenerator(KEEP_N_OPTIONS, keepNTranslationGenerator),
+ [KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME]: KEEP_N_DUPLICATED_PACKAGE_FILES_OPTIONS,
};
};
diff --git a/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue b/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue
new file mode 100644
index 00000000000..5caf95cd050
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue
@@ -0,0 +1,17 @@
+<template>
+ <section class="settings gl-py-7">
+ <div class="gl-lg-display-flex">
+ <div class="gl-lg-w-half gl-pr-10">
+ <h4>
+ <slot name="title"></slot>
+ </h4>
+ <p>
+ <slot name="description"></slot>
+ </p>
+ </div>
+ <div class="gl-lg-w-half gl-pt-3">
+ <slot></slot>
+ </div>
+ </div>
+ </section>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js
index afc72a2c627..5505205cf33 100644
--- a/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js
+++ b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js
@@ -11,6 +11,7 @@ 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 DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION = 'download_package_asset';
export const TRACKING_ACTIONS = {
DELETE_PACKAGE: DELETE_PACKAGE_TRACKING_ACTION,
@@ -20,6 +21,7 @@ export const TRACKING_ACTIONS = {
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,
+ DOWNLOAD_PACKAGE_ASSET: DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
};
export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert';
diff --git a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue
index 5ecacb84d65..ccb449f96e1 100644
--- a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue
+++ b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue
@@ -12,6 +12,7 @@ import {
import { toSafeInteger } from 'lodash';
import csrf from '~/lib/utils/csrf';
import { __, n__, s__, sprintf } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SignupCheckbox from './signup_checkbox.vue';
const DENYLIST_TYPE_RAW = 'raw';
@@ -31,7 +32,12 @@ export default {
GlLink,
SignupCheckbox,
GlModal,
+ PasswordComplexityCheckboxGroup: () =>
+ import(
+ 'ee_component/pages/admin/application_settings/general/components/password_complexity_checkbox_group.vue'
+ ),
},
+ mixins: [glFeatureFlagMixin()],
inject: [
'host',
'settingsPath',
@@ -178,6 +184,9 @@ export default {
this.submitForm();
},
+ setPasswordComplexity({ name, value }) {
+ this.$set(this.form, name, value);
+ },
submitForm() {
this.$refs.form.submit();
},
@@ -291,9 +300,7 @@ export default {
<template #description>
<gl-sprintf
:message="
- s__(
- 'ApplicationSettings|See GitLab\'s %{linkStart}Password Policy Guidelines%{linkEnd}.',
- )
+ s__('ApplicationSettings|See %{linkStart}password policy guidelines%{linkEnd}.')
"
>
<template #link="{ content }">
@@ -305,6 +312,10 @@ export default {
</template>
</gl-form-group>
+ <password-complexity-checkbox-group
+ v-if="glFeatures.passwordComplexity"
+ @set-password-complexity="setPasswordComplexity"
+ />
<gl-form-group
:description="$options.i18n.domainAllowListDescription"
:label="$options.i18n.domainAllowListLabel"
diff --git a/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js b/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js
index a50d8de0e88..0d5c55cb87b 100644
--- a/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js
+++ b/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js
@@ -18,6 +18,10 @@ export default function initSignupRestrictions(elementSelector = '#js-signup-for
'domainDenylistEnabled',
'denylistTypeRawSelected',
'emailRestrictionsEnabled',
+ 'passwordNumberRequired',
+ 'passwordLowercaseRequired',
+ 'passwordUppercaseRequired',
+ 'passwordSymbolRequired',
],
});
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
index 44299d235d5..e45a40bd44c 100644
--- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js
+++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
@@ -28,20 +28,32 @@ export default class Todos {
}
unbindEvents() {
- $('.js-done-todo, .js-undo-todo, .js-add-todo').off('click', this.updateRowStateClickedWrapper);
- $('.js-todos-mark-all', '.js-todos-undo-all').off('click', this.updateallStateClickedWrapper);
- $('.todo').off('click', this.goToTodoUrl);
- $('.todo').off('auxclick', this.goToTodoUrl);
+ document.querySelectorAll('.js-done-todo, .js-undo-todo, .js-add-todo').forEach((el) => {
+ el.removeEventListener('click', this.updateRowStateClickedWrapper);
+ });
+ document.querySelectorAll('.js-todos-mark-all, .js-todos-undo-all').forEach((el) => {
+ el.removeEventListener('click', this.updateallStateClickedWrapper);
+ });
+ document.querySelectorAll('.todo').forEach((el) => {
+ el.removeEventListener('click', this.goToTodoUrl);
+ el.removeEventListener('auxclick', this.goToTodoUrl);
+ });
}
bindEvents() {
this.updateRowStateClickedWrapper = this.updateRowStateClicked.bind(this);
this.updateAllStateClickedWrapper = this.updateAllStateClicked.bind(this);
- $('.js-done-todo, .js-undo-todo, .js-add-todo').on('click', this.updateRowStateClickedWrapper);
- $('.js-todos-mark-all, .js-todos-undo-all').on('click', this.updateAllStateClickedWrapper);
- $('.todo').on('click', this.goToTodoUrl);
- $('.todo').on('auxclick', this.goToTodoUrl);
+ document.querySelectorAll('.js-done-todo, .js-undo-todo, .js-add-todo').forEach((el) => {
+ el.addEventListener('click', this.updateRowStateClickedWrapper);
+ });
+ document.querySelectorAll('.js-todos-mark-all, .js-todos-undo-all').forEach((el) => {
+ el.addEventListener('click', this.updateAllStateClickedWrapper);
+ });
+ document.querySelectorAll('.todo').forEach((el) => {
+ el.addEventListener('click', this.goToTodoUrl);
+ el.addEventListener('auxclick', this.goToTodoUrl);
+ });
}
initFilters() {
@@ -181,7 +193,13 @@ export default class Todos {
}
updateBadges(data) {
- $(document).trigger('todo:toggle', data.count);
+ const event = new CustomEvent('todo:toggle', {
+ detail: {
+ count: data.count,
+ },
+ });
+
+ document.dispatchEvent(event);
document.querySelector('.js-todos-pending .js-todos-badge').innerHTML = addDelimiter(
data.count,
);
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index c7c2f6f773e..62d47cb49b8 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -5,12 +5,11 @@ import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { s__ } from '~/locale';
import { initMembersApp } from '~/members';
-import { MEMBER_TYPES } from '~/members/constants';
+import { MEMBER_TYPES, EE_APP_OPTIONS } from 'ee_else_ce/members/constants';
import { groupLinkRequestFormatter } from '~/members/utils';
const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
-
-initMembersApp(document.querySelector('.js-group-members-list-app'), {
+const APP_OPTIONS = {
[MEMBER_TYPES.user]: {
tableFields: SHARED_FIELDS.concat(['source', 'granted', 'userCreatedAt', 'lastActivityOn']),
tableAttrs: { tr: { 'data-qa-selector': 'member_row' } },
@@ -61,7 +60,10 @@ initMembersApp(document.querySelector('.js-group-members-list-app'), {
tableFields: SHARED_FIELDS.concat('requested'),
requestFormatter: groupMemberRequestFormatter,
},
-});
+ ...EE_APP_OPTIONS,
+};
+
+initMembersApp(document.querySelector('.js-group-members-list-app'), APP_OPTIONS);
initInviteMembersModal();
initInviteGroupsModal();
diff --git a/app/assets/javascripts/pages/groups/runners/index.js b/app/assets/javascripts/pages/groups/runners/index/index.js
index ca1a6bdab75..ca1a6bdab75 100644
--- a/app/assets/javascripts/pages/groups/runners/index.js
+++ b/app/assets/javascripts/pages/groups/runners/index/index.js
diff --git a/app/assets/javascripts/pages/groups/runners/show/index.js b/app/assets/javascripts/pages/groups/runners/show/index.js
new file mode 100644
index 00000000000..c59e3b80dc1
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/runners/show/index.js
@@ -0,0 +1,3 @@
+import { initGroupRunnerShow } from '~/runner/group_runner_show';
+
+initGroupRunnerShow();
diff --git a/app/assets/javascripts/pages/projects/branches/new/index.js b/app/assets/javascripts/pages/projects/branches/new/index.js
index 364223f1898..dbae89b5ade 100644
--- a/app/assets/javascripts/pages/projects/branches/new/index.js
+++ b/app/assets/javascripts/pages/projects/branches/new/index.js
@@ -1,8 +1,7 @@
-import $ from 'jquery';
import NewBranchForm from '~/new_branch_form';
// eslint-disable-next-line no-new
new NewBranchForm(
- $('.js-create-branch-form'),
+ document.querySelector('.js-create-branch-form'),
JSON.parse(document.getElementById('availableRefs').innerHTML),
);
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
index 701bf0c1e1d..f92a40e057f 100644
--- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
+++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
@@ -329,7 +329,7 @@ export default {
</div>
<p class="gl-mt-n5 gl-text-gray-500">
- {{ s__('ForkProject|Want to house several dependent projects under the same namespace?') }}
+ {{ s__('ForkProject|Want to organize several dependent projects under the same namespace?') }}
<gl-link :href="newGroupPath" target="_blank">
{{ s__('ForkProject|Create a group') }}
</gl-link>
diff --git a/app/assets/javascripts/pages/projects/google_cloud/configuration/index.js b/app/assets/javascripts/pages/projects/google_cloud/configuration/index.js
new file mode 100644
index 00000000000..abececa44ee
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/google_cloud/configuration/index.js
@@ -0,0 +1,3 @@
+import init from '~/google_cloud/configuration/index';
+
+init();
diff --git a/app/assets/javascripts/pages/projects/google_cloud/databases/index.js b/app/assets/javascripts/pages/projects/google_cloud/databases/index.js
new file mode 100644
index 00000000000..5482324f1cd
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/google_cloud/databases/index.js
@@ -0,0 +1,3 @@
+import init from '~/google_cloud/databases/index';
+
+init();
diff --git a/app/assets/javascripts/pages/projects/google_cloud/deployments/index.js b/app/assets/javascripts/pages/projects/google_cloud/deployments/index.js
new file mode 100644
index 00000000000..b5a29b3825b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/google_cloud/deployments/index.js
@@ -0,0 +1,3 @@
+import init from '~/google_cloud/deployments/index';
+
+init();
diff --git a/app/assets/javascripts/pages/projects/google_cloud/gcp_regions/index.js b/app/assets/javascripts/pages/projects/google_cloud/gcp_regions/index.js
new file mode 100644
index 00000000000..fb66e2fa051
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/google_cloud/gcp_regions/index.js
@@ -0,0 +1,3 @@
+import init from '~/google_cloud/gcp_regions/index';
+
+init();
diff --git a/app/assets/javascripts/pages/projects/google_cloud/index.js b/app/assets/javascripts/pages/projects/google_cloud/index.js
deleted file mode 100644
index 4506ea8efd1..00000000000
--- a/app/assets/javascripts/pages/projects/google_cloud/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import initGoogleCloud from '~/google_cloud/index';
-
-initGoogleCloud();
diff --git a/app/assets/javascripts/pages/projects/google_cloud/service_accounts/index.js b/app/assets/javascripts/pages/projects/google_cloud/service_accounts/index.js
new file mode 100644
index 00000000000..8b644c2b324
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/google_cloud/service_accounts/index.js
@@ -0,0 +1,3 @@
+import init from '~/google_cloud/service_accounts/index';
+
+init();
diff --git a/app/assets/javascripts/pages/projects/jobs/index/index.js b/app/assets/javascripts/pages/projects/jobs/index/index.js
index 75194499a7f..eb3a24f38a8 100644
--- a/app/assets/javascripts/pages/projects/jobs/index/index.js
+++ b/app/assets/javascripts/pages/projects/jobs/index/index.js
@@ -1,23 +1,3 @@
-import Vue from 'vue';
import initJobsTable from '~/jobs/components/table';
-import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
-if (gon.features?.jobsTableVue) {
- initJobsTable();
-} else {
- const remainingTimeElements = document.querySelectorAll('.js-remaining-time');
-
- remainingTimeElements.forEach(
- (el) =>
- new Vue({
- el,
- render(h) {
- return h(GlCountdown, {
- props: {
- endDateString: el.dateTime,
- },
- });
- },
- }),
- );
-}
+initJobsTable();
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/included_in_trial_indicator.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/included_in_trial_indicator.vue
new file mode 100644
index 00000000000..693dc6a15ad
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/included_in_trial_indicator.vue
@@ -0,0 +1,15 @@
+<script>
+import { s__ } from '~/locale';
+
+export default {
+ name: 'IncludedInTrialIndicator',
+ i18n: {
+ trialOnly: s__('LearnGitlab|- Included in trial'),
+ },
+};
+</script>
+<template>
+ <span class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only">
+ {{ $options.i18n.trialOnly }}
+ </span>
+</template>
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 db9ef4df8af..54e15b6552c 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
@@ -38,14 +38,16 @@ export default {
actionsData: this.actions,
};
},
- maxValue: Object.keys(ACTION_LABELS).length,
actionSections: Object.keys(ACTION_SECTIONS),
computed: {
+ maxValue() {
+ return Object.keys(this.actionsData).length;
+ },
progressValue() {
return Object.values(this.actionsData).filter((a) => a.completed).length;
},
progressPercentage() {
- return Math.round((this.progressValue / this.$options.maxValue) * 100);
+ return Math.round((this.progressValue / this.maxValue) * 100);
},
},
mounted() {
@@ -125,7 +127,7 @@ export default {
<template #percentSymbol>%</template>
</gl-sprintf>
</p>
- <gl-progress-bar :value="progressValue" :max="$options.maxValue" />
+ <gl-progress-bar :value="progressValue" :max="maxValue" />
</div>
<div class="row">
<div
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue
deleted file mode 100644
index 09cc0032871..00000000000
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue
+++ /dev/null
@@ -1,70 +0,0 @@
-<script>
-import { GlLink, GlCard, GlIcon } from '@gitlab/ui';
-import { s__ } from '~/locale';
-
-export default {
- name: 'LearnGitlabInfoCard',
- components: { GlLink, GlCard, GlIcon },
- i18n: {
- trial: s__('Learn GitLab|Trial only'),
- },
- props: {
- title: {
- required: true,
- type: String,
- },
- description: {
- required: true,
- type: String,
- },
- actionLabel: {
- required: true,
- type: String,
- },
- url: {
- required: true,
- type: String,
- },
- completed: {
- required: true,
- type: Boolean,
- },
- svg: {
- required: true,
- type: String,
- },
- trialRequired: {
- default: false,
- required: false,
- type: Boolean,
- },
- },
-};
-</script>
-<template>
- <gl-card class="gl-pt-0">
- <div class="gl-text-right gl-h-5">
- <gl-icon
- v-if="completed"
- name="check-circle-filled"
- class="gl-text-green-500"
- :size="16"
- data-testid="completed-icon"
- />
- <span
- v-else-if="trialRequired"
- class="gl-text-gray-500 gl-font-sm gl-font-style-italic"
- data-testid="trial-only"
- >{{ $options.i18n.trial }}</span
- >
- </div>
- <div
- class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content"
- >
- <img :src="svg" :alt="actionLabel" />
- <h6>{{ title }}</h6>
- <p class="gl-font-sm gl-text-gray-700">{{ description }}</p>
- <gl-link :href="url" target="_blank" rel="noopener noreferrer" />
- </div>
- </gl-card>
-</template>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
index 1912477758b..4eab0cccb06 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
@@ -6,6 +6,7 @@ import { isExperimentVariant } from '~/experimentation/utils';
import eventHub from '~/invite_members/event_hub';
import { s__, __ } from '~/locale';
import { ACTION_LABELS } from '../constants';
+import IncludedInTrialIndicator from './included_in_trial_indicator.vue';
export default {
name: 'LearnGitlabSectionLink',
@@ -15,12 +16,12 @@ export default {
GlButton,
GlPopover,
GitlabExperiment,
+ IncludedInTrialIndicator,
},
directives: {
GlTooltip,
},
i18n: {
- trialOnly: s__('LearnGitlab|Trial only'),
contactAdmin: s__('LearnGitlab|Contact your administrator to start a free Ultimate trial.'),
viewAdminList: s__('LearnGitlab|View administrator list'),
watchHow: __('Watch how'),
@@ -41,12 +42,6 @@ export default {
};
},
computed: {
- linkTitle() {
- return ACTION_LABELS[this.action].title;
- },
- trialOnly() {
- return ACTION_LABELS[this.action].trialRequired;
- },
showInviteModalLink() {
return (
this.action === 'userAdded' && isExperimentVariant('invite_for_help_continuous_onboarding')
@@ -55,49 +50,51 @@ export default {
openInNewTab() {
return ACTION_LABELS[this.action]?.openInNewTab === true || this.value.openInNewTab === true;
},
- linkToVideoTutorial() {
- return ACTION_LABELS[this.action].videoTutorial;
- },
},
methods: {
openModal() {
eventHub.$emit('openModal', { source: 'learn_gitlab' });
},
+ actionLabelValue(value) {
+ return ACTION_LABELS[this.action][value];
+ },
},
};
</script>
<template>
<div class="gl-mb-4">
- <div v-if="trialOnly" class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only">
- {{ $options.i18n.trialOnly }}
- </div>
<div class="flex align-items-center">
<span v-if="value.completed" class="gl-text-green-500">
<gl-icon name="check-circle-filled" :size="16" data-testid="completed-icon" />
- {{ linkTitle }}
+ {{ actionLabelValue('title') }}
+ <included-in-trial-indicator v-if="actionLabelValue('trialRequired')" />
</span>
- <gl-link
- v-else-if="showInviteModalLink"
- data-track-action="click_link"
- :data-track-label="linkTitle"
- data-track-property="Growth::Activation::Experiment::InviteForHelpContinuousOnboarding"
- data-testid="invite-for-help-continuous-onboarding-experiment-link"
- @click="openModal"
- >
- {{ linkTitle }}
- </gl-link>
- <gl-link
- v-else-if="value.enabled"
- :target="openInNewTab ? '_blank' : '_self'"
- :href="value.url"
- data-testid="uncompleted-learn-gitlab-link"
- data-track-action="click_link"
- :data-track-label="linkTitle"
- >
- {{ linkTitle }}
- </gl-link>
+ <div v-else-if="showInviteModalLink">
+ <gl-link
+ data-track-action="click_link"
+ :data-track-label="actionLabelValue('trackLabel')"
+ data-track-property="Growth::Activation::Experiment::InviteForHelpContinuousOnboarding"
+ data-testid="invite-for-help-continuous-onboarding-experiment-link"
+ @click="openModal"
+ >{{ actionLabelValue('title') }}</gl-link
+ >
+
+ <included-in-trial-indicator v-if="actionLabelValue('trialRequired')" />
+ </div>
+ <div v-else-if="value.enabled">
+ <gl-link
+ :target="openInNewTab ? '_blank' : '_self'"
+ :href="value.url"
+ data-testid="uncompleted-learn-gitlab-link"
+ data-track-action="click_link"
+ :data-track-label="actionLabelValue('trackLabel')"
+ >{{ actionLabelValue('title') }}</gl-link
+ >
+
+ <included-in-trial-indicator v-if="actionLabelValue('trialRequired')" />
+ </div>
<template v-else>
- <div data-testid="disabled-learn-gitlab-link">{{ linkTitle }}</div>
+ <div data-testid="disabled-learn-gitlab-link">{{ actionLabelValue('title') }}</div>
<gl-button
:id="popoverId"
category="tertiary"
@@ -127,19 +124,19 @@ export default {
<template #control></template>
<template #candidate>
<gl-button
- v-if="linkToVideoTutorial"
+ v-if="actionLabelValue('videoTutorial')"
v-gl-tooltip
category="tertiary"
icon="live-preview"
:title="$options.i18n.watchHow"
:aria-label="$options.i18n.watchHow"
- :href="linkToVideoTutorial"
+ :href="actionLabelValue('videoTutorial')"
target="_blank"
class="ml-auto"
size="small"
data-testid="video-tutorial-link"
data-track-action="click_video_link"
- :data-track-label="linkTitle"
+ :data-track-label="actionLabelValue('trackLabel')"
data-track-property="Growth::Conversion::Experiment::LearnGitLab"
data-track-experiment="video_tutorials_continuous_onboarding"
/>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
index 05bacd9b350..cb1a0302d91 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
@@ -2,9 +2,10 @@ import { s__ } from '~/locale';
export const ACTION_LABELS = {
gitWrite: {
- title: s__('LearnGitLab|Create or import a repository'),
- actionLabel: s__('LearnGitLab|Create or import a repository'),
+ title: s__('LearnGitLab|Create a repository'),
+ actionLabel: s__('LearnGitLab|Create a repository'),
description: s__('LearnGitLab|Create or import your first repository into your new project.'),
+ trackLabel: 'create_a_repository',
section: 'workspace',
position: 1,
},
@@ -14,20 +15,23 @@ export const ACTION_LABELS = {
description: s__(
'LearnGitLab|GitLab works best as a team. Invite your colleague to enjoy all features.',
),
+ trackLabel: 'invite_your_colleagues',
section: 'workspace',
position: 0,
},
pipelineCreated: {
- title: s__('LearnGitLab|Set up CI/CD'),
- actionLabel: s__('LearnGitLab|Set-up CI/CD'),
+ title: s__("LearnGitLab|Set up your first project's CI/CD"),
+ actionLabel: s__('LearnGitLab|Set up CI/CD'),
description: s__('LearnGitLab|Save time by automating your integration and deployment tasks.'),
+ trackLabel: 'set_up_your_first_project_s_ci_cd',
section: 'workspace',
position: 2,
},
trialStarted: {
- title: s__('LearnGitLab|Start a free Ultimate trial'),
+ title: s__('LearnGitLab|Start a free trial of GitLab Ultimate'),
actionLabel: s__('LearnGitLab|Try GitLab Ultimate for free'),
description: s__('LearnGitLab|Try all GitLab features for 30 days, no credit card required.'),
+ trackLabel: 'start_a_free_trial_of_gitlab_ultimate',
section: 'workspace',
position: 3,
openInNewTab: true,
@@ -38,6 +42,7 @@ export const ACTION_LABELS = {
description: s__(
'LearnGitLab|Prevent unexpected changes to important assets by assigning ownership of files and paths.',
),
+ trackLabel: 'add_code_owners',
trialRequired: true,
section: 'workspace',
position: 4,
@@ -45,9 +50,10 @@ export const ACTION_LABELS = {
videoTutorial: 'https://vimeo.com/670896787',
},
requiredMrApprovalsEnabled: {
- title: s__('LearnGitLab|Add merge request approval'),
+ title: s__('LearnGitLab|Enable require merge approvals'),
actionLabel: s__('LearnGitLab|Enable require merge approvals'),
description: s__('LearnGitLab|Route code reviews to the right reviewers, every time.'),
+ trackLabel: 'enable_require_merge_approvals',
trialRequired: true,
section: 'workspace',
position: 5,
@@ -55,28 +61,52 @@ export const ACTION_LABELS = {
videoTutorial: 'https://vimeo.com/670904904',
},
mergeRequestCreated: {
- title: s__('LearnGitLab|Submit a merge request'),
+ title: s__('LearnGitLab|Submit a merge request (MR)'),
actionLabel: s__('LearnGitLab|Submit a merge request (MR)'),
description: s__('LearnGitLab|Review and edit proposed changes to source code.'),
+ trackLabel: 'submit_a_merge_request_mr',
section: 'plan',
position: 1,
},
- securityScanEnabled: {
- title: s__('LearnGitLab|Run a Security scan using CI/CD'),
- actionLabel: s__('LearnGitLab|Run a Security scan using CI/CD'),
- description: s__('LearnGitLab|Scan your code to uncover vulnerabilities before deploying.'),
- section: 'deploy',
- position: 1,
- },
issueCreated: {
title: s__('LearnGitLab|Create an issue'),
actionLabel: s__('LearnGitLab|Create an issue'),
description: s__(
'LearnGitLab|Create/import issues (tickets) to collaborate on ideas and plan work.',
),
+ trackLabel: 'create_an_issue',
section: 'plan',
position: 0,
},
+ securityScanEnabled: {
+ title: s__('LearnGitLab|Run a Security scan using CI/CD'),
+ actionLabel: s__('LearnGitLab|Run a Security scan using CI/CD'),
+ description: s__('LearnGitLab|Scan your code to uncover vulnerabilities before deploying.'),
+ trackLabel: 'run_a_security_scan_using_ci_cd',
+ section: 'deploy',
+ position: 1,
+ },
+ licenseScanningRun: {
+ title: s__('LearnGitLab|Scan dependencies for licenses'),
+ trackLabel: 'scan_dependencies_for_licenses',
+ trialRequired: true,
+ section: 'deploy',
+ position: 2,
+ },
+ secureDependencyScanningRun: {
+ title: s__('LearnGitLab|Scan dependencies for vulnerabilities'),
+ trackLabel: 'scan_dependencies_for_vulnerabilities',
+ trialRequired: true,
+ section: 'deploy',
+ position: 3,
+ },
+ secureDastRun: {
+ title: s__('LearnGitLab|Analyze your application for vulnerabilities with DAST'),
+ trackLabel: 'analyze_your_application_for_vulnerabilities_with_dast',
+ trialRequired: true,
+ section: 'deploy',
+ position: 4,
+ },
};
export const ACTION_SECTIONS = {
diff --git a/app/assets/javascripts/pages/projects/logs/index.js b/app/assets/javascripts/pages/projects/logs/index.js
deleted file mode 100644
index 0cff1ffc27e..00000000000
--- a/app/assets/javascripts/pages/projects/logs/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import logsBundle from '~/logs';
-
-logsBundle();
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 48e360ce762..2db804e1ad8 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
@@ -9,6 +9,7 @@ import initSourcegraph from '~/sourcegraph';
import ZenMode from '~/zen_mode';
import initAwardsApp from '~/emoji/awards_app';
import MrWidgetHowToMergeModal from '~/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue';
+import { initMrExperienceSurvey } from '~/surveys/merge_request_experience';
import getStateQuery from './queries/get_state.query.graphql';
export default function initMergeRequestShow() {
@@ -18,6 +19,7 @@ export default function initMergeRequestShow() {
initSourcegraph();
initIssuableSidebar();
initAwardsApp(document.getElementById('js-vue-awards-block'));
+ initMrExperienceSurvey();
const el = document.querySelector('.js-mr-status-box');
const { iid, issuableType, projectPath } = el.dataset;
diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js
index bf4fb5f3b7e..9a7fd74fd8c 100644
--- a/app/assets/javascripts/pages/projects/project_members/index.js
+++ b/app/assets/javascripts/pages/projects/project_members/index.js
@@ -1,4 +1,5 @@
-import initImportAProjectModal from '~/invite_members/init_import_a_project_modal';
+import initImportProjectMembersTrigger from '~/invite_members/init_import_project_members_trigger';
+import initImportProjectMembersModal from '~/invite_members/init_import_project_members_modal';
import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteGroupsModal from '~/invite_members/init_invite_groups_modal';
@@ -9,11 +10,12 @@ import { MEMBER_TYPES } from '~/members/constants';
import { groupLinkRequestFormatter } from '~/members/utils';
import { projectMemberRequestFormatter } from '~/projects/members/utils';
-initImportAProjectModal();
+initImportProjectMembersModal();
initInviteMembersModal();
initInviteGroupsModal();
initInviteMembersTrigger();
initInviteGroupTrigger();
+initImportProjectMembersTrigger();
const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
initMembersApp(document.querySelector('.js-project-members-list-app'), {
@@ -38,7 +40,7 @@ initMembersApp(document.querySelector('.js-project-members-list-app'), {
},
},
[MEMBER_TYPES.group]: {
- tableFields: SHARED_FIELDS.concat('granted'),
+ tableFields: SHARED_FIELDS.concat(['source', 'granted']),
tableAttrs: {
table: { 'data-qa-selector': 'groups_list' },
tr: { 'data-qa-selector': 'group_row' },
@@ -46,7 +48,7 @@ initMembersApp(document.querySelector('.js-project-members-list-app'), {
requestFormatter: groupLinkRequestFormatter,
filteredSearchBar: {
show: true,
- tokens: [],
+ tokens: ['groups_with_inherited_permissions'],
searchParam: 'search_groups',
placeholder: s__('Members|Search groups'),
recentSearchesStorageKey: 'project_group_links',
diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
index 43ab829f5f9..6a9bd34db22 100644
--- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
@@ -9,6 +9,7 @@ import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle';
import initSettingsPanels from '~/settings_panels';
import { initTokenAccess } from '~/token_access';
+import { initCiSecureFiles } from '~/ci_secure_files';
// Initialize expandable settings panels
initSettingsPanels();
@@ -41,3 +42,4 @@ initSharedRunnersToggle();
initInstallRunner();
initRunnerAwsDeployments();
initTokenAccess();
+initCiSecureFiles();
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
index 81b0dbec0bd..f2c30870a68 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
@@ -61,6 +61,10 @@ export default {
GlFormCheckbox,
GlToggle,
ConfirmDanger,
+ otherProjectSettings: () =>
+ import(
+ 'jh_component/pages/projects/shared/permissions/components/other_project_settings.vue'
+ ),
},
mixins: [settingsMixin, glFeatureFlagsMixin()],
@@ -182,6 +186,10 @@ export default {
required: false,
default: false,
},
+ membersPagePath: {
+ type: String,
+ required: true,
+ },
},
data() {
const defaults = {
@@ -521,12 +529,22 @@ export default {
/>
</div>
</div>
- <span v-if="!visibilityAllowed(visibilityLevel)" class="form-text text-muted">{{
- s__(
- 'ProjectSettings|Visibility options for this fork are limited by the current visibility of the source project.',
- )
- }}</span>
- <span class="form-text text-muted">{{ visibilityLevelDescription }}</span>
+ <span
+ v-if="!visibilityAllowed(visibilityLevel)"
+ class="gl-display-block gl-text-gray-500 gl-mt-2"
+ >{{
+ s__(
+ 'ProjectSettings|Visibility options for this fork are limited by the current visibility of the source project.',
+ )
+ }}</span
+ >
+ <span class="gl-display-block gl-text-gray-500 gl-mt-2">
+ <gl-sprintf :message="visibilityLevelDescription">
+ <template #membersPageLink="{ content }">
+ <gl-link class="gl-link" :href="membersPagePath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
<div v-if="showAdditonalSettings" class="gl-mt-4">
<strong class="gl-display-block">{{ s__('ProjectSettings|Additional options') }}</strong>
<label
@@ -891,6 +909,7 @@ export default {
<template #help>{{ $options.i18n.pucWarningHelpText }}</template>
</gl-form-checkbox>
</project-setting-row>
+ <other-project-settings />
<confirm-danger
v-if="isVisibilityReduced"
button-variant="confirm"
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/constants.js b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
index fb1acd5311c..cfca9d400e3 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/constants.js
+++ b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
@@ -8,12 +8,10 @@ export const visibilityOptions = {
export const visibilityLevelDescriptions = {
[visibilityOptions.PRIVATE]: __(
- 'The project is accessible only by members of the project. Access must be granted explicitly to each user.',
- ),
- [visibilityOptions.INTERNAL]: __('The project can be accessed by any user who is logged in.'),
- [visibilityOptions.PUBLIC]: __(
- 'The project can be accessed by anyone, regardless of authentication.',
+ `Only accessible by %{membersPageLinkStart}project members%{membersPageLinkEnd}. Membership must be explicitly granted to each user.`,
),
+ [visibilityOptions.INTERNAL]: __('Accessible by any user who is logged in.'),
+ [visibilityOptions.PUBLIC]: __('Accessible by anyone, regardless of authentication.'),
};
export const featureAccessLevel = {
diff --git a/app/assets/javascripts/pages/projects/work_items/index.js b/app/assets/javascripts/pages/projects/work_items/index.js
index 11c257611f0..6eef2352e2c 100644
--- a/app/assets/javascripts/pages/projects/work_items/index.js
+++ b/app/assets/javascripts/pages/projects/work_items/index.js
@@ -1,3 +1,5 @@
import { initWorkItemsRoot } from '~/work_items/index';
+import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
initWorkItemsRoot();
+initInviteMembersModal();
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue
index 7c23f60954a..e92f386a29e 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue
@@ -3,6 +3,7 @@ import { GlSkeletonLoader, GlSafeHtmlDirective, GlAlert } from '@gitlab/ui';
import createFlash from '~/flash';
import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
+import { handleLocationHash } from '~/lib/utils/common_utils';
import { renderGFM } from '../render_gfm_facade';
export default {
@@ -43,6 +44,7 @@ export default {
this.$nextTick()
.then(() => {
renderGFM(this.$refs.content);
+ handleLocationHash();
})
.catch(() =>
createFlash({
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 024b3bc9595..3c22844434d 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -1,5 +1,16 @@
<script>
-import { GlForm, GlIcon, GlLink, GlButton, GlSprintf, GlAlert } from '@gitlab/ui';
+import {
+ GlForm,
+ GlIcon,
+ GlLink,
+ GlButton,
+ GlSprintf,
+ GlAlert,
+ GlFormGroup,
+ GlFormInput,
+ GlFormSelect,
+} from '@gitlab/ui';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import axios from '~/lib/utils/axios_utils';
import csrf from '~/lib/utils/csrf';
import { setUrlFragment } from '~/lib/utils/url_utility';
@@ -75,12 +86,16 @@ export default {
},
components: {
GlAlert,
+ GlIcon,
GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlFormSelect,
GlSprintf,
- GlIcon,
GlLink,
GlButton,
MarkdownField,
+ LocalStorageSync,
ContentEditor: () =>
import(
/* webpackChunkName: 'content_editor' */ '~/content_editor/components/content_editor.vue'
@@ -186,6 +201,10 @@ export default {
this.useContentEditor = !this.useContentEditor;
},
+ setUseContentEditor(value) {
+ this.useContentEditor = value;
+ },
+
async handleFormSubmit(e) {
e.preventDefault();
@@ -305,150 +324,151 @@ export default {
name="wiki[last_commit_sha]"
:value="pageInfo.lastCommitSha"
/>
- <div class="form-group row">
- <div class="col-sm-2 col-form-label">
- <label class="control-label-full-width" for="wiki_title">{{
- $options.i18n.title.label
- }}</label>
- </div>
- <div class="col-sm-10">
- <input
- id="wiki_title"
- v-model="title"
- name="wiki[title]"
- type="text"
- class="form-control"
- data-qa-selector="wiki_title_textbox"
- :required="true"
- :autofocus="!pageInfo.persisted"
- :placeholder="$options.i18n.title.placeholder"
- @input="updateCommitMessage"
- />
- <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">
- {{ $options.i18n.title.helpText.learnMore }}
- </gl-link>
- </span>
- </div>
- </div>
- <div class="form-group row">
- <div class="col-sm-2 col-form-label">
- <label class="control-label-full-width" for="wiki_format">{{
- $options.i18n.format.label
- }}</label>
+
+ <div class="row">
+ <div class="col-sm-9">
+ <gl-form-group :label="$options.i18n.title.label" label-for="wiki_title">
+ <template #description>
+ <gl-icon class="gl-mr-n1" name="bulb" />
+ {{ titleHelpText }}
+ <gl-link :href="helpPath" target="_blank">
+ {{ $options.i18n.title.helpText.learnMore }}
+ </gl-link>
+ </template>
+
+ <gl-form-input
+ id="wiki_title"
+ v-model="title"
+ name="wiki[title]"
+ type="text"
+ class="form-control"
+ data-qa-selector="wiki_title_textbox"
+ :required="true"
+ :autofocus="!pageInfo.persisted"
+ :placeholder="$options.i18n.title.placeholder"
+ @input="updateCommitMessage"
+ />
+ </gl-form-group>
</div>
- <div class="col-sm-10">
- <select
- id="wiki_format"
- v-model="format"
- class="form-control"
- name="wiki[format]"
- :disabled="isContentEditorActive"
- >
- <option v-for="(key, label) of formatOptions" :key="key" :value="key">
- {{ label }}
- </option>
- </select>
+
+ <div class="col-sm-3 row-sm-10">
+ <gl-form-group :label="$options.i18n.format.label" label-for="wiki_format">
+ <gl-form-select
+ id="wiki_format"
+ v-model="format"
+ name="wiki[format]"
+ :disabled="isContentEditorActive"
+ class="form-control"
+ :value="formatOptions.Markdown"
+ >
+ <option v-for="(key, label) of formatOptions" :key="key" :value="key">
+ {{ label }}
+ </option>
+ </gl-form-select>
+ </gl-form-group>
</div>
</div>
- <div class="form-group row" data-testid="wiki-form-content-fieldset">
- <div class="col-sm-2 col-form-label">
- <label class="control-label-full-width" for="wiki_content">{{
- $options.i18n.content.label
- }}</label>
- </div>
- <div class="col-sm-10">
- <div v-if="isMarkdownFormat" 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>
- <markdown-field
- v-if="!isContentEditorActive"
- :markdown-preview-path="pageInfo.markdownPreviewPath"
- :can-attach-file="true"
- :enable-autocomplete="true"
- :textarea-value="content"
- :markdown-docs-path="pageInfo.markdownHelpPath"
- :uploads-path="pageInfo.uploadsPath"
- :enable-preview="isMarkdownFormat"
- class="bordered-box"
- >
- <template #textarea>
- <textarea
- id="wiki_content"
- ref="textarea"
- v-model="content"
- name="wiki[content]"
- class="note-textarea js-gfm-input js-autosize markdown-area"
- dir="auto"
- data-supports-quick-actions="false"
- data-qa-selector="wiki_content_textarea"
- :autofocus="pageInfo.persisted"
- :aria-label="$options.i18n.content.label"
- :placeholder="$options.i18n.content.placeholder"
- @input="handleContentChange"
+
+ <div class="row" data-testid="wiki-form-content-fieldset">
+ <div class="col-sm-12 row-sm-5">
+ <gl-form-group>
+ <div v-if="isMarkdownFormat" 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
>
- </textarea>
- </template>
- </markdown-field>
- <div v-if="isContentEditorActive">
- <content-editor
- :render-markdown="renderMarkdown"
- :uploads-path="pageInfo.uploadsPath"
- @initialized="loadInitialContent"
- @change="handleContentEditorChange"
+ </div>
+ <local-storage-sync
+ storage-key="gl-wiki-content-editor-enabled"
+ :value="useContentEditor"
+ @input="setUseContentEditor"
/>
- <input id="wiki_content" v-model.trim="content" type="hidden" name="wiki[content]" />
- </div>
+ <markdown-field
+ v-if="!isContentEditorActive"
+ :markdown-preview-path="pageInfo.markdownPreviewPath"
+ :can-attach-file="true"
+ :enable-autocomplete="true"
+ :textarea-value="content"
+ :markdown-docs-path="pageInfo.markdownHelpPath"
+ :uploads-path="pageInfo.uploadsPath"
+ :enable-preview="isMarkdownFormat"
+ class="bordered-box"
+ >
+ <template #textarea>
+ <textarea
+ id="wiki_content"
+ ref="textarea"
+ v-model="content"
+ name="wiki[content]"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ dir="auto"
+ data-supports-quick-actions="false"
+ data-qa-selector="wiki_content_textarea"
+ :autofocus="pageInfo.persisted"
+ :aria-label="$options.i18n.content.label"
+ :placeholder="$options.i18n.content.placeholder"
+ @input="handleContentChange"
+ >
+ </textarea>
+ </template>
+ </markdown-field>
+ <div v-if="isContentEditorActive">
+ <content-editor
+ :render-markdown="renderMarkdown"
+ :uploads-path="pageInfo.uploadsPath"
+ @initialized="loadInitialContent"
+ @change="handleContentEditorChange"
+ />
+ <input id="wiki_content" v-model.trim="content" type="hidden" name="wiki[content]" />
+ </div>
- <div class="clearfix"></div>
- <div class="error-alert"></div>
+ <div class="clearfix"></div>
+ <div class="error-alert"></div>
- <div class="form-text gl-text-gray-600">
- <gl-sprintf v-if="displayWikiSpecificMarkdownHelp" :message="$options.i18n.linksHelpText">
- <template #linkExample
- ><code>{{ linkExample }}</code></template
+ <div class="form-text gl-text-gray-600">
+ <gl-sprintf
+ v-if="displayWikiSpecificMarkdownHelp"
+ :message="$options.i18n.linksHelpText"
>
- <template
- #link="// eslint-disable-next-line vue/no-template-shadow
+ <template #linkExample>
+ <code>{{ linkExample }}</code>
+ </template>
+ <template
+ #link="// eslint-disable-next-line vue/no-template-shadow
{ content }"
- ><gl-link
- :href="wikiSpecificMarkdownHelpPath"
- target="_blank"
- data-testid="wiki-markdown-help-link"
- >{{ content }}</gl-link
- ></template
- >
- </gl-sprintf>
- </div>
+ ><gl-link
+ :href="wikiSpecificMarkdownHelpPath"
+ target="_blank"
+ data-testid="wiki-markdown-help-link"
+ >{{ content }}</gl-link
+ ></template
+ >
+ </gl-sprintf>
+ </div>
+ </gl-form-group>
</div>
</div>
- <div class="form-group row">
- <div class="col-sm-2 col-form-label">
- <label class="control-label-full-width" for="wiki_message">{{
- $options.i18n.commitMessage.label
- }}</label>
- </div>
- <div class="col-sm-10">
- <input
- id="wiki_message"
- v-model.trim="commitMessage"
- name="wiki[message]"
- type="text"
- class="form-control"
- data-qa-selector="wiki_message_textbox"
- :placeholder="$options.i18n.commitMessage.label"
- />
+
+ <div class="row">
+ <div class="col-sm-12 row-sm-5">
+ <gl-form-group :label="$options.i18n.commitMessage.label" label-for="wiki_message">
+ <gl-form-input
+ id="wiki_message"
+ v-model.trim="commitMessage"
+ name="wiki[message]"
+ type="text"
+ class="form-control"
+ data-qa-selector="wiki_message_textbox"
+ :placeholder="$options.i18n.commitMessage.label"
+ />
+ </gl-form-group>
</div>
</div>
+
<div class="form-actions">
<gl-button
category="primary"
diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
index 1da4a8fea73..a5fa85f1ed5 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -216,7 +216,7 @@ export default {
v-if="currentRequest"
:current-request="currentRequest"
:requests="requests"
- class="ml-auto"
+ class="gl-ml-auto"
@change-current-request="changeCurrentRequest"
/>
<add-request v-on="$listeners" />
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 23f1592cac1..610a570c4ce 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
@@ -19,7 +19,7 @@ export const i18n = {
invalid: s__('Pipelines|This GitLab CI configuration is invalid.'),
invalidWithReason: s__('Pipelines|This GitLab CI configuration is invalid: %{reason}.'),
unavailableValidation: s__('Pipelines|Configuration validation currently not available.'),
- valid: s__('Pipelines|This GitLab CI configuration is valid.'),
+ valid: s__('Pipelines|Pipeline syntax is correct.'),
};
export default {
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 9a789ccab4d..0f19b9386e6 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
@@ -52,6 +52,11 @@ export default {
required: false,
default: false,
},
+ hideAlert: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
isValid: {
type: Boolean,
required: true,
@@ -63,7 +68,8 @@ export default {
},
lintHelpPagePath: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
warnings: {
type: Array,
@@ -96,6 +102,7 @@ export default {
<template>
<div>
<gl-alert
+ v-if="!hideAlert"
class="gl-mb-5"
:variant="status.variant"
:title="__('Status:')"
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 08d246a9a00..99ee244577e 100644
--- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
+++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
@@ -1,6 +1,6 @@
<script>
import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { getParameterValues, setUrlParams, updateHistory } from '~/lib/utils/url_utility';
@@ -16,6 +16,7 @@ import {
TAB_QUERY_PARAM,
TABS_INDEX,
VALIDATE_TAB,
+ VALIDATE_TAB_BADGE_DISMISSED_KEY,
VISUALIZE_TAB,
} from '../constants';
import getAppStatus from '../graphql/queries/client/app_status.query.graphql';
@@ -29,6 +30,7 @@ import WalkthroughPopover from './popovers/walkthrough_popover.vue';
export default {
i18n: {
+ new: __('NEW'),
tabEdit: s__('Pipelines|Edit'),
tabGraph: s__('Pipelines|Visualize'),
tabLint: s__('Pipelines|Lint'),
@@ -87,6 +89,10 @@ export default {
required: false,
default: '',
},
+ currentTab: {
+ type: String,
+ required: true,
+ },
isNewCiConfigFile: {
type: Boolean,
required: true,
@@ -104,6 +110,11 @@ export default {
},
},
},
+ data() {
+ return {
+ showValidateNewBadge: false,
+ };
+ },
computed: {
isMergedYamlAvailable() {
return this.ciConfigData?.mergedYaml;
@@ -123,6 +134,16 @@ export default {
isLoading() {
return this.appStatus === EDITOR_APP_STATUS_LOADING;
},
+ validateTabBadgeTitle() {
+ if (this.showValidateNewBadge) {
+ return this.$options.i18n.new;
+ }
+
+ return '';
+ },
+ },
+ mounted() {
+ this.showValidateNewBadge = !JSON.parse(localStorage.getItem(VALIDATE_TAB_BADGE_DISMISSED_KEY));
},
created() {
const [tabQueryParam] = getParameterValues(TAB_QUERY_PARAM);
@@ -134,6 +155,11 @@ export default {
},
methods: {
setCurrentTab(tabName) {
+ if (this.currentTab === VALIDATE_TAB) {
+ localStorage.setItem(VALIDATE_TAB_BADGE_DISMISSED_KEY, 'true');
+ this.showValidateNewBadge = false;
+ }
+
this.$emit('set-current-tab', tabName);
},
setDefaultTab(tabName) {
@@ -189,11 +215,11 @@ export default {
v-if="glFeatures.simulatePipeline"
class="gl-mb-3"
data-testid="validate-tab"
+ :badge-title="validateTabBadgeTitle"
:title="$options.i18n.tabValidate"
@click="setCurrentTab($options.tabConstants.VALIDATE_TAB)"
>
- <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" />
- <ci-validate v-else />
+ <ci-validate :ci-file-content="ciFileContent" />
</editor-tab>
<editor-tab
v-else
diff --git a/app/assets/javascripts/pipeline_editor/components/popovers/validate_pipeline_popover.vue b/app/assets/javascripts/pipeline_editor/components/popovers/validate_pipeline_popover.vue
new file mode 100644
index 00000000000..4730a521227
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/popovers/validate_pipeline_popover.vue
@@ -0,0 +1,72 @@
+<script>
+import { GlLink, GlPopover, GlOutsideDirective as Outside, GlSprintf } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import { VALIDATE_TAB_FEEDBACK_URL } from '../../constants';
+
+export const i18n = {
+ feedbackLink: __('Provide Feedback'),
+ popoverContent: s__(
+ 'PipelineEditor|Pipeline behavior will be simulated including the %{codeStart}rules%{codeEnd} %{codeStart}only%{codeEnd} %{codeStart}except%{codeEnd} and %{codeStart}needs%{codeEnd} job dependencies. %{linkStart}Learn more%{linkEnd}',
+ ),
+ title: s__('PipelineEditor|Validate pipeline under simulated conditions'),
+};
+
+export default {
+ name: 'ValidatePipelinePopover',
+ directives: { Outside },
+ components: {
+ GlLink,
+ GlPopover,
+ GlSprintf,
+ },
+ inject: ['simulatePipelineHelpPagePath'],
+ data() {
+ return {
+ showPopover: false,
+ };
+ },
+ methods: {
+ dismiss() {
+ this.showPopover = false;
+ },
+ },
+ i18n,
+ VALIDATE_TAB_FEEDBACK_URL,
+};
+</script>
+
+<template>
+ <gl-popover
+ :show.sync="showPopover"
+ target="validate-pipeline-help"
+ triggers="hover focus"
+ placement="top"
+ >
+ <p class="gl-my-3 gl-font-weight-bold">{{ $options.i18n.title }}</p>
+ <p>
+ <gl-sprintf :message="$options.i18n.popoverContent">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ <template #link="{ content }">
+ <gl-link
+ class="gl-font-sm"
+ target="_blank"
+ :href="simulatePipelineHelpPagePath"
+ data-testid="help-link"
+ >{{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </p>
+ <p class="gl-text-right gl-mb-3">
+ <gl-link
+ class="gl-font-sm"
+ target="_blank"
+ :href="$options.VALIDATE_TAB_FEEDBACK_URL"
+ data-testid="feedback-link"
+ >{{ $options.i18n.feedbackLink }}</gl-link
+ >
+ </p>
+ </gl-popover>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue b/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue
index 673599da085..65f399d1912 100644
--- a/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue
+++ b/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAlert, GlTab } from '@gitlab/ui';
+import { GlAlert, GlBadge, GlTab } from '@gitlab/ui';
import { __, s__ } from '~/locale';
/**
* Wrapper of <gl-tab> to optionally lazily render this tab's content
@@ -48,6 +48,7 @@ export default {
},
components: {
GlAlert,
+ GlBadge,
GlTab,
// Use a small renderless component to know when the tab content mounts because:
// - gl-tab always gets mounted, even if lazy is `true`. See:
@@ -59,6 +60,16 @@ export default {
},
inheritAttrs: false,
props: {
+ badgeTitle: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ badgeVariant: {
+ type: String,
+ required: false,
+ default: 'info',
+ },
emptyMessage: {
type: String,
required: false,
@@ -91,6 +102,10 @@ export default {
required: false,
default: false,
},
+ title: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -98,7 +113,11 @@ export default {
};
},
computed: {
+ hasBadgeTitle() {
+ return this.badgeTitle.length > 0;
+ },
slots() {
+ // eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots
return Object.keys(this.$slots);
},
},
@@ -116,6 +135,12 @@ export default {
</script>
<template>
<gl-tab :lazy="isLazy" v-bind="$attrs" v-on="$listeners">
+ <template #title>
+ <span>{{ title }}</span>
+ <gl-badge v-if="hasBadgeTitle" class="gl-ml-2" size="sm" :variant="badgeVariant">{{
+ badgeTitle
+ }}</gl-badge>
+ </template>
<gl-alert v-if="isEmpty" variant="tip">{{ emptyMessage }}</gl-alert>
<gl-alert v-else-if="isUnavailable" variant="danger" :dismissible="false">
{{ $options.i18n.unavailable }}</gl-alert
diff --git a/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue b/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue
index 5f26318497b..47673119db9 100644
--- a/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue
+++ b/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue
@@ -1,9 +1,35 @@
<script>
-import { GlButton, GlDropdown, GlTooltipDirective, GlSprintf } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlButton,
+ GlDropdown,
+ GlIcon,
+ GlLoadingIcon,
+ GlLink,
+ GlTooltip,
+ GlTooltipDirective,
+ GlSprintf,
+} from '@gitlab/ui';
import { s__, __ } from '~/locale';
+import ValidatePipelinePopover from '../popovers/validate_pipeline_popover.vue';
+import CiLintResults from '../lint/ci_lint_results.vue';
+import getBlobContent from '../../graphql/queries/blob_content.query.graphql';
+import getCurrentBranch from '../../graphql/queries/client/current_branch.query.graphql';
+import lintCiMutation from '../../graphql/mutations/client/lint_ci.mutation.graphql';
export const i18n = {
+ alertDesc: s__(
+ 'PipelineEditor|Simulated a %{codeStart}git push%{codeEnd} event for a default branch. %{codeStart}Rules%{codeEnd}, %{codeStart}only%{codeEnd}, %{codeStart}except%{codeEnd}, and %{codeStart}needs%{codeEnd} job dependencies logic have been evaluated. %{linkStart}Learn more%{linkEnd}',
+ ),
+ cancelBtn: __('Cancel'),
+ contentChange: s__(
+ 'PipelineEditor|Configuration content has changed. Re-run validation for updated results.',
+ ),
+ cta: s__('PipelineEditor|Validate pipeline'),
+ ctaDisabledTooltip: s__('PipelineEditor|Waiting for CI content to load...'),
+ errorAlertTitle: s__('PipelineEditor|Pipeline simulation completed with errors'),
help: __('Help'),
+ loading: s__('PipelineEditor|Validating pipeline... It can take up to a minute.'),
pipelineSource: s__('PipelineEditor|Pipeline Source'),
pipelineSourceDefault: s__('PipelineEditor|Git push event to the default branch'),
pipelineSourceTooltip: s__('PipelineEditor|Other pipeline sources are not available yet.'),
@@ -14,37 +40,179 @@ export const i18n = {
simulationNote: s__(
'PipelineEditor|Pipeline behavior will be simulated including the %{codeStart}rules%{codeEnd} %{codeStart}only%{codeEnd} %{codeStart}except%{codeEnd} and %{codeStart}needs%{codeEnd} job dependencies.',
),
- cta: s__('PipelineEditor|Validate pipeline'),
+ successAlertTitle: s__('PipelineEditor|Simulation completed successfully'),
};
+export const VALIDATE_TAB_INIT = 'VALIDATE_TAB_INIT';
+export const VALIDATE_TAB_RESULTS = 'VALIDATE_TAB_RESULTS';
+export const VALIDATE_TAB_LOADING = 'VALIDATE_TAB_LOADING';
+const BASE_CLASSES = [
+ 'gl-display-flex',
+ 'gl-flex-direction-column',
+ 'gl-align-items-center',
+ 'gl-mt-11',
+];
+
export default {
name: 'CiValidateTab',
components: {
+ CiLintResults,
+ GlAlert,
GlButton,
GlDropdown,
+ GlIcon,
+ GlLoadingIcon,
+ GlLink,
GlSprintf,
+ GlTooltip,
+ ValidatePipelinePopover,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- inject: ['validateTabIllustrationPath'],
+ inject: ['ciConfigPath', 'ciLintPath', 'projectFullPath', 'validateTabIllustrationPath'],
+ props: {
+ ciFileContent: {
+ type: String,
+ required: true,
+ },
+ },
+ apollo: {
+ initialBlobContent: {
+ query: getBlobContent,
+ variables() {
+ return {
+ projectPath: this.projectFullPath,
+ path: this.ciConfigPath,
+ ref: this.currentBranch,
+ };
+ },
+ update(data) {
+ return data?.project?.repository?.blobs?.nodes[0]?.rawBlob;
+ },
+ },
+ currentBranch: {
+ query: getCurrentBranch,
+ update(data) {
+ return data.workBranches?.current?.name;
+ },
+ },
+ },
+ data() {
+ return {
+ yaml: this.ciFileContent,
+ state: VALIDATE_TAB_INIT,
+ errors: [],
+ hasCiContentChanged: false,
+ isValid: false,
+ jobs: [],
+ warnings: [],
+ };
+ },
+ computed: {
+ isInitialCiContentLoading() {
+ return this.$apollo.queries.initialBlobContent.loading;
+ },
+ isInitState() {
+ return this.state === VALIDATE_TAB_INIT;
+ },
+ isSimulationLoading() {
+ return this.state === VALIDATE_TAB_LOADING;
+ },
+ hasSimulationResults() {
+ return this.state === VALIDATE_TAB_RESULTS;
+ },
+ resultStatus() {
+ return {
+ title: this.isValid ? i18n.successAlertTitle : i18n.errorAlertTitle,
+ variant: this.isValid ? 'success' : 'danger',
+ };
+ },
+ },
+ watch: {
+ ciFileContent(value) {
+ this.yaml = value;
+ this.hasCiContentChanged = true;
+ },
+ },
+ methods: {
+ cancelSimulation() {
+ this.state = VALIDATE_TAB_INIT;
+ },
+ async validateYaml() {
+ this.state = VALIDATE_TAB_LOADING;
+
+ try {
+ const {
+ data: {
+ lintCI: { errors, jobs, valid, warnings },
+ },
+ } = await this.$apollo.mutate({
+ mutation: lintCiMutation,
+ variables: {
+ dry_run: true,
+ content: this.yaml,
+ endpoint: this.ciLintPath,
+ },
+ });
+
+ // only save the result if the user did not cancel the simulation
+ if (this.state === VALIDATE_TAB_LOADING) {
+ this.errors = errors;
+ this.jobs = jobs;
+ this.warnings = warnings;
+ this.isValid = valid;
+ this.state = VALIDATE_TAB_RESULTS;
+ this.hasCiContentChanged = false;
+ }
+ } catch (error) {
+ this.cancelSimulation();
+ }
+ },
+ },
i18n,
+ BASE_CLASSES,
};
</script>
<template>
<div>
- <div class="gl-mt-3">
- <label>{{ $options.i18n.pipelineSource }}</label>
- <gl-dropdown
- v-gl-tooltip.hover
- :title="$options.i18n.pipelineSourceTooltip"
- :text="$options.i18n.pipelineSourceDefault"
- disabled
- data-testid="pipeline-source"
- />
+ <div class="gl-display-flex gl-justify-content-space-between gl-mt-3">
+ <div>
+ <label>{{ $options.i18n.pipelineSource }}</label>
+ <gl-dropdown
+ v-gl-tooltip.hover
+ class="gl-ml-3"
+ :title="$options.i18n.pipelineSourceTooltip"
+ :text="$options.i18n.pipelineSourceDefault"
+ disabled
+ data-testid="pipeline-source"
+ />
+ <validate-pipeline-popover />
+ <gl-icon
+ id="validate-pipeline-help"
+ name="question-o"
+ class="gl-ml-1 gl-fill-blue-500"
+ category="secondary"
+ variant="confirm"
+ :aria-label="$options.i18n.help"
+ />
+ </div>
+ <div v-if="hasSimulationResults && hasCiContentChanged">
+ <span class="gl-text-gray-400" data-testid="content-status">
+ {{ $options.i18n.contentChange }}
+ </span>
+ <gl-button
+ variant="confirm"
+ class="gl-ml-2 gl-mb-2"
+ data-testid="resimulate-pipeline-button"
+ @click="validateYaml"
+ >
+ {{ $options.i18n.cta }}
+ </gl-button>
+ </div>
</div>
- <div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11">
+ <div v-if="isInitState" :class="$options.BASE_CLASSES">
<img :src="validateTabIllustrationPath" />
<h1 class="gl-font-size-h1 gl-mb-6">{{ $options.i18n.title }}</h1>
<ul>
@@ -57,9 +225,61 @@ export default {
</gl-sprintf>
</li>
</ul>
- <gl-button variant="confirm" class="gl-mt-3" data-qa-selector="simulate_pipeline">
- {{ $options.i18n.cta }}
- </gl-button>
+ <div ref="simulatePipelineButton">
+ <gl-button
+ ref="simulatePipelineButton"
+ variant="confirm"
+ class="gl-mt-3"
+ :disabled="isInitialCiContentLoading"
+ data-testid="simulate-pipeline-button"
+ @click="validateYaml"
+ >
+ {{ $options.i18n.cta }}
+ </gl-button>
+ </div>
+ <gl-tooltip
+ v-if="isInitialCiContentLoading"
+ :target="() => $refs.simulatePipelineButton"
+ :title="$options.i18n.ctaDisabledTooltip"
+ data-testid="cta-tooltip"
+ />
+ </div>
+ <div v-else-if="isSimulationLoading" :class="$options.BASE_CLASSES">
+ <gl-loading-icon size="lg" class="gl-m-3" />
+ <h1 class="gl-font-size-h1 gl-mb-6">{{ $options.i18n.loading }}</h1>
+ <div>
+ <gl-button class="gl-mt-3" data-testid="cancel-simulation" @click="cancelSimulation">
+ {{ $options.i18n.cancelBtn }}
+ </gl-button>
+ <gl-button class="gl-mt-3" loading data-testid="simulate-pipeline-button">
+ {{ $options.i18n.cta }}
+ </gl-button>
+ </div>
+ </div>
+ <div v-else-if="hasSimulationResults" class="gl-mt-5">
+ <gl-alert
+ class="gl-mb-5"
+ :dismissible="false"
+ :title="resultStatus.title"
+ :variant="resultStatus.variant"
+ >
+ <gl-sprintf :message="$options.i18n.alertDesc">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ <template #link="{ content }">
+ <gl-link target="_blank" href="#">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ <ci-lint-results
+ dry-run
+ hide-alert
+ :is-valid="isValid"
+ :jobs="jobs"
+ :errors="errors"
+ :warnings="warnings"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js
index 8f688e6ba76..05db0afd15d 100644
--- a/app/assets/javascripts/pipeline_editor/constants.js
+++ b/app/assets/javascripts/pipeline_editor/constants.js
@@ -54,6 +54,7 @@ export const SOURCE_EDITOR_DEBOUNCE = 500;
export const FILE_TREE_DISPLAY_KEY = 'pipeline_editor_file_tree_display';
export const FILE_TREE_POPOVER_DISMISSED_KEY = 'pipeline_editor_file_tree_popover_dismissed';
export const FILE_TREE_TIP_DISMISSED_KEY = 'pipeline_editor_file_tree_tip_dismissed';
+export const VALIDATE_TAB_BADGE_DISMISSED_KEY = 'pipeline_editor_validate_tab_badge_dismissed';
export const STARTER_TEMPLATE_NAME = 'Getting-Started';
@@ -81,6 +82,7 @@ export const pipelineEditorTrackingOptions = {
export const TEMPLATE_REPOSITORY_URL =
'https://gitlab.com/gitlab-org/gitlab-foss/tree/master/lib/gitlab/ci/templates';
+export const VALIDATE_TAB_FEEDBACK_URL = 'https://gitlab.com/gitlab-org/gitlab/-/issues/346687';
export const COMMIT_SHA_POLL_INTERVAL = 1000;
diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js
index 4caa253b85e..4f5b69107bf 100644
--- a/app/assets/javascripts/pipeline_editor/index.js
+++ b/app/assets/javascripts/pipeline_editor/index.js
@@ -27,6 +27,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
ciConfigPath,
ciExamplesHelpPagePath,
ciHelpPagePath,
+ ciLintPath,
defaultBranch,
emptyStateIllustrationPath,
helpPaths,
@@ -40,6 +41,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
projectPath,
projectNamespace,
runnerHelpPagePath,
+ simulatePipelineHelpPagePath,
totalBranches,
validateTabIllustrationPath,
ymlHelpPagePath,
@@ -115,6 +117,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
ciConfigPath,
ciExamplesHelpPagePath,
ciHelpPagePath,
+ ciLintPath,
configurationPaths,
dataMethod: 'graphql',
defaultBranch,
@@ -130,6 +133,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
projectPath,
projectNamespace,
runnerHelpPagePath,
+ simulatePipelineHelpPagePath,
totalBranches: parseInt(totalBranches, 10),
validateTabIllustrationPath,
ymlHelpPagePath,
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
index f26cdd8b017..2d5c01a58b7 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
@@ -150,6 +150,7 @@ export default {
:ci-config-data="ciConfigData"
:ci-file-content="ciFileContent"
:commit-sha="commitSha"
+ :current-tab="currentTab"
:is-new-ci-config-file="isNewCiConfigFile"
:show-drawer="showDrawer"
v-on="$listeners"
diff --git a/app/assets/javascripts/pipeline_wizard/components/input_wrapper.vue b/app/assets/javascripts/pipeline_wizard/components/input_wrapper.vue
index 5efae2471e5..c9649b2f2f7 100644
--- a/app/assets/javascripts/pipeline_wizard/components/input_wrapper.vue
+++ b/app/assets/javascripts/pipeline_wizard/components/input_wrapper.vue
@@ -3,10 +3,12 @@ import { isNode, isDocument, isSeq, visit } from 'yaml';
import { capitalize } from 'lodash';
import TextWidget from '~/pipeline_wizard/components/widgets/text.vue';
import ListWidget from '~/pipeline_wizard/components/widgets/list.vue';
+import ChecklistWidget from '~/pipeline_wizard/components/widgets/checklist.vue';
const widgets = {
TextWidget,
ListWidget,
+ ChecklistWidget,
};
function isNullOrUndefined(v) {
@@ -30,8 +32,9 @@ export default {
},
target: {
type: String,
- required: true,
+ required: false,
validator: (v) => /^\$.*/g.test(v),
+ default: null,
},
widget: {
type: String,
@@ -48,6 +51,7 @@ export default {
},
computed: {
path() {
+ if (!this.target) return null;
let res;
visit(this.template, (seqKey, node, path) => {
if (node && node.value === this.target) {
diff --git a/app/assets/javascripts/pipeline_wizard/components/step.vue b/app/assets/javascripts/pipeline_wizard/components/step.vue
index 220b068f747..c6ee883aec8 100644
--- a/app/assets/javascripts/pipeline_wizard/components/step.vue
+++ b/app/assets/javascripts/pipeline_wizard/components/step.vue
@@ -31,10 +31,7 @@ export default {
inputs: {
type: Array,
required: true,
- validator: (value) =>
- value.every((i) => {
- return i?.target && i?.widget;
- }),
+ validator: (value) => value.every((i) => i?.widget),
},
template: {
type: null,
@@ -131,7 +128,7 @@ export default {
:template="template"
:validate="validate"
:widget="input.widget"
- class="gl-mb-2"
+ class="gl-mb-8"
v-bind="input"
@highlight="onHighlight"
@update:valid="(validationState) => onInputValidationStateChange(i, validationState)"
diff --git a/app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue b/app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue
new file mode 100644
index 00000000000..f2b159acfee
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue
@@ -0,0 +1,80 @@
+<script>
+import { GlFormGroup, GlFormCheckbox, GlFormCheckboxGroup } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+
+const isValidItemDefinition = (value) => {
+ // The Item definition should either be a simple string
+ // or an object with at least a "title" property
+ return typeof value === 'string' || Boolean(value.text);
+};
+
+export default {
+ name: 'ChecklistWidget',
+ components: {
+ GlFormGroup,
+ GlFormCheckbox,
+ GlFormCheckboxGroup,
+ },
+ props: {
+ title: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ items: {
+ type: Array,
+ required: false,
+ validator: (v) => v.every(isValidItemDefinition),
+ default: () => [],
+ },
+ validate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ checklistItems() {
+ return this.items.map((rawItem) => {
+ const id = rawItem.id || uniqueId();
+ return {
+ id,
+ text: rawItem.text || rawItem,
+ help: rawItem.help || null,
+ };
+ });
+ },
+ },
+ created() {
+ if (this.items.length > 0) {
+ this.$emit('update:valid', false);
+ }
+ },
+ methods: {
+ updateValidState(values) {
+ this.$emit(
+ 'update:valid',
+ this.checklistItems.every((item) => values.includes(item.id)),
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-group #default="{ ariaDescribedby }" :label="title">
+ <gl-form-checkbox-group :aria-describedby="ariaDescribedby" @input="updateValidState">
+ <gl-form-checkbox
+ v-for="item in checklistItems"
+ :id="item.id"
+ :key="item.id"
+ :value="item.id"
+ >
+ {{ item.text }}
+ <template v-if="item.help" #help>
+ {{ item.help }}
+ </template>
+ </gl-form-checkbox>
+ </gl-form-checkbox-group>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue
index f50cd175510..0fe87bcee7b 100644
--- a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue
+++ b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue
@@ -128,6 +128,7 @@ export default {
:filename="filename"
:project-path="projectPath"
@back="currentStepIndex--"
+ @done="$emit('done')"
/>
<wizard-step
v-for="(step, i) in stepList"
diff --git a/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue
index 7200b4e3782..939702fd1b5 100644
--- a/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue
+++ b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue
@@ -60,6 +60,7 @@ export default {
:filename="filename"
:project-path="projectPath"
:steps="steps"
+ @done="$emit('done')"
/>
</div>
</template>
diff --git a/app/assets/javascripts/pipeline_wizard/templates/.gitkeep b/app/assets/javascripts/pipeline_wizard/templates/.gitkeep
deleted file mode 100644
index e69de29bb2d..00000000000
--- a/app/assets/javascripts/pipeline_wizard/templates/.gitkeep
+++ /dev/null
diff --git a/app/assets/javascripts/pipeline_wizard/templates/pages.yml b/app/assets/javascripts/pipeline_wizard/templates/pages.yml
new file mode 100644
index 00000000000..cd2242b1ba7
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/templates/pages.yml
@@ -0,0 +1,53 @@
+title: Get started with Pages
+description: "GitLab Pages lets you deploy static websites in minutes. All you
+ need is a .gitlab-ci.yml file. Follow the below steps to
+ create one for your app now."
+steps:
+ - inputs:
+ - label: Select your build image
+ description: A Docker image that we can use to build your image
+ placeholder: node:lts
+ widget: text
+ target: $BUILD_IMAGE
+ required: true
+ pattern: "(?:[a-z]+/)?([a-z]+)(?::[0-9]+)?"
+ invalid-feedback: Please enter a valid docker image
+ - widget: checklist
+ title: "Before we begin, please check:"
+ items:
+ - text: The app's built output files are in a folder named "public"
+ help: GitLab Pages will only publish files in that folder.
+ You may need to adjust your build engine's config.
+ template:
+ # The Docker image that will be used to build your app
+ image: $BUILD_IMAGE
+ - inputs:
+ - label: Installation Steps
+ description: "Enter the steps that need to run to set up a local build
+ environment, for example installing dependencies."
+ placeholder: npm ci
+ widget: list
+ target: $INSTALLATION_STEPS
+ template:
+ # Functions that should be executed before the build script is run
+ before_script: $INSTALLATION_STEPS
+ - inputs:
+ - label: Build Steps
+ description: "Enter the steps necessary to build a production version of
+ your application."
+ widget: list
+ target: $BUILD_STEPS
+ template:
+
+ pages:
+ script: $BUILD_STEPS
+
+ artifacts:
+ paths:
+ # The folder that contains the files to be exposed at the Page URL
+ - public
+
+ rules:
+ # This ensures that only pushes to the default branch will trigger
+ # a pages deploy
+ - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
index f822e2c0874..14872c34afb 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
@@ -281,6 +281,7 @@ export default {
:type="graphViewType"
:show-links="showLinks"
:tip-previously-dismissed="hoverTipPreviouslyDismissed"
+ :is-pipeline-complete="pipeline.complete"
@dismissHoverTip="handleTipDismissal"
@updateViewType="updateViewType"
@updateShowLinksState="updateShowLinksState"
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
index 1920fed84ec..a8c5d85f4ed 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
@@ -1,17 +1,33 @@
<script>
-import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon, GlToggle } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlButton,
+ GlButtonGroup,
+ GlLoadingIcon,
+ GlToggle,
+ GlModalDirective,
+} from '@gitlab/ui';
import { __, s__ } from '~/locale';
+import Tracking from '~/tracking';
+import PerformanceInsightsModal from '../performance_insights_modal.vue';
+import { performanceModalId } from '../../constants';
import { STAGE_VIEW, LAYER_VIEW } from './constants';
export default {
name: 'GraphViewSelector',
+ performanceModalId,
components: {
GlAlert,
GlButton,
GlButtonGroup,
GlLoadingIcon,
GlToggle,
+ PerformanceInsightsModal,
},
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ mixins: [Tracking.mixin()],
props: {
showLinks: {
type: Boolean,
@@ -25,6 +41,10 @@ export default {
type: String,
required: true,
},
+ isPipelineComplete: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
return {
@@ -39,6 +59,7 @@ export default {
hoverTipText: __('Tip: Hover over a job to see the jobs it depends on to run.'),
linksLabelText: s__('GraphViewType|Show dependencies'),
viewLabelText: __('Group jobs by'),
+ performanceBtnText: __('Performance insights'),
},
views: {
[STAGE_VIEW]: {
@@ -129,6 +150,9 @@ export default {
this.$emit('updateShowLinksState', val);
});
},
+ trackInsightsClick() {
+ this.track('click_insights_button', { label: 'performance_insights' });
+ },
},
};
</script>
@@ -154,6 +178,15 @@ export default {
</gl-button>
</gl-button-group>
+ <gl-button
+ v-if="isPipelineComplete"
+ v-gl-modal="$options.performanceModalId"
+ data-testid="pipeline-insights-btn"
+ @click="trackInsightsClick"
+ >
+ {{ $options.i18n.performanceBtnText }}
+ </gl-button>
+
<div v-if="showLinksToggle" class="gl-display-flex gl-align-items-center">
<gl-toggle
v-model="showLinksActive"
@@ -169,5 +202,7 @@ export default {
<gl-alert v-if="showTip" class="gl-my-5" variant="tip" @dismiss="dismissTip">
{{ $options.i18n.hoverTipText }}
</gl-alert>
+
+ <performance-insights-modal />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index 37878f3fb6d..fabae62fc45 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -63,6 +63,18 @@ export default {
default: '',
},
},
+ modal: {
+ id: DELETE_MODAL_ID,
+ actionPrimary: {
+ text: __('Delete pipeline'),
+ attributes: {
+ variant: 'danger',
+ },
+ },
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ },
apollo: {
pipeline: {
context() {
@@ -275,7 +287,7 @@ export default {
<gl-button
v-if="pipeline.userPermissions.destroyPipeline"
- v-gl-modal="$options.DELETE_MODAL_ID"
+ v-gl-modal="$options.modal.id"
:loading="isDeleting"
:disabled="isDeleting"
class="gl-ml-3"
@@ -289,11 +301,11 @@ export default {
<gl-loading-icon v-if="isLoadingInitialQuery" size="lg" class="gl-mt-3 gl-mb-3" />
<gl-modal
- :modal-id="$options.DELETE_MODAL_ID"
+ :modal-id="$options.modal.id"
:title="__('Delete pipeline')"
- :ok-title="__('Delete pipeline')"
- ok-variant="danger"
- @ok="deletePipeline()"
+ :action-primary="$options.modal.actionPrimary"
+ :action-cancel="$options.modal.actionCancel"
+ @primary="deletePipeline()"
>
<p>
{{ deleteModalConfirmationText }}
diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue
index 070c5ee59de..0c6b8b9ed2b 100644
--- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue
+++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue
@@ -96,7 +96,7 @@ export default {
<template #cell(actions)="{ item }">
<gl-button
v-if="canRetryJob(item)"
- icon="repeat"
+ icon="retry"
:title="$options.retry"
:aria-label="$options.retry"
@click="retryJob(item.id)"
diff --git a/app/assets/javascripts/pipelines/components/performance_insights_modal.vue b/app/assets/javascripts/pipelines/components/performance_insights_modal.vue
new file mode 100644
index 00000000000..ae6b9186930
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/performance_insights_modal.vue
@@ -0,0 +1,168 @@
+<script>
+import { GlAlert, GlCard, GlLink, GlLoadingIcon, GlModal } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import { humanizeTimeInterval } from '~/lib/utils/datetime_utility';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
+import getPerformanceInsightsQuery from '../graphql/queries/get_performance_insights.query.graphql';
+import { performanceModalId } from '../constants';
+import { calculateJobStats, calculateSlowestFiveJobs } from '../utils';
+
+export default {
+ name: 'PerformanceInsightsModal',
+ i18n: {
+ queuedCardHeader: s__('Pipeline|Longest queued job'),
+ queuedCardHelp: s__(
+ 'Pipeline|The longest queued job is the job that spent the longest time in the pending state, waiting to be picked up by a Runner',
+ ),
+ executedCardHeader: s__('Pipeline|Last executed job'),
+ executedCardHelp: s__(
+ 'Pipeline|The last executed job is the last job to start in the pipeline.',
+ ),
+ viewDependency: s__('Pipeline|View dependency'),
+ slowJobsTitle: s__('Pipeline|Five slowest jobs'),
+ feeback: __('Feedback issue'),
+ insightsLimit: s__('Pipeline|Only able to show first 100 results'),
+ },
+ modal: {
+ title: s__('Pipeline|Performance insights'),
+ actionCancel: {
+ text: __('Close'),
+ attributes: {
+ variant: 'confirm',
+ },
+ },
+ },
+ performanceModalId,
+ components: {
+ GlAlert,
+ GlCard,
+ GlLink,
+ GlModal,
+ GlLoadingIcon,
+ HelpPopover,
+ },
+ inject: {
+ pipelineIid: {
+ default: '',
+ },
+ pipelineProjectPath: {
+ default: '',
+ },
+ },
+ apollo: {
+ jobs: {
+ query: getPerformanceInsightsQuery,
+ variables() {
+ return {
+ fullPath: this.pipelineProjectPath,
+ iid: this.pipelineIid,
+ };
+ },
+ update(data) {
+ return data.project?.pipeline?.jobs;
+ },
+ },
+ },
+ data() {
+ return {
+ jobs: null,
+ };
+ },
+ computed: {
+ longestQueuedJob() {
+ return calculateJobStats(this.jobs, 'queuedDuration');
+ },
+ lastExecutedJob() {
+ return calculateJobStats(this.jobs, 'startedAt');
+ },
+ slowestFiveJobs() {
+ return calculateSlowestFiveJobs(this.jobs);
+ },
+ queuedDurationDisplay() {
+ return humanizeTimeInterval(this.longestQueuedJob.queuedDuration);
+ },
+ showLimitMessage() {
+ return this.jobs.pageInfo.hasNextPage;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ :modal-id="$options.performanceModalId"
+ :title="$options.modal.title"
+ :action-cancel="$options.modal.actionCancel"
+ >
+ <gl-loading-icon v-if="$apollo.queries.jobs.loading" size="lg" />
+
+ <template v-else>
+ <gl-alert v-if="showLimitMessage" class="gl-mb-4" :dismissible="false">
+ <p>{{ $options.i18n.insightsLimit }}</p>
+ <gl-link href="https://gitlab.com/gitlab-org/gitlab/-/issues/365902" class="gl-mt-5">
+ {{ $options.i18n.feeback }}
+ </gl-link>
+ </gl-alert>
+ <div class="gl-display-flex gl-justify-content-space-between gl-mb-7">
+ <gl-card class="gl-w-half gl-mr-7 gl-text-center">
+ <template #header>
+ <span class="gl-font-weight-bold">{{ $options.i18n.queuedCardHeader }}</span>
+ <help-popover>
+ {{ $options.i18n.queuedCardHelp }}
+ </help-popover>
+ </template>
+ <div class="gl-display-flex gl-flex-direction-column">
+ <span
+ class="gl-font-weight-bold gl-font-size-h2 gl-mb-2"
+ data-testid="insights-queued-card-data"
+ >
+ {{ queuedDurationDisplay }}
+ </span>
+ <gl-link
+ :href="longestQueuedJob.detailedStatus.detailsPath"
+ data-testid="insights-queued-card-link"
+ >
+ {{ longestQueuedJob.name }}
+ </gl-link>
+ </div>
+ </gl-card>
+ <gl-card class="gl-w-half gl-text-center" data-testid="insights-executed-card">
+ <template #header>
+ <span class="gl-font-weight-bold">{{ $options.i18n.executedCardHeader }}</span>
+ <help-popover>
+ {{ $options.i18n.executedCardHelp }}
+ </help-popover>
+ </template>
+ <div class="gl-display-flex gl-flex-direction-column">
+ <span
+ class="gl-font-weight-bold gl-font-size-h2 gl-mb-2"
+ data-testid="insights-executed-card-data"
+ >
+ {{ lastExecutedJob.name }}
+ </span>
+ <gl-link
+ :href="lastExecutedJob.detailedStatus.detailsPath"
+ data-testid="insights-executed-card-link"
+ >
+ {{ $options.i18n.viewDependency }}
+ </gl-link>
+ </div>
+ </gl-card>
+ </div>
+
+ <div class="gl-mt-7">
+ <span class="gl-font-weight-bold">{{ $options.i18n.slowJobsTitle }}</span>
+ <div
+ v-for="job in slowestFiveJobs"
+ :key="job.name"
+ class="gl-display-flex gl-justify-content-space-between gl-mb-3 gl-mt-3 gl-p-4 gl-border-t-1 gl-border-t-solid gl-border-b-0 gl-border-b-solid gl-border-gray-100"
+ >
+ <span data-testid="insights-slow-job-stage">{{ job.stage.name }}</span>
+ <gl-link :href="job.detailedStatus.detailsPath" data-testid="insights-slow-job-link">{{
+ job.name
+ }}</gl-link>
+ </div>
+ </div>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
index fa0e153b2af..7a08dacb824 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
@@ -80,7 +80,7 @@ export default {
class="js-pipelines-retry-button"
data-qa-selector="pipeline_retry_button"
data-testid="pipelines-retry-button"
- icon="repeat"
+ icon="retry"
variant="default"
category="secondary"
@click="handleRetryClick"
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue b/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue
index 76ee6ab613b..69509c9088b 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue
@@ -1,5 +1,6 @@
<script>
-import { GlBadge, GlFriendlyWrap, GlLink, GlModal } from '@gitlab/ui';
+import { GlBadge, GlFriendlyWrap, GlLink, GlModal, GlTooltipDirective } from '@gitlab/ui';
+import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
import { __, n__, s__, sprintf } from '~/locale';
import CodeBlock from '~/vue_shared/components/code_block.vue';
@@ -11,6 +12,10 @@ export default {
GlFriendlyWrap,
GlLink,
GlModal,
+ ModalCopyButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
props: {
modalId: {
@@ -57,6 +62,7 @@ export default {
history: __('History'),
trace: __('System output'),
attachment: s__('TestReports|Attachment'),
+ copyTestName: s__('TestReports|Copy test name to rerun locally'),
},
modalCloseButton: {
text: __('Close'),
@@ -85,6 +91,13 @@ export default {
{{ testCase.file }}
</gl-link>
<span v-else>{{ testCase.file }}</span>
+ <modal-copy-button
+ :title="$options.text.copyTestName"
+ :text="testCase.file"
+ :modal-id="modalId"
+ category="tertiary"
+ class="gl-ml-1"
+ />
</div>
</div>
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
index 58d072b0005..3fb46a4f128 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
@@ -1,6 +1,7 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
+import createTestReportsStore from '../../stores/test_reports';
import EmptyState from './empty_state.vue';
import TestSuiteTable from './test_suite_table.vue';
import TestSummary from './test_summary.vue';
@@ -15,9 +16,10 @@ export default {
TestSummary,
TestSummaryTable,
},
+ inject: ['blobPath', 'summaryEndpoint', 'suiteEndpoint'],
computed: {
- ...mapState(['isLoading', 'selectedSuiteIndex', 'testReports']),
- ...mapGetters(['getSelectedSuite']),
+ ...mapState('testReports', ['isLoading', 'selectedSuiteIndex', 'testReports']),
+ ...mapGetters('testReports', ['getSelectedSuite']),
showSuite() {
return this.selectedSuiteIndex !== null;
},
@@ -27,10 +29,19 @@ export default {
},
},
created() {
+ this.$store.registerModule(
+ 'testReports',
+ createTestReportsStore({
+ blobPath: this.blobPath,
+ summaryEndpoint: this.summaryEndpoint,
+ suiteEndpoint: this.suiteEndpoint,
+ }),
+ );
+
this.fetchSummary();
},
methods: {
- ...mapActions([
+ ...mapActions('testReports', [
'fetchTestSuite',
'fetchSummary',
'setSelectedSuiteIndex',
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
index 1e481d37017..1f438c63fee 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
@@ -51,14 +51,18 @@ export default {
},
},
computed: {
- ...mapState(['pageInfo']),
- ...mapGetters(['getSuiteTests', 'getSuiteTestCount', 'getSuiteArtifactsExpired']),
+ ...mapState('testReports', ['pageInfo']),
+ ...mapGetters('testReports', [
+ 'getSuiteTests',
+ 'getSuiteTestCount',
+ 'getSuiteArtifactsExpired',
+ ]),
hasSuites() {
return this.getSuiteTests.length > 0;
},
},
methods: {
- ...mapActions(['setPage']),
+ ...mapActions('testReports', ['setPage']),
},
wrapSymbols: ['::', '#', '.', '_', '-', '/', '\\'],
i18n,
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
index 2b44ce57faa..8389c2a5104 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
@@ -19,7 +19,7 @@ export default {
},
},
computed: {
- ...mapGetters(['getTestSuites']),
+ ...mapGetters('testReports', ['getTestSuites']),
hasSuites() {
return this.getTestSuites.length > 0;
},
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index 0510992e962..2e825016c91 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -109,3 +109,5 @@ export const DEFAULT_FIELDS = [
columnClass: 'gl-w-20p',
},
];
+
+export const performanceModalId = 'performanceInsightsModal';
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_performance_insights.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_performance_insights.query.graphql
new file mode 100644
index 00000000000..25e990c8934
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_performance_insights.query.graphql
@@ -0,0 +1,28 @@
+query getPerformanceInsightsData($fullPath: ID!, $iid: ID!) {
+ project(fullPath: $fullPath) {
+ id
+ pipeline(iid: $iid) {
+ id
+ jobs {
+ pageInfo {
+ hasNextPage
+ }
+ nodes {
+ id
+ duration
+ detailedStatus {
+ id
+ detailsPath
+ }
+ name
+ stage {
+ id
+ name
+ }
+ startedAt
+ queuedDuration
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js
index e7c00d89a10..c0e769e2485 100644
--- a/app/assets/javascripts/pipelines/pipeline_tabs.js
+++ b/app/assets/javascripts/pipelines/pipeline_tabs.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import Vuex from 'vuex';
import VueApollo from 'vue-apollo';
import PipelineTabs from 'ee_else_ce/pipelines/components/pipeline_tabs.vue';
import { removeParams, updateHistory } from '~/lib/utils/url_utility';
@@ -7,6 +8,7 @@ import { parseBoolean } from '~/lib/utils/common_utils';
import { getPipelineDefaultTab, reportToSentry } from './utils';
Vue.use(VueApollo);
+Vue.use(Vuex);
export const createAppOptions = (selector, apolloProvider) => {
const el = document.querySelector(selector);
@@ -37,6 +39,7 @@ export const createAppOptions = (selector, apolloProvider) => {
PipelineTabs,
},
apolloProvider,
+ store: new Vuex.Store(),
provide: {
canGenerateCodequalityReports: parseBoolean(canGenerateCodequalityReports),
codequalityReportDownloadPath,
diff --git a/app/assets/javascripts/pipelines/pipeline_test_details.js b/app/assets/javascripts/pipelines/pipeline_test_details.js
index 27ab2418440..fe4ca8e9529 100644
--- a/app/assets/javascripts/pipelines/pipeline_test_details.js
+++ b/app/assets/javascripts/pipelines/pipeline_test_details.js
@@ -1,9 +1,10 @@
import Vue from 'vue';
+import Vuex from 'vuex';
import { parseBoolean } from '~/lib/utils/common_utils';
import Translate from '~/vue_shared/translate';
import TestReports from './components/test_reports/test_reports.vue';
-import createTestReportsStore from './stores/test_reports';
+Vue.use(Vuex);
Vue.use(Translate);
export const createTestDetails = (selector) => {
@@ -16,11 +17,6 @@ export const createTestDetails = (selector) => {
suiteEndpoint,
artifactsExpiredImagePath,
} = el?.dataset || {};
- const testReportsStore = createTestReportsStore({
- blobPath,
- summaryEndpoint,
- suiteEndpoint,
- });
// eslint-disable-next-line no-new
new Vue({
@@ -32,8 +28,11 @@ export const createTestDetails = (selector) => {
emptyStateImagePath,
artifactsExpiredImagePath,
hasTestReport: parseBoolean(hasTestReport),
+ blobPath,
+ summaryEndpoint,
+ suiteEndpoint,
},
- store: testReportsStore,
+ store: new Vuex.Store(),
render(createElement) {
return createElement('test-reports');
},
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/constants.js b/app/assets/javascripts/pipelines/stores/test_reports/constants.js
index 8eebfb6b208..83d14e1a109 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/constants.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/constants.js
@@ -1 +1 @@
-export const ARTIFACTS_EXPIRED_ERROR_MESSAGE = 'Test report artifacts have expired';
+export const ARTIFACTS_EXPIRED_ERROR_MESSAGE = 'Test report artifacts not found';
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/index.js b/app/assets/javascripts/pipelines/stores/test_reports/index.js
index 64d4b8bafb1..f45a53f47b7 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/index.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/index.js
@@ -1,16 +1,14 @@
-import Vue from 'vue';
-import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
-Vue.use(Vuex);
-
-export default (initialState) =>
- new Vuex.Store({
+export default (initialState) => {
+ return {
+ namespaced: true,
actions,
getters,
mutations,
state: state(initialState),
- });
+ };
+};
diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js
index 588d15495ab..83e00b80426 100644
--- a/app/assets/javascripts/pipelines/utils.js
+++ b/app/assets/javascripts/pipelines/utils.js
@@ -153,3 +153,24 @@ export const getPipelineDefaultTab = (url) => {
return null;
};
+
+export const calculateJobStats = (jobs, sortField) => {
+ const jobNodes = [...jobs.nodes];
+
+ const sorted = jobNodes.sort((a, b) => {
+ return b[sortField] - a[sortField];
+ });
+
+ return sorted[0];
+};
+
+export const calculateSlowestFiveJobs = (jobs) => {
+ const jobNodes = [...jobs.nodes];
+ const limit = 5;
+
+ return jobNodes
+ .sort((a, b) => {
+ return b.duration - a.duration;
+ })
+ .slice(0, limit);
+};
diff --git a/app/assets/javascripts/profile/account/index.js b/app/assets/javascripts/profile/account/index.js
index 00fe0bcf89b..f208280af27 100644
--- a/app/assets/javascripts/profile/account/index.js
+++ b/app/assets/javascripts/profile/account/index.js
@@ -30,7 +30,7 @@ export default () => {
deleteAccountModal,
},
mounted() {
- deleteAccountButton.classList.remove('disabled');
+ deleteAccountButton.disabled = false;
deleteAccountButton.addEventListener('click', () => {
this.$root.$emit(BV_SHOW_MODAL, 'delete-account-modal', '#delete-account-button');
});
diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js
index 25fefff219c..064bcf8e4c4 100644
--- a/app/assets/javascripts/profile/profile.js
+++ b/app/assets/javascripts/profile/profile.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import createFlash, { FLASH_TYPES } from '~/flash';
+import { VARIANT_DANGER, VARIANT_INFO, createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { parseBoolean } from '~/lib/utils/common_utils';
import { Rails } from '~/lib/utils/rails_ujs';
@@ -10,7 +10,7 @@ import TimezoneDropdown, {
export default class Profile {
constructor({ form } = {}) {
this.onSubmitForm = this.onSubmitForm.bind(this);
- this.form = form || $('.edit-user');
+ this.form = form || $('.js-edit-user');
this.setRepoRadio();
this.bindEvents();
this.initAvatarGlCrop();
@@ -84,9 +84,9 @@ export default class Profile {
this.updateHeaderAvatar();
}
- createFlash({
+ createAlert({
message: data.message,
- type: data.status === 'error' ? FLASH_TYPES.ALERT : FLASH_TYPES.NOTICE,
+ variant: data.status === 'error' ? VARIANT_DANGER : VARIANT_INFO,
});
})
.then(() => {
@@ -95,8 +95,9 @@ export default class Profile {
self.form.find(':input[disabled]').enable();
})
.catch((error) =>
- createFlash({
+ createAlert({
message: error.message,
+ variant: VARIANT_DANGER,
}),
);
}
diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
index 9bd78b7c89e..1cdf26b76b7 100644
--- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
+++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
@@ -43,7 +43,11 @@ export default {
},
apollo: {
pipeline: {
+ context() {
+ return getQueryHeaders(this.graphqlResourceEtag);
+ },
query: getLinkedPipelinesQuery,
+ pollInterval: COMMIT_BOX_POLL_INTERVAL,
variables() {
return {
fullPath: this.fullPath,
@@ -116,6 +120,7 @@ export default {
},
mounted() {
toggleQueryPollingByVisibility(this.$apollo.queries.pipelineStages);
+ toggleQueryPollingByVisibility(this.$apollo.queries.pipeline);
},
};
</script>
diff --git a/app/assets/javascripts/projects/commit_box/info/init_details_button.js b/app/assets/javascripts/projects/commit_box/info/init_details_button.js
index 833e946af5c..bc2c16b9e83 100644
--- a/app/assets/javascripts/projects/commit_box/info/init_details_button.js
+++ b/app/assets/javascripts/projects/commit_box/info/init_details_button.js
@@ -1,9 +1,7 @@
-import $ from 'jquery';
-
export const initDetailsButton = () => {
- $('body').on('click', '.js-details-expand', function expand(e) {
+ document.querySelector('.commit-info').addEventListener('click', function expand(e) {
e.preventDefault();
- $(this).next('.js-details-content').removeClass('hide');
- $(this).hide();
+ this.querySelector('.js-details-content').classList.remove('hide');
+ this.querySelector('.js-details-expand').classList.add('gl-display-none');
});
};
diff --git a/app/assets/javascripts/projects/commits/components/author_select.vue b/app/assets/javascripts/projects/commits/components/author_select.vue
index 884ef732144..f85be67d4b3 100644
--- a/app/assets/javascripts/projects/commits/components/author_select.vue
+++ b/app/assets/javascripts/projects/commits/components/author_select.vue
@@ -110,7 +110,7 @@ export default {
:text="dropdownText"
:disabled="hasSearchParam"
toggle-class="gl-py-3 gl-border-0"
- class="w-100 mt-2 mt-sm-0"
+ class="w-100 gl-mt-3 mt-sm-0"
>
<gl-dropdown-section-header>
{{ __('Search by author') }}
diff --git a/app/assets/javascripts/projects/compare/components/app.vue b/app/assets/javascripts/projects/compare/components/app.vue
index 3945bed9649..bda58091b97 100644
--- a/app/assets/javascripts/projects/compare/components/app.vue
+++ b/app/assets/javascripts/projects/compare/components/app.vue
@@ -121,27 +121,21 @@ export default {
@selectRevision="onSelectRevision"
/>
</div>
- <div class="gl-mt-6">
+ <div class="gl-display-flex gl-mt-6 gl-gap-3">
<gl-button category="primary" variant="confirm" @click="onSubmit">
{{ s__('CompareRevisions|Compare') }}
</gl-button>
- <gl-button data-testid="swapRevisionsButton" class="btn btn-default" @click="onSwapRevision">
+ <gl-button data-testid="swapRevisionsButton" @click="onSwapRevision">
{{ s__('CompareRevisions|Swap revisions') }}
</gl-button>
<gl-button
v-if="projectMergeRequestPath"
:href="projectMergeRequestPath"
data-testid="projectMrButton"
- class="btn btn-default gl-button"
>
{{ s__('CompareRevisions|View open merge request') }}
</gl-button>
- <gl-button
- v-else-if="createMrPath"
- :href="createMrPath"
- data-testid="createMrButton"
- class="btn btn-default gl-button"
- >
+ <gl-button v-else-if="createMrPath" :href="createMrPath" data-testid="createMrButton">
{{ s__('CompareRevisions|Create merge request') }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/projects/new/components/app.vue b/app/assets/javascripts/projects/new/components/app.vue
index 476d6466cbb..59ca393fe92 100644
--- a/app/assets/javascripts/projects/new/components/app.vue
+++ b/app/assets/javascripts/projects/new/components/app.vue
@@ -16,7 +16,7 @@ const PANELS = [
selector: '#blank-project-pane',
title: s__('ProjectsNew|Create blank project'),
description: s__(
- 'ProjectsNew|Create a blank project to house your files, plan your work, and collaborate on code, among other things.',
+ 'ProjectsNew|Create a blank project to store your files, plan your work, and collaborate on code, among other things.',
),
illustration: blankProjectIllustration,
},
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 506f1ec5ffd..eccfb3d844c 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
@@ -7,6 +7,7 @@ import {
GlDropdownText,
GlDropdownSectionHeader,
GlSearchBoxByType,
+ GlTruncate,
} from '@gitlab/ui';
import { joinPaths, PATH_SEPARATOR } from '~/lib/utils/url_utility';
import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
@@ -26,6 +27,7 @@ export default {
GlDropdownText,
GlDropdownSectionHeader,
GlSearchBoxByType,
+ GlTruncate,
},
mixins: [Tracking.mixin()],
apollo: {
@@ -55,10 +57,7 @@ export default {
id: this.namespaceId,
fullPath: this.namespaceFullPath,
}
- : {
- id: undefined,
- fullPath: s__('ProjectsNew|Pick a group or namespace'),
- },
+ : this.$options.emptyNameSpace,
shouldSkipQuery: true,
userNamespaceId: this.userNamespaceId,
};
@@ -118,12 +117,18 @@ export default {
this.setNamespace({ id, fullPath });
},
setNamespace({ id, fullPath }) {
- this.selectedNamespace = {
- id: getIdFromGraphQLId(id),
- fullPath,
- };
+ this.selectedNamespace = id
+ ? {
+ id: getIdFromGraphQLId(id),
+ fullPath,
+ }
+ : this.$options.emptyNameSpace;
},
},
+ emptyNameSpace: {
+ id: undefined,
+ fullPath: s__('ProjectsNew|Pick a group or namespace'),
+ },
};
</script>
@@ -137,13 +142,20 @@ export default {
>
<gl-dropdown
- :text="selectedNamespace.fullPath"
class="js-group-namespace-dropdown gl-flex-grow-1"
:toggle-class="`gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20 ${dropdownPlaceholderClass}`"
data-qa-selector="select_namespace_dropdown"
@show="track('activate_form_input', { label: trackLabel, property: 'project_path' })"
@shown="handleDropdownShown"
>
+ <template #button-text>
+ <gl-truncate
+ v-if="selectedNamespace.fullPath"
+ :text="selectedNamespace.fullPath"
+ position="start"
+ with-tooltip
+ />
+ </template>
<gl-search-box-by-type
ref="search"
v-model.trim="search"
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
index 35e7554aee2..186fcf70838 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
@@ -14,12 +14,15 @@ export default {
LeadTimeCharts: () => import('ee_component/dora/components/lead_time_charts.vue'),
TimeToRestoreServiceCharts: () =>
import('ee_component/dora/components/time_to_restore_service_charts.vue'),
+ ChangeFailureRateCharts: () =>
+ import('ee_component/dora/components/change_failure_rate_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',
timeToRestoreServiceTabEvent: 'p_analytics_ci_cd_time_to_restore_service',
+ changeFailureRateTabEvent: 'p_analytics_ci_cd_change_failure_rate',
inject: {
shouldRenderDoraCharts: {
type: Boolean,
@@ -40,7 +43,12 @@ export default {
const chartsToShow = ['pipelines'];
if (this.shouldRenderDoraCharts) {
- chartsToShow.push('deployment-frequency', 'lead-time', 'time-to-restore-service');
+ chartsToShow.push(
+ 'deployment-frequency',
+ 'lead-time',
+ 'time-to-restore-service',
+ 'change-failure-rate',
+ );
}
if (this.shouldRenderQualitySummary) {
@@ -105,6 +113,13 @@ export default {
>
<time-to-restore-service-charts />
</gl-tab>
+ <gl-tab
+ :title="s__('DORA4Metrics|Change failure rate')"
+ data-testid="change-failure-rate-tab"
+ @click="trackTabClick($options.changeFailureRateTabEvent)"
+ >
+ <change-failure-rate-charts />
+ </gl-tab>
</template>
<gl-tab v-if="shouldRenderQualitySummary" :title="s__('QualitySummary|Project quality')">
<project-quality-summary />
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index 186946a83ad..fe84660422b 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -342,6 +342,7 @@ const bindEvents = () => {
export default {
bindEvents,
+ validateGroupNamespaceDropdown,
deriveProjectPathFromUrl,
onProjectNameChange,
onProjectPathChange,
diff --git a/app/assets/javascripts/projects/project_visibility.js b/app/assets/javascripts/projects/project_visibility.js
index d299e106b14..b8ac17a01f2 100644
--- a/app/assets/javascripts/projects/project_visibility.js
+++ b/app/assets/javascripts/projects/project_visibility.js
@@ -10,7 +10,7 @@ const visibilityLevel = {
};
function setVisibilityOptions({ name, visibility, showPath, editPath }) {
- document.querySelectorAll('.visibility-level-setting .form-check').forEach((option) => {
+ document.querySelectorAll('.visibility-level-setting .gl-form-radio').forEach((option) => {
// Don't change anything if the option is restricted by admin
if (option.classList.contains('restricted')) {
return;
@@ -24,7 +24,7 @@ function setVisibilityOptions({ name, visibility, showPath, editPath }) {
optionInput.disabled = true;
const reason = option.querySelector('.option-disabled-reason');
if (reason) {
- const optionTitle = option.querySelector('.option-title');
+ const optionTitle = option.querySelector('.js-visibility-level-radio span');
const optionName = optionTitle ? optionTitle.innerText.toLowerCase() : '';
reason.innerHTML = sprintf(
__(
diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js
index 79dfa166b1a..060178a3cfb 100644
--- a/app/assets/javascripts/projects/settings/access_dropdown.js
+++ b/app/assets/javascripts/projects/settings/access_dropdown.js
@@ -441,11 +441,13 @@ export default class AccessDropdown {
const {
id,
fingerprint,
+ fingerprint_sha256: fingerprintSha256,
title,
owner: { avatar_url, name, username },
} = response;
- const shortFingerprint = `(${fingerprint.substring(0, 14)}...)`;
+ const availableFingerprint = fingerprintSha256 || fingerprint;
+ const shortFingerprint = `(${availableFingerprint.substring(0, 14)}...)`;
return {
id,
diff --git a/app/assets/javascripts/projects/settings/components/access_dropdown.vue b/app/assets/javascripts/projects/settings/components/access_dropdown.vue
index 9823b0229a0..fcf81c9d1f7 100644
--- a/app/assets/javascripts/projects/settings/components/access_dropdown.vue
+++ b/app/assets/javascripts/projects/settings/components/access_dropdown.vue
@@ -203,11 +203,13 @@ export default {
const {
id,
fingerprint,
+ fingerprint_sha256: fingerprintSha256,
title,
owner: { avatar_url, name, username },
} = response;
- const shortFingerprint = `(${fingerprint.substring(0, 14)}...)`;
+ const availableFingerprint = fingerprintSha256 || fingerprint;
+ const shortFingerprint = `(${availableFingerprint.substring(0, 14)}...)`;
return {
id,
@@ -351,7 +353,6 @@ export default {
<gl-dropdown-item
v-for="group in groups"
:key="`${group.id}${group.name}`"
- fingerprint
data-testid="group-dropdown-item"
:avatar-url="group.avatar_url"
is-check-item
@@ -388,7 +389,7 @@ export default {
}}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="key in deployKeys"
- :key="`${key.id}${key.fingerprint}`"
+ :key="`${key.id}-{key.title}`"
data-testid="deploy_key-dropdown-item"
is-check-item
:is-checked="isSelected(key)"
diff --git a/app/assets/javascripts/projects/star.js b/app/assets/javascripts/projects/star.js
index 578e22ca25d..5bbace11b15 100644
--- a/app/assets/javascripts/projects/star.js
+++ b/app/assets/javascripts/projects/star.js
@@ -1,31 +1,33 @@
-import $ from 'jquery';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from '~/lib/utils/common_utils';
import { __, s__ } from '~/locale';
export default class Star {
- constructor(container = '.project-home-panel') {
- $(`${container} .toggle-star`).on('click', function toggleStarClickCallback() {
- const $this = $(this);
- const $starSpan = $this.find('span');
- const $starIcon = $this.find('svg');
- const iconClasses = $starIcon.attr('class').split(' ');
+ constructor(containerSelector = '.project-home-panel') {
+ const container = document.querySelector(containerSelector);
+ const starToggle = container.querySelector('.toggle-star');
+ starToggle.addEventListener('click', function toggleStarClickCallback() {
+ const starSpan = starToggle.querySelector('span');
+ const starIcon = starToggle.querySelector('svg');
+ const iconClasses = Array.from(starIcon.classList.values());
axios
- .post($this.data('endpoint'))
+ .post(starToggle.dataset.endpoint)
.then(({ data }) => {
- const isStarred = $starSpan.hasClass('starred');
- $this.parent().find('.count').text(data.star_count);
+ const isStarred = starSpan.classList.contains('starred');
+ starToggle.parentNode.querySelector('.count').textContent = data.star_count;
if (isStarred) {
- $starSpan.removeClass('starred').text(s__('StarProject|Star'));
- $starIcon.remove();
- $this.prepend(spriteIcon('star-o', iconClasses));
+ starSpan.classList.remove('starred');
+ starSpan.textContent = s__('StarProject|Star');
+ starIcon.remove();
+ starSpan.insertAdjacentHTML('beforebegin', spriteIcon('star-o', iconClasses));
} else {
- $starSpan.addClass('starred').text(__('Unstar'));
- $starIcon.remove();
- $this.prepend(spriteIcon('star', iconClasses));
+ starSpan.classList.add('starred');
+ starSpan.textContent = __('Unstar');
+ starIcon.remove();
+ starSpan.insertAdjacentHTML('beforebegin', spriteIcon('star', iconClasses));
}
})
.catch(() =>
diff --git a/app/assets/javascripts/related_issues/components/add_issuable_form.vue b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
index 42de419aec4..d765033d00b 100644
--- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue
+++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
@@ -173,7 +173,7 @@ export default {
:label="issuableCategoryHeaderText"
label-for="linked-issue-type-radio"
label-class="label-bold"
- class="mb-2"
+ class="gl-mb-3"
>
<gl-form-radio-group
id="linked-issue-type-radio"
@@ -216,12 +216,12 @@ export default {
:disabled="isSubmitButtonDisabled"
:loading="isSubmitting"
type="submit"
- class="float-left"
+ class="gl-float-left"
data-qa-selector="add_issue_button"
>
{{ __('Add') }}
</gl-button>
- <gl-button class="float-right" @click="onFormCancel">
+ <gl-button class="gl-float-right" @click="onFormCancel">
{{ __('Cancel') }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue
index 327da1fb2a1..022c3224bb4 100644
--- a/app/assets/javascripts/releases/components/app_edit_new.vue
+++ b/app/assets/javascripts/releases/components/app_edit_new.vue
@@ -1,5 +1,13 @@
<script>
-import { GlButton, GlFormCheckbox, GlFormInput, GlFormGroup, GlLink, GlSprintf } from '@gitlab/ui';
+import {
+ GlButton,
+ GlDatepicker,
+ GlFormCheckbox,
+ GlFormInput,
+ GlFormGroup,
+ GlLink,
+ GlSprintf,
+} from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import { isSameOriginUrl, getParameterByName } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
@@ -7,6 +15,7 @@ import MilestoneCombobox from '~/milestones/components/milestone_combobox.vue';
import { BACK_URL_PARAM } from '~/releases/constants';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import AssetLinksForm from './asset_links_form.vue';
+import ConfirmDeleteModal from './confirm_delete_modal.vue';
import TagField from './tag_field.vue';
export default {
@@ -16,8 +25,10 @@ export default {
GlFormInput,
GlFormGroup,
GlButton,
+ GlDatepicker,
GlLink,
GlSprintf,
+ ConfirmDeleteModal,
MarkdownField,
AssetLinksForm,
MilestoneCombobox,
@@ -25,12 +36,14 @@ export default {
},
computed: {
...mapState('editNew', [
+ 'isExistingRelease',
'isFetchingRelease',
'isUpdatingRelease',
'fetchError',
'markdownDocsPath',
'markdownPreviewPath',
'editReleaseDocsPath',
+ 'upcomingReleaseDocsPath',
'releasesPagePath',
'release',
'newMilestonePath',
@@ -40,7 +53,7 @@ export default {
'groupMilestonesAvailable',
'tagNotes',
]),
- ...mapGetters('editNew', ['isValid', 'isExistingRelease', 'formattedReleaseNotes']),
+ ...mapGetters('editNew', ['isValid', 'formattedReleaseNotes']),
showForm() {
return Boolean(!this.isFetchingRelease && !this.fetchError && this.release);
},
@@ -76,6 +89,14 @@ export default {
this.updateIncludeTagNotes(includeTagNotes);
},
},
+ releasedAt: {
+ get() {
+ return this.release.releasedAt;
+ },
+ set(date) {
+ this.updateReleasedAt(date);
+ },
+ },
cancelPath() {
const backUrl = getParameterByName(BACK_URL_PARAM);
@@ -114,10 +135,12 @@ export default {
...mapActions('editNew', [
'initializeRelease',
'saveRelease',
+ 'deleteRelease',
'updateReleaseTitle',
'updateReleaseNotes',
'updateReleaseMilestones',
'updateIncludeTagNotes',
+ 'updateReleasedAt',
]),
submitForm() {
if (!this.isFormSubmissionDisabled) {
@@ -166,6 +189,22 @@ export default {
/>
</div>
</gl-form-group>
+ <gl-form-group :label="__('Release date')" label-for="release-released-at">
+ <template #label-description>
+ <gl-sprintf
+ :message="
+ __(
+ 'The date when the release is ready. A release with a date in the future is labeled as an %{linkStart}Upcoming Release%{linkEnd}.',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="upcomingReleaseDocsPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ <gl-datepicker id="release-released-at" v-model="releasedAt" :default-date="releasedAt" />
+ </gl-form-group>
<gl-form-group data-testid="release-notes">
<label for="release-notes">{{ __('Release notes') }}</label>
<div class="bordered-box pr-3 pl-3">
@@ -224,6 +263,7 @@ export default {
>
{{ saveButtonLabel }}
</gl-button>
+ <confirm-delete-modal v-if="isExistingRelease" @delete="deleteRelease" />
<gl-button :href="cancelPath" class="js-cancel-button">{{ __('Cancel') }}</gl-button>
</div>
</form>
diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue
index a949a9d1318..d63a83d1a08 100644
--- a/app/assets/javascripts/releases/components/app_index.vue
+++ b/app/assets/javascripts/releases/components/app_index.vue
@@ -4,9 +4,9 @@ import createFlash from '~/flash';
import { historyPushState } from '~/lib/utils/common_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { setUrlParams, getParameterByName } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
+import { __, sprintf } from '~/locale';
import { PAGE_SIZE, DEFAULT_SORT } from '~/releases/constants';
-import { convertAllReleasesGraphQLResponse } from '~/releases/util';
+import { convertAllReleasesGraphQLResponse, deleteReleaseSessionKey } from '~/releases/util';
import allReleasesQuery from '../graphql/queries/all_releases.query.graphql';
import ReleaseBlock from './release_block.vue';
import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
@@ -172,6 +172,20 @@ export default {
return this.isFullRequestLoaded && !this.shouldRenderEmptyState;
},
},
+ mounted() {
+ const key = deleteReleaseSessionKey(this.projectPath);
+ const deletedRelease = window.sessionStorage.getItem(key);
+
+ if (deletedRelease) {
+ this.$toast.show(
+ sprintf(__('Release %{deletedRelease} has been successfully deleted.'), {
+ deletedRelease,
+ }),
+ );
+ }
+
+ window.sessionStorage.removeItem(key);
+ },
created() {
this.updateQueryParamsFromUrl();
diff --git a/app/assets/javascripts/releases/components/confirm_delete_modal.vue b/app/assets/javascripts/releases/components/confirm_delete_modal.vue
new file mode 100644
index 00000000000..aa948fbbaf6
--- /dev/null
+++ b/app/assets/javascripts/releases/components/confirm_delete_modal.vue
@@ -0,0 +1,77 @@
+<script>
+import { GlModal, GlSprintf, GlLink, GlButton } from '@gitlab/ui';
+import { mapState } from 'vuex';
+import { __, s__, sprintf } from '~/locale';
+
+export default {
+ components: {
+ GlModal,
+ GlSprintf,
+ GlLink,
+ GlButton,
+ },
+ data() {
+ return {
+ visible: false,
+ };
+ },
+ computed: {
+ ...mapState('editNew', ['release', 'deleteReleaseDocsPath']),
+ title() {
+ return sprintf(__('Delete release %{release}?'), { release: this.release.name });
+ },
+ },
+ modalOptions: {
+ modalId: 'confirm-delete-release',
+ static: true,
+ actionPrimary: {
+ attributes: { variant: 'danger' },
+ text: __('Delete release'),
+ },
+ actionSecondary: {
+ text: __('Cancel'),
+ attributes: { variant: 'default' },
+ },
+ },
+ i18n: {
+ buttonLabel: __('Delete'),
+ line1: s__(
+ 'DeleteRelease|You are about to delete release %{release} and its assets. The Git tag %{tag} will not be deleted.',
+ ),
+ line2: s__(
+ 'DeleteRelease|For more details, see %{docsPathStart}Deleting a release%{docsPathEnd}.',
+ ),
+ line3: s__('DeleteRelease|Are you sure you want to delete this release?'),
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-button class="gl-mr-3" variant="danger" @click="visible = true">
+ {{ $options.i18n.buttonLabel }}
+ </gl-button>
+ <gl-modal
+ v-bind="$options.modalOptions"
+ v-model="visible"
+ :title="title"
+ @primary="$emit('delete')"
+ >
+ <p>
+ <gl-sprintf :message="$options.i18n.line1">
+ <template #release>{{ release.name }}</template>
+ <template #tag>
+ <gl-link :href="release.tagPath">{{ release.tagName }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ <p>
+ <gl-sprintf :message="$options.i18n.line2">
+ <template #docsPath="{ content }">
+ <gl-link :href="deleteReleaseDocsPath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ <p>{{ $options.i18n.line3 }}</p>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/releases/components/release_block_footer.vue b/app/assets/javascripts/releases/components/release_block_footer.vue
index 91d6d0911a4..3881c83b5c2 100644
--- a/app/assets/javascripts/releases/components/release_block_footer.vue
+++ b/app/assets/javascripts/releases/components/release_block_footer.vue
@@ -42,9 +42,9 @@ export default {
default: null,
},
releasedAt: {
- type: String,
+ type: Date,
required: false,
- default: '',
+ default: null,
},
},
computed: {
@@ -66,8 +66,11 @@ export default {
</script>
<template>
<div>
- <div v-if="commit" class="float-left mr-3 d-flex align-items-center js-commit-info">
- <gl-icon ref="commitIcon" name="commit" class="mr-1" />
+ <div
+ v-if="commit"
+ class="gl-float-left gl-mr-5 gl-display-flex gl-align-items-center js-commit-info"
+ >
+ <gl-icon ref="commitIcon" name="commit" class="gl-mr-2" />
<div v-gl-tooltip.bottom :title="commit.title">
<gl-link v-if="commitPath" :href="commitPath">
{{ commit.shortId }}
@@ -76,8 +79,11 @@ export default {
</div>
</div>
- <div v-if="tagName" class="float-left mr-3 d-flex align-items-center js-tag-info">
- <gl-icon name="tag" class="mr-1" />
+ <div
+ v-if="tagName"
+ class="gl-float-left gl-mr-5 gl-display-flex gl-align-items-center js-tag-info"
+ >
+ <gl-icon name="tag" class="gl-mr-2" />
<div v-gl-tooltip.bottom :title="__('Tag')">
<gl-link v-if="tagPath" :href="tagPath">
{{ tagName }}
@@ -88,23 +94,23 @@ export default {
<div
v-if="releasedAt || author"
- class="float-left d-flex align-items-center js-author-date-info"
+ class="gl-float-left gl-display-flex gl-align-items-center js-author-date-info"
>
- <span class="text-secondary">{{ createdTime }}&nbsp;</span>
+ <span class="gl-text-secondary">{{ createdTime }}&nbsp;</span>
<template v-if="releasedAt">
<span
v-gl-tooltip.bottom
:title="tooltipTitle(releasedAt)"
- class="text-secondary flex-shrink-0"
+ class="gl-text-secondary gl-flex-shrink-0"
>
{{ releasedAtTimeAgo }}&nbsp;
</span>
</template>
- <div v-if="author" class="d-flex">
- <span class="text-secondary">{{ __('by') }}&nbsp;</span>
+ <div v-if="author" class="gl-display-flex">
+ <span class="gl-text-secondary">{{ __('by') }}&nbsp;</span>
<user-avatar-link
- class="gl-my-n1"
+ class="gl-my-n1 gl-display-flex"
:link-href="author.webUrl"
:img-src="author.avatarUrl"
:img-alt="userImageAltDescription"
diff --git a/app/assets/javascripts/releases/components/tag_field.vue b/app/assets/javascripts/releases/components/tag_field.vue
index f4c0fd5e9ce..b4fea9bee35 100644
--- a/app/assets/javascripts/releases/components/tag_field.vue
+++ b/app/assets/javascripts/releases/components/tag_field.vue
@@ -1,5 +1,5 @@
<script>
-import { mapGetters } from 'vuex';
+import { mapState } from 'vuex';
import TagFieldExisting from './tag_field_existing.vue';
import TagFieldNew from './tag_field_new.vue';
@@ -9,7 +9,7 @@ export default {
TagFieldNew,
},
computed: {
- ...mapGetters('editNew', ['isExistingRelease']),
+ ...mapState('editNew', ['isExistingRelease']),
},
};
</script>
diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue
index d3b6d07590f..08b727dcca0 100644
--- a/app/assets/javascripts/releases/components/tag_field_new.vue
+++ b/app/assets/javascripts/releases/components/tag_field_new.vue
@@ -22,12 +22,10 @@ export default {
// the input field. This is used to avoid showing validation
// errors immediately when the page loads.
isInputDirty: false,
-
- showCreateFrom: true,
};
},
computed: {
- ...mapState('editNew', ['projectId', 'release', 'createFrom']),
+ ...mapState('editNew', ['projectId', 'release', 'createFrom', 'showCreateFrom']),
...mapGetters('editNew', ['validationErrors']),
tagName: {
get() {
@@ -40,7 +38,7 @@ export default {
// When this is called, the selection originated from the
// dropdown list of existing tag names, so we know the tag
// already exists and don't need to show the "create from" input
- this.showCreateFrom = false;
+ this.updateShowCreateFrom(false);
},
},
createFromModel: {
@@ -70,7 +68,12 @@ export default {
},
},
methods: {
- ...mapActions('editNew', ['updateReleaseTagName', 'updateCreateFrom', 'fetchTagNotes']),
+ ...mapActions('editNew', [
+ 'updateReleaseTagName',
+ 'updateCreateFrom',
+ 'fetchTagNotes',
+ 'updateShowCreateFrom',
+ ]),
markInputAsDirty() {
this.isInputDirty = true;
},
@@ -80,7 +83,7 @@ export default {
// This method is called when the user selects the "create tag"
// option, so the tag does not already exist. Because of this,
// we need to show the "create from" input.
- this.showCreateFrom = true;
+ this.updateShowCreateFrom(true);
},
shouldShowCreateTagOption(isLoading, matches, query) {
// Show the "create tag" option if:
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 236d266a40a..3ad66afa259 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
@@ -3,6 +3,8 @@ fragment ReleaseForEditing on Release {
name
tagName
description
+ releasedAt
+ tagPath
assets {
links {
nodes {
diff --git a/app/assets/javascripts/releases/graphql/mutations/delete_release.mutation.graphql b/app/assets/javascripts/releases/graphql/mutations/delete_release.mutation.graphql
new file mode 100644
index 00000000000..7a8bf9944a3
--- /dev/null
+++ b/app/assets/javascripts/releases/graphql/mutations/delete_release.mutation.graphql
@@ -0,0 +1,5 @@
+mutation deleteRelease($input: ReleaseDeleteInput!) {
+ releaseDelete(input: $input) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/releases/mount_edit.js b/app/assets/javascripts/releases/mount_edit.js
index fad0451ceef..c3130a0b778 100644
--- a/app/assets/javascripts/releases/mount_edit.js
+++ b/app/assets/javascripts/releases/mount_edit.js
@@ -11,7 +11,7 @@ export default () => {
const store = createStore({
modules: {
- editNew: createEditNewModule(el.dataset),
+ editNew: createEditNewModule({ ...el.dataset, isExistingRelease: true }),
},
});
diff --git a/app/assets/javascripts/releases/mount_index.js b/app/assets/javascripts/releases/mount_index.js
index afb8ab461cd..8e806f0e8d7 100644
--- a/app/assets/javascripts/releases/mount_index.js
+++ b/app/assets/javascripts/releases/mount_index.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { GlToast } from '@gitlab/ui';
import createDefaultClient from '~/lib/graphql';
import ReleaseIndexApp from './components/app_index.vue';
@@ -7,6 +8,7 @@ export default () => {
const el = document.getElementById('js-releases-page');
Vue.use(VueApollo);
+ Vue.use(GlToast);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
diff --git a/app/assets/javascripts/releases/mount_new.js b/app/assets/javascripts/releases/mount_new.js
index b358a27f06d..0a3f8b5e63b 100644
--- a/app/assets/javascripts/releases/mount_new.js
+++ b/app/assets/javascripts/releases/mount_new.js
@@ -11,7 +11,7 @@ export default () => {
const store = createStore({
modules: {
- editNew: createEditNewModule(el.dataset),
+ editNew: createEditNewModule({ ...el.dataset, isExistingRelease: false }),
},
});
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
index 08197377f61..a71a8125d65 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
@@ -3,16 +3,21 @@ import createFlash from '~/flash';
import { redirectTo } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import createReleaseMutation from '~/releases/graphql/mutations/create_release.mutation.graphql';
+import deleteReleaseMutation from '~/releases/graphql/mutations/delete_release.mutation.graphql';
import createReleaseAssetLinkMutation from '~/releases/graphql/mutations/create_release_link.mutation.graphql';
import deleteReleaseAssetLinkMutation from '~/releases/graphql/mutations/delete_release_link.mutation.graphql';
import updateReleaseMutation from '~/releases/graphql/mutations/update_release.mutation.graphql';
import oneReleaseForEditingQuery from '~/releases/graphql/queries/one_release_for_editing.query.graphql';
-import { gqClient, convertOneReleaseGraphQLResponse } from '~/releases/util';
+import {
+ gqClient,
+ convertOneReleaseGraphQLResponse,
+ deleteReleaseSessionKey,
+} from '~/releases/util';
import * as types from './mutation_types';
-export const initializeRelease = ({ commit, dispatch, getters }) => {
- if (getters.isExistingRelease) {
+export const initializeRelease = ({ commit, dispatch, state }) => {
+ if (state.isExistingRelease) {
// When editing an existing release,
// fetch the release object from the API
return dispatch('fetchRelease');
@@ -53,6 +58,9 @@ export const updateReleaseTagName = ({ commit }, tagName) =>
export const updateCreateFrom = ({ commit }, createFrom) =>
commit(types.UPDATE_CREATE_FROM, createFrom);
+export const updateShowCreateFrom = ({ commit }, showCreateFrom) =>
+ commit(types.UPDATE_SHOW_CREATE_FROM, showCreateFrom);
+
export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_RELEASE_TITLE, title);
export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_RELEASE_NOTES, notes);
@@ -88,10 +96,10 @@ export const receiveSaveReleaseSuccess = ({ commit }, urlToRedirectTo) => {
redirectTo(urlToRedirectTo);
};
-export const saveRelease = ({ commit, dispatch, getters }) => {
+export const saveRelease = ({ commit, dispatch, state }) => {
commit(types.REQUEST_SAVE_RELEASE);
- dispatch(getters.isExistingRelease ? 'updateRelease' : 'createRelease');
+ dispatch(state.isExistingRelease ? 'updateRelease' : 'createRelease');
};
/**
@@ -246,3 +254,30 @@ export const fetchTagNotes = ({ commit, state }, tagName) => {
export const updateIncludeTagNotes = ({ commit }, includeTagNotes) => {
commit(types.UPDATE_INCLUDE_TAG_NOTES, includeTagNotes);
};
+
+export const updateReleasedAt = ({ commit }, releasedAt) => {
+ commit(types.UPDATE_RELEASED_AT, releasedAt);
+};
+
+export const deleteRelease = ({ commit, getters, dispatch, state }) => {
+ commit(types.REQUEST_SAVE_RELEASE);
+ return gqClient
+ .mutate({
+ mutation: deleteReleaseMutation,
+ variables: getters.releaseDeleteMutationVariables,
+ })
+ .then((response) => checkForErrorsAsData(response, 'releaseDelete', ''))
+ .then(() => {
+ window.sessionStorage.setItem(
+ deleteReleaseSessionKey(state.projectPath),
+ state.originalRelease.name,
+ );
+ return dispatch('receiveSaveReleaseSuccess', state.releasesPagePath);
+ })
+ .catch((error) => {
+ commit(types.RECEIVE_SAVE_RELEASE_ERROR, error);
+ createFlash({
+ message: s__('Release|Something went wrong while deleting the release.'),
+ });
+ });
+};
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
index 0ca5eb9931a..62d6bd42d51 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
@@ -4,14 +4,6 @@ import { hasContent } from '~/lib/utils/text_utility';
import { getDuplicateItemsFromArray } from '~/lib/utils/array_utility';
/**
- * @returns {Boolean} `true` if the app is editing an existing release.
- * `false` if the app is creating a new release.
- */
-export const isExistingRelease = (state) => {
- return Boolean(state.tagName);
-};
-
-/**
* @param {Object} link The link to test
* @returns {Boolean} `true` if the release link is empty, i.e. it has
* empty (or whitespace-only) values for both `url` and `name`.
@@ -138,6 +130,7 @@ export const releaseUpdateMutatationVariables = (state, getters) => {
projectPath: state.projectPath,
tagName: state.release.tagName,
name,
+ releasedAt: state.release.releasedAt,
description: state.includeTagNotes
? getters.formattedReleaseNotes
: state.release.description,
@@ -163,6 +156,13 @@ export const releaseCreateMutatationVariables = (state, getters) => {
};
};
+export const releaseDeleteMutationVariables = (state) => ({
+ input: {
+ projectPath: state.projectPath,
+ tagName: state.release.tagName,
+ },
+});
+
export const formattedReleaseNotes = ({ includeTagNotes, release: { description }, tagNotes }) =>
includeTagNotes && tagNotes
? `${description}\n\n### ${s__('Releases|Tag message')}\n\n${tagNotes}\n`
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js
index daa077309a1..0ef017f4eb4 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js
@@ -6,6 +6,7 @@ export const RECEIVE_RELEASE_ERROR = 'RECEIVE_RELEASE_ERROR';
export const UPDATE_RELEASE_TAG_NAME = 'UPDATE_RELEASE_TAG_NAME';
export const UPDATE_CREATE_FROM = 'UPDATE_CREATE_FROM';
+export const UPDATE_SHOW_CREATE_FROM = 'UPDATE_SHOW_CREATE_FROM';
export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE';
export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES';
export const UPDATE_RELEASE_MILESTONES = 'UPDATE_RELEASE_MILESTONES';
@@ -26,3 +27,4 @@ export const RECEIVE_TAG_NOTES_SUCCESS = 'RECEIVE_TAG_NOTES_SUCCESS';
export const RECEIVE_TAG_NOTES_ERROR = 'RECEIVE_TAG_NOTES_ERROR';
export const UPDATE_INCLUDE_TAG_NOTES = 'UPDATE_INCLUDE_TAG_NOTES';
+export const UPDATE_RELEASED_AT = 'UPDATE_RELEASED_AT';
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
index 6b22468bbfe..ea794f91f66 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
@@ -9,11 +9,12 @@ const findReleaseLink = (release, id) => {
export default {
[types.INITIALIZE_EMPTY_RELEASE](state) {
state.release = {
- tagName: null,
+ tagName: state.tagName,
name: '',
description: '',
milestones: [],
groupMilestones: [],
+ releasedAt: new Date(),
assets: {
links: [],
},
@@ -41,6 +42,9 @@ export default {
[types.UPDATE_CREATE_FROM](state, createFrom) {
state.createFrom = createFrom;
},
+ [types.UPDATE_SHOW_CREATE_FROM](state, showCreateFrom) {
+ state.showCreateFrom = showCreateFrom;
+ },
[types.UPDATE_RELEASE_TITLE](state, title) {
state.release.name = title;
},
@@ -113,4 +117,7 @@ export default {
[types.UPDATE_INCLUDE_TAG_NOTES](state, includeTagNotes) {
state.includeTagNotes = includeTagNotes;
},
+ [types.UPDATE_RELEASED_AT](state, releasedAt) {
+ state.release.releasedAt = releasedAt;
+ },
};
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/state.js b/app/assets/javascripts/releases/stores/modules/edit_new/state.js
index 33cb3ee06d0..cb447cf9aaf 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/state.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/state.js
@@ -1,4 +1,5 @@
export default ({
+ isExistingRelease,
projectId,
groupId,
groupMilestonesAvailable = false,
@@ -10,10 +11,13 @@ export default ({
newMilestonePath,
releasesPagePath,
editReleaseDocsPath,
+ upcomingReleaseDocsPath,
+ deleteReleaseDocsPath = '',
tagName = null,
defaultBranch = null,
}) => ({
+ isExistingRelease,
projectId,
groupId,
groupMilestonesAvailable: Boolean(groupMilestonesAvailable),
@@ -25,12 +29,15 @@ export default ({
newMilestonePath,
releasesPagePath,
editReleaseDocsPath,
+ upcomingReleaseDocsPath,
+ deleteReleaseDocsPath,
/**
* The name of the tag associated with the release, provided by the backend.
- * When creating a new release, this value is null.
+ * When creating a new release, this is the default from the URL
*/
tagName,
+ showCreateFrom: !tagName,
defaultBranch,
createFrom: defaultBranch,
diff --git a/app/assets/javascripts/releases/util.js b/app/assets/javascripts/releases/util.js
index 22d5fb4f620..f1f5f4bca4c 100644
--- a/app/assets/javascripts/releases/util.js
+++ b/app/assets/javascripts/releases/util.js
@@ -11,10 +11,13 @@ const convertScalarProperties = (graphQLRelease) =>
'tagPath',
'description',
'descriptionHtml',
- 'releasedAt',
'upcomingRelease',
]);
+const convertDateProperties = ({ releasedAt }) => ({
+ releasedAt: new Date(releasedAt),
+});
+
const convertAssets = (graphQLRelease) => {
let sources = [];
if (graphQLRelease.assets.sources?.nodes) {
@@ -88,6 +91,7 @@ const convertMilestones = (graphQLRelease) => ({
*/
export const convertGraphQLRelease = (graphQLRelease) => ({
...convertScalarProperties(graphQLRelease),
+ ...convertDateProperties(graphQLRelease),
...convertAssets(graphQLRelease),
...convertEvidences(graphQLRelease),
...convertLinks(graphQLRelease),
@@ -129,3 +133,5 @@ export const convertOneReleaseGraphQLResponse = (response) => {
return { data: release };
};
+
+export const deleteReleaseSessionKey = (projectPath) => `deleteRelease:${projectPath}`;
diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/reports/components/summary_row.vue
index 92d0783749e..ee55368c829 100644
--- a/app/assets/javascripts/reports/components/summary_row.vue
+++ b/app/assets/javascripts/reports/components/summary_row.vue
@@ -84,7 +84,7 @@ export default {
</div>
</div>
<div
- v-if="$slots.default"
+ v-if="$slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */"
class="text-right flex-fill d-flex justify-content-end flex-column flex-sm-row"
>
<slot></slot>
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index 280455c3fed..bf4f19504f0 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -97,6 +97,7 @@ export default {
project: DEFAULT_BLOB_INFO.project,
gitpodEnabled: DEFAULT_BLOB_INFO.gitpodEnabled,
currentUser: DEFAULT_BLOB_INFO.currentUser,
+ useFallback: false,
};
},
computed: {
@@ -130,7 +131,7 @@ export default {
},
shouldLoadLegacyViewer() {
const isTextFile = this.viewer.fileType === TEXT_FILE_TYPE && !this.glFeatures.highlightJs;
- return isTextFile || LEGACY_FILE_TYPES.includes(this.blobInfo.fileType);
+ return isTextFile || LEGACY_FILE_TYPES.includes(this.blobInfo.fileType) || this.useFallback;
},
legacyViewerLoaded() {
return (
@@ -173,6 +174,10 @@ export default {
},
},
methods: {
+ onError() {
+ this.useFallback = true;
+ this.loadLegacyViewer();
+ },
loadLegacyViewer() {
if (this.legacyViewerLoaded) {
return;
@@ -303,7 +308,7 @@ export default {
:loading="isLoadingLegacyViewer"
:data-loading="isRenderingLegacyTextViewer"
/>
- <component :is="blobViewer" v-else :blob="blobInfo" class="blob-viewer" />
+ <component :is="blobViewer" v-else :blob="blobInfo" class="blob-viewer" @error="onError" />
<code-intelligence
v-if="blobViewer || legacyViewerLoaded"
:code-navigation-path="blobInfo.codeNavigationPath"
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index d24d7648f1b..9f2cf8505d3 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -49,10 +49,11 @@ export default {
};
},
update: (data) => {
- const pipelines = data.project?.repository?.tree?.lastCommit?.pipelines?.edges;
+ const lastCommit = data.project?.repository?.paginatedTree?.nodes[0]?.lastCommit;
+ const pipelines = lastCommit?.pipelines?.edges;
return {
- ...data.project?.repository?.tree?.lastCommit,
+ ...lastCommit,
pipeline: pipelines?.length && pipelines[0].node,
};
},
@@ -131,7 +132,9 @@ export default {
:css-classes="'gl-mr-0!' /* NOTE: this is needed only while we migrate user-avatar-image to GlAvatar (7731 epics) */"
:size="32"
/>
- <div class="commit-detail flex-list">
+ <div
+ class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-flex-grow-1 gl-min-w-0"
+ >
<div class="commit-content qa-commit-content">
<gl-link
v-safe-html:[$options.safeHtmlConfig]="commit.titleHtml"
diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue
index 41f7a4b147f..1f6b5e98122 100644
--- a/app/assets/javascripts/repository/components/table/index.vue
+++ b/app/assets/javascripts/repository/components/table/index.vue
@@ -103,14 +103,12 @@ export default {
return this.rowNumbers[key];
},
- getCommit(fileName, type) {
+ getCommit(fileName) {
if (!this.glFeatures.lazyLoadCommits) {
return {};
}
- return this.commits.find(
- (commitEntry) => commitEntry.fileName === fileName && commitEntry.type === type,
- );
+ return this.commits.find((commitEntry) => commitEntry.fileName === fileName);
},
},
};
@@ -152,7 +150,7 @@ export default {
:loading-path="loadingPath"
:total-entries="totalEntries"
:row-number="generateRowNumber(entry.flatPath, entry.id, index)"
- :commit-info="getCommit(entry.name, entry.type)"
+ :commit-info="getCommit(entry.name)"
v-on="$listeners"
/>
</template>
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index 2b910109f7d..99b7395d6e7 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -43,7 +43,6 @@ export default {
variables() {
return {
fileName: this.name,
- type: this.type,
path: this.currentPath,
projectPath: this.projectPath,
maxOffset: this.totalEntries,
@@ -135,14 +134,11 @@ export default {
commitData() {
return this.glFeatures.lazyLoadCommits ? this.commitInfo : this.commit;
},
- refactorBlobViewerEnabled() {
- return this.glFeatures.refactorBlobViewer;
- },
routerLinkTo() {
const blobRouteConfig = { path: `/-/blob/${this.escapedRef}/${escapeFileUrl(this.path)}` };
const treeRouteConfig = { path: `/-/tree/${this.escapedRef}/${escapeFileUrl(this.path)}` };
- if (this.refactorBlobViewerEnabled && this.isBlob) {
+ if (this.isBlob) {
return blobRouteConfig;
}
@@ -158,7 +154,7 @@ export default {
return this.type === 'commit';
},
linkComponent() {
- return this.isFolder || (this.refactorBlobViewerEnabled && this.isBlob) ? 'router-link' : 'a';
+ return this.isFolder || this.isBlob ? 'router-link' : 'a';
},
fullPath() {
return this.path.replace(new RegExp(`^${escapeRegExp(this.currentPath)}/`), '');
@@ -187,10 +183,6 @@ export default {
});
},
loadBlob() {
- if (!this.refactorBlobViewerEnabled) {
- return;
- }
-
this.apolloQuery(blobInfoQuery, {
projectPath: this.projectPath,
filePath: this.path,
diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js
index 2cafeed2ef4..0e80f306638 100644
--- a/app/assets/javascripts/repository/constants.js
+++ b/app/assets/javascripts/repository/constants.js
@@ -93,7 +93,6 @@ export const LFS_STORAGE = 'lfs';
* These are file types that we want the legacy (backend) syntax highlighter to highlight.
*/
export const LEGACY_FILE_TYPES = [
- 'package_json',
'gemfile',
'gemspec',
'composer_json',
diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js
index 29aabe1b00f..3a59a02af01 100644
--- a/app/assets/javascripts/repository/graphql.js
+++ b/app/assets/javascripts/repository/graphql.js
@@ -9,7 +9,7 @@ Vue.use(VueApollo);
const defaultClient = createDefaultClient(
{
Query: {
- commit(_, { path, fileName, type, maxOffset }) {
+ commit(_, { path, fileName, maxOffset }) {
return new Promise((resolve) => {
fetchLogsTree(
defaultClient,
@@ -19,7 +19,6 @@ const defaultClient = createDefaultClient(
resolve,
entry: {
name: fileName,
- type,
},
},
maxOffset,
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 8f8735a6371..1d295e18332 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -91,9 +91,7 @@ export default function setupVueRepositoryList() {
initLastCommitApp();
- if (gon.features.refactorBlobViewer) {
- initBlobControlsApp();
- }
+ initBlobControlsApp();
router.afterEach(({ params: { path } }) => {
setTitle(path, ref, fullName);
diff --git a/app/assets/javascripts/repository/log_tree.js b/app/assets/javascripts/repository/log_tree.js
index ac02392d60f..9345a8406e3 100644
--- a/app/assets/javascripts/repository/log_tree.js
+++ b/app/assets/javascripts/repository/log_tree.js
@@ -16,9 +16,7 @@ function setNextOffset(offset) {
}
export function resolveCommit(commits, path, { resolve, entry }) {
- const commit = commits.find(
- (c) => c.filePath === `${path}/${entry.name}` && c.type === entry.type,
- );
+ const commit = commits.find((c) => c.filePath === `${path}/${entry.name}`);
if (commit) {
resolve(commit);
diff --git a/app/assets/javascripts/repository/queries/commit.fragment.graphql b/app/assets/javascripts/repository/queries/commit.fragment.graphql
index b046fc1f730..80dedfe3e3f 100644
--- a/app/assets/javascripts/repository/queries/commit.fragment.graphql
+++ b/app/assets/javascripts/repository/queries/commit.fragment.graphql
@@ -6,5 +6,4 @@ fragment TreeEntryCommit on LogTreeCommit {
commitPath
fileName
filePath
- type
}
diff --git a/app/assets/javascripts/repository/queries/commit.query.graphql b/app/assets/javascripts/repository/queries/commit.query.graphql
index 7ae4a3b984a..1a01462bd19 100644
--- a/app/assets/javascripts/repository/queries/commit.query.graphql
+++ b/app/assets/javascripts/repository/queries/commit.query.graphql
@@ -1,7 +1,7 @@
#import "ee_else_ce/repository/queries/commit.fragment.graphql"
-query getCommit($fileName: String!, $type: String!, $path: String!, $maxOffset: Number!) {
- commit(path: $path, fileName: $fileName, type: $type, maxOffset: $maxOffset) @client {
+query getCommit($fileName: String!, $path: String!, $maxOffset: Number!) {
+ commit(path: $path, fileName: $fileName, maxOffset: $maxOffset) @client {
...TreeEntryCommit
}
}
diff --git a/app/assets/javascripts/repository/utils/commit.js b/app/assets/javascripts/repository/utils/commit.js
index a67252ec004..878b4fdd71a 100644
--- a/app/assets/javascripts/repository/utils/commit.js
+++ b/app/assets/javascripts/repository/utils/commit.js
@@ -7,7 +7,6 @@ export function normalizeData(data, path, extra = () => {}) {
commitPath: d.commit_path,
fileName: d.file_name,
filePath: `${path}/${d.file_name}`,
- type: d.type,
__typename: 'LogTreeCommit',
...extra(d),
}));
diff --git a/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue
index 06a8eb790fc..9fa4b521ebc 100644
--- a/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue
+++ b/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue
@@ -1,5 +1,5 @@
<script>
-import { GlBadge, GlTab, GlTooltipDirective } from '@gitlab/ui';
+import { GlBadge, GlTabs, GlTab, GlTooltipDirective } from '@gitlab/ui';
import { createAlert, VARIANT_SUCCESS } from '~/flash';
import { TYPE_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
@@ -11,7 +11,7 @@ import RunnerPauseButton from '../components/runner_pause_button.vue';
import RunnerHeader from '../components/runner_header.vue';
import RunnerDetails from '../components/runner_details.vue';
import RunnerJobs from '../components/runner_jobs.vue';
-import { I18N_FETCH_ERROR } from '../constants';
+import { I18N_DETAILS, I18N_FETCH_ERROR } from '../constants';
import runnerQuery from '../graphql/show/runner.query.graphql';
import { captureException } from '../sentry_utils';
import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage';
@@ -20,6 +20,7 @@ export default {
name: 'AdminRunnerShowApp',
components: {
GlBadge,
+ GlTabs,
GlTab,
RunnerDeleteButton,
RunnerEditButton,
@@ -84,6 +85,7 @@ export default {
redirectTo(this.runnersPath);
},
},
+ I18N_DETAILS,
};
</script>
<template>
@@ -96,24 +98,27 @@ export default {
</template>
</runner-header>
- <runner-details :runner="runner">
- <template #jobs-tab>
- <gl-tab>
- <template #title>
- {{ s__('Runners|Jobs') }}
- <gl-badge
- v-if="jobCount"
- data-testid="job-count-badge"
- class="gl-tab-counter-badge"
- size="sm"
- >
- {{ jobCount }}
- </gl-badge>
- </template>
+ <gl-tabs>
+ <gl-tab>
+ <template #title>{{ $options.I18N_DETAILS }}</template>
- <runner-jobs v-if="runner" :runner="runner" />
- </gl-tab>
- </template>
- </runner-details>
+ <runner-details v-if="runner" :runner="runner" />
+ </gl-tab>
+ <gl-tab>
+ <template #title>
+ {{ s__('Runners|Jobs') }}
+ <gl-badge
+ v-if="jobCount"
+ data-testid="job-count-badge"
+ class="gl-tab-counter-badge"
+ size="sm"
+ >
+ {{ jobCount }}
+ </gl-badge>
+ </template>
+
+ <runner-jobs v-if="runner" :runner="runner" />
+ </gl-tab>
+ </gl-tabs>
</div>
</template>
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 a90ef2d3530..f6b7a8b46d7 100644
--- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
@@ -1,10 +1,17 @@
<script>
-import { GlBadge, GlLink } from '@gitlab/ui';
+import { GlLink } from '@gitlab/ui';
import { createAlert } from '~/flash';
import { updateHistory } from '~/lib/utils/url_utility';
-import { formatNumber } from '~/locale';
import { fetchPolicies } from '~/lib/graphql';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { upgradeStatusTokenConfig } from 'ee_else_ce/runner/components/search_tokens/upgrade_status_token_config';
+import {
+ fromUrlQueryToSearch,
+ fromSearchToUrl,
+ fromSearchToVariables,
+ isSearchFiltered,
+} from 'ee_else_ce/runner/runner_search_utils';
+import allRunnersQuery from 'ee_else_ce/runner/graphql/list/all_runners.query.graphql';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
@@ -20,74 +27,12 @@ import RunnerActionsCell from '../components/cells/runner_actions_cell.vue';
import { pausedTokenConfig } from '../components/search_tokens/paused_token_config';
import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import { tagTokenConfig } from '../components/search_tokens/tag_token_config';
-import {
- ADMIN_FILTERED_SEARCH_NAMESPACE,
- INSTANCE_TYPE,
- GROUP_TYPE,
- PROJECT_TYPE,
- STATUS_ONLINE,
- STATUS_OFFLINE,
- STATUS_STALE,
- I18N_FETCH_ERROR,
-} from '../constants';
-import runnersAdminQuery from '../graphql/list/admin_runners.query.graphql';
-import runnersAdminCountQuery from '../graphql/list/admin_runners_count.query.graphql';
-import {
- fromUrlQueryToSearch,
- fromSearchToUrl,
- fromSearchToVariables,
- isSearchFiltered,
-} from '../runner_search_utils';
+import { ADMIN_FILTERED_SEARCH_NAMESPACE, INSTANCE_TYPE, I18N_FETCH_ERROR } from '../constants';
import { captureException } from '../sentry_utils';
-const countSmartQuery = () => ({
- query: runnersAdminCountQuery,
- fetchPolicy: fetchPolicies.NETWORK_ONLY,
- update(data) {
- return data?.runners?.count;
- },
- error(error) {
- this.reportToSentry(error);
- },
-});
-
-const tabCountSmartQuery = ({ type }) => {
- return {
- ...countSmartQuery(),
- variables() {
- return {
- ...this.countVariables,
- type,
- };
- },
- };
-};
-
-const statusCountSmartQuery = ({ status, name }) => {
- return {
- ...countSmartQuery(),
- skip() {
- // skip if filtering by status and not using _this_ status as filter
- if (this.countVariables.status && this.countVariables.status !== status) {
- // reset count for given status
- this[name] = null;
- return true;
- }
- return false;
- },
- variables() {
- return {
- ...this.countVariables,
- status,
- };
- },
- };
-};
-
export default {
name: 'AdminRunnersApp',
components: {
- GlBadge,
GlLink,
RegistrationDropdown,
RunnerFilteredSearchBar,
@@ -119,7 +64,7 @@ export default {
},
apollo: {
runners: {
- query: runnersAdminQuery,
+ query: allRunnersQuery,
fetchPolicy: fetchPolicies.NETWORK_ONLY,
variables() {
return this.variables;
@@ -137,31 +82,6 @@ export default {
this.reportToSentry(error);
},
},
-
- // Tabs counts
- allRunnersCount: {
- ...tabCountSmartQuery({ type: null }),
- },
- instanceRunnersCount: {
- ...tabCountSmartQuery({ type: INSTANCE_TYPE }),
- },
- groupRunnersCount: {
- ...tabCountSmartQuery({ type: GROUP_TYPE }),
- },
- projectRunnersCount: {
- ...tabCountSmartQuery({ type: PROJECT_TYPE }),
- },
-
- // Runner stats
- onlineRunnersTotal: {
- ...statusCountSmartQuery({ status: STATUS_ONLINE, name: 'onlineRunnersTotal' }),
- },
- offlineRunnersTotal: {
- ...statusCountSmartQuery({ status: STATUS_OFFLINE, name: 'offlineRunnersTotal' }),
- },
- staleRunnersTotal: {
- ...statusCountSmartQuery({ status: STATUS_STALE, name: 'staleRunnersTotal' }),
- },
},
computed: {
variables() {
@@ -186,6 +106,7 @@ export default {
...tagTokenConfig,
recentSuggestionsStorageKey: `${this.$options.filteredSearchNamespace}-recent-tags`,
},
+ upgradeStatusTokenConfig,
];
},
isBulkDeleteEnabled() {
@@ -214,39 +135,10 @@ export default {
this.reportToSentry(error);
},
methods: {
- tabCount({ runnerType }) {
- let count;
- switch (runnerType) {
- case null:
- count = this.allRunnersCount;
- break;
- case INSTANCE_TYPE:
- count = this.instanceRunnersCount;
- break;
- case GROUP_TYPE:
- count = this.groupRunnersCount;
- break;
- case PROJECT_TYPE:
- count = this.projectRunnersCount;
- break;
- default:
- return null;
- }
- if (typeof count === 'number') {
- return formatNumber(count);
- }
- return '';
- },
- refetchFilteredCounts() {
- this.$apollo.queries.allRunnersCount.refetch();
- this.$apollo.queries.instanceRunnersCount.refetch();
- this.$apollo.queries.groupRunnersCount.refetch();
- this.$apollo.queries.projectRunnersCount.refetch();
- },
onToggledPaused() {
- // When a runner is Paused, the tab count can
+ // When a runner becomes Paused, the tab count can
// become stale, refetch outdated counts.
- this.refetchFilteredCounts();
+ this.$refs['runner-type-tabs'].refetch();
},
onDeleted({ message }) {
this.$root.$toast?.show(message);
@@ -271,18 +163,14 @@ export default {
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"
>
<runner-type-tabs
+ ref="runner-type-tabs"
v-model="search"
+ :count-scope="$options.INSTANCE_TYPE"
+ :count-variables="countVariables"
class="gl-w-full"
content-class="gl-display-none"
nav-class="gl-border-none!"
- >
- <template #title="{ tab }">
- {{ tab.title }}
- <gl-badge v-if="tabCount(tab)" class="gl-ml-1" size="sm">
- {{ tabCount(tab) }}
- </gl-badge>
- </template>
- </runner-type-tabs>
+ />
<registration-dropdown
class="gl-w-full gl-sm-w-auto gl-mr-auto"
@@ -298,11 +186,7 @@ export default {
:namespace="$options.filteredSearchNamespace"
/>
- <runner-stats
- :online-runners-count="onlineRunnersTotal"
- :offline-runners-count="offlineRunnersTotal"
- :stale-runners-count="staleRunnersTotal"
- />
+ <runner-stats :scope="$options.INSTANCE_TYPE" :variables="countVariables" />
<runner-list-empty-state
v-if="noRunnersFound"
diff --git a/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue b/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue
index 09d46ce3e66..667cb0090b3 100644
--- a/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue
+++ b/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue
@@ -8,14 +8,16 @@ import runnersRegistrationTokenResetMutation from '~/runner/graphql/list/runners
import { captureException } from '~/runner/sentry_utils';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants';
+const i18n = {
+ modalAction: s__('Runners|Reset token'),
+ modalCancel: __('Cancel'),
+ modalCopy: __('Are you sure you want to reset the registration token?'),
+ modalTitle: __('Reset registration token'),
+};
+
export default {
name: 'RunnerRegistrationTokenReset',
- i18n: {
- modalAction: s__('Runners|Reset token'),
- modalCancel: __('Cancel'),
- modalCopy: __('Are you sure you want to reset the registration token?'),
- modalTitle: __('Reset registration token'),
- },
+ i18n,
components: {
GlDropdownItem,
GlLoadingIcon,
@@ -68,6 +70,18 @@ export default {
return null;
}
},
+ actionPrimary() {
+ return {
+ text: i18n.modalAction,
+ attributes: [{ variant: 'danger' }],
+ };
+ },
+ actionSecondary() {
+ return {
+ text: i18n.modalCancel,
+ attributes: [{ variant: 'default' }],
+ };
+ },
},
methods: {
handleModalPrimary() {
@@ -115,14 +129,8 @@ export default {
<gl-modal
size="sm"
:modal-id="$options.modalId"
- :action-primary="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
- text: $options.i18n.modalAction,
- attributes: [{ variant: 'danger' }],
- } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
- :action-secondary="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
- text: $options.i18n.modalCancel,
- attributes: [{ variant: 'default' }],
- } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
+ :action-primary="actionPrimary"
+ :action-secondary="actionSecondary"
:title="$options.i18n.modalTitle"
@primary="handleModalPrimary"
>
diff --git a/app/assets/javascripts/runner/components/runner_detail.vue b/app/assets/javascripts/runner/components/runner_detail.vue
index b1234818b7e..db67acef3db 100644
--- a/app/assets/javascripts/runner/components/runner_detail.vue
+++ b/app/assets/javascripts/runner/components/runner_detail.vue
@@ -41,6 +41,7 @@ export default {
<div class="gl-display-flex gl-pb-4">
<dt class="gl-mr-2">{{ label }}</dt>
<dd class="gl-mb-0">
+ <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots -->
<template v-if="value || $slots.value">
<slot name="value">{{ value }}</slot>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_details.vue b/app/assets/javascripts/runner/components/runner_details.vue
index 75ddec6c716..60469d26dd5 100644
--- a/app/assets/javascripts/runner/components/runner_details.vue
+++ b/app/assets/javascripts/runner/components/runner_details.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTabs, GlTab, GlIntersperse } from '@gitlab/ui';
+import { GlIntersperse } from '@gitlab/ui';
import { s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
@@ -11,14 +11,16 @@ import RunnerTags from './runner_tags.vue';
export default {
components: {
- GlTabs,
- GlTab,
GlIntersperse,
RunnerDetail,
RunnerMaintenanceNoteDetail: () =>
import('ee_component/runner/components/runner_maintenance_note_detail.vue'),
RunnerGroups,
RunnerProjects,
+ RunnerUpgradeStatusBadge: () =>
+ import('ee_component/runner/components/runner_upgrade_status_badge.vue'),
+ RunnerUpgradeStatusAlert: () =>
+ import('ee_component/runner/components/runner_upgrade_status_alert.vue'),
RunnerTags,
TimeAgo,
},
@@ -61,58 +63,57 @@ export default {
</script>
<template>
- <gl-tabs>
- <gl-tab>
- <template #title>{{ s__('Runners|Details') }}</template>
-
- <template v-if="runner">
- <div class="gl-pt-4">
- <dl class="gl-mb-0" data-testid="runner-details-list">
- <runner-detail :label="s__('Runners|Description')" :value="runner.description" />
- <runner-detail
- :label="s__('Runners|Last contact')"
- :empty-value="s__('Runners|Never contacted')"
- >
- <template #value>
- <time-ago v-if="runner.contactedAt" :time="runner.contactedAt" />
- </template>
- </runner-detail>
- <runner-detail :label="s__('Runners|Version')" :value="runner.version" />
- <runner-detail :label="s__('Runners|IP Address')" :value="runner.ipAddress" />
- <runner-detail :label="s__('Runners|Executor')" :value="runner.executorName" />
- <runner-detail :label="s__('Runners|Architecture')" :value="runner.architectureName" />
- <runner-detail :label="s__('Runners|Platform')" :value="runner.platformName" />
- <runner-detail :label="s__('Runners|Configuration')">
- <template #value>
- <gl-intersperse v-if="configTextProtected || configTextUntagged">
- <span v-if="configTextProtected">{{ configTextProtected }}</span>
- <span v-if="configTextUntagged">{{ configTextUntagged }}</span>
- </gl-intersperse>
- </template>
- </runner-detail>
- <runner-detail :label="s__('Runners|Maximum job timeout')" :value="maximumTimeout" />
- <runner-detail :label="s__('Runners|Tags')">
- <template #value>
- <runner-tags
- v-if="runner.tagList && runner.tagList.length"
- class="gl-vertical-align-middle"
- :tag-list="runner.tagList"
- size="sm"
- />
- </template>
- </runner-detail>
-
- <runner-maintenance-note-detail
- class="gl-pt-4 gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid"
- :value="runner.maintenanceNoteHtml"
+ <div>
+ <runner-upgrade-status-alert class="gl-my-4" :runner="runner" />
+ <div class="gl-pt-4">
+ <dl class="gl-mb-0" data-testid="runner-details-list">
+ <runner-detail :label="s__('Runners|Description')" :value="runner.description" />
+ <runner-detail
+ :label="s__('Runners|Last contact')"
+ :empty-value="s__('Runners|Never contacted')"
+ >
+ <template #value>
+ <time-ago v-if="runner.contactedAt" :time="runner.contactedAt" />
+ </template>
+ </runner-detail>
+ <runner-detail :label="s__('Runners|Version')">
+ <template v-if="runner.version" #value>
+ {{ runner.version }}
+ <runner-upgrade-status-badge size="sm" :runner="runner" />
+ </template>
+ </runner-detail>
+ <runner-detail :label="s__('Runners|IP Address')" :value="runner.ipAddress" />
+ <runner-detail :label="s__('Runners|Executor')" :value="runner.executorName" />
+ <runner-detail :label="s__('Runners|Architecture')" :value="runner.architectureName" />
+ <runner-detail :label="s__('Runners|Platform')" :value="runner.platformName" />
+ <runner-detail :label="s__('Runners|Configuration')">
+ <template #value>
+ <gl-intersperse v-if="configTextProtected || configTextUntagged">
+ <span v-if="configTextProtected">{{ configTextProtected }}</span>
+ <span v-if="configTextUntagged">{{ configTextUntagged }}</span>
+ </gl-intersperse>
+ </template>
+ </runner-detail>
+ <runner-detail :label="s__('Runners|Maximum job timeout')" :value="maximumTimeout" />
+ <runner-detail :label="s__('Runners|Tags')">
+ <template #value>
+ <runner-tags
+ v-if="runner.tagList && runner.tagList.length"
+ class="gl-vertical-align-middle"
+ :tag-list="runner.tagList"
+ size="sm"
/>
- </dl>
- </div>
+ </template>
+ </runner-detail>
+
+ <runner-maintenance-note-detail
+ class="gl-pt-4 gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid"
+ :value="runner.maintenanceNoteHtml"
+ />
+ </dl>
+ </div>
- <runner-groups v-if="isGroupRunner" :runner="runner" />
- <runner-projects v-if="isProjectRunner" :runner="runner" />
- </template>
- </gl-tab>
- <slot name="jobs-tab"></slot>
- </gl-tabs>
+ <runner-groups v-if="isGroupRunner" :runner="runner" />
+ <runner-projects v-if="isProjectRunner" :runner="runner" />
+ </div>
</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 f0f8bbdf5df..bff5ec9b238 100644
--- a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
+++ b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
@@ -45,7 +45,7 @@ export default {
},
},
data() {
- // filtered_search_bar_root.vue may mutate the inital
+ // filtered_search_bar_root.vue may mutate the initial
// filters. Use `cloneDeep` to prevent those mutations
// from affecting this component
const { filters, sort } = cloneDeep(this.value);
@@ -54,6 +54,14 @@ export default {
initialSortBy: sort,
};
},
+ computed: {
+ validTokens() {
+ // Some filters are only available in EE
+ // EE-only tokens are represented by `null` or `undefined`
+ // values when in CE
+ return this.tokens.filter(Boolean);
+ },
+ },
methods: {
onFilter(filters) {
// Apply new filters, from page 1
@@ -83,7 +91,7 @@ export default {
recent-searches-storage-key="runners-search"
:sort-options="$options.sortOptions"
:initial-filter-value="initialFilterValue"
- :tokens="tokens"
+ :tokens="validTokens"
:initial-sort-by="initialSortBy"
:search-input-placeholder="__('Search or filter results...')"
data-testid="runners-filtered-search"
diff --git a/app/assets/javascripts/runner/components/runner_type_tabs.vue b/app/assets/javascripts/runner/components/runner_type_tabs.vue
index 25ed6600dc9..6b9e3bf91ad 100644
--- a/app/assets/javascripts/runner/components/runner_type_tabs.vue
+++ b/app/assets/javascripts/runner/components/runner_type_tabs.vue
@@ -1,6 +1,7 @@
<script>
-import { GlTabs, GlTab } from '@gitlab/ui';
+import { GlBadge, GlTabs, GlTab } from '@gitlab/ui';
import { searchValidator } from '~/runner/runner_search_utils';
+import { formatNumber } from '~/locale';
import {
INSTANCE_TYPE,
GROUP_TYPE,
@@ -10,6 +11,7 @@ import {
I18N_GROUP_TYPE,
I18N_PROJECT_TYPE,
} from '../constants';
+import RunnerCount from './stat/runner_count.vue';
const I18N_TAB_TITLES = {
[INSTANCE_TYPE]: I18N_INSTANCE_TYPE,
@@ -17,10 +19,14 @@ const I18N_TAB_TITLES = {
[PROJECT_TYPE]: I18N_PROJECT_TYPE,
};
+const TAB_COUNT_REF = 'tab-count';
+
export default {
components: {
+ GlBadge,
GlTabs,
GlTab,
+ RunnerCount,
},
props: {
runnerTypes: {
@@ -33,6 +39,14 @@ export default {
required: true,
validator: searchValidator,
},
+ countScope: {
+ type: String,
+ required: true,
+ },
+ countVariables: {
+ type: Object,
+ required: true,
+ },
},
computed: {
tabs() {
@@ -62,7 +76,25 @@ export default {
isTabActive({ runnerType }) {
return runnerType === this.value.runnerType;
},
+ tabBadgeCountVariables(runnerType) {
+ return { ...this.countVariables, type: runnerType };
+ },
+ tabCount(count) {
+ if (typeof count === 'number') {
+ return formatNumber(count);
+ }
+ return '';
+ },
+
+ // Component API
+ refetch() {
+ // Refresh all of the counts here, can be called by parent component
+ this.$refs[TAB_COUNT_REF].forEach((countComponent) => {
+ countComponent.refetch();
+ });
+ },
},
+ TAB_COUNT_REF,
};
</script>
<template>
@@ -74,7 +106,17 @@ export default {
@click="onTabSelected(tab)"
>
<template #title>
- <slot name="title" :tab="tab">{{ tab.title }}</slot>
+ {{ tab.title }}
+ <runner-count
+ #default="{ count }"
+ :ref="$options.TAB_COUNT_REF"
+ :scope="countScope"
+ :variables="tabBadgeCountVariables(tab.runnerType)"
+ >
+ <gl-badge v-if="tabCount(count)" class="gl-ml-1" size="sm">
+ {{ tabCount(count) }}
+ </gl-badge>
+ </runner-count>
</template>
</gl-tab>
</gl-tabs>
diff --git a/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js b/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js
index 1bab875a8a1..c1ad5da3ab9 100644
--- a/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js
+++ b/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js
@@ -22,7 +22,7 @@ export const pausedTokenConfig = {
// contain spaces!
// see: https://gitlab.com/gitlab-org/gitlab/-/issues/344142
// see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438
- title: title.replace(' ', '\u00a0'),
+ title: title.replace(/\s/g, '\u00a0'),
})),
operators: OPERATOR_IS_ONLY,
};
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 f28bd491ea5..9e6f63d3f7c 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
@@ -30,7 +30,7 @@ export const statusTokenConfig = {
// contain spaces!
// see: https://gitlab.com/gitlab-org/gitlab/-/issues/344142
// see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438
- title: title.replace(' ', '\u00a0'),
+ title: title.replace(/\s/g, '\u00a0'),
})),
operators: OPERATOR_IS_ONLY,
};
diff --git a/app/assets/javascripts/runner/components/search_tokens/upgrade_status_token_config.js b/app/assets/javascripts/runner/components/search_tokens/upgrade_status_token_config.js
new file mode 100644
index 00000000000..17ee7073360
--- /dev/null
+++ b/app/assets/javascripts/runner/components/search_tokens/upgrade_status_token_config.js
@@ -0,0 +1,2 @@
+// Overridden in EE
+export const upgradeStatusTokenConfig = null;
diff --git a/app/assets/javascripts/runner/components/stat/runner_count.vue b/app/assets/javascripts/runner/components/stat/runner_count.vue
new file mode 100644
index 00000000000..af18b203f90
--- /dev/null
+++ b/app/assets/javascripts/runner/components/stat/runner_count.vue
@@ -0,0 +1,103 @@
+<script>
+import { fetchPolicies } from '~/lib/graphql';
+import { captureException } from '../../sentry_utils';
+import allRunnersCountQuery from '../../graphql/list/all_runners_count.query.graphql';
+import groupRunnersCountQuery from '../../graphql/list/group_runners_count.query.graphql';
+import { INSTANCE_TYPE, GROUP_TYPE } from '../../constants';
+
+/**
+ * Renderless component that wraps a "count" query for the
+ * number of runners that follow a filter criteria.
+ *
+ * Example usage:
+ *
+ * Render the count of "online" runners in the instance in a
+ * <strong/> tag.
+ *
+ * ```vue
+ * <runner-count-stat
+ * #default="{ count }"
+ * :scope="INSTANCE_TYPE"
+ * :variables="{ status: 'ONLINE' }"
+ * >
+ * <strong>{{ count }}</strong>
+ * </runner-count-stat>
+ * ```
+ *
+ * Use `:skip="true"` to prevent data from being fetched and
+ * even rendered.
+ */
+export default {
+ name: 'RunnerCount',
+ props: {
+ scope: {
+ type: String,
+ required: true,
+ validator: (val) => [INSTANCE_TYPE, GROUP_TYPE].includes(val),
+ },
+ variables: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ skip: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return { count: null };
+ },
+ apollo: {
+ count: {
+ query() {
+ if (this.scope === INSTANCE_TYPE) {
+ return allRunnersCountQuery;
+ } else if (this.scope === GROUP_TYPE) {
+ return groupRunnersCountQuery;
+ }
+ return null;
+ },
+ fetchPolicy: fetchPolicies.NETWORK_ONLY,
+ variables() {
+ return this.variables;
+ },
+ skip() {
+ if (this.skip) {
+ // Don't show data for skipped stats
+ this.count = null;
+ }
+ return this.skip;
+ },
+ update(data) {
+ if (this.scope === INSTANCE_TYPE) {
+ return data?.runners?.count;
+ } else if (this.scope === GROUP_TYPE) {
+ return data?.group?.runners?.count;
+ }
+ return null;
+ },
+ error(error) {
+ this.reportToSentry(error);
+ },
+ },
+ },
+ methods: {
+ reportToSentry(error) {
+ captureException({ error, component: this.$options.name });
+ },
+
+ // Component API
+ refetch() {
+ // Parent components can use this method to refresh the count
+ this.$apollo.queries.count.refetch();
+ },
+ },
+ render() {
+ return this.$scopedSlots.default({
+ count: this.count,
+ });
+ },
+};
+</script>
diff --git a/app/assets/javascripts/runner/components/stat/runner_stats.vue b/app/assets/javascripts/runner/components/stat/runner_stats.vue
index d3693ee593e..9e1ca9ba4ee 100644
--- a/app/assets/javascripts/runner/components/stat/runner_stats.vue
+++ b/app/assets/javascripts/runner/components/stat/runner_stats.vue
@@ -1,49 +1,47 @@
<script>
import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '../../constants';
+import RunnerCount from './runner_count.vue';
import RunnerStatusStat from './runner_status_stat.vue';
export default {
components: {
+ RunnerCount,
RunnerStatusStat,
},
props: {
- onlineRunnersCount: {
- type: Number,
- required: false,
- default: null,
+ scope: {
+ type: String,
+ required: true,
},
- offlineRunnersCount: {
- type: Number,
+ variables: {
+ type: Object,
required: false,
- default: null,
+ default: () => {},
},
- staleRunnersCount: {
- type: Number,
- required: false,
- default: null,
+ },
+ methods: {
+ countVariables(vars) {
+ return { ...this.variables, ...vars };
+ },
+ statusCountSkip(status) {
+ // Show an empty result when we already filter by another status
+ return this.variables.status && this.variables.status !== status;
},
},
- STATUS_ONLINE,
- STATUS_OFFLINE,
- STATUS_STALE,
+ STATUS_LIST: [STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE],
};
</script>
<template>
<div class="gl-display-flex gl-py-6">
- <runner-status-stat
- class="gl-px-5"
- :status="$options.STATUS_ONLINE"
- :value="onlineRunnersCount"
- />
- <runner-status-stat
- class="gl-px-5"
- :status="$options.STATUS_OFFLINE"
- :value="offlineRunnersCount"
- />
- <runner-status-stat
- class="gl-px-5"
- :status="$options.STATUS_STALE"
- :value="staleRunnersCount"
- />
+ <runner-count
+ v-for="status in $options.STATUS_LIST"
+ #default="{ count }"
+ :key="status"
+ :scope="scope"
+ :variables="countVariables({ status })"
+ :skip="statusCountSkip(status)"
+ >
+ <runner-status-stat class="gl-px-5" :status="status" :value="count" />
+ </runner-count>
</div>
</template>
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js
index b9621c26b59..64541729701 100644
--- a/app/assets/javascripts/runner/constants.js
+++ b/app/assets/javascripts/runner/constants.js
@@ -81,6 +81,7 @@ export const I18N_LOCKED_RUNNER_DESCRIPTION = s__(
// Runner details
+export const I18N_DETAILS = s__('Runners|Details');
export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})');
export const I18N_NONE = __('None');
export const I18N_NO_JOBS_FOUND = s__('Runners|This runner has not run any jobs.');
diff --git a/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/all_runners.query.graphql
index 61bfe03bf6e..6bb896dda16 100644
--- a/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/list/all_runners.query.graphql
@@ -1,7 +1,7 @@
#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql"
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
-query getRunners(
+query getAllRunners(
$before: String
$after: String
$first: Int
diff --git a/app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql b/app/assets/javascripts/runner/graphql/list/all_runners_count.query.graphql
index 1dd258a3524..82591b88d3e 100644
--- a/app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql
+++ b/app/assets/javascripts/runner/graphql/list/all_runners_count.query.graphql
@@ -1,4 +1,4 @@
-query getRunnersCount(
+query getAllRunnersCount(
$paused: Boolean
$status: CiRunnerStatus
$type: CiRunnerType
diff --git a/app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue b/app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue
index c336e091fdf..75138b1bd81 100644
--- a/app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue
+++ b/app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue
@@ -1,16 +1,13 @@
<script>
-import { GlBadge, GlTab, GlTooltipDirective } from '@gitlab/ui';
import { createAlert, VARIANT_SUCCESS } from '~/flash';
import { TYPE_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { redirectTo } from '~/lib/utils/url_utility';
-import { formatJobCount } from '../utils';
import RunnerDeleteButton from '../components/runner_delete_button.vue';
import RunnerEditButton from '../components/runner_edit_button.vue';
import RunnerPauseButton from '../components/runner_pause_button.vue';
import RunnerHeader from '../components/runner_header.vue';
import RunnerDetails from '../components/runner_details.vue';
-import RunnerJobs from '../components/runner_jobs.vue';
import { I18N_FETCH_ERROR } from '../constants';
import runnerQuery from '../graphql/show/runner.query.graphql';
import { captureException } from '../sentry_utils';
@@ -19,17 +16,11 @@ import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_lo
export default {
name: 'GroupRunnerShowApp',
components: {
- GlBadge,
- GlTab,
RunnerDeleteButton,
RunnerEditButton,
RunnerPauseButton,
RunnerHeader,
RunnerDetails,
- RunnerJobs,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
},
props: {
runnerId: {
@@ -40,6 +31,11 @@ export default {
type: String,
required: true,
},
+ editGroupRunnerPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -68,9 +64,6 @@ export default {
canDelete() {
return this.runner.userPermissions?.deleteRunner;
},
- jobCount() {
- return formatJobCount(this.runner?.jobCount);
- },
},
errorCaptured(error) {
this.reportToSentry(error);
@@ -90,25 +83,12 @@ export default {
<div>
<runner-header v-if="runner" :runner="runner">
<template #actions>
- <runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" />
+ <runner-edit-button v-if="canUpdate && editGroupRunnerPath" :href="editGroupRunnerPath" />
<runner-pause-button v-if="canUpdate" :runner="runner" />
<runner-delete-button v-if="canDelete" :runner="runner" @deleted="onDeleted" />
</template>
</runner-header>
- <runner-details :runner="runner">
- <template #jobs-tab>
- <gl-tab>
- <template #title>
- {{ s__('Runners|Jobs') }}
- <gl-badge v-if="jobCount" data-testid="job-count-badge" class="gl-ml-1" size="sm">
- {{ jobCount }}
- </gl-badge>
- </template>
-
- <runner-jobs v-if="runner" :runner="runner" />
- </gl-tab>
- </template>
- </runner-details>
+ <runner-details v-if="runner" :runner="runner" />
</div>
</template>
diff --git a/app/assets/javascripts/runner/group_runner_show/index.js b/app/assets/javascripts/runner/group_runner_show/index.js
index d1b87c8e427..62a0dab9211 100644
--- a/app/assets/javascripts/runner/group_runner_show/index.js
+++ b/app/assets/javascripts/runner/group_runner_show/index.js
@@ -1,21 +1,18 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage';
import GroupRunnerShowApp from './group_runner_show_app.vue';
Vue.use(VueApollo);
-export const initAdminRunnerShow = (selector = '#js-group-runner-show') => {
- showAlertFromLocalStorage();
-
+export const initGroupRunnerShow = (selector = '#js-group-runner-show') => {
const el = document.querySelector(selector);
if (!el) {
return null;
}
- const { runnerId, runnersPath } = el.dataset;
+ const { runnerId, runnersPath, editGroupRunnerPath } = el.dataset;
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
@@ -29,6 +26,7 @@ export const initAdminRunnerShow = (selector = '#js-group-runner-show') => {
props: {
runnerId,
runnersPath,
+ editGroupRunnerPath,
},
});
},
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 641b3a8f560..e8446dbe345 100644
--- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
@@ -1,8 +1,7 @@
<script>
-import { GlBadge, GlLink } from '@gitlab/ui';
+import { GlLink } from '@gitlab/ui';
import { createAlert } from '~/flash';
import { updateHistory } from '~/lib/utils/url_utility';
-import { formatNumber } from '~/locale';
import { fetchPolicies } from '~/lib/graphql';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
@@ -21,13 +20,9 @@ import {
GROUP_FILTERED_SEARCH_NAMESPACE,
GROUP_TYPE,
PROJECT_TYPE,
- STATUS_ONLINE,
- STATUS_OFFLINE,
- STATUS_STALE,
I18N_FETCH_ERROR,
} from '../constants';
import groupRunnersQuery from '../graphql/list/group_runners.query.graphql';
-import groupRunnersCountQuery from '../graphql/list/group_runners_count.query.graphql';
import {
fromUrlQueryToSearch,
fromSearchToUrl,
@@ -36,54 +31,9 @@ import {
} from '../runner_search_utils';
import { captureException } from '../sentry_utils';
-const countSmartQuery = () => ({
- query: groupRunnersCountQuery,
- fetchPolicy: fetchPolicies.NETWORK_ONLY,
- update(data) {
- return data?.group?.runners?.count;
- },
- error(error) {
- this.reportToSentry(error);
- },
-});
-
-const tabCountSmartQuery = ({ type }) => {
- return {
- ...countSmartQuery(),
- variables() {
- return {
- ...this.countVariables,
- type,
- };
- },
- };
-};
-
-const statusCountSmartQuery = ({ status, name }) => {
- return {
- ...countSmartQuery(),
- skip() {
- // skip if filtering by status and not using _this_ status as filter
- if (this.countVariables.status && this.countVariables.status !== status) {
- // reset count for given status
- this[name] = null;
- return true;
- }
- return false;
- },
- variables() {
- return {
- ...this.countVariables,
- status,
- };
- },
- };
-};
-
export default {
name: 'GroupRunnersApp',
components: {
- GlBadge,
GlLink,
RegistrationDropdown,
RunnerFilteredSearchBar,
@@ -153,28 +103,6 @@ export default {
this.reportToSentry(error);
},
},
-
- // Tabs counts
- allRunnersCount: {
- ...tabCountSmartQuery({ type: null }),
- },
- groupRunnersCount: {
- ...tabCountSmartQuery({ type: GROUP_TYPE }),
- },
- projectRunnersCount: {
- ...tabCountSmartQuery({ type: PROJECT_TYPE }),
- },
-
- // Runner status summary
- onlineRunnersTotal: {
- ...statusCountSmartQuery({ status: STATUS_ONLINE, name: 'onlineRunnersTotal' }),
- },
- offlineRunnersTotal: {
- ...statusCountSmartQuery({ status: STATUS_OFFLINE, name: 'offlineRunnersTotal' }),
- },
- staleRunnersTotal: {
- ...statusCountSmartQuery({ status: STATUS_STALE, name: 'staleRunnersTotal' }),
- },
},
computed: {
variables() {
@@ -221,41 +149,16 @@ export default {
this.reportToSentry(error);
},
methods: {
- tabCount({ runnerType }) {
- let count;
- switch (runnerType) {
- case null:
- count = this.allRunnersCount;
- break;
- case GROUP_TYPE:
- count = this.groupRunnersCount;
- break;
- case PROJECT_TYPE:
- count = this.projectRunnersCount;
- break;
- default:
- return null;
- }
- if (typeof count === 'number') {
- return formatNumber(count);
- }
- return null;
- },
webUrl(runner) {
return this.runners.urlsById[runner.id]?.web;
},
editUrl(runner) {
return this.runners.urlsById[runner.id]?.edit;
},
- refetchFilteredCounts() {
- this.$apollo.queries.allRunnersCount.refetch();
- this.$apollo.queries.groupRunnersCount.refetch();
- this.$apollo.queries.projectRunnersCount.refetch();
- },
onToggledPaused() {
- // When a runner is Paused, the tab count can
+ // When a runner becomes Paused, the tab count can
// become stale, refetch outdated counts.
- this.refetchFilteredCounts();
+ this.$refs['runner-type-tabs'].refetch();
},
onDeleted({ message }) {
this.$root.$toast?.show(message);
@@ -273,18 +176,15 @@ export default {
<div>
<div class="gl-display-flex gl-align-items-center">
<runner-type-tabs
+ ref="runner-type-tabs"
v-model="search"
+ :count-scope="$options.GROUP_TYPE"
+ :count-variables="countVariables"
:runner-types="$options.TABS_RUNNER_TYPES"
+ class="gl-w-full"
content-class="gl-display-none"
nav-class="gl-border-none!"
- >
- <template #title="{ tab }">
- {{ tab.title }}
- <gl-badge v-if="tabCount(tab)" class="gl-ml-1" size="sm">
- {{ tabCount(tab) }}
- </gl-badge>
- </template>
- </runner-type-tabs>
+ />
<registration-dropdown
class="gl-ml-auto"
@@ -300,11 +200,7 @@ export default {
:namespace="filteredSearchNamespace"
/>
- <runner-stats
- :online-runners-count="onlineRunnersTotal"
- :offline-runners-count="offlineRunnersTotal"
- :stale-runners-count="staleRunnersTotal"
- />
+ <runner-stats :scope="$options.GROUP_TYPE" :variables="countVariables" />
<runner-list-empty-state
v-if="noRunnersFound"
diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue
index 34910781247..ecde9235e93 100644
--- a/app/assets/javascripts/security_configuration/components/app.vue
+++ b/app/assets/javascripts/security_configuration/components/app.vue
@@ -206,6 +206,7 @@ export default {
<template #features>
<feature-card
v-for="feature in augmentedSecurityFeatures"
+ :id="feature.anchor"
:key="feature.type"
data-testid="security-testing-card"
:feature="feature"
@@ -254,7 +255,6 @@ export default {
</section-layout>
</gl-tab>
<gl-tab
- v-if="securityTrainingEnabled"
data-testid="vulnerability-management-tab"
:title="$options.i18n.vulnerabilityManagement"
query-param-value="vulnerability-management"
@@ -271,7 +271,7 @@ export default {
</p>
</template>
<template #features>
- <training-provider-list />
+ <training-provider-list :security-training-enabled="securityTrainingEnabled" />
</template>
</section-layout>
</gl-tab>
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index e4d2bd08f50..6efaf08a178 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -194,6 +194,7 @@ export const securityFeatures = [
helpPath: DAST_HELP_PATH,
configurationHelpPath: DAST_CONFIG_HELP_PATH,
type: REPORT_TYPE_DAST,
+ anchor: 'dast',
},
{
name: DEPENDENCY_SCANNING_NAME,
@@ -201,6 +202,7 @@ export const securityFeatures = [
helpPath: DEPENDENCY_SCANNING_HELP_PATH,
configurationHelpPath: DEPENDENCY_SCANNING_CONFIG_HELP_PATH,
type: REPORT_TYPE_DEPENDENCY_SCANNING,
+ anchor: 'dependency-scanning',
},
{
name: CONTAINER_SCANNING_NAME,
diff --git a/app/assets/javascripts/security_configuration/components/training_provider_list.vue b/app/assets/javascripts/security_configuration/components/training_provider_list.vue
index ef50d085ae8..0bcb2bb6720 100644
--- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue
+++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue
@@ -39,6 +39,7 @@ const i18n = {
primaryTrainingDescription: s__(
'SecurityTraining|Training from this partner takes precedence when more than one training partner is enabled.',
),
+ unavailableText: s__('SecurityConfiguration|Available with Ultimate'),
};
export default {
@@ -73,6 +74,13 @@ export default {
},
},
},
+ props: {
+ securityTrainingEnabled: {
+ type: Boolean,
+ required: true,
+ },
+ },
+
data() {
return {
errorMessage: '',
@@ -232,12 +240,13 @@ export default {
</div>
<ul v-else class="gl-list-style-none gl-m-0 gl-p-0">
<li v-for="provider in securityTrainingProviders" :key="provider.id" class="gl-mb-6">
- <gl-card>
+ <gl-card :body-class="{ 'gl-bg-gray-10': !securityTrainingEnabled }">
<div class="gl-display-flex">
<gl-toggle
:value="provider.isEnabled"
:label="__('Training mode')"
label-position="hidden"
+ :disabled="!securityTrainingEnabled"
@change="toggleProvider(provider)"
/>
<div v-if="$options.TEMP_PROVIDER_LOGOS[provider.name]" class="gl-ml-4">
@@ -249,7 +258,18 @@ export default {
></div>
</div>
<div class="gl-ml-3">
- <h3 class="gl-font-lg gl-m-0 gl-mb-2">{{ provider.name }}</h3>
+ <div class="gl-display-flex gl-justify-content-space-between">
+ <h3 class="gl-font-lg gl-m-0 gl-mb-2">
+ {{ provider.name }}
+ </h3>
+ <span
+ v-if="!securityTrainingEnabled"
+ data-testid="unavailable-text"
+ class="gl-text-gray-600"
+ >
+ {{ $options.i18n.unavailableText }}
+ </span>
+ </div>
<p>
{{ provider.description }}
<gl-link
@@ -263,7 +283,7 @@ export default {
</p>
<gl-form-radio
:checked="primaryProviderId"
- :disabled="!provider.isEnabled"
+ :disabled="!securityTrainingEnabled || !provider.isEnabled"
:value="provider.id"
@change="setPrimaryProvider(provider)"
>
diff --git a/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql b/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql
index 891e0dda312..9fdacb4ee10 100644
--- a/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql
+++ b/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql
@@ -1,7 +1,11 @@
-query getSecurityTrainingUrls($projectFullPath: ID!, $identifierExternalIds: [String!]!) {
+query getSecurityTrainingUrls(
+ $projectFullPath: ID!
+ $identifierExternalIds: [String!]!
+ $filename: String
+) {
project(fullPath: $projectFullPath) {
id
- securityTrainingUrls(identifierExternalIds: $identifierExternalIds) {
+ securityTrainingUrls(identifierExternalIds: $identifierExternalIds, filename: $filename) {
name
status
url
diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
index 2f31d8ef3fb..b14e816a674 100644
--- a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
+++ b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
@@ -136,7 +136,9 @@ export default {
<template>
<section class="settings no-animate js-self-monitoring-settings">
<div class="settings-header">
- <h4 class="js-section-header">
+ <h4
+ class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only"
+ >
{{ s__('SelfMonitoring|Self monitoring') }}
</h4>
<gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button>
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 eb0931c6fe2..579316f481c 100644
--- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
+++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
@@ -1,10 +1,13 @@
<script>
import {
+ GlButton,
GlToast,
GlModal,
GlTooltipDirective,
GlIcon,
GlFormCheckbox,
+ GlFormInput,
+ GlFormInputGroup,
GlDropdown,
GlDropdownItem,
GlSafeHtmlDirective,
@@ -38,9 +41,12 @@ const statusTimeRanges = [
export default {
components: {
+ GlButton,
GlIcon,
GlModal,
GlFormCheckbox,
+ GlFormInput,
+ GlFormInputGroup,
GlDropdown,
GlDropdownItem,
EmojiPicker: () => import('~/emoji/components/picker.vue'),
@@ -215,97 +221,80 @@ export default {
@primary="setStatus"
@secondary="removeStatus"
>
- <div>
- <input
- v-model="emoji"
- class="js-status-emoji-field"
- type="hidden"
- name="user[status][emoji]"
+ <input v-model="emoji" class="js-status-emoji-field" type="hidden" name="user[status][emoji]" />
+ <gl-form-input-group class="gl-mb-5">
+ <gl-form-input
+ ref="statusMessageField"
+ v-model="message"
+ :placeholder="s__(`SetStatusModal|What's your status?`)"
+ class="js-status-message-field"
+ name="user[status][message]"
+ @keyup="setDefaultEmoji"
+ @keyup.enter.prevent
/>
- <div ref="userStatusForm" class="form-group position-relative m-0">
- <div class="input-group gl-mb-5">
- <span class="input-group-prepend">
- <emoji-picker
- dropdown-class="gl-h-full"
- toggle-class="btn emoji-menu-toggle-button gl-px-4! gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
- boundary="viewport"
- :right="false"
- @click="setEmoji"
+ <template #prepend>
+ <emoji-picker
+ dropdown-class="gl-h-full"
+ toggle-class="btn emoji-menu-toggle-button gl-px-4! gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
+ boundary="viewport"
+ :right="false"
+ @click="setEmoji"
+ >
+ <template #button-content>
+ <span v-safe-html:[$options.safeHtmlConfig]="emojiTag"></span>
+ <span
+ v-show="noEmoji"
+ class="js-no-emoji-placeholder no-emoji-placeholder position-relative"
>
- <template #button-content>
- <span v-safe-html:[$options.safeHtmlConfig]="emojiTag"></span>
- <span
- v-show="noEmoji"
- class="js-no-emoji-placeholder no-emoji-placeholder position-relative"
- >
- <gl-icon name="slight-smile" class="award-control-icon-neutral" />
- <gl-icon name="smiley" class="award-control-icon-positive" />
- <gl-icon name="smile" class="award-control-icon-super-positive" />
- </span>
- </template>
- </emoji-picker>
- </span>
- <input
- ref="statusMessageField"
- v-model="message"
- :placeholder="s__('SetStatusModal|What\'s your status?')"
- type="text"
- class="form-control form-control input-lg js-status-message-field"
- name="user[status][message]"
- @keyup="setDefaultEmoji"
- @keyup.enter.prevent
- />
- <span v-show="isDirty" class="input-group-append">
- <button
- v-gl-tooltip.bottom
- :title="s__('SetStatusModal|Clear status')"
- :aria-label="s__('SetStatusModal|Clear status')"
- name="button"
- type="button"
- class="js-clear-user-status-button clear-user-status btn"
- @click="clearStatusInputs()"
- >
- <gl-icon name="close" />
- </button>
- </span>
- </div>
- <div class="form-group">
- <div class="gl-display-flex">
- <gl-form-checkbox
- v-model="availability"
- data-testid="user-availability-checkbox"
- class="gl-mb-0"
- >
- <span class="gl-font-weight-bold">{{ s__('SetStatusModal|Busy') }}</span>
- </gl-form-checkbox>
- </div>
- <div class="gl-display-flex">
- <span class="gl-text-gray-600 gl-ml-5">
- {{ s__('SetStatusModal|An indicator appears next to your name and avatar') }}
+ <gl-icon name="slight-smile" class="award-control-icon-neutral" />
+ <gl-icon name="smiley" class="award-control-icon-positive" />
+ <gl-icon name="smile" class="award-control-icon-super-positive" />
</span>
- </div>
- </div>
- <div class="form-group">
- <div class="gl-display-flex gl-align-items-baseline">
- <span class="gl-mr-3">{{ s__('SetStatusModal|Clear status after') }}</span>
- <gl-dropdown :text="clearStatusAfter" data-testid="clear-status-at-dropdown">
- <gl-dropdown-item
- v-for="after in $options.statusTimeRanges"
- :key="after.name"
- :data-testid="after.name"
- @click="setClearStatusAfter(after.label)"
- >{{ after.label }}</gl-dropdown-item
- >
- </gl-dropdown>
- </div>
- <div
- v-if="currentClearStatusAfter.length"
- class="gl-mt-3 gl-text-gray-400 gl-font-sm"
- data-testid="clear-status-at-message"
+ </template>
+ </emoji-picker>
+ </template>
+ <template v-if="isDirty" #append>
+ <gl-button
+ v-gl-tooltip.bottom
+ :title="s__('SetStatusModal|Clear status')"
+ :aria-label="s__('SetStatusModal|Clear status')"
+ icon="close"
+ class="js-clear-user-status-button"
+ @click="clearStatusInputs"
+ />
+ </template>
+ </gl-form-input-group>
+
+ <gl-form-checkbox
+ v-model="availability"
+ class="gl-mb-5"
+ data-testid="user-availability-checkbox"
+ >
+ {{ s__('SetStatusModal|Busy') }}
+ <template #help>
+ {{ s__('SetStatusModal|An indicator appears next to your name and avatar') }}
+ </template>
+ </gl-form-checkbox>
+
+ <div class="form-group">
+ <div class="gl-display-flex gl-align-items-baseline">
+ <span class="gl-mr-3">{{ s__('SetStatusModal|Clear status after') }}</span>
+ <gl-dropdown :text="clearStatusAfter" data-testid="clear-status-at-dropdown">
+ <gl-dropdown-item
+ v-for="after in $options.statusTimeRanges"
+ :key="after.name"
+ :data-testid="after.name"
+ @click="setClearStatusAfter(after.label)"
+ >{{ after.label }}</gl-dropdown-item
>
- {{ clearStatusAfterMessage }}
- </div>
- </div>
+ </gl-dropdown>
+ </div>
+ <div
+ v-if="currentClearStatusAfter.length"
+ class="gl-mt-3 gl-text-gray-400 gl-font-sm"
+ data-testid="clear-status-at-message"
+ >
+ {{ clearStatusAfterMessage }}
</div>
</div>
</gl-modal>
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
index c20dd3b677d..d17c8a123d5 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
@@ -72,9 +72,12 @@ export default {
},
},
computed: {
+ isMergeRequest() {
+ return this.issuableType === IssuableType.MergeRequest;
+ },
cannotMerge() {
const canMerge = this.user.mergeRequestInteraction?.canMerge || this.user.can_merge;
- return this.issuableType === IssuableType.MergeRequest && !canMerge;
+ return this.isMergeRequest && !canMerge;
},
tooltipTitle() {
const { name = '', availability = '' } = this.user;
@@ -86,6 +89,10 @@ export default {
});
},
tooltipOption() {
+ if (this.isMergeRequest) {
+ return null;
+ }
+
return {
container: 'body',
placement: this.tooltipPlacement,
@@ -96,6 +103,10 @@ export default {
return this.user.web_url || this.user.webUrl;
},
assigneeId() {
+ if (this.isMergeRequest) {
+ return null;
+ }
+
return isGid(this.user.id) ? getIdFromGraphQLId(this.user.id) : this.user.id;
},
},
@@ -105,6 +116,7 @@ export default {
<template>
<!-- must be `d-inline-block` or parent flex-basis causes width issues -->
<gl-link
+ v-gl-tooltip="tooltipOption"
:href="assigneeUrl"
:title="tooltipTitle"
:data-user-id="assigneeId"
diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
index 699d1bebea1..5f1808ff4da 100644
--- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
+++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
@@ -1,10 +1,11 @@
<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective, GlOutsideDirective as Outside } from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
import { __, sprintf } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import createFlash from '~/flash';
import eventHub from '~/sidebar/event_hub';
+import toast from '~/vue_shared/plugins/global_toast';
import editForm from './edit_form.vue';
export default {
@@ -27,6 +28,7 @@ export default {
},
directives: {
GlTooltip: GlTooltipDirective,
+ Outside,
},
mixins: [glFeatureFlagMixin()],
inject: ['fullPath'],
@@ -84,6 +86,11 @@ export default {
locked: !this.isLocked,
fullPath: this.fullPath,
})
+ .then(() => {
+ if (this.isMergeRequest) {
+ toast(this.isLocked ? __('Merge request locked.') : __('Merge request unlocked.'));
+ }
+ })
.catch(() => {
const flashMessage = __(
'Something went wrong trying to change the locked state of this %{issuableDisplayName}',
@@ -96,6 +103,9 @@ export default {
this.isLoading = false;
});
},
+ closeForm() {
+ this.isLockDialogOpen = false;
+ },
},
};
</script>
@@ -142,6 +152,7 @@ export default {
<div class="value sidebar-item-value hide-collapsed">
<edit-form
v-if="isLockDialogOpen"
+ v-outside="closeForm"
data-testid="edit-form"
:is-locked="isLocked"
:issuable-display-name="issuableDisplayName"
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
index 36a08482e69..c9b0a4ae2b3 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
@@ -68,10 +68,9 @@ export default {
<template>
<!-- must be `d-inline-block` or parent flex-basis causes width issues -->
<gl-link
+ v-gl-tooltip="tooltipOption"
:href="reviewerUrl"
:title="tooltipTitle"
- :data-user-id="user.id"
- data-placement="left"
class="gl-display-inline-block js-user-link"
>
<!-- use d-flex so that slot can be appropriately styled -->
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 1bafa845665..7662d645dd9 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
@@ -6,6 +6,7 @@ import { isLoggedIn } from '~/lib/utils/common_utils';
import { __, sprintf } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import toast from '~/vue_shared/plugins/global_toast';
import { subscribedQueries, Tracking } from '~/sidebar/constants';
const ICON_ON = 'notifications';
@@ -140,6 +141,10 @@ export default {
message: errors[0],
});
}
+
+ if (this.isMergeRequest) {
+ toast(subscribed ? __('Notifications turned on.') : __('Notifications turned off.'));
+ }
},
)
.catch(() => {
diff --git a/app/assets/javascripts/sidebar/graphql.js b/app/assets/javascripts/sidebar/graphql.js
index ff3fb4aae6b..127e3a3c610 100644
--- a/app/assets/javascripts/sidebar/graphql.js
+++ b/app/assets/javascripts/sidebar/graphql.js
@@ -2,7 +2,7 @@ import produce from 'immer';
import VueApollo from 'vue-apollo';
import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql';
import createDefaultClient from '~/lib/graphql';
-import { temporaryConfig } from '~/work_items/graphql/provider';
+import { temporaryConfig, resolvers as workItemResolvers } from '~/work_items/graphql/provider';
const resolvers = {
Mutation: {
@@ -13,6 +13,7 @@ const resolvers = {
});
cache.writeQuery({ query: getIssueStateQuery, data });
},
+ ...workItemResolvers.Mutation,
},
};
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index bb40ac14438..3f82fe5ce87 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -73,12 +73,14 @@ function mountSidebarToDoWidget() {
props: {
fullPath: projectPath,
issuableId:
- isInIssuePage() || isInDesignPage()
+ isInIssuePage() || isInIncidentPage() || isInDesignPage()
? convertToGraphQLId(TYPE_ISSUE, id)
: convertToGraphQLId(TYPE_MERGE_REQUEST, id),
issuableIid: iid,
issuableType:
- isInIssuePage() || isInDesignPage() ? IssuableType.Issue : IssuableType.MergeRequest,
+ isInIssuePage() || isInIncidentPage() || isInDesignPage()
+ ? IssuableType.Issue
+ : IssuableType.MergeRequest,
},
}),
});
diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js
index ea170203576..05268a5c89c 100644
--- a/app/assets/javascripts/sidebar/services/sidebar_service.js
+++ b/app/assets/javascripts/sidebar/services/sidebar_service.js
@@ -33,6 +33,7 @@ export default class SidebarService {
SidebarService.singleton = this;
}
+ // eslint-disable-next-line no-constructor-return
return SidebarService.singleton;
}
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index 7df901577b8..4df00903ab6 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -11,6 +11,8 @@ export default class SidebarMediator {
if (!SidebarMediator.singleton) {
this.initSingleton(options);
}
+
+ // eslint-disable-next-line no-constructor-return
return SidebarMediator.singleton;
}
diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
index ca85ee7fd94..971e2a15c68 100644
--- a/app/assets/javascripts/sidebar/stores/sidebar_store.js
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -4,6 +4,7 @@ export default class SidebarStore {
this.initSingleton(options);
}
+ // eslint-disable-next-line no-constructor-return
return SidebarStore.singleton;
}
diff --git a/app/assets/javascripts/surveys/components/satisfaction_rate.vue b/app/assets/javascripts/surveys/components/satisfaction_rate.vue
new file mode 100644
index 00000000000..d83de56169b
--- /dev/null
+++ b/app/assets/javascripts/surveys/components/satisfaction_rate.vue
@@ -0,0 +1,71 @@
+<script>
+import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ name: 'SatisfactionRate',
+ components: {
+ GlButton,
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ i18n: {
+ unhappy: s__('Surveys|Unhappy'),
+ delighted: s__('Surveys|Delighted'),
+ },
+ grades: [
+ {
+ title: s__('Surveys|Unhappy'),
+ icon: 'face-unhappy',
+ value: 1,
+ },
+ {
+ title: s__('Surveys|Sad'),
+ icon: 'slight-frown',
+ value: 2,
+ },
+ {
+ title: s__('Surveys|Neutral'),
+ icon: 'face-neutral',
+ value: 3,
+ },
+ {
+ title: s__('Surveys|Happy'),
+ icon: 'slight-smile',
+ value: 4,
+ },
+ {
+ title: s__('Surveys|Delighted'),
+ icon: 'smiley',
+ value: 5,
+ },
+ ],
+};
+</script>
+
+<template>
+ <div>
+ <ul class="gl-list-style-none gl-display-flex gl-p-0 gl-m-0 gl-justify-content-space-between">
+ <li v-for="grade in $options.grades" :key="grade.value">
+ <gl-button
+ v-gl-tooltip="grade.title"
+ class="gl-p-2!"
+ variant="default"
+ category="tertiary"
+ :aria-label="grade.title"
+ @click="$emit('rate', grade.value)"
+ >
+ <gl-icon class="gl-vertical-align-top" :name="grade.icon" :size="24" />
+ </gl-button>
+ </li>
+ </ul>
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-pt-3 gl-text-gray-500 gl-font-sm"
+ >
+ <div>{{ $options.i18n.unhappy }}</div>
+ <div>{{ $options.i18n.delighted }}</div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/surveys/merge_request_experience/app.js b/app/assets/javascripts/surveys/merge_request_experience/app.js
new file mode 100644
index 00000000000..ea5d8aef3c5
--- /dev/null
+++ b/app/assets/javascripts/surveys/merge_request_experience/app.js
@@ -0,0 +1,52 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import MergeRequestExperienceSurveyApp from '~/surveys/merge_request_experience/app.vue';
+import createDefaultClient from '~/lib/graphql';
+import Translate from '~/vue_shared/translate';
+
+Vue.use(Translate);
+Vue.use(VueApollo);
+
+export const startMrSurveyApp = () => {
+ let channel = null;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ const app = new Vue({
+ apolloProvider,
+ data() {
+ return {
+ hidden: false,
+ };
+ },
+ render(h) {
+ if (this.hidden) return null;
+ return h(MergeRequestExperienceSurveyApp, {
+ on: {
+ close: () => {
+ channel?.postMessage('close');
+ app.hidden = true;
+ },
+ rate: () => {
+ channel?.postMessage('close');
+ },
+ },
+ });
+ },
+ });
+
+ app.$mount('#js-mr-experience-survey');
+
+ if (window.BroadcastChannel) {
+ channel = new BroadcastChannel('mr_survey');
+ channel.addEventListener('message', ({ data }) => {
+ if (data === 'close') {
+ app.hidden = true;
+ channel.close();
+ channel = null;
+ }
+ });
+ }
+};
diff --git a/app/assets/javascripts/surveys/merge_request_experience/app.vue b/app/assets/javascripts/surveys/merge_request_experience/app.vue
new file mode 100644
index 00000000000..85eed6ae82a
--- /dev/null
+++ b/app/assets/javascripts/surveys/merge_request_experience/app.vue
@@ -0,0 +1,169 @@
+<script>
+import { GlButton, GlSprintf, GlSafeHtmlDirective, GlTooltipDirective } from '@gitlab/ui';
+import gitlabLogo from '@gitlab/svgs/dist/illustrations/gitlab_logo.svg';
+import { s__, __ } from '~/locale';
+import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
+import SatisfactionRate from '~/surveys/components/satisfaction_rate.vue';
+import Tracking from '~/tracking';
+
+const steps = [
+ {
+ label: 'overall',
+ question: s__('MrSurvey|Overall, how satisfied are you with merge requests?'),
+ },
+ {
+ label: 'performance',
+ question: s__(
+ 'MrSurvey|How satisfied are you with %{strongStart}speed/performance%{strongEnd} of merge requests?',
+ ),
+ },
+];
+
+export default {
+ name: 'MergeRequestExperienceSurveyApp',
+ components: {
+ UserCalloutDismisser,
+ GlSprintf,
+ GlButton,
+ SatisfactionRate,
+ },
+ directives: {
+ safeHtml: GlSafeHtmlDirective,
+ tooltip: GlTooltipDirective,
+ },
+ mixins: [Tracking.mixin()],
+ i18n: {
+ survey: s__('MrSurvey|Merge request experience survey'),
+ close: __('Close'),
+ legal: s__(
+ 'MrSurvey|By continuing, you acknowledge that responses will be used to improve GitLab and in accordance with the %{linkStart}GitLab Privacy Policy%{linkEnd}.',
+ ),
+ thanks: s__('MrSurvey|Thank you for your feedback!'),
+ },
+ gitlabLogo,
+ data() {
+ return {
+ visible: false,
+ stepIndex: 0,
+ };
+ },
+ computed: {
+ step() {
+ return steps[this.stepIndex];
+ },
+ },
+ mounted() {
+ document.addEventListener('keyup', this.handleKeyup);
+ },
+ destroyed() {
+ document.removeEventListener('keyup', this.handleKeyup);
+ },
+ methods: {
+ onQueryLoaded({ shouldShowCallout }) {
+ this.visible = shouldShowCallout;
+ if (!this.visible) this.$emit('close');
+ },
+ onRate(event) {
+ this.$emit('rate');
+ this.track('survey:mr_experience', {
+ label: this.step.label,
+ value: event,
+ });
+ this.stepIndex += 1;
+ if (!this.step) {
+ setTimeout(() => {
+ this.$emit('close');
+ }, 5000);
+ }
+ },
+ handleKeyup(e) {
+ if (e.key !== 'Escape') return;
+ this.$emit('close');
+ this.$refs.dismisser?.dismiss();
+ },
+ },
+};
+</script>
+
+<template>
+ <user-callout-dismisser
+ ref="dismisser"
+ feature-name="mr_experience_survey"
+ @queryResult.once="onQueryLoaded"
+ >
+ <template #default="{ dismiss }">
+ <aside
+ class="mr-experience-survey-wrapper gl-fixed gl-bottom-0 gl-right-0 gl-p-5"
+ :aria-label="$options.i18n.survey"
+ >
+ <transition name="survey-slide-up">
+ <div
+ v-if="visible"
+ class="mr-experience-survey-body gl-relative gl-display-flex gl-flex-direction-column gl-bg-white gl-p-5 gl-border gl-rounded-base"
+ >
+ <gl-button
+ v-tooltip="$options.i18n.close"
+ :aria-label="$options.i18n.close"
+ variant="default"
+ category="tertiary"
+ class="gl-top-4 gl-right-3 gl-absolute"
+ icon="close"
+ @click="
+ dismiss();
+ $emit('close');
+ "
+ />
+ <div
+ v-if="stepIndex === 0"
+ class="mr-experience-survey-legal gl-border-t gl-mt-5 gl-pt-3 gl-text-gray-500 gl-font-sm"
+ role="note"
+ >
+ <p class="gl-m-0">
+ <gl-sprintf :message="$options.i18n.legal">
+ <template #link="{ content }">
+ <a
+ class="gl-text-decoration-underline gl-text-gray-500"
+ href="https://about.gitlab.com/privacy/"
+ target="_blank"
+ rel="noreferrer nofollow"
+ v-text="content"
+ ></a>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+ <div class="gl-relative">
+ <div class="gl-absolute">
+ <div
+ v-safe-html="$options.gitlabLogo"
+ aria-hidden="true"
+ class="mr-experience-survey-logo"
+ ></div>
+ </div>
+ </div>
+ <section v-if="step">
+ <p id="mr_survey_question" ref="question" class="gl-m-0 gl-px-7">
+ <gl-sprintf :message="step.question">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ <satisfaction-rate
+ aria-labelledby="mr_survey_question"
+ class="gl-mt-5"
+ @rate="
+ dismiss();
+ onRate($event);
+ "
+ />
+ </section>
+ <section v-else class="gl-px-7">
+ {{ $options.i18n.thanks }}
+ </section>
+ </div>
+ </transition>
+ </aside>
+ </template>
+ </user-callout-dismisser>
+</template>
diff --git a/app/assets/javascripts/surveys/merge_request_experience/index.js b/app/assets/javascripts/surveys/merge_request_experience/index.js
new file mode 100644
index 00000000000..6073bde56c0
--- /dev/null
+++ b/app/assets/javascripts/surveys/merge_request_experience/index.js
@@ -0,0 +1,23 @@
+import { Tracker } from '~/tracking/tracker';
+
+const MR_SURVEY_WAIT_DURATION = 10000;
+
+const broadcastNotificationVisible = () => {
+ // We don't want to clutter up the UI by displaying the survey when broadcast message(s)
+ // are visible as well.
+ return Boolean(document.querySelector('.broadcast-notification-message'));
+};
+
+export const initMrExperienceSurvey = () => {
+ if (!gon.features?.mrExperienceSurvey) return;
+ if (!gon.current_user_id) return;
+ if (!Tracker.enabled()) return;
+ if (broadcastNotificationVisible()) return;
+
+ setTimeout(() => {
+ // eslint-disable-next-line promise/catch-or-return
+ import('./app').then(({ startMrSurveyApp }) => {
+ startMrSurveyApp();
+ });
+ }, MR_SURVEY_WAIT_DURATION);
+};
diff --git a/app/assets/javascripts/tabs/constants.js b/app/assets/javascripts/tabs/constants.js
index 90c9a89d652..0c227ab7afc 100644
--- a/app/assets/javascripts/tabs/constants.js
+++ b/app/assets/javascripts/tabs/constants.js
@@ -14,3 +14,6 @@ export const ATTR_ROLE = 'role';
export const ATTR_TABINDEX = 'tabindex';
export const TAB_SHOWN_EVENT = 'gl-tab-shown';
+
+export const HISTORY_TYPE_HASH = 'hash';
+export const ALLOWED_HISTORY_TYPES = [HISTORY_TYPE_HASH];
diff --git a/app/assets/javascripts/tabs/index.js b/app/assets/javascripts/tabs/index.js
index 44937e593e0..9230b7361a5 100644
--- a/app/assets/javascripts/tabs/index.js
+++ b/app/assets/javascripts/tabs/index.js
@@ -1,4 +1,5 @@
import { uniqueId } from 'lodash';
+import { historyReplaceState, NO_SCROLL_TO_HASH_CLASS } from '~/lib/utils/common_utils';
import {
ACTIVE_TAB_CLASSES,
ATTR_ROLE,
@@ -12,9 +13,11 @@ import {
KEY_CODE_RIGHT,
KEY_CODE_DOWN,
TAB_SHOWN_EVENT,
+ HISTORY_TYPE_HASH,
+ ALLOWED_HISTORY_TYPES,
} from './constants';
-export { TAB_SHOWN_EVENT };
+export { TAB_SHOWN_EVENT, HISTORY_TYPE_HASH };
/**
* The `GlTabsBehavior` class adds interactivity to tabs created by the `gl_tabs_nav` and
@@ -88,9 +91,13 @@ export class GlTabsBehavior {
/**
* Create a GlTabsBehavior instance.
*
- * @param {HTMLElement} el The element created by the Rails `gl_tabs_nav` helper.
+ * @param {HTMLElement} el - The element created by the Rails `gl_tabs_nav` helper.
+ * @param {Object} [options]
+ * @param {'hash' | null} [options.history=null] - Sets the type of routing GlTabs will use when navigating between tabs.
+ * 'hash': Updates the URL hash with the current tab ID.
+ * null: No routing mechanism will be used.
*/
- constructor(el) {
+ constructor(el, { history = null } = {}) {
if (!el) {
throw new Error('Cannot instantiate GlTabsBehavior without an element');
}
@@ -100,8 +107,11 @@ export class GlTabsBehavior {
this.tabs = this.getTabs();
this.activeTab = null;
+ this.history = ALLOWED_HISTORY_TYPES.includes(history) ? history : null;
+
this.setAccessibilityAttrs();
this.bindEvents();
+ if (this.history === HISTORY_TYPE_HASH) this.loadInitialTab();
}
setAccessibilityAttrs() {
@@ -128,6 +138,7 @@ export class GlTabsBehavior {
tab.setAttribute(ATTR_ARIA_CONTROLS, tabPanel.id);
}
+ tabPanel.classList.add(NO_SCROLL_TO_HASH_CLASS);
tabPanel.setAttribute(ATTR_ROLE, 'tabpanel');
tabPanel.setAttribute(ATTR_ARIA_LABELLEDBY, tab.id);
});
@@ -164,6 +175,11 @@ export class GlTabsBehavior {
});
}
+ loadInitialTab() {
+ const tab = this.tabList.querySelector(`a[href="${CSS.escape(window.location.hash)}"]`);
+ this.activateTab(tab || this.activeTab);
+ }
+
activatePreviousTab() {
const currentTabIndex = this.tabs.indexOf(this.activeTab);
@@ -216,6 +232,7 @@ export class GlTabsBehavior {
const tabPanel = this.getPanelForTab(tabToActivate);
tabPanel.classList.add(ACTIVE_PANEL_CLASS);
+ if (this.history === HISTORY_TYPE_HASH) historyReplaceState(tabToActivate.getAttribute('href'));
this.activeTab = tabToActivate;
this.dispatchTabShown(tabToActivate, tabPanel);
diff --git a/app/assets/javascripts/terms/components/app.vue b/app/assets/javascripts/terms/components/app.vue
index aedf5b6acfe..a54a198faed 100644
--- a/app/assets/javascripts/terms/components/app.vue
+++ b/app/assets/javascripts/terms/components/app.vue
@@ -7,6 +7,7 @@ import { isLoggedIn } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import csrf from '~/lib/utils/csrf';
import '~/behaviors/markdown/render_gfm';
+import { trackTrialAcceptTerms } from '~/google_tag_manager';
export default {
name: 'TermsApp',
@@ -73,6 +74,7 @@ export default {
this.setScrollableViewportHeight();
event.target.removeEventListener(FLASH_CLOSED_EVENT, this.handleFlashClose);
},
+ trackTrialAcceptTerms,
},
};
</script>
@@ -99,7 +101,13 @@ export default {
<gl-button type="submit">{{ $options.i18n.decline }}</gl-button>
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
</form>
- <form v-if="permissions.canAccept" class="gl-ml-3" method="post" :action="paths.accept">
+ <form
+ v-if="permissions.canAccept"
+ class="gl-ml-3"
+ method="post"
+ :action="paths.accept"
+ @submit="trackTrialAcceptTerms"
+ >
<gl-button
type="submit"
variant="confirm"
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
index a3615eab26f..3356cada58a 100644
--- a/app/assets/javascripts/user_popovers.js
+++ b/app/assets/javascripts/user_popovers.js
@@ -117,7 +117,7 @@ function launchPopover(el, mountPopover) {
mountPopover(popoverInstance);
}
-const userLinkSelector = 'a.js-user-link, a.gfm-project_member';
+const userLinkSelector = 'a.js-user-link[data-user], a.js-user-link[data-user-id]';
const getUserLinkNode = (node) => node.closest(userLinkSelector);
diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js
index e1e5cc565c6..94b4ee77e7e 100644
--- a/app/assets/javascripts/users_select/index.js
+++ b/app/assets/javascripts/users_select/index.js
@@ -35,7 +35,7 @@ function UsersSelect(currentUser, els, options = {}) {
}
}
- const { handleClick, autoAssignToMe } = options;
+ const { handleClick } = options;
const userSelect = this;
$els.each((i, dropdown) => {
@@ -172,7 +172,10 @@ function UsersSelect(currentUser, els, options = {}) {
});
};
- const onAssignToMeClick = () => {
+ $assignToMeLink.on('click', (e) => {
+ e.preventDefault();
+ $(e.currentTarget).hide();
+
if ($dropdown.data('multiSelect')) {
assignYourself();
checkMaxSelect();
@@ -191,19 +194,8 @@ function UsersSelect(currentUser, els, options = {}) {
.text(gon.current_user_fullname)
.removeClass('is-default');
}
- };
-
- $assignToMeLink.on('click', (e) => {
- e.preventDefault();
- $(e.currentTarget).hide();
- onAssignToMeClick();
});
- if (autoAssignToMe) {
- $assignToMeLink.hide();
- onAssignToMeClick();
- }
-
$block.on('click', '.js-assign-yourself', (e) => {
e.preventDefault();
return assignTo(userSelect.currentUser.id);
@@ -249,7 +241,7 @@ function UsersSelect(currentUser, els, options = {}) {
)} <% } %>`,
);
assigneeTemplate = template(
- `<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself">
+ `<% if (username) { %> <a class="author-link gl-font-weight-bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself">
${sprintf(s__('UsersSelect|No assignee - %{openingTag} assign yourself %{closingTag}'), {
openingTag: '<a href="#" class="js-assign-yourself">',
closingTag: '</a>',
@@ -585,7 +577,7 @@ function UsersSelect(currentUser, els, options = {}) {
)}</a></li>`;
} else {
// 0 margin, because it's now handled by a wrapper
- img = `<img src='${avatar}' class='avatar avatar-inline m-0' width='32' />`;
+ img = `<img src='${avatar}' class='avatar avatar-inline gl-m-0!' width='32' />`;
}
return userSelect.renderRow(
@@ -806,9 +798,9 @@ UsersSelect.prototype.renderRow = function (
: user.name;
return `
<li data-user-id=${user.id}>
- <a href="#" class="dropdown-menu-user-link d-flex align-items-center ${linkClasses}" ${tooltipAttributes}>
+ <a href="#" class="dropdown-menu-user-link gl-display-flex! gl-align-items-center ${linkClasses}" ${tooltipAttributes}>
${this.renderRowAvatar(issuableType, user, img)}
- <span class="d-flex flex-column overflow-hidden">
+ <span class="gl-display-flex gl-flex-direction-column gl-overflow-hidden">
<strong class="dropdown-menu-user-full-name gl-font-weight-bold">
${escape(name)}
</strong>
@@ -836,7 +828,7 @@ UsersSelect.prototype.renderRowAvatar = function (issuableType, user, img) {
? spriteIcon('warning-solid', 's12 merge-icon')
: '';
- return `<span class="position-relative mr-2">
+ return `<span class="gl-relative gl-mr-3">
${img}
${mergeIcon}
</span>`;
@@ -851,7 +843,7 @@ UsersSelect.prototype.renderApprovalRules = function (elsClassName, approvalRule
const [rule] = approvalRules;
const countText = sprintf(__('(+%{count}&nbsp;rules)'), { count });
- const renderApprovalRulesCount = count > 1 ? `<span class="ml-1">${countText}</span>` : '';
+ const renderApprovalRulesCount = count > 1 ? `<span class="gl-ml-2">${countText}</span>` : '';
const ruleName = rule.rule_type === 'code_owner' ? __('Code Owner') : escape(rule.name);
return `<div class="gl-display-flex gl-font-sm">
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue
index 655ceb5f700..b76d5d90ead 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlButton, GlDropdown, GlDropdownItem, GlTooltipDirective } from '@gitlab/ui';
import { sprintf, __ } from '~/locale';
export default {
@@ -8,6 +8,9 @@ export default {
GlDropdown,
GlDropdownItem,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
widget: {
type: String,
@@ -19,6 +22,12 @@ export default {
default: () => [],
},
},
+ data: () => {
+ return {
+ timeout: null,
+ updatingTooltip: false,
+ };
+ },
computed: {
dropdownLabel() {
return sprintf(__('%{widget} options'), { widget: this.widget });
@@ -27,9 +36,29 @@ export default {
methods: {
onClickAction(action) {
this.$emit('clickedAction', action);
+
if (action.onClick) {
action.onClick();
}
+
+ if (action.tooltipOnClick) {
+ this.updatingTooltip = true;
+ this.$root.$emit('bv::show::tooltip', action.id);
+
+ clearTimeout(this.timeout);
+
+ this.timeout = setTimeout(() => {
+ this.updatingTooltip = false;
+ this.$root.$emit('bv::hide::tooltip', action.id);
+ }, 1000);
+ }
+ },
+ setTooltip(btn) {
+ if (this.updatingTooltip && btn.tooltipOnClick) {
+ return btn.tooltipOnClick;
+ }
+
+ return btn.tooltipText;
},
},
};
@@ -55,6 +84,7 @@ export default {
:key="index"
:href="btn.href"
:target="btn.target"
+ :data-clipboard-text="btn.dataClipboardText"
@click="onClickAction(btn)"
>
{{ btn.text }}
@@ -63,15 +93,20 @@ export default {
<template v-if="tertiaryButtons.length">
<gl-button
v-for="(btn, index) in tertiaryButtons"
+ :id="btn.id"
:key="index"
+ v-gl-tooltip.hover
+ :title="setTooltip(btn)"
:href="btn.href"
:target="btn.target"
:class="{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }"
+ :data-clipboard-text="btn.dataClipboardText"
+ :icon="btn.icon"
+ :data-testid="btn.testId || 'extension-actions-button'"
+ :variant="btn.variant || 'confirm'"
category="tertiary"
- variant="confirm"
size="small"
class="gl-display-none gl-md-display-block gl-float-left"
- data-testid="extension-actions-button"
@click="onClickAction(btn)"
>
{{ btn.text }}
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 4ba620da00a..410331004e4 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
@@ -194,6 +194,24 @@ export default {
poll.makeRequest();
},
+ initExtensionFullDataPolling() {
+ const poll = new Poll({
+ resource: {
+ fetchData: () => this.fetchFullData(this),
+ },
+ method: 'fetchData',
+ successCallback: (response) => {
+ this.headerCheck(response, (data) => {
+ this.setFullData(data);
+ });
+ },
+ errorCallback: (e) => {
+ this.setExpandedError(e);
+ },
+ });
+
+ poll.makeRequest();
+ },
headerCheck(response, callback) {
const headers = normalizeHeaders(response.headers);
@@ -220,6 +238,10 @@ export default {
});
}
},
+ setFullData(data) {
+ this.loadingState = null;
+ this.fullData = data.map((x, i) => ({ id: i, ...x }));
+ },
setCollapsedData(data) {
this.collapsedData = data;
this.loadingState = null;
@@ -229,21 +251,26 @@ export default {
Sentry.captureException(e);
},
+ setExpandedError(e) {
+ this.loadingState = LOADING_STATES.expandedError;
+ Sentry.captureException(e);
+ },
loadAllData() {
if (this.hasFullData) return;
this.loadingState = LOADING_STATES.expandedLoading;
- this.fetchFullData(this)
- .then((data) => {
- this.loadingState = null;
- this.fullData = data.map((x, i) => ({ id: i, ...x }));
- })
- .catch((e) => {
- this.loadingState = LOADING_STATES.expandedError;
-
- Sentry.captureException(e);
- });
+ if (this.$options.enableExpandedPolling) {
+ this.initExtensionFullDataPolling();
+ } else {
+ this.fetchFullData(this)
+ .then((data) => {
+ this.setFullData(data);
+ })
+ .catch((e) => {
+ this.setExpandedError(e);
+ });
+ }
},
appear(index) {
if (index === this.fullData.length - 1) {
@@ -288,6 +315,7 @@ export default {
@mouseup="onRowMouseUp"
>
<status-icon
+ :level="1"
:name="$options.label || $options.name"
:is-loading="isLoadingSummary"
:icon-name="statusIconName"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
index f4fcf4c9571..7e329399957 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
@@ -20,6 +20,7 @@ export const registerExtension = (extension) => {
i18n: extension.i18n,
expandEvent: extension.expandEvent,
enablePolling: extension.enablePolling,
+ enableExpandedPolling: extension.enableExpandedPolling,
modalComponent: extension.modalComponent,
computed: {
...extension.props.reduce(
@@ -35,7 +36,7 @@ export const registerExtension = (extension) => {
(acc, computedKey) => ({
...acc,
// Making the computed property a method allows us to pass in arguments
- // this allows for each computed property to recieve some data
+ // this allows for each computed property to receive some data
[computedKey]() {
return extension.computed[computedKey];
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
index bb626c9adba..dc748ba44f2 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
@@ -9,6 +9,11 @@ export default {
GlIcon,
},
props: {
+ level: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
name: {
type: String,
required: false,
@@ -27,7 +32,7 @@ export default {
size: {
type: Number,
required: false,
- default: 16,
+ default: 12,
},
},
computed: {
@@ -44,8 +49,8 @@ export default {
<div
:class="[
$options.EXTENSION_ICON_CLASS[iconName],
- { 'mr-widget-extension-icon': !isLoading && size === 16 },
- { 'gl-p-2': isLoading || size === 16 },
+ { 'mr-widget-extension-icon gl-w-6': !isLoading && level === 1 },
+ { 'gl-p-2': isLoading || level === 1 },
]"
class="gl-rounded-full gl-mr-3 gl-relative gl-p-2"
>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js
index aec3a35f37c..b551cd2fd60 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js
@@ -65,7 +65,7 @@ function simplifyWidgetName(componentName) {
function baseRedisEventName(extensionName) {
const redisEventName = extensionName.replace(/([A-Z])/g, '_$1').toLowerCase();
- return `i_merge_request_widget_${redisEventName}`;
+ return `i_code_review_merge_request_widget_${redisEventName}`;
}
function whenable(bus) {
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 701ef89304c..a45823823f0 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
@@ -4,7 +4,7 @@ import StatusIcon from '../mr_widget_status_icon.vue';
export default {
i18n: {
- approvalNeeded: s__('mrWidget|Merge blocked: this merge request must be approved.'),
+ approvalNeeded: s__('mrWidget|Merge blocked: all required approvals must be given.'),
blockingMergeRequests: s__(
'mrWidget|Merge blocked: you can only merge after the above items are resolved.',
),
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 3511fffcfbb..59767eb2e6e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
@@ -3,6 +3,7 @@ import { GlButton, GlSkeletonLoader } from '@gitlab/ui';
import createFlash from '~/flash';
import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import toast from '~/vue_shared/plugins/global_toast';
import simplePoll from '~/lib/utils/simple_poll';
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
@@ -120,13 +121,15 @@ export default {
.poll()
.then((res) => res.data)
.then((res) => {
- if (res.rebase_in_progress) {
+ if (res.rebase_in_progress || res.should_be_rebased) {
continuePolling();
} else {
this.isMakingRequest = false;
if (res.merge_error && res.merge_error.length) {
this.rebasingError = res.merge_error;
+ } else {
+ toast(__('Rebase completed'));
}
eventHub.$emit('MRWidgetRebaseSuccess');
@@ -218,6 +221,17 @@ export default {
>
{{ __('Rebase') }}
</gl-button>
+ <gl-button
+ v-if="glFeatures.restructuredMrWidget && showRebaseWithoutCi"
+ :loading="isMakingRequest"
+ variant="confirm"
+ size="small"
+ category="secondary"
+ data-testid="rebase-without-ci-button"
+ @click="rebaseWithoutCi"
+ >
+ {{ __('Rebase without pipeline') }}
+ </gl-button>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js
index f14e80d0be6..22e907f7e48 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js
@@ -40,6 +40,9 @@ export default {
return numOfResults === 0 ? successText : warningText;
},
+ shouldCollapse() {
+ return this.collapsedData?.summary?.errored > 0;
+ },
fetchCollapsedData() {
return axios.get(this.accessibilityReportPath);
},
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 a7aaa2f4476..ca95e1b5de8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
@@ -32,7 +32,7 @@ export default {
// Status icon to be used next to the summary text
// Receives the collapsed data as an argument
statusIcon(count) {
- return EXTENSION_ICONS.warning;
+ return EXTENSION_ICONS.failed;
},
// Tertiary action buttons that will take the user elsewhere
// in the GitLab app
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js
index 23f14bea4e1..4994a0bcbeb 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js
@@ -7,6 +7,8 @@ export const TESTS_FAILED_STATUS = 'failed';
export const ERROR_STATUS = 'error';
export const i18n = {
+ copyFailedSpecs: s__('Reports|Copy failed tests'),
+ copyFailedSpecsTooltip: s__('Reports|Copy failed test names to run locally'),
label: s__('Reports|Test summary'),
loading: s__('Reports|Test summary results are loading'),
error: s__('Reports|Test summary failed to load results'),
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js
index 164bda33b95..c74445a5b80 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js
@@ -1,4 +1,5 @@
import { uniqueId } from 'lodash';
+import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import TestCaseDetails from '~/pipelines/components/test_reports/test_case_details.vue';
import { EXTENSION_ICONS } from '../../constants';
@@ -19,6 +20,20 @@ export default {
props: ['testResultsPath', 'headBlobPath', 'pipeline'],
modalComponent: TestCaseDetails,
computed: {
+ failedTestNames() {
+ if (!this.collapsedData?.suites) {
+ return '';
+ }
+
+ const newFailures = this.collapsedData?.suites.flatMap((suite) => [suite.new_failures || []]);
+ const fileNames = newFailures.flatMap((newFailure) => {
+ return newFailure.map((failure) => {
+ return failure.file;
+ });
+ });
+
+ return fileNames.join(' ');
+ },
summary(data) {
if (data.parsingInProgress) {
return this.$options.i18n.loading;
@@ -32,9 +47,6 @@ export default {
};
},
statusIcon(data) {
- if (data.parsingInProgress) {
- return null;
- }
if (data.status === TESTS_FAILED_STATUS) {
return EXTENSION_ICONS.warning;
}
@@ -44,30 +56,46 @@ export default {
return EXTENSION_ICONS.success;
},
tertiaryButtons() {
- return [
- {
- text: this.$options.i18n.fullReport,
- href: `${this.pipeline.path}/test_report`,
- target: '_blank',
- fullReport: true,
- },
- ];
+ const actionButtons = [];
+
+ if (this.failedTestNames().length > 0) {
+ actionButtons.push({
+ dataClipboardText: this.failedTestNames(),
+ id: uniqueId('copy-to-clipboard'),
+ icon: 'copy-to-clipboard',
+ testId: 'copy-failed-specs-btn',
+ text: this.$options.i18n.copyFailedSpecs,
+ tooltipText: this.$options.i18n.copyFailedSpecsTooltip,
+ tooltipOnClick: __('Copied'),
+ });
+ }
+
+ actionButtons.push({
+ text: this.$options.i18n.fullReport,
+ href: `${this.pipeline.path}/test_report`,
+ target: '_blank',
+ fullReport: true,
+ testId: 'full-report-link',
+ });
+
+ return actionButtons;
},
},
methods: {
fetchCollapsedData() {
- return axios.get(this.testResultsPath).then((res) => {
- const { data = {}, status } = res;
+ return axios.get(this.testResultsPath).then((response) => {
+ const { data = {}, status } = response;
+ const { suites = [], summary = {} } = data;
return {
- ...res,
+ ...response,
data: {
- hasSuiteError: data.suites?.some((suite) => suite.status === ERROR_STATUS),
+ hasSuiteError: suites.some((suite) => suite.status === ERROR_STATUS),
parsingInProgress: status === 204,
...data,
summary: {
- recentlyFailed: countRecentlyFailedTests(data.suites),
- ...data.summary,
+ recentlyFailed: countRecentlyFailedTests(suites),
+ ...summary,
},
},
};
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js
index 7bbcb0cd04a..4ffd06de61f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js
@@ -1,3 +1,4 @@
+import { isEmpty } from 'lodash';
import { i18n } from './constants';
const textBuilder = (results, boldNumbers = false) => {
@@ -65,6 +66,11 @@ export const reportSubTextBuilder = ({ suite_errors, summary }) => {
};
export const countRecentlyFailedTests = (subject) => {
+ // return 0 count if subject is [], null, or undefined
+ if (isEmpty(subject)) {
+ return 0;
+ }
+
// handle either a single report or an array of reports
const reports = !subject.length ? [subject] : subject;
@@ -73,10 +79,10 @@ export const countRecentlyFailedTests = (subject) => {
return (
[report.new_failures, report.existing_failures, report.resolved_failures]
// only count tests which have failed more than once
- .map(
- (failureArray) =>
- failureArray.filter((failure) => failure.recent_failures?.count > 1).length,
- )
+ .map((failureArray) => {
+ if (!failureArray) return 0;
+ return failureArray.filter((failure) => failure.recent_failures?.count > 1).length;
+ })
.reduce((total, count) => total + count, 0)
);
})
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
index c68437b9879..3e0ac236fdf 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
@@ -221,8 +221,11 @@ export default {
formattedHumanAccess() {
return (this.mr.humanAccess || '').toLowerCase();
},
+ hasMergeError() {
+ return this.mr.mergeError && this.state !== 'closed';
+ },
hasAlerts() {
- return this.mr.mergeError || this.showMergePipelineForkWarning;
+ return this.hasMergeError || this.showMergePipelineForkWarning;
},
shouldShowExtension() {
return (
@@ -574,7 +577,12 @@ export default {
/>
<div class="mr-section-container mr-widget-workflow">
<div v-if="hasAlerts" class="gl-overflow-hidden mr-widget-alert-container">
- <mr-widget-alert-message v-if="mr.mergeError" type="danger" dismissible>
+ <mr-widget-alert-message
+ v-if="hasMergeError"
+ type="danger"
+ dismissible
+ data-testid="merge_error"
+ >
<span v-safe-html="mergeError"></span>
</mr-widget-alert-message>
<mr-widget-alert-message
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 25c44beaf18..981c667f27a 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
@@ -6,11 +6,13 @@ query getState($projectPath: ID!, $iid: String!) {
mergeRequest(iid: $iid) {
id
autoMergeEnabled
+ availableAutoMergeStrategies
commitCount
conflicts
diffHeadSha
mergeError
mergeStatus
+ mergeable
mergeableDiscussionsState
headPipeline {
id
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
index 322ea64eb7e..f2c27cf611e 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
@@ -61,7 +61,7 @@ export default {
},
});
- return document.dispatchEvent(headerTodoEvent);
+ document.dispatchEvent(headerTodoEvent);
},
addToDo() {
this.isUpdating = true;
@@ -75,9 +75,10 @@ export default {
})
.then(({ data: { errors = [] } }) => {
if (errors[0]) {
- return this.throwError(errors[0]);
+ this.throwError(errors[0]);
+ return;
}
- return this.updateToDoCount(true);
+ this.updateToDoCount(true);
})
.catch(() => {
this.throwError();
@@ -98,9 +99,10 @@ export default {
})
.then(({ data: { errors = [] } }) => {
if (errors[0]) {
- return this.throwError(errors[0]);
+ this.throwError(errors[0]);
+ return;
}
- return this.updateToDoCount(false);
+ this.updateToDoCount(false);
})
.catch(() => {
this.throwError();
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_item.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_item.vue
index 92817d5fa70..70cac061ca6 100644
--- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_item.vue
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_item.vue
@@ -14,12 +14,12 @@ export default {
</script>
<template>
- <div>
+ <div class="color-item">
<span
- class="dropdown-label-box gl-flex-shrink-0 gl-top-1 gl-mr-0"
+ class="dropdown-label-box color-item-color"
data-testid="color-item"
:style="{ backgroundColor: color }"
></span>
- <span class="hide-collapsed">{{ title }}</span>
+ <span class="color-item-text">{{ title }}</span>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue
index 6b79883d76b..a88a4ca5cb8 100644
--- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue
@@ -1,4 +1,5 @@
<script>
+import { isString } from 'lodash';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
@@ -52,13 +53,23 @@ export default {
required: false,
default: s__('ColorWidget|Assign epic color'),
},
+ defaultColor: {
+ type: Object,
+ required: false,
+ validator(value) {
+ return isString(value?.color) && isString(value?.title);
+ },
+ default() {
+ return {
+ color: '',
+ title: '',
+ };
+ },
+ },
},
data() {
return {
- issuableColor: {
- color: '',
- title: '',
- },
+ issuableColor: this.defaultColor,
colorUpdateInProgress: false,
oldIid: null,
sidebarExpandedOnClick: false,
@@ -106,9 +117,9 @@ export default {
methods: {
handleDropdownClose(color) {
if (this.iid !== '') {
- this.updateSelectedColor(this.getUpdateVariables(color));
+ this.updateSelectedColor(color);
} else {
- this.$emit('updateSelectedColor', color);
+ this.$emit('updateSelectedColor', { color });
}
this.collapseEditableItem();
@@ -129,13 +140,15 @@ export default {
color: color.color,
};
},
- updateSelectedColor(inputVariables) {
+ updateSelectedColor(color) {
this.colorUpdateInProgress = true;
+ const input = this.getUpdateVariables(color);
+
this.$apollo
.mutate({
mutation: updateEpicColorMutation,
- variables: { input: inputVariables },
+ variables: { input },
})
.then(({ data }) => {
if (data.updateIssuableColor?.errors?.length) {
@@ -144,7 +157,7 @@ export default {
this.$emit('updateSelectedColor', {
id: data.updateIssuableColor?.issuable?.id,
- color: data.updateIssuableColor?.issuable?.color,
+ color,
});
})
.catch((error) =>
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/constants.js b/app/assets/javascripts/vue_shared/components/color_select_dropdown/constants.js
index c70785abd1e..701ac71d755 100644
--- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/constants.js
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/constants.js
@@ -1,4 +1,4 @@
-import { __, s__ } from '~/locale';
+import { s__ } from '~/locale';
export const COLOR_WIDGET_COLOR = s__('ColorWidget|Color');
@@ -7,7 +7,7 @@ export const DROPDOWN_VARIANT = {
Embedded: 'embedded',
};
-export const DEFAULT_COLOR = { title: __('SuggestedColors|Blue'), color: '#1068bf' };
+export const DEFAULT_COLOR = { title: s__('SuggestedColors|Blue'), color: '#1068bf' };
export const ISSUABLE_COLORS = [
DEFAULT_COLOR,
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents.vue
index 4eb1d3d08ca..84da6e1437e 100644
--- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents.vue
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents.vue
@@ -1,11 +1,13 @@
<script>
import { GlDropdown } from '@gitlab/ui';
+import ColorItem from './color_item.vue';
import DropdownContentsColorView from './dropdown_contents_color_view.vue';
import DropdownHeader from './dropdown_header.vue';
import { isDropdownVariantSidebar } from './utils';
export default {
components: {
+ ColorItem,
DropdownContentsColorView,
DropdownHeader,
GlDropdown,
@@ -42,12 +44,15 @@ export default {
},
computed: {
buttonText() {
- if (!this.localSelectedColor?.title) {
+ if (!this.hasSelectedColor) {
return this.dropdownButtonText;
}
return this.localSelectedColor.title;
},
+ hasSelectedColor() {
+ return this.localSelectedColor?.title;
+ },
},
watch: {
localSelectedColor: {
@@ -91,7 +96,15 @@ export default {
</script>
<template>
- <gl-dropdown ref="dropdown" :text="buttonText" class="gl-w-full" @hide="handleDropdownHide">
+ <gl-dropdown ref="dropdown" class="gl-w-full" @hide="handleDropdownHide">
+ <template #button-text>
+ <color-item
+ v-if="hasSelectedColor"
+ :color="localSelectedColor.color"
+ :title="localSelectedColor.title"
+ />
+ <span v-else data-testid="fallback-button-text">{{ buttonText }}</span>
+ </template>
<template #header>
<dropdown-header
ref="header"
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue
index 62f4cf59c14..91906388049 100644
--- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue
@@ -36,8 +36,8 @@ export default {
</script>
<template>
- <gl-dropdown-form>
- <div>
+ <gl-dropdown-form class="js-colors-list">
+ <div data-testid="dropdown-content">
<gl-dropdown-item
v-for="color in colors"
:key="color.color"
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_value.vue
index 4cba66eefd2..7ae803ebf4d 100644
--- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_value.vue
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_value.vue
@@ -20,6 +20,11 @@ export default {
required: true,
},
},
+ computed: {
+ hasColor() {
+ return this.selectedColor.color !== '';
+ },
+ },
};
</script>
@@ -31,13 +36,18 @@ export default {
class="sidebar-collapsed-icon"
>
<gl-icon name="appearance" />
+ <color-item :color="selectedColor.color" :title="selectedColor.title" />
+ </div>
+
+ <span v-if="!hasColor" class="no-value hide-collapsed">
+ <slot></slot>
+ </span>
+ <template v-else>
<color-item
+ class="hide-collapsed"
:color="selectedColor.color"
:title="selectedColor.title"
- class="gl-font-base gl-line-height-24"
/>
- </div>
-
- <color-item class="hide-collapsed" :color="selectedColor.color" :title="selectedColor.title" />
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/deployment_instance.vue b/app/assets/javascripts/vue_shared/components/deployment_instance.vue
index 4aae86fc82b..1b907078cf9 100644
--- a/app/assets/javascripts/vue_shared/components/deployment_instance.vue
+++ b/app/assets/javascripts/vue_shared/components/deployment_instance.vue
@@ -13,8 +13,6 @@
* Mockup is https://gitlab.com/gitlab-org/gitlab/issues/35570
*/
import { GlLink, GlTooltipDirective } from '@gitlab/ui';
-import { mergeUrlParams } from '~/lib/utils/url_utility';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
@@ -23,7 +21,6 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [glFeatureFlagsMixin()],
props: {
/**
* Represents the status of the pod. Each state is represented with a different
@@ -54,17 +51,11 @@ export default {
required: false,
default: '',
},
-
- logsPath: {
- type: String,
- required: false,
- default: '',
- },
},
computed: {
isLink() {
- return this.logsPath !== '' && this.podName !== '';
+ return this.podName !== '';
},
cssClass() {
@@ -74,12 +65,6 @@ export default {
link: this.isLink,
};
},
-
- computedLogPath() {
- return this.isLink && this.glFeatures.monitorLogging
- ? mergeUrlParams({ pod_name: this.podName }, this.logsPath)
- : null;
- },
},
};
</script>
@@ -88,7 +73,6 @@ export default {
v-gl-tooltip
:class="cssClass"
:title="tooltipText"
- :href="computedLogPath"
class="deployment-instance d-flex justify-content-center align-items-center"
/>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/dom_element_listener.vue b/app/assets/javascripts/vue_shared/components/dom_element_listener.vue
index ca427ed4897..b9608a26d91 100644
--- a/app/assets/javascripts/vue_shared/components/dom_element_listener.vue
+++ b/app/assets/javascripts/vue_shared/components/dom_element_listener.vue
@@ -22,7 +22,7 @@ export default {
});
},
render() {
- return this.$slots.default;
+ return this.$scopedSlots.default?.();
},
};
</script>
diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
index a512eb687b7..a246eadb790 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
@@ -37,6 +37,7 @@ export default {
aria-expanded="false"
>
<gl-loading-icon v-show="isLoading" size="sm" :inline="true" />
+ <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots -->
<slot v-if="$slots.default"></slot>
<span v-else class="dropdown-toggle-text"> {{ toggleText }} </span>
<gl-icon
diff --git a/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue b/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue
index 5d0ed8b0821..1da84df022f 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue
+++ b/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue
@@ -75,7 +75,7 @@ export default {
},
},
render() {
- return this.$slots.default;
+ return this.$scopedSlots.default?.();
},
};
</script>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_contact.fragment.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_contact.fragment.graphql
new file mode 100644
index 00000000000..38222e4e8c2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_contact.fragment.graphql
@@ -0,0 +1,6 @@
+fragment ContactFragment on CustomerRelationsContact {
+ id
+ firstName
+ lastName
+ email
+}
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_organization.fragment.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_organization.fragment.graphql
new file mode 100644
index 00000000000..a7de3c7f7af
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_organization.fragment.graphql
@@ -0,0 +1,4 @@
+fragment OrganizationFragment on CustomerRelationsOrganization {
+ id
+ name
+}
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_contacts.query.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_contacts.query.graphql
new file mode 100644
index 00000000000..647aaa0f7f8
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_contacts.query.graphql
@@ -0,0 +1,28 @@
+#import "./crm_contact.fragment.graphql"
+
+query searchCrmContacts(
+ $isProject: Boolean = false
+ $fullPath: ID!
+ $searchString: String
+ $searchIds: [CustomerRelationsContactID!]
+) {
+ group(fullPath: $fullPath) @skip(if: $isProject) {
+ id
+ contacts(search: $searchString, ids: $searchIds) {
+ nodes {
+ ...ContactFragment
+ }
+ }
+ }
+ project(fullPath: $fullPath) @include(if: $isProject) {
+ id
+ group {
+ id
+ contacts(search: $searchString, ids: $searchIds) {
+ nodes {
+ ...ContactFragment
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_organizations.query.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_organizations.query.graphql
new file mode 100644
index 00000000000..c4f4663de45
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_organizations.query.graphql
@@ -0,0 +1,28 @@
+#import "./crm_organization.fragment.graphql"
+
+query searchCrmOrganizations(
+ $isProject: Boolean = false
+ $fullPath: ID!
+ $searchString: String
+ $searchIds: [CustomerRelationsOrganizationID!]
+) {
+ group(fullPath: $fullPath) @skip(if: $isProject) {
+ id
+ organizations(search: $searchString, ids: $searchIds) {
+ nodes {
+ ...OrganizationFragment
+ }
+ }
+ }
+ project(fullPath: $fullPath) @include(if: $isProject) {
+ id
+ group {
+ id
+ organizations(search: $searchString, ids: $searchIds) {
+ nodes {
+ ...OrganizationFragment
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue
new file mode 100644
index 00000000000..adfe0559b62
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue
@@ -0,0 +1,131 @@
+<script>
+import { GlFilteredSearchSuggestion } from '@gitlab/ui';
+
+import { ITEM_TYPE } from '~/groups/constants';
+import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
+import createFlash from '~/flash';
+import { isPositiveInteger } from '~/lib/utils/number_utils';
+import { __ } from '~/locale';
+import searchCrmContactsQuery from '../queries/search_crm_contacts.query.graphql';
+
+import { DEFAULT_NONE_ANY } from '../constants';
+
+import BaseToken from './base_token.vue';
+
+export default {
+ components: {
+ BaseToken,
+ GlFilteredSearchSuggestion,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ active: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ contacts: this.config.initialContacts || [],
+ loading: false,
+ };
+ },
+ computed: {
+ defaultContacts() {
+ return this.config.defaultContacts || DEFAULT_NONE_ANY;
+ },
+ namespace() {
+ return this.config.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP;
+ },
+ },
+ methods: {
+ getActiveContact(contacts, data) {
+ return contacts.find((contact) => {
+ return `${this.formatContactId(contact)}` === data;
+ });
+ },
+ getContactName(contact) {
+ return `${contact.firstName} ${contact.lastName}`;
+ },
+ fetchContacts(searchTerm) {
+ let searchString = null;
+ let searchId = null;
+ if (isPositiveInteger(searchTerm)) {
+ searchId = this.formatContactGraphQLId(searchTerm);
+ } else {
+ searchString = searchTerm;
+ }
+
+ this.loading = true;
+
+ this.$apollo
+ .query({
+ query: searchCrmContactsQuery,
+ variables: {
+ fullPath: this.config.fullPath,
+ searchString,
+ searchIds: searchId ? [searchId] : null,
+ isProject: this.config.isProject,
+ },
+ })
+ .then(({ data }) => {
+ this.contacts = this.config.isProject
+ ? data[this.namespace]?.group.contacts.nodes
+ : data[this.namespace]?.contacts.nodes;
+ })
+ .catch(() =>
+ createFlash({
+ message: __('There was a problem fetching CRM contacts.'),
+ }),
+ )
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ formatContactId(contact) {
+ return `${getIdFromGraphQLId(contact.id)}`;
+ },
+ formatContactGraphQLId(id) {
+ return convertToGraphQLId('CustomerRelations::Contact', id);
+ },
+ },
+};
+</script>
+
+<template>
+ <base-token
+ :config="config"
+ :value="value"
+ :active="active"
+ :suggestions-loading="loading"
+ :suggestions="contacts"
+ :get-active-token-value="getActiveContact"
+ :default-suggestions="defaultContacts"
+ v-bind="$attrs"
+ @fetch-suggestions="fetchContacts"
+ v-on="$listeners"
+ >
+ <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
+ {{ activeTokenValue ? getContactName(activeTokenValue) : inputValue }}
+ </template>
+ <template #suggestions-list="{ suggestions }">
+ <gl-filtered-search-suggestion
+ v-for="contact in suggestions"
+ :key="formatContactId(contact)"
+ :value="formatContactId(contact)"
+ >
+ <div>
+ <div>{{ getContactName(contact) }}</div>
+ <div class="gl-font-sm">{{ contact.email }}</div>
+ </div>
+ </gl-filtered-search-suggestion>
+ </template>
+ </base-token>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue
new file mode 100644
index 00000000000..e6ab944449e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue
@@ -0,0 +1,125 @@
+<script>
+import { GlFilteredSearchSuggestion } from '@gitlab/ui';
+
+import { ITEM_TYPE } from '~/groups/constants';
+import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
+import createFlash from '~/flash';
+import { isPositiveInteger } from '~/lib/utils/number_utils';
+import { __ } from '~/locale';
+import searchCrmOrganizationsQuery from '../queries/search_crm_organizations.query.graphql';
+
+import { DEFAULT_NONE_ANY } from '../constants';
+
+import BaseToken from './base_token.vue';
+
+export default {
+ components: {
+ BaseToken,
+ GlFilteredSearchSuggestion,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ active: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ organizations: this.config.initialOrganizations || [],
+ loading: false,
+ };
+ },
+ computed: {
+ defaultOrganizations() {
+ return this.config.defaultOrganizations || DEFAULT_NONE_ANY;
+ },
+ namespace() {
+ return this.config.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP;
+ },
+ },
+ methods: {
+ getActiveOrganization(organizations, data) {
+ return organizations.find((organization) => {
+ return `${this.formatOrganizationId(organization)}` === data;
+ });
+ },
+ fetchOrganizations(searchTerm) {
+ let searchString = null;
+ let searchId = null;
+ if (isPositiveInteger(searchTerm)) {
+ searchId = this.formatOrganizationGraphQLId(searchTerm);
+ } else {
+ searchString = searchTerm;
+ }
+
+ this.loading = true;
+
+ this.$apollo
+ .query({
+ query: searchCrmOrganizationsQuery,
+ variables: {
+ fullPath: this.config.fullPath,
+ searchString,
+ searchIds: searchId ? [searchId] : null,
+ isProject: this.config.isProject,
+ },
+ })
+ .then(({ data }) => {
+ this.organizations = this.config.isProject
+ ? data[this.namespace]?.group.organizations.nodes
+ : data[this.namespace]?.organizations.nodes;
+ })
+ .catch(() =>
+ createFlash({
+ message: __('There was a problem fetching CRM organizations.'),
+ }),
+ )
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ formatOrganizationId(organization) {
+ return `${getIdFromGraphQLId(organization.id)}`;
+ },
+ formatOrganizationGraphQLId(id) {
+ return convertToGraphQLId('CustomerRelations::Organization', id);
+ },
+ },
+};
+</script>
+
+<template>
+ <base-token
+ :config="config"
+ :value="value"
+ :active="active"
+ :suggestions-loading="loading"
+ :suggestions="organizations"
+ :get-active-token-value="getActiveOrganization"
+ :default-suggestions="defaultOrganizations"
+ v-bind="$attrs"
+ @fetch-suggestions="fetchOrganizations"
+ v-on="$listeners"
+ >
+ <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
+ {{ activeTokenValue ? activeTokenValue.name : inputValue }}
+ </template>
+ <template #suggestions-list="{ suggestions }">
+ <gl-filtered-search-suggestion
+ v-for="organization in suggestions"
+ :key="formatOrganizationId(organization)"
+ :value="formatOrganizationId(organization)"
+ >
+ {{ organization.name }}
+ </gl-filtered-search-suggestion>
+ </template>
+ </base-token>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
index 15d858b99b9..482a2964b4c 100644
--- a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
+++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
@@ -139,6 +139,7 @@ export default {
/>
</template>
</gl-form-input-group>
+ <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots -->
<template v-for="slot in Object.keys($slots)" #[slot]>
<slot :name="slot"></slot>
</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 f2abade8036..96f7427dda1 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -163,6 +163,7 @@ export default {
</template>
</section>
+ <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots -->
<section v-if="$slots.default" data-testid="ci-header-action-buttons" class="gl-display-flex">
<slot></slot>
</section>
diff --git a/app/assets/javascripts/vue_shared/components/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue
index c3f184446a8..1b89bd324c6 100644
--- a/app/assets/javascripts/vue_shared/components/help_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/help_popover.vue
@@ -38,6 +38,7 @@ export default {
<template #default>
<div v-safe-html="options.content"></div>
</template>
+ <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots -->
<template v-for="slot in Object.keys($slots)" #[slot]>
<slot :name="slot"></slot>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
index 4ece87310c7..96c779c5ce4 100644
--- a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
+++ b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
@@ -100,7 +100,7 @@ export default {
},
},
render() {
- return this.$slots.default;
+ return this.$scopedSlots.default?.();
},
};
</script>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 1f309a19b14..32b3a0e22c2 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -248,7 +248,7 @@ export default {
labels: this.enableAutocomplete,
snippets: this.enableAutocomplete,
vulnerabilities: this.enableAutocomplete,
- contacts: this.enableAutocomplete && this.glFeatures.contactsAutocomplete,
+ contacts: this.enableAutocomplete,
},
true,
);
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
index 8a1b8363f19..7646a8718d6 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -139,8 +139,8 @@ export default {
</script>
<template>
- <div class="md-suggestion-header border-bottom-0 mt-2">
- <div class="js-suggestion-diff-header font-weight-bold">
+ <div class="md-suggestion-header border-bottom-0 gl-mt-3">
+ <div class="js-suggestion-diff-header gl-font-weight-bold">
{{ __('Suggested change') }}
<a v-if="helpPagePath" :href="helpPagePath" :aria-label="__('Help')" class="js-help-btn">
<gl-icon name="question-o" css-classes="link-highlight" />
@@ -151,13 +151,13 @@ export default {
</gl-badge>
<div
v-else-if="isApplying"
- class="d-flex align-items-center text-secondary"
+ class="gl-display-flex gl-align-items-center text-secondary"
data-qa-selector="applying_badge"
>
- <gl-loading-icon size="sm" class="d-flex-center mr-2" />
+ <gl-loading-icon size="sm" class="gl-align-items-center gl-justify-content-center gl-mr-3" />
<span>{{ applyingSuggestionsMessage }}</span>
</div>
- <div v-else-if="isLoggedIn" class="d-flex align-items-center">
+ <div v-else-if="isLoggedIn" class="gl-display-flex gl-align-items-center">
<div v-if="isBatched">
<gl-button
class="btn-inverted js-remove-from-batch-btn btn-grouped"
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 624dbcc6d8e..0cb4a5bc39f 100644
--- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
@@ -16,17 +16,17 @@
* :note="{body: 'This is a note'}"
* />
*/
-import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import { GlSafeHtmlDirective as SafeHtml, GlAvatarLink, GlAvatar } from '@gitlab/ui';
import { mapGetters } from 'vuex';
import { renderMarkdown } from '~/notes/utils';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
-import userAvatarLink from '../user_avatar/user_avatar_link.vue';
export default {
name: 'PlaceholderNote',
directives: { SafeHtml },
components: {
- userAvatarLink,
+ GlAvatarLink,
+ GlAvatar,
TimelineEntryItem,
},
props: {
@@ -55,7 +55,10 @@ export default {
return 24;
}
- return 40;
+ return {
+ default: 24,
+ md: 32,
+ };
},
},
};
@@ -64,11 +67,14 @@ export default {
<template>
<timeline-entry-item class="note note-wrapper being-posted fade-in-half">
<div class="timeline-icon">
- <user-avatar-link
- :link-href="getUserData.path"
- :img-src="getUserData.avatar_url"
- :img-size="avatarSize"
- />
+ <gl-avatar-link class="gl-mr-3" :href="getUserData.path">
+ <gl-avatar
+ :src="getUserData.avatar_url"
+ :entity-name="getUserData.username"
+ :alt="getUserData.name"
+ :size="avatarSize"
+ />
+ </gl-avatar-link>
</div>
<div ref="note" :class="{ discussion: !note.individual_note }" class="timeline-content">
<div class="note-header">
diff --git a/app/assets/javascripts/vue_shared/components/page_size_selector.vue b/app/assets/javascripts/vue_shared/components/page_size_selector.vue
new file mode 100644
index 00000000000..9783946b786
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/page_size_selector.vue
@@ -0,0 +1,37 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+
+export const PAGE_SIZES = [20, 50, 100];
+
+export default {
+ components: { GlDropdown, GlDropdownItem },
+ props: {
+ value: {
+ type: Number,
+ required: true,
+ },
+ },
+ methods: {
+ emitInput(pageSize) {
+ this.$emit('input', pageSize);
+ },
+ getPageSizeText(pageSize) {
+ return sprintf(s__('SecurityReports|Show %{pageSize} items'), { pageSize });
+ },
+ },
+ PAGE_SIZES,
+};
+</script>
+
+<template>
+ <gl-dropdown :text="getPageSizeText(value)" right menu-class="gl-w-auto! gl-min-w-0">
+ <gl-dropdown-item
+ v-for="pageSize in $options.PAGE_SIZES"
+ :key="pageSize"
+ @click="emitInput(pageSize)"
+ >
+ <span class="gl-white-space-nowrap">{{ getPageSizeText(pageSize) }}</span>
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
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 a8b250f2041..5516c9943b8 100644
--- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
@@ -38,6 +38,7 @@ export default {
},
},
mounted() {
+ // eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots
this.detailsSlots = Object.keys(this.$slots).filter((k) => k.startsWith('details-'));
},
methods: {
@@ -55,7 +56,7 @@ export default {
>
<div class="gl-display-flex gl-align-items-center gl-py-3">
<div
- v-if="$slots['left-action']"
+ v-if="$slots['left-action'] /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */"
class="gl-w-7 gl-display-flex gl-justify-content-start gl-pl-2"
>
<slot name="left-action"></slot>
@@ -65,7 +66,9 @@ export default {
>
<div class="gl-display-flex gl-flex-direction-column gl-xs-mb-3 gl-min-w-0 gl-flex-grow-1">
<div
- v-if="$slots['left-primary']"
+ v-if="
+ $slots['left-primary'] /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */
+ "
class="gl-display-flex gl-align-items-center gl-text-body gl-font-weight-bold gl-min-h-6 gl-min-w-0"
>
<slot name="left-primary"></slot>
@@ -79,7 +82,11 @@ export default {
/>
</div>
<div
- v-if="$slots['left-secondary']"
+ v-if="
+ /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[
+ 'left-secondary'
+ ]
+ "
class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-min-h-6 gl-min-w-0 gl-flex-grow-1"
>
<slot name="left-secondary"></slot>
@@ -89,13 +96,21 @@ export default {
class="gl-display-flex gl-flex-direction-column gl-sm-align-items-flex-end gl-justify-content-space-between gl-text-gray-500 gl-flex-shrink-0"
>
<div
- v-if="$slots['right-primary']"
+ v-if="
+ /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[
+ 'right-primary'
+ ]
+ "
class="gl-display-flex gl-align-items-center gl-sm-text-body gl-sm-font-weight-bold gl-min-h-6"
>
<slot name="right-primary"></slot>
</div>
<div
- v-if="$slots['right-secondary']"
+ v-if="
+ /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[
+ 'right-secondary'
+ ]
+ "
class="gl-display-flex gl-align-items-center gl-min-h-6"
>
<slot name="right-secondary"></slot>
@@ -103,7 +118,9 @@ export default {
</div>
</div>
<div
- v-if="$slots['right-action']"
+ v-if="
+ $slots['right-action'] /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */
+ "
class="gl-w-9 gl-display-flex gl-justify-content-end gl-pr-1"
>
<slot name="right-action"></slot>
diff --git a/app/assets/javascripts/vue_shared/components/registry/title_area.vue b/app/assets/javascripts/vue_shared/components/registry/title_area.vue
index fc0976b0792..ad979387596 100644
--- a/app/assets/javascripts/vue_shared/components/registry/title_area.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/title_area.vue
@@ -47,6 +47,7 @@ export default {
methods: {
recalculateMetadataSlots() {
const METADATA_PREFIX = 'metadata-';
+ // eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots
const metadataSlots = Object.keys(this.$slots).filter((k) => k.startsWith(METADATA_PREFIX));
if (!isEqual(metadataSlots, this.metadataSlots)) {
@@ -76,7 +77,9 @@ export default {
</h2>
<div
- v-if="$slots['sub-header']"
+ v-if="
+ $slots['sub-header'] /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */
+ "
class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-3"
>
<slot name="sub-header"></slot>
@@ -107,6 +110,7 @@ export default {
</template>
</div>
</div>
+ <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots -->
<div v-if="$slots['right-actions']" class="gl-mt-3">
<slot name="right-actions"></slot>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/epics_select/epics_select_bundle.js b/app/assets/javascripts/vue_shared/components/sidebar/epics_select/epics_select_bundle.js
new file mode 100644
index 00000000000..1c08433ee78
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/epics_select/epics_select_bundle.js
@@ -0,0 +1 @@
+// This empty file satisfies the import/no-unresolved rule for ee_else_ce imports.
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/health_status_select/health_status_bundle.js b/app/assets/javascripts/vue_shared/components/sidebar/health_status_select/health_status_bundle.js
new file mode 100644
index 00000000000..1c08433ee78
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/health_status_select/health_status_bundle.js
@@ -0,0 +1 @@
+// This empty file satisfies the import/no-unresolved rule for ee_else_ce imports.
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/iterations_dropdown_bundle.js b/app/assets/javascripts/vue_shared/components/sidebar/iterations_dropdown_bundle.js
new file mode 100644
index 00000000000..1c08433ee78
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/iterations_dropdown_bundle.js
@@ -0,0 +1 @@
+// This empty file satisfies the import/no-unresolved rule for ee_else_ce imports.
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 5471cda0cc5..0127df730b8 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
@@ -193,7 +193,7 @@ export default {
<gl-dropdown
ref="dropdown"
:text="buttonText"
- class="gl-w-full gl-mt-2"
+ class="gl-w-full"
data-testid="labels-select-dropdown-contents"
data-qa-selector="labels_dropdown_content"
@hide="handleDropdownHide"
diff --git a/app/assets/javascripts/vue_shared/components/slot_switch.vue b/app/assets/javascripts/vue_shared/components/slot_switch.vue
index 67726f01744..641b09e0982 100644
--- a/app/assets/javascripts/vue_shared/components/slot_switch.vue
+++ b/app/assets/javascripts/vue_shared/components/slot_switch.vue
@@ -20,6 +20,7 @@ export default {
computed: {
allSlotNames() {
+ // eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots
return Object.keys(this.$slots);
},
},
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
index 0d78530d878..3ac35abcf3a 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
@@ -45,6 +45,7 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = {
haskell: 'haskell',
haxe: 'haxe',
http: 'http',
+ html: 'xml',
hylang: 'hy',
ini: 'ini',
isbl: 'isbl',
@@ -90,7 +91,7 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = {
scala: 'scala',
scheme: 'scheme',
scss: 'scss',
- shell: 'shell',
+ shell: 'sh',
smalltalk: 'smalltalk',
sml: 'sml',
sqf: 'sqf',
@@ -112,6 +113,12 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = {
yaml: 'yaml',
};
+export const EVENT_ACTION = 'view_source';
+
+export const EVENT_LABEL_VIEWER = 'source_viewer';
+
+export const EVENT_LABEL_FALLBACK = 'legacy_fallback';
+
export const LINES_PER_CHUNK = 70;
export const BIDI_CHARS = [
@@ -138,3 +145,5 @@ export const BIDI_CHAR_TOOLTIP = __(
export const HLJS_COMMENT_SELECTOR = 'hljs-comment';
export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight';
+
+export const NPM_URL = 'https://npmjs.com/package';
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js
index c9f7e5508be..5d24a3d110b 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js
@@ -1,5 +1,6 @@
import { HLJS_ON_AFTER_HIGHLIGHT } from '../constants';
import wrapComments from './wrap_comments';
+import linkDependencies from './link_dependencies';
/**
* Registers our plugins for Highlight.js
@@ -8,6 +9,9 @@ import wrapComments from './wrap_comments';
*
* @param {Object} hljs - the Highlight.js instance.
*/
-export const registerPlugins = (hljs) => {
+export const registerPlugins = (hljs, fileType, rawContent) => {
hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapComments });
+ hljs.addPlugin({
+ [HLJS_ON_AFTER_HIGHLIGHT]: (result) => linkDependencies(result, fileType, rawContent),
+ });
};
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js
new file mode 100644
index 00000000000..5b7650c56ae
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js
@@ -0,0 +1,25 @@
+import packageJsonLinker from './utils/package_json_linker';
+
+const DEPENDENCY_LINKERS = {
+ package_json: packageJsonLinker,
+};
+
+/**
+ * Highlight.js plugin for generating links to dependencies when viewing dependency files.
+ *
+ * Plugin API: https://github.com/highlightjs/highlight.js/blob/main/docs/plugin-api.rst
+ *
+ * @param {Object} result - an object that represents the highlighted result from Highlight.js
+ * @param {String} fileType - a string containing the file type
+ * @param {String} rawContent - raw (non-highlighted) file content
+ */
+export default (result, fileType, rawContent) => {
+ if (DEPENDENCY_LINKERS[fileType]) {
+ try {
+ // eslint-disable-next-line no-param-reassign
+ result.value = DEPENDENCY_LINKERS[fileType](result, rawContent);
+ } catch (e) {
+ // Shallowed (do nothing), in this case the original unlinked dependencies will be rendered.
+ }
+ }
+};
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js
new file mode 100644
index 00000000000..56ad55ef553
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js
@@ -0,0 +1,15 @@
+import { escape } from 'lodash';
+import { setAttributes } from '~/lib/utils/dom_utils';
+
+export const createLink = (href, innerText) => {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ const rel = 'nofollow noreferrer noopener';
+ const link = document.createElement('a');
+
+ setAttributes(link, { href: escape(href), rel });
+ link.innerText = escape(innerText);
+
+ return link.outerHTML;
+};
+
+export const generateHLJSOpenTag = (type) => `<span class="hljs-${escape(type)}">&quot;`;
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js
new file mode 100644
index 00000000000..d013d077ba3
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js
@@ -0,0 +1,46 @@
+import { joinPaths } from '~/lib/utils/url_utility';
+import { NPM_URL } from '../../constants';
+import { createLink, generateHLJSOpenTag } from './dependency_linker_util';
+
+const attrOpenTag = generateHLJSOpenTag('attr');
+const stringOpenTag = generateHLJSOpenTag('string');
+const closeTag = '&quot;</span>';
+const DEPENDENCY_REGEX = new RegExp(
+ /*
+ * Detects dependencies inside of content that is highlighted by Highlight.js
+ * Example: <span class="hljs-attr">&quot;@babel/core&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;^7.18.5&quot;</span>
+ * Group 1: @babel/core
+ * Group 2: ^7.18.5
+ */
+ `${attrOpenTag}(.*)${closeTag}.*${stringOpenTag}(.*[0-9].*)(${closeTag})`,
+ 'gm',
+);
+
+const handleReplace = (original, packageName, version, dependenciesToLink) => {
+ const href = joinPaths(NPM_URL, packageName);
+ const packageLink = createLink(href, packageName);
+ const versionLink = createLink(href, version);
+ const closeAndOpenTag = `${closeTag}: ${attrOpenTag}`;
+ const dependencyToLink = dependenciesToLink[packageName];
+
+ if (dependencyToLink && dependencyToLink === version) {
+ return `${attrOpenTag}${packageLink}${closeAndOpenTag}${versionLink}${closeTag}`;
+ }
+
+ return original;
+};
+
+export default (result, raw) => {
+ const { dependencies, devDependencies, peerDependencies, optionalDependencies } = JSON.parse(raw);
+
+ const dependenciesToLink = {
+ ...dependencies,
+ ...devDependencies,
+ ...peerDependencies,
+ ...optionalDependencies,
+ };
+
+ return result.value.replace(DEPENDENCY_REGEX, (original, packageName, version) =>
+ handleReplace(original, packageName, version, dependenciesToLink),
+ );
+};
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
index f819a9e5be2..1bdae40332f 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
@@ -3,7 +3,14 @@ import { GlSafeHtmlDirective, GlLoadingIcon } from '@gitlab/ui';
import LineHighlighter from '~/blob/line_highlighter';
import eventHub from '~/notes/event_hub';
import languageLoader from '~/content_editor/services/highlight_js_language_loader';
-import { ROUGE_TO_HLJS_LANGUAGE_MAP, LINES_PER_CHUNK } from './constants';
+import Tracking from '~/tracking';
+import {
+ EVENT_ACTION,
+ EVENT_LABEL_VIEWER,
+ EVENT_LABEL_FALLBACK,
+ ROUGE_TO_HLJS_LANGUAGE_MAP,
+ LINES_PER_CHUNK,
+} from './constants';
import Chunk from './components/chunk.vue';
import { registerPlugins } from './plugins/index';
@@ -23,6 +30,7 @@ export default {
directives: {
SafeHtml: GlSafeHtmlDirective,
},
+ mixins: [Tracking.mixin()],
props: {
blob: {
type: Object,
@@ -49,8 +57,22 @@ export default {
lineNumbers() {
return this.splitContent.length;
},
+ unsupportedLanguage() {
+ const supportedLanguages = Object.keys(languageLoader);
+ return (
+ !supportedLanguages.includes(this.language) &&
+ !supportedLanguages.includes(this.blob.language)
+ );
+ },
},
async created() {
+ this.trackEvent(EVENT_LABEL_VIEWER);
+
+ if (this.unsupportedLanguage) {
+ this.handleUnsupportedLanguage();
+ return;
+ }
+
this.generateFirstChunk();
this.hljs = await this.loadHighlightJS();
@@ -70,6 +92,13 @@ export default {
});
},
methods: {
+ trackEvent(label) {
+ this.track(EVENT_ACTION, { label, property: this.blob.language });
+ },
+ handleUnsupportedLanguage() {
+ this.trackEvent(EVENT_LABEL_FALLBACK);
+ this.$emit('error');
+ },
generateFirstChunk() {
const lines = this.splitContent.splice(0, LINES_PER_CHUNK);
this.firstChunk = this.createChunk(lines);
@@ -112,7 +141,7 @@ export default {
let detectedLanguage = language;
let highlightedContent;
if (this.hljs) {
- registerPlugins(this.hljs);
+ registerPlugins(this.hljs, this.blob.fileType, this.content);
if (!detectedLanguage) {
const hljsHighlightAuto = this.hljs.highlightAuto(content);
highlightedContent = hljsHighlightAuto.value;
diff --git a/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue b/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue
index 20a666509a4..779a2ab5461 100644
--- a/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue
+++ b/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue
@@ -1,7 +1,6 @@
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
-import { s__ } from '~/locale';
export default {
name: 'UsageBanner',
@@ -15,13 +14,6 @@ export default {
default: false,
},
},
- i18n: {
- dependencyProxy: s__('UsageQuota|Dependency proxy'),
- storageUsed: s__('UsageQuota|Storage used'),
- dependencyProxyMessage: s__(
- 'UsageQuota|Local proxy used for frequently-accessed upstream Docker images. %{linkStart}More information%{linkEnd}',
- ),
- },
storageUsageQuotaHelpPage: helpPagePath('user/usage_quotas'),
};
</script>
@@ -33,13 +25,21 @@ export default {
>
<div class="gl-display-flex gl-flex-direction-column gl-xs-mb-3 gl-min-w-0 gl-flex-grow-1">
<div
- v-if="$slots['left-primary-text']"
+ v-if="
+ /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[
+ 'left-primary-text'
+ ]
+ "
class="gl-display-flex gl-align-items-center gl-text-body gl-font-weight-bold gl-min-h-6 gl-min-w-0"
>
<slot name="left-primary-text"></slot>
</div>
<div
- v-if="$slots['left-secondary-text']"
+ v-if="
+ /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[
+ 'left-secondary-text'
+ ]
+ "
class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-min-h-6 gl-min-w-0 gl-flex-grow-1 gl-w-70p gl-md-max-w-70p"
>
<slot name="left-secondary-text"></slot>
@@ -49,13 +49,21 @@ export default {
class="gl-display-flex gl-flex-direction-column gl-sm-align-items-flex-end gl-justify-content-space-between gl-text-gray-500 gl-flex-shrink-0"
>
<div
- v-if="$slots['right-primary-text']"
+ v-if="
+ /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[
+ 'right-primary-text'
+ ]
+ "
class="gl-display-flex gl-align-items-center gl-sm-text-body gl-sm-font-weight-bold gl-min-h-6"
>
<slot name="right-primary-text"></slot>
</div>
<div
- v-if="$slots['right-secondary-text']"
+ v-if="
+ /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[
+ 'right-secondary-text'
+ ]
+ "
class="gl-display-flex gl-align-items-center gl-min-h-6"
>
<slot v-if="!loading" name="right-secondary-text"></slot>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue
index c58a5357883..707b0bbec67 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue
@@ -96,7 +96,10 @@ export default {
/>
<gl-tooltip
- v-if="tooltipText || $slots.default"
+ v-if="
+ tooltipText ||
+ $slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */
+ "
:target="() => $refs.userAvatar.$el"
:placement="tooltipPlacement"
boundary="window"
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue
index 15ba8e3b39b..6e8c200d5c3 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue
@@ -100,7 +100,10 @@ export default {
class="avatar"
/>
<gl-tooltip
- v-if="tooltipText || $slots.default"
+ v-if="
+ tooltipText ||
+ $slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */
+ "
:target="() => $refs.userAvatarImage"
:placement="tooltipPlacement"
boundary="window"
diff --git a/app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue b/app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue
index 121c3bd94ef..ab5ddbc8af8 100644
--- a/app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue
+++ b/app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue
@@ -56,7 +56,13 @@ import getUserCalloutsQuery from '~/graphql_shared/queries/get_user_callouts.que
* - shouldShowCallout: boolean
* - A combination of the above which should cover 95% of use cases: `true`
* if the query has loaded without error, and the user is logged in, and
- * the callout has not been dismissed yet; `false` otherwise.
+ * the callout has not been dismissed yet; `false` otherwise
+ *
+ * The component emits a `queryResult` event when the GraphQL query
+ * completes. The payload is a combination of the ApolloQueryResult object and
+ * this component's `slotProps` computed property. This is useful for things
+ * like cleaning up/unmounting the component if the callout shouldn't be
+ * displayed.
*/
export default {
name: 'UserCalloutDismisser',
@@ -86,6 +92,9 @@ export default {
update(data) {
return data?.currentUser;
},
+ result(data) {
+ this.$emit('queryResult', { ...data, ...this.slotProps });
+ },
error(err) {
this.queryError = err;
},
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
index 768cd005727..a0d8ca117a4 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -7,13 +7,13 @@ import {
GlSafeHtmlDirective,
GlSprintf,
GlButton,
+ GlAvatarLabeled,
} from '@gitlab/ui';
import { __ } from '~/locale';
-import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue';
import { glEmojiTag } from '~/emoji';
import createFlash from '~/flash';
import { followUser, unfollowUser } from '~/rest_api';
-import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
+import { isUserBusy } from '~/set_status_modal/utils';
import { USER_POPOVER_DELAY } from './constants';
const MAX_SKELETON_LINES = 4;
@@ -22,15 +22,17 @@ export default {
name: 'UserPopover',
maxSkeletonLines: MAX_SKELETON_LINES,
USER_POPOVER_DELAY,
+ i18n: {
+ busy: __('Busy'),
+ },
components: {
GlIcon,
GlLink,
GlPopover,
GlSkeletonLoader,
- UserAvatarImage,
- UserNameWithStatus,
GlSprintf,
GlButton,
+ GlAvatarLabeled,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
@@ -95,6 +97,15 @@ export default {
toggleFollowButtonVariant() {
return this.user?.isFollowed ? 'default' : 'confirm';
},
+ hasPronouns() {
+ return Boolean(this.user?.pronouns?.trim());
+ },
+ isBusy() {
+ return isUserBusy(this.availabilityStatus);
+ },
+ username() {
+ return `@${this.user?.username}`;
+ },
},
methods: {
async toggleFollow() {
@@ -149,43 +160,46 @@ export default {
:placement="placement"
boundary="viewport"
triggers="hover focus manual"
+ data-testid="user-popover"
>
- <div class="gl-py-3 gl-line-height-normal gl-display-flex" data-testid="user-popover">
- <div class="gl-mr-4 gl-flex-shrink-0">
- <user-avatar-image :img-src="user.avatarUrl" :size="64" css-classes="gl-m-0!" />
+ <div class="gl-mb-3">
+ <div v-if="userIsLoading" class="gl-w-20">
+ <gl-skeleton-loader :width="160" :height="64">
+ <rect x="70" y="19" rx="3" ry="3" width="88" height="9" />
+ <rect x="70" y="36" rx="3" ry="3" width="64" height="8" />
+ <circle cx="32" cy="32" r="32" />
+ </gl-skeleton-loader>
</div>
- <div class="gl-w-full gl-word-break-word gl-display-flex gl-align-items-center">
- <template v-if="userIsLoading">
- <gl-skeleton-loader
- :lines="$options.maxSkeletonLines"
- preserve-aspect-ratio="none"
- equal-width-lines
- :height="52"
- />
- </template>
- <template v-else>
- <div>
- <h5 class="gl-m-0">
- <user-name-with-status
- :name="user.name"
- :availability="availabilityStatus"
- :pronouns="user.pronouns"
- />
- </h5>
- <span class="gl-text-gray-500">@{{ user.username }}</span>
- <div v-if="shouldRenderToggleFollowButton" class="gl-mt-3">
- <gl-button
- :variant="toggleFollowButtonVariant"
- :loading="toggleFollowLoading"
- size="small"
- data-testid="toggle-follow-button"
- @click="toggleFollow"
- >{{ toggleFollowButtonText }}</gl-button
- >
- </div>
- </div>
+ <gl-avatar-labeled
+ v-else
+ :size="64"
+ :src="user.avatarUrl"
+ :label="user.name"
+ :sub-label="username"
+ >
+ <gl-button
+ v-if="shouldRenderToggleFollowButton"
+ class="gl-mt-3 gl-align-self-start"
+ :variant="toggleFollowButtonVariant"
+ :loading="toggleFollowLoading"
+ size="small"
+ data-testid="toggle-follow-button"
+ @click="toggleFollow"
+ >{{ toggleFollowButtonText }}</gl-button
+ >
+
+ <template #meta>
+ <span
+ v-if="hasPronouns"
+ class="gl-text-gray-500 gl-font-sm gl-font-weight-normal gl-p-1"
+ data-testid="user-popover-pronouns"
+ >({{ user.pronouns }})</span
+ >
+ <span v-if="isBusy" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal gl-p-1"
+ >({{ $options.i18n.busy }})</span
+ >
</template>
- </div>
+ </gl-avatar-labeled>
</div>
<div class="gl-mt-2 gl-w-full gl-word-break-word">
<template v-if="userIsLoading">
diff --git a/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue
index eff39e2fb89..4ef9bc07b1c 100644
--- a/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue
+++ b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue
@@ -15,7 +15,7 @@ export default {
},
},
render() {
- return this.$slots.default;
+ return this.$scopedSlots.default?.();
},
};
</script>
diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue
index 89eecea5239..25799171905 100644
--- a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue
+++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue
@@ -81,7 +81,8 @@ export default {
ref="textarea"
v-model="issuableDescription"
dir="auto"
- class="note-textarea qa-issuable-form-description rspec-issuable-form-description js-gfm-input js-autosize markdown-area"
+ class="note-textarea rspec-issuable-form-description js-gfm-input js-autosize markdown-area"
+ data-qa-selector="issuable_form_description_field"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
></textarea>
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
index a9f8caa3e1f..b616b390032 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
@@ -86,7 +86,18 @@ export default {
createdAt() {
return getTimeago().format(this.issuable.createdAt);
},
- updatedAt() {
+ timestamp() {
+ if (this.issuable.state === 'closed' && this.issuable.closedAt) {
+ return this.issuable.closedAt;
+ }
+ return this.issuable.updatedAt;
+ },
+ formattedTimestamp() {
+ if (this.issuable.state === 'closed' && this.issuable.closedAt) {
+ return sprintf(__('closed %{timeago}'), {
+ timeago: getTimeago().format(this.issuable.closedAt),
+ });
+ }
return sprintf(__('updated %{timeAgo}'), {
timeAgo: getTimeago().format(this.issuable.updatedAt),
});
@@ -134,6 +145,7 @@ export default {
},
methods: {
hasSlotContents(slotName) {
+ // eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots
return Boolean(this.$slots[slotName]);
},
scopedLabel(label) {
@@ -311,10 +323,10 @@ export default {
<div
v-gl-tooltip.bottom
class="gl-text-gray-500 gl-display-none gl-sm-display-inline-block"
- :title="tooltipTitle(issuable.updatedAt)"
- data-testid="issuable-updated-at"
+ :title="tooltipTitle(timestamp)"
+ data-testid="issuable-timestamp"
>
- {{ updatedAt }}
+ {{ formattedTimestamp }}
</div>
</div>
</li>
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
index 8fbf0bb10a0..189bbb56432 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
@@ -1,11 +1,13 @@
<script>
import { GlAlert, GlKeysetPagination, GlSkeletonLoader, GlPagination } from '@gitlab/ui';
import { uniqueId } from 'lodash';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import PageSizeSelector from '~/vue_shared/components/page_size_selector.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
-import { DEFAULT_SKELETON_COUNT } from '../constants';
+import { DEFAULT_SKELETON_COUNT, PAGE_SIZE_STORAGE_KEY } from '../constants';
import IssuableBulkEditSidebar from './issuable_bulk_edit_sidebar.vue';
import IssuableItem from './issuable_item.vue';
import IssuableTabs from './issuable_tabs.vue';
@@ -29,6 +31,8 @@ export default {
IssuableBulkEditSidebar,
GlPagination,
VueDraggable,
+ PageSizeSelector,
+ LocalStorageSync,
},
props: {
namespace: {
@@ -173,6 +177,11 @@ export default {
required: false,
default: false,
},
+ showPageSizeChangeControls: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -262,7 +271,11 @@ export default {
handleVueDraggableUpdate({ newIndex, oldIndex }) {
this.$emit('reorder', { newIndex, oldIndex });
},
+ handlePageSizeChange(newPageSize) {
+ this.$emit('page-size-change', newPageSize);
+ },
},
+ PAGE_SIZE_STORAGE_KEY,
};
</script>
@@ -353,24 +366,38 @@ export default {
<slot v-else name="empty-state"></slot>
</template>
- <div v-if="showPaginationControls && useKeysetPagination" class="gl-text-center gl-mt-3">
+ <div class="gl-text-center gl-mt-6 gl-relative">
<gl-keyset-pagination
+ v-if="showPaginationControls && useKeysetPagination"
:has-next-page="hasNextPage"
:has-previous-page="hasPreviousPage"
@next="$emit('next-page')"
@prev="$emit('previous-page')"
/>
+ <gl-pagination
+ v-else-if="showPaginationControls"
+ :per-page="defaultPageSize"
+ :total-items="totalItems"
+ :value="currentPage"
+ :prev-page="previousPage"
+ :next-page="nextPage"
+ align="center"
+ class="gl-pagination gl-mt-3"
+ @input="$emit('page-change', $event)"
+ />
+
+ <local-storage-sync
+ v-if="showPageSizeChangeControls"
+ :value="defaultPageSize"
+ :storage-key="$options.PAGE_SIZE_STORAGE_KEY"
+ @input="handlePageSizeChange"
+ >
+ <page-size-selector
+ :value="defaultPageSize"
+ class="gl-absolute gl-right-0"
+ @input="handlePageSizeChange"
+ />
+ </local-storage-sync>
</div>
- <gl-pagination
- v-else-if="showPaginationControls"
- :per-page="defaultPageSize"
- :total-items="totalItems"
- :value="currentPage"
- :prev-page="previousPage"
- :next-page="nextPage"
- align="center"
- class="gl-pagination gl-mt-3"
- @input="$emit('page-change', $event)"
- />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/list/constants.js b/app/assets/javascripts/vue_shared/issuable/list/constants.js
index be9afc0610d..507f333a34e 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/constants.js
+++ b/app/assets/javascripts/vue_shared/issuable/list/constants.js
@@ -56,3 +56,5 @@ export const IssuableTypes = {
export const DEFAULT_PAGE_SIZE = 20;
export const DEFAULT_SKELETON_COUNT = 5;
+
+export const PAGE_SIZE_STORAGE_KEY = 'issuable_list_page_size';
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue
index f57b5b2deb4..d4e9120ff17 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue
@@ -37,7 +37,11 @@ export default {
</script>
<template>
- <div class="description" :class="{ 'js-task-list-container': canEdit && enableTaskList }">
+ <div
+ class="description"
+ :class="{ 'js-task-list-container': canEdit && enableTaskList }"
+ data-qa-selector="description_content"
+ >
<div ref="gfmContainer" v-safe-html="issuable.descriptionHtml" class="md"></div>
<textarea
v-if="issuable.description && enableTaskList"
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue
index 33dca3e9332..2fc1f935501 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue
@@ -123,7 +123,6 @@ export default {
:placeholder="__('Title')"
:aria-label="__('Title')"
:autofocus="true"
- class="qa-title-input"
@keydown="handleKeydown($event, 'title')"
/>
</gl-form-group>
@@ -149,7 +148,7 @@ export default {
:data-supports-quick-actions="enableAutocomplete"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
- class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
dir="auto"
@keydown="handleKeydown($event, 'description')"
></textarea>
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
index f035795a045..cdc5903b934 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
@@ -112,7 +112,7 @@ export default {
<gl-icon v-if="statusIcon" :name="statusIcon" :class="statusIconClass" />
<span class="gl-display-none gl-sm-display-block"><slot name="status-badge"></slot></span>
</gl-badge>
- <div class="issuable-meta gl-display-flex gl-align-items-center d-md-inline-block">
+ <div class="issuable-meta gl-display-flex! gl-align-items-center">
<div v-if="blocked || confidential" class="gl-display-inline-block">
<div v-if="blocked" data-testid="blocked" class="issuable-warning-icon inline">
<gl-icon name="lock" :aria-label="__('Blocked')" />
@@ -139,13 +139,15 @@ export default {
:size="24"
:src="author.avatarUrl"
:label="author.name"
- class="d-none d-sm-inline-flex gl-mx-1"
+ :class="[{ 'gl-display-none': !isAuthorExternal }, 'gl-sm-display-inline-flex gl-mx-1']"
>
<template #meta>
- <gl-icon v-if="isAuthorExternal" name="external-link" />
+ <gl-icon v-if="isAuthorExternal" name="external-link" class="gl-ml-1" />
</template>
</gl-avatar-labeled>
- <strong class="author d-sm-none d-inline">@{{ author.username }}</strong>
+ <strong v-if="author.username" class="author gl-display-inline gl-sm-display-none!"
+ >@{{ author.username }}</strong
+ >
</gl-avatar-link>
<span
v-if="taskCompletionStatus && hasTasks"
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
index 3d7c71ce974..35124bd15d2 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
@@ -64,8 +64,9 @@ export default {
<div class="title-container">
<h1
v-safe-html="issuable.titleHtml || issuable.title"
- class="title qa-title gl-font-size-h-display"
+ class="title gl-font-size-h-display"
dir="auto"
+ data-qa-selector="title_content"
data-testid="title"
></h1>
<gl-button
@@ -74,7 +75,7 @@ export default {
:title="$options.i18n.editTitleAndDescription"
:aria-label="$options.i18n.editTitleAndDescription"
icon="pencil"
- class="btn-edit js-issuable-edit qa-edit-button"
+ class="btn-edit js-issuable-edit"
@click="$emit('edit-issuable', $event)"
/>
</div>
diff --git a/app/assets/javascripts/work_items/components/item_state.vue b/app/assets/javascripts/work_items/components/item_state.vue
index 69670d3471c..2dc8e3a1101 100644
--- a/app/assets/javascripts/work_items/components/item_state.vue
+++ b/app/assets/javascripts/work_items/components/item_state.vue
@@ -54,7 +54,7 @@ export default {
:label-for="$options.labelId"
label-cols="3"
label-cols-lg="2"
- label-class="gl-pb-0!"
+ label-class="gl-pb-0! gl-overflow-wrap-break"
class="gl-align-items-center"
>
<gl-form-select
diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue
index ce2fa158596..1cdc9c28f05 100644
--- a/app/assets/javascripts/work_items/components/item_title.vue
+++ b/app/assets/javascripts/work_items/components/item_title.vue
@@ -1,5 +1,4 @@
<script>
-import { escape } from 'lodash';
import { __ } from '~/locale';
export default {
@@ -21,15 +20,11 @@ export default {
},
},
methods: {
- getSanitizedTitle(inputEl) {
- const { innerText } = inputEl;
- return escape(innerText);
- },
handleBlur({ target }) {
- this.$emit('title-changed', this.getSanitizedTitle(target));
+ this.$emit('title-changed', target.innerText);
},
handleInput({ target }) {
- this.$emit('title-input', this.getSanitizedTitle(target));
+ this.$emit('title-input', target.innerText);
},
handleSubmit() {
this.$refs.titleEl.blur();
@@ -40,7 +35,7 @@ export default {
<template>
<h2
- class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5 gl-w-full"
+ class="gl-font-weight-normal gl-sm-font-weight-bold gl-mb-5 gl-mt-0 gl-w-full"
:class="{ 'gl-cursor-not-allowed': disabled }"
aria-labelledby="item-title"
>
diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue
index 31e4a932c5a..77002eeaf55 100644
--- a/app/assets/javascripts/work_items/components/work_item_actions.vue
+++ b/app/assets/javascripts/work_items/components/work_item_actions.vue
@@ -5,7 +5,7 @@ import Tracking from '~/tracking';
export default {
i18n: {
- deleteWorkItem: s__('WorkItem|Delete work item'),
+ deleteTask: s__('WorkItem|Delete task'),
},
components: {
GlDropdown,
@@ -54,7 +54,7 @@ export default {
right
>
<gl-dropdown-item v-gl-modal="'work-item-confirm-delete'">{{
- $options.i18n.deleteWorkItem
+ $options.i18n.deleteTask
}}</gl-dropdown-item>
</gl-dropdown>
<gl-modal
@@ -66,9 +66,7 @@ export default {
@hide="handleCancelDeleteWorkItem"
>
{{
- s__(
- 'WorkItem|Are you sure you want to delete the work item? This action cannot be reversed.',
- )
+ s__('WorkItem|Are you sure you want to delete the task? This action cannot be reversed.')
}}
</gl-modal>
</div>
diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue
index 4d1c171772e..9ff424aa20f 100644
--- a/app/assets/javascripts/work_items/components/work_item_assignees.vue
+++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue
@@ -1,10 +1,35 @@
<script>
-import { GlTokenSelector, GlIcon, GlAvatar, GlLink } from '@gitlab/ui';
+import {
+ GlTokenSelector,
+ GlIcon,
+ GlAvatar,
+ GlLink,
+ GlSkeletonLoader,
+ GlButton,
+ GlDropdownItem,
+ GlDropdownDivider,
+} from '@gitlab/ui';
+import { debounce } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql';
+import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
+import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
+import { n__, s__ } from '~/locale';
+import Tracking from '~/tracking';
+import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql';
+import { i18n, TRACKING_CATEGORY_SHOW } from '../constants';
-function isClosingIcon(el) {
- return el?.classList.contains('gl-token-close');
+function isTokenSelectorElement(el) {
+ return el?.classList.contains('gl-token-close') || el?.classList.contains('dropdown-item');
+}
+
+function addClass(el) {
+ return {
+ ...el,
+ class: 'gl-bg-transparent',
+ };
}
export default {
@@ -13,7 +38,15 @@ export default {
GlIcon,
GlAvatar,
GlLink,
+ GlSkeletonLoader,
+ GlButton,
+ SidebarParticipant,
+ InviteMembersTrigger,
+ GlDropdownItem,
+ GlDropdownDivider,
},
+ mixins: [Tracking.mixin()],
+ inject: ['fullPath'],
props: {
workItemId: {
type: String,
@@ -23,67 +56,188 @@ export default {
type: Array,
required: true,
},
+ allowsMultipleAssignees: {
+ type: Boolean,
+ required: true,
+ },
+ workItemType: {
+ type: String,
+ required: true,
+ },
+ canUpdate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
isEditing: false,
- localAssignees: this.assignees.map((assignee) => ({
- ...assignee,
- class: 'gl-bg-transparent!',
- })),
+ searchStarted: false,
+ localAssignees: this.assignees.map(addClass),
+ searchKey: '',
+ searchUsers: [],
+ currentUser: null,
};
},
+ apollo: {
+ searchUsers: {
+ query() {
+ return userSearchQuery;
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ search: this.searchKey,
+ };
+ },
+ skip() {
+ return !this.searchStarted;
+ },
+ update(data) {
+ return data.workspace?.users?.nodes.map((node) => addClass({ ...node, ...node.user }));
+ },
+ error() {
+ this.$emit('error', i18n.fetchError);
+ },
+ },
+ currentUser: {
+ query: currentUserQuery,
+ },
+ },
computed: {
- assigneeIds() {
- return this.localAssignees.map((assignee) => assignee.id);
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_assignees',
+ property: `type_${this.workItemType}`,
+ };
},
assigneeListEmpty() {
return this.assignees.length === 0;
},
containerClass() {
- return !this.isEditing ? 'gl-shadow-none! gl-bg-transparent!' : '';
+ return !this.isEditing ? 'gl-shadow-none!' : '';
+ },
+ isLoadingUsers() {
+ return this.$apollo.queries.searchUsers.loading;
+ },
+ assigneeText() {
+ return n__('WorkItem|Assignee', 'WorkItem|Assignees', this.localAssignees.length);
+ },
+ dropdownItems() {
+ if (this.currentUser && this.searchEmpty) {
+ if (this.searchUsers.some((user) => user.username === this.currentUser.username)) {
+ return this.moveCurrentUserToStart(this.searchUsers);
+ }
+ return [this.currentUser, ...this.searchUsers];
+ }
+ return this.searchUsers;
+ },
+ searchEmpty() {
+ return this.searchKey.length === 0;
+ },
+ addAssigneesText() {
+ return this.allowsMultipleAssignees
+ ? s__('WorkItem|Add assignees')
+ : s__('WorkItem|Add assignee');
},
},
+ watch: {
+ assignees(newVal) {
+ if (!this.isEditing) {
+ this.localAssignees = newVal.map(addClass);
+ }
+ },
+ },
+ created() {
+ this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ },
methods: {
getUserId(id) {
return getIdFromGraphQLId(id);
},
- setAssignees(e) {
- if (isClosingIcon(e.relatedTarget) || !this.isEditing) return;
+ handleAssigneesInput(assignees) {
+ if (!this.allowsMultipleAssignees) {
+ this.localAssignees = assignees.length > 0 ? [assignees[assignees.length - 1]] : [];
+ this.isEditing = false;
+ return;
+ }
+ this.localAssignees = assignees;
+ this.focusTokenSelector();
+ },
+ handleBlur(e) {
+ if (isTokenSelectorElement(e.relatedTarget) || !this.isEditing) return;
this.isEditing = false;
+ this.setAssignees(this.localAssignees);
+ },
+ setAssignees(assignees) {
this.$apollo.mutate({
mutation: localUpdateWorkItemMutation,
variables: {
input: {
id: this.workItemId,
- assigneeIds: this.assigneeIds,
+ assignees,
},
},
});
+ this.track('updated_assignees');
},
- async focusTokenSelector() {
+ handleFocus() {
this.isEditing = true;
+ this.searchStarted = true;
+ },
+ async focusTokenSelector() {
+ this.handleFocus();
await this.$nextTick();
this.$refs.tokenSelector.focusTextInput();
},
+ handleMouseOver() {
+ this.timeout = setTimeout(() => {
+ this.searchStarted = true;
+ }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ },
+ handleMouseOut() {
+ clearTimeout(this.timeout);
+ },
+ setSearchKey(value) {
+ this.searchKey = value;
+ },
+ moveCurrentUserToStart(users = []) {
+ if (this.currentUser) {
+ return [this.currentUser, ...users.filter((user) => user.id !== this.currentUser.id)];
+ }
+ return users;
+ },
+ closeDropdown() {
+ this.$refs.tokenSelector.closeDropdown();
+ },
},
};
</script>
<template>
- <div class="gl-display-flex gl-mb-4 work-item-assignees gl-relative">
- <span class="gl-font-weight-bold gl-w-15 gl-pt-2" data-testid="assignees-title">{{
- __('Assignee(s)')
- }}</span>
+ <div class="form-row gl-mb-5 work-item-assignees gl-relative">
+ <span
+ class="gl-font-weight-bold col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break"
+ data-testid="assignees-title"
+ >{{ assigneeText }}</span
+ >
<gl-token-selector
ref="tokenSelector"
- v-model="localAssignees"
- hide-dropdown-with-no-items
+ :selected-tokens="localAssignees"
:container-class="containerClass"
- class="gl-w-full gl-border gl-border-white gl-hover-border-gray-200 gl-rounded-base"
- @token-remove="focusTokenSelector"
- @focus="isEditing = true"
- @blur="setAssignees"
+ class="assignees-selector gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0!"
+ :class="{ 'gl-hover-border-gray-200': canUpdate }"
+ :dropdown-items="dropdownItems"
+ :loading="isLoadingUsers"
+ :view-only="!canUpdate"
+ @input="handleAssigneesInput"
+ @text-input="debouncedSearchKeyUpdate"
+ @focus="handleFocus"
+ @blur="handleBlur"
+ @mouseover.native="handleMouseOver"
+ @mouseout.native="handleMouseOut"
>
<template #empty-placeholder>
<div
@@ -91,7 +245,15 @@ export default {
data-testid="empty-state"
>
<gl-icon name="profile" />
- <span class="gl-ml-2">{{ __('Add assignees') }}</span>
+ <span class="gl-ml-2 gl-mr-4">{{ addAssigneesText }}</span>
+ <gl-button
+ v-if="currentUser"
+ size="small"
+ class="assign-myself"
+ data-testid="assign-self"
+ @click.stop="setAssignees([currentUser])"
+ >{{ __('Assign myself') }}</gl-button
+ >
</div>
</template>
<template #token-content="{ token }">
@@ -106,6 +268,29 @@ export default {
<span class="gl-pl-2">{{ token.name }}</span>
</gl-link>
</template>
+ <template #dropdown-item-content="{ dropdownItem }">
+ <sidebar-participant :user="dropdownItem" />
+ </template>
+ <template #loading-content>
+ <gl-skeleton-loader :height="170">
+ <rect width="380" height="20" x="10" y="15" rx="4" />
+ <rect width="280" height="20" x="10" y="50" rx="4" />
+ <rect width="380" height="20" x="10" y="95" rx="4" />
+ <rect width="280" height="20" x="10" y="130" rx="4" />
+ </gl-skeleton-loader>
+ </template>
+ <template #dropdown-footer>
+ <gl-dropdown-divider />
+ <gl-dropdown-item @click="closeDropdown">
+ <invite-members-trigger
+ :display-text="__('Invite members')"
+ trigger-element="side-nav"
+ icon="plus"
+ trigger-source="work-item-assignees-dropdown"
+ classes="gl-display-block gl-text-body! gl-hover-text-decoration-none gl-pb-2"
+ />
+ </gl-dropdown-item>
+ </template>
</gl-token-selector>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue
index 5a85fcdd7ac..90e3cd45cb4 100644
--- a/app/assets/javascripts/work_items/components/work_item_description.vue
+++ b/app/assets/javascripts/work_items/components/work_item_description.vue
@@ -35,7 +35,7 @@ export default {
isEditing: false,
isSubmitting: false,
isSubmittingWithKeydown: false,
- desc: '',
+ descriptionText: '',
};
},
apollo: {
@@ -71,16 +71,17 @@ export default {
descriptionHtml() {
return this.workItemDescription?.descriptionHtml;
},
- descriptionText: {
- get() {
- return this.desc;
- },
- set(desc) {
- this.desc = desc;
- },
+ descriptionEmpty() {
+ return this.descriptionHtml?.trim() === '';
},
workItemDescription() {
- return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_DESCRIPTION);
+ const descriptionWidget = this.workItem?.widgets?.find(
+ (widget) => widget.type === WIDGET_TYPE_DESCRIPTION,
+ );
+ return {
+ ...descriptionWidget,
+ description: descriptionWidget?.description || '',
+ };
},
workItemType() {
return this.workItem?.workItemType?.name;
@@ -95,14 +96,14 @@ export default {
async startEditing() {
this.isEditing = true;
- this.desc = getDraft(this.autosaveKey) || this.workItemDescription?.description || '';
+ this.descriptionText = getDraft(this.autosaveKey) || this.workItemDescription?.description;
await this.$nextTick();
this.$refs.textarea.focus();
},
async cancelEditing() {
- const isDirty = this.desc !== this.workItemDescription?.description;
+ const isDirty = this.descriptionText !== this.workItemDescription?.description;
if (isDirty) {
const msg = s__('WorkItem|Are you sure you want to cancel editing?');
@@ -125,7 +126,7 @@ export default {
return;
}
- updateDraft(this.autosaveKey, this.desc);
+ updateDraft(this.autosaveKey, this.descriptionText);
},
async updateWorkItem(event) {
if (event.key) {
@@ -171,25 +172,10 @@ export default {
<template>
<gl-form-group
v-if="isEditing"
- class="gl-pt-5 gl-mb-5 gl-mt-0! gl-border-t! gl-border-b"
+ class="gl-my-5"
:label="__('Description')"
label-for="work-item-description"
- label-class="gl-float-left"
>
- <div class="gl-display-flex gl-justify-content-flex-end">
- <gl-button class="gl-ml-auto" data-testid="cancel" @click="cancelEditing">{{
- __('Cancel')
- }}</gl-button>
- <gl-button
- class="js-no-auto-disable gl-ml-4"
- category="primary"
- variant="confirm"
- :loading="isSubmitting"
- data-testid="save-description"
- @click="updateWorkItem"
- >{{ __('Save') }}</gl-button
- >
- </div>
<markdown-field
can-attach-file
:textarea-value="descriptionText"
@@ -216,19 +202,35 @@ export default {
></textarea>
</template>
</markdown-field>
- </gl-form-group>
- <div v-else class="gl-pt-5 gl-mb-5 gl-border-t gl-border-b">
+
<div class="gl-display-flex">
- <h3 class="gl-font-base gl-mt-0">{{ __('Description') }}</h3>
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :loading="isSubmitting"
+ data-testid="save-description"
+ @click="updateWorkItem"
+ >{{ __('Save') }}</gl-button
+ >
+ <gl-button category="tertiary" class="gl-ml-3" data-testid="cancel" @click="cancelEditing">{{
+ __('Cancel')
+ }}</gl-button>
+ </div>
+ </gl-form-group>
+ <div v-else class="gl-mb-5">
+ <div class="gl-display-flex gl-align-items-center gl-mb-5">
+ <h3 class="gl-font-base gl-my-0">{{ __('Description') }}</h3>
<gl-button
v-if="canEdit"
class="gl-ml-auto"
icon="pencil"
data-testid="edit-description"
+ :aria-label="__('Edit')"
@click="startEditing"
- >{{ __('Edit') }}</gl-button
- >
+ />
</div>
- <div v-safe-html="descriptionHtml" class="md gl-mb-5"></div>
+
+ <div v-if="descriptionEmpty" class="gl-text-secondary gl-mb-5">{{ __('None') }}</div>
+ <div v-else v-safe-html="descriptionHtml" class="md gl-mb-5 gl-min-h-8"></div>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue
index 5272df2d53f..ad90fe88947 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -1,11 +1,15 @@
<script>
-import { GlAlert, GlSkeletonLoader } from '@gitlab/ui';
+import { GlAlert, GlSkeletonLoader, GlIcon, GlButton } from '@gitlab/ui';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import {
i18n,
- WIDGET_TYPE_ASSIGNEE,
+ WIDGET_TYPE_ASSIGNEES,
+ WIDGET_TYPE_LABELS,
WIDGET_TYPE_DESCRIPTION,
WIDGET_TYPE_WEIGHT,
+ WIDGET_TYPE_HIERARCHY,
+ WORK_ITEM_VIEWED_STORAGE_KEY,
} from '../constants';
import workItemQuery from '../graphql/work_item.query.graphql';
import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql';
@@ -14,22 +18,34 @@ import WorkItemState from './work_item_state.vue';
import WorkItemTitle from './work_item_title.vue';
import WorkItemDescription from './work_item_description.vue';
import WorkItemAssignees from './work_item_assignees.vue';
+import WorkItemLabels from './work_item_labels.vue';
import WorkItemWeight from './work_item_weight.vue';
+import WorkItemInformation from './work_item_information.vue';
export default {
i18n,
components: {
GlAlert,
+ GlButton,
GlSkeletonLoader,
+ GlIcon,
WorkItemAssignees,
WorkItemActions,
WorkItemDescription,
+ WorkItemLabels,
WorkItemTitle,
WorkItemState,
WorkItemWeight,
+ WorkItemInformation,
+ LocalStorageSync,
},
mixins: [glFeatureFlagMixin()],
props: {
+ isModal: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
workItemId: {
type: String,
required: false,
@@ -45,6 +61,7 @@ export default {
return {
error: undefined,
workItem: {},
+ showInfoBanner: true,
};
},
apollo: {
@@ -91,17 +108,40 @@ export default {
return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_DESCRIPTION);
},
workItemAssignees() {
- return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_ASSIGNEE);
+ return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_ASSIGNEES);
+ },
+ workItemLabels() {
+ return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS);
},
workItemWeight() {
return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_WEIGHT);
},
+ workItemHierarchy() {
+ return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY);
+ },
+ parentWorkItem() {
+ return this.workItemHierarchy?.parent;
+ },
+ parentUrl() {
+ return `../../issues/${this.parentWorkItem?.iid}`;
+ },
+ },
+ beforeDestroy() {
+ /** make sure that if the user has not even dismissed the alert ,
+ * should no be able to see the information next time and update the local storage * */
+ this.dismissBanner();
},
+ methods: {
+ dismissBanner() {
+ this.showInfoBanner = false;
+ },
+ },
+ WORK_ITEM_VIEWED_STORAGE_KEY,
};
</script>
<template>
- <section>
+ <section class="gl-pt-5">
<gl-alert v-if="error" variant="danger" @dismiss="error = undefined">
{{ error }}
</gl-alert>
@@ -113,39 +153,95 @@ export default {
</gl-skeleton-loader>
</div>
<template v-else>
- <div class="gl-display-flex gl-align-items-start">
- <work-item-title
- :work-item-id="workItem.id"
- :work-item-title="workItem.title"
- :work-item-type="workItemType"
- :work-item-parent-id="workItemParentId"
- class="gl-mr-5"
- @error="error = $event"
- />
+ <div class="gl-display-flex gl-align-items-center">
+ <ul
+ v-if="parentWorkItem"
+ class="list-unstyled gl-display-flex gl-mr-auto"
+ data-testid="work-item-parent"
+ >
+ <li class="gl-ml-n4">
+ <gl-button icon="issues" category="tertiary" :href="parentUrl">{{
+ parentWorkItem.title
+ }}</gl-button>
+ <gl-icon name="chevron-right" :size="16" />
+ </li>
+ <li class="gl-px-4 gl-py-3 gl-line-height-0">
+ <gl-icon name="task-done" />
+ {{ workItemType }}
+ </li>
+ </ul>
+ <span
+ v-else
+ class="gl-font-weight-bold gl-text-secondary gl-mr-auto"
+ data-testid="work-item-type"
+ >{{ workItemType }}</span
+ >
<work-item-actions
:work-item-id="workItem.id"
:can-delete="canDelete"
- class="gl-ml-auto gl-mt-6"
@deleteWorkItem="$emit('deleteWorkItem')"
@error="error = $event"
/>
+ <gl-button
+ v-if="isModal"
+ category="tertiary"
+ data-testid="work-item-close"
+ icon="close"
+ :aria-label="__('Close')"
+ @click="$emit('close')"
+ />
</div>
- <template v-if="workItemsMvc2Enabled">
- <work-item-assignees
- v-if="workItemAssignees"
- :work-item-id="workItem.id"
- :assignees="workItemAssignees.nodes"
+ <local-storage-sync
+ v-model="showInfoBanner"
+ :storage-key="$options.WORK_ITEM_VIEWED_STORAGE_KEY"
+ >
+ <work-item-information
+ v-if="showInfoBanner"
+ :show-info-banner="showInfoBanner"
+ @work-item-banner-dismissed="dismissBanner"
/>
- <work-item-weight v-if="workItemWeight" :weight="workItemWeight.weight" />
- </template>
+ </local-storage-sync>
+ <work-item-title
+ :work-item-id="workItem.id"
+ :work-item-title="workItem.title"
+ :work-item-type="workItemType"
+ :work-item-parent-id="workItemParentId"
+ @error="error = $event"
+ />
<work-item-state
:work-item="workItem"
:work-item-parent-id="workItemParentId"
@error="error = $event"
/>
+ <template v-if="workItemsMvc2Enabled">
+ <work-item-assignees
+ v-if="workItemAssignees"
+ :can-update="canUpdate"
+ :work-item-id="workItem.id"
+ :assignees="workItemAssignees.assignees.nodes"
+ :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees"
+ :work-item-type="workItemType"
+ @error="error = $event"
+ />
+ <work-item-labels
+ v-if="workItemLabels"
+ :work-item-id="workItem.id"
+ :can-update="canUpdate"
+ @error="error = $event"
+ />
+ <work-item-weight
+ v-if="workItemWeight"
+ class="gl-mb-5"
+ :can-update="canUpdate"
+ :weight="workItemWeight.weight"
+ :work-item-id="workItem.id"
+ :work-item-type="workItemType"
+ />
+ </template>
<work-item-description
v-if="hasDescriptionWidget"
:work-item-id="workItem.id"
+ class="gl-pt-5"
@error="error = $event"
/>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
index d1c8022ac57..df7c6cab7ef 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
@@ -80,13 +80,16 @@ export default {
.catch((e) => {
this.error =
e.message ||
- s__('WorkItem|Something went wrong when deleting the work item. Please try again.');
+ s__('WorkItem|Something went wrong when deleting the task. Please try again.');
});
},
closeModal() {
this.error = '';
this.$emit('close');
},
+ hide() {
+ this.$refs.modal.hide();
+ },
setErrorMessage(message) {
this.error = message;
},
@@ -104,7 +107,6 @@ export default {
size="lg"
modal-id="work-item-detail-modal"
header-class="gl-p-0 gl-pb-2!"
- body-class="gl-pb-6!"
@hide="closeModal"
>
<gl-alert v-if="error" variant="danger" @dismiss="error = false">
@@ -112,9 +114,11 @@ export default {
</gl-alert>
<work-item-detail
+ is-modal
:work-item-parent-id="issueGid"
:work-item-id="workItemId"
class="gl-p-5 gl-mt-n3"
+ @close="hide"
@deleteWorkItem="deleteWorkItem"
/>
</gl-modal>
diff --git a/app/assets/javascripts/work_items/components/work_item_information.vue b/app/assets/javascripts/work_items/components/work_item_information.vue
new file mode 100644
index 00000000000..2ff7ba169ea
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_information.vue
@@ -0,0 +1,57 @@
+<script>
+import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+export default {
+ i18n: {
+ learnTasksButtonText: s__('WorkItem|Learn about tasks'),
+ workItemsText: s__('WorkItem|work items'),
+ tasksInformationTitle: s__('WorkItem|Introducing tasks'),
+ tasksInformationBody: s__(
+ 'WorkItem|A task provides the ability to break down your work into smaller pieces tied to an issue. Tasks are the first items using our new %{workItemsLink} objects. Additional work item types will be coming soon.',
+ ),
+ },
+ helpPageLinks: {
+ tasksDocLinkPath: helpPagePath('user/tasks'),
+ workItemsLinkPath: helpPagePath(`development/work_items`),
+ },
+ components: {
+ GlAlert,
+ GlSprintf,
+ GlLink,
+ },
+ props: {
+ showInfoBanner: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ emits: ['work-item-banner-dismissed'],
+};
+</script>
+
+<template>
+ <section class="gl-display-block gl-mb-2">
+ <gl-alert
+ v-if="showInfoBanner"
+ variant="tip"
+ :title="$options.i18n.tasksInformationTitle"
+ :primary-button-link="$options.helpPageLinks.tasksDocLinkPath"
+ :primary-button-text="$options.i18n.learnTasksButtonText"
+ data-testid="work-item-information"
+ class="gl-mt-3"
+ @dismiss="$emit('work-item-banner-dismissed')"
+ >
+ <gl-sprintf :message="$options.i18n.tasksInformationBody">
+ <template #workItemsLink>
+ <gl-link :href="$options.helpPageLinks.workItemsLinkPath">{{
+ $options.i18n.workItemsText
+ }}</gl-link>
+ </template>
+ ></gl-sprintf
+ >
+ </gl-alert>
+ </section>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue
new file mode 100644
index 00000000000..78ed67998d7
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_labels.vue
@@ -0,0 +1,246 @@
+<script>
+import { GlTokenSelector, GlLabel, GlSkeletonLoader } from '@gitlab/ui';
+import { debounce } from 'lodash';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import Tracking from '~/tracking';
+import labelSearchQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql';
+import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import { isScopedLabel, scopedLabelKey } from '~/lib/utils/common_utils';
+import workItemQuery from '../graphql/work_item.query.graphql';
+import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql';
+
+import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_LABELS } from '../constants';
+
+function isTokenSelectorElement(el) {
+ return el?.classList.contains('gl-label-close') || el?.classList.contains('dropdown-item');
+}
+
+function addClass(el) {
+ return {
+ ...el,
+ class: 'gl-bg-transparent',
+ };
+}
+
+export default {
+ components: {
+ GlTokenSelector,
+ GlLabel,
+ GlSkeletonLoader,
+ LabelItem,
+ },
+ mixins: [Tracking.mixin()],
+ inject: ['fullPath'],
+ props: {
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ canUpdate: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isEditing: false,
+ searchStarted: false,
+ localLabels: [],
+ searchKey: '',
+ searchLabels: [],
+ };
+ },
+ apollo: {
+ workItem: {
+ query: workItemQuery,
+ variables() {
+ return {
+ id: this.workItemId,
+ };
+ },
+ skip() {
+ return !this.workItemId;
+ },
+ error() {
+ this.$emit('error', i18n.fetchError);
+ },
+ },
+ searchLabels: {
+ query: labelSearchQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ search: this.searchKey,
+ };
+ },
+ skip() {
+ return !this.searchStarted;
+ },
+ update(data) {
+ return data.workspace?.labels?.nodes.map((node) => addClass({ ...node, ...node.label }));
+ },
+ error() {
+ this.$emit('error', i18n.fetchError);
+ },
+ },
+ },
+ computed: {
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_labels',
+ property: `type_${this.workItem.workItemType?.name}`,
+ };
+ },
+ allowScopedLabels() {
+ return this.labelsWidget.allowScopedLabels;
+ },
+ listEmpty() {
+ return this.labels.length === 0;
+ },
+ containerClass() {
+ return !this.isEditing ? 'gl-shadow-none!' : '';
+ },
+ isLoading() {
+ return this.$apollo.queries.searchLabels.loading;
+ },
+ labelsWidget() {
+ return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS);
+ },
+ labels() {
+ return this.labelsWidget?.nodes || [];
+ },
+ },
+ watch: {
+ labels(newVal) {
+ if (!this.isEditing) {
+ this.localLabels = newVal.map(addClass);
+ }
+ },
+ },
+ created() {
+ this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ },
+ methods: {
+ getId(id) {
+ return getIdFromGraphQLId(id);
+ },
+ removeLabel({ id }) {
+ this.localLabels = this.localLabels.filter((label) => label.id !== id);
+ },
+ setLabels(event) {
+ this.searchKey = '';
+ if (isTokenSelectorElement(event.relatedTarget) || !this.isEditing) return;
+ this.isEditing = false;
+ this.$apollo
+ .mutate({
+ mutation: localUpdateWorkItemMutation,
+ variables: {
+ input: {
+ id: this.workItemId,
+ labels: this.localLabels,
+ },
+ },
+ })
+ .catch((e) => {
+ this.$emit('error', e);
+ });
+ this.track('updated_labels');
+ },
+ handleFocus() {
+ this.isEditing = true;
+ this.searchStarted = true;
+ },
+ async focusTokenSelector(labels) {
+ if (this.allowScopedLabels) {
+ const newLabel = labels[labels.length - 1];
+ const existingLabels = labels.slice(0, labels.length - 1);
+
+ const newLabelKey = scopedLabelKey(newLabel);
+
+ const removeLabelsWithSameScope = existingLabels.filter((label) => {
+ const sameKey = newLabelKey === scopedLabelKey(label);
+ return !sameKey;
+ });
+
+ this.localLabels = [...removeLabelsWithSameScope, newLabel];
+ }
+ this.handleFocus();
+ await this.$nextTick();
+ this.$refs.tokenSelector.focusTextInput();
+ },
+ handleMouseOver() {
+ this.timeout = setTimeout(() => {
+ this.searchStarted = true;
+ }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ },
+ handleMouseOut() {
+ clearTimeout(this.timeout);
+ },
+ setSearchKey(value) {
+ this.searchKey = value;
+ },
+ scopedLabel(label) {
+ return this.allowScopedLabels && isScopedLabel(label);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="form-row gl-mb-5 work-item-labels gl-relative">
+ <span
+ class="gl-font-weight-bold gl-mt-2 col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break"
+ data-testid="labels-title"
+ >{{ __('Labels') }}</span
+ >
+ <gl-token-selector
+ ref="tokenSelector"
+ v-model="localLabels"
+ :container-class="containerClass"
+ :dropdown-items="searchLabels"
+ :loading="isLoading"
+ :view-only="!canUpdate"
+ class="gl-flex-grow-1 gl-border gl-border-white gl-hover-border-gray-200 gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2!"
+ @input="focusTokenSelector"
+ @text-input="debouncedSearchKeyUpdate"
+ @focus="handleFocus"
+ @blur="setLabels"
+ @mouseover.native="handleMouseOver"
+ @mouseout.native="handleMouseOut"
+ >
+ <template #empty-placeholder>
+ <div
+ class="add-labels gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-gray-400 gl-pr-4 gl-top-2"
+ data-testid="empty-state"
+ >
+ <span v-if="canUpdate" class="gl-ml-2">{{ __('Select labels') }}</span>
+ <span v-else class="gl-ml-2">{{ __('None') }}</span>
+ </div>
+ </template>
+ <template #token-content="{ token }">
+ <gl-label
+ :data-qa-label-name="token.title"
+ :title="token.title"
+ :description="token.description"
+ :background-color="token.color"
+ :scoped="scopedLabel(token)"
+ :show-close-button="canUpdate"
+ @close="removeLabel(token)"
+ />
+ </template>
+ <template #dropdown-item-content="{ dropdownItem }">
+ <label-item :label="dropdownItem" />
+ </template>
+ <template #loading-content>
+ <gl-skeleton-loader :height="170">
+ <rect width="380" height="20" x="10" y="15" rx="4" />
+ <rect width="280" height="20" x="10" y="50" rx="4" />
+ <rect width="380" height="20" x="10" y="95" rx="4" />
+ <rect width="280" height="20" x="10" y="130" rx="4" />
+ </gl-skeleton-loader>
+ </template>
+ </gl-token-selector>
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/index.js b/app/assets/javascripts/work_items/components/work_item_links/index.js
index 320a4a213e3..176f84f6c1a 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/index.js
+++ b/app/assets/javascripts/work_items/components/work_item_links/index.js
@@ -1,9 +1,11 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { GlToast } from '@gitlab/ui';
import createDefaultClient from '~/lib/graphql';
import WorkItemLinks from './work_item_links.vue';
Vue.use(VueApollo);
+Vue.use(GlToast);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
@@ -19,6 +21,7 @@ export default function initWorkItemLinks() {
if (!workItemLinksRoot) {
return;
}
+
// eslint-disable-next-line no-new
new Vue({
el: workItemLinksRoot,
@@ -27,6 +30,9 @@ export default function initWorkItemLinks() {
components: {
workItemLinks: WorkItemLinks,
},
+ provide: {
+ projectPath: workItemLinksRoot.dataset.projectPath,
+ },
render: (createElement) =>
createElement('work-item-links', {
props: {
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
index bdfff100333..89f086cfca5 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
@@ -11,6 +11,7 @@ import {
} from '../../constants';
import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql';
import WorkItemLinksForm from './work_item_links_form.vue';
+import WorkItemLinksMenu from './work_item_links_menu.vue';
export default {
components: {
@@ -19,6 +20,7 @@ export default {
GlIcon,
GlLoadingIcon,
WorkItemLinksForm,
+ WorkItemLinksMenu,
},
props: {
workItemId: {
@@ -77,6 +79,9 @@ export default {
isLoading() {
return this.$apollo.queries.children.loading;
},
+ childrenIds() {
+ return this.children.map((c) => c.id);
+ },
},
methods: {
badgeVariant(state) {
@@ -88,13 +93,16 @@ export default {
toggleAddForm() {
this.isShownAddForm = !this.isShownAddForm;
},
+ addChild(child) {
+ this.children = [child, ...this.children];
+ },
},
i18n: {
title: s__('WorkItem|Child items'),
emptyStateMessage: s__(
'WorkItem|No child items are currently assigned. Use child items to prioritize tasks that your team should complete in order to accomplish your goals!',
),
- addChildButtonLabel: s__('WorkItem|Add a child'),
+ addChildButtonLabel: s__('WorkItem|Add a task'),
},
WIDGET_TYPE_TASK_ICON: WIDGET_ICONS.TASK,
WORK_ITEM_STATUS_TEXT,
@@ -107,8 +115,16 @@ export default {
class="gl-p-4 gl-display-flex gl-justify-content-space-between"
:class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }"
>
- <h5 class="gl-m-0 gl-line-height-32">{{ $options.i18n.title }}</h5>
- <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-50 gl-pl-4">
+ <h5 class="gl-m-0 gl-line-height-32 gl-flex-grow-1">{{ $options.i18n.title }}</h5>
+ <gl-button
+ v-if="!isShownAddForm"
+ category="secondary"
+ data-testid="toggle-add-form"
+ @click="toggleAddForm"
+ >
+ {{ $options.i18n.addChildButtonLabel }}
+ </gl-button>
+ <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-50 gl-pl-4 gl-ml-3">
<gl-button
category="tertiary"
:icon="toggleIcon"
@@ -126,37 +142,38 @@ export default {
<gl-loading-icon v-if="isLoading" color="dark" class="gl-my-3" />
<template v-else>
- <div v-if="isChildrenEmpty" class="gl-px-8" data-testid="links-empty">
- <p>
+ <div v-if="isChildrenEmpty && !isShownAddForm" data-testid="links-empty">
+ <p class="gl-my-3">
{{ $options.i18n.emptyStateMessage }}
</p>
- <gl-button
- v-if="!isShownAddForm"
- category="secondary"
- variant="confirm"
- data-testid="toggle-add-form"
- @click="toggleAddForm"
- >
- {{ $options.i18n.addChildButtonLabel }}
- </gl-button>
- <work-item-links-form v-else data-testid="add-links-form" @cancel="toggleAddForm" />
</div>
+ <work-item-links-form
+ v-if="isShownAddForm"
+ data-testid="add-links-form"
+ :issuable-gid="issuableGid"
+ :children-ids="childrenIds"
+ @cancel="toggleAddForm"
+ @addWorkItemChild="addChild"
+ />
<div
v-for="child in children"
:key="child.id"
- class="gl-relative gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base"
+ class="gl-relative gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32"
data-testid="links-child"
>
<div>
<gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-3 gl-text-gray-700" />
<span class="gl-word-break-all">{{ child.title }}</span>
</div>
- <div class="gl-ml-0 gl-sm-ml-auto! gl-mt-3 gl-sm-mt-0">
+ <div
+ class="gl-ml-0 gl-sm-ml-auto! gl-mt-3 gl-sm-mt-0 gl-display-inline-flex gl-align-items-center"
+ >
<gl-badge :variant="badgeVariant(child.state)">
<span class="gl-sm-display-block">{{
$options.WORK_ITEM_STATUS_TEXT[child.state]
}}</span>
</gl-badge>
+ <work-item-links-menu :work-item-id="child.id" :parent-work-item-id="issuableGid" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
index 22728f58026..fadba0753db 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
@@ -1,27 +1,127 @@
<script>
-import { GlForm, GlFormInput, GlButton } from '@gitlab/ui';
+import { GlAlert, GlForm, GlFormCombobox, GlButton } from '@gitlab/ui';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { __, s__ } from '~/locale';
+import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql';
+import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
export default {
components: {
+ GlAlert,
GlForm,
- GlFormInput,
+ GlFormCombobox,
GlButton,
},
+ inject: ['projectPath'],
+ props: {
+ issuableGid: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ childrenIds: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ apollo: {
+ availableWorkItems: {
+ query: projectWorkItemsQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ searchTerm: this.search?.title || this.search,
+ types: ['TASK'],
+ };
+ },
+ skip() {
+ return this.search.length === 0;
+ },
+ update(data) {
+ return data.workspace.workItems.edges
+ .filter((wi) => !this.childrenIds.includes(wi.node.id))
+ .map((wi) => wi.node);
+ },
+ },
+ },
data() {
return {
- relatedWorkItem: '',
+ availableWorkItems: [],
+ search: '',
+ error: null,
};
},
+ methods: {
+ getIdFromGraphQLId,
+ unsetError() {
+ this.error = null;
+ },
+ addChild() {
+ this.$apollo
+ .mutate({
+ mutation: updateWorkItemMutation,
+ variables: {
+ input: {
+ id: this.issuableGid,
+ hierarchyWidget: {
+ childrenIds: [this.search.id],
+ },
+ },
+ },
+ })
+ .then(({ data }) => {
+ if (data.workItemUpdate?.errors?.length) {
+ [this.error] = data.workItemUpdate.errors;
+ } else {
+ this.unsetError();
+ this.$emit('addWorkItemChild', this.search);
+ }
+ })
+ .catch(() => {
+ this.error = this.$options.i18n.errorMessage;
+ })
+ .finally(() => {
+ this.search = '';
+ });
+ },
+ },
+ i18n: {
+ inputLabel: __('Children'),
+ errorMessage: s__(
+ 'WorkItem|Something went wrong when trying to add a child. Please try again.',
+ ),
+ },
};
</script>
<template>
- <gl-form @submit.prevent>
- <gl-form-input v-model="relatedWorkItem" class="gl-mb-4" />
- <gl-button type="submit" category="secondary" variant="confirm">
- {{ s__('WorkItem|Add') }}
+ <gl-form
+ class="gl-mb-3 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base"
+ >
+ <gl-alert v-if="error" variant="danger" class="gl-mb-3" @dismiss="unsetError">
+ {{ error }}
+ </gl-alert>
+ <gl-form-combobox
+ v-model="search"
+ :token-list="availableWorkItems"
+ match-value-to-attr="title"
+ class="gl-mb-4"
+ :label-text="$options.i18n.inputLabel"
+ label-sr-only
+ autofocus
+ >
+ <template #result="{ item }">
+ <div class="gl-display-flex">
+ <div class="gl-text-gray-400 gl-mr-4">{{ getIdFromGraphQLId(item.id) }}</div>
+ <div>{{ item.title }}</div>
+ </div>
+ </template>
+ </gl-form-combobox>
+ <gl-button category="secondary" data-testid="add-child-button" @click="addChild">
+ {{ s__('WorkItem|Add task') }}
</gl-button>
- <gl-button category="tertiary" class="gl-float-right" @click="$emit('cancel')">
+ <gl-button category="tertiary" @click="$emit('cancel')">
{{ s__('WorkItem|Cancel') }}
</gl-button>
</gl-form>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue
new file mode 100644
index 00000000000..6deb87c5dca
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue
@@ -0,0 +1,101 @@
+<script>
+import { GlIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { produce } from 'immer';
+import { s__ } from '~/locale';
+import changeWorkItemParentMutation from '../../graphql/change_work_item_parent_link.mutation.graphql';
+import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql';
+import { WIDGET_TYPE_HIERARCHY } from '../../constants';
+
+export default {
+ components: {
+ GlDropdownItem,
+ GlDropdown,
+ GlIcon,
+ },
+ props: {
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ parentWorkItemId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ activeToast: null,
+ };
+ },
+ methods: {
+ toggleChildFromCache(data, store) {
+ const sourceData = store.readQuery({
+ query: getWorkItemLinksQuery,
+ variables: { id: this.parentWorkItemId },
+ });
+
+ const newData = produce(sourceData, (draftState) => {
+ const widgetHierarchy = draftState.workItem.widgets.find(
+ (widget) => widget.type === WIDGET_TYPE_HIERARCHY,
+ );
+
+ const index = widgetHierarchy.children.nodes.findIndex(
+ (child) => child.id === this.workItemId,
+ );
+
+ if (index >= 0) {
+ widgetHierarchy.children.nodes.splice(index, 1);
+ } else {
+ widgetHierarchy.children.nodes.push(data.workItemUpdate.workItem);
+ }
+ });
+
+ store.writeQuery({
+ query: getWorkItemLinksQuery,
+ variables: { id: this.parentWorkItemId },
+ data: newData,
+ });
+ },
+ async addChild(data) {
+ const { data: resp } = await this.$apollo.mutate({
+ mutation: changeWorkItemParentMutation,
+ variables: { id: this.workItemId, parentId: this.parentWorkItemId },
+ update: this.toggleChildFromCache.bind(this, data),
+ });
+
+ if (resp.workItemUpdate.errors.length === 0) {
+ this.activeToast?.hide();
+ }
+ },
+ async removeChild() {
+ const { data } = await this.$apollo.mutate({
+ mutation: changeWorkItemParentMutation,
+ variables: { id: this.workItemId, parentId: null },
+ update: this.toggleChildFromCache.bind(this, null),
+ });
+
+ if (data.workItemUpdate.errors.length === 0) {
+ this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), {
+ action: {
+ text: s__('WorkItem|Undo'),
+ onClick: this.addChild.bind(this, data),
+ },
+ });
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <span class="gl-ml-2">
+ <gl-dropdown category="tertiary" toggle-class="btn-icon" :right="true">
+ <template #button-content>
+ <gl-icon name="ellipsis_v" :size="14" />
+ </template>
+ <gl-dropdown-item @click="removeChild">
+ {{ s__('WorkItem|Remove') }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </span>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_weight.vue b/app/assets/javascripts/work_items/components/work_item_weight.vue
index b0f2b3aa14a..30e2c1e56b8 100644
--- a/app/assets/javascripts/work_items/components/work_item_weight.vue
+++ b/app/assets/javascripts/work_items/components/work_item_weight.vue
@@ -1,26 +1,142 @@
<script>
+import { GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
import { __ } from '~/locale';
+import Tracking from '~/tracking';
+import { TRACKING_CATEGORY_SHOW } from '../constants';
+import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql';
+
+/* eslint-disable @gitlab/require-i18n-strings */
+const allowedKeys = [
+ 'Alt',
+ 'ArrowDown',
+ 'ArrowLeft',
+ 'ArrowRight',
+ 'ArrowUp',
+ 'Backspace',
+ 'Control',
+ 'Delete',
+ 'End',
+ 'Enter',
+ 'Home',
+ 'Meta',
+ 'PageDown',
+ 'PageUp',
+ 'Tab',
+ '0',
+ '1',
+ '2',
+ '3',
+ '4',
+ '5',
+ '6',
+ '7',
+ '8',
+ '9',
+];
+/* eslint-enable @gitlab/require-i18n-strings */
export default {
+ inputId: 'weight-widget-input',
+ components: {
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ },
+ mixins: [Tracking.mixin()],
inject: ['hasIssueWeightsFeature'],
props: {
+ canUpdate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
weight: {
type: Number,
required: false,
default: undefined,
},
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ workItemType: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isEditing: false,
+ };
},
computed: {
- weightText() {
- return this.weight ?? __('None');
+ placeholder() {
+ return this.canUpdate && this.isEditing ? __('Enter a number') : __('None');
+ },
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_weight',
+ property: `type_${this.workItemType}`,
+ };
+ },
+ type() {
+ return this.canUpdate && this.isEditing ? 'number' : 'text';
+ },
+ },
+ methods: {
+ blurInput() {
+ this.$refs.input.$el.blur();
+ },
+ handleFocus() {
+ this.isEditing = true;
+ },
+ handleKeydown(event) {
+ if (!allowedKeys.includes(event.key)) {
+ event.preventDefault();
+ }
+ },
+ updateWeight(event) {
+ this.isEditing = false;
+ this.track('updated_weight');
+ this.$apollo.mutate({
+ mutation: localUpdateWorkItemMutation,
+ variables: {
+ input: {
+ id: this.workItemId,
+ weight: event.target.value === '' ? null : Number(event.target.value),
+ },
+ },
+ });
},
},
};
</script>
<template>
- <div v-if="hasIssueWeightsFeature" class="gl-mb-5">
- <span class="gl-display-inline-block gl-font-weight-bold gl-w-15">{{ __('Weight') }}</span>
- {{ weightText }}
- </div>
+ <gl-form v-if="hasIssueWeightsFeature" @submit.prevent="blurInput">
+ <gl-form-group
+ class="gl-align-items-center"
+ :label="__('Weight')"
+ :label-for="$options.inputId"
+ label-class="gl-pb-0! gl-overflow-wrap-break"
+ label-cols="3"
+ label-cols-lg="2"
+ >
+ <gl-form-input
+ :id="$options.inputId"
+ ref="input"
+ min="0"
+ :placeholder="placeholder"
+ :readonly="!canUpdate"
+ size="sm"
+ :type="type"
+ :value="weight"
+ @blur="updateWeight"
+ @focus="handleFocus"
+ @keydown="handleKeydown"
+ @keydown.exact.esc.stop="blurInput"
+ />
+ </gl-form-group>
+ </gl-form>
</template>
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index 2df4978a319..2140b418e6d 100644
--- a/app/assets/javascripts/work_items/constants.js
+++ b/app/assets/javascripts/work_items/constants.js
@@ -13,12 +13,14 @@ export const i18n = {
updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'),
};
-export const DEFAULT_MODAL_TYPE = 'Task';
+export const TASK_TYPE_NAME = 'Task';
-export const WIDGET_TYPE_ASSIGNEE = 'ASSIGNEES';
+export const WIDGET_TYPE_ASSIGNEES = 'ASSIGNEES';
export const WIDGET_TYPE_DESCRIPTION = 'DESCRIPTION';
+export const WIDGET_TYPE_LABELS = 'LABELS';
export const WIDGET_TYPE_WEIGHT = 'WEIGHT';
export const WIDGET_TYPE_HIERARCHY = 'HIERARCHY';
+export const WORK_ITEM_VIEWED_STORAGE_KEY = 'gl-show-work-item-banner';
export const WIDGET_TYPE_TASK_ICON = 'task-done';
diff --git a/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql b/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql
new file mode 100644
index 00000000000..dc5286174d8
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql
@@ -0,0 +1,13 @@
+mutation changeWorkItemParentLink($id: WorkItemID!, $parentId: WorkItemID) {
+ workItemUpdate(input: { id: $id, hierarchyWidget: { parentId: $parentId } }) {
+ workItem {
+ id
+ workItemType {
+ id
+ }
+ title
+ state
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql
index b25210f5c74..ccfe62cc585 100644
--- a/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql
@@ -1,8 +1,12 @@
+#import "./work_item.fragment.graphql"
+
mutation workItemCreateFromTask($input: WorkItemCreateFromTaskInput!) {
workItemCreateFromTask(input: $input) {
workItem {
- id
- descriptionHtml
+ ...WorkItem
+ }
+ newWorkItem {
+ ...WorkItem
}
errors
}
diff --git a/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql
index 0d31ecef6f8..43c92cf89ec 100644
--- a/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql
@@ -1,6 +1,6 @@
#import "./work_item.fragment.graphql"
-mutation localUpdateWorkItem($input: LocalWorkItemAssigneesInput) {
+mutation localUpdateWorkItem($input: LocalUpdateWorkItemInput) {
localUpdateWorkItem(input: $input) @client {
workItem {
...WorkItem
diff --git a/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql
new file mode 100644
index 00000000000..7d38d203b84
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql
@@ -0,0 +1,14 @@
+query projectWorkItems($searchTerm: String, $projectPath: ID!, $types: [IssueType!]) {
+ workspace: project(fullPath: $projectPath) {
+ id
+ workItems(search: $searchTerm, types: $types) {
+ edges {
+ node {
+ id
+ title
+ state
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js
index 09d929faae2..8788ad21e7b 100644
--- a/app/assets/javascripts/work_items/graphql/provider.js
+++ b/app/assets/javascripts/work_items/graphql/provider.js
@@ -2,7 +2,7 @@ import produce from 'immer';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import { WIDGET_TYPE_ASSIGNEE } from '../constants';
+import { WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_LABELS, WIDGET_TYPE_WEIGHT } from '../constants';
import typeDefs from './typedefs.graphql';
import workItemQuery from './work_item.query.graphql';
@@ -10,7 +10,7 @@ export const temporaryConfig = {
typeDefs,
cacheConfig: {
possibleTypes: {
- LocalWorkItemWidget: ['LocalWorkItemAssignees'],
+ LocalWorkItemWidget: ['LocalWorkItemLabels', 'LocalWorkItemWeight'],
},
typePolicies: {
WorkItem: {
@@ -20,33 +20,15 @@ export const temporaryConfig = {
return (
widgets || [
{
- __typename: 'LocalWorkItemAssignees',
- type: 'ASSIGNEES',
- nodes: [
- {
- __typename: 'UserCore',
- id: 'gid://gitlab/User/1',
- avatarUrl: '',
- webUrl: '',
- // eslint-disable-next-line @gitlab/require-i18n-strings
- name: 'John Doe',
- username: 'doe_I',
- },
- {
- __typename: 'UserCore',
- id: 'gid://gitlab/User/2',
- avatarUrl: '',
- webUrl: '',
- // eslint-disable-next-line @gitlab/require-i18n-strings
- name: 'Marcus Rutherford',
- username: 'ruthfull',
- },
- ],
+ __typename: 'LocalWorkItemLabels',
+ type: WIDGET_TYPE_LABELS,
+ allowScopedLabels: true,
+ nodes: [],
},
{
__typename: 'LocalWorkItemWeight',
type: 'WEIGHT',
- weight: 0,
+ weight: null,
},
]
);
@@ -67,12 +49,26 @@ export const resolvers = {
});
const data = produce(sourceData, (draftData) => {
- const assigneesWidget = draftData.workItem.mockWidgets.find(
- (widget) => widget.type === WIDGET_TYPE_ASSIGNEE,
- );
- assigneesWidget.nodes = assigneesWidget.nodes.filter((assignee) =>
- input.assigneeIds.includes(assignee.id),
- );
+ if (input.assignees) {
+ const assigneesWidget = draftData.workItem.widgets.find(
+ (widget) => widget.type === WIDGET_TYPE_ASSIGNEES,
+ );
+ assigneesWidget.assignees.nodes = [...input.assignees];
+ }
+
+ if (input.weight != null) {
+ const weightWidget = draftData.workItem.mockWidgets.find(
+ (widget) => widget.type === WIDGET_TYPE_WEIGHT,
+ );
+ weightWidget.weight = input.weight;
+ }
+
+ if (input.labels) {
+ const labelsWidget = draftData.workItem.mockWidgets.find(
+ (widget) => widget.type === WIDGET_TYPE_LABELS,
+ );
+ labelsWidget.nodes = [...input.labels];
+ }
});
cache.writeQuery({
diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql
index bfe2f0fe0ce..48228b15a53 100644
--- a/app/assets/javascripts/work_items/graphql/typedefs.graphql
+++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql
@@ -1,5 +1,6 @@
enum LocalWidgetType {
ASSIGNEES
+ LABELS
WEIGHT
}
@@ -12,6 +13,12 @@ type LocalWorkItemAssignees implements LocalWorkItemWidget {
nodes: [UserCore]
}
+type LocalWorkItemLabels implements LocalWorkItemWidget {
+ type: LocalWidgetType!
+ allowScopedLabels: Boolean!
+ nodes: [Label!]
+}
+
type LocalWorkItemWeight implements LocalWorkItemWidget {
type: LocalWidgetType!
weight: Int
@@ -21,9 +28,11 @@ extend type WorkItem {
mockWidgets: [LocalWorkItemWidget]
}
-type LocalWorkItemAssigneesInput {
+input LocalUpdateWorkItemInput {
id: WorkItemID!
- assigneeIds: [ID!]
+ assignees: [UserCore!]
+ labels: [Label]
+ weight: Int
}
type LocalWorkItemPayload {
@@ -32,5 +41,5 @@ type LocalWorkItemPayload {
}
extend type Mutation {
- localUpdateWorkItem(input: LocalWorkItemAssigneesInput!): LocalWorkItemPayload
+ localUpdateWorkItem(input: LocalUpdateWorkItemInput!): LocalWorkItemPayload
}
diff --git a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
index c0b6e856411..25eb8099251 100644
--- a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
@@ -5,5 +5,6 @@ mutation workItemUpdate($input: WorkItemUpdateInput!) {
workItem {
...WorkItem
}
+ errors
}
}
diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql
index 470de060ee3..ad861a60d15 100644
--- a/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql
@@ -1,8 +1,13 @@
+#import "./work_item.fragment.graphql"
+
mutation workItemUpdateTask($input: WorkItemUpdateTaskInput!) {
workItemUpdate: workItemUpdateTask(input: $input) {
workItem {
id
descriptionHtml
}
+ task {
+ ...WorkItem
+ }
}
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
index 04701f6899e..5f64eda96aa 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
@@ -1,3 +1,5 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+
fragment WorkItem on WorkItem {
id
title
@@ -17,5 +19,29 @@ fragment WorkItem on WorkItem {
description
descriptionHtml
}
+ ... on WorkItemWidgetAssignees {
+ type
+ allowsMultipleAssignees
+ assignees {
+ nodes {
+ ...User
+ }
+ }
+ }
+ ... on WorkItemWidgetHierarchy {
+ type
+ parent {
+ id
+ iid
+ title
+ }
+ children {
+ edges {
+ node {
+ id
+ }
+ }
+ }
+ }
}
}
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 30bc61f5c59..61cb8802187 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql
@@ -1,17 +1,15 @@
+#import "~/graphql_shared/fragments/label.fragment.graphql"
#import "./work_item.fragment.graphql"
query workItem($id: WorkItemID!) {
workItem(id: $id) {
...WorkItem
mockWidgets @client {
- ... on LocalWorkItemAssignees {
+ ... on LocalWorkItemLabels {
type
+ allowScopedLabels
nodes {
- id
- avatarUrl
- name
- username
- webUrl
+ ...Label
}
}
... on LocalWorkItemWeight {
diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js
index 33e28831b54..6437df597b4 100644
--- a/app/assets/javascripts/work_items/index.js
+++ b/app/assets/javascripts/work_items/index.js
@@ -10,6 +10,7 @@ export const initWorkItemsRoot = () => {
return new Vue({
el,
+ name: 'WorkItemsRoot',
router: createRouter(el.dataset.fullPath),
apolloProvider: createApolloProvider(),
provide: {
diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue
index 04c6a61689c..482da5419c6 100644
--- a/app/assets/javascripts/work_items/pages/create_work_item.vue
+++ b/app/assets/javascripts/work_items/pages/create_work_item.vue
@@ -6,12 +6,11 @@ import workItemQuery from '../graphql/work_item.query.graphql';
import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql';
import createWorkItemFromTaskMutation from '../graphql/create_work_item_from_task.mutation.graphql';
import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql';
-import { DEFAULT_MODAL_TYPE } from '../constants';
import ItemTitle from '../components/item_title.vue';
export default {
- createErrorText: s__('WorkItem|Something went wrong when creating a work item. Please try again'),
+ createErrorText: s__('WorkItem|Something went wrong when creating a task. Please try again'),
fetchTypesErrorText: s__(
'WorkItem|Something went wrong when fetching work item types. Please try again',
),
@@ -24,11 +23,6 @@ export default {
},
inject: ['fullPath'],
props: {
- isModal: {
- type: Boolean,
- required: false,
- default: false,
- },
initialTitle: {
type: String,
required: false,
@@ -78,13 +72,6 @@ export default {
text: node.name,
}));
},
- result() {
- if (!this.selectedWorkItemType && this.isModal) {
- this.selectedWorkItemType = this.formOptions.find(
- (options) => options.text === DEFAULT_MODAL_TYPE,
- )?.value;
- }
- },
error() {
this.error = this.$options.fetchTypesErrorText;
},
@@ -104,11 +91,7 @@ export default {
methods: {
async createWorkItem() {
this.loading = true;
- if (this.isModal) {
- await this.createWorkItemFromTask();
- } else {
- await this.createStandaloneWorkItem();
- }
+ await this.createStandaloneWorkItem();
this.loading = false;
},
async createStandaloneWorkItem() {
@@ -174,11 +157,7 @@ export default {
this.title = title;
},
handleCancelClick() {
- if (!this.isModal) {
- this.$router.go(-1);
- return;
- }
- this.$emit('closeModal');
+ this.$router.go(-1);
},
},
};
@@ -187,7 +166,7 @@ export default {
<template>
<form @submit.prevent="createWorkItem">
<gl-alert v-if="error" variant="danger" @dismiss="error = null">{{ error }}</gl-alert>
- <div :class="{ 'gl-px-5': isModal }" data-testid="content">
+ <div data-testid="content">
<item-title :title="initialTitle" data-testid="title-input" @title-input="handleTitleInput" />
<div>
<gl-loading-icon
@@ -203,14 +182,11 @@ export default {
/>
</div>
</div>
- <div
- class="gl-bg-gray-10 gl-py-5 gl-px-6 gl-mt-4"
- :class="{ 'gl-display-flex gl-justify-content-end': isModal }"
- >
+ <div class="gl-bg-gray-10 gl-py-5 gl-px-6 gl-mt-4">
<gl-button
variant="confirm"
:disabled="isButtonDisabled"
- :class="{ 'gl-mr-3': !isModal }"
+ class="gl-mr-3"
:loading="loading"
data-testid="create-button"
type="submit"
@@ -221,7 +197,6 @@ export default {
type="button"
data-testid="cancel-button"
class="gl-order-n1"
- :class="{ 'gl-mr-3': isModal }"
@click="handleCancelClick"
>
{{ __('Cancel') }}