summaryrefslogtreecommitdiff
path: root/app/assets/javascripts
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts')
-rw-r--r--app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql1
-rw-r--r--app/assets/javascripts/actioncable_link.js2
-rw-r--r--app/assets/javascripts/activities.js4
-rw-r--r--app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue7
-rw-r--r--app/assets/javascripts/admin/application_settings/setup_service_usage_data.js15
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_table.vue15
-rw-r--r--app/assets/javascripts/alert_management/list.js2
-rw-r--r--app/assets/javascripts/alerts_settings/graphql.js12
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/fragmentTypes.json1
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql1
-rw-r--r--app/assets/javascripts/analytics/shared/components/daterange.vue33
-rw-r--r--app/assets/javascripts/analytics/shared/components/metric_popover.vue (renamed from app/assets/javascripts/cycle_analytics/components/metric_popover.vue)0
-rw-r--r--app/assets/javascripts/analytics/shared/components/metric_tile.vue51
-rw-r--r--app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue (renamed from app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue)53
-rw-r--r--app/assets/javascripts/analytics/shared/constants.js45
-rw-r--r--app/assets/javascripts/analytics/shared/utils.js27
-rw-r--r--app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql1
-rw-r--r--app/assets/javascripts/authentication/webauthn/util.js49
-rw-r--r--app/assets/javascripts/awards_handler.js8
-rw-r--r--app/assets/javascripts/badges/components/badge.vue9
-rw-r--r--app/assets/javascripts/badges/components/badge_form.vue12
-rw-r--r--app/assets/javascripts/badges/components/badge_list.vue10
-rw-r--r--app/assets/javascripts/badges/components/badge_list_row.vue5
-rw-r--r--app/assets/javascripts/batch_comments/components/diff_file_drafts.vue11
-rw-r--r--app/assets/javascripts/batch_comments/components/draft_note.vue18
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/bold.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/code.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/italic.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/link.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/math.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/blockquote.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/heading.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/image.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/list_item.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/paragraph.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/playable.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/text.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/serializer.js2
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts.js19
-rw-r--r--app/assets/javascripts/blob/components/blob_header.vue15
-rw-r--r--app/assets/javascripts/blob/components/blob_header_default_actions.vue35
-rw-r--r--app/assets/javascripts/blob/components/blob_header_filepath.vue18
-rw-r--r--app/assets/javascripts/blob/components/constants.js2
-rw-r--r--app/assets/javascripts/blob/pipeline_tour_success_modal.vue6
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue18
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue32
-rw-r--r--app/assets/javascripts/boards/components/board_filtered_search.vue48
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue3
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue3
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_new_item.vue13
-rw-r--r--app/assets/javascripts/boards/components/board_settings_sidebar.vue43
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue54
-rw-r--r--app/assets/javascripts/boards/components/issue_board_filtered_search.vue1
-rw-r--r--app/assets/javascripts/boards/config_toggle.js1
-rw-r--r--app/assets/javascripts/boards/graphql.js9
-rw-r--r--app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql7
-rw-r--r--app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql14
-rw-r--r--app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql7
-rw-r--r--app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql14
-rw-r--r--app/assets/javascripts/boards/index.js3
-rw-r--r--app/assets/javascripts/boards/mount_filtered_search_issue_boards.js1
-rw-r--r--app/assets/javascripts/boards/mount_multiple_boards_switcher.js17
-rw-r--r--app/assets/javascripts/boards/stores/actions.js2
-rw-r--r--app/assets/javascripts/boards/toggle_focus.js1
-rw-r--r--app/assets/javascripts/broadcast_notification.js4
-rw-r--r--app/assets/javascripts/captcha/apollo_captcha_link.js2
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue6
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue45
-rw-r--r--app/assets/javascripts/clusters/agents/components/show.vue3
-rw-r--r--app/assets/javascripts/clusters/agents/constants.js1
-rw-r--r--app/assets/javascripts/clusters/agents/graphql/provider.js17
-rw-r--r--app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql9
-rw-r--r--app/assets/javascripts/clusters/agents/index.js11
-rw-r--r--app/assets/javascripts/clusters/agents/router.js22
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_table.vue152
-rw-r--r--app/assets/javascripts/clusters_list/components/agents.vue55
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters.vue2
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_actions.vue34
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_view_all.vue60
-rw-r--r--app/assets/javascripts/clusters_list/components/delete_agent_button.vue (renamed from app/assets/javascripts/clusters_list/components/agent_options.vue)62
-rw-r--r--app/assets/javascripts/clusters_list/components/install_agent_modal.vue52
-rw-r--r--app/assets/javascripts/clusters_list/constants.js57
-rw-r--r--app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql7
-rw-r--r--app/assets/javascripts/clusters_list/load_main_view.js7
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js4
-rw-r--r--app/assets/javascripts/contextual_sidebar.js7
-rw-r--r--app/assets/javascripts/cycle_analytics/components/base.vue8
-rw-r--r--app/assets/javascripts/cycle_analytics/components/metric_tile.vue51
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_table.vue2
-rw-r--r--app/assets/javascripts/cycle_analytics/constants.js25
-rw-r--r--app/assets/javascripts/cycle_analytics/utils.js30
-rw-r--r--app/assets/javascripts/deprecated_notes.js8
-rw-r--r--app/assets/javascripts/design_management/components/design_overlay.vue1
-rw-r--r--app/assets/javascripts/design_management/components/design_sidebar.vue8
-rw-r--r--app/assets/javascripts/design_management/graphql.js8
-rw-r--r--app/assets/javascripts/design_management/graphql/fragmentTypes.json1
-rw-r--r--app/assets/javascripts/design_management/index.js1
-rw-r--r--app/assets/javascripts/diffs/components/app.vue87
-rw-r--r--app/assets/javascripts/diffs/components/collapsed_files_warning.vue14
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue2
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions.vue17
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussions.vue31
-rw-r--r--app/assets/javascripts/diffs/components/diff_expansion_cell.vue26
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_view.vue45
-rw-r--r--app/assets/javascripts/diffs/components/image_diff_overlay.vue44
-rw-r--r--app/assets/javascripts/diffs/components/merge_conflict_warning.vue14
-rw-r--r--app/assets/javascripts/diffs/constants.js6
-rw-r--r--app/assets/javascripts/diffs/index.js16
-rw-r--r--app/assets/javascripts/diffs/store/actions.js29
-rw-r--r--app/assets/javascripts/diffs/store/getters.js17
-rw-r--r--app/assets/javascripts/diffs/store/modules/diff_state.js4
-rw-r--r--app/assets/javascripts/diffs/store/utils.js18
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js94
-rw-r--r--app/assets/javascripts/editor/schema/ci.json3
-rw-r--r--app/assets/javascripts/emoji/awards_app/index.js1
-rw-r--r--app/assets/javascripts/emoji/awards_app/store/actions.js41
-rw-r--r--app/assets/javascripts/emoji/components/utils.js8
-rw-r--r--app/assets/javascripts/entrypoints/behaviors/redirect_listbox.js3
-rw-r--r--app/assets/javascripts/environments/components/canary_ingress.vue15
-rw-r--r--app/assets/javascripts/environments/components/canary_update_modal.vue2
-rw-r--r--app/assets/javascripts/environments/components/commit.vue54
-rw-r--r--app/assets/javascripts/environments/components/deploy_board.vue68
-rw-r--r--app/assets/javascripts/environments/components/deploy_board_wrapper.vue86
-rw-r--r--app/assets/javascripts/environments/components/deployment.vue217
-rw-r--r--app/assets/javascripts/environments/components/empty_state.vue4
-rw-r--r--app/assets/javascripts/environments/components/environment_pin.vue15
-rw-r--r--app/assets/javascripts/environments/components/new_environment_item.vue77
-rw-r--r--app/assets/javascripts/environments/components/new_environments_app.vue12
-rw-r--r--app/assets/javascripts/environments/graphql/mutations/cancel_auto_stop.mutation.graphql4
-rw-r--r--app/assets/javascripts/environments/graphql/mutations/set_environment_to_change_canary.mutation.graphql3
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environment_to_change_canary.query.graphql4
-rw-r--r--app/assets/javascripts/environments/graphql/resolvers.js11
-rw-r--r--app/assets/javascripts/environments/graphql/typedefs.graphql3
-rw-r--r--app/assets/javascripts/files_comment_button.js4
-rw-r--r--app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js15
-rw-r--r--app/assets/javascripts/filtered_search/available_dropdown_mappings.js5
-rw-r--r--app/assets/javascripts/filtered_search/constants.js2
-rw-r--r--app/assets/javascripts/flash.js12
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js74
-rw-r--r--app/assets/javascripts/google_cloud/components/deployments_service_table.vue25
-rw-r--r--app/assets/javascripts/google_cloud/components/home.vue8
-rw-r--r--app/assets/javascripts/google_cloud/components/service_accounts_form.vue12
-rw-r--r--app/assets/javascripts/google_cloud/components/service_accounts_list.vue48
-rw-r--r--app/assets/javascripts/google_tag_manager/index.js111
-rw-r--r--app/assets/javascripts/graphql_shared/fragment_types/vulnerability_location_types.js17
-rw-r--r--app/assets/javascripts/graphql_shared/possibleTypes.json1
-rw-r--r--app/assets/javascripts/groups/components/app.vue10
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue19
-rw-r--r--app/assets/javascripts/groups/components/invite_members_banner.vue1
-rw-r--r--app/assets/javascripts/groups/components/item_actions.vue81
-rw-r--r--app/assets/javascripts/groups/components/transfer_group_form.vue80
-rw-r--r--app/assets/javascripts/groups/constants.js6
-rw-r--r--app/assets/javascripts/groups/init_transfer_group_form.js52
-rw-r--r--app/assets/javascripts/groups/landing.js7
-rw-r--r--app/assets/javascripts/groups/store/groups_store.js1
-rw-r--r--app/assets/javascripts/groups/transfer_dropdown.js39
-rw-r--r--app/assets/javascripts/groups/transfer_edit.js11
-rw-r--r--app/assets/javascripts/groups_select.js2
-rw-r--r--app/assets/javascripts/ide/components/activity_bar.vue11
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/form.vue2
-rw-r--r--app/assets/javascripts/ide/components/preview/clientside.vue18
-rw-r--r--app/assets/javascripts/ide/constants.js4
-rw-r--r--app/assets/javascripts/ide/stores/modules/clientside/actions.js4
-rw-r--r--app/assets/javascripts/image_diff/helpers/badge_helper.js14
-rw-r--r--app/assets/javascripts/image_diff/helpers/dom_helper.js4
-rw-r--r--app/assets/javascripts/image_diff/image_diff.js2
-rw-r--r--app/assets/javascripts/image_diff/replaced_image_diff.js2
-rw-r--r--app/assets/javascripts/incidents/components/incidents_list.vue15
-rw-r--r--app/assets/javascripts/integrations/constants.js4
-rw-r--r--app/assets/javascripts/integrations/edit/components/dynamic_field.vue19
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue83
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue21
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue19
-rw-r--r--app/assets/javascripts/integrations/edit/event_hub.js3
-rw-r--r--app/assets/javascripts/integrations/edit/store/actions.js3
-rw-r--r--app/assets/javascripts/integrations/edit/store/mutation_types.js4
-rw-r--r--app/assets/javascripts/invite_members/components/group_select.vue47
-rw-r--r--app/assets/javascripts/invite_members/components/invite_group_trigger.vue2
-rw-r--r--app/assets/javascripts/invite_members/components/invite_groups_modal.vue146
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue419
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_trigger.vue2
-rw-r--r--app/assets/javascripts/invite_members/components/invite_modal_base.vue276
-rw-r--r--app/assets/javascripts/invite_members/constants.js91
-rw-r--r--app/assets/javascripts/invite_members/init_invite_groups_modal.js44
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_modal.js3
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_trigger.js1
-rw-r--r--app/assets/javascripts/issuable/bulk_update_sidebar/index.js1
-rw-r--r--app/assets/javascripts/issuable/index.js3
-rw-r--r--app/assets/javascripts/issuable/issuable_context.js4
-rw-r--r--app/assets/javascripts/issuable/issuable_form.js1
-rw-r--r--app/assets/javascripts/issues/create_merge_request_dropdown.js35
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue182
-rw-r--r--app/assets/javascripts/issues/list/components/new_issue_dropdown.vue2
-rw-r--r--app/assets/javascripts/issues/list/constants.js17
-rw-r--r--app/assets/javascripts/issues/list/index.js4
-rw-r--r--app/assets/javascripts/issues/list/queries/issue.fragment.graphql3
-rw-r--r--app/assets/javascripts/issues/list/queries/search_milestones.query.graphql15
-rw-r--r--app/assets/javascripts/issues/list/queries/set_sort_preference.mutation.graphql5
-rw-r--r--app/assets/javascripts/issues/list/utils.js17
-rw-r--r--app/assets/javascripts/issues/manual_ordering.js5
-rw-r--r--app/assets/javascripts/issues/new/index.js2
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/index.js1
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue126
-rw-r--r--app/assets/javascripts/issues/show/components/fields/description.vue5
-rw-r--r--app/assets/javascripts/issues/show/index.js7
-rw-r--r--app/assets/javascripts/jira_connect/branches/components/new_branch_form.vue99
-rw-r--r--app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue33
-rw-r--r--app/assets/javascripts/jira_connect/branches/constants.js3
-rw-r--r--app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql3
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue34
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/app.vue58
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue63
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue6
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue40
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions.vue43
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/actions_cell.vue20
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/duration_cell.vue1
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_app.vue4
-rw-r--r--app/assets/javascripts/labels/index.js2
-rw-r--r--app/assets/javascripts/lib/apollo/instrumentation_link.js2
-rw-r--r--app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js4
-rw-r--r--app/assets/javascripts/lib/graphql.js99
-rw-r--r--app/assets/javascripts/lib/prosemirror_markdown_serializer.js3
-rw-r--r--app/assets/javascripts/lib/utils/apollo_startup_js_link.js2
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js5
-rw-r--r--app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue34
-rw-r--r--app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js8
-rw-r--r--app/assets/javascripts/lib/utils/constants.js1
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js23
-rw-r--r--app/assets/javascripts/lib/utils/table_utility.js35
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js65
-rw-r--r--app/assets/javascripts/lib/utils/yaml.js121
-rw-r--r--app/assets/javascripts/listbox/index.js67
-rw-r--r--app/assets/javascripts/listbox/redirect_behavior.js22
-rw-r--r--app/assets/javascripts/logs/components/environment_logs.vue38
-rw-r--r--app/assets/javascripts/main.js2
-rw-r--r--app/assets/javascripts/members/components/avatars/user_avatar.vue14
-rw-r--r--app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue1
-rw-r--r--app/assets/javascripts/members/components/table/members_table.vue14
-rw-r--r--app/assets/javascripts/members/constants.js1
-rw-r--r--app/assets/javascripts/merge_conflicts/store/actions.js4
-rw-r--r--app/assets/javascripts/merge_conflicts/store/state.js4
-rw-r--r--app/assets/javascripts/merge_request_tabs.js17
-rw-r--r--app/assets/javascripts/milestones/index.js2
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue51
-rw-r--r--app/assets/javascripts/nav/components/top_nav_menu_item.vue2
-rw-r--r--app/assets/javascripts/nav/mount.js1
-rw-r--r--app/assets/javascripts/network/raphael.js11
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue2
-rw-r--r--app/assets/javascripts/notes/components/diff_with_note.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue5
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue8
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue26
-rw-r--r--app/assets/javascripts/notes/discussion_filters.js2
-rw-r--r--app/assets/javascripts/notes/index.js1
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js12
-rw-r--r--app/assets/javascripts/notes/sort_discussions.js1
-rw-r--r--app/assets/javascripts/notifications/components/notifications_dropdown.vue4
-rw-r--r--app/assets/javascripts/notifications/index.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue14
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue14
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue12
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue43
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/fragmentTypes.json17
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js15
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/cli_commands.vue (renamed from app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue)17
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/tags_loader.vue (renamed from app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/constants/index.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js (renamed from app/assets/javascripts/packages_and_registries/shared/constants.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/constants/quick_start.js9
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue6
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/payload_downloader.js52
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/payload_previewer.js21
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/service_usage_data/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/runners/show/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/edit/index.js7
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/imports/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue16
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue12
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/index/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/new/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue8
-rw-r--r--app/assets/javascripts/pages/projects/planning_hierarchy/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/project.js8
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/security/configuration/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/serverless/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue2
-rw-r--r--app/assets/javascripts/pages/users/index.js4
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue12
-rw-r--r--app/assets/javascripts/performance_bar/index.js1
-rw-r--r--app/assets/javascripts/persistent_user_callout.js4
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js1
-rw-r--r--app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue36
-rw-r--r--app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue6
-rw-r--r--app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue70
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue55
-rw-r--r--app/assets/javascripts/pipeline_editor/constants.js1
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue75
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue2
-rw-r--r--app/assets/javascripts/pipeline_new/constants.js7
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/commit.vue224
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/editor.vue94
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/step_nav.vue54
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/widgets/text.vue126
-rw-r--r--app/assets/javascripts/pipeline_wizard/queries/create_commit.graphql9
-rw-r--r--app/assets/javascripts/pipeline_wizard/queries/get_file_meta.graphql12
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue11
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue30
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue98
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue22
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/jobs_app.vue5
-rw-r--r--app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue14
-rw-r--r--app/assets/javascripts/pipelines/components/notification/deprecated_type_keyword_notification.vue102
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue18
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue167
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue10
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue9
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue25
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue6
-rw-r--r--app/assets/javascripts/pipelines/constants.js9
-rw-r--r--app/assets/javascripts/pipelines/graphql/fragmentTypes.json1
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_warnings.query.graphql12
-rw-r--r--app/assets/javascripts/pipelines/mixins/pipelines_mixin.js18
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js10
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_notification.js31
-rw-r--r--app/assets/javascripts/pipelines/pipeline_shared_client.js9
-rw-r--r--app/assets/javascripts/popovers/index.js2
-rw-r--r--app/assets/javascripts/projects/components/project_delete_button.vue71
-rw-r--r--app/assets/javascripts/projects/components/shared/delete_button.vue84
-rw-r--r--app/assets/javascripts/projects/new/components/deployment_target_select.vue61
-rw-r--r--app/assets/javascripts/projects/new/constants.js20
-rw-r--r--app/assets/javascripts/projects/new/index.js14
-rw-r--r--app/assets/javascripts/projects/project_new.js93
-rw-r--r--app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue23
-rw-r--r--app/assets/javascripts/projects/settings/components/transfer_project_form.vue12
-rw-r--r--app/assets/javascripts/projects/settings/constants.js7
-rw-r--r--app/assets/javascripts/projects/settings/init_transfer_project_form.js10
-rw-r--r--app/assets/javascripts/related_issues/components/add_issuable_form.vue12
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_block.vue19
-rw-r--r--app/assets/javascripts/related_issues/constants.js25
-rw-r--r--app/assets/javascripts/related_issues/index.js1
-rw-r--r--app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue9
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue95
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue20
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue14
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/index.js61
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue38
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue14
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue9
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue4
-rw-r--r--app/assets/javascripts/repository/components/table/parent_row.vue13
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue1
-rw-r--r--app/assets/javascripts/repository/constants.js51
-rw-r--r--app/assets/javascripts/repository/fragmentTypes.json1
-rw-r--r--app/assets/javascripts/repository/graphql.js9
-rw-r--r--app/assets/javascripts/repository/queries/blob_info.query.graphql13
-rw-r--r--app/assets/javascripts/right_sidebar.js4
-rw-r--r--app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue77
-rw-r--r--app/assets/javascripts/runner/admin_runner_show/index.js32
-rw-r--r--app/assets/javascripts/runner/admin_runners/admin_runners_app.vue11
-rw-r--r--app/assets/javascripts/runner/components/cells/link_cell.vue27
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_actions_cell.vue73
-rw-r--r--app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue19
-rw-r--r--app/assets/javascripts/runner/components/runner_assigned_item.vue39
-rw-r--r--app/assets/javascripts/runner/components/runner_detail.vue50
-rw-r--r--app/assets/javascripts/runner/components/runner_details.vue124
-rw-r--r--app/assets/javascripts/runner/components/runner_edit_button.vue26
-rw-r--r--app/assets/javascripts/runner/components/runner_groups.vue37
-rw-r--r--app/assets/javascripts/runner/components/runner_header.vue52
-rw-r--r--app/assets/javascripts/runner/components/runner_jobs.vue82
-rw-r--r--app/assets/javascripts/runner/components/runner_jobs_table.vue95
-rw-r--r--app/assets/javascripts/runner/components/runner_list.vue30
-rw-r--r--app/assets/javascripts/runner/components/runner_pagination.vue12
-rw-r--r--app/assets/javascripts/runner/components/runner_pause_button.vue122
-rw-r--r--app/assets/javascripts/runner/components/runner_projects.vue111
-rw-r--r--app/assets/javascripts/runner/components/runner_tags.vue4
-rw-r--r--app/assets/javascripts/runner/components/runner_type_tabs.vue59
-rw-r--r--app/assets/javascripts/runner/constants.js23
-rw-r--r--app/assets/javascripts/runner/graphql/get_group_runners.query.graphql2
-rw-r--r--app/assets/javascripts/runner/graphql/get_runner.query.graphql1
-rw-r--r--app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql36
-rw-r--r--app/assets/javascripts/runner/graphql/get_runner_projects.query.graphql26
-rw-r--r--app/assets/javascripts/runner/graphql/get_runners.query.graphql1
-rw-r--r--app/assets/javascripts/runner/graphql/runner_actions_update.mutation.graphql14
-rw-r--r--app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql20
-rw-r--r--app/assets/javascripts/runner/graphql/runner_node.fragment.graphql1
-rw-r--r--app/assets/javascripts/runner/graphql/runner_toggle_active.mutation.graphql12
-rw-r--r--app/assets/javascripts/runner/group_runners/group_runners_app.vue81
-rw-r--r--app/assets/javascripts/runner/runner_search_utils.js26
-rw-r--r--app/assets/javascripts/runner/utils.js72
-rw-r--r--app/assets/javascripts/security_configuration/components/app.vue6
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js34
-rw-r--r--app/assets/javascripts/security_configuration/components/feature_card.vue23
-rw-r--r--app/assets/javascripts/security_configuration/components/training_provider_list.vue106
-rw-r--r--app/assets/javascripts/security_configuration/components/upgrade_banner.vue18
-rw-r--r--app/assets/javascripts/security_configuration/constants.js2
-rw-r--r--app/assets/javascripts/security_configuration/graphql/configure_security_training_providers.mutation.graphql7
-rw-r--r--app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql16
-rw-r--r--app/assets/javascripts/security_configuration/index.js7
-rw-r--r--app/assets/javascripts/security_configuration/resolver.js56
-rw-r--r--app/assets/javascripts/security_configuration/utils.js13
-rw-r--r--app/assets/javascripts/serverless/components/empty_state.vue25
-rw-r--r--app/assets/javascripts/serverless/components/functions.vue24
-rw-r--r--app/assets/javascripts/serverless/constants.js3
-rw-r--r--app/assets/javascripts/serverless/survey_banner.js36
-rw-r--r--app/assets/javascripts/serverless/survey_banner.vue52
-rw-r--r--app/assets/javascripts/settings_panels.js23
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue36
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/help_state.vue5
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue37
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue15
-rw-r--r--app/assets/javascripts/sidebar/fragmentTypes.json1
-rw-r--r--app/assets/javascripts/sidebar/graphql.js15
-rw-r--r--app/assets/javascripts/sidebar/mount_milestone_sidebar.js1
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js16
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js2
-rw-r--r--app/assets/javascripts/tabs/constants.js6
-rw-r--r--app/assets/javascripts/terraform/components/states_table.vue17
-rw-r--r--app/assets/javascripts/terraform/index.js2
-rw-r--r--app/assets/javascripts/toggles/index.js65
-rw-r--r--app/assets/javascripts/tooltips/index.js1
-rw-r--r--app/assets/javascripts/user_callout.js6
-rw-r--r--app/assets/javascripts/vue_alerts.js6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue20
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue31
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/merge_failed_pipeline_confirmation_dialog.vue66
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue60
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue45
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue99
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/constants.js6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js120
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/issues.js4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js30
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/i18n.js17
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/index.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue18
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_fork_modal.vue68
-rw-r--r--app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue28
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js14
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue23
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue106
-rw-r--r--app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js195
-rw-r--r--app/assets/javascripts/vue_shared/components/help_popover.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue255
-rw-r--r--app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue104
-rw-r--r--app/assets/javascripts/vue_shared/components/pikaday.vue48
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue49
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue148
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue37
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/constants.js111
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue (renamed from app/assets/javascripts/vue_shared/components/source_viewer.vue)51
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/utils.js26
-rw-r--r--app/assets/javascripts/vue_shared/components/svg_gradient.vue34
-rw-r--r--app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue37
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue27
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue16
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue13
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue8
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue2
-rw-r--r--app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue8
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue2
-rw-r--r--app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue4
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue4
-rw-r--r--app/assets/javascripts/work_items/graphql/fragmentTypes.json1
-rw-r--r--app/assets/javascripts/work_items/graphql/project_work_item_types.query.graphql11
-rw-r--r--app/assets/javascripts/work_items/graphql/provider.js9
-rw-r--r--app/assets/javascripts/work_items/index.js4
-rw-r--r--app/assets/javascripts/work_items/pages/create_work_item.vue116
-rw-r--r--app/assets/javascripts/work_items_hierarchy/components/app.vue101
-rw-r--r--app/assets/javascripts/work_items_hierarchy/components/hierarchy.vue119
-rw-r--r--app/assets/javascripts/work_items_hierarchy/constants.js62
-rw-r--r--app/assets/javascripts/work_items_hierarchy/hierarchy_util.js10
-rw-r--r--app/assets/javascripts/work_items_hierarchy/static_response.js142
-rw-r--r--app/assets/javascripts/work_items_hierarchy/work_items_hierarchy_bundle.js26
504 files changed, 9432 insertions, 3953 deletions
diff --git a/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql
index 09278e1776a..cdc8a952ead 100644
--- a/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql
+++ b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql
@@ -22,6 +22,7 @@ query accessTokensGetProjects(
avatarUrl
}
pageInfo {
+ __typename
...PageInfo
}
}
diff --git a/app/assets/javascripts/actioncable_link.js b/app/assets/javascripts/actioncable_link.js
index 895a34ba157..cf53d9e21b4 100644
--- a/app/assets/javascripts/actioncable_link.js
+++ b/app/assets/javascripts/actioncable_link.js
@@ -1,4 +1,4 @@
-import { ApolloLink, Observable } from 'apollo-link';
+import { ApolloLink, Observable } from '@apollo/client/core';
import { print } from 'graphql';
import cable from '~/actioncable_consumer';
import { uuids } from '~/lib/utils/uuids';
diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js
index f45af5fe08e..74e0e1b6225 100644
--- a/app/assets/javascripts/activities.js
+++ b/app/assets/javascripts/activities.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
import $ from 'jquery';
-import Cookies from 'js-cookie';
+import { setCookie } from '~/lib/utils/common_utils';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import { localTimeAgo } from './lib/utils/datetime_utility';
@@ -55,7 +55,7 @@ export default class Activities {
const filter = $sender.attr('id').split('_')[0];
$('.event-filter .active').removeClass('active');
- Cookies.set('event_filter', filter);
+ setCookie('event_filter', filter);
$sender.closest('li').toggleClass('active');
}
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 90c9113e0e1..96584080d0f 100644
--- a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue
+++ b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue
@@ -1,5 +1,5 @@
<script>
-import { GlModal, GlTabs, GlTab, GlSearchBoxByType, GlSprintf } from '@gitlab/ui';
+import { GlModal, GlTabs, GlTab, GlSearchBoxByType, GlSprintf, GlBadge } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import ReviewTabContainer from '~/add_context_commits_modal/components/review_tab_container.vue';
import createFlash from '~/flash';
@@ -21,6 +21,7 @@ export default {
ReviewTabContainer,
GlSearchBoxByType,
GlSprintf,
+ GlBadge,
},
props: {
contextCommitsPath: {
@@ -239,7 +240,7 @@ export default {
<template #title>
<gl-sprintf :message="__(`Commits in %{codeStart}${targetBranch}%{codeEnd}`)">
<template #code="{ content }">
- <code>{{ content }}</code>
+ <code class="gl-ml-2">{{ content }}</code>
</template>
</gl-sprintf>
</template>
@@ -262,7 +263,7 @@ export default {
<gl-tab>
<template #title>
{{ __('Selected commits') }}
- <span class="badge badge-pill">{{ selectedCommitsCount }}</span>
+ <gl-badge size="sm" class="gl-ml-2">{{ selectedCommitsCount }}</gl-badge>
</template>
<review-tab-container
:is-loading="isLoadingContextCommits"
diff --git a/app/assets/javascripts/admin/application_settings/setup_service_usage_data.js b/app/assets/javascripts/admin/application_settings/setup_service_usage_data.js
new file mode 100644
index 00000000000..a88efbd89a8
--- /dev/null
+++ b/app/assets/javascripts/admin/application_settings/setup_service_usage_data.js
@@ -0,0 +1,15 @@
+import PayloadPreviewer from '~/pages/admin/application_settings/payload_previewer';
+import PayloadDownloader from '~/pages/admin/application_settings/payload_downloader';
+
+export default () => {
+ const payloadPreviewTrigger = document.querySelector('.js-payload-preview-trigger');
+ const payloadDownloadTrigger = document.querySelector('.js-payload-download-trigger');
+
+ if (payloadPreviewTrigger) {
+ new PayloadPreviewer(payloadPreviewTrigger).init();
+ }
+
+ if (payloadDownloadTrigger) {
+ new PayloadDownloader(payloadDownloadTrigger).init();
+ }
+};
diff --git a/app/assets/javascripts/alert_management/components/alert_management_table.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue
index 79a6bac3ba7..84c2b216859 100644
--- a/app/assets/javascripts/alert_management/components/alert_management_table.vue
+++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue
@@ -15,7 +15,7 @@ import getAlertsQuery from '~/graphql_shared/queries/get_alerts.query.graphql';
import { fetchPolicies } from '~/lib/graphql';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
-import { s__, __ } from '~/locale';
+import { s__, __, n__ } from '~/locale';
import AlertStatus from '~/vue_shared/alert_details/components/alert_status.vue';
import {
tdClass,
@@ -32,8 +32,11 @@ const TH_TEST_ID = { 'data-testid': 'alert-management-severity-sort' };
const TWELVE_HOURS_IN_MS = 12 * 60 * 60 * 1000;
+const MAX_VISIBLE_ASSIGNEES = 4;
+
export default {
trackAlertListViewsOptions,
+ MAX_VISIBLE_ASSIGNEES,
i18n: {
noAlertsMsg: s__(
'AlertManagement|No alerts available to display. See %{linkStart}enabling alert management%{linkEnd} for more information on adding alerts to the list.',
@@ -258,6 +261,13 @@ export default {
this.serverErrorMessage = '';
this.isErrorAlertDismissed = true;
},
+ assigneesBadgeSrOnlyText(item) {
+ return n__(
+ '%d additional assignee',
+ '%d additional assignees',
+ item.assignees.nodes.length - MAX_VISIBLE_ASSIGNEES,
+ );
+ },
},
};
</script>
@@ -365,10 +375,11 @@ export default {
<gl-avatars-inline
:avatars="item.assignees.nodes"
:collapsed="true"
- :max-visible="4"
+ :max-visible="$options.MAX_VISIBLE_ASSIGNEES"
:avatar-size="24"
badge-tooltip-prop="name"
:badge-tooltip-max-chars="100"
+ :badge-sr-only-text="assigneesBadgeSrOnlyText(item)"
>
<template #avatar="{ avatar }">
<gl-avatar-link
diff --git a/app/assets/javascripts/alert_management/list.js b/app/assets/javascripts/alert_management/list.js
index b23f8a8eba4..42cbeef56bf 100644
--- a/app/assets/javascripts/alert_management/list.js
+++ b/app/assets/javascripts/alert_management/list.js
@@ -1,4 +1,4 @@
-import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
+import { defaultDataIdFromObject } from '@apollo/client/core';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
diff --git a/app/assets/javascripts/alerts_settings/graphql.js b/app/assets/javascripts/alerts_settings/graphql.js
index b64e2e3eefa..36a98145457 100644
--- a/app/assets/javascripts/alerts_settings/graphql.js
+++ b/app/assets/javascripts/alerts_settings/graphql.js
@@ -1,15 +1,9 @@
-import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import produce from 'immer';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import introspectionQueryResultData from './graphql/fragmentTypes.json';
import getCurrentIntegrationQuery from './graphql/queries/get_current_integration.query.graphql';
-const fragmentMatcher = new IntrospectionFragmentMatcher({
- introspectionQueryResultData,
-});
-
Vue.use(VueApollo);
const resolvers = {
@@ -55,9 +49,5 @@ const resolvers = {
};
export default new VueApollo({
- defaultClient: createDefaultClient(resolvers, {
- cacheConfig: {
- fragmentMatcher,
- },
- }),
+ defaultClient: createDefaultClient(resolvers),
});
diff --git a/app/assets/javascripts/alerts_settings/graphql/fragmentTypes.json b/app/assets/javascripts/alerts_settings/graphql/fragmentTypes.json
deleted file mode 100644
index 07dfc43aa6c..00000000000
--- a/app/assets/javascripts/alerts_settings/graphql/fragmentTypes.json
+++ /dev/null
@@ -1 +0,0 @@
-{"__schema":{"types":[{"kind":"UNION","name":"AlertManagementIntegration","possibleTypes":[{"name":"AlertManagementHttpIntegration"},{"name":"AlertManagementPrometheusIntegration"}]}]}}
diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql
index 3cd3f2d92f8..ac9304391f9 100644
--- a/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql
@@ -5,6 +5,7 @@ query getIntegrations($projectPath: ID!) {
id
alertManagementIntegrations {
nodes {
+ __typename
...IntegrationItem
}
}
diff --git a/app/assets/javascripts/analytics/shared/components/daterange.vue b/app/assets/javascripts/analytics/shared/components/daterange.vue
index a5b9c40b9c9..7df66d1b2be 100644
--- a/app/assets/javascripts/analytics/shared/components/daterange.vue
+++ b/app/assets/javascripts/analytics/shared/components/daterange.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDaterangePicker, GlSprintf, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlDaterangePicker, GlSprintf } from '@gitlab/ui';
import { getDayDifference } from '~/lib/utils/datetime_utility';
import { __, sprintf } from '~/locale';
import { OFFSET_DATE_BY_ONE } from '../constants';
@@ -8,10 +8,6 @@ export default {
components: {
GlDaterangePicker,
GlSprintf,
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
},
props: {
show: {
@@ -56,7 +52,7 @@ export default {
return {
maxDateRangeTooltip: sprintf(
__(
- 'Showing data for workflow items created in this date range. Date range cannot exceed %{maxDateRange} days.',
+ 'Showing data for workflow items created in this date range. Date range limited to %{maxDateRange} days.',
),
{
maxDateRange: this.maxDateRange,
@@ -94,28 +90,15 @@ export default {
:max-date-range="maxDateRange"
:default-max-date="maxDate"
:same-day-selection="includeSelectedDate"
+ :tooltip="maxDateRangeTooltip"
theme="animate-picker"
start-picker-class="js-daterange-picker-from gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-lg-align-items-center gl-lg-mr-3 gl-mb-2 gl-lg-mb-0"
- end-picker-class="js-daterange-picker-to d-flex flex-column flex-lg-row align-items-lg-center"
+ end-picker-class="js-daterange-picker-to d-flex flex-column flex-lg-row align-items-lg-center gl-mb-2 gl-lg-mb-0"
label-class="gl-mb-2 gl-lg-mb-0"
- />
- <div
- v-if="maxDateRange"
- class="daterange-indicator d-flex flex-row flex-lg-row align-items-flex-start align-items-lg-center"
>
- <span class="number-of-days pl-2 pr-1">
- <gl-sprintf :message="n__('1 day selected', '%d days selected', numberOfDays)">
- <template #numberOfDays>{{ numberOfDays }}</template>
- </gl-sprintf>
- </span>
- <gl-icon
- v-gl-tooltip
- data-testid="helper-icon"
- :title="maxDateRangeTooltip"
- name="question"
- :size="14"
- class="text-secondary"
- />
- </div>
+ <gl-sprintf :message="n__('1 day selected', '%d days selected', numberOfDays)">
+ <template #numberOfDays>{{ numberOfDays }}</template>
+ </gl-sprintf>
+ </gl-daterange-picker>
</div>
</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/metric_popover.vue b/app/assets/javascripts/analytics/shared/components/metric_popover.vue
index 8d90e7b2392..8d90e7b2392 100644
--- a/app/assets/javascripts/cycle_analytics/components/metric_popover.vue
+++ b/app/assets/javascripts/analytics/shared/components/metric_popover.vue
diff --git a/app/assets/javascripts/analytics/shared/components/metric_tile.vue b/app/assets/javascripts/analytics/shared/components/metric_tile.vue
new file mode 100644
index 00000000000..845a3386f6c
--- /dev/null
+++ b/app/assets/javascripts/analytics/shared/components/metric_tile.vue
@@ -0,0 +1,51 @@
+<script>
+import { GlSingleStat } from '@gitlab/ui/dist/charts';
+import { redirectTo } from '~/lib/utils/url_utility';
+import MetricPopover from './metric_popover.vue';
+
+export default {
+ name: 'MetricTile',
+ components: {
+ GlSingleStat,
+ MetricPopover,
+ },
+ props: {
+ metric: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ decimalPlaces() {
+ const parsedFloat = parseFloat(this.metric.value);
+ return Number.isNaN(parsedFloat) || Number.isInteger(parsedFloat) ? 0 : 1;
+ },
+ hasLinks() {
+ return this.metric.links?.length && this.metric.links[0].url;
+ },
+ },
+ methods: {
+ clickHandler({ links }) {
+ if (this.hasLinks) {
+ redirectTo(links[0].url);
+ }
+ },
+ },
+};
+</script>
+<template>
+ <div v-bind="$attrs">
+ <gl-single-stat
+ :id="metric.identifier"
+ :value="`${metric.value}`"
+ :title="metric.label"
+ :unit="metric.unit || ''"
+ :should-animate="true"
+ :animation-decimal-places="decimalPlaces"
+ :class="{ 'gl-hover-cursor-pointer': hasLinks }"
+ tabindex="0"
+ @click="clickHandler(metric)"
+ />
+ <metric-popover :metric="metric" :target="metric.identifier" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue b/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue
index 9671742e564..1a3544e7677 100644
--- a/app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue
+++ b/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue
@@ -1,13 +1,11 @@
<script>
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
-import { GlSingleStat } from '@gitlab/ui/dist/charts';
-import { flatten } from 'lodash';
+import { flatten, isEqual } from 'lodash';
import createFlash from '~/flash';
import { sprintf, s__ } from '~/locale';
-import { redirectTo } from '~/lib/utils/url_utility';
import { METRICS_POPOVER_CONTENT } from '../constants';
import { removeFlash, prepareTimeMetricsData } from '../utils';
-import MetricPopover from './metric_popover.vue';
+import MetricTile from './metric_tile.vue';
const requestData = ({ request, endpoint, path, params, name }) => {
return request({ endpoint, params, requestPath: path })
@@ -33,9 +31,8 @@ const fetchMetricsData = (reqs = [], path, params) => {
export default {
name: 'ValueStreamMetrics',
components: {
- GlSingleStat,
GlSkeletonLoading,
- MetricPopover,
+ MetricTile,
},
props: {
requestPath: {
@@ -50,6 +47,11 @@ export default {
type: Array,
required: true,
},
+ filterFn: {
+ type: Function,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -58,8 +60,10 @@ export default {
};
},
watch: {
- requestParams() {
- this.fetchData();
+ requestParams(newVal, oldVal) {
+ if (!isEqual(newVal, oldVal)) {
+ this.fetchData();
+ }
},
},
mounted() {
@@ -71,40 +75,25 @@ export default {
this.isLoading = true;
return fetchMetricsData(this.requests, this.requestPath, this.requestParams)
.then((data) => {
- this.metrics = data;
+ this.metrics = this.filterFn ? this.filterFn(data) : data;
this.isLoading = false;
})
.catch(() => {
this.isLoading = false;
});
},
- hasLinks(links) {
- return links?.length && links[0].url;
- },
- clickHandler({ links }) {
- if (this.hasLinks(links)) {
- redirectTo(links[0].url);
- }
- },
},
};
</script>
<template>
- <div class="gl-display-flex gl-flex-wrap" data-testid="vsa-time-metrics">
+ <div class="gl-display-flex gl-flex-wrap" data-testid="vsa-metrics">
<gl-skeleton-loading v-if="isLoading" class="gl-h-auto gl-py-3 gl-pr-9 gl-my-6" />
- <div v-for="metric in metrics" v-show="!isLoading" :key="metric.key" class="gl-my-6 gl-pr-9">
- <gl-single-stat
- :id="metric.key"
- :value="`${metric.value}`"
- :title="metric.label"
- :unit="metric.unit || ''"
- :should-animate="true"
- :animation-decimal-places="1"
- :class="{ 'gl-hover-cursor-pointer': hasLinks(metric.links) }"
- tabindex="0"
- @click="clickHandler(metric)"
- />
- <metric-popover :metric="metric" :target="metric.key" />
- </div>
+ <metric-tile
+ v-for="metric in metrics"
+ v-show="!isLoading"
+ :key="metric.identifier"
+ :metric="metric"
+ class="gl-my-6 gl-pr-9"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js
index c06bd34f86f..2ac144ceb5e 100644
--- a/app/assets/javascripts/analytics/shared/constants.js
+++ b/app/assets/javascripts/analytics/shared/constants.js
@@ -1,4 +1,5 @@
import { masks } from 'dateformat';
+import { s__ } from '~/locale';
export const DATE_RANGE_LIMIT = 180;
export const OFFSET_DATE_BY_ONE = 1;
@@ -11,3 +12,47 @@ export const dateFormats = {
defaultDateTime: 'mmm d, yyyy h:MMtt',
month: 'mmmm',
};
+
+// 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'),
+ },
+};
diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js
index f55ef99964e..dde429ab278 100644
--- a/app/assets/javascripts/analytics/shared/utils.js
+++ b/app/assets/javascripts/analytics/shared/utils.js
@@ -1,4 +1,6 @@
import dateFormat from 'dateformat';
+import { hideFlash } from '~/flash';
+import { slugify } from '~/lib/utils/text_utility';
import { urlQueryToFilter } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import { dateFormats } from './constants';
@@ -69,3 +71,28 @@ export const getDataZoomOption = ({
};
});
};
+
+export const removeFlash = (type = 'alert') => {
+ const flashEl = document.querySelector(`.flash-${type}`);
+ if (flashEl) {
+ hideFlash(flashEl);
+ }
+};
+
+/**
+ * Prepares metric data to be rendered in the metric_card component
+ *
+ * @param {MetricData[]} data - The metric data to be rendered
+ * @param {Object} popoverContent - Key value pair of data to display in the popover
+ * @returns {TransformedMetricData[]} An array of metrics ready to render in the metric_card
+ */
+export const prepareTimeMetricsData = (data = [], popoverContent = {}) =>
+ data.map(({ title: label, identifier, ...rest }) => {
+ const metricIdentifier = identifier || slugify(label);
+ return {
+ ...rest,
+ label,
+ identifier: metricIdentifier,
+ description: popoverContent[metricIdentifier]?.description || '',
+ };
+ });
diff --git a/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql
index 2bde5973600..b353bcdfd0e 100644
--- a/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql
+++ b/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql
@@ -1,4 +1,5 @@
fragment Count on UsageTrendsMeasurement {
+ __typename
count
recordedAt
}
diff --git a/app/assets/javascripts/authentication/webauthn/util.js b/app/assets/javascripts/authentication/webauthn/util.js
index 5f06c000afe..eeda2bfaeaf 100644
--- a/app/assets/javascripts/authentication/webauthn/util.js
+++ b/app/assets/javascripts/authentication/webauthn/util.js
@@ -14,31 +14,36 @@ export function isHTTPS() {
export const FLOW_AUTHENTICATE = 'authenticate';
export const FLOW_REGISTER = 'register';
-// adapted from https://stackoverflow.com/a/21797381/8204697
-function base64ToBuffer(base64) {
- const binaryString = window.atob(base64);
- const len = binaryString.length;
- const bytes = new Uint8Array(len);
- for (let i = 0; i < len; i += 1) {
- bytes[i] = binaryString.charCodeAt(i);
- }
- return bytes.buffer;
-}
-
-// adapted from https://stackoverflow.com/a/9458996/8204697
-function bufferToBase64(buffer) {
- if (typeof buffer === 'string') {
- return buffer;
+/**
+ * Converts a base64 string to an ArrayBuffer
+ *
+ * @param {String} str - A base64 encoded string
+ * @returns {ArrayBuffer}
+ */
+export const base64ToBuffer = (str) => {
+ const rawStr = atob(str);
+ const buffer = new ArrayBuffer(rawStr.length);
+ const arr = new Uint8Array(buffer);
+ for (let i = 0; i < rawStr.length; i += 1) {
+ arr[i] = rawStr.charCodeAt(i);
}
+ return arr.buffer;
+};
- let binary = '';
- const bytes = new Uint8Array(buffer);
- const len = bytes.byteLength;
- for (let i = 0; i < len; i += 1) {
- binary += String.fromCharCode(bytes[i]);
+/**
+ * Converts ArrayBuffer to a base64-encoded string
+ *
+ * @param {ArrayBuffer, String} str -
+ * @returns {String} - ArrayBuffer to a base64-encoded string.
+ * When input is a string, returns the input as-is.
+ */
+export const bufferToBase64 = (input) => {
+ if (typeof input === 'string') {
+ return input;
}
- return window.btoa(binary);
-}
+ const arr = new Uint8Array(input);
+ return btoa(String.fromCharCode(...arr));
+};
/**
* Returns a copy of the given object with the id property converted to buffer
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index 43ca5b5cf89..aa735df7da5 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -2,10 +2,10 @@
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import $ from 'jquery';
-import Cookies from 'js-cookie';
import { uniq } from 'lodash';
+import { getCookie, setCookie, scrollToElement } from '~/lib/utils/common_utils';
import * as Emoji from '~/emoji';
-import { scrollToElement } from '~/lib/utils/common_utils';
+
import { dispose, fixTitle } from '~/tooltips';
import createFlash from './flash';
import axios from './lib/utils/axios_utils';
@@ -506,7 +506,7 @@ export class AwardsHandler {
addEmojiToFrequentlyUsedList(emoji) {
if (this.emoji.isEmojiNameValid(emoji)) {
this.frequentlyUsedEmojis = uniq(this.getFrequentlyUsedEmojis().concat(emoji));
- Cookies.set('frequently_used_emojis', this.frequentlyUsedEmojis.join(','), { expires: 365 });
+ setCookie('frequently_used_emojis', this.frequentlyUsedEmojis.join(','));
}
}
@@ -514,7 +514,7 @@ export class AwardsHandler {
return (
this.frequentlyUsedEmojis ||
(() => {
- const frequentlyUsedEmojis = uniq((Cookies.get('frequently_used_emojis') || '').split(','));
+ const frequentlyUsedEmojis = uniq((getCookie('frequently_used_emojis') || '').split(','));
this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter((inputName) =>
this.emoji.isEmojiNameValid(inputName),
);
diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue
index 53469ac8999..8bef972cc58 100644
--- a/app/assets/javascripts/badges/components/badge.vue
+++ b/app/assets/javascripts/badges/components/badge.vue
@@ -74,7 +74,14 @@ export default {
<template>
<div>
- <a v-show="!isLoading && !hasError" :href="linkUrl" target="_blank" rel="noopener noreferrer">
+ <a
+ v-show="!isLoading && !hasError"
+ :href="linkUrl"
+ target="_blank"
+ rel="noopener noreferrer"
+ data-qa-selector="badge_image_link"
+ :data-qa-link-url="linkUrl"
+ >
<img
:src="imageUrlWithRetries"
class="project-badge"
diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue
index 2c7e878f044..d1570e16639 100644
--- a/app/assets/javascripts/badges/components/badge_form.vue
+++ b/app/assets/javascripts/badges/components/badge_form.vue
@@ -182,7 +182,7 @@ export default {
@submit.prevent.stop="onSubmit"
>
<gl-form-group :label="s__('Badges|Name')" label-for="badge-name">
- <gl-form-input id="badge-name" v-model="name" />
+ <gl-form-input id="badge-name" v-model="name" data-qa-selector="badge_name_field" />
</gl-form-group>
<div class="form-group">
@@ -191,6 +191,7 @@ export default {
<input
id="badge-link-url"
v-model="linkUrl"
+ data-qa-selector="badge_link_url_field"
type="URL"
class="form-control gl-form-input"
required
@@ -206,6 +207,7 @@ export default {
<input
id="badge-image-url"
v-model="imageUrl"
+ data-qa-selector="badge_image_url_field"
type="URL"
class="form-control gl-form-input"
required
@@ -246,7 +248,13 @@ export default {
</gl-button>
</div>
<div v-else class="form-group">
- <gl-button :loading="isSaving" type="submit" variant="confirm" category="primary">
+ <gl-button
+ :loading="isSaving"
+ type="submit"
+ variant="confirm"
+ category="primary"
+ data-qa-selector="add_badge_button"
+ >
{{ s__('Badges|Add badge') }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/badges/components/badge_list.vue b/app/assets/javascripts/badges/components/badge_list.vue
index 86c7b4c7a6e..76625fe9a60 100644
--- a/app/assets/javascripts/badges/components/badge_list.vue
+++ b/app/assets/javascripts/badges/components/badge_list.vue
@@ -34,8 +34,14 @@ export default {
<span v-if="isGroupBadge">{{ s__('Badges|This group has no badges') }}</span>
<span v-else>{{ s__('Badges|This project has no badges') }}</span>
</div>
- <div v-else class="card-body">
- <badge-list-row v-for="badge in badges" :key="badge.id" :badge="badge" />
+ <div v-else class="card-body" data-qa-selector="badge_list_content">
+ <badge-list-row
+ v-for="badge in badges"
+ :key="badge.id"
+ :badge="badge"
+ data-qa-selector="badge_list_row"
+ :data-qa-badge-name="badge.name"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue
index d8525c15087..4c2b700c7ff 100644
--- a/app/assets/javascripts/badges/components/badge_list_row.vue
+++ b/app/assets/javascripts/badges/components/badge_list_row.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLoadingIcon, GlButton, GlModalDirective } from '@gitlab/ui';
+import { GlLoadingIcon, GlButton, GlModalDirective, GlBadge } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import { s__ } from '~/locale';
import { PROJECT_BADGE } from '../constants';
@@ -11,6 +11,7 @@ export default {
Badge,
GlLoadingIcon,
GlButton,
+ GlBadge,
},
directives: {
GlModal: GlModalDirective,
@@ -49,7 +50,7 @@ export default {
/>
<div class="table-section section-30">
<label class="label-bold str-truncated mb-0">{{ badge.name }}</label>
- <span class="badge badge-pill">{{ badgeKindText }}</span>
+ <gl-badge size="sm">{{ badgeKindText }}</gl-badge>
</div>
<span class="table-section section-30 str-truncated">{{ badge.linkUrl }}</span>
<div class="table-section section-10 table-button-footer">
diff --git a/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue b/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue
index 570954c7200..2ebde10c229 100644
--- a/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue
+++ b/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue
@@ -1,11 +1,13 @@
<script>
import { mapGetters } from 'vuex';
import imageDiff from '~/diffs/mixins/image_diff';
+import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue';
import DraftNote from './draft_note.vue';
export default {
components: {
DraftNote,
+ DesignNotePin,
},
mixins: [imageDiff],
props: {
@@ -31,9 +33,12 @@ export default {
class="discussion-notes diff-discussions position-relative"
>
<div class="notes">
- <span class="d-block btn-transparent badge badge-pill is-draft js-diff-notes-index">
- {{ toggleText(draft, index) }}
- </span>
+ <design-note-pin
+ :label="toggleText(draft, index)"
+ is-draft
+ class="js-diff-notes-index gl-translate-x-n50"
+ size="sm"
+ />
<draft-note :draft="draft" />
</div>
</div>
diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue
index a218624f2d4..c8130c47f5b 100644
--- a/app/assets/javascripts/batch_comments/components/draft_note.vue
+++ b/app/assets/javascripts/batch_comments/components/draft_note.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui';
+import { GlButton, GlSafeHtmlDirective, GlBadge } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import NoteableNote from '~/notes/components/noteable_note.vue';
import PublishButton from './publish_button.vue';
@@ -9,6 +9,7 @@ export default {
NoteableNote,
PublishButton,
GlButton,
+ GlBadge,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
@@ -100,9 +101,7 @@ export default {
@toggleResolveStatus="toggleResolveDiscussion(draft.id)"
>
<template #note-header-info>
- <strong class="badge draft-pending-label gl-mr-2">
- {{ __('Pending') }}
- </strong>
+ <gl-badge variant="warning" class="gl-mr-2">{{ __('Pending') }}</gl-badge>
</template>
</noteable-note>
</ul>
@@ -115,10 +114,15 @@ export default {
></div>
<p class="draft-note-actions d-flex">
- <publish-button :show-count="true" :should-publish="false" category="secondary" />
+ <publish-button
+ :show-count="true"
+ :should-publish="false"
+ category="secondary"
+ :disabled="isPublishingDraft(draft.id)"
+ />
<gl-button
- ref="publishNowButton"
- :loading="isPublishingDraft(draft.id) || isPublishing"
+ :disabled="isPublishing"
+ :loading="isPublishingDraft(draft.id)"
class="gl-ml-3"
@click="publishNow"
>
diff --git a/app/assets/javascripts/behaviors/markdown/marks/bold.js b/app/assets/javascripts/behaviors/markdown/marks/bold.js
index d307edd9fd3..89e373220af 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/bold.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/bold.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { Bold as BaseBold } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class Bold extends BaseBold {
diff --git a/app/assets/javascripts/behaviors/markdown/marks/code.js b/app/assets/javascripts/behaviors/markdown/marks/code.js
index ccfe2cf5b8d..68368dec676 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/code.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/code.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { Code as BaseCode } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class Code extends BaseCode {
diff --git a/app/assets/javascripts/behaviors/markdown/marks/italic.js b/app/assets/javascripts/behaviors/markdown/marks/italic.js
index dbef10536ab..7dc86102f18 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/italic.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/italic.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { Italic as BaseItalic } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class Italic extends BaseItalic {
diff --git a/app/assets/javascripts/behaviors/markdown/marks/link.js b/app/assets/javascripts/behaviors/markdown/marks/link.js
index 1111c51805d..b5e09017d83 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/link.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/link.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { Link as BaseLink } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class Link extends BaseLink {
diff --git a/app/assets/javascripts/behaviors/markdown/marks/math.js b/app/assets/javascripts/behaviors/markdown/marks/math.js
index 382bf5c9b5b..ca25ff7d07d 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/math.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/math.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { Mark } from 'tiptap';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
// Transforms generated HTML back to GFM for Banzai::Filter::MathFilter
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js b/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js
index bd5868e5524..8b14a04e2fe 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { Blockquote as BaseBlockquote } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class Blockquote extends BaseBlockquote {
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js b/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js
index 209e7239998..ef1eafaa419 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { BulletList as BaseBulletList } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class BulletList extends BaseBulletList {
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/heading.js b/app/assets/javascripts/behaviors/markdown/nodes/heading.js
index 708da053a2f..29967e61ffa 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/heading.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/heading.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { Heading as BaseHeading } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class Heading extends BaseHeading {
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js b/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js
index 47a24eae1e8..ee3aa145dc3 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { HorizontalRule as BaseHorizontalRule } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class HorizontalRule extends BaseHorizontalRule {
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/image.js b/app/assets/javascripts/behaviors/markdown/nodes/image.js
index 4cc28c45739..16647d2f96e 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/image.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/image.js
@@ -1,8 +1,8 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { Image as BaseImage } from 'tiptap-extensions';
import { placeholderImage } from '~/lazy_loader';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
export default class Image extends BaseImage {
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/list_item.js
index 0f56e89dca6..7204b7c09ba 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/list_item.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/list_item.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { ListItem as BaseListItem } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class ListItem extends BaseListItem {
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js b/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js
index 93d00f27868..5fd098cd46f 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { Node } from 'tiptap';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class Paragraph extends Node {
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/playable.js b/app/assets/javascripts/behaviors/markdown/nodes/playable.js
index 2b667aba2d6..90cbaf9ef4c 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/playable.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/playable.js
@@ -1,8 +1,8 @@
/* eslint-disable class-methods-use-this */
/* eslint-disable @gitlab/require-i18n-strings */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { Node } from 'tiptap';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
/**
* Abstract base class for playable media, like video and audio.
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/text.js b/app/assets/javascripts/behaviors/markdown/nodes/text.js
index 4eab10c9d98..0dc77a12f5c 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/text.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/text.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { Node } from 'tiptap';
+import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
export default class Text extends Node {
get name() {
diff --git a/app/assets/javascripts/behaviors/markdown/serializer.js b/app/assets/javascripts/behaviors/markdown/serializer.js
index b4adf1a413f..a5f97d7748a 100644
--- a/app/assets/javascripts/behaviors/markdown/serializer.js
+++ b/app/assets/javascripts/behaviors/markdown/serializer.js
@@ -1,4 +1,4 @@
-import { MarkdownSerializer } from 'prosemirror-markdown';
+import { MarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
import editorExtensions from './editor_extensions';
const nodes = editorExtensions
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
index ac2a4184176..9297b14aac9 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
@@ -1,9 +1,9 @@
import $ from 'jquery';
-import Cookies from 'js-cookie';
import { flatten } from 'lodash';
import Mousetrap from 'mousetrap';
import Vue from 'vue';
-import { parseBoolean } from '~/lib/utils/common_utils';
+import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils';
+
import findAndFollowLink from '~/lib/utils/navigation_utility';
import { refreshCurrentPage, visitUrl } from '~/lib/utils/url_utility';
import {
@@ -161,10 +161,10 @@ export default class Shortcuts {
static onTogglePerfBar(e) {
e.preventDefault();
const performanceBarCookieName = 'perf_bar_enabled';
- if (parseBoolean(Cookies.get(performanceBarCookieName))) {
- Cookies.set(performanceBarCookieName, 'false', { expires: 365, path: '/' });
+ if (parseBoolean(getCookie(performanceBarCookieName))) {
+ setCookie(performanceBarCookieName, 'false', { path: '/' });
} else {
- Cookies.set(performanceBarCookieName, 'true', { expires: 365, path: '/' });
+ setCookie(performanceBarCookieName, 'true', { path: '/' });
}
refreshCurrentPage();
}
@@ -172,8 +172,13 @@ export default class Shortcuts {
static onToggleCanary(e) {
e.preventDefault();
const canaryCookieName = 'gitlab_canary';
- const currentValue = parseBoolean(Cookies.get(canaryCookieName));
- Cookies.set(canaryCookieName, (!currentValue).toString(), { expires: 365, path: '/' });
+ const currentValue = parseBoolean(getCookie(canaryCookieName));
+ setCookie(canaryCookieName, (!currentValue).toString(), {
+ expires: 365,
+ path: '/',
+ // next.gitlab.com uses a leading period. See https://gitlab.com/gitlab-org/gitlab/-/issues/350186
+ domain: `.${window.location.hostname}`,
+ });
refreshCurrentPage();
}
diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue
index 1645469a218..c5ab28e6ec5 100644
--- a/app/assets/javascripts/blob/components/blob_header.vue
+++ b/app/assets/javascripts/blob/components/blob_header.vue
@@ -42,6 +42,11 @@ export default {
required: false,
default: false,
},
+ showPath: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -55,6 +60,9 @@ export default {
showDefaultActions() {
return !this.hideDefaultActions;
},
+ isEmpty() {
+ return this.blob.rawSize === 0;
+ },
},
watch: {
viewer(newVal, oldVal) {
@@ -74,7 +82,7 @@ export default {
<div class="js-file-title file-title-flex-parent">
<div class="gl-display-flex">
<table-of-contents class="gl-pr-2" />
- <blob-filepath :blob="blob">
+ <blob-filepath :blob="blob" :show-path="showPath">
<template #filepath-prepend>
<slot name="prepend"></slot>
</template>
@@ -88,10 +96,13 @@ export default {
<default-actions
v-if="showDefaultActions"
- :raw-path="blob.rawPath"
+ :raw-path="blob.externalStorageUrl || blob.rawPath"
:active-viewer="viewer"
:has-render-error="hasRenderError"
:is-binary="isBinary"
+ :environment-name="blob.environmentFormattedExternalUrl"
+ :environment-path="blob.environmentExternalUrlForRouteMap"
+ :is-empty="isEmpty"
@copy="proxyCopyRequest"
/>
</div>
diff --git a/app/assets/javascripts/blob/components/blob_header_default_actions.vue b/app/assets/javascripts/blob/components/blob_header_default_actions.vue
index 2798a918b15..12bcb24b0cc 100644
--- a/app/assets/javascripts/blob/components/blob_header_default_actions.vue
+++ b/app/assets/javascripts/blob/components/blob_header_default_actions.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui';
+import { sprintf, s__ } from '~/locale';
import {
BTN_COPY_CONTENTS_TITLE,
BTN_DOWNLOAD_TITLE,
@@ -37,6 +38,21 @@ export default {
required: false,
default: false,
},
+ environmentName: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ environmentPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ isEmpty: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
downloadUrl() {
@@ -51,6 +67,11 @@ export default {
showCopyButton() {
return !this.hasRenderError && !this.isBinary;
},
+ environmentTitle() {
+ return sprintf(s__('BlobViewer|View on %{environmentName}'), {
+ environmentName: this.environmentName,
+ });
+ },
},
BTN_COPY_CONTENTS_TITLE,
BTN_DOWNLOAD_TITLE,
@@ -71,6 +92,7 @@ export default {
icon="copy-to-clipboard"
category="primary"
variant="default"
+ class="js-copy-blob-source-btn"
/>
<gl-button
v-if="!isBinary"
@@ -84,6 +106,7 @@ export default {
variant="default"
/>
<gl-button
+ v-if="!isEmpty"
v-gl-tooltip.hover
:aria-label="$options.BTN_DOWNLOAD_TITLE"
:title="$options.BTN_DOWNLOAD_TITLE"
@@ -93,5 +116,17 @@ export default {
category="primary"
variant="default"
/>
+ <gl-button
+ v-if="environmentName && environmentPath"
+ v-gl-tooltip.hover
+ :aria-label="environmentTitle"
+ :title="environmentTitle"
+ :href="environmentPath"
+ data-testid="environment"
+ target="_blank"
+ icon="external-link"
+ category="primary"
+ variant="default"
+ />
</gl-button-group>
</template>
diff --git a/app/assets/javascripts/blob/components/blob_header_filepath.vue b/app/assets/javascripts/blob/components/blob_header_filepath.vue
index 90d01358451..62355306655 100644
--- a/app/assets/javascripts/blob/components/blob_header_filepath.vue
+++ b/app/assets/javascripts/blob/components/blob_header_filepath.vue
@@ -15,6 +15,11 @@ export default {
type: Object,
required: true,
},
+ showPath: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
computed: {
blobSize() {
@@ -26,6 +31,13 @@ export default {
showLfsBadge() {
return this.blob.storedExternally && this.blob.externalStorage === 'lfs';
},
+ fileName() {
+ if (this.showPath) {
+ return this.blob.path;
+ }
+
+ return this.blob.name;
+ },
},
};
</script>
@@ -33,12 +45,12 @@ export default {
<div class="file-header-content d-flex align-items-center lh-100">
<slot name="filepath-prepend"></slot>
- <template v-if="blob.path">
- <file-icon :file-name="blob.path" :size="16" aria-hidden="true" css-classes="mr-2" />
+ <template v-if="fileName">
+ <file-icon :file-name="fileName" :size="16" aria-hidden="true" css-classes="mr-2" />
<strong
class="file-title-name mr-1 js-blob-header-filepath"
data-qa-selector="file_title_content"
- >{{ blob.path }}</strong
+ >{{ fileName }}</strong
>
</template>
diff --git a/app/assets/javascripts/blob/components/constants.js b/app/assets/javascripts/blob/components/constants.js
index a129c537fa5..adac4d6408d 100644
--- a/app/assets/javascripts/blob/components/constants.js
+++ b/app/assets/javascripts/blob/components/constants.js
@@ -42,7 +42,7 @@ export const BLOB_RENDER_ERRORS = {
id: 'load',
text: __('load it anyway'),
conjunction: __('or'),
- href: '#',
+ href: '?expanded=true&viewer=simple',
target: '',
event: BLOB_RENDER_EVENT_LOAD,
},
diff --git a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue
index 47a0c4ba2d1..b4ca29114cb 100644
--- a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue
+++ b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue
@@ -1,6 +1,6 @@
<script>
import { GlModal, GlSprintf, GlLink, GlButton } from '@gitlab/ui';
-import Cookies from 'js-cookie';
+import { getCookie, removeCookie } from '~/lib/utils/common_utils';
import { __, s__ } from '~/locale';
import Tracking from '~/tracking';
@@ -62,7 +62,7 @@ export default {
return this.commitCookiePath || this.projectMergeRequestsPath;
},
commitCookiePath() {
- const cookieVal = Cookies.get(this.commitCookie);
+ const cookieVal = getCookie(this.commitCookie);
if (cookieVal !== 'true') return cookieVal;
return '';
@@ -85,7 +85,7 @@ export default {
},
methods: {
disableModalFromRenderingAgain() {
- Cookies.remove(this.commitCookie);
+ removeCookie(this.commitCookie);
},
},
};
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index ea80496c3f5..aee61a5b2a5 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -11,12 +11,10 @@ import { sortBy } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner';
import { isScopedLabel } from '~/lib/utils/common_utils';
-import { updateHistory } from '~/lib/utils/url_utility';
import { sprintf, __, n__ } from '~/locale';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import { ListType } from '../constants';
-import eventHub from '../eventhub';
import BoardBlockedIcon from './board_blocked_icon.vue';
import IssueDueDate from './issue_due_date.vue';
import IssueTimeEstimate from './issue_time_estimate.vue';
@@ -176,18 +174,10 @@ export default {
)
);
},
- filterByLabel(label) {
- if (!this.updateFilters) return;
+ labelTarget(label) {
const filterPath = window.location.search ? `${window.location.search}&` : '?';
- const filter = `label_name[]=${encodeURIComponent(label.title)}`;
-
- if (!filterPath.includes(filter)) {
- updateHistory({
- url: `${filterPath}${filter}`,
- });
- this.performSearch();
- eventHub.$emit('updateTokens');
- }
+ const value = encodeURIComponent(label.title);
+ return `${filterPath}label_name[]=${value}`;
},
showScopedLabel(label) {
return this.scopedLabelsAvailable && isScopedLabel(label);
@@ -242,7 +232,7 @@ export default {
:description="label.description"
size="sm"
:scoped="showScopedLabel(label)"
- @click="filterByLabel(label)"
+ :target="labelTarget(label)"
/>
</template>
</div>
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
index 156029b62b0..0320b4d925e 100644
--- a/app/assets/javascripts/boards/components/board_content_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -184,29 +184,15 @@ export default {
:issuable-type="issuableType"
data-testid="sidebar-milestones"
/>
- <template v-if="!glFeatures.iterationCadences">
- <sidebar-dropdown-widget
- v-if="iterationFeatureAvailable && !isIncidentSidebar"
- :iid="activeBoardItem.iid"
- issuable-attribute="iteration"
- :workspace-path="projectPathForActiveIssue"
- :attr-workspace-path="groupPathForActiveIssue"
- :issuable-type="issuableType"
- class="gl-mt-5"
- data-testid="iteration-edit"
- />
- </template>
- <template v-else>
- <iteration-sidebar-dropdown-widget
- v-if="iterationFeatureAvailable && !isIncidentSidebar"
- :iid="activeBoardItem.iid"
- :workspace-path="projectPathForActiveIssue"
- :attr-workspace-path="groupPathForActiveIssue"
- :issuable-type="issuableType"
- class="gl-mt-5"
- data-testid="iteration-edit"
- />
- </template>
+ <iteration-sidebar-dropdown-widget
+ v-if="iterationFeatureAvailable && !isIncidentSidebar"
+ :iid="activeBoardItem.iid"
+ :workspace-path="projectPathForActiveIssue"
+ :attr-workspace-path="groupPathForActiveIssue"
+ :issuable-type="issuableType"
+ class="gl-mt-5"
+ data-testid="iteration-edit"
+ />
</div>
<board-sidebar-time-tracker />
<sidebar-date-widget
diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue
index 2599d1c80b8..45192b5304a 100644
--- a/app/assets/javascripts/boards/components/board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/board_filtered_search.vue
@@ -1,5 +1,5 @@
<script>
-import { pickBy, isEmpty } from 'lodash';
+import { pickBy, isEmpty, mapValues } from 'lodash';
import { mapActions } from 'vuex';
import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
@@ -251,22 +251,36 @@ export default {
);
}
- return {
- ...notParams,
- author_username: authorUsername,
- 'label_name[]': labelName,
- assignee_username: assigneeUsername,
- assignee_id: assigneeId,
- milestone_title: milestoneTitle,
- iteration_id: iterationId,
- search,
- types,
- weight,
- epic_id: isGid(epicId) ? getIdFromGraphQLId(epicId) : epicId,
- my_reaction_emoji: myReactionEmoji,
- release_tag: releaseTag,
- confidential,
- };
+ return mapValues(
+ {
+ ...notParams,
+ author_username: authorUsername,
+ 'label_name[]': labelName,
+ assignee_username: assigneeUsername,
+ assignee_id: assigneeId,
+ milestone_title: milestoneTitle,
+ iteration_id: iterationId,
+ search,
+ types,
+ weight,
+ epic_id: isGid(epicId) ? getIdFromGraphQLId(epicId) : epicId,
+ my_reaction_emoji: myReactionEmoji,
+ release_tag: releaseTag,
+ confidential,
+ },
+ (value) => {
+ if (value || value === false) {
+ // note: need to check array for labels.
+ if (Array.isArray(value)) {
+ return value.map((valueItem) => encodeURIComponent(valueItem));
+ }
+
+ return encodeURIComponent(value);
+ }
+
+ return value;
+ },
+ );
},
},
created() {
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index 6ad57fd8985..cc048e2af1a 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -98,9 +98,6 @@ export default {
return this.$options.i18n[this.currentPage].btnText;
},
buttonKind() {
- if (this.isNewForm) {
- return 'success';
- }
if (this.isDeleteForm) {
return 'danger';
}
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index e4c3c3206a8..1024be61359 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -60,6 +60,9 @@ export default {
filters: this.filterParams,
};
},
+ skip() {
+ return this.isEpicBoard;
+ },
},
},
computed: {
diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue
index 84c9191975e..8db366e4995 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.vue
+++ b/app/assets/javascripts/boards/components/board_new_issue.vue
@@ -25,7 +25,7 @@ export default {
},
computed: {
...mapState(['selectedProject', 'fullPath']),
- ...mapGetters(['isGroupBoard']),
+ ...mapGetters(['isGroupBoard', 'getBoardItemsByList']),
formEventPrefix() {
return toggleFormEventPrefix.issue;
},
@@ -42,6 +42,7 @@ export default {
const labels = this.list.label ? [this.list.label] : [];
const assignees = this.list.assignee ? [this.list.assignee] : [];
const milestone = getMilestone(this.list);
+ const firstItemId = this.getBoardItemsByList(this.list.id)[0]?.id;
return this.addListNewIssue({
list: this.list,
@@ -51,6 +52,7 @@ export default {
assigneeIds: assignees?.map((a) => a?.id),
milestoneId: milestone?.id,
projectPath: this.projectPath,
+ moveAfterId: firstItemId,
},
}).then(() => {
this.cancel();
diff --git a/app/assets/javascripts/boards/components/board_new_item.vue b/app/assets/javascripts/boards/components/board_new_item.vue
index 44574de17d7..600917683cd 100644
--- a/app/assets/javascripts/boards/components/board_new_item.vue
+++ b/app/assets/javascripts/boards/components/board_new_item.vue
@@ -43,6 +43,12 @@ export default {
// eslint-disable-next-line @gitlab/require-i18n-strings
return `${this.list.id}-title`;
},
+ isIssueTitleEmpty() {
+ return this.title.trim() === '';
+ },
+ isCreatingIssueDisabled() {
+ return this.isIssueTitleEmpty || this.disableSubmit;
+ },
},
methods: {
handleFormCancel() {
@@ -54,7 +60,7 @@ export default {
eventHub.$emit(`scroll-board-list-${this.list.id}`);
this.$emit('form-submit', {
- title,
+ title: title.trim(),
list,
});
},
@@ -69,7 +75,7 @@ export default {
<label :for="inputFieldId" class="gl-font-weight-bold">{{ __('Title') }}</label>
<gl-form-input
:id="inputFieldId"
- v-model.trim="title"
+ v-model="title"
:autofocus="true"
autocomplete="off"
type="text"
@@ -78,7 +84,8 @@ export default {
<slot></slot>
<div class="gl-clearfix gl-mt-4">
<gl-button
- :disabled="!title || disableSubmit"
+ data-testid="create-button"
+ :disabled="isCreatingIssueDisabled"
class="gl-float-left js-no-auto-disable"
variant="confirm"
type="submit"
diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
index 6b7c08d05a5..24071c6f0b4 100644
--- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlDrawer, GlLabel } from '@gitlab/ui';
+import { GlButton, GlDrawer, GlLabel, GlModal, GlModalDirective } from '@gitlab/ui';
import { MountingPortal } from 'portal-vue';
import { mapActions, mapState, mapGetters } from 'vuex';
import { LIST, ListType, ListTypeTitles } from '~/boards/constants';
@@ -11,8 +11,14 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
listSettingsText: __('List settings'),
+ i18n: {
+ modalAction: __('Remove list'),
+ modalCopy: __('Are you sure you want to remove this list?'),
+ modalCancel: __('Cancel'),
+ },
components: {
GlButton,
+ GlModal,
GlDrawer,
GlLabel,
MountingPortal,
@@ -21,6 +27,9 @@ export default {
BoardSettingsListTypes: () =>
import('ee_component/boards/components/board_settings_list_types.vue'),
},
+ directives: {
+ GlModal: GlModalDirective,
+ },
mixins: [glFeatureFlagMixin(), Tracking.mixin()],
inject: ['canAdminList', 'scopedLabelsAvailable'],
inheritAttrs: false,
@@ -29,6 +38,7 @@ export default {
ListType,
};
},
+ modalId: 'board-settings-sidebar-modal',
computed: {
...mapGetters(['isSidebarOpen', 'isEpicBoard']),
...mapState(['activeId', 'sidebarType', 'boardLists']),
@@ -59,16 +69,16 @@ export default {
},
methods: {
...mapActions(['unsetActiveId', 'removeList']),
+ handleModalPrimary() {
+ this.deleteBoard();
+ },
showScopedLabels(label) {
return this.scopedLabelsAvailable && isScopedLabel(label);
},
deleteBoard() {
- // eslint-disable-next-line no-alert
- if (window.confirm(__('Are you sure you want to remove this list?'))) {
- this.track('click_button', { label: 'remove_list' });
- this.removeList(this.activeId);
- this.unsetActiveId();
- }
+ this.track('click_button', { label: 'remove_list' });
+ this.removeList(this.activeId);
+ this.unsetActiveId();
},
},
};
@@ -92,11 +102,10 @@ export default {
<template #header>
<div v-if="canAdminList && activeList.id" class="gl-mt-3">
<gl-button
+ v-gl-modal="$options.modalId"
variant="danger"
category="secondary"
size="small"
- data-testid="remove-list"
- @click.stop="deleteBoard"
>{{ __('Remove list') }}
</gl-button>
</div>
@@ -122,5 +131,21 @@ export default {
/>
</template>
</gl-drawer>
+ <gl-modal
+ :modal-id="$options.modalId"
+ :title="$options.i18n.modalAction"
+ size="sm"
+ :action-primary="{
+ text: $options.i18n.modalAction,
+ attributes: [{ variant: 'danger' }],
+ }"
+ :action-secondary="{
+ text: $options.i18n.modalCancel,
+ attributes: [{ variant: 'default' }],
+ }"
+ @primary="handleModalPrimary"
+ >
+ <p>{{ $options.i18n.modalCopy }}</p>
+ </gl-modal>
</mounting-portal>
</template>
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index 69343cd78d8..6dbb1ea0050 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -14,8 +14,6 @@ import { mapActions, mapGetters, mapState } from 'vuex';
import BoardForm from 'ee_else_ce/boards/components/board_form.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import axios from '~/lib/utils/axios_utils';
-import httpStatusCodes from '~/lib/utils/http_status';
import { s__ } from '~/locale';
import eventHub from '../eventhub';
@@ -23,6 +21,8 @@ import groupBoardsQuery from '../graphql/group_boards.query.graphql';
import projectBoardsQuery from '../graphql/project_boards.query.graphql';
import groupBoardQuery from '../graphql/group_board.query.graphql';
import projectBoardQuery from '../graphql/project_board.query.graphql';
+import groupRecentBoardsQuery from '../graphql/group_recent_boards.query.graphql';
+import projectRecentBoardsQuery from '../graphql/project_recent_boards.query.graphql';
const MIN_BOARDS_TO_VIEW_RECENT = 10;
@@ -40,7 +40,7 @@ export default {
directives: {
GlModalDirective,
},
- inject: ['fullPath', 'recentBoardsEndpoint'],
+ inject: ['fullPath'],
props: {
throttleDuration: {
type: Number,
@@ -158,6 +158,10 @@ export default {
this.scrollFadeInitialized = false;
this.$nextTick(this.setScrollFade);
},
+ recentBoards() {
+ this.scrollFadeInitialized = false;
+ this.$nextTick(this.setScrollFade);
+ },
},
created() {
eventHub.$on('showBoardModal', this.showPage);
@@ -173,11 +177,11 @@ export default {
cancel() {
this.showPage('');
},
- boardUpdate(data) {
+ boardUpdate(data, boardType) {
if (!data?.[this.parentType]) {
return [];
}
- return data[this.parentType].boards.edges.map(({ node }) => ({
+ return data[this.parentType][boardType].edges.map(({ node }) => ({
id: getIdFromGraphQLId(node.id),
name: node.name,
}));
@@ -185,6 +189,9 @@ export default {
boardQuery() {
return this.isGroupBoard ? groupBoardsQuery : projectBoardsQuery;
},
+ recentBoardsQuery() {
+ return this.isGroupBoard ? groupRecentBoardsQuery : projectRecentBoardsQuery;
+ },
loadBoards(toggleDropdown = true) {
if (toggleDropdown && this.boards.length > 0) {
return;
@@ -196,39 +203,20 @@ export default {
},
query: this.boardQuery,
loadingKey: 'loadingBoards',
- update: this.boardUpdate,
+ update: (data) => this.boardUpdate(data, 'boards'),
});
this.loadRecentBoards();
},
loadRecentBoards() {
- this.loadingRecentBoards = true;
- // Follow up to fetch recent boards using GraphQL
- // https://gitlab.com/gitlab-org/gitlab/-/issues/300985
- axios
- .get(this.recentBoardsEndpoint)
- .then((res) => {
- this.recentBoards = res.data;
- })
- .catch((err) => {
- /**
- * If user is unauthorized we'd still want to resolve the
- * request to display all boards.
- */
- if (err?.response?.status === httpStatusCodes.UNAUTHORIZED) {
- this.recentBoards = []; // recent boards are empty
- return;
- }
- throw err;
- })
- .then(() => this.$nextTick()) // Wait for boards list in DOM
- .then(() => {
- this.setScrollFade();
- })
- .catch(() => {})
- .finally(() => {
- this.loadingRecentBoards = false;
- });
+ this.$apollo.addSmartQuery('recentBoards', {
+ variables() {
+ return { fullPath: this.fullPath };
+ },
+ query: this.recentBoardsQuery,
+ loadingKey: 'loadingRecentBoards',
+ update: (data) => this.boardUpdate(data, 'recentIssueBoards'),
+ });
},
isScrolledUp() {
const { content } = this.$refs;
diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
index 7fc87f9f672..6bfdbb674a2 100644
--- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
@@ -157,6 +157,7 @@ export default {
symbol: '%',
token: MilestoneToken,
unique: true,
+ shouldSkipSort: true,
fetchMilestones: this.fetchMilestones,
},
{
diff --git a/app/assets/javascripts/boards/config_toggle.js b/app/assets/javascripts/boards/config_toggle.js
index 945a508c55d..1e54c2511b8 100644
--- a/app/assets/javascripts/boards/config_toggle.js
+++ b/app/assets/javascripts/boards/config_toggle.js
@@ -12,6 +12,7 @@ export default () => {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'ConfigToggleRoot',
render(h) {
return h(ConfigToggle, {
props: {
diff --git a/app/assets/javascripts/boards/graphql.js b/app/assets/javascripts/boards/graphql.js
index 64938cb42ed..95863d4d5ac 100644
--- a/app/assets/javascripts/boards/graphql.js
+++ b/app/assets/javascripts/boards/graphql.js
@@ -1,10 +1,5 @@
-import { IntrospectionFragmentMatcher, defaultDataIdFromObject } from 'apollo-cache-inmemory';
+import { defaultDataIdFromObject } from '@apollo/client/core';
import createDefaultClient from '~/lib/graphql';
-import introspectionQueryResultData from '~/sidebar/fragmentTypes.json';
-
-const fragmentMatcher = new IntrospectionFragmentMatcher({
- introspectionQueryResultData,
-});
export const gqlClient = createDefaultClient(
{},
@@ -14,8 +9,6 @@ export const gqlClient = createDefaultClient(
// eslint-disable-next-line no-underscore-dangle
return object.__typename === 'BoardList' ? object.iid : defaultDataIdFromObject(object);
},
-
- fragmentMatcher,
},
},
);
diff --git a/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql b/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql
index 6fe8bb799d6..9e6c26063e9 100644
--- a/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql
+++ b/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql
@@ -1,7 +1,12 @@
query GroupBoardMilestones($fullPath: ID!, $searchTerm: String, $state: MilestoneStateEnum) {
group(fullPath: $fullPath) {
id
- milestones(includeAncestors: true, searchTitle: $searchTerm, state: $state) {
+ milestones(
+ includeAncestors: true
+ searchTitle: $searchTerm
+ state: $state
+ sort: EXPIRED_LAST_DUE_DATE_ASC
+ ) {
nodes {
id
title
diff --git a/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql b/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql
new file mode 100644
index 00000000000..827c08486b1
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql
@@ -0,0 +1,14 @@
+#import "ee_else_ce/boards/graphql/board.fragment.graphql"
+
+query group_recent_boards($fullPath: ID!) {
+ group(fullPath: $fullPath) {
+ id
+ recentIssueBoards {
+ edges {
+ node {
+ ...BoardFragment
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql b/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql
index d917c7e809d..02aa08f90ef 100644
--- a/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql
+++ b/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql
@@ -1,7 +1,12 @@
query ProjectBoardMilestones($fullPath: ID!, $searchTerm: String, $state: MilestoneStateEnum) {
project(fullPath: $fullPath) {
id
- milestones(searchTitle: $searchTerm, includeAncestors: true, state: $state) {
+ milestones(
+ searchTitle: $searchTerm
+ includeAncestors: true
+ state: $state
+ sort: EXPIRED_LAST_DUE_DATE_ASC
+ ) {
nodes {
id
title
diff --git a/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql b/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql
new file mode 100644
index 00000000000..4d38e9b0498
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql
@@ -0,0 +1,14 @@
+#import "ee_else_ce/boards/graphql/board.fragment.graphql"
+
+query project_recent_boards($fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ id
+ recentIssueBoards {
+ edges {
+ node {
+ ...BoardFragment
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index ded3bfded86..f6073f9d981 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -64,6 +64,7 @@ function mountBoardApp(el) {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'BoardAppRoot',
store,
apolloProvider,
provide: {
@@ -121,6 +122,7 @@ export default () => {
// eslint-disable-next-line no-new
new Vue({
el: createColumnTriggerEl,
+ name: 'BoardAddNewColumnTriggerRoot',
components: {
BoardAddNewColumnTrigger,
},
@@ -144,7 +146,6 @@ export default () => {
mountMultipleBoardsSwitcher({
fullPath: $boardApp.dataset.fullPath,
rootPath: $boardApp.dataset.boardsEndpoint,
- recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint,
allowScopedLabels: $boardApp.dataset.scopedLabels,
labelsManagePath: $boardApp.dataset.labelsManagePath,
});
diff --git a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js
index a8ade58e316..327fb9ba8d7 100644
--- a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js
+++ b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js
@@ -18,6 +18,7 @@ export default (apolloProvider, isSignedIn, releasesFetchPath) => {
return new Vue({
el,
+ name: 'BoardFilteredSearchRoot',
provide: {
initialFilterParams,
isSignedIn,
diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
index ed32579a9c3..0bc9cfbd867 100644
--- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
+++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
@@ -1,27 +1,14 @@
-import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import BoardsSelector from 'ee_else_ce/boards/components/boards_selector.vue';
import store from '~/boards/stores';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
-import introspectionQueryResultData from '~/sidebar/fragmentTypes.json';
Vue.use(VueApollo);
-const fragmentMatcher = new IntrospectionFragmentMatcher({
- introspectionQueryResultData,
-});
-
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- cacheConfig: {
- fragmentMatcher,
- },
- },
- ),
+ defaultClient: createDefaultClient(),
});
export default (params = {}) => {
@@ -29,6 +16,7 @@ export default (params = {}) => {
const { dataset } = boardsSwitcherElement;
return new Vue({
el: boardsSwitcherElement,
+ name: 'BoardsSelectorRoot',
components: {
BoardsSelector,
},
@@ -37,7 +25,6 @@ export default (params = {}) => {
provide: {
fullPath: params.fullPath,
rootPath: params.rootPath,
- recentBoardsEndpoint: params.recentBoardsEndpoint,
allowScopedLabels: params.allowScopedLabels,
labelsManagePath: params.labelsManagePath,
allowLabelCreate: parseBoolean(dataset.canAdminBoard),
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index 48ca3239cfd..1ebfcfc331b 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -15,7 +15,6 @@ import {
FilterFields,
ListTypeTitles,
DraggableItemTypes,
- active,
} from 'ee_else_ce/boards/constants';
import {
formatIssueInput,
@@ -210,7 +209,6 @@ export default {
const variables = {
fullPath,
searchTerm,
- state: active,
};
let query;
diff --git a/app/assets/javascripts/boards/toggle_focus.js b/app/assets/javascripts/boards/toggle_focus.js
index 0a230f72dcc..8f057e192dd 100644
--- a/app/assets/javascripts/boards/toggle_focus.js
+++ b/app/assets/javascripts/boards/toggle_focus.js
@@ -6,6 +6,7 @@ export default () => {
return new Vue({
el: '#js-toggle-focus-btn',
+ name: 'ToggleFocusRoot',
render(h) {
return h(ToggleFocus, {
props: {
diff --git a/app/assets/javascripts/broadcast_notification.js b/app/assets/javascripts/broadcast_notification.js
index 2cf2e922f68..34282c6932e 100644
--- a/app/assets/javascripts/broadcast_notification.js
+++ b/app/assets/javascripts/broadcast_notification.js
@@ -1,4 +1,4 @@
-import Cookies from 'js-cookie';
+import { setCookie } from '~/lib/utils/common_utils';
const handleOnDismiss = ({ currentTarget }) => {
currentTarget.removeEventListener('click', handleOnDismiss);
@@ -6,7 +6,7 @@ const handleOnDismiss = ({ currentTarget }) => {
dataset: { id, expireDate },
} = currentTarget;
- Cookies.set(`hide_broadcast_message_${id}`, true, { expires: new Date(expireDate) });
+ setCookie(`hide_broadcast_message_${id}`, true, { expires: new Date(expireDate) });
const notification = document.querySelector(`.js-broadcast-notification-${id}`);
notification.parentNode.removeChild(notification);
diff --git a/app/assets/javascripts/captcha/apollo_captcha_link.js b/app/assets/javascripts/captcha/apollo_captcha_link.js
index e49abc10b29..d63ffaf5f1a 100644
--- a/app/assets/javascripts/captcha/apollo_captcha_link.js
+++ b/app/assets/javascripts/captcha/apollo_captcha_link.js
@@ -1,4 +1,4 @@
-import { ApolloLink, Observable } from 'apollo-link';
+import { ApolloLink, Observable } from '@apollo/client/core';
export const apolloCaptchaLink = new ApolloLink((operation, forward) =>
forward(operation).flatMap((result) => {
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
index e630ce71bd3..2e198c59926 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
@@ -14,8 +14,8 @@ import {
GlModal,
GlSprintf,
} from '@gitlab/ui';
-import Cookies from 'js-cookie';
import { mapActions, mapState } from 'vuex';
+import { getCookie, setCookie } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import Tracking from '~/tracking';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -59,7 +59,7 @@ export default {
mixins: [glFeatureFlagsMixin(), trackingMixin],
data() {
return {
- isTipDismissed: Cookies.get(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true',
+ isTipDismissed: getCookie(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true',
validationErrorEventProperty: '',
};
},
@@ -176,7 +176,7 @@ export default {
'setVariableProtected',
]),
dismissTip() {
- Cookies.set(AWS_TIP_DISMISSED_COOKIE_NAME, 'true', { expires: 90 });
+ setCookie(AWS_TIP_DISMISSED_COOKIE_NAME, 'true', { expires: 90 });
this.isTipDismissed = true;
},
deleteVarAndClose() {
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
index 9c0ffab7f6b..61636b389da 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
@@ -3,6 +3,7 @@ import { GlTable, GlButton, GlModalDirective, GlIcon, GlTooltipDirective } from
import { mapState, mapActions } from 'vuex';
import { s__, __ } 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 { ADD_CI_VARIABLE_MODAL_ID } from '../constants';
import CiVariablePopover from './ci_variable_popover.vue';
@@ -52,10 +53,11 @@ export default {
},
],
components: {
- GlTable,
+ CiVariablePopover,
GlButton,
GlIcon,
- CiVariablePopover,
+ GlTable,
+ TooltipOnTruncate,
},
directives: {
GlModalDirective,
@@ -67,8 +69,8 @@ export default {
valuesButtonText() {
return this.valuesHidden ? __('Reveal values') : __('Hide values');
},
- tableIsNotEmpty() {
- return this.variables && this.variables.length > 0;
+ isTableEmpty() {
+ return !this.variables || this.variables.length === 0;
},
fields() {
return this.$options.fields;
@@ -103,12 +105,14 @@ export default {
<col v-for="field in scope.fields" :key="field.key" :style="field.customStyle" />
</template>
<template #cell(key)="{ item }">
- <div class="gl-display-flex truncated-container gl-align-items-center">
- <span
- :id="`ci-variable-key-${item.id}`"
- class="gl-display-inline-block gl-max-w-full gl-text-truncate"
- >{{ item.key }}</span
- >
+ <div class="gl-display-flex gl-align-items-center">
+ <tooltip-on-truncate :title="item.key" truncate-target="child">
+ <span
+ :id="`ci-variable-key-${item.id}`"
+ class="gl-display-inline-block gl-max-w-full gl-text-truncate"
+ >{{ item.key }}</span
+ >
+ </tooltip-on-truncate>
<gl-button
v-gl-tooltip
category="tertiary"
@@ -120,7 +124,7 @@ export default {
</div>
</template>
<template #cell(value)="{ item }">
- <div class="gl-display-flex gl-align-items-center truncated-container">
+ <div class="gl-display-flex gl-align-items-center">
<span v-if="valuesHidden">*********************</span>
<span
v-else
@@ -147,10 +151,12 @@ export default {
<gl-icon v-else :size="$options.iconSize" :name="$options.falseIcon" />
</template>
<template #cell(environment_scope)="{ item }">
- <div class="d-flex truncated-container">
- <span :id="`ci-variable-env-${item.id}`" class="d-inline-block mw-100 text-truncate">{{
- item.environment_scope
- }}</span>
+ <div class="gl-display-flex">
+ <span
+ :id="`ci-variable-env-${item.id}`"
+ class="gl-display-inline-block gl-max-w-full gl-text-truncate"
+ >{{ item.environment_scope }}</span
+ >
<ci-variable-popover
:target="`ci-variable-env-${item.id}`"
:value="item.environment_scope"
@@ -160,7 +166,6 @@ export default {
</template>
<template #cell(actions)="{ item }">
<gl-button
- ref="edit-ci-variable"
v-gl-modal-directive="$options.modalId"
icon="pencil"
:aria-label="__('Edit')"
@@ -169,17 +174,16 @@ export default {
/>
</template>
<template #empty>
- <p ref="empty-variables" class="text-center empty-variables text-plain">
+ <p class="gl-text-center gl-py-6 gl-text-black-normal gl-mb-0">
{{ __('There are no variables yet.') }}
</p>
</template>
</gl-table>
<div
class="ci-variable-actions gl-display-flex"
- :class="{ 'justify-content-center': !tableIsNotEmpty }"
+ :class="{ 'gl-justify-content-center': isTableEmpty }"
>
<gl-button
- ref="add-ci-variable"
v-gl-modal-directive="$options.modalId"
class="gl-mr-3"
data-qa-selector="add_ci_variable_button"
@@ -188,8 +192,7 @@ export default {
>{{ __('Add variable') }}</gl-button
>
<gl-button
- v-if="tableIsNotEmpty"
- ref="secret-value-reveal-button"
+ v-if="!isTableEmpty"
data-qa-selector="reveal_ci_variable_value_button"
@click="toggleValues(!valuesHidden)"
>{{ valuesButtonText }}</gl-button
diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue
index a53bba6992d..63f068a9327 100644
--- a/app/assets/javascripts/clusters/agents/components/show.vue
+++ b/app/assets/javascripts/clusters/agents/components/show.vue
@@ -10,7 +10,7 @@ import {
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import { MAX_LIST_COUNT } from '../constants';
+import { MAX_LIST_COUNT, TOKEN_STATUS_ACTIVE } from '../constants';
import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql';
import TokenTable from './token_table.vue';
import ActivityEvents from './activity_events_list.vue';
@@ -30,6 +30,7 @@ export default {
return {
agentName: this.agentName,
projectPath: this.projectPath,
+ tokenStatus: TOKEN_STATUS_ACTIVE,
...this.cursor,
};
},
diff --git a/app/assets/javascripts/clusters/agents/constants.js b/app/assets/javascripts/clusters/agents/constants.js
index 315c7662755..98d4707b4de 100644
--- a/app/assets/javascripts/clusters/agents/constants.js
+++ b/app/assets/javascripts/clusters/agents/constants.js
@@ -36,3 +36,4 @@ export const EVENT_DETAILS = {
};
export const DEFAULT_ICON = 'token';
+export const TOKEN_STATUS_ACTIVE = 'ACTIVE';
diff --git a/app/assets/javascripts/clusters/agents/graphql/provider.js b/app/assets/javascripts/clusters/agents/graphql/provider.js
index 8b068fa1eee..9153c5252b3 100644
--- a/app/assets/javascripts/clusters/agents/graphql/provider.js
+++ b/app/assets/javascripts/clusters/agents/graphql/provider.js
@@ -1,25 +1,10 @@
-import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import { vulnerabilityLocationTypes } from '~/graphql_shared/fragment_types/vulnerability_location_types';
Vue.use(VueApollo);
-// We create a fragment matcher so that we can create a fragment from an interface
-// Without this, Apollo throws a heuristic fragment matcher warning
-const fragmentMatcher = new IntrospectionFragmentMatcher({
- introspectionQueryResultData: vulnerabilityLocationTypes,
-});
-
-const defaultClient = createDefaultClient(
- {},
- {
- cacheConfig: {
- fragmentMatcher,
- },
- },
-);
+const defaultClient = createDefaultClient();
export default new VueApollo({
defaultClient,
diff --git a/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql
index 3662e925261..3610662afc0 100644
--- a/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql
+++ b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql
@@ -4,6 +4,7 @@
query getClusterAgent(
$projectPath: ID!
$agentName: String!
+ $tokenStatus: AgentTokenStatus!
$first: Int
$last: Int
$afterToken: String
@@ -20,7 +21,13 @@ query getClusterAgent(
name
}
- tokens(first: $first, last: $last, before: $beforeToken, after: $afterToken) {
+ tokens(
+ status: $tokenStatus
+ first: $first
+ last: $last
+ before: $beforeToken
+ after: $afterToken
+ ) {
count
nodes {
diff --git a/app/assets/javascripts/clusters/agents/index.js b/app/assets/javascripts/clusters/agents/index.js
index 6c7fae274f8..ba7b3edba72 100644
--- a/app/assets/javascripts/clusters/agents/index.js
+++ b/app/assets/javascripts/clusters/agents/index.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import AgentShowPage from 'ee_else_ce/clusters/agents/components/show.vue';
import apolloProvider from './graphql/provider';
+import createRouter from './router';
export default () => {
const el = document.querySelector('#js-cluster-agent-details');
@@ -9,14 +10,22 @@ export default () => {
return null;
}
- const { activityEmptyStateImage, agentName, emptyStateSvgPath, projectPath } = el.dataset;
+ const {
+ activityEmptyStateImage,
+ agentName,
+ canAdminVulnerability,
+ emptyStateSvgPath,
+ projectPath,
+ } = el.dataset;
return new Vue({
el,
apolloProvider,
+ router: createRouter(),
provide: {
activityEmptyStateImage,
agentName,
+ canAdminVulnerability,
emptyStateSvgPath,
projectPath,
},
diff --git a/app/assets/javascripts/clusters/agents/router.js b/app/assets/javascripts/clusters/agents/router.js
new file mode 100644
index 00000000000..162a91dc300
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/router.js
@@ -0,0 +1,22 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+
+Vue.use(VueRouter);
+
+// Vue Router requires a component to render if the route matches, but since we're only using it for
+// querystring handling, we'll create an empty component.
+const EmptyRouterComponent = {
+ render(createElement) {
+ return createElement('div');
+ },
+};
+
+export default () => {
+ // Name and path here don't really matter since we're not rendering anything if the route matches.
+ const routes = [{ path: '/', name: 'cluster_agents', component: EmptyRouterComponent }];
+ return new VueRouter({
+ mode: 'history',
+ base: window.location.pathname,
+ routes,
+ });
+};
diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue
index 695e16b7b4b..61c4904aacf 100644
--- a/app/assets/javascripts/clusters_list/components/agent_table.vue
+++ b/app/assets/javascripts/clusters_list/components/agent_table.vue
@@ -1,23 +1,14 @@
<script>
import { GlLink, GlTable, GlIcon, GlSprintf, GlTooltip, GlPopover } from '@gitlab/ui';
-import { s__, __ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { helpPagePath } from '~/helpers/help_page_helper';
-import { AGENT_STATUSES } from '../constants';
+import { AGENT_STATUSES, I18N_AGENT_TABLE } from '../constants';
import { getAgentConfigPath } from '../clusters_util';
-import AgentOptions from './agent_options.vue';
+import DeleteAgentButton from './delete_agent_button.vue';
export default {
- i18n: {
- nameLabel: s__('ClusterAgents|Name'),
- statusLabel: s__('ClusterAgents|Connection status'),
- lastContactLabel: s__('ClusterAgents|Last contact'),
- configurationLabel: s__('ClusterAgents|Configuration'),
- optionsLabel: __('Options'),
- troubleshootingText: s__('ClusterAgents|Learn how to troubleshoot'),
- neverConnectedText: s__('ClusterAgents|Never'),
- },
+ i18n: I18N_AGENT_TABLE,
components: {
GlLink,
GlTable,
@@ -26,13 +17,15 @@ export default {
GlTooltip,
GlPopover,
TimeAgoTooltip,
- AgentOptions,
+ DeleteAgentButton,
},
mixins: [timeagoMixin],
AGENT_STATUSES,
- troubleshooting_link: helpPagePath('user/clusters/agent/index', {
- anchor: 'troubleshooting',
+ troubleshootingLink: helpPagePath('user/clusters/agent/troubleshooting'),
+ versionUpdateLink: helpPagePath('user/clusters/agent/install/index', {
+ anchor: 'update-the-agent-version',
}),
+ inject: ['gitlabVersion'],
props: {
agents: {
required: true,
@@ -69,30 +62,93 @@ export default {
tdClass,
},
{
+ key: 'version',
+ label: this.$options.i18n.versionLabel,
+ tdClass,
+ },
+ {
key: 'configuration',
label: this.$options.i18n.configurationLabel,
tdClass,
},
{
key: 'options',
- label: this.$options.i18n.optionsLabel,
+ label: '',
tdClass,
},
];
},
+ agentsList() {
+ if (!this.agents.length) {
+ return [];
+ }
+
+ return this.agents.map((agent) => {
+ const versions = this.getAgentVersions(agent);
+ return { ...agent, versions };
+ });
+ },
},
methods: {
- getCellId(item) {
+ getStatusCellId(item) {
return `connection-status-${item.name}`;
},
+ getVersionCellId(item) {
+ return `version-${item.name}`;
+ },
+ getPopoverTestId(item) {
+ return `popover-${item.name}`;
+ },
getAgentConfigPath,
+ getAgentVersions(agent) {
+ const agentConnections = agent.connections?.nodes || [];
+
+ const agentVersions = agentConnections.map((agentConnection) =>
+ agentConnection.metadata.version.replace('v', ''),
+ );
+
+ const uniqueAgentVersions = [...new Set(agentVersions)];
+
+ return uniqueAgentVersions.sort((a, b) => a.localeCompare(b));
+ },
+ getAgentVersionString(agent) {
+ return agent.versions[0] || '';
+ },
+ isVersionMismatch(agent) {
+ return agent.versions.length > 1;
+ },
+ isVersionOutdated(agent) {
+ if (!agent.versions.length) return false;
+
+ const [agentMajorVersion, agentMinorVersion] = this.getAgentVersionString(agent).split('.');
+ const [gitlabMajorVersion, gitlabMinorVersion] = this.gitlabVersion.split('.');
+
+ const majorVersionMismatch = agentMajorVersion !== gitlabMajorVersion;
+
+ // We should warn user if their current GitLab and agent versions are more than 1 minor version apart:
+ const minorVersionMismatch = Math.abs(agentMinorVersion - gitlabMinorVersion) > 1;
+
+ return majorVersionMismatch || minorVersionMismatch;
+ },
+
+ getVersionPopoverTitle(agent) {
+ if (this.isVersionMismatch(agent) && this.isVersionOutdated(agent)) {
+ return this.$options.i18n.versionMismatchOutdatedTitle;
+ } else if (this.isVersionMismatch(agent)) {
+ return this.$options.i18n.versionMismatchTitle;
+ } else if (this.isVersionOutdated(agent)) {
+ return this.$options.i18n.versionOutdatedTitle;
+ }
+
+ return null;
+ },
},
};
</script>
<template>
<gl-table
- :items="agents"
+ :items="agentsList"
:fields="fields"
stacked="md"
head-variant="white"
@@ -107,19 +163,23 @@ export default {
</template>
<template #cell(status)="{ item }">
- <span :id="getCellId(item)" class="gl-md-pr-5" data-testid="cluster-agent-connection-status">
+ <span
+ :id="getStatusCellId(item)"
+ class="gl-md-pr-5"
+ 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
>{{ $options.AGENT_STATUSES[item.status].name }}
</span>
- <gl-tooltip v-if="item.status === 'active'" :target="getCellId(item)" placement="right">
+ <gl-tooltip v-if="item.status === 'active'" :target="getStatusCellId(item)" placement="right">
<gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.title"
><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template>
</gl-sprintf>
</gl-tooltip>
<gl-popover
v-else
- :target="getCellId(item)"
+ :target="getStatusCellId(item)"
:title="$options.AGENT_STATUSES[item.status].tooltip.title"
placement="right"
container="viewport"
@@ -130,7 +190,7 @@ export default {
>
</p>
<p class="gl-mb-0">
- <gl-link :href="$options.troubleshooting_link" target="_blank" class="gl-font-sm">
+ <gl-link :href="$options.troubleshootingLink" target="_blank" class="gl-font-sm">
{{ $options.i18n.troubleshootingText }}</gl-link
>
</p>
@@ -144,6 +204,52 @@ export default {
</span>
</template>
+ <template #cell(version)="{ item }">
+ <span :id="getVersionCellId(item)" data-testid="cluster-agent-version">
+ {{ getAgentVersionString(item) }}
+
+ <gl-icon
+ v-if="isVersionMismatch(item) || isVersionOutdated(item)"
+ name="warning"
+ class="gl-text-orange-500 gl-ml-2"
+ />
+ </span>
+
+ <gl-popover
+ v-if="isVersionMismatch(item) || isVersionOutdated(item)"
+ :target="getVersionCellId(item)"
+ :title="getVersionPopoverTitle(item)"
+ :data-testid="getPopoverTestId(item)"
+ placement="right"
+ container="viewport"
+ >
+ <div v-if="isVersionMismatch(item) && isVersionOutdated(item)">
+ <p>{{ $options.i18n.versionMismatchText }}</p>
+
+ <p class="gl-mb-0">
+ <gl-sprintf :message="$options.i18n.versionOutdatedText">
+ <template #version>{{ gitlabVersion }}</template>
+ </gl-sprintf>
+ <gl-link :href="$options.versionUpdateLink" class="gl-font-sm">
+ {{ $options.i18n.viewDocsText }}</gl-link
+ >
+ </p>
+ </div>
+ <p v-else-if="isVersionMismatch(item)" class="gl-mb-0">
+ {{ $options.i18n.versionMismatchText }}
+ </p>
+
+ <p v-else-if="isVersionOutdated(item)" class="gl-mb-0">
+ <gl-sprintf :message="$options.i18n.versionOutdatedText">
+ <template #version>{{ gitlabVersion }}</template>
+ </gl-sprintf>
+ <gl-link :href="$options.versionUpdateLink" class="gl-font-sm">
+ {{ $options.i18n.viewDocsText }}</gl-link
+ >
+ </p>
+ </gl-popover>
+ </template>
+
<template #cell(configuration)="{ item }">
<span data-testid="cluster-agent-configuration-link">
<gl-link v-if="item.configFolder" :href="item.configFolder.webPath">
@@ -155,7 +261,7 @@ export default {
</template>
<template #cell(options)="{ item }">
- <agent-options
+ <delete-agent-button
:agent="item"
:default-branch-name="defaultBranchName"
:max-agents="maxAgents"
diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue
index 4fc421e7c31..bf096f53e9d 100644
--- a/app/assets/javascripts/clusters_list/components/agents.vue
+++ b/app/assets/javascripts/clusters_list/components/agents.vue
@@ -1,11 +1,29 @@
<script>
-import { GlAlert, GlKeysetPagination, GlLoadingIcon } from '@gitlab/ui';
-import { MAX_LIST_COUNT, ACTIVE_CONNECTION_TIME } from '../constants';
+import { GlAlert, GlKeysetPagination, GlLoadingIcon, GlBanner } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import {
+ MAX_LIST_COUNT,
+ ACTIVE_CONNECTION_TIME,
+ AGENT_FEEDBACK_ISSUE,
+ AGENT_FEEDBACK_KEY,
+} from '../constants';
import getAgentsQuery from '../graphql/queries/get_agents.query.graphql';
import AgentEmptyState from './agent_empty_state.vue';
import AgentTable from './agent_table.vue';
export default {
+ i18n: {
+ feedbackBannerTitle: s__('ClusterAgents|Tell us what you think'),
+ feedbackBannerText: s__(
+ 'ClusterAgents|We would love to learn more about your experience with the GitLab Agent.',
+ ),
+ feedbackBannerButton: s__('ClusterAgents|Give feedback'),
+ error: s__('ClusterAgents|An error occurred while loading your Agents'),
+ },
+ AGENT_FEEDBACK_ISSUE,
+ AGENT_FEEDBACK_KEY,
apollo: {
agents: {
query: getAgentsQuery,
@@ -31,7 +49,10 @@ export default {
GlAlert,
GlKeysetPagination,
GlLoadingIcon,
+ GlBanner,
+ LocalStorageSync,
},
+ mixins: [glFeatureFlagMixin()],
inject: ['projectPath'],
props: {
defaultBranchName: {
@@ -57,6 +78,7 @@ export default {
last: null,
},
folderList: {},
+ feedbackBannerDismissed: false,
};
},
computed: {
@@ -86,6 +108,12 @@ export default {
treePageInfo() {
return this.agents?.project?.repository?.tree?.trees?.pageInfo || {};
},
+ feedbackBannerEnabled() {
+ return this.glFeatures.showGitlabAgentFeedback;
+ },
+ feedbackBannerClasses() {
+ return this.isChildComponent ? 'gl-my-2' : 'gl-mb-4';
+ },
},
methods: {
reloadAgents() {
@@ -142,6 +170,9 @@ export default {
const count = this.agents?.project?.clusterAgents?.count;
this.$emit('onAgentsLoad', count);
},
+ handleBannerClose() {
+ this.feedbackBannerDismissed = true;
+ },
},
};
</script>
@@ -151,6 +182,24 @@ export default {
<section v-else-if="agentList">
<div v-if="agentList.length">
+ <local-storage-sync
+ v-if="feedbackBannerEnabled"
+ v-model="feedbackBannerDismissed"
+ :storage-key="$options.AGENT_FEEDBACK_KEY"
+ >
+ <gl-banner
+ v-if="!feedbackBannerDismissed"
+ variant="introduction"
+ :class="feedbackBannerClasses"
+ :title="$options.i18n.feedbackBannerTitle"
+ :button-text="$options.i18n.feedbackBannerButton"
+ :button-link="$options.AGENT_FEEDBACK_ISSUE"
+ @close="handleBannerClose"
+ >
+ <p>{{ $options.i18n.feedbackBannerText }}</p>
+ </gl-banner>
+ </local-storage-sync>
+
<agent-table
:agents="agentList"
:default-branch-name="defaultBranchName"
@@ -166,6 +215,6 @@ export default {
</section>
<gl-alert v-else variant="danger" :dismissible="false">
- {{ s__('ClusterAgents|An error occurred while loading your GitLab Agents') }}
+ {{ $options.i18n.error }}
</gl-alert>
</template>
diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue
index 9c330045596..7fb3aa3ff7e 100644
--- a/app/assets/javascripts/clusters_list/components/clusters.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters.vue
@@ -57,7 +57,7 @@ export default {
'totalClusters',
]),
contentAlignClasses() {
- return 'gl-display-flex gl-align-items-center gl-justify-content-end gl-justify-content-md-start';
+ return 'gl-display-flex gl-align-items-center gl-justify-content-end gl-md-justify-content-start';
},
currentPage: {
get() {
diff --git a/app/assets/javascripts/clusters_list/components/clusters_actions.vue b/app/assets/javascripts/clusters_list/components/clusters_actions.vue
index 25f67462223..5b8dc74b84f 100644
--- a/app/assets/javascripts/clusters_list/components/clusters_actions.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters_actions.vue
@@ -1,5 +1,13 @@
<script>
-import { GlDropdown, GlDropdownItem, GlModalDirective } from '@gitlab/ui';
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlModalDirective,
+ GlTooltipDirective,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
+} from '@gitlab/ui';
+
import { INSTALL_AGENT_MODAL_ID, CLUSTERS_ACTIONS } from '../constants';
export default {
@@ -8,11 +16,20 @@ export default {
components: {
GlDropdown,
GlDropdownItem,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
},
directives: {
GlModalDirective,
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: ['newClusterPath', 'addClusterPath', 'canAddCluster'],
+ computed: {
+ tooltip() {
+ const { connectWithAgent, dropdownDisabledHint } = this.$options.i18n;
+ return this.canAddCluster ? connectWithAgent : dropdownDisabledHint;
+ },
},
- inject: ['newClusterPath', 'addClusterPath'],
};
</script>
@@ -20,22 +37,27 @@ export default {
<div class="nav-controls gl-ml-auto">
<gl-dropdown
ref="dropdown"
- v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
+ v-gl-modal-directive="canAddCluster && $options.INSTALL_AGENT_MODAL_ID"
+ v-gl-tooltip="tooltip"
category="primary"
variant="confirm"
:text="$options.i18n.actionsButton"
+ :disabled="!canAddCluster"
split
right
>
- <gl-dropdown-item :href="newClusterPath" data-testid="new-cluster-link" @click.stop>
- {{ $options.i18n.createNewCluster }}
- </gl-dropdown-item>
+ <gl-dropdown-section-header>{{ $options.i18n.agent }}</gl-dropdown-section-header>
<gl-dropdown-item
v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
data-testid="connect-new-agent-link"
>
{{ $options.i18n.connectWithAgent }}
</gl-dropdown-item>
+ <gl-dropdown-divider />
+ <gl-dropdown-section-header>{{ $options.i18n.certificate }}</gl-dropdown-section-header>
+ <gl-dropdown-item :href="newClusterPath" data-testid="new-cluster-link" @click.stop>
+ {{ $options.i18n.createNewCluster }}
+ </gl-dropdown-item>
<gl-dropdown-item :href="addClusterPath" data-testid="connect-cluster-link" @click.stop>
{{ $options.i18n.connectExistingCluster }}
</gl-dropdown-item>
diff --git a/app/assets/javascripts/clusters_list/components/clusters_view_all.vue b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue
index 0e312d21e4e..b730c0adfa2 100644
--- a/app/assets/javascripts/clusters_list/components/clusters_view_all.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue
@@ -8,6 +8,7 @@ import {
GlBadge,
GlLoadingIcon,
GlModalDirective,
+ GlTooltipDirective,
} from '@gitlab/ui';
import { mapState } from 'vuex';
import {
@@ -33,6 +34,7 @@ export default {
},
directives: {
GlModalDirective,
+ GlTooltip: GlTooltipDirective,
},
MAX_CLUSTERS_LIST,
INSTALL_AGENT_MODAL_ID,
@@ -40,7 +42,7 @@ export default {
agent: AGENT_CARD_INFO,
certificate: CERTIFICATE_BASED_CARD_INFO,
},
- inject: ['addClusterPath'],
+ inject: ['addClusterPath', 'canAddCluster'],
props: {
defaultBranchName: {
default: '.noBranch',
@@ -91,6 +93,14 @@ export default {
return cardTitle;
},
+ installAgentTooltip() {
+ return this.canAddCluster ? '' : this.$options.i18n.agent.installAgentDisabledHint;
+ },
+ connectExistingClusterTooltip() {
+ return this.canAddCluster
+ ? ''
+ : this.$options.i18n.certificate.connectExistingClusterDisabledHint;
+ },
},
methods: {
cardFooterNumber(number) {
@@ -113,7 +123,7 @@ export default {
<div v-show="!isLoading" data-testid="clusters-cards-container">
<gl-card
header-class="gl-bg-white gl-display-flex gl-align-items-center gl-justify-content-space-between gl-py-4"
- body-class="gl-pb-0"
+ body-class="gl-pb-0 cluster-card-item"
footer-class="gl-text-right"
>
<template #header>
@@ -166,20 +176,29 @@ export default {
><gl-sprintf :message="$options.i18n.agent.footerText"
><template #number>{{ cardFooterNumber(totalAgents) }}</template></gl-sprintf
></gl-link
- ><gl-button
- v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
- class="gl-ml-4"
- category="secondary"
- variant="confirm"
- >{{ $options.i18n.agent.actionText }}</gl-button
>
+ <div
+ v-gl-tooltip="installAgentTooltip"
+ class="gl-display-inline-block"
+ tabindex="-1"
+ data-testid="install-agent-button-tooltip"
+ >
+ <gl-button
+ v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
+ class="gl-ml-4"
+ category="secondary"
+ variant="confirm"
+ :disabled="!canAddCluster"
+ >{{ $options.i18n.agent.actionText }}</gl-button
+ >
+ </div>
</template>
</gl-card>
<gl-card
class="gl-mt-6"
header-class="gl-bg-white gl-display-flex gl-align-items-center gl-justify-content-space-between"
- body-class="gl-pb-0"
+ body-class="gl-pb-0 cluster-card-item"
footer-class="gl-text-right"
>
<template #header>
@@ -206,14 +225,23 @@ export default {
><gl-sprintf :message="$options.i18n.certificate.footerText"
><template #number>{{ cardFooterNumber(totalClusters) }}</template></gl-sprintf
></gl-link
- ><gl-button
- category="secondary"
- data-qa-selector="connect_existing_cluster_button"
- variant="confirm"
- class="gl-ml-4"
- :href="addClusterPath"
- >{{ $options.i18n.certificate.actionText }}</gl-button
>
+ <div
+ v-gl-tooltip="connectExistingClusterTooltip"
+ class="gl-display-inline-block"
+ tabindex="-1"
+ data-testid="connect-existing-cluster-button-tooltip"
+ >
+ <gl-button
+ category="secondary"
+ data-qa-selector="connect_existing_cluster_button"
+ variant="confirm"
+ class="gl-ml-4"
+ :href="addClusterPath"
+ :disabled="!canAddCluster"
+ >{{ $options.i18n.certificate.actionText }}</gl-button
+ >
+ </div>
</template>
</gl-card>
</div>
diff --git a/app/assets/javascripts/clusters_list/components/agent_options.vue b/app/assets/javascripts/clusters_list/components/delete_agent_button.vue
index a364122ba56..6588d304d5c 100644
--- a/app/assets/javascripts/clusters_list/components/agent_options.vue
+++ b/app/assets/javascripts/clusters_list/components/delete_agent_button.vue
@@ -1,36 +1,23 @@
<script>
import {
- GlDropdown,
- GlDropdownItem,
+ GlButton,
GlModal,
GlModalDirective,
GlSprintf,
GlFormGroup,
GlFormInput,
+ GlTooltipDirective,
} from '@gitlab/ui';
-import { s__, __, sprintf } from '~/locale';
-import { DELETE_AGENT_MODAL_ID } from '../constants';
+import { sprintf } from '~/locale';
+import { DELETE_AGENT_BUTTON, DELETE_AGENT_MODAL_ID } from '../constants';
import deleteAgent from '../graphql/mutations/delete_agent.mutation.graphql';
import getAgentsQuery from '../graphql/queries/get_agents.query.graphql';
import { removeAgentFromStore } from '../graphql/cache_update';
export default {
- i18n: {
- dropdownText: __('More options'),
- deleteButton: s__('ClusterAgents|Delete agent'),
- modalTitle: __('Are you sure?'),
- modalBody: s__(
- 'ClusterAgents|Are you sure you want to delete this agent? You cannot undo this.',
- ),
- modalInputLabel: s__('ClusterAgents|To delete the agent, type %{name} to confirm:'),
- modalAction: s__('ClusterAgents|Delete'),
- modalCancel: __('Cancel'),
- successMessage: s__('ClusterAgents|%{name} successfully deleted'),
- defaultError: __('An error occurred. Please try again.'),
- },
+ i18n: DELETE_AGENT_BUTTON,
components: {
- GlDropdown,
- GlDropdownItem,
+ GlButton,
GlModal,
GlSprintf,
GlFormGroup,
@@ -38,8 +25,9 @@ export default {
},
directives: {
GlModalDirective,
+ GlTooltip: GlTooltipDirective,
},
- inject: ['projectPath'],
+ inject: ['projectPath', 'canAdminCluster'],
props: {
agent: {
required: true,
@@ -66,6 +54,13 @@ export default {
};
},
computed: {
+ deleteButtonDisabled() {
+ return this.loading || !this.canAdminCluster;
+ },
+ deleteButtonTooltip() {
+ const { deleteButton, disabledHint } = this.$options.i18n;
+ return this.deleteButtonDisabled ? disabledHint : deleteButton;
+ },
getAgentsQueryVariables() {
return {
defaultBranchName: this.defaultBranchName,
@@ -159,19 +154,22 @@ export default {
<template>
<div>
- <gl-dropdown
- icon="ellipsis_v"
- right
- :disabled="loading"
- :text="$options.i18n.dropdownText"
- text-sr-only
- category="tertiary"
- no-caret
+ <div
+ v-gl-tooltip="deleteButtonTooltip"
+ class="gl-display-inline-block"
+ tabindex="-1"
+ data-testid="delete-agent-button-tooltip"
>
- <gl-dropdown-item v-gl-modal-directive="modalId">
- {{ $options.i18n.deleteButton }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <gl-button
+ ref="deleteAgentButton"
+ v-gl-modal-directive="modalId"
+ icon="remove"
+ category="secondary"
+ variant="danger"
+ :disabled="deleteButtonDisabled"
+ :aria-label="$options.i18n.deleteButton"
+ />
+ </div>
<gl-modal
ref="modal"
diff --git a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
index 5eef76252bd..8fc0a66cd7e 100644
--- a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
+++ b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
@@ -111,6 +111,9 @@ export default {
canCancel() {
return !this.registered && !this.registering && this.isAgentRegistrationModal;
},
+ canRegister() {
+ return !this.registered && this.isAgentRegistrationModal;
+ },
agentRegistrationCommand() {
return generateAgentRegistrationCommand(this.agentToken, this.kasAddress);
},
@@ -142,6 +145,9 @@ export default {
isAgentRegistrationModal() {
return this.modalType === MODAL_TYPE_REGISTER;
},
+ isKasEnabledInEmptyStateModal() {
+ return this.isEmptyStateModal && !this.kasDisabled;
+ },
},
methods: {
setAgentName(name) {
@@ -350,18 +356,18 @@ export default {
<img :alt="i18n.altText" :src="emptyStateImage" height="100" />
</div>
- <p>
- <gl-sprintf :message="i18n.modalBody">
+ <p v-if="kasDisabled">
+ <gl-sprintf :message="i18n.enableKasText">
<template #link="{ content }">
- <gl-link :href="$options.installAgentPath"> {{ content }}</gl-link>
+ <gl-link :href="$options.enableKasPath">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
- <p v-if="kasDisabled">
- <gl-sprintf :message="i18n.enableKasText">
+ <p v-else>
+ <gl-sprintf :message="i18n.modalBody">
<template #link="{ content }">
- <gl-link :href="$options.enableKasPath"> {{ content }}</gl-link>
+ <gl-link :href="$options.installAgentPath">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
@@ -380,7 +386,16 @@ export default {
</gl-button>
<gl-button
- v-else-if="isAgentRegistrationModal"
+ v-if="canCancel"
+ :data-track-action="$options.EVENT_ACTIONS_CLICK"
+ :data-track-label="$options.EVENT_LABEL_MODAL"
+ data-track-property="cancel"
+ @click="closeModal"
+ >{{ i18n.cancel }}
+ </gl-button>
+
+ <gl-button
+ v-if="canRegister"
:disabled="!nextButtonDisabled"
variant="confirm"
category="primary"
@@ -392,32 +407,21 @@ export default {
</gl-button>
<gl-button
- v-if="canCancel"
+ v-if="isEmptyStateModal"
:data-track-action="$options.EVENT_ACTIONS_CLICK"
:data-track-label="$options.EVENT_LABEL_MODAL"
- data-track-property="cancel"
+ data-track-property="done"
@click="closeModal"
- >{{ i18n.cancel }}
+ >{{ i18n.done }}
</gl-button>
<gl-button
- v-if="isEmptyStateModal"
+ v-if="isKasEnabledInEmptyStateModal"
:href="repositoryPath"
variant="confirm"
- category="secondary"
- data-testid="agent-secondary-button"
- >{{ i18n.secondaryButton }}
- </gl-button>
-
- <gl-button
- v-if="isEmptyStateModal"
- variant="confirm"
category="primary"
- :data-track-action="$options.EVENT_ACTIONS_CLICK"
- :data-track-label="$options.EVENT_LABEL_MODAL"
- data-track-property="done"
- @click="closeModal"
- >{{ i18n.done }}
+ data-testid="agent-primary-button"
+ >{{ i18n.primaryButton }}
</gl-button>
</template>
</gl-modal>
diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js
index 380a5d0aada..5cf6fd050a1 100644
--- a/app/assets/javascripts/clusters_list/constants.js
+++ b/app/assets/javascripts/clusters_list/constants.js
@@ -64,6 +64,27 @@ export const STATUSES = {
creating: { title: __('Creating') },
};
+export const I18N_AGENT_TABLE = {
+ nameLabel: s__('ClusterAgents|Name'),
+ statusLabel: s__('ClusterAgents|Connection status'),
+ lastContactLabel: s__('ClusterAgents|Last contact'),
+ versionLabel: __('Version'),
+ configurationLabel: s__('ClusterAgents|Configuration'),
+ optionsLabel: __('Options'),
+ troubleshootingText: s__('ClusterAgents|Learn how to troubleshoot'),
+ neverConnectedText: s__('ClusterAgents|Never'),
+ versionMismatchTitle: s__('ClusterAgents|Agent version mismatch'),
+ versionMismatchText: s__(
+ "ClusterAgents|The Agent version do not match each other across your cluster's pods. This can happen when a new Agent version was just deployed and Kubernetes is shutting down the old pods.",
+ ),
+ versionOutdatedTitle: s__('ClusterAgents|Agent version update required'),
+ versionOutdatedText: s__(
+ 'ClusterAgents|Your Agent version is out of sync with your GitLab version (v%{version}), which might cause compatibility problems. Update the Agent installed on your cluster to the most recent version.',
+ ),
+ versionMismatchOutdatedTitle: s__('ClusterAgents|Agent version mismatch and update'),
+ viewDocsText: s__('ClusterAgents|How to update the Agent?'),
+};
+
export const I18N_AGENT_MODAL = {
agent_registration: {
registerAgentButton: s__('ClusterAgents|Register'),
@@ -112,7 +133,7 @@ export const I18N_AGENT_MODAL = {
"ClusterAgents|Your instance doesn't have the %{linkStart}GitLab Agent Server (KAS)%{linkEnd} set up. Ask a GitLab Administrator to install it.",
),
altText: s__('ClusterAgents|GitLab Agent for Kubernetes'),
- secondaryButton: s__('ClusterAgents|Go to the repository files'),
+ primaryButton: s__('ClusterAgents|Go to the repository files'),
done: __('Cancel'),
},
};
@@ -176,8 +197,8 @@ export const I18N_CLUSTERS_EMPTY_STATE = {
export const AGENT_CARD_INFO = {
tabName: 'agent',
- title: sprintf(s__('ClusterAgents|%{number} of %{total} agents')),
- emptyTitle: s__('ClusterAgents|No agents'),
+ title: sprintf(s__('ClusterAgents|%{number} of %{total} Agents')),
+ emptyTitle: s__('ClusterAgents|No Agents'),
tooltip: {
label: s__('ClusterAgents|Recommended'),
title: s__('ClusterAgents|GitLab Agent'),
@@ -188,8 +209,11 @@ export const AGENT_CARD_INFO = {
),
link: helpPagePath('user/clusters/agent/index'),
},
- actionText: s__('ClusterAgents|Install a new agent'),
+ actionText: s__('ClusterAgents|Install new Agent'),
footerText: sprintf(s__('ClusterAgents|View all %{number} agents')),
+ installAgentDisabledHint: s__(
+ 'ClusterAgents|Requires a Maintainer or greater role to install new agents',
+ ),
};
export const CERTIFICATE_BASED_CARD_INFO = {
@@ -201,6 +225,9 @@ export const CERTIFICATE_BASED_CARD_INFO = {
actionText: s__('ClusterAgents|Connect existing cluster'),
footerText: sprintf(s__('ClusterAgents|View all %{number} clusters')),
badgeText: s__('ClusterAgents|Deprecated'),
+ connectExistingClusterDisabledHint: s__(
+ 'ClusterAgents|Requires a maintainer or greater role to connect existing clusters',
+ ),
};
export const MAX_CLUSTERS_LIST = 6;
@@ -226,8 +253,25 @@ export const CLUSTERS_TABS = [
export const CLUSTERS_ACTIONS = {
actionsButton: s__('ClusterAgents|Actions'),
createNewCluster: s__('ClusterAgents|Create a new cluster'),
- connectWithAgent: s__('ClusterAgents|Connect with the Agent'),
+ connectWithAgent: s__('ClusterAgents|Connect with Agent'),
connectExistingCluster: s__('ClusterAgents|Connect with a certificate'),
+ agent: s__('ClusterAgents|Agent'),
+ certificate: s__('ClusterAgents|Certificate'),
+ dropdownDisabledHint: s__(
+ 'ClusterAgents|Requires a Maintainer or greater role to perform these actions',
+ ),
+};
+
+export const DELETE_AGENT_BUTTON = {
+ deleteButton: s__('ClusterAgents|Delete agent'),
+ disabledHint: s__('ClusterAgents|Requires a Maintainer or greater role to delete agents'),
+ modalTitle: __('Are you sure?'),
+ modalBody: s__('ClusterAgents|Are you sure you want to delete this agent? You cannot undo this.'),
+ modalInputLabel: s__('ClusterAgents|To delete the agent, type %{name} to confirm:'),
+ modalAction: s__('ClusterAgents|Delete'),
+ modalCancel: __('Cancel'),
+ successMessage: s__('ClusterAgents|%{name} successfully deleted'),
+ defaultError: __('An error occurred. Please try again.'),
};
export const AGENT = 'agent';
@@ -244,3 +288,6 @@ export const MODAL_TYPE_EMPTY = 'empty_state';
export const MODAL_TYPE_REGISTER = 'agent_registration';
export const DELETE_AGENT_MODAL_ID = 'delete-agent-modal-%{agentName}';
+
+export const AGENT_FEEDBACK_ISSUE = 'https://gitlab.com/gitlab-org/gitlab/-/issues/342696';
+export const AGENT_FEEDBACK_KEY = 'agent_feedback_banner';
diff --git a/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql b/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql
index cd46dfee170..05d2525ab98 100644
--- a/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql
+++ b/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql
@@ -2,6 +2,13 @@ fragment ClusterAgentFragment on ClusterAgent {
id
name
webPath
+ connections {
+ nodes {
+ metadata {
+ version
+ }
+ }
+ }
tokens {
nodes {
id
diff --git a/app/assets/javascripts/clusters_list/load_main_view.js b/app/assets/javascripts/clusters_list/load_main_view.js
index 08c99b46e16..d52b1d4a64d 100644
--- a/app/assets/javascripts/clusters_list/load_main_view.js
+++ b/app/assets/javascripts/clusters_list/load_main_view.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { parseBoolean } from '~/lib/utils/common_utils';
import createDefaultClient from '~/lib/graphql';
import ClustersMainView from './components/clusters_main_view.vue';
import { createStore } from './store';
@@ -24,6 +25,9 @@ export default () => {
addClusterPath,
emptyStateHelpText,
clustersEmptyStateImage,
+ canAddCluster,
+ canAdminCluster,
+ gitlabVersion,
} = el.dataset;
return new Vue({
@@ -37,6 +41,9 @@ export default () => {
addClusterPath,
emptyStateHelpText,
clustersEmptyStateImage,
+ canAddCluster: parseBoolean(canAddCluster),
+ canAdminCluster: parseBoolean(canAdminCluster),
+ gitlabVersion,
},
store: createStore(el.dataset),
render(createElement) {
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index d54fb7cded2..925b411e51c 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -1,8 +1,8 @@
+import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
import {
MarkdownSerializer as ProseMirrorMarkdownSerializer,
defaultMarkdownSerializer,
-} from 'prosemirror-markdown/src/to_markdown';
-import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
+} from '~/lib/prosemirror_markdown_serializer';
import Audio from '../extensions/audio';
import Blockquote from '../extensions/blockquote';
import Bold from '../extensions/bold';
diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js
index 08942374120..d1a68e80608 100644
--- a/app/assets/javascripts/contextual_sidebar.js
+++ b/app/assets/javascripts/contextual_sidebar.js
@@ -1,10 +1,9 @@
import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils';
import $ from 'jquery';
-import Cookies from 'js-cookie';
import { debounce } from 'lodash';
+import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
-import { parseBoolean } from '~/lib/utils/common_utils';
export const SIDEBAR_COLLAPSED_CLASS = 'js-sidebar-collapsed';
@@ -59,7 +58,7 @@ export default class ContextualSidebar {
if (!ContextualSidebar.isDesktopBreakpoint()) {
return;
}
- Cookies.set('sidebar_collapsed', value, { expires: 365 * 10 });
+ setCookie('sidebar_collapsed', value, { expires: 365 * 10 });
}
toggleSidebarNav(show) {
@@ -111,7 +110,7 @@ export default class ContextualSidebar {
if (!ContextualSidebar.isDesktopBreakpoint()) {
this.toggleSidebarNav(false);
} else {
- const collapse = parseBoolean(Cookies.get('sidebar_collapsed'));
+ const collapse = parseBoolean(getCookie('sidebar_collapsed'));
this.toggleCollapsedSidebar(collapse, true);
}
diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue
index bdfabb8e846..3d7a34581b3 100644
--- a/app/assets/javascripts/cycle_analytics/components/base.vue
+++ b/app/assets/javascripts/cycle_analytics/components/base.vue
@@ -1,12 +1,12 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
-import Cookies from 'js-cookie';
import { mapActions, mapState, mapGetters } from 'vuex';
+import { getCookie, setCookie } from '~/lib/utils/common_utils';
+import ValueStreamMetrics from '~/analytics/shared/components/value_stream_metrics.vue';
import { toYmd } from '~/analytics/shared/utils';
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
import StageTable from '~/cycle_analytics/components/stage_table.vue';
import ValueStreamFilters from '~/cycle_analytics/components/value_stream_filters.vue';
-import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
import { __ } from '~/locale';
import { SUMMARY_METRICS_REQUEST, METRICS_REQUESTS } from '../constants';
@@ -35,7 +35,7 @@ export default {
},
data() {
return {
- isOverviewDialogDismissed: Cookies.get(OVERVIEW_DIALOG_COOKIE),
+ isOverviewDialogDismissed: getCookie(OVERVIEW_DIALOG_COOKIE),
};
},
computed: {
@@ -134,7 +134,7 @@ export default {
},
dismissOverviewDialog() {
this.isOverviewDialogDismissed = true;
- Cookies.set(OVERVIEW_DIALOG_COOKIE, '1', { expires: 365 });
+ setCookie(OVERVIEW_DIALOG_COOKIE, '1');
},
isUserAllowed(id) {
const { permissions } = this;
diff --git a/app/assets/javascripts/cycle_analytics/components/metric_tile.vue b/app/assets/javascripts/cycle_analytics/components/metric_tile.vue
new file mode 100644
index 00000000000..a5c20b237b3
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/metric_tile.vue
@@ -0,0 +1,51 @@
+<script>
+import { GlSingleStat } from '@gitlab/ui/dist/charts';
+import { redirectTo } from '~/lib/utils/url_utility';
+import MetricPopover from '~/analytics/shared/components/metric_popover.vue';
+
+export default {
+ name: 'MetricTile',
+ components: {
+ GlSingleStat,
+ MetricPopover,
+ },
+ props: {
+ metric: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ decimalPlaces() {
+ const parsedFloat = parseFloat(this.metric.value);
+ return Number.isNaN(parsedFloat) || Number.isInteger(parsedFloat) ? 0 : 1;
+ },
+ hasLinks() {
+ return this.metric.links?.length && this.metric.links[0].url;
+ },
+ },
+ methods: {
+ clickHandler({ links }) {
+ if (this.hasLinks) {
+ redirectTo(links[0].url);
+ }
+ },
+ },
+};
+</script>
+<template>
+ <div v-bind="$attrs">
+ <gl-single-stat
+ :id="metric.identifier"
+ :value="`${metric.value}`"
+ :title="metric.label"
+ :unit="metric.unit || ''"
+ :should-animate="true"
+ :animation-decimal-places="decimalPlaces"
+ :class="{ 'gl-hover-cursor-pointer': hasLinks }"
+ tabindex="0"
+ @click="clickHandler(metric)"
+ />
+ <metric-popover :metric="metric" :target="metric.identifier" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/cycle_analytics/components/stage_table.vue
index 8f7a3f99bab..ea5a1291a17 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_table.vue
+++ b/app/assets/javascripts/cycle_analytics/components/stage_table.vue
@@ -266,7 +266,7 @@ export default {
>
<span class="gl-font-lg">&middot;</span>
<span data-testid="vsa-stage-event-date">
- {{ s__('OpenedNDaysAgo|Opened') }}
+ {{ s__('OpenedNDaysAgo|Created') }}
<gl-link class="gl-text-black-normal" :href="item.url">{{
item.createdAt
}}</gl-link>
diff --git a/app/assets/javascripts/cycle_analytics/constants.js b/app/assets/javascripts/cycle_analytics/constants.js
index 7d5822b0824..f0b2bd9dc5b 100644
--- a/app/assets/javascripts/cycle_analytics/constants.js
+++ b/app/assets/javascripts/cycle_analytics/constants.js
@@ -36,31 +36,6 @@ export const OVERVIEW_METRICS = {
RECENT_ACTIVITY: 'RECENT_ACTIVITY',
};
-export const METRICS_POPOVER_CONTENT = {
- '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.",
- ),
- },
- '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.',
- ),
- },
- '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.'),
- },
- commits: {
- description: s__('ValueStreamAnalytics|Number of commits pushed to the default branch'),
- },
-};
-
export const SUMMARY_METRICS_REQUEST = [
{ endpoint: METRIC_TYPE_SUMMARY, name: __('recent activity'), request: getValueStreamMetrics },
];
diff --git a/app/assets/javascripts/cycle_analytics/utils.js b/app/assets/javascripts/cycle_analytics/utils.js
index 9af63f5f9cc..428bb11b950 100644
--- a/app/assets/javascripts/cycle_analytics/utils.js
+++ b/app/assets/javascripts/cycle_analytics/utils.js
@@ -1,14 +1,5 @@
-import { hideFlash } from '~/flash';
import { parseSeconds } from '~/lib/utils/datetime_utility';
import { formatTimeAsSummary } from '~/lib/utils/datetime/date_format_utility';
-import { slugify } from '~/lib/utils/text_utility';
-
-export const removeFlash = (type = 'alert') => {
- const flashEl = document.querySelector(`.flash-${type}`);
- if (flashEl) {
- hideFlash(flashEl);
- }
-};
/**
* Takes the stages and median data, combined with the selected stage, to build an
@@ -80,30 +71,11 @@ export const filterStagesByHiddenStatus = (stages = [], isHidden = true) =>
* @typedef {Object} TransformedMetricData
* @property {String} label - Title of the metric measured
* @property {String} value - String representing the decimal point value, e.g '1.5'
- * @property {String} key - Slugified string based on the 'title'
+ * @property {String} identifier - Slugified string based on the 'title' or the provided 'identifier' attribute
* @property {String} description - String to display for a description
* @property {String} unit - String representing the decimal point value, e.g '1.5'
*/
-/**
- * Prepares metric data to be rendered in the metric_card component
- *
- * @param {MetricData[]} data - The metric data to be rendered
- * @param {Object} popoverContent - Key value pair of data to display in the popover
- * @returns {TransformedMetricData[]} An array of metrics ready to render in the metric_card
- */
-
-export const prepareTimeMetricsData = (data = [], popoverContent = {}) =>
- data.map(({ title: label, ...rest }) => {
- const key = slugify(label);
- return {
- ...rest,
- label,
- key,
- description: popoverContent[key]?.description || '',
- };
- });
-
const extractFeatures = (gon) => ({
cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups),
});
diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js
index 4ab3f140b61..82bbbe891e2 100644
--- a/app/assets/javascripts/deprecated_notes.js
+++ b/app/assets/javascripts/deprecated_notes.js
@@ -13,7 +13,6 @@ deprecated_notes_spec.js is the spec for the legacy, jQuery notes application. I
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import Autosize from 'autosize';
import $ from 'jquery';
-import Cookies from 'js-cookie';
import { escape, uniqueId } from 'lodash';
import Vue from 'vue';
import '~/lib/utils/jquery_at_who';
@@ -28,6 +27,7 @@ import { defaultAutocompleteConfig } from './gfm_auto_complete';
import GLForm from './gl_form';
import axios from './lib/utils/axios_utils';
import {
+ getCookie,
isInViewport,
getPagePath,
scrollToElement,
@@ -121,7 +121,7 @@ export default class Notes {
}
setViewType(view) {
- this.view = Cookies.get('diff_view') || view;
+ this.view = getCookie('diff_view') || view;
}
addBinding() {
@@ -473,7 +473,7 @@ export default class Notes {
}
isParallelView() {
- return Cookies.get('diff_view') === 'parallel';
+ return getCookie('diff_view') === 'parallel';
}
/**
@@ -694,7 +694,7 @@ export default class Notes {
// Convert returned HTML to a jQuery object so we can modify it further
const $noteEntityEl = $(noteEntity.html);
const $noteAvatar = $noteEntityEl.find('.image-diff-avatar-link');
- const $targetNoteBadge = $targetNote.find('.badge');
+ const $targetNoteBadge = $targetNote.find('.design-note-pin');
$noteAvatar.append($targetNoteBadge);
this.revertNoteEditForm($targetNote);
diff --git a/app/assets/javascripts/design_management/components/design_overlay.vue b/app/assets/javascripts/design_management/components/design_overlay.vue
index b058709b316..674415ec449 100644
--- a/app/assets/javascripts/design_management/components/design_overlay.vue
+++ b/app/assets/javascripts/design_management/components/design_overlay.vue
@@ -286,6 +286,7 @@ export default {
"
:is-inactive="isNoteInactive(note)"
:is-resolved="note.resolved"
+ is-on-image
@mousedown.stop="onNoteMousedown($event, note)"
@mouseup.stop="onNoteMouseup(note)"
/>
diff --git a/app/assets/javascripts/design_management/components/design_sidebar.vue b/app/assets/javascripts/design_management/components/design_sidebar.vue
index 6d0ed3b08a3..81d0b6d0df4 100644
--- a/app/assets/javascripts/design_management/components/design_sidebar.vue
+++ b/app/assets/javascripts/design_management/components/design_sidebar.vue
@@ -1,7 +1,7 @@
<script>
import { GlCollapse, GlButton, GlPopover } from '@gitlab/ui';
-import Cookies from 'js-cookie';
-import { parseBoolean, isLoggedIn } from '~/lib/utils/common_utils';
+import { getCookie, setCookie, parseBoolean, isLoggedIn } from '~/lib/utils/common_utils';
+
import { s__ } from '~/locale';
import Participants from '~/sidebar/components/participants/participants.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -53,7 +53,7 @@ export default {
},
data() {
return {
- isResolvedCommentsPopoverHidden: parseBoolean(Cookies.get(this.$options.cookieKey)),
+ isResolvedCommentsPopoverHidden: parseBoolean(getCookie(this.$options.cookieKey)),
discussionWithOpenForm: '',
isLoggedIn: isLoggedIn(),
};
@@ -96,7 +96,7 @@ export default {
methods: {
handleSidebarClick() {
this.isResolvedCommentsPopoverHidden = true;
- Cookies.set(this.$options.cookieKey, 'true', { expires: 365 * 10 });
+ setCookie(this.$options.cookieKey, 'true', { expires: 365 * 10 });
this.updateActiveDiscussion();
},
updateActiveDiscussion(id) {
diff --git a/app/assets/javascripts/design_management/graphql.js b/app/assets/javascripts/design_management/graphql.js
index 5cf32cb7fe3..8c44c5a5d0a 100644
--- a/app/assets/javascripts/design_management/graphql.js
+++ b/app/assets/javascripts/design_management/graphql.js
@@ -1,11 +1,10 @@
-import { defaultDataIdFromObject, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
+import { defaultDataIdFromObject } from '@apollo/client/core';
import produce from 'immer';
import { uniqueId } from 'lodash';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import axios from '~/lib/utils/axios_utils';
-import introspectionQueryResultData from './graphql/fragmentTypes.json';
import activeDiscussionQuery from './graphql/queries/active_discussion.query.graphql';
import getDesignQuery from './graphql/queries/get_design.query.graphql';
import typeDefs from './graphql/typedefs.graphql';
@@ -13,10 +12,6 @@ import { addPendingTodoToStore } from './utils/cache_update';
import { extractTodoIdFromDeletePath, createPendingTodo } from './utils/design_management_utils';
import { CREATE_DESIGN_TODO_EXISTS_ERROR } from './utils/error_messages';
-const fragmentMatcher = new IntrospectionFragmentMatcher({
- introspectionQueryResultData,
-});
-
Vue.use(VueApollo);
const resolvers = {
@@ -85,7 +80,6 @@ const defaultClient = createDefaultClient(
}
return defaultDataIdFromObject(object);
},
- fragmentMatcher,
},
typeDefs,
},
diff --git a/app/assets/javascripts/design_management/graphql/fragmentTypes.json b/app/assets/javascripts/design_management/graphql/fragmentTypes.json
deleted file mode 100644
index 0953231ea4c..00000000000
--- a/app/assets/javascripts/design_management/graphql/fragmentTypes.json
+++ /dev/null
@@ -1 +0,0 @@
-{"__schema":{"types":[{"kind":"INTERFACE","name":"User","possibleTypes":[{"name":"UserCore"}]},{"kind":"UNION","name":"NoteableType","possibleTypes":[{"name":"Design"},{"name":"Issue"},{"name":"MergeRequest"}]}]}}
diff --git a/app/assets/javascripts/design_management/index.js b/app/assets/javascripts/design_management/index.js
index 4ae76050aa5..b856ac6c627 100644
--- a/app/assets/javascripts/design_management/index.js
+++ b/app/assets/javascripts/design_management/index.js
@@ -24,6 +24,7 @@ export default () => {
return new Vue({
el,
+ name: 'DesignRoot',
router,
apolloProvider,
provide: {
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 66d06a3a1b6..5707e4d67f9 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -26,10 +26,8 @@ import {
TREE_LIST_WIDTH_STORAGE_KEY,
INITIAL_TREE_WIDTH,
MIN_TREE_WIDTH,
- MAX_TREE_WIDTH,
TREE_HIDE_STATS_WIDTH,
MR_TREE_SHOW_KEY,
- CENTERED_LIMITED_CONTAINER_CLASSES,
ALERT_OVERFLOW_HIDDEN,
ALERT_MERGE_CONFLICT,
ALERT_COLLAPSED_FILES,
@@ -55,6 +53,7 @@ import DiffFile from './diff_file.vue';
import HiddenFilesWarning from './hidden_files_warning.vue';
import NoChanges from './no_changes.vue';
import TreeList from './tree_list.vue';
+import VirtualScrollerScrollSync from './virtual_scroller_scroll_sync';
export default {
name: 'DiffsApp',
@@ -64,8 +63,7 @@ export default {
DynamicScrollerItem: () =>
import('vendor/vue-virtual-scroller').then(({ DynamicScrollerItem }) => DynamicScrollerItem),
PreRenderer: () => import('./pre_renderer.vue').then((PreRenderer) => PreRenderer),
- VirtualScrollerScrollSync: () =>
- import('./virtual_scroller_scroll_sync').then((VSSSync) => VSSSync),
+ VirtualScrollerScrollSync,
CompareVersions,
DiffFile,
NoChanges,
@@ -253,13 +251,6 @@ export default {
hideFileStats() {
return this.treeWidth <= TREE_HIDE_STATS_WIDTH;
},
- isLimitedContainer() {
- if (this.glFeatures.mrChangesFluidLayout) {
- return false;
- }
-
- return !this.renderFileTree && !this.isParallelView && !this.isFluidLayout;
- },
isFullChangeset() {
return this.startVersion === null && this.latestDiff;
},
@@ -395,8 +386,6 @@ export default {
this.adjustView();
this.subscribeToEvents();
- this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES;
-
this.unwatchDiscussions = this.$watch(
() => `${this.diffFiles.length}:${this.$store.state.notes.discussions.length}`,
() => this.setDiscussions(),
@@ -417,10 +406,8 @@ export default {
this.unsubscribeFromEvents();
this.removeEventListeners();
- if (window.gon?.features?.diffsVirtualScrolling) {
- diffsEventHub.$off('scrollToFileHash', this.scrollVirtualScrollerToFileHash);
- diffsEventHub.$off('scrollToIndex', this.scrollVirtualScrollerToIndex);
- }
+ diffsEventHub.$off('scrollToFileHash', this.scrollVirtualScrollerToFileHash);
+ diffsEventHub.$off('scrollToIndex', this.scrollVirtualScrollerToIndex);
},
methods: {
...mapActions(['startTaskList']),
@@ -533,32 +520,27 @@ export default {
);
}
- if (
- window.gon?.features?.diffsVirtualScrolling ||
- window.gon?.features?.diffSearchingUsageData
- ) {
- let keydownTime;
- Mousetrap.bind(['mod+f', 'mod+g'], () => {
- keydownTime = new Date().getTime();
- });
+ let keydownTime;
+ Mousetrap.bind(['mod+f', 'mod+g'], () => {
+ keydownTime = new Date().getTime();
+ });
- window.addEventListener('blur', () => {
- if (keydownTime) {
- const delta = new Date().getTime() - keydownTime;
+ window.addEventListener('blur', () => {
+ if (keydownTime) {
+ const delta = new Date().getTime() - keydownTime;
- // To make sure the user is using the find function we need to wait for blur
- // and max 1000ms to be sure it the search box is filtered
- if (delta >= 0 && delta < 1000) {
- this.disableVirtualScroller();
+ // To make sure the user is using the find function we need to wait for blur
+ // and max 1000ms to be sure it the search box is filtered
+ if (delta >= 0 && delta < 1000) {
+ this.disableVirtualScroller();
- if (window.gon?.features?.diffSearchingUsageData) {
- api.trackRedisHllUserEvent('i_code_review_user_searches_diff');
- api.trackRedisCounterEvent('diff_searches');
- }
+ if (window.gon?.features?.usageDataDiffSearches) {
+ api.trackRedisHllUserEvent('i_code_review_user_searches_diff');
+ api.trackRedisCounterEvent('diff_searches');
}
}
- });
- }
+ }
+ });
},
removeEventListeners() {
Mousetrap.unbind(keysFor(MR_PREVIOUS_FILE_IN_DIFF));
@@ -600,8 +582,6 @@ export default {
this.virtualScrollCurrentIndex = -1;
},
scrollVirtualScrollerToDiffNote() {
- if (!window.gon?.features?.diffsVirtualScrolling) return;
-
const id = window?.location?.hash;
if (id.startsWith('#note_')) {
@@ -616,11 +596,7 @@ export default {
}
},
subscribeToVirtualScrollingEvents() {
- if (
- window.gon?.features?.diffsVirtualScrolling &&
- this.shouldShow &&
- !this.subscribedToVirtualScrollingEvents
- ) {
+ if (this.shouldShow && !this.subscribedToVirtualScrollingEvents) {
diffsEventHub.$on('scrollToFileHash', this.scrollVirtualScrollerToFileHash);
diffsEventHub.$on('scrollToIndex', this.scrollVirtualScrollerToIndex);
@@ -632,7 +608,7 @@ export default {
},
},
minTreeWidth: MIN_TREE_WIDTH,
- maxTreeWidth: MAX_TREE_WIDTH,
+ maxTreeWidth: window.innerWidth / 2,
howToMergeDocsPath: helpPagePath('user/project/merge_requests/reviews/index.md', {
anchor: 'checkout-merge-requests-locally-through-the-head-ref',
}),
@@ -643,10 +619,7 @@ export default {
<div v-show="shouldShow">
<div v-if="isLoading || !isTreeLoaded" class="loading"><gl-loading-icon size="lg" /></div>
<div v-else id="diffs" :class="{ active: shouldShow }" class="diffs tab-pane">
- <compare-versions
- :is-limited-container="isLimitedContainer"
- :diff-files-count-text="numTotalFiles"
- />
+ <compare-versions :diff-files-count-text="numTotalFiles" />
<template v-if="!isBatchLoadingError">
<hidden-files-warning
@@ -656,10 +629,7 @@ export default {
:plain-diff-path="plainDiffPath"
:email-patch-path="emailPatchPath"
/>
- <collapsed-files-warning
- v-if="visibleWarning == $options.alerts.ALERT_COLLAPSED_FILES"
- :limited="isLimitedContainer"
- />
+ <collapsed-files-warning v-if="visibleWarning == $options.alerts.ALERT_COLLAPSED_FILES" />
</template>
<div
@@ -669,7 +639,7 @@ export default {
<div
v-if="renderFileTree"
:style="{ width: `${treeWidth}px` }"
- class="diff-tree-list js-diff-tree-list px-3 pr-md-0"
+ class="diff-tree-list js-diff-tree-list gl-px-5"
>
<panel-resizer
:size.sync="treeWidth"
@@ -681,12 +651,7 @@ export default {
/>
<tree-list :hide-file-stats="hideFileStats" />
</div>
- <div
- class="col-12 col-md-auto diff-files-holder"
- :class="{
- [CENTERED_LIMITED_CONTAINER_CLASSES]: isLimitedContainer,
- }"
- >
+ <div class="col-12 col-md-auto diff-files-holder">
<commit-widget v-if="commit" :commit="commit" :collapsible="false" />
<gl-alert
v-if="isBatchLoadingError"
diff --git a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue
index 240f102e600..b7eea32e699 100644
--- a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue
+++ b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue
@@ -2,7 +2,7 @@
import { GlAlert, GlButton } from '@gitlab/ui';
import { mapState } from 'vuex';
-import { CENTERED_LIMITED_CONTAINER_CLASSES, EVT_EXPAND_ALL_FILES } from '../constants';
+import { EVT_EXPAND_ALL_FILES } from '../constants';
import eventHub from '../event_hub';
export default {
@@ -11,11 +11,6 @@ export default {
GlButton,
},
props: {
- limited: {
- type: Boolean,
- required: false,
- default: false,
- },
dismissed: {
type: Boolean,
required: false,
@@ -29,11 +24,6 @@ export default {
},
computed: {
...mapState('diffs', ['diffFiles']),
- containerClasses() {
- return {
- [CENTERED_LIMITED_CONTAINER_CLASSES]: this.limited,
- };
- },
shouldDisplay() {
return !this.isDismissed && this.diffFiles.length > 1;
},
@@ -53,7 +43,7 @@ export default {
</script>
<template>
- <div v-if="shouldDisplay" data-testid="root" :class="containerClasses" class="col-12">
+ <div v-if="shouldDisplay" data-testid="root" class="col-12">
<gl-alert
:dismissible="true"
:title="__('Some changes are not shown')"
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index e54fde72847..df7cf83b3f0 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -155,9 +155,11 @@ export default {
<gl-button
v-if="commit.description_html && collapsible"
+ v-gl-tooltip
class="js-toggle-button"
size="small"
icon="ellipsis_h"
+ :title="__('Toggle commit description')"
:aria-label="__('Toggle commit description')"
/>
diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue
index 442807587d5..2b871680d5e 100644
--- a/app/assets/javascripts/diffs/components/compare_versions.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions.vue
@@ -3,7 +3,7 @@ import { GlTooltipDirective, GlIcon, GlLink, GlButtonGroup, GlButton, GlSprintf
import { mapActions, mapGetters, mapState } from 'vuex';
import { __ } from '~/locale';
import { setUrlParams } from '../../lib/utils/url_utility';
-import { CENTERED_LIMITED_CONTAINER_CLASSES, EVT_EXPAND_ALL_FILES } from '../constants';
+import { EVT_EXPAND_ALL_FILES } from '../constants';
import eventHub from '../event_hub';
import CompareDropdownLayout from './compare_dropdown_layout.vue';
import DiffStats from './diff_stats.vue';
@@ -24,11 +24,6 @@ export default {
GlTooltip: GlTooltipDirective,
},
props: {
- isLimitedContainer: {
- type: Boolean,
- required: false,
- default: false,
- },
diffFilesCountText: {
type: String,
required: false,
@@ -73,9 +68,6 @@ export default {
return this.commit && (this.commit.next_commit_id || this.commit.prev_commit_id);
},
},
- created() {
- this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES;
- },
methods: {
...mapActions('diffs', ['setInlineDiffViewType', 'setParallelDiffViewType', 'setShowTreeList']),
expandAllFiles() {
@@ -88,12 +80,7 @@ export default {
<template>
<div class="mr-version-controls border-top">
- <div
- class="mr-version-menus-container content-block"
- :class="{
- [CENTERED_LIMITED_CONTAINER_CLASSES]: isLimitedContainer,
- }"
- >
+ <div class="mr-version-menus-container content-block">
<gl-button
v-if="hasChanges"
v-gl-tooltip.hover
diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue
index 5e05ec87f84..47a05ce11cc 100644
--- a/app/assets/javascripts/diffs/components/diff_discussions.vue
+++ b/app/assets/javascripts/diffs/components/diff_discussions.vue
@@ -1,12 +1,14 @@
<script>
import { GlIcon } from '@gitlab/ui';
import { mapActions } from 'vuex';
+import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue';
import noteableDiscussion from '../../notes/components/noteable_discussion.vue';
export default {
components: {
noteableDiscussion,
GlIcon,
+ DesignNotePin,
},
props: {
discussions: {
@@ -62,20 +64,22 @@ export default {
<ul :data-discussion-id="discussion.id" class="notes">
<template v-if="shouldCollapseDiscussions">
<button
- :class="{
- 'diff-notes-collapse': discussion.expanded,
- 'btn-transparent badge badge-pill': !discussion.expanded,
- }"
+ v-if="discussion.expanded"
+ class="diff-notes-collapse js-diff-notes-toggle"
type="button"
- class="js-diff-notes-toggle"
:aria-label="__('Show comments')"
@click="toggleDiscussion({ discussionId: discussion.id })"
>
- <gl-icon v-if="discussion.expanded" name="collapse" class="collapse-icon" />
- <template v-else>
- {{ index + 1 }}
- </template>
+ <gl-icon name="collapse" class="collapse-icon" />
</button>
+ <design-note-pin
+ v-else
+ :label="index + 1"
+ :is-resolved="discussion.resolved"
+ size="sm"
+ class="js-diff-notes-toggle gl-translate-x-n50"
+ @click="toggleDiscussion({ discussionId: discussion.id })"
+ />
</template>
<noteable-discussion
v-show="isExpanded(discussion)"
@@ -87,9 +91,12 @@ export default {
@noteDeleted="deleteNoteHandler"
>
<template v-if="renderAvatarBadge" #avatar-badge>
- <span class="badge badge-pill">
- {{ index + 1 }}
- </span>
+ <design-note-pin
+ :label="index + 1"
+ class="user-avatar"
+ :is-resolved="discussion.resolved"
+ size="sm"
+ />
</template>
</noteable-discussion>
</ul>
diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
index edff2e67b20..4c7b8e8f667 100644
--- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
+++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
@@ -223,25 +223,31 @@ export default {
<template>
<div class="content js-line-expansion-content">
- <a
- v-if="canExpandDown"
- class="gl-mx-2 gl-cursor-pointer js-unfold-down gl-display-inline-block gl-py-4"
+ <button
+ type="button"
+ :disabled="!canExpandDown"
+ class="js-unfold-down gl-mx-2 gl-py-4 gl-cursor-pointer"
@click="handleExpandLines(EXPAND_DOWN)"
>
<gl-icon :size="12" name="expand-down" />
<span>{{ $options.i18n.showMore }}</span>
- </a>
- <a class="gl-mx-2 cursor-pointer js-unfold-all" @click="handleExpandLines()">
+ </button>
+ <button
+ type="button"
+ class="js-unfold-all gl-mx-2 gl-py-4 gl-cursor-pointer"
+ @click="handleExpandLines()"
+ >
<gl-icon :size="12" name="expand" />
<span>{{ $options.i18n.showAll }}</span>
- </a>
- <a
- v-if="canExpandUp"
- class="gl-mx-2 gl-cursor-pointer js-unfold gl-display-inline-block gl-py-4"
+ </button>
+ <button
+ type="button"
+ :disabled="!canExpandUp"
+ class="js-unfold gl-mx-2 gl-py-4 gl-cursor-pointer"
@click="handleExpandLines(EXPAND_UP)"
>
<gl-icon :size="12" name="expand-up" />
<span>{{ $options.i18n.showMore }}</span>
- </a>
+ </button>
</div>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 238f07ac22c..3cf1f69b08c 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -3,6 +3,7 @@ import {
GlTooltipDirective,
GlSafeHtmlDirective,
GlIcon,
+ GlBadge,
GlButton,
GlButtonGroup,
GlDropdown,
@@ -34,6 +35,7 @@ export default {
GlIcon,
FileIcon,
DiffStats,
+ GlBadge,
GlButton,
GlButtonGroup,
GlDropdown,
@@ -207,7 +209,7 @@ export default {
handler(val) {
const el = this.$el.closest('.vue-recycle-scroller__item-view');
- if (this.glFeatures.diffsVirtualScrolling && el) {
+ if (el) {
// We can't add a style with Vue because of the way the virtual
// scroller library renders the diff files
el.style.zIndex = val ? '1' : null;
@@ -349,7 +351,9 @@ export default {
{{ diffFile.a_mode }} → {{ diffFile.b_mode }}
</small>
- <span v-if="isUsingLfs" class="badge label label-lfs gl-mr-2"> {{ __('LFS') }} </span>
+ <gl-badge v-if="isUsingLfs" variant="neutral" class="gl-mr-2" data-testid="label-lfs">{{
+ __('LFS')
+ }}</gl-badge>
</div>
<div
diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue
index 8562a1d44e7..333bf1b356c 100644
--- a/app/assets/javascripts/diffs/components/diff_view.vue
+++ b/app/assets/javascripts/diffs/components/diff_view.vue
@@ -153,21 +153,38 @@ export default {
@mousedown="handleParallelLineMouseDown"
>
<template v-for="(line, index) in diffLines">
- <div
- v-if="line.isMatchLineLeft || line.isMatchLineRight"
- :key="`expand-${index}`"
- class="diff-tr line_expansion match"
- >
- <div class="diff-td text-center gl-font-regular">
- <diff-expansion-cell
- :file-hash="diffFile.file_hash"
- :context-lines-path="diffFile.context_lines_path"
- :line="line.left"
- :is-top="index === 0"
- :is-bottom="index + 1 === diffLinesLength"
- />
+ <template v-if="line.isMatchLineLeft || line.isMatchLineRight">
+ <div :key="`expand-${index}`" class="diff-tr line_expansion match">
+ <div class="diff-td text-center gl-font-regular">
+ <diff-expansion-cell
+ :file-hash="diffFile.file_hash"
+ :context-lines-path="diffFile.context_lines_path"
+ :line="line.left"
+ :is-top="index === 0"
+ :is-bottom="index + 1 === diffLinesLength"
+ />
+ </div>
</div>
- </div>
+ <div
+ v-if="line.left.rich_text"
+ :key="`expand-definition-${index}`"
+ class="diff-grid-row diff-tr line_holder match"
+ >
+ <div class="diff-grid-left diff-grid-3-col left-side">
+ <div class="diff-td diff-line-num"></div>
+ <div v-if="inline" class="diff-td diff-line-num"></div>
+ <div class="diff-td line_content left-side gl-white-space-normal!">
+ {{ line.left.rich_text }}
+ </div>
+ </div>
+ <div v-if="!inline" class="diff-grid-right diff-grid-3-col right-side">
+ <div class="diff-td diff-line-num"></div>
+ <div class="diff-td line_content right-side gl-white-space-normal!">
+ {{ line.left.rich_text }}
+ </div>
+ </div>
+ </div>
+ </template>
<diff-row
v-if="!line.isMatchLineLeft && !line.isMatchLineRight"
:key="line.line_code"
diff --git a/app/assets/javascripts/diffs/components/image_diff_overlay.vue b/app/assets/javascripts/diffs/components/image_diff_overlay.vue
index eede8e52292..8871be1f9af 100644
--- a/app/assets/javascripts/diffs/components/image_diff_overlay.vue
+++ b/app/assets/javascripts/diffs/components/image_diff_overlay.vue
@@ -1,8 +1,8 @@
<script>
-import { GlIcon } from '@gitlab/ui';
import { isArray } from 'lodash';
import { mapActions, mapGetters } from 'vuex';
import imageDiffMixin from 'ee_else_ce/diffs/mixins/image_diff';
+import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue';
function calcPercent(pos, renderedSize) {
return (100 * pos) / renderedSize;
@@ -11,7 +11,7 @@ function calcPercent(pos, renderedSize) {
export default {
name: 'ImageDiffOverlay',
components: {
- GlIcon,
+ DesignNotePin,
},
mixins: [imageDiffMixin],
props: {
@@ -36,7 +36,7 @@ export default {
badgeClass: {
type: String,
required: false,
- default: 'badge badge-pill',
+ default: '',
},
shouldToggleDiscussion: {
type: Boolean,
@@ -114,30 +114,28 @@ export default {
>
<span class="sr-only"> {{ __('Add image comment') }} </span>
</button>
- <button
+
+ <design-note-pin
v-for="(discussion, index) in allDiscussions"
:key="discussion.id"
- :style="getPosition(discussion)"
- :class="[badgeClass, { 'is-draft': discussion.isDraft }]"
- :disabled="!shouldToggleDiscussion"
- class="js-image-badge"
- type="button"
+ :label="showCommentIcon ? null : toggleText(discussion, index)"
+ :position="getPosition(discussion)"
:aria-label="__('Show comments')"
+ class="js-image-badge"
+ :class="badgeClass"
+ :is-draft="discussion.isDraft"
+ :is-resolved="discussion.resolved"
+ is-on-image
+ :disabled="!shouldToggleDiscussion"
@click="clickedToggle(discussion)"
- >
- <gl-icon v-if="showCommentIcon" name="image-comment-dark" :size="24" />
- <template v-else>
- {{ toggleText(discussion, index) }}
- </template>
- </button>
- <button
+ />
+
+ <design-note-pin
v-if="canComment && currentCommentForm"
- :style="{ left: `${currentCommentForm.xPercent}%`, top: `${currentCommentForm.yPercent}%` }"
- :aria-label="__('Comment form position')"
- class="btn-transparent comment-indicator position-absolute"
- type="button"
- >
- <gl-icon name="image-comment-dark" :size="24" />
- </button>
+ :position="{
+ left: `${currentCommentForm.xPercent}%`,
+ top: `${currentCommentForm.yPercent}%`,
+ }"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/diffs/components/merge_conflict_warning.vue b/app/assets/javascripts/diffs/components/merge_conflict_warning.vue
index 587efd6ed41..6e1e6f5c2d0 100644
--- a/app/assets/javascripts/diffs/components/merge_conflict_warning.vue
+++ b/app/assets/javascripts/diffs/components/merge_conflict_warning.vue
@@ -1,6 +1,5 @@
<script>
import { GlButton, GlAlert, GlModalDirective } from '@gitlab/ui';
-import { CENTERED_LIMITED_CONTAINER_CLASSES } from '../constants';
export default {
components: {
@@ -11,10 +10,6 @@ export default {
GlModalDirective,
},
props: {
- limited: {
- type: Boolean,
- required: true,
- },
mergeable: {
type: Boolean,
required: true,
@@ -24,18 +19,11 @@ export default {
required: true,
},
},
- computed: {
- containerClasses() {
- return {
- [CENTERED_LIMITED_CONTAINER_CLASSES]: this.limited,
- };
- },
- },
};
</script>
<template>
- <div :class="containerClasses">
+ <div>
<gl-alert
:dismissible="false"
:title="__('There are merge conflicts')"
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index 93961b07e2e..bbe27c0dbd6 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -29,8 +29,6 @@ export const UNFOLD_COUNT = 20;
export const COUNT_OF_AVATARS_IN_GUTTER = 3;
export const LENGTH_OF_AVATAR_TOOLTIP = 17;
-export const LINES_TO_BE_RENDERED_DIRECTLY = 100;
-
export const DIFF_FILE_SYMLINK_MODE = '120000';
export const DIFF_FILE_DELETED_MODE = '0';
@@ -42,7 +40,6 @@ export const TREE_LIST_WIDTH_STORAGE_KEY = 'mr_tree_list_width';
export const INITIAL_TREE_WIDTH = 320;
export const MIN_TREE_WIDTH = 240;
-export const MAX_TREE_WIDTH = 400;
export const TREE_HIDE_STATS_WIDTH = 260;
export const OLD_LINE_KEY = 'old_line';
@@ -50,9 +47,6 @@ export const NEW_LINE_KEY = 'new_line';
export const TYPE_KEY = 'type';
export const LEFT_LINE_KEY = 'left';
-export const CENTERED_LIMITED_CONTAINER_CLASSES =
- 'container-limited limit-container-width mx-lg-auto px-3';
-
export const MAX_RENDERING_DIFF_LINES = 500;
export const MAX_RENDERING_BULK_ROWS = 30;
export const MIN_RENDERING_MS = 2;
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 260ebdf2141..1691da34c6d 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -1,8 +1,7 @@
-import Cookies from 'js-cookie';
import Vue from 'vue';
import { mapActions, mapState, mapGetters } from 'vuex';
-import { parseBoolean } from '~/lib/utils/common_utils';
-import { getParameterValues } from '~/lib/utils/url_utility';
+import { getCookie, parseBoolean, removeCookie } from '~/lib/utils/common_utils';
+
import eventHub from '../notes/event_hub';
import diffsApp from './components/app.vue';
@@ -58,14 +57,14 @@ export default function initDiffsApp(store) {
// Check for cookie and save that setting for future use.
// Then delete the cookie as we are phasing it out and using the database as SSOT.
// NOTE: This can/should be removed later
- if (Cookies.get(DIFF_WHITESPACE_COOKIE_NAME)) {
- const hideWhitespace = Cookies.get(DIFF_WHITESPACE_COOKIE_NAME);
+ if (getCookie(DIFF_WHITESPACE_COOKIE_NAME)) {
+ const hideWhitespace = getCookie(DIFF_WHITESPACE_COOKIE_NAME);
this.setShowWhitespace({
url: this.endpointUpdateUser,
showWhitespace: hideWhitespace !== '1',
trackClick: false,
});
- Cookies.remove(DIFF_WHITESPACE_COOKIE_NAME);
+ removeCookie(DIFF_WHITESPACE_COOKIE_NAME);
} else {
// This is only to set the the user preference in Vuex for use later
this.setShowWhitespace({
@@ -74,11 +73,6 @@ export default function initDiffsApp(store) {
trackClick: false,
});
}
-
- const vScrollingParam = getParameterValues('virtual_scrolling')[0];
- if (vScrollingParam === 'false' || vScrollingParam === 'true') {
- Cookies.set('diffs_virtual_scrolling', vScrollingParam);
- }
},
methods: {
...mapActions('diffs', ['setRenderTreeList', 'setShowWhitespace']),
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 692cb913a57..e967be23f42 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -1,9 +1,14 @@
-import Cookies from 'js-cookie';
import Vue from 'vue';
+import {
+ setCookie,
+ handleLocationHash,
+ historyPushState,
+ scrollToElement,
+} from '~/lib/utils/common_utils';
import createFlash from '~/flash';
import { diffViewerModes } from '~/ide/constants';
import axios from '~/lib/utils/axios_utils';
-import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils';
+
import httpStatusCodes from '~/lib/utils/http_status';
import Poll from '~/lib/utils/poll';
import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility';
@@ -120,7 +125,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
commit(types.SET_DIFF_DATA_BATCH, { diff_files });
commit(types.SET_BATCH_LOADING_STATE, 'loaded');
- if (window.gon?.features?.diffsVirtualScrolling && !scrolledVirtualScroller) {
+ if (!scrolledVirtualScroller) {
const index = state.diffFiles.findIndex(
(f) =>
f.file_hash === hash || f[INLINE_DIFF_LINES_KEY].find((l) => l.line_code === hash),
@@ -190,9 +195,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
commit(types.SET_BATCH_LOADING_STATE, 'error');
});
- return getBatch().then(
- () => !window.gon?.features?.diffsVirtualScrolling && handleLocationHash(),
- );
+ return getBatch();
};
export const fetchDiffFilesMeta = ({ commit, state }) => {
@@ -369,7 +372,7 @@ export const setRenderIt = ({ commit }, file) => commit(types.RENDER_FILE, file)
export const setInlineDiffViewType = ({ commit }) => {
commit(types.SET_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE);
- Cookies.set(DIFF_VIEW_COOKIE_NAME, INLINE_DIFF_VIEW_TYPE);
+ setCookie(DIFF_VIEW_COOKIE_NAME, INLINE_DIFF_VIEW_TYPE);
const url = mergeUrlParams({ view: INLINE_DIFF_VIEW_TYPE }, window.location.href);
historyPushState(url);
@@ -381,7 +384,7 @@ export const setInlineDiffViewType = ({ commit }) => {
export const setParallelDiffViewType = ({ commit }) => {
commit(types.SET_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE);
- Cookies.set(DIFF_VIEW_COOKIE_NAME, PARALLEL_DIFF_VIEW_TYPE);
+ setCookie(DIFF_VIEW_COOKIE_NAME, PARALLEL_DIFF_VIEW_TYPE);
const url = mergeUrlParams({ view: PARALLEL_DIFF_VIEW_TYPE }, window.location.href);
historyPushState(url);
@@ -524,7 +527,7 @@ export const setCurrentFileHash = ({ commit }, hash) => {
commit(types.SET_CURRENT_DIFF_FILE, hash);
};
-export const scrollToFile = ({ state, commit, getters }, { path, setHash = true }) => {
+export const scrollToFile = ({ state, commit, getters }, { path }) => {
if (!state.treeEntries[path]) return;
const { fileHash } = state.treeEntries[path];
@@ -534,11 +537,9 @@ export const scrollToFile = ({ state, commit, getters }, { path, setHash = true
if (getters.isVirtualScrollingEnabled) {
eventHub.$emit('scrollToFileHash', fileHash);
- if (setHash) {
- setTimeout(() => {
- window.history.replaceState(null, null, `#${fileHash}`);
- });
- }
+ setTimeout(() => {
+ window.history.replaceState(null, null, `#${fileHash}`);
+ });
} else {
document.location.hash = fileHash;
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
index ca85be5d829..3a85c1a9fe1 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -1,6 +1,5 @@
-import Cookies from 'js-cookie';
-import { getParameterValues } from '~/lib/utils/url_utility';
import { __, n__ } from '~/locale';
+import { getParameterValues } from '~/lib/utils/url_utility';
import {
PARALLEL_DIFF_VIEW_TYPE,
INLINE_DIFF_VIEW_TYPE,
@@ -175,21 +174,11 @@ export function suggestionCommitMessage(state, _, rootState) {
}
export const isVirtualScrollingEnabled = (state) => {
- const vSrollerCookie = Cookies.get('diffs_virtual_scrolling');
-
- if (state.disableVirtualScroller) {
+ if (state.disableVirtualScroller || getParameterValues('virtual_scrolling')[0] === 'false') {
return false;
}
- if (vSrollerCookie) {
- return vSrollerCookie === 'true';
- }
-
- return (
- !state.viewDiffsFileByFile &&
- (window.gon?.features?.diffsVirtualScrolling ||
- getParameterValues('virtual_scrolling')[0] === 'true')
- );
+ return !state.viewDiffsFileByFile;
};
export const isBatchLoading = (state) => state.batchLoadingState === 'loading';
diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js
index 5f66360a040..329db1fe2cf 100644
--- a/app/assets/javascripts/diffs/store/modules/diff_state.js
+++ b/app/assets/javascripts/diffs/store/modules/diff_state.js
@@ -1,10 +1,10 @@
-import Cookies from 'js-cookie';
+import { getCookie } from '~/lib/utils/common_utils';
import { getParameterValues } from '~/lib/utils/url_utility';
import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants';
const getViewTypeFromQueryString = () => getParameterValues('view')[0];
-const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME);
+const viewTypeFromCookie = getCookie(DIFF_VIEW_COOKIE_NAME);
const defaultViewType = INLINE_DIFF_VIEW_TYPE;
export default () => ({
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 3f1af68e37a..f2028892a5f 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -9,7 +9,6 @@ import {
NEW_LINE_TYPE,
OLD_LINE_TYPE,
MATCH_LINE_TYPE,
- LINES_TO_BE_RENDERED_DIRECTLY,
INLINE_DIFF_LINES_KEY,
CONFLICT_OUR,
CONFLICT_THEIR,
@@ -380,16 +379,9 @@ function prepareDiffFileLines(file) {
return file;
}
-function finalizeDiffFile(file, index) {
- let renderIt = Boolean(window.gon?.features?.diffsVirtualScrolling);
-
- if (!window.gon?.features?.diffsVirtualScrolling) {
- renderIt =
- index < 3 ? file[INLINE_DIFF_LINES_KEY].length < LINES_TO_BE_RENDERED_DIRECTLY : false;
- }
-
+function finalizeDiffFile(file) {
Object.assign(file, {
- renderIt,
+ renderIt: true,
isShowingFullFile: false,
isLoadingFullFile: false,
discussions: [],
@@ -417,15 +409,13 @@ export function prepareDiffData({ diff, priorFiles = [], meta = false }) {
.map((file, index, allFiles) => prepareRawDiffFile({ file, allFiles, meta }))
.map(ensureBasicDiffFileLines)
.map(prepareDiffFileLines)
- .map((file, index) => finalizeDiffFile(file, priorFiles.length + index));
+ .map((file) => finalizeDiffFile(file));
return deduplicateFilesList([...priorFiles, ...cleanedFiles]);
}
export function getDiffPositionByLineCode(diffFiles) {
- let lines = [];
-
- lines = diffFiles.reduce((acc, diffFile) => {
+ const lines = diffFiles.reduce((acc, diffFile) => {
diffFile[INLINE_DIFF_LINES_KEY].forEach((line) => {
acc.push({ file: diffFile, line });
});
diff --git a/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js b/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js
index 05ce617ca7c..2fba02f212b 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js
@@ -20,33 +20,6 @@ export class YamlEditorExtension {
}
/**
- * Extends the source editor with capabilities for yaml files.
- *
- * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
- * @param {YamlEditorExtensionOptions} setupOptions
- */
- onSetup(instance, setupOptions = {}) {
- const { enableComments = false, highlightPath = null, model = null } = setupOptions;
- this.enableComments = enableComments;
- this.highlightPath = highlightPath;
- this.model = model;
-
- if (model) {
- this.initFromModel(instance, model);
- }
-
- instance.onDidChangeModelContent(() => instance.onUpdate());
- }
-
- initFromModel(instance, model) {
- const doc = new Document(model);
- if (this.enableComments) {
- YamlEditorExtension.transformComments(doc);
- }
- instance.setValue(doc.toString());
- }
-
- /**
* @private
* This wraps long comments to a maximum line length of 80 chars.
*
@@ -164,10 +137,10 @@ export class YamlEditorExtension {
if (!path) throw Error(`No path provided.`);
const blob = instance.getValue();
const doc = parseDocument(blob);
- const pathArray = toPath(path);
+ const pathArray = Array.isArray(path) ? path : toPath(path);
if (!doc.getIn(pathArray)) {
- throw Error(`The node ${path} could not be found inside the document.`);
+ return [null, null];
}
const parentNode = doc.getIn(pathArray.slice(0, pathArray.length - 1));
@@ -190,6 +163,33 @@ export class YamlEditorExtension {
return [startLine, endLine];
}
+ /**
+ * Extends the source editor with capabilities for yaml files.
+ *
+ * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
+ * @param {YamlEditorExtensionOptions} setupOptions
+ */
+ onSetup(instance, setupOptions = {}) {
+ const { enableComments = false, highlightPath = null, model = null } = setupOptions;
+ this.enableComments = enableComments;
+ this.highlightPath = highlightPath;
+ this.model = model;
+
+ if (model) {
+ this.initFromModel(instance, model);
+ }
+
+ instance.onDidChangeModelContent(() => instance.onUpdate());
+ }
+
+ initFromModel(instance, model) {
+ const doc = new Document(model);
+ if (this.enableComments) {
+ YamlEditorExtension.transformComments(doc);
+ }
+ instance.setValue(doc.toString());
+ }
+
setDoc(instance, doc) {
if (this.enableComments) {
YamlEditorExtension.transformComments(doc);
@@ -202,18 +202,31 @@ export class YamlEditorExtension {
}
}
- highlight(instance, path) {
+ highlight(instance, path, keepOnNotFound = false) {
// IMPORTANT
// removeHighlight and highlightLines both come from
// SourceEditorExtension. So it has to be installed prior to this extension
if (this.highlightPath === path) return;
- if (!path) {
+
+ if (!path || !path.length) {
instance.removeHighlights();
- } else {
- const res = YamlEditorExtension.locate(instance, path);
- instance.highlightLines(res);
+ this.highlightPath = null;
+ return;
}
- this.highlightPath = path || null;
+
+ const [startLine, endLine] = YamlEditorExtension.locate(instance, path);
+
+ if (startLine === null) {
+ // Path could not be found.
+ if (!keepOnNotFound) {
+ instance.removeHighlights();
+ this.highlightPath = null;
+ }
+ return;
+ }
+
+ instance.highlightLines([startLine, endLine]);
+ this.highlightPath = path;
}
provides() {
@@ -283,18 +296,23 @@ export class YamlEditorExtension {
* Add a line highlight style to the node specified by the path.
*
* @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
- * @param {string|null|false} path A path to a node of the Editor's value,
+ * @param {string|(string|number)[]|null|false} path A path to a node
+ * of the Editor's
+ * value,
* e.g. `"foo.bar[0]"`. If the value is falsy, this will remove all
* highlights.
+ * @param {boolean} [keepOnNotFound=false] If the passed path cannot
+ * be located, keep the previous highlight state
*/
- highlight: (instance, path) => this.highlight(instance, path),
+ highlight: (instance, path, keepOnNotFound) => this.highlight(instance, path, keepOnNotFound),
/**
* Return the line numbers of a certain node identified by `path` within
* the yaml.
*
* @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
- * @param {string} path A path to a node, eg. `foo.bar[0]`
+ * @param {string|(string|number)[]} path A path to a node, eg.
+ * `foo.bar[0]`
* @returns {number[]} Array following the schema `[firstLine, lastLine]`
* (both inclusive)
*
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
index f0db3e5594b..4d9fe6ff851 100644
--- a/app/assets/javascripts/editor/schema/ci.json
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -765,6 +765,9 @@
"filter": {
"oneOf": [
{
+ "type": "null"
+ },
+ {
"$ref": "#/definitions/filter_refs"
},
{
diff --git a/app/assets/javascripts/emoji/awards_app/index.js b/app/assets/javascripts/emoji/awards_app/index.js
index 1a084d37762..0986533dcd1 100644
--- a/app/assets/javascripts/emoji/awards_app/index.js
+++ b/app/assets/javascripts/emoji/awards_app/index.js
@@ -12,6 +12,7 @@ export default (el) => {
return new Vue({
el,
+ name: 'AwardsListRoot',
store: createstore(),
computed: {
...mapState(['currentUserId', 'canAwardEmoji', 'awards']),
diff --git a/app/assets/javascripts/emoji/awards_app/store/actions.js b/app/assets/javascripts/emoji/awards_app/store/actions.js
index f0340209248..f83bfe614dd 100644
--- a/app/assets/javascripts/emoji/awards_app/store/actions.js
+++ b/app/assets/javascripts/emoji/awards_app/store/actions.js
@@ -33,20 +33,51 @@ export const fetchAwards = async ({ commit, dispatch, state }, page = '1') => {
}
};
+/**
+ * Creates an intermediary award, used for display
+ * until the real award is loaded from the backend.
+ */
+const newOptimisticAward = (name, state) => {
+ const freeId = Math.min(...state.awards.map((a) => a.id), Number.MAX_SAFE_INTEGER) - 1;
+ return {
+ id: freeId,
+ name,
+ user: {
+ id: window.gon.current_user_id,
+ name: window.gon.current_user_fullname,
+ username: window.gon.current_username,
+ },
+ };
+};
+
export const toggleAward = async ({ commit, state }, name) => {
const award = state.awards.find((a) => a.name === name && a.user.id === state.currentUserId);
try {
if (award) {
- await axios.delete(joinPaths(gon.relative_url_root || '', `${state.path}/${award.id}`));
-
commit(REMOVE_AWARD, award.id);
+ await axios
+ .delete(joinPaths(gon.relative_url_root || '', `${state.path}/${award.id}`))
+ .catch((err) => {
+ commit(ADD_NEW_AWARD, award);
+
+ throw err;
+ });
+
showToast(__('Award removed'));
} else {
- const { data } = await axios.post(joinPaths(gon.relative_url_root || '', state.path), {
- name,
- });
+ const optimisticAward = newOptimisticAward(name, state);
+
+ commit(ADD_NEW_AWARD, optimisticAward);
+
+ const { data } = await axios
+ .post(joinPaths(gon.relative_url_root || '', state.path), {
+ name,
+ })
+ .finally(() => {
+ commit(REMOVE_AWARD, optimisticAward.id);
+ });
commit(ADD_NEW_AWARD, data);
diff --git a/app/assets/javascripts/emoji/components/utils.js b/app/assets/javascripts/emoji/components/utils.js
index 3465a8ae7e6..5eec0992896 100644
--- a/app/assets/javascripts/emoji/components/utils.js
+++ b/app/assets/javascripts/emoji/components/utils.js
@@ -1,5 +1,5 @@
-import Cookies from 'js-cookie';
import { chunk, memoize, uniq } from 'lodash';
+import { getCookie, setCookie } from '~/lib/utils/common_utils';
import { initEmojiMap, getEmojiCategoryMap } from '~/emoji';
import {
EMOJIS_PER_ROW,
@@ -13,7 +13,7 @@ export const generateCategoryHeight = (emojisLength) =>
emojisLength * EMOJI_ROW_HEIGHT + CATEGORY_ROW_HEIGHT;
export const getFrequentlyUsedEmojis = () => {
- const savedEmojis = Cookies.get(FREQUENTLY_USED_COOKIE_KEY);
+ const savedEmojis = getCookie(FREQUENTLY_USED_COOKIE_KEY);
if (!savedEmojis) return null;
@@ -30,13 +30,13 @@ export const getFrequentlyUsedEmojis = () => {
export const addToFrequentlyUsed = (emoji) => {
const frequentlyUsedEmojis = uniq(
- (Cookies.get(FREQUENTLY_USED_COOKIE_KEY) || '')
+ (getCookie(FREQUENTLY_USED_COOKIE_KEY) || '')
.split(',')
.filter((e) => e)
.concat(emoji),
);
- Cookies.set(FREQUENTLY_USED_COOKIE_KEY, frequentlyUsedEmojis.join(','), { expires: 365 });
+ setCookie(FREQUENTLY_USED_COOKIE_KEY, frequentlyUsedEmojis.join(','));
};
export const hasFrequentlyUsedEmojis = () => getFrequentlyUsedEmojis() !== null;
diff --git a/app/assets/javascripts/entrypoints/behaviors/redirect_listbox.js b/app/assets/javascripts/entrypoints/behaviors/redirect_listbox.js
new file mode 100644
index 00000000000..012cf949c96
--- /dev/null
+++ b/app/assets/javascripts/entrypoints/behaviors/redirect_listbox.js
@@ -0,0 +1,3 @@
+import { initRedirectListboxBehavior } from '~/listbox/redirect_behavior';
+
+initRedirectListboxBehavior();
diff --git a/app/assets/javascripts/environments/components/canary_ingress.vue b/app/assets/javascripts/environments/components/canary_ingress.vue
index 02d660a91c1..30f3f9dfc75 100644
--- a/app/assets/javascripts/environments/components/canary_ingress.vue
+++ b/app/assets/javascripts/environments/components/canary_ingress.vue
@@ -17,6 +17,11 @@ export default {
required: true,
type: Object,
},
+ graphql: {
+ required: false,
+ type: Boolean,
+ default: false,
+ },
},
ingressOptions: Array(100 / 5 + 1)
.fill(0)
@@ -47,11 +52,17 @@ export default {
canaryWeightId() {
return uniqueId('canary-weight-');
},
+ weight() {
+ if (this.graphql) {
+ return this.canaryIngress.canaryWeight;
+ }
+ return this.canaryIngress.canary_weight;
+ },
stableWeight() {
- return (100 - this.canaryIngress.canary_weight).toString();
+ return (100 - this.weight).toString();
},
canaryWeight() {
- return this.canaryIngress.canary_weight.toString();
+ return this.weight.toString();
},
},
methods: {
diff --git a/app/assets/javascripts/environments/components/canary_update_modal.vue b/app/assets/javascripts/environments/components/canary_update_modal.vue
index 8b1121c7158..fd4885a9dbd 100644
--- a/app/assets/javascripts/environments/components/canary_update_modal.vue
+++ b/app/assets/javascripts/environments/components/canary_update_modal.vue
@@ -71,7 +71,7 @@ export default {
mutation: updateCanaryIngress,
variables: {
input: {
- id: this.environment.global_id,
+ id: this.environment.global_id || this.environment.globalId,
weight: this.weight,
},
},
diff --git a/app/assets/javascripts/environments/components/commit.vue b/app/assets/javascripts/environments/components/commit.vue
new file mode 100644
index 00000000000..54b94480685
--- /dev/null
+++ b/app/assets/javascripts/environments/components/commit.vue
@@ -0,0 +1,54 @@
+<script>
+import { GlAvatar, GlAvatarLink, GlLink, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
+import { escape } from 'lodash';
+
+export default {
+ components: {
+ GlAvatar,
+ GlAvatarLink,
+ GlLink,
+ },
+ directives: {
+ GlTooltip,
+ },
+ props: {
+ commit: {
+ required: true,
+ type: Object,
+ },
+ },
+ computed: {
+ commitMessage() {
+ return this.commit?.message;
+ },
+ commitAuthorPath() {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return this.commit?.author?.path || `mailto:${escape(this.commit?.authorEmail)}`;
+ },
+ commitAuthorAvatar() {
+ return this.commit?.author?.avatarUrl || this.commit?.authorGravatarUrl;
+ },
+ commitAuthor() {
+ return this.commit?.author?.name || this.commit?.authorName;
+ },
+ commitPath() {
+ return this.commit?.commitPath;
+ },
+ },
+};
+</script>
+<template>
+ <div data-testid="deployment-commit" class="gl-display-flex gl-align-items-center">
+ <gl-avatar-link v-gl-tooltip :title="commitAuthor" :href="commitAuthorPath">
+ <gl-avatar :size="16" :src="commitAuthorAvatar" />
+ </gl-avatar-link>
+ <gl-link
+ v-gl-tooltip
+ :title="commitMessage"
+ :href="commitPath"
+ class="gl-ml-3 gl-str-truncated"
+ >
+ {{ commitMessage }}
+ </gl-link>
+ </div>
+</template>
diff --git a/app/assets/javascripts/environments/components/deploy_board.vue b/app/assets/javascripts/environments/components/deploy_board.vue
index c642a07fd1e..8a379ebdf66 100644
--- a/app/assets/javascripts/environments/components/deploy_board.vue
+++ b/app/assets/javascripts/environments/components/deploy_board.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
/**
* Renders a deploy board.
*
@@ -17,11 +16,11 @@ import {
GlTooltip,
GlTooltipDirective,
GlSafeHtmlDirective as SafeHtml,
+ GlSprintf,
} from '@gitlab/ui';
import { isEmpty } from 'lodash';
-import { n__ } from '~/locale';
+import { s__, n__ } from '~/locale';
import instanceComponent from '~/vue_shared/components/deployment_instance.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { STATUS_MAP, CANARY_STATUS } from '../constants';
import CanaryIngress from './canary_ingress.vue';
@@ -32,13 +31,13 @@ export default {
GlIcon,
GlLoadingIcon,
GlLink,
+ GlSprintf,
GlTooltip,
},
directives: {
GlTooltip: GlTooltipDirective,
SafeHtml,
},
- mixins: [glFeatureFlagsMixin()],
props: {
deployBoardData: {
type: Object,
@@ -57,6 +56,11 @@ export default {
required: false,
default: '',
},
+ graphql: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
canRenderDeployBoard() {
@@ -65,8 +69,15 @@ export default {
canRenderEmptyState() {
return this.isEmpty;
},
+ canaryIngress() {
+ if (this.graphql) {
+ return this.deployBoardData.canaryIngress;
+ }
+
+ return this.deployBoardData.canary_ingress;
+ },
canRenderCanaryWeight() {
- return !isEmpty(this.deployBoardData.canary_ingress);
+ return !isEmpty(this.canaryIngress);
},
instanceCount() {
const { instances } = this.deployBoardData;
@@ -90,8 +101,20 @@ export default {
deployBoardSvg() {
return deployBoardSvg;
},
+ rollbackUrl() {
+ if (this.graphql) {
+ return this.deployBoardData.rollbackUrl;
+ }
+ return this.deployBoardData.rollback_url;
+ },
+ abortUrl() {
+ if (this.graphql) {
+ return this.deployBoardData.abortUrl;
+ }
+ return this.deployBoardData.abort_url;
+ },
deployBoardActions() {
- return this.deployBoardData.rollback_url || this.deployBoardData.abort_url;
+ return this.rollbackUrl || this.abortUrl;
},
statuses() {
// Canary is not a pod status but it needs to be in the legend.
@@ -106,7 +129,17 @@ export default {
changeCanaryWeight(weight) {
this.$emit('changeCanaryWeight', weight);
},
+ podName(instance) {
+ if (this.graphql) {
+ return instance.podName;
+ }
+
+ return instance.pod_name;
+ },
},
+ emptyStateText: s__(
+ 'DeployBoards|To see deployment progress for your environments, make sure you are deploying to %{codeStart}$KUBE_NAMESPACE%{codeEnd} and annotating with %{codeStart}app.gitlab.com/app=$CI_PROJECT_PATH_SLUG%{codeEnd} and %{codeStart}app.gitlab.com/env=$CI_ENVIRONMENT_SLUG%{codeEnd}.',
+ ),
};
</script>
<template>
@@ -152,7 +185,7 @@ export default {
:key="i"
:status="instance.status"
:tooltip-text="instance.tooltip"
- :pod-name="instance.pod_name"
+ :pod-name="podName(instance)"
:logs-path="logsPath"
:stable="instance.stable"
/>
@@ -163,22 +196,23 @@ export default {
<canary-ingress
v-if="canRenderCanaryWeight"
class="deploy-board-canary-ingress"
- :canary-ingress="deployBoardData.canary_ingress"
+ :canary-ingress="canaryIngress"
+ :graphql="graphql"
@change="changeCanaryWeight"
/>
<section v-if="deployBoardActions" class="deploy-board-actions">
<gl-link
- v-if="deployBoardData.rollback_url"
- :href="deployBoardData.rollback_url"
+ v-if="rollbackUrl"
+ :href="rollbackUrl"
class="btn"
data-method="post"
rel="nofollow"
>{{ __('Rollback') }}</gl-link
>
<gl-link
- v-if="deployBoardData.abort_url"
- :href="deployBoardData.abort_url"
+ v-if="abortUrl"
+ :href="abortUrl"
class="btn btn-danger btn-inverted"
data-method="post"
rel="nofollow"
@@ -196,11 +230,11 @@ export default {
__('Kubernetes deployment not found')
}}</span>
<span>
- To see deployment progress for your environments, make sure you are deploying to
- <code>$KUBE_NAMESPACE</code> and annotating with
- <code>app.gitlab.com/app=$CI_PROJECT_PATH_SLUG</code>
- and
- <code>app.gitlab.com/env=$CI_ENVIRONMENT_SLUG</code>.
+ <gl-sprintf :message="$options.emptyStateText">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
</span>
</section>
</div>
diff --git a/app/assets/javascripts/environments/components/deploy_board_wrapper.vue b/app/assets/javascripts/environments/components/deploy_board_wrapper.vue
new file mode 100644
index 00000000000..d9d77088ad3
--- /dev/null
+++ b/app/assets/javascripts/environments/components/deploy_board_wrapper.vue
@@ -0,0 +1,86 @@
+<script>
+import { GlCollapse, GlButton } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import setEnvironmentToChangeCanaryMutation from '../graphql/mutations/set_environment_to_change_canary.mutation.graphql';
+import DeployBoard from './deploy_board.vue';
+
+export default {
+ components: {
+ DeployBoard,
+ GlButton,
+ GlCollapse,
+ },
+ props: {
+ rolloutStatus: {
+ required: true,
+ type: Object,
+ },
+ environment: {
+ required: true,
+ type: Object,
+ },
+ },
+ data() {
+ return { visible: false };
+ },
+ computed: {
+ icon() {
+ return this.visible ? 'angle-down' : 'angle-right';
+ },
+ label() {
+ return this.visible ? this.$options.i18n.collapse : this.$options.i18n.expand;
+ },
+ isLoading() {
+ return this.rolloutStatus.status === 'loading';
+ },
+ isEmpty() {
+ return this.rolloutStatus.status === 'not_found';
+ },
+ },
+ methods: {
+ toggleCollapse() {
+ this.visible = !this.visible;
+ },
+ changeCanaryWeight(weight) {
+ this.$apollo.mutate({
+ mutation: setEnvironmentToChangeCanaryMutation,
+ variables: {
+ environment: this.environment,
+ weight,
+ },
+ });
+ },
+ },
+ i18n: {
+ collapse: __('Collapse'),
+ expand: __('Expand'),
+ pods: s__('DeployBoard|Kubernetes Pods'),
+ },
+};
+</script>
+<template>
+ <div>
+ <div>
+ <gl-button
+ class="gl-mr-4 gl-min-w-fit-content"
+ :icon="icon"
+ :aria-label="label"
+ size="small"
+ category="tertiary"
+ @click="toggleCollapse"
+ />
+ <span>{{ $options.i18n.pods }}</span>
+ </div>
+ <gl-collapse :visible="visible">
+ <deploy-board
+ :deploy-board-data="rolloutStatus"
+ :is-loading="isLoading"
+ :is-empty="isEmpty"
+ :environment="environment"
+ graphql
+ class="gl-reset-bg!"
+ @changeCanaryWeight="changeCanaryWeight"
+ />
+ </gl-collapse>
+ </div>
+</template>
diff --git a/app/assets/javascripts/environments/components/deployment.vue b/app/assets/javascripts/environments/components/deployment.vue
index ef43ca6bc33..f98edb6bb7d 100644
--- a/app/assets/javascripts/environments/components/deployment.vue
+++ b/app/assets/javascripts/environments/components/deployment.vue
@@ -1,25 +1,240 @@
<script>
+import {
+ GlBadge,
+ GlButton,
+ GlCollapse,
+ GlIcon,
+ GlLink,
+ GlTooltipDirective as GlTooltip,
+ GlTruncate,
+} from '@gitlab/ui';
+import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
+import { __, s__ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import DeploymentStatusBadge from './deployment_status_badge.vue';
+import Commit from './commit.vue';
export default {
components: {
+ ClipboardButton,
+ Commit,
DeploymentStatusBadge,
+ GlBadge,
+ GlButton,
+ GlCollapse,
+ GlIcon,
+ GlLink,
+ GlTruncate,
+ TimeAgoTooltip,
+ },
+ directives: {
+ GlTooltip,
},
props: {
deployment: {
type: Object,
required: true,
},
+ latest: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ data() {
+ return { visible: false };
},
computed: {
status() {
return this.deployment?.status;
},
+ iid() {
+ return this.deployment?.iid;
+ },
+ shortSha() {
+ return this.commit?.shortId;
+ },
+ createdAt() {
+ return this.deployment?.createdAt;
+ },
+ isMobile() {
+ return !GlBreakpointInstance.isDesktop();
+ },
+ detailsButton() {
+ return this.visible
+ ? { text: this.$options.i18n.hideDetails, icon: 'expand-up' }
+ : { text: this.$options.i18n.showDetails, icon: 'expand-down' };
+ },
+ detailsButtonClasses() {
+ return this.isMobile ? 'gl-sr-only' : '';
+ },
+ commit() {
+ return this.deployment?.commit;
+ },
+ user() {
+ return this.deployment?.user;
+ },
+ username() {
+ return `@${this.user.username}`;
+ },
+ userPath() {
+ return this.user?.path;
+ },
+ deployable() {
+ return this.deployment?.deployable;
+ },
+ jobName() {
+ return this.deployable?.name;
+ },
+ jobPath() {
+ return this.deployable?.buildPath;
+ },
+ refLabel() {
+ return this.deployment?.tag ? this.$options.i18n.tag : this.$options.i18n.branch;
+ },
+ ref() {
+ return this.deployment?.ref;
+ },
+ refName() {
+ return this.ref?.name;
+ },
+ refPath() {
+ return this.ref?.refPath;
+ },
+ },
+ methods: {
+ toggleCollapse() {
+ this.visible = !this.visible;
+ },
+ },
+ i18n: {
+ latestBadge: s__('Deployment|Latest Deployed'),
+ deploymentId: s__('Deployment|Deployment ID'),
+ copyButton: __('Copy commit SHA'),
+ commitSha: __('Commit SHA'),
+ showDetails: __('Show details'),
+ hideDetails: __('Hide details'),
+ triggerer: s__('Deployment|Triggerer'),
+ job: __('Job'),
+ api: __('API'),
+ branch: __('Branch'),
+ tag: __('Tag'),
},
+ headerClasses: [
+ 'gl-display-flex',
+ 'gl-align-items-flex-start',
+ 'gl-md-align-items-center',
+ 'gl-justify-content-space-between',
+ 'gl-pr-6',
+ ],
+ headerDetailsClasses: [
+ 'gl-display-flex',
+ 'gl-flex-direction-column',
+ 'gl-md-flex-direction-row',
+ 'gl-align-items-flex-start',
+ 'gl-md-align-items-center',
+ 'gl-font-sm',
+ 'gl-text-gray-700',
+ ],
+ deploymentStatusClasses: [
+ 'gl-display-flex',
+ 'gl-gap-x-3',
+ 'gl-mr-0',
+ 'gl-md-mr-5',
+ 'gl-mb-3',
+ 'gl-md-mb-0',
+ ],
};
</script>
<template>
<div>
- <deployment-status-badge v-if="status" :status="status" />
+ <div :class="$options.headerClasses">
+ <div :class="$options.headerDetailsClasses">
+ <div :class="$options.deploymentStatusClasses">
+ <deployment-status-badge v-if="status" :status="status" />
+ <gl-badge v-if="latest" variant="info">{{ $options.i18n.latestBadge }}</gl-badge>
+ </div>
+ <div class="gl-display-flex gl-align-items-center gl-gap-x-5">
+ <div
+ v-if="iid"
+ v-gl-tooltip
+ class="gl-display-flex"
+ :title="$options.i18n.deploymentId"
+ :aria-label="$options.i18n.deploymentId"
+ >
+ <gl-icon ref="deployment-iid-icon" name="deployments" />
+ <span class="gl-ml-2">#{{ iid }}</span>
+ </div>
+ <div
+ v-if="shortSha"
+ data-testid="deployment-commit-sha"
+ class="gl-font-monospace gl-display-flex gl-align-items-center"
+ >
+ <gl-icon ref="deployment-commit-icon" name="commit" class="gl-mr-2" />
+ <span v-gl-tooltip :title="$options.i18n.commitSha">{{ shortSha }}</span>
+ <clipboard-button
+ :text="shortSha"
+ category="tertiary"
+ :title="$options.i18n.copyButton"
+ size="small"
+ />
+ </div>
+ <time-ago-tooltip v-if="createdAt" :time="createdAt" class="gl-display-flex">
+ <template #default="{ timeAgo }">
+ <gl-icon name="calendar" />
+ <span class="gl-mr-2 gl-white-space-nowrap">{{ timeAgo }}</span>
+ </template>
+ </time-ago-tooltip>
+ </div>
+ </div>
+ <gl-button
+ ref="details-toggle"
+ category="tertiary"
+ :icon="detailsButton.icon"
+ :button-text-classes="detailsButtonClasses"
+ @click="toggleCollapse"
+ >
+ {{ detailsButton.text }}
+ </gl-button>
+ </div>
+ <commit v-if="commit" :commit="commit" class="gl-mt-3" />
+ <gl-collapse :visible="visible">
+ <div
+ class="gl-display-flex gl-md-align-items-center gl-mt-5 gl-flex-direction-column gl-md-flex-direction-row gl-pr-4 gl-md-pr-0"
+ >
+ <div v-if="user" class="gl-display-flex gl-flex-direction-column gl-md-max-w-15p">
+ <span class="gl-text-gray-500">{{ $options.i18n.triggerer }}</span>
+ <gl-link :href="userPath" class="gl-font-monospace gl-mt-3">
+ <gl-truncate :text="username" with-tooltip />
+ </gl-link>
+ </div>
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-md-pl-7 gl-md-max-w-15p gl-mt-4 gl-md-mt-0"
+ >
+ <span class="gl-text-gray-500" :class="{ 'gl-ml-3': !deployable }">
+ {{ $options.i18n.job }}
+ </span>
+ <gl-link v-if="jobPath" :href="jobPath" class="gl-font-monospace gl-mt-3">
+ <gl-truncate :text="jobName" with-tooltip position="middle" />
+ </gl-link>
+ <span v-else-if="jobName" class="gl-font-monospace gl-mt-3">
+ <gl-truncate :text="jobName" with-tooltip position="middle" />
+ </span>
+ <gl-badge v-else class="gl-font-monospace gl-mt-3" variant="info">
+ {{ $options.i18n.api }}
+ </gl-badge>
+ </div>
+ <div
+ v-if="ref"
+ class="gl-display-flex gl-flex-direction-column gl-md-pl-7 gl-md-max-w-15p gl-mt-4 gl-md-mt-0"
+ >
+ <span class="gl-text-gray-500">{{ refLabel }}</span>
+ <gl-link :href="refPath" class="gl-font-monospace gl-mt-3">
+ <gl-truncate :text="refName" with-tooltip />
+ </gl-link>
+ </div>
+ </div>
+ </gl-collapse>
</div>
</template>
diff --git a/app/assets/javascripts/environments/components/empty_state.vue b/app/assets/javascripts/environments/components/empty_state.vue
index 977da12e8a9..36b9b647af7 100644
--- a/app/assets/javascripts/environments/components/empty_state.vue
+++ b/app/assets/javascripts/environments/components/empty_state.vue
@@ -12,10 +12,10 @@ export default {
<template>
<div class="empty-state">
<div class="text-content">
- <h4 class="blank-state-title js-blank-state-title">
+ <h4 class="js-blank-state-title">
{{ s__("Environments|You don't have any environments right now") }}
</h4>
- <p class="blank-state-text">
+ <p>
{{
s__(`Environments|Environments are places where
code gets deployed, such as staging or production.`)
diff --git a/app/assets/javascripts/environments/components/environment_pin.vue b/app/assets/javascripts/environments/components/environment_pin.vue
index 0b753d53ee3..f5a83b97552 100644
--- a/app/assets/javascripts/environments/components/environment_pin.vue
+++ b/app/assets/javascripts/environments/components/environment_pin.vue
@@ -6,6 +6,7 @@
import { GlDropdownItem } from '@gitlab/ui';
import { __ } from '~/locale';
import eventHub from '../event_hub';
+import cancelAutoStopMutation from '../graphql/mutations/cancel_auto_stop.mutation.graphql';
export default {
components: {
@@ -16,10 +17,22 @@ export default {
type: String,
required: true,
},
+ graphql: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
methods: {
onPinClick() {
- eventHub.$emit('cancelAutoStop', this.autoStopUrl);
+ if (this.graphql) {
+ this.$apollo.mutate({
+ mutation: cancelAutoStopMutation,
+ variables: { autoStopUrl: this.autoStopUrl },
+ });
+ } else {
+ eventHub.$emit('cancelAutoStop', this.autoStopUrl);
+ }
},
},
title: __('Prevent auto-stopping'),
diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue
index d3624103c13..27a763fb9c4 100644
--- a/app/assets/javascripts/environments/components/new_environment_item.vue
+++ b/app/assets/javascripts/environments/components/new_environment_item.vue
@@ -4,10 +4,12 @@ import {
GlDropdown,
GlButton,
GlLink,
+ GlSprintf,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import { truncate } from '~/lib/utils/text_utility';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import isLastDeployment from '../graphql/queries/is_last_deployment.query.graphql';
import ExternalUrl from './environment_external_url.vue';
import Actions from './environment_actions.vue';
@@ -18,6 +20,7 @@ import Monitoring from './environment_monitoring.vue';
import Terminal from './environment_terminal_button.vue';
import Delete from './environment_delete.vue';
import Deployment from './deployment.vue';
+import DeployBoardWrapper from './deploy_board_wrapper.vue';
export default {
components: {
@@ -25,19 +28,23 @@ export default {
GlDropdown,
GlButton,
GlLink,
+ GlSprintf,
Actions,
Deployment,
+ DeployBoardWrapper,
ExternalUrl,
StopComponent,
Rollback,
Monitoring,
Pin,
Terminal,
+ TimeAgoTooltip,
Delete,
},
directives: {
GlTooltip,
},
+ inject: ['helpPagePath'],
props: {
environment: {
required: true,
@@ -60,6 +67,10 @@ export default {
i18n: {
collapse: __('Collapse'),
expand: __('Expand'),
+ emptyState: s__(
+ 'Environments|There are no deployments for this environment yet. %{linkStart}Learn more about setting up deployments.%{linkEnd}',
+ ),
+ autoStopIn: s__('Environment|Auto stop %{time}'),
},
data() {
return { visible: false };
@@ -83,12 +94,15 @@ export default {
upcomingDeployment() {
return this.environment?.upcomingDeployment;
},
+ hasDeployment() {
+ return Boolean(this.environment?.upcomingDeployment || this.environment?.lastDeployment);
+ },
actions() {
if (!this.lastDeployment) {
return [];
}
- const { manualActions = [], scheduledActions = [] } = this.lastDeployment;
- const combinedActions = [...manualActions, ...scheduledActions];
+ const { manualActions, scheduledActions } = this.lastDeployment;
+ const combinedActions = [...(manualActions ?? []), ...(scheduledActions ?? [])];
return combinedActions.map((action) => ({
...action,
}));
@@ -133,6 +147,9 @@ export default {
displayName() {
return truncate(this.name, 80);
},
+ rolloutStatus() {
+ return this.environment?.rolloutStatus;
+ },
},
methods: {
toggleCollapse() {
@@ -144,7 +161,15 @@ export default {
'gl-border-t-solid',
'gl-border-1',
'gl-py-5',
- 'gl-pl-7',
+ 'gl-md-pl-7',
+ 'gl-bg-gray-10',
+ ],
+ deployBoardClasses: [
+ 'gl-border-gray-100',
+ 'gl-border-t-solid',
+ 'gl-border-1',
+ 'gl-py-4',
+ 'gl-md-pl-7',
'gl-bg-gray-10',
],
};
@@ -176,7 +201,14 @@ export default {
{{ displayName }}
</gl-link>
</div>
- <div>
+ <div class="gl-display-flex gl-align-items-center">
+ <p v-if="canShowAutoStopDate" class="gl-font-sm gl-text-gray-700 gl-mr-5 gl-mb-0">
+ <gl-sprintf :message="$options.i18n.autoStopIn">
+ <template #time>
+ <time-ago-tooltip :time="environment.autoStopAt" css-class="gl-font-weight-bold" />
+ </template>
+ </gl-sprintf>
+ </p>
<div class="btn-group table-action-buttons" role="group">
<external-url
v-if="externalUrl"
@@ -224,6 +256,7 @@ export default {
<pin
v-if="canShowAutoStopDate"
:auto-stop-url="autoStopPath"
+ graphql
data-track-action="click_button"
data-track-label="environment_pin"
/>
@@ -254,11 +287,37 @@ export default {
</div>
</div>
<gl-collapse :visible="visible">
- <div v-if="lastDeployment" :class="$options.deploymentClasses">
- <deployment :deployment="lastDeployment" :class="{ 'gl-ml-7': inFolder }" />
+ <template v-if="hasDeployment">
+ <div v-if="lastDeployment" :class="$options.deploymentClasses">
+ <deployment
+ :deployment="lastDeployment"
+ :class="{ 'gl-ml-7': inFolder }"
+ latest
+ class="gl-pl-4"
+ />
+ </div>
+ <div v-if="upcomingDeployment" :class="$options.deploymentClasses">
+ <deployment
+ :deployment="upcomingDeployment"
+ :class="{ 'gl-ml-7': inFolder }"
+ class="gl-pl-4"
+ />
+ </div>
+ </template>
+ <div v-else :class="$options.deploymentClasses">
+ <gl-sprintf :message="$options.i18n.emptyState">
+ <template #link="{ content }">
+ <gl-link :href="helpPagePath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
</div>
- <div v-if="upcomingDeployment" :class="$options.deploymentClasses">
- <deployment :deployment="upcomingDeployment" :class="{ 'gl-ml-7': inFolder }" />
+ <div v-if="rolloutStatus" :class="$options.deployBoardClasses">
+ <deploy-board-wrapper
+ :rollout-status="rolloutStatus"
+ :environment="environment"
+ :class="{ 'gl-ml-7': inFolder }"
+ class="gl-pl-4"
+ />
</div>
</gl-collapse>
</div>
diff --git a/app/assets/javascripts/environments/components/new_environments_app.vue b/app/assets/javascripts/environments/components/new_environments_app.vue
index cb36e226d0e..3699f39b611 100644
--- a/app/assets/javascripts/environments/components/new_environments_app.vue
+++ b/app/assets/javascripts/environments/components/new_environments_app.vue
@@ -8,16 +8,19 @@ import pageInfoQuery from '../graphql/queries/page_info.query.graphql';
import environmentToDeleteQuery from '../graphql/queries/environment_to_delete.query.graphql';
import environmentToRollbackQuery from '../graphql/queries/environment_to_rollback.query.graphql';
import environmentToStopQuery from '../graphql/queries/environment_to_stop.query.graphql';
+import environmentToChangeCanaryQuery from '../graphql/queries/environment_to_change_canary.query.graphql';
import EnvironmentFolder from './new_environment_folder.vue';
import EnableReviewAppModal from './enable_review_app_modal.vue';
import StopEnvironmentModal from './stop_environment_modal.vue';
import EnvironmentItem from './new_environment_item.vue';
import ConfirmRollbackModal from './confirm_rollback_modal.vue';
import DeleteEnvironmentModal from './delete_environment_modal.vue';
+import CanaryUpdateModal from './canary_update_modal.vue';
export default {
components: {
DeleteEnvironmentModal,
+ CanaryUpdateModal,
ConfirmRollbackModal,
EnvironmentFolder,
EnableReviewAppModal,
@@ -56,6 +59,12 @@ export default {
environmentToStop: {
query: environmentToStopQuery,
},
+ environmentToChangeCanary: {
+ query: environmentToChangeCanaryQuery,
+ },
+ weight: {
+ query: environmentToChangeCanaryQuery,
+ },
},
inject: ['newEnvironmentPath', 'canCreateEnvironment'],
i18n: {
@@ -80,6 +89,8 @@ export default {
environmentToDelete: {},
environmentToRollback: {},
environmentToStop: {},
+ environmentToChangeCanary: {},
+ weight: 0,
};
},
computed: {
@@ -186,6 +197,7 @@ export default {
<delete-environment-modal :environment="environmentToDelete" graphql />
<stop-environment-modal :environment="environmentToStop" graphql />
<confirm-rollback-modal :environment="environmentToRollback" graphql />
+ <canary-update-modal :environment="environmentToChangeCanary" :weight="weight" />
<gl-tabs
:action-secondary="addEnvironment"
:action-primary="openReviewAppModal"
diff --git a/app/assets/javascripts/environments/graphql/mutations/cancel_auto_stop.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/cancel_auto_stop.mutation.graphql
index 22dfb8a7a89..0b473495710 100644
--- a/app/assets/javascripts/environments/graphql/mutations/cancel_auto_stop.mutation.graphql
+++ b/app/assets/javascripts/environments/graphql/mutations/cancel_auto_stop.mutation.graphql
@@ -1,5 +1,5 @@
-mutation cancelAutoStop($environment: LocalEnvironment) {
- cancelAutoStop(environment: $environment) @client {
+mutation cancelAutoStop($autoStopUrl: String!) {
+ cancelAutoStop(autoStopUrl: $autoStopUrl) @client {
errors
}
}
diff --git a/app/assets/javascripts/environments/graphql/mutations/set_environment_to_change_canary.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/set_environment_to_change_canary.mutation.graphql
new file mode 100644
index 00000000000..0f48c1f5c05
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/mutations/set_environment_to_change_canary.mutation.graphql
@@ -0,0 +1,3 @@
+mutation SetEnvironmentToChangeCanary($environment: LocalEnvironmentInput, $weight: Int!) {
+ setEnvironmentToChangeCanary(environment: $environment, weight: $weight) @client
+}
diff --git a/app/assets/javascripts/environments/graphql/queries/environment_to_change_canary.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_to_change_canary.query.graphql
new file mode 100644
index 00000000000..b582ae55ba1
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/environment_to_change_canary.query.graphql
@@ -0,0 +1,4 @@
+query environmentToChangeCanary {
+ environmentToChangeCanary @client
+ weight @client
+}
diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js
index 812fa0c81f0..dc763b77157 100644
--- a/app/assets/javascripts/environments/graphql/resolvers.js
+++ b/app/assets/javascripts/environments/graphql/resolvers.js
@@ -10,6 +10,7 @@ import pollIntervalQuery from './queries/poll_interval.query.graphql';
import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql';
import environmentToStopQuery from './queries/environment_to_stop.query.graphql';
import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql';
+import environmentToChangeCanaryQuery from './queries/environment_to_change_canary.query.graphql';
import pageInfoQuery from './queries/page_info.query.graphql';
const buildErrors = (errors = []) => ({
@@ -134,9 +135,15 @@ export const resolvers = (endpoint) => ({
data: { environmentToRollback: environment },
});
},
- cancelAutoStop(_, { environment: { autoStopPath } }) {
+ setEnvironmentToChangeCanary(_, { environment, weight }, { client }) {
+ client.writeQuery({
+ query: environmentToChangeCanaryQuery,
+ data: { environmentToChangeCanary: environment, weight },
+ });
+ },
+ cancelAutoStop(_, { autoStopUrl }) {
return axios
- .post(autoStopPath)
+ .post(autoStopUrl)
.then(() => buildErrors())
.catch((err) =>
buildErrors([
diff --git a/app/assets/javascripts/environments/graphql/typedefs.graphql b/app/assets/javascripts/environments/graphql/typedefs.graphql
index c02f6b2838a..b4d1f7326f6 100644
--- a/app/assets/javascripts/environments/graphql/typedefs.graphql
+++ b/app/assets/javascripts/environments/graphql/typedefs.graphql
@@ -77,9 +77,10 @@ extend type Mutation {
stopEnvironment(environment: LocalEnvironmentInput): LocalErrors
deleteEnvironment(environment: LocalEnvironmentInput): LocalErrors
rollbackEnvironment(environment: LocalEnvironmentInput): LocalErrors
- cancelAutoStop(environment: LocalEnvironmentInput): LocalErrors
+ cancelAutoStop(autoStopUrl: String!): LocalErrors
setEnvironmentToDelete(environment: LocalEnvironmentInput): LocalErrors
setEnvironmentToRollback(environment: LocalEnvironmentInput): LocalErrors
setEnvironmentToStop(environment: LocalEnvironmentInput): LocalErrors
+ setEnvironmentToChangeCanary(environment: LocalEnvironmentInput, weight: Int): LocalErrors
action(environment: LocalEnvironmentInput): LocalErrors
}
diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js
index 0d7a475eb8e..071c95b8f0a 100644
--- a/app/assets/javascripts/files_comment_button.js
+++ b/app/assets/javascripts/files_comment_button.js
@@ -4,7 +4,7 @@
* causes reflows, visit https://gist.github.com/paulirish/5d52fb081b3570c81e3a
*/
-import Cookies from 'js-cookie';
+import { getCookie } from '~/lib/utils/common_utils';
const LINE_NUMBER_CLASS = 'diff-line-num';
const UNFOLDABLE_LINE_CLASS = 'js-unfold';
@@ -29,7 +29,7 @@ export default {
$diffFile.closest(DIFF_CONTAINER_SELECTOR).data('canCreateNote') === '';
}
- this.isParallelView = Cookies.get('diff_view') === 'parallel';
+ this.isParallelView = getCookie('diff_view') === 'parallel';
if (this.userCanCreateNote) {
$diffFile
diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
index d00e6e59cf5..28a3c54cc8f 100644
--- a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
+++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
@@ -13,6 +13,21 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => {
IssuableTokenKeys.tokenKeys.splice(2, 0, reviewerToken);
IssuableTokenKeys.tokenKeysWithAlternative.splice(2, 0, reviewerToken);
+ if (window.gon?.features?.mrAttentionRequests) {
+ const attentionRequestedToken = {
+ formattedKey: __('Attention'),
+ key: 'attention',
+ type: 'string',
+ param: '',
+ symbol: '@',
+ icon: 'user',
+ tag: '@attention',
+ hideNotEqual: true,
+ };
+ IssuableTokenKeys.tokenKeys.splice(2, 0, attentionRequestedToken);
+ IssuableTokenKeys.tokenKeysWithAlternative.splice(2, 0, attentionRequestedToken);
+ }
+
const draftToken = {
token: {
formattedKey: __('Draft'),
diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
index 3cd4d48a4a3..09cef74477c 100644
--- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
+++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
@@ -77,6 +77,11 @@ export default class AvailableDropdownMappings {
gl: DropdownUser,
element: this.container.querySelector('#js-dropdown-reviewer'),
},
+ attention: {
+ reference: null,
+ gl: DropdownUser,
+ element: this.container.getElementById('js-dropdown-attention-requested'),
+ },
'approved-by': {
reference: null,
gl: DropdownUser,
diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js
index e2d6936acbd..f8b5910de9e 100644
--- a/app/assets/javascripts/filtered_search/constants.js
+++ b/app/assets/javascripts/filtered_search/constants.js
@@ -1,4 +1,4 @@
-export const USER_TOKEN_TYPES = ['author', 'assignee', 'approved-by', 'reviewer'];
+export const USER_TOKEN_TYPES = ['author', 'assignee', 'approved-by', 'reviewer', 'attention'];
export const DROPDOWN_TYPE = {
hint: 'hint',
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index d9c2e55cffe..fa605f8c056 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -18,6 +18,13 @@ const VARIANT_DANGER = 'danger';
const VARIANT_INFO = 'info';
const VARIANT_TIP = 'tip';
+const TYPE_TO_VARIANT = {
+ [FLASH_TYPES.ALERT]: VARIANT_DANGER,
+ [FLASH_TYPES.NOTICE]: VARIANT_INFO,
+ [FLASH_TYPES.SUCCESS]: VARIANT_SUCCESS,
+ [FLASH_TYPES.WARNING]: VARIANT_WARNING,
+};
+
const FLASH_CLOSED_EVENT = 'flashClosed';
const getCloseEl = (flashEl) => {
@@ -61,7 +68,7 @@ const createAction = (config) => `
`;
const createFlashEl = (message, type) => `
- <div class="flash-${type}">
+ <div class="flash-${type}" data-testid="alert-${TYPE_TO_VARIANT[type]}">
<div class="flash-text">
${escape(message)}
<div class="close-icon-wrapper js-close-icon">
@@ -189,6 +196,9 @@ const createAlert = function createAlert({
secondaryButtonLink: secondaryButton?.link,
secondaryButtonText: secondaryButton?.text,
},
+ attrs: {
+ 'data-testid': `alert-${variant}`,
+ },
on,
},
message,
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index 69331ff1a06..bf29a356abd 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -86,6 +86,7 @@ export const defaultAutocompleteConfig = {
labels: true,
snippets: true,
vulnerabilities: true,
+ contacts: true,
};
class GfmAutoComplete {
@@ -127,6 +128,7 @@ class GfmAutoComplete {
if (this.enableMap.mergeRequests) this.setupMergeRequests($input);
if (this.enableMap.labels) this.setupLabels($input);
if (this.enableMap.snippets) this.setupSnippets($input);
+ if (this.enableMap.contacts) this.setupContacts($input);
$input.filter('[data-supports-quick-actions="true"]').atwho({
at: '/',
@@ -174,9 +176,16 @@ class GfmAutoComplete {
let tpl = '/${name} ';
let referencePrefix = null;
if (value.params.length > 0) {
- [[referencePrefix]] = value.params;
- if (/^[@%~]/.test(referencePrefix)) {
+ const regexp = /\[[a-z]+:/;
+ const match = regexp.exec(value.params);
+ if (match) {
+ [referencePrefix] = match;
tpl += '<%- referencePrefix %>';
+ } else {
+ [[referencePrefix]] = value.params;
+ if (/^[@%~]/.test(referencePrefix)) {
+ tpl += '<%- referencePrefix %>';
+ }
}
}
return template(tpl, { interpolate: /<%=([\s\S]+?)%>/g })({ referencePrefix });
@@ -266,6 +275,8 @@ class GfmAutoComplete {
UNASSIGN_REVIEWER: '/unassign_reviewer',
REASSIGN: '/reassign',
CC: '/cc',
+ ATTENTION: '/attention',
+ REMOVE_ATTENTION: '/remove_attention',
};
let assignees = [];
let reviewers = [];
@@ -344,6 +355,23 @@ class GfmAutoComplete {
} else if (command === MEMBER_COMMAND.UNASSIGN_REVIEWER) {
// Only include members which are not assigned as a reviewer to Issuable currently
return data.filter((member) => reviewers.includes(member.search));
+ } else if (
+ command === MEMBER_COMMAND.ATTENTION ||
+ command === MEMBER_COMMAND.REMOVE_ATTENTION
+ ) {
+ const attentionUsers = [
+ ...(SidebarMediator.singleton?.store?.assignees || []),
+ ...(SidebarMediator.singleton?.store?.reviewers || []),
+ ];
+ const attentionRequested = command === MEMBER_COMMAND.REMOVE_ATTENTION;
+
+ return data.filter((member) =>
+ attentionUsers.find(
+ (u) =>
+ createMemberSearchString(u).includes(member.search) &&
+ u.attention_requested === attentionRequested,
+ ),
+ );
}
return data;
@@ -619,6 +647,42 @@ class GfmAutoComplete {
});
}
+ setupContacts($input) {
+ $input.atwho({
+ at: '[contact:',
+ suffix: ']',
+ alias: 'contacts',
+ searchKey: 'search',
+ displayTpl(value) {
+ let tmpl = GfmAutoComplete.Loading.template;
+ if (value.email != null) {
+ tmpl = GfmAutoComplete.Contacts.templateFunction(value);
+ }
+ return tmpl;
+ },
+ data: GfmAutoComplete.defaultLoadingData,
+ // eslint-disable-next-line no-template-curly-in-string
+ insertTpl: '${atwho-at}${email}',
+ callbacks: {
+ ...this.getDefaultCallbacks(),
+ beforeSave(contacts) {
+ return $.map(contacts, (m) => {
+ if (m.email == null) {
+ return m;
+ }
+ return {
+ id: m.id,
+ email: m.email,
+ firstName: m.first_name,
+ lastName: m.last_name,
+ search: `${m.email}`,
+ };
+ });
+ },
+ },
+ });
+ }
+
getDefaultCallbacks() {
const self = this;
@@ -790,6 +854,7 @@ GfmAutoComplete.atTypeMap = {
'/': 'commands',
'[vulnerability:': 'vulnerabilities',
$: 'snippets',
+ '[contact:': 'contacts',
};
GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities'];
@@ -883,6 +948,11 @@ GfmAutoComplete.Milestones = {
return `<li>${escape(title)}</li>`;
},
};
+GfmAutoComplete.Contacts = {
+ templateFunction({ email, firstName, lastName }) {
+ return `<li><small>${firstName} ${lastName}</small> ${escape(email)}</li>`;
+ },
+};
GfmAutoComplete.Loading = {
template:
'<li style="pointer-events: none;"><span class="spinner align-text-bottom mr-1"></span>Loading...</li>',
diff --git a/app/assets/javascripts/google_cloud/components/deployments_service_table.vue b/app/assets/javascripts/google_cloud/components/deployments_service_table.vue
index 7d27d7cf6b2..26c9fd14dc6 100644
--- a/app/assets/javascripts/google_cloud/components/deployments_service_table.vue
+++ b/app/assets/javascripts/google_cloud/components/deployments_service_table.vue
@@ -2,6 +2,9 @@
import { GlButton, GlTable } from '@gitlab/ui';
import { __ } from '~/locale';
+const cloudRun = 'cloudRun';
+const cloudStorage = 'cloudStorage';
+
const i18n = {
cloudRun: __('Cloud Run'),
cloudRunDescription: __('Deploy container based web apps on Google managed clusters'),
@@ -28,6 +31,13 @@ export default {
required: true,
},
},
+ methods: {
+ actionUrl(key) {
+ if (key === cloudRun) return this.cloudRunUrl;
+ else if (key === cloudStorage) return this.cloudStorageUrl;
+ return '#';
+ },
+ },
fields: [
{ key: 'title', label: i18n.service },
{ key: 'description', label: i18n.description },
@@ -37,12 +47,19 @@ export default {
{
title: i18n.cloudRun,
description: i18n.cloudRunDescription,
- action: { title: i18n.configureViaMergeRequest, disabled: true },
+ action: {
+ key: cloudRun,
+ title: i18n.configureViaMergeRequest,
+ },
},
{
title: i18n.cloudStorage,
description: i18n.cloudStorageDescription,
- action: { title: i18n.configureViaMergeRequest, disabled: true },
+ action: {
+ key: cloudStorage,
+ title: i18n.configureViaMergeRequest,
+ disabled: true,
+ },
},
],
i18n,
@@ -54,7 +71,9 @@ export default {
<p>{{ $options.i18n.deploymentsDescription }}</p>
<gl-table :fields="$options.fields" :items="$options.items">
<template #cell(action)="{ value }">
- <gl-button :disabled="value.disabled">{{ value.title }}</gl-button>
+ <gl-button :disabled="value.disabled" :href="actionUrl(value.key)">
+ {{ value.title }}
+ </gl-button>
</template>
</gl-table>
</div>
diff --git a/app/assets/javascripts/google_cloud/components/home.vue b/app/assets/javascripts/google_cloud/components/home.vue
index 8ef110dcf22..c08d8bb7c51 100644
--- a/app/assets/javascripts/google_cloud/components/home.vue
+++ b/app/assets/javascripts/google_cloud/components/home.vue
@@ -23,11 +23,11 @@ export default {
type: String,
required: true,
},
- deploymentsCloudRunUrl: {
+ enableCloudRunUrl: {
type: String,
required: true,
},
- deploymentsCloudStorageUrl: {
+ enableCloudStorageUrl: {
type: String,
required: true,
},
@@ -47,8 +47,8 @@ export default {
</gl-tab>
<gl-tab :title="__('Deployments')">
<deployments-service-table
- :cloud-run-url="deploymentsCloudRunUrl"
- :cloud-storage-url="deploymentsCloudStorageUrl"
+ :cloud-run-url="enableCloudRunUrl"
+ :cloud-storage-url="enableCloudStorageUrl"
/>
</gl-tab>
<gl-tab :title="__('Services')" disabled />
diff --git a/app/assets/javascripts/google_cloud/components/service_accounts_form.vue b/app/assets/javascripts/google_cloud/components/service_accounts_form.vue
index e7a09668473..551783e6c50 100644
--- a/app/assets/javascripts/google_cloud/components/service_accounts_form.vue
+++ b/app/assets/javascripts/google_cloud/components/service_accounts_form.vue
@@ -1,9 +1,9 @@
<script>
-import { GlButton, GlFormGroup, GlFormSelect } from '@gitlab/ui';
+import { GlButton, GlFormGroup, GlFormSelect, GlFormCheckbox } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
- components: { GlButton, GlFormGroup, GlFormSelect },
+ components: { GlButton, GlFormGroup, GlFormSelect, GlFormCheckbox },
props: {
gcpProjects: { required: true, type: Array },
environments: { required: true, type: Array },
@@ -19,6 +19,9 @@ export default {
environmentDescription: __('Generated service account is linked to the selected environment'),
submitLabel: __('Create service account'),
cancelLabel: __('Cancel'),
+ checkboxLabel: __(
+ 'I understand the responsibilities involved with managing service account keys',
+ ),
},
};
</script>
@@ -59,6 +62,11 @@ export default {
</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">
diff --git a/app/assets/javascripts/google_cloud/components/service_accounts_list.vue b/app/assets/javascripts/google_cloud/components/service_accounts_list.vue
index b70b25a5dc3..4db84746482 100644
--- a/app/assets/javascripts/google_cloud/components/service_accounts_list.vue
+++ b/app/assets/javascripts/google_cloud/components/service_accounts_list.vue
@@ -1,9 +1,9 @@
<script>
-import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui';
+import { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf, GlTable } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
- components: { GlButton, GlEmptyState, GlTable },
+ components: { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf, GlTable },
props: {
list: {
type: Array,
@@ -28,6 +28,22 @@ export default {
],
};
},
+ i18n: {
+ createServiceAccount: __('Create service account'),
+ found: __('✔'),
+ notFound: __('Not found'),
+ noServiceAccountsTitle: __('No service accounts'),
+ noServiceAccountsDescription: __(
+ 'Service Accounts keys authorize GitLab to deploy your Google Cloud project',
+ ),
+ serviceAccountsTitle: __('Service accounts'),
+ serviceAccountsDescription: __(
+ 'Service Accounts keys authorize GitLab to deploy your Google Cloud project',
+ ),
+ secretManagersDescription: __(
+ 'Enhance security by storing service account keys in secret managers - learn more about %{docLinkStart}secret management with GitLab%{docLinkEnd}',
+ ),
+ },
};
</script>
@@ -35,31 +51,39 @@ export default {
<div>
<gl-empty-state
v-if="list.length === 0"
- :title="__('No service accounts')"
- :description="
- __('Service Accounts keys authorize GitLab to deploy your Google Cloud project')
- "
+ :title="$options.i18n.noServiceAccountsTitle"
+ :description="$options.i18n.noServiceAccountsDescription"
:primary-button-link="createUrl"
- :primary-button-text="__('Create service account')"
+ :primary-button-text="$options.i18n.createServiceAccount"
:svg-path="emptyIllustrationUrl"
/>
<div v-else>
- <h2 class="gl-font-size-h2">{{ __('Service Accounts') }}</h2>
- <p>{{ __('Service Accounts keys authorize GitLab to deploy your Google Cloud project') }}</p>
+ <h2 class="gl-font-size-h2">{{ $options.i18n.serviceAccountsTitle }}</h2>
+ <p>{{ $options.i18n.serviceAccountsDescription }}</p>
<gl-table :items="list" :fields="tableFields">
<template #cell(service_account_exists)="{ value }">
- {{ value ? '✔' : __('Not found') }}
+ {{ value ? $options.i18n.found : $options.i18n.notFound }}
</template>
<template #cell(service_account_key_exists)="{ value }">
- {{ value ? '✔' : __('Not found') }}
+ {{ value ? $options.i18n.found : $options.i18n.notFound }}
</template>
</gl-table>
<gl-button :href="createUrl" category="primary" variant="info">
- {{ __('Create service account') }}
+ {{ $options.i18n.createServiceAccount }}
</gl-button>
+
+ <gl-alert class="gl-mt-5" :dismissible="false" variant="tip">
+ <gl-sprintf :message="$options.i18n.secretManagersDescription">
+ <template #docLink="{ content }">
+ <gl-link href="https://docs.gitlab.com/ee/ci/secrets/">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/google_tag_manager/index.js b/app/assets/javascripts/google_tag_manager/index.js
index ab80e15c2ec..55987ce64e6 100644
--- a/app/assets/javascripts/google_tag_manager/index.js
+++ b/app/assets/javascripts/google_tag_manager/index.js
@@ -1,5 +1,43 @@
+import { v4 as uuidv4 } from 'uuid';
import { logError } from '~/lib/logger';
+const SKU_PREMIUM = '2c92a00d76f0d5060176f2fb0a5029ff';
+const SKU_ULTIMATE = '2c92a0ff76f0d5250176f2f8c86f305a';
+const PRODUCT_INFO = {
+ [SKU_PREMIUM]: {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ name: 'Premium',
+ id: '0002',
+ price: '228',
+ variant: 'SaaS',
+ },
+ [SKU_ULTIMATE]: {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ name: 'Ultimate',
+ id: '0001',
+ price: '1188',
+ variant: 'SaaS',
+ },
+};
+
+const generateProductInfo = (sku, quantity) => {
+ const product = PRODUCT_INFO[sku];
+
+ if (!product) {
+ logError('Unexpected product sku provided to generateProductInfo');
+ return {};
+ }
+
+ const productInfo = {
+ ...product,
+ brand: 'GitLab',
+ category: 'DevOps',
+ quantity,
+ };
+
+ return productInfo;
+};
+
const isSupported = () => Boolean(window.dataLayer) && gon.features?.gitlabGtmDatalayer;
const pushEvent = (event, args = {}) => {
@@ -17,6 +55,22 @@ const pushEvent = (event, args = {}) => {
}
};
+const pushEnhancedEcommerceEvent = (event, args = {}) => {
+ if (!window.dataLayer) {
+ return;
+ }
+
+ try {
+ window.dataLayer.push({ ecommerce: null }); // Clear the previous ecommerce object
+ window.dataLayer.push({
+ event,
+ ...args,
+ });
+ } catch (e) {
+ logError('Unexpected error while pushing to dataLayer', e);
+ }
+};
+
const pushAccountSubmit = (accountType, accountMethod) =>
pushEvent('accountSubmit', { accountType, accountMethod });
@@ -120,3 +174,60 @@ export const trackSaasTrialGetStarted = () => {
pushEvent('saasTrialGetStarted');
});
};
+
+export const trackCheckout = (selectedPlan, quantity) => {
+ if (!isSupported()) {
+ return;
+ }
+
+ const product = generateProductInfo(selectedPlan, quantity);
+
+ if (Object.keys(product).length === 0) {
+ return;
+ }
+
+ const eventData = {
+ ecommerce: {
+ currencyCode: 'USD',
+ checkout: {
+ actionField: { step: 1 },
+ products: [product],
+ },
+ },
+ };
+
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ pushEnhancedEcommerceEvent('EECCheckout', eventData);
+};
+
+export const trackTransaction = (transactionDetails) => {
+ if (!isSupported()) {
+ return;
+ }
+
+ const transactionId = uuidv4();
+ const { paymentOption, revenue, tax, selectedPlan, quantity } = transactionDetails;
+ const product = generateProductInfo(selectedPlan, quantity);
+
+ if (Object.keys(product).length === 0) {
+ return;
+ }
+
+ const eventData = {
+ ecommerce: {
+ currencyCode: 'USD',
+ purchase: {
+ actionField: {
+ id: transactionId,
+ affiliation: 'GitLab',
+ option: paymentOption,
+ revenue: revenue.toString(),
+ tax: tax.toString(),
+ },
+ products: [product],
+ },
+ },
+ };
+
+ pushEnhancedEcommerceEvent('EECtransactionSuccess', eventData);
+};
diff --git a/app/assets/javascripts/graphql_shared/fragment_types/vulnerability_location_types.js b/app/assets/javascripts/graphql_shared/fragment_types/vulnerability_location_types.js
deleted file mode 100644
index 30888e20a46..00000000000
--- a/app/assets/javascripts/graphql_shared/fragment_types/vulnerability_location_types.js
+++ /dev/null
@@ -1,17 +0,0 @@
-export const vulnerabilityLocationTypes = {
- __schema: {
- types: [
- {
- kind: 'UNION',
- name: 'VulnerabilityLocation',
- possibleTypes: [
- { name: 'VulnerabilityLocationContainerScanning' },
- { name: 'VulnerabilityLocationDast' },
- { name: 'VulnerabilityLocationDependencyScanning' },
- { name: 'VulnerabilityLocationSast' },
- { name: 'VulnerabilityLocationSecretDetection' },
- ],
- },
- ],
- },
-};
diff --git a/app/assets/javascripts/graphql_shared/possibleTypes.json b/app/assets/javascripts/graphql_shared/possibleTypes.json
new file mode 100644
index 00000000000..9a24d2a3afc
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/possibleTypes.json
@@ -0,0 +1 @@
+{"AlertManagementIntegration":["AlertManagementHttpIntegration","AlertManagementPrometheusIntegration"],"CurrentUserTodos":["BoardEpic","Design","Epic","EpicIssue","Issue","MergeRequest"],"DependencyLinkMetadata":["NugetDependencyLinkMetadata"],"DesignFields":["Design","DesignAtVersion"],"Entry":["Blob","Submodule","TreeEntry"],"Eventable":["BoardEpic","Epic"],"Issuable":["Epic","Issue","MergeRequest"],"JobNeedUnion":["CiBuildNeed","CiJob"],"MemberInterface":["GroupMember","ProjectMember"],"NoteableInterface":["AlertManagementAlert","BoardEpic","Design","Epic","EpicIssue","Issue","MergeRequest","Snippet","Vulnerability"],"NoteableType":["Design","Issue","MergeRequest"],"OrchestrationPolicy":["ScanExecutionPolicy","ScanResultPolicy"],"PackageFileMetadata":["ConanFileMetadata","HelmFileMetadata"],"PackageMetadata":["ComposerMetadata","ConanMetadata","MavenMetadata","NugetMetadata","PypiMetadata"],"ResolvableInterface":["Discussion","Note"],"Service":["BaseService","JiraService"],"TimeboxReportInterface":["Iteration","Milestone"],"User":["MergeRequestAssignee","MergeRequestReviewer","UserCore"],"VulnerabilityDetail":["VulnerabilityDetailBase","VulnerabilityDetailBoolean","VulnerabilityDetailCode","VulnerabilityDetailCommit","VulnerabilityDetailDiff","VulnerabilityDetailFileLocation","VulnerabilityDetailInt","VulnerabilityDetailList","VulnerabilityDetailMarkdown","VulnerabilityDetailModuleLocation","VulnerabilityDetailTable","VulnerabilityDetailText","VulnerabilityDetailUrl"],"VulnerabilityLocation":["VulnerabilityLocationClusterImageScanning","VulnerabilityLocationContainerScanning","VulnerabilityLocationCoverageFuzzing","VulnerabilityLocationDast","VulnerabilityLocationDependencyScanning","VulnerabilityLocationGeneric","VulnerabilityLocationSast","VulnerabilityLocationSecretDetection"]}
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
index a1ec5942d64..e3147065d5c 100644
--- a/app/assets/javascripts/groups/components/app.vue
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -41,6 +41,7 @@ export default {
},
data() {
return {
+ isModalVisible: false,
isLoading: true,
isSearchEmpty: false,
searchEmptyMessage: '',
@@ -101,6 +102,12 @@ export default {
eventHub.$off(`${this.action}updateGroups`, this.updateGroups);
},
methods: {
+ hideModal() {
+ this.isModalVisible = false;
+ },
+ showModal() {
+ this.isModalVisible = true;
+ },
fetchGroups({ parentId, page, filterGroupsBy, sortBy, archived, updatePagination }) {
return this.service
.getGroups(parentId, page, filterGroupsBy, sortBy, archived)
@@ -185,6 +192,7 @@ export default {
showLeaveGroupModal(group, parentGroup) {
this.targetGroup = group;
this.targetParentGroup = parentGroup;
+ this.showModal();
},
leaveGroup() {
this.targetGroup.isBeingRemoved = true;
@@ -256,10 +264,12 @@ export default {
/>
<gl-modal
modal-id="leave-group-modal"
+ :visible="isModalVisible"
:title="__('Are you sure?')"
:action-primary="primaryProps"
:action-cancel="cancelProps"
@primary="leaveGroup"
+ @hide="hideModal"
>
{{ groupLeaveConfirmationMessage }}
</gl-modal>
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 10c45abbfa2..707008ec493 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -34,8 +34,8 @@ export default {
),
itemCaret,
itemTypeIcon,
- itemStats,
itemActions,
+ itemStats,
},
props: {
parentGroup: {
@@ -92,6 +92,9 @@ export default {
complianceFramework() {
return this.group.complianceFramework;
},
+ showActionsMenu() {
+ return this.isGroup && (this.group.canEdit || this.group.canRemove || this.group.canLeave);
+ },
},
methods: {
onClickRowGroup(e) {
@@ -197,17 +200,19 @@ export default {
<div v-if="isGroupPendingRemoval">
<gl-badge variant="warning">{{ __('pending deletion') }}</gl-badge>
</div>
- <div class="metadata d-flex flex-grow-1 flex-shrink-0 flex-wrap justify-content-md-between">
+ <div
+ class="metadata gl-display-flex gl-flex-grow-1 gl-flex-shrink-0 gl-flex-wrap justify-content-md-between"
+ >
+ <item-stats
+ :item="group"
+ class="group-stats gl-mt-2 gl-display-none gl-md-display-flex gl-align-items-center"
+ />
<item-actions
- v-if="isGroup"
+ v-if="showActionsMenu"
:group="group"
:parent-group="parentGroup"
:action="action"
/>
- <item-stats
- :item="group"
- class="group-stats gl-mt-2 d-none d-md-flex gl-align-items-center"
- />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/groups/components/invite_members_banner.vue b/app/assets/javascripts/groups/components/invite_members_banner.vue
index dfc1549fb4a..7afea815197 100644
--- a/app/assets/javascripts/groups/components/invite_members_banner.vue
+++ b/app/assets/javascripts/groups/components/invite_members_banner.vue
@@ -46,7 +46,6 @@ export default {
},
openModal() {
eventHub.$emit('openModal', {
- inviteeType: 'members',
source: this.$options.openModalSource,
});
this.track(this.$options.buttonClickEvent);
diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue
index df751a3f37e..fc7cfffc22c 100644
--- a/app/assets/javascripts/groups/components/item_actions.vue
+++ b/app/assets/javascripts/groups/components/item_actions.vue
@@ -1,15 +1,17 @@
<script>
-import { GlTooltipDirective, GlButton, GlModalDirective } from '@gitlab/ui';
+import { GlTooltipDirective, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { COMMON_STR } from '../constants';
import eventHub from '../event_hub';
+const { LEAVE_BTN_TITLE, EDIT_BTN_TITLE, REMOVE_BTN_TITLE, OPTIONS_DROPDOWN_TITLE } = COMMON_STR;
+
export default {
components: {
- GlButton,
+ GlDropdown,
+ GlDropdownItem,
},
directives: {
GlTooltip: GlTooltipDirective,
- GlModal: GlModalDirective,
},
props: {
parentGroup: {
@@ -28,11 +30,8 @@ export default {
},
},
computed: {
- leaveBtnTitle() {
- return COMMON_STR.LEAVE_BTN_TITLE;
- },
- editBtnTitle() {
- return COMMON_STR.EDIT_BTN_TITLE;
+ removeButtonHref() {
+ return `${this.group.editPath}#js-remove-group-form`;
},
},
methods: {
@@ -40,33 +39,51 @@ export default {
eventHub.$emit(`${this.action}showLeaveGroupModal`, this.group, this.parentGroup);
},
},
+ i18n: {
+ leaveBtnTitle: LEAVE_BTN_TITLE,
+ editBtnTitle: EDIT_BTN_TITLE,
+ removeBtnTitle: REMOVE_BTN_TITLE,
+ optionsDropdownTitle: OPTIONS_DROPDOWN_TITLE,
+ },
};
</script>
<template>
- <div class="controls d-flex justify-content-end">
- <gl-button
- v-if="group.canLeave"
- v-gl-tooltip.top
- v-gl-modal.leave-group-modal
- :title="leaveBtnTitle"
- :aria-label="leaveBtnTitle"
- data-testid="leave-group-btn"
- size="small"
- icon="leave"
- class="leave-group gl-ml-3"
- @click.stop="onLeaveGroup"
- />
- <gl-button
- v-if="group.canEdit"
- v-gl-tooltip.top
- :href="group.editPath"
- :title="editBtnTitle"
- :aria-label="editBtnTitle"
- data-testid="edit-group-btn"
- size="small"
- icon="pencil"
- class="edit-group gl-ml-3"
- />
+ <div class="gl-display-flex gl-justify-content-end gl-ml-5">
+ <gl-dropdown
+ v-gl-tooltip.hover.focus="$options.i18n.optionsDropdownTitle"
+ right
+ category="tertiary"
+ icon="ellipsis_v"
+ no-caret
+ :data-testid="`group-${group.id}-dropdown-button`"
+ data-qa-selector="group_dropdown_button"
+ :data-qa-group-id="group.id"
+ >
+ <gl-dropdown-item
+ v-if="group.canEdit"
+ :data-testid="`edit-group-${group.id}-btn`"
+ :href="group.editPath"
+ @click.stop
+ >
+ {{ $options.i18n.editBtnTitle }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="group.canLeave"
+ :data-testid="`leave-group-${group.id}-btn`"
+ @click.stop="onLeaveGroup"
+ >
+ {{ $options.i18n.leaveBtnTitle }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="group.canRemove"
+ :href="removeButtonHref"
+ :data-testid="`remove-group-${group.id}-btn`"
+ variant="danger"
+ @click.stop
+ >
+ {{ $options.i18n.removeBtnTitle }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
</template>
diff --git a/app/assets/javascripts/groups/components/transfer_group_form.vue b/app/assets/javascripts/groups/components/transfer_group_form.vue
new file mode 100644
index 00000000000..e848f10352d
--- /dev/null
+++ b/app/assets/javascripts/groups/components/transfer_group_form.vue
@@ -0,0 +1,80 @@
+<script>
+import { GlFormGroup } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue';
+import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select.vue';
+
+export const i18n = {
+ confirmationMessage: __(
+ 'You are going to transfer %{group_name} to another namespace. Are you ABSOLUTELY sure?',
+ ),
+ emptyNamespaceTitle: __('No parent group'),
+ dropdownTitle: s__('GroupSettings|Select parent group'),
+};
+
+export default {
+ name: 'TransferGroupForm',
+ components: {
+ ConfirmDanger,
+ GlFormGroup,
+ NamespaceSelect,
+ },
+ props: {
+ groupNamespaces: {
+ type: Array,
+ required: true,
+ },
+ isPaidGroup: {
+ type: Boolean,
+ required: true,
+ },
+ confirmationPhrase: {
+ type: String,
+ required: true,
+ },
+ confirmButtonText: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ selectedId: null,
+ };
+ },
+ computed: {
+ disableSubmitButton() {
+ return this.isPaidGroup || !this.selectedId;
+ },
+ },
+ methods: {
+ handleSelected({ id }) {
+ this.selectedId = id;
+ },
+ },
+ i18n,
+};
+</script>
+<template>
+ <div>
+ <gl-form-group v-if="!isPaidGroup">
+ <namespace-select
+ :default-text="$options.i18n.dropdownTitle"
+ :group-namespaces="groupNamespaces"
+ :empty-namespace-title="$options.i18n.emptyNamespaceTitle"
+ :include-headers="false"
+ include-empty-namespace
+ data-testid="transfer-group-namespace-select"
+ @select="handleSelected"
+ />
+ <input type="hidden" name="new_parent_group_id" :value="selectedId" />
+ </gl-form-group>
+ <confirm-danger
+ button-class="qa-transfer-button"
+ :disabled="disableSubmitButton"
+ :phrase="confirmationPhrase"
+ :button-text="confirmButtonText"
+ @confirm="$emit('confirm')"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js
index e2722d780dc..005bac1e7b5 100644
--- a/app/assets/javascripts/groups/constants.js
+++ b/app/assets/javascripts/groups/constants.js
@@ -15,8 +15,10 @@ export const COMMON_STR = {
LEAVE_FORBIDDEN: s__(
'GroupsTree|Failed to leave the group. Please make sure you are not the only owner.',
),
- LEAVE_BTN_TITLE: s__('GroupsTree|Leave this group'),
- EDIT_BTN_TITLE: s__('GroupsTree|Edit group'),
+ LEAVE_BTN_TITLE: s__('GroupsTree|Leave group'),
+ EDIT_BTN_TITLE: s__('GroupsTree|Edit'),
+ REMOVE_BTN_TITLE: s__('GroupsTree|Delete'),
+ OPTIONS_DROPDOWN_TITLE: s__('GroupsTree|Options'),
GROUP_SEARCH_EMPTY: s__('GroupsTree|No groups matched your search'),
GROUP_PROJECT_SEARCH_EMPTY: s__('GroupsTree|No groups or projects matched your search'),
};
diff --git a/app/assets/javascripts/groups/init_transfer_group_form.js b/app/assets/javascripts/groups/init_transfer_group_form.js
new file mode 100644
index 00000000000..f055b926918
--- /dev/null
+++ b/app/assets/javascripts/groups/init_transfer_group_form.js
@@ -0,0 +1,52 @@
+import Vue from 'vue';
+import { sprintf } from '~/locale';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import TransferGroupForm, { i18n } from './components/transfer_group_form.vue';
+
+const prepareGroups = (rawGroups) => {
+ if (!rawGroups) {
+ return [];
+ }
+
+ return JSON.parse(rawGroups).map(({ id, text: humanName }) => ({
+ id,
+ humanName,
+ }));
+};
+
+export default () => {
+ const el = document.querySelector('.js-transfer-group-form');
+ if (!el) {
+ return false;
+ }
+
+ const {
+ targetFormId = null,
+ buttonText: confirmButtonText = '',
+ groupName = '',
+ parentGroups,
+ isPaidGroup,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ provide: {
+ confirmDangerMessage: sprintf(i18n.confirmationMessage, { group_name: groupName }),
+ },
+ render(createElement) {
+ return createElement(TransferGroupForm, {
+ props: {
+ groupNamespaces: prepareGroups(parentGroups),
+ isPaidGroup: parseBoolean(isPaidGroup),
+ confirmButtonText,
+ confirmationPhrase: groupName,
+ },
+ on: {
+ confirm: () => {
+ document.getElementById(targetFormId)?.submit();
+ },
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/groups/landing.js b/app/assets/javascripts/groups/landing.js
index bfb4d9ce67b..ed76bebf843 100644
--- a/app/assets/javascripts/groups/landing.js
+++ b/app/assets/javascripts/groups/landing.js
@@ -1,5 +1,4 @@
-import Cookies from 'js-cookie';
-import { parseBoolean } from '~/lib/utils/common_utils';
+import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils';
class Landing {
constructor(landingElement, dismissButton, cookieName) {
@@ -27,11 +26,11 @@ class Landing {
dismissLanding() {
this.landingElement.classList.add('hidden');
- Cookies.set(this.cookieName, 'true', { expires: 365 });
+ setCookie(this.cookieName, 'true');
}
isDismissed() {
- return parseBoolean(Cookies.get(this.cookieName));
+ return parseBoolean(getCookie(this.cookieName));
}
}
diff --git a/app/assets/javascripts/groups/store/groups_store.js b/app/assets/javascripts/groups/store/groups_store.js
index d3600bd223a..0917b9ceccf 100644
--- a/app/assets/javascripts/groups/store/groups_store.js
+++ b/app/assets/javascripts/groups/store/groups_store.js
@@ -83,6 +83,7 @@ export default class GroupsStore {
leavePath: rawGroupItem.leave_path,
canEdit: rawGroupItem.can_edit,
canLeave: rawGroupItem.can_leave,
+ canRemove: rawGroupItem.can_remove,
type: rawGroupItem.type,
permission: rawGroupItem.permission,
children: groupChildren,
diff --git a/app/assets/javascripts/groups/transfer_dropdown.js b/app/assets/javascripts/groups/transfer_dropdown.js
deleted file mode 100644
index d6343f698c0..00000000000
--- a/app/assets/javascripts/groups/transfer_dropdown.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import $ from 'jquery';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { __ } from '~/locale';
-
-export default class TransferDropdown {
- constructor() {
- this.groupDropdown = $('.js-groups-dropdown');
- this.parentInput = $('#new_parent_group_id');
- this.data = this.groupDropdown.data('data');
- this.init();
- }
-
- init() {
- this.buildDropdown();
- }
-
- buildDropdown() {
- const extraOptions = [{ id: '-1', text: __('No parent group') }, { type: 'divider' }];
-
- initDeprecatedJQueryDropdown(this.groupDropdown, {
- selectable: true,
- filterable: true,
- toggleLabel: (item) => item.text,
- search: { fields: ['text'] },
- data: extraOptions.concat(this.data),
- text: (item) => item.text,
- clicked: (options) => {
- const { e } = options;
- e.preventDefault();
- this.assignSelected(options.selectedObj);
- },
- });
- }
-
- assignSelected(selected) {
- this.parentInput.val(selected.id);
- this.parentInput.change();
- }
-}
diff --git a/app/assets/javascripts/groups/transfer_edit.js b/app/assets/javascripts/groups/transfer_edit.js
deleted file mode 100644
index bb15e11fd4c..00000000000
--- a/app/assets/javascripts/groups/transfer_edit.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import $ from 'jquery';
-
-export default function setupTransferEdit(formSelector, targetSelector) {
- const $transferForm = $(formSelector);
- const $selectNamespace = $transferForm.find(targetSelector);
-
- $selectNamespace.on('change', () => {
- $transferForm.find(':submit').prop('disabled', !$selectNamespace.val());
- });
- $selectNamespace.trigger('change');
-}
diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js
index bd71c5ebc11..64bba91eb4d 100644
--- a/app/assets/javascripts/groups_select.js
+++ b/app/assets/javascripts/groups_select.js
@@ -28,6 +28,7 @@ const groupsSelect = () => {
const skipGroups = $select.data('skipGroups') || [];
const parentGroupID = $select.data('parentId');
const groupsFilter = $select.data('groupsFilter');
+ const minAccessLevel = $select.data('minAccessLevel');
$select.select2({
placeholder: __('Search for a group'),
@@ -45,6 +46,7 @@ const groupsSelect = () => {
page,
per_page: window.GROUP_SELECT_PER_PAGE,
all_available: allAvailable,
+ min_access_level: minAccessLevel,
};
},
results(data, page) {
diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue
index 846b4d92724..92dacf8c94a 100644
--- a/app/assets/javascripts/ide/components/activity_bar.vue
+++ b/app/assets/javascripts/ide/components/activity_bar.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective, GlBadge } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { leftSidebarViews } from '../constants';
@@ -7,6 +7,7 @@ import { leftSidebarViews } from '../constants';
export default {
components: {
GlIcon,
+ GlBadge,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -82,9 +83,13 @@ export default {
@click.prevent="changedActivityView($event, $options.leftSidebarViews.commit.name)"
>
<gl-icon name="commit" />
- <div v-if="stagedFiles.length > 0" class="ide-commit-badge badge badge-pill">
+ <gl-badge
+ v-if="stagedFiles.length"
+ class="gl-absolute gl-px-2 gl-top-3 gl-right-3 gl-font-weight-bold gl-bg-gray-900! gl-text-white!"
+ size="sm"
+ >
{{ stagedFiles.length }}
- </div>
+ </gl-badge>
</button>
</li>
</ul>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
index 9ec4a07a3d0..44f543d9a76 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
@@ -187,7 +187,7 @@ export default {
class="qa-commit-button"
category="primary"
variant="confirm"
- @click="commit"
+ type="submit"
>
{{ __('Commit') }}
</gl-button>
diff --git a/app/assets/javascripts/ide/components/preview/clientside.vue b/app/assets/javascripts/ide/components/preview/clientside.vue
index 13f2e775fc3..b1f6f2c87b9 100644
--- a/app/assets/javascripts/ide/components/preview/clientside.vue
+++ b/app/assets/javascripts/ide/components/preview/clientside.vue
@@ -4,7 +4,12 @@ import { listen } from 'codesandbox-api';
import { isEmpty, debounce } from 'lodash';
import { Manager } from 'smooshpack';
import { mapActions, mapGetters, mapState } from 'vuex';
-import { packageJsonPath, LIVE_PREVIEW_DEBOUNCE } from '../../constants';
+import {
+ packageJsonPath,
+ LIVE_PREVIEW_DEBOUNCE,
+ PING_USAGE_PREVIEW_KEY,
+ PING_USAGE_PREVIEW_SUCCESS_KEY,
+} from '../../constants';
import eventHub from '../../eventhub';
import { createPathWithExt } from '../../utils';
import Navigator from './navigator.vue';
@@ -62,6 +67,15 @@ export default {
};
},
},
+ watch: {
+ sandpackReady: {
+ handler(val) {
+ if (val) {
+ this.pingUsage(PING_USAGE_PREVIEW_SUCCESS_KEY);
+ }
+ },
+ },
+ },
mounted() {
this.onFilesChangeCallback = debounce(() => this.update(), LIVE_PREVIEW_DEBOUNCE);
eventHub.$on('ide.files.change', this.onFilesChangeCallback);
@@ -101,7 +115,7 @@ export default {
initPreview() {
if (!this.mainEntry) return null;
- this.pingUsage();
+ this.pingUsage(PING_USAGE_PREVIEW_KEY);
return this.loadFileContent(this.mainEntry)
.then(() => this.$nextTick())
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index 775b6906498..bfe4c3ac271 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -114,3 +114,7 @@ export const LIVE_PREVIEW_DEBOUNCE = 2000;
export const MAX_MR_FILES_AUTO_OPEN = 10;
export const DEFAULT_BRANCH = 'main';
+
+// Ping Usage Metrics Keys
+export const PING_USAGE_PREVIEW_KEY = 'web_ide_clientside_preview';
+export const PING_USAGE_PREVIEW_SUCCESS_KEY = 'web_ide_clientside_preview_success';
diff --git a/app/assets/javascripts/ide/stores/modules/clientside/actions.js b/app/assets/javascripts/ide/stores/modules/clientside/actions.js
index e36419cd7eb..1a8e665867f 100644
--- a/app/assets/javascripts/ide/stores/modules/clientside/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/clientside/actions.js
@@ -1,9 +1,9 @@
import axios from '~/lib/utils/axios_utils';
-export const pingUsage = ({ rootGetters }) => {
+export const pingUsage = ({ rootGetters }, metricName) => {
const { web_url: projectUrl } = rootGetters.currentProject;
- const url = `${projectUrl}/service_ping/web_ide_clientside_preview`;
+ const url = `${projectUrl}/service_ping/${metricName}`;
return axios.post(url);
};
diff --git a/app/assets/javascripts/image_diff/helpers/badge_helper.js b/app/assets/javascripts/image_diff/helpers/badge_helper.js
index 8ee72235a23..5ff00394e3b 100644
--- a/app/assets/javascripts/image_diff/helpers/badge_helper.js
+++ b/app/assets/javascripts/image_diff/helpers/badge_helper.js
@@ -14,7 +14,15 @@ export function createImageBadge(noteId, { x, y }, classNames = []) {
}
export function addImageBadge(containerEl, { coordinate, badgeText, noteId }) {
- const buttonEl = createImageBadge(noteId, coordinate, ['badge', 'badge-pill']);
+ const buttonEl = createImageBadge(noteId, coordinate, [
+ 'gl-display-flex',
+ 'gl-align-items-center',
+ 'gl-justify-content-center',
+ 'gl-font-sm',
+ 'design-note-pin',
+ 'on-image',
+ 'gl-absolute',
+ ]);
buttonEl.textContent = badgeText;
containerEl.appendChild(buttonEl);
@@ -30,8 +38,8 @@ export function addImageCommentBadge(containerEl, { coordinate, noteId }) {
export function addAvatarBadge(el, event) {
const { noteId, badgeNumber } = event.detail;
- // Add badge to new comment
- const avatarBadgeEl = el.querySelector(`#${noteId} .badge`);
+ // Add design pin to new comment
+ const avatarBadgeEl = el.querySelector(`#${noteId} .design-note-pin`);
avatarBadgeEl.textContent = badgeNumber;
avatarBadgeEl.classList.remove('hidden');
}
diff --git a/app/assets/javascripts/image_diff/helpers/dom_helper.js b/app/assets/javascripts/image_diff/helpers/dom_helper.js
index a61e5f01f9b..3468a629f5a 100644
--- a/app/assets/javascripts/image_diff/helpers/dom_helper.js
+++ b/app/assets/javascripts/image_diff/helpers/dom_helper.js
@@ -10,12 +10,12 @@ export function setPositionDataAttribute(el, options) {
}
export function updateDiscussionAvatarBadgeNumber(discussionEl, newBadgeNumber) {
- const avatarBadgeEl = discussionEl.querySelector('.image-diff-avatar-link .badge');
+ const avatarBadgeEl = discussionEl.querySelector('.image-diff-avatar-link .design-note-pin');
avatarBadgeEl.textContent = newBadgeNumber;
}
export function updateDiscussionBadgeNumber(discussionEl, newBadgeNumber) {
- const discussionBadgeEl = discussionEl.querySelector('.badge');
+ const discussionBadgeEl = discussionEl.querySelector('.design-note-pin');
discussionBadgeEl.textContent = newBadgeNumber;
}
diff --git a/app/assets/javascripts/image_diff/image_diff.js b/app/assets/javascripts/image_diff/image_diff.js
index a0dd8e6f894..e3ca4327efe 100644
--- a/app/assets/javascripts/image_diff/image_diff.js
+++ b/app/assets/javascripts/image_diff/image_diff.js
@@ -118,7 +118,7 @@ export default class ImageDiff {
removeBadge(event) {
const { badgeNumber } = event.detail;
const indexToRemove = badgeNumber - 1;
- const imageBadgeEls = this.imageFrameEl.querySelectorAll('.badge');
+ const imageBadgeEls = this.imageFrameEl.querySelectorAll('.design-note-pin');
if (this.imageBadges.length !== badgeNumber) {
// Cascade badges count numbers for (avatar badges + image badges)
diff --git a/app/assets/javascripts/image_diff/replaced_image_diff.js b/app/assets/javascripts/image_diff/replaced_image_diff.js
index a3d9b8a138a..8b84cc45c21 100644
--- a/app/assets/javascripts/image_diff/replaced_image_diff.js
+++ b/app/assets/javascripts/image_diff/replaced_image_diff.js
@@ -61,7 +61,7 @@ export default class ReplacedImageDiff extends ImageDiff {
this.currentView = newView;
// Clear existing badges on new view
- const existingBadges = this.imageFrameEl.querySelectorAll('.badge');
+ const existingBadges = this.imageFrameEl.querySelectorAll('.design-note-pin');
[...existingBadges].map((badge) => badge.remove());
// Remove existing references to old view image badges
diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue
index 37597da3c8e..7a904bdb6ad 100644
--- a/app/assets/javascripts/incidents/components/incidents_list.vue
+++ b/app/assets/javascripts/incidents/components/incidents_list.vue
@@ -12,7 +12,7 @@ import {
} from '@gitlab/ui';
import { isValidSlaDueAt } from 'ee_else_ce/vue_shared/components/incidents/utils';
import { visitUrl, mergeUrlParams, joinPaths } from '~/lib/utils/url_utility';
-import { s__ } from '~/locale';
+import { s__, n__ } from '~/locale';
import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants';
import SeverityToken from '~/sidebar/components/severity/severity.vue';
import Tracking from '~/tracking';
@@ -38,6 +38,8 @@ import {
import getIncidentsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql';
import getIncidents from '../graphql/queries/get_incidents.query.graphql';
+const MAX_VISIBLE_ASSIGNEES = 4;
+
export default {
trackIncidentCreateNewOptions,
trackIncidentListViewsOptions,
@@ -94,6 +96,7 @@ export default {
thAttr: TH_PUBLISHED_TEST_ID,
},
],
+ MAX_VISIBLE_ASSIGNEES,
components: {
GlLoadingIcon,
GlTable,
@@ -295,6 +298,13 @@ export default {
errorAlertDismissed() {
this.isErrorAlertDismissed = true;
},
+ assigneesBadgeSrOnlyText(item) {
+ return n__(
+ '%d additional assignee',
+ '%d additional assignees',
+ item.assignees.nodes.length - MAX_VISIBLE_ASSIGNEES,
+ );
+ },
isValidSlaDueAt,
},
};
@@ -391,10 +401,11 @@ export default {
<gl-avatars-inline
:avatars="item.assignees.nodes"
:collapsed="true"
- :max-visible="4"
+ :max-visible="$options.MAX_VISIBLE_ASSIGNEES"
:avatar-size="24"
badge-tooltip-prop="name"
:badge-tooltip-max-chars="100"
+ :badge-sr-only-text="assigneesBadgeSrOnlyText(item)"
>
<template #avatar="{ avatar }">
<gl-avatar-link
diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js
index b90658fb13c..004601bc0a3 100644
--- a/app/assets/javascripts/integrations/constants.js
+++ b/app/assets/javascripts/integrations/constants.js
@@ -1,7 +1,5 @@
import { s__, __ } from '~/locale';
-export const VALIDATE_INTEGRATION_FORM_EVENT = 'validateIntegrationForm';
-
export const integrationLevels = {
GROUP: 'group',
INSTANCE: 'instance',
@@ -26,5 +24,3 @@ export const I18N_SUCCESSFUL_CONNECTION_MESSAGE = s__('Integrations|Connection s
export const settingsTabTitle = __('Settings');
export const overridesTabTitle = s__('Integrations|Projects using custom settings');
-
-export const INTEGRATION_FORM_SELECTOR = '.js-integration-settings-form';
diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
index 4b0579a5beb..b4ceec22822 100644
--- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
+++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
@@ -9,8 +9,6 @@ import {
} from '@gitlab/ui';
import { capitalize, lowerCase, isEmpty } from 'lodash';
import { mapGetters } from 'vuex';
-import { VALIDATE_INTEGRATION_FORM_EVENT } from '~/integrations/constants';
-import eventHub from '../event_hub';
export default {
name: 'DynamicField',
@@ -70,11 +68,15 @@ export default {
required: false,
default: null,
},
+ isValidated: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
model: this.value,
- validated: false,
};
},
computed: {
@@ -123,22 +125,13 @@ export default {
};
},
valid() {
- return !this.required || !isEmpty(this.model) || this.isNonEmptyPassword || !this.validated;
+ return !this.required || !isEmpty(this.model) || this.isNonEmptyPassword || !this.isValidated;
},
},
created() {
if (this.isNonEmptyPassword) {
this.model = null;
}
- eventHub.$on(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm);
- },
- beforeDestroy() {
- eventHub.$off(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm);
- },
- methods: {
- validateForm() {
- this.validated = true;
- },
},
helpHtmlConfig: {
ADD_ATTR: ['target'], // allow external links, can be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1427 is implemented
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index c3cc35adfa5..007a384f41e 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -5,16 +5,13 @@ import * as Sentry from '@sentry/browser';
import { mapState, mapActions, mapGetters } from 'vuex';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
- VALIDATE_INTEGRATION_FORM_EVENT,
I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE,
I18N_DEFAULT_ERROR_MESSAGE,
I18N_SUCCESSFUL_CONNECTION_MESSAGE,
- INTEGRATION_FORM_SELECTOR,
integrationLevels,
} from '~/integrations/constants';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
import csrf from '~/lib/utils/csrf';
-import eventHub from '../event_hub';
import { testIntegrationSettings } from '../api';
import ActiveCheckbox from './active_checkbox.vue';
import ConfirmationModal from './confirmation_modal.vue';
@@ -57,6 +54,7 @@ export default {
isTesting: false,
isSaving: false,
isResetting: false,
+ isValidated: false,
};
},
computed: {
@@ -83,54 +81,38 @@ export default {
disableButtons() {
return Boolean(this.isSaving || this.isResetting || this.isTesting);
},
- useVueForm() {
- return this.glFeatures?.vueIntegrationForm;
+ form() {
+ return this.$refs.integrationForm.$el;
},
- formContainerProps() {
- return this.useVueForm
- ? {
- ref: 'integrationForm',
- method: 'post',
- class: 'gl-mb-3 gl-show-field-errors integration-settings-form',
- action: this.propsSource.formPath,
- novalidate: !this.integrationActive,
- }
- : {};
- },
- formContainer() {
- return this.useVueForm ? GlForm : 'div';
- },
- },
- mounted() {
- this.form = this.useVueForm
- ? this.$refs.integrationForm.$el
- : document.querySelector(INTEGRATION_FORM_SELECTOR);
},
methods: {
- ...mapActions(['setOverride', 'fetchResetIntegration', 'requestJiraIssueTypes']),
+ ...mapActions(['setOverride', 'requestJiraIssueTypes']),
+ setIsValidated() {
+ this.isValidated = true;
+ },
onSaveClick() {
this.isSaving = true;
if (this.integrationActive && !this.form.checkValidity()) {
this.isSaving = false;
- eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT);
+ this.setIsValidated();
return;
}
this.form.submit();
},
onTestClick() {
- this.isTesting = true;
-
if (!this.form.checkValidity()) {
- eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT);
+ this.setIsValidated();
return;
}
+ this.isTesting = true;
+
testIntegrationSettings(this.propsSource.testPath, this.getFormData())
.then(({ data: { error, message = I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE } }) => {
if (error) {
- eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT);
+ this.setIsValidated();
this.$toast.show(message);
return;
}
@@ -169,16 +151,6 @@ export default {
},
onToggleIntegrationState(integrationActive) {
this.integrationActive = integrationActive;
- if (!this.form || this.useVueForm) {
- return;
- }
-
- // If integration will be active, enable form validation.
- if (integrationActive) {
- this.form.removeAttribute('novalidate');
- } else {
- this.form.setAttribute('novalidate', true);
- }
},
},
helpHtmlConfig: {
@@ -191,17 +163,21 @@ export default {
</script>
<template>
- <component :is="formContainer" v-bind="formContainerProps">
- <template v-if="useVueForm">
- <input type="hidden" name="_method" value="put" />
- <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
- <input
- type="hidden"
- name="redirect_to"
- :value="propsSource.redirectTo"
- data-testid="redirect-to-field"
- />
- </template>
+ <gl-form
+ ref="integrationForm"
+ method="post"
+ class="gl-mb-3 gl-show-field-errors integration-settings-form"
+ :action="propsSource.formPath"
+ :novalidate="!integrationActive"
+ >
+ <input type="hidden" name="_method" value="put" />
+ <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
+ <input
+ type="hidden"
+ name="redirect_to"
+ :value="propsSource.redirectTo"
+ data-testid="redirect-to-field"
+ />
<override-dropdown
v-if="defaultState !== null"
@@ -227,6 +203,7 @@ export default {
v-if="isJira"
:key="`${currentKey}-jira-trigger-fields`"
v-bind="propsSource.triggerFieldsProps"
+ :is-validated="isValidated"
/>
<trigger-fields
v-else-if="propsSource.triggerEvents.length"
@@ -238,11 +215,13 @@ export default {
v-for="field in propsSource.fields"
:key="`${currentKey}-${field.name}`"
v-bind="field"
+ :is-validated="isValidated"
/>
<jira-issues-fields
v-if="isJira && !isInstanceOrGroupLevel"
:key="`${currentKey}-jira-issues-fields`"
v-bind="propsSource.jiraIssuesProps"
+ :is-validated="isValidated"
@request-jira-issue-types="onRequestJiraIssueTypes"
/>
@@ -311,5 +290,5 @@ export default {
</div>
</div>
</div>
- </component>
+ </gl-form>
</template>
diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
index 99498501f6c..7f2f7620a86 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
@@ -1,9 +1,7 @@
<script>
import { GlFormGroup, GlFormCheckbox, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui';
import { mapGetters } from 'vuex';
-import { VALIDATE_INTEGRATION_FORM_EVENT } from '~/integrations/constants';
import { s__, __ } from '~/locale';
-import eventHub from '../event_hub';
import JiraUpgradeCta from './jira_upgrade_cta.vue';
export default {
@@ -64,29 +62,22 @@ export default {
required: false,
default: '',
},
+ isValidated: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
enableJiraIssues: this.initialEnableJiraIssues,
projectKey: this.initialProjectKey,
- validated: false,
};
},
computed: {
...mapGetters(['isInheriting']),
validProjectKey() {
- return !this.enableJiraIssues || Boolean(this.projectKey) || !this.validated;
- },
- },
- created() {
- eventHub.$on(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm);
- },
- beforeDestroy() {
- eventHub.$off(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm);
- },
- methods: {
- validateForm() {
- this.validated = true;
+ return !this.enableJiraIssues || Boolean(this.projectKey) || !this.isValidated;
},
},
i18n: {
diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
index 249a3e105b1..df5946b814a 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
@@ -9,9 +9,7 @@ import {
} from '@gitlab/ui';
import { mapGetters } from 'vuex';
import { helpPagePath } from '~/helpers/help_page_helper';
-import { VALIDATE_INTEGRATION_FORM_EVENT } from '~/integrations/constants';
import { s__ } from '~/locale';
-import eventHub from '../event_hub';
const commentDetailOptions = [
{
@@ -92,10 +90,14 @@ export default {
required: false,
default: '',
},
+ isValidated: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
- validated: false,
triggerCommit: this.initialTriggerCommit,
triggerMergeRequest: this.initialTriggerMergeRequest,
enableComments: this.initialEnableComments,
@@ -115,19 +117,10 @@ export default {
return this.triggerCommit || this.triggerMergeRequest;
},
validIssueTransitionId() {
- return !this.validated || Boolean(this.jiraIssueTransitionId);
+ return !this.isValidated || Boolean(this.jiraIssueTransitionId);
},
},
- created() {
- eventHub.$on(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm);
- },
- beforeDestroy() {
- eventHub.$off(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm);
- },
methods: {
- validateForm() {
- this.validated = true;
- },
showCustomIssueTransitions(currentOption) {
return (
this.jiraIssueTransitionAutomatic === ISSUE_TRANSITION_CUSTOM &&
diff --git a/app/assets/javascripts/integrations/edit/event_hub.js b/app/assets/javascripts/integrations/edit/event_hub.js
deleted file mode 100644
index e31806ad199..00000000000
--- a/app/assets/javascripts/integrations/edit/event_hub.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import createEventHub from '~/helpers/event_hub_factory';
-
-export default createEventHub();
diff --git a/app/assets/javascripts/integrations/edit/store/actions.js b/app/assets/javascripts/integrations/edit/store/actions.js
index 1398b710d1d..d31d3eb9d82 100644
--- a/app/assets/javascripts/integrations/edit/store/actions.js
+++ b/app/assets/javascripts/integrations/edit/store/actions.js
@@ -1,10 +1,8 @@
import {
- VALIDATE_INTEGRATION_FORM_EVENT,
I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE,
I18N_DEFAULT_ERROR_MESSAGE,
} from '~/integrations/constants';
import { testIntegrationSettings } from '../api';
-import eventHub from '../event_hub';
import * as types from './mutation_types';
export const setOverride = ({ commit }, override) => commit(types.SET_OVERRIDE, override);
@@ -19,7 +17,6 @@ export const requestJiraIssueTypes = ({ commit, dispatch, getters }, formData) =
data: { issuetypes, error, message = I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE },
}) => {
if (error || !issuetypes?.length) {
- eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT);
throw new Error(message);
}
diff --git a/app/assets/javascripts/integrations/edit/store/mutation_types.js b/app/assets/javascripts/integrations/edit/store/mutation_types.js
index ddf6bef7554..eb74b0b1c73 100644
--- a/app/assets/javascripts/integrations/edit/store/mutation_types.js
+++ b/app/assets/javascripts/integrations/edit/store/mutation_types.js
@@ -1,9 +1,5 @@
export const SET_OVERRIDE = 'SET_OVERRIDE';
-export const SET_IS_RESETTING = 'SET_IS_RESETTING';
export const SET_IS_LOADING_JIRA_ISSUE_TYPES = 'SET_IS_LOADING_JIRA_ISSUE_TYPES';
export const SET_JIRA_ISSUE_TYPES_ERROR_MESSAGE = 'SET_JIRA_ISSUE_TYPES_ERROR_MESSAGE';
export const SET_JIRA_ISSUE_TYPES = 'SET_JIRA_ISSUE_TYPES';
-
-export const REQUEST_RESET_INTEGRATION = 'REQUEST_RESET_INTEGRATION';
-export const RECEIVE_RESET_INTEGRATION_ERROR = 'RECEIVE_RESET_INTEGRATION_ERROR';
diff --git a/app/assets/javascripts/invite_members/components/group_select.vue b/app/assets/javascripts/invite_members/components/group_select.vue
index 216078ed35e..04a8ec3400f 100644
--- a/app/assets/javascripts/invite_members/components/group_select.vue
+++ b/app/assets/javascripts/invite_members/components/group_select.vue
@@ -24,6 +24,10 @@ export default {
prop: 'selectedGroup',
},
props: {
+ accessLevels: {
+ type: Object,
+ required: true,
+ },
groupsFilter: {
type: String,
required: false,
@@ -34,6 +38,10 @@ export default {
required: false,
default: null,
},
+ invalidGroups: {
+ type: Array,
+ required: true,
+ },
},
data() {
return {
@@ -50,6 +58,13 @@ export default {
isFetchResultEmpty() {
return this.groups.length === 0;
},
+ defaultFetchOptions() {
+ return {
+ exclude_internal: true,
+ active: true,
+ min_access_level: this.accessLevels.Guest,
+ };
+ },
},
watch: {
searchTerm() {
@@ -64,18 +79,26 @@ export default {
this.isFetching = true;
return this.fetchGroups()
.then((response) => {
- this.groups = response.map((group) => ({
- id: group.id,
- name: group.full_name,
- path: group.path,
- avatarUrl: group.avatar_url,
- }));
+ this.groups = this.processGroups(response);
this.isFetching = false;
})
.catch(() => {
this.isFetching = false;
});
}, SEARCH_DELAY),
+ processGroups(response) {
+ const rawGroups = response.map((group) => ({
+ id: group.id,
+ name: group.full_name,
+ path: group.path,
+ avatarUrl: group.avatar_url,
+ }));
+
+ return this.filterOutInvalidGroups(rawGroups);
+ },
+ filterOutInvalidGroups(groups) {
+ return groups.filter((group) => this.invalidGroups.indexOf(group.id) === -1);
+ },
selectGroup(group) {
this.selectedGroup = group;
@@ -84,13 +107,9 @@ export default {
fetchGroups() {
switch (this.groupsFilter) {
case GROUP_FILTERS.DESCENDANT_GROUPS:
- return getDescendentGroups(
- this.parentGroupId,
- this.searchTerm,
- this.$options.defaultFetchOptions,
- );
+ return getDescendentGroups(this.parentGroupId, this.searchTerm, this.defaultFetchOptions);
default:
- return getGroups(this.searchTerm, this.$options.defaultFetchOptions);
+ return getGroups(this.searchTerm, this.defaultFetchOptions);
}
},
},
@@ -99,10 +118,6 @@ export default {
searchPlaceholder: s__('GroupSelect|Search groups'),
emptySearchResult: s__('GroupSelect|No matching results'),
},
- defaultFetchOptions: {
- exclude_internal: true,
- active: true,
- },
};
</script>
<template>
diff --git a/app/assets/javascripts/invite_members/components/invite_group_trigger.vue b/app/assets/javascripts/invite_members/components/invite_group_trigger.vue
index c9de078319a..c08a4d75c59 100644
--- a/app/assets/javascripts/invite_members/components/invite_group_trigger.vue
+++ b/app/assets/javascripts/invite_members/components/invite_group_trigger.vue
@@ -21,7 +21,7 @@ export default {
},
methods: {
openModal() {
- eventHub.$emit('openModal', { inviteeType: 'group' });
+ eventHub.$emit('openGroupModal');
},
},
};
diff --git a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
new file mode 100644
index 00000000000..6598000c464
--- /dev/null
+++ b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
@@ -0,0 +1,146 @@
+<script>
+import { uniqueId } from 'lodash';
+import Api from '~/api';
+import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
+import { GROUP_FILTERS, GROUP_MODAL_LABELS } from '../constants';
+import eventHub from '../event_hub';
+import GroupSelect from './group_select.vue';
+import InviteModalBase from './invite_modal_base.vue';
+
+export default {
+ name: 'InviteMembersModal',
+ components: {
+ GroupSelect,
+ InviteModalBase,
+ },
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ isProject: {
+ type: Boolean,
+ required: true,
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ accessLevels: {
+ type: Object,
+ required: true,
+ },
+ defaultAccessLevel: {
+ type: Number,
+ required: true,
+ },
+ helpLink: {
+ type: String,
+ required: true,
+ },
+ groupSelectFilter: {
+ type: String,
+ required: false,
+ default: GROUP_FILTERS.ALL,
+ },
+ groupSelectParentId: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ invalidGroups: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ modalId: uniqueId('invite-groups-modal-'),
+ groupToBeSharedWith: {},
+ };
+ },
+ computed: {
+ labelIntroText() {
+ return this.$options.labels[this.inviteTo].introText;
+ },
+ inviteTo() {
+ return this.isProject ? 'toProject' : 'toGroup';
+ },
+ toastOptions() {
+ return {
+ onComplete: () => {
+ this.groupToBeSharedWith = {};
+ },
+ };
+ },
+ inviteDisabled() {
+ return Object.keys(this.groupToBeSharedWith).length === 0;
+ },
+ },
+ mounted() {
+ eventHub.$on('openGroupModal', () => {
+ this.openModal();
+ });
+ },
+ methods: {
+ openModal() {
+ this.$root.$emit(BV_SHOW_MODAL, this.modalId);
+ },
+ closeModal() {
+ this.$root.$emit(BV_HIDE_MODAL, this.modalId);
+ },
+ sendInvite({ onError, onSuccess, data: { accessLevel, expiresAt } }) {
+ const apiShareWithGroup = this.isProject
+ ? Api.projectShareWithGroup.bind(Api)
+ : Api.groupShareWithGroup.bind(Api);
+
+ apiShareWithGroup(this.id, {
+ format: 'json',
+ group_id: this.groupToBeSharedWith.id,
+ group_access: accessLevel,
+ expires_at: expiresAt,
+ })
+ .then(() => {
+ onSuccess();
+ this.showSuccessMessage();
+ })
+ .catch(onError);
+ },
+ resetFields() {
+ this.groupToBeSharedWith = {};
+ },
+ showSuccessMessage() {
+ this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
+ this.closeModal();
+ },
+ },
+ labels: GROUP_MODAL_LABELS,
+};
+</script>
+<template>
+ <invite-modal-base
+ :modal-id="modalId"
+ :modal-title="$options.labels.title"
+ :name="name"
+ :access-levels="accessLevels"
+ :default-access-level="defaultAccessLevel"
+ :help-link="helpLink"
+ v-bind="$attrs"
+ :label-intro-text="labelIntroText"
+ :label-search-field="$options.labels.searchField"
+ :submit-disabled="inviteDisabled"
+ @reset="resetFields"
+ @submit="sendInvite"
+ >
+ <template #select="{ clearValidation }">
+ <group-select
+ v-model="groupToBeSharedWith"
+ :access-levels="accessLevels"
+ :groups-filter="groupSelectFilter"
+ :parent-group-id="groupSelectParentId"
+ :invalid-groups="invalidGroups"
+ @input="clearValidation"
+ />
+ </template>
+ </invite-modal-base>
+</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 91a139a5105..6c0fc5caf26 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -1,56 +1,40 @@
<script>
import {
GlAlert,
- GlFormGroup,
- GlModal,
GlDropdown,
GlDropdownItem,
- GlDatepicker,
GlLink,
GlSprintf,
- GlButton,
- GlFormInput,
GlFormCheckboxGroup,
} from '@gitlab/ui';
-import { partition, isString, unescape, uniqueId } from 'lodash';
+import { partition, isString, uniqueId } 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 { sanitize } from '~/lib/dompurify';
-import { BV_SHOW_MODAL } from '~/lib/utils/constants';
+import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { getParameterValues } from '~/lib/utils/url_utility';
-import { sprintf } from '~/locale';
import {
- GROUP_FILTERS,
USERS_FILTER_ALL,
INVITE_MEMBERS_FOR_TASK,
- MODAL_LABELS,
+ MEMBER_MODAL_LABELS,
LEARN_GITLAB,
} from '../constants';
import eventHub from '../event_hub';
-import {
- responseMessageFromError,
- responseMessageFromSuccess,
-} from '../utils/response_message_parser';
+import { responseMessageFromSuccess } from '../utils/response_message_parser';
import ModalConfetti from './confetti.vue';
-import GroupSelect from './group_select.vue';
import MembersTokenSelect from './members_token_select.vue';
export default {
name: 'InviteMembersModal',
components: {
GlAlert,
- GlFormGroup,
- GlDatepicker,
GlLink,
- GlModal,
GlDropdown,
GlDropdownItem,
GlSprintf,
- GlButton,
- GlFormInput,
GlFormCheckboxGroup,
+ InviteModalBase,
MembersTokenSelect,
- GroupSelect,
ModalConfetti,
},
inject: ['newProjectPath'],
@@ -75,15 +59,9 @@ export default {
type: Number,
required: true,
},
- groupSelectFilter: {
+ helpLink: {
type: String,
- required: false,
- default: GROUP_FILTERS.ALL,
- },
- groupSelectParentId: {
- type: Number,
- required: false,
- default: null,
+ required: true,
},
usersFilter: {
type: String,
@@ -95,10 +73,6 @@ export default {
required: false,
default: null,
},
- helpLink: {
- type: String,
- required: true,
- },
tasksToBeDoneOptions: {
type: Array,
required: true,
@@ -110,73 +84,31 @@ export default {
},
data() {
return {
- visible: true,
modalId: uniqueId('invite-members-modal-'),
- selectedAccessLevel: this.defaultAccessLevel,
- inviteeType: 'members',
newUsersToInvite: [],
- selectedDate: undefined,
selectedTasksToBeDone: [],
selectedTaskProject: this.projects[0],
- groupToBeSharedWith: {},
source: 'unknown',
- invalidFeedbackMessage: '',
- isLoading: false,
mode: 'default',
+ // Kept in sync with "base"
+ selectedAccessLevel: undefined,
};
},
computed: {
isCelebration() {
return this.mode === 'celebrate';
},
- validationState() {
- return this.invalidFeedbackMessage === '' ? null : false;
- },
- isInviteGroup() {
- return this.inviteeType === 'group';
- },
modalTitle() {
- return this.$options.labels[this.inviteeType].modal[this.mode].title;
- },
- introText() {
- return sprintf(this.$options.labels[this.inviteeType][this.inviteTo][this.mode].introText, {
- name: this.name,
- });
+ return this.$options.labels.modal[this.mode].title;
},
inviteTo() {
return this.isProject ? 'toProject' : 'toGroup';
},
- toastOptions() {
- return {
- onComplete: () => {
- this.selectedAccessLevel = this.defaultAccessLevel;
- this.newUsersToInvite = [];
- this.groupToBeSharedWith = {};
- },
- };
- },
- basePostData() {
- return {
- expires_at: this.selectedDate,
- format: 'json',
- };
- },
- selectedRoleName() {
- return Object.keys(this.accessLevels).find(
- (key) => this.accessLevels[key] === Number(this.selectedAccessLevel),
- );
+ labelIntroText() {
+ return this.$options.labels[this.inviteTo][this.mode].introText;
},
inviteDisabled() {
- return (
- this.newUsersToInvite.length === 0 && Object.keys(this.groupToBeSharedWith).length === 0
- );
- },
- errorFieldDescription() {
- if (this.inviteeType === 'group') {
- return '';
- }
-
- return this.$options.labels[this.inviteeType].placeHolder;
+ return this.newUsersToInvite.length === 0;
},
tasksToBeDoneEnabled() {
return (
@@ -215,7 +147,7 @@ export default {
});
if (this.tasksToBeDoneEnabled) {
- this.openModal({ inviteeType: 'members', source: 'in_product_marketing_email' });
+ this.openModal({ source: 'in_product_marketing_email' });
this.trackEvent(INVITE_MEMBERS_FOR_TASK.name, INVITE_MEMBERS_FOR_TASK.view);
}
},
@@ -231,72 +163,42 @@ export default {
usersToAddById.map((user) => user.id).join(','),
];
},
- openModal({ mode = 'default', inviteeType, source }) {
+ openModal({ mode = 'default', source }) {
this.mode = mode;
- this.inviteeType = inviteeType;
this.source = source;
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
+ closeModal() {
+ this.$root.$emit(BV_HIDE_MODAL, this.modalId);
+ },
trackEvent(experimentName, eventName) {
const tracking = new ExperimentTracking(experimentName);
tracking.event(eventName);
},
- closeModal() {
- this.resetFields();
- this.$refs.modal.hide();
- },
- sendInvite() {
- if (this.isInviteGroup) {
- this.submitShareWithGroup();
- } else {
- this.submitInviteMembers();
- }
- },
- trackinviteMembersForTask() {
- const label = 'selected_tasks_to_be_done';
- const property = this.selectedTasksToBeDone.join(',');
- const tracking = new ExperimentTracking(INVITE_MEMBERS_FOR_TASK.name, { label, property });
- tracking.event(INVITE_MEMBERS_FOR_TASK.submit);
- },
- resetFields() {
- this.isLoading = false;
- this.selectedAccessLevel = this.defaultAccessLevel;
- this.selectedDate = undefined;
- this.newUsersToInvite = [];
- this.groupToBeSharedWith = {};
- this.invalidFeedbackMessage = '';
- this.selectedTasksToBeDone = [];
- [this.selectedTaskProject] = this.projects;
- },
- changeSelectedItem(item) {
- this.selectedAccessLevel = item;
- },
- changeSelectedTaskProject(project) {
- this.selectedTaskProject = project;
- },
- submitShareWithGroup() {
- const apiShareWithGroup = this.isProject
- ? Api.projectShareWithGroup.bind(Api)
- : Api.groupShareWithGroup.bind(Api);
-
- apiShareWithGroup(this.id, this.shareWithGroupPostData(this.groupToBeSharedWith.id))
- .then(this.showSuccessMessage)
- .catch(this.showInvalidFeedbackMessage);
- },
- submitInviteMembers() {
- this.invalidFeedbackMessage = '';
- this.isLoading = true;
-
+ sendInvite({ onError, onSuccess, data: { accessLevel, expiresAt } }) {
const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
const promises = [];
+ const baseData = {
+ format: 'json',
+ expires_at: expiresAt,
+ access_level: accessLevel,
+ invite_source: this.source,
+ tasks_to_be_done: this.tasksToBeDoneForPost,
+ tasks_project_id: this.tasksProjectForPost,
+ };
if (usersToInviteByEmail !== '') {
const apiInviteByEmail = this.isProject
? Api.inviteProjectMembersByEmail.bind(Api)
: Api.inviteGroupMembersByEmail.bind(Api);
- promises.push(apiInviteByEmail(this.id, this.inviteByEmailPostData(usersToInviteByEmail)));
+ promises.push(
+ apiInviteByEmail(this.id, {
+ ...baseData,
+ email: usersToInviteByEmail,
+ }),
+ );
}
if (usersToAddById !== '') {
@@ -304,188 +206,103 @@ export default {
? Api.addProjectMembersByUserId.bind(Api)
: Api.addGroupMembersByUserId.bind(Api);
- promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById)));
+ promises.push(
+ apiAddByUserId(this.id, {
+ ...baseData,
+ user_id: usersToAddById,
+ }),
+ );
}
this.trackinviteMembersForTask();
Promise.all(promises)
- .then(this.conditionallyShowSuccessMessage)
- .catch(this.showInvalidFeedbackMessage);
- },
- inviteByEmailPostData(usersToInviteByEmail) {
- return {
- ...this.basePostData,
- email: usersToInviteByEmail,
- access_level: this.selectedAccessLevel,
- invite_source: this.source,
- tasks_to_be_done: this.tasksToBeDoneForPost,
- tasks_project_id: this.tasksProjectForPost,
- };
+ .then((responses) => {
+ const message = responseMessageFromSuccess(responses);
+
+ if (message) {
+ onError({
+ response: {
+ data: {
+ message,
+ },
+ },
+ });
+ } else {
+ onSuccess();
+ this.showSuccessMessage();
+ }
+ })
+ .catch(onError);
},
- addByUserIdPostData(usersToAddById) {
- return {
- ...this.basePostData,
- user_id: usersToAddById,
- access_level: this.selectedAccessLevel,
- invite_source: this.source,
- tasks_to_be_done: this.tasksToBeDoneForPost,
- tasks_project_id: this.tasksProjectForPost,
- };
+ trackinviteMembersForTask() {
+ const label = 'selected_tasks_to_be_done';
+ const property = this.selectedTasksToBeDone.join(',');
+ const tracking = new ExperimentTracking(INVITE_MEMBERS_FOR_TASK.name, { label, property });
+ tracking.event(INVITE_MEMBERS_FOR_TASK.submit);
},
- shareWithGroupPostData(groupToBeSharedWith) {
- return {
- ...this.basePostData,
- group_id: groupToBeSharedWith,
- group_access: this.selectedAccessLevel,
- };
+ resetFields() {
+ this.newUsersToInvite = [];
+ this.selectedTasksToBeDone = [];
+ [this.selectedTaskProject] = this.projects;
},
- conditionallyShowSuccessMessage(response) {
- const message = this.unescapeMsg(responseMessageFromSuccess(response));
-
- if (message === '') {
- this.showSuccessMessage();
-
- return;
- }
-
- this.invalidFeedbackMessage = message;
- this.isLoading = false;
+ changeSelectedTaskProject(project) {
+ this.selectedTaskProject = project;
},
showSuccessMessage() {
if (this.isOnLearnGitlab) {
eventHub.$emit('showSuccessfulInvitationsAlert');
} else {
- this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
+ this.$toast.show(this.$options.labels.toastMessageSuccessful);
}
- this.closeModal();
- },
- showInvalidFeedbackMessage(response) {
- const message = this.unescapeMsg(responseMessageFromError(response));
- this.isLoading = false;
- this.invalidFeedbackMessage = message || this.$options.labels.invalidFeedbackMessageDefault;
- },
- handleMembersTokenSelectClear() {
- this.invalidFeedbackMessage = '';
+ this.closeModal();
},
- unescapeMsg(message) {
- return unescape(sanitize(message, { ALLOWED_TAGS: [] }));
+ onAccessLevelUpdate(val) {
+ this.selectedAccessLevel = val;
},
},
- labels: MODAL_LABELS,
- membersTokenSelectLabelId: 'invite-members-input',
+ labels: MEMBER_MODAL_LABELS,
};
</script>
<template>
- <gl-modal
- ref="modal"
+ <invite-modal-base
:modal-id="modalId"
- size="sm"
- data-qa-selector="invite_members_modal_content"
- data-testid="invite-members-modal"
- :title="modalTitle"
- :header-close-label="$options.labels.headerCloseLabel"
- @hidden="resetFields"
- @close="resetFields"
- @hide="resetFields"
+ :modal-title="modalTitle"
+ :name="name"
+ :access-levels="accessLevels"
+ :default-access-level="defaultAccessLevel"
+ :help-link="helpLink"
+ :label-intro-text="labelIntroText"
+ :label-search-field="$options.labels.searchField"
+ :form-group-description="$options.labels.placeHolder"
+ :submit-disabled="inviteDisabled"
+ @reset="resetFields"
+ @submit="sendInvite"
+ @access-level="onAccessLevelUpdate"
>
- <div>
- <div class="gl-display-flex">
- <div v-if="isCelebration" class="gl-p-4 gl-font-size-h1"><gl-emoji data-name="tada" /></div>
- <div>
- <p ref="introText">
- <gl-sprintf :message="introText">
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- <br />
- <span v-if="isCelebration">{{ $options.labels.members.modal.celebrate.intro }} </span>
- <modal-confetti v-if="isCelebration" />
- </p>
- </div>
- </div>
-
- <gl-form-group
- :invalid-feedback="invalidFeedbackMessage"
- :state="validationState"
- :description="errorFieldDescription"
- data-testid="members-form-group"
- >
- <label :id="$options.membersTokenSelectLabelId" class="col-form-label">{{
- $options.labels[inviteeType].searchField
- }}</label>
- <members-token-select
- v-if="!isInviteGroup"
- v-model="newUsersToInvite"
- class="gl-mb-2"
- :validation-state="validationState"
- :aria-labelledby="$options.membersTokenSelectLabelId"
- :users-filter="usersFilter"
- :filter-id="filterId"
- @clear="handleMembersTokenSelectClear"
- />
- <group-select
- v-if="isInviteGroup"
- v-model="groupToBeSharedWith"
- :groups-filter="groupSelectFilter"
- :parent-group-id="groupSelectParentId"
- @input="handleMembersTokenSelectClear"
- />
- </gl-form-group>
-
- <label class="gl-font-weight-bold">{{ $options.labels.accessLevel }}</label>
- <div class="gl-mt-2 gl-w-half gl-xs-w-full">
- <gl-dropdown
- class="gl-shadow-none gl-w-full"
- data-qa-selector="access_level_dropdown"
- v-bind="$attrs"
- :text="selectedRoleName"
- >
- <template v-for="(key, item) in accessLevels">
- <gl-dropdown-item
- :key="key"
- active-class="is-active"
- is-check-item
- :is-checked="key === selectedAccessLevel"
- @click="changeSelectedItem(key)"
- >
- <div>{{ item }}</div>
- </gl-dropdown-item>
- </template>
- </gl-dropdown>
- </div>
-
- <div class="gl-mt-2 gl-w-half gl-xs-w-full">
- <gl-sprintf :message="$options.labels.readMoreText">
- <template #link="{ content }">
- <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </div>
-
- <label class="gl-mt-5 gl-display-block" for="expires_at">{{
- $options.labels.accessExpireDate
- }}</label>
- <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block">
- <gl-datepicker
- v-model="selectedDate"
- class="gl-display-inline!"
- :min-date="new Date()"
- :target="null"
- >
- <template #default="{ formattedDate }">
- <gl-form-input
- class="gl-w-full"
- :value="formattedDate"
- :placeholder="__(`YYYY-MM-DD`)"
- />
- </template>
- </gl-datepicker>
- </div>
+ <template #intro-text-before>
+ <div v-if="isCelebration" class="gl-p-4 gl-font-size-h1"><gl-emoji data-name="tada" /></div>
+ </template>
+ <template #intro-text-after>
+ <br />
+ <span v-if="isCelebration">{{ $options.labels.modal.celebrate.intro }} </span>
+ <modal-confetti v-if="isCelebration" />
+ </template>
+ <template #select="{ clearValidation, validationState, labelId }">
+ <members-token-select
+ v-model="newUsersToInvite"
+ class="gl-mb-2"
+ :validation-state="validationState"
+ :aria-labelledby="labelId"
+ :users-filter="usersFilter"
+ :filter-id="filterId"
+ @clear="clearValidation"
+ />
+ </template>
+ <template #form-after>
<div v-if="showTasksToBeDone" data-testid="invite-members-modal-tasks-to-be-done">
<label class="gl-mt-5">
- {{ $options.labels.members.tasksToBeDone.title }}
+ {{ $options.labels.tasksToBeDone.title }}
</label>
<template v-if="projects.length">
<gl-form-checkbox-group
@@ -495,7 +312,7 @@ export default {
/>
<template v-if="showTaskProjects">
<label class="gl-mt-5 gl-display-block">
- {{ $options.labels.members.tasksProject.title }}
+ {{ $options.labels.tasksProject.title }}
</label>
<gl-dropdown
class="gl-w-half gl-xs-w-full"
@@ -522,7 +339,7 @@ export default {
:dismissible="false"
data-testid="invite-members-modal-no-projects-alert"
>
- <gl-sprintf :message="$options.labels.members.tasksToBeDone.noProjects">
+ <gl-sprintf :message="$options.labels.tasksToBeDone.noProjects">
<template #link="{ content }">
<gl-link :href="newProjectPath" target="_blank" class="gl-label-link">
{{ content }}
@@ -531,22 +348,6 @@ export default {
</gl-sprintf>
</gl-alert>
</div>
- </div>
-
- <template #modal-footer>
- <gl-button data-testid="cancel-button" @click="closeModal">
- {{ $options.labels.cancelButtonText }}
- </gl-button>
- <gl-button
- :disabled="inviteDisabled"
- :loading="isLoading"
- variant="success"
- data-qa-selector="invite_button"
- data-testid="invite-button"
- @click="sendInvite"
- >
- {{ $options.labels.inviteButtonText }}
- </gl-button>
</template>
- </gl-modal>
+ </invite-modal-base>
</template>
diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
index 7dd74f8803a..79b192e2495 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
@@ -71,7 +71,7 @@ export default {
return this.triggerElement === targetTriggerElement;
},
openModal() {
- eventHub.$emit('openModal', { inviteeType: 'members', source: this.triggerSource });
+ eventHub.$emit('openModal', { source: this.triggerSource });
},
},
TRIGGER_ELEMENT_BUTTON,
diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
new file mode 100644
index 00000000000..fc00f5b9343
--- /dev/null
+++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
@@ -0,0 +1,276 @@
+<script>
+import {
+ GlFormGroup,
+ GlModal,
+ GlDropdown,
+ GlDropdownItem,
+ GlDatepicker,
+ GlLink,
+ GlSprintf,
+ GlButton,
+ GlFormInput,
+} from '@gitlab/ui';
+import { unescape } from 'lodash';
+import { sanitize } from '~/lib/dompurify';
+import { sprintf } from '~/locale';
+import {
+ ACCESS_LEVEL,
+ ACCESS_EXPIRE_DATE,
+ INVALID_FEEDBACK_MESSAGE_DEFAULT,
+ READ_MORE_TEXT,
+ INVITE_BUTTON_TEXT,
+ CANCEL_BUTTON_TEXT,
+ HEADER_CLOSE_LABEL,
+} from '../constants';
+import { responseMessageFromError } from '../utils/response_message_parser';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlDatepicker,
+ GlLink,
+ GlModal,
+ GlDropdown,
+ GlDropdownItem,
+ GlSprintf,
+ GlButton,
+ GlFormInput,
+ },
+ inheritAttrs: false,
+ props: {
+ modalTitle: {
+ type: String,
+ required: true,
+ },
+ modalId: {
+ type: String,
+ required: true,
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ accessLevels: {
+ type: Object,
+ required: true,
+ },
+ defaultAccessLevel: {
+ type: Number,
+ required: true,
+ },
+ helpLink: {
+ type: String,
+ required: true,
+ },
+ labelIntroText: {
+ type: String,
+ required: true,
+ },
+ labelSearchField: {
+ type: String,
+ required: true,
+ },
+ formGroupDescription: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ submitDisabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ // Be sure to check out reset!
+ return {
+ invalidFeedbackMessage: '',
+ selectedAccessLevel: this.defaultAccessLevel,
+ selectedDate: undefined,
+ isLoading: false,
+ minDate: new Date(),
+ };
+ },
+ computed: {
+ introText() {
+ return sprintf(this.labelIntroText, { name: this.name });
+ },
+ validationState() {
+ return this.invalidFeedbackMessage ? false : null;
+ },
+ selectLabelId() {
+ return `${this.modalId}_select`;
+ },
+ selectedRoleName() {
+ return Object.keys(this.accessLevels).find(
+ (key) => this.accessLevels[key] === Number(this.selectedAccessLevel),
+ );
+ },
+ },
+ watch: {
+ selectedAccessLevel: {
+ immediate: true,
+ handler(val) {
+ this.$emit('access-level', val);
+ },
+ },
+ },
+ methods: {
+ showInvalidFeedbackMessage(response) {
+ const message = this.unescapeMsg(responseMessageFromError(response));
+
+ this.invalidFeedbackMessage = message || INVALID_FEEDBACK_MESSAGE_DEFAULT;
+ },
+ reset() {
+ // This component isn't necessarily disposed,
+ // so we might need to reset it's state.
+ this.isLoading = false;
+ this.invalidFeedbackMessage = '';
+ this.selectedAccessLevel = this.defaultAccessLevel;
+ this.selectedDate = undefined;
+
+ this.$emit('reset');
+ },
+ closeModal() {
+ this.reset();
+ this.$refs.modal.hide();
+ },
+ clearValidation() {
+ this.invalidFeedbackMessage = '';
+ },
+ changeSelectedItem(item) {
+ this.selectedAccessLevel = item;
+ },
+ submit() {
+ this.isLoading = true;
+ this.invalidFeedbackMessage = '';
+
+ this.$emit('submit', {
+ onSuccess: () => {
+ this.isLoading = false;
+ },
+ onError: (...args) => {
+ this.isLoading = false;
+ this.showInvalidFeedbackMessage(...args);
+ },
+ data: {
+ accessLevel: this.selectedAccessLevel,
+ expiresAt: this.selectedDate,
+ },
+ });
+ },
+ unescapeMsg(message) {
+ return unescape(sanitize(message, { ALLOWED_TAGS: [] }));
+ },
+ },
+ HEADER_CLOSE_LABEL,
+ ACCESS_EXPIRE_DATE,
+ ACCESS_LEVEL,
+ READ_MORE_TEXT,
+ INVITE_BUTTON_TEXT,
+ CANCEL_BUTTON_TEXT,
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="modal"
+ :modal-id="modalId"
+ data-qa-selector="invite_members_modal_content"
+ data-testid="invite-modal"
+ size="sm"
+ :title="modalTitle"
+ :header-close-label="$options.HEADER_CLOSE_LABEL"
+ @hidden="reset"
+ @close="reset"
+ @hide="reset"
+ >
+ <div class="gl-display-flex" data-testid="modal-base-intro-text">
+ <slot name="intro-text-before"></slot>
+ <p>
+ <gl-sprintf :message="introText">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ <slot name="intro-text-after"></slot>
+ </div>
+
+ <gl-form-group
+ :invalid-feedback="invalidFeedbackMessage"
+ :state="validationState"
+ :description="formGroupDescription"
+ data-testid="members-form-group"
+ >
+ <label :id="selectLabelId" class="col-form-label">{{ labelSearchField }}</label>
+ <slot
+ name="select"
+ v-bind="{ clearValidation, validationState, labelId: selectLabelId }"
+ ></slot>
+ </gl-form-group>
+
+ <label class="gl-font-weight-bold">{{ $options.ACCESS_LEVEL }}</label>
+ <div class="gl-mt-2 gl-w-half gl-xs-w-full">
+ <gl-dropdown
+ class="gl-shadow-none gl-w-full"
+ data-qa-selector="access_level_dropdown"
+ v-bind="$attrs"
+ :text="selectedRoleName"
+ >
+ <template v-for="(key, item) in accessLevels">
+ <gl-dropdown-item
+ :key="key"
+ active-class="is-active"
+ is-check-item
+ :is-checked="key === selectedAccessLevel"
+ @click="changeSelectedItem(key)"
+ >
+ <div>{{ item }}</div>
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+ </div>
+
+ <div class="gl-mt-2 gl-w-half gl-xs-w-full">
+ <gl-sprintf :message="$options.READ_MORE_TEXT">
+ <template #link="{ content }">
+ <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+
+ <label class="gl-mt-5 gl-display-block" for="expires_at">{{
+ $options.ACCESS_EXPIRE_DATE
+ }}</label>
+ <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block">
+ <gl-datepicker
+ v-model="selectedDate"
+ class="gl-display-inline!"
+ :min-date="minDate"
+ :target="null"
+ >
+ <template #default="{ formattedDate }">
+ <gl-form-input class="gl-w-full" :value="formattedDate" :placeholder="__(`YYYY-MM-DD`)" />
+ </template>
+ </gl-datepicker>
+ </div>
+ <slot name="form-after"></slot>
+
+ <template #modal-footer>
+ <gl-button data-testid="cancel-button" @click="closeModal">
+ {{ $options.CANCEL_BUTTON_TEXT }}
+ </gl-button>
+ <gl-button
+ :disabled="submitDisabled"
+ :loading="isLoading"
+ variant="success"
+ data-qa-selector="invite_button"
+ data-testid="invite-button"
+ @click="submit"
+ >
+ {{ $options.INVITE_BUTTON_TEXT }}
+ </gl-button>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index ec59b3909fe..cf2ee508184 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -72,67 +72,52 @@ export const INVITE_BUTTON_TEXT = s__('InviteMembersModal|Invite');
export const CANCEL_BUTTON_TEXT = s__('InviteMembersModal|Cancel');
export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team members');
-export const MODAL_LABELS = {
- members: {
- modal: {
- default: {
- title: MEMBERS_MODAL_DEFAULT_TITLE,
- },
- celebrate: {
- title: MEMBERS_MODAL_CELEBRATE_TITLE,
- intro: MEMBERS_MODAL_CELEBRATE_INTRO,
- },
+export const MEMBER_MODAL_LABELS = {
+ modal: {
+ default: {
+ title: MEMBERS_MODAL_DEFAULT_TITLE,
},
- toGroup: {
- default: {
- introText: MEMBERS_TO_GROUP_DEFAULT_INTRO_TEXT,
- },
- },
- toProject: {
- default: {
- introText: MEMBERS_TO_PROJECT_DEFAULT_INTRO_TEXT,
- },
- celebrate: {
- introText: MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT,
- },
- },
- searchField: MEMBERS_SEARCH_FIELD,
- placeHolder: MEMBERS_PLACEHOLDER,
- tasksToBeDone: {
- title: MEMBERS_TASKS_TO_BE_DONE_TITLE,
- noProjects: MEMBERS_TASKS_TO_BE_DONE_NO_PROJECTS,
- },
- tasksProject: {
- title: MEMBERS_TASKS_PROJECTS_TITLE,
+ celebrate: {
+ title: MEMBERS_MODAL_CELEBRATE_TITLE,
+ intro: MEMBERS_MODAL_CELEBRATE_INTRO,
},
},
- group: {
- modal: {
- default: {
- title: GROUP_MODAL_DEFAULT_TITLE,
- },
+ toGroup: {
+ default: {
+ introText: MEMBERS_TO_GROUP_DEFAULT_INTRO_TEXT,
},
- toGroup: {
- default: {
- introText: GROUP_MODAL_TO_GROUP_DEFAULT_INTRO_TEXT,
- },
+ },
+ toProject: {
+ default: {
+ introText: MEMBERS_TO_PROJECT_DEFAULT_INTRO_TEXT,
},
- toProject: {
- default: {
- introText: GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT,
- },
+ celebrate: {
+ introText: MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT,
},
- searchField: GROUP_SEARCH_FIELD,
- placeHolder: GROUP_PLACEHOLDER,
},
- accessLevel: ACCESS_LEVEL,
- accessExpireDate: ACCESS_EXPIRE_DATE,
+ searchField: MEMBERS_SEARCH_FIELD,
+ placeHolder: MEMBERS_PLACEHOLDER,
+ tasksToBeDone: {
+ title: MEMBERS_TASKS_TO_BE_DONE_TITLE,
+ noProjects: MEMBERS_TASKS_TO_BE_DONE_NO_PROJECTS,
+ },
+ tasksProject: {
+ title: MEMBERS_TASKS_PROJECTS_TITLE,
+ },
+ toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL,
+};
+
+export const GROUP_MODAL_LABELS = {
+ title: GROUP_MODAL_DEFAULT_TITLE,
+ toGroup: {
+ introText: GROUP_MODAL_TO_GROUP_DEFAULT_INTRO_TEXT,
+ },
+ toProject: {
+ introText: GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT,
+ },
+ searchField: GROUP_SEARCH_FIELD,
+ placeHolder: GROUP_PLACEHOLDER,
toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL,
- invalidFeedbackMessageDefault: INVALID_FEEDBACK_MESSAGE_DEFAULT,
- readMoreText: READ_MORE_TEXT,
- inviteButtonText: INVITE_BUTTON_TEXT,
- cancelButtonText: CANCEL_BUTTON_TEXT,
- headerCloseLabel: HEADER_CLOSE_LABEL,
};
export const LEARN_GITLAB = 'learn_gitlab';
diff --git a/app/assets/javascripts/invite_members/init_invite_groups_modal.js b/app/assets/javascripts/invite_members/init_invite_groups_modal.js
new file mode 100644
index 00000000000..be1576ad0b0
--- /dev/null
+++ b/app/assets/javascripts/invite_members/init_invite_groups_modal.js
@@ -0,0 +1,44 @@
+import { GlToast } from '@gitlab/ui';
+import Vue from 'vue';
+import InviteGroupsModal from '~/invite_members/components/invite_groups_modal.vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+
+Vue.use(GlToast);
+
+let initedInviteGroupsModal;
+
+export default function initInviteGroupsModal() {
+ if (initedInviteGroupsModal) {
+ // if we already loaded this in another part of the dom, we don't want to do it again
+ // else we will stack the modals
+ return false;
+ }
+
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/344955
+ // bug lying in wait here for someone to put group and project invite in same screen
+ // once that happens we'll need to mount these differently, perhaps split
+ // group/project to each mount one, with many ways to open it.
+ const el = document.querySelector('.js-invite-groups-modal');
+
+ if (!el) {
+ return false;
+ }
+
+ initedInviteGroupsModal = true;
+
+ return new Vue({
+ el,
+ render: (createElement) =>
+ createElement(InviteGroupsModal, {
+ props: {
+ ...el.dataset,
+ isProject: parseBoolean(el.dataset.isProject),
+ accessLevels: JSON.parse(el.dataset.accessLevels),
+ defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10),
+ groupSelectFilter: el.dataset.groupsFilter,
+ groupSelectParentId: parseInt(el.dataset.parentId, 10),
+ invalidGroups: JSON.parse(el.dataset.invalidGroups || '[]'),
+ },
+ }),
+ });
+}
diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js
index 2cc056f2ddb..e9d620cedf0 100644
--- a/app/assets/javascripts/invite_members/init_invite_members_modal.js
+++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js
@@ -28,6 +28,7 @@ export default function initInviteMembersModal() {
return new Vue({
el,
+ name: 'InviteMembersModalRoot',
provide: {
newProjectPath: el.dataset.newProjectPath,
},
@@ -38,8 +39,6 @@ export default function initInviteMembersModal() {
isProject: parseBoolean(el.dataset.isProject),
accessLevels: JSON.parse(el.dataset.accessLevels),
defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10),
- groupSelectFilter: el.dataset.groupsFilter,
- groupSelectParentId: parseInt(el.dataset.parentId, 10),
tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions || '[]'),
projects: JSON.parse(el.dataset.projects || '[]'),
usersFilter: el.dataset.usersFilter,
diff --git a/app/assets/javascripts/invite_members/init_invite_members_trigger.js b/app/assets/javascripts/invite_members/init_invite_members_trigger.js
index 935edb35349..54a5eab2e4b 100644
--- a/app/assets/javascripts/invite_members/init_invite_members_trigger.js
+++ b/app/assets/javascripts/invite_members/init_invite_members_trigger.js
@@ -11,6 +11,7 @@ export default function initInviteMembersTrigger() {
return triggers.forEach((el) => {
return new Vue({
el,
+ name: 'InviteMembersTriggerRoot',
render: (createElement) =>
createElement(InviteMembersTrigger, {
props: {
diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/index.js b/app/assets/javascripts/issuable/bulk_update_sidebar/index.js
index dca606556d0..967996b859e 100644
--- a/app/assets/javascripts/issuable/bulk_update_sidebar/index.js
+++ b/app/assets/javascripts/issuable/bulk_update_sidebar/index.js
@@ -23,6 +23,7 @@ export function initIssueStatusSelect() {
return new Vue({
el,
+ name: 'StatusSelectRoot',
render: (createElement) => createElement(StatusSelect),
});
}
diff --git a/app/assets/javascripts/issuable/index.js b/app/assets/javascripts/issuable/index.js
index 57bad5182e7..10dbefce503 100644
--- a/app/assets/javascripts/issuable/index.js
+++ b/app/assets/javascripts/issuable/index.js
@@ -32,6 +32,7 @@ export function initCsvImportExportButtons() {
return new Vue({
el,
+ name: 'CsvImportExportButtonsRoot',
provide: {
showExportButton: parseBoolean(showExportButton),
showImportButton: parseBoolean(showImportButton),
@@ -74,6 +75,7 @@ export function initIssuableByEmail() {
return new Vue({
el,
+ name: 'IssuableByEmailRoot',
provide: {
initialEmail,
issuableType,
@@ -97,6 +99,7 @@ export function initIssuableHeaderWarnings(store) {
return new Vue({
el,
+ name: 'IssuableHeaderWarningsRoot',
store,
provide: { hidden: parseBoolean(hidden) },
render: (createElement) => createElement(IssuableHeaderWarnings),
diff --git a/app/assets/javascripts/issuable/issuable_context.js b/app/assets/javascripts/issuable/issuable_context.js
index 453305dd6e0..37001d00a27 100644
--- a/app/assets/javascripts/issuable/issuable_context.js
+++ b/app/assets/javascripts/issuable/issuable_context.js
@@ -1,6 +1,6 @@
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import $ from 'jquery';
-import Cookies from 'js-cookie';
+import { setCookie } from '~/lib/utils/common_utils';
import { loadCSSFile } from '~/lib/utils/css_utils';
import UsersSelect from '~/users_select';
@@ -62,7 +62,7 @@ export default class IssuableContext {
const supportedSizes = ['xs', 'sm', 'md'];
if (supportedSizes.includes(bpBreakpoint)) {
- Cookies.set('collapsed_gutter', true);
+ setCookie('collapsed_gutter', true);
}
});
}
diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js
index 91f47a86cb7..88c1748db0b 100644
--- a/app/assets/javascripts/issuable/issuable_form.js
+++ b/app/assets/javascripts/issuable/issuable_form.js
@@ -77,6 +77,7 @@ export default class IssuableForm {
this.initAutosave();
this.form.on('submit', this.handleSubmit);
this.form.on('click', '.btn-cancel', this.resetAutosave);
+ this.form.find('.js-unwrap-on-load').unwrap();
this.initWip();
const $issuableDueDate = $('#issuable-due-date');
diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js
index 5d36396bc6e..a3752c7043c 100644
--- a/app/assets/javascripts/issues/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js
@@ -69,11 +69,11 @@ export default class CreateMergeRequestDropdown {
this.regexps = {
branch: {
createBranchPath: new RegExp('(branch_name=)(.+?)(?=&issue)'),
- createMrPath: new RegExp('(branch_name=)(.+?)(?=&ref)'),
+ createMrPath: new RegExp('(source_branch%5D=)(.+?)(?=&)'),
},
ref: {
createBranchPath: new RegExp('(ref=)(.+?)$'),
- createMrPath: new RegExp('(ref=)(.+?)$'),
+ createMrPath: new RegExp('(target_branch%5D=)(.+?)$'),
},
};
@@ -167,23 +167,18 @@ export default class CreateMergeRequestDropdown {
}
createMergeRequest() {
- this.isCreatingMergeRequest = true;
-
- return axios
- .post(this.createMrPath, {
- target_project_id: canCreateConfidentialMergeRequest()
- ? confidentialMergeRequestState.selectedProject.id
- : null,
- })
- .then(({ data }) => {
- this.mergeRequestCreated = true;
- window.location.href = data.url;
- })
- .catch(() =>
- createFlash({
- message: __('Failed to create merge request. Please try again.'),
- }),
- );
+ return new Promise(() => {
+ this.isCreatingMergeRequest = true;
+
+ return this.createBranch().then(() => {
+ window.location.href = canCreateConfidentialMergeRequest()
+ ? this.createMrPath.replace(
+ this.projectPath,
+ confidentialMergeRequestState.selectedProject.pathWithNamespace,
+ )
+ : this.createMrPath;
+ });
+ });
}
disable() {
@@ -562,5 +557,7 @@ export default class CreateMergeRequestDropdown {
this.regexps[target].createMrPath,
pathReplacement,
);
+
+ this.wrapperEl.dataset.createMrPath = this.createMrPath;
}
}
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 8b15e801f02..3866a7b3305 100644
--- a/app/assets/javascripts/issues/list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -10,16 +10,30 @@ import {
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
-import { orderBy } from 'lodash';
+import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue';
import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql';
import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql';
-import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue';
import createFlash, { FLASH_TYPES } from '~/flash';
import { TYPE_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { ITEM_TYPE } from '~/groups/constants';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
+import axios from '~/lib/utils/axios_utils';
+import { scrollUp } from '~/lib/utils/scroll_utils';
+import { getParameterByName, joinPaths } from '~/lib/utils/url_utility';
+import {
+ DEFAULT_NONE_ANY,
+ OPERATOR_IS_ONLY,
+ TOKEN_TITLE_ASSIGNEE,
+ TOKEN_TITLE_AUTHOR,
+ TOKEN_TITLE_CONFIDENTIAL,
+ TOKEN_TITLE_LABEL,
+ TOKEN_TITLE_MILESTONE,
+ TOKEN_TITLE_MY_REACTION,
+ TOKEN_TITLE_RELEASE,
+ TOKEN_TITLE_TYPE,
+} from '~/vue_shared/components/filtered_search_bar/constants';
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants';
import {
@@ -27,8 +41,6 @@ import {
i18n,
MAX_LIST_SIZE,
PAGE_SIZE,
- PARAM_DUE_DATE,
- PARAM_SORT,
PARAM_STATE,
RELATIVE_POSITION_ASC,
TOKEN_TYPE_ASSIGNEE,
@@ -41,37 +53,23 @@ import {
TOKEN_TYPE_TYPE,
UPDATED_DESC,
urlSortParams,
-} from '~/issues/list/constants';
+} from '../constants';
+import eventHub from '../eventhub';
+import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql';
+import searchLabelsQuery from '../queries/search_labels.query.graphql';
+import searchMilestonesQuery from '../queries/search_milestones.query.graphql';
+import searchUsersQuery from '../queries/search_users.query.graphql';
+import setSortPreferenceMutation from '../queries/set_sort_preference.mutation.graphql';
import {
convertToApiParams,
convertToSearchQuery,
convertToUrlParams,
- getDueDateValue,
getFilterTokens,
getInitialPageParams,
getSortKey,
getSortOptions,
-} from '~/issues/list/utils';
-import axios from '~/lib/utils/axios_utils';
-import { scrollUp } from '~/lib/utils/scroll_utils';
-import { getParameterByName, joinPaths } from '~/lib/utils/url_utility';
-import {
- DEFAULT_NONE_ANY,
- OPERATOR_IS_ONLY,
- TOKEN_TITLE_ASSIGNEE,
- TOKEN_TITLE_AUTHOR,
- TOKEN_TITLE_CONFIDENTIAL,
- TOKEN_TITLE_LABEL,
- TOKEN_TITLE_MILESTONE,
- TOKEN_TITLE_MY_REACTION,
- TOKEN_TITLE_RELEASE,
- TOKEN_TITLE_TYPE,
-} from '~/vue_shared/components/filtered_search_bar/constants';
-import eventHub from '../eventhub';
-import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql';
-import searchLabelsQuery from '../queries/search_labels.query.graphql';
-import searchMilestonesQuery from '../queries/search_milestones.query.graphql';
-import searchUsersQuery from '../queries/search_users.query.graphql';
+ isSortKey,
+} from '../utils';
import NewIssueDropdown from './new_issue_dropdown.vue';
const AuthorToken = () =>
@@ -103,74 +101,31 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- inject: {
- autocompleteAwardEmojisPath: {
- default: '',
- },
- calendarPath: {
- default: '',
- },
- canBulkUpdate: {
- default: false,
- },
- emptyStateSvgPath: {
- default: '',
- },
- exportCsvPath: {
- default: '',
- },
- fullPath: {
- default: '',
- },
- hasAnyIssues: {
- default: false,
- },
- hasAnyProjects: {
- default: false,
- },
- hasBlockedIssuesFeature: {
- default: false,
- },
- hasIssueWeightsFeature: {
- default: false,
- },
- hasMultipleIssueAssigneesFeature: {
- default: false,
- },
- initialEmail: {
- default: '',
- },
- isAnonymousSearchDisabled: {
- default: false,
- },
- isIssueRepositioningDisabled: {
- default: false,
- },
- isProject: {
- default: false,
- },
- isSignedIn: {
- default: false,
- },
- jiraIntegrationPath: {
- default: '',
- },
- newIssuePath: {
- default: '',
- },
- releasesPath: {
- default: '',
- },
- rssPath: {
- default: '',
- },
- showNewIssueLink: {
- default: false,
- },
- signInPath: {
- default: '',
- },
- },
+ inject: [
+ 'autocompleteAwardEmojisPath',
+ 'calendarPath',
+ 'canBulkUpdate',
+ 'emptyStateSvgPath',
+ 'exportCsvPath',
+ 'fullPath',
+ 'hasAnyIssues',
+ 'hasAnyProjects',
+ 'hasBlockedIssuesFeature',
+ 'hasIssueWeightsFeature',
+ 'hasMultipleIssueAssigneesFeature',
+ 'initialEmail',
+ 'initialSort',
+ 'isAnonymousSearchDisabled',
+ 'isIssueRepositioningDisabled',
+ 'isProject',
+ 'isSignedIn',
+ 'jiraIntegrationPath',
+ 'newIssuePath',
+ 'releasesPath',
+ 'rssPath',
+ 'showNewIssueLink',
+ 'signInPath',
+ ],
props: {
eeSearchTokens: {
type: Array,
@@ -181,7 +136,13 @@ export default {
data() {
const state = getParameterByName(PARAM_STATE);
const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC;
- let sortKey = getSortKey(getParameterByName(PARAM_SORT)) || defaultSortKey;
+ const dashboardSortKey = getSortKey(this.initialSort);
+ const graphQLSortKey =
+ isSortKey(this.initialSort?.toUpperCase()) && this.initialSort.toUpperCase();
+
+ // The initial sort is an old enum value when it is saved on the dashboard issues page.
+ // The initial sort is a GraphQL enum value when it is saved on the Vue issues list page.
+ let sortKey = dashboardSortKey || graphQLSortKey || defaultSortKey;
if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) {
this.showIssueRepositioningMessage();
@@ -198,7 +159,6 @@ export default {
}
return {
- dueDateFilter: getDueDateValue(getParameterByName(PARAM_DUE_DATE)),
exportCsvPathWithQuery: this.getExportCsvPathWithQuery(),
filterTokens: isSearchDisabled ? [] : getFilterTokens(window.location.search),
issues: [],
@@ -221,6 +181,9 @@ export default {
return data[this.namespace]?.issues.nodes ?? [];
},
result({ data }) {
+ if (!data) {
+ return;
+ }
this.pageInfo = data[this.namespace]?.issues.pageInfo ?? {};
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
},
@@ -341,6 +304,7 @@ export default {
token: MilestoneToken,
fetchMilestones: this.fetchMilestones,
recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-milestone`,
+ shouldSkipSort: true,
},
{
type: TOKEN_TYPE_LABEL,
@@ -406,7 +370,7 @@ export default {
tokens.sort((a, b) => a.title.localeCompare(b.title));
- return orderBy(tokens, ['title']);
+ return tokens;
},
showPaginationControls() {
return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage);
@@ -427,7 +391,6 @@ export default {
},
urlParams() {
return {
- due_date: this.dueDateFilter,
search: this.searchQuery,
sort: urlSortParams[this.sortKey],
state: this.state,
@@ -584,7 +547,6 @@ export default {
.put(joinPaths(issueToMove.webPath, 'reorder'), {
move_before_id: isMovingToBeginning ? null : getIdFromGraphQLId(moveBeforeId),
move_after_id: isMovingToEnd ? null : getIdFromGraphQLId(moveAfterId),
- group_full_path: this.isProject ? undefined : this.fullPath,
})
.then(() => {
const serializedVariables = JSON.stringify(this.queryVariables);
@@ -608,6 +570,25 @@ export default {
this.pageParams = getInitialPageParams(sortKey);
}
this.sortKey = sortKey;
+
+ if (this.isSignedIn) {
+ this.saveSortPreference(sortKey);
+ }
+ },
+ saveSortPreference(sortKey) {
+ this.$apollo
+ .mutate({
+ mutation: setSortPreferenceMutation,
+ variables: { input: { issuesSort: sortKey } },
+ })
+ .then(({ data }) => {
+ if (data.userPreferencesUpdate.errors.length) {
+ throw new Error(data.userPreferencesUpdate.errors);
+ }
+ })
+ .catch((error) => {
+ Sentry.captureException(error);
+ });
},
showAnonymousSearchingMessage() {
createFlash({
@@ -644,6 +625,7 @@ export default {
:tabs="$options.IssuableListTabs"
:current-tab="state"
:tab-counts="tabCounts"
+ :truncate-counts="!isProject"
:issuables-loading="$apollo.queries.issues.loading"
:is-manual-ordering="isManualOrdering"
:show-bulk-edit-sidebar="showBulkEditSidebar"
diff --git a/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue b/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue
index 71f84050ba8..666e80dfd4b 100644
--- a/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue
+++ b/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue
@@ -7,10 +7,10 @@ import {
GlSearchBoxByType,
} from '@gitlab/ui';
import createFlash from '~/flash';
-import searchProjectsQuery from '~/issues/list/queries/search_projects.query.graphql';
import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
+import searchProjectsQuery from '../queries/search_projects.query.graphql';
export default {
i18n: {
diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js
index 4a380848b4f..284167a933f 100644
--- a/app/assets/javascripts/issues/list/constants.js
+++ b/app/assets/javascripts/issues/list/constants.js
@@ -55,8 +55,6 @@ export const i18n = {
export const MAX_LIST_SIZE = 10;
export const PAGE_SIZE = 20;
export const PAGE_SIZE_MANUAL = 100;
-export const PARAM_DUE_DATE = 'due_date';
-export const PARAM_SORT = 'sort';
export const PARAM_STATE = 'state';
export const RELATIVE_POSITION = 'relative_position';
@@ -68,21 +66,6 @@ export const largePageSizeParams = {
firstPageSize: PAGE_SIZE_MANUAL,
};
-export const DUE_DATE_NONE = '0';
-export const DUE_DATE_ANY = '';
-export const DUE_DATE_OVERDUE = 'overdue';
-export const DUE_DATE_WEEK = 'week';
-export const DUE_DATE_MONTH = 'month';
-export const DUE_DATE_NEXT_MONTH_AND_PREVIOUS_TWO_WEEKS = 'next_month_and_previous_two_weeks';
-export const DUE_DATE_VALUES = [
- DUE_DATE_NONE,
- DUE_DATE_ANY,
- DUE_DATE_OVERDUE,
- DUE_DATE_WEEK,
- DUE_DATE_MONTH,
- DUE_DATE_NEXT_MONTH_AND_PREVIOUS_TWO_WEEKS,
-];
-
export const BLOCKING_ISSUES_ASC = 'BLOCKING_ISSUES_ASC';
export const BLOCKING_ISSUES_DESC = 'BLOCKING_ISSUES_DESC';
export const CREATED_ASC = 'CREATED_ASC';
diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js
index 01cc82ed8fd..3b2d37eab74 100644
--- a/app/assets/javascripts/issues/list/index.js
+++ b/app/assets/javascripts/issues/list/index.js
@@ -30,6 +30,7 @@ export function mountJiraIssuesListApp() {
return new Vue({
el,
+ name: 'JiraIssuesImportStatusRoot',
apolloProvider,
render(createComponent) {
return createComponent(JiraIssuesImportStatusRoot, {
@@ -99,6 +100,7 @@ export function mountIssuesListApp() {
hasMultipleIssueAssigneesFeature,
importCsvIssuesPath,
initialEmail,
+ initialSort,
isAnonymousSearchDisabled,
isIssueRepositioningDisabled,
isProject,
@@ -118,6 +120,7 @@ export function mountIssuesListApp() {
return new Vue({
el,
+ name: 'IssuesListRoot',
apolloProvider,
provide: {
autocompleteAwardEmojisPath,
@@ -133,6 +136,7 @@ export function mountIssuesListApp() {
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
hasIterationsFeature: parseBoolean(hasIterationsFeature),
hasMultipleIssueAssigneesFeature: parseBoolean(hasMultipleIssueAssigneesFeature),
+ initialSort,
isAnonymousSearchDisabled: parseBoolean(isAnonymousSearchDisabled),
isIssueRepositioningDisabled: parseBoolean(isIssueRepositioningDisabled),
isProject: parseBoolean(isProject),
diff --git a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
index 07dae3fd756..430d494deab 100644
--- a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
+++ b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
@@ -1,4 +1,5 @@
fragment IssueFragment on Issue {
+ __typename
id
iid
closedAt
@@ -18,6 +19,7 @@ fragment IssueFragment on Issue {
webUrl
assignees {
nodes {
+ __typename
id
avatarUrl
name
@@ -26,6 +28,7 @@ fragment IssueFragment on Issue {
}
}
author {
+ __typename
id
avatarUrl
name
diff --git a/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql b/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql
index e7eb08104a6..040240cde99 100644
--- a/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql
@@ -3,7 +3,13 @@
query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = false) {
group(fullPath: $fullPath) @skip(if: $isProject) {
id
- milestones(searchTitle: $search, includeAncestors: true, includeDescendants: true) {
+ milestones(
+ searchTitle: $search
+ includeAncestors: true
+ includeDescendants: true
+ sort: EXPIRED_LAST_DUE_DATE_ASC
+ state: active
+ ) {
nodes {
...Milestone
}
@@ -11,7 +17,12 @@ query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = fa
}
project(fullPath: $fullPath) @include(if: $isProject) {
id
- milestones(searchTitle: $search, includeAncestors: true) {
+ milestones(
+ searchTitle: $search
+ includeAncestors: true
+ sort: EXPIRED_LAST_DUE_DATE_ASC
+ state: active
+ ) {
nodes {
...Milestone
}
diff --git a/app/assets/javascripts/issues/list/queries/set_sort_preference.mutation.graphql b/app/assets/javascripts/issues/list/queries/set_sort_preference.mutation.graphql
new file mode 100644
index 00000000000..ed7b5193c9b
--- /dev/null
+++ b/app/assets/javascripts/issues/list/queries/set_sort_preference.mutation.graphql
@@ -0,0 +1,5 @@
+mutation setSortPreference($input: UserPreferencesUpdateInput!) {
+ userPreferencesUpdate(input: $input) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js
index 2919bbbfef8..6322968b3f0 100644
--- a/app/assets/javascripts/issues/list/utils.js
+++ b/app/assets/javascripts/issues/list/utils.js
@@ -1,3 +1,9 @@
+import { isPositiveInteger } from '~/lib/utils/number_utils';
+import { __ } from '~/locale';
+import {
+ FILTERED_SEARCH_TERM,
+ OPERATOR_IS_NOT,
+} from '~/vue_shared/components/filtered_search_bar/constants';
import {
API_PARAM,
BLOCKING_ISSUES_ASC,
@@ -7,7 +13,6 @@ import {
defaultPageSizeParams,
DUE_DATE_ASC,
DUE_DATE_DESC,
- DUE_DATE_VALUES,
filters,
LABEL_PRIORITY_ASC,
LABEL_PRIORITY_DESC,
@@ -36,13 +41,7 @@ import {
urlSortParams,
WEIGHT_ASC,
WEIGHT_DESC,
-} from '~/issues/list/constants';
-import { isPositiveInteger } from '~/lib/utils/number_utils';
-import { __ } from '~/locale';
-import {
- FILTERED_SEARCH_TERM,
- OPERATOR_IS_NOT,
-} from '~/vue_shared/components/filtered_search_bar/constants';
+} from './constants';
export const getInitialPageParams = (sortKey) =>
sortKey === RELATIVE_POSITION_ASC ? largePageSizeParams : defaultPageSizeParams;
@@ -50,7 +49,7 @@ export const getInitialPageParams = (sortKey) =>
export const getSortKey = (sort) =>
Object.keys(urlSortParams).find((key) => urlSortParams[key] === sort);
-export const getDueDateValue = (value) => (DUE_DATE_VALUES.includes(value) ? value : undefined);
+export const isSortKey = (sort) => Object.keys(urlSortParams).includes(sort);
export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) => {
const sortOptions = [
diff --git a/app/assets/javascripts/issues/manual_ordering.js b/app/assets/javascripts/issues/manual_ordering.js
index c78505d0610..8fb891f62f7 100644
--- a/app/assets/javascripts/issues/manual_ordering.js
+++ b/app/assets/javascripts/issues/manual_ordering.js
@@ -7,12 +7,11 @@ import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
-const updateIssue = (url, issueList, { move_before_id, move_after_id }) =>
+const updateIssue = (url, { move_before_id, move_after_id }) =>
axios
.put(`${url}/reorder`, {
move_before_id,
move_after_id,
- group_full_path: issueList.dataset.groupFullPath,
})
.catch(() => {
createFlash({
@@ -52,7 +51,7 @@ const initManualOrdering = () => {
const beforeId = prev && parseInt(prev.dataset.id, 10);
const afterId = next && parseInt(next.dataset.id, 10);
- updateIssue(url, issueList, { move_after_id: afterId, move_before_id: beforeId });
+ updateIssue(url, { move_after_id: afterId, move_before_id: beforeId });
},
}),
);
diff --git a/app/assets/javascripts/issues/new/index.js b/app/assets/javascripts/issues/new/index.js
index f96cacf2595..91599502996 100644
--- a/app/assets/javascripts/issues/new/index.js
+++ b/app/assets/javascripts/issues/new/index.js
@@ -20,6 +20,7 @@ export function initTitleSuggestions() {
return new Vue({
el,
+ name: 'TitleSuggestionsRoot',
apolloProvider,
data() {
return {
@@ -51,6 +52,7 @@ export function initTypePopover() {
return new Vue({
el,
+ name: 'TypePopoverRoot',
render: (createElement) => createElement(TypePopover),
});
}
diff --git a/app/assets/javascripts/issues/related_merge_requests/index.js b/app/assets/javascripts/issues/related_merge_requests/index.js
index 5045f7e1a2a..196084093c8 100644
--- a/app/assets/javascripts/issues/related_merge_requests/index.js
+++ b/app/assets/javascripts/issues/related_merge_requests/index.js
@@ -13,6 +13,7 @@ export function initRelatedMergeRequests() {
return new Vue({
el,
+ name: 'RelatedMergeRequestsRoot',
store: createStore(),
render: (createElement) =>
createElement(RelatedMergeRequests, {
diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue
index 7be4c13f544..eeccf886b65 100644
--- a/app/assets/javascripts/issues/show/components/description.vue
+++ b/app/assets/javascripts/issues/show/components/description.vue
@@ -1,18 +1,31 @@
<script>
-import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import {
+ GlSafeHtmlDirective as SafeHtml,
+ GlModal,
+ GlModalDirective,
+ GlPopover,
+ GlButton,
+} from '@gitlab/ui';
import $ from 'jquery';
import createFlash from '~/flash';
import { __, sprintf } from '~/locale';
import TaskList from '~/task_list';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import CreateWorkItem from '~/work_items/pages/create_work_item.vue';
import animateMixin from '../mixins/animate';
export default {
directives: {
SafeHtml,
+ GlModal: GlModalDirective,
},
-
- mixins: [animateMixin],
-
+ components: {
+ GlModal,
+ GlPopover,
+ CreateWorkItem,
+ GlButton,
+ },
+ mixins: [animateMixin, glFeatureFlagMixin()],
props: {
canUpdate: {
type: Boolean,
@@ -53,8 +66,15 @@ export default {
preAnimation: false,
pulseAnimation: false,
initialUpdate: true,
+ taskButtons: [],
+ activeTask: {},
};
},
+ computed: {
+ workItemsEnabled() {
+ return this.glFeatures.workItems;
+ },
+ },
watch: {
descriptionHtml(newDescription, oldDescription) {
if (!this.initialUpdate && newDescription !== oldDescription) {
@@ -74,6 +94,10 @@ export default {
mounted() {
this.renderGFM();
this.updateTaskStatusText();
+
+ if (this.workItemsEnabled) {
+ this.renderTaskActions();
+ }
},
methods: {
renderGFM() {
@@ -132,6 +156,63 @@ export default {
$tasksShort.text('');
}
},
+ renderTaskActions() {
+ if (!this.$el?.querySelectorAll) {
+ return;
+ }
+
+ const taskListFields = this.$el.querySelectorAll('.task-list-item');
+
+ taskListFields.forEach((item, index) => {
+ const button = document.createElement('button');
+ button.classList.add(
+ 'btn',
+ 'btn-default',
+ 'btn-md',
+ 'gl-button',
+ 'btn-default-tertiary',
+ 'gl-left-0',
+ 'gl-p-0!',
+ 'gl-top-2',
+ 'gl-absolute',
+ 'js-add-task',
+ );
+ button.id = `js-task-button-${index}`;
+ this.taskButtons.push(button.id);
+ button.innerHTML = `
+ <svg data-testid="ellipsis_v-icon" role="img" aria-hidden="true" class="dropdown-icon gl-icon s14">
+ <use href="${gon.sprite_icons}#ellipsis_v"></use>
+ </svg>
+ `;
+ item.prepend(button);
+ });
+ },
+ openCreateTaskModal(id) {
+ this.activeTask = { id, title: this.$el.querySelector(`#${id}`).parentElement.innerText };
+ this.$refs.modal.show();
+ },
+ closeCreateTaskModal() {
+ this.$refs.modal.hide();
+ },
+ handleCreateTask(title) {
+ const listItem = this.$el.querySelector(`#${this.activeTask.id}`).parentElement;
+ const taskBadge = document.createElement('span');
+ taskBadge.innerHTML = `
+ <svg data-testid="issue-open-m-icon" role="img" aria-hidden="true" class="gl-icon gl-fill-green-500 s12">
+ <use href="${gon.sprite_icons}#issue-open-m"></use>
+ </svg>
+ <span class="badge badge-info badge-pill gl-badge sm gl-mr-1">
+ ${__('Task')}
+ </span>
+ <a href="#">${title}</a>
+ `;
+ listItem.insertBefore(taskBadge, listItem.lastChild);
+ listItem.removeChild(listItem.lastChild);
+ this.closeCreateTaskModal();
+ },
+ focusButton() {
+ this.$refs.convertButton[0].$el.focus();
+ },
},
safeHtmlConfig: { ADD_TAGS: ['gl-emoji', 'copy-code'] },
};
@@ -142,12 +223,14 @@ export default {
v-if="descriptionHtml"
:class="{
'js-task-list-container': canUpdate,
+ 'work-items-enabled': workItemsEnabled,
}"
class="description"
>
<div
ref="gfm-content"
v-safe-html:[$options.safeHtmlConfig]="descriptionHtml"
+ data-testid="gfm-content"
:class="{
'issue-realtime-pre-pulse': preAnimation,
'issue-realtime-trigger-pulse': pulseAnimation,
@@ -157,13 +240,46 @@ export default {
<!-- eslint-disable vue/no-mutating-props -->
<textarea
v-if="descriptionText"
- ref="textarea"
v-model="descriptionText"
:data-update-url="updateUrl"
class="hidden js-task-list-field"
dir="auto"
+ data-testid="textarea"
>
</textarea>
<!-- eslint-enable vue/no-mutating-props -->
+ <gl-modal
+ ref="modal"
+ modal-id="create-task-modal"
+ :title="s__('WorkItem|New Task')"
+ hide-footer
+ body-class="gl-p-0!"
+ >
+ <create-work-item
+ :is-modal="true"
+ :initial-title="activeTask.title"
+ @closeModal="closeCreateTaskModal"
+ @onCreate="handleCreateTask"
+ />
+ </gl-modal>
+ <template v-if="workItemsEnabled">
+ <gl-popover
+ v-for="item in taskButtons"
+ :key="item"
+ :target="item"
+ placement="top"
+ triggers="focus"
+ @shown="focusButton"
+ >
+ <gl-button
+ ref="convertButton"
+ variant="link"
+ data-testid="convert-to-task"
+ class="gl-text-gray-900! gl-text-decoration-none! gl-outline-0!"
+ @click="openCreateTaskModal(item)"
+ >{{ s__('WorkItem|Convert to work item') }}</gl-button
+ >
+ </gl-popover>
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue
index 5476a1ef897..d5ac7b28afc 100644
--- a/app/assets/javascripts/issues/show/components/fields/description.vue
+++ b/app/assets/javascripts/issues/show/components/fields/description.vue
@@ -1,13 +1,12 @@
<script>
import markdownField from '~/vue_shared/components/markdown/field.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import updateMixin from '../../mixins/update';
export default {
components: {
markdownField,
},
- mixins: [glFeatureFlagsMixin(), updateMixin],
+ mixins: [updateMixin],
props: {
formState: {
type: Object,
@@ -56,7 +55,7 @@ export default {
v-model="formState.description"
class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea"
dir="auto"
- :data-supports-quick-actions="!glFeatures.tributeAutocomplete"
+ data-supports-quick-actions="true"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
@keydown.meta.enter="updateIssuable"
diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js
index 7f5a0e32f72..f5c71f9691f 100644
--- a/app/assets/javascripts/issues/show/index.js
+++ b/app/assets/javascripts/issues/show/index.js
@@ -44,6 +44,7 @@ export function initIncidentApp(issueData = {}) {
return new Vue({
el,
+ name: 'DescriptionRoot',
apolloProvider,
provide: {
issueType: INCIDENT_TYPE,
@@ -74,6 +75,8 @@ export function initIssueApp(issueData, store) {
return undefined;
}
+ const { fullPath } = el.dataset;
+
if (gon?.features?.fixCommentScroll) {
scrollToTargetOnResize();
}
@@ -84,10 +87,12 @@ export function initIssueApp(issueData, store) {
return new Vue({
el,
+ name: 'DescriptionRoot',
apolloProvider,
store,
provide: {
canCreateIncident,
+ fullPath,
},
computed: {
...mapGetters(['getNoteableData']),
@@ -120,6 +125,7 @@ export function initHeaderActions(store, type = '') {
return new Vue({
el,
+ name: 'HeaderActionsRoot',
apolloProvider,
store,
provide: {
@@ -154,6 +160,7 @@ export function initSentryErrorStackTrace() {
return new Vue({
el,
+ name: 'SentryErrorStackTraceRoot',
store: errorTrackingStore,
render: (createElement) =>
createElement(SentryErrorStackTrace, { props: { issueStackTracePath } }),
diff --git a/app/assets/javascripts/jira_connect/branches/components/new_branch_form.vue b/app/assets/javascripts/jira_connect/branches/components/new_branch_form.vue
index 66fcb8e10eb..46c27c33f56 100644
--- a/app/assets/javascripts/jira_connect/branches/components/new_branch_form.vue
+++ b/app/assets/javascripts/jira_connect/branches/components/new_branch_form.vue
@@ -1,5 +1,6 @@
<script>
-import { GlFormGroup, GlButton, GlFormInput, GlForm, GlAlert } from '@gitlab/ui';
+import { GlFormGroup, GlButton, GlFormInput, GlForm, GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
import {
CREATE_BRANCH_ERROR_GENERIC,
CREATE_BRANCH_ERROR_WITH_CONTEXT,
@@ -7,6 +8,7 @@ import {
I18N_NEW_BRANCH_LABEL_BRANCH,
I18N_NEW_BRANCH_LABEL_SOURCE,
I18N_NEW_BRANCH_SUBMIT_BUTTON_TEXT,
+ I18N_NEW_BRANCH_PERMISSION_ALERT,
} from '../constants';
import createBranchMutation from '../graphql/mutations/create_branch.mutation.graphql';
import ProjectDropdown from './project_dropdown.vue';
@@ -17,6 +19,8 @@ const DEFAULT_ALERT_PARAMS = {
title: '',
message: '',
variant: DEFAULT_ALERT_VARIANT,
+ link: undefined,
+ dismissible: true,
};
export default {
@@ -27,10 +31,16 @@ export default {
GlFormInput,
GlForm,
GlAlert,
+ GlSprintf,
+ GlLink,
ProjectDropdown,
SourceBranchDropdown,
},
- inject: ['initialBranchName'],
+ inject: {
+ initialBranchName: {
+ default: '',
+ },
+ },
data() {
return {
selectedProject: null,
@@ -40,6 +50,7 @@ export default {
alertParams: {
...DEFAULT_ALERT_PARAMS,
},
+ hasPermission: false,
};
},
computed: {
@@ -49,19 +60,38 @@ export default {
showAlert() {
return Boolean(this.alertParams?.message);
},
+ isBranchNameValid() {
+ return (this.branchName ?? '').trim().length > 0;
+ },
disableSubmitButton() {
- return !(this.selectedProject && this.selectedSourceBranchName && this.branchName);
+ return !(this.selectedProject && this.selectedSourceBranchName && this.isBranchNameValid);
},
},
methods: {
- displayAlert({ title, message, variant = DEFAULT_ALERT_VARIANT } = {}) {
+ displayAlert({
+ title,
+ message,
+ variant = DEFAULT_ALERT_VARIANT,
+ link,
+ dismissible = true,
+ } = {}) {
this.alertParams = {
title,
message,
variant,
+ link,
+ dismissible,
};
},
- onAlertDismiss() {
+ setPermissionAlert() {
+ this.displayAlert({
+ message: I18N_NEW_BRANCH_PERMISSION_ALERT,
+ variant: 'warning',
+ link: helpPagePath('user/permissions', { anchor: 'project-members-permissions' }),
+ dismissible: false,
+ });
+ },
+ dismissAlert() {
this.alertParams = {
...DEFAULT_ALERT_PARAMS,
};
@@ -69,6 +99,14 @@ export default {
onProjectSelect(project) {
this.selectedProject = project;
this.selectedSourceBranchName = null; // reset branch selection
+ this.hasPermission = this.selectedProject.userPermissions.pushCode;
+
+ if (!this.hasPermission) {
+ this.setPermissionAlert();
+ } else {
+ // clear alert if the user has permissions for the newly-selected project.
+ this.dismissAlert();
+ }
},
onSourceBranchSelect(branchName) {
this.selectedSourceBranchName = branchName;
@@ -127,10 +165,18 @@ export default {
class="gl-mb-5"
:variant="alertParams.variant"
:title="alertParams.title"
- @dismiss="onAlertDismiss"
+ :dismissible="alertParams.dismissible"
+ @dismiss="dismissAlert"
>
- {{ alertParams.message }}
+ <gl-sprintf :message="alertParams.message">
+ <template #link="{ content }">
+ <gl-link :href="alertParams.link" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
</gl-alert>
+
<gl-form-group :label="$options.i18n.I18N_NEW_BRANCH_LABEL_DROPDOWN" label-for="project-select">
<project-dropdown
id="project-select"
@@ -140,25 +186,28 @@ export default {
/>
</gl-form-group>
- <gl-form-group
- :label="$options.i18n.I18N_NEW_BRANCH_LABEL_BRANCH"
- label-for="branch-name-input"
- >
- <gl-form-input id="branch-name-input" v-model="branchName" type="text" required />
- </gl-form-group>
+ <template v-if="selectedProject && hasPermission">
+ <gl-form-group
+ :label="$options.i18n.I18N_NEW_BRANCH_LABEL_SOURCE"
+ label-for="source-branch-select"
+ >
+ <source-branch-dropdown
+ id="source-branch-select"
+ :selected-project="selectedProject"
+ :selected-branch-name="selectedSourceBranchName"
+ @change="onSourceBranchSelect"
+ @error="onError"
+ />
+ </gl-form-group>
- <gl-form-group
- :label="$options.i18n.I18N_NEW_BRANCH_LABEL_SOURCE"
- label-for="source-branch-select"
- >
- <source-branch-dropdown
- id="source-branch-select"
- :selected-project="selectedProject"
- :selected-branch-name="selectedSourceBranchName"
- @change="onSourceBranchSelect"
- @error="onError"
- />
- </gl-form-group>
+ <gl-form-group
+ :label="$options.i18n.I18N_NEW_BRANCH_LABEL_BRANCH"
+ label-for="branch-name-input"
+ class="gl-max-w-62"
+ >
+ <gl-form-input id="branch-name-input" v-model="branchName" type="text" required />
+ </gl-form-group>
+ </template>
<div class="form-actions">
<gl-button
diff --git a/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue b/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue
index 751f3e9639d..88005cccd89 100644
--- a/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue
+++ b/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue
@@ -1,5 +1,11 @@
<script>
-import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui';
+import {
+ GlDropdown,
+ GlSearchBoxByType,
+ GlLoadingIcon,
+ GlDropdownItem,
+ GlAvatarLabeled,
+} from '@gitlab/ui';
import { __ } from '~/locale';
import { PROJECTS_PER_PAGE } from '../constants';
import getProjectsQuery from '../graphql/queries/get_projects.query.graphql';
@@ -14,6 +20,7 @@ export default {
GlDropdownItem,
GlSearchBoxByType,
GlLoadingIcon,
+ GlAvatarLabeled,
},
props: {
selectedProject: {
@@ -56,7 +63,7 @@ export default {
return Boolean(this.$apollo.queries.projects.loading);
},
projectDropdownText() {
- return this.selectedProject?.nameWithNamespace || __('Select a project');
+ return this.selectedProject?.nameWithNamespace || this.$options.i18n.selectProjectText;
},
},
methods: {
@@ -70,11 +77,19 @@ export default {
return project.id === this.selectedProject?.id;
},
},
+ i18n: {
+ selectProjectText: __('Select a project'),
+ },
};
</script>
<template>
- <gl-dropdown :text="projectDropdownText" :loading="initialProjectsLoading">
+ <gl-dropdown
+ :text="projectDropdownText"
+ :loading="initialProjectsLoading"
+ menu-class="gl-w-auto!"
+ :header-text="$options.i18n.selectProjectText"
+ >
<template #header>
<gl-search-box-by-type v-model.trim="projectSearchQuery" :debounce="250" />
</template>
@@ -85,10 +100,20 @@ export default {
v-for="project in projects"
:key="project.id"
is-check-item
+ is-check-centered
:is-checked="isProjectSelected(project)"
+ :data-testid="`test-project-${project.id}`"
@click="onProjectSelect(project)"
>
- {{ project.nameWithNamespace }}
+ <gl-avatar-labeled
+ class="gl-text-truncate"
+ shape="rect"
+ :size="32"
+ :src="project.avatarUrl"
+ :label="project.name"
+ :entity-name="project.name"
+ :sub-label="project.nameWithNamespace"
+ />
</gl-dropdown-item>
</template>
</gl-dropdown>
diff --git a/app/assets/javascripts/jira_connect/branches/constants.js b/app/assets/javascripts/jira_connect/branches/constants.js
index ab9d3b2c110..43be774ce7c 100644
--- a/app/assets/javascripts/jira_connect/branches/constants.js
+++ b/app/assets/javascripts/jira_connect/branches/constants.js
@@ -23,3 +23,6 @@ export const I18N_NEW_BRANCH_SUCCESS_TITLE = s__(
export const I18N_NEW_BRANCH_SUCCESS_MESSAGE = s__(
'JiraConnect|You can now close this window and return to Jira.',
);
+export const I18N_NEW_BRANCH_PERMISSION_ALERT = s__(
+ "JiraConnect|You don't have permission to create branches for this project. Select a different project or contact the project owner for access. %{linkStart}Learn more.%{linkEnd}",
+);
diff --git a/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql
index 32fbc1113bc..03e8e3e986b 100644
--- a/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql
+++ b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql
@@ -26,6 +26,9 @@ query jiraGetProjects(
repository {
empty
}
+ userPermissions {
+ pushCode
+ }
}
pageInfo {
...PageInfo
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue
index 5a49d7c1a90..7f035dddafe 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue
@@ -30,7 +30,8 @@ export default {
page: 1,
totalItems: 0,
errorMessage: null,
- searchTerm: '',
+ userSearchTerm: '',
+ searchValue: '',
};
},
computed: {
@@ -45,16 +46,11 @@ export default {
},
methods: {
loadGroups() {
- // fetchGroups returns no results for search terms 0 < {length} < 3.
- // The desired UX is to return the unfiltered results for searches {length} < 3.
- // Here, we set the search to an empty string if {length} < 3
- const search = this.searchTerm?.length < MINIMUM_SEARCH_TERM_LENGTH ? '' : this.searchTerm;
-
this.isLoadingMore = true;
return fetchGroups(this.groupsPath, {
page: this.page,
perPage: this.$options.DEFAULT_GROUPS_PER_PAGE,
- search,
+ search: this.searchValue,
})
.then((response) => {
const { page, total } = parseIntPagination(normalizeHeaders(response.headers));
@@ -69,12 +65,24 @@ export default {
this.isLoadingMore = false;
});
},
- onGroupSearch(searchTerm) {
- // keep a copy of the search term for pagination
- this.searchTerm = searchTerm;
- // reset the current page
+ onGroupSearch(userSearchTerm = '') {
+ this.userSearchTerm = userSearchTerm;
+
+ // fetchGroups returns no results for search terms 0 < {length} < 3.
+ // The desired UX is to return the unfiltered results for searches {length} < 3.
+ // Here, we set the search to an empty string '' if {length} < 3
+ const newSearchValue =
+ this.userSearchTerm.length < MINIMUM_SEARCH_TERM_LENGTH ? '' : this.userSearchTerm;
+
+ // don't fetch new results if the search value didn't change.
+ if (newSearchValue === this.searchValue) {
+ return;
+ }
+
+ // reset the page.
this.page = 1;
- return this.loadGroups();
+ this.searchValue = newSearchValue;
+ this.loadGroups();
},
},
DEFAULT_GROUPS_PER_PAGE,
@@ -92,7 +100,7 @@ export default {
debounce="500"
:placeholder="__('Search by name')"
:is-loading="isLoadingMore"
- :value="searchTerm"
+ :value="userSearchTerm"
@input="onGroupSearch"
/>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
index 7fd4cc38f11..905e242e977 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
@@ -1,13 +1,13 @@
<script>
-import { GlAlert, GlLink, GlSprintf, GlEmptyState } from '@gitlab/ui';
+import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { mapState, mapMutations } from 'vuex';
import { retrieveAlert } from '~/jira_connect/subscriptions/utils';
import { SET_ALERT } from '../store/mutation_types';
-import SubscriptionsList from './subscriptions_list.vue';
-import AddNamespaceButton from './add_namespace_button.vue';
-import SignInButton from './sign_in_button.vue';
+import SignInPage from '../pages/sign_in.vue';
+import SubscriptionsPage from '../pages/subscriptions.vue';
import UserLink from './user_link.vue';
+import CompatibilityAlert from './compatibility_alert.vue';
export default {
name: 'JiraConnectApp',
@@ -15,11 +15,10 @@ export default {
GlAlert,
GlLink,
GlSprintf,
- GlEmptyState,
- SubscriptionsList,
- AddNamespaceButton,
- SignInButton,
UserLink,
+ CompatibilityAlert,
+ SignInPage,
+ SubscriptionsPage,
},
inject: {
usersPath: {
@@ -58,11 +57,14 @@ export default {
<template>
<div>
+ <compatibility-alert />
+
<gl-alert
v-if="shouldShowAlert"
class="gl-mb-7"
:variant="alert.variant"
:title="alert.title"
+ data-testid="jira-connect-persisted-alert"
@dismiss="setAlert"
>
<gl-sprintf v-if="alert.linkUrl" :message="alert.message">
@@ -79,43 +81,9 @@ export default {
<user-link :user-signed-in="userSignedIn" :has-subscriptions="hasSubscriptions" />
<h2 class="gl-text-center gl-mb-7">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2>
- <div class="jira-connect-app-body gl-mx-auto gl-px-5 gl-mb-7">
- <template v-if="hasSubscriptions">
- <div class="gl-display-flex gl-justify-content-end">
- <sign-in-button v-if="!userSignedIn" :users-path="usersPath" />
- <add-namespace-button v-else />
- </div>
-
- <subscriptions-list />
- </template>
- <template v-else>
- <div v-if="!userSignedIn" class="gl-text-center">
- <p class="gl-mb-7">{{ s__('JiraService|Sign in to GitLab.com to get started.') }}</p>
- <sign-in-button class="gl-mb-7" :users-path="usersPath">
- {{ __('Sign in to GitLab') }}
- </sign-in-button>
- <p>
- {{
- s__(
- 'Integrations|Note: this integration only works with accounts on GitLab.com (SaaS).',
- )
- }}
- </p>
- </div>
- <gl-empty-state
- v-else
- :title="s__('Integrations|No linked namespaces')"
- :description="
- s__(
- 'Integrations|Namespaces are the GitLab groups and subgroups you link to this Jira instance.',
- )
- "
- >
- <template #actions>
- <add-namespace-button />
- </template>
- </gl-empty-state>
- </template>
+ <div class="gl-layout-w-limited gl-mx-auto gl-px-5 gl-mb-7">
+ <sign-in-page v-if="!userSignedIn" :has-subscriptions="hasSubscriptions" />
+ <subscriptions-page v-else :has-subscriptions="hasSubscriptions" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue b/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue
new file mode 100644
index 00000000000..3cfbd87ac53
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue
@@ -0,0 +1,63 @@
+<script>
+import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+
+const COMPATIBILITY_ALERT_STATE_KEY = 'compatibility_alert_dismissed';
+
+export default {
+ name: 'CompatibilityAlert',
+ components: {
+ GlAlert,
+ GlSprintf,
+ GlLink,
+ LocalStorageSync,
+ },
+ data() {
+ return {
+ alertDismissed: false,
+ };
+ },
+ computed: {
+ shouldShowAlert() {
+ return !this.alertDismissed;
+ },
+ },
+ methods: {
+ dismissAlert() {
+ this.alertDismissed = true;
+ },
+ },
+ i18n: {
+ title: s__('Integrations|Known limitations'),
+ body: s__(
+ 'Integrations|This integration only works with GitLab.com. Adding a namespace only works in browsers that allow cross-site cookies. %{linkStart}Learn more%{linkEnd}.',
+ ),
+ },
+ DOCS_LINK_URL: helpPagePath('integration/jira/connect-app'),
+ COMPATIBILITY_ALERT_STATE_KEY,
+};
+</script>
+<template>
+ <local-storage-sync
+ v-model="alertDismissed"
+ :storage-key="$options.COMPATIBILITY_ALERT_STATE_KEY"
+ >
+ <gl-alert
+ v-if="shouldShowAlert"
+ class="gl-mb-7"
+ variant="info"
+ :title="$options.i18n.title"
+ @dismiss="dismissAlert"
+ >
+ <gl-sprintf :message="$options.i18n.body">
+ <template #link="{ content }">
+ <gl-link :href="$options.DOCS_LINK_URL" target="_blank" rel="noopener noreferrer">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ </local-storage-sync>
+</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue
index dc0a77e99c2..627abcdd4a0 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue
@@ -1,6 +1,7 @@
<script>
import { GlButton } from '@gitlab/ui';
import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils';
+import { s__ } from '~/locale';
export default {
components: {
@@ -25,12 +26,15 @@ export default {
this.signInURL = await getGitlabSignInURL(this.usersPath);
},
},
+ i18n: {
+ defaultButtonText: s__('Integrations|Sign in to GitLab'),
+ },
};
</script>
<template>
<gl-button category="primary" variant="info" :href="signInURL" target="_blank">
<slot>
- {{ s__('Integrations|Sign in to add namespaces') }}
+ {{ $options.i18n.defaultButtonText }}
</slot>
</gl-button>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue
new file mode 100644
index 00000000000..2bce5afc72b
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue
@@ -0,0 +1,40 @@
+<script>
+import { s__ } from '~/locale';
+import SubscriptionsList from '../components/subscriptions_list.vue';
+import SignInButton from '../components/sign_in_button.vue';
+
+export default {
+ name: 'SignInPage',
+ components: {
+ SubscriptionsList,
+ SignInButton,
+ },
+ inject: ['usersPath'],
+ props: {
+ hasSubscriptions: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ i18n: {
+ signinButtonTextWithSubscriptions: s__('Integrations|Sign in to add namespaces'),
+ signInText: s__('JiraService|Sign in to GitLab.com to get started.'),
+ },
+};
+</script>
+
+<template>
+ <div v-if="hasSubscriptions">
+ <div class="gl-display-flex gl-justify-content-end">
+ <sign-in-button :users-path="usersPath">
+ {{ $options.i18n.signinButtonTextWithSubscriptions }}
+ </sign-in-button>
+ </div>
+
+ <subscriptions-list />
+ </div>
+ <div v-else class="gl-text-center">
+ <p class="gl-mb-7">{{ $options.i18n.signInText }}</p>
+ <sign-in-button class="gl-mb-7" :users-path="usersPath" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions.vue
new file mode 100644
index 00000000000..426f2999370
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions.vue
@@ -0,0 +1,43 @@
+<script>
+import { GlEmptyState } from '@gitlab/ui';
+import SubscriptionsList from '../components/subscriptions_list.vue';
+import AddNamespaceButton from '../components/add_namespace_button.vue';
+
+export default {
+ name: 'SubscriptionsPage',
+ components: {
+ GlEmptyState,
+ SubscriptionsList,
+ AddNamespaceButton,
+ },
+ props: {
+ hasSubscriptions: {
+ type: Boolean,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="hasSubscriptions">
+ <div class="gl-display-flex gl-justify-content-end">
+ <add-namespace-button />
+ </div>
+
+ <subscriptions-list />
+ </div>
+ <gl-empty-state
+ v-else
+ :title="s__('Integrations|No linked namespaces')"
+ :description="
+ s__(
+ 'Integrations|Namespaces are the GitLab groups and subgroups you link to this Jira instance.',
+ )
+ "
+ >
+ <template #actions>
+ <add-namespace-button />
+ </template>
+ </gl-empty-state>
+</template>
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 7dfa963a857..753a15871ab 100644
--- a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
+++ b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
@@ -58,6 +58,14 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ retryBtnDisabled: false,
+ cancelBtnDisabled: false,
+ playManualBtnDisabled: false,
+ unscheduleBtnDisabled: false,
+ };
+ },
computed: {
hasArtifacts() {
return this.job.artifacts.nodes.find((artifact) => artifact.fileType === FILE_TYPE_ARCHIVE);
@@ -132,15 +140,23 @@ export default {
});
},
cancelJob() {
+ this.cancelBtnDisabled = true;
+
this.postJobAction(this.$options.jobCancel, cancelJobMutation);
},
retryJob() {
+ this.retryBtnDisabled = true;
+
this.postJobAction(this.$options.jobRetry, retryJobMutation);
},
playJob() {
+ this.playManualBtnDisabled = true;
+
this.postJobAction(this.$options.jobPlay, playJobMutation);
},
unscheduleJob() {
+ this.unscheduleBtnDisabled = true;
+
this.postJobAction(this.$options.jobUnschedule, unscheduleJobMutation);
},
},
@@ -155,6 +171,7 @@ export default {
data-testid="cancel-button"
icon="cancel"
:title="$options.CANCEL"
+ :disabled="cancelBtnDisabled"
@click="cancelJob()"
/>
<template v-else-if="isScheduled">
@@ -179,6 +196,7 @@ export default {
<gl-button
icon="time-out"
:title="$options.ACTIONS_UNSCHEDULE"
+ :disabled="unscheduleBtnDisabled"
data-testid="unschedule"
@click="unscheduleJob()"
/>
@@ -189,6 +207,7 @@ export default {
v-if="manualJobPlayable"
icon="play"
:title="$options.ACTIONS_PLAY"
+ :disabled="playManualBtnDisabled"
data-testid="play"
@click="playJob()"
/>
@@ -197,6 +216,7 @@ export default {
icon="repeat"
:title="$options.ACTIONS_RETRY"
:method="currentJobMethod"
+ :disabled="retryBtnDisabled"
data-testid="retry"
@click="retryJob()"
/>
diff --git a/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue b/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue
index ba5732d3d43..19594c4955d 100644
--- a/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue
+++ b/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue
@@ -39,6 +39,7 @@ export default {
<time
v-gl-tooltip
:title="tooltipTitle(finishedTime)"
+ :datetime="finishedTime"
data-placement="top"
data-container="body"
>
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 c786d35ac68..81f42c1e293 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
@@ -51,7 +51,9 @@ export default {
},
data() {
return {
- jobs: {},
+ jobs: {
+ list: [],
+ },
hasError: false,
isAlertDismissed: false,
scope: null,
diff --git a/app/assets/javascripts/labels/index.js b/app/assets/javascripts/labels/index.js
index e87ad8d9a06..0d4113bba4c 100644
--- a/app/assets/javascripts/labels/index.js
+++ b/app/assets/javascripts/labels/index.js
@@ -11,6 +11,7 @@ import ProjectLabelSubscription from './project_label_subscription';
export function initDeleteLabelModal(optionalProps = {}) {
new Vue({
+ name: 'DeleteLabelModalRoot',
render(h) {
return h(DeleteLabelModal, {
props: {
@@ -65,6 +66,7 @@ export function initLabelIndex() {
return new Vue({
el: '#js-promote-label-modal',
+ name: 'PromoteLabelModal',
data() {
return {
modalProps: {
diff --git a/app/assets/javascripts/lib/apollo/instrumentation_link.js b/app/assets/javascripts/lib/apollo/instrumentation_link.js
index 2ab364557b8..bbe16d260e7 100644
--- a/app/assets/javascripts/lib/apollo/instrumentation_link.js
+++ b/app/assets/javascripts/lib/apollo/instrumentation_link.js
@@ -1,4 +1,4 @@
-import { ApolloLink } from 'apollo-link';
+import { ApolloLink } from '@apollo/client/core';
import { memoize } from 'lodash';
export const FEATURE_CATEGORY_HEADER = 'x-gitlab-feature-category';
diff --git a/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js b/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js
index 9b7901685b6..b2a86ac257b 100644
--- a/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js
+++ b/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js
@@ -1,5 +1,5 @@
-import { Observable } from 'apollo-link';
-import { onError } from 'apollo-link-error';
+import { Observable } from '@apollo/client/core';
+import { onError } from '@apollo/client/link/error';
import { isNavigatingAway } from '~/lib/utils/is_navigating_away';
/**
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index df2e85afe24..f533ba3671c 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -1,11 +1,9 @@
-import { InMemoryCache } from 'apollo-cache-inmemory';
-import { ApolloClient } from 'apollo-client';
-import { ApolloLink } from 'apollo-link';
-import { BatchHttpLink } from 'apollo-link-batch-http';
-import { HttpLink } from 'apollo-link-http';
+import { ApolloClient, InMemoryCache, ApolloLink, HttpLink } from '@apollo/client/core';
+import { BatchHttpLink } from '@apollo/client/link/batch-http';
import { createUploadLink } from 'apollo-upload-client';
import ActionCableLink from '~/actioncable_link';
import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link';
+import possibleTypes from '~/graphql_shared/possibleTypes.json';
import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link';
import csrf from '~/lib/utils/csrf';
import { objectToQuery, queryToObject } from '~/lib/utils/url_utility';
@@ -21,6 +19,36 @@ export const fetchPolicies = {
CACHE_ONLY: 'cache-only',
};
+export const typePolicies = {
+ Repository: {
+ merge: true,
+ },
+ UserPermissions: {
+ merge: true,
+ },
+ MergeRequestPermissions: {
+ merge: true,
+ },
+ ContainerRepositoryConnection: {
+ merge: true,
+ },
+ TimelogConnection: {
+ merge: true,
+ },
+ BranchList: {
+ merge: true,
+ },
+ InstanceSecurityDashboard: {
+ merge: true,
+ },
+ PipelinePermissions: {
+ merge: true,
+ },
+ DesignCollection: {
+ merge: true,
+ },
+};
+
export const stripWhitespaceFromQuery = (url, path) => {
/* eslint-disable-next-line no-unused-vars */
const [_, params] = url.split(path);
@@ -46,6 +74,30 @@ export const stripWhitespaceFromQuery = (url, path) => {
return `${path}?${reassembled}`;
};
+const acs = [];
+
+let pendingApolloMutations = 0;
+
+// ### Why track pendingApolloMutations, but calculate pendingApolloRequests?
+//
+// In Apollo 2, we had a single link for counting operations.
+//
+// With Apollo 3, the `forward().map(...)` of deduped queries is never called.
+// So, we resorted to calculating the sum of `inFlightLinkObservables?.size`.
+// However! Mutations don't use `inFLightLinkObservables`, but since they are likely
+// not deduped we can count them...
+//
+// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55062#note_838943715
+// https://www.apollographql.com/docs/react/v2/networking/network-layer/#query-deduplication
+Object.defineProperty(window, 'pendingApolloRequests', {
+ get() {
+ return acs.reduce(
+ (sum, ac) => sum + (ac?.queryManager?.inFlightLinkObservables?.size || 0),
+ pendingApolloMutations,
+ );
+ },
+});
+
export default (resolvers = {}, config = {}) => {
const {
baseUrl,
@@ -56,6 +108,7 @@ export default (resolvers = {}, config = {}) => {
path = '/api/graphql',
useGet = false,
} = config;
+ let ac = null;
let uri = `${gon.relative_url_root || ''}${path}`;
if (baseUrl) {
@@ -75,16 +128,6 @@ export default (resolvers = {}, config = {}) => {
batchMax,
};
- const requestCounterLink = new ApolloLink((operation, forward) => {
- window.pendingApolloRequests = window.pendingApolloRequests || 0;
- window.pendingApolloRequests += 1;
-
- return forward(operation).map((response) => {
- window.pendingApolloRequests -= 1;
- return response;
- });
- });
-
/*
This custom fetcher intervention is to deal with an issue where we are using GET to access
eTag polling, but Apollo Client adds excessive whitespace, which causes the
@@ -138,6 +181,22 @@ export default (resolvers = {}, config = {}) => {
);
};
+ const hasMutation = (operation) =>
+ (operation?.query?.definitions || []).some((x) => x.operation === 'mutation');
+
+ const requestCounterLink = new ApolloLink((operation, forward) => {
+ if (hasMutation(operation)) {
+ pendingApolloMutations += 1;
+ }
+
+ return forward(operation).map((response) => {
+ if (hasMutation(operation)) {
+ pendingApolloMutations -= 1;
+ }
+ return response;
+ });
+ });
+
const appLink = ApolloLink.split(
hasSubscriptionOperation,
new ActionCableLink(),
@@ -155,19 +214,23 @@ export default (resolvers = {}, config = {}) => {
),
);
- return new ApolloClient({
+ ac = new ApolloClient({
typeDefs,
link: appLink,
cache: new InMemoryCache({
+ typePolicies,
+ possibleTypes,
...cacheConfig,
- freezeResults: true,
}),
resolvers,
- assumeImmutableResults: true,
defaultOptions: {
query: {
fetchPolicy,
},
},
});
+
+ acs.push(ac);
+
+ return ac;
};
diff --git a/app/assets/javascripts/lib/prosemirror_markdown_serializer.js b/app/assets/javascripts/lib/prosemirror_markdown_serializer.js
new file mode 100644
index 00000000000..6473683c3af
--- /dev/null
+++ b/app/assets/javascripts/lib/prosemirror_markdown_serializer.js
@@ -0,0 +1,3 @@
+// Import from `src/to_markdown` to avoid unnecessary bundling of unused libs
+// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79859
+export * from 'prosemirror-markdown/src/to_markdown';
diff --git a/app/assets/javascripts/lib/utils/apollo_startup_js_link.js b/app/assets/javascripts/lib/utils/apollo_startup_js_link.js
index 014823f3831..f240226e991 100644
--- a/app/assets/javascripts/lib/utils/apollo_startup_js_link.js
+++ b/app/assets/javascripts/lib/utils/apollo_startup_js_link.js
@@ -1,4 +1,4 @@
-import { ApolloLink, Observable } from 'apollo-link';
+import { ApolloLink, Observable } from '@apollo/client/core';
import { parse } from 'graphql';
import { isEqual, pickBy } from 'lodash';
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index eff00dff7a7..cf6ce2c4889 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -705,7 +705,10 @@ export const scopedLabelKey = ({ title = '' }) => {
};
// Methods to set and get Cookie
-export const setCookie = (name, value) => Cookies.set(name, value, { expires: 365 });
+export const setCookie = (name, value, attributes) => {
+ const defaults = { expires: 365, secure: Boolean(window.gon?.secure) };
+ Cookies.set(name, value, { ...defaults, ...attributes });
+};
export const getCookie = (name) => Cookies.get(name);
diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue
index 733d0f69f5d..f3380b7b4ba 100644
--- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue
+++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue
@@ -1,13 +1,21 @@
<script>
-import { GlModal } from '@gitlab/ui';
+import { GlModal, GlSafeHtmlDirective } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
cancelAction: { text: __('Cancel') },
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
components: {
GlModal,
},
props: {
+ title: {
+ type: String,
+ required: false,
+ default: '',
+ },
primaryText: {
type: String,
required: false,
@@ -18,11 +26,27 @@ export default {
required: false,
default: 'confirm',
},
+ modalHtmlMessage: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ hideCancel: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
primaryAction() {
return { text: this.primaryText, attributes: { variant: this.primaryVariant } };
},
+ cancelAction() {
+ return this.hideCancel ? null : this.$options.cancelAction;
+ },
+ shouldShowHeader() {
+ return Boolean(this.title?.length);
+ },
},
mounted() {
this.$refs.modal.show();
@@ -36,12 +60,14 @@ export default {
size="sm"
modal-id="confirmationModal"
body-class="gl-display-flex"
+ :title="title"
:action-primary="primaryAction"
- :action-cancel="$options.cancelAction"
- hide-header
+ :action-cancel="cancelAction"
+ :hide-header="!shouldShowHeader"
@primary="$emit('confirmed')"
@hidden="$emit('closed')"
>
- <div class="gl-align-self-center"><slot></slot></div>
+ <div v-if="!modalHtmlMessage" class="gl-align-self-center"><slot></slot></div>
+ <div v-else v-safe-html="modalHtmlMessage" class="gl-align-self-center"></div>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js
index fdd0e045d07..a8a89d0644a 100644
--- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js
+++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js
@@ -1,6 +1,9 @@
import Vue from 'vue';
-export function confirmAction(message, { primaryBtnVariant, primaryBtnText } = {}) {
+export function confirmAction(
+ message,
+ { primaryBtnVariant, primaryBtnText, modalHtmlMessage, title, hideCancel } = {},
+) {
return new Promise((resolve) => {
let confirmed = false;
@@ -15,6 +18,9 @@ export function confirmAction(message, { primaryBtnVariant, primaryBtnText } = {
props: {
primaryVariant: primaryBtnVariant,
primaryText: primaryBtnText,
+ title,
+ modalHtmlMessage,
+ hideCancel,
},
on: {
confirmed() {
diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js
index 36c6545164e..379c57f3945 100644
--- a/app/assets/javascripts/lib/utils/constants.js
+++ b/app/assets/javascripts/lib/utils/constants.js
@@ -1,6 +1,7 @@
export const BYTES_IN_KIB = 1024;
export const DEFAULT_DEBOUNCE_AND_THROTTLE_MS = 250;
export const HIDDEN_CLASS = 'hidden';
+export const THOUSAND = 1000;
export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80;
export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12;
diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js
index f46263c0e4d..b0e31fe729b 100644
--- a/app/assets/javascripts/lib/utils/number_utils.js
+++ b/app/assets/javascripts/lib/utils/number_utils.js
@@ -1,5 +1,5 @@
import { sprintf, __ } from '~/locale';
-import { BYTES_IN_KIB } from './constants';
+import { BYTES_IN_KIB, THOUSAND } from './constants';
/**
* Function that allows a number with an X amount of decimals
@@ -86,6 +86,27 @@ export function numberToHumanSize(size, digits = 2) {
}
/**
+ * Converts a number to kilos or megas.
+ *
+ * For example:
+ * - 123 becomes 123
+ * - 123456 becomes 123.4k
+ * - 123456789 becomes 123.4m
+ *
+ * @param number Number to format
+ * @param digits The number of digits to appear after the decimal point
+ * @return {string} Formatted number
+ */
+export function numberToMetricPrefix(number, digits = 1) {
+ if (number < THOUSAND) {
+ return number.toString();
+ }
+ if (number < THOUSAND ** 2) {
+ return `${(number / THOUSAND).toFixed(digits)}k`;
+ }
+ return `${(number / THOUSAND ** 2).toFixed(digits)}m`;
+}
+/**
* A simple method that returns the value of a + b
* It seems unessesary, but when combined with a reducer it
* adds up all the values in an array.
diff --git a/app/assets/javascripts/lib/utils/table_utility.js b/app/assets/javascripts/lib/utils/table_utility.js
index 33db7686e0f..6d66335b832 100644
--- a/app/assets/javascripts/lib/utils/table_utility.js
+++ b/app/assets/javascripts/lib/utils/table_utility.js
@@ -1,3 +1,4 @@
+import { convertToSnakeCase, convertToCamelCase } from '~/lib/utils/text_utility';
import { DEFAULT_TH_CLASSES } from './constants';
/**
@@ -7,3 +8,37 @@ import { DEFAULT_TH_CLASSES } from './constants';
* @returns {String} The classes to be used in GlTable fields object.
*/
export const thWidthClass = (width) => `gl-w-${width}p ${DEFAULT_TH_CLASSES}`;
+
+/**
+ * Converts a GlTable sort-changed event object into string format.
+ * This string can be used as a sort argument on GraphQL queries.
+ *
+ * @param {Object} - The table state context object.
+ * @returns {String} A string with the sort key and direction, for example 'NAME_DESC'.
+ */
+export const sortObjectToString = ({ sortBy, sortDesc }) => {
+ const sortingDirection = sortDesc ? 'DESC' : 'ASC';
+ const sortingColumn = convertToSnakeCase(sortBy).toUpperCase();
+
+ return `${sortingColumn}_${sortingDirection}`;
+};
+
+/**
+ * Converts a sort string into a sort state object that can be used to
+ * set the sort order on GlTable.
+ *
+ * @param {String} - The string with the sort key and direction, for example 'NAME_DESC'.
+ * @returns {Object} An object with the sortBy and sortDesc properties.
+ */
+export const sortStringToObject = (sortString) => {
+ let sortBy = null;
+ let sortDesc = null;
+
+ if (sortString && sortString.includes('_')) {
+ const [key, direction] = sortString.split(/_(ASC|DESC)$/);
+ sortBy = convertToCamelCase(key.toLowerCase());
+ sortDesc = direction === 'DESC';
+ }
+
+ return { sortBy, sortDesc };
+};
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 40dd29bea76..ec6789d81ec 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -5,6 +5,12 @@ import { insertText } from '~/lib/utils/common_utils';
const LINK_TAG_PATTERN = '[{text}](url)';
+// at the start of a line, find any amount of whitespace followed by
+// a bullet point character (*+-) and an optional checkbox ([ ] [x])
+// OR a number with a . after it and an optional checkbox ([ ] [x])
+// followed by one or more whitespace characters
+const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isOl>[*+-])|(?<isUl>\d+\.))( \[([x ])\])?\s)(?<content>.)?/;
+
function selectedText(text, textarea) {
return text.substring(textarea.selectionStart, textarea.selectionEnd);
}
@@ -13,8 +19,15 @@ function addBlockTags(blockTag, selected) {
return `${blockTag}\n${selected}\n${blockTag}`;
}
-function lineBefore(text, textarea) {
- const split = text.substring(0, textarea.selectionStart).trim().split('\n');
+function lineBefore(text, textarea, trimNewlines = true) {
+ let split = text.substring(0, textarea.selectionStart);
+
+ if (trimNewlines) {
+ split = split.trim();
+ }
+
+ split = split.split('\n');
+
return split[split.length - 1];
}
@@ -284,9 +297,9 @@ function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagCo
}
/* eslint-disable @gitlab/require-i18n-strings */
-export function keypressNoteText(e) {
+function handleSurroundSelectedText(e, textArea) {
if (!gon.markdown_surround_selection) return;
- if (this.selectionStart === this.selectionEnd) return;
+ if (textArea.selectionStart === textArea.selectionEnd) return;
const keys = {
'*': '**{text}**', // wraps with bold character
@@ -306,7 +319,7 @@ export function keypressNoteText(e) {
updateText({
tag,
- textArea: this,
+ textArea,
blockTag: '',
wrap: true,
select: '',
@@ -316,6 +329,48 @@ export function keypressNoteText(e) {
}
/* eslint-enable @gitlab/require-i18n-strings */
+function handleContinueList(e, textArea) {
+ if (!gon.features?.markdownContinueLists) return;
+ if (!(e.key === 'Enter')) return;
+ if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
+ if (textArea.selectionStart !== textArea.selectionEnd) return;
+
+ const currentLine = lineBefore(textArea.value, textArea, false);
+ const result = currentLine.match(LIST_LINE_HEAD_PATTERN);
+
+ if (result) {
+ const { indent, content, leader } = result.groups;
+ const prevLineEmpty = !content;
+
+ if (prevLineEmpty) {
+ // erase previous empty list item - select the text and allow the
+ // natural line feed erase the text
+ textArea.selectionStart = textArea.selectionStart - result[0].length;
+ return;
+ }
+
+ const itemInsert = `${indent}${leader}`;
+
+ e.preventDefault();
+
+ updateText({
+ tag: itemInsert,
+ textArea,
+ blockTag: '',
+ wrap: false,
+ select: '',
+ tagContent: '',
+ });
+ }
+}
+
+export function keypressNoteText(e) {
+ const textArea = this;
+
+ handleContinueList(e, textArea);
+ handleSurroundSelectedText(e, textArea);
+}
+
export function updateTextForToolbarBtn($toolbarBtn) {
return updateText({
textArea: $toolbarBtn.closest('.md-area').find('textarea'),
diff --git a/app/assets/javascripts/lib/utils/yaml.js b/app/assets/javascripts/lib/utils/yaml.js
new file mode 100644
index 00000000000..9270d388342
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/yaml.js
@@ -0,0 +1,121 @@
+/**
+ * This file adds a merge function to be used with a yaml Document as defined by
+ * the yaml@2.x package: https://eemeli.org/yaml/#yaml
+ *
+ * Ultimately, this functionality should be merged upstream into the package,
+ * track the progress of that effort at https://github.com/eemeli/yaml/pull/347
+ * */
+
+import { visit, Scalar, isCollection, isDocument, isScalar, isNode, isMap, isSeq } from 'yaml';
+
+function getPath(ancestry) {
+ return ancestry.reduce((p, { key }) => {
+ return key !== undefined ? [...p, key.value] : p;
+ }, []);
+}
+
+function getFirstChildNode(collection) {
+ let firstChildKey;
+ let type;
+ switch (collection.constructor.name) {
+ case 'YAMLSeq': // eslint-disable-line @gitlab/require-i18n-strings
+ return collection.items.find((i) => isNode(i));
+ case 'YAMLMap': // eslint-disable-line @gitlab/require-i18n-strings
+ firstChildKey = collection.items[0]?.key;
+ if (!firstChildKey) return undefined;
+ return isScalar(firstChildKey) ? firstChildKey : new Scalar(firstChildKey);
+ default:
+ type = collection.constructor?.name || typeof collection;
+ throw Error(`Cannot identify a child Node for type ${type}`);
+ }
+}
+
+function moveMetaPropsToFirstChildNode(collection) {
+ const firstChildNode = getFirstChildNode(collection);
+ const { comment, commentBefore, spaceBefore } = collection;
+ if (!(comment || commentBefore || spaceBefore)) return;
+ if (!firstChildNode)
+ throw new Error('Cannot move meta properties to a child of an empty Collection'); // eslint-disable-line @gitlab/require-i18n-strings
+ Object.assign(firstChildNode, { comment, commentBefore, spaceBefore });
+ Object.assign(collection, {
+ comment: undefined,
+ commentBefore: undefined,
+ spaceBefore: undefined,
+ });
+}
+
+function assert(isTypeFn, node, path) {
+ if (![isSeq, isMap].includes(isTypeFn)) {
+ throw new Error('assert() can only be used with isSeq() and isMap()');
+ }
+ const expectedTypeName = isTypeFn === isSeq ? 'YAMLSeq' : 'YAMLMap'; // eslint-disable-line @gitlab/require-i18n-strings
+ if (!isTypeFn(node)) {
+ const type = node?.constructor?.name || typeof node;
+ throw new Error(
+ `Type conflict at "${path.join(
+ '.',
+ )}": Destination node is of type ${type}, the node to be merged is of type ${expectedTypeName}.`,
+ );
+ }
+}
+
+function mergeCollection(target, node, path) {
+ // In case both the source and the target node have comments or spaces
+ // We'll move them to their first child so they do not conflict
+ moveMetaPropsToFirstChildNode(node);
+ if (target.hasIn(path)) {
+ const targetNode = target.getIn(path, true);
+ assert(isSeq(node) ? isSeq : isMap, targetNode, path);
+ moveMetaPropsToFirstChildNode(targetNode);
+ }
+}
+
+function mergePair(target, node, path) {
+ if (!isScalar(node.value)) return undefined;
+ if (target.hasIn([...path, node.key.value])) {
+ target.setIn(path, node);
+ } else {
+ target.addIn(path, node);
+ }
+ return visit.SKIP;
+}
+
+function getVisitorFn(target, options) {
+ return {
+ Map: (_, node, ancestors) => {
+ mergeCollection(target, node, getPath(ancestors));
+ },
+ Pair: (_, node, ancestors) => {
+ mergePair(target, node, getPath(ancestors));
+ },
+ Seq: (_, node, ancestors) => {
+ const path = getPath(ancestors);
+ mergeCollection(target, node, path);
+ if (options.onSequence === 'replace') {
+ target.setIn(path, node);
+ return visit.SKIP;
+ }
+ node.items.forEach((item) => target.addIn(path, item));
+ return visit.SKIP;
+ },
+ };
+}
+
+/** Merge another collection into this */
+export function merge(target, source, options = {}) {
+ const opt = {
+ onSequence: 'replace',
+ ...options,
+ };
+ const sourceNode = target.createNode(isDocument(source) ? source.contents : source);
+ if (!isCollection(sourceNode)) {
+ const type = source?.constructor?.name || typeof source;
+ throw new Error(`Cannot merge type "${type}", expected a Collection`);
+ }
+ if (!isCollection(target.contents)) {
+ // If the target doc is empty add the source to it directly
+ Object.assign(target, { contents: sourceNode });
+ return;
+ }
+ visit(sourceNode, getVisitorFn(target, opt));
+}
diff --git a/app/assets/javascripts/listbox/index.js b/app/assets/javascripts/listbox/index.js
new file mode 100644
index 00000000000..f63171e2785
--- /dev/null
+++ b/app/assets/javascripts/listbox/index.js
@@ -0,0 +1,67 @@
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+
+export function parseAttributes(el) {
+ const { items: itemsString, selected, right: rightString } = el.dataset;
+
+ const items = JSON.parse(itemsString);
+ const right = parseBoolean(rightString);
+
+ const { className } = el;
+
+ return { items, selected, right, className };
+}
+
+export function initListbox(el, { onChange } = {}) {
+ if (!el) return null;
+
+ const { items, selected, right, className } = parseAttributes(el);
+
+ return new Vue({
+ el,
+ data() {
+ return {
+ selected,
+ };
+ },
+ computed: {
+ text() {
+ return items.find(({ value }) => value === this.selected)?.text;
+ },
+ },
+ render(h) {
+ return h(
+ GlDropdown,
+ {
+ props: {
+ text: this.text,
+ right,
+ },
+ class: className,
+ },
+ items.map((item) =>
+ h(
+ GlDropdownItem,
+ {
+ props: {
+ isCheckItem: true,
+ isChecked: this.selected === item.value,
+ },
+ on: {
+ click: () => {
+ this.selected = item.value;
+
+ if (typeof onChange === 'function') {
+ onChange(item);
+ }
+ },
+ },
+ },
+ item.text,
+ ),
+ ),
+ );
+ },
+ });
+}
diff --git a/app/assets/javascripts/listbox/redirect_behavior.js b/app/assets/javascripts/listbox/redirect_behavior.js
new file mode 100644
index 00000000000..7e0ea2c4dfd
--- /dev/null
+++ b/app/assets/javascripts/listbox/redirect_behavior.js
@@ -0,0 +1,22 @@
+import { initListbox } from '~/listbox';
+import { redirectTo } from '~/lib/utils/url_utility';
+
+/**
+ * Instantiates GlListbox components with redirect behavior for tags created
+ * with the `gl_redirect_listbox_tag` HAML helper.
+ *
+ * NOTE: Do not import this script explicitly. Using `gl_redirect_listbox_tag`
+ * automatically injects the `redirect_listbox` bundle, which calls this
+ * function.
+ */
+export function initRedirectListboxBehavior() {
+ const elements = Array.from(document.querySelectorAll('.js-redirect-listbox'));
+
+ return elements.map((el) =>
+ initListbox(el, {
+ onChange({ href }) {
+ redirectTo(href);
+ },
+ }),
+ );
+}
diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue
index c9e7b034950..b0d31ca315e 100644
--- a/app/assets/javascripts/logs/components/environment_logs.vue
+++ b/app/assets/javascripts/logs/components/environment_logs.vue
@@ -2,6 +2,7 @@
import {
GlSprintf,
GlAlert,
+ GlLink,
GlDropdown,
GlDropdownSectionHeader,
GlDropdownItem,
@@ -20,6 +21,7 @@ import LogSimpleFilters from './log_simple_filters.vue';
export default {
components: {
GlSprintf,
+ GlLink,
GlAlert,
GlDropdown,
GlDropdownSectionHeader,
@@ -58,6 +60,7 @@ export default {
return {
isElasticStackCalloutDismissed: false,
scrollDownButtonDisabled: true,
+ isDeprecationNoticeDismissed: false,
};
},
computed: {
@@ -151,6 +154,41 @@ export default {
{{ 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 metrics, 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"
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 376134afef0..f78b4da181e 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -15,11 +15,11 @@ import { initRails } from '~/lib/utils/rails_ujs';
import * as popovers from '~/popovers';
import * as tooltips from '~/tooltips';
import { initPrefetchLinks } from '~/lib/utils/navigation_utility';
+import { logHelloDeferred } from 'jh_else_ce/lib/logger/hello_deferred';
import initAlertHandler from './alert_handler';
import { addDismissFlashClickListener } from './flash';
import initTodoToggle from './header';
import initLayoutNav from './layout_nav';
-import { logHelloDeferred } from './lib/logger/hello_deferred';
import { handleLocationHash, addSelectOnFocusBehaviour } from './lib/utils/common_utils';
import { localTimeAgo } from './lib/utils/datetime/timeago_utility';
import { getLocationHash, visitUrl } from './lib/utils/url_utility';
diff --git a/app/assets/javascripts/members/components/avatars/user_avatar.vue b/app/assets/javascripts/members/components/avatars/user_avatar.vue
index 9687eacb036..ec59f0f681c 100644
--- a/app/assets/javascripts/members/components/avatars/user_avatar.vue
+++ b/app/assets/javascripts/members/components/avatars/user_avatar.vue
@@ -8,10 +8,14 @@ import {
import { generateBadges } from 'ee_else_ce/members/utils';
import { glEmojiTag } from '~/emoji';
import { __ } from '~/locale';
+import { isUserBusy } from '~/set_status_modal/utils';
import { AVATAR_SIZE } from '../../constants';
export default {
name: 'UserAvatar',
+ i18n: {
+ busy: __('Busy'),
+ },
avatarSize: AVATAR_SIZE,
orphanedUserLabel: __('Orphaned member'),
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
@@ -46,7 +50,10 @@ export default {
}).filter((badge) => badge.show);
},
statusEmoji() {
- return this.user?.status?.emoji;
+ return this.user?.showStatus && this.user?.status?.emoji;
+ },
+ isUserBusy() {
+ return isUserBusy(this.user?.availability || '');
},
},
methods: {
@@ -73,6 +80,11 @@ export default {
:entity-id="user.id"
>
<template #meta>
+ <div v-if="isUserBusy" class="gl-p-1">
+ <span class="gl-text-gray-500 gl-font-sm gl-font-weight-normal"
+ >({{ $options.i18n.busy }})</span
+ >
+ </div>
<div v-if="statusEmoji" class="gl-p-1">
<span
v-safe-html:[$options.safeHtmlConfig]="glEmojiTag(statusEmoji)"
diff --git a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
index e9329fb1d88..633dee75237 100644
--- a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
+++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
@@ -151,6 +151,7 @@ export default {
:search-input-placeholder="filteredSearchBar.placeholder"
:initial-filter-value="initialFilterValue"
data-testid="members-filtered-search-bar"
+ data-qa-selector="members_filtered_search_bar_content"
@onFilter="handleFilter"
/>
</template>
diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue
index e09d16cf680..b4ba9aa36e7 100644
--- a/app/assets/javascripts/members/components/table/members_table.vue
+++ b/app/assets/javascripts/members/components/table/members_table.vue
@@ -11,7 +11,9 @@ import {
ACTIVE_TAB_QUERY_PARAM_NAME,
TAB_QUERY_PARAM_VALUES,
MEMBER_STATE_AWAITING,
+ MEMBER_STATE_ACTIVE,
USER_STATE_BLOCKED_PENDING_APPROVAL,
+ BADGE_LABELS_AWAITING_USER_SIGNUP,
BADGE_LABELS_PENDING_OWNER_APPROVAL,
} from '../../constants';
import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue';
@@ -154,8 +156,12 @@ export default {
* @see {@link ~/app/serializers/member_entity.rb}
* @returns {boolean}
*/
- isNewUser(memberInviteMetadata) {
- return memberInviteMetadata && !memberInviteMetadata.userState;
+ isNewUser(memberInviteMetadata, memberState) {
+ return (
+ memberInviteMetadata &&
+ !memberInviteMetadata.userState &&
+ memberState !== MEMBER_STATE_ACTIVE
+ );
},
/**
* Returns whether the user is awaiting root approval
@@ -204,6 +210,10 @@ export default {
* @returns {string}
*/
inviteBadge(memberInviteMetadata, memberState) {
+ if (this.isNewUser(memberInviteMetadata, memberState)) {
+ return BADGE_LABELS_AWAITING_USER_SIGNUP;
+ }
+
if (this.shouldAddPendingOwnerApprovalBadge(memberInviteMetadata, memberState)) {
return BADGE_LABELS_PENDING_OWNER_APPROVAL;
}
diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js
index 62241eaed04..273f1acebc7 100644
--- a/app/assets/javascripts/members/constants.js
+++ b/app/assets/javascripts/members/constants.js
@@ -111,6 +111,7 @@ export const MEMBER_STATE_CREATED = 0;
export const MEMBER_STATE_AWAITING = 1;
export const MEMBER_STATE_ACTIVE = 2;
+export const BADGE_LABELS_AWAITING_USER_SIGNUP = __('Awaiting user signup');
export const BADGE_LABELS_PENDING_OWNER_APPROVAL = __('Pending owner approval');
export const DAYS_TO_EXPIRE_SOON = 7;
diff --git a/app/assets/javascripts/merge_conflicts/store/actions.js b/app/assets/javascripts/merge_conflicts/store/actions.js
index df515c4ac1a..9c101da52f5 100644
--- a/app/assets/javascripts/merge_conflicts/store/actions.js
+++ b/app/assets/javascripts/merge_conflicts/store/actions.js
@@ -1,4 +1,4 @@
-import Cookies from 'js-cookie';
+import { setCookie } from '~/lib/utils/common_utils';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
@@ -51,7 +51,7 @@ export const setFailedRequest = ({ commit }, message) => {
export const setViewType = ({ commit }, viewType) => {
commit(types.SET_VIEW_TYPE, viewType);
- Cookies.set('diff_view', viewType);
+ setCookie('diff_view', viewType);
};
export const setSubmitState = ({ commit }, isSubmitting) => {
diff --git a/app/assets/javascripts/merge_conflicts/store/state.js b/app/assets/javascripts/merge_conflicts/store/state.js
index 8f700f58e54..7a2e28183a7 100644
--- a/app/assets/javascripts/merge_conflicts/store/state.js
+++ b/app/assets/javascripts/merge_conflicts/store/state.js
@@ -1,7 +1,7 @@
-import Cookies from 'js-cookie';
+import { getCookie } from '~/lib/utils/common_utils';
import { VIEW_TYPES } from '../constants';
-const diffViewType = Cookies.get('diff_view');
+const diffViewType = getCookie('diff_view');
export default () => ({
isLoading: true,
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index a40caea1223..ad0117844cd 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -1,20 +1,21 @@
/* eslint-disable no-new, class-methods-use-this */
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import $ from 'jquery';
-import Cookies from 'js-cookie';
import Vue from 'vue';
+import {
+ getCookie,
+ parseUrlPathname,
+ isMetaClick,
+ parseBoolean,
+ scrollToElement,
+} from '~/lib/utils/common_utils';
import createEventHub from '~/helpers/event_hub_factory';
import BlobForkSuggestion from './blob/blob_fork_suggestion';
import Diff from './diff';
import createFlash from './flash';
import { initDiffStatsDropdown } from './init_diff_stats_dropdown';
import axios from './lib/utils/axios_utils';
-import {
- parseUrlPathname,
- isMetaClick,
- parseBoolean,
- scrollToElement,
-} from './lib/utils/common_utils';
+
import { localTimeAgo } from './lib/utils/datetime_utility';
import { isInVueNoteablePage } from './lib/utils/dom_utils';
import { __ } from './locale';
@@ -514,7 +515,7 @@ export default class MergeRequestTabs {
// Expand the issuable sidebar unless the user explicitly collapsed it
expandView() {
- if (parseBoolean(Cookies.get('collapsed_gutter'))) {
+ if (parseBoolean(getCookie('collapsed_gutter'))) {
return;
}
const $gutterBtn = $('.js-sidebar-toggle');
diff --git a/app/assets/javascripts/milestones/index.js b/app/assets/javascripts/milestones/index.js
index 2ca5f104b4f..f90fdb04923 100644
--- a/app/assets/javascripts/milestones/index.js
+++ b/app/assets/javascripts/milestones/index.js
@@ -46,6 +46,7 @@ export function initPromoteMilestoneModal() {
return new Vue({
el: promoteMilestoneModal,
+ name: 'PromoteMilestoneModalRoot',
render(createElement) {
return createElement(PromoteMilestoneModal);
},
@@ -80,6 +81,7 @@ export function initDeleteMilestoneModal() {
return new Vue({
el: '#js-delete-milestone-modal',
+ name: 'DeleteMilestoneModalRoot',
data() {
return {
modalProps: {
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index c9767330b73..6467d953500 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -1,5 +1,13 @@
<script>
-import { GlButton, GlModalDirective, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import {
+ GlButton,
+ GlModalDirective,
+ GlTooltipDirective,
+ GlIcon,
+ GlAlert,
+ GlSprintf,
+ GlLink,
+} from '@gitlab/ui';
import Mousetrap from 'mousetrap';
import VueDraggable from 'vuedraggable';
import { mapActions, mapState, mapGetters } from 'vuex';
@@ -38,6 +46,9 @@ export default {
GroupEmptyState,
VariablesSection,
LinksSection,
+ GlAlert,
+ GlSprintf,
+ GlLink,
},
directives: {
GlModal: GlModalDirective,
@@ -143,6 +154,7 @@ export default {
isRearrangingPanels: false,
originalDocumentTitle: document.title,
hoveredPanel: '',
+ isDeprecationNoticeDismissed: false,
};
},
computed: {
@@ -392,9 +404,44 @@ export default {
},
};
</script>
-
<template>
<div class="prometheus-graphs" data-qa-selector="prometheus_graphs">
+ <div>
+ <gl-alert
+ v-if="!isDeprecationNoticeDismissed"
+ :title="__('Feature deprecation and removal')"
+ class="mb-3"
+ variant="danger"
+ @dismiss="isDeprecationNoticeDismissed = true"
+ >
+ <gl-sprintf
+ :message="
+ s__(
+ 'Deprecations|The metrics, 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>
+ </div>
<dashboard-header
v-if="showHeader"
ref="prometheusGraphsHeader"
diff --git a/app/assets/javascripts/nav/components/top_nav_menu_item.vue b/app/assets/javascripts/nav/components/top_nav_menu_item.vue
index 07c6fa7773a..bf1fd691ca8 100644
--- a/app/assets/javascripts/nav/components/top_nav_menu_item.vue
+++ b/app/assets/javascripts/nav/components/top_nav_menu_item.vue
@@ -35,7 +35,7 @@ export default {
<gl-button
category="tertiary"
:href="menuItem.href"
- class="top-nav-menu-item gl-display-block"
+ class="top-nav-menu-item gl-display-block gl-pr-3!"
:class="[menuItem.css_class, { [$options.ACTIVE_CLASS]: menuItem.active }]"
:aria-label="menuItem.title"
v-bind="dataAttrs"
diff --git a/app/assets/javascripts/nav/mount.js b/app/assets/javascripts/nav/mount.js
index 51b6a31b8cb..7b0cc977107 100644
--- a/app/assets/javascripts/nav/mount.js
+++ b/app/assets/javascripts/nav/mount.js
@@ -12,6 +12,7 @@ const mount = (el, Component) => {
return new Vue({
el,
+ name: 'TopNavRoot',
store,
render(h) {
return h(Component, {
diff --git a/app/assets/javascripts/network/raphael.js b/app/assets/javascripts/network/raphael.js
index 22e06a35d91..e13471c0e51 100644
--- a/app/assets/javascripts/network/raphael.js
+++ b/app/assets/javascripts/network/raphael.js
@@ -1,12 +1,14 @@
import Raphael from 'raphael/raphael';
+import { formatDate } from '~/lib/utils/datetime_utility';
Raphael.prototype.commitTooltip = function commitTooltip(x, y, commit) {
const boxWidth = 300;
const icon = this.image(gon.relative_url_root + commit.author.icon, x, y, 20, 20);
const nameText = this.text(x + 25, y + 10, commit.author.name);
- const idText = this.text(x, y + 35, commit.id);
- const messageText = this.text(x, y + 50, commit.message.replace(/\r?\n/g, ' \n '));
- const textSet = this.set(icon, nameText, idText, messageText).attr({
+ const dateText = this.text(x, y + 35, formatDate(commit.date));
+ const idText = this.text(x, y + 55, commit.id);
+ const messageText = this.text(x, y + 70, commit.message.replace(/\r?\n/g, ' \n '));
+ const textSet = this.set(icon, nameText, dateText, idText, messageText).attr({
'text-anchor': 'start',
font: '12px Monaco, monospace',
});
@@ -14,6 +16,9 @@ Raphael.prototype.commitTooltip = function commitTooltip(x, y, commit) {
font: '14px Arial',
'font-weight': 'bold',
});
+ dateText.attr({
+ fill: '#666',
+ });
idText.attr({
fill: '#AAA',
});
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 996c008b881..a9948fed3b6 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -369,7 +369,7 @@ export default {
class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area"
data-qa-selector="comment_field"
data-testid="comment-field"
- :data-supports-quick-actions="!glFeatures.tributeAutocomplete"
+ data-supports-quick-actions="true"
:aria-label="$options.i18n.comment"
:placeholder="$options.i18n.bodyPlaceholder"
@keydown.up="editCurrentUserLastNote()"
diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue
index b2d5910fd3f..b4f7ba5f960 100644
--- a/app/assets/javascripts/notes/components/diff_with_note.vue
+++ b/app/assets/javascripts/notes/components/diff_with_note.vue
@@ -107,7 +107,7 @@ export default {
<td v-if="error" class="js-error-lazy-load-diff diff-loading-error-block">
{{ __('Unable to load the diff') }}
<button
- class="btn-link btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button"
+ class="btn-link btn-link-retry gl-p-0 js-toggle-lazy-diff-retry-button"
@click="fetchDiff"
>
{{ __('Try again') }}
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index d6b65ed0e8b..ee22c118e11 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -5,7 +5,6 @@ import { getDraft, updateDraft } from '~/lib/utils/autosave';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import markdownField from '~/vue_shared/components/markdown/field.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../event_hub';
import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable';
@@ -20,7 +19,7 @@ export default {
GlSprintf,
GlLink,
},
- mixins: [glFeatureFlagsMixin(), issuableStateMixin, resolvable],
+ mixins: [issuableStateMixin, resolvable],
props: {
noteBody: {
type: String,
@@ -349,7 +348,7 @@ export default {
ref="textarea"
v-model="updatedNoteBody"
:disabled="isSubmitting"
- :data-supports-quick-actions="!isEditing && !glFeatures.tributeAutocomplete"
+ :data-supports-quick-actions="!isEditing"
name="note[note]"
class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form"
data-qa-selector="reply_field"
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 8e32c3b3073..ddf72587ba3 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -5,6 +5,7 @@ import DraftNote from '~/batch_comments/components/draft_note.vue';
import createFlash from '~/flash';
import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave';
import { isLoggedIn } from '~/lib/utils/common_utils';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { s__, __ } from '~/locale';
import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
@@ -170,12 +171,13 @@ export default {
this.expandDiscussion({ discussionId: this.discussion.id });
}
},
- cancelReplyForm(shouldConfirm, isDirty) {
+ async cancelReplyForm(shouldConfirm, isDirty) {
if (shouldConfirm && isDirty) {
const msg = s__('Notes|Are you sure you want to cancel creating this comment?');
- // eslint-disable-next-line no-alert
- if (!window.confirm(msg)) {
+ const confirmed = await confirmAction(msg);
+
+ if (!confirmed) {
return;
}
}
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 3250a4818c7..7bad10616cc 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -3,6 +3,7 @@ import { GlSprintf, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import $ from 'jquery';
import { escape, isEmpty } from 'lodash';
import { mapGetters, mapActions } from 'vuex';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { INLINE_DIFF_LINES_KEY } from '~/diffs/constants';
import createFlash from '~/flash';
import httpStatusCodes from '~/lib/utils/http_status';
@@ -243,14 +244,18 @@ export default {
this.setSelectedCommentPositionHover();
this.$emit('handleEdit');
},
- deleteHandler() {
+ async deleteHandler() {
const typeOfComment = this.note.isDraft ? __('pending comment') : __('comment');
- if (
- // eslint-disable-next-line no-alert
- window.confirm(
- sprintf(__('Are you sure you want to delete this %{typeOfComment}?'), { typeOfComment }),
- )
- ) {
+
+ const msg = sprintf(__('Are you sure you want to delete this %{typeOfComment}?'), {
+ typeOfComment,
+ });
+ const confirmed = await confirmAction(msg, {
+ primaryBtnVariant: 'danger',
+ primaryBtnText: __('Delete Comment'),
+ });
+
+ if (confirmed) {
this.isDeleting = true;
this.$emit('handleDeleteNote', this.note);
@@ -345,10 +350,11 @@ export default {
parent: this.$el,
});
},
- formCancelHandler({ shouldConfirm, isDirty }) {
+ async formCancelHandler({ shouldConfirm, isDirty }) {
if (shouldConfirm && isDirty) {
- // eslint-disable-next-line no-alert
- if (!window.confirm(__('Are you sure you want to cancel editing this comment?'))) return;
+ const msg = __('Are you sure you want to cancel editing this comment?');
+ const confirmed = await confirmAction(msg);
+ if (!confirmed) return;
}
this.$refs.noteBody.resetAutoSave();
if (this.oldContent) {
diff --git a/app/assets/javascripts/notes/discussion_filters.js b/app/assets/javascripts/notes/discussion_filters.js
index 7c9e7703d59..104e9d4183a 100644
--- a/app/assets/javascripts/notes/discussion_filters.js
+++ b/app/assets/javascripts/notes/discussion_filters.js
@@ -19,7 +19,7 @@ export default (store) => {
return new Vue({
el: discussionFilterEl,
- name: 'DiscussionFilter',
+ name: 'DiscussionFilterRoot',
components: {
DiscussionFilter,
},
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
index 2ce60976adb..19fa484d659 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -14,6 +14,7 @@ export default () => {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'NotesRoot',
components: {
notesApp,
},
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index ad529eb99b6..93236b05100 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -3,8 +3,6 @@ import { scrollToElementWithContext, scrollToElement } from '~/lib/utils/common_
import { updateHistory } from '../../lib/utils/url_utility';
import eventHub from '../event_hub';
-const isDiffsVirtualScrollingEnabled = () => window.gon?.features?.diffsVirtualScrolling;
-
/**
* @param {string} selector
* @returns {boolean}
@@ -15,7 +13,7 @@ function scrollTo(selector, { withoutContext = false } = {}) {
if (el) {
scrollFunction(el, {
- behavior: isDiffsVirtualScrollingEnabled() ? 'auto' : 'smooth',
+ behavior: 'auto',
});
return true;
}
@@ -31,7 +29,7 @@ function updateUrlWithNoteId(noteId) {
replace: true,
};
- if (noteId && isDiffsVirtualScrollingEnabled()) {
+ if (noteId) {
// Temporarily mask the ID to avoid the browser default
// scrolling taking over which is broken with virtual
// scrolling enabled.
@@ -115,17 +113,13 @@ function handleDiscussionJump(self, fn, discussionId = self.currentDiscussionId)
const isDiffView = window.mrTabs.currentAction === 'diffs';
const targetId = fn(discussionId, isDiffView);
const discussion = self.getDiscussion(targetId);
- const setHash = !isDiffView && !isDiffsVirtualScrollingEnabled();
const discussionFilePath = discussion?.diff_file?.file_path;
- if (isDiffsVirtualScrollingEnabled()) {
- window.location.hash = '';
- }
+ window.location.hash = '';
if (discussionFilePath) {
self.scrollToFile({
path: discussionFilePath,
- setHash,
});
}
diff --git a/app/assets/javascripts/notes/sort_discussions.js b/app/assets/javascripts/notes/sort_discussions.js
index ecfa3223039..ca8df880fe4 100644
--- a/app/assets/javascripts/notes/sort_discussions.js
+++ b/app/assets/javascripts/notes/sort_discussions.js
@@ -8,6 +8,7 @@ export default (store) => {
return new Vue({
el,
+ name: 'SortDiscussionRoot',
store,
render(createElement) {
return createElement(SortDiscussion);
diff --git a/app/assets/javascripts/notifications/components/notifications_dropdown.vue b/app/assets/javascripts/notifications/components/notifications_dropdown.vue
index 69eb2115bf4..6b450c2b5fd 100644
--- a/app/assets/javascripts/notifications/components/notifications_dropdown.vue
+++ b/app/assets/javascripts/notifications/components/notifications_dropdown.vue
@@ -42,6 +42,9 @@ export default {
showLabel: {
default: false,
},
+ noFlip: {
+ default: false,
+ },
},
data() {
return {
@@ -127,6 +130,7 @@ export default {
:disabled="disabled"
:split="isCustomNotification"
:text="buttonText"
+ :no-flip="noFlip"
@click="openNotificationsModal"
>
<notifications-dropdown-item
diff --git a/app/assets/javascripts/notifications/index.js b/app/assets/javascripts/notifications/index.js
index d60a368703c..a81f2c2590b 100644
--- a/app/assets/javascripts/notifications/index.js
+++ b/app/assets/javascripts/notifications/index.js
@@ -21,6 +21,7 @@ export default () => {
projectId,
groupId,
showLabel,
+ noFlip,
} = el.dataset;
return new Vue({
@@ -35,6 +36,7 @@ export default () => {
projectId,
groupId,
showLabel: parseBoolean(showLabel),
+ noFlip: parseBoolean(noFlip),
},
render(h) {
return h(NotificationsDropdown);
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue
index 4fda4058711..7659ba5f9ea 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue
@@ -7,6 +7,7 @@ import RegistryList from '~/packages_and_registries/shared/components/registry_l
import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
+import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue';
import {
REMOVE_TAGS_BUTTON_TITLE,
TAGS_LIST_TITLE,
@@ -20,7 +21,6 @@ import {
} from '../../constants/index';
import getContainerRepositoryTagsQuery from '../../graphql/queries/get_container_repository_tags.query.graphql';
import TagsListRow from './tags_list_row.vue';
-import TagsLoader from './tags_loader.vue';
export default {
name: 'TagsList',
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 bb687ffdb89..931849c9918 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
@@ -5,6 +5,7 @@ import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import Tracking from '~/tracking';
+import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue';
import DeleteImage from '../components/delete_image.vue';
import DeleteAlert from '../components/details_page/delete_alert.vue';
import DeleteModal from '../components/details_page/delete_modal.vue';
@@ -12,7 +13,6 @@ import DetailsHeader from '../components/details_page/details_header.vue';
import PartialCleanupAlert from '../components/details_page/partial_cleanup_alert.vue';
import StatusAlert from '../components/details_page/status_alert.vue';
import TagsList from '../components/details_page/tags_list.vue';
-import TagsLoader from '../components/details_page/tags_loader.vue';
import {
ALERT_SUCCESS_TAG,
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
index 3274de05803..e2acebf39d6 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
@@ -52,7 +52,7 @@ export default {
),
CliCommands: () =>
import(
- /* webpackChunkName: 'container_registry_components' */ '../components/list_page/cli_commands.vue'
+ /* webpackChunkName: 'container_registry_components' */ '~/packages_and_registries/shared/components/cli_commands.vue'
),
GlModal,
GlSprintf,
@@ -68,7 +68,7 @@ export default {
GlTooltip: GlTooltipDirective,
},
mixins: [Tracking.mixin()],
- inject: ['config'],
+ inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'],
loader: {
repeat: 10,
width: 1000,
@@ -96,6 +96,9 @@ export default {
return data[this.graphqlResource]?.containerRepositories.nodes;
},
result({ data }) {
+ if (!data) {
+ return;
+ }
this.pageInfo = data[this.graphqlResource]?.containerRepositories?.pageInfo;
this.containerRepositoriesCount = data[this.graphqlResource]?.containerRepositoriesCount;
},
@@ -321,7 +324,12 @@ export default {
:hide-expiration-policy-data="config.isGroupPage"
>
<template #commands>
- <cli-commands v-if="showCommands" />
+ <cli-commands
+ v-if="showCommands"
+ :docker-build-command="dockerBuildCommand"
+ :docker-push-command="dockerPushCommand"
+ :docker-login-command="dockerLoginCommand"
+ />
</template>
</registry-header>
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue
index 2a479c65d0c..9bab08b8548 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue
@@ -21,13 +21,17 @@ export default {
},
},
computed: {
- showModuleCount() {
- return Number.isInteger(this.count);
+ hasModules() {
+ return Number.isInteger(this.count) && this.count > 0;
},
moduleAmountText() {
return n__(`%d Module`, `%d Modules`, this.count);
},
infoMessages() {
+ if (!this.hasModules) {
+ return [];
+ }
+
return [{ text: this.$options.i18n.LIST_INTRO_TEXT, link: this.helpUrl }];
},
},
@@ -43,11 +47,7 @@ export default {
<template>
<title-area :title="$options.i18n.LIST_TITLE_TEXT" :info-messages="infoMessages">
<template #metadata-amount>
- <metadata-item
- v-if="showModuleCount"
- icon="infrastructure-registry"
- :text="moduleAmountText"
- />
+ <metadata-item v-if="hasModules" icon="infrastructure-registry" :text="moduleAmountText" />
</template>
</title-area>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue
index 462618a7f12..184a24047eb 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue
@@ -99,7 +99,7 @@ export default {
<template>
<div>
<infrastructure-title :help-url="packageHelpUrl" :count="packagesCount" />
- <infrastructure-search @update="requestPackagesList" />
+ <infrastructure-search v-if="packagesCount > 0" @update="requestPackagesList" />
<package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest">
<template #empty-state>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
index 1afd1b69db0..57ff3cd2a83 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
@@ -61,11 +61,13 @@ export default {
</template>
<template #right-secondary>
- <gl-sprintf :message="__('Created %{timestamp}')">
- <template #timestamp>
- <time-ago-tooltip :time="packageEntity.createdAt" />
- </template>
- </gl-sprintf>
+ <span>
+ <gl-sprintf :message="__('Created %{timestamp}')">
+ <template #timestamp>
+ <time-ago-tooltip :time="packageEntity.createdAt" />
+ </template>
+ </gl-sprintf>
+ </span>
</template>
</list-item>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue
index 3483d23e251..c27083261b5 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue
@@ -9,6 +9,8 @@ import {
FILTERED_SEARCH_TERM,
FILTERED_SEARCH_TYPE,
} from '~/packages_and_registries/shared/constants';
+import { LIST_KEY_CREATED_AT } from '~/packages_and_registries/package_registry/constants';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import PackageTypeToken from './tokens/package_type_token.vue';
export default {
@@ -22,13 +24,13 @@ export default {
operators: OPERATOR_IS_ONLY,
},
],
- components: { RegistrySearch, UrlSync },
+ components: { RegistrySearch, UrlSync, LocalStorageSync },
inject: ['isGroupPage'],
data() {
return {
filters: [],
sorting: {
- orderBy: 'name',
+ orderBy: LIST_KEY_CREATED_AT,
sort: 'desc',
},
mountRegistrySearch: false,
@@ -94,19 +96,26 @@ export default {
</script>
<template>
- <url-sync>
- <template #default="{ updateQuery }">
- <registry-search
- v-if="mountRegistrySearch"
- :filter="filters"
- :sorting="sorting"
- :tokens="$options.tokens"
- :sortable-fields="sortableFields"
- @sorting:changed="updateSortingAndEmitUpdate"
- @filter:changed="updateFilters"
- @filter:submit="emitUpdate"
- @query:changed="updateQuery"
- />
- </template>
- </url-sync>
+ <local-storage-sync
+ storage-key="package_registry_list_sorting"
+ :value="sorting"
+ as-json
+ @input="updateSorting"
+ >
+ <url-sync>
+ <template #default="{ updateQuery }">
+ <registry-search
+ v-if="mountRegistrySearch"
+ :filter="filters"
+ :sorting="sorting"
+ :tokens="$options.tokens"
+ :sortable-fields="sortableFields"
+ @sorting:changed="updateSortingAndEmitUpdate"
+ @filter:changed="updateFilters"
+ @filter:submit="emitUpdate"
+ @query:changed="updateQuery"
+ />
+ </template>
+ </url-sync>
+ </local-storage-sync>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragmentTypes.json b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragmentTypes.json
deleted file mode 100644
index c61a653d10b..00000000000
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragmentTypes.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "__schema": {
- "types": [
- {
- "kind": "UNION",
- "name": "PackageMetadata",
- "possibleTypes": [
- { "name": "ComposerMetadata" },
- { "name": "ConanMetadata" },
- { "name": "MavenMetadata" },
- { "name": "NugetMetadata" },
- { "name": "PypiMetadata" }
- ]
- }
- ]
- }
-}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js
index 21d6fbc9e1f..56f95fa2c1f 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js
@@ -1,22 +1,9 @@
-import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import introspectionQueryResultData from './fragmentTypes.json';
-
-const fragmentMatcher = new IntrospectionFragmentMatcher({
- introspectionQueryResultData,
-});
Vue.use(VueApollo);
export const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- cacheConfig: {
- fragmentMatcher,
- },
- },
- ),
+ defaultClient: createDefaultClient(),
});
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue b/app/assets/javascripts/packages_and_registries/shared/components/cli_commands.vue
index 07ee3c6083b..de7ab3e6d7b 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue
+++ b/app/assets/javascripts/packages_and_registries/shared/components/cli_commands.vue
@@ -10,7 +10,7 @@ import {
COPY_BUILD_TITLE,
PUSH_COMMAND_LABEL,
COPY_PUSH_TITLE,
-} from '../../constants/index';
+} from '../constants';
const trackingLabel = 'quickstart_dropdown';
@@ -20,7 +20,20 @@ export default {
CodeInstruction,
},
mixins: [Tracking.mixin({ label: trackingLabel })],
- inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'],
+ props: {
+ dockerBuildCommand: {
+ type: String,
+ required: true,
+ },
+ dockerPushCommand: {
+ type: String,
+ required: true,
+ },
+ dockerLoginCommand: {
+ type: String,
+ required: true,
+ },
+ },
trackingLabel,
i18n: {
QUICK_START,
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue b/app/assets/javascripts/packages_and_registries/shared/components/tags_loader.vue
index b7afa5fba33..b7afa5fba33 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue
+++ b/app/assets/javascripts/packages_and_registries/shared/components/tags_loader.vue
diff --git a/app/assets/javascripts/packages_and_registries/shared/constants/index.js b/app/assets/javascripts/packages_and_registries/shared/constants/index.js
new file mode 100644
index 00000000000..7659781d96e
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/shared/constants/index.js
@@ -0,0 +1,2 @@
+export * from './package_registry';
+export * from './quick_start';
diff --git a/app/assets/javascripts/packages_and_registries/shared/constants.js b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js
index afc72a2c627..afc72a2c627 100644
--- a/app/assets/javascripts/packages_and_registries/shared/constants.js
+++ b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js
diff --git a/app/assets/javascripts/packages_and_registries/shared/constants/quick_start.js b/app/assets/javascripts/packages_and_registries/shared/constants/quick_start.js
new file mode 100644
index 00000000000..6a39c07eba2
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/shared/constants/quick_start.js
@@ -0,0 +1,9 @@
+import { s__ } from '~/locale';
+
+export const QUICK_START = s__('ContainerRegistry|CLI Commands');
+export const LOGIN_COMMAND_LABEL = s__('ContainerRegistry|Login');
+export const COPY_LOGIN_TITLE = s__('ContainerRegistry|Copy login command');
+export const BUILD_COMMAND_LABEL = s__('ContainerRegistry|Build an image');
+export const COPY_BUILD_TITLE = s__('ContainerRegistry|Copy build command');
+export const PUSH_COMMAND_LABEL = s__('ContainerRegistry|Push an image');
+export const COPY_PUSH_TITLE = s__('ContainerRegistry|Copy push command');
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 c2510a16d2f..3ef75b3ef0e 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
@@ -140,8 +140,8 @@ export default {
return {
id: 'signup-settings-modal',
text: n__(
- 'ApplicationSettings|By making this change, you will automatically approve %d user with the pending approval status.',
- 'ApplicationSettings|By making this change, you will automatically approve %d users with the pending approval status.',
+ 'ApplicationSettings|By making this change, you will automatically approve %d user who is pending approval.',
+ 'ApplicationSettings|By making this change, you will automatically approve %d users who are pending approval.',
pendingUserCount,
),
actionPrimary: {
@@ -157,7 +157,7 @@ export default {
actionCancel: {
text: __('Cancel'),
},
- title: s__('ApplicationSettings|Approve users in the pending approval status?'),
+ title: s__('ApplicationSettings|Approve users who are pending approval?'),
};
},
},
diff --git a/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js b/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js
new file mode 100644
index 00000000000..67eee2c3209
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js
@@ -0,0 +1,52 @@
+import createFlash from '~/flash';
+import axios from '../../../lib/utils/axios_utils';
+import { __ } from '../../../locale';
+
+export default class PayloadDownloader {
+ constructor(trigger) {
+ this.trigger = trigger;
+ }
+
+ init() {
+ this.spinner = this.trigger.querySelector('.js-spinner');
+ this.text = this.trigger.querySelector('.js-text');
+
+ this.trigger.addEventListener('click', (event) => {
+ event.preventDefault();
+
+ return this.requestPayload();
+ });
+ }
+
+ requestPayload() {
+ this.spinner.classList.add('d-inline-flex');
+
+ return axios
+ .get(this.trigger.dataset.endpoint, {
+ responseType: 'json',
+ })
+ .then(({ data }) => {
+ PayloadDownloader.downloadFile(data);
+ })
+ .catch(() => {
+ createFlash({
+ message: __('Error fetching payload data.'),
+ });
+ })
+ .finally(() => {
+ this.spinner.classList.remove('d-inline-flex');
+ });
+ }
+
+ static downloadFile(data) {
+ const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
+
+ const link = document.createElement('a');
+ link.href = window.URL.createObjectURL(blob);
+ link.download = `${data.recorded_at.slice(0, 10)} payload.json`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ window.URL.revokeObjectURL(link.href);
+ }
+}
diff --git a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
index 08f6633f424..c017cf0afa2 100644
--- a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
+++ b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
@@ -5,7 +5,6 @@ import { __ } from '../../../locale';
export default class PayloadPreviewer {
constructor(trigger) {
this.trigger = trigger;
- this.container = document.querySelector(trigger.dataset.payloadSelector);
this.isVisible = false;
this.isInserted = false;
}
@@ -23,21 +22,27 @@ export default class PayloadPreviewer {
});
}
+ getContainer() {
+ return document.querySelector(this.trigger.dataset.payloadSelector);
+ }
+
requestPayload() {
if (this.isInserted) return this.showPayload();
- this.spinner.classList.add('d-inline-flex');
+ this.spinner.classList.add('gl-display-inline-flex');
+
+ const container = this.getContainer();
return axios
- .get(this.container.dataset.endpoint, {
+ .get(container.dataset.endpoint, {
responseType: 'text',
})
.then(({ data }) => {
- this.spinner.classList.remove('d-inline-flex');
+ this.spinner.classList.remove('gl-display-inline-flex');
this.insertPayload(data);
})
.catch(() => {
- this.spinner.classList.remove('d-inline-flex');
+ this.spinner.classList.remove('gl-display-inline-flex');
createFlash({
message: __('Error fetching payload data.'),
});
@@ -46,19 +51,19 @@ export default class PayloadPreviewer {
hidePayload() {
this.isVisible = false;
- this.container.classList.add('d-none');
+ this.getContainer().classList.add('gl-display-none');
this.text.textContent = __('Preview payload');
}
showPayload() {
this.isVisible = true;
- this.container.classList.remove('d-none');
+ this.getContainer().classList.remove('gl-display-none');
this.text.textContent = __('Hide payload');
}
insertPayload(data) {
this.isInserted = true;
- this.container.innerHTML = data;
+ this.getContainer().innerHTML = data;
this.showPayload();
}
}
diff --git a/app/assets/javascripts/pages/admin/application_settings/service_usage_data/index.js b/app/assets/javascripts/pages/admin/application_settings/service_usage_data/index.js
new file mode 100644
index 00000000000..8a12e753847
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/application_settings/service_usage_data/index.js
@@ -0,0 +1,3 @@
+import initServiceUsageData from '~/admin/application_settings/setup_service_usage_data';
+
+initServiceUsageData();
diff --git a/app/assets/javascripts/pages/admin/runners/show/index.js b/app/assets/javascripts/pages/admin/runners/show/index.js
new file mode 100644
index 00000000000..f76f3a2430d
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/runners/show/index.js
@@ -0,0 +1,3 @@
+import { initAdminRunnerShow } from '~/runner/admin_runner_show';
+
+initAdminRunnerShow();
diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js
index f6155b2ab2f..96487e14e30 100644
--- a/app/assets/javascripts/pages/groups/edit/index.js
+++ b/app/assets/javascripts/pages/groups/edit/index.js
@@ -1,8 +1,7 @@
import { GROUP_BADGE } from '~/badges/constants';
import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory';
import initFilePickers from '~/file_pickers';
-import TransferDropdown from '~/groups/transfer_dropdown';
-import setupTransferEdit from '~/groups/transfer_edit';
+import initTransferGroupForm from '~/groups/init_transfer_group_form';
import groupsSelect from '~/groups_select';
import { initCascadingSettingsLockPopovers } from '~/namespaces/cascading_settings';
import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
@@ -15,11 +14,11 @@ document.addEventListener('DOMContentLoaded', () => {
initFilePickers();
initConfirmDanger();
initSettingsPanels();
+ initTransferGroupForm();
dirtySubmitFactory(
document.querySelectorAll('.js-general-settings-form, .js-general-permissions-form'),
);
mountBadgeSettings(GROUP_BADGE);
- setupTransferEdit('.js-group-transfer-form', '#new_parent_group_id');
// Initialize Subgroups selector
groupsSelect();
@@ -28,6 +27,4 @@ document.addEventListener('DOMContentLoaded', () => {
initSearchSettings();
initCascadingSettingsLockPopovers();
-
- return new TransferDropdown();
});
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index 01a371920f8..14ce3f775b1 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -1,6 +1,7 @@
import { groupMemberRequestFormatter } from '~/groups/members/utils';
import groupsSelect from '~/groups_select';
import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger';
+import initInviteGroupsModal from '~/invite_members/init_invite_groups_modal';
import initInviteMembersForm from '~/invite_members/init_invite_members_form';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
@@ -56,6 +57,7 @@ groupsSelect();
memberExpirationDate();
memberExpirationDate('.js-access-expiration-date-groups');
initInviteMembersModal();
+initInviteGroupsModal();
initInviteMembersTrigger();
initInviteGroupTrigger();
diff --git a/app/assets/javascripts/pages/projects/imports/new/index.js b/app/assets/javascripts/pages/projects/imports/new/index.js
new file mode 100644
index 00000000000..4acfc5265ac
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/imports/new/index.js
@@ -0,0 +1,3 @@
+import initProjectNew from '~/projects/project_new';
+
+initProjectNew.bindEvents();
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 42c40cda601..adae97c6b6f 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
@@ -2,7 +2,8 @@
import { GlProgressBar, GlSprintf, GlAlert } from '@gitlab/ui';
import eventHub from '~/invite_members/event_hub';
import { s__ } from '~/locale';
-import { ACTION_LABELS, ACTION_SECTIONS } from '../constants';
+import { getCookie, removeCookie, parseBoolean } from '~/lib/utils/common_utils';
+import { ACTION_LABELS, ACTION_SECTIONS, INVITE_MODAL_OPEN_COOKIE } from '../constants';
import LearnGitlabSectionCard from './learn_gitlab_section_card.vue';
export default {
@@ -26,7 +27,7 @@ export default {
required: true,
type: Object,
},
- inviteMembersOpen: {
+ inviteMembers: {
type: Boolean,
required: false,
default: false,
@@ -53,7 +54,7 @@ export default {
},
},
mounted() {
- if (this.inviteMembersOpen) {
+ if (this.inviteMembers && this.getCookieForInviteMembers()) {
this.openInviteMembersModal('celebrate');
}
@@ -63,8 +64,15 @@ export default {
eventHub.$off('showSuccessfulInvitationsAlert', this.handleShowSuccessfulInvitationsAlert);
},
methods: {
+ getCookieForInviteMembers() {
+ const value = parseBoolean(getCookie(INVITE_MODAL_OPEN_COOKIE));
+
+ removeCookie(INVITE_MODAL_OPEN_COOKIE);
+
+ return value;
+ },
openInviteMembersModal(mode) {
- eventHub.$emit('openModal', { mode, inviteeType: 'members', source: 'learn-gitlab' });
+ eventHub.$emit('openModal', { mode, source: 'learn-gitlab' });
},
handleShowSuccessfulInvitationsAlert() {
this.showSuccessfulInvitationsAlert = true;
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 3a401f5cb31..d0ec02bbd0c 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
@@ -31,14 +31,13 @@ export default {
this.action === 'userAdded' && isExperimentVariant('invite_for_help_continuous_onboarding')
);
},
+ openInNewTab() {
+ return ACTION_LABELS[this.action]?.openInNewTab === true;
+ },
},
methods: {
openModal() {
- eventHub.$emit('openModal', {
- inviteeType: 'members',
- source: 'learn_gitlab',
- tasksToBeDoneEnabled: true,
- });
+ eventHub.$emit('openModal', { source: 'learn_gitlab' });
},
},
};
@@ -61,8 +60,9 @@ export default {
</gl-link>
<gl-link
v-else
- target="_blank"
+ :target="openInNewTab ? '_blank' : '_self'"
:href="value.url"
+ data-testid="uncompleted-learn-gitlab-link"
data-track-action="click_link"
:data-track-label="$options.i18n.ACTION_LABELS[action].title"
data-track-property="Growth::Conversion::Experiment::LearnGitLab"
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 9e204aa6746..880cf699e5e 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
@@ -62,6 +62,7 @@ export const ACTION_LABELS = {
description: s__('LearnGitLab|Scan your code to uncover vulnerabilities before deploying.'),
section: 'deploy',
position: 1,
+ openInNewTab: true,
},
issueCreated: {
title: s__('LearnGitLab|Create an issue'),
@@ -94,3 +95,5 @@ export const ACTION_SECTIONS = {
),
},
};
+
+export const INVITE_MODAL_OPEN_COOKIE = 'confetti_post_signup';
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js
index 1f91cc46946..c62cab1a425 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import LearnGitlab from '../components/learn_gitlab.vue';
function initLearnGitlab() {
@@ -13,13 +13,13 @@ function initLearnGitlab() {
const actions = convertObjectPropsToCamelCase(JSON.parse(el.dataset.actions));
const sections = convertObjectPropsToCamelCase(JSON.parse(el.dataset.sections));
const project = convertObjectPropsToCamelCase(JSON.parse(el.dataset.project));
- const { inviteMembersOpen } = el.dataset;
+ const { inviteMembers } = el.dataset;
return new Vue({
el,
render(createElement) {
return createElement(LearnGitlab, {
- props: { actions, sections, project, inviteMembersOpen },
+ props: { actions, sections, project, inviteMembers: parseBoolean(inviteMembers) },
});
},
});
diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js
index 5d830872ed9..50733d8a145 100644
--- a/app/assets/javascripts/pages/projects/new/index.js
+++ b/app/assets/javascripts/pages/projects/new/index.js
@@ -1,4 +1,8 @@
-import { initNewProjectCreation, initNewProjectUrlSelect } from '~/projects/new';
+import {
+ initNewProjectCreation,
+ initNewProjectUrlSelect,
+ initDeploymentTargetSelect,
+} from '~/projects/new';
import initProjectVisibilitySelector from '~/projects/project_visibility';
import initProjectNew from '~/projects/project_new';
@@ -6,3 +10,4 @@ initProjectVisibilitySelector();
initProjectNew.bindEvents();
initNewProjectCreation();
initNewProjectUrlSelect();
+initDeploymentTargetSelect();
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
index 42b08bcaa7b..ee70ff858be 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
@@ -1,8 +1,8 @@
<script>
import { GlButton } from '@gitlab/ui';
-import Cookies from 'js-cookie';
import Vue from 'vue';
-import { parseBoolean } from '~/lib/utils/common_utils';
+import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils';
+
import Translate from '../../../../../vue_shared/translate';
Vue.use(Translate);
@@ -17,13 +17,13 @@ export default {
inject: ['docsUrl', 'illustrationUrl'],
data() {
return {
- calloutDismissed: parseBoolean(Cookies.get(cookieKey)),
+ calloutDismissed: parseBoolean(getCookie(cookieKey)),
};
},
methods: {
dismissCallout() {
this.calloutDismissed = true;
- Cookies.set(cookieKey, this.calloutDismissed, { expires: 365 });
+ setCookie(cookieKey, this.calloutDismissed);
},
},
};
diff --git a/app/assets/javascripts/pages/projects/planning_hierarchy/index.js b/app/assets/javascripts/pages/projects/planning_hierarchy/index.js
new file mode 100644
index 00000000000..d5dfe2d5f37
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/planning_hierarchy/index.js
@@ -0,0 +1,3 @@
+import { initWorkItemsHierarchy } from '~/work_items_hierarchy/work_items_hierarchy_bundle';
+
+initWorkItemsHierarchy();
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index a26aeeb6db4..0c17bf2f344 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -1,7 +1,7 @@
/* eslint-disable func-names, no-return-assign */
import $ from 'jquery';
-import Cookies from 'js-cookie';
+import { setCookie } from '~/lib/utils/common_utils';
import initClonePanel from '~/clone_panel';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import createFlash from '~/flash';
@@ -24,19 +24,19 @@ export default class Project {
}
$('.js-hide-no-ssh-message').on('click', function (e) {
- Cookies.set('hide_no_ssh_message', 'false');
+ setCookie('hide_no_ssh_message', 'false');
$(this).parents('.js-no-ssh-key-message').remove();
return e.preventDefault();
});
$('.js-hide-no-password-message').on('click', function (e) {
- Cookies.set('hide_no_password_message', 'false');
+ setCookie('hide_no_password_message', 'false');
$(this).parents('.js-no-password-message').remove();
return e.preventDefault();
});
$('.hide-auto-devops-implicitly-enabled-banner').on('click', function (e) {
const projectId = $(this).data('project-id');
const cookieKey = `hide_auto_devops_implicitly_enabled_banner_${projectId}`;
- Cookies.set(cookieKey, 'false');
+ setCookie(cookieKey, 'false');
$(this).parents('.auto-devops-implicitly-enabled-banner').remove();
return e.preventDefault();
});
diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js
index 947bbdacf2c..26c42247cf7 100644
--- a/app/assets/javascripts/pages/projects/project_members/index.js
+++ b/app/assets/javascripts/pages/projects/project_members/index.js
@@ -3,6 +3,7 @@ import initImportAProjectModal from '~/invite_members/init_import_a_project_moda
import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger';
import initInviteMembersForm from '~/invite_members/init_invite_members_form';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
+import initInviteGroupsModal from '~/invite_members/init_invite_groups_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { s__ } from '~/locale';
import memberExpirationDate from '~/member_expiration_date';
@@ -17,6 +18,7 @@ memberExpirationDate();
memberExpirationDate('.js-access-expiration-date-groups');
initImportAProjectModal();
initInviteMembersModal();
+initInviteGroupsModal();
initInviteMembersTrigger();
initInviteGroupTrigger();
diff --git a/app/assets/javascripts/pages/projects/security/configuration/index.js b/app/assets/javascripts/pages/projects/security/configuration/index.js
index 5f801501b2f..f13a48c1224 100644
--- a/app/assets/javascripts/pages/projects/security/configuration/index.js
+++ b/app/assets/javascripts/pages/projects/security/configuration/index.js
@@ -1,3 +1,3 @@
import { initSecurityConfiguration } from '~/security_configuration';
-initSecurityConfiguration(document.querySelector('#js-security-configuration-static'));
+initSecurityConfiguration(document.querySelector('#js-security-configuration'));
diff --git a/app/assets/javascripts/pages/projects/serverless/index.js b/app/assets/javascripts/pages/projects/serverless/index.js
index 640301dd478..9ae81b327b1 100644
--- a/app/assets/javascripts/pages/projects/serverless/index.js
+++ b/app/assets/javascripts/pages/projects/serverless/index.js
@@ -1,5 +1,3 @@
import ServerlessBundle from '~/serverless/serverless_bundle';
-import initServerlessSurveyBanner from '~/serverless/survey_banner';
-initServerlessSurveyBanner();
new ServerlessBundle(); // eslint-disable-line no-new
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 d5e00f54e91..184bda4410f 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
@@ -280,7 +280,7 @@ export default {
}
return s__(
- 'ProjectSettings|View and edit files in this project. Non-project members will only have read access.',
+ 'ProjectSettings|View and edit files in this project. Non-project members have only read access.',
);
},
cveIdRequestIsDisabled() {
diff --git a/app/assets/javascripts/pages/users/index.js b/app/assets/javascripts/pages/users/index.js
index 58ceb524360..5cbb7a06bc1 100644
--- a/app/assets/javascripts/pages/users/index.js
+++ b/app/assets/javascripts/pages/users/index.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import Cookies from 'js-cookie';
+import { setCookie } from '~/lib/utils/common_utils';
import UserCallout from '~/user_callout';
import UserTabs from './user_tabs';
@@ -10,7 +10,7 @@ function initUserProfile(action) {
// hide project limit message
$('.hide-project-limit-message').on('click', (e) => {
e.preventDefault();
- Cookies.set('hide_project_limit_message', 'false');
+ setCookie('hide_project_limit_message', 'false');
$(this).parents('.project-limit-message').remove();
});
}
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 ed30198244f..710f49b833c 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -124,6 +124,9 @@ export default {
const fileName = this.requests[0].truncatedUrl;
return `${fileName}_perf_bar_${Date.now()}.json`;
},
+ memoryReportPath() {
+ return mergeUrlParams({ performance_bar: 'memory' }, window.location.href);
+ },
},
mounted() {
this.currentRequest = this.requestId;
@@ -182,6 +185,15 @@ export default {
s__('PerformanceBar|Download')
}}</a>
</div>
+ <div
+ v-if="currentRequest.details && env === 'development'"
+ id="peek-memory-report"
+ class="view"
+ >
+ <a class="gl-text-blue-200" :href="memoryReportPath">{{
+ s__('PerformanceBar|Memory report')
+ }}</a>
+ </div>
<div v-if="currentRequest.details" id="peek-flamegraph" class="view">
<span class="gl-text-white-200">{{ s__('PerformanceBar|Flamegraph with mode:') }}</span>
<a class="gl-text-blue-200" :href="flamegraphPath('wall')">{{
diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js
index 66e999ca43b..eb5b50dd1ec 100644
--- a/app/assets/javascripts/performance_bar/index.js
+++ b/app/assets/javascripts/performance_bar/index.js
@@ -20,6 +20,7 @@ const initPerformanceBar = (el) => {
return new Vue({
el,
+ name: 'PerformanceBarRoot',
components: {
PerformanceBarApp: () => import('./components/performance_bar_app.vue'),
},
diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js
index bc83844b8b9..b003302ec8e 100644
--- a/app/assets/javascripts/persistent_user_callout.js
+++ b/app/assets/javascripts/persistent_user_callout.js
@@ -7,10 +7,11 @@ const DEFERRED_LINK_CLASS = 'deferred-link';
export default class PersistentUserCallout {
constructor(container, options = container.dataset) {
- const { dismissEndpoint, featureId, deferLinks } = options;
+ const { dismissEndpoint, featureId, groupId, deferLinks } = options;
this.container = container;
this.dismissEndpoint = dismissEndpoint;
this.featureId = featureId;
+ this.groupId = groupId;
this.deferLinks = parseBoolean(deferLinks);
this.init();
@@ -52,6 +53,7 @@ export default class PersistentUserCallout {
axios
.post(this.dismissEndpoint, {
feature_name: this.featureId,
+ group_id: this.groupId,
})
.then(() => {
this.container.remove();
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index a7f8704b559..337c204c36a 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -10,6 +10,7 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-new-user-signups-cap-reached',
'.js-eoa-bronze-plan-banner',
'.js-security-newsletter-callout',
+ '.js-approaching-seats-count-threshold',
];
const initCallouts = () => {
diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
index 54c9688d88f..8ff1aea020f 100644
--- a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
+++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
@@ -1,11 +1,11 @@
<script>
-import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale';
import {
COMMIT_ACTION_CREATE,
COMMIT_ACTION_UPDATE,
COMMIT_FAILURE,
COMMIT_SUCCESS,
+ COMMIT_SUCCESS_WITH_REDIRECT,
} from '../../constants';
import commitCIFile from '../../graphql/mutations/commit_ci_file.mutation.graphql';
import updateCurrentBranchMutation from '../../graphql/mutations/client/update_current_branch.mutation.graphql';
@@ -15,9 +15,6 @@ import getCurrentBranch from '../../graphql/queries/client/current_branch.query.
import CommitForm from './commit_form.vue';
-const MR_SOURCE_BRANCH = 'merge_request[source_branch]';
-const MR_TARGET_BRANCH = 'merge_request[target_branch]';
-
export default {
alertTexts: {
[COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'),
@@ -29,7 +26,7 @@ export default {
components: {
CommitForm,
},
- inject: ['projectFullPath', 'ciConfigPath', 'newMergeRequestPath'],
+ inject: ['projectFullPath', 'ciConfigPath'],
props: {
ciFileContent: {
type: String,
@@ -74,16 +71,6 @@ export default {
},
},
methods: {
- redirectToNewMergeRequest(sourceBranch) {
- const url = mergeUrlParams(
- {
- [MR_SOURCE_BRANCH]: sourceBranch,
- [MR_TARGET_BRANCH]: this.currentBranch,
- },
- this.newMergeRequestPath,
- );
- redirectTo(url);
- },
async onCommitSubmit({ message, targetBranch, openMergeRequest }) {
this.isSaving = true;
@@ -112,12 +99,25 @@ export default {
if (errors?.length) {
this.$emit('showError', { type: COMMIT_FAILURE, reasons: errors });
- } else if (openMergeRequest) {
- this.redirectToNewMergeRequest(targetBranch);
} else {
- this.$emit('commit', { type: COMMIT_SUCCESS });
+ const commitBranch = targetBranch;
+ const params = openMergeRequest
+ ? {
+ type: COMMIT_SUCCESS_WITH_REDIRECT,
+ params: {
+ sourceBranch: commitBranch,
+ targetBranch: this.currentBranch,
+ },
+ }
+ : { type: COMMIT_SUCCESS };
+
+ this.$emit('commit', {
+ ...params,
+ });
+
this.updateLastCommitBranch(targetBranch);
this.updateCurrentBranch(targetBranch);
+
if (this.currentBranch === targetBranch) {
this.$emit('updateCommitSha');
}
diff --git a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
index bfbf24c6b13..5177cea900c 100644
--- a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
+++ b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
@@ -5,6 +5,11 @@ import SourceEditor from '~/vue_shared/components/source_editor.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
+ editorOptions: {
+ // Quick suggestions is so that monaco can provide
+ // autocomplete for keywords
+ quickSuggestions: true,
+ },
components: {
SourceEditor,
},
@@ -29,6 +34,7 @@ export default {
<div class="gl-border-solid gl-border-gray-100 gl-border-1 gl-border-t-none!">
<source-editor
ref="editor"
+ :editor-options="$options.editorOptions"
:file-name="ciConfigPath"
v-bind="$attrs"
@[$options.readyEvent]="registerCiSchema($event)"
diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
index 4f79a81d539..ead2076ec3b 100644
--- a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
+++ b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
@@ -9,7 +9,6 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import { produce } from 'immer';
-import { fetchPolicies } from '~/lib/graphql';
import { historyPushState } from '~/lib/utils/common_utils';
import { setUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
@@ -63,8 +62,6 @@ export default {
return {
availableBranches: [],
branchSelected: null,
- filteredBranches: [],
- isSearchingBranches: false,
pageLimit: this.paginationLimit,
pageCounter: 0,
searchTerm: '',
@@ -76,10 +73,9 @@ export default {
query: getAvailableBranchesQuery,
variables() {
return {
- limit: this.paginationLimit,
offset: 0,
projectFullPath: this.projectFullPath,
- searchPattern: '*',
+ ...this.availableBranchesVariables,
};
},
update(data) {
@@ -116,14 +112,24 @@ export default {
},
},
computed: {
- branches() {
- return this.searchTerm.length > 0 ? this.filteredBranches : this.availableBranches;
+ availableBranchesVariables() {
+ if (this.searchTerm.length > 0) {
+ return {
+ limit: this.totalBranches,
+ searchPattern: `*${this.searchTerm}*`,
+ };
+ }
+
+ return {
+ limit: this.paginationLimit,
+ searchPattern: '*',
+ };
},
enableBranchSwitcher() {
- return this.branches.length > 0 || this.searchTerm.length > 0;
+ return this.availableBranches.length > 0 || this.searchTerm.length > 0;
},
isBranchesLoading() {
- return this.$apollo.queries.availableBranches.loading || this.isSearchingBranches;
+ return this.$apollo.queries.availableBranches.loading;
},
},
watch: {
@@ -134,38 +140,21 @@ export default {
},
},
methods: {
- availableBranchesQueryVars(varsOverride = {}) {
- if (this.searchTerm.length > 0) {
- return {
- limit: this.totalBranches,
- offset: 0,
- projectFullPath: this.projectFullPath,
- searchPattern: `*${this.searchTerm}*`,
- ...varsOverride,
- };
- }
-
- return {
- limit: this.paginationLimit,
- offset: this.pageCounter * this.paginationLimit,
- projectFullPath: this.projectFullPath,
- searchPattern: '*',
- ...varsOverride,
- };
- },
// if there is no searchPattern, paginate by {paginationLimit} branches
fetchNextBranches() {
if (
this.isBranchesLoading ||
this.searchTerm.length > 0 ||
- this.branches.length >= this.totalBranches
+ this.availableBranches.length >= this.totalBranches
) {
return;
}
this.$apollo.queries.availableBranches
.fetchMore({
- variables: this.availableBranchesQueryVars(),
+ variables: {
+ offset: this.pageCounter * this.paginationLimit,
+ },
updateQuery(previousResult, { fetchMoreResult }) {
const previousBranches = previousResult.project.repository.branchNames;
const newBranches = fetchMoreResult.project.repository.branchNames;
@@ -204,23 +193,6 @@ export default {
async setSearchTerm(newSearchTerm) {
this.pageCounter = 0;
this.searchTerm = newSearchTerm.trim();
-
- if (this.searchTerm === '') {
- this.pageLimit = this.paginationLimit;
- return;
- }
-
- this.isSearchingBranches = true;
- const fetchResults = await this.$apollo
- .query({
- query: getAvailableBranchesQuery,
- fetchPolicy: fetchPolicies.NETWORK_ONLY,
- variables: this.availableBranchesQueryVars(),
- })
- .catch(this.showFetchError);
-
- this.isSearchingBranches = false;
- this.filteredBranches = fetchResults?.data?.project?.repository?.branchNames || [];
},
showFetchError() {
this.$emit('showError', {
@@ -255,14 +227,14 @@ export default {
</gl-dropdown-section-header>
<gl-infinite-scroll
- :fetched-items="branches.length"
+ :fetched-items="availableBranches.length"
:max-list-height="250"
data-qa-selector="branch_menu_container"
@bottomReached="fetchNextBranches"
>
<template #items>
<gl-dropdown-item
- v-for="branch in branches"
+ v-for="branch in availableBranches"
:key="branch"
:is-checked="currentBranch === branch"
:is-check-item="true"
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
index 72b492a5877..4b9c98135ec 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
@@ -49,7 +49,7 @@ export default {
pipelineEtag: {
query: getPipelineEtag,
update(data) {
- return data.etags.pipeline;
+ return data.etags?.pipeline;
},
},
pipeline: {
diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue
index 7206f19d060..c72cff4c6f8 100644
--- a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue
+++ b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue
@@ -5,6 +5,7 @@ import { __, s__ } from '~/locale';
import {
COMMIT_FAILURE,
COMMIT_SUCCESS,
+ COMMIT_SUCCESS_WITH_REDIRECT,
DEFAULT_FAILURE,
DEFAULT_SUCCESS,
LOAD_FAILURE_UNKNOWN,
@@ -21,14 +22,18 @@ export default {
GlAlert,
CodeSnippetAlert,
},
- errorTexts: {
+
+ errors: {
[COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'),
[DEFAULT_FAILURE]: __('Something went wrong on our end.'),
[LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'),
[PIPELINE_FAILURE]: s__('Pipelines|There was a problem with loading the pipeline data.'),
},
- successTexts: {
+ success: {
[COMMIT_SUCCESS]: __('Your changes have been successfully committed.'),
+ [COMMIT_SUCCESS_WITH_REDIRECT]: s__(
+ 'Pipelines|Your changes have been successfully committed. Now redirecting to the new merge request page.',
+ ),
[DEFAULT_SUCCESS]: __('Your action succeeded.'),
},
props: {
@@ -65,42 +70,20 @@ export default {
},
computed: {
failure() {
- switch (this.failureType) {
- case LOAD_FAILURE_UNKNOWN:
- return {
- text: this.$options.errorTexts[LOAD_FAILURE_UNKNOWN],
- variant: 'danger',
- };
- case COMMIT_FAILURE:
- return {
- text: this.$options.errorTexts[COMMIT_FAILURE],
- variant: 'danger',
- };
- case PIPELINE_FAILURE:
- return {
- text: this.$options.errorTexts[PIPELINE_FAILURE],
- variant: 'danger',
- };
- default:
- return {
- text: this.$options.errorTexts[DEFAULT_FAILURE],
- variant: 'danger',
- };
- }
+ const { errors } = this.$options;
+
+ return {
+ text: errors[this.failureType] ?? errors[DEFAULT_FAILURE],
+ variant: 'danger',
+ };
},
success() {
- switch (this.successType) {
- case COMMIT_SUCCESS:
- return {
- text: this.$options.successTexts[COMMIT_SUCCESS],
- variant: 'info',
- };
- default:
- return {
- text: this.$options.successTexts[DEFAULT_SUCCESS],
- variant: 'info',
- };
- }
+ const { success } = this.$options;
+
+ return {
+ text: success[this.successType] ?? success[DEFAULT_SUCCESS],
+ variant: 'info',
+ };
},
},
created() {
diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js
index bc79b0742e7..a65463d02aa 100644
--- a/app/assets/javascripts/pipeline_editor/constants.js
+++ b/app/assets/javascripts/pipeline_editor/constants.js
@@ -20,6 +20,7 @@ export const EDITOR_APP_VALID_STATUSES = [
export const COMMIT_FAILURE = 'COMMIT_FAILURE';
export const COMMIT_SUCCESS = 'COMMIT_SUCCESS';
+export const COMMIT_SUCCESS_WITH_REDIRECT = 'COMMIT_SUCCESS_WITH_REDIRECT';
export const DEFAULT_FAILURE = 'DEFAULT_FAILURE';
export const DEFAULT_SUCCESS = 'DEFAULT_SUCCESS';
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
index 90f48195c5e..1da50c55a68 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
@@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon, GlModal } from '@gitlab/ui';
import { fetchPolicies } from '~/lib/graphql';
-import { queryToObject } from '~/lib/utils/url_utility';
+import { mergeUrlParams, queryToObject, redirectTo } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils';
@@ -11,6 +11,7 @@ import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_stat
import PipelineEditorMessages from './components/ui/pipeline_editor_messages.vue';
import {
COMMIT_SHA_POLL_INTERVAL,
+ COMMIT_SUCCESS_WITH_REDIRECT,
EDITOR_APP_STATUS_EMPTY,
EDITOR_APP_STATUS_LOADING,
EDITOR_APP_STATUS_LINT_UNAVAILABLE,
@@ -27,6 +28,9 @@ import getTemplate from './graphql/queries/get_starter_template.query.graphql';
import getLatestCommitShaQuery from './graphql/queries/latest_commit_sha.query.graphql';
import PipelineEditorHome from './pipeline_editor_home.vue';
+const MR_SOURCE_BRANCH = 'merge_request[source_branch]';
+const MR_TARGET_BRANCH = 'merge_request[target_branch]';
+
export default {
components: {
ConfirmUnsavedChangesDialog,
@@ -36,14 +40,7 @@ export default {
PipelineEditorHome,
PipelineEditorMessages,
},
- inject: {
- ciConfigPath: {
- default: '',
- },
- projectFullPath: {
- default: '',
- },
- },
+ inject: ['ciConfigPath', 'newMergeRequestPath', 'projectFullPath'],
data() {
return {
ciConfigData: {},
@@ -57,7 +54,7 @@ export default {
lastCommittedContent: '',
shouldSkipStartScreen: false,
showFailure: false,
- showResetComfirmationModal: false,
+ showResetConfirmationModal: false,
showStartScreen: false,
showSuccess: false,
starterTemplate: '',
@@ -199,7 +196,7 @@ export default {
currentBranch: {
query: getCurrentBranch,
update(data) {
- return data.workBranches.current.name;
+ return data.workBranches?.current?.name;
},
},
starterTemplate: {
@@ -217,7 +214,7 @@ export default {
return data.project?.ciTemplate?.content || '';
},
result({ data }) {
- this.updateCiConfig(data.project?.ciTemplate?.content || '');
+ this.updateCiConfig(data?.project?.ciTemplate?.content || '');
},
error() {
this.reportFailure(LOAD_FAILURE_UNKNOWN);
@@ -271,17 +268,39 @@ export default {
this.checkShouldSkipStartScreen();
},
methods: {
+ checkShouldSkipStartScreen() {
+ const params = queryToObject(window.location.search);
+ this.shouldSkipStartScreen = Boolean(params?.add_new_config_file);
+ },
+ confirmReset() {
+ if (this.hasUnsavedChanges) {
+ this.showResetConfirmationModal = true;
+ }
+ },
hideFailure() {
this.showFailure = false;
},
hideSuccess() {
this.showSuccess = false;
},
- confirmReset() {
- if (this.hasUnsavedChanges) {
- this.showResetComfirmationModal = true;
+ loadTemplateFromURL() {
+ const templateName = queryToObject(window.location.search)?.template;
+
+ if (templateName) {
+ this.starterTemplateName = templateName;
+ this.setNewEmptyCiConfigFile();
}
},
+ redirectToNewMergeRequest(sourceBranch, targetBranch) {
+ const url = mergeUrlParams(
+ {
+ [MR_SOURCE_BRANCH]: sourceBranch,
+ [MR_TARGET_BRANCH]: targetBranch,
+ },
+ this.newMergeRequestPath,
+ );
+ redirectTo(url);
+ },
async refetchContent() {
this.$apollo.queries.initialCiFileContent.skip = false;
await this.$apollo.queries.initialCiFileContent.refetch();
@@ -298,7 +317,7 @@ export default {
this.successType = type;
},
resetContent() {
- this.showResetComfirmationModal = false;
+ this.showResetConfirmationModal = false;
this.currentCiFileContent = this.lastCommittedContent;
},
setAppStatus(appStatus) {
@@ -323,7 +342,7 @@ export default {
this.isFetchingCommitSha = true;
this.$apollo.queries.commitSha.refetch();
},
- updateOnCommit({ type }) {
+ async updateOnCommit({ type, params = {} }) {
this.reportSuccess(type);
if (this.isNewCiConfigFile) {
@@ -333,19 +352,17 @@ export default {
// Keep track of the latest committed content to know
// if the user has made changes to the file that are unsaved.
this.lastCommittedContent = this.currentCiFileContent;
- },
- loadTemplateFromURL() {
- const templateName = queryToObject(window.location.search)?.template;
- if (templateName) {
- this.starterTemplateName = templateName;
- this.setNewEmptyCiConfigFile();
+ if (type === COMMIT_SUCCESS_WITH_REDIRECT) {
+ const { sourceBranch, targetBranch } = params;
+ // This force update does 2 things for us:
+ // 1. It make sure `hasUnsavedChanges` is updated so
+ // we don't show a modal when the user creates an MR
+ // 2. Ensure the commit success banner is visible.
+ await this.$forceUpdate();
+ this.redirectToNewMergeRequest(sourceBranch, targetBranch);
}
},
- checkShouldSkipStartScreen() {
- const params = queryToObject(window.location.search);
- this.shouldSkipStartScreen = Boolean(params?.add_new_config_file);
- },
},
};
</script>
@@ -358,7 +375,7 @@ export default {
@createEmptyConfigFile="setNewEmptyCiConfigFile"
@refetchContent="refetchContent"
/>
- <div v-else>
+ <div v-else class="gl-pr-10">
<pipeline-editor-messages
:failure-type="failureType"
:failure-reasons="failureReasons"
@@ -382,7 +399,7 @@ export default {
@updateCommitSha="updateCommitSha"
/>
<gl-modal
- v-model="showResetComfirmationModal"
+ v-model="showResetConfirmationModal"
modal-id="reset-content"
:title="$options.i18n.resetModal.title"
:action-cancel="$options.i18n.resetModal.actionCancel"
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
index 96680080f0c..bb759477e1e 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
@@ -90,7 +90,7 @@ export default {
</script>
<template>
- <div class="gl-pr-10 gl-transition-medium gl-w-full">
+ <div class="gl-transition-medium gl-w-full">
<gl-modal
v-if="showSwitchBranchModal"
visible
diff --git a/app/assets/javascripts/pipeline_new/constants.js b/app/assets/javascripts/pipeline_new/constants.js
index a6c9f3cb746..43f7634083b 100644
--- a/app/assets/javascripts/pipeline_new/constants.js
+++ b/app/assets/javascripts/pipeline_new/constants.js
@@ -1,3 +1,4 @@
+import { __ } from '~/locale';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
export const VARIABLE_TYPE = 'env_var';
@@ -7,5 +8,7 @@ export const CONFIG_VARIABLES_TIMEOUT = 5000;
export const BRANCH_REF_TYPE = 'branch';
export const TAG_REF_TYPE = 'tag';
-export const CC_VALIDATION_REQUIRED_ERROR =
- 'Credit card required to be on file in order to create a pipeline';
+// must match pipeline/chain/validate/after_config.rb
+export const CC_VALIDATION_REQUIRED_ERROR = __(
+ 'Credit card required to be on file in order to create a pipeline',
+);
diff --git a/app/assets/javascripts/pipeline_wizard/components/commit.vue b/app/assets/javascripts/pipeline_wizard/components/commit.vue
new file mode 100644
index 00000000000..518b41c66b1
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/components/commit.vue
@@ -0,0 +1,224 @@
+<script>
+import { GlAlert, GlButton, GlForm, GlFormGroup, GlFormTextarea } from '@gitlab/ui';
+import RefSelector from '~/ref/components/ref_selector.vue';
+import { __, s__, sprintf } from '~/locale';
+import createCommitMutation from '../queries/create_commit.graphql';
+import getFileMetaDataQuery from '../queries/get_file_meta.graphql';
+import StepNav from './step_nav.vue';
+
+export const i18n = {
+ updateFileHeading: s__('PipelineWizard|Commit changes to your file'),
+ createFileHeading: s__('PipelineWizard|Commit your new file'),
+ fieldRequiredFeedback: __('This field is required'),
+ commitMessageLabel: s__('PipelineWizard|Commit Message'),
+ branchSelectorLabel: s__('PipelineWizard|Commit file to Branch'),
+ defaultUpdateCommitMessage: s__('PipelineWizardDefaultCommitMessage|Update %{filename}'),
+ defaultCreateCommitMessage: s__('PipelineWizardDefaultCommitMessage|Add %{filename}'),
+ commitButtonLabel: s__('PipelineWizard|Commit'),
+ commitSuccessMessage: s__('PipelineWizard|The file has been committed.'),
+ errors: {
+ loadError: s__(
+ 'PipelineWizard|There was a problem while checking whether your file already exists in the specified branch.',
+ ),
+ commitError: s__('PipelineWizard|There was a problem committing the changes.'),
+ },
+};
+
+const COMMIT_ACTION = {
+ CREATE: 'CREATE',
+ UPDATE: 'UPDATE',
+};
+
+export default {
+ i18n,
+ name: 'PipelineWizardCommitStep',
+ components: {
+ RefSelector,
+ GlAlert,
+ GlButton,
+ GlForm,
+ GlFormGroup,
+ GlFormTextarea,
+ StepNav,
+ },
+ props: {
+ prev: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ defaultBranch: {
+ type: String,
+ required: true,
+ },
+ fileContent: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ filename: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ branch: this.defaultBranch,
+ loading: false,
+ loadError: null,
+ commitError: null,
+ message: null,
+ };
+ },
+ computed: {
+ fileExistsInRepo() {
+ return this.project?.repository?.blobs.nodes.length > 0;
+ },
+ commitAction() {
+ return this.fileExistsInRepo ? COMMIT_ACTION.UPDATE : COMMIT_ACTION.CREATE;
+ },
+ defaultMessage() {
+ return sprintf(
+ this.fileExistsInRepo
+ ? this.$options.i18n.defaultUpdateCommitMessage
+ : this.$options.i18n.defaultCreateCommitMessage,
+ { filename: this.filename },
+ );
+ },
+ isCommitButtonEnabled() {
+ return this.fileExistsCheckInProgress;
+ },
+ fileExistsCheckInProgress() {
+ return this.$apollo.queries.project.loading;
+ },
+ mutationPayload() {
+ return {
+ mutation: createCommitMutation,
+ variables: {
+ input: {
+ projectPath: this.projectPath,
+ branch: this.branch,
+ message: this.message || this.defaultMessage,
+ actions: [
+ {
+ action: this.commitAction,
+ filePath: `/${this.filename}`,
+ content: this.fileContent,
+ },
+ ],
+ },
+ },
+ };
+ },
+ },
+ apollo: {
+ project: {
+ query: getFileMetaDataQuery,
+ variables() {
+ this.loadError = null;
+ return {
+ fullPath: this.projectPath,
+ filePath: this.filename,
+ ref: this.branch,
+ };
+ },
+ error() {
+ this.loadError = this.$options.i18n.errors.loadError;
+ },
+ },
+ },
+ methods: {
+ async commit() {
+ this.loading = true;
+ try {
+ const { data } = await this.$apollo.mutate(this.mutationPayload);
+ const hasError = Boolean(data.commitCreate.errors?.length);
+ if (hasError) {
+ this.commitError = this.$options.i18n.errors.commitError;
+ } else {
+ this.handleCommitSuccess();
+ }
+ } catch (e) {
+ this.commitError = this.$options.i18n.errors.commitError;
+ } finally {
+ this.loading = false;
+ }
+ },
+ handleCommitSuccess() {
+ this.$toast.show(this.$options.i18n.commitSuccessMessage);
+ this.$emit('done');
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h4 v-if="fileExistsInRepo" key="create-heading">
+ {{ $options.i18n.updateFileHeading }}
+ </h4>
+ <h4 v-else key="update-heading">
+ {{ $options.i18n.createFileHeading }}
+ </h4>
+ <gl-alert
+ v-if="!!loadError"
+ :dismissible="false"
+ class="gl-mb-5"
+ data-testid="load-error"
+ variant="danger"
+ >
+ {{ loadError }}
+ </gl-alert>
+ <gl-form class="gl-max-w-48">
+ <gl-form-group
+ :invalid-feedback="$options.i18n.fieldRequiredFeedback"
+ :label="$options.i18n.commitMessageLabel"
+ data-testid="commit_message_group"
+ label-for="commit_message"
+ >
+ <gl-form-textarea
+ id="commit_message"
+ v-model="message"
+ :placeholder="defaultMessage"
+ data-testid="commit_message"
+ size="md"
+ @input="(v) => $emit('update:message', v)"
+ />
+ </gl-form-group>
+ <gl-form-group
+ :invalid-feedback="$options.i18n.fieldRequiredFeedback"
+ :label="$options.i18n.branchSelectorLabel"
+ data-testid="branch_selector_group"
+ label-for="branch"
+ >
+ <ref-selector id="branch" v-model="branch" data-testid="branch" :project-id="projectPath" />
+ </gl-form-group>
+ <gl-alert
+ v-if="!!commitError"
+ :dismissible="false"
+ class="gl-mb-5"
+ data-testid="commit-error"
+ variant="danger"
+ >
+ {{ commitError }}
+ </gl-alert>
+ <step-nav show-back-button v-bind="$props" @back="$emit('go-back')">
+ <template #after>
+ <gl-button
+ :disabled="isCommitButtonEnabled"
+ :loading="fileExistsCheckInProgress || loading"
+ category="primary"
+ variant="confirm"
+ @click="commit"
+ >
+ {{ $options.i18n.commitButtonLabel }}
+ </gl-button>
+ </template>
+ </step-nav>
+ </gl-form>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_wizard/components/editor.vue b/app/assets/javascripts/pipeline_wizard/components/editor.vue
new file mode 100644
index 00000000000..41611233f71
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/components/editor.vue
@@ -0,0 +1,94 @@
+<script>
+import { debounce } from 'lodash';
+import { isDocument } from 'yaml';
+import { CONTENT_UPDATE_DEBOUNCE } from '~/editor/constants';
+import SourceEditor from '~/editor/source_editor';
+import { YamlEditorExtension } from '~/editor/extensions/source_editor_yaml_ext';
+import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
+
+export default {
+ name: 'YamlEditor',
+ props: {
+ doc: {
+ type: Object,
+ required: true,
+ validator: (d) => isDocument(d),
+ },
+ highlight: {
+ type: [String, Array],
+ required: false,
+ default: null,
+ },
+ filename: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ editor: null,
+ isUpdating: false,
+ yamlEditorExtension: null,
+ };
+ },
+ watch: {
+ doc: {
+ handler() {
+ this.updateEditorContent();
+ },
+ deep: true,
+ },
+ highlight(v) {
+ this.requestHighlight(v);
+ },
+ },
+ mounted() {
+ this.editor = new SourceEditor().createInstance({
+ el: this.$el,
+ blobPath: this.filename,
+ language: 'yaml',
+ });
+ [, this.yamlEditorExtension] = this.editor.use([
+ { definition: SourceEditorExtension },
+ {
+ definition: YamlEditorExtension,
+ setupOptions: {
+ highlightPath: this.highlight,
+ },
+ },
+ ]);
+ this.editor.onDidChangeModelContent(
+ debounce(() => this.handleChange(), CONTENT_UPDATE_DEBOUNCE),
+ );
+ this.updateEditorContent();
+ this.emitValue();
+ },
+ methods: {
+ async updateEditorContent() {
+ this.isUpdating = true;
+ this.editor.setDoc(this.doc);
+ this.isUpdating = false;
+ this.requestHighlight(this.highlight);
+ },
+ handleChange() {
+ this.emitValue();
+ if (!this.isUpdating) {
+ this.handleTouch();
+ }
+ },
+ emitValue() {
+ this.$emit('update:yaml', this.editor.getValue());
+ },
+ handleTouch() {
+ this.$emit('touch');
+ },
+ requestHighlight(path) {
+ this.editor.highlight(path, true);
+ },
+ },
+};
+</script>
+
+<template>
+ <div id="source-editor-yaml-editor"></div>
+</template>
diff --git a/app/assets/javascripts/pipeline_wizard/components/step_nav.vue b/app/assets/javascripts/pipeline_wizard/components/step_nav.vue
new file mode 100644
index 00000000000..8f9198855c6
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/components/step_nav.vue
@@ -0,0 +1,54 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+
+export default {
+ name: 'StepNav',
+ components: {
+ GlButton,
+ },
+ props: {
+ showBackButton: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showNextButton: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ nextButtonEnabled: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <slot name="before"></slot>
+ <gl-button
+ v-if="showBackButton"
+ category="secondary"
+ data-testid="back-button"
+ @click="$emit('back')"
+ >
+ {{ __('Back') }}
+ </gl-button>
+ <gl-button
+ v-if="showNextButton"
+ :disabled="!nextButtonEnabled"
+ category="primary"
+ data-testid="next-button"
+ variant="confirm"
+ @click="$emit('next')"
+ >
+ {{ __('Next') }}
+ </gl-button>
+ <slot name="after"></slot>
+ </div>
+</template>
+
+<style scoped></style>
diff --git a/app/assets/javascripts/pipeline_wizard/components/widgets/text.vue b/app/assets/javascripts/pipeline_wizard/components/widgets/text.vue
new file mode 100644
index 00000000000..26235b20ce9
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/components/widgets/text.vue
@@ -0,0 +1,126 @@
+<script>
+import { GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+import { s__ } from '~/locale';
+
+const VALIDATION_STATE = {
+ NO_VALIDATION: null,
+ INVALID: false,
+ VALID: true,
+};
+
+export default {
+ name: 'TextWidget',
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ },
+ props: {
+ label: {
+ type: String,
+ required: true,
+ },
+ description: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ placeholder: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ invalidFeedback: {
+ type: String,
+ required: false,
+ default: s__('PipelineWizardInputValidation|This value is not valid'),
+ },
+ id: {
+ type: String,
+ required: false,
+ default: () => uniqueId('textWidget-'),
+ },
+ pattern: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ validate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ required: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ default: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ touched: false,
+ value: this.default,
+ };
+ },
+ computed: {
+ validationState() {
+ if (!this.showValidationState) return VALIDATION_STATE.NO_VALIDATION;
+ if (this.isRequiredButEmpty) return VALIDATION_STATE.INVALID;
+ return this.needsValidationAndPasses ? VALIDATION_STATE.VALID : VALIDATION_STATE.INVALID;
+ },
+ showValidationState() {
+ return this.touched || this.validate;
+ },
+ isRequiredButEmpty() {
+ return this.required && !this.value;
+ },
+ needsValidationAndPasses() {
+ return !this.pattern || new RegExp(this.pattern).test(this.value);
+ },
+ invalidFeedbackMessage() {
+ return this.isRequiredButEmpty
+ ? s__('PipelineWizardInputValidation|This field is required')
+ : this.invalidFeedback;
+ },
+ },
+ watch: {
+ validationState(v) {
+ this.$emit('update:valid', v);
+ },
+ value(v) {
+ this.$emit('input', v.trim());
+ },
+ },
+ created() {
+ if (this.default) {
+ this.$emit('input', this.value);
+ }
+ },
+};
+</script>
+
+<template>
+ <div data-testid="text-widget">
+ <gl-form-group
+ :description="description"
+ :invalid-feedback="invalidFeedbackMessage"
+ :label="label"
+ :label-for="id"
+ :state="validationState"
+ >
+ <gl-form-input
+ :id="id"
+ v-model="value"
+ :placeholder="placeholder"
+ :state="validationState"
+ type="text"
+ @blur="touched = true"
+ />
+ </gl-form-group>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_wizard/queries/create_commit.graphql b/app/assets/javascripts/pipeline_wizard/queries/create_commit.graphql
new file mode 100644
index 00000000000..9abf8eff587
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/queries/create_commit.graphql
@@ -0,0 +1,9 @@
+mutation CreateCommit($input: CommitCreateInput!) {
+ commitCreate(input: $input) {
+ commit {
+ id
+ }
+ content
+ errors
+ }
+}
diff --git a/app/assets/javascripts/pipeline_wizard/queries/get_file_meta.graphql b/app/assets/javascripts/pipeline_wizard/queries/get_file_meta.graphql
new file mode 100644
index 00000000000..87f014fade6
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/queries/get_file_meta.graphql
@@ -0,0 +1,12 @@
+query GetFileMetadata($fullPath: ID!, $filePath: String!, $ref: String) {
+ project(fullPath: $fullPath) {
+ id
+ repository {
+ blobs(paths: [$filePath], ref: $ref) {
+ nodes {
+ id
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
index 12c3f9a7f40..795ba91a164 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
@@ -59,7 +59,11 @@ export default {
</script>
<template>
<!-- eslint-disable @gitlab/vue-no-data-toggle -->
- <div :id="computedJobId" class="ci-job-dropdown-container dropdown dropright">
+ <div
+ :id="computedJobId"
+ class="ci-job-dropdown-container dropdown dropright"
+ data-qa-selector="job_dropdown_container"
+ >
<button
type="button"
data-toggle="dropdown"
@@ -79,7 +83,10 @@ export default {
</div>
</button>
- <ul class="dropdown-menu big-pipeline-graph-dropdown-menu js-grouped-pipeline-dropdown">
+ <ul
+ class="dropdown-menu big-pipeline-graph-dropdown-menu js-grouped-pipeline-dropdown"
+ data-qa-selector="jobs_dropdown_menu"
+ >
<li class="scrollable-menu">
<ul>
<li v-for="job in group.jobs" :key="job.id">
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index ee58dcc4882..795b95421c7 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -2,7 +2,7 @@
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
-import { sprintf } from '~/locale';
+import { sprintf, __ } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { reportToSentry } from '../../utils';
import ActionComponent from '../jobs_shared/action_component.vue';
@@ -160,6 +160,21 @@ export default {
hasAction() {
return this.job.status && this.job.status.action && this.job.status.action.path;
},
+ hasUnauthorizedManualAction() {
+ return (
+ !this.hasAction &&
+ this.job.status?.group === 'manual' &&
+ this.job.status?.label?.includes('(not allowed)')
+ );
+ },
+ unauthorizedManualActionIcon() {
+ /*
+ The action object is not available when the user cannot run the action.
+ So we can show the correct icon, extract the action name from the label instead:
+ "manual play action (not allowed)" or "manual stop action (not allowed)"
+ */
+ return this.job.status?.label?.split(' ')[1];
+ },
relatedDownstreamHovered() {
return this.job.name === this.sourceJobHovered;
},
@@ -198,6 +213,9 @@ export default {
this.$emit('pipelineActionRequestComplete');
},
},
+ i18n: {
+ unauthorizedTooltip: __('You are not authorized to run this manual job'),
+ },
};
</script>
<template>
@@ -242,8 +260,16 @@ export default {
:link="status.action.path"
:action-icon="status.action.icon"
class="gl-mr-1"
- data-qa-selector="action_button"
+ data-qa-selector="job_action_button"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
/>
+ <action-component
+ v-if="hasUnauthorizedManualAction"
+ disabled
+ :tooltip-text="$options.i18n.unauthorizedTooltip"
+ :action-icon="unauthorizedManualActionIcon"
+ :link="`unauthorized-${computedJobId}`"
+ class="gl-mr-1"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
index e0c1dcc5be5..c59f56fc68f 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTooltipDirective, GlButton, GlLink, GlLoadingIcon, GlBadge } from '@gitlab/ui';
+import { GlBadge, GlButton, GlLink, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { __, sprintf } from '~/locale';
import CiStatus from '~/vue_shared/components/ci_icon.vue';
@@ -12,10 +12,10 @@ export default {
},
components: {
CiStatus,
+ GlBadge,
GlButton,
GlLink,
GlLoadingIcon,
- GlBadge,
},
props: {
columnTitle: {
@@ -26,6 +26,10 @@ export default {
type: Boolean,
required: true,
},
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
pipeline: {
type: Object,
required: true,
@@ -34,33 +38,40 @@ export default {
type: String,
required: true,
},
- isLoading: {
- type: Boolean,
- required: true,
- },
},
computed: {
- tooltipText() {
- return `${this.downstreamTitle} #${this.pipeline.id} - ${this.pipelineStatus.label} -
- ${this.sourceJobInfo}`;
+ buttonBorderClass() {
+ return this.isUpstream ? 'gl-border-r-1!' : 'gl-border-l-1!';
},
buttonId() {
return `js-linked-pipeline-${this.pipeline.id}`;
},
- pipelineStatus() {
- return this.pipeline.status;
+ cardSpacingClass() {
+ return this.isDownstream ? 'gl-pr-0' : '';
},
- projectName() {
- return this.pipeline.project.name;
+ expandedIcon() {
+ if (this.isUpstream) {
+ return this.expanded ? 'angle-right' : 'angle-left';
+ }
+ return this.expanded ? 'angle-left' : 'angle-right';
+ },
+ childPipeline() {
+ return this.isDownstream && this.isSameProject;
},
downstreamTitle() {
return this.childPipeline ? this.sourceJobName : this.pipeline.project.name;
},
- parentPipeline() {
- return this.isUpstream && this.isSameProject;
+ flexDirection() {
+ return this.isUpstream ? 'gl-flex-direction-row-reverse' : 'gl-flex-direction-row';
},
- childPipeline() {
- return this.isDownstream && this.isSameProject;
+ isDownstream() {
+ return this.type === DOWNSTREAM;
+ },
+ isSameProject() {
+ return !this.pipeline.multiproject;
+ },
+ isUpstream() {
+ return this.type === UPSTREAM;
},
label() {
if (this.parentPipeline) {
@@ -70,17 +81,17 @@ export default {
}
return __('Multi-project');
},
+ parentPipeline() {
+ return this.isUpstream && this.isSameProject;
+ },
pipelineIsLoading() {
return Boolean(this.isLoading || this.pipeline.isLoading);
},
- isDownstream() {
- return this.type === DOWNSTREAM;
- },
- isUpstream() {
- return this.type === UPSTREAM;
+ pipelineStatus() {
+ return this.pipeline.status;
},
- isSameProject() {
- return !this.pipeline.multiproject;
+ projectName() {
+ return this.pipeline.project.name;
},
sourceJobName() {
return this.pipeline.sourceJob?.name ?? '';
@@ -88,28 +99,23 @@ export default {
sourceJobInfo() {
return this.isDownstream ? sprintf(__('Created by %{job}'), { job: this.sourceJobName }) : '';
},
- expandedIcon() {
- if (this.isUpstream) {
- return this.expanded ? 'angle-right' : 'angle-left';
- }
- return this.expanded ? 'angle-left' : 'angle-right';
- },
- expandButtonPosition() {
- return this.isUpstream ? 'gl-left-0 gl-border-r-1!' : 'gl-right-0 gl-border-l-1!';
+ tooltipText() {
+ return `${this.downstreamTitle} #${this.pipeline.id} - ${this.pipelineStatus.label} -
+ ${this.sourceJobInfo}`;
},
},
errorCaptured(err, _vm, info) {
reportToSentry('linked_pipeline', `error: ${err}, info: ${info}`);
},
methods: {
+ hideTooltips() {
+ this.$root.$emit(BV_HIDE_TOOLTIP);
+ },
onClickLinkedPipeline() {
this.hideTooltips();
this.$emit('pipelineClicked', this.$refs.linkedPipeline);
this.$emit('pipelineExpandToggle', this.sourceJobName, !this.expanded);
},
- hideTooltips() {
- this.$root.$emit(BV_HIDE_TOOLTIP);
- },
onDownstreamHovered() {
this.$emit('downstreamHovered', this.sourceJobName);
},
@@ -124,27 +130,23 @@ export default {
<div
ref="linkedPipeline"
v-gl-tooltip
- class="gl-downstream-pipeline-job-width"
+ class="gl-h-full gl-display-flex! gl-border-solid gl-border-gray-100 gl-border-1"
+ :class="flexDirection"
:title="tooltipText"
data-qa-selector="child_pipeline"
@mouseover="onDownstreamHovered"
@mouseleave="onDownstreamHoverLeave"
>
- <div
- class="gl-relative gl-bg-white gl-p-3 gl-border-solid gl-border-gray-100 gl-border-1"
- :class="{ 'gl-pl-9': isUpstream }"
- >
- <div class="gl-display-flex gl-pr-7 gl-pipeline-job-width">
+ <div class="gl-w-full gl-bg-white gl-p-3" :class="cardSpacingClass">
+ <div class="gl-display-flex gl-pr-3">
<ci-status
v-if="!pipelineIsLoading"
:status="pipelineStatus"
:size="24"
css-classes="gl-top-0 gl-pr-2"
/>
- <div v-else class="gl-pr-2"><gl-loading-icon size="sm" inline /></div>
- <div
- class="gl-display-flex gl-flex-direction-column gl-pipeline-job-width gl-text-truncate"
- >
+ <div v-else class="gl-pr-3"><gl-loading-icon size="sm" inline /></div>
+ <div class="gl-display-flex gl-flex-direction-column gl-downstream-pipeline-job-width">
<span class="gl-text-truncate" data-testid="downstream-title">
{{ downstreamTitle }}
</span>
@@ -160,10 +162,12 @@ export default {
{{ label }}
</gl-badge>
</div>
+ </div>
+ <div class="gl-display-flex">
<gl-button
:id="buttonId"
- class="gl-absolute gl-top-0 gl-bottom-0 gl-shadow-none! gl-rounded-0!"
- :class="`js-pipeline-expand-${pipeline.id} ${expandButtonPosition}`"
+ class="gl-shadow-none! gl-rounded-0!"
+ :class="`js-pipeline-expand-${pipeline.id} ${buttonBorderClass}`"
:icon="expandedIcon"
:aria-label="__('Expand pipeline')"
data-testid="expand-pipeline-button"
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index 8088858f381..6a4d1bb44f2 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -1,9 +1,22 @@
<script>
-import { GlAlert, GlButton, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlButton,
+ GlLoadingIcon,
+ GlModal,
+ GlModalDirective,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import ciHeader from '~/vue_shared/components/header_ci_component.vue';
-import { LOAD_FAILURE, POST_FAILURE, DELETE_FAILURE, DEFAULT } from '../constants';
+import {
+ LOAD_FAILURE,
+ POST_FAILURE,
+ DELETE_FAILURE,
+ DEFAULT,
+ BUTTON_TOOLTIP_RETRY,
+} from '../constants';
import cancelPipelineMutation from '../graphql/mutations/cancel_pipeline.mutation.graphql';
import deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutation.graphql';
import retryPipelineMutation from '../graphql/mutations/retry_pipeline.mutation.graphql';
@@ -15,6 +28,7 @@ const POLL_INTERVAL = 10000;
export default {
name: 'PipelineHeaderSection',
+ BUTTON_TOOLTIP_RETRY,
pipelineCancel: 'pipelineCancel',
pipelineRetry: 'pipelineRetry',
finishedStatuses: ['FAILED', 'SUCCESS', 'CANCELED'],
@@ -27,6 +41,7 @@ export default {
},
directives: {
GlModal: GlModalDirective,
+ GlTooltip: GlTooltipDirective,
},
errorTexts: {
[LOAD_FAILURE]: __('We are currently unable to fetch data for the pipeline header.'),
@@ -225,6 +240,9 @@ export default {
>
<gl-button
v-if="canRetryPipeline"
+ v-gl-tooltip
+ :aria-label="$options.BUTTON_TOOLTIP_RETRY"
+ :title="$options.BUTTON_TOOLTIP_RETRY"
:loading="isRetrying"
:disabled="isRetrying"
category="secondary"
diff --git a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
index e11073aee33..99fb5c146ba 100644
--- a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
+++ b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
@@ -36,10 +36,13 @@ export default {
return data.project?.pipeline?.jobs?.nodes || [];
},
result({ data }) {
+ if (!data) {
+ return;
+ }
this.jobsPageInfo = data.project?.pipeline?.jobs?.pageInfo || {};
},
error() {
- createFlash({ message: __('An error occured while fetching the pipelines jobs.') });
+ createFlash({ message: __('An error occurred while fetching the pipelines jobs.') });
},
},
},
diff --git a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue
index efad43ddd4f..ca2537ca4f4 100644
--- a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue
+++ b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue
@@ -92,14 +92,20 @@ export default {
<template>
<gl-button
:id="`js-ci-action-${link}`"
- v-gl-tooltip="{ boundary: 'viewport' }"
- :title="tooltipText"
:class="cssClass"
:disabled="isDisabled"
class="js-ci-action gl-ci-action-icon-container ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center"
+ data-testid="ci-action-component"
@click.stop="onClickAction"
>
- <gl-loading-icon v-if="isLoading" size="sm" class="js-action-icon-loading" />
- <gl-icon v-else :name="actionIcon" class="gl-mr-0!" :aria-label="actionIcon" />
+ <div
+ v-gl-tooltip.viewport
+ :title="tooltipText"
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-h-full"
+ data-testid="ci-action-icon-tooltip-wrapper"
+ >
+ <gl-loading-icon v-if="isLoading" size="sm" class="js-action-icon-loading" />
+ <gl-icon v-else :name="actionIcon" class="gl-mr-0!" :aria-label="actionIcon" />
+ </div>
</gl-button>
</template>
diff --git a/app/assets/javascripts/pipelines/components/notification/deprecated_type_keyword_notification.vue b/app/assets/javascripts/pipelines/components/notification/deprecated_type_keyword_notification.vue
new file mode 100644
index 00000000000..b8f9f84c217
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/notification/deprecated_type_keyword_notification.vue
@@ -0,0 +1,102 @@
+<script>
+import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+import getPipelineWarnings from '../../graphql/queries/get_pipeline_warnings.query.graphql';
+
+export default {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ expectedMessage: 'will be removed in',
+ i18n: {
+ title: __('Found warning in your .gitlab-ci.yml'),
+ rootTypesWarning: __(
+ '%{codeStart}types%{codeEnd} is deprecated and will be removed in 15.0. Use %{codeStart}stages%{codeEnd} instead. %{linkStart}Learn More %{linkEnd}',
+ ),
+ typeWarning: __(
+ '%{codeStart}type%{codeEnd} is deprecated and will be removed in 15.0. Use %{codeStart}stage%{codeEnd} instead. %{linkStart}Learn More %{linkEnd}',
+ ),
+ },
+ components: {
+ GlAlert,
+ GlLink,
+ GlSprintf,
+ },
+ inject: ['deprecatedKeywordsDocPath', 'fullPath', 'pipelineIid'],
+ apollo: {
+ warnings: {
+ query: getPipelineWarnings,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: this.pipelineIid,
+ };
+ },
+ update(data) {
+ return data?.project?.pipeline?.warningMessages || [];
+ },
+ error() {
+ this.hasError = true;
+ },
+ },
+ },
+ data() {
+ return {
+ warnings: [],
+ hasError: false,
+ };
+ },
+ computed: {
+ deprecationWarnings() {
+ return this.warnings.filter(({ content }) => {
+ return content.includes(this.$options.expectedMessage);
+ });
+ },
+ formattedWarnings() {
+ // The API doesn't have a mechanism currently to return a
+ // type instead of just the error message. To work around this,
+ // we check if the deprecation message is found within the warnings
+ // and show a FE version of that message with the link to the documentation
+ // and translated. We can have only 2 types of warnings: root types and individual
+ // type. If the word `root` is present, then we know it's the root type deprecation
+ // and if not, it's the normal type. This has to be deleted in 15.0.
+ // Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/350810
+ return this.deprecationWarnings.map(({ content }) => {
+ if (content.includes('root')) {
+ return this.$options.i18n.rootTypesWarning;
+ }
+ return this.$options.i18n.typeWarning;
+ });
+ },
+ hasDeprecationWarning() {
+ return this.formattedWarnings.length > 0;
+ },
+ showWarning() {
+ return (
+ !this.$apollo.queries.warnings?.loading && !this.hasError && this.hasDeprecationWarning
+ );
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert
+ v-if="showWarning"
+ :title="$options.i18n.title"
+ variant="warning"
+ :dismissible="false"
+ >
+ <ul class="gl-mb-0">
+ <li v-for="warning in formattedWarnings" :key="warning">
+ <gl-sprintf :message="warning">
+ <template #code="{ content }">
+ <code> {{ content }}</code>
+ </template>
+ <template #link="{ content }">
+ <gl-link :href="deprecatedKeywordsDocPath" target="_blank"> {{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </li>
+ </ul>
+ </gl-alert>
+ </div>
+</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 b6c178d20b0..fa0e153b2af 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
@@ -1,15 +1,13 @@
<script>
import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
import eventHub from '../../event_hub';
+import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL } from '../../constants';
import PipelineMultiActions from './pipeline_multi_actions.vue';
import PipelinesManualActions from './pipelines_manual_actions.vue';
export default {
- i18n: {
- cancelTitle: __('Cancel'),
- redeployTitle: __('Retry'),
- },
+ BUTTON_TOOLTIP_RETRY,
+ BUTTON_TOOLTIP_CANCEL,
directives: {
GlTooltip: GlTooltipDirective,
GlModalDirective,
@@ -75,12 +73,13 @@ export default {
<gl-button
v-if="pipeline.flags.retryable"
v-gl-tooltip.hover
- :aria-label="$options.i18n.redeployTitle"
- :title="$options.i18n.redeployTitle"
+ :aria-label="$options.BUTTON_TOOLTIP_RETRY"
+ :title="$options.BUTTON_TOOLTIP_RETRY"
:disabled="isRetrying"
:loading="isRetrying"
class="js-pipelines-retry-button"
data-qa-selector="pipeline_retry_button"
+ data-testid="pipelines-retry-button"
icon="repeat"
variant="default"
category="secondary"
@@ -91,14 +90,15 @@ export default {
v-if="pipeline.flags.cancelable"
v-gl-tooltip.hover
v-gl-modal-directive="'confirmation-modal'"
- :aria-label="$options.i18n.cancelTitle"
- :title="$options.i18n.cancelTitle"
+ :aria-label="$options.BUTTON_TOOLTIP_CANCEL"
+ :title="$options.BUTTON_TOOLTIP_CANCEL"
:loading="isCancelling"
:disabled="isCancelling"
icon="cancel"
variant="danger"
category="primary"
class="js-pipelines-cancel-button gl-ml-1"
+ data-testid="pipelines-cancel-button"
@click="handleCancelClick"
/>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
index 0528e4c147c..b29c8426301 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
@@ -26,7 +26,7 @@ export default {
v-if="user"
:link-href="user.path"
:img-src="user.avatar_url"
- :img-size="26"
+ :img-size="32"
:tooltip-text="user.name"
class="gl-ml-3 js-pipeline-url-user"
/>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
index e2f30d5a8e6..52da4d01468 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -1,15 +1,19 @@
<script>
-import { GlLink, GlPopover, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui';
+import { GlIcon, GlLink, GlPopover, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { SCHEDULE_ORIGIN } from '../../constants';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
+import { SCHEDULE_ORIGIN, ICONS } from '../../constants';
export default {
components: {
+ GlIcon,
GlLink,
GlPopover,
GlSprintf,
GlBadge,
+ TooltipOnTruncate,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -33,11 +37,12 @@ export default {
type: String,
required: true,
},
+ viewType: {
+ type: String,
+ required: true,
+ },
},
computed: {
- user() {
- return this.pipeline.user;
- },
isScheduled() {
return this.pipeline.source === SCHEDULE_ORIGIN;
},
@@ -53,12 +58,160 @@ export default {
autoDevopsHelpPath() {
return helpPagePath('topics/autodevops/index.md');
},
+ mergeRequestRef() {
+ return this.pipeline?.merge_request;
+ },
+ commitRef() {
+ return this.pipeline?.ref;
+ },
+ commitTag() {
+ return this.commitRef?.tag;
+ },
+ commitUrl() {
+ return this.pipeline?.commit?.commit_path;
+ },
+ commitShortSha() {
+ return this.pipeline?.commit?.short_id;
+ },
+ refUrl() {
+ return this.commitRef?.ref_url || this.commitRef?.path;
+ },
+ tooltipTitle() {
+ return this.mergeRequestRef?.title || this.commitRef?.name;
+ },
+ commitAuthor() {
+ let commitAuthorInformation;
+ const pipelineCommit = this.pipeline?.commit;
+ const pipelineCommitAuthor = pipelineCommit?.author;
+
+ if (!pipelineCommit) {
+ return null;
+ }
+
+ // 1. person who is an author of a commit might be a GitLab user
+ if (pipelineCommitAuthor) {
+ // 2. if person who is an author of a commit is a GitLab user
+ // they can have a GitLab avatar
+ if (pipelineCommitAuthor?.avatar_url) {
+ commitAuthorInformation = pipelineCommitAuthor;
+
+ // 3. If GitLab user does not have avatar, they might have a Gravatar
+ } else if (pipelineCommit.author_gravatar_url) {
+ commitAuthorInformation = {
+ ...pipelineCommitAuthor,
+ avatar_url: pipelineCommit.author_gravatar_url,
+ };
+ }
+ // 4. If committer is not a GitLab User, they can have a Gravatar
+ } else {
+ commitAuthorInformation = {
+ avatar_url: pipelineCommit.author_gravatar_url,
+ path: `mailto:${pipelineCommit.author_email}`,
+ username: pipelineCommit.author_name,
+ };
+ }
+
+ return commitAuthorInformation;
+ },
+ commitIcon() {
+ let name = '';
+
+ if (this.commitTag) {
+ name = ICONS.TAG;
+ } else if (this.mergeRequestRef) {
+ name = ICONS.MR;
+ } else {
+ name = ICONS.BRANCH;
+ }
+
+ return name;
+ },
+ commitIconTooltipTitle() {
+ switch (this.commitIcon) {
+ case ICONS.TAG:
+ return __('Tag');
+ case ICONS.MR:
+ return __('Merge Request');
+ default:
+ return __('Branch');
+ }
+ },
+ commitTitleText() {
+ return this.pipeline?.commit?.title || __("Can't find HEAD commit for this branch");
+ },
+ hasAuthor() {
+ return (
+ this.commitAuthor?.avatar_url && this.commitAuthor?.path && this.commitAuthor?.username
+ );
+ },
+ userImageAltDescription() {
+ return this.commitAuthor?.username
+ ? sprintf(__("%{username}'s avatar"), { username: this.commitAuthor.username })
+ : null;
+ },
+ rearrangePipelinesTable() {
+ return this.glFeatures?.rearrangePipelinesTable;
+ },
},
};
</script>
<template>
<div class="pipeline-tags" data-testid="pipeline-url-table-cell">
+ <template v-if="rearrangePipelinesTable">
+ <div class="commit-title gl-mb-2" data-testid="commit-title-container">
+ <span class="gl-display-flex">
+ <tooltip-on-truncate :title="commitTitleText" class="flex-truncate-child gl-flex-grow-1">
+ <gl-link
+ :href="pipeline.path"
+ class="commit-row-message gl-text-blue-600!"
+ data-testid="commit-title"
+ data-qa-selector="pipeline_url_link"
+ >{{ commitTitleText }}</gl-link
+ >
+ </tooltip-on-truncate>
+ </span>
+ </div>
+ <div class="gl-mb-2">
+ <span class="gl-font-weight-bold gl-text-gray-500" data-testid="pipeline-identifier">
+ #{{ pipeline[pipelineKey] }}
+ </span>
+ <!--Commit row-->
+ <div class="icon-container gl-display-inline-block">
+ <gl-icon
+ v-gl-tooltip
+ :name="commitIcon"
+ :title="commitIconTooltipTitle"
+ data-testid="commit-icon-type"
+ />
+ </div>
+ <tooltip-on-truncate :title="tooltipTitle" truncate-target="child" placement="top">
+ <gl-link
+ v-if="mergeRequestRef"
+ :href="mergeRequestRef.path"
+ class="ref-name"
+ data-testid="merge-request-ref"
+ >{{ mergeRequestRef.iid }}</gl-link
+ >
+ <gl-link v-else :href="refUrl" class="ref-name" data-testid="commit-ref-name">{{
+ commitRef.name
+ }}</gl-link>
+ </tooltip-on-truncate>
+ <gl-icon
+ v-gl-tooltip
+ name="commit"
+ class="commit-icon"
+ :title="__('Commit')"
+ data-testid="commit-icon"
+ />
+
+ <gl-link :href="commitUrl" class="commit-sha mr-0" data-testid="commit-short-sha">{{
+ commitShortSha
+ }}</gl-link>
+ <!--End of commit row-->
+ </div>
+ </template>
<gl-link
+ v-if="!rearrangePipelinesTable"
:href="pipeline.path"
class="gl-text-decoration-underline"
data-testid="pipeline-url-link"
@@ -66,7 +219,7 @@ export default {
>
#{{ pipeline[pipelineKey] }}
</gl-link>
- <div class="label-container">
+ <div class="label-container gl-mt-1">
<gl-badge
v-if="isScheduled"
v-gl-tooltip
@@ -163,7 +316,7 @@ export default {
v-gl-tooltip
:title="
__(
- 'Pipelines for merge requests are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more in the documentation for Pipelines for Merged Results.',
+ 'Merge request pipelines are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more in the documentation for merge request pipelines.',
)
"
variant="info"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
index b94f1a42039..47fffa8a6b2 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
@@ -2,6 +2,7 @@
import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { s__, __, sprintf } from '~/locale';
import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
import eventHub from '../../event_hub';
@@ -28,7 +29,7 @@ export default {
};
},
methods: {
- onClickAction(action) {
+ async onClickAction(action) {
if (action.scheduled_at) {
const confirmationMessage = sprintf(
s__(
@@ -36,9 +37,10 @@ export default {
),
{ jobName: action.name },
);
- // https://gitlab.com/gitlab-org/gitlab-foss/issues/52156
- // eslint-disable-next-line no-alert
- if (!window.confirm(confirmationMessage)) {
+
+ const confirmed = await confirmAction(confirmationMessage);
+
+ if (!confirmed) {
return;
}
}
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue
index f56457a4162..54901c2d13f 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue
@@ -3,12 +3,16 @@ import CodeQualityWalkthrough from '~/code_quality_walkthrough/components/step.v
import { PIPELINE_STATUSES } from '~/code_quality_walkthrough/constants';
import { CHILD_VIEW } from '~/pipelines/constants';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import PipelinesTimeago from './time_ago.vue';
export default {
components: {
CodeQualityWalkthrough,
CiBadge,
+ PipelinesTimeago,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
pipeline: {
type: Object,
@@ -40,6 +44,9 @@ export default {
codeQualityBuildPath() {
return this.pipeline?.details?.code_quality_build_path;
},
+ rearrangePipelinesTable() {
+ return this.glFeatures?.rearrangePipelinesTable;
+ },
},
};
</script>
@@ -48,11 +55,13 @@ export default {
<div>
<ci-badge
id="js-code-quality-walkthrough"
+ class="gl-mb-3"
:status="pipelineStatus"
:show-text="!isChildView"
:icon-classes="'gl-vertical-align-middle!'"
data-qa-selector="pipeline_commit_status"
/>
+ <pipelines-timeago v-if="rearrangePipelinesTable" class="gl-mt-3" :pipeline="pipeline" />
<code-quality-walkthrough
v-if="shouldRenderCodeQualityWalkthrough"
:step="codeQualityStep"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
index d64decc81ec..9919a18cb99 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -1,6 +1,7 @@
<script>
import { GlTableLite, GlTooltipDirective } from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../../event_hub';
import PipelineMiniGraph from './pipeline_mini_graph.vue';
import PipelineOperations from './pipeline_operations.vue';
@@ -33,6 +34,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagMixin()],
props: {
pipelines: {
type: Array,
@@ -72,16 +74,18 @@ export default {
key: 'status',
label: s__('Pipeline|Status'),
thClass: DEFAULT_TH_CLASSES,
- columnClass: 'gl-w-10p',
+ columnClass: this.rearrangePipelinesTable ? 'gl-w-15p' : 'gl-w-10p',
tdClass: DEFAULT_TD_CLASS,
thAttr: { 'data-testid': 'status-th' },
},
{
key: 'pipeline',
- label: this.pipelineKeyOption.label,
+ label: this.rearrangePipelinesTable ? __('Pipeline') : this.pipelineKeyOption.label,
thClass: DEFAULT_TH_CLASSES,
- tdClass: `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`,
- columnClass: 'gl-w-10p',
+ tdClass: this.rearrangePipelinesTable
+ ? `${DEFAULT_TD_CLASS}`
+ : `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`,
+ columnClass: this.rearrangePipelinesTable ? 'gl-w-30p' : 'gl-w-10p',
thAttr: { 'data-testid': 'pipeline-th' },
},
{
@@ -113,7 +117,7 @@ export default {
label: s__('Pipeline|Duration'),
thClass: DEFAULT_TH_CLASSES,
tdClass: DEFAULT_TD_CLASS,
- columnClass: 'gl-w-15p',
+ columnClass: this.rearrangePipelinesTable ? 'gl-w-5p' : 'gl-w-15p',
thAttr: { 'data-testid': 'timeago-th' },
},
{
@@ -124,7 +128,13 @@ export default {
thAttr: { 'data-testid': 'actions-th' },
},
];
- return fields;
+
+ return !this.rearrangePipelinesTable
+ ? fields
+ : fields.filter((field) => !['commit', 'timeago'].includes(field.key));
+ },
+ rearrangePipelinesTable() {
+ return this.glFeatures?.rearrangePipelinesTable;
},
},
watch: {
@@ -182,6 +192,7 @@ export default {
:pipeline="item"
:pipeline-schedule-url="pipelineScheduleUrl"
:pipeline-key="pipelineKeyOption.key"
+ :view-type="viewType"
/>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
index e6b03751350..c45e3f24567 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -54,11 +54,14 @@ export default {
showSkipped() {
return !this.duration && !this.finishedTime && this.skipped;
},
+ shouldDisplayAsBlock() {
+ return this.glFeatures?.rearrangePipelinesTable;
+ },
},
};
</script>
<template>
- <div>
+ <div class="{ 'gl-display-block': shouldDisplayAsBlock }">
<span v-if="showInProgress" data-testid="pipeline-in-progress">
<gl-icon v-if="stuck" name="warning" class="gl-mr-2" :size="12" data-testid="warning-icon" />
<gl-icon
@@ -87,6 +90,7 @@ export default {
<time
v-gl-tooltip
:title="tooltipTitle(finishedTime)"
+ :datetime="finishedTime"
data-placement="top"
data-container="body"
>
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index 410fc7b82cd..36f708ff2af 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -10,6 +10,12 @@ export const SCHEDULE_ORIGIN = 'schedule';
export const NEEDS_PROPERTY = 'needs';
export const EXPLICIT_NEEDS_PROPERTY = 'previousStageJobsOrNeeds';
+export const ICONS = {
+ TAG: 'tag',
+ MR: 'git-merge',
+ BRANCH: 'branch',
+};
+
export const TestStatus = {
FAILED: 'failed',
SKIPPED: 'skipped',
@@ -53,3 +59,6 @@ export const PipelineKeyOptions = [
];
export const TOAST_MESSAGE = s__('Pipeline|Creating pipeline.');
+
+export const BUTTON_TOOLTIP_RETRY = __('Retry failed jobs');
+export const BUTTON_TOOLTIP_CANCEL = __('Cancel');
diff --git a/app/assets/javascripts/pipelines/graphql/fragmentTypes.json b/app/assets/javascripts/pipelines/graphql/fragmentTypes.json
deleted file mode 100644
index 4601b74b5c1..00000000000
--- a/app/assets/javascripts/pipelines/graphql/fragmentTypes.json
+++ /dev/null
@@ -1 +0,0 @@
-{"__schema":{"types":[{"kind":"UNION","name":"JobNeedUnion","possibleTypes":[{"name":"CiBuildNeed"},{"name":"CiJob"}]}]}} \ No newline at end of file
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_warnings.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_warnings.query.graphql
new file mode 100644
index 00000000000..cd1d2b62a3d
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_warnings.query.graphql
@@ -0,0 +1,12 @@
+query getPipelineWarnings($fullPath: ID!, $iid: ID!) {
+ project(fullPath: $fullPath) {
+ id
+ pipeline(iid: $iid) {
+ id
+ warningMessages {
+ content
+ id
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
index 3201f88a9e3..c4f7665c91d 100644
--- a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
+++ b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
@@ -1,6 +1,7 @@
import Visibility from 'visibilityjs';
import createFlash from '~/flash';
import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
+import httpStatusCodes from '~/lib/utils/http_status';
import Poll from '~/lib/utils/poll';
import { __ } from '~/locale';
import { validateParams } from '~/pipelines/utils';
@@ -195,11 +196,20 @@ export default {
this.$toast.show(TOAST_MESSAGE);
this.updateTable();
})
- .catch(() => {
+ .catch((e) => {
+ const unauthorized = e.response.status === httpStatusCodes.UNAUTHORIZED;
+ const badRequest = e.response.status === httpStatusCodes.BAD_REQUEST;
+
+ let errorMessage = __(
+ 'An error occurred while trying to run a new pipeline for this merge request.',
+ );
+
+ if (unauthorized || badRequest) {
+ errorMessage = __('You do not have permission to run a pipeline on this branch.');
+ }
+
createFlash({
- message: __(
- 'An error occurred while trying to run a new pipeline for this merge request.',
- ),
+ message: errorMessage,
});
})
.finally(() => this.store.toggleIsRunningPipeline(false));
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index ae8b2503c79..bfb95e5ab0c 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -3,6 +3,7 @@ import { __ } from '~/locale';
import createDagApp from './pipeline_details_dag';
import { createPipelinesDetailApp } from './pipeline_details_graph';
import { createPipelineHeaderApp } from './pipeline_details_header';
+import { createPipelineNotificationApp } from './pipeline_details_notification';
import { createPipelineJobsApp } from './pipeline_details_jobs';
import { apolloProvider } from './pipeline_shared_client';
import { createTestDetails } from './pipeline_test_details';
@@ -11,6 +12,7 @@ const SELECTORS = {
PIPELINE_DETAILS: '.js-pipeline-details-vue',
PIPELINE_GRAPH: '#js-pipeline-graph-vue',
PIPELINE_HEADER: '#js-pipeline-header-vue',
+ PIPELINE_NOTIFICATION: '#js-pipeline-notification',
PIPELINE_TESTS: '#js-pipeline-tests-detail',
PIPELINE_JOBS: '#js-pipeline-jobs-vue',
};
@@ -43,6 +45,14 @@ export default async function initPipelineDetailsBundle() {
}
try {
+ createPipelineNotificationApp(SELECTORS.PIPELINE_NOTIFICATION, apolloProvider);
+ } catch {
+ createFlash({
+ message: __('An error occurred while loading a section of this page.'),
+ });
+ }
+
+ try {
createDagApp(apolloProvider);
} catch {
createFlash({
diff --git a/app/assets/javascripts/pipelines/pipeline_details_notification.js b/app/assets/javascripts/pipelines/pipeline_details_notification.js
new file mode 100644
index 00000000000..0061be843c5
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_details_notification.js
@@ -0,0 +1,31 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import DeprecatedKeywordNotification from './components/notification/deprecated_type_keyword_notification.vue';
+
+Vue.use(VueApollo);
+
+export const createPipelineNotificationApp = (elSelector, apolloProvider) => {
+ const el = document.querySelector(elSelector);
+
+ if (!el) {
+ return;
+ }
+
+ const { deprecatedKeywordsDocPath, fullPath, pipelineIid } = el?.dataset;
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ components: {
+ DeprecatedKeywordNotification,
+ },
+ provide: {
+ deprecatedKeywordsDocPath,
+ fullPath,
+ pipelineIid,
+ },
+ apolloProvider,
+ render(createElement) {
+ return createElement('deprecated-keyword-notification');
+ },
+ });
+};
diff --git a/app/assets/javascripts/pipelines/pipeline_shared_client.js b/app/assets/javascripts/pipelines/pipeline_shared_client.js
index 84276588d6a..c3be487caae 100644
--- a/app/assets/javascripts/pipelines/pipeline_shared_client.js
+++ b/app/assets/javascripts/pipelines/pipeline_shared_client.js
@@ -1,19 +1,10 @@
import VueApollo from 'vue-apollo';
-import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import createDefaultClient from '~/lib/graphql';
-import introspectionQueryResultData from './graphql/fragmentTypes.json';
-
-export const fragmentMatcher = new IntrospectionFragmentMatcher({
- introspectionQueryResultData,
-});
export const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
{},
{
- cacheConfig: {
- fragmentMatcher,
- },
useGet: true,
},
),
diff --git a/app/assets/javascripts/popovers/index.js b/app/assets/javascripts/popovers/index.js
index 7db669b8c52..94340ae16a0 100644
--- a/app/assets/javascripts/popovers/index.js
+++ b/app/assets/javascripts/popovers/index.js
@@ -13,7 +13,7 @@ const getPopoversApp = () => {
document.body.appendChild(container);
const Popovers = Vue.extend(PopoversComponent);
- app = new Popovers();
+ app = new Popovers({ name: 'PopoversRoot' });
app.$mount(`#${APP_ELEMENT_ID}`);
}
diff --git a/app/assets/javascripts/projects/components/project_delete_button.vue b/app/assets/javascripts/projects/components/project_delete_button.vue
index eaf93e2da4f..924b6f55db4 100644
--- a/app/assets/javascripts/projects/components/project_delete_button.vue
+++ b/app/assets/javascripts/projects/components/project_delete_button.vue
@@ -1,12 +1,8 @@
<script>
-import { GlAlert, GlSprintf } from '@gitlab/ui';
-import { __ } from '~/locale';
import SharedDeleteButton from './shared/delete_button.vue';
export default {
components: {
- GlSprintf,
- GlAlert,
SharedDeleteButton,
},
props: {
@@ -39,66 +35,17 @@ export default {
required: true,
},
},
- strings: {
- alertTitle: __('You are about to permanently delete this project'),
- alertBody: __(
- 'After a project is permanently deleted, it %{strongStart}cannot be recovered%{strongEnd}. Permanently deleting this project will %{strongStart}immediately delete%{strongEnd} its repositories and %{strongStart}all related resources%{strongEnd}, including issues, merge requests etc.',
- ),
- isNotForkMessage: __(
- 'This project is %{strongStart}NOT%{strongEnd} a fork, and has the following:',
- ),
- isForkMessage: __('This forked project has the following:'),
- },
};
</script>
<template>
- <shared-delete-button v-bind="{ confirmPhrase, formPath }">
- <template #modal-body>
- <gl-alert
- class="gl-mb-5"
- variant="danger"
- :title="$options.strings.alertTitle"
- :dismissible="false"
- >
- <p>
- <gl-sprintf v-if="isFork" :message="$options.strings.isForkMessage" />
- <gl-sprintf v-else :message="$options.strings.isNotForkMessage">
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- </p>
- <ul>
- <li>
- <gl-sprintf :message="n__('%d issue', '%d issues', issuesCount)">
- <template #issuesCount>{{ issuesCount }}</template>
- </gl-sprintf>
- </li>
- <li>
- <gl-sprintf
- :message="n__('%d merge requests', '%d merge requests', mergeRequestsCount)"
- >
- <template #mergeRequestsCount>{{ mergeRequestsCount }}</template>
- </gl-sprintf>
- </li>
- <li>
- <gl-sprintf :message="n__('%d fork', '%d forks', forksCount)">
- <template #forksCount>{{ forksCount }}</template>
- </gl-sprintf>
- </li>
- <li>
- <gl-sprintf :message="n__('%d star', '%d stars', starsCount)">
- <template #starsCount>{{ starsCount }}</template>
- </gl-sprintf>
- </li>
- </ul>
- <gl-sprintf :message="$options.strings.alertBody">
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- </gl-alert>
- </template>
- </shared-delete-button>
+ <shared-delete-button
+ :confirm-phrase="confirmPhrase"
+ :form-path="formPath"
+ :is-fork="isFork"
+ :issues-count="issuesCount"
+ :merge-requests-count="mergeRequestsCount"
+ :forks-count="forksCount"
+ :stars-count="starsCount"
+ />
</template>
diff --git a/app/assets/javascripts/projects/components/shared/delete_button.vue b/app/assets/javascripts/projects/components/shared/delete_button.vue
index 2e46f437ace..fd71a246a26 100644
--- a/app/assets/javascripts/projects/components/shared/delete_button.vue
+++ b/app/assets/javascripts/projects/components/shared/delete_button.vue
@@ -1,14 +1,16 @@
<script>
-import { GlModal, GlModalDirective, GlFormInput, GlButton } from '@gitlab/ui';
+import { GlModal, GlModalDirective, GlFormInput, GlButton, GlAlert, GlSprintf } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import csrf from '~/lib/utils/csrf';
import { __ } from '~/locale';
export default {
components: {
+ GlAlert,
GlModal,
GlFormInput,
GlButton,
+ GlSprintf,
},
directives: {
GlModal: GlModalDirective,
@@ -22,6 +24,26 @@ export default {
type: String,
required: true,
},
+ isFork: {
+ type: Boolean,
+ required: true,
+ },
+ issuesCount: {
+ type: Number,
+ required: true,
+ },
+ mergeRequestsCount: {
+ type: Number,
+ required: true,
+ },
+ forksCount: {
+ type: Number,
+ required: true,
+ },
+ starsCount: {
+ type: Number,
+ required: true,
+ },
},
data() {
return {
@@ -55,8 +77,17 @@ export default {
},
strings: {
deleteProject: __('Delete project'),
- title: __('Delete project. Are you ABSOLUTELY SURE?'),
- confirmText: __('Please type the following to confirm:'),
+ title: __('Are you absolutely sure?'),
+ confirmText: __('Enter the following to confirm:'),
+ isForkAlertTitle: __('You are about to delete this forked project containing:'),
+ isNotForkAlertTitle: __('You are about to delete this project containing:'),
+ isForkAlertBody: __('This process deletes the project repository and all related resources.'),
+ isNotForkAlertBody: __(
+ 'This project is %{strongStart}NOT%{strongEnd} a fork. This process deletes the project repository and all related resources.',
+ ),
+ isNotForkMessage: __(
+ 'This project is %{strongStart}NOT%{strongEnd} a fork, and has the following:',
+ ),
},
};
</script>
@@ -83,7 +114,52 @@ export default {
>
<template #modal-title>{{ $options.strings.title }}</template>
<div>
- <slot name="modal-body"></slot>
+ <gl-alert class="gl-mb-5" variant="danger" :dismissible="false">
+ <h4 v-if="isFork" data-testid="delete-alert-title" class="gl-alert-title">
+ {{ $options.strings.isForkAlertTitle }}
+ </h4>
+ <h4 v-else data-testid="delete-alert-title" class="gl-alert-title">
+ {{ $options.strings.isNotForkAlertTitle }}
+ </h4>
+ <ul>
+ <li>
+ <gl-sprintf :message="n__('%d issue', '%d issues', issuesCount)">
+ <template #issuesCount>{{ issuesCount }}</template>
+ </gl-sprintf>
+ </li>
+ <li>
+ <gl-sprintf
+ :message="n__('%d merge requests', '%d merge requests', mergeRequestsCount)"
+ >
+ <template #mergeRequestsCount>{{ mergeRequestsCount }}</template>
+ </gl-sprintf>
+ </li>
+ <li>
+ <gl-sprintf :message="n__('%d fork', '%d forks', forksCount)">
+ <template #forksCount>{{ forksCount }}</template>
+ </gl-sprintf>
+ </li>
+ <li>
+ <gl-sprintf :message="n__('%d star', '%d stars', starsCount)">
+ <template #starsCount>{{ starsCount }}</template>
+ </gl-sprintf>
+ </li>
+ </ul>
+ <gl-sprintf
+ v-if="isFork"
+ data-testid="delete-alert-body"
+ :message="$options.strings.isForkAlertBody"
+ />
+ <gl-sprintf
+ v-else
+ data-testid="delete-alert-body"
+ :message="$options.strings.isNotForkAlertBody"
+ >
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
<p class="gl-mb-1">{{ $options.strings.confirmText }}</p>
<p>
<code class="gl-white-space-pre-wrap">{{ confirmPhrase }}</code>
diff --git a/app/assets/javascripts/projects/new/components/deployment_target_select.vue b/app/assets/javascripts/projects/new/components/deployment_target_select.vue
new file mode 100644
index 00000000000..f3b7e39f148
--- /dev/null
+++ b/app/assets/javascripts/projects/new/components/deployment_target_select.vue
@@ -0,0 +1,61 @@
+<script>
+import { GlFormGroup, GlFormSelect } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import Tracking from '~/tracking';
+import {
+ DEPLOYMENT_TARGET_SELECTIONS,
+ DEPLOYMENT_TARGET_LABEL,
+ DEPLOYMENT_TARGET_EVENT,
+ NEW_PROJECT_FORM,
+} from '../constants';
+
+const trackingMixin = Tracking.mixin({ label: DEPLOYMENT_TARGET_LABEL });
+
+export default {
+ i18n: {
+ deploymentTargetLabel: s__('Deployment Target|Project deployment target (optional)'),
+ defaultOption: s__('Deployment Target|Select the deployment target'),
+ },
+ deploymentTargets: DEPLOYMENT_TARGET_SELECTIONS,
+ selectId: 'deployment-target-select',
+ components: {
+ GlFormGroup,
+ GlFormSelect,
+ },
+ mixins: [trackingMixin],
+ data() {
+ return {
+ selectedTarget: null,
+ formSubmitted: false,
+ };
+ },
+ mounted() {
+ const form = document.getElementById(NEW_PROJECT_FORM);
+ form.addEventListener('submit', () => {
+ this.formSubmitted = true;
+ this.trackSelection();
+ });
+ },
+ methods: {
+ trackSelection() {
+ if (this.formSubmitted && this.selectedTarget) {
+ this.track(DEPLOYMENT_TARGET_EVENT, { property: this.selectedTarget });
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-group :label="$options.i18n.deploymentTargetLabel" :label-for="$options.selectId">
+ <gl-form-select
+ :id="$options.selectId"
+ v-model="selectedTarget"
+ :options="$options.deploymentTargets"
+ >
+ <template #first>
+ <option :value="null" disabled>{{ $options.i18n.defaultOption }}</option>
+ </template>
+ </gl-form-select>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/projects/new/constants.js b/app/assets/javascripts/projects/new/constants.js
new file mode 100644
index 00000000000..c5e6722981b
--- /dev/null
+++ b/app/assets/javascripts/projects/new/constants.js
@@ -0,0 +1,20 @@
+import { s__ } from '~/locale';
+
+export const DEPLOYMENT_TARGET_SELECTIONS = [
+ s__('DeploymentTarget|Kubernetes (GKE, EKS, OpenShift, and so on)'),
+ s__('DeploymentTarget|Managed container runtime (Fargate, Cloud Run, DigitalOcean App)'),
+ s__('DeploymentTarget|Self-managed container runtime (Podman, Docker Swarm, Docker Compose)'),
+ s__('DeploymentTarget|Heroku'),
+ s__('DeploymentTarget|Virtual machine (for example, EC2)'),
+ s__('DeploymentTarget|Mobile app store'),
+ s__('DeploymentTarget|Registry (package or container)'),
+ s__('DeploymentTarget|Infrastructure provider (Terraform, Cloudformation, and so on)'),
+ s__('DeploymentTarget|Serverless backend (Lambda, Cloud functions)'),
+ s__('DeploymentTarget|GitLab Pages'),
+ s__('DeploymentTarget|Other hosting service'),
+ s__('DeploymentTarget|No deployment planned'),
+];
+
+export const NEW_PROJECT_FORM = 'new_project';
+export const DEPLOYMENT_TARGET_LABEL = 'new_project_deployment_target';
+export const DEPLOYMENT_TARGET_EVENT = 'select_deployment_target';
diff --git a/app/assets/javascripts/projects/new/index.js b/app/assets/javascripts/projects/new/index.js
index 010c6a29ae3..4de9b8a6f47 100644
--- a/app/assets/javascripts/projects/new/index.js
+++ b/app/assets/javascripts/projects/new/index.js
@@ -4,6 +4,7 @@ import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import NewProjectCreationApp from './components/app.vue';
import NewProjectUrlSelect from './components/new_project_url_select.vue';
+import DeploymentTargetSelect from './components/deployment_target_select.vue';
export function initNewProjectCreation() {
const el = document.querySelector('.js-new-project-creation');
@@ -64,3 +65,16 @@ export function initNewProjectUrlSelect() {
}),
);
}
+
+export function initDeploymentTargetSelect() {
+ const el = document.querySelector('.js-deployment-target-select');
+
+ if (!el) {
+ return null;
+ }
+
+ return new Vue({
+ el,
+ render: (createElement) => createElement(DeploymentTargetSelect),
+ });
+}
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index 8d71a3dab68..62e2cec874a 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -1,6 +1,8 @@
import $ from 'jquery';
import { debounce } from 'lodash';
import DEFAULT_PROJECT_TEMPLATES from 'ee_else_ce/projects/default_project_templates';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '../lib/utils/constants';
import axios from '../lib/utils/axios_utils';
import {
convertToTitleCase,
@@ -13,20 +15,26 @@ let hasUserDefinedProjectPath = false;
let hasUserDefinedProjectName = false;
const invalidInputClass = 'gl-field-error-outline';
+const cancelSource = axios.CancelToken.source();
+const endpoint = `${gon.relative_url_root}/import/url/validate`;
+let importCredentialsValidationPromise = null;
const validateImportCredentials = (url, user, password) => {
- const endpoint = `${gon.relative_url_root}/import/url/validate`;
- return axios
- .post(endpoint, {
- url,
- user,
- password,
- })
+ cancelSource.cancel();
+ importCredentialsValidationPromise = axios
+ .post(endpoint, { url, user, password }, { cancelToken: cancelSource.cancel() })
.then(({ data }) => data)
- .catch(() => ({
- // intentionally reporting success in case of validation error
- // we do not want to block users from trying import in case of validation exception
- success: true,
- }));
+ .catch((thrown) =>
+ axios.isCancel(thrown)
+ ? {
+ cancelled: true,
+ }
+ : {
+ // intentionally reporting success in case of validation error
+ // we do not want to block users from trying import in case of validation exception
+ success: true,
+ },
+ );
+ return importCredentialsValidationPromise;
};
const onProjectNameChange = ($projectNameInput, $projectPathInput) => {
@@ -72,7 +80,7 @@ const deriveProjectPathFromUrl = ($projectImportUrl) => {
.parents('.toggle-import-form')
.find('#project_path');
- if (hasUserDefinedProjectPath) {
+ if (hasUserDefinedProjectPath || $currentProjectPath.length === 0) {
return;
}
@@ -98,6 +106,21 @@ const deriveProjectPathFromUrl = ($projectImportUrl) => {
};
const bindHowToImport = () => {
+ const importLinks = document.querySelectorAll('.js-how-to-import-link');
+
+ importLinks.forEach((link) => {
+ const { modalTitle: title, modalMessage: modalHtmlMessage } = link.dataset;
+
+ link.addEventListener('click', (e) => {
+ e.preventDefault();
+ confirmAction('', {
+ modalHtmlMessage,
+ title,
+ hideCancel: true,
+ });
+ });
+ });
+
$('.how_to_import_link').on('click', (e) => {
e.preventDefault();
$(e.currentTarget).next('.modal').show();
@@ -114,7 +137,7 @@ const bindEvents = () => {
const $projectImportUrlUser = $('#project_import_url_user');
const $projectImportUrlPassword = $('#project_import_url_password');
const $projectImportUrlError = $('.js-import-url-error');
- const $projectImportForm = $('.project-import form');
+ const $projectImportForm = $('form.js-project-import');
const $projectPath = $('.tab-pane.active #project_path');
const $useTemplateBtn = $('.template-button > input');
const $projectFieldsForm = $('.project-fields-form');
@@ -124,7 +147,7 @@ const bindEvents = () => {
const $projectTemplateButtons = $('.project-templates-buttons');
const $projectName = $('.tab-pane.active #project_name');
- if ($newProjectForm.length !== 1) {
+ if ($newProjectForm.length !== 1 && $projectImportForm.length !== 1) {
return;
}
@@ -168,20 +191,28 @@ const bindEvents = () => {
$projectPath.val($projectPath.val().trim());
});
- const updateUrlPathWarningVisibility = debounce(async () => {
- const { success: isUrlValid } = await validateImportCredentials(
+ const updateUrlPathWarningVisibility = async () => {
+ const { success: isUrlValid, cancelled } = await validateImportCredentials(
$projectImportUrl.val(),
$projectImportUrlUser.val(),
$projectImportUrlPassword.val(),
);
+ if (cancelled) {
+ return;
+ }
+
$projectImportUrl.toggleClass(invalidInputClass, !isUrlValid);
$projectImportUrlError.toggleClass('hide', isUrlValid);
- }, 500);
+ };
+ const debouncedUpdateUrlPathWarningVisibility = debounce(
+ updateUrlPathWarningVisibility,
+ DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
+ );
let isProjectImportUrlDirty = false;
$projectImportUrl.on('blur', () => {
isProjectImportUrlDirty = true;
- updateUrlPathWarningVisibility();
+ debouncedUpdateUrlPathWarningVisibility();
});
$projectImportUrl.on('keyup', () => {
deriveProjectPathFromUrl($projectImportUrl);
@@ -190,17 +221,33 @@ const bindEvents = () => {
[$projectImportUrl, $projectImportUrlUser, $projectImportUrlPassword].forEach(($f) => {
$f.on('input', () => {
if (isProjectImportUrlDirty) {
- updateUrlPathWarningVisibility();
+ debouncedUpdateUrlPathWarningVisibility();
}
});
});
- $projectImportForm.on('submit', (e) => {
+ $projectImportForm.on('submit', async (e) => {
+ e.preventDefault();
+
+ if (importCredentialsValidationPromise === null) {
+ // we didn't validate credentials yet
+ debouncedUpdateUrlPathWarningVisibility.cancel();
+ updateUrlPathWarningVisibility();
+ }
+
+ const submitBtn = $projectImportForm.find('input[type="submit"]');
+
+ submitBtn.disable();
+ await importCredentialsValidationPromise;
+ submitBtn.enable();
+
const $invalidFields = $projectImportForm.find(`.${invalidInputClass}`);
if ($invalidFields.length > 0) {
$invalidFields[0].focus();
- e.preventDefault();
- e.stopPropagation();
+ } else {
+ // calling .submit() on HTMLFormElement does not trigger 'submit' event
+ // We are using this behavior to bypass this handler and avoid infinite loop
+ $projectImportForm[0].submit();
}
});
diff --git a/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue
index 91d8fca0487..aa3235b1515 100644
--- a/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue
+++ b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue
@@ -2,6 +2,7 @@
import { GlAlert, GlToggle, GlTooltip } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { __, s__ } from '~/locale';
+import { CC_VALIDATION_REQUIRED_ERROR } from '../constants';
const DEFAULT_ERROR_MESSAGE = __('An error occurred while updating the configuration.');
const REQUIRES_VALIDATION_TEXT = s__(
@@ -47,11 +48,13 @@ export default {
};
},
computed: {
- showCreditCardValidation() {
+ ccRequiredError() {
+ return this.errorMessage === CC_VALIDATION_REQUIRED_ERROR && !this.ccAlertDismissed;
+ },
+ genericError() {
return (
- this.isCreditCardValidationRequired &&
- !this.isSharedRunnerEnabled &&
- !this.successfulValidation &&
+ this.errorMessage &&
+ this.errorMessage !== CC_VALIDATION_REQUIRED_ERROR &&
!this.ccAlertDismissed
);
},
@@ -62,6 +65,7 @@ export default {
},
toggleSharedRunners() {
this.isLoading = true;
+ this.ccAlertDismissed = false;
this.errorMessage = null;
axios
@@ -82,20 +86,19 @@ export default {
<template>
<div>
<section class="gl-mt-5">
- <gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" :dismissible="false">
- {{ errorMessage }}
- </gl-alert>
-
<cc-validation-required-alert
- v-if="showCreditCardValidation"
+ v-if="ccRequiredError"
class="gl-pb-5"
:custom-message="$options.i18n.REQUIRES_VALIDATION_TEXT"
@verifiedCreditCard="creditCardValidated"
@dismiss="ccAlertDismissed = true"
/>
+ <gl-alert v-if="genericError" class="gl-mb-3" variant="danger" :dismissible="false">
+ {{ errorMessage }}
+ </gl-alert>
+
<gl-toggle
- v-else
ref="sharedRunnersToggle"
:disabled="isDisabledAndUnoverridable"
:is-loading="isLoading"
diff --git a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue
index b98e1101884..fe968e74c6d 100644
--- a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue
+++ b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue
@@ -11,8 +11,12 @@ export default {
ConfirmDanger,
},
props: {
- namespaces: {
- type: Object,
+ groupNamespaces: {
+ type: Array,
+ required: true,
+ },
+ userNamespaces: {
+ type: Array,
required: true,
},
confirmationPhrase: {
@@ -44,10 +48,10 @@ export default {
<div>
<gl-form-group>
<namespace-select
- class="qa-namespaces-list"
data-testid="transfer-project-namespace"
:full-width="true"
- :data="namespaces"
+ :group-namespaces="groupNamespaces"
+ :user-namespaces="userNamespaces"
:selected-namespace="selectedNamespace"
@select="handleSelect"
/>
diff --git a/app/assets/javascripts/projects/settings/constants.js b/app/assets/javascripts/projects/settings/constants.js
index f5591c43dc4..9cf1afd334f 100644
--- a/app/assets/javascripts/projects/settings/constants.js
+++ b/app/assets/javascripts/projects/settings/constants.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
export const LEVEL_TYPES = {
ROLE: 'role',
USER: 'user',
@@ -18,3 +20,8 @@ export const ACCESS_LEVELS = {
};
export const ACCESS_LEVEL_NONE = 0;
+
+// must match shared_runners_setting in update_service.rb
+export const CC_VALIDATION_REQUIRED_ERROR = __(
+ 'Shared runners enabled cannot be enabled until a valid credit card is on file',
+);
diff --git a/app/assets/javascripts/projects/settings/init_transfer_project_form.js b/app/assets/javascripts/projects/settings/init_transfer_project_form.js
index 47b49031dc9..a5f720bffaa 100644
--- a/app/assets/javascripts/projects/settings/init_transfer_project_form.js
+++ b/app/assets/javascripts/projects/settings/init_transfer_project_form.js
@@ -3,10 +3,14 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import TransferProjectForm from './components/transfer_project_form.vue';
const prepareNamespaces = (rawNamespaces = '') => {
+ if (!rawNamespaces) {
+ return { groupNamespaces: [], userNamespaces: [] };
+ }
+
const data = JSON.parse(rawNamespaces);
return {
- group: data?.group.map(convertObjectPropsToCamelCase),
- user: data?.user.map(convertObjectPropsToCamelCase),
+ groupNamespaces: data?.group?.map(convertObjectPropsToCamelCase) || [],
+ userNamespaces: data?.user?.map(convertObjectPropsToCamelCase) || [],
};
};
@@ -35,7 +39,7 @@ export default () => {
props: {
confirmButtonText,
confirmationPhrase,
- namespaces: prepareNamespaces(namespaces),
+ ...prepareNamespaces(namespaces),
},
on: {
selectNamespace: (id) => {
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 f936c03c5d3..9ee2e7a4ffd 100644
--- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue
+++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
@@ -9,6 +9,8 @@ import {
linkedIssueTypesMap,
addRelatedIssueErrorMap,
addRelatedItemErrorMap,
+ issuablesFormCategoryHeaderTextMap,
+ issuablesFormInputTextMap,
} from '../constants';
import RelatedIssuableInput from './related_issuable_input.vue';
@@ -134,6 +136,12 @@ export default {
epics: mergeUrlParams({ confidential_only: true }, this.autoCompleteSources.epics),
};
},
+ issuableCategoryHeaderText() {
+ return issuablesFormCategoryHeaderTextMap[this.issuableType];
+ },
+ issuableInputText() {
+ return issuablesFormInputTextMap[this.issuableType];
+ },
},
methods: {
onPendingIssuableRemoveRequest(params) {
@@ -162,7 +170,7 @@ export default {
<form @submit.prevent="onFormSubmit">
<template v-if="showCategorizedIssues">
<gl-form-group
- :label="__('The current issue')"
+ :label="issuableCategoryHeaderText"
label-for="linked-issue-type-radio"
label-class="label-bold"
class="mb-2"
@@ -175,7 +183,7 @@ export default {
/>
</gl-form-group>
<p class="bold">
- {{ __('the following issue(s)') }}
+ {{ issuableInputText }}
</p>
</template>
<related-issuable-input
diff --git a/app/assets/javascripts/related_issues/components/related_issues_block.vue b/app/assets/javascripts/related_issues/components/related_issues_block.vue
index 94535e1b8c9..bc97fab9ad2 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_block.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue
@@ -5,6 +5,9 @@ import {
issuableQaClassMap,
linkedIssueTypesMap,
linkedIssueTypesTextMap,
+ issuablesBlockHeaderTextMap,
+ issuablesBlockHelpTextMap,
+ issuablesBlockAddButtonTextMap,
} from '../constants';
import AddIssuableForm from './add_issuable_form.vue';
import RelatedIssuesList from './related_issues_list.vue';
@@ -105,6 +108,15 @@ export default {
hasBody() {
return this.isFormVisible || this.shouldShowTokenBody;
},
+ headerText() {
+ return issuablesBlockHeaderTextMap[this.issuableType];
+ },
+ helpLinkText() {
+ return issuablesBlockHelpTextMap[this.issuableType];
+ },
+ addIssuableButtonText() {
+ return issuablesBlockAddButtonTextMap[this.issuableType];
+ },
badgeLabel() {
return this.isFetching && this.relatedIssues.length === 0 ? '...' : this.relatedIssues.length;
},
@@ -138,13 +150,14 @@ export default {
href="#related-issues"
aria-hidden="true"
/>
- <slot name="header-text">{{ __('Linked issues') }}</slot>
+ <slot name="header-text">{{ headerText }}</slot>
<gl-link
v-if="hasHelpPath"
:href="helpPath"
target="_blank"
class="gl-display-flex gl-align-items-center gl-ml-2 gl-text-gray-500"
- :aria-label="__('Read more about related issues')"
+ data-testid="help-link"
+ :aria-label="helpLinkText"
>
<gl-icon name="question" :size="12" />
</gl-link>
@@ -160,7 +173,7 @@ export default {
v-if="canAdmin"
data-qa-selector="related_issues_plus_button"
icon="plus"
- :aria-label="__('Add a related issue')"
+ :aria-label="addIssuableButtonText"
:class="qaClass"
@click="$emit('toggleAddRelatedIssuesForm', $event)"
/>
diff --git a/app/assets/javascripts/related_issues/constants.js b/app/assets/javascripts/related_issues/constants.js
index 89eae069a24..f911468d8f1 100644
--- a/app/assets/javascripts/related_issues/constants.js
+++ b/app/assets/javascripts/related_issues/constants.js
@@ -104,3 +104,28 @@ export const PathIdSeparator = {
Epic: '&',
Issue: '#',
};
+
+export const issuablesBlockHeaderTextMap = {
+ [issuableTypesMap.ISSUE]: __('Linked issues'),
+ [issuableTypesMap.EPIC]: __('Linked epics'),
+};
+
+export const issuablesBlockHelpTextMap = {
+ [issuableTypesMap.ISSUE]: __('Read more about related issues'),
+ [issuableTypesMap.EPIC]: __('Read more about related epics'),
+};
+
+export const issuablesBlockAddButtonTextMap = {
+ [issuableTypesMap.ISSUE]: __('Add a related issue'),
+ [issuableTypesMap.EPIC]: __('Add a related epic'),
+};
+
+export const issuablesFormCategoryHeaderTextMap = {
+ [issuableTypesMap.ISSUE]: __('The current issue'),
+ [issuableTypesMap.EPIC]: __('The current epic'),
+};
+
+export const issuablesFormInputTextMap = {
+ [issuableTypesMap.ISSUE]: __('the following issue(s)'),
+ [issuableTypesMap.EPIC]: __('the following epic(s)'),
+};
diff --git a/app/assets/javascripts/related_issues/index.js b/app/assets/javascripts/related_issues/index.js
index 0ee99df1455..35858be90b2 100644
--- a/app/assets/javascripts/related_issues/index.js
+++ b/app/assets/javascripts/related_issues/index.js
@@ -8,6 +8,7 @@ export default function initRelatedIssues() {
// eslint-disable-next-line no-new
new Vue({
el: relatedIssuesRootElement,
+ name: 'RelatedIssuesRoot',
components: {
relatedIssuesRoot: RelatedIssuesRoot,
},
diff --git a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue
index ed4f3c4e0fe..05ab5c2cc90 100644
--- a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue
+++ b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue
@@ -1,9 +1,10 @@
<script>
-import { GlLink } from '@gitlab/ui';
+import { GlBadge, GlLink } from '@gitlab/ui';
export default {
name: 'AccessibilityIssueBody',
components: {
+ GlBadge,
GlLink,
},
props: {
@@ -38,9 +39,9 @@ export default {
<template>
<div class="report-block-list-issue-description gl-mt-2 gl-mb-2">
<div ref="accessibility-issue-description" class="report-block-list-issue-description-text">
- <div v-if="isNew" ref="accessibility-issue-is-new-badge" class="badge badge-danger gl-mr-2">
- {{ s__('AccessibilityReport|New') }}
- </div>
+ <gl-badge v-if="isNew" class="gl-mr-2" variant="danger">{{
+ s__('AccessibilityReport|New')
+ }}</gl-badge>
<div>
{{
sprintf(
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index 9368d7e6058..52963b49f68 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -9,12 +9,14 @@ import axios from '~/lib/utils/axios_utils';
import { isLoggedIn } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import { redirectTo } from '~/lib/utils/url_utility';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import getRefMixin from '../mixins/get_ref';
import blobInfoQuery from '../queries/blob_info.query.graphql';
+import { DEFAULT_BLOB_INFO, TEXT_FILE_TYPE, LFS_STORAGE } from '../constants';
import BlobButtonGroup from './blob_button_group.vue';
import BlobEdit from './blob_edit.vue';
import ForkSuggestion from './fork_suggestion.vue';
-import { loadViewer, viewerProps } from './blob_viewers';
+import { loadViewer } from './blob_viewers';
export default {
i18n: {
@@ -29,7 +31,7 @@ export default {
GlButton,
ForkSuggestion,
},
- mixins: [getRefMixin],
+ mixins: [getRefMixin, glFeatureFlagMixin()],
inject: {
originalBranch: {
default: '',
@@ -43,12 +45,11 @@ export default {
projectPath: this.projectPath,
filePath: this.path,
ref: this.originalBranch || this.ref,
+ shouldFetchRawText: Boolean(this.glFeatures.highlightJs),
};
},
result() {
- this.switchViewer(
- this.hasRichViewer && !window.location.hash ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER,
- );
+ this.switchViewer(this.hasRichViewer ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER);
},
error() {
this.displayError();
@@ -78,50 +79,7 @@ export default {
isBinary: false,
isLoadingLegacyViewer: false,
activeViewerType: SIMPLE_BLOB_VIEWER,
- project: {
- userPermissions: {
- pushCode: false,
- downloadCode: false,
- createMergeRequestIn: false,
- forkProject: false,
- },
- pathLocks: {
- nodes: [],
- },
- repository: {
- empty: true,
- blobs: {
- nodes: [
- {
- name: '',
- size: '',
- rawTextBlob: '',
- type: '',
- fileType: '',
- tooLarge: false,
- path: '',
- editBlobPath: '',
- ideEditPath: '',
- forkAndEditPath: '',
- ideForkAndEditPath: '',
- storedExternally: false,
- externalStorage: '',
- canModifyBlob: false,
- canCurrentUserPushToBranch: false,
- archived: false,
- rawPath: '',
- externalStorageUrl: '',
- replacePath: '',
- pipelineEditorPath: '',
- deletePath: '',
- simpleViewer: {},
- richViewer: null,
- webPath: '',
- },
- ],
- },
- },
- },
+ project: DEFAULT_BLOB_INFO,
};
},
computed: {
@@ -132,7 +90,7 @@ export default {
return this.$apollo.queries.project.loading;
},
isBinaryFileType() {
- return this.isBinary || this.blobInfo.simpleViewer?.fileType !== 'text';
+ return this.isBinary || this.blobInfo.simpleViewer?.fileType !== TEXT_FILE_TYPE;
},
blobInfo() {
const nodes = this.project?.repository?.blobs?.nodes || [];
@@ -151,11 +109,16 @@ export default {
},
blobViewer() {
const { fileType } = this.viewer;
- return loadViewer(fileType);
+ return this.shouldLoadLegacyViewer ? null : loadViewer(fileType, this.isUsingLfs);
},
- viewerProps() {
- const { fileType } = this.viewer;
- return viewerProps(fileType, this.blobInfo);
+ shouldLoadLegacyViewer() {
+ return this.viewer.fileType === TEXT_FILE_TYPE && !this.glFeatures.highlightJs;
+ },
+ legacyViewerLoaded() {
+ return (
+ (this.activeViewerType === SIMPLE_BLOB_VIEWER && this.legacySimpleViewer) ||
+ (this.activeViewerType === RICH_BLOB_VIEWER && this.legacyRichViewer)
+ );
},
canLock() {
const { pushCode, downloadCode } = this.project.userPermissions;
@@ -183,18 +146,23 @@ export default {
? this.blobInfo.ideForkAndEditPath
: this.blobInfo.forkAndEditPath;
},
+ isUsingLfs() {
+ return this.blobInfo.storedExternally && this.blobInfo.externalStorage === LFS_STORAGE;
+ },
},
methods: {
- loadLegacyViewer(type) {
- if (this.legacyViewerLoaded(type)) {
+ loadLegacyViewer() {
+ if (this.legacyViewerLoaded) {
return;
}
+ const type = this.activeViewerType;
+
this.isLoadingLegacyViewer = true;
axios
.get(`${this.blobInfo.webPath}?format=json&viewer=${type}`)
.then(({ data: { html, binary } }) => {
- if (type === 'simple') {
+ if (type === SIMPLE_BLOB_VIEWER) {
this.legacySimpleViewer = html;
} else {
this.legacyRichViewer = html;
@@ -205,12 +173,6 @@ export default {
})
.catch(() => this.displayError());
},
- legacyViewerLoaded(type) {
- return (
- (type === SIMPLE_BLOB_VIEWER && this.legacySimpleViewer) ||
- (type === RICH_BLOB_VIEWER && this.legacyRichViewer)
- );
- },
displayError() {
createFlash({ message: __('An error occurred while loading the file. Please try again.') });
},
@@ -218,7 +180,7 @@ export default {
this.activeViewerType = newViewer || SIMPLE_BLOB_VIEWER;
if (!this.blobViewer) {
- this.loadLegacyViewer(this.activeViewerType);
+ this.loadLegacyViewer();
}
},
editBlob(target) {
@@ -243,10 +205,11 @@ export default {
<div v-if="blobInfo && !isLoading" class="file-holder">
<blob-header
:blob="blobInfo"
- :hide-viewer-switcher="!hasRichViewer || isBinaryFileType"
+ :hide-viewer-switcher="!hasRichViewer || isBinaryFileType || isUsingLfs"
:is-binary="isBinaryFileType"
:active-viewer-type="viewer.type"
:has-render-error="hasRenderError"
+ :show-path="false"
@viewer-changed="switchViewer"
>
<template #actions>
@@ -303,7 +266,7 @@ export default {
:hide-line-numbers="true"
:loading="isLoadingLegacyViewer"
/>
- <component :is="blobViewer" v-else v-bind="viewerProps" class="blob-viewer" />
+ <component :is="blobViewer" v-else :blob="blobInfo" class="blob-viewer" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue
index 48fa33eb558..f7b318c64d9 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue
@@ -9,19 +9,17 @@ export default {
GlLink,
},
props: {
- fileName: {
- type: String,
+ blob: {
+ type: Object,
required: true,
},
- filePath: {
- type: String,
- required: true,
- },
- fileSize: {
- type: Number,
- required: false,
- default: 0,
- },
+ },
+ data() {
+ return {
+ fileName: this.blob.name,
+ filePath: this.blob.rawPath,
+ fileSize: this.blob.rawSize || 0,
+ };
},
computed: {
downloadFileSize() {
diff --git a/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue
index 83d36209bb3..5027f7877aa 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue
@@ -1,15 +1,17 @@
<script>
export default {
props: {
- url: {
- type: String,
- required: true,
- },
- alt: {
- type: String,
+ blob: {
+ type: Object,
required: true,
},
},
+ data() {
+ return {
+ url: this.blob.rawPath,
+ alt: this.blob.name,
+ };
+ },
};
</script>
<template>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js
index 8f6f2d15215..e942f59e7d8 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/index.js
+++ b/app/assets/javascripts/repository/components/blob_viewers/index.js
@@ -1,48 +1,19 @@
-export const loadViewer = (type) => {
- switch (type) {
- case 'empty':
- return () => import(/* webpackChunkName: 'blob_empty_viewer' */ './empty_viewer.vue');
- case 'text':
- return gon.features.highlightJs
- ? () =>
- import(
- /* webpackChunkName: 'blob_text_viewer' */ '~/vue_shared/components/source_viewer.vue'
- )
- : null;
- case 'download':
- return () => import(/* webpackChunkName: 'blob_download_viewer' */ './download_viewer.vue');
- case 'image':
- return () => import(/* webpackChunkName: 'blob_image_viewer' */ './image_viewer.vue');
- case 'video':
- return () => import(/* webpackChunkName: 'blob_video_viewer' */ './video_viewer.vue');
- case 'pdf':
- return () => import(/* webpackChunkName: 'blob_pdf_viewer' */ './pdf_viewer.vue');
- default:
- return null;
- }
+const viewers = {
+ download: () => import('./download_viewer.vue'),
+ image: () => import('./image_viewer.vue'),
+ video: () => import('./video_viewer.vue'),
+ empty: () => import('./empty_viewer.vue'),
+ text: () => import('~/vue_shared/components/source_viewer/source_viewer.vue'),
+ pdf: () => import('./pdf_viewer.vue'),
+ lfs: () => import('./lfs_viewer.vue'),
};
-export const viewerProps = (type, blob) => {
- return {
- text: {
- content: blob.rawTextBlob,
- autoDetect: true, // We'll eventually disable autoDetect and pass the language explicitly to reduce the footprint (https://gitlab.com/gitlab-org/gitlab/-/issues/348145)
- },
- download: {
- fileName: blob.name,
- filePath: blob.rawPath,
- fileSize: blob.rawSize,
- },
- image: {
- url: blob.rawPath,
- alt: blob.name,
- },
- video: {
- url: blob.rawPath,
- },
- pdf: {
- url: blob.rawPath,
- fileSize: blob.rawSize,
- },
- }[type];
+export const loadViewer = (type, isUsingLfs) => {
+ let viewer = viewers[type];
+
+ if (!viewer && isUsingLfs) {
+ viewer = viewers.lfs;
+ }
+
+ return viewer;
};
diff --git a/app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue
new file mode 100644
index 00000000000..6dc7e10662e
--- /dev/null
+++ b/app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue
@@ -0,0 +1,38 @@
+<script>
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ i18n: {
+ lfsText: __(
+ 'This content could not be displayed because it is stored in LFS. You can %{linkStart}download it%{linkEnd} instead.',
+ ),
+ },
+ components: {
+ GlLink,
+ GlSprintf,
+ },
+ props: {
+ blob: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ fileName: this.blob.name,
+ filePath: this.blob.rawPath,
+ };
+ },
+};
+</script>
+
+<template>
+ <div class="gl-text-center gl-py-13 gl-bg-gray-50" data-type="lfs">
+ <gl-sprintf :message="$options.i18n.lfsText">
+ <template #link="{ content }">
+ <gl-link :href="filePath" :download="fileName" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+</template>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue
index 803a357df52..c3df5984426 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue
@@ -11,17 +11,17 @@ export default {
tooLargeButtonText: __('Download PDF'),
},
props: {
- url: {
- type: String,
- required: true,
- },
- fileSize: {
- type: Number,
+ blob: {
+ type: Object,
required: true,
},
},
data() {
- return { totalPages: 0 };
+ return {
+ url: this.blob.rawPath,
+ fileSize: this.blob.rawSize,
+ totalPages: 0,
+ };
},
computed: {
tooLargeToDisplay() {
diff --git a/app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue
index dec0c4802ca..260b831f4d1 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue
@@ -1,11 +1,16 @@
<script>
export default {
props: {
- url: {
- type: String,
+ blob: {
+ type: Object,
required: true,
},
},
+ data() {
+ return {
+ url: this.blob.rawPath,
+ };
+ },
};
</script>
<template>
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index 43e114a91d3..c3d121505b6 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -139,8 +139,10 @@ export default {
/>
<gl-button
v-if="commit.descriptionHtml"
+ v-gl-tooltip
:class="{ open: showDescription }"
- :aria-label="__('Show commit description')"
+ :title="__('Toggle commit description')"
+ :aria-label="__('Toggle commit description')"
class="text-expander gl-vertical-align-bottom!"
icon="ellipsis_h"
@click="toggleShowDescription"
diff --git a/app/assets/javascripts/repository/components/table/parent_row.vue b/app/assets/javascripts/repository/components/table/parent_row.vue
index fb0e505a16e..8a081944600 100644
--- a/app/assets/javascripts/repository/components/table/parent_row.vue
+++ b/app/assets/javascripts/repository/components/table/parent_row.vue
@@ -1,10 +1,13 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
export default {
components: {
GlLoadingIcon,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
commitRef: {
type: String,
@@ -41,7 +44,13 @@ export default {
<template>
<tr class="tree-item">
- <td colspan="3" class="tree-item-file-name" @click.self="clickRow">
+ <td
+ v-gl-tooltip.left.viewport
+ :title="__('Go to parent directory')"
+ colspan="3"
+ class="tree-item-file-name"
+ @click.self="clickRow"
+ >
<gl-loading-icon
v-if="parentPath === loadingPath"
size="sm"
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index 8fcec5fb893..7aac35e7613 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -195,6 +195,7 @@ export default {
projectPath: this.projectPath,
filePath: this.path,
ref: this.ref,
+ shouldFetchRawText: Boolean(this.glFeatures.highlightJs),
});
},
apolloQuery(query, variables) {
diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js
index d01757d6141..e206d9bfbd2 100644
--- a/app/assets/javascripts/repository/constants.js
+++ b/app/assets/javascripts/repository/constants.js
@@ -25,3 +25,54 @@ export const PDF_MAX_FILE_SIZE = 10000000; // 10 MB
export const PDF_MAX_PAGE_LIMIT = 50;
export const ROW_APPEAR_DELAY = 150;
+
+export const DEFAULT_BLOB_INFO = {
+ userPermissions: {
+ pushCode: false,
+ downloadCode: false,
+ createMergeRequestIn: false,
+ forkProject: false,
+ },
+ pathLocks: {
+ nodes: [],
+ },
+ repository: {
+ empty: true,
+ blobs: {
+ nodes: [
+ {
+ name: '',
+ size: '',
+ rawTextBlob: '',
+ type: '',
+ fileType: '',
+ tooLarge: false,
+ path: '',
+ editBlobPath: '',
+ ideEditPath: '',
+ forkAndEditPath: '',
+ ideForkAndEditPath: '',
+ storedExternally: false,
+ externalStorage: '',
+ environmentFormattedExternalUrl: '',
+ environmentExternalUrlForRouteMap: '',
+ canModifyBlob: false,
+ canCurrentUserPushToBranch: false,
+ archived: false,
+ rawPath: '',
+ externalStorageUrl: '',
+ replacePath: '',
+ pipelineEditorPath: '',
+ deletePath: '',
+ simpleViewer: {},
+ richViewer: null,
+ webPath: '',
+ },
+ ],
+ },
+ },
+};
+
+export const TEXT_FILE_TYPE = 'text';
+
+export const LFS_STORAGE = 'lfs';
diff --git a/app/assets/javascripts/repository/fragmentTypes.json b/app/assets/javascripts/repository/fragmentTypes.json
deleted file mode 100644
index 949ebca432b..00000000000
--- a/app/assets/javascripts/repository/fragmentTypes.json
+++ /dev/null
@@ -1 +0,0 @@
-{"__schema":{"types":[{"kind":"INTERFACE","name":"Entry","possibleTypes":[{"name":"Blob"},{"name":"Submodule"},{"name":"TreeEntry"}]}]}}
diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js
index 96d712ce9b4..29aabe1b00f 100644
--- a/app/assets/javascripts/repository/graphql.js
+++ b/app/assets/javascripts/repository/graphql.js
@@ -1,19 +1,11 @@
-import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import axios from '~/lib/utils/axios_utils';
-import introspectionQueryResultData from './fragmentTypes.json';
import { fetchLogsTree } from './log_tree';
Vue.use(VueApollo);
-// We create a fragment matcher so that we can create a fragment from an interface
-// Without this, Apollo throws a heuristic fragment matcher warning
-const fragmentMatcher = new IntrospectionFragmentMatcher({
- introspectionQueryResultData,
-});
-
const defaultClient = createDefaultClient(
{
Query: {
@@ -43,7 +35,6 @@ const defaultClient = createDefaultClient(
},
{
cacheConfig: {
- fragmentMatcher,
dataIdFromObject: (obj) => {
/* eslint-disable @gitlab/require-i18n-strings */
// eslint-disable-next-line no-underscore-dangle
diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql
index ae20a0f0bc4..78323fdc5f4 100644
--- a/app/assets/javascripts/repository/queries/blob_info.query.graphql
+++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql
@@ -1,6 +1,11 @@
#import "ee_else_ce/repository/queries/path_locks.fragment.graphql"
-query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) {
+query getBlobInfo(
+ $projectPath: ID!
+ $filePath: String!
+ $ref: String!
+ $shouldFetchRawText: Boolean!
+) {
project(fullPath: $projectPath) {
userPermissions {
pushCode
@@ -18,18 +23,22 @@ query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) {
name
size
rawSize
- rawTextBlob
+ rawTextBlob @include(if: $shouldFetchRawText)
fileType
+ language
path
editBlobPath
ideEditPath
forkAndEditPath
ideForkAndEditPath
+ environmentFormattedExternalUrl
+ environmentExternalUrlForRouteMap
canModifyBlob
canCurrentUserPushToBranch
archived
storedExternally
externalStorage
+ externalStorageUrl
rawPath
replacePath
pipelineEditorPath
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index ee9533bbec3..009afe03ea6 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -1,7 +1,7 @@
/* eslint-disable func-names, consistent-return, no-param-reassign */
import $ from 'jquery';
-import Cookies from 'js-cookie';
+import { setCookie } from '~/lib/utils/common_utils';
import { hide, fixTitle } from '~/tooltips';
import createFlash from './flash';
import axios from './lib/utils/axios_utils';
@@ -80,7 +80,7 @@ Sidebar.prototype.sidebarToggleClicked = function (e, triggered) {
hide($this);
if (!triggered) {
- Cookies.set('collapsed_gutter', $('.right-sidebar').hasClass('right-sidebar-collapsed'));
+ setCookie('collapsed_gutter', $('.right-sidebar').hasClass('right-sidebar-collapsed'));
}
};
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
new file mode 100644
index 00000000000..2795ddbbbcb
--- /dev/null
+++ b/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue
@@ -0,0 +1,77 @@
+<script>
+import { GlTooltipDirective } from '@gitlab/ui';
+import { createAlert } from '~/flash';
+import { TYPE_CI_RUNNER } from '~/graphql_shared/constants';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+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 { I18N_FETCH_ERROR } from '../constants';
+import getRunnerQuery from '../graphql/get_runner.query.graphql';
+import { captureException } from '../sentry_utils';
+
+export default {
+ name: 'AdminRunnerShowApp',
+ components: {
+ RunnerEditButton,
+ RunnerPauseButton,
+ RunnerHeader,
+ RunnerDetails,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ runnerId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ runner: null,
+ };
+ },
+ apollo: {
+ runner: {
+ query: getRunnerQuery,
+ variables() {
+ return {
+ id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId),
+ };
+ },
+ error(error) {
+ createAlert({ message: I18N_FETCH_ERROR });
+
+ this.reportToSentry(error);
+ },
+ },
+ },
+ computed: {
+ canUpdate() {
+ return this.runner.userPermissions?.updateRunner;
+ },
+ },
+ errorCaptured(error) {
+ this.reportToSentry(error);
+ },
+ methods: {
+ reportToSentry(error) {
+ captureException({ error, component: this.$options.name });
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <runner-header v-if="runner" :runner="runner">
+ <template #actions>
+ <runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" />
+ <runner-pause-button v-if="canUpdate" :runner="runner" />
+ </template>
+ </runner-header>
+
+ <runner-details :runner="runner" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/runner/admin_runner_show/index.js b/app/assets/javascripts/runner/admin_runner_show/index.js
new file mode 100644
index 00000000000..a781898cf8d
--- /dev/null
+++ b/app/assets/javascripts/runner/admin_runner_show/index.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import AdminRunnerShowApp from './admin_runner_show_app.vue';
+
+Vue.use(VueApollo);
+
+export const initAdminRunnerShow = (selector = '#js-admin-runner-show') => {
+ const el = document.querySelector(selector);
+
+ if (!el) {
+ return null;
+ }
+
+ const { runnerId } = el.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(h) {
+ return h(AdminRunnerShowApp, {
+ props: {
+ runnerId,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
index bb2bac531a7..a968d4029f8 100644
--- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
@@ -99,7 +99,10 @@ export default {
allRunnersCount: {
...runnersCountSmartQuery,
variables() {
- return this.countVariables;
+ return {
+ ...this.countVariables,
+ type: null,
+ };
},
},
instanceRunnersCount: {
@@ -276,7 +279,11 @@ export default {
</gl-link>
</template>
</runner-list>
- <runner-pagination v-model="search.pagination" :page-info="runners.pageInfo" />
+ <runner-pagination
+ v-model="search.pagination"
+ class="gl-mt-3"
+ :page-info="runners.pageInfo"
+ />
</template>
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/cells/link_cell.vue b/app/assets/javascripts/runner/components/cells/link_cell.vue
new file mode 100644
index 00000000000..2843ddbacaf
--- /dev/null
+++ b/app/assets/javascripts/runner/components/cells/link_cell.vue
@@ -0,0 +1,27 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+
+export default {
+ props: {
+ href: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ component() {
+ if (this.href) {
+ return GlLink;
+ }
+ return 'span';
+ },
+ },
+};
+</script>
+
+<template>
+ <component :is="component" :href="href" v-bind="$attrs" v-on="$listeners">
+ <slot></slot>
+ </component>
+</template>
diff --git a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
index 0934508c87f..ae9c774f2a2 100644
--- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
@@ -1,16 +1,14 @@
<script>
import { GlButton, GlButtonGroup, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
import { createAlert } from '~/flash';
-import { __, s__, sprintf } from '~/locale';
+import { s__, sprintf } from '~/locale';
import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql';
-import runnerActionsUpdateMutation from '~/runner/graphql/runner_actions_update.mutation.graphql';
import { captureException } from '~/runner/sentry_utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import RunnerEditButton from '../runner_edit_button.vue';
+import RunnerPauseButton from '../runner_pause_button.vue';
import RunnerDeleteModal from '../runner_delete_modal.vue';
-const I18N_EDIT = __('Edit');
-const I18N_PAUSE = __('Pause');
-const I18N_RESUME = __('Resume');
const I18N_DELETE = s__('Runners|Delete runner');
const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted');
@@ -19,6 +17,8 @@ export default {
components: {
GlButton,
GlButtonGroup,
+ RunnerEditButton,
+ RunnerPauseButton,
RunnerDeleteModal,
},
directives: {
@@ -38,20 +38,6 @@ export default {
};
},
computed: {
- isActive() {
- return this.runner.active;
- },
- toggleActiveIcon() {
- return this.isActive ? 'pause' : 'play';
- },
- toggleActiveTitle() {
- if (this.updating) {
- // Prevent a "sticky" tooltip: If this button is disabled,
- // mouseout listeners don't run leaving the tooltip stuck
- return '';
- }
- return this.isActive ? I18N_PAUSE : I18N_RESUME;
- },
deleteTitle() {
if (this.deleting) {
// Prevent a "sticky" tooltip: If this button is disabled,
@@ -77,35 +63,6 @@ export default {
},
},
methods: {
- async onToggleActive() {
- this.updating = true;
- try {
- const toggledActive = !this.runner.active;
-
- const {
- data: {
- runnerUpdate: { errors },
- },
- } = await this.$apollo.mutate({
- mutation: runnerActionsUpdateMutation,
- variables: {
- input: {
- id: this.runner.id,
- active: toggledActive,
- },
- },
- });
-
- if (errors && errors.length) {
- throw new Error(errors.join(' '));
- }
- } catch (e) {
- this.onError(e);
- } finally {
- this.updating = false;
- }
- },
-
async onDelete() {
// Deleting stays "true" until this row is removed,
// should only change back if the operation fails.
@@ -147,7 +104,6 @@ export default {
captureException({ error, component: this.$options.name });
},
},
- I18N_EDIT,
I18N_DELETE,
};
</script>
@@ -161,23 +117,8 @@ export default {
See https://gitlab.com/gitlab-org/gitlab/-/issues/334802
-->
- <gl-button
- v-if="canUpdate && runner.editAdminUrl"
- v-gl-tooltip.hover.viewport="$options.I18N_EDIT"
- :href="runner.editAdminUrl"
- :aria-label="$options.I18N_EDIT"
- icon="pencil"
- data-testid="edit-runner"
- />
- <gl-button
- v-if="canUpdate"
- v-gl-tooltip.hover.viewport="toggleActiveTitle"
- :aria-label="toggleActiveTitle"
- :icon="toggleActiveIcon"
- :loading="updating"
- data-testid="toggle-active-runner"
- @click="onToggleActive"
- />
+ <runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" />
+ <runner-pause-button v-if="canUpdate" :runner="runner" :compact="true" />
<gl-button
v-if="canDelete"
v-gl-tooltip.hover.viewport="deleteTitle"
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 0e259807f98..54c35e483dc 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
@@ -11,8 +11,10 @@ import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants';
export default {
name: 'RunnerRegistrationTokenReset',
i18n: {
- modalTitle: __('Reset registration token'),
+ modalAction: s__('Runners|Reset token'),
+ modalCancel: __('Cancel'),
modalCopy: __('Are you sure you want to reset the registration token?'),
+ modalTitle: __('Reset registration token'),
},
components: {
GlDropdownItem,
@@ -30,7 +32,7 @@ export default {
default: null,
},
},
- modalID: 'token-reset-modal',
+ modalId: 'token-reset-modal',
props: {
type: {
type: String,
@@ -111,10 +113,19 @@ export default {
};
</script>
<template>
- <gl-dropdown-item v-gl-modal="$options.modalID">
+ <gl-dropdown-item v-gl-modal="$options.modalId">
{{ __('Reset registration token') }}
<gl-modal
- :modal-id="$options.modalID"
+ size="sm"
+ :modal-id="$options.modalId"
+ :action-primary="{
+ text: $options.i18n.modalAction,
+ attributes: [{ variant: 'danger' }],
+ }"
+ :action-secondary="{
+ text: $options.i18n.modalCancel,
+ attributes: [{ variant: 'default' }],
+ }"
:title="$options.i18n.modalTitle"
@primary="handleModalPrimary"
>
diff --git a/app/assets/javascripts/runner/components/runner_assigned_item.vue b/app/assets/javascripts/runner/components/runner_assigned_item.vue
new file mode 100644
index 00000000000..ea8074199a6
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_assigned_item.vue
@@ -0,0 +1,39 @@
+<script>
+import { GlAvatar, GlLink } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlAvatar,
+ GlLink,
+ },
+ props: {
+ href: {
+ type: String,
+ required: true,
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ fullName: {
+ type: String,
+ required: true,
+ },
+ avatarUrl: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-center gl-py-5">
+ <gl-link :href="href" data-testid="item-avatar" class="gl-text-decoration-none! gl-mr-3">
+ <gl-avatar shape="rect" :entity-name="name" :alt="name" :src="avatarUrl" :size="48" />
+ </gl-link>
+
+ <gl-link :href="href" class="gl-font-weight-bold gl-text-gray-900!">{{ fullName }}</gl-link>
+ </div>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_detail.vue b/app/assets/javascripts/runner/components/runner_detail.vue
new file mode 100644
index 00000000000..b1234818b7e
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_detail.vue
@@ -0,0 +1,50 @@
+<script>
+import { __ } from '~/locale';
+
+/**
+ * Usage:
+ *
+ * With a `value` prop:
+ *
+ * <runner-detail label="Field Name" :value="value" />
+ *
+ * Or a `value` slot:
+ *
+ * <runner-detail label="Field Name">
+ * <template #value>
+ * <strong>{{ value }}</strong>
+ * </template>
+ * </runner-detail>
+ *
+ */
+export default {
+ props: {
+ label: {
+ type: String,
+ required: true,
+ },
+ value: {
+ type: String,
+ default: null,
+ required: false,
+ },
+ emptyValue: {
+ type: String,
+ default: __('None'),
+ required: false,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-pb-4">
+ <dt class="gl-mr-2">{{ label }}</dt>
+ <dd class="gl-mb-0">
+ <template v-if="value || $slots.value">
+ <slot name="value">{{ value }}</slot>
+ </template>
+ <span v-else class="gl-text-gray-500">{{ emptyValue }}</span>
+ </dd>
+ </div>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_details.vue b/app/assets/javascripts/runner/components/runner_details.vue
new file mode 100644
index 00000000000..b6a5ffc7a64
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_details.vue
@@ -0,0 +1,124 @@
+<script>
+import { GlBadge, GlTabs, GlTab, 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';
+import { ACCESS_LEVEL_REF_PROTECTED, GROUP_TYPE, PROJECT_TYPE } from '../constants';
+import { formatJobCount } from '../utils';
+import RunnerDetail from './runner_detail.vue';
+import RunnerGroups from './runner_groups.vue';
+import RunnerProjects from './runner_projects.vue';
+import RunnerJobs from './runner_jobs.vue';
+import RunnerTags from './runner_tags.vue';
+
+export default {
+ components: {
+ GlBadge,
+ GlTabs,
+ GlTab,
+ GlIntersperse,
+ RunnerDetail,
+ RunnerGroups,
+ RunnerProjects,
+ RunnerJobs,
+ RunnerTags,
+ TimeAgo,
+ },
+ props: {
+ runner: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ maximumTimeout() {
+ const { maximumTimeout } = this.runner;
+ if (typeof maximumTimeout !== 'number') {
+ return null;
+ }
+ return timeIntervalInWords(maximumTimeout);
+ },
+ configTextProtected() {
+ if (this.runner.accessLevel === ACCESS_LEVEL_REF_PROTECTED) {
+ return s__('Runners|Protected');
+ }
+ return null;
+ },
+ configTextUntagged() {
+ if (this.runner.runUntagged) {
+ return s__('Runners|Runs untagged jobs');
+ }
+ return null;
+ },
+ isGroupRunner() {
+ return this.runner?.runnerType === GROUP_TYPE;
+ },
+ isProjectRunner() {
+ return this.runner?.runnerType === PROJECT_TYPE;
+ },
+ jobCount() {
+ return formatJobCount(this.runner?.jobCount);
+ },
+ },
+ ACCESS_LEVEL_REF_PROTECTED,
+};
+</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|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>
+ </dl>
+ </div>
+
+ <runner-groups v-if="isGroupRunner" :runner="runner" />
+ <runner-projects v-if="isProjectRunner" :runner="runner" />
+ </template>
+ </gl-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>
+ </gl-tabs>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_edit_button.vue b/app/assets/javascripts/runner/components/runner_edit_button.vue
new file mode 100644
index 00000000000..b115be09e69
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_edit_button.vue
@@ -0,0 +1,26 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+const I18N_EDIT = __('Edit');
+
+export default {
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ I18N_EDIT,
+};
+</script>
+
+<template>
+ <gl-button
+ v-gl-tooltip="$options.I18N_EDIT"
+ v-bind="$attrs"
+ :aria-label="$options.I18N_EDIT"
+ icon="pencil"
+ v-on="$listeners"
+ />
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_groups.vue b/app/assets/javascripts/runner/components/runner_groups.vue
new file mode 100644
index 00000000000..c3b35bd52a9
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_groups.vue
@@ -0,0 +1,37 @@
+<script>
+import RunnerAssignedItem from './runner_assigned_item.vue';
+
+export default {
+ components: {
+ RunnerAssignedItem,
+ },
+ props: {
+ runner: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ groups() {
+ return this.runner.groups?.nodes || [];
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid">
+ <h3 class="gl-font-lg gl-mt-5 gl-mb-0">{{ s__('Runners|Assigned Group') }}</h3>
+ <template v-if="groups.length">
+ <runner-assigned-item
+ v-for="group in groups"
+ :key="group.id"
+ :href="group.webUrl"
+ :name="group.name"
+ :full-name="group.fullName"
+ :avatar-url="group.avatarUrl"
+ />
+ </template>
+ <span v-else class="gl-text-gray-500">{{ __('None') }}</span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_header.vue b/app/assets/javascripts/runner/components/runner_header.vue
index 09f58df7bd0..abc07cec1ad 100644
--- a/app/assets/javascripts/runner/components/runner_header.vue
+++ b/app/assets/javascripts/runner/components/runner_header.vue
@@ -1,19 +1,23 @@
<script>
-import { GlSprintf } from '@gitlab/ui';
+import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { sprintf } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { I18N_DETAILS_TITLE } from '../constants';
+import { I18N_DETAILS_TITLE, I18N_LOCKED_RUNNER_DESCRIPTION } from '../constants';
import RunnerTypeBadge from './runner_type_badge.vue';
import RunnerStatusBadge from './runner_status_badge.vue';
export default {
components: {
+ GlIcon,
GlSprintf,
TimeAgo,
RunnerTypeBadge,
RunnerStatusBadge,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
runner: {
type: Object,
@@ -29,24 +33,36 @@ export default {
return sprintf(I18N_DETAILS_TITLE, { runner_id: id });
},
},
+ I18N_LOCKED_RUNNER_DESCRIPTION,
};
</script>
<template>
- <div class="gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100">
- <runner-status-badge :runner="runner" />
- <runner-type-badge v-if="runner" :type="runner.runnerType" />
- <template v-if="runner.createdAt">
- <gl-sprintf :message="__('%{runner} created %{timeago}')">
- <template #runner>
- <strong>{{ heading }}</strong>
- </template>
- <template #timeago>
- <time-ago :time="runner.createdAt" />
- </template>
- </gl-sprintf>
- </template>
- <template v-else>
- <strong>{{ heading }}</strong>
- </template>
+ <div
+ class="gl-display-flex gl-align-items-center gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
+ >
+ <div>
+ <runner-status-badge :runner="runner" />
+ <runner-type-badge v-if="runner" :type="runner.runnerType" />
+ <template v-if="runner.createdAt">
+ <gl-sprintf :message="__('%{runner} created %{timeago}')">
+ <template #runner>
+ <strong>{{ heading }}</strong>
+ <gl-icon
+ v-if="runner.locked"
+ v-gl-tooltip="$options.I18N_LOCKED_RUNNER_DESCRIPTION"
+ name="lock"
+ :aria-label="$options.I18N_LOCKED_RUNNER_DESCRIPTION"
+ />
+ </template>
+ <template #timeago>
+ <time-ago :time="runner.createdAt" />
+ </template>
+ </gl-sprintf>
+ </template>
+ <template v-else>
+ <strong>{{ heading }}</strong>
+ </template>
+ </div>
+ <div class="gl-ml-auto gl-flex-shrink-0"><slot name="actions"></slot></div>
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_jobs.vue b/app/assets/javascripts/runner/components/runner_jobs.vue
new file mode 100644
index 00000000000..c13e7e90168
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_jobs.vue
@@ -0,0 +1,82 @@
+<script>
+import { GlSkeletonLoading } from '@gitlab/ui';
+import { createAlert } from '~/flash';
+import getRunnerJobsQuery from '../graphql/get_runner_jobs.query.graphql';
+import { I18N_FETCH_ERROR, I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '../constants';
+import { captureException } from '../sentry_utils';
+import { getPaginationVariables } from '../utils';
+import RunnerJobsTable from './runner_jobs_table.vue';
+import RunnerPagination from './runner_pagination.vue';
+
+export default {
+ name: 'RunnerJobs',
+ components: {
+ GlSkeletonLoading,
+ RunnerJobsTable,
+ RunnerPagination,
+ },
+ props: {
+ runner: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ jobs: {
+ items: [],
+ pageInfo: {},
+ },
+ pagination: {
+ page: 1,
+ },
+ };
+ },
+ apollo: {
+ jobs: {
+ query: getRunnerJobsQuery,
+ variables() {
+ return this.variables;
+ },
+ update({ runner }) {
+ return {
+ items: runner?.jobs?.nodes || [],
+ pageInfo: runner?.jobs?.pageInfo || {},
+ };
+ },
+ error(error) {
+ createAlert({ message: I18N_FETCH_ERROR });
+ this.reportToSentry(error);
+ },
+ },
+ },
+ computed: {
+ variables() {
+ const { id } = this.runner;
+ return {
+ id,
+ ...getPaginationVariables(this.pagination, RUNNER_DETAILS_JOBS_PAGE_SIZE),
+ };
+ },
+ loading() {
+ return this.$apollo.queries.jobs.loading;
+ },
+ },
+ methods: {
+ reportToSentry(error) {
+ captureException({ error, component: this.$options.name });
+ },
+ },
+ I18N_NO_JOBS_FOUND,
+};
+</script>
+
+<template>
+ <div class="gl-pt-3">
+ <gl-skeleton-loading v-if="loading" class="gl-py-5" />
+ <runner-jobs-table v-else-if="jobs.items.length" :jobs="jobs.items" />
+ <p v-else>{{ $options.I18N_NO_JOBS_FOUND }}</p>
+
+ <runner-pagination v-model="pagination" :disabled="loading" :page-info="jobs.pageInfo" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_jobs_table.vue b/app/assets/javascripts/runner/components/runner_jobs_table.vue
new file mode 100644
index 00000000000..7817577bab0
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_jobs_table.vue
@@ -0,0 +1,95 @@
+<script>
+import { GlTableLite } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
+import RunnerTags from '~/runner/components/runner_tags.vue';
+import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import { tableField } from '../utils';
+import LinkCell from './cells/link_cell.vue';
+
+export default {
+ components: {
+ CiBadge,
+ GlTableLite,
+ LinkCell,
+ RunnerTags,
+ TimeAgo,
+ },
+ props: {
+ jobs: {
+ type: Array,
+ required: true,
+ },
+ },
+ methods: {
+ trAttr(job) {
+ if (job?.id) {
+ return { 'data-testid': `job-row-${getIdFromGraphQLId(job.id)}` };
+ }
+ return {};
+ },
+ jobId(job) {
+ return getIdFromGraphQLId(job.id);
+ },
+ jobPath(job) {
+ return job.detailedStatus?.detailsPath;
+ },
+ projectName(job) {
+ return job.pipeline?.project?.name;
+ },
+ projectWebUrl(job) {
+ return job.pipeline?.project?.webUrl;
+ },
+ commitShortSha(job) {
+ return job.shortSha;
+ },
+ commitPath(job) {
+ return job.commitPath;
+ },
+ },
+ fields: [
+ tableField({ key: 'status', label: s__('Job|Status') }),
+ tableField({ key: 'job', label: __('Job') }),
+ tableField({ key: 'project', label: __('Project') }),
+ tableField({ key: 'commit', label: __('Commit') }),
+ tableField({ key: 'finished_at', label: s__('Job|Finished at') }),
+ tableField({ key: 'tags', label: s__('Runners|Tags') }),
+ ],
+};
+</script>
+
+<template>
+ <gl-table-lite
+ :items="jobs"
+ :fields="$options.fields"
+ :tbody-tr-attr="trAttr"
+ primary-key="id"
+ stacked="md"
+ fixed
+ >
+ <template #cell(status)="{ item = {} }">
+ <ci-badge v-if="item.detailedStatus" :status="item.detailedStatus" />
+ </template>
+
+ <template #cell(job)="{ item = {} }">
+ <link-cell :href="jobPath(item)"> #{{ jobId(item) }} </link-cell>
+ </template>
+
+ <template #cell(project)="{ item = {} }">
+ <link-cell :href="projectWebUrl(item)">{{ projectName(item) }}</link-cell>
+ </template>
+
+ <template #cell(commit)="{ item = {} }">
+ <link-cell :href="commitPath(item)"> {{ commitShortSha(item) }}</link-cell>
+ </template>
+
+ <template #cell(tags)="{ item = {} }">
+ <runner-tags :tag-list="item.tags" />
+ </template>
+
+ <template #cell(finished_at)="{ item = {} }">
+ <time-ago v-if="item.finishedAt" :time="item.finishedAt" />
+ </template>
+ </gl-table-lite>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue
index 023308dbac2..bb36882d3ae 100644
--- a/app/assets/javascripts/runner/components/runner_list.vue
+++ b/app/assets/javascripts/runner/components/runner_list.vue
@@ -2,31 +2,14 @@
import { GlTable, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { formatNumber, __, s__ } from '~/locale';
+import { __, s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
-import { RUNNER_JOB_COUNT_LIMIT } from '../constants';
+import { formatJobCount, tableField } from '../utils';
import RunnerActionsCell from './cells/runner_actions_cell.vue';
import RunnerSummaryCell from './cells/runner_summary_cell.vue';
import RunnerStatusCell from './cells/runner_status_cell.vue';
import RunnerTags from './runner_tags.vue';
-const tableField = ({ key, label = '', thClasses = [] }) => {
- return {
- key,
- label,
- thClass: [
- 'gl-bg-transparent!',
- 'gl-border-b-solid!',
- 'gl-border-b-gray-100!',
- 'gl-border-b-1!',
- ...thClasses,
- ],
- tdAttr: {
- 'data-testid': `td-${key}`,
- },
- };
-};
-
export default {
components: {
GlTable,
@@ -54,10 +37,7 @@ export default {
},
methods: {
formatJobCount(jobCount) {
- if (jobCount > RUNNER_JOB_COUNT_LIMIT) {
- return `${formatNumber(RUNNER_JOB_COUNT_LIMIT)}+`;
- }
- return formatNumber(jobCount);
+ return formatJobCount(jobCount);
},
runnerTrAttr(runner) {
if (runner) {
@@ -70,9 +50,9 @@ export default {
},
fields: [
tableField({ key: 'status', label: s__('Runners|Status') }),
- tableField({ key: 'summary', label: s__('Runners|Runner ID'), thClasses: ['gl-lg-w-25p'] }),
+ tableField({ key: 'summary', label: s__('Runners|Runner'), thClasses: ['gl-lg-w-25p'] }),
tableField({ key: 'version', label: __('Version') }),
- tableField({ key: 'ipAddress', label: __('IP Address') }),
+ tableField({ key: 'ipAddress', label: __('IP') }),
tableField({ key: 'jobCount', label: __('Jobs') }),
tableField({ key: 'tagList', label: __('Tags'), thClasses: ['gl-lg-w-25p'] }),
tableField({ key: 'contactedAt', label: __('Last contact') }),
diff --git a/app/assets/javascripts/runner/components/runner_pagination.vue b/app/assets/javascripts/runner/components/runner_pagination.vue
index 8645b90f5cd..b683a7f2330 100644
--- a/app/assets/javascripts/runner/components/runner_pagination.vue
+++ b/app/assets/javascripts/runner/components/runner_pagination.vue
@@ -29,7 +29,14 @@ export default {
},
methods: {
handlePageChange(page) {
- if (page > this.value.page) {
+ if (page === 1) {
+ // Small optimization for first page
+ // If we have loaded using "first",
+ // page is already cached.
+ this.$emit('input', {
+ page,
+ });
+ } else if (page > this.value.page) {
this.$emit('input', {
page,
after: this.pageInfo.endCursor,
@@ -47,11 +54,12 @@ export default {
<template>
<gl-pagination
+ v-bind="$attrs"
:value="value.page"
:prev-page="prevPage"
:next-page="nextPage"
align="center"
- class="gl-pagination gl-mt-3"
+ class="gl-pagination"
@input="handlePageChange"
/>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_pause_button.vue b/app/assets/javascripts/runner/components/runner_pause_button.vue
new file mode 100644
index 00000000000..a8b259f5b90
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_pause_button.vue
@@ -0,0 +1,122 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import runnerToggleActiveMutation from '~/runner/graphql/runner_toggle_active.mutation.graphql';
+import { createAlert } from '~/flash';
+import { captureException } from '~/runner/sentry_utils';
+import { I18N_PAUSE, I18N_RESUME } from '../constants';
+
+export default {
+ name: 'RunnerPauseButton',
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ runner: {
+ type: Object,
+ required: true,
+ },
+ compact: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ updating: false,
+ };
+ },
+ computed: {
+ isActive() {
+ return this.runner.active;
+ },
+ icon() {
+ return this.isActive ? 'pause' : 'play';
+ },
+ label() {
+ return this.isActive ? I18N_PAUSE : I18N_RESUME;
+ },
+ buttonContent() {
+ if (this.compact) {
+ return null;
+ }
+ return this.label;
+ },
+ ariaLabel() {
+ if (this.compact) {
+ return this.label;
+ }
+ return null;
+ },
+ tooltip() {
+ // Only show tooltip when compact.
+ // Also prevent a "sticky" tooltip: If this button is
+ // disabled, mouseout listeners don't run leaving the tooltip stuck
+ if (this.compact && !this.updating) {
+ return this.label;
+ }
+ return '';
+ },
+ },
+ methods: {
+ async onToggle() {
+ this.updating = true;
+ try {
+ const input = {
+ id: this.runner.id,
+ active: !this.isActive,
+ };
+
+ const {
+ data: {
+ runnerUpdate: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: runnerToggleActiveMutation,
+ variables: {
+ input,
+ },
+ });
+
+ if (errors && errors.length) {
+ throw new Error(errors.join(' '));
+ }
+ } catch (e) {
+ this.onError(e);
+ } finally {
+ this.updating = false;
+ }
+ },
+ onError(error) {
+ const { message } = error;
+ createAlert({ message });
+
+ this.reportToSentry(error);
+ },
+ reportToSentry(error) {
+ captureException({ error, component: this.$options.name });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button
+ v-gl-tooltip.hover.viewport="tooltip"
+ v-bind="$attrs"
+ :aria-label="ariaLabel"
+ :icon="icon"
+ :loading="updating"
+ @click="onToggle"
+ v-on="$listeners"
+ >
+ <!--
+ Use <template v-if> to ensure a square button is shown when compact: true.
+ Sending empty content will still show a distorted/rectangular button.
+ -->
+ <template v-if="buttonContent">{{ buttonContent }}</template>
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_projects.vue b/app/assets/javascripts/runner/components/runner_projects.vue
new file mode 100644
index 00000000000..c4065a24ff2
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_projects.vue
@@ -0,0 +1,111 @@
+<script>
+import { GlSkeletonLoading } from '@gitlab/ui';
+import { sprintf, formatNumber } from '~/locale';
+import { createAlert } from '~/flash';
+import getRunnerProjectsQuery from '../graphql/get_runner_projects.query.graphql';
+import {
+ I18N_ASSIGNED_PROJECTS,
+ I18N_NONE,
+ I18N_FETCH_ERROR,
+ RUNNER_DETAILS_PROJECTS_PAGE_SIZE,
+} from '../constants';
+import { getPaginationVariables } from '../utils';
+import { captureException } from '../sentry_utils';
+import RunnerAssignedItem from './runner_assigned_item.vue';
+import RunnerPagination from './runner_pagination.vue';
+
+export default {
+ name: 'RunnerProjects',
+ components: {
+ GlSkeletonLoading,
+ RunnerAssignedItem,
+ RunnerPagination,
+ },
+ props: {
+ runner: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ projects: {
+ items: [],
+ pageInfo: {},
+ count: 0,
+ },
+ pagination: {
+ page: 1,
+ },
+ };
+ },
+ apollo: {
+ projects: {
+ query: getRunnerProjectsQuery,
+ variables() {
+ return this.variables;
+ },
+ update(data) {
+ const { runner } = data;
+ return {
+ count: runner?.projectCount || 0,
+ items: runner?.projects?.nodes || [],
+ pageInfo: runner?.projects?.pageInfo || {},
+ };
+ },
+ error(error) {
+ createAlert({ message: I18N_FETCH_ERROR });
+
+ this.reportToSentry(error);
+ },
+ },
+ },
+ computed: {
+ variables() {
+ const { id } = this.runner;
+ return {
+ id,
+ ...getPaginationVariables(this.pagination, RUNNER_DETAILS_PROJECTS_PAGE_SIZE),
+ };
+ },
+ loading() {
+ return this.$apollo.queries.projects.loading;
+ },
+ heading() {
+ return sprintf(I18N_ASSIGNED_PROJECTS, {
+ projectCount: formatNumber(this.projects.count),
+ });
+ },
+ },
+ methods: {
+ reportToSentry(error) {
+ captureException({ error, component: this.$options.name });
+ },
+ },
+ I18N_NONE,
+};
+</script>
+
+<template>
+ <div class="gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid">
+ <h3 class="gl-font-lg gl-mt-5 gl-mb-0">
+ {{ heading }}
+ </h3>
+
+ <gl-skeleton-loading v-if="loading" class="gl-py-5" />
+ <template v-else-if="projects.items.length">
+ <runner-assigned-item
+ v-for="(project, i) in projects.items"
+ :key="project.id"
+ :class="{ 'gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid': i !== 0 }"
+ :href="project.webUrl"
+ :name="project.name"
+ :full-name="project.nameWithNamespace"
+ :avatar-url="project.avatarUrl"
+ />
+ </template>
+ <span v-else class="gl-text-gray-500">{{ $options.I18N_NONE }}</span>
+
+ <runner-pagination v-model="pagination" :disabled="loading" :page-info="projects.pageInfo" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_tags.vue b/app/assets/javascripts/runner/components/runner_tags.vue
index 8da5e33076f..797d2a35b2c 100644
--- a/app/assets/javascripts/runner/components/runner_tags.vue
+++ b/app/assets/javascripts/runner/components/runner_tags.vue
@@ -20,7 +20,7 @@ export default {
};
</script>
<template>
- <div>
+ <span>
<runner-tag
v-for="tag in tagList"
:key="tag"
@@ -28,5 +28,5 @@ export default {
:tag="tag"
:size="size"
/>
- </div>
+ </span>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_type_tabs.vue b/app/assets/javascripts/runner/components/runner_type_tabs.vue
index b767dafaccf..25ed6600dc9 100644
--- a/app/assets/javascripts/runner/components/runner_type_tabs.vue
+++ b/app/assets/javascripts/runner/components/runner_type_tabs.vue
@@ -1,27 +1,21 @@
<script>
import { GlTabs, GlTab } from '@gitlab/ui';
-import { s__ } from '~/locale';
import { searchValidator } from '~/runner/runner_search_utils';
-import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
+import {
+ INSTANCE_TYPE,
+ GROUP_TYPE,
+ PROJECT_TYPE,
+ I18N_ALL_TYPES,
+ I18N_INSTANCE_TYPE,
+ I18N_GROUP_TYPE,
+ I18N_PROJECT_TYPE,
+} from '../constants';
-const tabs = [
- {
- title: s__('Runners|All'),
- runnerType: null,
- },
- {
- title: s__('Runners|Instance'),
- runnerType: INSTANCE_TYPE,
- },
- {
- title: s__('Runners|Group'),
- runnerType: GROUP_TYPE,
- },
- {
- title: s__('Runners|Project'),
- runnerType: PROJECT_TYPE,
- },
-];
+const I18N_TAB_TITLES = {
+ [INSTANCE_TYPE]: I18N_INSTANCE_TYPE,
+ [GROUP_TYPE]: I18N_GROUP_TYPE,
+ [PROJECT_TYPE]: I18N_PROJECT_TYPE,
+};
export default {
components: {
@@ -29,12 +23,34 @@ export default {
GlTab,
},
props: {
+ runnerTypes: {
+ type: Array,
+ required: false,
+ default: () => [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE],
+ },
value: {
type: Object,
required: true,
validator: searchValidator,
},
},
+ computed: {
+ tabs() {
+ const tabs = this.runnerTypes.map((runnerType) => ({
+ title: I18N_TAB_TITLES[runnerType],
+ runnerType,
+ }));
+
+ // Always add a "All" tab that resets filters
+ return [
+ {
+ title: I18N_ALL_TYPES,
+ runnerType: null,
+ },
+ ...tabs,
+ ];
+ },
+ },
methods: {
onTabSelected({ runnerType }) {
this.$emit('input', {
@@ -47,13 +63,12 @@ export default {
return runnerType === this.value.runnerType;
},
},
- tabs,
};
</script>
<template>
<gl-tabs v-bind="$attrs" data-testid="runner-type-tabs">
<gl-tab
- v-for="tab in $options.tabs"
+ v-for="tab in tabs"
:key="`${tab.runnerType}`"
:active="isTabActive(tab)"
@click="onTabSelected(tab)"
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js
index ce8019ffaa0..1544efaaae2 100644
--- a/app/assets/javascripts/runner/constants.js
+++ b/app/assets/javascripts/runner/constants.js
@@ -1,13 +1,20 @@
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
export const RUNNER_PAGE_SIZE = 20;
export const RUNNER_JOB_COUNT_LIMIT = 1000;
-export const GROUP_RUNNER_COUNT_LIMIT = 1000;
+
+export const RUNNER_DETAILS_PROJECTS_PAGE_SIZE = 5;
+export const RUNNER_DETAILS_JOBS_PAGE_SIZE = 30;
export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.');
export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}');
// Type
+
+export const I18N_ALL_TYPES = s__('Runners|All');
+export const I18N_INSTANCE_TYPE = s__('Runners|Instance');
+export const I18N_GROUP_TYPE = s__('Runners|Group');
+export const I18N_PROJECT_TYPE = s__('Runners|Project');
export const I18N_INSTANCE_RUNNER_DESCRIPTION = s__('Runners|Available to all projects');
export const I18N_GROUP_RUNNER_DESCRIPTION = s__(
'Runners|Available to all projects and subgroups in the group',
@@ -28,9 +35,21 @@ export const I18N_STALE_RUNNER_DESCRIPTION = s__(
'Runners|No contact from this runner in over 3 months',
);
+// Active flag
+export const I18N_PAUSE = __('Pause');
+export const I18N_RESUME = __('Resume');
+
export const I18N_LOCKED_RUNNER_DESCRIPTION = s__('Runners|You cannot assign to other projects');
export const I18N_PAUSED_RUNNER_DESCRIPTION = s__('Runners|Not available to run jobs');
+// Runner details
+
+export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})');
+export const I18N_NONE = __('None');
+export const I18N_NO_JOBS_FOUND = s__('Runner|This runner has not run any jobs.');
+
+// Styles
+
export const RUNNER_TAG_BADGE_VARIANT = 'neutral';
export const RUNNER_TAG_BG_CLASS = 'gl-bg-blue-100';
diff --git a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql b/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql
index f7bcd683718..986dd16b992 100644
--- a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql
@@ -28,10 +28,12 @@ query getGroupRunners(
edges {
webUrl
node {
+ __typename
...RunnerNode
}
}
pageInfo {
+ __typename
...PageInfo
}
}
diff --git a/app/assets/javascripts/runner/graphql/get_runner.query.graphql b/app/assets/javascripts/runner/graphql/get_runner.query.graphql
index 59c55eae060..f6ce8281c64 100644
--- a/app/assets/javascripts/runner/graphql/get_runner.query.graphql
+++ b/app/assets/javascripts/runner/graphql/get_runner.query.graphql
@@ -4,6 +4,7 @@ query getRunner($id: CiRunnerID!) {
# We have an id in deeply nested fragment
# eslint-disable-next-line @graphql-eslint/require-id-when-available
runner(id: $id) {
+ __typename
...RunnerDetails
}
}
diff --git a/app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql b/app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql
new file mode 100644
index 00000000000..2b1decd3ddd
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql
@@ -0,0 +1,36 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+
+query getRunnerJobs($id: CiRunnerID!, $first: Int, $last: Int, $before: String, $after: String) {
+ runner(id: $id) {
+ id
+ projectCount
+ jobs(before: $before, after: $after, first: $first, last: $last) {
+ nodes {
+ id
+ detailedStatus {
+ # fields for `<ci-badge>`
+ id
+ detailsPath
+ group
+ icon
+ text
+ }
+ pipeline {
+ id
+ project {
+ id
+ name
+ webUrl
+ }
+ }
+ shortSha
+ commitPath
+ tags
+ finishedAt
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/runner/graphql/get_runner_projects.query.graphql b/app/assets/javascripts/runner/graphql/get_runner_projects.query.graphql
new file mode 100644
index 00000000000..f97237b8267
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/get_runner_projects.query.graphql
@@ -0,0 +1,26 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+
+query getRunnerProjects(
+ $id: CiRunnerID!
+ $first: Int
+ $last: Int
+ $before: String
+ $after: String
+) {
+ runner(id: $id) {
+ id
+ projectCount
+ projects(first: $first, last: $last, before: $before, after: $after) {
+ nodes {
+ id
+ avatarUrl
+ name
+ nameWithNamespace
+ webUrl
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/runner/graphql/get_runners.query.graphql b/app/assets/javascripts/runner/graphql/get_runners.query.graphql
index 05df399fa6a..ed03a8c34ae 100644
--- a/app/assets/javascripts/runner/graphql/get_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/get_runners.query.graphql
@@ -29,6 +29,7 @@ query getRunners(
editAdminUrl
}
pageInfo {
+ __typename
...PageInfo
}
}
diff --git a/app/assets/javascripts/runner/graphql/runner_actions_update.mutation.graphql b/app/assets/javascripts/runner/graphql/runner_actions_update.mutation.graphql
deleted file mode 100644
index 547cc43907c..00000000000
--- a/app/assets/javascripts/runner/graphql/runner_actions_update.mutation.graphql
+++ /dev/null
@@ -1,14 +0,0 @@
-#import "~/runner/graphql/runner_node.fragment.graphql"
-
-# Mutation for updates within the runners list via action
-# buttons (play, pause, ...), loads attributes shown in the
-# runner list.
-
-mutation runnerActionsUpdate($input: RunnerUpdateInput!) {
- runnerUpdate(input: $input) {
- runner {
- ...RunnerNode
- }
- errors
- }
-}
diff --git a/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql
index 8e968343b9b..74760bbaa07 100644
--- a/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql
@@ -8,7 +8,27 @@ fragment RunnerDetailsShared on CiRunner {
ipAddress
description
maximumTimeout
+ jobCount
tagList
createdAt
status(legacyMode: null)
+ contactedAt
+ version
+ editAdminUrl
+ userPermissions {
+ updateRunner
+ deleteRunner
+ }
+ groups {
+ # Only a single group can be loaded here, while projects
+ # are loaded separately using the query with pagination
+ # parameters `get_runner_projects.query.graphql`.
+ nodes {
+ id
+ avatarUrl
+ name
+ fullName
+ webUrl
+ }
+ }
}
diff --git a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql
index 4a771d779dc..fbdef817f2f 100644
--- a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql
@@ -1,4 +1,5 @@
fragment RunnerNode on CiRunner {
+ __typename
id
description
runnerType
diff --git a/app/assets/javascripts/runner/graphql/runner_toggle_active.mutation.graphql b/app/assets/javascripts/runner/graphql/runner_toggle_active.mutation.graphql
new file mode 100644
index 00000000000..9b15570dbc0
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/runner_toggle_active.mutation.graphql
@@ -0,0 +1,12 @@
+# Mutation executed for the pause/resume button in the
+# runner list and details views.
+
+mutation runnerToggleActive($input: RunnerUpdateInput!) {
+ runnerUpdate(input: $input) {
+ runner {
+ id
+ active
+ }
+ errors
+ }
+}
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 3a7b58e3dc9..c4ee0ad4dfb 100644
--- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
@@ -1,9 +1,9 @@
<script>
-import { GlLink } from '@gitlab/ui';
+import { GlBadge, GlLink } from '@gitlab/ui';
import { createAlert } from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
import { updateHistory } from '~/lib/utils/url_utility';
-import { formatNumber, sprintf, s__ } from '~/locale';
+import { formatNumber } from '~/locale';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
@@ -18,7 +18,7 @@ import {
I18N_FETCH_ERROR,
GROUP_FILTERED_SEARCH_NAMESPACE,
GROUP_TYPE,
- GROUP_RUNNER_COUNT_LIMIT,
+ PROJECT_TYPE,
STATUS_ONLINE,
STATUS_OFFLINE,
STATUS_STALE,
@@ -46,6 +46,7 @@ const runnersCountSmartQuery = {
export default {
name: 'GroupRunnersApp',
components: {
+ GlBadge,
GlLink,
RegistrationDropdown,
RunnerFilteredSearchBar,
@@ -131,6 +132,33 @@ export default {
};
},
},
+ allRunnersCount: {
+ ...runnersCountSmartQuery,
+ variables() {
+ return {
+ ...this.countVariables,
+ type: null,
+ };
+ },
+ },
+ groupRunnersCount: {
+ ...runnersCountSmartQuery,
+ variables() {
+ return {
+ ...this.countVariables,
+ type: GROUP_TYPE,
+ };
+ },
+ },
+ projectRunnersCount: {
+ ...runnersCountSmartQuery,
+ variables() {
+ return {
+ ...this.countVariables,
+ type: PROJECT_TYPE,
+ };
+ },
+ },
},
computed: {
variables() {
@@ -139,23 +167,17 @@ export default {
groupFullPath: this.groupFullPath,
};
},
+ countVariables() {
+ // Exclude pagination variables, leave only filters variables
+ const { sort, before, last, after, first, ...countVariables } = this.variables;
+ return countVariables;
+ },
runnersLoading() {
return this.$apollo.queries.runners.loading;
},
noRunnersFound() {
return !this.runnersLoading && !this.runners.items.length;
},
- groupRunnersCount() {
- if (this.groupRunnersLimitedCount > GROUP_RUNNER_COUNT_LIMIT) {
- return `${formatNumber(GROUP_RUNNER_COUNT_LIMIT)}+`;
- }
- return formatNumber(this.groupRunnersLimitedCount);
- },
- runnerCountMessage() {
- return sprintf(s__('Runners|Runners in this group: %{groupRunnersCount}'), {
- groupRunnersCount: this.groupRunnersCount,
- });
- },
searchTokens() {
return [statusTokenConfig];
},
@@ -179,10 +201,31 @@ 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;
+ },
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
},
+ TABS_RUNNER_TYPES: [GROUP_TYPE, PROJECT_TYPE],
GROUP_TYPE,
};
</script>
@@ -198,9 +241,17 @@ export default {
<div class="gl-display-flex gl-align-items-center">
<runner-type-tabs
v-model="search"
+ :runner-types="$options.TABS_RUNNER_TYPES"
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"
diff --git a/app/assets/javascripts/runner/runner_search_utils.js b/app/assets/javascripts/runner/runner_search_utils.js
index c80a73948b8..fe141332be3 100644
--- a/app/assets/javascripts/runner/runner_search_utils.js
+++ b/app/assets/javascripts/runner/runner_search_utils.js
@@ -18,6 +18,7 @@ import {
RUNNER_PAGE_SIZE,
STATUS_NEVER_CONTACTED,
} from './constants';
+import { getPaginationVariables } from './utils';
/**
* The filters and sorting of the runners are built around
@@ -184,30 +185,27 @@ export const fromSearchToVariables = ({
sort = null,
pagination = {},
} = {}) => {
- const variables = {};
+ const filterVariables = {};
const queryObj = filterToQueryObject(processFilters(filters), {
filteredSearchTermKey: PARAM_KEY_SEARCH,
});
- [variables.status] = queryObj[PARAM_KEY_STATUS] || [];
- variables.search = queryObj[PARAM_KEY_SEARCH];
- variables.tagList = queryObj[PARAM_KEY_TAG];
+ [filterVariables.status] = queryObj[PARAM_KEY_STATUS] || [];
+ filterVariables.search = queryObj[PARAM_KEY_SEARCH];
+ filterVariables.tagList = queryObj[PARAM_KEY_TAG];
if (runnerType) {
- variables.type = runnerType;
+ filterVariables.type = runnerType;
}
if (sort) {
- variables.sort = sort;
+ filterVariables.sort = sort;
}
- if (pagination.before) {
- variables.before = pagination.before;
- variables.last = RUNNER_PAGE_SIZE;
- } else {
- variables.after = pagination.after;
- variables.first = RUNNER_PAGE_SIZE;
- }
+ const paginationVariables = getPaginationVariables(pagination, RUNNER_PAGE_SIZE);
- return variables;
+ return {
+ ...filterVariables,
+ ...paginationVariables,
+ };
};
diff --git a/app/assets/javascripts/runner/utils.js b/app/assets/javascripts/runner/utils.js
new file mode 100644
index 00000000000..6e4c8c45e7b
--- /dev/null
+++ b/app/assets/javascripts/runner/utils.js
@@ -0,0 +1,72 @@
+import { formatNumber } from '~/locale';
+import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants';
+import { RUNNER_JOB_COUNT_LIMIT } from './constants';
+
+/**
+ * Formats a job count, limited to a max number
+ *
+ * @param {Number} jobCount
+ * @returns Formatted string
+ */
+export const formatJobCount = (jobCount) => {
+ if (typeof jobCount !== 'number') {
+ return '';
+ }
+ if (jobCount > RUNNER_JOB_COUNT_LIMIT) {
+ return `${formatNumber(RUNNER_JOB_COUNT_LIMIT)}+`;
+ }
+ return formatNumber(jobCount);
+};
+
+/**
+ * Returns a GlTable fields with a given key and label
+ *
+ * @param {Object} options
+ * @returns Field object to add to GlTable fields
+ */
+export const tableField = ({ key, label = '', thClasses = [] }) => {
+ return {
+ key,
+ label,
+ thClass: [DEFAULT_TH_CLASSES, ...thClasses],
+ tdAttr: {
+ 'data-testid': `td-${key}`,
+ },
+ };
+};
+
+/**
+ * Returns variables for a GraphQL query that uses keyset
+ * pagination.
+ *
+ * https://docs.gitlab.com/ee/development/graphql_guide/pagination.html#keyset-pagination
+ *
+ * @param {Object} pagination - Contains before, after, page
+ * @param {Number} pageSize
+ * @returns Variables
+ */
+export const getPaginationVariables = (pagination, pageSize = 10) => {
+ const { before, after } = pagination;
+
+ // first + after: Next page
+ // Get the first N items after item X
+ if (after) {
+ return {
+ after,
+ first: pageSize,
+ };
+ }
+
+ // last + before: Prev page
+ // Get the first N items before item X, when you click on Prev
+ if (before) {
+ return {
+ before,
+ last: pageSize,
+ };
+ }
+
+ // first page
+ // Get the first N items
+ return { first: pageSize };
+};
diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue
index d228f77f27d..c48c9067250 100644
--- a/app/assets/javascripts/security_configuration/components/app.vue
+++ b/app/assets/javascripts/security_configuration/components/app.vue
@@ -50,7 +50,7 @@ export default {
TrainingProviderList,
},
mixins: [glFeatureFlagsMixin()],
- inject: ['projectPath'],
+ inject: ['projectFullPath'],
props: {
augmentedSecurityFeatures: {
type: Array,
@@ -107,14 +107,14 @@ export default {
shouldShowAutoDevopsEnabledAlert() {
return (
this.autoDevopsEnabled &&
- !this.autoDevopsEnabledAlertDismissedProjects.includes(this.projectPath)
+ !this.autoDevopsEnabledAlertDismissedProjects.includes(this.projectFullPath)
);
},
},
methods: {
dismissAutoDevopsEnabledAlert() {
const dismissedProjects = new Set(this.autoDevopsEnabledAlertDismissedProjects);
- dismissedProjects.add(this.projectPath);
+ dismissedProjects.add(this.projectFullPath);
this.autoDevopsEnabledAlertDismissedProjects = Array.from(dismissedProjects);
},
onError(message) {
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index 034dba29196..81d222438e3 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -123,7 +123,7 @@ export const COVERAGE_FUZZING_CONFIG_HELP_PATH = helpPagePath(
export const CORPUS_MANAGEMENT_NAME = __('Corpus Management');
export const CORPUS_MANAGEMENT_DESCRIPTION = s__(
- 'SecurityConfiguration|Manage corpus files used as mutation sources in coverage fuzzing.',
+ 'SecurityConfiguration|Manage corpus files used as seed inputs with coverage-guided fuzzing.',
);
export const CORPUS_MANAGEMENT_CONFIG_TEXT = s__('SecurityConfiguration|Manage corpus');
@@ -159,15 +159,6 @@ export const securityFeatures = [
helpPath: SAST_HELP_PATH,
configurationHelpPath: SAST_CONFIG_HELP_PATH,
type: REPORT_TYPE_SAST,
- // This field is currently hardcoded because SAST is always available.
- // It will eventually come from the Backend, the progress is tracked in
- // https://gitlab.com/gitlab-org/gitlab/-/issues/331622
- available: true,
-
- // This field is currently hardcoded because SAST can always be enabled via MR
- // It will eventually come from the Backend, the progress is tracked in
- // https://gitlab.com/gitlab-org/gitlab/-/issues/331621
- canEnableByMergeRequest: true,
},
{
name: SAST_IAC_NAME,
@@ -176,15 +167,6 @@ export const securityFeatures = [
helpPath: SAST_IAC_HELP_PATH,
configurationHelpPath: SAST_IAC_CONFIG_HELP_PATH,
type: REPORT_TYPE_SAST_IAC,
-
- // This field is currently hardcoded because SAST IaC is always available.
- // It will eventually come from the Backend, the progress is tracked in
- // https://gitlab.com/gitlab-org/gitlab/-/issues/331622
- available: true,
-
- // This field will eventually come from the backend, the progress is
- // tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/331621
- canEnableByMergeRequest: true,
},
{
name: DAST_NAME,
@@ -206,10 +188,6 @@ export const securityFeatures = [
helpPath: DEPENDENCY_SCANNING_HELP_PATH,
configurationHelpPath: DEPENDENCY_SCANNING_CONFIG_HELP_PATH,
type: REPORT_TYPE_DEPENDENCY_SCANNING,
-
- // This field will eventually come from the backend, the progress is
- // tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/331621
- canEnableByMergeRequest: true,
},
{
name: CONTAINER_SCANNING_NAME,
@@ -231,16 +209,6 @@ export const securityFeatures = [
helpPath: SECRET_DETECTION_HELP_PATH,
configurationHelpPath: SECRET_DETECTION_CONFIG_HELP_PATH,
type: REPORT_TYPE_SECRET_DETECTION,
-
- // This field is currently hardcoded because Secret Detection is always
- // available. It will eventually come from the Backend, the progress is
- // tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/333113
- available: true,
-
- // This field is currently hardcoded because SAST can always be enabled via MR
- // It will eventually come from the Backend, the progress is tracked in
- // https://gitlab.com/gitlab-org/gitlab/-/issues/331621
- canEnableByMergeRequest: true,
},
{
name: API_FUZZING_NAME,
diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue
index 33d72b54f86..1c37d8008de 100644
--- a/app/assets/javascripts/security_configuration/components/feature_card.vue
+++ b/app/assets/javascripts/security_configuration/components/feature_card.vue
@@ -24,9 +24,6 @@ export default {
enabled() {
return this.available && this.feature.configured;
},
- hasStatus() {
- return !this.available || typeof this.feature.configured === 'boolean';
- },
shortName() {
return this.feature.shortName ?? this.feature.name;
},
@@ -93,19 +90,17 @@ export default {
data-testid="feature-status"
:data-qa-selector="`${feature.type}_status`"
>
- <template v-if="hasStatus">
- <template v-if="enabled">
- <gl-icon name="check-circle-filled" />
- <span class="gl-text-green-700">{{ $options.i18n.enabled }}</span>
- </template>
+ <template v-if="enabled">
+ <gl-icon name="check-circle-filled" />
+ <span class="gl-text-green-700">{{ $options.i18n.enabled }}</span>
+ </template>
- <template v-else-if="available">
- {{ $options.i18n.notEnabled }}
- </template>
+ <template v-else-if="available">
+ {{ $options.i18n.notEnabled }}
+ </template>
- <template v-else>
- {{ $options.i18n.availableWith }}
- </template>
+ <template v-else>
+ {{ $options.i18n.availableWith }}
</template>
</div>
</div>
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 ca4596e16b3..539e2bff17c 100644
--- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue
+++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue
@@ -1,6 +1,13 @@
<script>
import { GlAlert, GlCard, GlToggle, GlLink, GlSkeletonLoader } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import Tracking from '~/tracking';
import { __ } from '~/locale';
+import {
+ TRACK_TOGGLE_TRAINING_PROVIDER_ACTION,
+ TRACK_TOGGLE_TRAINING_PROVIDER_LABEL,
+} from '~/security_configuration/constants';
+import dismissUserCalloutMutation from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql';
import securityTrainingProvidersQuery from '../graphql/security_training_providers.query.graphql';
import configureSecurityTrainingProvidersMutation from '../graphql/configure_security_training_providers.mutation.graphql';
@@ -21,10 +28,19 @@ export default {
GlLink,
GlSkeletonLoader,
},
- inject: ['projectPath'],
+ mixins: [Tracking.mixin()],
+ inject: ['projectFullPath'],
apollo: {
securityTrainingProviders: {
query: securityTrainingProvidersQuery,
+ variables() {
+ return {
+ fullPath: this.projectFullPath,
+ };
+ },
+ update({ project }) {
+ return project?.securityTrainingProviders;
+ },
error() {
this.errorMessage = this.$options.i18n.providerQueryErrorMessage;
},
@@ -33,8 +49,9 @@ export default {
data() {
return {
errorMessage: '',
- toggleLoading: false,
+ providerLoadingId: null,
securityTrainingProviders: [],
+ hasTouchedConfiguration: false,
};
},
computed: {
@@ -42,33 +59,59 @@ export default {
return this.$apollo.queries.securityTrainingProviders.loading;
},
},
+ created() {
+ const unwatchConfigChance = this.$watch('hasTouchedConfiguration', () => {
+ this.dismissFeaturePromotionCallout();
+ unwatchConfigChance();
+ });
+ },
methods: {
- toggleProvider(selectedProviderId) {
- const toggledProviders = this.securityTrainingProviders.map((provider) => ({
- ...provider,
- ...(provider.id === selectedProviderId && { isEnabled: !provider.isEnabled }),
- }));
+ async dismissFeaturePromotionCallout() {
+ try {
+ const {
+ data: {
+ userCalloutCreate: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: dismissUserCalloutMutation,
+ variables: {
+ input: {
+ featureName: 'security_training_feature_promotion',
+ },
+ },
+ });
- const enabledProviderIds = toggledProviders
- .filter(({ isEnabled }) => isEnabled)
- .map(({ id }) => id);
+ // handle errors reported from the backend
+ if (errors?.length > 0) {
+ throw new Error(errors[0]);
+ }
+ } catch (e) {
+ Sentry.captureException(e);
+ }
+ },
+ toggleProvider(provider) {
+ const { isEnabled } = provider;
+ const toggledIsEnabled = !isEnabled;
- this.storeEnabledProviders(toggledProviders, enabledProviderIds);
+ this.trackProviderToggle(provider.id, toggledIsEnabled);
+ this.storeProvider({ ...provider, isEnabled: toggledIsEnabled });
},
- async storeEnabledProviders(toggledProviders, enabledProviderIds) {
- this.toggleLoading = true;
+ async storeProvider({ id, isEnabled, isPrimary }) {
+ this.providerLoadingId = id;
try {
const {
data: {
- configureSecurityTrainingProviders: { errors = [] },
+ securityTrainingUpdate: { errors = [] },
},
} = await this.$apollo.mutate({
mutation: configureSecurityTrainingProvidersMutation,
variables: {
input: {
- enabledProviders: enabledProviderIds,
- fullPath: this.projectPath,
+ projectPath: this.projectFullPath,
+ providerId: id,
+ isEnabled,
+ isPrimary,
},
},
});
@@ -77,12 +120,23 @@ export default {
// throwing an error here means we can handle scenarios within the `catch` block below
throw new Error();
}
+
+ this.hasTouchedConfiguration = true;
} catch {
this.errorMessage = this.$options.i18n.configMutationErrorMessage;
} finally {
- this.toggleLoading = false;
+ this.providerLoadingId = null;
}
},
+ trackProviderToggle(providerId, providerIsEnabled) {
+ this.track(TRACK_TOGGLE_TRAINING_PROVIDER_ACTION, {
+ label: TRACK_TOGGLE_TRAINING_PROVIDER_LABEL,
+ property: providerId,
+ extra: {
+ providerIsEnabled,
+ },
+ });
+ },
},
i18n,
};
@@ -104,25 +158,21 @@ export default {
</gl-skeleton-loader>
</div>
<ul v-else class="gl-list-style-none gl-m-0 gl-p-0">
- <li
- v-for="{ id, isEnabled, name, description, url } in securityTrainingProviders"
- :key="id"
- class="gl-mb-6"
- >
+ <li v-for="provider in securityTrainingProviders" :key="provider.id" class="gl-mb-6">
<gl-card>
<div class="gl-display-flex">
<gl-toggle
- :value="isEnabled"
+ :value="provider.isEnabled"
:label="__('Training mode')"
label-position="hidden"
- :is-loading="toggleLoading"
- @change="toggleProvider(id)"
+ :is-loading="providerLoadingId === provider.id"
+ @change="toggleProvider(provider)"
/>
<div class="gl-ml-5">
- <h3 class="gl-font-lg gl-m-0 gl-mb-2">{{ name }}</h3>
+ <h3 class="gl-font-lg gl-m-0 gl-mb-2">{{ provider.name }}</h3>
<p>
- {{ description }}
- <gl-link :href="url" target="_blank">{{ __('Learn more.') }}</gl-link>
+ {{ provider.description }}
+ <gl-link :href="provider.url" target="_blank">{{ __('Learn more.') }}</gl-link>
</p>
</div>
</div>
diff --git a/app/assets/javascripts/security_configuration/components/upgrade_banner.vue b/app/assets/javascripts/security_configuration/components/upgrade_banner.vue
index 79e6b9d7a23..891d7bf2eb0 100644
--- a/app/assets/javascripts/security_configuration/components/upgrade_banner.vue
+++ b/app/assets/javascripts/security_configuration/components/upgrade_banner.vue
@@ -1,11 +1,16 @@
<script>
import { GlBanner } from '@gitlab/ui';
import { s__ } from '~/locale';
+import Tracking from '~/tracking';
+
+export const SECURITY_UPGRADE_BANNER = 'security_upgrade_banner';
+export const UPGRADE_OR_FREE_TRIAL = 'upgrade_or_free_trial';
export default {
components: {
GlBanner,
},
+ mixins: [Tracking.mixin({ property: SECURITY_UPGRADE_BANNER })],
inject: ['upgradePath'],
i18n: {
title: s__('SecurityConfiguration|Secure your project'),
@@ -22,6 +27,17 @@ export default {
],
buttonText: s__('SecurityConfiguration|Upgrade or start a free trial'),
},
+ mounted() {
+ this.track('render', { label: SECURITY_UPGRADE_BANNER });
+ },
+ methods: {
+ bannerClosed() {
+ this.track('dismiss_banner', { label: SECURITY_UPGRADE_BANNER });
+ },
+ bannerButtonClicked() {
+ this.track('click_button', { label: UPGRADE_OR_FREE_TRIAL });
+ },
+ },
};
</script>
@@ -31,6 +47,8 @@ export default {
:button-text="$options.i18n.buttonText"
:button-link="upgradePath"
variant="introduction"
+ @close="bannerClosed"
+ @primary="bannerButtonClicked"
v-on="$listeners"
>
<p>{{ $options.i18n.bodyStart }}</p>
diff --git a/app/assets/javascripts/security_configuration/constants.js b/app/assets/javascripts/security_configuration/constants.js
new file mode 100644
index 00000000000..dc76436e91d
--- /dev/null
+++ b/app/assets/javascripts/security_configuration/constants.js
@@ -0,0 +1,2 @@
+export const TRACK_TOGGLE_TRAINING_PROVIDER_ACTION = 'toggle_security_training_provider';
+export const TRACK_TOGGLE_TRAINING_PROVIDER_LABEL = 'update_security_training_provider';
diff --git a/app/assets/javascripts/security_configuration/graphql/configure_security_training_providers.mutation.graphql b/app/assets/javascripts/security_configuration/graphql/configure_security_training_providers.mutation.graphql
index 660e0fadafb..3528bfaf7b8 100644
--- a/app/assets/javascripts/security_configuration/graphql/configure_security_training_providers.mutation.graphql
+++ b/app/assets/javascripts/security_configuration/graphql/configure_security_training_providers.mutation.graphql
@@ -1,9 +1,10 @@
-mutation configureSecurityTrainingProviders($input: configureSecurityTrainingProvidersInput!) {
- configureSecurityTrainingProviders(input: $input) @client {
+mutation updateSecurityTraining($input: SecurityTrainingUpdateInput!) {
+ securityTrainingUpdate(input: $input) {
errors
- securityTrainingProviders {
+ training {
id
isEnabled
+ isPrimary
}
}
}
diff --git a/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql b/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql
index e0c5715ba8e..2baeda318f3 100644
--- a/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql
+++ b/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql
@@ -1,9 +1,13 @@
-query Query {
- securityTrainingProviders @client {
- name
+query getSecurityTrainingProviders($fullPath: ID!) {
+ project(fullPath: $fullPath) {
id
- description
- isEnabled
- url
+ securityTrainingProviders {
+ name
+ id
+ description
+ isPrimary
+ isEnabled
+ url
+ }
}
}
diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js
index 24c0585e077..8416692dd27 100644
--- a/app/assets/javascripts/security_configuration/index.js
+++ b/app/assets/javascripts/security_configuration/index.js
@@ -5,7 +5,6 @@ import { parseBooleanDataAttributes } from '~/lib/utils/dom_utils';
import SecurityConfigurationApp from './components/app.vue';
import { securityFeatures, complianceFeatures } from './components/constants';
import { augmentFeatures } from './utils';
-import tempResolvers from './resolver';
export const initSecurityConfiguration = (el) => {
if (!el) {
@@ -15,11 +14,11 @@ export const initSecurityConfiguration = (el) => {
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(tempResolvers),
+ defaultClient: createDefaultClient(),
});
const {
- projectPath,
+ projectFullPath,
upgradePath,
features,
latestPipelinePath,
@@ -38,7 +37,7 @@ export const initSecurityConfiguration = (el) => {
el,
apolloProvider,
provide: {
- projectPath,
+ projectFullPath,
upgradePath,
autoDevopsHelpPagePath,
autoDevopsPath,
diff --git a/app/assets/javascripts/security_configuration/resolver.js b/app/assets/javascripts/security_configuration/resolver.js
deleted file mode 100644
index 93175d4a3d1..00000000000
--- a/app/assets/javascripts/security_configuration/resolver.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import produce from 'immer';
-import { __ } from '~/locale';
-import securityTrainingProvidersQuery from './graphql/security_training_providers.query.graphql';
-
-// Note: this is behind a feature flag and only a placeholder
-// until the actual GraphQL fields have been added
-// https://gitlab.com/gitlab-org/gi tlab/-/issues/346480
-export default {
- Query: {
- securityTrainingProviders() {
- return [
- {
- __typename: 'SecurityTrainingProvider',
- id: 101,
- name: __('Kontra'),
- description: __('Interactive developer security education.'),
- url: 'https://application.security/',
- isEnabled: false,
- },
- {
- __typename: 'SecurityTrainingProvider',
- id: 102,
- name: __('SecureCodeWarrior'),
- description: __('Security training with guide and learning pathways.'),
- url: 'https://www.securecodewarrior.com/',
- isEnabled: true,
- },
- ];
- },
- },
-
- Mutation: {
- configureSecurityTrainingProviders: (
- _,
- { input: { enabledProviders, primaryProvider } },
- { cache },
- ) => {
- const sourceData = cache.readQuery({
- query: securityTrainingProvidersQuery,
- });
-
- const data = produce(sourceData.securityTrainingProviders, (draftData) => {
- /* eslint-disable no-param-reassign */
- draftData.forEach((provider) => {
- provider.isPrimary = provider.id === primaryProvider;
- provider.isEnabled =
- provider.id === primaryProvider || enabledProviders.includes(provider.id);
- });
- });
- return {
- __typename: 'configureSecurityTrainingProvidersPayload',
- securityTrainingProviders: data,
- };
- },
- },
-};
diff --git a/app/assets/javascripts/security_configuration/utils.js b/app/assets/javascripts/security_configuration/utils.js
index 47231497b8f..173560f8370 100644
--- a/app/assets/javascripts/security_configuration/utils.js
+++ b/app/assets/javascripts/security_configuration/utils.js
@@ -1,6 +1,19 @@
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { SCANNER_NAMES_MAP } from '~/security_configuration/components/constants';
+/**
+ * This function takes in 3 arrays of objects, securityFeatures, complianceFeatures and features.
+ * securityFeatures and complianceFeatures are static arrays living in the constants.
+ * features is dynamic and coming from the backend.
+ * This function builds a superset of those arrays.
+ * It looks for matching keys within the dynamic and the static arrays
+ * and will enrich the objects with the available static data.
+ * @param [{}] securityFeatures
+ * @param [{}] complianceFeatures
+ * @param [{}] features
+ * @returns {Object} Object with enriched features from constants divided into Security and Compliance Features
+ */
+
export const augmentFeatures = (securityFeatures, complianceFeatures, features = []) => {
const featuresByType = features.reduce((acc, feature) => {
acc[feature.type] = convertObjectPropsToCamelCase(feature, { deep: true });
diff --git a/app/assets/javascripts/serverless/components/empty_state.vue b/app/assets/javascripts/serverless/components/empty_state.vue
index 8a5ed9debb3..6d1cea519c4 100644
--- a/app/assets/javascripts/serverless/components/empty_state.vue
+++ b/app/assets/javascripts/serverless/components/empty_state.vue
@@ -1,6 +1,8 @@
<script>
import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import { mapState } from 'vuex';
+import { s__ } from '~/locale';
+import { DEPRECATION_POST_LINK } from '../constants';
export default {
components: {
@@ -8,6 +10,13 @@ export default {
GlLink,
GlSprintf,
},
+ i18n: {
+ title: s__('Serverless|Getting started with serverless'),
+ description: s__(
+ 'Serverless|Serverless was %{postLinkStart}deprecated%{postLinkEnd}. But if you opt to use it, you must install Knative in your Kubernetes cluster first. %{linkStart}Learn more.%{linkEnd}',
+ ),
+ },
+ deprecationPostLink: DEPRECATION_POST_LINK,
computed: {
...mapState(['emptyImagePath', 'helpPath']),
},
@@ -15,18 +24,12 @@ export default {
</script>
<template>
- <gl-empty-state
- :svg-path="emptyImagePath"
- :title="s__('Serverless|Getting started with serverless')"
- >
+ <gl-empty-state :svg-path="emptyImagePath" :title="$options.i18n.title">
<template #description>
- <gl-sprintf
- :message="
- s__(
- 'Serverless|In order to start using functions as a service, you must first install Knative on your Kubernetes cluster. %{linkStart}More information%{linkEnd}',
- )
- "
- >
+ <gl-sprintf :message="$options.i18n.description">
+ <template #postLink="{ content }">
+ <gl-link :href="$options.deprecationPostLink" target="_blank">{{ content }}</gl-link>
+ </template>
<template #link="{ content }">
<gl-link :href="helpPath">{{ content }}</gl-link>
</template>
diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue
index b2d7aa75051..e9461aa3ead 100644
--- a/app/assets/javascripts/serverless/components/functions.vue
+++ b/app/assets/javascripts/serverless/components/functions.vue
@@ -1,8 +1,14 @@
<script>
-import { GlLink, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import {
+ GlLink,
+ GlAlert,
+ GlSprintf,
+ GlLoadingIcon,
+ GlSafeHtmlDirective as SafeHtml,
+} from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import { sprintf, s__ } from '~/locale';
-import { CHECKING_INSTALLED } from '../constants';
+import { CHECKING_INSTALLED, DEPRECATION_POST_LINK } from '../constants';
import EmptyState from './empty_state.vue';
import EnvironmentRow from './environment_row.vue';
@@ -11,11 +17,14 @@ export default {
EnvironmentRow,
EmptyState,
GlLink,
+ GlAlert,
+ GlSprintf,
GlLoadingIcon,
},
directives: {
SafeHtml,
},
+ deprecationPostLink: DEPRECATION_POST_LINK,
computed: {
...mapState(['installed', 'isLoading', 'hasFunctionData', 'helpPath', 'statusPath']),
...mapGetters(['getFunctions']),
@@ -65,6 +74,17 @@ export default {
<template>
<section id="serverless-functions" class="flex-grow">
+ <gl-alert class="gl-mt-6" variant="warning" :dismissible="false">
+ <gl-sprintf
+ :message="s__('Serverless|Serverless was %{linkStart}deprecated%{linkEnd} in GitLab 14.3.')"
+ ><template #link="{ content }"
+ ><gl-link :href="$options.deprecationPostLink" target="_blank">{{
+ content
+ }}</gl-link></template
+ ></gl-sprintf
+ >
+ </gl-alert>
+
<gl-loading-icon v-if="checkingInstalled" size="lg" class="gl-mt-3 gl-mb-3" />
<div v-else-if="isInstalled">
diff --git a/app/assets/javascripts/serverless/constants.js b/app/assets/javascripts/serverless/constants.js
index 2fa15e56ccb..42c9ee983b4 100644
--- a/app/assets/javascripts/serverless/constants.js
+++ b/app/assets/javascripts/serverless/constants.js
@@ -5,3 +5,6 @@ export const X_INTERVAL = 5; // Reflects the number of verticle bars on the x-ax
export const CHECKING_INSTALLED = 'checking'; // The backend is still determining whether or not Knative is installed
export const TIMEOUT = 'timeout';
+
+export const DEPRECATION_POST_LINK =
+ 'https://about.gitlab.com/releases/2021/09/22/gitlab-14-3-released/#gitlab-serverless';
diff --git a/app/assets/javascripts/serverless/survey_banner.js b/app/assets/javascripts/serverless/survey_banner.js
deleted file mode 100644
index 070e8f4c661..00000000000
--- a/app/assets/javascripts/serverless/survey_banner.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import Vue from 'vue';
-import { setUrlParams } from '~/lib/utils/url_utility';
-import SurveyBanner from './survey_banner.vue';
-
-let bannerInstance;
-const SURVEY_URL_BASE = 'https://gitlab.fra1.qualtrics.com/jfe/form/SV_00PfofFfY9s8Shf';
-
-export default function initServerlessSurveyBanner() {
- const el = document.querySelector('.js-serverless-survey-banner');
- if (el && !bannerInstance) {
- const { userName, userEmail } = el.dataset;
-
- // pre-populate survey fields
- const surveyUrl = setUrlParams(
- {
- Q_PopulateResponse: JSON.stringify({
- QID1: userEmail,
- QID2: userName,
- QID16: '1', // selects "yes" to "do you currently use GitLab?"
- }),
- },
- SURVEY_URL_BASE,
- );
-
- bannerInstance = new Vue({
- el,
- render(createElement) {
- return createElement(SurveyBanner, {
- props: {
- surveyUrl,
- },
- });
- },
- });
- }
-}
diff --git a/app/assets/javascripts/serverless/survey_banner.vue b/app/assets/javascripts/serverless/survey_banner.vue
deleted file mode 100644
index c48c294c0f7..00000000000
--- a/app/assets/javascripts/serverless/survey_banner.vue
+++ /dev/null
@@ -1,52 +0,0 @@
-<script>
-import { GlBanner } from '@gitlab/ui';
-import Cookies from 'js-cookie';
-import { parseBoolean } from '~/lib/utils/common_utils';
-
-export default {
- components: {
- GlBanner,
- },
- props: {
- surveyUrl: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- visible: true,
- };
- },
- created() {
- if (parseBoolean(Cookies.get('hide_serverless_survey'))) {
- this.visible = false;
- }
- },
- methods: {
- handleClose() {
- Cookies.set('hide_serverless_survey', 'true', { expires: 365 * 10 });
- this.visible = false;
- },
- },
-};
-</script>
-
-<template>
- <gl-banner
- v-if="visible"
- class="mt-4"
- :title="s__('Serverless|Help shape the future of Serverless at GitLab')"
- :button-text="s__('Serverless|Sign up for First Look')"
- :button-link="surveyUrl"
- @close="handleClose"
- >
- <p>
- {{
- s__(
- 'Serverless|We are continually striving to improve our Serverless functionality. As a Knative user, we would love to hear how we can make this experience better for you. Sign up for GitLab First Look today and we will be in touch shortly.',
- )
- }}
- </p>
- </gl-banner>
-</template>
diff --git a/app/assets/javascripts/settings_panels.js b/app/assets/javascripts/settings_panels.js
index 2c6da5669ef..fe5b21713a2 100644
--- a/app/assets/javascripts/settings_panels.js
+++ b/app/assets/javascripts/settings_panels.js
@@ -18,8 +18,6 @@ export function expandSection(sectionArg) {
const $section = $(sectionArg);
$section.find('.js-settings-toggle:not(.js-settings-toggle-trigger-only)').text(__('Collapse'));
- // eslint-disable-next-line @gitlab/no-global-event-off
- $section.find('.settings-content').off('scroll.expandSection').scrollTop(0);
$section.addClass('expanded');
if (!$section.hasClass('no-animate')) {
$section
@@ -32,7 +30,6 @@ export function closeSection(sectionArg) {
const $section = $(sectionArg);
$section.find('.js-settings-toggle:not(.js-settings-toggle-trigger-only)').text(__('Expand'));
- $section.find('.settings-content').on('scroll.expandSection', () => expandSection($section));
$section.removeClass('expanded');
if (!$section.hasClass('no-animate')) {
$section
@@ -55,18 +52,16 @@ export default function initSettingsPanels() {
const $section = $(elm);
$section.on('click.toggleSection', '.js-settings-toggle', () => toggleSection($section));
- if (!isExpanded($section)) {
- $section.find('.settings-content').on('scroll.expandSection', () => {
- $section.removeClass('no-animate');
+ if (window.location.hash) {
+ const $target = $(window.location.hash);
+ if (
+ $target.length &&
+ !isExpanded($section) &&
+ ($section.is($target) || $section.find($target).length)
+ ) {
+ $section.addClass('no-animate');
expandSection($section);
- });
+ }
}
});
-
- if (window.location.hash) {
- const $target = $(window.location.hash);
- if ($target.length && $target.hasClass('settings')) {
- expandSection($target);
- }
- }
}
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
index 5b4dc20e9c8..18654b73ab3 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
@@ -96,6 +96,9 @@ export default {
return data.workspace?.issuable;
},
result({ data }) {
+ if (!data) {
+ return;
+ }
const issuable = data.workspace?.issuable;
if (issuable) {
this.selected = cloneDeep(issuable.assignees.nodes);
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
index dc0f2b54a7b..f234c5ea3c9 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
@@ -66,6 +66,9 @@ export default {
return data.workspace?.issuable?.confidential || false;
},
result({ data }) {
+ if (!data) {
+ return;
+ }
this.$emit('confidentialityUpdated', data.workspace?.issuable?.confidential);
},
error() {
diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
index 404bcc3122a..be7a89c2869 100644
--- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
+++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
@@ -86,6 +86,9 @@ export default {
return data.workspace?.issuable || {};
},
result({ data }) {
+ if (!data) {
+ return;
+ }
this.$emit(`${this.dateType}Updated`, data.workspace?.issuable?.[this.dateType]);
},
error() {
diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
index da792b3a2aa..ec23e817127 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
@@ -10,6 +10,7 @@ import {
GlIcon,
GlTooltipDirective,
} from '@gitlab/ui';
+import { kebabCase, snakeCase } from 'lodash';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { IssuableType } from '~/issues/constants';
@@ -221,6 +222,12 @@ export default {
// MV to EE https://gitlab.com/gitlab-org/gitlab/-/issues/345311
return this.issuableAttribute === IssuableType.Epic;
},
+ formatIssuableAttribute() {
+ return {
+ kebab: kebabCase(this.issuableAttribute),
+ snake: snakeCase(this.issuableAttribute),
+ };
+ },
},
methods: {
updateAttribute(attributeId) {
@@ -300,21 +307,28 @@ export default {
<sidebar-editable-item
ref="editable"
:title="attributeTypeTitle"
- :data-testid="`${issuableAttribute}-edit`"
+ :data-testid="`${formatIssuableAttribute.kebab}-edit`"
:tracking="tracking"
:loading="updating || loading"
@open="handleOpen"
@close="handleClose"
>
<template #collapsed>
- <div v-if="isClassicSidebar" v-gl-tooltip class="sidebar-collapsed-icon">
- <gl-icon :size="16" :aria-label="attributeTypeTitle" :name="attributeTypeIcon" />
- <span class="collapse-truncated-title">
- {{ attributeTitle }}
- </span>
- </div>
+ <slot name="value-collapsed" :current-attribute="currentAttribute">
+ <div
+ v-if="isClassicSidebar"
+ v-gl-tooltip.left.viewport
+ :title="attributeTypeTitle"
+ class="sidebar-collapsed-icon"
+ >
+ <gl-icon :aria-label="attributeTypeTitle" :name="attributeTypeIcon" />
+ <span class="collapse-truncated-title">
+ {{ attributeTitle }}
+ </span>
+ </div>
+ </slot>
<div
- :data-testid="`select-${issuableAttribute}`"
+ :data-testid="`select-${formatIssuableAttribute.kebab}`"
:class="isClassicSidebar ? 'hide-collapsed' : 'gl-mt-3'"
>
<span v-if="updating" class="gl-font-weight-bold">{{ selectedTitle }}</span>
@@ -332,7 +346,7 @@ export default {
v-gl-tooltip="tooltipText"
class="gl-text-gray-900! gl-font-weight-bold"
:href="attributeUrl"
- :data-qa-selector="`${issuableAttribute}_link`"
+ :data-qa-selector="`${formatIssuableAttribute.snake}_link`"
>
{{ attributeTitle }}
<span v-if="isAttributeOverdue(currentAttribute)">{{ $options.i18n.expired }}</span>
@@ -354,7 +368,7 @@ export default {
>
<gl-search-box-by-type ref="search" v-model="searchTerm" />
<gl-dropdown-item
- :data-testid="`no-${issuableAttribute}-item`"
+ :data-testid="`no-${formatIssuableAttribute.kebab}-item`"
:is-check-item="true"
:is-checked="isAttributeChecked($options.noAttributeId)"
@click="updateAttribute($options.noAttributeId)"
@@ -384,7 +398,7 @@ export default {
:key="attrItem.id"
:is-check-item="true"
:is-checked="isAttributeChecked(attrItem.id)"
- :data-testid="`${issuableAttribute}-items`"
+ :data-testid="`${formatIssuableAttribute.kebab}-items`"
@click="updateAttribute(attrItem.id)"
>
{{ attrItem.title }}
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 701833c4e95..7a10a9f3a4c 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
@@ -61,6 +61,9 @@ export default {
return data.workspace?.issuable?.subscribed || false;
},
result({ data }) {
+ if (!data) {
+ return;
+ }
this.emailsDisabled = this.parentIsGroup
? data.workspace?.emailsDisabled
: data.workspace?.issuable?.emailsDisabled;
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
index 7c157fe2775..bb90ef8e444 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
@@ -38,7 +38,10 @@ export default {
</script>
<template>
- <div data-testid="helpPane" class="time-tracking-help-state">
+ <div
+ data-testid="helpPane"
+ class="sidebar-help-state gl-bg-white gl-border-gray-100 gl-border-t-solid gl-border-b-solid gl-border-1"
+ >
<div class="time-tracking-info">
<h4>{{ __('Track time with quick actions') }}</h4>
<p>{{ __('Quick actions can be used in description and comment boxes.') }}</p>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
index 91c67a03dfb..d222a2af382 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon, GlLink, GlModal, GlModalDirective, GlLoadingIcon } from '@gitlab/ui';
+import { GlIcon, GlLink, GlModal, GlButton, GlModalDirective, GlLoadingIcon } from '@gitlab/ui';
import { IssuableType } from '~/issues/constants';
import { s__, __ } from '~/locale';
import { timeTrackingQueries } from '~/sidebar/constants';
@@ -21,6 +21,7 @@ export default {
GlIcon,
GlLink,
GlModal,
+ GlButton,
GlLoadingIcon,
TimeTrackingCollapsedState,
TimeTrackingSpentOnlyPane,
@@ -187,7 +188,11 @@ export default {
</script>
<template>
- <div v-cloak class="time-tracker time-tracking-component-wrap" data-testid="time-tracker">
+ <div
+ v-cloak
+ class="time-tracker time-tracking-component-wrap sidebar-help-wrap"
+ data-testid="time-tracker"
+ >
<time-tracking-collapsed-state
v-if="showCollapsed"
:show-comparison-state="showComparisonState"
@@ -198,25 +203,21 @@ export default {
:time-spent-human-readable="humanTotalTimeSpent"
:time-estimate-human-readable="humanTimeEstimate"
/>
- <div class="hide-collapsed gl-line-height-20 gl-text-gray-900">
+ <div
+ class="hide-collapsed gl-line-height-20 gl-text-gray-900 gl-display-flex gl-align-items-center"
+ >
{{ __('Time tracking') }}
<gl-loading-icon v-if="isTimeTrackingInfoLoading" size="sm" inline />
- <div
- v-if="!showHelpState"
- data-testid="helpButton"
- class="help-button float-right"
- @click="toggleHelpState(true)"
+ <gl-button
+ :data-testid="showHelpState ? 'closeHelpButton' : 'helpButton'"
+ category="tertiary"
+ size="small"
+ variant="link"
+ class="gl-ml-auto"
+ @click="toggleHelpState(!showHelpState)"
>
- <gl-icon name="question-o" />
- </div>
- <div
- v-else
- data-testid="closeHelpButton"
- class="close-help-button float-right"
- @click="toggleHelpState(false)"
- >
- <gl-icon name="close" />
- </div>
+ <gl-icon :name="showHelpState ? 'close' : 'question-o'" class="gl-text-gray-900!" />
+ </gl-button>
</div>
<div v-if="!isTimeTrackingInfoLoading" class="hide-collapsed">
<div v-if="showEstimateOnlyState" data-testid="estimateOnlyPane">
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
index a9c4203af22..eabba619af5 100644
--- a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
@@ -59,6 +59,10 @@ export default {
return data.workspace?.issuable?.currentUserTodos.nodes[0]?.id;
},
result({ data }) {
+ if (!data) {
+ return;
+ }
+
const currentUserTodos = data.workspace?.issuable?.currentUserTodos?.nodes ?? [];
this.todoId = currentUserTodos[0]?.id;
this.$emit('todoUpdated', currentUserTodos.length > 0);
@@ -177,19 +181,14 @@ export default {
/>
<gl-button
v-if="isClassicSidebar"
+ v-gl-tooltip.left.viewport
+ :title="tootltipTitle"
category="tertiary"
type="reset"
class="sidebar-collapsed-icon sidebar-collapsed-container gl-rounded-0! gl-shadow-none!"
@click.stop.prevent="toggleTodo"
>
- <gl-icon
- v-gl-tooltip.left.viewport
- :title="tootltipTitle"
- :size="16"
- :class="{ 'todo-undone': hasTodo }"
- :name="collapsedButtonIcon"
- :aria-label="collapsedButtonIcon"
- />
+ <gl-icon :class="{ 'todo-undone': hasTodo }" :name="collapsedButtonIcon" />
</gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/fragmentTypes.json b/app/assets/javascripts/sidebar/fragmentTypes.json
deleted file mode 100644
index a1c68bba454..00000000000
--- a/app/assets/javascripts/sidebar/fragmentTypes.json
+++ /dev/null
@@ -1 +0,0 @@
-{"__schema":{"types":[{"kind":"UNION","name":"Issuable","possibleTypes":[{"name":"Issue"},{"name":"MergeRequest"}]}, {"kind":"INTERFACE","name":"User","possibleTypes":[{"name":"UserCore"}]}]}}
diff --git a/app/assets/javascripts/sidebar/graphql.js b/app/assets/javascripts/sidebar/graphql.js
index 5b2ce3fe446..fc757922f09 100644
--- a/app/assets/javascripts/sidebar/graphql.js
+++ b/app/assets/javascripts/sidebar/graphql.js
@@ -1,15 +1,11 @@
-import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import produce from 'immer';
import VueApollo from 'vue-apollo';
import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql';
+import { resolvers as workItemResolvers } from '~/work_items/graphql/resolvers';
import createDefaultClient from '~/lib/graphql';
-import introspectionQueryResultData from './fragmentTypes.json';
-
-const fragmentMatcher = new IntrospectionFragmentMatcher({
- introspectionQueryResultData,
-});
const resolvers = {
+ ...workItemResolvers,
Mutation: {
updateIssueState: (_, { issueType = undefined, isDirty = false }, { cache }) => {
const sourceData = cache.readQuery({ query: getIssueStateQuery });
@@ -18,14 +14,11 @@ const resolvers = {
});
cache.writeQuery({ query: getIssueStateQuery, data });
},
+ ...workItemResolvers.Mutation,
},
};
-export const defaultClient = createDefaultClient(resolvers, {
- cacheConfig: {
- fragmentMatcher,
- },
-});
+export const defaultClient = createDefaultClient(resolvers);
export const apolloProvider = new VueApollo({
defaultClient,
diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
index 1947c4801db..2aacce2fb00 100644
--- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
@@ -21,6 +21,7 @@ export default class SidebarMilestone {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarMilestoneRoot',
components: {
timeTracker,
},
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 6363422259e..c29784aa328 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -57,6 +57,7 @@ function mountSidebarToDoWidget() {
return new Vue({
el,
+ name: 'SidebarTodoRoot',
apolloProvider,
components: {
SidebarTodoWidget,
@@ -103,6 +104,7 @@ function mountAssigneesComponentDeprecated(mediator) {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarAssigneesRoot',
apolloProvider,
components: {
SidebarAssignees,
@@ -135,6 +137,7 @@ function mountAssigneesComponent() {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarAssigneesRoot',
apolloProvider,
components: {
SidebarAssigneesWidget,
@@ -185,6 +188,7 @@ function mountReviewersComponent(mediator) {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarReviewersRoot',
apolloProvider,
components: {
SidebarReviewers,
@@ -218,6 +222,7 @@ function mountCrmContactsComponent() {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarCrmContactsRoot',
apolloProvider,
components: {
CrmContacts,
@@ -242,6 +247,7 @@ function mountMilestoneSelect() {
return new Vue({
el,
+ name: 'SidebarMilestoneRoot',
apolloProvider,
components: {
SidebarDropdownWidget,
@@ -274,6 +280,7 @@ export function mountSidebarLabels() {
return new Vue({
el,
+ name: 'SidebarLabelsRoot',
apolloProvider,
components: {
@@ -328,6 +335,7 @@ function mountConfidentialComponent() {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarConfidentialRoot',
apolloProvider,
components: {
SidebarConfidentialityWidget,
@@ -362,6 +370,7 @@ function mountDueDateComponent() {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarDueDateRoot',
apolloProvider,
components: {
SidebarDueDateWidget,
@@ -392,6 +401,7 @@ function mountReferenceComponent() {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarReferenceRoot',
apolloProvider,
components: {
SidebarReferenceWidget,
@@ -428,6 +438,7 @@ function mountLockComponent(store) {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarLockRoot',
store,
provide: {
fullPath,
@@ -451,6 +462,7 @@ function mountParticipantsComponent() {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarParticipantsRoot',
apolloProvider,
components: {
SidebarParticipantsWidget,
@@ -479,6 +491,7 @@ function mountSubscriptionsComponent() {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarSubscriptionsRoot',
apolloProvider,
components: {
SidebarSubscriptionsWidget,
@@ -509,6 +522,7 @@ function mountTimeTrackingComponent() {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarTimeTrackingRoot',
apolloProvider,
provide: { issuableType },
render: (createElement) =>
@@ -534,6 +548,7 @@ function mountSeverityComponent() {
return new Vue({
el: severityContainerEl,
+ name: 'SidebarSeverityRoot',
apolloProvider,
components: {
SidebarSeverity,
@@ -562,6 +577,7 @@ function mountCopyEmailComponent() {
// eslint-disable-next-line no-new
new Vue({
el,
+ name: 'SidebarCopyEmailRoot',
render: (createElement) =>
createElement(CopyEmailToClipboard, { props: { issueEmailAddress: createNoteEmail } }),
});
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index 25468d4a697..4664bb56958 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -1,4 +1,4 @@
-import Store from 'ee_else_ce/sidebar/stores/sidebar_store';
+import Store from '~/sidebar/stores/sidebar_store';
import createFlash from '~/flash';
import { __, sprintf } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
diff --git a/app/assets/javascripts/tabs/constants.js b/app/assets/javascripts/tabs/constants.js
index 3b84d7394d4..90c9a89d652 100644
--- a/app/assets/javascripts/tabs/constants.js
+++ b/app/assets/javascripts/tabs/constants.js
@@ -1,8 +1,4 @@
-export const ACTIVE_TAB_CLASSES = Object.freeze([
- 'active',
- 'gl-tab-nav-item-active',
- 'gl-tab-nav-item-active-indigo',
-]);
+export const ACTIVE_TAB_CLASSES = Object.freeze(['active', 'gl-tab-nav-item-active']);
export const ACTIVE_PANEL_CLASS = 'active';
diff --git a/app/assets/javascripts/terraform/components/states_table.vue b/app/assets/javascripts/terraform/components/states_table.vue
index d066834540f..efc2991f40f 100644
--- a/app/assets/javascripts/terraform/components/states_table.vue
+++ b/app/assets/javascripts/terraform/components/states_table.vue
@@ -1,14 +1,5 @@
<script>
-import {
- GlAlert,
- GlBadge,
- GlIcon,
- GlLink,
- GlLoadingIcon,
- GlSprintf,
- GlTable,
- GlTooltip,
-} from '@gitlab/ui';
+import { GlAlert, GlBadge, GlLink, GlLoadingIcon, GlSprintf, GlTable, GlTooltip } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { s__ } from '~/locale';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
@@ -21,7 +12,6 @@ export default {
CiBadge,
GlAlert,
GlBadge,
- GlIcon,
GlLink,
GlLoadingIcon,
GlSprintf,
@@ -128,7 +118,7 @@ export default {
>
<template #cell(name)="{ item }">
<div
- class="gl-display-flex align-items-center gl-justify-content-end gl-justify-content-md-start"
+ class="gl-display-flex align-items-center gl-justify-content-end gl-md-justify-content-start"
data-testid="terraform-states-table-name"
>
<p class="gl-font-weight-bold gl-m-0 gl-text-gray-900">
@@ -156,8 +146,7 @@ export default {
:id="`terraformLockedBadgeContainer${item.name}`"
class="gl-mx-3"
>
- <gl-badge :id="`terraformLockedBadge${item.name}`">
- <gl-icon name="lock" />
+ <gl-badge :id="`terraformLockedBadge${item.name}`" icon="lock">
{{ $options.i18n.locked }}
</gl-badge>
diff --git a/app/assets/javascripts/terraform/index.js b/app/assets/javascripts/terraform/index.js
index 1b8cab0d51e..34261f3c4db 100644
--- a/app/assets/javascripts/terraform/index.js
+++ b/app/assets/javascripts/terraform/index.js
@@ -1,5 +1,5 @@
+import { defaultDataIdFromObject } from '@apollo/client/core';
import { GlToast } from '@gitlab/ui';
-import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
diff --git a/app/assets/javascripts/toggles/index.js b/app/assets/javascripts/toggles/index.js
new file mode 100644
index 00000000000..046b9fc7dcd
--- /dev/null
+++ b/app/assets/javascripts/toggles/index.js
@@ -0,0 +1,65 @@
+import { kebabCase } from 'lodash';
+import Vue from 'vue';
+import { GlToggle } from '@gitlab/ui';
+import { parseBoolean } from '~/lib/utils/common_utils';
+
+export const initToggle = (el) => {
+ if (!el) {
+ return false;
+ }
+
+ const {
+ name,
+ isChecked,
+ disabled,
+ isLoading,
+ label,
+ help,
+ labelPosition,
+ ...dataset
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ props: {
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: parseBoolean(disabled),
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: parseBoolean(isLoading),
+ },
+ },
+ data() {
+ return {
+ value: parseBoolean(isChecked),
+ };
+ },
+ render(h) {
+ return h(GlToggle, {
+ props: {
+ name,
+ value: this.value,
+ disabled: this.disabled,
+ isLoading: this.isLoading,
+ label,
+ help,
+ labelPosition,
+ },
+ class: el.className,
+ attrs: Object.fromEntries(
+ Object.entries(dataset).map(([key, value]) => [`data-${kebabCase(key)}`, value]),
+ ),
+ on: {
+ change: (newValue) => {
+ this.value = newValue;
+ this.$emit('change', newValue);
+ },
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/tooltips/index.js b/app/assets/javascripts/tooltips/index.js
index 49a43b120e0..4639671984a 100644
--- a/app/assets/javascripts/tooltips/index.js
+++ b/app/assets/javascripts/tooltips/index.js
@@ -21,6 +21,7 @@ const tooltipsApp = () => {
document.body.appendChild(container);
app = new Vue({
+ name: 'TooltipsRoot',
render(h) {
return h(Tooltips, {
props: {
diff --git a/app/assets/javascripts/user_callout.js b/app/assets/javascripts/user_callout.js
index 44e54c85f3c..ee23f8c5a0c 100644
--- a/app/assets/javascripts/user_callout.js
+++ b/app/assets/javascripts/user_callout.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import Cookies from 'js-cookie';
+import { getCookie, setCookie } from '~/lib/utils/common_utils';
export default class UserCallout {
constructor(options = {}) {
@@ -9,7 +9,7 @@ export default class UserCallout {
this.userCalloutBody = $(`.${className}`);
this.cookieName = this.userCalloutBody.data('uid');
- this.isCalloutDismissed = Cookies.get(this.cookieName);
+ this.isCalloutDismissed = getCookie(this.cookieName);
this.init();
}
@@ -30,7 +30,7 @@ export default class UserCallout {
cookieOptions.path = this.userCalloutBody.data('projectPath');
}
- Cookies.set(this.cookieName, 'true', cookieOptions);
+ setCookie(this.cookieName, 'true', cookieOptions);
if ($currentTarget.hasClass('close') || $currentTarget.hasClass('js-close')) {
this.userCalloutBody.remove();
diff --git a/app/assets/javascripts/vue_alerts.js b/app/assets/javascripts/vue_alerts.js
index b44f787cf30..f3bf121c0f8 100644
--- a/app/assets/javascripts/vue_alerts.js
+++ b/app/assets/javascripts/vue_alerts.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
-import Cookies from 'js-cookie';
-import { parseBoolean } from '~/lib/utils/common_utils';
+import { setCookie, parseBoolean } from '~/lib/utils/common_utils';
+
import DismissibleAlert from '~/vue_shared/components/dismissible_alert.vue';
const getCookieExpirationPeriod = (expirationPeriod) => {
@@ -33,7 +33,7 @@ const mountVueAlert = (el) => {
if (!dismissCookieName) {
return;
}
- Cookies.set(dismissCookieName, true, {
+ setCookie(dismissCookieName, true, {
expires: getCookieExpirationPeriod(dismissCookieExpire),
});
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
index 5ef7c2f72e0..7ba387c79b1 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
@@ -1,5 +1,6 @@
<script>
import createFlash from '~/flash';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { visitUrl } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -79,6 +80,7 @@ export default {
[STOPPING]: {
actionName: STOPPING,
buttonText: s__('MrDeploymentActions|Stop environment'),
+ buttonVariant: 'danger',
busyText: __('This environment is being deployed'),
confirmMessage: __('Are you sure you want to stop this environment?'),
errorMessage: __('Something went wrong while stopping this environment. Please try again.'),
@@ -86,6 +88,7 @@ export default {
[DEPLOYING]: {
actionName: DEPLOYING,
buttonText: s__('MrDeploymentActions|Deploy'),
+ buttonVariant: 'confirm',
busyText: __('This environment is being deployed'),
confirmMessage: __('Are you sure you want to deploy this environment?'),
errorMessage: __('Something went wrong while deploying this environment. Please try again.'),
@@ -93,14 +96,27 @@ export default {
[REDEPLOYING]: {
actionName: REDEPLOYING,
buttonText: s__('MrDeploymentActions|Re-deploy'),
+ buttonVariant: 'confirm',
busyText: __('This environment is being re-deployed'),
confirmMessage: __('Are you sure you want to re-deploy this environment?'),
errorMessage: __('Something went wrong while deploying this environment. Please try again.'),
},
},
methods: {
- executeAction(endpoint, { actionName, confirmMessage, errorMessage }) {
- const isConfirmed = confirm(confirmMessage); //eslint-disable-line
+ async executeAction(
+ endpoint,
+ {
+ actionName,
+ buttonText: primaryBtnText,
+ buttonVariant: primaryBtnVariant,
+ confirmMessage,
+ errorMessage,
+ },
+ ) {
+ const isConfirmed = await confirmAction(confirmMessage, {
+ primaryBtnVariant,
+ primaryBtnText,
+ });
if (isConfirmed) {
this.actionInProgress = actionName;
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 7322958e6df..a25b4ab54e5 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
@@ -128,10 +128,12 @@ export default {
api.trackRedisHllUserEvent(this.$options.expandEvent);
}
}),
- toggleCollapsed() {
- this.isCollapsed = !this.isCollapsed;
+ toggleCollapsed(e) {
+ if (!e?.target?.closest('.btn:not(.btn-icon),a')) {
+ this.isCollapsed = !this.isCollapsed;
- this.triggerRedisTracking();
+ this.triggerRedisTracking();
+ }
},
initExtensionPolling() {
const poll = new Poll({
@@ -139,7 +141,7 @@ export default {
fetchData: () => this.fetchCollapsedData(this.$props),
},
method: 'fetchData',
- successCallback: (data) => {
+ successCallback: ({ data }) => {
if (Object.keys(data).length > 0) {
poll.stop();
this.setCollapsedData(data);
@@ -207,6 +209,19 @@ export default {
this.showFade = true;
}
},
+ onRowMouseDown() {
+ this.down = Number(new Date());
+ },
+ onRowMouseUp(e) {
+ const up = Number(new Date());
+
+ // To allow for text to be selected we check if the the user is clicking
+ // or selecting, if they are selecting the time difference should be
+ // more than 200ms
+ if (up - this.down < 200) {
+ this.toggleCollapsed(e);
+ }
+ },
generateText,
},
EXTENSION_ICON_CLASS,
@@ -215,7 +230,7 @@ export default {
<template>
<section class="media-section" data-testid="widget-extension">
- <div class="media gl-p-5">
+ <div class="media gl-p-5 gl-cursor-pointer" @mousedown="onRowMouseDown" @mouseup="onRowMouseUp">
<status-icon
:name="$options.label || $options.name"
:is-loading="isLoadingSummary"
@@ -253,7 +268,7 @@ export default {
category="tertiary"
data-testid="toggle-button"
size="small"
- @click="toggleCollapsed"
+ @click.self="toggleCollapsed"
/>
</div>
</div>
@@ -317,9 +332,13 @@ export default {
<div v-if="data.link">
<gl-link :href="data.link.href">{{ data.link.text }}</gl-link>
</div>
+ <div v-if="data.supportingText">
+ <p v-safe-html="generateText(data.supportingText)" class="gl-m-0"></p>
+ </div>
<gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'">
{{ data.badge.text }}
</gl-badge>
+
<actions
:widget="$options.label || $options.name"
:tertiary-buttons="data.actions"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue
index cd5b7c3110d..8b410926c46 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue
@@ -90,7 +90,7 @@ export default {
</template>
<div class="row">
<div
- class="col-md-5 order-md-last col-12 gl-mt-5 gl-mt-md-n2! gl-pt-md-2 svg-content svg-225"
+ class="col-md-5 order-md-last col-12 gl-mt-5 gl-md-mt-n2! gl-md-pt-2 svg-content svg-225"
>
<img data-testid="pipeline-image" :src="pipelineSvgPath" />
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_failed_pipeline_confirmation_dialog.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_failed_pipeline_confirmation_dialog.vue
new file mode 100644
index 00000000000..7279ad971be
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_failed_pipeline_confirmation_dialog.vue
@@ -0,0 +1,66 @@
+<script>
+import { GlModal, GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ name: 'MergeFailedPipelineConfirmationDialog',
+ i18n: {
+ primary: __('Merge unverified changes'),
+ cancel: __('Cancel'),
+ info: __(
+ 'The latest pipeline for this merge request did not succeed. The latest changes are unverified.',
+ ),
+ confirmation: __('Are you sure you want to attempt to merge?'),
+ title: __('Merge unverified changes?'),
+ },
+ components: {
+ GlModal,
+ GlButton,
+ },
+ props: {
+ visible: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ methods: {
+ hide() {
+ this.$refs.modal.hide();
+ },
+ cancel() {
+ this.hide();
+ this.$emit('cancel');
+ },
+ focusCancelButton() {
+ this.$refs.cancelButton.$el.focus();
+ },
+ mergeChanges() {
+ this.$emit('mergeWithFailedPipeline');
+ this.hide();
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ ref="modal"
+ size="sm"
+ modal-id="merge-train-failed-pipeline-confirmation-dialog"
+ :title="$options.i18n.title"
+ :visible="visible"
+ data-testid="merge-failed-pipeline-confirmation-dialog"
+ @shown="focusCancelButton"
+ @hide="$emit('cancel')"
+ >
+ <p>{{ $options.i18n.info }}</p>
+ <p>{{ $options.i18n.confirmation }}</p>
+ <template #modal-footer>
+ <gl-button ref="cancelButton" data-testid="merge-cancel-btn" @click="cancel">{{
+ $options.i18n.cancel
+ }}</gl-button>
+ <gl-button variant="danger" data-testid="merge-unverified-changes" @click="mergeChanges">
+ {{ $options.i18n.primary }}
+ </gl-button>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
index 247877a8235..e0c4679b983 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
@@ -1,7 +1,14 @@
<script>
-import { MERGE_ACTIVE_STATUS_PHRASES } from '../../constants';
+import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
+import simplePoll from '~/lib/utils/simple_poll';
+import MergeRequest from '../../../merge_request';
+import eventHub from '../../event_hub';
+import { MERGE_ACTIVE_STATUS_PHRASES, STATE_MACHINE } from '../../constants';
import statusIcon from '../mr_widget_status_icon.vue';
+const { transitions } = STATE_MACHINE;
+const { MERGE_FAILURE } = transitions;
+
export default {
name: 'MRWidgetMerging',
components: {
@@ -12,6 +19,10 @@ export default {
type: Object,
required: true,
},
+ service: {
+ type: Object,
+ required: true,
+ },
},
data() {
const statusCount = MERGE_ACTIVE_STATUS_PHRASES.length;
@@ -20,6 +31,53 @@ export default {
mergeStatus: MERGE_ACTIVE_STATUS_PHRASES[Math.floor(Math.random() * statusCount)],
};
},
+ mounted() {
+ this.initiateMergePolling();
+ },
+ methods: {
+ initiateMergePolling() {
+ simplePoll(
+ (continuePolling, stopPolling) => {
+ this.handleMergePolling(continuePolling, stopPolling);
+ },
+ { timeout: 0 },
+ );
+ },
+ handleMergePolling(continuePolling, stopPolling) {
+ this.service
+ .poll()
+ .then((res) => res.data)
+ .then((data) => {
+ if (data.state === 'merged') {
+ // If state is merged we should update the widget and stop the polling
+ eventHub.$emit('MRWidgetUpdateRequested');
+ eventHub.$emit('FetchActionsContent');
+ MergeRequest.hideCloseButton();
+ MergeRequest.decreaseCounter();
+ stopPolling();
+
+ refreshUserMergeRequestCounts();
+
+ // If user checked remove source branch and we didn't remove the branch yet
+ // we should start another polling for source branch remove process
+ if (this.removeSourceBranch && data.source_branch_exists) {
+ this.initiateRemoveSourceBranchPolling();
+ }
+ } else if (data.merge_error) {
+ eventHub.$emit('FailedToMerge', data.merge_error);
+ this.mr.transitionStateMachine({ transition: MERGE_FAILURE });
+ stopPolling();
+ } else {
+ // MR is not merged yet, continue polling until the state becomes 'merged'
+ continuePolling();
+ }
+ })
+ .catch(() => {
+ this.mr.transitionStateMachine({ transition: MERGE_FAILURE });
+ stopPolling();
+ });
+ },
+ },
};
</script>
<template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
index 5b03eda2eac..cadbd9c28a9 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
@@ -1,9 +1,14 @@
<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { sprintf, s__ } from '~/locale';
+import { GlIcon, GlTooltipDirective, GlSprintf } from '@gitlab/ui';
+import { sprintf } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import missingBranchQuery from '../../queries/states/missing_branch.query.graphql';
+import {
+ MR_WIDGET_MISSING_BRANCH_WHICH,
+ MR_WIDGET_MISSING_BRANCH_RESTORE,
+ MR_WIDGET_MISSING_BRANCH_MANUALCLI,
+} from '../../i18n';
import statusIcon from '../mr_widget_status_icon.vue';
export default {
@@ -13,6 +18,7 @@ export default {
},
components: {
GlIcon,
+ GlSprintf,
statusIcon,
},
mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
@@ -45,26 +51,20 @@ export default {
return this.mr.sourceBranchRemoved;
},
- missingBranchName() {
+ type() {
return this.sourceBranchRemoved ? 'source' : 'target';
},
- missingBranchNameMessage() {
- return sprintf(
- s__('mrWidget| Please restore it or use a different %{missingBranchName} branch'),
- {
- missingBranchName: this.missingBranchName,
- },
- );
+ name() {
+ return this.type === 'source' ? this.mr.sourceBranch : this.mr.targetBranch;
+ },
+ warning() {
+ return sprintf(MR_WIDGET_MISSING_BRANCH_WHICH, { type: this.type, name: this.name });
+ },
+ restore() {
+ return sprintf(MR_WIDGET_MISSING_BRANCH_RESTORE, { type: this.type });
},
message() {
- return sprintf(
- s__(
- 'mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line',
- ),
- {
- missingBranchName: this.missingBranchName,
- },
- );
+ return sprintf(MR_WIDGET_MISSING_BRANCH_MANUALCLI, { type: this.type });
},
},
};
@@ -79,9 +79,14 @@ export default {
'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget,
}"
class="bold js-branch-text"
+ data-testid="widget-content"
>
- <span class="capitalize" data-testid="missingBranchName"> {{ missingBranchName }} </span>
- {{ s__('mrWidget|branch does not exist.') }} {{ missingBranchNameMessage }}
+ <gl-sprintf :message="warning">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ {{ restore }}
<gl-icon
v-gl-tooltip
:title="message"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue
index d88dad2e086..d204befef58 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue
@@ -20,7 +20,7 @@ export default {
},
i18n: {
failedMessage: s__(
- `mrWidget|The pipeline for this merge request did not complete. Push a new commit to fix the failure, or check the %{linkStart}troubleshooting documentation%{linkEnd} to see other possible actions.`,
+ `mrWidget|Merge blocked: pipeline must succeed. Push a commit that fixes the failure, or %{linkStart}learn about other solutions.%{linkEnd}`,
),
},
};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
index 06ce312bd4c..bc094501e89 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
@@ -14,7 +14,6 @@ import {
import { isEmpty } from 'lodash';
import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge';
import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql';
-import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import createFlash from '~/flash';
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
import simplePoll from '~/lib/utils/simple_poll';
@@ -22,11 +21,8 @@ import { __, s__ } from '~/locale';
import SmartInterval from '~/smart_interval';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { helpPagePath } from '~/helpers/help_page_helper';
-import MergeRequest from '../../../merge_request';
import {
AUTO_MERGE_STRATEGIES,
- DANGER,
- CONFIRM,
WARNING,
MT_MERGE_STRATEGY,
PIPELINE_FAILED_STATE,
@@ -42,6 +38,7 @@ import CommitEdit from './commit_edit.vue';
import CommitMessageDropdown from './commit_message_dropdown.vue';
import CommitsHeader from './commits_header.vue';
import SquashBeforeMerge from './squash_before_merge.vue';
+import MergeFailedPipelineConfirmationDialog from './merge_failed_pipeline_confirmation_dialog.vue';
const PIPELINE_RUNNING_STATE = 'running';
const PIPELINE_PENDING_STATE = 'pending';
@@ -52,7 +49,7 @@ const MERGE_SUCCESS_STATUS = 'success';
const MERGE_HOOK_VALIDATION_ERROR_STATUS = 'hook_validation_error';
const { transitions } = STATE_MACHINE;
-const { MERGE, MERGED, MERGE_FAILURE, AUTO_MERGE } = transitions;
+const { MERGE, MERGE_FAILURE, AUTO_MERGE, MERGING } = transitions;
export default {
name: 'ReadyToMerge',
@@ -106,6 +103,7 @@ export default {
GlDropdownItem,
GlFormCheckbox,
GlSkeletonLoader,
+ MergeFailedPipelineConfirmationDialog,
MergeTrainHelperIcon: () =>
import('ee_component/vue_merge_request_widget/components/merge_train_helper_icon.vue'),
MergeImmediatelyConfirmationDialog: () =>
@@ -138,7 +136,8 @@ export default {
squashBeforeMerge: this.mr.squashIsSelected,
isSquashReadOnly: this.mr.squashIsReadonly,
squashCommitMessage: this.mr.squashCommitMessage,
- isPipelineFailedModalVisible: false,
+ isPipelineFailedModalVisibleMergeTrain: false,
+ isPipelineFailedModalVisibleNormalMerge: false,
editCommitMessage: false,
};
},
@@ -166,6 +165,9 @@ export default {
return this.mr.isPipelineFailed;
},
+ showMergeFailedPipelineConfirmationDialog() {
+ return this.status === PIPELINE_FAILED_STATE && this.isPipelineFailed;
+ },
isMergeAllowed() {
if (this.glFeatures.mergeRequestWidgetGraphql) {
return this.state.mergeable;
@@ -248,13 +250,6 @@ export default {
return PIPELINE_SUCCESS_STATE;
},
- mergeButtonVariant() {
- if (this.status === PIPELINE_FAILED_STATE || this.isPipelineFailed) {
- return DANGER;
- }
-
- return CONFIRM;
- },
iconClass() {
if (this.shouldRenderMergeTrainHelperIcon && !this.mr.preventMerge) {
return PIPELINE_RUNNING_STATE;
@@ -279,6 +274,10 @@ export default {
return this.autoMergeText;
}
+ if (this.status === PIPELINE_FAILED_STATE || this.isPipelineFailed) {
+ return __('Merge...');
+ }
+
return __('Merge');
},
hasPipelineMustSucceedConflict() {
@@ -361,8 +360,13 @@ export default {
return this.$apollo.queries.state.refetch();
},
handleMergeButtonClick(useAutoMerge, mergeImmediately = false, confirmationClicked = false) {
- if (this.showFailedPipelineModal && !confirmationClicked) {
- this.isPipelineFailedModalVisible = true;
+ if (this.showMergeFailedPipelineConfirmationDialog && !confirmationClicked) {
+ this.isPipelineFailedModalVisibleNormalMerge = true;
+ return;
+ }
+
+ if (this.showFailedPipelineModalMergeTrain && !confirmationClicked) {
+ this.isPipelineFailedModalVisibleMergeTrain = true;
return;
}
@@ -406,7 +410,7 @@ export default {
eventHub.$emit('MRWidgetUpdateRequested');
this.mr.transitionStateMachine({ transition: AUTO_MERGE });
} else if (data.status === MERGE_SUCCESS_STATUS) {
- this.initiateMergePolling();
+ this.mr.transitionStateMachine({ transition: MERGING });
} else if (hasError) {
eventHub.$emit('FailedToMerge', data.merge_error);
this.mr.transitionStateMachine({ transition: MERGE_FAILURE });
@@ -434,51 +438,8 @@ export default {
onMergeImmediatelyConfirmation() {
this.handleMergeButtonClick(false, true, true);
},
- initiateMergePolling() {
- simplePoll(
- (continuePolling, stopPolling) => {
- this.handleMergePolling(continuePolling, stopPolling);
- },
- { timeout: 0 },
- );
- },
- handleMergePolling(continuePolling, stopPolling) {
- this.service
- .poll()
- .then((res) => res.data)
- .then((data) => {
- if (data.state === 'merged') {
- // If state is merged we should update the widget and stop the polling
- eventHub.$emit('MRWidgetUpdateRequested');
- eventHub.$emit('FetchActionsContent');
- MergeRequest.hideCloseButton();
- MergeRequest.decreaseCounter();
- this.mr.transitionStateMachine({ transition: MERGED });
- stopPolling();
-
- refreshUserMergeRequestCounts();
-
- // If user checked remove source branch and we didn't remove the branch yet
- // we should start another polling for source branch remove process
- if (this.removeSourceBranch && data.source_branch_exists) {
- this.initiateRemoveSourceBranchPolling();
- }
- } else if (data.merge_error) {
- eventHub.$emit('FailedToMerge', data.merge_error);
- this.mr.transitionStateMachine({ transition: MERGE_FAILURE });
- stopPolling();
- } else {
- // MR is not merged yet, continue polling until the state becomes 'merged'
- continuePolling();
- }
- })
- .catch(() => {
- createFlash({
- message: __('Something went wrong while merging this merge request. Please try again.'),
- });
- this.mr.transitionStateMachine({ transition: MERGE_FAILURE });
- stopPolling();
- });
+ onMergeWithFailedPipelineConfirmation() {
+ this.handleMergeButtonClick(false, true, true);
},
initiateRemoveSourceBranchPolling() {
// We need to show source branch is being removed spinner in another component
@@ -559,7 +520,7 @@ export default {
category="primary"
class="accept-merge-request"
data-testid="merge-button"
- :variant="mergeButtonVariant"
+ variant="confirm"
:disabled="isMergeButtonDisabled"
:loading="isMakingRequest"
data-qa-selector="merge_button"
@@ -570,7 +531,7 @@ export default {
v-if="shouldShowMergeImmediatelyDropdown"
v-gl-tooltip.hover.focus="__('Select merge moment')"
:disabled="isMergeButtonDisabled"
- :variant="mergeButtonVariant"
+ variant="confirm"
data-qa-selector="merge_moment_dropdown"
toggle-class="btn-icon js-merge-moment"
>
@@ -593,18 +554,22 @@ export default {
/>
</gl-dropdown>
<merge-train-failed-pipeline-confirmation-dialog
- :visible="isPipelineFailedModalVisible"
+ :visible="isPipelineFailedModalVisibleMergeTrain"
@startMergeTrain="onStartMergeTrainConfirmation"
- @cancel="isPipelineFailedModalVisible = false"
+ @cancel="isPipelineFailedModalVisibleMergeTrain = false"
+ />
+ <merge-failed-pipeline-confirmation-dialog
+ :visible="isPipelineFailedModalVisibleNormalMerge"
+ @mergeWithFailedPipeline="onMergeWithFailedPipelineConfirmation"
+ @cancel="isPipelineFailedModalVisibleNormalMerge = false"
/>
</gl-button-group>
+ <merge-train-helper-icon v-if="shouldRenderMergeTrainHelperIcon" class="gl-mx-3" />
<div
v-if="shouldShowMergeControls"
:class="{ 'gl-w-full gl-order-n1 gl-mb-5': glFeatures.restructuredMrWidget }"
class="gl-display-flex gl-align-items-center gl-flex-wrap"
>
- <merge-train-helper-icon v-if="shouldRenderMergeTrainHelperIcon" class="gl-mx-3" />
-
<gl-form-checkbox
v-if="canRemoveSourceBranch"
id="remove-source-branch-input"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
index 8cf6383c26a..25ba4bf12af 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
@@ -43,8 +43,8 @@ export default {
class="gl-ml-3"
size="small"
:icon="glFeatures.restructuredMrWidget ? undefined : 'comment-next'"
- :variant="glFeatures.restructuredMrWidget && 'confirm'"
- :category="glFeatures.restructuredMrWidget && 'secondary'"
+ :variant="glFeatures.restructuredMrWidget ? 'confirm' : 'default'"
+ :category="glFeatures.restructuredMrWidget ? 'secondary' : 'primary'"
@click="jumpToFirstUnresolvedDiscussion"
>
{{ s__('mrWidget|Jump to first unresolved thread') }}
diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js
index 32effb91043..d337a554663 100644
--- a/app/assets/javascripts/vue_merge_request_widget/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/constants.js
@@ -68,6 +68,7 @@ const STATE_MACHINE = {
states: {
IDLE: 'IDLE',
MERGING: 'MERGING',
+ MERGED: 'MERGED',
AUTO_MERGE: 'AUTO_MERGE',
},
transitions: {
@@ -75,6 +76,7 @@ const STATE_MACHINE = {
AUTO_MERGE: 'start-auto-merge',
MERGE_FAILURE: 'merge-failed',
MERGED: 'merge-done',
+ MERGING: 'merging',
},
};
const { states, transitions } = STATE_MACHINE;
@@ -86,11 +88,12 @@ STATE_MACHINE.definition = {
on: {
[transitions.MERGE]: states.MERGING,
[transitions.AUTO_MERGE]: states.AUTO_MERGE,
+ [transitions.MERGING]: states.MERGING,
},
},
[states.MERGING]: {
on: {
- [transitions.MERGED]: states.IDLE,
+ [transitions.MERGED]: states.MERGED,
[transitions.MERGE_FAILURE]: states.IDLE,
},
},
@@ -110,6 +113,7 @@ export const stateToTransitionMap = {
};
export const stateToComponentMap = {
[states.MERGING]: classStateMap[stateKey.merging],
+ [states.MERGED]: classStateMap[stateKey.merged],
[states.AUTO_MERGE]: classStateMap[stateKey.autoMergeEnabled],
};
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
new file mode 100644
index 00000000000..168f10bd148
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js
@@ -0,0 +1,120 @@
+import { uniqueId } from 'lodash';
+import { __, n__, s__, sprintf } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
+import { EXTENSION_ICONS } from '../../constants';
+
+export default {
+ name: 'WidgetAccessibility',
+ enablePolling: true,
+ i18n: {
+ loading: s__('Reports|Accessibility scanning results are being parsed'),
+ error: s__('Reports|Accessibility scanning failed loading results'),
+ },
+ props: ['accessibilityReportPath'],
+ computed: {
+ statusIcon() {
+ return this.collapsedData.status === 'failed'
+ ? EXTENSION_ICONS.warning
+ : EXTENSION_ICONS.success;
+ },
+ },
+ methods: {
+ summary() {
+ const numOfResults = this.collapsedData?.summary?.errored || 0;
+
+ const successText = s__(
+ 'Reports|Accessibility scanning detected no issues for the source branch only',
+ );
+ const warningText = sprintf(
+ n__(
+ 'Reports|Accessibility scanning detected %{strong_start}%{number}%{strong_end} issue for the source branch only',
+ 'Reports|Accessibility scanning detected %{strong_start}%{number}%{strong_end} issues for the source branch only',
+ numOfResults,
+ ),
+ {
+ number: numOfResults,
+ },
+ false,
+ );
+
+ return numOfResults === 0 ? successText : warningText;
+ },
+ fetchCollapsedData() {
+ return axios.get(this.accessibilityReportPath);
+ },
+ fetchFullData() {
+ return Promise.resolve(this.prepareReports());
+ },
+ parsedTECHSCode(code) {
+ /*
+ * In issue code looks like "WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail"
+ * or "WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent"
+ *
+ * The TECHS code is the "G18", "G168", "H91", etc. from the code which is used for the documentation.
+ * Here we simply split the string on `.` and get the code in the 5th position
+ */
+ return code?.split('.')[4];
+ },
+ formatLearnMoreUrl(code) {
+ const parsed = this.parsedTECHSCode(code);
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return `https://www.w3.org/TR/WCAG20-TECHS/${parsed || 'Overview'}.html`;
+ },
+ formatText(code) {
+ return sprintf(
+ s__(
+ 'AccessibilityReport|The accessibility scanning found an error of the following type: %{code}',
+ ),
+ { code },
+ );
+ },
+ formatMessage(message) {
+ return sprintf(s__('AccessibilityReport|Message: %{message}'), { message });
+ },
+ prepareReports() {
+ const { new_errors, existing_errors, resolved_errors } = this.collapsedData;
+
+ const newErrors = new_errors.map((error) => {
+ return {
+ header: __('New'),
+ id: uniqueId('new-error-'),
+ text: this.formatText(error.code),
+ icon: { name: EXTENSION_ICONS.failed },
+ link: {
+ href: this.formatLearnMoreUrl(error.code),
+ text: __('Learn more'),
+ },
+ supportingText: this.formatMessage(error.message),
+ };
+ });
+
+ const existingErrors = existing_errors.map((error) => {
+ return {
+ id: uniqueId('existing-error-'),
+ text: this.formatText(error.code),
+ icon: { name: EXTENSION_ICONS.failed },
+ link: {
+ href: this.formatLearnMoreUrl(error.code),
+ text: __('Learn more'),
+ },
+ supportingText: this.formatMessage(error.message),
+ };
+ });
+
+ const resolvedErrors = resolved_errors.map((error) => {
+ return {
+ id: uniqueId('resolved-error-'),
+ text: this.formatText(error.code),
+ icon: { name: EXTENSION_ICONS.success },
+ link: {
+ href: this.formatLearnMoreUrl(error.code),
+ text: __('Learn more'),
+ },
+ supportingText: this.formatMessage(error.message),
+ };
+ });
+
+ return [...newErrors, ...existingErrors, ...resolvedErrors];
+ },
+ },
+};
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 ba3336df2eb..4aeebf095c4 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
@@ -25,9 +25,9 @@ export default {
n__(
'ciReport|Load performance test metrics detected %{strong_start}%{changesFound}%{strong_end} change',
'ciReport|Load performance test metrics detected %{strong_start}%{changesFound}%{strong_end} changes',
- changesFound,
+ count,
),
- { changesFound },
+ { changesFound: count },
);
},
// Status icon to be used next to the summary text
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js
index a564acada02..8fcc4f818ec 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js
@@ -73,26 +73,30 @@ export default {
return `${title}${subtitle}`;
},
fetchCollapsedData() {
- return Promise.resolve(this.fetchPlans().then(this.prepareReports));
- },
- fetchFullData() {
- const { valid, invalid } = this.collapsedData;
- return Promise.resolve([...valid, ...invalid]);
- },
- // Custom methods
- fetchPlans() {
return axios
.get(this.terraformReportsPath)
- .then(({ data }) => {
- return Object.keys(data).map((key) => {
- return data[key];
+ .then((res) => {
+ const reports = Object.keys(res.data).map((key) => {
+ return res.data[key];
});
+
+ const formattedData = this.prepareReports(reports);
+
+ return {
+ ...res,
+ data: formattedData,
+ };
})
.catch(() => {
- const invalidData = { tf_report_error: 'api_error' };
- return [invalidData];
+ const formattedData = this.prepareReports([{ tf_report_error: 'api_error' }]);
+
+ return { data: formattedData };
});
},
+ fetchFullData() {
+ const { valid, invalid } = this.collapsedData;
+ return Promise.resolve([...valid, ...invalid]);
+ },
createReportRow(report, iconName) {
const addNum = Number(report.create);
const changeNum = Number(report.update);
diff --git a/app/assets/javascripts/vue_merge_request_widget/i18n.js b/app/assets/javascripts/vue_merge_request_widget/i18n.js
index c88e795e5f3..454a14faabb 100644
--- a/app/assets/javascripts/vue_merge_request_widget/i18n.js
+++ b/app/assets/javascripts/vue_merge_request_widget/i18n.js
@@ -1,4 +1,14 @@
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
+
+export const MR_WIDGET_MISSING_BRANCH_WHICH = s__(
+ 'mrWidget|The %{type} branch %{codeStart}%{name}%{codeEnd} does not exist.',
+);
+export const MR_WIDGET_MISSING_BRANCH_RESTORE = s__(
+ 'mrWidget|Please restore it or use a different %{type} branch.',
+);
+export const MR_WIDGET_MISSING_BRANCH_MANUALCLI = s__(
+ 'mrWidget|If the %{type} branch exists in your local repository, you can merge this merge request manually using the command line.',
+);
export const SQUASH_BEFORE_MERGE = {
tooltipTitle: __('Required in this project.'),
@@ -10,3 +20,8 @@ export const I18N_SHA_MISMATCH = {
warningMessage: __('Merge blocked: new changes were just added.'),
actionButtonLabel: __('Review changes'),
};
+
+export const MERGE_TRAIN_BUTTON_TEXT = {
+ failed: __('Start merge train...'),
+ passed: __('Start merge train'),
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
index fa618756bb5..247a3711fc8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
@@ -48,7 +48,7 @@ export default {
pipelineId() {
return this.pipeline.id;
},
- showFailedPipelineModal() {
+ showFailedPipelineModalMergeTrain() {
return false;
},
},
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 83a07240403..11de58aa344 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
@@ -45,6 +45,7 @@ import eventHub from './event_hub';
import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables';
import getStateQuery from './queries/get_state.query.graphql';
import terraformExtension from './extensions/terraform';
+import accessibilityExtension from './extensions/accessibility';
export default {
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
@@ -205,7 +206,7 @@ export default {
);
},
shouldShowAccessibilityReport() {
- return this.mr.accessibilityReportPath;
+ return Boolean(this.mr?.accessibilityReportPath);
},
formattedHumanAccess() {
return (this.mr.humanAccess || '').toLowerCase();
@@ -240,6 +241,11 @@ export default {
this.registerTerraformPlans();
}
},
+ shouldShowAccessibilityReport(newVal) {
+ if (newVal) {
+ this.registerAccessibilityExtension();
+ }
+ },
},
mounted() {
MRWidgetService.fetchInitialData()
@@ -478,6 +484,11 @@ export default {
registerExtension(terraformExtension);
}
},
+ registerAccessibilityExtension() {
+ if (this.shouldShowAccessibilityReport && this.shouldShowExtension) {
+ registerExtension(accessibilityExtension);
+ }
+ },
},
};
</script>
@@ -567,7 +578,7 @@ export default {
:endpoint="mr.accessibilityReportPath"
/>
- <div class="mr-widget-section">
+ <div class="mr-widget-section" data-qa-selector="mr_widget_content">
<component :is="componentName" :mr="mr" :service="service" />
<ready-to-merge
v-if="isRestructuredMrWidgetEnabled && mr.commitsCount"
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 0b8396b4461..25c44beaf18 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
@@ -3,7 +3,6 @@ query getState($projectPath: ID!, $iid: String!) {
id
archived
onlyAllowMergeIfPipelineSucceeds
-
mergeRequest(iid: $iid) {
id
autoMergeEnabled
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql
index 2d79d35cf24..ad93a3a7371 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql
@@ -4,6 +4,7 @@ query autoMergeEnabled($projectPath: ID!, $iid: String!) {
project(fullPath: $projectPath) {
id
mergeRequest(iid: $iid) {
+ id
...autoMergeEnabled
}
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql
index f713739f65a..556ecee254d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql
@@ -2,6 +2,7 @@
query readyToMerge($projectPath: ID!, $iid: String!) {
project(fullPath: $projectPath) {
+ id
...ReadyToMerge
}
}
diff --git a/app/assets/javascripts/vue_shared/alert_details/index.js b/app/assets/javascripts/vue_shared/alert_details/index.js
index 9f1da9ae173..d0155c18b9c 100644
--- a/app/assets/javascripts/vue_shared/alert_details/index.js
+++ b/app/assets/javascripts/vue_shared/alert_details/index.js
@@ -1,4 +1,4 @@
-import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
+import { defaultDataIdFromObject } from '@apollo/client/core';
import produce from 'immer';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
@@ -70,6 +70,7 @@ export default (selector) => {
// eslint-disable-next-line no-new
new Vue({
el: selector,
+ name: 'AlertDetailsRoot',
components: {
AlertDetails,
},
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
index 82a28d4cb5f..b6010d4b70c 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -75,6 +75,9 @@ export default {
return this.noteAuthorId === this.currentUserId;
},
},
+ mounted() {
+ this.virtualScrollerItem = this.$el.closest('.vue-recycle-scroller__item-view');
+ },
methods: {
getAwardClassBindings(awardList) {
return {
@@ -162,6 +165,10 @@ export default {
},
setIsMenuOpen(menuOpen) {
this.isMenuOpen = menuOpen;
+
+ if (this.virtualScrollerItem) {
+ this.virtualScrollerItem.style.zIndex = this.isMenuOpen ? 1 : null;
+ }
},
},
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
index 2c74d56f617..3aaa7d915ea 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
@@ -1,6 +1,7 @@
<script>
import { GlIcon, GlSafeHtmlDirective } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import LineHighlighter from '~/blob/line_highlighter';
import { HIGHLIGHT_CLASS_NAME } from './constants';
import ViewerMixin from './mixins';
@@ -20,13 +21,22 @@ export default {
};
},
computed: {
+ refactorBlobViewerEnabled() {
+ return this.glFeatures.refactorBlobViewer;
+ },
+
lineNumbers() {
return this.content.split('\n').length;
},
},
mounted() {
- const { hash } = window.location;
- if (hash) this.scrollToLine(hash, true);
+ if (this.refactorBlobViewerEnabled) {
+ // This line will be removed once we start using highlight.js on the frontend (https://gitlab.com/groups/gitlab-org/-/epics/7146)
+ new LineHighlighter(); // eslint-disable-line no-new
+ } else {
+ const { hash } = window.location;
+ if (hash) this.scrollToLine(hash, true);
+ }
},
methods: {
scrollToLine(hash, scroll = false) {
@@ -51,7 +61,7 @@ export default {
<template>
<div>
<div class="file-content code js-syntax-highlight" :class="$options.userColorScheme">
- <div v-if="!hideLineNumbers" class="line-numbers">
+ <div v-if="!hideLineNumbers" class="line-numbers gl-pt-0!">
<a
v-for="line in lineNumbers"
:id="`L${line}`"
@@ -67,7 +77,7 @@ export default {
</div>
<div class="blob-content">
<pre
- class="code highlight"
+ class="code highlight gl-p-0! gl-display-flex"
><code v-safe-html="content" :data-blob-hash="blobHash"></code></pre>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
index 0575d7f6404..8b76af05ffe 100644
--- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
@@ -45,7 +45,8 @@ export default {
:chart-data="chart.data"
:area-chart-options="chartOptions"
>
- {{ dateRange }}
+ <p>{{ dateRange }}</p>
+ <slot name="metrics" :selected-chart="selectedChart"></slot>
<template #tooltip-title>
<slot name="tooltip-title"></slot>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/confirm_fork_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_fork_modal.vue
new file mode 100644
index 00000000000..64e3b5d0bae
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/confirm_fork_modal.vue
@@ -0,0 +1,68 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export const i18n = {
+ btnText: __('Fork project'),
+ title: __('Fork project?'),
+ message: __(
+ 'You can’t edit files directly in this project. Fork this project and submit a merge request with your changes.',
+ ),
+};
+
+export default {
+ name: 'ConfirmForkModal',
+ components: {
+ GlModal,
+ },
+ model: {
+ prop: 'visible',
+ event: 'change',
+ },
+ props: {
+ visible: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ modalId: {
+ type: String,
+ required: true,
+ },
+ forkPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ btnActions() {
+ return {
+ cancel: { text: __('Cancel') },
+ primary: {
+ text: this.$options.i18n.btnText,
+ attributes: {
+ href: this.forkPath,
+ variant: 'confirm',
+ 'data-qa-selector': 'fork_project_button',
+ 'data-method': 'post',
+ },
+ },
+ };
+ },
+ },
+ i18n,
+};
+</script>
+<template>
+ <gl-modal
+ :visible="visible"
+ data-qa-selector="confirm_fork_modal"
+ :modal-id="modalId"
+ :title="$options.i18n.title"
+ :action-primary="btnActions.primary"
+ :action-cancel="btnActions.cancel"
+ @change="$emit('change', $event)"
+ >
+ <p>{{ $options.i18n.message }}</p>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue b/app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue
index cb038a8c4e1..c411496fad1 100644
--- a/app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue
+++ b/app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue
@@ -28,12 +28,37 @@ export default {
required: false,
default: false,
},
+ isOnImage: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isDraft: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ size: {
+ type: String,
+ required: false,
+ default: 'md',
+ validator: (value) => ['sm', 'md'].includes(value),
+ },
+ ariaLabel: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
computed: {
isNewNote() {
return this.label === null;
},
pinLabel() {
+ if (this.ariaLabel) {
+ return this.ariaLabel;
+ }
+
return this.isNewNote
? __('Comment form position')
: sprintf(__("Comment '%{label}' position"), { label: this.label });
@@ -51,7 +76,10 @@ export default {
'js-image-badge design-note-pin': !isNewNote,
resolved: isResolved,
inactive: isInactive,
+ draft: isDraft,
+ 'on-image': isOnImage,
'gl-absolute': position,
+ small: size === 'sm',
}"
class="gl-display-flex gl-align-items-center gl-justify-content-center gl-font-sm"
type="button"
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
index 810d9f782b9..3d48c74b40b 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
@@ -23,9 +23,19 @@ export const DEFAULT_LABEL_NONE = { value: FILTER_NONE, text: __('None'), title:
export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __('Any'), title: __('Any') };
export const DEFAULT_NONE_ANY = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY];
+export const DEFAULT_MILESTONE_UPCOMING = {
+ value: FILTER_UPCOMING,
+ text: __('Upcoming'),
+ title: __('Upcoming'),
+};
+export const DEFAULT_MILESTONE_STARTED = {
+ value: FILTER_STARTED,
+ text: __('Started'),
+ title: __('Started'),
+};
export const DEFAULT_MILESTONES = DEFAULT_NONE_ANY.concat([
- { value: FILTER_UPCOMING, text: __('Upcoming'), title: __('Upcoming') },
- { value: FILTER_STARTED, text: __('Started'), title: __('Started') },
+ DEFAULT_MILESTONE_UPCOMING,
+ DEFAULT_MILESTONE_STARTED,
]);
export const SortDirection = {
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
index bbc1888bc0b..157068b2c0f 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
@@ -163,19 +163,22 @@ export default {
},
},
methods: {
- handleInput: debounce(function debouncedSearch({ data }) {
- this.searchKey = data;
+ handleInput: debounce(function debouncedSearch({ data, operator }) {
+ // Prevent fetching suggestions when data or operator is not present
+ if (data || operator) {
+ this.searchKey = data;
- if (!this.suggestionsLoading && !this.activeTokenValue) {
- let search = this.searchTerm ? this.searchTerm : data;
+ if (!this.suggestionsLoading && !this.activeTokenValue) {
+ let search = this.searchTerm ? this.searchTerm : data;
- if (search.startsWith('"') && search.endsWith('"')) {
- search = stripQuotes(search);
- } else if (search.startsWith('"')) {
- search = search.slice(1, search.length);
- }
+ if (search.startsWith('"') && search.endsWith('"')) {
+ search = stripQuotes(search);
+ } else if (search.startsWith('"')) {
+ search = search.slice(1, search.length);
+ }
- this.$emit('fetch-suggestions', search);
+ this.$emit('fetch-suggestions', search);
+ }
}
}, DEBOUNCE_DELAY),
handleTokenValueSelected(selectedValue) {
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
index 0d3394788fa..11c081ab4f8 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
@@ -57,7 +57,12 @@ export default {
.fetchMilestones(searchTerm)
.then((response) => {
const data = Array.isArray(response) ? response : response.data;
- this.milestones = data.slice().sort(sortMilestonesByDueDate);
+
+ if (this.config.shouldSkipSort) {
+ this.milestones = data;
+ } else {
+ this.milestones = data.slice().sort(sortMilestonesByDueDate);
+ }
})
.catch(() => {
createFlash({ message: __('There was a problem fetching milestones.') });
diff --git a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue
deleted file mode 100644
index 9ab91e567e6..00000000000
--- a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue
+++ /dev/null
@@ -1,106 +0,0 @@
-<script>
-import Tribute from '@gitlab/tributejs';
-import {
- GfmAutocompleteType,
- tributeConfig,
-} from 'ee_else_ce/vue_shared/components/gfm_autocomplete/utils';
-import * as Emoji from '~/emoji';
-import createFlash from '~/flash';
-import axios from '~/lib/utils/axios_utils';
-import { __ } from '~/locale';
-import SidebarMediator from '~/sidebar/sidebar_mediator';
-
-export default {
- errorMessage: __(
- 'An error occurred while getting autocomplete data. Please refresh the page and try again.',
- ),
- props: {
- autocompleteTypes: {
- type: Array,
- required: false,
- default: () => Object.values(GfmAutocompleteType),
- },
- dataSources: {
- type: Object,
- required: false,
- default: () => gl.GfmAutoComplete?.dataSources || {},
- },
- },
- computed: {
- config() {
- return this.autocompleteTypes.map((type) => ({
- ...tributeConfig[type].config,
- loadingItemTemplate: `<span class="gl-spinner gl-vertical-align-text-bottom gl-ml-3 gl-mr-2"></span>${__(
- 'Loading',
- )}`,
- requireLeadingSpace: true,
- values: this.getValues(type),
- }));
- },
- },
- mounted() {
- this.cache = {};
- this.tribute = new Tribute({ collection: this.config });
-
- const input = this.$slots.default?.[0]?.elm;
- this.tribute.attach(input);
- },
- beforeDestroy() {
- const input = this.$slots.default?.[0]?.elm;
- this.tribute.detach(input);
- },
- methods: {
- cacheAssignees() {
- const isAssigneesLengthSame =
- this.assignees?.length === SidebarMediator.singleton?.store?.assignees?.length;
-
- if (!this.assignees || !isAssigneesLengthSame) {
- this.assignees =
- SidebarMediator.singleton?.store?.assignees?.map((assignee) => assignee.username) || [];
- }
- },
- filterValues(type) {
- // The assignees AJAX response can come after the user first invokes autocomplete
- // so we need to check more than once if we need to update the assignee cache
- this.cacheAssignees();
-
- return tributeConfig[type].filterValues
- ? tributeConfig[type].filterValues({
- assignees: this.assignees,
- collection: this.cache[type],
- fullText: this.$slots.default?.[0]?.elm?.value,
- selectionStart: this.$slots.default?.[0]?.elm?.selectionStart,
- })
- : this.cache[type];
- },
- getValues(type) {
- return (inputText, processValues) => {
- if (this.cache[type]) {
- processValues(this.filterValues(type));
- } else if (type === GfmAutocompleteType.Emojis) {
- Emoji.initEmojiMap()
- .then(() => {
- const emojis = Emoji.getValidEmojiNames();
- this.cache[type] = emojis;
- processValues(emojis);
- })
- .catch(() => createFlash({ message: this.$options.errorMessage }));
- } else if (this.dataSources[type]) {
- axios
- .get(this.dataSources[type])
- .then((response) => {
- this.cache[type] = response.data;
- processValues(this.filterValues(type));
- })
- .catch(() => createFlash({ message: this.$options.errorMessage }));
- } else {
- processValues([]);
- }
- };
- },
- },
- render(createElement) {
- return createElement('div', this.$slots.default);
- },
-};
-</script>
diff --git a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js
deleted file mode 100644
index 44c3fc34ba6..00000000000
--- a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js
+++ /dev/null
@@ -1,195 +0,0 @@
-import { escape, last } from 'lodash';
-import * as Emoji from '~/emoji';
-import { spriteIcon } from '~/lib/utils/common_utils';
-
-const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings
-
-// Number of users to show in the autocomplete menu to avoid doing a mass fetch of 100+ avatars
-const memberLimit = 10;
-
-const nonWordOrInteger = /\W|^\d+$/;
-
-export const menuItemLimit = 100;
-
-export const GfmAutocompleteType = {
- Emojis: 'emojis',
- Issues: 'issues',
- Labels: 'labels',
- Members: 'members',
- MergeRequests: 'mergeRequests',
- Milestones: 'milestones',
- QuickActions: 'commands',
- Snippets: 'snippets',
-};
-
-function doesCurrentLineStartWith(searchString, fullText, selectionStart) {
- const currentLineNumber = fullText.slice(0, selectionStart).split('\n').length;
- const currentLine = fullText.split('\n')[currentLineNumber - 1];
- return currentLine.startsWith(searchString);
-}
-
-export const tributeConfig = {
- [GfmAutocompleteType.Emojis]: {
- config: {
- trigger: ':',
- lookup: (value) => value,
- menuItemLimit,
- menuItemTemplate: ({ original }) => `${original} ${Emoji.glEmojiTag(original)}`,
- selectTemplate: ({ original }) => `:${original}:`,
- },
- },
-
- [GfmAutocompleteType.Issues]: {
- config: {
- trigger: '#',
- lookup: (value) => `${value.iid}${value.title}`,
- menuItemLimit,
- menuItemTemplate: ({ original }) =>
- `<small>${original.reference || original.iid}</small> ${escape(original.title)}`,
- selectTemplate: ({ original }) => original.reference || `#${original.iid}`,
- },
- },
-
- [GfmAutocompleteType.Labels]: {
- config: {
- trigger: '~',
- lookup: 'title',
- menuItemLimit,
- menuItemTemplate: ({ original }) => `
- <span class="dropdown-label-box" style="background: ${escape(original.color)};"></span>
- ${escape(original.title)}`,
- selectTemplate: ({ original }) =>
- nonWordOrInteger.test(original.title)
- ? `~"${escape(original.title)}"`
- : `~${escape(original.title)}`,
- },
- filterValues({ collection, fullText, selectionStart }) {
- if (doesCurrentLineStartWith('/label', fullText, selectionStart)) {
- return collection.filter((label) => !label.set);
- }
-
- if (doesCurrentLineStartWith('/unlabel', fullText, selectionStart)) {
- return collection.filter((label) => label.set);
- }
-
- return collection;
- },
- },
-
- [GfmAutocompleteType.Members]: {
- config: {
- trigger: '@',
- fillAttr: 'username',
- lookup: (value) =>
- value.type === groupType ? last(value.name.split(' / ')) : `${value.name}${value.username}`,
- menuItemLimit: memberLimit,
- menuItemTemplate: ({ original }) => {
- const commonClasses = 'gl-avatar gl-avatar-s32 gl-flex-shrink-0';
- const noAvatarClasses = `${commonClasses} gl-rounded-small
- gl-display-flex gl-align-items-center gl-justify-content-center`;
-
- const avatar = original.avatar_url
- ? `<img class="${commonClasses} gl-avatar-circle" src="${original.avatar_url}" alt="" />`
- : `<div class="${noAvatarClasses}" aria-hidden="true">
- ${original.username.charAt(0).toUpperCase()}</div>`;
-
- let displayName = original.name;
- let parentGroupOrUsername = `@${original.username}`;
-
- if (original.type === groupType) {
- const splitName = original.name.split(' / ');
- displayName = splitName.pop();
- parentGroupOrUsername = splitName.pop();
- }
-
- const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : '';
-
- const disabledMentionsIcon = original.mentionsDisabled
- ? spriteIcon('notifications-off', 's16 gl-ml-3')
- : '';
-
- return `
- <div class="gl-display-flex gl-align-items-center">
- ${avatar}
- <div class="gl-line-height-normal gl-ml-4">
- <div>${escape(displayName)}${count}</div>
- <div class="gl-text-gray-700">${escape(parentGroupOrUsername)}</div>
- </div>
- ${disabledMentionsIcon}
- </div>
- `;
- },
- },
- filterValues({ assignees, collection, fullText, selectionStart }) {
- if (doesCurrentLineStartWith('/assign', fullText, selectionStart)) {
- return collection.filter((member) => !assignees.includes(member.username));
- }
-
- if (doesCurrentLineStartWith('/unassign', fullText, selectionStart)) {
- return collection.filter((member) => assignees.includes(member.username));
- }
-
- return collection;
- },
- },
-
- [GfmAutocompleteType.MergeRequests]: {
- config: {
- trigger: '!',
- lookup: (value) => `${value.iid}${value.title}`,
- menuItemLimit,
- menuItemTemplate: ({ original }) =>
- `<small>${original.reference || original.iid}</small> ${escape(original.title)}`,
- selectTemplate: ({ original }) => original.reference || `!${original.iid}`,
- },
- },
-
- [GfmAutocompleteType.Milestones]: {
- config: {
- trigger: '%',
- lookup: 'title',
- menuItemLimit,
- menuItemTemplate: ({ original }) => escape(original.title),
- selectTemplate: ({ original }) => `%"${escape(original.title)}"`,
- },
- },
-
- [GfmAutocompleteType.QuickActions]: {
- config: {
- trigger: '/',
- fillAttr: 'name',
- lookup: (value) => `${value.name}${value.aliases.join()}`,
- menuItemLimit,
- menuItemTemplate: ({ original }) => {
- const aliases = original.aliases.length
- ? `<small>(or /${original.aliases.join(', /')})</small>`
- : '';
-
- const params = original.params.length ? `<small>${original.params.join(' ')}</small>` : '';
-
- let description = '';
-
- if (original.warning) {
- const confidentialIcon =
- original.icon === 'confidential' ? spriteIcon('eye-slash', 's16 gl-mr-2') : '';
- description = `<small>${confidentialIcon}<em>${original.warning}</em></small>`;
- } else if (original.description) {
- description = `<small><em>${original.description}</em></small>`;
- }
-
- return `<div>/${original.name} ${aliases} ${params}</div>
- <div>${description}</div>`;
- },
- },
- },
-
- [GfmAutocompleteType.Snippets]: {
- config: {
- trigger: '$',
- fillAttr: 'id',
- lookup: (value) => `${value.id}${value.title}`,
- menuItemLimit,
- menuItemTemplate: ({ original }) => `<small>${original.id}</small> ${escape(original.title)}`,
- },
- },
-};
diff --git a/app/assets/javascripts/vue_shared/components/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue
index f36b9107a6e..f3b871c91b6 100644
--- a/app/assets/javascripts/vue_shared/components/help_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/help_popover.vue
@@ -33,6 +33,9 @@ export default {
<template #default>
<div v-safe-html="options.content"></div>
</template>
+ <template v-for="slot in Object.keys($slots)" #[slot]>
+ <slot :name="slot"></slot>
+ </template>
</gl-popover>
</span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 5c86c928ce3..cbf38984e23 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -8,7 +8,6 @@ import GLForm from '~/gl_form';
import axios from '~/lib/utils/axios_utils';
import { stripHtml } from '~/lib/utils/text_utility';
import { __, sprintf } from '~/locale';
-import GfmAutocomplete from '~/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue';
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MarkdownHeader from './header.vue';
@@ -20,7 +19,6 @@ function cleanUpLine(content) {
export default {
components: {
- GfmAutocomplete,
MarkdownHeader,
MarkdownToolbar,
GlIcon,
@@ -212,15 +210,16 @@ export default {
return new GLForm(
$(this.$refs['gl-form']),
{
- emojis: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- issues: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- mergeRequests: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- epics: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- milestones: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- snippets: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
+ emojis: this.enableAutocomplete,
+ members: this.enableAutocomplete,
+ issues: this.enableAutocomplete,
+ mergeRequests: this.enableAutocomplete,
+ epics: this.enableAutocomplete,
+ milestones: this.enableAutocomplete,
+ labels: this.enableAutocomplete,
+ snippets: this.enableAutocomplete,
vulnerabilities: this.enableAutocomplete,
+ contacts: this.enableAutocomplete && this.glFeatures.contactsAutocomplete,
},
true,
);
@@ -311,10 +310,7 @@ export default {
/>
<div v-show="!previewMarkdown" class="md-write-holder">
<div class="zen-backdrop">
- <gfm-autocomplete v-if="glFeatures.tributeAutocomplete">
- <slot name="textarea"></slot>
- </gfm-autocomplete>
- <slot v-else name="textarea"></slot>
+ <slot name="textarea"></slot>
<a
class="zen-control zen-control-leave js-zen-leave gl-text-gray-500"
href="#"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 3ed9de6c133..e2b6579a841 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -1,9 +1,9 @@
<script>
-import { GlPopover, GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { GlPopover, GlButton, GlTooltipDirective, GlTabs, GlTab } from '@gitlab/ui';
import $ from 'jquery';
import { keysFor, BOLD_TEXT, ITALIC_TEXT, LINK_TEXT } from '~/behaviors/shortcuts/keybindings';
import { getSelectedFragment } from '~/lib/utils/common_utils';
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
import { CopyAsGFM } from '../../../behaviors/markdown/copy_as_gfm';
import ToolbarButton from './toolbar_button.vue';
@@ -12,6 +12,8 @@ export default {
ToolbarButton,
GlPopover,
GlButton,
+ GlTabs,
+ GlTab,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -144,136 +146,143 @@ export default {
italic: keysFor(ITALIC_TEXT),
link: keysFor(LINK_TEXT),
},
+ i18n: {
+ writeTabTitle: __('Write'),
+ previewTabTitle: __('Preview'),
+ },
};
</script>
<template>
<div class="md-header">
- <ul class="nav-links clearfix">
- <li :class="{ active: !previewMarkdown }" class="md-header-tab">
- <button class="js-write-link" type="button" @click="writeMarkdownTab($event)">
- {{ __('Write') }}
- </button>
- </li>
- <li :class="{ active: previewMarkdown }" class="md-header-tab">
- <button
- class="js-preview-link js-md-preview-button"
- type="button"
- @click="previewMarkdownTab($event)"
- >
- {{ __('Preview') }}
- </button>
- </li>
- <li :class="{ active: !previewMarkdown }" class="md-header-toolbar">
- <toolbar-button
- tag="**"
- :button-title="
- sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), { modifierKey })
- "
- :shortcuts="$options.shortcuts.bold"
- icon="bold"
- />
- <toolbar-button
- tag="_"
- :button-title="
- sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), { modifierKey })
- "
- :shortcuts="$options.shortcuts.italic"
- icon="italic"
- />
- <toolbar-button
- :prepend="true"
- :tag="tag"
- :button-title="__('Insert a quote')"
- icon="quote"
- @click="handleQuote"
- />
- <template v-if="canSuggest">
+ <gl-tabs content-class="gl-display-none">
+ <gl-tab
+ title-link-class="gl-pt-3 gl-px-3 js-md-write-button"
+ :title="$options.i18n.writeTabTitle"
+ :active="!previewMarkdown"
+ data-testid="write-tab"
+ @click="writeMarkdownTab($event)"
+ />
+ <gl-tab
+ title-link-class="gl-pt-3 gl-px-3 js-md-preview-button"
+ :title="$options.i18n.previewTabTitle"
+ :active="previewMarkdown"
+ data-testid="preview-tab"
+ @click="previewMarkdownTab($event)"
+ />
+
+ <template v-if="!previewMarkdown" #tabs-end>
+ <div class="md-header-toolbar gl-ml-auto gl-pb-3 gl-justify-content-center">
+ <toolbar-button
+ tag="**"
+ :button-title="
+ sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), { modifierKey })
+ "
+ :shortcuts="$options.shortcuts.bold"
+ icon="bold"
+ />
+ <toolbar-button
+ tag="_"
+ :button-title="
+ sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), { modifierKey })
+ "
+ :shortcuts="$options.shortcuts.italic"
+ icon="italic"
+ />
<toolbar-button
- ref="suggestButton"
- :tag="mdSuggestion"
:prepend="true"
- :button-title="__('Insert suggestion')"
- :cursor-offset="4"
- :tag-content="lineContent"
- icon="doc-code"
- data-qa-selector="suggestion_button"
- class="js-suggestion-btn"
- @click="handleSuggestDismissed"
+ :tag="tag"
+ :button-title="__('Insert a quote')"
+ icon="quote"
+ @click="handleQuote"
/>
- <gl-popover
- v-if="suggestPopoverVisible"
- :target="$refs.suggestButton.$el"
- :css-classes="['diff-suggest-popover']"
- placement="bottom"
- :show="suggestPopoverVisible"
- >
- <strong>{{ __('New! Suggest changes directly') }}</strong>
- <p class="mb-2">
- {{
- __(
- 'Suggest code changes which can be immediately applied in one click. Try it out!',
- )
- }}
- </p>
- <gl-button
- variant="info"
- category="primary"
- size="small"
+ <template v-if="canSuggest">
+ <toolbar-button
+ ref="suggestButton"
+ :tag="mdSuggestion"
+ :prepend="true"
+ :button-title="__('Insert suggestion')"
+ :cursor-offset="4"
+ :tag-content="lineContent"
+ icon="doc-code"
+ data-qa-selector="suggestion_button"
+ class="js-suggestion-btn"
@click="handleSuggestDismissed"
+ />
+ <gl-popover
+ v-if="suggestPopoverVisible"
+ :target="$refs.suggestButton.$el"
+ :css-classes="['diff-suggest-popover']"
+ placement="bottom"
+ :show="suggestPopoverVisible"
>
- {{ __('Got it') }}
- </gl-button>
- </gl-popover>
- </template>
- <toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" />
- <toolbar-button
- tag="[{text}](url)"
- tag-select="url"
- :button-title="
- sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), { modifierKey })
- "
- :shortcuts="$options.shortcuts.link"
- icon="link"
- />
- <toolbar-button
- :prepend="true"
- tag="- "
- :button-title="__('Add a bullet list')"
- icon="list-bulleted"
- />
- <toolbar-button
- :prepend="true"
- tag="1. "
- :button-title="__('Add a numbered list')"
- icon="list-numbered"
- />
- <toolbar-button
- :prepend="true"
- tag="- [ ] "
- :button-title="__('Add a task list')"
- icon="list-task"
- />
- <toolbar-button
- :tag="mdCollapsibleSection"
- :prepend="true"
- tag-select="Click to expand"
- :button-title="__('Add a collapsible section')"
- icon="details-block"
- />
- <toolbar-button
- :tag="mdTable"
- :prepend="true"
- :button-title="__('Add a table')"
- icon="table"
- />
- <toolbar-button
- class="js-zen-enter"
- :prepend="true"
- :button-title="__('Go full screen')"
- icon="maximize"
- />
- </li>
- </ul>
+ <strong>{{ __('New! Suggest changes directly') }}</strong>
+ <p class="mb-2">
+ {{
+ __(
+ 'Suggest code changes which can be immediately applied in one click. Try it out!',
+ )
+ }}
+ </p>
+ <gl-button
+ variant="info"
+ category="primary"
+ size="small"
+ @click="handleSuggestDismissed"
+ >
+ {{ __('Got it') }}
+ </gl-button>
+ </gl-popover>
+ </template>
+ <toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" />
+ <toolbar-button
+ tag="[{text}](url)"
+ tag-select="url"
+ :button-title="
+ sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), { modifierKey })
+ "
+ :shortcuts="$options.shortcuts.link"
+ icon="link"
+ />
+ <toolbar-button
+ :prepend="true"
+ tag="- "
+ :button-title="__('Add a bullet list')"
+ icon="list-bulleted"
+ />
+ <toolbar-button
+ :prepend="true"
+ tag="1. "
+ :button-title="__('Add a numbered list')"
+ icon="list-numbered"
+ />
+ <toolbar-button
+ :prepend="true"
+ tag="- [ ] "
+ :button-title="__('Add a task list')"
+ icon="list-task"
+ />
+ <toolbar-button
+ :tag="mdCollapsibleSection"
+ :prepend="true"
+ tag-select="Click to expand"
+ :button-title="__('Add a collapsible section')"
+ icon="details-block"
+ />
+ <toolbar-button
+ :tag="mdTable"
+ :prepend="true"
+ :button-title="__('Add a table')"
+ icon="table"
+ />
+ <toolbar-button
+ class="js-zen-enter"
+ :prepend="true"
+ :button-title="__('Go full screen')"
+ icon="maximize"
+ />
+ </div>
+ </template>
+ </gl-tabs>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue
index 7d2af7983d1..521b1a1075a 100644
--- a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue
+++ b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue
@@ -1,34 +1,74 @@
<script>
-import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui';
+import {
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlSearchBoxByType,
+} from '@gitlab/ui';
import { __ } from '~/locale';
+export const EMPTY_NAMESPACE_ID = -1;
export const i18n = {
DEFAULT_TEXT: __('Select a new namespace'),
+ DEFAULT_EMPTY_NAMESPACE_TEXT: __('No namespace'),
GROUPS: __('Groups'),
USERS: __('Users'),
};
-const filterByName = (data, searchTerm = '') =>
- data.filter((d) => d.humanName.toLowerCase().includes(searchTerm));
+const filterByName = (data, searchTerm = '') => {
+ if (!searchTerm) {
+ return data;
+ }
+
+ return data.filter((d) => d.humanName.toLowerCase().includes(searchTerm.toLowerCase()));
+};
export default {
name: 'NamespaceSelect',
components: {
GlDropdown,
+ GlDropdownDivider,
GlDropdownItem,
GlDropdownSectionHeader,
GlSearchBoxByType,
},
props: {
- data: {
- type: Object,
- required: true,
+ groupNamespaces: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ userNamespaces: {
+ type: Array,
+ required: false,
+ default: () => [],
},
fullWidth: {
type: Boolean,
required: false,
default: false,
},
+ defaultText: {
+ type: String,
+ required: false,
+ default: i18n.DEFAULT_TEXT,
+ },
+ includeHeaders: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ emptyNamespaceTitle: {
+ type: String,
+ required: false,
+ default: i18n.DEFAULT_EMPTY_NAMESPACE_TEXT,
+ },
+ includeEmptyNamespace: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -38,21 +78,33 @@ export default {
},
computed: {
hasUserNamespaces() {
- return this.data.user?.length;
+ return this.userNamespaces.length;
},
hasGroupNamespaces() {
- return this.data.group?.length;
+ return this.groupNamespaces.length;
},
filteredGroupNamespaces() {
if (!this.hasGroupNamespaces) return [];
- return filterByName(this.data.group, this.searchTerm);
+ return filterByName(this.groupNamespaces, this.searchTerm);
},
filteredUserNamespaces() {
if (!this.hasUserNamespaces) return [];
- return filterByName(this.data.user, this.searchTerm);
+ return filterByName(this.userNamespaces, this.searchTerm);
},
selectedNamespaceText() {
- return this.selectedNamespace?.humanName || this.$options.i18n.DEFAULT_TEXT;
+ return this.selectedNamespace?.humanName || this.defaultText;
+ },
+ filteredEmptyNamespaceTitle() {
+ const { includeEmptyNamespace, emptyNamespaceTitle, searchTerm } = this;
+
+ if (!includeEmptyNamespace) {
+ return '';
+ }
+ if (!searchTerm) {
+ return emptyNamespaceTitle;
+ }
+
+ return emptyNamespaceTitle.toLowerCase().includes(searchTerm.toLowerCase());
},
},
methods: {
@@ -60,31 +112,47 @@ export default {
this.selectedNamespace = item;
this.$emit('select', item);
},
+ handleSelectEmptyNamespace() {
+ this.handleSelect({ id: EMPTY_NAMESPACE_ID, humanName: this.emptyNamespaceTitle });
+ },
},
i18n,
};
</script>
<template>
- <gl-dropdown :text="selectedNamespaceText" :block="fullWidth">
+ <gl-dropdown :text="selectedNamespaceText" :block="fullWidth" data-qa-selector="namespaces_list">
<template #header>
<gl-search-box-by-type v-model.trim="searchTerm" />
</template>
- <div v-if="hasGroupNamespaces" class="qa-namespaces-list-groups">
- <gl-dropdown-section-header>{{ $options.i18n.GROUPS }}</gl-dropdown-section-header>
+ <div v-if="filteredEmptyNamespaceTitle">
+ <gl-dropdown-item
+ data-qa-selector="namespaces_list_item"
+ @click="handleSelectEmptyNamespace()"
+ >
+ {{ emptyNamespaceTitle }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ </div>
+ <div v-if="hasGroupNamespaces" data-qa-selector="namespaces_list_groups">
+ <gl-dropdown-section-header v-if="includeHeaders">{{
+ $options.i18n.GROUPS
+ }}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="item in filteredGroupNamespaces"
:key="item.id"
- class="qa-namespaces-list-item"
+ data-qa-selector="namespaces_list_item"
@click="handleSelect(item)"
>{{ item.humanName }}</gl-dropdown-item
>
</div>
- <div v-if="hasUserNamespaces" class="qa-namespaces-list-users">
- <gl-dropdown-section-header>{{ $options.i18n.USERS }}</gl-dropdown-section-header>
+ <div v-if="hasUserNamespaces" data-qa-selector="namespaces_list_users">
+ <gl-dropdown-section-header v-if="includeHeaders">{{
+ $options.i18n.USERS
+ }}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="item in filteredUserNamespaces"
:key="item.id"
- class="qa-namespaces-list-item"
+ data-qa-selector="namespaces_list_item"
@click="handleSelect(item)"
>{{ item.humanName }}</gl-dropdown-item
>
diff --git a/app/assets/javascripts/vue_shared/components/pikaday.vue b/app/assets/javascripts/vue_shared/components/pikaday.vue
deleted file mode 100644
index 3c0ac32e512..00000000000
--- a/app/assets/javascripts/vue_shared/components/pikaday.vue
+++ /dev/null
@@ -1,48 +0,0 @@
-<script>
-import { GlDatepicker } from '@gitlab/ui';
-import { pikadayToString } from '~/lib/utils/datetime_utility';
-
-export default {
- name: 'DatePicker',
- components: {
- GlDatepicker,
- },
- props: {
- selectedDate: {
- type: Date,
- required: false,
- default: null,
- },
- minDate: {
- type: Date,
- required: false,
- default: null,
- },
- maxDate: {
- type: Date,
- required: false,
- default: null,
- },
- },
- methods: {
- selected(date) {
- this.$emit('newDateSelected', pikadayToString(date));
- },
- toggled() {
- this.$emit('hidePicker');
- },
- },
-};
-</script>
-
-<template>
- <gl-datepicker
- :value="selectedDate"
- :min-date="minDate"
- :max-date="maxDate"
- start-opened
- @close="toggled"
- @click="toggled"
- @input="selected"
- />
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
index d886a67fff7..5d144c0d699 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
@@ -13,7 +13,7 @@ export default {
},
modalId: 'runner-instructions-modal',
i18n: {
- buttonText: s__('Runners|Show Runner installation instructions'),
+ buttonText: s__('Runners|Show runner installation instructions'),
},
data() {
return {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue
deleted file mode 100644
index 460a10e08ed..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue
+++ /dev/null
@@ -1,49 +0,0 @@
-<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-
-export default {
- name: 'CollapsedCalendarIcon',
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- components: {
- GlIcon,
- },
- props: {
- containerClass: {
- type: String,
- required: false,
- default: '',
- },
- text: {
- type: String,
- required: false,
- default: '',
- },
- showIcon: {
- type: Boolean,
- required: false,
- default: true,
- },
- tooltipText: {
- type: String,
- required: false,
- default: '',
- },
- },
- methods: {
- click() {
- this.$emit('click');
- },
- },
-};
-</script>
-
-<template>
- <div v-gl-tooltip.left.viewport="tooltipText" :class="containerClass" @click="click">
- <gl-icon v-if="showIcon" name="calendar" />
- <slot>
- <span> {{ text }} </span>
- </slot>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue
deleted file mode 100644
index 4531fafbf72..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue
+++ /dev/null
@@ -1,148 +0,0 @@
-<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-import { __ } from '~/locale';
-import { dateInWords } from '../../../lib/utils/datetime_utility';
-import datePicker from '../pikaday.vue';
-import collapsedCalendarIcon from './collapsed_calendar_icon.vue';
-import toggleSidebar from './toggle_sidebar.vue';
-
-export default {
- name: 'SidebarDatePicker',
- components: {
- datePicker,
- toggleSidebar,
- collapsedCalendarIcon,
- GlLoadingIcon,
- },
- props: {
- blockClass: {
- type: String,
- required: false,
- default: '',
- },
- collapsed: {
- type: Boolean,
- required: false,
- default: true,
- },
- showToggleSidebar: {
- type: Boolean,
- required: false,
- default: false,
- },
- isLoading: {
- type: Boolean,
- required: false,
- default: false,
- },
- editable: {
- type: Boolean,
- required: false,
- default: false,
- },
- label: {
- type: String,
- required: false,
- default: __('Date picker'),
- },
- selectedDate: {
- type: Date,
- required: false,
- default: null,
- },
- minDate: {
- type: Date,
- required: false,
- default: null,
- },
- maxDate: {
- type: Date,
- required: false,
- default: null,
- },
- },
- data() {
- return {
- editing: false,
- };
- },
- computed: {
- selectedAndEditable() {
- return this.selectedDate && this.editable;
- },
- selectedDateWords() {
- return dateInWords(this.selectedDate, true);
- },
- collapsedText() {
- return this.selectedDateWords ? this.selectedDateWords : __('None');
- },
- },
- methods: {
- stopEditing() {
- this.editing = false;
- },
- toggleDatePicker() {
- this.editing = !this.editing;
- },
- newDateSelected(date = null) {
- this.date = date;
- this.editing = false;
- this.$emit('saveDate', date);
- },
- toggleSidebar() {
- this.$emit('toggleCollapse');
- },
- },
-};
-</script>
-
-<template>
- <div :class="blockClass" class="block">
- <div class="issuable-sidebar-header">
- <toggle-sidebar :collapsed="collapsed" @toggle="toggleSidebar" />
- </div>
- <collapsed-calendar-icon :text="collapsedText" class="sidebar-collapsed-icon" />
- <div class="title">
- {{ label }}
- <gl-loading-icon v-if="isLoading" size="sm" :inline="true" />
- <div class="float-right">
- <button
- v-if="editable && !editing"
- type="button"
- class="btn-blank btn-link btn-primary-hover-link btn-sidebar-action"
- @click="toggleDatePicker"
- >
- {{ __('Edit') }}
- </button>
- <toggle-sidebar v-if="showToggleSidebar" :collapsed="collapsed" @toggle="toggleSidebar" />
- </div>
- </div>
- <div class="value">
- <date-picker
- v-if="editing"
- :selected-date="selectedDate"
- :min-date="minDate"
- :max-date="maxDate"
- :label="label"
- @newDateSelected="newDateSelected"
- @hidePicker="stopEditing"
- />
- <span v-else class="value-content">
- <template v-if="selectedDate">
- <strong>{{ selectedDateWords }}</strong>
- <span v-if="selectedAndEditable" class="no-value">
- -
- <button
- type="button"
- class="btn-blank btn-link btn-secondary-hover-link"
- @click="newDateSelected(null)"
- >
- {{ __('remove') }}
- </button>
- </span>
- </template>
- <span v-else class="no-value">{{ __('None') }}</span>
- </span>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
index b99083713a8..88977652556 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
@@ -117,7 +117,11 @@ export default {
labelCreate: { label },
},
},
- ) => this.updateLabelsInCache(store, label),
+ ) => {
+ if (label) {
+ this.updateLabelsInCache(store, label);
+ }
+ },
});
if (labelCreate.errors.length) {
[this.error] = labelCreate.errors;
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
deleted file mode 100644
index 17904f20341..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
+++ /dev/null
@@ -1,37 +0,0 @@
-<script>
-import { GlDropdown, GlDropdownForm, GlDropdownDivider } from '@gitlab/ui';
-
-export default {
- components: {
- GlDropdownForm,
- GlDropdown,
- GlDropdownDivider,
- },
- props: {
- headerText: {
- type: String,
- required: true,
- },
- text: {
- type: String,
- required: true,
- },
- },
-};
-</script>
-
-<template>
- <gl-dropdown class="show" :text="text" @toggle="$emit('toggle')">
- <template #header>
- <p class="gl-font-weight-bold gl-text-center gl-mt-2 gl-mb-4">{{ headerText }}</p>
- <gl-dropdown-divider />
- <slot name="search"></slot>
- </template>
- <gl-dropdown-form>
- <slot name="items"></slot>
- </gl-dropdown-form>
- <template #footer>
- <slot name="footer"></slot>
- </template>
- </gl-dropdown>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
new file mode 100644
index 00000000000..9efe0147c37
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
@@ -0,0 +1,111 @@
+// Language map from Rouge::Lexer to highlight.js
+// Rouge::Lexer - We use it on the BE to determine the language of a source file (https://github.com/rouge-ruby/rouge/blob/master/docs/Languages.md).
+// Highlight.js - We use it on the FE to highlight the syntax of a source file (https://github.com/highlightjs/highlight.js/tree/main/src/languages).
+export const ROUGE_TO_HLJS_LANGUAGE_MAP = {
+ bsl: '1c',
+ actionscript: 'actionscript',
+ ada: 'ada',
+ apache: 'apache',
+ applescript: 'applescript',
+ armasm: 'armasm',
+ awk: 'awk',
+ c: 'c',
+ ceylon: 'ceylon',
+ clean: 'clean',
+ clojure: 'clojure',
+ cmake: 'cmake',
+ coffeescript: 'coffeescript',
+ coq: 'coq',
+ cpp: 'cpp',
+ crystal: 'crystal',
+ csharp: 'csharp',
+ css: 'css',
+ d: 'd',
+ dart: 'dart',
+ pascal: 'delphi',
+ diff: 'diff',
+ jinja: 'django',
+ docker: 'dockerfile',
+ batchfile: 'dos',
+ elixir: 'elixir',
+ elm: 'elm',
+ erb: 'erb',
+ erlang: 'erlang',
+ fortran: 'fortran',
+ fsharp: 'fsharp',
+ gherkin: 'gherkin',
+ glsl: 'glsl',
+ go: 'go',
+ gradle: 'gradle',
+ groovy: 'groovy',
+ haml: 'haml',
+ handlebars: 'handlebars',
+ haskell: 'haskell',
+ haxe: 'haxe',
+ http: 'http',
+ hylang: 'hy',
+ ini: 'ini',
+ isbl: 'isbl',
+ java: 'java',
+ javascript: 'javascript',
+ json: 'json',
+ julia: 'julia',
+ kotlin: 'kotlin',
+ lasso: 'lasso',
+ tex: 'latex',
+ common_lisp: 'lisp',
+ livescript: 'livescript',
+ llvm: 'llvm',
+ hlsl: 'lsl',
+ lua: 'lua',
+ make: 'makefile',
+ markdown: 'markdown',
+ mathematica: 'mathematica',
+ matlab: 'matlab',
+ moonscript: 'moonscript',
+ nginx: 'nginx',
+ nim: 'nim',
+ nix: 'nix',
+ objective_c: 'objectivec',
+ ocaml: 'ocaml',
+ perl: 'perl',
+ php: 'php',
+ plaintext: 'plaintext',
+ pony: 'pony',
+ powershell: 'powershell',
+ prolog: 'prolog',
+ properties: 'properties',
+ protobuf: 'protobuf',
+ puppet: 'puppet',
+ python: 'python',
+ q: 'q',
+ qml: 'qml',
+ r: 'r',
+ reasonml: 'reasonml',
+ ruby: 'ruby',
+ rust: 'rust',
+ sas: 'sas',
+ scala: 'scala',
+ scheme: 'scheme',
+ scss: 'scss',
+ shell: 'shell',
+ smalltalk: 'smalltalk',
+ sml: 'sml',
+ sqf: 'sqf',
+ sql: 'sql',
+ stan: 'stan',
+ stata: 'stata',
+ swift: 'swift',
+ tap: 'tap',
+ tcl: 'tcl',
+ twig: 'twig',
+ typescript: 'typescript',
+ vala: 'vala',
+ vb: 'vbnet',
+ verilog: 'verilog',
+ vhdl: 'vhdl',
+ viml: 'vim',
+ xml: 'xml',
+ xquery: 'xquery',
+ yaml: 'yaml',
+};
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
index 99895926653..5aae1812de3 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
@@ -1,36 +1,31 @@
<script>
-import { GlSafeHtmlDirective } from '@gitlab/ui';
+import { GlSafeHtmlDirective, GlLoadingIcon } from '@gitlab/ui';
import LineNumbers from '~/vue_shared/components/line_numbers.vue';
import { sanitize } from '~/lib/dompurify';
+import { ROUGE_TO_HLJS_LANGUAGE_MAP } from './constants';
+import { wrapLines } from './utils';
const LINE_SELECT_CLASS_NAME = 'hll';
export default {
components: {
LineNumbers,
+ GlLoadingIcon,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
},
props: {
- content: {
- type: String,
+ blob: {
+ type: Object,
required: true,
},
- language: {
- type: String,
- required: false,
- default: 'plaintext',
- },
- autoDetect: {
- type: Boolean,
- required: false,
- default: false,
- },
},
data() {
return {
languageDefinition: null,
+ content: this.blob.rawTextBlob,
+ language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language],
hljs: null,
};
},
@@ -42,14 +37,14 @@ export default {
let highlightedContent;
if (this.hljs) {
- if (this.autoDetect) {
+ if (!this.language) {
highlightedContent = this.hljs.highlightAuto(this.content).value;
} else if (this.languageDefinition) {
highlightedContent = this.hljs.highlight(this.content, { language: this.language }).value;
}
}
- return this.wrapLines(highlightedContent);
+ return wrapLines(highlightedContent);
},
},
watch: {
@@ -63,14 +58,14 @@ export default {
async mounted() {
this.hljs = await this.loadHighlightJS();
- if (!this.autoDetect) {
+ if (this.language) {
this.languageDefinition = await this.loadLanguage();
}
},
methods: {
loadHighlightJS() {
- // With auto-detect enabled we load all common languages else we load only the core (smallest footprint)
- return this.autoDetect ? import('highlight.js/lib/common') : import('highlight.js/lib/core');
+ // If no language can be mapped to highlight.js we load all common languages else we load only the core (smallest footprint)
+ return !this.language ? import('highlight.js/lib/common') : import('highlight.js/lib/core');
},
async loadLanguage() {
let languageDefinition;
@@ -84,15 +79,6 @@ export default {
return languageDefinition;
},
- wrapLines(content) {
- return (
- content &&
- content
- .split('\n')
- .map((line, i) => `<span id="LC${i + 1}" class="line">${line}</span>`)
- .join('\r\n')
- );
- },
selectLine() {
const hash = sanitize(this.$route.hash);
const lineToSelect = hash && this.$el.querySelector(hash);
@@ -115,9 +101,16 @@ export default {
};
</script>
<template>
- <div class="file-content code js-syntax-highlight" :class="$options.userColorScheme">
+ <gl-loading-icon v-if="!highlightedContent" size="sm" class="gl-my-5" />
+ <div
+ v-else
+ class="file-content code js-syntax-highlight blob-content gl-display-flex"
+ :class="$options.userColorScheme"
+ data-type="simple"
+ data-qa-selector="blob_viewer_file_content"
+ >
<line-numbers :lines="lineNumbers" />
- <pre class="code"><code v-safe-html="highlightedContent"></code>
+ <pre class="code gl-pb-0!"><code v-safe-html="highlightedContent"></code>
</pre>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/utils.js b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js
new file mode 100644
index 00000000000..e64e564bf61
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js
@@ -0,0 +1,26 @@
+export const wrapLines = (content) => {
+ return (
+ content &&
+ content
+ .split('\n')
+ .map((line, i) => {
+ let formattedLine;
+ const idAttribute = `id="LC${i + 1}"`;
+
+ if (line.includes('<span class="hljs') && !line.includes('</span>')) {
+ /**
+ * In some cases highlight.js will wrap multiple lines in a span, in these cases we want to append the line number to the existing span
+ *
+ * example (before): <span class="hljs-code">```bash
+ * example (after): <span id="LC67" class="hljs-code">```bash
+ */
+ formattedLine = line.replace(/(?=class="hljs)/, `${idAttribute} `);
+ } else {
+ formattedLine = `<span ${idAttribute} class="line">${line}</span>`;
+ }
+
+ return formattedLine;
+ })
+ .join('\n')
+ );
+};
diff --git a/app/assets/javascripts/vue_shared/components/svg_gradient.vue b/app/assets/javascripts/vue_shared/components/svg_gradient.vue
deleted file mode 100644
index 5ce45d492f9..00000000000
--- a/app/assets/javascripts/vue_shared/components/svg_gradient.vue
+++ /dev/null
@@ -1,34 +0,0 @@
-<script>
-export default {
- props: {
- colors: {
- type: Array,
- required: true,
- validator(value) {
- return value.length === 2;
- },
- },
- opacity: {
- type: Array,
- required: true,
- validator(value) {
- return value.length === 2;
- },
- },
- identifierName: {
- type: String,
- required: true,
- },
- },
-};
-</script>
-<template>
- <svg height="0" width="0">
- <defs>
- <linearGradient :id="identifierName">
- <stop :stop-color="colors[0]" :stop-opacity="opacity[0]" offset="0%" />
- <stop :stop-color="colors[1]" :stop-opacity="opacity[1]" offset="100%" />
- </linearGradient>
- </defs>
- </svg>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
index 0a7a22ed3a8..62de76e46b5 100644
--- a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
+++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
@@ -41,6 +41,16 @@ export default {
required: false,
default: false,
},
+ inputFieldName: {
+ type: String,
+ required: false,
+ default: 'upload_file',
+ },
+ shouldUpdateInputOnFileDrop: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -84,6 +94,30 @@ export default {
return;
}
+ // NOTE: This is a temporary solution to integrate dropzone into a Rails
+ // form. On file drop if `shouldUpdateInputOnFileDrop` is true, the file
+ // input value is updated. So that when the form is submitted — the file
+ // value would be send together with the form data. This solution should
+ // be removed when License file upload page is fully migrated:
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/352501
+ // NOTE: as per https://caniuse.com/mdn-api_htmlinputelement_files, IE11
+ // is not able to set input.files property, thought the user would still
+ // be able to use the file picker dialogue option, by clicking the
+ // "openFileUpload" button
+ if (this.shouldUpdateInputOnFileDrop) {
+ // Since FileList cannot be easily manipulated, to match requirement of
+ // singleFileSelection, we're throwing an error if multiple files were
+ // dropped on the dropzone
+ // NOTE: we can drop this logic together with
+ // `shouldUpdateInputOnFileDrop` flag
+ if (this.singleFileSelection && files.length > 1) {
+ this.$emit('error');
+ return;
+ }
+
+ this.$refs.fileUpload.files = files;
+ }
+
this.$emit('change', this.singleFileSelection ? files[0] : files);
},
ondragenter(e) {
@@ -116,6 +150,7 @@ export default {
<slot>
<button
class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3"
+ type="button"
@click="openFileUpload"
>
<div
@@ -147,7 +182,7 @@ export default {
<input
ref="fileUpload"
type="file"
- name="upload_file"
+ :name="inputFieldName"
:accept="validFileMimetypes"
class="hide"
:multiple="!singleFileSelection"
diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
index f02cd5c4e2e..82022d1f4d6 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -1,9 +1,9 @@
<script>
-import $ from 'jquery';
import { GlModal, GlSprintf, GlLink } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import ActionsButton from '~/vue_shared/components/actions_button.vue';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import ConfirmForkModal from '~/vue_shared/components/confirm_fork_modal.vue';
const KEY_EDIT = 'edit';
const KEY_WEB_IDE = 'webide';
@@ -16,6 +16,7 @@ export default {
GlModal,
GlSprintf,
GlLink,
+ ConfirmForkModal,
},
i18n: {
modal: {
@@ -103,11 +104,22 @@ export default {
required: false,
default: false,
},
+ forkPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ forkModalId: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
selection: KEY_WEB_IDE,
showEnableGitpodModal: false,
+ showForkModal: false,
};
},
computed: {
@@ -128,7 +140,7 @@ export default {
return;
}
- this.showJQueryModal('#modal-confirm-fork-edit');
+ this.showModal('showForkModal');
},
}
: { href: this.editUrl };
@@ -171,7 +183,7 @@ export default {
return;
}
- this.showJQueryModal('#modal-confirm-fork-webide');
+ this.showModal('showForkModal');
},
}
: { href: this.webIdeUrl };
@@ -247,9 +259,6 @@ export default {
select(key) {
this.selection = key;
},
- showJQueryModal(id) {
- $(id).modal('show');
- },
showModal(dataKey) {
this[dataKey] = true;
},
@@ -282,5 +291,11 @@ export default {
</template>
</gl-sprintf>
</gl-modal>
+ <confirm-fork-modal
+ v-if="showWebIdeButton || showEditButton"
+ v-model="showForkModal"
+ :modal-id="forkModalId"
+ :fork-path="forkPath"
+ />
</div>
</template>
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 af0235bfc69..8008b85bbdb 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
@@ -31,10 +31,6 @@ export default {
type: Object,
required: true,
},
- enableLabelPermalinks: {
- type: Boolean,
- required: true,
- },
labelFilterParam: {
type: String,
required: false,
@@ -121,7 +117,10 @@ export default {
},
showIssuableMeta() {
return Boolean(
- this.hasSlotContents('status') || this.showDiscussions || this.issuable.assignees,
+ this.hasSlotContents('status') ||
+ this.hasSlotContents('statistics') ||
+ this.showDiscussions ||
+ this.issuable.assignees,
);
},
issuableNotesLink() {
@@ -139,11 +138,8 @@ export default {
return label.title || label.name;
},
labelTarget(label) {
- if (this.enableLabelPermalinks) {
- const value = encodeURIComponent(this.labelTitle(label));
- return `?${this.labelFilterParam}[]=${value}`;
- }
- return '#';
+ const value = encodeURIComponent(this.labelTitle(label));
+ return `?${this.labelFilterParam}[]=${value}`;
},
/**
* This is needed as an independent method since
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 2f8401b45f0..028d48e7e8a 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
@@ -15,6 +15,7 @@ const VueDraggable = () => import('vuedraggable');
export default {
vueDraggableAttributes: {
animation: 200,
+ forceFallback: true,
ghostClass: 'gl-visibility-hidden',
tag: 'ul',
},
@@ -78,6 +79,11 @@ export default {
required: false,
default: null,
},
+ truncateCounts: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
currentTab: {
type: String,
required: true,
@@ -127,11 +133,6 @@ export default {
required: false,
default: 2,
},
- enableLabelPermalinks: {
- type: Boolean,
- required: false,
- default: true,
- },
labelFilterParam: {
type: String,
required: false,
@@ -261,6 +262,7 @@ export default {
:tabs="tabs"
:tab-counts="tabCounts"
:current-tab="currentTab"
+ :truncate-counts="truncateCounts"
@click="$emit('click-tab', $event)"
>
<template #nav-actions>
@@ -314,7 +316,6 @@ export default {
:data-qa-issuable-title="issuable.title"
:issuable-symbol="issuableSymbol"
:issuable="issuable"
- :enable-label-permalinks="enableLabelPermalinks"
:label-filter-param="labelFilterParam"
:show-checkbox="showBulkEditSidebar"
:checked="issuableChecked(issuable)"
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue
index 9bf54e98cc4..0691bc02b5c 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue
@@ -1,5 +1,6 @@
<script>
import { GlTabs, GlTab, GlBadge } from '@gitlab/ui';
+import { numberToMetricPrefix } from '~/lib/utils/number_utils';
import { formatNumber } from '~/locale';
export default {
@@ -22,6 +23,11 @@ export default {
type: String,
required: true,
},
+ truncateCounts: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
methods: {
isTabActive(tabName) {
@@ -31,7 +37,7 @@ export default {
return Number.isInteger(this.tabCounts[tab.name]);
},
formatNumber(count) {
- return formatNumber(count);
+ return this.truncateCounts ? numberToMetricPrefix(count) : formatNumber(count);
},
},
};
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 d7da533d055..ee7e113af72 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
@@ -102,7 +102,7 @@ export default {
</div>
</div>
<span>
- {{ __('Opened') }}
+ {{ __('Created') }}
<time-ago-tooltip data-testid="startTimeItem" :time="createdAt" />
{{ __('by') }}
</span>
diff --git a/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue b/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue
index 99dcccd12ed..774267639fc 100644
--- a/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue
+++ b/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue
@@ -1,8 +1,8 @@
<script>
import { GlIcon } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
-import Cookies from 'js-cookie';
-import { parseBoolean } from '~/lib/utils/common_utils';
+import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils';
+
import { USER_COLLAPSED_GUTTER_COOKIE } from '../constants';
export default {
@@ -10,7 +10,7 @@ export default {
GlIcon,
},
data() {
- const userExpanded = !parseBoolean(Cookies.get(USER_COLLAPSED_GUTTER_COOKIE));
+ const userExpanded = !parseBoolean(getCookie(USER_COLLAPSED_GUTTER_COOKIE));
// We're deliberately keeping two different props for sidebar status;
// 1. userExpanded reflects value based on cookie `collapsed_gutter`.
@@ -46,7 +46,7 @@ export default {
this.isExpanded = !this.isExpanded;
this.userExpanded = this.isExpanded;
- Cookies.set(USER_COLLAPSED_GUTTER_COOKIE, !this.userExpanded);
+ setCookie(USER_COLLAPSED_GUTTER_COOKIE, !this.userExpanded);
this.updatePageContainerClass();
},
},
diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
index f67e590e2ce..1f3cc663848 100644
--- a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
+++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
@@ -11,7 +11,7 @@ export default {
WelcomePage,
LegacyContainer,
CreditCardVerification: () =>
- import('ee_component/pages/groups/new/components/credit_card_verification.vue'),
+ import('ee_component/namespaces/verification/components/credit_card_verification.vue'),
},
directives: {
SafeHtml,
diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
index d1630c9ac13..3afd1f9410b 100644
--- a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
+++ b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
@@ -14,7 +14,7 @@ export default {
components: {
GlButton,
},
- inject: ['projectPath'],
+ inject: ['projectFullPath'],
props: {
feature: {
type: Object,
@@ -47,7 +47,7 @@ export default {
try {
const { mutationSettings } = this;
const { data } = await this.$apollo.mutate(
- mutationSettings.getMutationPayload(this.projectPath),
+ mutationSettings.getMutationPayload(this.projectFullPath),
);
const { errors, successPath } = data[mutationSettings.mutationId];
diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
index 12f2bc71505..f6d85599dba 100644
--- a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
+++ b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
@@ -102,8 +102,8 @@ export default {
error(error) {
this.showError(error);
},
- result({ loading }) {
- if (loading) {
+ result({ loading, data }) {
+ if (loading || !data) {
return;
}
diff --git a/app/assets/javascripts/work_items/graphql/fragmentTypes.json b/app/assets/javascripts/work_items/graphql/fragmentTypes.json
deleted file mode 100644
index 3b837e84ee9..00000000000
--- a/app/assets/javascripts/work_items/graphql/fragmentTypes.json
+++ /dev/null
@@ -1 +0,0 @@
-{"__schema":{"types":[{"kind":"INTERFACE","name":"LocalWorkItemWidget","possibleTypes":[{"name":"LocalTitleWidget"}]}]}}
diff --git a/app/assets/javascripts/work_items/graphql/project_work_item_types.query.graphql b/app/assets/javascripts/work_items/graphql/project_work_item_types.query.graphql
new file mode 100644
index 00000000000..e7e3ce8c1ae
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/project_work_item_types.query.graphql
@@ -0,0 +1,11 @@
+query projectWorkItemTypes($fullPath: ID!) {
+ workspace: project(fullPath: $fullPath) {
+ id
+ workItemTypes {
+ nodes {
+ id
+ name
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js
index fb536a425c0..676fffb12d8 100644
--- a/app/assets/javascripts/work_items/graphql/provider.js
+++ b/app/assets/javascripts/work_items/graphql/provider.js
@@ -1,23 +1,14 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import createDefaultClient from '~/lib/graphql';
import workItemQuery from './work_item.query.graphql';
-import introspectionQueryResultData from './fragmentTypes.json';
import { resolvers } from './resolvers';
import typeDefs from './typedefs.graphql';
-const fragmentMatcher = new IntrospectionFragmentMatcher({
- introspectionQueryResultData,
-});
-
export function createApolloProvider() {
Vue.use(VueApollo);
const defaultClient = createDefaultClient(resolvers, {
- cacheConfig: {
- fragmentMatcher,
- },
typeDefs,
});
diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js
index 7cc8a23b7b1..10fae9b9cc0 100644
--- a/app/assets/javascripts/work_items/index.js
+++ b/app/assets/javascripts/work_items/index.js
@@ -5,11 +5,15 @@ import { createApolloProvider } from './graphql/provider';
export const initWorkItemsRoot = () => {
const el = document.querySelector('#js-work-items');
+ const { fullPath } = el.dataset;
return new Vue({
el,
router: createRouter(el.dataset.fullPath),
apolloProvider: createApolloProvider(),
+ provide: {
+ fullPath,
+ },
render(createElement) {
return createElement(App);
},
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 12bad5606d4..6c3bcf8f6a8 100644
--- a/app/assets/javascripts/work_items/pages/create_work_item.vue
+++ b/app/assets/javascripts/work_items/pages/create_work_item.vue
@@ -1,6 +1,8 @@
<script>
-import { GlButton, GlAlert } from '@gitlab/ui';
+import { GlButton, GlAlert, GlLoadingIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { s__ } from '~/locale';
import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql';
+import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql';
import ItemTitle from '../components/item_title.vue';
@@ -8,14 +10,55 @@ export default {
components: {
GlButton,
GlAlert,
+ GlLoadingIcon,
+ GlDropdown,
+ GlDropdownItem,
ItemTitle,
},
+ inject: ['fullPath'],
+ props: {
+ isModal: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ initialTitle: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
data() {
return {
- title: '',
- error: false,
+ title: this.initialTitle,
+ error: null,
+ workItemTypes: [],
+ selectedWorkItemType: null,
};
},
+ apollo: {
+ workItemTypes: {
+ query: projectWorkItemTypesQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ };
+ },
+ update(data) {
+ return data.workspace?.workItemTypes?.nodes;
+ },
+ error() {
+ this.error = s__(
+ 'WorkItem|Something went wrong when fetching work item types. Please try again',
+ );
+ },
+ },
+ },
+ computed: {
+ dropdownButtonText() {
+ return this.selectedWorkItemType?.name || s__('WorkItem|Type');
+ },
+ },
methods: {
async createWorkItem() {
try {
@@ -35,35 +78,82 @@ export default {
},
},
} = response;
- this.$router.push({ name: 'workItem', params: { id } });
+ if (!this.isModal) {
+ this.$router.push({ name: 'workItem', params: { id } });
+ } else {
+ this.$emit('onCreate', this.title);
+ }
} catch {
- this.error = true;
+ this.error = s__(
+ 'WorkItem|Something went wrong when creating a work item. Please try again',
+ );
}
},
handleTitleInput(title) {
this.title = title;
},
+ handleCancelClick() {
+ if (!this.isModal) {
+ this.$router.go(-1);
+ return;
+ }
+ this.$emit('closeModal');
+ },
+ selectWorkItemType(type) {
+ this.selectedWorkItemType = type;
+ },
},
};
</script>
<template>
<form @submit.prevent="createWorkItem">
- <gl-alert v-if="error" variant="danger" @dismiss="error = false">{{
- __('Something went wrong when creating a work item. Please try again')
- }}</gl-alert>
- <item-title data-testid="title-input" @title-input="handleTitleInput" />
- <div class="gl-bg-gray-10 gl-py-5 gl-px-6">
+ <gl-alert v-if="error" variant="danger" @dismiss="error = null">{{ error }}</gl-alert>
+ <div :class="{ 'gl-px-5': isModal }" data-testid="content">
+ <item-title
+ :initial-title="title"
+ data-testid="title-input"
+ @title-input="handleTitleInput"
+ />
+ <div>
+ <gl-dropdown :text="dropdownButtonText">
+ <gl-loading-icon
+ v-if="$apollo.queries.workItemTypes.loading"
+ size="md"
+ data-testid="loading-types"
+ />
+ <template v-else>
+ <gl-dropdown-item
+ v-for="type in workItemTypes"
+ :key="type.id"
+ @click="selectWorkItemType(type)"
+ >
+ {{ type.name }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+ </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 }"
+ >
<gl-button
variant="confirm"
:disabled="title.length === 0"
- class="gl-mr-3"
+ :class="{ 'gl-mr-3': !isModal }"
data-testid="create-button"
type="submit"
>
- {{ __('Create') }}
+ {{ s__('WorkItem|Create work item') }}
</gl-button>
- <gl-button type="button" data-testid="cancel-button" @click="$router.go(-1)">
+ <gl-button
+ type="button"
+ data-testid="cancel-button"
+ class="gl-order-n1"
+ :class="{ 'gl-mr-3': isModal }"
+ @click="handleCancelClick"
+ >
{{ __('Cancel') }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/work_items_hierarchy/components/app.vue b/app/assets/javascripts/work_items_hierarchy/components/app.vue
new file mode 100644
index 00000000000..621cfe5bace
--- /dev/null
+++ b/app/assets/javascripts/work_items_hierarchy/components/app.vue
@@ -0,0 +1,101 @@
+<script>
+import { GlBanner } from '@gitlab/ui';
+import Cookies from 'js-cookie';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import RESPONSE from '../static_response';
+import { WORK_ITEMS_SURVEY_COOKIE_NAME, workItemTypes } from '../constants';
+import Hierarchy from './hierarchy.vue';
+
+export default {
+ components: {
+ GlBanner,
+ Hierarchy,
+ },
+ inject: ['illustrationPath', 'licensePlan'],
+ data() {
+ return {
+ bannerVisible: !parseBoolean(Cookies.get(WORK_ITEMS_SURVEY_COOKIE_NAME)),
+ workItemHierarchy: RESPONSE[this.licensePlan],
+ };
+ },
+ computed: {
+ hasUnavailableStructure() {
+ return this.workItemTypes.unavailable.length > 0;
+ },
+ workItemTypes() {
+ return this.workItemHierarchy.reduce(
+ (itemTypes, item) => {
+ const skipItem = workItemTypes[item.type].isWorkItem && !window.gon?.features?.workItems;
+
+ if (skipItem) {
+ return itemTypes;
+ }
+ const key = item.available ? 'available' : 'unavailable';
+ const nestedTypes = item.nestedTypes?.map((type) => workItemTypes[type]);
+
+ itemTypes[key].push({
+ ...item,
+ ...workItemTypes[item.type],
+ nestedTypes,
+ });
+
+ return itemTypes;
+ },
+ { available: [], unavailable: [] },
+ );
+ },
+ },
+ methods: {
+ handleClose() {
+ Cookies.set(WORK_ITEMS_SURVEY_COOKIE_NAME, 'true', { expires: 365 * 10 });
+ this.bannerVisible = false;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-banner
+ v-if="bannerVisible"
+ class="gl-mt-4 gl-px-5!"
+ :title="s__('Hierarchy|Help us improve work items in GitLab!')"
+ :button-text="s__('Hierarchy|Take the work items survey')"
+ button-link="https://forms.gle/u1BmRp8rTbwj52iq5"
+ :svg-path="illustrationPath"
+ @close="handleClose"
+ >
+ <p>
+ {{
+ s__(
+ 'Hierarchy|Is there a framework or type of work item you wish you had access to in GitLab? Give us your feedback and help us build the experiences valuable to you.',
+ )
+ }}
+ </p>
+ </gl-banner>
+ <h3 class="gl-mt-5!">{{ s__('Hierarchy|Planning hierarchy') }}</h3>
+ <p>
+ {{
+ s__(
+ 'Hierarchy|Deliver value more efficiently by breaking down necessary work into a hierarchical structure. This structure helps teams understand scope, priorities, and how work cascades up toward larger goals.',
+ )
+ }}
+ </p>
+
+ <div class="gl-font-weight-bold gl-mb-2">{{ s__('Hierarchy|Current structure') }}</div>
+ <p class="gl-mb-3!">{{ s__('Hierarchy|You can start using these items now.') }}</p>
+ <hierarchy :work-item-types="workItemTypes.available" />
+
+ <div
+ v-if="hasUnavailableStructure"
+ data-testid="unavailable-structure"
+ class="gl-font-weight-bold gl-mt-5 gl-mb-2"
+ >
+ {{ s__('Hierarchy|Unavailable structure') }}
+ </div>
+ <p v-if="hasUnavailableStructure" class="gl-mb-3!">
+ {{ s__('Hierarchy|These items are unavailable in the current structure.') }}
+ </p>
+ <hierarchy :work-item-types="workItemTypes.unavailable" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items_hierarchy/components/hierarchy.vue b/app/assets/javascripts/work_items_hierarchy/components/hierarchy.vue
new file mode 100644
index 00000000000..9b81218b6e4
--- /dev/null
+++ b/app/assets/javascripts/work_items_hierarchy/components/hierarchy.vue
@@ -0,0 +1,119 @@
+<script>
+import { GlIcon, GlBadge } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlIcon,
+ GlBadge,
+ },
+ props: {
+ workItemTypes: {
+ type: Array,
+ required: true,
+ },
+ },
+ methods: {
+ isLastItem(index, workItem) {
+ const hasMoreThanOneItem = workItem.nestedTypes.length > 1;
+ const isLastItemInArray = index === workItem.nestedTypes.length - 1;
+
+ return isLastItemInArray && hasMoreThanOneItem;
+ },
+ nestedWorkItemTypeMargin(index, workItem) {
+ const isLastItemInArray = index === workItem.nestedTypes.length - 1;
+ const hasMoreThanOneItem = workItem.nestedTypes.length > 1;
+
+ if (isLastItemInArray && hasMoreThanOneItem) {
+ return 'gl-ml-0';
+ }
+
+ return 'gl-ml-6';
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div
+ v-for="workItem in workItemTypes"
+ :key="workItem.id"
+ class="gl-mb-3"
+ :class="{ flex: !workItem.available }"
+ >
+ <span
+ class="gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-base gl-pl-2 gl-pt-2 gl-pb-2 gl-pr-3 gl-display-inline-flex gl-align-items-center gl-justify-content-center gl-line-height-normal"
+ data-testid="work-item-wrapper"
+ >
+ <span
+ :style="{
+ backgroundColor: workItem.backgroundColor,
+ color: workItem.color,
+ }"
+ class="gl-rounded-base gl-mr-2 gl-display-inline-flex justify-content-center align-items-center hierarchy-icon-wrapper"
+ >
+ <gl-icon :size="workItem.iconSize || 12" :name="workItem.icon" />
+ </span>
+
+ {{ workItem.title }}
+ </span>
+
+ <gl-badge
+ v-if="!workItem.available"
+ variant="info"
+ icon="license"
+ size="sm"
+ class="gl-ml-3 gl-align-self-center"
+ >{{ workItem.license }}</gl-badge
+ >
+
+ <div v-if="workItem.nestedTypes" :class="{ 'gl-relative': workItem.nestedTypes.length > 1 }">
+ <svg
+ v-if="workItem.nestedTypes.length > 1"
+ class="hierarchy-rounded-arrow-tail gl-text-gray-400"
+ data-testid="hierarchy-rounded-arrow-tail"
+ width="2"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <line
+ x1="0.75"
+ y1="1"
+ x2="0.75"
+ y2="100%"
+ stroke="currentColor"
+ stroke-width="1.5"
+ stroke-linecap="round"
+ />
+ </svg>
+ <template v-for="(nestedWorkItem, index) in workItem.nestedTypes">
+ <div :key="nestedWorkItem.id" class="gl-display-block gl-mt-2 gl-ml-6">
+ <gl-icon name="arrow-down" class="gl-text-gray-400" />
+ </div>
+ <gl-icon
+ v-if="isLastItem(index, workItem)"
+ :key="nestedWorkItem.id"
+ name="level-up"
+ class="gl-text-gray-400 gl-ml-2 hierarchy-rounded-arrow"
+ />
+ <span
+ :key="nestedWorkItem.id"
+ class="gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-base gl-pl-2 gl-pt-2 gl-pb-2 gl-pr-3 gl-display-inline-flex gl-align-items-center gl-justify-content-center gl-mt-2 gl-line-height-normal"
+ :class="nestedWorkItemTypeMargin(index, workItem)"
+ >
+ <span
+ :style="{
+ backgroundColor: nestedWorkItem.backgroundColor,
+ color: nestedWorkItem.color,
+ }"
+ class="gl-rounded-base gl-mr-2 gl-display-inline-flex justify-content-center align-items-center hierarchy-icon-wrapper"
+ >
+ <gl-icon :size="nestedWorkItem.iconSize || 12" :name="nestedWorkItem.icon" />
+ </span>
+
+ {{ nestedWorkItem.title }}
+ </span>
+ </template>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items_hierarchy/constants.js b/app/assets/javascripts/work_items_hierarchy/constants.js
new file mode 100644
index 00000000000..c14fe67af4d
--- /dev/null
+++ b/app/assets/javascripts/work_items_hierarchy/constants.js
@@ -0,0 +1,62 @@
+import { __ } from '~/locale';
+
+export const WORK_ITEMS_SURVEY_COOKIE_NAME = 'hide_work_items_hierarchy_survey';
+
+/**
+ * Hard-coded strings since we're rendering hierarchy
+ * items from mock responses. Remove this when we
+ * have a real hierarchy endpoint.
+ */
+export const LICENSE_PLAN = {
+ FREE: 'free',
+ PREMIUM: 'premium',
+ ULTIMATE: 'ultimate',
+};
+
+export const workItemTypes = {
+ EPIC: {
+ title: __('Epic'),
+ icon: 'epic',
+ color: '#694CC0',
+ backgroundColor: '#E1D8F9',
+ },
+ ISSUE: {
+ title: __('Issue'),
+ icon: 'issues',
+ color: '#1068BF',
+ backgroundColor: '#CBE2F9',
+ },
+ TASK: {
+ title: __('Task'),
+ icon: 'task-done',
+ color: '#217645',
+ backgroundColor: '#C3E6CD',
+ isWorkItem: true,
+ },
+ INCIDENT: {
+ title: __('Incident'),
+ icon: 'issue-type-incident',
+ backgroundColor: '#db2a0f',
+ color: '#FDD4CD',
+ iconSize: 16,
+ },
+ SUB_EPIC: {
+ title: __('Child epic'),
+ icon: 'epic',
+ color: '#AB6100',
+ backgroundColor: '#F5D9A8',
+ },
+ REQUIREMENT: {
+ title: __('Requirement'),
+ icon: 'requirements',
+ color: '#0068c5',
+ backgroundColor: '#c5e3fb',
+ },
+ TEST_CASE: {
+ title: __('Test case'),
+ icon: 'issue-type-test-case',
+ backgroundColor: '#007a3f',
+ color: '#bae8cb',
+ iconSize: 16,
+ },
+};
diff --git a/app/assets/javascripts/work_items_hierarchy/hierarchy_util.js b/app/assets/javascripts/work_items_hierarchy/hierarchy_util.js
new file mode 100644
index 00000000000..61d93acdb91
--- /dev/null
+++ b/app/assets/javascripts/work_items_hierarchy/hierarchy_util.js
@@ -0,0 +1,10 @@
+import { LICENSE_PLAN } from './constants';
+
+export function inferLicensePlan({ hasSubEpics, hasEpics }) {
+ if (hasSubEpics) {
+ return LICENSE_PLAN.ULTIMATE;
+ } else if (hasEpics) {
+ return LICENSE_PLAN.PREMIUM;
+ }
+ return LICENSE_PLAN.FREE;
+}
diff --git a/app/assets/javascripts/work_items_hierarchy/static_response.js b/app/assets/javascripts/work_items_hierarchy/static_response.js
new file mode 100644
index 00000000000..d1e2e486082
--- /dev/null
+++ b/app/assets/javascripts/work_items_hierarchy/static_response.js
@@ -0,0 +1,142 @@
+const FREE_TIER = 'free';
+const ULTIMATE_TIER = 'ultimate';
+const PREMIUM_TIER = 'premium';
+
+const RESPONSE = {
+ [FREE_TIER]: [
+ {
+ id: '1',
+ type: 'ISSUE',
+ available: true,
+ license: null,
+ nestedTypes: null,
+ },
+ {
+ id: '2',
+ type: 'TASK',
+ available: true,
+ license: null,
+ nestedTypes: null,
+ },
+ {
+ id: '3',
+ type: 'INCIDENT',
+ available: true,
+ license: null,
+ nestedTypes: null,
+ },
+ {
+ id: '4',
+ type: 'EPIC',
+ available: false,
+ license: 'Premium', // eslint-disable-line @gitlab/require-i18n-strings
+ nestedTypes: null,
+ },
+ {
+ id: '5',
+ type: 'SUB_EPIC',
+ available: false,
+ license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings
+ nestedTypes: null,
+ },
+ {
+ id: '6',
+ type: 'REQUIREMENT',
+ available: false,
+ license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings
+ nestedTypes: null,
+ },
+ {
+ id: '7',
+ type: 'TEST_CASE',
+ available: false,
+ license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings
+ nestedTypes: null,
+ },
+ ],
+
+ [PREMIUM_TIER]: [
+ {
+ id: '1',
+ type: 'EPIC',
+ available: true,
+ license: null,
+ nestedTypes: ['ISSUE'],
+ },
+ {
+ id: '2',
+ type: 'TASK',
+ available: true,
+ license: null,
+ nestedTypes: null,
+ },
+ {
+ id: '3',
+ type: 'INCIDENT',
+ available: true,
+ license: null,
+ nestedTypes: null,
+ },
+ {
+ id: '5',
+ type: 'SUB_EPIC',
+ available: false,
+ license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings
+ nestedTypes: null,
+ },
+ {
+ id: '6',
+ type: 'REQUIREMENT',
+ available: false,
+ license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings
+ nestedTypes: null,
+ },
+ {
+ id: '7',
+ type: 'TEST_CASE',
+ available: false,
+ license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings
+ nestedTypes: null,
+ },
+ ],
+
+ [ULTIMATE_TIER]: [
+ {
+ id: '1',
+ type: 'EPIC',
+ available: true,
+ license: null,
+ nestedTypes: ['SUB_EPIC', 'ISSUE'],
+ },
+ {
+ id: '2',
+ type: 'TASK',
+ available: true,
+ license: null,
+ nestedTypes: null,
+ },
+ {
+ id: '3',
+ type: 'INCIDENT',
+ available: true,
+ license: null,
+ nestedTypes: null,
+ },
+ {
+ id: '6',
+ type: 'REQUIREMENT',
+ available: true,
+ license: null,
+ nestedTypes: null,
+ },
+ {
+ id: '7',
+ type: 'TEST_CASE',
+ available: true,
+ license: null,
+ nestedTypes: null,
+ },
+ ],
+};
+
+export default RESPONSE;
diff --git a/app/assets/javascripts/work_items_hierarchy/work_items_hierarchy_bundle.js b/app/assets/javascripts/work_items_hierarchy/work_items_hierarchy_bundle.js
new file mode 100644
index 00000000000..2258c725301
--- /dev/null
+++ b/app/assets/javascripts/work_items_hierarchy/work_items_hierarchy_bundle.js
@@ -0,0 +1,26 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import App from './components/app.vue';
+import { inferLicensePlan } from './hierarchy_util';
+
+export const initWorkItemsHierarchy = () => {
+ const el = document.querySelector('#js-work-items-hierarchy');
+
+ const { illustrationPath, hasEpics, hasSubEpics } = el.dataset;
+
+ const licensePlan = inferLicensePlan({
+ hasEpics: parseBoolean(hasEpics),
+ hasSubEpics: parseBoolean(hasSubEpics),
+ });
+
+ return new Vue({
+ el,
+ provide: {
+ illustrationPath,
+ licensePlan,
+ },
+ render(createElement) {
+ return createElement(App);
+ },
+ });
+};