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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-03-18 23:02:30 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-03-18 23:02:30 +0300
commit41fe97390ceddf945f3d967b8fdb3de4c66b7dea (patch)
tree9c8d89a8624828992f06d892cd2f43818ff5dcc8 /app/assets/javascripts
parent0804d2dc31052fb45a1efecedc8e06ce9bc32862 (diff)
Add latest changes from gitlab-org/gitlab@14-9-stable-eev14.9.0-rc42
Diffstat (limited to 'app/assets/javascripts')
-rw-r--r--app/assets/javascripts/access_tokens/components/expires_at_field.vue43
-rw-r--r--app/assets/javascripts/access_tokens/components/tokens_app.vue1
-rw-r--r--app/assets/javascripts/access_tokens/index.js2
-rw-r--r--app/assets/javascripts/activities.js2
-rw-r--r--app/assets/javascripts/admin/applications/components/delete_application.vue84
-rw-r--r--app/assets/javascripts/admin/applications/index.js15
-rw-r--r--app/assets/javascripts/admin/topics/components/remove_avatar.vue67
-rw-r--r--app/assets/javascripts/admin/topics/index.js22
-rw-r--r--app/assets/javascripts/admin/users/components/user_actions.vue7
-rw-r--r--app/assets/javascripts/admin/users/components/user_avatar.vue2
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_table.vue7
-rw-r--r--app/assets/javascripts/api.js8
-rw-r--r--app/assets/javascripts/attention_requests/components/navigation_popover.vue120
-rw-r--r--app/assets/javascripts/attention_requests/index.js73
-rw-r--r--app/assets/javascripts/authentication/webauthn/util.js11
-rw-r--r--app/assets/javascripts/behaviors/markdown/editor_extensions.js78
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/bold.js22
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/code.js17
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/inline_diff.js66
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/inline_html.js71
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/italic.js16
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/link.js58
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/math.js61
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/strike.js31
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/audio.js9
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/blockquote.js18
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js16
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/code_block.js127
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/description_details.js30
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/description_list.js29
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/description_term.js32
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/details.js30
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/doc.js21
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/emoji.js84
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/hard_break.js18
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/heading.js26
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js15
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/image.js83
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/list_item.js17
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js29
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js38
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/paragraph.js29
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/playable.js93
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/reference.js85
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/summary.js32
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/table.js32
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/table_body.js30
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/table_cell.js54
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/table_head.js30
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js40
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js49
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/table_row.js30
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/task_list.js39
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js70
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/text.js23
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/video.js10
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/schema.js32
-rw-r--r--app/assets/javascripts/behaviors/markdown/serializer.js32
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/keybindings.js16
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts.js7
-rw-r--r--app/assets/javascripts/blob/components/blob_header.vue5
-rw-r--r--app/assets/javascripts/blob/components/blob_header_default_actions.vue3
-rw-r--r--app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue7
-rw-r--r--app/assets/javascripts/blob/csv/csv_viewer.vue32
-rw-r--r--app/assets/javascripts/blob/template_selector.js23
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js11
-rw-r--r--app/assets/javascripts/boards/boards_util.js14
-rw-r--r--app/assets/javascripts/boards/components/board_filtered_search.vue6
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue40
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue14
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue7
-rw-r--r--app/assets/javascripts/boards/filtered_search_boards.js81
-rw-r--r--app/assets/javascripts/boards/graphql.js1
-rw-r--r--app/assets/javascripts/boards/index.js42
-rw-r--r--app/assets/javascripts/boards/mount_filtered_search_issue_boards.js10
-rw-r--r--app/assets/javascripts/boards/stores/actions.js36
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js3
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js17
-rw-r--r--app/assets/javascripts/boards/stores/state.js1
-rw-r--r--app/assets/javascripts/branches/ajax_loading_spinner.js31
-rw-r--r--app/assets/javascripts/captcha/apollo_captcha_link.js2
-rw-r--r--app/assets/javascripts/captcha/captcha_modal.vue6
-rw-r--r--app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js4
-rw-r--r--app/assets/javascripts/ci_lint/components/ci_lint.vue2
-rw-r--r--app/assets/javascripts/ci_secure_files/components/secure_files_list.vue133
-rw-r--r--app/assets/javascripts/ci_secure_files/index.js17
-rw-r--r--app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue16
-rw-r--r--app/assets/javascripts/ci_variable_list/ci_variable_list.js3
-rw-r--r--app/assets/javascripts/clusters/agents/components/create_token_button.vue246
-rw-r--r--app/assets/javascripts/clusters/agents/components/show.vue2
-rw-r--r--app/assets/javascripts/clusters/agents/components/token_table.vue41
-rw-r--r--app/assets/javascripts/clusters/agents/constants.js7
-rw-r--r--app/assets/javascripts/clusters/agents/graphql/cache_update.js24
-rw-r--r--app/assets/javascripts/clusters/agents/graphql/mutations/create_new_agent_token.mutation.graphql11
-rw-r--r--app/assets/javascripts/clusters/agents/index.js5
-rw-r--r--app/assets/javascripts/clusters/components/new_cluster.vue6
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_table.vue27
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_token.vue109
-rw-r--r--app/assets/javascripts/clusters_list/components/agents.vue3
-rw-r--r--app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue50
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters.vue8
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_actions.vue60
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_empty_state.vue4
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_main_view.vue36
-rw-r--r--app/assets/javascripts/clusters_list/components/delete_agent_button.vue2
-rw-r--r--app/assets/javascripts/clusters_list/components/install_agent_modal.vue175
-rw-r--r--app/assets/javascripts/clusters_list/constants.js145
-rw-r--r--app/assets/javascripts/clusters_list/graphql/cache_update.js10
-rw-r--r--app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql8
-rw-r--r--app/assets/javascripts/clusters_list/index.js58
-rw-r--r--app/assets/javascripts/clusters_list/load_clusters.js25
-rw-r--r--app/assets/javascripts/clusters_list/load_main_view.js57
-rw-r--r--app/assets/javascripts/code_navigation/components/app.vue27
-rw-r--r--app/assets/javascripts/code_quality_walkthrough/components/step.vue150
-rw-r--r--app/assets/javascripts/code_quality_walkthrough/constants.js67
-rw-r--r--app/assets/javascripts/code_quality_walkthrough/index.js14
-rw-r--r--app/assets/javascripts/code_quality_walkthrough/utils.js39
-rw-r--r--app/assets/javascripts/commons/nav/user_merge_requests.js23
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue29
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor_provider.vue1
-rw-r--r--app/assets/javascripts/content_editor/components/editor_state_observer.vue34
-rw-r--r--app/assets/javascripts/content_editor/components/loading_indicator.vue39
-rw-r--r--app/assets/javascripts/content_editor/constants.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/attachment.js17
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js2
-rw-r--r--app/assets/javascripts/content_editor/extensions/paste_markdown.js86
-rw-r--r--app/assets/javascripts/content_editor/extensions/table.js3
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js48
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js13
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_deserializer.js33
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js27
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_sourcemap.js6
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js9
-rw-r--r--app/assets/javascripts/content_editor/services/upload_helpers.js19
-rw-r--r--app/assets/javascripts/contributors/stores/getters.js5
-rw-r--r--app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue33
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_table.vue2
-rw-r--r--app/assets/javascripts/cycle_analytics/components/total_time.vue (renamed from app/assets/javascripts/cycle_analytics/components/total_time_component.vue)0
-rw-r--r--app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue61
-rw-r--r--app/assets/javascripts/deploy_tokens/components/revoke_button.vue6
-rw-r--r--app/assets/javascripts/deploy_tokens/init_revoke_button.js3
-rw-r--r--app/assets/javascripts/deprecated_notes.js33
-rw-r--r--app/assets/javascripts/diff.js42
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue4
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_view.vue18
-rw-r--r--app/assets/javascripts/diffs/components/hidden_files_warning.vue52
-rw-r--r--app/assets/javascripts/diffs/store/getters_versions_dropdowns.js13
-rw-r--r--app/assets/javascripts/dirty_submit/dirty_submit_form.js12
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js2
-rw-r--r--app/assets/javascripts/editor/schema/ci.json95
-rw-r--r--app/assets/javascripts/emoji/components/picker.vue1
-rw-r--r--app/assets/javascripts/environments/components/commit.vue1
-rw-r--r--app/assets/javascripts/environments/components/delete_environment_modal.vue3
-rw-r--r--app/assets/javascripts/environments/components/deployment.vue8
-rw-r--r--app/assets/javascripts/environments/components/enable_review_app_modal.vue10
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.vue10
-rw-r--r--app/assets/javascripts/environments/components/environment_folder.vue (renamed from app/assets/javascripts/environments/components/new_environment_folder.vue)19
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue390
-rw-r--r--app/assets/javascripts/environments/components/new_environment_item.vue21
-rw-r--r--app/assets/javascripts/environments/components/new_environments_app.vue252
-rw-r--r--app/assets/javascripts/environments/constants.js10
-rw-r--r--app/assets/javascripts/environments/graphql/client.js49
-rw-r--r--app/assets/javascripts/environments/graphql/queries/folder.query.graphql4
-rw-r--r--app/assets/javascripts/environments/graphql/resolvers.js17
-rw-r--r--app/assets/javascripts/environments/index.js55
-rw-r--r--app/assets/javascripts/environments/new_index.js38
-rw-r--r--app/assets/javascripts/error_tracking/components/constants.js21
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details.vue2
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue44
-rw-r--r--app/assets/javascripts/error_tracking/constants.js30
-rw-r--r--app/assets/javascripts/error_tracking/list.js8
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/app.vue64
-rw-r--r--app/assets/javascripts/error_tracking_settings/constants.js7
-rw-r--r--app/assets/javascripts/experimentation/components/gitlab_experiment.vue2
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown.js3
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js14
-rw-r--r--app/assets/javascripts/google_cloud/components/app.vue4
-rw-r--r--app/assets/javascripts/google_cloud/components/gcp_regions_form.vue62
-rw-r--r--app/assets/javascripts/google_cloud/components/gcp_regions_list.vue56
-rw-r--r--app/assets/javascripts/google_cloud/components/home.vue25
-rw-r--r--app/assets/javascripts/google_cloud/components/revoke_oauth.vue38
-rw-r--r--app/assets/javascripts/google_cloud/components/service_accounts_form.vue47
-rw-r--r--app/assets/javascripts/google_cloud/components/service_accounts_list.vue18
-rw-r--r--app/assets/javascripts/google_tag_manager/index.js44
-rw-r--r--app/assets/javascripts/gpg_badges.js3
-rw-r--r--app/assets/javascripts/graphql_shared/constants.js2
-rw-r--r--app/assets/javascripts/graphql_shared/possibleTypes.json2
-rw-r--r--app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql24
-rw-r--r--app/assets/javascripts/groups/components/item_stats.vue2
-rw-r--r--app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue18
-rw-r--r--app/assets/javascripts/header_search/components/header_search_default_items.vue4
-rw-r--r--app/assets/javascripts/header_search/index.js4
-rw-r--r--app/assets/javascripts/header_search/store/actions.js3
-rw-r--r--app/assets/javascripts/header_search/store/getters.js34
-rw-r--r--app/assets/javascripts/header_search/store/index.js3
-rw-r--r--app/assets/javascripts/header_search/store/mutations.js4
-rw-r--r--app/assets/javascripts/header_search/store/state.js12
-rw-r--r--app/assets/javascripts/ide/components/file_templates/bar.vue91
-rw-r--r--app/assets/javascripts/ide/components/file_templates/dropdown.vue2
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue24
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue12
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue2
-rw-r--r--app/assets/javascripts/incidents/components/incidents_list.vue57
-rw-r--r--app/assets/javascripts/incidents/constants.js7
-rw-r--r--app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql1
-rw-r--r--app/assets/javascripts/incidents/list.js1
-rw-r--r--app/assets/javascripts/integrations/constants.js13
-rw-r--r--app/assets/javascripts/integrations/edit/components/active_checkbox.vue8
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue93
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue18
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue15
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/connection.vue45
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/jira_issues.vue33
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/jira_trigger.vue32
-rw-r--r--app/assets/javascripts/integrations/edit/components/trigger_fields.vue6
-rw-r--r--app/assets/javascripts/integrations/edit/index.js14
-rw-r--r--app/assets/javascripts/integrations/edit/store/getters.js5
-rw-r--r--app/assets/javascripts/invite_members/components/invite_group_trigger.vue7
-rw-r--r--app/assets/javascripts/invite_members/components/invite_groups_modal.vue28
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue34
-rw-r--r--app/assets/javascripts/invite_members/components/invite_modal_base.vue235
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_form.js7
-rw-r--r--app/assets/javascripts/invite_members/utils/get_invalid_feedback_message.js12
-rw-r--r--app/assets/javascripts/issuable/components/issuable_by_email.vue1
-rw-r--r--app/assets/javascripts/issues/create_merge_request_dropdown.js12
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue20
-rw-r--r--app/assets/javascripts/issues/list/constants.js11
-rw-r--r--app/assets/javascripts/issues/list/queries/get_issues.query.graphql3
-rw-r--r--app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql7
-rw-r--r--app/assets/javascripts/issues/list/queries/search_users.query.graphql4
-rw-r--r--app/assets/javascripts/issues/list/utils.js11
-rw-r--r--app/assets/javascripts/issues/show/components/delete_issue_modal.vue5
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue41
-rw-r--r--app/assets/javascripts/issues/show/components/fields/description_template.vue5
-rw-r--r--app/assets/javascripts/issues/show/components/header_actions.vue16
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue8
-rw-r--r--app/assets/javascripts/issues/show/components/title.vue4
-rw-r--r--app/assets/javascripts/issues/show/index.js4
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/app.vue26
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/sign_in_legacy_button.vue (renamed from app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue)4
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue124
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue23
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/constants.js21
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/index.js10
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue39
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pkce.js60
-rw-r--r--app/assets/javascripts/jobs/components/job_app.vue17
-rw-r--r--app/assets/javascripts/jobs/components/job_log_controllers.vue20
-rw-r--r--app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue22
-rw-r--r--app/assets/javascripts/jobs/components/log/line_header.vue2
-rw-r--r--app/assets/javascripts/jobs/components/sidebar.vue38
-rw-r--r--app/assets/javascripts/jobs/components/stages_dropdown.vue129
-rw-r--r--app/assets/javascripts/jobs/components/table/constants.js10
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/cache_config.js30
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql15
-rw-r--r--app/assets/javascripts/jobs/components/table/index.js8
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_app.vue67
-rw-r--r--app/assets/javascripts/jobs/index.js2
-rw-r--r--app/assets/javascripts/lib/utils/accessor.js8
-rw-r--r--app/assets/javascripts/lib/utils/array_utility.js10
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js13
-rw-r--r--app/assets/javascripts/lib/utils/ignore_while_pending.js26
-rw-r--r--app/assets/javascripts/lib/utils/rails_ujs.js5
-rw-r--r--app/assets/javascripts/lib/utils/resize_observer.js22
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js54
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js14
-rw-r--r--app/assets/javascripts/loading_icon_for_legacy_js.js53
-rw-r--r--app/assets/javascripts/main.js18
-rw-r--r--app/assets/javascripts/member_expiration_date.js54
-rw-r--r--app/assets/javascripts/members/components/action_buttons/remove_member_button.vue3
-rw-r--r--app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue3
-rw-r--r--app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue41
-rw-r--r--app/assets/javascripts/members/constants.js37
-rw-r--r--app/assets/javascripts/members/index.js2
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue7
-rw-r--r--app/assets/javascripts/merge_request_tabs.js247
-rw-r--r--app/assets/javascripts/monitoring/components/charts/bar.vue34
-rw-r--r--app/assets/javascripts/monitoring/components/charts/column.vue34
-rw-r--r--app/assets/javascripts/monitoring/components/charts/gauge.vue36
-rw-r--r--app/assets/javascripts/monitoring/components/charts/heatmap.vue34
-rw-r--r--app/assets/javascripts/monitoring/components/charts/stacked_column.vue46
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue103
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue13
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue5
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue8
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue14
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue14
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js6
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_metadata.query.graphql (renamed from app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql)3
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue13
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue66
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue17
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/constants.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/graphql/utils/cache_update.js1
-rw-r--r--app/assets/javascripts/pages/admin/applications/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/clusters/connect/index.js (renamed from app/assets/javascripts/pages/admin/clusters/new/index.js)0
-rw-r--r--app/assets/javascripts/pages/admin/topics/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/todos.js8
-rw-r--r--app/assets/javascripts/pages/groups/clusters/connect/index.js (renamed from app/assets/javascripts/pages/groups/clusters/new/index.js)0
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js15
-rw-r--r--app/assets/javascripts/pages/jira_connect/oauth_callbacks/index.js28
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/branches/index/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/ci/secure_files/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/clusters/connect/index.js (renamed from app/assets/javascripts/pages/projects/clusters/new/index.js)0
-rw-r--r--app/assets/javascripts/pages/projects/environments/index/index.js12
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/app.vue43
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue25
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue93
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue148
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/index.js79
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue7
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue10
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue4
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js1
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/index/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/pages_domains/form.js15
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js54
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js53
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js13
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue3
-rw-r--r--app/assets/javascripts/pages/users/activity_calendar.js11
-rw-r--r--app/assets/javascripts/performance_bar/components/detailed_metric.vue21
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue1
-rw-r--r--app/assets/javascripts/performance_bar/components/request_selector.vue38
-rw-r--r--app/assets/javascripts/performance_bar/constants.js14
-rw-r--r--app/assets/javascripts/performance_bar/index.js41
-rw-r--r--app/assets/javascripts/performance_bar/stores/performance_bar_store.js2
-rw-r--r--app/assets/javascripts/persistent_user_callout.js19
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js1
-rw-r--r--app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue18
-rw-r--r--app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue6
-rw-r--r--app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue22
-rw-r--r--app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue3
-rw-r--r--app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue8
-rw-r--r--app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue7
-rw-r--r--app/assets/javascripts/pipeline_editor/constants.js45
-rw-r--r--app/assets/javascripts/pipeline_editor/index.js6
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue25
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue1
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/commit.vue4
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/input.vue99
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/step.vue149
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/widgets/list.vue195
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/wrapper.vue185
-rw-r--r--app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue65
-rw-r--r--app/assets/javascripts/pipeline_wizard/validators.js4
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/jobs_app.vue15
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue100
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_labels.vue170
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue267
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue16
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue226
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_commit.vue85
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue30
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue128
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue8
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue7
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js4
-rw-r--r--app/assets/javascripts/pipelines/pipelines_index.js6
-rw-r--r--app/assets/javascripts/profile/profile.js4
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/index.js9
-rw-r--r--app/assets/javascripts/projects/project_new.js13
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_create.js24
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_edit.js66
-rw-r--r--app/assets/javascripts/ref/components/ref_selector.vue27
-rw-r--r--app/assets/javascripts/ref/stores/actions.js3
-rw-r--r--app/assets/javascripts/ref/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/ref/stores/mutations.js5
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_block.vue13
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_list.vue7
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_root.vue12
-rw-r--r--app/assets/javascripts/related_issues/index.js1
-rw-r--r--app/assets/javascripts/releases/components/app_index.vue2
-rw-r--r--app/assets/javascripts/releases/components/asset_links_form.vue12
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/actions.js2
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/getters.js14
-rw-r--r--app/assets/javascripts/reports/codequality_report/constants.js14
-rw-r--r--app/assets/javascripts/reports/constants.js1
-rw-r--r--app/assets/javascripts/repository/components/blob_button_group.vue14
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue77
-rw-r--r--app/assets/javascripts/repository/components/blob_edit.vue78
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/audio_viewer.vue20
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/csv_viewer.vue26
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue2
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue2
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/index.js2
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue2
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue2
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue13
-rw-r--r--app/assets/javascripts/repository/components/delete_blob_modal.vue2
-rw-r--r--app/assets/javascripts/repository/constants.js10
-rw-r--r--app/assets/javascripts/repository/index.js4
-rw-r--r--app/assets/javascripts/repository/queries/application_info.query.graphql3
-rw-r--r--app/assets/javascripts/repository/queries/blob_info.query.graphql4
-rw-r--r--app/assets/javascripts/repository/queries/user_info.query.graphql8
-rw-r--r--app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue4
-rw-r--r--app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue4
-rw-r--r--app/assets/javascripts/runner/admin_runners/admin_runners_app.vue22
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_actions_cell.vue118
-rw-r--r--app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue9
-rw-r--r--app/assets/javascripts/runner/components/runner_delete_button.vue144
-rw-r--r--app/assets/javascripts/runner/components/runner_edit_button.vue4
-rw-r--r--app/assets/javascripts/runner/components/runner_jobs.vue11
-rw-r--r--app/assets/javascripts/runner/components/runner_list.vue33
-rw-r--r--app/assets/javascripts/runner/components/runner_pause_button.vue20
-rw-r--r--app/assets/javascripts/runner/components/runner_paused_badge.vue12
-rw-r--r--app/assets/javascripts/runner/components/runner_projects.vue12
-rw-r--r--app/assets/javascripts/runner/components/runner_update_form.vue9
-rw-r--r--app/assets/javascripts/runner/constants.js16
-rw-r--r--app/assets/javascripts/runner/graphql/details/runner.query.graphql (renamed from app/assets/javascripts/runner/graphql/get_runner.query.graphql)3
-rw-r--r--app/assets/javascripts/runner/graphql/details/runner_details.fragment.graphql (renamed from app/assets/javascripts/runner/graphql/runner_details.fragment.graphql)0
-rw-r--r--app/assets/javascripts/runner/graphql/details/runner_details_shared.fragment.graphql (renamed from app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql)3
-rw-r--r--app/assets/javascripts/runner/graphql/details/runner_jobs.query.graphql (renamed from app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql)0
-rw-r--r--app/assets/javascripts/runner/graphql/details/runner_projects.query.graphql (renamed from app/assets/javascripts/runner/graphql/get_runner_projects.query.graphql)0
-rw-r--r--app/assets/javascripts/runner/graphql/details/runner_update.mutation.graphql (renamed from app/assets/javascripts/runner/graphql/runner_update.mutation.graphql)2
-rw-r--r--app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql (renamed from app/assets/javascripts/runner/graphql/get_runners.query.graphql)4
-rw-r--r--app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql (renamed from app/assets/javascripts/runner/graphql/get_runners_count.query.graphql)0
-rw-r--r--app/assets/javascripts/runner/graphql/list/group_runners.query.graphql (renamed from app/assets/javascripts/runner/graphql/get_group_runners.query.graphql)6
-rw-r--r--app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql (renamed from app/assets/javascripts/runner/graphql/get_group_runners_count.query.graphql)0
-rw-r--r--app/assets/javascripts/runner/graphql/list/list_item.fragment.graphql (renamed from app/assets/javascripts/runner/graphql/runner_node.fragment.graphql)2
-rw-r--r--app/assets/javascripts/runner/graphql/list/runners_registration_token_reset.mutation.graphql (renamed from app/assets/javascripts/runner/graphql/runners_registration_token_reset.mutation.graphql)0
-rw-r--r--app/assets/javascripts/runner/graphql/shared/runner_delete.mutation.graphql (renamed from app/assets/javascripts/runner/graphql/runner_delete.mutation.graphql)0
-rw-r--r--app/assets/javascripts/runner/graphql/shared/runner_toggle_active.mutation.graphql (renamed from app/assets/javascripts/runner/graphql/runner_toggle_active.mutation.graphql)0
-rw-r--r--app/assets/javascripts/runner/group_runners/group_runners_app.vue57
-rw-r--r--app/assets/javascripts/search/topbar/components/app.vue55
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js34
-rw-r--r--app/assets/javascripts/security_configuration/components/feature_card.vue5
-rw-r--r--app/assets/javascripts/security_configuration/components/training_provider_list.vue139
-rw-r--r--app/assets/javascripts/security_configuration/constants.js6
-rw-r--r--app/assets/javascripts/security_configuration/graphql/cache_utils.js40
-rw-r--r--app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql10
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue20
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue28
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/attention_requested_toggle.vue26
-rw-r--r--app/assets/javascripts/sidebar/components/incidents/constants.js25
-rw-r--r--app/assets/javascripts/sidebar/components/incidents/escalation_status.vue61
-rw-r--r--app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue135
-rw-r--r--app/assets/javascripts/sidebar/components/incidents/utils.js5
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/severity/severity.vue8
-rw-r--r--app/assets/javascripts/sidebar/constants.js19
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js57
-rw-r--r--app/assets/javascripts/sidebar/queries/escalation_status.query.graphql9
-rw-r--r--app/assets/javascripts/sidebar/queries/update_escalation_status.mutation.graphql10
-rw-r--r--app/assets/javascripts/sidebar/sidebar_bundle.js12
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js2
-rw-r--r--app/assets/javascripts/single_file_diff.js3
-rw-r--r--app/assets/javascripts/terraform/components/empty_state.vue2
-rw-r--r--app/assets/javascripts/toggle_buttons.js63
-rw-r--r--app/assets/javascripts/toggles/index.js20
-rw-r--r--app/assets/javascripts/tracking/dispatch_snowplow_event.js7
-rw-r--r--app/assets/javascripts/tracking/tracking.js6
-rw-r--r--app/assets/javascripts/users_select/index.js6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue95
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue95
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue30
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue31
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue13
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue48
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue40
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/constants.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js123
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/issues.js10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js11
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue21
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/content_transition.vue32
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue56
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue33
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue25
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue42
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js22
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue112
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql13
-rw-r--r--app/assets/javascripts/vue_shared/components/source_editor.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/utils.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue80
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue106
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue110
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue54
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue117
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_old.vue117
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_select/user_select.vue126
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue44
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue7
-rw-r--r--app/assets/javascripts/webpack_non_compiled_placeholder.js15
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail_modal.vue62
-rw-r--r--app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql14
-rw-r--r--app/assets/javascripts/work_items/graphql/provider.js23
-rw-r--r--app/assets/javascripts/work_items/graphql/resolvers.js43
-rw-r--r--app/assets/javascripts/work_items/graphql/typedefs.graphql8
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql14
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.query.graphql12
-rw-r--r--app/assets/javascripts/work_items/pages/create_work_item.vue34
-rw-r--r--app/assets/javascripts/work_items/pages/work_item_root.vue36
519 files changed, 9831 insertions, 5860 deletions
diff --git a/app/assets/javascripts/access_tokens/components/expires_at_field.vue b/app/assets/javascripts/access_tokens/components/expires_at_field.vue
index 1fec186f2fa..561b2617c5f 100644
--- a/app/assets/javascripts/access_tokens/components/expires_at_field.vue
+++ b/app/assets/javascripts/access_tokens/components/expires_at_field.vue
@@ -1,15 +1,31 @@
<script>
-import { GlDatepicker, GlFormInput } from '@gitlab/ui';
+import { GlDatepicker, GlFormInput, GlFormGroup } from '@gitlab/ui';
+
+import { __ } from '~/locale';
export default {
name: 'ExpiresAtField',
- components: { GlDatepicker, GlFormInput },
+ i18n: {
+ label: __('Expiration date'),
+ },
+ components: {
+ GlDatepicker,
+ GlFormInput,
+ GlFormGroup,
+ MaxExpirationDateMessage: () =>
+ import('ee_component/access_tokens/components/max_expiration_date_message.vue'),
+ },
props: {
inputAttrs: {
type: Object,
required: false,
default: () => ({}),
},
+ maxDate: {
+ type: Date,
+ required: false,
+ default: () => null,
+ },
},
data() {
return {
@@ -20,13 +36,18 @@ export default {
</script>
<template>
- <gl-datepicker :target="null" :min-date="minDate">
- <gl-form-input
- v-bind="inputAttrs"
- class="datepicker gl-datepicker-input"
- autocomplete="off"
- inputmode="none"
- data-qa-selector="expiry_date_field"
- />
- </gl-datepicker>
+ <gl-form-group :label="$options.i18n.label" :label-for="inputAttrs.id">
+ <gl-datepicker :target="null" :min-date="minDate" :max-date="maxDate">
+ <gl-form-input
+ v-bind="inputAttrs"
+ class="datepicker gl-datepicker-input"
+ autocomplete="off"
+ inputmode="none"
+ data-qa-selector="expiry_date_field"
+ />
+ </gl-datepicker>
+ <template #description>
+ <max-expiration-date-message :max-date="maxDate" />
+ </template>
+ </gl-form-group>
</template>
diff --git a/app/assets/javascripts/access_tokens/components/tokens_app.vue b/app/assets/javascripts/access_tokens/components/tokens_app.vue
index 755991f64e0..10d4d62d803 100644
--- a/app/assets/javascripts/access_tokens/components/tokens_app.vue
+++ b/app/assets/javascripts/access_tokens/components/tokens_app.vue
@@ -100,6 +100,7 @@ export default {
<gl-link
:href="tokenData.resetPath"
:data-confirm="$options.i18n[tokenType].resetConfirmMessage"
+ data-confirm-btn-variant="danger"
data-method="put"
>{{ content }}</gl-link
>
diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js
index 9a1e7d877f8..c59bd445539 100644
--- a/app/assets/javascripts/access_tokens/index.js
+++ b/app/assets/javascripts/access_tokens/index.js
@@ -17,6 +17,7 @@ export const initExpiresAtField = () => {
}
const { expiresAt: inputAttrs } = parseRailsFormFields(el);
+ const { maxDate } = el.dataset;
return new Vue({
el,
@@ -24,6 +25,7 @@ export const initExpiresAtField = () => {
return h(ExpiresAtField, {
props: {
inputAttrs,
+ maxDate: maxDate ? new Date(maxDate) : undefined,
},
});
},
diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js
index 74e0e1b6225..7a78ccdb0cd 100644
--- a/app/assets/javascripts/activities.js
+++ b/app/assets/javascripts/activities.js
@@ -33,7 +33,7 @@ export default class Activities {
errorCallback: () =>
createFlash({
message: s__(
- 'Activity|An error occured while retrieving activity. Reload the page to try again.',
+ 'Activity|An error occurred while retrieving activity. Reload the page to try again.',
),
parent: this.containerEl,
}),
diff --git a/app/assets/javascripts/admin/applications/components/delete_application.vue b/app/assets/javascripts/admin/applications/components/delete_application.vue
new file mode 100644
index 00000000000..77694296b0a
--- /dev/null
+++ b/app/assets/javascripts/admin/applications/components/delete_application.vue
@@ -0,0 +1,84 @@
+<script>
+import { GlModal, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+import csrf from '~/lib/utils/csrf';
+
+export default {
+ components: {
+ GlModal,
+ GlSprintf,
+ },
+ data() {
+ return {
+ name: '',
+ path: '',
+ buttons: [],
+ };
+ },
+ mounted() {
+ this.buttons = document.querySelectorAll('.js-application-delete-button');
+
+ this.buttons.forEach((button) => button.addEventListener('click', this.buttonEvent));
+ },
+ destroy() {
+ this.buttons.forEach((button) => button.removeEventListener('click', this.buttonEvent));
+ },
+ methods: {
+ buttonEvent(e) {
+ e.preventDefault();
+ this.show(e.target.dataset);
+ },
+ show(dataset) {
+ const { name, path } = dataset;
+
+ this.name = name;
+ this.path = path;
+
+ this.$refs.deleteModal.show();
+ },
+ deleteApplication() {
+ this.$refs.deleteForm.submit();
+ },
+ },
+ i18n: {
+ destroy: __('Destroy'),
+ title: __('Confirm destroy application'),
+ body: __('Are you sure that you want to destroy %{application}'),
+ },
+ modal: {
+ actionPrimary: {
+ text: __('Destroy'),
+ attributes: {
+ variant: 'danger',
+ },
+ },
+ actionSecondary: {
+ text: __('Cancel'),
+ attributes: {
+ variant: 'default',
+ },
+ },
+ },
+ csrf,
+};
+</script>
+<template>
+ <gl-modal
+ ref="deleteModal"
+ :title="$options.i18n.title"
+ :action-primary="$options.modal.actionPrimary"
+ :action-secondary="$options.modal.actionSecondary"
+ modal-id="delete-application-modal"
+ size="sm"
+ @primary="deleteApplication"
+ ><gl-sprintf :message="$options.i18n.body">
+ <template #application>
+ <strong>{{ name }}</strong>
+ </template></gl-sprintf
+ >
+ <form ref="deleteForm" method="post" :action="path">
+ <input type="hidden" name="_method" value="delete" />
+ <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
+ </form>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/admin/applications/index.js b/app/assets/javascripts/admin/applications/index.js
new file mode 100644
index 00000000000..5875fd18729
--- /dev/null
+++ b/app/assets/javascripts/admin/applications/index.js
@@ -0,0 +1,15 @@
+import Vue from 'vue';
+import DeleteApplication from './components/delete_application.vue';
+
+export default () => {
+ const el = document.querySelector('.js-application-delete-modal');
+
+ if (!el) return false;
+
+ return new Vue({
+ el,
+ render(h) {
+ return h(DeleteApplication);
+ },
+ });
+};
diff --git a/app/assets/javascripts/admin/topics/components/remove_avatar.vue b/app/assets/javascripts/admin/topics/components/remove_avatar.vue
new file mode 100644
index 00000000000..5e94d6185e0
--- /dev/null
+++ b/app/assets/javascripts/admin/topics/components/remove_avatar.vue
@@ -0,0 +1,67 @@
+<script>
+import { uniqueId } from 'lodash';
+import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+import csrf from '~/lib/utils/csrf';
+
+export default {
+ components: {
+ GlButton,
+ GlModal,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ inject: ['path'],
+ data() {
+ return {
+ modalId: uniqueId('remove-topic-avatar-'),
+ };
+ },
+ methods: {
+ deleteApplication() {
+ this.$refs.deleteForm.submit();
+ },
+ },
+ i18n: {
+ remove: __('Remove avatar'),
+ title: __('Confirm remove avatar'),
+ body: __('Avatar will be removed. Are you sure?'),
+ },
+ modal: {
+ actionPrimary: {
+ text: __('Remove'),
+ attributes: {
+ variant: 'danger',
+ },
+ },
+ actionSecondary: {
+ text: __('Cancel'),
+ attributes: {
+ variant: 'default',
+ },
+ },
+ },
+ csrf,
+};
+</script>
+<template>
+ <div>
+ <gl-button v-gl-modal="modalId" variant="danger" category="secondary" class="gl-mt-2">{{
+ $options.i18n.remove
+ }}</gl-button>
+ <gl-modal
+ :title="$options.i18n.title"
+ :action-primary="$options.modal.actionPrimary"
+ :action-secondary="$options.modal.actionSecondary"
+ :modal-id="modalId"
+ size="sm"
+ @primary="deleteApplication"
+ >{{ $options.i18n.body }}
+ <form ref="deleteForm" method="post" :action="path">
+ <input type="hidden" name="_method" value="delete" />
+ <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
+ </form>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/admin/topics/index.js b/app/assets/javascripts/admin/topics/index.js
new file mode 100644
index 00000000000..8fbcadf3369
--- /dev/null
+++ b/app/assets/javascripts/admin/topics/index.js
@@ -0,0 +1,22 @@
+import Vue from 'vue';
+import RemoveAvatar from './components/remove_avatar.vue';
+
+export default () => {
+ const el = document.querySelector('.js-remove-topic-avatar');
+
+ if (!el) {
+ return false;
+ }
+
+ const { path } = el.dataset;
+
+ return new Vue({
+ el,
+ provide: {
+ path,
+ },
+ render(h) {
+ return h(RemoveAvatar);
+ },
+ });
+};
diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue
index f5d21ece138..829174d7593 100644
--- a/app/assets/javascripts/admin/users/components/user_actions.vue
+++ b/app/assets/javascripts/admin/users/components/user_actions.vue
@@ -69,7 +69,6 @@ export default {
editButtonAttrs() {
return {
'data-testid': 'edit',
- icon: 'pencil-square',
href: this.userPaths.edit,
};
},
@@ -101,6 +100,7 @@ export default {
<gl-button
v-else
v-gl-tooltip="$options.i18n.edit"
+ icon="pencil-square"
v-bind="editButtonAttrs"
:aria-label="$options.i18n.edit"
/>
@@ -108,10 +108,9 @@ export default {
<div v-if="hasDropdownActions" class="gl-p-2">
<gl-dropdown
+ v-gl-tooltip="$options.i18n.userAdministration"
data-testid="dropdown-toggle"
- :text="$options.i18n.userAdministration"
- :text-sr-only="!showButtonLabels"
- icon="ellipsis_h"
+ icon="ellipsis_v"
data-qa-selector="user_actions_dropdown_toggle"
:data-qa-username="user.username"
no-caret
diff --git a/app/assets/javascripts/admin/users/components/user_avatar.vue b/app/assets/javascripts/admin/users/components/user_avatar.vue
index ce22595609d..dd354794cf3 100644
--- a/app/assets/javascripts/admin/users/components/user_avatar.vue
+++ b/app/assets/javascripts/admin/users/components/user_avatar.vue
@@ -27,8 +27,6 @@ export default {
return this.adminUserPath.replace('id', this.user.username);
},
adminUserMailto() {
- // NOTE: 'mailto:' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives
- // eslint-disable-next-line @gitlab/require-i18n-strings
return `mailto:${this.user.email}`;
},
userNoteShort() {
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 84c2b216859..929f5d10956 100644
--- a/app/assets/javascripts/alert_management/components/alert_management_table.vue
+++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue
@@ -12,8 +12,8 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import getAlertsQuery from '~/graphql_shared/queries/get_alerts.query.graphql';
+import { sortObjectToString } from '~/lib/utils/table_utility';
import { fetchPolicies } from '~/lib/graphql';
-import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
import { s__, __, n__ } from '~/locale';
import AlertStatus from '~/vue_shared/alert_details/components/alert_status.vue';
@@ -213,11 +213,8 @@ export default {
},
methods: {
fetchSortedData({ sortBy, sortDesc }) {
- const sortingDirection = sortDesc ? 'DESC' : 'ASC';
- const sortingColumn = convertToSnakeCase(sortBy).toUpperCase();
-
this.pagination = initialPaginationState;
- this.sort = `${sortingColumn}_${sortingDirection}`;
+ this.sort = sortObjectToString({ sortBy, sortDesc });
},
navigateToAlertDetails({ iid }, index, { metaKey }) {
return visitUrl(joinPaths(window.location.pathname, iid, 'details'), metaKey);
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 8c996b448aa..35fc64d43e5 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -92,6 +92,7 @@ const Api = {
groupNotificationSettingsPath: '/api/:version/groups/:id/notification_settings',
notificationSettingsPath: '/api/:version/notification_settings',
deployKeysPath: '/api/:version/deploy_keys',
+ secureFilesPath: '/api/:version/projects/:project_id/secure_files',
group(groupId, callback = () => {}) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
@@ -957,6 +958,13 @@ const Api = {
return axios.get(url, { params: { per_page: DEFAULT_PER_PAGE, ...params } });
},
+ // TODO: replace this when GraphQL support has been added https://gitlab.com/gitlab-org/gitlab/-/issues/352184
+ projectSecureFiles(projectId, options = {}) {
+ const url = Api.buildUrl(this.secureFilesPath).replace(':project_id', projectId);
+
+ return axios.get(url, { params: { per_page: DEFAULT_PER_PAGE, ...options } });
+ },
+
async updateNotificationSettings(projectId, groupId, data = {}) {
let url = Api.buildUrl(this.notificationSettingsPath);
diff --git a/app/assets/javascripts/attention_requests/components/navigation_popover.vue b/app/assets/javascripts/attention_requests/components/navigation_popover.vue
new file mode 100644
index 00000000000..1542bc9a7e9
--- /dev/null
+++ b/app/assets/javascripts/attention_requests/components/navigation_popover.vue
@@ -0,0 +1,120 @@
+<script>
+import { GlPopover, GlSprintf, GlButton, GlLink, GlIcon } from '@gitlab/ui';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
+
+export default {
+ components: {
+ GlPopover,
+ GlSprintf,
+ GlButton,
+ GlLink,
+ GlIcon,
+ UserCalloutDismisser,
+ },
+ inject: {
+ message: {
+ default: '',
+ },
+ observerElSelector: {
+ default: '',
+ },
+ observerElToggledClass: {
+ default: '',
+ },
+ featureName: {
+ default: '',
+ },
+ popoverTarget: {
+ default: '',
+ },
+ showAttentionIcon: {
+ default: false,
+ },
+ delay: {
+ default: 0,
+ },
+ popoverCssClass: {
+ default: '',
+ },
+ },
+ data() {
+ return {
+ showPopover: false,
+ popoverPlacement: this.popoverPosition(),
+ };
+ },
+ mounted() {
+ this.observeEl = document.querySelector(this.observerElSelector);
+ this.observer = new MutationObserver(this.callback);
+ this.observer.observe(this.observeEl, {
+ attributes: true,
+ });
+ this.callback();
+
+ window.addEventListener('resize', () => {
+ this.popoverPlacement = this.popoverPosition();
+ });
+ },
+ beforeDestroy() {
+ this.observer.disconnect();
+ },
+ methods: {
+ callback() {
+ if (this.showPopover) {
+ this.$root.$emit('bv::hide::popover');
+ }
+
+ setTimeout(() => this.toggleShowPopover(), this.delay);
+ },
+ toggleShowPopover() {
+ this.showPopover = this.observeEl.classList.contains(this.observerElToggledClass);
+ },
+ getPopoverTarget() {
+ return document.querySelector(this.popoverTarget);
+ },
+ popoverPosition() {
+ if (bp.isDesktop()) {
+ return 'left';
+ }
+
+ return 'bottom';
+ },
+ },
+ docsPage: helpPagePath('development/code_review.html'),
+};
+</script>
+
+<template>
+ <user-callout-dismisser :feature-name="featureName">
+ <template #default="{ shouldShowCallout, dismiss }">
+ <gl-popover
+ v-if="shouldShowCallout"
+ :show-close-button="false"
+ :target="() => getPopoverTarget()"
+ :show="showPopover"
+ :delay="0"
+ triggers="manual"
+ :placement="popoverPlacement"
+ boundary="window"
+ no-fade
+ :css-classes="[popoverCssClass]"
+ >
+ <p v-for="(m, index) in message" :key="index" class="gl-mb-5">
+ <gl-sprintf :message="m">
+ <template #strong="{ content }">
+ <strong><gl-icon v-if="showAttentionIcon" name="attention" /> {{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-button size="small" variant="confirm" class="gl-mr-5" @click.prevent.stop="dismiss">
+ {{ __('Got it!') }}
+ </gl-button>
+ <gl-link :href="$options.docsPage" target="_blank">{{ __('Learn more') }}</gl-link>
+ </div>
+ </gl-popover>
+ </template>
+ </user-callout-dismisser>
+</template>
diff --git a/app/assets/javascripts/attention_requests/index.js b/app/assets/javascripts/attention_requests/index.js
new file mode 100644
index 00000000000..2a142ab46e5
--- /dev/null
+++ b/app/assets/javascripts/attention_requests/index.js
@@ -0,0 +1,73 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { __ } from '~/locale';
+import createDefaultClient from '~/lib/graphql';
+import NavigationPopover from './components/navigation_popover.vue';
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
+export const initTopNavPopover = () => {
+ const el = document.getElementById('js-need-attention-nav-onboarding');
+
+ if (!el) return;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ apolloProvider,
+ provide: {
+ observerElSelector: '.user-counter.dropdown',
+ observerElToggledClass: 'show',
+ message: [
+ __(
+ '%{strongStart}Need your attention%{strongEnd} are the merge requests that need your help to move forward, as an assignee or reviewer.',
+ ),
+ ],
+ featureName: 'attention_requests_top_nav',
+ popoverTarget: '#js-need-attention-nav',
+ },
+ render(h) {
+ return h(NavigationPopover);
+ },
+ });
+};
+
+export const initSideNavPopover = () => {
+ const el = document.getElementById('js-need-attention-sidebar-onboarding');
+
+ if (!el) return;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ apolloProvider,
+ provide: {
+ observerElSelector: '.js-right-sidebar',
+ observerElToggledClass: 'right-sidebar-expanded',
+ message: [
+ __(
+ 'To ask someone to look at a merge request, select %{strongStart}Request attention%{strongEnd}. Select again to remove the request.',
+ ),
+ __(
+ 'Some actions remove attention requests, like a reviewer approving or anyone merging the merge request.',
+ ),
+ ],
+ featureName: 'attention_requests_side_nav',
+ popoverTarget: '.js-attention-request-toggle',
+ showAttentionIcon: true,
+ delay: 500,
+ popoverCssClass: 'attention-request-sidebar-popover',
+ },
+ render(h) {
+ return h(NavigationPopover);
+ },
+ });
+};
+
+export default () => {
+ initTopNavPopover();
+};
diff --git a/app/assets/javascripts/authentication/webauthn/util.js b/app/assets/javascripts/authentication/webauthn/util.js
index eeda2bfaeaf..2a0740cf488 100644
--- a/app/assets/javascripts/authentication/webauthn/util.js
+++ b/app/assets/javascripts/authentication/webauthn/util.js
@@ -46,6 +46,17 @@ export const bufferToBase64 = (input) => {
};
/**
+ * Return a URL-safe base64 string.
+ *
+ * RFC: https://datatracker.ietf.org/doc/html/rfc4648#section-5
+ * @param {String} base64Str
+ * @returns {String}
+ */
+export const base64ToBase64Url = (base64Str) => {
+ return base64Str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+};
+
+/**
* Returns a copy of the given object with the id property converted to buffer
*
* @param {Object} param
diff --git a/app/assets/javascripts/behaviors/markdown/editor_extensions.js b/app/assets/javascripts/behaviors/markdown/editor_extensions.js
index b512e4dbc8b..165031c3e7d 100644
--- a/app/assets/javascripts/behaviors/markdown/editor_extensions.js
+++ b/app/assets/javascripts/behaviors/markdown/editor_extensions.js
@@ -48,54 +48,48 @@ import Video from './nodes/video';
// from GFM should have a node or mark here.
// The GFM-to-HTML-to-GFM cycle is tested in spec/features/markdown/copy_as_gfm_spec.rb.
-export default [
- new Doc(),
- new Paragraph(),
- new Text(),
+export default {
+ nodes: [
+ Doc(),
+ Paragraph(),
+ Text(),
- new Blockquote(),
- new CodeBlock(),
- new HardBreak(),
- new Heading({ maxLevel: 6 }),
- new HorizontalRule(),
- new Image(),
+ Blockquote(),
+ CodeBlock(),
+ HardBreak(),
+ Heading(),
+ HorizontalRule(),
+ Image(),
- new Table(),
- new TableHead(),
- new TableBody(),
- new TableHeaderRow(),
- new TableRow(),
- new TableCell(),
+ Table(),
+ TableHead(),
+ TableBody(),
+ TableHeaderRow(),
+ TableRow(),
+ TableCell(),
- new Emoji(),
- new Reference(),
+ Emoji(),
+ Reference(),
- new TableOfContents(),
- new Video(),
- new Audio(),
+ TableOfContents(),
+ Video(),
+ Audio(),
- new BulletList(),
- new OrderedList(),
- new ListItem(),
+ BulletList(),
+ OrderedList(),
+ ListItem(),
- new DescriptionList(),
- new DescriptionTerm(),
- new DescriptionDetails(),
+ DescriptionList(),
+ DescriptionTerm(),
+ DescriptionDetails(),
- new TaskList(),
- new OrderedTaskList(),
- new TaskListItem(),
+ TaskList(),
+ OrderedTaskList(),
+ TaskListItem(),
- new Summary(),
- new Details(),
+ Summary(),
+ Details(),
+ ],
- new Bold(),
- new Italic(),
- new Strike(),
- new InlineDiff(),
-
- new Link(),
- new Code(),
- new MathMark(),
- new InlineHTML(),
-];
+ marks: [Bold(), Italic(), Strike(), InlineDiff(), Link(), Code(), MathMark(), InlineHTML()],
+};
diff --git a/app/assets/javascripts/behaviors/markdown/marks/bold.js b/app/assets/javascripts/behaviors/markdown/marks/bold.js
index 89e373220af..dd730947a5f 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/bold.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/bold.js
@@ -1,11 +1,17 @@
-/* eslint-disable class-methods-use-this */
-
-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 {
- get toMarkdown() {
- return defaultMarkdownSerializer.marks.strong;
- }
-}
+export default () => {
+ return {
+ name: 'bold',
+ schema: {
+ parseDOM: [
+ {
+ tag: 'strong',
+ },
+ ],
+ toDOM: () => ['strong', 0],
+ },
+ toMarkdown: defaultMarkdownSerializer.marks.strong,
+ };
+};
diff --git a/app/assets/javascripts/behaviors/markdown/marks/code.js b/app/assets/javascripts/behaviors/markdown/marks/code.js
index 68368dec676..ea5af8b4a1f 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/code.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/code.js
@@ -1,11 +1,12 @@
-/* eslint-disable class-methods-use-this */
-
-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 {
- get toMarkdown() {
- return defaultMarkdownSerializer.marks.code;
- }
-}
+export default () => ({
+ name: 'code',
+ schema: {
+ excludes: '_',
+ parseDOM: [{ tag: 'code' }],
+ toDOM: () => ['code', 0],
+ },
+ toMarkdown: defaultMarkdownSerializer.marks.code,
+});
diff --git a/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js b/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js
index 7f1506cd5d9..69d345c81e4 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js
@@ -1,41 +1,29 @@
-/* eslint-disable class-methods-use-this */
-
-import { Mark } from 'tiptap';
-
// Transforms generated HTML back to GFM for Banzai::Filter::InlineDiffFilter
-export default class InlineDiff extends Mark {
- get name() {
- return 'inline_diff';
- }
-
- get schema() {
- return {
- attrs: {
- addition: {
- default: true,
- },
+export default () => ({
+ name: 'inline_diff',
+ schema: {
+ attrs: {
+ addition: {
+ default: true,
},
- parseDOM: [
- { tag: 'span.idiff.addition', attrs: { addition: true } },
- { tag: 'span.idiff.deletion', attrs: { addition: false } },
- ],
- toDOM: (node) => [
- 'span',
- { class: `idiff left right ${node.attrs.addition ? 'addition' : 'deletion'}` },
- 0,
- ],
- };
- }
-
- get toMarkdown() {
- return {
- mixable: true,
- open(state, mark) {
- return mark.attrs.addition ? '{+' : '{-';
- },
- close(state, mark) {
- return mark.attrs.addition ? '+}' : '-}';
- },
- };
- }
-}
+ },
+ parseDOM: [
+ { tag: 'span.idiff.addition', attrs: { addition: true } },
+ { tag: 'span.idiff.deletion', attrs: { addition: false } },
+ ],
+ toDOM: (node) => [
+ 'span',
+ { class: `idiff left right ${node.attrs.addition ? 'addition' : 'deletion'}` },
+ 0,
+ ],
+ },
+ toMarkdown: {
+ mixable: true,
+ open(_, mark) {
+ return mark.attrs.addition ? '{+' : '{-';
+ },
+ close(_, mark) {
+ return mark.attrs.addition ? '+}' : '-}';
+ },
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/marks/inline_html.js b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js
index e957f81b774..4520598e0ab 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/inline_html.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js
@@ -1,46 +1,35 @@
-/* eslint-disable class-methods-use-this */
-
import { escape } from 'lodash';
-import { Mark } from 'tiptap';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class InlineHTML extends Mark {
- get name() {
- return 'inline_html';
- }
-
- get schema() {
- return {
- excludes: '',
- attrs: {
- tag: {},
- title: { default: null },
- },
- parseDOM: [
- {
- tag: 'sup, sub, kbd, q, samp, var',
- getAttrs: (el) => ({ tag: el.nodeName.toLowerCase() }),
- },
- {
- tag: 'abbr',
- getAttrs: (el) => ({ tag: 'abbr', title: el.getAttribute('title') }),
- },
- ],
- toDOM: (node) => [node.attrs.tag, { title: node.attrs.title }, 0],
- };
- }
-
- get toMarkdown() {
- return {
- mixable: true,
- open(state, mark) {
- return `<${mark.attrs.tag}${
- mark.attrs.title ? ` title="${state.esc(escape(mark.attrs.title))}"` : ''
- }>`;
+export default () => ({
+ name: 'inline_html',
+ schema: {
+ excludes: '',
+ attrs: {
+ tag: {},
+ title: { default: null },
+ },
+ parseDOM: [
+ {
+ tag: 'sup, sub, kbd, q, samp, var',
+ getAttrs: (el) => ({ tag: el.nodeName.toLowerCase() }),
},
- close(state, mark) {
- return `</${mark.attrs.tag}>`;
+ {
+ tag: 'abbr',
+ getAttrs: (el) => ({ tag: 'abbr', title: el.getAttribute('title') }),
},
- };
- }
-}
+ ],
+ toDOM: (node) => [node.attrs.tag, { title: node.attrs.title }, 0],
+ },
+ toMarkdown: {
+ mixable: true,
+ open(state, mark) {
+ return `<${mark.attrs.tag}${
+ mark.attrs.title ? ` title="${state.esc(escape(mark.attrs.title))}"` : ''
+ }>`;
+ },
+ close(_, mark) {
+ return `</${mark.attrs.tag}>`;
+ },
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/marks/italic.js b/app/assets/javascripts/behaviors/markdown/marks/italic.js
index 7dc86102f18..3ec8f0071e9 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/italic.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/italic.js
@@ -1,11 +1,11 @@
-/* eslint-disable class-methods-use-this */
-
-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 {
- get toMarkdown() {
- return defaultMarkdownSerializer.marks.em;
- }
-}
+export default () => ({
+ name: 'italic',
+ schema: {
+ parseDOM: [{ tag: 'em' }],
+ toDOM: () => ['em', 0],
+ },
+ toMarkdown: defaultMarkdownSerializer.marks.em,
+});
diff --git a/app/assets/javascripts/behaviors/markdown/marks/link.js b/app/assets/javascripts/behaviors/markdown/marks/link.js
index b5e09017d83..977453fee01 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/link.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/link.js
@@ -1,21 +1,47 @@
-/* eslint-disable class-methods-use-this */
-
-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 {
- get toMarkdown() {
- return {
- mixable: true,
- open(state, mark, parent, index) {
- const open = defaultMarkdownSerializer.marks.link.open(state, mark, parent, index);
- return open === '<' ? '' : open;
+export default () => ({
+ name: 'link',
+ schema: {
+ attrs: {
+ href: {
+ default: null,
+ },
+ target: {
+ default: null,
+ },
+ },
+ inclusive: false,
+ parseDOM: [
+ {
+ tag: 'a[href]',
+ getAttrs: (dom) => ({
+ href: dom.getAttribute('href'),
+ target: dom.getAttribute('target'),
+ }),
},
- close(state, mark, parent, index) {
- const close = defaultMarkdownSerializer.marks.link.close(state, mark, parent, index);
- return close === '>' ? '' : close;
+ ],
+ toDOM: (node) => [
+ 'a',
+ {
+ ...node.attrs,
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ rel: 'noopener noreferrer nofollow',
+ target: node.attrs.target,
},
- };
- }
-}
+ 0,
+ ],
+ },
+ toMarkdown: {
+ mixable: true,
+ open(state, mark, parent, index) {
+ const open = defaultMarkdownSerializer.marks.link.open(state, mark, parent, index);
+ return open === '<' ? '' : open;
+ },
+ close(state, mark, parent, index) {
+ const close = defaultMarkdownSerializer.marks.link.close(state, mark, parent, index);
+ return close === '>' ? '' : close;
+ },
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/marks/math.js b/app/assets/javascripts/behaviors/markdown/marks/math.js
index ca25ff7d07d..a50a649b6eb 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/math.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/math.js
@@ -1,42 +1,31 @@
-/* eslint-disable class-methods-use-this */
-
-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
-export default class MathMark extends Mark {
- get name() {
- return 'math';
- }
-
- get schema() {
- return {
- parseDOM: [
- // Matches HTML generated by Banzai::Filter::MathFilter
- {
- tag: 'code.code.math[data-math-style=inline]',
- priority: HIGHER_PARSE_RULE_PRIORITY,
- },
- // Matches HTML after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js
- {
- tag: 'span.katex',
- contentElement: 'annotation[encoding="application/x-tex"]',
- },
- ],
- toDOM: () => ['code', { class: 'code math', 'data-math-style': 'inline' }, 0],
- };
- }
-
- get toMarkdown() {
- return {
- escape: false,
- open(state, mark, parent, index) {
- return `$${defaultMarkdownSerializer.marks.code.open(state, mark, parent, index)}`;
+export default () => ({
+ name: 'math',
+ schema: {
+ parseDOM: [
+ // Matches HTML generated by Banzai::Filter::MathFilter
+ {
+ tag: 'code.code.math[data-math-style=inline]',
+ priority: HIGHER_PARSE_RULE_PRIORITY,
},
- close(state, mark, parent, index) {
- return `${defaultMarkdownSerializer.marks.code.close(state, mark, parent, index)}$`;
+ // Matches HTML after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js
+ {
+ tag: 'span.katex',
+ contentElement: 'annotation[encoding="application/x-tex"]',
},
- };
- }
-}
+ ],
+ toDOM: () => ['code', { class: 'code math', 'data-math-style': 'inline' }, 0],
+ },
+ toMarkdown: {
+ escape: false,
+ open(state, mark, parent, index) {
+ return `$${defaultMarkdownSerializer.marks.code.open(state, mark, parent, index)}`;
+ },
+ close(state, mark, parent, index) {
+ return `${defaultMarkdownSerializer.marks.code.close(state, mark, parent, index)}$`;
+ },
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/marks/strike.js b/app/assets/javascripts/behaviors/markdown/marks/strike.js
index c2951a40a4b..967c0a120cd 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/strike.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/strike.js
@@ -1,15 +1,18 @@
-/* eslint-disable class-methods-use-this */
-
-import { Strike as BaseStrike } from 'tiptap-extensions';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class Strike extends BaseStrike {
- get toMarkdown() {
- return {
- open: '~~',
- close: '~~',
- mixable: true,
- expelEnclosingWhitespace: true,
- };
- }
-}
+export default () => ({
+ name: 'strike',
+ schema: {
+ parseDOM: [
+ {
+ tag: 'del',
+ },
+ ],
+ toDOM: () => ['s', 0],
+ },
+ toMarkdown: {
+ open: '~~',
+ close: '~~',
+ mixable: true,
+ expelEnclosingWhitespace: true,
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/audio.js b/app/assets/javascripts/behaviors/markdown/nodes/audio.js
index 146349b118c..97ab86c6d23 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/audio.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/audio.js
@@ -1,9 +1,4 @@
-import Playable from './playable';
+import playable from './playable';
// Transforms generated HTML back to GFM for Banzai::Filter::AudioLinkFilter
-export default class Audio extends Playable {
- constructor() {
- super();
- this.mediaType = 'audio';
- }
-}
+export default () => playable({ mediaType: 'audio' });
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js b/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js
index 8b14a04e2fe..6a4552d47e4 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js
@@ -1,13 +1,19 @@
-/* eslint-disable class-methods-use-this */
-
-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 {
+export default () => ({
+ name: 'blockquote',
+ schema: {
+ content: 'block*',
+ group: 'block',
+ defining: true,
+ draggable: false,
+ parseDOM: [{ tag: 'blockquote' }],
+ toDOM: () => ['blockquote', 0],
+ },
toMarkdown(state, node) {
if (!node.childCount) return;
defaultMarkdownSerializer.nodes.blockquote(state, node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js b/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js
index ef1eafaa419..95cd3605da5 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js
@@ -1,11 +1,15 @@
-/* eslint-disable class-methods-use-this */
-
-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 {
+export default () => ({
+ name: 'bullet_list',
+ schema: {
+ content: 'list_item+',
+ group: 'block',
+ parseDOM: [{ tag: 'ul' }],
+ toDOM: () => ['ul', 0],
+ },
toMarkdown(state, node) {
defaultMarkdownSerializer.nodes.bullet_list(state, node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/code_block.js b/app/assets/javascripts/behaviors/markdown/nodes/code_block.js
index cd90d67c60d..0ff59779e7d 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/code_block.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/code_block.js
@@ -1,7 +1,3 @@
-/* eslint-disable class-methods-use-this */
-
-import { CodeBlock as BaseCodeBlock } from 'tiptap-extensions';
-
const PLAINTEXT_LANG = 'plaintext';
// Transforms generated HTML back to GFM for:
@@ -9,68 +5,67 @@ const PLAINTEXT_LANG = 'plaintext';
// - Banzai::Filter::MathFilter
// - Banzai::Filter::MermaidFilter
// - Banzai::Filter::SuggestionFilter
-export default class CodeBlock extends BaseCodeBlock {
- get schema() {
- return {
- content: 'text*',
- marks: '',
- group: 'block',
- code: true,
- defining: true,
- attrs: {
- lang: { default: PLAINTEXT_LANG },
- },
- parseDOM: [
- // Matches HTML generated by Banzai::Filter::SyntaxHighlightFilter, Banzai::Filter::MathFilter, Banzai::Filter::MermaidFilter, or Banzai::Filter::SuggestionFilter
- {
- tag: 'pre.code.highlight',
- preserveWhitespace: 'full',
- getAttrs: (el) => {
- const lang = el.getAttribute('lang');
- if (!lang || lang === '') return {};
+export default () => ({
+ name: 'code_block',
+ schema: {
+ content: 'text*',
+ marks: '',
+ group: 'block',
+ code: true,
+ defining: true,
+ attrs: {
+ lang: { default: PLAINTEXT_LANG },
+ },
+ parseDOM: [
+ // Matches HTML generated by Banzai::Filter::SyntaxHighlightFilter, Banzai::Filter::MathFilter, Banzai::Filter::MermaidFilter, or Banzai::Filter::SuggestionFilter
+ {
+ tag: 'pre.code.highlight',
+ preserveWhitespace: 'full',
+ getAttrs: (el) => {
+ const lang = el.getAttribute('lang');
+ if (!lang || lang === '') return {};
- return { lang };
- },
- },
- // Matches HTML generated by Banzai::Filter::MathFilter,
- // after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js
- {
- tag: 'span.katex-display',
- preserveWhitespace: 'full',
- contentElement: 'annotation[encoding="application/x-tex"]',
- attrs: { lang: 'math' },
- },
- // Matches HTML generated by Banzai::Filter::MermaidFilter,
- // after being transformed by app/assets/javascripts/behaviors/markdown/render_mermaid.js
- {
- tag: 'svg.mermaid',
- preserveWhitespace: 'full',
- contentElement: 'text.source',
- attrs: { lang: 'mermaid' },
- },
- // Matches HTML generated by Banzai::Filter::SuggestionFilter,
- // after being transformed by app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
- {
- tag: '.md-suggestion',
- skip: true,
- },
- {
- tag: '.md-suggestion-header',
- ignore: true,
+ return { lang };
},
- {
- tag: '.md-suggestion-diff',
- preserveWhitespace: 'full',
- getContent: (el, schema) =>
- [...el.querySelectorAll('.line_content.new span')].map((span) =>
- schema.text(span.innerText),
- ),
- attrs: { lang: 'suggestion' },
- },
- ],
- toDOM: (node) => ['pre', { class: 'code highlight', lang: node.attrs.lang }, ['code', 0]],
- };
- }
+ },
+ // Matches HTML generated by Banzai::Filter::MathFilter,
+ // after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js
+ {
+ tag: 'span.katex-display',
+ preserveWhitespace: 'full',
+ contentElement: 'annotation[encoding="application/x-tex"]',
+ attrs: { lang: 'math' },
+ },
+ // Matches HTML generated by Banzai::Filter::MermaidFilter,
+ // after being transformed by app/assets/javascripts/behaviors/markdown/render_mermaid.js
+ {
+ tag: 'svg.mermaid',
+ preserveWhitespace: 'full',
+ contentElement: 'text.source',
+ attrs: { lang: 'mermaid' },
+ },
+ // Matches HTML generated by Banzai::Filter::SuggestionFilter,
+ // after being transformed by app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+ {
+ tag: '.md-suggestion',
+ skip: true,
+ },
+ {
+ tag: '.md-suggestion-header',
+ ignore: true,
+ },
+ {
+ tag: '.md-suggestion-diff',
+ preserveWhitespace: 'full',
+ getContent: (el, schema) =>
+ [...el.querySelectorAll('.line_content.new span')].map((span) =>
+ schema.text(span.innerText),
+ ),
+ attrs: { lang: 'suggestion' },
+ },
+ ],
+ toDOM: (node) => ['pre', { class: 'code highlight', lang: node.attrs.lang }, ['code', 0]],
+ },
toMarkdown(state, node) {
if (!node.childCount) return;
@@ -95,5 +90,5 @@ export default class CodeBlock extends BaseCodeBlock {
state.write('```');
state.closeBlock(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/description_details.js b/app/assets/javascripts/behaviors/markdown/nodes/description_details.js
index a4451d8ce8d..20760286045 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/description_details.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/description_details.js
@@ -1,22 +1,14 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class DescriptionDetails extends Node {
- get name() {
- return 'description_details';
- }
+export default () => ({
+ name: 'description_details',
- get schema() {
- return {
- content: 'text*',
- marks: '',
- defining: true,
- parseDOM: [{ tag: 'dd' }],
- toDOM: () => ['dd', 0],
- };
- }
+ schema: {
+ content: 'text*',
+ marks: '',
+ defining: true,
+ parseDOM: [{ tag: 'dd' }],
+ toDOM: () => ['dd', 0],
+ },
toMarkdown(state, node) {
state.flushClose(1);
@@ -24,5 +16,5 @@ export default class DescriptionDetails extends Node {
state.text(node.textContent, false);
state.write('</dd>');
state.closeBlock(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/description_list.js b/app/assets/javascripts/behaviors/markdown/nodes/description_list.js
index 6aa1aca29d7..c5305c48423 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/description_list.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/description_list.js
@@ -1,21 +1,12 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class DescriptionList extends Node {
- get name() {
- return 'description_list';
- }
-
- get schema() {
- return {
- content: '(description_term+ description_details+)+',
- group: 'block',
- parseDOM: [{ tag: 'dl' }],
- toDOM: () => ['dl', 0],
- };
- }
+export default () => ({
+ name: 'description_list',
+ schema: {
+ content: '(description_term+ description_details+)+',
+ group: 'block',
+ parseDOM: [{ tag: 'dl' }],
+ toDOM: () => ['dl', 0],
+ },
toMarkdown(state, node) {
state.write('<dl>\n');
@@ -24,5 +15,5 @@ export default class DescriptionList extends Node {
state.ensureNewLine();
state.write('</dl>');
state.closeBlock(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/description_term.js b/app/assets/javascripts/behaviors/markdown/nodes/description_term.js
index 89057ec6444..f78f7f13fc4 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/description_term.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/description_term.js
@@ -1,28 +1,18 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class DescriptionTerm extends Node {
- get name() {
- return 'description_term';
- }
-
- get schema() {
- return {
- content: 'text*',
- marks: '',
- defining: true,
- parseDOM: [{ tag: 'dt' }],
- toDOM: () => ['dt', 0],
- };
- }
-
+export default () => ({
+ name: 'description_term',
+ schema: {
+ content: 'text*',
+ marks: '',
+ defining: true,
+ parseDOM: [{ tag: 'dt' }],
+ toDOM: () => ['dt', 0],
+ },
toMarkdown(state, node) {
state.flushClose(state.closed && state.closed.type === node.type ? 1 : 2);
state.write('<dt>');
state.text(node.textContent, false);
state.write('</dt>');
state.closeBlock(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/details.js b/app/assets/javascripts/behaviors/markdown/nodes/details.js
index 1c40dbb8168..9fb0d60b93a 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/details.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/details.js
@@ -1,22 +1,12 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class Details extends Node {
- get name() {
- return 'details';
- }
-
- get schema() {
- return {
- content: 'summary block*',
- group: 'block',
- parseDOM: [{ tag: 'details' }],
- toDOM: () => ['details', { open: true, onclick: 'return false', tabindex: '-1' }, 0],
- };
- }
-
+export default () => ({
+ name: 'details',
+ schema: {
+ content: 'summary block*',
+ group: 'block',
+ parseDOM: [{ tag: 'details' }],
+ toDOM: () => ['details', { open: true, onclick: 'return false', tabindex: '-1' }, 0],
+ },
toMarkdown(state, node) {
state.write('<details>\n');
state.renderContent(node);
@@ -24,5 +14,5 @@ export default class Details extends Node {
state.ensureNewLine();
state.write('</details>');
state.closeBlock(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/doc.js b/app/assets/javascripts/behaviors/markdown/nodes/doc.js
index 88b16fd85da..3101e6e0e3a 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/doc.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/doc.js
@@ -1,15 +1,6 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-
-export default class Doc extends Node {
- get name() {
- return 'doc';
- }
-
- get schema() {
- return {
- content: 'block+',
- };
- }
-}
+export default () => ({
+ name: 'doc',
+ schema: {
+ content: 'block+',
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/emoji.js b/app/assets/javascripts/behaviors/markdown/nodes/emoji.js
index 9d0890aa1b4..086c277bad4 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/emoji.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/emoji.js
@@ -1,53 +1,43 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-
// Transforms generated HTML back to GFM for Banzai::Filter::EmojiFilter
-export default class Emoji extends Node {
- get name() {
- return 'emoji';
- }
-
- get schema() {
- return {
- inline: true,
- group: 'inline',
- attrs: {
- name: {},
- title: {},
- moji: {},
+export default () => ({
+ name: 'emoji',
+ schema: {
+ inline: true,
+ group: 'inline',
+ attrs: {
+ name: {},
+ title: {},
+ moji: {},
+ },
+ parseDOM: [
+ {
+ tag: 'gl-emoji',
+ getAttrs: (el) => ({
+ name: el.dataset.name,
+ title: el.getAttribute('title'),
+ moji: el.textContent,
+ }),
},
- parseDOM: [
- {
- tag: 'gl-emoji',
- getAttrs: (el) => ({
- name: el.dataset.name,
- title: el.getAttribute('title'),
- moji: el.textContent,
- }),
- },
- {
- tag: 'img.emoji',
- getAttrs: (el) => {
- const name = el.getAttribute('title').replace(/^:|:$/g, '');
+ {
+ tag: 'img.emoji',
+ getAttrs: (el) => {
+ const name = el.getAttribute('title').replace(/^:|:$/g, '');
- return {
- name,
- title: name,
- moji: name,
- };
- },
+ return {
+ name,
+ title: name,
+ moji: name,
+ };
},
- ],
- toDOM: (node) => [
- 'gl-emoji',
- { 'data-name': node.attrs.name, title: node.attrs.title },
- node.attrs.moji,
- ],
- };
- }
-
+ },
+ ],
+ toDOM: (node) => [
+ 'gl-emoji',
+ { 'data-name': node.attrs.name, title: node.attrs.title },
+ node.attrs.moji,
+ ],
+ },
toMarkdown(state, node) {
state.write(`:${node.attrs.name}:`);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js b/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js
index 59e5d8ab3e2..1668af9c3f4 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js
@@ -1,10 +1,14 @@
-/* eslint-disable class-methods-use-this */
-
-import { HardBreak as BaseHardBreak } from 'tiptap-extensions';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class HardBreak extends BaseHardBreak {
+export default () => ({
+ name: 'hard_break',
+ schema: {
+ inline: true,
+ group: 'inline',
+ selectable: false,
+ parseDOM: [{ tag: 'br' }],
+ toDOM: () => ['br'],
+ },
toMarkdown(state) {
if (!state.atBlank()) state.write(' \n');
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/heading.js b/app/assets/javascripts/behaviors/markdown/nodes/heading.js
index 29967e61ffa..21b4ec69b70 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/heading.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/heading.js
@@ -1,13 +1,27 @@
-/* eslint-disable class-methods-use-this */
-
-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 {
+export default ({ levels = [1, 2, 3, 4, 5, 6] } = {}) => ({
+ name: 'heading',
+ schema: {
+ attrs: {
+ level: {
+ default: 1,
+ },
+ },
+ content: 'inline*',
+ group: 'block',
+ defining: true,
+ draggable: false,
+ parseDOM: levels.map((level) => ({
+ tag: `h${level}`,
+ attrs: { level },
+ })),
+ toDOM: (node) => [`h${node.attrs.level}`, 0],
+ },
toMarkdown(state, node) {
if (!node.childCount) return;
defaultMarkdownSerializer.nodes.heading(state, node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js b/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js
index ee3aa145dc3..2d7074e567f 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js
@@ -1,11 +1,14 @@
-/* eslint-disable class-methods-use-this */
-
-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 {
+export default () => ({
+ name: 'horizontal_rule',
+ schema: {
+ group: 'block',
+ parseDOM: [{ tag: 'hr' }],
+ toDOM: () => ['hr'],
+ },
toMarkdown(state, node) {
defaultMarkdownSerializer.nodes.horizontal_rule(state, node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/image.js b/app/assets/javascripts/behaviors/markdown/nodes/image.js
index 16647d2f96e..370cc347a05 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/image.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/image.js
@@ -1,53 +1,48 @@
-/* eslint-disable class-methods-use-this */
-
-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 {
- get schema() {
- return {
- attrs: {
- src: {},
- alt: {
- default: null,
- },
- title: {
- default: null,
- },
+export default () => ({
+ name: 'image',
+ schema: {
+ attrs: {
+ src: {},
+ alt: {
+ default: null,
},
- group: 'inline',
- inline: true,
- draggable: true,
- parseDOM: [
- // Matches HTML generated by Banzai::Filter::ImageLinkFilter
- {
- tag: 'a.no-attachment-icon',
- priority: HIGHER_PARSE_RULE_PRIORITY,
- skip: true,
- },
- // Matches HTML generated by Banzai::Filter::ImageLazyLoadFilter
- {
- tag: 'img[src]:not(.emoji)',
- getAttrs: (el) => {
- const imageSrc = el.src;
- const imageUrl =
- imageSrc && imageSrc !== placeholderImage ? imageSrc : el.dataset.src || '';
+ title: {
+ default: null,
+ },
+ },
+ group: 'inline',
+ inline: true,
+ draggable: true,
+ parseDOM: [
+ // Matches HTML generated by Banzai::Filter::ImageLinkFilter
+ {
+ tag: 'a.no-attachment-icon',
+ priority: HIGHER_PARSE_RULE_PRIORITY,
+ skip: true,
+ },
+ // Matches HTML generated by Banzai::Filter::ImageLazyLoadFilter
+ {
+ tag: 'img[src]:not(.emoji)',
+ getAttrs: (el) => {
+ const imageSrc = el.src;
+ const imageUrl =
+ imageSrc && imageSrc !== placeholderImage ? imageSrc : el.dataset.src || '';
- return {
- src: imageUrl,
- title: el.getAttribute('title'),
- alt: el.getAttribute('alt'),
- };
- },
+ return {
+ src: imageUrl,
+ title: el.getAttribute('title'),
+ alt: el.getAttribute('alt'),
+ };
},
- ],
- toDOM: (node) => ['img', node.attrs],
- };
- }
-
+ },
+ ],
+ toDOM: (node) => ['img', node.attrs],
+ },
toMarkdown(state, node) {
defaultMarkdownSerializer.nodes.image(state, node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/list_item.js
index 7204b7c09ba..97c1f07427d 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/list_item.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/list_item.js
@@ -1,11 +1,16 @@
-/* eslint-disable class-methods-use-this */
-
-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 {
+export default () => ({
+ name: 'list_item',
+ schema: {
+ content: 'paragraph block*',
+ defining: true,
+ draggable: false,
+ parseDOM: [{ tag: 'li' }],
+ toDOM: () => ['li', 0],
+ },
toMarkdown(state, node) {
defaultMarkdownSerializer.nodes.list_item(state, node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js b/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js
index 4c1542d14ea..f2f3eff266a 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js
@@ -1,10 +1,25 @@
-/* eslint-disable class-methods-use-this */
-
-import { OrderedList as BaseOrderedList } from 'tiptap-extensions';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class OrderedList extends BaseOrderedList {
+export default () => ({
+ name: 'ordered_list',
+ schema: {
+ attrs: {
+ order: {
+ default: 1,
+ },
+ },
+ content: 'list_item+',
+ group: 'block',
+ parseDOM: [
+ {
+ tag: 'ol',
+ getAttrs: (dom) => ({
+ order: dom.hasAttribute('start') ? dom.getAttribute('start') + 1 : 1,
+ }),
+ },
+ ],
+ toDOM: (node) => (node.attrs.order === 1 ? ['ol', 0] : ['ol', { start: node.attrs.order }, 0]),
+ },
toMarkdown(state, node) {
state.renderList(node, ' ', () => '1. ');
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js b/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js
index a28d7be3758..53a6a0d9e07 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js
@@ -1,29 +1,21 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter
-export default class OrderedTaskList extends Node {
- get name() {
- return 'ordered_task_list';
- }
-
- get schema() {
- return {
- group: 'block',
- content: '(task_list_item|list_item)+',
- parseDOM: [
- {
- priority: HIGHER_PARSE_RULE_PRIORITY,
- tag: 'ol.task-list',
- },
- ],
- toDOM: () => ['ol', { class: 'task-list' }, 0],
- };
- }
+export default () => ({
+ name: 'ordered_task_list',
+ schema: {
+ group: 'block',
+ content: '(task_list_item|list_item)+',
+ parseDOM: [
+ {
+ priority: HIGHER_PARSE_RULE_PRIORITY,
+ tag: 'ol.task-list',
+ },
+ ],
+ toDOM: () => ['ol', { class: 'task-list' }, 0],
+ },
toMarkdown(state, node) {
state.renderList(node, ' ', () => '1. ');
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js b/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js
index 5fd098cd46f..310feebb390 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js
@@ -1,24 +1,15 @@
-/* eslint-disable class-methods-use-this */
-
-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 {
- get name() {
- return 'paragraph';
- }
-
- get schema() {
- return {
- content: 'inline*',
- group: 'block',
- parseDOM: [{ tag: 'p' }],
- toDOM: () => ['p', 0],
- };
- }
-
+export default () => ({
+ name: 'paragraph',
+ schema: {
+ content: 'inline*',
+ group: 'block',
+ parseDOM: [{ tag: 'p' }],
+ toDOM: () => ['p', 0],
+ },
toMarkdown(state, node) {
defaultMarkdownSerializer.nodes.paragraph(state, node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/playable.js b/app/assets/javascripts/behaviors/markdown/nodes/playable.js
index 90cbaf9ef4c..7559c2a6a8a 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/playable.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/playable.js
@@ -1,7 +1,3 @@
-/* eslint-disable class-methods-use-this */
-/* eslint-disable @gitlab/require-i18n-strings */
-
-import { Node } from 'tiptap';
import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
/**
@@ -10,62 +6,51 @@ import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer
* the `mediaType` property in their constructors.
* @abstract
*/
-export default class Playable extends Node {
- constructor() {
- super();
- this.mediaType = '';
- this.extraElementAttrs = {};
- }
-
- get name() {
- return this.mediaType;
- }
-
- get schema() {
- const attrs = {
- src: {},
- alt: {
- default: null,
- },
- };
-
- const parseDOM = [
+export default ({ mediaType, extraElementAttrs = {} }) => {
+ const attrs = {
+ src: {},
+ alt: {
+ default: null,
+ },
+ };
+ const parseDOM = [
+ {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ tag: `.${mediaType}-container`,
+ getAttrs: (el) => ({
+ src: el.querySelector(mediaType).src,
+ alt: el.querySelector(mediaType).dataset.title,
+ }),
+ },
+ ];
+ const toDOM = (node) => [
+ 'span',
+ { class: `media-container ${mediaType}-container` },
+ [
+ mediaType,
{
- tag: `.${this.mediaType}-container`,
- getAttrs: (el) => ({
- src: el.querySelector(this.mediaType).src,
- alt: el.querySelector(this.mediaType).dataset.title,
- }),
+ src: node.attrs.src,
+ controls: true,
+ 'data-setup': '{}',
+ 'data-title': node.attrs.alt,
+ ...extraElementAttrs,
},
- ];
-
- const toDOM = (node) => [
- 'span',
- { class: `media-container ${this.mediaType}-container` },
- [
- this.mediaType,
- {
- src: node.attrs.src,
- controls: true,
- 'data-setup': '{}',
- 'data-title': node.attrs.alt,
- ...this.extraElementAttrs,
- },
- ],
- ['a', { href: node.attrs.src }, node.attrs.alt],
- ];
+ ],
+ ['a', { href: node.attrs.src }, node.attrs.alt],
+ ];
- return {
+ return {
+ name: mediaType,
+ schema: {
attrs,
group: 'inline',
inline: true,
draggable: true,
parseDOM,
toDOM,
- };
- }
-
- toMarkdown(state, node) {
- defaultMarkdownSerializer.nodes.image(state, node);
- }
-}
+ },
+ toMarkdown(state, node) {
+ defaultMarkdownSerializer.nodes.image(state, node);
+ },
+ };
+};
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/reference.js b/app/assets/javascripts/behaviors/markdown/nodes/reference.js
index dd82ea58ea5..9ae6ab07004 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/reference.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/reference.js
@@ -1,53 +1,44 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
// Transforms generated HTML back to GFM for Banzai::Filter::ReferenceFilter and subclasses
-export default class Reference extends Node {
- get name() {
- return 'reference';
- }
-
- get schema() {
- return {
- inline: true,
- group: 'inline',
- atom: true,
- attrs: {
- className: {},
- referenceType: {},
- originalText: { default: null },
- href: {},
- text: {},
+export default () => ({
+ name: 'reference',
+ schema: {
+ inline: true,
+ group: 'inline',
+ atom: true,
+ attrs: {
+ className: {},
+ referenceType: {},
+ originalText: { default: null },
+ href: {},
+ text: {},
+ },
+ parseDOM: [
+ {
+ tag: 'a.gfm:not([data-link=true])',
+ priority: HIGHER_PARSE_RULE_PRIORITY,
+ getAttrs: (el) => ({
+ className: el.className,
+ referenceType: el.dataset.referenceType,
+ originalText: el.dataset.original,
+ href: el.getAttribute('href'),
+ text: el.textContent,
+ }),
},
- parseDOM: [
- {
- tag: 'a.gfm:not([data-link=true])',
- priority: HIGHER_PARSE_RULE_PRIORITY,
- getAttrs: (el) => ({
- className: el.className,
- referenceType: el.dataset.referenceType,
- originalText: el.dataset.original,
- href: el.getAttribute('href'),
- text: el.textContent,
- }),
- },
- ],
- toDOM: (node) => [
- 'a',
- {
- class: node.attrs.className,
- href: node.attrs.href,
- 'data-reference-type': node.attrs.referenceType,
- 'data-original': node.attrs.originalText,
- },
- node.attrs.text,
- ],
- };
- }
-
+ ],
+ toDOM: (node) => [
+ 'a',
+ {
+ class: node.attrs.className,
+ href: node.attrs.href,
+ 'data-reference-type': node.attrs.referenceType,
+ 'data-original': node.attrs.originalText,
+ },
+ node.attrs.text,
+ ],
+ },
toMarkdown(state, node) {
state.write(node.attrs.originalText || node.attrs.text);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/summary.js b/app/assets/javascripts/behaviors/markdown/nodes/summary.js
index 2e36e316d71..eb91b3c981e 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/summary.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/summary.js
@@ -1,27 +1,17 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class Summary extends Node {
- get name() {
- return 'summary';
- }
-
- get schema() {
- return {
- content: 'text*',
- marks: '',
- defining: true,
- parseDOM: [{ tag: 'summary' }],
- toDOM: () => ['summary', 0],
- };
- }
-
+export default () => ({
+ name: 'summary',
+ schema: {
+ content: 'text*',
+ marks: '',
+ defining: true,
+ parseDOM: [{ tag: 'summary' }],
+ toDOM: () => ['summary', 0],
+ },
toMarkdown(state, node) {
state.write('<summary>');
state.text(node.textContent, false);
state.write('</summary>');
state.closeBlock(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table.js b/app/assets/javascripts/behaviors/markdown/nodes/table.js
index a7fcb9227cd..c766f7f1fba 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/table.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/table.js
@@ -1,25 +1,15 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class Table extends Node {
- get name() {
- return 'table';
- }
-
- get schema() {
- return {
- content: 'table_head table_body',
- group: 'block',
- isolating: true,
- parseDOM: [{ tag: 'table' }],
- toDOM: () => ['table', 0],
- };
- }
-
+export default () => ({
+ name: 'table',
+ schema: {
+ content: 'table_head table_body',
+ group: 'block',
+ isolating: true,
+ parseDOM: [{ tag: 'table' }],
+ toDOM: () => ['table', 0],
+ },
toMarkdown(state, node) {
state.renderContent(node);
state.closeBlock(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_body.js b/app/assets/javascripts/behaviors/markdown/nodes/table_body.js
index 403556dc0c8..0a49fb558ae 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/table_body.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/table_body.js
@@ -1,24 +1,14 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class TableBody extends Node {
- get name() {
- return 'table_body';
- }
-
- get schema() {
- return {
- content: 'table_row+',
- parseDOM: [{ tag: 'tbody' }],
- toDOM: () => ['tbody', 0],
- };
- }
-
- toMarkdown(state, node) {
+export default () => ({
+ name: 'table_body',
+ schema: {
+ content: 'table_row+',
+ parseDOM: [{ tag: 'tbody' }],
+ toDOM: () => ['tbody', 0],
+ },
+ toMarkdown: (state, node) => {
state.flushClose(1);
state.renderContent(node);
state.closeBlock(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js b/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js
index ebb66cd4da5..f46344ba43c 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js
@@ -1,35 +1,25 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class TableCell extends Node {
- get name() {
- return 'table_cell';
- }
-
- get schema() {
- return {
- attrs: {
- header: { default: false },
- align: { default: null },
+export default () => ({
+ name: 'table_cell',
+ schema: {
+ attrs: {
+ header: { default: false },
+ align: { default: null },
+ },
+ content: 'inline*',
+ isolating: true,
+ parseDOM: [
+ {
+ tag: 'td, th',
+ getAttrs: (el) => ({
+ header: el.tagName === 'TH',
+ align: el.getAttribute('align') || el.style.textAlign,
+ }),
},
- content: 'inline*',
- isolating: true,
- parseDOM: [
- {
- tag: 'td, th',
- getAttrs: (el) => ({
- header: el.tagName === 'TH',
- align: el.getAttribute('align') || el.style.textAlign,
- }),
- },
- ],
- toDOM: (node) => [node.attrs.header ? 'th' : 'td', { align: node.attrs.align }, 0],
- };
- }
-
- toMarkdown(state, node) {
+ ],
+ toDOM: (node) => [node.attrs.header ? 'th' : 'td', { align: node.attrs.align }, 0],
+ },
+ toMarkdown: (state, node) => {
state.renderInline(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_head.js b/app/assets/javascripts/behaviors/markdown/nodes/table_head.js
index 4cb94bf088c..2e9b53ee0ac 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/table_head.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/table_head.js
@@ -1,24 +1,14 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class TableHead extends Node {
- get name() {
- return 'table_head';
- }
-
- get schema() {
- return {
- content: 'table_header_row',
- parseDOM: [{ tag: 'thead' }],
- toDOM: () => ['thead', 0],
- };
- }
-
- toMarkdown(state, node) {
+export default () => ({
+ name: 'table_head',
+ schema: {
+ content: 'table_header_row',
+ parseDOM: [{ tag: 'thead' }],
+ toDOM: () => ['thead', 0],
+ },
+ toMarkdown: (state, node) => {
state.flushClose(1);
state.renderContent(node);
state.closeBlock(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js b/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js
index 2cb2bb9e7fe..d8aa497066c 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js
@@ -1,31 +1,23 @@
-/* eslint-disable class-methods-use-this */
-
import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
import TableRow from './table_row';
const CENTER_ALIGN = 'center';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class TableHeaderRow extends TableRow {
- get name() {
- return 'table_header_row';
- }
-
- get schema() {
- return {
- content: 'table_cell+',
- parseDOM: [
- {
- tag: 'thead tr',
- priority: HIGHER_PARSE_RULE_PRIORITY,
- },
- ],
- toDOM: () => ['tr', 0],
- };
- }
-
- toMarkdown(state, node) {
- const cellWidths = super.toMarkdown(state, node);
+export default () => ({
+ name: 'table_header_row',
+ schema: {
+ content: 'table_cell+',
+ parseDOM: [
+ {
+ tag: 'thead tr',
+ priority: HIGHER_PARSE_RULE_PRIORITY,
+ },
+ ],
+ toDOM: () => ['tr', 0],
+ },
+ toMarkdown: (state, node) => {
+ const cellWidths = TableRow().toMarkdown(state, node);
state.flushClose(1);
@@ -40,5 +32,5 @@ export default class TableHeaderRow extends TableRow {
state.write('|');
state.closeBlock(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js b/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js
index db9072acc3a..4a0256c4644 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js
@@ -1,35 +1,26 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
import { __ } from '~/locale';
import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
// Transforms generated HTML back to GFM for Banzai::Filter::TableOfContentsFilter
-export default class TableOfContents extends Node {
- get name() {
- return 'table_of_contents';
- }
-
- get schema() {
- return {
- group: 'block',
- atom: true,
- parseDOM: [
- {
- tag: 'ul.section-nav',
- priority: HIGHER_PARSE_RULE_PRIORITY,
- },
- {
- tag: 'p.table-of-contents',
- priority: HIGHER_PARSE_RULE_PRIORITY,
- },
- ],
- toDOM: () => ['p', { class: 'table-of-contents' }, __('Table of Contents')],
- };
- }
-
- toMarkdown(state, node) {
+export default () => ({
+ name: 'table_of_contents',
+ schema: {
+ group: 'block',
+ atom: true,
+ parseDOM: [
+ {
+ tag: 'ul.section-nav',
+ priority: HIGHER_PARSE_RULE_PRIORITY,
+ },
+ {
+ tag: 'p.table-of-contents',
+ priority: HIGHER_PARSE_RULE_PRIORITY,
+ },
+ ],
+ toDOM: () => ['p', { class: 'table-of-contents' }, __('Table of Contents')],
+ },
+ toMarkdown: (state, node) => {
state.write('[[_TOC_]]');
state.closeBlock(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_row.js b/app/assets/javascripts/behaviors/markdown/nodes/table_row.js
index 5852502773a..3830dae4f0d 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/table_row.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/table_row.js
@@ -1,22 +1,12 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
-export default class TableRow extends Node {
- get name() {
- return 'table_row';
- }
-
- get schema() {
- return {
- content: 'table_cell+',
- parseDOM: [{ tag: 'tr' }],
- toDOM: () => ['tr', 0],
- };
- }
-
- toMarkdown(state, node) {
+export default () => ({
+ name: 'table_row',
+ schema: {
+ content: 'table_cell+',
+ parseDOM: [{ tag: 'tr' }],
+ toDOM: () => ['tr', 0],
+ },
+ toMarkdown: (state, node) => {
const cellWidths = [];
state.flushClose(1);
@@ -34,5 +24,5 @@ export default class TableRow extends Node {
state.closeBlock(node);
return cellWidths;
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list.js
index 35ba2eb0674..3c3812ad8f7 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/task_list.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list.js
@@ -1,29 +1,20 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter
-export default class TaskList extends Node {
- get name() {
- return 'task_list';
- }
-
- get schema() {
- return {
- group: 'block',
- content: '(task_list_item|list_item)+',
- parseDOM: [
- {
- priority: HIGHER_PARSE_RULE_PRIORITY,
- tag: 'ul.task-list',
- },
- ],
- toDOM: () => ['ul', { class: 'task-list' }, 0],
- };
- }
-
+export default () => ({
+ name: 'task_list',
+ schema: {
+ group: 'block',
+ content: '(task_list_item|list_item)+',
+ parseDOM: [
+ {
+ priority: HIGHER_PARSE_RULE_PRIORITY,
+ tag: 'ul.task-list',
+ },
+ ],
+ toDOM: () => ['ul', { class: 'task-list' }, 0],
+ },
toMarkdown(state, node) {
state.renderList(node, ' ', () => '* ');
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js
index 56c2b17286d..10ffce9b1b8 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js
@@ -1,50 +1,38 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter
-export default class TaskListItem extends Node {
- get name() {
- return 'task_list_item';
- }
-
- get schema() {
- return {
- attrs: {
- done: {
- default: false,
- },
+export default () => ({
+ name: 'task_list_item',
+ schema: {
+ attrs: {
+ done: {
+ default: false,
},
- defining: true,
- draggable: false,
- content: 'paragraph block*',
- parseDOM: [
- {
- priority: HIGHER_PARSE_RULE_PRIORITY,
- tag: 'li.task-list-item',
- getAttrs: (el) => {
- const checkbox = el.querySelector('input[type=checkbox].task-list-item-checkbox');
- return { done: checkbox && checkbox.checked };
- },
+ },
+ defining: true,
+ draggable: false,
+ content: 'paragraph block*',
+ parseDOM: [
+ {
+ priority: HIGHER_PARSE_RULE_PRIORITY,
+ tag: 'li.task-list-item',
+ getAttrs: (el) => {
+ const checkbox = el.querySelector('input[type=checkbox].task-list-item-checkbox');
+ return { done: checkbox && checkbox.checked };
},
- ],
- toDOM(node) {
- return [
- 'li',
- { class: 'task-list-item' },
- [
- 'input',
- { type: 'checkbox', class: 'task-list-item-checkbox', checked: node.attrs.done },
- ],
- ['div', { class: 'todo-content' }, 0],
- ];
},
- };
- }
-
+ ],
+ toDOM(node) {
+ return [
+ 'li',
+ { class: 'task-list-item' },
+ ['input', { type: 'checkbox', class: 'task-list-item-checkbox', checked: node.attrs.done }],
+ ['div', { class: 'todo-content' }, 0],
+ ];
+ },
+ },
toMarkdown(state, node) {
state.write(`[${node.attrs.done ? 'x' : ' '}] `);
state.renderContent(node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/text.js b/app/assets/javascripts/behaviors/markdown/nodes/text.js
index 0dc77a12f5c..0e1f0bc0e40 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/text.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/text.js
@@ -1,20 +1,11 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
-export default class Text extends Node {
- get name() {
- return 'text';
- }
-
- get schema() {
- return {
- group: 'inline',
- };
- }
-
+export default () => ({
+ name: 'text',
+ schema: {
+ group: 'inline',
+ },
toMarkdown(state, node) {
defaultMarkdownSerializer.nodes.text(state, node);
- }
-}
+ },
+});
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/video.js b/app/assets/javascripts/behaviors/markdown/nodes/video.js
index 68085c2c416..aa1088826da 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/video.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/video.js
@@ -1,10 +1,4 @@
-import Playable from './playable';
+import playable from './playable';
// Transforms generated HTML back to GFM for Banzai::Filter::VideoLinkFilter
-export default class Video extends Playable {
- constructor() {
- super();
- this.mediaType = 'video';
- this.extraElementAttrs = { width: '400' };
- }
-}
+export default () => playable({ mediaType: 'video', extraElementAttrs: { width: '400' } });
diff --git a/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js
index 1d54a1b0c04..85a991a1ec9 100644
--- a/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js
+++ b/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js
@@ -88,7 +88,7 @@ function renderMermaidEl(el, source) {
const iframeEl = document.createElement('iframe');
setAttributes(iframeEl, {
src: getSandboxFrameSrc(),
- sandbox: 'allow-scripts',
+ sandbox: 'allow-scripts allow-popups',
frameBorder: 0,
scrolling: 'no',
width: '100%',
diff --git a/app/assets/javascripts/behaviors/markdown/schema.js b/app/assets/javascripts/behaviors/markdown/schema.js
index 8bea24584cc..1b0f46ff4cb 100644
--- a/app/assets/javascripts/behaviors/markdown/schema.js
+++ b/app/assets/javascripts/behaviors/markdown/schema.js
@@ -1,24 +1,20 @@
import { Schema } from 'prosemirror-model';
import editorExtensions from './editor_extensions';
-const nodes = editorExtensions
- .filter((extension) => extension.type === 'node')
- .reduce(
- (ns, { name, schema }) => ({
- ...ns,
- [name]: schema,
- }),
- {},
- );
+const nodes = editorExtensions.nodes.reduce(
+ (ns, { name, schema }) => ({
+ ...ns,
+ [name]: schema,
+ }),
+ {},
+);
-const marks = editorExtensions
- .filter((extension) => extension.type === 'mark')
- .reduce(
- (ms, { name, schema }) => ({
- ...ms,
- [name]: schema,
- }),
- {},
- );
+const marks = editorExtensions.marks.reduce(
+ (ms, { name, schema }) => ({
+ ...ms,
+ [name]: schema,
+ }),
+ {},
+);
export default new Schema({ nodes, marks });
diff --git a/app/assets/javascripts/behaviors/markdown/serializer.js b/app/assets/javascripts/behaviors/markdown/serializer.js
index a5f97d7748a..e3e8a380cd5 100644
--- a/app/assets/javascripts/behaviors/markdown/serializer.js
+++ b/app/assets/javascripts/behaviors/markdown/serializer.js
@@ -1,24 +1,20 @@
import { MarkdownSerializer } from '~/lib/prosemirror_markdown_serializer';
import editorExtensions from './editor_extensions';
-const nodes = editorExtensions
- .filter((extension) => extension.type === 'node')
- .reduce(
- (ns, { name, toMarkdown }) => ({
- ...ns,
- [name]: toMarkdown,
- }),
- {},
- );
+const nodes = editorExtensions.nodes.reduce(
+ (ns, { name, toMarkdown }) => ({
+ ...ns,
+ [name]: toMarkdown,
+ }),
+ {},
+);
-const marks = editorExtensions
- .filter((extension) => extension.type === 'mark')
- .reduce(
- (ms, { name, toMarkdown }) => ({
- ...ms,
- [name]: toMarkdown,
- }),
- {},
- );
+const marks = editorExtensions.marks.reduce(
+ (ms, { name, toMarkdown }) => ({
+ ...ms,
+ [name]: toMarkdown,
+ }),
+ {},
+);
export default new MarkdownSerializer(nodes, marks);
diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
index b27dccabdf8..23b66405844 100644
--- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js
+++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
@@ -131,6 +131,13 @@ export const ITALIC_TEXT = {
customizable: false,
};
+export const STRIKETHROUGH_TEXT = {
+ id: 'editing.strikethroughText',
+ description: __('Strikethrough text'),
+ defaultKeys: ['mod+shift+x'],
+ customizable: false,
+};
+
export const LINK_TEXT = {
id: 'editing.linkText',
description: __('Link text'),
@@ -511,7 +518,14 @@ export const GLOBAL_SHORTCUTS_GROUP = {
export const EDITING_SHORTCUTS_GROUP = {
id: 'editing',
name: __('Editing'),
- keybindings: [BOLD_TEXT, ITALIC_TEXT, LINK_TEXT, TOGGLE_MARKDOWN_PREVIEW, EDIT_RECENT_COMMENT],
+ keybindings: [
+ BOLD_TEXT,
+ ITALIC_TEXT,
+ STRIKETHROUGH_TEXT,
+ LINK_TEXT,
+ TOGGLE_MARKDOWN_PREVIEW,
+ EDIT_RECENT_COMMENT,
+ ],
};
export const WIKI_SHORTCUTS_GROUP = {
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
index 9297b14aac9..4d78c7b56a0 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
@@ -173,12 +173,7 @@ export default class Shortcuts {
e.preventDefault();
const canaryCookieName = 'gitlab_canary';
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}`,
- });
+ setCookie(canaryCookieName, (!currentValue).toString(), { expires: 365, path: '/' });
refreshCurrentPage();
}
diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue
index c5ab28e6ec5..8a4fe1a9025 100644
--- a/app/assets/javascripts/blob/components/blob_header.vue
+++ b/app/assets/javascripts/blob/components/blob_header.vue
@@ -63,6 +63,9 @@ export default {
isEmpty() {
return this.blob.rawSize === 0;
},
+ blobSwitcherDocIcon() {
+ return this.blob.richViewer?.fileType === 'csv' ? 'table' : 'document';
+ },
},
watch: {
viewer(newVal, oldVal) {
@@ -90,7 +93,7 @@ export default {
</div>
<div class="gl-sm-display-flex file-actions">
- <viewer-switcher v-if="showViewerSwitcher" v-model="viewer" />
+ <viewer-switcher v-if="showViewerSwitcher" v-model="viewer" :doc-icon="blobSwitcherDocIcon" />
<slot name="actions"></slot>
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 12bcb24b0cc..61baf4fa495 100644
--- a/app/assets/javascripts/blob/components/blob_header_default_actions.vue
+++ b/app/assets/javascripts/blob/components/blob_header_default_actions.vue
@@ -1,6 +1,7 @@
<script>
import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
+import { setUrlParams, relativePathToAbsolute, getBaseURL } from '~/lib/utils/url_utility';
import {
BTN_COPY_CONTENTS_TITLE,
BTN_DOWNLOAD_TITLE,
@@ -56,7 +57,7 @@ export default {
},
computed: {
downloadUrl() {
- return `${this.rawPath}?inline=false`;
+ return setUrlParams({ inline: false }, relativePathToAbsolute(this.rawPath, getBaseURL()));
},
copyDisabled() {
return this.activeViewer === RICH_BLOB_VIEWER;
diff --git a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue
index b2546d47694..7351df0f93b 100644
--- a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue
+++ b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue
@@ -21,6 +21,11 @@ export default {
default: SIMPLE_BLOB_VIEWER,
required: false,
},
+ docIcon: {
+ type: String,
+ default: 'document',
+ required: false,
+ },
},
computed: {
isSimpleViewer() {
@@ -62,7 +67,7 @@ export default {
:aria-label="$options.RICH_BLOB_VIEWER_TITLE"
:title="$options.RICH_BLOB_VIEWER_TITLE"
:selected="isRichViewer"
- icon="document"
+ :icon="docIcon"
category="primary"
variant="default"
class="js-blob-viewer-switch-btn"
diff --git a/app/assets/javascripts/blob/csv/csv_viewer.vue b/app/assets/javascripts/blob/csv/csv_viewer.vue
index 1f9d20a487f..169167625e0 100644
--- a/app/assets/javascripts/blob/csv/csv_viewer.vue
+++ b/app/assets/javascripts/blob/csv/csv_viewer.vue
@@ -14,6 +14,11 @@ export default {
type: String,
required: true,
},
+ remoteFile: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -23,14 +28,29 @@ export default {
};
},
mounted() {
- const parsed = Papa.parse(this.csv, { skipEmptyLines: true });
- this.items = parsed.data;
-
- if (parsed.errors.length) {
- this.papaParseErrors = parsed.errors;
+ if (!this.remoteFile) {
+ const parsed = Papa.parse(this.csv, { skipEmptyLines: true });
+ this.handleParsedData(parsed);
+ } else {
+ Papa.parse(this.csv, {
+ download: true,
+ skipEmptyLines: true,
+ complete: (parsed) => {
+ this.handleParsedData(parsed);
+ },
+ });
}
+ },
+ methods: {
+ handleParsedData(parsed) {
+ this.items = parsed.data;
- this.loading = false;
+ if (parsed.errors.length) {
+ this.papaParseErrors = parsed.errors;
+ }
+
+ this.loading = false;
+ },
},
};
</script>
diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js
index 9fa70ce3c62..7eb699eacbe 100644
--- a/app/assets/javascripts/blob/template_selector.js
+++ b/app/assets/javascripts/blob/template_selector.js
@@ -2,6 +2,7 @@
import $ from 'jquery';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
+import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
export default class TemplateSelector {
constructor({ dropdown, data, pattern, wrapper, editor, $input } = {}) {
@@ -10,10 +11,9 @@ export default class TemplateSelector {
this.dropdown = dropdown;
this.$dropdownContainer = wrapper;
this.$filenameInput = $input || $('#file_name');
- this.$dropdownIcon = $('.dropdown-menu-toggle-icon', dropdown);
- this.$loadingIcon = $(
- '<div class="gl-spinner gl-spinner-orange gl-spinner-sm gl-absolute gl-top-3 gl-right-3 gl-display-none"></div>',
- ).insertAfter(this.$dropdownIcon);
+ this.dropdownIcon = dropdown[0].querySelector('.dropdown-menu-toggle-icon');
+ this.loadingIcon = loadingIconForLegacyJS({ classes: ['gl-display-none'] });
+ this.dropdownIcon.parentNode.insertBefore(this.loadingIcon, this.dropdownIcon.nextSibling);
this.initDropdown(dropdown, data);
this.listenForFilenameInput();
@@ -78,7 +78,12 @@ export default class TemplateSelector {
setEditorContent(file, { skipFocus } = {}) {
if (!file) return;
- const newValue = file.content;
+ let newValue = file.content;
+
+ const urlParams = new URLSearchParams(window.location.search);
+ if (urlParams.has('issue[description]')) {
+ newValue += `\n${urlParams.get('issue[description]')}`;
+ }
this.editor.setValue(newValue, 1);
@@ -95,12 +100,12 @@ export default class TemplateSelector {
}
startLoadingSpinner() {
- this.$loadingIcon.removeClass('gl-display-none');
- this.$dropdownIcon.addClass('gl-display-none');
+ this.loadingIcon.classList.remove('gl-display-none');
+ this.dropdownIcon.classList.add('gl-display-none');
}
stopLoadingSpinner() {
- this.$loadingIcon.addClass('gl-display-none');
- this.$dropdownIcon.removeClass('gl-display-none');
+ this.loadingIcon.classList.add('gl-display-none');
+ this.dropdownIcon.classList.remove('gl-display-none');
}
}
diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js
index 2d9ffda06d0..425de914c17 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -2,7 +2,6 @@
import $ from 'jquery';
import initPopover from '~/blob/suggest_gitlab_ci_yml';
-import initCodeQualityWalkthrough from '~/code_quality_walkthrough';
import createFlash from '~/flash';
import { disableButtonIfEmptyField, setCookie } from '~/lib/utils/common_utils';
import Tracking from '~/tracking';
@@ -39,13 +38,6 @@ const initPopovers = () => {
}
};
-const initCodeQualityWalkthroughStep = () => {
- const codeQualityWalkthroughEl = document.querySelector('.js-code-quality-walkthrough');
- if (codeQualityWalkthroughEl) {
- initCodeQualityWalkthrough(codeQualityWalkthroughEl);
- }
-};
-
export const initUploadForm = () => {
const uploadBlobForm = $('.js-upload-blob-form');
if (uploadBlobForm.length) {
@@ -71,7 +63,7 @@ export default () => {
const isMarkdown = editBlobForm.data('is-markdown');
const previewMarkdownPath = editBlobForm.data('previewMarkdownPath');
const commitButton = $('.js-commit-button');
- const cancelLink = $('.btn.btn-cancel');
+ const cancelLink = $('#cancel-changes');
import('./edit_blob')
.then(({ default: EditBlob } = {}) => {
@@ -84,7 +76,6 @@ export default () => {
previewMarkdownPath,
});
initPopovers();
- initCodeQualityWalkthroughStep();
})
.catch((e) =>
createFlash({
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
index 7e4d3ebb686..96cc774a280 100644
--- a/app/assets/javascripts/boards/boards_util.js
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -1,5 +1,6 @@
import { sortBy, cloneDeep } from 'lodash';
-import { isGid } from '~/graphql_shared/utils';
+import { TYPE_BOARD, TYPE_ITERATION, TYPE_MILESTONE, TYPE_USER } from '~/graphql_shared/constants';
+import { isGid, convertToGraphQLId } from '~/graphql_shared/utils';
import { ListType, MilestoneIDs, AssigneeFilterType, MilestoneFilterType } from './constants';
export function getMilestone() {
@@ -80,19 +81,22 @@ export function formatListsPageInfo(lists) {
}
export function fullBoardId(boardId) {
- return `gid://gitlab/Board/${boardId}`;
+ if (!boardId) {
+ return null;
+ }
+ return convertToGraphQLId(TYPE_BOARD, boardId);
}
export function fullIterationId(id) {
- return `gid://gitlab/Iteration/${id}`;
+ return convertToGraphQLId(TYPE_ITERATION, id);
}
export function fullUserId(id) {
- return `gid://gitlab/User/${id}`;
+ return convertToGraphQLId(TYPE_USER, id);
}
export function fullMilestoneId(id) {
- return `gid://gitlab/Milestone/${id}`;
+ return convertToGraphQLId(TYPE_MILESTONE, id);
}
export function fullLabelId(label) {
diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue
index 45192b5304a..95d4fd5bc0a 100644
--- a/app/assets/javascripts/boards/components/board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/board_filtered_search.vue
@@ -151,10 +151,10 @@ export default {
});
}
- if (this.filterParams['not[iteration_id]']) {
+ if (this.filterParams['not[iterationId]']) {
filteredSearchValue.push({
- type: 'iteration_id',
- value: { data: this.filterParams['not[iteration_id]'], operator: '!=' },
+ type: 'iteration',
+ value: { data: this.filterParams['not[iterationId]'], operator: '!=' },
});
}
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index cc048e2af1a..5fcf9514708 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -1,11 +1,9 @@
<script>
import { GlModal, GlAlert } from '@gitlab/ui';
import { mapGetters, mapActions, mapState } from 'vuex';
-import { TYPE_USER, TYPE_ITERATION, TYPE_MILESTONE } from '~/graphql_shared/constants';
-import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { getParameterByName, visitUrl } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
-import { fullLabelId } from '../boards_util';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { formType } from '../constants';
import createBoardMutation from '../graphql/board_create.mutation.graphql';
@@ -18,6 +16,7 @@ const boardDefaults = {
name: '',
labels: [],
milestone: {},
+ iterationCadence: {},
iteration: {},
assignee: {},
weight: null,
@@ -44,6 +43,7 @@ export default {
BoardConfigurationOptions,
GlAlert,
},
+ mixins: [glFeatureFlagMixin()],
inject: {
fullPath: {
default: '',
@@ -158,33 +158,8 @@ export default {
groupPath: this.isGroupBoard ? this.fullPath : undefined,
};
},
- issueBoardScopeMutationVariables() {
- return {
- weight: this.board.weight,
- assigneeId: this.board.assignee?.id
- ? convertToGraphQLId(TYPE_USER, this.board.assignee.id)
- : null,
- // Temporarily converting to milestone ID due to https://gitlab.com/gitlab-org/gitlab/-/issues/344779
- milestoneId: this.board.milestone?.id
- ? convertToGraphQLId(TYPE_MILESTONE, getIdFromGraphQLId(this.board.milestone.id))
- : null,
- // Temporarily converting to iteration ID due to https://gitlab.com/gitlab-org/gitlab/-/issues/344779
- iterationId: this.board.iteration?.id
- ? convertToGraphQLId(TYPE_ITERATION, getIdFromGraphQLId(this.board.iteration.id))
- : null,
- };
- },
- boardScopeMutationVariables() {
- return {
- labelIds: this.board.labels.map(fullLabelId),
- ...(this.isIssueBoard && this.issueBoardScopeMutationVariables),
- };
- },
mutationVariables() {
- return {
- ...this.baseMutationVariables,
- ...(this.scopedIssueBoardFeatureEnabled ? this.boardScopeMutationVariables : {}),
- };
+ return this.baseMutationVariables;
},
},
mounted() {
@@ -259,9 +234,12 @@ export default {
this.board = { ...boardDefaults, ...this.currentBoard };
}
},
- setIteration(iterationId) {
+ setIteration(iteration) {
+ if (this.glFeatures.iterationCadences) {
+ this.board.iterationCadenceId = iteration.iterationCadenceId;
+ }
this.$set(this.board, 'iteration', {
- id: iterationId,
+ id: iteration.id,
});
},
setBoardLabels(labels) {
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index 6835d83a66c..46b28d20da9 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -89,10 +89,6 @@ export default {
listTitle() {
return this.list?.label?.description || this.list?.assignee?.name || this.list.title || '';
},
- listIterationPeriod() {
- const iteration = this.list?.iteration;
- return iteration ? this.getIterationPeriod(iteration) : '';
- },
isIterationList() {
return this.listType === ListType.iteration;
},
@@ -108,9 +104,6 @@ export default {
showIterationListDetails() {
return this.isIterationList && this.showListDetails;
},
- iterationCadencesAvailable() {
- return this.isIterationList && this.glFeatures.iterationCadences;
- },
showListDetails() {
return !this.list.collapsed || !this.isSwimlanesHeader;
},
@@ -344,13 +337,6 @@ export default {
class="board-title-main-text gl-text-truncate"
>
{{ listTitle }}
- <span
- v-if="iterationCadencesAvailable"
- class="gl-display-inline-block gl-text-gray-400"
- data-testid="board-list-iteration-period"
- >
- {{ listIterationPeriod }}</span
- >
</span>
<span
v-if="listType === 'assignee'"
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index 6dbb1ea0050..91fdfd668fc 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -101,6 +101,7 @@ export default {
},
update(data) {
const board = data.workspace?.board;
+ this.setBoardConfig(board);
return {
...board,
labels: board?.labels?.nodes,
@@ -170,7 +171,7 @@ export default {
eventHub.$off('showBoardModal', this.showPage);
},
methods: {
- ...mapActions(['setError']),
+ ...mapActions(['setError', 'setBoardConfig']),
showPage(page) {
this.currentPage = page;
},
@@ -315,9 +316,7 @@ export default {
<gl-dropdown-item v-if="hasMissingBoards" class="no-pointer-events">
{{
- s__(
- 'IssueBoards|Some of your boards are hidden, activate a license to see them again.',
- )
+ s__('IssueBoards|Some of your boards are hidden, add a license to see them again.')
}}
</gl-dropdown-item>
</div>
diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js
deleted file mode 100644
index 72586970008..00000000000
--- a/app/assets/javascripts/boards/filtered_search_boards.js
+++ /dev/null
@@ -1,81 +0,0 @@
-import { transformBoardConfig } from 'ee_else_ce/boards/boards_util';
-import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager';
-import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
-import { updateHistory } from '~/lib/utils/url_utility';
-import FilteredSearchContainer from '../filtered_search/container';
-import vuexstore from './stores';
-
-export default class FilteredSearchBoards extends FilteredSearchManager {
- constructor(store, updateUrl = false, cantEdit = []) {
- super({
- page: 'boards',
- isGroupDecendent: true,
- stateFiltersSelector: '.issues-state-filters',
- isGroup: IS_EE,
- useDefaultState: false,
- filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
- });
-
- this.store = store;
- this.updateUrl = updateUrl;
-
- // Issue boards is slightly different, we handle all the requests async
- // instead or reloading the page, we just re-fire the list ajax requests
- this.isHandledAsync = true;
- this.cantEdit = cantEdit.filter((i) => typeof i === 'string');
- this.cantEditWithValue = cantEdit.filter((i) => typeof i === 'object');
-
- if (vuexstore.state.boardConfig) {
- const boardConfigPath = transformBoardConfig(vuexstore.state.boardConfig);
- // TODO Refactor: https://gitlab.com/gitlab-org/gitlab/-/issues/329274
- // here we are using "window.location.search" as a temporary store
- // only to unpack the params and do another validation inside
- // 'performSearch' and 'setFilter' vuex actions.
- if (boardConfigPath !== '') {
- const filterPath = window.location.search ? `${window.location.search}&` : '?';
- updateHistory({
- url: `${filterPath}${transformBoardConfig(vuexstore.state.boardConfig)}`,
- });
- }
- }
- }
-
- updateObject(path) {
- const groupByParam = new URLSearchParams(window.location.search).get('group_by');
- this.store.path = `${path.substr(1)}${groupByParam ? `&group_by=${groupByParam}` : ''}`;
-
- updateHistory({
- url: `?${path.substr(1)}${groupByParam ? `&group_by=${groupByParam}` : ''}`,
- });
- vuexstore.dispatch('performSearch');
- }
-
- removeTokens() {
- const tokens = FilteredSearchContainer.container.querySelectorAll('.js-visual-token');
-
- // Remove all the tokens as they will be replaced by the search manager
- [].forEach.call(tokens, (el) => {
- el.parentNode.removeChild(el);
- });
-
- this.filteredSearchInput.value = '';
- }
-
- updateTokens() {
- this.removeTokens();
-
- this.loadSearchParamsFromURL();
-
- // Get the placeholder back if search is empty
- this.filteredSearchInput.dispatchEvent(new Event('input'));
- }
-
- canEdit(tokenName, tokenValue) {
- if (this.cantEdit.includes(tokenName)) return false;
- return (
- this.cantEditWithValue.findIndex(
- (token) => token.name === tokenName && token.value === tokenValue,
- ) === -1
- );
- }
-}
diff --git a/app/assets/javascripts/boards/graphql.js b/app/assets/javascripts/boards/graphql.js
index 95863d4d5ac..d066a5d002e 100644
--- a/app/assets/javascripts/boards/graphql.js
+++ b/app/assets/javascripts/boards/graphql.js
@@ -10,5 +10,6 @@ export const gqlClient = createDefaultClient(
return object.__typename === 'BoardList' ? object.iid : defaultDataIdFromObject(object);
},
},
+ batchMax: 2,
},
);
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index f6073f9d981..b31b56e6839 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -8,8 +8,6 @@ import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_t
import BoardApp from '~/boards/components/board_app.vue';
import '~/boards/filters/due_date_filters';
import { issuableTypes } from '~/boards/constants';
-import eventHub from '~/boards/eventhub';
-import FilteredSearchBoards from '~/boards/filtered_search_boards';
import initBoardsFilteredSearch from '~/boards/mount_filtered_search_issue_boards';
import store from '~/boards/stores';
import toggleFocusMode from '~/boards/toggle_focus';
@@ -30,6 +28,12 @@ const apolloProvider = new VueApollo({
function mountBoardApp(el) {
const { boardId, groupId, fullPath, rootPath } = el.dataset;
+ store.dispatch('fetchBoard', {
+ fullPath,
+ fullBoardId: fullBoardId(boardId),
+ boardType: el.dataset.parent,
+ });
+
store.dispatch('setInitialBoardData', {
boardId,
fullBoardId: fullBoardId(boardId),
@@ -37,30 +41,8 @@ function mountBoardApp(el) {
boardType: el.dataset.parent,
disabled: parseBoolean(el.dataset.disabled) || true,
issuableType: issuableTypes.issue,
- boardConfig: {
- milestoneId: parseInt(el.dataset.boardMilestoneId, 10),
- milestoneTitle: el.dataset.boardMilestoneTitle || '',
- iterationId: parseInt(el.dataset.boardIterationId, 10),
- iterationTitle: el.dataset.boardIterationTitle || '',
- assigneeId: el.dataset.boardAssigneeId,
- assigneeUsername: el.dataset.boardAssigneeUsername,
- labels: el.dataset.labels ? JSON.parse(el.dataset.labels) : [],
- labelIds: el.dataset.labelIds ? JSON.parse(el.dataset.labelIds) : [],
- weight: el.dataset.boardWeight ? parseInt(el.dataset.boardWeight, 10) : null,
- },
});
- if (!gon?.features?.issueBoardsFilteredSearch) {
- // Warning: FilteredSearchBoards has an implicit dependency on the Vuex state 'boardConfig'
- // Improve this situation in the future.
- const filterManager = new FilteredSearchBoards({ path: '' }, true, []);
- filterManager.setup();
-
- eventHub.$on('updateTokens', () => {
- filterManager.updateTokens();
- });
- }
-
// eslint-disable-next-line no-new
new Vue({
el,
@@ -110,10 +92,14 @@ export default () => {
}
});
- if (gon?.features?.issueBoardsFilteredSearch) {
- const { releasesFetchPath } = $boardApp.dataset;
- initBoardsFilteredSearch(apolloProvider, isLoggedIn(), releasesFetchPath);
- }
+ const { releasesFetchPath, epicFeatureAvailable, iterationFeatureAvailable } = $boardApp.dataset;
+ initBoardsFilteredSearch(
+ apolloProvider,
+ isLoggedIn(),
+ releasesFetchPath,
+ parseBoolean(epicFeatureAvailable),
+ parseBoolean(iterationFeatureAvailable),
+ );
mountBoardApp($boardApp);
diff --git a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js
index 327fb9ba8d7..bb659eb075a 100644
--- a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js
+++ b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js
@@ -4,7 +4,13 @@ import store from '~/boards/stores';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { queryToObject } from '~/lib/utils/url_utility';
-export default (apolloProvider, isSignedIn, releasesFetchPath) => {
+export default (
+ apolloProvider,
+ isSignedIn,
+ releasesFetchPath,
+ epicFeatureAvailable,
+ iterationFeatureAvailable,
+) => {
const el = document.getElementById('js-issue-board-filtered-search');
const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true });
@@ -23,6 +29,8 @@ export default (apolloProvider, isSignedIn, releasesFetchPath) => {
initialFilterParams,
isSignedIn,
releasesFetchPath,
+ epicFeatureAvailable,
+ iterationFeatureAvailable,
},
store, // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/324094
apolloProvider,
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index 1ebfcfc331b..82307da2572 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -36,6 +36,8 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { queryToObject } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import { gqlClient } from '../graphql';
+import projectBoardQuery from '../graphql/project_board.query.graphql';
+import groupBoardQuery from '../graphql/group_board.query.graphql';
import boardLabelsQuery from '../graphql/board_labels.query.graphql';
import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql';
import groupProjectsQuery from '../graphql/group_projects.query.graphql';
@@ -46,10 +48,44 @@ import projectBoardMilestonesQuery from '../graphql/project_board_milestones.que
import * as types from './mutation_types';
export default {
+ fetchBoard: ({ commit, dispatch }, { fullPath, fullBoardId, boardType }) => {
+ const variables = {
+ fullPath,
+ boardId: fullBoardId,
+ };
+
+ return gqlClient
+ .query({
+ query: boardType === BoardType.group ? groupBoardQuery : projectBoardQuery,
+ variables,
+ })
+ .then(({ data }) => {
+ const board = data.workspace?.board;
+ commit(types.RECEIVE_BOARD_SUCCESS, board);
+ dispatch('setBoardConfig', board);
+ })
+ .catch(() => commit(types.RECEIVE_BOARD_FAILURE));
+ },
+
setInitialBoardData: ({ commit }, data) => {
commit(types.SET_INITIAL_BOARD_DATA, data);
},
+ setBoardConfig: ({ commit }, board) => {
+ const config = {
+ milestoneId: board.milestone?.id || null,
+ milestoneTitle: board.milestone?.title || null,
+ iterationId: board.iteration?.id || null,
+ iterationTitle: board.iteration?.title || null,
+ assigneeId: board.assignee?.id || null,
+ assigneeUsername: board.assignee?.username || null,
+ labels: board.labels?.nodes || [],
+ labelIds: board.labels?.nodes?.map((label) => label.id) || [],
+ weight: board.weight,
+ };
+ commit(types.SET_BOARD_CONFIG, config);
+ },
+
setActiveId({ commit }, { id, sidebarType }) {
commit(types.SET_ACTIVE_ID, { id, sidebarType });
},
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
index 31b78014525..668a3b5e0f9 100644
--- a/app/assets/javascripts/boards/stores/mutation_types.js
+++ b/app/assets/javascripts/boards/stores/mutation_types.js
@@ -1,4 +1,7 @@
+export const RECEIVE_BOARD_SUCCESS = 'RECEIVE_BOARD_SUCCESS';
+export const RECEIVE_BOARD_FAILURE = 'RECEIVE_BOARD_FAILURE';
export const SET_INITIAL_BOARD_DATA = 'SET_INITIAL_BOARD_DATA';
+export const SET_BOARD_CONFIG = 'SET_BOARD_CONFIG';
export const SET_FILTERS = 'SET_FILTERS';
export const CREATE_LIST_SUCCESS = 'CREATE_LIST_SUCCESS';
export const CREATE_LIST_FAILURE = 'CREATE_LIST_FAILURE';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index 2a2ce7652e6..9a50dcf05b8 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -33,10 +33,20 @@ export const addItemToList = ({ state, listId, itemId, moveBeforeId, moveAfterId
};
export default {
+ [mutationTypes.RECEIVE_BOARD_SUCCESS]: (state, board) => {
+ state.board = {
+ ...board,
+ labels: board?.labels?.nodes || [],
+ };
+ },
+
+ [mutationTypes.RECEIVE_BOARD_FAILURE]: (state) => {
+ state.error = s__('Boards|An error occurred while fetching the board. Please reload the page.');
+ },
+
[mutationTypes.SET_INITIAL_BOARD_DATA](state, data) {
const {
allowSubEpics,
- boardConfig,
boardId,
boardType,
disabled,
@@ -45,7 +55,6 @@ export default {
issuableType,
} = data;
state.allowSubEpics = allowSubEpics;
- state.boardConfig = boardConfig;
state.boardId = boardId;
state.boardType = boardType;
state.disabled = disabled;
@@ -54,6 +63,10 @@ export default {
state.issuableType = issuableType;
},
+ [mutationTypes.SET_BOARD_CONFIG](state, boardConfig) {
+ state.boardConfig = boardConfig;
+ },
+
[mutationTypes.RECEIVE_BOARD_LISTS_SUCCESS]: (state, lists) => {
state.boardLists = lists;
},
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js
index 80c51c966d2..7af4e5a8798 100644
--- a/app/assets/javascripts/boards/stores/state.js
+++ b/app/assets/javascripts/boards/stores/state.js
@@ -1,6 +1,7 @@
import { inactiveId, ListType } from '~/boards/constants';
export default () => ({
+ board: {},
boardType: null,
issuableType: null,
fullPath: null,
diff --git a/app/assets/javascripts/branches/ajax_loading_spinner.js b/app/assets/javascripts/branches/ajax_loading_spinner.js
deleted file mode 100644
index 79f4f919f3d..00000000000
--- a/app/assets/javascripts/branches/ajax_loading_spinner.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import $ from 'jquery';
-
-export default class AjaxLoadingSpinner {
- static init() {
- const $elements = $('.js-ajax-loading-spinner');
- $elements.on('ajax:beforeSend', AjaxLoadingSpinner.ajaxBeforeSend);
- }
-
- static ajaxBeforeSend(e) {
- const button = e.target;
- const newButton = document.createElement('button');
- newButton.classList.add('btn', 'btn-default', 'disabled', 'gl-button');
- newButton.setAttribute('disabled', 'disabled');
-
- const spinner = document.createElement('span');
- spinner.classList.add('align-text-bottom', 'gl-spinner', 'gl-spinner-sm', 'gl-spinner-orange');
- newButton.appendChild(spinner);
-
- button.classList.add('hidden');
- button.parentNode.insertBefore(newButton, button.nextSibling);
-
- $(button).one('ajax:error', () => {
- newButton.remove();
- button.classList.remove('hidden');
- });
-
- $(button).one('ajax:success', () => {
- $(button).off('ajax:beforeSend', AjaxLoadingSpinner.ajaxBeforeSend);
- });
- }
-}
diff --git a/app/assets/javascripts/captcha/apollo_captcha_link.js b/app/assets/javascripts/captcha/apollo_captcha_link.js
index d63ffaf5f1a..2d154139c7b 100644
--- a/app/assets/javascripts/captcha/apollo_captcha_link.js
+++ b/app/assets/javascripts/captcha/apollo_captcha_link.js
@@ -12,7 +12,7 @@ export const apolloCaptchaLink = new ApolloLink((operation, forward) =>
const spamLogId = captchaError.extensions.spam_log_id;
return new Observable((observer) => {
- import('~/captcha/wait_for_captcha_to_be_solved')
+ import('jh_else_ce/captcha/wait_for_captcha_to_be_solved')
.then(({ waitForCaptchaToBeSolved }) => waitForCaptchaToBeSolved(captchaSiteKey))
.then((captchaResponse) => {
// If the captcha was solved correctly, we re-do our action while setting
diff --git a/app/assets/javascripts/captcha/captcha_modal.vue b/app/assets/javascripts/captcha/captcha_modal.vue
index a98a52a3130..b8b90b04beb 100644
--- a/app/assets/javascripts/captcha/captcha_modal.vue
+++ b/app/assets/javascripts/captcha/captcha_modal.vue
@@ -1,7 +1,7 @@
<script>
-// NOTE 1: This is similar to recaptcha_modal.vue, but it directly uses the reCAPTCHA Javascript API
-// (https://developers.google.com/recaptcha/docs/display#js_api) and gl-modal, rather than relying
-// on the form-based ReCAPTCHA HTML being pre-rendered by the backend and using deprecated-modal.
+// NOTE 1: This modal directly uses the reCAPTCHA Javascript API
+// (https://developers.google.com/recaptcha/docs/display#js_api) and gl-modal,
+// rather than relying form-based reCAPTCHA HTML being pre-rendered by the backend.
// NOTE 2: Even though this modal currently only supports reCAPTCHA, we use 'captcha' instead
// of 'recaptcha' throughout the code, so that we can easily add support for future alternative
diff --git a/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js b/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js
index fdab188f6be..19fde2500f1 100644
--- a/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js
+++ b/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js
@@ -9,7 +9,9 @@ function needsCaptchaResponse(err) {
const showCaptchaModalAndResubmit = async (axios, data, errConfig) => {
// NOTE: We asynchronously import and unbox the module. Since this is included globally, we don't
// do a regular import because that would increase the size of the webpack bundle.
- const { waitForCaptchaToBeSolved } = await import('~/captcha/wait_for_captcha_to_be_solved');
+ const { waitForCaptchaToBeSolved } = await import(
+ 'jh_else_ce/captcha/wait_for_captcha_to_be_solved'
+ );
// show the CAPTCHA modal and wait for it to be solved or closed
const captchaResponse = await waitForCaptchaToBeSolved(data.captcha_site_key);
diff --git a/app/assets/javascripts/ci_lint/components/ci_lint.vue b/app/assets/javascripts/ci_lint/components/ci_lint.vue
index d541e89756a..8db4cba529f 100644
--- a/app/assets/javascripts/ci_lint/components/ci_lint.vue
+++ b/app/assets/javascripts/ci_lint/components/ci_lint.vue
@@ -103,7 +103,7 @@ export default {
class="gl-mr-4"
:loading="loading"
category="primary"
- variant="success"
+ variant="confirm"
data-testid="ci-lint-validate"
@click="lint"
>{{ __('Validate') }}</gl-button
diff --git a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue
new file mode 100644
index 00000000000..d70ade36fe9
--- /dev/null
+++ b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue
@@ -0,0 +1,133 @@
+<script>
+import { GlLink, GlLoadingIcon, GlPagination, GlTable } from '@gitlab/ui';
+import Api, { DEFAULT_PER_PAGE } from '~/api';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { __ } from '~/locale';
+import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ components: {
+ GlLink,
+ GlLoadingIcon,
+ GlPagination,
+ GlTable,
+ TimeagoTooltip,
+ },
+ inject: ['projectId'],
+ docsLink: helpPagePath('ci/secure_files/index'),
+ DEFAULT_PER_PAGE,
+ i18n: {
+ pagination: {
+ next: __('Next'),
+ prev: __('Prev'),
+ },
+ title: __('Secure Files'),
+ overviewMessage: __(
+ 'Use Secure Files to store files used by your pipelines such as Android keystores, or Apple provisioning profiles and signing certificates.',
+ ),
+ moreInformation: __('More information'),
+ },
+ data() {
+ return {
+ page: 1,
+ totalItems: 0,
+ loading: false,
+ projectSecureFiles: [],
+ };
+ },
+ fields: [
+ {
+ key: 'name',
+ label: __('Filename'),
+ },
+ {
+ key: 'permissions',
+ label: __('Permissions'),
+ },
+ {
+ key: 'created_at',
+ label: __('Uploaded'),
+ },
+ ],
+ computed: {
+ fields() {
+ return this.$options.fields;
+ },
+ },
+ watch: {
+ page(newPage) {
+ this.getProjectSecureFiles(newPage);
+ },
+ },
+ created() {
+ this.getProjectSecureFiles();
+ },
+ methods: {
+ async getProjectSecureFiles(page) {
+ this.loading = true;
+ const response = await Api.projectSecureFiles(this.projectId, { page });
+
+ this.totalItems = parseInt(response.headers?.['x-total'], 10) || 0;
+
+ this.projectSecureFiles = response.data;
+
+ this.loading = false;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h1 data-testid="title" class="gl-font-size-h1 gl-mt-3 gl-mb-0">{{ $options.i18n.title }}</h1>
+
+ <p>
+ <span data-testid="info-message" class="gl-mr-2">
+ {{ $options.i18n.overviewMessage }}
+ <gl-link :href="$options.docsLink" target="_blank">{{
+ $options.i18n.moreInformation
+ }}</gl-link>
+ </span>
+ </p>
+
+ <gl-table
+ :busy="loading"
+ :fields="fields"
+ :items="projectSecureFiles"
+ tbody-tr-class="js-ci-secure-files-row"
+ data-qa-selector="ci_secure_files_table_content"
+ sort-by="key"
+ sort-direction="asc"
+ stacked="lg"
+ table-class="text-secondary"
+ show-empty
+ sort-icon-left
+ no-sort-reset
+ >
+ <template #table-busy>
+ <gl-loading-icon size="lg" class="gl-my-5" />
+ </template>
+
+ <template #cell(name)="{ item }">
+ {{ item.name }}
+ </template>
+
+ <template #cell(permissions)="{ item }">
+ {{ item.permissions }}
+ </template>
+
+ <template #cell(created_at)="{ item }">
+ <timeago-tooltip :time="item.created_at" />
+ </template>
+ </gl-table>
+ <gl-pagination
+ v-if="!loading"
+ v-model="page"
+ :per-page="$options.DEFAULT_PER_PAGE"
+ :total-items="totalItems"
+ :next-text="$options.i18n.pagination.next"
+ :prev-text="$options.i18n.pagination.prev"
+ align="center"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci_secure_files/index.js b/app/assets/javascripts/ci_secure_files/index.js
new file mode 100644
index 00000000000..18b4ac6866e
--- /dev/null
+++ b/app/assets/javascripts/ci_secure_files/index.js
@@ -0,0 +1,17 @@
+import Vue from 'vue';
+import SecureFilesList from './components/secure_files_list.vue';
+
+export const initCiSecureFiles = (selector = '#js-ci-secure-files') => {
+ const containerEl = document.querySelector(selector);
+ const { projectId } = containerEl.dataset;
+
+ return new Vue({
+ el: containerEl,
+ provide: {
+ projectId,
+ },
+ render(createElement) {
+ return createElement(SecureFilesList);
+ },
+ });
+};
diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
index 4ab9b36058d..4156717908d 100644
--- a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
+++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
@@ -8,8 +8,12 @@ import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link
export default {
i18n: {
+ copyTrigger: s__('Pipelines|Copy trigger token'),
editButton: s__('Pipelines|Edit'),
- revokeButton: s__('Pipelines|Revoke'),
+ revokeButton: s__('Pipelines|Revoke trigger'),
+ revokeButtonConfirm: s__(
+ 'Pipelines|By revoking a trigger you will break any processes making use of it. Are you sure?',
+ ),
},
components: {
GlTable,
@@ -72,7 +76,7 @@ export default {
:text="item.token"
data-testid="clipboard-btn"
data-qa-selector="clipboard_button"
- :title="s__('Pipelines|Copy trigger token')"
+ :title="$options.i18n.copyTrigger"
css-class="gl-border-none gl-py-0 gl-px-2"
/>
<div class="label-container">
@@ -122,13 +126,9 @@ export default {
:title="$options.i18n.revokeButton"
:aria-label="$options.i18n.revokeButton"
icon="remove"
- variant="warning"
- :data-confirm="
- s__(
- 'Pipelines|By revoking a trigger you will break any processes making use of it. Are you sure?',
- )
- "
+ :data-confirm="$options.i18n.revokeButtonConfirm"
data-method="delete"
+ data-confirm-btn-variant="danger"
rel="nofollow"
class="gl-ml-3"
data-testid="trigger_revoke_button"
diff --git a/app/assets/javascripts/ci_variable_list/ci_variable_list.js b/app/assets/javascripts/ci_variable_list/ci_variable_list.js
index 065cb4f5616..055e2f83e33 100644
--- a/app/assets/javascripts/ci_variable_list/ci_variable_list.js
+++ b/app/assets/javascripts/ci_variable_list/ci_variable_list.js
@@ -3,7 +3,6 @@ import SecretValues from '../behaviors/secret_values';
import CreateItemDropdown from '../create_item_dropdown';
import { parseBoolean } from '../lib/utils/common_utils';
import { s__ } from '../locale';
-import setupToggleButtons from '../toggle_buttons';
const ALL_ENVIRONMENTS_STRING = s__('CiVariable|All environments');
@@ -115,8 +114,6 @@ export default class VariableList {
initRow(rowEl) {
const $row = $(rowEl);
- setupToggleButtons($row[0]);
-
// Reset the resizable textarea
$row.find(this.inputMap.secret_value.selector).css('height', '');
diff --git a/app/assets/javascripts/clusters/agents/components/create_token_button.vue b/app/assets/javascripts/clusters/agents/components/create_token_button.vue
new file mode 100644
index 00000000000..3e1a8994fb8
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/components/create_token_button.vue
@@ -0,0 +1,246 @@
+<script>
+import {
+ GlButton,
+ GlModalDirective,
+ GlTooltip,
+ GlModal,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlAlert,
+} from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import Tracking from '~/tracking';
+import AgentToken from '~/clusters_list/components/agent_token.vue';
+import {
+ CREATE_TOKEN_MODAL,
+ EVENT_LABEL_MODAL,
+ EVENT_ACTIONS_OPEN,
+ EVENT_ACTIONS_CLICK,
+ TOKEN_NAME_LIMIT,
+ TOKEN_STATUS_ACTIVE,
+} from '../constants';
+import createNewAgentToken from '../graphql/mutations/create_new_agent_token.mutation.graphql';
+import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql';
+import { addAgentTokenToStore } from '../graphql/cache_update';
+
+const trackingMixin = Tracking.mixin({ label: EVENT_LABEL_MODAL });
+
+export default {
+ components: {
+ AgentToken,
+ GlButton,
+ GlTooltip,
+ GlModal,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlAlert,
+ },
+ directives: {
+ GlModalDirective,
+ },
+ mixins: [trackingMixin],
+ inject: ['agentName', 'projectPath', 'canAdminCluster'],
+ props: {
+ clusterAgentId: {
+ required: true,
+ type: String,
+ },
+ cursor: {
+ required: true,
+ type: Object,
+ },
+ },
+ modalId: CREATE_TOKEN_MODAL,
+ EVENT_ACTIONS_OPEN,
+ EVENT_ACTIONS_CLICK,
+ EVENT_LABEL_MODAL,
+ TOKEN_NAME_LIMIT,
+ i18n: {
+ createTokenButton: s__('ClusterAgents|Create token'),
+ modalTitle: s__('ClusterAgents|Create agent access token'),
+ unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'),
+ errorTitle: s__('ClusterAgents|Failed to create a token'),
+ dropdownDisabledHint: s__(
+ 'ClusterAgents|Requires a Maintainer or greater role to perform these actions',
+ ),
+ modalCancel: __('Cancel'),
+ modalClose: __('Close'),
+ tokenNameLabel: __('Name'),
+ tokenDescriptionLabel: __('Description (optional)'),
+ },
+ data() {
+ return {
+ token: {
+ name: null,
+ description: null,
+ },
+ agentToken: null,
+ error: null,
+ loading: false,
+ variables: {
+ agentName: this.agentName,
+ projectPath: this.projectPath,
+ tokenStatus: TOKEN_STATUS_ACTIVE,
+ ...this.cursor,
+ },
+ };
+ },
+ computed: {
+ modalBtnDisabled() {
+ return this.loading || !this.hasTokenName;
+ },
+ hasTokenName() {
+ return Boolean(this.token.name?.length);
+ },
+ },
+ methods: {
+ async createToken() {
+ this.loading = true;
+ this.error = null;
+
+ try {
+ const { errors: tokenErrors, secret } = await this.createAgentTokenMutation();
+
+ if (tokenErrors?.length > 0) {
+ throw new Error(tokenErrors[0]);
+ }
+
+ this.agentToken = secret;
+ } catch (error) {
+ if (error) {
+ this.error = error.message;
+ } else {
+ this.error = this.$options.i18n.unknownError;
+ }
+ } finally {
+ this.loading = false;
+ }
+ },
+ resetModal() {
+ this.agentToken = null;
+ this.token.name = null;
+ this.token.description = null;
+ this.error = null;
+ },
+ closeModal() {
+ this.$refs.modal.hide();
+ },
+ createAgentTokenMutation() {
+ return this.$apollo
+ .mutate({
+ mutation: createNewAgentToken,
+ variables: {
+ input: {
+ clusterAgentId: this.clusterAgentId,
+ name: this.token.name,
+ description: this.token.description,
+ },
+ },
+ update: (store, { data: { clusterAgentTokenCreate } }) => {
+ addAgentTokenToStore(
+ store,
+ clusterAgentTokenCreate,
+ getClusterAgentQuery,
+ this.variables,
+ );
+ },
+ })
+ .then(({ data: { clusterAgentTokenCreate } }) => clusterAgentTokenCreate);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div ref="addToken" class="gl-display-inline-block">
+ <gl-button
+ v-gl-modal-directive="$options.modalId"
+ :disabled="!canAdminCluster"
+ category="primary"
+ variant="confirm"
+ >{{ $options.i18n.createTokenButton }}
+ </gl-button>
+
+ <gl-tooltip
+ v-if="!canAdminCluster"
+ :target="() => $refs.addToken"
+ :title="$options.i18n.dropdownDisabledHint"
+ />
+ </div>
+
+ <gl-modal
+ ref="modal"
+ :modal-id="$options.modalId"
+ :title="$options.i18n.modalTitle"
+ static
+ lazy
+ @hidden="resetModal"
+ @show="track($options.EVENT_ACTIONS_OPEN)"
+ >
+ <gl-alert
+ v-if="error"
+ :title="$options.i18n.errorTitle"
+ :dismissible="false"
+ variant="danger"
+ class="gl-mb-5"
+ >
+ {{ error }}
+ </gl-alert>
+
+ <template v-if="!agentToken">
+ <gl-form-group :label="$options.i18n.tokenNameLabel">
+ <gl-form-input
+ v-model="token.name"
+ :max-length="$options.TOKEN_NAME_LIMIT"
+ :disabled="loading"
+ required
+ />
+ </gl-form-group>
+
+ <gl-form-group :label="$options.i18n.tokenDescriptionLabel">
+ <gl-form-textarea v-model="token.description" :disabled="loading" name="description" />
+ </gl-form-group>
+ </template>
+
+ <agent-token v-else :agent-token="agentToken" :modal-id="$options.modalId" />
+
+ <template #modal-footer>
+ <gl-button
+ v-if="!agentToken && !loading"
+ :data-track-action="$options.EVENT_ACTIONS_CLICK"
+ :data-track-label="$options.EVENT_LABEL_MODAL"
+ data-track-property="close"
+ data-testid="agent-token-close-button"
+ @click="closeModal"
+ >{{ $options.i18n.modalCancel }}
+ </gl-button>
+
+ <gl-button
+ v-if="!agentToken"
+ :disabled="modalBtnDisabled"
+ :loading="loading"
+ :data-track-action="$options.EVENT_ACTIONS_CLICK"
+ :data-track-label="$options.EVENT_LABEL_MODAL"
+ data-track-property="create-token"
+ variant="confirm"
+ type="submit"
+ @click="createToken"
+ >{{ $options.i18n.createTokenButton }}
+ </gl-button>
+
+ <gl-button
+ v-else
+ :data-track-action="$options.EVENT_ACTIONS_CLICK"
+ :data-track-label="$options.EVENT_LABEL_MODAL"
+ data-track-property="close"
+ variant="confirm"
+ @click="closeModal"
+ >{{ $options.i18n.modalClose }}
+ </gl-button>
+ </template>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue
index 63f068a9327..5df3e0811a5 100644
--- a/app/assets/javascripts/clusters/agents/components/show.vue
+++ b/app/assets/javascripts/clusters/agents/components/show.vue
@@ -143,7 +143,7 @@ export default {
<gl-loading-icon v-if="isLoading" size="md" class="gl-m-3" />
<div v-else>
- <token-table :tokens="tokens" />
+ <token-table :tokens="tokens" :cluster-agent-id="clusterAgent.id" :cursor="cursor" />
<div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5">
<gl-keyset-pagination v-bind="tokenPageInfo" @prev="prevPage" @next="nextPage" />
diff --git a/app/assets/javascripts/clusters/agents/components/token_table.vue b/app/assets/javascripts/clusters/agents/components/token_table.vue
index 019fac531d1..fbb39c28d78 100644
--- a/app/assets/javascripts/clusters/agents/components/token_table.vue
+++ b/app/assets/javascripts/clusters/agents/components/token_table.vue
@@ -1,17 +1,17 @@
<script>
-import { GlEmptyState, GlLink, GlTable, GlTooltip, GlTruncate } from '@gitlab/ui';
-import { helpPagePath } from '~/helpers/help_page_helper';
+import { GlEmptyState, GlTable, GlTooltip, GlTruncate } from '@gitlab/ui';
import { s__ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import CreateTokenButton from './create_token_button.vue';
export default {
components: {
GlEmptyState,
- GlLink,
GlTable,
GlTooltip,
GlTruncate,
TimeAgoTooltip,
+ CreateTokenButton,
},
i18n: {
createdBy: s__('ClusterAgents|Created by'),
@@ -19,7 +19,6 @@ export default {
dateCreated: s__('ClusterAgents|Date created'),
description: s__('ClusterAgents|Description'),
lastUsed: s__('ClusterAgents|Last contact'),
- learnMore: s__('ClusterAgents|Learn how to create an agent access token'),
name: s__('ClusterAgents|Name'),
neverUsed: s__('ClusterAgents|Never'),
noTokens: s__('ClusterAgents|This agent has no tokens'),
@@ -30,6 +29,14 @@ export default {
required: true,
type: Array,
},
+ clusterAgentId: {
+ required: true,
+ type: String,
+ },
+ cursor: {
+ required: true,
+ type: Object,
+ },
},
computed: {
fields() {
@@ -61,11 +68,6 @@ export default {
},
];
},
- learnMoreUrl() {
- return helpPagePath('user/clusters/agent/install/index', {
- anchor: 'register-an-agent-with-gitlab',
- });
- },
},
methods: {
createdByName(token) {
@@ -77,11 +79,11 @@ export default {
<template>
<div v-if="tokens.length">
- <div class="gl-text-right gl-my-5">
- <gl-link target="_blank" :href="learnMoreUrl">
- {{ $options.i18n.learnMore }}
- </gl-link>
- </div>
+ <create-token-button
+ class="gl-text-right gl-my-5"
+ :cluster-agent-id="clusterAgentId"
+ :cursor="cursor"
+ />
<gl-table
:items="tokens"
@@ -120,10 +122,9 @@ export default {
</gl-table>
</div>
- <gl-empty-state
- v-else
- :title="$options.i18n.noTokens"
- :primary-button-link="learnMoreUrl"
- :primary-button-text="$options.i18n.learnMore"
- />
+ <gl-empty-state v-else :title="$options.i18n.noTokens">
+ <template #actions>
+ <create-token-button :cluster-agent-id="clusterAgentId" :cursor="cursor" />
+ </template>
+ </gl-empty-state>
</template>
diff --git a/app/assets/javascripts/clusters/agents/constants.js b/app/assets/javascripts/clusters/agents/constants.js
index 98d4707b4de..50d8f5e9e40 100644
--- a/app/assets/javascripts/clusters/agents/constants.js
+++ b/app/assets/javascripts/clusters/agents/constants.js
@@ -37,3 +37,10 @@ export const EVENT_DETAILS = {
export const DEFAULT_ICON = 'token';
export const TOKEN_STATUS_ACTIVE = 'ACTIVE';
+
+export const CREATE_TOKEN_MODAL = 'create-token';
+export const EVENT_LABEL_MODAL = 'agent_token_creation_modal';
+export const EVENT_ACTIONS_OPEN = 'open_modal';
+export const EVENT_ACTIONS_CLICK = 'click_button';
+
+export const TOKEN_NAME_LIMIT = 255;
diff --git a/app/assets/javascripts/clusters/agents/graphql/cache_update.js b/app/assets/javascripts/clusters/agents/graphql/cache_update.js
new file mode 100644
index 00000000000..0219c4150eb
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/graphql/cache_update.js
@@ -0,0 +1,24 @@
+import produce from 'immer';
+
+export const hasErrors = ({ errors = [] }) => errors?.length;
+
+export function addAgentTokenToStore(store, clusterAgentTokenCreate, query, variables) {
+ if (!hasErrors(clusterAgentTokenCreate)) {
+ const { token } = clusterAgentTokenCreate;
+ const sourceData = store.readQuery({
+ query,
+ variables,
+ });
+
+ const data = produce(sourceData, (draftData) => {
+ draftData.project.clusterAgent.tokens.nodes.unshift(token);
+ draftData.project.clusterAgent.tokens.count += 1;
+ });
+
+ store.writeQuery({
+ query,
+ variables,
+ data,
+ });
+ }
+}
diff --git a/app/assets/javascripts/clusters/agents/graphql/mutations/create_new_agent_token.mutation.graphql b/app/assets/javascripts/clusters/agents/graphql/mutations/create_new_agent_token.mutation.graphql
new file mode 100644
index 00000000000..4a61263ba70
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/graphql/mutations/create_new_agent_token.mutation.graphql
@@ -0,0 +1,11 @@
+#import "../fragments/cluster_agent_token.fragment.graphql"
+
+mutation createNewAgentToken($input: ClusterAgentTokenCreateInput!) {
+ clusterAgentTokenCreate(input: $input) {
+ secret
+ token {
+ ...Token
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/clusters/agents/index.js b/app/assets/javascripts/clusters/agents/index.js
index ba7b3edba72..8a447f57f00 100644
--- a/app/assets/javascripts/clusters/agents/index.js
+++ b/app/assets/javascripts/clusters/agents/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
import AgentShowPage from 'ee_else_ce/clusters/agents/components/show.vue';
import apolloProvider from './graphql/provider';
import createRouter from './router';
@@ -16,6 +17,8 @@ export default () => {
canAdminVulnerability,
emptyStateSvgPath,
projectPath,
+ kasAddress,
+ canAdminCluster,
} = el.dataset;
return new Vue({
@@ -28,6 +31,8 @@ export default () => {
canAdminVulnerability,
emptyStateSvgPath,
projectPath,
+ kasAddress,
+ canAdminCluster: parseBoolean(canAdminCluster),
},
render(createElement) {
return createElement(AgentShowPage);
diff --git a/app/assets/javascripts/clusters/components/new_cluster.vue b/app/assets/javascripts/clusters/components/new_cluster.vue
index 2e74ad073c5..8f3e2916270 100644
--- a/app/assets/javascripts/clusters/components/new_cluster.vue
+++ b/app/assets/javascripts/clusters/components/new_cluster.vue
@@ -5,9 +5,9 @@ import { s__ } from '~/locale';
export default {
i18n: {
- title: s__('ClusterIntegration|Enter the details for your Kubernetes cluster'),
+ title: s__('ClusterIntegration|Enter your Kubernetes cluster certificate details'),
information: s__(
- 'ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{linkStart}documentation%{linkEnd} on Kubernetes',
+ 'ClusterIntegration|Enter details about your cluster. %{linkStart}How do I use a certificate to connect to my cluster?%{linkEnd}',
),
},
components: {
@@ -21,7 +21,7 @@ export default {
</script>
<template>
- <div>
+ <div class="gl-pt-4">
<h4>{{ $options.i18n.title }}</h4>
<p>
<gl-sprintf :message="$options.i18n.information">
diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue
index 61c4904aacf..1144ce68e2c 100644
--- a/app/assets/javascripts/clusters_list/components/agent_table.vue
+++ b/app/assets/javascripts/clusters_list/components/agent_table.vue
@@ -1,5 +1,13 @@
<script>
-import { GlLink, GlTable, GlIcon, GlSprintf, GlTooltip, GlPopover } from '@gitlab/ui';
+import {
+ GlLink,
+ GlTable,
+ GlIcon,
+ GlSprintf,
+ GlTooltip,
+ GlTooltipDirective,
+ GlPopover,
+} from '@gitlab/ui';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { helpPagePath } from '~/helpers/help_page_helper';
@@ -19,12 +27,18 @@ export default {
TimeAgoTooltip,
DeleteAgentButton,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
mixins: [timeagoMixin],
AGENT_STATUSES,
troubleshootingLink: helpPagePath('user/clusters/agent/troubleshooting'),
versionUpdateLink: helpPagePath('user/clusters/agent/install/index', {
anchor: 'update-the-agent-version',
}),
+ configHelpLink: helpPagePath('user/clusters/agent/install/index', {
+ anchor: 'create-an-agent-without-configuration-file',
+ }),
inject: ['gitlabVersion'],
props: {
agents: {
@@ -256,7 +270,16 @@ export default {
{{ getAgentConfigPath(item.name) }}
</gl-link>
- <span v-else>{{ getAgentConfigPath(item.name) }}</span>
+ <span v-else
+ >{{ $options.i18n.defaultConfigText }}
+ <gl-link
+ v-gl-tooltip
+ :href="$options.configHelpLink"
+ :title="$options.i18n.defaultConfigTooltip"
+ :aria-label="$options.i18n.defaultConfigTooltip"
+ class="gl-vertical-align-middle"
+ ><gl-icon name="question" :size="14" /></gl-link
+ ></span>
</span>
</template>
diff --git a/app/assets/javascripts/clusters_list/components/agent_token.vue b/app/assets/javascripts/clusters_list/components/agent_token.vue
new file mode 100644
index 00000000000..eab3fc3ed63
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/components/agent_token.vue
@@ -0,0 +1,109 @@
+<script>
+import { GlAlert, GlFormInputGroup, GlLink, GlSprintf } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
+import CodeBlock from '~/vue_shared/components/code_block.vue';
+import { generateAgentRegistrationCommand } from '../clusters_util';
+import { I18N_AGENT_TOKEN } from '../constants';
+
+export default {
+ i18n: I18N_AGENT_TOKEN,
+ basicInstallPath: helpPagePath('user/clusters/agent/install/index', {
+ anchor: 'install-the-agent-into-the-cluster',
+ }),
+ advancedInstallPath: helpPagePath('user/clusters/agent/install/index', {
+ anchor: 'advanced-installation',
+ }),
+ components: {
+ GlAlert,
+ CodeBlock,
+ GlFormInputGroup,
+ GlLink,
+ GlSprintf,
+ ModalCopyButton,
+ },
+ inject: ['kasAddress'],
+ props: {
+ agentToken: {
+ required: true,
+ type: String,
+ },
+ modalId: {
+ required: true,
+ type: String,
+ },
+ },
+ computed: {
+ agentRegistrationCommand() {
+ return generateAgentRegistrationCommand(this.agentToken, this.kasAddress);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <p>
+ <strong>{{ $options.i18n.tokenTitle }}</strong>
+ </p>
+
+ <p>
+ <gl-sprintf :message="$options.i18n.tokenBody">
+ <template #link="{ content }">
+ <gl-link :href="$options.basicInstallPath" target="_blank"> {{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <p>
+ <gl-alert
+ :title="$options.i18n.tokenSingleUseWarningTitle"
+ variant="warning"
+ :dismissible="false"
+ >
+ {{ $options.i18n.tokenSingleUseWarningBody }}
+ </gl-alert>
+ </p>
+
+ <p>
+ <gl-form-input-group readonly :value="agentToken" :select-on-click="true">
+ <template #append>
+ <modal-copy-button
+ :text="agentToken"
+ :title="$options.i18n.copyToken"
+ :modal-id="modalId"
+ />
+ </template>
+ </gl-form-input-group>
+ </p>
+
+ <p>
+ <strong>{{ $options.i18n.basicInstallTitle }}</strong>
+ </p>
+
+ <p>
+ {{ $options.i18n.basicInstallBody }}
+ </p>
+
+ <p class="gl-display-flex gl-align-items-flex-start">
+ <code-block class="gl-w-full" :code="agentRegistrationCommand" />
+ <modal-copy-button
+ :title="$options.i18n.copyCommand"
+ :text="agentRegistrationCommand"
+ :modal-id="modalId"
+ />
+ </p>
+
+ <p>
+ <strong>{{ $options.i18n.advancedInstallTitle }}</strong>
+ </p>
+
+ <p>
+ <gl-sprintf :message="$options.i18n.advancedInstallBody">
+ <template #link="{ content }">
+ <gl-link :href="$options.advancedInstallPath" target="_blank"> {{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue
index bf096f53e9d..70b9b8ac3c9 100644
--- a/app/assets/javascripts/clusters_list/components/agents.vue
+++ b/app/assets/javascripts/clusters_list/components/agents.vue
@@ -116,9 +116,6 @@ export default {
},
},
methods: {
- reloadAgents() {
- this.$apollo.queries.agents.refetch();
- },
nextPage() {
this.cursor = {
first: MAX_LIST_COUNT,
diff --git a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue
index 1630d0d5c92..662cf2a7e36 100644
--- a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue
+++ b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue
@@ -1,5 +1,11 @@
<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlSearchBoxByType,
+ GlSprintf,
+} from '@gitlab/ui';
import { I18N_AVAILABLE_AGENTS_DROPDOWN } from '../constants';
export default {
@@ -8,6 +14,9 @@ export default {
components: {
GlDropdown,
GlDropdownItem,
+ GlDropdownDivider,
+ GlSearchBoxByType,
+ GlSprintf,
},
props: {
isRegistering: {
@@ -22,6 +31,7 @@ export default {
data() {
return {
selectedAgent: null,
+ searchTerm: '',
};
},
computed: {
@@ -34,22 +44,45 @@ export default {
return this.selectedAgent;
},
+ shouldRenderCreateButton() {
+ return this.searchTerm && !this.availableAgents.includes(this.searchTerm);
+ },
+ filteredResults() {
+ const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
+ return this.availableAgents.filter((resultString) =>
+ resultString.toLowerCase().includes(lowerCasedSearchTerm),
+ );
+ },
},
methods: {
selectAgent(agent) {
this.$emit('agentSelected', agent);
this.selectedAgent = agent;
+ this.clearSearch();
},
isSelected(agent) {
return this.selectedAgent === agent;
},
+ clearSearch() {
+ this.searchTerm = '';
+ },
+ focusSearch() {
+ this.$refs.searchInput.focusInput();
+ },
+ handleShow() {
+ this.clearSearch();
+ this.focusSearch();
+ },
},
};
</script>
<template>
- <gl-dropdown :text="dropdownText" :loading="isRegistering">
+ <gl-dropdown :text="dropdownText" :loading="isRegistering" @shown="handleShow">
+ <template #header>
+ <gl-search-box-by-type ref="searchInput" v-model.trim="searchTerm" />
+ </template>
<gl-dropdown-item
- v-for="agent in availableAgents"
+ v-for="agent in filteredResults"
:key="agent"
:is-checked="isSelected(agent)"
is-check-item
@@ -57,5 +90,16 @@ export default {
>
{{ agent }}
</gl-dropdown-item>
+ <gl-dropdown-item v-if="!filteredResults.length" ref="noMatchingResults">{{
+ $options.i18n.noResults
+ }}</gl-dropdown-item>
+ <template v-if="shouldRenderCreateButton">
+ <gl-dropdown-divider />
+ <gl-dropdown-item data-testid="create-config-button" @click="selectAgent(searchTerm)">
+ <gl-sprintf :message="$options.i18n.createButton">
+ <template #searchTerm>{{ searchTerm }}</template>
+ </gl-sprintf>
+ </gl-dropdown-item>
+ </template>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue
index 7fb3aa3ff7e..59cfdde731d 100644
--- a/app/assets/javascripts/clusters_list/components/clusters.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters.vue
@@ -6,7 +6,7 @@ import {
GlPagination,
GlDeprecatedSkeletonLoading as GlSkeletonLoading,
GlSprintf,
- GlTable,
+ GlTableLite,
GlTooltipDirective,
} from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
@@ -27,7 +27,7 @@ export default {
GlPagination,
GlSkeletonLoading,
GlSprintf,
- GlTable,
+ GlTableLite,
NodeErrorHelpText,
ClustersEmptyState,
},
@@ -229,7 +229,7 @@ export default {
<section v-else>
<ancestor-notice />
- <gl-table
+ <gl-table-lite
v-if="hasClusters"
:items="clusters"
:fields="fields"
@@ -326,7 +326,7 @@ export default {
{{ value }}
</gl-badge>
</template>
- </gl-table>
+ </gl-table-lite>
<clusters-empty-state v-else :is-child-component="isChildComponent" />
diff --git a/app/assets/javascripts/clusters_list/components/clusters_actions.vue b/app/assets/javascripts/clusters_list/components/clusters_actions.vue
index 5b8dc74b84f..ccb973f1eb8 100644
--- a/app/assets/javascripts/clusters_list/components/clusters_actions.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters_actions.vue
@@ -1,5 +1,6 @@
<script>
import {
+ GlButton,
GlDropdown,
GlDropdownItem,
GlModalDirective,
@@ -14,6 +15,7 @@ export default {
i18n: CLUSTERS_ACTIONS,
INSTALL_AGENT_MODAL_ID,
components: {
+ GlButton,
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
@@ -23,11 +25,27 @@ export default {
GlModalDirective,
GlTooltip: GlTooltipDirective,
},
- inject: ['newClusterPath', 'addClusterPath', 'canAddCluster'],
+ inject: [
+ 'newClusterPath',
+ 'addClusterPath',
+ 'canAddCluster',
+ 'displayClusterAgents',
+ 'certificateBasedClustersEnabled',
+ ],
computed: {
tooltip() {
- const { connectWithAgent, dropdownDisabledHint } = this.$options.i18n;
- return this.canAddCluster ? connectWithAgent : dropdownDisabledHint;
+ const { connectWithAgent, connectExistingCluster, dropdownDisabledHint } = this.$options.i18n;
+
+ if (!this.canAddCluster) {
+ return dropdownDisabledHint;
+ } else if (this.displayClusterAgents) {
+ return connectWithAgent;
+ }
+
+ return connectExistingCluster;
+ },
+ shouldTriggerModal() {
+ return this.canAddCluster && this.displayClusterAgents;
},
},
};
@@ -36,25 +54,29 @@ export default {
<template>
<div class="nav-controls gl-ml-auto">
<gl-dropdown
+ v-if="certificateBasedClustersEnabled"
ref="dropdown"
- v-gl-modal-directive="canAddCluster && $options.INSTALL_AGENT_MODAL_ID"
+ v-gl-modal-directive="shouldTriggerModal && $options.INSTALL_AGENT_MODAL_ID"
v-gl-tooltip="tooltip"
category="primary"
variant="confirm"
:text="$options.i18n.actionsButton"
:disabled="!canAddCluster"
- split
+ :split="displayClusterAgents"
right
>
- <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>
+ <template v-if="displayClusterAgents">
+ <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>
+ </template>
+
<gl-dropdown-item :href="newClusterPath" data-testid="new-cluster-link" @click.stop>
{{ $options.i18n.createNewCluster }}
</gl-dropdown-item>
@@ -62,5 +84,15 @@ export default {
{{ $options.i18n.connectExistingCluster }}
</gl-dropdown-item>
</gl-dropdown>
+ <gl-button
+ v-else
+ v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
+ v-gl-tooltip="tooltip"
+ :disabled="!canAddCluster"
+ category="primary"
+ variant="confirm"
+ >
+ {{ $options.i18n.connectWithAgent }}
+ </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue b/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue
index ce601de57bd..76bec05cfc7 100644
--- a/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue
@@ -13,7 +13,7 @@ export default {
GlSprintf,
GlAlert,
},
- inject: ['emptyStateHelpText', 'clustersEmptyStateImage', 'newClusterPath'],
+ inject: ['emptyStateHelpText', 'clustersEmptyStateImage', 'addClusterPath'],
props: {
isChildComponent: {
default: false,
@@ -57,7 +57,7 @@ export default {
category="primary"
variant="confirm"
:disabled="!canAddCluster"
- :href="newClusterPath"
+ :href="addClusterPath"
>
{{ $options.i18n.buttonText }}
</gl-button>
diff --git a/app/assets/javascripts/clusters_list/components/clusters_main_view.vue b/app/assets/javascripts/clusters_list/components/clusters_main_view.vue
index 7dd5ece9b8e..aab6d3dc1f0 100644
--- a/app/assets/javascripts/clusters_list/components/clusters_main_view.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters_main_view.vue
@@ -3,11 +3,13 @@ import { GlTabs, GlTab } from '@gitlab/ui';
import Tracking from '~/tracking';
import {
CLUSTERS_TABS,
+ CERTIFICATE_TAB,
MAX_CLUSTERS_LIST,
MAX_LIST_COUNT,
AGENT,
EVENT_LABEL_TABS,
EVENT_ACTIONS_CHANGE,
+ AGENT_TAB,
} from '../constants';
import Agents from './agents.vue';
import InstallAgentModal from './install_agent_modal.vue';
@@ -27,8 +29,8 @@ export default {
Agents,
InstallAgentModal,
},
- CLUSTERS_TABS,
mixins: [trackingMixin],
+ inject: ['displayClusterAgents', 'certificateBasedClustersEnabled'],
props: {
defaultBranchName: {
default: '.noBranch',
@@ -42,13 +44,30 @@ export default {
maxAgents: MAX_CLUSTERS_LIST,
};
},
+ computed: {
+ availableTabs() {
+ const clusterTabs = this.displayClusterAgents ? CLUSTERS_TABS : [CERTIFICATE_TAB];
+ return this.certificateBasedClustersEnabled ? clusterTabs : [AGENT_TAB];
+ },
+ },
+ watch: {
+ selectedTabIndex: {
+ handler(val) {
+ this.onTabChange(val);
+ },
+ immediate: true,
+ },
+ },
methods: {
- onTabChange(tabName) {
- this.selectedTabIndex = CLUSTERS_TABS.findIndex((tab) => tab.queryParamValue === tabName);
- this.maxAgents = tabName === AGENT ? MAX_LIST_COUNT : MAX_CLUSTERS_LIST;
+ setSelectedTab(tabName) {
+ this.selectedTabIndex = this.availableTabs.findIndex(
+ (tab) => tab.queryParamValue === tabName,
+ );
},
- trackTabChange(tab) {
- const tabName = CLUSTERS_TABS[tab].queryParamValue;
+ onTabChange(tab) {
+ const tabName = this.availableTabs[tab].queryParamValue;
+
+ this.maxAgents = tabName === AGENT ? MAX_LIST_COUNT : MAX_CLUSTERS_LIST;
this.track(EVENT_ACTIONS_CHANGE, { property: tabName });
},
},
@@ -61,10 +80,9 @@ export default {
sync-active-tab-with-query-params
nav-class="gl-flex-grow-1 gl-align-items-center"
lazy
- @input="trackTabChange"
>
<gl-tab
- v-for="(tab, idx) in $options.CLUSTERS_TABS"
+ v-for="(tab, idx) in availableTabs"
:key="idx"
:title="tab.title"
:query-param-value="tab.queryParamValue"
@@ -74,7 +92,7 @@ export default {
:is="tab.component"
:default-branch-name="defaultBranchName"
data-testid="clusters-tab-component"
- @changeTab="onTabChange"
+ @changeTab="setSelectedTab"
/>
</gl-tab>
diff --git a/app/assets/javascripts/clusters_list/components/delete_agent_button.vue b/app/assets/javascripts/clusters_list/components/delete_agent_button.vue
index 6588d304d5c..6f2c353a67b 100644
--- a/app/assets/javascripts/clusters_list/components/delete_agent_button.vue
+++ b/app/assets/javascripts/clusters_list/components/delete_agent_button.vue
@@ -116,7 +116,7 @@ export default {
this.$toast.show(this.error || successMessage);
- this.$refs.modal.hide();
+ this.$refs.modal?.hide();
}
},
deleteAgentMutation() {
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 8fc0a66cd7e..ae0affe4c8b 100644
--- a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
+++ b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
@@ -1,18 +1,7 @@
<script>
-import {
- GlAlert,
- GlButton,
- GlFormGroup,
- GlFormInputGroup,
- GlLink,
- GlModal,
- GlSprintf,
-} from '@gitlab/ui';
+import { GlAlert, GlButton, GlFormGroup, GlLink, GlModal, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
-import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
-import CodeBlock from '~/vue_shared/components/code_block.vue';
import Tracking from '~/tracking';
-import { generateAgentRegistrationCommand } from '../clusters_util';
import {
INSTALL_AGENT_MODAL_ID,
I18N_AGENT_MODAL,
@@ -30,39 +19,32 @@ import createAgentToken from '../graphql/mutations/create_agent_token.mutation.g
import getAgentsQuery from '../graphql/queries/get_agents.query.graphql';
import agentConfigurations from '../graphql/queries/agent_configurations.query.graphql';
import AvailableAgentsDropdown from './available_agents_dropdown.vue';
+import AgentToken from './agent_token.vue';
const trackingMixin = Tracking.mixin({ label: EVENT_LABEL_MODAL });
export default {
modalId: INSTALL_AGENT_MODAL_ID,
+ i18n: I18N_AGENT_MODAL,
EVENT_ACTIONS_OPEN,
EVENT_ACTIONS_CLICK,
EVENT_LABEL_MODAL,
- basicInstallPath: helpPagePath('user/clusters/agent/install/index', {
- anchor: 'install-the-agent-into-the-cluster',
- }),
- advancedInstallPath: helpPagePath('user/clusters/agent/install/index', {
- anchor: 'advanced-installation',
- }),
enableKasPath: helpPagePath('administration/clusters/kas'),
- installAgentPath: helpPagePath('user/clusters/agent/install/index'),
registerAgentPath: helpPagePath('user/clusters/agent/install/index', {
anchor: 'register-an-agent-with-gitlab',
}),
components: {
AvailableAgentsDropdown,
- CodeBlock,
+ AgentToken,
GlAlert,
GlButton,
GlFormGroup,
- GlFormInputGroup,
GlLink,
GlModal,
GlSprintf,
- ModalCopyButton,
},
mixins: [trackingMixin],
- inject: ['projectPath', 'kasAddress', 'emptyStateImage'],
+ inject: ['projectPath', 'emptyStateImage'],
props: {
defaultBranchName: {
default: '.noBranch',
@@ -109,13 +91,10 @@ export default {
return !this.registering && this.agentName !== null;
},
canCancel() {
- return !this.registered && !this.registering && this.isAgentRegistrationModal;
+ return !this.registered && !this.registering && !this.kasDisabled;
},
canRegister() {
- return !this.registered && this.isAgentRegistrationModal;
- },
- agentRegistrationCommand() {
- return generateAgentRegistrationCommand(this.agentToken, this.kasAddress);
+ return !this.registered && !this.kasDisabled;
},
getAgentsQueryVariables() {
return {
@@ -125,32 +104,20 @@ export default {
projectPath: this.projectPath,
};
},
- i18n() {
- return I18N_AGENT_MODAL[this.modalType];
- },
+
repositoryPath() {
return `/${this.projectPath}`;
},
modalType() {
- return !this.availableAgents?.length && !this.registered
- ? MODAL_TYPE_EMPTY
- : MODAL_TYPE_REGISTER;
+ return this.kasDisabled ? MODAL_TYPE_EMPTY : MODAL_TYPE_REGISTER;
},
modalSize() {
- return this.isEmptyStateModal ? 'sm' : 'md';
- },
- isEmptyStateModal() {
- return this.modalType === MODAL_TYPE_EMPTY;
- },
- isAgentRegistrationModal() {
- return this.modalType === MODAL_TYPE_REGISTER;
- },
- isKasEnabledInEmptyStateModal() {
- return this.isEmptyStateModal && !this.kasDisabled;
+ return this.kasDisabled ? 'sm' : 'md';
},
},
methods: {
setAgentName(name) {
+ this.error = null;
this.agentName = name;
this.track(EVENT_ACTIONS_SELECT);
},
@@ -194,13 +161,13 @@ export default {
return createClusterAgent;
});
},
- createAgentTokenMutation(agendId) {
+ createAgentTokenMutation(agentId) {
return this.$apollo
.mutate({
mutation: createAgentToken,
variables: {
input: {
- clusterAgentId: agendId,
+ clusterAgentId: agentId,
name: this.agentName,
},
},
@@ -244,7 +211,7 @@ export default {
if (error) {
this.error = error.message;
} else {
- this.error = this.i18n.unknownError;
+ this.error = this.$options.i18n.unknownError;
}
} finally {
this.registering = false;
@@ -258,22 +225,21 @@ export default {
<gl-modal
ref="modal"
:modal-id="$options.modalId"
- :title="i18n.modalTitle"
+ :title="$options.i18n.modalTitle"
:size="modalSize"
static
lazy
@hidden="resetModal"
@show="track($options.EVENT_ACTIONS_OPEN, { property: modalType })"
>
- <template v-if="isAgentRegistrationModal">
+ <template v-if="!kasDisabled">
<template v-if="!registered">
- <p>
- <strong>{{ i18n.selectAgentTitle }}</strong>
- </p>
-
- <p class="gl-mb-0">{{ i18n.selectAgentBody }}</p>
- <p>
- <gl-link :href="$options.registerAgentPath"> {{ i18n.learnMoreLink }}</gl-link>
+ <p class="gl-mb-0">
+ <gl-sprintf :message="$options.i18n.modalBody">
+ <template #link="{ content }">
+ <gl-link :href="repositoryPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
</p>
<form>
@@ -287,90 +253,36 @@ export default {
</gl-form-group>
</form>
- <p v-if="error">
- <gl-alert :title="i18n.registrationErrorTitle" variant="danger" :dismissible="false">
- {{ error }}
- </gl-alert>
- </p>
- </template>
-
- <template v-else>
- <p>
- <strong>{{ i18n.tokenTitle }}</strong>
- </p>
-
<p>
- <gl-sprintf :message="i18n.tokenBody">
- <template #link="{ content }">
- <gl-link :href="$options.basicInstallPath" target="_blank"> {{ content }}</gl-link>
- </template>
- </gl-sprintf>
+ <gl-link :href="$options.registerAgentPath"> {{ $options.i18n.learnMoreLink }}</gl-link>
</p>
- <p>
- <gl-alert :title="i18n.tokenSingleUseWarningTitle" variant="warning" :dismissible="false">
- {{ i18n.tokenSingleUseWarningBody }}
+ <p v-if="error">
+ <gl-alert
+ :title="$options.i18n.registrationErrorTitle"
+ variant="danger"
+ :dismissible="false"
+ >
+ {{ error }}
</gl-alert>
</p>
-
- <p>
- <gl-form-input-group readonly :value="agentToken" :select-on-click="true">
- <template #append>
- <modal-copy-button
- :text="agentToken"
- :title="i18n.copyToken"
- :modal-id="$options.modalId"
- />
- </template>
- </gl-form-input-group>
- </p>
-
- <p>
- <strong>{{ i18n.basicInstallTitle }}</strong>
- </p>
-
- <p>
- {{ i18n.basicInstallBody }}
- </p>
-
- <p>
- <code-block :code="agentRegistrationCommand" />
- </p>
-
- <p>
- <strong>{{ i18n.advancedInstallTitle }}</strong>
- </p>
-
- <p>
- <gl-sprintf :message="i18n.advancedInstallBody">
- <template #link="{ content }">
- <gl-link :href="$options.advancedInstallPath" target="_blank"> {{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </p>
</template>
+
+ <agent-token v-else :agent-token="agentToken" :modal-id="$options.modalId" />
</template>
<template v-else>
<div class="gl-text-center gl-mb-5">
- <img :alt="i18n.altText" :src="emptyStateImage" height="100" />
+ <img :alt="$options.i18n.altText" :src="emptyStateImage" height="100" />
</div>
<p v-if="kasDisabled">
- <gl-sprintf :message="i18n.enableKasText">
+ <gl-sprintf :message="$options.i18n.enableKasText">
<template #link="{ content }">
<gl-link :href="$options.enableKasPath">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
-
- <p v-else>
- <gl-sprintf :message="i18n.modalBody">
- <template #link="{ content }">
- <gl-link :href="$options.installAgentPath">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </p>
</template>
<template #modal-footer>
@@ -382,7 +294,7 @@ export default {
:data-track-label="$options.EVENT_LABEL_MODAL"
data-track-property="close"
@click="closeModal"
- >{{ i18n.close }}
+ >{{ $options.i18n.close }}
</gl-button>
<gl-button
@@ -391,7 +303,7 @@ export default {
:data-track-label="$options.EVENT_LABEL_MODAL"
data-track-property="cancel"
@click="closeModal"
- >{{ i18n.cancel }}
+ >{{ $options.i18n.cancel }}
</gl-button>
<gl-button
@@ -403,25 +315,16 @@ export default {
:data-track-label="$options.EVENT_LABEL_MODAL"
data-track-property="register"
@click="registerAgent"
- >{{ i18n.registerAgentButton }}
+ >{{ $options.i18n.registerAgentButton }}
</gl-button>
<gl-button
- v-if="isEmptyStateModal"
+ v-if="kasDisabled"
:data-track-action="$options.EVENT_ACTIONS_CLICK"
:data-track-label="$options.EVENT_LABEL_MODAL"
data-track-property="done"
@click="closeModal"
- >{{ i18n.done }}
- </gl-button>
-
- <gl-button
- v-if="isKasEnabledInEmptyStateModal"
- :href="repositoryPath"
- variant="confirm"
- category="primary"
- data-testid="agent-primary-button"
- >{{ i18n.primaryButton }}
+ >{{ $options.i18n.close }}
</gl-button>
</template>
</gl-modal>
diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js
index 5cf6fd050a1..c914ee518b2 100644
--- a/app/assets/javascripts/clusters_list/constants.js
+++ b/app/assets/javascripts/clusters_list/constants.js
@@ -75,74 +75,74 @@ export const I18N_AGENT_TABLE = {
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.",
+ "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.',
+ '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?'),
+ viewDocsText: s__('ClusterAgents|How to update an agent?'),
+ defaultConfigText: s__('ClusterAgents|Default configuration'),
+ defaultConfigTooltip: s__('ClusterAgents|What is default configuration?'),
};
-export const I18N_AGENT_MODAL = {
- agent_registration: {
- registerAgentButton: s__('ClusterAgents|Register'),
- close: __('Close'),
- cancel: __('Cancel'),
-
- modalTitle: s__('ClusterAgents|Connect a cluster through the Agent'),
- selectAgentTitle: s__('ClusterAgents|Select an agent to register with GitLab'),
- selectAgentBody: s__(
- 'ClusterAgents|Register an agent to generate a token that will be used to install the agent on your cluster in the next step.',
- ),
- learnMoreLink: s__('ClusterAgents|How to register an agent?'),
+export const I18N_AGENT_TOKEN = {
+ copyToken: s__('ClusterAgents|Copy token'),
+ copyCommand: s__('ClusterAgents|Copy command'),
+ tokenTitle: s__('ClusterAgents|Registration token'),
- copyToken: s__('ClusterAgents|Copy token'),
- tokenTitle: s__('ClusterAgents|Registration token'),
- tokenBody: s__(
- `ClusterAgents|The registration token will be used to connect the agent on your cluster to GitLab. %{linkStart}What are registration tokens?%{linkEnd}`,
- ),
+ tokenBody: s__(
+ `ClusterAgents|The registration token will be used to connect the agent on your cluster to GitLab. %{linkStart}What are registration tokens?%{linkEnd}`,
+ ),
+ tokenSingleUseWarningTitle: s__(
+ 'ClusterAgents|You cannot see this token again after you close this window.',
+ ),
+ tokenSingleUseWarningBody: s__(
+ `ClusterAgents|The recommended installation method includes the token. If you want to follow the advanced installation method provided in the docs, make sure you save the token value before you close this window.`,
+ ),
- tokenSingleUseWarningTitle: s__(
- 'ClusterAgents|You cannot see this token again after you close this window.',
- ),
- tokenSingleUseWarningBody: s__(
- `ClusterAgents|The recommended installation method includes the token. If you want to follow the advanced installation method provided in the docs, make sure you save the token value before you close this window.`,
- ),
+ basicInstallTitle: s__('ClusterAgents|Recommended installation method'),
+ basicInstallBody: __(
+ `Open a CLI and connect to the cluster you want to install the agent in. Use this installation method to minimize any manual steps. The token is already included in the command.`,
+ ),
- basicInstallTitle: s__('ClusterAgents|Recommended installation method'),
- basicInstallBody: __(
- `Open a CLI and connect to the cluster you want to install the agent in. Use this installation method to minimize any manual steps. The token is already included in the command.`,
- ),
+ advancedInstallTitle: s__('ClusterAgents|Advanced installation methods'),
+ advancedInstallBody: s__(
+ 'ClusterAgents|For the advanced installation method %{linkStart}see the documentation%{linkEnd}.',
+ ),
+};
- advancedInstallTitle: s__('ClusterAgents|Advanced installation methods'),
- advancedInstallBody: s__(
- 'ClusterAgents|For the advanced installation method %{linkStart}see the documentation%{linkEnd}.',
- ),
+export const I18N_AGENT_MODAL = {
+ registerAgentButton: s__('ClusterAgents|Register'),
+ close: __('Close'),
+ cancel: __('Cancel'),
- registrationErrorTitle: s__('ClusterAgents|Failed to register an agent'),
- unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'),
- },
- empty_state: {
- modalTitle: s__('ClusterAgents|Connect your cluster through the Agent'),
- modalBody: s__(
- "ClusterAgents|To install a new agent, first add the agent's configuration file to this repository. %{linkStart}Learn more about installing GitLab Agent.%{linkEnd}",
- ),
- enableKasText: s__(
- "ClusterAgents|Your instance doesn't have the %{linkStart}GitLab Agent Server (KAS)%{linkEnd} set up. Ask a GitLab Administrator to install it.",
- ),
- altText: s__('ClusterAgents|GitLab Agent for Kubernetes'),
- primaryButton: s__('ClusterAgents|Go to the repository files'),
- done: __('Cancel'),
- },
+ modalTitle: s__('ClusterAgents|Connect a cluster through an agent'),
+ modalBody: s__(
+ 'ClusterAgents|Add an agent configuration file to %{linkStart}this repository%{linkEnd} and select it, or create a new one to register with GitLab:',
+ ),
+ enableKasText: s__(
+ "ClusterAgents|Your instance doesn't have the %{linkStart}GitLab Agent Server (KAS)%{linkEnd} set up. Ask a GitLab Administrator to install it.",
+ ),
+ altText: s__('ClusterAgents|GitLab Agent for Kubernetes'),
+ learnMoreLink: s__('ClusterAgents|How do I register an agent?'),
+ copyToken: s__('ClusterAgents|Copy token'),
+ tokenTitle: s__('ClusterAgents|Registration token'),
+ tokenBody: s__(
+ `ClusterAgents|The registration token will be used to connect the agent on your cluster to GitLab. %{linkStart}What are registration tokens?%{linkEnd}`,
+ ),
+ registrationErrorTitle: s__('ClusterAgents|Failed to register an agent'),
+ unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'),
};
export const KAS_DISABLED_ERROR = 'Gitlab::Kas::Client::ConfigurationError';
export const I18N_AVAILABLE_AGENTS_DROPDOWN = {
- selectAgent: s__('ClusterAgents|Select an agent'),
- registeringAgent: s__('ClusterAgents|Registering Agent'),
+ selectAgent: s__('ClusterAgents|Select an agent or enter a name to create new'),
+ registeringAgent: s__('ClusterAgents|Registering agent'),
+ noResults: __('No matching results'),
+ createButton: s__('ClusterAgents|Create agent: %{searchTerm}'),
};
export const AGENT_STATUSES = {
@@ -197,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'),
@@ -209,7 +209,7 @@ export const AGENT_CARD_INFO = {
),
link: helpPagePath('user/clusters/agent/index'),
},
- actionText: s__('ClusterAgents|Install new Agent'),
+ actionText: s__('ClusterAgents|Install a new agent'),
footerText: sprintf(s__('ClusterAgents|View all %{number} agents')),
installAgentDisabledHint: s__(
'ClusterAgents|Requires a Maintainer or greater role to install new agents',
@@ -232,28 +232,29 @@ export const CERTIFICATE_BASED_CARD_INFO = {
export const MAX_CLUSTERS_LIST = 6;
-export const CLUSTERS_TABS = [
- {
- title: s__('ClusterAgents|All'),
- component: 'ClustersViewAll',
- queryParamValue: 'all',
- },
- {
- title: s__('ClusterAgents|Agent'),
- component: 'agents',
- queryParamValue: 'agent',
- },
- {
- title: s__('ClusterAgents|Certificate'),
- component: 'clusters',
- queryParamValue: 'certificate_based',
- },
-];
+export const ALL_TAB = {
+ title: s__('ClusterAgents|All'),
+ component: 'ClustersViewAll',
+ queryParamValue: 'all',
+};
+
+export const AGENT_TAB = {
+ title: s__('ClusterAgents|Agent'),
+ component: 'agents',
+ queryParamValue: 'agent',
+};
+export const CERTIFICATE_TAB = {
+ title: s__('ClusterAgents|Certificate'),
+ component: 'clusters',
+ queryParamValue: 'certificate_based',
+};
+
+export const CLUSTERS_TABS = [ALL_TAB, AGENT_TAB, CERTIFICATE_TAB];
export const CLUSTERS_ACTIONS = {
actionsButton: s__('ClusterAgents|Actions'),
createNewCluster: s__('ClusterAgents|Create a new cluster'),
- connectWithAgent: s__('ClusterAgents|Connect with Agent'),
+ connectWithAgent: s__('ClusterAgents|Connect with an agent'),
connectExistingCluster: s__('ClusterAgents|Connect with a certificate'),
agent: s__('ClusterAgents|Agent'),
certificate: s__('ClusterAgents|Certificate'),
diff --git a/app/assets/javascripts/clusters_list/graphql/cache_update.js b/app/assets/javascripts/clusters_list/graphql/cache_update.js
index 6476b7a6c2f..e68f6a378c0 100644
--- a/app/assets/javascripts/clusters_list/graphql/cache_update.js
+++ b/app/assets/javascripts/clusters_list/graphql/cache_update.js
@@ -1,5 +1,4 @@
import produce from 'immer';
-import { getAgentConfigPath } from '../clusters_util';
export const hasErrors = ({ errors = [] }) => errors?.length;
@@ -12,17 +11,8 @@ export function addAgentToStore(store, createClusterAgent, query, variables) {
});
const data = produce(sourceData, (draftData) => {
- const configuration = {
- id: clusterAgent.id,
- name: clusterAgent.name,
- path: getAgentConfigPath(clusterAgent.name),
- webPath: clusterAgent.webPath,
- __typename: 'TreeEntry',
- };
-
draftData.project.clusterAgents.nodes.push(clusterAgent);
draftData.project.clusterAgents.count += 1;
- draftData.project.repository.tree.trees.nodes.push(configuration);
});
store.writeQuery({
diff --git a/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql
index f8efb6683f6..7743ffba5de 100644
--- a/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql
+++ b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql
@@ -7,9 +7,7 @@ query getAgents(
$first: Int
$last: Int
$afterAgent: String
- $afterTree: String
$beforeAgent: String
- $beforeTree: String
) {
project(fullPath: $projectPath) {
id
@@ -27,17 +25,13 @@ query getAgents(
repository {
tree(path: ".gitlab/agents", ref: $defaultBranchName) {
- trees(first: $first, last: $last, after: $afterTree, before: $beforeTree) {
+ trees {
nodes {
id
name
path
webPath
}
-
- pageInfo {
- ...PageInfo
- }
}
}
}
diff --git a/app/assets/javascripts/clusters_list/index.js b/app/assets/javascripts/clusters_list/index.js
index 6148483dcb0..27eebc9d891 100644
--- a/app/assets/javascripts/clusters_list/index.js
+++ b/app/assets/javascripts/clusters_list/index.js
@@ -1,13 +1,63 @@
import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import loadClusters from './load_clusters';
-import loadMainView from './load_main_view';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import createDefaultClient from '~/lib/graphql';
+import ClustersMainView from './components/clusters_main_view.vue';
+import { createStore } from './store';
Vue.use(GlToast);
Vue.use(VueApollo);
export default () => {
- loadClusters(Vue);
- loadMainView(Vue, VueApollo);
+ const el = document.querySelector('.js-clusters-main-view');
+
+ if (!el) {
+ return null;
+ }
+
+ const defaultClient = createDefaultClient();
+
+ const {
+ emptyStateImage,
+ defaultBranchName,
+ projectPath,
+ kasAddress,
+ newClusterPath,
+ addClusterPath,
+ emptyStateHelpText,
+ clustersEmptyStateImage,
+ canAddCluster,
+ canAdminCluster,
+ gitlabVersion,
+ displayClusterAgents,
+ certificateBasedClustersEnabled,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ apolloProvider: new VueApollo({ defaultClient }),
+ provide: {
+ emptyStateImage,
+ projectPath,
+ kasAddress,
+ newClusterPath,
+ addClusterPath,
+ emptyStateHelpText,
+ clustersEmptyStateImage,
+ canAddCluster: parseBoolean(canAddCluster),
+ canAdminCluster: parseBoolean(canAdminCluster),
+ gitlabVersion,
+ displayClusterAgents: parseBoolean(displayClusterAgents),
+ certificateBasedClustersEnabled: parseBoolean(certificateBasedClustersEnabled),
+ },
+ store: createStore(el.dataset),
+ render(createElement) {
+ return createElement(ClustersMainView, {
+ props: {
+ defaultBranchName,
+ },
+ });
+ },
+ });
};
diff --git a/app/assets/javascripts/clusters_list/load_clusters.js b/app/assets/javascripts/clusters_list/load_clusters.js
deleted file mode 100644
index 1bb3ea546b2..00000000000
--- a/app/assets/javascripts/clusters_list/load_clusters.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import Clusters from './components/clusters.vue';
-import { createStore } from './store';
-
-export default (Vue) => {
- const el = document.querySelector('#js-clusters-list-app');
-
- if (!el) {
- return null;
- }
-
- const { emptyStateHelpText, newClusterPath, clustersEmptyStateImage } = el.dataset;
-
- return new Vue({
- el,
- provide: {
- emptyStateHelpText,
- newClusterPath,
- clustersEmptyStateImage,
- },
- store: createStore(el.dataset),
- render(createElement) {
- return createElement(Clusters);
- },
- });
-};
diff --git a/app/assets/javascripts/clusters_list/load_main_view.js b/app/assets/javascripts/clusters_list/load_main_view.js
deleted file mode 100644
index d52b1d4a64d..00000000000
--- a/app/assets/javascripts/clusters_list/load_main_view.js
+++ /dev/null
@@ -1,57 +0,0 @@
-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';
-
-Vue.use(VueApollo);
-
-export default () => {
- const el = document.querySelector('.js-clusters-main-view');
-
- if (!el) {
- return null;
- }
-
- const defaultClient = createDefaultClient();
-
- const {
- emptyStateImage,
- defaultBranchName,
- projectPath,
- kasAddress,
- newClusterPath,
- addClusterPath,
- emptyStateHelpText,
- clustersEmptyStateImage,
- canAddCluster,
- canAdminCluster,
- gitlabVersion,
- } = el.dataset;
-
- return new Vue({
- el,
- apolloProvider: new VueApollo({ defaultClient }),
- provide: {
- emptyStateImage,
- projectPath,
- kasAddress,
- newClusterPath,
- addClusterPath,
- emptyStateHelpText,
- clustersEmptyStateImage,
- canAddCluster: parseBoolean(canAddCluster),
- canAdminCluster: parseBoolean(canAdminCluster),
- gitlabVersion,
- },
- store: createStore(el.dataset),
- render(createElement) {
- return createElement(ClustersMainView, {
- props: {
- defaultBranchName,
- },
- });
- },
- });
-};
diff --git a/app/assets/javascripts/code_navigation/components/app.vue b/app/assets/javascripts/code_navigation/components/app.vue
index d38b38947b6..5c77f087d63 100644
--- a/app/assets/javascripts/code_navigation/components/app.vue
+++ b/app/assets/javascripts/code_navigation/components/app.vue
@@ -7,6 +7,23 @@ export default {
components: {
Popover,
},
+ props: {
+ codeNavigationPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ blobPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ pathPrefix: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
computed: {
...mapState([
'currentDefinition',
@@ -16,6 +33,14 @@ export default {
]),
},
mounted() {
+ if (this.codeNavigationPath && this.blobPath && this.pathPrefix) {
+ const initialData = {
+ blobs: [{ path: this.blobPath, codeNavigationPath: this.codeNavigationPath }],
+ definitionPathPrefix: this.pathPrefix,
+ };
+ this.setInitialData(initialData);
+ }
+
this.body = document.body;
eventHub.$on('showBlobInteractionZones', this.showBlobInteractionZones);
@@ -28,7 +53,7 @@ export default {
this.removeGlobalEventListeners();
},
methods: {
- ...mapActions(['fetchData', 'showDefinition', 'showBlobInteractionZones']),
+ ...mapActions(['fetchData', 'showDefinition', 'showBlobInteractionZones', 'setInitialData']),
addGlobalEventListeners() {
if (this.body) {
this.body.addEventListener('click', this.showDefinition);
diff --git a/app/assets/javascripts/code_quality_walkthrough/components/step.vue b/app/assets/javascripts/code_quality_walkthrough/components/step.vue
deleted file mode 100644
index 1a23c96b7d6..00000000000
--- a/app/assets/javascripts/code_quality_walkthrough/components/step.vue
+++ /dev/null
@@ -1,150 +0,0 @@
-<script>
-import { GlPopover, GlSprintf, GlButton, GlAlert } from '@gitlab/ui';
-import { STEPS, STEPSTATES } from '../constants';
-import {
- isWalkthroughEnabled,
- getExperimentSettings,
- setExperimentSettings,
- track,
-} from '../utils';
-
-export default {
- target: '#js-code-quality-walkthrough',
- components: {
- GlPopover,
- GlSprintf,
- GlButton,
- GlAlert,
- },
- props: {
- step: {
- type: String,
- required: true,
- },
- link: {
- type: String,
- required: false,
- default: null,
- },
- },
- data() {
- return {
- dismissedSettings: getExperimentSettings(),
- currentStep: STEPSTATES[this.step],
- };
- },
- computed: {
- isPopoverVisible() {
- return (
- [
- STEPS.commitCiFile,
- STEPS.runningPipeline,
- STEPS.successPipeline,
- STEPS.failedPipeline,
- ].includes(this.step) &&
- isWalkthroughEnabled() &&
- !this.isDismissed
- );
- },
- isAlertVisible() {
- return this.step === STEPS.troubleshootJob && isWalkthroughEnabled() && !this.isDismissed;
- },
- isDismissed() {
- return this.dismissedSettings[this.step];
- },
- title() {
- return this.currentStep?.title || '';
- },
- body() {
- return this.currentStep?.body || '';
- },
- buttonText() {
- return this.currentStep?.buttonText || '';
- },
- buttonLink() {
- return [STEPS.successPipeline, STEPS.failedPipeline].includes(this.step) ? this.link : '';
- },
- placement() {
- return this.currentStep?.placement || 'bottom';
- },
- offset() {
- return this.currentStep?.offset || 0;
- },
- },
- created() {
- this.trackDisplayed();
- },
- updated() {
- this.trackDisplayed();
- },
- methods: {
- onDismiss() {
- this.$set(this.dismissedSettings, this.step, true);
- setExperimentSettings(this.dismissedSettings);
- const action = [STEPS.successPipeline, STEPS.failedPipeline].includes(this.step)
- ? 'view_logs'
- : 'dismissed';
- this.trackAction(action);
- },
- trackDisplayed() {
- if (this.isPopoverVisible || this.isAlertVisible) {
- this.trackAction('displayed');
- }
- },
- trackAction(action) {
- track(`${this.step}_${action}`);
- },
- },
-};
-</script>
-
-<template>
- <div>
- <gl-popover
- v-if="isPopoverVisible"
- :key="step"
- :target="$options.target"
- :placement="placement"
- :offset="offset"
- show
- triggers="manual"
- container="viewport"
- >
- <template #title>
- <gl-sprintf :message="title">
- <template #emoji="{ content }">
- <gl-emoji class="gl-mr-2" :data-name="content"
- /></template>
- </gl-sprintf>
- </template>
- <gl-sprintf :message="body">
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- <template #lineBreak>
- <div class="gl-mt-5"></div>
- </template>
- <template #emoji="{ content }">
- <gl-emoji :data-name="content" />
- </template>
- </gl-sprintf>
- <div class="gl-mt-2 gl-text-right">
- <gl-button category="tertiary" variant="link" :href="buttonLink" @click="onDismiss">
- {{ buttonText }}
- </gl-button>
- </div>
- </gl-popover>
- <gl-alert
- v-if="isAlertVisible"
- variant="tip"
- :title="title"
- :primary-button-text="buttonText"
- :primary-button-link="link"
- class="gl-my-5"
- @primaryAction="trackAction('clicked')"
- @dismiss="onDismiss"
- >
- {{ body }}
- </gl-alert>
- </div>
-</template>
diff --git a/app/assets/javascripts/code_quality_walkthrough/constants.js b/app/assets/javascripts/code_quality_walkthrough/constants.js
deleted file mode 100644
index 011df06b5cc..00000000000
--- a/app/assets/javascripts/code_quality_walkthrough/constants.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import { s__ } from '~/locale';
-
-export const EXPERIMENT_NAME = 'code_quality_walkthrough';
-
-export const STEPS = {
- commitCiFile: 'commit_ci_file',
- runningPipeline: 'running_pipeline',
- successPipeline: 'success_pipeline',
- failedPipeline: 'failed_pipeline',
- troubleshootJob: 'troubleshoot_job',
-};
-
-export const STEPSTATES = {
- [STEPS.commitCiFile]: {
- title: s__("codeQualityWalkthrough|Let's start by creating a new CI file."),
- body: s__(
- 'codeQualityWalkthrough|To begin with code quality, we first need to create a new CI file using our code editor. We added a code quality template in the code editor to help you get started %{emojiStart}wink%{emojiEnd} .%{lineBreak}Take some time to review the template, when you are ready, use the %{strongStart}commit changes%{strongEnd} button at the bottom of the page.',
- ),
- buttonText: s__('codeQualityWalkthrough|Got it'),
- placement: 'right',
- offset: 90,
- },
- [STEPS.runningPipeline]: {
- title: s__(
- 'codeQualityWalkthrough|Congrats! Your first pipeline is running %{emojiStart}zap%{emojiEnd}',
- ),
- body: s__(
- "codeQualityWalkthrough|Your pipeline can take a few minutes to run. If you enabled email notifications, you'll receive an email with your pipeline status. In the meantime, why don't you get some coffee? You earned it!",
- ),
- buttonText: s__('codeQualityWalkthrough|Got it'),
- offset: 97,
- },
- [STEPS.successPipeline]: {
- title: s__(
- "codeQualityWalkthrough|Well done! You've just automated your code quality review. %{emojiStart}raised_hands%{emojiEnd}",
- ),
- body: s__(
- 'codeQualityWalkthrough|A code quality job will now run every time you or your team members commit changes to your project. You can view the results of the code quality job in the job logs.',
- ),
- buttonText: s__('codeQualityWalkthrough|View the logs'),
- offset: 98,
- },
- [STEPS.failedPipeline]: {
- title: s__(
- "codeQualityWalkthrough|Something went wrong. %{emojiStart}thinking%{emojiEnd} Let's fix it.",
- ),
- body: s__(
- "codeQualityWalkthrough|Your job failed. No worries - this happens. Let's view the logs, and see how we can fix it.",
- ),
- buttonText: s__('codeQualityWalkthrough|View the logs'),
- offset: 98,
- },
- [STEPS.troubleshootJob]: {
- title: s__('codeQualityWalkthrough|Troubleshoot your code quality job'),
- body: s__(
- 'codeQualityWalkthrough|Not sure how to fix your failed job? We have compiled some tips on how to troubleshoot code quality jobs in the documentation.',
- ),
- buttonText: s__('codeQualityWalkthrough|Read the documentation'),
- },
-};
-
-export const PIPELINE_STATUSES = {
- running: 'running',
- successWithWarnings: 'success-with-warnings',
- success: 'success',
- failed: 'failed',
-};
diff --git a/app/assets/javascripts/code_quality_walkthrough/index.js b/app/assets/javascripts/code_quality_walkthrough/index.js
deleted file mode 100644
index b0592b8a84b..00000000000
--- a/app/assets/javascripts/code_quality_walkthrough/index.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import Vue from 'vue';
-import Step from './components/step.vue';
-
-export default (el) =>
- new Vue({
- el,
- render(createElement) {
- return createElement(Step, {
- props: {
- step: el.dataset.step,
- },
- });
- },
- });
diff --git a/app/assets/javascripts/code_quality_walkthrough/utils.js b/app/assets/javascripts/code_quality_walkthrough/utils.js
deleted file mode 100644
index 894ec9a171d..00000000000
--- a/app/assets/javascripts/code_quality_walkthrough/utils.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants';
-import { getExperimentData } from '~/experimentation/utils';
-import { setCookie, getCookie } from '~/lib/utils/common_utils';
-import { getParameterByName } from '~/lib/utils/url_utility';
-import Tracking from '~/tracking';
-import { EXPERIMENT_NAME } from './constants';
-
-export function getExperimentSettings() {
- return JSON.parse(getCookie(EXPERIMENT_NAME) || '{}');
-}
-
-export function setExperimentSettings(settings) {
- setCookie(EXPERIMENT_NAME, settings);
-}
-
-export function isWalkthroughEnabled() {
- return getParameterByName(EXPERIMENT_NAME);
-}
-
-export function track(action) {
- const { data } = getExperimentSettings();
-
- if (data) {
- Tracking.event(EXPERIMENT_NAME, action, {
- context: {
- schema: TRACKING_CONTEXT_SCHEMA,
- data,
- },
- });
- }
-}
-
-export function startCodeQualityWalkthrough() {
- const data = getExperimentData(EXPERIMENT_NAME);
-
- if (data) {
- setExperimentSettings({ data });
- }
-}
diff --git a/app/assets/javascripts/commons/nav/user_merge_requests.js b/app/assets/javascripts/commons/nav/user_merge_requests.js
index 84ab728274f..784e9cb2faa 100644
--- a/app/assets/javascripts/commons/nav/user_merge_requests.js
+++ b/app/assets/javascripts/commons/nav/user_merge_requests.js
@@ -23,7 +23,18 @@ function updateReviewerMergeRequestCounts(newCount) {
function updateMergeRequestCounts(newCount) {
const mergeRequestsCountEl = document.querySelector('.js-merge-requests-count');
mergeRequestsCountEl.textContent = newCount.toLocaleString();
- mergeRequestsCountEl.classList.toggle('hidden', Number(newCount) === 0);
+ mergeRequestsCountEl.classList.toggle('gl-display-none', Number(newCount) === 0);
+}
+
+function updateAttentionRequestsCount(count) {
+ const attentionCountEl = document.querySelector('.js-attention-count');
+ attentionCountEl.textContent = count.toLocaleString();
+
+ if (Number(count) === 0) {
+ attentionCountEl.classList.replace('badge-warning', 'badge-neutral');
+ } else {
+ attentionCountEl.classList.replace('badge-neutral', 'badge-warning');
+ }
}
/**
@@ -32,14 +43,22 @@ function updateMergeRequestCounts(newCount) {
export function refreshUserMergeRequestCounts() {
return getUserCounts()
.then(({ data }) => {
+ const attentionRequestsEnabled = window.gon?.features?.mrAttentionRequests;
const assignedMergeRequests = data.assigned_merge_requests;
const reviewerMergeRequests = data.review_requested_merge_requests;
- const fullCount = assignedMergeRequests + reviewerMergeRequests;
+ const attentionRequests = data.attention_requests;
+ const fullCount = attentionRequestsEnabled
+ ? attentionRequests
+ : assignedMergeRequests + reviewerMergeRequests;
updateUserMergeRequestCounts(assignedMergeRequests);
updateReviewerMergeRequestCounts(reviewerMergeRequests);
updateMergeRequestCounts(fullCount);
broadcastCount(fullCount);
+
+ if (attentionRequestsEnabled) {
+ updateAttentionRequestsCount(attentionRequests);
+ }
})
.catch((ex) => {
console.error(ex); // eslint-disable-line no-console
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index a8405fe37c7..a942c9f1149 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -1,17 +1,16 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
-import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants';
import { createContentEditor } from '../services/create_content_editor';
import ContentEditorAlert from './content_editor_alert.vue';
import ContentEditorProvider from './content_editor_provider.vue';
import EditorStateObserver from './editor_state_observer.vue';
import FormattingBubbleMenu from './formatting_bubble_menu.vue';
import TopToolbar from './top_toolbar.vue';
+import LoadingIndicator from './loading_indicator.vue';
export default {
components: {
- GlLoadingIcon,
+ LoadingIndicator,
ContentEditorAlert,
ContentEditorProvider,
TiptapEditorContent,
@@ -41,7 +40,6 @@ export default {
},
data() {
return {
- isLoadingContent: false,
focused: false,
};
},
@@ -55,25 +53,14 @@ export default {
extensions,
serializerConfig,
});
-
- this.contentEditor.on(LOADING_CONTENT_EVENT, this.displayLoadingIndicator);
- this.contentEditor.on(LOADING_SUCCESS_EVENT, this.hideLoadingIndicator);
- this.contentEditor.on(LOADING_ERROR_EVENT, this.hideLoadingIndicator);
+ },
+ mounted() {
this.$emit('initialized', this.contentEditor);
},
beforeDestroy() {
this.contentEditor.dispose();
- this.contentEditor.off(LOADING_CONTENT_EVENT, this.displayLoadingIndicator);
- this.contentEditor.off(LOADING_SUCCESS_EVENT, this.hideLoadingIndicator);
- this.contentEditor.off(LOADING_ERROR_EVENT, this.hideLoadingIndicator);
},
methods: {
- displayLoadingIndicator() {
- this.isLoadingContent = true;
- },
- hideLoadingIndicator() {
- this.isLoadingContent = false;
- },
focus() {
this.focused = true;
},
@@ -100,13 +87,11 @@ export default {
:class="{ 'is-focused': focused }"
>
<top-toolbar ref="toolbar" class="gl-mb-4" />
- <div v-if="isLoadingContent" class="gl-w-full gl-display-flex gl-justify-content-center">
- <gl-loading-icon size="sm" />
- </div>
- <template v-else>
+ <div class="gl-relative">
<formatting-bubble-menu />
<tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" />
- </template>
+ <loading-indicator />
+ </div>
</div>
</div>
</content-editor-provider>
diff --git a/app/assets/javascripts/content_editor/components/content_editor_provider.vue b/app/assets/javascripts/content_editor/components/content_editor_provider.vue
index 630aff9858f..cba3b627390 100644
--- a/app/assets/javascripts/content_editor/components/content_editor_provider.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor_provider.vue
@@ -8,6 +8,7 @@ export default {
return {
contentEditor,
+ eventHub: contentEditor.eventHub,
tiptapEditor: contentEditor.tiptapEditor,
};
},
diff --git a/app/assets/javascripts/content_editor/components/editor_state_observer.vue b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
index 0604047a953..02de6470cf2 100644
--- a/app/assets/javascripts/content_editor/components/editor_state_observer.vue
+++ b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
@@ -1,5 +1,11 @@
<script>
import { debounce } from 'lodash';
+import {
+ LOADING_CONTENT_EVENT,
+ LOADING_SUCCESS_EVENT,
+ LOADING_ERROR_EVENT,
+ ALERT_EVENT,
+} from '../constants';
export const tiptapToComponentMap = {
update: 'docUpdate',
@@ -7,30 +13,48 @@ export const tiptapToComponentMap = {
transaction: 'transaction',
focus: 'focus',
blur: 'blur',
- alert: 'alert',
};
+export const eventHubEvents = [
+ ALERT_EVENT,
+ LOADING_CONTENT_EVENT,
+ LOADING_SUCCESS_EVENT,
+ LOADING_ERROR_EVENT,
+];
+
const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEventName];
export default {
- inject: ['tiptapEditor'],
+ inject: ['tiptapEditor', 'eventHub'],
created() {
this.disposables = [];
Object.keys(tiptapToComponentMap).forEach((tiptapEvent) => {
- const eventHandler = debounce((params) => this.handleTipTapEvent(tiptapEvent, params), 100);
+ const eventHandler = debounce(
+ (params) => this.bubbleEvent(getComponentEventName(tiptapEvent), params),
+ 100,
+ );
this.tiptapEditor?.on(tiptapEvent, eventHandler);
this.disposables.push(() => this.tiptapEditor?.off(tiptapEvent, eventHandler));
});
+
+ eventHubEvents.forEach((event) => {
+ const handler = (...params) => {
+ this.bubbleEvent(event, ...params);
+ };
+
+ this.eventHub.$on(event, handler);
+ this.disposables.push(() => this.eventHub?.$off(event, handler));
+ });
},
beforeDestroy() {
this.disposables.forEach((dispose) => dispose());
},
methods: {
- handleTipTapEvent(tiptapEvent, params) {
- this.$emit(getComponentEventName(tiptapEvent), params);
+ bubbleEvent(eventHubEvent, params) {
+ this.$emit(eventHubEvent, params);
},
},
render() {
diff --git a/app/assets/javascripts/content_editor/components/loading_indicator.vue b/app/assets/javascripts/content_editor/components/loading_indicator.vue
new file mode 100644
index 00000000000..5b9383d6e11
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/loading_indicator.vue
@@ -0,0 +1,39 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import EditorStateObserver from './editor_state_observer.vue';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ EditorStateObserver,
+ },
+ data() {
+ return {
+ isLoading: false,
+ };
+ },
+ methods: {
+ displayLoadingIndicator() {
+ this.isLoading = true;
+ },
+ hideLoadingIndicator() {
+ this.isLoading = false;
+ },
+ },
+};
+</script>
+<template>
+ <editor-state-observer
+ @loading="displayLoadingIndicator"
+ @loadingSuccess="hideLoadingIndicator"
+ @loadingError="hideLoadingIndicator"
+ >
+ <div
+ v-if="isLoading"
+ class="gl-w-full gl-display-flex gl-justify-content-center gl-align-items-center gl-absolute gl-top-0 gl-bottom-0"
+ >
+ <div class="gl-bg-white gl-absolute gl-w-full gl-h-full gl-opacity-3"></div>
+ <gl-loading-icon size="md" />
+ </div>
+ </editor-state-observer>
+</template>
diff --git a/app/assets/javascripts/content_editor/constants.js b/app/assets/javascripts/content_editor/constants.js
index 5e56078df01..a39a243ec6b 100644
--- a/app/assets/javascripts/content_editor/constants.js
+++ b/app/assets/javascripts/content_editor/constants.js
@@ -42,9 +42,10 @@ export const TEXT_STYLE_DROPDOWN_ITEMS = [
},
];
-export const LOADING_CONTENT_EVENT = 'loadingContent';
+export const LOADING_CONTENT_EVENT = 'loading';
export const LOADING_SUCCESS_EVENT = 'loadingSuccess';
export const LOADING_ERROR_EVENT = 'loadingError';
+export const ALERT_EVENT = 'alert';
export const PARSE_HTML_PRIORITY_LOWEST = 1;
export const PARSE_HTML_PRIORITY_DEFAULT = 50;
@@ -56,3 +57,4 @@ export const EXTENSION_PRIORITY_LOWER = 75;
* https://tiptap.dev/guide/custom-extensions/#priority
*/
export const EXTENSION_PRIORITY_DEFAULT = 100;
+export const EXTENSION_PRIORITY_HIGHEST = 200;
diff --git a/app/assets/javascripts/content_editor/extensions/attachment.js b/app/assets/javascripts/content_editor/extensions/attachment.js
index 72df1d071d1..9634730f637 100644
--- a/app/assets/javascripts/content_editor/extensions/attachment.js
+++ b/app/assets/javascripts/content_editor/extensions/attachment.js
@@ -9,15 +9,22 @@ export default Extension.create({
return {
uploadsPath: null,
renderMarkdown: null,
+ eventHub: null,
};
},
addCommands() {
return {
uploadAttachment: ({ file }) => () => {
- const { uploadsPath, renderMarkdown } = this.options;
+ const { uploadsPath, renderMarkdown, eventHub } = this.options;
- return handleFileEvent({ file, uploadsPath, renderMarkdown, editor: this.editor });
+ return handleFileEvent({
+ file,
+ uploadsPath,
+ renderMarkdown,
+ editor: this.editor,
+ eventHub,
+ });
},
};
},
@@ -29,23 +36,25 @@ export default Extension.create({
key: new PluginKey('attachment'),
props: {
handlePaste: (_, event) => {
- const { uploadsPath, renderMarkdown } = this.options;
+ const { uploadsPath, renderMarkdown, eventHub } = this.options;
return handleFileEvent({
editor,
file: event.clipboardData.files[0],
uploadsPath,
renderMarkdown,
+ eventHub,
});
},
handleDrop: (_, event) => {
- const { uploadsPath, renderMarkdown } = this.options;
+ const { uploadsPath, renderMarkdown, eventHub } = this.options;
return handleFileEvent({
editor,
file: event.dataTransfer.files[0],
uploadsPath,
renderMarkdown,
+ eventHub,
});
},
},
diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
index 9dc17fcd570..204ac07d401 100644
--- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -1,5 +1,5 @@
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
-import * as lowlight from 'lowlight';
+import { lowlight } from 'lowlight/lib/all';
const extractLanguage = (element) => element.getAttribute('lang');
diff --git a/app/assets/javascripts/content_editor/extensions/paste_markdown.js b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
new file mode 100644
index 00000000000..c349aa42a62
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
@@ -0,0 +1,86 @@
+import { Extension } from '@tiptap/core';
+import { Plugin, PluginKey } from 'prosemirror-state';
+import { __ } from '~/locale';
+import { VARIANT_DANGER } from '~/flash';
+import createMarkdownDeserializer from '../services/markdown_deserializer';
+import {
+ ALERT_EVENT,
+ LOADING_CONTENT_EVENT,
+ LOADING_SUCCESS_EVENT,
+ LOADING_ERROR_EVENT,
+ EXTENSION_PRIORITY_HIGHEST,
+} from '../constants';
+
+const TEXT_FORMAT = 'text/plain';
+const HTML_FORMAT = 'text/html';
+const VS_CODE_FORMAT = 'vscode-editor-data';
+
+export default Extension.create({
+ name: 'pasteMarkdown',
+ priority: EXTENSION_PRIORITY_HIGHEST,
+ addOptions() {
+ return {
+ renderMarkdown: null,
+ };
+ },
+ addCommands() {
+ return {
+ pasteMarkdown: (markdown) => () => {
+ const { editor, options } = this;
+ const { renderMarkdown, eventHub } = options;
+ const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
+
+ eventHub.$emit(LOADING_CONTENT_EVENT);
+
+ deserializer
+ .deserialize({ schema: editor.schema, content: markdown })
+ .then(({ document }) => {
+ if (!document) {
+ return;
+ }
+
+ const { state, view } = editor;
+ const { tr, selection } = state;
+
+ tr.replaceWith(selection.from - 1, selection.to, document.content);
+ view.dispatch(tr);
+ eventHub.$emit(LOADING_SUCCESS_EVENT);
+ })
+ .catch(() => {
+ eventHub.$emit(ALERT_EVENT, {
+ message: __('An error occurred while pasting text in the editor. Please try again.'),
+ variant: VARIANT_DANGER,
+ });
+ eventHub.$emit(LOADING_ERROR_EVENT);
+ });
+
+ return true;
+ },
+ };
+ },
+ addProseMirrorPlugins() {
+ return [
+ new Plugin({
+ key: new PluginKey('pasteMarkdown'),
+ props: {
+ handlePaste: (_, event) => {
+ const { clipboardData } = event;
+ const content = clipboardData.getData(TEXT_FORMAT);
+ const hasHTML = clipboardData.types.some((type) => type === HTML_FORMAT);
+ const hasVsCode = clipboardData.types.some((type) => type === VS_CODE_FORMAT);
+ const vsCodeMeta = hasVsCode ? JSON.parse(clipboardData.getData(VS_CODE_FORMAT)) : {};
+ const language = vsCodeMeta.mode;
+
+ if (!content || (hasHTML && !hasVsCode) || (hasVsCode && language !== 'markdown')) {
+ return false;
+ }
+
+ this.editor.commands.pasteMarkdown(content);
+
+ return true;
+ },
+ },
+ }),
+ ];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/table.js b/app/assets/javascripts/content_editor/extensions/table.js
index 004bb8b815c..d7456ab4094 100644
--- a/app/assets/javascripts/content_editor/extensions/table.js
+++ b/app/assets/javascripts/content_editor/extensions/table.js
@@ -1,5 +1,6 @@
import { Table } from '@tiptap/extension-table';
import { debounce } from 'lodash';
+import { VARIANT_WARNING } from '~/flash';
import { __ } from '~/locale';
import { getMarkdownSource } from '../services/markdown_sourcemap';
import { shouldRenderHTMLTable } from '../services/serialization_helpers';
@@ -14,7 +15,7 @@ const onUpdate = debounce((editor) => {
message: __(
'The content editor may change the markdown formatting style of the document, which may not match your original markdown style.',
),
- variant: 'warning',
+ variant: VARIANT_WARNING,
});
alertShown = true;
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js
index a387322bff7..c5638da2daf 100644
--- a/app/assets/javascripts/content_editor/services/content_editor.js
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -1,17 +1,23 @@
-import eventHubFactory from '~/helpers/event_hub_factory';
+import { TextSelection } from 'prosemirror-state';
import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants';
+
/* eslint-disable no-underscore-dangle */
export class ContentEditor {
- constructor({ tiptapEditor, serializer }) {
+ constructor({ tiptapEditor, serializer, deserializer, eventHub }) {
this._tiptapEditor = tiptapEditor;
this._serializer = serializer;
- this._eventHub = eventHubFactory();
+ this._deserializer = deserializer;
+ this._eventHub = eventHub;
}
get tiptapEditor() {
return this._tiptapEditor;
}
+ get eventHub() {
+ return this._eventHub;
+ }
+
get empty() {
const doc = this.tiptapEditor?.state.doc;
@@ -23,39 +29,31 @@ export class ContentEditor {
this.tiptapEditor.destroy();
}
- once(type, handler) {
- this._eventHub.$once(type, handler);
- }
-
- on(type, handler) {
- this._eventHub.$on(type, handler);
- }
-
- emit(type, params = {}) {
- this._eventHub.$emit(type, params);
- }
-
- off(type, handler) {
- this._eventHub.$off(type, handler);
- }
-
disposeAllEvents() {
this._eventHub.dispose();
}
async setSerializedContent(serializedContent) {
- const { _tiptapEditor: editor, _serializer: serializer } = this;
+ const { _tiptapEditor: editor, _deserializer: deserializer, _eventHub: eventHub } = this;
+ const { doc, tr } = editor.state;
+ const selection = TextSelection.create(doc, 0, doc.content.size);
try {
- this._eventHub.$emit(LOADING_CONTENT_EVENT);
- const document = await serializer.deserialize({
+ eventHub.$emit(LOADING_CONTENT_EVENT);
+ const { document } = await deserializer.deserialize({
schema: editor.schema,
content: serializedContent,
});
- editor.commands.setContent(document);
- this._eventHub.$emit(LOADING_SUCCESS_EVENT);
+
+ if (document) {
+ tr.setSelection(selection)
+ .replaceSelectionWith(document, false)
+ .setMeta('preventUpdate', true);
+ editor.view.dispatch(tr);
+ }
+ eventHub.$emit(LOADING_SUCCESS_EVENT);
} catch (e) {
- this._eventHub.$emit(LOADING_ERROR_EVENT, e);
+ eventHub.$emit(LOADING_ERROR_EVENT, e);
throw e;
}
}
diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js
index f451357e211..d9d39a387d0 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -1,5 +1,6 @@
import { Editor } from '@tiptap/vue-2';
import { isFunction } from 'lodash';
+import eventHubFactory from '~/helpers/event_hub_factory';
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
import Attachment from '../extensions/attachment';
import Audio from '../extensions/audio';
@@ -38,6 +39,7 @@ import Loading from '../extensions/loading';
import MathInline from '../extensions/math_inline';
import OrderedList from '../extensions/ordered_list';
import Paragraph from '../extensions/paragraph';
+import PasteMarkdown from '../extensions/paste_markdown';
import Reference from '../extensions/reference';
import Strike from '../extensions/strike';
import Subscript from '../extensions/subscript';
@@ -54,6 +56,7 @@ import Video from '../extensions/video';
import WordBreak from '../extensions/word_break';
import { ContentEditor } from './content_editor';
import createMarkdownSerializer from './markdown_serializer';
+import createMarkdownDeserializer from './markdown_deserializer';
import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
@@ -78,8 +81,10 @@ export const createContentEditor = ({
throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
}
+ const eventHub = eventHubFactory();
+
const builtInContentEditorExtensions = [
- Attachment.configure({ uploadsPath, renderMarkdown }),
+ Attachment.configure({ uploadsPath, renderMarkdown, eventHub }),
Audio,
Blockquote,
Bold,
@@ -116,6 +121,7 @@ export const createContentEditor = ({
MathInline,
OrderedList,
Paragraph,
+ PasteMarkdown.configure({ renderMarkdown, eventHub }),
Reference,
Strike,
Subscript,
@@ -135,7 +141,8 @@ export const createContentEditor = ({
const allExtensions = [...builtInContentEditorExtensions, ...extensions];
const trackedExtensions = allExtensions.map(trackInputRulesAndShortcuts);
const tiptapEditor = createTiptapEditor({ extensions: trackedExtensions, ...tiptapOptions });
- const serializer = createMarkdownSerializer({ render: renderMarkdown, serializerConfig });
+ const serializer = createMarkdownSerializer({ serializerConfig });
+ const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
- return new ContentEditor({ tiptapEditor, serializer });
+ return new ContentEditor({ tiptapEditor, serializer, eventHub, deserializer });
};
diff --git a/app/assets/javascripts/content_editor/services/markdown_deserializer.js b/app/assets/javascripts/content_editor/services/markdown_deserializer.js
new file mode 100644
index 00000000000..cd4863d8eac
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/markdown_deserializer.js
@@ -0,0 +1,33 @@
+import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
+
+export default ({ render }) => {
+ /**
+ * Converts a Markdown string into a ProseMirror JSONDocument based
+ * on a ProseMirror schema.
+ *
+ * @param {Object} options — The schema and content for deserialization
+ * @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines
+ * the types of content supported in the document
+ * @param {String} params.content An arbitrary markdown string
+ *
+ * @returns An object with the following properties:
+ * - document: A ProseMirror document object generated from the deserialized Markdown
+ * - dom: The Markdown Deserializer renders Markdown as HTML to generate the ProseMirror
+ * document. The dom property contains the HTML generated from the Markdown Source.
+ */
+ return {
+ deserialize: async ({ schema, content }) => {
+ const html = await render(content);
+
+ if (!html) return {};
+
+ const parser = new DOMParser();
+ const { body } = parser.parseFromString(html, 'text/html');
+
+ // append original source as a comment that nodes can access
+ body.append(document.createComment(content));
+
+ return { document: ProseMirrorDOMParser.fromSchema(schema).parse(body), dom: body };
+ },
+ };
+};
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index 925b411e51c..eaaf69c3068 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -1,4 +1,3 @@
-import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
import {
MarkdownSerializer as ProseMirrorMarkdownSerializer,
defaultMarkdownSerializer,
@@ -237,31 +236,7 @@ const defaultSerializerConfig = {
* that parses the Markdown and converts it into HTML.
* @returns a markdown serializer
*/
-export default ({ render = () => null, serializerConfig = {} } = {}) => ({
- /**
- * Converts a Markdown string into a ProseMirror JSONDocument based
- * on a ProseMirror schema.
- * @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines
- * the types of content supported in the document
- * @param {String} params.content An arbitrary markdown string
- * @returns A ProseMirror JSONDocument
- */
- deserialize: async ({ schema, content }) => {
- const html = await render(content);
-
- if (!html) return null;
-
- const parser = new DOMParser();
- const { body } = parser.parseFromString(html, 'text/html');
-
- // append original source as a comment that nodes can access
- body.append(document.createComment(content));
-
- const state = ProseMirrorDOMParser.fromSchema(schema).parse(body);
-
- return state.toJSON();
- },
-
+export default ({ serializerConfig = {} } = {}) => ({
/**
* Converts a ProseMirror JSONDocument based
* on a ProseMirror schema into Markdown
diff --git a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
index a1199589c9b..4285e04bbab 100644
--- a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
+++ b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
@@ -1,7 +1,9 @@
-const getFullSource = (element) => {
+import { isString } from 'lodash';
+
+export const getFullSource = (element) => {
const commentNode = element.ownerDocument.body.lastChild;
- if (commentNode.nodeName === '#comment') {
+ if (commentNode?.nodeName === '#comment' && isString(commentNode.textContent)) {
return commentNode.textContent.split('\n');
}
diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js
index 4d5a54c0347..5fdd294aa96 100644
--- a/app/assets/javascripts/content_editor/services/serialization_helpers.js
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -259,11 +259,16 @@ export function renderContent(state, node, forceRenderInline) {
}
}
-export function renderHTMLNode(tagName, forceRenderInline = false) {
+export function renderHTMLNode(tagName, forceRenderContentInline = false) {
return (state, node) => {
renderTagOpen(state, tagName, node.attrs);
- renderContent(state, node, forceRenderInline);
+ renderContent(state, node, forceRenderContentInline);
renderTagClose(state, tagName, false);
+
+ if (forceRenderContentInline) {
+ state.closeBlock(node);
+ state.flushClose();
+ }
};
}
diff --git a/app/assets/javascripts/content_editor/services/upload_helpers.js b/app/assets/javascripts/content_editor/services/upload_helpers.js
index f5bf2742748..1abecb8f414 100644
--- a/app/assets/javascripts/content_editor/services/upload_helpers.js
+++ b/app/assets/javascripts/content_editor/services/upload_helpers.js
@@ -1,3 +1,4 @@
+import { VARIANT_DANGER } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import { extractFilename, readFileAsDataURL } from './utils';
@@ -49,7 +50,7 @@ export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => {
return extractAttachmentLinkUrl(rendered);
};
-const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown }) => {
+const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
const encodedSrc = await readFileAsDataURL(file);
const { view } = editor;
@@ -72,14 +73,14 @@ const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown }) => {
);
} catch (e) {
editor.commands.deleteRange({ from: position, to: position + 1 });
- editor.emit('alert', {
+ eventHub.$emit('alert', {
message: __('An error occurred while uploading the image. Please try again.'),
- variant: 'danger',
+ variant: VARIANT_DANGER,
});
}
};
-const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) => {
+const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
await Promise.resolve();
const { view } = editor;
@@ -103,23 +104,23 @@ const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) =
);
} catch (e) {
editor.commands.deleteRange({ from, to: from + 1 });
- editor.emit('alert', {
+ eventHub.$emit('alert', {
message: __('An error occurred while uploading the file. Please try again.'),
- variant: 'danger',
+ variant: VARIANT_DANGER,
});
}
};
-export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown }) => {
+export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
if (!file) return false;
if (acceptedMimes.image.includes(file?.type)) {
- uploadImage({ editor, file, uploadsPath, renderMarkdown });
+ uploadImage({ editor, file, uploadsPath, renderMarkdown, eventHub });
return true;
}
- uploadAttachment({ editor, file, uploadsPath, renderMarkdown });
+ uploadAttachment({ editor, file, uploadsPath, renderMarkdown, eventHub });
return true;
};
diff --git a/app/assets/javascripts/contributors/stores/getters.js b/app/assets/javascripts/contributors/stores/getters.js
index 45b569066f8..79f5c701fb8 100644
--- a/app/assets/javascripts/contributors/stores/getters.js
+++ b/app/assets/javascripts/contributors/stores/getters.js
@@ -7,10 +7,11 @@ export const parsedData = (state) => {
state.chartData.forEach(({ date, author_name, author_email }) => {
total[date] = total[date] ? total[date] + 1 : 1;
- const authorData = byAuthorEmail[author_email];
+ const normalizedEmail = author_email.toLowerCase();
+ const authorData = byAuthorEmail[normalizedEmail];
if (!authorData) {
- byAuthorEmail[author_email] = {
+ byAuthorEmail[normalizedEmail] = {
name: author_name,
commits: 1,
dates: {
diff --git a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue
deleted file mode 100644
index 4c44aac4e2a..00000000000
--- a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue
+++ /dev/null
@@ -1,33 +0,0 @@
-<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-
-export default {
- components: {
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- count: {
- type: Number,
- required: true,
- },
- },
-};
-</script>
-<template>
- <span v-if="count === 50" class="events-info float-right">
- <gl-icon
- v-gl-tooltip="{
- title: n__(
- 'Limited to showing %d event at most',
- 'Limited to showing %d events at most',
- 50,
- ),
- }"
- name="warning"
- />
- {{ n__('Showing %d event', 'Showing %d events', 50) }}
- </span>
-</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/cycle_analytics/components/stage_table.vue
index ea5a1291a17..6a45969fd1a 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_table.vue
+++ b/app/assets/javascripts/cycle_analytics/components/stage_table.vue
@@ -18,7 +18,7 @@ import {
PAGINATION_SORT_DIRECTION_ASC,
PAGINATION_SORT_DIRECTION_DESC,
} from '../constants';
-import TotalTime from './total_time_component.vue';
+import TotalTime from './total_time.vue';
const DEFAULT_WORKFLOW_TITLE_PROPERTIES = {
thClass: 'gl-w-half',
diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.vue b/app/assets/javascripts/cycle_analytics/components/total_time.vue
index a5a90a56974..a5a90a56974 100644
--- a/app/assets/javascripts/cycle_analytics/components/total_time_component.vue
+++ b/app/assets/javascripts/cycle_analytics/components/total_time.vue
diff --git a/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue b/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue
index 64461797c46..66bccf19496 100644
--- a/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue
+++ b/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue
@@ -1,16 +1,28 @@
<script>
+import { GlIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui';
+import { s__ } from '~/locale';
import DateRange from '~/analytics/shared/components/daterange.vue';
import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue';
import { DATE_RANGE_LIMIT, PROJECTS_PER_PAGE } from '~/analytics/shared/constants';
import FilterBar from './filter_bar.vue';
+export const AGGREGATION_TOGGLE_LABEL = s__('CycleAnalytics|Filter by stop date');
+export const AGGREGATION_DESCRIPTION = s__(
+ 'CycleAnalytics|When enabled, the results show items with a stop event within the date range. When disabled, the results show items with a start event within the date range.',
+);
+
export default {
name: 'ValueStreamFilters',
components: {
+ GlIcon,
+ GlToggle,
DateRange,
ProjectsDropdownFilter,
FilterBar,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
selectedProjects: {
type: Array,
@@ -45,6 +57,21 @@ export default {
required: false,
default: null,
},
+ canToggleAggregation: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isAggregationEnabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isUpdatingAggregationData: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
projectsQueryParams() {
@@ -54,8 +81,19 @@ export default {
};
},
},
+ methods: {
+ onUpdateAggregation(ev) {
+ if (!this.isUpdatingAggregationData) {
+ this.$emit('toggleAggregation', ev);
+ }
+ },
+ },
multiProjectSelect: true,
maxDateRange: DATE_RANGE_LIMIT,
+ i18n: {
+ AGGREGATION_TOGGLE_LABEL,
+ AGGREGATION_DESCRIPTION,
+ },
};
</script>
<template>
@@ -84,7 +122,28 @@ export default {
@selected="$emit('selectProject', $event)"
/>
</div>
- <div>
+ <div class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row">
+ <div
+ v-if="canToggleAggregation"
+ class="gl-display-flex gl-text-align-center gl-my-2 gl-lg-mt-0 gl-lg-mb-0 gl-mr-5"
+ >
+ <gl-toggle
+ class="gl-flex-direction-row"
+ :value="isAggregationEnabled"
+ :label="$options.i18n.AGGREGATION_TOGGLE_LABEL"
+ :disabled="isUpdatingAggregationData"
+ label-position="left"
+ @change="onUpdateAggregation"
+ >
+ <template #label>
+ {{ $options.i18n.AGGREGATION_TOGGLE_LABEL }}&nbsp;<gl-icon
+ v-gl-tooltip.hover
+ :title="$options.i18n.AGGREGATION_DESCRIPTION"
+ name="information-o"
+ />
+ </template>
+ </gl-toggle>
+ </div>
<date-range
v-if="hasDateRangeFilter"
:start-date="startDate"
diff --git a/app/assets/javascripts/deploy_tokens/components/revoke_button.vue b/app/assets/javascripts/deploy_tokens/components/revoke_button.vue
index fdf8b7796bf..7879357a042 100644
--- a/app/assets/javascripts/deploy_tokens/components/revoke_button.vue
+++ b/app/assets/javascripts/deploy_tokens/components/revoke_button.vue
@@ -17,9 +17,6 @@ export default {
revokePath: {
default: '',
},
- buttonClass: {
- default: '',
- },
},
computed: {
modalId() {
@@ -38,10 +35,9 @@ export default {
<div>
<gl-button
v-gl-modal="modalId"
- :class="buttonClass"
category="primary"
variant="danger"
- class="float-right"
+ class="gl-float-right"
data-testid="revoke-button"
>{{ s__('DeployTokens|Revoke') }}</gl-button
>
diff --git a/app/assets/javascripts/deploy_tokens/init_revoke_button.js b/app/assets/javascripts/deploy_tokens/init_revoke_button.js
index 20187150a60..bc3f3c9ddf4 100644
--- a/app/assets/javascripts/deploy_tokens/init_revoke_button.js
+++ b/app/assets/javascripts/deploy_tokens/init_revoke_button.js
@@ -9,14 +9,13 @@ export default () => {
}
return containers.forEach((el) => {
- const { token, revokePath, buttonClass } = el.dataset;
+ const { token, revokePath } = el.dataset;
return new Vue({
el,
provide: {
token: JSON.parse(token),
revokePath,
- buttonClass,
},
render(h) {
return h(RevokeButton);
diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js
index 82bbbe891e2..0707ae02872 100644
--- a/app/assets/javascripts/deprecated_notes.js
+++ b/app/assets/javascripts/deprecated_notes.js
@@ -1,6 +1,6 @@
/* eslint-disable no-restricted-properties, babel/camelcase,
no-unused-expressions, default-case,
-consistent-return, no-alert, no-param-reassign,
+consistent-return, no-param-reassign,
no-shadow, no-useless-escape,
class-methods-use-this */
@@ -17,9 +17,11 @@ import { escape, uniqueId } from 'lodash';
import Vue from 'vue';
import '~/lib/utils/jquery_at_who';
import AjaxCache from '~/lib/utils/ajax_cache';
+import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
import syntaxHighlight from '~/syntax_highlight';
import CommentTypeDropdown from '~/notes/components/comment_type_dropdown.vue';
import * as constants from '~/notes/constants';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import Autosave from './autosave';
import loadAwardsHandler from './awards_handler';
import createFlash from './flash';
@@ -243,7 +245,7 @@ export default class Notes {
});
}
- keydownNoteText(e) {
+ async keydownNoteText(e) {
let discussionNoteForm;
let editNote;
let myLastNote;
@@ -276,9 +278,11 @@ export default class Notes {
discussionNoteForm = $textarea.closest('.js-discussion-note-form');
if (discussionNoteForm.length) {
if ($textarea.val() !== '') {
- if (!window.confirm(__('Your comment will be discarded.'))) {
- return;
- }
+ const confirmed = await confirmAction(__('Your comment will be discarded.'), {
+ primaryBtnVariant: 'danger',
+ primaryBtnText: __('Discard'),
+ });
+ if (!confirmed) return;
}
this.removeDiscussionNoteForm(discussionNoteForm);
return;
@@ -288,9 +292,14 @@ export default class Notes {
originalText = $textarea.closest('form').data('originalNote');
newText = $textarea.val();
if (originalText !== newText) {
- if (!window.confirm(__('Are you sure you want to discard this comment?'))) {
- return;
- }
+ const confirmed = await confirmAction(
+ __('Are you sure you want to discard this comment?'),
+ {
+ primaryBtnVariant: 'danger',
+ primaryBtnText: __('Discard'),
+ },
+ );
+ if (!confirmed) return;
}
return this.removeNoteEditForm(editNote);
}
@@ -1753,9 +1762,11 @@ export default class Notes {
// Show updated comment content temporarily
$noteBodyText.html(formContent);
$editingNote.removeClass('is-editing fade-in-full').addClass('being-posted fade-in-half');
- $editingNote
- .find('.note-headline-meta a')
- .html('<span class="spinner align-text-bottom"></span>');
+
+ const $timeAgo = $editingNote.find('.note-headline-meta a');
+
+ $timeAgo.empty();
+ $timeAgo.append(loadingIconForLegacyJS({ inline: true, size: 'sm' }));
// Make request to update comment on server
axios
diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js
index 14d6e2db09d..a12829f8420 100644
--- a/app/assets/javascripts/diff.js
+++ b/app/assets/javascripts/diff.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { merge } from 'lodash';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
@@ -41,6 +42,8 @@ export default class Diff {
}
this.openAnchoredDiff();
+
+ this.prepareRenderedDiff();
}
handleClickUnfold(e) {
@@ -150,4 +153,43 @@ export default class Diff {
.addClass('hll');
}
}
+
+ prepareRenderedDiff() {
+ const $elements = $('[data-diff-toggle-entity]');
+
+ if ($elements.length === 0) return;
+
+ const diff = this;
+
+ const elements = $elements.toArray().map(this.formatElementToObject).reduce(merge);
+
+ Object.values(elements).forEach((e) => {
+ e.toShowBtn.onclick = () => diff.showOneHideAnother('rendered', e); // eslint-disable-line no-param-reassign
+ e.toHideBtn.onclick = () => diff.showOneHideAnother('raw', e); // eslint-disable-line no-param-reassign
+
+ diff.showOneHideAnother('rendered', e);
+ });
+ }
+
+ formatElementToObject = (element) => {
+ const key = element.attributes['data-file-hash'].value;
+ const name = element.attributes['data-diff-toggle-entity'].value;
+
+ return { [key]: { [name]: element } };
+ };
+
+ showOneHideAnother = (mode, elements) => {
+ let { toShowBtn, toHideBtn, toShow, toHide } = elements;
+
+ if (mode === 'raw') {
+ [toShowBtn, toHideBtn] = [toHideBtn, toShowBtn];
+ [toShow, toHide] = [toHide, toShow];
+ }
+
+ toShowBtn.classList.add('selected');
+ toHideBtn.classList.remove('selected');
+
+ toHide.classList.add('hidden');
+ toShow.classList.remove('hidden');
+ };
}
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index df7cf83b3f0..ba10f6deb29 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -72,8 +72,6 @@ export default {
return this.author.id ? this.author.id : '';
},
authorUrl() {
- // name: 'mailto:' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives
- // eslint-disable-next-line @gitlab/require-i18n-strings
return this.author.web_url || `mailto:${this.commit.author_email}`;
},
authorAvatar() {
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
index 858d9e221ae..7ed5713ebfa 100644
--- a/app/assets/javascripts/diffs/components/diff_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -163,8 +163,8 @@ export default {
v-if="diffFile.discussions.length"
class="diff-file-discussions"
:discussions="diffFile.discussions"
- :should-collapse-discussions="true"
- :render-avatar-badge="true"
+ should-collapse-discussions
+ render-avatar-badge
/>
<diff-file-drafts :file-hash="diffFileHash" class="diff-file-discussions" />
<note-form
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 3cf1f69b08c..495c87a695c 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -431,7 +431,7 @@ export default {
class="js-ide-edit-blob"
data-qa-selector="edit_in_ide_button"
>
- {{ __('Edit in Web IDE') }}
+ {{ __('Open in Web IDE') }}
</gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue
index 333bf1b356c..f46b0a538f1 100644
--- a/app/assets/javascripts/diffs/components/diff_view.vue
+++ b/app/assets/javascripts/diffs/components/diff_view.vue
@@ -1,4 +1,5 @@
<script>
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { mapGetters, mapState, mapActions } from 'vuex';
import { IdState } from 'vendor/vue-virtual-scroller';
import DraftNote from '~/batch_comments/components/draft_note.vue';
@@ -19,6 +20,9 @@ export default {
DiffCommentCell,
DraftNote,
},
+ directives: {
+ SafeHtml,
+ },
mixins: [
draftCommentsMixin,
glFeatureFlagsMixin(),
@@ -173,15 +177,17 @@ export default {
<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
+ v-safe-html="line.left.rich_text"
+ class="diff-td line_content left-side gl-white-space-normal!"
+ ></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
+ v-safe-html="line.left.rich_text"
+ class="diff-td line_content right-side gl-white-space-normal!"
+ ></div>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/diffs/components/hidden_files_warning.vue b/app/assets/javascripts/diffs/components/hidden_files_warning.vue
index baf7471582a..b9962682848 100644
--- a/app/assets/javascripts/diffs/components/hidden_files_warning.vue
+++ b/app/assets/javascripts/diffs/components/hidden_files_warning.vue
@@ -1,6 +1,19 @@
<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
+import { GlAlert, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export const i18n = {
+ title: __('Too many changes to show.'),
+ plainDiff: __('Plain diff'),
+ emailPatch: __('Email patch'),
+};
+
export default {
+ i18n,
+ components: {
+ GlAlert,
+ GlSprintf,
+ },
props: {
total: {
type: String,
@@ -23,17 +36,28 @@ export default {
</script>
<template>
- <div class="alert alert-warning">
- <h4>
- {{ __('Too many changes to show.') }}
- <div class="float-right">
- <a :href="plainDiffPath" class="btn btn-sm"> {{ __('Plain diff') }} </a>
- <a :href="emailPatchPath" class="btn btn-sm"> {{ __('Email patch') }} </a>
- </div>
- </h4>
- <p>
- To preserve performance only <strong> {{ visible }} of {{ total }} </strong> files are
- displayed.
- </p>
- </div>
+ <gl-alert
+ variant="warning"
+ :title="$options.i18n.title"
+ :primary-button-text="$options.i18n.plainDiff"
+ :primary-button-link="plainDiffPath"
+ :secondary-button-text="$options.i18n.emailPatch"
+ :secondary-button-link="emailPatchPath"
+ :dismissible="false"
+ >
+ <gl-sprintf
+ :message="
+ sprintf(
+ __(
+ 'To preserve performance only %{strongStart}%{visible} of %{total}%{strongEnd} files are displayed.',
+ ),
+ { visible, total },
+ )
+ "
+ >
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
</template>
diff --git a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js
index 65ffd42fa27..734407dec45 100644
--- a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js
+++ b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js
@@ -15,24 +15,19 @@ export const diffCompareDropdownTargetVersions = (state, getters) => {
// startVersion only exists if the user has selected a version other
// than "base" so if startVersion is null then base must be selected
- const defaultMergeRefForDiffs = window.gon?.features?.defaultMergeRefForDiffs || false;
const diffHeadParam = getParameterByName('diff_head');
- const diffHead = parseBoolean(diffHeadParam) || (!diffHeadParam && defaultMergeRefForDiffs);
- const isBaseSelected = !state.startVersion && !diffHead;
+ const diffHead = parseBoolean(diffHeadParam) || !diffHeadParam;
+ const isBaseSelected = !state.startVersion;
const isHeadSelected = !state.startVersion && diffHead;
let baseVersion = null;
- if (
- !defaultMergeRefForDiffs ||
- (defaultMergeRefForDiffs && !state.mergeRequestDiff.head_version_path)
- ) {
+ if (!state.mergeRequestDiff.head_version_path) {
baseVersion = {
versionName: state.targetBranchName,
version_index: DIFF_COMPARE_BASE_VERSION_INDEX,
href: state.mergeRequestDiff.base_version_path,
isBase: true,
- selected:
- isBaseSelected || (defaultMergeRefForDiffs && !state.mergeRequestDiff.head_version_path),
+ selected: isBaseSelected,
};
}
diff --git a/app/assets/javascripts/dirty_submit/dirty_submit_form.js b/app/assets/javascripts/dirty_submit/dirty_submit_form.js
index db13daf0799..83dd4b0a124 100644
--- a/app/assets/javascripts/dirty_submit/dirty_submit_form.js
+++ b/app/assets/javascripts/dirty_submit/dirty_submit_form.js
@@ -1,11 +1,13 @@
import $ from 'jquery';
import { memoize, throttle } from 'lodash';
+import createEventHub from '~/helpers/event_hub_factory';
class DirtySubmitForm {
constructor(form) {
this.form = form;
this.dirtyInputs = [];
this.isDisabled = true;
+ this.events = createEventHub();
this.init();
}
@@ -36,11 +38,21 @@ class DirtySubmitForm {
this.form.addEventListener('submit', (event) => this.formSubmit(event));
}
+ addInputsListener(callback) {
+ this.events.$on('input', callback);
+ }
+
+ removeInputsListener(callback) {
+ this.events.$off('input', callback);
+ }
+
updateDirtyInput(event) {
const { target } = event;
if (!target.dataset.isDirtySubmitInput) return;
+ this.events.$emit('input', event);
+
this.updateDirtyInputs(target);
this.toggleSubmission();
}
diff --git a/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js
index 0290bb84b5f..b41eae88c54 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js
@@ -14,7 +14,7 @@ export class CiSchemaExtension {
// to fetch schema files, hence the `gon.gitlab_url`
// reference. This prevents error:
// "Failed to execute 'fetch' on 'WorkerGlobalScope'"
- const absoluteSchemaUrl = gon.gitlab_url + ciSchemaPath;
+ const absoluteSchemaUrl = new URL(ciSchemaPath, gon.gitlab_url).href;
const modelFileName = instance.getModel().uri.path.split('/').pop();
registerSchema({
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
index 4d9fe6ff851..1c56327c03c 100644
--- a/app/assets/javascripts/editor/schema/ci.json
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -423,37 +423,34 @@
"description": "Defines secrets to be injected as environment variables",
"additionalProperties": {
"type": "object",
- "additionalProperties": {
- "type": "object",
- "description": "Environment variable name",
- "properties": {
- "vault": {
- "oneOf": [
- {
- "type": "string",
- "description": "The secret to be fetched from Vault (e.g. 'production/db/password@ops' translates to secret 'ops/data/production/db', field `password`)"
- },
- {
- "type": "object",
- "properties": {
- "engine": {
- "type": "object",
- "properties": {
- "name": { "type": "string" },
- "path": { "type": "string" }
- },
- "required": ["name", "path"]
+ "description": "Environment variable name",
+ "properties": {
+ "vault": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "The secret to be fetched from Vault (e.g. 'production/db/password@ops' translates to secret 'ops/data/production/db', field `password`)"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "engine": {
+ "type": "object",
+ "properties": {
+ "name": { "type": "string" },
+ "path": { "type": "string" }
},
- "path": { "type": "string" },
- "field": { "type": "string" }
+ "required": ["name", "path"]
},
- "required": ["engine", "path", "field"]
- }
- ]
- }
- },
- "required": ["vault"]
- }
+ "path": { "type": "string" },
+ "field": { "type": "string" }
+ },
+ "required": ["engine", "path", "field"]
+ }
+ ]
+ }
+ },
+ "required": ["vault"]
}
},
"before_script": {
@@ -1250,7 +1247,7 @@
"oneOf": [
{
"type": "object",
- "description": "Trigger a multi-project pipeline. Read more: https://docs.gitlab.com/ee/ci/yaml/README.html#simple-trigger-syntax-for-multi-project-pipelines",
+ "description": "Trigger a multi-project pipeline. Read more: https://docs.gitlab.com/ee/ci/pipelines/multi_project_pipelines.html#specify-a-downstream-pipeline-branch",
"additionalProperties": false,
"properties": {
"project": {
@@ -1266,6 +1263,23 @@
"description": "You can mirror the pipeline status from the triggered pipeline to the source bridge job by using strategy: depend",
"type": "string",
"enum": ["depend"]
+ },
+ "forward": {
+ "description": "Specify what to forward to the downstream pipeline.",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "yaml_variables": {
+ "type": "boolean",
+ "description": "Variables defined in the trigger job are passed to downstream pipelines.",
+ "default": true
+ },
+ "pipeline_variables": {
+ "type": "boolean",
+ "description": "Variables added for manual pipeline runs are passed to downstream pipelines.",
+ "default": false
+ }
+ }
}
},
"required": ["project"],
@@ -1275,7 +1289,7 @@
},
{
"type": "object",
- "description": "Trigger a child pipeline. Read more: https://docs.gitlab.com/ee/ci/yaml/README.html#trigger-syntax-for-child-pipeline",
+ "description": "Trigger a child pipeline. Read more: https://docs.gitlab.com/ee/ci/pipelines/parent_child_pipelines.html",
"additionalProperties": false,
"properties": {
"include": {
@@ -1365,11 +1379,28 @@
"description": "You can mirror the pipeline status from the triggered pipeline to the source bridge job by using strategy: depend",
"type": "string",
"enum": ["depend"]
+ },
+ "forward": {
+ "description": "Specify what to forward to the downstream pipeline.",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "yaml_variables": {
+ "type": "boolean",
+ "description": "Variables defined in the trigger job are passed to downstream pipelines.",
+ "default": true
+ },
+ "pipeline_variables": {
+ "type": "boolean",
+ "description": "Variables added for manual pipeline runs are passed to downstream pipelines.",
+ "default": false
+ }
+ }
}
}
},
{
- "description": "Path to the project, e.g. `group/project`, or `group/sub-group/project`.",
+ "description": "Path to the project, e.g. `group/project`, or `group/sub-group/project`. Read more: https://docs.gitlab.com/ee/ci/pipelines/multi_project_pipelines.html#define-multi-project-pipelines-in-your-gitlab-ciyml-file",
"type": "string",
"pattern": "\\S/\\S"
}
diff --git a/app/assets/javascripts/emoji/components/picker.vue b/app/assets/javascripts/emoji/components/picker.vue
index 686b5ffff9e..840297b870a 100644
--- a/app/assets/javascripts/emoji/components/picker.vue
+++ b/app/assets/javascripts/emoji/components/picker.vue
@@ -108,6 +108,7 @@ export default {
class="gl-mx-5! gl-mb-2!"
autofocus
debounce="500"
+ :aria-label="__('Search for an emoji')"
@input="onSearchInput"
/>
<div
diff --git a/app/assets/javascripts/environments/components/commit.vue b/app/assets/javascripts/environments/components/commit.vue
index 54b94480685..8577bf629a3 100644
--- a/app/assets/javascripts/environments/components/commit.vue
+++ b/app/assets/javascripts/environments/components/commit.vue
@@ -22,7 +22,6 @@ export default {
return this.commit?.message;
},
commitAuthorPath() {
- // eslint-disable-next-line @gitlab/require-i18n-strings
return this.commit?.author?.path || `mailto:${escape(this.commit?.authorEmail)}`;
},
commitAuthorAvatar() {
diff --git a/app/assets/javascripts/environments/components/delete_environment_modal.vue b/app/assets/javascripts/environments/components/delete_environment_modal.vue
index d3d4c7d23d8..3173c2bd644 100644
--- a/app/assets/javascripts/environments/components/delete_environment_modal.vue
+++ b/app/assets/javascripts/environments/components/delete_environment_modal.vue
@@ -62,7 +62,8 @@ export default {
mutation: deleteEnvironmentMutation,
variables: { environment: this.environment },
})
- .then(([message]) => {
+ .then(({ data }) => {
+ const [message] = data?.deleteEvironment?.errors ?? [];
if (message) {
createFlash({ message });
}
diff --git a/app/assets/javascripts/environments/components/deployment.vue b/app/assets/javascripts/environments/components/deployment.vue
index f98edb6bb7d..19284b26d51 100644
--- a/app/assets/javascripts/environments/components/deployment.vue
+++ b/app/assets/javascripts/environments/components/deployment.vue
@@ -102,6 +102,9 @@ export default {
refPath() {
return this.ref?.refPath;
},
+ needsApproval() {
+ return this.deployment.pendingApprovalCount > 0;
+ },
},
methods: {
toggleCollapse() {
@@ -116,6 +119,7 @@ export default {
showDetails: __('Show details'),
hideDetails: __('Hide details'),
triggerer: s__('Deployment|Triggerer'),
+ needsApproval: s__('Deployment|Needs Approval'),
job: __('Job'),
api: __('API'),
branch: __('Branch'),
@@ -153,6 +157,9 @@ export default {
<div :class="$options.headerDetailsClasses">
<div :class="$options.deploymentStatusClasses">
<deployment-status-badge v-if="status" :status="status" />
+ <gl-badge v-if="needsApproval" variant="warning">
+ {{ $options.i18n.needsApproval }}
+ </gl-badge>
<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">
@@ -199,6 +206,7 @@ export default {
</gl-button>
</div>
<commit v-if="commit" :commit="commit" class="gl-mt-3" />
+ <div class="gl-mt-3"><slot name="approval"></slot></div>
<gl-collapse :visible="visible">
<div
class="gl-display-flex gl-md-align-items-center gl-mt-5 gl-flex-direction-column gl-md-flex-direction-row gl-pr-4 gl-md-pr-0"
diff --git a/app/assets/javascripts/environments/components/enable_review_app_modal.vue b/app/assets/javascripts/environments/components/enable_review_app_modal.vue
index b757c55bfdb..4d43ee156fb 100644
--- a/app/assets/javascripts/environments/components/enable_review_app_modal.vue
+++ b/app/assets/javascripts/environments/components/enable_review_app_modal.vue
@@ -1,5 +1,6 @@
<script>
import { GlLink, GlModal, GlSprintf } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
@@ -44,6 +45,11 @@ export default {
copyToClipboardText: s__('EnableReviewApp|Copy snippet text'),
title: s__('ReviewApp|Enable Review App'),
},
+ data() {
+ const modalInfoCopyId = uniqueId('enable-review-app-copy-string-');
+
+ return { modalInfoCopyId };
+ },
computed: {
modalInfoCopyStr() {
return `deploy_review:
@@ -99,14 +105,14 @@ export default {
</gl-sprintf>
</p>
<div class="gl-display-flex align-items-start">
- <pre class="gl-w-full" data-testid="enable-review-app-copy-string">
+ <pre :id="modalInfoCopyId" class="gl-w-full" data-testid="enable-review-app-copy-string">
{{ modalInfoCopyStr }} </pre
>
<modal-copy-button
:title="$options.modalInfo.copyToClipboardText"
- :text="$options.modalInfo.copyString"
:modal-id="modalId"
css-classes="border-0"
+ :target="`#${modalInfoCopyId}`"
/>
</div>
</div>
diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue
index 98c95507168..c7e024aadec 100644
--- a/app/assets/javascripts/environments/components/environment_actions.vue
+++ b/app/assets/javascripts/environments/components/environment_actions.vue
@@ -1,5 +1,6 @@
<script>
import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { formatTime } from '~/lib/utils/datetime_utility';
import { __, s__, sprintf } from '~/locale';
import eventHub from '../event_hub';
@@ -37,7 +38,7 @@ export default {
},
},
methods: {
- onClickAction(action) {
+ async onClickAction(action) {
if (action.scheduledAt) {
const confirmationMessage = sprintf(
s__(
@@ -45,9 +46,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/environments/components/new_environment_folder.vue b/app/assets/javascripts/environments/components/environment_folder.vue
index 0d3867a4d74..d5c6d26cfd0 100644
--- a/app/assets/javascripts/environments/components/new_environment_folder.vue
+++ b/app/assets/javascripts/environments/components/environment_folder.vue
@@ -1,7 +1,9 @@
<script>
import { GlButton, GlCollapse, GlIcon, GlBadge, GlLink } from '@gitlab/ui';
import { __, s__ } from '~/locale';
+import pollIntervalQuery from '../graphql/queries/poll_interval.query.graphql';
import folderQuery from '../graphql/queries/folder.query.graphql';
+import { ENVIRONMENT_COUNT_BY_SCOPE } from '../constants';
import EnvironmentItem from './new_environment_item.vue';
export default {
@@ -18,16 +20,26 @@ export default {
type: Object,
required: true,
},
+ scope: {
+ type: String,
+ required: true,
+ },
},
data() {
- return { visible: false };
+ return { visible: false, interval: undefined };
},
apollo: {
folder: {
query: folderQuery,
variables() {
- return { environment: this.nestedEnvironment.latest };
+ return { environment: this.nestedEnvironment.latest, scope: this.scope };
},
+ pollInterval() {
+ return this.interval;
+ },
+ },
+ interval: {
+ query: pollIntervalQuery,
},
},
i18n: {
@@ -45,7 +57,8 @@ export default {
return this.visible ? this.$options.i18n.collapse : this.$options.i18n.expand;
},
count() {
- return this.folder?.availableCount ?? 0;
+ const count = ENVIRONMENT_COUNT_BY_SCOPE[this.scope];
+ return this.folder?.[count] ?? 0;
},
folderClass() {
return { 'gl-font-weight-bold': this.visible };
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index acc16ecd874..c7008c03099 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -1,188 +1,272 @@
<script>
-import { GlBadge, GlButton, GlModalDirective, GlTab, GlTabs } from '@gitlab/ui';
-import createFlash from '~/flash';
-import { s__ } from '~/locale';
-import eventHub from '../event_hub';
-import environmentsMixin from '../mixins/environments_mixin';
-import EnvironmentsPaginationApiMixin from '../mixins/environments_pagination_api_mixin';
-import ConfirmRollbackModal from './confirm_rollback_modal.vue';
-import DeleteEnvironmentModal from './delete_environment_modal.vue';
-import emptyState from './empty_state.vue';
+import { GlBadge, GlPagination, GlTab, GlTabs } from '@gitlab/ui';
+import { s__, __, sprintf } from '~/locale';
+import { updateHistory, setUrlParams, queryToObject } from '~/lib/utils/url_utility';
+import environmentAppQuery from '../graphql/queries/environment_app.query.graphql';
+import pollIntervalQuery from '../graphql/queries/poll_interval.query.graphql';
+import pageInfoQuery from '../graphql/queries/page_info.query.graphql';
+import 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 { ENVIRONMENTS_SCOPE } from '../constants';
+import EnvironmentFolder from './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';
+import EmptyState from './empty_state.vue';
export default {
- i18n: {
- newEnvironmentButtonLabel: s__('Environments|New environment'),
- reviewAppButtonLabel: s__('Environments|Enable review app'),
- },
- modal: {
- id: 'enable-review-app-info',
- },
components: {
+ DeleteEnvironmentModal,
+ CanaryUpdateModal,
ConfirmRollbackModal,
- emptyState,
+ EmptyState,
+ EnvironmentFolder,
EnableReviewAppModal,
+ EnvironmentItem,
+ StopEnvironmentModal,
GlBadge,
- GlButton,
+ GlPagination,
GlTab,
GlTabs,
- StopEnvironmentModal,
- DeleteEnvironmentModal,
},
- directives: {
- 'gl-modal': GlModalDirective,
- },
- mixins: [EnvironmentsPaginationApiMixin, environmentsMixin],
- props: {
- endpoint: {
- type: String,
- required: true,
+ apollo: {
+ environmentApp: {
+ query: environmentAppQuery,
+ variables() {
+ return {
+ scope: this.scope,
+ page: this.page ?? 1,
+ };
+ },
+ pollInterval() {
+ return this.interval;
+ },
+ },
+ interval: {
+ query: pollIntervalQuery,
+ },
+ pageInfo: {
+ query: pageInfoQuery,
+ },
+ environmentToDelete: {
+ query: environmentToDeleteQuery,
},
- canCreateEnvironment: {
- type: Boolean,
- required: true,
+ environmentToRollback: {
+ query: environmentToRollbackQuery,
},
- newEnvironmentPath: {
- type: String,
- required: true,
+ environmentToStop: {
+ query: environmentToStopQuery,
},
- helpPagePath: {
- type: String,
- required: true,
+ environmentToChangeCanary: {
+ query: environmentToChangeCanaryQuery,
+ },
+ weight: {
+ query: environmentToChangeCanaryQuery,
},
},
-
- created() {
- eventHub.$on('toggleFolder', this.toggleFolder);
- eventHub.$on('toggleDeployBoard', this.toggleDeployBoard);
+ inject: ['newEnvironmentPath', 'canCreateEnvironment', 'helpPagePath'],
+ i18n: {
+ newEnvironmentButtonLabel: s__('Environments|New environment'),
+ reviewAppButtonLabel: s__('Environments|Enable review app'),
+ available: __('Available'),
+ stopped: __('Stopped'),
+ prevPage: __('Go to previous page'),
+ nextPage: __('Go to next page'),
+ next: __('Next'),
+ prev: __('Prev'),
+ goto: (page) => sprintf(__('Go to page %{page}'), { page }),
},
-
- beforeDestroy() {
- // eslint-disable-next-line @gitlab/no-global-event-off
- eventHub.$off('toggleFolder');
- // eslint-disable-next-line @gitlab/no-global-event-off
- eventHub.$off('toggleDeployBoard');
+ modalId: 'enable-review-app-info',
+ data() {
+ const { page = '1', scope } = queryToObject(window.location.search);
+ return {
+ interval: undefined,
+ isReviewAppModalVisible: false,
+ page: parseInt(page, 10),
+ pageInfo: {},
+ scope: Object.values(ENVIRONMENTS_SCOPE).includes(scope)
+ ? scope
+ : ENVIRONMENTS_SCOPE.AVAILABLE,
+ environmentToDelete: {},
+ environmentToRollback: {},
+ environmentToStop: {},
+ environmentToChangeCanary: {},
+ weight: 0,
+ };
},
-
- methods: {
- toggleDeployBoard(model) {
- this.store.toggleDeployBoard(model.id);
+ computed: {
+ canSetupReviewApp() {
+ return this.environmentApp?.reviewApp?.canSetupReviewApp;
},
- toggleFolder(folder) {
- this.store.toggleFolder(folder);
-
- if (!folder.isOpen) {
- this.fetchChildEnvironments(folder, true);
- }
+ folders() {
+ return this.environmentApp?.environments?.filter((e) => e.size > 1) ?? [];
},
-
- fetchChildEnvironments(folder, showLoader = false) {
- this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', showLoader);
-
- this.service
- .getFolderContent(folder.folder_path, folder.state)
- .then((response) => this.store.setfolderContent(folder, response.data.environments))
- .then(() => this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', false))
- .catch(() => {
- createFlash({
- message: s__('Environments|An error occurred while fetching the environments.'),
- });
- this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', false);
- });
+ environments() {
+ return this.environmentApp?.environments?.filter((e) => e.size === 1) ?? [];
},
+ hasEnvironments() {
+ return this.environments.length > 0 || this.folders.length > 0;
+ },
+ availableCount() {
+ return this.environmentApp?.availableCount;
+ },
+ addEnvironment() {
+ if (!this.canCreateEnvironment) {
+ return null;
+ }
- successCallback(resp) {
- this.saveData(resp);
-
- // We need to verify if any folder is open to also update it
- const openFolders = this.store.getOpenFolders();
- if (openFolders.length) {
- openFolders.forEach((folder) => this.fetchChildEnvironments(folder));
+ return {
+ text: this.$options.i18n.newEnvironmentButtonLabel,
+ attributes: {
+ href: this.newEnvironmentPath,
+ category: 'primary',
+ variant: 'confirm',
+ },
+ };
+ },
+ openReviewAppModal() {
+ if (!this.canSetupReviewApp) {
+ return null;
}
+
+ return {
+ text: this.$options.i18n.reviewAppButtonLabel,
+ attributes: {
+ category: 'secondary',
+ variant: 'confirm',
+ },
+ };
+ },
+ stoppedCount() {
+ return this.environmentApp?.stoppedCount;
},
+ totalItems() {
+ return this.pageInfo?.total;
+ },
+ itemsPerPage() {
+ return this.pageInfo?.perPage;
+ },
+ },
+ mounted() {
+ window.addEventListener('popstate', this.syncPageFromQueryParams);
},
+ destroyed() {
+ window.removeEventListener('popstate', this.syncPageFromQueryParams);
+ this.$apollo.queries.environmentApp.stopPolling();
+ },
+ methods: {
+ showReviewAppModal() {
+ this.isReviewAppModalVisible = true;
+ },
+ setScope(scope) {
+ this.scope = scope;
+ this.moveToPage(1);
+ },
+ movePage(direction) {
+ this.moveToPage(this.pageInfo[`${direction}Page`]);
+ },
+ moveToPage(page) {
+ this.page = page;
+ updateHistory({
+ url: setUrlParams({ page: this.page }),
+ title: document.title,
+ });
+ this.resetPolling();
+ },
+ syncPageFromQueryParams() {
+ const { page = '1' } = queryToObject(window.location.search);
+ this.page = parseInt(page, 10);
+ },
+ resetPolling() {
+ this.$apollo.queries.environmentApp.stopPolling();
+ this.$apollo.queries.environmentApp.refetch();
+ this.$nextTick(() => {
+ if (this.interval) {
+ this.$apollo.queries.environmentApp.startPolling(this.interval);
+ }
+ });
+ },
+ },
+ ENVIRONMENTS_SCOPE,
};
</script>
<template>
- <div class="environments-section">
- <stop-environment-modal :environment="environmentInStopModal" />
- <delete-environment-modal :environment="environmentInDeleteModal" />
- <confirm-rollback-modal :environment="environmentInRollbackModal" />
-
- <div class="gl-w-full">
- <div class="gl-display-flex gl-flex-direction-column gl-mt-3 gl-md-display-none!">
- <gl-button
- v-if="state.reviewAppDetails.can_setup_review_app"
- v-gl-modal="$options.modal.id"
- data-testid="enable-review-app"
- variant="info"
- category="secondary"
- type="button"
- class="gl-mb-3 gl-flex-grow-1"
- >{{ $options.i18n.reviewAppButtonLabel }}</gl-button
- >
- <gl-button
- v-if="canCreateEnvironment"
- :href="newEnvironmentPath"
- data-testid="new-environment"
- category="primary"
- variant="confirm"
- >{{ $options.i18n.newEnvironmentButtonLabel }}</gl-button
- >
- </div>
- <gl-tabs :value="activeTab" content-class="gl-display-none">
- <gl-tab
- v-for="(tab, idx) in tabs"
- :key="idx"
- :title-item-class="`js-environments-tab-${tab.scope}`"
- @click="onChangeTab(tab.scope)"
- >
- <template #title>
- <span>{{ tab.name }}</span>
- <gl-badge size="sm" class="gl-tab-counter-badge">{{ tab.count }}</gl-badge>
- </template>
- </gl-tab>
- <template #tabs-end>
- <div
- class="gl-display-none gl-md-display-flex gl-lg-align-items-center gl-lg-flex-direction-row gl-lg-flex-fill-1 gl-lg-justify-content-end gl-lg-mt-0"
- >
- <gl-button
- v-if="state.reviewAppDetails.can_setup_review_app"
- v-gl-modal="$options.modal.id"
- data-testid="enable-review-app"
- variant="info"
- category="secondary"
- type="button"
- class="gl-mb-3 gl-lg-mr-3 gl-lg-mb-0"
- >{{ $options.i18n.reviewAppButtonLabel }}</gl-button
- >
- <gl-button
- v-if="canCreateEnvironment"
- :href="newEnvironmentPath"
- data-testid="new-environment"
- category="primary"
- variant="confirm"
- >{{ $options.i18n.newEnvironmentButtonLabel }}</gl-button
- >
- </div>
+ <div>
+ <enable-review-app-modal
+ v-if="canSetupReviewApp"
+ v-model="isReviewAppModalVisible"
+ :modal-id="$options.modalId"
+ data-testid="enable-review-app-modal"
+ />
+ <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"
+ sync-active-tab-with-query-params
+ query-param-name="scope"
+ @primary="showReviewAppModal"
+ >
+ <gl-tab
+ :query-param-value="$options.ENVIRONMENTS_SCOPE.AVAILABLE"
+ @click="setScope($options.ENVIRONMENTS_SCOPE.AVAILABLE)"
+ >
+ <template #title>
+ <span>{{ $options.i18n.available }}</span>
+ <gl-badge size="sm" class="gl-tab-counter-badge">
+ {{ availableCount }}
+ </gl-badge>
</template>
- </gl-tabs>
- <container
- :is-loading="isLoading"
- :environments="state.environments"
- :pagination="state.paginationInformation"
- @onChangePage="onChangePage"
+ </gl-tab>
+ <gl-tab
+ :query-param-value="$options.ENVIRONMENTS_SCOPE.STOPPED"
+ @click="setScope($options.ENVIRONMENTS_SCOPE.STOPPED)"
>
- <template v-if="!isLoading && state.environments.length === 0" #empty-state>
- <empty-state :help-path="helpPagePath" />
+ <template #title>
+ <span>{{ $options.i18n.stopped }}</span>
+ <gl-badge size="sm" class="gl-tab-counter-badge">
+ {{ stoppedCount }}
+ </gl-badge>
</template>
- </container>
- <enable-review-app-modal
- v-if="state.reviewAppDetails.can_setup_review_app"
- :modal-id="$options.modal.id"
- data-testid="enable-review-app-modal"
+ </gl-tab>
+ </gl-tabs>
+ <template v-if="hasEnvironments">
+ <environment-folder
+ v-for="folder in folders"
+ :key="folder.name"
+ class="gl-mb-3"
+ :scope="scope"
+ :nested-environment="folder"
+ />
+ <environment-item
+ v-for="environment in environments"
+ :key="environment.name"
+ class="gl-mb-3 gl-border-gray-100 gl-border-1 gl-border-b-solid"
+ :environment="environment.latest"
+ @change="resetPolling"
/>
- </div>
+ </template>
+ <empty-state v-else :help-path="helpPagePath" />
+ <gl-pagination
+ align="center"
+ :total-items="totalItems"
+ :per-page="itemsPerPage"
+ :value="page"
+ :next="$options.i18n.next"
+ :prev="$options.i18n.prev"
+ :label-previous-page="$options.prevPage"
+ :label-next-page="$options.nextPage"
+ :label-page="$options.goto"
+ @next="movePage('next')"
+ @previous="movePage('previous')"
+ @input="moveToPage"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue
index 27a763fb9c4..f35fabccae7 100644
--- a/app/assets/javascripts/environments/components/new_environment_item.vue
+++ b/app/assets/javascripts/environments/components/new_environment_item.vue
@@ -40,6 +40,9 @@ export default {
Terminal,
TimeAgoTooltip,
Delete,
+ EnvironmentAlert: () => import('ee_component/environments/components/environment_alert.vue'),
+ EnvironmentApproval: () =>
+ import('ee_component/environments/components/environment_approval.vue'),
},
directives: {
GlTooltip,
@@ -97,6 +100,9 @@ export default {
hasDeployment() {
return Boolean(this.environment?.upcomingDeployment || this.environment?.lastDeployment);
},
+ hasOpenedAlert() {
+ return this.environment?.hasOpenedAlert;
+ },
actions() {
if (!this.lastDeployment) {
return [];
@@ -296,12 +302,20 @@ export default {
class="gl-pl-4"
/>
</div>
- <div v-if="upcomingDeployment" :class="$options.deploymentClasses">
+ <div
+ v-if="upcomingDeployment"
+ :class="$options.deploymentClasses"
+ data-testid="upcoming-deployment-content"
+ >
<deployment
:deployment="upcomingDeployment"
:class="{ 'gl-ml-7': inFolder }"
class="gl-pl-4"
- />
+ >
+ <template #approval>
+ <environment-approval :environment="environment" @change="$emit('change')" />
+ </template>
+ </deployment>
</div>
</template>
<div v-else :class="$options.deploymentClasses">
@@ -319,6 +333,9 @@ export default {
class="gl-pl-4"
/>
</div>
+ <div v-if="hasOpenedAlert" class="gl-bg-gray-10 gl-md-px-7">
+ <environment-alert :environment="environment" class="gl-pl-4 gl-py-5" />
+ </div>
</gl-collapse>
</div>
</template>
diff --git a/app/assets/javascripts/environments/components/new_environments_app.vue b/app/assets/javascripts/environments/components/new_environments_app.vue
deleted file mode 100644
index 3699f39b611..00000000000
--- a/app/assets/javascripts/environments/components/new_environments_app.vue
+++ /dev/null
@@ -1,252 +0,0 @@
-<script>
-import { GlBadge, GlPagination, GlTab, GlTabs } from '@gitlab/ui';
-import { s__, __, sprintf } from '~/locale';
-import { updateHistory, setUrlParams, queryToObject } from '~/lib/utils/url_utility';
-import environmentAppQuery from '../graphql/queries/environment_app.query.graphql';
-import pollIntervalQuery from '../graphql/queries/poll_interval.query.graphql';
-import pageInfoQuery from '../graphql/queries/page_info.query.graphql';
-import 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,
- EnvironmentItem,
- StopEnvironmentModal,
- GlBadge,
- GlPagination,
- GlTab,
- GlTabs,
- },
- apollo: {
- environmentApp: {
- query: environmentAppQuery,
- variables() {
- return {
- scope: this.scope,
- page: this.page ?? 1,
- };
- },
- pollInterval() {
- return this.interval;
- },
- },
- interval: {
- query: pollIntervalQuery,
- },
- pageInfo: {
- query: pageInfoQuery,
- },
- environmentToDelete: {
- query: environmentToDeleteQuery,
- },
- environmentToRollback: {
- query: environmentToRollbackQuery,
- },
- environmentToStop: {
- query: environmentToStopQuery,
- },
- environmentToChangeCanary: {
- query: environmentToChangeCanaryQuery,
- },
- weight: {
- query: environmentToChangeCanaryQuery,
- },
- },
- inject: ['newEnvironmentPath', 'canCreateEnvironment'],
- i18n: {
- newEnvironmentButtonLabel: s__('Environments|New environment'),
- reviewAppButtonLabel: s__('Environments|Enable review app'),
- available: __('Available'),
- stopped: __('Stopped'),
- prevPage: __('Go to previous page'),
- nextPage: __('Go to next page'),
- next: __('Next'),
- prev: __('Prev'),
- goto: (page) => sprintf(__('Go to page %{page}'), { page }),
- },
- modalId: 'enable-review-app-info',
- data() {
- const { page = '1', scope = 'available' } = queryToObject(window.location.search);
- return {
- interval: undefined,
- isReviewAppModalVisible: false,
- page: parseInt(page, 10),
- scope,
- environmentToDelete: {},
- environmentToRollback: {},
- environmentToStop: {},
- environmentToChangeCanary: {},
- weight: 0,
- };
- },
- computed: {
- canSetupReviewApp() {
- return this.environmentApp?.reviewApp?.canSetupReviewApp;
- },
- folders() {
- return this.environmentApp?.environments?.filter((e) => e.size > 1) ?? [];
- },
- environments() {
- return this.environmentApp?.environments?.filter((e) => e.size === 1) ?? [];
- },
- availableCount() {
- return this.environmentApp?.availableCount;
- },
- addEnvironment() {
- if (!this.canCreateEnvironment) {
- return null;
- }
-
- return {
- text: this.$options.i18n.newEnvironmentButtonLabel,
- attributes: {
- href: this.newEnvironmentPath,
- category: 'primary',
- variant: 'confirm',
- },
- };
- },
- openReviewAppModal() {
- if (!this.canSetupReviewApp) {
- return null;
- }
-
- return {
- text: this.$options.i18n.reviewAppButtonLabel,
- attributes: {
- category: 'secondary',
- variant: 'confirm',
- },
- };
- },
- stoppedCount() {
- return this.environmentApp?.stoppedCount;
- },
- totalItems() {
- return this.pageInfo?.total;
- },
- itemsPerPage() {
- return this.pageInfo?.perPage;
- },
- },
- mounted() {
- window.addEventListener('popstate', this.syncPageFromQueryParams);
- },
- destroyed() {
- window.removeEventListener('popstate', this.syncPageFromQueryParams);
- this.$apollo.queries.environmentApp.stopPolling();
- },
- methods: {
- showReviewAppModal() {
- this.isReviewAppModalVisible = true;
- },
- setScope(scope) {
- this.scope = scope;
- this.moveToPage(1);
- },
- movePage(direction) {
- this.moveToPage(this.pageInfo[`${direction}Page`]);
- },
- moveToPage(page) {
- this.page = page;
- updateHistory({
- url: setUrlParams({ page: this.page }),
- title: document.title,
- });
- this.resetPolling();
- },
- syncPageFromQueryParams() {
- const { page = '1' } = queryToObject(window.location.search);
- this.page = parseInt(page, 10);
- },
- resetPolling() {
- this.$apollo.queries.environmentApp.stopPolling();
- this.$nextTick(() => {
- if (this.interval) {
- this.$apollo.queries.environmentApp.startPolling(this.interval);
- } else {
- this.$apollo.queries.environmentApp.refetch({ scope: this.scope, page: this.page });
- }
- });
- },
- },
-};
-</script>
-<template>
- <div>
- <enable-review-app-modal
- v-if="canSetupReviewApp"
- v-model="isReviewAppModalVisible"
- :modal-id="$options.modalId"
- data-testid="enable-review-app-modal"
- />
- <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"
- sync-active-tab-with-query-params
- query-param-name="scope"
- @primary="showReviewAppModal"
- >
- <gl-tab query-param-value="available" @click="setScope('available')">
- <template #title>
- <span>{{ $options.i18n.available }}</span>
- <gl-badge size="sm" class="gl-tab-counter-badge">
- {{ availableCount }}
- </gl-badge>
- </template>
- </gl-tab>
- <gl-tab query-param-value="stopped" @click="setScope('stopped')">
- <template #title>
- <span>{{ $options.i18n.stopped }}</span>
- <gl-badge size="sm" class="gl-tab-counter-badge">
- {{ stoppedCount }}
- </gl-badge>
- </template>
- </gl-tab>
- </gl-tabs>
- <environment-folder
- v-for="folder in folders"
- :key="folder.name"
- class="gl-mb-3"
- :nested-environment="folder"
- />
- <environment-item
- v-for="environment in environments"
- :key="environment.name"
- class="gl-mb-3 gl-border-gray-100 gl-border-1 gl-border-b-solid"
- :environment="environment.latest"
- />
- <gl-pagination
- align="center"
- :total-items="totalItems"
- :per-page="itemsPerPage"
- :value="page"
- :next="$options.i18n.next"
- :prev="$options.i18n.prev"
- :label-previous-page="$options.prevPage"
- :label-next-page="$options.nextPage"
- :label-page="$options.goto"
- @next="movePage('next')"
- @previous="movePage('previous')"
- @input="moveToPage"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js
index 6d427bef4e6..942491039d6 100644
--- a/app/assets/javascripts/environments/constants.js
+++ b/app/assets/javascripts/environments/constants.js
@@ -38,3 +38,13 @@ export const CANARY_STATUS = {
};
export const CANARY_UPDATE_MODAL = 'confirm-canary-change';
+
+export const ENVIRONMENTS_SCOPE = {
+ AVAILABLE: 'available',
+ STOPPED: 'stopped',
+};
+
+export const ENVIRONMENT_COUNT_BY_SCOPE = {
+ [ENVIRONMENTS_SCOPE.AVAILABLE]: 'availableCount',
+ [ENVIRONMENTS_SCOPE.STOPPED]: 'stoppedCount',
+};
diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js
index 64b18c2003b..26514b59995 100644
--- a/app/assets/javascripts/environments/graphql/client.js
+++ b/app/assets/javascripts/environments/graphql/client.js
@@ -2,6 +2,9 @@ import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import environmentApp from './queries/environment_app.query.graphql';
import pageInfoQuery from './queries/page_info.query.graphql';
+import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql';
+import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql';
+import environmentToStopQuery from './queries/environment_to_stop.query.graphql';
import { resolvers } from './resolvers';
import typeDefs from './typedefs.graphql';
@@ -33,6 +36,52 @@ export const apolloProvider = (endpoint) => {
},
},
});
+
+ cache.writeQuery({
+ query: environmentToDeleteQuery,
+ data: {
+ environmentToDelete: {
+ name: 'null',
+ __typename: 'LocalEnvironment',
+ id: '0',
+ deletePath: null,
+ folderPath: null,
+ retryUrl: null,
+ autoStopPath: null,
+ lastDeployment: null,
+ },
+ },
+ });
+ cache.writeQuery({
+ query: environmentToStopQuery,
+ data: {
+ environmentToStop: {
+ name: 'null',
+ __typename: 'LocalEnvironment',
+ id: '0',
+ deletePath: null,
+ folderPath: null,
+ retryUrl: null,
+ autoStopPath: null,
+ lastDeployment: null,
+ },
+ },
+ });
+ cache.writeQuery({
+ query: environmentToRollbackQuery,
+ data: {
+ environmentToRollback: {
+ name: 'null',
+ __typename: 'LocalEnvironment',
+ id: '0',
+ deletePath: null,
+ folderPath: null,
+ retryUrl: null,
+ autoStopPath: null,
+ lastDeployment: null,
+ },
+ },
+ });
return new VueApollo({
defaultClient,
});
diff --git a/app/assets/javascripts/environments/graphql/queries/folder.query.graphql b/app/assets/javascripts/environments/graphql/queries/folder.query.graphql
index 3292c916b2e..e8c145ee916 100644
--- a/app/assets/javascripts/environments/graphql/queries/folder.query.graphql
+++ b/app/assets/javascripts/environments/graphql/queries/folder.query.graphql
@@ -1,5 +1,5 @@
-query getEnvironmentFolder($environment: NestedLocalEnvironment) {
- folder(environment: $environment) @client {
+query getEnvironmentFolder($environment: NestedLocalEnvironment, $scope: String) {
+ folder(environment: $environment, scope: $scope) @client {
availableCount
environments
stoppedCount
diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js
index dc763b77157..a7866c1e778 100644
--- a/app/assets/javascripts/environments/graphql/resolvers.js
+++ b/app/assets/javascripts/environments/graphql/resolvers.js
@@ -11,6 +11,7 @@ import environmentToRollbackQuery from './queries/environment_to_rollback.query.
import environmentToStopQuery from './queries/environment_to_stop.query.graphql';
import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql';
import environmentToChangeCanaryQuery from './queries/environment_to_change_canary.query.graphql';
+import isEnvironmentStoppingQuery from './queries/is_environment_stopping.query.graphql';
import pageInfoQuery from './queries/page_info.query.graphql';
const buildErrors = (errors = []) => ({
@@ -58,8 +59,8 @@ export const resolvers = (endpoint) => ({
};
});
},
- folder(_, { environment: { folderPath } }) {
- return axios.get(folderPath, { params: { per_page: 3 } }).then((res) => ({
+ folder(_, { environment: { folderPath }, scope }) {
+ return axios.get(folderPath, { params: { scope, per_page: 3 } }).then((res) => ({
availableCount: res.data.available_count,
environments: res.data.environments.map(mapEnvironment),
stoppedCount: res.data.stopped_count,
@@ -71,11 +72,21 @@ export const resolvers = (endpoint) => ({
},
},
Mutation: {
- stopEnvironment(_, { environment }) {
+ stopEnvironment(_, { environment }, { client }) {
+ client.writeQuery({
+ query: isEnvironmentStoppingQuery,
+ variables: { environment },
+ data: { isEnvironmentStopping: true },
+ });
return axios
.post(environment.stopPath)
.then(() => buildErrors())
.catch(() => {
+ client.writeQuery({
+ query: isEnvironmentStoppingQuery,
+ variables: { environment },
+ data: { isEnvironmentStopping: false },
+ });
return buildErrors([
s__('Environments|An error occurred while stopping the environment, please try again'),
]);
diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js
index 3b1d35c1f22..d9a523fd806 100644
--- a/app/assets/javascripts/environments/index.js
+++ b/app/assets/javascripts/environments/index.js
@@ -1,48 +1,37 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '../lib/utils/common_utils';
-import Translate from '../vue_shared/translate';
-import environmentsComponent from './components/environments_app.vue';
+import { apolloProvider } from './graphql/client';
+import EnvironmentsApp from './components/environments_app.vue';
-Vue.use(Translate);
Vue.use(VueApollo);
-const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
-});
-
export default (el) => {
if (el) {
+ const {
+ canCreateEnvironment,
+ endpoint,
+ newEnvironmentPath,
+ helpPagePath,
+ projectPath,
+ defaultBranchName,
+ projectId,
+ } = el.dataset;
+
return new Vue({
el,
- components: {
- environmentsComponent,
- },
- apolloProvider,
+ apolloProvider: apolloProvider(endpoint),
provide: {
- projectPath: el.dataset.projectPath,
- defaultBranchName: el.dataset.defaultBranchName,
- },
- data() {
- const environmentsData = el.dataset;
-
- return {
- endpoint: environmentsData.environmentsDataEndpoint,
- newEnvironmentPath: environmentsData.newEnvironmentPath,
- helpPagePath: environmentsData.helpPagePath,
- canCreateEnvironment: parseBoolean(environmentsData.canCreateEnvironment),
- };
+ projectPath,
+ defaultBranchName,
+ endpoint,
+ newEnvironmentPath,
+ helpPagePath,
+ projectId,
+ canCreateEnvironment: parseBoolean(canCreateEnvironment),
},
- render(createElement) {
- return createElement('environments-component', {
- props: {
- endpoint: this.endpoint,
- newEnvironmentPath: this.newEnvironmentPath,
- helpPagePath: this.helpPagePath,
- canCreateEnvironment: this.canCreateEnvironment,
- },
- });
+ render(h) {
+ return h(EnvironmentsApp);
},
});
}
diff --git a/app/assets/javascripts/environments/new_index.js b/app/assets/javascripts/environments/new_index.js
deleted file mode 100644
index dd5c709c75a..00000000000
--- a/app/assets/javascripts/environments/new_index.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import { parseBoolean } from '../lib/utils/common_utils';
-import { apolloProvider } from './graphql/client';
-import EnvironmentsApp from './components/new_environments_app.vue';
-
-Vue.use(VueApollo);
-
-export default (el) => {
- if (el) {
- const {
- canCreateEnvironment,
- endpoint,
- newEnvironmentPath,
- helpPagePath,
- projectPath,
- defaultBranchName,
- } = el.dataset;
-
- return new Vue({
- el,
- apolloProvider: apolloProvider(endpoint),
- provide: {
- projectPath,
- defaultBranchName,
- endpoint,
- newEnvironmentPath,
- helpPagePath,
- canCreateEnvironment: parseBoolean(canCreateEnvironment),
- },
- render(h) {
- return h(EnvironmentsApp);
- },
- });
- }
-
- return null;
-};
diff --git a/app/assets/javascripts/error_tracking/components/constants.js b/app/assets/javascripts/error_tracking/components/constants.js
deleted file mode 100644
index 41b952e26d8..00000000000
--- a/app/assets/javascripts/error_tracking/components/constants.js
+++ /dev/null
@@ -1,21 +0,0 @@
-export const severityLevel = {
- FATAL: 'fatal',
- ERROR: 'error',
- WARNING: 'warning',
- INFO: 'info',
- DEBUG: 'debug',
-};
-
-export const severityLevelVariant = {
- [severityLevel.FATAL]: 'danger',
- [severityLevel.ERROR]: 'neutral',
- [severityLevel.WARNING]: 'warning',
- [severityLevel.INFO]: 'info',
- [severityLevel.DEBUG]: 'muted',
-};
-
-export const errorStatus = {
- IGNORED: 'ignored',
- RESOLVED: 'resolved',
- UNRESOLVED: 'unresolved',
-};
diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue
index e00fec6fddf..0a8abdc90c6 100644
--- a/app/assets/javascripts/error_tracking/components/error_details.vue
+++ b/app/assets/javascripts/error_tracking/components/error_details.vue
@@ -26,7 +26,7 @@ import {
trackErrorStatusUpdateOptions,
} from '../utils';
-import { severityLevel, severityLevelVariant, errorStatus } from './constants';
+import { severityLevel, severityLevelVariant, errorStatus } from '../constants';
import Stacktrace from './stacktrace.vue';
const SENTRY_TIMEOUT = 10000;
diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
index 5db8c8cf8d3..3d540d46b3c 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -1,5 +1,6 @@
<script>
import {
+ GlAlert,
GlEmptyState,
GlButton,
GlIcon,
@@ -10,6 +11,7 @@ import {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
+ GlSprintf,
GlTooltipDirective,
GlPagination,
} from '@gitlab/ui';
@@ -21,6 +23,7 @@ import { __ } from '~/locale';
import Tracking from '~/tracking';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { trackErrorListViewsOptions, trackErrorStatusUpdateOptions } from '../utils';
+import { I18N_ERROR_TRACKING_LIST } from '../constants';
import ErrorTrackingActions from './error_tracking_actions.vue';
export const tableDataClass = 'table-col d-flex d-md-table-cell align-items-center';
@@ -29,6 +32,7 @@ export default {
FIRST_PAGE: 1,
PREV_PAGE: 1,
NEXT_PAGE: 2,
+ i18n: I18N_ERROR_TRACKING_LIST,
fields: [
{
key: 'error',
@@ -71,6 +75,7 @@ export default {
frequency: __('Frequency'),
},
components: {
+ GlAlert,
GlEmptyState,
GlButton,
GlDropdown,
@@ -81,6 +86,7 @@ export default {
GlLoadingIcon,
GlTable,
GlFormInput,
+ GlSprintf,
GlPagination,
TimeAgo,
ErrorTrackingActions,
@@ -117,12 +123,17 @@ export default {
type: String,
required: true,
},
+ showIntegratedTrackingDisabledAlert: {
+ type: Boolean,
+ required: false,
+ },
},
hasLocalStorage: AccessorUtils.canUseLocalStorage(),
data() {
return {
errorSearchQuery: '',
pageValue: this.$options.FIRST_PAGE,
+ isAlertDismissed: false,
};
},
computed: {
@@ -142,6 +153,9 @@ export default {
errorTrackingHelpUrl() {
return helpPagePath('operations/error_tracking');
},
+ showIntegratedDisabledAlert() {
+ return !this.isAlertDismissed && this.showIntegratedTrackingDisabledAlert;
+ },
},
watch: {
pagination() {
@@ -150,6 +164,8 @@ export default {
}
},
},
+ epicLink: 'https://gitlab.com/gitlab-org/gitlab/-/issues/353639',
+ featureFlagLink: helpPagePath('operations/error_tracking'),
created() {
if (this.errorTrackingEnabled) {
this.setEndpoint(this.indexPath);
@@ -232,6 +248,34 @@ export default {
<template>
<div class="error-list">
<div v-if="errorTrackingEnabled">
+ <gl-alert
+ v-if="showIntegratedDisabledAlert"
+ variant="danger"
+ data-testid="integrated-disabled-alert"
+ @dismiss="isAlertDismissed = true"
+ >
+ <gl-sprintf :message="this.$options.i18n.integratedErrorTrackingDisabledText">
+ <template #epicLink="{ content }">
+ <gl-link :href="$options.epicLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ <template #flagLink="{ content }">
+ <gl-link :href="$options.featureFlagLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ <template #settingsLink="{ content }">
+ <gl-link :href="enableErrorTrackingLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ <div>
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :href="enableErrorTrackingLink"
+ class="gl-mr-auto gl-mt-3"
+ >
+ {{ $options.i18n.viewProjectSettingsButton }}
+ </gl-button>
+ </div>
+ </gl-alert>
<div
class="row flex-column flex-md-row align-items-md-center m-0 mt-sm-2 p-3 p-sm-3 bg-secondary border"
>
diff --git a/app/assets/javascripts/error_tracking/constants.js b/app/assets/javascripts/error_tracking/constants.js
new file mode 100644
index 00000000000..f01bac2e81d
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/constants.js
@@ -0,0 +1,30 @@
+import { s__ } from '~/locale';
+
+export const severityLevel = {
+ FATAL: 'fatal',
+ ERROR: 'error',
+ WARNING: 'warning',
+ INFO: 'info',
+ DEBUG: 'debug',
+};
+
+export const severityLevelVariant = {
+ [severityLevel.FATAL]: 'danger',
+ [severityLevel.ERROR]: 'neutral',
+ [severityLevel.WARNING]: 'warning',
+ [severityLevel.INFO]: 'info',
+ [severityLevel.DEBUG]: 'muted',
+};
+
+export const errorStatus = {
+ IGNORED: 'ignored',
+ RESOLVED: 'resolved',
+ UNRESOLVED: 'unresolved',
+};
+
+export const I18N_ERROR_TRACKING_LIST = {
+ integratedErrorTrackingDisabledText: s__(
+ 'ErrorTracking|Integrated error tracking is %{epicLinkStart}turned off by default%{epicLinkEnd} and no longer active for this project. To re-enable error tracking on self-hosted instances, you can either %{flagLinkStart}turn on the feature flag%{flagLinkEnd} for integrated error tracking, or provide a %{settingsLinkStart}Sentry API URL and Auth Token%{settingsLinkEnd} on your project settings page. However, error tracking is not ready for production use and cannot be enabled on GitLab.com.',
+ ),
+ viewProjectSettingsButton: s__('ErrorTracking|View project settings'),
+};
diff --git a/app/assets/javascripts/error_tracking/list.js b/app/assets/javascripts/error_tracking/list.js
index 9c729407009..8b2086e1522 100644
--- a/app/assets/javascripts/error_tracking/list.js
+++ b/app/assets/javascripts/error_tracking/list.js
@@ -14,10 +14,15 @@ export default () => {
projectPath,
listPath,
} = domEl.dataset;
- let { errorTrackingEnabled, userCanEnableErrorTracking } = domEl.dataset;
+ let {
+ errorTrackingEnabled,
+ userCanEnableErrorTracking,
+ showIntegratedTrackingDisabledAlert,
+ } = domEl.dataset;
errorTrackingEnabled = parseBoolean(errorTrackingEnabled);
userCanEnableErrorTracking = parseBoolean(userCanEnableErrorTracking);
+ showIntegratedTrackingDisabledAlert = parseBoolean(showIntegratedTrackingDisabledAlert);
// eslint-disable-next-line no-new
new Vue({
@@ -36,6 +41,7 @@ export default () => {
userCanEnableErrorTracking,
projectPath,
listPath,
+ showIntegratedTrackingDisabledAlert,
},
});
},
diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue
index 4808cd1d1c0..e850d954e0a 100644
--- a/app/assets/javascripts/error_tracking_settings/components/app.vue
+++ b/app/assets/javascripts/error_tracking_settings/components/app.vue
@@ -1,29 +1,40 @@
<script>
import {
+ GlAlert,
GlButton,
GlFormGroup,
GlFormCheckbox,
GlFormRadioGroup,
GlFormRadio,
GlFormInputGroup,
+ GlLink,
+ GlSprintf,
} from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { I18N_ERROR_TRACKING_SETTINGS } from '../constants';
import ErrorTrackingForm from './error_tracking_form.vue';
import ProjectDropdown from './project_dropdown.vue';
export default {
+ i18n: I18N_ERROR_TRACKING_SETTINGS,
components: {
ErrorTrackingForm,
+ GlAlert,
GlButton,
GlFormCheckbox,
GlFormGroup,
GlFormRadioGroup,
GlFormRadio,
GlFormInputGroup,
+ GlLink,
+ GlSprintf,
ProjectDropdown,
ClipboardButton,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
initialApiHost: {
type: String,
@@ -62,6 +73,11 @@ export default {
default: null,
},
},
+ data() {
+ return {
+ isAlertDismissed: false,
+ };
+ },
computed: {
...mapGetters([
'dropdownLabel',
@@ -81,12 +97,34 @@ export default {
showGitlabDsnSetting() {
return this.integrated && this.enabled && this.gitlabDsn;
},
+ showIntegratedErrorTracking() {
+ return this.glFeatures.integratedErrorTracking === true;
+ },
+ setInitialEnabled() {
+ if (this.showIntegratedErrorTracking) {
+ return this.initialEnabled;
+ }
+ if (this.initialIntegrated === 'true') {
+ return 'false';
+ }
+ return this.initialEnabled;
+ },
+ showIntegratedTrackingDisabledAlert() {
+ return (
+ !this.isAlertDismissed &&
+ !this.showIntegratedErrorTracking &&
+ this.initialIntegrated === 'true' &&
+ this.initialEnabled === 'true'
+ );
+ },
},
+ epicLink: 'https://gitlab.com/gitlab-org/gitlab/-/issues/353639',
+ featureFlagLink: helpPagePath('operations/error_tracking'),
created() {
this.setInitialState({
apiHost: this.initialApiHost,
- enabled: this.initialEnabled,
- integrated: this.initialIntegrated,
+ enabled: this.setInitialEnabled,
+ integrated: this.showIntegratedErrorTracking && this.initialIntegrated,
project: this.initialProject,
token: this.initialToken,
listProjectsEndpoint: this.listProjectsEndpoint,
@@ -104,21 +142,41 @@ export default {
handleSubmit() {
this.updateSettings();
},
+ dismissAlert() {
+ this.isAlertDismissed = true;
+ },
},
};
</script>
<template>
<div>
+ <gl-alert v-if="showIntegratedTrackingDisabledAlert" variant="danger" @dismiss="dismissAlert">
+ <gl-sprintf :message="this.$options.i18n.integratedErrorTrackingDisabledText">
+ <template #epicLink="{ content }">
+ <gl-link :href="$options.epicLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ <template #flagLink="{ content }">
+ <gl-link :href="$options.featureFlagLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+
<gl-form-group
:label="s__('ErrorTracking|Enable error tracking')"
label-for="error-tracking-enabled"
>
- <gl-form-checkbox id="error-tracking-enabled" :checked="enabled" @change="updateEnabled">
+ <gl-form-checkbox
+ id="error-tracking-enabled"
+ :checked="enabled"
+ data-testid="error-tracking-enabled"
+ @change="updateEnabled"
+ >
{{ s__('ErrorTracking|Active') }}
</gl-form-checkbox>
</gl-form-group>
<gl-form-group
+ v-if="showIntegratedErrorTracking"
:label="s__('ErrorTracking|Error tracking backend')"
data-testid="tracking-backend-settings"
>
diff --git a/app/assets/javascripts/error_tracking_settings/constants.js b/app/assets/javascripts/error_tracking_settings/constants.js
new file mode 100644
index 00000000000..ee86c55e843
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/constants.js
@@ -0,0 +1,7 @@
+import { s__ } from '~/locale';
+
+export const I18N_ERROR_TRACKING_SETTINGS = {
+ integratedErrorTrackingDisabledText: s__(
+ 'ErrorTracking|Integrated error tracking is %{epicLinkStart}turned off by default%{epicLinkEnd} and no longer active for this project. To re-enable error tracking on self-hosted instances, you can either %{flagLinkStart}turn on the feature flag%{flagLinkEnd} for integrated error tracking, or provide a Sentry API URL and Auth Token below. However, error tracking is not ready for production use and cannot be enabled on GitLab.com.',
+ ),
+};
diff --git a/app/assets/javascripts/experimentation/components/gitlab_experiment.vue b/app/assets/javascripts/experimentation/components/gitlab_experiment.vue
index 294dbf77991..678ce447e80 100644
--- a/app/assets/javascripts/experimentation/components/gitlab_experiment.vue
+++ b/app/assets/javascripts/experimentation/components/gitlab_experiment.vue
@@ -9,7 +9,7 @@ export default {
},
},
render() {
- return this.$slots?.[getExperimentVariant(this.name)];
+ return this.$scopedSlots?.[getExperimentVariant(this.name)]?.();
},
};
</script>
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
index fcc7caa9ff2..9de291b7809 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
@@ -1,3 +1,4 @@
+import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
import { FILTER_TYPE } from './constants';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
@@ -13,7 +14,7 @@ export default class FilteredSearchDropdown {
this.filter = filter;
this.dropdown = dropdown;
this.loadingTemplate = `<div class="filter-dropdown-loading">
- <span class="spinner"></span>
+ ${loadingIconForLegacyJS().outerHTML}
</div>`;
this.bindEvents();
}
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index bf29a356abd..8cb2e9e249b 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -3,6 +3,7 @@ import '~/lib/utils/jquery_at_who';
import { escape as lodashEscape, sortBy, template, escapeRegExp } from 'lodash';
import * as Emoji from '~/emoji';
import axios from '~/lib/utils/axios_utils';
+import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
import { s__, __, sprintf } from '~/locale';
import { isUserBusy } from '~/set_status_modal/utils';
import SidebarMediator from '~/sidebar/sidebar_mediator';
@@ -574,6 +575,10 @@ class GfmAutoComplete {
// Do not match if there is no `~` before the cursor
return null;
}
+ if (subtext.endsWith('~~')) {
+ // Do not match if there are two consecutive `~` characters (strikethrough) before the cursor
+ return null;
+ }
const lastCandidate = subtext.split(flag).pop();
if (labels.find((label) => label.title.startsWith(lastCandidate))) {
return lastCandidate;
@@ -953,9 +958,14 @@ GfmAutoComplete.Contacts = {
return `<li><small>${firstName} ${lastName}</small> ${escape(email)}</li>`;
},
};
+
+const loadingSpinner = loadingIconForLegacyJS({
+ inline: true,
+ classes: ['gl-mr-2'],
+}).outerHTML;
+
GfmAutoComplete.Loading = {
- template:
- '<li style="pointer-events: none;"><span class="spinner align-text-bottom mr-1"></span>Loading...</li>',
+ template: `<li style="pointer-events: none;">${loadingSpinner}Loading...</li>`,
};
export default GfmAutoComplete;
diff --git a/app/assets/javascripts/google_cloud/components/app.vue b/app/assets/javascripts/google_cloud/components/app.vue
index 64784755b66..03b256297f6 100644
--- a/app/assets/javascripts/google_cloud/components/app.vue
+++ b/app/assets/javascripts/google_cloud/components/app.vue
@@ -4,6 +4,7 @@ import { __ } from '~/locale';
import Home from './home.vue';
import IncubationBanner from './incubation_banner.vue';
import ServiceAccountsForm from './service_accounts_form.vue';
+import GcpRegionsForm from './gcp_regions_form.vue';
import NoGcpProjects from './errors/no_gcp_projects.vue';
import GcpError from './errors/gcp_error.vue';
@@ -11,6 +12,7 @@ const SCREEN_GCP_ERROR = 'gcp_error';
const SCREEN_HOME = 'home';
const SCREEN_NO_GCP_PROJECTS = 'no_gcp_projects';
const SCREEN_SERVICE_ACCOUNTS_FORM = 'service_accounts_form';
+const SCREEN_GCP_REGIONS_FORM = 'gcp_regions_form';
export default {
components: {
@@ -34,6 +36,8 @@ export default {
return NoGcpProjects;
case SCREEN_SERVICE_ACCOUNTS_FORM:
return ServiceAccountsForm;
+ case SCREEN_GCP_REGIONS_FORM:
+ return GcpRegionsForm;
default:
throw new Error(__('Unknown screen'));
}
diff --git a/app/assets/javascripts/google_cloud/components/gcp_regions_form.vue b/app/assets/javascripts/google_cloud/components/gcp_regions_form.vue
new file mode 100644
index 00000000000..23011e5a5b0
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/components/gcp_regions_form.vue
@@ -0,0 +1,62 @@
+<script>
+import { GlButton, GlFormGroup, GlFormSelect } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+
+export default {
+ components: { GlButton, GlFormGroup, GlFormSelect },
+ props: {
+ availableRegions: { required: true, type: Array },
+ refs: { required: true, type: Array },
+ cancelPath: { required: true, type: String },
+ },
+ i18n: {
+ title: __('Configure region for environment'),
+ gcpRegionLabel: __('Region'),
+ gcpRegionDescription: __('List of suitable GCP locations'),
+ refsLabel: s__('GoogleCloud|Refs'),
+ refsDescription: s__('GoogleCloud|Configured region is linked to the selected branch or tag'),
+ submitLabel: __('Configure region'),
+ cancelLabel: __('Cancel'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <header class="gl-my-5 gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid">
+ <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1>
+ </header>
+
+ <gl-form-group
+ label-for="ref"
+ :label="$options.i18n.refsLabel"
+ :description="$options.i18n.refsDescription"
+ >
+ <gl-form-select id="ref" name="ref" required>
+ <option value="*">{{ __('All') }}</option>
+ <option v-for="ref in refs" :key="ref" :value="ref">
+ {{ ref }}
+ </option>
+ </gl-form-select>
+ </gl-form-group>
+
+ <gl-form-group
+ label-for="gcp_region"
+ :label="$options.i18n.gcpRegionLabel"
+ :description="$options.i18n.gcpRegionDescription"
+ >
+ <gl-form-select id="gcp_region" name="gcp_region" required :list="availableRegions">
+ <option v-for="(region, index) in availableRegions" :key="index" :value="region">
+ {{ region }}
+ </option>
+ </gl-form-select>
+ </gl-form-group>
+
+ <div class="form-actions row">
+ <gl-button type="submit" category="primary" variant="confirm">
+ {{ $options.i18n.submitLabel }}
+ </gl-button>
+ <gl-button class="gl-ml-1" :href="cancelPath">{{ $options.i18n.cancelLabel }}</gl-button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/components/gcp_regions_list.vue b/app/assets/javascripts/google_cloud/components/gcp_regions_list.vue
new file mode 100644
index 00000000000..1cc5a85198a
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/components/gcp_regions_list.vue
@@ -0,0 +1,56 @@
+<script>
+import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: { GlButton, GlEmptyState, GlTable },
+ props: {
+ list: {
+ type: Array,
+ required: true,
+ },
+ createUrl: {
+ type: String,
+ required: true,
+ },
+ emptyIllustrationUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ tableFields: [
+ { key: 'environment', label: __('Environment'), sortable: true },
+ { key: 'gcp_region', label: __('Region'), sortable: true },
+ ],
+ i18n: {
+ emptyStateTitle: __('No regions configured'),
+ description: __('Configure your environments to be deployed to specific geographical regions'),
+ emptyStateAction: __('Add a GCP region'),
+ configureRegions: __('Configure regions'),
+ listTitle: __('Regions'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-empty-state
+ v-if="list.length === 0"
+ :title="$options.i18n.emptyStateTitle"
+ :description="$options.i18n.description"
+ :primary-button-link="createUrl"
+ :primary-button-text="$options.i18n.configureRegions"
+ />
+
+ <div v-else>
+ <h2 class="gl-font-size-h2">{{ $options.i18n.listTitle }}</h2>
+ <p>{{ $options.i18n.description }}</p>
+
+ <gl-table :items="list" :fields="$options.tableFields" />
+
+ <gl-button :href="createUrl" category="primary" variant="info">
+ {{ $options.i18n.configureRegions }}
+ </gl-button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/components/home.vue b/app/assets/javascripts/google_cloud/components/home.vue
index c08d8bb7c51..e41337e2679 100644
--- a/app/assets/javascripts/google_cloud/components/home.vue
+++ b/app/assets/javascripts/google_cloud/components/home.vue
@@ -1,14 +1,18 @@
<script>
import { GlTabs, GlTab } from '@gitlab/ui';
import DeploymentsServiceTable from './deployments_service_table.vue';
+import RevokeOauth from './revoke_oauth.vue';
import ServiceAccountsList from './service_accounts_list.vue';
+import GcpRegionsList from './gcp_regions_list.vue';
export default {
components: {
GlTabs,
GlTab,
DeploymentsServiceTable,
+ RevokeOauth,
ServiceAccountsList,
+ GcpRegionsList,
},
props: {
serviceAccounts: {
@@ -19,6 +23,10 @@ export default {
type: String,
required: true,
},
+ configureGcpRegionsUrl: {
+ type: String,
+ required: true,
+ },
emptyIllustrationUrl: {
type: String,
required: true,
@@ -31,6 +39,14 @@ export default {
type: String,
required: true,
},
+ gcpRegions: {
+ type: Array,
+ required: true,
+ },
+ revokeOauthUrl: {
+ type: String,
+ required: true,
+ },
},
};
</script>
@@ -44,6 +60,15 @@ export default {
:create-url="createServiceAccountUrl"
:empty-illustration-url="emptyIllustrationUrl"
/>
+ <hr />
+ <gcp-regions-list
+ class="gl-mx-4"
+ :empty-illustration-url="emptyIllustrationUrl"
+ :create-url="configureGcpRegionsUrl"
+ :list="gcpRegions"
+ />
+ <hr v-if="revokeOauthUrl" />
+ <revoke-oauth v-if="revokeOauthUrl" :url="revokeOauthUrl" />
</gl-tab>
<gl-tab :title="__('Deployments')">
<deployments-service-table
diff --git a/app/assets/javascripts/google_cloud/components/revoke_oauth.vue b/app/assets/javascripts/google_cloud/components/revoke_oauth.vue
new file mode 100644
index 00000000000..07d966894f6
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/components/revoke_oauth.vue
@@ -0,0 +1,38 @@
+<script>
+import { GlButton, GlForm } from '@gitlab/ui';
+import csrf from '~/lib/utils/csrf';
+import { s__ } from '~/locale';
+
+export const GOOGLE_CLOUD_REVOKE_TITLE = s__('GoogleCloud|Revoke authorizations');
+export const GOOGLE_CLOUD_REVOKE_DESCRIPTION = s__(
+ 'GoogleCloud|Revoke authorizations granted to GitLab. This does not invalidate service accounts.',
+);
+
+export default {
+ components: { GlButton, GlForm },
+ csrf,
+ props: {
+ url: {
+ type: String,
+ required: true,
+ },
+ },
+ i18n: {
+ title: GOOGLE_CLOUD_REVOKE_TITLE,
+ description: GOOGLE_CLOUD_REVOKE_DESCRIPTION,
+ },
+};
+</script>
+
+<template>
+ <div class="gl-mx-4">
+ <h2 class="gl-font-size-h2">{{ $options.i18n.title }}</h2>
+ <p>{{ $options.i18n.description }}</p>
+ <gl-form :action="url" method="post">
+ <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+ <gl-button category="secondary" variant="warning" type="submit">
+ {{ $options.i18n.title }}
+ </gl-button>
+ </gl-form>
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/components/service_accounts_form.vue b/app/assets/javascripts/google_cloud/components/service_accounts_form.vue
index 551783e6c50..faec94e735b 100644
--- a/app/assets/javascripts/google_cloud/components/service_accounts_form.vue
+++ b/app/assets/javascripts/google_cloud/components/service_accounts_form.vue
@@ -1,26 +1,29 @@
<script>
-import { GlButton, GlFormGroup, GlFormSelect, GlFormCheckbox } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { GlButton, GlFormCheckbox, GlFormGroup, GlFormSelect } from '@gitlab/ui';
+import { s__ } from '~/locale';
export default {
+ ALL_REFS: '*',
components: { GlButton, GlFormGroup, GlFormSelect, GlFormCheckbox },
props: {
gcpProjects: { required: true, type: Array },
- environments: { required: true, type: Array },
+ refs: { required: true, type: Array },
cancelPath: { required: true, type: String },
},
i18n: {
- title: __('Create service account'),
- gcpProjectLabel: __('Google Cloud project'),
- gcpProjectDescription: __(
- 'New service account is generated for the selected Google Cloud project',
+ title: s__('GoogleCloud|Create service account'),
+ gcpProjectLabel: s__('GoogleCloud|Google Cloud project'),
+ gcpProjectDescription: s__(
+ 'GoogleCloud|New service account is generated for the selected Google Cloud project',
),
- environmentLabel: __('Environment'),
- environmentDescription: __('Generated service account is linked to the selected environment'),
- submitLabel: __('Create service account'),
- cancelLabel: __('Cancel'),
- checkboxLabel: __(
- 'I understand the responsibilities involved with managing service account keys',
+ refsLabel: s__('GoogleCloud|Refs'),
+ refsDescription: s__(
+ 'GoogleCloud|Generated service account is linked to the selected branch or tag',
+ ),
+ submitLabel: s__('GoogleCloud|Create service account'),
+ cancelLabel: s__('GoogleCloud|Cancel'),
+ checkboxLabel: s__(
+ 'GoogleCloud|I understand the responsibilities involved with managing service account keys',
),
},
};
@@ -47,18 +50,14 @@ export default {
</gl-form-select>
</gl-form-group>
<gl-form-group
- label-for="environment"
- :label="$options.i18n.environmentLabel"
- :description="$options.i18n.environmentDescription"
+ label-for="ref"
+ :label="$options.i18n.refsLabel"
+ :description="$options.i18n.refsDescription"
>
- <gl-form-select id="environment" name="environment" required>
- <option value="*">{{ __('All') }}</option>
- <option
- v-for="environment in environments"
- :key="environment.name"
- :value="environment.name"
- >
- {{ environment.name }}
+ <gl-form-select id="ref" name="ref" required>
+ <option :value="$options.ALL_REFS">{{ __('All') }}</option>
+ <option v-for="ref in refs" :key="ref" :value="ref">
+ {{ ref }}
</option>
</gl-form-select>
</gl-form-group>
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 4db84746482..37b716d7be5 100644
--- a/app/assets/javascripts/google_cloud/components/service_accounts_list.vue
+++ b/app/assets/javascripts/google_cloud/components/service_accounts_list.vue
@@ -18,16 +18,12 @@ export default {
required: true,
},
},
- data() {
- return {
- tableFields: [
- { key: 'environment', label: __('Environment'), sortable: true },
- { key: 'gcp_project', label: __('Google Cloud Project'), sortable: true },
- { key: 'service_account_exists', label: __('Service Account'), sortable: true },
- { key: 'service_account_key_exists', label: __('Service Account Key'), sortable: true },
- ],
- };
- },
+ tableFields: [
+ { key: 'ref', label: __('Environment'), sortable: true },
+ { key: 'gcp_project', label: __('Google Cloud Project'), sortable: true },
+ { key: 'service_account_exists', label: __('Service Account'), sortable: true },
+ { key: 'service_account_key_exists', label: __('Service Account Key'), sortable: true },
+ ],
i18n: {
createServiceAccount: __('Create service account'),
found: __('✔'),
@@ -62,7 +58,7 @@ export default {
<h2 class="gl-font-size-h2">{{ $options.i18n.serviceAccountsTitle }}</h2>
<p>{{ $options.i18n.serviceAccountsDescription }}</p>
- <gl-table :items="list" :fields="tableFields">
+ <gl-table :items="list" :fields="$options.tableFields">
<template #cell(service_account_exists)="{ value }">
{{ value ? $options.i18n.found : $options.i18n.notFound }}
</template>
diff --git a/app/assets/javascripts/google_tag_manager/index.js b/app/assets/javascripts/google_tag_manager/index.js
index 55987ce64e6..f42152006d2 100644
--- a/app/assets/javascripts/google_tag_manager/index.js
+++ b/app/assets/javascripts/google_tag_manager/index.js
@@ -150,7 +150,7 @@ export const trackSaasTrialProject = () => {
});
};
-export const trackSaasTrialProjectImport = () => {
+export const trackProjectImport = () => {
if (!isSupported()) {
return;
}
@@ -159,7 +159,7 @@ export const trackSaasTrialProjectImport = () => {
importButtons.forEach((button) => {
button.addEventListener('click', () => {
const { platform } = button.dataset;
- pushEvent('saasTrialProjectImport', { saasProjectImport: platform });
+ pushEvent('projectImport', { platform });
});
});
};
@@ -231,3 +231,43 @@ export const trackTransaction = (transactionDetails) => {
pushEnhancedEcommerceEvent('EECtransactionSuccess', eventData);
};
+
+export const trackAddToCartUsageTab = () => {
+ if (!isSupported()) {
+ return;
+ }
+
+ const getStartedButton = document.querySelector('.js-buy-additional-minutes');
+ getStartedButton.addEventListener('click', () => {
+ window.dataLayer.push({
+ event: 'EECproductAddToCart',
+ ecommerce: {
+ currencyCode: 'USD',
+ add: {
+ products: [
+ {
+ name: 'CI/CD Minutes',
+ id: '0003',
+ price: '10',
+ brand: 'GitLab',
+ category: 'DevOps',
+ variant: 'add-on',
+ quantity: 1,
+ },
+ ],
+ },
+ },
+ });
+ });
+};
+
+export const trackCombinedGroupProjectForm = () => {
+ if (!isSupported()) {
+ return;
+ }
+
+ const form = document.querySelector('.js-groups-projects-form');
+ form.addEventListener('submit', () => {
+ pushEvent('combinedGroupProjectFormSubmit');
+ });
+};
diff --git a/app/assets/javascripts/gpg_badges.js b/app/assets/javascripts/gpg_badges.js
index 7964e762dac..d376c9f76ba 100644
--- a/app/assets/javascripts/gpg_badges.js
+++ b/app/assets/javascripts/gpg_badges.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { queryToObject } from '~/lib/utils/url_utility';
+import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
import { __ } from '~/locale';
@@ -14,7 +15,7 @@ export default class GpgBadges {
const badges = $('.js-loading-gpg-badge');
- badges.html('<span class="gl-spinner gl-spinner-orange gl-spinner-sm"></span>');
+ badges.html(loadingIconForLegacyJS());
badges.children().attr('aria-label', __('Loading'));
const displayError = () =>
diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js
index 3b36c3e6ac5..4ebb49b4756 100644
--- a/app/assets/javascripts/graphql_shared/constants.js
+++ b/app/assets/javascripts/graphql_shared/constants.js
@@ -1,9 +1,11 @@
export const MINIMUM_SEARCH_LENGTH = 3;
+export const TYPE_BOARD = 'Board';
export const TYPE_CI_RUNNER = 'Ci::Runner';
export const TYPE_CRM_CONTACT = 'CustomerRelations::Contact';
export const TYPE_DISCUSSION = 'Discussion';
export const TYPE_EPIC = 'Epic';
+export const TYPE_EPIC_BOARD = 'Boards::EpicBoard';
export const TYPE_GROUP = 'Group';
export const TYPE_ISSUE = 'Issue';
export const TYPE_ITERATION = 'Iteration';
diff --git a/app/assets/javascripts/graphql_shared/possibleTypes.json b/app/assets/javascripts/graphql_shared/possibleTypes.json
index 9a24d2a3afc..01116067887 100644
--- a/app/assets/javascripts/graphql_shared/possibleTypes.json
+++ b/app/assets/javascripts/graphql_shared/possibleTypes.json
@@ -1 +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"]}
+{"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","WorkItem"],"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"],"Todoable":["AlertManagementAlert","BoardEpic","Commit","Design","Epic","EpicIssue","Issue","MergeRequest"],"User":["MergeRequestAssignee","MergeRequestAuthor","MergeRequestParticipant","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"]} \ No newline at end of file
diff --git a/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql b/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql
new file mode 100644
index 00000000000..2bd016feb19
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql
@@ -0,0 +1,24 @@
+#import "../fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
+
+query projectUsersSearchWithMRPermissions(
+ $search: String!
+ $fullPath: ID!
+ $mergeRequestId: MergeRequestID!
+) {
+ workspace: project(fullPath: $fullPath) {
+ id
+ users: projectMembers(search: $search, relations: [DIRECT, INHERITED, INVITED_GROUPS]) {
+ nodes {
+ id
+ mergeRequestInteraction(id: $mergeRequestId) {
+ canMerge
+ }
+ user {
+ ...User
+ ...UserAvailability
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue
index c24eeed9f03..3620c884c5f 100644
--- a/app/assets/javascripts/groups/components/item_stats.vue
+++ b/app/assets/javascripts/groups/components/item_stats.vue
@@ -68,7 +68,7 @@ export default {
/>
<item-stats-value
v-if="isGroup"
- :title="__('Members')"
+ :title="__('Direct members')"
:value="item.memberCount"
css-class="number-users gl-ml-5"
icon-name="users"
diff --git a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
index 9f4f4768247..c0e2c18bece 100644
--- a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
+++ b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
@@ -4,20 +4,28 @@ import {
GlDropdownSectionHeader,
GlDropdownDivider,
GlAvatar,
+ GlAlert,
GlLoadingIcon,
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
+import { s__ } from '~/locale';
import highlight from '~/lib/utils/highlight';
import { GROUPS_CATEGORY, PROJECTS_CATEGORY, LARGE_AVATAR_PX, SMALL_AVATAR_PX } from '../constants';
export default {
name: 'HeaderSearchAutocompleteItems',
+ i18n: {
+ autocompleteErrorMessage: s__(
+ 'GlobalSearch|There was an error fetching search autocomplete suggestions.',
+ ),
+ },
components: {
GlDropdownItem,
GlDropdownSectionHeader,
GlDropdownDivider,
GlAvatar,
+ GlAlert,
GlLoadingIcon,
},
directives: {
@@ -31,7 +39,7 @@ export default {
},
},
computed: {
- ...mapState(['search', 'loading']),
+ ...mapState(['search', 'loading', 'autocompleteError']),
...mapGetters(['autocompleteGroupedSearchOptions']),
},
watch: {
@@ -93,5 +101,13 @@ export default {
</div>
</template>
<gl-loading-icon v-else size="lg" class="my-4" />
+ <gl-alert
+ v-if="autocompleteError"
+ class="gl-text-body gl-mt-2"
+ :dismissible="false"
+ variant="danger"
+ >
+ {{ $options.i18n.autocompleteErrorMessage }}
+ </gl-alert>
</div>
</template>
diff --git a/app/assets/javascripts/header_search/components/header_search_default_items.vue b/app/assets/javascripts/header_search/components/header_search_default_items.vue
index 53e63bc6cca..04deaba7b0f 100644
--- a/app/assets/javascripts/header_search/components/header_search_default_items.vue
+++ b/app/assets/javascripts/header_search/components/header_search_default_items.vue
@@ -24,8 +24,8 @@ export default {
...mapGetters(['defaultSearchOptions']),
sectionHeader() {
return (
- this.searchContext.project?.name ||
- this.searchContext.group?.name ||
+ this.searchContext?.project?.name ||
+ this.searchContext?.group?.name ||
this.$options.i18n.allGitLab
);
},
diff --git a/app/assets/javascripts/header_search/index.js b/app/assets/javascripts/header_search/index.js
index d7e21f55ea5..4af8513ecdb 100644
--- a/app/assets/javascripts/header_search/index.js
+++ b/app/assets/javascripts/header_search/index.js
@@ -5,7 +5,7 @@ import createStore from './store';
Vue.use(Translate);
-export const initHeaderSearchApp = () => {
+export const initHeaderSearchApp = (search = '') => {
const el = document.getElementById('js-header-search');
if (!el) {
@@ -18,7 +18,7 @@ export const initHeaderSearchApp = () => {
return new Vue({
el,
- store: createStore({ searchPath, issuesPath, mrPath, autocompletePath, searchContext }),
+ store: createStore({ searchPath, issuesPath, mrPath, autocompletePath, searchContext, search }),
render(createElement) {
return createElement(HeaderSearchApp);
},
diff --git a/app/assets/javascripts/header_search/store/actions.js b/app/assets/javascripts/header_search/store/actions.js
index 0ba956f3ed1..ee4c312fed0 100644
--- a/app/assets/javascripts/header_search/store/actions.js
+++ b/app/assets/javascripts/header_search/store/actions.js
@@ -1,6 +1,4 @@
-import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
-import { __ } from '~/locale';
import * as types from './mutation_types';
export const fetchAutocompleteOptions = ({ commit, getters }) => {
@@ -10,7 +8,6 @@ export const fetchAutocompleteOptions = ({ commit, getters }) => {
.then(({ data }) => commit(types.RECEIVE_AUTOCOMPLETE_SUCCESS, data))
.catch(() => {
commit(types.RECEIVE_AUTOCOMPLETE_ERROR);
- createFlash({ message: __('There was an error fetching search autocomplete suggestions') });
});
};
diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js
index a1348a8aa3f..87dec95153f 100644
--- a/app/assets/javascripts/header_search/store/getters.js
+++ b/app/assets/javascripts/header_search/store/getters.js
@@ -17,9 +17,12 @@ export const searchQuery = (state) => {
{
search: state.search,
nav_source: 'navbar',
- project_id: state.searchContext.project?.id,
- group_id: state.searchContext.group?.id,
+ project_id: state.searchContext?.project?.id,
+ group_id: state.searchContext?.group?.id,
scope: state.searchContext?.scope,
+ snippets: state.searchContext?.for_snippets ? true : null,
+ search_code: state.searchContext?.code_search ? true : null,
+ repository_ref: state.searchContext?.ref,
},
isNil,
);
@@ -31,7 +34,7 @@ export const autocompleteQuery = (state) => {
const query = omitBy(
{
term: state.search,
- project_id: state.searchContext.project?.id,
+ project_id: state.searchContext?.project?.id,
project_ref: state.searchContext?.ref,
},
isNil,
@@ -42,16 +45,16 @@ export const autocompleteQuery = (state) => {
export const scopedIssuesPath = (state) => {
return (
- state.searchContext.project_metadata?.issues_path ||
- state.searchContext.group_metadata?.issues_path ||
+ state.searchContext?.project_metadata?.issues_path ||
+ state.searchContext?.group_metadata?.issues_path ||
state.issuesPath
);
};
export const scopedMRPath = (state) => {
return (
- state.searchContext.project_metadata?.mr_path ||
- state.searchContext.group_metadata?.mr_path ||
+ state.searchContext?.project_metadata?.mr_path ||
+ state.searchContext?.group_metadata?.mr_path ||
state.mrPath
);
};
@@ -96,6 +99,9 @@ export const projectUrl = (state) => {
project_id: state.searchContext?.project?.id,
group_id: state.searchContext?.group?.id,
scope: state.searchContext?.scope,
+ snippets: state.searchContext?.for_snippets ? true : null,
+ search_code: state.searchContext?.code_search ? true : null,
+ repository_ref: state.searchContext?.ref,
},
isNil,
);
@@ -110,6 +116,9 @@ export const groupUrl = (state) => {
nav_source: 'navbar',
group_id: state.searchContext?.group?.id,
scope: state.searchContext?.scope,
+ snippets: state.searchContext?.for_snippets ? true : null,
+ search_code: state.searchContext?.code_search ? true : null,
+ repository_ref: state.searchContext?.ref,
},
isNil,
);
@@ -123,6 +132,9 @@ export const allUrl = (state) => {
search: state.search,
nav_source: 'navbar',
scope: state.searchContext?.scope,
+ snippets: state.searchContext?.for_snippets ? true : null,
+ search_code: state.searchContext?.code_search ? true : null,
+ repository_ref: state.searchContext?.ref,
},
isNil,
);
@@ -133,19 +145,19 @@ export const allUrl = (state) => {
export const scopedSearchOptions = (state, getters) => {
const options = [];
- if (state.searchContext.project) {
+ if (state.searchContext?.project) {
options.push({
html_id: 'scoped-in-project',
- scope: state.searchContext.project.name,
+ scope: state.searchContext.project?.name || '',
description: MSG_IN_PROJECT,
url: getters.projectUrl,
});
}
- if (state.searchContext.group) {
+ if (state.searchContext?.group) {
options.push({
html_id: 'scoped-in-group',
- scope: state.searchContext.group.name,
+ scope: state.searchContext.group?.name || '',
description: MSG_IN_GROUP,
url: getters.groupUrl,
});
diff --git a/app/assets/javascripts/header_search/store/index.js b/app/assets/javascripts/header_search/store/index.js
index 06cca4be8a7..b83433c5b49 100644
--- a/app/assets/javascripts/header_search/store/index.js
+++ b/app/assets/javascripts/header_search/store/index.js
@@ -13,11 +13,12 @@ export const getStoreConfig = ({
mrPath,
autocompletePath,
searchContext,
+ search,
}) => ({
actions,
getters,
mutations,
- state: createState({ searchPath, issuesPath, mrPath, autocompletePath, searchContext }),
+ state: createState({ searchPath, issuesPath, mrPath, autocompletePath, searchContext, search }),
});
const createStore = (config) => new Vuex.Store(getStoreConfig(config));
diff --git a/app/assets/javascripts/header_search/store/mutations.js b/app/assets/javascripts/header_search/store/mutations.js
index 26b4a8854fe..92948bec515 100644
--- a/app/assets/javascripts/header_search/store/mutations.js
+++ b/app/assets/javascripts/header_search/store/mutations.js
@@ -4,19 +4,23 @@ export default {
[types.REQUEST_AUTOCOMPLETE](state) {
state.loading = true;
state.autocompleteOptions = [];
+ state.autocompleteError = false;
},
[types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) {
state.loading = false;
state.autocompleteOptions = data.map((d, i) => {
return { html_id: `autocomplete-${d.category}-${i}`, ...d };
});
+ state.autocompleteError = false;
},
[types.RECEIVE_AUTOCOMPLETE_ERROR](state) {
state.loading = false;
state.autocompleteOptions = [];
+ state.autocompleteError = true;
},
[types.CLEAR_AUTOCOMPLETE](state) {
state.autocompleteOptions = [];
+ state.autocompleteError = false;
},
[types.SET_SEARCH](state, value) {
state.search = value;
diff --git a/app/assets/javascripts/header_search/store/state.js b/app/assets/javascripts/header_search/store/state.js
index 3d4073f0583..bebdbc7b92e 100644
--- a/app/assets/javascripts/header_search/store/state.js
+++ b/app/assets/javascripts/header_search/store/state.js
@@ -1,11 +1,19 @@
-const createState = ({ searchPath, issuesPath, mrPath, autocompletePath, searchContext }) => ({
+const createState = ({
searchPath,
issuesPath,
mrPath,
autocompletePath,
searchContext,
- search: '',
+ search,
+}) => ({
+ searchPath,
+ issuesPath,
+ mrPath,
+ autocompletePath,
+ searchContext,
+ search,
autocompleteOptions: [],
+ autocompleteError: false,
loading: false,
});
export default createState;
diff --git a/app/assets/javascripts/ide/components/file_templates/bar.vue b/app/assets/javascripts/ide/components/file_templates/bar.vue
index 0803925104d..0921b5a5424 100644
--- a/app/assets/javascripts/ide/components/file_templates/bar.vue
+++ b/app/assets/javascripts/ide/components/file_templates/bar.vue
@@ -1,17 +1,46 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlDropdown, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
-import Dropdown from './dropdown.vue';
+import { __ } from '~/locale';
+
+const barLabel = __('File templates');
+const templateListDropdownLabel = __('Choose a template...');
+const templateTypesDropdownLabel = __('Choose a type...');
+const undoButtonText = __('Undo');
export default {
+ i18n: {
+ barLabel,
+ templateListDropdownLabel,
+ templateTypesDropdownLabel,
+ undoButtonText,
+ },
components: {
- Dropdown,
GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ },
+ data() {
+ return {
+ search: '',
+ };
},
computed: {
...mapGetters(['activeFile']),
...mapGetters('fileTemplates', ['templateTypes']),
- ...mapState('fileTemplates', ['selectedTemplateType', 'updateSuccess']),
+ ...mapState('fileTemplates', [
+ 'selectedTemplateType',
+ 'updateSuccess',
+ 'templates',
+ 'isLoading',
+ ]),
+ filteredTemplateTypes() {
+ return this.templates.filter((t) => {
+ return t.name.toLowerCase().includes(this.search.toLowerCase());
+ });
+ },
showTemplatesDropdown() {
return Object.keys(this.selectedTemplateType).length > 0;
},
@@ -26,6 +55,7 @@ export default {
...mapActions('fileTemplates', [
'setSelectedTemplateType',
'fetchTemplate',
+ 'fetchTemplateTypes',
'undoFileTemplate',
]),
setInitialType() {
@@ -50,27 +80,46 @@ export default {
<template>
<div
- class="d-flex align-items-center ide-file-templates qa-file-templates-bar gl-relative gl-z-index-1"
+ class="gl-display-flex gl-align-items-center ide-file-templates qa-file-templates-bar gl-relative gl-z-index-1"
>
- <strong class="gl-mr-3"> {{ __('File templates') }} </strong>
- <dropdown
- :data="templateTypes"
- :label="selectedTemplateType.name || __('Choose a type...')"
- class="mr-2"
- @click="selectTemplateType"
- />
- <dropdown
+ <strong class="gl-mr-3"> {{ $options.i18n.barLabel }} </strong>
+ <gl-dropdown
+ class="gl-mr-6"
+ :text="selectedTemplateType.name || $options.i18n.templateTypesDropdownLabel"
+ >
+ <gl-dropdown-item
+ v-for="template in templateTypes"
+ :key="template.key"
+ @click.prevent="selectTemplateType(template)"
+ >
+ {{ template.name }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ <gl-dropdown
v-if="showTemplatesDropdown"
- :label="__('Choose a template...')"
- :is-async-data="true"
- :searchable="true"
- :title="__('File templates')"
- class="mr-2 qa-file-template-dropdown"
- @click="selectTemplate"
- />
+ class="gl-mr-6 qa-file-template-dropdown"
+ :text="$options.i18n.templateListDropdownLabel"
+ @show="fetchTemplateTypes"
+ >
+ <template #header>
+ <gl-search-box-by-type v-model.trim="search" data-qa-selector="dropdown_filter_input" />
+ </template>
+ <div>
+ <gl-loading-icon v-if="isLoading" />
+ <template v-else>
+ <gl-dropdown-item
+ v-for="template in filteredTemplateTypes"
+ :key="template.key"
+ @click="selectTemplate(template)"
+ >
+ {{ template.name }}
+ </gl-dropdown-item>
+ </template>
+ </div>
+ </gl-dropdown>
<transition name="fade">
<gl-button v-show="updateSuccess" category="secondary" variant="default" @click="undo">
- {{ __('Undo') }}
+ {{ $options.i18n.undoButtonText }}
</gl-button>
</transition>
</div>
diff --git a/app/assets/javascripts/ide/components/file_templates/dropdown.vue b/app/assets/javascripts/ide/components/file_templates/dropdown.vue
index ec61e3374d7..e8b42ac9490 100644
--- a/app/assets/javascripts/ide/components/file_templates/dropdown.vue
+++ b/app/assets/javascripts/ide/components/file_templates/dropdown.vue
@@ -84,7 +84,7 @@ export default {
v-model="search"
:placeholder="__('Filter...')"
type="search"
- class="dropdown-input-field qa-dropdown-filter-input"
+ class="dropdown-input-field"
/>
<gl-icon name="search" class="dropdown-input-search" />
</div>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index 1c5a00568eb..e3c230f7660 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -6,6 +6,10 @@ import { __, sprintf } from '~/locale';
import { modalTypes } from '../../constants';
import { trimPathComponents, getPathParent } from '../../utils';
+const i18n = {
+ cancelButtonText: __('Cancel'),
+};
+
export default {
components: {
GlModal,
@@ -43,6 +47,18 @@ export default {
return __('Create file');
},
+ actionPrimary() {
+ return {
+ text: this.buttonLabel,
+ attributes: [{ variant: 'confirm' }],
+ };
+ },
+ actionCancel() {
+ return {
+ text: i18n.cancelButtonText,
+ attributes: [{ variant: 'default' }],
+ };
+ },
isCreatingNewFile() {
return this.modalType === modalTypes.blob;
},
@@ -136,11 +152,11 @@ export default {
data-qa-selector="new_file_modal"
data-testid="ide-new-entry"
:title="modalTitle"
- :ok-title="buttonLabel"
- ok-variant="success"
size="lg"
- @ok="submitForm"
- @hide="resetData"
+ :action-primary="actionPrimary"
+ :action-cancel="actionCancel"
+ @primary="submitForm"
+ @cancel="resetData"
>
<div class="form-group row">
<label class="label-bold col-form-label col-sm-2"> {{ __('Name') }} </label>
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index 05493db1dff..f14d86114b8 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -147,6 +147,9 @@ export default {
fileType() {
return this.previewMode?.id || '';
},
+ showTabs() {
+ return !this.shouldHideEditor && this.isEditModeActive && this.previewMode;
+ },
},
watch: {
'file.name': {
@@ -194,6 +197,9 @@ export default {
this.refreshEditorDimensions();
}
},
+ showTabs() {
+ this.$nextTick(() => this.refreshEditorDimensions());
+ },
rightPaneIsOpen() {
this.refreshEditorDimensions();
},
@@ -410,7 +416,7 @@ export default {
}
},
refreshEditorDimensions() {
- if (this.showEditor) {
+ if (this.showEditor && this.editor) {
this.editor.updateDimensions();
}
},
@@ -495,7 +501,7 @@ export default {
<template>
<div id="ide" class="blob-viewer-container blob-editor-container">
- <div v-if="!shouldHideEditor && isEditModeActive" class="ide-mode-tabs clearfix">
+ <div v-if="showTabs" class="ide-mode-tabs clearfix">
<ul class="nav-links float-left border-bottom-0">
<li :class="editTabCSS">
<a
@@ -506,7 +512,7 @@ export default {
>{{ __('Edit') }}</a
>
</li>
- <li v-if="previewMode" :class="previewTabCSS">
+ <li :class="previewTabCSS">
<a
href="javascript:void(0);"
role="button"
diff --git a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
index b9f0b5012ac..bd0f4cd5dd7 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
@@ -137,7 +137,7 @@ export default {
<gl-form-input
data-qa-selector="githubish_import_filter_field"
name="filter"
- :placeholder="__('Filter your repositories by name')"
+ :placeholder="__('Filter by name')"
autofocus
size="lg"
@keyup.enter="setFilter($event.target.value)"
diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue
index 7a904bdb6ad..324797ad645 100644
--- a/app/assets/javascripts/incidents/components/incidents_list.vue
+++ b/app/assets/javascripts/incidents/components/incidents_list.vue
@@ -1,5 +1,6 @@
<script>
import {
+ GlLink,
GlLoadingIcon,
GlTable,
GlAvatarsInline,
@@ -24,9 +25,11 @@ import {
} from '~/vue_shared/components/paginated_table_with_search_and_tabs/constants';
import PaginatedTableWithSearchAndTabs from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import {
I18N,
INCIDENT_STATUS_TABS,
+ ESCALATION_STATUSES,
TH_CREATED_AT_TEST_ID,
TH_INCIDENT_SLA_TEST_ID,
TH_SEVERITY_TEST_ID,
@@ -38,7 +41,7 @@ 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;
+const MAX_VISIBLE_ASSIGNEES = 3;
export default {
trackIncidentCreateNewOptions,
@@ -49,7 +52,7 @@ export default {
{
key: 'severity',
label: s__('IncidentManagement|Severity'),
- thClass: `${thClass} w-15p`,
+ thClass: `${thClass} gl-w-15p`,
tdClass: `${tdClass} sortable-cell`,
actualSortKey: 'SEVERITY',
sortable: true,
@@ -62,6 +65,12 @@ export default {
tdClass,
},
{
+ key: 'escalationStatus',
+ label: s__('IncidentManagement|Status'),
+ thClass: `${thClass} gl-w-eighth gl-pointer-events-none`,
+ tdClass,
+ },
+ {
key: 'createdAt',
label: s__('IncidentManagement|Date created'),
thClass: `${thClass} gl-w-eighth`,
@@ -73,7 +82,7 @@ export default {
{
key: 'incidentSla',
label: s__('IncidentManagement|Time to SLA'),
- thClass: `gl-text-right gl-w-eighth`,
+ thClass: `gl-text-right gl-w-10p`,
tdClass: `${tdClass} gl-text-right`,
thAttr: TH_INCIDENT_SLA_TEST_ID,
actualSortKey: 'SLA_DUE_AT',
@@ -83,13 +92,13 @@ export default {
{
key: 'assignees',
label: s__('IncidentManagement|Assignees'),
- thClass: 'gl-pointer-events-none w-15p',
+ thClass: 'gl-pointer-events-none gl-w-15',
tdClass,
},
{
key: 'published',
label: s__('IncidentManagement|Published'),
- thClass: `${thClass} w-15p`,
+ thClass: `${thClass} gl-w-15`,
tdClass: `${tdClass} sortable-cell`,
actualSortKey: 'PUBLISHED',
sortable: true,
@@ -98,6 +107,7 @@ export default {
],
MAX_VISIBLE_ASSIGNEES,
components: {
+ GlLink,
GlLoadingIcon,
GlTable,
GlAvatarsInline,
@@ -112,6 +122,7 @@ export default {
GlEmptyState,
SeverityToken,
PaginatedTableWithSearchAndTabs,
+ TooltipOnTruncate,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -129,6 +140,7 @@ export default {
'assigneeUsernameQuery',
'slaFeatureAvailable',
'canCreateIncident',
+ 'incidentEscalationsAvailable',
],
apollo: {
incidents: {
@@ -222,6 +234,7 @@ export default {
const isHidden = {
published: !this.publishedAvailable,
incidentSla: !this.slaFeatureAvailable,
+ escalationStatus: !this.incidentEscalationsAvailable,
};
return this.$options.fields.filter(({ key }) => !isHidden[key]);
@@ -260,7 +273,7 @@ export default {
return Boolean(assignees.nodes?.length);
},
navigateToIncidentDetails({ iid }) {
- return visitUrl(joinPaths(this.issuePath, INCIDENT_DETAILS_PATH, iid));
+ return visitUrl(this.showIncidentLink({ iid }));
},
navigateToCreateNewIncident() {
const { category, action } = this.$options.trackIncidentCreateNewOptions;
@@ -283,6 +296,12 @@ export default {
getSeverity(severity) {
return INCIDENT_SEVERITY[severity];
},
+ getEscalationStatus(escalationStatus) {
+ return ESCALATION_STATUSES[escalationStatus] || this.$options.i18n.noEscalationStatus;
+ },
+ showIncidentLink({ iid }) {
+ return joinPaths(this.issuePath, INCIDENT_DETAILS_PATH, iid);
+ },
pageChanged(pagination) {
this.pagination = pagination;
},
@@ -370,7 +389,14 @@ export default {
<template #cell(title)="{ item }">
<div :class="{ 'gl-display-flex gl-align-items-center': item.state === 'closed' }">
- <div class="gl-max-w-full text-truncate" :title="item.title">{{ item.title }}</div>
+ <gl-link
+ v-gl-tooltip
+ :title="item.title"
+ data-testid="incident-link"
+ :href="showIncidentLink(item)"
+ >
+ {{ item.title }}
+ </gl-link>
<gl-icon
v-if="item.state === 'closed'"
name="issue-close"
@@ -381,8 +407,21 @@ export default {
</div>
</template>
+ <template v-if="incidentEscalationsAvailable" #cell(escalationStatus)="{ item }">
+ <tooltip-on-truncate
+ :title="getEscalationStatus(item.escalationStatus)"
+ data-testid="incident-escalation-status"
+ class="gl-display-block gl-text-truncate"
+ >
+ {{ getEscalationStatus(item.escalationStatus) }}
+ </tooltip-on-truncate>
+ </template>
+
<template #cell(createdAt)="{ item }">
- <time-ago-tooltip :time="item.createdAt" />
+ <time-ago-tooltip
+ :time="item.createdAt"
+ class="gl-display-block gl-max-w-full gl-text-truncate"
+ />
</template>
<template v-if="slaFeatureAvailable" #cell(incidentSla)="{ item }">
@@ -392,6 +431,7 @@ export default {
:project-path="projectPath"
:sla-due-at="item.slaDueAt"
data-testid="incident-sla"
+ class="gl-display-block gl-max-w-full gl-text-truncate"
/>
</template>
@@ -432,6 +472,7 @@ export default {
:un-published="$options.i18n.unPublished"
/>
</template>
+
<template #table-busy>
<gl-loading-icon size="lg" color="dark" class="mt-3" />
</template>
diff --git a/app/assets/javascripts/incidents/constants.js b/app/assets/javascripts/incidents/constants.js
index 23909ae3b6c..21cdbef05a1 100644
--- a/app/assets/javascripts/incidents/constants.js
+++ b/app/assets/javascripts/incidents/constants.js
@@ -7,6 +7,7 @@ export const I18N = {
unassigned: s__('IncidentManagement|Unassigned'),
createIncidentBtnLabel: s__('IncidentManagement|Create incident'),
unPublished: s__('IncidentManagement|Unpublished'),
+ noEscalationStatus: s__('IncidentManagement|None'),
emptyState: {
title: s__('IncidentManagement|Display your incidents in a dedicated view'),
emptyClosedTabTitle: s__('IncidentManagement|There are no closed incidents'),
@@ -37,6 +38,12 @@ export const INCIDENT_STATUS_TABS = [
},
];
+export const ESCALATION_STATUSES = {
+ TRIGGERED: s__('AlertManagement|Triggered'),
+ ACKNOWLEDGED: s__('AlertManagement|Acknowledged'),
+ RESOLVED: s__('AlertManagement|Resolved'),
+};
+
export const DEFAULT_PAGE_SIZE = 20;
export const TH_CREATED_AT_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' };
export const TH_SEVERITY_TEST_ID = { 'data-testid': 'incident-management-severity-sort' };
diff --git a/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql b/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql
index faa68d37088..b72941966c6 100644
--- a/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql
+++ b/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql
@@ -1,4 +1,5 @@
# eslint-disable-next-line @graphql-eslint/require-id-when-available
fragment IncidentFields on Issue {
severity
+ escalationStatus
}
diff --git a/app/assets/javascripts/incidents/list.js b/app/assets/javascripts/incidents/list.js
index 1d40f1093a4..c0f16a43d5c 100644
--- a/app/assets/javascripts/incidents/list.js
+++ b/app/assets/javascripts/incidents/list.js
@@ -46,6 +46,7 @@ export default () => {
assigneeUsernameQuery,
slaFeatureAvailable: parseBoolean(slaFeatureAvailable),
canCreateIncident: parseBoolean(canCreateIncident),
+ incidentEscalationsAvailable: parseBoolean(gon?.features?.incidentEscalations),
},
apolloProvider,
render(createElement) {
diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js
index 004601bc0a3..c5ed5bb08a9 100644
--- a/app/assets/javascripts/integrations/constants.js
+++ b/app/assets/javascripts/integrations/constants.js
@@ -1,6 +1,7 @@
import { s__, __ } from '~/locale';
export const integrationLevels = {
+ PROJECT: 'project',
GROUP: 'group',
INSTANCE: 'instance',
};
@@ -24,3 +25,15 @@ 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 integrationFormSections = {
+ CONNECTION: 'connection',
+ JIRA_TRIGGER: 'jira_trigger',
+ JIRA_ISSUES: 'jira_issues',
+};
+
+export const integrationFormSectionComponents = {
+ [integrationFormSections.CONNECTION]: 'IntegrationSectionConnection',
+ [integrationFormSections.JIRA_TRIGGER]: 'IntegrationSectionJiraTrigger',
+ [integrationFormSections.JIRA_ISSUES]: 'IntegrationSectionJiraIssues',
+};
diff --git a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
index 5ddf3aeb639..a4415a5a2b3 100644
--- a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
+++ b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
@@ -1,6 +1,6 @@
<script>
import { GlFormGroup, GlFormCheckbox } from '@gitlab/ui';
-import { mapGetters } from 'vuex';
+import { mapGetters, mapState } from 'vuex';
export default {
name: 'ActiveCheckbox',
@@ -15,6 +15,10 @@ export default {
},
computed: {
...mapGetters(['isInheriting', 'propsSource']),
+ ...mapState(['customState']),
+ disabled() {
+ return this.isInheriting || this.customState.activateDisabled;
+ },
},
mounted() {
this.activated = this.propsSource.initialActivated;
@@ -34,7 +38,7 @@ export default {
<gl-form-checkbox
v-model="activated"
class="gl-display-block"
- :disabled="isInheriting"
+ :disabled="disabled"
@change="onChange"
>
{{ __('Active') }}
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index 007a384f41e..6e89872ff68 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -3,12 +3,14 @@ import { GlButton, GlModalDirective, GlSafeHtmlDirective as SafeHtml, GlForm } f
import axios from 'axios';
import * as Sentry from '@sentry/browser';
import { mapState, mapActions, mapGetters } from 'vuex';
+
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE,
I18N_DEFAULT_ERROR_MESSAGE,
I18N_SUCCESSFUL_CONNECTION_MESSAGE,
integrationLevels,
+ integrationFormSectionComponents,
} from '~/integrations/constants';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
import csrf from '~/lib/utils/csrf';
@@ -33,6 +35,18 @@ export default {
DynamicField,
ConfirmationModal,
ResetConfirmationModal,
+ IntegrationSectionConnection: () =>
+ import(
+ /* webpackChunkName: 'integrationSectionConnection' */ '~/integrations/edit/components/sections/connection.vue'
+ ),
+ IntegrationSectionJiraIssues: () =>
+ import(
+ /* webpackChunkName: 'integrationSectionJiraIssues' */ '~/integrations/edit/components/sections/jira_issues.vue'
+ ),
+ IntegrationSectionJiraTrigger: () =>
+ import(
+ /* webpackChunkName: 'integrationSectionJiraTrigger' */ '~/integrations/edit/components/sections/jira_trigger.vue'
+ ),
GlButton,
GlForm,
},
@@ -41,10 +55,13 @@ export default {
SafeHtml,
},
mixins: [glFeatureFlagsMixin()],
- props: {
+ provide() {
+ return {
+ hasSections: this.hasSections,
+ };
+ },
+ inject: {
helpHtml: {
- type: String,
- required: false,
default: '',
},
},
@@ -81,28 +98,42 @@ export default {
disableButtons() {
return Boolean(this.isSaving || this.isResetting || this.isTesting);
},
- form() {
- return this.$refs.integrationForm.$el;
+ sectionsEnabled() {
+ return this.glFeatures.integrationFormSections;
+ },
+ hasSections() {
+ return this.sectionsEnabled && this.customState.sections.length !== 0;
+ },
+ fieldsWithoutSection() {
+ return this.sectionsEnabled
+ ? this.propsSource.fields.filter((field) => !field.section)
+ : this.propsSource.fields;
},
},
methods: {
...mapActions(['setOverride', 'requestJiraIssueTypes']),
+ fieldsForSection(section) {
+ return this.propsSource.fields.filter((field) => field.section === section.type);
+ },
+ form() {
+ return this.$refs.integrationForm.$el;
+ },
setIsValidated() {
this.isValidated = true;
},
onSaveClick() {
this.isSaving = true;
- if (this.integrationActive && !this.form.checkValidity()) {
+ if (this.integrationActive && !this.form().checkValidity()) {
this.isSaving = false;
this.setIsValidated();
return;
}
- this.form.submit();
+ this.form().submit();
},
onTestClick() {
- if (!this.form.checkValidity()) {
+ if (!this.form().checkValidity()) {
this.setIsValidated();
return;
}
@@ -147,7 +178,7 @@ export default {
this.requestJiraIssueTypes(this.getFormData());
},
getFormData() {
- return new FormData(this.form);
+ return new FormData(this.form());
},
onToggleIntegrationState(integrationActive) {
this.integrationActive = integrationActive;
@@ -159,6 +190,7 @@ export default {
FORBID_ATTR: [], // This is trusted input so we can override the default config to allow data-* attributes
},
csrf,
+ integrationFormSectionComponents,
};
</script>
@@ -187,46 +219,75 @@ export default {
@change="setOverride"
/>
+ <template v-if="hasSections">
+ <div
+ v-for="(section, index) in customState.sections"
+ :key="section.type"
+ :class="{ 'gl-border-b gl-pb-3 gl-mb-6': index !== customState.sections.length - 1 }"
+ data-testid="integration-section"
+ >
+ <div class="row">
+ <div class="col-lg-4">
+ <h4 class="gl-mt-0">{{ section.title }}</h4>
+ <p v-safe-html="section.description"></p>
+ </div>
+
+ <div class="col-lg-8">
+ <component
+ :is="$options.integrationFormSectionComponents[section.type]"
+ :fields="fieldsForSection(section)"
+ :is-validated="isValidated"
+ @toggle-integration-active="onToggleIntegrationState"
+ @request-jira-issue-types="onRequestJiraIssueTypes"
+ />
+ </div>
+ </div>
+ </div>
+ </template>
+
<div class="row">
<div class="col-lg-4"></div>
<div class="col-lg-8">
<!-- helpHtml is trusted input -->
- <div v-if="helpHtml" v-safe-html:[$options.helpHtmlConfig]="helpHtml"></div>
+ <div v-if="helpHtml && !hasSections" v-safe-html:[$options.helpHtmlConfig]="helpHtml"></div>
<active-checkbox
- v-if="propsSource.showActive"
+ v-if="propsSource.showActive && !hasSections"
:key="`${currentKey}-active-checkbox`"
@toggle-integration-active="onToggleIntegrationState"
/>
<jira-trigger-fields
- v-if="isJira"
+ v-if="isJira && !hasSections"
:key="`${currentKey}-jira-trigger-fields`"
v-bind="propsSource.triggerFieldsProps"
:is-validated="isValidated"
/>
<trigger-fields
- v-else-if="propsSource.triggerEvents.length"
+ v-else-if="propsSource.triggerEvents.length && !hasSections"
:key="`${currentKey}-trigger-fields`"
:events="propsSource.triggerEvents"
:type="propsSource.type"
/>
<dynamic-field
- v-for="field in propsSource.fields"
+ v-for="field in fieldsWithoutSection"
:key="`${currentKey}-${field.name}`"
v-bind="field"
:is-validated="isValidated"
/>
<jira-issues-fields
- v-if="isJira && !isInstanceOrGroupLevel"
+ v-if="isJira && !isInstanceOrGroupLevel && !hasSections"
:key="`${currentKey}-jira-issues-fields`"
v-bind="propsSource.jiraIssuesProps"
:is-validated="isValidated"
@request-jira-issue-types="onRequestJiraIssueTypes"
/>
+ </div>
+ </div>
+ <div v-if="isEditable" class="row">
+ <div :class="hasSections ? 'col' : 'col-lg-8 offset-lg-4'">
<div
- v-if="isEditable"
class="footer-block row-content-block gl-display-flex gl-justify-content-space-between"
>
<div>
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 7f2f7620a86..7cf8e11f162 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
@@ -16,6 +16,11 @@ export default {
JiraIssueCreationVulnerabilities: () =>
import('ee_component/integrations/edit/components/jira_issue_creation_vulnerabilities.vue'),
},
+ inject: {
+ hasSections: {
+ default: false,
+ },
+ },
props: {
showJiraIssuesIntegration: {
type: Boolean,
@@ -83,17 +88,17 @@ export default {
i18n: {
sectionTitle: s__('JiraService|View Jira issues in GitLab'),
sectionDescription: s__(
- 'JiraService|Work on Jira issues without leaving GitLab. Adds a Jira menu to access your list of Jira issues and view any issue as read-only.',
+ 'JiraService|Work on Jira issues without leaving GitLab. Add a Jira menu to access a read-only list of your Jira issues.',
),
enableCheckboxLabel: s__('JiraService|Enable Jira issues'),
enableCheckboxHelp: s__(
- 'JiraService|Warning: All GitLab users that have access to this GitLab project are able to view all issues from the Jira project specified below.',
+ 'JiraService|Warning: All GitLab users with access to this GitLab project can view all issues from the Jira project you select.',
),
projectKeyLabel: s__('JiraService|Jira project key'),
projectKeyPlaceholder: s__('JiraService|For example, AB'),
requiredFieldFeedback: __('This field is required.'),
issueTrackerConflictWarning: s__(
- 'JiraService|Displaying Jira issues while leaving the GitLab issue functionality enabled might be confusing. Consider %{linkStart}disabling GitLab issues%{linkEnd} if they won’t otherwise be used.',
+ 'JiraService|Displaying Jira issues while leaving GitLab issues also enabled might be confusing. Consider %{linkStart}disabling GitLab issues%{linkEnd} if they won’t otherwise be used.',
),
},
};
@@ -101,9 +106,12 @@ export default {
<template>
<div>
- <gl-form-group :label="$options.i18n.sectionTitle" label-for="jira-issue-settings">
+ <gl-form-group
+ :label="hasSections ? null : $options.i18n.sectionTitle"
+ label-for="jira-issue-settings"
+ >
<div id="jira-issue-settings">
- <p>
+ <p v-if="!hasSections">
{{ $options.i18n.sectionDescription }}
</p>
<template v-if="showJiraIssuesIntegration">
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 df5946b814a..3c06660e7c5 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
@@ -62,6 +62,11 @@ export default {
GlLink,
GlSprintf,
},
+ inject: {
+ hasSections: {
+ default: false,
+ },
+ },
props: {
initialTriggerCommit: {
type: Boolean,
@@ -134,12 +139,14 @@ export default {
<template>
<div>
<gl-form-group
- :label="__('Trigger')"
+ :label="hasSections ? null : __('Trigger')"
label-for="service[trigger]"
:description="
- s__(
- 'Integrations|When you mention a Jira issue in a commit or merge request, GitLab creates a remote link and comment (if enabled).',
- )
+ hasSections
+ ? null
+ : s__(
+ 'JiraService|When a Jira issue is mentioned in a commit or merge request, a remote link and comment (if enabled) will be created.',
+ )
"
>
<input name="service[commit_events]" type="hidden" :value="triggerCommit || false" />
diff --git a/app/assets/javascripts/integrations/edit/components/sections/connection.vue b/app/assets/javascripts/integrations/edit/components/sections/connection.vue
new file mode 100644
index 00000000000..364e9324e43
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/sections/connection.vue
@@ -0,0 +1,45 @@
+<script>
+import { mapGetters } from 'vuex';
+
+import ActiveCheckbox from '../active_checkbox.vue';
+import DynamicField from '../dynamic_field.vue';
+
+export default {
+ name: 'IntegrationSectionConnection',
+ components: {
+ ActiveCheckbox,
+ DynamicField,
+ },
+ props: {
+ fields: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ isValidated: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ ...mapGetters(['currentKey', 'propsSource']),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <active-checkbox
+ v-if="propsSource.showActive"
+ :key="`${currentKey}-active-checkbox`"
+ @toggle-integration-active="$emit('toggle-integration-active', $event)"
+ />
+ <dynamic-field
+ v-for="field in fields"
+ :key="`${currentKey}-${field.name}`"
+ v-bind="field"
+ :is-validated="isValidated"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/components/sections/jira_issues.vue b/app/assets/javascripts/integrations/edit/components/sections/jira_issues.vue
new file mode 100644
index 00000000000..75202209d38
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/sections/jira_issues.vue
@@ -0,0 +1,33 @@
+<script>
+import { mapGetters } from 'vuex';
+
+import JiraIssuesFields from '../jira_issues_fields.vue';
+
+export default {
+ name: 'IntegrationSectionJiraIssues',
+ components: {
+ JiraIssuesFields,
+ },
+ props: {
+ isValidated: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ ...mapGetters(['currentKey', 'propsSource']),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <jira-issues-fields
+ :key="`${currentKey}-jira-issues-fields`"
+ v-bind="propsSource.jiraIssuesProps"
+ :is-validated="isValidated"
+ @request-jira-issue-types="$emit('request-jira-issue-types')"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/components/sections/jira_trigger.vue b/app/assets/javascripts/integrations/edit/components/sections/jira_trigger.vue
new file mode 100644
index 00000000000..f36d3b1fbda
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/sections/jira_trigger.vue
@@ -0,0 +1,32 @@
+<script>
+import { mapGetters } from 'vuex';
+
+import JiraTriggerFields from '../jira_trigger_fields.vue';
+
+export default {
+ name: 'IntegrationSectionJiraTrigger',
+ components: {
+ JiraTriggerFields,
+ },
+ props: {
+ isValidated: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ ...mapGetters(['currentKey', 'propsSource']),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <jira-trigger-fields
+ :key="`${currentKey}-jira-trigger-fields`"
+ v-bind="propsSource.triggerFieldsProps"
+ :is-validated="isValidated"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
index 433fe21ad76..92042a5c981 100644
--- a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
@@ -1,6 +1,5 @@
<script>
import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui';
-import { startCase } from 'lodash';
import { mapGetters } from 'vuex';
import { __ } from '~/locale';
@@ -45,7 +44,6 @@ export default {
fieldName(name) {
return `service[${name}]`;
},
- startCase,
},
};
</script>
@@ -58,10 +56,10 @@ export default {
data-testid="trigger-fields-group"
>
<div id="trigger-fields" class="gl-pt-3">
- <gl-form-group v-for="event in events" :key="event.title" :description="event.description">
+ <gl-form-group v-for="event in events" :key="event.name" :description="event.description">
<input :name="checkboxName(event.name)" type="hidden" :value="event.value || false" />
<gl-form-checkbox v-model="event.value" :disabled="isInheriting">
- {{ startCase(event.title) }}
+ {{ event.title }}
</gl-form-checkbox>
<gl-form-input
v-if="event.field"
diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js
index fbda8c1e3d0..3e58dd0be99 100644
--- a/app/assets/javascripts/integrations/edit/index.js
+++ b/app/assets/javascripts/integrations/edit/index.js
@@ -22,6 +22,7 @@ function parseDatasetToProps(data) {
editProjectPath,
learnMorePath,
triggerEvents,
+ sections,
fields,
inheritFromId,
integrationLevel,
@@ -38,6 +39,7 @@ function parseDatasetToProps(data) {
const {
showActive,
activated,
+ activateDisabled,
editable,
canTest,
commitEvents,
@@ -53,6 +55,7 @@ function parseDatasetToProps(data) {
return {
initialActivated: activated,
showActive,
+ activateDisabled,
type,
cancelPath,
editable,
@@ -81,6 +84,7 @@ function parseDatasetToProps(data) {
},
learnMorePath,
triggerEvents: JSON.parse(triggerEvents),
+ sections: JSON.parse(sections, { deep: true }),
fields: convertObjectPropsToCamelCase(JSON.parse(fields), { deep: true }),
inheritFromId: parseInt(inheritFromId, 10),
integrationLevel,
@@ -114,13 +118,13 @@ export default function initIntegrationSettingsForm() {
return new Vue({
el: customSettingsEl,
+ name: 'IntegrationEditRoot',
store: createStore(initialState),
+ provide: {
+ helpHtml,
+ },
render(createElement) {
- return createElement(IntegrationForm, {
- props: {
- helpHtml,
- },
- });
+ return createElement(IntegrationForm);
},
});
}
diff --git a/app/assets/javascripts/integrations/edit/store/getters.js b/app/assets/javascripts/integrations/edit/store/getters.js
index b79132128cc..b0adc444395 100644
--- a/app/assets/javascripts/integrations/edit/store/getters.js
+++ b/app/assets/javascripts/integrations/edit/store/getters.js
@@ -1,5 +1,10 @@
+import { integrationLevels } from '~/integrations/constants';
+
export const isInheriting = (state) => (state.defaultState === null ? false : !state.override);
+export const isProjectLevel = (state) =>
+ state.customState.integrationLevel === integrationLevels.PROJECT;
+
export const propsSource = (state, getters) =>
getters.isInheriting ? state.defaultState : state.customState;
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 c08a4d75c59..424a9d3fabd 100644
--- a/app/assets/javascripts/invite_members/components/invite_group_trigger.vue
+++ b/app/assets/javascripts/invite_members/components/invite_group_trigger.vue
@@ -28,7 +28,12 @@ export default {
</script>
<template>
- <gl-button :class="classes" data-qa-selector="invite_a_group_button" @click="openModal">
+ <gl-button
+ :class="classes"
+ data-qa-selector="invite_a_group_button"
+ data-test-id="invite-group-button"
+ @click="openModal"
+ >
{{ displayText }}
</gl-button>
</template>
diff --git a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
index 6598000c464..f266d978ffa 100644
--- a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
@@ -4,6 +4,7 @@ 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 { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message';
import GroupSelect from './group_select.vue';
import InviteModalBase from './invite_modal_base.vue';
@@ -55,6 +56,8 @@ export default {
},
data() {
return {
+ invalidFeedbackMessage: '',
+ isLoading: false,
modalId: uniqueId('invite-groups-modal-'),
groupToBeSharedWith: {},
};
@@ -83,13 +86,19 @@ export default {
});
},
methods: {
+ showInvalidFeedbackMessage(response) {
+ this.invalidFeedbackMessage = getInvalidFeedbackMessage(response);
+ },
openModal() {
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
closeModal() {
this.$root.$emit(BV_HIDE_MODAL, this.modalId);
},
- sendInvite({ onError, onSuccess, data: { accessLevel, expiresAt } }) {
+ sendInvite({ accessLevel, expiresAt }) {
+ this.invalidFeedbackMessage = '';
+ this.isLoading = true;
+
const apiShareWithGroup = this.isProject
? Api.projectShareWithGroup.bind(Api)
: Api.groupShareWithGroup.bind(Api);
@@ -101,18 +110,27 @@ export default {
expires_at: expiresAt,
})
.then(() => {
- onSuccess();
this.showSuccessMessage();
})
- .catch(onError);
+ .catch((e) => {
+ this.showInvalidFeedbackMessage(e);
+ })
+ .finally(() => {
+ this.isLoading = false;
+ });
},
resetFields() {
+ this.invalidFeedbackMessage = '';
+ this.isLoading = false;
this.groupToBeSharedWith = {};
},
showSuccessMessage() {
this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
this.closeModal();
},
+ clearValidation() {
+ this.invalidFeedbackMessage = '';
+ },
},
labels: GROUP_MODAL_LABELS,
};
@@ -129,10 +147,12 @@ export default {
:label-intro-text="labelIntroText"
:label-search-field="$options.labels.searchField"
:submit-disabled="inviteDisabled"
+ :invalid-feedback-message="invalidFeedbackMessage"
+ :is-loading="isLoading"
@reset="resetFields"
@submit="sendInvite"
>
- <template #select="{ clearValidation }">
+ <template #select>
<group-select
v-model="groupToBeSharedWith"
:access-levels="accessLevels"
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 6c0fc5caf26..be48a58d838 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -21,6 +21,7 @@ import {
} from '../constants';
import eventHub from '../event_hub';
import { responseMessageFromSuccess } from '../utils/response_message_parser';
+import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message';
import ModalConfetti from './confetti.vue';
import MembersTokenSelect from './members_token_select.vue';
@@ -84,6 +85,8 @@ export default {
},
data() {
return {
+ invalidFeedbackMessage: '',
+ isLoading: false,
modalId: uniqueId('invite-members-modal-'),
newUsersToInvite: [],
selectedTasksToBeDone: [],
@@ -152,6 +155,9 @@ export default {
}
},
methods: {
+ showInvalidFeedbackMessage(response) {
+ this.invalidFeedbackMessage = getInvalidFeedbackMessage(response);
+ },
partitionNewUsersToInvite() {
const [usersToInviteByEmail, usersToAddById] = partition(
this.newUsersToInvite,
@@ -176,7 +182,10 @@ export default {
const tracking = new ExperimentTracking(experimentName);
tracking.event(eventName);
},
- sendInvite({ onError, onSuccess, data: { accessLevel, expiresAt } }) {
+ sendInvite({ accessLevel, expiresAt }) {
+ this.isLoading = true;
+ this.invalidFeedbackMessage = '';
+
const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
const promises = [];
const baseData = {
@@ -220,19 +229,17 @@ export default {
const message = responseMessageFromSuccess(responses);
if (message) {
- onError({
- response: {
- data: {
- message,
- },
- },
+ this.showInvalidFeedbackMessage({
+ response: { data: { message } },
});
} else {
- onSuccess();
this.showSuccessMessage();
}
})
- .catch(onError);
+ .catch((e) => this.showInvalidFeedbackMessage(e))
+ .finally(() => {
+ this.isLoading = false;
+ });
},
trackinviteMembersForTask() {
const label = 'selected_tasks_to_be_done';
@@ -241,6 +248,8 @@ export default {
tracking.event(INVITE_MEMBERS_FOR_TASK.submit);
},
resetFields() {
+ this.isLoading = false;
+ this.invalidFeedbackMessage = '';
this.newUsersToInvite = [];
this.selectedTasksToBeDone = [];
[this.selectedTaskProject] = this.projects;
@@ -260,6 +269,9 @@ export default {
onAccessLevelUpdate(val) {
this.selectedAccessLevel = val;
},
+ clearValidation() {
+ this.invalidFeedbackMessage = '';
+ },
},
labels: MEMBER_MODAL_LABELS,
};
@@ -276,6 +288,8 @@ export default {
:label-search-field="$options.labels.searchField"
:form-group-description="$options.labels.placeHolder"
:submit-disabled="inviteDisabled"
+ :invalid-feedback-message="invalidFeedbackMessage"
+ :is-loading="isLoading"
@reset="resetFields"
@submit="sendInvite"
@access-level="onAccessLevelUpdate"
@@ -288,7 +302,7 @@ export default {
<span v-if="isCelebration">{{ $options.labels.modal.celebrate.intro }} </span>
<modal-confetti v-if="isCelebration" />
</template>
- <template #select="{ clearValidation, validationState, labelId }">
+ <template #select="{ validationState, labelId }">
<members-token-select
v-model="newUsersToInvite"
class="gl-mb-2"
diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
index fc00f5b9343..bafbe94b8bd 100644
--- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue
+++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
@@ -10,19 +10,27 @@ import {
GlButton,
GlFormInput,
} from '@gitlab/ui';
-import { unescape } from 'lodash';
-import { sanitize } from '~/lib/dompurify';
import { sprintf } from '~/locale';
+import ContentTransition from '~/vue_shared/components/content_transition.vue';
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';
+
+const DEFAULT_SLOT = 'default';
+const DEFAULT_SLOTS = [
+ {
+ key: DEFAULT_SLOT,
+ attributes: {
+ class: 'invite-modal-content',
+ 'data-testid': 'invite-modal-initial-content',
+ },
+ },
+];
export default {
components: {
@@ -35,6 +43,7 @@ export default {
GlSprintf,
GlButton,
GlFormInput,
+ ContentTransition,
},
inheritAttrs: false,
props: {
@@ -80,14 +89,37 @@ export default {
required: false,
default: false,
},
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ invalidFeedbackMessage: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ submitButtonText: {
+ type: String,
+ required: false,
+ default: INVITE_BUTTON_TEXT,
+ },
+ currentSlot: {
+ type: String,
+ required: false,
+ default: DEFAULT_SLOT,
+ },
+ extraSlots: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
data() {
// Be sure to check out reset!
return {
- invalidFeedbackMessage: '',
selectedAccessLevel: this.defaultAccessLevel,
selectedDate: undefined,
- isLoading: false,
minDate: new Date(),
};
},
@@ -106,6 +138,9 @@ export default {
(key) => this.accessLevels[key] === Number(this.selectedAccessLevel),
);
},
+ contentSlots() {
+ return [...DEFAULT_SLOTS, ...(this.extraSlots || [])];
+ },
},
watch: {
selectedAccessLevel: {
@@ -116,16 +151,9 @@ export default {
},
},
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;
@@ -135,33 +163,15 @@ export default {
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,
- },
+ accessLevel: this.selectedAccessLevel,
+ expiresAt: this.selectedDate,
});
},
- unescapeMsg(message) {
- return unescape(sanitize(message, { ALLOWED_TAGS: [] }));
- },
},
HEADER_CLOSE_LABEL,
ACCESS_EXPIRE_DATE,
@@ -169,6 +179,7 @@ export default {
READ_MORE_TEXT,
INVITE_BUTTON_TEXT,
CANCEL_BUTTON_TEXT,
+ DEFAULT_SLOT,
};
</script>
@@ -185,91 +196,105 @@ export default {
@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"
+ <content-transition
+ class="gl-display-grid"
+ transition-name="invite-modal-transition"
+ :slots="contentSlots"
+ :current-slot="currentSlot"
>
- <label :id="selectLabelId" class="col-form-label">{{ labelSearchField }}</label>
- <slot
- name="select"
- v-bind="{ clearValidation, validationState, labelId: selectLabelId }"
- ></slot>
- </gl-form-group>
+ <template #[$options.DEFAULT_SLOT]>
+ <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>
- <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>
+ <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="{ validationState, labelId: selectLabelId }"></slot>
+ </gl-form-group>
- <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-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>
- <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>
+ <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>
+ <template v-for="{ key } in extraSlots" #[key]>
+ <slot :name="key"></slot>
+ </template>
+ </content-transition>
<template #modal-footer>
- <gl-button data-testid="cancel-button" @click="closeModal">
- {{ $options.CANCEL_BUTTON_TEXT }}
- </gl-button>
+ <slot name="cancel-button">
+ <gl-button data-testid="cancel-button" @click="closeModal">
+ {{ $options.CANCEL_BUTTON_TEXT }}
+ </gl-button>
+ </slot>
<gl-button
:disabled="submitDisabled"
:loading="isLoading"
- variant="success"
+ variant="confirm"
data-qa-selector="invite_button"
data-testid="invite-button"
@click="submit"
>
- {{ $options.INVITE_BUTTON_TEXT }}
+ {{ submitButtonText }}
</gl-button>
</template>
</gl-modal>
diff --git a/app/assets/javascripts/invite_members/init_invite_members_form.js b/app/assets/javascripts/invite_members/init_invite_members_form.js
deleted file mode 100644
index 5f8688755ba..00000000000
--- a/app/assets/javascripts/invite_members/init_invite_members_form.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import { disableButtonIfEmptyField } from '~/lib/utils/common_utils';
-
-// This is only used when `invite_members_group_modal` feature flag is disabled.
-// This file can be removed when `invite_members_group_modal` feature flag is removed
-export default () => {
- disableButtonIfEmptyField('#user_ids', 'input[name=commit]', 'change');
-};
diff --git a/app/assets/javascripts/invite_members/utils/get_invalid_feedback_message.js b/app/assets/javascripts/invite_members/utils/get_invalid_feedback_message.js
new file mode 100644
index 00000000000..62f66d009dc
--- /dev/null
+++ b/app/assets/javascripts/invite_members/utils/get_invalid_feedback_message.js
@@ -0,0 +1,12 @@
+import { unescape } from 'lodash';
+import { sanitize } from '~/lib/dompurify';
+import { INVALID_FEEDBACK_MESSAGE_DEFAULT } from '../constants';
+import { responseMessageFromError } from './response_message_parser';
+
+const unescapeMsg = (message) => unescape(sanitize(message, { ALLOWED_TAGS: [] }));
+
+export const getInvalidFeedbackMessage = (response) => {
+ const message = unescapeMsg(responseMessageFromError(response));
+
+ return message || INVALID_FEEDBACK_MESSAGE_DEFAULT;
+};
diff --git a/app/assets/javascripts/issuable/components/issuable_by_email.vue b/app/assets/javascripts/issuable/components/issuable_by_email.vue
index 512fa6f8c68..fcebae3af71 100644
--- a/app/assets/javascripts/issuable/components/issuable_by_email.vue
+++ b/app/assets/javascripts/issuable/components/issuable_by_email.vue
@@ -65,7 +65,6 @@ export default {
const body = sprintf(__('Enter the %{name} description'), {
name: this.issuableName,
});
- // eslint-disable-next-line @gitlab/require-i18n-strings
return `mailto:${this.email}?subject=${subject}&body=${body}`;
},
},
diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js
index a3752c7043c..247f8dd0bd6 100644
--- a/app/assets/javascripts/issues/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js
@@ -10,6 +10,7 @@ import ISetter from '~/filtered_search/droplab/plugins/input_setter';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __, sprintf } from '~/locale';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
// Todo: Remove this when fixing issue in input_setter plugin
const InputSetter = { ...ISetter };
@@ -171,12 +172,21 @@ export default class CreateMergeRequestDropdown {
this.isCreatingMergeRequest = true;
return this.createBranch().then(() => {
- window.location.href = canCreateConfidentialMergeRequest()
+ let path = canCreateConfidentialMergeRequest()
? this.createMrPath.replace(
this.projectPath,
confidentialMergeRequestState.selectedProject.pathWithNamespace,
)
: this.createMrPath;
+ path = mergeUrlParams(
+ {
+ 'merge_request[target_branch]': this.refInput.value,
+ 'merge_request[source_branch]': this.branchInput.value,
+ },
+ path,
+ );
+
+ window.location.href = path;
});
});
}
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 3866a7b3305..a532fa5b771 100644
--- a/app/assets/javascripts/issues/list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -39,8 +39,11 @@ import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/con
import {
CREATED_DESC,
i18n,
+ ISSUE_REFERENCE,
MAX_LIST_SIZE,
PAGE_SIZE,
+ PARAM_PAGE_AFTER,
+ PARAM_PAGE_BEFORE,
PARAM_STATE,
RELATIVE_POSITION_ASC,
TOKEN_TYPE_ASSIGNEE,
@@ -134,6 +137,8 @@ export default {
},
},
data() {
+ const pageAfter = getParameterByName(PARAM_PAGE_AFTER);
+ const pageBefore = getParameterByName(PARAM_PAGE_BEFORE);
const state = getParameterByName(PARAM_STATE);
const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC;
const dashboardSortKey = getSortKey(this.initialSort);
@@ -165,7 +170,7 @@ export default {
issuesCounts: {},
issuesError: null,
pageInfo: {},
- pageParams: getInitialPageParams(sortKey),
+ pageParams: getInitialPageParams(sortKey, pageAfter, pageBefore),
showBulkEditSidebar: false,
sortKey,
state: state || IssuableStates.Opened,
@@ -219,11 +224,13 @@ export default {
},
computed: {
queryVariables() {
+ const isIidSearch = ISSUE_REFERENCE.test(this.searchQuery);
return {
fullPath: this.fullPath,
+ iid: isIidSearch ? this.searchQuery.slice(1) : undefined,
isProject: this.isProject,
isSignedIn: this.isSignedIn,
- search: this.searchQuery,
+ search: isIidSearch ? undefined : this.searchQuery,
sort: this.sortKey,
state: this.state,
...this.pageParams,
@@ -234,7 +241,12 @@ export default {
return this.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP;
},
hasSearch() {
- return this.searchQuery || Object.keys(this.urlFilterParams).length;
+ return (
+ this.searchQuery ||
+ Object.keys(this.urlFilterParams).length ||
+ this.pageParams.afterCursor ||
+ this.pageParams.beforeCursor
+ );
},
isBulkEditButtonDisabled() {
return this.showBulkEditSidebar || !this.issues.length;
@@ -391,6 +403,8 @@ export default {
},
urlParams() {
return {
+ page_after: this.pageParams.afterCursor,
+ page_before: this.pageParams.beforeCursor,
search: this.searchQuery,
sort: urlSortParams[this.sortKey],
state: this.state,
diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js
index 284167a933f..4b07a078512 100644
--- a/app/assets/javascripts/issues/list/constants.js
+++ b/app/assets/javascripts/issues/list/constants.js
@@ -52,20 +52,15 @@ export const i18n = {
upvotes: __('Upvotes'),
};
+export const ISSUE_REFERENCE = /^#\d+$/;
export const MAX_LIST_SIZE = 10;
export const PAGE_SIZE = 20;
export const PAGE_SIZE_MANUAL = 100;
+export const PARAM_PAGE_AFTER = 'page_after';
+export const PARAM_PAGE_BEFORE = 'page_before';
export const PARAM_STATE = 'state';
export const RELATIVE_POSITION = 'relative_position';
-export const defaultPageSizeParams = {
- firstPageSize: PAGE_SIZE,
-};
-
-export const largePageSizeParams = {
- firstPageSize: PAGE_SIZE_MANUAL,
-};
-
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/queries/get_issues.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql
index be8deb3fe97..529262d2162 100644
--- a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql
@@ -5,6 +5,7 @@ query getIssues(
$isProject: Boolean = false
$isSignedIn: Boolean = false
$fullPath: ID!
+ $iid: String
$search: String
$sort: IssueSort
$state: IssuableState
@@ -29,6 +30,7 @@ query getIssues(
id
issues(
includeSubgroups: true
+ iid: $iid
search: $search
sort: $sort
state: $state
@@ -59,6 +61,7 @@ query getIssues(
project(fullPath: $fullPath) @include(if: $isProject) {
id
issues(
+ iid: $iid
search: $search
sort: $sort
state: $state
diff --git a/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql
index 1a345fd2877..58e7ce32e7c 100644
--- a/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql
@@ -1,6 +1,7 @@
query getIssuesCount(
$isProject: Boolean = false
$fullPath: ID!
+ $iid: String
$search: String
$assigneeId: String
$assigneeUsernames: [String!]
@@ -20,6 +21,7 @@ query getIssuesCount(
openedIssues: issues(
includeSubgroups: true
state: opened
+ iid: $iid
search: $search
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
@@ -37,6 +39,7 @@ query getIssuesCount(
closedIssues: issues(
includeSubgroups: true
state: closed
+ iid: $iid
search: $search
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
@@ -54,6 +57,7 @@ query getIssuesCount(
allIssues: issues(
includeSubgroups: true
state: all
+ iid: $iid
search: $search
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
@@ -73,6 +77,7 @@ query getIssuesCount(
id
openedIssues: issues(
state: opened
+ iid: $iid
search: $search
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
@@ -91,6 +96,7 @@ query getIssuesCount(
}
closedIssues: issues(
state: closed
+ iid: $iid
search: $search
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
@@ -109,6 +115,7 @@ query getIssuesCount(
}
allIssues: issues(
state: all
+ iid: $iid
search: $search
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
diff --git a/app/assets/javascripts/issues/list/queries/search_users.query.graphql b/app/assets/javascripts/issues/list/queries/search_users.query.graphql
index 92517ad35d0..46b48e4e41c 100644
--- a/app/assets/javascripts/issues/list/queries/search_users.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/search_users.query.graphql
@@ -3,7 +3,7 @@
query searchUsers($fullPath: ID!, $search: String, $isProject: Boolean = false) {
group(fullPath: $fullPath) @skip(if: $isProject) {
id
- groupMembers(search: $search) {
+ groupMembers(search: $search, relations: [DIRECT, INHERITED, SHARED_FROM_GROUPS]) {
nodes {
id
user {
@@ -14,7 +14,7 @@ query searchUsers($fullPath: ID!, $search: String, $isProject: Boolean = false)
}
project(fullPath: $fullPath) @include(if: $isProject) {
id
- projectMembers(search: $search) {
+ projectMembers(search: $search, relations: [DIRECT, INHERITED, INVITED_GROUPS]) {
nodes {
id
user {
diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js
index 6322968b3f0..4b77bd9bc5f 100644
--- a/app/assets/javascripts/issues/list/utils.js
+++ b/app/assets/javascripts/issues/list/utils.js
@@ -10,16 +10,16 @@ import {
BLOCKING_ISSUES_DESC,
CREATED_ASC,
CREATED_DESC,
- defaultPageSizeParams,
DUE_DATE_ASC,
DUE_DATE_DESC,
filters,
LABEL_PRIORITY_ASC,
LABEL_PRIORITY_DESC,
- largePageSizeParams,
MILESTONE_DUE_ASC,
MILESTONE_DUE_DESC,
NORMAL_FILTER,
+ PAGE_SIZE,
+ PAGE_SIZE_MANUAL,
POPULARITY_ASC,
POPULARITY_DESC,
PRIORITY_ASC,
@@ -43,8 +43,11 @@ import {
WEIGHT_DESC,
} from './constants';
-export const getInitialPageParams = (sortKey) =>
- sortKey === RELATIVE_POSITION_ASC ? largePageSizeParams : defaultPageSizeParams;
+export const getInitialPageParams = (sortKey, afterCursor, beforeCursor) => ({
+ firstPageSize: sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE,
+ afterCursor,
+ beforeCursor,
+});
export const getSortKey = (sort) =>
Object.keys(urlSortParams).find((key) => urlSortParams[key] === sort);
diff --git a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue
index 26862346b86..47b09bd6aa0 100644
--- a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue
+++ b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue
@@ -31,7 +31,10 @@ export default {
computed: {
actionPrimary() {
return {
- attributes: { variant: 'danger' },
+ attributes: {
+ variant: 'danger',
+ 'data-qa-selector': 'confirm_delete_issue_button',
+ },
text: this.title,
};
},
diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue
index eeccf886b65..68ed7bb4062 100644
--- a/app/assets/javascripts/issues/show/components/description.vue
+++ b/app/assets/javascripts/issues/show/components/description.vue
@@ -10,7 +10,9 @@ import $ from 'jquery';
import createFlash from '~/flash';
import { __, sprintf } from '~/locale';
import TaskList from '~/task_list';
+import Tracking from '~/tracking';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import CreateWorkItem from '~/work_items/pages/create_work_item.vue';
import animateMixin from '../mixins/animate';
@@ -24,8 +26,9 @@ export default {
GlPopover,
CreateWorkItem,
GlButton,
+ WorkItemDetailModal,
},
- mixins: [animateMixin, glFeatureFlagMixin()],
+ mixins: [animateMixin, glFeatureFlagMixin(), Tracking.mixin()],
props: {
canUpdate: {
type: Boolean,
@@ -68,9 +71,13 @@ export default {
initialUpdate: true,
taskButtons: [],
activeTask: {},
+ workItemId: null,
};
},
computed: {
+ showWorkItemDetailModal() {
+ return Boolean(this.workItemId);
+ },
workItemsEnabled() {
return this.glFeatures.workItems;
},
@@ -194,7 +201,13 @@ export default {
closeCreateTaskModal() {
this.$refs.modal.hide();
},
- handleCreateTask(title) {
+ closeWorkItemDetailModal() {
+ this.workItemId = null;
+ },
+ handleWorkItemDetailModalError(message) {
+ createFlash({ message });
+ },
+ handleCreateTask({ id, title, type }) {
const listItem = this.$el.querySelector(`#${this.activeTask.id}`).parentElement;
const taskBadge = document.createElement('span');
taskBadge.innerHTML = `
@@ -204,12 +217,28 @@ export default {
<span class="badge badge-info badge-pill gl-badge sm gl-mr-1">
${__('Task')}
</span>
- <a href="#">${title}</a>
`;
+ const button = this.createWorkItemDetailButton(id, title, type);
+ taskBadge.append(button);
+
listItem.insertBefore(taskBadge, listItem.lastChild);
listItem.removeChild(listItem.lastChild);
this.closeCreateTaskModal();
},
+ createWorkItemDetailButton(id, title, type) {
+ const button = document.createElement('button');
+ button.addEventListener('click', () => {
+ this.workItemId = id;
+ this.track('viewed_work_item_from_modal', {
+ category: 'workItems:show',
+ label: 'work_item_view',
+ property: `type_${type}`,
+ });
+ });
+ button.classList.add('btn-link');
+ button.innerText = title;
+ return button;
+ },
focusButton() {
this.$refs.convertButton[0].$el.focus();
},
@@ -262,6 +291,12 @@ export default {
@onCreate="handleCreateTask"
/>
</gl-modal>
+ <work-item-detail-modal
+ :visible="showWorkItemDetailModal"
+ :work-item-id="workItemId"
+ @close="closeWorkItemDetailModal"
+ @error="handleWorkItemDetailModalError"
+ />
<template v-if="workItemsEnabled">
<gl-popover
v-for="item in taskButtons"
diff --git a/app/assets/javascripts/issues/show/components/fields/description_template.vue b/app/assets/javascripts/issues/show/components/fields/description_template.vue
index 9ce49b65a1a..d528641dcb6 100644
--- a/app/assets/javascripts/issues/show/components/fields/description_template.vue
+++ b/app/assets/javascripts/issues/show/components/fields/description_template.vue
@@ -68,7 +68,10 @@ export default {
data-toggle="dropdown"
>
<span class="dropdown-toggle-text">{{ __('Choose a template') }}</span>
- <gl-icon name="chevron-down" class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" />
+ <gl-icon
+ name="chevron-down"
+ class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500 dropdown-menu-toggle-icon"
+ />
</button>
<div class="dropdown-menu dropdown-select">
<div class="dropdown-title gl-display-flex gl-justify-content-center">
diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue
index 8ba08472ea0..adf449aca7b 100644
--- a/app/assets/javascripts/issues/show/components/header_actions.vue
+++ b/app/assets/javascripts/issues/show/components/header_actions.vue
@@ -128,13 +128,21 @@ export default {
});
},
newIssueTypeText() {
- return sprintf(__('New %{issueType}'), { issueType: this.issueType });
+ return sprintf(__('New related %{issueType}'), { issueType: this.issueType });
},
showToggleIssueStateButton() {
const canClose = !this.isClosed && this.canUpdateIssue;
const canReopen = this.isClosed && this.canReopenIssue;
return canClose || canReopen;
},
+ hasDesktopDropdown() {
+ return (
+ this.canCreateIssue || this.canPromoteToEpic || !this.isIssueAuthor || this.canReportSpam
+ );
+ },
+ hasMobileDropdown() {
+ return this.hasDesktopDropdown || this.showToggleIssueStateButton;
+ },
},
created() {
eventHub.$on('toggle.issuable.state', this.toggleIssueState);
@@ -223,10 +231,12 @@ export default {
<template>
<div class="detail-page-header-actions gl-display-flex">
<gl-dropdown
+ v-if="hasMobileDropdown"
class="gl-sm-display-none! w-100"
block
:text="dropdownText"
data-qa-selector="issue_actions_dropdown"
+ data-testid="mobile-dropdown"
:loading="isToggleStateButtonLoading"
>
<gl-dropdown-item
@@ -276,11 +286,14 @@ export default {
</gl-button>
<gl-dropdown
+ v-if="hasDesktopDropdown"
class="gl-display-none gl-sm-display-inline-flex! gl-ml-3"
icon="ellipsis_v"
category="tertiary"
+ data-qa-selector="issue_actions_ellipsis_dropdown"
:text="dropdownText"
:text-sr-only="true"
+ data-testid="desktop-dropdown"
no-caret
right
>
@@ -311,6 +324,7 @@ export default {
<gl-dropdown-item
v-gl-modal="$options.deleteModalId"
variant="danger"
+ data-qa-selector="delete_issue_button"
@click="track('click_dropdown')"
>
{{ deleteButtonText }}
diff --git a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
index 4790062ab7d..04ddc7f3501 100644
--- a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
@@ -5,6 +5,7 @@ import { trackIncidentDetailsViewsOptions } from '~/incidents/constants';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import DescriptionComponent from '../description.vue';
import getAlert from './graphql/queries/get_alert.graphql';
import HighlightBar from './highlight_bar.vue';
@@ -17,7 +18,10 @@ export default {
GlTabs,
HighlightBar,
MetricsTab: () => import('ee_component/issues/show/components/incidents/metrics_tab.vue'),
+ TimelineTab: () =>
+ import('ee_component/issues/show/components/incidents/timeline_events_tab.vue'),
},
+ mixins: [glFeatureFlagsMixin()],
inject: ['fullPath', 'iid', 'uploadMetricsFeatureAvailable'],
apollo: {
alert: {
@@ -47,6 +51,9 @@ export default {
loading() {
return this.$apollo.queries.alert.loading;
},
+ incidentTabEnabled() {
+ return this.glFeatures.incidentTimelineEvents && this.glFeatures.incidentTimelineEventTab;
+ },
},
mounted() {
this.trackPageViews();
@@ -76,6 +83,7 @@ export default {
>
<alert-details-table :alert="alert" :loading="loading" />
</gl-tab>
+ <timeline-tab v-if="incidentTabEnabled" data-testid="timeline-events-tab" />
</gl-tabs>
</div>
</template>
diff --git a/app/assets/javascripts/issues/show/components/title.vue b/app/assets/javascripts/issues/show/components/title.vue
index 5e92211685a..1982147e454 100644
--- a/app/assets/javascripts/issues/show/components/title.vue
+++ b/app/assets/javascripts/issues/show/components/title.vue
@@ -68,7 +68,7 @@ export default {
<template>
<div class="title-container">
- <h2
+ <h1
v-safe-html="titleHtml"
:class="{
'issue-realtime-pre-pulse': preAnimation,
@@ -76,7 +76,7 @@ export default {
}"
class="title qa-title"
dir="auto"
- ></h2>
+ ></h1>
<gl-button
v-if="showInlineEditButton && canUpdate"
v-gl-tooltip.bottom
diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js
index f5c71f9691f..c9af5d9b4a7 100644
--- a/app/assets/javascripts/issues/show/index.js
+++ b/app/assets/javascripts/issues/show/index.js
@@ -77,9 +77,7 @@ export function initIssueApp(issueData, store) {
const { fullPath } = el.dataset;
- if (gon?.features?.fixCommentScroll) {
- scrollToTargetOnResize();
- }
+ scrollToTargetOnResize();
bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
index 905e242e977..afdb414e82c 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
@@ -3,6 +3,7 @@ import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { mapState, mapMutations } from 'vuex';
import { retrieveAlert } from '~/jira_connect/subscriptions/utils';
+import { I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE } from '../constants';
import { SET_ALERT } from '../store/mutation_types';
import SignInPage from '../pages/sign_in.vue';
import SubscriptionsPage from '../pages/subscriptions.vue';
@@ -28,6 +29,11 @@ export default {
default: [],
},
},
+ data() {
+ return {
+ user: null,
+ };
+ },
computed: {
...mapState(['alert']),
shouldShowAlert() {
@@ -37,7 +43,7 @@ export default {
return !isEmpty(this.subscriptions);
},
userSignedIn() {
- return Boolean(!this.usersPath);
+ return Boolean(!this.usersPath || this.user);
},
},
created() {
@@ -51,6 +57,15 @@ export default {
const { linkUrl, title, message, variant } = retrieveAlert() || {};
this.setAlert({ linkUrl, title, message, variant });
},
+ onSignInOauth(user) {
+ this.user = user;
+ },
+ onSignInError() {
+ this.setAlert({
+ message: I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE,
+ variant: 'danger',
+ });
+ },
},
};
</script>
@@ -78,11 +93,16 @@ export default {
</template>
</gl-alert>
- <user-link :user-signed-in="userSignedIn" :has-subscriptions="hasSubscriptions" />
+ <user-link :user-signed-in="userSignedIn" :has-subscriptions="hasSubscriptions" :user="user" />
<h2 class="gl-text-center gl-mb-7">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2>
<div class="gl-layout-w-limited gl-mx-auto gl-px-5 gl-mb-7">
- <sign-in-page v-if="!userSignedIn" :has-subscriptions="hasSubscriptions" />
+ <sign-in-page
+ v-if="!userSignedIn"
+ :has-subscriptions="hasSubscriptions"
+ @sign-in-oauth="onSignInOauth"
+ @error="onSignInError"
+ />
<subscriptions-page v-else :has-subscriptions="hasSubscriptions" />
</div>
</div>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_legacy_button.vue
index 627abcdd4a0..ec718d5b3ca 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_legacy_button.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton } from '@gitlab/ui';
import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils';
-import { s__ } from '~/locale';
+import { I18N_DEFAULT_SIGN_IN_BUTTON_TEXT } from '~/jira_connect/subscriptions/constants';
export default {
components: {
@@ -27,7 +27,7 @@ export default {
},
},
i18n: {
- defaultButtonText: s__('Integrations|Sign in to GitLab'),
+ defaultButtonText: I18N_DEFAULT_SIGN_IN_BUTTON_TEXT,
},
};
</script>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
new file mode 100644
index 00000000000..d7ec909cb28
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
@@ -0,0 +1,124 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import {
+ I18N_DEFAULT_SIGN_IN_BUTTON_TEXT,
+ OAUTH_WINDOW_OPTIONS,
+ PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM,
+} from '~/jira_connect/subscriptions/constants';
+import { setUrlParams } from '~/lib/utils/url_utility';
+import AccessorUtilities from '~/lib/utils/accessor';
+
+import { createCodeVerifier, createCodeChallenge } from '../pkce';
+
+export default {
+ components: {
+ GlButton,
+ },
+ inject: ['oauthMetadata'],
+ data() {
+ return {
+ token: null,
+ loading: false,
+ codeVerifier: null,
+ canUseCrypto: AccessorUtilities.canUseCrypto(),
+ };
+ },
+ mounted() {
+ window.addEventListener('message', this.handleWindowMessage);
+ },
+ beforeDestroy() {
+ window.removeEventListener('message', this.handleWindowMessage);
+ },
+ methods: {
+ async startOAuthFlow() {
+ this.loading = true;
+
+ // Generate state necessary for PKCE OAuth flow
+ this.codeVerifier = createCodeVerifier();
+ const codeChallenge = await createCodeChallenge(this.codeVerifier);
+
+ // Build the initial OAuth authorization URL
+ const { oauth_authorize_url: oauthAuthorizeURL } = this.oauthMetadata;
+ const oauthAuthorizeURLWithChallenge = setUrlParams(
+ {
+ code_challenge: codeChallenge,
+ code_challenge_method: PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM.short,
+ },
+ oauthAuthorizeURL,
+ );
+
+ window.open(
+ oauthAuthorizeURLWithChallenge,
+ this.$options.i18n.defaultButtonText,
+ OAUTH_WINDOW_OPTIONS,
+ );
+ },
+ async handleWindowMessage(event) {
+ if (window.origin !== event.origin) {
+ this.loading = false;
+ this.handleError();
+ return;
+ }
+
+ // Verify that OAuth state isn't altered.
+ const state = event.data?.state;
+ if (state !== this.oauthMetadata.state) {
+ this.loading = false;
+ this.handleError();
+ return;
+ }
+
+ // Request access token and load the authenticated user.
+ const code = event.data?.code;
+ try {
+ const accessToken = await this.getOAuthToken(code);
+ await this.loadUser(accessToken);
+ } catch (e) {
+ this.handleError();
+ } finally {
+ this.loading = false;
+ }
+ },
+ handleError() {
+ this.$emit('error');
+ },
+ async getOAuthToken(code) {
+ const {
+ oauth_token_payload: oauthTokenPayload,
+ oauth_token_url: oauthTokenURL,
+ } = this.oauthMetadata;
+ const { data } = await axios.post(oauthTokenURL, {
+ ...oauthTokenPayload,
+ code,
+ code_verifier: this.codeVerifier,
+ });
+
+ return data.access_token;
+ },
+ async loadUser(accessToken) {
+ const { data } = await axios.get('/api/v4/user', {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ });
+
+ this.$emit('sign-in', data);
+ },
+ },
+ i18n: {
+ defaultButtonText: I18N_DEFAULT_SIGN_IN_BUTTON_TEXT,
+ },
+};
+</script>
+<template>
+ <gl-button
+ category="primary"
+ variant="info"
+ :loading="loading"
+ :disabled="!canUseCrypto"
+ @click="startOAuthFlow"
+ >
+ <slot>
+ {{ $options.i18n.defaultButtonText }}
+ </slot>
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue b/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue
index fad3d2616d8..5e2c83aff65 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue
@@ -25,6 +25,11 @@ export default {
type: Boolean,
required: true,
},
+ user: {
+ type: Object,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -32,8 +37,19 @@ export default {
};
},
computed: {
+ gitlabUserName() {
+ return gon.current_username ?? this.user?.username;
+ },
gitlabUserHandle() {
- return `@${gon.current_username}`;
+ return this.gitlabUserName ? `@${this.gitlabUserName}` : undefined;
+ },
+ gitlabUserLink() {
+ return this.gitlabUserPath ?? `${gon.relative_root_url}/${this.gitlabUserName}`;
+ },
+ signedInText() {
+ return this.gitlabUserHandle
+ ? this.$options.i18n.signedInAsUserText
+ : this.$options.i18n.signedInText;
},
},
async created() {
@@ -42,14 +58,15 @@ export default {
i18n: {
signInText: __('Sign in to GitLab'),
signedInAsUserText: __('Signed in to GitLab as %{user_link}'),
+ signedInText: __('Signed in to GitLab'),
},
};
</script>
<template>
<div class="jira-connect-user gl-font-base">
- <gl-sprintf v-if="userSignedIn" :message="$options.i18n.signedInAsUserText">
+ <gl-sprintf v-if="userSignedIn" :message="signedInText">
<template #user_link>
- <gl-link data-testid="gitlab-user-link" :href="gitlabUserPath" target="_blank">
+ <gl-link data-testid="gitlab-user-link" :href="gitlabUserLink" target="_blank">
{{ gitlabUserHandle }}
</gl-link>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/constants.js b/app/assets/javascripts/jira_connect/subscriptions/constants.js
index 2a65b7bc1fa..d30ebdbb487 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/constants.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/constants.js
@@ -1,5 +1,26 @@
+import { s__ } from '~/locale';
+
export const DEFAULT_GROUPS_PER_PAGE = 10;
export const ALERT_LOCALSTORAGE_KEY = 'gitlab_alert';
export const MINIMUM_SEARCH_TERM_LENGTH = 3;
export const ADD_NAMESPACE_MODAL_ID = 'add-namespace-modal';
+
+export const I18N_DEFAULT_SIGN_IN_BUTTON_TEXT = s__('Integrations|Sign in to GitLab');
+export const I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE = s__('Integrations|Failed to sign in to GitLab.');
+
+const OAUTH_WINDOW_SIZE = 800;
+export const OAUTH_WINDOW_OPTIONS = [
+ 'resizable=yes',
+ 'scrollbars=yes',
+ 'status=yes',
+ `width=${OAUTH_WINDOW_SIZE}`,
+ `height=${OAUTH_WINDOW_SIZE}`,
+ `left=${window.screen.width / 2 - OAUTH_WINDOW_SIZE / 2}`,
+ `top=${window.screen.height / 2 - OAUTH_WINDOW_SIZE / 2}`,
+].join(',');
+
+export const PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM = {
+ long: 'SHA-256',
+ short: 'S256',
+};
diff --git a/app/assets/javascripts/jira_connect/subscriptions/index.js b/app/assets/javascripts/jira_connect/subscriptions/index.js
index cd1fc1d4455..320f0f8aa6c 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/index.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/index.js
@@ -21,7 +21,14 @@ export function initJiraConnect() {
Vue.use(Translate);
Vue.use(GlFeatureFlagsPlugin);
- const { groupsPath, subscriptions, subscriptionsPath, usersPath, gitlabUserPath } = el.dataset;
+ const {
+ groupsPath,
+ subscriptions,
+ subscriptionsPath,
+ usersPath,
+ gitlabUserPath,
+ oauthMetadata,
+ } = el.dataset;
sizeToParent();
return new Vue({
@@ -33,6 +40,7 @@ export function initJiraConnect() {
subscriptionsPath,
usersPath,
gitlabUserPath,
+ oauthMetadata: oauthMetadata ? JSON.parse(oauthMetadata) : null,
},
render(createElement) {
return createElement(JiraConnectApp);
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue
index 2bce5afc72b..a24ee33b723 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue
@@ -1,14 +1,17 @@
<script>
import { s__ } from '~/locale';
+
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SubscriptionsList from '../components/subscriptions_list.vue';
-import SignInButton from '../components/sign_in_button.vue';
export default {
name: 'SignInPage',
components: {
SubscriptionsList,
- SignInButton,
+ SignInLegacyButton: () => import('../components/sign_in_legacy_button.vue'),
+ SignInOauthButton: () => import('../components/sign_in_oauth_button.vue'),
},
+ mixins: [glFeatureFlagMixin()],
inject: ['usersPath'],
props: {
hasSubscriptions: {
@@ -16,25 +19,47 @@ export default {
required: true,
},
},
+ computed: {
+ useSignInOauthButton() {
+ return this.glFeatures.jiraConnectOauth;
+ },
+ },
i18n: {
- signinButtonTextWithSubscriptions: s__('Integrations|Sign in to add namespaces'),
+ signInButtonTextWithSubscriptions: s__('Integrations|Sign in to add namespaces'),
signInText: s__('JiraService|Sign in to GitLab.com to get started.'),
},
+ methods: {
+ onSignInError() {
+ this.$emit('error');
+ },
+ },
};
</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>
+ <sign-in-oauth-button
+ v-if="useSignInOauthButton"
+ @sign-in="$emit('sign-in-oauth', $event)"
+ @error="onSignInError"
+ >
+ {{ $options.i18n.signInButtonTextWithSubscriptions }}
+ </sign-in-oauth-button>
+ <sign-in-legacy-button v-else :users-path="usersPath">
+ {{ $options.i18n.signInButtonTextWithSubscriptions }}
+ </sign-in-legacy-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" />
+ <sign-in-oauth-button
+ v-if="useSignInOauthButton"
+ @sign-in="$emit('sign-in-oauth', $event)"
+ @error="onSignInError"
+ />
+ <sign-in-legacy-button v-else class="gl-mb-7" :users-path="usersPath" />
</div>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pkce.js b/app/assets/javascripts/jira_connect/subscriptions/pkce.js
new file mode 100644
index 00000000000..18ea5cae860
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/subscriptions/pkce.js
@@ -0,0 +1,60 @@
+import { bufferToBase64, base64ToBase64Url } from '~/authentication/webauthn/util';
+import { PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM } from './constants';
+
+// PKCE codeverifier should have a maximum length of 128 characters.
+// Using 96 bytes generates a string of 128 characters.
+// RFC: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
+export const CODE_VERIFIER_BYTES = 96;
+
+/**
+ * Generate a cryptographically random string.
+ * @param {Number} lengthBytes
+ * @returns {String} a random string
+ */
+function getRandomString(lengthBytes) {
+ // generate random values and load them into byteArray.
+ const byteArray = new Uint8Array(lengthBytes);
+ window.crypto.getRandomValues(byteArray);
+
+ // Convert array to string
+ const randomString = bufferToBase64(byteArray);
+ return randomString;
+}
+
+/**
+ * Creates a code verifier to be used for OAuth PKCE authentication.
+ * The code verifier has 128 characters.
+ *
+ * RFC: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
+ * @returns {String} code verifier
+ */
+export function createCodeVerifier() {
+ const verifier = getRandomString(CODE_VERIFIER_BYTES);
+ return base64ToBase64Url(verifier);
+}
+
+/**
+ * Creates a code challenge for OAuth PKCE authentication.
+ * The code challenge is derived from the given [codeVerifier].
+ * [codeVerifier] is tranformed in the following way (as per the RFC):
+ * code_challenge = BASE64URL-ENCODE(SHA256(ASCII(codeVerifier)))
+ *
+ * RFC: https://datatracker.ietf.org/doc/html/rfc7636#section-4.2
+ * @param {String} codeVerifier
+ * @returns {String} code challenge
+ */
+export async function createCodeChallenge(codeVerifier) {
+ // Generate SHA-256 digest of the [codeVerifier]
+ const buffer = new TextEncoder().encode(codeVerifier);
+ const digestArrayBuffer = await window.crypto.subtle.digest(
+ PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM.long,
+ buffer,
+ );
+
+ // Convert digest to a Base64URL-encoded string
+ const digestHash = bufferToBase64(digestArrayBuffer);
+ // Escape string to remove reserved charaters
+ const codeChallenge = base64ToBase64Url(digestHash);
+
+ return codeChallenge;
+}
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue
index fe4158a1bd1..85fe5ed7e26 100644
--- a/app/assets/javascripts/jobs/components/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job_app.vue
@@ -3,7 +3,6 @@ import { GlLoadingIcon, GlIcon, GlSafeHtmlDirective as SafeHtml, GlAlert } from
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { throttle, isEmpty } from 'lodash';
import { mapGetters, mapState, mapActions } from 'vuex';
-import CodeQualityWalkthrough from '~/code_quality_walkthrough/components/step.vue';
import { isScrolledToBottom } from '~/lib/utils/scroll_utils';
import { __, sprintf } from '~/locale';
import CiHeader from '~/vue_shared/components/header_ci_component.vue';
@@ -33,7 +32,6 @@ export default {
GlLoadingIcon,
SharedRunner: () => import('ee_component/jobs/components/shared_runner_limit_block.vue'),
GlAlert,
- CodeQualityWalkthrough,
},
directives: {
SafeHtml,
@@ -69,11 +67,6 @@ export default {
required: false,
default: null,
},
- codeQualityHelpUrl: {
- type: String,
- required: false,
- default: null,
- },
},
computed: {
...mapState([
@@ -123,9 +116,6 @@ export default {
return this.shouldRenderCalloutMessage && !this.hasUnmetPrerequisitesFailure;
},
- shouldRenderCodeQualityWalkthrough() {
- return this.job.status.group === 'failed-with-warnings';
- },
itemName() {
return sprintf(__('Job %{jobName}'), { jobName: this.job.name });
},
@@ -224,11 +214,6 @@ export default {
>
<div v-safe-html="job.callout_message"></div>
</gl-alert>
- <code-quality-walkthrough
- v-if="shouldRenderCodeQualityWalkthrough"
- step="troubleshoot_job"
- :link="codeQualityHelpUrl"
- />
</header>
<!-- EO Header Section -->
@@ -288,7 +273,6 @@ export default {
'sidebar-collapsed': !isSidebarOpen,
'has-archived-block': job.archived,
}"
- :erase-path="job.erase_path"
:size="jobLogSize"
:raw-path="job.raw_path"
:is-scroll-bottom-disabled="isScrollBottomDisabled"
@@ -325,6 +309,7 @@ export default {
'right-sidebar-expanded': isSidebarOpen,
'right-sidebar-collapsed': !isSidebarOpen,
}"
+ :erase-path="job.erase_path"
:artifact-help-url="artifactHelpUrl"
data-testid="job-sidebar"
/>
diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue
index 8e35fd91481..eb6a284dfaf 100644
--- a/app/assets/javascripts/jobs/components/job_log_controllers.vue
+++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue
@@ -5,7 +5,6 @@ import { __, s__, sprintf } from '~/locale';
export default {
i18n: {
- eraseLogButtonLabel: s__('Job|Erase job log and artifacts'),
scrollToBottomButtonLabel: s__('Job|Scroll to bottom'),
scrollToTopButtonLabel: s__('Job|Scroll to top'),
showRawButtonLabel: s__('Job|Show complete raw'),
@@ -18,11 +17,6 @@ export default {
GlTooltip: GlTooltipDirective,
},
props: {
- erasePath: {
- type: String,
- required: false,
- default: null,
- },
size: {
type: Number,
required: true,
@@ -97,20 +91,6 @@ export default {
data-testid="job-raw-link-controller"
icon="doc-text"
/>
-
- <gl-button
- v-if="erasePath"
- v-gl-tooltip.body
- :title="$options.i18n.eraseLogButtonLabel"
- :aria-label="$options.i18n.eraseLogButtonLabel"
- :href="erasePath"
- :data-confirm="__('Are you sure you want to erase this build?')"
- class="gl-ml-3"
- data-testid="job-log-erase-link"
- data-confirm-btn-variant="danger"
- data-method="post"
- icon="remove"
- />
<!-- eo links -->
<!-- scroll buttons -->
diff --git a/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue b/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue
index a43b3297d75..a7bf365d35c 100644
--- a/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue
+++ b/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlLink, GlModalDirective } from '@gitlab/ui';
+import { GlButton, GlModalDirective } from '@gitlab/ui';
import { mapGetters } from 'vuex';
import { JOB_SIDEBAR } from '../constants';
@@ -10,7 +10,6 @@ export default {
},
components: {
GlButton,
- GlLink,
},
directives: {
GlModal: GlModalDirective,
@@ -37,9 +36,18 @@ export default {
:aria-label="$options.i18n.retryLabel"
category="primary"
variant="confirm"
- >{{ $options.i18n.retryLabel }}</gl-button
- >
- <gl-link v-else :href="href" class="btn gl-button btn-confirm" data-method="post" rel="nofollow"
- >{{ $options.i18n.retryLabel }}
- </gl-link>
+ icon="retry"
+ data-testid="retry-job-button"
+ />
+
+ <gl-button
+ v-else
+ :href="href"
+ :aria-label="$options.i18n.retryLabel"
+ category="primary"
+ variant="confirm"
+ icon="retry"
+ data-method="post"
+ data-testid="retry-job-link"
+ />
</template>
diff --git a/app/assets/javascripts/jobs/components/log/line_header.vue b/app/assets/javascripts/jobs/components/log/line_header.vue
index 3bb1f58573c..c72d488f844 100644
--- a/app/assets/javascripts/jobs/components/log/line_header.vue
+++ b/app/assets/javascripts/jobs/components/log/line_header.vue
@@ -43,7 +43,7 @@ export default {
<template>
<div
- class="log-line collapsible-line d-flex justify-content-between ws-normal"
+ class="log-line collapsible-line d-flex justify-content-between ws-normal gl-align-items-flex-start"
role="button"
@click="handleOnClick"
>
diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue
index 9aa1503c7c3..1b4c9ebdf7d 100644
--- a/app/assets/javascripts/jobs/components/sidebar.vue
+++ b/app/assets/javascripts/jobs/components/sidebar.vue
@@ -1,7 +1,8 @@
<script>
-import { GlButton, GlIcon } from '@gitlab/ui';
+import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
+import { s__ } from '~/locale';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import { JOB_SIDEBAR } from '../constants';
import ArtifactsBlock from './artifacts_block.vue';
@@ -18,10 +19,17 @@ export const forwardDeploymentFailureModalId = 'forward-deployment-failure';
export default {
name: 'JobSidebar',
i18n: {
+ eraseLogButtonLabel: s__('Job|Erase job log and artifacts'),
+ eraseLogConfirmText: s__('Job|Are you sure you want to erase this job log and artifacts?'),
+ cancelJobButtonLabel: s__('Job|Cancel'),
+ retryJobButtonLabel: s__('Job|Retry'),
...JOB_SIDEBAR,
},
borderTopClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100'],
forwardDeploymentFailureModalId,
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
components: {
ArtifactsBlock,
CommitBlock,
@@ -41,6 +49,11 @@ export default {
required: false,
default: '',
},
+ erasePath: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
computed: {
...mapGetters(['hasForwardDeploymentFailure']),
@@ -81,8 +94,24 @@ export default {
</h4>
</tooltip-on-truncate>
<div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right">
+ <gl-button
+ v-if="erasePath"
+ v-gl-tooltip.left
+ :title="$options.i18n.eraseLogButtonLabel"
+ :aria-label="$options.i18n.eraseLogButtonLabel"
+ :href="erasePath"
+ :data-confirm="$options.i18n.eraseLogConfirmText"
+ class="gl-mr-2"
+ data-testid="job-log-erase-link"
+ data-confirm-btn-variant="danger"
+ data-method="post"
+ icon="remove"
+ />
<job-sidebar-retry-button
v-if="job.retry_path"
+ v-gl-tooltip.left
+ :title="$options.i18n.retryJobButtonLabel"
+ :aria-label="$options.i18n.retryJobButtonLabel"
:category="retryButtonCategory"
:href="job.retry_path"
:modal-id="$options.forwardDeploymentFailureModalId"
@@ -92,12 +121,15 @@ export default {
/>
<gl-button
v-if="job.cancel_path"
+ v-gl-tooltip.left
+ :title="$options.i18n.cancelJobButtonLabel"
+ :aria-label="$options.i18n.cancelJobButtonLabel"
:href="job.cancel_path"
+ icon="cancel"
data-method="post"
data-testid="cancel-button"
rel="nofollow"
- >{{ $options.i18n.cancel }}
- </gl-button>
+ />
</div>
<gl-button
diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue
index 1780afd39e8..7c4811b2d6f 100644
--- a/app/assets/javascripts/jobs/components/stages_dropdown.vue
+++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue
@@ -1,8 +1,12 @@
<script>
-import { GlLink, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlLink, GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
+import Mousetrap from 'mousetrap';
+import { s__ } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import clipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { clickCopyToClipboardButton } from '~/behaviors/copy_to_clipboard';
+import { keysFor, MR_COPY_SOURCE_BRANCH_NAME } from '~/behaviors/shortcuts/keybindings';
export default {
components: {
@@ -11,6 +15,7 @@ export default {
GlDropdown,
GlDropdownItem,
GlLink,
+ GlSprintf,
},
props: {
pipeline: {
@@ -36,11 +41,43 @@ export default {
isMergeRequestPipeline() {
return Boolean(this.pipeline.flags && this.pipeline.flags.merge_request_pipeline);
},
+ pipelineInfo() {
+ if (!this.hasRef) {
+ return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id}');
+ } else if (!this.isTriggeredByMergeRequest) {
+ return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id} for %{ref}');
+ } else if (!this.isMergeRequestPipeline) {
+ return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id} for %{mrId} with %{source}');
+ }
+
+ return s__(
+ 'Job|%{boldStart}Pipeline%{boldEnd} %{id} for %{mrId} with %{source} into %{target}',
+ );
+ },
+ },
+ mounted() {
+ Mousetrap.bind(keysFor(MR_COPY_SOURCE_BRANCH_NAME), this.handleKeyboardCopy);
+ },
+ beforeDestroy() {
+ Mousetrap.unbind(keysFor(MR_COPY_SOURCE_BRANCH_NAME));
},
methods: {
onStageClick(stage) {
this.$emit('requestSidebarStageDropdown', stage);
},
+ handleKeyboardCopy() {
+ let button;
+
+ if (!this.hasRef) {
+ return;
+ } else if (!this.isTriggeredByMergeRequest) {
+ button = this.$refs['copy-source-ref-link'];
+ } else {
+ button = this.$refs['copy-source-branch-link'];
+ }
+
+ clickCopyToClipboardButton(button.$el);
+ },
},
};
</script>
@@ -48,54 +85,72 @@ export default {
<div class="dropdown">
<div class="js-pipeline-info" data-testid="pipeline-info">
<ci-icon :status="pipeline.details.status" />
-
- <span class="font-weight-bold">{{ s__('Job|Pipeline') }}</span>
- <gl-link
- :href="pipeline.path"
- class="js-pipeline-path link-commit"
- data-testid="pipeline-path"
- data-qa-selector="pipeline_path"
- >#{{ pipeline.id }}</gl-link
- >
- <template v-if="hasRef">
- {{ s__('Job|for') }}
-
- <template v-if="isTriggeredByMergeRequest">
+ <gl-sprintf :message="pipelineInfo">
+ <template #bold="{ content }">
+ <span class="font-weight-bold">{{ content }}</span>
+ </template>
+ <template #id>
+ <gl-link
+ :href="pipeline.path"
+ class="js-pipeline-path link-commit"
+ data-testid="pipeline-path"
+ data-qa-selector="pipeline_path"
+ >#{{ pipeline.id }}</gl-link
+ >
+ </template>
+ <template #mrId>
<gl-link
:href="pipeline.merge_request.path"
class="link-commit ref-name"
data-testid="mr-link"
>!{{ pipeline.merge_request.iid }}</gl-link
>
- {{ s__('Job|with') }}
+ </template>
+ <template #ref>
+ <gl-link
+ :href="pipeline.ref.path"
+ class="link-commit ref-name"
+ data-testid="source-ref-link"
+ >{{ pipeline.ref.name }}</gl-link
+ ><clipboard-button
+ ref="copy-source-ref-link"
+ :text="pipeline.ref.name"
+ :title="__('Copy reference')"
+ category="tertiary"
+ size="small"
+ data-testid="copy-source-ref-link"
+ />
+ </template>
+ <template #source>
<gl-link
:href="pipeline.merge_request.source_branch_path"
class="link-commit ref-name"
data-testid="source-branch-link"
>{{ pipeline.merge_request.source_branch }}</gl-link
- >
-
- <template v-if="isMergeRequestPipeline">
- {{ s__('Job|into') }}
- <gl-link
- :href="pipeline.merge_request.target_branch_path"
- class="link-commit ref-name"
- data-testid="target-branch-link"
- >{{ pipeline.merge_request.target_branch }}</gl-link
- >
- </template>
+ ><clipboard-button
+ ref="copy-source-branch-link"
+ :text="pipeline.merge_request.source_branch"
+ :title="__('Copy branch name')"
+ category="tertiary"
+ size="small"
+ data-testid="copy-source-branch-link"
+ />
+ </template>
+ <template #target>
+ <gl-link
+ :href="pipeline.merge_request.target_branch_path"
+ class="link-commit ref-name"
+ data-testid="target-branch-link"
+ >{{ pipeline.merge_request.target_branch }}</gl-link
+ ><clipboard-button
+ :text="pipeline.merge_request.target_branch"
+ :title="__('Copy branch name')"
+ category="tertiary"
+ size="small"
+ data-testid="copy-target-branch-link"
+ />
</template>
- <gl-link v-else :href="pipeline.ref.path" class="link-commit ref-name">{{
- pipeline.ref.name
- }}</gl-link
- ><clipboard-button
- :text="pipeline.ref.name"
- :title="__('Copy reference')"
- category="tertiary"
- size="small"
- data-testid="copy-source-ref-link"
- />
- </template>
+ </gl-sprintf>
</div>
<gl-dropdown :text="selectedStage" class="js-selected-stage gl-w-full gl-mt-3">
diff --git a/app/assets/javascripts/jobs/components/table/constants.js b/app/assets/javascripts/jobs/components/table/constants.js
index 962979ba573..951d9324813 100644
--- a/app/assets/javascripts/jobs/components/table/constants.js
+++ b/app/assets/javascripts/jobs/components/table/constants.js
@@ -1,16 +1,6 @@
import { s__, __ } from '~/locale';
import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants';
-export const GRAPHQL_PAGE_SIZE = 30;
-
-export const initialPaginationState = {
- currentPage: 1,
- prevPageCursor: '',
- nextPageCursor: '',
- first: GRAPHQL_PAGE_SIZE,
- last: null,
-};
-
/* Error constants */
export const POST_FAILURE = 'post_failure';
export const DEFAULT = 'default';
diff --git a/app/assets/javascripts/jobs/components/table/graphql/cache_config.js b/app/assets/javascripts/jobs/components/table/graphql/cache_config.js
new file mode 100644
index 00000000000..b9946925c95
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/graphql/cache_config.js
@@ -0,0 +1,30 @@
+import { isEqual } from 'lodash';
+
+export default {
+ typePolicies: {
+ Project: {
+ fields: {
+ jobs: {
+ keyArgs: false,
+ },
+ },
+ },
+ CiJobConnection: {
+ merge(existing = {}, incoming, { args = {} }) {
+ let nodes;
+
+ if (Object.keys(existing).length !== 0 && isEqual(existing?.statuses, args?.statuses)) {
+ nodes = [...existing.nodes, ...incoming.nodes];
+ } else {
+ nodes = [...incoming.nodes];
+ }
+
+ return {
+ nodes,
+ statuses: Array.isArray(args.statuses) ? [...args.statuses] : args.statuses,
+ pageInfo: incoming.pageInfo,
+ };
+ },
+ },
+ },
+};
diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
index 88937185a8c..151e49af87e 100644
--- a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
+++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
@@ -1,25 +1,22 @@
-query getJobs(
- $fullPath: ID!
- $first: Int
- $last: Int
- $after: String
- $before: String
- $statuses: [CiJobStatus!]
-) {
+query getJobs($fullPath: ID!, $after: String, $statuses: [CiJobStatus!]) {
project(fullPath: $fullPath) {
id
- jobs(after: $after, before: $before, first: $first, last: $last, statuses: $statuses) {
+ __typename
+ jobs(after: $after, first: 30, statuses: $statuses) {
pageInfo {
endCursor
hasNextPage
hasPreviousPage
startCursor
+ __typename
}
nodes {
+ __typename
artifacts {
nodes {
downloadPath
fileType
+ __typename
}
}
allowFailure
diff --git a/app/assets/javascripts/jobs/components/table/index.js b/app/assets/javascripts/jobs/components/table/index.js
index f24daf90815..1b9c7cdcfdd 100644
--- a/app/assets/javascripts/jobs/components/table/index.js
+++ b/app/assets/javascripts/jobs/components/table/index.js
@@ -4,12 +4,18 @@ import VueApollo from 'vue-apollo';
import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
+import cacheConfig from './graphql/cache_config';
Vue.use(VueApollo);
Vue.use(GlToast);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient(
+ {},
+ {
+ cacheConfig,
+ },
+ ),
});
export default (containerId = 'js-jobs-table') => {
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 81f42c1e293..864e322eecd 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
@@ -1,7 +1,6 @@
<script>
-import { GlAlert, GlPagination, GlSkeletonLoader } from '@gitlab/ui';
+import { GlAlert, GlSkeletonLoader, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
-import { GRAPHQL_PAGE_SIZE, initialPaginationState } from './constants';
import eventHub from './event_hub';
import GetJobs from './graphql/queries/get_jobs.query.graphql';
import JobsTable from './jobs_table.vue';
@@ -11,14 +10,16 @@ import JobsTableTabs from './jobs_table_tabs.vue';
export default {
i18n: {
errorMsg: __('There was an error fetching the jobs for your project.'),
+ loadingAriaLabel: __('Loading'),
},
components: {
GlAlert,
- GlPagination,
GlSkeletonLoader,
JobsTable,
JobsTableEmptyState,
JobsTableTabs,
+ GlIntersectionObserver,
+ GlLoadingIcon,
},
inject: {
fullPath: {
@@ -31,10 +32,6 @@ export default {
variables() {
return {
fullPath: this.fullPath,
- first: this.pagination.first,
- last: this.pagination.last,
- after: this.pagination.nextPageCursor,
- before: this.pagination.prevPageCursor,
};
},
update(data) {
@@ -57,7 +54,7 @@ export default {
hasError: false,
isAlertDismissed: false,
scope: null,
- pagination: initialPaginationState,
+ firstLoad: true,
};
},
computed: {
@@ -67,14 +64,8 @@ export default {
showEmptyState() {
return this.jobs.list.length === 0 && !this.scope;
},
- prevPage() {
- return Math.max(this.pagination.currentPage - 1, 0);
- },
- nextPage() {
- return this.jobs.pageInfo?.hasNextPage ? this.pagination.currentPage + 1 : null;
- },
- showPaginationControls() {
- return Boolean(this.prevPage || this.nextPage) && !this.$apollo.loading;
+ hasNextPage() {
+ return this.jobs?.pageInfo?.hasNextPage;
},
},
mounted() {
@@ -88,26 +79,22 @@ export default {
this.$apollo.queries.jobs.refetch({ statuses: this.scope });
},
fetchJobsByStatus(scope) {
+ this.firstLoad = true;
+
this.scope = scope;
this.$apollo.queries.jobs.refetch({ statuses: scope });
},
- handlePageChange(page) {
- const { startCursor, endCursor } = this.jobs.pageInfo;
+ fetchMoreJobs() {
+ this.firstLoad = false;
- if (page > this.pagination.currentPage) {
- this.pagination = {
- ...initialPaginationState,
- nextPageCursor: endCursor,
- currentPage: page,
- };
- } else {
- this.pagination = {
- last: GRAPHQL_PAGE_SIZE,
- first: null,
- prevPageCursor: startCursor,
- currentPage: page,
- };
+ if (!this.$apollo.queries.jobs.loading) {
+ this.$apollo.queries.jobs.fetchMore({
+ variables: {
+ fullPath: this.fullPath,
+ after: this.jobs?.pageInfo?.endCursor,
+ },
+ });
}
},
},
@@ -128,7 +115,7 @@ export default {
<jobs-table-tabs @fetchJobsByStatus="fetchJobsByStatus" />
- <div v-if="$apollo.loading" class="gl-mt-5">
+ <div v-if="$apollo.loading && firstLoad" class="gl-mt-5">
<gl-skeleton-loader :width="1248" :height="73">
<circle cx="748.031" cy="37.7193" r="15.0307" />
<circle cx="787.241" cy="37.7193" r="15.0307" />
@@ -149,14 +136,12 @@ export default {
<jobs-table v-else :jobs="jobs.list" />
- <gl-pagination
- v-if="showPaginationControls"
- :value="pagination.currentPage"
- :prev-page="prevPage"
- :next-page="nextPage"
- align="center"
- class="gl-mt-3"
- @input="handlePageChange"
- />
+ <gl-intersection-observer v-if="hasNextPage" @appear="fetchMoreJobs">
+ <gl-loading-icon
+ v-if="$apollo.loading"
+ size="md"
+ :aria-label="$options.i18n.loadingAriaLabel"
+ />
+ </gl-intersection-observer>
</div>
</template>
diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js
index 6e958ea1842..26dd38bbe08 100644
--- a/app/assets/javascripts/jobs/index.js
+++ b/app/assets/javascripts/jobs/index.js
@@ -14,7 +14,6 @@ const initializeJobPage = (element) => {
const {
artifactHelpUrl,
deploymentHelpUrl,
- codeQualityHelpUrl,
runnerSettingsUrl,
subscriptionsMoreMinutesUrl,
endpoint,
@@ -39,7 +38,6 @@ const initializeJobPage = (element) => {
props: {
artifactHelpUrl,
deploymentHelpUrl,
- codeQualityHelpUrl,
runnerSettingsUrl,
subscriptionsMoreMinutesUrl,
endpoint,
diff --git a/app/assets/javascripts/lib/utils/accessor.js b/app/assets/javascripts/lib/utils/accessor.js
index d4a6d70c62c..f7cdc564538 100644
--- a/app/assets/javascripts/lib/utils/accessor.js
+++ b/app/assets/javascripts/lib/utils/accessor.js
@@ -50,8 +50,16 @@ function canUseLocalStorage() {
return safe;
}
+/**
+ * Determines if `window.crypto` is available.
+ */
+function canUseCrypto() {
+ return window.crypto?.subtle !== undefined;
+}
+
const AccessorUtilities = {
canUseLocalStorage,
+ canUseCrypto,
};
export default AccessorUtilities;
diff --git a/app/assets/javascripts/lib/utils/array_utility.js b/app/assets/javascripts/lib/utils/array_utility.js
index 197e7790ed7..04f9cb1cdb5 100644
--- a/app/assets/javascripts/lib/utils/array_utility.js
+++ b/app/assets/javascripts/lib/utils/array_utility.js
@@ -18,3 +18,13 @@ export const swapArrayItems = (array, leftIndex = 0, rightIndex = 0) => {
copy[rightIndex] = temp;
return copy;
};
+
+/**
+ * Return an array with all duplicate items from the given array
+ *
+ * @param {Array} array - The source array
+ * @returns {Array} new array with all duplicate items
+ */
+export const getDuplicateItemsFromArray = (array) => [
+ ...new Set(array.filter((value, index) => array.indexOf(value) !== index)),
+];
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index cf6ce2c4889..96d019f62f2 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -130,19 +130,6 @@ export const isInViewport = (el, offset = {}) => {
);
};
-export const parseUrl = (url) => {
- const parser = document.createElement('a');
- parser.href = url;
- return parser;
-};
-
-export const parseUrlPathname = (url) => {
- const parsedUrl = parseUrl(url);
- // parsedUrl.pathname will return an absolute path for Firefox and a relative path for IE11
- // We have to make sure we always have an absolute path.
- return parsedUrl.pathname.charAt(0) === '/' ? parsedUrl.pathname : `/${parsedUrl.pathname}`;
-};
-
export const isMetaKey = (e) => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
// Identify following special clicks
diff --git a/app/assets/javascripts/lib/utils/ignore_while_pending.js b/app/assets/javascripts/lib/utils/ignore_while_pending.js
new file mode 100644
index 00000000000..e85a573c8f2
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/ignore_while_pending.js
@@ -0,0 +1,26 @@
+/**
+ * This will wrap the given function to make sure that it is only triggered once
+ * while executing asynchronously
+ *
+ * @param {Function} fn some function that returns a promise
+ * @returns A function that will only be triggered *once* while the promise is executing
+ */
+export const ignoreWhilePending = (fn) => {
+ const isPendingMap = new WeakMap();
+ const defaultContext = {};
+
+ // We need this to be a function so we get the `this`
+ return function ignoreWhilePendingInner(...args) {
+ const context = this || defaultContext;
+
+ if (isPendingMap.get(context)) {
+ return Promise.resolve();
+ }
+
+ isPendingMap.set(context, true);
+
+ return fn.apply(this, args).finally(() => {
+ isPendingMap.delete(context);
+ });
+ };
+};
diff --git a/app/assets/javascripts/lib/utils/rails_ujs.js b/app/assets/javascripts/lib/utils/rails_ujs.js
index 6b1985a23ba..b4f425da871 100644
--- a/app/assets/javascripts/lib/utils/rails_ujs.js
+++ b/app/assets/javascripts/lib/utils/rails_ujs.js
@@ -1,5 +1,6 @@
import Rails from '@rails/ujs';
import { confirmViaGlModal } from './confirm_via_gl_modal/confirm_via_gl_modal';
+import { ignoreWhilePending } from './ignore_while_pending';
function monkeyPatchConfirmModal() {
/**
@@ -18,8 +19,10 @@ function monkeyPatchConfirmModal() {
* @param element {HTMLElement} Element that was clicked on
* @returns {boolean}
*/
+ const safeConfirm = ignoreWhilePending(confirmViaGlModal);
+
function confirmViaModal(message, element) {
- confirmViaGlModal(message, element)
+ safeConfirm(message, element)
.then((confirmed) => {
if (confirmed) {
Rails.confirm = () => true;
diff --git a/app/assets/javascripts/lib/utils/resize_observer.js b/app/assets/javascripts/lib/utils/resize_observer.js
index e72c6fe1679..5d194340b9e 100644
--- a/app/assets/javascripts/lib/utils/resize_observer.js
+++ b/app/assets/javascripts/lib/utils/resize_observer.js
@@ -10,22 +10,30 @@ export function createResizeObserver() {
});
}
-// watches for change in size of a container element (e.g. for lazy-loaded images)
-// and scroll the target element to the top of the content area
-// stop watching after any user input. So if user opens sidebar or manually
-// scrolls the page we don't hijack their scroll position
+/**
+ * Watches for change in size of a container element (e.g. for lazy-loaded images)
+ * and scrolls the target note to the top of the content area.
+ * Stops watching after any user input. So if user opens sidebar or manually
+ * scrolls the page we don't hijack their scroll position
+ *
+ * @param {Object} options
+ * @param {string} options.targetId - id of element to scroll to
+ * @param {string} options.container - Selector of element containing target
+ *
+ * @return {ResizeObserver|null} - ResizeObserver instance if target looks like a note DOM ID
+ */
export function scrollToTargetOnResize({
- target = window.location.hash,
+ targetId = window.location.hash.slice(1),
container = '#content-body',
} = {}) {
- if (!target) return null;
+ if (!targetId) return null;
const ro = createResizeObserver();
const containerEl = document.querySelector(container);
let interactionListenersAdded = false;
function keepTargetAtTop() {
- const anchorEl = document.querySelector(target);
+ const anchorEl = document.getElementById(targetId);
if (!anchorEl) return;
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index ec6789d81ec..ac2eb34260c 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -9,7 +9,7 @@ const LINK_TAG_PATTERN = '[{text}](url)';
// 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>.)?/;
+const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isUl>[*+-])|(?<isOl>\d+\.))( \[([x ])\])?\s)(?<content>.)?/;
function selectedText(text, textarea) {
return text.substring(textarea.selectionStart, textarea.selectionEnd);
@@ -31,8 +31,19 @@ function lineBefore(text, textarea, trimNewlines = true) {
return split[split.length - 1];
}
-function lineAfter(text, textarea) {
- return text.substring(textarea.selectionEnd).trim().split('\n')[0];
+function lineAfter(text, textarea, trimNewlines = true) {
+ let split = text.substring(textarea.selectionEnd);
+
+ if (trimNewlines) {
+ split = split.trim();
+ } else {
+ // remove possible leading newline to get at the real line
+ split = split.replace(/^\n/, '');
+ }
+
+ split = split.split('\n');
+
+ return split[0];
}
function convertMonacoSelectionToAceFormat(sel) {
@@ -329,6 +340,25 @@ function handleSurroundSelectedText(e, textArea) {
}
/* eslint-enable @gitlab/require-i18n-strings */
+/**
+ * Returns the content for a new line following a list item.
+ *
+ * @param {Object} result - regex match of the current line
+ * @param {Object?} nextLineResult - regex match of the next line
+ * @returns string with the new list item
+ */
+function continueOlText(result, nextLineResult) {
+ const { indent, leader } = result.groups;
+ const { indent: nextIndent, isOl: nextIsOl } = nextLineResult?.groups ?? {};
+
+ const [numStr, postfix = ''] = leader.split('.');
+
+ const incrementBy = nextIsOl && nextIndent === indent ? 0 : 1;
+ const num = parseInt(numStr, 10) + incrementBy;
+
+ return `${indent}${num}.${postfix}`;
+}
+
function handleContinueList(e, textArea) {
if (!gon.features?.markdownContinueLists) return;
if (!(e.key === 'Enter')) return;
@@ -339,7 +369,7 @@ function handleContinueList(e, textArea) {
const result = currentLine.match(LIST_LINE_HEAD_PATTERN);
if (result) {
- const { indent, content, leader } = result.groups;
+ const { leader, indent, content, isOl } = result.groups;
const prevLineEmpty = !content;
if (prevLineEmpty) {
@@ -349,12 +379,22 @@ function handleContinueList(e, textArea) {
return;
}
- const itemInsert = `${indent}${leader}`;
+ let itemToInsert;
+
+ if (isOl) {
+ const nextLine = lineAfter(textArea.value, textArea, false);
+ const nextLineResult = nextLine.match(LIST_LINE_HEAD_PATTERN);
+
+ itemToInsert = continueOlText(result, nextLineResult);
+ } else {
+ // isUl
+ itemToInsert = `${indent}${leader}`;
+ }
e.preventDefault();
updateText({
- tag: itemInsert,
+ tag: itemToInsert,
textArea,
blockTag: '',
wrap: false,
@@ -367,6 +407,8 @@ function handleContinueList(e, textArea) {
export function keypressNoteText(e) {
const textArea = this;
+ if ($(textArea).atwho?.('isSelecting')) return;
+
handleContinueList(e, textArea);
handleSurroundSelectedText(e, textArea);
}
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index 12462a2575e..335cd6a16e5 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -18,6 +18,20 @@ function resetRegExp(regex) {
return regex;
}
+/**
+ * Returns the absolute pathname for a relative or absolute URL string.
+ *
+ * A few examples of inputs and outputs:
+ * 1) 'http://a.com/b/c/d' => '/b/c/d'
+ * 2) '/b/c/d' => '/b/c/d'
+ * 3) 'b/c/d' => '/b/c/d' or '[path]/b/c/d' depending of the current path of the
+ * document.location
+ */
+export const parseUrlPathname = (url) => {
+ const { pathname } = new URL(url, document.location.href);
+ return pathname;
+};
+
// Returns a decoded url parameter value
// - Treats '+' as '%20'
function decodeUrlParameter(val) {
diff --git a/app/assets/javascripts/loading_icon_for_legacy_js.js b/app/assets/javascripts/loading_icon_for_legacy_js.js
new file mode 100644
index 00000000000..d50a4275424
--- /dev/null
+++ b/app/assets/javascripts/loading_icon_for_legacy_js.js
@@ -0,0 +1,53 @@
+import Vue from 'vue';
+import { GlLoadingIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+const defaultValue = (prop) => GlLoadingIcon.props[prop]?.default;
+
+/**
+ * Returns a loading icon/spinner element.
+ *
+ * This should *only* be used in existing legacy areas of code where Vue is not
+ * in use, as part of the migration strategy defined in
+ * https://gitlab.com/groups/gitlab-org/-/epics/7626.
+ *
+ * @param {object} props - The props to configure the spinner.
+ * @param {boolean} props.inline - Display the spinner inline; otherwise, as a block.
+ * @param {string} props.color - The color of the spinner ('dark' or 'light')
+ * @param {string} props.size - The size of the spinner ('sm', 'md', 'lg', 'xl')
+ * @param {string[]} props.classes - Additional classes to apply to the element.
+ * @param {string} props.label - The ARIA label to apply to the spinner.
+ * @returns {HTMLElement}
+ */
+export const loadingIconForLegacyJS = ({
+ inline = defaultValue('inline'),
+ color = defaultValue('color'),
+ size = defaultValue('size'),
+ classes = [],
+ label = __('Loading'),
+} = {}) => {
+ const mountEl = document.createElement('div');
+
+ const vm = new Vue({
+ el: mountEl,
+ render(h) {
+ return h(GlLoadingIcon, {
+ class: classes,
+ props: {
+ inline,
+ color,
+ size,
+ label,
+ },
+ });
+ },
+ });
+
+ // Ensure it's rendered
+ vm.$forceUpdate();
+
+ const el = vm.$el.cloneNode(true);
+ vm.$destroy();
+
+ return el;
+};
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index f78b4da181e..b3cb93e74f2 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -116,16 +116,18 @@ function deferredInitialisation() {
);
}
- const search = document.querySelector('#search');
- if (search) {
- search.addEventListener(
+ const searchInputBox = document.querySelector('#search');
+ if (searchInputBox) {
+ searchInputBox.addEventListener(
'focus',
() => {
if (gon.features?.newHeaderSearch) {
import(/* webpackChunkName: 'globalSearch' */ '~/header_search')
.then(async ({ initHeaderSearchApp }) => {
- await initHeaderSearchApp();
- document.querySelector('#search').focus();
+ // In case the user started searching before we bootstrapped, let's pass the search along.
+ const initialSearchValue = searchInputBox.value;
+ await initHeaderSearchApp(initialSearchValue);
+ searchInputBox.focus();
})
.catch(() => {});
} else {
@@ -159,6 +161,12 @@ function deferredInitialisation() {
// Adding a helper class to activate animations only after all is rendered
setTimeout(() => $body.addClass('page-initialised'), 1000);
+
+ if (window.gon?.features?.mrAttentionRequests) {
+ import('~/attention_requests')
+ .then((module) => module.default())
+ .catch(() => {});
+ }
}
const $body = $('body');
diff --git a/app/assets/javascripts/member_expiration_date.js b/app/assets/javascripts/member_expiration_date.js
deleted file mode 100644
index a28427eb9ac..00000000000
--- a/app/assets/javascripts/member_expiration_date.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import $ from 'jquery';
-import Pikaday from 'pikaday';
-import { parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility';
-
-// Add datepickers to all `js-access-expiration-date` elements. If those elements are
-// children of an element with the `clearable-input` class, and have a sibling
-// `js-clear-input` element, then show that element when there is a value in the
-// datepicker, and make clicking on that element clear the field.
-//
-export default function memberExpirationDate(selector = '.js-access-expiration-date') {
- function toggleClearInput() {
- $(this)
- .closest('.clearable-input')
- .toggleClass('has-value', $(this).val() !== '');
- }
- const inputs = $(selector);
-
- inputs.each((i, el) => {
- const $input = $(el);
-
- const calendar = new Pikaday({
- field: $input.get(0),
- theme: 'gitlab-theme animate-picker',
- format: 'yyyy-mm-dd',
- minDate: new Date(),
- container: $input.parent().get(0),
- parse: (dateString) => parsePikadayDate(dateString),
- toString: (date) => pikadayToString(date),
- onSelect(dateText) {
- $input.val(calendar.toString(dateText));
-
- toggleClearInput.call($input);
- },
- firstDay: gon.first_day_of_week,
- });
-
- calendar.setDate(parsePikadayDate($input.val()));
- $input.data('pikaday', calendar);
- });
-
- inputs.next('.js-clear-input').on('click', function clicked(event) {
- event.preventDefault();
-
- const input = $(this).closest('.clearable-input').find(selector);
- const calendar = input.data('pikaday');
-
- calendar.setDate(null);
- toggleClearInput.call(input);
- });
-
- inputs.on('blur', toggleClearInput);
-
- inputs.each(toggleClearInput);
-}
diff --git a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
index 01606d07554..27c67e84675 100644
--- a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
@@ -25,7 +25,8 @@ export default {
},
title: {
type: String,
- required: true,
+ required: false,
+ default: null,
},
icon: {
type: String,
diff --git a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
index 594da7f68cc..122e0a142a9 100644
--- a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
+++ b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
@@ -61,7 +61,7 @@ export default {
};
},
removeMemberButtonText() {
- return this.isInvitedUser ? null : __('Remove user');
+ return this.isInvitedUser ? null : __('Remove member');
},
removeMemberButtonIcon() {
return this.isInvitedUser ? 'remove' : '';
@@ -86,7 +86,6 @@ export default {
:icon="removeMemberButtonIcon"
:button-text="removeMemberButtonText"
:button-category="removeMemberButtonCategory"
- :title="s__('Member|Remove member')"
/>
</div>
<div v-else-if="permissions.canOverride && !member.isOverridden" class="gl-px-1">
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 633dee75237..ca60f876c6f 100644
--- a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
+++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
@@ -1,5 +1,4 @@
<script>
-import { GlFilteredSearchToken } from '@gitlab/ui';
import { mapState } from 'vuex';
import {
getParameterByName,
@@ -7,46 +6,24 @@ import {
queryToObject,
redirectTo,
} from '~/lib/utils/url_utility';
-import { s__ } from '~/locale';
import {
SEARCH_TOKEN_TYPE,
SORT_QUERY_PARAM_NAME,
ACTIVE_TAB_QUERY_PARAM_NAME,
-} from '~/members/constants';
-import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
+ AVAILABLE_FILTERED_SEARCH_TOKENS,
+} from 'ee_else_ce/members/constants';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
export default {
name: 'MembersFilteredSearchBar',
components: { FilteredSearchBar },
- availableTokens: [
- {
- type: 'two_factor',
- icon: 'lock',
- title: s__('Members|2FA'),
- token: GlFilteredSearchToken,
- unique: true,
- operators: OPERATOR_IS_ONLY,
- options: [
- { value: 'enabled', title: s__('Members|Enabled') },
- { value: 'disabled', title: s__('Members|Disabled') },
- ],
- requiredPermissions: 'canManageMembers',
- },
- {
- type: 'with_inherited_permissions',
- icon: 'group',
- title: s__('Members|Membership'),
- token: GlFilteredSearchToken,
- unique: true,
- operators: OPERATOR_IS_ONLY,
- options: [
- { value: 'exclude', title: s__('Members|Direct') },
- { value: 'only', title: s__('Members|Inherited') },
- ],
- },
- ],
- inject: ['namespace', 'sourceId', 'canManageMembers'],
+ availableTokens: AVAILABLE_FILTERED_SEARCH_TOKENS,
+ inject: {
+ namespace: {},
+ sourceId: {},
+ canManageMembers: {},
+ canFilterByEnterprise: { default: false },
+ },
data() {
return {
initialFilterValue: [],
diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js
index 273f1acebc7..49ce00a1689 100644
--- a/app/assets/javascripts/members/constants.js
+++ b/app/assets/javascripts/members/constants.js
@@ -1,4 +1,7 @@
-import { __ } from '~/locale';
+import { GlFilteredSearchToken } from '@gitlab/ui';
+
+import { __, s__ } from '~/locale';
+import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
export const FIELD_KEY_ACCOUNT = 'account';
export const FIELD_KEY_SOURCE = 'source';
@@ -82,6 +85,38 @@ export const DEFAULT_SORT = {
sortDesc: false,
};
+export const FILTERED_SEARCH_TOKEN_TWO_FACTOR = {
+ type: 'two_factor',
+ icon: 'lock',
+ title: s__('Members|2FA'),
+ token: GlFilteredSearchToken,
+ unique: true,
+ operators: OPERATOR_IS_ONLY,
+ options: [
+ { value: 'enabled', title: s__('Members|Enabled') },
+ { value: 'disabled', title: s__('Members|Disabled') },
+ ],
+ requiredPermissions: 'canManageMembers',
+};
+
+export const FILTERED_SEARCH_TOKEN_WITH_INHERITED_PERMISSIONS = {
+ type: 'with_inherited_permissions',
+ icon: 'group',
+ title: s__('Members|Membership'),
+ token: GlFilteredSearchToken,
+ unique: true,
+ operators: OPERATOR_IS_ONLY,
+ options: [
+ { value: 'exclude', title: s__('Members|Direct') },
+ { value: 'only', title: s__('Members|Inherited') },
+ ],
+};
+
+export const AVAILABLE_FILTERED_SEARCH_TOKENS = [
+ FILTERED_SEARCH_TOKEN_TWO_FACTOR,
+ FILTERED_SEARCH_TOKEN_WITH_INHERITED_PERMISSIONS,
+];
+
export const AVATAR_SIZE = 48;
export const MEMBER_TYPES = {
diff --git a/app/assets/javascripts/members/index.js b/app/assets/javascripts/members/index.js
index 510e89240f4..0df876cabd7 100644
--- a/app/assets/javascripts/members/index.js
+++ b/app/assets/javascripts/members/index.js
@@ -18,6 +18,7 @@ export const initMembersApp = (el, options) => {
sourceId,
canManageMembers,
canExportMembers,
+ canFilterByEnterprise,
exportCsvPath,
...vuexStoreAttributes
} = parseDataAttributes(el);
@@ -60,6 +61,7 @@ export const initMembersApp = (el, options) => {
currentUserId: gon.current_user_id || null,
sourceId,
canManageMembers,
+ canFilterByEnterprise,
canExportMembers,
exportCsvPath,
},
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
index 5fcc778a714..fdcb99351a7 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
+++ b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
@@ -1,5 +1,5 @@
<script>
-import { GlSprintf, GlButton, GlButtonGroup } from '@gitlab/ui';
+import { GlSprintf, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui';
import { mapGetters, mapState, mapActions } from 'vuex';
import { __ } from '~/locale';
import FileIcon from '~/vue_shared/components/file_icon.vue';
@@ -23,6 +23,7 @@ export default {
GlButton,
GlButtonGroup,
GlSprintf,
+ GlLoadingIcon,
FileIcon,
DiffFileEditor,
InlineConflictLines,
@@ -72,9 +73,7 @@ export default {
</script>
<template>
<div id="conflicts">
- <div v-if="isLoading" class="loading">
- <div class="spinner spinner-md"></div>
- </div>
+ <gl-loading-icon v-if="isLoading" size="md" data-testid="loading-spinner" />
<div v-if="hasError" class="nothing-here-block">
{{ conflictsData.errorMessage }}
</div>
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index ad0117844cd..61f7a079d77 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -2,13 +2,8 @@
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import $ from 'jquery';
import Vue from 'vue';
-import {
- getCookie,
- parseUrlPathname,
- isMetaClick,
- parseBoolean,
- scrollToElement,
-} from '~/lib/utils/common_utils';
+import { getCookie, isMetaClick, parseBoolean, scrollToElement } from '~/lib/utils/common_utils';
+import { parseUrlPathname } from '~/lib/utils/url_utility';
import createEventHub from '~/helpers/event_hub_factory';
import BlobForkSuggestion from './blob/blob_fork_suggestion';
import Diff from './diff';
@@ -70,6 +65,103 @@ const FAST_DELAY_FOR_RERENDER = 75;
// Store the `location` object, allowing for easier stubbing in tests
let { location } = window;
+function scrollToContainer(container) {
+ if (location.hash) {
+ const $el = $(`${container} ${location.hash}:not(.match)`);
+
+ if ($el.length) {
+ scrollToElement($el[0]);
+ }
+ }
+}
+
+function computeTopOffset(tabs) {
+ const navbar = document.querySelector('.navbar-gitlab');
+ const peek = document.getElementById('js-peek');
+ let stickyTop;
+
+ stickyTop = navbar ? navbar.offsetHeight : 0;
+ stickyTop = peek ? stickyTop + peek.offsetHeight : stickyTop;
+ stickyTop = tabs ? stickyTop + tabs.offsetHeight : stickyTop;
+
+ return stickyTop;
+}
+
+function mountPipelines() {
+ const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view');
+ const { mrWidgetData } = gl;
+ const table = new Vue({
+ components: {
+ CommitPipelinesTable: () => import('~/commit/pipelines/pipelines_table.vue'),
+ },
+ provide: {
+ artifactsEndpoint: pipelineTableViewEl.dataset.artifactsEndpoint,
+ artifactsEndpointPlaceholder: pipelineTableViewEl.dataset.artifactsEndpointPlaceholder,
+ targetProjectFullPath: mrWidgetData?.target_project_full_path || '',
+ },
+ render(createElement) {
+ return createElement('commit-pipelines-table', {
+ props: {
+ endpoint: pipelineTableViewEl.dataset.endpoint,
+ emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath,
+ errorStateSvgPath: pipelineTableViewEl.dataset.errorStateSvgPath,
+ canCreatePipelineInTargetProject: Boolean(
+ mrWidgetData?.can_create_pipeline_in_target_project,
+ ),
+ sourceProjectFullPath: mrWidgetData?.source_project_full_path || '',
+ targetProjectFullPath: mrWidgetData?.target_project_full_path || '',
+ projectId: pipelineTableViewEl.dataset.projectId,
+ mergeRequestId: mrWidgetData ? mrWidgetData.iid : null,
+ },
+ });
+ },
+ }).$mount();
+
+ // $mount(el) replaces the el with the new rendered component. We need it in order to mount
+ // it everytime this tab is clicked - https://vuejs.org/v2/api/#vm-mount
+ pipelineTableViewEl.appendChild(table.$el);
+
+ return table;
+}
+
+function destroyPipelines(app) {
+ if (app && app.$destroy) {
+ app.$destroy();
+
+ document.querySelector('#commit-pipeline-table-view').innerHTML = '';
+ }
+
+ return null;
+}
+
+function loadDiffs({ url, sticky }) {
+ return axios.get(`${url}.json${location.search}`).then(({ data }) => {
+ const $container = $('#diffs');
+ $container.html(data.html);
+ initDiffStatsDropdown(sticky);
+
+ localTimeAgo(document.querySelectorAll('#diffs .js-timeago'));
+ syntaxHighlight($('#diffs .js-syntax-highlight'));
+
+ new Diff();
+ scrollToContainer('#diffs');
+
+ $('.diff-file').each((i, el) => {
+ new BlobForkSuggestion({
+ openButtons: $(el).find('.js-edit-blob-link-fork-toggler'),
+ forkButtons: $(el).find('.js-fork-suggestion-button'),
+ cancelButtons: $(el).find('.js-cancel-fork-suggestion-button'),
+ suggestionSections: $(el).find('.js-file-fork-suggestion-section'),
+ actionTextPieces: $(el).find('.js-file-fork-suggestion-section-action'),
+ }).init();
+ });
+ });
+}
+
+function toggleLoader(state) {
+ $('.mr-loading-status .loading').toggleClass('hide', !state);
+}
+
export default class MergeRequestTabs {
constructor({ action, setUrl, stubLocation } = {}) {
this.mergeRequestTabs = document.querySelector('.merge-request-tabs-container');
@@ -107,13 +199,7 @@ export default class MergeRequestTabs {
}
this.bindEvents();
- if (
- this.mergeRequestTabs &&
- this.mergeRequestTabs.querySelector(`a[data-action='${action}']`) &&
- this.mergeRequestTabs.querySelector(`a[data-action='${action}']`).click
- ) {
- this.mergeRequestTabs.querySelector(`a[data-action='${action}']`).click();
- }
+ this.mergeRequestTabs?.querySelector(`a[data-action='${action}']`)?.click?.();
}
bindEvents() {
@@ -132,15 +218,6 @@ export default class MergeRequestTabs {
$('.merge-request-tabs a[data-toggle="tabvue"]').off('click', this.clickTab);
}
- destroyPipelinesView() {
- if (this.commitPipelinesTable) {
- this.commitPipelinesTable.$destroy();
- this.commitPipelinesTable = null;
-
- document.querySelector('#commit-pipeline-table-view').innerHTML = '';
- }
- }
-
storeScroll() {
if (this.currentTab) {
this.scrollPositions[this.currentTab] = document.documentElement.scrollTop;
@@ -207,11 +284,11 @@ export default class MergeRequestTabs {
this.loadCommits(href);
this.expandView();
this.resetViewContainer();
- this.destroyPipelinesView();
+ this.commitPipelinesTable = destroyPipelines(this.commitPipelinesTable);
} else if (action === 'new') {
this.expandView();
this.resetViewContainer();
- this.destroyPipelinesView();
+ this.commitPipelinesTable = destroyPipelines(this.commitPipelinesTable);
} else if (this.isDiffAction(action)) {
if (!isInVueNoteablePage()) {
/*
@@ -228,7 +305,7 @@ export default class MergeRequestTabs {
this.shrinkView();
}
this.expandViewContainer();
- this.destroyPipelinesView();
+ this.commitPipelinesTable = destroyPipelines(this.commitPipelinesTable);
this.commitsTab.classList.remove('active');
} else if (action === 'pipelines') {
this.resetViewContainer();
@@ -247,7 +324,7 @@ export default class MergeRequestTabs {
this.expandView();
}
this.resetViewContainer();
- this.destroyPipelinesView();
+ this.commitPipelinesTable = destroyPipelines(this.commitPipelinesTable);
}
$('.detail-page-description').renderGFM();
@@ -280,16 +357,6 @@ export default class MergeRequestTabs {
this.eventHub.$emit('MergeRequestTabChange', action);
}
- scrollToContainerElement(container) {
- if (location.hash) {
- const $el = $(`${container} ${location.hash}:not(.match)`);
-
- if ($el.length) {
- scrollToElement($el[0]);
- }
- }
- }
-
// Replaces the current merge request-specific action in the URL with a new one
//
// If the action is "notes", the URL is reset to the standard
@@ -356,7 +423,7 @@ export default class MergeRequestTabs {
return;
}
- this.toggleLoading(true);
+ toggleLoader(true);
axios
.get(`${source}.json`)
@@ -365,15 +432,15 @@ export default class MergeRequestTabs {
commitsDiv.innerHTML = data.html;
localTimeAgo(commitsDiv.querySelectorAll('.js-timeago'));
this.commitsLoaded = true;
- this.scrollToContainerElement('#commits');
+ scrollToContainer('#commits');
- this.toggleLoading(false);
+ toggleLoader(false);
return import('./add_context_commits_modal');
})
.then((m) => m.default())
.catch(() => {
- this.toggleLoading(false);
+ toggleLoader(false);
createFlash({
message: __('An error occurred while fetching this tab.'),
});
@@ -381,39 +448,7 @@ export default class MergeRequestTabs {
}
mountPipelinesView() {
- const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view');
- const { mrWidgetData } = gl;
-
- this.commitPipelinesTable = new Vue({
- components: {
- CommitPipelinesTable: () => import('~/commit/pipelines/pipelines_table.vue'),
- },
- provide: {
- artifactsEndpoint: pipelineTableViewEl.dataset.artifactsEndpoint,
- artifactsEndpointPlaceholder: pipelineTableViewEl.dataset.artifactsEndpointPlaceholder,
- targetProjectFullPath: mrWidgetData?.target_project_full_path || '',
- },
- render(createElement) {
- return createElement('commit-pipelines-table', {
- props: {
- endpoint: pipelineTableViewEl.dataset.endpoint,
- emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath,
- errorStateSvgPath: pipelineTableViewEl.dataset.errorStateSvgPath,
- canCreatePipelineInTargetProject: Boolean(
- mrWidgetData?.can_create_pipeline_in_target_project,
- ),
- sourceProjectFullPath: mrWidgetData?.source_project_full_path || '',
- targetProjectFullPath: mrWidgetData?.target_project_full_path || '',
- projectId: pipelineTableViewEl.dataset.projectId,
- mergeRequestId: mrWidgetData ? mrWidgetData.iid : null,
- },
- });
- },
- }).$mount();
-
- // $mount(el) replaces the el with the new rendered component. We need it in order to mount
- // it everytime this tab is clicked - https://vuejs.org/v2/api/#vm-mount
- pipelineTableViewEl.appendChild(this.commitPipelinesTable.$el);
+ this.commitPipelinesTable = mountPipelines();
}
// load the diff tab content from the backend
@@ -423,57 +458,31 @@ export default class MergeRequestTabs {
return;
}
- // We extract pathname for the current Changes tab anchor href
- // some pages like MergeRequestsController#new has query parameters on that anchor
- const urlPathname = parseUrlPathname(source);
-
- this.toggleLoading(true);
-
- axios
- .get(`${urlPathname}.json${location.search}`)
- .then(({ data }) => {
- const $container = $('#diffs');
- $container.html(data.html);
- initDiffStatsDropdown(this.stickyTop);
-
- localTimeAgo(document.querySelectorAll('#diffs .js-timeago'));
- syntaxHighlight($('#diffs .js-syntax-highlight'));
+ toggleLoader(true);
+ loadDiffs({
+ // We extract pathname for the current Changes tab anchor href
+ // some pages like MergeRequestsController#new has query parameters on that anchor
+ url: parseUrlPathname(source),
+ sticky: computeTopOffset(this.mergeRequestTabs),
+ })
+ .then(() => {
if (this.isDiffAction(this.currentAction)) {
this.expandViewContainer();
}
- this.diffsLoaded = true;
- new Diff();
- this.scrollToContainerElement('#diffs');
-
- $('.diff-file').each((i, el) => {
- new BlobForkSuggestion({
- openButtons: $(el).find('.js-edit-blob-link-fork-toggler'),
- forkButtons: $(el).find('.js-fork-suggestion-button'),
- cancelButtons: $(el).find('.js-cancel-fork-suggestion-button'),
- suggestionSections: $(el).find('.js-file-fork-suggestion-section'),
- actionTextPieces: $(el).find('.js-file-fork-suggestion-section-action'),
- }).init();
- });
-
- this.toggleLoading(false);
+ this.diffsLoaded = true;
})
.catch(() => {
- this.toggleLoading(false);
createFlash({
message: __('An error occurred while fetching this tab.'),
});
+ })
+ .finally(() => {
+ toggleLoader(false);
});
}
- // Show or hide the loading spinner
- //
- // status - Boolean, true to show, false to hide
- toggleLoading(status) {
- $('.mr-loading-status .loading').toggleClass('hide', !status);
- }
-
diffViewType() {
return $('.js-diff-view-buttons button.active').data('viewType');
}
@@ -529,18 +538,4 @@ export default class MergeRequestTabs {
}
}, 0);
}
-
- get stickyTop() {
- let stickyTop = this.navbar ? this.navbar.offsetHeight : 0;
-
- if (this.peek) {
- stickyTop += this.peek.offsetHeight;
- }
-
- if (this.mergeRequestTabs) {
- stickyTop += this.mergeRequestTabs.offsetHeight;
- }
-
- return stickyTop;
- }
}
diff --git a/app/assets/javascripts/monitoring/components/charts/bar.vue b/app/assets/javascripts/monitoring/components/charts/bar.vue
index a4cef5ea256..1e0f4b10297 100644
--- a/app/assets/javascripts/monitoring/components/charts/bar.vue
+++ b/app/assets/javascripts/monitoring/components/charts/bar.vue
@@ -1,5 +1,4 @@
<script>
-import { GlResizeObserverDirective } from '@gitlab/ui';
import { GlBarChart } from '@gitlab/ui/dist/charts';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import { chartHeight } from '../../constants';
@@ -9,9 +8,6 @@ export default {
components: {
GlBarChart,
},
- directives: {
- GlResizeObserverDirective,
- },
props: {
graphData: {
type: Object,
@@ -60,11 +56,6 @@ export default {
formatLegendLabel(query) {
return query.label;
},
- onResize() {
- if (!this.$refs.barChart) return;
- const { width } = this.$refs.barChart.$el.getBoundingClientRect();
- this.width = width;
- },
setSvg(name) {
getSvgIconPathContent(name)
.then((path) => {
@@ -81,17 +72,16 @@ export default {
};
</script>
<template>
- <div v-gl-resize-observer-directive="onResize">
- <gl-bar-chart
- ref="barChart"
- v-bind="$attrs"
- :data="chartData"
- :option="chartOptions"
- :width="width"
- :height="height"
- :x-axis-title="xAxisTitle"
- :y-axis-title="yAxisTitle"
- :x-axis-type="xAxisType"
- />
- </div>
+ <gl-bar-chart
+ ref="barChart"
+ v-bind="$attrs"
+ :responsive="true"
+ :data="chartData"
+ :option="chartOptions"
+ :width="width"
+ :height="height"
+ :x-axis-title="xAxisTitle"
+ :y-axis-title="yAxisTitle"
+ :x-axis-type="xAxisType"
+ />
</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/column.vue b/app/assets/javascripts/monitoring/components/charts/column.vue
index 37251af2049..e8f54b1fa34 100644
--- a/app/assets/javascripts/monitoring/components/charts/column.vue
+++ b/app/assets/javascripts/monitoring/components/charts/column.vue
@@ -1,5 +1,4 @@
<script>
-import { GlResizeObserverDirective } from '@gitlab/ui';
import { GlColumnChart } from '@gitlab/ui/dist/charts';
import { makeDataSeries } from '~/helpers/monitor_helper';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
@@ -12,9 +11,6 @@ export default {
components: {
GlColumnChart,
},
- directives: {
- GlResizeObserverDirective,
- },
props: {
graphData: {
type: Object,
@@ -83,11 +79,6 @@ export default {
formatLegendLabel(query) {
return query.label;
},
- onResize() {
- if (!this.$refs.columnChart) return;
- const { width } = this.$refs.columnChart.$el.getBoundingClientRect();
- this.width = width;
- },
setSvg(name) {
getSvgIconPathContent(name)
.then((path) => {
@@ -101,17 +92,16 @@ export default {
};
</script>
<template>
- <div v-gl-resize-observer-directive="onResize">
- <gl-column-chart
- ref="columnChart"
- v-bind="$attrs"
- :bars="barChartData"
- :option="chartOptions"
- :width="width"
- :height="height"
- :x-axis-title="xAxisTitle"
- :y-axis-title="yAxisTitle"
- :x-axis-type="xAxisType"
- />
- </div>
+ <gl-column-chart
+ ref="columnChart"
+ v-bind="$attrs"
+ :responsive="true"
+ :bars="barChartData"
+ :option="chartOptions"
+ :width="width"
+ :height="height"
+ :x-axis-title="xAxisTitle"
+ :y-axis-title="yAxisTitle"
+ :x-axis-type="xAxisType"
+ />
</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/gauge.vue b/app/assets/javascripts/monitoring/components/charts/gauge.vue
index 461ff06be72..0477ff19ffe 100644
--- a/app/assets/javascripts/monitoring/components/charts/gauge.vue
+++ b/app/assets/javascripts/monitoring/components/charts/gauge.vue
@@ -1,5 +1,4 @@
<script>
-import { GlResizeObserverDirective } from '@gitlab/ui';
import { GlGaugeChart } from '@gitlab/ui/dist/charts';
import { isFinite, isArray, isInteger } from 'lodash';
import { getFormatter, SUPPORTED_FORMATS } from '~/lib/utils/unit_format';
@@ -10,9 +9,6 @@ export default {
components: {
GlGaugeChart,
},
- directives: {
- GlResizeObserverDirective,
- },
props: {
graphData: {
type: Object,
@@ -96,27 +92,19 @@ export default {
return this.queryResult || NaN;
},
},
- methods: {
- onResize() {
- if (!this.$refs.gaugeChart) return;
- const { width } = this.$refs.gaugeChart.$el.getBoundingClientRect();
- this.width = width;
- },
- },
};
</script>
<template>
- <div v-gl-resize-observer-directive="onResize">
- <gl-gauge-chart
- ref="gaugeChart"
- v-bind="$attrs"
- :value="value"
- :min="rangeValues.min"
- :max="rangeValues.max"
- :thresholds="thresholdsValue"
- :text="textValue"
- :split-number="splitValue"
- :width="width"
- />
- </div>
+ <gl-gauge-chart
+ ref="gaugeChart"
+ v-bind="$attrs"
+ :responsive="true"
+ :value="value"
+ :min="rangeValues.min"
+ :max="rangeValues.max"
+ :thresholds="thresholdsValue"
+ :text="textValue"
+ :split-number="splitValue"
+ :width="width"
+ />
</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue
index ed888ef022c..12add274a90 100644
--- a/app/assets/javascripts/monitoring/components/charts/heatmap.vue
+++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue
@@ -1,5 +1,4 @@
<script>
-import { GlResizeObserverDirective } from '@gitlab/ui';
import { GlHeatmap } from '@gitlab/ui/dist/charts';
import { formatDate, timezones, formats } from '../../format_date';
import { graphDataValidatorForValues } from '../../utils';
@@ -8,9 +7,6 @@ export default {
components: {
GlHeatmap,
},
- directives: {
- GlResizeObserverDirective,
- },
props: {
graphData: {
type: Object,
@@ -61,26 +57,18 @@ export default {
return this.graphData.metrics[0];
},
},
- methods: {
- onResize() {
- if (this.$refs.heatmapChart) return;
- const { width } = this.$refs.heatmapChart.$el.getBoundingClientRect();
- this.width = width;
- },
- },
};
</script>
<template>
- <div v-gl-resize-observer-directive="onResize">
- <gl-heatmap
- ref="heatmapChart"
- v-bind="$attrs"
- :data-series="chartData"
- :x-axis-name="xAxisName"
- :y-axis-name="yAxisName"
- :x-axis-labels="xAxisLabels"
- :y-axis-labels="yAxisLabels"
- :width="width"
- />
- </div>
+ <gl-heatmap
+ ref="heatmapChart"
+ v-bind="$attrs"
+ :responsive="true"
+ :data-series="chartData"
+ :x-axis-name="xAxisName"
+ :y-axis-name="yAxisName"
+ :x-axis-labels="xAxisLabels"
+ :y-axis-labels="yAxisLabels"
+ :width="width"
+ />
</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue
index a53f899f752..0cf39448d6b 100644
--- a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue
+++ b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue
@@ -1,5 +1,4 @@
<script>
-import { GlResizeObserverDirective } from '@gitlab/ui';
import { GlStackedColumnChart } from '@gitlab/ui/dist/charts';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import { s__ } from '~/locale';
@@ -12,9 +11,6 @@ export default {
components: {
GlStackedColumnChart,
},
- directives: {
- GlResizeObserverDirective,
- },
props: {
graphData: {
type: Object,
@@ -125,32 +121,26 @@ export default {
console.error('SVG could not be rendered correctly: ', e);
});
},
- onResize() {
- if (!this.$refs.chart) return;
- const { width } = this.$refs.chart.$el.getBoundingClientRect();
- this.width = width;
- },
},
};
</script>
<template>
- <div v-gl-resize-observer-directive="onResize">
- <gl-stacked-column-chart
- ref="chart"
- v-bind="$attrs"
- :bars="chartData"
- :option="chartOptions"
- :x-axis-title="xAxisTitle"
- :y-axis-title="yAxisTitle"
- :x-axis-type="xAxisType"
- :group-by="groupBy"
- :width="width"
- :height="height"
- :legend-layout="legendLayout"
- :legend-average-text="legendAverageText"
- :legend-current-text="legendCurrentText"
- :legend-max-text="legendMaxText"
- :legend-min-text="legendMinText"
- />
- </div>
+ <gl-stacked-column-chart
+ ref="chart"
+ v-bind="$attrs"
+ :responsive="true"
+ :bars="chartData"
+ :option="chartOptions"
+ :x-axis-title="xAxisTitle"
+ :y-axis-title="yAxisTitle"
+ :x-axis-type="xAxisType"
+ :group-by="groupBy"
+ :width="width"
+ :height="height"
+ :legend-layout="legendLayout"
+ :legend-average-text="legendAverageText"
+ :legend-current-text="legendCurrentText"
+ :legend-max-text="legendMaxText"
+ :legend-min-text="legendMinText"
+ />
</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue
index 5529a94874b..a95b143920b 100644
--- a/app/assets/javascripts/monitoring/components/charts/time_series.vue
+++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLink, GlTooltip, GlResizeObserverDirective, GlIcon } from '@gitlab/ui';
+import { GlLink, GlTooltip, GlIcon } from '@gitlab/ui';
import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import { isEmpty, omit, throttle } from 'lodash';
import { makeDataSeries } from '~/helpers/monitor_helper';
@@ -28,9 +28,6 @@ export default {
GlLink,
GlIcon,
},
- directives: {
- GlResizeObserverDirective,
- },
inheritAttrs: false,
props: {
graphData: {
@@ -366,64 +363,58 @@ export default {
eChart.off('datazoom');
eChart.on('datazoom', this.throttledDatazoom);
},
- onResize() {
- if (!this.$refs.chart) return;
- const { width } = this.$refs.chart.$el.getBoundingClientRect();
- this.width = width;
- },
},
};
</script>
<template>
- <div v-gl-resize-observer-directive="onResize">
- <component
- :is="glChartComponent"
- ref="chart"
- v-bind="$attrs"
- :group-id="groupId"
- :data="chartData"
- :option="chartOptions"
- :format-tooltip-text="formatTooltipText"
- :format-annotations-tooltip-text="formatAnnotationsTooltipText"
- :width="width"
- :height="height"
- :legend-layout="legendLayout"
- :legend-average-text="legendAverageText"
- :legend-current-text="legendCurrentText"
- :legend-max-text="legendMaxText"
- :legend-min-text="legendMinText"
- @created="onChartCreated"
- @updated="onChartUpdated"
- >
- <template #tooltip-title>
- <template v-if="tooltip.type === 'deployments'">
- {{ __('Deployed') }}
- </template>
- <div v-else class="text-nowrap">
- {{ tooltip.title }}
- </div>
+ <component
+ :is="glChartComponent"
+ ref="chart"
+ v-bind="$attrs"
+ :responsive="true"
+ :group-id="groupId"
+ :data="chartData"
+ :option="chartOptions"
+ :format-tooltip-text="formatTooltipText"
+ :format-annotations-tooltip-text="formatAnnotationsTooltipText"
+ :width="width"
+ :height="height"
+ :legend-layout="legendLayout"
+ :legend-average-text="legendAverageText"
+ :legend-current-text="legendCurrentText"
+ :legend-max-text="legendMaxText"
+ :legend-min-text="legendMinText"
+ @created="onChartCreated"
+ @updated="onChartUpdated"
+ >
+ <template #tooltip-title>
+ <template v-if="tooltip.type === 'deployments'">
+ {{ __('Deployed') }}
</template>
- <template #tooltip-content>
- <div v-if="tooltip.type === 'deployments'" class="d-flex align-items-center">
- <gl-icon name="commit" class="mr-2" />
- <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link>
- </div>
- <template v-else>
- <div
- v-for="(content, key) in tooltip.content"
- :key="key"
- class="d-flex justify-content-between"
- >
- <gl-chart-series-label :color="isMultiSeries ? content.color : ''">
- {{ content.name }}
- </gl-chart-series-label>
- <div class="gl-ml-7">
- {{ content.value }}
- </div>
+ <div v-else class="text-nowrap">
+ {{ tooltip.title }}
+ </div>
+ </template>
+ <template #tooltip-content>
+ <div v-if="tooltip.type === 'deployments'" class="d-flex align-items-center">
+ <gl-icon name="commit" class="mr-2" />
+ <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link>
+ </div>
+ <template v-else>
+ <div
+ v-for="(content, key) in tooltip.content"
+ :key="key"
+ class="d-flex justify-content-between"
+ >
+ <gl-chart-series-label :color="isMultiSeries ? content.color : ''">
+ {{ content.name }}
+ </gl-chart-series-label>
+ <div class="gl-ml-7">
+ {{ content.value }}
</div>
- </template>
+ </div>
</template>
- </component>
- </div>
+ </template>
+ </component>
</template>
diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue
index 102afaf308f..d5a7fc36ace 100644
--- a/app/assets/javascripts/notes/components/discussion_filter.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter.vue
@@ -116,7 +116,7 @@ export default {
<gl-dropdown
v-if="displayFilters"
id="discussion-filter-dropdown"
- class="gl-mr-3 full-width-mobile discussion-filter-container js-discussion-filter-container"
+ class="full-width-mobile discussion-filter-container js-discussion-filter-container"
data-qa-selector="discussion_filter_dropdown"
:text="currentFilter.title"
:disabled="isLoading"
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index 0925195d4bb..71d767c3b95 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -6,6 +6,7 @@ import {
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
import { mapActions } from 'vuex';
+import { __ } from '~/locale';
import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UserNameWithStatus from '../../sidebar/components/assignees/user_name_with_status.vue';
@@ -139,6 +140,10 @@ export default {
return selectedAuthor?.availability || '';
},
},
+ i18n: {
+ showThread: __('Show thread'),
+ hideThread: __('Hide thread'),
+ },
};
</script>
@@ -148,10 +153,16 @@ export default {
<button
class="note-action-button discussion-toggle-button js-vue-toggle-button"
type="button"
+ data-testid="thread-toggle"
@click="handleToggle"
>
<gl-icon ref="chevronIcon" :name="toggleChevronIconName" />
- {{ __('Toggle thread') }}
+ <template v-if="expanded">
+ {{ $options.i18n.hideThread }}
+ </template>
+ <template v-else>
+ {{ $options.i18n.showThread }}
+ </template>
</button>
</div>
<template v-if="hasAuthor">
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index ddf72587ba3..c4602363da1 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -6,6 +6,7 @@ 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 { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
import { s__, __ } from '~/locale';
import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
@@ -171,7 +172,7 @@ export default {
this.expandDiscussion({ discussionId: this.discussion.id });
}
},
- async cancelReplyForm(shouldConfirm, isDirty) {
+ cancelReplyForm: ignoreWhilePending(async function cancelReplyForm(shouldConfirm, isDirty) {
if (shouldConfirm && isDirty) {
const msg = s__('Notes|Are you sure you want to cancel creating this comment?');
@@ -188,7 +189,7 @@ export default {
this.isReplying = false;
clearDraft(this.autosaveKey);
- },
+ }),
saveReply(noteText, form, callback) {
if (!noteText) {
this.cancelReplyForm();
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 7bad10616cc..a271ac91f6e 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -7,6 +7,7 @@ import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_m
import { INLINE_DIFF_LINES_KEY } from '~/diffs/constants';
import createFlash from '~/flash';
import httpStatusCodes from '~/lib/utils/http_status';
+import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
import { truncateSha } from '~/lib/utils/text_utility';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import { __, s__, sprintf } from '../../locale';
@@ -350,7 +351,10 @@ export default {
parent: this.$el,
});
},
- async formCancelHandler({ shouldConfirm, isDirty }) {
+ formCancelHandler: ignoreWhilePending(async function formCancelHandler({
+ shouldConfirm,
+ isDirty,
+ }) {
if (shouldConfirm && isDirty) {
const msg = __('Are you sure you want to cancel editing this comment?');
const confirmed = await confirmAction(msg);
@@ -364,7 +368,7 @@ export default {
}
this.isEditing = false;
this.$emit('cancelForm');
- },
+ }),
recoverNoteContent(noteText) {
// we need to do this to prevent noteForm inconsistent content warning
// this is something we intentionally do so we need to recover the content
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue
index 56d2ff86fb7..1b7d5af6134 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue
@@ -1,7 +1,11 @@
<script>
import { GlSprintf, GlAlert, GlLink } from '@gitlab/ui';
-import { ALERT_MESSAGES, ADMIN_GARBAGE_COLLECTION_TIP } from '../../constants/index';
+import {
+ ALERT_MESSAGES,
+ ADMIN_GARBAGE_COLLECTION_TIP,
+ ALERT_DANGER_IMPORTING,
+} from '../../constants/index';
export default {
components: {
@@ -23,6 +27,7 @@ export default {
},
},
garbageCollectionHelpPagePath: { type: String, required: false, default: '' },
+ containerRegistryImportingHelpPagePath: { type: String, required: false, default: '' },
isAdmin: {
type: Boolean,
default: false,
@@ -48,6 +53,11 @@ export default {
}
return config;
},
+ alertHref() {
+ return this.deleteAlertType === ALERT_DANGER_IMPORTING
+ ? this.containerRegistryImportingHelpPagePath
+ : this.garbageCollectionHelpPagePath;
+ },
},
};
</script>
@@ -61,7 +71,7 @@ export default {
>
<gl-sprintf :message="deleteAlertConfig.message">
<template #docLink="{ content }">
- <gl-link :href="garbageCollectionHelpPagePath" target="_blank">
+ <gl-link :href="alertHref" target="_blank">
{{ content }}
</gl-link>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
index 29c181f04fb..ab0418388cd 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
@@ -4,6 +4,7 @@ import { sprintf, n__, s__ } from '~/locale';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
import {
UPDATED_AT,
CLEANUP_UNSCHEDULED_TEXT,
@@ -23,7 +24,7 @@ import {
ROOT_IMAGE_TOOLTIP,
} from '../../constants/index';
-import getContainerRepositoryTagsCountQuery from '../../graphql/queries/get_container_repository_tags_count.query.graphql';
+import getContainerRepositoryMetadata from '../../graphql/queries/get_container_repository_metadata.query.graphql';
export default {
name: 'DetailsHeader',
@@ -50,7 +51,7 @@ export default {
},
apollo: {
containerRepository: {
- query: getContainerRepositoryTagsCountQuery,
+ query: getContainerRepositoryMetadata,
variables() {
return {
id: this.image.id,
@@ -101,6 +102,10 @@ export default {
imageName() {
return this.imageDetails.name || ROOT_IMAGE_TEXT;
},
+ formattedSize() {
+ const { size } = this.imageDetails;
+ return size ? numberToHumanSize(Number(size)) : null;
+ },
},
};
</script>
@@ -119,10 +124,15 @@ export default {
:aria-label="rootImageTooltip"
/>
</template>
+
<template #metadata-tags-count>
<metadata-item icon="tag" :text="tagCountText" data-testid="tags-count" />
</template>
+ <template v-if="formattedSize" #metadata-size>
+ <metadata-item icon="disk" :text="formattedSize" data-testid="image-size" />
+ </template>
+
<template #metadata-cleanup>
<metadata-item
icon="expire"
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
index 8b8769a884d..3c7f7ca9aa8 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
@@ -93,6 +93,10 @@ export const DETAILS_DELETE_IMAGE_ERROR_MESSAGE = s__(
'ContainerRegistry|Something went wrong while scheduling the image for deletion.',
);
+export const DETAILS_IMPORTING_ERROR_MESSAGE = s__(
+ 'ContainerRegistry|Tags temporarily cannot be marked for deletion. Please try again in a few minutes. %{docLinkStart}More details%{docLinkEnd}.',
+);
+
export const DELETE_IMAGE_CONFIRMATION_TITLE = s__('ContainerRegistry|Delete image repository?');
export const DELETE_IMAGE_CONFIRMATION_TEXT = s__(
'ContainerRegistry|Deleting the image repository will delete all images and tags inside. This action cannot be undone. Please type the following to confirm: %{code}',
@@ -133,6 +137,7 @@ export const ALERT_DANGER_TAG = 'danger_tag';
export const ALERT_SUCCESS_TAGS = 'success_tags';
export const ALERT_DANGER_TAGS = 'danger_tags';
export const ALERT_DANGER_IMAGE = 'danger_image';
+export const ALERT_DANGER_IMPORTING = 'danger_importing';
export const DELETE_SCHEDULED = 'DELETE_SCHEDULED';
export const DELETE_FAILED = 'DELETE_FAILED';
@@ -143,6 +148,7 @@ export const ALERT_MESSAGES = {
[ALERT_SUCCESS_TAGS]: DELETE_TAGS_SUCCESS_MESSAGE,
[ALERT_DANGER_TAGS]: DELETE_TAGS_ERROR_MESSAGE,
[ALERT_DANGER_IMAGE]: DETAILS_DELETE_IMAGE_ERROR_MESSAGE,
+ [ALERT_DANGER_IMPORTING]: DETAILS_IMPORTING_ERROR_MESSAGE,
};
export const UNFINISHED_STATUS = 'UNFINISHED';
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_metadata.query.graphql
index 9092a71edb0..f1f67b98407 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_metadata.query.graphql
@@ -1,6 +1,7 @@
-query getContainerRepositoryTagsCount($id: ID!) {
+query getContainerRepositoryMetadata($id: ID!) {
containerRepository(id: $id) {
id
tagsCount
+ size
}
}
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 931849c9918..71a85d8885e 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
@@ -20,6 +20,7 @@ import {
ALERT_SUCCESS_TAGS,
ALERT_DANGER_TAGS,
ALERT_DANGER_IMAGE,
+ ALERT_DANGER_IMPORTING,
FETCH_IMAGES_LIST_ERROR_MESSAGE,
UNFINISHED_STATUS,
MISSING_OR_DELETED_IMAGE_BREADCRUMB,
@@ -32,6 +33,8 @@ import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_c
import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql';
import getContainerRepositoryTagsQuery from '../graphql/queries/get_container_repository_tags.query.graphql';
+const REPOSITORY_IMPORTING_ERROR_MESSAGE = 'repository importing';
+
export default {
name: 'RegistryDetailsPage',
components: {
@@ -147,12 +150,17 @@ export default {
});
if (data?.destroyContainerRepositoryTags?.errors[0]) {
- throw new Error();
+ throw new Error(data.destroyContainerRepositoryTags.errors[0]);
}
this.deleteAlertType =
itemsToBeDeleted.length === 0 ? ALERT_SUCCESS_TAG : ALERT_SUCCESS_TAGS;
} catch (e) {
- this.deleteAlertType = itemsToBeDeleted.length === 0 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS;
+ if (e.message === REPOSITORY_IMPORTING_ERROR_MESSAGE) {
+ this.deleteAlertType = ALERT_DANGER_IMPORTING;
+ } else {
+ this.deleteAlertType =
+ itemsToBeDeleted.length === 0 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS;
+ }
}
this.mutationLoading = false;
@@ -188,6 +196,7 @@ export default {
<delete-alert
v-model="deleteAlertType"
:garbage-collection-help-page-path="config.garbageCollectionHelpPagePath"
+ :container-registry-importing-help-page-path="config.containerRegistryImportingHelpPagePath"
:is-admin="config.isAdmin"
class="gl-my-2"
/>
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 e2acebf39d6..5f9e614bebb 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
@@ -13,9 +13,8 @@ import getContainerRepositoriesQuery from 'shared_queries/container_registry/get
import createFlash from '~/flash';
import CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
-import { extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
import Tracking from '~/tracking';
-import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
+import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
import DeleteImage from '../components/delete_image.vue';
import RegistryHeader from '../components/list_page/registry_header.vue';
@@ -61,8 +60,8 @@ export default {
GlSkeletonLoader,
RegistryHeader,
DeleteImage,
- RegistrySearch,
CleanupPolicyEnabledAlert,
+ PersistedSearch,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -130,8 +129,7 @@ export default {
containerRepositoriesCount: 0,
itemToDelete: {},
deleteAlertType: null,
- filter: [],
- sorting: { orderBy: 'UPDATED', sort: 'desc' },
+ sorting: null,
name: null,
mutationLoading: false,
fetchBaseQuery: false,
@@ -154,7 +152,7 @@ export default {
queryVariables() {
return {
name: this.name,
- sort: this.sortBy,
+ sort: this.sorting,
fullPath: this.config.isGroupPage ? this.config.groupPath : this.config.projectPath,
isGroupPage: this.config.isGroupPage,
first: GRAPHQL_PAGE_SIZE,
@@ -182,24 +180,6 @@ export default {
? DELETE_IMAGE_SUCCESS_MESSAGE
: DELETE_IMAGE_ERROR_MESSAGE;
},
- sortBy() {
- const { orderBy, sort } = this.sorting;
- return `${orderBy}_${sort}`.toUpperCase();
- },
- },
- mounted() {
- const { sorting, filters } = extractFilterAndSorting(this.$route.query);
-
- this.filter = [...filters];
- this.name = filters[0]?.value.data;
- this.sorting = { ...this.sorting, ...sorting };
-
- // If the two graphql calls - which are not batched - resolve togheter we will have a race
- // condition when apollo sets the cache, with this we give the 'base' call an headstart
- this.fetchBaseQuery = true;
- setTimeout(() => {
- this.fetchAdditionalDetails = true;
- }, 200);
},
methods: {
deleteImage(item) {
@@ -258,18 +238,20 @@ export default {
this.track('confirm_delete');
this.mutationLoading = true;
},
- updateSorting(value) {
- this.sorting = {
- ...this.sorting,
- ...value,
- };
- },
- doFilter() {
- const search = this.filter.find((i) => i.type === FILTERED_SEARCH_TERM);
+ handleSearchUpdate({ sort, filters }) {
+ this.sorting = sort;
+
+ const search = filters.find((i) => i.type === FILTERED_SEARCH_TERM);
this.name = search?.value?.data;
- },
- updateUrlQueryString(query) {
- this.$router.push({ query });
+
+ if (!this.fetchBaseQuery && !this.fetchAdditionalDetails) {
+ // If the two graphql calls - which are not batched - resolve together we will have a race
+ // condition when apollo sets the cache, with this we give the 'base' call an headstart
+ this.fetchBaseQuery = true;
+ setTimeout(() => {
+ this.fetchAdditionalDetails = true;
+ }, 200);
+ }
},
},
};
@@ -332,16 +314,12 @@ export default {
/>
</template>
</registry-header>
-
- <registry-search
- :filter="filter"
- :sorting="sorting"
- :tokens="[]"
+ <persisted-search
+ class="gl-mb-5"
:sortable-fields="$options.searchConfig"
- @sorting:changed="updateSorting"
- @filter:changed="filter = $event"
- @filter:submit="doFilter"
- @query:changed="updateUrlQueryString"
+ :default-order="$options.searchConfig[0].orderBy"
+ default-sort="desc"
+ @update="handleSearchUpdate"
/>
<div v-if="isLoading" class="gl-mt-5">
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue
index 6030af9d2c3..ae2d5f4fbc5 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue
@@ -13,7 +13,6 @@ import {
REMOVE_INFO_TEXT,
EXPIRATION_SCHEDULE_LABEL,
NAME_REGEX_LABEL,
- NAME_REGEX_PLACEHOLDER,
NAME_REGEX_DESCRIPTION,
CADENCE_LABEL,
EXPIRATION_POLICY_FOOTER_NOTE,
@@ -68,7 +67,6 @@ export default {
REMOVE_INFO_TEXT,
EXPIRATION_SCHEDULE_LABEL,
NAME_REGEX_LABEL,
- NAME_REGEX_PLACEHOLDER,
NAME_REGEX_DESCRIPTION,
CADENCE_LABEL,
EXPIRATION_POLICY_FOOTER_NOTE,
@@ -141,6 +139,17 @@ export default {
[model]: state,
};
},
+ encapsulateError(path, message) {
+ return {
+ graphQLErrors: [
+ {
+ extensions: {
+ problems: [{ path: [path], message }],
+ },
+ },
+ ],
+ };
+ },
submit() {
this.track('submit_form');
this.apiErrors = {};
@@ -156,7 +165,8 @@ export default {
.then(({ data }) => {
const errorMessage = data?.updateContainerExpirationPolicy?.errors[0];
if (errorMessage) {
- this.$toast.show(errorMessage);
+ const customError = this.encapsulateError('nameRegex', errorMessage);
+ throw customError;
} else {
this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE);
}
@@ -273,7 +283,6 @@ export default {
:error="apiErrors.nameRegex"
:disabled="isFieldDisabled"
:label="$options.i18n.NAME_REGEX_LABEL"
- :placeholder="$options.i18n.NAME_REGEX_PLACEHOLDER"
:description="$options.i18n.NAME_REGEX_DESCRIPTION"
name="remove-regex"
data-testid="remove-regex-input"
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
index 4d477fbd05d..841585c5646 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
@@ -32,7 +32,6 @@ export const REMOVE_INFO_TEXT = s__(
);
export const EXPIRATION_SCHEDULE_LABEL = s__('ContainerRegistry|Remove tags older than:');
export const NAME_REGEX_LABEL = s__('ContainerRegistry|Remove tags matching:');
-export const NAME_REGEX_PLACEHOLDER = '.*';
export const NAME_REGEX_DESCRIPTION = s__(
'ContainerRegistry|Tags with names that match this regex pattern are removed. %{linkStart}View regex examples.%{linkEnd}',
);
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/utils/cache_update.js b/app/assets/javascripts/packages_and_registries/settings/project/graphql/utils/cache_update.js
index c4b2af13862..5e0be3834cb 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/graphql/utils/cache_update.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/utils/cache_update.js
@@ -10,6 +10,7 @@ export const updateContainerExpirationPolicy = (projectPath) => (client, { data:
const data = produce(sourceData, (draftState) => {
draftState.project.containerExpirationPolicy = {
+ ...draftState.project.containerExpirationPolicy,
...updatedData.updateContainerExpirationPolicy.containerExpirationPolicy,
};
});
diff --git a/app/assets/javascripts/pages/admin/applications/index.js b/app/assets/javascripts/pages/admin/applications/index.js
new file mode 100644
index 00000000000..3397b02aeba
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/applications/index.js
@@ -0,0 +1,3 @@
+import initApplicationDeleteButtons from '~/admin/applications';
+
+initApplicationDeleteButtons();
diff --git a/app/assets/javascripts/pages/admin/clusters/new/index.js b/app/assets/javascripts/pages/admin/clusters/connect/index.js
index de9ded87ef3..de9ded87ef3 100644
--- a/app/assets/javascripts/pages/admin/clusters/new/index.js
+++ b/app/assets/javascripts/pages/admin/clusters/connect/index.js
diff --git a/app/assets/javascripts/pages/admin/topics/edit/index.js b/app/assets/javascripts/pages/admin/topics/edit/index.js
index c4e05bbd092..f5e6d044865 100644
--- a/app/assets/javascripts/pages/admin/topics/edit/index.js
+++ b/app/assets/javascripts/pages/admin/topics/edit/index.js
@@ -2,7 +2,10 @@ import $ from 'jquery';
import GLForm from '~/gl_form';
import initFilePickers from '~/file_pickers';
import ZenMode from '~/zen_mode';
+import initRemoveAvatar from '~/admin/topics';
new GLForm($('.js-project-topic-form')); // eslint-disable-line no-new
initFilePickers();
new ZenMode(); // eslint-disable-line no-new
+
+initRemoveAvatar();
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
index cabb1b24ae6..c4bbbdcd8ec 100644
--- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js
+++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
@@ -96,6 +96,8 @@ export default class Todos {
target.setAttribute('disabled', true);
target.classList.add('disabled');
+ target.querySelector('.gl-spinner-container').classList.add('gl-mr-2');
+
axios[target.dataset.method](target.dataset.href)
.then(({ data }) => {
this.updateRowState(target);
@@ -118,6 +120,8 @@ export default class Todos {
target.removeAttribute('disabled');
target.classList.remove('disabled');
+ target.querySelector('.gl-spinner-container').classList.remove('gl-mr-2');
+
if (isInactive === true) {
restoreBtn.classList.add('hidden');
doneBtn.classList.remove('hidden');
@@ -140,6 +144,8 @@ export default class Todos {
target.setAttribute('disabled', true);
target.classList.add('disabled');
+ target.querySelector('.gl-spinner-container').classList.add('gl-mr-2');
+
axios[target.dataset.method](target.dataset.href, {
ids: this.todo_ids,
})
@@ -163,6 +169,8 @@ export default class Todos {
target.removeAttribute('disabled');
target.classList.remove('disabled');
+ target.querySelector('.gl-spinner-container').classList.remove('gl-mr-2');
+
this.todo_ids = target === markAllDoneBtn ? data.updated_ids : [];
undoAllBtn.classList.toggle('hidden');
markAllDoneBtn.classList.toggle('hidden');
diff --git a/app/assets/javascripts/pages/groups/clusters/new/index.js b/app/assets/javascripts/pages/groups/clusters/connect/index.js
index de9ded87ef3..de9ded87ef3 100644
--- a/app/assets/javascripts/pages/groups/clusters/new/index.js
+++ b/app/assets/javascripts/pages/groups/clusters/connect/index.js
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index 14ce3f775b1..280b544af3c 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -1,16 +1,12 @@
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';
import { s__ } from '~/locale';
-import memberExpirationDate from '~/member_expiration_date';
import { initMembersApp } from '~/members';
import { MEMBER_TYPES } from '~/members/constants';
import { groupLinkRequestFormatter } from '~/members/utils';
-import UsersSelect from '~/users_select';
const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
@@ -22,7 +18,7 @@ initMembersApp(document.querySelector('.js-group-members-list-app'), {
requestFormatter: groupMemberRequestFormatter,
filteredSearchBar: {
show: true,
- tokens: ['two_factor', 'with_inherited_permissions'],
+ tokens: ['two_factor', 'with_inherited_permissions', 'enterprise'],
searchParam: 'search',
placeholder: s__('Members|Filter members'),
recentSearchesStorageKey: 'group_members',
@@ -53,16 +49,7 @@ initMembersApp(document.querySelector('.js-group-members-list-app'), {
},
});
-groupsSelect();
-memberExpirationDate();
-memberExpirationDate('.js-access-expiration-date-groups');
initInviteMembersModal();
initInviteGroupsModal();
initInviteMembersTrigger();
initInviteGroupTrigger();
-
-// This is only used when `invite_members_group_modal` feature flag is disabled.
-// This can be removed when `invite_members_group_modal` feature flag is removed.
-initInviteMembersForm();
-
-new UsersSelect(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/jira_connect/oauth_callbacks/index.js b/app/assets/javascripts/pages/jira_connect/oauth_callbacks/index.js
new file mode 100644
index 00000000000..3fe238dcb35
--- /dev/null
+++ b/app/assets/javascripts/pages/jira_connect/oauth_callbacks/index.js
@@ -0,0 +1,28 @@
+function getOriginURL() {
+ const origin = new URL(window.opener.location);
+ origin.hash = '';
+ origin.search = '';
+
+ return origin;
+}
+
+function postMessageToJiraConnectApp(data) {
+ window.opener.postMessage(data, getOriginURL().toString());
+}
+
+function initOAuthCallbacks() {
+ const params = new URLSearchParams(window.location.search);
+ if (params.has('code') && params.has('state')) {
+ postMessageToJiraConnectApp({
+ success: true,
+ code: params.get('code'),
+ state: params.get('state'),
+ });
+ } else {
+ postMessageToJiraConnectApp({ success: false });
+ }
+
+ window.close();
+}
+
+initOAuthCallbacks();
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index 2fc9a111405..740fdb8a96a 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import Vuex from 'vuex';
import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
import TableOfContents from '~/blob/components/table_contents.vue';
@@ -11,7 +12,9 @@ import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
import BlobContentViewer from '~/repository/components/blob_content_viewer.vue';
import '~/sourcegraph/load';
+import createStore from '~/code_navigation/store';
+Vue.use(Vuex);
Vue.use(VueApollo);
Vue.use(VueRouter);
@@ -29,6 +32,7 @@ if (viewBlobEl) {
// eslint-disable-next-line no-new
new Vue({
el: viewBlobEl,
+ store: createStore(),
router,
apolloProvider,
provide: {
@@ -78,7 +82,7 @@ GpgBadges.fetch();
const codeNavEl = document.getElementById('js-code-navigation');
-if (codeNavEl) {
+if (codeNavEl && !viewBlobEl) {
const { codeNavigationPath, blobPath, definitionPathPrefix } = codeNavEl.dataset;
// eslint-disable-next-line promise/catch-or-return
diff --git a/app/assets/javascripts/pages/projects/branches/index/index.js b/app/assets/javascripts/pages/projects/branches/index/index.js
index d279c4cbb08..f3530b46845 100644
--- a/app/assets/javascripts/pages/projects/branches/index/index.js
+++ b/app/assets/javascripts/pages/projects/branches/index/index.js
@@ -1,12 +1,9 @@
import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
-import AjaxLoadingSpinner from '~/branches/ajax_loading_spinner';
import BranchSortDropdown from '~/branches/branch_sort_dropdown';
import initDiverganceGraph from '~/branches/divergence_graph';
import initDeleteBranchButton from '~/branches/init_delete_branch_button';
import initDeleteBranchModal from '~/branches/init_delete_branch_modal';
-AjaxLoadingSpinner.init();
-
const { divergingCountsEndpoint, defaultBranch } = document.querySelector(
'.js-branch-list',
).dataset;
diff --git a/app/assets/javascripts/pages/projects/ci/secure_files/show/index.js b/app/assets/javascripts/pages/projects/ci/secure_files/show/index.js
new file mode 100644
index 00000000000..61486606665
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/ci/secure_files/show/index.js
@@ -0,0 +1,3 @@
+import { initCiSecureFiles } from '~/ci_secure_files';
+
+initCiSecureFiles();
diff --git a/app/assets/javascripts/pages/projects/clusters/new/index.js b/app/assets/javascripts/pages/projects/clusters/connect/index.js
index de9ded87ef3..de9ded87ef3 100644
--- a/app/assets/javascripts/pages/projects/clusters/new/index.js
+++ b/app/assets/javascripts/pages/projects/clusters/connect/index.js
diff --git a/app/assets/javascripts/pages/projects/environments/index/index.js b/app/assets/javascripts/pages/projects/environments/index/index.js
index f0554d64ddc..8e0d9ee0eab 100644
--- a/app/assets/javascripts/pages/projects/environments/index/index.js
+++ b/app/assets/javascripts/pages/projects/environments/index/index.js
@@ -1,11 +1,5 @@
-import initEnvironments from '~/environments/';
-import initNewEnvironments from '~/environments/new_index';
+import initEnvironments from '~/environments/index';
-let el = document.getElementById('environments-list-view');
+const el = document.getElementById('environments-table');
-if (el) {
- initEnvironments(el);
-} else {
- el = document.getElementById('environments-table');
- initNewEnvironments(el);
-}
+initEnvironments(el);
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/app.vue b/app/assets/javascripts/pages/projects/forks/new/components/app.vue
index 7fb41c6e7b7..0995a2118b1 100644
--- a/app/assets/javascripts/pages/projects/forks/new/components/app.vue
+++ b/app/assets/javascripts/pages/projects/forks/new/components/app.vue
@@ -10,38 +10,6 @@ export default {
type: String,
required: true,
},
- endpoint: {
- type: String,
- required: true,
- },
- projectFullPath: {
- type: String,
- required: true,
- },
- projectId: {
- type: String,
- required: true,
- },
- projectName: {
- type: String,
- required: true,
- },
- projectPath: {
- type: String,
- required: true,
- },
- projectDescription: {
- type: String,
- required: true,
- },
- projectVisibility: {
- type: String,
- required: true,
- },
- restrictedVisibilityLevels: {
- type: Array,
- required: true,
- },
},
};
</script>
@@ -62,16 +30,7 @@ export default {
</p>
</div>
<div class="col-lg-9">
- <fork-form
- :endpoint="endpoint"
- :project-full-path="projectFullPath"
- :project-id="projectId"
- :project-name="projectName"
- :project-path="projectPath"
- :project-description="projectDescription"
- :project-visibility="projectVisibility"
- :restricted-visibility-levels="restrictedVisibilityLevels"
- />
+ <fork-form />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
index 25b62e6c971..701bf0c1e1d 100644
--- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
+++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
@@ -72,40 +72,29 @@ export default {
visibilityHelpPath: {
default: '',
},
- },
- props: {
endpoint: {
- type: String,
- required: true,
+ default: '',
},
projectFullPath: {
- type: String,
- required: true,
+ default: '',
},
projectId: {
- type: String,
- required: true,
+ default: '',
},
projectName: {
- type: String,
- required: true,
+ default: '',
},
projectPath: {
- type: String,
- required: true,
+ default: '',
},
projectDescription: {
- type: String,
- required: false,
default: '',
},
projectVisibility: {
- type: String,
- required: true,
+ default: '',
},
restrictedVisibilityLevels: {
- type: Array,
- required: true,
+ default: [],
},
},
data() {
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue
deleted file mode 100644
index 10753de6cd0..00000000000
--- a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue
+++ /dev/null
@@ -1,93 +0,0 @@
-<script>
-import { GlTabs, GlTab, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
-import createFlash from '~/flash';
-import axios from '~/lib/utils/axios_utils';
-import { __ } from '~/locale';
-import ForkGroupsListItem from './fork_groups_list_item.vue';
-
-export default {
- components: {
- GlTabs,
- GlTab,
- GlLoadingIcon,
- GlSearchBoxByType,
- ForkGroupsListItem,
- },
- props: {
- endpoint: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- namespaces: null,
- filter: '',
- };
- },
- computed: {
- filteredNamespaces() {
- return this.namespaces.filter((n) =>
- n.name.toLowerCase().includes(this.filter.toLowerCase()),
- );
- },
- },
-
- mounted() {
- this.loadGroups();
- },
-
- methods: {
- loadGroups() {
- axios
- .get(this.endpoint)
- .then((response) => {
- this.namespaces = response.data.namespaces;
- })
- .catch(() =>
- createFlash({
- message: __('There was a problem fetching groups.'),
- }),
- );
- },
- },
-
- i18n: {
- searchPlaceholder: __('Search by name'),
- },
-};
-</script>
-<template>
- <gl-tabs class="fork-groups">
- <gl-tab :title="__('Groups and subgroups')">
- <gl-loading-icon v-if="!namespaces" size="md" class="gl-mt-3" />
- <template v-else-if="namespaces.length === 0">
- <div class="gl-text-center">
- <div class="h5">{{ __('No available groups to fork the project.') }}</div>
- <p class="gl-mt-5">
- {{ __('You must have permission to create a project in a group before forking.') }}
- </p>
- </div>
- </template>
- <div v-else-if="filteredNamespaces.length === 0" class="gl-text-center gl-mt-3">
- {{ s__('GroupsTree|No groups matched your search') }}
- </div>
- <ul v-else class="groups-list group-list-tree">
- <fork-groups-list-item
- v-for="(namespace, index) in filteredNamespaces"
- :key="index"
- :group="namespace"
- />
- </ul>
- </gl-tab>
- <template #tabs-end>
- <gl-search-box-by-type
- v-if="namespaces && namespaces.length"
- v-model="filter"
- :placeholder="$options.i18n.searchPlaceholder"
- class="gl-align-self-center gl-ml-auto fork-filtered-search"
- data-qa-selector="fork_groups_list_search_field"
- />
- </template>
- </gl-tabs>
-</template>
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue
deleted file mode 100644
index d41488acf46..00000000000
--- a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue
+++ /dev/null
@@ -1,148 +0,0 @@
-<script>
-import {
- GlLink,
- GlButton,
- GlIcon,
- GlAvatar,
- GlTooltipDirective,
- GlTooltip,
- GlBadge,
- GlSafeHtmlDirective as SafeHtml,
-} from '@gitlab/ui';
-import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '~/groups/constants';
-import csrf from '~/lib/utils/csrf';
-import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
-
-export default {
- components: {
- GlIcon,
- GlAvatar,
- GlBadge,
- GlButton,
- GlTooltip,
- GlLink,
- UserAccessRoleBadge,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- SafeHtml,
- },
- props: {
- group: {
- type: Object,
- required: true,
- },
- },
- data() {
- return { namespaces: null, isForking: false };
- },
-
- computed: {
- rowClass() {
- return {
- 'has-description': this.group.description,
- 'being-removed': this.isGroupPendingRemoval,
- };
- },
- isGroupPendingRemoval() {
- return this.group.marked_for_deletion;
- },
- hasForkedProject() {
- return Boolean(this.group.forked_project_path);
- },
- visibilityIcon() {
- return VISIBILITY_TYPE_ICON[this.group.visibility];
- },
- visibilityTooltip() {
- return GROUP_VISIBILITY_TYPE[this.group.visibility];
- },
- isSelectButtonDisabled() {
- return !this.group.can_create_project;
- },
- },
-
- methods: {
- fork() {
- this.isForking = true;
- this.$refs.form.submit();
- },
- },
-
- csrf,
-};
-</script>
-<template>
- <li :class="rowClass" class="group-row">
- <div class="group-row-contents gl-display-flex gl-align-items-center gl-py-3 gl-pr-5">
- <div
- class="folder-toggle-wrap gl-mr-3 gl-display-flex gl-align-items-center gl-text-gray-500"
- >
- <gl-icon name="folder-o" />
- </div>
- <gl-link
- :href="group.relative_path"
- class="gl-display-none gl-flex-shrink-0 gl-sm-display-flex gl-mr-3"
- >
- <gl-avatar :size="32" shape="rect" :entity-name="group.name" :src="group.avatarUrl" />
- </gl-link>
- <div class="gl-min-w-0 gl-display-flex gl-flex-grow-1 gl-flex-shrink-1 gl-align-items-center">
- <div class="gl-min-w-0 gl-flex-grow-1 flex-shrink-1">
- <div class="title gl-display-flex gl-align-items-center gl-flex-wrap gl-mr-3">
- <gl-link :href="group.relative_path" class="gl-mt-3 gl-mr-3 gl-text-gray-900!">
- {{ group.full_name }}
- </gl-link>
- <gl-icon
- v-gl-tooltip.hover.bottom
- class="gl-display-inline-flex gl-mt-3 gl-mr-3 gl-text-gray-500"
- :name="visibilityIcon"
- :title="visibilityTooltip"
- />
- <gl-badge
- v-if="isGroupPendingRemoval"
- variant="warning"
- class="gl-display-none gl-sm-display-flex gl-mt-3 gl-mr-1"
- >{{ __('pending deletion') }}</gl-badge
- >
- <user-access-role-badge v-if="group.permission" class="gl-mt-3">
- {{ group.permission }}
- </user-access-role-badge>
- </div>
- <div v-if="group.description" class="description gl-line-height-20">
- <span v-safe-html="group.markdown_description"> </span>
- </div>
- </div>
- <div class="gl-display-flex gl-flex-shrink-0">
- <gl-button
- v-if="hasForkedProject"
- class="gl-h-7 gl-text-decoration-none!"
- :href="group.forked_project_path"
- >{{ __('Go to fork') }}</gl-button
- >
- <template v-else>
- <div ref="selectButtonWrapper">
- <form ref="form" method="POST" :action="group.fork_path">
- <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
- <gl-button
- type="submit"
- class="gl-h-7"
- :data-qa-name="group.full_name"
- category="secondary"
- variant="success"
- :disabled="isSelectButtonDisabled"
- :loading="isForking"
- @click="fork"
- >{{ __('Select') }}</gl-button
- >
- </form>
- </div>
- <gl-tooltip v-if="isSelectButtonDisabled" :target="() => $refs.selectButtonWrapper">
- {{
- __('You must have permission to create a project in a namespace before forking.')
- }}
- </gl-tooltip>
- </template>
- </div>
- </div>
- </div>
- </li>
-</template>
diff --git a/app/assets/javascripts/pages/projects/forks/new/index.js b/app/assets/javascripts/pages/projects/forks/new/index.js
index 1a171252048..cbf74f755e7 100644
--- a/app/assets/javascripts/pages/projects/forks/new/index.js
+++ b/app/assets/javascripts/pages/projects/forks/new/index.js
@@ -1,61 +1,42 @@
import Vue from 'vue';
import App from './components/app.vue';
-import ForkGroupsList from './components/fork_groups_list.vue';
const mountElement = document.getElementById('fork-groups-mount-element');
-if (gon.features.forkProjectForm) {
- const {
- forkIllustration,
- endpoint,
+const {
+ forkIllustration,
+ endpoint,
+ newGroupPath,
+ projectFullPath,
+ visibilityHelpPath,
+ projectId,
+ projectName,
+ projectPath,
+ projectDescription,
+ projectVisibility,
+ restrictedVisibilityLevels,
+} = mountElement.dataset;
+
+// eslint-disable-next-line no-new
+new Vue({
+ el: mountElement,
+ provide: {
newGroupPath,
- projectFullPath,
visibilityHelpPath,
+ endpoint,
+ projectFullPath,
projectId,
projectName,
projectPath,
projectDescription,
projectVisibility,
- restrictedVisibilityLevels,
- } = mountElement.dataset;
-
- // eslint-disable-next-line no-new
- new Vue({
- el: mountElement,
- provide: {
- newGroupPath,
- visibilityHelpPath,
- },
- render(h) {
- return h(App, {
- props: {
- forkIllustration,
- endpoint,
- newGroupPath,
- projectFullPath,
- visibilityHelpPath,
- projectId,
- projectName,
- projectPath,
- projectDescription,
- projectVisibility,
- restrictedVisibilityLevels: JSON.parse(restrictedVisibilityLevels),
- },
- });
- },
- });
-} else {
- const { endpoint } = mountElement.dataset;
-
- // eslint-disable-next-line no-new
- new Vue({
- el: mountElement,
- render(h) {
- return h(ForkGroupsList, {
- props: {
- endpoint,
- },
- });
- },
- });
-}
+ restrictedVisibilityLevels: JSON.parse(restrictedVisibilityLevels),
+ },
+ render(h) {
+ return h(App, {
+ props: {
+ forkIllustration,
+ },
+ });
+ },
+});
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 adae97c6b6f..67962d69fa5 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
@@ -27,11 +27,6 @@ export default {
required: true,
type: Object,
},
- inviteMembers: {
- type: Boolean,
- required: false,
- default: false,
- },
project: {
required: true,
type: Object,
@@ -54,7 +49,7 @@ export default {
},
},
mounted() {
- if (this.inviteMembers && this.getCookieForInviteMembers()) {
+ if (this.getCookieForInviteMembers()) {
this.openInviteMembersModal('celebrate');
}
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue
index ad6dfbf41ca..09cc0032871 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue
@@ -64,15 +64,7 @@ export default {
<img :src="svg" :alt="actionLabel" />
<h6>{{ title }}</h6>
<p class="gl-font-sm gl-text-gray-700">{{ description }}</p>
- <gl-link
- :href="url"
- target="_blank"
- rel="noopener noreferrer"
- data-track-action="click_link"
- :data-track-label="actionLabel"
- data-track-property="Growth::Activation::Experiment::LearnGitLabB"
- >{{ actionLabel }}</gl-link
- >
+ <gl-link :href="url" target="_blank" rel="noopener noreferrer" />
</div>
</gl-card>
</template>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
index d0ec02bbd0c..573f996a254 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
@@ -32,7 +32,7 @@ export default {
);
},
openInNewTab() {
- return ACTION_LABELS[this.action]?.openInNewTab === true;
+ return ACTION_LABELS[this.action]?.openInNewTab === true || this.value.openInNewTab === true;
},
},
methods: {
@@ -65,8 +65,6 @@ export default {
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"
- data-track-experiment="change_continuous_onboarding_link_urls"
>
{{ $options.i18n.ACTION_LABELS[action].title }}
</gl-link>
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 880cf699e5e..1887c48dd1b 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
@@ -62,7 +62,6 @@ 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'),
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 c62cab1a425..63357ea9c72 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, parseBoolean } from '~/lib/utils/common_utils';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import LearnGitlab from '../components/learn_gitlab.vue';
function initLearnGitlab() {
@@ -13,13 +13,12 @@ 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 { inviteMembers } = el.dataset;
return new Vue({
el,
render(createElement) {
return createElement(LearnGitlab, {
- props: { actions, sections, project, inviteMembers: parseBoolean(inviteMembers) },
+ props: { actions, sections, project },
});
},
});
diff --git a/app/assets/javascripts/pages/projects/pages_domains/form.js b/app/assets/javascripts/pages/projects/pages_domains/form.js
index 169530685ad..6836d399fa4 100644
--- a/app/assets/javascripts/pages/projects/pages_domains/form.js
+++ b/app/assets/javascripts/pages/projects/pages_domains/form.js
@@ -1,4 +1,4 @@
-import setupToggleButtons from '~/toggle_buttons';
+import { initToggle } from '~/toggles';
function updateVisibility(selector, isVisible) {
Array.from(document.querySelectorAll(selector)).forEach((el) => {
@@ -11,12 +11,12 @@ function updateVisibility(selector, isVisible) {
}
export default () => {
- const toggleContainer = document.querySelector('.js-auto-ssl-toggle-container');
+ const sslToggle = initToggle(document.querySelector('.js-enable-ssl-gl-toggle'));
+ const sslToggleInput = document.querySelector('.js-project-feature-toggle-input');
- if (toggleContainer) {
- const onToggleButtonClicked = (isAutoSslEnabled) => {
+ if (sslToggle) {
+ sslToggle.$on('change', (isAutoSslEnabled) => {
updateVisibility('.js-shown-unless-auto-ssl', !isAutoSslEnabled);
-
updateVisibility('.js-shown-if-auto-ssl', isAutoSslEnabled);
Array.from(document.querySelectorAll('.js-enabled-unless-auto-ssl')).forEach((el) => {
@@ -26,8 +26,9 @@ export default () => {
el.removeAttribute('disabled');
}
});
- };
- setupToggleButtons(toggleContainer, onToggleButtonClicked);
+ sslToggleInput.setAttribute('value', isAutoSslEnabled);
+ });
}
+ return sslToggle;
};
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js
deleted file mode 100644
index 6017cd653e4..00000000000
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import $ from 'jquery';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-
-export default class TargetBranchDropdown {
- constructor() {
- this.$dropdown = $('.js-target-branch-dropdown');
- this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text');
- this.$input = $('#schedule_ref');
- this.initDefaultBranch();
- this.initDropdown();
- }
-
- initDropdown() {
- initDeprecatedJQueryDropdown(this.$dropdown, {
- data: this.formatBranchesList(),
- filterable: true,
- selectable: true,
- toggleLabel: (item) => item.name,
- search: {
- fields: ['name'],
- },
- clicked: (cfg) => this.updateInputValue(cfg),
- text: (item) => item.name,
- });
-
- this.setDropdownToggle();
- }
-
- formatBranchesList() {
- return this.$dropdown.data('data').map((val) => ({ name: val }));
- }
-
- setDropdownToggle() {
- const initialValue = this.$input.val();
-
- this.$dropdownToggle.text(initialValue);
- }
-
- initDefaultBranch() {
- const initialValue = this.$input.val();
- const defaultBranch = this.$dropdown.data('defaultBranch');
-
- if (!initialValue) {
- this.$input.val(defaultBranch);
- }
- }
-
- updateInputValue({ selectedObj, e }) {
- e.preventDefault();
-
- this.$input.val(selectedObj.name);
- gl.pipelineScheduleFieldErrors.updateFormValidityState();
- }
-}
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
index 9056c76d6ca..9c039a6be81 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
@@ -1,10 +1,12 @@
import $ from 'jquery';
import Vue from 'vue';
+import { __ } from '~/locale';
+import RefSelector from '~/ref/components/ref_selector.vue';
+import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants';
import setupNativeFormVariableList from '../../../../ci_variable_list/native_form_variable_list';
import GlFieldErrors from '../../../../gl_field_errors';
import Translate from '../../../../vue_shared/translate';
import intervalPatternInput from './components/interval_pattern_input.vue';
-import TargetBranchDropdown from './components/target_branch_dropdown';
import TimezoneDropdown from './components/timezone_dropdown';
Vue.use(Translate);
@@ -30,6 +32,52 @@ function initIntervalPatternInput() {
});
}
+function getEnabledRefTypes() {
+ const refTypes = [REF_TYPE_BRANCHES];
+
+ if (gon.features.pipelineSchedulesWithTags) {
+ refTypes.push(REF_TYPE_TAGS);
+ }
+
+ return refTypes;
+}
+
+function initTargetRefDropdown() {
+ const $refField = document.getElementById('schedule_ref');
+ const el = document.querySelector('.js-target-ref-dropdown');
+ const { projectId, defaultBranch } = el.dataset;
+
+ if (!$refField.value) {
+ $refField.value = defaultBranch;
+ }
+
+ const refDropdown = new Vue({
+ el,
+ render(h) {
+ return h(RefSelector, {
+ props: {
+ enabledRefTypes: getEnabledRefTypes(),
+ projectId,
+ value: $refField.value,
+ useSymbolicRefNames: true,
+ translations: {
+ dropdownHeader: gon.features.pipelineSchedulesWithTags
+ ? __('Select target branch or tag')
+ : __('Select target branch'),
+ },
+ },
+ class: 'gl-w-full',
+ });
+ },
+ });
+
+ refDropdown.$children[0].$on('input', (newRef) => {
+ $refField.value = newRef;
+ });
+
+ return refDropdown;
+}
+
export default () => {
/* Most of the form is written in haml, but for fields with more complex behaviors,
* you should mount individual Vue components here. If at some point components need
@@ -48,9 +96,10 @@ export default () => {
gl.pipelineScheduleFieldErrors.updateFormValidityState();
},
});
- gl.targetBranchDropdown = new TargetBranchDropdown();
gl.pipelineScheduleFieldErrors = new GlFieldErrors(formElement);
+ initTargetRefDropdown();
+
setupNativeFormVariableList({
container: $('.js-ci-variable-list-section'),
formField: 'schedule',
diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js
index 26c42247cf7..2c0394dc12c 100644
--- a/app/assets/javascripts/pages/projects/project_members/index.js
+++ b/app/assets/javascripts/pages/projects/project_members/index.js
@@ -1,33 +1,20 @@
-import groupsSelect from '~/groups_select';
import initImportAProjectModal from '~/invite_members/init_import_a_project_modal';
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';
import { initMembersApp } from '~/members';
import { MEMBER_TYPES } from '~/members/constants';
import { groupLinkRequestFormatter } from '~/members/utils';
import { projectMemberRequestFormatter } from '~/projects/members/utils';
-import UsersSelect from '~/users_select';
-groupsSelect();
-memberExpirationDate();
-memberExpirationDate('.js-access-expiration-date-groups');
initImportAProjectModal();
initInviteMembersModal();
initInviteGroupsModal();
initInviteMembersTrigger();
initInviteGroupTrigger();
-// This is only used when `invite_members_group_modal` feature flag is disabled.
-// This can be removed when `invite_members_group_modal` feature flag is removed.
-initInviteMembersForm();
-
-new UsersSelect(); // eslint-disable-line no-new
-
const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
initMembersApp(document.querySelector('.js-project-members-list-app'), {
[MEMBER_TYPES.user]: {
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
index c28de88554a..8ef31b9b983 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -60,7 +60,7 @@ export default {
contentEditor: {
renderFailed: {
message: s__(
- 'WikiPage|An error occured while trying to render the content editor. Please try again later.',
+ 'WikiPage|An error occurred while trying to render the content editor. Please try again later.',
),
primaryAction: s__('WikiPage|Retry'),
},
@@ -495,6 +495,7 @@ export default {
:textarea-value="content"
:markdown-docs-path="pageInfo.markdownHelpPath"
:uploads-path="pageInfo.uploadsPath"
+ :enable-preview="isMarkdownFormat"
class="bordered-box"
>
<template #textarea>
diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js
index 7f4e79976bc..996e12bc105 100644
--- a/app/assets/javascripts/pages/users/activity_calendar.js
+++ b/app/assets/javascripts/pages/users/activity_calendar.js
@@ -7,6 +7,7 @@ import axios from '~/lib/utils/axios_utils';
import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility';
import { formatDate } from '~/lib/utils/datetime/date_format_utility';
import { n__, s__, __ } from '~/locale';
+import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
const d3 = { select };
@@ -24,12 +25,6 @@ const CONTRIB_LEGENDS = [
{ title: __('30+ contributions'), min: 30 },
];
-const LOADING_HTML = `
- <div class="text-center">
- <div class="spinner spinner-md"></div>
- </div>
-`;
-
function getSystemDate(systemUtcOffsetSeconds) {
const date = new Date();
const localUtcOffsetMinutes = 0 - date.getTimezoneOffset();
@@ -286,7 +281,9 @@ export default class ActivityCalendar {
this.currentSelectedDate.getDate(),
].join('-');
- $(this.activitiesContainer).html(LOADING_HTML);
+ $(this.activitiesContainer)
+ .empty()
+ .append(loadingIconForLegacyJS({ size: 'lg' }));
axios
.get(this.calendarActivitiesPath, {
diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
index 1bb82e1d8e6..0640faae8b7 100644
--- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue
+++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlModal, GlModalDirective, GlSegmentedControl } from '@gitlab/ui';
+import { GlButton, GlDropdown, GlDropdownItem, GlModal, GlModalDirective } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { sortOrders, sortOrderOptions } from '../constants';
@@ -9,8 +9,9 @@ export default {
components: {
RequestWarning,
GlButton,
+ GlDropdown,
+ GlDropdownItem,
GlModal,
- GlSegmentedControl,
},
directives: {
'gl-modal': GlModalDirective,
@@ -156,13 +157,19 @@ export default {
</div>
</div>
</div>
- <gl-segmented-control
+ <gl-dropdown
v-if="displaySortOrder"
+ :text="$options.sortOrderOptions[sortOrder]"
+ right
data-testid="performance-bar-sort-order"
- :options="$options.sortOrderOptions"
- :checked="sortOrder"
- @input="changeSortOrder"
- />
+ >
+ <gl-dropdown-item
+ v-for="option in Object.keys($options.sortOrderOptions)"
+ :key="option"
+ @click="changeSortOrder(option)"
+ >{{ $options.sortOrderOptions[option] }}</gl-dropdown-item
+ >
+ </gl-dropdown>
</div>
<hr />
<table class="table gl-table">
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 710f49b833c..0f744e858f2 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -134,6 +134,7 @@ export default {
methods: {
changeCurrentRequest(newRequestId) {
this.currentRequest = newRequestId;
+ this.$emit('change-request', newRequestId);
},
flamegraphPath(mode) {
return mergeUrlParams(
diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue
index a46ac620f48..ffc22c2113d 100644
--- a/app/assets/javascripts/performance_bar/components/request_selector.vue
+++ b/app/assets/javascripts/performance_bar/components/request_selector.vue
@@ -1,15 +1,5 @@
<script>
-import { GlPopover, GlSafeHtmlDirective } from '@gitlab/ui';
-import { glEmojiTag } from '~/emoji';
-import { n__ } from '~/locale';
-
export default {
- components: {
- GlPopover,
- },
- directives: {
- SafeHtml: GlSafeHtmlDirective,
- },
props: {
currentRequest: {
type: Object,
@@ -25,27 +15,11 @@ export default {
currentRequestId: this.currentRequest.id,
};
},
- computed: {
- requestsWithWarnings() {
- return this.requests.filter((request) => request.hasWarnings);
- },
- warningMessage() {
- return n__(
- '%d request with warnings',
- '%d requests with warnings',
- this.requestsWithWarnings.length,
- );
- },
- },
watch: {
currentRequestId(newRequestId) {
this.$emit('change-current-request', newRequestId);
},
},
- methods: {
- glEmojiTag,
- },
- safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
};
</script>
<template>
@@ -58,19 +32,7 @@ export default {
data-qa-selector="request_dropdown_option"
>
{{ request.truncatedUrl }}
- <span v-if="request.hasWarnings">(!)</span>
</option>
</select>
- <span v-if="requestsWithWarnings.length" class="gl-cursor-default">
- <span
- id="performance-bar-request-selector-warning"
- v-safe-html:[$options.safeHtmlConfig]="glEmojiTag('warning')"
- ></span>
- <gl-popover
- placement="bottom"
- target="performance-bar-request-selector-warning"
- :content="warningMessage"
- />
- </span>
</div>
</template>
diff --git a/app/assets/javascripts/performance_bar/constants.js b/app/assets/javascripts/performance_bar/constants.js
index 9659383edd9..09745797424 100644
--- a/app/assets/javascripts/performance_bar/constants.js
+++ b/app/assets/javascripts/performance_bar/constants.js
@@ -5,13 +5,7 @@ export const sortOrders = {
CHRONOLOGICAL: 'chronological',
};
-export const sortOrderOptions = [
- {
- value: sortOrders.DURATION,
- text: s__('PerformanceBar|Sort by duration'),
- },
- {
- value: sortOrders.CHRONOLOGICAL,
- text: s__('PerformanceBar|Sort chronologically'),
- },
-];
+export const sortOrderOptions = {
+ [sortOrders.DURATION]: s__('PerformanceBar|Sort by duration'),
+ [sortOrders.CHRONOLOGICAL]: s__('PerformanceBar|Sort chronologically'),
+};
diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js
index eb5b50dd1ec..e7f84eacdca 100644
--- a/app/assets/javascripts/performance_bar/index.js
+++ b/app/assets/javascripts/performance_bar/index.js
@@ -1,5 +1,6 @@
import '../webpack';
+import { isEmpty } from 'lodash';
import Vue from 'vue';
import axios from '~/lib/utils/axios_utils';
import { numberToHumanSize } from '~/lib/utils/number_utils';
@@ -37,9 +38,10 @@ const initPerformanceBar = (el) => {
};
},
mounted() {
- PerformanceBarService.registerInterceptor(this.peekUrl, this.loadRequestDetails);
+ PerformanceBarService.registerInterceptor(this.peekUrl, this.addRequest);
- this.loadRequestDetails(this.requestId, window.location.href);
+ this.addRequest(this.requestId, window.location.href);
+ this.loadRequestDetails(this.requestId);
},
beforeDestroy() {
PerformanceBarService.removeInterceptor();
@@ -51,26 +53,32 @@ const initPerformanceBar = (el) => {
// want to trace the request.
axios.get(urlOrRequestId);
} else {
- this.loadRequestDetails(urlOrRequestId, urlOrRequestId);
+ this.addRequest(urlOrRequestId, urlOrRequestId);
}
},
- loadRequestDetails(requestId, requestUrl) {
+ addRequest(requestId, requestUrl) {
if (!this.store.canTrackRequest(requestUrl)) {
return;
}
this.store.addRequest(requestId, requestUrl);
+ },
+ loadRequestDetails(requestId) {
+ const request = this.store.findRequest(requestId);
+
+ if (request && isEmpty(request.details)) {
+ return PerformanceBarService.fetchRequestDetails(this.peekUrl, requestId)
+ .then((res) => {
+ this.store.addRequestDetails(requestId, res.data);
+ if (this.requestId === requestId) this.collectFrontendPerformanceMetrics();
+ })
+ .catch(() =>
+ // eslint-disable-next-line no-console
+ console.warn(`Error getting performance bar results for ${requestId}`),
+ );
+ }
- PerformanceBarService.fetchRequestDetails(this.peekUrl, requestId)
- .then((res) => {
- this.store.addRequestDetails(requestId, res.data);
-
- if (this.requestId === requestId) this.collectFrontendPerformanceMetrics();
- })
- .catch(() =>
- // eslint-disable-next-line no-console
- console.warn(`Error getting performance bar results for ${requestId}`),
- );
+ return Promise.resolve();
},
collectFrontendPerformanceMetrics() {
if (performance) {
@@ -82,7 +90,9 @@ const initPerformanceBar = (el) => {
let summary = {};
if (navigationEntries.length > 0) {
const backend = Math.round(navigationEntries[0].responseEnd);
- const firstContentfulPaint = Math.round(paintEntries[1].startTime);
+ const firstContentfulPaint = Math.round(
+ paintEntries.find((entry) => entry.name === 'first-contentful-paint')?.startTime,
+ );
const domContentLoaded = Math.round(navigationEntries[0].domContentLoadedEventEnd);
summary = {
@@ -141,6 +151,7 @@ const initPerformanceBar = (el) => {
},
on: {
'add-request': this.addRequestManually,
+ 'change-request': this.loadRequestDetails,
},
});
},
diff --git a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
index 51a8eb5ca69..5a69960e4d9 100644
--- a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
+++ b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
@@ -12,7 +12,6 @@ export default class PerformanceBarStore {
url: requestUrl,
truncatedUrl: shortUrl,
details: {},
- hasWarnings: false,
});
}
@@ -27,7 +26,6 @@ export default class PerformanceBarStore {
const request = this.findRequest(requestId);
request.details = requestDetails.data;
- request.hasWarnings = requestDetails.has_warnings;
return request;
}
diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js
index b003302ec8e..7c424088c8b 100644
--- a/app/assets/javascripts/persistent_user_callout.js
+++ b/app/assets/javascripts/persistent_user_callout.js
@@ -13,23 +13,25 @@ export default class PersistentUserCallout {
this.featureId = featureId;
this.groupId = groupId;
this.deferLinks = parseBoolean(deferLinks);
+ this.closeButtons = this.container.querySelectorAll('.js-close');
this.init();
}
init() {
- const closeButton = this.container.querySelector('.js-close');
const followLink = this.container.querySelector('.js-follow-link');
- if (closeButton) {
- this.handleCloseButtonCallout(closeButton);
+ if (this.closeButtons.length) {
+ this.handleCloseButtonCallout();
} else if (followLink) {
this.handleFollowLinkCallout(followLink);
}
}
- handleCloseButtonCallout(closeButton) {
- closeButton.addEventListener('click', (event) => this.dismiss(event));
+ handleCloseButtonCallout() {
+ this.closeButtons.forEach((closeButton) => {
+ closeButton.addEventListener('click', this.dismiss);
+ });
if (this.deferLinks) {
this.container.addEventListener('click', (event) => {
@@ -47,7 +49,7 @@ export default class PersistentUserCallout {
followLink.addEventListener('click', (event) => this.registerCalloutWithLink(event));
}
- dismiss(event, deferredLinkOptions = null) {
+ dismiss = (event, deferredLinkOptions = null) => {
event.preventDefault();
axios
@@ -57,6 +59,9 @@ export default class PersistentUserCallout {
})
.then(() => {
this.container.remove();
+ this.closeButtons.forEach((closeButton) => {
+ closeButton.removeEventListener('click', this.dismiss);
+ });
if (deferredLinkOptions) {
const { href, target } = deferredLinkOptions;
@@ -70,7 +75,7 @@ export default class PersistentUserCallout {
),
});
});
- }
+ };
registerCalloutWithLink(event) {
event.preventDefault();
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index 337c204c36a..f6de21ec0c5 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -11,6 +11,7 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-eoa-bronze-plan-banner',
'.js-security-newsletter-callout',
'.js-approaching-seats-count-threshold',
+ '.js-storage-enforcement-banner',
];
const initCallouts = () => {
diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
index ca78f194a82..8536db78dfb 100644
--- a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
+++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
@@ -31,6 +31,14 @@ export default {
required: false,
default: '',
},
+ hasUnsavedChanges: {
+ type: Boolean,
+ required: true,
+ },
+ isNewCiConfigFile: {
+ type: Boolean,
+ required: true,
+ },
isSaving: {
type: Boolean,
required: false,
@@ -50,11 +58,14 @@ export default {
};
},
computed: {
+ isCommitFormFilledOut() {
+ return this.message && this.targetBranch;
+ },
isCurrentBranchTarget() {
return this.targetBranch === this.currentBranch;
},
- submitDisabled() {
- return !(this.message && this.targetBranch);
+ isSubmitDisabled() {
+ return !this.isCommitFormFilledOut || (!this.hasUnsavedChanges && !this.isNewCiConfigFile);
},
},
watch: {
@@ -125,6 +136,7 @@ export default {
v-if="!isCurrentBranchTarget"
v-model="openMergeRequest"
data-testid="new-mr-checkbox"
+ data-qa-selector="new_mr_checkbox"
class="gl-mt-3"
>
<gl-sprintf :message="$options.i18n.startMergeRequest">
@@ -143,7 +155,7 @@ export default {
category="primary"
variant="confirm"
data-qa-selector="commit_changes_button"
- :disabled="submitDisabled"
+ :disabled="isSubmitDisabled"
:loading="isSaving"
>
{{ $options.i18n.commitChanges }}
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 8ff1aea020f..4ef598d6ff3 100644
--- a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
+++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
@@ -37,6 +37,10 @@ export default {
required: false,
default: '',
},
+ hasUnsavedChanges: {
+ type: Boolean,
+ required: true,
+ },
isNewCiConfigFile: {
type: Boolean,
required: false,
@@ -151,6 +155,8 @@ export default {
<commit-form
:current-branch="currentBranch"
:default-message="defaultCommitMessage"
+ :has-unsaved-changes="hasUnsavedChanges"
+ :is-new-ci-config-file="isNewCiConfigFile"
:is-saving="isSaving"
:scroll-to-commit-form="scrollToCommitForm"
v-on="$listeners"
diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue
index 7bc096ce2c8..9cb070a5517 100644
--- a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue
+++ b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue
@@ -2,7 +2,6 @@
import { GlButton, GlIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
-import { experiment } from '~/experimentation/utils';
import { DRAWER_EXPANDED_KEY } from '../../constants';
import FirstPipelineCard from './cards/first_pipeline_card.vue';
import GettingStartedCard from './cards/getting_started_card.vue';
@@ -50,29 +49,8 @@ export default {
},
mounted() {
this.setTopPosition();
- this.setInitialExpandState();
},
methods: {
- setInitialExpandState() {
- let isExpanded;
-
- experiment('pipeline_editor_walkthrough', {
- control: () => {
- isExpanded = true;
- },
- candidate: () => {
- isExpanded = false;
- },
- });
-
- // We check in the local storage and if no value is defined, we want the default
- // to be true. We want to explicitly set it to true here so that the drawer
- // animates to open on load.
- const localValue = localStorage.getItem(this.$options.localDrawerKey);
- if (localValue === null) {
- this.isExpanded = isExpanded;
- }
- },
setTopPosition() {
const navbarEl = document.querySelector('.js-navbar');
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 5177cea900c..255e3cb31f1 100644
--- a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
+++ b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
@@ -3,6 +3,7 @@ import { EDITOR_READY_EVENT } from '~/editor/constants';
import { CiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext';
import SourceEditor from '~/vue_shared/components/source_editor.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { SOURCE_EDITOR_DEBOUNCE } from '../../constants';
export default {
editorOptions: {
@@ -10,6 +11,7 @@ export default {
// autocomplete for keywords
quickSuggestions: true,
},
+ debounceValue: SOURCE_EDITOR_DEBOUNCE,
components: {
SourceEditor,
},
@@ -34,6 +36,7 @@ export default {
<div class="gl-border-solid gl-border-gray-100 gl-border-1 gl-border-t-none!">
<source-editor
ref="editor"
+ :debounce-value="$options.debounceValue"
:editor-options="$options.editorOptions"
:file-name="ciConfigPath"
v-bind="$attrs"
diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
index c75b1d4bb11..5cff93c884f 100644
--- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
+++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
@@ -4,7 +4,6 @@ import { s__ } from '~/locale';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { getParameterValues, setUrlParams, updateHistory } from '~/lib/utils/url_utility';
-import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue';
import {
CREATE_TAB,
EDITOR_APP_STATUS_EMPTY,
@@ -66,7 +65,6 @@ export default {
GlTabs,
PipelineGraph,
TextEditor,
- GitlabExperiment,
WalkthroughPopover,
},
mixins: [glFeatureFlagsMixin()],
@@ -158,11 +156,7 @@ export default {
data-testid="editor-tab"
@click="setCurrentTab($options.tabConstants.CREATE_TAB)"
>
- <gitlab-experiment name="pipeline_editor_walkthrough">
- <template #candidate>
- <walkthrough-popover v-if="isNewCiConfigFile" v-on="$listeners" />
- </template>
- </gitlab-experiment>
+ <walkthrough-popover v-if="isNewCiConfigFile" v-on="$listeners" />
<ci-editor-header />
<text-editor :commit-sha="commitSha" :value="ciFileContent" v-on="$listeners" />
</editor-tab>
diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue
index dcd08c9de8d..aee71999373 100644
--- a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue
+++ b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue
@@ -41,7 +41,12 @@ export default {
</template>
</gl-sprintf>
</p>
- <gl-button variant="confirm" class="gl-mt-3" @click="createEmptyConfigFile">
+ <gl-button
+ variant="confirm"
+ class="gl-mt-3"
+ data-qa-selector="create_new_ci_button"
+ @click="createEmptyConfigFile"
+ >
{{ $options.i18n.btnText }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js
index a65463d02aa..2ebc4306405 100644
--- a/app/assets/javascripts/pipeline_editor/constants.js
+++ b/app/assets/javascripts/pipeline_editor/constants.js
@@ -1,3 +1,5 @@
+import { s__ } from '~/locale';
+
// Values for CI_CONFIG_STATUS_* comes from lint graphQL
export const CI_CONFIG_STATUS_INVALID = 'INVALID';
export const CI_CONFIG_STATUS_VALID = 'VALID';
@@ -47,6 +49,7 @@ export const DRAWER_EXPANDED_KEY = 'pipeline_editor_drawer_expanded';
export const BRANCH_PAGINATION_LIMIT = 20;
export const BRANCH_SEARCH_DEBOUNCE = '500';
+export const SOURCE_EDITOR_DEBOUNCE = 500;
export const STARTER_TEMPLATE_NAME = 'Getting-Started';
@@ -61,3 +64,45 @@ export const TEMPLATE_REPOSITORY_URL =
'https://gitlab.com/gitlab-org/gitlab-foss/tree/master/lib/gitlab/ci/templates';
export const COMMIT_SHA_POLL_INTERVAL = 1000;
+
+export const RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME = 'runners_availability_section';
+export const RUNNERS_SETTINGS_LINK_CLICKED_EVENT = 'runners_settings_link_clicked';
+export const RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT = 'runners_documentation_link_clicked';
+export const RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT = 'runners_settings_button_clicked';
+export const I18N = {
+ title: s__('Pipelines|Get started with GitLab CI/CD'),
+ runners: {
+ title: s__('Pipelines|Runners are available to run your jobs now'),
+ subtitle: s__(
+ 'Pipelines|GitLab Runner is an application that works with GitLab CI/CD to run jobs in a pipeline. There are active runners available to run your jobs right now. If you prefer, you can %{settingsLinkStart}configure your runners%{settingsLinkEnd} or %{docsLinkStart}learn more%{docsLinkEnd} about runners.',
+ ),
+ },
+ noRunners: {
+ title: s__('Pipelines|No runners detected'),
+ subtitle: s__(
+ 'Pipelines|A GitLab Runner is an application that works with GitLab CI/CD to run jobs in a pipeline. Install GitLab Runner and register your own runners to get started with CI/CD.',
+ ),
+ cta: s__('Pipelines|Install GitLab Runner'),
+ },
+ learnBasics: {
+ title: s__('Pipelines|Learn the basics of pipelines and .yml files'),
+ subtitle: s__(
+ 'Pipelines|Use a sample %{codeStart}.gitlab-ci.yml%{codeEnd} template file to explore how CI/CD works.',
+ ),
+ gettingStarted: {
+ title: s__('Pipelines|"Hello world" with GitLab CI'),
+ description: s__(
+ 'Pipelines|Get familiar with GitLab CI syntax by setting up a simple pipeline running a "Hello world" script to see how it runs, explore how CI/CD works.',
+ ),
+ cta: s__('Pipelines|Try test template'),
+ },
+ },
+ templates: {
+ title: s__('Pipelines|Ready to set up CI/CD for your project?'),
+ subtitle: s__(
+ "Pipelines|Use a template based on your project's language or framework to get started with GitLab CI/CD.",
+ ),
+ description: s__('Pipelines|CI/CD template to test and deploy your %{name} project.'),
+ cta: s__('Pipelines|Use template'),
+ },
+};
diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js
index 04f91cb3d1e..732fc665c9e 100644
--- a/app/assets/javascripts/pipeline_editor/index.js
+++ b/app/assets/javascripts/pipeline_editor/index.js
@@ -2,7 +2,6 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import { resetServiceWorkersPublicPath } from '../lib/utils/webpack';
import { EDITOR_APP_STATUS_LOADING } from './constants';
import { CODE_SNIPPET_SOURCE_SETTINGS } from './components/code_snippet_alert/constants';
import getCurrentBranch from './graphql/queries/client/current_branch.query.graphql';
@@ -14,11 +13,6 @@ import typeDefs from './graphql/typedefs.graphql';
import PipelineEditorApp from './pipeline_editor_app.vue';
export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
- // Prevent issues loading syntax validation workers
- // Fixes https://gitlab.com/gitlab-org/gitlab/-/issues/297252
- // TODO Remove when https://gitlab.com/gitlab-org/gitlab/-/issues/321656 is resolved
- resetServiceWorkersPublicPath();
-
const el = document.querySelector(selector);
if (!el) {
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
index 1da50c55a68..a5436ca63cb 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
@@ -69,9 +69,10 @@ export default {
// If it's a brand new file, we don't want to fetch the content.
// Then when the user commits the first time, the query would run
// to get the initial file content, but we already have it in `lastCommitedContent`
- // so we skip the loading altogether.
- skip({ isNewCiConfigFile, lastCommittedContent }) {
- return isNewCiConfigFile || lastCommittedContent;
+ // so we skip the loading altogether. We also wait for the currentBranch
+ // to have been fetched
+ skip() {
+ return this.shouldSkipBlobContentQuery;
},
variables() {
return {
@@ -128,8 +129,8 @@ export default {
},
ciConfigData: {
query: getCiConfigData,
- skip({ currentCiFileContent }) {
- return !currentCiFileContent;
+ skip() {
+ return this.shouldSkipCiConfigQuery;
},
variables() {
return {
@@ -174,6 +175,9 @@ export default {
},
commitSha: {
query: getLatestCommitShaQuery,
+ skip({ currentBranch }) {
+ return !currentBranch;
+ },
variables() {
return {
projectPath: this.projectFullPath,
@@ -181,7 +185,7 @@ export default {
};
},
update(data) {
- const latestCommitSha = data.project?.repository?.tree?.lastCommit?.sha;
+ const latestCommitSha = data?.project?.repository?.tree?.lastCommit?.sha;
if (this.isFetchingCommitSha && latestCommitSha === this.commitSha) {
this.$apollo.queries.commitSha.startPolling(COMMIT_SHA_POLL_INTERVAL);
@@ -192,6 +196,9 @@ export default {
this.$apollo.queries.commitSha.stopPolling();
return latestCommitSha;
},
+ error() {
+ this.reportFailure(LOAD_FAILURE_UNKNOWN);
+ },
},
currentBranch: {
query: getCurrentBranch,
@@ -234,6 +241,12 @@ export default {
isEmpty() {
return this.currentCiFileContent === '';
},
+ shouldSkipBlobContentQuery() {
+ return this.isNewCiConfigFile || this.lastCommittedContent || !this.currentBranch;
+ },
+ shouldSkipCiConfigQuery() {
+ return !this.currentCiFileContent || !this.commitSha;
+ },
},
i18n: {
resetModal: {
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
index bb759477e1e..631dd8a2c00 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
@@ -131,6 +131,7 @@ export default {
:ref="$options.commitSectionRef"
:ci-file-content="ciFileContent"
:commit-sha="commitSha"
+ :has-unsaved-changes="hasUnsavedChanges"
:is-new-ci-config-file="isNewCiConfigFile"
:scroll-to-commit-form="scrollToCommitForm"
@scrolled-to-commit-form="setScrollToCommitForm(false)"
diff --git a/app/assets/javascripts/pipeline_wizard/components/commit.vue b/app/assets/javascripts/pipeline_wizard/components/commit.vue
index 518b41c66b1..e68458a494f 100644
--- a/app/assets/javascripts/pipeline_wizard/components/commit.vue
+++ b/app/assets/javascripts/pipeline_wizard/components/commit.vue
@@ -195,7 +195,7 @@ export default {
data-testid="branch_selector_group"
label-for="branch"
>
- <ref-selector id="branch" v-model="branch" data-testid="branch" :project-id="projectPath" />
+ <ref-selector id="branch" v-model="branch" :project-id="projectPath" data-testid="branch" />
</gl-form-group>
<gl-alert
v-if="!!commitError"
@@ -206,7 +206,7 @@ export default {
>
{{ commitError }}
</gl-alert>
- <step-nav show-back-button v-bind="$props" @back="$emit('go-back')">
+ <step-nav show-back-button v-bind="$props" @back="$emit('back')">
<template #after>
<gl-button
:disabled="isCommitButtonEnabled"
diff --git a/app/assets/javascripts/pipeline_wizard/components/input.vue b/app/assets/javascripts/pipeline_wizard/components/input.vue
new file mode 100644
index 00000000000..9a0c8026648
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/components/input.vue
@@ -0,0 +1,99 @@
+<script>
+import { isNode, isDocument, isSeq, visit } from 'yaml';
+import { capitalize } from 'lodash';
+import TextWidget from '~/pipeline_wizard/components/widgets/text.vue';
+import ListWidget from '~/pipeline_wizard/components/widgets/list.vue';
+
+const widgets = {
+ TextWidget,
+ ListWidget,
+};
+
+function isNullOrUndefined(v) {
+ return [undefined, null].includes(v);
+}
+
+export default {
+ components: {
+ ...widgets,
+ },
+ props: {
+ template: {
+ type: Object,
+ required: true,
+ validator: (v) => isNode(v),
+ },
+ compiled: {
+ type: Object,
+ required: true,
+ validator: (v) => isDocument(v) || isNode(v),
+ },
+ target: {
+ type: String,
+ required: true,
+ validator: (v) => /^\$.*/g.test(v),
+ },
+ widget: {
+ type: String,
+ required: true,
+ validator: (v) => {
+ return Object.keys(widgets).includes(`${capitalize(v)}Widget`);
+ },
+ },
+ validate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ path() {
+ let res;
+ visit(this.template, (seqKey, node, path) => {
+ if (node && node.value === this.target) {
+ // `path` is an array of objects (all the node's parents)
+ // So this reducer will reduce it to an array of the path's keys,
+ // e.g. `[ 'foo', 'bar', '0' ]`
+ res = path.reduce((p, { key }) => (key ? [...p, `${key}`] : p), []);
+ const parent = path[path.length - 1];
+ if (isSeq(parent)) {
+ res.push(seqKey);
+ }
+ }
+ });
+ return res;
+ },
+ },
+ methods: {
+ compile(v) {
+ if (!this.path) return;
+ if (isNullOrUndefined(v)) {
+ this.compiled.deleteIn(this.path);
+ }
+ this.compiled.setIn(this.path, v);
+ },
+ onModelChange(v) {
+ this.$emit('beforeUpdate:compiled');
+ this.compile(v);
+ this.$emit('update:compiled', this.compiled);
+ this.$emit('highlight', this.path);
+ },
+ onValidationStateChange(v) {
+ this.$emit('update:valid', v);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <component
+ :is="`${widget}-widget`"
+ ref="widget"
+ :validate="validate"
+ v-bind="$attrs"
+ @input="onModelChange"
+ @update:valid="onValidationStateChange"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_wizard/components/step.vue b/app/assets/javascripts/pipeline_wizard/components/step.vue
new file mode 100644
index 00000000000..c6f793e4cc5
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/components/step.vue
@@ -0,0 +1,149 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import { isNode, isDocument, parseDocument, Document } from 'yaml';
+import { merge } from '~/lib/utils/yaml';
+import { s__ } from '~/locale';
+import { logError } from '~/lib/logger';
+import InputWrapper from './input.vue';
+import StepNav from './step_nav.vue';
+
+export default {
+ name: 'PipelineWizardStep',
+ i18n: {
+ errors: {
+ cloneErrorUserMessage: s__(
+ 'PipelineWizard|There was an unexpected error trying to set up the template. The error has been logged.',
+ ),
+ },
+ },
+ components: {
+ StepNav,
+ InputWrapper,
+ GlAlert,
+ },
+ props: {
+ // As the inputs prop we expect to receive an array of instructions
+ // on how to display the input fields that will be used to obtain the
+ // user's input. Each input instruction needs a target prop, specifying
+ // the placeholder in the template that will be replaced by the user's
+ // input. The selected widget may require additional validation for the
+ // input object.
+ inputs: {
+ type: Array,
+ required: true,
+ validator: (value) =>
+ value.every((i) => {
+ return i?.target && i?.widget;
+ }),
+ },
+ template: {
+ type: null,
+ required: true,
+ validator: (v) => isNode(v),
+ },
+ hasPreviousStep: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ compiled: {
+ type: Object,
+ required: true,
+ validator: (v) => isDocument(v),
+ },
+ },
+ data() {
+ return {
+ wasCompiled: false,
+ validate: false,
+ inputValidStates: Array(this.inputs.length).fill(null),
+ error: null,
+ };
+ },
+ computed: {
+ inputValidStatesThatAreNotNull() {
+ return this.inputValidStates?.filter((s) => s !== null);
+ },
+ areAllInputValidStatesNull() {
+ return !this.inputValidStatesThatAreNotNull?.length;
+ },
+ isValid() {
+ return this.areAllInputValidStatesNull || this.inputValidStatesThatAreNotNull.every((s) => s);
+ },
+ },
+ methods: {
+ forceClone(yamlNode) {
+ try {
+ // document.clone() will only clone the root document object,
+ // but the references to the child nodes inside will be retained.
+ // So in order to ensure a full clone, we need to stringify
+ // and parse until there's a better implementation in the
+ // yaml package.
+ return parseDocument(new Document(yamlNode).toString());
+ } catch (e) {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ logError('An unexpected error occurred while trying to clone a template', e);
+ this.error = this.$options.i18n.errors.cloneErrorUserMessage;
+ return null;
+ }
+ },
+ compile() {
+ if (this.wasCompiled) return;
+ // NOTE: This modifies this.compiled without triggering reactivity.
+ // this is done on purpose, see
+ // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81412#note_862972703
+ // for more information
+ merge(this.compiled, this.forceClone(this.template));
+ this.wasCompiled = true;
+ },
+ onUpdate(c) {
+ this.$emit('update:compiled', c);
+ },
+ onPrevClick() {
+ this.$emit('back');
+ },
+ async onNextClick() {
+ this.validate = true;
+ await this.$nextTick();
+ if (this.isValid) {
+ this.$emit('next');
+ }
+ },
+ onInputValidationStateChange(inputId, value) {
+ this.$set(this.inputValidStates, inputId, value);
+ },
+ onHighlight(path) {
+ this.$emit('update:highlight', path);
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert v-if="error" class="gl-mb-4" variant="danger">
+ {{ error }}
+ </gl-alert>
+ <input-wrapper
+ v-for="(input, i) in inputs"
+ :key="input.target"
+ :compiled="compiled"
+ :target="input.target"
+ :template="template"
+ :validate="validate"
+ :widget="input.widget"
+ class="gl-mb-2"
+ v-bind="input"
+ @highlight="onHighlight"
+ @update:valid="(validationState) => onInputValidationStateChange(i, validationState)"
+ @update:compiled="onUpdate"
+ @beforeUpdate:compiled.once="compile"
+ />
+ <step-nav
+ :next-button-enabled="isValid"
+ :show-back-button="hasPreviousStep"
+ show-next-button
+ @back="onPrevClick"
+ @next="onNextClick"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_wizard/components/widgets/list.vue b/app/assets/javascripts/pipeline_wizard/components/widgets/list.vue
new file mode 100644
index 00000000000..a5ce56daf07
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/components/widgets/list.vue
@@ -0,0 +1,195 @@
+<script>
+import { uniqueId } from 'lodash';
+import { GlButton, GlFormGroup, GlFormInputGroup } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+const VALIDATION_STATE = {
+ NO_VALIDATION: null,
+ INVALID: false,
+ VALID: true,
+};
+
+export const i18n = {
+ addStepButtonLabel: s__('PipelineWizardListWidget|add another step'),
+ removeStepButtonLabel: s__('PipelineWizardListWidget|remove step'),
+ invalidFeedback: s__('PipelineWizardInputValidation|This value is not valid'),
+ errors: {
+ needsAnyValueError: s__('PipelineWizardInputValidation|At least one entry is required'),
+ },
+};
+
+export default {
+ i18n,
+ name: 'ListWidget',
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlFormInputGroup,
+ },
+ props: {
+ label: {
+ type: String,
+ required: true,
+ },
+ description: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ placeholder: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ default: {
+ type: Array,
+ required: false,
+ default: null,
+ },
+ invalidFeedback: {
+ type: String,
+ required: false,
+ default: i18n.invalidFeedback,
+ },
+ id: {
+ type: String,
+ required: false,
+ default: () => uniqueId('listWidget-'),
+ },
+ pattern: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ required: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ validate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ touched: false,
+ value: this.default ? this.default.map(this.getAsValueEntry) : [this.getAsValueEntry(null)],
+ };
+ },
+ computed: {
+ sanitizedValue() {
+ // Filter out empty steps
+ return this.value.filter(({ value }) => Boolean(value)).map(({ value }) => value) || [];
+ },
+ hasAnyValue() {
+ return this.value.some(({ value }) => Boolean(value));
+ },
+ needsAnyValue() {
+ return this.required && !this.value.some(({ value }) => Boolean(value));
+ },
+ inputFieldStates() {
+ return this.value.map(this.getValidationStateForValue);
+ },
+ inputGroupState() {
+ return this.showValidationState
+ ? this.inputFieldStates.every((v) => v !== VALIDATION_STATE.INVALID)
+ : VALIDATION_STATE.NO_VALIDATION;
+ },
+ showValidationState() {
+ return this.touched || this.validate;
+ },
+ feedback() {
+ return this.needsAnyValue
+ ? this.$options.i18n.errors.needsAnyValueError
+ : this.invalidFeedback;
+ },
+ },
+ async created() {
+ if (this.default) {
+ // emit an updated default value
+ await this.$nextTick();
+ this.$emit('input', this.sanitizedValue);
+ }
+ },
+ methods: {
+ addInputField() {
+ this.value.push(this.getAsValueEntry(null));
+ },
+ getAsValueEntry(value) {
+ return {
+ id: uniqueId('listValue-'),
+ value,
+ };
+ },
+ getValidationStateForValue({ value }, fieldIndex) {
+ // If we require a value to be set, mark the first
+ // field as invalid, but not all of them.
+ if (this.needsAnyValue && fieldIndex === 0) return VALIDATION_STATE.INVALID;
+ if (!value) return VALIDATION_STATE.NO_VALIDATION;
+ return this.passesPatternValidation(value)
+ ? VALIDATION_STATE.VALID
+ : VALIDATION_STATE.INVALID;
+ },
+ passesPatternValidation(v) {
+ return !this.pattern || new RegExp(this.pattern).test(v);
+ },
+ async onValueUpdate() {
+ await this.$nextTick();
+ this.$emit('input', this.sanitizedValue);
+ },
+ onTouch() {
+ this.touched = true;
+ },
+ removeValue(index) {
+ this.value.splice(index, 1);
+ this.onValueUpdate();
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-mb-6">
+ <gl-form-group
+ :invalid-feedback="feedback"
+ :label="label"
+ :label-description="description"
+ :state="inputGroupState"
+ class="gl-mb-2"
+ >
+ <gl-form-input-group
+ v-for="(item, i) in value"
+ :key="item.id"
+ v-model.trim="value[i].value"
+ :placeholder="i === 0 ? placeholder : undefined"
+ :state="inputFieldStates[i]"
+ class="gl-mb-2"
+ type="text"
+ @blur="onTouch"
+ @input="onValueUpdate"
+ >
+ <template v-if="value.length > 1" #append>
+ <gl-button
+ :aria-label="$options.i18n.removeStepButtonLabel"
+ category="secondary"
+ data-testid="remove-step-button"
+ icon="remove"
+ @click="removeValue"
+ />
+ </template>
+ </gl-form-input-group>
+ </gl-form-group>
+ <gl-button
+ category="tertiary"
+ data-testid="add-step-button"
+ icon="plus"
+ size="small"
+ variant="confirm"
+ @click="addInputField"
+ >
+ {{ $options.i18n.addStepButtonLabel }}
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue
new file mode 100644
index 00000000000..b7207576ddc
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue
@@ -0,0 +1,185 @@
+<script>
+import { GlProgressBar } from '@gitlab/ui';
+import { Document } from 'yaml';
+import { merge } from '~/lib/utils/yaml';
+import { __ } from '~/locale';
+import { isValidStepSeq } from '~/pipeline_wizard/validators';
+import YamlEditor from './editor.vue';
+import WizardStep from './step.vue';
+import CommitStep from './commit.vue';
+
+export const i18n = {
+ stepNofN: __('Step %{currentStep} of %{stepCount}'),
+ draft: __('Draft: %{filename}'),
+ overlayMessage: __(`Start inputting changes and we will generate a
+ YAML-file for you to add to your repository`),
+};
+
+export default {
+ name: 'PipelineWizardWrapper',
+ i18n,
+ components: {
+ GlProgressBar,
+ YamlEditor,
+ WizardStep,
+ CommitStep,
+ },
+ props: {
+ steps: {
+ type: Object,
+ required: true,
+ validator: isValidStepSeq,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ defaultBranch: {
+ type: String,
+ required: true,
+ },
+ filename: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ highlightPath: null,
+ currentStepIndex: 0,
+ // TODO: In order to support updating existing pipelines, the below
+ // should contain a parsed version of an existing .gitlab-ci.yml.
+ // See https://gitlab.com/gitlab-org/gitlab/-/issues/355306
+ compiled: new Document({}),
+ showPlaceholder: true,
+ pipelineBlob: null,
+ placeholder: this.getPlaceholder(),
+ };
+ },
+ computed: {
+ currentStepConfig() {
+ return this.steps.get(this.currentStepIndex);
+ },
+ currentStepInputs() {
+ return this.currentStepConfig.get('inputs').toJSON();
+ },
+ currentStepTemplate() {
+ return this.currentStepConfig.get('template', true);
+ },
+ currentStep() {
+ return this.currentStepIndex + 1;
+ },
+ stepCount() {
+ return this.steps.items.length + 1;
+ },
+ progress() {
+ return Math.ceil((this.currentStep / (this.stepCount + 1)) * 100);
+ },
+ isLastStep() {
+ return this.currentStep === this.stepCount;
+ },
+ },
+ watch: {
+ isLastStep(value) {
+ if (value) this.resetHighlight();
+ },
+ },
+ methods: {
+ resetHighlight() {
+ this.highlightPath = null;
+ },
+ onUpdate() {
+ this.showPlaceholder = false;
+ },
+ onEditorUpdate(blob) {
+ // TODO: In a later iteration, we could add a loopback allowing for
+ // changes from the editor to flow back into the model
+ // see https://gitlab.com/gitlab-org/gitlab/-/issues/355312
+ this.pipelineBlob = blob;
+ },
+ getPlaceholder() {
+ const doc = new Document({});
+ this.steps.items.forEach((tpl) => {
+ merge(doc, tpl.get('template').clone());
+ });
+ return doc;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="row gl-mt-8">
+ <main class="col-md-6 gl-pr-8">
+ <header class="gl-mb-5">
+ <h3 class="text-secondary gl-mt-0" data-testid="step-count">
+ {{ sprintf($options.i18n.stepNofN, { currentStep, stepCount }) }}
+ </h3>
+ <gl-progress-bar :value="progress" variant="success" />
+ </header>
+ <section class="gl-mb-4">
+ <commit-step
+ v-if="isLastStep"
+ ref="step"
+ :default-branch="defaultBranch"
+ :file-content="pipelineBlob"
+ :filename="filename"
+ :project-path="projectPath"
+ @back="currentStepIndex--"
+ />
+ <wizard-step
+ v-else
+ :key="currentStepIndex"
+ ref="step"
+ :compiled.sync="compiled"
+ :has-next-step="currentStepIndex < steps.items.length"
+ :has-previous-step="currentStepIndex > 0"
+ :highlight.sync="highlightPath"
+ :inputs="currentStepInputs"
+ :template="currentStepTemplate"
+ @back="currentStepIndex--"
+ @next="currentStepIndex++"
+ @update:compiled="onUpdate"
+ />
+ </section>
+ </main>
+ <aside class="col-md-6 gl-pt-3">
+ <div
+ class="gl-border-1 gl-border-gray-100 gl-border-solid border-radius-default gl-bg-gray-10"
+ >
+ <h6 class="gl-p-2 gl-px-4 text-secondary" data-testid="editor-header">
+ {{ sprintf($options.i18n.draft, { filename }) }}
+ </h6>
+ <div class="gl-relative gl-overflow-hidden">
+ <yaml-editor
+ :aria-hidden="showPlaceholder"
+ :doc="showPlaceholder ? placeholder : compiled"
+ :filename="filename"
+ :highlight="highlightPath"
+ class="gl-w-full"
+ @update:yaml="onEditorUpdate"
+ />
+ <div
+ v-if="showPlaceholder"
+ class="gl-absolute gl-top-0 gl-right-0 gl-bottom-0 gl-left-0 gl-filter-blur-1"
+ data-testid="placeholder-overlay"
+ >
+ <div
+ class="gl-absolute gl-top-0 gl-right-0 gl-bottom-0 gl-left-0 bg-white gl-opacity-5 gl-z-index-2"
+ ></div>
+ <div
+ class="gl-relative gl-h-full gl-display-flex gl-align-items-center gl-justify-content-center gl-z-index-3"
+ >
+ <div class="gl-max-w-34">
+ <h4 data-testid="filename">{{ filename }}</h4>
+ <p data-testid="description">
+ {{ $options.i18n.overlayMessage }}
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </aside>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue
new file mode 100644
index 00000000000..7200b4e3782
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue
@@ -0,0 +1,65 @@
+<script>
+import { parseDocument } from 'yaml';
+import WizardWrapper from './components/wrapper.vue';
+
+export default {
+ name: 'PipelineWizard',
+ components: {
+ WizardWrapper,
+ },
+ props: {
+ template: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ defaultBranch: {
+ type: String,
+ required: true,
+ },
+ defaultFilename: {
+ type: String,
+ required: false,
+ default: '.gitlab-ci.yml',
+ },
+ },
+ computed: {
+ parsedTemplate() {
+ return this.template ? parseDocument(this.template) : null;
+ },
+ title() {
+ return this.parsedTemplate?.get('title');
+ },
+ description() {
+ return this.parsedTemplate?.get('description');
+ },
+ filename() {
+ return this.parsedTemplate?.get('filename') || this.defaultFilename;
+ },
+ steps() {
+ return this.parsedTemplate?.get('steps');
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="gl-my-8">
+ <h2 class="gl-mb-4" data-testid="title">{{ title }}</h2>
+ <p class="text-tertiary gl-font-lg gl-max-w-80" data-testid="description">
+ {{ description }}
+ </p>
+ </div>
+ <wizard-wrapper
+ v-if="steps"
+ :default-branch="defaultBranch"
+ :filename="filename"
+ :project-path="projectPath"
+ :steps="steps"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_wizard/validators.js b/app/assets/javascripts/pipeline_wizard/validators.js
new file mode 100644
index 00000000000..57cd56b23a5
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/validators.js
@@ -0,0 +1,4 @@
+import { isSeq } from 'yaml';
+
+export const isValidStepSeq = (v) =>
+ isSeq(v) && v.items.every((s) => s.get('inputs') && s.get('template'));
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index 6a4d1bb44f2..ac97c9d2743 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -174,6 +174,8 @@ export default {
});
if (errors.length > 0) {
+ this.isRetrying = false;
+
this.reportFailure(POST_FAILURE);
} else {
await this.$apollo.queries.pipeline.refetch();
@@ -182,6 +184,8 @@ export default {
}
}
} catch {
+ this.isRetrying = false;
+
this.reportFailure(POST_FAILURE);
}
},
diff --git a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
index 99fb5c146ba..b45f3e4f32c 100644
--- a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
+++ b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
@@ -60,6 +60,15 @@ export default {
iid: this.pipelineIid,
};
},
+ loading() {
+ return this.$apollo.queries.jobs.loading;
+ },
+ showSkeletonLoader() {
+ return this.firstLoad && this.loading;
+ },
+ showLoadingSpinner() {
+ return !this.firstLoad && this.loading;
+ },
},
mounted() {
eventHub.$on('jobActionPerformed', this.handleJobAction);
@@ -69,7 +78,7 @@ export default {
},
methods: {
handleJobAction() {
- this.firstLoad = true;
+ this.firstLoad = false;
this.$apollo.queries.jobs.refetch();
},
@@ -98,7 +107,7 @@ export default {
<template>
<div>
- <div v-if="$apollo.loading && firstLoad" class="gl-mt-5">
+ <div v-if="showSkeletonLoader" class="gl-mt-5">
<gl-skeleton-loader :width="1248" :height="73">
<circle cx="748.031" cy="37.7193" r="15.0307" />
<circle cx="787.241" cy="37.7193" r="15.0307" />
@@ -118,7 +127,7 @@ export default {
<jobs-table v-else :jobs="jobs" :table-fields="$options.fields" data-testid="jobs-tab-table" />
<gl-intersection-observer v-if="jobsPageInfo.hasNextPage" @appear="fetchMoreJobs">
- <gl-loading-icon v-if="$apollo.loading" size="md" />
+ <gl-loading-icon v-if="showLoadingSpinner" size="md" />
</gl-intersection-observer>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
index 1ce6654e0e9..0380ba646cc 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
@@ -1,33 +1,15 @@
<script>
-import { GlEmptyState, GlButton } from '@gitlab/ui';
-import { startCodeQualityWalkthrough, track } from '~/code_quality_walkthrough/utils';
-import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue';
-import ExperimentTracking from '~/experimentation/experiment_tracking';
-import { getExperimentData } from '~/experimentation/utils';
-import { helpPagePath } from '~/helpers/help_page_helper';
+import { GlEmptyState } from '@gitlab/ui';
import { s__ } from '~/locale';
import PipelinesCiTemplates from './pipelines_ci_templates.vue';
export default {
i18n: {
- title: s__('Pipelines|Build with confidence'),
- description: s__(`Pipelines|GitLab CI/CD can automatically build,
- test, and deploy your code. Let GitLab take care of time
- consuming tasks, so you can spend more time creating.`),
- aboutRunnersBtnText: s__('Pipelines|Learn about Runners'),
- installRunnersBtnText: s__('Pipelines|Install GitLab Runners'),
- codeQualityTitle: s__('Pipelines|Improve code quality with GitLab CI/CD'),
- codeQualityDescription: s__(`Pipelines|To keep your codebase simple,
- readable, and accessible to contributors, use GitLab CI/CD
- to analyze your code quality with every push to your project.`),
- codeQualityBtnText: s__('Pipelines|Add a code quality job'),
noCiDescription: s__('Pipelines|This project is not currently set up to run pipelines.'),
},
name: 'PipelinesEmptyState',
components: {
GlEmptyState,
- GlButton,
- GitlabExperiment,
PipelinesCiTemplates,
},
props: {
@@ -39,88 +21,26 @@ export default {
type: Boolean,
required: true,
},
- codeQualityPagePath: {
- type: String,
- required: false,
- default: null,
- },
ciRunnerSettingsPath: {
type: String,
required: false,
default: null,
},
- },
- computed: {
- ciHelpPagePath() {
- return helpPagePath('ci/quick_start/index.md');
- },
- isCodeQualityExperimentActive() {
- return this.canSetCi && Boolean(getExperimentData('code_quality_walkthrough'));
- },
- isCiRunnerTemplatesExperimentActive() {
- return this.canSetCi && Boolean(getExperimentData('ci_runner_templates'));
- },
- },
- mounted() {
- startCodeQualityWalkthrough();
- },
- methods: {
- trackClick() {
- track('cta_clicked');
- },
- trackCiRunnerTemplatesClick(action) {
- const tracking = new ExperimentTracking('ci_runner_templates');
- tracking.event(action);
+ anyRunnersAvailable: {
+ type: Boolean,
+ required: false,
+ default: true,
},
},
};
</script>
<template>
<div>
- <gitlab-experiment v-if="isCodeQualityExperimentActive" name="code_quality_walkthrough">
- <template #control><pipelines-ci-templates /></template>
- <template #candidate>
- <gl-empty-state
- :title="$options.i18n.codeQualityTitle"
- :svg-path="emptyStateSvgPath"
- :description="$options.i18n.codeQualityDescription"
- >
- <template #actions>
- <gl-button :href="codeQualityPagePath" variant="confirm" @click="trackClick()">
- {{ $options.i18n.codeQualityBtnText }}
- </gl-button>
- </template>
- </gl-empty-state>
- </template>
- </gitlab-experiment>
- <gitlab-experiment v-else-if="isCiRunnerTemplatesExperimentActive" name="ci_runner_templates">
- <template #control><pipelines-ci-templates /></template>
- <template #candidate>
- <gl-empty-state
- :title="$options.i18n.title"
- :svg-path="emptyStateSvgPath"
- :description="$options.i18n.description"
- >
- <template #actions>
- <gl-button
- :href="ciRunnerSettingsPath"
- variant="confirm"
- @click="trackCiRunnerTemplatesClick('install_runners_button_clicked')"
- >
- {{ $options.i18n.installRunnersBtnText }}
- </gl-button>
- <gl-button
- :href="ciHelpPagePath"
- variant="default"
- @click="trackCiRunnerTemplatesClick('learn_button_clicked')"
- >
- {{ $options.i18n.aboutRunnersBtnText }}
- </gl-button>
- </template>
- </gl-empty-state>
- </template>
- </gitlab-experiment>
- <pipelines-ci-templates v-else-if="canSetCi" />
+ <pipelines-ci-templates
+ v-if="canSetCi"
+ :ci-runner-settings-path="ciRunnerSettingsPath"
+ :any-runners-available="anyRunnersAvailable"
+ />
<gl-empty-state
v-else
title=""
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_labels.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_labels.vue
new file mode 100644
index 00000000000..40b2454b8c1
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_labels.vue
@@ -0,0 +1,170 @@
+<script>
+import { GlLink, GlPopover, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { SCHEDULE_ORIGIN } from '../../constants';
+
+export default {
+ components: {
+ GlBadge,
+ GlLink,
+ GlPopover,
+ GlSprintf,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: {
+ targetProjectFullPath: {
+ default: '',
+ },
+ },
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ pipelineScheduleUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ isScheduled() {
+ return this.pipeline.source === SCHEDULE_ORIGIN;
+ },
+ isInFork() {
+ return Boolean(
+ this.targetProjectFullPath &&
+ this.pipeline?.project?.full_path !== `/${this.targetProjectFullPath}`,
+ );
+ },
+ autoDevopsTagId() {
+ return `pipeline-url-autodevops-${this.pipeline.id}`;
+ },
+ autoDevopsHelpPath() {
+ return helpPagePath('topics/autodevops/index.md');
+ },
+ },
+};
+</script>
+<template>
+ <div class="label-container gl-mt-1">
+ <gl-badge
+ v-if="isScheduled"
+ v-gl-tooltip
+ :href="pipelineScheduleUrl"
+ target="__blank"
+ :title="__('This pipeline was triggered by a schedule.')"
+ variant="info"
+ size="sm"
+ data-testid="pipeline-url-scheduled"
+ >{{ __('Scheduled') }}</gl-badge
+ >
+ <gl-badge
+ v-if="pipeline.flags.latest"
+ v-gl-tooltip
+ :title="__('Latest pipeline for the most recent commit on this branch')"
+ variant="success"
+ size="sm"
+ data-testid="pipeline-url-latest"
+ >{{ __('latest') }}</gl-badge
+ >
+ <gl-badge
+ v-if="pipeline.flags.merge_train_pipeline"
+ v-gl-tooltip
+ :title="
+ s__(
+ 'Pipeline|This pipeline ran on the contents of this merge request combined with the contents of all other merge requests queued for merging into the target branch.',
+ )
+ "
+ variant="info"
+ size="sm"
+ data-testid="pipeline-url-train"
+ >{{ s__('Pipeline|merge train') }}</gl-badge
+ >
+ <gl-badge
+ v-if="pipeline.flags.yaml_errors"
+ v-gl-tooltip
+ :title="pipeline.yaml_errors"
+ variant="danger"
+ size="sm"
+ data-testid="pipeline-url-yaml"
+ >{{ __('yaml invalid') }}</gl-badge
+ >
+ <gl-badge
+ v-if="pipeline.flags.failure_reason"
+ v-gl-tooltip
+ :title="pipeline.failure_reason"
+ variant="danger"
+ size="sm"
+ data-testid="pipeline-url-failure"
+ >{{ __('error') }}</gl-badge
+ >
+ <template v-if="pipeline.flags.auto_devops">
+ <gl-link
+ :id="autoDevopsTagId"
+ tabindex="0"
+ data-testid="pipeline-url-autodevops"
+ role="button"
+ >
+ <gl-badge variant="info" size="sm">
+ {{ __('Auto DevOps') }}
+ </gl-badge>
+ </gl-link>
+ <gl-popover :target="autoDevopsTagId" triggers="focus" placement="top">
+ <template #title>
+ <div class="gl-font-weight-normal gl-line-height-normal">
+ <gl-sprintf
+ :message="
+ __(
+ 'This pipeline makes use of a predefined CI/CD configuration enabled by %{strongStart}Auto DevOps.%{strongEnd}',
+ )
+ "
+ >
+ <template #strong="{ content }">
+ <b>{{ content }}</b>
+ </template>
+ </gl-sprintf>
+ </div>
+ </template>
+ <gl-link
+ :href="autoDevopsHelpPath"
+ data-testid="pipeline-url-autodevops-link"
+ target="_blank"
+ >
+ {{ __('Learn more about Auto DevOps') }}
+ </gl-link>
+ </gl-popover>
+ </template>
+
+ <gl-badge
+ v-if="pipeline.flags.stuck"
+ variant="warning"
+ size="sm"
+ data-testid="pipeline-url-stuck"
+ >{{ __('stuck') }}</gl-badge
+ >
+ <gl-badge
+ v-if="pipeline.flags.detached_merge_request_pipeline"
+ v-gl-tooltip
+ :title="
+ s__(
+ `Pipeline|This pipeline ran on the contents of this merge request's source branch, not the target branch.`,
+ )
+ "
+ variant="info"
+ size="sm"
+ data-testid="pipeline-url-detached"
+ >{{ s__('Pipeline|merge request') }}</gl-badge
+ >
+ <gl-badge
+ v-if="isInFork"
+ v-gl-tooltip
+ :title="__('Pipeline ran in fork of project')"
+ variant="info"
+ size="sm"
+ data-testid="pipeline-url-fork"
+ >{{ __('fork') }}</gl-badge
+ >
+ </div>
+</template>
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 7c78abae77f..1dcbd77a92d 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -1,29 +1,20 @@
<script>
-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 { GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
-import { SCHEDULE_ORIGIN, ICONS } from '../../constants';
+import { ICONS } from '../../constants';
+import PipelineLabels from './pipeline_labels.vue';
export default {
components: {
GlIcon,
GlLink,
- GlPopover,
- GlSprintf,
- GlBadge,
+ PipelineLabels,
TooltipOnTruncate,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [glFeatureFlagMixin()],
- inject: {
- targetProjectFullPath: {
- default: '',
- },
- },
props: {
pipeline: {
type: Object,
@@ -37,27 +28,8 @@ export default {
type: String,
required: true,
},
- viewType: {
- type: String,
- required: true,
- },
},
computed: {
- isScheduled() {
- return this.pipeline.source === SCHEDULE_ORIGIN;
- },
- isInFork() {
- return Boolean(
- this.targetProjectFullPath &&
- this.pipeline?.project?.full_path !== `/${this.targetProjectFullPath}`,
- );
- },
- autoDevopsTagId() {
- return `pipeline-url-autodevops-${this.pipeline.id}`;
- },
- autoDevopsHelpPath() {
- return helpPagePath('topics/autodevops/index.md');
- },
mergeRequestRef() {
return this.pipeline?.merge_request;
},
@@ -139,205 +111,66 @@ export default {
commitTitle() {
return this.pipeline?.commit?.title;
},
- 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 v-if="commitTitle" class="gl-display-flex">
- <tooltip-on-truncate :title="commitTitle" class="flex-truncate-child gl-flex-grow-1">
- <gl-link
- :href="commitUrl"
- class="commit-row-message gl-text-gray-900"
- data-testid="commit-title"
- >{{ commitTitle }}</gl-link
- >
- </tooltip-on-truncate>
- </span>
- <span v-else>{{ __("Can't find HEAD commit for this branch") }}</span>
- </div>
- <div class="gl-mb-2">
- <gl-link
- :href="pipeline.path"
- class="gl-text-decoration-underline gl-text-blue-600!"
- data-testid="pipeline-url-link"
- data-qa-selector="pipeline_url_link"
- >
- #{{ pipeline[pipelineKey] }}
- </gl-link>
- <!--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">
+ <div class="commit-title gl-mb-2" data-testid="commit-title-container">
+ <span v-if="commitTitle" class="gl-display-flex">
+ <tooltip-on-truncate :title="commitTitle" class="gl-flex-grow-1 gl-text-truncate">
<gl-link
- v-if="mergeRequestRef"
- :href="mergeRequestRef.path"
- class="ref-name"
- data-testid="merge-request-ref"
- >{{ mergeRequestRef.iid }}</gl-link
+ :href="commitUrl"
+ class="commit-row-message gl-text-gray-900"
+ data-testid="commit-title"
+ >{{ commitTitle }}</gl-link
>
- <gl-link v-else :href="refUrl" class="ref-name" data-testid="commit-ref-name">{{
- commitRef.name
- }}</gl-link>
</tooltip-on-truncate>
+ </span>
+ <span v-else>{{ __("Can't find HEAD commit for this branch") }}</span>
+ </div>
+ <div class="gl-mb-2">
+ <gl-link
+ :href="pipeline.path"
+ class="gl-text-decoration-underline gl-text-blue-600! gl-mr-3"
+ data-testid="pipeline-url-link"
+ data-qa-selector="pipeline_url_link"
+ >
+ #{{ pipeline[pipelineKey] }}
+ </gl-link>
+ <!--Commit row-->
+ <div class="icon-container gl-display-inline-block gl-mr-1">
<gl-icon
v-gl-tooltip
- name="commit"
- class="commit-icon"
- :title="__('Commit')"
- data-testid="commit-icon"
+ :name="commitIcon"
+ :title="commitIconTooltipTitle"
+ data-testid="commit-icon-type"
/>
-
- <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"
- data-qa-selector="pipeline_url_link"
- >
- #{{ pipeline[pipelineKey] }}
- </gl-link>
- <div class="label-container gl-mt-1">
- <gl-badge
- v-if="isScheduled"
- v-gl-tooltip
- :href="pipelineScheduleUrl"
- target="__blank"
- :title="__('This pipeline was triggered by a schedule.')"
- variant="info"
- size="sm"
- data-testid="pipeline-url-scheduled"
- >{{ __('Scheduled') }}</gl-badge
- >
- <gl-badge
- v-if="pipeline.flags.latest"
- v-gl-tooltip
- :title="__('Latest pipeline for the most recent commit on this branch')"
- variant="success"
- size="sm"
- data-testid="pipeline-url-latest"
- >{{ __('latest') }}</gl-badge
- >
- <gl-badge
- v-if="pipeline.flags.merge_train_pipeline"
- v-gl-tooltip
- :title="__('This is a merge train pipeline')"
- variant="info"
- size="sm"
- data-testid="pipeline-url-train"
- >{{ __('train') }}</gl-badge
- >
- <gl-badge
- v-if="pipeline.flags.yaml_errors"
- v-gl-tooltip
- :title="pipeline.yaml_errors"
- variant="danger"
- size="sm"
- data-testid="pipeline-url-yaml"
- >{{ __('yaml invalid') }}</gl-badge
- >
- <gl-badge
- v-if="pipeline.flags.failure_reason"
- v-gl-tooltip
- :title="pipeline.failure_reason"
- variant="danger"
- size="sm"
- data-testid="pipeline-url-failure"
- >{{ __('error') }}</gl-badge
- >
- <template v-if="pipeline.flags.auto_devops">
+ <tooltip-on-truncate :title="tooltipTitle" truncate-target="child" placement="top">
<gl-link
- :id="autoDevopsTagId"
- tabindex="0"
- data-testid="pipeline-url-autodevops"
- role="button"
+ v-if="mergeRequestRef"
+ :href="mergeRequestRef.path"
+ class="ref-name gl-mr-3"
+ data-testid="merge-request-ref"
+ >{{ mergeRequestRef.iid }}</gl-link
>
- <gl-badge variant="info" size="sm">
- {{ __('Auto DevOps') }}
- </gl-badge>
- </gl-link>
- <gl-popover :target="autoDevopsTagId" triggers="focus" placement="top">
- <template #title>
- <div class="gl-font-weight-normal gl-line-height-normal">
- <gl-sprintf
- :message="
- __(
- 'This pipeline makes use of a predefined CI/CD configuration enabled by %{strongStart}Auto DevOps.%{strongEnd}',
- )
- "
- >
- <template #strong="{ content }">
- <b>{{ content }}</b>
- </template>
- </gl-sprintf>
- </div>
- </template>
- <gl-link
- :href="autoDevopsHelpPath"
- data-testid="pipeline-url-autodevops-link"
- target="_blank"
- >
- {{ __('Learn more about Auto DevOps') }}
- </gl-link>
- </gl-popover>
- </template>
-
- <gl-badge
- v-if="pipeline.flags.stuck"
- variant="warning"
- size="sm"
- data-testid="pipeline-url-stuck"
- >{{ __('stuck') }}</gl-badge
- >
- <gl-badge
- v-if="pipeline.flags.detached_merge_request_pipeline"
- v-gl-tooltip
- :title="
- __(
- '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"
- size="sm"
- data-testid="pipeline-url-detached"
- >{{ __('detached') }}</gl-badge
- >
- <gl-badge
- v-if="isInFork"
+ <gl-link v-else :href="refUrl" class="ref-name gl-mr-3" data-testid="commit-ref-name">{{
+ commitRef.name
+ }}</gl-link>
+ </tooltip-on-truncate>
+ <gl-icon
v-gl-tooltip
- :title="__('Pipeline ran in fork of project')"
- variant="info"
- size="sm"
- data-testid="pipeline-url-fork"
- >{{ __('fork') }}</gl-badge
- >
+ name="commit"
+ class="commit-icon gl-mr-1"
+ :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>
+ <pipeline-labels :pipeline-schedule-url="pipelineScheduleUrl" :pipeline="pipeline" />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
index e7ff5449331..db9dc74863d 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -98,19 +98,24 @@ export default {
type: String,
required: true,
},
+ defaultBranchName: {
+ type: String,
+ required: false,
+ default: null,
+ },
params: {
type: Object,
required: true,
},
- codeQualityPagePath: {
+ ciRunnerSettingsPath: {
type: String,
required: false,
default: null,
},
- ciRunnerSettingsPath: {
- type: String,
+ anyRunnersAvailable: {
+ type: Boolean,
required: false,
- default: null,
+ default: true,
},
},
data() {
@@ -347,6 +352,7 @@ export default {
<pipelines-filtered-search
class="gl-display-flex gl-flex-grow-1 gl-mr-4"
:project-id="projectId"
+ :default-branch-name="defaultBranchName"
:params="validatedParams"
@filterPipelines="filterPipelines"
/>
@@ -380,8 +386,8 @@ export default {
v-else-if="stateToRender === $options.stateMap.emptyState"
:empty-state-svg-path="emptyStateSvgPath"
:can-set-ci="canCreatePipeline"
- :code-quality-page-path="codeQualityPagePath"
:ci-runner-settings-path="ciRunnerSettingsPath"
+ :any-runners-available="anyRunnersAvailable"
/>
<gl-empty-state
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue
index 83f6356f31a..d50229e47c4 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue
@@ -1,8 +1,19 @@
<script>
-import { GlAvatar, GlButton, GlCard, GlSprintf } from '@gitlab/ui';
+import { GlAvatar, GlButton, GlCard, GlSprintf, GlIcon, GlLink } from '@gitlab/ui';
import { mergeUrlParams } from '~/lib/utils/url_utility';
-import { s__, sprintf } from '~/locale';
-import { STARTER_TEMPLATE_NAME } from '~/pipeline_editor/constants';
+import { sprintf } from '~/locale';
+import {
+ STARTER_TEMPLATE_NAME,
+ RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME,
+ RUNNERS_SETTINGS_LINK_CLICKED_EVENT,
+ RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT,
+ RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT,
+ I18N,
+} from '~/pipeline_editor/constants';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue';
+import ExperimentTracking from '~/experimentation/experiment_tracking';
+import { isExperimentVariant } from '~/experimentation/utils';
import Tracking from '~/tracking';
export default {
@@ -11,39 +22,37 @@ export default {
GlButton,
GlCard,
GlSprintf,
+ GlIcon,
+ GlLink,
+ GitlabExperiment,
},
mixins: [Tracking.mixin()],
STARTER_TEMPLATE_NAME,
- i18n: {
- cta: s__('Pipelines|Use template'),
- testTemplates: {
- title: s__('Pipelines|Use a sample CI/CD template'),
- subtitle: s__(
- 'Pipelines|Use a sample %{codeStart}.gitlab-ci.yml%{codeEnd} template file to explore how CI/CD works.',
- ),
- gettingStarted: {
- title: s__('Pipelines|Get started with GitLab CI/CD'),
- description: s__(
- 'Pipelines|Get familiar with GitLab CI/CD syntax by starting with a basic 3 stage CI/CD pipeline.',
- ),
- },
+ RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME,
+ RUNNERS_SETTINGS_LINK_CLICKED_EVENT,
+ RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT,
+ RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT,
+ I18N,
+ inject: ['pipelineEditorPath', 'suggestedCiTemplates'],
+ props: {
+ ciRunnerSettingsPath: {
+ type: String,
+ required: false,
+ default: null,
},
- templates: {
- title: s__('Pipelines|Use a CI/CD template'),
- subtitle: s__(
- "Pipelines|Use a template based on your project's language or framework to get started with GitLab CI/CD.",
- ),
- description: s__('Pipelines|CI/CD template to test and deploy your %{name} project.'),
+ anyRunnersAvailable: {
+ type: Boolean,
+ required: false,
+ default: true,
},
},
- inject: ['pipelineEditorPath', 'suggestedCiTemplates'],
data() {
const templates = this.suggestedCiTemplates.map(({ name, logo }) => {
return {
name,
logo,
link: mergeUrlParams({ template: name }, this.pipelineEditorPath),
- description: sprintf(this.$options.i18n.templates.description, { name }),
+ description: sprintf(this.$options.I18N.templates.description, { name }),
};
});
@@ -53,39 +62,104 @@ export default {
{ template: STARTER_TEMPLATE_NAME },
this.pipelineEditorPath,
),
+ tracker: null,
};
},
+ computed: {
+ sharedRunnersHelpPagePath() {
+ return helpPagePath('ci/runners/runners_scope', { anchor: 'shared-runners' });
+ },
+ runnersAvailabilitySectionExperimentEnabled() {
+ return isExperimentVariant(RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME);
+ },
+ },
+ created() {
+ this.tracker = new ExperimentTracking(RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME);
+ },
methods: {
trackEvent(template) {
this.track('template_clicked', {
label: template,
});
},
+ trackExperimentEvent(action) {
+ this.tracker.event(action);
+ },
},
};
</script>
<template>
<div>
- <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.i18n.testTemplates.title }}</h2>
- <p class="gl-text-gray-800 gl-mb-6">
- <gl-sprintf :message="$options.i18n.testTemplates.subtitle">
- <template #code="{ content }">
- <code>{{ content }}</code>
- </template>
- </gl-sprintf>
- </p>
+ <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.I18N.title }}</h2>
- <div class="row gl-mb-8">
- <div class="col-12">
+ <gitlab-experiment :name="$options.RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME">
+ <template #candidate>
+ <div v-if="anyRunnersAvailable">
+ <h2 class="gl-font-base gl-text-gray-900">
+ <gl-icon name="check-circle-filled" class="gl-text-green-500 gl-mr-2" :size="12" />
+ {{ $options.I18N.runners.title }}
+ </h2>
+ <p class="gl-text-gray-800 gl-mb-6">
+ <gl-sprintf :message="$options.I18N.runners.subtitle">
+ <template #settingsLink="{ content }">
+ <gl-link
+ data-testid="settings-link"
+ :href="ciRunnerSettingsPath"
+ @click="trackExperimentEvent($options.RUNNERS_SETTINGS_LINK_CLICKED_EVENT)"
+ >{{ content }}</gl-link
+ >
+ </template>
+ <template #docsLink="{ content }">
+ <gl-link
+ data-testid="documentation-link"
+ :href="sharedRunnersHelpPagePath"
+ @click="trackExperimentEvent($options.RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT)"
+ >{{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+
+ <div v-else>
+ <h2 class="gl-font-base gl-text-gray-900">
+ <gl-icon name="warning-solid" class="gl-text-red-600 gl-mr-2" :size="14" />
+ {{ $options.I18N.noRunners.title }}
+ </h2>
+ <p class="gl-text-gray-800 gl-mb-6">{{ $options.I18N.noRunners.subtitle }}</p>
+ <gl-button
+ data-testid="settings-button"
+ category="primary"
+ variant="confirm"
+ :href="ciRunnerSettingsPath"
+ @click="trackExperimentEvent($options.RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT)"
+ >
+ {{ $options.I18N.noRunners.cta }}
+ </gl-button>
+ </div>
+ </template>
+ </gitlab-experiment>
+
+ <template v-if="!runnersAvailabilitySectionExperimentEnabled || anyRunnersAvailable">
+ <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.learnBasics.title }}</h2>
+ <p class="gl-text-gray-800 gl-mb-6">
+ <gl-sprintf :message="$options.I18N.learnBasics.subtitle">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <div class="gl-lg-w-25p gl-lg-pr-5 gl-mb-8">
<gl-card>
<div class="gl-flex-direction-row">
<div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div>
<div class="gl-mb-3">
- <strong class="gl-text-gray-800 gl-mb-2">{{
- $options.i18n.testTemplates.gettingStarted.title
- }}</strong>
+ <strong class="gl-text-gray-800 gl-mb-2">
+ {{ $options.I18N.learnBasics.gettingStarted.title }}
+ </strong>
</div>
- <p class="gl-font-sm">{{ $options.i18n.testTemplates.gettingStarted.description }}</p>
+ <p class="gl-font-sm">{{ $options.I18N.learnBasics.gettingStarted.description }}</p>
</div>
<gl-button
@@ -95,51 +169,51 @@ export default {
data-testid="test-template-link"
@click="trackEvent($options.STARTER_TEMPLATE_NAME)"
>
- {{ $options.i18n.cta }}
+ {{ $options.I18N.learnBasics.gettingStarted.cta }}
</gl-button>
</gl-card>
</div>
- </div>
- <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.i18n.templates.title }}</h2>
- <p class="gl-text-gray-800 gl-mb-6">{{ $options.i18n.templates.subtitle }}</p>
+ <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.templates.title }}</h2>
+ <p class="gl-text-gray-800 gl-mb-6">{{ $options.I18N.templates.subtitle }}</p>
- <ul class="gl-list-style-none gl-pl-0">
- <li v-for="template in templates" :key="template.name">
- <div
- class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-pb-3 gl-pt-3"
- >
- <div class="gl-display-flex gl-flex-direction-row gl-align-items-center">
- <gl-avatar
- :src="template.logo"
- :size="64"
- class="gl-mr-6 gl-bg-white dark-mode-override"
- shape="rect"
- :alt="template.name"
- data-testid="template-logo"
- />
- <div class="gl-flex-direction-row">
- <div class="gl-mb-3">
- <strong class="gl-text-gray-800" data-testid="template-name">{{
- template.name
- }}</strong>
+ <ul class="gl-list-style-none gl-pl-0">
+ <li v-for="template in templates" :key="template.name">
+ <div
+ class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-pb-3 gl-pt-3"
+ >
+ <div class="gl-display-flex gl-flex-direction-row gl-align-items-center">
+ <gl-avatar
+ :src="template.logo"
+ :size="48"
+ class="gl-mr-5 gl-bg-white dark-mode-override"
+ shape="rect"
+ :alt="template.name"
+ data-testid="template-logo"
+ />
+ <div class="gl-flex-direction-row">
+ <div class="gl-mb-3">
+ <strong class="gl-text-gray-800" data-testid="template-name">
+ {{ template.name }}
+ </strong>
+ </div>
+ <p class="gl-mb-0 gl-font-sm" data-testid="template-description">
+ {{ template.description }}
+ </p>
</div>
- <p class="gl-mb-0 gl-font-sm" data-testid="template-description">
- {{ template.description }}
- </p>
</div>
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :href="template.link"
+ data-testid="template-link"
+ @click="trackEvent(template.name)"
+ >
+ {{ $options.I18N.templates.cta }}
+ </gl-button>
</div>
- <gl-button
- category="primary"
- variant="confirm"
- :href="template.link"
- data-testid="template-link"
- @click="trackEvent(template.name)"
- >
- {{ $options.i18n.cta }}
- </gl-button>
- </div>
- </li>
- </ul>
+ </li>
+ </ul>
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_commit.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_commit.vue
deleted file mode 100644
index cc676883c1d..00000000000
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_commit.vue
+++ /dev/null
@@ -1,85 +0,0 @@
-<script>
-import { CHILD_VIEW } from '~/pipelines/constants';
-import CommitComponent from '~/vue_shared/components/commit.vue';
-
-export default {
- components: {
- CommitComponent,
- },
- props: {
- pipeline: {
- type: Object,
- required: true,
- },
- viewType: {
- type: String,
- required: true,
- },
- },
- computed: {
- commitAuthor() {
- let commitAuthorInformation;
-
- if (!this.pipeline || !this.pipeline.commit) {
- return null;
- }
-
- // 1. person who is an author of a commit might be a GitLab user
- if (this.pipeline.commit.author) {
- // 2. if person who is an author of a commit is a GitLab user
- // they can have a GitLab avatar
- if (this.pipeline.commit.author.avatar_url) {
- commitAuthorInformation = this.pipeline.commit.author;
-
- // 3. If GitLab user does not have avatar, they might have a Gravatar
- } else if (this.pipeline.commit.author_gravatar_url) {
- commitAuthorInformation = {
- ...this.pipeline.commit.author,
- avatar_url: this.pipeline.commit.author_gravatar_url,
- };
- }
- // 4. If committer is not a GitLab User, they can have a Gravatar
- } else {
- commitAuthorInformation = {
- avatar_url: this.pipeline.commit.author_gravatar_url,
- path: `mailto:${this.pipeline.commit.author_email}`,
- username: this.pipeline.commit.author_name,
- };
- }
-
- return commitAuthorInformation;
- },
- commitTag() {
- return this.pipeline?.ref?.tag;
- },
- commitRef() {
- return this.pipeline?.ref;
- },
- commitUrl() {
- return this.pipeline?.commit?.commit_path;
- },
- commitShortSha() {
- return this.pipeline?.commit?.short_id;
- },
- commitTitle() {
- return this.pipeline?.commit?.title;
- },
- isChildView() {
- return this.viewType === CHILD_VIEW;
- },
- },
-};
-</script>
-
-<template>
- <commit-component
- :tag="commitTag"
- :commit-ref="commitRef"
- :commit-url="commitUrl"
- :merge-request-ref="pipeline.merge_request"
- :short-sha="commitShortSha"
- :title="commitTitle"
- :author="commitAuthor"
- :show-ref-info="!isChildView"
- />
-</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue
index 2dfdaa0ea28..4d28545a035 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue
@@ -24,6 +24,11 @@ export default {
type: String,
required: true,
},
+ defaultBranchName: {
+ type: String,
+ required: false,
+ default: null,
+ },
params: {
type: Object,
required: true,
@@ -57,6 +62,7 @@ export default {
token: PipelineBranchNameToken,
operators: OPERATOR_IS_ONLY,
projectId: this.projectId,
+ defaultBranchName: this.defaultBranchName,
disabled: this.selectedTypes.includes(this.$options.tagType),
},
{
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 54901c2d13f..e765a8cd86c 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue
@@ -1,18 +1,13 @@
<script>
-import CodeQualityWalkthrough from '~/code_quality_walkthrough/components/step.vue';
-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,
@@ -30,23 +25,6 @@ export default {
isChildView() {
return this.viewType === CHILD_VIEW;
},
- shouldRenderCodeQualityWalkthrough() {
- return Object.values(PIPELINE_STATUSES).includes(this.pipelineStatus.group);
- },
- codeQualityStep() {
- const prefix = [PIPELINE_STATUSES.successWithWarnings, PIPELINE_STATUSES.failed].includes(
- this.pipelineStatus.group,
- )
- ? 'failed'
- : this.pipelineStatus.group;
- return `${prefix}_pipeline`;
- },
- codeQualityBuildPath() {
- return this.pipeline?.details?.code_quality_build_path;
- },
- rearrangePipelinesTable() {
- return this.glFeatures?.rearrangePipelinesTable;
- },
},
};
</script>
@@ -54,18 +32,12 @@ export default {
<template>
<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"
- :link="codeQualityBuildPath"
- />
+ <pipelines-timeago class="gl-mt-3" :pipeline="pipeline" />
</div>
</template>
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 9919a18cb99..6f0e67e1ae0 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -1,16 +1,13 @@
<script>
import { GlTableLite, GlTooltipDirective } from '@gitlab/ui';
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';
import PipelineStopModal from './pipeline_stop_modal.vue';
import PipelineTriggerer from './pipeline_triggerer.vue';
import PipelineUrl from './pipeline_url.vue';
-import PipelinesCommit from './pipelines_commit.vue';
import PipelinesStatusBadge from './pipelines_status_badge.vue';
-import PipelinesTimeago from './time_ago.vue';
const DEFAULT_TD_CLASS = 'gl-p-5!';
const HIDE_TD_ON_MOBILE = 'gl-display-none! gl-lg-display-table-cell!';
@@ -22,19 +19,57 @@ export default {
GlTableLite,
LinkedPipelinesMiniList: () =>
import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'),
- PipelinesCommit,
PipelineMiniGraph,
PipelineOperations,
PipelinesStatusBadge,
PipelineStopModal,
- PipelinesTimeago,
PipelineTriggerer,
PipelineUrl,
},
+ tableFields: [
+ {
+ key: 'status',
+ label: s__('Pipeline|Status'),
+ thClass: DEFAULT_TH_CLASSES,
+ columnClass: 'gl-w-15p',
+ tdClass: DEFAULT_TD_CLASS,
+ thAttr: { 'data-testid': 'status-th' },
+ },
+ {
+ key: 'pipeline',
+ label: __('Pipeline'),
+ thClass: DEFAULT_TH_CLASSES,
+ tdClass: `${DEFAULT_TD_CLASS}`,
+ columnClass: 'gl-w-30p',
+ thAttr: { 'data-testid': 'pipeline-th' },
+ },
+ {
+ key: 'triggerer',
+ label: s__('Pipeline|Triggerer'),
+ thClass: DEFAULT_TH_CLASSES,
+ tdClass: `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`,
+ columnClass: 'gl-w-10p',
+ thAttr: { 'data-testid': 'triggerer-th' },
+ },
+ {
+ key: 'stages',
+ label: s__('Pipeline|Stages'),
+ thClass: DEFAULT_TH_CLASSES,
+ tdClass: DEFAULT_TD_CLASS,
+ columnClass: 'gl-w-quarter',
+ thAttr: { 'data-testid': 'stages-th' },
+ },
+ {
+ key: 'actions',
+ thClass: DEFAULT_TH_CLASSES,
+ tdClass: DEFAULT_TD_CLASS,
+ columnClass: 'gl-w-15p',
+ thAttr: { 'data-testid': 'actions-th' },
+ },
+ ],
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [glFeatureFlagMixin()],
props: {
pipelines: {
type: Array,
@@ -67,76 +102,6 @@ export default {
cancelingPipeline: null,
};
},
- computed: {
- tableFields() {
- const fields = [
- {
- key: 'status',
- label: s__('Pipeline|Status'),
- thClass: DEFAULT_TH_CLASSES,
- columnClass: this.rearrangePipelinesTable ? 'gl-w-15p' : 'gl-w-10p',
- tdClass: DEFAULT_TD_CLASS,
- thAttr: { 'data-testid': 'status-th' },
- },
- {
- key: 'pipeline',
- label: this.rearrangePipelinesTable ? __('Pipeline') : this.pipelineKeyOption.label,
- thClass: DEFAULT_TH_CLASSES,
- 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' },
- },
- {
- key: 'triggerer',
- label: s__('Pipeline|Triggerer'),
- thClass: DEFAULT_TH_CLASSES,
- tdClass: `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`,
- columnClass: 'gl-w-10p',
- thAttr: { 'data-testid': 'triggerer-th' },
- },
- {
- key: 'commit',
- label: s__('Pipeline|Commit'),
- thClass: DEFAULT_TH_CLASSES,
- tdClass: DEFAULT_TD_CLASS,
- columnClass: 'gl-w-20p',
- thAttr: { 'data-testid': 'commit-th' },
- },
- {
- key: 'stages',
- label: s__('Pipeline|Stages'),
- thClass: DEFAULT_TH_CLASSES,
- tdClass: DEFAULT_TD_CLASS,
- columnClass: 'gl-w-quarter',
- thAttr: { 'data-testid': 'stages-th' },
- },
- {
- key: 'timeago',
- label: s__('Pipeline|Duration'),
- thClass: DEFAULT_TH_CLASSES,
- tdClass: DEFAULT_TD_CLASS,
- columnClass: this.rearrangePipelinesTable ? 'gl-w-5p' : 'gl-w-15p',
- thAttr: { 'data-testid': 'timeago-th' },
- },
- {
- key: 'actions',
- thClass: DEFAULT_TH_CLASSES,
- tdClass: DEFAULT_TD_CLASS,
- columnClass: 'gl-w-15p',
- thAttr: { 'data-testid': 'actions-th' },
- },
- ];
-
- return !this.rearrangePipelinesTable
- ? fields
- : fields.filter((field) => !['commit', 'timeago'].includes(field.key));
- },
- rearrangePipelinesTable() {
- return this.glFeatures?.rearrangePipelinesTable;
- },
- },
watch: {
pipelines() {
this.cancelingPipeline = null;
@@ -167,7 +132,7 @@ export default {
<template>
<div class="ci-table">
<gl-table-lite
- :fields="tableFields"
+ :fields="$options.tableFields"
:items="pipelines"
tbody-tr-class="commit"
:tbody-tr-attr="{ 'data-testid': 'pipeline-table-row' }"
@@ -192,7 +157,6 @@ export default {
:pipeline="item"
:pipeline-schedule-url="pipelineScheduleUrl"
:pipeline-key="pipelineKeyOption.key"
- :view-type="viewType"
/>
</template>
@@ -200,10 +164,6 @@ export default {
<pipeline-triggerer :pipeline="item" />
</template>
- <template #cell(commit)="{ item }">
- <pipelines-commit :pipeline="item" :view-type="viewType" />
- </template>
-
<template #cell(stages)="{ item }">
<div class="stage-cell">
<!-- This empty div should be removed, see https://gitlab.com/gitlab-org/gitlab/-/issues/323488 -->
@@ -229,10 +189,6 @@ export default {
</div>
</template>
- <template #cell(timeago)="{ item }">
- <pipelines-timeago :pipeline="item" />
- </template>
-
<template #cell(actions)="{ item }">
<pipeline-operations :pipeline="item" :canceling-pipeline="cancelingPipeline" />
</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 c45e3f24567..cde963e4051 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -1,6 +1,5 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
@@ -8,7 +7,7 @@ export default {
GlTooltip: GlTooltipDirective,
},
components: { GlIcon },
- mixins: [timeagoMixin, glFeatureFlagMixin()],
+ mixins: [timeagoMixin],
props: {
pipeline: {
type: Object,
@@ -54,14 +53,11 @@ export default {
showSkipped() {
return !this.duration && !this.finishedTime && this.skipped;
},
- shouldDisplayAsBlock() {
- return this.glFeatures?.rearrangePipelinesTable;
- },
},
};
</script>
<template>
- <div class="{ 'gl-display-block': shouldDisplayAsBlock }">
+ <div class="gl-display-block">
<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
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue
index 5409e68cdc4..1db2898b72a 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue
@@ -35,6 +35,13 @@ export default {
Api.branches(this.config.projectId, searchterm)
.then(({ data }) => {
this.branches = data.map((branch) => branch.name);
+ if (!searchterm && this.config.defaultBranchName) {
+ // Shift the default branch to the top of the list
+ this.branches = this.branches.filter(
+ (branch) => branch !== this.config.defaultBranchName,
+ );
+ this.branches.unshift(this.config.defaultBranchName);
+ }
this.loading = false;
})
.catch((err) => {
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index bfb95e5ab0c..801f71cb364 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -69,9 +69,7 @@ export default async function initPipelineDetailsBundle() {
}
try {
- if (gon.features?.jobsTabVue) {
- createPipelineJobsApp(SELECTORS.PIPELINE_JOBS);
- }
+ createPipelineJobsApp(SELECTORS.PIPELINE_JOBS);
} catch {
createFlash({
message: __('An error occurred while loading the Jobs tab.'),
diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js
index c4c2b5f2927..f4d9a44a754 100644
--- a/app/assets/javascripts/pipelines/pipelines_index.js
+++ b/app/assets/javascripts/pipelines/pipelines_index.js
@@ -36,9 +36,10 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
ciLintPath,
resetCachePath,
projectId,
+ defaultBranchName,
params,
- codeQualityPagePath,
ciRunnerSettingsPath,
+ anyRunnersAvailable,
} = el.dataset;
return new Vue({
@@ -75,9 +76,10 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
ciLintPath,
resetCachePath,
projectId,
+ defaultBranchName,
params: JSON.parse(params),
- codeQualityPagePath,
ciRunnerSettingsPath,
+ anyRunnersAvailable: parseBoolean(anyRunnersAvailable),
},
});
},
diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js
index ff9b47cdcd6..25fefff219c 100644
--- a/app/assets/javascripts/profile/profile.js
+++ b/app/assets/javascripts/profile/profile.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import createFlash from '~/flash';
+import createFlash, { FLASH_TYPES } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { parseBoolean } from '~/lib/utils/common_utils';
import { Rails } from '~/lib/utils/rails_ujs';
@@ -86,7 +86,7 @@ export default class Profile {
createFlash({
message: data.message,
- type: 'notice',
+ type: data.status === 'error' ? FLASH_TYPES.ALERT : FLASH_TYPES.NOTICE,
});
})
.then(() => {
diff --git a/app/assets/javascripts/projects/pipelines/charts/index.js b/app/assets/javascripts/projects/pipelines/charts/index.js
index 94d32609e5d..28b77f6defd 100644
--- a/app/assets/javascripts/projects/pipelines/charts/index.js
+++ b/app/assets/javascripts/projects/pipelines/charts/index.js
@@ -11,7 +11,13 @@ const apolloProvider = new VueApollo({
});
const mountPipelineChartsApp = (el) => {
- const { projectPath, failedPipelinesLink, coverageChartPath, defaultBranch } = el.dataset;
+ const {
+ projectPath,
+ failedPipelinesLink,
+ coverageChartPath,
+ defaultBranch,
+ testRunsEmptyStateImagePath,
+ } = el.dataset;
const shouldRenderDoraCharts = parseBoolean(el.dataset.shouldRenderDoraCharts);
const shouldRenderQualitySummary = parseBoolean(el.dataset.shouldRenderQualitySummary);
@@ -30,6 +36,7 @@ const mountPipelineChartsApp = (el) => {
shouldRenderQualitySummary,
coverageChartPath,
defaultBranch,
+ testRunsEmptyStateImagePath,
},
render: (createElement) => createElement(ProjectPipelinesCharts, {}),
});
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index 62e2cec874a..f1b7e3df7d6 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -120,15 +120,6 @@ const bindHowToImport = () => {
});
});
});
-
- $('.how_to_import_link').on('click', (e) => {
- e.preventDefault();
- $(e.currentTarget).next('.modal').show();
- });
-
- $('.modal-header .close').on('click', () => {
- $('.modal').hide();
- });
};
const bindEvents = () => {
@@ -153,8 +144,8 @@ const bindEvents = () => {
bindHowToImport();
- $('.btn_import_gitlab_project').on('click', () => {
- const importHref = $('a.btn_import_gitlab_project').attr('href');
+ $('.btn_import_gitlab_project').on('click contextmenu', () => {
+ const importHref = $('a.btn_import_gitlab_project').attr('data-href');
$('.btn_import_gitlab_project').attr(
'href',
`${importHref}?namespace_id=${$(
diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js
index d4b52860261..16eb5c3de32 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_create.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_create.js
@@ -5,6 +5,7 @@ import AccessorUtilities from '~/lib/utils/accessor';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import AccessDropdown from '~/projects/settings/access_dropdown';
+import { initToggle } from '~/toggles';
import { ACCESS_LEVELS, LEVEL_TYPES } from './constants';
export default class ProtectedBranchCreate {
@@ -15,25 +16,18 @@ export default class ProtectedBranchCreate {
this.isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage();
this.currentProjectUserDefaults = {};
this.buildDropdowns();
- this.$forcePushToggle = this.$form.find('.js-force-push-toggle');
- this.$codeOwnerToggle = this.$form.find('.js-code-owner-toggle');
- this.bindEvents();
- }
- bindEvents() {
- this.$forcePushToggle.on('click', this.onForcePushToggleClick.bind(this));
+ this.forcePushToggle = initToggle(document.querySelector('.js-force-push-toggle'));
+
if (this.hasLicense) {
- this.$codeOwnerToggle.on('click', this.onCodeOwnerToggleClick.bind(this));
+ this.codeOwnerToggle = initToggle(document.querySelector('.js-code-owner-toggle'));
}
- this.$form.on('submit', this.onFormSubmit.bind(this));
- }
- onForcePushToggleClick() {
- this.$forcePushToggle.toggleClass('is-checked');
+ this.bindEvents();
}
- onCodeOwnerToggleClick() {
- this.$codeOwnerToggle.toggleClass('is-checked');
+ bindEvents() {
+ this.$form.on('submit', this.onFormSubmit.bind(this));
}
buildDropdowns() {
@@ -92,8 +86,8 @@ export default class ProtectedBranchCreate {
authenticity_token: this.$form.find('input[name="authenticity_token"]').val(),
protected_branch: {
name: this.$form.find('input[name="protected_branch[name]"]').val(),
- allow_force_push: this.$forcePushToggle.hasClass('is-checked'),
- code_owner_approval_required: this.$codeOwnerToggle.hasClass('is-checked'),
+ allow_force_push: this.forcePushToggle.value,
+ code_owner_approval_required: this.codeOwnerToggle?.value ?? false,
},
};
diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js
index 86273cfdda6..15e706e38c6 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_edit.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js
@@ -3,6 +3,7 @@ import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import AccessDropdown from '~/projects/settings/access_dropdown';
+import { initToggle } from '~/toggles';
import { ACCESS_LEVELS, LEVEL_TYPES } from './constants';
export default class ProtectedBranchEdit {
@@ -14,8 +15,6 @@ export default class ProtectedBranchEdit {
this.$wrap = options.$wrap;
this.$allowedToMergeDropdown = this.$wrap.find('.js-allowed-to-merge');
this.$allowedToPushDropdown = this.$wrap.find('.js-allowed-to-push');
- this.$forcePushToggle = this.$wrap.find('.js-force-push-toggle');
- this.$codeOwnerToggle = this.$wrap.find('.js-code-owner-toggle');
this.$wraps[ACCESS_LEVELS.MERGE] = this.$allowedToMergeDropdown.closest(
`.${ACCESS_LEVELS.MERGE}-container`,
@@ -25,36 +24,47 @@ export default class ProtectedBranchEdit {
);
this.buildDropdowns();
- this.bindEvents();
+ this.initToggles();
}
- bindEvents() {
- this.$forcePushToggle.on('click', this.onForcePushToggleClick.bind(this));
- if (this.hasLicense) {
- this.$codeOwnerToggle.on('click', this.onCodeOwnerToggleClick.bind(this));
+ initToggles() {
+ const wrap = this.$wrap.get(0);
+
+ const forcePushToggle = initToggle(wrap.querySelector('.js-force-push-toggle'));
+ if (forcePushToggle) {
+ forcePushToggle.$on('change', (value) => {
+ forcePushToggle.isLoading = true;
+ forcePushToggle.disabled = true;
+ this.updateProtectedBranch(
+ {
+ allow_force_push: value,
+ },
+ () => {
+ forcePushToggle.isLoading = false;
+ forcePushToggle.disabled = false;
+ },
+ );
+ });
}
- }
-
- onForcePushToggleClick() {
- this.$forcePushToggle.toggleClass('is-checked');
- this.$forcePushToggle.prop('disabled', true);
-
- const formData = {
- allow_force_push: this.$forcePushToggle.hasClass('is-checked'),
- };
-
- this.updateProtectedBranch(formData, () => this.$forcePushToggle.prop('disabled', false));
- }
- onCodeOwnerToggleClick() {
- this.$codeOwnerToggle.toggleClass('is-checked');
- this.$codeOwnerToggle.prop('disabled', true);
-
- const formData = {
- code_owner_approval_required: this.$codeOwnerToggle.hasClass('is-checked'),
- };
-
- this.updateProtectedBranch(formData, () => this.$codeOwnerToggle.prop('disabled', false));
+ if (this.hasLicense) {
+ const codeOwnerToggle = initToggle(wrap.querySelector('.js-code-owner-toggle'));
+ if (codeOwnerToggle) {
+ codeOwnerToggle.$on('change', (value) => {
+ codeOwnerToggle.isLoading = true;
+ codeOwnerToggle.disabled = true;
+ this.updateProtectedBranch(
+ {
+ code_owner_approval_required: value,
+ },
+ () => {
+ codeOwnerToggle.isLoading = false;
+ codeOwnerToggle.disabled = false;
+ },
+ );
+ });
+ }
+ }
}
updateProtectedBranch(formData, callback) {
diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue
index ce781c64006..d02526160fd 100644
--- a/app/assets/javascripts/ref/components/ref_selector.vue
+++ b/app/assets/javascripts/ref/components/ref_selector.vue
@@ -58,6 +58,11 @@ export default {
required: false,
default: () => ({}),
},
+ useSymbolicRefNames: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
/** The validation state of this component. */
state: {
@@ -121,8 +126,15 @@ export default {
query: this.lastQuery,
};
},
+ selectedRefForDisplay() {
+ if (this.useSymbolicRefNames && this.selectedRef) {
+ return this.selectedRef.replace(/^refs\/(tags|heads)\//, '');
+ }
+
+ return this.selectedRef;
+ },
buttonText() {
- return this.selectedRef || this.i18n.noRefSelected;
+ return this.selectedRefForDisplay || this.i18n.noRefSelected;
},
},
watch: {
@@ -164,9 +176,20 @@ export default {
},
{ immediate: true },
);
+
+ this.$watch(
+ 'useSymbolicRefNames',
+ () => this.setUseSymbolicRefNames(this.useSymbolicRefNames),
+ { immediate: true },
+ );
},
methods: {
- ...mapActions(['setEnabledRefTypes', 'setProjectId', 'setSelectedRef']),
+ ...mapActions([
+ 'setEnabledRefTypes',
+ 'setUseSymbolicRefNames',
+ 'setProjectId',
+ 'setSelectedRef',
+ ]),
...mapActions({ storeSearch: 'search' }),
focusSearchBox() {
this.$refs.searchBox.$el.querySelector('input').focus();
diff --git a/app/assets/javascripts/ref/stores/actions.js b/app/assets/javascripts/ref/stores/actions.js
index 3832cc0c21d..a6019f21e73 100644
--- a/app/assets/javascripts/ref/stores/actions.js
+++ b/app/assets/javascripts/ref/stores/actions.js
@@ -5,6 +5,9 @@ import * as types from './mutation_types';
export const setEnabledRefTypes = ({ commit }, refTypes) =>
commit(types.SET_ENABLED_REF_TYPES, refTypes);
+export const setUseSymbolicRefNames = ({ commit }, useSymbolicRefNames) =>
+ commit(types.SET_USE_SYMBOLIC_REF_NAMES, useSymbolicRefNames);
+
export const setProjectId = ({ commit }, projectId) => commit(types.SET_PROJECT_ID, projectId);
export const setSelectedRef = ({ commit }, selectedRef) =>
diff --git a/app/assets/javascripts/ref/stores/mutation_types.js b/app/assets/javascripts/ref/stores/mutation_types.js
index c26f4fa00c7..4c602908cae 100644
--- a/app/assets/javascripts/ref/stores/mutation_types.js
+++ b/app/assets/javascripts/ref/stores/mutation_types.js
@@ -1,4 +1,5 @@
export const SET_ENABLED_REF_TYPES = 'SET_ENABLED_REF_TYPES';
+export const SET_USE_SYMBOLIC_REF_NAMES = 'SET_USE_SYMBOLIC_REF_NAMES';
export const SET_PROJECT_ID = 'SET_PROJECT_ID';
export const SET_SELECTED_REF = 'SET_SELECTED_REF';
diff --git a/app/assets/javascripts/ref/stores/mutations.js b/app/assets/javascripts/ref/stores/mutations.js
index f91cbae8462..e078d3333d4 100644
--- a/app/assets/javascripts/ref/stores/mutations.js
+++ b/app/assets/javascripts/ref/stores/mutations.js
@@ -7,6 +7,9 @@ export default {
[types.SET_ENABLED_REF_TYPES](state, refTypes) {
state.enabledRefTypes = refTypes;
},
+ [types.SET_USE_SYMBOLIC_REF_NAMES](state, useSymbolicRefNames) {
+ state.useSymbolicRefNames = useSymbolicRefNames;
+ },
[types.SET_PROJECT_ID](state, projectId) {
state.projectId = projectId;
},
@@ -28,6 +31,7 @@ export default {
state.matches.branches = {
list: convertObjectPropsToCamelCase(response.data).map((b) => ({
name: b.name,
+ value: state.useSymbolicRefNames ? `refs/heads/${b.name}` : undefined,
default: b.default,
})),
totalCount: parseInt(response.headers[X_TOTAL_HEADER], 10),
@@ -46,6 +50,7 @@ export default {
state.matches.tags = {
list: convertObjectPropsToCamelCase(response.data).map((b) => ({
name: b.name,
+ value: state.useSymbolicRefNames ? `refs/tags/${b.name}` : undefined,
})),
totalCount: parseInt(response.headers[X_TOTAL_HEADER], 10),
error: null,
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 bc97fab9ad2..eeb4c254a1b 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_block.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue
@@ -85,6 +85,16 @@ export default {
required: false,
default: true,
},
+ autoCompleteEpics: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ autoCompleteIssues: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
computed: {
hasRelatedIssues() {
@@ -198,6 +208,8 @@ export default {
:input-value="inputValue"
:pending-references="pendingReferences"
:auto-complete-sources="autoCompleteSources"
+ :auto-complete-epics="autoCompleteEpics"
+ :auto-complete-issues="autoCompleteIssues"
:path-id-separator="pathIdSeparator"
@pendingIssuableRemoveRequest="$emit('pendingIssuableRemoveRequest', $event)"
@addIssuableFormInput="$emit('addIssuableFormInput', $event)"
@@ -210,6 +222,7 @@ export default {
<related-issues-list
v-for="category in categorisedIssues"
:key="category.linkType"
+ :list-link-type="category.linkType"
:heading="$options.linkedIssueTypesTextMap[category.linkType]"
:can-admin="canAdmin"
:can-reorder="canReorder"
diff --git a/app/assets/javascripts/related_issues/components/related_issues_list.vue b/app/assets/javascripts/related_issues/components/related_issues_list.vue
index 8b39851405e..174049b15fe 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_list.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue
@@ -21,6 +21,11 @@ export default {
required: false,
default: false,
},
+ listLinkType: {
+ type: String,
+ required: false,
+ default: '',
+ },
heading: {
type: String,
required: false,
@@ -91,7 +96,7 @@ export default {
</script>
<template>
- <div>
+ <div :data-link-type="listLinkType">
<h4 v-if="heading" class="gl-font-base mt-0">{{ heading }}</h4>
<div
class="related-issues-token-body bordered-box bg-white"
diff --git a/app/assets/javascripts/related_issues/components/related_issues_root.vue b/app/assets/javascripts/related_issues/components/related_issues_root.vue
index 7e2fda8495c..40d58c04753 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_root.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_root.vue
@@ -71,6 +71,16 @@ export default {
required: false,
default: true,
},
+ autoCompleteEpics: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ autoCompleteIssues: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
pathIdSeparator: {
type: String,
required: false,
@@ -241,6 +251,8 @@ export default {
:is-form-visible="isFormVisible"
:input-value="inputValue"
:auto-complete-sources="autoCompleteSources"
+ :auto-complete-epics="autoCompleteEpics"
+ :auto-complete-issues="autoCompleteIssues"
:issuable-type="issuableType"
:path-id-separator="pathIdSeparator"
:show-categorized-issues="showCategorizedIssues"
diff --git a/app/assets/javascripts/related_issues/index.js b/app/assets/javascripts/related_issues/index.js
index 35858be90b2..b61f1cf2470 100644
--- a/app/assets/javascripts/related_issues/index.js
+++ b/app/assets/javascripts/related_issues/index.js
@@ -21,6 +21,7 @@ export default function initRelatedIssues() {
showCategorizedIssues: parseBoolean(
relatedIssuesRootElement.dataset.showCategorizedIssues,
),
+ autoCompleteEpics: false,
},
}),
});
diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue
index c2c91f406a1..e53bfea7389 100644
--- a/app/assets/javascripts/releases/components/app_index.vue
+++ b/app/assets/javascripts/releases/components/app_index.vue
@@ -68,7 +68,7 @@ export default {
:href="newReleasePath"
:aria-describedby="shouldRenderEmptyState && 'releases-description'"
category="primary"
- variant="success"
+ variant="confirm"
data-testid="new-release-button"
>
{{ __('New release') }}
diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue
index b9601428850..b81da399a7b 100644
--- a/app/assets/javascripts/releases/components/asset_links_form.vue
+++ b/app/assets/javascripts/releases/components/asset_links_form.vue
@@ -56,6 +56,9 @@ export default {
hasDuplicateUrl(link) {
return Boolean(this.getLinkErrors(link).isDuplicate);
},
+ hasDuplicateName(link) {
+ return Boolean(this.getLinkErrors(link).isTitleDuplicate);
+ },
hasBadFormat(link) {
return Boolean(this.getLinkErrors(link).isBadFormat);
},
@@ -72,7 +75,7 @@ export default {
return !this.hasDuplicateUrl(link) && !this.hasBadFormat(link) && !this.hasEmptyUrl(link);
},
isNameValid(link) {
- return !this.hasEmptyName(link);
+ return !this.hasEmptyName(link) && !this.hasDuplicateName(link);
},
/**
@@ -121,7 +124,7 @@ export default {
<p>
{{
__(
- 'Point to any links you like: documentation, built binaries, or other related materials. These can be internal or external links from your GitLab instance. Duplicate URLs are not allowed.',
+ 'Point to any links you like: documentation, built binaries, or other related materials. These can be internal or external links from your GitLab instance. Each URL and link title must be unique.',
)
}}
</p>
@@ -165,7 +168,7 @@ export default {
</gl-sprintf>
</span>
<span v-else-if="hasDuplicateUrl(link)" class="invalid-feedback d-inline">
- {{ __('This URL is already used for another link; duplicate URLs are not allowed') }}
+ {{ __('This URL already exists.') }}
</span>
</template>
</gl-form-group>
@@ -191,6 +194,9 @@ export default {
<span v-if="hasEmptyName(link)" class="invalid-feedback d-inline">
{{ __('Link title is required') }}
</span>
+ <span v-else-if="hasDuplicateName(link)" class="invalid-feedback d-inline">
+ {{ __('This title already exists.') }}
+ </span>
</template>
</gl-form-group>
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
index 576f099248e..b3ba4f9263a 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
@@ -162,7 +162,7 @@ const createReleaseLink = async ({ state, link }) => {
input: {
projectPath: state.projectPath,
tagName: state.tagName,
- name: link.name,
+ name: link.name.trim(),
url: link.url,
linkType: link.linkType.toUpperCase(),
directAssetPath: link.directAssetPath,
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
index d83ec05872a..d4f49e53619 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
@@ -1,5 +1,6 @@
import { isEmpty } from 'lodash';
import { hasContent } from '~/lib/utils/text_utility';
+import { getDuplicateItemsFromArray } from '~/lib/utils/array_utility';
/**
* @returns {Boolean} `true` if the app is editing an existing release.
@@ -95,6 +96,17 @@ export const validationErrors = (state) => {
}
});
+ // check for duplicated Link Titles
+ const linkTitles = state.release.assets.links.map((link) => link.name.trim());
+ const duplicatedTitles = getDuplicateItemsFromArray(linkTitles);
+
+ // add a validation error for each link that shares Link Title
+ state.release.assets.links.forEach((link) => {
+ if (hasContent(link.name) && duplicatedTitles.includes(link.name.trim())) {
+ errors.assets.links[link.id].isTitleDuplicate = true;
+ }
+ });
+
return errors;
};
@@ -131,7 +143,7 @@ export const releaseCreateMutatationVariables = (state, getters) => {
ref: state.createFrom,
assets: {
links: getters.releaseLinksToCreate.map(({ name, url, linkType }) => ({
- name,
+ name: name.trim(),
url,
linkType: linkType.toUpperCase(),
})),
diff --git a/app/assets/javascripts/reports/codequality_report/constants.js b/app/assets/javascripts/reports/codequality_report/constants.js
index 502977e714c..0c472b24471 100644
--- a/app/assets/javascripts/reports/codequality_report/constants.js
+++ b/app/assets/javascripts/reports/codequality_report/constants.js
@@ -15,3 +15,17 @@ export const SEVERITY_ICONS = {
blocker: 'severity-critical',
unknown: 'severity-unknown',
};
+
+// This is the icons mapping for the code Quality Merge-Request Widget Extension
+// once the refactor_mr_widgets_extensions flag is activated the above SEVERITY_ICONS
+// need be removed and this variable needs to be rename to SEVERITY_ICONS
+// Rollout Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/341759
+
+export const SEVERITY_ICONS_EXTENSION = {
+ info: 'severityInfo',
+ minor: 'severityLow',
+ major: 'severityMedium',
+ critical: 'severityHigh',
+ blocker: 'severityCritical',
+ unknown: 'severityUnknown',
+};
diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js
index 53273aeff33..bad6fa1e7b9 100644
--- a/app/assets/javascripts/reports/constants.js
+++ b/app/assets/javascripts/reports/constants.js
@@ -18,6 +18,7 @@ export const ICON_WARNING = 'warning';
export const ICON_SUCCESS = 'success';
export const ICON_NOTFOUND = 'notfound';
export const ICON_PENDING = 'pending';
+export const ICON_FAILED = 'failed';
export const status = {
LOADING,
diff --git a/app/assets/javascripts/repository/components/blob_button_group.vue b/app/assets/javascripts/repository/components/blob_button_group.vue
index 857795c71b0..d79ccde61a8 100644
--- a/app/assets/javascripts/repository/components/blob_button_group.vue
+++ b/app/assets/javascripts/repository/components/blob_button_group.vue
@@ -7,6 +7,8 @@ import getRefMixin from '../mixins/get_ref';
import DeleteBlobModal from './delete_blob_modal.vue';
import UploadBlobModal from './upload_blob_modal.vue';
+const REPLACE_BLOB_MODAL_ID = 'modal-replace-blob';
+
export default {
i18n: {
replace: __('Replace'),
@@ -76,9 +78,6 @@ export default {
},
},
computed: {
- replaceModalId() {
- return uniqueId('replace-modal');
- },
replaceModalTitle() {
return sprintf(__('Replace %{name}'), { name: this.name });
},
@@ -95,13 +94,14 @@ export default {
methods: {
showModal(modalId) {
if (this.showForkSuggestion) {
- this.$emit('fork');
+ this.$emit('fork', 'view');
return;
}
this.$refs[modalId].show();
},
},
+ replaceBlobModalId: REPLACE_BLOB_MODAL_ID,
};
</script>
@@ -118,7 +118,7 @@ export default {
data-testid="lock"
:data-qa-selector="lockBtnQASelector"
/>
- <gl-button data-testid="replace" @click="showModal(replaceModalId)">
+ <gl-button data-testid="replace" @click="showModal($options.replaceBlobModalId)">
{{ $options.i18n.replace }}
</gl-button>
<gl-button data-testid="delete" @click="showModal(deleteModalId)">
@@ -126,8 +126,8 @@ export default {
</gl-button>
</gl-button-group>
<upload-blob-modal
- :ref="replaceModalId"
- :modal-id="replaceModalId"
+ :ref="$options.replaceBlobModalId"
+ :modal-id="$options.replaceBlobModalId"
:modal-title="replaceModalTitle"
:commit-message="replaceModalTitle"
:target-branch="targetBranch || ref"
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index 52963b49f68..85652301f4d 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -10,11 +10,14 @@ 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 WebIdeLink from '~/vue_shared/components/web_ide_link.vue';
+import CodeIntelligence from '~/code_navigation/components/app.vue';
import getRefMixin from '../mixins/get_ref';
import blobInfoQuery from '../queries/blob_info.query.graphql';
+import userInfoQuery from '../queries/user_info.query.graphql';
+import applicationInfoQuery from '../queries/application_info.query.graphql';
import { DEFAULT_BLOB_INFO, TEXT_FILE_TYPE, LFS_STORAGE } from '../constants';
import BlobButtonGroup from './blob_button_group.vue';
-import BlobEdit from './blob_edit.vue';
import ForkSuggestion from './fork_suggestion.vue';
import { loadViewer } from './blob_viewers';
@@ -24,12 +27,13 @@ export default {
},
components: {
BlobHeader,
- BlobEdit,
BlobButtonGroup,
BlobContent,
GlLoadingIcon,
GlButton,
ForkSuggestion,
+ WebIdeLink,
+ CodeIntelligence,
},
mixins: [getRefMixin, glFeatureFlagMixin()],
inject: {
@@ -38,6 +42,18 @@ export default {
},
},
apollo: {
+ gitpodEnabled: {
+ query: applicationInfoQuery,
+ error() {
+ this.displayError();
+ },
+ },
+ currentUser: {
+ query: userInfoQuery,
+ error() {
+ this.displayError();
+ },
+ },
project: {
query: blobInfoQuery,
variables() {
@@ -78,8 +94,11 @@ export default {
legacySimpleViewer: null,
isBinary: false,
isLoadingLegacyViewer: false,
+ isRenderingLegacyTextViewer: false,
activeViewerType: SIMPLE_BLOB_VIEWER,
- project: DEFAULT_BLOB_INFO,
+ project: DEFAULT_BLOB_INFO.project,
+ gitpodEnabled: DEFAULT_BLOB_INFO.gitpodEnabled,
+ currentUser: DEFAULT_BLOB_INFO.currentUser,
};
},
computed: {
@@ -142,9 +161,13 @@ export default {
return this.isLoggedIn && !canModifyBlob && createMergeRequestIn && forkProject;
},
forkPath() {
- return this.forkTarget === 'ide'
- ? this.blobInfo.ideForkAndEditPath
- : this.blobInfo.forkAndEditPath;
+ const forkPaths = {
+ ide: this.blobInfo.ideForkAndEditPath,
+ simple: this.blobInfo.forkAndEditPath,
+ view: this.blobInfo.forkAndViewPath,
+ };
+
+ return forkPaths[this.forkTarget];
},
isUsingLfs() {
return this.blobInfo.storedExternally && this.blobInfo.externalStorage === LFS_STORAGE;
@@ -163,7 +186,13 @@ export default {
.get(`${this.blobInfo.webPath}?format=json&viewer=${type}`)
.then(({ data: { html, binary } }) => {
if (type === SIMPLE_BLOB_VIEWER) {
+ this.isRenderingLegacyTextViewer = true;
+
this.legacySimpleViewer = html;
+
+ window.requestIdleCallback(() => {
+ this.isRenderingLegacyTextViewer = false;
+ });
} else {
this.legacyRichViewer = html;
}
@@ -213,26 +242,25 @@ export default {
@viewer-changed="switchViewer"
>
<template #actions>
- <blob-edit
+ <web-ide-link
v-if="!blobInfo.archived"
:show-edit-button="!isBinaryFileType"
- :edit-path="blobInfo.editBlobPath"
- :web-ide-path="blobInfo.ideEditPath"
+ class="gl-mr-3"
+ :edit-url="blobInfo.editBlobPath"
+ :web-ide-url="blobInfo.ideEditPath"
:needs-to-fork="showForkSuggestion"
+ :show-pipeline-editor-button="Boolean(blobInfo.pipelineEditorPath)"
+ :pipeline-editor-url="blobInfo.pipelineEditorPath"
+ :gitpod-url="blobInfo.gitpodBlobUrl"
+ :show-gitpod-button="gitpodEnabled"
+ :gitpod-enabled="currentUser && currentUser.gitpodEnabled"
+ :user-preferences-gitpod-path="currentUser && currentUser.preferencesGitpodPath"
+ :user-profile-enable-gitpod-path="currentUser && currentUser.profileEnableGitpodPath"
+ is-blob
+ disable-fork-modal
@edit="editBlob"
/>
- <gl-button
- v-if="blobInfo.pipelineEditorPath"
- class="gl-mr-3"
- category="secondary"
- variant="confirm"
- data-testid="pipeline-editor"
- :href="blobInfo.pipelineEditorPath"
- >
- {{ $options.i18n.pipelineEditor }}
- </gl-button>
-
<blob-button-group
v-if="isLoggedIn && !blobInfo.archived"
:path="path"
@@ -246,7 +274,7 @@ export default {
:is-locked="Boolean(pathLockedByUser)"
:can-lock="canLock"
:show-fork-suggestion="showForkSuggestion"
- @fork="setForkTarget('ide')"
+ @fork="setForkTarget('view')"
/>
</template>
</blob-header>
@@ -265,8 +293,15 @@ export default {
:active-viewer="viewer"
:hide-line-numbers="true"
:loading="isLoadingLegacyViewer"
+ :data-loading="isRenderingLegacyTextViewer"
/>
<component :is="blobViewer" v-else :blob="blobInfo" class="blob-viewer" />
+ <code-intelligence
+ v-if="blobViewer || legacyViewerLoaded"
+ :code-navigation-path="blobInfo.codeNavigationPath"
+ :blob-path="blobInfo.path"
+ :path-prefix="blobInfo.projectBlobPathRoot"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/repository/components/blob_edit.vue b/app/assets/javascripts/repository/components/blob_edit.vue
deleted file mode 100644
index 69e2bd563c9..00000000000
--- a/app/assets/javascripts/repository/components/blob_edit.vue
+++ /dev/null
@@ -1,78 +0,0 @@
-<script>
-import { GlButton } from '@gitlab/ui';
-import { __ } from '~/locale';
-import WebIdeLink from '~/vue_shared/components/web_ide_link.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-
-export default {
- i18n: {
- edit: __('Edit'),
- webIde: __('Web IDE'),
- },
- components: {
- GlButton,
- WebIdeLink,
- },
- mixins: [glFeatureFlagsMixin()],
- props: {
- showEditButton: {
- type: Boolean,
- required: true,
- },
- editPath: {
- type: String,
- required: true,
- },
- webIdePath: {
- type: String,
- required: true,
- },
- needsToFork: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- methods: {
- onEdit(target) {
- this.$emit('edit', target);
- },
- },
-};
-</script>
-
-<template>
- <web-ide-link
- v-if="glFeatures.consolidatedEditButton"
- :show-edit-button="showEditButton"
- class="gl-mr-3"
- :edit-url="editPath"
- :web-ide-url="webIdePath"
- :needs-to-fork="needsToFork"
- :is-blob="true"
- disable-fork-modal
- @edit="onEdit"
- />
- <div v-else>
- <gl-button
- v-if="showEditButton"
- class="gl-mr-2"
- category="primary"
- variant="confirm"
- data-testid="edit"
- @click="onEdit('simple')"
- >
- {{ $options.i18n.edit }}
- </gl-button>
-
- <gl-button
- class="gl-mr-3"
- category="primary"
- variant="confirm"
- data-testid="web-ide"
- @click="onEdit('ide')"
- >
- {{ $options.i18n.webIde }}
- </gl-button>
- </div>
-</template>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/audio_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/audio_viewer.vue
new file mode 100644
index 00000000000..048730c02c1
--- /dev/null
+++ b/app/assets/javascripts/repository/components/blob_viewers/audio_viewer.vue
@@ -0,0 +1,20 @@
+<script>
+export default {
+ props: {
+ blob: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ src: this.blob.rawPath,
+ };
+ },
+};
+</script>
+<template>
+ <div class="gl-text-center gl-p-7">
+ <audio :src="src" controls data-testid="audio"></audio>
+ </div>
+</template>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/csv_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/csv_viewer.vue
new file mode 100644
index 00000000000..86a0bb9fad0
--- /dev/null
+++ b/app/assets/javascripts/repository/components/blob_viewers/csv_viewer.vue
@@ -0,0 +1,26 @@
+<script>
+import CsvViewer from '~/blob/csv/csv_viewer.vue';
+
+export default {
+ components: {
+ CsvViewer,
+ },
+ props: {
+ blob: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ url: this.blob.rawPath,
+ };
+ },
+};
+</script>
+
+<template>
+ <div>
+ <csv-viewer :csv="url" remote-file data-testid="csv" />
+ </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 f7b318c64d9..be5e9685ccd 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue
@@ -17,7 +17,7 @@ export default {
data() {
return {
fileName: this.blob.name,
- filePath: this.blob.rawPath,
+ filePath: this.blob.externalStorageUrl || this.blob.rawPath,
fileSize: this.blob.rawSize || 0,
};
},
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 5027f7877aa..014f1abc121 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue
@@ -16,6 +16,6 @@ export default {
</script>
<template>
<div class="gl-text-center gl-p-7 gl-bg-gray-50">
- <img :src="url" :alt="alt" data-testid="image" />
+ <img :src="url" :alt="alt" data-testid="image" class="gl-max-w-full" />
</div>
</template>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js
index e942f59e7d8..cbe18ea396e 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/index.js
+++ b/app/assets/javascripts/repository/components/blob_viewers/index.js
@@ -1,4 +1,5 @@
const viewers = {
+ csv: () => import('./csv_viewer.vue'),
download: () => import('./download_viewer.vue'),
image: () => import('./image_viewer.vue'),
video: () => import('./video_viewer.vue'),
@@ -6,6 +7,7 @@ const viewers = {
text: () => import('~/vue_shared/components/source_viewer/source_viewer.vue'),
pdf: () => import('./pdf_viewer.vue'),
lfs: () => import('./lfs_viewer.vue'),
+ audio: () => import('./audio_viewer.vue'),
};
export const loadViewer = (type, isUsingLfs) => {
diff --git a/app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue
index 6dc7e10662e..9d39764e9a4 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_viewers/lfs_viewer.vue
@@ -21,7 +21,7 @@ export default {
data() {
return {
fileName: this.blob.name,
- filePath: this.blob.rawPath,
+ filePath: this.blob.externalStorageUrl || this.blob.rawPath,
};
},
};
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 c3df5984426..37c8f636757 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue
@@ -18,7 +18,7 @@ export default {
},
data() {
return {
- url: this.blob.rawPath,
+ url: this.blob.externalStorageUrl || this.blob.rawPath,
fileSize: this.blob.rawSize,
totalPages: 0,
};
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
index d3717f10ec7..08faf19d12a 100644
--- a/app/assets/javascripts/repository/components/breadcrumbs.vue
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -148,11 +148,16 @@ export default {
.reduce(
(acc, name, i) => {
const path = joinPaths(i > 0 ? acc[i].path : '', escapeFileUrl(name));
+ const isLastPath = i === this.currentPath.split('/').length - 1;
+ const to =
+ this.isBlobPath && isLastPath
+ ? `/-/blob/${joinPaths(this.escapedRef, path)}`
+ : `/-/tree/${joinPaths(this.escapedRef, path)}`;
return acc.concat({
name,
path,
- to: `/-/tree/${joinPaths(this.escapedRef, path)}`,
+ to,
});
},
[
@@ -274,9 +279,11 @@ export default {
return items;
},
+ isBlobPath() {
+ return this.$route.name === 'blobPath' || this.$route.name === 'blobPathDecoded';
+ },
renderAddToTreeDropdown() {
- const isBlobPath = this.$route.name === 'blobPath' || this.$route.name === 'blobPathDecoded';
- return !isBlobPath && (this.canCollaborate || this.canCreateMrFromFork);
+ return !this.isBlobPath && (this.canCollaborate || this.canCreateMrFromFork);
},
},
methods: {
diff --git a/app/assets/javascripts/repository/components/delete_blob_modal.vue b/app/assets/javascripts/repository/components/delete_blob_modal.vue
index f3c9aea36f1..baf8449b188 100644
--- a/app/assets/javascripts/repository/components/delete_blob_modal.vue
+++ b/app/assets/javascripts/repository/components/delete_blob_modal.vue
@@ -87,7 +87,7 @@ export default {
fields: {
// fields key must match case of form name for validation directive to work
commit_message: initFormField({ value: this.commitMessage }),
- branch_name: initFormField({ value: this.targetBranch }),
+ branch_name: initFormField({ value: this.targetBranch, skipValidation: !this.canPushCode }),
},
};
return {
diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js
index e206d9bfbd2..bb9d3180be8 100644
--- a/app/assets/javascripts/repository/constants.js
+++ b/app/assets/javascripts/repository/constants.js
@@ -27,6 +27,12 @@ export const PDF_MAX_PAGE_LIMIT = 50;
export const ROW_APPEAR_DELAY = 150;
export const DEFAULT_BLOB_INFO = {
+ gitpodEnabled: false,
+ currentUser: {
+ gitpodEnabled: false,
+ preferencesGitpodPath: null,
+ profileEnableGitpodPath: null,
+ },
userPermissions: {
pushCode: false,
downloadCode: false,
@@ -49,9 +55,13 @@ export const DEFAULT_BLOB_INFO = {
tooLarge: false,
path: '',
editBlobPath: '',
+ gitpodBlobUrl: '',
ideEditPath: '',
forkAndEditPath: '',
ideForkAndEditPath: '',
+ codeNavigationPath: '',
+ projectBlobPathRoot: '',
+ forkAndViewPath: '',
storedExternally: false,
externalStorage: '',
environmentFormattedExternalUrl: '',
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 120c32caefd..b38a1cfdc7b 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -1,10 +1,12 @@
import { GlButton } from '@gitlab/ui';
import Vue from 'vue';
+import Vuex from 'vuex';
import { parseBoolean } from '~/lib/utils/common_utils';
import { escapeFileUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
import PerformancePlugin from '~/performance/vue_performance_plugin';
+import createStore from '~/code_navigation/store';
import App from './components/app.vue';
import Breadcrumbs from './components/breadcrumbs.vue';
import DirectoryDownloadLinks from './components/directory_download_links.vue';
@@ -19,6 +21,7 @@ import createRouter from './router';
import { updateFormAction } from './utils/dom';
import { setTitle } from './utils/title';
+Vue.use(Vuex);
Vue.use(PerformancePlugin, {
components: ['SimpleViewer', 'BlobContent'],
});
@@ -200,6 +203,7 @@ export default function setupVueRepositoryList() {
// eslint-disable-next-line no-new
new Vue({
el,
+ store: createStore(),
router,
apolloProvider,
render(h) {
diff --git a/app/assets/javascripts/repository/queries/application_info.query.graphql b/app/assets/javascripts/repository/queries/application_info.query.graphql
new file mode 100644
index 00000000000..fd69de39f75
--- /dev/null
+++ b/app/assets/javascripts/repository/queries/application_info.query.graphql
@@ -0,0 +1,3 @@
+query getApplicationInfo {
+ gitpodEnabled
+}
diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql
index 78323fdc5f4..8baee80e5d6 100644
--- a/app/assets/javascripts/repository/queries/blob_info.query.graphql
+++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql
@@ -28,9 +28,13 @@ query getBlobInfo(
language
path
editBlobPath
+ gitpodBlobUrl
ideEditPath
forkAndEditPath
ideForkAndEditPath
+ codeNavigationPath
+ projectBlobPathRoot
+ forkAndViewPath
environmentFormattedExternalUrl
environmentExternalUrlForRouteMap
canModifyBlob
diff --git a/app/assets/javascripts/repository/queries/user_info.query.graphql b/app/assets/javascripts/repository/queries/user_info.query.graphql
new file mode 100644
index 00000000000..114947a423d
--- /dev/null
+++ b/app/assets/javascripts/repository/queries/user_info.query.graphql
@@ -0,0 +1,8 @@
+query getUserInfo {
+ currentUser {
+ id
+ gitpodEnabled
+ preferencesGitpodPath
+ profileEnableGitpodPath
+ }
+}
diff --git a/app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue b/app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue
index 4d2ca9b0c58..c2db3b9facd 100644
--- a/app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue
+++ b/app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue
@@ -5,7 +5,7 @@ import { convertToGraphQLId } from '~/graphql_shared/utils';
import RunnerHeader from '../components/runner_header.vue';
import RunnerUpdateForm from '../components/runner_update_form.vue';
import { I18N_FETCH_ERROR } from '../constants';
-import getRunnerQuery from '../graphql/get_runner.query.graphql';
+import runnerQuery from '../graphql/details/runner.query.graphql';
import { captureException } from '../sentry_utils';
export default {
@@ -27,7 +27,7 @@ export default {
},
apollo: {
runner: {
- query: getRunnerQuery,
+ query: runnerQuery,
variables() {
return {
id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId),
diff --git a/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue
index 2795ddbbbcb..86ad912f017 100644
--- a/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue
+++ b/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue
@@ -8,7 +8,7 @@ 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 runnerQuery from '../graphql/details/runner.query.graphql';
import { captureException } from '../sentry_utils';
export default {
@@ -35,7 +35,7 @@ export default {
},
apollo: {
runner: {
- query: getRunnerQuery,
+ query: runnerQuery,
variables() {
return {
id: convertToGraphQLId(TYPE_CI_RUNNER, this.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 a968d4029f8..8aba91eedf7 100644
--- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
@@ -12,6 +12,7 @@ import RunnerName from '../components/runner_name.vue';
import RunnerStats from '../components/stat/runner_stats.vue';
import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeTabs from '../components/runner_type_tabs.vue';
+import RunnerActionsCell from '../components/cells/runner_actions_cell.vue';
import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import { tagTokenConfig } from '../components/search_tokens/tag_token_config';
@@ -25,8 +26,8 @@ import {
STATUS_STALE,
I18N_FETCH_ERROR,
} from '../constants';
-import getRunnersQuery from '../graphql/get_runners.query.graphql';
-import getRunnersCountQuery from '../graphql/get_runners_count.query.graphql';
+import runnersAdminQuery from '../graphql/list/admin_runners.query.graphql';
+import runnersAdminCountQuery from '../graphql/list/admin_runners_count.query.graphql';
import {
fromUrlQueryToSearch,
fromSearchToUrl,
@@ -35,7 +36,7 @@ import {
import { captureException } from '../sentry_utils';
const runnersCountSmartQuery = {
- query: getRunnersCountQuery,
+ query: runnersAdminCountQuery,
fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
update(data) {
return data?.runners?.count;
@@ -57,6 +58,7 @@ export default {
RunnerStats,
RunnerPagination,
RunnerTypeTabs,
+ RunnerActionsCell,
},
props: {
registrationToken: {
@@ -75,7 +77,7 @@ export default {
},
apollo: {
runners: {
- query: getRunnersQuery,
+ query: runnersAdminQuery,
// Runners can be updated by users directly in this list.
// A "cache and network" policy prevents outdated filtered
// results.
@@ -187,6 +189,7 @@ export default {
deep: true,
handler() {
// TODO Implement back button response using onpopstate
+ // See: https://gitlab.com/gitlab-org/gitlab/-/issues/333804
updateHistory({
url: fromSearchToUrl(this.search),
title: document.title,
@@ -221,6 +224,10 @@ export default {
}
return '';
},
+ onDeleted({ message }) {
+ this.$root.$toast?.show(message);
+ this.$apollo.queries.runners.refetch();
+ },
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
@@ -278,6 +285,13 @@ export default {
<runner-name :runner="runner" />
</gl-link>
</template>
+ <template #runner-actions-cell="{ runner }">
+ <runner-actions-cell
+ :runner="runner"
+ :edit-url="runner.editAdminUrl"
+ @deleted="onDeleted"
+ />
+ </template>
</runner-list>
<runner-pagination
v-model="search.pagination"
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 ae9c774f2a2..c69321de001 100644
--- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
@@ -1,60 +1,30 @@
<script>
-import { GlButton, GlButtonGroup, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
-import { createAlert } from '~/flash';
-import { s__, sprintf } from '~/locale';
-import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql';
-import { captureException } from '~/runner/sentry_utils';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { GlButtonGroup } from '@gitlab/ui';
import RunnerEditButton from '../runner_edit_button.vue';
import RunnerPauseButton from '../runner_pause_button.vue';
-import RunnerDeleteModal from '../runner_delete_modal.vue';
-
-const I18N_DELETE = s__('Runners|Delete runner');
-const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted');
+import RunnerDeleteButton from '../runner_delete_button.vue';
export default {
name: 'RunnerActionsCell',
components: {
- GlButton,
GlButtonGroup,
RunnerEditButton,
RunnerPauseButton,
- RunnerDeleteModal,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- GlModal: GlModalDirective,
+ RunnerDeleteButton,
},
props: {
runner: {
type: Object,
required: true,
},
+ editUrl: {
+ type: String,
+ default: null,
+ required: false,
+ },
},
- data() {
- return {
- updating: false,
- deleting: false,
- };
- },
+ emits: ['deleted'],
computed: {
- deleteTitle() {
- if (this.deleting) {
- // Prevent a "sticky" tooltip: If this button is disabled,
- // mouseout listeners don't run leaving the tooltip stuck
- return '';
- }
- return I18N_DELETE;
- },
- runnerId() {
- return getIdFromGraphQLId(this.runner.id);
- },
- runnerName() {
- return `#${this.runnerId} (${this.runner.shortSha})`;
- },
- runnerDeleteModalId() {
- return `delete-runner-modal-${this.runnerId}`;
- },
canUpdate() {
return this.runner.userPermissions?.updateRunner;
},
@@ -63,79 +33,17 @@ export default {
},
},
methods: {
- async onDelete() {
- // Deleting stays "true" until this row is removed,
- // should only change back if the operation fails.
- this.deleting = true;
- try {
- const {
- data: {
- runnerDelete: { errors },
- },
- } = await this.$apollo.mutate({
- mutation: runnerDeleteMutation,
- variables: {
- input: {
- id: this.runner.id,
- },
- },
- awaitRefetchQueries: true,
- refetchQueries: ['getRunners', 'getGroupRunners'],
- });
- if (errors && errors.length) {
- throw new Error(errors.join(' '));
- } else {
- // Use $root to have the toast message stay after this element is removed
- this.$root.$toast?.show(sprintf(I18N_DELETED_TOAST, { name: this.runnerName }));
- }
- } catch (e) {
- this.deleting = false;
- this.onError(e);
- }
- },
-
- onError(error) {
- const { message } = error;
- createAlert({ message });
-
- this.reportToSentry(error);
- },
- reportToSentry(error) {
- captureException({ error, component: this.$options.name });
+ onDeleted(value) {
+ this.$emit('deleted', value);
},
},
- I18N_DELETE,
};
</script>
<template>
<gl-button-group>
- <!--
- This button appears for administrators: those with
- access to the adminUrl. More advanced permissions policies
- will allow more granular permissions.
-
- See https://gitlab.com/gitlab-org/gitlab/-/issues/334802
- -->
- <runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" />
+ <runner-edit-button v-if="canUpdate && editUrl" :href="editUrl" />
<runner-pause-button v-if="canUpdate" :runner="runner" :compact="true" />
- <gl-button
- v-if="canDelete"
- v-gl-tooltip.hover.viewport="deleteTitle"
- v-gl-modal="runnerDeleteModalId"
- :aria-label="deleteTitle"
- icon="close"
- :loading="deleting"
- variant="danger"
- data-testid="delete-runner"
- />
-
- <runner-delete-modal
- v-if="canDelete"
- :ref="runnerDeleteModalId"
- :modal-id="runnerDeleteModalId"
- :runner-name="runnerName"
- @primary="onDelete"
- />
+ <runner-delete-button v-if="canDelete" :runner="runner" :compact="true" @deleted="onDeleted" />
</gl-button-group>
</template>
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 54c35e483dc..1234054c660 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
@@ -4,7 +4,7 @@ import { createAlert } from '~/flash';
import { TYPE_GROUP, TYPE_PROJECT } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
-import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql';
+import runnersRegistrationTokenResetMutation from '~/runner/graphql/list/runners_registration_token_reset.mutation.graphql';
import { captureException } from '~/runner/sentry_utils';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants';
@@ -98,17 +98,14 @@ export default {
},
onError(error) {
const { message } = error;
- createAlert({ message });
- this.reportToSentry(error);
+ createAlert({ message });
+ captureException({ error, component: this.$options.name });
},
onSuccess(token) {
this.$toast?.show(s__('Runners|New registration token generated!'));
this.$emit('tokenReset', token);
},
- reportToSentry(error) {
- captureException({ error, component: this.$options.name });
- },
},
};
</script>
diff --git a/app/assets/javascripts/runner/components/runner_delete_button.vue b/app/assets/javascripts/runner/components/runner_delete_button.vue
new file mode 100644
index 00000000000..854c983f4da
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_delete_button.vue
@@ -0,0 +1,144 @@
+<script>
+import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
+import runnerDeleteMutation from '~/runner/graphql/shared/runner_delete.mutation.graphql';
+import { createAlert } from '~/flash';
+import { sprintf } from '~/locale';
+import { captureException } from '~/runner/sentry_utils';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { I18N_DELETE_RUNNER, I18N_DELETED_TOAST } from '../constants';
+import RunnerDeleteModal from './runner_delete_modal.vue';
+
+export default {
+ name: 'RunnerDeleteButton',
+ components: {
+ GlButton,
+ RunnerDeleteModal,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ GlModal: GlModalDirective,
+ },
+ props: {
+ runner: {
+ type: Object,
+ required: true,
+ validator: (runner) => {
+ return runner?.id && runner?.shortSha;
+ },
+ },
+ compact: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ emits: ['deleted'],
+ data() {
+ return {
+ deleting: false,
+ };
+ },
+ computed: {
+ runnerId() {
+ return getIdFromGraphQLId(this.runner.id);
+ },
+ runnerName() {
+ return `#${this.runnerId} (${this.runner.shortSha})`;
+ },
+ runnerDeleteModalId() {
+ return `delete-runner-modal-${this.runnerId}`;
+ },
+ icon() {
+ if (this.compact) {
+ return 'close';
+ }
+ return '';
+ },
+ buttonContent() {
+ if (this.compact) {
+ return null;
+ }
+ return I18N_DELETE_RUNNER;
+ },
+ buttonClass() {
+ // Ensure a square button is shown when compact: true.
+ // Without this class we will have distorted/rectangular button.
+ if (this.compact) {
+ return 'btn-icon';
+ }
+ return null;
+ },
+ ariaLabel() {
+ if (this.compact) {
+ return I18N_DELETE_RUNNER;
+ }
+ 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.deleting) {
+ return I18N_DELETE_RUNNER;
+ }
+ return '';
+ },
+ },
+ methods: {
+ async onDelete() {
+ // Deleting stays "true" until this row is removed,
+ // should only change back if the operation fails.
+ this.deleting = true;
+ try {
+ const {
+ data: {
+ runnerDelete: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: runnerDeleteMutation,
+ variables: {
+ input: {
+ id: this.runner.id,
+ },
+ },
+ });
+ if (errors && errors.length) {
+ throw new Error(errors.join(' '));
+ } else {
+ this.$emit('deleted', {
+ message: sprintf(I18N_DELETED_TOAST, { name: this.runnerName }),
+ });
+ }
+ } catch (e) {
+ this.deleting = false;
+ this.onError(e);
+ }
+ },
+ onError(error) {
+ const { message } = error;
+
+ createAlert({ message });
+ captureException({ error, component: this.$options.name });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button
+ v-gl-tooltip.hover.viewport="tooltip"
+ v-gl-modal="runnerDeleteModalId"
+ :aria-label="ariaLabel"
+ :icon="icon"
+ :class="buttonClass"
+ :loading="deleting"
+ variant="danger"
+ >
+ {{ buttonContent }}
+ <runner-delete-modal
+ :modal-id="runnerDeleteModalId"
+ :runner-name="runnerName"
+ @primary="onDelete"
+ />
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_edit_button.vue b/app/assets/javascripts/runner/components/runner_edit_button.vue
index b115be09e69..33e0acaf5c0 100644
--- a/app/assets/javascripts/runner/components/runner_edit_button.vue
+++ b/app/assets/javascripts/runner/components/runner_edit_button.vue
@@ -1,8 +1,6 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
-
-const I18N_EDIT = __('Edit');
+import { I18N_EDIT } from '../constants';
export default {
components: {
diff --git a/app/assets/javascripts/runner/components/runner_jobs.vue b/app/assets/javascripts/runner/components/runner_jobs.vue
index c13e7e90168..eb77babcc57 100644
--- a/app/assets/javascripts/runner/components/runner_jobs.vue
+++ b/app/assets/javascripts/runner/components/runner_jobs.vue
@@ -1,7 +1,7 @@
<script>
import { GlSkeletonLoading } from '@gitlab/ui';
import { createAlert } from '~/flash';
-import getRunnerJobsQuery from '../graphql/get_runner_jobs.query.graphql';
+import runnerJobsQuery from '../graphql/details/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';
@@ -34,7 +34,7 @@ export default {
},
apollo: {
jobs: {
- query: getRunnerJobsQuery,
+ query: runnerJobsQuery,
variables() {
return this.variables;
},
@@ -46,7 +46,7 @@ export default {
},
error(error) {
createAlert({ message: I18N_FETCH_ERROR });
- this.reportToSentry(error);
+ captureException({ error, component: this.$options.name });
},
},
},
@@ -62,11 +62,6 @@ export default {
return this.$apollo.queries.jobs.loading;
},
},
- methods: {
- reportToSentry(error) {
- captureException({ error, component: this.$options.name });
- },
- },
I18N_NO_JOBS_FOUND,
};
</script>
diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue
index bb36882d3ae..51749b0255f 100644
--- a/app/assets/javascripts/runner/components/runner_list.vue
+++ b/app/assets/javascripts/runner/components/runner_list.vue
@@ -1,22 +1,20 @@
<script>
-import { GlTable, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui';
+import { GlTableLite, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { 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';
export default {
components: {
- GlTable,
+ GlTableLite,
GlSkeletonLoader,
TooltipOnTruncate,
TimeAgo,
- RunnerActionsCell,
RunnerSummaryCell,
RunnerTags,
RunnerStatusCell,
@@ -35,6 +33,16 @@ export default {
required: true,
},
},
+ computed: {
+ tableClass() {
+ // <gl-table-lite> does not provide a busy state, add
+ // simple support for it.
+ // See http://bootstrap-vue.org/docs/components/table#table-busy-state
+ return {
+ 'gl-opacity-6': this.loading,
+ };
+ },
+ },
methods: {
formatJobCount(jobCount) {
return formatJobCount(jobCount);
@@ -62,8 +70,9 @@ export default {
</script>
<template>
<div>
- <gl-table
- :busy="loading"
+ <gl-table-lite
+ :aria-busy="loading"
+ :class="tableClass"
:items="runners"
:fields="$options.fields"
:tbody-tr-attr="runnerTrAttr"
@@ -72,10 +81,6 @@ export default {
primary-key="id"
fixed
>
- <template v-if="!runners.length" #table-busy>
- <gl-skeleton-loader v-for="i in 4" :key="i" />
- </template>
-
<template #cell(status)="{ item }">
<runner-status-cell :runner="item" />
</template>
@@ -114,8 +119,12 @@ export default {
</template>
<template #cell(actions)="{ item }">
- <runner-actions-cell :runner="item" />
+ <slot name="runner-actions-cell" :runner="item"></slot>
</template>
- </gl-table>
+ </gl-table-lite>
+
+ <template v-if="!runners.length && loading">
+ <gl-skeleton-loader v-for="i in 4" :key="i" />
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_pause_button.vue b/app/assets/javascripts/runner/components/runner_pause_button.vue
index a8b259f5b90..c88634bfbd9 100644
--- a/app/assets/javascripts/runner/components/runner_pause_button.vue
+++ b/app/assets/javascripts/runner/components/runner_pause_button.vue
@@ -1,9 +1,9 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import runnerToggleActiveMutation from '~/runner/graphql/runner_toggle_active.mutation.graphql';
+import runnerToggleActiveMutation from '~/runner/graphql/shared/runner_toggle_active.mutation.graphql';
import { createAlert } from '~/flash';
import { captureException } from '~/runner/sentry_utils';
-import { I18N_PAUSE, I18N_RESUME } from '../constants';
+import { I18N_PAUSE, I18N_PAUSE_TOOLTIP, I18N_RESUME, I18N_RESUME_TOOLTIP } from '../constants';
export default {
name: 'RunnerPauseButton',
@@ -52,11 +52,10 @@ export default {
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;
+ // Prevent a "sticky" tooltip: If this button is disabled,
+ // mouseout listeners don't run leaving the tooltip stuck
+ if (!this.updating) {
+ return this.isActive ? I18N_PAUSE_TOOLTIP : I18N_RESUME_TOOLTIP;
}
return '';
},
@@ -92,11 +91,8 @@ export default {
},
onError(error) {
const { message } = error;
- createAlert({ message });
- this.reportToSentry(error);
- },
- reportToSentry(error) {
+ createAlert({ message });
captureException({ error, component: this.$options.name });
},
},
@@ -105,7 +101,7 @@ export default {
<template>
<gl-button
- v-gl-tooltip.hover.viewport="tooltip"
+ v-gl-tooltip="tooltip"
v-bind="$attrs"
:aria-label="ariaLabel"
:icon="icon"
diff --git a/app/assets/javascripts/runner/components/runner_paused_badge.vue b/app/assets/javascripts/runner/components/runner_paused_badge.vue
index d1e6fa05e4d..27618290ce6 100644
--- a/app/assets/javascripts/runner/components/runner_paused_badge.vue
+++ b/app/assets/javascripts/runner/components/runner_paused_badge.vue
@@ -1,6 +1,6 @@
<script>
import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
-import { I18N_PAUSED_RUNNER_DESCRIPTION } from '../constants';
+import { I18N_PAUSED_DESCRIPTION } from '../constants';
export default {
components: {
@@ -9,17 +9,11 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- i18n: {
- I18N_PAUSED_RUNNER_DESCRIPTION,
- },
+ I18N_PAUSED_DESCRIPTION,
};
</script>
<template>
- <gl-badge
- v-gl-tooltip="$options.i18n.I18N_PAUSED_RUNNER_DESCRIPTION"
- variant="danger"
- v-bind="$attrs"
- >
+ <gl-badge v-gl-tooltip="$options.I18N_PAUSED_DESCRIPTION" variant="danger" v-bind="$attrs">
{{ s__('Runners|paused') }}
</gl-badge>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_projects.vue b/app/assets/javascripts/runner/components/runner_projects.vue
index c4065a24ff2..f8ec29b8a24 100644
--- a/app/assets/javascripts/runner/components/runner_projects.vue
+++ b/app/assets/javascripts/runner/components/runner_projects.vue
@@ -2,7 +2,7 @@
import { GlSkeletonLoading } from '@gitlab/ui';
import { sprintf, formatNumber } from '~/locale';
import { createAlert } from '~/flash';
-import getRunnerProjectsQuery from '../graphql/get_runner_projects.query.graphql';
+import runnerProjectsQuery from '../graphql/details/runner_projects.query.graphql';
import {
I18N_ASSIGNED_PROJECTS,
I18N_NONE,
@@ -41,7 +41,7 @@ export default {
},
apollo: {
projects: {
- query: getRunnerProjectsQuery,
+ query: runnerProjectsQuery,
variables() {
return this.variables;
},
@@ -55,8 +55,7 @@ export default {
},
error(error) {
createAlert({ message: I18N_FETCH_ERROR });
-
- this.reportToSentry(error);
+ captureException({ error, component: this.$options.name });
},
},
},
@@ -77,11 +76,6 @@ export default {
});
},
},
- methods: {
- reportToSentry(error) {
- captureException({ error, component: this.$options.name });
- },
- },
I18N_NONE,
};
</script>
diff --git a/app/assets/javascripts/runner/components/runner_update_form.vue b/app/assets/javascripts/runner/components/runner_update_form.vue
index e3deb94236e..e44450a2a8d 100644
--- a/app/assets/javascripts/runner/components/runner_update_form.vue
+++ b/app/assets/javascripts/runner/components/runner_update_form.vue
@@ -15,7 +15,7 @@ import { createAlert, VARIANT_SUCCESS } from '~/flash';
import { __ } from '~/locale';
import { captureException } from '~/runner/sentry_utils';
import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants';
-import runnerUpdateMutation from '../graphql/runner_update.mutation.graphql';
+import runnerUpdateMutation from '../graphql/details/runner_update.mutation.graphql';
export default {
name: 'RunnerUpdateForm',
@@ -82,9 +82,9 @@ export default {
this.onSuccess();
} catch (error) {
const { message } = error;
- createAlert({ message });
- this.reportToSentry(error);
+ createAlert({ message });
+ captureException({ error, component: this.$options.name });
} finally {
this.saving = false;
}
@@ -93,9 +93,6 @@ export default {
createAlert({ message: __('Changes saved.'), variant: VARIANT_SUCCESS });
this.model = runnerToModel(this.runner);
},
- reportToSentry(error) {
- captureException({ error, component: this.$options.name });
- },
},
ACCESS_LEVEL_NOT_PROTECTED,
ACCESS_LEVEL_REF_PROTECTED,
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js
index 1544efaaae2..bd5be2175ad 100644
--- a/app/assets/javascripts/runner/constants.js
+++ b/app/assets/javascripts/runner/constants.js
@@ -35,12 +35,20 @@ export const I18N_STALE_RUNNER_DESCRIPTION = s__(
'Runners|No contact from this runner in over 3 months',
);
-// Active flag
+// Actions
+export const I18N_EDIT = __('Edit');
+
export const I18N_PAUSE = __('Pause');
+export const I18N_PAUSE_TOOLTIP = s__('Runners|Pause from accepting jobs');
+export const I18N_PAUSED_DESCRIPTION = s__('Runners|Not accepting jobs');
+
export const I18N_RESUME = __('Resume');
+export const I18N_RESUME_TOOLTIP = s__('Runners|Resume accepting jobs');
+
+export const I18N_DELETE_RUNNER = s__('Runners|Delete runner');
+export const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted');
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
@@ -91,8 +99,8 @@ export const ACCESS_LEVEL_REF_PROTECTED = 'REF_PROTECTED';
// CiRunnerSort
export const CREATED_DESC = 'CREATED_DESC';
-export const CREATED_ASC = 'CREATED_ASC'; // TODO Add this to the API
-export const CONTACTED_DESC = 'CONTACTED_DESC'; // TODO Add this to the API
+export const CREATED_ASC = 'CREATED_ASC';
+export const CONTACTED_DESC = 'CONTACTED_DESC';
export const CONTACTED_ASC = 'CONTACTED_ASC';
export const DEFAULT_SORT = CREATED_DESC;
diff --git a/app/assets/javascripts/runner/graphql/get_runner.query.graphql b/app/assets/javascripts/runner/graphql/details/runner.query.graphql
index f6ce8281c64..4792a186160 100644
--- a/app/assets/javascripts/runner/graphql/get_runner.query.graphql
+++ b/app/assets/javascripts/runner/graphql/details/runner.query.graphql
@@ -1,10 +1,9 @@
-#import "ee_else_ce/runner/graphql/runner_details.fragment.graphql"
+#import "ee_else_ce/runner/graphql/details/runner_details.fragment.graphql"
query getRunner($id: CiRunnerID!) {
# We have an id in deeply nested fragment
# eslint-disable-next-line @graphql-eslint/require-id-when-available
runner(id: $id) {
- __typename
...RunnerDetails
}
}
diff --git a/app/assets/javascripts/runner/graphql/runner_details.fragment.graphql b/app/assets/javascripts/runner/graphql/details/runner_details.fragment.graphql
index 2449ee0fc0f..2449ee0fc0f 100644
--- a/app/assets/javascripts/runner/graphql/runner_details.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/details/runner_details.fragment.graphql
diff --git a/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/details/runner_details_shared.fragment.graphql
index 74760bbaa07..d8c67728fac 100644
--- a/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/details/runner_details_shared.fragment.graphql
@@ -1,4 +1,5 @@
fragment RunnerDetailsShared on CiRunner {
+ __typename
id
runnerType
active
@@ -22,7 +23,7 @@ fragment RunnerDetailsShared on CiRunner {
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`.
+ # parameters `runner_projects.query.graphql`.
nodes {
id
avatarUrl
diff --git a/app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql b/app/assets/javascripts/runner/graphql/details/runner_jobs.query.graphql
index 2b1decd3ddd..2b1decd3ddd 100644
--- a/app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql
+++ b/app/assets/javascripts/runner/graphql/details/runner_jobs.query.graphql
diff --git a/app/assets/javascripts/runner/graphql/get_runner_projects.query.graphql b/app/assets/javascripts/runner/graphql/details/runner_projects.query.graphql
index f97237b8267..f97237b8267 100644
--- a/app/assets/javascripts/runner/graphql/get_runner_projects.query.graphql
+++ b/app/assets/javascripts/runner/graphql/details/runner_projects.query.graphql
diff --git a/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql b/app/assets/javascripts/runner/graphql/details/runner_update.mutation.graphql
index 8d1b75828be..e4bf51e2c30 100644
--- a/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql
+++ b/app/assets/javascripts/runner/graphql/details/runner_update.mutation.graphql
@@ -1,4 +1,4 @@
-#import "ee_else_ce/runner/graphql/runner_details.fragment.graphql"
+#import "ee_else_ce/runner/graphql/details/runner_details.fragment.graphql"
# Mutation for updates from the runner form, loads
# attributes shown in the runner details.
diff --git a/app/assets/javascripts/runner/graphql/get_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql
index ed03a8c34ae..8df4c2fc65c 100644
--- a/app/assets/javascripts/runner/graphql/get_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql
@@ -1,4 +1,4 @@
-#import "~/runner/graphql/runner_node.fragment.graphql"
+#import "~/runner/graphql/list/list_item.fragment.graphql"
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getRunners(
@@ -24,7 +24,7 @@ query getRunners(
sort: $sort
) {
nodes {
- ...RunnerNode
+ ...ListItem
adminUrl
editAdminUrl
}
diff --git a/app/assets/javascripts/runner/graphql/get_runners_count.query.graphql b/app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql
index 181a4495cae..181a4495cae 100644
--- a/app/assets/javascripts/runner/graphql/get_runners_count.query.graphql
+++ b/app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql
diff --git a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql
index 986dd16b992..b517f5e89a8 100644
--- a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql
@@ -1,4 +1,4 @@
-#import "~/runner/graphql/runner_node.fragment.graphql"
+#import "~/runner/graphql/list/list_item.fragment.graphql"
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getGroupRunners(
@@ -27,9 +27,9 @@ query getGroupRunners(
) {
edges {
webUrl
+ editUrl
node {
- __typename
- ...RunnerNode
+ ...ListItem
}
}
pageInfo {
diff --git a/app/assets/javascripts/runner/graphql/get_group_runners_count.query.graphql b/app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql
index 554eb09e372..554eb09e372 100644
--- a/app/assets/javascripts/runner/graphql/get_group_runners_count.query.graphql
+++ b/app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql
diff --git a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql b/app/assets/javascripts/runner/graphql/list/list_item.fragment.graphql
index fbdef817f2f..620c18c5bc0 100644
--- a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/list/list_item.fragment.graphql
@@ -1,4 +1,4 @@
-fragment RunnerNode on CiRunner {
+fragment ListItem on CiRunner {
__typename
id
description
diff --git a/app/assets/javascripts/runner/graphql/runners_registration_token_reset.mutation.graphql b/app/assets/javascripts/runner/graphql/list/runners_registration_token_reset.mutation.graphql
index 9c2797732ad..9c2797732ad 100644
--- a/app/assets/javascripts/runner/graphql/runners_registration_token_reset.mutation.graphql
+++ b/app/assets/javascripts/runner/graphql/list/runners_registration_token_reset.mutation.graphql
diff --git a/app/assets/javascripts/runner/graphql/runner_delete.mutation.graphql b/app/assets/javascripts/runner/graphql/shared/runner_delete.mutation.graphql
index d580ea2785e..d580ea2785e 100644
--- a/app/assets/javascripts/runner/graphql/runner_delete.mutation.graphql
+++ b/app/assets/javascripts/runner/graphql/shared/runner_delete.mutation.graphql
diff --git a/app/assets/javascripts/runner/graphql/runner_toggle_active.mutation.graphql b/app/assets/javascripts/runner/graphql/shared/runner_toggle_active.mutation.graphql
index 9b15570dbc0..9b15570dbc0 100644
--- a/app/assets/javascripts/runner/graphql/runner_toggle_active.mutation.graphql
+++ b/app/assets/javascripts/runner/graphql/shared/runner_toggle_active.mutation.graphql
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 c4ee0ad4dfb..35fd7fff6d3 100644
--- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
@@ -12,19 +12,20 @@ import RunnerName from '../components/runner_name.vue';
import RunnerStats from '../components/stat/runner_stats.vue';
import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeTabs from '../components/runner_type_tabs.vue';
+import RunnerActionsCell from '../components/cells/runner_actions_cell.vue';
import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import {
- I18N_FETCH_ERROR,
GROUP_FILTERED_SEARCH_NAMESPACE,
GROUP_TYPE,
PROJECT_TYPE,
STATUS_ONLINE,
STATUS_OFFLINE,
STATUS_STALE,
+ I18N_FETCH_ERROR,
} from '../constants';
-import getGroupRunnersQuery from '../graphql/get_group_runners.query.graphql';
-import getGroupRunnersCountQuery from '../graphql/get_group_runners_count.query.graphql';
+import groupRunnersQuery from '../graphql/list/group_runners.query.graphql';
+import groupRunnersCountQuery from '../graphql/list/group_runners_count.query.graphql';
import {
fromUrlQueryToSearch,
fromSearchToUrl,
@@ -33,7 +34,7 @@ import {
import { captureException } from '../sentry_utils';
const runnersCountSmartQuery = {
- query: getGroupRunnersCountQuery,
+ query: groupRunnersCountQuery,
fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
update(data) {
return data?.group?.runners?.count;
@@ -55,6 +56,7 @@ export default {
RunnerStats,
RunnerPagination,
RunnerTypeTabs,
+ RunnerActionsCell,
},
props: {
registrationToken: {
@@ -74,15 +76,15 @@ export default {
return {
search: fromUrlQueryToSearch(),
runners: {
- webUrls: [],
items: [],
+ urlsById: {},
pageInfo: {},
},
};
},
apollo: {
runners: {
- query: getGroupRunnersQuery,
+ query: groupRunnersQuery,
// Runners can be updated by users directly in this list.
// A "cache and network" policy prevents outdated filtered
// results.
@@ -91,12 +93,23 @@ export default {
return this.variables;
},
update(data) {
- const { runners } = data?.group || {};
+ const { edges = [], pageInfo = {} } = data?.group?.runners || {};
+
+ const items = [];
+ const urlsById = {};
+
+ edges.forEach(({ node, webUrl, editUrl }) => {
+ items.push(node);
+ urlsById[node.id] = {
+ web: webUrl,
+ edit: editUrl,
+ };
+ });
return {
- webUrls: runners?.edges.map(({ webUrl }) => webUrl) || [],
- items: runners?.edges.map(({ node }) => node) || [],
- pageInfo: runners?.pageInfo || {},
+ items,
+ urlsById,
+ pageInfo,
};
},
error(error) {
@@ -190,6 +203,7 @@ export default {
deep: true,
handler() {
// TODO Implement back button reponse using onpopstate
+ // See https://gitlab.com/gitlab-org/gitlab/-/issues/333804
updateHistory({
url: fromSearchToUrl(this.search),
title: document.title,
@@ -221,6 +235,16 @@ export default {
}
return null;
},
+ webUrl(runner) {
+ return this.runners.urlsById[runner.id]?.web;
+ },
+ editUrl(runner) {
+ return this.runners.urlsById[runner.id]?.edit;
+ },
+ onDeleted({ message }) {
+ this.$root.$toast?.show(message);
+ this.$apollo.queries.runners.refetch();
+ },
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
@@ -272,13 +296,20 @@ export default {
</div>
<template v-else>
<runner-list :runners="runners.items" :loading="runnersLoading">
- <template #runner-name="{ runner, index }">
- <gl-link :href="runners.webUrls[index]">
+ <template #runner-name="{ runner }">
+ <gl-link :href="webUrl(runner)">
<runner-name :runner="runner" />
</gl-link>
</template>
+ <template #runner-actions-cell="{ runner }">
+ <runner-actions-cell :runner="runner" :edit-url="editUrl(runner)" @deleted="onDeleted" />
+ </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/search/topbar/components/app.vue b/app/assets/javascripts/search/topbar/components/app.vue
index 65114ee066e..f27dae8249d 100644
--- a/app/assets/javascripts/search/topbar/components/app.vue
+++ b/app/assets/javascripts/search/topbar/components/app.vue
@@ -1,17 +1,20 @@
<script>
-import { GlForm, GlSearchBoxByType, GlButton } from '@gitlab/ui';
+import { GlSearchBoxByClick } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
+import { s__ } from '~/locale';
import GroupFilter from './group_filter.vue';
import ProjectFilter from './project_filter.vue';
export default {
name: 'GlobalSearchTopbar',
+ i18n: {
+ searchPlaceholder: s__(`GlobalSearch|Search for projects, issues, etc.`),
+ searchLabel: s__(`GlobalSearch|What are you searching for?`),
+ },
components: {
- GlForm,
- GlSearchBoxByType,
+ GlSearchBoxByClick,
GroupFilter,
ProjectFilter,
- GlButton,
},
props: {
groupInitialData: {
@@ -49,28 +52,24 @@ export default {
</script>
<template>
- <gl-form class="search-page-form" @submit.prevent="applyQuery">
- <section class="gl-lg-display-flex gl-align-items-flex-end">
- <div class="gl-flex-grow-1 gl-mb-4 gl-lg-mb-0 gl-lg-mr-2">
- <label>{{ __('What are you searching for?') }}</label>
- <gl-search-box-by-type
- id="dashboard_search"
- v-model="search"
- name="search"
- :placeholder="__(`Search for projects, issues, etc.`)"
- />
- </div>
- <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2">
- <label class="gl-display-block">{{ __('Group') }}</label>
- <group-filter :initial-data="groupInitialData" />
- </div>
- <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2">
- <label class="gl-display-block">{{ __('Project') }}</label>
- <project-filter :initial-data="projectInitialData" />
- </div>
- <gl-button class="btn-search gl-lg-ml-2" category="primary" variant="confirm" type="submit"
- >{{ __('Search') }}
- </gl-button>
- </section>
- </gl-form>
+ <section class="search-page-form gl-lg-display-flex gl-align-items-flex-end">
+ <div class="gl-flex-grow-1 gl-mb-4 gl-lg-mb-0 gl-lg-mr-2">
+ <label>{{ $options.i18n.searchLabel }}</label>
+ <gl-search-box-by-click
+ id="dashboard_search"
+ v-model="search"
+ name="search"
+ :placeholder="$options.i18n.searchPlaceholder"
+ @submit="applyQuery"
+ />
+ </div>
+ <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2">
+ <label class="gl-display-block">{{ __('Group') }}</label>
+ <group-filter :initial-data="groupInitialData" />
+ </div>
+ <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2">
+ <label class="gl-display-block">{{ __('Project') }}</label>
+ <project-filter :initial-data="projectInitialData" />
+ </div>
+ </section>
</template>
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index 81d222438e3..39a2939f52a 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -16,6 +16,8 @@ import {
REPORT_TYPE_LICENSE_COMPLIANCE,
} from '~/vue_shared/security_reports/constants';
+import kontraLogo from 'images/vulnerability/kontra-logo.svg';
+import scwLogo from 'images/vulnerability/scw-logo.svg';
import configureSastMutation from '../graphql/configure_sast.mutation.graphql';
import configureSastIacMutation from '../graphql/configure_iac.mutation.graphql';
import configureSecretDetectionMutation from '../graphql/configure_secret_detection.mutation.graphql';
@@ -222,14 +224,12 @@ export const securityFeatures = [
helpPath: COVERAGE_FUZZING_HELP_PATH,
configurationHelpPath: COVERAGE_FUZZING_CONFIG_HELP_PATH,
type: REPORT_TYPE_COVERAGE_FUZZING,
- secondary: gon?.features?.corpusManagementUi
- ? {
- type: REPORT_TYPE_CORPUS_MANAGEMENT,
- name: CORPUS_MANAGEMENT_NAME,
- description: CORPUS_MANAGEMENT_DESCRIPTION,
- configurationText: CORPUS_MANAGEMENT_CONFIG_TEXT,
- }
- : {},
+ secondary: {
+ type: REPORT_TYPE_CORPUS_MANAGEMENT,
+ name: CORPUS_MANAGEMENT_NAME,
+ description: CORPUS_MANAGEMENT_DESCRIPTION,
+ configurationText: CORPUS_MANAGEMENT_CONFIG_TEXT,
+ },
},
];
@@ -281,3 +281,21 @@ export const featureToMutationMap = {
export const AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY =
'security_configuration_auto_devops_enabled_dismissed_projects';
+
+// Fetch the svg path from the GraphQL query once this issue is resolved
+// https://gitlab.com/gitlab-org/gitlab/-/issues/346899
+export const TEMP_PROVIDER_LOGOS = {
+ Kontra: {
+ svg: kontraLogo,
+ },
+ [__('Secure Code Warrior')]: {
+ svg: scwLogo,
+ },
+};
+
+// Use the `url` field from the GraphQL query once this issue is resolved
+// https://gitlab.com/gitlab-org/gitlab/-/issues/356129
+export const TEMP_PROVIDER_URLS = {
+ Kontra: 'https://application.security/',
+ [__('Secure Code Warrior')]: 'https://www.securecodewarrior.com/',
+};
diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue
index 1c37d8008de..cd5ad86e1a8 100644
--- a/app/assets/javascripts/security_configuration/components/feature_card.vue
+++ b/app/assets/javascripts/security_configuration/components/feature_card.vue
@@ -31,13 +31,12 @@ export default {
const button = this.enabled
? {
text: this.$options.i18n.configureFeature,
- category: 'secondary',
}
: {
text: this.$options.i18n.enableFeature,
- category: 'primary',
};
+ button.category = 'secondary';
button.text = sprintf(button.text, { feature: this.shortName });
return button;
@@ -126,7 +125,7 @@ export default {
v-else-if="showManageViaMr"
:feature="feature"
variant="confirm"
- category="primary"
+ category="secondary"
class="gl-mt-5"
:data-qa-selector="`${feature.type}_mr_button`"
@error="onError"
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 539e2bff17c..bb540303cfd 100644
--- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue
+++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue
@@ -1,15 +1,31 @@
<script>
-import { GlAlert, GlCard, GlToggle, GlLink, GlSkeletonLoader } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlTooltipDirective,
+ GlCard,
+ GlToggle,
+ GlLink,
+ GlSkeletonLoader,
+ GlIcon,
+ GlSafeHtmlDirective,
+} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import Tracking from '~/tracking';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import {
TRACK_TOGGLE_TRAINING_PROVIDER_ACTION,
TRACK_TOGGLE_TRAINING_PROVIDER_LABEL,
+ TRACK_PROVIDER_LEARN_MORE_CLICK_ACTION,
+ TRACK_PROVIDER_LEARN_MORE_CLICK_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';
+import securityTrainingProvidersQuery from '~/security_configuration/graphql/security_training_providers.query.graphql';
+import configureSecurityTrainingProvidersMutation from '~/security_configuration/graphql/configure_security_training_providers.mutation.graphql';
+import {
+ updateSecurityTrainingCache,
+ updateSecurityTrainingOptimisticResponse,
+} from '~/security_configuration/graphql/cache_utils';
+import { TEMP_PROVIDER_LOGOS, TEMP_PROVIDER_URLS } from './constants';
const i18n = {
providerQueryErrorMessage: __(
@@ -18,6 +34,10 @@ const i18n = {
configMutationErrorMessage: __(
'Could not save configuration. Please refresh the page, or try again later.',
),
+ primaryTraining: s__('SecurityTraining|Primary Training'),
+ primaryTrainingDescription: s__(
+ 'SecurityTraining|Training from this partner takes precedence when more than one training partner is enabled.',
+ ),
};
export default {
@@ -27,6 +47,11 @@ export default {
GlToggle,
GlLink,
GlSkeletonLoader,
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ SafeHtml: GlSafeHtmlDirective,
},
mixins: [Tracking.mixin()],
inject: ['projectFullPath'],
@@ -49,12 +74,14 @@ export default {
data() {
return {
errorMessage: '',
- providerLoadingId: null,
securityTrainingProviders: [],
hasTouchedConfiguration: false,
};
},
computed: {
+ enabledProviders() {
+ return this.securityTrainingProviders.filter(({ isEnabled }) => isEnabled);
+ },
isLoading() {
return this.$apollo.queries.securityTrainingProviders.loading;
},
@@ -89,15 +116,41 @@ export default {
Sentry.captureException(e);
}
},
- toggleProvider(provider) {
- const { isEnabled } = provider;
+ async toggleProvider(provider) {
+ const { isEnabled, isPrimary } = provider;
const toggledIsEnabled = !isEnabled;
this.trackProviderToggle(provider.id, toggledIsEnabled);
- this.storeProvider({ ...provider, isEnabled: toggledIsEnabled });
+
+ // when the current primary provider gets disabled then set the first enabled to be the new primary
+ if (!toggledIsEnabled && isPrimary && this.enabledProviders.length > 1) {
+ const firstOtherEnabledProvider = this.enabledProviders.find(
+ ({ id }) => id !== provider.id,
+ );
+ this.setPrimaryProvider(firstOtherEnabledProvider);
+ }
+
+ this.storeProvider({
+ ...provider,
+ isEnabled: toggledIsEnabled,
+ });
},
- async storeProvider({ id, isEnabled, isPrimary }) {
- this.providerLoadingId = id;
+ setPrimaryProvider(provider) {
+ this.storeProvider({ ...provider, isPrimary: true });
+ },
+ async storeProvider(provider) {
+ const { id, isEnabled, isPrimary } = provider;
+ let nextIsPrimary = isPrimary;
+
+ // if the current provider has been disabled it can't be primary
+ if (!isEnabled) {
+ nextIsPrimary = false;
+ }
+
+ // if the current provider is the only enabled provider it should be primary
+ if (isEnabled && !this.enabledProviders.length) {
+ nextIsPrimary = true;
+ }
try {
const {
@@ -111,9 +164,18 @@ export default {
projectPath: this.projectFullPath,
providerId: id,
isEnabled,
- isPrimary,
+ isPrimary: nextIsPrimary,
},
},
+ optimisticResponse: updateSecurityTrainingOptimisticResponse({
+ id,
+ isEnabled,
+ isPrimary: nextIsPrimary,
+ }),
+ update: updateSecurityTrainingCache({
+ query: securityTrainingProvidersQuery,
+ variables: { fullPath: this.projectFullPath },
+ }),
});
if (errors.length > 0) {
@@ -124,8 +186,6 @@ export default {
this.hasTouchedConfiguration = true;
} catch {
this.errorMessage = this.$options.i18n.configMutationErrorMessage;
- } finally {
- this.providerLoadingId = null;
}
},
trackProviderToggle(providerId, providerIsEnabled) {
@@ -137,8 +197,16 @@ export default {
},
});
},
+ trackProviderLearnMoreClick(providerId) {
+ this.track(TRACK_PROVIDER_LEARN_MORE_CLICK_ACTION, {
+ label: TRACK_PROVIDER_LEARN_MORE_CLICK_LABEL,
+ property: providerId,
+ });
+ },
},
i18n,
+ TEMP_PROVIDER_LOGOS,
+ TEMP_PROVIDER_URLS,
};
</script>
@@ -165,15 +233,54 @@ export default {
:value="provider.isEnabled"
:label="__('Training mode')"
label-position="hidden"
- :is-loading="providerLoadingId === provider.id"
@change="toggleProvider(provider)"
/>
- <div class="gl-ml-5">
+ <div v-if="$options.TEMP_PROVIDER_LOGOS[provider.name]" class="gl-ml-4">
+ <div
+ v-safe-html="$options.TEMP_PROVIDER_LOGOS[provider.name].svg"
+ data-testid="provider-logo"
+ style="width: 18px"
+ role="presentation"
+ ></div>
+ </div>
+ <div class="gl-ml-3">
<h3 class="gl-font-lg gl-m-0 gl-mb-2">{{ provider.name }}</h3>
<p>
{{ provider.description }}
- <gl-link :href="provider.url" target="_blank">{{ __('Learn more.') }}</gl-link>
+ <gl-link
+ v-if="$options.TEMP_PROVIDER_URLS[provider.name]"
+ :href="$options.TEMP_PROVIDER_URLS[provider.name]"
+ target="_blank"
+ @click="trackProviderLearnMoreClick(provider.id)"
+ >
+ {{ __('Learn more.') }}
+ </gl-link>
</p>
+ <!-- Note: The following `div` and it's content will be replaced by 'GlFormRadio' once https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1720#note_857342988 is resolved -->
+ <div
+ class="gl-form-radio custom-control custom-radio"
+ data-testid="primary-provider-radio"
+ >
+ <input
+ :id="`security-training-provider-${provider.id}`"
+ type="radio"
+ :checked="provider.isPrimary"
+ class="custom-control-input"
+ :disabled="!provider.isEnabled"
+ @change="setPrimaryProvider(provider)"
+ />
+ <label
+ class="custom-control-label"
+ :for="`security-training-provider-${provider.id}`"
+ >
+ {{ $options.i18n.primaryTraining }}
+ </label>
+ <gl-icon
+ v-gl-tooltip="$options.i18n.primaryTrainingDescription"
+ name="information-o"
+ class="gl-ml-2 gl-cursor-help"
+ />
+ </div>
</div>
</div>
</gl-card>
diff --git a/app/assets/javascripts/security_configuration/constants.js b/app/assets/javascripts/security_configuration/constants.js
index dc76436e91d..14eb10ac2aa 100644
--- a/app/assets/javascripts/security_configuration/constants.js
+++ b/app/assets/javascripts/security_configuration/constants.js
@@ -1,2 +1,8 @@
export const TRACK_TOGGLE_TRAINING_PROVIDER_ACTION = 'toggle_security_training_provider';
export const TRACK_TOGGLE_TRAINING_PROVIDER_LABEL = 'update_security_training_provider';
+export const TRACK_CLICK_TRAINING_LINK_ACTION = 'click_security_training_link';
+export const TRACK_PROVIDER_LEARN_MORE_CLICK_ACTION = 'click_link';
+export const TRACK_PROVIDER_LEARN_MORE_CLICK_LABEL = 'security_training_provider';
+export const TRACK_TRAINING_LOADED_ACTION = 'security_training_link_loaded';
+export const TRACK_PROMOTION_BANNER_CTA_CLICK_ACTION = 'click_button';
+export const TRACK_PROMOTION_BANNER_CTA_CLICK_LABEL = 'security_training_promotion_cta';
diff --git a/app/assets/javascripts/security_configuration/graphql/cache_utils.js b/app/assets/javascripts/security_configuration/graphql/cache_utils.js
new file mode 100644
index 00000000000..6d5258b01dc
--- /dev/null
+++ b/app/assets/javascripts/security_configuration/graphql/cache_utils.js
@@ -0,0 +1,40 @@
+import produce from 'immer';
+
+export const updateSecurityTrainingOptimisticResponse = (changes) => ({
+ // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ __typename: 'Mutation',
+ securityTrainingUpdate: {
+ __typename: 'SecurityTrainingUpdatePayload',
+ training: {
+ __typename: 'ProjectSecurityTraining',
+ ...changes,
+ },
+ errors: [],
+ },
+});
+
+export const updateSecurityTrainingCache = ({ query, variables }) => (cache, { data }) => {
+ const {
+ securityTrainingUpdate: { training: updatedProvider },
+ } = data;
+ const { project } = cache.readQuery({ query, variables });
+ if (!updatedProvider.isPrimary) {
+ return;
+ }
+
+ // when we set a new primary provider, we need to unset the previous one(s)
+ const updatedProject = produce(project, (draft) => {
+ draft.securityTrainingProviders.forEach((provider) => {
+ // eslint-disable-next-line no-param-reassign
+ provider.isPrimary = provider.id === updatedProvider.id;
+ });
+ });
+
+ // write to the cache
+ cache.writeQuery({
+ query,
+ variables,
+ data: { project: updatedProject },
+ });
+};
diff --git a/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql b/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql
new file mode 100644
index 00000000000..f0474614dab
--- /dev/null
+++ b/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql
@@ -0,0 +1,10 @@
+query getSecurityTrainingUrls($projectFullPath: ID!, $identifierExternalIds: [String!]!) {
+ project(fullPath: $projectFullPath) {
+ id
+ securityTrainingUrls(identifierExternalIds: $identifierExternalIds) {
+ name
+ status
+ url
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
index da9ff407faf..240e12ee597 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
@@ -1,5 +1,6 @@
<script>
import { GlIcon } from '@gitlab/ui';
+import { IssuableType } from '~/issues/constants';
import { __, sprintf } from '~/locale';
export default {
@@ -31,10 +32,11 @@ export default {
);
},
isMergeRequest() {
- return this.issuableType === 'merge_request';
+ return this.issuableType === IssuableType.MergeRequest;
},
hasMergeIcon() {
- return this.isMergeRequest && !this.user.can_merge;
+ const canMerge = this.user.mergeRequestInteraction?.canMerge || this.user.can_merge;
+ return this.isMergeRequest && !canMerge;
},
},
};
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
index 2a237e7ace0..578c344da02 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
@@ -1,5 +1,6 @@
<script>
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
+import { IssuableType } from '~/issues/constants';
import { __ } from '~/locale';
import { isUserBusy } from '~/set_status_modal/utils';
import AssigneeAvatar from './assignee_avatar.vue';
@@ -71,7 +72,8 @@ export default {
},
computed: {
cannotMerge() {
- return this.issuableType === 'merge_request' && !this.user.can_merge;
+ const canMerge = this.user.mergeRequestInteraction?.canMerge || this.user.can_merge;
+ return this.issuableType === IssuableType.MergeRequest && !canMerge;
},
tooltipTitle() {
const { name = '', availability = '' } = this.user;
diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
index 6a74ab83c22..856687c00ae 100644
--- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
@@ -58,7 +58,7 @@ export default {
return this.users.length > 2;
},
allAssigneesCanMerge() {
- return this.users.every((user) => user.can_merge);
+ return this.users.every((user) => user.can_merge || user.mergeRequestInteraction?.canMerge);
},
sidebarAvatarCounter() {
if (this.users.length > DEFAULT_MAX_COUNTER) {
@@ -77,7 +77,9 @@ export default {
return '';
}
- const mergeLength = this.users.filter((u) => u.can_merge).length;
+ const mergeLength = this.users.filter(
+ (u) => u.can_merge || u.mergeRequestInteraction?.canMerge,
+ ).length;
if (mergeLength === this.users.length) {
return '';
diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
index a3379784bc1..59a4eb54bbe 100644
--- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
@@ -44,7 +44,7 @@ export default {
<div class="gl-display-flex gl-flex-direction-column issuable-assignees">
<div
v-if="emptyUsers"
- class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-2 hide-collapsed"
+ class="gl-display-flex gl-align-items-center gl-text-gray-500 hide-collapsed"
data-testid="none"
>
<span> {{ __('None') }}</span>
@@ -65,7 +65,7 @@ export default {
v-else
:users="users"
:issuable-type="issuableType"
- class="gl-text-gray-800 gl-mt-2 hide-collapsed"
+ class="gl-text-gray-800 hide-collapsed"
@toggle-attention-requested="toggleAttentionRequested"
/>
</div>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
index 453dd1b0580..e596d6292bf 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
@@ -63,7 +63,7 @@ export default {
computed: {
shouldEnableRealtime() {
// Note: Realtime is only available on issues right now, future support for MR wil be built later.
- return this.glFeatures.realTimeIssueSidebar && this.issuableType === 'issue';
+ return this.issuableType === 'issue';
},
queryVariables() {
return {
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 18654b73ab3..7743004a293 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
@@ -1,6 +1,5 @@
<script>
import { GlDropdownItem } from '@gitlab/ui';
-import { cloneDeep } from 'lodash';
import Vue from 'vue';
import createFlash from '~/flash';
import { IssuableType } from '~/issues/constants';
@@ -101,7 +100,10 @@ export default {
}
const issuable = data.workspace?.issuable;
if (issuable) {
- this.selected = cloneDeep(issuable.assignees.nodes);
+ this.selected = issuable.assignees.nodes.map((node) => ({
+ ...node,
+ canMerge: node.mergeRequestInteraction?.canMerge || false,
+ }));
}
},
error() {
@@ -112,7 +114,7 @@ export default {
computed: {
shouldEnableRealtime() {
// Note: Realtime is only available on issues right now, future support for MR wil be built later.
- return this.glFeatures.realTimeIssueSidebar && this.issuableType === IssuableType.Issue;
+ return this.issuableType === IssuableType.Issue;
},
queryVariables() {
return {
@@ -141,6 +143,7 @@ export default {
username: gon?.current_username,
name: gon?.current_user_fullname,
avatarUrl: gon?.current_user_avatar_url,
+ canMerge: this.issuable?.userPermissions?.canMerge || false,
};
},
signedIn() {
@@ -206,8 +209,8 @@ export default {
expandWidget() {
this.$refs.toggle.expand();
},
- focusSearch() {
- this.$refs.userSelect.focusSearch();
+ showDropdown() {
+ this.$refs.userSelect.showDropdown();
},
showError() {
createFlash({ message: __('An error occurred while fetching participants.') });
@@ -236,11 +239,11 @@ export default {
:initial-loading="isAssigneesLoading"
:title="assigneeText"
:is-dirty="isDirty"
- @open="focusSearch"
+ @open="showDropdown"
@close="saveAssignees"
>
<template #collapsed>
- <slot name="collapsed" :users="assignees" :on-click="expandWidget"></slot>
+ <slot name="collapsed" :users="assignees"></slot>
<issuable-assignees
:users="assignees"
:issuable-type="issuableType"
@@ -256,12 +259,13 @@ export default {
:text="$options.i18n.assignees"
:header-text="$options.i18n.assignTo"
:iid="iid"
+ :issuable-id="issuableId"
:full-path="fullPath"
:allow-multiple-assignees="allowMultipleAssignees"
:current-user="currentUser"
:issuable-type="issuableType"
:is-editing="edit"
- class="gl-w-full dropdown-menu-user"
+ class="gl-w-full dropdown-menu-user gl-mt-n3"
@toggle="collapseWidget"
@error="showError"
@input="setDirtyState"
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue
index 8ef65ef7308..28bc5afc1a4 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue
@@ -30,6 +30,6 @@ export default {
:event="$options.dataTrackEvent"
:label="$options.dataTrackLabel"
:trigger-source="triggerSource"
- classes="gl-display-block gl-pl-6 gl-hover-text-decoration-none gl-hover-text-blue-800!"
+ classes="gl-display-block gl-pl-0 gl-hover-text-decoration-none gl-hover-text-blue-800!"
/>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
index e2a38a100b9..19f588b28be 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
@@ -1,17 +1,24 @@
<script>
-import { GlAvatarLabeled, GlAvatarLink } from '@gitlab/ui';
+import { GlAvatarLabeled, GlAvatarLink, GlIcon } from '@gitlab/ui';
+import { IssuableType } from '~/issues/constants';
import { s__, sprintf } from '~/locale';
export default {
components: {
GlAvatarLabeled,
GlAvatarLink,
+ GlIcon,
},
props: {
user: {
type: Object,
required: true,
},
+ issuableType: {
+ type: String,
+ required: false,
+ default: IssuableType.Issue,
+ },
},
computed: {
userLabel() {
@@ -22,6 +29,9 @@ export default {
author: this.user.name,
});
},
+ hasCannotMergeIcon() {
+ return this.issuableType === IssuableType.MergeRequest && !this.user.canMerge;
+ },
},
};
</script>
@@ -31,9 +41,19 @@ export default {
<gl-avatar-labeled
:size="32"
:label="userLabel"
- :sub-label="user.username"
+ :sub-label="`@${user.username}`"
:src="user.avatarUrl || user.avatar || user.avatar_url"
- class="gl-align-items-center"
- />
+ class="gl-align-items-center gl-relative"
+ >
+ <template #meta>
+ <gl-icon
+ v-if="hasCannotMergeIcon"
+ name="warning-solid"
+ aria-hidden="true"
+ class="merge-icon"
+ :size="12"
+ />
+ </template>
+ </gl-avatar-labeled>
</gl-avatar-link>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
index a27dbee31ec..558fe8ca2aa 100644
--- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -114,7 +114,7 @@ export default {
class="gl-display-inline-block"
>
<attention-requested-toggle
- v-if="showVerticalList && user.can_update_merge_request"
+ v-if="showVerticalList"
:user="user"
type="assignee"
@toggle-attention-requested="toggleAttentionRequested"
diff --git a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue
index 42e56906e2c..6ba88939373 100644
--- a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue
+++ b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue
@@ -8,6 +8,8 @@ export default {
attentionRequestedReviewer: __('Request attention to review'),
attentionRequestedAssignee: __('Request attention'),
removeAttentionRequested: __('Remove attention request'),
+ attentionRequestedNoPermission: __('Attention requested'),
+ noAttentionRequestedNoPermission: __('No attention request'),
},
components: {
GlButton,
@@ -33,17 +35,25 @@ export default {
computed: {
tooltipTitle() {
if (this.user.attention_requested) {
- return this.$options.i18n.removeAttentionRequested;
+ if (this.user.can_update_merge_request) {
+ return this.$options.i18n.removeAttentionRequested;
+ }
+
+ return this.$options.i18n.attentionRequestedNoPermission;
+ }
+
+ if (this.user.can_update_merge_request) {
+ return this.type === 'reviewer'
+ ? this.$options.i18n.attentionRequestedReviewer
+ : this.$options.i18n.attentionRequestedAssignee;
}
- return this.type === 'reviewer'
- ? this.$options.i18n.attentionRequestedReviewer
- : this.$options.i18n.attentionRequestedAssignee;
+ return this.$options.i18n.noAttentionRequestedNoPermission;
},
},
methods: {
toggleAttentionRequired() {
- if (this.loading) return;
+ if (this.loading || !this.user.can_update_merge_request) return;
this.$root.$emit(BV_HIDE_TOOLTIP);
this.loading = true;
@@ -60,12 +70,16 @@ export default {
</script>
<template>
- <span v-gl-tooltip.left.viewport="tooltipTitle">
+ <span
+ v-gl-tooltip.left.viewport="tooltipTitle"
+ class="gl-display-inline-block js-attention-request-toggle"
+ >
<gl-button
:loading="loading"
:variant="user.attention_requested ? 'warning' : 'default'"
:icon="user.attention_requested ? 'attention-solid' : 'attention'"
:aria-label="tooltipTitle"
+ :class="{ 'gl-pointer-events-none': !user.can_update_merge_request }"
size="small"
category="tertiary"
@click="toggleAttentionRequired"
diff --git a/app/assets/javascripts/sidebar/components/incidents/constants.js b/app/assets/javascripts/sidebar/components/incidents/constants.js
new file mode 100644
index 00000000000..cd05a6099fd
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/incidents/constants.js
@@ -0,0 +1,25 @@
+import { s__ } from '~/locale';
+
+export const STATUS_TRIGGERED = 'TRIGGERED';
+export const STATUS_ACKNOWLEDGED = 'ACKNOWLEDGED';
+export const STATUS_RESOLVED = 'RESOLVED';
+
+export const STATUS_TRIGGERED_LABEL = s__('IncidentManagement|Triggered');
+export const STATUS_ACKNOWLEDGED_LABEL = s__('IncidentManagement|Acknowledged');
+export const STATUS_RESOLVED_LABEL = s__('IncidentManagement|Resolved');
+
+export const STATUS_LABELS = {
+ [STATUS_TRIGGERED]: STATUS_TRIGGERED_LABEL,
+ [STATUS_ACKNOWLEDGED]: STATUS_ACKNOWLEDGED_LABEL,
+ [STATUS_RESOLVED]: STATUS_RESOLVED_LABEL,
+};
+
+export const i18n = {
+ fetchError: s__(
+ 'IncidentManagement|An error occurred while fetching the incident status. Please reload the page.',
+ ),
+ title: s__('IncidentManagement|Status'),
+ updateError: s__(
+ 'IncidentManagement|An error occurred while updating the incident status. Please reload the page and try again.',
+ ),
+};
diff --git a/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue
new file mode 100644
index 00000000000..2c32cf89387
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue
@@ -0,0 +1,61 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { i18n, STATUS_ACKNOWLEDGED, STATUS_TRIGGERED, STATUS_RESOLVED } from './constants';
+import { getStatusLabel } from './utils';
+
+const STATUS_LIST = [STATUS_TRIGGERED, STATUS_ACKNOWLEDGED, STATUS_RESOLVED];
+
+export default {
+ i18n,
+ STATUS_LIST,
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ },
+ props: {
+ value: {
+ type: String,
+ required: false,
+ default: null,
+ validator(value) {
+ return [...STATUS_LIST, null].includes(value);
+ },
+ },
+ },
+ computed: {
+ currentStatusLabel() {
+ return this.getStatusLabel(this.value);
+ },
+ },
+ methods: {
+ show() {
+ this.$refs.dropdown.show();
+ },
+ hide() {
+ this.$refs.dropdown.hide();
+ },
+ getStatusLabel,
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ ref="dropdown"
+ block
+ :text="currentStatusLabel"
+ toggle-class="dropdown-menu-toggle gl-mb-2"
+ >
+ <slot name="header"> </slot>
+ <gl-dropdown-item
+ v-for="status in $options.STATUS_LIST"
+ :key="status"
+ data-testid="status-dropdown-item"
+ :is-check-item="true"
+ :is-checked="status === value"
+ @click="$emit('input', status)"
+ >
+ {{ getStatusLabel(status) }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue
new file mode 100644
index 00000000000..67ae1e6fcab
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue
@@ -0,0 +1,135 @@
+<script>
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { escalationStatusQuery, escalationStatusMutation } from '~/sidebar/constants';
+import { createAlert } from '~/flash';
+import { logError } from '~/lib/logger';
+import EscalationStatus from 'ee_else_ce/sidebar/components/incidents/escalation_status.vue';
+import SidebarEditableItem from '../sidebar_editable_item.vue';
+import { i18n } from './constants';
+import { getStatusLabel } from './utils';
+
+export default {
+ i18n,
+ components: {
+ EscalationStatus,
+ SidebarEditableItem,
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ iid: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ issuableType: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ status: null,
+ isUpdating: false,
+ };
+ },
+ apollo: {
+ status: {
+ query() {
+ return escalationStatusQuery;
+ },
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ iid: this.iid,
+ };
+ },
+ update(data) {
+ return data.workspace?.issuable?.escalationStatus;
+ },
+ error(error) {
+ const message = this.$options.i18n.fetchError;
+ createAlert({ message });
+ logError(message, error);
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.status.loading;
+ },
+ currentStatusLabel() {
+ return getStatusLabel(this.status);
+ },
+ tooltipText() {
+ return `${this.$options.i18n.title}: ${this.currentStatusLabel}`;
+ },
+ },
+ methods: {
+ updateStatus(status) {
+ this.isUpdating = true;
+ this.closeSidebar();
+ return this.$apollo
+ .mutate({
+ mutation: escalationStatusMutation,
+ variables: {
+ status,
+ iid: this.iid,
+ projectPath: this.projectPath,
+ },
+ })
+ .then(({ data: { issueSetEscalationStatus } }) => {
+ this.status = issueSetEscalationStatus.issue.escalationStatus;
+ })
+ .catch((error) => {
+ const message = this.$options.i18n.updateError;
+ createAlert({ message });
+ logError(message, error);
+ })
+ .finally(() => {
+ this.isUpdating = false;
+ });
+ },
+ closeSidebar() {
+ this.close();
+ this.$refs.editable.collapse();
+ },
+ open() {
+ this.$refs.escalationStatus.show();
+ },
+ close() {
+ this.$refs.escalationStatus.hide();
+ },
+ },
+};
+</script>
+
+<template>
+ <sidebar-editable-item
+ ref="editable"
+ :title="$options.i18n.title"
+ :initial-loading="isLoading"
+ :loading="isUpdating"
+ @open="open"
+ @close="close"
+ >
+ <template #default>
+ <escalation-status ref="escalationStatus" :value="status" @input="updateStatus" />
+ </template>
+ <template #collapsed>
+ <div
+ v-gl-tooltip.viewport.left="tooltipText"
+ class="sidebar-collapsed-icon"
+ data-testid="status-icon"
+ >
+ <gl-icon name="status" :size="16" />
+ </div>
+ <span class="hide-collapsed text-secondary">{{ currentStatusLabel }}</span>
+ </template>
+ </sidebar-editable-item>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/incidents/utils.js b/app/assets/javascripts/sidebar/components/incidents/utils.js
new file mode 100644
index 00000000000..59bf1ea466c
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/incidents/utils.js
@@ -0,0 +1,5 @@
+import { s__ } from '~/locale';
+
+import { STATUS_LABELS } from './constants';
+
+export const getStatusLabel = (status) => STATUS_LABELS[status] ?? s__('IncidentManagement|None');
diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
index adaf1b65f3f..9485802d3da 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
@@ -98,7 +98,7 @@ export default {
data-testid="reviewer"
>
<attention-requested-toggle
- v-if="glFeatures.mrAttentionRequests && user.can_update_merge_request"
+ v-if="glFeatures.mrAttentionRequests"
:user="user"
type="reviewer"
@toggle-attention-requested="toggleAttentionRequested"
diff --git a/app/assets/javascripts/sidebar/components/severity/severity.vue b/app/assets/javascripts/sidebar/components/severity/severity.vue
index 7e7d62256c9..0db856543d0 100644
--- a/app/assets/javascripts/sidebar/components/severity/severity.vue
+++ b/app/assets/javascripts/sidebar/components/severity/severity.vue
@@ -1,9 +1,11 @@
<script>
import { GlIcon } from '@gitlab/ui';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
export default {
components: {
GlIcon,
+ TooltipOnTruncate,
},
props: {
severity: {
@@ -30,13 +32,15 @@ export default {
<template>
<div
- class="incident-severity gl-display-inline-flex gl-align-items-center gl-justify-content-between"
+ class="incident-severity gl-display-inline-flex gl-align-items-center gl-justify-content-between gl-max-w-full"
>
<gl-icon
:size="iconSize"
:name="`severity-${severity.icon}`"
:class="[`icon-${severity.icon}`, { 'gl-mr-3': !iconOnly }]"
/>
- <span v-if="!iconOnly">{{ severity.label }}</span>
+ <tooltip-on-truncate v-if="!iconOnly" :title="severity.label" class="gl-text-truncate">{{
+ severity.label
+ }}</tooltip-on-truncate>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
index 0238fb8e8d5..989dc574bc3 100644
--- a/app/assets/javascripts/sidebar/constants.js
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -1,7 +1,8 @@
import { s__, sprintf } from '~/locale';
import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql';
+import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
+import userSearchWithMRPermissionsQuery from '~/graphql_shared/queries/users_search_with_mr_permissions.graphql';
import { IssuableType, WorkspaceType } from '~/issues/constants';
-import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql';
import epicDueDateQuery from '~/sidebar/queries/epic_due_date.query.graphql';
import epicParticipantsQuery from '~/sidebar/queries/epic_participants.query.graphql';
@@ -49,12 +50,12 @@ import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries
import getMrTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql';
import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
import updateMergeRequestAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql';
+import getEscalationStatusQuery from '~/sidebar/queries/escalation_status.query.graphql';
+import updateEscalationStatusMutation from '~/sidebar/queries/update_escalation_status.mutation.graphql';
import projectIssueMilestoneMutation from './queries/project_issue_milestone.mutation.graphql';
import projectIssueMilestoneQuery from './queries/project_issue_milestone.query.graphql';
import projectMilestonesQuery from './queries/project_milestones.query.graphql';
-export const ASSIGNEES_DEBOUNCE_DELAY = DEFAULT_DEBOUNCE_AND_THROTTLE_MS;
-
export const defaultEpicSort = 'TITLE_ASC';
export const epicIidPattern = /^&(?<iid>\d+)$/;
@@ -91,6 +92,15 @@ export const participantsQueries = {
},
};
+export const userSearchQueries = {
+ [IssuableType.Issue]: {
+ query: userSearchQuery,
+ },
+ [IssuableType.MergeRequest]: {
+ query: userSearchWithMRPermissionsQuery,
+ },
+};
+
export const confidentialityQueries = {
[IssuableType.Issue]: {
query: issueConfidentialQuery,
@@ -305,3 +315,6 @@ export function dropdowni18nText(issuableAttribute, issuableType) {
),
};
}
+
+export const escalationStatusQuery = getEscalationStatusQuery;
+export const escalationStatusMutation = updateEscalationStatusMutation;
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index c29784aa328..2a7d967cb61 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -10,6 +10,7 @@ import {
isInIssuePage,
isInDesignPage,
isInIncidentPage,
+ isInMRPage,
parseBoolean,
} from '~/lib/utils/common_utils';
import { __ } from '~/locale';
@@ -27,9 +28,11 @@ import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_v
import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import eventHub from '~/sidebar/event_hub';
+import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import Translate from '../vue_shared/translate';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue';
+import SidebarEscalationStatus from './components/incidents/sidebar_escalation_status.vue';
import IssuableLockForm from './components/lock/issuable_lock_form.vue';
import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue';
import SidebarSeverity from './components/severity/sidebar_severity.vue';
@@ -134,6 +137,8 @@ function mountAssigneesComponent() {
if (!el) return;
const { id, iid, fullPath, editable } = getSidebarOptions();
+ const isIssuablePage = isInIssuePage() || isInIncidentPage() || isInDesignPage();
+ const issuableType = isIssuablePage ? IssuableType.Issue : IssuableType.MergeRequest;
// eslint-disable-next-line no-new
new Vue({
el,
@@ -151,21 +156,16 @@ function mountAssigneesComponent() {
props: {
iid: String(iid),
fullPath,
- issuableType:
- isInIssuePage() || isInIncidentPage() || isInDesignPage()
- ? IssuableType.Issue
- : IssuableType.MergeRequest,
+ issuableType,
issuableId: id,
allowMultipleAssignees: !el.dataset.maxAssignees,
},
scopedSlots: {
- collapsed: ({ users, onClick }) =>
+ collapsed: ({ users }) =>
createElement(CollapsedAssigneeList, {
props: {
users,
- },
- nativeOn: {
- click: onClick,
+ issuableType,
},
}),
},
@@ -567,6 +567,36 @@ function mountSeverityComponent() {
});
}
+function mountEscalationStatusComponent() {
+ const statusContainerEl = document.querySelector('#js-escalation-status');
+
+ if (!statusContainerEl) {
+ return false;
+ }
+
+ const { issuableType } = getSidebarOptions();
+ const { canUpdate, issueIid, projectPath } = statusContainerEl.dataset;
+
+ return new Vue({
+ el: statusContainerEl,
+ apolloProvider,
+ components: {
+ SidebarEscalationStatus,
+ },
+ provide: {
+ canUpdate: parseBoolean(canUpdate),
+ },
+ render: (createElement) =>
+ createElement('sidebar-escalation-status', {
+ props: {
+ iid: issueIid,
+ issuableType,
+ projectPath,
+ },
+ }),
+ });
+}
+
function mountCopyEmailComponent() {
const el = document.getElementById('issuable-copy-email');
@@ -584,7 +614,7 @@ function mountCopyEmailComponent() {
}
const isAssigneesWidgetShown =
- (isInIssuePage() || isInDesignPage()) && gon.features.issueAssigneesWidget;
+ (isInIssuePage() || isInDesignPage() || isInMRPage()) && gon.features.issueAssigneesWidget;
export function mountSidebar(mediator, store) {
initInviteMembersModal();
@@ -618,10 +648,13 @@ export function mountSidebar(mediator, store) {
mountSeverityComponent();
+ mountEscalationStatusComponent();
+
if (window.gon?.features?.mrAttentionRequests) {
- eventHub.$on('removeCurrentUserAttentionRequested', () =>
- mediator.removeCurrentUserAttentionRequested(),
- );
+ eventHub.$on('removeCurrentUserAttentionRequested', () => {
+ mediator.removeCurrentUserAttentionRequested();
+ refreshUserMergeRequestCounts();
+ });
}
}
diff --git a/app/assets/javascripts/sidebar/queries/escalation_status.query.graphql b/app/assets/javascripts/sidebar/queries/escalation_status.query.graphql
new file mode 100644
index 00000000000..cb7c5a0fbe7
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/escalation_status.query.graphql
@@ -0,0 +1,9 @@
+query escalationStatusQuery($fullPath: ID!, $iid: String) {
+ workspace: project(fullPath: $fullPath) {
+ id
+ issuable: issue(iid: $iid) {
+ id
+ escalationStatus
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/update_escalation_status.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_escalation_status.mutation.graphql
new file mode 100644
index 00000000000..a4aff7968df
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/update_escalation_status.mutation.graphql
@@ -0,0 +1,10 @@
+mutation updateEscalationStatus($projectPath: ID!, $status: IssueEscalationStatus!, $iid: String!) {
+ issueSetEscalationStatus(input: { projectPath: $projectPath, status: $status, iid: $iid }) {
+ errors
+ clientMutationId
+ issue {
+ id
+ escalationStatus
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js
index 1be670f7590..74ab65e4e04 100644
--- a/app/assets/javascripts/sidebar/sidebar_bundle.js
+++ b/app/assets/javascripts/sidebar/sidebar_bundle.js
@@ -3,7 +3,17 @@ import Mediator from './sidebar_mediator';
export default (store) => {
const mediator = new Mediator(getSidebarOptions());
- mediator.fetch();
+ mediator
+ .fetch()
+ .then(() => {
+ if (window.gon?.features?.mrAttentionRequests) {
+ return import('~/attention_requests');
+ }
+
+ return null;
+ })
+ .then((module) => module?.initSideNavPopover())
+ .catch(() => {});
mountSidebar(mediator, store);
};
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index 4664bb56958..83fb8f31dfb 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -2,6 +2,7 @@ import Store from '~/sidebar/stores/sidebar_store';
import createFlash from '~/flash';
import { __, sprintf } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
+import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import { visitUrl } from '../lib/utils/url_utility';
import Service from './services/sidebar_service';
@@ -125,6 +126,7 @@ export default class SidebarMediator {
this.store.updateReviewer(user.id, 'attention_requested');
this.store.updateAssignee(user.id, 'attention_requested');
+ refreshUserMergeRequestCounts();
callback();
} catch (error) {
callback();
diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js
index d2841156e55..b7159fd6835 100644
--- a/app/assets/javascripts/single_file_diff.js
+++ b/app/assets/javascripts/single_file_diff.js
@@ -1,6 +1,7 @@
/* eslint-disable consistent-return */
import $ from 'jquery';
+import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
import { spriteIcon } from '~/lib/utils/common_utils';
import FilesCommentButton from './files_comment_button';
import createFlash from './flash';
@@ -10,7 +11,7 @@ import { __ } from './locale';
import syntaxHighlight from './syntax_highlight';
const WRAPPER = '<div class="diff-content"></div>';
-const LOADING_HTML = '<span class="spinner"></span>';
+const LOADING_HTML = loadingIconForLegacyJS().outerHTML;
const ERROR_HTML = `<div class="nothing-here-block">${spriteIcon(
'warning-solid',
's16',
diff --git a/app/assets/javascripts/terraform/components/empty_state.vue b/app/assets/javascripts/terraform/components/empty_state.vue
index a5a613b7282..fd9177bef3f 100644
--- a/app/assets/javascripts/terraform/components/empty_state.vue
+++ b/app/assets/javascripts/terraform/components/empty_state.vue
@@ -16,7 +16,7 @@ export default {
},
computed: {
docsUrl() {
- return helpPagePath('user/infrastructure/terraform_state');
+ return helpPagePath('user/infrastructure/iac/terraform_state');
},
},
};
diff --git a/app/assets/javascripts/toggle_buttons.js b/app/assets/javascripts/toggle_buttons.js
deleted file mode 100644
index 5b85107991a..00000000000
--- a/app/assets/javascripts/toggle_buttons.js
+++ /dev/null
@@ -1,63 +0,0 @@
-import $ from 'jquery';
-import createFlash from './flash';
-import { parseBoolean } from './lib/utils/common_utils';
-import { __ } from './locale';
-
-/*
- example HAML:
- ```
- %button.js-project-feature-toggle.project-feature-toggle{ type: "button",
- class: "#{'is-checked' if enabled?}",
- 'aria-label': _('Toggle Kubernetes Cluster') }
- %input{ type: "hidden", class: 'js-project-feature-toggle-input', value: enabled? }
- ```
-*/
-
-function updateToggle(toggle, isOn) {
- toggle.classList.toggle('is-checked', isOn);
-}
-
-function onToggleClicked(toggle, input, clickCallback) {
- const previousIsOn = parseBoolean(input.value);
-
- // Visually change the toggle and start loading
- updateToggle(toggle, !previousIsOn);
- toggle.setAttribute('disabled', true);
- toggle.classList.toggle('is-loading', true);
-
- Promise.resolve(clickCallback(!previousIsOn, toggle))
- .then(() => {
- // Actually change the input value
- input.setAttribute('value', !previousIsOn);
- })
- .catch(() => {
- // Revert the visuals if something goes wrong
- updateToggle(toggle, previousIsOn);
- })
- .then(() => {
- // Remove the loading indicator in any case
- toggle.removeAttribute('disabled');
- toggle.classList.toggle('is-loading', false);
-
- $(input).trigger('trigger-change');
- })
- .catch(() => {
- createFlash({
- message: __('Something went wrong when toggling the button'),
- });
- });
-}
-
-export default function setupToggleButtons(container, clickCallback = () => {}) {
- const toggles = container.querySelectorAll('.js-project-feature-toggle');
-
- toggles.forEach((toggle) => {
- const input = toggle.querySelector('.js-project-feature-toggle-input');
- const isOn = parseBoolean(input.value);
-
- // Get the visible toggle in sync with the hidden input
- updateToggle(toggle, isOn);
-
- toggle.addEventListener('click', onToggleClicked.bind(null, toggle, input, clickCallback));
- });
-}
diff --git a/app/assets/javascripts/toggles/index.js b/app/assets/javascripts/toggles/index.js
index 046b9fc7dcd..5848b3a424c 100644
--- a/app/assets/javascripts/toggles/index.js
+++ b/app/assets/javascripts/toggles/index.js
@@ -8,16 +8,12 @@ export const initToggle = (el) => {
return false;
}
- const {
- name,
- isChecked,
- disabled,
- isLoading,
- label,
- help,
- labelPosition,
- ...dataset
- } = el.dataset;
+ const { name, id, isChecked, disabled, isLoading, label, help, labelPosition, ...dataset } =
+ el.dataset || {};
+
+ const dataAttrs = Object.fromEntries(
+ Object.entries(dataset).map(([key, value]) => [`data-${kebabCase(key)}`, value]),
+ );
return new Vue({
el,
@@ -50,9 +46,7 @@ export const initToggle = (el) => {
labelPosition,
},
class: el.className,
- attrs: Object.fromEntries(
- Object.entries(dataset).map(([key, value]) => [`data-${kebabCase(key)}`, value]),
- ),
+ attrs: { id, ...dataAttrs },
on: {
change: (newValue) => {
this.value = newValue;
diff --git a/app/assets/javascripts/tracking/dispatch_snowplow_event.js b/app/assets/javascripts/tracking/dispatch_snowplow_event.js
index bc9d7384ea4..7e596f5f36f 100644
--- a/app/assets/javascripts/tracking/dispatch_snowplow_event.js
+++ b/app/assets/javascripts/tracking/dispatch_snowplow_event.js
@@ -10,7 +10,8 @@ export function dispatchSnowplowEvent(
throw new Error('Tracking: no category provided for tracking.');
}
- const { label, property, value, extra = {} } = data;
+ const { label, property, extra = {} } = data;
+ let { value } = data;
const standardContext = getStandardContext({ extra });
const contexts = [standardContext];
@@ -19,5 +20,9 @@ export function dispatchSnowplowEvent(
contexts.push(data.context);
}
+ if (value !== undefined) {
+ value = Number(value);
+ }
+
return window.snowplow('trackStructEvent', category, action, label, property, value, contexts);
}
diff --git a/app/assets/javascripts/tracking/tracking.js b/app/assets/javascripts/tracking/tracking.js
index c26abc261ed..173eef0646b 100644
--- a/app/assets/javascripts/tracking/tracking.js
+++ b/app/assets/javascripts/tracking/tracking.js
@@ -10,6 +10,8 @@ import {
addReferrersCacheEntry,
} from './utils';
+const ALLOWED_URL_HASHES = ['#diff', '#note'];
+
export default class Tracking {
static queuedEvents = [];
static initialized = false;
@@ -183,7 +185,9 @@ export default class Tracking {
originalUrl: window.location.href,
});
- window.snowplow('setCustomUrl', pageLinks.url);
+ const appendHash = ALLOWED_URL_HASHES.some((prefix) => window.location.hash.startsWith(prefix));
+ const customUrl = `${pageUrl}${appendHash ? window.location.hash : ''}`;
+ window.snowplow('setCustomUrl', customUrl);
if (document.referrer) {
const node = referrers.find((links) => links.originalUrl === document.referrer);
diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js
index 8ed92e6b948..656c851aa3d 100644
--- a/app/assets/javascripts/users_select/index.js
+++ b/app/assets/javascripts/users_select/index.js
@@ -210,7 +210,7 @@ function UsersSelect(currentUser, els, options = {}) {
return axios.put(issueURL, data).then(({ data }) => {
let user = {};
- let tooltipTitle = user.name;
+ let tooltipTitle;
$dropdown.trigger('loaded.gl.dropdown');
$loading.addClass('gl-display-none');
if (data.assignee) {
@@ -806,7 +806,9 @@ UsersSelect.prototype.renderRow = function (
</strong>
${
username
- ? `<span class="dropdown-menu-user-username gl-text-gray-400">${username}</span>`
+ ? `<span class="dropdown-menu-user-username gl-text-gray-400">${escape(
+ username,
+ )}</span>`
: ''
}
${this.renderApprovalRules(elsClassName, user.applicable_approval_rules)}
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 a25b4ab54e5..684386883c8 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
@@ -2,21 +2,20 @@
import {
GlButton,
GlLoadingIcon,
- GlLink,
- GlBadge,
GlSafeHtmlDirective,
GlTooltipDirective,
GlIntersectionObserver,
} from '@gitlab/ui';
import { once } from 'lodash';
import * as Sentry from '@sentry/browser';
+import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller';
import api from '~/api';
import { sprintf, s__, __ } from '~/locale';
-import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
import Poll from '~/lib/utils/poll';
import { EXTENSION_ICON_CLASS, EXTENSION_ICONS } from '../../constants';
import StatusIcon from './status_icon.vue';
import Actions from './actions.vue';
+import ChildContent from './child_content.vue';
import { generateText } from './utils';
export const LOADING_STATES = {
@@ -30,12 +29,12 @@ export default {
components: {
GlButton,
GlLoadingIcon,
- GlLink,
- GlBadge,
GlIntersectionObserver,
- SmartVirtualList,
StatusIcon,
Actions,
+ ChildContent,
+ DynamicScroller,
+ DynamicScrollerItem,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
@@ -188,7 +187,7 @@ export default {
this.fetchFullData(this.$props)
.then((data) => {
this.loadingState = null;
- this.fullData = data;
+ this.fullData = data.map((x, i) => ({ id: i, ...x }));
})
.catch((e) => {
this.loadingState = LOADING_STATES.expandedError;
@@ -196,9 +195,6 @@ export default {
Sentry.captureException(e);
});
},
- isArray(arr) {
- return Array.isArray(arr);
- },
appear(index) {
if (index === this.fullData.length - 1) {
this.showFade = false;
@@ -281,80 +277,33 @@ export default {
<div v-if="isLoadingExpanded" class="report-block-container">
<gl-loading-icon size="sm" inline /> {{ __('Loading...') }}
</div>
- <smart-virtual-list
+ <dynamic-scroller
v-else-if="hasFullData"
- :length="fullData.length"
- :remain="20"
- :size="32"
- wtag="ul"
- wclass="report-block-list"
+ :items="fullData"
+ :min-item-size="32"
class="report-block-container gl-px-5 gl-py-0"
>
- <li
- v-for="(data, index) in fullData"
- :key="data.id"
- :class="{
- 'gl-border-b-solid gl-border-b-1 gl-border-gray-100': index !== fullData.length - 1,
- }"
- class="gl-py-3 gl-pl-7"
- data-testid="extension-list-item"
- >
- <div class="gl-w-full">
- <div v-if="data.header" class="gl-mb-2">
- <template v-if="isArray(data.header)">
- <component
- :is="headerI === 0 ? 'strong' : 'span'"
- v-for="(header, headerI) in data.header"
- :key="headerI"
- v-safe-html="generateText(header)"
- class="gl-display-block"
- />
- </template>
- <strong v-else v-safe-html="generateText(data.header)"></strong>
- </div>
- <div class="gl-display-flex">
- <status-icon
- v-if="data.icon"
- :icon-name="data.icon.name"
- :size="12"
- class="gl-pl-0"
- />
+ <template #default="{ item, index, active }">
+ <dynamic-scroller-item :item="item" :active="active" :class="{ active }">
+ <div
+ :class="{
+ 'gl-border-b-solid gl-border-b-1 gl-border-gray-100': index !== fullData.length - 1,
+ }"
+ class="gl-py-3 gl-pl-7"
+ data-testid="extension-list-item"
+ >
<gl-intersection-observer
:options="{ rootMargin: '100px', thresholds: 0.1 }"
class="gl-w-full"
@appear="appear(index)"
@disappear="disappear(index)"
>
- <div class="gl-flex-wrap gl-display-flex gl-w-full">
- <div class="gl-mr-4 gl-display-flex gl-align-items-center">
- <p v-safe-html="generateText(data.text)" class="gl-m-0"></p>
- </div>
- <div v-if="data.link">
- <gl-link :href="data.link.href">{{ data.link.text }}</gl-link>
- </div>
- <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"
- class="gl-ml-auto"
- />
- </div>
- <p
- v-if="data.subtext"
- v-safe-html="generateText(data.subtext)"
- class="gl-m-0 gl-font-sm"
- ></p>
+ <child-content :data="item" :widget-label="widgetLabel" :level="2" />
</gl-intersection-observer>
</div>
- </div>
- </li>
- </smart-virtual-list>
+ </dynamic-scroller-item>
+ </template>
+ </dynamic-scroller>
<div
:class="{ show: showFade }"
class="fade mr-extenson-scrim gl-absolute gl-left-0 gl-bottom-0 gl-w-full gl-h-7 gl-pointer-events-none"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue
new file mode 100644
index 00000000000..5f42c6c7acb
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue
@@ -0,0 +1,95 @@
+<script>
+import { GlBadge, GlLink, GlSafeHtmlDirective } from '@gitlab/ui';
+import StatusIcon from './status_icon.vue';
+import Actions from './actions.vue';
+import { generateText } from './utils';
+
+export default {
+ name: 'ChildContent',
+ components: {
+ GlBadge,
+ GlLink,
+ StatusIcon,
+ Actions,
+ },
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ props: {
+ data: {
+ type: Object,
+ required: true,
+ },
+ widgetLabel: {
+ type: String,
+ required: true,
+ },
+ level: {
+ type: Number,
+ required: true,
+ },
+ },
+ methods: {
+ isArray(arr) {
+ return Array.isArray(arr);
+ },
+ generateText,
+ },
+};
+</script>
+
+<template>
+ <div :class="{ 'gl-pl-6': level === 3 }" class="gl-w-full">
+ <div v-if="data.header" class="gl-mb-2">
+ <template v-if="isArray(data.header)">
+ <component
+ :is="headerI === 0 ? 'strong' : 'span'"
+ v-for="(header, headerI) in data.header"
+ :key="headerI"
+ v-safe-html="generateText(header)"
+ class="gl-display-block"
+ />
+ </template>
+ <strong v-else v-safe-html="generateText(data.header)"></strong>
+ </div>
+ <div class="gl-display-flex">
+ <status-icon v-if="data.icon" :icon-name="data.icon.name" :size="12" class="gl-pl-0" />
+ <div class="gl-w-full">
+ <div class="gl-flex-wrap gl-display-flex gl-w-full">
+ <div class="gl-mr-4 gl-display-flex gl-align-items-center">
+ <p v-safe-html="generateText(data.text)" class="gl-m-0"></p>
+ </div>
+ <div v-if="data.link">
+ <gl-link :href="data.link.href">{{ data.link.text }}</gl-link>
+ </div>
+ <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="widgetLabel" :tertiary-buttons="data.actions" class="gl-ml-auto" />
+ </div>
+ <p
+ v-if="data.subtext"
+ v-safe-html="generateText(data.subtext)"
+ class="gl-m-0 gl-font-sm"
+ ></p>
+ </div>
+ </div>
+ <template v-if="data.children && level === 2">
+ <ul class="gl-m-0 gl-p-0 gl-list-style-none">
+ <li>
+ <child-content
+ v-for="childData in data.children"
+ :key="childData.id"
+ :data="childData"
+ :widget-label="widgetLabel"
+ :level="3"
+ data-testid="child-content"
+ />
+ </li>
+ </ul>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
index b75f2dce54e..f5667aee15b 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
@@ -70,7 +70,9 @@ export default {
<template v-if="isCollapsed">
<slot name="header"></slot>
<gl-button
- variant="link"
+ category="tertiary"
+ variant="confirm"
+ size="small"
data-testid="mr-collapsible-title"
:disabled="isLoading"
:class="{ 'border-0': isLoading }"
@@ -81,7 +83,9 @@ export default {
</template>
<gl-button
v-else
- variant="link"
+ category="tertiary"
+ variant="confirm"
+ size="small"
data-testid="mr-collapsible-title"
:disabled="isLoading"
:class="{ 'border-0': isLoading }"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
index 68cff1368af..b062833cdf8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
@@ -1,6 +1,7 @@
<script>
/* eslint-disable @gitlab/require-i18n-strings */
import { GlModal, GlLink, GlSprintf } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { escapeShellString } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -10,24 +11,26 @@ export default {
steps: {
step1: {
label: __('Step 1.'),
- help: __('Fetch and check out the branch for this merge request'),
+ help: __("Fetch and check out this merge request's feature branch:"),
},
step2: {
label: __('Step 2.'),
- help: __('Review the changes locally'),
+ help: __('Review the changes locally.'),
},
step3: {
label: __('Step 3.'),
- help: __('Merge the branch and fix any conflicts that come up'),
+ help: __(
+ 'Merge the feature branch into the target branch and fix any conflicts. %{linkStart}How do I fix them?%{linkEnd}',
+ ),
},
step4: {
label: __('Step 4.'),
- help: __('Push the result of the merge to GitLab'),
+ help: __('Push the target branch up to GitLab.'),
},
},
copyCommands: __('Copy commands'),
tip: __(
- '%{strongStart}Tip:%{strongEnd} You can also checkout merge requests locally by %{linkStart}following these guidelines%{linkEnd}',
+ '%{strongStart}Tip:%{strongEnd} You can also check out merge requests locally. %{linkStart}Learn more.%{linkEnd}',
),
title: __('Check out, review, and merge locally'),
},
@@ -74,6 +77,13 @@ export default {
default: null,
},
},
+ data() {
+ return {
+ resolveConflictsFromCli: helpPagePath('ee/user/project/merge_requests/conflicts.html', {
+ anchor: 'resolve-conflicts-from-the-command-line',
+ }),
+ };
+ },
computed: {
mergeInfo1() {
const escapedOriginBranch = escapeShellString(`origin/${this.sourceBranch}`);
@@ -138,7 +148,13 @@ export default {
<strong>
{{ $options.i18n.steps.step3.label }}
</strong>
- {{ $options.i18n.steps.step3.help }}
+ <gl-sprintf :message="$options.i18n.steps.step3.help">
+ <template #link="{ content }">
+ <gl-link class="gl-display-inline-block" :href="resolveConflictsFromCli">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
</p>
<div class="gl-display-flex">
<pre class="gl-w-full" data-testid="how-to-merge-instructions">{{ mergeInfo2 }}</pre>
@@ -163,7 +179,7 @@ export default {
/>
</div>
<p v-if="reviewingDocsPath">
- <gl-sprintf :message="$options.i18n.tip">
+ <gl-sprintf data-testid="docs-tip" :message="$options.i18n.tip">
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
index 730d11b1208..2cef37d5c2e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
@@ -1,5 +1,5 @@
<script>
-import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import { GlSafeHtmlDirective as SafeHtml, GlLink } from '@gitlab/ui';
import { s__, n__ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -8,6 +8,9 @@ export default {
directives: {
SafeHtml,
},
+ components: {
+ GlLink,
+ },
mixins: [glFeatureFlagMixin()],
props: {
relatedLinks: {
@@ -37,6 +40,17 @@ export default {
return n__('mrWidget|Closes issue', 'mrWidget|Closes issues', this.relatedLinks.closingCount);
},
+ assignIssueText() {
+ if (this.relatedLinks.unassignedCount > 1) {
+ return s__('mrWidget|Assign yourself to these issues');
+ }
+ return s__('mrWidget|Assign yourself to this issue');
+ },
+ shouldShowAssignToMeLink() {
+ return (
+ this.relatedLinks.unassignedCount && this.relatedLinks.assignToMe && this.showAssignToMe
+ );
+ },
},
};
</script>
@@ -44,23 +58,28 @@ export default {
<section>
<p
v-if="relatedLinks.closing"
- :class="{ 'gl-display-line gl-m-0': glFeatures.restructuredMrWidget }"
+ :class="{ 'gl-display-inline gl-m-0': glFeatures.restructuredMrWidget }"
>
{{ closesText }}
<span v-safe-html="relatedLinks.closing"></span>
</p>
<p
v-if="relatedLinks.mentioned"
- :class="{ 'gl-display-line gl-m-0': glFeatures.restructuredMrWidget }"
+ :class="{ 'gl-display-inline gl-m-0': glFeatures.restructuredMrWidget }"
>
+ <span v-if="relatedLinks.closing && glFeatures.restructuredMrWidget">&middot;</span>
{{ n__('mrWidget|Mentions issue', 'mrWidget|Mentions issues', relatedLinks.mentionedCount) }}
<span v-safe-html="relatedLinks.mentioned"></span>
</p>
<p
- v-if="relatedLinks.assignToMe && showAssignToMe"
- :class="{ 'gl-display-line gl-m-0': glFeatures.restructuredMrWidget }"
+ v-if="shouldShowAssignToMeLink"
+ :class="{ 'gl-display-inline gl-m-0': glFeatures.restructuredMrWidget }"
>
- <span v-html="relatedLinks.assignToMe /* eslint-disable-line vue/no-v-html */"></span>
+ <span>
+ <gl-link rel="nofollow" data-method="post" :href="relatedLinks.assignToMe">{{
+ assignIssueText
+ }}</gl-link>
+ </span>
</p>
</section>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue
index 73d75352cb5..5baeb309f79 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue
@@ -21,7 +21,9 @@ export default {
<gl-dropdown
right
text="Use an existing commit message"
- variant="link"
+ category="tertiary"
+ variant="confirm"
+ size="small"
class="mr-commit-dropdown"
>
<gl-dropdown-item
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
index 5c4a526bcc3..400759aa086 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
@@ -77,7 +77,7 @@ export default {
:target-branch="targetBranch"
/>
</span>
- <gl-button variant="link" class="modify-message-button">
+ <gl-button category="tertiary" variant="confirm" size="small" class="modify-message-button">
{{ modifyLinkMessage }}
</gl-button>
</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
index a2c9cfe53cc..7435f578852 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
@@ -82,17 +82,8 @@ export default {
return this.mr.shouldBeRebased;
},
- sourceBranchProtected() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- return this.stateData.sourceBranchProtected;
- }
-
- return this.mr.sourceBranchProtected;
- },
showResolveButton() {
- return (
- this.mr.conflictResolutionPath && this.canPushToSourceBranch && !this.sourceBranchProtected
- );
+ return this.mr.conflictResolutionPath && this.canPushToSourceBranch;
},
},
};
@@ -144,7 +135,7 @@ export default {
:size="glFeatures.restructuredMrWidget ? 'small' : 'medium'"
data-testid="merge-locally-button"
>
- {{ s__('mrWidget|Merge locally') }}
+ {{ s__('mrWidget|Resolve locally') }}
</gl-button>
</template>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
index bb0fb410d3e..ebdc8309cd5 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
@@ -3,13 +3,11 @@ import { GlButton, GlSkeletonLoader } from '@gitlab/ui';
import createFlash from '~/flash';
import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import ActionsButton from '~/vue_shared/components/actions_button.vue';
import simplePoll from '../../../lib/utils/simple_poll';
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import rebaseQuery from '../../queries/states/rebase.query.graphql';
import statusIcon from '../mr_widget_status_icon.vue';
-import { REBASE_BUTTON_KEY, REBASE_WITHOUT_CI_BUTTON_KEY } from '../../constants';
export default {
name: 'MRWidgetRebase',
@@ -28,7 +26,6 @@ export default {
components: {
statusIcon,
GlSkeletonLoader,
- ActionsButton,
GlButton,
},
mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
@@ -47,7 +44,6 @@ export default {
state: {},
isMakingRequest: false,
rebasingError: null,
- selectedRebaseAction: REBASE_BUTTON_KEY,
};
},
computed: {
@@ -93,28 +89,6 @@ export default {
fastForwardMergeText() {
return __('Merge blocked: the source branch must be rebased onto the target branch.');
},
- actions() {
- return [this.rebaseAction, this.rebaseWithoutCiAction].filter((action) => action);
- },
- rebaseAction() {
- return {
- key: REBASE_BUTTON_KEY,
- text: __('Rebase'),
- secondaryText: __('Rebases and triggers a pipeline'),
- attrs: {
- 'data-qa-selector': 'mr_rebase_button',
- },
- handle: () => this.rebase(),
- };
- },
- rebaseWithoutCiAction() {
- return {
- key: REBASE_WITHOUT_CI_BUTTON_KEY,
- text: __('Rebase without CI'),
- secondaryText: __('Performs a rebase but skips triggering a new pipeline'),
- handle: () => this.rebase({ skipCi: true }),
- };
- },
},
methods: {
rebase({ skipCi = false } = {}) {
@@ -138,8 +112,8 @@ export default {
}
});
},
- selectRebaseAction(key) {
- this.selectedRebaseAction = key;
+ rebaseWithoutCi() {
+ return this.rebase({ skipCi: true });
},
checkRebaseStatus(continuePolling, stopPolling) {
this.service
@@ -198,10 +172,10 @@ export default {
>
<div
v-if="!rebaseInProgress && canPushToSourceBranch && !isMakingRequest"
- class="accept-merge-holder clearfix js-toggle-container accept-action media space-children"
+ class="accept-merge-holder clearfix js-toggle-container accept-action media space-children gl-align-items-center"
>
<gl-button
- v-if="!glFeatures.restructuredMrWidget && !showRebaseWithoutCi"
+ v-if="!glFeatures.restructuredMrWidget"
:loading="isMakingRequest"
variant="confirm"
data-qa-selector="mr_rebase_button"
@@ -210,14 +184,16 @@ export default {
>
{{ __('Rebase') }}
</gl-button>
- <actions-button
+ <gl-button
v-if="!glFeatures.restructuredMrWidget && showRebaseWithoutCi"
- :actions="actions"
- :selected-key="selectedRebaseAction"
+ :loading="isMakingRequest"
variant="confirm"
- category="primary"
- @select="selectRebaseAction"
- />
+ category="secondary"
+ data-testid="rebase-without-ci-button"
+ @click="rebaseWithoutCi"
+ >
+ {{ __('Rebase without pipeline') }}
+ </gl-button>
<span
v-if="!rebasingError"
:class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }"
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 bc094501e89..4f8faeb877f 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
@@ -82,6 +82,13 @@ export default {
};
this.loading = false;
+ if (!this.commitMessageIsTouched) {
+ this.commitMessage = this.state.defaultMergeCommitMessage;
+ }
+ if (!this.squashCommitMessageIsTouched) {
+ this.squashCommitMessage = this.state.defaultSquashCommitMessage;
+ }
+
if (this.state.mergeTrainsCount !== null && this.state.mergeTrainsCount !== undefined) {
this.initPolling();
}
@@ -133,9 +140,11 @@ export default {
isMakingRequest: false,
isMergingImmediately: false,
commitMessage: this.mr.commitMessage,
+ commitMessageIsTouched: false,
squashBeforeMerge: this.mr.squashIsSelected,
isSquashReadOnly: this.mr.squashIsReadonly,
squashCommitMessage: this.mr.squashCommitMessage,
+ squashCommitMessageIsTouched: false,
isPipelineFailedModalVisibleMergeTrain: false,
isPipelineFailedModalVisibleNormalMerge: false,
editCommitMessage: false,
@@ -295,13 +304,6 @@ export default {
return enableSquashBeforeMerge;
},
- shouldShowMergeControls() {
- if (this.glFeatures.restructuredMrWidget) {
- return this.restructuredWidgetShowMergeButtons;
- }
-
- return this.isMergeAllowed || this.isAutoMergeAvailable;
- },
shouldShowSquashEdit() {
return this.squashBeforeMerge && this.shouldShowSquashBeforeMerge;
},
@@ -472,6 +474,14 @@ export default {
});
});
},
+ setCommitMessage(val) {
+ this.commitMessage = val;
+ this.commitMessageIsTouched = true;
+ },
+ setSquashCommitMessage(val) {
+ this.squashCommitMessage = val;
+ this.squashCommitMessageIsTouched = true;
+ },
},
i18n: {
mergeCommitTemplateHintText: s__(
@@ -637,21 +647,23 @@ export default {
>
<commit-edit
v-if="shouldShowSquashEdit"
- v-model="squashCommitMessage"
+ :value="squashCommitMessage"
:label="__('Squash commit message')"
input-id="squash-message-edit"
class="gl-m-0! gl-p-0!"
+ @input="setSquashCommitMessage"
>
<template #header>
- <commit-message-dropdown v-model="squashCommitMessage" :commits="commits" />
+ <commit-message-dropdown :commits="commits" @input="setSquashCommitMessage" />
</template>
</commit-edit>
<commit-edit
v-if="shouldShowMergeEdit"
- v-model="commitMessage"
+ :value="commitMessage"
:label="__('Merge commit message')"
input-id="merge-message-edit"
class="gl-m-0! gl-p-0!"
+ @input="setCommitMessage"
/>
<li class="gl-m-0! gl-p-0!">
<p class="form-text text-muted">
@@ -755,20 +767,22 @@ export default {
<ul class="border-top content-list commits-list flex-list">
<commit-edit
v-if="shouldShowSquashEdit"
- v-model="squashCommitMessage"
+ :value="squashCommitMessage"
:label="__('Squash commit message')"
input-id="squash-message-edit"
squash
+ @input="setSquashCommitMessage"
>
<template #header>
- <commit-message-dropdown v-model="squashCommitMessage" :commits="commits" />
+ <commit-message-dropdown :commits="commits" @input="setSquashCommitMessage" />
</template>
</commit-edit>
<commit-edit
v-if="shouldShowMergeEdit"
- v-model="commitMessage"
+ :value="commitMessage"
:label="__('Merge commit message')"
input-id="merge-message-edit"
+ @input="setCommitMessage"
/>
<li>
<p class="form-text text-muted">
diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js
index d337a554663..533bb38a88c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/constants.js
@@ -166,6 +166,3 @@ export const EXTENSION_SUMMARY_FAILED_CLASS = 'gl-text-red-500';
export const EXTENSION_SUMMARY_NEUTRAL_CLASS = 'gl-text-gray-700';
export { STATE_MACHINE };
-
-export const REBASE_BUTTON_KEY = 'rebase';
-export const REBASE_WITHOUT_CI_BUTTON_KEY = 'rebaseWithoutCi';
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js
new file mode 100644
index 00000000000..d32db50874c
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js
@@ -0,0 +1,123 @@
+import { n__, s__, sprintf } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
+import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants';
+import { SEVERITY_ICONS_EXTENSION } from '~/reports/codequality_report/constants';
+import { parseCodeclimateMetrics } from '~/reports/codequality_report/store/utils/codequality_parser';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+
+export default {
+ name: 'WidgetCodeQuality',
+ props: ['codeQuality', 'blobPath'],
+ i18n: {
+ label: s__('ciReport|Code Quality'),
+ loading: s__('ciReport|Code Quality test metrics results are being parsed'),
+ error: s__('ciReport|Code Quality failed loading results'),
+ },
+ expandEvent: 'i_testing_code_quality_widget_total',
+ computed: {
+ summary() {
+ const { newErrors, resolvedErrors, errorSummary } = this.collapsedData;
+ if (errorSummary.errored >= 1 && errorSummary.resolved >= 1) {
+ const improvements = sprintf(
+ n__(
+ '%{strongOpen}%{errors}%{strongClose} point',
+ '%{strongOpen}%{errors}%{strongClose} points',
+ resolvedErrors.length,
+ ),
+ {
+ errors: resolvedErrors.length,
+ strongOpen: '<strong>',
+ strongClose: '</strong>',
+ },
+ false,
+ );
+
+ const degradations = sprintf(
+ n__(
+ '%{strongOpen}%{errors}%{strongClose} point',
+ '%{strongOpen}%{errors}%{strongClose} points',
+ newErrors.length,
+ ),
+ { errors: newErrors.length, strongOpen: '<strong>', strongClose: '</strong>' },
+ false,
+ );
+ return sprintf(
+ s__(`ciReport|Code Quality improved on ${improvements} and degraded on ${degradations}.`),
+ );
+ } else if (errorSummary.resolved >= 1) {
+ const improvements = n__('%d point', '%d points', resolvedErrors.length);
+ return sprintf(s__(`ciReport|Code Quality improved on ${improvements}.`));
+ } else if (errorSummary.errored >= 1) {
+ const degradations = n__('%d point', '%d points', newErrors.length);
+ return sprintf(s__(`ciReport|Code Quality degraded on ${degradations}.`));
+ }
+ return s__(`ciReport|No changes to Code Quality.`);
+ },
+ statusIcon() {
+ if (this.collapsedData.errorSummary?.errored >= 1) {
+ return EXTENSION_ICONS.warning;
+ }
+ return EXTENSION_ICONS.success;
+ },
+ },
+ methods: {
+ fetchCollapsedData() {
+ return Promise.all([this.fetchReport(this.codeQuality)]).then((values) => {
+ return {
+ resolvedErrors: parseCodeclimateMetrics(
+ values[0].resolved_errors,
+ this.blobPath.head_path,
+ ),
+ newErrors: parseCodeclimateMetrics(values[0].new_errors, this.blobPath.head_path),
+ existingErrors: parseCodeclimateMetrics(
+ values[0].existing_errors,
+ this.blobPath.head_path,
+ ),
+ errorSummary: values[0].summary,
+ };
+ });
+ },
+ fetchFullData() {
+ const fullData = [];
+
+ this.collapsedData.newErrors.map((e) => {
+ return fullData.push({
+ text: `${capitalizeFirstCharacter(e.severity)} - ${e.description}`,
+ subtext: sprintf(
+ s__(`ciReport|in %{open_link}${e.file_path}:${e.line}%{close_link}`),
+ {
+ open_link: `<a class="gl-text-decoration-underline" href="${e.urlPath}">`,
+ close_link: '</a>',
+ },
+ false,
+ ),
+ icon: {
+ name: SEVERITY_ICONS_EXTENSION[e.severity],
+ },
+ });
+ });
+
+ this.collapsedData.resolvedErrors.map((e) => {
+ return fullData.push({
+ text: `${capitalizeFirstCharacter(e.severity)} - ${e.description}`,
+ subtext: sprintf(
+ s__(`ciReport|in %{open_link}${e.file_path}:${e.line}%{close_link}`),
+ {
+ open_link: `<a class="gl-text-decoration-underline" href="${e.urlPath}">`,
+ close_link: '</a>',
+ },
+ false,
+ ),
+ icon: {
+ name: SEVERITY_ICONS_EXTENSION[e.severity],
+ },
+ });
+ });
+
+ return Promise.resolve(fullData);
+ },
+ fetchReport(endpoint) {
+ return axios.get(endpoint).then((res) => res.data);
+ },
+ },
+};
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 4aeebf095c4..e52f2c2c666 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
@@ -88,6 +88,16 @@ export default {
// text: 'Link text', // Required: Text to be used inside the link
// },
actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }],
+ children: [
+ {
+ id: `child-${issue.id}`,
+ header: 'New',
+ text: '%{critical_start}1 Critical%{critical_end}',
+ icon: {
+ name: EXTENSION_ICONS.error,
+ },
+ },
+ ],
}));
});
},
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 247a3711fc8..627ddb0445e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
@@ -1,8 +1,6 @@
import { __ } from '~/locale';
-export const MERGE_DISABLED_TEXT = __(
- 'Merge blocked: all merge request dependencies must be merged or closed.',
-);
+export const MERGE_DISABLED_TEXT = __('You can only merge once the items above are resolved.');
export const MERGE_DISABLED_SKIPPED_PIPELINE_TEXT = __(
"Merge blocked: pipeline must succeed. It's waiting for a manual job to continue.",
);
@@ -22,6 +20,13 @@ export default {
this.mr.preventMerge,
);
},
+ shouldShowMergeControls() {
+ if (this.glFeatures.restructuredMrWidget) {
+ return this.restructuredWidgetShowMergeButtons;
+ }
+
+ return this.isMergeAllowed || this.isAutoMergeAvailable;
+ },
mergeDisabledText() {
if (this.pipeline?.status === PIPELINE_SKIPPED_STATUS) {
return MERGE_DISABLED_SKIPPED_PIPELINE_TEXT;
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 11de58aa344..965746e79fb 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
@@ -46,6 +46,7 @@ import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variab
import getStateQuery from './queries/get_state.query.graphql';
import terraformExtension from './extensions/terraform';
import accessibilityExtension from './extensions/accessibility';
+import codeQualityExtension from './extensions/code_quality';
export default {
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
@@ -241,6 +242,11 @@ export default {
this.registerTerraformPlans();
}
},
+ shouldRenderCodeQuality(newVal) {
+ if (newVal) {
+ this.registerCodeQualityExtension();
+ }
+ },
shouldShowAccessibilityReport(newVal) {
if (newVal) {
this.registerAccessibilityExtension();
@@ -352,6 +358,8 @@ export default {
return Promise.resolve();
},
initPolling() {
+ if (this.startingPollInterval <= 0) return;
+
this.pollingInterval = new SmartInterval({
callback: this.checkStatus,
startingInterval: this.startingPollInterval,
@@ -435,10 +443,10 @@ export default {
notify.notifyMe(title, message, this.mr.gitlabLogo);
},
resumePolling() {
- this.pollingInterval.resume();
+ this.pollingInterval?.resume();
},
stopPolling() {
- this.pollingInterval.stopTimer();
+ this.pollingInterval?.stopTimer();
},
bindEventHubListeners() {
eventHub.$on('MRWidgetUpdateRequested', (cb) => {
@@ -489,6 +497,11 @@ export default {
registerExtension(accessibilityExtension);
}
},
+ registerCodeQualityExtension() {
+ if (this.shouldRenderCodeQuality && this.shouldShowExtension) {
+ registerExtension(codeQualityExtension);
+ }
+ },
},
};
</script>
@@ -544,7 +557,7 @@ export default {
</div>
<extensions-container :mr="mr" />
<grouped-codequality-reports-app
- v-if="shouldRenderCodeQuality"
+ v-if="shouldRenderCodeQuality && !shouldShowExtension"
:head-blob-path="mr.headBlobPath"
:base-blob-path="mr.baseBlobPath"
:codequality-reports-path="mr.codequalityReportsPath"
@@ -574,7 +587,7 @@ export default {
/>
<grouped-accessibility-reports-app
- v-if="shouldShowAccessibilityReport"
+ v-if="shouldShowAccessibilityReport && !shouldShowExtension"
:endpoint="mr.accessibilityReportPath"
/>
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql
index d85794f7245..99e6f4e9beb 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql
@@ -1,9 +1,11 @@
fragment ReadyToMerge on Project {
+ __typename
id
onlyAllowMergeIfPipelineSucceeds
mergeRequestsFfOnlyEnabled
squashReadOnly
mergeRequest(iid: $iid) {
+ __typename
id
autoMergeEnabled
shouldRemoveSourceBranch
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index 5378dabf638..eb07609d5d6 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -32,9 +32,15 @@ export default class MergeRequestStore {
this.setPaths(data);
this.setData(data);
+ this.initCodeQualityReport(data);
this.setGitpodData(data);
}
+ initCodeQualityReport(data) {
+ this.blobPath = data.blob_path;
+ this.codeQuality = data.codequality_reports_path;
+ }
+
setData(data, isRebased) {
this.initApprovals();
@@ -82,14 +88,16 @@ export default class MergeRequestStore {
const { closing } = links;
const mentioned = links.mentioned_but_not_closing;
const assignToMe = links.assign_to_closing;
+ const unassignedCount = links.assign_to_closing_count;
- if (closing || mentioned || assignToMe) {
+ if (closing || mentioned || unassignedCount) {
this.relatedLinks = {
closing,
mentioned,
assignToMe,
closingCount: links.closing_count,
mentionedCount: links.mentioned_count,
+ unassignedCount: links.assign_to_closing_count,
};
}
}
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
index b6010d4b70c..96970f4ce2f 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -199,12 +199,15 @@ export default {
<div v-if="canAwardEmoji" class="award-menu-holder gl-my-2">
<emoji-picker
v-if="glFeatures.improvedEmojiPicker"
+ v-gl-tooltip.viewport
+ :title="__('Add reaction')"
:toggle-class="['add-reaction-button btn-icon gl-relative!', { 'is-active': isMenuOpen }]"
@click="handleAward"
@shown="setIsMenuOpen(true)"
@hidden="setIsMenuOpen(false)"
>
<template #button-content>
+ <span class="gl-sr-only">{{ __('Add reaction') }}</span>
<span class="reaction-control-icon reaction-control-icon-neutral">
<gl-icon name="slight-smile" />
</span>
diff --git a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue
index 7563c35dfc8..7a166f9a3e4 100644
--- a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue
+++ b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue
@@ -7,6 +7,7 @@
:invalid-feedback="__('Please enter a valid hex (#RRGGBB or #RGB) color value')"
:label="__('Background color')"
:value="#FF0000"
+ :suggestedColors="{ '#ff0000': 'Red', '#808080': 'Gray' }",
state="isValidColor"
/>
*/
@@ -48,6 +49,11 @@ export default {
required: false,
default: null,
},
+ suggestedColors: {
+ type: Object,
+ required: false,
+ default: () => gon.suggested_label_colors,
+ },
},
computed: {
description() {
@@ -55,9 +61,6 @@ export default {
? this.$options.i18n.fullDescription
: this.$options.i18n.shortDescription;
},
- suggestedColors() {
- return gon.suggested_label_colors;
- },
previewColor() {
if (this.state) {
return { backgroundColor: this.value };
diff --git a/app/assets/javascripts/vue_shared/components/content_transition.vue b/app/assets/javascripts/vue_shared/components/content_transition.vue
new file mode 100644
index 00000000000..446610d6b91
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/content_transition.vue
@@ -0,0 +1,32 @@
+<script>
+export default {
+ props: {
+ currentSlot: {
+ type: String,
+ required: true,
+ },
+ slots: {
+ type: Array,
+ required: true,
+ },
+ transitionName: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ shouldShow(key) {
+ return this.currentSlot === key;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <transition v-for="{ key, attributes } in slots" :key="key" :name="transitionName">
+ <div v-show="shouldShow(key)" v-bind="attributes">
+ <slot :name="key"></slot>
+ </div>
+ </transition>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue
index 153b0981813..2a79ccc2648 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue
+++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue
@@ -1,22 +1,28 @@
<script>
import {
+ GlIcon,
GlLoadingIcon,
GlDropdown,
GlDropdownForm,
GlDropdownDivider,
GlDropdownItem,
+ GlDropdownSectionHeader,
GlSearchBoxByType,
} from '@gitlab/ui';
import { __ } from '~/locale';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
export default {
components: {
+ GlIcon,
GlLoadingIcon,
GlDropdown,
GlDropdownForm,
GlDropdownDivider,
GlDropdownItem,
+ GlDropdownSectionHeader,
GlSearchBoxByType,
+ TooltipOnTruncate,
},
props: {
selectText: {
@@ -39,6 +45,11 @@ export default {
required: false,
default: () => [],
},
+ groupedOptions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
isLoading: {
type: Boolean,
required: false,
@@ -79,11 +90,7 @@ export default {
if (Array.isArray(this.selected)) {
return this.selected.some((label) => label.title === option.title);
}
- return (
- this.selected &&
- ((option.name && this.selected.name === option.name) ||
- (option.title && this.selected.title === option.title))
- );
+ return this.selected && option.id && this.selected.id === option.id;
},
showDropdown() {
this.$refs.dropdown.show();
@@ -101,6 +108,9 @@ export default {
// TODO: this has some knowledge of the context where the component is used. We could later rework it.
return option.username || null;
},
+ optionKey(option) {
+ return option.key ? option.key : option.id;
+ },
},
i18n: {
noMatchingResults: __('No matching results'),
@@ -154,10 +164,10 @@ export default {
</template>
<gl-dropdown-item
v-for="option in options"
- :key="option.id"
+ :key="optionKey(option)"
:is-checked="isSelected(option)"
- :is-check-centered="true"
- :is-check-item="true"
+ is-check-centered
+ is-check-item
:avatar-url="avatarUrl(option)"
:secondary-text="secondaryText(option)"
data-testid="unselected-option"
@@ -167,6 +177,36 @@ export default {
{{ option.title }}
</slot>
</gl-dropdown-item>
+ <template v-for="(optionGroup, index) in groupedOptions">
+ <gl-dropdown-divider v-if="index !== 0" :key="index" />
+ <gl-dropdown-section-header :key="optionGroup.id">
+ <div class="gl-display-flex gl-max-w-full">
+ <tooltip-on-truncate
+ :title="optionGroup.title"
+ class="gl-text-truncate gl-flex-grow-1"
+ >
+ {{ optionGroup.title }}
+ </tooltip-on-truncate>
+ <span v-if="optionGroup.secondaryText" class="gl-float-right gl-font-weight-normal">
+ <gl-icon name="clock" class="gl-mr-2" />
+ {{ optionGroup.secondaryText }}
+ </span>
+ </div>
+ </gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="option in optionGroup.options"
+ :key="optionKey(option)"
+ :is-checked="isSelected(option)"
+ is-check-centered
+ is-check-item
+ data-testid="unselected-option"
+ @click="selectOption(option)"
+ >
+ <slot name="item" :item="option">
+ {{ option.title }}
+ </slot>
+ </gl-dropdown-item>
+ </template>
<gl-dropdown-item v-if="noOptionsFound" class="gl-pl-6!">
{{ $options.i18n.noMatchingResults }}
</gl-dropdown-item>
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 157068b2c0f..e7923e0b55e 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
@@ -76,9 +76,10 @@ export default {
},
data() {
return {
+ hasFetched: false, // use this to avoid flash of `No suggestions found` before fetching
searchKey: '',
recentSuggestions: this.config.recentSuggestionsStorageKey
- ? getRecentlyUsedSuggestions(this.config.recentSuggestionsStorageKey)
+ ? getRecentlyUsedSuggestions(this.config.recentSuggestionsStorageKey) ?? []
: [],
};
},
@@ -86,6 +87,9 @@ export default {
isRecentSuggestionsEnabled() {
return Boolean(this.config.recentSuggestionsStorageKey);
},
+ suggestionsEnabled() {
+ return !this.config.suggestionsDisabled;
+ },
recentTokenIds() {
return this.recentSuggestions.map((tokenValue) => tokenValue[this.valueIdentifier]);
},
@@ -134,17 +138,6 @@ export default {
showAvailableSuggestions() {
return this.availableSuggestions.length > 0;
},
- showSuggestions() {
- // These conditions must match the template under `#suggestions` slot
- // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/65817#note_632619411
- return (
- this.showDefaultSuggestions ||
- this.showRecentSuggestions ||
- this.showPreloadedSuggestions ||
- this.suggestionsLoading ||
- this.showAvailableSuggestions
- );
- },
searchTerm() {
return this.searchBy && this.activeTokenValue
? this.activeTokenValue[this.searchBy]
@@ -161,6 +154,13 @@ export default {
}
},
},
+ suggestionsLoading: {
+ handler(loading) {
+ if (loading) {
+ this.hasFetched = true;
+ }
+ },
+ },
},
methods: {
handleInput: debounce(function debouncedSearch({ data, operator }) {
@@ -216,7 +216,7 @@ export default {
<template #view="viewTokenProps">
<slot name="view" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot>
</template>
- <template v-if="showSuggestions" #suggestions>
+ <template v-if="suggestionsEnabled" #suggestions>
<template v-if="showDefaultSuggestions">
<gl-filtered-search-suggestion
v-for="token in availableDefaultSuggestions"
@@ -238,12 +238,13 @@ export default {
:suggestions="preloadedSuggestions"
></slot>
<gl-loading-icon v-if="suggestionsLoading" size="sm" />
+ <template v-else-if="showAvailableSuggestions">
+ <slot name="suggestions-list" :suggestions="availableSuggestions"></slot>
+ </template>
<gl-dropdown-text v-else-if="showNoMatchesText">
{{ __('No matches found') }}
</gl-dropdown-text>
- <template v-else>
- <slot name="suggestions-list" :suggestions="availableSuggestions"></slot>
- </template>
+ <gl-dropdown-text v-else-if="hasFetched">{{ __('No suggestions found') }}</gl-dropdown-text>
</template>
</gl-filtered-search-token>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index cbf38984e23..e1020ce656b 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -48,6 +48,11 @@ export default {
required: false,
default: '',
},
+ enablePreview: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
addSpacingClasses: {
type: Boolean,
required: false,
@@ -113,6 +118,7 @@ export default {
markdownPreviewLoading: false,
previewMarkdown: false,
suggestions: this.note.suggestions || [],
+ debouncedFetchMarkdownLoading: false,
};
},
computed: {
@@ -198,12 +204,22 @@ export default {
const justRemovedAll = hadAll && !hasAll;
if (justAddedAll) {
+ this.debouncedFetchMarkdownLoading = false;
this.debouncedFetchMarkdown();
} else if (justRemovedAll) {
+ this.debouncedFetchMarkdownLoading = true;
this.referencedUsers = [];
}
},
},
+ enablePreview: {
+ immediate: true,
+ handler(newVal) {
+ if (!newVal) {
+ this.showWriteTab();
+ }
+ },
+ },
},
mounted() {
// GLForm class handles all the toolbar buttons
@@ -271,7 +287,12 @@ export default {
},
debouncedFetchMarkdown: debounce(function debouncedFetchMarkdown() {
- return this.fetchMarkdown();
+ return this.fetchMarkdown().then(() => {
+ if (this.debouncedFetchMarkdownLoading) {
+ this.referencedUsers = [];
+ this.debouncedFetchMarkdownLoading = false;
+ }
+ });
}, 400),
renderMarkdown(data = {}) {
@@ -301,6 +322,7 @@ export default {
:preview-markdown="previewMarkdown"
:line-content="lineContent"
:can-suggest="canSuggest"
+ :enable-preview="enablePreview"
:show-suggest-popover="showSuggestPopover"
:suggestion-start-index="suggestionsStartIndex"
data-testid="markdownHeader"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 3b99afa9e3d..13189670e17 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -1,7 +1,13 @@
<script>
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 {
+ keysFor,
+ BOLD_TEXT,
+ ITALIC_TEXT,
+ STRIKETHROUGH_TEXT,
+ LINK_TEXT,
+} from '~/behaviors/shortcuts/keybindings';
import { getSelectedFragment } from '~/lib/utils/common_utils';
import { s__, __ } from '~/locale';
import { CopyAsGFM } from '../../../behaviors/markdown/copy_as_gfm';
@@ -43,6 +49,11 @@ export default {
required: false,
default: 0,
},
+ enablePreview: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -144,6 +155,7 @@ export default {
shortcuts: {
bold: keysFor(BOLD_TEXT),
italic: keysFor(ITALIC_TEXT),
+ strikethrough: keysFor(STRIKETHROUGH_TEXT),
link: keysFor(LINK_TEXT),
},
i18n: {
@@ -164,6 +176,7 @@ export default {
@click="writeMarkdownTab($event)"
/>
<gl-tab
+ v-if="enablePreview"
title-link-class="gl-pt-3 gl-px-3 js-md-preview-button"
:title="$options.i18n.previewTabTitle"
:active="previewMarkdown"
@@ -194,6 +207,16 @@ export default {
icon="italic"
/>
<toolbar-button
+ tag="~~"
+ :button-title="
+ sprintf(s__('MarkdownEditor|Add strikethrough text (%{modifierKey}⇧X)'), {
+ modifierKey,
+ })
+ "
+ :shortcuts="$options.shortcuts.strikethrough"
+ icon="strikethrough"
+ />
+ <toolbar-button
:prepend="true"
:tag="tag"
:button-title="__('Insert a quote')"
diff --git a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
index 0b302f22062..7a7074da084 100644
--- a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
@@ -1,12 +1,7 @@
<script>
-import { GlLink, GlIcon } from '@gitlab/ui';
-import { escape } from 'lodash';
+import { GlLink, GlIcon, GlSprintf } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
-function buildDocsLinkStart(path) {
- return `<a href="${escape(path)}" target="_blank" rel="noopener noreferrer">`;
-}
-
const NoteableTypeText = {
Issue: __('issue'),
Epic: __('epic'),
@@ -17,6 +12,7 @@ export default {
components: {
GlIcon,
GlLink,
+ GlSprintf,
},
props: {
isLocked: {
@@ -59,20 +55,6 @@ export default {
noteableTypeText() {
return NoteableTypeText[this.noteableType];
},
- confidentialAndLockedDiscussionText() {
- return sprintf(
- __(
- 'This %{noteableTypeText} is %{confidentialLinkStart}confidential%{linkEnd} and %{lockedLinkStart}locked%{linkEnd}.',
- ),
- {
- noteableTypeText: this.noteableTypeText,
- confidentialLinkStart: buildDocsLinkStart(this.confidentialNoteableDocsPath),
- lockedLinkStart: buildDocsLinkStart(this.lockedNoteableDocsPath),
- linkEnd: '</a>',
- },
- false,
- );
- },
confidentialContextText() {
return sprintf(__('This is a confidential %{noteableTypeText}.'), {
noteableTypeText: this.noteableTypeText,
@@ -91,9 +73,23 @@ export default {
<gl-icon v-if="!isLockedAndConfidential" :name="warningIcon" :size="16" class="icon inline" />
<span v-if="isLockedAndConfidential" ref="lockedAndConfidential">
- <span
- v-html="confidentialAndLockedDiscussionText /* eslint-disable-line vue/no-v-html */"
- ></span>
+ <span>
+ <gl-sprintf
+ :message="
+ __(
+ 'This %{noteableTypeText} is %{confidentialLinkStart}confidential%{confidentialLinkEnd} and %{lockedLinkStart}locked%{lockedLinkEnd}.',
+ )
+ "
+ >
+ <template #noteableTypeText>{{ noteableTypeText }}</template>
+ <template #confidentialLink="{ content }">
+ <gl-link :href="confidentialNoteableDocsPath" target="_blank">{{ content }}</gl-link>
+ </template>
+ <template #lockedLink="{ content }">
+ <gl-link :href="lockedNoteableDocsPath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
{{
__("People without permission will never get a notification and won't be able to comment.")
}}
diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js
index 46361c6eb32..88c975b97b9 100644
--- a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js
+++ b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js
@@ -1,7 +1,5 @@
import { s__, sprintf } from '~/locale';
-export const EXPERIMENT_NAME = 'ci_runner_templates';
-
export const README_URL =
'https://gitlab.com/guided-explorations/aws/gitlab-runner-autoscaling-aws-asg/-/blob/main/easybuttons.md';
@@ -16,7 +14,11 @@ export const EASY_BUTTONS = [
templateName:
'easybutton-amazon-linux-2-docker-manual-scaling-with-schedule-ondemandonly.cf.yml',
description: s__(
- 'Runners|Amazon Linux 2 Docker HA with manual scaling and optional scheduling. Non-spot. Default choice for Linux Docker executor.',
+ 'Runners|Amazon Linux 2 Docker HA with manual scaling and optional scheduling. Non-spot.',
+ ),
+ moreDetails1: s__('Runners|No spot. This is the default choice for Linux Docker executor.'),
+ moreDetails2: s__(
+ 'Runners|A capacity of 1 enables warm HA through Auto Scaling group re-spawn. A capacity of 2 enables hot HA because the service is available even when a node is lost. A capacity of 3 or more enables hot HA and manual scaling of runner fleet.',
),
},
{
@@ -28,12 +30,20 @@ export const EASY_BUTTONS = [
),
{ percentage: '100%' },
),
+ moreDetails1: sprintf(s__('Runners|%{percentage} spot.'), { percentage: '100%' }),
+ moreDetails2: s__(
+ 'Runners|Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.',
+ ),
},
{
stackName: 'win2019-shell-non-spot',
templateName: 'easybutton-windows2019-shell-manual-scaling-with-scheduling-ondemandonly.cf.yml',
description: s__(
- 'Runners|Windows 2019 Shell with manual scaling and optional scheduling. Non-spot. Default choice for Windows Shell executor.',
+ 'Runners|Windows 2019 Shell with manual scaling and optional scheduling. Non-spot.',
+ ),
+ moreDetails1: s__('Runners|No spot. Default choice for Windows Shell executor.'),
+ moreDetails2: s__(
+ 'Runners|Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.',
),
},
{
@@ -45,5 +55,9 @@ export const EASY_BUTTONS = [
),
{ percentage: '100%' },
),
+ moreDetails1: sprintf(s__('Runners|%{percentage} spot.'), { percentage: '100%' }),
+ moreDetails2: s__(
+ 'Runners|Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.',
+ ),
},
];
diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue
index 57cc25caa25..eee65d90285 100644
--- a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue
@@ -1,35 +1,44 @@
<script>
-import { GlModal, GlSprintf, GlLink } from '@gitlab/ui';
-import awsCloudFormationImageUrl from 'images/aws-cloud-formation.png';
-import ExperimentTracking from '~/experimentation/experiment_tracking';
-import { getBaseURL, objectToQuery } from '~/lib/utils/url_utility';
-import { __, s__ } from '~/locale';
import {
- EXPERIMENT_NAME,
- README_URL,
- CF_BASE_URL,
- TEMPLATES_BASE_URL,
- EASY_BUTTONS,
-} from './constants';
+ GlModal,
+ GlSprintf,
+ GlLink,
+ GlFormRadioGroup,
+ GlFormRadio,
+ GlAccordion,
+ GlAccordionItem,
+} from '@gitlab/ui';
+import Tracking from '~/tracking';
+import { getBaseURL, objectToQuery, visitUrl } from '~/lib/utils/url_utility';
+import { __, s__ } from '~/locale';
+import { README_URL, CF_BASE_URL, TEMPLATES_BASE_URL, EASY_BUTTONS } from './constants';
export default {
components: {
GlModal,
GlSprintf,
GlLink,
+ GlFormRadioGroup,
+ GlFormRadio,
+ GlAccordion,
+ GlAccordionItem,
},
+ mixins: [Tracking.mixin()],
props: {
modalId: {
type: String,
required: true,
},
- imgSrc: {
- type: String,
- required: false,
- default: awsCloudFormationImageUrl,
- },
+ },
+ data() {
+ return {
+ selected: this.$options.easyButtons[0],
+ };
},
methods: {
+ borderBottom(idx) {
+ return idx < this.$options.easyButtons.length - 1;
+ },
easyButtonUrl(easyButton) {
const params = {
templateURL: TEMPLATES_BASE_URL + easyButton.templateName,
@@ -39,21 +48,30 @@ export default {
return CF_BASE_URL + objectToQuery(params);
},
trackCiRunnerTemplatesClick(stackName) {
- const tracking = new ExperimentTracking(EXPERIMENT_NAME);
- tracking.event(`template_clicked_${stackName}`);
+ this.track('template_clicked', {
+ label: stackName,
+ });
+ },
+ handleModalPrimary() {
+ this.trackCiRunnerTemplatesClick(this.selected.stackName);
+ visitUrl(this.easyButtonUrl(this.selected), true);
},
},
i18n: {
title: s__('Runners|Deploy GitLab Runner in AWS'),
instructions: s__(
- 'Runners|For each solution, you will choose a capacity. 1 enables warm HA through Auto Scaling group re-spawn. 2 enables hot HA because the service is available even when a node is lost. 3 or more enables hot HA and manual scaling of runner fleet.',
+ 'Runners|Select your preferred option here. In the next step, you can choose the capacity for your runner in the AWS CloudFormation console.',
),
- dont_see_what_you_are_looking_for: s__(
- "Rnners|Don't see what you are looking for? See the full list of options, including a fully customizable option, %{linkStart}here%{linkEnd}.",
- ),
- note: s__(
- 'Runners|If you do not select an AWS VPC, the runner will deploy to the Default VPC in the AWS Region you select. Please consult with your AWS administrator to understand if there are any security risks to deploying into the Default VPC in any given region in your AWS account.',
+ chooseRunner: s__('Runners|Choose your preferred GitLab Runner'),
+ dontSeeWhatYouAreLookingFor: s__(
+ "Runners|Don't see what you are looking for? See the full list of options, including a fully customizable option %{linkStart}here%{linkEnd}.",
),
+ moreDetails: __('More Details'),
+ lessDetails: __('Less Details'),
+ },
+ deployButton: {
+ text: s__('Runners|Deploy GitLab Runner in AWS'),
+ attributes: [{ variant: 'confirm' }],
},
closeButton: {
text: __('Cancel'),
@@ -67,37 +85,41 @@ export default {
<gl-modal
:modal-id="modalId"
:title="$options.i18n.title"
+ :action-primary="$options.deployButton"
:action-secondary="$options.closeButton"
size="sm"
+ @primary="handleModalPrimary"
>
<p>{{ $options.i18n.instructions }}</p>
- <ul class="gl-list-style-none gl-p-0 gl-mb-0">
- <li v-for="easyButton in $options.easyButtons" :key="easyButton.templateName">
- <gl-link
- :href="easyButtonUrl(easyButton)"
- target="_blank"
- class="gl-display-flex gl-font-weight-bold"
- @click="trackCiRunnerTemplatesClick(easyButton.stackName)"
- >
- <img
- :title="easyButton.stackName"
- :alt="easyButton.stackName"
- :src="imgSrc"
- width="46"
- height="46"
- class="gl-mt-2 gl-mr-5 gl-mb-6"
- />
+ <gl-form-radio-group v-model="selected" :label="$options.i18n.chooseRunner" label-sr-only>
+ <gl-form-radio
+ v-for="(easyButton, idx) in $options.easyButtons"
+ :key="easyButton.templateName"
+ :value="easyButton"
+ class="gl-py-5 gl-pl-8"
+ :class="{ 'gl-border-b': borderBottom(idx) }"
+ >
+ <div class="gl-mt-n1 gl-pl-4 gl-pb-2 gl-font-weight-bold">
{{ easyButton.description }}
- </gl-link>
- </li>
- </ul>
+ <gl-accordion :header-level="3" class="gl-pt-3">
+ <gl-accordion-item
+ :title="$options.i18n.moreDetails"
+ :title-visible="$options.i18n.lessDetails"
+ class="gl-font-weight-normal"
+ >
+ <p class="gl-pt-2">{{ easyButton.moreDetails1 }}</p>
+ <p class="gl-m-0">{{ easyButton.moreDetails2 }}</p>
+ </gl-accordion-item>
+ </gl-accordion>
+ </div>
+ </gl-form-radio>
+ </gl-form-radio-group>
<p>
- <gl-sprintf :message="$options.i18n.dont_see_what_you_are_looking_for">
+ <gl-sprintf :message="$options.i18n.dontSeeWhatYouAreLookingFor">
<template #link="{ content }">
<gl-link :href="$options.readmeUrl" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
- <p class="gl-font-sm gl-mb-0">{{ $options.i18n.note }}</p>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
index 81e19e48d75..7127940bb05 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
@@ -10,8 +10,14 @@ query getMrAssignees($fullPath: ID!, $iid: String!) {
nodes {
...User
...UserAvailability
+ mergeRequestInteraction {
+ canMerge
+ }
}
}
+ userPermissions {
+ canMerge
+ }
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql
index 77140ea36d8..5fec2ccbdfb 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql
@@ -2,21 +2,18 @@
#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
- mergeRequestSetAssignees(
+ issuableSetAssignees: mergeRequestSetAssignees(
input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $fullPath }
) {
- mergeRequest {
+ issuable: mergeRequest {
id
assignees {
nodes {
...User
...UserAvailability
- }
- }
- participants {
- nodes {
- ...User
- ...UserAvailability
+ mergeRequestInteraction {
+ canMerge
+ }
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/source_editor.vue b/app/assets/javascripts/vue_shared/components/source_editor.vue
index 011cad4267c..6a0bf07c8b4 100644
--- a/app/assets/javascripts/vue_shared/components/source_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/source_editor.vue
@@ -46,6 +46,11 @@ export default {
required: false,
default: () => ({}),
},
+ debounceValue: {
+ type: Number,
+ required: false,
+ default: CONTENT_UPDATE_DEBOUNCE,
+ },
},
data() {
return {
@@ -73,9 +78,7 @@ export default {
...this.editorOptions,
});
- this.editor.onDidChangeModelContent(
- debounce(this.onFileChange.bind(this), CONTENT_UPDATE_DEBOUNCE),
- );
+ this.editor.onDidChangeModelContent(debounce(this.onFileChange.bind(this), this.debounceValue));
},
beforeDestroy() {
this.editor.dispose();
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
index 5aae1812de3..4a78cbacec0 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
@@ -35,16 +35,20 @@ export default {
},
highlightedContent() {
let highlightedContent;
+ let { language } = this;
if (this.hljs) {
- if (!this.language) {
- highlightedContent = this.hljs.highlightAuto(this.content).value;
+ if (!language) {
+ const hljsHighlightAuto = this.hljs.highlightAuto(this.content);
+
+ highlightedContent = hljsHighlightAuto.value;
+ language = hljsHighlightAuto.language;
} else if (this.languageDefinition) {
highlightedContent = this.hljs.highlight(this.content, { language: this.language }).value;
}
}
- return wrapLines(highlightedContent);
+ return wrapLines(highlightedContent, language);
},
},
watch: {
@@ -110,7 +114,7 @@ export default {
data-qa-selector="blob_viewer_file_content"
>
<line-numbers :lines="lineNumbers" />
- <pre class="code gl-pb-0!"><code v-safe-html="highlightedContent"></code>
+ <pre class="code highlight 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
index e64e564bf61..d726a8a55ff 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/utils.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js
@@ -1,11 +1,13 @@
-export const wrapLines = (content) => {
+export const wrapLines = (content, language) => {
+ const isValidLanguage = /^[a-z\d\-_]+$/.test(language); // To prevent the possibility of a vulnerability we only allow languages that contain alphanumeric characters ([a-z\d), dashes (-) or underscores (_).
+
return (
content &&
content
.split('\n')
.map((line, i) => {
let formattedLine;
- const idAttribute = `id="LC${i + 1}"`;
+ const attributes = `id="LC${i + 1}" lang="${isValidLanguage ? language : ''}"`;
if (line.includes('<span class="hljs') && !line.includes('</span>')) {
/**
@@ -14,9 +16,9 @@ export const wrapLines = (content) => {
* example (before): <span class="hljs-code">```bash
* example (after): <span id="LC67" class="hljs-code">```bash
*/
- formattedLine = line.replace(/(?=class="hljs)/, `${idAttribute} `);
+ formattedLine = line.replace(/(?=class="hljs)/, `${attributes} `);
} else {
- formattedLine = `<span ${idAttribute} class="line">${line}</span>`;
+ formattedLine = `<span ${attributes} class="line">${line}</span>`;
}
return formattedLine;
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
index efb99eb0d94..d07f65cf5c1 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
@@ -1,30 +1,33 @@
<script>
/* This is a re-usable vue component for rendering a user avatar that
- does not need to link to the user's profile. The image and an optional
- tooltip can be configured by props passed to this component.
+ does not need to link to the user's profile. The image and an optional
+ tooltip can be configured by props passed to this component.
- Sample configuration:
+ Sample configuration:
- <user-avatar-image
- :lazy="true"
- :img-src="userAvatarSrc"
- :img-alt="tooltipText"
- :tooltip-text="tooltipText"
- tooltip-placement="top"
- />
+ <user-avatar-image
+ lazy
+ :img-src="userAvatarSrc"
+ :img-alt="tooltipText"
+ :tooltip-text="tooltipText"
+ tooltip-placement="top"
+ />
-*/
+ */
-import { GlTooltip } from '@gitlab/ui';
import defaultAvatarUrl from 'images/no_avatar.png';
import { __ } from '~/locale';
-import { placeholderImage } from '../../../lazy_loader';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import UserAvatarImageNew from './user_avatar_image_new.vue';
+import UserAvatarImageOld from './user_avatar_image_old.vue';
export default {
name: 'UserAvatarImage',
components: {
- GlTooltip,
+ UserAvatarImageNew,
+ UserAvatarImageOld,
},
+ mixins: [glFeatureFlagMixin()],
props: {
lazy: {
type: Boolean,
@@ -62,51 +65,14 @@ export default {
default: 'top',
},
},
- computed: {
- // API response sends null when gravatar is disabled and
- // we provide an empty string when we use it inside user avatar link.
- // In both cases we should render the defaultAvatarUrl
- sanitizedSource() {
- let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
- // Only adds the width to the URL if its not a base64 data image
- if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?'))
- baseSrc += `?width=${this.size}`;
- return baseSrc;
- },
- resultantSrcAttribute() {
- return this.lazy ? placeholderImage : this.sanitizedSource;
- },
- avatarSizeClass() {
- return `s${this.size}`;
- },
- },
};
</script>
<template>
- <span>
- <img
- ref="userAvatarImage"
- :class="{
- lazy: lazy,
- [avatarSizeClass]: true,
- [cssClasses]: true,
- }"
- :src="resultantSrcAttribute"
- :width="size"
- :height="size"
- :alt="imgAlt"
- :data-src="sanitizedSource"
- class="avatar"
- />
- <gl-tooltip
- v-if="tooltipText || $slots.default"
- :target="() => $refs.userAvatarImage"
- :placement="tooltipPlacement"
- boundary="window"
- class="js-user-avatar-image-tooltip"
- >
- <slot> {{ tooltipText }} </slot>
- </gl-tooltip>
- </span>
+ <user-avatar-image-new v-if="glFeatures.glAvatarForAllUserAvatars" v-bind="$props">
+ <slot></slot>
+ </user-avatar-image-new>
+ <user-avatar-image-old v-else v-bind="$props">
+ <slot></slot>
+ </user-avatar-image-old>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue
new file mode 100644
index 00000000000..f52a3471ea4
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue
@@ -0,0 +1,106 @@
+<script>
+/* This is a re-usable vue component for rendering a user avatar that
+ does not need to link to the user's profile. The image and an optional
+ tooltip can be configured by props passed to this component.
+
+ Sample configuration:
+
+ <user-avatar
+ lazy
+ :img-src="userAvatarSrc"
+ :img-alt="tooltipText"
+ :tooltip-text="tooltipText"
+ tooltip-placement="top"
+ />
+
+ */
+
+import { GlTooltip, GlAvatar } from '@gitlab/ui';
+import defaultAvatarUrl from 'images/no_avatar.png';
+import { __ } from '~/locale';
+import { placeholderImage } from '../../../lazy_loader';
+
+export default {
+ name: 'UserAvatarImageNew',
+ components: {
+ GlTooltip,
+ GlAvatar,
+ },
+ props: {
+ lazy: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ imgSrc: {
+ type: String,
+ required: false,
+ default: defaultAvatarUrl,
+ },
+ cssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgAlt: {
+ type: String,
+ required: false,
+ default: __('user avatar'),
+ },
+ size: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ tooltipText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ },
+ computed: {
+ // API response sends null when gravatar is disabled and
+ // we provide an empty string when we use it inside user avatar link.
+ // In both cases we should render the defaultAvatarUrl
+ sanitizedSource() {
+ let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
+ // Only adds the width to the URL if its not a base64 data image
+ if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?'))
+ baseSrc += `?width=${this.size}`;
+ return baseSrc;
+ },
+ resultantSrcAttribute() {
+ return this.lazy ? placeholderImage : this.sanitizedSource;
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <gl-avatar
+ ref="userAvatar"
+ :class="{
+ lazy: lazy,
+ [cssClasses]: true,
+ }"
+ :src="resultantSrcAttribute"
+ :data-src="sanitizedSource"
+ :size="size"
+ :alt="imgAlt"
+ />
+
+ <gl-tooltip
+ :target="() => $refs.userAvatar.$el"
+ :placement="tooltipPlacement"
+ boundary="window"
+ >
+ <slot> {{ tooltipText }}</slot>
+ </gl-tooltip>
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue
new file mode 100644
index 00000000000..bca10c76038
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue
@@ -0,0 +1,110 @@
+<script>
+/* This is a re-usable vue component for rendering a user avatar that
+ does not need to link to the user's profile. The image and an optional
+ tooltip can be configured by props passed to this component.
+
+ Sample configuration:
+
+ <user-avatar-image
+ lazy
+ :img-src="userAvatarSrc"
+ :img-alt="tooltipText"
+ :tooltip-text="tooltipText"
+ tooltip-placement="top"
+ />
+
+ */
+
+import { GlTooltip } from '@gitlab/ui';
+import defaultAvatarUrl from 'images/no_avatar.png';
+import { __ } from '~/locale';
+import { placeholderImage } from '../../../lazy_loader';
+
+export default {
+ name: 'UserAvatarImageOld',
+ components: {
+ GlTooltip,
+ },
+ props: {
+ lazy: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ imgSrc: {
+ type: String,
+ required: false,
+ default: defaultAvatarUrl,
+ },
+ cssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgAlt: {
+ type: String,
+ required: false,
+ default: __('user avatar'),
+ },
+ size: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ tooltipText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ },
+ computed: {
+ // API response sends null when gravatar is disabled and
+ // we provide an empty string when we use it inside user avatar link.
+ // In both cases we should render the defaultAvatarUrl
+ sanitizedSource() {
+ let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
+ // Only adds the width to the URL if its not a base64 data image
+ if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?'))
+ baseSrc += `?width=${this.size}`;
+ return baseSrc;
+ },
+ resultantSrcAttribute() {
+ return this.lazy ? placeholderImage : this.sanitizedSource;
+ },
+ avatarSizeClass() {
+ return `s${this.size}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <img
+ ref="userAvatarImage"
+ :class="{
+ lazy: lazy,
+ [avatarSizeClass]: true,
+ [cssClasses]: true,
+ }"
+ :src="resultantSrcAttribute"
+ :width="size"
+ :height="size"
+ :alt="imgAlt"
+ :data-src="sanitizedSource"
+ class="avatar"
+ />
+ <gl-tooltip
+ :target="() => $refs.userAvatarImage"
+ :placement="tooltipPlacement"
+ boundary="window"
+ >
+ <slot> {{ tooltipText }}</slot>
+ </gl-tooltip>
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
index 04423aac651..887deff17c9 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
@@ -17,18 +17,17 @@
*/
-import { GlLink, GlTooltipDirective } from '@gitlab/ui';
-import userAvatarImage from './user_avatar_image.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import UserAvatarLinkNew from './user_avatar_link_new.vue';
+import UserAvatarLinkOld from './user_avatar_link_old.vue';
export default {
name: 'UserAvatarLink',
components: {
- GlLink,
- userAvatarImage,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
+ UserAvatarLinkNew,
+ UserAvatarLinkOld,
},
+ mixins: [glFeatureFlagMixin()],
props: {
lazy: {
type: Boolean,
@@ -76,36 +75,21 @@ export default {
default: '',
},
},
- computed: {
- shouldShowUsername() {
- return this.username.length > 0;
- },
- avatarTooltipText() {
- return this.shouldShowUsername ? '' : this.tooltipText;
- },
- },
};
</script>
<template>
- <gl-link :href="linkHref" class="user-avatar-link">
- <user-avatar-image
- :img-src="imgSrc"
- :img-alt="imgAlt"
- :css-classes="imgCssClasses"
- :size="imgSize"
- :tooltip-text="avatarTooltipText"
- :tooltip-placement="tooltipPlacement"
- :lazy="lazy"
- >
- <slot></slot> </user-avatar-image
- ><span
- v-if="shouldShowUsername"
- v-gl-tooltip
- :title="tooltipText"
- :tooltip-placement="tooltipPlacement"
- class="js-user-avatar-link-username"
- >{{ username }}</span
- ><slot name="avatar-badge"></slot>
- </gl-link>
+ <user-avatar-link-new v-if="glFeatures.glAvatarForAllUserAvatars" v-bind="$props">
+ <slot></slot>
+ <template #avatar-badge>
+ <slot name="avatar-badge"></slot>
+ </template>
+ </user-avatar-link-new>
+
+ <user-avatar-link-old v-else v-bind="$props">
+ <slot></slot>
+ <template #avatar-badge>
+ <slot name="avatar-badge"></slot>
+ </template>
+ </user-avatar-link-old>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue
new file mode 100644
index 00000000000..3b459569274
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue
@@ -0,0 +1,117 @@
+<script>
+/* This is a re-usable vue component for rendering a user avatar wrapped in
+ a clickable link (likely to the user's profile). The link, image, and
+ tooltip can be configured by props passed to this component.
+
+ Sample configuration:
+
+ <user-avatar-link
+ :link-href="userProfileUrl"
+ :img-src="userAvatarSrc"
+ :img-alt="tooltipText"
+ :img-size="20"
+ :tooltip-text="tooltipText"
+ :tooltip-placement="top"
+ :username="username"
+ />
+
+*/
+
+import { GlAvatarLink, GlTooltipDirective } from '@gitlab/ui';
+import UserAvatarImage from './user_avatar_image.vue';
+
+export default {
+ name: 'UserAvatarLinkNew',
+ components: {
+ UserAvatarImage,
+ GlAvatarLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ lazy: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ linkHref: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgSrc: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgAlt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgCssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgSize: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ tooltipText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ username: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ shouldShowUsername() {
+ return this.username.length > 0;
+ },
+ avatarTooltipText() {
+ return this.shouldShowUsername ? '' : this.tooltipText;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-avatar-link :href="linkHref" class="user-avatar-link">
+ <user-avatar-image
+ :img-src="imgSrc"
+ :img-alt="imgAlt"
+ :css-classes="imgCssClasses"
+ :size="imgSize"
+ :tooltip-text="avatarTooltipText"
+ :tooltip-placement="tooltipPlacement"
+ :lazy="lazy"
+ >
+ <slot></slot>
+ </user-avatar-image>
+
+ <span
+ v-if="shouldShowUsername"
+ v-gl-tooltip
+ :title="tooltipText"
+ :tooltip-placement="tooltipPlacement"
+ class="gl-ml-3"
+ data-testid="user-avatar-link-username"
+ >
+ {{ username }}
+ </span>
+
+ <slot name="avatar-badge"></slot>
+ </gl-avatar-link>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_old.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_old.vue
new file mode 100644
index 00000000000..c2e46e61e1b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_old.vue
@@ -0,0 +1,117 @@
+<script>
+/* This is a re-usable vue component for rendering a user avatar wrapped in
+ a clickable link (likely to the user's profile). The link, image, and
+ tooltip can be configured by props passed to this component.
+
+ Sample configuration:
+
+ <user-avatar-link
+ :link-href="userProfileUrl"
+ :img-src="userAvatarSrc"
+ :img-alt="tooltipText"
+ :img-size="20"
+ :tooltip-text="tooltipText"
+ :tooltip-placement="top"
+ :username="username"
+ />
+
+*/
+
+import { GlLink, GlTooltipDirective } from '@gitlab/ui';
+import UserAvatarImage from './user_avatar_image.vue';
+
+export default {
+ name: 'UserAvatarLinkOld',
+ components: {
+ GlLink,
+ UserAvatarImage,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ lazy: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ linkHref: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgSrc: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgAlt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgCssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgSize: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ tooltipText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ username: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ shouldShowUsername() {
+ return this.username.length > 0;
+ },
+ avatarTooltipText() {
+ return this.shouldShowUsername ? '' : this.tooltipText;
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <gl-link :href="linkHref" class="user-avatar-link">
+ <user-avatar-image
+ :img-src="imgSrc"
+ :img-alt="imgAlt"
+ :css-classes="imgCssClasses"
+ :size="imgSize"
+ :tooltip-text="avatarTooltipText"
+ :tooltip-placement="tooltipPlacement"
+ :lazy="lazy"
+ >
+ <slot></slot>
+ </user-avatar-image>
+
+ <span
+ v-if="shouldShowUsername"
+ v-gl-tooltip
+ :title="tooltipText"
+ :tooltip-placement="tooltipPlacement"
+ data-testid="user-avatar-link-username"
+ >
+ {{ username }}
+ </span>
+ <slot name="avatar-badge"></slot>
+ </gl-link>
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
index 05e0c3b0be3..41507ca94e2 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -116,7 +116,7 @@ export default {
<div v-if="statusHtml" class="gl-mb-2" data-testid="user-popover-status">
<span v-safe-html:[$options.safeHtmlConfig]="statusHtml"></span>
</div>
- <div v-if="user.bot" class="gl-text-blue-500">
+ <div v-if="user.bot && user.websiteUrl" class="gl-text-blue-500">
<gl-icon name="question" />
<gl-link data-testid="user-popover-bot-docs-link" :href="user.websiteUrl">
<gl-sprintf :message="__('Learn more about %{username}')">
diff --git a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
index b85cae0c64f..9df5254155e 100644
--- a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
+++ b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
@@ -1,4 +1,5 @@
<script>
+import { debounce } from 'lodash';
import {
GlDropdown,
GlDropdownForm,
@@ -6,11 +7,14 @@ import {
GlDropdownItem,
GlSearchBoxByType,
GlLoadingIcon,
+ GlTooltipDirective,
} from '@gitlab/ui';
-import searchUsers from '~/graphql_shared/queries/users_search.query.graphql';
import { __ } from '~/locale';
import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
-import { ASSIGNEES_DEBOUNCE_DELAY, participantsQueries } from '~/sidebar/constants';
+import { IssuableType } from '~/issues/constants';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import { participantsQueries, userSearchQueries } from '~/sidebar/constants';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
export default {
i18n: {
@@ -25,6 +29,9 @@ export default {
SidebarParticipant,
GlLoadingIcon,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
headerText: {
type: String,
@@ -58,13 +65,18 @@ export default {
issuableType: {
type: String,
required: false,
- default: 'issue',
+ default: IssuableType.Issue,
},
isEditing: {
type: Boolean,
required: false,
default: true,
},
+ issuableId: {
+ type: Number,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -89,28 +101,35 @@ export default {
};
},
update(data) {
- return data.workspace?.issuable?.participants.nodes;
+ return data.workspace?.issuable?.participants.nodes.map((node) => ({
+ ...node,
+ canMerge: false,
+ }));
},
error() {
this.$emit('error');
},
},
searchUsers: {
- query: searchUsers,
+ query() {
+ return userSearchQueries[this.issuableType].query;
+ },
variables() {
- return {
- fullPath: this.fullPath,
- search: this.search,
- first: 20,
- };
+ return this.searchUsersVariables;
},
skip() {
return !this.isEditing;
},
update(data) {
- return data.workspace?.users?.nodes.filter((x) => x?.user).map(({ user }) => user) || [];
+ return (
+ data.workspace?.users?.nodes
+ .filter((x) => x?.user)
+ .map((node) => ({
+ ...node.user,
+ canMerge: node.mergeRequestInteraction?.canMerge || false,
+ })) || []
+ );
},
- debounce: ASSIGNEES_DEBOUNCE_DELAY,
error() {
this.$emit('error');
this.isSearching = false;
@@ -121,6 +140,23 @@ export default {
},
},
computed: {
+ isMergeRequest() {
+ return this.issuableType === IssuableType.MergeRequest;
+ },
+ searchUsersVariables() {
+ const variables = {
+ fullPath: this.fullPath,
+ search: this.search,
+ first: 20,
+ };
+ if (!this.isMergeRequest) {
+ return variables;
+ }
+ return {
+ ...variables,
+ mergeRequestId: convertToGraphQLId('MergeRequest', this.issuableId),
+ };
+ },
isLoading() {
return this.$apollo.queries.searchUsers.loading || this.$apollo.queries.participants.loading;
},
@@ -135,8 +171,8 @@ export default {
// TODO this de-duplication is temporary (BE fix required)
// https://gitlab.com/gitlab-org/gitlab/-/issues/327822
- const mergedSearchResults = filteredParticipants
- .concat(this.searchUsers)
+ const mergedSearchResults = this.searchUsers
+ .concat(filteredParticipants)
.reduce(
(acc, current) => (acc.some((user) => current.id === user.id) ? acc : [...acc, current]),
[],
@@ -179,6 +215,7 @@ export default {
return this.selectedFiltered.length === 0;
},
},
+
watch: {
// We need to add this watcher to track the moment when user is alredy typing
// but query is still not started due to debounce
@@ -188,15 +225,21 @@ export default {
}
},
},
+ created() {
+ this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ },
methods: {
selectAssignee(user) {
let selected = [...this.value];
if (!this.allowMultipleAssignees) {
selected = [user];
+ this.$emit('input', selected);
+ this.$refs.dropdown.hide();
+ this.$emit('toggle');
} else {
selected.push(user);
+ this.$emit('input', selected);
}
- this.$emit('input', selected);
},
unselect(name) {
const selected = this.value.filter((user) => user.username !== name);
@@ -205,6 +248,9 @@ export default {
focusSearch() {
this.$refs.search.focusInput();
},
+ showDropdown() {
+ this.$refs.dropdown.show();
+ },
showDivider(list) {
return list.length > 0 && this.isSearchEmpty;
},
@@ -216,22 +262,37 @@ export default {
const currentUser = usersCopy.find((user) => user.username === this.currentUser.username);
if (currentUser) {
+ currentUser.canMerge = this.currentUser.canMerge;
const index = usersCopy.indexOf(currentUser);
usersCopy.splice(0, 0, usersCopy.splice(index, 1)[0]);
}
return usersCopy;
},
+ setSearchKey(value) {
+ this.search = value.trim();
+ },
+ tooltipText(user) {
+ if (!this.isMergeRequest) {
+ return '';
+ }
+ return user.canMerge ? '' : __('Cannot merge');
+ },
},
};
</script>
<template>
- <gl-dropdown class="show" :text="text" @toggle="$emit('toggle')">
+ <gl-dropdown ref="dropdown" :text="text" @toggle="$emit('toggle')" @shown="focusSearch">
<template #header>
<p class="gl-font-weight-bold gl-text-center gl-mt-2 gl-mb-4">{{ headerText }}</p>
<gl-dropdown-divider />
- <gl-search-box-by-type ref="search" v-model.trim="search" class="js-dropdown-input-field" />
+ <gl-search-box-by-type
+ ref="search"
+ :value="search"
+ class="js-dropdown-input-field"
+ @input="debouncedSearchKeyUpdate"
+ />
</template>
<gl-dropdown-form class="gl-relative gl-min-h-7">
<gl-loading-icon
@@ -247,7 +308,7 @@ export default {
:is-checked="selectedIsEmpty"
:is-check-centered="true"
data-testid="unassign"
- @click="$emit('input', [])"
+ @click.native.capture.stop="$emit('input', [])"
>
<span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" class="gl-font-weight-bold">{{
$options.i18n.unassigned
@@ -258,27 +319,44 @@ export default {
<gl-dropdown-item
v-for="item in selectedFiltered"
:key="item.id"
+ v-gl-tooltip.left.viewport
+ :title="tooltipText(item)"
+ boundary="viewport"
is-checked
is-check-centered
data-testid="selected-participant"
- @click.stop="unselect(item.username)"
+ @click.native.capture.stop="unselect(item.username)"
>
- <sidebar-participant :user="item" />
+ <sidebar-participant :user="item" :issuable-type="issuableType" />
</gl-dropdown-item>
<template v-if="showCurrentUser">
<gl-dropdown-divider />
- <gl-dropdown-item data-testid="current-user" @click.stop="selectAssignee(currentUser)">
- <sidebar-participant :user="currentUser" class="gl-pl-6!" />
+ <gl-dropdown-item
+ data-testid="current-user"
+ @click.native.capture.stop="selectAssignee(currentUser)"
+ >
+ <sidebar-participant
+ :user="currentUser"
+ :issuable-type="issuableType"
+ class="gl-pl-6!"
+ />
</gl-dropdown-item>
</template>
<gl-dropdown-divider v-if="showDivider(unselectedFiltered)" />
<gl-dropdown-item
v-for="unselectedUser in unselectedFiltered"
:key="unselectedUser.id"
+ v-gl-tooltip.left.viewport
+ :title="tooltipText(unselectedUser)"
+ boundary="viewport"
data-testid="unselected-participant"
- @click="selectAssignee(unselectedUser)"
+ @click.native.capture.stop="selectAssignee(unselectedUser)"
>
- <sidebar-participant :user="unselectedUser" class="gl-pl-6!" />
+ <sidebar-participant
+ :user="unselectedUser"
+ :issuable-type="issuableType"
+ class="gl-pl-6!"
+ />
</gl-dropdown-item>
<gl-dropdown-item v-if="noUsersFound" data-testid="empty-results" class="gl-pl-6!">
{{ __('No matching results') }}
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 82022d1f4d6..199516b3eb3 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -8,6 +8,7 @@ import ConfirmForkModal from '~/vue_shared/components/confirm_fork_modal.vue';
const KEY_EDIT = 'edit';
const KEY_WEB_IDE = 'webide';
const KEY_GITPOD = 'gitpod';
+const KEY_PIPELINE_EDITOR = 'pipeline_editor';
export default {
components: {
@@ -64,6 +65,11 @@ export default {
required: false,
default: false,
},
+ showPipelineEditorButton: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
userPreferencesGitpodPath: {
type: String,
required: false,
@@ -79,6 +85,11 @@ export default {
required: false,
default: '',
},
+ pipelineEditorUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
webIdeUrl: {
type: String,
required: false,
@@ -117,14 +128,19 @@ export default {
},
data() {
return {
- selection: KEY_WEB_IDE,
+ selection: this.showPipelineEditorButton ? KEY_PIPELINE_EDITOR : KEY_WEB_IDE,
showEnableGitpodModal: false,
showForkModal: false,
};
},
computed: {
actions() {
- return [this.webIdeAction, this.editAction, this.gitpodAction].filter((action) => action);
+ return [
+ this.pipelineEditorAction,
+ this.webIdeAction,
+ this.editAction,
+ this.gitpodAction,
+ ].filter((action) => action);
},
editAction() {
if (!this.showEditButton) {
@@ -162,7 +178,7 @@ export default {
if (this.webIdeText) {
return this.webIdeText;
} else if (this.isBlob) {
- return __('Edit in Web IDE');
+ return __('Open in Web IDE');
} else if (this.isFork) {
return __('Edit fork in Web IDE');
}
@@ -202,6 +218,9 @@ export default {
};
},
gitpodActionText() {
+ if (this.isBlob) {
+ return __('Open in Gitpod');
+ }
return this.gitpodText || __('Gitpod');
},
computedShowGitpodButton() {
@@ -209,11 +228,28 @@ export default {
this.showGitpodButton && this.userPreferencesGitpodPath && this.userProfileEnableGitpodPath
);
},
+ pipelineEditorAction() {
+ if (!this.showPipelineEditorButton) {
+ return null;
+ }
+
+ const secondaryText = __('Edit, lint, and visualize your pipeline.');
+
+ return {
+ key: KEY_PIPELINE_EDITOR,
+ text: __('Edit in pipeline editor'),
+ secondaryText,
+ tooltip: secondaryText,
+ attrs: {
+ 'data-qa-selector': 'pipeline_editor_button',
+ },
+ href: this.pipelineEditorUrl,
+ };
+ },
gitpodAction() {
if (!this.computedShowGitpodButton) {
return null;
}
-
const handleOptions = this.gitpodEnabled
? { href: this.gitpodUrl }
: {
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
index b96ce0c43f7..45941174a62 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
@@ -58,7 +58,12 @@ export default {
<template>
<div>
<div class="title-container">
- <h2 v-safe-html="issuable.titleHtml || issuable.title" class="title qa-title" dir="auto"></h2>
+ <h1
+ v-safe-html="issuable.titleHtml || issuable.title"
+ class="title qa-title"
+ dir="auto"
+ data-testid="title"
+ ></h1>
<gl-button
v-if="enableEdit"
v-gl-tooltip.bottom
diff --git a/app/assets/javascripts/webpack_non_compiled_placeholder.js b/app/assets/javascripts/webpack_non_compiled_placeholder.js
index 55ac2f0be6a..af671e72129 100644
--- a/app/assets/javascripts/webpack_non_compiled_placeholder.js
+++ b/app/assets/javascripts/webpack_non_compiled_placeholder.js
@@ -1,3 +1,4 @@
+/* globals LIVE_RELOAD */
const div = document.createElement('div');
Object.assign(div.style, {
@@ -15,6 +16,10 @@ Object.assign(div.style, {
'text-align': 'center',
});
+const reloadMessage = LIVE_RELOAD
+ ? 'You have live_reload enabled, the page will reload automatically when complete.'
+ : 'You have live_reload disabled, the page will reload automatically in a few seconds.';
+
div.innerHTML = `
<!-- https://github.com/webpack/media/blob/master/logo/icon-square-big.svg -->
<svg height="50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 1200">
@@ -30,9 +35,15 @@ div.innerHTML = `
Learn more <a href="https://gitlab.com/gitlab-org/gitlab-development-kit/-/blob/main/doc/configuration.md#webpack-settings">here</a>.
</p>
<p>
- If you have live_reload enabled, the page will reload automatically when complete.<br />
- Otherwise, please <a href="">reload the page manually in a few seconds</a>
+ ${reloadMessage}<br />
+ If it doesn't, please <a href="">reload the page manually</a>.
</p>
`;
document.body.append(div);
+
+if (!LIVE_RELOAD) {
+ setTimeout(() => {
+ window.location.reload();
+ }, 5000);
+}
diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
new file mode 100644
index 00000000000..942677bb937
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
@@ -0,0 +1,62 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import workItemQuery from '../graphql/work_item.query.graphql';
+import ItemTitle from './item_title.vue';
+
+export default {
+ components: {
+ GlModal,
+ ItemTitle,
+ },
+ props: {
+ visible: {
+ type: Boolean,
+ required: true,
+ },
+ workItemId: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ workItem: {},
+ };
+ },
+ apollo: {
+ workItem: {
+ query: workItemQuery,
+ variables() {
+ return {
+ id: this.workItemId,
+ };
+ },
+ update(data) {
+ return data.workItem;
+ },
+ skip() {
+ return !this.workItemId;
+ },
+ error() {
+ this.$emit(
+ 'error',
+ s__('WorkItem|Something went wrong when fetching the work item. Please try again.'),
+ );
+ },
+ },
+ },
+ computed: {
+ workItemTitle() {
+ return this.workItem?.title;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal hide-footer modal-id="work-item-detail-modal" :visible="visible" @hide="$emit('close')">
+ <item-title class="gl-m-0!" :initial-title="workItemTitle" />
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql
index 2f302dae7d7..9312d1c582b 100644
--- a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql
@@ -1,16 +1,16 @@
#import './widget.fragment.graphql'
-mutation createWorkItem($input: LocalCreateWorkItemInput) {
- localCreateWorkItem(input: $input) @client {
+mutation createWorkItem($input: WorkItemCreateInput!) {
+ workItemCreate(input: $input) {
workItem {
id
- type
- widgets {
+ title
+ workItemType {
+ id
+ }
+ widgets @client {
nodes {
...WidgetBase
- ... on LocalTitleWidget {
- contentText
- }
}
}
}
diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js
index 676fffb12d8..28328a840cf 100644
--- a/app/assets/javascripts/work_items/graphql/provider.js
+++ b/app/assets/javascripts/work_items/graphql/provider.js
@@ -10,29 +10,28 @@ export function createApolloProvider() {
const defaultClient = createDefaultClient(resolvers, {
typeDefs,
+ cacheConfig: {
+ possibleTypes: {
+ LocalWorkItemWidget: ['LocalTitleWidget'],
+ },
+ },
});
defaultClient.cache.writeQuery({
query: workItemQuery,
variables: {
- id: '1',
+ id: 'gid://gitlab/WorkItem/1',
},
data: {
- workItem: {
+ localWorkItem: {
__typename: 'LocalWorkItem',
- id: '1',
+ id: 'gid://gitlab/WorkItem/1',
type: 'FEATURE',
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ title: 'Test Work Item',
widgets: {
__typename: 'LocalWorkItemWidgetConnection',
- nodes: [
- {
- __typename: 'LocalTitleWidget',
- type: 'TITLE',
- enabled: true,
- // eslint-disable-next-line @gitlab/require-i18n-strings
- contentText: 'Test Work Item Title',
- },
- ],
+ nodes: [],
},
},
},
diff --git a/app/assets/javascripts/work_items/graphql/resolvers.js b/app/assets/javascripts/work_items/graphql/resolvers.js
index 63d5234d083..fb74e27f840 100644
--- a/app/assets/javascripts/work_items/graphql/resolvers.js
+++ b/app/assets/javascripts/work_items/graphql/resolvers.js
@@ -1,53 +1,24 @@
-import { uuids } from '~/lib/utils/uuids';
import workItemQuery from './work_item.query.graphql';
export const resolvers = {
Mutation: {
- localCreateWorkItem(_, { input }, { cache }) {
- const id = uuids()[0];
- const workItem = {
- __typename: 'LocalWorkItem',
- type: 'FEATURE',
- id,
- widgets: {
- __typename: 'LocalWorkItemWidgetConnection',
- nodes: [
- {
- __typename: 'LocalTitleWidget',
- type: 'TITLE',
- enabled: true,
- contentText: input.title,
- },
- ],
- },
- };
-
- cache.writeQuery({ query: workItemQuery, variables: { id }, data: { workItem } });
-
- return {
- __typename: 'LocalCreateWorkItemPayload',
- workItem,
- };
- },
-
localUpdateWorkItem(_, { input }, { cache }) {
- const workItemTitle = {
- __typename: 'LocalTitleWidget',
- type: 'TITLE',
- enabled: true,
- contentText: input.title,
- };
const workItem = {
__typename: 'LocalWorkItem',
type: 'FEATURE',
id: input.id,
+ title: input.title,
widgets: {
__typename: 'LocalWorkItemWidgetConnection',
- nodes: [workItemTitle],
+ nodes: [],
},
};
- cache.writeQuery({ query: workItemQuery, variables: { id: input.id }, data: { workItem } });
+ cache.writeQuery({
+ query: workItemQuery,
+ variables: { id: input.id },
+ data: { localWorkItem: workItem },
+ });
return {
__typename: 'LocalUpdateWorkItemPayload',
diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql
index 177eea00322..9b4811203f5 100644
--- a/app/assets/javascripts/work_items/graphql/typedefs.graphql
+++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql
@@ -22,14 +22,10 @@ type LocalWorkItemWidgetConnection {
pageInfo: PageInfo!
}
-type LocalTitleWidget implements LocalWorkItemWidget {
- type: LocalWidgetType!
- contentText: String!
-}
-
type LocalWorkItem {
id: ID!
type: LocalWorkItemType!
+ title: String!
widgets: [LocalWorkItemWidgetConnection]
}
@@ -51,7 +47,7 @@ type LocalUpdateWorkItemPayload {
}
extend type Query {
- workItem(id: ID!): LocalWorkItem!
+ localWorkItem(id: ID!): LocalWorkItem!
}
extend type Mutation {
diff --git a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
index f0563f099b2..efb1ed8d6df 100644
--- a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
@@ -1,16 +1,16 @@
#import './widget.fragment.graphql'
-mutation updateWorkItem($input: LocalUpdateWorkItemInput) {
- localUpdateWorkItem(input: $input) @client {
+mutation workItemUpdate($input: WorkItemUpdateInput!) {
+ workItemUpdate(input: $input) {
workItem {
id
- type
- widgets {
+ title
+ workItemType {
+ id
+ }
+ widgets @client {
nodes {
...WidgetBase
- ... on LocalTitleWidget {
- contentText
- }
}
}
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql
index 9f173f7c302..b32cb4f28fb 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql
@@ -1,15 +1,15 @@
#import './widget.fragment.graphql'
query WorkItem($id: ID!) {
- workItem(id: $id) @client {
+ workItem(id: $id) {
id
- type
- widgets {
+ title
+ workItemType {
+ id
+ }
+ widgets @client {
nodes {
...WidgetBase
- ... on LocalTitleWidget {
- contentText
- }
}
}
}
diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue
index 6c3bcf8f6a8..cc90cedb110 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, GlLoadingIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import workItemQuery from '../graphql/work_item.query.graphql';
import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql';
import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql';
@@ -67,21 +69,45 @@ export default {
variables: {
input: {
title: this.title,
+ projectPath: this.fullPath,
+ workItemTypeId: this.selectedWorkItemType?.id,
},
},
+ update(store, { data: { workItemCreate } }) {
+ const { id, title, workItemType } = workItemCreate.workItem;
+
+ store.writeQuery({
+ query: workItemQuery,
+ variables: {
+ id,
+ },
+ data: {
+ workItem: {
+ __typename: 'WorkItem',
+ id,
+ title,
+ workItemType,
+ widgets: {
+ __typename: 'LocalWorkItemWidgetConnection',
+ nodes: [],
+ },
+ },
+ },
+ });
+ },
});
const {
data: {
- localCreateWorkItem: {
- workItem: { id },
+ workItemCreate: {
+ workItem: { id, type },
},
},
} = response;
if (!this.isModal) {
- this.$router.push({ name: 'workItem', params: { id } });
+ this.$router.push({ name: 'workItem', params: { id: `${getIdFromGraphQLId(id)}` } });
} else {
- this.$emit('onCreate', this.title);
+ this.$emit('onCreate', { id, title: this.title, type });
}
} catch {
this.error = s__(
diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue
index 4262e169655..32b6fc231a8 100644
--- a/app/assets/javascripts/work_items/pages/work_item_root.vue
+++ b/app/assets/javascripts/work_items/pages/work_item_root.vue
@@ -1,9 +1,10 @@
<script>
-import { GlAlert } from '@gitlab/ui';
+import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
import Tracking from '~/tracking';
import workItemQuery from '../graphql/work_item.query.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
-import { widgetTypes, WI_TITLE_TRACK_LABEL } from '../constants';
+import { WI_TITLE_TRACK_LABEL } from '../constants';
import ItemTitle from '../components/item_title.vue';
@@ -14,6 +15,7 @@ export default {
components: {
ItemTitle,
GlAlert,
+ GlLoadingIcon,
},
mixins: [trackingMixin],
props: {
@@ -24,7 +26,7 @@ export default {
},
data() {
return {
- workItem: null,
+ workItem: {},
error: false,
};
},
@@ -33,7 +35,7 @@ export default {
query: workItemQuery,
variables() {
return {
- id: this.id,
+ id: this.gid,
};
},
},
@@ -47,19 +49,19 @@ export default {
property: '[type_work_item]',
};
},
- titleWidgetData() {
- return this.workItem?.widgets?.nodes?.find((widget) => widget.type === widgetTypes.title);
+ gid() {
+ return convertToGraphQLId('WorkItem', this.id);
},
},
methods: {
- async updateWorkItem(title) {
+ async updateWorkItem(updatedTitle) {
try {
await this.$apollo.mutate({
mutation: updateWorkItemMutation,
variables: {
input: {
- id: this.id,
- title,
+ id: this.gid,
+ title: updatedTitle,
},
},
});
@@ -79,12 +81,18 @@ export default {
}}</gl-alert>
<!-- Title widget placeholder -->
<div>
- <item-title
- v-if="titleWidgetData"
- :initial-title="titleWidgetData.contentText"
- data-testid="title"
- @title-changed="updateWorkItem"
+ <gl-loading-icon
+ v-if="$apollo.queries.workItem.loading"
+ size="md"
+ data-testid="loading-types"
/>
+ <template v-else>
+ <item-title
+ :initial-title="workItem.title"
+ data-testid="title"
+ @title-changed="updateWorkItem"
+ />
+ </template>
</div>
</section>
</template>