summaryrefslogtreecommitdiff
path: root/app/assets
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-03-16 18:18:33 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-03-16 18:18:33 +0000
commitf64a639bcfa1fc2bc89ca7db268f594306edfd7c (patch)
treea2c3c2ebcc3b45e596949db485d6ed18ffaacfa1 /app/assets
parentbfbc3e0d6583ea1a91f627528bedc3d65ba4b10f (diff)
downloadgitlab-ce-f64a639bcfa1fc2bc89ca7db268f594306edfd7c.tar.gz
Add latest changes from gitlab-org/gitlab@13-10-stable-eev13.10.0-rc40
Diffstat (limited to 'app/assets')
-rw-r--r--app/assets/images/learn_gitlab/code_owners_enabled.svg5
-rw-r--r--app/assets/images/learn_gitlab/git_write.svg16
-rw-r--r--app/assets/images/learn_gitlab/merge_request_created.svg107
-rw-r--r--app/assets/images/learn_gitlab/pipeline_created.svg38
-rw-r--r--app/assets/images/learn_gitlab/required_mr_approvals_enabled.svg70
-rw-r--r--app/assets/images/learn_gitlab/security_scan_enabled.svg36
-rw-r--r--app/assets/images/learn_gitlab/trial_started.svg9
-rw-r--r--app/assets/images/learn_gitlab/user_added.svg4
-rw-r--r--app/assets/javascripts/access_tokens/components/projects_field.vue69
-rw-r--r--app/assets/javascripts/access_tokens/components/projects_token_selector.vue156
-rw-r--r--app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql28
-rw-r--r--app/assets/javascripts/access_tokens/index.js62
-rw-r--r--app/assets/javascripts/admin/dev_ops_report/devops_adoption.js2
-rw-r--r--app/assets/javascripts/admin/users/tabs.js11
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_table.vue20
-rw-r--r--app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue43
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue15
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue636
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue189
-rw-r--r--app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json95
-rw-r--r--app/assets/javascripts/alerts_settings/constants.js152
-rw-r--r--app/assets/javascripts/alerts_settings/graphql.js18
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/fragments/http_integration_item.fragment.graphql7
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/fragments/http_integration_payload_data.fragment.graphql3
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql22
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql4
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql4
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/update_current_http_integration.mutation.graphql25
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/update_current_prometheus_integration.mutation.graphql (renamed from app/assets/javascripts/alerts_settings/graphql/mutations/update_current_intergration.mutation.graphql)4
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql4
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/queries/get_http_integrations.query.graphql12
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/queries/parse_sample_payload.query.graphql9
-rw-r--r--app/assets/javascripts/alerts_settings/index.js5
-rw-r--r--app/assets/javascripts/alerts_settings/utils/cache_updates.js35
-rw-r--r--app/assets/javascripts/alerts_settings/utils/error_messages.js2
-rw-r--r--app/assets/javascripts/alerts_settings/utils/mapping_transformations.js28
-rw-r--r--app/assets/javascripts/analytics/devops_report/components/usage_ping_disabled.vue (renamed from app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue)0
-rw-r--r--app/assets/javascripts/analytics/devops_report/devops_score_empty_state.js (renamed from app/assets/javascripts/admin/dev_ops_report/devops_score_empty_state.js)0
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/components/charts_config.js87
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/components/projects_and_groups_chart.vue224
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/graphql/fragments/count.fragment.graphql4
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/graphql/queries/groups.query.graphql13
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql34
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/graphql/queries/projects.query.graphql13
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/app.vue (renamed from app/assets/javascripts/analytics/instance_statistics/components/app.vue)21
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/charts_config.js106
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue (renamed from app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue)27
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/usage_trends_count_chart.vue (renamed from app/assets/javascripts/analytics/instance_statistics/components/instance_statistics_count_chart.vue)6
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/users_chart.vue (renamed from app/assets/javascripts/analytics/instance_statistics/components/users_chart.vue)2
-rw-r--r--app/assets/javascripts/analytics/usage_trends/constants.js (renamed from app/assets/javascripts/analytics/instance_statistics/constants.js)0
-rw-r--r--app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql4
-rw-r--r--app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_count.query.graphql (renamed from app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_count.query.graphql)2
-rw-r--r--app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_trends_count.query.graphql34
-rw-r--r--app/assets/javascripts/analytics/usage_trends/graphql/queries/users.query.graphql (renamed from app/assets/javascripts/analytics/instance_statistics/graphql/queries/users.query.graphql)2
-rw-r--r--app/assets/javascripts/analytics/usage_trends/index.js (renamed from app/assets/javascripts/analytics/instance_statistics/index.js)6
-rw-r--r--app/assets/javascripts/analytics/usage_trends/utils.js (renamed from app/assets/javascripts/analytics/instance_statistics/utils.js)6
-rw-r--r--app/assets/javascripts/api.js24
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue2
-rw-r--r--app/assets/javascripts/awards_handler.js9
-rw-r--r--app/assets/javascripts/badges/components/badge.vue2
-rw-r--r--app/assets/javascripts/badges/components/badge_form.vue4
-rw-r--r--app/assets/javascripts/batch_comments/components/publish_button.vue3
-rw-r--r--app/assets/javascripts/batch_comments/components/review_bar.vue5
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js22
-rw-r--r--app/assets/javascripts/behaviors/copy_to_clipboard.js38
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_math.js99
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/keybindings.js135
-rw-r--r--app/assets/javascripts/behaviors/toggler_behavior.js2
-rw-r--r--app/assets/javascripts/blob/blob_file_dropzone.js1
-rw-r--r--app/assets/javascripts/blob/viewer/index.js9
-rw-r--r--app/assets/javascripts/boards/boards_util.js28
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column.vue143
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column_form.vue131
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column_trigger.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue83
-rw-r--r--app/assets/javascripts/boards/components/board_card_deprecated.vue61
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue (renamed from app/assets/javascripts/boards/components/issue_card_inner.vue)84
-rw-r--r--app/assets/javascripts/boards/components/board_card_layout.vue98
-rw-r--r--app/assets/javascripts/boards/components/board_card_layout_deprecated.vue3
-rw-r--r--app/assets/javascripts/boards/components/board_column.vue21
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue31
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue75
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue91
-rw-r--r--app/assets/javascripts/boards/components/board_list_deprecated.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue51
-rw-r--r--app/assets/javascripts/boards/components/board_list_header_deprecated.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.vue7
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue_deprecated.vue6
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js6
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue43
-rw-r--r--app/assets/javascripts/boards/components/boards_selector_deprecated.vue7
-rw-r--r--app/assets/javascripts/boards/components/config_toggle.vue64
-rw-r--r--app/assets/javascripts/boards/components/filtered_search.vue54
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue6
-rw-r--r--app/assets/javascripts/boards/components/issue_due_date.vue6
-rw-r--r--app/assets/javascripts/boards/components/issue_time_estimate.vue2
-rw-r--r--app/assets/javascripts/boards/components/item_count.vue (renamed from app/assets/javascripts/boards/components/issue_count.vue)10
-rw-r--r--app/assets/javascripts/boards/components/modal/list.vue6
-rw-r--r--app/assets/javascripts/boards/components/project_select.vue11
-rw-r--r--app/assets/javascripts/boards/components/project_select_deprecated.vue1
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue2
-rw-r--r--app/assets/javascripts/boards/components/sidebar/remove_issue.vue88
-rw-r--r--app/assets/javascripts/boards/config_toggle.js25
-rw-r--r--app/assets/javascripts/boards/constants.js5
-rw-r--r--app/assets/javascripts/boards/filtered_search.js25
-rw-r--r--app/assets/javascripts/boards/graphql/board_labels.query.graphql4
-rw-r--r--app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql20
-rw-r--r--app/assets/javascripts/boards/graphql/group_projects.query.graphql1
-rw-r--r--app/assets/javascripts/boards/graphql/users_search.query.graphql11
-rw-r--r--app/assets/javascripts/boards/index.js29
-rw-r--r--app/assets/javascripts/boards/mixins/board_card_inner.js (renamed from app/assets/javascripts/boards/mixins/issue_card_inner.js)4
-rw-r--r--app/assets/javascripts/boards/mount_multiple_boards_switcher.js6
-rw-r--r--app/assets/javascripts/boards/stores/actions.js107
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js4
-rw-r--r--app/assets/javascripts/boards/stores/getters.js24
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js10
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js113
-rw-r--r--app/assets/javascripts/boards/stores/state.js14
-rw-r--r--app/assets/javascripts/captcha/captcha_modal.vue10
-rw-r--r--app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js37
-rw-r--r--app/assets/javascripts/captcha/unsolved_captcha_error.js10
-rw-r--r--app/assets/javascripts/captcha/wait_for_captcha_to_be_solved.js53
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue12
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue1
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue2
-rw-r--r--app/assets/javascripts/clusters/components/fluentd_output_settings.vue20
-rw-r--r--app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue22
-rw-r--r--app/assets/javascripts/clusters/forms/components/integration_form.vue14
-rw-r--r--app/assets/javascripts/clusters_list/store/actions.js2
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_bundle.js2
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.vue18
-rw-r--r--app/assets/javascripts/commons/bootstrap.js67
-rw-r--r--app/assets/javascripts/commons/vue.js2
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/constants.js5
-rw-r--r--app/assets/javascripts/cycle_analytics/components/base.vue288
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js156
-rw-r--r--app/assets/javascripts/cycle_analytics/index.js32
-rw-r--r--app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js43
-rw-r--r--app/assets/javascripts/design_management/components/delete_button.vue16
-rw-r--r--app/assets/javascripts/design_management/components/design_destroyer.vue1
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_discussion.vue6
-rw-r--r--app/assets/javascripts/design_management/components/list/item.vue1
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/index.vue2
-rw-r--r--app/assets/javascripts/design_management/components/upload/button.vue2
-rw-r--r--app/assets/javascripts/design_management/graphql.js1
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue5
-rw-r--r--app/assets/javascripts/design_management/utils/cache_update.js3
-rw-r--r--app/assets/javascripts/diffs/components/app.vue14
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussion_reply.vue5
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue14
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue6
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_table_row.vue6
-rw-r--r--app/assets/javascripts/diffs/components/settings_dropdown.vue46
-rw-r--r--app/assets/javascripts/diffs/constants.js1
-rw-r--r--app/assets/javascripts/diffs/i18n.js1
-rw-r--r--app/assets/javascripts/diffs/index.js2
-rw-r--r--app/assets/javascripts/diffs/store/actions.js5
-rw-r--r--app/assets/javascripts/diffs/store/utils.js2
-rw-r--r--app/assets/javascripts/diffs/utils/file_reviews.js7
-rw-r--r--app/assets/javascripts/diffs/utils/preferences.js13
-rw-r--r--app/assets/javascripts/editor/constants.js3
-rw-r--r--app/assets/javascripts/editor/extensions/editor_lite_webide_ext.js164
-rw-r--r--app/assets/javascripts/emoji/components/category.vue61
-rw-r--r--app/assets/javascripts/emoji/components/emoji_group.vue35
-rw-r--r--app/assets/javascripts/emoji/components/emoji_list.vue44
-rw-r--r--app/assets/javascripts/emoji/components/picker.vue121
-rw-r--r--app/assets/javascripts/emoji/components/utils.js27
-rw-r--r--app/assets/javascripts/emoji/constants.js14
-rw-r--r--app/assets/javascripts/emoji/index.js16
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue6
-rw-r--r--app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js2
-rw-r--r--app/assets/javascripts/environments/services/environments_service.js4
-rw-r--r--app/assets/javascripts/experimentation/constants.js1
-rw-r--r--app/assets/javascripts/experimentation/experiment_tracking.js24
-rw-r--r--app/assets/javascripts/experimentation/utils.js10
-rw-r--r--app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue13
-rw-r--r--app/assets/javascripts/feature_flags/components/edit_feature_flag.vue2
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags.vue8
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags_table.vue7
-rw-r--r--app/assets/javascripts/flash.js2
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js78
-rw-r--r--app/assets/javascripts/grafana_integration/components/grafana_integration.vue26
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql6
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/epic.fragment.graphql1
-rw-r--r--app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql14
-rw-r--r--app/assets/javascripts/graphql_shared/queries/users_search.query.graphql12
-rw-r--r--app/assets/javascripts/graphql_shared/utils.js34
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue43
-rw-r--r--app/assets/javascripts/groups/components/invite_members_banner.vue4
-rw-r--r--app/assets/javascripts/groups/components/item_caret.vue4
-rw-r--r--app/assets/javascripts/groups_select.js30
-rw-r--r--app/assets/javascripts/helpers/cve_id_request_helper.js50
-rw-r--r--app/assets/javascripts/ide/components/branches/item.vue2
-rw-r--r--app/assets/javascripts/ide/components/branches/search_list.vue7
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/form.vue18
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue1
-rw-r--r--app/assets/javascripts/ide/components/file_templates/bar.vue4
-rw-r--r--app/assets/javascripts/ide/components/ide.vue13
-rw-r--r--app/assets/javascripts/ide/components/merge_requests/item.vue2
-rw-r--r--app/assets/javascripts/ide/components/merge_requests/list.vue9
-rw-r--r--app/assets/javascripts/ide/components/nav_form.vue25
-rw-r--r--app/assets/javascripts/ide/components/pipelines/list.vue3
-rw-r--r--app/assets/javascripts/ide/components/preview/navigator.vue2
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue81
-rw-r--r--app/assets/javascripts/ide/components/repo_tab.vue46
-rw-r--r--app/assets/javascripts/ide/components/repo_tabs.vue6
-rw-r--r--app/assets/javascripts/ide/components/shared/tokened_input.vue4
-rw-r--r--app/assets/javascripts/ide/constants.js1
-rw-r--r--app/assets/javascripts/ide/index.js1
-rw-r--r--app/assets/javascripts/ide/messages.js17
-rw-r--r--app/assets/javascripts/ide/queries/getUserPermissions.query.graphql9
-rw-r--r--app/assets/javascripts/ide/queries/get_ide_project.query.graphql7
-rw-r--r--app/assets/javascripts/ide/queries/ide_project.fragment.graphql7
-rw-r--r--app/assets/javascripts/ide/services/index.js4
-rw-r--r--app/assets/javascripts/ide/stores/getters.js44
-rw-r--r--app/assets/javascripts/import_entities/components/import_status.vue2
-rw-r--r--app/assets/javascripts/import_entities/constants.js6
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table.vue92
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue147
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js78
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/queries/group.query.graphql5
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js83
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js12
-rw-r--r--app/assets/javascripts/import_entities/import_groups/index.js3
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue10
-rw-r--r--app/assets/javascripts/incidents_settings/constants.js4
-rw-r--r--app/assets/javascripts/integrations/edit/components/active_checkbox.vue2
-rw-r--r--app/assets/javascripts/integrations/edit/components/confirmation_modal.vue2
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue17
-rw-r--r--app/assets/javascripts/invite_members/components/group_select.vue103
-rw-r--r--app/assets/javascripts/invite_members/components/invite_group_trigger.vue34
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue148
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_trigger.vue29
-rw-r--r--app/assets/javascripts/invite_members/components/members_token_select.vue4
-rw-r--r--app/assets/javascripts/invite_members/constants.js2
-rw-r--r--app/assets/javascripts/invite_members/init_invite_group_trigger.js20
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_form.js7
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_modal.js1
-rw-r--r--app/assets/javascripts/issuable/components/csv_export_modal.vue100
-rw-r--r--app/assets/javascripts/issuable/components/csv_import_export_buttons.vue107
-rw-r--r--app/assets/javascripts/issuable/components/csv_import_modal.vue86
-rw-r--r--app/assets/javascripts/issuable/constants.js6
-rw-r--r--app/assets/javascripts/issuable/init_csv_import_export_buttons.js45
-rw-r--r--app/assets/javascripts/issuable_form.js6
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_item.vue65
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_list_root.vue11
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_body.vue70
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_description.vue23
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_discussion.vue15
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_header.vue32
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_show_root.vue36
-rw-r--r--app/assets/javascripts/issue.js9
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue51
-rw-r--r--app/assets/javascripts/issue_show/components/description.vue15
-rw-r--r--app/assets/javascripts/issue_show/components/edit_actions.vue16
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description_template.vue4
-rw-r--r--app/assets/javascripts/issue_show/components/form.vue6
-rw-r--r--app/assets/javascripts/issue_show/components/header_actions.vue3
-rw-r--r--app/assets/javascripts/issue_show/services/index.js2
-rw-r--r--app/assets/javascripts/issue_show/stores/index.js2
-rw-r--r--app/assets/javascripts/issue_show/utils/parse_data.js2
-rw-r--r--app/assets/javascripts/issues_list/components/issuable.vue31
-rw-r--r--app/assets/javascripts/issues_list/components/issue_card_time_info.vue124
-rw-r--r--app/assets/javascripts/issues_list/components/issues_list_app.vue143
-rw-r--r--app/assets/javascripts/issues_list/components/jira_issues_import_status_app.vue (renamed from app/assets/javascripts/issues_list/components/jira_issues_list_root.vue)6
-rw-r--r--app/assets/javascripts/issues_list/index.js42
-rw-r--r--app/assets/javascripts/issues_list/service_desk_helper.js30
-rw-r--r--app/assets/javascripts/jira_connect/components/app.vue48
-rw-r--r--app/assets/javascripts/jira_connect/components/groups_list_item.vue11
-rw-r--r--app/assets/javascripts/jira_connect/constants.js1
-rw-r--r--app/assets/javascripts/jira_connect/index.js5
-rw-r--r--app/assets/javascripts/jira_connect/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/jira_connect/store/mutations.js6
-rw-r--r--app/assets/javascripts/jira_connect/store/state.js2
-rw-r--r--app/assets/javascripts/jira_connect/utils.js33
-rw-r--r--app/assets/javascripts/jira_import/utils/cache_update.js3
-rw-r--r--app/assets/javascripts/jobs/components/artifacts_block.vue2
-rw-r--r--app/assets/javascripts/jobs/components/commit_block.vue11
-rw-r--r--app/assets/javascripts/jobs/components/job_container_item.vue2
-rw-r--r--app/assets/javascripts/jobs/components/job_log_controllers.vue15
-rw-r--r--app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue4
-rw-r--r--app/assets/javascripts/jobs/components/sidebar.vue74
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_job_details_container.vue2
-rw-r--r--app/assets/javascripts/jobs/components/stages_dropdown.vue2
-rw-r--r--app/assets/javascripts/jobs/components/trigger_block.vue2
-rw-r--r--app/assets/javascripts/jobs/store/getters.js2
-rw-r--r--app/assets/javascripts/jobs/svg/scroll_down.svg4
-rw-r--r--app/assets/javascripts/lib/chrome_84_icon_fix.js78
-rw-r--r--app/assets/javascripts/lib/graphql.js3
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js107
-rw-r--r--app/assets/javascripts/lib/utils/experimentation.js3
-rw-r--r--app/assets/javascripts/lib/utils/http_status.js1
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js21
-rw-r--r--app/assets/javascripts/lib/utils/select2_utils.js25
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js6
-rw-r--r--app/assets/javascripts/lib/utils/unit_format/formatter_factory.js70
-rw-r--r--app/assets/javascripts/lib/utils/unit_format/index.js342
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js5
-rw-r--r--app/assets/javascripts/locale/index.js20
-rw-r--r--app/assets/javascripts/main.js2
-rw-r--r--app/assets/javascripts/members.js111
-rw-r--r--app/assets/javascripts/members/components/avatars/user_avatar.vue8
-rw-r--r--app/assets/javascripts/members/utils.js4
-rw-r--r--app/assets/javascripts/merge_conflicts/components/diff_file_editor.js115
-rw-r--r--app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue128
-rw-r--r--app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js22
-rw-r--r--app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue47
-rw-r--r--app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js37
-rw-r--r--app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue47
-rw-r--r--app/assets/javascripts/merge_conflicts/constants.js20
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue217
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js26
-rw-r--r--app/assets/javascripts/merge_conflicts/store/actions.js120
-rw-r--r--app/assets/javascripts/merge_conflicts/store/getters.js117
-rw-r--r--app/assets/javascripts/merge_conflicts/store/index.js16
-rw-r--r--app/assets/javascripts/merge_conflicts/store/mutation_types.js8
-rw-r--r--app/assets/javascripts/merge_conflicts/store/mutations.js40
-rw-r--r--app/assets/javascripts/merge_conflicts/store/state.js13
-rw-r--r--app/assets/javascripts/merge_conflicts/utils.js228
-rw-r--r--app/assets/javascripts/merge_request_tabs.js2
-rw-r--r--app/assets/javascripts/milestone.js27
-rw-r--r--app/assets/javascripts/milestone_select.js2
-rw-r--r--app/assets/javascripts/monitoring/components/charts/anomaly.vue1
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js2
-rw-r--r--app/assets/javascripts/monitoring/stores/embed_group/index.js2
-rw-r--r--app/assets/javascripts/network/branch_graph.js8
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue208
-rw-r--r--app/assets/javascripts/notes/components/discussion_actions.vue4
-rw-r--r--app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue2
-rw-r--r--app/assets/javascripts/notes/components/discussion_reply_placeholder.vue29
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue92
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue4
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue9
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue3
-rw-r--r--app/assets/javascripts/notes/components/sidebar_subscription.vue58
-rw-r--r--app/assets/javascripts/notes/i18n.js26
-rw-r--r--app/assets/javascripts/notes/stores/actions.js44
-rw-r--r--app/assets/javascripts/notes/stores/utils.js4
-rw-r--r--app/assets/javascripts/notifications/components/custom_notifications_modal.vue17
-rw-r--r--app/assets/javascripts/notifications/components/notifications_dropdown.vue71
-rw-r--r--app/assets/javascripts/notifications/components/notifications_dropdown_item.vue8
-rw-r--r--app/assets/javascripts/notifications/constants.js5
-rw-r--r--app/assets/javascripts/notifications_dropdown.js35
-rw-r--r--app/assets/javascripts/notifications_form.js48
-rw-r--r--app/assets/javascripts/packages/details/components/composer_installation.vue5
-rw-r--r--app/assets/javascripts/packages/details/components/conan_installation.vue5
-rw-r--r--app/assets/javascripts/packages/details/components/installation_title.vue38
-rw-r--r--app/assets/javascripts/packages/details/components/maven_installation.vue128
-rw-r--r--app/assets/javascripts/packages/details/components/npm_installation.vue5
-rw-r--r--app/assets/javascripts/packages/details/components/nuget_installation.vue6
-rw-r--r--app/assets/javascripts/packages/details/components/pypi_installation.vue5
-rw-r--r--app/assets/javascripts/packages/details/constants.js3
-rw-r--r--app/assets/javascripts/packages/details/store/getters.js16
-rw-r--r--app/assets/javascripts/packages/list/constants.js2
-rw-r--r--app/assets/javascripts/packages/shared/components/package_list_row.vue2
-rw-r--r--app/assets/javascripts/packages/shared/utils.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue5
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue3
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js1
-rw-r--r--app/assets/javascripts/pages/admin/abuse_reports/index.js7
-rw-r--r--app/assets/javascripts/pages/admin/admin.js32
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/general/index.js26
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/index.js18
-rw-r--r--app/assets/javascripts/pages/admin/dev_ops_report/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/impersonation_tokens/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/index.js10
-rw-r--r--app/assets/javascripts/pages/admin/instance_statistics/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/projects/index.js12
-rw-r--r--app/assets/javascripts/pages/admin/projects/index/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/runners/index.js14
-rw-r--r--app/assets/javascripts/pages/admin/services/index/index.js6
-rw-r--r--app/assets/javascripts/pages/admin/usage_trends/index.js3
-rw-r--r--app/assets/javascripts/pages/dashboard/issues/index.js16
-rw-r--r--app/assets/javascripts/pages/dashboard/milestones/show/index.js2
-rw-r--r--app/assets/javascripts/pages/dashboard/projects/index/index.js7
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/activity/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js9
-rw-r--r--app/assets/javascripts/pages/groups/milestones/show/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/new/index.js14
-rw-r--r--app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js27
-rw-r--r--app/assets/javascripts/pages/groups/settings/integrations/edit/index.js16
-rw-r--r--app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/settings/repository/show/index.js11
-rw-r--r--app/assets/javascripts/pages/groups/shared/group_details.js13
-rw-r--r--app/assets/javascripts/pages/groups/show/index.js6
-rw-r--r--app/assets/javascripts/pages/profiles/index.js28
-rw-r--r--app/assets/javascripts/pages/profiles/index/index.js7
-rw-r--r--app/assets/javascripts/pages/profiles/notifications/show/index.js4
-rw-r--r--app/assets/javascripts/pages/profiles/personal_access_tokens/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/blob/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/blob/new/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js98
-rw-r--r--app/assets/javascripts/pages/projects/boards/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/branches/index/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/branches/new/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/compare/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/cycle_analytics/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js22
-rw-r--r--app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js28
-rw-r--r--app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js19
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/app.vue72
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue304
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue20
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/index.js48
-rw-r--r--app/assets/javascripts/pages/projects/imports/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/issues/index/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/jobs/index/index.js30
-rw-r--r--app/assets/javascripts/pages/projects/labels/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue8
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue109
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue70
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js56
-rw-r--r--app/assets/javascripts/pages/projects/logs/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/edit/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/index/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/milestones/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/milestones/index/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/milestones/new/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/milestones/show/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/new/index.js42
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js110
-rw-r--r--app/assets/javascripts/pages/projects/prometheus/metrics/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/prometheus/metrics/new/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/settings/access_tokens/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/settings/operations/show/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue42
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/constants.js6
-rw-r--r--app/assets/javascripts/pages/projects/show/index.js22
-rw-r--r--app/assets/javascripts/pages/projects/tags/new/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/tags/releases/index.js6
-rw-r--r--app/assets/javascripts/pages/search/show/index.js4
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_alert.vue31
-rw-r--r--app/assets/javascripts/pages/shared/wikis/index.js28
-rw-r--r--app/assets/javascripts/pages/users/index.js10
-rw-r--r--app/assets/javascripts/performance/constants.js21
-rw-r--r--app/assets/javascripts/performance_bar/components/detailed_metric.vue2
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue9
-rw-r--r--app/assets/javascripts/performance_bar/index.js2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue16
-rw-r--r--app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue34
-rw-r--r--app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue47
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue120
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue18
-rw-r--r--app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue56
-rw-r--r--app/assets/javascripts/pipeline_editor/constants.js4
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql3
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.graphql3
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/client/is_new_ci_config_file.graphql3
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql17
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/resolvers.js23
-rw-r--r--app/assets/javascripts/pipeline_editor/index.js4
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue91
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue1
-rw-r--r--app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue189
-rw-r--r--app/assets/javascripts/pipeline_new/components/refs_dropdown.vue113
-rw-r--r--app/assets/javascripts/pipeline_new/constants.js1
-rw-r--r--app/assets/javascripts/pipeline_new/index.js14
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue29
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue71
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue35
-rw-r--r--app/assets/javascripts/pipelines/components/graph/utils.js97
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/api.js8
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue74
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue115
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue15
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue71
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue54
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue119
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue152
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue12
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue92
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue10
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_commit.vue85
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue (renamed from app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue)1
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue37
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue212
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue61
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/stage.vue234
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue49
-rw-r--r--app/assets/javascripts/pipelines/constants.js3
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js3
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_graph.js9
-rw-r--r--app/assets/javascripts/pipelines/pipelines_index.js4
-rw-r--r--app/assets/javascripts/profile/account/components/update_username.vue8
-rw-r--r--app/assets/javascripts/profile/preferences/components/profile_preferences.vue26
-rw-r--r--app/assets/javascripts/profile/profile.js1
-rw-r--r--app/assets/javascripts/projects/commit/components/branches_dropdown.vue23
-rw-r--r--app/assets/javascripts/projects/commit/components/form_modal.vue30
-rw-r--r--app/assets/javascripts/projects/commit/components/projects_dropdown.vue87
-rw-r--r--app/assets/javascripts/projects/commit/constants.js13
-rw-r--r--app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js8
-rw-r--r--app/assets/javascripts/projects/commit/store/actions.js20
-rw-r--r--app/assets/javascripts/projects/commit/store/getters.js2
-rw-r--r--app/assets/javascripts/projects/commit/store/mutation_types.js3
-rw-r--r--app/assets/javascripts/projects/commit/store/mutations.js9
-rw-r--r--app/assets/javascripts/projects/commit/store/state.js3
-rw-r--r--app/assets/javascripts/projects/commit_box/info/index.js14
-rw-r--r--app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js26
-rw-r--r--app/assets/javascripts/projects/commits/store/actions.js2
-rw-r--r--app/assets/javascripts/projects/compare/components/app.vue79
-rw-r--r--app/assets/javascripts/projects/compare/components/app_legacy.vue89
-rw-r--r--app/assets/javascripts/projects/compare/components/repo_dropdown.vue93
-rw-r--r--app/assets/javascripts/projects/compare/components/revision_card.vue65
-rw-r--r--app/assets/javascripts/projects/compare/components/revision_dropdown.vue115
-rw-r--r--app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue145
-rw-r--r--app/assets/javascripts/projects/compare/index.js42
-rw-r--r--app/assets/javascripts/projects/details/upload_button.vue59
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/components/new_project_push_tip_popover.vue66
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue14
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/index.js6
-rw-r--r--app/assets/javascripts/projects/feature_flags_user_lists/show/index.js23
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue6
-rw-r--r--app/assets/javascripts/projects/project_new.js35
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue2
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue12
-rw-r--r--app/assets/javascripts/projects/upload_file_experiment.js33
-rw-r--r--app/assets/javascripts/projects/upload_file_experiment_tracking.js9
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_create.js7
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_edit.js21
-rw-r--r--app/assets/javascripts/ref/components/ref_results_section.vue8
-rw-r--r--app/assets/javascripts/ref/components/ref_selector.vue239
-rw-r--r--app/assets/javascripts/ref/constants.js5
-rw-r--r--app/assets/javascripts/ref/stores/actions.js17
-rw-r--r--app/assets/javascripts/ref/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/ref/stores/mutations.js3
-rw-r--r--app/assets/javascripts/ref/stores/state.js25
-rw-r--r--app/assets/javascripts/registry/explorer/components/delete_button.vue1
-rw-r--r--app/assets/javascripts/registry/explorer/components/delete_image.vue1
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/details_header.vue32
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue1
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/image_list.vue1
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue7
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue19
-rw-r--r--app/assets/javascripts/registry/explorer/constants/common.js3
-rw-r--r--app/assets/javascripts/registry/explorer/constants/details.js8
-rw-r--r--app/assets/javascripts/registry/explorer/constants/expiration_policies.js3
-rw-r--r--app/assets/javascripts/registry/explorer/constants/index.js1
-rw-r--r--app/assets/javascripts/registry/explorer/pages/details.vue7
-rw-r--r--app/assets/javascripts/registry/explorer/pages/list.vue1
-rw-r--r--app/assets/javascripts/registry/settings/components/expiration_toggle.vue12
-rw-r--r--app/assets/javascripts/registry/settings/graphql/utils/cache_update.js1
-rw-r--r--app/assets/javascripts/related_issues/components/related_issuable_input.vue1
-rw-r--r--app/assets/javascripts/releases/components/app_edit_new.vue11
-rw-r--r--app/assets/javascripts/releases/components/asset_links_form.vue3
-rw-r--r--app/assets/javascripts/releases/components/tag_field_new.vue70
-rw-r--r--app/assets/javascripts/reports/components/grouped_issues_list.vue13
-rw-r--r--app/assets/javascripts/reports/components/issue_body.js2
-rw-r--r--app/assets/javascripts/reports/components/issues_list.vue13
-rw-r--r--app/assets/javascripts/reports/components/report_section.vue5
-rw-r--r--app/assets/javascripts/reports/components/summary_row.vue32
-rw-r--r--app/assets/javascripts/reports/components/test_issue_body.vue64
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/components/modal.vue (renamed from app/assets/javascripts/reports/components/modal.vue)2
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue64
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue (renamed from app/assets/javascripts/reports/components/grouped_test_reports_app.vue)54
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/actions.js (renamed from app/assets/javascripts/reports/store/actions.js)6
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/getters.js (renamed from app/assets/javascripts/reports/store/getters.js)2
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/index.js (renamed from app/assets/javascripts/reports/store/index.js)0
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/mutation_types.js (renamed from app/assets/javascripts/reports/store/mutation_types.js)0
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/mutations.js (renamed from app/assets/javascripts/reports/store/mutations.js)0
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/state.js (renamed from app/assets/javascripts/reports/store/state.js)2
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/utils.js (renamed from app/assets/javascripts/reports/store/utils.js)2
-rw-r--r--app/assets/javascripts/repository/components/upload_blob_modal.vue220
-rw-r--r--app/assets/javascripts/repository/index.js19
-rw-r--r--app/assets/javascripts/right_sidebar.js3
-rw-r--r--app/assets/javascripts/security_configuration/components/configuration_table.vue23
-rw-r--r--app/assets/javascripts/security_configuration/components/manage_sast.vue8
-rw-r--r--app/assets/javascripts/security_configuration/components/scanners_constants.js (renamed from app/assets/javascripts/security_configuration/components/features_constants.js)64
-rw-r--r--app/assets/javascripts/security_configuration/components/upgrade.vue10
-rw-r--r--app/assets/javascripts/security_configuration/index.js3
-rw-r--r--app/assets/javascripts/self_monitor/components/self_monitor_form.vue4
-rw-r--r--app/assets/javascripts/sentry/sentry_config.js2
-rw-r--r--app/assets/javascripts/sentry/wrapper.js26
-rw-r--r--app/assets/javascripts/shared/popover.js33
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue9
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue50
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue113
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/edit_form.vue64
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue81
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue64
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue136
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue142
-rw-r--r--app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue84
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue16
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_editable_item.vue37
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/help_state.vue6
-rw-r--r--app/assets/javascripts/sidebar/constants.js28
-rw-r--r--app/assets/javascripts/sidebar/graphql.js8
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js110
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql10
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql10
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_reference.query.graphql10
-rw-r--r--app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql10
-rw-r--r--app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql7
-rw-r--r--app/assets/javascripts/sidebar/queries/update_epic_confidential.mutation.graphql9
-rw-r--r--app/assets/javascripts/sidebar/queries/update_issue_confidential.mutation.graphql (renamed from app/assets/javascripts/sidebar/components/confidential/mutations/update_issue_confidential.mutation.graphql)5
-rw-r--r--app/assets/javascripts/sidebar/services/sidebar_service.js15
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js1
-rw-r--r--app/assets/javascripts/single_file_diff.js2
-rw-r--r--app/assets/javascripts/snippets/components/edit.vue5
-rw-r--r--app/assets/javascripts/snippets/components/snippet_visibility_edit.vue7
-rw-r--r--app/assets/javascripts/static_site_editor/constants.js2
-rw-r--r--app/assets/javascripts/tooltips/components/tooltips.vue6
-rw-r--r--app/assets/javascripts/tooltips/index.js1
-rw-r--r--app/assets/javascripts/tracking.js62
-rw-r--r--app/assets/javascripts/user_popovers.js27
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue92
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue25
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue49
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue37
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue48
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue82
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue36
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue96
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js3
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue10
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue2
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue1
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/index.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue22
-rw-r--r--app/assets/javascripts/vue_shared/components/changed_file_icon.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_icon.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/clipboard_button.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/code_instruction.vue40
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/list_item.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue59
-rw-r--r--app/assets/javascripts/vue_shared/components/select2_select.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/settings/settings_block.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql4
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql4
-rw-r--r--app/assets/javascripts/vue_shared/components/tabs/tab.vue47
-rw-r--r--app/assets/javascripts/vue_shared/components/tabs/tabs.js76
-rw-r--r--app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/user_access_role_badge.vue22
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue16
-rw-r--r--app/assets/javascripts/vue_shared/directives/tooltip.js35
-rw-r--r--app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js7
-rw-r--r--app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js2
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue2
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/constants.js3
-rw-r--r--app/assets/stylesheets/_page_specific_files.scss2
-rw-r--r--app/assets/stylesheets/bootstrap_migration.scss23
-rw-r--r--app/assets/stylesheets/components/avatar.scss5
-rw-r--r--app/assets/stylesheets/components/milestone_combobox.scss18
-rw-r--r--app/assets/stylesheets/components/ref_selector.scss16
-rw-r--r--app/assets/stylesheets/disable_animations.scss6
-rw-r--r--app/assets/stylesheets/framework.scss1
-rw-r--r--app/assets/stylesheets/framework/awards.scss2
-rw-r--r--app/assets/stylesheets/framework/buttons.scss40
-rw-r--r--app/assets/stylesheets/framework/diffs.scss1
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss26
-rw-r--r--app/assets/stylesheets/framework/editor-lite.scss5
-rw-r--r--app/assets/stylesheets/framework/emojis.scss23
-rw-r--r--app/assets/stylesheets/framework/filters.scss3
-rw-r--r--app/assets/stylesheets/framework/forms.scss3
-rw-r--r--app/assets/stylesheets/framework/header.scss1
-rw-r--r--app/assets/stylesheets/framework/highlight.scss3
-rw-r--r--app/assets/stylesheets/framework/layout.scss3
-rw-r--r--app/assets/stylesheets/framework/lists.scss11
-rw-r--r--app/assets/stylesheets/framework/mixins.scss1
-rw-r--r--app/assets/stylesheets/framework/modal.scss16
-rw-r--r--app/assets/stylesheets/framework/tooltips.scss6
-rw-r--r--app/assets/stylesheets/framework/typography.scss21
-rw-r--r--app/assets/stylesheets/framework/variables.scss6
-rw-r--r--app/assets/stylesheets/highlight/conflict_colors.scss2
-rw-r--r--app/assets/stylesheets/highlight/themes/dark.scss2
-rw-r--r--app/assets/stylesheets/highlight/themes/monokai.scss2
-rw-r--r--app/assets/stylesheets/highlight/white_base.scss6
-rw-r--r--app/assets/stylesheets/lazy_bundles/select2_overrides.scss16
-rw-r--r--app/assets/stylesheets/mailer_client_specific.scss1
-rw-r--r--app/assets/stylesheets/mailers/highlighted_diff_email.scss6
-rw-r--r--app/assets/stylesheets/page_bundles/boards.scss11
-rw-r--r--app/assets/stylesheets/page_bundles/build.scss29
-rw-r--r--app/assets/stylesheets/page_bundles/cycle_analytics.scss32
-rw-r--r--app/assets/stylesheets/page_bundles/ide.scss3
-rw-r--r--app/assets/stylesheets/page_bundles/ide_themes/_solarized-light.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/import.scss7
-rw-r--r--app/assets/stylesheets/page_bundles/jira_connect.scss56
-rw-r--r--app/assets/stylesheets/page_bundles/learn_gitlab.scss3
-rw-r--r--app/assets/stylesheets/page_bundles/members.scss (renamed from app/assets/stylesheets/pages/members.scss)2
-rw-r--r--app/assets/stylesheets/page_bundles/oncall_schedules.scss7
-rw-r--r--app/assets/stylesheets/page_bundles/pipelines.scss19
-rw-r--r--app/assets/stylesheets/page_bundles/reports.scss5
-rw-r--r--app/assets/stylesheets/page_bundles/signup.scss5
-rw-r--r--app/assets/stylesheets/pages/clusters.scss1
-rw-r--r--app/assets/stylesheets/pages/groups.scss4
-rw-r--r--app/assets/stylesheets/pages/login.scss11
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss27
-rw-r--r--app/assets/stylesheets/pages/note_form.scss41
-rw-r--r--app/assets/stylesheets/pages/projects.scss71
-rw-r--r--app/assets/stylesheets/pages/trials.scss15
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss1
-rw-r--r--app/assets/stylesheets/startup/startup-signin.scss5
-rw-r--r--app/assets/stylesheets/test_environment.scss11
-rw-r--r--app/assets/stylesheets/themes/theme_helper.scss5
-rw-r--r--app/assets/stylesheets/themes/theme_light.scss21
-rw-r--r--app/assets/stylesheets/utilities.scss15
-rw-r--r--app/assets/stylesheets/vendors/atwho.scss5
725 files changed, 14407 insertions, 6592 deletions
diff --git a/app/assets/images/learn_gitlab/code_owners_enabled.svg b/app/assets/images/learn_gitlab/code_owners_enabled.svg
new file mode 100644
index 00000000000..019d74c64cc
--- /dev/null
+++ b/app/assets/images/learn_gitlab/code_owners_enabled.svg
@@ -0,0 +1,5 @@
+<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M13 25C19.6274 25 25 19.6274 25 13C25 6.37258 19.6274 1 13 1C6.37258 1 1 6.37258 1 13C1 19.6274 6.37258 25 13 25Z" fill="white" stroke="#C2B7E6" stroke-width="2"/>
+<path d="M1.16748 12.3359C2.88075 11.7701 4.4618 10.8635 5.81545 9.67055C7.16911 8.47763 8.26738 7.02313 9.04415 5.39461M6.94481 2.60461C9.28681 6.43995 13.5115 8.99995 18.3335 8.99995C20.2715 8.99995 22.1135 8.58661 23.7748 7.84261L6.94481 2.60461Z" stroke="#C2B7E6"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M10.1176 15.8941H15.7647C15.7647 17.447 14.4941 18.7176 12.9412 18.7176C11.3882 18.7176 10.1176 17.447 10.1176 15.8941ZM9.05882 15.1882C8.47294 15.1882 8 14.7153 8 14.1294C8 13.5435 8.47294 13.0706 9.05882 13.0706C9.64471 13.0706 10.1176 13.5435 10.1176 14.1294C10.1176 14.7153 9.64471 15.1882 9.05882 15.1882ZM16.8235 15.1882C16.2376 15.1882 15.7647 14.7153 15.7647 14.1294C15.7647 13.5435 16.2376 13.0706 16.8235 13.0706C17.4094 13.0706 17.8824 13.5435 17.8824 14.1294C17.8824 14.7153 17.4094 15.1882 16.8235 15.1882Z" fill="#6B4FBB"/>
+</svg>
diff --git a/app/assets/images/learn_gitlab/git_write.svg b/app/assets/images/learn_gitlab/git_write.svg
new file mode 100644
index 00000000000..ad87b3f3b12
--- /dev/null
+++ b/app/assets/images/learn_gitlab/git_write.svg
@@ -0,0 +1,16 @@
+<svg width="40" height="39" viewBox="0 0 40 39" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0)">
+<path d="M32.2886 3.99573H37.8624C38.1834 3.99573 38.4435 4.25587 38.4435 4.57679V33.9598C38.4435 34.2808 38.1834 34.5409 37.8624 34.5409H32.2886V3.99573Z" fill="#F0F0F0" stroke="#DBDBDB" stroke-width="2"/>
+<path d="M10.757 9.4011L10.7363 4.92686C10.7337 4.35386 11.1491 3.86447 11.7148 3.77395L30.908 0.703095C31.614 0.590124 32.2537 1.13556 32.2537 1.85062V37.0106C32.2537 37.723 31.6184 38.2678 30.9143 38.1591L11.8555 35.2171C11.2908 35.13 10.8733 34.6453 10.8707 34.074L10.8502 29.6368" stroke="#DBDBDB" stroke-width="2"/>
+<path d="M11.2195 29.7561C16.877 29.7561 21.4634 25.1698 21.4634 19.5122C21.4634 13.8547 16.877 9.26831 11.2195 9.26831C5.56194 9.26831 0.975586 13.8547 0.975586 19.5122C0.975586 25.1698 5.56194 29.7561 11.2195 29.7561Z" stroke="#6E49CB"/>
+<path d="M11.2194 27.8048C15.7994 27.8048 19.5121 24.0921 19.5121 19.5122C19.5121 14.9322 15.7994 11.2195 11.2194 11.2195C6.63952 11.2195 2.92676 14.9322 2.92676 19.5122C2.92676 24.0921 6.63952 27.8048 11.2194 27.8048Z" fill="#6E49CB"/>
+<path d="M11.2194 27.8048C15.7994 27.8048 19.5121 24.0921 19.5121 19.5122C19.5121 14.9322 15.7994 11.2195 11.2194 11.2195C6.63952 11.2195 2.92676 14.9322 2.92676 19.5122C2.92676 24.0921 6.63952 27.8048 11.2194 27.8048Z" fill="white" fill-opacity="0.9"/>
+<path d="M10.8843 23.4146V16.276" stroke="#6E49CB" stroke-linecap="round"/>
+<path d="M7.31689 19.6609H14.634" stroke="#6E49CB" stroke-linecap="round"/>
+</g>
+<defs>
+<clipPath id="clip0">
+<rect width="40" height="39.0244" fill="white"/>
+</clipPath>
+</defs>
+</svg>
diff --git a/app/assets/images/learn_gitlab/merge_request_created.svg b/app/assets/images/learn_gitlab/merge_request_created.svg
new file mode 100644
index 00000000000..b8137a60f06
--- /dev/null
+++ b/app/assets/images/learn_gitlab/merge_request_created.svg
@@ -0,0 +1,107 @@
+<svg width="79" height="47" viewBox="0 0 79 47" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0)">
+<path d="M27.0655 1.96289H5.80354C4.48549 1.96289 3.41699 3.03139 3.41699 4.34944C3.41699 5.6675 4.48549 6.73599 5.80354 6.73599H27.0655C28.3836 6.73599 29.4521 5.6675 29.4521 4.34944C29.4521 3.03139 28.3836 1.96289 27.0655 1.96289Z" fill="#F9F9F9"/>
+<path d="M23.1603 11.5092H-0.705247C-2.0233 11.5092 -3.0918 12.5776 -3.0918 13.8957C-3.0918 15.2138 -2.0233 16.2823 -0.705247 16.2823H23.1603C24.4783 16.2823 25.5468 15.2138 25.5468 13.8957C25.5468 12.5776 24.4783 11.5092 23.1603 11.5092Z" fill="#F9F9F9"/>
+<path d="M80.8713 16.2822H44.4222C43.1041 16.2822 42.0356 17.3507 42.0356 18.6688C42.0356 19.9868 43.1041 21.0553 44.4222 21.0553H80.8713C82.1894 21.0553 83.2579 19.9868 83.2579 18.6688C83.2579 17.3507 82.1894 16.2822 80.8713 16.2822Z" fill="#F9F9F9"/>
+<path d="M56.789 44.7039H27.2825C25.9645 44.7039 24.896 45.7724 24.896 47.0904C24.896 48.4085 25.9645 49.477 27.2825 49.477H56.789C58.107 49.477 59.1755 48.4085 59.1755 47.0904C59.1755 45.7724 58.107 44.7039 56.789 44.7039Z" fill="#F9F9F9"/>
+<path d="M43.1205 35.3746H13.6141C12.296 35.3746 11.2275 36.4431 11.2275 37.7612C11.2275 39.0792 12.296 40.1477 13.6141 40.1477H43.1205C44.4386 40.1477 45.5071 39.0792 45.5071 37.7612C45.5071 36.4431 44.4386 35.3746 43.1205 35.3746Z" fill="#F9F9F9"/>
+<path d="M77.1829 25.8284H6.02034C4.70228 25.8284 3.63379 26.8969 3.63379 28.2149C3.63379 29.533 4.70228 30.6015 6.02034 30.6015H77.1829C78.501 30.6015 79.5695 29.533 79.5695 28.2149C79.5695 26.8969 78.501 25.8284 77.1829 25.8284Z" fill="#F9F9F9"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M30.103 6.73596H1.46436H6.12898C7.43073 6.73596 8.51553 7.82076 8.51553 9.12251C8.51553 10.4243 7.43073 11.5091 6.12898 11.5091H1.46436H30.103H22.1839C20.8822 11.5091 19.7974 10.4243 19.7974 9.12251C19.7974 7.82076 20.8822 6.73596 22.1839 6.73596H30.103ZM84.7766 21.0553H59.3924H67.3114C68.6132 21.0553 69.698 22.1401 69.698 23.4418C69.698 24.7436 68.6132 25.8284 67.3114 25.8284H59.3924H84.7766H76.8576C75.5559 25.8284 74.4711 24.7436 74.4711 23.4418C74.4711 22.1401 75.5559 21.0553 76.8576 21.0553H84.7766ZM31.8386 30.6015H6.45441H14.3734C15.6752 30.6015 16.76 31.6862 16.76 32.988C16.76 34.2898 15.6752 35.3746 14.3734 35.3746H6.45441H31.8386H23.9196C22.6179 35.3746 21.5331 34.2898 21.5331 32.988C21.5331 31.6862 22.6179 30.6015 23.9196 30.6015H31.8386ZM48.1106 40.1477H22.7263H27.391C28.6927 40.1477 29.7775 41.2324 29.7775 42.5342C29.7775 43.836 28.6927 44.9207 27.391 44.9207H22.7263H48.1106H36.9372C35.6354 44.9207 34.5506 43.836 34.5506 42.5342C34.5506 41.2324 35.6354 40.1477 36.9372 40.1477H48.1106Z" fill="#F9F9F9"/>
+<path d="M68.0708 4.78333H12.0954C10.8971 4.78333 9.92578 5.75468 9.92578 6.95292V41.4494C9.92578 42.6476 10.8971 43.619 12.0954 43.619H68.0708C69.269 43.619 70.2404 42.6476 70.2404 41.4494V6.95292C70.2404 5.75468 69.269 4.78333 68.0708 4.78333Z" fill="white" stroke="#EEEEEE" stroke-width="2"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M10.7935 11.0751H69.5894V11.9429H10.7935V11.0751Z" fill="#EEEEEE"/>
+<path d="M18.387 18.0178H17.5192C17.3994 18.0178 17.3022 18.115 17.3022 18.2348C17.3022 18.3546 17.3994 18.4517 17.5192 18.4517H18.387C18.5069 18.4517 18.604 18.3546 18.604 18.2348C18.604 18.115 18.5069 18.0178 18.387 18.0178Z" fill="#B5A7DD"/>
+<path d="M23.3771 17.8009H20.9906C20.7509 17.8009 20.5566 17.9952 20.5566 18.2348C20.5566 18.4745 20.7509 18.6687 20.9906 18.6687H23.3771C23.6167 18.6687 23.811 18.4745 23.811 18.2348C23.811 17.9952 23.6167 17.8009 23.3771 17.8009Z" fill="#EEEEEE"/>
+<path d="M35.7438 17.8009H33.3573C33.1176 17.8009 32.9233 17.9952 32.9233 18.2348C32.9233 18.4745 33.1176 18.6687 33.3573 18.6687H35.7438C35.9835 18.6687 36.1777 18.4745 36.1777 18.2348C36.1777 17.9952 35.9835 17.8009 35.7438 17.8009Z" fill="#EEEEEE"/>
+<path d="M28.5841 22.574H26.1976C25.9579 22.574 25.7637 22.7682 25.7637 23.0079C25.7637 23.2475 25.9579 23.4418 26.1976 23.4418H28.5841C28.8238 23.4418 29.0181 23.2475 29.0181 23.0079C29.0181 22.7682 28.8238 22.574 28.5841 22.574Z" fill="#EEEEEE"/>
+<path d="M31.6217 20.1875H29.2352C28.9955 20.1875 28.8013 20.3818 28.8013 20.6214C28.8013 20.8611 28.9955 21.0553 29.2352 21.0553H31.6217C31.8614 21.0553 32.0557 20.8611 32.0557 20.6214C32.0557 20.3818 31.8614 20.1875 31.6217 20.1875Z" fill="#FC6D26"/>
+<path opacity="0.5" d="M31.6216 17.8009H28.1502C27.9106 17.8009 27.7163 17.9952 27.7163 18.2348C27.7163 18.4745 27.9106 18.6687 28.1502 18.6687H31.6216C31.8612 18.6687 32.0555 18.4745 32.0555 18.2348C32.0555 17.9952 31.8612 17.8009 31.6216 17.8009Z" fill="#FC6D26"/>
+<path d="M24.4619 22.574H20.9906C20.7509 22.574 20.5566 22.7682 20.5566 23.0079C20.5566 23.2475 20.7509 23.4418 20.9906 23.4418H24.4619C24.7016 23.4418 24.8958 23.2475 24.8958 23.0079C24.8958 22.7682 24.7016 22.574 24.4619 22.574Z" fill="#EEEEEE"/>
+<path d="M27.4995 20.1875H24.0282C23.7885 20.1875 23.5942 20.3818 23.5942 20.6214C23.5942 20.8611 23.7885 21.0553 24.0282 21.0553H27.4995C27.7392 21.0553 27.9334 20.8611 27.9334 20.6214C27.9334 20.3818 27.7392 20.1875 27.4995 20.1875Z" fill="#EEEEEE"/>
+<path d="M26.4144 17.8009H25.1126C24.873 17.8009 24.6787 17.9952 24.6787 18.2348C24.6787 18.4745 24.873 18.6687 25.1126 18.6687H26.4144C26.654 18.6687 26.8483 18.4745 26.8483 18.2348C26.8483 17.9952 26.654 17.8009 26.4144 17.8009Z" fill="#FC6D26"/>
+<path d="M22.2923 20.1875H20.9906C20.7509 20.1875 20.5566 20.3818 20.5566 20.6214C20.5566 20.8611 20.7509 21.0553 20.9906 21.0553H22.2923C22.532 21.0553 22.7262 20.8611 22.7262 20.6214C22.7262 20.3818 22.532 20.1875 22.2923 20.1875Z" fill="#EEEEEE"/>
+<path d="M18.387 20.4044H17.5192C17.3994 20.4044 17.3022 20.5016 17.3022 20.6214C17.3022 20.7412 17.3994 20.8383 17.5192 20.8383H18.387C18.5069 20.8383 18.604 20.7412 18.604 20.6214C18.604 20.5016 18.5069 20.4044 18.387 20.4044Z" fill="#B5A7DD"/>
+<path d="M18.387 22.791H17.5192C17.3994 22.791 17.3022 22.8882 17.3022 23.008C17.3022 23.1278 17.3994 23.2249 17.5192 23.2249H18.387C18.5069 23.2249 18.604 23.1278 18.604 23.008C18.604 22.8882 18.5069 22.791 18.387 22.791Z" fill="#B5A7DD"/>
+<path d="M18.387 25.1774H17.5192C17.3994 25.1774 17.3022 25.2745 17.3022 25.3943C17.3022 25.5142 17.3994 25.6113 17.5192 25.6113H18.387C18.5069 25.6113 18.604 25.5142 18.604 25.3943C18.604 25.2745 18.5069 25.1774 18.387 25.1774Z" fill="#B5A7DD"/>
+<path d="M23.3771 24.9604H20.9906C20.7509 24.9604 20.5566 25.1547 20.5566 25.3944C20.5566 25.634 20.7509 25.8283 20.9906 25.8283H23.3771C23.6167 25.8283 23.811 25.634 23.811 25.3944C23.811 25.1547 23.6167 24.9604 23.3771 24.9604Z" fill="#FC6D26"/>
+<path d="M35.7438 24.9604H33.3573C33.1176 24.9604 32.9233 25.1547 32.9233 25.3944C32.9233 25.634 33.1176 25.8283 33.3573 25.8283H35.7438C35.9835 25.8283 36.1777 25.634 36.1777 25.3944C36.1777 25.1547 35.9835 24.9604 35.7438 24.9604Z" fill="#EEEEEE"/>
+<path opacity="0.5" d="M28.5841 29.7335H26.1976C25.9579 29.7335 25.7637 29.9278 25.7637 30.1674C25.7637 30.4071 25.9579 30.6014 26.1976 30.6014H28.5841C28.8238 30.6014 29.0181 30.4071 29.0181 30.1674C29.0181 29.9278 28.8238 29.7335 28.5841 29.7335Z" fill="#FC6D26"/>
+<path d="M31.6217 27.347H29.2352C28.9955 27.347 28.8013 27.5413 28.8013 27.781C28.8013 28.0206 28.9955 28.2149 29.2352 28.2149H31.6217C31.8614 28.2149 32.0557 28.0206 32.0557 27.781C32.0557 27.5413 31.8614 27.347 31.6217 27.347Z" fill="#EEEEEE"/>
+<path d="M31.6216 24.9604H28.1502C27.9106 24.9604 27.7163 25.1547 27.7163 25.3944C27.7163 25.634 27.9106 25.8283 28.1502 25.8283H31.6216C31.8612 25.8283 32.0555 25.634 32.0555 25.3944C32.0555 25.1547 31.8612 24.9604 31.6216 24.9604Z" fill="#FC6D26"/>
+<path d="M24.4619 29.7335H20.9906C20.7509 29.7335 20.5566 29.9278 20.5566 30.1674C20.5566 30.4071 20.7509 30.6014 20.9906 30.6014H24.4619C24.7016 30.6014 24.8958 30.4071 24.8958 30.1674C24.8958 29.9278 24.7016 29.7335 24.4619 29.7335Z" fill="#FC6D26"/>
+<path d="M27.4995 27.347H24.0282C23.7885 27.347 23.5942 27.5413 23.5942 27.781C23.5942 28.0206 23.7885 28.2149 24.0282 28.2149H27.4995C27.7392 28.2149 27.9334 28.0206 27.9334 27.781C27.9334 27.5413 27.7392 27.347 27.4995 27.347Z" fill="#EEEEEE"/>
+<path opacity="0.5" d="M26.4144 24.9604H25.1126C24.873 24.9604 24.6787 25.1547 24.6787 25.3944C24.6787 25.634 24.873 25.8283 25.1126 25.8283H26.4144C26.654 25.8283 26.8483 25.634 26.8483 25.3944C26.8483 25.1547 26.654 24.9604 26.4144 24.9604Z" fill="#FC6D26"/>
+<path d="M22.2923 27.347H20.9906C20.7509 27.347 20.5566 27.5413 20.5566 27.781C20.5566 28.0206 20.7509 28.2149 20.9906 28.2149H22.2923C22.532 28.2149 22.7262 28.0206 22.7262 27.781C22.7262 27.5413 22.532 27.347 22.2923 27.347Z" fill="#EEEEEE"/>
+<path d="M18.387 27.564H17.5192C17.3994 27.564 17.3022 27.6611 17.3022 27.7809C17.3022 27.9007 17.3994 27.9979 17.5192 27.9979H18.387C18.5069 27.9979 18.604 27.9007 18.604 27.7809C18.604 27.6611 18.5069 27.564 18.387 27.564Z" fill="#B5A7DD"/>
+<path d="M18.387 29.9506H17.5192C17.3994 29.9506 17.3022 30.0477 17.3022 30.1675C17.3022 30.2873 17.3994 30.3845 17.5192 30.3845H18.387C18.5069 30.3845 18.604 30.2873 18.604 30.1675C18.604 30.0477 18.5069 29.9506 18.387 29.9506Z" fill="#B5A7DD"/>
+<path d="M18.387 32.337H17.5192C17.3994 32.337 17.3022 32.4342 17.3022 32.554C17.3022 32.6738 17.3994 32.771 17.5192 32.771H18.387C18.5069 32.771 18.604 32.6738 18.604 32.554C18.604 32.4342 18.5069 32.337 18.387 32.337Z" fill="#B5A7DD"/>
+<path d="M23.3771 32.1201H20.9906C20.7509 32.1201 20.5566 32.3144 20.5566 32.554C20.5566 32.7937 20.7509 32.988 20.9906 32.988H23.3771C23.6167 32.988 23.811 32.7937 23.811 32.554C23.811 32.3144 23.6167 32.1201 23.3771 32.1201Z" fill="#EEEEEE"/>
+<path d="M35.7438 32.1201H33.3573C33.1176 32.1201 32.9233 32.3144 32.9233 32.554C32.9233 32.7937 33.1176 32.988 33.3573 32.988H35.7438C35.9835 32.988 36.1777 32.7937 36.1777 32.554C36.1777 32.3144 35.9835 32.1201 35.7438 32.1201Z" fill="#EEEEEE"/>
+<path d="M28.5841 36.8932H26.1976C25.9579 36.8932 25.7637 37.0875 25.7637 37.3271C25.7637 37.5668 25.9579 37.761 26.1976 37.761H28.5841C28.8238 37.761 29.0181 37.5668 29.0181 37.3271C29.0181 37.0875 28.8238 36.8932 28.5841 36.8932Z" fill="#EEEEEE"/>
+<path d="M31.6217 34.5067H29.2352C28.9955 34.5067 28.8013 34.701 28.8013 34.9406C28.8013 35.1803 28.9955 35.3745 29.2352 35.3745H31.6217C31.8614 35.3745 32.0557 35.1803 32.0557 34.9406C32.0557 34.701 31.8614 34.5067 31.6217 34.5067Z" fill="#EEEEEE"/>
+<path d="M31.6216 32.1201H28.1502C27.9106 32.1201 27.7163 32.3144 27.7163 32.554C27.7163 32.7937 27.9106 32.988 28.1502 32.988H31.6216C31.8612 32.988 32.0555 32.7937 32.0555 32.554C32.0555 32.3144 31.8612 32.1201 31.6216 32.1201Z" fill="#FC6D26"/>
+<path d="M24.4619 36.8932H20.9906C20.7509 36.8932 20.5566 37.0875 20.5566 37.3271C20.5566 37.5668 20.7509 37.761 20.9906 37.761H24.4619C24.7016 37.761 24.8958 37.5668 24.8958 37.3271C24.8958 37.0875 24.7016 36.8932 24.4619 36.8932Z" fill="#EEEEEE"/>
+<path d="M27.4995 34.5067H24.0282C23.7885 34.5067 23.5942 34.701 23.5942 34.9406C23.5942 35.1803 23.7885 35.3745 24.0282 35.3745H27.4995C27.7392 35.3745 27.9334 35.1803 27.9334 34.9406C27.9334 34.701 27.7392 34.5067 27.4995 34.5067Z" fill="#EEEEEE"/>
+<path opacity="0.5" d="M26.4144 32.1201H25.1126C24.873 32.1201 24.6787 32.3144 24.6787 32.554C24.6787 32.7937 24.873 32.988 25.1126 32.988H26.4144C26.654 32.988 26.8483 32.7937 26.8483 32.554C26.8483 32.3144 26.654 32.1201 26.4144 32.1201Z" fill="#FC6D26"/>
+<path d="M22.2923 34.5067H20.9906C20.7509 34.5067 20.5566 34.701 20.5566 34.9406C20.5566 35.1803 20.7509 35.3745 20.9906 35.3745H22.2923C22.532 35.3745 22.7262 35.1803 22.7262 34.9406C22.7262 34.701 22.532 34.5067 22.2923 34.5067Z" fill="#EEEEEE"/>
+<path d="M18.387 34.7236H17.5192C17.3994 34.7236 17.3022 34.8208 17.3022 34.9406C17.3022 35.0604 17.3994 35.1575 17.5192 35.1575H18.387C18.5069 35.1575 18.604 35.0604 18.604 34.9406C18.604 34.8208 18.5069 34.7236 18.387 34.7236Z" fill="#B5A7DD"/>
+<path d="M18.387 37.1102H17.5192C17.3994 37.1102 17.3022 37.2074 17.3022 37.3272C17.3022 37.447 17.3994 37.5442 17.5192 37.5442H18.387C18.5069 37.5442 18.604 37.447 18.604 37.3272C18.604 37.2074 18.5069 37.1102 18.387 37.1102Z" fill="#B5A7DD"/>
+<path d="M45.073 17.8008H44.2052C44.0854 17.8008 43.9883 17.8979 43.9883 18.0177C43.9883 18.1376 44.0854 18.2347 44.2052 18.2347H45.073C45.1929 18.2347 45.29 18.1376 45.29 18.0177C45.29 17.8979 45.1929 17.8008 45.073 17.8008Z" fill="#FDE5D8"/>
+<path d="M50.0631 17.5839H47.6766C47.4369 17.5839 47.2427 17.7781 47.2427 18.0178C47.2427 18.2574 47.4369 18.4517 47.6766 18.4517H50.0631C50.3028 18.4517 50.4971 18.2574 50.4971 18.0178C50.4971 17.7781 50.3028 17.5839 50.0631 17.5839Z" fill="#EEEEEE"/>
+<path d="M62.4299 17.5839H60.0433C59.8036 17.5839 59.6094 17.7781 59.6094 18.0178C59.6094 18.2574 59.8036 18.4517 60.0433 18.4517H62.4299C62.6695 18.4517 62.8638 18.2574 62.8638 18.0178C62.8638 17.7781 62.6695 17.5839 62.4299 17.5839Z" fill="#EEEEEE"/>
+<path opacity="0.5" d="M55.2702 22.3569H52.8836C52.644 22.3569 52.4497 22.5512 52.4497 22.7909C52.4497 23.0305 52.644 23.2248 52.8836 23.2248H55.2702C55.5098 23.2248 55.7041 23.0305 55.7041 22.7909C55.7041 22.5512 55.5098 22.3569 55.2702 22.3569Z" fill="#6B4FBB"/>
+<path d="M58.3078 19.9705H55.9212C55.6816 19.9705 55.4873 20.1647 55.4873 20.4044C55.4873 20.644 55.6816 20.8383 55.9212 20.8383H58.3078C58.5474 20.8383 58.7417 20.644 58.7417 20.4044C58.7417 20.1647 58.5474 19.9705 58.3078 19.9705Z" fill="#6B4FBB"/>
+<path opacity="0.5" d="M58.3076 17.5839H54.8363C54.5966 17.5839 54.4023 17.7781 54.4023 18.0178C54.4023 18.2574 54.5966 18.4517 54.8363 18.4517H58.3076C58.5473 18.4517 58.7415 18.2574 58.7415 18.0178C58.7415 17.7781 58.5473 17.5839 58.3076 17.5839Z" fill="#6B4FBB"/>
+<path d="M51.1479 22.3569H47.6766C47.4369 22.3569 47.2427 22.5512 47.2427 22.7909C47.2427 23.0305 47.4369 23.2248 47.6766 23.2248H51.1479C51.3876 23.2248 51.5819 23.0305 51.5819 22.7909C51.5819 22.5512 51.3876 22.3569 51.1479 22.3569Z" fill="#6B4FBB"/>
+<path d="M54.1855 19.9705H50.7142C50.4745 19.9705 50.2803 20.1647 50.2803 20.4044C50.2803 20.644 50.4745 20.8383 50.7142 20.8383H54.1855C54.4252 20.8383 54.6195 20.644 54.6195 20.4044C54.6195 20.1647 54.4252 19.9705 54.1855 19.9705Z" fill="#EEEEEE"/>
+<path d="M53.1004 17.5839H51.7987C51.559 17.5839 51.3647 17.7781 51.3647 18.0178C51.3647 18.2574 51.559 18.4517 51.7987 18.4517H53.1004C53.3401 18.4517 53.5343 18.2574 53.5343 18.0178C53.5343 17.7781 53.3401 17.5839 53.1004 17.5839Z" fill="#6B4FBB"/>
+<path d="M48.9783 19.9705H47.6766C47.4369 19.9705 47.2427 20.1647 47.2427 20.4044C47.2427 20.644 47.4369 20.8383 47.6766 20.8383H48.9783C49.218 20.8383 49.4123 20.644 49.4123 20.4044C49.4123 20.1647 49.218 19.9705 48.9783 19.9705Z" fill="#EEEEEE"/>
+<path d="M45.073 20.1874H44.2052C44.0854 20.1874 43.9883 20.2845 43.9883 20.4043C43.9883 20.5242 44.0854 20.6213 44.2052 20.6213H45.073C45.1929 20.6213 45.29 20.5242 45.29 20.4043C45.29 20.2845 45.1929 20.1874 45.073 20.1874Z" fill="#FDE5D8"/>
+<path d="M45.073 22.574H44.2052C44.0854 22.574 43.9883 22.6711 43.9883 22.7909C43.9883 22.9108 44.0854 23.0079 44.2052 23.0079H45.073C45.1929 23.0079 45.29 22.9108 45.29 22.7909C45.29 22.6711 45.1929 22.574 45.073 22.574Z" fill="#FDE5D8"/>
+<path d="M45.073 24.9604H44.2052C44.0854 24.9604 43.9883 25.0576 43.9883 25.1774C43.9883 25.2972 44.0854 25.3944 44.2052 25.3944H45.073C45.1929 25.3944 45.29 25.2972 45.29 25.1774C45.29 25.0576 45.1929 24.9604 45.073 24.9604Z" fill="#FDE5D8"/>
+<path d="M50.0631 24.7435H47.6766C47.4369 24.7435 47.2427 24.9378 47.2427 25.1774C47.2427 25.4171 47.4369 25.6114 47.6766 25.6114H50.0631C50.3028 25.6114 50.4971 25.4171 50.4971 25.1774C50.4971 24.9378 50.3028 24.7435 50.0631 24.7435Z" fill="#EEEEEE"/>
+<path d="M59.3922 22.3569H57.0057C56.766 22.3569 56.5718 22.5512 56.5718 22.7909C56.5718 23.0305 56.766 23.2248 57.0057 23.2248H59.3922C59.6319 23.2248 59.8262 23.0305 59.8262 22.7909C59.8262 22.5512 59.6319 22.3569 59.3922 22.3569Z" fill="#EEEEEE"/>
+<path opacity="0.5" d="M55.2702 29.5166H52.8836C52.644 29.5166 52.4497 29.7109 52.4497 29.9505C52.4497 30.1902 52.644 30.3844 52.8836 30.3844H55.2702C55.5098 30.3844 55.7041 30.1902 55.7041 29.9505C55.7041 29.7109 55.5098 29.5166 55.2702 29.5166Z" fill="#6B4FBB"/>
+<path d="M53.1007 27.1301H50.7142C50.4745 27.1301 50.2803 27.3244 50.2803 27.564C50.2803 27.8037 50.4745 27.998 50.7142 27.998H53.1007C53.3404 27.998 53.5347 27.8037 53.5347 27.564C53.5347 27.3244 53.3404 27.1301 53.1007 27.1301Z" fill="#6B4FBB"/>
+<path d="M58.3076 24.7435H54.8363C54.5966 24.7435 54.4023 24.9378 54.4023 25.1774C54.4023 25.4171 54.5966 25.6114 54.8363 25.6114H58.3076C58.5473 25.6114 58.7415 25.4171 58.7415 25.1774C58.7415 24.9378 58.5473 24.7435 58.3076 24.7435Z" fill="#6B4FBB"/>
+<path d="M51.1479 29.5166H47.6766C47.4369 29.5166 47.2427 29.7109 47.2427 29.9505C47.2427 30.1902 47.4369 30.3844 47.6766 30.3844H51.1479C51.3876 30.3844 51.5819 30.1902 51.5819 29.9505C51.5819 29.7109 51.3876 29.5166 51.1479 29.5166Z" fill="#EEEEEE"/>
+<path d="M53.1004 24.7435H51.7987C51.559 24.7435 51.3647 24.9378 51.3647 25.1774C51.3647 25.4171 51.559 25.6114 51.7987 25.6114H53.1004C53.3401 25.6114 53.5343 25.4171 53.5343 25.1774C53.5343 24.9378 53.3401 24.7435 53.1004 24.7435Z" fill="#EEEEEE"/>
+<path d="M48.9783 27.1301H47.6766C47.4369 27.1301 47.2427 27.3244 47.2427 27.564C47.2427 27.8037 47.4369 27.998 47.6766 27.998H48.9783C49.218 27.998 49.4123 27.8037 49.4123 27.564C49.4123 27.3244 49.218 27.1301 48.9783 27.1301Z" fill="#EEEEEE"/>
+<path d="M56.138 27.1301H54.8363C54.5966 27.1301 54.4023 27.3244 54.4023 27.564C54.4023 27.8037 54.5966 27.998 54.8363 27.998H56.138C56.3777 27.998 56.5719 27.8037 56.5719 27.564C56.5719 27.3244 56.3777 27.1301 56.138 27.1301Z" fill="#EEEEEE"/>
+<path d="M59.1756 27.1301H57.8739C57.6342 27.1301 57.4399 27.3244 57.4399 27.564C57.4399 27.8037 57.6342 27.998 57.8739 27.998H59.1756C59.4153 27.998 59.6095 27.8037 59.6095 27.564C59.6095 27.3244 59.4153 27.1301 59.1756 27.1301Z" fill="#EEEEEE"/>
+<path d="M62.43 22.3569H61.1283C60.8886 22.3569 60.6943 22.5512 60.6943 22.7909C60.6943 23.0305 60.8886 23.2248 61.1283 23.2248H62.43C62.6697 23.2248 62.8639 23.0305 62.8639 22.7909C62.8639 22.5512 62.6697 22.3569 62.43 22.3569Z" fill="#EEEEEE"/>
+<path d="M45.073 27.347H44.2052C44.0854 27.347 43.9883 27.4442 43.9883 27.564C43.9883 27.6838 44.0854 27.781 44.2052 27.781H45.073C45.1929 27.781 45.29 27.6838 45.29 27.564C45.29 27.4442 45.1929 27.347 45.073 27.347Z" fill="#FDE5D8"/>
+<path d="M45.073 29.7335H44.2052C44.0854 29.7335 43.9883 29.8307 43.9883 29.9505C43.9883 30.0703 44.0854 30.1674 44.2052 30.1674H45.073C45.1929 30.1674 45.29 30.0703 45.29 29.9505C45.29 29.8307 45.1929 29.7335 45.073 29.7335Z" fill="#FDE5D8"/>
+<path d="M45.073 32.1201H44.2052C44.0854 32.1201 43.9883 32.2173 43.9883 32.3371C43.9883 32.4569 44.0854 32.554 44.2052 32.554H45.073C45.1929 32.554 45.29 32.4569 45.29 32.3371C45.29 32.2173 45.1929 32.1201 45.073 32.1201Z" fill="#FDE5D8"/>
+<path d="M50.0631 31.9032H47.6766C47.4369 31.9032 47.2427 32.0975 47.2427 32.3371C47.2427 32.5768 47.4369 32.771 47.6766 32.771H50.0631C50.3028 32.771 50.4971 32.5768 50.4971 32.3371C50.4971 32.0975 50.3028 31.9032 50.0631 31.9032Z" fill="#6B4FBB"/>
+<path d="M55.2702 36.6763H52.8836C52.644 36.6763 52.4497 36.8705 52.4497 37.1102C52.4497 37.3498 52.644 37.5441 52.8836 37.5441H55.2702C55.5098 37.5441 55.7041 37.3498 55.7041 37.1102C55.7041 36.8705 55.5098 36.6763 55.2702 36.6763Z" fill="#EEEEEE"/>
+<path opacity="0.5" d="M58.3078 34.2897H55.9212C55.6816 34.2897 55.4873 34.4839 55.4873 34.7236C55.4873 34.9632 55.6816 35.1575 55.9212 35.1575H58.3078C58.5474 35.1575 58.7417 34.9632 58.7417 34.7236C58.7417 34.4839 58.5474 34.2897 58.3078 34.2897Z" fill="#6B4FBB"/>
+<path d="M51.1479 36.6763H47.6766C47.4369 36.6763 47.2427 36.8705 47.2427 37.1102C47.2427 37.3498 47.4369 37.5441 47.6766 37.5441H51.1479C51.3876 37.5441 51.5819 37.3498 51.5819 37.1102C51.5819 36.8705 51.3876 36.6763 51.1479 36.6763Z" fill="#EEEEEE"/>
+<path d="M54.1855 34.2897H50.7142C50.4745 34.2897 50.2803 34.4839 50.2803 34.7236C50.2803 34.9632 50.4745 35.1575 50.7142 35.1575H54.1855C54.4252 35.1575 54.6195 34.9632 54.6195 34.7236C54.6195 34.4839 54.4252 34.2897 54.1855 34.2897Z" fill="#6B4FBB"/>
+<path d="M53.1004 31.9032H51.7987C51.559 31.9032 51.3647 32.0975 51.3647 32.3371C51.3647 32.5768 51.559 32.771 51.7987 32.771H53.1004C53.3401 32.771 53.5343 32.5768 53.5343 32.3371C53.5343 32.0975 53.3401 31.9032 53.1004 31.9032Z" fill="#EEEEEE"/>
+<path d="M61.3451 34.2897H60.0433C59.8036 34.2897 59.6094 34.4839 59.6094 34.7236C59.6094 34.9632 59.8036 35.1575 60.0433 35.1575H61.3451C61.5847 35.1575 61.779 34.9632 61.779 34.7236C61.779 34.4839 61.5847 34.2897 61.3451 34.2897Z" fill="#EEEEEE"/>
+<path d="M48.9783 34.2897H47.6766C47.4369 34.2897 47.2427 34.4839 47.2427 34.7236C47.2427 34.9632 47.4369 35.1575 47.6766 35.1575H48.9783C49.218 35.1575 49.4123 34.9632 49.4123 34.7236C49.4123 34.4839 49.218 34.2897 48.9783 34.2897Z" fill="#EEEEEE"/>
+<path d="M45.073 34.5067H44.2052C44.0854 34.5067 43.9883 34.6039 43.9883 34.7237C43.9883 34.8435 44.0854 34.9406 44.2052 34.9406H45.073C45.1929 34.9406 45.29 34.8435 45.29 34.7237C45.29 34.6039 45.1929 34.5067 45.073 34.5067Z" fill="#FDE5D8"/>
+<path d="M45.073 36.8932H44.2052C44.0854 36.8932 43.9883 36.9903 43.9883 37.1101C43.9883 37.23 44.0854 37.3271 44.2052 37.3271H45.073C45.1929 37.3271 45.29 37.23 45.29 37.1101C45.29 36.9903 45.1929 36.8932 45.073 36.8932Z" fill="#FDE5D8"/>
+<path d="M66.0312 11.2921H75.4472C76.6405 11.2921 77.6168 10.3158 77.6168 9.12254V2.83072C77.6168 1.63745 76.6405 0.661133 75.4472 0.661133H65.2502C64.0569 0.661133 63.0806 1.63745 63.0806 2.83072V11.943C63.0806 13.1363 63.7748 13.4617 64.6427 12.5939L66.0312 11.2921Z" fill="white" stroke="#FDE5D8" stroke-width="2"/>
+<path d="M72.0845 3.69861H66.4436C66.2639 3.69861 66.1182 3.84431 66.1182 4.02405C66.1182 4.20378 66.2639 4.34949 66.4436 4.34949H72.0845C72.2643 4.34949 72.41 4.20378 72.41 4.02405C72.41 3.84431 72.2643 3.69861 72.0845 3.69861Z" fill="#FDB692"/>
+<path d="M74.2541 5.65125H66.4436C66.2639 5.65125 66.1182 5.79695 66.1182 5.97668C66.1182 6.15642 66.2639 6.30212 66.4436 6.30212H74.2541C74.4339 6.30212 74.5796 6.15642 74.5796 5.97668C74.5796 5.79695 74.4339 5.65125 74.2541 5.65125Z" fill="#FDB692"/>
+<path d="M72.0845 7.60388H66.4436C66.2639 7.60388 66.1182 7.74959 66.1182 7.92932C66.1182 8.10906 66.2639 8.25476 66.4436 8.25476H72.0845C72.2643 8.25476 72.41 8.10906 72.41 7.92932C72.41 7.74959 72.2643 7.60388 72.0845 7.60388Z" fill="#FDB692"/>
+<path d="M64.1655 21.0553C65.9629 21.0553 67.4199 19.5982 67.4199 17.8009C67.4199 16.0035 65.9629 14.5465 64.1655 14.5465C62.3682 14.5465 60.9111 16.0035 60.9111 17.8009C60.9111 19.5982 62.3682 21.0553 64.1655 21.0553Z" fill="#FFF7F4" stroke="#FC6D26"/>
+<path d="M62.3867 15.1974C63.0376 16.1303 64.079 16.7161 65.2506 16.7161C65.9665 16.7161 66.6174 16.4991 67.2032 16.1303" stroke="#FC6D26" stroke-width="0.5"/>
+<path d="M62.9724 18.6687C63.1521 18.6687 63.2979 18.523 63.2979 18.3433C63.2979 18.1635 63.1521 18.0178 62.9724 18.0178C62.7927 18.0178 62.647 18.1635 62.647 18.3433C62.647 18.523 62.7927 18.6687 62.9724 18.6687Z" fill="#FC6D26"/>
+<path d="M65.3591 18.6687C65.5389 18.6687 65.6846 18.523 65.6846 18.3433C65.6846 18.1635 65.5389 18.0178 65.3591 18.0178C65.1794 18.0178 65.0337 18.1635 65.0337 18.3433C65.0337 18.523 65.1794 18.6687 65.3591 18.6687Z" fill="#FC6D26"/>
+<path d="M16.0005 39.7137C17.7978 39.7137 19.2549 38.2567 19.2549 36.4593C19.2549 34.662 17.7978 33.205 16.0005 33.205C14.2031 33.205 12.7461 34.662 12.7461 36.4593C12.7461 38.2567 14.2031 39.7137 16.0005 39.7137Z" fill="#F4F1FA" stroke="#6B4FBB"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M18.1701 34.5068H18.7559C18.1701 33.5955 17.1721 32.988 16.0005 32.988C14.8289 32.988 13.8309 33.5955 13.2451 34.5068H13.8309L14.3733 33.8559L14.9157 34.5068L15.4581 33.8559L16.0005 34.5068L16.5429 33.8559L17.0853 34.5068L17.6277 33.8559L18.1701 34.5068Z" fill="#6B4FBB"/>
+<path d="M14.8074 37.1102C14.9871 37.1102 15.1328 36.9645 15.1328 36.7848C15.1328 36.6051 14.9871 36.4594 14.8074 36.4594C14.6276 36.4594 14.4819 36.6051 14.4819 36.7848C14.4819 36.9645 14.6276 37.1102 14.8074 37.1102Z" fill="#6B4FBB"/>
+<path d="M17.1936 37.1102C17.3733 37.1102 17.519 36.9645 17.519 36.7848C17.519 36.6051 17.3733 36.4594 17.1936 36.4594C17.0139 36.4594 16.8682 36.6051 16.8682 36.7848C16.8682 36.9645 17.0139 37.1102 17.1936 37.1102Z" fill="#6B4FBB"/>
+<path d="M13.0498 29.7336H3.63382C2.44055 29.7336 1.46423 28.7573 1.46423 27.5641V21.2722C1.46423 20.079 2.44055 19.1027 3.63382 19.1027H13.8309C15.0242 19.1027 16.0005 20.079 16.0005 21.2722V30.3845C16.0005 31.5778 15.3062 31.9032 14.4384 31.0354L13.0498 29.7336Z" fill="white" stroke="#E2DCF2" stroke-width="2"/>
+<path opacity="0.5" d="M4.82708 22.1401H10.468C10.6478 22.1401 10.7935 22.2858 10.7935 22.4656C10.7935 22.6453 10.6478 22.791 10.468 22.791H4.82708C4.64735 22.791 4.50164 22.6453 4.50164 22.4656C4.50164 22.2858 4.64735 22.1401 4.82708 22.1401Z" fill="#6B4FBB"/>
+<path opacity="0.5" d="M4.82724 24.0928H8.29859C8.47832 24.0928 8.62402 24.2385 8.62402 24.4182C8.62402 24.5979 8.47832 24.7437 8.29859 24.7437H4.82724C4.64751 24.7437 4.50181 24.5979 4.50181 24.4182C4.50181 24.2385 4.64751 24.0928 4.82724 24.0928Z" fill="#6B4FBB"/>
+<path opacity="0.5" d="M4.82724 26.0454H8.29859C8.47832 26.0454 8.62402 26.1911 8.62402 26.3708C8.62402 26.5506 8.47832 26.6963 8.29859 26.6963H4.82724C4.64751 26.6963 4.50181 26.5506 4.50181 26.3708C4.50181 26.1911 4.64751 26.0454 4.82724 26.0454Z" fill="#6B4FBB"/>
+</g>
+<defs>
+<clipPath id="clip0">
+<rect width="83.5292" height="48.8158" fill="white" transform="translate(-2.44092 0.661133)"/>
+</clipPath>
+</defs>
+</svg>
diff --git a/app/assets/images/learn_gitlab/pipeline_created.svg b/app/assets/images/learn_gitlab/pipeline_created.svg
new file mode 100644
index 00000000000..91c716be475
--- /dev/null
+++ b/app/assets/images/learn_gitlab/pipeline_created.svg
@@ -0,0 +1,38 @@
+<svg width="52" height="48" viewBox="0 0 52 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M32.1404 11.789L45.6109 14.1173C47.0988 14.3744 48.0923 15.7653 47.8299 17.2237L44.9797 33.0687C44.7173 34.5272 43.2985 35.5011 41.8106 35.2439L28.34 32.9157C26.8521 32.6585 25.8586 31.2677 26.121 29.8092L28.9712 13.9642C29.2336 12.5057 30.6525 11.5318 32.1404 11.789Z" fill="white"/>
+<path d="M31.9504 12.8453C31.0576 12.691 30.2063 13.2754 30.0489 14.1505L27.1986 29.9955C27.0412 30.8705 27.6373 31.705 28.5301 31.8593L42.0006 34.1876C42.8933 34.3419 43.7446 33.7576 43.9021 32.8825L46.7523 17.0375C46.9097 16.1624 46.3136 15.3279 45.4209 15.1736L31.9504 12.8453ZM32.1404 11.789L45.6109 14.1173C47.0988 14.3744 48.0923 15.7653 47.8299 17.2237L44.9797 33.0687C44.7173 34.5272 43.2985 35.5011 41.8106 35.2439L28.34 32.9157C26.8521 32.6585 25.8586 31.2677 26.121 29.8092L28.9712 13.9642C29.2336 12.5057 30.6525 11.5318 32.1404 11.789Z" fill="#E1D8F9"/>
+<path d="M39.2265 9.7425L25.6003 8.57392C24.0951 8.44485 22.7683 9.53622 22.6366 11.0116L21.206 27.0398C21.0743 28.5151 22.1877 29.8158 23.6928 29.9449L37.3191 31.1134C38.8242 31.2425 40.1511 30.1511 40.2828 28.6758L41.7133 12.6476C41.845 11.1722 40.7316 9.87157 39.2265 9.7425Z" fill="black" fill-opacity="0.03"/>
+<path d="M34.477 47.7322H41.0349C46.4986 47.5866 50.8833 43.2004 50.8833 37.8104C50.8833 32.3306 46.3517 27.8886 40.7614 27.8886C40.4897 27.8886 40.2205 27.8993 39.9544 27.9197C38.6002 24.003 34.8187 21.1847 30.3659 21.1847C28.9201 21.1826 27.4908 21.4853 26.1748 22.0723C24.3556 19.4336 21.2753 17.6986 17.7818 17.6986C12.1915 17.6986 7.65986 22.1406 7.65986 27.6204C7.65986 27.8081 7.66533 27.995 7.67572 28.1803C3.26748 29.253 0 33.1573 0 37.8104C0 43.2902 4.53163 47.7322 10.122 47.7322C10.2106 47.7322 10.2987 47.7311 10.3868 47.729L34.477 47.7322Z" fill="#F4F0FF"/>
+<path d="M40.1486 9.42187L26.547 8.27441C25.0446 8.14767 23.718 9.24259 23.5839 10.72L22.1275 26.7704C21.9934 28.2478 23.1027 29.5482 24.6051 29.6749L38.2068 30.8224C39.7092 30.9491 41.0358 29.8542 41.1698 28.3768L42.6262 12.3264C42.7603 10.849 41.651 9.54862 40.1486 9.42187Z" fill="white"/>
+<path d="M26.4499 9.34444C25.5485 9.26839 24.7525 9.92534 24.6721 10.8118L23.2156 26.8622C23.1352 27.7486 23.8007 28.5288 24.7022 28.6049L38.3038 29.7523C39.2053 29.8284 40.0013 29.1714 40.0817 28.285L41.5381 12.2346C41.6185 11.3482 40.953 10.5679 40.0516 10.4919L26.4499 9.34444ZM26.547 8.27441L40.1486 9.42187C41.651 9.54862 42.7603 10.849 42.6262 12.3264L41.1698 28.3768C41.0358 29.8542 39.7092 30.9491 38.2068 30.8224L24.6051 29.6749C23.1027 29.5482 21.9934 28.2478 22.1275 26.7704L23.5839 10.72C23.718 9.24259 25.0446 8.14767 26.547 8.27441Z" fill="#E1D8F9"/>
+<path d="M28.8145 13.3182L27.7253 13.2263C27.4245 13.2009 27.1589 13.4202 27.1321 13.7161C27.1052 14.012 27.3273 14.2725 27.6281 14.2979L28.7173 14.3898C29.018 14.4152 29.2836 14.1959 29.3105 13.9C29.3373 13.6041 29.1152 13.3436 28.8145 13.3182Z" fill="#FC6D26"/>
+<path d="M33.2417 19.0816L32.1525 18.9897C31.8517 18.9643 31.5862 19.1836 31.5593 19.4795C31.5325 19.7755 31.7545 20.0359 32.0553 20.0613L33.1445 20.1533C33.4453 20.1787 33.7109 19.9593 33.7377 19.6634C33.7645 19.3675 33.5425 19.107 33.2417 19.0816Z" fill="#E1DBF1"/>
+<path d="M36.5464 22.0571L35.4572 21.9652C35.1564 21.9398 34.8908 22.1591 34.864 22.455C34.8372 22.7509 35.0592 23.0114 35.36 23.0368L36.4492 23.1287C36.75 23.1541 37.0155 22.9348 37.0424 22.6389C37.0692 22.343 36.8472 22.0825 36.5464 22.0571Z" fill="#FEE1D3"/>
+<path d="M36.5127 19.3624L35.4235 19.2705C35.1227 19.2451 34.8571 19.4644 34.8303 19.7603C34.8035 20.0562 35.0255 20.3167 35.3263 20.3421L36.4155 20.434C36.7163 20.4594 36.9819 20.2401 37.0087 19.9442C37.0355 19.6483 36.8135 19.3878 36.5127 19.3624Z" fill="#FEF0E8"/>
+<path d="M33.1744 13.6912L30.9954 13.5071C30.6946 13.4817 30.4289 13.7012 30.4021 13.9975C30.3752 14.2938 30.5974 14.5545 30.8982 14.58L33.0772 14.764C33.378 14.7895 33.6437 14.5699 33.6705 14.2736C33.6974 13.9774 33.4752 13.7166 33.1744 13.6912Z" fill="#EFEDF8"/>
+<path d="M27.8257 21.3098L27.2813 21.2638C26.9806 21.2385 26.7151 21.4576 26.6882 21.7534C26.6614 22.0491 26.8834 22.3094 27.1841 22.3348L27.7286 22.3807C28.0293 22.4061 28.2948 22.1869 28.3216 21.8912C28.3484 21.5955 28.1264 21.3351 27.8257 21.3098Z" fill="#FEF0E8"/>
+<path d="M30.5513 21.5437L30.0069 21.4977C29.7062 21.4723 29.4407 21.6915 29.4138 21.9873C29.387 22.283 29.609 22.5433 29.9097 22.5687L30.4541 22.6146C30.7548 22.64 31.0203 22.4208 31.0472 22.1251C31.074 21.8293 30.852 21.569 30.5513 21.5437Z" fill="#FC6D26"/>
+<path d="M33.2764 21.7772L32.732 21.7312C32.4313 21.7059 32.1657 21.925 32.1389 22.2208C32.1121 22.5165 32.3341 22.7768 32.6348 22.8022L33.1792 22.8481C33.4799 22.8735 33.7455 22.6543 33.7723 22.3586C33.7991 22.0629 33.5771 21.8025 33.2764 21.7772Z" fill="#E1D8F9"/>
+<path d="M29.6651 16.0818L27.4861 15.8977C27.1853 15.8723 26.9196 16.0919 26.8928 16.3881C26.866 16.6844 27.0881 16.9452 27.3889 16.9706L29.5679 17.1547C29.8688 17.1801 30.1344 16.9605 30.1613 16.6643C30.1881 16.368 29.966 16.1072 29.6651 16.0818Z" fill="#E1D8F9"/>
+<path d="M37.126 24.7972L34.9471 24.6132C34.6462 24.5877 34.3806 24.8073 34.3537 25.1036C34.3269 25.3998 34.549 25.6606 34.8499 25.686L37.0288 25.8701C37.3297 25.8955 37.5953 25.676 37.6222 25.3797C37.649 25.0834 37.4269 24.8227 37.126 24.7972Z" fill="#6B4FBB"/>
+<path d="M37.8406 16.7796L31.8467 16.2719C31.5457 16.2464 31.28 16.4666 31.2531 16.7638C31.2263 17.0609 31.4485 17.3225 31.7494 17.348L37.7434 17.8558C38.0443 17.8812 38.3101 17.661 38.3369 17.3638C38.3638 17.0667 38.1416 16.8051 37.8406 16.7796Z" fill="#C3B8E3"/>
+<path d="M33.0379 24.4431L27.0439 23.9353C26.743 23.9098 26.4773 24.1301 26.4504 24.4272C26.4235 24.7244 26.6457 24.986 26.9467 25.0114L32.9407 25.5192C33.2416 25.5447 33.5073 25.3245 33.5342 25.0273C33.5611 24.7301 33.3389 24.4686 33.0379 24.4431Z" fill="#FEE1D3"/>
+<path d="M38.0793 14.1112L35.3553 13.881C35.0545 13.8556 34.7888 14.0753 34.7619 14.3717C34.7351 14.6681 34.9572 14.929 35.2581 14.9544L37.982 15.1847C38.2829 15.2101 38.5486 14.9904 38.5754 14.694C38.6023 14.3976 38.3801 14.1367 38.0793 14.1112Z" fill="#E1DBF1"/>
+<path d="M29.9723 18.7993L27.2484 18.5691C26.9475 18.5437 26.6818 18.7634 26.655 19.0598C26.6282 19.3562 26.8503 19.6171 27.1512 19.6426L29.8751 19.8728C30.176 19.8982 30.4417 19.6785 30.4685 19.3821C30.4953 19.0857 30.2732 18.8248 29.9723 18.7993Z" fill="#6B4FBB"/>
+<path d="M35.2983 48H41.8562C47.3199 47.8544 51.7046 43.4682 51.7046 38.0782C51.7046 32.5984 47.173 28.1564 41.5827 28.1564C41.311 28.1564 41.0418 28.1671 40.7757 28.1875C39.4215 24.2708 35.64 21.4525 31.1871 21.4525C29.7414 21.4504 28.3121 21.7532 26.9961 22.3401C25.1769 19.7014 22.0965 17.9664 18.6031 17.9664C13.0128 17.9664 8.48115 22.4085 8.48115 27.8882C8.48115 28.076 8.48662 28.2629 8.49701 28.4482C4.08877 29.5208 0.821289 33.4252 0.821289 38.0782C0.821289 43.558 5.35292 48 10.9432 48C11.0319 48 11.12 47.999 11.2081 47.9968L35.2983 48Z" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M35.2976 47.4637H41.8407C47.0193 47.3258 51.1567 43.1667 51.1567 38.0782C51.1567 32.8947 46.87 28.6927 41.5819 28.6927C41.3259 28.6927 41.0712 28.7026 40.8179 28.7222L40.3936 28.7549L40.2568 28.3596C38.9505 24.5818 35.3216 21.9887 31.1864 21.9887C29.8189 21.9868 28.4669 22.2731 27.2222 22.8283L26.8009 23.0163L26.5418 22.6406C24.7718 20.0727 21.8233 18.5027 18.6024 18.5027C13.3143 18.5027 9.02753 22.7047 9.02753 27.8882C9.02753 28.0654 9.03246 28.2422 9.04258 28.4183L9.06775 28.8616L8.62813 28.9686C4.39332 29.9986 1.36768 33.7413 1.36768 38.0782C1.36768 43.2616 5.65446 47.4637 10.9425 47.4637C11.0262 47.4637 11.1099 47.4626 11.2073 47.4604L35.2976 47.4637Z" fill="white"/>
+<path d="M41.5827 28.1564C47.1729 28.1564 51.7046 32.5985 51.7046 38.0782C51.7046 43.4542 47.3344 47.854 41.8415 48H35.2983L11.2203 47.9967C11.1169 47.999 11.0296 48 10.9432 48C5.35303 48 0.821289 43.5579 0.821289 38.0782C0.821289 33.5134 3.99981 29.542 8.49703 28.4481C8.48641 28.2631 8.48115 28.0765 8.48115 27.8882C8.48115 22.4086 13.0129 17.9664 18.6031 17.9664C21.9832 17.9664 25.1114 19.6064 26.9958 22.3403C28.3124 21.7532 29.742 21.4504 31.1871 21.4525C35.535 21.4525 39.3871 24.1716 40.7757 28.1874C41.0441 28.1668 41.3131 28.1564 41.5827 28.1564ZM41.8266 46.9276C46.7122 46.7976 50.6104 42.873 50.6104 38.0782C50.6104 33.1909 46.5685 29.229 41.5827 29.229C41.3418 29.229 41.1015 29.2383 40.8615 29.2569L40.4372 29.2896C40.1876 29.3088 39.9566 29.1596 39.8762 28.9272L39.7393 28.5318C38.501 24.9504 35.0651 22.5251 31.1863 22.5251C29.8973 22.5232 28.6228 22.7932 27.4496 23.3165L27.0283 23.5045C26.7852 23.6129 26.4979 23.5336 26.3483 23.3167L26.0892 22.9409C24.4075 20.5012 21.6186 19.0391 18.6031 19.0391C13.6172 19.0391 9.57541 23.001 9.57541 27.8882C9.57541 28.0567 9.58012 28.2233 9.58961 28.3886L9.61478 28.8319C9.62938 29.089 9.45538 29.3201 9.20034 29.3822L8.76063 29.4892C4.75075 30.4645 1.91555 34.007 1.91555 38.0782C1.91555 42.9655 5.95738 46.9274 10.9432 46.9274C11.0209 46.9274 11.1001 46.9264 11.2081 46.9242L35.2984 46.9274L41.8266 46.9276Z" fill="#E1D8F9"/>
+<path d="M19.2501 27.9907C19.8019 27.7404 20.3915 27.5821 20.9945 27.5223L21.1208 27.0649C21.3387 26.2755 22.1656 25.8045 22.9613 26.0114L23.7546 26.2177C24.1383 26.3176 24.4646 26.564 24.6618 26.9026C24.8589 27.2412 24.9107 27.6443 24.8057 28.0231L24.6794 28.4806C25.1755 28.8277 25.6031 29.2528 25.9488 29.7327L26.4143 29.6081C27.2149 29.3942 28.0346 29.8574 28.2469 30.6437L28.4563 31.4252C28.5574 31.8033 28.5015 32.2068 28.3009 32.5468C28.1003 32.8869 27.7714 33.1356 27.3867 33.2383L26.9221 33.3629C26.892 33.6548 26.8373 33.9481 26.7564 34.241C26.6755 34.5339 26.5718 34.8144 26.4475 35.0819L26.7857 35.4149C27.3682 35.9895 27.3647 36.9241 26.7779 37.5044L26.1939 38.0796C25.9113 38.3577 25.5291 38.5148 25.1313 38.5162C24.7336 38.5176 24.3529 38.3633 24.0732 38.0871L23.7343 37.7533C23.1824 38.0036 22.5929 38.1619 21.9899 38.2217L21.8636 38.6791C21.6457 39.4685 20.8188 39.9395 20.0231 39.7326L19.2298 39.5263C18.8461 39.4264 18.5198 39.18 18.3226 38.8414C18.1255 38.5028 18.0737 38.0997 18.1787 37.7209L18.3049 37.2634C17.8136 36.919 17.3846 36.4958 17.0356 36.0113L16.5701 36.1359C15.7695 36.3498 14.9498 35.8866 14.7374 35.1003L14.5281 34.3188C14.427 33.9407 14.4829 33.5372 14.6835 33.1972C14.8841 32.8571 15.2129 32.6084 15.5977 32.5057L16.0623 32.3812C16.0929 32.0847 16.1484 31.791 16.228 31.503C16.3089 31.2101 16.4126 30.9296 16.5369 30.6622L16.1986 30.3291C15.6162 29.7545 15.6197 28.8199 16.2065 28.2397L16.7905 27.6645C17.073 27.3863 17.4553 27.2292 17.853 27.2278C18.2508 27.2264 18.6314 27.3807 18.9112 27.6569L19.2501 27.9907Z" fill="white"/>
+<path d="M20.9944 27.5222L21.121 27.0643C21.3393 26.2769 22.1613 25.806 22.9613 26.0114L23.7546 26.2177C24.5536 26.4255 25.0238 27.2335 24.8057 28.0232L24.6796 28.4802C25.1712 28.8248 25.6002 29.2481 25.9491 29.7327L26.4148 29.6079C27.2147 29.3945 28.0336 29.8569 28.247 30.6437L28.4564 31.4256C28.5575 31.8038 28.5014 32.2073 28.3006 32.5474C28.0999 32.8874 27.7708 33.136 27.3868 33.2383L26.9221 33.3628C26.8918 33.6578 26.8364 33.9512 26.7564 34.241C26.6764 34.5306 26.5733 34.8113 26.4475 35.0819L26.7861 35.4156C27.3676 35.9897 27.3645 36.9229 26.7779 37.5044L26.1937 38.0798C25.911 38.358 25.5287 38.515 25.1308 38.5163C24.733 38.5175 24.3523 38.363 24.0733 38.0872L23.7342 37.7533C23.1825 38.0036 22.593 38.1619 21.99 38.2218L21.8635 38.6797C21.6451 39.4672 20.8231 39.938 20.0231 39.7326L19.2298 39.5263C18.4308 39.3185 17.9606 38.5106 18.1787 37.7209L18.3049 37.2638C17.8132 36.9192 17.3842 36.496 17.0353 36.0114L16.5696 36.1361C15.7697 36.3495 14.9509 35.8871 14.7374 35.1003L14.528 34.3184C14.4269 33.9403 14.483 33.5367 14.6838 33.1967C14.8846 32.8566 15.2136 32.608 15.5977 32.5057L16.0623 32.3813C16.0926 32.0862 16.148 31.7928 16.2279 31.5033C16.3073 31.2153 16.4106 30.9341 16.537 30.6621L16.1983 30.3284C15.6168 29.7543 15.62 28.8211 16.2065 28.2396L16.7907 27.6643C17.0734 27.3861 17.4558 27.229 17.8536 27.2278C18.2515 27.2265 18.6321 27.3811 18.9111 27.6568L19.2501 27.9906C19.8125 27.7376 20.3968 27.5816 20.9944 27.5222ZM19.3541 29.1231C19.1465 29.2172 18.9024 29.1749 18.742 29.0169L18.1356 28.4197C18.0595 28.3445 17.956 28.3025 17.8477 28.3028C17.7395 28.3032 17.6354 28.3459 17.5587 28.4214L16.976 28.9953C16.8161 29.1539 16.8152 29.4089 16.9737 29.5654L17.5783 30.1611C17.7385 30.3189 17.7816 30.5589 17.6864 30.7633L17.5285 31.1027C17.4273 31.3204 17.3445 31.5458 17.2808 31.7768C17.2166 32.0096 17.1723 32.2446 17.148 32.4805L17.1099 32.8514C17.087 33.0746 16.926 33.2615 16.706 33.3205L15.8747 33.5432C15.77 33.5711 15.6805 33.6387 15.6258 33.7312C15.5712 33.8237 15.556 33.9335 15.5834 34.0361L15.7923 34.816C15.8505 35.0306 16.0743 35.1569 16.2923 35.0987L17.124 34.876C17.3443 34.817 17.5775 34.8987 17.7088 35.0811L17.927 35.3841C18.2062 35.7718 18.5493 36.1104 18.942 36.3857L19.2494 36.6007C19.4343 36.7301 19.5173 36.9598 19.4573 37.1768L19.2315 37.9947C19.1719 38.2106 19.2997 38.4302 19.5168 38.4867L20.3085 38.6926C20.5267 38.7486 20.7513 38.6199 20.8108 38.4054L21.0366 37.5874C21.0965 37.3706 21.2864 37.212 21.5131 37.1895L21.89 37.1522C22.3723 37.1043 22.8438 36.9776 23.2856 36.7771L23.6306 36.6208C23.8382 36.5268 24.082 36.5692 24.2424 36.7271L24.8488 37.3243C24.9249 37.3995 25.0285 37.4416 25.1367 37.4412C25.2449 37.4409 25.349 37.3981 25.4257 37.3226L26.0084 36.7487C26.1683 36.5901 26.1692 36.3351 26.0107 36.1786L25.4061 35.5829C25.2459 35.4251 25.2028 35.1851 25.298 34.9807L25.456 34.6412C25.5567 34.4246 25.6393 34.1998 25.7036 33.9672C25.7678 33.7345 25.8122 33.4995 25.8364 33.2636L25.8745 32.8927C25.8974 32.6695 26.0584 32.4825 26.2784 32.4236L27.1097 32.2009C27.2144 32.173 27.3039 32.1053 27.3586 32.0128C27.4132 31.9203 27.4284 31.8105 27.401 31.708L27.1921 30.928C27.1339 30.7135 26.9101 30.5871 26.6921 30.6453L25.8604 30.868C25.6401 30.9271 25.4069 30.8453 25.2756 30.663L25.0574 30.3599C24.7783 29.9722 24.4351 29.6336 24.0427 29.3586L23.7353 29.1438C23.5501 29.0145 23.4671 28.7847 23.527 28.5675L23.7529 27.7494C23.8125 27.5334 23.6847 27.3138 23.4676 27.2573L22.6759 27.0514C22.4578 26.9954 22.2332 27.1241 22.1736 27.3387L21.9478 28.1566C21.8879 28.3735 21.698 28.5321 21.4713 28.5545L21.0944 28.5918C20.6121 28.6397 20.1406 28.7665 19.6992 28.9668C19.696 28.9682 19.5809 29.0203 19.3541 29.1231Z" fill="#7B58CF"/>
+<path d="M19.4256 36.6322C20.4661 37.2185 21.8046 36.8571 22.4152 35.825C23.0258 34.7929 22.6774 33.4808 21.6369 32.8945C20.5964 32.3082 19.2579 32.6696 18.6473 33.7017C18.0367 34.7339 18.3851 36.0459 19.4256 36.6322Z" fill="white"/>
+<path d="M20.2669 34.8677C19.2265 34.2814 18.878 32.9693 19.4886 31.9372C20.0992 30.9051 21.4377 30.5437 22.4782 31.13C23.5187 31.7163 23.8671 33.0283 23.2565 34.0605C22.6459 35.0926 21.3074 35.454 20.2669 34.8677ZM20.8198 33.9333C21.34 34.2264 22.0092 34.0457 22.3145 33.5297C22.6199 33.0136 22.4456 32.3576 21.9254 32.0644C21.4051 31.7713 20.7359 31.952 20.4306 32.468C20.1253 32.9841 20.2995 33.6401 20.8198 33.9333Z" fill="#6B4FBB"/>
+<path d="M30.889 35.0709C31.3313 34.8678 31.8035 34.7389 32.2863 34.6895L32.3883 34.3201C32.5643 33.6826 33.2272 33.3008 33.8637 33.4663L34.4983 33.6313C34.8053 33.7113 35.066 33.9096 35.2232 34.1825C35.3803 34.4555 35.421 34.7807 35.3362 35.0867L35.2342 35.4561C35.6306 35.7354 35.972 36.0777 36.2478 36.4644L36.6207 36.3629C37.262 36.1888 37.9173 36.5612 38.0857 37.1955L38.2517 37.8259C38.3319 38.1309 38.2864 38.4566 38.1251 38.7315C37.9638 39.0063 37.7001 39.2077 37.3919 39.2913L37.0198 39.3927C36.9951 39.6284 36.9507 39.8652 36.8854 40.1018C36.82 40.3384 36.7365 40.565 36.6364 40.7811L36.9065 41.0493C37.3717 41.512 37.367 42.2664 36.8961 42.7358L36.4275 43.2012C36.2007 43.4262 35.8945 43.5537 35.5761 43.5556C35.2577 43.5575 34.9532 43.4336 34.7299 43.2112L34.4592 42.9424C34.017 43.1455 33.5447 43.2744 33.0619 43.3238L32.9599 43.6932C32.7839 44.3307 32.1211 44.7125 31.4845 44.547L30.8499 44.3819C30.543 44.302 30.2822 44.1037 30.1251 43.8308C29.9679 43.5578 29.9273 43.2326 30.0121 42.9266L30.1141 42.5572C29.7214 42.2801 29.3788 41.9393 29.1004 41.5488L28.7276 41.6503C28.0863 41.8245 27.431 41.4521 27.2626 40.8178L27.0965 40.1874C27.0163 39.8824 27.0619 39.5566 27.2231 39.2818C27.3844 39.007 27.6481 38.8056 27.9563 38.722L28.3285 38.6206C28.3536 38.3812 28.3986 38.144 28.4629 37.9115C28.5282 37.6749 28.6118 37.4483 28.7118 37.2322L28.4417 36.964C27.9766 36.5013 27.9813 35.7469 28.4522 35.2775L28.9207 34.8121C29.1475 34.5871 29.4538 34.4595 29.7722 34.4577C30.0906 34.4558 30.395 34.5797 30.6184 34.802L30.889 35.0709Z" fill="white"/>
+<path d="M32.1998 34.5947L32.2833 34.2923C32.4746 33.6024 33.1919 33.1869 33.8914 33.3665L34.526 33.5315C35.2246 33.7132 35.6326 34.4223 35.4416 35.1142L35.3583 35.4159C35.7153 35.6768 36.0302 35.9898 36.2925 36.3444L36.5904 36.2633C37.2909 36.0733 38.0069 36.4799 38.1913 37.1715L38.3575 37.8023C38.4444 38.133 38.3949 38.4857 38.2201 38.7834C38.045 39.0816 37.7581 39.3004 37.423 39.3911L37.1204 39.4735C37.0938 39.6938 37.0505 39.9128 36.9908 40.1294C36.931 40.3458 36.8557 40.5563 36.7651 40.7602L36.9851 40.9788C37.4903 41.4818 37.4856 42.298 36.9752 42.8081L36.5064 43.2737C36.2596 43.5186 35.9255 43.6575 35.5775 43.6595C35.2288 43.6614 34.8954 43.5256 34.6519 43.2828L34.4357 43.0682C34.026 43.2479 33.5922 43.3661 33.1486 43.4189L33.0651 43.7214C32.8738 44.4113 32.1565 44.8267 31.457 44.6472L30.8224 44.4821C30.1238 44.3005 29.7158 43.5913 29.9068 42.8994L29.9901 42.5978C29.6331 42.3368 29.3182 42.0238 29.0559 41.6693L28.758 41.7504C28.0575 41.9403 27.3415 41.5338 27.1571 40.8421L26.9909 40.2114C26.9041 39.8807 26.9535 39.5279 27.1283 39.2303C27.3034 38.9321 27.5903 38.7132 27.9254 38.6225L28.228 38.5401C28.2546 38.3199 28.2979 38.1009 28.3576 37.8845C28.417 37.6691 28.4924 37.4583 28.5833 37.2535L28.3633 37.0348C27.8581 36.5319 27.8628 35.7157 28.3732 35.2055L28.842 34.74C29.0888 34.4951 29.4229 34.3561 29.7709 34.3542C30.1196 34.3522 30.453 34.4881 30.6965 34.7308L30.9127 34.9455C31.3224 34.7658 31.7562 34.6475 32.1998 34.5947ZM31.0182 36.0782C30.8094 36.1741 30.5632 36.1315 30.4022 35.9716L29.9178 35.4906C29.878 35.4508 29.8241 35.4289 29.7676 35.4292C29.7105 35.4295 29.6548 35.4527 29.6133 35.4939L29.1459 35.958C29.057 36.0469 29.0562 36.1897 29.1419 36.275L29.6246 36.7547C29.7833 36.9124 29.8259 37.151 29.7315 37.3546L29.6044 37.6287C29.5253 37.7997 29.4605 37.9767 29.4105 38.1581C29.36 38.3408 29.3251 38.5252 29.3058 38.7103L29.2745 39.0097C29.2514 39.2318 29.0916 39.4178 28.8729 39.4774L28.2068 39.6588C28.1506 39.674 28.1018 39.7112 28.0716 39.7626C28.0411 39.8146 28.0325 39.8763 28.0474 39.9333L28.2131 40.5621C28.2441 40.6786 28.3616 40.7453 28.4764 40.7142L29.1425 40.5328C29.3644 40.4725 29.5996 40.555 29.731 40.7394L29.9051 40.9836C30.1208 41.2862 30.3862 41.5502 30.6899 41.7646L30.9355 41.9376C31.1195 42.0672 31.2018 42.2962 31.142 42.5126L30.9597 43.1732C30.9264 43.2936 30.995 43.4128 31.1094 43.4425L31.7424 43.6071C31.8578 43.6367 31.9794 43.5663 32.0125 43.447L32.1949 42.7864C32.2546 42.5701 32.4437 42.4117 32.6697 42.3887L32.9714 42.3578C33.3452 42.3195 33.711 42.2196 34.054 42.0621L34.3305 41.9353C34.5392 41.8396 34.7853 41.8823 34.9462 42.0421L35.4306 42.5231C35.4704 42.5628 35.5243 42.5848 35.5808 42.5845C35.6379 42.5841 35.6936 42.561 35.7352 42.5197L36.2025 42.0556C36.2914 41.9668 36.2922 41.8239 36.2065 41.7387L35.7238 41.259C35.5651 41.1013 35.5225 40.8626 35.6169 40.6591L35.7441 40.3847C35.8228 40.2147 35.8875 40.0382 35.9379 39.8556C35.9884 39.6728 36.0233 39.4884 36.0427 39.3034L36.0739 39.0039C36.097 38.7819 36.2568 38.5958 36.4755 38.5363L37.1416 38.3549C37.1978 38.3397 37.2466 38.3025 37.2768 38.2511C37.3073 38.1991 37.3159 38.1373 37.301 38.0803L37.1353 37.4515C37.1043 37.335 36.9868 37.2683 36.872 37.2995L36.2059 37.4808C35.9841 37.5412 35.7488 37.4586 35.6174 37.2743L35.4433 37.0301C35.2276 36.7274 34.9622 36.4634 34.6588 36.2493L34.4132 36.0765C34.2289 35.947 34.1465 35.7178 34.2063 35.5012L34.3888 34.8404C34.422 34.7201 34.3534 34.6009 34.239 34.5711L33.606 34.4065C33.4907 34.3769 33.369 34.4473 33.3359 34.5666L33.1535 35.2272C33.0938 35.4435 32.9047 35.6019 32.6788 35.625L32.377 35.6558C32.0032 35.6941 31.6374 35.794 31.2948 35.9514C31.2919 35.9528 31.1997 35.995 31.0182 36.0782Z" fill="#FC6D26"/>
+<path d="M31.0067 42.0443C31.839 42.5134 32.9136 42.2178 33.4068 41.3841C33.9 40.5504 33.6251 39.4944 32.7928 39.0253C31.9604 38.5563 30.8858 38.8519 30.3926 39.6855C29.8994 40.5192 30.1743 41.5753 31.0067 42.0443Z" fill="white"/>
+<path d="M31.6893 40.6162C30.8561 40.1466 30.5815 39.0919 31.073 38.2611C31.5645 37.4302 32.6378 37.135 33.471 37.6045C34.3042 38.074 34.5788 39.1288 34.0873 39.9596C33.5957 40.7905 32.5225 41.0857 31.6893 40.6162ZM32.1315 39.8686C32.5469 40.1027 33.0855 39.9546 33.3337 39.535C33.5819 39.1154 33.4441 38.5861 33.0288 38.3521C32.6134 38.118 32.0748 38.2661 31.8266 38.6857C31.5783 39.1053 31.7162 39.6346 32.1315 39.8686Z" fill="#FC6D26"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M16.8736 10.2573C16.9313 9.88553 16.9613 9.50478 16.9613 9.11717C16.9613 4.97039 13.5319 1.60876 9.30146 1.60876C5.07104 1.60876 1.6416 4.97039 1.6416 9.11717C1.6416 13.2639 5.07104 16.6256 9.30146 16.6256C11.7125 16.6256 13.8633 15.5337 15.2674 13.8269L17.582 13.5753C17.637 13.5693 17.6908 13.5552 17.7414 13.5334C18.0182 13.4145 18.1442 13.0982 18.0228 12.8269L16.8736 10.2573Z" fill="#F4F0FF"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M17.42 9.18492C17.4777 8.81314 17.5077 8.43239 17.5077 8.04478C17.5077 3.898 14.0783 0.536377 9.84784 0.536377C5.61742 0.536377 2.18799 3.898 2.18799 8.04478C2.18799 12.1916 5.61742 15.5532 9.84784 15.5532C12.2588 15.5532 14.4097 14.4613 15.8138 12.7545L18.1284 12.5029C18.1834 12.4969 18.2371 12.4828 18.2878 12.461C18.5646 12.3421 18.6905 12.0258 18.5692 11.7545L17.42 9.18492Z" fill="white"/>
+<path d="M19.0711 11.5391C19.3137 12.0817 19.0617 12.7143 18.5082 12.9522C18.4069 12.9957 18.2994 13.0239 18.1895 13.0359L16.0945 13.2636C14.5478 15.0412 12.2855 16.0894 9.84859 16.0894C5.31599 16.0894 1.6416 12.4877 1.6416 8.04471C1.6416 3.60174 5.31599 0 9.84859 0C14.3812 0 18.0556 3.60174 18.0556 8.04471C18.0556 8.40289 18.0317 8.75827 17.9843 9.10926L19.0711 11.5391ZM16.8798 9.10409C16.9339 8.75583 16.9613 8.40206 16.9613 8.04471C16.9613 4.19414 13.7768 1.07263 9.84859 1.07263C5.92034 1.07263 2.73587 4.19414 2.73587 8.04471C2.73587 11.8953 5.92034 15.0168 9.84859 15.0168C12.0288 15.0168 14.0459 14.0499 15.3886 12.4178L15.5301 12.2457L18.0689 11.9698L16.9196 9.4002L16.8559 9.2578L16.8798 9.10409Z" fill="#E1D8F9"/>
+<path d="M9.98471 11.9331C7.8695 11.9331 6.15479 10.2523 6.15479 8.17888C6.15479 6.1055 7.8695 4.42468 9.98471 4.42468C12.0999 4.42468 13.8146 6.1055 13.8146 8.17888C13.8146 10.2523 12.0999 11.9331 9.98471 11.9331ZM9.98471 11.1286C11.6467 11.1286 12.9939 9.80798 12.9939 8.17888C12.9939 6.54979 11.6467 5.22915 9.98471 5.22915C8.32276 5.22915 6.97548 6.54979 6.97548 8.17888C6.97548 9.80798 8.32276 11.1286 9.98471 11.1286Z" fill="#31AF64"/>
+<path d="M9.62376 8.52988L9.09523 8.0118C8.93843 7.85825 8.68438 7.85825 8.52758 8.0118C8.4521 8.08542 8.40967 8.1855 8.40967 8.28987C8.40967 8.39425 8.4521 8.49433 8.52758 8.56795L9.31408 9.33864C9.31836 9.34303 9.32274 9.34732 9.32721 9.35151C9.47767 9.499 9.71841 9.49873 9.86751 9.35285L11.4564 7.79539C11.5279 7.725 11.5679 7.62964 11.5677 7.53031C11.5674 7.43098 11.5269 7.33582 11.455 7.26578C11.3837 7.19514 11.2866 7.15528 11.1851 7.15503C11.0837 7.15478 10.9864 7.19415 10.9147 7.26444L9.62376 8.52988Z" fill="#31AF64"/>
+</svg>
diff --git a/app/assets/images/learn_gitlab/required_mr_approvals_enabled.svg b/app/assets/images/learn_gitlab/required_mr_approvals_enabled.svg
new file mode 100644
index 00000000000..027767368a6
--- /dev/null
+++ b/app/assets/images/learn_gitlab/required_mr_approvals_enabled.svg
@@ -0,0 +1,70 @@
+<svg width="80" height="56" viewBox="0 0 80 56" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.1567 40.6485C16.4079 40.6485 20.6649 36.3891 20.6649 31.135C20.6649 25.8808 16.4079 21.6215 11.1567 21.6215C5.9055 21.6215 1.64856 25.8808 1.64856 31.135C1.64856 36.3891 5.9055 40.6485 11.1567 40.6485Z" fill="#F9F9F9"/>
+<path d="M19.0163 30.4864C19.0163 35.1888 15.2065 38.9999 10.5081 38.9999C5.80976 38.9999 2 35.1888 2 30.4864C2 25.784 5.80976 21.9729 10.5081 21.9729C15.2065 21.9729 19.0163 25.784 19.0163 30.4864Z" fill="white" stroke="#EEEEEE" stroke-width="2"/>
+<path d="M10.5075 39.5674C15.52 39.5674 19.5834 35.5017 19.5834 30.4863C19.5834 25.471 15.52 21.4053 10.5075 21.4053C5.49496 21.4053 1.43152 25.471 1.43152 30.4863C1.43152 35.5017 5.49496 39.5674 10.5075 39.5674Z" stroke="#EEEEEE"/>
+<path d="M8.43196 33.0239C8.57802 32.9204 8.75259 32.8648 8.93158 32.8648H13.1009C13.817 32.8648 14.3975 32.284 14.3975 31.5675V28.5405C14.3975 27.824 13.817 27.2432 13.1009 27.2432H8.34686C7.63078 27.2432 7.05029 27.824 7.05029 28.5405V34.0031L8.43196 33.0239ZM8.93158 33.7296L7.20891 34.9505C7.09937 35.0282 6.96844 35.0699 6.8342 35.0699C6.47616 35.0699 6.18591 34.7795 6.18591 34.4212V28.5405C6.18591 27.3463 7.1534 26.3783 8.34686 26.3783H13.1009C14.2944 26.3783 15.2619 27.3463 15.2619 28.5405V31.5675C15.2619 32.7616 14.2944 33.7296 13.1009 33.7296H8.93158Z" fill="#FEE1D3"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M9.21191 29.6217C9.4506 29.6217 9.6441 29.8153 9.6441 30.0541C9.6441 30.293 9.4506 30.4866 9.21191 30.4866C8.97322 30.4866 8.77972 30.293 8.77972 30.0541C8.77972 29.8153 8.97322 29.6217 9.21191 29.6217ZM10.7246 29.6217C10.9633 29.6217 11.1568 29.8153 11.1568 30.0541C11.1568 30.293 10.9633 30.4866 10.7246 30.4866C10.4859 30.4866 10.2924 30.293 10.2924 30.0541C10.2924 29.8153 10.4859 29.6217 10.7246 29.6217ZM12.2372 29.6217C12.4759 29.6217 12.6694 29.8153 12.6694 30.0541C12.6694 30.293 12.4759 30.4866 12.2372 30.4866C11.9985 30.4866 11.805 30.293 11.805 30.0541C11.805 29.8153 11.9985 29.6217 12.2372 29.6217Z" fill="#FC6D26"/>
+<path d="M18.0716 14.0539C21.7713 14.0539 24.7705 11.0531 24.7705 7.35125C24.7705 3.64946 21.7713 0.64856 18.0716 0.64856C14.3719 0.64856 11.3727 3.64946 11.3727 7.35125C11.3727 11.0531 14.3719 14.0539 18.0716 14.0539Z" fill="#F9F9F9"/>
+<path d="M17.423 13.4054C21.1228 13.4054 24.122 10.4045 24.122 6.70269C24.122 3.0009 21.1228 0 17.423 0C13.7233 0 10.7241 3.0009 10.7241 6.70269C10.7241 10.4045 13.7233 13.4054 17.423 13.4054Z" fill="white"/>
+<path d="M19.1525 6.27026H15.6949C15.4563 6.27026 15.2628 6.46387 15.2628 6.7027C15.2628 6.94152 15.4563 7.13513 15.6949 7.13513H19.1525C19.3911 7.13513 19.5846 6.94152 19.5846 6.7027C19.5846 6.46387 19.3911 6.27026 19.1525 6.27026Z" fill="#6B4FBB"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.7467 48.0001H20.8808C19.4486 48.0001 18.2877 46.8384 18.2877 45.4055V12.5406C18.2877 11.1077 19.4486 9.94604 20.8808 9.94604H65.3962C66.8284 9.94604 67.9893 11.1077 67.9893 12.5406V16.5187C69.077 15.9128 70.3297 15.5677 71.6629 15.5677C75.84 15.5677 79.2262 18.9558 79.2262 23.1352C79.2262 27.3147 75.84 30.7028 71.6629 30.7028C70.3297 30.7028 69.077 30.3576 67.9893 29.7517V45.4055C67.9893 46.8384 66.8284 48.0001 65.3962 48.0001H62.8938C62.9761 48.4198 63.0192 48.8535 63.0192 49.2974C63.0192 52.9991 60.02 56 56.3202 56C52.6205 56 49.6213 52.9991 49.6213 49.2974C49.6213 48.8535 49.6644 48.4198 49.7467 48.0001Z" fill="#F9F9F9"/>
+<path d="M71.0139 30.0543C75.191 30.0543 78.5772 26.6662 78.5772 22.4868C78.5772 18.3073 75.191 14.9192 71.0139 14.9192C66.8368 14.9192 63.4506 18.3073 63.4506 22.4868C63.4506 26.6662 66.8368 30.0543 71.0139 30.0543Z" fill="white"/>
+<path d="M71.0148 29.6218C74.9532 29.6218 78.1459 26.4273 78.1459 22.4867C78.1459 18.5461 74.9532 15.3516 71.0148 15.3516C67.0764 15.3516 63.8837 18.5461 63.8837 22.4867C63.8837 26.4273 67.0764 29.6218 71.0148 29.6218Z" stroke="#EEEEEE" stroke-width="2"/>
+<path d="M71.014 25.7303C72.8042 25.7303 74.2554 24.2782 74.2554 22.487C74.2554 20.6958 72.8042 19.2438 71.014 19.2438C69.2238 19.2438 67.7726 20.6958 67.7726 22.487C67.7726 24.2782 69.2238 25.7303 71.014 25.7303Z" fill="#F4F1FA" stroke="#6B4FBB"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M71.9682 20.5401L71.9667 20.5401L71.4366 19.8925L70.9063 20.5404L70.9051 20.5404L70.3748 19.8925L69.8443 20.5407L69.8436 20.5407L69.3131 19.8925L68.7823 20.541L68.2041 20.5411C68.7671 19.6321 69.7666 19.0276 70.9057 19.0276C72.0443 19.0276 73.0433 19.6315 73.6065 20.5397L73.0282 20.5399L72.4983 19.8925L71.9682 20.5401Z" fill="#6B4FBB"/>
+<path d="M69.8258 23.1357C70.0048 23.1357 70.1499 22.9905 70.1499 22.8114C70.1499 22.6323 70.0048 22.4871 69.8258 22.4871C69.6468 22.4871 69.5016 22.6323 69.5016 22.8114C69.5016 22.9905 69.6468 23.1357 69.8258 23.1357Z" fill="#6B4FBB"/>
+<path d="M72.2021 23.1357C72.3811 23.1357 72.5263 22.9905 72.5263 22.8114C72.5263 22.6323 72.3811 22.4871 72.2021 22.4871C72.0231 22.4871 71.878 22.6323 71.878 22.8114C71.878 22.9905 72.0231 23.1357 72.2021 23.1357Z" fill="#6B4FBB"/>
+<path d="M65.1797 9.29712H19.7999C18.6064 9.29712 17.6389 10.2652 17.6389 11.4593V45.189C17.6389 46.3831 18.6064 47.3511 19.7999 47.3511H65.1797C66.3731 47.3511 67.3406 46.3831 67.3406 45.189V11.4593C67.3406 10.2652 66.3731 9.29712 65.1797 9.29712Z" fill="white"/>
+<path d="M64.7466 9.72986H20.2311C19.0377 9.72986 18.0702 10.6979 18.0702 11.892V44.7568C18.0702 45.951 19.0377 46.919 20.2311 46.919H64.7466C65.94 46.919 66.9075 45.951 66.9075 44.7568V11.892C66.9075 10.6979 65.94 9.72986 64.7466 9.72986Z" stroke="#EEEEEE" stroke-width="2"/>
+<path d="M25.6338 18.8108C25.6338 18.572 25.4403 18.3784 25.2016 18.3784C24.9629 18.3784 24.7694 18.572 24.7694 18.8108V44.3243C24.7694 44.5632 24.9629 44.7568 25.2016 44.7568C25.4403 44.7568 25.6338 44.5632 25.6338 44.3243V18.8108Z" fill="#EEEEEE"/>
+<path d="M22.177 22.0541H20.8804C20.6417 22.0541 20.4482 22.2477 20.4482 22.4865C20.4482 22.7253 20.6417 22.9189 20.8804 22.9189H22.177C22.4157 22.9189 22.6092 22.7253 22.6092 22.4865C22.6092 22.2477 22.4157 22.0541 22.177 22.0541Z" fill="#FEE1D3"/>
+<path d="M22.177 24.6486H20.8804C20.6417 24.6486 20.4482 24.8422 20.4482 25.081C20.4482 25.3198 20.6417 25.5134 20.8804 25.5134H22.177C22.4157 25.5134 22.6092 25.3198 22.6092 25.081C22.6092 24.8422 22.4157 24.6486 22.177 24.6486Z" fill="#F0EDF8"/>
+<path d="M22.177 27.2432H20.8804C20.6417 27.2432 20.4482 27.4368 20.4482 27.6756C20.4482 27.9144 20.6417 28.108 20.8804 28.108H22.177C22.4157 28.108 22.6092 27.9144 22.6092 27.6756C22.6092 27.4368 22.4157 27.2432 22.177 27.2432Z" fill="#FEF0E9"/>
+<path d="M22.177 29.8379H20.8804C20.6417 29.8379 20.4482 30.0315 20.4482 30.2703C20.4482 30.5091 20.6417 30.7028 20.8804 30.7028H22.177C22.4157 30.7028 22.6092 30.5091 22.6092 30.2703C22.6092 30.0315 22.4157 29.8379 22.177 29.8379Z" fill="#FEE1D3"/>
+<path d="M22.177 32.4324H20.8804C20.6417 32.4324 20.4482 32.626 20.4482 32.8648C20.4482 33.1036 20.6417 33.2972 20.8804 33.2972H22.177C22.4157 33.2972 22.6092 33.1036 22.6092 32.8648C22.6092 32.626 22.4157 32.4324 22.177 32.4324Z" fill="#E1DBF1"/>
+<path d="M22.177 35.027H20.8804C20.6417 35.027 20.4482 35.2206 20.4482 35.4594C20.4482 35.6982 20.6417 35.8918 20.8804 35.8918H22.177C22.4157 35.8918 22.6092 35.6982 22.6092 35.4594C22.6092 35.2206 22.4157 35.027 22.177 35.027Z" fill="#F0EDF8"/>
+<path d="M22.177 37.6216H20.8804C20.6417 37.6216 20.4482 37.8152 20.4482 38.054C20.4482 38.2928 20.6417 38.4864 20.8804 38.4864H22.177C22.4157 38.4864 22.6092 38.2928 22.6092 38.054C22.6092 37.8152 22.4157 37.6216 22.177 37.6216Z" fill="#FEF0E9"/>
+<path d="M22.177 40.2161H20.8804C20.6417 40.2161 20.4482 40.4097 20.4482 40.6485C20.4482 40.8873 20.6417 41.0809 20.8804 41.0809H22.177C22.4157 41.0809 22.6092 40.8873 22.6092 40.6485C22.6092 40.4097 22.4157 40.2161 22.177 40.2161Z" fill="#FEE1D3"/>
+<path d="M32.1164 22.0538H29.9555C29.7168 22.0538 29.5233 22.2474 29.5233 22.4863C29.5233 22.7251 29.7168 22.9187 29.9555 22.9187H32.1164C32.3551 22.9187 32.5486 22.7251 32.5486 22.4863C32.5486 22.2474 32.3551 22.0538 32.1164 22.0538Z" fill="#6B4FBB"/>
+<path d="M36.439 22.0537H34.278C34.0393 22.0537 33.8458 22.2473 33.8458 22.4861C33.8458 22.725 34.0393 22.9186 34.278 22.9186H36.439C36.6776 22.9186 36.8711 22.725 36.8711 22.4861C36.8711 22.2473 36.6776 22.0537 36.439 22.0537Z" fill="#F0EDF8"/>
+<path d="M40.7601 22.0537H38.5992C38.3605 22.0537 38.167 22.2473 38.167 22.4861C38.167 22.725 38.3605 22.9186 38.5992 22.9186H40.7601C40.9988 22.9186 41.1923 22.725 41.1923 22.4861C41.1923 22.2473 40.9988 22.0537 40.7601 22.0537Z" fill="#FEF0E9"/>
+<path d="M32.1161 24.6484H29.9551C29.7164 24.6484 29.5229 24.842 29.5229 25.0809C29.5229 25.3197 29.7164 25.5133 29.9551 25.5133H32.1161C32.3548 25.5133 32.5483 25.3197 32.5483 25.0809C32.5483 24.842 32.3548 24.6484 32.1161 24.6484Z" fill="#F0EDF8"/>
+<path d="M40.7601 27.243H38.5992C38.3605 27.243 38.167 27.4366 38.167 27.6755C38.167 27.9143 38.3605 28.1079 38.5992 28.1079H40.7601C40.9988 28.1079 41.1923 27.9143 41.1923 27.6755C41.1923 27.4366 40.9988 27.243 40.7601 27.243Z" fill="#FEF0E9"/>
+<path d="M32.1161 32.4323H29.9551C29.7164 32.4323 29.5229 32.6259 29.5229 32.8647C29.5229 33.1035 29.7164 33.2971 29.9551 33.2971H32.1161C32.3548 33.2971 32.5483 33.1035 32.5483 32.8647C32.5483 32.6259 32.3548 32.4323 32.1161 32.4323Z" fill="#E1DBF1"/>
+<path d="M40.7601 29.8378H38.5992C38.3605 29.8378 38.167 30.0314 38.167 30.2702C38.167 30.509 38.3605 30.7026 38.5992 30.7026H40.7601C40.9988 30.7026 41.1923 30.509 41.1923 30.2702C41.1923 30.0314 40.9988 29.8378 40.7601 29.8378Z" fill="#FEF0E9"/>
+<path d="M34.9263 24.6484H34.278C34.0393 24.6484 33.8458 24.842 33.8458 25.0809C33.8458 25.3197 34.0393 25.5133 34.278 25.5133H34.9263C35.165 25.5133 35.3585 25.3197 35.3585 25.0809C35.3585 24.842 35.165 24.6484 34.9263 24.6484Z" fill="#FEE1D3"/>
+<path d="M36.4379 29.8378H35.7896C35.5509 29.8378 35.3574 30.0314 35.3574 30.2702C35.3574 30.509 35.5509 30.7026 35.7896 30.7026H36.4379C36.6766 30.7026 36.8701 30.509 36.8701 30.2702C36.8701 30.0314 36.6766 29.8378 36.4379 29.8378Z" fill="#6B4FBB"/>
+<path d="M34.9263 32.4323H34.278C34.0393 32.4323 33.8458 32.6259 33.8458 32.8647C33.8458 33.1035 34.0393 33.2971 34.278 33.2971H34.9263C35.165 33.2971 35.3585 33.1035 35.3585 32.8647C35.3585 32.6259 35.165 32.4323 34.9263 32.4323Z" fill="#FEE1D3"/>
+<path d="M30.6034 27.243H29.9551C29.7164 27.243 29.5229 27.4366 29.5229 27.6755C29.5229 27.9143 29.7164 28.1079 29.9551 28.1079H30.6034C30.8421 28.1079 31.0356 27.9143 31.0356 27.6755C31.0356 27.4366 30.8421 27.243 30.6034 27.243Z" fill="#FC6D26"/>
+<path d="M36.4383 27.243H32.7647C32.526 27.243 32.3325 27.4366 32.3325 27.6755C32.3325 27.9143 32.526 28.1079 32.7647 28.1079H36.4383C36.677 28.1079 36.8705 27.9143 36.8705 27.6755C36.8705 27.4366 36.677 27.243 36.4383 27.243Z" fill="#E1DBF1"/>
+<path d="M33.6287 29.8378H29.9551C29.7164 29.8378 29.5229 30.0314 29.5229 30.2702C29.5229 30.509 29.7164 30.7026 29.9551 30.7026H33.6287C33.8674 30.7026 34.0609 30.509 34.0609 30.2702C34.0609 30.0314 33.8674 29.8378 33.6287 29.8378Z" fill="#EEEEEE"/>
+<path d="M37.7342 24.6484H37.0859C36.8472 24.6484 36.6537 24.842 36.6537 25.0809C36.6537 25.3197 36.8472 25.5133 37.0859 25.5133H37.7342C37.9728 25.5133 38.1663 25.3197 38.1663 25.0809C38.1663 24.842 37.9728 24.6484 37.7342 24.6484Z" fill="#6B4FBB"/>
+<path d="M53.2933 22.054H51.1323C50.8936 22.054 50.7001 22.2476 50.7001 22.4864C50.7001 22.7252 50.8936 22.9188 51.1323 22.9188H53.2933C53.532 22.9188 53.7254 22.7252 53.7254 22.4864C53.7254 22.2476 53.532 22.054 53.2933 22.054Z" fill="#FEE1D3"/>
+<path d="M57.6143 22.054H55.4533C55.2146 22.054 55.0211 22.2476 55.0211 22.4864C55.0211 22.7252 55.2146 22.9188 55.4533 22.9188H57.6143C57.8529 22.9188 58.0464 22.7252 58.0464 22.4864C58.0464 22.2476 57.8529 22.054 57.6143 22.054Z" fill="#F0EDF8"/>
+<path d="M61.9367 22.054H59.7758C59.5371 22.054 59.3436 22.2476 59.3436 22.4864C59.3436 22.7252 59.5371 22.9188 59.7758 22.9188H61.9367C62.1754 22.9188 62.3689 22.7252 62.3689 22.4864C62.3689 22.2476 62.1754 22.054 61.9367 22.054Z" fill="#FC6D26"/>
+<path d="M53.2931 24.6486H51.1321C50.8934 24.6486 50.7 24.8422 50.7 25.081C50.7 25.3198 50.8934 25.5134 51.1321 25.5134H53.2931C53.5318 25.5134 53.7253 25.3198 53.7253 25.081C53.7253 24.8422 53.5318 24.6486 53.2931 24.6486Z" fill="#FEF0E9"/>
+<path d="M61.9367 27.2432H59.7758C59.5371 27.2432 59.3436 27.4368 59.3436 27.6756C59.3436 27.9144 59.5371 28.108 59.7758 28.108H61.9367C62.1754 28.108 62.3689 27.9144 62.3689 27.6756C62.3689 27.4368 62.1754 27.2432 61.9367 27.2432Z" fill="#E1DBF1"/>
+<path d="M53.2931 32.4324H51.1321C50.8934 32.4324 50.7 32.626 50.7 32.8648C50.7 33.1036 50.8934 33.2972 51.1321 33.2972H53.2931C53.5318 33.2972 53.7253 33.1036 53.7253 32.8648C53.7253 32.626 53.5318 32.4324 53.2931 32.4324Z" fill="#F0EDF8"/>
+<path d="M61.9367 29.8378H59.7758C59.5371 29.8378 59.3436 30.0314 59.3436 30.2702C59.3436 30.509 59.5371 30.7026 59.7758 30.7026H61.9367C62.1754 30.7026 62.3689 30.509 62.3689 30.2702C62.3689 30.0314 62.1754 29.8378 61.9367 29.8378Z" fill="#FEE1D3"/>
+<path d="M56.1016 24.6486H55.4533C55.2146 24.6486 55.0211 24.8422 55.0211 25.081C55.0211 25.3198 55.2146 25.5134 55.4533 25.5134H56.1016C56.3403 25.5134 56.5338 25.3198 56.5338 25.081C56.5338 24.8422 56.3403 24.6486 56.1016 24.6486Z" fill="#FC6D26"/>
+<path d="M57.6149 29.8378H56.9666C56.7279 29.8378 56.5344 30.0314 56.5344 30.2702C56.5344 30.509 56.7279 30.7026 56.9666 30.7026H57.6149C57.8536 30.7026 58.0471 30.509 58.0471 30.2702C58.0471 30.0314 57.8536 29.8378 57.6149 29.8378Z" fill="#6B4FBB"/>
+<path d="M56.1016 32.4324H55.4533C55.2146 32.4324 55.0211 32.626 55.0211 32.8648C55.0211 33.1036 55.2146 33.2972 55.4533 33.2972H56.1016C56.3403 33.2972 56.5338 33.1036 56.5338 32.8648C56.5338 32.626 56.3403 32.4324 56.1016 32.4324Z" fill="#FC6D26"/>
+<path d="M51.7804 27.2432H51.1321C50.8934 27.2432 50.7 27.4368 50.7 27.6756C50.7 27.9144 50.8934 28.108 51.1321 28.108H51.7804C52.0191 28.108 52.2126 27.9144 52.2126 27.6756C52.2126 27.4368 52.0191 27.2432 51.7804 27.2432Z" fill="#6B4FBB"/>
+<path d="M57.6153 27.2432H53.9417C53.703 27.2432 53.5095 27.4368 53.5095 27.6756C53.5095 27.9144 53.703 28.108 53.9417 28.108H57.6153C57.854 28.108 58.0475 27.9144 58.0475 27.6756C58.0475 27.4368 57.854 27.2432 57.6153 27.2432Z" fill="#FEE1D3"/>
+<path d="M54.8057 29.8378H51.1321C50.8934 29.8378 50.7 30.0314 50.7 30.2702C50.7 30.509 50.8934 30.7026 51.1321 30.7026H54.8057C55.0444 30.7026 55.2379 30.509 55.2379 30.2702C55.2379 30.0314 55.0444 29.8378 54.8057 29.8378Z" fill="#FEF0E9"/>
+<path d="M58.9112 24.6486H58.2629C58.0242 24.6486 57.8307 24.8422 57.8307 25.081C57.8307 25.3198 58.0242 25.5134 58.2629 25.5134H58.9112C59.1499 25.5134 59.3433 25.3198 59.3433 25.081C59.3433 24.8422 59.1499 24.6486 58.9112 24.6486Z" fill="#6B4FBB"/>
+<path d="M32.1163 35.027H29.9553C29.7166 35.027 29.5231 35.2206 29.5231 35.4594C29.5231 35.6982 29.7166 35.8918 29.9553 35.8918H32.1163C32.355 35.8918 32.5484 35.6982 32.5484 35.4594C32.5484 35.2206 32.355 35.027 32.1163 35.027Z" fill="#F0EDF8"/>
+<path d="M36.4372 35.027H34.2763C34.0376 35.027 33.8441 35.2206 33.8441 35.4594C33.8441 35.6982 34.0376 35.8918 34.2763 35.8918H36.4372C36.6759 35.8918 36.8694 35.6982 36.8694 35.4594C36.8694 35.2206 36.6759 35.027 36.4372 35.027Z" fill="#6B4FBB"/>
+<path d="M40.7597 35.027H38.5988C38.3601 35.027 38.1666 35.2206 38.1666 35.4594C38.1666 35.6982 38.3601 35.8918 38.5988 35.8918H40.7597C40.9984 35.8918 41.1919 35.6982 41.1919 35.4594C41.1919 35.2206 40.9984 35.027 40.7597 35.027Z" fill="#E1DBF1"/>
+<path d="M32.1161 37.6216H29.9551C29.7164 37.6216 29.5229 37.8152 29.5229 38.054C29.5229 38.2928 29.7164 38.4864 29.9551 38.4864H32.1161C32.3548 38.4864 32.5483 38.2928 32.5483 38.054C32.5483 37.8152 32.3548 37.6216 32.1161 37.6216Z" fill="#FEF0E9"/>
+<path d="M40.7597 40.2161H38.5988C38.3601 40.2161 38.1666 40.4097 38.1666 40.6485C38.1666 40.8873 38.3601 41.0809 38.5988 41.0809H40.7597C40.9984 41.0809 41.1919 40.8873 41.1919 40.6485C41.1919 40.4097 40.9984 40.2161 40.7597 40.2161Z" fill="#FEE1D3"/>
+<path d="M34.9246 37.6216H34.2763C34.0376 37.6216 33.8441 37.8152 33.8441 38.054C33.8441 38.2928 34.0376 38.4864 34.2763 38.4864H34.9246C35.1633 38.4864 35.3568 38.2928 35.3568 38.054C35.3568 37.8152 35.1633 37.6216 34.9246 37.6216Z" fill="#EEEEEE"/>
+<path d="M30.6034 40.2161H29.9551C29.7164 40.2161 29.5229 40.4097 29.5229 40.6485C29.5229 40.8873 29.7164 41.0809 29.9551 41.0809H30.6034C30.8421 41.0809 31.0356 40.8873 31.0356 40.6485C31.0356 40.4097 30.8421 40.2161 30.6034 40.2161Z" fill="#6B4FBB"/>
+<path d="M36.4383 40.2161H32.7647C32.526 40.2161 32.3325 40.4097 32.3325 40.6485C32.3325 40.8873 32.526 41.0809 32.7647 41.0809H36.4383C36.677 41.0809 36.8705 40.8873 36.8705 40.6485C36.8705 40.4097 36.677 40.2161 36.4383 40.2161Z" fill="#FEF0E9"/>
+<path d="M37.7342 37.6216H37.0859C36.8472 37.6216 36.6537 37.8152 36.6537 38.054C36.6537 38.2928 36.8472 38.4864 37.0859 38.4864H37.7342C37.9728 38.4864 38.1663 38.2928 38.1663 38.054C38.1663 37.8152 37.9728 37.6216 37.7342 37.6216Z" fill="#FC6D26"/>
+<path d="M46.3791 25.2972C46.3791 25.0584 46.1856 24.8647 45.947 24.8647C45.7083 24.8647 45.5148 25.0584 45.5148 25.2972V38.0539C45.5148 38.2927 45.7083 38.4863 45.947 38.4863C46.1856 38.4863 46.3791 38.2927 46.3791 38.0539V25.2972Z" fill="#EEEEEE"/>
+<path d="M66.9082 15.135H18.0709C17.8322 15.135 17.6387 15.3286 17.6387 15.5674C17.6387 15.8063 17.8322 15.9999 18.0709 15.9999H66.9082C67.1469 15.9999 67.3404 15.8063 67.3404 15.5674C67.3404 15.3286 67.1469 15.135 66.9082 15.135Z" fill="#EEEEEE"/>
+<path d="M55.8884 55.3513C59.5881 55.3513 62.5874 52.3504 62.5874 48.6486C62.5874 44.9468 59.5881 41.9459 55.8884 41.9459C52.1887 41.9459 49.1895 44.9468 49.1895 48.6486C49.1895 52.3504 52.1887 55.3513 55.8884 55.3513Z" fill="white"/>
+<path d="M55.8878 54.9188C59.3489 54.9188 62.1546 52.1115 62.1546 48.6486C62.1546 45.1856 59.3489 42.3783 55.8878 42.3783C52.4268 42.3783 49.6211 45.1856 49.6211 48.6486C49.6211 52.1115 52.4268 54.9188 55.8878 54.9188Z" stroke="#EEEEEE" stroke-width="2"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M55.457 48.2161V46.9188C55.457 46.6799 55.6505 46.4863 55.8892 46.4863C56.1278 46.4863 56.3213 46.6799 56.3213 46.9188V48.2161H57.6179C57.8566 48.2161 58.0501 48.4097 58.0501 48.6485C58.0501 48.8873 57.8566 49.0809 57.6179 49.0809H56.3213V50.3782C56.3213 50.617 56.1278 50.8106 55.8892 50.8106C55.6505 50.8106 55.457 50.617 55.457 50.3782V49.0809H54.1604C53.9217 49.0809 53.7282 48.8873 53.7282 48.6485C53.7282 48.4097 53.9217 48.2161 54.1604 48.2161H55.457Z" fill="#FC6D26"/>
+</svg>
diff --git a/app/assets/images/learn_gitlab/security_scan_enabled.svg b/app/assets/images/learn_gitlab/security_scan_enabled.svg
new file mode 100644
index 00000000000..eea0693484c
--- /dev/null
+++ b/app/assets/images/learn_gitlab/security_scan_enabled.svg
@@ -0,0 +1,36 @@
+<svg width="47" height="47" viewBox="0 0 47 47" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M23.0588 47C35.7939 47 46.1176 36.6762 46.1176 23.9411C46.1176 11.2061 35.7939 0.882324 23.0588 0.882324C10.3238 0.882324 0 11.2061 0 23.9411C0 36.6762 10.3238 47 23.0588 47Z" fill="#EEEEEE"/>
+<path d="M23.0588 45.1176C34.7542 45.1176 44.2353 35.6366 44.2353 23.9411C44.2353 12.2457 34.7542 2.76465 23.0588 2.76465C11.3634 2.76465 1.88232 12.2457 1.88232 23.9411C1.88232 35.6366 11.3634 45.1176 23.0588 45.1176Z" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M16.3292 14.5295H16.9802C17.4786 14.5295 17.8827 14.9336 17.8827 15.432V16.8825C17.8827 17.4023 17.4613 17.8237 16.9415 17.8237H13.6556C13.1683 17.8237 12.7732 17.4286 12.7732 16.9413C12.7732 16.6257 12.9419 16.334 13.2155 16.1766L15.8598 14.655C16.0026 14.5728 16.1644 14.5295 16.3292 14.5295ZM12.2356 23.0001H16.9415C17.4613 23.0001 17.8827 23.4215 17.8827 23.9413V25.3531C17.8827 25.8729 17.4613 26.2942 16.9415 26.2942H12.2356C11.7158 26.2942 11.2944 25.8729 11.2944 25.3531V23.9413C11.2944 23.4215 11.7158 23.0001 12.2356 23.0001Z" fill="#EFEDF8"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M6.11793 18.7649H10.8238C11.3436 18.7649 11.765 19.1863 11.765 19.7061V21.1178C11.765 21.6376 11.3436 22.059 10.8238 22.059H6.11793C5.59814 22.059 5.17676 21.6376 5.17676 21.1178V19.7061C5.17676 19.1863 5.59814 18.7649 6.11793 18.7649Z" fill="#F9E2D5"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M40.4709 18.7649H35.765C35.2452 18.7649 34.8239 19.1863 34.8239 19.7061V21.1178C34.8239 21.6376 35.2452 22.059 35.765 22.059H40.4709C40.9907 22.059 41.4121 21.6376 41.4121 21.1178V19.7061C41.4121 19.1863 40.9907 18.7649 40.4709 18.7649Z" fill="#F9E2D5"/>
+<path d="M16.9415 31.0002H12.2356C11.7158 31.0002 11.2944 31.4216 11.2944 31.9414V33.3532C11.2944 33.873 11.7158 34.2944 12.2356 34.2944H16.9415C17.4613 34.2944 17.8827 33.873 17.8827 33.3532V31.9414C17.8827 31.4216 17.4613 31.0002 16.9415 31.0002Z" fill="#F9E2D5"/>
+<path d="M29.6474 31.0002H34.3533C34.8731 31.0002 35.2944 31.4216 35.2944 31.9414V33.3532C35.2944 33.873 34.8731 34.2944 34.3533 34.2944H29.6474C29.1276 34.2944 28.7062 33.873 28.7062 33.3532V31.9414C28.7062 31.4216 29.1276 31.0002 29.6474 31.0002Z" fill="#F9E2D5"/>
+<path d="M9.41202 31.0002H8.00026C7.48046 31.0002 7.05908 31.4216 7.05908 31.9414V33.3532C7.05908 33.873 7.48046 34.2944 8.00026 34.2944H9.41202C9.93182 34.2944 10.3532 33.873 10.3532 33.3532V31.9414C10.3532 31.4216 9.93182 31.0002 9.41202 31.0002Z" fill="#F9E2D5"/>
+<path d="M37.1768 31.0002H38.5886C39.1084 31.0002 39.5298 31.4216 39.5298 31.9414V33.3532C39.5298 33.873 39.1084 34.2944 38.5886 34.2944H37.1768C36.657 34.2944 36.2357 33.873 36.2357 33.3532V31.9414C36.2357 31.4216 36.657 31.0002 37.1768 31.0002Z" fill="#F9E2D5"/>
+<path d="M9.41202 23.0002H8.00026C7.48046 23.0002 7.05908 23.4216 7.05908 23.9414V25.3532C7.05908 25.873 7.48046 26.2944 8.00026 26.2944H9.41202C9.93182 26.2944 10.3532 25.873 10.3532 25.3532V23.9414C10.3532 23.4216 9.93182 23.0002 9.41202 23.0002Z" fill="#F9E2D5"/>
+<path d="M37.1768 23.0002H38.5886C39.1084 23.0002 39.5298 23.4216 39.5298 23.9414V25.3532C39.5298 25.873 39.1084 26.2944 38.5886 26.2944H37.1768C36.657 26.2944 36.2357 25.873 36.2357 25.3532V23.9414C36.2357 23.4216 36.657 23.0002 37.1768 23.0002Z" fill="#F9E2D5"/>
+<path d="M9.41202 14.5295H8.00026C7.48046 14.5295 7.05908 14.9509 7.05908 15.4707V16.8825C7.05908 17.4023 7.48046 17.8237 8.00026 17.8237H9.41202C9.93182 17.8237 10.3532 17.4023 10.3532 16.8825V15.4707C10.3532 14.9509 9.93182 14.5295 9.41202 14.5295Z" fill="#F9E2D5"/>
+<path d="M37.1768 14.5295H38.5886C39.1084 14.5295 39.5298 14.9509 39.5298 15.4707V16.8825C39.5298 17.4023 39.1084 17.8237 38.5886 17.8237H37.1768C36.657 17.8237 36.2357 17.4023 36.2357 16.8825V15.4707C36.2357 14.9509 36.657 14.5295 37.1768 14.5295Z" fill="#F9E2D5"/>
+<path d="M10.8238 26.7649H6.11793C5.59814 26.7649 5.17676 27.1863 5.17676 27.7061V29.1178C5.17676 29.6376 5.59814 30.059 6.11793 30.059H10.8238C11.3436 30.059 11.765 29.6376 11.765 29.1178V27.7061C11.765 27.1863 11.3436 26.7649 10.8238 26.7649Z" fill="#F9E2D5"/>
+<path d="M35.765 26.7649H40.4709C40.9907 26.7649 41.4121 27.1863 41.4121 27.7061V29.1178C41.4121 29.6376 40.9907 30.059 40.4709 30.059H35.765C35.2452 30.059 34.8239 29.6376 34.8239 29.1178V27.7061C34.8239 27.1863 35.2452 26.7649 35.765 26.7649Z" fill="#F9E2D5"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.2356 14.5295H16.9415C17.4613 14.5295 17.8827 14.9509 17.8827 15.4707V16.8825C17.8827 17.4023 17.4613 17.8237 16.9415 17.8237H12.2356C11.7158 17.8237 11.2944 17.4023 11.2944 16.8825V15.4707C11.2944 14.9509 11.7158 14.5295 12.2356 14.5295Z" fill="#EFEDF8"/>
+<path d="M28.2354 14.5295H23.5296C23.0098 14.5295 22.5884 14.9509 22.5884 15.4707V16.8825C22.5884 17.4023 23.0098 17.8237 23.5296 17.8237H28.2354C28.7552 17.8237 29.1766 17.4023 29.1766 16.8825V15.4707C29.1766 14.9509 28.7552 14.5295 28.2354 14.5295Z" fill="#EFEDF8"/>
+<path d="M28.2354 23.0002H23.5296C23.0098 23.0002 22.5884 23.4216 22.5884 23.9414V25.3532C22.5884 25.873 23.0098 26.2944 23.5296 26.2944H28.2354C28.7552 26.2944 29.1766 25.873 29.1766 25.3532V23.9414C29.1766 23.4216 28.7552 23.0002 28.2354 23.0002Z" fill="#EFEDF8"/>
+<path d="M32.0002 26.7649H29.6472C29.1274 26.7649 28.7061 27.1863 28.7061 27.7061V29.1178C28.7061 29.6376 29.1274 30.059 29.6472 30.059H32.0002C32.52 30.059 32.9413 29.6376 32.9413 29.1178V27.7061C32.9413 27.1863 32.52 26.7649 32.0002 26.7649Z" fill="#EFEDF8"/>
+<path d="M22.5885 18.7649H17.8826C17.3628 18.7649 16.9414 19.1863 16.9414 19.7061V21.1178C16.9414 21.6376 17.3628 22.059 17.8826 22.059H22.5885C23.1083 22.059 23.5296 21.6376 23.5296 21.1178V19.7061C23.5296 19.1863 23.1083 18.7649 22.5885 18.7649Z" fill="#EFEDF8"/>
+<path d="M21.1767 14.5295H19.7649C19.2451 14.5295 18.8237 14.9509 18.8237 15.4707V16.8825C18.8237 17.4023 19.2451 17.8237 19.7649 17.8237H21.1767C21.6965 17.8237 22.1178 17.4023 22.1178 16.8825V15.4707C22.1178 14.9509 21.6965 14.5295 21.1767 14.5295Z" fill="#EFEDF8"/>
+<path d="M21.1767 23.0002H19.7649C19.2451 23.0002 18.8237 23.4216 18.8237 23.9414V25.3532C18.8237 25.873 19.2451 26.2944 19.7649 26.2944H21.1767C21.6965 26.2944 22.1178 25.873 22.1178 25.3532V23.9414C22.1178 23.4216 21.6965 23.0002 21.1767 23.0002Z" fill="#EFEDF8"/>
+<path d="M15.059 26.7649H13.6472C13.1274 26.7649 12.7061 27.1863 12.7061 27.7061V29.1178C12.7061 29.6376 13.1274 30.059 13.6472 30.059H15.059C15.5788 30.059 16.0002 29.6376 16.0002 29.1178V27.7061C16.0002 27.1863 15.5788 26.7649 15.059 26.7649Z" fill="#EFEDF8"/>
+<path d="M21.1767 31.0002H19.7649C19.2451 31.0002 18.8237 31.4216 18.8237 31.9414V33.3532C18.8237 33.873 19.2451 34.2944 19.7649 34.2944H21.1767C21.6965 34.2944 22.1178 33.873 22.1178 33.3532V31.9414C22.1178 31.4216 21.6965 31.0002 21.1767 31.0002Z" fill="#EFEDF8"/>
+<path d="M32.4706 23.0002H31.0589C30.5391 23.0002 30.1177 23.4216 30.1177 23.9414V25.3532C30.1177 25.873 30.5391 26.2944 31.0589 26.2944H32.4706C32.9904 26.2944 33.4118 25.873 33.4118 25.3532V23.9414C33.4118 23.4216 32.9904 23.0002 32.4706 23.0002Z" fill="#EFEDF8"/>
+<path d="M15.059 18.7649H13.6472C13.1274 18.7649 12.7061 19.1863 12.7061 19.7061V21.1178C12.7061 21.6376 13.1274 22.059 13.6472 22.059H15.059C15.5788 22.059 16.0002 21.6376 16.0002 21.1178V19.7061C16.0002 19.1863 15.5788 18.7649 15.059 18.7649Z" fill="#EFEDF8"/>
+<path d="M26.8241 18.7649H25.4124C24.8926 18.7649 24.4712 19.1863 24.4712 19.7061V21.1178C24.4712 21.6376 24.8926 22.059 25.4124 22.059H26.8241C27.3439 22.059 27.7653 21.6376 27.7653 21.1178V19.7061C27.7653 19.1863 27.3439 18.7649 26.8241 18.7649Z" fill="#EFEDF8"/>
+<path d="M32.4706 14.5295H31.0589C30.5391 14.5295 30.1177 14.9509 30.1177 15.4707V16.8825C30.1177 17.4023 30.5391 17.8237 31.0589 17.8237H32.4706C32.9904 17.8237 33.4118 17.4023 33.4118 16.8825V15.4707C33.4118 14.9509 32.9904 14.5295 32.4706 14.5295Z" fill="#EFEDF8"/>
+<path d="M34.3531 18.7649H29.6472C29.1274 18.7649 28.7061 19.1863 28.7061 19.7061V21.1178C28.7061 21.6376 29.1274 22.059 29.6472 22.059H34.3531C34.8729 22.059 35.2943 21.6376 35.2943 21.1178V19.7061C35.2943 19.1863 34.8729 18.7649 34.3531 18.7649Z" fill="#EFEDF8"/>
+<path d="M22.5885 26.7649H17.8826C17.3628 26.7649 16.9414 27.1863 16.9414 27.7061V29.1178C16.9414 29.6376 17.3628 30.059 17.8826 30.059H22.5885C23.1083 30.059 23.5296 29.6376 23.5296 29.1178V27.7061C23.5296 27.1863 23.1083 26.7649 22.5885 26.7649Z" fill="#EFEDF8"/>
+<path d="M28.2354 31.0002H23.5296C23.0098 31.0002 22.5884 31.4216 22.5884 31.9414V33.3532C22.5884 33.873 23.0098 34.2944 23.5296 34.2944H28.2354C28.7552 34.2944 29.1766 33.873 29.1766 33.3532V31.9414C29.1766 31.4216 28.7552 31.0002 28.2354 31.0002Z" fill="#EFEDF8"/>
+<path d="M26.8241 26.7649H25.4124C24.8926 26.7649 24.4712 27.1863 24.4712 27.7061V29.1178C24.4712 29.6376 24.8926 30.059 25.4124 30.059H26.8241C27.3439 30.059 27.7653 29.6376 27.7653 29.1178V27.7061C27.7653 27.1863 27.3439 26.7649 26.8241 26.7649Z" fill="#EFEDF8"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M23.5516 10.3323L36.0222 13.8791C36.4267 13.9942 36.7059 14.3638 36.7059 14.7844V21.1043C36.7059 25.151 35.0909 29.032 32.2166 31.8933L23.9581 40.1143C23.5909 40.4798 22.9973 40.4798 22.6301 40.1143L14.3717 31.8933C11.4972 29.032 9.88232 25.151 9.88232 21.1043V14.7844C9.88232 14.3638 10.1614 13.9942 10.566 13.8791L23.0366 10.3323C23.2049 10.2844 23.3833 10.2844 23.5516 10.3323ZM23.1301 12.4046L11.9603 15.5579C11.7575 15.6151 11.6175 15.8001 11.6175 16.0108V20.6639C11.6175 24.3243 13.0892 27.8348 15.7088 30.4231L22.9272 37.5553C23.1105 37.7364 23.4054 37.7364 23.5887 37.5553L30.807 30.4231C33.4268 27.8348 34.8983 24.3243 34.8983 20.6639V16.0108C34.8983 15.8001 34.7583 15.6151 34.5556 15.5579L23.3858 12.4046C23.3022 12.381 23.2137 12.381 23.1301 12.4046Z" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M23.3165 11.7436L34.14 14.8206C34.5447 14.9356 34.8238 15.3052 34.8238 15.7259V21.1793C34.8238 24.7274 33.4072 28.1301 30.8859 30.6389L23.723 37.7659C23.3558 38.1312 22.7625 38.1312 22.3953 37.7659L15.2325 30.6389C12.711 28.1301 11.2944 24.7274 11.2944 21.1793V15.7259C11.2944 15.3052 11.5736 14.9356 11.9782 14.8206L22.8018 11.7436C22.97 11.6958 23.1483 11.6958 23.3165 11.7436ZM22.8996 13.5561L13.1593 16.3045C12.9566 16.3617 12.8166 16.5467 12.8166 16.7574V20.7932C12.8166 24.0026 14.1075 27.0805 16.4054 29.3498L22.6968 35.5631C22.88 35.7442 23.1748 35.7442 23.3581 35.5631L29.6494 29.3498C31.9474 27.0805 33.2383 24.0026 33.2383 20.7932V16.7574C33.2383 16.5467 33.0983 16.3617 32.8955 16.3045L23.1552 13.5561C23.0717 13.5325 22.9832 13.5325 22.8996 13.5561Z" fill="#6E49CB"/>
+</svg>
diff --git a/app/assets/images/learn_gitlab/trial_started.svg b/app/assets/images/learn_gitlab/trial_started.svg
new file mode 100644
index 00000000000..42d6fb6c013
--- /dev/null
+++ b/app/assets/images/learn_gitlab/trial_started.svg
@@ -0,0 +1,9 @@
+<svg width="32" height="30" viewBox="0 0 32 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M15.9653 29.8263L21.8368 11.6285H10.0933L15.9653 29.8263Z" fill="#E38800"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M15.9656 29.8263L10.0936 11.6285H1.86475L15.9656 29.8263Z" fill="#F7980A"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M1.86441 11.6285L0.0800968 17.1586C-0.0826524 17.663 0.0955967 18.2156 0.521693 18.5273L15.9652 29.8261L1.86441 11.6285Z" fill="#FCA326"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M1.86426 11.6286H10.0933L6.55678 0.668335C6.37489 0.104294 5.58257 0.104447 5.40067 0.668335L1.86426 11.6286Z" fill="#E38800"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M15.9653 29.8263L21.8369 11.6285H30.0658L15.9653 29.8263Z" fill="#F7980A"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M30.0662 11.6285L31.8505 17.1586C32.0132 17.663 31.835 18.2156 31.4089 18.5273L15.9653 29.8261L30.0662 11.6285Z" fill="#FCA326"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M30.066 11.6286H21.8369L25.3735 0.668335C25.5554 0.104294 26.3477 0.104447 26.5296 0.668335L30.066 11.6286Z" fill="#E38800"/>
+</svg>
diff --git a/app/assets/images/learn_gitlab/user_added.svg b/app/assets/images/learn_gitlab/user_added.svg
new file mode 100644
index 00000000000..efbccff0bbb
--- /dev/null
+++ b/app/assets/images/learn_gitlab/user_added.svg
@@ -0,0 +1,4 @@
+<svg width="38" height="24" viewBox="0 0 38 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M33.6353 7.51765H33.8824L32.4706 5.80941L31.0588 7.50353L29.6471 5.80941L28.2353 7.50353L26.8235 5.80941L25.4118 7.50353L24 5.80941L22.5882 7.51765H23.1318C23.6965 8.89412 24 10.4118 24 12C24 14.0047 23.5059 15.8824 22.6447 17.5482C24.0353 19.1576 26.0541 20.1176 28.2353 20.1176C32.3294 20.1176 35.6471 16.7718 35.6471 12.6353C35.6471 10.6588 34.8847 8.85177 33.6353 7.51765ZM22.0094 5.36471C23.7035 3.88235 25.9059 3.03529 28.2353 3.03529C33.5012 3.03529 37.7647 7.34118 37.7647 12.6353C37.7647 17.9294 33.5012 22.2353 28.2353 22.2353C25.6376 22.2353 23.2235 21.1765 21.4588 19.3835C19.2706 22.1929 15.84 24 12 24C5.36471 24 0 18.6353 0 12C0 5.36471 5.36471 0 12 0C14.2729 0 16.3976 0.635295 18.2118 1.72941C19.7153 2.64706 21.0141 3.88235 22.0094 5.37177V5.36471ZM3.52941 8.47059C3.07059 9.55765 2.82353 10.7506 2.82353 12C2.82353 17.0682 6.93177 21.1765 12 21.1765C17.0682 21.1765 21.1765 17.0682 21.1765 12C21.1765 10.7506 20.9294 9.55765 20.4706 8.47059H14.1176C13.7435 8.47059 13.3835 8.32941 13.1294 8.04706L12 6.94588L10.8706 8.06118C10.6165 8.34353 10.2565 8.48471 9.88235 8.48471H3.52941V8.47059ZM18.6212 5.64706C16.9271 3.88235 14.5553 2.82353 12 2.82353C9.44471 2.82353 7.07294 3.88235 5.37882 5.64706H9.29647L11.0047 3.95294C11.5271 3.38824 12.4165 3.38824 12.9812 3.95294L14.6753 5.64706H18.5859H18.6212Z" fill="#E1DBF2"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M25.1292 14.3435C24.6633 14.3435 24.2821 13.9623 24.2821 13.4964C24.2821 13.0164 24.6633 12.6353 25.1292 12.6353C25.5951 12.6353 25.9762 13.0164 25.9762 13.4823C25.9762 13.9623 25.5951 14.3435 25.1292 14.3435ZM31.3409 14.3435C30.8751 14.3435 30.4939 13.9623 30.4939 13.4964C30.4939 13.0164 30.8751 12.6353 31.3409 12.6353C31.8068 12.6353 32.188 13.0164 32.188 13.4823C32.188 13.9623 31.8068 14.3435 31.3409 14.3435ZM9.17624 15.5294H14.8233C14.8233 17.0823 13.5527 18.3529 11.9998 18.3529C10.4468 18.3529 9.17624 17.0823 9.17624 15.5294ZM8.11742 14.8235C7.53153 14.8235 7.05859 14.3505 7.05859 13.7647C7.05859 13.1788 7.53153 12.7058 8.11742 12.7058C8.7033 12.7058 9.17624 13.1788 9.17624 13.7647C9.17624 14.3505 8.7033 14.8235 8.11742 14.8235ZM15.8821 14.8235C15.2962 14.8235 14.8233 14.3505 14.8233 13.7647C14.8233 13.1788 15.2962 12.7058 15.8821 12.7058C16.468 12.7058 16.9409 13.1788 16.9409 13.7647C16.9409 14.3505 16.468 14.8235 15.8821 14.8235Z" fill="#6B4FBB"/>
+</svg>
diff --git a/app/assets/javascripts/access_tokens/components/projects_field.vue b/app/assets/javascripts/access_tokens/components/projects_field.vue
new file mode 100644
index 00000000000..066cea5e90c
--- /dev/null
+++ b/app/assets/javascripts/access_tokens/components/projects_field.vue
@@ -0,0 +1,69 @@
+<script>
+import { GlFormGroup, GlFormRadio, GlFormText } from '@gitlab/ui';
+import ProjectsTokenSelector from './projects_token_selector.vue';
+
+export default {
+ name: 'ProjectsField',
+ ALL_PROJECTS: 'ALL_PROJECTS',
+ SELECTED_PROJECTS: 'SELECTED_PROJECTS',
+ components: { GlFormGroup, GlFormRadio, GlFormText, ProjectsTokenSelector },
+ props: {
+ inputAttrs: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ selectedRadio: !this.inputAttrs.value
+ ? this.$options.ALL_PROJECTS
+ : this.$options.SELECTED_PROJECTS,
+ selectedProjects: [],
+ };
+ },
+ computed: {
+ allProjectsRadioSelected() {
+ return this.selectedRadio === this.$options.ALL_PROJECTS;
+ },
+ hiddenInputValue() {
+ return this.allProjectsRadioSelected
+ ? null
+ : this.selectedProjects.map((project) => project.id).join(',');
+ },
+ initialProjectIds() {
+ if (!this.inputAttrs.value) {
+ return [];
+ }
+
+ return this.inputAttrs.value.split(',');
+ },
+ },
+ methods: {
+ handleTokenSelectorFocus() {
+ this.selectedRadio = this.$options.SELECTED_PROJECTS;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-form-group :label="__('Projects')" label-class="gl-pb-0!">
+ <gl-form-text class="gl-pb-3">{{
+ __('Set access permissions for this token.')
+ }}</gl-form-text>
+ <gl-form-radio v-model="selectedRadio" :value="$options.ALL_PROJECTS">{{
+ __('All projects')
+ }}</gl-form-radio>
+ <gl-form-radio v-model="selectedRadio" :value="$options.SELECTED_PROJECTS">{{
+ __('Selected projects')
+ }}</gl-form-radio>
+ <input :id="inputAttrs.id" type="hidden" :name="inputAttrs.name" :value="hiddenInputValue" />
+ <projects-token-selector
+ v-model="selectedProjects"
+ :initial-project-ids="initialProjectIds"
+ @focus="handleTokenSelectorFocus"
+ />
+ </gl-form-group>
+ </div>
+</template>
diff --git a/app/assets/javascripts/access_tokens/components/projects_token_selector.vue b/app/assets/javascripts/access_tokens/components/projects_token_selector.vue
new file mode 100644
index 00000000000..a746f62b3a1
--- /dev/null
+++ b/app/assets/javascripts/access_tokens/components/projects_token_selector.vue
@@ -0,0 +1,156 @@
+<script>
+import {
+ GlTokenSelector,
+ GlAvatar,
+ GlAvatarLabeled,
+ GlIntersectionObserver,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import produce from 'immer';
+
+import { convertToGraphQLIds, convertNodeIdsFromGraphQLIds } from '~/graphql_shared/utils';
+
+import getProjectsQuery from '../graphql/queries/get_projects.query.graphql';
+
+const DEBOUNCE_DELAY = 250;
+const PROJECTS_PER_PAGE = 20;
+const GRAPHQL_ENTITY_TYPE = 'Project';
+
+export default {
+ name: 'ProjectsTokenSelector',
+ components: {
+ GlTokenSelector,
+ GlAvatar,
+ GlAvatarLabeled,
+ GlIntersectionObserver,
+ GlLoadingIcon,
+ },
+ model: {
+ prop: 'selectedProjects',
+ },
+ props: {
+ selectedProjects: {
+ type: Array,
+ required: true,
+ },
+ initialProjectIds: {
+ type: Array,
+ required: true,
+ },
+ },
+ apollo: {
+ projects: {
+ query: getProjectsQuery,
+ debounce: DEBOUNCE_DELAY,
+ variables() {
+ return {
+ search: this.searchQuery,
+ after: null,
+ first: PROJECTS_PER_PAGE,
+ };
+ },
+ update({ projects }) {
+ return {
+ list: convertNodeIdsFromGraphQLIds(projects.nodes),
+ pageInfo: projects.pageInfo,
+ };
+ },
+ result() {
+ this.isLoadingMoreProjects = false;
+ this.isSearching = false;
+ },
+ },
+ initialProjects: {
+ query: getProjectsQuery,
+ variables() {
+ return {
+ ids: convertToGraphQLIds(GRAPHQL_ENTITY_TYPE, this.initialProjectIds),
+ };
+ },
+ manual: true,
+ skip() {
+ return !this.initialProjectIds.length;
+ },
+ result({ data: { projects } }) {
+ this.$emit('input', convertNodeIdsFromGraphQLIds(projects.nodes));
+ },
+ },
+ },
+ data() {
+ return {
+ projects: {
+ list: [],
+ pageInfo: {},
+ },
+ searchQuery: '',
+ isLoadingMoreProjects: false,
+ isSearching: false,
+ };
+ },
+ methods: {
+ handleSearch(query) {
+ this.isSearching = true;
+ this.searchQuery = query;
+ },
+ loadMoreProjects() {
+ this.isLoadingMoreProjects = true;
+
+ this.$apollo.queries.projects.fetchMore({
+ variables: {
+ after: this.projects.pageInfo.endCursor,
+ first: PROJECTS_PER_PAGE,
+ },
+ updateQuery(previousResult, { fetchMoreResult: { projects: newProjects } }) {
+ const { projects: previousProjects } = previousResult;
+
+ return produce(previousResult, (draftData) => {
+ draftData.projects.nodes = [...previousProjects.nodes, ...newProjects.nodes];
+ draftData.projects.pageInfo = newProjects.pageInfo;
+ });
+ },
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-relative">
+ <gl-token-selector
+ :selected-tokens="selectedProjects"
+ :dropdown-items="projects.list"
+ :loading="isSearching"
+ :placeholder="__('Select projects')"
+ menu-class="gl-w-full! gl-max-w-full!"
+ @input="$emit('input', $event)"
+ @focus="$emit('focus', $event)"
+ @text-input="handleSearch"
+ @keydown.enter.prevent
+ >
+ <template #token-content="{ token: project }">
+ <gl-avatar
+ :entity-id="project.id"
+ :entity-name="project.name"
+ :src="project.avatarUrl"
+ :size="16"
+ />
+ {{ project.nameWithNamespace }}
+ </template>
+ <template #dropdown-item-content="{ dropdownItem: project }">
+ <gl-avatar-labeled
+ :entity-id="project.id"
+ :entity-name="project.name"
+ :size="32"
+ :src="project.avatarUrl"
+ :label="project.name"
+ :sub-label="project.nameWithNamespace"
+ />
+ </template>
+ <template #dropdown-footer>
+ <gl-intersection-observer v-if="projects.pageInfo.hasNextPage" @appear="loadMoreProjects">
+ <gl-loading-icon v-if="isLoadingMoreProjects" size="md" />
+ </gl-intersection-observer>
+ </template>
+ </gl-token-selector>
+ </div>
+</template>
diff --git a/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql
new file mode 100644
index 00000000000..60110437ecd
--- /dev/null
+++ b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql
@@ -0,0 +1,28 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+
+query getProjects(
+ $search: String = ""
+ $after: String = ""
+ $first: Int = null
+ $ids: [ID!] = null
+) {
+ projects(
+ search: $search
+ after: $after
+ first: $first
+ ids: $ids
+ membership: true
+ searchNamespaces: true
+ sort: "UPDATED_ASC"
+ ) {
+ nodes {
+ id
+ name
+ nameWithNamespace
+ avatarUrl
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+}
diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js
index b4353af30d5..43d56295f78 100644
--- a/app/assets/javascripts/access_tokens/index.js
+++ b/app/assets/javascripts/access_tokens/index.js
@@ -1,4 +1,7 @@
import Vue from 'vue';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+
import ExpiresAtField from './components/expires_at_field.vue';
const getInputAttrs = (el) => {
@@ -7,11 +10,12 @@ const getInputAttrs = (el) => {
return {
id: input.id,
name: input.name,
+ value: input.value,
placeholder: input.placeholder,
};
};
-const initExpiresAtField = () => {
+export const initExpiresAtField = () => {
const el = document.querySelector('.js-access-tokens-expires-at');
if (!el) {
@@ -32,4 +36,58 @@ const initExpiresAtField = () => {
});
};
-export default initExpiresAtField;
+export const initProjectsField = () => {
+ const el = document.querySelector('.js-access-tokens-projects');
+
+ if (!el) {
+ return null;
+ }
+
+ const inputAttrs = getInputAttrs(el);
+
+ if (window.gon.features.personalAccessTokensScopedToProjects) {
+ return new Promise((resolve) => {
+ Promise.all([
+ import('./components/projects_field.vue'),
+ import('vue-apollo'),
+ import('~/lib/graphql'),
+ ])
+ .then(
+ ([
+ { default: ProjectsField },
+ { default: VueApollo },
+ { default: createDefaultClient },
+ ]) => {
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ Vue.use(VueApollo);
+
+ resolve(
+ new Vue({
+ el,
+ apolloProvider,
+ render(h) {
+ return h(ProjectsField, {
+ props: {
+ inputAttrs,
+ },
+ });
+ },
+ }),
+ );
+ },
+ )
+ .catch(() => {
+ createFlash({
+ message: __(
+ 'An error occurred while loading the access tokens form, please try again.',
+ ),
+ });
+ });
+ });
+ }
+
+ return null;
+};
diff --git a/app/assets/javascripts/admin/dev_ops_report/devops_adoption.js b/app/assets/javascripts/admin/dev_ops_report/devops_adoption.js
deleted file mode 100644
index ae73033079d..00000000000
--- a/app/assets/javascripts/admin/dev_ops_report/devops_adoption.js
+++ /dev/null
@@ -1,2 +0,0 @@
-// EE-specific feature. Find the implementation in the `ee/`-folder
-export default () => {};
diff --git a/app/assets/javascripts/admin/users/tabs.js b/app/assets/javascripts/admin/users/tabs.js
index 9ada77396c7..cbaab7df4e9 100644
--- a/app/assets/javascripts/admin/users/tabs.js
+++ b/app/assets/javascripts/admin/users/tabs.js
@@ -1,11 +1,20 @@
+import Api from '~/api';
import { historyPushState } from '~/lib/utils/common_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
const COHORTS_PANE = 'cohorts';
+const COHORTS_PANE_TAB_CLICK_EVENT = 'i_analytics_cohorts';
const tabClickHandler = (e) => {
const { hash } = e.currentTarget;
- const tab = hash === `#${COHORTS_PANE}` ? COHORTS_PANE : null;
+
+ let tab = null;
+
+ if (hash === `#${COHORTS_PANE}`) {
+ tab = COHORTS_PANE;
+ Api.trackRedisHllUserEvent(COHORTS_PANE_TAB_CLICK_EVENT);
+ }
+
const newUrl = mergeUrlParams({ tab }, window.location.href);
historyPushState(newUrl);
};
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 dd702c4a5d3..79a6bac3ba7 100644
--- a/app/assets/javascripts/alert_management/components/alert_management_table.vue
+++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue
@@ -42,6 +42,7 @@ export default {
"AlertManagement|There was an error displaying the alerts. Confirm your endpoint's configuration details to ensure alerts appear.",
),
unassigned: __('Unassigned'),
+ closed: __('closed'),
},
fields: [
{
@@ -75,7 +76,7 @@ export default {
{
key: 'issue',
label: s__('AlertManagement|Incident'),
- thClass: 'gl-w-12 gl-pointer-events-none',
+ thClass: 'gl-w-15p gl-pointer-events-none',
tdClass,
},
{
@@ -221,8 +222,11 @@ export default {
hasAssignees(assignees) {
return Boolean(assignees.nodes?.length);
},
- getIssueLink(item) {
- return joinPaths('/', this.projectPath, '-', 'issues', item.issueIid);
+ getIssueMeta({ issue: { iid, state } }) {
+ return {
+ state: state === 'closed' ? `(${this.$options.i18n.closed})` : '',
+ link: joinPaths('/', this.projectPath, '-', 'issues/incident', iid),
+ };
},
tbodyTrClass(item) {
return {
@@ -343,8 +347,14 @@ export default {
</template>
<template #cell(issue)="{ item }">
- <gl-link v-if="item.issueIid" data-testid="issueField" :href="getIssueLink(item)">
- #{{ item.issueIid }}
+ <gl-link
+ v-if="item.issue"
+ v-gl-tooltip
+ :title="item.issue.title"
+ data-testid="issueField"
+ :href="getIssueMeta(item).link"
+ >
+ #{{ item.issue.iid }} {{ getIssueMeta(item).state }}
</gl-link>
<div v-else data-testid="issueField">{{ s__('AlertManagement|None') }}</div>
</template>
diff --git a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
index 1135562834a..07b2e59671e 100644
--- a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
+++ b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
@@ -7,15 +7,12 @@ import {
GlSearchBoxByType,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
-import { cloneDeep } from 'lodash';
+import { cloneDeep, isEqual } from 'lodash';
import Vue from 'vue';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { s__, __ } from '~/locale';
-import {
- getMappingData,
- getPayloadFields,
- transformForSave,
-} from '../utils/mapping_transformations';
+import { mappingFields } from '../constants';
+import { getMappingData, transformForSave } from '../utils/mapping_transformations';
export const i18n = {
columns: {
@@ -33,6 +30,7 @@ export const i18n = {
export default {
i18n,
+ mappingFields,
components: {
GlIcon,
GlFormInput,
@@ -73,18 +71,15 @@ export default {
};
},
computed: {
- payloadFields() {
- return getPayloadFields(this.parsedPayload);
- },
mappingData() {
- return getMappingData(this.gitlabFields, this.payloadFields, this.savedMapping);
+ return getMappingData(this.gitlabFields, this.parsedPayload, this.savedMapping);
},
hasFallbackColumn() {
return this.gitlabFields.some(({ numberOfFallbacks }) => Boolean(numberOfFallbacks));
},
},
methods: {
- setMapping(gitlabKey, mappingKey, valueKey) {
+ setMapping(gitlabKey, mappingKey, valueKey = mappingFields.mapping) {
const fieldIndex = this.gitlabFields.findIndex((field) => field.name === gitlabKey);
const updatedField = { ...this.gitlabFields[fieldIndex], ...{ [valueKey]: mappingKey } };
Vue.set(this.gitlabFields, fieldIndex, updatedField);
@@ -100,11 +95,11 @@ export default {
return fields.filter((field) => field.label.toLowerCase().includes(search));
},
isSelected(fieldValue, mapping) {
- return fieldValue === mapping;
+ return isEqual(fieldValue, mapping);
},
- selectedValue(name) {
+ selectedValue(mapping) {
return (
- this.payloadFields.find((item) => item.name === name)?.label ||
+ this.parsedPayload.find((item) => isEqual(item.path, mapping))?.label ||
this.$options.i18n.makeSelection
);
},
@@ -150,7 +145,7 @@ export default {
:key="gitlabField.name"
class="gl-display-table-row"
>
- <div class="gl-display-table-cell gl-py-3 gl-pr-3 w-30p gl-vertical-align-middle">
+ <div class="gl-display-table-cell gl-py-3 gl-pr-3 gl-w-30p gl-vertical-align-middle">
<gl-form-input
aria-labelledby="gitlabFieldsHeader"
disabled
@@ -164,7 +159,7 @@ export default {
</div>
</div>
- <div class="gl-display-table-cell gl-py-3 gl-pr-3 w-30p gl-vertical-align-middle">
+ <div class="gl-display-table-cell gl-py-3 gl-pr-3 gl-w-30p gl-vertical-align-middle">
<gl-dropdown
:disabled="!gitlabField.mappingFields.length"
aria-labelledby="parsedFieldsHeader"
@@ -175,10 +170,10 @@ export default {
<gl-search-box-by-type @input="setSearchTerm($event, 'searchTerm', gitlabField.name)" />
<gl-dropdown-item
v-for="mappingField in filterFields(gitlabField.searchTerm, gitlabField.mappingFields)"
- :key="`${mappingField.name}__mapping`"
- :is-checked="isSelected(gitlabField.mapping, mappingField.name)"
+ :key="`${mappingField.path}__mapping`"
+ :is-checked="isSelected(gitlabField.mapping, mappingField.path)"
is-check-item
- @click="setMapping(gitlabField.name, mappingField.name, 'mapping')"
+ @click="setMapping(gitlabField.name, mappingField.path)"
>
{{ mappingField.label }}
</gl-dropdown-item>
@@ -188,7 +183,7 @@ export default {
</gl-dropdown>
</div>
- <div class="gl-display-table-cell gl-py-3 w-30p">
+ <div class="gl-display-table-cell gl-py-3 gl-w-30p">
<gl-dropdown
v-if="Boolean(gitlabField.numberOfFallbacks)"
:disabled="!gitlabField.mappingFields.length"
@@ -205,10 +200,12 @@ export default {
gitlabField.fallbackSearchTerm,
gitlabField.mappingFields,
)"
- :key="`${mappingField.name}__fallback`"
- :is-checked="isSelected(gitlabField.fallback, mappingField.name)"
+ :key="`${mappingField.path}__fallback`"
+ :is-checked="isSelected(gitlabField.fallback, mappingField.path)"
is-check-item
- @click="setMapping(gitlabField.name, mappingField.name, 'fallback')"
+ @click="
+ setMapping(gitlabField.name, mappingField.path, $options.mappingFields.fallback)
+ "
>
{{ mappingField.label }}
</gl-dropdown-item>
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
index 6cfb4601192..a5e17d80f86 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
@@ -10,6 +10,7 @@ import {
GlTooltipDirective,
GlSprintf,
} from '@gitlab/ui';
+import { capitalize } from 'lodash';
import { s__, __ } from '~/locale';
import Tracking from '~/tracking';
import {
@@ -77,6 +78,7 @@ export default {
{
key: 'type',
label: __('Type'),
+ formatter: (value) => (value === typeSet.prometheus ? capitalize(value) : value),
},
{
key: 'actions',
@@ -120,14 +122,17 @@ export default {
const { category, action } = trackAlertIntegrationsViewsOptions;
Tracking.event(category, action);
},
- setIntegrationToDelete({ name, id }) {
- this.integrationToDelete.id = id;
- this.integrationToDelete.name = name;
+ setIntegrationToDelete(integration) {
+ this.integrationToDelete = integration;
},
deleteIntegration() {
- this.$emit('delete-integration', { id: this.integrationToDelete.id });
+ const { id, type } = this.integrationToDelete;
+ this.$emit('delete-integration', { id, type });
this.integrationToDelete = { ...integrationToDeleteDefault };
},
+ editIntegration({ id, type }) {
+ this.$emit('edit-integration', { id, type });
+ },
},
};
</script>
@@ -169,7 +174,7 @@ export default {
<template #cell(actions)="{ item }">
<gl-button-group class="gl-ml-3">
- <gl-button icon="pencil" @click="$emit('edit-integration', { id: item.id })" />
+ <gl-button icon="settings" @click="editIntegration(item)" />
<gl-button
v-gl-modal.deleteIntegration
:disabled="item.type === $options.typeSet.prometheus"
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
index 18372c54b84..5d9513e5b53 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
@@ -1,7 +1,6 @@
<script>
import {
GlButton,
- GlCollapse,
GlForm,
GlFormGroup,
GlFormSelect,
@@ -11,98 +10,39 @@ import {
GlModal,
GlModalDirective,
GlToggle,
+ GlTabs,
+ GlTab,
} from '@gitlab/ui';
-import { s__ } from '~/locale';
+import * as Sentry from '@sentry/browser';
+import { isEmpty, omit } from 'lodash';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
integrationTypes,
+ integrationSteps,
+ createStepNumbers,
+ editStepNumbers,
JSON_VALIDATE_DELAY,
targetPrometheusUrlPlaceholder,
typeSet,
+ viewCredentialsTabIndex,
+ i18n,
} from '../constants';
import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql';
+import parseSamplePayloadQuery from '../graphql/queries/parse_sample_payload.query.graphql';
import MappingBuilder from './alert_mapping_builder.vue';
import AlertSettingsFormHelpBlock from './alert_settings_form_help_block.vue';
-// Mocks will be removed when integrating with BE is ready
-// data format is defined and will be the same as mocked (maybe with some minor changes)
-// feature rollout plan - https://gitlab.com/gitlab-org/gitlab/-/issues/262707#note_442529171
-import mockedCustomMapping from './mocks/parsedMapping.json';
-
-export const i18n = {
- integrationFormSteps: {
- step1: {
- label: s__('AlertSettings|1. Select integration type'),
- enterprise: s__(
- 'AlertSettings|In free versions of GitLab, only one integration for each type can be added. %{linkStart}Upgrade your subscription%{linkEnd} to add additional integrations.',
- ),
- },
- step2: {
- label: s__('AlertSettings|2. Name integration'),
- placeholder: s__('AlertSettings|Enter integration name'),
- prometheus: s__('AlertSettings|Prometheus'),
- },
- step3: {
- label: s__('AlertSettings|3. Set up webhook'),
- help: s__(
- "AlertSettings|Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.",
- ),
- prometheusHelp: s__(
- 'AlertSettings|Utilize the URL and authorization key below to authorize Prometheus to send alerts to GitLab. Review the Prometheus documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.',
- ),
- info: s__('AlertSettings|Authorization key'),
- reset: s__('AlertSettings|Reset Key'),
- },
- step4: {
- label: s__('AlertSettings|4. Sample alert payload (optional)'),
- help: s__(
- 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to create a custom mapping (optional), or to test the integration (also optional).',
- ),
- prometheusHelp: s__(
- 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to test the integration (optional).',
- ),
- placeholder: s__('AlertSettings|{ "events": [{ "application": "Name of application" }] }'),
- resetHeader: s__('AlertSettings|Reset the mapping'),
- resetBody: s__(
- "AlertSettings|If you edit the payload, the stored mapping will be reset, and you'll need to re-map the fields.",
- ),
- resetOk: s__('AlertSettings|Proceed with editing'),
- editPayload: s__('AlertSettings|Edit payload'),
- submitPayload: s__('AlertSettings|Submit payload'),
- payloadParsedSucessMsg: s__(
- 'AlertSettings|Sample payload has been parsed. You can now map the fields.',
- ),
- },
- step5: {
- label: s__('AlertSettings|5. Map fields (optional)'),
- intro: s__(
- "AlertSettings|If you've provided a sample alert payload, you can create a custom mapping for your endpoint. The default GitLab alert keys are listed below. Please define which payload key should map to the specified GitLab key.",
- ),
- },
- prometheusFormUrl: {
- label: s__('AlertSettings|Prometheus API base URL'),
- help: s__('AlertSettings|URL cannot be blank and must start with http or https'),
- },
- restKeyInfo: {
- label: s__(
- 'AlertSettings|Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.',
- ),
- },
- },
-};
export default {
- integrationTypes,
placeholders: {
prometheus: targetPrometheusUrlPlaceholder,
},
JSON_VALIDATE_DELAY,
typeSet,
+ integrationSteps,
i18n,
components: {
ClipboardButton,
GlButton,
- GlCollapse,
GlForm,
GlFormGroup,
GlFormInput,
@@ -111,13 +51,14 @@ export default {
GlFormSelect,
GlModal,
GlToggle,
+ GlTabs,
+ GlTab,
AlertSettingsFormHelpBlock,
MappingBuilder,
},
directives: {
GlModal: GlModalDirective,
},
- mixins: [glFeatureFlagsMixin()],
inject: {
generic: {
default: {},
@@ -128,6 +69,9 @@ export default {
multiIntegrations: {
default: false,
},
+ projectPath: {
+ default: '',
+ },
},
props: {
loading: {
@@ -151,26 +95,40 @@ export default {
},
data() {
return {
- selectedIntegration: integrationTypes[0].value,
+ integrationTypesOptions: Object.values(integrationTypes),
+ selectedIntegration: integrationTypes.none.value,
active: false,
- formVisible: false,
- integrationTestPayload: {
+ samplePayload: {
json: null,
error: null,
},
- resetSamplePayloadConfirmed: false,
- customMapping: null,
+ testPayload: {
+ json: null,
+ error: null,
+ },
+ resetPayloadAndMappingConfirmed: false,
mapping: [],
parsingPayload: false,
currentIntegration: null,
+ parsedPayload: [],
+ activeTabIndex: 0,
};
},
computed: {
isPrometheus() {
return this.selectedIntegration === this.$options.typeSet.prometheus;
},
- jsonIsValid() {
- return this.integrationTestPayload.error === null;
+ isHttp() {
+ return this.selectedIntegration === this.$options.typeSet.http;
+ },
+ isCreating() {
+ return !this.currentIntegration;
+ },
+ isSampePayloadValid() {
+ return this.samplePayload.error === null;
+ },
+ isTestPayloadValid() {
+ return this.testPayload.error === null;
},
selectedIntegrationType() {
switch (this.selectedIntegration) {
@@ -197,90 +155,88 @@ export default {
},
testAlertPayload() {
return {
- data: this.integrationTestPayload.json,
+ data: this.testPayload.json,
endpoint: this.integrationForm.url,
token: this.integrationForm.token,
};
},
showMappingBuilder() {
- return (
- this.multiIntegrations &&
- this.glFeatures.multipleHttpIntegrationsCustomMapping &&
- this.selectedIntegration === typeSet.http &&
- this.alertFields?.length
- );
- },
- parsedSamplePayload() {
- return this.customMapping?.samplePayload?.payloadAlerFields?.nodes;
- },
- savedMapping() {
- return this.customMapping?.storedMapping?.nodes;
+ return this.multiIntegrations && this.isHttp && this.alertFields?.length;
},
hasSamplePayload() {
- return Boolean(this.customMapping?.samplePayload);
+ return this.isValidNonEmptyJSON(this.currentIntegration?.payloadExample);
},
canEditPayload() {
- return this.hasSamplePayload && !this.resetSamplePayloadConfirmed;
+ return this.hasSamplePayload && !this.resetPayloadAndMappingConfirmed;
+ },
+ canParseSamplePayload() {
+ return !this.active || !this.isSampePayloadValid || !this.samplePayload.json;
},
isResetAuthKeyDisabled() {
return !this.active && !this.integrationForm.token !== '';
},
isPayloadEditDisabled() {
- return this.glFeatures.multipleHttpIntegrationsCustomMapping
- ? !this.active || this.canEditPayload
- : !this.active;
- },
- isSubmitTestPayloadDisabled() {
- return (
- !this.active ||
- Boolean(this.integrationTestPayload.error) ||
- this.integrationTestPayload.json === ''
- );
+ return !this.active || this.canEditPayload;
},
isSelectDisabled() {
return this.currentIntegration !== null || !this.canAddIntegration;
},
+ viewCredentialsHelpMsg() {
+ return this.isPrometheus
+ ? i18n.integrationFormSteps.setupCredentials.prometheusHelp
+ : i18n.integrationFormSteps.setupCredentials.help;
+ },
},
watch: {
currentIntegration(val) {
if (val === null) {
- return this.reset();
+ this.reset();
+ return;
+ }
+ const { type, active, payloadExample, payloadAlertFields, payloadAttributeMappings } = val;
+ this.selectedIntegration = type;
+ this.active = active;
+
+ if (type === typeSet.http && this.showMappingBuilder) {
+ this.parsedPayload = payloadAlertFields;
+ this.samplePayload.json = this.isValidNonEmptyJSON(payloadExample) ? payloadExample : null;
+ const mapping = payloadAttributeMappings.map((mappingItem) =>
+ omit(mappingItem, '__typename'),
+ );
+ this.updateMapping(mapping);
}
- this.selectedIntegration = val.type;
- this.active = val.active;
- if (val.type === typeSet.http && this.showMappingBuilder) this.getIntegrationMapping(val.id);
- return this.integrationTypeSelect();
+ this.activeTabIndex = viewCredentialsTabIndex;
+ this.$el.scrollIntoView({ block: 'center' });
},
},
methods: {
- integrationTypeSelect() {
- if (this.selectedIntegration === integrationTypes[0].value) {
- this.formVisible = false;
- } else {
- this.formVisible = true;
+ isValidNonEmptyJSON(JSONString) {
+ if (JSONString) {
+ let parsed;
+ try {
+ parsed = JSON.parse(JSONString);
+ } catch (error) {
+ Sentry.captureException(error);
+ }
+ if (parsed) return !isEmpty(parsed);
}
+ return false;
},
- submitWithTestPayload() {
- this.$emit('set-test-alert-payload', this.testAlertPayload);
- this.submit();
+ sendTestAlert() {
+ this.$emit('test-alert-payload', this.testAlertPayload);
},
submit() {
const { name, apiUrl } = this.integrationForm;
- const customMappingVariables = this.glFeatures.multipleHttpIntegrationsCustomMapping
- ? {
- payloadAttributeMappings: this.mapping,
- payloadExample: this.integrationTestPayload.json,
- }
- : {};
+ const customMappingVariables = {
+ payloadAttributeMappings: this.mapping,
+ payloadExample: this.samplePayload.json || '{}',
+ };
const variables =
this.selectedIntegration === typeSet.http
- ? {
- name,
- active: this.active,
- ...customMappingVariables,
- }
+ ? { name, active: this.active, ...customMappingVariables }
: { apiUrl, active: this.active };
+
const integrationPayload = { type: this.selectedIntegration, variables };
if (this.currentIntegration) {
@@ -291,19 +247,15 @@ export default {
return this.$emit('create-new-integration', integrationPayload);
},
reset() {
- this.selectedIntegration = integrationTypes[0].value;
- this.integrationTypeSelect();
-
- if (this.currentIntegration) {
- return this.$emit('clear-current-integration');
- }
-
- return this.resetFormValues();
+ this.resetFormValues();
+ this.resetPayloadAndMapping();
+ this.$emit('clear-current-integration', { type: this.currentIntegration?.type });
},
resetFormValues() {
+ this.selectedIntegration = integrationTypes.none.value;
this.integrationForm.name = '';
this.integrationForm.apiUrl = '';
- this.integrationTestPayload = {
+ this.samplePayload = {
json: null,
error: null,
};
@@ -319,117 +271,135 @@ export default {
variables: { id: this.currentIntegration.id },
});
},
- validateJson() {
- this.integrationTestPayload.error = null;
- if (this.integrationTestPayload.json === '') {
+ validateJson(isSamplePayload = true) {
+ const payload = isSamplePayload ? this.samplePayload : this.testPayload;
+
+ payload.error = null;
+ if (payload.json === '') {
return;
}
try {
- JSON.parse(this.integrationTestPayload.json);
+ JSON.parse(payload.json);
} catch (e) {
- this.integrationTestPayload.error = JSON.stringify(e.message);
+ payload.error = JSON.stringify(e.message);
}
},
parseMapping() {
- // TODO: replace with real BE mutation when ready;
this.parsingPayload = true;
- return new Promise((resolve) => {
- setTimeout(() => resolve(mockedCustomMapping), 1000);
- })
- .then((res) => {
- const mapping = { ...res };
- delete mapping.storedMapping;
- this.customMapping = res;
- this.integrationTestPayload.json = res?.samplePayload.body;
- this.resetSamplePayloadConfirmed = false;
-
- this.$toast.show(this.$options.i18n.integrationFormSteps.step4.payloadParsedSucessMsg);
+ return this.$apollo
+ .query({
+ query: parseSamplePayloadQuery,
+ variables: { projectPath: this.projectPath, payload: this.samplePayload.json },
+ })
+ .then(
+ ({
+ data: {
+ project: { alertManagementPayloadFields },
+ },
+ }) => {
+ this.parsedPayload = alertManagementPayloadFields;
+ this.resetPayloadAndMappingConfirmed = false;
+
+ this.$toast.show(
+ this.$options.i18n.integrationFormSteps.setSamplePayload.payloadParsedSucessMsg,
+ );
+ },
+ )
+ .catch(({ message }) => {
+ this.samplePayload.error = message;
})
.finally(() => {
this.parsingPayload = false;
});
},
- getIntegrationMapping() {
- // TODO: replace with real BE mutation when ready;
- return Promise.resolve(mockedCustomMapping).then((res) => {
- this.customMapping = res;
- this.integrationTestPayload.json = res?.samplePayload.body;
- });
- },
updateMapping(mapping) {
this.mapping = mapping;
},
+ resetPayloadAndMapping() {
+ this.resetPayloadAndMappingConfirmed = true;
+ this.parsedPayload = [];
+ this.updateMapping([]);
+ },
+ getLabelWithStepNumber(step, label) {
+ let stepNumber = editStepNumbers[step];
+
+ if (this.isCreating) {
+ stepNumber = createStepNumbers[step];
+ }
+
+ return stepNumber ? `${stepNumber}.${label}` : label;
+ },
},
};
</script>
<template>
<gl-form class="gl-mt-6" @submit.prevent="submit" @reset.prevent="reset">
- <h5 class="gl-font-lg gl-my-5">{{ s__('AlertSettings|Add new integrations') }}</h5>
- <gl-form-group
- id="integration-type"
- :label="$options.i18n.integrationFormSteps.step1.label"
- label-for="integration-type"
- >
- <gl-form-select
- v-model="selectedIntegration"
- :disabled="isSelectDisabled"
- class="mw-100"
- :options="$options.integrationTypes"
- @change="integrationTypeSelect"
- />
-
- <div v-if="!canAddIntegration" class="gl-my-4" data-testid="multi-integrations-not-supported">
- <alert-settings-form-help-block
- :message="$options.i18n.integrationFormSteps.step1.enterprise"
- link="https://about.gitlab.com/pricing"
- />
- </div>
- </gl-form-group>
- <gl-collapse v-model="formVisible" class="gl-mt-3">
- <div>
+ <gl-tabs v-model="activeTabIndex">
+ <gl-tab :title="$options.i18n.integrationTabs.configureDetails">
<gl-form-group
- id="name-integration"
- :label="$options.i18n.integrationFormSteps.step2.label"
- label-for="name-integration"
+ v-if="isCreating"
+ id="integration-type"
+ :label="
+ getLabelWithStepNumber(
+ $options.integrationSteps.selectType,
+ $options.i18n.integrationFormSteps.selectType.label,
+ )
+ "
+ label-for="integration-type"
>
- <gl-form-input
- v-model="integrationForm.name"
- :disabled="isPrometheus"
- type="text"
- :placeholder="
- isPrometheus
- ? $options.i18n.integrationFormSteps.step2.prometheus
- : $options.i18n.integrationFormSteps.step2.placeholder
- "
+ <gl-form-select
+ v-model="selectedIntegration"
+ :disabled="isSelectDisabled"
+ class="gl-max-w-full"
+ :options="integrationTypesOptions"
/>
- </gl-form-group>
- <gl-form-group
- id="integration-webhook"
- :label="$options.i18n.integrationFormSteps.step3.label"
- label-for="integration-webhook"
- >
+
<alert-settings-form-help-block
- :message="
- isPrometheus
- ? $options.i18n.integrationFormSteps.step3.prometheusHelp
- : $options.i18n.integrationFormSteps.step3.help
- "
- link="https://docs.gitlab.com/ee/operations/incident_management/alert_integrations.html"
+ v-if="!canAddIntegration"
+ disabled="true"
+ class="gl-display-inline-block gl-my-4"
+ :message="$options.i18n.integrationFormSteps.selectType.enterprise"
+ link="https://about.gitlab.com/pricing"
+ data-testid="multi-integrations-not-supported"
/>
+ </gl-form-group>
+ <div class="gl-mt-3">
+ <gl-form-group
+ v-if="isHttp"
+ id="name-integration"
+ :label="
+ getLabelWithStepNumber(
+ $options.integrationSteps.nameIntegration,
+ $options.i18n.integrationFormSteps.nameIntegration.label,
+ )
+ "
+ label-for="name-integration"
+ >
+ <gl-form-input
+ v-model="integrationForm.name"
+ type="text"
+ :placeholder="$options.i18n.integrationFormSteps.nameIntegration.placeholder"
+ />
+ </gl-form-group>
<gl-toggle
v-model="active"
:is-loading="loading"
- :label="__('Active')"
+ :label="$options.i18n.integrationFormSteps.nameIntegration.activeToggle"
class="gl-my-4 gl-font-weight-normal"
/>
<div v-if="isPrometheus" class="gl-my-4">
<span class="gl-font-weight-bold">
- {{ $options.i18n.integrationFormSteps.prometheusFormUrl.label }}
+ {{
+ getLabelWithStepNumber(
+ $options.integrationSteps.setPrometheusApiUrl,
+ $options.i18n.integrationFormSteps.prometheusFormUrl.label,
+ )
+ }}
</span>
<gl-form-input
@@ -444,16 +414,123 @@ export default {
</span>
</div>
+ <template v-if="showMappingBuilder">
+ <gl-form-group
+ data-testid="sample-payload-section"
+ :label="
+ getLabelWithStepNumber(
+ $options.integrationSteps.setSamplePayload,
+ $options.i18n.integrationFormSteps.setSamplePayload.label,
+ )
+ "
+ label-for="sample-payload"
+ class="gl-mb-0!"
+ :invalid-feedback="samplePayload.error"
+ >
+ <alert-settings-form-help-block
+ :message="$options.i18n.integrationFormSteps.setSamplePayload.testPayloadHelpHttp"
+ :link="generic.alertsUsageUrl"
+ />
+
+ <gl-form-textarea
+ id="sample-payload"
+ v-model.trim="samplePayload.json"
+ :disabled="isPayloadEditDisabled"
+ :state="isSampePayloadValid"
+ :placeholder="$options.i18n.integrationFormSteps.setSamplePayload.placeholder"
+ class="gl-my-3"
+ :debounce="$options.JSON_VALIDATE_DELAY"
+ rows="6"
+ max-rows="10"
+ @input="validateJson"
+ />
+ </gl-form-group>
+
+ <gl-button
+ v-if="canEditPayload"
+ v-gl-modal.resetPayloadModal
+ data-testid="payload-action-btn"
+ :disabled="!active"
+ class="gl-mt-3"
+ >
+ {{ $options.i18n.integrationFormSteps.setSamplePayload.editPayload }}
+ </gl-button>
+
+ <gl-button
+ v-else
+ data-testid="payload-action-btn"
+ :class="{ 'gl-mt-3': samplePayload.error }"
+ :disabled="canParseSamplePayload"
+ :loading="parsingPayload"
+ @click="parseMapping"
+ >
+ {{ $options.i18n.integrationFormSteps.setSamplePayload.parsePayload }}
+ </gl-button>
+ <gl-modal
+ modal-id="resetPayloadModal"
+ :title="$options.i18n.integrationFormSteps.setSamplePayload.resetHeader"
+ :ok-title="$options.i18n.integrationFormSteps.setSamplePayload.resetOk"
+ ok-variant="danger"
+ @ok="resetPayloadAndMapping"
+ >
+ {{ $options.i18n.integrationFormSteps.setSamplePayload.resetBody }}
+ </gl-modal>
+
+ <gl-form-group
+ id="mapping-builder"
+ class="gl-mt-5"
+ :label="
+ getLabelWithStepNumber(
+ $options.integrationSteps.customizeMapping,
+ $options.i18n.integrationFormSteps.mapFields.label,
+ )
+ "
+ label-for="mapping-builder"
+ >
+ <span>{{ $options.i18n.integrationFormSteps.mapFields.intro }}</span>
+ <mapping-builder
+ :parsed-payload="parsedPayload"
+ :saved-mapping="mapping"
+ :alert-fields="alertFields"
+ @onMappingUpdate="updateMapping"
+ />
+ </gl-form-group>
+ </template>
+ </div>
+
+ <div class="gl-display-flex gl-justify-content-start gl-py-3">
+ <gl-button
+ type="submit"
+ variant="confirm"
+ class="js-no-auto-disable"
+ data-testid="integration-form-submit"
+ >
+ {{ $options.i18n.saveIntegration }}
+ </gl-button>
+
+ <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{
+ $options.i18n.cancelAndClose
+ }}</gl-button>
+ </div>
+ </gl-tab>
+
+ <gl-tab :title="$options.i18n.integrationTabs.viewCredentials" :disabled="isCreating">
+ <alert-settings-form-help-block
+ :message="viewCredentialsHelpMsg"
+ link="https://docs.gitlab.com/ee/operations/incident_management/alert_integrations.html"
+ />
+
+ <gl-form-group id="integration-webhook">
<div class="gl-my-4">
<span class="gl-font-weight-bold">
- {{ s__('AlertSettings|Webhook URL') }}
+ {{ $options.i18n.integrationFormSteps.setupCredentials.webhookUrl }}
</span>
<gl-form-input-group id="url" readonly :value="integrationForm.url">
<template #append>
<clipboard-button
:text="integrationForm.url || ''"
- :title="__('Copy')"
+ :title="$options.i18n.copy"
class="gl-m-0!"
/>
</template>
@@ -462,7 +539,7 @@ export default {
<div class="gl-my-4">
<span class="gl-font-weight-bold">
- {{ $options.i18n.integrationFormSteps.step3.info }}
+ {{ $options.i18n.integrationFormSteps.setupCredentials.authorizationKey }}
</span>
<gl-form-input-group
@@ -474,124 +551,67 @@ export default {
<template #append>
<clipboard-button
:text="integrationForm.token || ''"
- :title="__('Copy')"
+ :title="$options.i18n.copy"
class="gl-m-0!"
/>
</template>
</gl-form-input-group>
-
- <gl-button v-gl-modal.authKeyModal :disabled="isResetAuthKeyDisabled">
- {{ $options.i18n.integrationFormSteps.step3.reset }}
- </gl-button>
- <gl-modal
- modal-id="authKeyModal"
- :title="$options.i18n.integrationFormSteps.step3.reset"
- :ok-title="$options.i18n.integrationFormSteps.step3.reset"
- ok-variant="danger"
- @ok="resetAuthKey"
- >
- {{ $options.i18n.integrationFormSteps.restKeyInfo.label }}
- </gl-modal>
</div>
</gl-form-group>
- <gl-form-group
- id="test-integration"
- :label="$options.i18n.integrationFormSteps.step4.label"
- label-for="test-integration"
- :class="{ 'gl-mb-0!': showMappingBuilder }"
- :invalid-feedback="integrationTestPayload.error"
+ <gl-button v-gl-modal.authKeyModal :disabled="isResetAuthKeyDisabled" variant="danger">
+ {{ $options.i18n.integrationFormSteps.setupCredentials.reset }}
+ </gl-button>
+
+ <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{
+ $options.i18n.cancelAndClose
+ }}</gl-button>
+
+ <gl-modal
+ modal-id="authKeyModal"
+ :title="$options.i18n.integrationFormSteps.setupCredentials.reset"
+ :ok-title="$options.i18n.integrationFormSteps.setupCredentials.reset"
+ ok-variant="danger"
+ @ok="resetAuthKey"
>
+ {{ $options.i18n.integrationFormSteps.restKeyInfo.label }}
+ </gl-modal>
+ </gl-tab>
+
+ <gl-tab :title="$options.i18n.integrationTabs.sendTestAlert" :disabled="isCreating">
+ <gl-form-group id="test-integration" :invalid-feedback="testPayload.error">
<alert-settings-form-help-block
- :message="
- isPrometheus || !showMappingBuilder
- ? $options.i18n.integrationFormSteps.step4.prometheusHelp
- : $options.i18n.integrationFormSteps.step4.help
- "
+ :message="$options.i18n.integrationFormSteps.setSamplePayload.testPayloadHelp"
:link="generic.alertsUsageUrl"
/>
<gl-form-textarea
id="test-payload"
- v-model.trim="integrationTestPayload.json"
- :disabled="isPayloadEditDisabled"
- :state="jsonIsValid"
- :placeholder="$options.i18n.integrationFormSteps.step4.placeholder"
+ v-model.trim="testPayload.json"
+ :state="isTestPayloadValid"
+ :placeholder="$options.i18n.integrationFormSteps.setSamplePayload.placeholder"
class="gl-my-3"
:debounce="$options.JSON_VALIDATE_DELAY"
rows="6"
max-rows="10"
- @input="validateJson"
+ @input="validateJson(false)"
/>
</gl-form-group>
- <template v-if="showMappingBuilder">
- <gl-button
- v-if="canEditPayload"
- v-gl-modal.resetPayloadModal
- data-testid="payload-action-btn"
- :disabled="!active"
- class="gl-mt-3"
- >
- {{ $options.i18n.integrationFormSteps.step4.editPayload }}
- </gl-button>
-
- <gl-button
- v-else
- data-testid="payload-action-btn"
- :class="{ 'gl-mt-3': integrationTestPayload.error }"
- :disabled="!active"
- :loading="parsingPayload"
- @click="parseMapping"
- >
- {{ $options.i18n.integrationFormSteps.step4.submitPayload }}
- </gl-button>
- <gl-modal
- modal-id="resetPayloadModal"
- :title="$options.i18n.integrationFormSteps.step4.resetHeader"
- :ok-title="$options.i18n.integrationFormSteps.step4.resetOk"
- ok-variant="danger"
- @ok="resetSamplePayloadConfirmed = true"
- >
- {{ $options.i18n.integrationFormSteps.step4.resetBody }}
- </gl-modal>
- </template>
-
- <gl-form-group
- v-if="showMappingBuilder"
- id="mapping-builder"
- class="gl-mt-5"
- :label="$options.i18n.integrationFormSteps.step5.label"
- label-for="mapping-builder"
- >
- <span>{{ $options.i18n.integrationFormSteps.step5.intro }}</span>
- <mapping-builder
- :parsed-payload="parsedSamplePayload"
- :saved-mapping="savedMapping"
- :alert-fields="alertFields"
- @onMappingUpdate="updateMapping"
- />
- </gl-form-group>
- </div>
- <div class="gl-display-flex gl-justify-content-start gl-py-3">
<gl-button
- type="submit"
- variant="success"
+ :disabled="!isTestPayloadValid"
+ data-testid="send-test-alert"
+ variant="confirm"
class="js-no-auto-disable"
- data-testid="integration-form-submit"
- >{{ s__('AlertSettings|Save integration') }}
- </gl-button>
- <gl-button
- data-testid="integration-test-and-submit"
- :disabled="isSubmitTestPayloadDisabled"
- category="secondary"
- variant="success"
- class="gl-mx-3 js-no-auto-disable"
- @click="submitWithTestPayload"
- >{{ s__('AlertSettings|Save and test payload') }}</gl-button
+ @click="sendTestAlert"
>
- <gl-button type="reset" class="js-no-auto-disable">{{ __('Cancel') }}</gl-button>
- </div>
- </gl-collapse>
+ {{ $options.i18n.send }}
+ </gl-button>
+
+ <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{
+ $options.i18n.cancelAndClose
+ }}</gl-button>
+ </gl-tab>
+ </gl-tabs>
</gl-form>
</template>
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
index 366f2209fb2..3ffb652e61b 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
@@ -1,22 +1,26 @@
<script>
+import { GlButton } from '@gitlab/ui';
+import createHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql';
+import updateHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql';
import createFlash, { FLASH_TYPES } from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
import { s__ } from '~/locale';
import { typeSet } from '../constants';
-import createHttpIntegrationMutation from '../graphql/mutations/create_http_integration.mutation.graphql';
import createPrometheusIntegrationMutation from '../graphql/mutations/create_prometheus_integration.mutation.graphql';
import destroyHttpIntegrationMutation from '../graphql/mutations/destroy_http_integration.mutation.graphql';
import resetHttpTokenMutation from '../graphql/mutations/reset_http_token.mutation.graphql';
import resetPrometheusTokenMutation from '../graphql/mutations/reset_prometheus_token.mutation.graphql';
-import updateCurrentIntergrationMutation from '../graphql/mutations/update_current_intergration.mutation.graphql';
-import updateHttpIntegrationMutation from '../graphql/mutations/update_http_integration.mutation.graphql';
+import updateCurrentHttpIntegrationMutation from '../graphql/mutations/update_current_http_integration.mutation.graphql';
+import updateCurrentPrometheusIntegrationMutation from '../graphql/mutations/update_current_prometheus_integration.mutation.graphql';
import updatePrometheusIntegrationMutation from '../graphql/mutations/update_prometheus_integration.mutation.graphql';
import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql';
+import getHttpIntegrationsQuery from '../graphql/queries/get_http_integrations.query.graphql';
import getIntegrationsQuery from '../graphql/queries/get_integrations.query.graphql';
import service from '../services';
import {
updateStoreAfterIntegrationDelete,
updateStoreAfterIntegrationAdd,
+ updateStoreAfterHttpIntegrationAdd,
} from '../utils/cache_updates';
import {
DELETE_INTEGRATION_ERROR,
@@ -28,20 +32,24 @@ import {
import IntegrationsList from './alerts_integrations_list.vue';
import AlertSettingsForm from './alerts_settings_form.vue';
+export const i18n = {
+ changesSaved: s__(
+ 'AlertsIntegrations|The integration has been successfully saved. Alerts from this new integration should now appear on your alerts list.',
+ ),
+ integrationRemoved: s__('AlertsIntegrations|The integration has been successfully removed.'),
+ alertSent: s__(
+ 'AlertsIntegrations|The test alert has been successfully sent, and should now be visible on your alerts list.',
+ ),
+ addNewIntegration: s__('AlertSettings|Add new integration'),
+};
+
export default {
typeSet,
- i18n: {
- changesSaved: s__(
- 'AlertsIntegrations|The integration has been successfully saved. Alerts from this new integration should now appear on your alerts list.',
- ),
- integrationRemoved: s__('AlertsIntegrations|The integration has been successfully removed.'),
- alertSent: s__(
- 'AlertsIntegrations|The test alert has been successfully sent, and should now be visible on your alerts list.',
- ),
- },
+ i18n,
components: {
IntegrationsList,
AlertSettingsForm,
+ GlButton,
},
inject: {
generic: {
@@ -84,6 +92,28 @@ export default {
createFlash({ message: err });
},
},
+ // TODO: we'll need to update the logic to request specific http integration by its id on edit
+ // when BE adds support for it https://gitlab.com/gitlab-org/gitlab/-/issues/321674
+ // currently the request for ALL http integrations is made and on specific integration edit we search it in the list
+ httpIntegrations: {
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
+ query: getHttpIntegrationsQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ };
+ },
+ update(data) {
+ const { alertManagementHttpIntegrations: { nodes: list = [] } = {} } = data.project || {};
+
+ return {
+ list,
+ };
+ },
+ error(err) {
+ createFlash({ message: err });
+ },
+ },
currentIntegration: {
query: getCurrentIntegrationQuery,
},
@@ -91,9 +121,10 @@ export default {
data() {
return {
isUpdating: false,
- testAlertPayload: null,
integrations: {},
+ httpIntegrations: {},
currentIntegration: null,
+ formVisible: false,
};
},
computed: {
@@ -105,22 +136,28 @@ export default {
},
},
methods: {
+ isHttp(type) {
+ return type === typeSet.http;
+ },
createNewIntegration({ type, variables }) {
const { projectPath } = this;
+ const isHttp = this.isHttp(type);
this.isUpdating = true;
this.$apollo
.mutate({
- mutation:
- type === this.$options.typeSet.http
- ? createHttpIntegrationMutation
- : createPrometheusIntegrationMutation,
+ mutation: isHttp ? createHttpIntegrationMutation : createPrometheusIntegrationMutation,
variables: {
...variables,
projectPath,
},
update(store, { data }) {
updateStoreAfterIntegrationAdd(store, getIntegrationsQuery, data, { projectPath });
+ if (isHttp) {
+ updateStoreAfterHttpIntegrationAdd(store, getHttpIntegrationsQuery, data, {
+ projectPath,
+ });
+ }
},
})
.then(({ data: { httpIntegrationCreate, prometheusIntegrationCreate } = {} } = {}) => {
@@ -128,18 +165,9 @@ export default {
if (error) {
return createFlash({ message: error });
}
+ const { integration } = httpIntegrationCreate || prometheusIntegrationCreate;
- if (this.testAlertPayload) {
- const integration =
- httpIntegrationCreate?.integration || prometheusIntegrationCreate?.integration;
-
- const payload = {
- ...this.testAlertPayload,
- endpoint: integration.url,
- token: integration.token,
- };
- return this.validateAlertPayload(payload);
- }
+ this.editIntegration(integration);
return createFlash({
message: this.$options.i18n.changesSaved,
@@ -157,10 +185,9 @@ export default {
this.isUpdating = true;
this.$apollo
.mutate({
- mutation:
- type === this.$options.typeSet.http
- ? updateHttpIntegrationMutation
- : updatePrometheusIntegrationMutation,
+ mutation: this.isHttp(type)
+ ? updateHttpIntegrationMutation
+ : updatePrometheusIntegrationMutation,
variables: {
...variables,
id: this.currentIntegration.id,
@@ -172,11 +199,7 @@ export default {
return createFlash({ message: error });
}
- if (this.testAlertPayload) {
- return this.validateAlertPayload();
- }
-
- this.clearCurrentIntegration();
+ this.clearCurrentIntegration({ type });
return createFlash({
message: this.$options.i18n.changesSaved,
@@ -188,23 +211,19 @@ export default {
})
.finally(() => {
this.isUpdating = false;
- this.testAlertPayload = null;
});
},
resetToken({ type, variables }) {
this.isUpdating = true;
this.$apollo
.mutate({
- mutation:
- type === this.$options.typeSet.http
- ? resetHttpTokenMutation
- : resetPrometheusTokenMutation,
+ mutation: this.isHttp(type) ? resetHttpTokenMutation : resetPrometheusTokenMutation,
variables,
})
.then(
({ data: { httpIntegrationResetToken, prometheusIntegrationResetToken } = {} } = {}) => {
- const error =
- httpIntegrationResetToken?.errors[0] || prometheusIntegrationResetToken?.errors[0];
+ const [error] =
+ httpIntegrationResetToken?.errors || prometheusIntegrationResetToken?.errors;
if (error) {
return createFlash({ message: error });
}
@@ -214,10 +233,10 @@ export default {
prometheusIntegrationResetToken?.integration;
this.$apollo.mutate({
- mutation: updateCurrentIntergrationMutation,
- variables: {
- ...integration,
- },
+ mutation: this.isHttp(type)
+ ? updateCurrentHttpIntegrationMutation
+ : updateCurrentPrometheusIntegrationMutation,
+ variables: integration,
});
return createFlash({
@@ -233,33 +252,31 @@ export default {
this.isUpdating = false;
});
},
- editIntegration({ id }) {
- const currentIntegration = this.integrations.list.find(
- (integration) => integration.id === id,
- );
+ editIntegration({ id, type }) {
+ let currentIntegration = this.integrations.list.find((integration) => integration.id === id);
+ if (this.isHttp(type)) {
+ const httpIntegrationMappingData = this.httpIntegrations.list.find(
+ (integration) => integration.id === id,
+ );
+ currentIntegration = { ...currentIntegration, ...httpIntegrationMappingData };
+ }
+
this.$apollo.mutate({
- mutation: updateCurrentIntergrationMutation,
- variables: {
- id: currentIntegration.id,
- name: currentIntegration.name,
- active: currentIntegration.active,
- token: currentIntegration.token,
- type: currentIntegration.type,
- url: currentIntegration.url,
- apiUrl: currentIntegration.apiUrl,
- },
+ mutation: this.isHttp(type)
+ ? updateCurrentHttpIntegrationMutation
+ : updateCurrentPrometheusIntegrationMutation,
+ variables: currentIntegration,
});
+ this.setFormVisibility(true);
},
- deleteIntegration({ id }) {
+ deleteIntegration({ id, type }) {
const { projectPath } = this;
this.isUpdating = true;
this.$apollo
.mutate({
mutation: destroyHttpIntegrationMutation,
- variables: {
- id,
- },
+ variables: { id },
update(store, { data }) {
updateStoreAfterIntegrationDelete(store, getIntegrationsQuery, data, { projectPath });
},
@@ -269,7 +286,7 @@ export default {
if (error) {
return createFlash({ message: error });
}
- this.clearCurrentIntegration();
+ this.clearCurrentIntegration({ type });
return createFlash({
message: this.$options.i18n.integrationRemoved,
type: FLASH_TYPES.SUCCESS,
@@ -282,18 +299,20 @@ export default {
this.isUpdating = false;
});
},
- clearCurrentIntegration() {
- this.$apollo.mutate({
- mutation: updateCurrentIntergrationMutation,
- variables: {},
- });
- },
- setTestAlertPayload(payload) {
- this.testAlertPayload = payload;
+ clearCurrentIntegration({ type }) {
+ if (type) {
+ this.$apollo.mutate({
+ mutation: this.isHttp(type)
+ ? updateCurrentHttpIntegrationMutation
+ : updateCurrentPrometheusIntegrationMutation,
+ variables: {},
+ });
+ }
+ this.setFormVisibility(false);
},
- validateAlertPayload(payload) {
+ testAlertPayload(payload) {
return service
- .updateTestAlert(payload ?? this.testAlertPayload)
+ .updateTestAlert(payload)
.then(() => {
return createFlash({
message: this.$options.i18n.alertSent,
@@ -304,6 +323,9 @@ export default {
createFlash({ message: INTEGRATION_PAYLOAD_TEST_ERROR });
});
},
+ setFormVisibility(visible) {
+ this.formVisible = visible;
+ },
},
};
</script>
@@ -316,7 +338,18 @@ export default {
@edit-integration="editIntegration"
@delete-integration="deleteIntegration"
/>
+ <gl-button
+ v-if="canAddIntegration && !formVisible"
+ category="secondary"
+ variant="confirm"
+ data-testid="add-integration-btn"
+ class="gl-mt-3"
+ @click="setFormVisibility(true)"
+ >
+ {{ $options.i18n.addNewIntegration }}
+ </gl-button>
<alert-settings-form
+ v-if="formVisible"
:loading="isUpdating"
:can-add-integration="canAddIntegration"
:alert-fields="alertFields"
@@ -324,7 +357,7 @@ export default {
@update-integration="updateIntegration"
@reset-token="resetToken"
@clear-current-integration="clearCurrentIntegration"
- @set-test-alert-payload="setTestAlertPayload"
+ @test-alert-payload="testAlertPayload"
/>
</div>
</template>
diff --git a/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json b/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json
deleted file mode 100644
index 80fbebf2a60..00000000000
--- a/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json
+++ /dev/null
@@ -1,95 +0,0 @@
-{
- "samplePayload": {
- "body": "{\n \"dashboardId\":1,\n \"evalMatches\":[\n {\n \"value\":1,\n \"metric\":\"Count\",\n \"tags\":{}\n }\n ],\n \"imageUrl\":\"https://grafana.com/static/assets/img/blog/mixed_styles.png\",\n \"message\":\"Notification Message\",\n \"orgId\":1,\n \"panelId\":2,\n \"ruleId\":1,\n \"ruleName\":\"Panel Title alert\",\n \"ruleUrl\":\"http://localhost:3000/d/hZ7BuVbWz/test-dashboard?fullscreen\\u0026edit\\u0026tab=alert\\u0026panelId=2\\u0026orgId=1\",\n \"state\":\"alerting\",\n \"tags\":{\n \"tag name\":\"tag value\"\n },\n \"title\":\"[Alerting] Panel Title alert\"\n}\n",
- "payloadAlerFields": {
- "nodes": [
- {
- "path": ["dashboardId"],
- "label": "Dashboard Id",
- "type": "string"
- },
- {
- "path": ["evalMatches"],
- "label": "Eval Matches",
- "type": "array"
- },
- {
- "path": ["createdAt"],
- "label": "Created At",
- "type": "datetime"
- },
- {
- "path": ["imageUrl"],
- "label": "Image Url",
- "type": "string"
- },
- {
- "path": ["message"],
- "label": "Message",
- "type": "string"
- },
- {
- "path": ["orgId"],
- "label": "Org Id",
- "type": "string"
- },
- {
- "path": ["panelId"],
- "label": "Panel Id",
- "type": "string"
- },
- {
- "path": ["ruleId"],
- "label": "Rule Id",
- "type": "string"
- },
- {
- "path": ["ruleName"],
- "label": "Rule Name",
- "type": "string"
- },
- {
- "path": ["ruleUrl"],
- "label": "Rule Url",
- "type": "string"
- },
- {
- "path": ["state"],
- "label": "State",
- "type": "string"
- },
- {
- "path": ["title"],
- "label": "Title",
- "type": "string"
- },
- {
- "path": ["tags", "tag"],
- "label": "Tags",
- "type": "string"
- }
- ]
- }
- },
- "storedMapping": {
- "nodes": [
- {
- "alertFieldName": "title",
- "payloadAlertPaths": "title",
- "fallbackAlertPaths": "ruleUrl"
- },
- {
- "alertFieldName": "description",
- "payloadAlertPaths": "message"
- },
- {
- "alertFieldName": "hosts",
- "payloadAlertPaths": "evalMatches"
- },
- {
- "alertFieldName": "startTime",
- "payloadAlertPaths": "createdAt"
- }
- ]
- }
-}
diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js
index ecd7c921b2f..ce6cf61b5dd 100644
--- a/app/assets/javascripts/alerts_settings/constants.js
+++ b/app/assets/javascripts/alerts_settings/constants.js
@@ -1,50 +1,106 @@
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
-// TODO: Remove this as part of the form old removal
export const i18n = {
- usageSection: s__(
- 'AlertSettings|You must provide this URL and authorization key to authorize an external service to send alerts to GitLab. You can provide this URL and key to multiple services. After configuring an external service, alerts from your service will display on the GitLab %{linkStart}Alerts%{linkEnd} page.',
- ),
- setupSection: s__(
- "AlertSettings|Review your external service's documentation to learn where to provide this information to your external service, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.",
- ),
- errorMsg: s__('AlertSettings|There was an error updating the alert settings.'),
- errorKeyMsg: s__(
- 'AlertSettings|There was an error while trying to reset the key. Please refresh the page to try again.',
- ),
- restKeyInfo: s__(
- 'AlertSettings|Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.',
- ),
+ integrationTabs: {
+ configureDetails: s__('AlertSettings|Configure details'),
+ viewCredentials: s__('AlertSettings|View credentials'),
+ sendTestAlert: s__('AlertSettings|Send test alert'),
+ },
+ integrationFormSteps: {
+ selectType: {
+ label: s__('AlertSettings|Select integration type'),
+ enterprise: s__(
+ 'AlertSettings|In free versions of GitLab, only one integration for each type can be added. %{linkStart}Upgrade your subscription%{linkEnd} to add additional integrations.',
+ ),
+ },
+ nameIntegration: {
+ label: s__('AlertSettings|Name integration'),
+ placeholder: s__('AlertSettings|Enter integration name'),
+ activeToggle: __('Active'),
+ },
+ setupCredentials: {
+ help: s__(
+ "AlertSettings|Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.",
+ ),
+ prometheusHelp: s__(
+ 'AlertSettings|Utilize the URL and authorization key below to authorize Prometheus to send alerts to GitLab. Review the Prometheus documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.',
+ ),
+ webhookUrl: s__('AlertSettings|Webhook URL'),
+ authorizationKey: s__('AlertSettings|Authorization key'),
+ reset: s__('AlertSettings|Reset Key'),
+ },
+ setSamplePayload: {
+ label: s__('AlertSettings|Sample alert payload (optional)'),
+ testPayloadHelpHttp: s__(
+ 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to create a custom mapping (optional).',
+ ),
+ testPayloadHelp: s__(
+ 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This will allow you to send an alert to an active GitLab alerting point.',
+ ),
+ placeholder: s__('AlertSettings|{ "events": [{ "application": "Name of application" }] }'),
+ resetHeader: s__('AlertSettings|Reset the mapping'),
+ resetBody: s__(
+ "AlertSettings|If you edit the payload, the stored mapping will be reset, and you'll need to re-map the fields.",
+ ),
+ resetOk: s__('AlertSettings|Proceed with editing'),
+ editPayload: s__('AlertSettings|Edit payload'),
+ parsePayload: s__('AlertSettings|Parse payload for custom mapping'),
+ payloadParsedSucessMsg: s__(
+ 'AlertSettings|Sample payload has been parsed. You can now map the fields.',
+ ),
+ },
+ mapFields: {
+ label: s__('AlertSettings|Customize alert payload mapping (optional)'),
+ intro: s__(
+ 'AlertSettings|If you intend to create a custom mapping, provide an example payload from your monitoring tool and click "parse payload fields" button to continue. The sample payload is required for completing the custom mapping; if you want to skip the mapping step, progress straight to saving your integration.',
+ ),
+ },
+ prometheusFormUrl: {
+ label: s__('AlertSettings|Prometheus API base URL'),
+ help: s__('AlertSettings|URL cannot be blank and must start with http or https'),
+ },
+ restKeyInfo: {
+ label: s__(
+ 'AlertSettings|Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.',
+ ),
+ },
+ },
+ saveIntegration: s__('AlertSettings|Save integration'),
changesSaved: s__('AlertSettings|Your integration was successfully updated.'),
- prometheusInfo: s__('AlertSettings|Add URL and auth key to your Prometheus config file'),
- integrationsInfo: s__(
- 'AlertSettings|Learn more about our our upcoming %{linkStart}integrations%{linkEnd}',
- ),
- resetKey: s__('AlertSettings|Reset key'),
- copyToClipboard: s__('AlertSettings|Copy'),
- apiBaseUrlLabel: s__('AlertSettings|API URL'),
- authKeyLabel: s__('AlertSettings|Authorization key'),
- urlLabel: s__('AlertSettings|Webhook URL'),
- activeLabel: s__('AlertSettings|Active'),
- apiBaseUrlHelpText: s__('AlertSettings|URL cannot be blank and must start with http or https'),
- testAlertInfo: s__('AlertSettings|Test alert payload'),
- alertJson: s__('AlertSettings|Alert test payload'),
- alertJsonPlaceholder: s__('AlertSettings|Enter test alert JSON....'),
- testAlertFailed: s__('AlertSettings|Test failed. Do you still want to save your changes anyway?'),
- testAlertSuccess: s__(
- 'AlertSettings|Test alert sent successfully. If you have made other changes, please save them now.',
- ),
- authKeyRest: s__(
- 'AlertSettings|Authorization key has been successfully reset. Please save your changes now.',
- ),
- integration: s__('AlertSettings|Integration'),
+ cancelAndClose: __('Cancel and close'),
+ send: s__('AlertSettings|Send'),
+ copy: __('Copy'),
};
-export const integrationTypes = [
- { value: '', text: s__('AlertSettings|Select integration type') },
- { value: 'HTTP', text: s__('AlertSettings|HTTP Endpoint') },
- { value: 'PROMETHEUS', text: s__('AlertSettings|External Prometheus') },
-];
+export const integrationSteps = {
+ selectType: 'SELECT_TYPE',
+ nameIntegration: 'NAME_INTEGRATION',
+ setPrometheusApiUrl: 'SET_PROMETHEUS_API_URL',
+ setSamplePayload: 'SET_SAMPLE_PAYLOAD',
+ customizeMapping: 'CUSTOMIZE_MAPPING',
+};
+
+export const createStepNumbers = {
+ [integrationSteps.selectType]: 1,
+ [integrationSteps.nameIntegration]: 2,
+ [integrationSteps.setPrometheusApiUrl]: 2,
+ [integrationSteps.setSamplePayload]: 3,
+ [integrationSteps.customizeMapping]: 4,
+};
+
+export const editStepNumbers = {
+ [integrationSteps.selectType]: 1,
+ [integrationSteps.nameIntegration]: 1,
+ [integrationSteps.setPrometheusApiUrl]: null,
+ [integrationSteps.setSamplePayload]: 2,
+ [integrationSteps.customizeMapping]: 3,
+};
+
+export const integrationTypes = {
+ none: { value: '', text: s__('AlertSettings|Select integration type') },
+ http: { value: 'HTTP', text: s__('AlertSettings|HTTP Endpoint') },
+ prometheus: { value: 'PROMETHEUS', text: s__('AlertSettings|External Prometheus') },
+};
export const typeSet = {
http: 'HTTP',
@@ -57,14 +113,18 @@ export const JSON_VALIDATE_DELAY = 250;
export const targetPrometheusUrlPlaceholder = 'http://prometheus.example.com/';
-export const sectionHash = 'js-alert-management-settings';
-
-/* eslint-disable @gitlab/require-i18n-strings */
-
/**
* Tracks snowplow event when user views alerts integration list
*/
export const trackAlertIntegrationsViewsOptions = {
+ /* eslint-disable-next-line @gitlab/require-i18n-strings */
category: 'Alert Integrations',
action: 'view_alert_integrations_list',
};
+
+export const mappingFields = {
+ mapping: 'mapping',
+ fallback: 'fallback',
+};
+
+export const viewCredentialsTabIndex = 1;
diff --git a/app/assets/javascripts/alerts_settings/graphql.js b/app/assets/javascripts/alerts_settings/graphql.js
index 5fd05169533..72817f636ff 100644
--- a/app/assets/javascripts/alerts_settings/graphql.js
+++ b/app/assets/javascripts/alerts_settings/graphql.js
@@ -10,16 +10,25 @@ const resolvers = {
Mutation: {
updateCurrentIntegration: (
_,
- { id = null, name, active, token, type, url, apiUrl },
+ {
+ id = null,
+ name,
+ active,
+ token,
+ type,
+ url,
+ apiUrl,
+ payloadExample,
+ payloadAttributeMappings,
+ payloadAlertFields,
+ },
{ cache },
) => {
const sourceData = cache.readQuery({ query: getCurrentIntegrationQuery });
const data = produce(sourceData, (draftData) => {
if (id === null) {
- // eslint-disable-next-line no-param-reassign
draftData.currentIntegration = null;
} else {
- // eslint-disable-next-line no-param-reassign
draftData.currentIntegration = {
id,
name,
@@ -28,6 +37,9 @@ const resolvers = {
type,
url,
apiUrl,
+ payloadExample,
+ payloadAttributeMappings,
+ payloadAlertFields,
};
}
});
diff --git a/app/assets/javascripts/alerts_settings/graphql/fragments/http_integration_item.fragment.graphql b/app/assets/javascripts/alerts_settings/graphql/fragments/http_integration_item.fragment.graphql
new file mode 100644
index 00000000000..742228e2928
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/graphql/fragments/http_integration_item.fragment.graphql
@@ -0,0 +1,7 @@
+#import "./integration_item.fragment.graphql"
+#import "ee_else_ce/alerts_settings/graphql/fragments/http_integration_payload_data.fragment.graphql"
+
+fragment HttpIntegrationItem on AlertManagementHttpIntegration {
+ ...IntegrationItem
+ ...HttpIntegrationPayloadData
+}
diff --git a/app/assets/javascripts/alerts_settings/graphql/fragments/http_integration_payload_data.fragment.graphql b/app/assets/javascripts/alerts_settings/graphql/fragments/http_integration_payload_data.fragment.graphql
new file mode 100644
index 00000000000..df6ad0b712d
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/graphql/fragments/http_integration_payload_data.fragment.graphql
@@ -0,0 +1,3 @@
+fragment HttpIntegrationPayloadData on AlertManagementHttpIntegration {
+ id
+}
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql
index f3fc10b4bd4..babcdea935d 100644
--- a/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql
@@ -1,24 +1,10 @@
-#import "../fragments/integration_item.fragment.graphql"
+#import "../fragments/http_integration_item.fragment.graphql"
-mutation createHttpIntegration(
- $projectPath: ID!
- $name: String!
- $active: Boolean!
- $payloadExample: JsonString
- $payloadAttributeMappings: [AlertManagementPayloadAlertFieldInput!]
-) {
- httpIntegrationCreate(
- input: {
- projectPath: $projectPath
- name: $name
- active: $active
- payloadExample: $payloadExample
- payloadAttributeMappings: $payloadAttributeMappings
- }
- ) {
+mutation createHttpIntegration($projectPath: ID!, $name: String!, $active: Boolean!) {
+ httpIntegrationCreate(input: { projectPath: $projectPath, name: $name, active: $active }) {
errors
integration {
- ...IntegrationItem
+ ...HttpIntegrationItem
}
}
}
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql
index 0a49c140e6a..a3a50651fd0 100644
--- a/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql
@@ -1,10 +1,10 @@
-#import "../fragments/integration_item.fragment.graphql"
+#import "../fragments/http_integration_item.fragment.graphql"
mutation destroyHttpIntegration($id: ID!) {
httpIntegrationDestroy(input: { id: $id }) {
errors
integration {
- ...IntegrationItem
+ ...HttpIntegrationItem
}
}
}
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql
index 178d1e13047..c0754d8e32b 100644
--- a/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql
@@ -1,10 +1,10 @@
-#import "../fragments/integration_item.fragment.graphql"
+#import "../fragments/http_integration_item.fragment.graphql"
mutation resetHttpIntegrationToken($id: ID!) {
httpIntegrationResetToken(input: { id: $id }) {
errors
integration {
- ...IntegrationItem
+ ...HttpIntegrationItem
}
}
}
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_http_integration.mutation.graphql
new file mode 100644
index 00000000000..5f3d305993c
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_http_integration.mutation.graphql
@@ -0,0 +1,25 @@
+mutation updateCurrentHttpIntegration(
+ $id: String
+ $name: String
+ $active: Boolean
+ $token: String
+ $type: String
+ $url: String
+ $apiUrl: String
+ $payloadExample: JsonString
+ $payloadAttributeMappings: [AlertManagementPayloadAlertFieldInput!]
+ $payloadAlertFields: [AlertManagementPayloadAlertField!]
+) {
+ updateCurrentIntegration(
+ id: $id
+ name: $name
+ active: $active
+ token: $token
+ type: $type
+ url: $url
+ apiUrl: $apiUrl
+ payloadExample: $payloadExample
+ payloadAttributeMappings: $payloadAttributeMappings
+ payloadAlertFields: $payloadAlertFields
+ ) @client
+}
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_intergration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_prometheus_integration.mutation.graphql
index 3505241309e..5bd63820629 100644
--- a/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_intergration.mutation.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_prometheus_integration.mutation.graphql
@@ -1,4 +1,4 @@
-mutation updateCurrentIntegration(
+mutation updateCurrentPrometheusIntegration(
$id: String
$name: String
$active: Boolean
@@ -6,6 +6,7 @@ mutation updateCurrentIntegration(
$type: String
$url: String
$apiUrl: String
+ $samplePayload: String
) {
updateCurrentIntegration(
id: $id
@@ -15,5 +16,6 @@ mutation updateCurrentIntegration(
type: $type
url: $url
apiUrl: $apiUrl
+ samplePayload: $samplePayload
) @client
}
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql
index bb5b334deeb..37df9ec25eb 100644
--- a/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql
@@ -1,10 +1,10 @@
-#import "../fragments/integration_item.fragment.graphql"
+#import "../fragments/http_integration_item.fragment.graphql"
mutation updateHttpIntegration($id: ID!, $name: String!, $active: Boolean!) {
httpIntegrationUpdate(input: { id: $id, name: $name, active: $active }) {
errors
integration {
- ...IntegrationItem
+ ...HttpIntegrationItem
}
}
}
diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integrations.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integrations.query.graphql
new file mode 100644
index 00000000000..833a2d6c12f
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integrations.query.graphql
@@ -0,0 +1,12 @@
+#import "ee_else_ce/alerts_settings/graphql/fragments/http_integration_payload_data.fragment.graphql"
+
+# TODO: this query need to accept http integration id to request a sepcific integration
+query getHttpIntegrations($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ alertManagementHttpIntegrations {
+ nodes {
+ ...HttpIntegrationPayloadData
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/parse_sample_payload.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/parse_sample_payload.query.graphql
new file mode 100644
index 00000000000..159b2661f0b
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/graphql/queries/parse_sample_payload.query.graphql
@@ -0,0 +1,9 @@
+query parsePayloadFields($projectPath: ID!, $payload: String!) {
+ project(fullPath: $projectPath) {
+ alertManagementPayloadFields(payloadExample: $payload) {
+ path
+ label
+ type
+ }
+ }
+}
diff --git a/app/assets/javascripts/alerts_settings/index.js b/app/assets/javascripts/alerts_settings/index.js
index 8506b3fda01..321af9fedb6 100644
--- a/app/assets/javascripts/alerts_settings/index.js
+++ b/app/assets/javascripts/alerts_settings/index.js
@@ -63,10 +63,7 @@ export default (el) => {
render(createElement) {
return createElement('alert-settings-wrapper', {
props: {
- alertFields:
- gon.features?.multipleHttpIntegrationsCustomMapping && parseBoolean(multiIntegrations)
- ? JSON.parse(alertFields)
- : null,
+ alertFields: parseBoolean(multiIntegrations) ? JSON.parse(alertFields) : null,
},
});
},
diff --git a/app/assets/javascripts/alerts_settings/utils/cache_updates.js b/app/assets/javascripts/alerts_settings/utils/cache_updates.js
index 758f3eb6dd4..716c709a931 100644
--- a/app/assets/javascripts/alerts_settings/utils/cache_updates.js
+++ b/app/assets/javascripts/alerts_settings/utils/cache_updates.js
@@ -15,7 +15,6 @@ const deleteIntegrationFromStore = (store, query, { httpIntegrationDestroy }, va
});
const data = produce(sourceData, (draftData) => {
- // eslint-disable-next-line no-param-reassign
draftData.project.alertManagementIntegrations.nodes = draftData.project.alertManagementIntegrations.nodes.filter(
({ id }) => id !== integration.id,
);
@@ -46,7 +45,6 @@ const addIntegrationToStore = (
});
const data = produce(sourceData, (draftData) => {
- // eslint-disable-next-line no-param-reassign
draftData.project.alertManagementIntegrations.nodes = [
integration,
...draftData.project.alertManagementIntegrations.nodes,
@@ -60,6 +58,31 @@ const addIntegrationToStore = (
});
};
+const addHttpIntegrationToStore = (store, query, { httpIntegrationCreate }, variables) => {
+ const integration = httpIntegrationCreate?.integration;
+ if (!integration) {
+ return;
+ }
+
+ const sourceData = store.readQuery({
+ query,
+ variables,
+ });
+
+ const data = produce(sourceData, (draftData) => {
+ draftData.project.alertManagementHttpIntegrations.nodes = [
+ integration,
+ ...draftData.project.alertManagementHttpIntegrations.nodes,
+ ];
+ });
+
+ store.writeQuery({
+ query,
+ variables,
+ data,
+ });
+};
+
const onError = (data, message) => {
createFlash({ message });
throw new Error(data.errors);
@@ -82,3 +105,11 @@ export const updateStoreAfterIntegrationAdd = (store, query, data, variables) =>
addIntegrationToStore(store, query, data, variables);
}
};
+
+export const updateStoreAfterHttpIntegrationAdd = (store, query, data, variables) => {
+ if (hasErrors(data)) {
+ onError(data, ADD_INTEGRATION_ERROR);
+ } else {
+ addHttpIntegrationToStore(store, query, data, variables);
+ }
+};
diff --git a/app/assets/javascripts/alerts_settings/utils/error_messages.js b/app/assets/javascripts/alerts_settings/utils/error_messages.js
index 979d1ca3ccc..e380257f983 100644
--- a/app/assets/javascripts/alerts_settings/utils/error_messages.js
+++ b/app/assets/javascripts/alerts_settings/utils/error_messages.js
@@ -17,5 +17,5 @@ export const RESET_INTEGRATION_TOKEN_ERROR = s__(
);
export const INTEGRATION_PAYLOAD_TEST_ERROR = s__(
- 'AlertsIntegrations|Integration payload is invalid. You can still save your changes.',
+ 'AlertsIntegrations|Integration payload is invalid.',
);
diff --git a/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js b/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js
index a86103540c0..5c4b9bcd505 100644
--- a/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js
+++ b/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js
@@ -1,3 +1,4 @@
+import { isEqual } from 'lodash';
/**
* Given data for GitLab alert fields, parsed payload fields data and previously stored mapping (if any)
* creates an object in a form convenient to build UI && interact with it
@@ -10,16 +11,19 @@
export const getMappingData = (gitlabFields, payloadFields, savedMapping) => {
return gitlabFields.map((gitlabField) => {
// find fields from payload that match gitlab alert field by type
- const mappingFields = payloadFields.filter(({ type }) => gitlabField.types.includes(type));
+ const mappingFields = payloadFields.filter(({ type }) =>
+ gitlabField.types.includes(type.toLowerCase()),
+ );
// find the mapping that was previously stored
- const foundMapping = savedMapping.find(({ fieldName }) => fieldName === gitlabField.name);
-
- const { fallbackAlertPaths, payloadAlertPaths } = foundMapping || {};
+ const foundMapping = savedMapping.find(
+ ({ fieldName }) => fieldName.toLowerCase() === gitlabField.name,
+ );
+ const { path: mapping, fallbackPath: fallback } = foundMapping || {};
return {
- mapping: payloadAlertPaths,
- fallback: fallbackAlertPaths,
+ mapping,
+ fallback,
searchTerm: '',
fallbackSearchTerm: '',
mappingFields,
@@ -36,7 +40,7 @@ export const getMappingData = (gitlabFields, payloadFields, savedMapping) => {
*/
export const transformForSave = (mappingData) => {
return mappingData.reduce((acc, field) => {
- const mapped = field.mappingFields.find(({ name }) => name === field.mapping);
+ const mapped = field.mappingFields.find(({ path }) => isEqual(path, field.mapping));
if (mapped) {
const { path, type, label } = mapped;
acc.push({
@@ -49,13 +53,3 @@ export const transformForSave = (mappingData) => {
return acc;
}, []);
};
-
-/**
- * Adds `name` prop to each provided by BE parsed payload field
- * @param {Object} payload - parsed sample payload
- *
- * @return {Object} same as input with an extra `name` property which basically serves as a key to make a match
- */
-export const getPayloadFields = (payload) => {
- return payload.map((field) => ({ ...field, name: field.path.join('_') }));
-};
diff --git a/app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue b/app/assets/javascripts/analytics/devops_report/components/usage_ping_disabled.vue
index c0ad814172d..c0ad814172d 100644
--- a/app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue
+++ b/app/assets/javascripts/analytics/devops_report/components/usage_ping_disabled.vue
diff --git a/app/assets/javascripts/admin/dev_ops_report/devops_score_empty_state.js b/app/assets/javascripts/analytics/devops_report/devops_score_empty_state.js
index 0cb8d9be0e4..0cb8d9be0e4 100644
--- a/app/assets/javascripts/admin/dev_ops_report/devops_score_empty_state.js
+++ b/app/assets/javascripts/analytics/devops_report/devops_score_empty_state.js
diff --git a/app/assets/javascripts/analytics/instance_statistics/components/charts_config.js b/app/assets/javascripts/analytics/instance_statistics/components/charts_config.js
deleted file mode 100644
index 6fba3c56cfe..00000000000
--- a/app/assets/javascripts/analytics/instance_statistics/components/charts_config.js
+++ /dev/null
@@ -1,87 +0,0 @@
-import { s__, __, sprintf } from '~/locale';
-import query from '../graphql/queries/instance_count.query.graphql';
-
-const noDataMessage = s__('InstanceStatistics|No data available.');
-
-export default [
- {
- loadChartError: sprintf(
- s__(
- 'InstanceStatistics|Could not load the pipelines chart. Please refresh the page to try again.',
- ),
- ),
- noDataMessage,
- chartTitle: s__('InstanceStatistics|Pipelines'),
- yAxisTitle: s__('InstanceStatistics|Items'),
- xAxisTitle: s__('InstanceStatistics|Month'),
- queries: [
- {
- query,
- title: s__('InstanceStatistics|Pipelines total'),
- identifier: 'PIPELINES',
- loadError: sprintf(
- s__('InstanceStatistics|There was an error fetching the total pipelines'),
- ),
- },
- {
- query,
- title: s__('InstanceStatistics|Pipelines succeeded'),
- identifier: 'PIPELINES_SUCCEEDED',
- loadError: sprintf(
- s__('InstanceStatistics|There was an error fetching the successful pipelines'),
- ),
- },
- {
- query,
- title: s__('InstanceStatistics|Pipelines failed'),
- identifier: 'PIPELINES_FAILED',
- loadError: sprintf(
- s__('InstanceStatistics|There was an error fetching the failed pipelines'),
- ),
- },
- {
- query,
- title: s__('InstanceStatistics|Pipelines canceled'),
- identifier: 'PIPELINES_CANCELED',
- loadError: sprintf(
- s__('InstanceStatistics|There was an error fetching the cancelled pipelines'),
- ),
- },
- {
- query,
- title: s__('InstanceStatistics|Pipelines skipped'),
- identifier: 'PIPELINES_SKIPPED',
- loadError: sprintf(
- s__('InstanceStatistics|There was an error fetching the skipped pipelines'),
- ),
- },
- ],
- },
- {
- loadChartError: sprintf(
- s__(
- 'InstanceStatistics|Could not load the issues and merge requests chart. Please refresh the page to try again.',
- ),
- ),
- noDataMessage,
- chartTitle: s__('InstanceStatistics|Issues & Merge Requests'),
- yAxisTitle: s__('InstanceStatistics|Items'),
- xAxisTitle: s__('InstanceStatistics|Month'),
- queries: [
- {
- query,
- title: __('Issues'),
- identifier: 'ISSUES',
- loadError: sprintf(s__('InstanceStatistics|There was an error fetching the issues')),
- },
- {
- query,
- title: __('Merge requests'),
- identifier: 'MERGE_REQUESTS',
- loadError: sprintf(
- s__('InstanceStatistics|There was an error fetching the merge requests'),
- ),
- },
- ],
- },
-];
diff --git a/app/assets/javascripts/analytics/instance_statistics/components/projects_and_groups_chart.vue b/app/assets/javascripts/analytics/instance_statistics/components/projects_and_groups_chart.vue
deleted file mode 100644
index 3ffec90fb68..00000000000
--- a/app/assets/javascripts/analytics/instance_statistics/components/projects_and_groups_chart.vue
+++ /dev/null
@@ -1,224 +0,0 @@
-<script>
-import { GlAlert } from '@gitlab/ui';
-import { GlLineChart } from '@gitlab/ui/dist/charts';
-import produce from 'immer';
-import { sortBy } from 'lodash';
-import { formatDateAsMonth } from '~/lib/utils/datetime_utility';
-import { s__, __ } from '~/locale';
-import * as Sentry from '~/sentry/wrapper';
-import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
-import latestGroupsQuery from '../graphql/queries/groups.query.graphql';
-import latestProjectsQuery from '../graphql/queries/projects.query.graphql';
-import { getAverageByMonth } from '../utils';
-
-const sortByDate = (data) => sortBy(data, (item) => new Date(item[0]).getTime());
-
-const averageAndSortData = (data = [], maxDataPoints) => {
- const averaged = getAverageByMonth(
- data.length > maxDataPoints ? data.slice(0, maxDataPoints) : data,
- { shouldRound: true },
- );
- return sortByDate(averaged);
-};
-
-export default {
- name: 'ProjectsAndGroupsChart',
- components: { GlAlert, GlLineChart, ChartSkeletonLoader },
- props: {
- startDate: {
- type: Date,
- required: true,
- },
- endDate: {
- type: Date,
- required: true,
- },
- totalDataPoints: {
- type: Number,
- required: true,
- },
- },
- data() {
- return {
- loadingError: false,
- errorMessage: '',
- groups: [],
- projects: [],
- groupsPageInfo: null,
- projectsPageInfo: null,
- };
- },
- apollo: {
- groups: {
- query: latestGroupsQuery,
- variables() {
- return {
- first: this.totalDataPoints,
- after: null,
- };
- },
- update(data) {
- return data.groups?.nodes || [];
- },
- result({ data }) {
- const {
- groups: { pageInfo },
- } = data;
- this.groupsPageInfo = pageInfo;
- this.fetchNextPage({
- query: this.$apollo.queries.groups,
- pageInfo: this.groupsPageInfo,
- dataKey: 'groups',
- errorMessage: this.$options.i18n.loadGroupsDataError,
- });
- },
- error(error) {
- this.handleError({
- message: this.$options.i18n.loadGroupsDataError,
- error,
- dataKey: 'groups',
- });
- },
- },
- projects: {
- query: latestProjectsQuery,
- variables() {
- return {
- first: this.totalDataPoints,
- after: null,
- };
- },
- update(data) {
- return data.projects?.nodes || [];
- },
- result({ data }) {
- const {
- projects: { pageInfo },
- } = data;
- this.projectsPageInfo = pageInfo;
- this.fetchNextPage({
- query: this.$apollo.queries.projects,
- pageInfo: this.projectsPageInfo,
- dataKey: 'projects',
- errorMessage: this.$options.i18n.loadProjectsDataError,
- });
- },
- error(error) {
- this.handleError({
- message: this.$options.i18n.loadProjectsDataError,
- error,
- dataKey: 'projects',
- });
- },
- },
- },
- i18n: {
- yAxisTitle: s__('InstanceStatistics|Total projects & groups'),
- xAxisTitle: __('Month'),
- loadChartError: s__(
- 'InstanceStatistics|Could not load the projects and groups chart. Please refresh the page to try again.',
- ),
- loadProjectsDataError: s__('InstanceStatistics|There was an error while loading the projects'),
- loadGroupsDataError: s__('InstanceStatistics|There was an error while loading the groups'),
- noDataMessage: s__('InstanceStatistics|No data available.'),
- },
- computed: {
- isLoadingGroups() {
- return this.$apollo.queries.groups.loading || this.groupsPageInfo?.hasNextPage;
- },
- isLoadingProjects() {
- return this.$apollo.queries.projects.loading || this.projectsPageInfo?.hasNextPage;
- },
- isLoading() {
- return this.isLoadingProjects && this.isLoadingGroups;
- },
- groupChartData() {
- return averageAndSortData(this.groups, this.totalDataPoints);
- },
- projectChartData() {
- return averageAndSortData(this.projects, this.totalDataPoints);
- },
- hasNoData() {
- const { projectChartData, groupChartData } = this;
- return Boolean(!projectChartData.length && !groupChartData.length);
- },
- options() {
- return {
- xAxis: {
- name: this.$options.i18n.xAxisTitle,
- type: 'category',
- axisLabel: {
- formatter: (value) => {
- return formatDateAsMonth(value);
- },
- },
- },
- yAxis: {
- name: this.$options.i18n.yAxisTitle,
- },
- };
- },
- chartData() {
- return [
- {
- name: s__('InstanceStatistics|Total projects'),
- data: this.projectChartData,
- },
- {
- name: s__('InstanceStatistics|Total groups'),
- data: this.groupChartData,
- },
- ];
- },
- },
- methods: {
- handleError({ error, message = this.$options.i18n.loadChartError, dataKey = null }) {
- this.loadingError = true;
- this.errorMessage = message;
- if (!dataKey) {
- this.projects = [];
- this.groups = [];
- } else {
- this[dataKey] = [];
- }
- Sentry.captureException(error);
- },
- fetchNextPage({ pageInfo, query, dataKey, errorMessage }) {
- if (pageInfo?.hasNextPage) {
- query
- .fetchMore({
- variables: { first: this.totalDataPoints, after: pageInfo.endCursor },
- updateQuery: (previousResult, { fetchMoreResult }) => {
- const results = produce(fetchMoreResult, (newData) => {
- // eslint-disable-next-line no-param-reassign
- newData[dataKey].nodes = [
- ...previousResult[dataKey].nodes,
- ...newData[dataKey].nodes,
- ];
- });
- return results;
- },
- })
- .catch((error) => {
- this.handleError({ error, message: errorMessage, dataKey });
- });
- }
- },
- },
-};
-</script>
-<template>
- <div>
- <h3>{{ $options.i18n.yAxisTitle }}</h3>
- <chart-skeleton-loader v-if="isLoading" />
- <gl-alert v-else-if="hasNoData" variant="info" :dismissible="false" class="gl-mt-3">
- {{ $options.i18n.noDataMessage }}
- </gl-alert>
- <div v-else>
- <gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3">{{
- errorMessage
- }}</gl-alert>
- <gl-line-chart :option="options" :include-legend-avg-max="true" :data="chartData" />
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/fragments/count.fragment.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/fragments/count.fragment.graphql
deleted file mode 100644
index 40cef95c2e7..00000000000
--- a/app/assets/javascripts/analytics/instance_statistics/graphql/fragments/count.fragment.graphql
+++ /dev/null
@@ -1,4 +0,0 @@
-fragment Count on InstanceStatisticsMeasurement {
- count
- recordedAt
-}
diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/groups.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/groups.query.graphql
deleted file mode 100644
index ec56d91ffaa..00000000000
--- a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/groups.query.graphql
+++ /dev/null
@@ -1,13 +0,0 @@
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
-#import "../fragments/count.fragment.graphql"
-
-query getGroupsCount($first: Int, $after: String) {
- groups: instanceStatisticsMeasurements(identifier: GROUPS, first: $first, after: $after) {
- nodes {
- ...Count
- }
- pageInfo {
- ...PageInfo
- }
- }
-}
diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql
deleted file mode 100644
index f14c2658674..00000000000
--- a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql
+++ /dev/null
@@ -1,34 +0,0 @@
-#import "../fragments/count.fragment.graphql"
-
-query getInstanceCounts {
- projects: instanceStatisticsMeasurements(identifier: PROJECTS, first: 1) {
- nodes {
- ...Count
- }
- }
- groups: instanceStatisticsMeasurements(identifier: GROUPS, first: 1) {
- nodes {
- ...Count
- }
- }
- users: instanceStatisticsMeasurements(identifier: USERS, first: 1) {
- nodes {
- ...Count
- }
- }
- issues: instanceStatisticsMeasurements(identifier: ISSUES, first: 1) {
- nodes {
- ...Count
- }
- }
- mergeRequests: instanceStatisticsMeasurements(identifier: MERGE_REQUESTS, first: 1) {
- nodes {
- ...Count
- }
- }
- pipelines: instanceStatisticsMeasurements(identifier: PIPELINES, first: 1) {
- nodes {
- ...Count
- }
- }
-}
diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/projects.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/projects.query.graphql
deleted file mode 100644
index 0845b703435..00000000000
--- a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/projects.query.graphql
+++ /dev/null
@@ -1,13 +0,0 @@
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
-#import "../fragments/count.fragment.graphql"
-
-query getProjectsCount($first: Int, $after: String) {
- projects: instanceStatisticsMeasurements(identifier: PROJECTS, first: $first, after: $after) {
- nodes {
- ...Count
- }
- pageInfo {
- ...PageInfo
- }
- }
-}
diff --git a/app/assets/javascripts/analytics/instance_statistics/components/app.vue b/app/assets/javascripts/analytics/usage_trends/components/app.vue
index 3bf41eaa008..4c5ddd7f458 100644
--- a/app/assets/javascripts/analytics/instance_statistics/components/app.vue
+++ b/app/assets/javascripts/analytics/usage_trends/components/app.vue
@@ -1,18 +1,16 @@
<script>
import { TODAY, TOTAL_DAYS_TO_SHOW, START_DATE } from '../constants';
import ChartsConfig from './charts_config';
-import InstanceCounts from './instance_counts.vue';
-import InstanceStatisticsCountChart from './instance_statistics_count_chart.vue';
-import ProjectsAndGroupsChart from './projects_and_groups_chart.vue';
+import UsageCounts from './usage_counts.vue';
+import UsageTrendsCountChart from './usage_trends_count_chart.vue';
import UsersChart from './users_chart.vue';
export default {
- name: 'InstanceStatisticsApp',
+ name: 'UsageTrendsApp',
components: {
- InstanceCounts,
- InstanceStatisticsCountChart,
+ UsageCounts,
+ UsageTrendsCountChart,
UsersChart,
- ProjectsAndGroupsChart,
},
TOTAL_DAYS_TO_SHOW,
START_DATE,
@@ -23,18 +21,13 @@ export default {
<template>
<div>
- <instance-counts />
+ <usage-counts />
<users-chart
:start-date="$options.START_DATE"
:end-date="$options.TODAY"
:total-data-points="$options.TOTAL_DAYS_TO_SHOW"
/>
- <projects-and-groups-chart
- :start-date="$options.START_DATE"
- :end-date="$options.TODAY"
- :total-data-points="$options.TOTAL_DAYS_TO_SHOW"
- />
- <instance-statistics-count-chart
+ <usage-trends-count-chart
v-for="chartOptions in $options.configs"
:key="chartOptions.chartTitle"
:queries="chartOptions.queries"
diff --git a/app/assets/javascripts/analytics/usage_trends/components/charts_config.js b/app/assets/javascripts/analytics/usage_trends/components/charts_config.js
new file mode 100644
index 00000000000..014f823cdc4
--- /dev/null
+++ b/app/assets/javascripts/analytics/usage_trends/components/charts_config.js
@@ -0,0 +1,106 @@
+import { s__, __ } from '~/locale';
+import query from '../graphql/queries/usage_count.query.graphql';
+
+const noDataMessage = s__('UsageTrends|No data available.');
+
+export default [
+ {
+ loadChartError: s__(
+ 'UsageTrends|Could not load the projects and groups chart. Please refresh the page to try again.',
+ ),
+ noDataMessage,
+ chartTitle: s__('UsageTrends|Total projects & groups'),
+ yAxisTitle: s__('UsageTrends|Total projects & groups'),
+ xAxisTitle: s__('UsageTrends|Month'),
+ queries: [
+ {
+ query,
+ title: s__('UsageTrends|Total projects'),
+ identifier: 'PROJECTS',
+ loadError: s__('UsageTrends|There was an error fetching the projects. Please try again.'),
+ },
+ {
+ query,
+ title: s__('UsageTrends|Total groups'),
+ identifier: 'GROUPS',
+ loadError: s__('UsageTrends|There was an error fetching the groups. Please try again.'),
+ },
+ ],
+ },
+ {
+ loadChartError: s__(
+ 'UsageTrends|Could not load the pipelines chart. Please refresh the page to try again.',
+ ),
+ noDataMessage,
+ chartTitle: s__('UsageTrends|Pipelines'),
+ yAxisTitle: s__('UsageTrends|Items'),
+ xAxisTitle: s__('UsageTrends|Month'),
+ queries: [
+ {
+ query,
+ title: s__('UsageTrends|Pipelines total'),
+ identifier: 'PIPELINES',
+ loadError: s__(
+ 'UsageTrends|There was an error fetching the total pipelines. Please try again.',
+ ),
+ },
+ {
+ query,
+ title: s__('UsageTrends|Pipelines succeeded'),
+ identifier: 'PIPELINES_SUCCEEDED',
+ loadError: s__(
+ 'UsageTrends|There was an error fetching the successful pipelines. Please try again.',
+ ),
+ },
+ {
+ query,
+ title: s__('UsageTrends|Pipelines failed'),
+ identifier: 'PIPELINES_FAILED',
+ loadError: s__(
+ 'UsageTrends|There was an error fetching the failed pipelines. Please try again.',
+ ),
+ },
+ {
+ query,
+ title: s__('UsageTrends|Pipelines canceled'),
+ identifier: 'PIPELINES_CANCELED',
+ loadError: s__(
+ 'UsageTrends|There was an error fetching the cancelled pipelines. Please try again.',
+ ),
+ },
+ {
+ query,
+ title: s__('UsageTrends|Pipelines skipped'),
+ identifier: 'PIPELINES_SKIPPED',
+ loadError: s__(
+ 'UsageTrends|There was an error fetching the skipped pipelines. Please try again.',
+ ),
+ },
+ ],
+ },
+ {
+ loadChartError: s__(
+ 'UsageTrends|Could not load the issues and merge requests chart. Please refresh the page to try again.',
+ ),
+ noDataMessage,
+ chartTitle: s__('UsageTrends|Issues & Merge Requests'),
+ yAxisTitle: s__('UsageTrends|Items'),
+ xAxisTitle: s__('UsageTrends|Month'),
+ queries: [
+ {
+ query,
+ title: __('Issues'),
+ identifier: 'ISSUES',
+ loadError: s__('UsageTrends|There was an error fetching the issues. Please try again.'),
+ },
+ {
+ query,
+ title: __('Merge requests'),
+ identifier: 'MERGE_REQUESTS',
+ loadError: s__(
+ 'UsageTrends|There was an error fetching the merge requests. Please try again.',
+ ),
+ },
+ ],
+ },
+];
diff --git a/app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
index f3779ed62e9..0630cca93ae 100644
--- a/app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue
+++ b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
@@ -1,15 +1,15 @@
<script>
+import * as Sentry from '@sentry/browser';
import MetricCard from '~/analytics/shared/components/metric_card.vue';
import { deprecatedCreateFlash as createFlash } from '~/flash';
-import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format';
+import { number } from '~/lib/utils/unit_format';
import { s__ } from '~/locale';
-import * as Sentry from '~/sentry/wrapper';
-import instanceStatisticsCountQuery from '../graphql/queries/instance_statistics_count.query.graphql';
+import usageTrendsCountQuery from '../graphql/queries/usage_trends_count.query.graphql';
const defaultPrecision = 0;
export default {
- name: 'InstanceCounts',
+ name: 'UsageCounts',
components: {
MetricCard,
},
@@ -20,12 +20,11 @@ export default {
},
apollo: {
counts: {
- query: instanceStatisticsCountQuery,
+ query: usageTrendsCountQuery,
update(data) {
return Object.entries(data).map(([key, obj]) => {
const label = this.$options.i18n.labels[key];
- const formatter = getFormatter(SUPPORTED_FORMATS.number);
- const value = obj.nodes?.length ? formatter(obj.nodes[0].count, defaultPrecision) : null;
+ const value = obj.nodes?.length ? number(obj.nodes[0].count, defaultPrecision) : null;
return {
key,
@@ -42,14 +41,14 @@ export default {
},
i18n: {
labels: {
- users: s__('InstanceStatistics|Users'),
- projects: s__('InstanceStatistics|Projects'),
- groups: s__('InstanceStatistics|Groups'),
- issues: s__('InstanceStatistics|Issues'),
- mergeRequests: s__('InstanceStatistics|Merge Requests'),
- pipelines: s__('InstanceStatistics|Pipelines'),
+ users: s__('UsageTrends|Users'),
+ projects: s__('UsageTrends|Projects'),
+ groups: s__('UsageTrends|Groups'),
+ issues: s__('UsageTrends|Issues'),
+ mergeRequests: s__('UsageTrends|Merge Requests'),
+ pipelines: s__('UsageTrends|Pipelines'),
},
- loadCountsError: s__('Could not load instance counts. Please refresh the page to try again.'),
+ loadCountsError: s__('Could not load usage counts. Please refresh the page to try again.'),
},
};
</script>
diff --git a/app/assets/javascripts/analytics/instance_statistics/components/instance_statistics_count_chart.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_trends_count_chart.vue
index e2defe0572d..8d7761694d1 100644
--- a/app/assets/javascripts/analytics/instance_statistics/components/instance_statistics_count_chart.vue
+++ b/app/assets/javascripts/analytics/usage_trends/components/usage_trends_count_chart.vue
@@ -1,21 +1,21 @@
<script>
import { GlAlert } from '@gitlab/ui';
import { GlLineChart } from '@gitlab/ui/dist/charts';
+import * as Sentry from '@sentry/browser';
import { some, every } from 'lodash';
import {
differenceInMonths,
formatDateAsMonth,
getDayDifference,
} from '~/lib/utils/datetime_utility';
-import * as Sentry from '~/sentry/wrapper';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import { TODAY, START_DATE } from '../constants';
import { getAverageByMonth, getEarliestDate, generateDataKeys } from '../utils';
-const QUERY_DATA_KEY = 'instanceStatisticsMeasurements';
+const QUERY_DATA_KEY = 'usageTrendsMeasurements';
export default {
- name: 'InstanceStatisticsCountChart',
+ name: 'UsageTrendsCountChart',
components: {
GlLineChart,
GlAlert,
diff --git a/app/assets/javascripts/analytics/instance_statistics/components/users_chart.vue b/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue
index 73940f028a1..09dfcddcb73 100644
--- a/app/assets/javascripts/analytics/instance_statistics/components/users_chart.vue
+++ b/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue
@@ -1,11 +1,11 @@
<script>
import { GlAlert } from '@gitlab/ui';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
+import * as Sentry from '@sentry/browser';
import produce from 'immer';
import { sortBy } from 'lodash';
import { formatDateAsMonth } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
-import * as Sentry from '~/sentry/wrapper';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import usersQuery from '../graphql/queries/users.query.graphql';
import { getAverageByMonth } from '../utils';
diff --git a/app/assets/javascripts/analytics/instance_statistics/constants.js b/app/assets/javascripts/analytics/usage_trends/constants.js
index 846c0ef408b..846c0ef408b 100644
--- a/app/assets/javascripts/analytics/instance_statistics/constants.js
+++ b/app/assets/javascripts/analytics/usage_trends/constants.js
diff --git a/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql
new file mode 100644
index 00000000000..2bde5973600
--- /dev/null
+++ b/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql
@@ -0,0 +1,4 @@
+fragment Count on UsageTrendsMeasurement {
+ count
+ recordedAt
+}
diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_count.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_count.query.graphql
index dd22a16cd51..2a5546efb68 100644
--- a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_count.query.graphql
+++ b/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_count.query.graphql
@@ -2,7 +2,7 @@
#import "../fragments/count.fragment.graphql"
query getCount($identifier: MeasurementIdentifier!, $first: Int, $after: String) {
- instanceStatisticsMeasurements(identifier: $identifier, first: $first, after: $after) {
+ usageTrendsMeasurements(identifier: $identifier, first: $first, after: $after) {
nodes {
...Count
}
diff --git a/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_trends_count.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_trends_count.query.graphql
new file mode 100644
index 00000000000..8cadcfae380
--- /dev/null
+++ b/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_trends_count.query.graphql
@@ -0,0 +1,34 @@
+#import "../fragments/count.fragment.graphql"
+
+query getInstanceCounts {
+ projects: usageTrendsMeasurements(identifier: PROJECTS, first: 1) {
+ nodes {
+ ...Count
+ }
+ }
+ groups: usageTrendsMeasurements(identifier: GROUPS, first: 1) {
+ nodes {
+ ...Count
+ }
+ }
+ users: usageTrendsMeasurements(identifier: USERS, first: 1) {
+ nodes {
+ ...Count
+ }
+ }
+ issues: usageTrendsMeasurements(identifier: ISSUES, first: 1) {
+ nodes {
+ ...Count
+ }
+ }
+ mergeRequests: usageTrendsMeasurements(identifier: MERGE_REQUESTS, first: 1) {
+ nodes {
+ ...Count
+ }
+ }
+ pipelines: usageTrendsMeasurements(identifier: PIPELINES, first: 1) {
+ nodes {
+ ...Count
+ }
+ }
+}
diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/users.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/users.query.graphql
index 6235e36eb89..7c02ac49a42 100644
--- a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/users.query.graphql
+++ b/app/assets/javascripts/analytics/usage_trends/graphql/queries/users.query.graphql
@@ -2,7 +2,7 @@
#import "../fragments/count.fragment.graphql"
query getUsersCount($first: Int, $after: String) {
- users: instanceStatisticsMeasurements(identifier: USERS, first: $first, after: $after) {
+ users: usageTrendsMeasurements(identifier: USERS, first: $first, after: $after) {
nodes {
...Count
}
diff --git a/app/assets/javascripts/analytics/instance_statistics/index.js b/app/assets/javascripts/analytics/usage_trends/index.js
index 0d7dcf6ace8..d1880b09f15 100644
--- a/app/assets/javascripts/analytics/instance_statistics/index.js
+++ b/app/assets/javascripts/analytics/usage_trends/index.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import InstanceStatisticsApp from './components/app.vue';
+import UsageTrendsApp from './components/app.vue';
Vue.use(VueApollo);
@@ -10,7 +10,7 @@ const apolloProvider = new VueApollo({
});
export default () => {
- const el = document.getElementById('js-instance-statistics-app');
+ const el = document.getElementById('js-usage-trends-app');
if (!el) return false;
@@ -18,7 +18,7 @@ export default () => {
el,
apolloProvider,
render(h) {
- return h(InstanceStatisticsApp);
+ return h(UsageTrendsApp);
},
});
};
diff --git a/app/assets/javascripts/analytics/instance_statistics/utils.js b/app/assets/javascripts/analytics/usage_trends/utils.js
index 396962ffad6..91907877ed6 100644
--- a/app/assets/javascripts/analytics/instance_statistics/utils.js
+++ b/app/assets/javascripts/analytics/usage_trends/utils.js
@@ -41,8 +41,8 @@ export function getAverageByMonth(items = [], options = {}) {
}
/**
- * Takes an array of instance counts and returns the last item in the list
- * @param {Array} arr array of instance counts in the form { count: Number, recordedAt: date String }
+ * Takes an array of usage counts and returns the last item in the list
+ * @param {Array} arr array of usage counts in the form { count: Number, recordedAt: date String }
* @return {String} the 'recordedAt' value of the earliest item
*/
export const getEarliestDate = (arr = []) => {
@@ -54,7 +54,7 @@ export const getEarliestDate = (arr = []) => {
* Takes an array of queries and produces an object with the query identifier as key
* and a supplied defaultValue as its value
* @param {Array} queries array of chart query configs,
- * see ./analytics/instance_statistics/components/charts_config.js
+ * see ./analytics/usage_trends/components/charts_config.js
* @param {any} defaultValue value to set each identifier to
* @return {Object} key value pair of the form { queryIdentifier: defaultValue }
*/
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index c7e6b98a934..48005787d81 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -24,6 +24,7 @@ const Api = {
projectPackagesPath: '/api/:version/projects/:id/packages',
projectPackagePath: '/api/:version/projects/:id/packages/:package_id',
groupProjectsPath: '/api/:version/groups/:id/projects.json',
+ groupSharePath: '/api/:version/groups/:id/share',
projectsPath: '/api/:version/projects.json',
projectPath: '/api/:version/projects/:id',
forkedProjectsPath: '/api/:version/projects/:id/forks',
@@ -39,6 +40,7 @@ const Api = {
projectRunnersPath: '/api/:version/projects/:id/runners',
projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches',
projectSearchPath: '/api/:version/projects/:id/search',
+ projectSharePath: '/api/:version/projects/:id/share',
projectMilestonesPath: '/api/:version/projects/:id/milestones',
projectIssuePath: '/api/:version/projects/:id/issues/:issue_iid',
mergeRequestsPath: '/api/:version/merge_requests',
@@ -365,6 +367,16 @@ const Api = {
});
},
+ projectShareWithGroup(id, options = {}) {
+ const url = Api.buildUrl(Api.projectSharePath).replace(':id', encodeURIComponent(id));
+
+ return axios.post(url, {
+ expires_at: options.expires_at,
+ group_access: options.group_access,
+ group_id: options.group_id,
+ });
+ },
+
projectMilestones(id, params = {}) {
const url = Api.buildUrl(Api.projectMilestonesPath).replace(':id', encodeURIComponent(id));
@@ -426,6 +438,16 @@ const Api = {
});
},
+ groupShareWithGroup(id, options = {}) {
+ const url = Api.buildUrl(Api.groupSharePath).replace(':id', encodeURIComponent(id));
+
+ return axios.post(url, {
+ expires_at: options.expires_at,
+ group_access: options.group_access,
+ group_id: options.group_id,
+ });
+ },
+
commit(id, sha, params = {}) {
const url = Api.buildUrl(this.commitPath)
.replace(':id', encodeURIComponent(id))
@@ -446,7 +468,7 @@ const Api = {
applySuggestion(id, message = '') {
const url = Api.buildUrl(Api.applySuggestionPath).replace(':id', encodeURIComponent(id));
- const params = gon.features?.suggestionsCustomCommit ? { commit_message: message } : false;
+ const params = { commit_message: message };
return axios.put(url, params);
},
diff --git a/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
index 0e589d98668..55642aa64db 100644
--- a/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
+++ b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
@@ -162,7 +162,7 @@ export default {
:href="profileAccountPath"
:disabled="proceedButtonDisabled"
:title="$options.i18n.proceedButton"
- variant="success"
+ variant="confirm"
data-qa-selector="proceed_button"
data-track-event="click_button"
:data-track-label="`${$options.trackingLabelPrefix}proceed_button`"
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index 6fb90551ed7..dbdc7e43d2d 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -5,12 +5,14 @@ import $ from 'jquery';
import Cookies from 'js-cookie';
import { uniq } from 'lodash';
import * as Emoji from '~/emoji';
+import { scrollToElement } from '~/lib/utils/common_utils';
import { dispose, fixTitle } from '~/tooltips';
import { deprecatedCreateFlash as flash } from './flash';
import axios from './lib/utils/axios_utils';
import { isInVueNoteablePage } from './lib/utils/dom_utils';
import { __ } from './locale';
+window.axios = axios;
const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd';
const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd';
@@ -495,12 +497,7 @@ export class AwardsHandler {
}
scrollToAwards() {
- const options = {
- scrollTop: $('.awards').offset().top - 110,
- };
-
- // eslint-disable-next-line no-jquery/no-animate
- return $('body, html').animate(options, 200);
+ scrollToElement('.awards', { offset: -110 });
}
addEmojiToFrequentlyUsedList(emoji) {
diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue
index c3512773457..9e5d70075f3 100644
--- a/app/assets/javascripts/badges/components/badge.vue
+++ b/app/assets/javascripts/badges/components/badge.vue
@@ -96,7 +96,7 @@ export default {
v-gl-tooltip.hover
:title="s__('Badges|Reload badge image')"
category="tertiary"
- variant="success"
+ variant="confirm"
type="button"
icon="retry"
size="small"
diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue
index 20541ad8ccc..b65a8b4fa9c 100644
--- a/app/assets/javascripts/badges/components/badge_form.vue
+++ b/app/assets/javascripts/badges/components/badge_form.vue
@@ -225,7 +225,7 @@ export default {
<gl-button
:loading="isSaving"
type="submit"
- variant="success"
+ variant="confirm"
category="primary"
data-testid="saveEditing"
>
@@ -233,7 +233,7 @@ export default {
</gl-button>
</div>
<div v-else class="form-group">
- <gl-button :loading="isSaving" type="submit" variant="success" category="primary">
+ <gl-button :loading="isSaving" type="submit" variant="confirm" category="primary">
{{ s__('Badges|Add badge') }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/batch_comments/components/publish_button.vue b/app/assets/javascripts/batch_comments/components/publish_button.vue
index 8568dba5947..2a7be605003 100644
--- a/app/assets/javascripts/batch_comments/components/publish_button.vue
+++ b/app/assets/javascripts/batch_comments/components/publish_button.vue
@@ -40,7 +40,8 @@ export default {
<template>
<gl-button
:loading="isPublishing"
- class="js-publish-draft-button qa-submit-review"
+ class="js-publish-draft-button"
+ data-qa-selector="submit_review_button"
:category="category"
:variant="variant"
@click="onClick"
diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue
index 035d6f4e0ab..9ffc5ee34cf 100644
--- a/app/assets/javascripts/batch_comments/components/review_bar.vue
+++ b/app/assets/javascripts/batch_comments/components/review_bar.vue
@@ -27,7 +27,10 @@ export default {
<template>
<div v-show="draftsCount > 0">
<nav class="review-bar-component">
- <div class="review-bar-content qa-review-bar d-flex gl-justify-content-end">
+ <div
+ class="review-bar-content d-flex gl-justify-content-end"
+ data-qa-selector="review_bar_content"
+ >
<preview-dropdown />
<publish-button class="gl-ml-3" show-count />
</div>
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
index 36fef06eeff..88be64d0a1a 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
@@ -1,3 +1,4 @@
+import { isEmpty } from 'lodash';
import { deprecatedCreateFlash as flash } from '~/flash';
import { scrollToElement } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
@@ -88,18 +89,23 @@ export const updateDiscussionsAfterPublish = async ({ dispatch, getters, rootGet
export const updateDraft = (
{ commit, getters },
{ note, noteText, resolveDiscussion, position, callback },
-) =>
- service
- .update(getters.getNotesData.draftsPath, {
- draftId: note.id,
- note: noteText,
- resolveDiscussion,
- position: JSON.stringify(position),
- })
+) => {
+ const params = {
+ draftId: note.id,
+ note: noteText,
+ resolveDiscussion,
+ };
+ // Stringifying an empty object yields `{}` which breaks graphql queries
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/298827
+ if (!isEmpty(position)) params.position = JSON.stringify(position);
+
+ return service
+ .update(getters.getNotesData.draftsPath, params)
.then((res) => res.data)
.then((data) => commit(types.RECEIVE_DRAFT_UPDATE_SUCCESS, data))
.then(callback)
.catch(() => flash(__('An error occurred while updating the comment')));
+};
export const scrollToDraft = ({ dispatch, rootGetters }, draft) => {
const discussion = draft.discussion_id && rootGetters.getDiscussion(draft.discussion_id);
diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js
index a31bcc2cb41..de248340738 100644
--- a/app/assets/javascripts/behaviors/copy_to_clipboard.js
+++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js
@@ -1,31 +1,27 @@
import Clipboard from 'clipboard';
import $ from 'jquery';
import { sprintf, __ } from '~/locale';
-import { fixTitle, show } from '~/tooltips';
+import { fixTitle, add, show, once } from '~/tooltips';
function showTooltip(target, title) {
- const { originalTitle } = target.dataset;
- const hideTooltip = () => {
- target.removeEventListener('mouseout', hideTooltip);
- setTimeout(() => {
+ const { title: originalTitle } = target.dataset;
+
+ once('hidden', (tooltip) => {
+ if (tooltip.target === target) {
target.setAttribute('title', originalTitle);
fixTitle(target);
- }, 100);
- };
+ }
+ });
target.setAttribute('title', title);
-
fixTitle(target);
show(target);
-
- target.addEventListener('mouseout', hideTooltip);
+ setTimeout(() => target.blur(), 1000);
}
function genericSuccess(e) {
// Clear the selection and blur the trigger so it loses its border
e.clearSelection();
- $(e.trigger).blur();
-
showTooltip(e.trigger, __('Copied'));
}
@@ -88,24 +84,8 @@ export default function initCopyToClipboard() {
* @param {HTMLElement} btnElement
*/
export function clickCopyToClipboardButton(btnElement) {
- const $btnElement = $(btnElement);
-
// Ensure the button has already been tooltip'd.
- // If the use hasn't yet interacted (i.e. hovered or clicked)
- // with the button, Bootstrap hasn't yet initialized
- // the tooltip, and its `data-original-title` will be `undefined`.
- // This value is used in the functions above.
- $btnElement.tooltip();
- btnElement.dispatchEvent(new MouseEvent('mouseover'));
+ add([btnElement], { show: true });
btnElement.click();
-
- // Manually trigger the necessary events to hide the
- // button's tooltip and allow the button to perform its
- // tooltip cleanup (updating the title from "Copied" back
- // to its original title, "Copy branch name").
- setTimeout(() => {
- btnElement.dispatchEvent(new MouseEvent('mouseout'));
- $btnElement.tooltip('hide');
- }, 2000);
}
diff --git a/app/assets/javascripts/behaviors/markdown/render_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js
index 5479866c99a..8238f5523f3 100644
--- a/app/assets/javascripts/behaviors/markdown/render_math.js
+++ b/app/assets/javascripts/behaviors/markdown/render_math.js
@@ -1,6 +1,6 @@
-import { deprecatedCreateFlash as flash } from '~/flash';
+import { spriteIcon } from '~/lib/utils/common_utils';
import { differenceInMilliseconds } from '~/lib/utils/datetime_utility';
-import { s__, sprintf } from '~/locale';
+import { s__ } from '~/locale';
// Renders math using KaTeX in any element with the
// `js-render-math` class
@@ -13,30 +13,10 @@ import { s__, sprintf } from '~/locale';
const MAX_MATH_CHARS = 1000;
const MAX_RENDER_TIME_MS = 2000;
-// These messages might be used with inline errors in the future. Keep them around. For now, we will
-// display a single error message using flash().
-
-// const CHAR_LIMIT_EXCEEDED_MSG = sprintf(
-// s__(
-// 'math|The following math is too long. For performance reasons, math blocks are limited to %{maxChars} characters. Try splitting up this block, or include an image instead.',
-// ),
-// { maxChars: MAX_MATH_CHARS },
-// );
-// const RENDER_TIME_EXCEEDED_MSG = s__(
-// "math|The math in this entry is taking too long to render. Any math below this point won't be shown. Consider splitting it among multiple entries.",
-// );
-
-const RENDER_FLASH_MSG = sprintf(
- s__(
- 'math|The math in this entry is taking too long to render and may not be displayed as expected. For performance reasons, math blocks are also limited to %{maxChars} characters. Consider splitting up large formulae, splitting math blocks among multiple entries, or using an image instead.',
- ),
- { maxChars: MAX_MATH_CHARS },
-);
-
// Wait for the browser to reflow the layout. Reflowing SVG takes time.
// This has to wrap the inner function, otherwise IE/Edge throw "invalid calling object".
const waitForReflow = (fn) => {
- window.requestAnimationFrame(fn);
+ window.requestIdleCallback(fn);
};
/**
@@ -67,37 +47,69 @@ class SafeMathRenderer {
this.renderElement = this.renderElement.bind(this);
this.render = this.render.bind(this);
+ this.attachEvents = this.attachEvents.bind(this);
}
- renderElement() {
- if (!this.queue.length) {
+ renderElement(chosenEl) {
+ if (!this.queue.length && !chosenEl) {
return;
}
- const el = this.queue.shift();
+ const el = chosenEl || this.queue.shift();
+ const forceRender = Boolean(chosenEl);
const text = el.textContent;
el.removeAttribute('style');
-
- if (this.totalMS >= MAX_RENDER_TIME_MS || text.length > MAX_MATH_CHARS) {
- if (!this.flashShown) {
- flash(RENDER_FLASH_MSG);
- this.flashShown = true;
- }
-
+ if (!forceRender && (this.totalMS >= MAX_RENDER_TIME_MS || text.length > MAX_MATH_CHARS)) {
// Show unrendered math code
+ const wrapperElement = document.createElement('div');
const codeElement = document.createElement('pre');
+
codeElement.className = 'code';
codeElement.textContent = el.textContent;
- el.parentNode.replaceChild(codeElement, el);
+
+ const { parentNode } = el;
+ parentNode.replaceChild(wrapperElement, el);
+
+ const html = `
+ <div class="alert gl-alert gl-alert-warning alert-dismissible lazy-render-math-container js-lazy-render-math-container fade show" role="alert">
+ ${spriteIcon('warning', 'text-warning-600 s16 gl-alert-icon')}
+ <div class="display-flex gl-alert-content">
+ <div>${s__(
+ 'math|Displaying this math block may cause performance issues on this page',
+ )}</div>
+ <div class="gl-alert-actions">
+ <button class="js-lazy-render-math btn gl-alert-action btn-primary btn-md gl-button">Display anyway</button>
+ </div>
+ </div>
+ <button type="button" class="close" data-dismiss="alert" aria-label="Close">
+ ${spriteIcon('close', 's16')}
+ </button>
+ </div>
+ `;
+
+ if (!wrapperElement.classList.contains('lazy-alert-shown')) {
+ wrapperElement.innerHTML = html;
+ wrapperElement.append(codeElement);
+ wrapperElement.classList.add('lazy-alert-shown');
+ }
// Render the next math
this.renderElement();
} else {
this.startTime = Date.now();
+ /* Get the correct reference to the display container when:
+ * a.) Happy path: when the math block is present, and
+ * b.) When we've replace the block with <pre> for lazy rendering
+ */
+ let displayContainer = el;
+ if (el.tagName === 'PRE') {
+ displayContainer = el.parentElement;
+ }
+
try {
- el.innerHTML = this.katex.renderToString(text, {
+ displayContainer.innerHTML = this.katex.renderToString(text, {
displayMode: el.getAttribute('data-math-style') === 'display',
throwOnError: true,
maxSize: 20,
@@ -135,6 +147,22 @@ class SafeMathRenderer {
// and less prone to timeouts.
setTimeout(this.renderElement, 400);
}
+
+ attachEvents() {
+ document.body.addEventListener('click', (event) => {
+ if (!event.target.classList.contains('js-lazy-render-math')) {
+ return;
+ }
+
+ const parent = event.target.closest('.js-lazy-render-math-container');
+
+ const pre = parent.nextElementSibling;
+
+ parent.remove();
+
+ this.renderElement(pre);
+ });
+ }
}
export default function renderMath($els) {
@@ -146,6 +174,7 @@ export default function renderMath($els) {
.then(([katex]) => {
const renderer = new SafeMathRenderer($els.get(), katex);
renderer.render();
+ renderer.attachEvents();
})
.catch(() => {});
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
index 0513e807ed6..a8fe00d26e6 100644
--- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js
+++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
@@ -1,91 +1,80 @@
-import { flatten } from 'lodash';
+import { memoize } from 'lodash';
import AccessorUtilities from '~/lib/utils/accessor';
import { s__ } from '~/locale';
-import { shouldDisableShortcuts } from './shortcuts_toggle';
-export const LOCAL_STORAGE_KEY = 'gl-keyboard-shortcuts-customizations';
-
-let parsedCustomizations = {};
-const localStorageIsSafe = AccessorUtilities.isLocalStorageAccessSafe();
+const isCustomizable = (command) =>
+ 'customizable' in command ? Boolean(command.customizable) : true;
-if (localStorageIsSafe) {
- try {
- parsedCustomizations = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) || '{}');
- } catch (e) {
- /* do nothing */
- }
-}
+export const LOCAL_STORAGE_KEY = 'gl-keyboard-shortcuts-customizations';
/**
- * A map of command => keys of all keyboard shortcuts
- * that have been customized by the user.
+ * @returns { Object.<string, string[]> } A map of command ID => keys of all
+ * keyboard shortcuts that have been customized by the user. These
+ * customizations are fetched from `localStorage`. This function is memoized,
+ * so its return value will not reflect changes made to the `localStorage` data
+ * after it has been called once.
*
* @example
* { "globalShortcuts.togglePerformanceBar": ["p e r f"] }
- *
- * @type { Object.<string, string[]> }
*/
-export const customizations = parsedCustomizations;
+export const getCustomizations = memoize(() => {
+ let parsedCustomizations = {};
+ const localStorageIsSafe = AccessorUtilities.isLocalStorageAccessSafe();
+
+ if (localStorageIsSafe) {
+ try {
+ parsedCustomizations = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) || '{}');
+ } catch (e) {
+ /* do nothing */
+ }
+ }
+
+ return parsedCustomizations;
+});
// All available commands
-export const TOGGLE_PERFORMANCE_BAR = 'globalShortcuts.togglePerformanceBar';
-export const TOGGLE_CANARY = 'globalShortcuts.toggleCanary';
+export const TOGGLE_PERFORMANCE_BAR = {
+ id: 'globalShortcuts.togglePerformanceBar',
+ description: s__('KeyboardShortcuts|Toggle the Performance Bar'),
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ defaultKeys: ['p b'],
+};
-/** All keybindings, grouped and ordered with descriptions */
-export const keybindingGroups = [
- {
- groupId: 'globalShortcuts',
- name: s__('KeyboardShortcuts|Global Shortcuts'),
- keybindings: [
- {
- description: s__('KeyboardShortcuts|Toggle the Performance Bar'),
- command: TOGGLE_PERFORMANCE_BAR,
- // eslint-disable-next-line @gitlab/require-i18n-strings
- defaultKeys: ['p b'],
- },
- {
- description: s__('KeyboardShortcuts|Toggle GitLab Next'),
- command: TOGGLE_CANARY,
- // eslint-disable-next-line @gitlab/require-i18n-strings
- defaultKeys: ['g x'],
- },
- ],
- },
-]
+export const TOGGLE_CANARY = {
+ id: 'globalShortcuts.toggleCanary',
+ description: s__('KeyboardShortcuts|Toggle GitLab Next'),
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ defaultKeys: ['g x'],
+};
- // For each keybinding object, add a `customKeys` property populated with the
- // user's custom keybindings (if the command has been customized).
- // `customKeys` will be `undefined` if the command hasn't been customized.
- .map((group) => {
- return {
- ...group,
- keybindings: group.keybindings.map((binding) => ({
- ...binding,
- customKeys: customizations[binding.command],
- })),
- };
- });
+export const WEB_IDE_COMMIT = {
+ id: 'webIDE.commit',
+ description: s__('KeyboardShortcuts|Commit (when editing commit message)'),
+ defaultKeys: ['mod+enter'],
+ customizable: false,
+};
-/**
- * A simple map of command => keys. All user customizations are included in this map.
- * This mapping is used to simplify `keysFor` below.
- *
- * @example
- * { "globalShortcuts.togglePerformanceBar": ["p e r f"] }
- */
-const commandToKeys = flatten(keybindingGroups.map((group) => group.keybindings)).reduce(
- (acc, binding) => {
- acc[binding.command] = binding.customKeys || binding.defaultKeys;
- return acc;
- },
- {},
-);
+// All keybinding groups
+export const GLOBAL_SHORTCUTS_GROUP = {
+ id: 'globalShortcuts',
+ name: s__('KeyboardShortcuts|Global Shortcuts'),
+ keybindings: [TOGGLE_PERFORMANCE_BAR, TOGGLE_CANARY],
+};
+
+export const WEB_IDE_GROUP = {
+ id: 'webIDE',
+ name: s__('KeyboardShortcuts|Web IDE'),
+ keybindings: [WEB_IDE_COMMIT],
+};
+
+/** All keybindings, grouped and ordered with descriptions */
+export const keybindingGroups = [GLOBAL_SHORTCUTS_GROUP, WEB_IDE_GROUP];
/**
* Gets keyboard shortcuts associated with a command
*
- * @param {string} command The command string. All command
- * strings are available as imports from this file.
+ * @param {string} command The command object. All command
+ * objects are available as imports from this file.
*
* @returns {string[]} An array of keyboard shortcut strings bound to the command
*
@@ -95,9 +84,11 @@ const commandToKeys = flatten(keybindingGroups.map((group) => group.keybindings)
* Mousetrap.bind(keysFor(TOGGLE_PERFORMANCE_BAR), handler);
*/
export const keysFor = (command) => {
- if (shouldDisableShortcuts()) {
- return [];
+ if (!isCustomizable(command)) {
+ // if the command is defined with `customizable: false`,
+ // don't allow this command to be customized.
+ return command.defaultKeys;
}
- return commandToKeys[command];
+ return getCustomizations()[command.id] || command.defaultKeys;
};
diff --git a/app/assets/javascripts/behaviors/toggler_behavior.js b/app/assets/javascripts/behaviors/toggler_behavior.js
index 4b63143c4ba..30424fee46a 100644
--- a/app/assets/javascripts/behaviors/toggler_behavior.js
+++ b/app/assets/javascripts/behaviors/toggler_behavior.js
@@ -30,7 +30,7 @@ $(() => {
}
$('body').on('click', '.js-toggle-button', function toggleButton(e) {
- e.currentTarget.classList.toggle(e.currentTarget.dataset.toggleOpenClass || 'open');
+ e.currentTarget.classList.toggle(e.currentTarget.dataset.toggleOpenClass || 'selected');
toggleContainer($(this).closest('.js-toggle-container'));
const targetTag = e.currentTarget.tagName.toLowerCase();
diff --git a/app/assets/javascripts/blob/blob_file_dropzone.js b/app/assets/javascripts/blob/blob_file_dropzone.js
index e72c5c90986..470c679b8ba 100644
--- a/app/assets/javascripts/blob/blob_file_dropzone.js
+++ b/app/assets/javascripts/blob/blob_file_dropzone.js
@@ -83,6 +83,7 @@ export default class BlobFileDropzone {
submitButton.on('click', (e) => {
e.preventDefault();
e.stopPropagation();
+
if (dropzone[0].dropzone.getQueuedFiles().length === 0) {
// eslint-disable-next-line no-alert
alert(__('Please select a file'));
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index b4cd0d5d875..4741152afce 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -62,6 +62,7 @@ export default class BlobViewer {
this.switcher = document.querySelector('.js-blob-viewer-switcher');
this.switcherBtns = document.querySelectorAll('.js-blob-viewer-switch-btn');
this.copySourceBtn = document.querySelector('.js-copy-blob-source-btn');
+ this.copySourceBtnTooltip = document.querySelector('.js-copy-blob-source-btn-tooltip');
this.simpleViewer = this.$fileHolder[0].querySelector('.blob-viewer[data-type="simple"]');
this.richViewer = this.$fileHolder[0].querySelector('.blob-viewer[data-type="rich"]');
@@ -109,23 +110,23 @@ export default class BlobViewer {
toggleCopyButtonState() {
if (!this.copySourceBtn) return;
if (this.simpleViewer.getAttribute('data-loaded')) {
- this.copySourceBtn.setAttribute('title', __('Copy file contents'));
+ this.copySourceBtnTooltip.setAttribute('title', __('Copy file contents'));
this.copySourceBtn.classList.remove('disabled');
} else if (this.activeViewer === this.simpleViewer) {
- this.copySourceBtn.setAttribute(
+ this.copySourceBtnTooltip.setAttribute(
'title',
__('Wait for the file to load to copy its contents'),
);
this.copySourceBtn.classList.add('disabled');
} else {
- this.copySourceBtn.setAttribute(
+ this.copySourceBtnTooltip.setAttribute(
'title',
__('Switch to the source to copy the file contents'),
);
this.copySourceBtn.classList.add('disabled');
}
- fixTitle($(this.copySourceBtn));
+ fixTitle($(this.copySourceBtnTooltip));
}
switchToViewer(name) {
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
index 13ad820477f..2cd25f58770 100644
--- a/app/assets/javascripts/boards/boards_util.js
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -36,11 +36,11 @@ export function formatIssue(issue) {
}
export function formatListIssues(listIssues) {
- const issues = {};
- let listIssuesCount;
+ const boardItems = {};
+ let listItemsCount;
const listData = listIssues.nodes.reduce((map, list) => {
- listIssuesCount = list.issues.count;
+ listItemsCount = list.issues.count;
let sortedIssues = list.issues.edges.map((issueNode) => ({
...issueNode.node,
}));
@@ -58,14 +58,14 @@ export function formatListIssues(listIssues) {
assignees: i.assignees?.nodes || [],
};
- issues[id] = listIssue;
+ boardItems[id] = listIssue;
return id;
}),
};
}, {});
- return { listData, issues, listIssuesCount };
+ return { listData, boardItems, listItemsCount };
}
export function formatListsPageInfo(lists) {
@@ -113,31 +113,31 @@ export function formatIssueInput(issueInput, boardConfig) {
};
}
-export function moveIssueListHelper(issue, fromList, toList) {
- const updatedIssue = issue;
+export function moveItemListHelper(item, fromList, toList) {
+ const updatedItem = item;
if (
toList.listType === ListType.label &&
- !updatedIssue.labels.find((label) => label.id === toList.label.id)
+ !updatedItem.labels.find((label) => label.id === toList.label.id)
) {
- updatedIssue.labels.push(toList.label);
+ updatedItem.labels.push(toList.label);
}
if (fromList?.label && fromList.listType === ListType.label) {
- updatedIssue.labels = updatedIssue.labels.filter((label) => fromList.label.id !== label.id);
+ updatedItem.labels = updatedItem.labels.filter((label) => fromList.label.id !== label.id);
}
if (
toList.listType === ListType.assignee &&
- !updatedIssue.assignees.find((assignee) => assignee.id === toList.assignee.id)
+ !updatedItem.assignees.find((assignee) => assignee.id === toList.assignee.id)
) {
- updatedIssue.assignees.push(toList.assignee);
+ updatedItem.assignees.push(toList.assignee);
}
if (fromList?.assignee && fromList.listType === ListType.assignee) {
- updatedIssue.assignees = updatedIssue.assignees.filter(
+ updatedItem.assignees = updatedItem.assignees.filter(
(assignee) => assignee.id !== fromList.assignee.id,
);
}
- return updatedIssue;
+ return updatedItem;
}
export function isListDraggable(list) {
diff --git a/app/assets/javascripts/boards/components/board_add_new_column.vue b/app/assets/javascripts/boards/components/board_add_new_column.vue
new file mode 100644
index 00000000000..3c7c792b787
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_add_new_column.vue
@@ -0,0 +1,143 @@
+<script>
+import {
+ GlFormRadio,
+ GlFormRadioGroup,
+ GlLabel,
+ GlTooltipDirective as GlTooltip,
+} from '@gitlab/ui';
+import { mapActions, mapGetters, mapState } from 'vuex';
+import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue';
+import { ListType } from '~/boards/constants';
+import boardsStore from '~/boards/stores/boards_store';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { isScopedLabel } from '~/lib/utils/common_utils';
+
+export default {
+ components: {
+ BoardAddNewColumnForm,
+ GlFormRadio,
+ GlFormRadioGroup,
+ GlLabel,
+ },
+ directives: {
+ GlTooltip,
+ },
+ inject: ['scopedLabelsAvailable'],
+ data() {
+ return {
+ selectedId: null,
+ };
+ },
+ computed: {
+ ...mapState(['labels', 'labelsLoading']),
+ ...mapGetters(['getListByLabelId', 'shouldUseGraphQL']),
+ selectedLabel() {
+ if (!this.selectedId) {
+ return null;
+ }
+ return this.labels.find(({ id }) => id === this.selectedId);
+ },
+ columnForSelected() {
+ return this.getListByLabelId(this.selectedId);
+ },
+ },
+ created() {
+ this.filterItems();
+ },
+ methods: {
+ ...mapActions(['createList', 'fetchLabels', 'highlightList', 'setAddColumnFormVisibility']),
+ highlight(listId) {
+ if (this.shouldUseGraphQL) {
+ this.highlightList(listId);
+ } else {
+ const list = boardsStore.state.lists.find(({ id }) => id === listId);
+ list.highlighted = true;
+ setTimeout(() => {
+ list.highlighted = false;
+ }, 2000);
+ }
+ },
+ addList() {
+ if (!this.selectedLabel) {
+ return;
+ }
+
+ this.setAddColumnFormVisibility(false);
+
+ if (this.columnForSelected) {
+ const listId = this.columnForSelected.id;
+ this.highlight(listId);
+ return;
+ }
+
+ if (this.shouldUseGraphQL) {
+ this.createList({ labelId: this.selectedId });
+ } else {
+ const listObj = {
+ labelId: getIdFromGraphQLId(this.selectedId),
+ title: this.selectedLabel.title,
+ position: boardsStore.state.lists.length - 2,
+ list_type: ListType.label,
+ label: this.selectedLabel,
+ };
+
+ boardsStore.new(listObj);
+ }
+ },
+
+ filterItems(searchTerm) {
+ this.fetchLabels(searchTerm);
+ },
+
+ showScopedLabels(label) {
+ return this.scopedLabelsAvailable && isScopedLabel(label);
+ },
+ },
+};
+</script>
+
+<template>
+ <board-add-new-column-form
+ :loading="labelsLoading"
+ :form-description="__('A label list displays issues with the selected label.')"
+ :search-label="__('Select label')"
+ :search-placeholder="__('Search labels')"
+ :selected-id="selectedId"
+ @filter-items="filterItems"
+ @add-list="addList"
+ >
+ <template slot="selected">
+ <gl-label
+ v-if="selectedLabel"
+ v-gl-tooltip
+ :title="selectedLabel.title"
+ :description="selectedLabel.description"
+ :background-color="selectedLabel.color"
+ :scoped="showScopedLabels(selectedLabel)"
+ />
+ </template>
+
+ <template slot="items">
+ <gl-form-radio-group
+ v-if="labels.length > 0"
+ v-model="selectedId"
+ class="gl-overflow-y-auto gl-px-5 gl-pt-3"
+ >
+ <label
+ v-for="label in labels"
+ :key="label.id"
+ class="gl-display-flex gl-flex-align-items-center gl-mb-5 gl-font-weight-normal"
+ >
+ <gl-form-radio :value="label.id" class="gl-mb-0" />
+ <span
+ class="dropdown-label-box gl-top-0"
+ :style="{
+ backgroundColor: label.color,
+ }"
+ ></span>
+ <span>{{ label.title }}</span>
+ </label>
+ </gl-form-radio-group>
+ </template>
+ </board-add-new-column-form>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_add_new_column_form.vue b/app/assets/javascripts/boards/components/board_add_new_column_form.vue
new file mode 100644
index 00000000000..d85343a5390
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_add_new_column_form.vue
@@ -0,0 +1,131 @@
+<script>
+import { GlButton, GlFormGroup, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui';
+import { mapActions } from 'vuex';
+import { __ } from '~/locale';
+
+export default {
+ i18n: {
+ add: __('Add to board'),
+ cancel: __('Cancel'),
+ newList: __('New list'),
+ noneSelected: __('None'),
+ noResults: __('No matching results'),
+ selected: __('Selected'),
+ },
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlSearchBoxByType,
+ GlSkeletonLoader,
+ },
+ props: {
+ loading: {
+ type: Boolean,
+ required: true,
+ },
+ formDescription: {
+ type: String,
+ required: true,
+ },
+ searchLabel: {
+ type: String,
+ required: true,
+ },
+ searchPlaceholder: {
+ type: String,
+ required: true,
+ },
+ selectedId: {
+ type: [Number, String],
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ searchValue: '',
+ };
+ },
+ methods: {
+ ...mapActions(['setAddColumnFormVisibility']),
+ },
+};
+</script>
+
+<template>
+ <div
+ class="board-add-new-list board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal gl-flex-shrink-0"
+ data-testid="board-add-new-column"
+ data-qa-selector="board_add_new_list"
+ >
+ <div
+ class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-white"
+ >
+ <h3
+ class="gl-font-base gl-px-5 gl-py-5 gl-m-0 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
+ data-testid="board-add-column-form-title"
+ >
+ {{ $options.i18n.newList }}
+ </h3>
+
+ <div class="gl-display-flex gl-flex-direction-column gl-h-full gl-overflow-hidden">
+ <slot name="select-list-type">
+ <div class="gl-mb-5"></div>
+ </slot>
+
+ <p class="gl-px-5">{{ formDescription }}</p>
+
+ <div class="gl-px-5 gl-pb-4">
+ <label class="gl-mb-2">{{ $options.i18n.selected }}</label>
+ <slot name="selected">
+ <div class="gl-text-gray-500">{{ $options.i18n.noneSelected }}</div>
+ </slot>
+ </div>
+
+ <gl-form-group
+ class="gl-mx-5 gl-mb-3"
+ :label="searchLabel"
+ label-for="board-available-column-entities"
+ >
+ <gl-search-box-by-type
+ id="board-available-column-entities"
+ v-model="searchValue"
+ debounce="250"
+ :placeholder="searchPlaceholder"
+ @input="$emit('filter-items', $event)"
+ />
+ </gl-form-group>
+
+ <div v-if="loading" class="gl-px-5">
+ <gl-skeleton-loader :width="500" :height="172">
+ <rect width="480" height="20" x="10" y="15" rx="4" />
+ <rect width="380" height="20" x="10" y="50" rx="4" />
+ <rect width="430" height="20" x="10" y="85" rx="4" />
+ </gl-skeleton-loader>
+ </div>
+
+ <slot v-else name="items">
+ <p class="gl-mx-5">{{ $options.i18n.noResults }}</p>
+ </slot>
+ </div>
+ <div
+ class="gl-display-flex gl-p-3 gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
+ >
+ <gl-button
+ data-testid="cancelAddNewColumn"
+ class="gl-ml-auto gl-mr-3"
+ @click="setAddColumnFormVisibility(false)"
+ >{{ $options.i18n.cancel }}</gl-button
+ >
+ <gl-button
+ data-testid="addNewColumnButton"
+ :disabled="!selectedId"
+ variant="confirm"
+ class="gl-mr-4"
+ @click="$emit('add-list')"
+ >{{ $options.i18n.add }}</gl-button
+ >
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue
index 85fca589279..7c08e33be7e 100644
--- a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue
+++ b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue
@@ -13,7 +13,7 @@ export default {
</script>
<template>
- <span class="gl-ml-3 gl-display-flex gl-align-items-center">
+ <span class="gl-ml-3 gl-display-flex gl-align-items-center" data-testid="boards-create-list">
<gl-button variant="confirm" @click="setAddColumnFormVisibility(true)"
>{{ __('Create list') }}
</gl-button>
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue
index e6009343626..aacea0b970c 100644
--- a/app/assets/javascripts/boards/components/board_card.vue
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -1,14 +1,11 @@
<script>
-import sidebarEventHub from '~/sidebar/event_hub';
-import eventHub from '../eventhub';
-import boardsStore from '../stores/boards_store';
-import BoardCardLayout from './board_card_layout.vue';
-import BoardCardLayoutDeprecated from './board_card_layout_deprecated.vue';
+import { mapActions, mapGetters, mapState } from 'vuex';
+import BoardCardInner from './board_card_inner.vue';
export default {
- name: 'BoardsIssueCard',
+ name: 'BoardCard',
components: {
- BoardCardLayout: gon.features?.graphqlBoardLists ? BoardCardLayout : BoardCardLayoutDeprecated,
+ BoardCardInner,
},
props: {
list: {
@@ -16,34 +13,46 @@ export default {
default: () => ({}),
required: false,
},
- issue: {
+ item: {
type: Object,
default: () => ({}),
required: false,
},
+ disabled: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ index: {
+ type: Number,
+ default: 0,
+ required: false,
+ },
},
- methods: {
- // These are methods instead of computed's, because boardsStore is not reactive.
+ computed: {
+ ...mapState(['selectedBoardItems', 'activeId']),
+ ...mapGetters(['isSwimlanesOn']),
isActive() {
- return this.getActiveId() === this.issue.id;
+ return this.item.id === this.activeId;
},
- getActiveId() {
- return boardsStore.detail?.issue?.id;
+ multiSelectVisible() {
+ return (
+ !this.activeId &&
+ this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.item.id) > -1
+ );
},
- showIssue({ isMultiSelect }) {
- // If no issues are opened, close all sidebars first
- if (!this.getActiveId()) {
- sidebarEventHub.$emit('sidebar.closeAll');
- }
- if (this.isActive()) {
- eventHub.$emit('clearDetailIssue', isMultiSelect);
+ },
+ methods: {
+ ...mapActions(['toggleBoardItemMultiSelection', 'toggleBoardItem']),
+ toggleIssue(e) {
+ // Don't do anything if this happened on a no trigger element
+ if (e.target.classList.contains('js-no-trigger')) return;
- if (isMultiSelect) {
- eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
- }
+ const isMultiSelect = e.ctrlKey || e.metaKey;
+ if (isMultiSelect) {
+ this.toggleBoardItemMultiSelection(this.item);
} else {
- eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
- boardsStore.setListDetail(this.list);
+ this.toggleBoardItem({ boardItem: this.item });
}
},
},
@@ -51,12 +60,22 @@ export default {
</script>
<template>
- <board-card-layout
+ <li
data-qa-selector="board_card"
- :issue="issue"
- :list="list"
- :is-active="isActive()"
- v-bind="$attrs"
- @show="showIssue"
- />
+ :class="{
+ 'multi-select': multiSelectVisible,
+ 'user-can-drag': !disabled && item.id,
+ 'is-disabled': disabled || !item.id,
+ 'is-active': isActive,
+ }"
+ :index="index"
+ :data-item-id="item.id"
+ :data-item-iid="item.iid"
+ :data-item-path="item.referencePath"
+ data-testid="board_card"
+ class="board-card gl-p-5 gl-rounded-base"
+ @mouseup="toggleIssue($event)"
+ >
+ <board-card-inner :list="list" :item="item" :update-filters="true" />
+ </li>
</template>
diff --git a/app/assets/javascripts/boards/components/board_card_deprecated.vue b/app/assets/javascripts/boards/components/board_card_deprecated.vue
new file mode 100644
index 00000000000..e12a2836f67
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_card_deprecated.vue
@@ -0,0 +1,61 @@
+<script>
+// This component is being replaced in favor of './board_card.vue' for GraphQL boards
+import sidebarEventHub from '~/sidebar/event_hub';
+import eventHub from '../eventhub';
+import boardsStore from '../stores/boards_store';
+import BoardCardLayoutDeprecated from './board_card_layout_deprecated.vue';
+
+export default {
+ components: {
+ BoardCardLayout: BoardCardLayoutDeprecated,
+ },
+ props: {
+ list: {
+ type: Object,
+ default: () => ({}),
+ required: false,
+ },
+ issue: {
+ type: Object,
+ default: () => ({}),
+ required: false,
+ },
+ },
+ methods: {
+ // These are methods instead of computed's, because boardsStore is not reactive.
+ isActive() {
+ return this.getActiveId() === this.issue.id;
+ },
+ getActiveId() {
+ return boardsStore.detail?.issue?.id;
+ },
+ showIssue({ isMultiSelect }) {
+ // If no issues are opened, close all sidebars first
+ if (!this.getActiveId()) {
+ sidebarEventHub.$emit('sidebar.closeAll');
+ }
+ if (this.isActive()) {
+ eventHub.$emit('clearDetailIssue', isMultiSelect);
+
+ if (isMultiSelect) {
+ eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
+ }
+ } else {
+ eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
+ boardsStore.setListDetail(this.list);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <board-card-layout
+ data-qa-selector="board_card"
+ :issue="issue"
+ :list="list"
+ :is-active="isActive()"
+ v-bind="$attrs"
+ @show="showIssue"
+ />
+</template>
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index e5ea30df767..d4d6b17a589 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -1,8 +1,8 @@
<script>
import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { sortBy } from 'lodash';
-import { mapActions, mapState } from 'vuex';
-import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner';
+import { mapActions, mapGetters, mapState } from 'vuex';
+import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { updateHistory } from '~/lib/utils/url_utility';
import { sprintf, __, n__ } from '~/locale';
@@ -26,10 +26,10 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [issueCardInner],
- inject: ['groupId', 'rootPath', 'scopedLabelsAvailable'],
+ mixins: [boardCardInner],
+ inject: ['rootPath', 'scopedLabelsAvailable'],
props: {
- issue: {
+ item: {
type: Object,
required: true,
},
@@ -53,18 +53,19 @@ export default {
},
computed: {
...mapState(['isShowingLabels']),
+ ...mapGetters(['isEpicBoard']),
cappedAssignees() {
// e.g. maxRender is 4,
// Render up to all 4 assignees if there are only 4 assigness
// Otherwise render up to the limitBeforeCounter
- if (this.issue.assignees.length <= this.maxRender) {
- return this.issue.assignees.slice(0, this.maxRender);
+ if (this.item.assignees.length <= this.maxRender) {
+ return this.item.assignees.slice(0, this.maxRender);
}
- return this.issue.assignees.slice(0, this.limitBeforeCounter);
+ return this.item.assignees.slice(0, this.limitBeforeCounter);
},
numberOverLimit() {
- return this.issue.assignees.length - this.limitBeforeCounter;
+ return this.item.assignees.length - this.limitBeforeCounter;
},
assigneeCounterTooltip() {
const { numberOverLimit, maxCounter } = this;
@@ -79,31 +80,35 @@ export default {
return `+${this.numberOverLimit}`;
},
shouldRenderCounter() {
- if (this.issue.assignees.length <= this.maxRender) {
+ if (this.item.assignees.length <= this.maxRender) {
return false;
}
- return this.issue.assignees.length > this.numberOverLimit;
+ return this.item.assignees.length > this.numberOverLimit;
},
- issueId() {
- if (this.issue.iid) {
- return `#${this.issue.iid}`;
+ itemPrefix() {
+ return this.isEpicBoard ? '&' : '#';
+ },
+
+ itemId() {
+ if (this.item.iid) {
+ return `${this.itemPrefix}${this.item.iid}`;
}
return false;
},
showLabelFooter() {
- return this.isShowingLabels && this.issue.labels.find(this.showLabel);
+ return this.isShowingLabels && this.item.labels.find(this.showLabel);
},
- issueReferencePath() {
- const { referencePath, groupId } = this.issue;
- return !groupId ? referencePath.split('#')[0] : null;
+ itemReferencePath() {
+ const { referencePath } = this.item;
+ return referencePath.split(this.itemPrefix)[0];
},
orderedLabels() {
- return sortBy(this.issue.labels.filter(this.isNonListLabel), 'title');
+ return sortBy(this.item.labels.filter(this.isNonListLabel), 'title');
},
blockedLabel() {
- if (this.issue.blockedByCount) {
- return n__(`Blocked by %d issue`, `Blocked by %d issues`, this.issue.blockedByCount);
+ if (this.item.blockedByCount) {
+ return n__(`Blocked by %d issue`, `Blocked by %d issues`, this.item.blockedByCount);
}
return __('Blocked issue');
},
@@ -160,7 +165,7 @@ export default {
<div class="gl-display-flex" dir="auto">
<h4 class="board-card-title gl-mb-0 gl-mt-0">
<gl-icon
- v-if="issue.blocked"
+ v-if="item.blocked"
v-gl-tooltip
name="issue-block"
:title="blockedLabel"
@@ -169,7 +174,7 @@ export default {
data-testid="issue-blocked-icon"
/>
<gl-icon
- v-if="issue.confidential"
+ v-if="item.confidential"
v-gl-tooltip
name="eye-slash"
:title="__('Confidential')"
@@ -177,11 +182,11 @@ export default {
:aria-label="__('Confidential')"
/>
<a
- :href="issue.path || issue.webUrl || ''"
- :title="issue.title"
+ :href="item.path || item.webUrl || ''"
+ :title="item.title"
class="js-no-trigger"
@mousemove.stop
- >{{ issue.title }}</a
+ >{{ item.title }}</a
>
</h4>
</div>
@@ -205,29 +210,30 @@ export default {
class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden js-board-card-number-container"
>
<span
- v-if="issue.referencePath"
+ v-if="item.referencePath"
class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3"
+ :class="{ 'gl-font-base': isEpicBoard }"
>
<tooltip-on-truncate
- v-if="issueReferencePath"
- :title="issueReferencePath"
+ v-if="itemReferencePath"
+ :title="itemReferencePath"
placement="bottom"
- class="board-issue-path gl-text-truncate gl-font-weight-bold"
- >{{ issueReferencePath }}</tooltip-on-truncate
+ class="board-item-path gl-text-truncate gl-font-weight-bold"
+ >{{ itemReferencePath }}</tooltip-on-truncate
>
- #{{ issue.iid }}
+ {{ itemId }}
</span>
<span class="board-info-items gl-mt-3 gl-display-inline-block">
<issue-due-date
- v-if="issue.dueDate"
- :date="issue.dueDate"
- :closed="issue.closed || Boolean(issue.closedAt)"
+ v-if="item.dueDate"
+ :date="item.dueDate"
+ :closed="item.closed || Boolean(item.closedAt)"
/>
- <issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" />
+ <issue-time-estimate v-if="item.timeEstimate" :estimate="item.timeEstimate" />
<issue-card-weight
- v-if="validIssueWeight"
- :weight="issue.weight"
- @click="filterByWeight(issue.weight)"
+ v-if="validIssueWeight(item)"
+ :weight="item.weight"
+ @click="filterByWeight(item.weight)"
/>
</span>
</div>
diff --git a/app/assets/javascripts/boards/components/board_card_layout.vue b/app/assets/javascripts/boards/components/board_card_layout.vue
deleted file mode 100644
index 5e3c3702519..00000000000
--- a/app/assets/javascripts/boards/components/board_card_layout.vue
+++ /dev/null
@@ -1,98 +0,0 @@
-<script>
-import { mapActions, mapGetters, mapState } from 'vuex';
-import { ISSUABLE } from '~/boards/constants';
-import IssueCardInner from './issue_card_inner.vue';
-
-export default {
- name: 'BoardCardLayout',
- components: {
- IssueCardInner,
- },
- props: {
- list: {
- type: Object,
- default: () => ({}),
- required: false,
- },
- issue: {
- type: Object,
- default: () => ({}),
- required: false,
- },
- disabled: {
- type: Boolean,
- default: false,
- required: false,
- },
- index: {
- type: Number,
- default: 0,
- required: false,
- },
- isActive: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- showDetail: false,
- };
- },
- computed: {
- ...mapState(['selectedBoardItems']),
- ...mapGetters(['isSwimlanesOn']),
- multiSelectVisible() {
- return this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.issue.id) > -1;
- },
- },
- methods: {
- ...mapActions(['setActiveId', 'toggleBoardItemMultiSelection']),
- mouseDown() {
- this.showDetail = true;
- },
- mouseMove() {
- this.showDetail = false;
- },
- showIssue(e) {
- // Don't do anything if this happened on a no trigger element
- if (e.target.classList.contains('js-no-trigger')) return;
-
- const isMultiSelect = e.ctrlKey || e.metaKey;
-
- if (!isMultiSelect) {
- this.setActiveId({ id: this.issue.id, sidebarType: ISSUABLE });
- } else {
- this.toggleBoardItemMultiSelection(this.issue);
- }
-
- if (this.showDetail || isMultiSelect) {
- this.showDetail = false;
- }
- },
- },
-};
-</script>
-
-<template>
- <li
- :class="{
- 'multi-select': multiSelectVisible,
- 'user-can-drag': !disabled && issue.id,
- 'is-disabled': disabled || !issue.id,
- 'is-active': isActive,
- }"
- :index="index"
- :data-issue-id="issue.id"
- :data-issue-iid="issue.iid"
- :data-issue-path="issue.referencePath"
- data-testid="board_card"
- class="board-card gl-p-5 gl-rounded-base"
- @mousedown="mouseDown"
- @mousemove="mouseMove"
- @mouseup="showIssue($event)"
- >
- <issue-card-inner :list="list" :issue="issue" :update-filters="true" />
- </li>
-</template>
diff --git a/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue b/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue
index f9a726134a3..3381e4c3a7d 100644
--- a/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue
+++ b/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue
@@ -3,13 +3,12 @@ import { mapActions, mapGetters } from 'vuex';
import { ISSUABLE } from '~/boards/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import boardsStore from '../stores/boards_store';
-import IssueCardInner from './issue_card_inner.vue';
import IssueCardInnerDeprecated from './issue_card_inner_deprecated.vue';
export default {
name: 'BoardCardLayout',
components: {
- IssueCardInner: gon.features?.graphqlBoardLists ? IssueCardInner : IssueCardInnerDeprecated,
+ IssueCardInner: IssueCardInnerDeprecated,
},
mixins: [glFeatureFlagMixin()],
props: {
diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue
index 41b9ee795eb..c9e667d526c 100644
--- a/app/assets/javascripts/boards/components/board_column.vue
+++ b/app/assets/javascripts/boards/components/board_column.vue
@@ -32,12 +32,12 @@ export default {
},
computed: {
...mapState(['filterParams', 'highlightedLists']),
- ...mapGetters(['getIssuesByList']),
+ ...mapGetters(['getBoardItemsByList']),
highlighted() {
return this.highlightedLists.includes(this.list.id);
},
- listIssues() {
- return this.getIssuesByList(this.list.id);
+ listItems() {
+ return this.getBoardItemsByList(this.list.id);
},
isListDraggable() {
return isListDraggable(this.list);
@@ -46,11 +46,20 @@ export default {
watch: {
filterParams: {
handler() {
- this.fetchIssuesForList({ listId: this.list.id });
+ if (this.list.id) {
+ this.fetchItemsForList({ listId: this.list.id });
+ }
},
deep: true,
immediate: true,
},
+ 'list.id': {
+ handler(id) {
+ if (id) {
+ this.fetchItemsForList({ listId: this.list.id });
+ }
+ },
+ },
highlighted: {
handler(highlighted) {
if (highlighted) {
@@ -63,7 +72,7 @@ export default {
},
},
methods: {
- ...mapActions(['fetchIssuesForList']),
+ ...mapActions(['fetchItemsForList']),
},
};
</script>
@@ -87,7 +96,7 @@ export default {
<board-list
ref="board-list"
:disabled="disabled"
- :issues="listIssues"
+ :board-items="listItems"
:list="list"
:can-admin-list="canAdminList"
/>
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index 9b10e7d7db5..e9c4237d759 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -3,6 +3,7 @@ import { GlAlert } from '@gitlab/ui';
import { sortBy } from 'lodash';
import Draggable from 'vuedraggable';
import { mapState, mapGetters, mapActions } from 'vuex';
+import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue';
import { sortableEnd, sortableStart } from '~/boards/mixins/sortable_default_options';
import defaultSortableConfig from '~/sortable/sortable_config';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -11,7 +12,11 @@ import BoardColumnDeprecated from './board_column_deprecated.vue';
export default {
components: {
- BoardColumn: gon.features?.graphqlBoardLists ? BoardColumn : BoardColumnDeprecated,
+ BoardAddNewColumn,
+ BoardColumn:
+ gon.features?.graphqlBoardLists || gon.features?.epicBoards
+ ? BoardColumn
+ : BoardColumnDeprecated,
BoardContentSidebar: () => import('ee_component/boards/components/board_content_sidebar.vue'),
EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'),
GlAlert,
@@ -33,15 +38,18 @@ export default {
},
},
computed: {
- ...mapState(['boardLists', 'error']),
- ...mapGetters(['isSwimlanesOn']),
+ ...mapState(['boardLists', 'error', 'addColumnForm']),
+ ...mapGetters(['isSwimlanesOn', 'isEpicBoard']),
+ addColumnFormVisible() {
+ return this.addColumnForm?.visible;
+ },
boardListsToUse() {
- return this.glFeatures.graphqlBoardLists || this.isSwimlanesOn
+ return this.glFeatures.graphqlBoardLists || this.isSwimlanesOn || this.isEpicBoard
? sortBy([...Object.values(this.boardLists)], 'position')
: this.lists;
},
canDragColumns() {
- return this.glFeatures.graphqlBoardLists && this.canAdminList;
+ return !this.isEpicBoard && this.glFeatures.graphqlBoardLists && this.canAdminList;
},
boardColumnWrapper() {
return this.canDragColumns ? Draggable : 'div';
@@ -62,12 +70,17 @@ export default {
},
methods: {
...mapActions(['moveList']),
+ afterFormEnters() {
+ const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list;
+ el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' });
+ },
handleDragOnStart() {
sortableStart();
},
handleDragOnEnd(params) {
sortableEnd();
+ if (this.isEpicBoard) return;
const { item, newIndex, oldIndex, to } = params;
@@ -100,13 +113,17 @@ export default {
@end="handleDragOnEnd"
>
<board-column
- v-for="list in boardListsToUse"
- :key="list.id"
+ v-for="(list, index) in boardListsToUse"
+ :key="index"
ref="board"
:can-admin-list="canAdminList"
:list="list"
:disabled="disabled"
/>
+
+ <transition name="slide" @after-enter="afterFormEnters">
+ <board-add-new-column v-if="addColumnFormVisible" />
+ </transition>
</component>
<epics-swimlanes
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index f65f00bcccc..d8504dcfb0f 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -1,5 +1,6 @@
<script>
import { GlModal } from '@gitlab/ui';
+import { mapGetters } from 'vuex';
import { deprecatedCreateFlash as Flash } from '~/flash';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { getParameterByName } from '~/lib/utils/common_utils';
@@ -106,6 +107,7 @@ export default {
};
},
computed: {
+ ...mapGetters(['isEpicBoard', 'isGroupBoard', 'isProjectBoard']),
isNewForm() {
return this.currentPage === formType.new;
},
@@ -161,42 +163,49 @@ export default {
currentMutation() {
return this.board.id ? updateBoardMutation : createBoardMutation;
},
- mutationVariables() {
+ baseMutationVariables() {
const { board } = this;
- /* eslint-disable @gitlab/require-i18n-strings */
- let baseMutationVariables = {
+ const variables = {
name: board.name,
hideBacklogList: board.hide_backlog_list,
hideClosedList: board.hide_closed_list,
};
- if (this.scopedIssueBoardFeatureEnabled) {
- baseMutationVariables = {
- ...baseMutationVariables,
- weight: board.weight,
- assigneeId: board.assignee?.id ? convertToGraphQLId('User', board.assignee.id) : null,
- milestoneId:
- board.milestone?.id || board.milestone?.id === 0
- ? convertToGraphQLId('Milestone', board.milestone.id)
- : null,
- labelIds: board.labels.map(fullLabelId),
- iterationId: board.iteration_id
- ? convertToGraphQLId('Iteration', board.iteration_id)
- : null,
- };
- }
- /* eslint-enable @gitlab/require-i18n-strings */
return board.id
? {
- ...baseMutationVariables,
+ ...variables,
id: fullBoardId(board.id),
}
: {
- ...baseMutationVariables,
- projectPath: this.projectId ? this.fullPath : null,
- groupPath: this.groupId ? this.fullPath : null,
+ ...variables,
+ projectPath: this.isProjectBoard ? this.fullPath : undefined,
+ groupPath: this.isGroupBoard ? this.fullPath : undefined,
};
},
+ boardScopeMutationVariables() {
+ /* eslint-disable @gitlab/require-i18n-strings */
+ return {
+ weight: this.board.weight,
+ assigneeId: this.board.assignee?.id
+ ? convertToGraphQLId('User', this.board.assignee.id)
+ : null,
+ milestoneId:
+ this.board.milestone?.id || this.board.milestone?.id === 0
+ ? convertToGraphQLId('Milestone', this.board.milestone.id)
+ : null,
+ labelIds: this.board.labels.map(fullLabelId),
+ iterationId: this.board.iteration_id
+ ? convertToGraphQLId('Iteration', this.board.iteration_id)
+ : null,
+ };
+ /* eslint-enable @gitlab/require-i18n-strings */
+ },
+ mutationVariables() {
+ return {
+ ...this.baseMutationVariables,
+ ...(this.scopedIssueBoardFeatureEnabled ? this.boardScopeMutationVariables : {}),
+ };
+ },
},
mounted() {
this.resetFormState();
@@ -208,6 +217,16 @@ export default {
setIteration(iterationId) {
this.board.iteration_id = iterationId;
},
+ boardCreateResponse(data) {
+ return data.createBoard.board.webPath;
+ },
+ boardUpdateResponse(data) {
+ const path = data.updateBoard.board.webPath;
+ const param = getParameterByName('group_by')
+ ? `?group_by=${getParameterByName('group_by')}`
+ : '';
+ return `${path}${param}`;
+ },
async createOrUpdateBoard() {
const response = await this.$apollo.mutate({
mutation: this.currentMutation,
@@ -215,14 +234,10 @@ export default {
});
if (!this.board.id) {
- return response.data.createBoard.board.webPath;
+ return this.boardCreateResponse(response.data);
}
- const path = response.data.updateBoard.board.webPath;
- const param = getParameterByName('group_by')
- ? `?group_by=${getParameterByName('group_by')}`
- : '';
- return `${path}${param}`;
+ return this.boardUpdateResponse(response.data);
},
async submit() {
if (this.board.name.length === 0) return;
@@ -309,7 +324,7 @@ export default {
/>
<board-scope
- v-if="scopedIssueBoardFeatureEnabled"
+ v-if="scopedIssueBoardFeatureEnabled && !isEpicBoard"
:collapse-scope="isNewForm"
:board="board"
:can-admin-board="canAdminBoard"
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index 7495b1163be..ae8434be312 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import Draggable from 'vuedraggable';
-import { mapActions, mapState } from 'vuex';
+import { mapActions, mapGetters, mapState } from 'vuex';
import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_options';
import { sprintf, __ } from '~/locale';
import defaultSortableConfig from '~/sortable/sortable_config';
@@ -12,9 +12,10 @@ import BoardNewIssue from './board_new_issue.vue';
export default {
name: 'BoardList',
i18n: {
- loadingIssues: __('Loading issues'),
- loadingMoreissues: __('Loading more issues'),
+ loading: __('Loading'),
+ loadingMoreboardItems: __('Loading more'),
showingAllIssues: __('Showing all issues'),
+ showingAllEpics: __('Showing all epics'),
},
components: {
BoardCard,
@@ -30,7 +31,7 @@ export default {
type: Object,
required: true,
},
- issues: {
+ boardItems: {
type: Array,
required: true,
},
@@ -49,14 +50,19 @@ export default {
},
computed: {
...mapState(['pageInfoByListId', 'listsFlags']),
+ ...mapGetters(['isEpicBoard']),
+ listItemsCount() {
+ return this.isEpicBoard ? this.list.epicsCount : this.list.issuesCount;
+ },
paginatedIssueText() {
- return sprintf(__('Showing %{pageSize} of %{total} issues'), {
- pageSize: this.issues.length,
- total: this.list.issuesCount,
+ return sprintf(__('Showing %{pageSize} of %{total} %{issuableType}'), {
+ pageSize: this.boardItems.length,
+ total: this.listItemsCount,
+ issuableType: this.isEpicBoard ? 'epics' : 'issues',
});
},
- issuesSizeExceedsMax() {
- return this.list.maxIssueCount > 0 && this.list.issuesCount > this.list.maxIssueCount;
+ boardItemsSizeExceedsMax() {
+ return this.list.maxIssueCount > 0 && this.listItemsCount > this.list.maxIssueCount;
},
hasNextPage() {
return this.pageInfoByListId[this.list.id].hasNextPage;
@@ -71,8 +77,13 @@ export default {
// When list is draggable, the reference to the list needs to be accessed differently
return this.canAdminList ? this.$refs.list.$el : this.$refs.list;
},
- showingAllIssues() {
- return this.issues.length === this.list.issuesCount;
+ showingAllItems() {
+ return this.boardItems.length === this.listItemsCount;
+ },
+ showingAllItemsText() {
+ return this.isEpicBoard
+ ? this.$options.i18n.showingAllEpics
+ : this.$options.i18n.showingAllIssues;
},
treeRootWrapper() {
return this.canAdminList ? Draggable : 'ul';
@@ -85,14 +96,14 @@ export default {
tag: 'ul',
'ghost-class': 'board-card-drag-active',
'data-list-id': this.list.id,
- value: this.issues,
+ value: this.boardItems,
};
return this.canAdminList ? options : {};
},
},
watch: {
- issues() {
+ boardItems() {
this.$nextTick(() => {
this.showCount = this.scrollHeight() > Math.ceil(this.listHeight());
});
@@ -112,7 +123,7 @@ export default {
this.listRef.removeEventListener('scroll', this.onScroll);
},
methods: {
- ...mapActions(['fetchIssuesForList', 'moveIssue']),
+ ...mapActions(['fetchItemsForList', 'moveItem']),
listHeight() {
return this.listRef.getBoundingClientRect().height;
},
@@ -126,7 +137,7 @@ export default {
this.listRef.scrollTop = 0;
},
loadNextPage() {
- this.fetchIssuesForList({ listId: this.list.id, fetchNext: true });
+ this.fetchItemsForList({ listId: this.list.id, fetchNext: true });
},
toggleForm() {
this.showIssueForm = !this.showIssueForm;
@@ -148,40 +159,40 @@ export default {
handleDragOnEnd(params) {
sortableEnd();
const { newIndex, oldIndex, from, to, item } = params;
- const { issueId, issueIid, issuePath } = item.dataset;
+ const { itemId, itemIid, itemPath } = item.dataset;
const { children } = to;
let moveBeforeId;
let moveAfterId;
- const getIssueId = (el) => Number(el.dataset.issueId);
+ const getItemId = (el) => Number(el.dataset.itemId);
- // If issue is being moved within the same list
+ // If item is being moved within the same list
if (from === to) {
if (newIndex > oldIndex && children.length > 1) {
- // If issue is being moved down we look for the issue that ends up before
- moveBeforeId = getIssueId(children[newIndex]);
+ // If item is being moved down we look for the item that ends up before
+ moveBeforeId = getItemId(children[newIndex]);
} else if (newIndex < oldIndex && children.length > 1) {
- // If issue is being moved up we look for the issue that ends up after
- moveAfterId = getIssueId(children[newIndex]);
+ // If item is being moved up we look for the item that ends up after
+ moveAfterId = getItemId(children[newIndex]);
} else {
- // If issue remains in the same list at the same position we do nothing
+ // If item remains in the same list at the same position we do nothing
return;
}
} else {
- // We look for the issue that ends up before the moved issue if it exists
+ // We look for the item that ends up before the moved item if it exists
if (children[newIndex - 1]) {
- moveBeforeId = getIssueId(children[newIndex - 1]);
+ moveBeforeId = getItemId(children[newIndex - 1]);
}
- // We look for the issue that ends up after the moved issue if it exists
+ // We look for the item that ends up after the moved item if it exists
if (children[newIndex]) {
- moveAfterId = getIssueId(children[newIndex]);
+ moveAfterId = getItemId(children[newIndex]);
}
}
- this.moveIssue({
- issueId,
- issueIid,
- issuePath,
+ this.moveItem({
+ itemId,
+ itemIid,
+ itemPath,
fromListId: from.dataset.listId,
toListId: to.dataset.listId,
moveBeforeId,
@@ -201,7 +212,7 @@ export default {
<div
v-if="loading"
class="gl-mt-4 gl-text-center"
- :aria-label="$options.i18n.loadingIssues"
+ :aria-label="$options.i18n.loading"
data-testid="board_list_loading"
>
<gl-loading-icon />
@@ -214,24 +225,28 @@ export default {
v-bind="treeRootOptions"
:data-board="list.id"
:data-board-type="list.listType"
- :class="{ 'bg-danger-100': issuesSizeExceedsMax }"
+ :class="{ 'bg-danger-100': boardItemsSizeExceedsMax }"
class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-2 js-board-list"
data-testid="tree-root-wrapper"
@start="handleDragOnStart"
@end="handleDragOnEnd"
>
<board-card
- v-for="(issue, index) in issues"
+ v-for="(item, index) in boardItems"
ref="issue"
- :key="issue.id"
+ :key="item.id"
:index="index"
:list="list"
- :issue="issue"
+ :item="item"
:disabled="disabled"
/>
<li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1">
- <gl-loading-icon v-if="loadingMore" :label="$options.i18n.loadingMoreissues" />
- <span v-if="showingAllIssues">{{ $options.i18n.showingAllIssues }}</span>
+ <gl-loading-icon
+ v-if="loadingMore"
+ :label="$options.i18n.loadingMoreboardItems"
+ data-testid="count-loading-icon"
+ />
+ <span v-if="showingAllItems">{{ showingAllItemsText }}</span>
<span v-else>{{ paginatedIssueText }}</span>
</li>
</component>
diff --git a/app/assets/javascripts/boards/components/board_list_deprecated.vue b/app/assets/javascripts/boards/components/board_list_deprecated.vue
index 9b4961d362d..d59fbcc1b31 100644
--- a/app/assets/javascripts/boards/components/board_list_deprecated.vue
+++ b/app/assets/javascripts/boards/components/board_list_deprecated.vue
@@ -11,7 +11,7 @@ import {
sortableEnd,
} from '../mixins/sortable_default_options';
import boardsStore from '../stores/boards_store';
-import boardCard from './board_card.vue';
+import boardCard from './board_card_deprecated.vue';
import boardNewIssue from './board_new_issue_deprecated.vue';
// This component is being replaced in favor of './board_list.vue' for GraphQL boards
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index a933370427c..6ccaec4a633 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -8,16 +8,16 @@ import {
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
+import { mapActions, mapGetters, mapState } from 'vuex';
import { isListDraggable } from '~/boards/boards_util';
-import { isScopedLabel } from '~/lib/utils/common_utils';
+import { isScopedLabel, parseBoolean } from '~/lib/utils/common_utils';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { n__, s__, __ } from '~/locale';
import sidebarEventHub from '~/sidebar/event_hub';
import AccessorUtilities from '../../lib/utils/accessor';
import { inactiveId, LIST, ListType } from '../constants';
import eventHub from '../eventhub';
-import IssueCount from './issue_count.vue';
+import ItemCount from './item_count.vue';
export default {
i18n: {
@@ -33,7 +33,7 @@ export default {
GlTooltip,
GlIcon,
GlSprintf,
- IssueCount,
+ ItemCount,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -70,6 +70,7 @@ export default {
},
computed: {
...mapState(['activeId']),
+ ...mapGetters(['isEpicBoard']),
isLoggedIn() {
return Boolean(this.currentUserId);
},
@@ -97,11 +98,14 @@ export default {
showListDetails() {
return !this.list.collapsed || !this.isSwimlanesHeader;
},
- issuesCount() {
+ itemsCount() {
return this.list.issuesCount;
},
- issuesTooltipLabel() {
- return n__(`%d issue`, `%d issues`, this.issuesCount);
+ countIcon() {
+ return 'issues';
+ },
+ itemsTooltipLabel() {
+ return n__(`%d issue`, `%d issues`, this.itemsCount);
},
chevronTooltip() {
return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse;
@@ -110,7 +114,7 @@ export default {
return this.list.collapsed ? 'chevron-down' : 'chevron-right';
},
isNewIssueShown() {
- return this.listType === ListType.backlog || this.showListHeaderButton;
+ return (this.listType === ListType.backlog || this.showListHeaderButton) && !this.isEpicBoard;
},
isSettingsShown() {
return (
@@ -131,8 +135,14 @@ export default {
return !this.disabled && isListDraggable(this.list);
},
},
+ created() {
+ const localCollapsed = parseBoolean(localStorage.getItem(`${this.uniqueKey}.collapsed`));
+ if ((!this.isLoggedIn || this.isEpicBoard) && localCollapsed) {
+ this.toggleListCollapsed({ listId: this.list.id, collapsed: true });
+ }
+ },
methods: {
- ...mapActions(['updateList', 'setActiveId']),
+ ...mapActions(['updateList', 'setActiveId', 'toggleListCollapsed']),
openSidebarSettings() {
if (this.activeId === inactiveId) {
sidebarEventHub.$emit('sidebar.closeAll');
@@ -148,10 +158,10 @@ export default {
eventHub.$emit(`toggle-issue-form-${this.list.id}`);
},
toggleExpanded() {
- // eslint-disable-next-line vue/no-mutating-props
- this.list.collapsed = !this.list.collapsed;
+ const collapsed = !this.list.collapsed;
+ this.toggleListCollapsed({ listId: this.list.id, collapsed });
- if (!this.isLoggedIn) {
+ if (!this.isLoggedIn || this.isEpicBoard) {
this.addToLocalStorage();
} else {
this.updateListFunction();
@@ -163,7 +173,7 @@ export default {
},
addToLocalStorage() {
if (AccessorUtilities.isLocalStorageAccessSafe()) {
- localStorage.setItem(`${this.uniqueKey}.expanded`, !this.list.collapsed);
+ localStorage.setItem(`${this.uniqueKey}.collapsed`, this.list.collapsed);
}
},
updateListFunction() {
@@ -203,6 +213,7 @@ export default {
class="board-title-caret no-drag gl-cursor-pointer"
category="tertiary"
size="small"
+ data-testid="board-title-caret"
@click="toggleExpanded"
/>
<!-- EE start -->
@@ -301,11 +312,11 @@ export default {
<div v-if="list.maxIssueCount !== 0">
•
<gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')">
- <template #issuesSize>{{ issuesTooltipLabel }}</template>
+ <template #issuesSize>{{ itemsTooltipLabel }}</template>
<template #maxIssueCount>{{ list.maxIssueCount }}</template>
</gl-sprintf>
</div>
- <div v-else>• {{ issuesTooltipLabel }}</div>
+ <div v-else>• {{ itemsTooltipLabel }}</div>
<div v-if="weightFeatureAvailable">
•
<gl-sprintf :message="__('%{totalWeight} total weight')">
@@ -323,13 +334,13 @@ export default {
}"
>
<span class="gl-display-inline-flex">
- <gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltipLabel" />
- <span ref="issueCount" class="issue-count-badge-count">
- <gl-icon class="gl-mr-2" name="issues" />
- <issue-count :issues-size="issuesCount" :max-issue-count="list.maxIssueCount" />
+ <gl-tooltip :target="() => $refs.itemCount" :title="itemsTooltipLabel" />
+ <span ref="itemCount" class="issue-count-badge-count">
+ <gl-icon class="gl-mr-2" :name="countIcon" />
+ <item-count :items-size="itemsCount" :max-issue-count="list.maxIssueCount" />
</span>
<!-- EE start -->
- <template v-if="weightFeatureAvailable">
+ <template v-if="weightFeatureAvailable && !isEpicBoard">
<gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" />
<span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3">
<gl-icon class="gl-mr-2" name="weight" />
diff --git a/app/assets/javascripts/boards/components/board_list_header_deprecated.vue b/app/assets/javascripts/boards/components/board_list_header_deprecated.vue
index ff043d3aa01..429ffd4cd06 100644
--- a/app/assets/javascripts/boards/components/board_list_header_deprecated.vue
+++ b/app/assets/javascripts/boards/components/board_list_header_deprecated.vue
@@ -17,7 +17,7 @@ import AccessorUtilities from '../../lib/utils/accessor';
import { inactiveId, LIST, ListType } from '../constants';
import eventHub from '../eventhub';
import boardsStore from '../stores/boards_store';
-import IssueCount from './issue_count.vue';
+import IssueCount from './item_count.vue';
// This component is being replaced in favor of './board_list_header.vue' for GraphQL boards
@@ -308,7 +308,7 @@ export default {
<gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltipLabel" />
<span ref="issueCount" class="issue-count-badge-count">
<gl-icon class="gl-mr-2" name="issues" />
- <issue-count :issues-size="issuesCount" :max-issue-count="list.maxIssueCount" />
+ <issue-count :items-size="issuesCount" :max-issue-count="list.maxIssueCount" />
</span>
<!-- The following is only true in EE. -->
<template v-if="weightFeatureAvailable">
diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue
index 1df154688c8..a81c28733cd 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.vue
+++ b/app/assets/javascripts/boards/components/board_new_issue.vue
@@ -1,6 +1,6 @@
<script>
import { GlButton } from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
+import { mapActions, mapGetters, mapState } from 'vuex';
import { getMilestone } from 'ee_else_ce/boards/boards_util';
import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -32,8 +32,9 @@ export default {
},
computed: {
...mapState(['selectedProject']),
+ ...mapGetters(['isGroupBoard']),
disabled() {
- if (this.groupId) {
+ if (this.isGroupBoard) {
return this.title === '' || !this.selectedProject.name;
}
return this.title === '';
@@ -98,7 +99,7 @@ export default {
name="issue_title"
autocomplete="off"
/>
- <project-select v-if="groupId" :group-id="groupId" :list="list" />
+ <project-select v-if="isGroupBoard" :group-id="groupId" :list="list" />
<div class="clearfix gl-mt-3">
<gl-button
ref="submitButton"
diff --git a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue
index eff87ff110e..16f23dfff0e 100644
--- a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue
+++ b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton } from '@gitlab/ui';
+import { mapGetters } from 'vuex';
import { getMilestone } from 'ee_else_ce/boards/boards_util';
import ListIssue from 'ee_else_ce/boards/models/issue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -31,8 +32,9 @@ export default {
};
},
computed: {
+ ...mapGetters(['isGroupBoard']),
disabled() {
- if (this.groupId) {
+ if (this.isGroupBoard) {
return this.title === '' || !this.selectedProject.name;
}
return this.title === '';
@@ -110,7 +112,7 @@ export default {
name="issue_title"
autocomplete="off"
/>
- <project-select v-if="groupId" :group-id="groupId" :list="list" />
+ <project-select v-if="isGroupBoard" :group-id="groupId" :list="list" />
<div class="clearfix gl-mt-3">
<gl-button
ref="submitButton"
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
index 6d5a13be3ac..55bc91cbcff 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -20,7 +20,6 @@ import Subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue'
import TimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
import eventHub from '~/sidebar/event_hub';
import boardsStore from '../stores/boards_store';
-import RemoveBtn from './sidebar/remove_issue.vue';
export default Vue.extend({
components: {
@@ -29,7 +28,6 @@ export default Vue.extend({
GlLabel,
SidebarEpicsSelect: () =>
import('ee_component/sidebar/components/sidebar_item_epics_select.vue'),
- RemoveBtn,
Subscriptions,
TimeTracker,
SidebarAssigneesWidget,
@@ -107,8 +105,8 @@ export default Vue.extend({
closeSidebar() {
this.detail.issue = {};
},
- setAssignees(data) {
- boardsStore.detail.issue.setAssignees(data.issueSetAssignees.issue.assignees.nodes);
+ setAssignees(assignees) {
+ boardsStore.detail.issue.setAssignees(assignees);
},
showScopedLabels(label) {
return boardsStore.scopedLabels.enabled && isScopedLabel(label);
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index 2a064aaa885..5124467136e 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -9,6 +9,9 @@ import {
GlModalDirective,
} from '@gitlab/ui';
import { throttle } from 'lodash';
+import { mapGetters, mapState } from 'vuex';
+
+import BoardForm from 'ee_else_ce/boards/components/board_form.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import axios from '~/lib/utils/axios_utils';
@@ -18,8 +21,6 @@ import eventHub from '../eventhub';
import groupQuery from '../graphql/group_boards.query.graphql';
import projectQuery from '../graphql/project_boards.query.graphql';
-import BoardForm from './board_form.vue';
-
const MIN_BOARDS_TO_VIEW_RECENT = 10;
export default {
@@ -109,8 +110,10 @@ export default {
};
},
computed: {
+ ...mapState(['boardType']),
+ ...mapGetters(['isGroupBoard']),
parentType() {
- return this.groupId ? 'group' : 'project';
+ return this.boardType;
},
loading() {
return this.loadingRecentBoards || Boolean(this.loadingBoards);
@@ -123,6 +126,9 @@ export default {
board() {
return this.currentBoard;
},
+ showCreate() {
+ return this.multipleIssueBoardsAvailable;
+ },
showDelete() {
return this.boards.length > 1;
},
@@ -158,6 +164,18 @@ export default {
cancel() {
this.showPage('');
},
+ boardUpdate(data) {
+ if (!data?.[this.parentType]) {
+ return [];
+ }
+ return data[this.parentType].boards.edges.map(({ node }) => ({
+ id: getIdFromGraphQLId(node.id),
+ name: node.name,
+ }));
+ },
+ boardQuery() {
+ return this.isGroupBoard ? groupQuery : projectQuery;
+ },
loadBoards(toggleDropdown = true) {
if (toggleDropdown && this.boards.length > 0) {
return;
@@ -167,21 +185,14 @@ export default {
variables() {
return { fullPath: this.fullPath };
},
- query() {
- return this.groupId ? groupQuery : projectQuery;
- },
+ query: this.boardQuery,
loadingKey: 'loadingBoards',
- update(data) {
- if (!data?.[this.parentType]) {
- return [];
- }
- return data[this.parentType].boards.edges.map(({ node }) => ({
- id: getIdFromGraphQLId(node.id),
- name: node.name,
- }));
- },
+ update: this.boardUpdate,
});
+ this.loadRecentBoards();
+ },
+ loadRecentBoards() {
this.loadingRecentBoards = true;
// Follow up to fetch recent boards using GraphQL
// https://gitlab.com/gitlab-org/gitlab/-/issues/300985
@@ -322,7 +333,7 @@ export default {
<gl-dropdown-divider />
<gl-dropdown-item
- v-if="multipleIssueBoardsAvailable"
+ v-if="showCreate"
v-gl-modal-directive="'board-config-modal'"
data-qa-selector="create_new_board_button"
@click.prevent="showPage('new')"
diff --git a/app/assets/javascripts/boards/components/boards_selector_deprecated.vue b/app/assets/javascripts/boards/components/boards_selector_deprecated.vue
index 33ad46a0d29..85c7b27336b 100644
--- a/app/assets/javascripts/boards/components/boards_selector_deprecated.vue
+++ b/app/assets/javascripts/boards/components/boards_selector_deprecated.vue
@@ -9,6 +9,7 @@ import {
GlModalDirective,
} from '@gitlab/ui';
import { throttle } from 'lodash';
+import { mapGetters, mapState } from 'vuex';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import httpStatusCodes from '~/lib/utils/http_status';
@@ -108,8 +109,10 @@ export default {
};
},
computed: {
+ ...mapState(['boardType']),
+ ...mapGetters(['isGroupBoard']),
parentType() {
- return this.groupId ? 'group' : 'project';
+ return this.boardType;
},
loading() {
return this.loadingRecentBoards || Boolean(this.loadingBoards);
@@ -167,7 +170,7 @@ export default {
return { fullPath: this.state.endpoints.fullPath };
},
query() {
- return this.groupId ? groupQuery : projectQuery;
+ return this.isGroupBoard ? groupQuery : projectQuery;
},
loadingKey: 'loadingBoards',
update(data) {
diff --git a/app/assets/javascripts/boards/components/config_toggle.vue b/app/assets/javascripts/boards/components/config_toggle.vue
new file mode 100644
index 00000000000..7ec99e51f5b
--- /dev/null
+++ b/app/assets/javascripts/boards/components/config_toggle.vue
@@ -0,0 +1,64 @@
+<script>
+import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
+import { formType } from '~/boards/constants';
+import eventHub from '~/boards/eventhub';
+import { s__, __ } from '~/locale';
+
+export default {
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ GlModalDirective,
+ },
+ props: {
+ boardsStore: {
+ type: Object,
+ required: true,
+ },
+ canAdminList: {
+ type: Boolean,
+ required: true,
+ },
+ hasScope: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ state: this.boardsStore.state,
+ };
+ },
+ computed: {
+ buttonText() {
+ return this.canAdminList ? s__('Boards|Edit board') : s__('Boards|View scope');
+ },
+ tooltipTitle() {
+ return this.hasScope ? __("This board's scope is reduced") : '';
+ },
+ },
+ methods: {
+ showPage() {
+ eventHub.$emit('showBoardModal', formType.edit);
+ return this.boardsStore.showPage(formType.edit);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-ml-3 gl-display-flex gl-align-items-center">
+ <gl-button
+ v-gl-modal-directive="'board-config-modal'"
+ v-gl-tooltip
+ :title="tooltipTitle"
+ :class="{ 'dot-highlight': hasScope }"
+ data-qa-selector="boards_config_button"
+ @click.prevent="showPage"
+ >
+ {{ buttonText }}
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/filtered_search.vue b/app/assets/javascripts/boards/components/filtered_search.vue
new file mode 100644
index 00000000000..8505ea39a6b
--- /dev/null
+++ b/app/assets/javascripts/boards/components/filtered_search.vue
@@ -0,0 +1,54 @@
+<script>
+import { mapActions } from 'vuex';
+import { historyPushState } from '~/lib/utils/common_utils';
+import { setUrlParams } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+
+export default {
+ i18n: {
+ search: __('Search'),
+ },
+ components: { FilteredSearch },
+ props: {
+ search: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ initialSearch() {
+ return [{ type: 'filtered-search-term', value: { data: this.search } }];
+ },
+ },
+ methods: {
+ ...mapActions(['performSearch']),
+ handleSearch(filters) {
+ let itemValue = '';
+ const [item] = filters;
+
+ if (filters.length === 0) {
+ itemValue = '';
+ } else {
+ itemValue = item?.value?.data;
+ }
+
+ historyPushState(setUrlParams({ search: itemValue }, window.location.href));
+
+ this.performSearch();
+ },
+ },
+};
+</script>
+
+<template>
+ <filtered-search
+ class="gl-w-full"
+ namespace=""
+ :tokens="[]"
+ :search-input-placeholder="$options.i18n.search"
+ :initial-filter-value="initialSearch"
+ @onFilter="handleSearch"
+ />
+</template>
diff --git a/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue b/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue
index 069cc2cda22..2652fac1818 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue
+++ b/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue
@@ -2,7 +2,7 @@
import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { sortBy } from 'lodash';
import { mapState } from 'vuex';
-import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner';
+import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { sprintf, __, n__ } from '~/locale';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
@@ -24,7 +24,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [issueCardInner],
+ mixins: [boardCardInner],
inject: ['groupId', 'rootPath'],
props: {
issue: {
@@ -207,7 +207,7 @@ export default {
/>
<issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" />
<issue-card-weight
- v-if="validIssueWeight"
+ v-if="validIssueWeight(issue)"
:weight="issue.weight"
@click="filterByWeight(issue.weight)"
/>
diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue
index 7e3f36c8a17..73ec008c2b6 100644
--- a/app/assets/javascripts/boards/components/issue_due_date.vue
+++ b/app/assets/javascripts/boards/components/issue_due_date.vue
@@ -86,7 +86,11 @@ export default {
<template>
<span>
<span ref="issueDueDate" :class="cssClass" class="board-card-info card-number">
- <gl-icon :class="{ 'text-danger': isPastDue }" class="board-card-info-icon" name="calendar" />
+ <gl-icon
+ :class="{ 'text-danger': isPastDue }"
+ class="board-card-info-icon gl-mr-2"
+ name="calendar"
+ />
<time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{
body
}}</time>
diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue
index 42d187b9b40..1ab7deebfaf 100644
--- a/app/assets/javascripts/boards/components/issue_time_estimate.vue
+++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue
@@ -37,7 +37,7 @@ export default {
<template>
<span>
<span ref="issueTimeEstimate" class="board-card-info card-number">
- <gl-icon name="hourglass" class="board-card-info-icon" />
+ <gl-icon name="hourglass" class="board-card-info-icon gl-mr-2" />
<time class="board-card-info-text">{{ timeEstimate }}</time>
</span>
<gl-tooltip
diff --git a/app/assets/javascripts/boards/components/issue_count.vue b/app/assets/javascripts/boards/components/item_count.vue
index d55f7151d7e..9b1ff254766 100644
--- a/app/assets/javascripts/boards/components/issue_count.vue
+++ b/app/assets/javascripts/boards/components/item_count.vue
@@ -7,7 +7,7 @@ export default {
required: false,
default: 0,
},
- issuesSize: {
+ itemsSize: {
type: Number,
required: false,
default: 0,
@@ -18,16 +18,16 @@ export default {
return this.maxIssueCount !== 0;
},
issuesExceedMax() {
- return this.isMaxLimitSet && this.issuesSize > this.maxIssueCount;
+ return this.isMaxLimitSet && this.itemsSize > this.maxIssueCount;
},
},
};
</script>
<template>
- <div class="issue-count text-nowrap">
- <span class="js-issue-size" :class="{ 'text-danger': issuesExceedMax }">
- {{ issuesSize }}
+ <div class="item-count text-nowrap">
+ <span :class="{ 'text-danger': issuesExceedMax }" data-testid="board-items-count">
+ {{ itemsSize }}
</span>
<span v-if="isMaxLimitSet" class="js-max-issue-size">
{{ maxIssueCount }}
diff --git a/app/assets/javascripts/boards/components/modal/list.vue b/app/assets/javascripts/boards/components/modal/list.vue
index bf69f8140d5..e66cae0ce18 100644
--- a/app/assets/javascripts/boards/components/modal/list.vue
+++ b/app/assets/javascripts/boards/components/modal/list.vue
@@ -2,11 +2,11 @@
import { GlIcon } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import ModalStore from '../../stores/modal_store';
-import IssueCardInner from '../issue_card_inner.vue';
+import BoardCardInner from '../board_card_inner.vue';
export default {
components: {
- IssueCardInner,
+ BoardCardInner,
GlIcon,
},
props: {
@@ -126,7 +126,7 @@ export default {
class="board-card position-relative p-3 rounded"
@click="toggleIssue($event, issue)"
>
- <issue-card-inner :issue="issue" />
+ <board-card-inner :item="issue" />
<gl-icon
v-if="issue.selected"
:aria-label="'Issue #' + issue.id + ' selected'"
diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue
index cfc1752a828..77b6af77652 100644
--- a/app/assets/javascripts/boards/components/project_select.vue
+++ b/app/assets/javascripts/boards/components/project_select.vue
@@ -7,7 +7,7 @@ import {
GlIntersectionObserver,
GlLoadingIcon,
} from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
+import { mapActions, mapState, mapGetters } from 'vuex';
import { s__ } from '~/locale';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
import { ListType } from '../constants';
@@ -49,7 +49,8 @@ export default {
};
},
computed: {
- ...mapState(['groupProjects', 'groupProjectsFlags']),
+ ...mapState(['groupProjectsFlags']),
+ ...mapGetters(['activeGroupProjects']),
selectedProjectName() {
return this.selectedProject.name || this.$options.i18n.dropdownText;
},
@@ -65,7 +66,7 @@ export default {
};
},
isFetchResultEmpty() {
- return this.groupProjects.length === 0;
+ return this.activeGroupProjects.length === 0;
},
hasNextPage() {
return this.groupProjectsFlags.pageInfo?.hasNextPage;
@@ -84,7 +85,7 @@ export default {
methods: {
...mapActions(['fetchGroupProjects', 'setSelectedProject']),
selectProject(projectId) {
- this.selectedProject = this.groupProjects.find((project) => project.id === projectId);
+ this.selectedProject = this.activeGroupProjects.find((project) => project.id === projectId);
this.setSelectedProject(this.selectedProject);
},
loadMoreProjects() {
@@ -113,7 +114,7 @@ export default {
:placeholder="$options.i18n.searchPlaceholder"
/>
<gl-dropdown-item
- v-for="project in groupProjects"
+ v-for="project in activeGroupProjects"
v-show="!groupProjectsFlags.isLoading"
:key="project.id"
:name="project.name"
diff --git a/app/assets/javascripts/boards/components/project_select_deprecated.vue b/app/assets/javascripts/boards/components/project_select_deprecated.vue
index 5605e9945ea..afe161d9c54 100644
--- a/app/assets/javascripts/boards/components/project_select_deprecated.vue
+++ b/app/assets/javascripts/boards/components/project_select_deprecated.vue
@@ -25,6 +25,7 @@ export default {
with_shared: false,
include_subgroups: true,
order_by: 'similarity',
+ archived: false,
},
components: {
GlLoadingIcon,
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue
index aa4fdcf9a94..f01c8e8fa20 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue
@@ -64,6 +64,8 @@ export default {
v-if="!activeIssue.emailsDisabled"
:value="activeIssue.subscribed"
:is-loading="loading"
+ :label="$options.i18n.header.title"
+ label-position="hidden"
data-testid="notification-subscribe-toggle"
@change="handleToggleSubscription"
/>
diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue
deleted file mode 100644
index 8d65f3240c8..00000000000
--- a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue
+++ /dev/null
@@ -1,88 +0,0 @@
-<script>
-import { GlButton } from '@gitlab/ui';
-import axios from '~/lib/utils/axios_utils';
-import { deprecatedCreateFlash as Flash } from '../../../flash';
-import { __ } from '../../../locale';
-import boardsStore from '../../stores/boards_store';
-
-export default {
- components: {
- GlButton,
- },
- props: {
- issue: {
- type: Object,
- required: true,
- },
- list: {
- type: Object,
- required: true,
- },
- },
- computed: {
- updateUrl() {
- return this.issue.path;
- },
- },
- methods: {
- removeIssue() {
- const { issue } = this;
- const lists = issue.getLists();
- const req = this.buildPatchRequest(issue, lists);
-
- const data = {
- issue: this.seedPatchRequest(issue, req),
- };
-
- if (data.issue.label_ids.length === 0) {
- data.issue.label_ids = [''];
- }
-
- // Post the remove data
- axios.patch(this.updateUrl, data).catch(() => {
- Flash(__('Failed to remove issue from board, please try again.'));
-
- lists.forEach((list) => {
- list.addIssue(issue);
- });
- });
-
- // Remove from the frontend store
- lists.forEach((list) => {
- list.removeIssue(issue);
- });
-
- boardsStore.clearDetailIssue();
- },
- /**
- * Build the default patch request.
- */
- buildPatchRequest(issue, lists) {
- const listLabelIds = lists.map((list) => list.label.id);
-
- const labelIds = issue.labels
- .map((label) => label.id)
- .filter((id) => !listLabelIds.includes(id));
-
- return {
- label_ids: labelIds,
- };
- },
- /**
- * Seed the given patch request.
- *
- * (This is overridden in EE)
- */
- seedPatchRequest(issue, req) {
- return req;
- },
- },
-};
-</script>
-<template>
- <div class="block list">
- <gl-button variant="default" category="secondary" block="block" @click="removeIssue">
- {{ __('Remove from board') }}
- </gl-button>
- </div>
-</template>
diff --git a/app/assets/javascripts/boards/config_toggle.js b/app/assets/javascripts/boards/config_toggle.js
index 2d1ec238274..7f327c5764d 100644
--- a/app/assets/javascripts/boards/config_toggle.js
+++ b/app/assets/javascripts/boards/config_toggle.js
@@ -1 +1,24 @@
-export default () => {};
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import ConfigToggle from './components/config_toggle.vue';
+
+export default (boardsStore) => {
+ const el = document.querySelector('.js-board-config');
+
+ if (!el) {
+ return;
+ }
+
+ gl.boardConfigToggle = new Vue({
+ el,
+ render(h) {
+ return h(ConfigToggle, {
+ props: {
+ boardsStore,
+ canAdminList: parseBoolean(el.dataset.canAdminList),
+ hasScope: parseBoolean(el.dataset.hasScope),
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index 3ab89b2c9da..65ebfe7be6c 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -1,5 +1,10 @@
import { __ } from '~/locale';
+export const issuableTypes = {
+ issue: 'issue',
+ epic: 'epic',
+};
+
export const BoardType = {
project: 'project',
group: 'group',
diff --git a/app/assets/javascripts/boards/filtered_search.js b/app/assets/javascripts/boards/filtered_search.js
new file mode 100644
index 00000000000..182a2cf3724
--- /dev/null
+++ b/app/assets/javascripts/boards/filtered_search.js
@@ -0,0 +1,25 @@
+import Vue from 'vue';
+import store from '~/boards/stores';
+import { queryToObject } from '~/lib/utils/url_utility';
+import FilteredSearch from './components/filtered_search.vue';
+
+export default () => {
+ const queryParams = queryToObject(window.location.search);
+ const el = document.getElementById('js-board-filtered-search');
+
+ /*
+ When https://github.com/vuejs/vue-apollo/pull/1153 is merged and deployed
+ we can remove apolloProvider option from here. Currently without it its causing
+ an error
+ */
+
+ return new Vue({
+ el,
+ store,
+ apolloProvider: {},
+ render: (createElement) =>
+ createElement(FilteredSearch, {
+ props: { search: queryParams.search },
+ }),
+ });
+};
diff --git a/app/assets/javascripts/boards/graphql/board_labels.query.graphql b/app/assets/javascripts/boards/graphql/board_labels.query.graphql
index 42a94419a97..b19a24e8808 100644
--- a/app/assets/javascripts/boards/graphql/board_labels.query.graphql
+++ b/app/assets/javascripts/boards/graphql/board_labels.query.graphql
@@ -7,14 +7,14 @@ query BoardLabels(
$isProject: Boolean = false
) {
group(fullPath: $fullPath) @include(if: $isGroup) {
- labels(searchTerm: $searchTerm) {
+ labels(searchTerm: $searchTerm, onlyGroupLabels: true, includeAncestorGroups: true) {
nodes {
...Label
}
}
}
project(fullPath: $fullPath) @include(if: $isProject) {
- labels(searchTerm: $searchTerm) {
+ labels(searchTerm: $searchTerm, includeAncestorGroups: true) {
nodes {
...Label
}
diff --git a/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql
index f78a21baa7f..3eb23f62940 100644
--- a/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql
@@ -1,21 +1,7 @@
-#import "ee_else_ce/boards/graphql/board_list.fragment.graphql"
+#import "./board_list.fragment.graphql"
-mutation CreateBoardList(
- $boardId: BoardID!
- $backlog: Boolean
- $labelId: LabelID
- $milestoneId: MilestoneID
- $assigneeId: UserID
-) {
- boardListCreate(
- input: {
- boardId: $boardId
- backlog: $backlog
- labelId: $labelId
- milestoneId: $milestoneId
- assigneeId: $assigneeId
- }
- ) {
+mutation CreateBoardList($boardId: BoardID!, $backlog: Boolean, $labelId: LabelID) {
+ boardListCreate(input: { boardId: $boardId, backlog: $backlog, labelId: $labelId }) {
list {
...BoardListFragment
}
diff --git a/app/assets/javascripts/boards/graphql/group_projects.query.graphql b/app/assets/javascripts/boards/graphql/group_projects.query.graphql
index 1afa6e48547..80a37c9943d 100644
--- a/app/assets/javascripts/boards/graphql/group_projects.query.graphql
+++ b/app/assets/javascripts/boards/graphql/group_projects.query.graphql
@@ -8,6 +8,7 @@ query getGroupProjects($fullPath: ID!, $search: String, $after: String) {
name
fullPath
nameWithNamespace
+ archived
}
pageInfo {
...PageInfo
diff --git a/app/assets/javascripts/boards/graphql/users_search.query.graphql b/app/assets/javascripts/boards/graphql/users_search.query.graphql
deleted file mode 100644
index ca016495d79..00000000000
--- a/app/assets/javascripts/boards/graphql/users_search.query.graphql
+++ /dev/null
@@ -1,11 +0,0 @@
-query usersSearch($search: String!) {
- users(search: $search) {
- nodes {
- username
- name
- webUrl
- avatarUrl
- id
- }
- }
-}
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index 859295318ed..ceca5b0a451 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -6,7 +6,6 @@ import 'ee_else_ce/boards/models/issue';
import 'ee_else_ce/boards/models/list';
import BoardSidebar from 'ee_else_ce/boards/components/board_sidebar';
import initNewListDropdown from 'ee_else_ce/boards/components/new_list_dropdown';
-import boardConfigToggle from 'ee_else_ce/boards/config_toggle';
import {
setWeightFetchingState,
setEpicFetchingState,
@@ -24,6 +23,7 @@ import '~/boards/models/milestone';
import '~/boards/models/project';
import '~/boards/filters/due_date_filters';
import BoardAddIssuesModal from '~/boards/components/modal/index.vue';
+import { issuableTypes } from '~/boards/constants';
import eventHub from '~/boards/eventhub';
import FilteredSearchBoards from '~/boards/filtered_search_boards';
import modalMixin from '~/boards/mixins/modal_mixins';
@@ -40,6 +40,7 @@ import {
} from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import sidebarEventHub from '~/sidebar/event_hub';
+import boardConfigToggle from './config_toggle';
import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher';
Vue.use(VueApollo);
@@ -52,7 +53,6 @@ let issueBoardsApp;
export default () => {
const $boardApp = document.getElementById('board-app');
-
// check for browser back and trigger a hard reload to circumvent browser caching.
window.addEventListener('pageshow', (event) => {
const isNavTypeBackForward =
@@ -72,6 +72,14 @@ export default () => {
boardsStore.setTimeTrackingLimitToHours($boardApp.dataset.timeTrackingLimitToHours);
}
+ if (gon?.features?.boardsFilteredSearch) {
+ import('~/boards/filtered_search')
+ .then(({ default: initFilteredSearch }) => {
+ initFilteredSearch(apolloProvider);
+ })
+ .catch(() => {});
+ }
+
// eslint-disable-next-line @gitlab/no-runtime-template-compiler
issueBoardsApp = new Vue({
el: $boardApp,
@@ -96,6 +104,9 @@ export default () => {
? parseInt($boardApp.dataset.boardWeight, 10)
: null,
scopedLabelsAvailable: parseBoolean($boardApp.dataset.scopedLabels),
+ milestoneListsAvailable: parseBoolean($boardApp.dataset.milestoneListsAvailable),
+ assigneeListsAvailable: parseBoolean($boardApp.dataset.assigneeListsAvailable),
+ iterationListsAvailable: parseBoolean($boardApp.dataset.iterationListsAvailable),
},
store,
apolloProvider,
@@ -124,6 +135,7 @@ export default () => {
fullPath: $boardApp.dataset.fullPath,
boardType: this.parent,
disabled: this.disabled,
+ issuableType: issuableTypes.issue,
boardConfig: {
milestoneId: parseInt($boardApp.dataset.boardMilestoneId, 10),
milestoneTitle: $boardApp.dataset.boardMilestoneTitle || '',
@@ -162,8 +174,15 @@ export default () => {
eventHub.$off('initialBoardLoad', this.initialBoardLoad);
},
mounted() {
- this.filterManager = new FilteredSearchBoards(boardsStore.filter, true, boardsStore.cantEdit);
- this.filterManager.setup();
+ if (!gon.features?.boardsFilteredSearch) {
+ this.filterManager = new FilteredSearchBoards(
+ boardsStore.filter,
+ true,
+ boardsStore.cantEdit,
+ );
+
+ this.filterManager.setup();
+ }
this.performSearch();
@@ -349,7 +368,7 @@ export default () => {
toggleFocusMode(ModalStore, boardsStore);
toggleLabels();
- if (gon.features?.swimlanes) {
+ if (gon.licensed_features?.swimlanes) {
toggleEpicsSwimlanes();
}
diff --git a/app/assets/javascripts/boards/mixins/issue_card_inner.js b/app/assets/javascripts/boards/mixins/board_card_inner.js
index 04e971b756d..a6f278f3bc9 100644
--- a/app/assets/javascripts/boards/mixins/issue_card_inner.js
+++ b/app/assets/javascripts/boards/mixins/board_card_inner.js
@@ -1,10 +1,8 @@
export default {
- computed: {
+ methods: {
validIssueWeight() {
return false;
},
- },
- methods: {
filterByWeight() {},
},
};
diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
index fa58af24ba2..7f655091cd0 100644
--- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
+++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { mapGetters } from 'vuex';
-import BoardsSelector from '~/boards/components/boards_selector.vue';
+import BoardsSelector from 'ee_else_ce/boards/components/boards_selector.vue';
import BoardsSelectorDeprecated from '~/boards/components/boards_selector_deprecated.vue';
import store from '~/boards/stores';
import createDefaultClient from '~/lib/graphql';
@@ -48,10 +48,10 @@ export default (params = {}) => {
return { boardsSelectorProps };
},
computed: {
- ...mapGetters(['shouldUseGraphQL']),
+ ...mapGetters(['shouldUseGraphQL', 'isEpicBoard']),
},
render(createElement) {
- if (this.shouldUseGraphQL) {
+ if (this.shouldUseGraphQL || this.isEpicBoard) {
return createElement(BoardsSelector, {
props: this.boardsSelectorProps,
});
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index a7cf1e9e647..19b31ee7291 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -1,6 +1,13 @@
import { pick } from 'lodash';
+import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql';
import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
-import { BoardType, ListType, inactiveId, flashAnimationDuration } from '~/boards/constants';
+import {
+ BoardType,
+ ListType,
+ inactiveId,
+ flashAnimationDuration,
+ ISSUABLE,
+} from '~/boards/constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { convertObjectPropsToCamelCase, urlParamsToObject } from '~/lib/utils/common_utils';
@@ -15,7 +22,6 @@ import {
transformNotFilters,
} from '../boards_util';
import boardLabelsQuery from '../graphql/board_labels.query.graphql';
-import createBoardListMutation from '../graphql/board_list_create.mutation.graphql';
import destroyBoardListMutation from '../graphql/board_list_destroy.mutation.graphql';
import updateBoardListMutation from '../graphql/board_list_update.mutation.graphql';
import groupProjectsQuery from '../graphql/group_projects.query.graphql';
@@ -79,7 +85,11 @@ export default {
}
},
- fetchLists: ({ commit, state, dispatch }) => {
+ fetchLists: ({ dispatch }) => {
+ dispatch('fetchIssueLists');
+ },
+
+ fetchIssueLists: ({ commit, state, dispatch }) => {
const { boardType, filterParams, fullPath, boardId } = state;
const variables = {
@@ -118,9 +128,13 @@ export default {
}, flashAnimationDuration);
},
- createList: (
+ createList: ({ dispatch }, { backlog, labelId, milestoneId, assigneeId }) => {
+ dispatch('createIssueList', { backlog, labelId, milestoneId, assigneeId });
+ },
+
+ createIssueList: (
{ state, commit, dispatch, getters },
- { backlog, labelId, milestoneId, assigneeId },
+ { backlog, labelId, milestoneId, assigneeId, iterationId },
) => {
const { boardId } = state;
@@ -140,25 +154,29 @@ export default {
labelId,
milestoneId,
assigneeId,
+ iterationId,
},
})
.then(({ data }) => {
- if (data?.boardListCreate?.errors.length) {
- commit(types.CREATE_LIST_FAILURE);
+ if (data.boardListCreate?.errors.length) {
+ commit(types.CREATE_LIST_FAILURE, data.boardListCreate.errors[0]);
} else {
const list = data.boardListCreate?.list;
dispatch('addList', list);
dispatch('highlightList', list.id);
}
})
- .catch(() => commit(types.CREATE_LIST_FAILURE));
+ .catch((e) => {
+ commit(types.CREATE_LIST_FAILURE);
+ throw e;
+ });
},
addList: ({ commit }, list) => {
commit(types.RECEIVE_ADD_LIST_SUCCESS, updateListPosition(list));
},
- fetchLabels: ({ state, commit }, searchTerm) => {
+ fetchLabels: ({ state, commit, getters }, searchTerm) => {
const { fullPath, boardType } = state;
const variables = {
@@ -168,15 +186,29 @@ export default {
isProject: boardType === BoardType.project,
};
+ commit(types.RECEIVE_LABELS_REQUEST);
+
return gqlClient
.query({
query: boardLabelsQuery,
variables,
})
.then(({ data }) => {
- const labels = data[boardType]?.labels.nodes;
+ let labels = data[boardType]?.labels.nodes;
+
+ if (!getters.shouldUseGraphQL && !getters.isEpicBoard) {
+ labels = labels.map((label) => ({
+ ...label,
+ id: getIdFromGraphQLId(label.id),
+ }));
+ }
+
commit(types.RECEIVE_LABELS_SUCCESS, labels);
return labels;
+ })
+ .catch((e) => {
+ commit(types.RECEIVE_LABELS_FAILURE);
+ throw e;
});
},
@@ -225,6 +257,10 @@ export default {
});
},
+ toggleListCollapsed: ({ commit }, { listId, collapsed }) => {
+ commit(types.TOGGLE_LIST_COLLAPSED, { listId, collapsed });
+ },
+
removeList: ({ state, commit }, listId) => {
const listsBackup = { ...state.boardLists };
@@ -253,8 +289,8 @@ export default {
});
},
- fetchIssuesForList: ({ state, commit }, { listId, fetchNext = false }) => {
- commit(types.REQUEST_ISSUES_FOR_LIST, { listId, fetchNext });
+ fetchItemsForList: ({ state, commit }, { listId, fetchNext = false }) => {
+ commit(types.REQUEST_ITEMS_FOR_LIST, { listId, fetchNext });
const { fullPath, boardId, boardType, filterParams } = state;
@@ -279,28 +315,32 @@ export default {
})
.then(({ data }) => {
const { lists } = data[boardType]?.board;
- const listIssues = formatListIssues(lists);
+ const listItems = formatListIssues(lists);
const listPageInfo = formatListsPageInfo(lists);
- commit(types.RECEIVE_ISSUES_FOR_LIST_SUCCESS, { listIssues, listPageInfo, listId });
+ commit(types.RECEIVE_ITEMS_FOR_LIST_SUCCESS, { listItems, listPageInfo, listId });
})
- .catch(() => commit(types.RECEIVE_ISSUES_FOR_LIST_FAILURE, listId));
+ .catch(() => commit(types.RECEIVE_ITEMS_FOR_LIST_FAILURE, listId));
},
resetIssues: ({ commit }) => {
commit(types.RESET_ISSUES);
},
+ moveItem: ({ dispatch }) => {
+ dispatch('moveIssue');
+ },
+
moveIssue: (
{ state, commit },
- { issueId, issueIid, issuePath, fromListId, toListId, moveBeforeId, moveAfterId },
+ { itemId, itemIid, itemPath, fromListId, toListId, moveBeforeId, moveAfterId },
) => {
- const originalIssue = state.issues[issueId];
- const fromList = state.issuesByListId[fromListId];
- const originalIndex = fromList.indexOf(Number(issueId));
+ const originalIssue = state.boardItems[itemId];
+ const fromList = state.boardItemsByListId[fromListId];
+ const originalIndex = fromList.indexOf(Number(itemId));
commit(types.MOVE_ISSUE, { originalIssue, fromListId, toListId, moveBeforeId, moveAfterId });
const { boardId } = state;
- const [fullProjectPath] = issuePath.split(/[#]/);
+ const [fullProjectPath] = itemPath.split(/[#]/);
gqlClient
.mutate({
@@ -308,7 +348,7 @@ export default {
variables: {
projectPath: fullProjectPath,
boardId: fullBoardId(boardId),
- iid: issueIid,
+ iid: itemIid,
fromListId: getIdFromGraphQLId(fromListId),
toListId: getIdFromGraphQLId(toListId),
moveBeforeId,
@@ -317,7 +357,7 @@ export default {
})
.then(({ data }) => {
if (data?.issueMoveList?.errors.length) {
- commit(types.MOVE_ISSUE_FAILURE, { originalIssue, fromListId, toListId, originalIndex });
+ throw new Error();
} else {
const issue = data.issueMoveList?.issue;
commit(types.MOVE_ISSUE_SUCCESS, { issue });
@@ -532,10 +572,17 @@ export default {
commit(types.SET_SELECTED_PROJECT, project);
},
- toggleBoardItemMultiSelection: ({ commit, state }, boardItem) => {
+ toggleBoardItemMultiSelection: ({ commit, state, dispatch, getters }, boardItem) => {
const { selectedBoardItems } = state;
const index = selectedBoardItems.indexOf(boardItem);
+ // If user already selected an item (activeIssue) without using mult-select,
+ // include that item in the selection and unset state.ActiveId to hide the sidebar.
+ if (getters.activeIssue) {
+ commit(types.ADD_BOARD_ITEM_TO_SELECTION, getters.activeIssue);
+ dispatch('unsetActiveId');
+ }
+
if (index === -1) {
commit(types.ADD_BOARD_ITEM_TO_SELECTION, boardItem);
} else {
@@ -547,6 +594,20 @@ export default {
commit(types.SET_ADD_COLUMN_FORM_VISIBLE, visible);
},
+ resetBoardItemMultiSelection: ({ commit }) => {
+ commit(types.RESET_BOARD_ITEM_SELECTION);
+ },
+
+ toggleBoardItem: ({ state, dispatch }, { boardItem, sidebarType = ISSUABLE }) => {
+ dispatch('resetBoardItemMultiSelection');
+
+ if (boardItem.id === state.activeId) {
+ dispatch('unsetActiveId');
+ } else {
+ dispatch('setActiveId', { id: boardItem.id, sidebarType });
+ }
+ },
+
fetchBacklog: () => {
notImplemented();
},
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index fbff736c7e1..092f81ad279 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -575,7 +575,7 @@ const boardsStore = {
},
saveList(list) {
- const entity = list.label || list.assignee || list.milestone;
+ const entity = list.label || list.assignee || list.milestone || list.iteration;
let entityType = '';
if (list.label) {
entityType = 'label_id';
@@ -583,6 +583,8 @@ const boardsStore = {
entityType = 'assignee_id';
} else if (IS_EE && list.milestone) {
entityType = 'milestone_id';
+ } else if (IS_EE && list.iteration) {
+ entityType = 'iteration_id';
}
return this.createList(entity.id, entityType)
diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js
index cab97088bc6..caa518f91ce 100644
--- a/app/assets/javascripts/boards/stores/getters.js
+++ b/app/assets/javascripts/boards/stores/getters.js
@@ -1,20 +1,22 @@
import { find } from 'lodash';
-import { inactiveId } from '../constants';
+import { BoardType, inactiveId } from '../constants';
export default {
+ isGroupBoard: (state) => state.boardType === BoardType.group,
+ isProjectBoard: (state) => state.boardType === BoardType.project,
isSidebarOpen: (state) => state.activeId !== inactiveId,
isSwimlanesOn: () => false,
- getIssueById: (state) => (id) => {
- return state.issues[id] || {};
+ getBoardItemById: (state) => (id) => {
+ return state.boardItems[id] || {};
},
- getIssuesByList: (state, getters) => (listId) => {
- const listIssueIds = state.issuesByListId[listId] || [];
- return listIssueIds.map((id) => getters.getIssueById(id));
+ getBoardItemsByList: (state, getters) => (listId) => {
+ const listItemsIds = state.boardItemsByListId[listId] || [];
+ return listItemsIds.map((id) => getters.getBoardItemById(id));
},
activeIssue: (state) => {
- return state.issues[state.activeId] || {};
+ return state.boardItems[state.activeId] || {};
},
groupPathForActiveIssue: (_, getters) => {
@@ -27,6 +29,10 @@ export default {
return referencePath.slice(0, referencePath.indexOf('#'));
},
+ activeGroupProjects: (state) => {
+ return state.groupProjects.filter((p) => !p.archived);
+ },
+
getListByLabelId: (state) => (labelId) => {
if (!labelId) {
return null;
@@ -38,6 +44,10 @@ export default {
return find(state.boardLists, (l) => l.title === title);
},
+ isEpicBoard: () => {
+ return false;
+ },
+
shouldUseGraphQL: () => {
return gon?.features?.graphqlBoardLists;
},
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
index a89e961ae2d..e7c034fb087 100644
--- a/app/assets/javascripts/boards/stores/mutation_types.js
+++ b/app/assets/javascripts/boards/stores/mutation_types.js
@@ -2,7 +2,9 @@ export const SET_INITIAL_BOARD_DATA = 'SET_INITIAL_BOARD_DATA';
export const SET_FILTERS = 'SET_FILTERS';
export const CREATE_LIST_SUCCESS = 'CREATE_LIST_SUCCESS';
export const CREATE_LIST_FAILURE = 'CREATE_LIST_FAILURE';
+export const RECEIVE_LABELS_REQUEST = 'RECEIVE_LABELS_REQUEST';
export const RECEIVE_LABELS_SUCCESS = 'RECEIVE_LABELS_SUCCESS';
+export const RECEIVE_LABELS_FAILURE = 'RECEIVE_LABELS_FAILURE';
export const GENERATE_DEFAULT_LISTS_FAILURE = 'GENERATE_DEFAULT_LISTS_FAILURE';
export const RECEIVE_BOARD_LISTS_SUCCESS = 'RECEIVE_BOARD_LISTS_SUCCESS';
export const RECEIVE_BOARD_LISTS_FAILURE = 'RECEIVE_BOARD_LISTS_FAILURE';
@@ -12,11 +14,12 @@ export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS';
export const RECEIVE_ADD_LIST_ERROR = 'RECEIVE_ADD_LIST_ERROR';
export const MOVE_LIST = 'MOVE_LIST';
export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE';
+export const TOGGLE_LIST_COLLAPSED = 'TOGGLE_LIST_COLLAPSED';
export const REMOVE_LIST = 'REMOVE_LIST';
export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE';
-export const REQUEST_ISSUES_FOR_LIST = 'REQUEST_ISSUES_FOR_LIST';
-export const RECEIVE_ISSUES_FOR_LIST_FAILURE = 'RECEIVE_ISSUES_FOR_LIST_FAILURE';
-export const RECEIVE_ISSUES_FOR_LIST_SUCCESS = 'RECEIVE_ISSUES_FOR_LIST_SUCCESS';
+export const REQUEST_ITEMS_FOR_LIST = 'REQUEST_ITEMS_FOR_LIST';
+export const RECEIVE_ITEMS_FOR_LIST_FAILURE = 'RECEIVE_ITEMS_FOR_LIST_FAILURE';
+export const RECEIVE_ITEMS_FOR_LIST_SUCCESS = 'RECEIVE_ITEMS_FOR_LIST_SUCCESS';
export const CREATE_ISSUE_FAILURE = 'CREATE_ISSUE_FAILURE';
export const REQUEST_ADD_ISSUE = 'REQUEST_ADD_ISSUE';
export const RECEIVE_ADD_ISSUE_SUCCESS = 'RECEIVE_ADD_ISSUE_SUCCESS';
@@ -45,3 +48,4 @@ export const REMOVE_BOARD_ITEM_FROM_SELECTION = 'REMOVE_BOARD_ITEM_FROM_SELECTIO
export const SET_ADD_COLUMN_FORM_VISIBLE = 'SET_ADD_COLUMN_FORM_VISIBLE';
export const ADD_LIST_TO_HIGHLIGHTED_LISTS = 'ADD_LIST_TO_HIGHLIGHTED_LISTS';
export const REMOVE_LIST_FROM_HIGHLIGHTED_LISTS = 'REMOVE_LIST_FROM_HIGHLIGHTED_LISTS';
+export const RESET_BOARD_ITEM_SELECTION = 'RESET_BOARD_ITEM_SELECTION';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index 79c98c3d90c..75b60366b6a 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -2,7 +2,8 @@ import { pull, union } from 'lodash';
import Vue from 'vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { s__ } from '~/locale';
-import { formatIssue, moveIssueListHelper } from '../boards_util';
+import { formatIssue, moveItemListHelper } from '../boards_util';
+import { issuableTypes } from '../constants';
import * as mutationTypes from './mutation_types';
const notImplemented = () => {
@@ -10,34 +11,42 @@ const notImplemented = () => {
throw new Error('Not implemented!');
};
-export const removeIssueFromList = ({ state, listId, issueId }) => {
- Vue.set(state.issuesByListId, listId, pull(state.issuesByListId[listId], issueId));
+const updateListItemsCount = ({ state, listId, value }) => {
const list = state.boardLists[listId];
- Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount - 1 });
+ if (state.issuableType === issuableTypes.epic) {
+ Vue.set(state.boardLists, listId, { ...list, epicsCount: list.epicsCount + value });
+ } else {
+ Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount + value });
+ }
+};
+
+export const removeItemFromList = ({ state, listId, itemId }) => {
+ Vue.set(state.boardItemsByListId, listId, pull(state.boardItemsByListId[listId], itemId));
+ updateListItemsCount({ state, listId, value: -1 });
};
-export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atIndex }) => {
- const listIssues = state.issuesByListId[listId];
+export const addItemToList = ({ state, listId, itemId, moveBeforeId, moveAfterId, atIndex }) => {
+ const listIssues = state.boardItemsByListId[listId];
let newIndex = atIndex || 0;
if (moveBeforeId) {
newIndex = listIssues.indexOf(moveBeforeId) + 1;
} else if (moveAfterId) {
newIndex = listIssues.indexOf(moveAfterId);
}
- listIssues.splice(newIndex, 0, issueId);
- Vue.set(state.issuesByListId, listId, listIssues);
- const list = state.boardLists[listId];
- Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount + 1 });
+ listIssues.splice(newIndex, 0, itemId);
+ Vue.set(state.boardItemsByListId, listId, listIssues);
+ updateListItemsCount({ state, listId, value: 1 });
};
export default {
[mutationTypes.SET_INITIAL_BOARD_DATA](state, data) {
- const { boardType, disabled, boardId, fullPath, boardConfig } = data;
+ const { boardType, disabled, boardId, fullPath, boardConfig, issuableType } = data;
state.boardId = boardId;
state.fullPath = fullPath;
state.boardType = boardType;
state.disabled = disabled;
state.boardConfig = boardConfig;
+ state.issuableType = issuableType;
},
[mutationTypes.RECEIVE_BOARD_LISTS_SUCCESS]: (state, lists) => {
@@ -59,12 +68,25 @@ export default {
state.filterParams = filterParams;
},
- [mutationTypes.CREATE_LIST_FAILURE]: (state) => {
- state.error = s__('Boards|An error occurred while creating the list. Please try again.');
+ [mutationTypes.CREATE_LIST_FAILURE]: (
+ state,
+ error = s__('Boards|An error occurred while creating the list. Please try again.'),
+ ) => {
+ state.error = error;
+ },
+
+ [mutationTypes.RECEIVE_LABELS_REQUEST]: (state) => {
+ state.labelsLoading = true;
},
[mutationTypes.RECEIVE_LABELS_SUCCESS]: (state, labels) => {
state.labels = labels;
+ state.labelsLoading = false;
+ },
+
+ [mutationTypes.RECEIVE_LABELS_FAILURE]: (state) => {
+ state.error = s__('Boards|An error occurred while fetching labels. Please reload the page.');
+ state.labelsLoading = false;
},
[mutationTypes.GENERATE_DEFAULT_LISTS_FAILURE]: (state) => {
@@ -94,6 +116,10 @@ export default {
Vue.set(state, 'boardLists', backupList);
},
+ [mutationTypes.TOGGLE_LIST_COLLAPSED]: (state, { listId, collapsed }) => {
+ Vue.set(state.boardLists[listId], 'collapsed', collapsed);
+ },
+
[mutationTypes.REMOVE_LIST]: (state, listId) => {
Vue.delete(state.boardLists, listId);
},
@@ -103,26 +129,23 @@ export default {
state.boardLists = listsBackup;
},
- [mutationTypes.REQUEST_ISSUES_FOR_LIST]: (state, { listId, fetchNext }) => {
+ [mutationTypes.REQUEST_ITEMS_FOR_LIST]: (state, { listId, fetchNext }) => {
Vue.set(state.listsFlags, listId, { [fetchNext ? 'isLoadingMore' : 'isLoading']: true });
},
- [mutationTypes.RECEIVE_ISSUES_FOR_LIST_SUCCESS]: (
- state,
- { listIssues, listPageInfo, listId },
- ) => {
- const { listData, issues } = listIssues;
- Vue.set(state, 'issues', { ...state.issues, ...issues });
+ [mutationTypes.RECEIVE_ITEMS_FOR_LIST_SUCCESS]: (state, { listItems, listPageInfo, listId }) => {
+ const { listData, boardItems } = listItems;
+ Vue.set(state, 'boardItems', { ...state.boardItems, ...boardItems });
Vue.set(
- state.issuesByListId,
+ state.boardItemsByListId,
listId,
- union(state.issuesByListId[listId] || [], listData[listId]),
+ union(state.boardItemsByListId[listId] || [], listData[listId]),
);
Vue.set(state.pageInfoByListId, listId, listPageInfo[listId]);
Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: false });
},
- [mutationTypes.RECEIVE_ISSUES_FOR_LIST_FAILURE]: (state, listId) => {
+ [mutationTypes.RECEIVE_ITEMS_FOR_LIST_FAILURE]: (state, listId) => {
state.error = s__(
'Boards|An error occurred while fetching the board issues. Please reload the page.',
);
@@ -130,18 +153,18 @@ export default {
},
[mutationTypes.RESET_ISSUES]: (state) => {
- Object.keys(state.issuesByListId).forEach((listId) => {
- Vue.set(state.issuesByListId, listId, []);
+ Object.keys(state.boardItemsByListId).forEach((listId) => {
+ Vue.set(state.boardItemsByListId, listId, []);
});
},
[mutationTypes.UPDATE_ISSUE_BY_ID]: (state, { issueId, prop, value }) => {
- if (!state.issues[issueId]) {
+ if (!state.boardItems[issueId]) {
/* eslint-disable-next-line @gitlab/require-i18n-strings */
throw new Error('No issue found.');
}
- Vue.set(state.issues[issueId], prop, value);
+ Vue.set(state.boardItems[issueId], prop, value);
},
[mutationTypes.SET_ASSIGNEE_LOADING](state, isLoading) {
@@ -167,16 +190,16 @@ export default {
const fromList = state.boardLists[fromListId];
const toList = state.boardLists[toListId];
- const issue = moveIssueListHelper(originalIssue, fromList, toList);
- Vue.set(state.issues, issue.id, issue);
+ const issue = moveItemListHelper(originalIssue, fromList, toList);
+ Vue.set(state.boardItems, issue.id, issue);
- removeIssueFromList({ state, listId: fromListId, issueId: issue.id });
- addIssueToList({ state, listId: toListId, issueId: issue.id, moveBeforeId, moveAfterId });
+ removeItemFromList({ state, listId: fromListId, itemId: issue.id });
+ addItemToList({ state, listId: toListId, itemId: issue.id, moveBeforeId, moveAfterId });
},
[mutationTypes.MOVE_ISSUE_SUCCESS]: (state, { issue }) => {
const issueId = getIdFromGraphQLId(issue.id);
- Vue.set(state.issues, issueId, formatIssue({ ...issue, id: issueId }));
+ Vue.set(state.boardItems, issueId, formatIssue({ ...issue, id: issueId }));
},
[mutationTypes.MOVE_ISSUE_FAILURE]: (
@@ -184,12 +207,12 @@ export default {
{ originalIssue, fromListId, toListId, originalIndex },
) => {
state.error = s__('Boards|An error occurred while moving the issue. Please try again.');
- Vue.set(state.issues, originalIssue.id, originalIssue);
- removeIssueFromList({ state, listId: toListId, issueId: originalIssue.id });
- addIssueToList({
+ Vue.set(state.boardItems, originalIssue.id, originalIssue);
+ removeItemFromList({ state, listId: toListId, itemId: originalIssue.id });
+ addItemToList({
state,
listId: fromListId,
- issueId: originalIssue.id,
+ itemId: originalIssue.id,
atIndex: originalIndex,
});
},
@@ -211,23 +234,23 @@ export default {
},
[mutationTypes.ADD_ISSUE_TO_LIST]: (state, { list, issue, position }) => {
- addIssueToList({
+ addItemToList({
state,
listId: list.id,
- issueId: issue.id,
+ itemId: issue.id,
atIndex: position,
});
- Vue.set(state.issues, issue.id, issue);
+ Vue.set(state.boardItems, issue.id, issue);
},
[mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issueId }) => {
state.error = s__('Boards|An error occurred while creating the issue. Please try again.');
- removeIssueFromList({ state, listId: list.id, issueId });
+ removeItemFromList({ state, listId: list.id, itemId: issueId });
},
[mutationTypes.REMOVE_ISSUE_FROM_LIST]: (state, { list, issue }) => {
- removeIssueFromList({ state, listId: list.id, issueId: issue.id });
- Vue.delete(state.issues, issue.id);
+ removeItemFromList({ state, listId: list.id, itemId: issue.id });
+ Vue.delete(state.boardItems, issue.id);
},
[mutationTypes.SET_CURRENT_PAGE]: () => {
@@ -272,7 +295,7 @@ export default {
},
[mutationTypes.SET_ADD_COLUMN_FORM_VISIBLE]: (state, visible) => {
- state.addColumnFormVisible = visible;
+ Vue.set(state.addColumnForm, 'visible', visible);
},
[mutationTypes.ADD_LIST_TO_HIGHLIGHTED_LISTS]: (state, listId) => {
@@ -282,4 +305,8 @@ export default {
[mutationTypes.REMOVE_LIST_FROM_HIGHLIGHTED_LISTS]: (state, listId) => {
state.highlightedLists = state.highlightedLists.filter((id) => id !== listId);
},
+
+ [mutationTypes.RESET_BOARD_ITEM_SELECTION]: (state) => {
+ state.selectedBoardItems = [];
+ },
};
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js
index 91544d6c9c5..19ba2a5df83 100644
--- a/app/assets/javascripts/boards/stores/state.js
+++ b/app/assets/javascripts/boards/stores/state.js
@@ -1,19 +1,22 @@
-import { inactiveId } from '~/boards/constants';
+import { inactiveId, ListType } from '~/boards/constants';
export default () => ({
boardType: null,
+ issuableType: null,
+ fullPath: null,
disabled: false,
isShowingLabels: true,
activeId: inactiveId,
sidebarType: '',
boardLists: {},
listsFlags: {},
- issuesByListId: {},
+ boardItemsByListId: {},
isSettingAssignees: false,
pageInfoByListId: {},
- issues: {},
+ boardItems: {},
filterParams: {},
boardConfig: {},
+ labelsLoading: false,
labels: [],
highlightedLists: [],
selectedBoardItems: [],
@@ -25,7 +28,10 @@ export default () => ({
},
selectedProject: {},
error: undefined,
- addColumnFormVisible: false,
+ addColumnForm: {
+ visible: false,
+ columnType: ListType.label,
+ },
// TODO: remove after ce/ee split of board_content.vue
isShowingEpicsSwimlanes: false,
});
diff --git a/app/assets/javascripts/captcha/captcha_modal.vue b/app/assets/javascripts/captcha/captcha_modal.vue
index e6c73bc9643..a98a52a3130 100644
--- a/app/assets/javascripts/captcha/captcha_modal.vue
+++ b/app/assets/javascripts/captcha/captcha_modal.vue
@@ -41,10 +41,17 @@ export default {
}
},
},
+ mounted() {
+ // If this is true, we need to present the captcha modal to the user.
+ // When the modal is shown we will also initialize and render the form.
+ if (this.needsCaptchaResponse) {
+ this.$refs.modal.show();
+ }
+ },
methods: {
emitReceivedCaptchaResponse(captchaResponse) {
- this.$emit('receivedCaptchaResponse', captchaResponse);
this.$refs.modal.hide();
+ this.$emit('receivedCaptchaResponse', captchaResponse);
},
emitNullReceivedCaptchaResponse() {
this.emitReceivedCaptchaResponse(null);
@@ -103,6 +110,7 @@ export default {
:action-cancel="{ text: __('Cancel') }"
@shown="shown"
@hide="hide"
+ @hidden="$emit('hidden')"
>
<div ref="captcha"></div>
<p>{{ __('We want to be sure it is you, please confirm you are not a robot.') }}</p>
diff --git a/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js b/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js
new file mode 100644
index 00000000000..c9eac44eb28
--- /dev/null
+++ b/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js
@@ -0,0 +1,37 @@
+const supportedMethods = ['patch', 'post', 'put'];
+
+export function registerCaptchaModalInterceptor(axios) {
+ return axios.interceptors.response.use(
+ (response) => {
+ return response;
+ },
+ (err) => {
+ if (
+ supportedMethods.includes(err?.config?.method) &&
+ err?.response?.data?.needs_captcha_response
+ ) {
+ const { data } = err.response;
+ const captchaSiteKey = data.captcha_site_key;
+ const spamLogId = data.spam_log_id;
+ // eslint-disable-next-line promise/no-promise-in-callback
+ return import('~/captcha/wait_for_captcha_to_be_solved')
+ .then(({ waitForCaptchaToBeSolved }) => waitForCaptchaToBeSolved(captchaSiteKey))
+ .then((captchaResponse) => {
+ const errConfig = err.config;
+ const originalData = JSON.parse(errConfig.data);
+ return axios({
+ method: errConfig.method,
+ url: errConfig.url,
+ data: {
+ ...originalData,
+ captcha_response: captchaResponse,
+ spam_log_id: spamLogId,
+ },
+ });
+ });
+ }
+
+ return Promise.reject(err);
+ },
+ );
+}
diff --git a/app/assets/javascripts/captcha/unsolved_captcha_error.js b/app/assets/javascripts/captcha/unsolved_captcha_error.js
new file mode 100644
index 00000000000..1e5c2a4d852
--- /dev/null
+++ b/app/assets/javascripts/captcha/unsolved_captcha_error.js
@@ -0,0 +1,10 @@
+import { __ } from '~/locale';
+
+class UnsolvedCaptchaError extends Error {
+ constructor(message) {
+ super(message || __('You must solve the CAPTCHA in order to submit'));
+ this.name = 'UnsolvedCaptchaError';
+ }
+}
+
+export default UnsolvedCaptchaError;
diff --git a/app/assets/javascripts/captcha/wait_for_captcha_to_be_solved.js b/app/assets/javascripts/captcha/wait_for_captcha_to_be_solved.js
new file mode 100644
index 00000000000..0fd0f571d3b
--- /dev/null
+++ b/app/assets/javascripts/captcha/wait_for_captcha_to_be_solved.js
@@ -0,0 +1,53 @@
+import Vue from 'vue';
+import CaptchaModal from '~/captcha/captcha_modal.vue';
+import UnsolvedCaptchaError from '~/captcha/unsolved_captcha_error';
+
+/**
+ * Opens a Captcha Modal with provided captchaSiteKey.
+ *
+ * Returns a Promise which resolves if the captcha is solved correctly, and rejects
+ * if the captcha process is aborted.
+ *
+ * @param captchaSiteKey
+ * @returns {Promise}
+ */
+export function waitForCaptchaToBeSolved(captchaSiteKey) {
+ return new Promise((resolve, reject) => {
+ let captchaModalElement = document.createElement('div');
+
+ document.body.append(captchaModalElement);
+
+ let captchaModalVueInstance = new Vue({
+ el: captchaModalElement,
+ render: (createElement) => {
+ return createElement(CaptchaModal, {
+ props: {
+ captchaSiteKey,
+ needsCaptchaResponse: true,
+ },
+ on: {
+ hidden: () => {
+ // Cleaning up the modal from the DOM
+ captchaModalVueInstance.$destroy();
+ captchaModalVueInstance.$el.remove();
+ captchaModalElement.remove();
+
+ captchaModalElement = null;
+ captchaModalVueInstance = null;
+ },
+ receivedCaptchaResponse: (captchaResponse) => {
+ if (captchaResponse) {
+ resolve(captchaResponse);
+ } else {
+ // reject the promise with a custom exception, allowing consuming apps to
+ // adjust their error handling, if appropriate.
+ const error = new UnsolvedCaptchaError();
+ reject(error);
+ }
+ },
+ },
+ });
+ },
+ });
+ });
+}
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue
index 104d6672015..ecb39f214ec 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue
@@ -20,7 +20,7 @@ export default {
},
data() {
return {
- searchTerm: this.value || '',
+ searchTerm: '',
};
},
computed: {
@@ -38,11 +38,6 @@ export default {
);
},
},
- watch: {
- value(newVal) {
- this.searchTerm = newVal;
- },
- },
methods: {
selectEnvironment(selected) {
this.$emit('selectEnvironment', selected);
@@ -55,11 +50,14 @@ export default {
isSelected(env) {
return this.value === env;
},
+ clearSearch() {
+ this.searchTerm = '';
+ },
},
};
</script>
<template>
- <gl-dropdown :text="value">
+ <gl-dropdown :text="value" @show="clearSearch">
<gl-search-box-by-type v-model.trim="searchTerm" data-testid="ci-environment-search" />
<gl-dropdown-item
v-for="environment in filteredResults"
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
index af4349083b6..be7c0b68b4c 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
@@ -265,7 +265,6 @@ export default {
<gl-form-checkbox
ref="masked-ci-variable"
v-model="masked"
- data-qa-selector="ci_variable_masked_checkbox"
data-testid="ci-variable-masked-checkbox"
>
{{ __('Mask variable') }}
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
index 47b2745af08..c9943052356 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
@@ -84,7 +84,7 @@ export default {
</script>
<template>
- <div class="ci-variable-table">
+ <div class="ci-variable-table" data-testid="ci-variable-table">
<gl-table
:fields="fields"
:items="variables"
diff --git a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue
index 369cb2fa0f3..aaad0009ef3 100644
--- a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue
+++ b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue
@@ -54,15 +54,17 @@ export default {
required: false,
},
},
- data: () => ({
- currentServerSideSettings: {
- host: null,
- port: null,
- protocol: null,
- wafLogEnabled: null,
- ciliumLogEnabled: null,
- },
- }),
+ data() {
+ return {
+ currentServerSideSettings: {
+ host: null,
+ port: null,
+ protocol: null,
+ wafLogEnabled: null,
+ ciliumLogEnabled: null,
+ },
+ };
+ },
computed: {
isSaving() {
return [UPDATING].includes(this.status);
diff --git a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue
index 26767c32275..1ba28660e5c 100644
--- a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue
+++ b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue
@@ -18,6 +18,9 @@ import { s__, __ } from '../../locale';
const { UPDATING, UNINSTALLING, INSTALLING, INSTALLED, UPDATED } = APPLICATION_STATUS;
export default {
+ i18n: {
+ modSecurityEnabled: s__('ClusterIntegration|ModSecurity enabled'),
+ },
title: __('Web Application Firewall'),
modsecurityUrl: 'https://modsecurity.org/about.html',
components: {
@@ -53,11 +56,13 @@ export default {
}),
},
},
- data: () => ({
- modSecurityLogo,
- initialValue: null,
- initialMode: null,
- }),
+ data() {
+ return {
+ modSecurityLogo,
+ initialValue: null,
+ initialMode: null,
+ };
+ },
computed: {
modSecurityEnabled: {
get() {
@@ -200,7 +205,12 @@ export default {
</strong>
</p>
<div class="form-check form-check-inline mt-3">
- <gl-toggle v-model="modSecurityEnabled" :disabled="saveButtonDisabled" />
+ <gl-toggle
+ v-model="modSecurityEnabled"
+ :disabled="saveButtonDisabled"
+ :label="$options.i18n.modSecurityEnabled"
+ label-position="hidden"
+ />
</div>
<div
v-if="ingress.modsecurity_enabled"
diff --git a/app/assets/javascripts/clusters/forms/components/integration_form.vue b/app/assets/javascripts/clusters/forms/components/integration_form.vue
index f0dafa7ef53..a344f9578fd 100644
--- a/app/assets/javascripts/clusters/forms/components/integration_form.vue
+++ b/app/assets/javascripts/clusters/forms/components/integration_form.vue
@@ -9,8 +9,14 @@ import {
GlButton,
} from '@gitlab/ui';
import { mapState } from 'vuex';
+import { s__ } from '~/locale';
export default {
+ i18n: {
+ toggleLabel: s__(
+ "ClusterIntegration|Enable or disable GitLab's connection to your Kubernetes cluster.",
+ ),
+ },
components: {
GlFormGroup,
GlToggle,
@@ -79,11 +85,9 @@ export default {
data-qa-selector="integration_status_toggle"
aria-describedby="toggleCluster"
:disabled="!editable"
- :title="
- s__(
- 'ClusterIntegration|Enable or disable GitLab\'s connection to your Kubernetes cluster.',
- )
- "
+ :label="$options.i18n.toggleLabel"
+ label-position="hidden"
+ :title="$options.i18n.toggleLabel"
/>
</div>
</div>
diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js
index 45d3e0cbc23..40a86a1e58c 100644
--- a/app/assets/javascripts/clusters_list/store/actions.js
+++ b/app/assets/javascripts/clusters_list/store/actions.js
@@ -1,9 +1,9 @@
+import * as Sentry from '@sentry/browser';
import { deprecatedCreateFlash as flash } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import Poll from '~/lib/utils/poll';
import { __ } from '~/locale';
-import * as Sentry from '~/sentry/wrapper';
import { MAX_REQUESTS } from '../constants';
import * as types from './mutation_types';
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
index 920ffde3e32..2e050c066f1 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
+++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
@@ -31,10 +31,8 @@ export default () => {
return createElement(CommitPipelinesTable, {
props: {
endpoint: pipelineTableViewEl.dataset.endpoint,
- helpPagePath: pipelineTableViewEl.dataset.helpPagePath,
emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath,
errorStateSvgPath: pipelineTableViewEl.dataset.errorStateSvgPath,
- autoDevopsHelpPath: pipelineTableViewEl.dataset.helpAutoDevopsPath,
},
});
},
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
index 787152d00ef..e1cca5adc73 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
@@ -8,6 +8,7 @@ import PipelinesMixin from '~/pipelines/mixins/pipelines_mixin';
import PipelinesService from '~/pipelines/services/pipelines_service';
import PipelineStore from '~/pipelines/stores/pipelines_store';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
@@ -19,20 +20,12 @@ export default {
TablePagination,
SvgBlankState,
},
- mixins: [PipelinesMixin],
+ mixins: [PipelinesMixin, glFeatureFlagMixin()],
props: {
endpoint: {
type: String,
required: true,
},
- helpPagePath: {
- type: String,
- required: true,
- },
- autoDevopsHelpPath: {
- type: String,
- required: true,
- },
errorStateSvgPath: {
type: String,
required: true,
@@ -98,6 +91,9 @@ export default {
canRenderPipelineButton() {
return this.latestPipelineDetachedFlag;
},
+ pipelineButtonClass() {
+ return !this.glFeatures.newPipelinesTable ? 'gl-md-display-none' : 'gl-lg-display-none';
+ },
isForkMergeRequest() {
return this.sourceProjectFullPath !== this.targetProjectFullPath;
},
@@ -200,7 +196,8 @@ export default {
<gl-button
v-if="canRenderPipelineButton"
block
- class="gl-mt-3 gl-mb-0 gl-md-display-none"
+ class="gl-mt-3 gl-mb-3"
+ :class="pipelineButtonClass"
variant="success"
data-testid="run_pipeline_button_mobile"
:loading="state.isRunningMergeRequestPipeline"
@@ -212,7 +209,6 @@ export default {
<pipelines-table-component
:pipelines="state.pipelines"
:update-graph-dropdown="updateGraphDropdown"
- :auto-devops-help-path="autoDevopsHelpPath"
:view-type="viewType"
>
<template #table-header-actions>
diff --git a/app/assets/javascripts/commons/bootstrap.js b/app/assets/javascripts/commons/bootstrap.js
index f750c62103e..e5e23f2fb5e 100644
--- a/app/assets/javascripts/commons/bootstrap.js
+++ b/app/assets/javascripts/commons/bootstrap.js
@@ -6,8 +6,6 @@ import 'bootstrap/js/dist/button';
import 'bootstrap/js/dist/collapse';
import 'bootstrap/js/dist/modal';
import 'bootstrap/js/dist/dropdown';
-import 'bootstrap/js/dist/popover';
-import 'bootstrap/js/dist/tooltip';
import 'bootstrap/js/dist/tab';
// custom jQuery functions
@@ -19,68 +17,3 @@ $.fn.extend({
return $(this).prop('disabled', false).removeClass('disabled');
},
});
-
-/*
- Starting with bootstrap 4.3.1, bootstrap sanitizes html used for tooltips / popovers.
- This extends the default whitelists with more elements / attributes:
- https://getbootstrap.com/docs/4.3/getting-started/javascript/#sanitizer
- */
-const whitelist = $.fn.tooltip.Constructor.Default.whiteList;
-
-const inputAttributes = ['value', 'type'];
-
-const dataAttributes = [
- 'data-toggle',
- 'data-placement',
- 'data-container',
- 'data-title',
- 'data-class',
- 'data-clipboard-text',
- 'data-placement',
-];
-
-// Whitelisting data attributes
-whitelist['*'] = [
- ...whitelist['*'],
- ...dataAttributes,
- 'title',
- 'width height',
- 'abbr',
- 'datetime',
- 'name',
- 'width',
- 'height',
-];
-
-// Whitelist missing elements:
-whitelist.label = ['for'];
-whitelist.button = [...inputAttributes];
-whitelist.input = [...inputAttributes];
-
-whitelist.tt = [];
-whitelist.samp = [];
-whitelist.kbd = [];
-whitelist.var = [];
-whitelist.dfn = [];
-whitelist.cite = [];
-whitelist.big = [];
-whitelist.address = [];
-whitelist.dl = [];
-whitelist.dt = [];
-whitelist.dd = [];
-whitelist.abbr = [];
-whitelist.acronym = [];
-whitelist.blockquote = [];
-whitelist.del = [];
-whitelist.ins = [];
-whitelist['gl-emoji'] = [
- 'data-name',
- 'data-unicode-version',
- 'data-fallback-src',
- 'data-fallback-sprite-class',
-];
-
-// Whitelisting SVG tags and attributes
-whitelist.svg = ['viewBox'];
-whitelist.use = ['xlink:href'];
-whitelist.path = ['d'];
diff --git a/app/assets/javascripts/commons/vue.js b/app/assets/javascripts/commons/vue.js
index 5b5a1507d38..23647d99656 100644
--- a/app/assets/javascripts/commons/vue.js
+++ b/app/assets/javascripts/commons/vue.js
@@ -6,3 +6,5 @@ if (process.env.NODE_ENV !== 'production') {
}
Vue.use(GlFeatureFlagsPlugin);
+
+Vue.config.ignoredElements = ['gl-emoji'];
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/constants.js b/app/assets/javascripts/create_cluster/eks_cluster/constants.js
index 0f0db2090c1..1c698cc2796 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/constants.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/constants.js
@@ -1,8 +1,9 @@
export const DEFAULT_REGION = 'us-east-2';
export const KUBERNETES_VERSIONS = [
- { name: '1.14', value: '1.14' },
{ name: '1.15', value: '1.15' },
- { name: '1.16', value: '1.16', default: true },
+ { name: '1.16', value: '1.16' },
{ name: '1.17', value: '1.17' },
+ { name: '1.18', value: '1.18' },
+ { name: '1.19', value: '1.19', default: true },
];
diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue
new file mode 100644
index 00000000000..df77d641e21
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/base.vue
@@ -0,0 +1,288 @@
+<script>
+import { GlIcon, GlEmptyState, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+import Cookies from 'js-cookie';
+import { deprecatedCreateFlash as Flash } from '~/flash';
+import { __ } from '~/locale';
+import banner from './banner.vue';
+import stageCodeComponent from './stage_code_component.vue';
+import stageComponent from './stage_component.vue';
+import stageNavItem from './stage_nav_item.vue';
+import stageReviewComponent from './stage_review_component.vue';
+import stageStagingComponent from './stage_staging_component.vue';
+import stageTestComponent from './stage_test_component.vue';
+
+const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
+
+export default {
+ name: 'CycleAnalytics',
+ components: {
+ GlIcon,
+ GlEmptyState,
+ GlLoadingIcon,
+ GlSprintf,
+ banner,
+ 'stage-issue-component': stageComponent,
+ 'stage-plan-component': stageComponent,
+ 'stage-code-component': stageCodeComponent,
+ 'stage-test-component': stageTestComponent,
+ 'stage-review-component': stageReviewComponent,
+ 'stage-staging-component': stageStagingComponent,
+ 'stage-production-component': stageComponent,
+ 'stage-nav-item': stageNavItem,
+ },
+ props: {
+ noDataSvgPath: {
+ type: String,
+ required: true,
+ },
+ noAccessSvgPath: {
+ type: String,
+ required: true,
+ },
+ store: {
+ type: Object,
+ required: true,
+ },
+ service: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ state: this.store.state,
+ isLoading: false,
+ isLoadingStage: false,
+ isEmptyStage: false,
+ hasError: true,
+ startDate: 30,
+ isOverviewDialogDismissed: Cookies.get(OVERVIEW_DIALOG_COOKIE),
+ };
+ },
+ computed: {
+ currentStage() {
+ return this.store.currentActiveStage();
+ },
+ },
+ created() {
+ this.fetchCycleAnalyticsData();
+ },
+ methods: {
+ handleError() {
+ this.store.setErrorState(true);
+ return new Flash(__('There was an error while fetching value stream analytics data.'));
+ },
+ handleDateSelect(startDate) {
+ this.startDate = startDate;
+ this.fetchCycleAnalyticsData({ startDate: this.startDate });
+ },
+ fetchCycleAnalyticsData(options) {
+ const fetchOptions = options || { startDate: this.startDate };
+
+ this.isLoading = true;
+
+ this.service
+ .fetchCycleAnalyticsData(fetchOptions)
+ .then((response) => {
+ this.store.setCycleAnalyticsData(response);
+ this.selectDefaultStage();
+ })
+ .catch(() => {
+ this.handleError();
+ })
+ .finally(() => {
+ this.isLoading = false;
+ });
+ },
+ selectDefaultStage() {
+ const stage = this.state.stages[0];
+ this.selectStage(stage);
+ },
+ selectStage(stage) {
+ if (this.isLoadingStage) return;
+ if (this.currentStage === stage) return;
+
+ if (!stage.isUserAllowed) {
+ this.store.setActiveStage(stage);
+ return;
+ }
+
+ this.isLoadingStage = true;
+ this.store.setStageEvents([], stage);
+ this.store.setActiveStage(stage);
+
+ this.service
+ .fetchStageData({
+ stage,
+ startDate: this.startDate,
+ projectIds: this.selectedProjectIds,
+ })
+ .then((response) => {
+ this.isEmptyStage = !response.events.length;
+ this.store.setStageEvents(response.events, stage);
+ })
+ .catch(() => {
+ this.isEmptyStage = true;
+ })
+ .finally(() => {
+ this.isLoadingStage = false;
+ });
+ },
+ dismissOverviewDialog() {
+ this.isOverviewDialogDismissed = true;
+ Cookies.set(OVERVIEW_DIALOG_COOKIE, '1', { expires: 365 });
+ },
+ },
+ dayRangeOptions: [7, 30, 90],
+ i18n: {
+ dropdownText: __('Last %{days} days'),
+ },
+};
+</script>
+<template>
+ <div class="cycle-analytics">
+ <gl-loading-icon v-if="isLoading" size="lg" />
+ <div v-else class="wrapper">
+ <div class="card">
+ <div class="card-header">{{ __('Recent Project Activity') }}</div>
+ <div class="d-flex justify-content-between">
+ <div v-for="item in state.summary" :key="item.title" class="flex-grow text-center">
+ <h3 class="header">{{ item.value }}</h3>
+ <p class="text">{{ item.title }}</p>
+ </div>
+ <div class="flex-grow align-self-center text-center">
+ <div class="js-ca-dropdown dropdown inline">
+ <button class="dropdown-menu-toggle" data-toggle="dropdown" type="button">
+ <span class="dropdown-label">
+ <gl-sprintf :message="$options.i18n.dropdownText">
+ <template #days>{{ startDate }}</template>
+ </gl-sprintf>
+ <gl-icon name="chevron-down" class="dropdown-menu-toggle-icon gl-top-3" />
+ </span>
+ </button>
+ <ul class="dropdown-menu dropdown-menu-right">
+ <li v-for="days in $options.dayRangeOptions" :key="`day-range-${days}`">
+ <a href="#" @click.prevent="handleDateSelect(days)">
+ <gl-sprintf :message="$options.i18n.dropdownText">
+ <template #days>{{ days }}</template>
+ </gl-sprintf>
+ </a>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="stage-panel-container">
+ <div class="card stage-panel">
+ <div class="card-header border-bottom-0">
+ <nav class="col-headers">
+ <ul>
+ <li class="stage-header pl-5">
+ <span class="stage-name font-weight-bold">{{
+ s__('ProjectLifecycle|Stage')
+ }}</span>
+ <span
+ class="has-tooltip"
+ data-placement="top"
+ :title="__('The phase of the development lifecycle.')"
+ aria-hidden="true"
+ >
+ <gl-icon name="question-o" class="gl-text-gray-500" />
+ </span>
+ </li>
+ <li class="median-header">
+ <span class="stage-name font-weight-bold">{{ __('Median') }}</span>
+ <span
+ class="has-tooltip"
+ data-placement="top"
+ :title="
+ __(
+ 'The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.',
+ )
+ "
+ aria-hidden="true"
+ >
+ <gl-icon name="question-o" class="gl-text-gray-500" />
+ </span>
+ </li>
+ <li class="event-header pl-3">
+ <span
+ v-if="currentStage && currentStage.legend"
+ class="stage-name font-weight-bold"
+ >{{ currentStage ? __(currentStage.legend) : __('Related Issues') }}</span
+ >
+ <span
+ class="has-tooltip"
+ data-placement="top"
+ :title="
+ __('The collection of events added to the data gathered for that stage.')
+ "
+ aria-hidden="true"
+ >
+ <gl-icon name="question-o" class="gl-text-gray-500" />
+ </span>
+ </li>
+ <li class="total-time-header pr-5 text-right">
+ <span class="stage-name font-weight-bold">{{ __('Time') }}</span>
+ <span
+ class="has-tooltip"
+ data-placement="top"
+ :title="__('The time taken by each data entry gathered by that stage.')"
+ aria-hidden="true"
+ >
+ <gl-icon name="question-o" class="gl-text-gray-500" />
+ </span>
+ </li>
+ </ul>
+ </nav>
+ </div>
+
+ <div class="stage-panel-body">
+ <nav class="stage-nav">
+ <ul>
+ <stage-nav-item
+ v-for="stage in state.stages"
+ :key="stage.title"
+ :title="stage.title"
+ :is-user-allowed="stage.isUserAllowed"
+ :value="stage.value"
+ :is-active="stage.active"
+ @select="selectStage(stage)"
+ />
+ </ul>
+ </nav>
+ <section class="stage-events overflow-auto">
+ <gl-loading-icon v-show="isLoadingStage" size="lg" />
+ <template v-if="currentStage && !currentStage.isUserAllowed">
+ <gl-empty-state
+ class="js-empty-state"
+ :title="__('You need permission.')"
+ :svg-path="noAccessSvgPath"
+ :description="__('Want to see the data? Please ask an administrator for access.')"
+ />
+ </template>
+ <template v-else>
+ <template v-if="currentStage && isEmptyStage && !isLoadingStage">
+ <gl-empty-state
+ class="js-empty-state"
+ :description="currentStage.emptyStageText"
+ :svg-path="noDataSvgPath"
+ :title="__('We don\'t have enough data to show this stage.')"
+ />
+ </template>
+ <template v-if="state.events.length && !isLoadingStage && !isEmptyStage">
+ <component
+ :is="currentStage.component"
+ :stage="currentStage"
+ :items="state.events"
+ />
+ </template>
+ </template>
+ </section>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
deleted file mode 100644
index 847820c965f..00000000000
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
+++ /dev/null
@@ -1,156 +0,0 @@
-// This is a true violation of @gitlab/no-runtime-template-compiler, as it
-// relies on app/views/projects/cycle_analytics/show.html.haml for its
-// template.
-/* eslint-disable @gitlab/no-runtime-template-compiler */
-import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
-import $ from 'jquery';
-import Cookies from 'js-cookie';
-import Vue from 'vue';
-import { __ } from '~/locale';
-import { deprecatedCreateFlash as Flash } from '../flash';
-import Translate from '../vue_shared/translate';
-import banner from './components/banner.vue';
-import stageCodeComponent from './components/stage_code_component.vue';
-import stageComponent from './components/stage_component.vue';
-import stageNavItem from './components/stage_nav_item.vue';
-import stageReviewComponent from './components/stage_review_component.vue';
-import stageStagingComponent from './components/stage_staging_component.vue';
-import stageTestComponent from './components/stage_test_component.vue';
-import CycleAnalyticsService from './cycle_analytics_service';
-import CycleAnalyticsStore from './cycle_analytics_store';
-
-Vue.use(Translate);
-
-export default () => {
- const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
- const cycleAnalyticsEl = document.querySelector('#cycle-analytics');
-
- // eslint-disable-next-line no-new
- new Vue({
- el: '#cycle-analytics',
- name: 'CycleAnalytics',
- components: {
- GlEmptyState,
- GlLoadingIcon,
- banner,
- 'stage-issue-component': stageComponent,
- 'stage-plan-component': stageComponent,
- 'stage-code-component': stageCodeComponent,
- 'stage-test-component': stageTestComponent,
- 'stage-review-component': stageReviewComponent,
- 'stage-staging-component': stageStagingComponent,
- 'stage-production-component': stageComponent,
- 'stage-nav-item': stageNavItem,
- },
- data() {
- return {
- store: CycleAnalyticsStore,
- state: CycleAnalyticsStore.state,
- isLoading: false,
- isLoadingStage: false,
- isEmptyStage: false,
- hasError: false,
- startDate: 30,
- isOverviewDialogDismissed: Cookies.get(OVERVIEW_DIALOG_COOKIE),
- service: this.createCycleAnalyticsService(cycleAnalyticsEl.dataset.requestPath),
- };
- },
- computed: {
- currentStage() {
- return this.store.currentActiveStage();
- },
- },
- created() {
- // Conditional check placed here to prevent this method from being called on the
- // new Value Stream Analytics page (i.e. the new page will be initialized blank and only
- // after a group is selected the cycle analyitcs data will be fetched). Once the
- // old (current) page has been removed this entire created method as well as the
- // variable itself can be completely removed.
- // Follow up issue: https://gitlab.com/gitlab-org/gitlab-foss/issues/64490
- if (cycleAnalyticsEl.dataset.requestPath) this.fetchCycleAnalyticsData();
- },
- methods: {
- handleError() {
- this.store.setErrorState(true);
- return new Flash(__('There was an error while fetching value stream analytics data.'));
- },
- initDropdown() {
- const $dropdown = $('.js-ca-dropdown');
- const $label = $dropdown.find('.dropdown-label');
-
- // eslint-disable-next-line @gitlab/no-global-event-off
- $dropdown
- .find('li a')
- .off('click')
- .on('click', (e) => {
- e.preventDefault();
- const $target = $(e.currentTarget);
- this.startDate = $target.data('value');
-
- $label.text($target.text().trim());
- this.fetchCycleAnalyticsData({ startDate: this.startDate });
- });
- },
- fetchCycleAnalyticsData(options) {
- const fetchOptions = options || { startDate: this.startDate };
-
- this.isLoading = true;
-
- this.service
- .fetchCycleAnalyticsData(fetchOptions)
- .then((response) => {
- this.store.setCycleAnalyticsData(response);
- this.selectDefaultStage();
- this.initDropdown();
- this.isLoading = false;
- })
- .catch(() => {
- this.handleError();
- this.isLoading = false;
- });
- },
- selectDefaultStage() {
- const stage = this.state.stages[0];
- this.selectStage(stage);
- },
- selectStage(stage) {
- if (this.isLoadingStage) return;
- if (this.currentStage === stage) return;
-
- if (!stage.isUserAllowed) {
- this.store.setActiveStage(stage);
- return;
- }
-
- this.isLoadingStage = true;
- this.store.setStageEvents([], stage);
- this.store.setActiveStage(stage);
-
- this.service
- .fetchStageData({
- stage,
- startDate: this.startDate,
- projectIds: this.selectedProjectIds,
- })
- .then((response) => {
- this.isEmptyStage = !response.events.length;
- this.store.setStageEvents(response.events, stage);
- this.isLoadingStage = false;
- })
- .catch(() => {
- this.isEmptyStage = true;
- this.isLoadingStage = false;
- });
- },
- dismissOverviewDialog() {
- this.isOverviewDialogDismissed = true;
- Cookies.set(OVERVIEW_DIALOG_COOKIE, '1', { expires: 365 });
- },
- createCycleAnalyticsService(requestPath) {
- return new CycleAnalyticsService({
- requestPath,
- });
- },
- },
- });
-};
diff --git a/app/assets/javascripts/cycle_analytics/index.js b/app/assets/javascripts/cycle_analytics/index.js
new file mode 100644
index 00000000000..42d6700fae1
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/index.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import Translate from '../vue_shared/translate';
+import CycleAnalytics from './components/base.vue';
+import CycleAnalyticsService from './cycle_analytics_service';
+import CycleAnalyticsStore from './cycle_analytics_store';
+
+Vue.use(Translate);
+
+const createCycleAnalyticsService = (requestPath) =>
+ new CycleAnalyticsService({
+ requestPath,
+ });
+
+export default () => {
+ const el = document.querySelector('#js-cycle-analytics');
+ const { noAccessSvgPath, noDataSvgPath } = el.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ name: 'CycleAnalytics',
+ render: (createElement) =>
+ createElement(CycleAnalytics, {
+ props: {
+ noDataSvgPath,
+ noAccessSvgPath,
+ store: CycleAnalyticsStore,
+ service: createCycleAnalyticsService(el.dataset.requestPath),
+ },
+ }),
+ });
+};
diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js
index 162491312a8..a1dd12ff769 100644
--- a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js
+++ b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js
@@ -29,6 +29,26 @@ const FILTER_INPUT = '.dropdown-input .dropdown-input-field:not(.dropdown-no-fil
const NO_FILTER_INPUT = '.dropdown-input .dropdown-input-field.dropdown-no-filter';
+let mouseEventListenersAdded = false;
+let mousedownTarget = null;
+let mouseupTarget = null;
+
+function addGlobalMouseEventListeners() {
+ // Remember mousedown and mouseup locations.
+ // Required in the `hide.bs.dropdown` listener for
+ // dropdown close prevention in some cases.
+ document.addEventListener('mousedown', ({ target }) => {
+ mousedownTarget = target;
+ });
+ document.addEventListener('mouseup', ({ target }) => {
+ mouseupTarget = target;
+ });
+ document.addEventListener('click', () => {
+ mousedownTarget = null;
+ mouseupTarget = null;
+ });
+}
+
export class GitLabDropdown {
constructor(el1, options) {
let selector;
@@ -36,9 +56,14 @@ export class GitLabDropdown {
this.el = el1;
this.options = options;
this.updateLabel = this.updateLabel.bind(this);
- this.hidden = this.hidden.bind(this);
this.opened = this.opened.bind(this);
+ this.hide = this.hide.bind(this);
+ this.hidden = this.hidden.bind(this);
this.shouldPropagate = this.shouldPropagate.bind(this);
+ if (!mouseEventListenersAdded) {
+ addGlobalMouseEventListeners();
+ mouseEventListenersAdded = true;
+ }
self = this;
selector = $(this.el).data('target');
this.dropdown = selector != null ? $(selector) : $(this.el).parent();
@@ -132,6 +157,7 @@ export class GitLabDropdown {
}
// Event listeners
this.dropdown.on('shown.bs.dropdown', this.opened);
+ this.dropdown.on('hide.bs.dropdown', this.hide);
this.dropdown.on('hidden.bs.dropdown', this.hidden);
$(this.el).on('update.label', this.updateLabel);
this.dropdown.on('click', '.dropdown-menu, .dropdown-menu-close', this.shouldPropagate);
@@ -334,6 +360,21 @@ export class GitLabDropdown {
$menu.css('bottom', '100%');
}
+ hide(e) {
+ // Prevent dropdowns with a search from being closed when the
+ // mousedown event happened inside the dropdown box and only
+ // the mouseup event did not.
+ if (this.options.search && mousedownTarget) {
+ const isIn = (element, $possibleContainer) => Boolean($possibleContainer.has(element).length);
+ const $menu = this.dropdown.find('.dropdown-menu');
+ const mousedownInsideDropdown = isIn(mousedownTarget, $menu);
+ const mouseupOutsideDropdown = !isIn(mouseupTarget, $menu);
+ if (mousedownInsideDropdown && mouseupOutsideDropdown) {
+ e.preventDefault();
+ }
+ }
+ }
+
hidden(e) {
this.resetRows();
this.removeArrowKeyEvent();
diff --git a/app/assets/javascripts/design_management/components/delete_button.vue b/app/assets/javascripts/design_management/components/delete_button.vue
index 273fa3f6be2..fbcce22ec1e 100644
--- a/app/assets/javascripts/design_management/components/delete_button.vue
+++ b/app/assets/javascripts/design_management/components/delete_button.vue
@@ -73,21 +73,19 @@ export default {
</script>
<template>
- <div class="gl-display-flex gl-align-items-center gl-h-full">
+ <div>
<gl-modal
:modal-id="modalId"
:title="$options.modal.title"
:action-primary="$options.modal.actionPrimary"
:action-cancel="$options.modal.actionCancel"
- @ok="$emit('deleteSelectedDesigns')"
+ @ok="$emit('delete-selected-designs')"
>
- <p>
- {{
- s__(
- 'DesignManagement|Archived designs will still be available in previous versions of the design collection.',
- )
- }}
- </p>
+ {{
+ s__(
+ 'DesignManagement|Archived designs will still be available in previous versions of the design collection.',
+ )
+ }}
</gl-modal>
<gl-button
v-gl-modal-directive="modalId"
diff --git a/app/assets/javascripts/design_management/components/design_destroyer.vue b/app/assets/javascripts/design_management/components/design_destroyer.vue
index 01f9cac456d..0178111f651 100644
--- a/app/assets/javascripts/design_management/components/design_destroyer.vue
+++ b/app/assets/javascripts/design_management/components/design_destroyer.vue
@@ -55,6 +55,7 @@ export default {
iid,
}"
:update="updateStoreAfterDelete"
+ :tag="null"
v-on="$listeners"
>
<slot v-bind="{ mutate, loading, error }"></slot>
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
index 33f0aa00cad..b1c37b0687f 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
@@ -253,12 +253,12 @@ export default {
:class="{ 'gl-bg-blue-50': isDiscussionActive }"
@error="$emit('update-note-error', $event)"
/>
- <li v-show="isReplyPlaceholderVisible" class="reply-wrapper">
+ <li v-show="isReplyPlaceholderVisible" class="reply-wrapper discussion-reply-holder">
<reply-placeholder
v-if="!isFormVisible"
class="qa-discussion-reply"
- :button-text="__('Reply...')"
- @onClick="showForm"
+ :placeholder-text="__('Reply…')"
+ @focus="showForm"
/>
<apollo-mutation
v-else
diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue
index 5eabe9ef1bc..2169c9111d2 100644
--- a/app/assets/javascripts/design_management/components/list/item.vue
+++ b/app/assets/javascripts/design_management/components/list/item.vue
@@ -138,6 +138,7 @@ export default {
<gl-icon
:name="icon.name"
:size="18"
+ use-deprecated-sizes
:class="icon.classes"
data-qa-selector="design_status_icon"
:data-qa-status="icon.name"
diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue
index a3b0f06fb28..8abf1529f3c 100644
--- a/app/assets/javascripts/design_management/components/toolbar/index.vue
+++ b/app/assets/javascripts/design_management/components/toolbar/index.vue
@@ -130,7 +130,7 @@ export default {
button-icon="archive"
button-category="secondary"
:title="s__('DesignManagement|Archive design')"
- @deleteSelectedDesigns="$emit('delete')"
+ @delete-selected-designs="$emit('delete')"
/>
</header>
</template>
diff --git a/app/assets/javascripts/design_management/components/upload/button.vue b/app/assets/javascripts/design_management/components/upload/button.vue
index d7b287f663b..394ccb3c483 100644
--- a/app/assets/javascripts/design_management/components/upload/button.vue
+++ b/app/assets/javascripts/design_management/components/upload/button.vue
@@ -50,7 +50,7 @@ export default {
type="file"
name="design_file"
:accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype"
- class="hide"
+ class="gl-display-none"
multiple
@change="onFileUploadChange"
/>
diff --git a/app/assets/javascripts/design_management/graphql.js b/app/assets/javascripts/design_management/graphql.js
index c6c3e66a01f..9a0547ee9db 100644
--- a/app/assets/javascripts/design_management/graphql.js
+++ b/app/assets/javascripts/design_management/graphql.js
@@ -20,7 +20,6 @@ const resolvers = {
const sourceData = cache.readQuery({ query: activeDiscussionQuery });
const data = produce(sourceData, (draftData) => {
- // eslint-disable-next-line no-param-reassign
draftData.activeDiscussion = {
__typename: 'ActiveDiscussion',
id,
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index c73c8fb6ca4..99ac38fc554 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -365,7 +365,8 @@ export default {
v-if="isLatestVersion"
variant="link"
size="small"
- class="gl-mr-4 js-select-all"
+ class="gl-mr-3"
+ data-testid="select-all-designs-button"
@click="toggleDesignsSelection"
>{{ selectAllButtonText }}
</gl-button>
@@ -385,7 +386,7 @@ export default {
data-qa-selector="archive_button"
:loading="loading"
:has-selected-designs="hasSelectedDesigns"
- @deleteSelectedDesigns="mutate()"
+ @delete-selected-designs="mutate()"
>
{{ s__('DesignManagement|Archive selected') }}
</delete-button>
diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js
index c561eda12ed..33c4fd5a7d9 100644
--- a/app/assets/javascripts/design_management/utils/cache_update.js
+++ b/app/assets/javascripts/design_management/utils/cache_update.js
@@ -41,7 +41,6 @@ const addNewVersionToStore = (store, query, version) => {
const sourceData = store.readQuery(query);
const data = produce(sourceData, (draftData) => {
- // eslint-disable-next-line no-param-reassign
draftData.project.issue.designCollection.versions.nodes = [
version,
...draftData.project.issue.designCollection.versions.nodes,
@@ -168,7 +167,6 @@ const addNewDesignToStore = (store, designManagementUpload, query) => {
nodes: newVersions,
},
};
- // eslint-disable-next-line no-param-reassign
draftData.project.issue.designCollection = updatedDesigns;
});
@@ -182,7 +180,6 @@ const moveDesignInStore = (store, designManagementMove, query) => {
const sourceData = store.readQuery(query);
const data = produce(sourceData, (draftData) => {
- // eslint-disable-next-line no-param-reassign
draftData.project.issue.designCollection.designs =
designManagementMove.designCollection.designs;
});
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 4323499ef1f..253e1e3b70e 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -23,9 +23,7 @@ import {
ALERT_OVERFLOW_HIDDEN,
ALERT_MERGE_CONFLICT,
ALERT_COLLAPSED_FILES,
- EVT_VIEW_FILE_BY_FILE,
} from '../constants';
-import eventHub from '../event_hub';
import { reviewStatuses } from '../utils/file_reviews';
import { diffsApp } from '../utils/performance';
@@ -127,7 +125,7 @@ export default {
required: false,
default: '',
},
- mrReviews: {
+ rehydratedMrReviews: {
type: Object,
required: false,
default: () => ({}),
@@ -166,6 +164,7 @@ export default {
'canMerge',
'hasConflicts',
'viewDiffsFileByFile',
+ 'mrReviews',
]),
...mapGetters('diffs', ['whichCollapsedTypes', 'isParallelView', 'currentDiffIndex']),
...mapGetters(['isNotesFetched', 'getNoteableData']),
@@ -270,7 +269,7 @@ export default {
showSuggestPopover: this.showSuggestPopover,
viewDiffsFileByFile: fileByFile(this.fileByFileUserPreference),
defaultSuggestionCommitMessage: this.defaultSuggestionCommitMessage,
- mrReviews: this.mrReviews || {},
+ mrReviews: this.rehydratedMrReviews,
});
if (this.shouldShow) {
@@ -332,16 +331,11 @@ export default {
subscribeToEvents() {
notesEventHub.$once('fetchDiffData', this.fetchData);
notesEventHub.$on('refetchDiffData', this.refetchDiffData);
- eventHub.$on(EVT_VIEW_FILE_BY_FILE, this.fileByFileListener);
},
unsubscribeFromEvents() {
- eventHub.$off(EVT_VIEW_FILE_BY_FILE, this.fileByFileListener);
notesEventHub.$off('refetchDiffData', this.refetchDiffData);
notesEventHub.$off('fetchDiffData', this.fetchData);
},
- fileByFileListener({ setting } = {}) {
- this.setFileByFile({ fileByFile: setting });
- },
navigateToDiffFileNumber(number) {
this.navigateToDiffFileIndex(number - 1);
},
@@ -520,7 +514,7 @@ export default {
v-for="(file, index) in diffs"
:key="file.newPath"
:file="file"
- :reviewed="fileReviews[index]"
+ :reviewed="fileReviews[file.id]"
:is-first-file="index === 0"
:is-last-file="index === diffFilesLength - 1"
:help-page-path="helpPagePath"
diff --git a/app/assets/javascripts/diffs/components/diff_discussion_reply.vue b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue
index 9027d0c8aa4..3766c125325 100644
--- a/app/assets/javascripts/diffs/components/diff_discussion_reply.vue
+++ b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue
@@ -35,8 +35,9 @@ export default {
<slot v-if="hasForm" name="form"></slot>
<template v-else-if="renderReplyPlaceholder">
<reply-placeholder
- :button-text="__('Start a new discussion...')"
- @onClick="$emit('showNewDiscussionForm')"
+ :placeholder-text="__('Start a new discussion…')"
+ :label-text="__('New discussion')"
+ @focus="$emit('showNewDiscussionForm')"
/>
</template>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index f77c8d7406b..ca4543f7002 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -150,6 +150,11 @@ export default {
},
},
watch: {
+ 'file.id': {
+ handler: function fileIdHandler() {
+ this.manageViewedEffects();
+ },
+ },
'file.file_hash': {
handler: function hashChangeWatch(newHash, oldHash) {
this.isCollapsed = isCollapsed(this.file);
@@ -186,9 +191,7 @@ export default {
this.postRender();
}
- if (this.reviewed && !this.isCollapsed && this.showLocalFileReviews) {
- this.handleToggle();
- }
+ this.manageViewedEffects();
},
beforeDestroy() {
eventHub.$off(EVT_EXPAND_ALL_FILES, this.expandAllListener);
@@ -200,6 +203,11 @@ export default {
'setRenderIt',
'setFileCollapsedByUser',
]),
+ manageViewedEffects() {
+ if (this.reviewed && !this.isCollapsed && this.showLocalFileReviews) {
+ this.handleToggle();
+ }
+ },
expandAllListener() {
if (this.isCollapsed) {
this.handleToggle();
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 1195a7f2565..1f50b3a38a6 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -339,14 +339,12 @@ export default {
v-if="isReviewable && showLocalFileReviews"
v-gl-tooltip.hover
data-testid="fileReviewCheckbox"
- class="gl-mb-0"
+ class="gl-mr-5 gl-display-flex gl-align-items-center"
:title="$options.i18n.fileReviewTooltip"
:checked="reviewed"
@change="toggleReview"
>
- <span class="gl-line-height-20">
- {{ $options.i18n.fileReviewLabel }}
- </span>
+ {{ $options.i18n.fileReviewLabel }}
</gl-form-checkbox>
<gl-button-group class="gl-pt-0!">
<gl-button
diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
index 154f2fdcfc7..fb9202c5aab 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
@@ -139,7 +139,8 @@ export default {
v-show="shouldShowCommentButton"
ref="addDiffNoteButton"
type="button"
- class="add-diff-note note-button js-add-diff-note-button qa-diff-comment"
+ class="add-diff-note note-button js-add-diff-note-button"
+ data-qa-selector="diff_comment_button"
:disabled="line.commentsDisabled"
@click="handleCommentButton"
>
@@ -167,10 +168,11 @@ export default {
"
/>
</td>
- <td ref="newTd" class="diff-line-num new_line qa-new-diff-line" :class="classNameMapCell">
+ <td ref="newTd" class="diff-line-num new_line" :class="classNameMapCell">
<a
v-if="line.new_line"
ref="lineNumberRefNew"
+ data-qa-selector="new_diff_line_link"
:data-linenumber="line.new_line"
:href="line.lineHref"
@click="setHighlightedRow(line.lineCode)"
diff --git a/app/assets/javascripts/diffs/components/settings_dropdown.vue b/app/assets/javascripts/diffs/components/settings_dropdown.vue
index 7d74e81257a..879922f86a2 100644
--- a/app/assets/javascripts/diffs/components/settings_dropdown.vue
+++ b/app/assets/javascripts/diffs/components/settings_dropdown.vue
@@ -1,9 +1,6 @@
<script>
import { GlButtonGroup, GlButton, GlDropdown, GlFormCheckbox } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
-
-import { EVT_VIEW_FILE_BY_FILE } from '../constants';
-import eventHub from '../event_hub';
import { SETTINGS_DROPDOWN } from '../i18n';
export default {
@@ -24,9 +21,13 @@ export default {
'setParallelDiffViewType',
'setRenderTreeList',
'setShowWhitespace',
+ 'setFileByFile',
]),
toggleFileByFile() {
- eventHub.$emit(EVT_VIEW_FILE_BY_FILE, { setting: !this.viewDiffsFileByFile });
+ this.setFileByFile({ fileByFile: !this.viewDiffsFileByFile });
+ },
+ toggleWhitespace(updatedSetting) {
+ this.setShowWhitespace({ showWhitespace: updatedSetting, pushState: true });
},
},
};
@@ -82,26 +83,21 @@ export default {
</gl-button>
</gl-button-group>
</div>
- <div class="gl-mt-3 gl-px-3">
- <label class="gl-mb-0">
- <input
- id="show-whitespace"
- type="checkbox"
- :checked="showWhitespace"
- @change="setShowWhitespace({ showWhitespace: $event.target.checked, pushState: true })"
- />
- {{ __('Show whitespace changes') }}
- </label>
- </div>
- <div class="gl-mt-3 gl-px-3">
- <gl-form-checkbox
- data-testid="file-by-file"
- class="gl-mb-0"
- :checked="viewDiffsFileByFile"
- @input="toggleFileByFile"
- >
- {{ $options.i18n.fileByFile }}
- </gl-form-checkbox>
- </div>
+ <gl-form-checkbox
+ data-testid="show-whitespace"
+ class="gl-mt-3 gl-ml-3"
+ :checked="showWhitespace"
+ @input="toggleWhitespace"
+ >
+ {{ $options.i18n.whitespace }}
+ </gl-form-checkbox>
+ <gl-form-checkbox
+ data-testid="file-by-file"
+ class="gl-ml-3 gl-mb-0"
+ :checked="viewDiffsFileByFile"
+ @input="toggleFileByFile"
+ >
+ {{ $options.i18n.fileByFile }}
+ </gl-form-checkbox>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index 7080348ee7d..0163f508fea 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -103,7 +103,6 @@ export const RENAMED_DIFF_TRANSITIONS = {
// MR Diffs known events
export const EVT_EXPAND_ALL_FILES = 'mr:diffs:expandAllFiles';
-export const EVT_VIEW_FILE_BY_FILE = 'mr:diffs:preference:fileByFile';
export const EVT_PERF_MARK_FILE_TREE_START = 'mr:diffs:perf:fileTreeStart';
export const EVT_PERF_MARK_FILE_TREE_END = 'mr:diffs:perf:fileTreeEnd';
export const EVT_PERF_MARK_DIFF_FILES_START = 'mr:diffs:perf:filesStart';
diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js
index 2a061876937..b2354af1eec 100644
--- a/app/assets/javascripts/diffs/i18n.js
+++ b/app/assets/javascripts/diffs/i18n.js
@@ -21,5 +21,6 @@ export const DIFF_FILE = {
};
export const SETTINGS_DROPDOWN = {
+ whitespace: __('Show whitespace changes'),
fileByFile: __('Show one file at a time'),
};
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 68fe204d955..87e9af174e5 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -124,7 +124,7 @@ export default function initDiffsApp(store) {
showSuggestPopover: this.showSuggestPopover,
fileByFileUserPreference: this.viewDiffsFileByFile,
defaultSuggestionCommitMessage: this.defaultSuggestionCommitMessage,
- mrReviews: getReviewsForMergeRequest(mrPath),
+ rehydratedMrReviews: getReviewsForMergeRequest(mrPath),
},
});
},
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 4b2dc2d45df..8796016def9 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -741,12 +741,7 @@ export const navigateToDiffFileIndex = ({ commit, state }, index) => {
export const setFileByFile = ({ commit }, { fileByFile }) => {
const fileViewMode = fileByFile ? DIFF_VIEW_FILE_BY_FILE : DIFF_VIEW_ALL_FILES;
commit(types.SET_FILE_BY_FILE, fileByFile);
-
Cookies.set(DIFF_FILE_BY_FILE_COOKIE_NAME, fileViewMode);
-
- historyPushState(
- mergeUrlParams({ [DIFF_FILE_BY_FILE_COOKIE_NAME]: fileViewMode }, window.location.href),
- );
};
export function reviewFile({ commit, state }, { file, reviewed = true }) {
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 87b4f33c216..b37a75eb2a3 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -283,7 +283,7 @@ export function addContextLines(options) {
* Trims the first char of the `richText` property when it's either a space or a diff symbol.
* @param {Object} line
* @returns {Object}
- * @deprecated
+ * @deprecated Use `line.rich_text = line.rich_text ? line.rich_text.replace(/^[+ -]/, '') : undefined;` instead!. For more information, see https://gitlab.com/gitlab-org/gitlab/-/issues/299329
*/
export function trimFirstCharOfLineContent(line = {}) {
// eslint-disable-next-line no-param-reassign
diff --git a/app/assets/javascripts/diffs/utils/file_reviews.js b/app/assets/javascripts/diffs/utils/file_reviews.js
index 5fafc1714ae..7a4b1aa6b17 100644
--- a/app/assets/javascripts/diffs/utils/file_reviews.js
+++ b/app/assets/javascripts/diffs/utils/file_reviews.js
@@ -9,7 +9,12 @@ export function isFileReviewed(reviews, file) {
}
export function reviewStatuses(files, reviews) {
- return files.map((file) => isFileReviewed(reviews, file));
+ return files.reduce((flat, file) => {
+ return {
+ ...flat,
+ [file.id]: isFileReviewed(reviews, file),
+ };
+ }, {});
}
export function getReviewsForMergeRequest(mrPath) {
diff --git a/app/assets/javascripts/diffs/utils/preferences.js b/app/assets/javascripts/diffs/utils/preferences.js
index e440de3350a..6b4aaf45937 100644
--- a/app/assets/javascripts/diffs/utils/preferences.js
+++ b/app/assets/javascripts/diffs/utils/preferences.js
@@ -1,22 +1,13 @@
import Cookies from 'js-cookie';
-import { getParameterValues } from '~/lib/utils/url_utility';
-
import { DIFF_FILE_BY_FILE_COOKIE_NAME, DIFF_VIEW_FILE_BY_FILE } from '../constants';
export function fileByFile(pref = false) {
- const search = getParameterValues(DIFF_FILE_BY_FILE_COOKIE_NAME)?.[0];
const cookie = Cookies.get(DIFF_FILE_BY_FILE_COOKIE_NAME);
- let viewFileByFile = pref;
// use the cookie first, if it exists
if (cookie) {
- viewFileByFile = cookie === DIFF_VIEW_FILE_BY_FILE;
- }
-
- // the search parameter of the URL should override, if it exists
- if (search) {
- viewFileByFile = search === DIFF_VIEW_FILE_BY_FILE;
+ return cookie === DIFF_VIEW_FILE_BY_FILE;
}
- return viewFileByFile;
+ return pref;
}
diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js
index d9e6a6c13e2..c991316dda2 100644
--- a/app/assets/javascripts/editor/constants.js
+++ b/app/assets/javascripts/editor/constants.js
@@ -16,6 +16,9 @@ export const EDITOR_READY_EVENT = 'editor-ready';
export const EDITOR_TYPE_CODE = 'vs.editor.ICodeEditor';
export const EDITOR_TYPE_DIFF = 'vs.editor.IDiffEditor';
+export const EDITOR_CODE_INSTANCE_FN = 'createInstance';
+export const EDITOR_DIFF_INSTANCE_FN = 'createDiffInstance';
+
//
// EXTENSIONS' CONSTANTS
//
diff --git a/app/assets/javascripts/editor/extensions/editor_lite_webide_ext.js b/app/assets/javascripts/editor/extensions/editor_lite_webide_ext.js
new file mode 100644
index 00000000000..83b0386d470
--- /dev/null
+++ b/app/assets/javascripts/editor/extensions/editor_lite_webide_ext.js
@@ -0,0 +1,164 @@
+import { debounce } from 'lodash';
+import { KeyCode, KeyMod, Range } from 'monaco-editor';
+import { EDITOR_TYPE_DIFF } from '~/editor/constants';
+import { EditorLiteExtension } from '~/editor/extensions/editor_lite_extension_base';
+import Disposable from '~/ide/lib/common/disposable';
+import { editorOptions } from '~/ide/lib/editor_options';
+import keymap from '~/ide/lib/keymap.json';
+
+const isDiffEditorType = (instance) => {
+ return instance.getEditorType() === EDITOR_TYPE_DIFF;
+};
+
+export const UPDATE_DIMENSIONS_DELAY = 200;
+
+export class EditorWebIdeExtension extends EditorLiteExtension {
+ constructor({ instance, modelManager, ...options } = {}) {
+ super({
+ instance,
+ ...options,
+ modelManager,
+ disposable: new Disposable(),
+ debouncedUpdate: debounce(() => {
+ instance.updateDimensions();
+ }, UPDATE_DIMENSIONS_DELAY),
+ });
+
+ window.addEventListener('resize', instance.debouncedUpdate, false);
+
+ instance.onDidDispose(() => {
+ window.removeEventListener('resize', instance.debouncedUpdate);
+
+ // catch any potential errors with disposing the error
+ // this is mainly for tests caused by elements not existing
+ try {
+ instance.disposable.dispose();
+ } catch (e) {
+ if (process.env.NODE_ENV !== 'test') {
+ // eslint-disable-next-line no-console
+ console.error(e);
+ }
+ }
+ });
+
+ EditorWebIdeExtension.addActions(instance);
+ }
+
+ static addActions(instance) {
+ const { store } = instance;
+ const getKeyCode = (key) => {
+ const monacoKeyMod = key.indexOf('KEY_') === 0;
+
+ return monacoKeyMod ? KeyCode[key] : KeyMod[key];
+ };
+
+ keymap.forEach((command) => {
+ const { bindings, id, label, action } = command;
+
+ const keybindings = bindings.map((binding) => {
+ const keys = binding.split('+');
+
+ // eslint-disable-next-line no-bitwise
+ return keys.length > 1 ? getKeyCode(keys[0]) | getKeyCode(keys[1]) : getKeyCode(keys[0]);
+ });
+
+ instance.addAction({
+ id,
+ label,
+ keybindings,
+ run() {
+ store.dispatch(action.name, action.params);
+ return null;
+ },
+ });
+ });
+ }
+
+ createModel(file, head = null) {
+ return this.modelManager.addModel(file, head);
+ }
+
+ attachModel(model) {
+ if (isDiffEditorType(this)) {
+ this.setModel({
+ original: model.getOriginalModel(),
+ modified: model.getModel(),
+ });
+
+ return;
+ }
+
+ this.setModel(model.getModel());
+
+ this.updateOptions(
+ editorOptions.reduce((acc, obj) => {
+ Object.keys(obj).forEach((key) => {
+ Object.assign(acc, {
+ [key]: obj[key](model),
+ });
+ });
+ return acc;
+ }, {}),
+ );
+ }
+
+ attachMergeRequestModel(model) {
+ this.setModel({
+ original: model.getBaseModel(),
+ modified: model.getModel(),
+ });
+ }
+
+ updateDimensions() {
+ this.layout();
+ this.updateDiffView();
+ }
+
+ setPos({ lineNumber, column }) {
+ this.revealPositionInCenter({
+ lineNumber,
+ column,
+ });
+ this.setPosition({
+ lineNumber,
+ column,
+ });
+ }
+
+ onPositionChange(cb) {
+ if (!this.onDidChangeCursorPosition) {
+ return;
+ }
+
+ this.disposable.add(this.onDidChangeCursorPosition((e) => cb(this, e)));
+ }
+
+ updateDiffView() {
+ if (!isDiffEditorType(this)) {
+ return;
+ }
+
+ this.updateOptions({
+ renderSideBySide: EditorWebIdeExtension.renderSideBySide(this.getDomNode()),
+ });
+ }
+
+ replaceSelectedText(text) {
+ let selection = this.getSelection();
+ const range = new Range(
+ selection.startLineNumber,
+ selection.startColumn,
+ selection.endLineNumber,
+ selection.endColumn,
+ );
+
+ this.executeEdits('', [{ range, text }]);
+
+ selection = this.getSelection();
+ this.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn });
+ }
+
+ static renderSideBySide(domElement) {
+ return domElement.offsetWidth >= 700;
+ }
+}
diff --git a/app/assets/javascripts/emoji/components/category.vue b/app/assets/javascripts/emoji/components/category.vue
new file mode 100644
index 00000000000..a11122d5403
--- /dev/null
+++ b/app/assets/javascripts/emoji/components/category.vue
@@ -0,0 +1,61 @@
+<script>
+import { GlIntersectionObserver } from '@gitlab/ui';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+import EmojiGroup from './emoji_group.vue';
+
+export default {
+ components: {
+ GlIntersectionObserver,
+ EmojiGroup,
+ },
+ props: {
+ category: {
+ type: String,
+ required: true,
+ },
+ emojis: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ renderGroup: false,
+ };
+ },
+ computed: {
+ categoryTitle() {
+ return capitalizeFirstCharacter(this.category);
+ },
+ },
+ methods: {
+ categoryAppeared() {
+ this.renderGroup = true;
+ this.$emit('appear', this.category);
+ },
+ categoryDissappeared() {
+ this.renderGroup = false;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-intersection-observer class="gl-px-5 gl-h-full" @appear="categoryAppeared">
+ <div class="gl-top-0 gl-py-3 gl-w-full emoji-picker-category-header">
+ <b>{{ categoryTitle }}</b>
+ </div>
+ <template v-if="emojis.length">
+ <emoji-group
+ v-for="(emojiGroup, index) in emojis"
+ :key="index"
+ :emojis="emojiGroup"
+ :render-group="renderGroup"
+ :click-emoji="(emoji) => $emit('click', emoji)"
+ />
+ </template>
+ <p v-else>
+ {{ s__('AwardEmoji|No emojis found.') }}
+ </p>
+ </gl-intersection-observer>
+</template>
diff --git a/app/assets/javascripts/emoji/components/emoji_group.vue b/app/assets/javascripts/emoji/components/emoji_group.vue
new file mode 100644
index 00000000000..539cd6963b1
--- /dev/null
+++ b/app/assets/javascripts/emoji/components/emoji_group.vue
@@ -0,0 +1,35 @@
+<script>
+export default {
+ props: {
+ emojis: {
+ type: Array,
+ required: true,
+ },
+ renderGroup: {
+ type: Boolean,
+ required: true,
+ },
+ clickEmoji: {
+ type: Function,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template functional>
+ <div class="gl-display-flex gl-flex-wrap gl-mb-2">
+ <template v-if="props.renderGroup">
+ <button
+ v-for="emoji in props.emojis"
+ :key="emoji"
+ type="button"
+ class="gl-border-0 gl-bg-transparent gl-px-0 gl-py-2 gl-text-center emoji-picker-emoji"
+ data-testid="emoji-button"
+ @click="props.clickEmoji(emoji)"
+ >
+ <gl-emoji :data-name="emoji" />
+ </button>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/emoji/components/emoji_list.vue b/app/assets/javascripts/emoji/components/emoji_list.vue
new file mode 100644
index 00000000000..0d73d751c6d
--- /dev/null
+++ b/app/assets/javascripts/emoji/components/emoji_list.vue
@@ -0,0 +1,44 @@
+<script>
+import { chunk } from 'lodash';
+import { searchEmoji } from '~/emoji';
+import { EMOJIS_PER_ROW } from '../constants';
+import { getEmojiCategories, generateCategoryHeight } from './utils';
+
+export default {
+ props: {
+ searchValue: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return { render: false };
+ },
+ computed: {
+ filteredCategories() {
+ if (this.searchValue !== '') {
+ const emojis = chunk(
+ searchEmoji(this.searchValue).map(({ emoji }) => emoji.name),
+ EMOJIS_PER_ROW,
+ );
+
+ return {
+ search: { emojis, height: generateCategoryHeight(emojis.length) },
+ };
+ }
+
+ return this.categories;
+ },
+ },
+ async mounted() {
+ this.categories = await getEmojiCategories();
+ this.render = true;
+ },
+};
+</script>
+
+<template>
+ <div v-if="render">
+ <slot :filtered-categories="filteredCategories"></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/emoji/components/picker.vue b/app/assets/javascripts/emoji/components/picker.vue
new file mode 100644
index 00000000000..7cd20d82329
--- /dev/null
+++ b/app/assets/javascripts/emoji/components/picker.vue
@@ -0,0 +1,121 @@
+<script>
+import { GlIcon, GlDropdown, GlSearchBoxByType } from '@gitlab/ui';
+import VirtualList from 'vue-virtual-scroll-list';
+import { CATEGORY_NAMES } from '~/emoji';
+import { CATEGORY_ICON_MAP } from '../constants';
+import Category from './category.vue';
+import EmojiList from './emoji_list.vue';
+import { getEmojiCategories } from './utils';
+
+export default {
+ components: {
+ GlIcon,
+ GlDropdown,
+ GlSearchBoxByType,
+ VirtualList,
+ Category,
+ EmojiList,
+ },
+ props: {
+ toggleClass: {
+ type: [Array, String, Object],
+ required: false,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ currentCategory: null,
+ searchValue: '',
+ };
+ },
+ computed: {
+ categoryNames() {
+ return CATEGORY_NAMES.map((category) => ({
+ name: category,
+ icon: CATEGORY_ICON_MAP[category],
+ }));
+ },
+ },
+ methods: {
+ categoryAppeared(category) {
+ this.currentCategory = category;
+ },
+ async scrollToCategory(categoryName) {
+ const categories = await getEmojiCategories();
+ const { top } = categories[categoryName];
+
+ this.$refs.virtualScoller.setScrollTop(top);
+ },
+ selectEmoji(name) {
+ this.$emit('click', name);
+ this.$refs.dropdown.hide();
+ },
+ getBoundaryElement() {
+ return document.querySelector('.content-wrapper') || 'scrollParent';
+ },
+ onSearchInput() {
+ this.$refs.virtualScoller.setScrollTop(0);
+ this.$refs.virtualScoller.forceRender();
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="emoji-picker">
+ <gl-dropdown
+ ref="dropdown"
+ :toggle-class="toggleClass"
+ :boundary="getBoundaryElement()"
+ menu-class="dropdown-extended-height"
+ no-flip
+ right
+ lazy
+ >
+ <template #button-content><slot name="button-content"></slot></template>
+ <gl-search-box-by-type
+ v-model="searchValue"
+ class="gl-mx-5! gl-mb-2!"
+ autofocus
+ debounce="500"
+ @input="onSearchInput"
+ />
+ <div
+ v-show="!searchValue"
+ class="gl-display-flex gl-mx-5 gl-border-b-solid gl-border-gray-100 gl-border-b-1"
+ >
+ <button
+ v-for="category in categoryNames"
+ :key="category.name"
+ :class="{
+ 'gl-text-black-normal! emoji-picker-category-active': category.name === currentCategory,
+ }"
+ type="button"
+ class="gl-border-0 gl-border-b-2 gl-border-b-solid gl-flex-fill-1 gl-text-gray-300 gl-pt-3 gl-pb-3 gl-bg-transparent emoji-picker-category-tab"
+ @click="scrollToCategory(category.name)"
+ >
+ <gl-icon :name="category.icon" :size="12" />
+ </button>
+ </div>
+ <emoji-list :search-value="searchValue">
+ <template #default="{ filteredCategories }">
+ <virtual-list ref="virtualScoller" :size="258" :remain="1" :bench="2" variable>
+ <div
+ v-for="(category, categoryKey) in filteredCategories"
+ :key="categoryKey"
+ :style="{ height: category.height + 'px' }"
+ >
+ <category
+ :category="categoryKey"
+ :emojis="category.emojis"
+ @appear="categoryAppeared"
+ @click="selectEmoji"
+ />
+ </div>
+ </virtual-list>
+ </template>
+ </emoji-list>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/emoji/components/utils.js b/app/assets/javascripts/emoji/components/utils.js
new file mode 100644
index 00000000000..b95b56a1d6f
--- /dev/null
+++ b/app/assets/javascripts/emoji/components/utils.js
@@ -0,0 +1,27 @@
+import { chunk, memoize } from 'lodash';
+import { initEmojiMap, getEmojiCategoryMap } from '~/emoji';
+import { EMOJIS_PER_ROW, EMOJI_ROW_HEIGHT, CATEGORY_ROW_HEIGHT } from '../constants';
+
+export const generateCategoryHeight = (emojisLength) =>
+ emojisLength * EMOJI_ROW_HEIGHT + CATEGORY_ROW_HEIGHT;
+
+export const getEmojiCategories = memoize(async () => {
+ await initEmojiMap();
+
+ const categories = await getEmojiCategoryMap();
+ let top = 0;
+
+ return Object.freeze(
+ Object.keys(categories).reduce((acc, category) => {
+ const emojis = chunk(categories[category], EMOJIS_PER_ROW);
+ const height = generateCategoryHeight(emojis.length);
+ const newAcc = {
+ ...acc,
+ [category]: { emojis, height, top },
+ };
+ top += height;
+
+ return newAcc;
+ }, {}),
+ );
+});
diff --git a/app/assets/javascripts/emoji/constants.js b/app/assets/javascripts/emoji/constants.js
new file mode 100644
index 00000000000..bf73d1ca5a9
--- /dev/null
+++ b/app/assets/javascripts/emoji/constants.js
@@ -0,0 +1,14 @@
+export const CATEGORY_ICON_MAP = {
+ activity: 'dumbbell',
+ people: 'smiley',
+ nature: 'nature',
+ food: 'food',
+ travel: 'car',
+ objects: 'object',
+ symbols: 'heart',
+ flags: 'flag',
+};
+
+export const EMOJIS_PER_ROW = 9;
+export const EMOJI_ROW_HEIGHT = 34;
+export const CATEGORY_ROW_HEIGHT = 37;
diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js
index d022fcbeabe..d3b658a4020 100644
--- a/app/assets/javascripts/emoji/index.js
+++ b/app/assets/javascripts/emoji/index.js
@@ -2,6 +2,7 @@ import { escape, minBy } from 'lodash';
import emojiAliases from 'emojis/aliases.json';
import AccessorUtilities from '../lib/utils/accessor';
import axios from '../lib/utils/axios_utils';
+import { CATEGORY_ICON_MAP } from './constants';
let emojiMap = null;
let validEmojiNames = null;
@@ -155,19 +156,14 @@ export function sortEmoji(items) {
return [...items].sort((a, b) => a.score - b.score || a.fieldValue.localeCompare(b.fieldValue));
}
+export const CATEGORY_NAMES = Object.keys(CATEGORY_ICON_MAP);
+
let emojiCategoryMap;
export function getEmojiCategoryMap() {
if (!emojiCategoryMap) {
- emojiCategoryMap = {
- activity: [],
- people: [],
- nature: [],
- food: [],
- travel: [],
- objects: [],
- symbols: [],
- flags: [],
- };
+ emojiCategoryMap = CATEGORY_NAMES.reduce((acc, category) => {
+ return { ...acc, [category]: [] };
+ }, {});
Object.keys(emojiMap).forEach((name) => {
const emoji = emojiMap[name];
if (emojiCategoryMap[emoji.c]) {
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index ed852895f10..602639f09a6 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -85,7 +85,7 @@ export default {
this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', showLoader);
this.service
- .getFolderContent(folder.folder_path)
+ .getFolderContent(folder.folder_path, folder.state)
.then((response) => this.store.setfolderContent(folder, response.data.environments))
.then(() => this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', false))
.catch(() => {
@@ -129,7 +129,7 @@ export default {
:href="newEnvironmentPath"
data-testid="new-environment"
category="primary"
- variant="success"
+ variant="confirm"
>{{ $options.i18n.newEnvironmentButtonLabel }}</gl-button
>
</div>
@@ -164,7 +164,7 @@ export default {
:href="newEnvironmentPath"
data-testid="new-environment"
category="primary"
- variant="success"
+ variant="confirm"
>{{ $options.i18n.newEnvironmentButtonLabel }}</gl-button
>
</div>
diff --git a/app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js b/app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js
index b62fe196a6f..a76c8e445ed 100644
--- a/app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js
+++ b/app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js
@@ -16,6 +16,7 @@ export default {
let params = {
scope,
page: '1',
+ nested: true,
};
params = this.onChangeWithFilter(params);
@@ -27,6 +28,7 @@ export default {
/* URLS parameters are strings, we need to parse to match types */
let params = {
page: Number(page).toString(),
+ nested: true,
};
if (this.scope) {
diff --git a/app/assets/javascripts/environments/services/environments_service.js b/app/assets/javascripts/environments/services/environments_service.js
index 122c8f84a2c..dbd82a3c985 100644
--- a/app/assets/javascripts/environments/services/environments_service.js
+++ b/app/assets/javascripts/environments/services/environments_service.js
@@ -21,7 +21,7 @@ export default class EnvironmentsService {
return axios.delete(endpoint, {});
}
- getFolderContent(folderUrl) {
- return axios.get(`${folderUrl}.json?per_page=${this.folderResults}`);
+ getFolderContent(folderUrl, scope) {
+ return axios.get(`${folderUrl}.json?per_page=${this.folderResults}&scope=${scope}`);
}
}
diff --git a/app/assets/javascripts/experimentation/constants.js b/app/assets/javascripts/experimentation/constants.js
new file mode 100644
index 00000000000..b7e61d43b11
--- /dev/null
+++ b/app/assets/javascripts/experimentation/constants.js
@@ -0,0 +1 @@
+export const TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0';
diff --git a/app/assets/javascripts/experimentation/experiment_tracking.js b/app/assets/javascripts/experimentation/experiment_tracking.js
new file mode 100644
index 00000000000..c721828036e
--- /dev/null
+++ b/app/assets/javascripts/experimentation/experiment_tracking.js
@@ -0,0 +1,24 @@
+import Tracking from '~/tracking';
+import { TRACKING_CONTEXT_SCHEMA } from './constants';
+import { getExperimentData } from './utils';
+
+export default class ExperimentTracking {
+ constructor(experimentName, trackingArgs = {}) {
+ this.trackingArgs = trackingArgs;
+ this.data = getExperimentData(experimentName);
+ }
+
+ event(action) {
+ if (!this.data) {
+ return false;
+ }
+
+ return Tracking.event(document.body.dataset.page, action, {
+ ...this.trackingArgs,
+ context: {
+ schema: TRACKING_CONTEXT_SCHEMA,
+ data: this.data,
+ },
+ });
+ }
+}
diff --git a/app/assets/javascripts/experimentation/utils.js b/app/assets/javascripts/experimentation/utils.js
new file mode 100644
index 00000000000..d3e7800f643
--- /dev/null
+++ b/app/assets/javascripts/experimentation/utils.js
@@ -0,0 +1,10 @@
+// This file only applies to use of experiments through https://gitlab.com/gitlab-org/gitlab-experiment
+import { get } from 'lodash';
+
+export function getExperimentData(experimentName) {
+ return get(window, ['gon', 'experiment', experimentName]);
+}
+
+export function isExperimentVariant(experimentName, variantName) {
+ return getExperimentData(experimentName)?.variant === variantName;
+}
diff --git a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
index 5fcca11e695..77e40039b43 100644
--- a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
+++ b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
@@ -84,6 +84,11 @@ export default {
cancelActionProps() {
return {
text: this.$options.translations.cancelActionLabel,
+ attributes: [
+ {
+ category: 'secondary',
+ },
+ ],
};
},
canRegenerateInstanceId() {
@@ -120,11 +125,11 @@ export default {
<template>
<gl-modal
:modal-id="modalId"
- :action-cancel="cancelActionProps"
- :action-primary="regenerateInstanceIdActionProps"
- @canceled="clearState"
+ :action-primary="cancelActionProps"
+ :action-secondary="regenerateInstanceIdActionProps"
+ @secondary.prevent="rotateToken"
@hide="clearState"
- @primary.prevent="rotateToken"
+ @primary="clearState"
>
<template #modal-title>
{{ $options.translations.modalTitle }}
diff --git a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
index b1e60066e11..e7f4b51c964 100644
--- a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
+++ b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
@@ -86,6 +86,8 @@ export default {
data-track-event="click_button"
data-track-label="feature_flag_toggle"
class="gl-mr-4"
+ :label="__('Feature flag status')"
+ label-position="hidden"
@change="toggleActive"
/>
<h3 class="page-title gl-m-0">{{ title }}</h3>
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue
index 348b71dc1c6..115d68de5c9 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue
@@ -213,7 +213,7 @@ export default {
<gl-button
v-if="newUserListPath"
:href="newUserListPath"
- variant="success"
+ variant="confirm"
category="secondary"
class="gl-mb-3"
data-testid="ff-new-list-button"
@@ -224,7 +224,7 @@ export default {
<gl-button
v-if="hasNewPath"
:href="featureFlagsLimitExceeded ? '' : newFeatureFlagPath"
- variant="success"
+ variant="confirm"
data-testid="ff-new-button"
@click="onNewFeatureFlagCLick"
>
@@ -301,7 +301,7 @@ export default {
<gl-button
v-if="newUserListPath"
:href="newUserListPath"
- variant="success"
+ variant="confirm"
category="secondary"
class="gl-mb-0 gl-mr-4"
data-testid="ff-new-list-button"
@@ -312,7 +312,7 @@ export default {
<gl-button
v-if="hasNewPath"
:href="featureFlagsLimitExceeded ? '' : newFeatureFlagPath"
- variant="success"
+ variant="confirm"
data-testid="ff-new-button"
@click="onNewFeatureFlagCLick"
>
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
index 04a5e5bc3c5..222815407ea 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
@@ -1,11 +1,14 @@
<script>
import { GlBadge, GlButton, GlTooltipDirective, GlModal, GlToggle, GlIcon } from '@gitlab/ui';
-import { sprintf, s__ } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { ROLLOUT_STRATEGY_PERCENT_ROLLOUT, NEW_VERSION_FLAG, LEGACY_FLAG } from '../constants';
import { labelForStrategy } from '../utils';
export default {
+ i18n: {
+ toggleLabel: __('Feature flag status'),
+ },
components: {
GlBadge,
GlButton,
@@ -138,6 +141,8 @@ export default {
v-if="featureFlag.update_path"
:value="featureFlag.active"
:disabled="statusToggleDisabled(featureFlag)"
+ :label="$options.i18n.toggleLabel"
+ label-position="hidden"
data-testid="feature-flag-status-toggle"
data-track-event="click_button"
data-track-label="feature_flag_toggle"
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index d14af53746e..d26a6bc5f6b 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -1,5 +1,5 @@
+import * as Sentry from '@sentry/browser';
import { escape } from 'lodash';
-import * as Sentry from '~/sentry/wrapper';
import { spriteIcon } from './lib/utils/common_utils';
const FLASH_TYPES = {
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index d209a971c39..c5ea4cc92fd 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -3,11 +3,12 @@ import '~/lib/utils/jquery_at_who';
import { escape, template } from 'lodash';
import * as Emoji from '~/emoji';
import axios from '~/lib/utils/axios_utils';
-import { s__ } from '~/locale';
+import { s__, __, sprintf } from '~/locale';
import { isUserBusy } from '~/set_status_modal/utils';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import AjaxCache from './lib/utils/ajax_cache';
import { spriteIcon } from './lib/utils/common_utils';
+import { parsePikadayDate } from './lib/utils/datetime_utility';
import glRegexp from './lib/utils/regexp';
function sanitize(str) {
@@ -266,6 +267,7 @@ class GfmAutoComplete {
},
// eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${username}',
+ limit: 10,
searchKey: 'search',
alwaysHighlightFirst: true,
skipSpecialCharacterTest: true,
@@ -311,6 +313,38 @@ class GfmAutoComplete {
return data;
},
+ sorter(query, items) {
+ // Disable auto-selecting the loading icon
+ this.setting.highlightFirst = this.setting.alwaysHighlightFirst;
+ if (GfmAutoComplete.isLoading(items)) {
+ this.setting.highlightFirst = false;
+ return items;
+ }
+
+ if (!query) {
+ return items;
+ }
+
+ const lowercaseQuery = query.toLowerCase();
+ const members = items.slice();
+ const { nameOrUsernameStartsWith, nameOrUsernameIncludes } = GfmAutoComplete.Members;
+
+ return members.sort((a, b) => {
+ if (nameOrUsernameStartsWith(a, lowercaseQuery)) {
+ return -1;
+ }
+ if (nameOrUsernameStartsWith(b, lowercaseQuery)) {
+ return 1;
+ }
+ if (nameOrUsernameIncludes(a, lowercaseQuery)) {
+ return -1;
+ }
+ if (nameOrUsernameIncludes(b, lowercaseQuery)) {
+ return 1;
+ }
+ return 0;
+ });
+ },
},
});
}
@@ -359,7 +393,7 @@ class GfmAutoComplete {
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
if (value.title != null) {
- tmpl = GfmAutoComplete.Milestones.templateFunction(value.title);
+ tmpl = GfmAutoComplete.Milestones.templateFunction(value.title, value.expired);
}
return tmpl;
},
@@ -367,16 +401,39 @@ class GfmAutoComplete {
callbacks: {
...this.getDefaultCallbacks(),
beforeSave(milestones) {
- return $.map(milestones, (m) => {
+ const parsedMilestones = $.map(milestones, (m) => {
if (m.title == null) {
return m;
}
+
+ const dueDate = m.due_date ? parsePikadayDate(m.due_date) : null;
+ const expired = dueDate ? Date.now() > dueDate.getTime() : false;
+
return {
id: m.iid,
title: sanitize(m.title),
search: m.title,
+ expired,
+ dueDate,
};
});
+
+ // Sort milestones by due date when present.
+ if (typeof parsedMilestones[0] === 'object') {
+ return parsedMilestones.sort((mA, mB) => {
+ // Move all expired milestones to the bottom.
+ if (mA.expired) return 1;
+ if (mB.expired) return -1;
+
+ // Move milestones without due dates just above expired milestones.
+ if (!mA.dueDate) return 1;
+ if (!mB.dueDate) return -1;
+
+ // Sort by due date in ascending order.
+ return mA.dueDate - mB.dueDate;
+ });
+ }
+ return parsedMilestones;
},
},
});
@@ -772,6 +829,14 @@ GfmAutoComplete.Members = {
title,
)}${availabilityStatus}</small> ${icon}</li>`;
},
+ nameOrUsernameStartsWith(member, query) {
+ // `member.search` is a name:username string like `MargeSimpson msimpson`
+ return member.search.split(' ').some((name) => name.toLowerCase().startsWith(query));
+ },
+ nameOrUsernameIncludes(member, query) {
+ // `member.search` is a name:username string like `MargeSimpson msimpson`
+ return member.search.toLowerCase().includes(query);
+ },
};
GfmAutoComplete.Labels = {
templateFunction(color, title) {
@@ -792,7 +857,12 @@ GfmAutoComplete.Issues = {
};
// Milestones
GfmAutoComplete.Milestones = {
- templateFunction(title) {
+ templateFunction(title, expired) {
+ if (expired) {
+ return `<li>${sprintf(__('%{milestone} (expired)'), {
+ milestone: escape(title),
+ })}</li>`;
+ }
return `<li>${escape(title)}</li>`;
},
};
diff --git a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
index 7b029c6cf54..ab13450bb1e 100644
--- a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
+++ b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
@@ -1,6 +1,7 @@
<script>
-import { GlButton, GlFormGroup, GlFormInput, GlFormCheckbox, GlIcon } from '@gitlab/ui';
+import { GlButton, GlFormGroup, GlFormInput, GlFormCheckbox, GlIcon, GlLink } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
+import { helpPagePath } from '~/helpers/help_page_helper';
export default {
components: {
@@ -9,9 +10,15 @@ export default {
GlFormGroup,
GlFormInput,
GlIcon,
+ GlLink,
},
data() {
- return { placeholderUrl: 'https://my-url.grafana.net/' };
+ return {
+ helpUrl: helpPagePath('operations/metrics/embed_grafana', {
+ anchor: 'use-integration-with-grafana-api',
+ }),
+ placeholderUrl: 'https://my-grafana.example.com/',
+ };
},
computed: {
...mapState(['operationsSettingsEndpoint', 'grafanaToken', 'grafanaUrl', 'grafanaEnabled']),
@@ -59,7 +66,12 @@ export default {
</h4>
<gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button>
<p class="js-section-sub-header">
- {{ s__('GrafanaIntegration|Embed Grafana charts in GitLab issues.') }}
+ {{
+ s__(
+ 'GrafanaIntegration|Set up Grafana authentication to embed Grafana panels in GitLab Flavored Markdown.',
+ )
+ }}
+ <gl-link :href="helpUrl">{{ __('Learn more.') }}</gl-link>
</p>
</div>
<div class="settings-content">
@@ -78,22 +90,22 @@ export default {
>
<gl-form-input id="grafana-url" v-model="localGrafanaUrl" :placeholder="placeholderUrl" />
</gl-form-group>
- <gl-form-group :label="s__('GrafanaIntegration|API Token')" label-for="grafana-token">
+ <gl-form-group :label="s__('GrafanaIntegration|API token')" label-for="grafana-token">
<gl-form-input id="grafana-token" v-model="localGrafanaToken" />
<p class="form-text text-muted">
- {{ s__('GrafanaIntegration|Enter the Grafana API Token.') }}
+ {{ s__('GrafanaIntegration|Enter the Grafana API token.') }}
<a
href="https://grafana.com/docs/http_api/auth/#create-api-token"
target="_blank"
rel="noopener noreferrer"
>
- {{ __('More information') }}
+ {{ __('More information.') }}
<gl-icon name="external-link" class="vertical-align-middle" />
</a>
</p>
</gl-form-group>
<gl-button variant="success" category="primary" @click="updateGrafanaIntegration">
- {{ __('Save Changes') }}
+ {{ __('Save changes') }}
</gl-button>
</form>
</div>
diff --git a/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql
index 62119177887..101633ef7a7 100644
--- a/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql
+++ b/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql
@@ -5,7 +5,11 @@ fragment AlertListItem on AlertManagementAlert {
status
startedAt
eventCount
- issueIid
+ issue {
+ iid
+ state
+ title
+ }
assignees {
nodes {
name
diff --git a/app/assets/javascripts/graphql_shared/fragments/epic.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/epic.fragment.graphql
index 286ebbd019e..b5b4ba4e772 100644
--- a/app/assets/javascripts/graphql_shared/fragments/epic.fragment.graphql
+++ b/app/assets/javascripts/graphql_shared/fragments/epic.fragment.graphql
@@ -4,6 +4,7 @@ fragment EpicNode on Epic {
title
state
reference
+ webPath
webUrl
createdAt
closedAt
diff --git a/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql
new file mode 100644
index 00000000000..1d9497d65ce
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql
@@ -0,0 +1,14 @@
+query searchProjectMembers($fullPath: ID!, $search: String) {
+ project(fullPath: $fullPath) {
+ projectMembers(search: $search) {
+ nodes {
+ user {
+ id
+ name
+ username
+ avatarUrl
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
index b64ceb8e2c9..aaaaf3485ad 100644
--- a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
+++ b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
@@ -1,9 +1,13 @@
#import "../fragments/user.fragment.graphql"
-query usersSearch($search: String!) {
- users(search: $search) {
- nodes {
- ...User
+query usersSearch($search: String!, $fullPath: ID!) {
+ workspace: project(fullPath: $fullPath) {
+ users: projectMembers(search: $search) {
+ nodes {
+ user {
+ ...User
+ }
+ }
}
}
}
diff --git a/app/assets/javascripts/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js
index 4715bbc94f6..e64e8009a5f 100644
--- a/app/assets/javascripts/graphql_shared/utils.js
+++ b/app/assets/javascripts/graphql_shared/utils.js
@@ -1,3 +1,5 @@
+import { isArray } from 'lodash';
+
/**
* Ids generated by GraphQL endpoints are usually in the format
* gid://gitlab/Environments/123. This method extracts Id number
@@ -52,3 +54,35 @@ export const convertToGraphQLId = (type, id) => {
* @returns {Array}
*/
export const convertToGraphQLIds = (type, ids) => ids.map((id) => convertToGraphQLId(type, id));
+
+/**
+ * Ids generated by GraphQL endpoints are usually in the format
+ * gid://gitlab/Groups/123. This method takes an array of
+ * GraphQL Ids and converts them to a number.
+ *
+ * @param {Array} ids An array of GraphQL IDs
+ * @returns {Array}
+ */
+export const convertFromGraphQLIds = (ids) => {
+ if (!isArray(ids)) {
+ throw new TypeError(`ids must be an array; got ${typeof ids}`);
+ }
+
+ return ids.map((id) => getIdFromGraphQLId(id));
+};
+
+/**
+ * Ids generated by GraphQL endpoints are usually in the format
+ * gid://gitlab/Groups/123. This method takes an array of nodes
+ * and converts the `id` properties from a GraphQL Id to a number.
+ *
+ * @param {Array} nodes An array of nodes with an `id` property
+ * @returns {Array}
+ */
+export const convertNodeIdsFromGraphQLIds = (nodes) => {
+ if (!isArray(nodes)) {
+ throw new TypeError(`nodes must be an array; got ${typeof nodes}`);
+ }
+
+ return nodes.map((node) => (node.id ? { ...node, id: getIdFromGraphQLId(node.id) } : node));
+};
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index d41fa0b2410..9d46fcec09b 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -1,29 +1,36 @@
<script>
-/* eslint-disable vue/no-v-html */
-import { GlLoadingIcon, GlBadge, GlTooltipDirective } from '@gitlab/ui';
-import { visitUrl } from '../../lib/utils/url_utility';
-import identicon from '../../vue_shared/components/identicon.vue';
+import {
+ GlLoadingIcon,
+ GlBadge,
+ GlIcon,
+ GlTooltipDirective,
+ GlSafeHtmlDirective,
+} from '@gitlab/ui';
+import { visitUrl } from '~/lib/utils/url_utility';
+import identicon from '~/vue_shared/components/identicon.vue';
+import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '../constants';
import eventHub from '../event_hub';
import itemActions from './item_actions.vue';
import itemCaret from './item_caret.vue';
import itemStats from './item_stats.vue';
-import itemStatsValue from './item_stats_value.vue';
import itemTypeIcon from './item_type_icon.vue';
export default {
directives: {
GlTooltip: GlTooltipDirective,
+ SafeHtml: GlSafeHtmlDirective,
},
components: {
GlBadge,
GlLoadingIcon,
+ GlIcon,
+ UserAccessRoleBadge,
identicon,
itemCaret,
itemTypeIcon,
itemStats,
- itemStatsValue,
itemActions,
},
props: {
@@ -91,6 +98,7 @@ export default {
}
},
},
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
};
</script>
@@ -140,28 +148,31 @@ export default {
data-testid="group-name"
:href="group.relativePath"
:title="group.fullName"
- class="no-expand gl-mt-3 gl-mr-3 gl-text-gray-900!"
+ class="no-expand gl-mr-3 gl-mt-3 gl-text-gray-900!"
:itemprop="microdata.nameItemprop"
- >{{
+ >
+ {{
// ending bracket must be by closing tag to prevent
// link hover text-decoration from over-extending
group.name
- }}</a
- >
- <item-stats-value
- :icon-name="visibilityIcon"
+ }}
+ </a>
+ <gl-icon
+ v-gl-tooltip.hover.bottom
+ class="gl-display-inline-flex gl-align-items-center gl-mr-3 gl-mt-3 gl-text-gray-500"
+ :name="visibilityIcon"
:title="visibilityTooltip"
- css-class="item-visibility d-inline-flex align-items-center gl-mt-3 gl-mr-2 text-secondary"
+ data-testid="group-visibility-icon"
/>
- <span v-if="group.permission" class="user-access-role gl-mt-3">
+ <user-access-role-badge v-if="group.permission" class="gl-mt-3">
{{ group.permission }}
- </span>
+ </user-access-role-badge>
</div>
<div v-if="group.description" class="description">
<span
+ v-safe-html:[$options.safeHtmlConfig]="group.description"
:itemprop="microdata.descriptionItemprop"
data-testid="group-description"
- v-html="group.description"
>
</span>
</div>
diff --git a/app/assets/javascripts/groups/components/invite_members_banner.vue b/app/assets/javascripts/groups/components/invite_members_banner.vue
index 81c5e3ce85d..747cea6a46e 100644
--- a/app/assets/javascripts/groups/components/invite_members_banner.vue
+++ b/app/assets/javascripts/groups/components/invite_members_banner.vue
@@ -35,7 +35,9 @@ export default {
this.track(this.$options.dismissEvent);
},
trackOnShow() {
- if (!this.isDismissed) this.track(this.$options.displayEvent);
+ this.$nextTick(() => {
+ if (!this.isDismissed) this.track(this.$options.displayEvent);
+ });
},
addTrackingAttributesToButton() {
if (this.$refs.banner === undefined) return;
diff --git a/app/assets/javascripts/groups/components/item_caret.vue b/app/assets/javascripts/groups/components/item_caret.vue
index e23b0fa7413..9c379d7bf9b 100644
--- a/app/assets/javascripts/groups/components/item_caret.vue
+++ b/app/assets/javascripts/groups/components/item_caret.vue
@@ -21,5 +21,7 @@ export default {
</script>
<template>
- <span class="folder-caret gl-mr-2"> <gl-icon :size="10" :name="iconClass" /> </span>
+ <span class="folder-caret gl-mr-2">
+ <gl-icon :size="10" :name="iconClass" use-deprecated-sizes />
+ </span>
</template>
diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js
index da2890f91fc..93fbbf07ae2 100644
--- a/app/assets/javascripts/groups_select.js
+++ b/app/assets/javascripts/groups_select.js
@@ -2,30 +2,8 @@ import $ from 'jquery';
import { escape } from 'lodash';
import { __ } from '~/locale';
import Api from './api';
-import axios from './lib/utils/axios_utils';
-import { normalizeHeaders } from './lib/utils/common_utils';
import { loadCSSFile } from './lib/utils/css_utils';
-
-const fetchGroups = (params) => {
- axios[params.type.toLowerCase()](params.url, {
- params: params.data,
- })
- .then((res) => {
- const results = res.data || [];
- const headers = normalizeHeaders(res.headers);
- const currentPage = parseInt(headers['X-PAGE'], 10) || 0;
- const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0;
- const more = currentPage < totalPages;
-
- params.success({
- results,
- pagination: {
- more,
- },
- });
- })
- .catch(params.error);
-};
+import { select2AxiosTransport } from './lib/utils/select2_utils';
const groupsSelect = () => {
loadCSSFile(gon.select2_css_path)
@@ -51,9 +29,7 @@ const groupsSelect = () => {
url: Api.buildUrl(groupsPath),
dataType: 'json',
quietMillis: 250,
- transport(params) {
- fetchGroups(params);
- },
+ transport: select2AxiosTransport,
data(search, page) {
return {
search,
@@ -63,8 +39,6 @@ const groupsSelect = () => {
};
},
results(data, page) {
- if (data.length) return { results: [] };
-
const groups = data.length ? data : data.results || [];
const more = data.pagination ? data.pagination.more : false;
const results = groups.filter((group) => skipGroups.indexOf(group.id) === -1);
diff --git a/app/assets/javascripts/helpers/cve_id_request_helper.js b/app/assets/javascripts/helpers/cve_id_request_helper.js
new file mode 100644
index 00000000000..71d3fd4c4fe
--- /dev/null
+++ b/app/assets/javascripts/helpers/cve_id_request_helper.js
@@ -0,0 +1,50 @@
+export function createCveIdRequestIssueBody(fullPath, iid) {
+ return `### Vulnerability Submission
+
+**NOTE:** Only maintainers of GitLab-hosted projects may request a CVE for
+a vulnerability within their project.
+
+Project issue: ${fullPath}#${iid}
+
+#### Publishing Schedule
+
+After a CVE request is validated, a CVE identifier will be assigned. On what
+schedule should the details of the CVE be published?
+
+* [ ] Publish immediately
+* [ ] Wait to publish
+
+<!--
+Please fill out the yaml codeblock below
+-->
+
+\`\`\`yaml
+reporter:
+ name: "TODO" # "First Last"
+ email: "TODO" # "email@domain.tld"
+vulnerability:
+ description: "TODO" # "[VULNTYPE] in [COMPONENT] in [VENDOR][PRODUCT] [VERSION] allows [ATTACKER] to [IMPACT] via [VECTOR]"
+ cwe: "TODO" # "CWE-22" # Path Traversal
+ product:
+ gitlab_path: "${fullPath}"
+ vendor: "TODO" # "Deluxe Sandwich Maker Company"
+ name: "TODO" # "Deluxe Sandwich Maker 2"
+ affected_versions:
+ - "TODO" # "1.2.3"
+ - "TODO" # ">1.3.0, <=1.3.9"
+ fixed_versions:
+ - "TODO" # "1.2.4"
+ - "TODO" # "1.3.10"
+ impact: "TODO" # "CVSS v3 string" # https://nvd.nist.gov/vuln-metrics/cvss/v3-calculator
+ solution: "TODO" # "Upgrade to version 1.2.4 or 1.3.10"
+ credit: "TODO"
+ references:
+ - "TODO" # "https://some.domain.tld/a/reference"
+\`\`\`
+
+CVSS scores can be computed by means of the [NVD CVSS Calculator](https://nvd.nist.gov/vuln-metrics/cvss/v3-calculator).
+
+/relate ${fullPath}#${iid}
+/label ~"devops::secure" ~"group::vulnerability research" ~"vulnerability research::cve" ~"advisory::queued"
+ `;
+}
diff --git a/app/assets/javascripts/ide/components/branches/item.vue b/app/assets/javascripts/ide/components/branches/item.vue
index 2fe435b92ab..35e2f99cb6a 100644
--- a/app/assets/javascripts/ide/components/branches/item.vue
+++ b/app/assets/javascripts/ide/components/branches/item.vue
@@ -34,7 +34,7 @@ export default {
<template>
<a :href="branchHref" class="btn-link d-flex align-items-center">
<span class="d-flex gl-mr-3 ide-search-list-current-icon">
- <gl-icon v-if="isActive" :size="18" name="mobile-issue-close" />
+ <gl-icon v-if="isActive" :size="18" name="mobile-issue-close" use-deprecated-sizes />
</span>
<span>
<strong> {{ item.name }} </strong>
diff --git a/app/assets/javascripts/ide/components/branches/search_list.vue b/app/assets/javascripts/ide/components/branches/search_list.vue
index 1ae7cf9339d..5e93b7c1bbb 100644
--- a/app/assets/javascripts/ide/components/branches/search_list.vue
+++ b/app/assets/javascripts/ide/components/branches/search_list.vue
@@ -57,7 +57,10 @@ export default {
<template>
<div>
- <label class="dropdown-input pt-3 pb-3 mb-0 border-bottom block position-relative" @click.stop>
+ <label
+ class="dropdown-input gl-pt-3 gl-pb-5 gl-mb-0 gl-border-b-1 gl-border-b-solid gl-display-block"
+ @click.stop
+ >
<input
ref="searchInput"
v-model="search"
@@ -66,7 +69,7 @@ export default {
class="form-control dropdown-input-field"
@input="searchBranches"
/>
- <gl-icon :size="18" name="search" class="ml-3 input-icon" />
+ <gl-icon :size="18" name="search" class="ml-3 input-icon" use-deprecated-sizes />
</label>
<div class="dropdown-content ide-merge-requests-dropdown-content d-flex">
<gl-loading-icon
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
index cdb3eaff207..2897f4cbf77 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
@@ -1,17 +1,13 @@
<script>
import { GlModal, GlSafeHtmlDirective, GlButton, GlTooltipDirective } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
-import { n__, s__ } from '~/locale';
+import { n__ } from '~/locale';
import { leftSidebarViews, MAX_WINDOW_HEIGHT_COMPACT } from '../../constants';
import { createUnexpectedCommitError } from '../../lib/errors';
import Actions from './actions.vue';
import CommitMessageField from './message_field.vue';
import SuccessMessage from './success_message.vue';
-const MSG_CANNOT_PUSH_CODE = s__(
- 'WebIDE|You need permission to edit files directly in this project.',
-);
-
export default {
components: {
Actions,
@@ -35,14 +31,14 @@ export default {
computed: {
...mapState(['changedFiles', 'stagedFiles', 'currentActivityView', 'lastCommitMsg']),
...mapState('commit', ['commitMessage', 'submitCommitLoading', 'commitError']),
- ...mapGetters(['someUncommittedChanges', 'canPushCode']),
+ ...mapGetters(['someUncommittedChanges', 'canPushCodeStatus']),
...mapGetters('commit', ['discardDraftButtonDisabled', 'preBuiltCommitMessage']),
commitButtonDisabled() {
- return !this.canPushCode || !this.someUncommittedChanges;
+ return !this.canPushCodeStatus.isAllowed || !this.someUncommittedChanges;
},
commitButtonTooltip() {
- if (!this.canPushCode) {
- return MSG_CANNOT_PUSH_CODE;
+ if (!this.canPushCodeStatus.isAllowed) {
+ return this.canPushCodeStatus.messageShort;
}
return '';
@@ -86,7 +82,7 @@ export default {
commit() {
// Even though the submit button will be disabled, we need to disable the submission
// since hitting enter on the branch name text input also submits the form.
- if (!this.canPushCode) {
+ if (!this.canPushCodeStatus.isAllowed) {
return false;
}
@@ -130,8 +126,6 @@ export default {
this.componentHeight = null;
},
},
- // Expose for tests
- MSG_CANNOT_PUSH_CODE,
};
</script>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue
index 121dae4b87e..43bf2e1a90c 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue
@@ -41,7 +41,6 @@ export default {
:disabled="shouldDisableNewMrOption"
:checked="shouldCreateMR"
type="checkbox"
- data-qa-selector="start_new_mr_checkbox"
@change="toggleShouldCreateMR"
/>
<span class="gl-ml-3 ide-option-label">
diff --git a/app/assets/javascripts/ide/components/file_templates/bar.vue b/app/assets/javascripts/ide/components/file_templates/bar.vue
index bd4c4f18141..0803925104d 100644
--- a/app/assets/javascripts/ide/components/file_templates/bar.vue
+++ b/app/assets/javascripts/ide/components/file_templates/bar.vue
@@ -49,7 +49,9 @@ export default {
</script>
<template>
- <div class="d-flex align-items-center ide-file-templates qa-file-templates-bar">
+ <div
+ class="d-flex 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"
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index 2816f89d60d..ff2644704d9 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -1,7 +1,7 @@
<script>
import { GlAlert, GlButton, GlLoadingIcon } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
-import { __, s__ } from '~/locale';
+import { __ } from '~/locale';
import {
WEBIDE_MARK_APP_START,
WEBIDE_MARK_FILE_FINISH,
@@ -25,10 +25,6 @@ eventHub.$on(WEBIDE_MEASURE_FILE_AFTER_INTERACTION, () =>
),
);
-const MSG_CANNOT_PUSH_CODE = s__(
- 'WebIDE|You need permission to edit files directly in this project. Fork this project to make your changes and submit a merge request.',
-);
-
export default {
components: {
IdeSidebar,
@@ -63,7 +59,7 @@ export default {
'loading',
]),
...mapGetters([
- 'canPushCode',
+ 'canPushCodeStatus',
'activeFile',
'someUncommittedChanges',
'isCommitModeActive',
@@ -116,7 +112,6 @@ export default {
this.loadDeferred = true;
},
},
- MSG_CANNOT_PUSH_CODE,
};
</script>
@@ -125,8 +120,8 @@ export default {
class="ide position-relative d-flex flex-column align-items-stretch"
:class="{ [`theme-${themeName}`]: themeName }"
>
- <gl-alert v-if="!canPushCode" :dismissible="false">{{
- $options.MSG_CANNOT_PUSH_CODE
+ <gl-alert v-if="!canPushCodeStatus.isAllowed" :dismissible="false">{{
+ canPushCodeStatus.message
}}</gl-alert>
<error-message v-if="errorMessage" :message="errorMessage" />
<div class="ide-view flex-grow d-flex">
diff --git a/app/assets/javascripts/ide/components/merge_requests/item.vue b/app/assets/javascripts/ide/components/merge_requests/item.vue
index 7aa9a4f864a..639937481f3 100644
--- a/app/assets/javascripts/ide/components/merge_requests/item.vue
+++ b/app/assets/javascripts/ide/components/merge_requests/item.vue
@@ -41,7 +41,7 @@ export default {
<template>
<a :href="mergeRequestHref" class="btn-link d-flex align-items-center">
<span class="d-flex gl-mr-3 ide-search-list-current-icon">
- <gl-icon v-if="isActive" :size="18" name="mobile-issue-close" />
+ <gl-icon v-if="isActive" :size="18" name="mobile-issue-close" use-deprecated-sizes />
</span>
<span>
<strong> {{ item.title }} </strong>
diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue
index 680e8841a1f..f7cfe80df5c 100644
--- a/app/assets/javascripts/ide/components/merge_requests/list.vue
+++ b/app/assets/javascripts/ide/components/merge_requests/list.vue
@@ -75,7 +75,10 @@ export default {
<template>
<div>
- <label class="dropdown-input pt-3 pb-3 mb-0 border-bottom block" @click.stop>
+ <label
+ class="dropdown-input gl-pt-3 gl-pb-5 gl-mb-0 gl-border-b-1 gl-border-b-solid gl-display-block"
+ @click.stop
+ >
<tokened-input
v-model="search"
:tokens="searchTokens"
@@ -84,7 +87,7 @@ export default {
@input="searchMergeRequests"
@removeToken="setSearchType(null)"
/>
- <gl-icon :size="18" name="search" class="ml-3 input-icon" />
+ <gl-icon :size="18" name="search" class="ml-3 input-icon" use-deprecated-sizes />
</label>
<div class="dropdown-content ide-merge-requests-dropdown-content d-flex">
<gl-loading-icon
@@ -102,7 +105,7 @@ export default {
@click.stop="setSearchType(searchType)"
>
<span class="d-flex gl-mr-3 ide-search-list-current-icon">
- <gl-icon :size="18" name="search" />
+ <gl-icon :size="18" name="search" use-deprecated-sizes />
</span>
<span>{{ searchType.label }}</span>
</button>
diff --git a/app/assets/javascripts/ide/components/nav_form.vue b/app/assets/javascripts/ide/components/nav_form.vue
index 62bb4841760..98f0504298b 100644
--- a/app/assets/javascripts/ide/components/nav_form.vue
+++ b/app/assets/javascripts/ide/components/nav_form.vue
@@ -1,13 +1,12 @@
<script>
-import Tab from '~/vue_shared/components/tabs/tab.vue';
-import Tabs from '~/vue_shared/components/tabs/tabs';
+import { GlTab, GlTabs } from '@gitlab/ui';
import BranchesSearchList from './branches/search_list.vue';
import MergeRequestSearchList from './merge_requests/list.vue';
export default {
components: {
- Tabs,
- Tab,
+ GlTab,
+ GlTabs,
BranchesSearchList,
MergeRequestSearchList,
},
@@ -23,20 +22,14 @@ export default {
<template>
<div class="ide-nav-form p-0">
- <tabs v-if="showMergeRequests" stop-propagation>
- <tab active>
- <template #title>
- {{ __('Branches') }}
- </template>
+ <gl-tabs v-if="showMergeRequests">
+ <gl-tab :title="__('Branches')">
<branches-search-list />
- </tab>
- <tab>
- <template #title>
- {{ __('Merge Requests') }}
- </template>
+ </gl-tab>
+ <gl-tab :title="__('Merge Requests')">
<merge-request-search-list />
- </tab>
- </tabs>
+ </gl-tab>
+ </gl-tabs>
<branches-search-list v-else />
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue
index 2526db0cd7b..907ac496982 100644
--- a/app/assets/javascripts/ide/components/pipelines/list.vue
+++ b/app/assets/javascripts/ide/components/pipelines/list.vue
@@ -32,7 +32,7 @@ export default {
SafeHtml,
},
computed: {
- ...mapState(['pipelinesEmptyStateSvgPath', 'links']),
+ ...mapState(['pipelinesEmptyStateSvgPath']),
...mapGetters(['currentProject']),
...mapGetters('pipelines', ['jobsCount', 'failedJobsCount', 'failedStages', 'pipelineFailed']),
...mapState('pipelines', [
@@ -85,7 +85,6 @@ export default {
</header>
<empty-state
v-if="!latestPipeline"
- :help-page-path="links.ciHelpPagePath"
:empty-state-svg-path="pipelinesEmptyStateSvgPath"
:can-set-ci="true"
class="mb-auto mt-auto"
diff --git a/app/assets/javascripts/ide/components/preview/navigator.vue b/app/assets/javascripts/ide/components/preview/navigator.vue
index 0c6cb041095..4d35e946d89 100644
--- a/app/assets/javascripts/ide/components/preview/navigator.vue
+++ b/app/assets/javascripts/ide/components/preview/navigator.vue
@@ -117,7 +117,7 @@ export default {
class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent"
@click="refresh"
>
- <gl-icon :size="18" name="retry" class="m-auto" />
+ <gl-icon :size="18" name="retry" use-deprecated-sizes class="m-auto" />
</button>
<div class="position-relative w-100 gl-ml-2">
<input
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index 690060f5cb0..b57dcd4276c 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -1,6 +1,15 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
+import {
+ EDITOR_TYPE_DIFF,
+ EDITOR_CODE_INSTANCE_FN,
+ EDITOR_DIFF_INSTANCE_FN,
+} from '~/editor/constants';
+import EditorLite from '~/editor/editor_lite';
+import { EditorWebIdeExtension } from '~/editor/extensions/editor_lite_webide_ext';
import { deprecatedCreateFlash as flash } from '~/flash';
+import ModelManager from '~/ide/lib/common/model_manager';
+import { defaultDiffEditorOptions, defaultEditorOptions } from '~/ide/lib/editor_options';
import { __ } from '~/locale';
import {
WEBIDE_MARK_FILE_CLICKED,
@@ -20,7 +29,6 @@ import {
FILE_VIEW_MODE_PREVIEW,
} from '../constants';
import eventHub from '../eventhub';
-import Editor from '../lib/editor';
import { getRulesWithTraversal } from '../lib/editorconfig/parser';
import mapRulesToMonaco from '../lib/editorconfig/rules_mapper';
import { getFileEditorOrDefault } from '../stores/modules/editor/utils';
@@ -46,6 +54,9 @@ export default {
content: '',
images: {},
rules: {},
+ globalEditor: null,
+ modelManager: new ModelManager(),
+ isEditorLoading: true,
};
},
computed: {
@@ -132,6 +143,7 @@ export default {
// Compare key to allow for files opened in review mode to be cached differently
if (oldVal.key !== this.file.key) {
+ this.isEditorLoading = true;
this.initEditor();
if (this.currentActivityView !== leftSidebarViews.edit.name) {
@@ -149,6 +161,7 @@ export default {
}
},
viewer() {
+ this.isEditorLoading = false;
if (!this.file.pending) {
this.createEditorInstance();
}
@@ -181,11 +194,11 @@ export default {
},
},
beforeDestroy() {
- this.editor.dispose();
+ this.globalEditor.dispose();
},
mounted() {
- if (!this.editor) {
- this.editor = Editor.create(this.$store, this.editorOptions);
+ if (!this.globalEditor) {
+ this.globalEditor = new EditorLite();
}
this.initEditor();
@@ -211,8 +224,6 @@ export default {
return;
}
- this.editor.clearEditor();
-
this.registerSchemaForFile();
Promise.all([this.fetchFileData(), this.fetchEditorconfigRules()])
@@ -251,20 +262,45 @@ export default {
return;
}
- this.editor.dispose();
+ const isDiff = this.viewer !== viewerTypes.edit;
+ const shouldDisposeEditor = isDiff !== (this.editor?.getEditorType() === EDITOR_TYPE_DIFF);
- this.$nextTick(() => {
- if (this.viewer === viewerTypes.edit) {
- this.editor.createInstance(this.$refs.editor);
- } else {
- this.editor.createDiffInstance(this.$refs.editor);
+ if (this.editor && !shouldDisposeEditor) {
+ this.setupEditor();
+ } else {
+ if (this.editor && shouldDisposeEditor) {
+ this.editor.dispose();
}
+ const instanceOptions = isDiff ? defaultDiffEditorOptions : defaultEditorOptions;
+ const method = isDiff ? EDITOR_DIFF_INSTANCE_FN : EDITOR_CODE_INSTANCE_FN;
- this.setupEditor();
- });
+ this.editor = this.globalEditor[method]({
+ el: this.$refs.editor,
+ blobPath: this.file.path,
+ blobGlobalId: this.file.key,
+ blobContent: this.content || this.file.content,
+ ...instanceOptions,
+ ...this.editorOptions,
+ });
+
+ this.editor.use(
+ new EditorWebIdeExtension({
+ instance: this.editor,
+ modelManager: this.modelManager,
+ store: this.$store,
+ file: this.file,
+ options: this.editorOptions,
+ }),
+ );
+
+ this.$nextTick(() => {
+ this.setupEditor();
+ });
+ }
},
+
setupEditor() {
- if (!this.file || !this.editor.instance || this.file.loading) return;
+ if (!this.file || !this.editor || this.file.loading) return;
const head = this.getStagedFile(this.file.path);
@@ -279,6 +315,8 @@ export default {
this.editor.attachModel(this.model);
}
+ this.isEditorLoading = false;
+
this.model.updateOptions(this.rules);
this.model.onChange((model) => {
@@ -298,7 +336,7 @@ export default {
});
});
- this.editor.setPosition({
+ this.editor.setPos({
lineNumber: this.fileEditor.editorRow,
column: this.fileEditor.editorColumn,
});
@@ -308,6 +346,10 @@ export default {
fileLanguage: this.model.language,
});
+ this.$nextTick(() => {
+ this.editor.updateDimensions();
+ });
+
this.$emit('editorSetup');
if (performance.getEntriesByName(WEBIDE_MARK_FILE_CLICKED).length) {
eventHub.$emit(WEBIDE_MEASURE_FILE_AFTER_INTERACTION);
@@ -344,7 +386,7 @@ export default {
});
},
onPaste(event) {
- const editor = this.editor.instance;
+ const { editor } = this;
const reImage = /^image\/(png|jpg|jpeg|gif)$/;
const file = event.clipboardData.files[0];
@@ -395,6 +437,7 @@ export default {
<a
href="javascript:void(0);"
role="button"
+ data-testid="edit-tab"
@click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })"
>
{{ __('Edit') }}
@@ -404,6 +447,7 @@ export default {
<a
href="javascript:void(0);"
role="button"
+ data-testid="preview-tab"
@click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })"
>{{ previewMode.previewTitle }}</a
>
@@ -414,6 +458,7 @@ export default {
<div
v-show="showEditor"
ref="editor"
+ :key="`content-editor`"
:class="{
'is-readonly': isCommitModeActive,
'is-deleted': file.deleted,
@@ -421,6 +466,8 @@ export default {
}"
class="multi-file-editor-holder"
data-qa-selector="editor_container"
+ data-testid="editor-container"
+ :data-editor-loading="isEditorLoading"
@focusout="triggerFilesChange"
></div>
<content-viewer
diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue
index d28751c9571..64ec2cc67c7 100644
--- a/app/assets/javascripts/ide/components/repo_tab.vue
+++ b/app/assets/javascripts/ide/components/repo_tab.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTab } from '@gitlab/ui';
import { mapActions, mapGetters } from 'vuex';
import { __, sprintf } from '~/locale';
@@ -13,6 +13,7 @@ export default {
FileIcon,
GlIcon,
ChangedFileIcon,
+ GlTab,
},
props: {
tab: {
@@ -71,29 +72,30 @@ export default {
</script>
<template>
- <li
- :class="{
- active: tab.active,
- disabled: tab.pending,
- }"
+ <gl-tab
+ :active="tab.active"
+ :disabled="tab.pending"
+ :title="tab.name"
@click="clickFile(tab)"
@mouseover="mouseOverTab"
@mouseout="mouseOutTab"
>
- <div :title="getUrlForPath(tab.path)" class="multi-file-tab">
- <file-icon :file-name="tab.name" :size="16" />
- {{ tab.name }}
- <file-status-icon :file="tab" />
- </div>
- <button
- :aria-label="closeLabel"
- :disabled="tab.pending"
- type="button"
- class="multi-file-tab-close"
- @click.stop.prevent="closeFile(tab)"
- >
- <gl-icon v-if="!showChangedIcon" :size="12" name="close" />
- <changed-file-icon v-else :file="tab" />
- </button>
- </li>
+ <template #title>
+ <div :title="getUrlForPath(tab.path)" class="multi-file-tab">
+ <file-icon :file-name="tab.name" :size="16" />
+ {{ tab.name }}
+ <file-status-icon :file="tab" />
+ </div>
+ <button
+ :aria-label="closeLabel"
+ :disabled="tab.pending"
+ type="button"
+ class="multi-file-tab-close"
+ @click.stop.prevent="closeFile(tab)"
+ >
+ <gl-icon v-if="!showChangedIcon" :size="12" name="close" />
+ <changed-file-icon v-else :file="tab" />
+ </button>
+ </template>
+ </gl-tab>
</template>
diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue
index c03694e3619..932040c7fa5 100644
--- a/app/assets/javascripts/ide/components/repo_tabs.vue
+++ b/app/assets/javascripts/ide/components/repo_tabs.vue
@@ -1,10 +1,12 @@
<script>
+import { GlTabs } from '@gitlab/ui';
import { mapActions, mapGetters } from 'vuex';
import RepoTab from './repo_tab.vue';
export default {
components: {
RepoTab,
+ GlTabs,
},
props: {
activeFile: {
@@ -42,8 +44,8 @@ export default {
<template>
<div class="multi-file-tabs">
- <ul ref="tabsScroller" class="list-unstyled gl-mb-0">
+ <gl-tabs>
<repo-tab v-for="tab in files" :key="tab.key" :tab="tab" />
- </ul>
+ </gl-tabs>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/shared/tokened_input.vue b/app/assets/javascripts/ide/components/shared/tokened_input.vue
index e7a4c5487d1..ed0dab47947 100644
--- a/app/assets/javascripts/ide/components/shared/tokened_input.vue
+++ b/app/assets/javascripts/ide/components/shared/tokened_input.vue
@@ -81,7 +81,9 @@ export default {
>
<div class="value-container rounded">
<div class="value">{{ token.label }}</div>
- <div class="remove-token inverted"><gl-icon :size="10" name="close" /></div>
+ <div class="remove-token inverted">
+ <gl-icon :size="10" name="close" use-deprecated-sizes />
+ </div>
</div>
</button>
</div>
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index ed6b750480b..056df3739ee 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -15,6 +15,7 @@ export const FILE_VIEW_MODE_PREVIEW = 'preview';
export const PERMISSION_CREATE_MR = 'createMergeRequestIn';
export const PERMISSION_READ_MR = 'readMergeRequest';
export const PERMISSION_PUSH_CODE = 'pushCode';
+export const PUSH_RULE_REJECT_UNSIGNED_COMMITS = 'rejectUnsignedCommits';
// The default permission object to use when the project data isn't available yet.
// This helps us encapsulate checks like `canPushCode` without requiring an
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index 1b4b59ef62f..f4a0f324e4a 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -53,7 +53,6 @@ export function initIde(el, options = {}) {
promotionSvgPath: el.dataset.promotionSvgPath,
});
this.setLinks({
- ciHelpPagePath: el.dataset.ciHelpPagePath,
webIDEHelpPagePath: el.dataset.webIdeHelpPagePath,
});
this.setInitialData({
diff --git a/app/assets/javascripts/ide/messages.js b/app/assets/javascripts/ide/messages.js
new file mode 100644
index 00000000000..4298d4c627c
--- /dev/null
+++ b/app/assets/javascripts/ide/messages.js
@@ -0,0 +1,17 @@
+import { s__ } from '~/locale';
+
+export const MSG_CANNOT_PUSH_CODE = s__(
+ 'WebIDE|You need permission to edit files directly in this project. Fork this project to make your changes and submit a merge request.',
+);
+
+export const MSG_CANNOT_PUSH_CODE_SHORT = s__(
+ 'WebIDE|You need permission to edit files directly in this project.',
+);
+
+export const MSG_CANNOT_PUSH_UNSIGNED = s__(
+ 'WebIDE|This project does not accept unsigned commits. You will not be able to commit your changes through the Web IDE.',
+);
+
+export const MSG_CANNOT_PUSH_UNSIGNED_SHORT = s__(
+ 'WebIDE|This project does not accept unsigned commits.',
+);
diff --git a/app/assets/javascripts/ide/queries/getUserPermissions.query.graphql b/app/assets/javascripts/ide/queries/getUserPermissions.query.graphql
deleted file mode 100644
index f0b50793226..00000000000
--- a/app/assets/javascripts/ide/queries/getUserPermissions.query.graphql
+++ /dev/null
@@ -1,9 +0,0 @@
-query getUserPermissions($projectPath: ID!) {
- project(fullPath: $projectPath) {
- userPermissions {
- createMergeRequestIn
- readMergeRequest
- pushCode
- }
- }
-}
diff --git a/app/assets/javascripts/ide/queries/get_ide_project.query.graphql b/app/assets/javascripts/ide/queries/get_ide_project.query.graphql
new file mode 100644
index 00000000000..6ceb36909f3
--- /dev/null
+++ b/app/assets/javascripts/ide/queries/get_ide_project.query.graphql
@@ -0,0 +1,7 @@
+#import "~/ide/queries/ide_project.fragment.graphql"
+
+query getIdeProject($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ ...IdeProject
+ }
+}
diff --git a/app/assets/javascripts/ide/queries/ide_project.fragment.graphql b/app/assets/javascripts/ide/queries/ide_project.fragment.graphql
new file mode 100644
index 00000000000..c107f2376f9
--- /dev/null
+++ b/app/assets/javascripts/ide/queries/ide_project.fragment.graphql
@@ -0,0 +1,7 @@
+fragment IdeProject on Project {
+ userPermissions {
+ createMergeRequestIn
+ readMergeRequest
+ pushCode
+ }
+}
diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js
index e98653aedc2..0aa08323d13 100644
--- a/app/assets/javascripts/ide/services/index.js
+++ b/app/assets/javascripts/ide/services/index.js
@@ -1,14 +1,14 @@
+import getIdeProject from 'ee_else_ce/ide/queries/get_ide_project.query.graphql';
import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
-import getUserPermissions from '../queries/getUserPermissions.query.graphql';
import { query } from './gql';
const fetchApiProjectData = (projectPath) => Api.project(projectPath).then(({ data }) => data);
const fetchGqlProjectData = (projectPath) =>
query({
- query: getUserPermissions,
+ query: getIdeProject,
variables: { projectPath },
}).then(({ data }) => data.project);
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
index 9b93fc74f76..a5bb32ec44a 100644
--- a/app/assets/javascripts/ide/stores/getters.js
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -7,7 +7,14 @@ import {
PERMISSION_READ_MR,
PERMISSION_CREATE_MR,
PERMISSION_PUSH_CODE,
+ PUSH_RULE_REJECT_UNSIGNED_COMMITS,
} from '../constants';
+import {
+ MSG_CANNOT_PUSH_CODE,
+ MSG_CANNOT_PUSH_CODE_SHORT,
+ MSG_CANNOT_PUSH_UNSIGNED,
+ MSG_CANNOT_PUSH_UNSIGNED_SHORT,
+} from '../messages';
import { getChangesCountForFiles, filePathMatches } from './utils';
export const activeFile = (state) => state.openFiles.find((file) => file.active) || null;
@@ -153,14 +160,47 @@ export const getDiffInfo = (state, getters) => (path) => {
export const findProjectPermissions = (state, getters) => (projectId) =>
getters.findProject(projectId)?.userPermissions || DEFAULT_PERMISSIONS;
+export const findPushRules = (state, getters) => (projectId) =>
+ getters.findProject(projectId)?.pushRules || {};
+
export const canReadMergeRequests = (state, getters) =>
Boolean(getters.findProjectPermissions(state.currentProjectId)[PERMISSION_READ_MR]);
export const canCreateMergeRequests = (state, getters) =>
Boolean(getters.findProjectPermissions(state.currentProjectId)[PERMISSION_CREATE_MR]);
-export const canPushCode = (state, getters) =>
- Boolean(getters.findProjectPermissions(state.currentProjectId)[PERMISSION_PUSH_CODE]);
+/**
+ * Returns an object with `isAllowed` and `message` based on why the user cant push code
+ */
+export const canPushCodeStatus = (state, getters) => {
+ const canPushCode = getters.findProjectPermissions(state.currentProjectId)[PERMISSION_PUSH_CODE];
+ const rejectUnsignedCommits = getters.findPushRules(state.currentProjectId)[
+ PUSH_RULE_REJECT_UNSIGNED_COMMITS
+ ];
+
+ if (rejectUnsignedCommits) {
+ return {
+ isAllowed: false,
+ message: MSG_CANNOT_PUSH_UNSIGNED,
+ messageShort: MSG_CANNOT_PUSH_UNSIGNED_SHORT,
+ };
+ }
+ if (!canPushCode) {
+ return {
+ isAllowed: false,
+ message: MSG_CANNOT_PUSH_CODE,
+ messageShort: MSG_CANNOT_PUSH_CODE_SHORT,
+ };
+ }
+
+ return {
+ isAllowed: true,
+ message: '',
+ messageShort: '',
+ };
+};
+
+export const canPushCode = (state, getters) => getters.canPushCodeStatus.isAllowed;
export const entryExists = (state) => (path) =>
Boolean(state.entries[path] && !state.entries[path].deleted);
diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue
index 9e3347a657f..8df51ef7f9b 100644
--- a/app/assets/javascripts/import_entities/components/import_status.vue
+++ b/app/assets/javascripts/import_entities/components/import_status.vue
@@ -34,7 +34,7 @@ export default {
</script>
<template>
- <div>
+ <div class="gl-display-flex gl-h-7 gl-align-items-center">
<gl-loading-icon
v-if="mappedStatus.loadingIcon"
:inline="true"
diff --git a/app/assets/javascripts/import_entities/constants.js b/app/assets/javascripts/import_entities/constants.js
index ad33ca158d2..c2f398cb8a8 100644
--- a/app/assets/javascripts/import_entities/constants.js
+++ b/app/assets/javascripts/import_entities/constants.js
@@ -7,6 +7,7 @@ export const STATUSES = {
FINISHED: 'finished',
FAILED: 'failed',
SCHEDULED: 'scheduled',
+ CREATED: 'created',
STARTED: 'started',
NONE: 'none',
SCHEDULING: 'scheduling',
@@ -23,6 +24,11 @@ const STATUS_MAP = {
text: __('Failed'),
textClass: 'text-danger',
},
+ [STATUSES.CREATED]: {
+ icon: 'pending',
+ text: __('Scheduled'),
+ textClass: 'text-warning',
+ },
[STATUSES.SCHEDULED]: {
icon: 'pending',
text: __('Scheduled'),
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
index 7c5f48dcafc..f337520b0db 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
@@ -1,13 +1,15 @@
<script>
import {
GlEmptyState,
+ GlDropdown,
+ GlDropdownItem,
GlIcon,
GlLink,
GlLoadingIcon,
GlSearchBoxByClick,
GlSprintf,
} from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
import importGroupMutation from '../graphql/mutations/import_group.mutation.graphql';
import setNewNameMutation from '../graphql/mutations/set_new_name.mutation.graphql';
@@ -16,9 +18,14 @@ import availableNamespacesQuery from '../graphql/queries/available_namespaces.qu
import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql';
import ImportTableRow from './import_table_row.vue';
+const PAGE_SIZES = [20, 50, 100];
+const DEFAULT_PAGE_SIZE = PAGE_SIZES[0];
+
export default {
components: {
GlEmptyState,
+ GlDropdown,
+ GlDropdownItem,
GlIcon,
GlLink,
GlLoadingIcon,
@@ -33,12 +40,17 @@ export default {
type: String,
required: true,
},
+ groupPathRegex: {
+ type: RegExp,
+ required: true,
+ },
},
data() {
return {
filter: '',
page: 1,
+ perPage: DEFAULT_PAGE_SIZE,
};
},
@@ -46,13 +58,17 @@ export default {
bulkImportSourceGroups: {
query: bulkImportSourceGroupsQuery,
variables() {
- return { page: this.page, filter: this.filter };
+ return { page: this.page, filter: this.filter, perPage: this.perPage };
},
},
availableNamespaces: availableNamespacesQuery,
},
computed: {
+ humanizedTotal() {
+ return this.paginationInfo.total >= 1000 ? __('1000+') : this.paginationInfo.total;
+ },
+
hasGroups() {
return this.bulkImportSourceGroups?.nodes?.length > 0;
},
@@ -113,14 +129,20 @@ export default {
variables: { sourceGroupId },
});
},
+
+ setPageSize(size) {
+ this.perPage = size;
+ },
},
+
+ PAGE_SIZES,
};
</script>
<template>
<div>
<div
- class="gl-py-5 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex gl-align-items-center"
+ class="gl-py-5 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex"
>
<span>
<gl-sprintf v-if="!$apollo.loading && hasGroups" :message="statusMessage">
@@ -147,12 +169,17 @@ export default {
</div>
<gl-loading-icon v-if="$apollo.loading" size="md" class="gl-mt-5" />
<template v-else>
- <gl-empty-state v-if="hasEmptyFilter" :title="__('Sorry, your filter produced no results')" />
+ <gl-empty-state
+ v-if="hasEmptyFilter"
+ :title="__('Sorry, your filter produced no results')"
+ :description="__('To widen your search, change or remove filters above.')"
+ />
<gl-empty-state
v-else-if="!hasGroups"
- :title="s__('BulkImport|No groups available for import')"
+ :title="s__('BulkImport|You have no groups to import')"
+ :description="s__('Check your source instance permissions.')"
/>
- <div v-else class="gl-display-flex gl-flex-direction-column gl-align-items-center">
+ <template v-else>
<table class="gl-w-full">
<thead class="gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1">
<th class="gl-py-4 import-jobs-from-col">{{ s__('BulkImport|From source group') }}</th>
@@ -160,12 +187,13 @@ export default {
<th class="gl-py-4 import-jobs-status-col">{{ __('Status') }}</th>
<th class="gl-py-4 import-jobs-cta-col"></th>
</thead>
- <tbody>
+ <tbody class="gl-vertical-align-top">
<template v-for="group in bulkImportSourceGroups.nodes">
<import-table-row
:key="group.id"
:group="group"
:available-namespaces="availableNamespaces"
+ :group-path-regex="groupPathRegex"
@update-target-namespace="updateTargetNamespace(group.id, $event)"
@update-new-name="updateNewName(group.id, $event)"
@import-group="importGroup(group.id)"
@@ -173,12 +201,50 @@ export default {
</template>
</tbody>
</table>
- <pagination-links
- :change="setPage"
- :page-info="bulkImportSourceGroups.pageInfo"
- class="gl-mt-3"
- />
- </div>
+ <div v-if="hasGroups" class="gl-display-flex gl-mt-3 gl-align-items-center">
+ <pagination-links
+ :change="setPage"
+ :page-info="bulkImportSourceGroups.pageInfo"
+ class="gl-m-0"
+ />
+ <gl-dropdown category="tertiary" class="gl-ml-auto">
+ <template #button-content>
+ <span class="font-weight-bold">
+ <gl-sprintf :message="__('%{count} items per page')">
+ <template #count>
+ {{ perPage }}
+ </template>
+ </gl-sprintf>
+ </span>
+ <gl-icon class="gl-button-icon dropdown-chevron" name="chevron-down" />
+ </template>
+ <gl-dropdown-item
+ v-for="size in $options.PAGE_SIZES"
+ :key="size"
+ @click="setPageSize(size)"
+ >
+ <gl-sprintf :message="__('%{count} items per page')">
+ <template #count>
+ {{ size }}
+ </template>
+ </gl-sprintf>
+ </gl-dropdown-item>
+ </gl-dropdown>
+ <div class="gl-ml-2">
+ <gl-sprintf :message="s__('BulkImport|Showing %{start}-%{end} of %{total}')">
+ <template #start>
+ {{ paginationInfo.start }}
+ </template>
+ <template #end>
+ {{ paginationInfo.end }}
+ </template>
+ <template #total>
+ {{ humanizedTotal }}
+ </template>
+ </gl-sprintf>
+ </div>
+ </div>
+ </template>
</template>
</div>
</template>
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue
index 1707ab10c89..aed879e75fb 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue
@@ -1,15 +1,29 @@
<script>
-import { GlButton, GlIcon, GlLink, GlFormInput } from '@gitlab/ui';
+import {
+ GlButton,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlIcon,
+ GlLink,
+ GlFormInput,
+} from '@gitlab/ui';
import { joinPaths } from '~/lib/utils/url_utility';
-import Select2Select from '~/vue_shared/components/select2_select.vue';
import ImportStatus from '../../components/import_status.vue';
import { STATUSES } from '../../constants';
+import groupQuery from '../graphql/queries/group.query.graphql';
+
+const DEBOUNCE_INTERVAL = 300;
export default {
components: {
- Select2Select,
ImportStatus,
GlButton,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
GlLink,
GlIcon,
GlFormInput,
@@ -23,9 +37,41 @@ export default {
type: Array,
required: true,
},
+ groupPathRegex: {
+ type: RegExp,
+ required: true,
+ },
+ },
+
+ apollo: {
+ existingGroup: {
+ query: groupQuery,
+ debounce: DEBOUNCE_INTERVAL,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ };
+ },
+ skip() {
+ return !this.isNameValid || this.isAlreadyImported;
+ },
+ },
},
+
computed: {
- isDisabled() {
+ importTarget() {
+ return this.group.import_target;
+ },
+
+ isInvalid() {
+ return Boolean(!this.isNameValid || this.existingGroup);
+ },
+
+ isNameValid() {
+ return this.groupPathRegex.test(this.importTarget.new_name);
+ },
+
+ isAlreadyImported() {
return this.group.status !== STATUSES.NONE;
},
@@ -33,61 +79,89 @@ export default {
return this.group.status === STATUSES.FINISHED;
},
- select2Options() {
- return {
- data: this.availableNamespaces.map((namespace) => ({
- id: namespace.full_path,
- text: namespace.full_path,
- })),
- };
- },
- },
- methods: {
- getPath(group) {
- return `${group.import_target.target_namespace}/${group.import_target.new_name}`;
+ fullPath() {
+ return `${this.importTarget.target_namespace}/${this.importTarget.new_name}`;
},
- getFullPath(group) {
- return joinPaths(gon.relative_url_root || '/', this.getPath(group));
+ absolutePath() {
+ return joinPaths(gon.relative_url_root || '/', this.fullPath);
},
},
};
</script>
<template>
- <tr class="gl-border-gray-200 gl-border-0 gl-border-b-1">
+ <tr class="gl-border-gray-200 gl-border-0 gl-border-b-1 gl-border-solid">
<td class="gl-p-4">
- <gl-link :href="group.web_url" target="_blank">
+ <gl-link
+ :href="group.web_url"
+ target="_blank"
+ class="gl-display-flex gl-align-items-center gl-h-7"
+ >
{{ group.full_path }} <gl-icon name="external-link" />
</gl-link>
</td>
<td class="gl-p-4">
- <gl-link v-if="isFinished" :href="getFullPath(group)">{{ getPath(group) }}</gl-link>
+ <gl-link
+ v-if="isFinished"
+ class="gl-display-flex gl-align-items-center gl-h-7"
+ :href="absolutePath"
+ >
+ {{ fullPath }}
+ </gl-link>
<div
v-else
class="import-entities-target-select gl-display-flex gl-align-items-stretch"
:class="{
- disabled: isDisabled,
+ disabled: isAlreadyImported,
}"
>
- <select2-select
- :disabled="isDisabled"
- :options="select2Options"
- :value="group.import_target.target_namespace"
- @input="$emit('update-target-namespace', $event)"
- />
+ <gl-dropdown
+ :text="importTarget.target_namespace"
+ :disabled="isAlreadyImported"
+ toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
+ class="import-entities-namespace-dropdown gl-h-7 gl-flex-fill-1"
+ >
+ <gl-dropdown-item @click="$emit('update-target-namespace', '')">{{
+ s__('BulkImport|No parent')
+ }}</gl-dropdown-item>
+ <template v-if="availableNamespaces.length">
+ <gl-dropdown-divider />
+ <gl-dropdown-section-header>
+ {{ s__('BulkImport|Existing groups') }}
+ </gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="ns in availableNamespaces"
+ :key="ns.full_path"
+ @click="$emit('update-target-namespace', ns.full_path)"
+ >
+ {{ ns.full_path }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
<div
- class="import-entities-target-select-separator gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1"
+ class="import-entities-target-select-separator gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1"
>
/
</div>
- <gl-form-input
- class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
- :disabled="isDisabled"
- :value="group.import_target.new_name"
- @input="$emit('update-new-name', $event)"
- />
+ <div class="gl-flex-fill-1">
+ <gl-form-input
+ class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
+ :class="{ 'is-invalid': isInvalid && !isAlreadyImported }"
+ :disabled="isAlreadyImported"
+ :value="importTarget.new_name"
+ @input="$emit('update-new-name', $event)"
+ />
+ <p v-if="isInvalid" class="gl-text-red-500 gl-m-0 gl-mt-2">
+ <template v-if="!isNameValid">
+ {{ __('Please choose a group URL with no special characters.') }}
+ </template>
+ <template v-else-if="existingGroup">
+ {{ s__('BulkImport|Name already exists.') }}
+ </template>
+ </p>
+ </div>
</div>
</td>
<td class="gl-p-4 gl-white-space-nowrap">
@@ -95,7 +169,8 @@ export default {
</td>
<td class="gl-p-4">
<gl-button
- v-if="!isDisabled"
+ v-if="!isAlreadyImported"
+ :disabled="isInvalid"
variant="success"
category="secondary"
@click="$emit('import-group')"
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
index 8110934efc4..d444cc77aa7 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
@@ -15,52 +15,71 @@ export const clientTypenames = {
BulkImportPageInfo: 'ClientBulkImportPageInfo',
};
-export function createResolvers({ endpoints }) {
+export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGroupsManager }) {
let statusPoller;
+ let sourceGroupManager;
+ const getGroupsManager = (client) => {
+ if (!sourceGroupManager) {
+ sourceGroupManager = new GroupsManager({ client, sourceUrl });
+ }
+ return sourceGroupManager;
+ };
+
return {
Query: {
async bulkImportSourceGroups(_, vars, { client }) {
- const {
- data: { availableNamespaces },
- } = await client.query({ query: availableNamespacesQuery });
-
if (!statusPoller) {
statusPoller = new StatusPoller({
- client,
+ groupManager: getGroupsManager(client),
pollPath: endpoints.jobs,
});
statusPoller.startPolling();
}
- return axios
- .get(endpoints.status, {
+ const groupsManager = getGroupsManager(client);
+ return Promise.all([
+ axios.get(endpoints.status, {
params: {
page: vars.page,
per_page: vars.perPage,
filter: vars.filter,
},
- })
- .then(({ headers, data }) => {
+ }),
+ client.query({ query: availableNamespacesQuery }),
+ ]).then(
+ ([
+ { headers, data },
+ {
+ data: { availableNamespaces },
+ },
+ ]) => {
const pagination = parseIntPagination(normalizeHeaders(headers));
return {
__typename: clientTypenames.BulkImportSourceGroupConnection,
- nodes: data.importable_data.map((group) => ({
- __typename: clientTypenames.BulkImportSourceGroup,
- ...group,
- status: STATUSES.NONE,
- import_target: {
- new_name: group.full_path,
- target_namespace: availableNamespaces[0].full_path,
- },
- })),
+ nodes: data.importable_data.map((group) => {
+ const cachedImportState = groupsManager.getImportStateFromStorageByGroupId(
+ group.id,
+ );
+
+ return {
+ __typename: clientTypenames.BulkImportSourceGroup,
+ ...group,
+ status: cachedImportState?.status ?? STATUSES.NONE,
+ import_target: cachedImportState?.importTarget ?? {
+ new_name: group.full_path,
+ target_namespace: availableNamespaces[0]?.full_path ?? '',
+ },
+ };
+ }),
pageInfo: {
__typename: clientTypenames.BulkImportPageInfo,
...pagination,
},
};
- });
+ },
+ );
},
availableNamespaces: () =>
@@ -73,21 +92,21 @@ export function createResolvers({ endpoints }) {
},
Mutation: {
setTargetNamespace(_, { targetNamespace, sourceGroupId }, { client }) {
- new SourceGroupsManager({ client }).updateById(sourceGroupId, (sourceGroup) => {
+ getGroupsManager(client).updateById(sourceGroupId, (sourceGroup) => {
// eslint-disable-next-line no-param-reassign
sourceGroup.import_target.target_namespace = targetNamespace;
});
},
setNewName(_, { newName, sourceGroupId }, { client }) {
- new SourceGroupsManager({ client }).updateById(sourceGroupId, (sourceGroup) => {
+ getGroupsManager(client).updateById(sourceGroupId, (sourceGroup) => {
// eslint-disable-next-line no-param-reassign
sourceGroup.import_target.new_name = newName;
});
},
async importGroup(_, { sourceGroupId }, { client }) {
- const groupManager = new SourceGroupsManager({ client });
+ const groupManager = getGroupsManager(client);
const group = groupManager.findById(sourceGroupId);
groupManager.setImportStatus(group, STATUSES.SCHEDULING);
try {
@@ -101,13 +120,10 @@ export function createResolvers({ endpoints }) {
},
],
});
- groupManager.setImportStatus(group, STATUSES.STARTED);
- SourceGroupsManager.attachImportId(group, response.data.id);
+ groupManager.startImport({ group, importId: response.data.id });
} catch (e) {
- createFlash({
- message: s__('BulkImport|Importing the group failed'),
- });
-
+ const message = e?.response?.data?.error ?? s__('BulkImport|Importing the group failed');
+ createFlash({ message });
groupManager.setImportStatus(group, STATUSES.NONE);
throw e;
}
@@ -116,5 +132,5 @@ export function createResolvers({ endpoints }) {
};
}
-export const createApolloClient = ({ endpoints }) =>
- createDefaultClient(createResolvers({ endpoints }), { assumeImmutableResults: true });
+export const createApolloClient = ({ sourceUrl, endpoints }) =>
+ createDefaultClient(createResolvers({ sourceUrl, endpoints }), { assumeImmutableResults: true });
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/group.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/group.query.graphql
new file mode 100644
index 00000000000..52df3581ac4
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/queries/group.query.graphql
@@ -0,0 +1,5 @@
+query group($fullPath: ID!) {
+ existingGroup: group(fullPath: $fullPath) {
+ id
+ }
+}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js
index 261e30edbbb..2c88d25358f 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js
@@ -1,5 +1,7 @@
import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import produce from 'immer';
+import { debounce, merge } from 'lodash';
+import { STATUSES } from '../../../constants';
import ImportSourceGroupFragment from '../fragments/bulk_import_source_group_item.fragment.graphql';
function extractTypeConditionFromFragment(fragment) {
@@ -13,15 +15,24 @@ function generateGroupId(id) {
});
}
+export const KEY = 'gl-bulk-imports-import-state';
+export const DEBOUNCE_INTERVAL = 200;
+
export class SourceGroupsManager {
- static importMap = new Map();
+ constructor({ client, sourceUrl, storage = window.localStorage }) {
+ this.client = client;
+ this.sourceUrl = sourceUrl;
- static attachImportId(group, importId) {
- SourceGroupsManager.importMap.set(importId, group.id);
+ this.storage = storage;
+ this.importStates = this.loadImportStatesFromStorage();
}
- constructor({ client }) {
- this.client = client;
+ loadImportStatesFromStorage() {
+ try {
+ return JSON.parse(this.storage.getItem(KEY)) ?? {};
+ } catch {
+ return {};
+ }
}
findById(id) {
@@ -42,8 +53,48 @@ export class SourceGroupsManager {
this.update(group, fn);
}
- findByImportId(importId) {
- return this.findById(SourceGroupsManager.importMap.get(importId));
+ saveImportState(importId, group) {
+ this.importStates[this.getStorageKey(importId)] = {
+ id: group.id,
+ importTarget: group.import_target,
+ status: group.status,
+ };
+ this.saveImportStatesToStorage();
+ }
+
+ getImportStateFromStorage(importId) {
+ return this.importStates[this.getStorageKey(importId)];
+ }
+
+ getImportStateFromStorageByGroupId(groupId) {
+ const PREFIX = this.getStorageKey('');
+ const [, importState] =
+ Object.entries(this.importStates).find(
+ ([key, group]) => key.startsWith(PREFIX) && group.id === groupId,
+ ) ?? [];
+
+ return importState;
+ }
+
+ getStorageKey(importId) {
+ return `${this.sourceUrl}|${importId}`;
+ }
+
+ saveImportStatesToStorage = debounce(() => {
+ try {
+ // storage might be changed in other tab so fetch first
+ this.storage.setItem(
+ KEY,
+ JSON.stringify(merge({}, this.loadImportStatesFromStorage(), this.importStates)),
+ );
+ } catch {
+ // empty catch intentional: storage might be unavailable or full
+ }
+ }, DEBOUNCE_INTERVAL);
+
+ startImport({ group, importId }) {
+ this.setImportStatus(group, STATUSES.CREATED);
+ this.saveImportState(importId, group);
}
setImportStatus(group, status) {
@@ -52,4 +103,22 @@ export class SourceGroupsManager {
sourceGroup.status = status;
});
}
+
+ setImportStatusByImportId(importId, status) {
+ const importState = this.getImportStateFromStorage(importId);
+ if (!importState) {
+ return;
+ }
+
+ if (importState.status !== status) {
+ importState.status = status;
+ }
+
+ const group = this.findById(importState.id);
+ if (group?.id) {
+ this.setImportStatus(group, status);
+ }
+
+ this.saveImportStatesToStorage();
+ }
}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js
index 63cd6b48fc4..b80a575afce 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js
@@ -3,12 +3,9 @@ import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import Poll from '~/lib/utils/poll';
import { s__ } from '~/locale';
-import { SourceGroupsManager } from './source_groups_manager';
export class StatusPoller {
- constructor({ client, pollPath }) {
- this.client = client;
-
+ constructor({ groupManager, pollPath }) {
this.eTagPoll = new Poll({
resource: {
fetchJobs: () => axios.get(pollPath),
@@ -29,7 +26,7 @@ export class StatusPoller {
}
});
- this.groupManager = new SourceGroupsManager({ client });
+ this.groupManager = groupManager;
}
startPolling() {
@@ -38,10 +35,7 @@ export class StatusPoller {
async updateImportsStatuses(importStatuses) {
importStatuses.forEach(({ id, status_name: statusName }) => {
- const group = this.groupManager.findByImportId(id);
- if (group.id) {
- this.groupManager.setImportStatus(group, statusName);
- }
+ this.groupManager.setImportStatusByImportId(id, statusName);
});
}
}
diff --git a/app/assets/javascripts/import_entities/import_groups/index.js b/app/assets/javascripts/import_entities/import_groups/index.js
index cd837a840e4..cc60c8cbdb0 100644
--- a/app/assets/javascripts/import_entities/import_groups/index.js
+++ b/app/assets/javascripts/import_entities/import_groups/index.js
@@ -16,9 +16,11 @@ export function mountImportGroupsApp(mountElement) {
createBulkImportPath,
jobsPath,
sourceUrl,
+ groupPathRegex,
} = mountElement.dataset;
const apolloProvider = new VueApollo({
defaultClient: createApolloClient({
+ sourceUrl,
endpoints: {
status: statusPath,
availableNamespaces: availableNamespacesPath,
@@ -35,6 +37,7 @@ export function mountImportGroupsApp(mountElement) {
return createElement(ImportTable, {
props: {
sourceUrl,
+ groupPathRegex: new RegExp(`^(${groupPathRegex})$`),
},
});
},
diff --git a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
index 289c83979bb..ebb09947663 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
@@ -101,7 +101,8 @@ export default {
<template>
<tr
- class="qa-project-import-row gl-h-11 gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100 gl-h-11"
+ class="gl-h-11 gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100 gl-h-11"
+ data-qa-selector="project_import_row"
>
<td class="gl-p-4">
<gl-link :href="repo.importSource.providerLink" target="_blank" data-testid="providerLink"
@@ -112,6 +113,7 @@ export default {
<td
class="gl-display-flex gl-flex-sm-wrap gl-p-4 gl-pt-5 gl-vertical-align-top"
data-testid="fullPath"
+ data-qa-selector="project_path_content"
>
<template v-if="repo.importSource.target">{{ repo.importSource.target }}</template>
<template v-else-if="isImportNotStarted">
@@ -124,7 +126,8 @@ export default {
</div>
<gl-form-input
v-model="newNameInput"
- class="gl-rounded-top-left-none gl-rounded-bottom-left-none qa-project-path-field"
+ class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
+ data-qa-selector="project_path_field"
/>
</div>
</template>
@@ -140,12 +143,13 @@ export default {
:href="repo.importedProject.fullPath"
rel="noreferrer noopener"
target="_blank"
+ data-qa-selector="go_to_project_button"
>{{ __('Go to project') }}
</gl-button>
<gl-button
v-if="isImportNotStarted"
type="button"
- class="qa-import-button"
+ data-qa-selector="import_button"
@click="fetchImport(repo.importSource.id)"
>
{{ importButtonText }}
diff --git a/app/assets/javascripts/incidents_settings/constants.js b/app/assets/javascripts/incidents_settings/constants.js
index fcac9c519c2..577d8ecb777 100644
--- a/app/assets/javascripts/incidents_settings/constants.js
+++ b/app/assets/javascripts/incidents_settings/constants.js
@@ -40,7 +40,7 @@ export const I18N_ALERT_SETTINGS_FORM = {
label: __('Incident template (optional)'),
},
sendEmail: {
- label: __('Send a separate email notification to Developers.'),
+ label: __('Send a single email notification to Owners and Maintainers for new alerts.'),
},
autoCloseIncidents: {
label: __('Automatically close incidents when the associated Prometheus alert resolves.'),
@@ -51,7 +51,7 @@ export const NO_ISSUE_TEMPLATE_SELECTED = { key: '', name: __('No template selec
export const TAKING_INCIDENT_ACTION_DOCS_LINK =
'/help/operations/metrics/alerts#trigger-actions-from-alerts';
export const ISSUE_TEMPLATES_DOCS_LINK =
- '/help/user/project/description_templates#creating-issue-templates';
+ '/help/user/project/description_templates#create-an-issue-template';
/* PagerDuty integration settings constants */
diff --git a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
index 8677f139723..1e33ceb7835 100644
--- a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
+++ b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
@@ -38,7 +38,7 @@ export default {
<gl-form-checkbox
v-model="activated"
name="service[active]"
- class="gl-display-block gl-line-height-0"
+ class="gl-display-block"
:disabled="isInheriting"
@change="onChange"
>
diff --git a/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue
index bcf4b036fd2..89f7e3b7a89 100644
--- a/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue
+++ b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue
@@ -13,7 +13,7 @@ export default {
return {
text: __('Save'),
attributes: [
- { variant: 'success' },
+ { variant: 'confirm' },
{ category: 'primary' },
{ disabled: this.isDisabled },
],
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index 3ec0c23e55d..91f7c7dabf6 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -61,9 +61,6 @@ export default {
this.customState.integrationLevel === integrationLevels.GROUP
);
},
- showJiraIssuesFields() {
- return this.isJira && this.glFeatures.jiraIssuesIntegration;
- },
showReset() {
return this.isInstanceOrGroupLevel && this.propsSource.resetPath;
},
@@ -129,7 +126,7 @@ export default {
v-bind="field"
/>
<jira-issues-fields
- v-if="showJiraIssuesFields"
+ v-if="isJira && !isInstanceOrGroupLevel"
:key="`${currentKey}-jira-issues-fields`"
v-bind="propsSource.jiraIssuesProps"
/>
@@ -138,7 +135,7 @@ export default {
<gl-button
v-gl-modal.confirmSaveIntegration
category="primary"
- variant="success"
+ variant="confirm"
:loading="isSaving"
:disabled="isDisabled"
data-qa-selector="save_changes_button"
@@ -150,7 +147,7 @@ export default {
<gl-button
v-else
category="primary"
- variant="success"
+ variant="confirm"
type="submit"
:loading="isSaving"
:disabled="isDisabled"
@@ -162,6 +159,8 @@ export default {
<gl-button
v-if="propsSource.canTest"
+ category="secondary"
+ variant="confirm"
:loading="isTesting"
:disabled="isDisabled"
:href="propsSource.testPath"
@@ -174,7 +173,7 @@ export default {
<gl-button
v-gl-modal.confirmResetIntegration
category="secondary"
- variant="default"
+ variant="confirm"
:loading="isResetting"
:disabled="isDisabled"
data-testid="reset-button"
@@ -184,9 +183,7 @@ export default {
<reset-confirmation-modal @reset="onResetClick" />
</template>
- <gl-button class="btn-cancel" :href="propsSource.cancelPath">{{
- __('Cancel')
- }}</gl-button>
+ <gl-button :href="propsSource.cancelPath">{{ __('Cancel') }}</gl-button>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/invite_members/components/group_select.vue b/app/assets/javascripts/invite_members/components/group_select.vue
new file mode 100644
index 00000000000..4a72e97db8c
--- /dev/null
+++ b/app/assets/javascripts/invite_members/components/group_select.vue
@@ -0,0 +1,103 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlDropdownText, GlSearchBoxByType } from '@gitlab/ui';
+import { debounce } from 'lodash';
+import Api from '~/api';
+import { s__ } from '~/locale';
+import { SEARCH_DELAY } from '../constants';
+
+export default {
+ name: 'GroupSelect',
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlSearchBoxByType,
+ },
+ model: {
+ prop: 'selectedGroup',
+ },
+ data() {
+ return {
+ isFetching: false,
+ groups: [],
+ selectedGroup: {},
+ searchTerm: '',
+ };
+ },
+ computed: {
+ selectedGroupName() {
+ return this.selectedGroup.name || this.$options.i18n.dropdownText;
+ },
+ isFetchResultEmpty() {
+ return this.groups.length === 0;
+ },
+ },
+ watch: {
+ searchTerm() {
+ this.retrieveGroups();
+ },
+ },
+ mounted() {
+ this.retrieveGroups();
+ },
+ methods: {
+ retrieveGroups: debounce(function debouncedRetrieveGroups() {
+ this.isFetching = true;
+ return Api.groups(this.searchTerm, this.$options.defaultFetchOptions)
+ .then((response) => {
+ this.groups = response.map((group) => ({
+ id: group.id,
+ name: group.full_name,
+ path: group.path,
+ }));
+ this.isFetching = false;
+ })
+ .catch(() => {
+ this.isFetching = false;
+ });
+ }, SEARCH_DELAY),
+ selectGroup(group) {
+ this.selectedGroup = group;
+
+ this.$emit('input', this.selectedGroup);
+ },
+ },
+ i18n: {
+ dropdownText: s__('GroupSelect|Select a group'),
+ searchPlaceholder: s__('GroupSelect|Search groups'),
+ emptySearchResult: s__('GroupSelect|No matching results'),
+ },
+ defaultFetchOptions: {
+ exclude_internal: true,
+ active: true,
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-dropdown
+ data-testid="group-select-dropdown"
+ :text="selectedGroupName"
+ block
+ menu-class="gl-w-full!"
+ >
+ <gl-search-box-by-type
+ v-model.trim="searchTerm"
+ :is-loading="isFetching"
+ :placeholder="$options.i18n.searchPlaceholder"
+ data-qa-selector="group_select_dropdown_search_field"
+ />
+ <gl-dropdown-item
+ v-for="group in groups"
+ :key="group.id"
+ :name="group.name"
+ @click="selectGroup(group)"
+ >
+ {{ group.name }}
+ </gl-dropdown-item>
+ <gl-dropdown-text v-if="isFetchResultEmpty && !isFetching" data-testid="empty-result-message">
+ <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span>
+ </gl-dropdown-text>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/invite_members/components/invite_group_trigger.vue b/app/assets/javascripts/invite_members/components/invite_group_trigger.vue
new file mode 100644
index 00000000000..c9de078319a
--- /dev/null
+++ b/app/assets/javascripts/invite_members/components/invite_group_trigger.vue
@@ -0,0 +1,34 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import eventHub from '../event_hub';
+
+export default {
+ components: {
+ GlButton,
+ },
+ props: {
+ displayText: {
+ type: String,
+ required: false,
+ default: s__('InviteMembers|Invite a group'),
+ },
+ classes: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ methods: {
+ openModal() {
+ eventHub.$emit('openModal', { inviteeType: 'group' });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button :class="classes" data-qa-selector="invite_a_group_button" @click="openModal">
+ {{ displayText }}
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
index f5a65882fba..47f1405c980 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -11,9 +11,10 @@ import {
} from '@gitlab/ui';
import { partition, isString } from 'lodash';
import Api from '~/api';
+import GroupSelect from '~/invite_members/components/group_select.vue';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
-import { s__, __, sprintf } from '~/locale';
+import { s__, sprintf } from '~/locale';
import eventHub from '../event_hub';
export default {
@@ -28,6 +29,7 @@ export default {
GlButton,
GlFormInput,
MembersTokenSelect,
+ GroupSelect,
},
props: {
id: {
@@ -47,7 +49,7 @@ export default {
required: true,
},
defaultAccessLevel: {
- type: String,
+ type: Number,
required: true,
},
helpLink: {
@@ -60,21 +62,21 @@ export default {
visible: true,
modalId: 'invite-members-modal',
selectedAccessLevel: this.defaultAccessLevel,
+ inviteeType: 'members',
newUsersToInvite: [],
selectedDate: undefined,
+ groupToBeSharedWith: {},
};
},
computed: {
- inviteToName() {
- return this.name.toUpperCase();
- },
- inviteToType() {
- return this.isProject ? __('project') : __('group');
+ isInviteGroup() {
+ return this.inviteeType === 'group';
},
introText() {
- return sprintf(s__("InviteMembersModal|You're inviting members to the %{name} %{type}"), {
- name: this.inviteToName,
- type: this.inviteToType,
+ const inviteTo = this.isProject ? 'toProject' : 'toGroup';
+
+ return sprintf(this.$options.labels[this.inviteeType][inviteTo].introText, {
+ name: this.name,
});
},
toastOptions() {
@@ -82,12 +84,12 @@ export default {
onComplete: () => {
this.selectedAccessLevel = this.defaultAccessLevel;
this.newUsersToInvite = [];
+ this.groupToBeSharedWith = {};
},
};
},
basePostData() {
return {
- access_level: this.selectedAccessLevel,
expires_at: this.selectedDate,
format: 'json',
};
@@ -97,9 +99,16 @@ export default {
(key) => this.accessLevels[key] === Number(this.selectedAccessLevel),
);
},
+ inviteDisabled() {
+ return (
+ this.newUsersToInvite.length === 0 && Object.keys(this.groupToBeSharedWith).length === 0
+ );
+ },
},
mounted() {
- eventHub.$on('openModal', this.openModal);
+ eventHub.$on('openModal', (options) => {
+ this.openModal(options);
+ });
},
methods: {
partitionNewUsersToInvite() {
@@ -113,26 +122,42 @@ export default {
usersToAddById.map((user) => user.id).join(','),
];
},
- openModal() {
+ openModal({ inviteeType }) {
+ this.inviteeType = inviteeType;
+
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
closeModal() {
this.$root.$emit(BV_HIDE_MODAL, this.modalId);
},
sendInvite() {
- this.submitForm();
+ if (this.isInviteGroup) {
+ this.submitShareWithGroup();
+ } else {
+ this.submitInviteMembers();
+ }
this.closeModal();
},
cancelInvite() {
this.selectedAccessLevel = this.defaultAccessLevel;
this.selectedDate = undefined;
- this.newUsersToInvite = '';
+ this.newUsersToInvite = [];
+ this.groupToBeSharedWith = {};
this.closeModal();
},
changeSelectedItem(item) {
this.selectedAccessLevel = item;
},
- submitForm() {
+ submitShareWithGroup() {
+ const apiShareWithGroup = this.isProject
+ ? Api.projectShareWithGroup.bind(Api)
+ : Api.groupShareWithGroup.bind(Api);
+
+ apiShareWithGroup(this.id, this.shareWithGroupPostData(this.groupToBeSharedWith.id))
+ .then(this.showToastMessageSuccess)
+ .catch(this.showToastMessageError);
+ },
+ submitInviteMembers() {
const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
const promises = [];
@@ -155,10 +180,25 @@ export default {
Promise.all(promises).then(this.showToastMessageSuccess).catch(this.showToastMessageError);
},
inviteByEmailPostData(usersToInviteByEmail) {
- return { ...this.basePostData, email: usersToInviteByEmail };
+ return {
+ ...this.basePostData,
+ email: usersToInviteByEmail,
+ access_level: this.selectedAccessLevel,
+ };
},
addByUserIdPostData(usersToAddById) {
- return { ...this.basePostData, user_id: usersToAddById };
+ return {
+ ...this.basePostData,
+ user_id: usersToAddById,
+ access_level: this.selectedAccessLevel,
+ };
+ },
+ shareWithGroupPostData(groupToBeSharedWith) {
+ return {
+ ...this.basePostData,
+ group_id: groupToBeSharedWith,
+ group_access: this.selectedAccessLevel,
+ };
},
showToastMessageSuccess() {
this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
@@ -170,9 +210,36 @@ export default {
},
},
labels: {
- modalTitle: s__('InviteMembersModal|Invite team members'),
- newUsersToInvite: s__('InviteMembersModal|GitLab member or Email address'),
- userPlaceholder: s__('InviteMembersModal|Search for members to invite'),
+ members: {
+ modalTitle: s__('InviteMembersModal|Invite team members'),
+ searchField: s__('InviteMembersModal|GitLab member or Email address'),
+ placeHolder: s__('InviteMembersModal|Search for members to invite'),
+ toGroup: {
+ introText: s__(
+ "InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} group.",
+ ),
+ },
+ toProject: {
+ introText: s__(
+ "InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} project.",
+ ),
+ },
+ },
+ group: {
+ modalTitle: s__('InviteMembersModal|Invite a group'),
+ searchField: s__('InviteMembersModal|Select a group to invite'),
+ placeHolder: s__('InviteMembersModal|Search for a group to invite'),
+ toGroup: {
+ introText: s__(
+ "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} group.",
+ ),
+ },
+ toProject: {
+ introText: s__(
+ "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} project.",
+ ),
+ },
+ },
accessLevel: s__('InviteMembersModal|Choose a role permission'),
accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'),
toastMessageSuccessful: s__('InviteMembersModal|Members were successfully added'),
@@ -189,31 +256,45 @@ export default {
<gl-modal
:modal-id="modalId"
size="sm"
- :title="$options.labels.modalTitle"
+ data-qa-selector="invite_members_modal_content"
+ :title="$options.labels[inviteeType].modalTitle"
:header-close-label="$options.labels.headerCloseLabel"
>
- <div class="gl-ml-5 gl-mr-5">
- <div>{{ introText }}</div>
+ <div>
+ <p ref="introText">
+ <gl-sprintf :message="introText">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
<label :id="$options.membersTokenSelectLabelId" class="gl-font-weight-bold gl-mt-5">{{
- $options.labels.newUsersToInvite
+ $options.labels[inviteeType].searchField
}}</label>
<div class="gl-mt-2">
<members-token-select
+ v-if="!isInviteGroup"
v-model="newUsersToInvite"
- :label="$options.labels.newUsersToInvite"
:aria-labelledby="$options.membersTokenSelectLabelId"
- :placeholder="$options.labels.userPlaceholder"
+ :placeholder="$options.labels[inviteeType].placeHolder"
/>
+ <group-select v-if="isInviteGroup" v-model="groupToBeSharedWith" />
</div>
- <label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.accessLevel }}</label>
+ <label class="gl-font-weight-bold gl-mt-3">{{ $options.labels.accessLevel }}</label>
<div class="gl-mt-2 gl-w-half gl-xs-w-full">
- <gl-dropdown class="gl-shadow-none gl-w-full" v-bind="$attrs" :text="selectedRoleName">
+ <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)"
>
@@ -223,7 +304,7 @@ export default {
</gl-dropdown>
</div>
- <div class="gl-mt-2">
+ <div class="gl-mt-2 gl-w-half gl-xs-w-full">
<gl-sprintf :message="$options.labels.readMoreText">
<template #link="{ content }">
<gl-link :href="helpLink" target="_blank">{{ content }}</gl-link>
@@ -231,7 +312,7 @@ export default {
</gl-sprintf>
</div>
- <label class="gl-font-weight-bold gl-mt-5" for="expires_at">{{
+ <label class="gl-font-weight-bold gl-mt-5 gl-display-block" for="expires_at">{{
$options.labels.accessExpireDate
}}</label>
<div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block">
@@ -253,15 +334,16 @@ export default {
</div>
<template #modal-footer>
- <div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-p-3">
+ <div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0">
<gl-button ref="cancelButton" @click="cancelInvite">
{{ $options.labels.cancelButtonText }}
</gl-button>
<div class="gl-mr-3"></div>
<gl-button
ref="inviteButton"
- :disabled="!newUsersToInvite"
+ :disabled="inviteDisabled"
variant="success"
+ data-qa-selector="invite_button"
@click="sendInvite"
>{{ $options.labels.inviteButtonText }}</gl-button
>
diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
index eb97c458f88..666693e934f 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
@@ -1,13 +1,10 @@
<script>
-import { GlLink, GlIcon } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
export default {
- components: {
- GlLink,
- GlIcon,
- },
+ components: { GlButton },
props: {
displayText: {
type: String,
@@ -24,20 +21,28 @@ export default {
required: false,
default: '',
},
+ variant: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
},
methods: {
openModal() {
- eventHub.$emit('openModal');
+ eventHub.$emit('openModal', { inviteeType: 'members' });
},
},
};
</script>
<template>
- <gl-link :class="classes" @click="openModal">
- <div v-if="icon" class="nav-icon-container">
- <gl-icon :size="16" :name="icon" />
- </div>
- <span class="nav-item-name"> {{ displayText }} </span>
- </gl-link>
+ <gl-button
+ :class="classes"
+ :icon="icon"
+ :variant="variant"
+ data-qa-selector="invite_members_button"
+ @click="openModal"
+ >
+ {{ displayText }}
+ </gl-button>
</template>
diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue
index 233a214013b..db6a7888786 100644
--- a/app/assets/javascripts/invite_members/components/members_token_select.vue
+++ b/app/assets/javascripts/invite_members/components/members_token_select.vue
@@ -3,7 +3,7 @@ import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlSprintf } from '@gitlab/u
import { debounce } from 'lodash';
import { __ } from '~/locale';
import { getUsers } from '~/rest_api';
-import { USER_SEARCH_DELAY } from '../constants';
+import { SEARCH_DELAY } from '../constants';
export default {
components: {
@@ -67,7 +67,7 @@ export default {
.catch(() => {
this.loading = false;
});
- }, USER_SEARCH_DELAY),
+ }, SEARCH_DELAY),
handleInput() {
this.$emit('input', this.selectedTokens);
},
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index 1ff2125c292..2044dad896f 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -1 +1 @@
-export const USER_SEARCH_DELAY = 200;
+export const SEARCH_DELAY = 200;
diff --git a/app/assets/javascripts/invite_members/init_invite_group_trigger.js b/app/assets/javascripts/invite_members/init_invite_group_trigger.js
new file mode 100644
index 00000000000..c01bb1bae28
--- /dev/null
+++ b/app/assets/javascripts/invite_members/init_invite_group_trigger.js
@@ -0,0 +1,20 @@
+import Vue from 'vue';
+import InviteGroupTrigger from '~/invite_members/components/invite_group_trigger.vue';
+
+export default function initInviteGroupTrigger() {
+ const el = document.querySelector('.js-invite-group-trigger');
+
+ if (!el) {
+ return false;
+ }
+
+ return new Vue({
+ el,
+ render: (createElement) =>
+ createElement(InviteGroupTrigger, {
+ props: {
+ ...el.dataset,
+ },
+ }),
+ });
+}
diff --git a/app/assets/javascripts/invite_members/init_invite_members_form.js b/app/assets/javascripts/invite_members/init_invite_members_form.js
new file mode 100644
index 00000000000..5f8688755ba
--- /dev/null
+++ b/app/assets/javascripts/invite_members/init_invite_members_form.js
@@ -0,0 +1,7 @@
+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/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js
index 3de99dcc546..fc77bd53ba4 100644
--- a/app/assets/javascripts/invite_members/init_invite_members_modal.js
+++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js
@@ -20,6 +20,7 @@ export default function initInviteMembersModal() {
...el.dataset,
isProject: parseBoolean(el.dataset.isProject),
accessLevels: JSON.parse(el.dataset.accessLevels),
+ defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10),
},
}),
});
diff --git a/app/assets/javascripts/issuable/components/csv_export_modal.vue b/app/assets/javascripts/issuable/components/csv_export_modal.vue
new file mode 100644
index 00000000000..78987a5c629
--- /dev/null
+++ b/app/assets/javascripts/issuable/components/csv_export_modal.vue
@@ -0,0 +1,100 @@
+<script>
+import { GlButton, GlModal, GlSprintf, GlIcon } from '@gitlab/ui';
+import { ISSUABLE_TYPE } from '../constants';
+
+export default {
+ name: 'CsvExportModal',
+ components: {
+ GlButton,
+ GlModal,
+ GlSprintf,
+ GlIcon,
+ },
+ inject: {
+ issuableType: {
+ default: '',
+ },
+ issuableCount: {
+ default: 0,
+ },
+ email: {
+ default: '',
+ },
+ exportCsvPath: {
+ default: '',
+ },
+ },
+ props: {
+ modalId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ issuableName: this.issuableType === ISSUABLE_TYPE.issues ? 'issues' : 'merge requests',
+ };
+ },
+ issueableType: ISSUABLE_TYPE,
+};
+</script>
+
+<template>
+ <gl-modal :modal-id="modalId" body-class="gl-p-0!" data-qa-selector="export_issuable_modal">
+ <template #modal-title>
+ <gl-sprintf :message="__('Export %{name}')">
+ <template #name>{{ issuableName }}</template>
+ </gl-sprintf>
+ </template>
+ <div
+ v-if="issuableCount > -1"
+ data-testid="issuable-count-note"
+ class="gl-justify-content-start gl-align-items-center gl-p-4 gl-border-b-solid gl-border-1 gl-border-gray-50"
+ >
+ <gl-icon name="check" class="gl-color-green-400" />
+ <strong class="gl-m-3">
+ <gl-sprintf
+ v-if="issuableType === $options.issueableType.issues"
+ :message="n__('1 issue selected', '%d issues selected', issuableCount)"
+ >
+ <template #issuableCount>{{ issuableCount }}</template>
+ </gl-sprintf>
+ <gl-sprintf
+ v-else
+ :message="n__('1 merge request selected', '%d merge request selected', issuableCount)"
+ >
+ <template #issuableCount>{{ issuableCount }}</template>
+ </gl-sprintf>
+ </strong>
+ </div>
+ <div class="modal-text gl-px-4 gl-py-5">
+ <gl-sprintf
+ :message="
+ __(
+ `The CSV export will be created in the background. Once finished, it will be sent to %{strongStart}${email}%{strongEnd} in an attachment.`,
+ )
+ "
+ >
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </div>
+ <template #modal-footer>
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :href="exportCsvPath"
+ data-method="post"
+ :data-qa-selector="`export_${issuableType}_button`"
+ data-track-event="click_button"
+ :data-track-label="`export_${issuableType}_csv`"
+ >
+ <gl-sprintf :message="__('Export %{name}')">
+ <template #name>{{ issuableName }}</template>
+ </gl-sprintf>
+ </gl-button>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
new file mode 100644
index 00000000000..bbf4160ce35
--- /dev/null
+++ b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
@@ -0,0 +1,107 @@
+<script>
+import {
+ GlButtonGroup,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlTooltipDirective,
+ GlModalDirective,
+} from '@gitlab/ui';
+import { __ } from '~/locale';
+import { ISSUABLE_TYPE } from '../constants';
+import CsvExportModal from './csv_export_modal.vue';
+import CsvImportModal from './csv_import_modal.vue';
+
+export default {
+ name: 'CsvImportExportButtons',
+ components: {
+ GlButtonGroup,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ CsvExportModal,
+ CsvImportModal,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ GlModal: GlModalDirective,
+ },
+ inject: {
+ issuableType: {
+ default: ISSUABLE_TYPE.issues,
+ },
+ showExportButton: {
+ default: false,
+ },
+ showImportButton: {
+ default: false,
+ },
+ containerClass: {
+ default: '',
+ },
+ canEdit: {
+ default: false,
+ },
+ projectImportJiraPath: {
+ default: null,
+ },
+ showLabel: {
+ default: false,
+ },
+ },
+ computed: {
+ exportModalId() {
+ return `${this.issuableType}-export-modal`;
+ },
+ importModalId() {
+ return `${this.issuableType}-import-modal`;
+ },
+ importButtonText() {
+ return this.showLabel ? this.$options.importIssuesText : null;
+ },
+ importButtonTooltipText() {
+ return this.showLabel ? null : this.$options.importIssuesText;
+ },
+ importButtonIcon() {
+ return this.showLabel ? null : 'import';
+ },
+ },
+ importIssuesText: __('Import issues'),
+};
+</script>
+
+<template>
+ <div :class="containerClass">
+ <gl-button-group>
+ <gl-button
+ v-if="showExportButton"
+ v-gl-tooltip.hover="__('Export as CSV')"
+ v-gl-modal="exportModalId"
+ icon="export"
+ data-qa-selector="export_as_csv_button"
+ data-testid="export-csv-button"
+ />
+ <gl-dropdown
+ v-if="showImportButton"
+ v-gl-tooltip.hover="importButtonTooltipText"
+ data-qa-selector="import_issues_dropdown"
+ data-testid="import-csv-dropdown"
+ :text="importButtonText"
+ :icon="importButtonIcon"
+ >
+ <gl-dropdown-item v-gl-modal="importModalId" data-testid="import-csv-link">{{
+ __('Import CSV')
+ }}</gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="canEdit"
+ :href="projectImportJiraPath"
+ data-qa-selector="import_from_jira_link"
+ data-testid="import-from-jira-link"
+ >{{ __('Import from Jira') }}</gl-dropdown-item
+ >
+ </gl-dropdown>
+ </gl-button-group>
+ <csv-export-modal v-if="showExportButton" :modal-id="exportModalId" />
+ <csv-import-modal v-if="showImportButton" :modal-id="importModalId" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/issuable/components/csv_import_modal.vue b/app/assets/javascripts/issuable/components/csv_import_modal.vue
new file mode 100644
index 00000000000..77fc2f31583
--- /dev/null
+++ b/app/assets/javascripts/issuable/components/csv_import_modal.vue
@@ -0,0 +1,86 @@
+<script>
+import { GlModal, GlSprintf, GlFormGroup, GlButton } from '@gitlab/ui';
+import csrf from '~/lib/utils/csrf';
+import { ISSUABLE_TYPE } from '../constants';
+
+export default {
+ name: 'CsvImportModal',
+ components: {
+ GlModal,
+ GlSprintf,
+ GlFormGroup,
+ GlButton,
+ },
+ inject: {
+ issuableType: {
+ default: '',
+ },
+ exportCsvPath: {
+ default: '',
+ },
+ importCsvIssuesPath: {
+ default: '',
+ },
+ maxAttachmentSize: {
+ default: 0,
+ },
+ },
+ props: {
+ modalId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ issuableName: this.issuableType === ISSUABLE_TYPE.issues ? 'issues' : 'merge requests',
+ };
+ },
+ methods: {
+ submitForm() {
+ this.$refs.form.submit();
+ },
+ },
+ csrf,
+};
+</script>
+
+<template>
+ <gl-modal :modal-id="modalId" :title="__('Import issues')">
+ <form
+ ref="form"
+ :action="importCsvIssuesPath"
+ enctype="multipart/form-data"
+ method="post"
+ data-testid="import-csv-form"
+ >
+ <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+ <p>
+ {{
+ __(
+ "Your issues will be imported in the background. Once finished, you'll get a confirmation email.",
+ )
+ }}
+ </p>
+ <gl-form-group :label="__('Upload CSV file')" label-for="file">
+ <input id="file" type="file" name="file" accept=".csv,text/csv" />
+ </gl-form-group>
+ <p class="text-secondary">
+ {{
+ __(
+ 'It must have a header row and at least two columns: the first column is the issue title and the second column is the issue description. The separator is automatically detected.',
+ )
+ }}
+ <gl-sprintf :message="__('The maximum file size allowed is %{size}.')"
+ ><template #size>{{ maxAttachmentSize }}</template></gl-sprintf
+ >
+ </p>
+ </form>
+ <template #modal-footer>
+ <gl-button category="primary" variant="confirm" @click="submitForm">{{
+ __('Import issues')
+ }}</gl-button>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/issuable/constants.js b/app/assets/javascripts/issuable/constants.js
new file mode 100644
index 00000000000..9344f4a7c9a
--- /dev/null
+++ b/app/assets/javascripts/issuable/constants.js
@@ -0,0 +1,6 @@
+export const EVENT_ISSUABLE_VUE_APP_CHANGE = 'issuable_vue_app:change';
+
+export const ISSUABLE_TYPE = {
+ issues: 'issues',
+ mergeRequests: 'merge-requests',
+};
diff --git a/app/assets/javascripts/issuable/init_csv_import_export_buttons.js b/app/assets/javascripts/issuable/init_csv_import_export_buttons.js
new file mode 100644
index 00000000000..5a720b89d33
--- /dev/null
+++ b/app/assets/javascripts/issuable/init_csv_import_export_buttons.js
@@ -0,0 +1,45 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import ImportExportButtons from './components/csv_import_export_buttons.vue';
+
+export default () => {
+ const el = document.querySelector('.js-csv-import-export-buttons');
+
+ if (!el) return null;
+
+ const {
+ showExportButton,
+ showImportButton,
+ issuableType,
+ issuableCount,
+ email,
+ exportCsvPath,
+ importCsvIssuesPath,
+ containerClass,
+ canEdit,
+ projectImportJiraPath,
+ maxAttachmentSize,
+ showLabel,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ provide: {
+ showExportButton: parseBoolean(showExportButton),
+ showImportButton: parseBoolean(showImportButton),
+ issuableType,
+ issuableCount,
+ email,
+ exportCsvPath,
+ importCsvIssuesPath,
+ containerClass,
+ canEdit: parseBoolean(canEdit),
+ projectImportJiraPath,
+ maxAttachmentSize,
+ showLabel,
+ },
+ render(h) {
+ return h(ImportExportButtons);
+ },
+ });
+};
diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js
index 1b06dffbae7..153123a005f 100644
--- a/app/assets/javascripts/issuable_form.js
+++ b/app/assets/javascripts/issuable_form.js
@@ -5,6 +5,7 @@ import Autosave from './autosave';
import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select';
import { loadCSSFile } from './lib/utils/css_utils';
import { parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility';
+import { select2AxiosTransport } from './lib/utils/select2_utils';
import { queryToObject, objectToQuery } from './lib/utils/url_utility';
import UsersSelect from './users_select';
import ZenMode from './zen_mode';
@@ -199,15 +200,16 @@ export default class IssuableForm {
search,
};
},
- results(data) {
+ results({ results }) {
return {
// `data` keys are translated so we can't just access them with a string based key
- results: data[Object.keys(data)[0]].map((name) => ({
+ results: results[Object.keys(results)[0]].map((name) => ({
id: name,
text: name,
})),
};
},
+ transport: select2AxiosTransport,
},
initSelection(el, callback) {
const val = el.val();
diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue
index 39852eba71a..92c527c79ff 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_item.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue
@@ -5,7 +5,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { getTimeago } from '~/lib/utils/datetime_utility';
import { isExternal, setUrlFragment } from '~/lib/utils/url_utility';
-import { __, sprintf } from '~/locale';
+import { __, n__, sprintf } from '~/locale';
import IssuableAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
@@ -34,6 +34,11 @@ export default {
type: Boolean,
required: true,
},
+ labelFilterParam: {
+ type: String,
+ required: false,
+ default: 'label_name',
+ },
showCheckbox: {
type: Boolean,
required: true,
@@ -81,8 +86,26 @@ export default {
}
return {};
},
+ taskStatus() {
+ const { completedCount, count } = this.issuable.taskCompletionStatus || {};
+ if (!count) {
+ return undefined;
+ }
+
+ return sprintf(
+ n__(
+ '%{completedCount} of %{count} task completed',
+ '%{completedCount} of %{count} tasks completed',
+ count,
+ ),
+ { completedCount, count },
+ );
+ },
+ notesCount() {
+ return this.issuable.userDiscussionsCount ?? this.issuable.userNotesCount;
+ },
showDiscussions() {
- return typeof this.issuable.userDiscussionsCount === 'number';
+ return typeof this.notesCount === 'number';
},
showIssuableMeta() {
return Boolean(
@@ -105,9 +128,8 @@ export default {
},
labelTarget(label) {
if (this.enableLabelPermalinks) {
- const key = encodeURIComponent('label_name[]');
const value = encodeURIComponent(this.labelTitle(label));
- return `?${key}=${value}`;
+ return `?${this.labelFilterParam}[]=${value}`;
}
return '#';
},
@@ -144,19 +166,27 @@ export default {
v-gl-tooltip
name="eye-slash"
:title="__('Confidential')"
+ :aria-label="__('Confidential')"
/>
<gl-link :href="webUrl" v-bind="issuableTitleProps"
>{{ issuable.title
}}<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2"
/></gl-link>
</span>
+ <span
+ v-if="taskStatus"
+ class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-3"
+ data-testid="task-status"
+ >
+ {{ taskStatus }}
+ </span>
</div>
<div class="issuable-info">
<slot v-if="hasSlotContents('reference')" name="reference"></slot>
<span v-else data-testid="issuable-reference" class="issuable-reference"
>{{ issuableSymbol }}{{ issuable.iid }}</span
>
- <span class="issuable-authored d-none d-sm-inline-block">
+ <span class="issuable-authored gl-display-none gl-sm-display-inline-block! gl-mr-3">
&middot;
<span
v-gl-tooltip:tooltipcontainer.bottom
@@ -199,6 +229,16 @@ export default {
<li v-if="hasSlotContents('status')" class="issuable-status">
<slot name="status"></slot>
</li>
+ <li v-if="assignees.length" class="gl-display-flex">
+ <issuable-assignees
+ :assignees="assignees"
+ :icon-size="16"
+ :max-visible="4"
+ img-css-classes="gl-mr-2!"
+ class="gl-align-items-center gl-display-flex gl-ml-3"
+ />
+ </li>
+ <slot name="statistics"></slot>
<li
v-if="showDiscussions"
data-testid="issuable-discussions"
@@ -208,26 +248,17 @@ export default {
v-gl-tooltip:tooltipcontainer.top
:title="__('Comments')"
:href="issuableNotesLink"
- :class="{ 'no-comments': !issuable.userDiscussionsCount }"
+ :class="{ 'no-comments': !notesCount }"
class="gl-reset-color!"
>
<gl-icon name="comments" />
- {{ issuable.userDiscussionsCount }}
+ {{ notesCount }}
</gl-link>
</li>
- <li v-if="assignees.length" class="gl-display-flex">
- <issuable-assignees
- :assignees="issuable.assignees"
- :icon-size="16"
- :max-visible="4"
- img-css-classes="gl-mr-2!"
- class="gl-align-items-center gl-display-flex gl-ml-3"
- />
- </li>
</ul>
<div
data-testid="issuable-updated-at"
- class="float-right issuable-updated-at d-none d-sm-inline-block"
+ class="float-right issuable-updated-at gl-display-none gl-sm-display-inline-block"
>
<span
v-gl-tooltip:tooltipcontainer.bottom
diff --git a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
index 708e175cdb2..40b0fcbb8c6 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
@@ -122,6 +122,11 @@ export default {
required: false,
default: true,
},
+ labelFilterParam: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -180,7 +185,7 @@ export default {
handler(params) {
if (Object.keys(params).length) {
updateHistory({
- url: setUrlParams(params, window.location.href, true),
+ url: setUrlParams(params, window.location.href, true, false, true),
title: document.title,
replace: true,
});
@@ -258,6 +263,7 @@ export default {
:issuable-symbol="issuableSymbol"
:issuable="issuable"
:enable-label-permalinks="enableLabelPermalinks"
+ :label-filter-param="labelFilterParam"
:show-checkbox="showBulkEditSidebar"
:checked="issuableChecked(issuable)"
@checked-input="handleIssuableCheckedInput(issuable, $event)"
@@ -274,6 +280,9 @@ export default {
<template #status>
<slot name="status" :issuable="issuable"></slot>
</template>
+ <template #statistics>
+ <slot name="statistics" :issuable="issuable"></slot>
+ </template>
</issuable-item>
</ul>
<slot v-if="!issuablesLoading && !issuables.length" name="empty-state"></slot>
diff --git a/app/assets/javascripts/issuable_show/components/issuable_body.vue b/app/assets/javascripts/issuable_show/components/issuable_body.vue
index 02cf7a67727..fe102e942c9 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_body.vue
+++ b/app/assets/javascripts/issuable_show/components/issuable_body.vue
@@ -1,6 +1,8 @@
<script>
import { GlLink } from '@gitlab/ui';
+import TaskList from '~/task_list';
+
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import IssuableDescription from './issuable_description.vue';
@@ -40,6 +42,11 @@ export default {
type: Boolean,
required: true,
},
+ enableTaskList: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
editFormVisible: {
type: Boolean,
required: true,
@@ -56,6 +63,16 @@ export default {
type: String,
required: true,
},
+ taskListUpdatePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ taskListLockVersion: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
computed: {
isUpdated() {
@@ -65,7 +82,50 @@ export default {
return this.issuable.updatedBy;
},
},
+ watch: {
+ /**
+ * When user switches between view and edit modes,
+ * taskList instance becomes invalid so whenever
+ * view mode is rendered, we need to re-initialize
+ * taskList to ensure the behaviour functional.
+ */
+ editFormVisible(value) {
+ if (!value) {
+ this.$nextTick(() => {
+ this.initTaskList();
+ });
+ }
+ },
+ },
+ mounted() {
+ if (this.enableEdit && this.enableTaskList) {
+ this.initTaskList();
+ }
+ },
methods: {
+ initTaskList() {
+ this.taskList = new TaskList({
+ /**
+ * We have hard-coded dataType to `issue`
+ * as currently only `issue` types can handle
+ * task-lists, however, we can still use
+ * task lists in Issue, Test Cases and Incidents
+ * as all of those are derived from `issue`.
+ */
+ dataType: 'issue',
+ fieldName: 'description',
+ lockVersion: this.taskListLockVersion,
+ selector: '.js-detail-page-description',
+ onSuccess: this.handleTaskListUpdateSuccess.bind(this),
+ onError: this.handleTaskListUpdateFailure.bind(this),
+ });
+ },
+ handleTaskListUpdateSuccess(updatedIssuable) {
+ this.$emit('task-list-update-success', updatedIssuable);
+ },
+ handleTaskListUpdateFailure() {
+ this.$emit('task-list-update-failure');
+ },
handleKeydownTitle(e, issuableMeta) {
this.$emit('keydown-title', e, issuableMeta);
},
@@ -78,7 +138,7 @@ export default {
<template>
<div class="issue-details issuable-details">
- <div class="detail-page-description content-block">
+ <div class="detail-page-description js-detail-page-description content-block">
<issuable-edit-form
v-if="editFormVisible"
:issuable="issuable"
@@ -106,7 +166,13 @@ export default {
<slot name="status-badge"></slot>
</template>
</issuable-title>
- <issuable-description v-if="issuable.descriptionHtml" :issuable="issuable" />
+ <issuable-description
+ v-if="issuable.descriptionHtml"
+ :issuable="issuable"
+ :enable-task-list="enableTaskList"
+ :can-edit="enableEdit"
+ :task-list-update-path="taskListUpdatePath"
+ />
<small v-if="isUpdated" class="edited-text gl-font-sm!">
{{ __('Edited') }}
<time-ago-tooltip :time="issuable.updatedAt" tooltip-placement="bottom" />
diff --git a/app/assets/javascripts/issuable_show/components/issuable_description.vue b/app/assets/javascripts/issuable_show/components/issuable_description.vue
index aa7e530972f..f57b5b2deb4 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_description.vue
+++ b/app/assets/javascripts/issuable_show/components/issuable_description.vue
@@ -12,6 +12,18 @@ export default {
type: Object,
required: true,
},
+ enableTaskList: {
+ type: Boolean,
+ required: true,
+ },
+ canEdit: {
+ type: Boolean,
+ required: true,
+ },
+ taskListUpdatePath: {
+ type: String,
+ required: true,
+ },
},
mounted() {
this.renderGFM();
@@ -25,7 +37,16 @@ export default {
</script>
<template>
- <div class="description">
+ <div class="description" :class="{ 'js-task-list-container': canEdit && enableTaskList }">
<div ref="gfmContainer" v-safe-html="issuable.descriptionHtml" class="md"></div>
+ <textarea
+ v-if="issuable.description && enableTaskList"
+ ref="textarea"
+ :value="issuable.description"
+ :data-update-url="taskListUpdatePath"
+ class="gl-display-none js-task-list-field"
+ dir="auto"
+ >
+ </textarea>
</div>
</template>
diff --git a/app/assets/javascripts/issuable_show/components/issuable_discussion.vue b/app/assets/javascripts/issuable_show/components/issuable_discussion.vue
new file mode 100644
index 00000000000..5858af6cc51
--- /dev/null
+++ b/app/assets/javascripts/issuable_show/components/issuable_discussion.vue
@@ -0,0 +1,15 @@
+<script>
+export default {
+ name: 'IssuableDiscussion',
+};
+</script>
+
+<template>
+ <section class="issuable-discussion">
+ <div>
+ <ul class="notes main-notes-list timeline">
+ <slot name="discussion"></slot>
+ </ul>
+ </div>
+ </section>
+</template>
diff --git a/app/assets/javascripts/issuable_show/components/issuable_header.vue b/app/assets/javascripts/issuable_show/components/issuable_header.vue
index de17f7e7f6b..d7da533d055 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_header.vue
+++ b/app/assets/javascripts/issuable_show/components/issuable_header.vue
@@ -3,6 +3,7 @@ import { GlIcon, GlButton, GlTooltipDirective, GlAvatarLink, GlAvatarLabeled } f
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isExternal } from '~/lib/utils/url_utility';
+import { n__, sprintf } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
@@ -45,6 +46,11 @@ export default {
required: false,
default: false,
},
+ taskCompletionStatus: {
+ type: Object,
+ required: false,
+ default: null,
+ },
},
computed: {
authorId() {
@@ -53,6 +59,18 @@ export default {
isAuthorExternal() {
return isExternal(this.author.webUrl);
},
+ taskStatusString() {
+ const { count, completedCount } = this.taskCompletionStatus;
+
+ return sprintf(
+ n__(
+ '%{completedCount} of %{count} task completed',
+ '%{completedCount} of %{count} tasks completed',
+ count,
+ ),
+ { completedCount, count },
+ );
+ },
},
mounted() {
this.toggleSidebarButtonEl = document.querySelector('.js-toggle-right-sidebar-button');
@@ -74,8 +92,8 @@ export default {
<gl-icon v-if="statusIcon" :name="statusIcon" class="d-block d-sm-none" />
<span class="d-none d-sm-block"><slot name="status-badge"></slot></span>
</div>
- <div class="issuable-meta gl-display-flex gl-align-items-center">
- <div class="gl-display-inline-block">
+ <div class="issuable-meta gl-display-flex gl-align-items-center d-md-inline-block">
+ <div v-if="blocked || confidential" class="gl-display-inline-block">
<div v-if="blocked" data-testid="blocked" class="issuable-warning-icon inline">
<gl-icon name="lock" :aria-label="__('Blocked')" />
</div>
@@ -95,13 +113,13 @@ export default {
:data-name="author.name"
:href="author.webUrl"
target="_blank"
- class="js-user-link gl-ml-2"
+ class="js-user-link gl-vertical-align-middle gl-ml-2"
>
<gl-avatar-labeled
:size="24"
:src="author.avatarUrl"
:label="author.name"
- class="d-none d-sm-inline-flex gl-ml-1"
+ class="d-none d-sm-inline-flex gl-mx-1"
>
<template #meta>
<gl-icon v-if="isAuthorExternal" name="external-link" />
@@ -109,6 +127,12 @@ export default {
</gl-avatar-labeled>
<strong class="author d-sm-none d-inline">@{{ author.username }}</strong>
</gl-avatar-link>
+ <span
+ v-if="taskCompletionStatus"
+ data-testid="task-status"
+ class="gl-display-none gl-md-display-block gl-lg-display-inline-block"
+ >{{ taskStatusString }}</span
+ >
</div>
<gl-button
data-testid="sidebar-toggle"
diff --git a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue b/app/assets/javascripts/issuable_show/components/issuable_show_root.vue
index 240f35b74c8..b514a6b01d8 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue
+++ b/app/assets/javascripts/issuable_show/components/issuable_show_root.vue
@@ -2,6 +2,7 @@
import IssuableSidebar from '~/issuable_sidebar/components/issuable_sidebar_root.vue';
import IssuableBody from './issuable_body.vue';
+import IssuableDiscussion from './issuable_discussion.vue';
import IssuableHeader from './issuable_header.vue';
export default {
@@ -9,6 +10,7 @@ export default {
IssuableSidebar,
IssuableHeader,
IssuableBody,
+ IssuableDiscussion,
},
props: {
issuable: {
@@ -40,6 +42,11 @@ export default {
required: false,
default: true,
},
+ enableTaskList: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
editFormVisible: {
type: Boolean,
required: false,
@@ -60,6 +67,21 @@ export default {
required: false,
default: '',
},
+ taskCompletionStatus: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ taskListUpdatePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ taskListLockVersion: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
methods: {
handleKeydownTitle(e, issuableMeta) {
@@ -81,6 +103,7 @@ export default {
:confidential="issuable.confidential"
:created-at="issuable.createdAt"
:author="issuable.author"
+ :task-completion-status="taskCompletionStatus"
>
<template #status-badge>
<slot name="status-badge"></slot>
@@ -89,6 +112,7 @@ export default {
<slot name="header-actions"></slot>
</template>
</issuable-header>
+
<issuable-body
:issuable="issuable"
:status-badge-class="statusBadgeClass"
@@ -96,11 +120,16 @@ export default {
:enable-edit="enableEdit"
:enable-autocomplete="enableAutocomplete"
:enable-autosave="enableAutosave"
+ :enable-task-list="enableTaskList"
:edit-form-visible="editFormVisible"
:show-field-title="showFieldTitle"
:description-preview-path="descriptionPreviewPath"
:description-help-path="descriptionHelpPath"
+ :task-list-update-path="taskListUpdatePath"
+ :task-list-lock-version="taskListLockVersion"
@edit-issuable="$emit('edit-issuable', $event)"
+ @task-list-update-success="$emit('task-list-update-success', $event)"
+ @task-list-update-failure="$emit('task-list-update-failure')"
@keydown-title="handleKeydownTitle"
@keydown-description="handleKeydownDescription"
>
@@ -111,6 +140,13 @@ export default {
<slot name="edit-form-actions" v-bind="actionsProps"></slot>
</template>
</issuable-body>
+
+ <issuable-discussion>
+ <template #discussion>
+ <slot name="discussion"></slot>
+ </template>
+ </issuable-discussion>
+
<issuable-sidebar @sidebar-toggle="$emit('sidebar-toggle', $event)">
<template #right-sidebar-items="sidebarProps">
<slot name="right-sidebar-items" v-bind="sidebarProps"></slot>
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index 3f25682ab8b..f6eff8133a7 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import { joinPaths } from '~/lib/utils/url_utility';
import CreateMergeRequestDropdown from './create_merge_request_dropdown';
import { deprecatedCreateFlash as flash } from './flash';
+import { EVENT_ISSUABLE_VUE_APP_CHANGE } from './issuable/constants';
import axios from './lib/utils/axios_utils';
import { addDelimiter } from './lib/utils/text_utility';
import { __ } from './locale';
@@ -23,9 +24,13 @@ export default class Issue {
}
// Listen to state changes in the Vue app
- document.addEventListener('issuable_vue_app:change', (event) => {
+ this.issuableVueAppChangeHandler = (event) =>
this.updateTopState(event.detail.isClosed, event.detail.data);
- });
+ document.addEventListener(EVENT_ISSUABLE_VUE_APP_CHANGE, this.issuableVueAppChangeHandler);
+ }
+
+ dispose() {
+ document.removeEventListener(EVENT_ISSUABLE_VUE_APP_CHANGE, this.issuableVueAppChangeHandler);
}
/**
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index e70c18040b3..9b978483cc6 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -5,7 +5,6 @@ import { deprecatedCreateFlash as createFlash } from '~/flash';
import Poll from '~/lib/utils/poll';
import { visitUrl } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale';
-import recaptchaModalImplementor from '~/vue_shared/mixins/recaptcha_modal_implementor';
import { IssuableStatus, IssuableStatusText, IssuableType } from '../constants';
import eventHub from '../event_hub';
import Service from '../services/index';
@@ -25,7 +24,6 @@ export default {
formComponent,
PinnedLinks,
},
- mixins: [recaptchaModalImplementor],
props: {
endpoint: {
required: true,
@@ -250,6 +248,7 @@ export default {
},
},
created() {
+ this.flashContainer = null;
this.service = new Service(this.endpoint);
this.poll = new Poll({
resource: this.service,
@@ -289,7 +288,7 @@ export default {
methods: {
handleBeforeUnloadEvent(e) {
const event = e;
- if (this.showForm && this.issueChanged && !this.showRecaptcha) {
+ if (this.showForm && this.issueChanged) {
event.returnValue = __('Are you sure you want to lose your issue information?');
}
return undefined;
@@ -307,7 +306,7 @@ export default {
});
},
- updateAndShowForm(templates = []) {
+ updateAndShowForm(templates = {}) {
if (!this.showForm) {
this.showForm = true;
this.store.setFormState({
@@ -347,10 +346,10 @@ export default {
},
updateIssuable() {
+ this.clearFlash();
return this.service
.updateIssuable(this.store.formState)
.then((res) => res.data)
- .then((data) => this.checkForSpam(data))
.then((data) => {
if (!window.location.pathname.includes(data.web_url)) {
visitUrl(data.web_url);
@@ -361,28 +360,22 @@ export default {
eventHub.$emit('close.form');
})
.catch((error = {}) => {
- const { name, response = {} } = error;
+ const { message, response = {} } = error;
- if (name === 'SpamError') {
- this.openRecaptcha();
- } else {
- let errMsg = this.defaultErrorMessage;
+ this.store.setFormState({
+ updateLoading: false,
+ });
- if (response.data && response.data.errors) {
- errMsg += `. ${response.data.errors.join(' ')}`;
- }
+ let errMsg = this.defaultErrorMessage;
- createFlash(errMsg);
+ if (response.data && response.data.errors) {
+ errMsg += `. ${response.data.errors.join(' ')}`;
+ } else if (message) {
+ errMsg += `. ${message}`;
}
- });
- },
-
- closeRecaptchaModal() {
- this.store.setFormState({
- updateLoading: false,
- });
- this.closeRecaptcha();
+ this.flashContainer = createFlash(errMsg);
+ });
},
deleteIssuable(payload) {
@@ -409,6 +402,13 @@ export default {
showStickyHeader() {
this.isStickyHeaderShowing = true;
},
+
+ clearFlash() {
+ if (this.flashContainer) {
+ this.flashContainer.style.display = 'none';
+ this.flashContainer = null;
+ }
+ },
},
};
</script>
@@ -430,13 +430,6 @@ export default {
:enable-autocomplete="enableAutocomplete"
:issuable-type="issuableType"
/>
-
- <recaptcha-modal
- v-show="showRecaptcha"
- ref="recaptchaModal"
- :html="recaptchaHTML"
- @close="closeRecaptchaModal"
- />
</div>
<div v-else>
<title-component
diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue
index 5416d3bebd0..68bc6fe4c0e 100644
--- a/app/assets/javascripts/issue_show/components/description.vue
+++ b/app/assets/javascripts/issue_show/components/description.vue
@@ -4,7 +4,6 @@ import $ from 'jquery';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { s__, sprintf } from '~/locale';
import TaskList from '../../task_list';
-import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor';
import animateMixin from '../mixins/animate';
export default {
@@ -12,7 +11,7 @@ export default {
SafeHtml,
},
- mixins: [animateMixin, recaptchaModalImplementor],
+ mixins: [animateMixin],
props: {
canUpdate: {
@@ -87,21 +86,11 @@ export default {
fieldName: 'description',
lockVersion: this.lockVersion,
selector: '.detail-page-description',
- onSuccess: this.taskListUpdateSuccess.bind(this),
onError: this.taskListUpdateError.bind(this),
});
}
},
- taskListUpdateSuccess(data) {
- try {
- this.checkForSpam(data);
- this.closeRecaptcha();
- } catch (error) {
- if (error && error.name === 'SpamError') this.openRecaptcha();
- }
- },
-
taskListUpdateError() {
createFlash(
sprintf(
@@ -165,7 +154,5 @@ export default {
>
</textarea>
<!-- eslint-enable vue/no-mutating-props -->
-
- <recaptcha-modal v-show="showRecaptcha" :html="recaptchaHTML" @close="closeRecaptcha" />
</div>
</template>
diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue
index dd378c40b46..20c759cfbbd 100644
--- a/app/assets/javascripts/issue_show/components/edit_actions.vue
+++ b/app/assets/javascripts/issue_show/components/edit_actions.vue
@@ -45,15 +45,23 @@ export default {
shouldShowDeleteButton() {
return this.canDestroy && this.showDeleteButton;
},
+ deleteIssuableButtonText() {
+ return sprintf(__('Delete %{issuableType}'), {
+ issuableType: issuableTypes[this.issuableType],
+ });
+ },
},
methods: {
closeForm() {
eventHub.$emit('close.form');
},
deleteIssuable() {
- const confirmMessage = sprintf(__('%{issuableType} will be removed! Are you sure?'), {
- issuableType: issuableTypes[this.issuableType],
- });
+ const confirmMessage =
+ this.issuableType === 'epic'
+ ? __('Delete this epic and all descendants?')
+ : sprintf(__('%{issuableType} will be removed! Are you sure?'), {
+ issuableType: issuableTypes[this.issuableType],
+ });
// eslint-disable-next-line no-alert
if (window.confirm(confirmMessage)) {
this.deleteLoading = true;
@@ -90,7 +98,7 @@ export default {
class="float-right gl-mr-3 qa-delete-button"
@click="deleteIssuable"
>
- {{ __('Delete') }}
+ {{ deleteIssuableButtonText }}
</gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue
index dbec6f15cab..570bc7df3cf 100644
--- a/app/assets/javascripts/issue_show/components/fields/description_template.vue
+++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue
@@ -13,9 +13,9 @@ export default {
required: true,
},
issuableTemplates: {
- type: Array,
+ type: [Object, Array],
required: false,
- default: () => [],
+ default: () => {},
},
projectPath: {
type: String,
diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue
index b7425448052..76ea489fb86 100644
--- a/app/assets/javascripts/issue_show/components/form.vue
+++ b/app/assets/javascripts/issue_show/components/form.vue
@@ -26,9 +26,9 @@ export default {
required: true,
},
issuableTemplates: {
- type: Array,
+ type: [Object, Array],
required: false,
- default: () => [],
+ default: () => {},
},
issuableType: {
type: String,
@@ -72,7 +72,7 @@ export default {
},
computed: {
hasIssuableTemplates() {
- return this.issuableTemplates.length;
+ return Object.values(Object(this.issuableTemplates)).length;
},
showLockedWarning() {
return this.formState.lockedWarningVisible && !this.formState.updateLoading;
diff --git a/app/assets/javascripts/issue_show/components/header_actions.vue b/app/assets/javascripts/issue_show/components/header_actions.vue
index 9c3988d0469..2f2c4c6e341 100644
--- a/app/assets/javascripts/issue_show/components/header_actions.vue
+++ b/app/assets/javascripts/issue_show/components/header_actions.vue
@@ -2,6 +2,7 @@
import { GlButton, GlDropdown, GlDropdownItem, GlIcon, GlLink, GlModal } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import createFlash, { FLASH_TYPES } from '~/flash';
+import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
import { IssuableType } from '~/issuable_show/constants';
import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
@@ -148,7 +149,7 @@ export default {
};
// Dispatch event which updates open/close state, shared among the issue show page
- document.dispatchEvent(new CustomEvent('issuable_vue_app:change', payload));
+ document.dispatchEvent(new CustomEvent(EVENT_ISSUABLE_VUE_APP_CHANGE, payload));
})
.catch(() => createFlash({ message: __('Error occurred while updating the issue status') }))
.finally(() => {
diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issue_show/services/index.js
index b1deeaae0fc..08b04ebfdaf 100644
--- a/app/assets/javascripts/issue_show/services/index.js
+++ b/app/assets/javascripts/issue_show/services/index.js
@@ -1,9 +1,11 @@
+import { registerCaptchaModalInterceptor } from '~/captcha/captcha_modal_axios_interceptor';
import axios from '../../lib/utils/axios_utils';
export default class Service {
constructor(endpoint) {
this.endpoint = `${endpoint}.json`;
this.realtimeEndpoint = `${endpoint}/realtime_changes`;
+ registerCaptchaModalInterceptor(axios);
}
getData() {
diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js
index 06bbd406e3a..a50913d3455 100644
--- a/app/assets/javascripts/issue_show/stores/index.js
+++ b/app/assets/javascripts/issue_show/stores/index.js
@@ -11,7 +11,7 @@ export default class Store {
lockedWarningVisible: false,
updateLoading: false,
lock_version: 0,
- issuableTemplates: [],
+ issuableTemplates: {},
};
}
diff --git a/app/assets/javascripts/issue_show/utils/parse_data.js b/app/assets/javascripts/issue_show/utils/parse_data.js
index 19d1e0eebcb..f1e6bd2419a 100644
--- a/app/assets/javascripts/issue_show/utils/parse_data.js
+++ b/app/assets/javascripts/issue_show/utils/parse_data.js
@@ -1,5 +1,5 @@
+import * as Sentry from '@sentry/browser';
import { sanitize } from '~/lib/dompurify';
-import * as Sentry from '~/sentry/wrapper';
// We currently load + parse the data from the issue app and related merge request
let cachedParsedData;
diff --git a/app/assets/javascripts/issues_list/components/issuable.vue b/app/assets/javascripts/issues_list/components/issuable.vue
index b7af6e098e1..60b01a6d37f 100644
--- a/app/assets/javascripts/issues_list/components/issuable.vue
+++ b/app/assets/javascripts/issues_list/components/issuable.vue
@@ -25,16 +25,16 @@ import {
newDateAsLocaleTime,
} from '~/lib/utils/datetime_utility';
import { convertToCamelCase } from '~/lib/utils/text_utility';
-import { mergeUrlParams } from '~/lib/utils/url_utility';
+import { mergeUrlParams, setUrlFragment, isExternal } from '~/lib/utils/url_utility';
import { sprintf, __ } from '~/locale';
import initUserPopovers from '~/user_popovers';
import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
export default {
i18n: {
- openedAgo: __('opened %{timeAgoString} by %{user}'),
- openedAgoJira: __('opened %{timeAgoString} by %{user} in Jira'),
- openedAgoServiceDesk: __('opened %{timeAgoString} by %{email} via %{user}'),
+ openedAgo: __('created %{timeAgoString} by %{user}'),
+ openedAgoJira: __('created %{timeAgoString} by %{user} in Jira'),
+ openedAgoServiceDesk: __('created %{timeAgoString} by %{email} via %{user}'),
},
components: {
IssueAssignees,
@@ -102,8 +102,14 @@ export default {
isJiraIssue() {
return this.issuable.external_tracker === 'jira';
},
+ webUrl() {
+ return this.issuable.gitlab_web_url || this.issuable.web_url;
+ },
+ isIssuableUrlExternal() {
+ return isExternal(this.webUrl);
+ },
linkTarget() {
- return this.isJiraIssue ? '_blank' : null;
+ return this.isIssuableUrlExternal ? '_blank' : null;
},
issueCreatedToday() {
return getDayDifference(new Date(this.issuable.created_at), new Date()) < 1;
@@ -188,7 +194,7 @@ export default {
value: this.issuable.blocking_issues_count,
title: __('Blocking issues'),
dataTestId: 'blocking-issues',
- href: `${this.issuable.web_url}#related-issues`,
+ href: setUrlFragment(this.webUrl, 'related-issues'),
icon: 'issue-block',
},
{
@@ -197,7 +203,7 @@ export default {
value: this.issuable.user_notes_count,
title: __('Comments'),
dataTestId: 'notes-count',
- href: `${this.issuable.web_url}#notes`,
+ href: setUrlFragment(this.webUrl, 'notes'),
class: { 'no-comments': !this.issuable.user_notes_count, 'issuable-comments': true },
icon: 'comments',
},
@@ -252,7 +258,7 @@ export default {
:class="{ today: issueCreatedToday, closed: isClosed }"
:data-id="issuable.id"
:data-labels="labelIdsString"
- :data-url="issuable.web_url"
+ :data-url="webUrl"
data-qa-selector="issue_container"
:data-qa-issue-title="issuable.title"
>
@@ -284,13 +290,14 @@ export default {
:aria-label="$options.confidentialTooltipText"
/>
<gl-link
- :href="issuable.web_url"
+ :href="webUrl"
:target="linkTarget"
data-testid="issuable-title"
data-qa-selector="issue_link"
- >{{ issuable.title
- }}<gl-icon
- v-if="isJiraIssue"
+ >
+ {{ issuable.title }}
+ <gl-icon
+ v-if="isIssuableUrlExternal"
name="external-link"
class="gl-vertical-align-text-bottom gl-ml-2"
/>
diff --git a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue
new file mode 100644
index 00000000000..8d00d337bac
--- /dev/null
+++ b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue
@@ -0,0 +1,124 @@
+<script>
+import { GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import {
+ dateInWords,
+ getTimeRemainingInWords,
+ isInFuture,
+ isInPast,
+ isToday,
+} from '~/lib/utils/datetime_utility';
+import { convertToCamelCase } from '~/lib/utils/text_utility';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlLink,
+ GlIcon,
+ IssueHealthStatus: () =>
+ import('ee_component/related_items_tree/components/issue_health_status.vue'),
+ WeightCount: () => import('ee_component/issues/components/weight_count.vue'),
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: {
+ hasIssuableHealthStatusFeature: {
+ default: false,
+ },
+ },
+ props: {
+ issue: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ milestoneDate() {
+ if (this.issue.milestone?.dueDate) {
+ const { dueDate, startDate } = this.issue.milestone;
+ const date = dateInWords(new Date(dueDate), true);
+ const remainingTime = this.milestoneRemainingTime(dueDate, startDate);
+ return `${date} (${remainingTime})`;
+ }
+ return __('Milestone');
+ },
+ dueDate() {
+ return this.issue.dueDate && dateInWords(new Date(this.issue.dueDate), true);
+ },
+ isDueDateInPast() {
+ return isInPast(new Date(this.issue.dueDate));
+ },
+ timeEstimate() {
+ return this.issue.timeStats?.humanTimeEstimate;
+ },
+ showHealthStatus() {
+ return this.hasIssuableHealthStatusFeature && this.issue.healthStatus;
+ },
+ healthStatus() {
+ return convertToCamelCase(this.issue.healthStatus);
+ },
+ },
+ methods: {
+ milestoneRemainingTime(dueDate, startDate) {
+ const due = new Date(dueDate);
+ const start = new Date(startDate);
+
+ if (dueDate && isInPast(due)) {
+ return __('Past due');
+ } else if (dueDate && isToday(due)) {
+ return __('Today');
+ } else if (startDate && isInFuture(start)) {
+ return __('Upcoming');
+ } else if (dueDate) {
+ return getTimeRemainingInWords(due);
+ }
+ return '';
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <span
+ v-if="issue.milestone"
+ class="issuable-milestone gl-display-none gl-sm-display-inline-block! gl-mr-3"
+ data-testid="issuable-milestone"
+ >
+ <gl-link v-gl-tooltip :href="issue.milestone.webUrl" :title="milestoneDate">
+ <gl-icon name="clock" />
+ {{ issue.milestone.title }}
+ </gl-link>
+ </span>
+ <span
+ v-if="issue.dueDate"
+ v-gl-tooltip
+ class="issuable-due-date gl-display-none gl-sm-display-inline-block! gl-mr-3"
+ :class="{ 'gl-text-red-500': isDueDateInPast }"
+ :title="__('Due date')"
+ data-testid="issuable-due-date"
+ >
+ <gl-icon name="calendar" />
+ {{ dueDate }}
+ </span>
+ <span
+ v-if="timeEstimate"
+ v-gl-tooltip
+ class="gl-display-none gl-sm-display-inline-block! gl-mr-3"
+ :title="__('Estimate')"
+ data-testid="time-estimate"
+ >
+ <gl-icon name="timer" />
+ {{ timeEstimate }}
+ </span>
+ <weight-count
+ class="gl-display-none gl-sm-display-inline-block gl-mr-3"
+ :weight="issue.weight"
+ />
+ <issue-health-status
+ v-if="showHealthStatus"
+ class="gl-display-none gl-sm-display-inline-block"
+ :health-status="healthStatus"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/issues_list/components/issues_list_app.vue b/app/assets/javascripts/issues_list/components/issues_list_app.vue
new file mode 100644
index 00000000000..c57fa5a82fa
--- /dev/null
+++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue
@@ -0,0 +1,143 @@
+<script>
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { toNumber } from 'lodash';
+import createFlash from '~/flash';
+import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
+import { IssuableStatus } from '~/issue_show/constants';
+import { PAGE_SIZE } from '~/issues_list/constants';
+import axios from '~/lib/utils/axios_utils';
+import { convertObjectPropsToCamelCase, getParameterByName } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+import IssueCardTimeInfo from './issue_card_time_info.vue';
+
+export default {
+ PAGE_SIZE,
+ components: {
+ GlIcon,
+ IssuableList,
+ IssueCardTimeInfo,
+ BlockingIssuesCount: () => import('ee_component/issues/components/blocking_issues_count.vue'),
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: {
+ endpoint: {
+ default: '',
+ },
+ fullPath: {
+ default: '',
+ },
+ },
+ data() {
+ return {
+ currentPage: toNumber(getParameterByName('page')) || 1,
+ isLoading: false,
+ issues: [],
+ totalIssues: 0,
+ };
+ },
+ computed: {
+ urlParams() {
+ return {
+ page: this.currentPage,
+ state: IssuableStatus.Open,
+ };
+ },
+ },
+ mounted() {
+ this.fetchIssues();
+ },
+ methods: {
+ fetchIssues(pageToFetch) {
+ this.isLoading = true;
+
+ return axios
+ .get(this.endpoint, {
+ params: {
+ page: pageToFetch || this.currentPage,
+ per_page: this.$options.PAGE_SIZE,
+ state: IssuableStatus.Open,
+ with_labels_details: true,
+ },
+ })
+ .then(({ data, headers }) => {
+ this.currentPage = Number(headers['x-page']);
+ this.totalIssues = Number(headers['x-total']);
+ this.issues = data.map((issue) => convertObjectPropsToCamelCase(issue, { deep: true }));
+ })
+ .catch(() => {
+ createFlash({ message: __('An error occurred while loading issues') });
+ })
+ .finally(() => {
+ this.isLoading = false;
+ });
+ },
+ handlePageChange(page) {
+ this.fetchIssues(page);
+ },
+ },
+};
+</script>
+
+<template>
+ <issuable-list
+ :namespace="fullPath"
+ recent-searches-storage-key="issues"
+ :search-input-placeholder="__('Search or filter results…')"
+ :search-tokens="[]"
+ :sort-options="[]"
+ :issuables="issues"
+ :tabs="[]"
+ current-tab=""
+ :issuables-loading="isLoading"
+ :show-pagination-controls="true"
+ :total-items="totalIssues"
+ :current-page="currentPage"
+ :previous-page="currentPage - 1"
+ :next-page="currentPage + 1"
+ :url-params="urlParams"
+ @page-change="handlePageChange"
+ >
+ <template #timeframe="{ issuable = {} }">
+ <issue-card-time-info :issue="issuable" />
+ </template>
+ <template #statistics="{ issuable = {} }">
+ <li
+ v-if="issuable.mergeRequestsCount"
+ v-gl-tooltip
+ class="gl-display-none gl-sm-display-block"
+ :title="__('Related merge requests')"
+ data-testid="issuable-mr"
+ >
+ <gl-icon name="merge-request" />
+ {{ issuable.mergeRequestsCount }}
+ </li>
+ <li
+ v-if="issuable.upvotes"
+ v-gl-tooltip
+ class="gl-display-none gl-sm-display-block"
+ :title="__('Upvotes')"
+ data-testid="issuable-upvotes"
+ >
+ <gl-icon name="thumb-up" />
+ {{ issuable.upvotes }}
+ </li>
+ <li
+ v-if="issuable.downvotes"
+ v-gl-tooltip
+ class="gl-display-none gl-sm-display-block"
+ :title="__('Downvotes')"
+ data-testid="issuable-downvotes"
+ >
+ <gl-icon name="thumb-down" />
+ {{ issuable.downvotes }}
+ </li>
+ <blocking-issues-count
+ class="gl-display-none gl-sm-display-block"
+ :blocking-issues-count="issuable.blockingIssuesCount"
+ :is-list-item="true"
+ />
+ </template>
+ </issuable-list>
+</template>
diff --git a/app/assets/javascripts/issues_list/components/jira_issues_list_root.vue b/app/assets/javascripts/issues_list/components/jira_issues_import_status_app.vue
index 7396cfe27b3..ba0ca57523a 100644
--- a/app/assets/javascripts/issues_list/components/jira_issues_list_root.vue
+++ b/app/assets/javascripts/issues_list/components/jira_issues_import_status_app.vue
@@ -11,7 +11,7 @@ import { n__ } from '~/locale';
import getIssuesListDetailsQuery from '../queries/get_issues_list_details.query.graphql';
export default {
- name: 'JiraIssuesList',
+ name: 'JiraIssuesImportStatus',
components: {
GlAlert,
GlLabel,
@@ -89,13 +89,13 @@ export default {
</script>
<template>
- <div class="issuable-list-root">
+ <div class="gl-my-5">
<gl-alert v-if="jiraImport.shouldShowInProgressAlert" @dismiss="hideInProgressAlert">
{{ __('Import in progress. Refresh page to see newly added issues.') }}
</gl-alert>
<gl-alert
- v-if="jiraImport.shouldShowFinishedAlert"
+ v-else-if="jiraImport.shouldShowFinishedAlert"
variant="success"
@dismiss="hideFinishedAlert"
>
diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js
index 5c3910955bc..a283cbdc86b 100644
--- a/app/assets/javascripts/issues_list/index.js
+++ b/app/assets/javascripts/issues_list/index.js
@@ -1,12 +1,13 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import IssuesListApp from '~/issues_list/components/issues_list_app.vue';
import createDefaultClient from '~/lib/graphql';
-import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import IssuablesListApp from './components/issuables_list_app.vue';
-import JiraIssuesListRoot from './components/jira_issues_list_root.vue';
+import JiraIssuesImportStatusRoot from './components/jira_issues_import_status_app.vue';
function mountJiraIssuesListApp() {
- const el = document.querySelector('.js-projects-issues-root');
+ const el = document.querySelector('.js-jira-issues-import-status');
if (!el) {
return false;
@@ -23,7 +24,7 @@ function mountJiraIssuesListApp() {
el,
apolloProvider,
render(createComponent) {
- return createComponent(JiraIssuesListRoot, {
+ return createComponent(JiraIssuesImportStatusRoot, {
props: {
canEdit: parseBoolean(el.dataset.canEdit),
isJiraConfigured: parseBoolean(el.dataset.isJiraConfigured),
@@ -36,7 +37,7 @@ function mountJiraIssuesListApp() {
}
function mountIssuablesListApp() {
- if (!gon.features?.vueIssuablesList && !gon.features?.jiraIssuesIntegration) {
+ if (!gon.features?.vueIssuablesList) {
return;
}
@@ -64,6 +65,37 @@ function mountIssuablesListApp() {
});
}
+export function initIssuesListApp() {
+ const el = document.querySelector('.js-issues-list');
+
+ if (!el) {
+ return false;
+ }
+
+ const {
+ endpoint,
+ fullPath,
+ hasBlockedIssuesFeature,
+ hasIssuableHealthStatusFeature,
+ hasIssueWeightsFeature,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ // Currently does not use Vue Apollo, but need to provide {} for now until the
+ // issue is fixed upstream in https://github.com/vuejs/vue-apollo/pull/1153
+ apolloProvider: {},
+ provide: {
+ endpoint,
+ fullPath,
+ hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
+ hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
+ hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
+ },
+ render: (createComponent) => createComponent(IssuesListApp),
+ });
+}
+
export default function initIssuablesList() {
mountJiraIssuesListApp();
mountIssuablesListApp();
diff --git a/app/assets/javascripts/issues_list/service_desk_helper.js b/app/assets/javascripts/issues_list/service_desk_helper.js
index 0a34b754377..f96567ef53b 100644
--- a/app/assets/javascripts/issues_list/service_desk_helper.js
+++ b/app/assets/javascripts/issues_list/service_desk_helper.js
@@ -1,4 +1,4 @@
-import { __ } from '~/locale';
+import { s__ } from '~/locale';
/**
* Generates empty state messages for Service Desk issues list.
@@ -15,23 +15,23 @@ export function generateMessages(emptyStateMeta) {
incomingEmailHelpPage,
} = emptyStateMeta;
- const serviceDeskSupportedTitle = __(
- 'Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab',
+ const serviceDeskSupportedTitle = s__(
+ 'ServiceDesk|Use Service Desk to connect with your users and offer customer support through email right inside GitLab',
);
- const serviceDeskSupportedMessage = __(
- 'Those emails automatically become issues (with the comments becoming the email conversation) listed here.',
+ const serviceDeskSupportedMessage = s__(
+ 'ServiceDesk|Issues created from Service Desk emails appear here. Each comment becomes part of the email conversation.',
);
const commonDescription = `
<span>${serviceDeskSupportedMessage}</span>
- <a href="${serviceDeskHelpPage}">${__('Read more')}</a>`;
+ <a href="${serviceDeskHelpPage}">${s__('Learn more.')}</a>`;
return {
serviceDeskEnabledAndCanEditProjectSettings: {
title: serviceDeskSupportedTitle,
svgPath,
- description: `<p>${__('Have your users email')}
+ description: `<p>${s__('ServiceDesk|Your users can send emails to this address:')}
<code>${serviceDeskAddress}</code>
</p>
${commonDescription}`,
@@ -46,7 +46,7 @@ export function generateMessages(emptyStateMeta) {
svgPath,
description: commonDescription,
primaryLink: editProjectPage,
- primaryText: __('Turn on Service Desk'),
+ primaryText: s__('ServiceDesk|Enable Service Desk'),
},
serviceDeskDisabledAndCannotEditProjectSettings: {
title: serviceDeskSupportedTitle,
@@ -54,19 +54,19 @@ export function generateMessages(emptyStateMeta) {
description: commonDescription,
},
serviceDeskIsNotSupported: {
- title: __('Service Desk is not supported'),
+ title: s__('ServiceDesk|Service Desk is not supported'),
svgPath,
- description: __(
- 'In order to enable Service Desk for your instance, you must first set up incoming email.',
+ description: s__(
+ 'ServiceDesk|To enable Service Desk on this instance, an instance administrator must first set up incoming email.',
),
primaryLink: incomingEmailHelpPage,
- primaryText: __('More information'),
+ primaryText: s__('Learn more.'),
},
serviceDeskIsNotEnabled: {
- title: __('Service Desk is not enabled'),
+ title: s__('ServiceDesk|Service Desk is not enabled'),
svgPath,
- description: __(
- 'For help setting up the Service Desk for your instance, please contact an administrator.',
+ description: s__(
+ 'ServiceDesk|For help setting up the Service Desk for your instance, please contact an administrator.',
),
},
};
diff --git a/app/assets/javascripts/jira_connect/components/app.vue b/app/assets/javascripts/jira_connect/components/app.vue
index a4ba86dc6a1..fe5ad8b67d7 100644
--- a/app/assets/javascripts/jira_connect/components/app.vue
+++ b/app/assets/javascripts/jira_connect/components/app.vue
@@ -1,10 +1,11 @@
<script>
-import { GlAlert, GlButton, GlModal, GlModalDirective } from '@gitlab/ui';
-import { mapState } from 'vuex';
+import { GlAlert, GlButton, GlModal, GlModalDirective, GlLink, GlSprintf } from '@gitlab/ui';
+import { mapState, mapMutations } from 'vuex';
import { getLocation } from '~/jira_connect/api';
import { __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-
+import { SET_ALERT } from '../store/mutation_types';
+import { retrieveAlert } from '../utils';
import GroupsList from './groups_list.vue';
export default {
@@ -14,6 +15,8 @@ export default {
GlButton,
GlModal,
GroupsList,
+ GlLink,
+ GlSprintf,
},
directives: {
GlModalDirective,
@@ -30,10 +33,7 @@ export default {
};
},
computed: {
- ...mapState(['errorMessage']),
- showNewUI() {
- return this.glFeatures.newJiraConnectUi;
- },
+ ...mapState(['alert']),
usersPathWithReturnTo() {
if (this.location) {
return `${this.usersPath}?return_to=${this.location}`;
@@ -41,6 +41,9 @@ export default {
return this.usersPath;
},
+ shouldShowAlert() {
+ return Boolean(this.alert?.message);
+ },
},
modal: {
cancelProps: {
@@ -48,27 +51,48 @@ export default {
},
},
created() {
+ this.setInitialAlert();
this.setLocation();
},
methods: {
+ ...mapMutations({
+ setAlert: SET_ALERT,
+ }),
async setLocation() {
this.location = await getLocation();
},
+ setInitialAlert() {
+ const { linkUrl, title, message, variant } = retrieveAlert() || {};
+ this.setAlert({ linkUrl, title, message, variant });
+ },
},
};
</script>
<template>
<div>
- <gl-alert v-if="errorMessage" class="gl-mb-6" variant="danger" :dismissible="false">
- {{ errorMessage }}
+ <gl-alert
+ v-if="shouldShowAlert"
+ class="gl-mb-7"
+ :variant="alert.variant"
+ :title="alert.title"
+ @dismiss="setAlert"
+ >
+ <gl-sprintf v-if="alert.linkUrl" :message="alert.message">
+ <template #link="{ content }">
+ <gl-link :href="alert.linkUrl" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+
+ <template v-else>
+ {{ alert.message }}
+ </template>
</gl-alert>
- <h2>{{ s__('JiraService|GitLab for Jira Configuration') }}</h2>
+ <h2 class="gl-text-center">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2>
<div
- v-if="showNewUI"
- class="gl-display-flex gl-justify-content-space-between gl-my-7 gl-pb-4 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200"
+ class="jira-connect-app-body gl-display-flex gl-justify-content-space-between gl-my-7 gl-pb-4 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200"
>
<h5 class="gl-align-self-center gl-mb-0" data-testid="new-jira-connect-ui-heading">
{{ s__('Integrations|Linked namespaces') }}
diff --git a/app/assets/javascripts/jira_connect/components/groups_list_item.vue b/app/assets/javascripts/jira_connect/components/groups_list_item.vue
index 69b09ab0a21..b8959a2a505 100644
--- a/app/assets/javascripts/jira_connect/components/groups_list_item.vue
+++ b/app/assets/javascripts/jira_connect/components/groups_list_item.vue
@@ -1,7 +1,9 @@
<script>
import { GlAvatar, GlButton, GlIcon } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { addSubscription } from '~/jira_connect/api';
import { s__ } from '~/locale';
+import { persistAlert } from '../utils';
export default {
components: {
@@ -31,6 +33,15 @@ export default {
addSubscription(this.subscriptionsPath, this.group.full_path)
.then(() => {
+ persistAlert({
+ title: s__('Integrations|Namespace successfully linked'),
+ message: s__(
+ 'Integrations|You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}',
+ ),
+ linkUrl: helpPagePath('integration/jira_development_panel.html', { anchor: 'usage' }),
+ variant: 'success',
+ });
+
AP.navigator.reload();
})
.catch((error) => {
diff --git a/app/assets/javascripts/jira_connect/constants.js b/app/assets/javascripts/jira_connect/constants.js
index 2b3be5cd5cd..63b79581a1b 100644
--- a/app/assets/javascripts/jira_connect/constants.js
+++ b/app/assets/javascripts/jira_connect/constants.js
@@ -1 +1,2 @@
export const defaultPerPage = 10;
+export const ALERT_LOCALSTORAGE_KEY = 'gitlab_alert';
diff --git a/app/assets/javascripts/jira_connect/index.js b/app/assets/javascripts/jira_connect/index.js
index 7191fce3c33..ecdb41607a4 100644
--- a/app/assets/javascripts/jira_connect/index.js
+++ b/app/assets/javascripts/jira_connect/index.js
@@ -6,7 +6,7 @@ import Translate from '~/vue_shared/translate';
import JiraConnectApp from './components/app.vue';
import createStore from './store';
-import { SET_ERROR_MESSAGE } from './store/mutation_types';
+import { SET_ALERT } from './store/mutation_types';
const store = createStore();
@@ -17,7 +17,7 @@ const reqComplete = () => {
const reqFailed = (res, fallbackErrorMessage) => {
const { error = fallbackErrorMessage } = res || {};
- store.commit(SET_ERROR_MESSAGE, error);
+ store.commit(SET_ALERT, { message: error, variant: 'danger' });
};
const updateSignInLinks = async () => {
@@ -77,6 +77,7 @@ export async function initJiraConnect() {
Vue.use(GlFeatureFlagsPlugin);
const { groupsPath, subscriptionsPath, usersPath } = el.dataset;
+ AP.sizeToParent();
return new Vue({
el,
diff --git a/app/assets/javascripts/jira_connect/store/mutation_types.js b/app/assets/javascripts/jira_connect/store/mutation_types.js
index 7f6ff1256bb..15f36b824d9 100644
--- a/app/assets/javascripts/jira_connect/store/mutation_types.js
+++ b/app/assets/javascripts/jira_connect/store/mutation_types.js
@@ -1 +1 @@
-export const SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE';
+export const SET_ALERT = 'SET_ALERT';
diff --git a/app/assets/javascripts/jira_connect/store/mutations.js b/app/assets/javascripts/jira_connect/store/mutations.js
index c3acd07f89f..2a25e0fe25f 100644
--- a/app/assets/javascripts/jira_connect/store/mutations.js
+++ b/app/assets/javascripts/jira_connect/store/mutations.js
@@ -1,7 +1,7 @@
-import { SET_ERROR_MESSAGE } from './mutation_types';
+import { SET_ALERT } from './mutation_types';
export default {
- [SET_ERROR_MESSAGE](state, errorMessage) {
- state.errorMessage = errorMessage;
+ [SET_ALERT](state, { title, message, variant, linkUrl } = {}) {
+ state.alert = { title, message, variant, linkUrl };
},
};
diff --git a/app/assets/javascripts/jira_connect/store/state.js b/app/assets/javascripts/jira_connect/store/state.js
index 079b8350770..c807df03f00 100644
--- a/app/assets/javascripts/jira_connect/store/state.js
+++ b/app/assets/javascripts/jira_connect/store/state.js
@@ -1,3 +1,3 @@
export default () => ({
- errorMessage: undefined,
+ alert: undefined,
});
diff --git a/app/assets/javascripts/jira_connect/utils.js b/app/assets/javascripts/jira_connect/utils.js
new file mode 100644
index 00000000000..2a6c53ba42c
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/utils.js
@@ -0,0 +1,33 @@
+import AccessorUtilities from '~/lib/utils/accessor';
+import { ALERT_LOCALSTORAGE_KEY } from './constants';
+
+/**
+ * Persist alert data to localStorage.
+ */
+export const persistAlert = ({ title, message, linkUrl, variant } = {}) => {
+ if (!AccessorUtilities.isLocalStorageAccessSafe()) {
+ return;
+ }
+
+ const payload = JSON.stringify({ title, message, linkUrl, variant });
+ localStorage.setItem(ALERT_LOCALSTORAGE_KEY, payload);
+};
+
+/**
+ * Return alert data from localStorage.
+ */
+export const retrieveAlert = () => {
+ if (!AccessorUtilities.isLocalStorageAccessSafe()) {
+ return null;
+ }
+
+ const initialAlertJSON = localStorage.getItem(ALERT_LOCALSTORAGE_KEY);
+ // immediately clean up
+ localStorage.removeItem(ALERT_LOCALSTORAGE_KEY);
+
+ if (!initialAlertJSON) {
+ return null;
+ }
+
+ return JSON.parse(initialAlertJSON);
+};
diff --git a/app/assets/javascripts/jira_import/utils/cache_update.js b/app/assets/javascripts/jira_import/utils/cache_update.js
index db7dbb7353f..aa6c6a3ca23 100644
--- a/app/assets/javascripts/jira_import/utils/cache_update.js
+++ b/app/assets/javascripts/jira_import/utils/cache_update.js
@@ -21,8 +21,7 @@ export const addInProgressImportToStore = (store, jiraImportStart, fullPath) =>
store.writeQuery({
...queryDetails,
data: produce(sourceData, (draftData) => {
- draftData.project.jiraImportStatus = IMPORT_STATE.SCHEDULED; // eslint-disable-line no-param-reassign
- // eslint-disable-next-line no-param-reassign
+ draftData.project.jiraImportStatus = IMPORT_STATE.SCHEDULED;
draftData.project.jiraImports.nodes = [
...sourceData.project.jiraImports.nodes,
jiraImportStart.jiraImport,
diff --git a/app/assets/javascripts/jobs/components/artifacts_block.vue b/app/assets/javascripts/jobs/components/artifacts_block.vue
index 0f34926f689..2018942a7e8 100644
--- a/app/assets/javascripts/jobs/components/artifacts_block.vue
+++ b/app/assets/javascripts/jobs/components/artifacts_block.vue
@@ -37,7 +37,7 @@ export default {
};
</script>
<template>
- <div class="block">
+ <div>
<div class="title gl-font-weight-bold">{{ s__('Job|Job artifacts') }}</div>
<p
v-if="isExpired || willExpire"
diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue
index 222fae6d9a8..eae6b5d5419 100644
--- a/app/assets/javascripts/jobs/components/commit_block.vue
+++ b/app/assets/javascripts/jobs/components/commit_block.vue
@@ -18,20 +18,11 @@ export default {
required: false,
default: null,
},
- isLastBlock: {
- type: Boolean,
- required: true,
- },
},
};
</script>
<template>
- <div
- :class="{
- 'block-last': isLastBlock,
- block: !isLastBlock,
- }"
- >
+ <div>
<span class="font-weight-bold">{{ __('Commit') }}</span>
<gl-link :href="commit.commit_path" class="js-commit-sha commit-sha link-commit">
diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job_container_item.vue
index e68368919ab..488d838db52 100644
--- a/app/assets/javascripts/jobs/components/job_container_item.vue
+++ b/app/assets/javascripts/jobs/components/job_container_item.vue
@@ -63,7 +63,7 @@ export default {
<span class="text-truncate w-100">{{ job.name ? job.name : job.id }}</span>
- <gl-icon v-if="job.retried" name="retry" class="js-retry-icon" />
+ <gl-icon v-if="job.retried" name="retry" />
</gl-link>
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue
index fbdbfddff56..ce4a85b35b7 100644
--- a/app/assets/javascripts/jobs/components/job_log_controllers.vue
+++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue
@@ -1,9 +1,7 @@
<script>
-/* eslint-disable vue/no-v-html */
import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { __, sprintf } from '~/locale';
-import scrollDown from '../svg/scroll_down.svg';
export default {
components: {
@@ -13,7 +11,6 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- scrollDown,
props: {
erasePath: {
type: String,
@@ -87,7 +84,6 @@ export default {
v-gl-tooltip.body
:title="s__('Job|Show complete raw')"
:href="rawPath"
- class="controllers-buttons"
data-testid="job-raw-link-controller"
icon="doc-text"
/>
@@ -98,7 +94,7 @@ export default {
:title="s__('Job|Erase job log')"
:href="erasePath"
:data-confirm="__('Are you sure you want to erase this build?')"
- class="controllers-buttons"
+ class="gl-ml-3"
data-testid="job-log-erase-link"
data-method="post"
icon="remove"
@@ -106,25 +102,24 @@ export default {
<!-- eo links -->
<!-- scroll buttons -->
- <div v-gl-tooltip :title="s__('Job|Scroll to top')" class="controllers-buttons">
+ <div v-gl-tooltip :title="s__('Job|Scroll to top')" class="gl-ml-3">
<gl-button
:disabled="isScrollTopDisabled"
- class="btn-scroll btn-transparent btn-blank"
+ class="btn-scroll"
data-testid="job-controller-scroll-top"
icon="scroll_up"
@click="handleScrollToTop"
/>
</div>
- <div v-gl-tooltip :title="s__('Job|Scroll to bottom')" class="controllers-buttons">
+ <div v-gl-tooltip :title="s__('Job|Scroll to bottom')" class="gl-ml-3">
<gl-button
:disabled="isScrollBottomDisabled"
- class="js-scroll-bottom btn-scroll btn-transparent btn-blank"
+ class="js-scroll-bottom btn-scroll"
data-testid="job-controller-scroll-bottom"
icon="scroll_down"
:class="{ animate: isScrollingDown }"
@click="handleScrollToBottom"
- v-html="$options.scrollDown"
/>
</div>
<!-- eo 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 258b8cadd63..a43b3297d75 100644
--- a/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue
+++ b/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue
@@ -36,10 +36,10 @@ export default {
v-gl-modal="modalId"
:aria-label="$options.i18n.retryLabel"
category="primary"
- variant="info"
+ variant="confirm"
>{{ $options.i18n.retryLabel }}</gl-button
>
- <gl-link v-else :href="href" data-method="post" rel="nofollow"
+ <gl-link v-else :href="href" class="btn gl-button btn-confirm" data-method="post" rel="nofollow"
>{{ $options.i18n.retryLabel }}
</gl-link>
</template>
diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue
index f63fe72a71a..fcf03dff34e 100644
--- a/app/assets/javascripts/jobs/components/sidebar.vue
+++ b/app/assets/javascripts/jobs/components/sidebar.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlIcon, GlLink } from '@gitlab/ui';
+import { GlButton, GlIcon } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
@@ -20,12 +20,12 @@ export default {
i18n: {
...JOB_SIDEBAR,
},
+ borderTopClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100'],
forwardDeploymentFailureModalId,
components: {
ArtifactsBlock,
CommitBlock,
GlButton,
- GlLink,
GlIcon,
JobsContainer,
JobSidebarRetryButton,
@@ -45,11 +45,8 @@ export default {
computed: {
...mapGetters(['hasForwardDeploymentFailure']),
...mapState(['job', 'stages', 'jobs', 'selectedStage']),
- retryButtonClass() {
- let className = 'btn gl-button gl-text-decoration-none!';
- className +=
- this.job.status && this.job.recoverable ? ' btn-confirm' : ' btn-confirm-secondary';
- return className;
+ retryButtonCategory() {
+ return this.job.status && this.job.recoverable ? 'primary' : 'secondary';
},
hasArtifact() {
return !isEmpty(this.job.artifact);
@@ -76,71 +73,94 @@ export default {
<aside class="right-sidebar build-sidebar" data-offset-top="101" data-spy="affix">
<div class="sidebar-container">
<div class="blocks-container">
- <div class="block d-flex flex-nowrap align-items-center">
+ <div class="gl-py-5 gl-display-flex gl-align-items-center">
<tooltip-on-truncate :title="job.name" truncate-target="child"
><h4 class="my-0 mr-2 gl-text-truncate">
{{ job.name }}
</h4>
</tooltip-on-truncate>
- <div class="flex-grow-1 flex-shrink-0 text-right">
+ <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right">
<job-sidebar-retry-button
v-if="job.retry_path"
- :class="retryButtonClass"
+ :category="retryButtonCategory"
:href="job.retry_path"
:modal-id="$options.forwardDeploymentFailureModalId"
+ variant="confirm"
data-qa-selector="retry_button"
data-testid="retry-button"
/>
- <gl-link
+ <gl-button
v-if="job.cancel_path"
:href="job.cancel_path"
- class="btn gl-button btn-default gl-text-decoration-none!"
data-method="post"
data-testid="cancel-button"
rel="nofollow"
>{{ $options.i18n.cancel }}
- </gl-link>
+ </gl-button>
</div>
<gl-button
:aria-label="$options.i18n.toggleSidebar"
category="tertiary"
- class="gl-md-display-none gl-ml-2 js-sidebar-build-toggle"
+ class="gl-md-display-none gl-ml-2"
icon="chevron-double-lg-right"
@click="toggleSidebar"
/>
</div>
- <div v-if="job.terminal_path || job.new_issue_path" class="block retry-link">
- <gl-link
+ <div
+ v-if="job.terminal_path || job.new_issue_path"
+ class="gl-py-5"
+ :class="$options.borderTopClass"
+ >
+ <gl-button
v-if="job.new_issue_path"
:href="job.new_issue_path"
- class="btn gl-button btn-success-secondary float-left mr-2 gl-text-decoration-none!"
+ category="secondary"
+ variant="confirm"
data-testid="job-new-issue"
- >{{ $options.i18n.newIssue }}
- </gl-link>
- <gl-link
+ >
+ {{ $options.i18n.newIssue }}
+ </gl-button>
+ <gl-button
v-if="job.terminal_path"
:href="job.terminal_path"
- class="btn btn-primary btn-inverted visible-md-block visible-lg-block float-left"
target="_blank"
data-testid="terminal-link"
>
{{ $options.i18n.debug }}
- <gl-icon :size="14" name="external-link" />
- </gl-link>
+ <gl-icon name="external-link" />
+ </gl-button>
</div>
- <job-sidebar-details-container />
- <artifacts-block v-if="hasArtifact" :artifact="job.artifact" :help-url="artifactHelpUrl" />
- <trigger-block v-if="hasTriggers" :trigger="job.trigger" />
+
+ <job-sidebar-details-container class="gl-py-5" :class="$options.borderTopClass" />
+
+ <artifacts-block
+ v-if="hasArtifact"
+ class="gl-py-5"
+ :class="$options.borderTopClass"
+ :artifact="job.artifact"
+ :help-url="artifactHelpUrl"
+ />
+
+ <trigger-block
+ v-if="hasTriggers"
+ class="gl-py-5"
+ :class="$options.borderTopClass"
+ :trigger="job.trigger"
+ />
+
<commit-block
:commit="commit"
- :is-last-block="hasStages"
+ class="gl-py-5"
+ :class="$options.borderTopClass"
:merge-request="job.merge_request"
/>
<stages-dropdown
v-if="job.pipeline"
+ class="gl-py-5"
+ :class="$options.borderTopClass"
:pipeline="job.pipeline"
:selected-stage="selectedStage"
:stages="stages"
diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
index 62cd30fb320..b20d58b6ffe 100644
--- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
+++ b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
@@ -73,7 +73,7 @@ export default {
</script>
<template>
- <div v-if="shouldRenderBlock" class="block">
+ <div v-if="shouldRenderBlock">
<detail-row v-if="job.duration" :value="duration" title="Duration" />
<detail-row
v-if="job.finished_at"
diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue
index 64c4031b002..18de849af88 100644
--- a/app/assets/javascripts/jobs/components/stages_dropdown.vue
+++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue
@@ -43,7 +43,7 @@ export default {
};
</script>
<template>
- <div class="block-last dropdown">
+ <div class="dropdown">
<div class="js-pipeline-info">
<ci-icon :status="pipeline.details.status" class="vertical-align-middle" />
diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/trigger_block.vue
index f6b98777011..fef5b37015c 100644
--- a/app/assets/javascripts/jobs/components/trigger_block.vue
+++ b/app/assets/javascripts/jobs/components/trigger_block.vue
@@ -61,7 +61,7 @@ export default {
</script>
<template>
- <div class="block">
+ <div>
<p
v-if="trigger.short_token"
:class="{ 'gl-mb-2': hasVariables, 'gl-mb-0': !hasVariables }"
diff --git a/app/assets/javascripts/jobs/store/getters.js b/app/assets/javascripts/jobs/store/getters.js
index 930a225857d..6cb96bee07d 100644
--- a/app/assets/javascripts/jobs/store/getters.js
+++ b/app/assets/javascripts/jobs/store/getters.js
@@ -1,7 +1,7 @@
import { isEmpty, isString } from 'lodash';
import { isScrolledToBottom } from '~/lib/utils/scroll_utils';
-export const headerTime = (state) => state.job.started ?? state.job.created_at;
+export const headerTime = (state) => (state.job.started ? state.job.started : state.job.created_at);
export const hasForwardDeploymentFailure = (state) =>
state?.job?.failure_reason === 'forward_deployment_failure';
diff --git a/app/assets/javascripts/jobs/svg/scroll_down.svg b/app/assets/javascripts/jobs/svg/scroll_down.svg
deleted file mode 100644
index fb934f68704..00000000000
--- a/app/assets/javascripts/jobs/svg/scroll_down.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path class="scroll-arrow" d="M8 10.4142L4.29289 6.70711C3.90237 6.31658 3.90237 5.68342 4.29289 5.2929C4.68342 4.90237 5.31658 4.90237 5.70711 5.2929L7 6.58579L7 1C7 0.447715 7.44772 0 8 0C8.55229 0 9 0.447715 9 1L9 6.58579L10.2929 5.2929C10.6834 4.90237 11.3166 4.90237 11.7071 5.2929C12.0976 5.68342 12.0976 6.31658 11.7071 6.70711L8 10.4142Z"/>
-<path class="scroll-dot" d="M8 16C9.10457 16 10 15.1046 10 14C10 12.8954 9.10457 12 8 12C6.89543 12 6 12.8954 6 14C6 15.1046 6.89543 16 8 16Z"/>
-</svg>
diff --git a/app/assets/javascripts/lib/chrome_84_icon_fix.js b/app/assets/javascripts/lib/chrome_84_icon_fix.js
deleted file mode 100644
index 20fe9590ce3..00000000000
--- a/app/assets/javascripts/lib/chrome_84_icon_fix.js
+++ /dev/null
@@ -1,78 +0,0 @@
-import { debounce } from 'lodash';
-
-/*
- Chrome and Edge 84 have a bug relating to icon sprite svgs
- https://bugs.chromium.org/p/chromium/issues/detail?id=1107442
-
- If the SVG is loaded, under certain circumstances the icons are not
- shown. We load our sprite icons with JS and add them to the body.
- Then we iterate over all the `use` elements and replace their reference
- to that svg which we added internally. In order to avoid id conflicts,
- those are renamed with a unique prefix.
-
- We do that once the DOMContentLoaded fired and otherwise we use a
- mutation observer to re-trigger this logic.
-
- In order to not have a big impact on performance or to cause flickering
- of of content,
-
- 1. we only do it for each svg once
- 2. we debounce the event handler and just do it in a requestIdleCallback
-
- Before we tried to do it with the library svg4everybody and it had a big
- performance impact. See:
- https://gitlab.com/gitlab-org/quality/performance/-/issues/312
- */
-document.addEventListener('DOMContentLoaded', async () => {
- const GITLAB_SVG_PREFIX = 'chrome-issue-230433-gitlab-svgs';
- const FILE_ICON_PREFIX = 'chrome-issue-230433-file-icons';
- const SKIP_ATTRIBUTE = 'data-replaced-by-chrome-issue-230433';
-
- const fixSVGs = () => {
- requestIdleCallback(() => {
- document.querySelectorAll(`use:not([${SKIP_ATTRIBUTE}])`).forEach((use) => {
- const href = use?.getAttribute('href') ?? use?.getAttribute('xlink:href') ?? '';
-
- if (href.includes(window.gon.sprite_icons)) {
- use.removeAttribute('xlink:href');
- use.setAttribute('href', `#${GITLAB_SVG_PREFIX}-${href.split('#')[1]}`);
- } else if (href.includes(window.gon.sprite_file_icons)) {
- use.removeAttribute('xlink:href');
- use.setAttribute('href', `#${FILE_ICON_PREFIX}-${href.split('#')[1]}`);
- }
-
- use.setAttribute(SKIP_ATTRIBUTE, 'true');
- });
- });
- };
-
- const watchForNewSVGs = () => {
- const observer = new MutationObserver(debounce(fixSVGs, 200));
- observer.observe(document.querySelector('body'), {
- childList: true,
- attributes: false,
- subtree: true,
- });
- };
-
- const retrieveIconSprites = async (url, prefix) => {
- const div = document.createElement('div');
- div.classList.add('hidden');
- const result = await fetch(url);
- div.innerHTML = await result.text();
- div.querySelectorAll('[id]').forEach((node) => {
- node.setAttribute('id', `${prefix}-${node.getAttribute('id')}`);
- });
- document.body.append(div);
- };
-
- if (window.gon && window.gon.sprite_icons) {
- await retrieveIconSprites(window.gon.sprite_icons, GITLAB_SVG_PREFIX);
- if (window.gon.sprite_file_icons) {
- await retrieveIconSprites(window.gon.sprite_file_icons, FILE_ICON_PREFIX);
- }
-
- fixSVGs();
- watchForNewSVGs();
- }
-});
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index bda5550a9f4..e090f9f6e8c 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -2,6 +2,7 @@ import { InMemoryCache } from 'apollo-cache-inmemory';
import { ApolloClient } from 'apollo-client';
import { ApolloLink } from 'apollo-link';
import { BatchHttpLink } from 'apollo-link-batch-http';
+import { createHttpLink } from 'apollo-link-http';
import { createUploadLink } from 'apollo-upload-client';
import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link';
import csrf from '~/lib/utils/csrf';
@@ -48,7 +49,7 @@ export default (resolvers = {}, config = {}) => {
const uploadsLink = ApolloLink.split(
(operation) => operation.getContext().hasUpload || operation.getContext().isSingleRequest,
createUploadLink(httpOptions),
- new BatchHttpLink(httpOptions),
+ config.useGet ? createHttpLink(httpOptions) : new BatchHttpLink(httpOptions),
);
const performanceBarLink = new ApolloLink((operation, forward) => {
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index 38b3b26dc44..145b419f8f0 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -769,6 +769,19 @@ export const nMonthsAfter = (date, numberOfMonths, { utc = false } = {}) => {
};
/**
+ * Returns the date `n` years after the date provided.
+ *
+ * @param {Date} date the initial date
+ * @param {Number} numberOfYears number of years after
+ * @return {Date} A `Date` object `n` years after the provided `Date`
+ */
+export const nYearsAfter = (date, numberOfYears) => {
+ const clone = newDate(date);
+ clone.setFullYear(clone.getFullYear() + numberOfYears);
+ return clone;
+};
+
+/**
* Returns the date `n` months before the date provided
*
* @param {Date} date the initial date
@@ -993,6 +1006,78 @@ export const isToday = (date) => {
};
/**
+ * Checks whether the date is in the past.
+ *
+ * @param {Date} date
+ * @return {Boolean} Returns true if the date falls before today, otherwise false.
+ */
+export const isInPast = (date) => !isToday(date) && differenceInMilliseconds(date, Date.now()) > 0;
+
+/**
+ * Checks whether the date is in the future.
+ * .
+ * @param {Date} date
+ * @return {Boolean} Returns true if the date falls after today, otherwise false.
+ */
+export const isInFuture = (date) =>
+ !isToday(date) && differenceInMilliseconds(Date.now(), date) > 0;
+
+/**
+ * Checks whether dateA falls before dateB.
+ *
+ * @param {Date} dateA
+ * @param {Date} dateB
+ * @return {Boolean} Returns true if dateA falls before dateB, otherwise false
+ */
+export const fallsBefore = (dateA, dateB) => differenceInMilliseconds(dateA, dateB) > 0;
+
+/**
+ * Removes the time component of the date.
+ *
+ * @param {Date} date
+ * @return {Date} Returns a clone of the date with the time set to midnight
+ */
+export const removeTime = (date) => {
+ const clone = newDate(date);
+ clone.setHours(0, 0, 0, 0);
+ return clone;
+};
+
+/**
+ * Calculates the time remaining from today in words in the format
+ * `n days/weeks/months/years remaining`.
+ *
+ * @param {Date} date A date in future
+ * @return {String} The time remaining in the format `n days/weeks/months/years remaining`
+ */
+export const getTimeRemainingInWords = (date) => {
+ const today = removeTime(new Date());
+ const dateInFuture = removeTime(date);
+
+ const oneWeekFromNow = nWeeksAfter(today, 1);
+ const oneMonthFromNow = nMonthsAfter(today, 1);
+ const oneYearFromNow = nYearsAfter(today, 1);
+
+ if (fallsBefore(dateInFuture, oneWeekFromNow)) {
+ const days = getDayDifference(today, dateInFuture);
+ return n__('1 day remaining', '%d days remaining', days);
+ }
+
+ if (fallsBefore(dateInFuture, oneMonthFromNow)) {
+ const weeks = Math.floor(getDayDifference(today, dateInFuture) / 7);
+ return n__('1 week remaining', '%d weeks remaining', weeks);
+ }
+
+ if (fallsBefore(dateInFuture, oneYearFromNow)) {
+ const months = differenceInMonths(today, dateInFuture);
+ return n__('1 month remaining', '%d months remaining', months);
+ }
+
+ const years = dateInFuture.getFullYear() - today.getFullYear();
+ return n__('1 year remaining', '%d years remaining', years);
+};
+
+/**
* Returns the start of the provided day
*
* @param {Object} [options={}] Additional options for this calculation
@@ -1010,3 +1095,25 @@ export const getStartOfDay = (date, { utc = false } = {}) => {
return new Date(cloneValue);
};
+
+/**
+ * Returns the start of the current week against the provide date
+ *
+ * @param {Date} date The current date instance to calculate against
+ * @param {Object} [options={}] Additional options for this calculation
+ * @param {boolean} [options.utc=false] Performs the calculation using UTC time.
+ * If `true`, the time returned will be midnight UTC. If `false` (the default)
+ * the time returned will be midnight in the user's local time.
+ *
+ * @returns {Date} A new `Date` object that represents the start of the current week
+ * of the provided date
+ */
+export const getStartOfWeek = (date, { utc = false } = {}) => {
+ const cloneValue = utc
+ ? new Date(date.setUTCHours(0, 0, 0, 0))
+ : new Date(date.setHours(0, 0, 0, 0));
+
+ const diff = cloneValue.getDate() - cloneValue.getDay() + (cloneValue.getDay() === 0 ? -6 : 1);
+
+ return new Date(date.setDate(diff));
+};
diff --git a/app/assets/javascripts/lib/utils/experimentation.js b/app/assets/javascripts/lib/utils/experimentation.js
deleted file mode 100644
index 555e76055e0..00000000000
--- a/app/assets/javascripts/lib/utils/experimentation.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export function isExperimentEnabled(experimentKey) {
- return Boolean(window.gon?.experiments?.[experimentKey]);
-}
diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js
index 06529f06a66..6b9be34235b 100644
--- a/app/assets/javascripts/lib/utils/http_status.js
+++ b/app/assets/javascripts/lib/utils/http_status.js
@@ -19,6 +19,7 @@ const httpStatusCodes = {
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
+ METHOD_NOT_ALLOWED: 405,
CONFLICT: 409,
GONE: 410,
UNPROCESSABLE_ENTITY: 422,
diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js
index 0f29f538b07..63feb6f9b1d 100644
--- a/app/assets/javascripts/lib/utils/number_utils.js
+++ b/app/assets/javascripts/lib/utils/number_utils.js
@@ -150,3 +150,24 @@ export const formattedChangeInPercent = (firstY, lastY, { nonFiniteResult = '-'
return `${change >= 0 ? '+' : ''}${change}%`;
};
+
+/**
+ * Checks whether a value is numerical in nature by converting it using parseInt
+ *
+ * Example outcomes:
+ * - isNumeric(100) = true
+ * - isNumeric('100') = true
+ * - isNumeric(1.0) = true
+ * - isNumeric('1.0') = true
+ * - isNumeric('abc100') = false
+ * - isNumeric('abc') = false
+ * - isNumeric(true) = false
+ * - isNumeric(undefined) = false
+ * - isNumeric(null) = false
+ *
+ * @param value
+ * @returns {boolean}
+ */
+export const isNumeric = (value) => {
+ return !Number.isNaN(parseInt(value, 10));
+};
diff --git a/app/assets/javascripts/lib/utils/select2_utils.js b/app/assets/javascripts/lib/utils/select2_utils.js
new file mode 100644
index 00000000000..03c0e608b79
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/select2_utils.js
@@ -0,0 +1,25 @@
+import axios from './axios_utils';
+import { normalizeHeaders, parseIntPagination } from './common_utils';
+
+// This is used in the select2 config to replace jQuery.ajax with axios
+export const select2AxiosTransport = (params) => {
+ axios({
+ method: params.type?.toLowerCase() || 'get',
+ url: params.url,
+ params: params.data,
+ })
+ .then((res) => {
+ const results = res.data || [];
+ const headers = normalizeHeaders(res.headers);
+ const pagination = parseIntPagination(headers);
+ const more = pagination.nextPage > pagination.page;
+
+ params.success({
+ results,
+ pagination: {
+ more,
+ },
+ });
+ })
+ .catch(params.error);
+};
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 5e66aa05218..345dfaf895b 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -283,9 +283,9 @@ function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagCo
/* eslint-disable @gitlab/require-i18n-strings */
export function keypressNoteText(e) {
- if (this.selectionStart === this.selectionEnd) {
- return;
- }
+ if (!gon.markdown_surround_selection) return;
+ if (this.selectionStart === this.selectionEnd) return;
+
const keys = {
'*': '**{text}**', // wraps with bold character
_: '_{text}_', // wraps with italic character
diff --git a/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js b/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js
index 15f9512fe92..418cc69bf5a 100644
--- a/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js
+++ b/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js
@@ -1,39 +1,30 @@
+import { formatNumber } from '~/locale';
+
/**
- * Formats a number as string using `toLocaleString`.
+ * Formats a number as a string using `toLocaleString`.
*
* @param {Number} number to be converted
- * @param {params} Parameters
- * @param {params.fractionDigits} Number of decimal digits
- * to display, defaults to using `toLocaleString` defaults.
- * @param {params.maxLength} Max output char lenght at the
+ *
+ * @param {options.maxCharLength} Max output char length at the
* expense of precision, if the output is longer than this,
* the formatter switches to using exponential notation.
- * @param {params.factor} Value is multiplied by this factor,
- * useful for value normalization.
- * @returns Formatted value
+ *
+ * @param {options.valueFactor} Value is multiplied by this factor,
+ * useful for value normalization or to alter orders of magnitude.
+ *
+ * @param {options} Other options to be passed to
+ * `formatNumber` such as `valueFactor`, `unit` and `style`.
+ *
*/
-function formatNumber(
- value,
- { fractionDigits = undefined, valueFactor = 1, style = undefined, maxLength = undefined },
-) {
- if (value === null) {
- return '';
- }
-
- const locale = document.documentElement.lang || undefined;
- const num = value * valueFactor;
- const formatted = num.toLocaleString(locale, {
- minimumFractionDigits: fractionDigits,
- maximumFractionDigits: fractionDigits,
- style,
- });
+const formatNumberNormalized = (value, { maxCharLength, valueFactor = 1, ...options }) => {
+ const formatted = formatNumber(value * valueFactor, options);
- if (maxLength !== undefined && formatted.length > maxLength) {
+ if (maxCharLength !== undefined && formatted.length > maxCharLength) {
// 123456 becomes 1.23e+8
- return num.toExponential(2);
+ return value.toExponential(2);
}
return formatted;
-}
+};
/**
* Formats a number as a string scaling it up according to units.
@@ -76,7 +67,10 @@ const scaledFormatter = (units, unitFactor = 1000) => {
const unit = units[scale];
- return `${formatNumber(num, { fractionDigits })}${unit}`;
+ return `${formatNumberNormalized(num, {
+ maximumFractionDigits: fractionDigits,
+ minimumFractionDigits: fractionDigits,
+ })}${unit}`;
};
};
@@ -84,8 +78,14 @@ const scaledFormatter = (units, unitFactor = 1000) => {
* Returns a function that formats a number as a string.
*/
export const numberFormatter = (style = 'decimal', valueFactor = 1) => {
- return (value, fractionDigits, maxLength) => {
- return `${formatNumber(value, { fractionDigits, maxLength, valueFactor, style })}`;
+ return (value, fractionDigits, maxCharLength) => {
+ return `${formatNumberNormalized(value, {
+ maxCharLength,
+ valueFactor,
+ style,
+ maximumFractionDigits: fractionDigits,
+ minimumFractionDigits: fractionDigits,
+ })}`;
};
};
@@ -93,9 +93,15 @@ export const numberFormatter = (style = 'decimal', valueFactor = 1) => {
* Returns a function that formats a number as a string with a suffix.
*/
export const suffixFormatter = (unit = '', valueFactor = 1) => {
- return (value, fractionDigits, maxLength) => {
- const length = maxLength !== undefined ? maxLength - unit.length : undefined;
- return `${formatNumber(value, { fractionDigits, maxLength: length, valueFactor })}${unit}`;
+ return (value, fractionDigits, maxCharLength) => {
+ const length = maxCharLength !== undefined ? maxCharLength - unit.length : undefined;
+
+ return `${formatNumberNormalized(value, {
+ maxCharLength: length,
+ valueFactor,
+ maximumFractionDigits: fractionDigits,
+ minimumFractionDigits: fractionDigits,
+ })}${unit}`;
};
};
diff --git a/app/assets/javascripts/lib/utils/unit_format/index.js b/app/assets/javascripts/lib/utils/unit_format/index.js
index 9f979f7ea4b..bc82c6aa74d 100644
--- a/app/assets/javascripts/lib/utils/unit_format/index.js
+++ b/app/assets/javascripts/lib/utils/unit_format/index.js
@@ -46,227 +46,261 @@ export const SUPPORTED_FORMATS = {
};
/**
- * Returns a function that formats number to different units
- * @param {String} format - Format to use, must be one of the SUPPORTED_FORMATS. Defaults to engineering notation.
+ * Returns a function that formats number to different units.
*
+ * Used for dynamic formatting, for more convenience, use the functions below.
*
+ * @param {String} format - Format to use, must be one of the SUPPORTED_FORMATS. Defaults to engineering notation.
*/
export const getFormatter = (format = SUPPORTED_FORMATS.engineering) => {
// Number
-
if (format === SUPPORTED_FORMATS.number) {
- /**
- * Formats a number
- *
- * @function
- * @param {Number} value - Number to format
- * @param {Number} fractionDigits - precision decimals
- * @param {Number} maxLength - Max length of formatted number
- * if length is exceeded, exponential format is used.
- */
return numberFormatter();
}
if (format === SUPPORTED_FORMATS.percent) {
- /**
- * Formats a percentge (0 - 1)
- *
- * @function
- * @param {Number} value - Number to format, `1` is rendered as `100%`
- * @param {Number} fractionDigits - number of precision decimals
- * @param {Number} maxLength - Max length of formatted number
- * if length is exceeded, exponential format is used.
- */
return numberFormatter('percent');
}
if (format === SUPPORTED_FORMATS.percentHundred) {
- /**
- * Formats a percentge (0 to 100)
- *
- * @function
- * @param {Number} value - Number to format, `100` is rendered as `100%`
- * @param {Number} fractionDigits - number of precision decimals
- * @param {Number} maxLength - Max length of formatted number
- * if length is exceeded, exponential format is used.
- */
return numberFormatter('percent', 1 / 100);
}
// Durations
-
if (format === SUPPORTED_FORMATS.seconds) {
- /**
- * Formats a number of seconds
- *
- * @function
- * @param {Number} value - Number to format, `1` is rendered as `1s`
- * @param {Number} fractionDigits - number of precision decimals
- * @param {Number} maxLength - Max length of formatted number
- * if length is exceeded, exponential format is used.
- */
return suffixFormatter(s__('Units|s'));
}
if (format === SUPPORTED_FORMATS.milliseconds) {
- /**
- * Formats a number of milliseconds with ms as units
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1ms`
- * @param {Number} fractionDigits - number of precision decimals
- * @param {Number} maxLength - Max length of formatted number
- * if length is exceeded, exponential format is used.
- */
return suffixFormatter(s__('Units|ms'));
}
// Digital (Metric)
-
if (format === SUPPORTED_FORMATS.decimalBytes) {
- /**
- * Formats a number of bytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1B`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledSIFormatter('B');
}
if (format === SUPPORTED_FORMATS.kilobytes) {
- /**
- * Formats a number of kilobytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1kB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledSIFormatter('B', 1);
}
if (format === SUPPORTED_FORMATS.megabytes) {
- /**
- * Formats a number of megabytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1MB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledSIFormatter('B', 2);
}
if (format === SUPPORTED_FORMATS.gigabytes) {
- /**
- * Formats a number of gigabytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1GB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledSIFormatter('B', 3);
}
if (format === SUPPORTED_FORMATS.terabytes) {
- /**
- * Formats a number of terabytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1GB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledSIFormatter('B', 4);
}
if (format === SUPPORTED_FORMATS.petabytes) {
- /**
- * Formats a number of petabytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1PB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledSIFormatter('B', 5);
}
// Digital (IEC)
-
if (format === SUPPORTED_FORMATS.bytes) {
- /**
- * Formats a number of bytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1B`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledBinaryFormatter('B');
}
if (format === SUPPORTED_FORMATS.kibibytes) {
- /**
- * Formats a number of kilobytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1kB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledBinaryFormatter('B', 1);
}
if (format === SUPPORTED_FORMATS.mebibytes) {
- /**
- * Formats a number of megabytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1MB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledBinaryFormatter('B', 2);
}
if (format === SUPPORTED_FORMATS.gibibytes) {
- /**
- * Formats a number of gigabytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1GB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledBinaryFormatter('B', 3);
}
if (format === SUPPORTED_FORMATS.tebibytes) {
- /**
- * Formats a number of terabytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1GB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledBinaryFormatter('B', 4);
}
if (format === SUPPORTED_FORMATS.pebibytes) {
- /**
- * Formats a number of petabytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1PB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledBinaryFormatter('B', 5);
}
+ // Default
if (format === SUPPORTED_FORMATS.engineering) {
- /**
- * Formats via engineering notation
- *
- * @function
- * @param {Number} value - Value to format
- * @param {Number} fractionDigits - precision decimals - Defaults to 2
- */
return engineeringNotation;
}
// Fail so client library addresses issue
throw TypeError(`${format} is not a valid number format`);
};
+
+/**
+ * Formats a number
+ *
+ * @function
+ * @param {Number} value - Number to format
+ * @param {Number} fractionDigits - precision decimals
+ * @param {Number} maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ */
+export const number = getFormatter(SUPPORTED_FORMATS.number);
+
+/**
+ * Formats a percentage (0 - 1)
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is rendered as `100%`
+ * @param {Number} fractionDigits - number of precision decimals
+ * @param {Number} maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ */
+export const percent = getFormatter(SUPPORTED_FORMATS.percent);
+
+/**
+ * Formats a percentage (0 to 100)
+ *
+ * @function
+ * @param {Number} value - Number to format, `100` is rendered as `100%`
+ * @param {Number} fractionDigits - number of precision decimals
+ * @param {Number} maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ */
+export const percentHundred = getFormatter(SUPPORTED_FORMATS.percentHundred);
+
+/**
+ * Formats a number of seconds
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is rendered as `1s`
+ * @param {Number} fractionDigits - number of precision decimals
+ * @param {Number} maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ */
+export const seconds = getFormatter(SUPPORTED_FORMATS.seconds);
+
+/**
+ * Formats a number of milliseconds with ms as units
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1ms`
+ * @param {Number} fractionDigits - number of precision decimals
+ * @param {Number} maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ */
+export const milliseconds = getFormatter(SUPPORTED_FORMATS.milliseconds);
+
+/**
+ * Formats a number of bytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1B`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const decimalBytes = getFormatter(SUPPORTED_FORMATS.decimalBytes);
+
+/**
+ * Formats a number of kilobytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1kB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const kilobytes = getFormatter(SUPPORTED_FORMATS.kilobytes);
+
+/**
+ * Formats a number of megabytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1MB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const megabytes = getFormatter(SUPPORTED_FORMATS.megabytes);
+
+/**
+ * Formats a number of gigabytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1GB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const gigabytes = getFormatter(SUPPORTED_FORMATS.gigabytes);
+
+/**
+ * Formats a number of terabytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1GB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const terabytes = getFormatter(SUPPORTED_FORMATS.terabytes);
+
+/**
+ * Formats a number of petabytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1PB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const petabytes = getFormatter(SUPPORTED_FORMATS.petabytes);
+
+/**
+ * Formats a number of bytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1B`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const bytes = getFormatter(SUPPORTED_FORMATS.bytes);
+
+/**
+ * Formats a number of kilobytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1kB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const kibibytes = getFormatter(SUPPORTED_FORMATS.kibibytes);
+
+/**
+ * Formats a number of megabytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1MB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const mebibytes = getFormatter(SUPPORTED_FORMATS.mebibytes);
+
+/**
+ * Formats a number of gigabytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1GB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const gibibytes = getFormatter(SUPPORTED_FORMATS.gibibytes);
+
+/**
+ * Formats a number of terabytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1GB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const tebibytes = getFormatter(SUPPORTED_FORMATS.tebibytes);
+
+/**
+ * Formats a number of petabytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1PB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const pebibytes = getFormatter(SUPPORTED_FORMATS.pebibytes);
+
+/**
+ * Formats via engineering notation
+ *
+ * @function
+ * @param {Number} value - Value to format
+ * @param {Number} fractionDigits - precision decimals - Defaults to 2
+ */
+export const engineering = getFormatter();
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index cc2cf787a8f..5b3aa3cf9dc 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -473,6 +473,7 @@ export const setUrlParams = (
url = window.location.href,
clearParams = false,
railsArraySyntax = false,
+ decodeParams = false,
) => {
const urlObj = new URL(url);
const queryString = urlObj.search;
@@ -495,7 +496,9 @@ export const setUrlParams = (
}
});
- urlObj.search = searchParams.toString();
+ urlObj.search = decodeParams
+ ? decodeURIComponent(searchParams.toString())
+ : searchParams.toString();
return urlObj.toString();
};
diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js
index 35087b920c7..10518fa73d9 100644
--- a/app/assets/javascripts/locale/index.js
+++ b/app/assets/javascripts/locale/index.js
@@ -58,10 +58,30 @@ const pgettext = (keyOrContext, key) => {
*/
const createDateTimeFormat = (formatOptions) => Intl.DateTimeFormat(languageCode(), formatOptions);
+/**
+ * Formats a number as a string using `toLocaleString`.
+ *
+ * @param {Number} value - number to be converted
+ * @param {options?} options - options to be passed to
+ * `toLocaleString` such as `unit` and `style`.
+ * @param {langCode?} langCode - If set, forces a different
+ * language code from the one currently in the document.
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat
+ *
+ * @returns If value is a number, the formatted value as a string
+ */
+function formatNumber(value, options = {}, langCode = languageCode()) {
+ if (typeof value !== 'number' && typeof value !== 'bigint') {
+ return value;
+ }
+ return value.toLocaleString(langCode, options);
+}
+
export { languageCode };
export { gettext as __ };
export { ngettext as n__ };
export { pgettext as s__ };
export { sprintf };
export { createDateTimeFormat };
+export { formatNumber };
export default locale;
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 32ecc0036bd..417baddc031 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -272,7 +272,7 @@ document.addEventListener('DOMContentLoaded', () => {
e.preventDefault();
- $this.toggleClass('active');
+ $this.toggleClass('selected');
if ($this.hasClass('active')) {
notesHolders.show().find('.hide, .content').show();
diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js
deleted file mode 100644
index a8edeaf5176..00000000000
--- a/app/assets/javascripts/members.js
+++ /dev/null
@@ -1,111 +0,0 @@
-import $ from 'jquery';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { disableButtonIfEmptyField } from '~/lib/utils/common_utils';
-import { Rails } from '~/lib/utils/rails_ujs';
-import { __, sprintf } from '~/locale';
-
-export default class Members {
- constructor() {
- this.addListeners();
- this.initGLDropdown();
- }
-
- addListeners() {
- // eslint-disable-next-line @gitlab/no-global-event-off
- $('.js-member-update-control').off('change').on('change', this.formSubmit.bind(this));
- // eslint-disable-next-line @gitlab/no-global-event-off
- $('.js-edit-member-form').off('ajax:success').on('ajax:success', this.formSuccess.bind(this));
- disableButtonIfEmptyField('#user_ids', 'input[name=commit]', 'change');
- }
-
- dropdownClicked(options) {
- options.e.preventDefault();
-
- this.formSubmit(null, options.$el);
- }
-
- // eslint-disable-next-line class-methods-use-this
- dropdownToggleLabel(selected, $el) {
- return $el.text();
- }
-
- // eslint-disable-next-line class-methods-use-this
- dropdownIsSelectable(selected, $el) {
- return !$el.hasClass('is-active');
- }
-
- initGLDropdown() {
- $('.js-member-permissions-dropdown').each((i, btn) => {
- const $btn = $(btn);
-
- initDeprecatedJQueryDropdown($btn, {
- selectable: true,
- isSelectable: (selected, $el) => this.dropdownIsSelectable(selected, $el),
- fieldName: $btn.data('fieldName'),
- id(selected, $el) {
- return $el.data('id');
- },
- toggleLabel: (selected, $el) => this.dropdownToggleLabel(selected, $el, $btn),
- clicked: (options) => this.dropdownClicked(options),
- });
- });
- }
-
- formSubmit(e, $el = null) {
- const $this = e ? $(e.currentTarget) : $el;
- const { $toggle, $dateInput } = this.getMemberListItems($this);
- const formEl = $this.closest('form').get(0);
-
- Rails.fire(formEl, 'submit');
-
- $toggle.disable();
- $dateInput.disable();
- }
-
- formSuccess(e) {
- const { $toggle, $dateInput, $expiresIn, $expiresInText } = this.getMemberListItems(
- $(e.currentTarget).closest('.js-member'),
- );
-
- const [data] = e.detail;
- const expiresIn = data?.expires_in;
-
- if (expiresIn) {
- $expiresIn.removeClass('gl-display-none');
-
- $expiresInText.text(sprintf(__('Expires in %{expires_at}'), { expires_at: expiresIn }));
-
- const { expires_soon: expiresSoon, expires_at_formatted: expiresAtFormatted } = data;
-
- if (expiresSoon) {
- $expiresInText.addClass('text-warning');
- } else {
- $expiresInText.removeClass('text-warning');
- }
-
- // Update tooltip
- if (expiresAtFormatted) {
- $expiresInText.attr('title', expiresAtFormatted);
- $expiresInText.attr('data-original-title', expiresAtFormatted);
- }
- } else {
- $expiresIn.addClass('gl-display-none');
- }
-
- $toggle.enable();
- $dateInput.enable();
- }
-
- // eslint-disable-next-line class-methods-use-this
- getMemberListItems($el) {
- const $memberListItem = $el.is('.js-member') ? $el : $(`#${$el.data('elId')}`);
-
- return {
- $memberListItem,
- $expiresIn: $memberListItem.find('.js-expires-in'),
- $expiresInText: $memberListItem.find('.js-expires-in-text'),
- $toggle: $memberListItem.find('.dropdown-menu-toggle'),
- $dateInput: $memberListItem.find('.js-access-expiration-date'),
- };
- }
-}
diff --git a/app/assets/javascripts/members/components/avatars/user_avatar.vue b/app/assets/javascripts/members/components/avatars/user_avatar.vue
index 79dda3c1379..658fb43cecb 100644
--- a/app/assets/javascripts/members/components/avatars/user_avatar.vue
+++ b/app/assets/javascripts/members/components/avatars/user_avatar.vue
@@ -5,6 +5,7 @@ import {
GlBadge,
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
+import { mapState } from 'vuex';
import { generateBadges } from 'ee_else_ce/members/utils';
import { glEmojiTag } from '~/emoji';
import { __ } from '~/locale';
@@ -34,11 +35,16 @@ export default {
},
},
computed: {
+ ...mapState(['canManageMembers']),
user() {
return this.member.user;
},
badges() {
- return generateBadges(this.member, this.isCurrentUser).filter((badge) => badge.show);
+ return generateBadges({
+ member: this.member,
+ isCurrentUser: this.isCurrentUser,
+ canManageMembers: this.canManageMembers,
+ }).filter((badge) => badge.show);
},
statusEmoji() {
return this.user?.status?.emoji;
diff --git a/app/assets/javascripts/members/utils.js b/app/assets/javascripts/members/utils.js
index 4de2dadb490..2bf30dd7b6e 100644
--- a/app/assets/javascripts/members/utils.js
+++ b/app/assets/javascripts/members/utils.js
@@ -13,7 +13,7 @@ import {
GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME,
} from './constants';
-export const generateBadges = (member, isCurrentUser) => [
+export const generateBadges = ({ member, isCurrentUser, canManageMembers }) => [
{
show: isCurrentUser,
text: __("It's you"),
@@ -25,7 +25,7 @@ export const generateBadges = (member, isCurrentUser) => [
variant: 'danger',
},
{
- show: member.user?.twoFactorEnabled,
+ show: member.user?.twoFactorEnabled && (canManageMembers || isCurrentUser),
text: __('2FA'),
variant: 'info',
},
diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js
deleted file mode 100644
index 6eaabbb3519..00000000000
--- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js
+++ /dev/null
@@ -1,115 +0,0 @@
-// This is a true violation of @gitlab/no-runtime-template-compiler, as it relies on
-// app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml
-// for its template.
-/* eslint-disable no-param-reassign, @gitlab/no-runtime-template-compiler */
-
-import { debounce } from 'lodash';
-import Vue from 'vue';
-import { deprecatedCreateFlash as flash } from '~/flash';
-import axios from '~/lib/utils/axios_utils';
-import { __ } from '~/locale';
-
-((global) => {
- global.mergeConflicts = global.mergeConflicts || {};
-
- global.mergeConflicts.diffFileEditor = Vue.extend({
- props: {
- file: {
- type: Object,
- required: true,
- },
- onCancelDiscardConfirmation: {
- type: Function,
- required: true,
- },
- onAcceptDiscardConfirmation: {
- type: Function,
- required: true,
- },
- },
- data() {
- return {
- saved: false,
- fileLoaded: false,
- originalContent: '',
- };
- },
- computed: {
- classObject() {
- return {
- saved: this.saved,
- };
- },
- },
- watch: {
- 'file.showEditor': function showEditorWatcher(val) {
- this.resetEditorContent();
-
- if (!val || this.fileLoaded) {
- return;
- }
-
- this.loadEditor();
- },
- },
- mounted() {
- if (this.file.loadEditor) {
- this.loadEditor();
- }
- },
- methods: {
- loadEditor() {
- const EditorPromise = import(/* webpackChunkName: 'EditorLite' */ '~/editor/editor_lite');
- const DataPromise = axios.get(this.file.content_path);
-
- Promise.all([EditorPromise, DataPromise])
- .then(
- ([
- { default: EditorLite },
- {
- data: { content, new_path: path },
- },
- ]) => {
- const contentEl = this.$el.querySelector('.editor');
-
- this.originalContent = content;
- this.fileLoaded = true;
-
- this.editor = new EditorLite().createInstance({
- el: contentEl,
- blobPath: path,
- blobContent: content,
- });
- this.editor.onDidChangeModelContent(
- debounce(this.saveDiffResolution.bind(this), 250),
- );
- },
- )
- .catch(() => {
- flash(__('An error occurred while loading the file'));
- });
- },
- saveDiffResolution() {
- this.saved = true;
-
- // This probably be better placed in the data provider
- /* eslint-disable vue/no-mutating-props */
- this.file.content = this.editor.getValue();
- this.file.resolveEditChanged = this.file.content !== this.originalContent;
- this.file.promptDiscardConfirmation = false;
- /* eslint-enable vue/no-mutating-props */
- },
- resetEditorContent() {
- if (this.fileLoaded) {
- this.editor.setValue(this.originalContent);
- }
- },
- cancelDiscardConfirmation(file) {
- this.onCancelDiscardConfirmation(file);
- },
- acceptDiscardConfirmation(file) {
- this.onAcceptDiscardConfirmation(file);
- },
- },
- });
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue
new file mode 100644
index 00000000000..2c7c8038af5
--- /dev/null
+++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue
@@ -0,0 +1,128 @@
+<script>
+import { debounce } from 'lodash';
+import { deprecatedCreateFlash as flash } from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+
+export default {
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ onCancelDiscardConfirmation: {
+ type: Function,
+ required: true,
+ },
+ onAcceptDiscardConfirmation: {
+ type: Function,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ saved: false,
+ fileLoaded: false,
+ originalContent: '',
+ };
+ },
+ computed: {
+ classObject() {
+ return {
+ saved: this.saved,
+ };
+ },
+ },
+ watch: {
+ 'file.showEditor': function showEditorWatcher(val) {
+ this.resetEditorContent();
+
+ if (!val || this.fileLoaded) {
+ return;
+ }
+
+ this.loadEditor();
+ },
+ },
+ mounted() {
+ if (this.file.loadEditor) {
+ this.loadEditor();
+ }
+ },
+ methods: {
+ loadEditor() {
+ const EditorPromise = import(/* webpackChunkName: 'EditorLite' */ '~/editor/editor_lite');
+ const DataPromise = axios.get(this.file.content_path);
+
+ Promise.all([EditorPromise, DataPromise])
+ .then(
+ ([
+ { default: EditorLite },
+ {
+ data: { content, new_path: path },
+ },
+ ]) => {
+ const contentEl = this.$el.querySelector('.editor');
+
+ this.originalContent = content;
+ this.fileLoaded = true;
+
+ this.editor = new EditorLite().createInstance({
+ el: contentEl,
+ blobPath: path,
+ blobContent: content,
+ });
+ this.editor.onDidChangeModelContent(debounce(this.saveDiffResolution.bind(this), 250));
+ },
+ )
+ .catch(() => {
+ flash(__('An error occurred while loading the file'));
+ });
+ },
+ saveDiffResolution() {
+ this.saved = true;
+
+ // This probably be better placed in the data provider
+ /* eslint-disable vue/no-mutating-props */
+ this.file.content = this.editor.getValue();
+ this.file.resolveEditChanged = this.file.content !== this.originalContent;
+ this.file.promptDiscardConfirmation = false;
+ /* eslint-enable vue/no-mutating-props */
+ },
+ resetEditorContent() {
+ if (this.fileLoaded) {
+ this.editor.setValue(this.originalContent);
+ }
+ },
+ cancelDiscardConfirmation(file) {
+ this.onCancelDiscardConfirmation(file);
+ },
+ acceptDiscardConfirmation(file) {
+ this.onAcceptDiscardConfirmation(file);
+ },
+ },
+};
+</script>
+<template>
+ <div v-show="file.showEditor" class="diff-editor-wrap">
+ <div v-if="file.promptDiscardConfirmation" class="discard-changes-alert-wrap">
+ <div class="discard-changes-alert">
+ {{ __('Are you sure you want to discard your changes?') }}
+ <div class="discard-actions">
+ <button
+ class="btn btn-sm btn-danger-secondary gl-button"
+ @click="acceptDiscardConfirmation(file)"
+ >
+ {{ __('Discard changes') }}
+ </button>
+ <button class="btn btn-default btn-sm gl-button" @click="cancelDiscardConfirmation(file)">
+ {{ __('Cancel') }}
+ </button>
+ </div>
+ </div>
+ </div>
+ <div :class="classObject" class="editor-wrap">
+ <div class="editor" style="height: 350px" data-editor-loading="true"></div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js
deleted file mode 100644
index 47214e288ae..00000000000
--- a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js
+++ /dev/null
@@ -1,22 +0,0 @@
-// This is a true violation of @gitlab/no-runtime-template-compiler, as it relies on
-// app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml
-// for its template.
-/* eslint-disable no-param-reassign, @gitlab/no-runtime-template-compiler */
-
-import Vue from 'vue';
-import actionsMixin from '../mixins/line_conflict_actions';
-import utilsMixin from '../mixins/line_conflict_utils';
-
-((global) => {
- global.mergeConflicts = global.mergeConflicts || {};
-
- global.mergeConflicts.inlineConflictLines = Vue.extend({
- mixins: [utilsMixin, actionsMixin],
- props: {
- file: {
- type: Object,
- required: true,
- },
- },
- });
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue
new file mode 100644
index 00000000000..519fd53af1e
--- /dev/null
+++ b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue
@@ -0,0 +1,47 @@
+<script>
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import actionsMixin from '../mixins/line_conflict_actions';
+import utilsMixin from '../mixins/line_conflict_utils';
+
+export default {
+ directives: {
+ SafeHtml,
+ },
+ mixins: [utilsMixin, actionsMixin],
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <table class="diff-wrap-lines code code-commit js-syntax-highlight">
+ <tr
+ v-for="line in file.inlineLines"
+ :key="(line.isHeader ? line.id : line.new_line) + line.richText"
+ class="line_holder diff-inline"
+ >
+ <template v-if="line.isHeader">
+ <td :class="lineCssClass(line)" class="diff-line-num header"></td>
+ <td :class="lineCssClass(line)" class="diff-line-num header"></td>
+ <td :class="lineCssClass(line)" class="line_content header">
+ <strong>{{ line.richText }}</strong>
+ <button class="btn" @click="handleSelected(file, line.id, line.section)">
+ {{ line.buttonTitle }}
+ </button>
+ </td>
+ </template>
+ <template v-else>
+ <td :class="lineCssClass(line)" class="diff-line-num new_line">
+ <a>{{ line.new_line }}</a>
+ </td>
+ <td :class="lineCssClass(line)" class="diff-line-num old_line">
+ <a>{{ line.old_line }}</a>
+ </td>
+ <td v-safe-html="line.richText" :class="lineCssClass(line)" class="line_content"></td>
+ </template>
+ </tr>
+ </table>
+</template>
diff --git a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js
deleted file mode 100644
index 1d5946cd78a..00000000000
--- a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js
+++ /dev/null
@@ -1,37 +0,0 @@
-/* eslint-disable no-param-reassign */
-
-import Vue from 'vue';
-import actionsMixin from '../mixins/line_conflict_actions';
-import utilsMixin from '../mixins/line_conflict_utils';
-
-((global) => {
- global.mergeConflicts = global.mergeConflicts || {};
-
- global.mergeConflicts.parallelConflictLines = Vue.extend({
- mixins: [utilsMixin, actionsMixin],
- props: {
- file: {
- type: Object,
- required: true,
- },
- },
- // This is a true violation of @gitlab/no-runtime-template-compiler, as it
- // has a template string.
- // eslint-disable-next-line @gitlab/no-runtime-template-compiler
- template: `
- <table class="diff-wrap-lines code js-syntax-highlight">
- <tr class="line_holder parallel" v-for="section in file.parallelLines">
- <template v-for="line in section">
- <td class="diff-line-num header" :class="lineCssClass(line)" v-if="line.isHeader"></td>
- <td class="line_content header" :class="lineCssClass(line)" v-if="line.isHeader">
- <strong>{{line.richText}}</strong>
- <button class="btn" @click="handleSelected(file, line.id, line.section)">{{line.buttonTitle}}</button>
- </td>
- <td class="diff-line-num old_line" :class="lineCssClass(line)" v-if="!line.isHeader">{{line.lineNumber}}</td>
- <td class="line_content parallel" :class="lineCssClass(line)" v-if="!line.isHeader" v-html="line.richText"></td>
- </template>
- </tr>
- </table>
- `,
- });
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue
new file mode 100644
index 00000000000..e66f641f70d
--- /dev/null
+++ b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue
@@ -0,0 +1,47 @@
+<script>
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import actionsMixin from '../mixins/line_conflict_actions';
+import utilsMixin from '../mixins/line_conflict_utils';
+
+export default {
+ directives: {
+ SafeHtml,
+ },
+ mixins: [utilsMixin, actionsMixin],
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <!-- Unfortunately there isn't a good key for these sections -->
+ <!-- eslint-disable vue/require-v-for-key -->
+ <table class="diff-wrap-lines code js-syntax-highlight">
+ <tr v-for="section in file.parallelLines" class="line_holder parallel">
+ <template v-for="line in section">
+ <template v-if="line.isHeader">
+ <td class="diff-line-num header" :class="lineCssClass(line)"></td>
+ <td class="line_content header" :class="lineCssClass(line)">
+ <strong>{{ line.richText }}</strong>
+ <button class="btn" @click="handleSelected(file, line.id, line.section)">
+ {{ line.buttonTitle }}
+ </button>
+ </td>
+ </template>
+ <template v-else>
+ <td class="diff-line-num old_line" :class="lineCssClass(line)">
+ {{ line.lineNumber }}
+ </td>
+ <td
+ v-safe-html="line.richText"
+ class="line_content parallel"
+ :class="lineCssClass(line)"
+ ></td>
+ </template>
+ </template>
+ </tr>
+ </table>
+</template>
diff --git a/app/assets/javascripts/merge_conflicts/constants.js b/app/assets/javascripts/merge_conflicts/constants.js
new file mode 100644
index 00000000000..6f3ee339e36
--- /dev/null
+++ b/app/assets/javascripts/merge_conflicts/constants.js
@@ -0,0 +1,20 @@
+import { s__ } from '~/locale';
+
+export const CONFLICT_TYPES = {
+ TEXT: 'text',
+ TEXT_EDITOR: 'text-editor',
+};
+
+export const VIEW_TYPES = {
+ INLINE: 'inline',
+ PARALLEL: 'parallel',
+};
+
+export const EDIT_RESOLVE_MODE = 'edit';
+export const INTERACTIVE_RESOLVE_MODE = 'interactive';
+export const DEFAULT_RESOLVE_MODE = INTERACTIVE_RESOLVE_MODE;
+
+export const HEAD_HEADER_TEXT = s__('MergeConflict|HEAD//our changes');
+export const ORIGIN_HEADER_TEXT = s__('MergeConflict|origin//their changes');
+export const HEAD_BUTTON_TITLE = s__('MergeConflict|Use ours');
+export const ORIGIN_BUTTON_TITLE = s__('MergeConflict|Use theirs');
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
new file mode 100644
index 00000000000..16a7cfb2ba8
--- /dev/null
+++ b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
@@ -0,0 +1,217 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+import FileIcon from '~/vue_shared/components/file_icon.vue';
+import DiffFileEditor from './components/diff_file_editor.vue';
+import InlineConflictLines from './components/inline_conflict_lines.vue';
+import ParallelConflictLines from './components/parallel_conflict_lines.vue';
+
+/**
+ * NOTE: Most of this component is directly using $root, rather than props or a better data store.
+ * This is BAD and one shouldn't copy that behavior. Similarly a lot of the classes below should
+ * be replaced with GitLab UI components.
+ *
+ * We are just doing it temporarily in order to migrate the template from HAML => Vue in an iterative manner
+ * and are going to clean it up as part of:
+ *
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/321090
+ *
+ */
+export default {
+ components: {
+ GlSprintf,
+ FileIcon,
+ DiffFileEditor,
+ InlineConflictLines,
+ ParallelConflictLines,
+ },
+ inject: ['mergeRequestPath', 'sourceBranchPath'],
+ i18n: {
+ commitStatSummary: __('Showing %{conflict} between %{sourceBranch} and %{targetBranch}'),
+ resolveInfo: __(
+ 'You can resolve the merge conflict using either the Interactive mode, by choosing %{use_ours} or %{use_theirs} buttons, or by editing the files directly. Commit these changes into %{branch_name}',
+ ),
+ },
+};
+</script>
+<template>
+ <div id="conflicts">
+ <div v-if="$root.isLoading" class="loading">
+ <div class="spinner spinner-md"></div>
+ </div>
+ <div v-if="$root.hasError" class="nothing-here-block">
+ {{ $root.conflictsData.errorMessage }}
+ </div>
+ <template v-if="!$root.isLoading && !$root.hasError">
+ <div class="content-block oneline-block files-changed">
+ <div v-if="$root.showDiffViewTypeSwitcher" class="inline-parallel-buttons">
+ <div class="btn-group">
+ <button
+ :class="{ active: !$root.isParallel }"
+ class="btn gl-button"
+ @click="$root.handleViewTypeChange('inline')"
+ >
+ {{ __('Inline') }}
+ </button>
+ <button
+ :class="{ active: $root.isParallel }"
+ class="btn gl-button"
+ @click="$root.handleViewTypeChange('parallel')"
+ >
+ {{ __('Side-by-side') }}
+ </button>
+ </div>
+ </div>
+ <div class="js-toggle-container">
+ <div class="commit-stat-summary">
+ <gl-sprintf :message="$options.i18n.commitStatSummary">
+ <template #conflict>
+ <strong class="cred">
+ {{ $root.conflictsCountText }}
+ </strong>
+ </template>
+ <template #sourceBranch>
+ <strong class="ref-name">
+ {{ $root.conflictsData.sourceBranch }}
+ </strong>
+ </template>
+ <template #targetBranch>
+ <strong class="ref-name">
+ {{ $root.conflictsData.targetBranch }}
+ </strong>
+ </template>
+ </gl-sprintf>
+ </div>
+ </div>
+ </div>
+ <div class="files-wrapper">
+ <div class="files">
+ <div
+ v-for="file in $root.conflictsData.files"
+ :key="file.blobPath"
+ class="diff-file file-holder conflict"
+ >
+ <div class="js-file-title file-title file-title-flex-parent cursor-default">
+ <div class="file-header-content">
+ <file-icon :file-name="file.filePath" :size="18" css-classes="gl-mr-2" />
+ <strong class="file-title-name">{{ file.filePath }}</strong>
+ </div>
+ <div class="file-actions d-flex align-items-center gl-ml-auto gl-align-self-start">
+ <div v-if="file.type === 'text'" class="btn-group gl-mr-3">
+ <button
+ :class="{ active: file.resolveMode === 'interactive' }"
+ class="btn gl-button"
+ type="button"
+ @click="$root.onClickResolveModeButton(file, 'interactive')"
+ >
+ {{ __('Interactive mode') }}
+ </button>
+ <button
+ :class="{ active: file.resolveMode === 'edit' }"
+ class="btn gl-button"
+ type="button"
+ @click="$root.onClickResolveModeButton(file, 'edit')"
+ >
+ {{ __('Edit inline') }}
+ </button>
+ </div>
+ <a :href="file.blobPath" class="btn gl-button view-file">
+ <gl-sprintf :message="__('View file @ %{commitSha}')">
+ <template #commitSha>
+ {{ $root.conflictsData.shortCommitSha }}
+ </template>
+ </gl-sprintf>
+ </a>
+ </div>
+ </div>
+ <div class="diff-content diff-wrap-lines">
+ <div
+ v-show="
+ !$root.isParallel && file.resolveMode === 'interactive' && file.type === 'text'
+ "
+ class="file-content"
+ >
+ <inline-conflict-lines :file="file" />
+ </div>
+ <div
+ v-show="
+ $root.isParallel && file.resolveMode === 'interactive' && file.type === 'text'
+ "
+ class="file-content"
+ >
+ <parallel-conflict-lines :file="file" />
+ </div>
+ <div v-show="file.resolveMode === 'edit' || file.type === 'text-editor'">
+ <diff-file-editor
+ :file="file"
+ :on-accept-discard-confirmation="$root.acceptDiscardConfirmation"
+ :on-cancel-discard-confirmation="$root.cancelDiscardConfirmation"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <hr />
+ <div class="resolve-conflicts-form">
+ <div class="form-group row">
+ <div class="col-md-4">
+ <h4>
+ {{ __('Resolve conflicts on source branch') }}
+ </h4>
+ <div class="resolve-info">
+ <gl-sprintf :message="$options.i18n.resolveInfo">
+ <template #use_ours>
+ <code>{{ s__('MergeConflict|Use ours') }}</code>
+ </template>
+ <template #use_theirs>
+ <code>{{ s__('MergeConflict|Use theirs') }}</code>
+ </template>
+ <template #branch_name>
+ <a class="ref-name" :href="sourceBranchPath">
+ {{ $root.conflictsData.sourceBranch }}
+ </a>
+ </template>
+ </gl-sprintf>
+ </div>
+ </div>
+ <div class="col-md-8">
+ <label class="label-bold" for="commit-message">
+ {{ __('Commit message') }}
+ </label>
+ <div class="commit-message-container">
+ <div class="max-width-marker"></div>
+ <textarea
+ id="commit-message"
+ v-model="$root.conflictsData.commitMessage"
+ class="form-control js-commit-message"
+ rows="5"
+ ></textarea>
+ </div>
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="offset-md-4 col-md-8">
+ <div class="row">
+ <div class="col-6">
+ <button
+ :disabled="!$root.readyToCommit"
+ class="btn gl-button btn-success js-submit-button"
+ type="button"
+ @click="$root.commit()"
+ >
+ <span>{{ $root.commitButtonText }}</span>
+ </button>
+ </div>
+ <div class="col-6 text-right">
+ <a :href="mergeRequestPath" class="gl-button btn btn-default">
+ {{ __('Cancel') }}
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
index e3972b8b574..4b73dd317cd 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
+++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
@@ -1,19 +1,12 @@
-// This is a true violation of @gitlab/no-runtime-template-compiler, as it
-// relies on app/views/projects/merge_requests/conflicts/show.html.haml for its
-// template.
-/* eslint-disable @gitlab/no-runtime-template-compiler */
import $ from 'jquery';
import Vue from 'vue';
import { __ } from '~/locale';
-import FileIcon from '~/vue_shared/components/file_icon.vue';
import { deprecatedCreateFlash as createFlash } from '../flash';
import initIssuableSidebar from '../init_issuable_sidebar';
import './merge_conflict_store';
import syntaxHighlight from '../syntax_highlight';
+import MergeConflictsResolverApp from './merge_conflict_resolver_app.vue';
import MergeConflictsService from './merge_conflict_service';
-import './components/diff_file_editor';
-import './components/inline_conflict_lines';
-import './components/parallel_conflict_lines';
export default function initMergeConflicts() {
const INTERACTIVE_RESOLVE_MODE = 'interactive';
@@ -24,15 +17,15 @@ export default function initMergeConflicts() {
resolveConflictsPath: conflictsEl.dataset.resolveConflictsPath,
});
+ const { sourceBranchPath, mergeRequestPath } = conflictsEl.dataset;
+
initIssuableSidebar();
- gl.MergeConflictsResolverApp = new Vue({
- el: '#conflicts',
- components: {
- FileIcon,
- 'diff-file-editor': gl.mergeConflicts.diffFileEditor,
- 'inline-conflict-lines': gl.mergeConflicts.inlineConflictLines,
- 'parallel-conflict-lines': gl.mergeConflicts.parallelConflictLines,
+ return new Vue({
+ el: conflictsEl,
+ provide: {
+ sourceBranchPath,
+ mergeRequestPath,
},
data: mergeConflictsStore.state,
computed: {
@@ -103,5 +96,8 @@ export default function initMergeConflicts() {
});
},
},
+ render(createElement) {
+ return createElement(MergeConflictsResolverApp);
+ },
});
}
diff --git a/app/assets/javascripts/merge_conflicts/store/actions.js b/app/assets/javascripts/merge_conflicts/store/actions.js
new file mode 100644
index 00000000000..8036e90c58c
--- /dev/null
+++ b/app/assets/javascripts/merge_conflicts/store/actions.js
@@ -0,0 +1,120 @@
+import Cookies from 'js-cookie';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import { INTERACTIVE_RESOLVE_MODE, EDIT_RESOLVE_MODE } from '../constants';
+import { decorateFiles, restoreFileLinesState, markLine } from '../utils';
+import * as types from './mutation_types';
+
+export const fetchConflictsData = async ({ commit, dispatch }, conflictsPath) => {
+ commit(types.SET_LOADING_STATE, true);
+ try {
+ const { data } = await axios.get(conflictsPath);
+ if (data.type === 'error') {
+ commit(types.SET_FAILED_REQUEST, data.message);
+ } else {
+ dispatch('setConflictsData', data);
+ }
+ } catch (e) {
+ commit(types.SET_FAILED_REQUEST);
+ }
+ commit(types.SET_LOADING_STATE, false);
+};
+
+export const setConflictsData = async ({ commit }, data) => {
+ const files = decorateFiles(data.files);
+ commit(types.SET_CONFLICTS_DATA, { ...data, files });
+};
+
+export const submitResolvedConflicts = async ({ commit, getters }, resolveConflictsPath) => {
+ commit(types.SET_SUBMIT_STATE, true);
+ try {
+ const { data } = await axios.post(resolveConflictsPath, getters.getCommitData);
+ window.location.assign(data.redirect_to);
+ } catch (e) {
+ commit(types.SET_SUBMIT_STATE, false);
+ createFlash({ message: __('Failed to save merge conflicts resolutions. Please try again!') });
+ }
+};
+
+export const setLoadingState = ({ commit }, isLoading) => {
+ commit(types.SET_LOADING_STATE, isLoading);
+};
+
+export const setErrorState = ({ commit }, hasError) => {
+ commit(types.SET_ERROR_STATE, hasError);
+};
+
+export const setFailedRequest = ({ commit }, message) => {
+ commit(types.SET_FAILED_REQUEST, message);
+};
+
+export const setViewType = ({ commit }, viewType) => {
+ commit(types.SET_VIEW_TYPE, viewType);
+ Cookies.set('diff_view', viewType);
+};
+
+export const setSubmitState = ({ commit }, isSubmitting) => {
+ commit(types.SET_SUBMIT_STATE, isSubmitting);
+};
+
+export const updateCommitMessage = ({ commit }, commitMessage) => {
+ commit(types.UPDATE_CONFLICTS_DATA, { commitMessage });
+};
+
+export const setFileResolveMode = ({ commit, state, getters }, { file, mode }) => {
+ const index = getters.getFileIndex(file);
+ const updated = { ...state.conflictsData.files[index] };
+ if (mode === INTERACTIVE_RESOLVE_MODE) {
+ updated.showEditor = false;
+ } else if (mode === EDIT_RESOLVE_MODE) {
+ // Restore Interactive mode when switching to Edit mode
+ updated.showEditor = true;
+ updated.loadEditor = true;
+ updated.resolutionData = {};
+
+ const { inlineLines, parallelLines } = restoreFileLinesState(updated);
+ updated.parallelLines = parallelLines;
+ updated.inlineLines = inlineLines;
+ }
+ updated.resolveMode = mode;
+ commit(types.UPDATE_FILE, { file: updated, index });
+};
+
+export const setPromptConfirmationState = (
+ { commit, state, getters },
+ { file, promptDiscardConfirmation },
+) => {
+ const index = getters.getFileIndex(file);
+ const updated = { ...state.conflictsData.files[index], promptDiscardConfirmation };
+ commit(types.UPDATE_FILE, { file: updated, index });
+};
+
+export const handleSelected = ({ commit, state, getters }, { file, line: { id, section } }) => {
+ const index = getters.getFileIndex(file);
+ const updated = { ...state.conflictsData.files[index] };
+ updated.resolutionData = { ...updated.resolutionData, [id]: section };
+
+ updated.inlineLines = file.inlineLines.map((line) => {
+ if (id === line.id && (line.hasConflict || line.isHeader)) {
+ return markLine(line, section);
+ }
+ return line;
+ });
+
+ updated.parallelLines = file.parallelLines.map((lines) => {
+ let left = { ...lines[0] };
+ let right = { ...lines[1] };
+ const hasSameId = right.id === id || left.id === id;
+ const isLeftMatch = left.hasConflict || left.isHeader;
+ const isRightMatch = right.hasConflict || right.isHeader;
+
+ if (hasSameId && (isLeftMatch || isRightMatch)) {
+ left = markLine(left, section);
+ right = markLine(right, section);
+ }
+ return [left, right];
+ });
+
+ commit(types.UPDATE_FILE, { file: updated, index });
+};
diff --git a/app/assets/javascripts/merge_conflicts/store/getters.js b/app/assets/javascripts/merge_conflicts/store/getters.js
new file mode 100644
index 00000000000..03e425fb478
--- /dev/null
+++ b/app/assets/javascripts/merge_conflicts/store/getters.js
@@ -0,0 +1,117 @@
+import { s__ } from '~/locale';
+import { CONFLICT_TYPES, EDIT_RESOLVE_MODE, INTERACTIVE_RESOLVE_MODE } from '../constants';
+
+export const getConflictsCount = (state) => {
+ if (!state.conflictsData.files.length) {
+ return 0;
+ }
+
+ const { files } = state.conflictsData;
+ let count = 0;
+
+ files.forEach((file) => {
+ if (file.type === CONFLICT_TYPES.TEXT) {
+ file.sections.forEach((section) => {
+ if (section.conflict) {
+ count += 1;
+ }
+ });
+ } else {
+ count += 1;
+ }
+ });
+
+ return count;
+};
+
+export const getConflictsCountText = (state, getters) => {
+ const count = getters.getConflictsCount;
+ const text = count > 1 ? s__('MergeConflict|conflicts') : s__('MergeConflict|conflict');
+
+ return `${count} ${text}`;
+};
+
+export const isReadyToCommit = (state) => {
+ const { files } = state.conflictsData;
+ const hasCommitMessage = state.conflictsData.commitMessage.trim().length;
+ let unresolved = 0;
+
+ for (let i = 0, l = files.length; i < l; i += 1) {
+ const file = files[i];
+
+ if (file.resolveMode === INTERACTIVE_RESOLVE_MODE) {
+ let numberConflicts = 0;
+ const resolvedConflicts = Object.keys(file.resolutionData).length;
+
+ // We only check for conflicts type 'text'
+ // since conflicts `text_editor` can´t be resolved in interactive mode
+ if (file.type === CONFLICT_TYPES.TEXT) {
+ for (let j = 0, k = file.sections.length; j < k; j += 1) {
+ if (file.sections[j].conflict) {
+ numberConflicts += 1;
+ }
+ }
+
+ if (resolvedConflicts !== numberConflicts) {
+ unresolved += 1;
+ }
+ }
+ } else if (file.resolveMode === EDIT_RESOLVE_MODE) {
+ // Unlikely to happen since switching to Edit mode saves content automatically.
+ // Checking anyway in case the save strategy changes in the future
+ if (!file.content) {
+ unresolved += 1;
+ // eslint-disable-next-line no-continue
+ continue;
+ }
+ }
+ }
+
+ return !state.isSubmitting && hasCommitMessage && !unresolved;
+};
+
+export const getCommitButtonText = (state) => {
+ const initial = s__('MergeConflict|Commit to source branch');
+ const inProgress = s__('MergeConflict|Committing...');
+
+ return state.isSubmitting ? inProgress : initial;
+};
+
+export const getCommitData = (state) => {
+ let commitData = {};
+
+ commitData = {
+ commit_message: state.conflictsData.commitMessage,
+ files: [],
+ };
+
+ state.conflictsData.files.forEach((file) => {
+ const addFile = {
+ old_path: file.old_path,
+ new_path: file.new_path,
+ };
+
+ if (file.type === CONFLICT_TYPES.TEXT) {
+ // Submit only one data for type of editing
+ if (file.resolveMode === INTERACTIVE_RESOLVE_MODE) {
+ addFile.sections = file.resolutionData;
+ } else if (file.resolveMode === EDIT_RESOLVE_MODE) {
+ addFile.content = file.content;
+ }
+ } else if (file.type === CONFLICT_TYPES.TEXT_EDITOR) {
+ addFile.content = file.content;
+ }
+
+ commitData.files.push(addFile);
+ });
+
+ return commitData;
+};
+
+export const fileTextTypePresent = (state) => {
+ return state.conflictsData?.files.some((f) => f.type === CONFLICT_TYPES.TEXT);
+};
+
+export const getFileIndex = (state) => ({ blobPath }) => {
+ return state.conflictsData.files.findIndex((f) => f.blobPath === blobPath);
+};
diff --git a/app/assets/javascripts/merge_conflicts/store/index.js b/app/assets/javascripts/merge_conflicts/store/index.js
new file mode 100644
index 00000000000..18e3351ed13
--- /dev/null
+++ b/app/assets/javascripts/merge_conflicts/store/index.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+import state from './state';
+
+Vue.use(Vuex);
+
+export const createStore = () =>
+ new Vuex.Store({
+ state,
+ getters,
+ actions,
+ mutations,
+ });
diff --git a/app/assets/javascripts/merge_conflicts/store/mutation_types.js b/app/assets/javascripts/merge_conflicts/store/mutation_types.js
new file mode 100644
index 00000000000..ab80f8e52ad
--- /dev/null
+++ b/app/assets/javascripts/merge_conflicts/store/mutation_types.js
@@ -0,0 +1,8 @@
+export const SET_LOADING_STATE = 'SET_LOADING_STATE';
+export const SET_ERROR_STATE = 'SET_ERROR_STATE';
+export const SET_FAILED_REQUEST = 'SET_FAILED_REQUEST';
+export const SET_VIEW_TYPE = 'SET_VIEW_TYPE';
+export const SET_SUBMIT_STATE = 'SET_SUBMIT_STATE';
+export const SET_CONFLICTS_DATA = 'SET_CONFLICTS_DATA';
+export const UPDATE_FILE = 'UPDATE_FILE';
+export const UPDATE_CONFLICTS_DATA = 'UPDATE_CONFLICTS_DATA';
diff --git a/app/assets/javascripts/merge_conflicts/store/mutations.js b/app/assets/javascripts/merge_conflicts/store/mutations.js
new file mode 100644
index 00000000000..2cee55319eb
--- /dev/null
+++ b/app/assets/javascripts/merge_conflicts/store/mutations.js
@@ -0,0 +1,40 @@
+import { VIEW_TYPES } from '../constants';
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_LOADING_STATE]: (state, value) => {
+ state.isLoading = value;
+ },
+ [types.SET_ERROR_STATE]: (state, value) => {
+ state.hasError = value;
+ },
+ [types.SET_FAILED_REQUEST]: (state, value) => {
+ state.hasError = true;
+ state.conflictsData.errorMessage = value;
+ },
+ [types.SET_VIEW_TYPE]: (state, value) => {
+ state.diffView = value;
+ state.isParallel = value === VIEW_TYPES.PARALLEL;
+ },
+ [types.SET_SUBMIT_STATE]: (state, value) => {
+ state.isSubmitting = value;
+ },
+ [types.SET_CONFLICTS_DATA]: (state, data) => {
+ state.conflictsData = {
+ files: data.files,
+ commitMessage: data.commit_message,
+ sourceBranch: data.source_branch,
+ targetBranch: data.target_branch,
+ shortCommitSha: data.commit_sha.slice(0, 7),
+ };
+ },
+ [types.UPDATE_CONFLICTS_DATA]: (state, payload) => {
+ state.conflictsData = {
+ ...state.conflictsData,
+ ...payload,
+ };
+ },
+ [types.UPDATE_FILE]: (state, { file, index }) => {
+ state.conflictsData.files.splice(index, 1, file);
+ },
+};
diff --git a/app/assets/javascripts/merge_conflicts/store/state.js b/app/assets/javascripts/merge_conflicts/store/state.js
new file mode 100644
index 00000000000..8f700f58e54
--- /dev/null
+++ b/app/assets/javascripts/merge_conflicts/store/state.js
@@ -0,0 +1,13 @@
+import Cookies from 'js-cookie';
+import { VIEW_TYPES } from '../constants';
+
+const diffViewType = Cookies.get('diff_view');
+
+export default () => ({
+ isLoading: true,
+ hasError: false,
+ isSubmitting: false,
+ isParallel: diffViewType === VIEW_TYPES.PARALLEL,
+ diffViewType,
+ conflictsData: {},
+});
diff --git a/app/assets/javascripts/merge_conflicts/utils.js b/app/assets/javascripts/merge_conflicts/utils.js
new file mode 100644
index 00000000000..e42703ef0a5
--- /dev/null
+++ b/app/assets/javascripts/merge_conflicts/utils.js
@@ -0,0 +1,228 @@
+import {
+ ORIGIN_HEADER_TEXT,
+ ORIGIN_BUTTON_TITLE,
+ HEAD_HEADER_TEXT,
+ HEAD_BUTTON_TITLE,
+ DEFAULT_RESOLVE_MODE,
+ CONFLICT_TYPES,
+} from './constants';
+
+export const getFilePath = (file) => {
+ const { old_path, new_path } = file;
+ // eslint-disable-next-line babel/camelcase
+ return old_path === new_path ? new_path : `${old_path} → ${new_path}`;
+};
+
+export const checkLineLengths = ({ left, right }) => {
+ const wLeft = [...left];
+ const wRight = [...right];
+ if (left.length !== right.length) {
+ if (left.length > right.length) {
+ const diff = left.length - right.length;
+ for (let i = 0; i < diff; i += 1) {
+ wRight.push({ lineType: 'emptyLine', richText: '' });
+ }
+ } else {
+ const diff = right.length - left.length;
+ for (let i = 0; i < diff; i += 1) {
+ wLeft.push({ lineType: 'emptyLine', richText: '' });
+ }
+ }
+ }
+ return { left: wLeft, right: wRight };
+};
+
+export const getHeadHeaderLine = (id) => {
+ return {
+ id,
+ richText: HEAD_HEADER_TEXT,
+ buttonTitle: HEAD_BUTTON_TITLE,
+ type: 'new',
+ section: 'head',
+ isHeader: true,
+ isHead: true,
+ isSelected: false,
+ isUnselected: false,
+ };
+};
+
+export const decorateLineForInlineView = (line, id, conflict) => {
+ const { type } = line;
+ return {
+ id,
+ hasConflict: conflict,
+ isHead: type === 'new',
+ isOrigin: type === 'old',
+ hasMatch: type === 'match',
+ richText: line.rich_text,
+ isSelected: false,
+ isUnselected: false,
+ };
+};
+
+export const getLineForParallelView = (line, id, lineType, isHead) => {
+ const { old_line, new_line, rich_text } = line;
+ const hasConflict = lineType === 'conflict';
+
+ return {
+ id,
+ lineType,
+ hasConflict,
+ isHead: hasConflict && isHead,
+ isOrigin: hasConflict && !isHead,
+ hasMatch: lineType === 'match',
+ // eslint-disable-next-line babel/camelcase
+ lineNumber: isHead ? new_line : old_line,
+ section: isHead ? 'head' : 'origin',
+ richText: rich_text,
+ isSelected: false,
+ isUnselected: false,
+ };
+};
+
+export const getOriginHeaderLine = (id) => {
+ return {
+ id,
+ richText: ORIGIN_HEADER_TEXT,
+ buttonTitle: ORIGIN_BUTTON_TITLE,
+ type: 'old',
+ section: 'origin',
+ isHeader: true,
+ isOrigin: true,
+ isSelected: false,
+ isUnselected: false,
+ };
+};
+
+export const setInlineLine = (file) => {
+ const inlineLines = [];
+
+ file.sections.forEach((section) => {
+ let currentLineType = 'new';
+ const { conflict, lines, id } = section;
+
+ if (conflict) {
+ inlineLines.push(getHeadHeaderLine(id));
+ }
+
+ lines.forEach((line) => {
+ const { type } = line;
+
+ if ((type === 'new' || type === 'old') && currentLineType !== type) {
+ currentLineType = type;
+ inlineLines.push({ lineType: 'emptyLine', richText: '' });
+ }
+
+ const decoratedLine = decorateLineForInlineView(line, id, conflict);
+ inlineLines.push(decoratedLine);
+ });
+
+ if (conflict) {
+ inlineLines.push(getOriginHeaderLine(id));
+ }
+ });
+
+ return inlineLines;
+};
+
+export const setParallelLine = (file) => {
+ const parallelLines = [];
+ let linesObj = { left: [], right: [] };
+
+ file.sections.forEach((section) => {
+ const { conflict, lines, id } = section;
+
+ if (conflict) {
+ linesObj.left.push(getOriginHeaderLine(id));
+ linesObj.right.push(getHeadHeaderLine(id));
+ }
+
+ lines.forEach((line) => {
+ const { type } = line;
+
+ if (conflict) {
+ if (type === 'old') {
+ linesObj.left.push(getLineForParallelView(line, id, 'conflict'));
+ } else if (type === 'new') {
+ linesObj.right.push(getLineForParallelView(line, id, 'conflict', true));
+ }
+ } else {
+ const lineType = type || 'context';
+
+ linesObj.left.push(getLineForParallelView(line, id, lineType));
+ linesObj.right.push(getLineForParallelView(line, id, lineType, true));
+ }
+ });
+
+ linesObj = checkLineLengths(linesObj);
+ });
+
+ for (let i = 0, len = linesObj.left.length; i < len; i += 1) {
+ parallelLines.push([linesObj.right[i], linesObj.left[i]]);
+ }
+ return parallelLines;
+};
+
+export const decorateFiles = (files) => {
+ return files.map((file) => {
+ const f = { ...file };
+ f.content = '';
+ f.resolutionData = {};
+ f.promptDiscardConfirmation = false;
+ f.resolveMode = DEFAULT_RESOLVE_MODE;
+ f.filePath = getFilePath(file);
+ f.blobPath = f.blob_path;
+
+ if (f.type === CONFLICT_TYPES.TEXT) {
+ f.showEditor = false;
+ f.loadEditor = false;
+
+ f.inlineLines = setInlineLine(file);
+ f.parallelLines = setParallelLine(file);
+ } else if (f.type === CONFLICT_TYPES.TEXT_EDITOR) {
+ f.showEditor = true;
+ f.loadEditor = true;
+ }
+ return f;
+ });
+};
+
+export const restoreFileLinesState = (file) => {
+ const inlineLines = file.inlineLines.map((line) => {
+ if (line.hasConflict || line.isHeader) {
+ return { ...line, isSelected: false, isUnselected: false };
+ }
+ return { ...line };
+ });
+
+ const parallelLines = file.parallelLines.map((lines) => {
+ const left = { ...lines[0] };
+ const right = { ...lines[1] };
+ const isLeftMatch = left.hasConflict || left.isHeader;
+ const isRightMatch = right.hasConflict || right.isHeader;
+
+ if (isLeftMatch || isRightMatch) {
+ left.isSelected = false;
+ left.isUnselected = false;
+ right.isSelected = false;
+ right.isUnselected = false;
+ }
+ return [left, right];
+ });
+ return { inlineLines, parallelLines };
+};
+
+export const markLine = (line, selection) => {
+ const updated = { ...line };
+ if (selection === 'head' && line.isHead) {
+ updated.isSelected = true;
+ updated.isUnselected = false;
+ } else if (selection === 'origin' && updated.isOrigin) {
+ updated.isSelected = true;
+ updated.isUnselected = false;
+ } else {
+ updated.isSelected = false;
+ updated.isUnselected = true;
+ }
+ return updated;
+};
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 4dab796d8a4..81b9db6b4d5 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -361,10 +361,8 @@ export default class MergeRequestTabs {
return createElement(CommitPipelinesTable, {
props: {
endpoint: pipelineTableViewEl.dataset.endpoint,
- helpPagePath: pipelineTableViewEl.dataset.helpPagePath,
emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath,
errorStateSvgPath: pipelineTableViewEl.dataset.errorStateSvgPath,
- autoDevopsHelpPath: pipelineTableViewEl.dataset.helpAutoDevopsPath,
canCreatePipelineInTargetProject: Boolean(
mrWidgetData?.can_create_pipeline_in_target_project,
),
diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js
index f249fef5ea4..280613bda49 100644
--- a/app/assets/javascripts/milestone.js
+++ b/app/assets/javascripts/milestone.js
@@ -2,7 +2,6 @@ import $ from 'jquery';
import { deprecatedCreateFlash as flash } from './flash';
import axios from './lib/utils/axios_utils';
import { __ } from './locale';
-import { mouseenter, debouncedMouseleave, togglePopover } from './shared/popover';
export default class Milestone {
constructor() {
@@ -43,30 +42,4 @@ export default class Milestone {
.catch(() => flash(__('Error loading milestone tab')));
}
}
-
- static initDeprecationMessage() {
- const deprecationMesssageContainer = document.querySelector(
- '.js-milestone-deprecation-message',
- );
-
- if (!deprecationMesssageContainer) return;
-
- const deprecationMessage = deprecationMesssageContainer.querySelector(
- '.js-milestone-deprecation-message-template',
- ).innerHTML;
- const $popover = $('.js-popover-link', deprecationMesssageContainer);
- const hideOnScroll = togglePopover.bind($popover, false);
-
- $popover
- .popover({
- content: deprecationMessage,
- html: true,
- placement: 'bottom',
- })
- .on('mouseenter', mouseenter)
- .on('mouseleave', debouncedMouseleave())
- .on('show.bs.popover', () => {
- window.addEventListener('scroll', hideOnScroll, { once: true });
- });
- }
}
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index 4cbe0a53307..f4b60fc0961 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -119,7 +119,7 @@ export default class MilestoneSelect {
title: __('Any milestone'),
});
}
- if (showNo) {
+ if (showNo && term.trim() === '') {
extraOptions.push({
id: -1,
name: __('No milestone'),
diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
index 8569a67da34..a995497ab9c 100644
--- a/app/assets/javascripts/monitoring/components/charts/anomaly.vue
+++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
@@ -86,7 +86,6 @@ export default {
const originalMetricQuery = this.graphData.metrics[0];
const metricQuery = produce(originalMetricQuery, (draftQuery) => {
- // eslint-disable-next-line no-param-reassign
draftQuery.result[0].values = draftQuery.result[0].values.map(([x, y]) => [
x,
y + this.yOffset,
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 8522ac6a57d..a0b4fd0b608 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -1,7 +1,7 @@
+import * as Sentry from '@sentry/browser';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
-import * as Sentry from '~/sentry/wrapper';
import { convertObjectPropsToCamelCase } from '../../lib/utils/common_utils';
import { s__, sprintf } from '../../locale';
import { ENVIRONMENT_AVAILABLE_STATE, OVERVIEW_DASHBOARD_PATH, VARIABLE_TYPES } from '../constants';
diff --git a/app/assets/javascripts/monitoring/stores/embed_group/index.js b/app/assets/javascripts/monitoring/stores/embed_group/index.js
index 773bca9f87e..66c65adc413 100644
--- a/app/assets/javascripts/monitoring/stores/embed_group/index.js
+++ b/app/assets/javascripts/monitoring/stores/embed_group/index.js
@@ -20,5 +20,3 @@ export const createStore = () =>
},
},
});
-
-export default createStore();
diff --git a/app/assets/javascripts/network/branch_graph.js b/app/assets/javascripts/network/branch_graph.js
index 1f360e84f68..6ee5d85a09f 100644
--- a/app/assets/javascripts/network/branch_graph.js
+++ b/app/assets/javascripts/network/branch_graph.js
@@ -234,8 +234,7 @@ export default class BranchGraph {
appendAnchor(x, y, commit) {
const { r, top, options } = this;
- const anchor = r
- .circle(x, y, 10)
+ r.circle(x, y, 10)
.attr({
fill: '#000',
opacity: 0,
@@ -245,13 +244,14 @@ export default class BranchGraph {
.hover(
function () {
this.tooltip = r.commitTooltip(x + 5, y, commit);
- return top.push(this.tooltip.insertBefore(this));
+ top.push(this.tooltip.insertBefore(this));
+ return this.tooltip.toFront();
},
function () {
+ top.remove(this.tooltip);
return this.tooltip && this.tooltip.remove() && delete this.tooltip;
},
);
- return top.push(anchor);
}
drawDot(x, y, commit) {
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 50db3b86025..08d7c745791 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -1,39 +1,61 @@
<script>
-import { GlButton, GlIcon, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlButton,
+ GlIcon,
+ GlFormCheckbox,
+ GlTooltipDirective,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+} from '@gitlab/ui';
import Autosize from 'autosize';
import $ from 'jquery';
import { mapActions, mapGetters, mapState } from 'vuex';
import Autosave from '~/autosave';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import { deprecatedCreateFlash as Flash } from '~/flash';
+import httpStatusCodes from '~/lib/utils/http_status';
import {
capitalizeFirstCharacter,
convertToCamelCase,
splitCamelCase,
slugifyWithUnderscore,
} from '~/lib/utils/text_utility';
-import { __, sprintf } from '~/locale';
+import { sprintf } from '~/locale';
import markdownField from '~/vue_shared/components/markdown/field.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
import * as constants from '../constants';
import eventHub from '../event_hub';
+import { COMMENT_FORM } from '../i18n';
+
import issuableStateMixin from '../mixins/issuable_state';
import CommentFieldLayout from './comment_field_layout.vue';
import discussionLockedWidget from './discussion_locked_widget.vue';
import noteSignedOutWidget from './note_signed_out_widget.vue';
+const { UNPROCESSABLE_ENTITY } = httpStatusCodes;
+
export default {
name: 'CommentForm',
+ i18n: COMMENT_FORM,
+ noteTypeComment: constants.COMMENT,
+ noteTypeDiscussion: constants.DISCUSSION,
components: {
noteSignedOutWidget,
discussionLockedWidget,
markdownField,
+ GlAlert,
GlButton,
TimelineEntryItem,
GlIcon,
CommentFieldLayout,
GlFormCheckbox,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -49,6 +71,7 @@ export default {
return {
note: '',
noteType: constants.COMMENT,
+ errors: [],
noteIsConfidential: false,
isSubmitting: false,
};
@@ -63,6 +86,12 @@ export default {
'openState',
]),
...mapState(['isToggleStateButtonLoading']),
+ isNoteTypeComment() {
+ return this.noteType === constants.COMMENT;
+ },
+ isNoteTypeDiscussion() {
+ return this.noteType === constants.DISCUSSION;
+ },
noteableDisplayName() {
return splitCamelCase(this.noteableType).toLowerCase();
},
@@ -70,12 +99,19 @@ export default {
return this.getUserData.id;
},
commentButtonTitle() {
- return this.noteType === constants.COMMENT ? __('Comment') : __('Start thread');
+ return this.noteType === constants.COMMENT
+ ? this.$options.i18n.comment
+ : this.$options.i18n.startThread;
},
startDiscussionDescription() {
return this.getNoteableData.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE
- ? __('Discuss a specific suggestion or question that needs to be resolved.')
- : __('Discuss a specific suggestion or question.');
+ ? this.$options.i18n.discussionThatNeedsResolution
+ : this.$options.i18n.discussion;
+ },
+ commentDescription() {
+ return sprintf(this.$options.i18n.submitButton.commentHelp, {
+ noteableDisplayName: this.noteableDisplayName,
+ });
},
isOpen() {
return this.openState === constants.OPENED || this.openState === constants.REOPENED;
@@ -90,21 +126,18 @@ export default {
const openOrClose = this.isOpen ? 'close' : 'reopen';
if (this.note.length) {
- return sprintf(__('%{actionText} & %{openOrClose} %{noteable}'), {
+ return sprintf(this.$options.i18n.actionButtonWithNote, {
actionText: this.commentButtonTitle,
openOrClose,
noteable: this.noteableDisplayName,
});
}
- return sprintf(__('%{openOrClose} %{noteable}'), {
+ return sprintf(this.$options.i18n.actionButton, {
openOrClose: capitalizeFirstCharacter(openOrClose),
noteable: this.noteableDisplayName,
});
},
- buttonVariant() {
- return this.isOpen ? 'warning' : 'default';
- },
actionButtonClassNames() {
return {
'btn-reopen': !this.isOpen,
@@ -140,8 +173,8 @@ export default {
},
issuableTypeTitle() {
return this.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE
- ? __('merge request')
- : __('issue');
+ ? this.$options.i18n.mergeRequest
+ : this.$options.i18n.issue;
},
isIssue() {
return this.noteableDisplayName === constants.ISSUE_NOTEABLE_TYPE;
@@ -149,9 +182,6 @@ export default {
trackingLabel() {
return slugifyWithUnderscore(`${this.commentButtonTitle} button`);
},
- hasCloseAndCommentButton() {
- return !this.glFeatures.removeCommentCloseReopen;
- },
confidentialNotesEnabled() {
return Boolean(this.glFeatures.confidentialNotes);
},
@@ -177,11 +207,19 @@ export default {
'reopenIssuable',
'toggleIssueLocalState',
]),
+ handleSaveError({ data, status }) {
+ if (status === UNPROCESSABLE_ENTITY && data.errors?.commands_only?.length) {
+ this.errors = data.errors.commands_only;
+ } else {
+ this.errors = [this.$options.i18n.GENERIC_UNSUBMITTABLE_NETWORK];
+ }
+ },
handleSave(withIssueAction) {
+ this.errors = [];
+
if (this.note.length) {
const noteData = {
endpoint: this.endpoint,
- flashContainer: this.$el,
data: {
note: {
noteable_type: this.noteableType,
@@ -212,12 +250,10 @@ export default {
this.toggleIssueState();
}
})
- .catch(() => {
+ .catch(({ response }) => {
+ this.handleSaveError(response);
+
this.discard(false);
- const msg = __(
- 'Your comment could not be submitted! Please check your network connection and try again.',
- );
- Flash(msg, 'alert', this.$el);
this.note = noteData.data.note.note; // Restore textarea content.
this.removePlaceholderNotes();
})
@@ -260,6 +296,12 @@ export default {
setNoteType(type) {
this.noteType = type;
},
+ setNoteTypeToComment() {
+ this.setNoteType(constants.COMMENT);
+ },
+ setNoteTypeToDiscussion() {
+ this.setNoteType(constants.DISCUSSION);
+ },
editCurrentUserLastNote() {
if (this.note === '') {
const lastNote = this.getCurrentUserLastNote;
@@ -276,7 +318,7 @@ export default {
const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType));
this.autosave = new Autosave($(this.$refs.textarea), [
- __('Note'),
+ this.$options.i18n.note,
noteableType,
this.getNoteableData.id,
]);
@@ -290,6 +332,9 @@ export default {
hasEmailParticipants() {
return this.getNoteableData.issue_email_participants?.length;
},
+ dismissError(index) {
+ this.errors.splice(index, 1);
+ },
},
};
</script>
@@ -300,7 +345,15 @@ export default {
<discussion-locked-widget v-else-if="!canCreateNote" :issuable-type="issuableTypeTitle" />
<ul v-else-if="canCreateNote" class="notes notes-form timeline">
<timeline-entry-item class="note-form">
- <div class="flash-container error-alert timeline-content"></div>
+ <gl-alert
+ v-for="(error, index) in errors"
+ :key="index"
+ variant="danger"
+ class="gl-mb-2"
+ @dismiss="() => dismissError(index)"
+ >
+ {{ error }}
+ </gl-alert>
<div class="timeline-content timeline-content-form">
<form ref="commentForm" class="new-note common-note-form gfm-form js-main-target-form">
<comment-field-layout
@@ -329,8 +382,8 @@ export default {
data-qa-selector="comment_field"
data-testid="comment-field"
:data-supports-quick-actions="!glFeatures.tributeAutocomplete"
- :aria-label="__('Description')"
- :placeholder="__('Write a comment or drag your files here…')"
+ :aria-label="$options.i18n.comment"
+ :placeholder="$options.i18n.bodyPlaceholder"
@keydown.up="editCurrentUserLastNote()"
@keydown.meta.enter="handleSave()"
@keydown.ctrl.enter="handleSave()"
@@ -345,87 +398,52 @@ export default {
class="gl-mb-6"
data-testid="confidential-note-checkbox"
>
- {{ s__('Notes|Make this comment confidential') }}
+ {{ $options.i18n.confidential }}
<gl-icon
v-gl-tooltip:tooltipcontainer.bottom
name="question"
:size="16"
- :title="s__('Notes|Confidential comments are only visible to project members')"
+ :title="$options.i18n.confidentialVisibility"
class="gl-text-gray-500"
/>
</gl-form-checkbox>
- <div
- class="btn-group gl-mr-3 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
+ <gl-dropdown
+ split
+ :text="commentButtonTitle"
+ class="gl-mr-3 js-comment-button js-comment-submit-button comment-type-dropdown"
+ category="primary"
+ variant="success"
+ :disabled="disableSubmitButton"
+ data-testid="comment-button"
+ data-qa-selector="comment_button"
+ :data-track-label="trackingLabel"
+ data-track-event="click_button"
+ @click="handleSave()"
>
- <gl-button
- :disabled="disableSubmitButton"
- class="js-comment-button js-comment-submit-button"
- data-qa-selector="comment_button"
- data-testid="comment-button"
- type="submit"
- category="primary"
- variant="success"
- :data-track-label="trackingLabel"
- data-track-event="click_button"
- @click.prevent="handleSave()"
- >{{ commentButtonTitle }}</gl-button
+ <gl-dropdown-item
+ is-check-item
+ :is-checked="isNoteTypeComment"
+ :selected="isNoteTypeComment"
+ @click="setNoteTypeToComment"
>
- <gl-button
- :disabled="disableSubmitButton"
- name="button"
- category="primary"
- variant="success"
- class="note-type-toggle js-note-new-discussion dropdown-toggle"
- data-qa-selector="note_dropdown"
- data-display="static"
- data-toggle="dropdown"
- icon="chevron-down"
- :aria-label="__('Open comment type dropdown')"
- />
-
- <ul class="note-type-dropdown dropdown-open-top dropdown-menu">
- <li :class="{ 'droplab-item-selected': noteType === 'comment' }">
- <button
- type="button"
- class="btn btn-transparent"
- @click.prevent="setNoteType('comment')"
- >
- <gl-icon name="check" class="icon gl-flex-shrink-0" />
- <div class="description">
- <strong>{{ __('Comment') }}</strong>
- <p>
- {{
- sprintf(__('Add a general comment to this %{noteableDisplayName}.'), {
- noteableDisplayName,
- })
- }}
- </p>
- </div>
- </button>
- </li>
- <li class="divider droplab-item-ignore"></li>
- <li :class="{ 'droplab-item-selected': noteType === 'discussion' }">
- <button
- type="button"
- class="btn btn-transparent"
- data-qa-selector="discussion_menu_item"
- @click.prevent="setNoteType('discussion')"
- >
- <gl-icon name="check" class="icon gl-flex-shrink-0" />
- <div class="description">
- <strong>{{ __('Start thread') }}</strong>
- <p>{{ startDiscussionDescription }}</p>
- </div>
- </button>
- </li>
- </ul>
- </div>
-
+ <strong>{{ $options.i18n.submitButton.comment }}</strong>
+ <p class="gl-m-0">{{ commentDescription }}</p>
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ <gl-dropdown-item
+ is-check-item
+ :is-checked="isNoteTypeDiscussion"
+ :selected="isNoteTypeDiscussion"
+ data-qa-selector="discussion_menu_item"
+ @click="setNoteTypeToDiscussion"
+ >
+ <strong>{{ $options.i18n.submitButton.startThread }}</strong>
+ <p class="gl-m-0">{{ startDiscussionDescription }}</p>
+ </gl-dropdown-item>
+ </gl-dropdown>
<gl-button
- v-if="hasCloseAndCommentButton && canToggleIssueState"
+ v-if="canToggleIssueState"
:loading="isToggleStateButtonLoading"
- category="secondary"
- :variant="buttonVariant"
:class="[actionButtonClassNames, 'btn-comment btn-comment-and-close']"
:disabled="isSubmitting"
data-testid="close-reopen-button"
diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue
index 27408bc3354..6f0745d4fb0 100644
--- a/app/assets/javascripts/notes/components/discussion_actions.vue
+++ b/app/assets/javascripts/notes/components/discussion_actions.vue
@@ -50,8 +50,8 @@ export default {
<div class="discussion-with-resolve-btn clearfix">
<reply-placeholder
data-qa-selector="discussion_reply_tab"
- :button-text="s__('MergeRequests|Reply...')"
- @onClick="$emit('showReplyForm')"
+ :placeholder-text="__('Reply…')"
+ @focus="$emit('showReplyForm')"
/>
<div v-if="userCanResolveDiscussion" class="btn-group discussion-actions" role="group">
diff --git a/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue b/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue
index dfeda4aae7c..663163a7552 100644
--- a/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue
+++ b/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue
@@ -20,7 +20,7 @@ export default {
'li',
{
class:
- 'discussion-collapsible gl-border-solid gl-border-gray-100 gl-border-1 gl-rounded-base gl-overflow-hidden clearfix',
+ 'discussion-collapsible gl-border-solid gl-border-gray-100 gl-border-1 gl-rounded-base clearfix',
},
[h('ul', { class: 'notes' }, children)],
);
diff --git a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue
index 0204169214b..1165a869d2b 100644
--- a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue
+++ b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue
@@ -1,23 +1,30 @@
<script>
+import { __ } from '~/locale';
+
export default {
name: 'ReplyPlaceholder',
props: {
- buttonText: {
+ placeholderText: {
+ type: String,
+ required: false,
+ default: __('Reply…'),
+ },
+ labelText: {
type: String,
- required: true,
+ required: false,
+ default: __('Reply to comment'),
},
},
};
</script>
<template>
- <button
- ref="button"
- type="button"
- class="js-vue-discussion-reply btn btn-text-field"
- :title="s__('MergeRequests|Add a reply')"
- @click="$emit('onClick')"
- >
- {{ buttonText }}
- </button>
+ <textarea
+ ref="textarea"
+ rows="1"
+ class="reply-placeholder-text-field js-vue-discussion-reply"
+ :placeholder="placeholderText"
+ :aria-label="labelText"
+ @focus="$emit('focus')"
+ ></textarea>
</template>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index 907a4316a93..ed6701b34e8 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -1,12 +1,14 @@
<script>
import { GlTooltipDirective, GlIcon, GlButton, GlDropdownItem } from '@gitlab/ui';
-import { mapGetters } from 'vuex';
+import { mapActions, mapGetters } from 'vuex';
import Api from '~/api';
import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status';
import { deprecatedCreateFlash as flash } from '~/flash';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { __, sprintf } from '~/locale';
import eventHub from '~/sidebar/event_hub';
+import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { splitCamelCase } from '../../lib/utils/text_utility';
import ReplyButton from './note_actions/reply_button.vue';
@@ -17,11 +19,13 @@ export default {
ReplyButton,
GlButton,
GlDropdownItem,
+ UserAccessRoleBadge,
+ EmojiPicker: () => import('~/emoji/components/picker.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [resolvedStatusMixin],
+ mixins: [resolvedStatusMixin, glFeatureFlagsMixin()],
props: {
author: {
type: Object,
@@ -115,6 +119,10 @@ export default {
type: Boolean,
required: true,
},
+ awardPath: {
+ type: String,
+ required: true,
+ },
},
computed: {
...mapGetters(['getUserDataByProp', 'getNoteableData']),
@@ -183,6 +191,7 @@ export default {
},
},
methods: {
+ ...mapActions(['toggleAwardRequest']),
onEdit() {
this.$emit('handleEdit');
},
@@ -220,30 +229,43 @@ export default {
.catch(() => flash(__('Something went wrong while updating assignees')));
}
},
+ setAwardEmoji(awardName) {
+ this.toggleAwardRequest({
+ endpoint: this.awardPath,
+ noteId: this.noteId,
+ awardName,
+ });
+ },
},
};
</script>
<template>
<div class="note-actions">
- <span
+ <user-access-role-badge
v-if="isAuthor"
- class="note-role user-access-role has-tooltip d-none d-md-inline-block"
+ v-gl-tooltip
+ class="gl-mx-3 d-none d-md-inline-block"
:title="displayAuthorBadgeText"
- >{{ __('Author') }}</span
>
- <span
+ {{ __('Author') }}
+ </user-access-role-badge>
+ <user-access-role-badge
v-if="accessLevel"
- class="note-role user-access-role has-tooltip"
+ v-gl-tooltip
+ class="gl-mx-3"
:title="displayMemberBadgeText"
- >{{ accessLevel }}</span
>
- <span
+ {{ accessLevel }}
+ </user-access-role-badge>
+ <user-access-role-badge
v-else-if="isContributor"
- class="note-role user-access-role has-tooltip"
+ v-gl-tooltip
+ class="gl-mx-3"
:title="displayContributorBadgeText"
- >{{ __('Contributor') }}</span
>
+ {{ __('Contributor') }}
+ </user-access-role-badge>
<gl-button
v-if="canResolve"
ref="resolveButton"
@@ -259,19 +281,41 @@ export default {
class="line-resolve-btn note-action-button"
@click="onResolve"
/>
- <a
- v-if="canAwardEmoji"
- v-gl-tooltip
- :class="{ 'js-user-authored': isAuthoredByCurrentUser }"
- class="note-action-button note-emoji-button js-add-award js-note-emoji gl-text-gray-600 gl-m-2"
- href="#"
- title="Add reaction"
- data-position="right"
- >
- <gl-icon class="link-highlight award-control-icon-neutral" name="slight-smile" />
- <gl-icon class="link-highlight award-control-icon-positive" name="smiley" />
- <gl-icon class="link-highlight award-control-icon-super-positive" name="smile" />
- </a>
+ <template v-if="canAwardEmoji">
+ <emoji-picker
+ v-if="glFeatures.improvedEmojiPicker"
+ toggle-class="note-action-button note-emoji-button gl-text-gray-600 gl-m-2 gl-p-0! gl-shadow-none! gl-bg-transparent!"
+ @click="setAwardEmoji"
+ >
+ <template #button-content>
+ <gl-icon class="link-highlight award-control-icon-neutral gl-m-0!" name="slight-smile" />
+ <gl-icon class="link-highlight award-control-icon-positive gl-m-0!" name="smiley" />
+ <gl-icon class="link-highlight award-control-icon-super-positive gl-m-0!" name="smile" />
+ </template>
+ </emoji-picker>
+ <gl-button
+ v-else
+ v-gl-tooltip
+ :class="{ 'js-user-authored': isAuthoredByCurrentUser }"
+ class="note-action-button note-emoji-button add-reaction-button js-add-award js-note-emoji"
+ category="tertiary"
+ variant="default"
+ size="small"
+ title="Add reaction"
+ data-position="right"
+ :aria-label="__('Add reaction')"
+ >
+ <span class="reaction-control-icon reaction-control-icon-neutral">
+ <gl-icon name="slight-smile" />
+ </span>
+ <span class="reaction-control-icon reaction-control-icon-positive">
+ <gl-icon name="smiley" />
+ </span>
+ <span class="reaction-control-icon reaction-control-icon-super-positive">
+ <gl-icon name="smile" />
+ </span>
+ </gl-button>
+ </template>
<reply-button
v-if="showReply"
ref="replyButton"
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 653bc450d0b..d74ade15de1 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -201,7 +201,7 @@ export default {
changedCommentText() {
return sprintf(
__(
- 'This comment has changed since you started editing, please review the %{startTag}updated comment%{endTag} to ensure information is not lost.',
+ 'This comment changed after you started editing it. Review the %{startTag}updated comment%{endTag} to ensure information is not lost.',
),
{
startTag: `<a href="${this.noteHash}" target="_blank" rel="noopener noreferrer">`,
@@ -345,7 +345,7 @@ export default {
class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form"
data-qa-selector="reply_field"
dir="auto"
- :aria-label="__('Description')"
+ :aria-label="__('Reply to comment')"
:placeholder="__('Write a comment or drag your files here…')"
@keydown.meta.enter="handleKeySubmit()"
@keydown.ctrl.enter="handleKeySubmit()"
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 4343fac3cfa..185f4a70367 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -1,7 +1,7 @@
<script>
import { GlSprintf, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import $ from 'jquery';
-import { escape } from 'lodash';
+import { escape, isEmpty } from 'lodash';
import { mapGetters, mapActions } from 'vuex';
import { INLINE_DIFF_LINES_KEY } from '~/diffs/constants';
import httpStatusCodes from '~/lib/utils/http_status';
@@ -282,9 +282,13 @@ export default {
note: {
target_type: this.getNoteableData.targetType,
target_id: this.note.noteable_id,
- note: { note: noteText, position: JSON.stringify(position) },
+ note: { note: noteText },
},
};
+
+ // Stringifying an empty object yields `{}` which breaks graphql queries
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/298827
+ if (!isEmpty(position)) data.note.note.position = JSON.stringify(position);
this.isRequesting = true;
this.oldContent = this.note.note_html;
// eslint-disable-next-line vue/no-mutating-props
@@ -416,6 +420,7 @@ export default {
:is-draft="note.isDraft"
:resolve-discussion="note.isDraft && note.resolve_discussion"
:discussion-id="discussionId"
+ :award-path="note.toggle_award_path"
@handleEdit="editHandler"
@handleDelete="deleteHandler"
@handleResolve="resolveHandler"
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 2d66e0d24e3..58cfd150659 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -17,6 +17,7 @@ import commentForm from './comment_form.vue';
import discussionFilterNote from './discussion_filter_note.vue';
import noteableDiscussion from './noteable_discussion.vue';
import noteableNote from './noteable_note.vue';
+import SidebarSubscription from './sidebar_subscription.vue';
export default {
name: 'NotesApp',
@@ -30,6 +31,7 @@ export default {
skeletonLoadingContainer,
discussionFilterNote,
OrderedLayout,
+ SidebarSubscription,
},
mixins: [glFeatureFlagsMixin()],
props: {
@@ -261,6 +263,7 @@ export default {
<template>
<div v-show="shouldShow" id="notes">
+ <sidebar-subscription :iid="noteableData.iid" :noteable-data="noteableData" />
<ordered-layout :slot-keys="slotKeys">
<template #form>
<comment-form
diff --git a/app/assets/javascripts/notes/components/sidebar_subscription.vue b/app/assets/javascripts/notes/components/sidebar_subscription.vue
new file mode 100644
index 00000000000..047c04c8482
--- /dev/null
+++ b/app/assets/javascripts/notes/components/sidebar_subscription.vue
@@ -0,0 +1,58 @@
+<script>
+import { mapActions } from 'vuex';
+import { IssuableType } from '~/issue_show/constants';
+import { fetchPolicies } from '~/lib/graphql';
+import { confidentialityQueries } from '~/sidebar/constants';
+import { defaultClient as gqlClient } from '~/sidebar/graphql';
+
+export default {
+ props: {
+ noteableData: {
+ type: Object,
+ required: true,
+ },
+ iid: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ fullPath() {
+ if (this.noteableData.web_url) {
+ return this.noteableData.web_url.split('/-/')[0].substring(1).replace('groups/', '');
+ }
+ return null;
+ },
+ issuableType() {
+ return this.noteableData.noteableType.toLowerCase();
+ },
+ },
+ created() {
+ if (this.issuableType !== IssuableType.Issue && this.issuableType !== IssuableType.Epic) {
+ return;
+ }
+
+ gqlClient
+ .watchQuery({
+ query: confidentialityQueries[this.issuableType].query,
+ variables: {
+ iid: String(this.iid),
+ fullPath: this.fullPath,
+ },
+ fetchPolicy: fetchPolicies.CACHE_ONLY,
+ })
+ .subscribe((res) => {
+ const issuable = res.data?.workspace?.issuable;
+ if (issuable) {
+ this.setConfidentiality(issuable.confidential);
+ }
+ });
+ },
+ methods: {
+ ...mapActions(['setConfidentiality']),
+ },
+ render() {
+ return null;
+ },
+};
+</script>
diff --git a/app/assets/javascripts/notes/i18n.js b/app/assets/javascripts/notes/i18n.js
new file mode 100644
index 00000000000..1ffb94d11ad
--- /dev/null
+++ b/app/assets/javascripts/notes/i18n.js
@@ -0,0 +1,26 @@
+import { __, s__ } from '~/locale';
+
+export const COMMENT_FORM = {
+ GENERIC_UNSUBMITTABLE_NETWORK: __(
+ 'Your comment could not be submitted! Please check your network connection and try again.',
+ ),
+ note: __('Note'),
+ comment: __('Comment'),
+ issue: __('issue'),
+ startThread: __('Start thread'),
+ mergeRequest: __('merge request'),
+ bodyPlaceholder: __('Write a comment or drag your files here…'),
+ confidential: s__('Notes|Make this comment confidential'),
+ confidentialVisibility: s__('Notes|Confidential comments are only visible to project members'),
+ discussionThatNeedsResolution: __(
+ 'Discuss a specific suggestion or question that needs to be resolved.',
+ ),
+ discussion: __('Discuss a specific suggestion or question.'),
+ actionButtonWithNote: __('%{actionText} & %{openOrClose} %{noteable}'),
+ actionButton: __('%{openOrClose} %{noteable}'),
+ submitButton: {
+ startThread: __('Start thread'),
+ comment: __('Comment'),
+ commentHelp: __('Add a general comment to this %{noteableDisplayName}.'),
+ },
+};
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 19403c29cda..1204d68159f 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -2,9 +2,10 @@ import $ from 'jquery';
import Visibility from 'visibilityjs';
import Vue from 'vue';
import Api from '~/api';
+import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
import axios from '~/lib/utils/axios_utils';
import { __, sprintf } from '~/locale';
-import updateIssueConfidentialMutation from '~/sidebar/components/confidential/mutations/update_issue_confidential.mutation.graphql';
+import { confidentialWidget } from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql';
import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql';
import loadAwardsHandler from '../../awards_handler';
@@ -267,7 +268,7 @@ export const toggleStateButtonLoading = ({ commit }, value) =>
commit(types.TOGGLE_STATE_BUTTON_LOADING, value);
export const emitStateChangedEvent = ({ getters }, data) => {
- const event = new CustomEvent('issuable_vue_app:change', {
+ const event = new CustomEvent(EVENT_ISSUABLE_VUE_APP_CHANGE, {
detail: {
data,
isClosed: getters.openState === constants.CLOSED,
@@ -340,6 +341,15 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
if (hasQuickActions && message) {
eTagPoll.makeRequest();
+ // synchronizing the quick action with the sidebar widget
+ // this is a temporary solution until we have confidentiality real-time updates
+ if (
+ confidentialWidget.setConfidentiality &&
+ message.some((m) => m.includes('confidential'))
+ ) {
+ confidentialWidget.setConfidentiality();
+ }
+
$('.js-gfm-input').trigger('clear-commands-cache.atwho');
Flash(message || __('Commands applied'), 'notice', noteData.flashContainer);
@@ -719,33 +729,3 @@ export const updateAssignees = ({ commit }, assignees) => {
export const updateDiscussionPosition = ({ commit }, updatedPosition) => {
commit(types.UPDATE_DISCUSSION_POSITION, updatedPosition);
};
-
-export const updateConfidentialityOnIssuable = (
- { getters, commit },
- { confidential, fullPath },
-) => {
- const { iid } = getters.getNoteableData;
-
- return utils.gqClient
- .mutate({
- mutation: updateIssueConfidentialMutation,
- variables: {
- input: {
- projectPath: fullPath,
- iid: String(iid),
- confidential,
- },
- },
- })
- .then(({ data }) => {
- const {
- issueSetConfidential: { issue, errors },
- } = data;
-
- if (errors?.length) {
- Flash(errors[0], 'alert');
- } else {
- setConfidentiality({ commit }, issue.confidential);
- }
- });
-};
diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js
index 627e405c75c..592e634e034 100644
--- a/app/assets/javascripts/notes/stores/utils.js
+++ b/app/assets/javascripts/notes/stores/utils.js
@@ -1,4 +1,4 @@
-import { trimFirstCharOfLineContent } from '~/diffs/store/utils';
+import { trimFirstCharOfLineContent } from '~/diffs/store/utils'; // eslint-disable-line import/no-deprecated
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import AjaxCache from '~/lib/utils/ajax_cache';
import { sprintf, __ } from '~/locale';
@@ -34,7 +34,7 @@ export const hasQuickActions = (note) => createQuickActionsRegex().test(note);
export const stripQuickActions = (note) => note.replace(createQuickActionsRegex(), '').trim();
export const prepareDiffLines = (diffLines) =>
- diffLines.map((line) => ({ ...trimFirstCharOfLineContent(line) }));
+ diffLines.map((line) => ({ ...trimFirstCharOfLineContent(line) })); // eslint-disable-line import/no-deprecated
export const gqClient = createGqClient(
{},
diff --git a/app/assets/javascripts/notifications/components/custom_notifications_modal.vue b/app/assets/javascripts/notifications/components/custom_notifications_modal.vue
index 0f628897e17..2b5cff35fc8 100644
--- a/app/assets/javascripts/notifications/components/custom_notifications_modal.vue
+++ b/app/assets/javascripts/notifications/components/custom_notifications_modal.vue
@@ -24,12 +24,21 @@ export default {
default: '',
},
},
+ model: {
+ prop: 'visible',
+ event: 'change',
+ },
props: {
modalId: {
type: String,
required: false,
default: 'custom-notifications-modal',
},
+ visible: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -95,9 +104,11 @@ export default {
<template>
<gl-modal
ref="modal"
+ :visible="visible"
:modal-id="modalId"
:title="$options.i18n.customNotificationsModal.title"
@show="onOpen"
+ v-on="$listeners"
>
<div class="container-fluid">
<div class="row">
@@ -115,7 +126,11 @@ export default {
<gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3" />
<template v-else>
<gl-form-group v-for="event in events" :key="event.id">
- <gl-form-checkbox v-model="event.enabled" @change="updateEvent($event, event)">
+ <gl-form-checkbox
+ v-model="event.enabled"
+ :data-testid="`notification-setting-${event.id}`"
+ @change="updateEvent($event, event)"
+ >
<strong>{{ event.name }}</strong
><gl-loading-icon v-if="event.loading" :inline="true" class="gl-ml-2" />
</gl-form-checkbox>
diff --git a/app/assets/javascripts/notifications/components/notifications_dropdown.vue b/app/assets/javascripts/notifications/components/notifications_dropdown.vue
index e4cedfdb810..4963b9386c1 100644
--- a/app/assets/javascripts/notifications/components/notifications_dropdown.vue
+++ b/app/assets/javascripts/notifications/components/notifications_dropdown.vue
@@ -1,12 +1,5 @@
<script>
-import {
- GlButtonGroup,
- GlButton,
- GlDropdown,
- GlDropdownDivider,
- GlTooltipDirective,
- GlModalDirective,
-} from '@gitlab/ui';
+import { GlDropdown, GlDropdownDivider, GlTooltipDirective } from '@gitlab/ui';
import Api from '~/api';
import { sprintf } from '~/locale';
import { CUSTOM_LEVEL, i18n } from '../constants';
@@ -16,8 +9,6 @@ import NotificationsDropdownItem from './notifications_dropdown_item.vue';
export default {
name: 'NotificationsDropdown',
components: {
- GlButtonGroup,
- GlButton,
GlDropdown,
GlDropdownDivider,
NotificationsDropdownItem,
@@ -25,7 +16,6 @@ export default {
},
directives: {
GlTooltip: GlTooltipDirective,
- 'gl-modal': GlModalDirective,
},
inject: {
containerClass: {
@@ -57,6 +47,7 @@ export default {
return {
selectedNotificationLevel: this.initialNotificationLevel,
isLoading: false,
+ notificationsModalVisible: false,
};
},
computed: {
@@ -95,6 +86,11 @@ export default {
},
},
methods: {
+ openNotificationsModal() {
+ if (this.isCustomNotification) {
+ this.notificationsModalVisible = true;
+ }
+ },
selectItem(level) {
if (level !== this.selectedNotificationLevel) {
this.updateNotificationLevel(level);
@@ -106,10 +102,7 @@ export default {
try {
await Api.updateNotificationSettings(this.projectId, this.groupId, { level });
this.selectedNotificationLevel = level;
-
- if (level === CUSTOM_LEVEL) {
- this.$refs.customNotificationsModal.open();
- }
+ this.openNotificationsModal();
} catch (error) {
this.$toast.show(this.$options.i18n.updateNotificationLevelErrorMessage, { type: 'error' });
} finally {
@@ -125,52 +118,16 @@ export default {
<template>
<div :class="containerClass">
- <gl-button-group
- v-if="isCustomNotification"
- v-gl-tooltip="{ title: buttonTooltip }"
- data-testid="notificationButton"
- :size="buttonSize"
- >
- <gl-button
- v-gl-modal="$options.modalId"
- :size="buttonSize"
- :icon="buttonIcon"
- :loading="isLoading"
- :disabled="disabled"
- >
- <template v-if="buttonText">{{ buttonText }}</template>
- </gl-button>
- <gl-dropdown :size="buttonSize" :disabled="disabled">
- <notifications-dropdown-item
- v-for="item in notificationLevels"
- :key="item.level"
- :level="item.level"
- :title="item.title"
- :description="item.description"
- :notification-level="selectedNotificationLevel"
- @item-selected="selectItem"
- />
- <gl-dropdown-divider />
- <notifications-dropdown-item
- :key="$options.customLevel"
- :level="$options.customLevel"
- :title="$options.i18n.notificationTitles.custom"
- :description="$options.i18n.notificationDescriptions.custom"
- :notification-level="selectedNotificationLevel"
- @item-selected="selectItem"
- />
- </gl-dropdown>
- </gl-button-group>
-
<gl-dropdown
- v-else
v-gl-tooltip="{ title: buttonTooltip }"
- data-testid="notificationButton"
- :text="buttonText"
+ data-testid="notification-dropdown"
+ :size="buttonSize"
:icon="buttonIcon"
:loading="isLoading"
- :size="buttonSize"
:disabled="disabled"
+ :split="isCustomNotification"
+ :text="buttonText"
+ @click="openNotificationsModal"
>
<notifications-dropdown-item
v-for="item in notificationLevels"
@@ -191,6 +148,6 @@ export default {
@item-selected="selectItem"
/>
</gl-dropdown>
- <custom-notifications-modal ref="customNotificationsModal" :modal-id="$options.modalId" />
+ <custom-notifications-modal v-model="notificationsModalVisible" :modal-id="$options.modalId" />
</div>
</template>
diff --git a/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue b/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue
index 73bb9c1b36f..2138372d8ad 100644
--- a/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue
+++ b/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue
@@ -33,7 +33,13 @@ export default {
</script>
<template>
- <gl-dropdown-item is-check-item :is-checked="isActive" @click="$emit('item-selected', level)">
+ <gl-dropdown-item
+ is-check-item
+ :is-checked="isActive"
+ :class="{ 'is-active': isActive }"
+ data-testid="notification-item"
+ @click="$emit('item-selected', level)"
+ >
<div class="gl-display-flex gl-flex-direction-column">
<span class="gl-font-weight-bold">{{ title }}</span>
<span class="gl-text-gray-500">{{ description }}</span>
diff --git a/app/assets/javascripts/notifications/constants.js b/app/assets/javascripts/notifications/constants.js
index 07c569a0293..4f875977d78 100644
--- a/app/assets/javascripts/notifications/constants.js
+++ b/app/assets/javascripts/notifications/constants.js
@@ -22,10 +22,10 @@ export const i18n = {
owner_disabled: __('Notifications have been disabled by the project or group owner'),
},
updateNotificationLevelErrorMessage: __(
- 'An error occured while updating the notification settings. Please try again.',
+ 'An error occurred while updating the notification settings. Please try again.',
),
loadNotificationLevelErrorMessage: __(
- 'An error occured while loading the notification settings. Please try again.',
+ 'An error occurred while loading the notification settings. Please try again.',
),
customNotificationsModal: {
title: __('Custom notification events'),
@@ -53,6 +53,7 @@ export const i18n = {
reassign_merge_request: s__('NotificationEvent|Reassign merge request'),
reopen_issue: s__('NotificationEvent|Reopen issue'),
reopen_merge_request: s__('NotificationEvent|Reopen merge request'),
+ merge_when_pipeline_succeeds: s__('NotificationEvent|Merge when pipeline succeeds'),
success_pipeline: s__('NotificationEvent|Successful pipeline'),
},
};
diff --git a/app/assets/javascripts/notifications_dropdown.js b/app/assets/javascripts/notifications_dropdown.js
deleted file mode 100644
index d61defed14d..00000000000
--- a/app/assets/javascripts/notifications_dropdown.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import $ from 'jquery';
-import { Rails } from '~/lib/utils/rails_ujs';
-import { __ } from '~/locale';
-import { deprecatedCreateFlash as Flash } from './flash';
-
-export default function notificationsDropdown() {
- $(document).on('click', '.update-notification', function updateNotificationCallback(e) {
- e.preventDefault();
-
- if ($(this).is('.is-active') && $(this).data('notificationLevel') === 'custom') {
- return;
- }
-
- const notificationLevel = $(this).data('notificationLevel');
- const form = $(this).parents('.notification-form').first();
-
- form.find('.js-notification-loading').toggleClass('spinner');
- if (form.hasClass('no-label')) {
- form.find('.js-notification-loading').toggleClass('hidden');
- form.find('.js-notifications-icon').toggleClass('hidden');
- }
- form.find('#notification_setting_level').val(notificationLevel);
- Rails.fire(form[0], 'submit');
- });
-
- $(document).on('ajax:success', '.notification-form', (e) => {
- const data = e.detail[0];
-
- if (data.saved) {
- $(e.currentTarget).closest('.js-notification-dropdown').replaceWith(data.html);
- } else {
- Flash(__('Failed to save new settings'), 'alert');
- }
- });
-}
diff --git a/app/assets/javascripts/notifications_form.js b/app/assets/javascripts/notifications_form.js
deleted file mode 100644
index 8b90da71bef..00000000000
--- a/app/assets/javascripts/notifications_form.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import $ from 'jquery';
-import { deprecatedCreateFlash as flash } from './flash';
-import axios from './lib/utils/axios_utils';
-import { __ } from './locale';
-
-export default class NotificationsForm {
- constructor() {
- this.toggleCheckbox = this.toggleCheckbox.bind(this);
- this.initEventListeners();
- }
-
- initEventListeners() {
- $(document).on('change', '.js-custom-notification-event', this.toggleCheckbox);
- }
-
- toggleCheckbox(e) {
- const $checkbox = $(e.currentTarget);
- const $parent = $checkbox.closest('.form-check');
-
- this.saveEvent($checkbox, $parent);
- }
-
- // eslint-disable-next-line class-methods-use-this
- showCheckboxLoadingSpinner($parent) {
- $parent.find('.is-loading').removeClass('gl-display-none');
- $parent.find('.is-done').addClass('gl-display-none');
- }
-
- saveEvent($checkbox, $parent) {
- const form = $parent.parents('form').first();
-
- this.showCheckboxLoadingSpinner($parent);
-
- axios[form.attr('method')](form.attr('action'), form.serialize())
- .then(({ data }) => {
- $checkbox.enable();
- if (data.saved) {
- $parent.find('.is-loading').addClass('gl-display-none');
- $parent.find('.is-done').removeClass('gl-display-none');
-
- setTimeout(() => {
- $parent.find('.is-done').addClass('gl-display-none');
- }, 2000);
- }
- })
- .catch(() => flash(__('There was an error saving your notification settings.')));
- }
-}
diff --git a/app/assets/javascripts/packages/details/components/composer_installation.vue b/app/assets/javascripts/packages/details/components/composer_installation.vue
index 9d87ae8f836..bf1e5083e12 100644
--- a/app/assets/javascripts/packages/details/components/composer_installation.vue
+++ b/app/assets/javascripts/packages/details/components/composer_installation.vue
@@ -2,12 +2,14 @@
import { GlLink, GlSprintf } from '@gitlab/ui';
import { mapGetters, mapState } from 'vuex';
import { s__ } from '~/locale';
+import InstallationTitle from '~/packages/details/components/installation_title.vue';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
import { TrackingActions, TrackingLabels } from '../constants';
export default {
name: 'ComposerInstallation',
components: {
+ InstallationTitle,
CodeInstruction,
GlLink,
GlSprintf,
@@ -27,12 +29,13 @@ export default {
},
trackingActions: { ...TrackingActions },
TrackingLabels,
+ installOptions: [{ value: 'composer', label: s__('PackageRegistry|Show Composer commands') }],
};
</script>
<template>
<div v-if="groupExists" data-testid="root-node">
- <h3 class="gl-font-lg">{{ __('Installation') }}</h3>
+ <installation-title package-type="composer" :options="$options.installOptions" />
<code-instruction
:label="$options.i18n.registryInclude"
diff --git a/app/assets/javascripts/packages/details/components/conan_installation.vue b/app/assets/javascripts/packages/details/components/conan_installation.vue
index a5df87c9c5b..1d855f6cf3e 100644
--- a/app/assets/javascripts/packages/details/components/conan_installation.vue
+++ b/app/assets/javascripts/packages/details/components/conan_installation.vue
@@ -2,12 +2,14 @@
import { GlLink, GlSprintf } from '@gitlab/ui';
import { mapGetters, mapState } from 'vuex';
import { s__ } from '~/locale';
+import InstallationTitle from '~/packages/details/components/installation_title.vue';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
import { TrackingActions, TrackingLabels } from '../constants';
export default {
name: 'ConanInstallation',
components: {
+ InstallationTitle,
CodeInstruction,
GlLink,
GlSprintf,
@@ -23,12 +25,13 @@ export default {
},
trackingActions: { ...TrackingActions },
TrackingLabels,
+ installOptions: [{ value: 'conan', label: s__('PackageRegistry|Show Conan commands') }],
};
</script>
<template>
<div>
- <h3 class="gl-font-lg">{{ __('Installation') }}</h3>
+ <installation-title package-type="conan" :options="$options.installOptions" />
<code-instruction
:label="s__('PackageRegistry|Conan Command')"
diff --git a/app/assets/javascripts/packages/details/components/installation_title.vue b/app/assets/javascripts/packages/details/components/installation_title.vue
new file mode 100644
index 00000000000..43133bf7825
--- /dev/null
+++ b/app/assets/javascripts/packages/details/components/installation_title.vue
@@ -0,0 +1,38 @@
+<script>
+import PersistedDropdownSelection from '~/vue_shared/components/registry/persisted_dropdown_selection.vue';
+
+export default {
+ name: 'InstallationTitle',
+ components: {
+ PersistedDropdownSelection,
+ },
+ props: {
+ packageType: {
+ type: String,
+ required: true,
+ },
+ options: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ storageKey() {
+ return `package_${this.packageType}_installation_instructions`;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center">
+ <h3 class="gl-font-lg">{{ __('Installation') }}</h3>
+ <div>
+ <persisted-dropdown-selection
+ :storage-key="storageKey"
+ :options="options"
+ @change="$emit('change', $event)"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages/details/components/maven_installation.vue b/app/assets/javascripts/packages/details/components/maven_installation.vue
index c2f6f76967b..b9532cb7e72 100644
--- a/app/assets/javascripts/packages/details/components/maven_installation.vue
+++ b/app/assets/javascripts/packages/details/components/maven_installation.vue
@@ -2,19 +2,36 @@
import { GlLink, GlSprintf } from '@gitlab/ui';
import { mapGetters, mapState } from 'vuex';
import { s__ } from '~/locale';
+import InstallationTitle from '~/packages/details/components/installation_title.vue';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
+
import { TrackingActions, TrackingLabels } from '../constants';
export default {
name: 'MavenInstallation',
components: {
+ InstallationTitle,
CodeInstruction,
GlLink,
GlSprintf,
},
+ data() {
+ return {
+ instructionType: 'maven',
+ };
+ },
computed: {
...mapState(['mavenHelpPath']),
- ...mapGetters(['mavenInstallationXml', 'mavenInstallationCommand', 'mavenSetupXml']),
+ ...mapGetters([
+ 'mavenInstallationXml',
+ 'mavenInstallationCommand',
+ 'mavenSetupXml',
+ 'gradleGroovyInstalCommand',
+ 'gradleGroovyAddSourceCommand',
+ ]),
+ showMaven() {
+ return this.instructionType === 'maven';
+ },
},
i18n: {
xmlText: s__(
@@ -29,57 +46,84 @@ export default {
},
trackingActions: { ...TrackingActions },
TrackingLabels,
+ installOptions: [
+ { value: 'maven', label: s__('PackageRegistry|Show Maven commands') },
+ { value: 'groovy', label: s__('PackageRegistry|Show Gradle Groovy DSL commands') },
+ ],
};
</script>
<template>
<div>
- <h3 class="gl-font-lg">{{ __('Installation') }}</h3>
+ <installation-title
+ package-type="maven"
+ :options="$options.installOptions"
+ @change="instructionType = $event"
+ />
- <p>
- <gl-sprintf :message="$options.i18n.xmlText">
- <template #code="{ content }">
- <code>{{ content }}</code>
- </template>
- </gl-sprintf>
- </p>
+ <template v-if="showMaven">
+ <p>
+ <gl-sprintf :message="$options.i18n.xmlText">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
- <code-instruction
- :label="s__('PackageRegistry|Maven XML')"
- :instruction="mavenInstallationXml"
- :copy-text="s__('PackageRegistry|Copy Maven XML')"
- multiline
- :tracking-action="$options.trackingActions.COPY_MAVEN_XML"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
+ <code-instruction
+ :instruction="mavenInstallationXml"
+ :copy-text="s__('PackageRegistry|Copy Maven XML')"
+ :tracking-action="$options.trackingActions.COPY_MAVEN_XML"
+ :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
+ multiline
+ />
- <code-instruction
- :label="s__('PackageRegistry|Maven Command')"
- :instruction="mavenInstallationCommand"
- :copy-text="s__('PackageRegistry|Copy Maven command')"
- :tracking-action="$options.trackingActions.COPY_MAVEN_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
+ <code-instruction
+ :label="s__('PackageRegistry|Maven Command')"
+ :instruction="mavenInstallationCommand"
+ :copy-text="s__('PackageRegistry|Copy Maven command')"
+ :tracking-action="$options.trackingActions.COPY_MAVEN_COMMAND"
+ :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
+ />
- <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3>
- <p>
- <gl-sprintf :message="$options.i18n.setupText">
- <template #code="{ content }">
- <code>{{ content }}</code>
+ <h3 class="gl-font-lg">{{ s__('PackageRegistry|Registry setup') }}</h3>
+ <p>
+ <gl-sprintf :message="$options.i18n.setupText">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ <code-instruction
+ :instruction="mavenSetupXml"
+ :copy-text="s__('PackageRegistry|Copy Maven registry XML')"
+ :tracking-action="$options.trackingActions.COPY_MAVEN_SETUP"
+ :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
+ multiline
+ />
+ <gl-sprintf :message="$options.i18n.helpText">
+ <template #link="{ content }">
+ <gl-link :href="mavenHelpPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
- </p>
- <code-instruction
- :instruction="mavenSetupXml"
- :copy-text="s__('PackageRegistry|Copy Maven registry XML')"
- multiline
- :tracking-action="$options.trackingActions.COPY_MAVEN_SETUP"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
- <gl-sprintf :message="$options.i18n.helpText">
- <template #link="{ content }">
- <gl-link :href="mavenHelpPath" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
+ </template>
+ <template v-else>
+ <code-instruction
+ class="gl-mb-5"
+ :label="s__('PackageRegistry|Gradle Groovy DSL install command')"
+ :instruction="gradleGroovyInstalCommand"
+ :copy-text="s__('PackageRegistry|Copy Gradle Groovy DSL install command')"
+ :tracking-action="$options.trackingActions.COPY_GRADLE_INSTALL_COMMAND"
+ :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
+ />
+ <code-instruction
+ :label="s__('PackageRegistry|Add Gradle Groovy DSL repository command')"
+ :instruction="gradleGroovyAddSourceCommand"
+ :copy-text="s__('PackageRegistry|Copy add Gradle Groovy DSL repository command')"
+ :tracking-action="$options.trackingActions.COPY_GRADLE_ADD_TO_SOURCE_COMMAND"
+ :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
+ multiline
+ />
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/packages/details/components/npm_installation.vue b/app/assets/javascripts/packages/details/components/npm_installation.vue
index 37ba279d098..18f15e2c63e 100644
--- a/app/assets/javascripts/packages/details/components/npm_installation.vue
+++ b/app/assets/javascripts/packages/details/components/npm_installation.vue
@@ -2,12 +2,14 @@
import { GlLink, GlSprintf } from '@gitlab/ui';
import { mapGetters, mapState } from 'vuex';
import { s__ } from '~/locale';
+import InstallationTitle from '~/packages/details/components/installation_title.vue';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
import { NpmManager, TrackingActions, TrackingLabels } from '../constants';
export default {
name: 'NpmInstallation',
components: {
+ InstallationTitle,
CodeInstruction,
GlLink,
GlSprintf,
@@ -35,12 +37,13 @@ export default {
},
trackingActions: { ...TrackingActions },
TrackingLabels,
+ installOptions: [{ value: 'npm', label: s__('PackageRegistry|Show NPM commands') }],
};
</script>
<template>
<div>
- <h3 class="gl-font-lg">{{ __('Installation') }}</h3>
+ <installation-title package-type="npm" :options="$options.installOptions" />
<code-instruction
:label="s__('PackageRegistry|npm command')"
diff --git a/app/assets/javascripts/packages/details/components/nuget_installation.vue b/app/assets/javascripts/packages/details/components/nuget_installation.vue
index 36887703716..d5e64722f24 100644
--- a/app/assets/javascripts/packages/details/components/nuget_installation.vue
+++ b/app/assets/javascripts/packages/details/components/nuget_installation.vue
@@ -2,12 +2,14 @@
import { GlLink, GlSprintf } from '@gitlab/ui';
import { mapGetters, mapState } from 'vuex';
import { s__ } from '~/locale';
+import InstallationTitle from '~/packages/details/components/installation_title.vue';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
import { TrackingActions, TrackingLabels } from '../constants';
export default {
name: 'NugetInstallation',
components: {
+ InstallationTitle,
CodeInstruction,
GlLink,
GlSprintf,
@@ -23,12 +25,14 @@ export default {
},
trackingActions: { ...TrackingActions },
TrackingLabels,
+ installOptions: [{ value: 'nuget', label: s__('PackageRegistry|Show Nuget commands') }],
};
</script>
<template>
<div>
- <h3 class="gl-font-lg">{{ __('Installation') }}</h3>
+ <installation-title package-type="nuget" :options="$options.installOptions" />
+
<code-instruction
:label="s__('PackageRegistry|NuGet Command')"
:instruction="nugetInstallationCommand"
diff --git a/app/assets/javascripts/packages/details/components/pypi_installation.vue b/app/assets/javascripts/packages/details/components/pypi_installation.vue
index f87be68be48..fe4709d5feb 100644
--- a/app/assets/javascripts/packages/details/components/pypi_installation.vue
+++ b/app/assets/javascripts/packages/details/components/pypi_installation.vue
@@ -2,12 +2,14 @@
import { GlLink, GlSprintf } from '@gitlab/ui';
import { mapGetters, mapState } from 'vuex';
import { s__ } from '~/locale';
+import InstallationTitle from '~/packages/details/components/installation_title.vue';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
import { TrackingActions, TrackingLabels } from '../constants';
export default {
name: 'PyPiInstallation',
components: {
+ InstallationTitle,
CodeInstruction,
GlLink,
GlSprintf,
@@ -26,12 +28,13 @@ export default {
},
trackingActions: { ...TrackingActions },
TrackingLabels,
+ installOptions: [{ value: 'pypi', label: s__('PackageRegistry|Show PyPi commands') }],
};
</script>
<template>
<div>
- <h3 class="gl-font-lg">{{ __('Installation') }}</h3>
+ <installation-title package-type="pypi" :options="$options.installOptions" />
<code-instruction
:label="s__('PackageRegistry|Pip Command')"
diff --git a/app/assets/javascripts/packages/details/constants.js b/app/assets/javascripts/packages/details/constants.js
index 986b0667356..f0300ee29b4 100644
--- a/app/assets/javascripts/packages/details/constants.js
+++ b/app/assets/javascripts/packages/details/constants.js
@@ -35,6 +35,9 @@ export const TrackingActions = {
COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND: 'copy_composer_registry_include_command',
COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND: 'copy_composer_package_include_command',
+
+ COPY_GRADLE_INSTALL_COMMAND: 'copy_gradle_install_command',
+ COPY_GRADLE_ADD_TO_SOURCE_COMMAND: 'copy_gradle_add_to_source_command',
};
export const NpmManager = {
diff --git a/app/assets/javascripts/packages/details/store/getters.js b/app/assets/javascripts/packages/details/store/getters.js
index 14e76ac84bd..fb9b7d61fd2 100644
--- a/app/assets/javascripts/packages/details/store/getters.js
+++ b/app/assets/javascripts/packages/details/store/getters.js
@@ -110,4 +110,20 @@ export const composerPackageInclude = ({ packageEntity }) =>
// eslint-disable-next-line @gitlab/require-i18n-strings
`composer req ${[packageEntity.name]}:${packageEntity.version}`;
+export const gradleGroovyInstalCommand = ({ packageEntity }) => {
+ const {
+ app_group: group = '',
+ app_name: name = '',
+ app_version: version = '',
+ } = packageEntity.maven_metadatum;
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return `implementation '${group}:${name}:${version}'`;
+};
+
+export const gradleGroovyAddSourceCommand = ({ mavenPath }) =>
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ `maven {
+ url '${mavenPath}'
+}`;
+
export const groupExists = ({ groupListUrl }) => groupListUrl.length > 0;
diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages/list/constants.js
index d47eb8c3421..25a55200df2 100644
--- a/app/assets/javascripts/packages/list/constants.js
+++ b/app/assets/javascripts/packages/list/constants.js
@@ -71,7 +71,7 @@ export const PACKAGE_TYPES = [
type: PackageType.MAVEN,
},
{
- title: s__('PackageRegistry|NPM'),
+ title: s__('PackageRegistry|npm'),
type: PackageType.NPM,
},
{
diff --git a/app/assets/javascripts/packages/shared/components/package_list_row.vue b/app/assets/javascripts/packages/shared/components/package_list_row.vue
index cdfe042c39f..172b356227a 100644
--- a/app/assets/javascripts/packages/shared/components/package_list_row.vue
+++ b/app/assets/javascripts/packages/shared/components/package_list_row.vue
@@ -123,7 +123,7 @@ export default {
<gl-button
data-testid="action-delete"
icon="remove"
- category="primary"
+ category="secondary"
variant="danger"
:title="s__('PackageRegistry|Remove package')"
:aria-label="s__('PackageRegistry|Remove package')"
diff --git a/app/assets/javascripts/packages/shared/utils.js b/app/assets/javascripts/packages/shared/utils.js
index 677550f77ec..d34372e89b6 100644
--- a/app/assets/javascripts/packages/shared/utils.js
+++ b/app/assets/javascripts/packages/shared/utils.js
@@ -14,7 +14,7 @@ export const getPackageTypeLabel = (packageType) => {
case PackageType.MAVEN:
return s__('PackageType|Maven');
case PackageType.NPM:
- return s__('PackageType|NPM');
+ return s__('PackageType|npm');
case PackageType.NUGET:
return s__('PackageType|NuGet');
case PackageType.PYPI:
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
index 933cbeaedce..4f5c53ed4a3 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
@@ -111,7 +111,10 @@ export default {
{{ alertMessage }}
</gl-alert>
- <settings-block :default-expanded="defaultExpanded">
+ <settings-block
+ :default-expanded="defaultExpanded"
+ data-qa-selector="package_registry_settings_content"
+ >
<template #title> {{ $options.i18n.PACKAGE_SETTINGS_HEADER }}</template>
<template #description>
<span data-testid="description">
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue
index aa7c6e9d8d6..d4f51b83e1e 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue
@@ -79,11 +79,12 @@ export default {
<form>
<div class="gl-display-flex">
<gl-toggle
+ data-qa-selector="allow_duplicates_toggle"
:value="mavenDuplicatesAllowed"
@change="update($options.modelNames.MAVEN_DUPLICATES_ALLOWED, $event)"
/>
<div class="gl-ml-5">
- <div data-testid="toggle-label">
+ <div data-testid="toggle-label" data-qa-selector="allow_duplicates_label">
<gl-sprintf :message="enabledButtonLabel">
<template #bold="{ content }">
<strong>{{ content }}</strong>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js
index 06b57fe013f..fb06f557d66 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js
@@ -9,7 +9,6 @@ export const updateGroupPackageSettings = (fullPath) => (client, { data: updated
const sourceData = client.readQuery(queryAndParams);
const data = produce(sourceData, (draftState) => {
- // eslint-disable-next-line no-param-reassign
draftState.group.packageSettings = {
...updatedData.updateNamespacePackageSettings.packageSettings,
};
diff --git a/app/assets/javascripts/pages/admin/abuse_reports/index.js b/app/assets/javascripts/pages/admin/abuse_reports/index.js
index 5649c47d7e8..0a4311ec73a 100644
--- a/app/assets/javascripts/pages/admin/abuse_reports/index.js
+++ b/app/assets/javascripts/pages/admin/abuse_reports/index.js
@@ -1,8 +1,5 @@
-/* eslint-disable no-new */
import UsersSelect from '~/users_select';
import AbuseReports from './abuse_reports';
-document.addEventListener('DOMContentLoaded', () => {
- new AbuseReports();
- new UsersSelect();
-});
+new AbuseReports(); /* eslint-disable-line no-new */
+new UsersSelect(); /* eslint-disable-line no-new */
diff --git a/app/assets/javascripts/pages/admin/admin.js b/app/assets/javascripts/pages/admin/admin.js
index e92262852cf..2732fc191be 100644
--- a/app/assets/javascripts/pages/admin/admin.js
+++ b/app/assets/javascripts/pages/admin/admin.js
@@ -12,8 +12,6 @@ function showDenylistType() {
}
export default function adminInit() {
- const modal = $('.change-owner-holder');
-
$('input#user_force_random_password').on('change', function randomPasswordClick() {
const $elems = $('#user_password, #user_password_confirmation');
if ($(this).attr('checked')) {
@@ -28,36 +26,6 @@ export default function adminInit() {
$('.js-toggle-colors-container').toggleClass('hide');
});
- $('.log-tabs a').on('click', function logTabsClick(e) {
- e.preventDefault();
- $(this).tab('show');
- });
-
- $('.log-bottom').on('click', (e) => {
- e.preventDefault();
- const $visibleLog = $('.file-content:visible');
-
- // eslint-disable-next-line no-jquery/no-animate
- $visibleLog.animate(
- {
- scrollTop: $visibleLog.find('ol').height(),
- },
- 'fast',
- );
- });
-
- $('.change-owner-link').on('click', function changeOwnerLinkClick(e) {
- e.preventDefault();
- $(this).hide();
- modal.show();
- });
-
- $('.change-owner-cancel-link').on('click', (e) => {
- e.preventDefault();
- modal.hide();
- $('.change-owner-link').show();
- });
-
$('li.project_member, li.group_member').on('ajax:success', refreshCurrentPage);
$("input[name='denylist_type']").on('click', showDenylistType);
diff --git a/app/assets/javascripts/pages/admin/application_settings/general/index.js b/app/assets/javascripts/pages/admin/application_settings/general/index.js
index f7bd32880ff..eda1a9d3599 100644
--- a/app/assets/javascripts/pages/admin/application_settings/general/index.js
+++ b/app/assets/javascripts/pages/admin/application_settings/general/index.js
@@ -1,25 +1,27 @@
-// This is a true violation of @gitlab/no-runtime-template-compiler, as it
-// relies on app/views/admin/application_settings/_gitpod.html.haml for its
-// template.
-/* eslint-disable @gitlab/no-runtime-template-compiler */
import Vue from 'vue';
import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue';
import initUserInternalRegexPlaceholder from '../account_and_limits';
-document.addEventListener('DOMContentLoaded', () => {
+(() => {
initUserInternalRegexPlaceholder();
- const gitpodSettingEl = document.querySelector('#js-gitpod-settings-help-text');
- if (!gitpodSettingEl) {
+ const el = document.querySelector('#js-gitpod-settings-help-text');
+ if (!el) {
return;
}
+ const { message, messageUrl } = el.dataset;
+
// eslint-disable-next-line no-new
new Vue({
- el: gitpodSettingEl,
- name: 'GitpodSettings',
- components: {
- IntegrationHelpText,
+ el,
+ render(createElement) {
+ return createElement(IntegrationHelpText, {
+ props: {
+ message,
+ messageUrl,
+ },
+ });
},
});
-});
+})();
diff --git a/app/assets/javascripts/pages/admin/application_settings/index.js b/app/assets/javascripts/pages/admin/application_settings/index.js
index e7e74588bec..e3c6b0f6f5b 100644
--- a/app/assets/javascripts/pages/admin/application_settings/index.js
+++ b/app/assets/javascripts/pages/admin/application_settings/index.js
@@ -4,13 +4,11 @@ import initSearchSettings from '~/search_settings';
import selfMonitor from '~/self_monitor';
import initSettingsPanels from '~/settings_panels';
-document.addEventListener('DOMContentLoaded', () => {
- if (gon.features?.ciInstanceVariablesUi) {
- initVariableList('js-instance-variables');
- }
- selfMonitor();
- // Initialize expandable settings panels
- initSettingsPanels();
- projectSelect();
- initSearchSettings();
-});
+if (gon.features?.ciInstanceVariablesUi) {
+ initVariableList('js-instance-variables');
+}
+selfMonitor();
+// Initialize expandable settings panels
+initSettingsPanels();
+projectSelect();
+initSearchSettings();
diff --git a/app/assets/javascripts/pages/admin/dev_ops_report/index.js b/app/assets/javascripts/pages/admin/dev_ops_report/index.js
index 220fc049562..cf06ee2c22a 100644
--- a/app/assets/javascripts/pages/admin/dev_ops_report/index.js
+++ b/app/assets/javascripts/pages/admin/dev_ops_report/index.js
@@ -1,5 +1,3 @@
-import initDevopAdoption from 'ee_else_ce/admin/dev_ops_report/devops_adoption';
-import initDevOpsScoreEmptyState from '~/admin/dev_ops_report/devops_score_empty_state';
+import initDevOpsScoreEmptyState from '~/analytics/devops_report/devops_score_empty_state';
initDevOpsScoreEmptyState();
-initDevopAdoption();
diff --git a/app/assets/javascripts/pages/admin/impersonation_tokens/index.js b/app/assets/javascripts/pages/admin/impersonation_tokens/index.js
index ae2209b0292..dc1bb88bf4b 100644
--- a/app/assets/javascripts/pages/admin/impersonation_tokens/index.js
+++ b/app/assets/javascripts/pages/admin/impersonation_tokens/index.js
@@ -1,3 +1,3 @@
-import initExpiresAtField from '~/access_tokens';
+import { initExpiresAtField } from '~/access_tokens';
-document.addEventListener('DOMContentLoaded', initExpiresAtField);
+initExpiresAtField();
diff --git a/app/assets/javascripts/pages/admin/index.js b/app/assets/javascripts/pages/admin/index.js
index 792a6eda14e..8d5dfd689e8 100644
--- a/app/assets/javascripts/pages/admin/index.js
+++ b/app/assets/javascripts/pages/admin/index.js
@@ -2,10 +2,8 @@ import initAdminStatisticsPanel from '../../admin/statistics_panel/index';
import initVueAlerts from '../../vue_alerts';
import initAdmin from './admin';
-document.addEventListener('DOMContentLoaded', initVueAlerts);
+initVueAlerts();
-document.addEventListener('DOMContentLoaded', () => {
- const statisticsPanelContainer = document.getElementById('js-admin-statistics-container');
- initAdmin();
- initAdminStatisticsPanel(statisticsPanelContainer);
-});
+const statisticsPanelContainer = document.getElementById('js-admin-statistics-container');
+initAdmin();
+initAdminStatisticsPanel(statisticsPanelContainer);
diff --git a/app/assets/javascripts/pages/admin/instance_statistics/index.js b/app/assets/javascripts/pages/admin/instance_statistics/index.js
deleted file mode 100644
index d6b0a834ce3..00000000000
--- a/app/assets/javascripts/pages/admin/instance_statistics/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import initInstanceStatisticsApp from '~/analytics/instance_statistics';
-
-document.addEventListener('DOMContentLoaded', () => initInstanceStatisticsApp());
diff --git a/app/assets/javascripts/pages/admin/projects/index.js b/app/assets/javascripts/pages/admin/projects/index.js
index 2286a085143..042ff7808f1 100644
--- a/app/assets/javascripts/pages/admin/projects/index.js
+++ b/app/assets/javascripts/pages/admin/projects/index.js
@@ -17,12 +17,10 @@ function mountRemoveMemberModal() {
});
}
-document.addEventListener('DOMContentLoaded', () => {
- mountRemoveMemberModal();
+mountRemoveMemberModal();
- new ProjectsList(); // eslint-disable-line no-new
+new ProjectsList(); // eslint-disable-line no-new
- document
- .querySelectorAll('.js-namespace-select')
- .forEach((dropdown) => new NamespaceSelect({ dropdown }));
-});
+document
+ .querySelectorAll('.js-namespace-select')
+ .forEach((dropdown) => new NamespaceSelect({ dropdown }));
diff --git a/app/assets/javascripts/pages/admin/projects/index/index.js b/app/assets/javascripts/pages/admin/projects/index/index.js
index 1971323abac..cc9a9b6cc38 100644
--- a/app/assets/javascripts/pages/admin/projects/index/index.js
+++ b/app/assets/javascripts/pages/admin/projects/index/index.js
@@ -6,7 +6,7 @@ import Translate from '~/vue_shared/translate';
import deleteProjectModal from './components/delete_project_modal.vue';
-document.addEventListener('DOMContentLoaded', () => {
+(() => {
Vue.use(Translate);
const deleteProjectModalEl = document.getElementById('delete-project-modal');
@@ -39,4 +39,4 @@ document.addEventListener('DOMContentLoaded', () => {
});
},
});
-});
+})();
diff --git a/app/assets/javascripts/pages/admin/runners/index.js b/app/assets/javascripts/pages/admin/runners/index.js
index f07ac1d8674..45ed3ac6bd8 100644
--- a/app/assets/javascripts/pages/admin/runners/index.js
+++ b/app/assets/javascripts/pages/admin/runners/index.js
@@ -3,12 +3,10 @@ import { FILTERED_SEARCH } from '~/pages/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
-document.addEventListener('DOMContentLoaded', () => {
- initFilteredSearch({
- page: FILTERED_SEARCH.ADMIN_RUNNERS,
- filteredSearchTokenKeys: AdminRunnersFilteredSearchTokenKeys,
- useDefaultState: true,
- });
-
- initInstallRunner();
+initFilteredSearch({
+ page: FILTERED_SEARCH.ADMIN_RUNNERS,
+ filteredSearchTokenKeys: AdminRunnersFilteredSearchTokenKeys,
+ useDefaultState: true,
});
+
+initInstallRunner();
diff --git a/app/assets/javascripts/pages/admin/services/index/index.js b/app/assets/javascripts/pages/admin/services/index/index.js
index b2dfbb5a9fc..b695cf70c5d 100644
--- a/app/assets/javascripts/pages/admin/services/index/index.js
+++ b/app/assets/javascripts/pages/admin/services/index/index.js
@@ -1,6 +1,4 @@
import PersistentUserCallout from '~/persistent_user_callout';
-document.addEventListener('DOMContentLoaded', () => {
- const callout = document.querySelector('.js-service-templates-deprecated');
- PersistentUserCallout.factory(callout);
-});
+const callout = document.querySelector('.js-service-templates-deprecated');
+PersistentUserCallout.factory(callout);
diff --git a/app/assets/javascripts/pages/admin/usage_trends/index.js b/app/assets/javascripts/pages/admin/usage_trends/index.js
new file mode 100644
index 00000000000..23d2bd85979
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/usage_trends/index.js
@@ -0,0 +1,3 @@
+import initUsageTrendsApp from '~/analytics/usage_trends';
+
+initUsageTrendsApp();
diff --git a/app/assets/javascripts/pages/dashboard/issues/index.js b/app/assets/javascripts/pages/dashboard/issues/index.js
index 3ad95fb1318..3e09b1796b1 100644
--- a/app/assets/javascripts/pages/dashboard/issues/index.js
+++ b/app/assets/javascripts/pages/dashboard/issues/index.js
@@ -4,13 +4,11 @@ import { FILTERED_SEARCH } from '~/pages/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import projectSelect from '~/project_select';
-document.addEventListener('DOMContentLoaded', () => {
- initFilteredSearch({
- page: FILTERED_SEARCH.ISSUES,
- filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
- useDefaultState: true,
- });
-
- projectSelect();
- initManualOrdering();
+initFilteredSearch({
+ page: FILTERED_SEARCH.ISSUES,
+ filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
+ useDefaultState: true,
});
+
+projectSelect();
+initManualOrdering();
diff --git a/app/assets/javascripts/pages/dashboard/milestones/show/index.js b/app/assets/javascripts/pages/dashboard/milestones/show/index.js
index 8b529585898..397149aaa9e 100644
--- a/app/assets/javascripts/pages/dashboard/milestones/show/index.js
+++ b/app/assets/javascripts/pages/dashboard/milestones/show/index.js
@@ -6,6 +6,4 @@ document.addEventListener('DOMContentLoaded', () => {
new Milestone(); // eslint-disable-line no-new
new Sidebar(); // eslint-disable-line no-new
new MountMilestoneSidebar(); // eslint-disable-line no-new
-
- Milestone.initDeprecationMessage();
});
diff --git a/app/assets/javascripts/pages/dashboard/projects/index/index.js b/app/assets/javascripts/pages/dashboard/projects/index/index.js
index b3c95f4ac1f..c34d15b869a 100644
--- a/app/assets/javascripts/pages/dashboard/projects/index/index.js
+++ b/app/assets/javascripts/pages/dashboard/projects/index/index.js
@@ -1,8 +1,5 @@
import ProjectsList from '~/projects_list';
import initCustomizeHomepageBanner from './init_customize_homepage_banner';
-document.addEventListener('DOMContentLoaded', () => {
- new ProjectsList(); // eslint-disable-line no-new
-
- initCustomizeHomepageBanner();
-});
+new ProjectsList(); // eslint-disable-line no-new
+initCustomizeHomepageBanner();
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/index.js b/app/assets/javascripts/pages/dashboard/todos/index/index.js
index 9d2c2f2994f..2fe90c24e77 100644
--- a/app/assets/javascripts/pages/dashboard/todos/index/index.js
+++ b/app/assets/javascripts/pages/dashboard/todos/index/index.js
@@ -1,3 +1,3 @@
import Todos from './todos';
-document.addEventListener('DOMContentLoaded', () => new Todos());
+new Todos(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/groups/activity/index.js b/app/assets/javascripts/pages/groups/activity/index.js
index 1b887cad496..8b7c36a0976 100644
--- a/app/assets/javascripts/pages/groups/activity/index.js
+++ b/app/assets/javascripts/pages/groups/activity/index.js
@@ -1,3 +1,4 @@
import Activities from '~/activities';
-document.addEventListener('DOMContentLoaded', () => new Activities());
+// eslint-disable-next-line no-new
+new Activities();
diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js
index 95ee512b71a..176d2406751 100644
--- a/app/assets/javascripts/pages/groups/edit/index.js
+++ b/app/assets/javascripts/pages/groups/edit/index.js
@@ -6,6 +6,7 @@ import TransferDropdown from '~/groups/transfer_dropdown';
import groupsSelect from '~/groups_select';
import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
import projectSelect from '~/project_select';
+import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
import setupTransferEdit from '~/transfer_edit';
@@ -24,5 +25,7 @@ document.addEventListener('DOMContentLoaded', () => {
projectSelect();
+ initSearchSettings();
+
return new TransferDropdown();
});
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index 3496f699b06..ab70fa572ba 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -1,11 +1,13 @@
import Vue from 'vue';
import { groupMemberRequestFormatter } from '~/groups/members/utils';
import groupsSelect from '~/groups_select';
+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 initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { s__ } from '~/locale';
import memberExpirationDate from '~/member_expiration_date';
-import { initMembersApp } from '~/members/index';
+import { initMembersApp } from '~/members';
import { groupLinkRequestFormatter } from '~/members/utils';
import UsersSelect from '~/users_select';
import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue';
@@ -70,5 +72,10 @@ memberExpirationDate('.js-access-expiration-date-groups');
mountRemoveMemberModal();
initInviteMembersModal();
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/groups/milestones/show/index.js b/app/assets/javascripts/pages/groups/milestones/show/index.js
index 3094fe5cd21..2a2cc5faebe 100644
--- a/app/assets/javascripts/pages/groups/milestones/show/index.js
+++ b/app/assets/javascripts/pages/groups/milestones/show/index.js
@@ -1,9 +1,7 @@
-import Milestone from '~/milestone';
import initDeleteMilestoneModal from '~/pages/milestones/shared/delete_milestone_modal_init';
import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show';
document.addEventListener('DOMContentLoaded', () => {
initMilestonesShow();
initDeleteMilestoneModal();
- Milestone.initDeprecationMessage();
});
diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js
index 63515abe698..322ad2c79e7 100644
--- a/app/assets/javascripts/pages/groups/new/index.js
+++ b/app/assets/javascripts/pages/groups/new/index.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import BindInOut from '~/behaviors/bind_in_out';
import initFilePickers from '~/file_pickers';
import Group from '~/group';
+import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs';
import GroupPathValidator from './group_path_validator';
const parentId = $('#group_parent_id');
@@ -12,3 +13,16 @@ BindInOut.initAll();
initFilePickers();
new Group(); // eslint-disable-line no-new
+
+const CONTAINER_SELECTOR = '.group-edit-container .nav-tabs';
+const DEFAULT_ACTION = '#create-group-pane';
+// eslint-disable-next-line no-new
+new LinkedTabs({
+ defaultAction: DEFAULT_ACTION,
+ parentEl: CONTAINER_SELECTOR,
+ hashedTabs: true,
+});
+
+if (window.location.hash) {
+ $(CONTAINER_SELECTOR).find(`a[href="${window.location.hash}"]`).tab('show');
+}
diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
index 378b8663777..0c3fdcf3e75 100644
--- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
@@ -4,21 +4,22 @@ import initSharedRunnersForm from '~/group_settings/mount_shared_runners';
import { FILTERED_SEARCH } from '~/pages/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
+import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
-document.addEventListener('DOMContentLoaded', () => {
- // Initialize expandable settings panels
- initSettingsPanels();
+// Initialize expandable settings panels
+initSettingsPanels();
- initFilteredSearch({
- page: FILTERED_SEARCH.ADMIN_RUNNERS,
- filteredSearchTokenKeys: GroupRunnersFilteredSearchTokenKeys,
- anchor: FILTERED_SEARCH.GROUP_RUNNERS_ANCHOR,
- useDefaultState: false,
- });
+initFilteredSearch({
+ page: FILTERED_SEARCH.ADMIN_RUNNERS,
+ filteredSearchTokenKeys: GroupRunnersFilteredSearchTokenKeys,
+ anchor: FILTERED_SEARCH.GROUP_RUNNERS_ANCHOR,
+ useDefaultState: false,
+});
- initSharedRunnersForm();
- initVariableList();
+initSharedRunnersForm();
+initVariableList();
- initInstallRunner();
-});
+initInstallRunner();
+
+initSearchSettings();
diff --git a/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js b/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js
index ba4b271f09e..a8698e10c57 100644
--- a/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js
+++ b/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js
@@ -1,13 +1,11 @@
import IntegrationSettingsForm from '~/integrations/integration_settings_form';
import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics';
-document.addEventListener('DOMContentLoaded', () => {
- const prometheusSettingsWrapper = document.querySelector('.js-prometheus-metrics-monitoring');
- const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
- integrationSettingsForm.init();
+const prometheusSettingsWrapper = document.querySelector('.js-prometheus-metrics-monitoring');
+const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
+integrationSettingsForm.init();
- if (prometheusSettingsWrapper) {
- const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
- prometheusMetrics.loadActiveMetrics();
- }
-});
+if (prometheusSettingsWrapper) {
+ const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
+ prometheusMetrics.loadActiveMetrics();
+}
diff --git a/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js b/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js
index 3b922622d2c..d13bf026777 100644
--- a/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js
+++ b/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js
@@ -1,3 +1,6 @@
import bundle from '~/packages_and_registries/settings/group/bundle';
+import initSearchSettings from '~/search_settings';
bundle();
+
+document.addEventListener('DOMContentLoaded', initSearchSettings);
diff --git a/app/assets/javascripts/pages/groups/settings/repository/show/index.js b/app/assets/javascripts/pages/groups/settings/repository/show/index.js
index a1bcf6dbf57..2c9867653de 100644
--- a/app/assets/javascripts/pages/groups/settings/repository/show/index.js
+++ b/app/assets/javascripts/pages/groups/settings/repository/show/index.js
@@ -1,9 +1,10 @@
import DueDateSelectors from '~/due_date_select';
+import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
-document.addEventListener('DOMContentLoaded', () => {
- // Initialize expandable settings panels
- initSettingsPanels();
+// Initialize expandable settings panels
+initSettingsPanels();
- new DueDateSelectors(); // eslint-disable-line no-new
-});
+new DueDateSelectors(); // eslint-disable-line no-new
+
+initSearchSettings();
diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js
index 8c272e561db..9e75985c130 100644
--- a/app/assets/javascripts/pages/groups/shared/group_details.js
+++ b/app/assets/javascripts/pages/groups/shared/group_details.js
@@ -3,12 +3,8 @@
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import { ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED } from '~/groups/constants';
import initInviteMembersBanner from '~/groups/init_invite_members_banner';
-import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
-import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { getPagePath, getDashPath } from '~/lib/utils/common_utils';
import initNotificationsDropdown from '~/notifications';
-import notificationsDropdown from '~/notifications_dropdown';
-import NotificationsForm from '~/notifications_form';
import ProjectsList from '~/projects_list';
import GroupTabs from './group_tabs';
@@ -22,17 +18,10 @@ export default function initGroupDetails(actionName = 'show') {
new GroupTabs({ parentEl: '.groups-listing', action });
new ShortcutsNavigation();
- new NotificationsForm();
- if (gon.features?.vueNotificationDropdown) {
- initNotificationsDropdown();
- } else {
- notificationsDropdown();
- }
+ initNotificationsDropdown();
new ProjectsList();
initInviteMembersBanner();
- initInviteMembersModal();
- initInviteMembersTrigger();
}
diff --git a/app/assets/javascripts/pages/groups/show/index.js b/app/assets/javascripts/pages/groups/show/index.js
index 82ee5ead83d..e4a84dd5eec 100644
--- a/app/assets/javascripts/pages/groups/show/index.js
+++ b/app/assets/javascripts/pages/groups/show/index.js
@@ -1,7 +1,5 @@
import leaveByUrl from '~/namespaces/leave_by_url';
import initGroupDetails from '../shared/group_details';
-document.addEventListener('DOMContentLoaded', () => {
- leaveByUrl('group');
- initGroupDetails();
-});
+leaveByUrl('group');
+initGroupDetails();
diff --git a/app/assets/javascripts/pages/profiles/index.js b/app/assets/javascripts/pages/profiles/index.js
index 535fe5fa4eb..80bc32dd43f 100644
--- a/app/assets/javascripts/pages/profiles/index.js
+++ b/app/assets/javascripts/pages/profiles/index.js
@@ -3,21 +3,19 @@ import '~/profile/gl_crop';
import Profile from '~/profile/profile';
import initSearchSettings from '~/search_settings';
-document.addEventListener('DOMContentLoaded', () => {
- // eslint-disable-next-line func-names
- $(document).on('input.ssh_key', '#key_key', function () {
- const $title = $('#key_title');
- const comment = $(this)
- .val()
- .match(/^\S+ \S+ (.+)\n?$/);
+// eslint-disable-next-line func-names
+$(document).on('input.ssh_key', '#key_key', function () {
+ const $title = $('#key_title');
+ const comment = $(this)
+ .val()
+ .match(/^\S+ \S+ (.+)\n?$/);
- // Extract the SSH Key title from its comment
- if (comment && comment.length > 1) {
- $title.val(comment[1]).change();
- }
- });
+ // Extract the SSH Key title from its comment
+ if (comment && comment.length > 1) {
+ $title.val(comment[1]).change();
+ }
+});
- new Profile(); // eslint-disable-line no-new
+new Profile(); // eslint-disable-line no-new
- initSearchSettings();
-});
+initSearchSettings();
diff --git a/app/assets/javascripts/pages/profiles/index/index.js b/app/assets/javascripts/pages/profiles/index/index.js
deleted file mode 100644
index 586b3be8661..00000000000
--- a/app/assets/javascripts/pages/profiles/index/index.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import notificationsDropdown from '../../../notifications_dropdown';
-import NotificationsForm from '../../../notifications_form';
-
-document.addEventListener('DOMContentLoaded', () => {
- new NotificationsForm(); // eslint-disable-line no-new
- notificationsDropdown();
-});
diff --git a/app/assets/javascripts/pages/profiles/notifications/show/index.js b/app/assets/javascripts/pages/profiles/notifications/show/index.js
index 639f5deb72c..51ba6c7a01e 100644
--- a/app/assets/javascripts/pages/profiles/notifications/show/index.js
+++ b/app/assets/javascripts/pages/profiles/notifications/show/index.js
@@ -1,9 +1,5 @@
import initNotificationsDropdown from '~/notifications';
-import notificationsDropdown from '../../../../notifications_dropdown';
-import NotificationsForm from '../../../../notifications_form';
document.addEventListener('DOMContentLoaded', () => {
- new NotificationsForm(); // eslint-disable-line no-new
- notificationsDropdown();
initNotificationsDropdown();
});
diff --git a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
index ae2209b0292..fdbfc35456f 100644
--- a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
+++ b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
@@ -1,3 +1,4 @@
-import initExpiresAtField from '~/access_tokens';
+import { initExpiresAtField, initProjectsField } from '~/access_tokens';
-document.addEventListener('DOMContentLoaded', initExpiresAtField);
+initExpiresAtField();
+initProjectsField();
diff --git a/app/assets/javascripts/pages/projects/blob/edit/index.js b/app/assets/javascripts/pages/projects/blob/edit/index.js
index 189053f3ed7..ed416610173 100644
--- a/app/assets/javascripts/pages/projects/blob/edit/index.js
+++ b/app/assets/javascripts/pages/projects/blob/edit/index.js
@@ -1,3 +1,3 @@
import initBlobBundle from '~/blob_edit/blob_bundle';
-document.addEventListener('DOMContentLoaded', initBlobBundle);
+initBlobBundle();
diff --git a/app/assets/javascripts/pages/projects/blob/new/index.js b/app/assets/javascripts/pages/projects/blob/new/index.js
index 189053f3ed7..ed416610173 100644
--- a/app/assets/javascripts/pages/projects/blob/new/index.js
+++ b/app/assets/javascripts/pages/projects/blob/new/index.js
@@ -1,3 +1,3 @@
import initBlobBundle from '~/blob_edit/blob_bundle';
-document.addEventListener('DOMContentLoaded', initBlobBundle);
+initBlobBundle();
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index 61ff1c95a38..10bac6d60c2 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -7,61 +7,59 @@ import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
import '~/sourcegraph/load';
-document.addEventListener('DOMContentLoaded', () => {
- new BlobViewer(); // eslint-disable-line no-new
- initBlob();
+new BlobViewer(); // eslint-disable-line no-new
+initBlob();
- const CommitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status');
- const statusLink = document.querySelector('.commit-actions .ci-status-link');
- if (statusLink) {
- statusLink.remove();
- // eslint-disable-next-line no-new
- new Vue({
- el: CommitPipelineStatusEl,
- components: {
- commitPipelineStatus,
- },
- render(createElement) {
- return createElement('commit-pipeline-status', {
- props: {
- endpoint: CommitPipelineStatusEl.dataset.endpoint,
- },
- });
- },
- });
- }
+const CommitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status');
+const statusLink = document.querySelector('.commit-actions .ci-status-link');
+if (statusLink) {
+ statusLink.remove();
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: CommitPipelineStatusEl,
+ components: {
+ commitPipelineStatus,
+ },
+ render(createElement) {
+ return createElement('commit-pipeline-status', {
+ props: {
+ endpoint: CommitPipelineStatusEl.dataset.endpoint,
+ },
+ });
+ },
+ });
+}
- initWebIdeLink({ el: document.getElementById('js-blob-web-ide-link') });
+initWebIdeLink({ el: document.getElementById('js-blob-web-ide-link') });
- GpgBadges.fetch();
+GpgBadges.fetch();
- const codeNavEl = document.getElementById('js-code-navigation');
+const codeNavEl = document.getElementById('js-code-navigation');
- if (codeNavEl) {
- const { codeNavigationPath, blobPath, definitionPathPrefix } = codeNavEl.dataset;
+if (codeNavEl) {
+ const { codeNavigationPath, blobPath, definitionPathPrefix } = codeNavEl.dataset;
- // eslint-disable-next-line promise/catch-or-return
- import('~/code_navigation').then((m) =>
- m.default({
- blobs: [{ path: blobPath, codeNavigationPath }],
- definitionPathPrefix,
- }),
- );
- }
+ // eslint-disable-next-line promise/catch-or-return
+ import('~/code_navigation').then((m) =>
+ m.default({
+ blobs: [{ path: blobPath, codeNavigationPath }],
+ definitionPathPrefix,
+ }),
+ );
+}
- const successPipelineEl = document.querySelector('.js-success-pipeline-modal');
+const successPipelineEl = document.querySelector('.js-success-pipeline-modal');
- if (successPipelineEl) {
- // eslint-disable-next-line no-new
- new Vue({
- el: successPipelineEl,
- render(createElement) {
- return createElement(PipelineTourSuccessModal, {
- props: {
- ...successPipelineEl.dataset,
- },
- });
- },
- });
- }
-});
+if (successPipelineEl) {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: successPipelineEl,
+ render(createElement) {
+ return createElement(PipelineTourSuccessModal, {
+ props: {
+ ...successPipelineEl.dataset,
+ },
+ });
+ },
+ });
+}
diff --git a/app/assets/javascripts/pages/projects/boards/index.js b/app/assets/javascripts/pages/projects/boards/index.js
index 3a06d0faa3e..bde0007ec6a 100644
--- a/app/assets/javascripts/pages/projects/boards/index.js
+++ b/app/assets/javascripts/pages/projects/boards/index.js
@@ -2,8 +2,6 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import initBoards from '~/boards';
import UsersSelect from '~/users_select';
-document.addEventListener('DOMContentLoaded', () => {
- new UsersSelect(); // eslint-disable-line no-new
- new ShortcutsNavigation(); // eslint-disable-line no-new
- initBoards();
-});
+new UsersSelect(); // eslint-disable-line no-new
+new ShortcutsNavigation(); // eslint-disable-line no-new
+initBoards();
diff --git a/app/assets/javascripts/pages/projects/branches/index/index.js b/app/assets/javascripts/pages/projects/branches/index/index.js
index 623d0a10606..72861855c5a 100644
--- a/app/assets/javascripts/pages/projects/branches/index/index.js
+++ b/app/assets/javascripts/pages/projects/branches/index/index.js
@@ -2,8 +2,6 @@ import AjaxLoadingSpinner from '~/branches/ajax_loading_spinner';
import DeleteModal from '~/branches/branches_delete_modal';
import initDiverganceGraph from '~/branches/divergence_graph';
-document.addEventListener('DOMContentLoaded', () => {
- AjaxLoadingSpinner.init();
- new DeleteModal(); // eslint-disable-line no-new
- initDiverganceGraph(document.querySelector('.js-branch-list').dataset.divergingCountsEndpoint);
-});
+AjaxLoadingSpinner.init();
+new DeleteModal(); // eslint-disable-line no-new
+initDiverganceGraph(document.querySelector('.js-branch-list').dataset.divergingCountsEndpoint);
diff --git a/app/assets/javascripts/pages/projects/branches/new/index.js b/app/assets/javascripts/pages/projects/branches/new/index.js
index 13ff47d53c2..364223f1898 100644
--- a/app/assets/javascripts/pages/projects/branches/new/index.js
+++ b/app/assets/javascripts/pages/projects/branches/new/index.js
@@ -1,11 +1,8 @@
import $ from 'jquery';
import NewBranchForm from '~/new_branch_form';
-document.addEventListener(
- 'DOMContentLoaded',
- () =>
- new NewBranchForm(
- $('.js-create-branch-form'),
- JSON.parse(document.getElementById('availableRefs').innerHTML),
- ),
+// eslint-disable-next-line no-new
+new NewBranchForm(
+ $('.js-create-branch-form'),
+ JSON.parse(document.getElementById('availableRefs').innerHTML),
);
diff --git a/app/assets/javascripts/pages/projects/compare/show/index.js b/app/assets/javascripts/pages/projects/compare/show/index.js
index f1cf9caa28b..549e596cb8d 100644
--- a/app/assets/javascripts/pages/projects/compare/show/index.js
+++ b/app/assets/javascripts/pages/projects/compare/show/index.js
@@ -1,6 +1,9 @@
import Diff from '~/diff';
import GpgBadges from '~/gpg_badges';
import initChangesDropdown from '~/init_changes_dropdown';
+import initCompareSelector from '~/projects/compare';
+
+initCompareSelector();
document.addEventListener('DOMContentLoaded', () => {
new Diff(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js
index df58e9dd072..255d05b39be 100644
--- a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js
+++ b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js
@@ -1,3 +1,3 @@
-import initCycleAnalytics from '~/cycle_analytics/cycle_analytics_bundle';
+import initCycleAnalytics from '~/cycle_analytics';
document.addEventListener('DOMContentLoaded', initCycleAnalytics);
diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js
index bbe84322462..43fd5375222 100644
--- a/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js
+++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js
@@ -1,3 +1,5 @@
+/* eslint-disable no-new */
+
import Vue from 'vue';
import Vuex from 'vuex';
import EditUserList from '~/user_lists/components/edit_user_list.vue';
@@ -5,15 +7,13 @@ import createStore from '~/user_lists/store/edit';
Vue.use(Vuex);
-document.addEventListener('DOMContentLoaded', () => {
- const el = document.getElementById('js-edit-user-list');
- const { userListsDocsPath } = el.dataset;
- return new Vue({
- el,
- store: createStore(el.dataset),
- provide: { userListsDocsPath },
- render(h) {
- return h(EditUserList, {});
- },
- });
+const el = document.getElementById('js-edit-user-list');
+const { userListsDocsPath } = el.dataset;
+new Vue({
+ el,
+ store: createStore(el.dataset),
+ provide: { userListsDocsPath },
+ render(h) {
+ return h(EditUserList, {});
+ },
});
diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js
index 679f0af8efc..e855447d5ce 100644
--- a/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js
+++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js
@@ -1,3 +1,5 @@
+/* eslint-disable no-new */
+
import Vue from 'vue';
import Vuex from 'vuex';
import NewUserList from '~/user_lists/components/new_user_list.vue';
@@ -5,18 +7,16 @@ import createStore from '~/user_lists/store/new';
Vue.use(Vuex);
-document.addEventListener('DOMContentLoaded', () => {
- const el = document.getElementById('js-new-user-list');
- const { userListsDocsPath, featureFlagsPath } = el.dataset;
- return new Vue({
- el,
- store: createStore(el.dataset),
- provide: {
- userListsDocsPath,
- featureFlagsPath,
- },
- render(h) {
- return h(NewUserList);
- },
- });
+const el = document.getElementById('js-new-user-list');
+const { userListsDocsPath, featureFlagsPath } = el.dataset;
+new Vue({
+ el,
+ store: createStore(el.dataset),
+ provide: {
+ userListsDocsPath,
+ featureFlagsPath,
+ },
+ render(h) {
+ return h(NewUserList);
+ },
});
diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js
index bccd9dce2ec..2dca0ea7f29 100644
--- a/app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js
+++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js
@@ -1,18 +1,3 @@
-import Vue from 'vue';
-import Vuex from 'vuex';
-import UserList from '~/user_lists/components/user_list.vue';
-import createStore from '~/user_lists/store/show';
+import featureFlagsUserListInit from '~/projects/feature_flags_user_lists/show/index';
-Vue.use(Vuex);
-
-document.addEventListener('DOMContentLoaded', () => {
- const el = document.getElementById('js-edit-user-list');
- return new Vue({
- el,
- store: createStore(el.dataset),
- render(h) {
- const { emptyStatePath } = el.dataset;
- return h(UserList, { props: { emptyStatePath } });
- },
- });
-});
+featureFlagsUserListInit();
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/app.vue b/app/assets/javascripts/pages/projects/forks/new/components/app.vue
new file mode 100644
index 00000000000..02b357d389b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/forks/new/components/app.vue
@@ -0,0 +1,72 @@
+<script>
+import ForkForm from './fork_form.vue';
+
+export default {
+ components: {
+ ForkForm,
+ },
+ props: {
+ forkIllustration: {
+ 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,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="row gl-mt-5">
+ <div class="col-lg-3">
+ <img :src="forkIllustration" />
+ <h4 class="">{{ s__('ForkProject|Fork project') }}</h4>
+ <p>
+ {{ s__('ForkProject|A fork is a copy of a project.') }}
+ <br />
+ {{
+ s__(
+ 'ForkProject|Forking a repository allows you to make changes without affecting the original project.',
+ )
+ }}
+ </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"
+ />
+ </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
new file mode 100644
index 00000000000..7112b23775d
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
@@ -0,0 +1,304 @@
+<script>
+import {
+ GlIcon,
+ GlLink,
+ GlForm,
+ GlFormInputGroup,
+ GlInputGroupText,
+ GlFormInput,
+ GlFormGroup,
+ GlFormTextarea,
+ GlButton,
+ GlFormRadio,
+ GlFormRadioGroup,
+ GlFormSelect,
+} from '@gitlab/ui';
+import { buildApiUrl } from '~/api/api_utils';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import csrf from '~/lib/utils/csrf';
+import { redirectTo } from '~/lib/utils/url_utility';
+import { s__ } from '~/locale';
+
+const PRIVATE_VISIBILITY = 'private';
+const INTERNAL_VISIBILITY = 'internal';
+const PUBLIC_VISIBILITY = 'public';
+
+const ALLOWED_VISIBILITY = {
+ private: [PRIVATE_VISIBILITY],
+ internal: [INTERNAL_VISIBILITY, PRIVATE_VISIBILITY],
+ public: [INTERNAL_VISIBILITY, PRIVATE_VISIBILITY, PUBLIC_VISIBILITY],
+};
+
+export default {
+ components: {
+ GlForm,
+ GlIcon,
+ GlLink,
+ GlButton,
+ GlFormInputGroup,
+ GlInputGroupText,
+ GlFormInput,
+ GlFormTextarea,
+ GlFormGroup,
+ GlFormRadio,
+ GlFormRadioGroup,
+ GlFormSelect,
+ },
+ inject: {
+ newGroupPath: {
+ default: '',
+ },
+ visibilityHelpPath: {
+ default: '',
+ },
+ },
+ props: {
+ 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,
+ },
+ },
+ data() {
+ return {
+ isSaving: false,
+ namespaces: [],
+ selectedNamespace: {},
+ fork: {
+ name: this.projectName,
+ slug: this.projectPath,
+ description: this.projectDescription,
+ visibility: this.projectVisibility,
+ },
+ };
+ },
+ computed: {
+ projectUrl() {
+ return `${gon.gitlab_url}/`;
+ },
+ projectAllowedVisibility() {
+ return ALLOWED_VISIBILITY[this.projectVisibility];
+ },
+ namespaceAllowedVisibility() {
+ return (
+ ALLOWED_VISIBILITY[this.selectedNamespace.visibility] ||
+ ALLOWED_VISIBILITY[PUBLIC_VISIBILITY]
+ );
+ },
+ visibilityLevels() {
+ return [
+ {
+ text: s__('ForkProject|Private'),
+ value: PRIVATE_VISIBILITY,
+ icon: 'lock',
+ help: s__('ForkProject|The project can be accessed without any authentication.'),
+ disabled: this.isVisibilityLevelDisabled(PRIVATE_VISIBILITY),
+ },
+ {
+ text: s__('ForkProject|Internal'),
+ value: INTERNAL_VISIBILITY,
+ icon: 'shield',
+ help: s__('ForkProject|The project can be accessed by any logged in user.'),
+ disabled: this.isVisibilityLevelDisabled(INTERNAL_VISIBILITY),
+ },
+ {
+ text: s__('ForkProject|Public'),
+ value: PUBLIC_VISIBILITY,
+ icon: 'earth',
+ help: s__(
+ 'ForkProject|Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.',
+ ),
+ disabled: this.isVisibilityLevelDisabled(PUBLIC_VISIBILITY),
+ },
+ ];
+ },
+ },
+ watch: {
+ selectedNamespace(newVal) {
+ const { visibility } = newVal;
+
+ if (this.projectAllowedVisibility.includes(visibility)) {
+ this.fork.visibility = visibility;
+ }
+ },
+ },
+ mounted() {
+ this.fetchNamespaces();
+ },
+ methods: {
+ async fetchNamespaces() {
+ const { data } = await axios.get(this.endpoint);
+ this.namespaces = data.namespaces;
+ },
+ isVisibilityLevelDisabled(visibilityLevel) {
+ return !(
+ this.projectAllowedVisibility.includes(visibilityLevel) &&
+ this.namespaceAllowedVisibility.includes(visibilityLevel)
+ );
+ },
+ async onSubmit() {
+ this.isSaving = true;
+
+ const { projectId } = this;
+ const { name, slug, description, visibility } = this.fork;
+ const { id: namespaceId } = this.selectedNamespace;
+
+ const postParams = {
+ id: projectId,
+ name,
+ namespace_id: namespaceId,
+ path: slug,
+ description,
+ visibility,
+ };
+
+ const forkProjectPath = `/api/:version/projects/:id/fork`;
+ const url = buildApiUrl(forkProjectPath).replace(':id', encodeURIComponent(this.projectId));
+
+ try {
+ const { data } = await axios.post(url, postParams);
+ redirectTo(data.web_url);
+ return;
+ } catch (error) {
+ createFlash({ message: error });
+ }
+ },
+ },
+ csrf,
+};
+</script>
+
+<template>
+ <gl-form method="POST" @submit.prevent="onSubmit">
+ <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
+
+ <gl-form-group label="Project name" label-for="fork-name">
+ <gl-form-input id="fork-name" v-model="fork.name" data-testid="fork-name-input" required />
+ </gl-form-group>
+
+ <div class="gl-md-display-flex">
+ <div class="gl-flex-basis-half">
+ <gl-form-group label="Project URL" label-for="fork-url" class="gl-md-mr-3">
+ <gl-form-input-group>
+ <template #prepend>
+ <gl-input-group-text>
+ {{ projectUrl }}
+ </gl-input-group-text>
+ </template>
+ <gl-form-select
+ id="fork-url"
+ v-model="selectedNamespace"
+ data-testid="fork-url-input"
+ required
+ >
+ <template slot="first">
+ <option :value="null" disabled>{{ s__('ForkProject|Select a namespace') }}</option>
+ </template>
+ <option v-for="namespace in namespaces" :key="namespace.id" :value="namespace">
+ {{ namespace.name }}
+ </option>
+ </gl-form-select>
+ </gl-form-input-group>
+ </gl-form-group>
+ </div>
+ <div class="gl-flex-basis-half">
+ <gl-form-group label="Project slug" label-for="fork-slug" class="gl-md-ml-3">
+ <gl-form-input
+ id="fork-slug"
+ v-model="fork.slug"
+ data-testid="fork-slug-input"
+ required
+ />
+ </gl-form-group>
+ </div>
+ </div>
+
+ <p class="gl-mt-n5 gl-text-gray-500">
+ {{ s__('ForkProject|Want to house several dependent projects under the same namespace?') }}
+ <gl-link :href="newGroupPath" target="_blank">
+ {{ s__('ForkProject|Create a group') }}
+ </gl-link>
+ </p>
+
+ <gl-form-group label="Project description (optional)" label-for="fork-description">
+ <gl-form-textarea
+ id="fork-description"
+ v-model="fork.description"
+ data-testid="fork-description-textarea"
+ />
+ </gl-form-group>
+
+ <gl-form-group>
+ <label>
+ {{ s__('ForkProject|Visibility level') }}
+ <gl-link :href="visibilityHelpPath" target="_blank">
+ <gl-icon name="question-o" />
+ </gl-link>
+ </label>
+ <gl-form-radio-group
+ v-model="fork.visibility"
+ data-testid="fork-visibility-radio-group"
+ required
+ >
+ <gl-form-radio
+ v-for="{ text, value, icon, help, disabled } in visibilityLevels"
+ :key="value"
+ :value="value"
+ :disabled="disabled"
+ :data-testid="`radio-${value}`"
+ >
+ <div>
+ <gl-icon :name="icon" />
+ <span>{{ text }}</span>
+ </div>
+ <template #help>{{ help }}</template>
+ </gl-form-radio>
+ </gl-form-radio-group>
+ </gl-form-group>
+
+ <div class="gl-display-flex gl-justify-content-space-between gl-mt-8">
+ <gl-button
+ type="submit"
+ category="primary"
+ variant="confirm"
+ data-testid="submit-button"
+ :loading="isSaving"
+ >
+ {{ s__('ForkProject|Fork project') }}
+ </gl-button>
+ <gl-button
+ type="reset"
+ class="gl-mr-3"
+ data-testid="cancel-button"
+ :disabled="isSaving"
+ :href="projectFullPath"
+ >
+ {{ s__('ForkProject|Cancel') }}
+ </gl-button>
+ </div>
+ </gl-form>
+</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
index 46d1696b88b..88f4bba5e2a 100644
--- 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
@@ -11,6 +11,7 @@ import {
} 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: {
@@ -20,6 +21,7 @@ export default {
GlButton,
GlTooltip,
GlLink,
+ UserAccessRoleBadge,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -72,7 +74,9 @@ export default {
<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-2 gl-display-flex gl-align-items-center">
+ <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
@@ -84,12 +88,12 @@ export default {
<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-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-mr-0 gl-inline-flex gl-mt-3 text-secondary"
+ class="gl-display-inline-flex gl-mt-3 gl-mr-3 gl-text-gray-500"
:name="visibilityIcon"
:title="visibilityTooltip"
/>
@@ -99,11 +103,11 @@ export default {
class="gl-display-none gl-sm-display-flex gl-mt-3 gl-mr-1"
>{{ __('pending removal') }}</gl-badge
>
- <span v-if="group.permission" class="user-access-role gl-mt-3">
+ <user-access-role-badge v-if="group.permission" class="gl-mt-3">
{{ group.permission }}
- </span>
+ </user-access-role-badge>
</div>
- <div v-if="group.description" class="description">
+ <div v-if="group.description" class="description gl-line-height-20">
<span v-safe-html="group.markdown_description"> </span>
</div>
</div>
diff --git a/app/assets/javascripts/pages/projects/forks/new/index.js b/app/assets/javascripts/pages/projects/forks/new/index.js
index a018d7e0926..372967c8a1e 100644
--- a/app/assets/javascripts/pages/projects/forks/new/index.js
+++ b/app/assets/javascripts/pages/projects/forks/new/index.js
@@ -1,12 +1,52 @@
import Vue from 'vue';
+import App from './components/app.vue';
import ForkGroupsList from './components/fork_groups_list.vue';
-document.addEventListener('DOMContentLoaded', () => {
- const mountElement = document.getElementById('fork-groups-mount-element');
+const mountElement = document.getElementById('fork-groups-mount-element');
+if (gon.features.forkProjectForm) {
+ const {
+ forkIllustration,
+ endpoint,
+ newGroupPath,
+ projectFullPath,
+ visibilityHelpPath,
+ projectId,
+ projectName,
+ projectPath,
+ projectDescription,
+ projectVisibility,
+ } = 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,
+ },
+ });
+ },
+ });
+} else {
const { endpoint } = mountElement.dataset;
- return new Vue({
+ // eslint-disable-next-line no-new
+ new Vue({
el: mountElement,
render(h) {
return h(ForkGroupsList, {
@@ -16,4 +56,4 @@ document.addEventListener('DOMContentLoaded', () => {
});
},
});
-});
+}
diff --git a/app/assets/javascripts/pages/projects/imports/show/index.js b/app/assets/javascripts/pages/projects/imports/show/index.js
index d5f92baf054..8397826f8eb 100644
--- a/app/assets/javascripts/pages/projects/imports/show/index.js
+++ b/app/assets/javascripts/pages/projects/imports/show/index.js
@@ -1,5 +1,3 @@
import ProjectImport from '~/project_import';
-document.addEventListener('DOMContentLoaded', () => {
- new ProjectImport(); // eslint-disable-line no-new
-});
+new ProjectImport(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js
index 525d90e162d..366f8dc61bc 100644
--- a/app/assets/javascripts/pages/projects/issues/index/index.js
+++ b/app/assets/javascripts/pages/projects/issues/index/index.js
@@ -2,9 +2,10 @@
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
+import initCsvImportExportButtons from '~/issuable/init_csv_import_export_buttons';
import initIssuableByEmail from '~/issuable/init_issuable_by_email';
import IssuableIndex from '~/issuable_index';
-import initIssuablesList from '~/issues_list';
+import initIssuablesList, { initIssuesListApp } from '~/issues_list';
import initManualOrdering from '~/manual_ordering';
import { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants';
@@ -26,3 +27,5 @@ new UsersSelect();
initManualOrdering();
initIssuablesList();
initIssuableByEmail();
+initCsvImportExportButtons();
+initIssuesListApp();
diff --git a/app/assets/javascripts/pages/projects/jobs/index/index.js b/app/assets/javascripts/pages/projects/jobs/index/index.js
index 6a70d4cf26d..681d151b77f 100644
--- a/app/assets/javascripts/pages/projects/jobs/index/index.js
+++ b/app/assets/javascripts/pages/projects/jobs/index/index.js
@@ -1,19 +1,17 @@
import Vue from 'vue';
import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
-document.addEventListener('DOMContentLoaded', () => {
- const remainingTimeElements = document.querySelectorAll('.js-remaining-time');
- remainingTimeElements.forEach(
- (el) =>
- new Vue({
- el,
- render(h) {
- return h(GlCountdown, {
- props: {
- endDateString: el.dateTime,
- },
- });
- },
- }),
- );
-});
+const remainingTimeElements = document.querySelectorAll('.js-remaining-time');
+remainingTimeElements.forEach(
+ (el) =>
+ new Vue({
+ el,
+ render(h) {
+ return h(GlCountdown, {
+ props: {
+ endDateString: el.dateTime,
+ },
+ });
+ },
+ }),
+);
diff --git a/app/assets/javascripts/pages/projects/labels/edit/index.js b/app/assets/javascripts/pages/projects/labels/edit/index.js
index 83d6ac9fd14..3b7562deed9 100644
--- a/app/assets/javascripts/pages/projects/labels/edit/index.js
+++ b/app/assets/javascripts/pages/projects/labels/edit/index.js
@@ -1,3 +1,3 @@
import Labels from 'ee_else_ce/labels';
-document.addEventListener('DOMContentLoaded', () => new Labels());
+new Labels(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue
index 0393793bfe1..32ca623ca45 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue
@@ -1,11 +1,11 @@
<script>
import { GlLink } from '@gitlab/ui';
-import { ACTION_TEXT } from '../constants';
+import { ACTION_LABELS } from '../constants';
export default {
components: { GlLink },
i18n: {
- ACTION_TEXT,
+ ACTION_LABELS,
},
props: {
actions: {
@@ -18,9 +18,9 @@ export default {
<template>
<ul>
<li v-for="(value, action) in actions" :key="action">
- <span v-if="value.completed">{{ $options.i18n.ACTION_TEXT[action] }}</span>
+ <span v-if="value.completed">{{ $options.i18n.ACTION_LABELS[action].title }}</span>
<span v-else>
- <gl-link :href="value.url">{{ $options.i18n.ACTION_TEXT[action] }}</gl-link>
+ <gl-link :href="value.url">{{ $options.i18n.ACTION_LABELS[action].title }}</gl-link>
</span>
</li>
</ul>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue
index 0393793bfe1..230054ff76e 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue
@@ -1,11 +1,35 @@
<script>
-import { GlLink } from '@gitlab/ui';
-import { ACTION_TEXT } from '../constants';
+import { GlProgressBar, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { ACTION_LABELS } from '../constants';
+import LearnGitlabInfoCard from './learn_gitlab_info_card.vue';
export default {
- components: { GlLink },
+ components: { LearnGitlabInfoCard, GlProgressBar, GlSprintf },
i18n: {
- ACTION_TEXT,
+ title: s__('LearnGitLab|Learn GitLab'),
+ description: s__(
+ 'LearnGitLab|Ready to get started with GitLab? Follow these steps to set up your workspace, plan and commit changes, and deploy your project.',
+ ),
+ percentageCompleted: s__(`LearnGitLab|%{percentage}%{percentSymbol} completed`),
+ workspace: {
+ title: s__('LearnGitLab|Set up your workspace'),
+ description: s__(
+ "LearnGitLab|Complete these tasks first so you can enjoy GitLab's features to their fullest:",
+ ),
+ },
+ plan: {
+ title: s__('LearnGitLab|Plan and execute'),
+ description: s__(
+ 'LearnGitLab|Create a workflow for your new workspace, and learn how GitLab features work together:',
+ ),
+ },
+ deploy: {
+ title: s__('LearnGitLab|Deploy'),
+ description: s__(
+ 'LearnGitLab|Use your new GitLab workflow to deploy your application, monitor its health, and keep it secure:',
+ ),
+ },
},
props: {
actions: {
@@ -13,15 +37,76 @@ export default {
type: Object,
},
},
+ maxValue: Object.keys(ACTION_LABELS).length,
+ methods: {
+ infoProps(action) {
+ return {
+ ...this.actions[action],
+ ...ACTION_LABELS[action],
+ };
+ },
+ progressValue() {
+ return Object.values(this.actions).filter((a) => a.completed).length;
+ },
+ progressPercentage() {
+ return Math.round((this.progressValue() / this.$options.maxValue) * 100);
+ },
+ },
};
</script>
<template>
- <ul>
- <li v-for="(value, action) in actions" :key="action">
- <span v-if="value.completed">{{ $options.i18n.ACTION_TEXT[action] }}</span>
- <span v-else>
- <gl-link :href="value.url">{{ $options.i18n.ACTION_TEXT[action] }}</gl-link>
- </span>
- </li>
- </ul>
+ <div>
+ <div class="row">
+ <div class="gl-mb-7 col-md-8 col-lg-7">
+ <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1>
+ <p class="gl-text-gray-700 gl-mb-0">{{ $options.i18n.description }}</p>
+ </div>
+ </div>
+
+ <div class="gl-mb-3">
+ <p class="gl-text-gray-500 gl-mb-2" data-testid="completion-percentage">
+ <gl-sprintf :message="$options.i18n.percentageCompleted">
+ <template #percentage>{{ progressPercentage() }}</template>
+ <template #percentSymbol>%</template>
+ </gl-sprintf>
+ </p>
+ <gl-progress-bar :value="progressValue()" :max="$options.maxValue" />
+ </div>
+
+ <h2 class="gl-font-lg gl-mb-3">{{ $options.i18n.workspace.title }}</h2>
+ <p class="gl-text-gray-700 gl-mb-6">{{ $options.i18n.workspace.description }}</p>
+
+ <div class="row row-cols-2 row-cols-md-3 row-cols-lg-4">
+ <div class="col gl-mb-6"><learn-gitlab-info-card v-bind="infoProps('userAdded')" /></div>
+ <div class="col gl-mb-6"><learn-gitlab-info-card v-bind="infoProps('gitWrite')" /></div>
+ <div class="col gl-mb-6">
+ <learn-gitlab-info-card v-bind="infoProps('pipelineCreated')" />
+ </div>
+ <div class="col gl-mb-6"><learn-gitlab-info-card v-bind="infoProps('trialStarted')" /></div>
+ <div class="col gl-mb-6">
+ <learn-gitlab-info-card v-bind="infoProps('codeOwnersEnabled')" />
+ </div>
+ <div class="col gl-mb-6">
+ <learn-gitlab-info-card v-bind="infoProps('requiredMrApprovalsEnabled')" />
+ </div>
+ </div>
+
+ <h2 class="gl-font-lg gl-mb-3">{{ $options.i18n.plan.title }}</h2>
+ <p class="gl-text-gray-700 gl-mb-6">{{ $options.i18n.plan.description }}</p>
+
+ <div class="row row-cols-2 row-cols-md-3 row-cols-lg-4">
+ <div class="col gl-mb-6">
+ <learn-gitlab-info-card v-bind="infoProps('mergeRequestCreated')" />
+ </div>
+ </div>
+
+ <h2 class="gl-font-lg gl-mb-3">{{ $options.i18n.deploy.title }}</h2>
+ <p class="gl-text-gray-700 gl-mb-6">{{ $options.i18n.deploy.description }}</p>
+
+ <div class="row row-cols-2 row-cols-lg-4 g-2 g-lg-3">
+ <div class="col gl-mb-6">
+ <learn-gitlab-info-card v-bind="infoProps('securityScanEnabled')" />
+ </div>
+ </div>
+ </div>
</template>
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
new file mode 100644
index 00000000000..3d2a8eed9d4
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue
@@ -0,0 +1,70 @@
+<script>
+import { GlLink, GlCard, GlIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ name: 'LearnGitlabInfoCard',
+ components: { GlLink, GlCard, GlIcon },
+ i18n: {
+ trial: s__('Learn GitLab|Trial only'),
+ },
+ props: {
+ title: {
+ required: true,
+ type: String,
+ },
+ description: {
+ required: true,
+ type: String,
+ },
+ actionLabel: {
+ required: true,
+ type: String,
+ },
+ url: {
+ required: true,
+ type: String,
+ },
+ completed: {
+ required: true,
+ type: Boolean,
+ },
+ svg: {
+ required: true,
+ type: String,
+ },
+ trialRequired: {
+ default: false,
+ required: false,
+ type: Boolean,
+ },
+ },
+};
+</script>
+<template>
+ <gl-card class="gl-pt-0">
+ <div class="gl-text-right gl-h-5">
+ <gl-icon
+ v-if="completed"
+ name="check-circle-filled"
+ class="gl-text-green-500"
+ :size="16"
+ data-testid="completed-icon"
+ />
+ <span
+ v-else-if="trialRequired"
+ class="gl-text-gray-500 gl-font-sm gl-font-style-italic"
+ data-testid="trial-only"
+ >{{ $options.i18n.trial }}</span
+ >
+ </div>
+ <div
+ class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content"
+ >
+ <img :src="svg" />
+ <h6>{{ title }}</h6>
+ <p class="gl-font-sm gl-text-gray-700">{{ description }}</p>
+ <gl-link :href="url" target="_blank">{{ actionLabel }}</gl-link>
+ </div>
+ </gl-card>
+</template>
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 8606af29785..80f04b0cf44 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
@@ -1,12 +1,50 @@
import { s__ } from '~/locale';
-export const ACTION_TEXT = {
- gitWrite: s__('LearnGitLab|Create a repository'),
- userAdded: s__('LearnGitLab|Invite your colleagues'),
- pipelineCreated: s__('LearnGitLab|Set-up CI/CD'),
- trialStarted: s__('LearnGitLab|Start a free trial of GitLab Gold'),
- codeOwnersEnabled: s__('LearnGitLab|Add code owners'),
- requiredMrApprovalsEnabled: s__('LearnGitLab|Enable require merge approvals'),
- mergeRequestCreated: s__('LearnGitLab|Submit a merge request (MR)'),
- securityScanEnabled: s__('LearnGitLab|Run a Security scan using CI/CD'),
+export const ACTION_LABELS = {
+ gitWrite: {
+ title: s__('LearnGitLab|Create or import a repository'),
+ actionLabel: s__('LearnGitLab|Create or import a repository'),
+ description: s__('LearnGitLab|Create or import your first repository into your new project.'),
+ },
+ userAdded: {
+ title: s__('LearnGitLab|Invite your colleagues'),
+ actionLabel: s__('LearnGitLab|Invite your colleagues'),
+ description: s__(
+ 'LearnGitLab|GitLab works best as a team. Invite your colleague to enjoy all features.',
+ ),
+ },
+ pipelineCreated: {
+ title: s__('LearnGitLab|Set up CI/CD'),
+ actionLabel: s__('LearnGitLab|Set-up CI/CD'),
+ description: s__('LearnGitLab|Save time by automating your integration and deployment tasks.'),
+ },
+ trialStarted: {
+ title: s__('LearnGitLab|Start a free Ultimate trial'),
+ actionLabel: s__('LearnGitLab|Try GitLab Ultimate for free'),
+ description: s__('LearnGitLab|Try all GitLab features for 30 days, no credit card required.'),
+ },
+ codeOwnersEnabled: {
+ title: s__('LearnGitLab|Add code owners'),
+ actionLabel: s__('LearnGitLab|Add code owners'),
+ description: s__(
+ 'LearnGitLab|Prevent unexpected changes to important assets by assigning ownership of files and paths.',
+ ),
+ trialRequired: true,
+ },
+ requiredMrApprovalsEnabled: {
+ title: s__('LearnGitLab|Add merge request approval'),
+ actionLabel: s__('LearnGitLab|Enable require merge approvals'),
+ description: s__('LearnGitLab|Route code reviews to the right reviewers, every time.'),
+ trialRequired: true,
+ },
+ mergeRequestCreated: {
+ title: s__('LearnGitLab|Submit a merge request'),
+ actionLabel: s__('LearnGitLab|Submit a merge request (MR)'),
+ description: s__('LearnGitLab|Review and edit proposed changes to source code.'),
+ },
+ securityScanEnabled: {
+ title: s__('LearnGitLab|Run a security scan'),
+ actionLabel: s__('LearnGitLab|Run a Security scan'),
+ description: s__('LearnGitLab|Scan your code to uncover vulnerabilities before deploying.'),
+ },
};
diff --git a/app/assets/javascripts/pages/projects/logs/index.js b/app/assets/javascripts/pages/projects/logs/index.js
index 36747069ebb..0cff1ffc27e 100644
--- a/app/assets/javascripts/pages/projects/logs/index.js
+++ b/app/assets/javascripts/pages/projects/logs/index.js
@@ -1,3 +1,3 @@
import logsBundle from '~/logs';
-document.addEventListener('DOMContentLoaded', logsBundle);
+logsBundle();
diff --git a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
index 399aebb0c83..ec21d8c84e0 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
@@ -1,7 +1,5 @@
import initMergeRequest from '~/pages/projects/merge_requests/init_merge_request';
import initCheckFormState from './check_form_state';
-document.addEventListener('DOMContentLoaded', () => {
- initMergeRequest();
- initCheckFormState();
-});
+initMergeRequest();
+initCheckFormState();
diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
index 76705256fe2..d279086df7b 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
@@ -1,6 +1,7 @@
import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
+import initCsvImportExportButtons from '~/issuable/init_csv_import_export_buttons';
import initIssuableByEmail from '~/issuable/init_issuable_by_email';
import IssuableIndex from '~/issuable_index';
import { FILTERED_SEARCH } from '~/pages/constants';
@@ -22,3 +23,4 @@ new UsersSelect(); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new
initIssuableByEmail();
+initCsvImportExportButtons();
diff --git a/app/assets/javascripts/pages/projects/milestones/edit/index.js b/app/assets/javascripts/pages/projects/milestones/edit/index.js
index 9a4ebf9890d..4f8514a9a1d 100644
--- a/app/assets/javascripts/pages/projects/milestones/edit/index.js
+++ b/app/assets/javascripts/pages/projects/milestones/edit/index.js
@@ -1,3 +1,3 @@
-import initForm from '../../../../shared/milestones/form';
+import initForm from '~/shared/milestones/form';
-document.addEventListener('DOMContentLoaded', () => initForm());
+initForm();
diff --git a/app/assets/javascripts/pages/projects/milestones/index/index.js b/app/assets/javascripts/pages/projects/milestones/index/index.js
index 38789365a67..150b506b121 100644
--- a/app/assets/javascripts/pages/projects/milestones/index/index.js
+++ b/app/assets/javascripts/pages/projects/milestones/index/index.js
@@ -1,3 +1,3 @@
import milestones from '~/pages/milestones/shared';
-document.addEventListener('DOMContentLoaded', milestones);
+milestones();
diff --git a/app/assets/javascripts/pages/projects/milestones/new/index.js b/app/assets/javascripts/pages/projects/milestones/new/index.js
index 9a4ebf9890d..364b0d95d9c 100644
--- a/app/assets/javascripts/pages/projects/milestones/new/index.js
+++ b/app/assets/javascripts/pages/projects/milestones/new/index.js
@@ -1,3 +1,3 @@
import initForm from '../../../../shared/milestones/form';
-document.addEventListener('DOMContentLoaded', () => initForm());
+initForm();
diff --git a/app/assets/javascripts/pages/projects/milestones/show/index.js b/app/assets/javascripts/pages/projects/milestones/show/index.js
index a853413e1f7..3c755e9b98c 100644
--- a/app/assets/javascripts/pages/projects/milestones/show/index.js
+++ b/app/assets/javascripts/pages/projects/milestones/show/index.js
@@ -1,7 +1,5 @@
import milestones from '~/pages/milestones/shared';
import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show';
-document.addEventListener('DOMContentLoaded', () => {
- initMilestonesShow();
- milestones();
-});
+initMilestonesShow();
+milestones();
diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js
index 437594fdf11..e10e2872dce 100644
--- a/app/assets/javascripts/pages/projects/new/index.js
+++ b/app/assets/javascripts/pages/projects/new/index.js
@@ -3,28 +3,26 @@ import { __ } from '~/locale';
import initProjectVisibilitySelector from '../../../project_visibility';
import initProjectNew from '../../../projects/project_new';
-document.addEventListener('DOMContentLoaded', () => {
- initProjectVisibilitySelector();
- initProjectNew.bindEvents();
+initProjectVisibilitySelector();
+initProjectNew.bindEvents();
- import(
- /* webpackChunkName: 'experiment_new_project_creation' */ '../../../projects/experiment_new_project_creation'
- )
- .then((m) => {
- const el = document.querySelector('.js-experiment-new-project-creation');
+import(
+ /* webpackChunkName: 'experiment_new_project_creation' */ '../../../projects/experiment_new_project_creation'
+)
+ .then((m) => {
+ const el = document.querySelector('.js-experiment-new-project-creation');
- if (!el) {
- return;
- }
+ if (!el) {
+ return;
+ }
- const config = {
- hasErrors: 'hasErrors' in el.dataset,
- isCiCdAvailable: 'isCiCdAvailable' in el.dataset,
- newProjectGuidelines: el.dataset.newProjectGuidelines,
- };
- m.default(el, config);
- })
- .catch(() => {
- createFlash(__('An error occurred while loading project creation UI'));
- });
-});
+ const config = {
+ hasErrors: 'hasErrors' in el.dataset,
+ isCiCdAvailable: 'isCiCdAvailable' in el.dataset,
+ newProjectGuidelines: el.dataset.newProjectGuidelines,
+ };
+ m.default(el, config);
+ })
+ .catch(() => {
+ createFlash(__('An error occurred while loading project creation UI'));
+ });
diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js
index ed11b07be4a..4aea5614bfb 100644
--- a/app/assets/javascripts/pages/projects/project_members/index.js
+++ b/app/assets/javascripts/pages/projects/project_members/index.js
@@ -1,11 +1,14 @@
import Vue from 'vue';
-import { deprecatedCreateFlash as flash } from '~/flash';
import groupsSelect from '~/groups_select';
+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 initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
-import { __ } from '~/locale';
+import { s__ } from '~/locale';
import memberExpirationDate from '~/member_expiration_date';
-import Members from '~/members';
+import { initMembersApp } from '~/members';
+import { groupLinkRequestFormatter } from '~/members/utils';
+import { projectMemberRequestFormatter } from '~/projects/members/utils';
import UsersSelect from '~/users_select';
import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue';
@@ -29,68 +32,51 @@ memberExpirationDate('.js-access-expiration-date-groups');
mountRemoveMemberModal();
initInviteMembersModal();
initInviteMembersTrigger();
+initInviteGroupTrigger();
-new Members(); // eslint-disable-line no-new
-new UsersSelect(); // eslint-disable-line no-new
+// 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();
-if (window.gon.features.vueProjectMembersList) {
- const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions'];
+new UsersSelect(); // eslint-disable-line no-new
- Promise.all([
- import('~/members/index'),
- import('~/members/utils'),
- import('~/projects/members/utils'),
- import('~/locale'),
- ])
- .then(
- ([
- { initMembersApp },
- { groupLinkRequestFormatter },
- { projectMemberRequestFormatter },
- { s__ },
- ]) => {
- initMembersApp(document.querySelector('.js-project-members-list'), {
- tableFields: SHARED_FIELDS.concat(['source', 'granted']),
- tableAttrs: { tr: { 'data-qa-selector': 'member_row' } },
- tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'],
- requestFormatter: projectMemberRequestFormatter,
- filteredSearchBar: {
- show: true,
- tokens: ['with_inherited_permissions'],
- searchParam: 'search',
- placeholder: s__('Members|Filter members'),
- recentSearchesStorageKey: 'project_members',
- },
- });
+const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions'];
+initMembersApp(document.querySelector('.js-project-members-list'), {
+ tableFields: SHARED_FIELDS.concat(['source', 'granted']),
+ tableAttrs: { tr: { 'data-qa-selector': 'member_row' } },
+ tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'],
+ requestFormatter: projectMemberRequestFormatter,
+ filteredSearchBar: {
+ show: true,
+ tokens: ['with_inherited_permissions'],
+ searchParam: 'search',
+ placeholder: s__('Members|Filter members'),
+ recentSearchesStorageKey: 'project_members',
+ },
+});
- initMembersApp(document.querySelector('.js-project-group-links-list'), {
- tableFields: SHARED_FIELDS.concat('granted'),
- tableAttrs: {
- table: { 'data-qa-selector': 'groups_list' },
- tr: { 'data-qa-selector': 'group_row' },
- },
- requestFormatter: groupLinkRequestFormatter,
- filteredSearchBar: {
- show: true,
- tokens: [],
- searchParam: 'search_groups',
- placeholder: s__('Members|Search groups'),
- recentSearchesStorageKey: 'project_group_links',
- },
- });
+initMembersApp(document.querySelector('.js-project-group-links-list'), {
+ tableFields: SHARED_FIELDS.concat('granted'),
+ tableAttrs: {
+ table: { 'data-qa-selector': 'groups_list' },
+ tr: { 'data-qa-selector': 'group_row' },
+ },
+ requestFormatter: groupLinkRequestFormatter,
+ filteredSearchBar: {
+ show: true,
+ tokens: [],
+ searchParam: 'search_groups',
+ placeholder: s__('Members|Search groups'),
+ recentSearchesStorageKey: 'project_group_links',
+ },
+});
- initMembersApp(document.querySelector('.js-project-invited-members-list'), {
- tableFields: SHARED_FIELDS.concat('invited'),
- requestFormatter: projectMemberRequestFormatter,
- });
+initMembersApp(document.querySelector('.js-project-invited-members-list'), {
+ tableFields: SHARED_FIELDS.concat('invited'),
+ requestFormatter: projectMemberRequestFormatter,
+});
- initMembersApp(document.querySelector('.js-project-access-requests-list'), {
- tableFields: SHARED_FIELDS.concat('requested'),
- requestFormatter: projectMemberRequestFormatter,
- });
- },
- )
- .catch(() => {
- flash(__('An error occurred while loading the members, please try again.'));
- });
-}
+initMembersApp(document.querySelector('.js-project-access-requests-list'), {
+ tableFields: SHARED_FIELDS.concat('requested'),
+ requestFormatter: projectMemberRequestFormatter,
+});
diff --git a/app/assets/javascripts/pages/projects/prometheus/metrics/edit/index.js b/app/assets/javascripts/pages/projects/prometheus/metrics/edit/index.js
index 2fd047675b9..82856c1c8b9 100644
--- a/app/assets/javascripts/pages/projects/prometheus/metrics/edit/index.js
+++ b/app/assets/javascripts/pages/projects/prometheus/metrics/edit/index.js
@@ -1,3 +1,3 @@
-import customMetrics from '~/custom_metrics';
+import CustomMetrics from '~/custom_metrics';
-document.addEventListener('DOMContentLoaded', customMetrics);
+CustomMetrics();
diff --git a/app/assets/javascripts/pages/projects/prometheus/metrics/new/index.js b/app/assets/javascripts/pages/projects/prometheus/metrics/new/index.js
index 2fd047675b9..82856c1c8b9 100644
--- a/app/assets/javascripts/pages/projects/prometheus/metrics/new/index.js
+++ b/app/assets/javascripts/pages/projects/prometheus/metrics/new/index.js
@@ -1,3 +1,3 @@
-import customMetrics from '~/custom_metrics';
+import CustomMetrics from '~/custom_metrics';
-document.addEventListener('DOMContentLoaded', customMetrics);
+CustomMetrics();
diff --git a/app/assets/javascripts/pages/projects/settings/access_tokens/index.js b/app/assets/javascripts/pages/projects/settings/access_tokens/index.js
index 22dddb72f98..dc1bb88bf4b 100644
--- a/app/assets/javascripts/pages/projects/settings/access_tokens/index.js
+++ b/app/assets/javascripts/pages/projects/settings/access_tokens/index.js
@@ -1,3 +1,3 @@
-import initExpiresAtField from '~/access_tokens';
+import { initExpiresAtField } from '~/access_tokens';
initExpiresAtField();
diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
index be9259ec3ca..b7e8d4b03ac 100644
--- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
@@ -6,6 +6,7 @@ import initDeployFreeze from '~/deploy_freeze';
import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle';
import registrySettingsApp from '~/registry/settings/registry_settings_bundle';
+import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
document.addEventListener('DOMContentLoaded', () => {
@@ -42,4 +43,6 @@ document.addEventListener('DOMContentLoaded', () => {
}
initInstallRunner();
+
+ initSearchSettings();
});
diff --git a/app/assets/javascripts/pages/projects/settings/operations/show/index.js b/app/assets/javascripts/pages/projects/settings/operations/show/index.js
index 3a46241e2eb..4a800ab150d 100644
--- a/app/assets/javascripts/pages/projects/settings/operations/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js
@@ -3,6 +3,7 @@ import mountErrorTrackingForm from '~/error_tracking_settings';
import mountGrafanaIntegration from '~/grafana_integration';
import initIncidentsSettings from '~/incidents_settings';
import mountOperationSettings from '~/operation_settings';
+import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
initIncidentsSettings();
@@ -13,3 +14,7 @@ if (!IS_EE) {
initSettingsPanels();
}
mountAlertsSettings(document.querySelector('.js-alerts-settings'));
+
+document.addEventListener('DOMContentLoaded', () => {
+ initSearchSettings();
+});
diff --git a/app/assets/javascripts/pages/projects/settings/repository/show/index.js b/app/assets/javascripts/pages/projects/settings/repository/show/index.js
index e90954c14c5..c7bcbb83051 100644
--- a/app/assets/javascripts/pages/projects/settings/repository/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/repository/show/index.js
@@ -1,4 +1,5 @@
import MirrorRepos from '~/mirrors/mirror_repos';
+import initSearchSettings from '~/search_settings';
import initForm from '../form';
document.addEventListener('DOMContentLoaded', () => {
@@ -6,4 +7,6 @@ document.addEventListener('DOMContentLoaded', () => {
const mirrorReposContainer = document.querySelector('.js-mirror-settings');
if (mirrorReposContainer) new MirrorRepos(mirrorReposContainer).init();
+
+ initSearchSettings();
});
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
index 94a9bc168e5..0b58cb4731d 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
@@ -11,6 +11,7 @@ import {
featureAccessLevelEveryone,
featureAccessLevel,
featureAccessLevelNone,
+ CVE_ID_REQUEST_BUTTON_I18N,
} from '../constants';
import { toggleHiddenClassBySelector } from '../external';
import projectFeatureSetting from './project_feature_setting.vue';
@@ -19,6 +20,10 @@ import projectSettingRow from './project_setting_row.vue';
const PAGE_FEATURE_ACCESS_LEVEL = s__('ProjectSettings|Everyone');
export default {
+ i18n: {
+ ...CVE_ID_REQUEST_BUTTON_I18N,
+ },
+
components: {
projectFeatureSetting,
projectSettingRow,
@@ -31,6 +36,11 @@ export default {
mixins: [settingsMixin, glFeatureFlagsMixin()],
props: {
+ requestCveAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
currentSettings: {
type: Object,
required: true,
@@ -74,11 +84,6 @@ export default {
required: false,
default: false,
},
- securityAndComplianceAvailable: {
- type: Boolean,
- required: false,
- default: false,
- },
visibilityHelpPath: {
type: String,
required: false,
@@ -99,6 +104,11 @@ export default {
required: false,
default: '',
},
+ cveIdRequestHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
registryHelpPath: {
type: String,
required: false,
@@ -152,6 +162,7 @@ export default {
requestAccessEnabled: true,
highlightChangesClass: false,
emailsDisabled: false,
+ cveIdRequestEnabled: true,
featureAccessLevelEveryone,
featureAccessLevelMembers,
};
@@ -230,6 +241,9 @@ export default {
'ProjectSettings|View and edit files in this project. Non-project members will only have read access.',
);
},
+ cveIdRequestIsDisabled() {
+ return this.visibilityLevel !== visibilityOptions.PUBLIC;
+ },
},
watch: {
@@ -417,6 +431,19 @@ export default {
:options="featureAccessLevelOptions"
name="project[project_feature_attributes][issues_access_level]"
/>
+ <project-setting-row
+ v-if="requestCveAvailable"
+ :help-path="cveIdRequestHelpPath"
+ :help-text="$options.i18n.cve_request_toggle_label"
+ >
+ <gl-toggle
+ v-model="cveIdRequestEnabled"
+ class="gl-my-2"
+ :disabled="cveIdRequestIsDisabled"
+ name="project[project_setting_attributes][cve_id_request_enabled]"
+ data-testid="cve_id_request_toggle"
+ />
+ </project-setting-row>
</project-setting-row>
<project-setting-row
ref="repository-settings"
@@ -563,7 +590,6 @@ export default {
/>
</project-setting-row>
<project-setting-row
- v-if="securityAndComplianceAvailable"
:label="s__('ProjectSettings|Security & Compliance')"
:help-text="s__('ProjectSettings|Security & Compliance for this project')"
>
@@ -613,7 +639,9 @@ export default {
<project-setting-row
ref="operations-settings"
:label="s__('ProjectSettings|Operations')"
- :help-text="s__('ProjectSettings|Environments, logs, cluster management, and more.')"
+ :help-text="
+ s__('ProjectSettings|Configure your project resources and monitor their health.')
+ "
>
<project-feature-setting
v-model="operationsAccessLevel"
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/constants.js b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
index 6771391254e..e160fdacca6 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/constants.js
+++ b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
@@ -1,4 +1,4 @@
-import { __ } from '~/locale';
+import { s__, __ } from '~/locale';
export const visibilityOptions = {
PRIVATE: 0,
@@ -42,3 +42,7 @@ export const featureAccessLevelEveryone = [
featureAccessLevel.EVERYONE,
featureAccessLevelDescriptions[featureAccessLevel.EVERYONE],
];
+
+export const CVE_ID_REQUEST_BUTTON_I18N = {
+ cve_request_toggle_label: s__('CVE|Enable CVE ID requests in the issue sidebar'),
+};
diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js
index 0494dad6e33..83e43d7ac48 100644
--- a/app/assets/javascripts/pages/projects/show/index.js
+++ b/app/assets/javascripts/pages/projects/show/index.js
@@ -3,20 +3,16 @@ import Activities from '~/activities';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import BlobViewer from '~/blob/viewer/index';
import { initUploadForm } from '~/blob_edit/blob_bundle';
-import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
-import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import leaveByUrl from '~/namespaces/leave_by_url';
import initVueNotificationsDropdown from '~/notifications';
-import NotificationsForm from '~/notifications_form';
+import { initUploadFileTrigger } from '~/projects/upload_file_experiment';
import initReadMore from '~/read_more';
import UserCallout from '~/user_callout';
-import notificationsDropdown from '../../../notifications_dropdown';
import Star from '../../../star';
initReadMore();
new Star(); // eslint-disable-line no-new
-new NotificationsForm(); // eslint-disable-line no-new
// eslint-disable-next-line no-new
new UserCallout({
setCalloutPerProject: false,
@@ -24,9 +20,12 @@ new UserCallout({
});
// Project show page loads different overview content based on user preferences
-const treeSlider = document.getElementById('js-tree-list');
-if (treeSlider) {
+
+if (document.querySelector('.js-upload-blob-form')) {
initUploadForm();
+}
+
+if (document.getElementById('js-tree-list')) {
initTree();
}
@@ -40,15 +39,8 @@ if (document.querySelector('.project-show-activity')) {
leaveByUrl('project');
-if (gon.features?.vueNotificationDropdown) {
- initVueNotificationsDropdown();
-} else {
- notificationsDropdown();
-}
-
initVueNotificationsDropdown();
new ShortcutsNavigation(); // eslint-disable-line no-new
-initInviteMembersTrigger();
-initInviteMembersModal();
+initUploadFileTrigger();
diff --git a/app/assets/javascripts/pages/projects/tags/new/index.js b/app/assets/javascripts/pages/projects/tags/new/index.js
index 11a19a673b1..b071e7a45fc 100644
--- a/app/assets/javascripts/pages/projects/tags/new/index.js
+++ b/app/assets/javascripts/pages/projects/tags/new/index.js
@@ -3,8 +3,6 @@ import GLForm from '../../../../gl_form';
import RefSelectDropdown from '../../../../ref_select_dropdown';
import ZenMode from '../../../../zen_mode';
-document.addEventListener('DOMContentLoaded', () => {
- new ZenMode(); // eslint-disable-line no-new
- new GLForm($('.tag-form')); // eslint-disable-line no-new
- new RefSelectDropdown($('.js-branch-select')); // eslint-disable-line no-new
-});
+new ZenMode(); // eslint-disable-line no-new
+new GLForm($('.tag-form')); // eslint-disable-line no-new
+new RefSelectDropdown($('.js-branch-select')); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/tags/releases/index.js b/app/assets/javascripts/pages/projects/tags/releases/index.js
index abdc97f62d0..cafd880b4be 100644
--- a/app/assets/javascripts/pages/projects/tags/releases/index.js
+++ b/app/assets/javascripts/pages/projects/tags/releases/index.js
@@ -2,7 +2,5 @@ import $ from 'jquery';
import GLForm from '~/gl_form';
import ZenMode from '~/zen_mode';
-document.addEventListener('DOMContentLoaded', () => {
- new ZenMode(); // eslint-disable-line no-new
- new GLForm($('.release-form')); // eslint-disable-line no-new
-});
+new ZenMode(); // eslint-disable-line no-new
+new GLForm($('.release-form')); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/search/show/index.js b/app/assets/javascripts/pages/search/show/index.js
index a8c288c3663..2ee33584ee1 100644
--- a/app/assets/javascripts/pages/search/show/index.js
+++ b/app/assets/javascripts/pages/search/show/index.js
@@ -1,5 +1,3 @@
import { initSearchApp } from '~/search';
-document.addEventListener('DOMContentLoaded', () => {
- initSearchApp();
-});
+initSearchApp();
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_alert.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_alert.vue
new file mode 100644
index 00000000000..6cea26f2bed
--- /dev/null
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_alert.vue
@@ -0,0 +1,31 @@
+<script>
+import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlAlert,
+ GlLink,
+ GlSprintf,
+ },
+ props: {
+ error: {
+ type: String,
+ required: true,
+ },
+ wikiPagePath: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-alert variant="danger" :dismissible="false">
+ <gl-sprintf :message="error">
+ <template #wikiLink="{ content }">
+ <gl-link :href="wikiPagePath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/pages/shared/wikis/index.js b/app/assets/javascripts/pages/shared/wikis/index.js
index c8dc75828e4..c382a372260 100644
--- a/app/assets/javascripts/pages/shared/wikis/index.js
+++ b/app/assets/javascripts/pages/shared/wikis/index.js
@@ -6,9 +6,10 @@ import Translate from '~/vue_shared/translate';
import GLForm from '../../../gl_form';
import ZenMode from '../../../zen_mode';
import deleteWikiModal from './components/delete_wiki_modal.vue';
+import wikiAlert from './components/wiki_alert.vue';
import Wikis from './wikis';
-export default () => {
+const createModalVueApp = () => {
new Wikis(); // eslint-disable-line no-new
new ShortcutsWiki(); // eslint-disable-line no-new
new ZenMode(); // eslint-disable-line no-new
@@ -39,3 +40,28 @@ export default () => {
});
}
};
+
+const createAlertVueApp = () => {
+ const el = document.getElementById('js-wiki-error');
+ if (el) {
+ const { error, wikiPagePath } = el.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ render(createElement) {
+ return createElement(wikiAlert, {
+ props: {
+ error,
+ wikiPagePath,
+ },
+ });
+ },
+ });
+ }
+};
+
+export default () => {
+ createModalVueApp();
+ createAlertVueApp();
+};
diff --git a/app/assets/javascripts/pages/users/index.js b/app/assets/javascripts/pages/users/index.js
index b22287a0093..58ceb524360 100644
--- a/app/assets/javascripts/pages/users/index.js
+++ b/app/assets/javascripts/pages/users/index.js
@@ -15,9 +15,7 @@ function initUserProfile(action) {
});
}
-document.addEventListener('DOMContentLoaded', () => {
- const page = $('body').attr('data-page');
- const action = page.split(':')[1];
- initUserProfile(action);
- new UserCallout(); // eslint-disable-line no-new
-});
+const page = $('body').attr('data-page');
+const action = page.split(':')[1];
+initUserProfile(action);
+new UserCallout(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/performance/constants.js b/app/assets/javascripts/performance/constants.js
index 069f3c265f3..4ac758550e0 100644
--- a/app/assets/javascripts/performance/constants.js
+++ b/app/assets/javascripts/performance/constants.js
@@ -54,3 +54,24 @@ export const MR_DIFFS_MARK_DIFF_FILES_END = 'mr-diffs-mark-diff-files-end';
// Measures
export const MR_DIFFS_MEASURE_FILE_TREE_DONE = 'mr-diffs-measure-file-tree-done';
export const MR_DIFFS_MEASURE_DIFF_FILES_DONE = 'mr-diffs-measure-diff-files-done';
+
+//
+// Pipelines Detail namespace
+//
+
+// Marks
+export const PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START =
+ 'pipelines-detail-links-mark-calculate-start';
+export const PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END =
+ 'pipelines-detail-links-mark-calculate-end';
+
+// Measures
+export const PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION =
+ 'Pipelines Detail Graph: Links Calculation';
+
+// Metrics
+// Note: These strings must match the backend
+// (defined in: app/services/ci/prometheus_metrics/observe_histograms_service.rb)
+export const PIPELINES_DETAIL_LINK_DURATION = 'pipeline_graph_link_calculation_duration_seconds';
+export const PIPELINES_DETAIL_LINKS_TOTAL = 'pipeline_graph_links_total';
+export const PIPELINES_DETAIL_LINKS_JOB_RATIO = 'pipeline_graph_link_per_job_ratio';
diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
index de4bbb36141..9bf77239a6b 100644
--- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue
+++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
@@ -109,7 +109,7 @@ export default {
<div
v-for="(key, keyIndex) in keys"
:key="key"
- class="break-word"
+ class="text-break-word"
:class="{ 'mb-3 bold': keyIndex == 0 }"
>
{{ item[key] }}
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 85789cd1fdf..6b446eb6073 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -30,13 +30,17 @@ export default {
type: String,
required: true,
},
+ statsUrl: {
+ type: String,
+ required: true,
+ },
},
detailedMetrics: [
{
metric: 'active-record',
title: 'pg',
header: s__('PerformanceBar|SQL queries'),
- keys: ['sql', 'cached'],
+ keys: ['sql', 'cached', 'db_role'],
},
{
metric: 'bullet',
@@ -169,6 +173,9 @@ export default {
class="ml-auto"
@change-current-request="changeCurrentRequest"
/>
+ <div v-if="statsUrl" id="peek-stats" class="view">
+ <a class="gl-text-blue-300" :href="statsUrl">{{ s__('PerformanceBar|Stats') }}</a>
+ </div>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js
index 522e34753e9..51b6108868f 100644
--- a/app/assets/javascripts/performance_bar/index.js
+++ b/app/assets/javascripts/performance_bar/index.js
@@ -29,6 +29,7 @@ const initPerformanceBar = (el) => {
requestId: performanceBarData.requestId,
peekUrl: performanceBarData.peekUrl,
profileUrl: performanceBarData.profileUrl,
+ statsUrl: performanceBarData.statsUrl,
};
},
mounted() {
@@ -119,6 +120,7 @@ const initPerformanceBar = (el) => {
requestId: this.requestId,
peekUrl: this.peekUrl,
profileUrl: this.profileUrl,
+ statsUrl: this.statsUrl,
},
on: {
'add-request': this.addRequestManually,
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 9279273283e..b088678fee8 100644
--- a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
+++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
@@ -21,7 +21,7 @@ export default {
GlSprintf,
},
props: {
- defaultBranch: {
+ currentBranch: {
type: String,
required: false,
default: '',
@@ -40,23 +40,23 @@ export default {
data() {
return {
message: this.defaultMessage,
- branch: this.defaultBranch,
openMergeRequest: false,
+ targetBranch: this.currentBranch,
};
},
computed: {
- isDefaultBranch() {
- return this.branch === this.defaultBranch;
+ isCurrentBranchTarget() {
+ return this.targetBranch === this.currentBranch;
},
submitDisabled() {
- return !(this.message && this.branch);
+ return !(this.message && this.targetBranch);
},
},
methods: {
onSubmit() {
this.$emit('submit', {
message: this.message,
- branch: this.branch,
+ targetBranch: this.targetBranch,
openMergeRequest: this.openMergeRequest,
});
},
@@ -100,12 +100,12 @@ export default {
>
<gl-form-input
id="target-branch-field"
- v-model="branch"
+ v-model="targetBranch"
class="gl-font-monospace!"
required
/>
<gl-form-checkbox
- v-if="!isDefaultBranch"
+ v-if="!isCurrentBranchTarget"
v-model="openMergeRequest"
data-testid="new-mr-checkbox"
class="gl-mt-3"
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 b40c9a48903..14a4a9d5710 100644
--- a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
+++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
@@ -1,9 +1,16 @@
<script>
import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale';
-import { COMMIT_FAILURE, COMMIT_SUCCESS } from '../../constants';
+import {
+ COMMIT_ACTION_CREATE,
+ COMMIT_ACTION_UPDATE,
+ COMMIT_FAILURE,
+ COMMIT_SUCCESS,
+} from '../../constants';
import commitCIFile from '../../graphql/mutations/commit_ci_file.mutation.graphql';
import getCommitSha from '../../graphql/queries/client/commit_sha.graphql';
+import getCurrentBranch from '../../graphql/queries/client/current_branch.graphql';
+import getIsNewCiConfigFile from '../../graphql/queries/client/is_new_ci_config_file.graphql';
import CommitForm from './commit_form.vue';
@@ -21,7 +28,7 @@ export default {
components: {
CommitForm,
},
- inject: ['projectFullPath', 'ciConfigPath', 'defaultBranch', 'newMergeRequestPath'],
+ inject: ['projectFullPath', 'ciConfigPath', 'newMergeRequestPath'],
props: {
ciFileContent: {
type: String,
@@ -31,15 +38,25 @@ export default {
data() {
return {
commit: {},
+ isNewCiConfigFile: false,
isSaving: false,
};
},
apollo: {
+ isNewCiConfigFile: {
+ query: getIsNewCiConfigFile,
+ },
commitSha: {
query: getCommitSha,
},
+ currentBranch: {
+ query: getCurrentBranch,
+ },
},
computed: {
+ action() {
+ return this.isNewCiConfigFile ? COMMIT_ACTION_CREATE : COMMIT_ACTION_UPDATE;
+ },
defaultCommitMessage() {
return sprintf(this.$options.i18n.defaultCommitMessage, { sourcePath: this.ciConfigPath });
},
@@ -49,13 +66,13 @@ export default {
const url = mergeUrlParams(
{
[MR_SOURCE_BRANCH]: sourceBranch,
- [MR_TARGET_BRANCH]: this.defaultBranch,
+ [MR_TARGET_BRANCH]: this.currentBranch,
},
this.newMergeRequestPath,
);
redirectTo(url);
},
- async onCommitSubmit({ message, branch, openMergeRequest }) {
+ async onCommitSubmit({ message, targetBranch, openMergeRequest }) {
this.isSaving = true;
try {
@@ -66,9 +83,10 @@ export default {
} = await this.$apollo.mutate({
mutation: commitCIFile,
variables: {
+ action: this.action,
projectPath: this.projectFullPath,
- branch,
- startBranch: this.defaultBranch,
+ branch: targetBranch,
+ startBranch: this.currentBranch,
message,
filePath: this.ciConfigPath,
content: this.ciFileContent,
@@ -86,7 +104,7 @@ export default {
if (errors?.length) {
this.$emit('showError', { type: COMMIT_FAILURE, reasons: errors });
} else if (openMergeRequest) {
- this.redirectToNewMergeRequest(branch);
+ this.redirectToNewMergeRequest(targetBranch);
} else {
this.$emit('commit', { type: COMMIT_SUCCESS });
}
@@ -105,7 +123,7 @@ export default {
<template>
<commit-form
- :default-branch="defaultBranch"
+ :current-branch="currentBranch"
:default-message="defaultCommitMessage"
:is-saving="isSaving"
@cancel="onCommitCancel"
diff --git a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue b/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue
index 007faa4ed0d..f36b22f33c3 100644
--- a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue
+++ b/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue
@@ -82,7 +82,7 @@ export default {
</gl-alert>
<div v-else>
<div class="gl-display-flex gl-align-items-center">
- <gl-icon :size="18" name="lock" class="gl-text-gray-500 gl-mr-3" />
+ <gl-icon :size="18" name="lock" use-deprecated-sizes class="gl-text-gray-500 gl-mr-3" />
{{ $options.i18n.viewOnlyMessage }}
</div>
<div class="gl-mt-3 gl-border-solid gl-border-gray-100 gl-border-1">
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue
index ab41c0170e9..7a35e31e9ce 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue
@@ -1,13 +1,40 @@
<script>
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import PipelineStatus from './pipeline_status.vue';
import ValidationSegment from './validation_segment.vue';
+const baseClasses = ['gl-p-5', 'gl-bg-gray-10', 'gl-border-solid', 'gl-border-gray-100'];
+
+const pipelineStatusClasses = [
+ ...baseClasses,
+ 'gl-border-1',
+ 'gl-border-b-0!',
+ 'gl-rounded-top-base',
+];
+
+const validationSegmentClasses = [...baseClasses, 'gl-border-1', 'gl-rounded-base'];
+
+const validationSegmentWithPipelineStatusClasses = [
+ ...baseClasses,
+ 'gl-border-1',
+ 'gl-rounded-bottom-left-base',
+ 'gl-rounded-bottom-right-base',
+];
+
export default {
- validationSegmentClasses:
- 'gl-p-5 gl-bg-gray-10 gl-border-solid gl-border-1 gl-border-gray-100 gl-rounded-base',
+ pipelineStatusClasses,
+ validationSegmentClasses,
+ validationSegmentWithPipelineStatusClasses,
components: {
+ PipelineStatus,
ValidationSegment,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
+ ciFileContent: {
+ type: String,
+ required: true,
+ },
ciConfigData: {
type: Object,
required: true,
@@ -17,13 +44,27 @@ export default {
required: true,
},
},
+ computed: {
+ showPipelineStatus() {
+ return this.glFeatures.pipelineStatusForPipelineEditor;
+ },
+ // make sure corners are rounded correctly depending on if
+ // pipeline status is rendered
+ validationStyling() {
+ return this.showPipelineStatus
+ ? this.$options.validationSegmentWithPipelineStatusClasses
+ : this.$options.validationSegmentClasses;
+ },
+ },
};
</script>
<template>
<div class="gl-mb-5">
+ <pipeline-status v-if="showPipelineStatus" :class="$options.pipelineStatusClasses" />
<validation-segment
- :class="$options.validationSegmentClasses"
+ :class="validationStyling"
:loading="isCiConfigDataLoading"
+ :ci-file-content="ciFileContent"
:ci-config="ciConfigData"
/>
</div>
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
new file mode 100644
index 00000000000..b1ea464be99
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
@@ -0,0 +1,120 @@
+<script>
+import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { s__ } from '~/locale';
+import getCommitSha from '~/pipeline_editor/graphql/queries/client/commit_sha.graphql';
+import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+
+const POLL_INTERVAL = 10000;
+export const i18n = {
+ fetchError: s__('Pipeline|We are currently unable to fetch pipeline data'),
+ fetchLoading: s__('Pipeline|Checking pipeline status'),
+ pipelineInfo: s__(
+ `Pipeline|Pipeline %{idStart}#%{idEnd} %{statusStart}%{statusEnd} for %{commitStart}%{commitEnd}`,
+ ),
+};
+
+export default {
+ i18n,
+ components: {
+ CiIcon,
+ GlIcon,
+ GlLink,
+ GlLoadingIcon,
+ GlSprintf,
+ },
+ inject: ['projectFullPath'],
+ apollo: {
+ commitSha: {
+ query: getCommitSha,
+ },
+ pipeline: {
+ query: getPipelineQuery,
+ variables() {
+ return {
+ fullPath: this.projectFullPath,
+ sha: this.commitSha,
+ };
+ },
+ update: (data) => {
+ const { id, commitPath = '', shortSha = '', detailedStatus = {} } =
+ data.project?.pipeline || {};
+
+ return {
+ id,
+ commitPath,
+ shortSha,
+ detailedStatus,
+ };
+ },
+ error() {
+ this.hasError = true;
+ },
+ pollInterval: POLL_INTERVAL,
+ },
+ },
+ data() {
+ return {
+ hasError: false,
+ };
+ },
+ computed: {
+ hasPipelineData() {
+ return Boolean(this.$apollo.queries.pipeline?.id);
+ },
+ isQueryLoading() {
+ return this.$apollo.queries.pipeline.loading && !this.hasPipelineData;
+ },
+ status() {
+ return this.pipeline.detailedStatus;
+ },
+ pipelineId() {
+ return getIdFromGraphQLId(this.pipeline.id);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-white-space-nowrap gl-max-w-full">
+ <template v-if="isQueryLoading">
+ <gl-loading-icon class="gl-mr-auto gl-display-inline-block" size="sm" />
+ <span data-testid="pipeline-loading-msg">{{ $options.i18n.fetchLoading }}</span>
+ </template>
+ <template v-else-if="hasError">
+ <gl-icon class="gl-mr-auto" name="warning-solid" />
+ <span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span>
+ </template>
+ <template v-else>
+ <a :href="status.detailsPath" class="gl-mr-auto">
+ <ci-icon :status="status" :size="18" />
+ </a>
+ <span class="gl-font-weight-bold">
+ <gl-sprintf :message="$options.i18n.pipelineInfo">
+ <template #id="{ content }">
+ <gl-link
+ :href="status.detailsPath"
+ class="pipeline-id gl-font-weight-normal pipeline-number"
+ target="_blank"
+ data-testid="pipeline-id"
+ >
+ {{ content }}{{ pipelineId }}</gl-link
+ >
+ </template>
+ <template #status>{{ status.text }}</template>
+ <template #commit>
+ <gl-link
+ :href="pipeline.commitPath"
+ class="commit-sha gl-font-weight-normal"
+ target="_blank"
+ data-testid="pipeline-commit"
+ >
+ {{ pipeline.shortSha }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
index 94fb3a66fdd..541ab74b177 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
@@ -5,6 +5,9 @@ import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import { CI_CONFIG_STATUS_VALID } from '../../constants';
export const i18n = {
+ empty: __(
+ "We'll continuously validate your pipeline configuration. The validation results will appear here.",
+ ),
learnMore: __('Learn more'),
loading: s__('Pipelines|Validating GitLab CI configuration…'),
invalid: s__('Pipelines|This GitLab CI configuration is invalid.'),
@@ -26,6 +29,10 @@ export default {
},
},
props: {
+ ciFileContent: {
+ type: String,
+ required: true,
+ },
ciConfig: {
type: Object,
required: false,
@@ -38,17 +45,22 @@ export default {
},
},
computed: {
+ isEmpty() {
+ return !this.ciFileContent;
+ },
isValid() {
return this.ciConfig?.status === CI_CONFIG_STATUS_VALID;
},
icon() {
- if (this.isValid) {
+ if (this.isValid || this.isEmpty) {
return 'check';
}
return 'warning-solid';
},
message() {
- if (this.isValid) {
+ if (this.isEmpty) {
+ return this.$options.i18n.empty;
+ } else if (this.isValid) {
return this.$options.i18n.valid;
}
@@ -74,7 +86,7 @@ export default {
<tooltip-on-truncate :title="message" class="gl-text-truncate">
<gl-icon :name="icon" /> <span data-testid="validationMsg">{{ message }}</span>
</tooltip-on-truncate>
- <span class="gl-flex-shrink-0 gl-pl-2">
+ <span v-if="!isEmpty" class="gl-flex-shrink-0 gl-pl-2">
<gl-link data-testid="learnMoreLink" :href="ymlHelpPagePath">
{{ $options.i18n.learnMore }}
</gl-link>
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
new file mode 100644
index 00000000000..d4f04a0d055
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue
@@ -0,0 +1,56 @@
+<script>
+import { GlButton, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
+export default {
+ components: {
+ GlButton,
+ GlSprintf,
+ },
+ i18n: {
+ title: __('Optimize your workflow with CI/CD Pipelines'),
+ body: __(
+ 'Create a new %{codeStart}.gitlab-ci.yml%{codeEnd} file at the root of the repository to get started.',
+ ),
+ btnText: __('Create new CI/CD pipeline'),
+ },
+ mixins: [glFeatureFlagsMixin()],
+ inject: {
+ emptyStateIllustrationPath: {
+ default: '',
+ },
+ },
+ computed: {
+ showCTAButton() {
+ return this.glFeatures.pipelineEditorEmptyStateAction;
+ },
+ },
+ methods: {
+ createEmptyConfigFile() {
+ this.$emit('createEmptyConfigFile');
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11">
+ <img :src="emptyStateIllustrationPath" />
+ <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1>
+ <p class="gl-mt-3">
+ <gl-sprintf :message="$options.i18n.body">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ <gl-button
+ v-if="showCTAButton"
+ variant="confirm"
+ class="gl-mt-3"
+ @click="createEmptyConfigFile"
+ >
+ {{ $options.i18n.btnText }}
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js
index e676fdeae02..353deafe770 100644
--- a/app/assets/javascripts/pipeline_editor/constants.js
+++ b/app/assets/javascripts/pipeline_editor/constants.js
@@ -5,7 +5,6 @@ export const COMMIT_FAILURE = 'COMMIT_FAILURE';
export const COMMIT_SUCCESS = 'COMMIT_SUCCESS';
export const DEFAULT_FAILURE = 'DEFAULT_FAILURE';
-export const LOAD_FAILURE_NO_FILE = 'LOAD_FAILURE_NO_FILE';
export const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN';
export const CREATE_TAB = 'CREATE_TAB';
@@ -14,3 +13,6 @@ export const MERGED_TAB = 'MERGED_TAB';
export const VISUALIZE_TAB = 'VISUALIZE_TAB';
export const TABS_WITH_COMMIT_FORM = [CREATE_TAB, LINT_TAB, VISUALIZE_TAB];
+
+export const COMMIT_ACTION_CREATE = 'CREATE';
+export const COMMIT_ACTION_UPDATE = 'UPDATE';
diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql
index 2af0cd5f6d4..3b2daa45a18 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql
@@ -1,4 +1,5 @@
mutation commitCIFile(
+ $action: CommitActionMode!
$projectPath: ID!
$branch: String!
$startBranch: String
@@ -14,7 +15,7 @@ mutation commitCIFile(
startBranch: $startBranch
message: $message
actions: [
- { action: UPDATE, filePath: $filePath, lastCommitId: $lastCommitId, content: $content }
+ { action: $action, filePath: $filePath, lastCommitId: $lastCommitId, content: $content }
]
}
) {
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.graphql
new file mode 100644
index 00000000000..acd46013f5b
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.graphql
@@ -0,0 +1,3 @@
+query getCurrentBranch {
+ currentBranch @client
+}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/is_new_ci_config_file.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/is_new_ci_config_file.graphql
new file mode 100644
index 00000000000..8c2ca276f50
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/is_new_ci_config_file.graphql
@@ -0,0 +1,3 @@
+query getIsNewCiConfigFile {
+ isNewCiConfigFile @client
+}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql
new file mode 100644
index 00000000000..7cc7f92fb60
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql
@@ -0,0 +1,17 @@
+query getPipeline($fullPath: ID!, $sha: String!) {
+ project(fullPath: $fullPath) @client {
+ pipeline(sha: $sha) {
+ commitPath
+ id
+ iid
+ shortSha
+ status
+ detailedStatus {
+ detailsPath
+ icon
+ group
+ text
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
index 81e75c32846..13f6200693b 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
+++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
@@ -11,6 +11,29 @@ export const resolvers = {
}),
};
},
+
+ /* eslint-disable @gitlab/require-i18n-strings */
+ project() {
+ return {
+ __typename: 'Project',
+ pipeline: {
+ __typename: 'Pipeline',
+ commitPath: `/-/commit/aabbccdd`,
+ id: 'gid://gitlab/Ci::Pipeline/118',
+ iid: '28',
+ shortSha: 'aabbccdd',
+ status: 'SUCCESS',
+ detailedStatus: {
+ __typename: 'DetailedStatus',
+ detailsPath: '/root/sample-ci-project/-/pipelines/118"',
+ group: 'success',
+ icon: 'status_success',
+ text: 'passed',
+ },
+ },
+ };
+ },
+ /* eslint-enable @gitlab/require-i18n-strings */
},
Mutation: {
lintCI: (_, { endpoint, content, dry_run }) => {
diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js
index dc427f55b5f..b17ec2d5c25 100644
--- a/app/assets/javascripts/pipeline_editor/index.js
+++ b/app/assets/javascripts/pipeline_editor/index.js
@@ -22,9 +22,11 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
const {
// Add to apollo cache as it can be updated by future queries
commitSha,
+ initialBranchName,
// Add to provide/inject API for static values
ciConfigPath,
defaultBranch,
+ emptyStateIllustrationPath,
lintHelpPagePath,
newMergeRequestPath,
projectFullPath,
@@ -41,6 +43,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
apolloProvider.clients.defaultClient.cache.writeData({
data: {
+ currentBranch: initialBranchName || defaultBranch,
commitSha,
},
});
@@ -51,6 +54,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
provide: {
ciConfigPath,
defaultBranch,
+ emptyStateIllustrationPath,
lintHelpPagePath,
newMergeRequestPath,
projectFullPath,
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
index b4a818e2472..c1168979e9f 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
@@ -1,19 +1,16 @@
<script>
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import httpStatusCodes from '~/lib/utils/http_status';
-import { __, s__, sprintf } from '~/locale';
+import { __, s__ } from '~/locale';
import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils';
import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue';
-import {
- COMMIT_FAILURE,
- COMMIT_SUCCESS,
- DEFAULT_FAILURE,
- LOAD_FAILURE_NO_FILE,
- LOAD_FAILURE_UNKNOWN,
-} from './constants';
+import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_state.vue';
+import { COMMIT_FAILURE, COMMIT_SUCCESS, DEFAULT_FAILURE, LOAD_FAILURE_UNKNOWN } from './constants';
import getBlobContent from './graphql/queries/blob_content.graphql';
import getCiConfigData from './graphql/queries/ci_config.graphql';
+import getCurrentBranch from './graphql/queries/client/current_branch.graphql';
+import getIsNewCiConfigFile from './graphql/queries/client/is_new_ci_config_file.graphql';
import PipelineEditorHome from './pipeline_editor_home.vue';
export default {
@@ -21,15 +18,13 @@ export default {
ConfirmUnsavedChangesDialog,
GlAlert,
GlLoadingIcon,
+ PipelineEditorEmptyState,
PipelineEditorHome,
},
inject: {
ciConfigPath: {
default: '',
},
- defaultBranch: {
- default: null,
- },
projectFullPath: {
default: '',
},
@@ -40,6 +35,8 @@ export default {
// Success and failure state
failureType: null,
failureReasons: [],
+ showStartScreen: false,
+ isNewCiConfigFile: false,
initialCiFileContent: '',
lastCommittedContent: '',
currentCiFileContent: '',
@@ -51,11 +48,18 @@ export default {
apollo: {
initialCiFileContent: {
query: getBlobContent,
+ // 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;
+ },
variables() {
return {
projectPath: this.projectFullPath,
path: this.ciConfigPath,
- ref: this.defaultBranch,
+ ref: this.currentBranch,
};
},
update(data) {
@@ -94,6 +98,12 @@ export default {
this.reportFailure(LOAD_FAILURE_UNKNOWN);
},
},
+ currentBranch: {
+ query: getCurrentBranch,
+ },
+ isNewCiConfigFile: {
+ query: getIsNewCiConfigFile,
+ },
},
computed: {
hasUnsavedChanges() {
@@ -102,21 +112,11 @@ export default {
isBlobContentLoading() {
return this.$apollo.queries.initialCiFileContent.loading;
},
- isBlobContentError() {
- return this.failureType === LOAD_FAILURE_NO_FILE;
- },
isCiConfigDataLoading() {
return this.$apollo.queries.ciConfigData.loading;
},
failure() {
switch (this.failureType) {
- case LOAD_FAILURE_NO_FILE:
- return {
- text: sprintf(this.$options.errorTexts[LOAD_FAILURE_NO_FILE], {
- filePath: this.ciConfigPath,
- }),
- variant: 'danger',
- };
case LOAD_FAILURE_UNKNOWN:
return {
text: this.$options.errorTexts[LOAD_FAILURE_UNKNOWN],
@@ -154,9 +154,6 @@ export default {
errorTexts: {
[COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'),
[DEFAULT_FAILURE]: __('Something went wrong on our end.'),
- [LOAD_FAILURE_NO_FILE]: s__(
- 'Pipelines|There is no %{filePath} file in this repository, please add one and visit the Pipeline Editor again.',
- ),
[LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'),
},
successTexts: {
@@ -173,7 +170,7 @@ export default {
response?.status === httpStatusCodes.NOT_FOUND ||
response?.status === httpStatusCodes.BAD_REQUEST
) {
- this.reportFailure(LOAD_FAILURE_NO_FILE);
+ this.showStartScreen = true;
} else {
this.reportFailure(LOAD_FAILURE_UNKNOWN);
}
@@ -186,17 +183,25 @@ export default {
this.showSuccessAlert = false;
},
reportFailure(type, reasons = []) {
+ window.scrollTo({ top: 0, behavior: 'smooth' });
this.showFailureAlert = true;
this.failureType = type;
this.failureReasons = reasons;
},
reportSuccess(type) {
+ window.scrollTo({ top: 0, behavior: 'smooth' });
this.showSuccessAlert = true;
this.successType = type;
},
resetContent() {
this.currentCiFileContent = this.lastCommittedContent;
},
+ setNewEmptyCiConfigFile() {
+ this.$apollo
+ .getClient()
+ .writeQuery({ query: getIsNewCiConfigFile, data: { isNewCiConfigFile: true } });
+ this.showStartScreen = false;
+ },
showErrorAlert({ type, reasons = [] }) {
this.reportFailure(type, reasons);
},
@@ -205,6 +210,12 @@ export default {
},
updateOnCommit({ type }) {
this.reportSuccess(type);
+
+ if (this.isNewCiConfigFile) {
+ this.$apollo
+ .getClient()
+ .writeQuery({ query: getIsNewCiConfigFile, data: { isNewCiConfigFile: false } });
+ }
// Keep track of the latest commited content to know
// if the user has made changes to the file that are unsaved.
this.lastCommittedContent = this.currentCiFileContent;
@@ -214,18 +225,22 @@ export default {
</script>
<template>
- <div class="gl-mt-4">
- <gl-alert v-if="showSuccessAlert" :variant="success.variant" @dismiss="dismissSuccess">
- {{ success.text }}
- </gl-alert>
- <gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="dismissFailure">
- {{ failure.text }}
- <ul v-if="failureReasons.length" class="gl-mb-0">
- <li v-for="reason in failureReasons" :key="reason">{{ reason }}</li>
- </ul>
- </gl-alert>
+ <div class="gl-mt-4 gl-relative">
<gl-loading-icon v-if="isBlobContentLoading" size="lg" class="gl-m-3" />
- <div v-else-if="!isBlobContentError" class="gl-mt-4">
+ <pipeline-editor-empty-state
+ v-else-if="showStartScreen"
+ @createEmptyConfigFile="setNewEmptyCiConfigFile"
+ />
+ <div v-else>
+ <gl-alert v-if="showSuccessAlert" :variant="success.variant" @dismiss="dismissSuccess">
+ {{ success.text }}
+ </gl-alert>
+ <gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="dismissFailure">
+ {{ failure.text }}
+ <ul v-if="failureReasons.length" class="gl-mb-0">
+ <li v-for="reason in failureReasons" :key="reason">{{ reason }}</li>
+ </ul>
+ </gl-alert>
<pipeline-editor-home
:is-ci-config-data-loading="isCiConfigDataLoading"
:ci-config-data="ciConfigData"
@@ -235,7 +250,7 @@ export default {
@showError="showErrorAlert"
@updateCiConfig="updateCiConfig"
/>
+ <confirm-unsaved-changes-dialog :has-unsaved-changes="hasUnsavedChanges" />
</div>
- <confirm-unsaved-changes-dialog :has-unsaved-changes="hasUnsavedChanges" />
</div>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
index 8c9aad6ed87..ef46040153f 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
@@ -45,6 +45,7 @@ export default {
<template>
<div>
<pipeline-editor-header
+ :ci-file-content="ciFileContent"
:ci-config-data="ciConfigData"
:is-ci-config-data-loading="isCiConfigDataLoading"
/>
diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
index 5070971c563..ff6a354f673 100644
--- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
+++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
@@ -9,14 +9,11 @@ import {
GlFormSelect,
GlFormTextarea,
GlLink,
- GlDropdown,
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlSearchBoxByType,
GlSprintf,
GlLoadingIcon,
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { uniqueId } from 'lodash';
import Vue from 'vue';
import axios from '~/lib/utils/axios_utils';
@@ -24,21 +21,27 @@ import { backOff } from '~/lib/utils/common_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import { redirectTo } from '~/lib/utils/url_utility';
import { s__, __, n__ } from '~/locale';
-import * as Sentry from '~/sentry/wrapper';
import { VARIABLE_TYPE, FILE_TYPE, CONFIG_VARIABLES_TIMEOUT } from '../constants';
+import RefsDropdown from './refs_dropdown.vue';
+
+const i18n = {
+ variablesDescription: s__(
+ 'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.',
+ ),
+ defaultError: __('Something went wrong on our end. Please try again.'),
+ refsLoadingErrorTitle: s__('Pipeline|Branches or tags could not be loaded.'),
+ submitErrorTitle: s__('Pipeline|Pipeline cannot be run.'),
+ warningTitle: __('The form contains the following warning:'),
+ maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'),
+};
export default {
typeOptions: [
{ value: VARIABLE_TYPE, text: __('Variable') },
{ value: FILE_TYPE, text: __('File') },
],
- variablesDescription: s__(
- 'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.',
- ),
+ i18n,
formElementClasses: 'gl-mr-3 gl-mb-3 gl-flex-basis-quarter gl-flex-shrink-0 gl-flex-grow-0',
- errorTitle: __('Pipeline cannot be run.'),
- warningTitle: __('The form contains the following warning:'),
- maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'),
// this height value is used inline on the textarea to match the input field height
// it's used to prevent the overwrite if 'gl-h-7' or 'gl-h-7!' were used
textAreaStyle: { height: '32px' },
@@ -52,12 +55,9 @@ export default {
GlFormSelect,
GlFormTextarea,
GlLink,
- GlDropdown,
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlSearchBoxByType,
GlSprintf,
GlLoadingIcon,
+ RefsDropdown,
},
directives: { SafeHtml },
props: {
@@ -77,14 +77,6 @@ export default {
type: String,
required: true,
},
- branches: {
- type: Array,
- required: true,
- },
- tags: {
- type: Array,
- required: true,
- },
settingsLink: {
type: String,
required: true,
@@ -111,11 +103,11 @@ export default {
},
data() {
return {
- searchTerm: '',
refValue: {
shortName: this.refParam,
},
form: {},
+ errorTitle: null,
error: null,
warnings: [],
totalWarnings: 0,
@@ -125,22 +117,6 @@ export default {
};
},
computed: {
- lowerCasedSearchTerm() {
- return this.searchTerm.toLowerCase();
- },
- filteredBranches() {
- return this.branches.filter((branch) =>
- branch.shortName.toLowerCase().includes(this.lowerCasedSearchTerm),
- );
- },
- filteredTags() {
- return this.tags.filter((tag) =>
- tag.shortName.toLowerCase().includes(this.lowerCasedSearchTerm),
- );
- },
- hasTags() {
- return this.tags.length > 0;
- },
overMaxWarningsLimit() {
return this.totalWarnings > this.maxWarnings;
},
@@ -148,7 +124,7 @@ export default {
return n__('%d warning found:', '%d warnings found:', this.warnings.length);
},
summaryMessage() {
- return this.overMaxWarningsLimit ? this.$options.maxWarningsSummary : this.warningsSummary;
+ return this.overMaxWarningsLimit ? i18n.maxWarningsSummary : this.warningsSummary;
},
shouldShowWarning() {
return this.warnings.length > 0 && !this.isWarningDismissed;
@@ -166,6 +142,11 @@ export default {
return this.form[this.refFullName]?.descriptions ?? {};
},
},
+ watch: {
+ refValue() {
+ this.loadConfigVariablesForm();
+ },
+ },
created() {
// this is needed until we add support for ref type in url query strings
// ensure default branch is called with full ref on load
@@ -174,7 +155,7 @@ export default {
this.refValue.fullName = `refs/heads/${this.refValue.shortName}`;
}
- this.setRefSelected(this.refValue);
+ this.loadConfigVariablesForm();
},
methods: {
addEmptyVariable(refValue) {
@@ -213,49 +194,47 @@ export default {
this.setVariable(refValue, type, key, value);
});
},
- setRefSelected(refValue) {
- this.refValue = refValue;
-
- if (!this.form[this.refFullName]) {
- this.fetchConfigVariables(this.refFullName || this.refShortName)
- .then(({ descriptions, params }) => {
- Vue.set(this.form, this.refFullName, {
- variables: [],
- descriptions,
- });
-
- // Add default variables from yml
- this.setVariableParams(this.refFullName, VARIABLE_TYPE, params);
- })
- .catch(() => {
- Vue.set(this.form, this.refFullName, {
- variables: [],
- descriptions: {},
- });
- })
- .finally(() => {
- // Add/update variables, e.g. from query string
- if (this.variableParams) {
- this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams);
- }
- if (this.fileParams) {
- this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams);
- }
-
- // Adds empty var at the end of the form
- this.addEmptyVariable(this.refFullName);
- });
- }
- },
- isSelected(ref) {
- return ref.fullName === this.refValue.fullName;
- },
removeVariable(index) {
this.variables.splice(index, 1);
},
canRemove(index) {
return index < this.variables.length - 1;
},
+ loadConfigVariablesForm() {
+ // Skip when variables already cached in `form`
+ if (this.form[this.refFullName]) {
+ return;
+ }
+
+ this.fetchConfigVariables(this.refFullName || this.refShortName)
+ .then(({ descriptions, params }) => {
+ Vue.set(this.form, this.refFullName, {
+ variables: [],
+ descriptions,
+ });
+
+ // Add default variables from yml
+ this.setVariableParams(this.refFullName, VARIABLE_TYPE, params);
+ })
+ .catch(() => {
+ Vue.set(this.form, this.refFullName, {
+ variables: [],
+ descriptions: {},
+ });
+ })
+ .finally(() => {
+ // Add/update variables, e.g. from query string
+ if (this.variableParams) {
+ this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams);
+ }
+ if (this.fileParams) {
+ this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams);
+ }
+
+ // Adds empty var at the end of the form
+ this.addEmptyVariable(this.refFullName);
+ });
+ },
fetchConfigVariables(refValue) {
this.isLoading = true;
@@ -330,11 +309,25 @@ export default {
} = err?.response?.data;
const [error] = errors;
- this.error = error;
- this.warnings = warnings;
- this.totalWarnings = totalWarnings;
+ this.reportError({
+ title: i18n.submitErrorTitle,
+ error,
+ warnings,
+ totalWarnings,
+ });
});
},
+ onRefsLoadingError(error) {
+ this.reportError({ title: i18n.refsLoadingErrorTitle });
+
+ Sentry.captureException(error);
+ },
+ reportError({ title = null, error = i18n.defaultError, warnings = [], totalWarnings = 0 }) {
+ this.errorTitle = title;
+ this.error = error;
+ this.warnings = warnings;
+ this.totalWarnings = totalWarnings;
+ },
},
};
</script>
@@ -343,7 +336,7 @@ export default {
<gl-form @submit.prevent="createPipeline">
<gl-alert
v-if="error"
- :title="$options.errorTitle"
+ :title="errorTitle"
:dismissible="false"
variant="danger"
class="gl-mb-4"
@@ -353,7 +346,7 @@ export default {
</gl-alert>
<gl-alert
v-if="shouldShowWarning"
- :title="$options.warningTitle"
+ :title="$options.i18n.warningTitle"
variant="warning"
class="gl-mb-4"
data-testid="run-pipeline-warning-alert"
@@ -380,31 +373,7 @@ export default {
</details>
</gl-alert>
<gl-form-group :label="s__('Pipeline|Run for branch name or tag')">
- <gl-dropdown :text="refShortName" block>
- <gl-search-box-by-type v-model.trim="searchTerm" :placeholder="__('Search refs')" />
- <gl-dropdown-section-header>{{ __('Branches') }}</gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="branch in filteredBranches"
- :key="branch.fullName"
- class="gl-font-monospace"
- is-check-item
- :is-checked="isSelected(branch)"
- @click="setRefSelected(branch)"
- >
- {{ branch.shortName }}
- </gl-dropdown-item>
- <gl-dropdown-section-header v-if="hasTags">{{ __('Tags') }}</gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="tag in filteredTags"
- :key="tag.fullName"
- class="gl-font-monospace"
- is-check-item
- :is-checked="isSelected(tag)"
- @click="setRefSelected(tag)"
- >
- {{ tag.shortName }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <refs-dropdown v-model="refValue" @loadingError="onRefsLoadingError" />
</gl-form-group>
<gl-loading-icon v-if="isLoading" class="gl-mb-5" size="lg" />
@@ -465,7 +434,7 @@ export default {
</div>
<template #description
- ><gl-sprintf :message="$options.variablesDescription">
+ ><gl-sprintf :message="$options.i18n.variablesDescription">
<template #link="{ content }">
<gl-link :href="settingsLink">{{ content }}</gl-link>
</template>
diff --git a/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue b/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue
new file mode 100644
index 00000000000..ed5c659d1df
--- /dev/null
+++ b/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue
@@ -0,0 +1,113 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui';
+import { debounce } from 'lodash';
+import axios from '~/lib/utils/axios_utils';
+import { BRANCH_REF_TYPE, TAG_REF_TYPE, DEBOUNCE_REFS_SEARCH_MS } from '../constants';
+import formatRefs from '../utils/format_refs';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlSearchBoxByType,
+ },
+ inject: ['projectRefsEndpoint'],
+ props: {
+ value: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ data() {
+ return {
+ isLoading: false,
+ searchTerm: '',
+ branches: [],
+ tags: [],
+ };
+ },
+ computed: {
+ lowerCasedSearchTerm() {
+ return this.searchTerm.toLowerCase();
+ },
+ refShortName() {
+ return this.value.shortName;
+ },
+ hasTags() {
+ return this.tags.length > 0;
+ },
+ },
+ watch: {
+ searchTerm() {
+ this.debouncedLoadRefs();
+ },
+ },
+ methods: {
+ loadRefs() {
+ this.isLoading = true;
+
+ axios
+ .get(this.projectRefsEndpoint, {
+ params: {
+ search: this.lowerCasedSearchTerm,
+ },
+ })
+ .then(({ data }) => {
+ // Note: These keys are uppercase in API
+ const { Branches = [], Tags = [] } = data;
+
+ this.branches = formatRefs(Branches, BRANCH_REF_TYPE);
+ this.tags = formatRefs(Tags, TAG_REF_TYPE);
+ })
+ .catch((e) => {
+ this.$emit('loadingError', e);
+ })
+ .finally(() => {
+ this.isLoading = false;
+ });
+ },
+ debouncedLoadRefs: debounce(function debouncedLoadRefs() {
+ this.loadRefs();
+ }, DEBOUNCE_REFS_SEARCH_MS),
+ setRefSelected(ref) {
+ this.$emit('input', ref);
+ },
+ isSelected(ref) {
+ return ref.fullName === this.value.fullName;
+ },
+ },
+};
+</script>
+<template>
+ <gl-dropdown :text="refShortName" block @show.once="loadRefs">
+ <gl-search-box-by-type
+ v-model.trim="searchTerm"
+ :is-loading="isLoading"
+ :placeholder="__('Search refs')"
+ />
+ <gl-dropdown-section-header>{{ __('Branches') }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="branch in branches"
+ :key="branch.fullName"
+ class="gl-font-monospace"
+ is-check-item
+ :is-checked="isSelected(branch)"
+ @click="setRefSelected(branch)"
+ >
+ {{ branch.shortName }}
+ </gl-dropdown-item>
+ <gl-dropdown-section-header v-if="hasTags">{{ __('Tags') }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="tag in tags"
+ :key="tag.fullName"
+ class="gl-font-monospace"
+ is-check-item
+ :is-checked="isSelected(tag)"
+ @click="setRefSelected(tag)"
+ >
+ {{ tag.shortName }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/pipeline_new/constants.js b/app/assets/javascripts/pipeline_new/constants.js
index 004bbe7daf4..681755dc6ab 100644
--- a/app/assets/javascripts/pipeline_new/constants.js
+++ b/app/assets/javascripts/pipeline_new/constants.js
@@ -1,5 +1,6 @@
export const VARIABLE_TYPE = 'env_var';
export const FILE_TYPE = 'file';
+export const DEBOUNCE_REFS_SEARCH_MS = 250;
export const CONFIG_VARIABLES_TIMEOUT = 5000;
export const BRANCH_REF_TYPE = 'branch';
export const TAG_REF_TYPE = 'tag';
diff --git a/app/assets/javascripts/pipeline_new/index.js b/app/assets/javascripts/pipeline_new/index.js
index 0b85184ec90..a645ea8603b 100644
--- a/app/assets/javascripts/pipeline_new/index.js
+++ b/app/assets/javascripts/pipeline_new/index.js
@@ -1,10 +1,13 @@
import Vue from 'vue';
import PipelineNewForm from './components/pipeline_new_form.vue';
-import formatRefs from './utils/format_refs';
export default () => {
const el = document.getElementById('js-new-pipeline');
const {
+ // provide/inject
+ projectRefsEndpoint,
+
+ // props
projectId,
pipelinesPath,
configVariablesPath,
@@ -12,19 +15,18 @@ export default () => {
refParam,
varParam,
fileParam,
- branchRefs,
- tagRefs,
settingsLink,
maxWarnings,
} = el?.dataset;
const variableParams = JSON.parse(varParam);
const fileParams = JSON.parse(fileParam);
- const branches = formatRefs(JSON.parse(branchRefs), 'branch');
- const tags = formatRefs(JSON.parse(tagRefs), 'tag');
return new Vue({
el,
+ provide: {
+ projectRefsEndpoint,
+ },
render(createElement) {
return createElement(PipelineNewForm, {
props: {
@@ -35,8 +37,6 @@ export default () => {
refParam,
variableParams,
fileParams,
- branches,
- tags,
settingsLink,
maxWarnings: Number(maxWarnings),
},
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index 93156d5d05b..363226a0d85 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -4,7 +4,7 @@ import LinksLayer from '../graph_shared/links_layer.vue';
import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH } from './constants';
import LinkedPipelinesColumn from './linked_pipelines_column.vue';
import StageColumnComponent from './stage_column_component.vue';
-import { reportToSentry } from './utils';
+import { reportToSentry, validateConfigPaths } from './utils';
export default {
name: 'PipelineGraph',
@@ -15,15 +15,20 @@ export default {
StageColumnComponent,
},
props: {
- isLinkedPipeline: {
- type: Boolean,
- required: false,
- default: false,
+ configPaths: {
+ type: Object,
+ required: true,
+ validator: validateConfigPaths,
},
pipeline: {
type: Object,
required: true,
},
+ isLinkedPipeline: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
type: {
type: String,
required: false,
@@ -66,6 +71,12 @@ export default {
hasUpstreamPipelines() {
return Boolean(this.pipeline?.upstream?.length > 0);
},
+ metricsConfig() {
+ return {
+ path: this.configPaths.metricsPath,
+ collectMetrics: true,
+ };
+ },
// The show downstream check prevents showing redundant linked columns
showDownstreamPipelines() {
return (
@@ -95,8 +106,8 @@ export default {
height: this.$refs[this.containerId].scrollHeight,
};
},
- onError(errorType) {
- this.$emit('error', errorType);
+ onError(payload) {
+ this.$emit('error', payload);
},
setJob(jobName) {
this.hoveredJobName = jobName;
@@ -131,6 +142,7 @@ export default {
<template #upstream>
<linked-pipelines-column
v-if="showUpstreamPipelines"
+ :config-paths="configPaths"
:linked-pipelines="upstreamPipelines"
:column-title="__('Upstream')"
:type="$options.pipelineTypeConstants.UPSTREAM"
@@ -145,6 +157,8 @@ export default {
:container-id="containerId"
:container-measurements="measurements"
:highlighted-job="hoveredJobName"
+ :metrics-config="metricsConfig"
+ :never-show-links="true"
default-link-color="gl-stroke-transparent"
@error="onError"
@highlightedJobsChange="updateHighlightedJobs"
@@ -170,6 +184,7 @@ export default {
<linked-pipelines-column
v-if="showDownstreamPipelines"
class="gl-mr-6"
+ :config-paths="configPaths"
:linked-pipelines="downstreamPipelines"
:column-title="__('Downstream')"
:type="$options.pipelineTypeConstants.DOWNSTREAM"
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
index f596333237d..962f2ca2a4c 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
@@ -2,9 +2,15 @@
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql';
import { __ } from '~/locale';
-import { DEFAULT, LOAD_FAILURE } from '../../constants';
+import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '../../constants';
import PipelineGraph from './graph_component.vue';
-import { unwrapPipelineData, toggleQueryPollingByVisibility, reportToSentry } from './utils';
+import {
+ getQueryHeaders,
+ reportToSentry,
+ serializeLoadErrors,
+ toggleQueryPollingByVisibility,
+ unwrapPipelineData,
+} from './utils';
export default {
name: 'PipelineGraphWrapper',
@@ -14,6 +20,12 @@ export default {
PipelineGraph,
},
inject: {
+ graphqlResourceEtag: {
+ default: '',
+ },
+ metricsPath: {
+ default: '',
+ },
pipelineIid: {
default: '',
},
@@ -29,11 +41,15 @@ export default {
};
},
errorTexts: {
+ [DRAW_FAILURE]: __('An error occurred while drawing job relationship links.'),
[LOAD_FAILURE]: __('We are currently unable to fetch data for this pipeline.'),
[DEFAULT]: __('An unknown error occurred while loading this graph.'),
},
apollo: {
pipeline: {
+ context() {
+ return getQueryHeaders(this.graphqlResourceEtag);
+ },
query: getPipelineDetails,
pollInterval: 10000,
variables() {
@@ -43,16 +59,45 @@ export default {
};
},
update(data) {
+ /*
+ This check prevents the pipeline from being overwritten
+ when a poll times out and the data returned is empty.
+ This can be removed once the timeout behavior is updated.
+ See: https://gitlab.com/gitlab-org/gitlab/-/issues/323213.
+ */
+
+ if (!data?.project?.pipeline) {
+ return this.pipeline;
+ }
+
return unwrapPipelineData(this.pipelineProjectPath, data);
},
- error() {
- this.reportFailure(LOAD_FAILURE);
+ error(err) {
+ this.reportFailure({ type: LOAD_FAILURE, skipSentry: true });
+ reportToSentry(
+ this.$options.name,
+ `type: ${LOAD_FAILURE}, info: ${serializeLoadErrors(err)}`,
+ );
+ },
+ result({ error }) {
+ /*
+ If there is a successful load after a failure, clear
+ the failure notification to avoid confusion.
+ */
+ if (!error && this.alertType === LOAD_FAILURE) {
+ this.hideAlert();
+ }
},
},
},
computed: {
alert() {
switch (this.alertType) {
+ case DRAW_FAILURE:
+ return {
+ text: this.$options.errorTexts[DRAW_FAILURE],
+ variant: 'danger',
+ };
case LOAD_FAILURE:
return {
text: this.$options.errorTexts[LOAD_FAILURE],
@@ -65,6 +110,12 @@ export default {
};
}
},
+ configPaths() {
+ return {
+ graphqlResourceEtag: this.graphqlResourceEtag,
+ metricsPath: this.metricsPath,
+ };
+ },
showLoadingIcon() {
/*
Shows the icon only when the graph is empty, not when it is is
@@ -82,15 +133,20 @@ export default {
methods: {
hideAlert() {
this.showAlert = false;
+ this.alertType = null;
},
refreshPipelineGraph() {
this.$apollo.queries.pipeline.refetch();
},
- reportFailure(type) {
+ /* eslint-disable @gitlab/require-i18n-strings */
+ reportFailure({ type, err = 'No error string passed.', skipSentry = false }) {
this.showAlert = true;
- this.failureType = type;
- reportToSentry(this.$options.name, this.failureType);
+ this.alertType = type;
+ if (!skipSentry) {
+ reportToSentry(this.$options.name, `type: ${type}, info: ${err}`);
+ }
},
+ /* eslint-enable @gitlab/require-i18n-strings */
},
};
</script>
@@ -102,6 +158,7 @@ export default {
<gl-loading-icon v-if="showLoadingIcon" class="gl-mx-auto gl-my-4" size="lg" />
<pipeline-graph
v-if="pipeline"
+ :config-paths="configPaths"
:pipeline="pipeline"
@error="reportFailure"
@refreshPipelineGraph="refreshPipelineGraph"
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
index 3ce77a1c60a..b55a77a3c4f 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
@@ -3,7 +3,14 @@ import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.qu
import { LOAD_FAILURE } from '../../constants';
import { ONE_COL_WIDTH, UPSTREAM } from './constants';
import LinkedPipeline from './linked_pipeline.vue';
-import { unwrapPipelineData, toggleQueryPollingByVisibility, reportToSentry } from './utils';
+import {
+ getQueryHeaders,
+ reportToSentry,
+ serializeLoadErrors,
+ toggleQueryPollingByVisibility,
+ unwrapPipelineData,
+ validateConfigPaths,
+} from './utils';
export default {
components: {
@@ -15,6 +22,11 @@ export default {
type: String,
required: true,
},
+ configPaths: {
+ type: Object,
+ required: true,
+ validator: validateConfigPaths,
+ },
linkedPipelines: {
type: Array,
required: true,
@@ -72,6 +84,9 @@ export default {
this.$apollo.addSmartQuery('currentPipeline', {
query: getPipelineDetails,
pollInterval: 10000,
+ context() {
+ return getQueryHeaders(this.configPaths.graphqlResourceEtag);
+ },
variables() {
return {
projectPath,
@@ -79,18 +94,29 @@ export default {
};
},
update(data) {
+ /*
+ This check prevents the pipeline from being overwritten
+ when a poll times out and the data returned is empty.
+ This can be removed once the timeout behavior is updated.
+ See: https://gitlab.com/gitlab-org/gitlab/-/issues/323213.
+ */
+
+ if (!data?.project?.pipeline) {
+ return this.currentPipeline;
+ }
+
return unwrapPipelineData(projectPath, data);
},
result() {
this.loadingPipelineId = null;
this.$emit('scrollContainer');
},
- error(err, _vm, _key, type) {
- this.$emit('error', LOAD_FAILURE);
+ error(err) {
+ this.$emit('error', { type: LOAD_FAILURE, skipSentry: true });
reportToSentry(
'linked_pipelines_column',
- `error type: ${LOAD_FAILURE}, error: ${err}, apollo error type: ${type}`,
+ `error type: ${LOAD_FAILURE}, error: ${serializeLoadErrors(err)}`,
);
},
});
@@ -175,6 +201,7 @@ export default {
v-if="isExpanded(pipeline.id)"
:type="type"
class="d-inline-block gl-mt-n2"
+ :config-paths="configPaths"
:pipeline="currentPipeline"
:is-linked-pipeline="true"
/>
diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js
index 1a935599bfa..b9a8e2638bc 100644
--- a/app/assets/javascripts/pipelines/components/graph/utils.js
+++ b/app/assets/javascripts/pipelines/components/graph/utils.js
@@ -1,6 +1,6 @@
+import * as Sentry from '@sentry/browser';
import Visibility from 'visibilityjs';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import * as Sentry from '~/sentry/wrapper';
import { unwrapStagesWithNeeds } from '../unwrapping_utils';
const addMulti = (mainPipelineProjectPath, linkedPipeline) => {
@@ -10,6 +10,73 @@ const addMulti = (mainPipelineProjectPath, linkedPipeline) => {
};
};
+/* eslint-disable @gitlab/require-i18n-strings */
+const getQueryHeaders = (etagResource) => {
+ return {
+ fetchOptions: {
+ method: 'GET',
+ },
+ headers: {
+ 'X-GITLAB-GRAPHQL-FEATURE-CORRELATION': 'verify/ci/pipeline-graph',
+ 'X-GITLAB-GRAPHQL-RESOURCE-ETAG': etagResource,
+ 'X-Requested-With': 'XMLHttpRequest',
+ },
+ };
+};
+
+const reportToSentry = (component, failureType) => {
+ Sentry.withScope((scope) => {
+ scope.setTag('component', component);
+ Sentry.captureException(failureType);
+ });
+};
+
+const serializeGqlErr = (gqlError) => {
+ const { locations = [], message = '', path = [] } = gqlError;
+
+ return `
+ ${message}.
+ Locations: ${locations
+ .flatMap((loc) => Object.entries(loc))
+ .flat(2)
+ .join(' ')}.
+ Path: ${path.join(', ')}.
+ `;
+};
+
+const serializeLoadErrors = (errors) => {
+ const { gqlError, graphQLErrors, networkError, message } = errors;
+
+ if (graphQLErrors) {
+ return graphQLErrors.map((err) => serializeGqlErr(err)).join('; ');
+ }
+
+ if (gqlError) {
+ return serializeGqlErr(gqlError);
+ }
+
+ if (networkError) {
+ return `Network error: ${networkError.message}`;
+ }
+
+ return message;
+};
+
+/* eslint-enable @gitlab/require-i18n-strings */
+
+const toggleQueryPollingByVisibility = (queryRef, interval = 10000) => {
+ const stopStartQuery = (query) => {
+ if (!Visibility.hidden()) {
+ query.startPolling(interval);
+ } else {
+ query.stopPolling();
+ }
+ };
+
+ stopStartQuery(queryRef);
+ Visibility.change(stopStartQuery.bind(null, queryRef));
+};
+
const transformId = (linkedPipeline) => {
return { ...linkedPipeline, id: getIdFromGraphQLId(linkedPipeline.id) };
};
@@ -42,24 +109,14 @@ const unwrapPipelineData = (mainPipelineProjectPath, data) => {
};
};
-const toggleQueryPollingByVisibility = (queryRef, interval = 10000) => {
- const stopStartQuery = (query) => {
- if (!Visibility.hidden()) {
- query.startPolling(interval);
- } else {
- query.stopPolling();
- }
- };
-
- stopStartQuery(queryRef);
- Visibility.change(stopStartQuery.bind(null, queryRef));
-};
-
-export { unwrapPipelineData, toggleQueryPollingByVisibility };
+const validateConfigPaths = (value) => value.graphqlResourceEtag?.length > 0;
-export const reportToSentry = (component, failureType) => {
- Sentry.withScope((scope) => {
- scope.setTag('component', component);
- Sentry.captureException(failureType);
- });
+export {
+ getQueryHeaders,
+ reportToSentry,
+ serializeGqlErr,
+ serializeLoadErrors,
+ toggleQueryPollingByVisibility,
+ unwrapPipelineData,
+ validateConfigPaths,
};
diff --git a/app/assets/javascripts/pipelines/components/graph_shared/api.js b/app/assets/javascripts/pipelines/components/graph_shared/api.js
new file mode 100644
index 00000000000..04ac15ae24c
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph_shared/api.js
@@ -0,0 +1,8 @@
+import axios from '~/lib/utils/axios_utils';
+import { reportToSentry } from '../graph/utils';
+
+export const reportPerformance = (path, stats) => {
+ axios.post(path, stats).catch((err) => {
+ reportToSentry('links_inner_perf', `error: ${err}`);
+ });
+};
diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue
index 289e04e02c5..fad57084992 100644
--- a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue
+++ b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue
@@ -1,8 +1,19 @@
<script>
import { isEmpty } from 'lodash';
+import {
+ PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START,
+ PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END,
+ PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION,
+ PIPELINES_DETAIL_LINK_DURATION,
+ PIPELINES_DETAIL_LINKS_TOTAL,
+ PIPELINES_DETAIL_LINKS_JOB_RATIO,
+} from '~/performance/constants';
+import { performanceMarkAndMeasure } from '~/performance/utils';
import { DRAW_FAILURE } from '../../constants';
import { createJobsHash, generateJobNeedsDict } from '../../utils';
+import { reportToSentry } from '../graph/utils';
import { parseData } from '../parsing_utils';
+import { reportPerformance } from './api';
import { generateLinksData } from './drawing_utils';
export default {
@@ -25,6 +36,15 @@ export default {
type: Array,
required: true,
},
+ totalGroups: {
+ type: Number,
+ required: true,
+ },
+ metricsConfig: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
defaultLinkColor: {
type: String,
required: false,
@@ -43,6 +63,9 @@ export default {
};
},
computed: {
+ shouldCollectMetrics() {
+ return this.metricsConfig.collectMetrics && this.metricsConfig.path;
+ },
hasHighlightedJob() {
return Boolean(this.highlightedJob);
},
@@ -87,23 +110,70 @@ export default {
this.$emit('highlightedJobsChange', jobs);
},
},
+ errorCaptured(err, _vm, info) {
+ reportToSentry(this.$options.name, `error: ${err}, info: ${info}`);
+ },
mounted() {
if (!isEmpty(this.pipelineData)) {
this.prepareLinkData();
}
},
methods: {
+ beginPerfMeasure() {
+ if (this.shouldCollectMetrics) {
+ performanceMarkAndMeasure({ mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START });
+ }
+ },
+ finishPerfMeasureAndSend() {
+ if (this.shouldCollectMetrics) {
+ performanceMarkAndMeasure({
+ mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END,
+ measures: [
+ {
+ name: PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION,
+ start: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START,
+ },
+ ],
+ });
+ }
+
+ window.requestAnimationFrame(() => {
+ const duration = window.performance.getEntriesByName(
+ PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION,
+ )[0]?.duration;
+
+ if (!duration) {
+ return;
+ }
+
+ const data = {
+ histograms: [
+ { name: PIPELINES_DETAIL_LINK_DURATION, value: duration / 1000 },
+ { name: PIPELINES_DETAIL_LINKS_TOTAL, value: this.links.length },
+ {
+ name: PIPELINES_DETAIL_LINKS_JOB_RATIO,
+ value: this.links.length / this.totalGroups,
+ },
+ ],
+ };
+
+ reportPerformance(this.metricsConfig.path, data);
+ });
+ },
isLinkHighlighted(linkRef) {
return this.highlightedLinks.includes(linkRef);
},
prepareLinkData() {
+ this.beginPerfMeasure();
try {
const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups);
const parsedData = parseData(arrayOfJobs);
this.links = generateLinksData(parsedData, this.containerId, `-${this.pipelineId}`);
- } catch {
- this.$emit('error', DRAW_FAILURE);
+ } catch (err) {
+ this.$emit('error', { type: DRAW_FAILURE, reportToSentry: false });
+ reportToSentry(this.$options.name, err);
}
+ this.finishPerfMeasureAndSend();
},
getLinkClasses(link) {
return [
diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue
index 1c1bc7ecb2a..42eab13b0bd 100644
--- a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue
+++ b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue
@@ -1,6 +1,19 @@
<script>
import { GlAlert } from '@gitlab/ui';
+import { isEmpty } from 'lodash';
import { __ } from '~/locale';
+import {
+ PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START,
+ PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END,
+ PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION,
+ PIPELINES_DETAIL_LINK_DURATION,
+ PIPELINES_DETAIL_LINKS_TOTAL,
+ PIPELINES_DETAIL_LINKS_JOB_RATIO,
+} from '~/performance/constants';
+import { performanceMarkAndMeasure } from '~/performance/utils';
+import { reportToSentry } from '../graph/utils';
+import { parseData } from '../parsing_utils';
+import { reportPerformance } from './api';
import LinksInner from './links_inner.vue';
export default {
@@ -19,6 +32,16 @@ export default {
type: Array,
required: true,
},
+ metricsConfig: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ neverShowLinks: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -41,16 +64,93 @@ export default {
return acc + Number(groups.length);
}, 0);
},
+ shouldCollectMetrics() {
+ return this.metricsConfig.collectMetrics && this.metricsConfig.path;
+ },
showAlert() {
- return !this.showLinkedLayers && !this.alertDismissed;
+ /*
+ This is a hard override that allows us to turn off the links without
+ needing to remove the component entirely for iteration or based on graph type.
+ */
+ if (this.neverShowLinks) {
+ return false;
+ }
+
+ return !this.containerZero && !this.showLinkedLayers && !this.alertDismissed;
},
showLinkedLayers() {
+ /*
+ This is a hard override that allows us to turn off the links without
+ needing to remove the component entirely for iteration or based on graph type.
+ */
+ if (this.neverShowLinks) {
+ return false;
+ }
+
return (
!this.containerZero && (this.showLinksOverride || this.numGroups < this.$options.MAX_GROUPS)
);
},
},
+ errorCaptured(err, _vm, info) {
+ reportToSentry(this.$options.name, `error: ${err}, info: ${info}`);
+ },
+ mounted() {
+ /*
+ This is code to get metrics for the graph (to observe links performance).
+ It is currently here because we want values for links without drawing them.
+ It can be removed when https://gitlab.com/gitlab-org/gitlab/-/issues/298930
+ is closed and functionality is enabled by default.
+ */
+
+ if (this.neverShowLinks && !isEmpty(this.pipelineData)) {
+ window.requestAnimationFrame(() => {
+ this.prepareLinkData();
+ });
+ }
+ },
methods: {
+ beginPerfMeasure() {
+ if (this.shouldCollectMetrics) {
+ performanceMarkAndMeasure({ mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START });
+ }
+ },
+ finishPerfMeasureAndSend(numLinks) {
+ if (this.shouldCollectMetrics) {
+ performanceMarkAndMeasure({
+ mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END,
+ measures: [
+ {
+ name: PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION,
+ start: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START,
+ },
+ ],
+ });
+ }
+
+ window.requestAnimationFrame(() => {
+ const duration = window.performance.getEntriesByName(
+ PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION,
+ )[0]?.duration;
+
+ if (!duration) {
+ return;
+ }
+
+ const data = {
+ histograms: [
+ { name: PIPELINES_DETAIL_LINK_DURATION, value: duration / 1000 },
+ { name: PIPELINES_DETAIL_LINKS_TOTAL, value: numLinks },
+ {
+ name: PIPELINES_DETAIL_LINKS_JOB_RATIO,
+ value: numLinks / this.numGroups,
+ },
+ ],
+ };
+
+ reportPerformance(this.metricsConfig.path, data);
+ });
+ },
dismissAlert() {
this.alertDismissed = true;
},
@@ -58,6 +158,17 @@ export default {
this.dismissAlert();
this.showLinksOverride = true;
},
+ prepareLinkData() {
+ this.beginPerfMeasure();
+ let numLinks;
+ try {
+ const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups);
+ numLinks = parseData(arrayOfJobs).links.length;
+ } catch (err) {
+ reportToSentry(this.$options.name, err);
+ }
+ this.finishPerfMeasureAndSend(numLinks);
+ },
},
};
</script>
@@ -66,6 +177,8 @@ export default {
v-if="showLinkedLayers"
:container-measurements="containerMeasurements"
:pipeline-data="pipelineData"
+ :total-groups="numGroups"
+ :metrics-config="metricsConfig"
v-bind="$attrs"
v-on="$listeners"
>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
index 4a7ee3b2af7..707d6966e77 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
@@ -64,13 +64,6 @@ export default {
hasHighlightedJob() {
return Boolean(this.highlightedJob);
},
- alert() {
- if (this.hasError) {
- return this.failure;
- }
-
- return this.warning;
- },
failure() {
switch (this.failureType) {
case DRAW_FAILURE:
@@ -210,11 +203,11 @@ export default {
<div>
<gl-alert
v-if="hasError"
- :variant="alert.variant"
- :dismissible="alert.dismissible"
- @dismiss="alert.dismissible ? resetFailure : null"
+ :variant="failure.variant"
+ :dismissible="failure.dismissible"
+ @dismiss="resetFailure"
>
- {{ alert.text }}
+ {{ failure.text }}
</gl-alert>
<div
v-if="!hideGraph"
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 8a656bb47f4..f8107d288d9 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
@@ -1,23 +1,22 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlEmptyState } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
export default {
i18n: {
- infoMessage: 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.`),
- buttonMessage: s__('Pipelines|Get started with CI/CD'),
+ 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.`),
+ btnText: s__('Pipelines|Get started with CI/CD'),
+ noCiDescription: s__('Pipelines|This project is not currently set up to run pipelines.'),
},
name: 'PipelinesEmptyState',
components: {
- GlButton,
+ GlEmptyState,
},
props: {
- helpPagePath: {
- type: String,
- required: true,
- },
emptyStateSvgPath: {
type: String,
required: true,
@@ -27,40 +26,28 @@ export default {
required: true,
},
},
+ computed: {
+ ciHelpPagePath() {
+ return helpPagePath('ci/quick_start/index.md');
+ },
+ },
};
</script>
<template>
- <div class="row empty-state js-empty-state">
- <div class="col-12">
- <div class="svg-content svg-250"><img :src="emptyStateSvgPath" /></div>
- </div>
-
- <div class="col-12">
- <div class="text-content">
- <template v-if="canSetCi">
- <h4 data-testid="header-text" class="gl-text-center">
- {{ s__('Pipelines|Build with confidence') }}
- </h4>
- <p data-testid="info-text">
- {{ $options.i18n.infoMessage }}
- </p>
-
- <div class="gl-text-center">
- <gl-button
- :href="helpPagePath"
- variant="info"
- category="primary"
- data-testid="get-started-pipelines"
- >
- {{ $options.i18n.buttonMessage }}
- </gl-button>
- </div>
- </template>
-
- <p v-else class="gl-text-center">
- {{ s__('Pipelines|This project is not currently set up to run pipelines.') }}
- </p>
- </div>
- </div>
+ <div>
+ <gl-empty-state
+ v-if="canSetCi"
+ :title="$options.i18n.title"
+ :svg-path="emptyStateSvgPath"
+ :description="$options.i18n.description"
+ :primary-button-text="$options.i18n.btnText"
+ :primary-button-link="ciHelpPagePath"
+ />
+ <gl-empty-state
+ v-else
+ title=""
+ :svg-path="emptyStateSvgPath"
+ :description="$options.i18n.noCiDescription"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue
new file mode 100644
index 00000000000..05372010d0f
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue
@@ -0,0 +1,54 @@
+<script>
+import PipelineStage from '~/pipelines/components/pipelines_list/pipeline_stage.vue';
+/**
+ * Renders the pipeline mini graph.
+ */
+export default {
+ components: {
+ PipelineStage,
+ },
+ props: {
+ stages: {
+ type: Array,
+ required: true,
+ },
+ updateDropdown: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ stagesClass: {
+ type: [Array, Object, String],
+ required: false,
+ default: '',
+ },
+ isMergeTrain: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ methods: {
+ onPipelineActionRequestComplete() {
+ this.$emit('pipelineActionRequestComplete');
+ },
+ },
+};
+</script>
+<template>
+ <div data-testid="widget-mini-pipeline-graph">
+ <div
+ v-for="stage in stages"
+ :key="stage.name"
+ :class="stagesClass"
+ class="stage-container dropdown"
+ >
+ <pipeline-stage
+ :stage="stage"
+ :update-dropdown="updateDropdown"
+ :is-merge-train="isMergeTrain"
+ @pipelineActionRequestComplete="onPipelineActionRequestComplete"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
new file mode 100644
index 00000000000..81eeead2171
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
@@ -0,0 +1,119 @@
+<script>
+import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+import eventHub from '../../event_hub';
+import PipelinesArtifactsComponent from './pipelines_artifacts.vue';
+import PipelinesManualActions from './pipelines_manual_actions.vue';
+
+export default {
+ i18n: {
+ cancelTitle: __('Cancel'),
+ redeployTitle: __('Retry'),
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ GlModalDirective,
+ },
+ components: {
+ GlButton,
+ PipelinesManualActions,
+ PipelinesArtifactsComponent,
+ },
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ cancelingPipeline: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ isRetrying: false,
+ };
+ },
+ computed: {
+ displayPipelineActions() {
+ return (
+ this.pipeline.flags.retryable ||
+ this.pipeline.flags.cancelable ||
+ this.pipeline.details.manual_actions.length ||
+ this.pipeline.details.artifacts.length
+ );
+ },
+ actions() {
+ if (!this.pipeline || !this.pipeline.details) {
+ return [];
+ }
+ const { details } = this.pipeline;
+ return [...(details.manual_actions || []), ...(details.scheduled_actions || [])];
+ },
+ isCancelling() {
+ return this.cancelingPipeline === this.pipeline.id;
+ },
+ },
+ watch: {
+ pipeline() {
+ this.isRetrying = false;
+ },
+ },
+ methods: {
+ handleCancelClick() {
+ eventHub.$emit('openConfirmationModal', {
+ pipeline: this.pipeline,
+ endpoint: this.pipeline.cancel_path,
+ });
+ },
+ handleRetryClick() {
+ this.isRetrying = true;
+ eventHub.$emit('retryPipeline', this.pipeline.retry_path);
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="displayPipelineActions" class="gl-text-right">
+ <div class="btn-group">
+ <pipelines-manual-actions v-if="actions.length > 0" :actions="actions" />
+
+ <pipelines-artifacts-component
+ v-if="pipeline.details.artifacts.length"
+ :artifacts="pipeline.details.artifacts"
+ />
+
+ <gl-button
+ v-if="pipeline.flags.retryable"
+ v-gl-tooltip.hover
+ :aria-label="$options.i18n.redeployTitle"
+ :title="$options.i18n.redeployTitle"
+ :disabled="isRetrying"
+ :loading="isRetrying"
+ class="js-pipelines-retry-button"
+ data-qa-selector="pipeline_retry_button"
+ icon="repeat"
+ variant="default"
+ category="secondary"
+ @click="handleRetryClick"
+ />
+
+ <gl-button
+ v-if="pipeline.flags.cancelable"
+ v-gl-tooltip.hover
+ v-gl-modal-directive="'confirmation-modal'"
+ :aria-label="$options.i18n.cancelTitle"
+ :title="$options.i18n.cancelTitle"
+ :loading="isCancelling"
+ :disabled="isCancelling"
+ icon="close"
+ variant="danger"
+ category="primary"
+ class="js-pipelines-cancel-button"
+ @click="handleCancelClick"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue
new file mode 100644
index 00000000000..bdb7dd06620
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue
@@ -0,0 +1,152 @@
+<script>
+/**
+ * Renders each stage of the pipeline mini graph.
+ *
+ * Given the provided endpoint will make a request to
+ * fetch the dropdown data when the stage is clicked.
+ *
+ * Request is made inside this component to make it reusable between:
+ * 1. Pipelines main table
+ * 2. Pipelines table in commit and Merge request views
+ * 3. Merge request widget
+ * 4. Commit widget
+ */
+
+import { GlDropdown, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { deprecatedCreateFlash as Flash } from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import eventHub from '../../event_hub';
+import JobItem from '../graph/job_item.vue';
+
+export default {
+ components: {
+ GlIcon,
+ GlLoadingIcon,
+ GlDropdown,
+ JobItem,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ stage: {
+ type: Object,
+ required: true,
+ },
+ updateDropdown: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isMergeTrain: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ isLoading: false,
+ dropdownContent: [],
+ };
+ },
+ computed: {
+ triggerButtonClass() {
+ return `ci-status-icon-${this.stage.status.group}`;
+ },
+ borderlessIcon() {
+ return `${this.stage.status.icon}_borderless`;
+ },
+ },
+ watch: {
+ updateDropdown() {
+ if (this.updateDropdown && this.isDropdownOpen() && !this.isLoading) {
+ this.fetchJobs();
+ }
+ },
+ },
+ methods: {
+ onShowDropdown() {
+ eventHub.$emit('clickedDropdown');
+ this.isLoading = true;
+ this.fetchJobs();
+ },
+ fetchJobs() {
+ axios
+ .get(this.stage.dropdown_path)
+ .then(({ data }) => {
+ this.dropdownContent = data.latest_statuses;
+ this.isLoading = false;
+ })
+ .catch(() => {
+ this.$refs.dropdown.hide();
+ this.isLoading = false;
+
+ Flash(__('Something went wrong on our end.'));
+ });
+ },
+ isDropdownOpen() {
+ return this.$el.classList.contains('show');
+ },
+ pipelineActionRequestComplete() {
+ // close the dropdown in MR widget
+ this.$refs.dropdown.hide();
+
+ // warn the pipelines table to update
+ this.$emit('pipelineActionRequestComplete');
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ ref="dropdown"
+ v-gl-tooltip.hover
+ data-testid="mini-pipeline-graph-dropdown"
+ :title="stage.title"
+ variant="link"
+ :lazy="true"
+ :popper-opts="{ placement: 'bottom' }"
+ :toggle-class="['mini-pipeline-graph-dropdown-toggle', triggerButtonClass]"
+ menu-class="mini-pipeline-graph-dropdown-menu"
+ @show="onShowDropdown"
+ >
+ <template #button-content>
+ <span class="gl-pointer-events-none">
+ <gl-icon :name="borderlessIcon" />
+ </span>
+ </template>
+ <gl-loading-icon v-if="isLoading" />
+ <ul
+ v-else
+ class="js-builds-dropdown-list scrollable-menu"
+ data-testid="mini-pipeline-graph-dropdown-menu-list"
+ >
+ <li v-for="job in dropdownContent" :key="job.id">
+ <job-item
+ :dropdown-length="dropdownContent.length"
+ :job="job"
+ css-class-job-name="mini-pipeline-graph-dropdown-item"
+ @pipelineActionRequestComplete="pipelineActionRequestComplete"
+ />
+ </li>
+ <template v-if="isMergeTrain">
+ <li class="gl-new-dropdown-divider" role="presentation">
+ <hr role="separator" aria-orientation="horizontal" class="dropdown-divider" />
+ </li>
+ <li>
+ <div
+ class="gl-display-flex gl-align-items-center"
+ data-testid="warning-message-merge-trains"
+ >
+ <div class="menu-item gl-font-sm gl-text-gray-300!">
+ {{ s__('Pipeline|Merge train pipeline jobs can not be retried') }}
+ </div>
+ </div>
+ </li>
+ </template>
+ </ul>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
index 6ac60727f23..c707b395192 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
@@ -1,10 +1,12 @@
<script>
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
UserAvatarLink,
},
+ mixins: [glFeatureFlagMixin()],
props: {
pipeline: {
type: Object,
@@ -15,11 +17,19 @@ export default {
user() {
return this.pipeline.user;
},
+ classes() {
+ const triggererClass = 'pipeline-triggerer';
+
+ if (this.glFeatures.newPipelinesTable) {
+ return triggererClass;
+ }
+ return `table-section section-10 d-none d-md-block ${triggererClass}`;
+ },
},
};
</script>
<template>
- <div class="table-section section-10 d-none d-md-block pipeline-triggerer">
+ <div :class="classes" data-testid="pipeline-triggerer">
<user-avatar-link
v-if="user"
:link-href="user.path"
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 823ada133d2..0de520a2ca7 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -1,5 +1,7 @@
<script>
import { GlLink, GlPopover, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { SCHEDULE_ORIGIN } from '../../constants';
export default {
@@ -12,6 +14,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagMixin()],
inject: {
targetProjectFullPath: {
default: '',
@@ -26,10 +29,6 @@ export default {
type: String,
required: true,
},
- autoDevopsHelpPath: {
- type: String,
- required: true,
- },
},
computed: {
user() {
@@ -44,11 +43,25 @@ export default {
this.pipeline?.project?.full_path !== `/${this.targetProjectFullPath}`,
);
},
+ autoDevopsTagId() {
+ return `pipeline-url-autodevops-${this.pipeline.id}`;
+ },
+ autoDevopsHelpPath() {
+ return helpPagePath('topics/autodevops/index.md');
+ },
+ classes() {
+ const tagsClass = 'pipeline-tags';
+
+ if (this.glFeatures.newPipelinesTable) {
+ return tagsClass;
+ }
+ return `table-section section-10 d-none d-md-block ${tagsClass}`;
+ },
},
};
</script>
<template>
- <div class="table-section section-10 d-none d-md-block pipeline-tags">
+ <div :class="classes" data-testid="pipeline-url-table-cell">
<gl-link
:href="pipeline.path"
data-testid="pipeline-url-link"
@@ -103,38 +116,43 @@ export default {
data-testid="pipeline-url-failure"
>{{ __('error') }}</gl-badge
>
- <gl-link
- v-if="pipeline.flags.auto_devops"
- :id="`pipeline-url-autodevops-${pipeline.id}`"
- tabindex="0"
- data-testid="pipeline-url-autodevops"
- role="button"
- ><gl-badge variant="info" size="sm">{{ __('Auto DevOps') }}</gl-badge></gl-link
- >
- <gl-popover
- :target="`pipeline-url-autodevops-${pipeline.id}`"
- 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" target="_blank" rel="noopener noreferrer nofollow">{{
- __('Learn more about Auto DevOps')
- }}</gl-link>
- </gl-popover>
+ <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"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
index 48009a9fcb8..19d93e7d083 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -52,10 +52,6 @@ export default {
required: false,
default: '',
},
- helpPagePath: {
- type: String,
- required: true,
- },
emptyStateSvgPath: {
type: String,
required: true,
@@ -68,10 +64,6 @@ export default {
type: String,
required: true,
},
- autoDevopsHelpPath: {
- type: String,
- required: true,
- },
hasGitlabCi: {
type: Boolean,
required: true,
@@ -337,7 +329,6 @@ export default {
<empty-state
v-else-if="stateToRender === $options.stateMap.emptyState"
- :help-page-path="helpPagePath"
:empty-state-svg-path="emptyStateSvgPath"
:can-set-ci="canCreatePipeline"
/>
@@ -362,7 +353,6 @@ export default {
:pipelines="state.pipelines"
:pipeline-schedule-url="pipelineScheduleUrl"
:update-graph-dropdown="updateGraphDropdown"
- :auto-devops-help-path="autoDevopsHelpPath"
:view-type="viewType"
/>
</div>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
index b13460b4c68..9c3990f82df 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
@@ -31,6 +31,8 @@ export default {
:text="$options.translations.artifacts"
:aria-label="$options.translations.artifacts"
icon="download"
+ right
+ lazy
text-sr-only
>
<gl-dropdown-item
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_commit.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_commit.vue
new file mode 100644
index 00000000000..cc676883c1d
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_commit.vue
@@ -0,0 +1,85 @@
+<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_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
index 6890cbb9bed..b94f1a42039 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
@@ -82,6 +82,7 @@ export default {
:loading="isLoading"
data-testid="pipelines-manual-actions-dropdown"
right
+ lazy
icon="play"
>
<gl-dropdown-item
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
new file mode 100644
index 00000000000..cc3c8d522b3
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue
@@ -0,0 +1,37 @@
+<script>
+import { CHILD_VIEW } from '~/pipelines/constants';
+import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
+
+export default {
+ components: {
+ CiBadge,
+ },
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ viewType: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ pipelineStatus() {
+ return this.pipeline?.details?.status ?? {};
+ },
+ isChildView() {
+ return this.viewType === CHILD_VIEW;
+ },
+ },
+};
+</script>
+
+<template>
+ <ci-badge
+ :status="pipelineStatus"
+ :show-text="!isChildView"
+ :icon-classes="'gl-vertical-align-middle!'"
+ data-qa-selector="pipeline_commit_status"
+ />
+</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 24c67184e56..aa27aa7e50d 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -1,22 +1,97 @@
<script>
-import { GlTooltipDirective } from '@gitlab/ui';
+import { GlTable, 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 PipelinesTableRowComponent from './pipelines_table_row.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!';
+const DEFAULT_TH_CLASSES =
+ 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1! gl-font-sm!';
-/**
- * Pipelines Table Component.
- *
- * Given an array of objects, renders a table.
- */
export default {
+ fields: [
+ {
+ key: 'status',
+ label: s__('Pipeline|Status'),
+ thClass: DEFAULT_TH_CLASSES,
+ columnClass: 'gl-w-10p',
+ tdClass: DEFAULT_TD_CLASS,
+ thAttr: { 'data-testid': 'status-th' },
+ },
+ {
+ key: 'pipeline',
+ label: s__('Pipeline|Pipeline'),
+ thClass: DEFAULT_TH_CLASSES,
+ tdClass: `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`,
+ columnClass: '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-15p',
+ thAttr: { 'data-testid': 'stages-th' },
+ },
+ {
+ key: 'timeago',
+ label: s__('Pipeline|Duration'),
+ thClass: DEFAULT_TH_CLASSES,
+ tdClass: DEFAULT_TD_CLASS,
+ columnClass: 'gl-w-15p',
+ thAttr: { 'data-testid': 'timeago-th' },
+ },
+ {
+ key: 'actions',
+ thClass: DEFAULT_TH_CLASSES,
+ tdClass: DEFAULT_TD_CLASS,
+ columnClass: 'gl-w-20p',
+ thAttr: { 'data-testid': 'actions-th' },
+ },
+ ],
components: {
- PipelinesTableRowComponent,
+ GlTable,
+ PipelinesCommit,
+ PipelineMiniGraph,
+ PipelineOperations,
+ PipelinesStatusBadge,
PipelineStopModal,
+ PipelinesTableRowComponent,
+ PipelinesTimeago,
+ PipelineTriggerer,
+ PipelineUrl,
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagMixin()],
props: {
pipelines: {
type: Array,
@@ -32,10 +107,6 @@ export default {
required: false,
default: false,
},
- autoDevopsHelpPath: {
- type: String,
- required: true,
- },
viewType: {
type: String,
required: true,
@@ -70,42 +141,103 @@ export default {
eventHub.$emit('postAction', this.endpoint);
this.cancelingPipeline = this.pipelineId;
},
+ onPipelineActionRequestComplete() {
+ eventHub.$emit('refreshPipelinesTable');
+ },
},
};
</script>
<template>
<div class="ci-table">
- <div class="gl-responsive-table-row table-row-header" role="row">
- <div class="table-section section-10 js-pipeline-status" role="rowheader">
- {{ s__('Pipeline|Status') }}
- </div>
- <div class="table-section section-10 js-pipeline-info pipeline-info" role="rowheader">
- {{ s__('Pipeline|Pipeline') }}
- </div>
- <div class="table-section section-10 js-triggerer-info triggerer-info" role="rowheader">
- {{ s__('Pipeline|Triggerer') }}
- </div>
- <div class="table-section section-20 js-pipeline-commit pipeline-commit" role="rowheader">
- {{ s__('Pipeline|Commit') }}
- </div>
- <div class="table-section section-15 js-pipeline-stages pipeline-stages" role="rowheader">
- {{ s__('Pipeline|Stages') }}
- </div>
- <div class="table-section section-15" role="rowheader"></div>
- <div class="table-section section-20" role="rowheader">
- <slot name="table-header-actions"></slot>
+ <div v-if="!glFeatures.newPipelinesTable" data-testid="legacy-ci-table">
+ <div class="gl-responsive-table-row table-row-header" role="row">
+ <div class="table-section section-10 js-pipeline-status" role="rowheader">
+ {{ s__('Pipeline|Status') }}
+ </div>
+ <div class="table-section section-10 js-pipeline-info pipeline-info" role="rowheader">
+ {{ s__('Pipeline|Pipeline') }}
+ </div>
+ <div class="table-section section-10 js-triggerer-info triggerer-info" role="rowheader">
+ {{ s__('Pipeline|Triggerer') }}
+ </div>
+ <div class="table-section section-20 js-pipeline-commit pipeline-commit" role="rowheader">
+ {{ s__('Pipeline|Commit') }}
+ </div>
+ <div class="table-section section-15 js-pipeline-stages pipeline-stages" role="rowheader">
+ {{ s__('Pipeline|Stages') }}
+ </div>
+ <div class="table-section section-15" role="rowheader"></div>
+ <div class="table-section section-20" role="rowheader">
+ <slot name="table-header-actions"></slot>
+ </div>
</div>
+ <pipelines-table-row-component
+ v-for="model in pipelines"
+ :key="model.id"
+ :pipeline="model"
+ :pipeline-schedule-url="pipelineScheduleUrl"
+ :update-graph-dropdown="updateGraphDropdown"
+ :view-type="viewType"
+ :canceling-pipeline="cancelingPipeline"
+ />
</div>
- <pipelines-table-row-component
- v-for="model in pipelines"
- :key="model.id"
- :pipeline="model"
- :pipeline-schedule-url="pipelineScheduleUrl"
- :update-graph-dropdown="updateGraphDropdown"
- :auto-devops-help-path="autoDevopsHelpPath"
- :view-type="viewType"
- :canceling-pipeline="cancelingPipeline"
- />
+
+ <gl-table
+ v-else
+ :fields="$options.fields"
+ :items="pipelines"
+ tbody-tr-class="commit"
+ :tbody-tr-attr="{ 'data-testid': 'pipeline-table-row' }"
+ stacked="lg"
+ fixed
+ >
+ <template #head(actions)>
+ <span class="gl-display-block gl-lg-display-none!">{{ s__('Pipeline|Actions') }}</span>
+ <slot name="table-header-actions"></slot>
+ </template>
+
+ <template #table-colgroup="{ fields }">
+ <col v-for="field in fields" :key="field.key" :class="field.columnClass" />
+ </template>
+
+ <template #cell(status)="{ item }">
+ <pipelines-status-badge :pipeline="item" :view-type="viewType" />
+ </template>
+
+ <template #cell(pipeline)="{ item }">
+ <pipeline-url :pipeline="item" :pipeline-schedule-url="pipelineScheduleUrl" />
+ </template>
+
+ <template #cell(triggerer)="{ item }">
+ <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 -->
+ <div></div>
+ <pipeline-mini-graph
+ v-if="item.details && item.details.stages && item.details.stages.length > 0"
+ :stages="item.details.stages"
+ :update-dropdown="updateGraphDropdown"
+ @pipelineActionRequestComplete="onPipelineActionRequestComplete"
+ />
+ </div>
+ </template>
+
+ <template #cell(timeago)="{ item }">
+ <pipelines-timeago :pipeline="item" />
+ </template>
+
+ <template #cell(actions)="{ item }">
+ <pipeline-operations :pipeline="item" :canceling-pipeline="cancelingPipeline" />
+ </template>
+ </gl-table>
+
<pipeline-stop-modal :pipeline="pipeline" @submit="onSubmit" />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue
index 572abe2a24a..f684a0b0fcd 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue
@@ -3,13 +3,12 @@ import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui';
import { __ } from '~/locale';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
import CommitComponent from '~/vue_shared/components/commit.vue';
-import { PIPELINES_TABLE } from '../../constants';
import eventHub from '../../event_hub';
+import PipelineMiniGraph from './pipeline_mini_graph.vue';
import PipelineTriggerer from './pipeline_triggerer.vue';
import PipelineUrl from './pipeline_url.vue';
-import PipelinesActionsComponent from './pipelines_actions.vue';
import PipelinesArtifactsComponent from './pipelines_artifacts.vue';
-import PipelineStage from './stage.vue';
+import PipelinesManualActionsComponent from './pipelines_manual_actions.vue';
import PipelinesTimeago from './time_ago.vue';
export default {
@@ -22,10 +21,10 @@ export default {
GlModalDirective,
},
components: {
- PipelinesActionsComponent,
+ PipelinesManualActionsComponent,
PipelinesArtifactsComponent,
CommitComponent,
- PipelineStage,
+ PipelineMiniGraph,
PipelineUrl,
PipelineTriggerer,
CiBadge,
@@ -47,10 +46,6 @@ export default {
required: false,
default: false,
},
- autoDevopsHelpPath: {
- type: String,
- required: true,
- },
viewType: {
type: String,
required: true,
@@ -61,7 +56,6 @@ export default {
default: null,
},
},
- pipelinesTable: PIPELINES_TABLE,
data() {
return {
isRetrying: false,
@@ -137,15 +131,12 @@ export default {
commitTitle() {
return this.pipeline?.commit?.title;
},
- pipelineDuration() {
- return this.pipeline?.details?.duration ?? 0;
- },
- pipelineFinishedAt() {
- return this.pipeline?.details?.finished_at ?? '';
- },
pipelineStatus() {
return this.pipeline?.details?.status ?? {};
},
+ hasStages() {
+ return this.pipeline?.details?.stages?.length > 0;
+ },
displayPipelineActions() {
return (
this.pipeline.flags.retryable ||
@@ -177,6 +168,10 @@ export default {
this.isRetrying = true;
eventHub.$emit('retryPipeline', this.pipeline.retry_path);
},
+ handlePipelineActionRequestComplete() {
+ // warn the pipelines table to update
+ eventHub.$emit('refreshPipelinesTable');
+ },
},
};
</script>
@@ -194,11 +189,7 @@ export default {
</div>
</div>
- <pipeline-url
- :pipeline="pipeline"
- :pipeline-schedule-url="pipelineScheduleUrl"
- :auto-devops-help-path="autoDevopsHelpPath"
- />
+ <pipeline-url :pipeline="pipeline" :pipeline-schedule-url="pipelineScheduleUrl" />
<pipeline-triggerer :pipeline="pipeline" />
<div class="table-section section-wrap section-20">
@@ -220,35 +211,23 @@ export default {
<div class="table-section section-wrap section-15 stage-cell">
<div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Stages') }}</div>
<div class="table-mobile-content">
- <template v-if="pipeline.details.stages.length > 0">
- <div
- v-for="(stage, index) in pipeline.details.stages"
- :key="index"
- class="stage-container dropdown"
- data-testid="widget-mini-pipeline-graph"
- >
- <pipeline-stage
- :type="$options.pipelinesTable"
- :stage="stage"
- :update-dropdown="updateGraphDropdown"
- />
- </div>
- </template>
+ <pipeline-mini-graph
+ v-if="hasStages"
+ :stages="pipeline.details.stages"
+ :update-dropdown="updateGraphDropdown"
+ @pipelineActionRequestComplete="handlePipelineActionRequestComplete"
+ />
</div>
</div>
- <pipelines-timeago
- class="gl-text-right"
- :duration="pipelineDuration"
- :finished-time="pipelineFinishedAt"
- />
+ <pipelines-timeago class="gl-text-right" :pipeline="pipeline" />
<div
v-if="displayPipelineActions"
class="table-section section-20 table-button-footer pipeline-actions"
>
<div class="btn-group table-action-buttons">
- <pipelines-actions-component v-if="actions.length > 0" :actions="actions" />
+ <pipelines-manual-actions-component v-if="actions.length > 0" :actions="actions" />
<pipelines-artifacts-component
v-if="pipeline.details.artifacts.length"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue
deleted file mode 100644
index f5dfb9e72d5..00000000000
--- a/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue
+++ /dev/null
@@ -1,234 +0,0 @@
-<script>
-/**
- * Renders each stage of the pipeline mini graph.
- *
- * Given the provided endpoint will make a request to
- * fetch the dropdown data when the stage is clicked.
- *
- * Request is made inside this component to make it reusable between:
- * 1. Pipelines main table
- * 2. Pipelines table in commit and Merge request views
- * 3. Merge request widget
- * 4. Commit widget
- */
-import { GlDropdown, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui';
-import $ from 'jquery';
-import { deprecatedCreateFlash as Flash } from '~/flash';
-import axios from '~/lib/utils/axios_utils';
-import { __ } from '~/locale';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { PIPELINES_TABLE } from '../../constants';
-import eventHub from '../../event_hub';
-import JobItem from '../graph/job_item.vue';
-
-export default {
- components: {
- GlIcon,
- GlLoadingIcon,
- GlDropdown,
- JobItem,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- mixins: [glFeatureFlagsMixin()],
- props: {
- stage: {
- type: Object,
- required: true,
- },
-
- updateDropdown: {
- type: Boolean,
- required: false,
- default: false,
- },
-
- type: {
- type: String,
- required: false,
- default: '',
- },
- },
- data() {
- return {
- isLoading: false,
- dropdownContent: [],
- };
- },
- computed: {
- isCiMiniPipelineGlDropdown() {
- // Feature flag ci_mini_pipeline_gl_dropdown
- // See more at https://gitlab.com/gitlab-org/gitlab/-/issues/300400
- return this.glFeatures?.ciMiniPipelineGlDropdown;
- },
- triggerButtonClass() {
- return `ci-status-icon-${this.stage.status.group}`;
- },
- borderlessIcon() {
- return `${this.stage.status.icon}_borderless`;
- },
- },
- watch: {
- updateDropdown() {
- if (this.updateDropdown && this.isDropdownOpen() && !this.isLoading) {
- this.fetchJobs();
- }
- },
- },
- updated() {
- if (!this.isCiMiniPipelineGlDropdown && this.dropdownContent.length) {
- this.stopDropdownClickPropagation();
- }
- },
- methods: {
- onShowDropdown() {
- eventHub.$emit('clickedDropdown');
- this.isLoading = true;
- this.fetchJobs();
- },
- onClickStage() {
- if (!this.isDropdownOpen()) {
- eventHub.$emit('clickedDropdown');
- this.isLoading = true;
- this.fetchJobs();
- }
- },
- fetchJobs() {
- axios
- .get(this.stage.dropdown_path)
- .then(({ data }) => {
- this.dropdownContent = data.latest_statuses;
- this.isLoading = false;
- })
- .catch(() => {
- if (this.isCiMiniPipelineGlDropdown) {
- this.$refs.stageGlDropdown.hide();
- } else {
- this.closeDropdown();
- }
- this.isLoading = false;
-
- Flash(__('Something went wrong on our end.'));
- });
- },
- /**
- * When the user right clicks or cmd/ctrl + click in the job name
- * the dropdown should not be closed and the link should open in another tab,
- * so we stop propagation of the click event inside the dropdown.
- *
- * Since this component is rendered multiple times per page we need to guarantee we only
- * target the click event of this component.
- *
- * Note: This should be removed once ci_mini_pipeline_gl_dropdown FF is removed as true.
- */
- stopDropdownClickPropagation() {
- $(
- '.js-builds-dropdown-list button, .js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item',
- this.$el,
- ).on('click', (e) => {
- e.stopPropagation();
- });
- },
- closeDropdown() {
- if (this.isDropdownOpen()) {
- $(this.$refs.dropdown).dropdown('toggle');
- }
- },
- isDropdownOpen() {
- return this.$el.classList.contains('show');
- },
- pipelineActionRequestComplete() {
- if (this.type === PIPELINES_TABLE) {
- // warn the table to update
- eventHub.$emit('refreshPipelinesTable');
- return;
- }
- // close the dropdown in mr widget
- if (this.isCiMiniPipelineGlDropdown) {
- this.$refs.stageGlDropdown.hide();
- } else {
- $(this.$refs.dropdown).dropdown('toggle');
- }
- },
- },
-};
-</script>
-
-<template>
- <div class="dropdown">
- <gl-dropdown
- v-if="isCiMiniPipelineGlDropdown"
- ref="stageGlDropdown"
- v-gl-tooltip.hover
- data-testid="mini-pipeline-graph-dropdown"
- :title="stage.title"
- variant="link"
- :lazy="true"
- :popper-opts="{ placement: 'bottom' }"
- :toggle-class="['mini-pipeline-graph-gl-dropdown-toggle', triggerButtonClass]"
- menu-class="mini-pipeline-graph-dropdown-menu"
- @show="onShowDropdown"
- >
- <template #button-content>
- <span class="gl-pointer-events-none">
- <gl-icon :name="borderlessIcon" />
- </span>
- </template>
- <gl-loading-icon v-if="isLoading" />
- <ul
- v-else
- class="js-builds-dropdown-list scrollable-menu"
- data-testid="mini-pipeline-graph-dropdown-menu-list"
- >
- <li v-for="job in dropdownContent" :key="job.id">
- <job-item
- :dropdown-length="dropdownContent.length"
- :job="job"
- css-class-job-name="mini-pipeline-graph-dropdown-item"
- @pipelineActionRequestComplete="pipelineActionRequestComplete"
- />
- </li>
- </ul>
- </gl-dropdown>
-
- <template v-else>
- <button
- id="stageDropdown"
- ref="dropdown"
- v-gl-tooltip.hover
- :class="triggerButtonClass"
- :title="stage.title"
- class="mini-pipeline-graph-dropdown-toggle"
- data-testid="mini-pipeline-graph-dropdown-toggle"
- data-toggle="dropdown"
- data-display="static"
- type="button"
- aria-haspopup="true"
- aria-expanded="false"
- @click="onClickStage"
- >
- <span :aria-label="stage.title" aria-hidden="true" class="gl-pointer-events-none">
- <gl-icon :name="borderlessIcon" />
- </span>
- </button>
-
- <div
- class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container"
- aria-labelledby="stageDropdown"
- >
- <gl-loading-icon v-if="isLoading" />
- <ul v-else class="js-builds-dropdown-list scrollable-menu">
- <li v-for="job in dropdownContent" :key="job.id">
- <job-item
- :dropdown-length="dropdownContent.length"
- :job="job"
- css-class-job-name="mini-pipeline-graph-dropdown-item"
- @pipelineActionRequestComplete="pipelineActionRequestComplete"
- />
- </li>
- </ul>
- </div>
- </template>
- </div>
-</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 5548a1021f5..543bdf94307 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -1,5 +1,6 @@
<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 {
@@ -7,23 +8,19 @@ export default {
GlTooltip: GlTooltipDirective,
},
components: { GlIcon },
- mixins: [timeagoMixin],
+ mixins: [timeagoMixin, glFeatureFlagMixin()],
props: {
- finishedTime: {
- type: String,
- required: true,
- },
- duration: {
- type: Number,
+ pipeline: {
+ type: Object,
required: true,
},
},
computed: {
- hasDuration() {
- return this.duration > 0;
+ duration() {
+ return this.pipeline?.details?.duration;
},
- hasFinishedTime() {
- return this.finishedTime !== '';
+ finishedTime() {
+ return this.pipeline?.details?.finished_at;
},
durationFormatted() {
const date = new Date(this.duration * 1000);
@@ -45,20 +42,36 @@ export default {
return `${hh}:${mm}:${ss}`;
},
+ legacySectionClass() {
+ return !this.glFeatures.newPipelinesTable ? 'table-section section-15' : '';
+ },
+ legacyTableMobileClass() {
+ return !this.glFeatures.newPipelinesTable ? 'table-mobile-content' : '';
+ },
+ showInProgress() {
+ return !this.duration && !this.finishedTime;
+ },
},
};
</script>
<template>
- <div class="table-section section-15">
- <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Duration') }}</div>
- <div class="table-mobile-content">
- <p v-if="hasDuration" class="duration">
- <gl-icon name="timer" class="gl-vertical-align-baseline!" />
+ <div :class="legacySectionClass">
+ <div v-if="!glFeatures.newPipelinesTable" class="table-mobile-header" role="rowheader">
+ {{ s__('Pipeline|Duration') }}
+ </div>
+ <div :class="legacyTableMobileClass">
+ <span v-if="showInProgress" data-testid="pipeline-in-progress">
+ <gl-icon name="hourglass" class="gl-vertical-align-baseline! gl-mr-2" :size="12" />
+ {{ s__('Pipeline|In progress') }}
+ </span>
+
+ <p v-if="duration" class="duration">
+ <gl-icon name="timer" class="gl-vertical-align-baseline!" :size="12" />
{{ durationFormatted }}
</p>
- <p v-if="hasFinishedTime" class="finished-at d-none d-md-block">
- <gl-icon name="calendar" class="gl-vertical-align-baseline!" />
+ <p v-if="finishedTime" class="finished-at d-none d-md-block">
+ <gl-icon name="calendar" class="gl-vertical-align-baseline!" :size="12" />
<time
v-gl-tooltip
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index 757d285ef19..21b114825a6 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -1,7 +1,6 @@
import { s__, __ } from '~/locale';
export const CANCEL_REQUEST = 'CANCEL_REQUEST';
-export const PIPELINES_TABLE = 'PIPELINES_TABLE';
export const LAYOUT_CHANGE_DELAY = 300;
export const FILTER_PIPELINES_SEARCH_DELAY = 200;
export const ANY_TRIGGER_AUTHOR = 'Any';
@@ -34,3 +33,5 @@ export const LOAD_FAILURE = 'load_failure';
export const PARSE_FAILURE = 'parse_failure';
export const POST_FAILURE = 'post_failure';
export const UNSUPPORTED_DATA = 'unsupported_data';
+
+export const CHILD_VIEW = 'child';
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index f837851e5c1..c3444f38ea0 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -93,8 +93,7 @@ export default async function initPipelineDetailsBundle() {
/* webpackChunkName: 'createPipelinesDetailApp' */ './pipeline_details_graph'
);
- const { pipelineProjectPath, pipelineIid } = dataset;
- createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, pipelineProjectPath, pipelineIid);
+ createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, dataset);
} catch {
Flash(__('An error occurred while loading the pipeline.'));
}
diff --git a/app/assets/javascripts/pipelines/pipeline_details_graph.js b/app/assets/javascripts/pipelines/pipeline_details_graph.js
index 55f3731a3ca..9eba39738dc 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_graph.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_graph.js
@@ -11,12 +11,15 @@ const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
{},
{
- batchMax: 2,
+ useGet: true,
},
),
});
-const createPipelinesDetailApp = (selector, pipelineProjectPath, pipelineIid) => {
+const createPipelinesDetailApp = (
+ selector,
+ { pipelineProjectPath, pipelineIid, metricsPath, graphqlResourceEtag } = {},
+) => {
// eslint-disable-next-line no-new
new Vue({
el: selector,
@@ -25,8 +28,10 @@ const createPipelinesDetailApp = (selector, pipelineProjectPath, pipelineIid) =>
},
apolloProvider,
provide: {
+ metricsPath,
pipelineProjectPath,
pipelineIid,
+ graphqlResourceEtag,
dataMethod: GRAPHQL,
},
errorCaptured(err, _vm, info) {
diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js
index 7bcc51e18e5..0e2e9785956 100644
--- a/app/assets/javascripts/pipelines/pipelines_index.js
+++ b/app/assets/javascripts/pipelines/pipelines_index.js
@@ -23,11 +23,9 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
const {
endpoint,
pipelineScheduleUrl,
- helpPagePath,
emptyStateSvgPath,
errorStateSvgPath,
noPipelinesSvgPath,
- autoDevopsHelpPath,
newPipelinePath,
canCreatePipeline,
hasGitlabCi,
@@ -56,11 +54,9 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
store: this.store,
endpoint,
pipelineScheduleUrl,
- helpPagePath,
emptyStateSvgPath,
errorStateSvgPath,
noPipelinesSvgPath,
- autoDevopsHelpPath,
newPipelinePath,
canCreatePipeline: parseBoolean(canCreatePipeline),
hasGitlabCi: parseBoolean(hasGitlabCi),
diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue
index c5478aa0226..f18c4d8f03e 100644
--- a/app/assets/javascripts/profile/account/components/update_username.vue
+++ b/app/assets/javascripts/profile/account/components/update_username.vue
@@ -90,7 +90,10 @@ Please update your Git repository remotes as soon as possible.`),
this.isRequestPending = false;
})
.catch((error) => {
- Flash(error.response.data.message);
+ Flash(
+ error?.response?.data?.message ||
+ s__('Profiles|An error occurred while updating your username, please try again.'),
+ );
this.isRequestPending = false;
throw error;
});
@@ -121,7 +124,8 @@ Please update your Git repository remotes as soon as possible.`),
</div>
<gl-button
v-gl-modal-directive="$options.modalId"
- :disabled="isRequestPending || newUsername === username"
+ :disabled="newUsername === username"
+ :loading="isRequestPending"
category="primary"
variant="warning"
data-testid="username-change-confirmation-modal"
diff --git a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
index 184ee3810ac..07d8f3cc5f1 100644
--- a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
+++ b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
@@ -44,6 +44,8 @@ export default {
data() {
return {
isSubmitEnabled: true,
+ darkModeOnCreate: null,
+ darkModeOnSubmit: null,
};
},
computed: {
@@ -58,6 +60,7 @@ export default {
this.formEl.addEventListener('ajax:beforeSend', this.handleLoading);
this.formEl.addEventListener('ajax:success', this.handleSuccess);
this.formEl.addEventListener('ajax:error', this.handleError);
+ this.darkModeOnCreate = this.darkModeSelected();
},
beforeDestroy() {
this.formEl.removeEventListener('ajax:beforeSend', this.handleLoading);
@@ -65,16 +68,27 @@ export default {
this.formEl.removeEventListener('ajax:error', this.handleError);
},
methods: {
+ darkModeSelected() {
+ const theme = this.getSelectedTheme();
+ return theme ? theme.css_class === 'gl-dark' : null;
+ },
+ getSelectedTheme() {
+ const themeId = new FormData(this.formEl).get('user[theme_id]');
+ return this.applicationThemes[themeId] ?? null;
+ },
handleLoading() {
this.isSubmitEnabled = false;
+ this.darkModeOnSubmit = this.darkModeSelected();
},
handleSuccess(customEvent) {
- const formData = new FormData(this.formEl);
- updateClasses(
- this.bodyClasses,
- this.applicationThemes[formData.get('user[theme_id]')].css_class,
- this.selectedLayout,
- );
+ // Reload the page if the theme has changed from light to dark mode or vice versa
+ // to correctly load all required styles.
+ const modeChanged = this.darkModeOnCreate ? !this.darkModeOnSubmit : this.darkModeOnSubmit;
+ if (modeChanged) {
+ window.location.reload();
+ return;
+ }
+ updateClasses(this.bodyClasses, this.getSelectedTheme().css_class, this.selectedLayout);
const { message = this.$options.i18n.defaultSuccess, type = FLASH_TYPES.NOTICE } =
customEvent?.detail?.[0] || {};
createFlash({ message, type });
diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js
index a7332b81b9f..dad2c18fb18 100644
--- a/app/assets/javascripts/profile/profile.js
+++ b/app/assets/javascripts/profile/profile.js
@@ -95,6 +95,7 @@ export default class Profile {
updateHeaderAvatar() {
$('.header-user-avatar').attr('src', this.avatarGlCrop.dataURL);
+ $('.js-sidebar-user-avatar').attr('src', this.avatarGlCrop.dataURL);
}
setRepoRadio() {
diff --git a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue
index 3527bcb04c6..cc5bc703994 100644
--- a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue
+++ b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue
@@ -7,7 +7,11 @@ import {
GlLoadingIcon,
} from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
-import { I18N_DROPDOWN } from '../constants';
+import {
+ I18N_NO_RESULTS_MESSAGE,
+ I18N_BRANCH_HEADER,
+ I18N_BRANCH_SEARCH_PLACEHOLDER,
+} from '../constants';
export default {
name: 'BranchesDropdown',
@@ -25,7 +29,11 @@ export default {
default: '',
},
},
- i18n: I18N_DROPDOWN,
+ i18n: {
+ noResultsMessage: I18N_NO_RESULTS_MESSAGE,
+ branchHeaderTitle: I18N_BRANCH_HEADER,
+ branchSearchPlaceholder: I18N_BRANCH_SEARCH_PLACEHOLDER,
+ },
data() {
return {
searchTerm: this.value,
@@ -41,6 +49,13 @@ export default {
);
},
},
+ watch: {
+ // Parent component can set the branch value (e.g. when the user selects a different project)
+ // and we need to keep the search term in sync with the selected value
+ value(val) {
+ this.searchTermChanged(val);
+ },
+ },
mounted() {
this.fetchBranches(this.searchTerm);
},
@@ -61,13 +76,13 @@ export default {
};
</script>
<template>
- <gl-dropdown :text="value" :header-text="$options.i18n.headerTitle">
+ <gl-dropdown :text="value" :header-text="$options.i18n.branchHeaderTitle">
<gl-search-box-by-type
:value="searchTerm"
trim
autocomplete="off"
:debounce="250"
- :placeholder="$options.i18n.searchPlaceholder"
+ :placeholder="$options.i18n.branchSearchPlaceholder"
data-testid="dropdown-search-box"
@input="searchTermChanged"
/>
diff --git a/app/assets/javascripts/projects/commit/components/form_modal.vue b/app/assets/javascripts/projects/commit/components/form_modal.vue
index ed216a91ca0..30968d29cde 100644
--- a/app/assets/javascripts/projects/commit/components/form_modal.vue
+++ b/app/assets/javascripts/projects/commit/components/form_modal.vue
@@ -3,18 +3,22 @@ import { GlModal, GlForm, GlFormCheckbox, GlSprintf, GlFormGroup } from '@gitlab
import { mapActions, mapState } from 'vuex';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import csrf from '~/lib/utils/csrf';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../event_hub';
import BranchesDropdown from './branches_dropdown.vue';
+import ProjectsDropdown from './projects_dropdown.vue';
export default {
components: {
BranchesDropdown,
+ ProjectsDropdown,
GlModal,
GlForm,
GlFormCheckbox,
GlSprintf,
GlFormGroup,
},
+ mixins: [glFeatureFlagsMixin()],
inject: {
prependedText: {
default: '',
@@ -60,13 +64,17 @@ export default {
'modalTitle',
'existingBranch',
'prependedText',
+ 'targetProjectId',
+ 'targetProjectName',
+ 'branchesEndpoint',
]),
},
mounted() {
+ this.setSelectedProject(this.targetProjectId);
eventHub.$on(this.openModal, this.show);
},
methods: {
- ...mapActions(['clearModal', 'setBranch', 'setSelectedBranch']),
+ ...mapActions(['clearModal', 'setBranch', 'setSelectedBranch', 'setSelectedProject']),
show() {
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
@@ -102,6 +110,26 @@ export default {
<input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
<gl-form-group
+ v-if="glFeatures.pickIntoProject"
+ :label="i18n.projectLabel"
+ label-for="start_project"
+ data-testid="dropdown-group"
+ >
+ <input
+ id="target_project_id"
+ type="hidden"
+ name="target_project_id"
+ :value="targetProjectId"
+ />
+
+ <projects-dropdown
+ class="gl-w-half"
+ :value="targetProjectName"
+ @selectProject="setSelectedProject"
+ />
+ </gl-form-group>
+
+ <gl-form-group
:label="i18n.branchLabel"
label-for="start_branch"
data-testid="dropdown-group"
diff --git a/app/assets/javascripts/projects/commit/components/projects_dropdown.vue b/app/assets/javascripts/projects/commit/components/projects_dropdown.vue
new file mode 100644
index 00000000000..6288bcdaad0
--- /dev/null
+++ b/app/assets/javascripts/projects/commit/components/projects_dropdown.vue
@@ -0,0 +1,87 @@
+<script>
+import { GlDropdown, GlSearchBoxByType, GlDropdownItem, GlDropdownText } from '@gitlab/ui';
+import { mapGetters, mapState } from 'vuex';
+import {
+ I18N_NO_RESULTS_MESSAGE,
+ I18N_PROJECT_HEADER,
+ I18N_PROJECT_SEARCH_PLACEHOLDER,
+} from '../constants';
+
+export default {
+ name: 'ProjectsDropdown',
+ components: {
+ GlDropdown,
+ GlSearchBoxByType,
+ GlDropdownItem,
+ GlDropdownText,
+ },
+ props: {
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ i18n: {
+ noResultsMessage: I18N_NO_RESULTS_MESSAGE,
+ projectHeaderTitle: I18N_PROJECT_HEADER,
+ projectSearchPlaceholder: I18N_PROJECT_SEARCH_PLACEHOLDER,
+ },
+ data() {
+ return {
+ filterTerm: this.value,
+ };
+ },
+ computed: {
+ ...mapGetters(['sortedProjects']),
+ ...mapState(['targetProjectId']),
+ filteredResults() {
+ const lowerCasedFilterTerm = this.filterTerm.toLowerCase();
+ return this.sortedProjects.filter((project) =>
+ project.name.toLowerCase().includes(lowerCasedFilterTerm),
+ );
+ },
+ selectedProject() {
+ return this.sortedProjects.find((project) => project.id === this.targetProjectId) || {};
+ },
+ },
+ methods: {
+ selectProject(project) {
+ this.$emit('selectProject', project.id);
+ this.filterTerm = project.name; // when we select a project, we want the dropdown to filter to the selected project
+ },
+ isSelected(selectedProject) {
+ return selectedProject === this.selectedProject;
+ },
+ filterTermChanged(value) {
+ this.filterTerm = value;
+ },
+ },
+};
+</script>
+<template>
+ <gl-dropdown :text="selectedProject.name" :header-text="$options.i18n.projectHeaderTitle">
+ <gl-search-box-by-type
+ :value="filterTerm"
+ trim
+ autocomplete="off"
+ :placeholder="$options.i18n.projectSearchPlaceholder"
+ data-testid="dropdown-search-box"
+ @input="filterTermChanged"
+ />
+ <gl-dropdown-item
+ v-for="project in filteredResults"
+ :key="project.name"
+ :name="project.name"
+ :is-checked="isSelected(project)"
+ is-check-item
+ data-testid="dropdown-item"
+ @click="selectProject(project)"
+ >
+ {{ project.name }}
+ </gl-dropdown-item>
+ <gl-dropdown-text v-if="!filteredResults.length" data-testid="empty-result-message">
+ <span class="gl-text-gray-500">{{ $options.i18n.noResultsMessage }}</span>
+ </gl-dropdown-text>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/projects/commit/constants.js b/app/assets/javascripts/projects/commit/constants.js
index b47c744e5fb..d6bb4e9483f 100644
--- a/app/assets/javascripts/projects/commit/constants.js
+++ b/app/assets/javascripts/projects/commit/constants.js
@@ -26,6 +26,7 @@ export const I18N_REVERT_MODAL = {
export const I18N_CHERRY_PICK_MODAL = {
branchLabel: s__('ChangeTypeAction|Pick into branch'),
+ projectLabel: s__('ChangeTypeAction|Pick into project'),
actionPrimaryText: s__('ChangeTypeAction|Cherry-pick'),
};
@@ -33,10 +34,12 @@ export const PREPENDED_MODAL_TEXT = s__(
'ChangeTypeAction|This will create a new commit in order to revert the existing changes.',
);
-export const I18N_DROPDOWN = {
- noResultsMessage: __('No matching results'),
- headerTitle: s__('ChangeTypeAction|Switch branch'),
- searchPlaceholder: s__('ChangeTypeAction|Search branches'),
-};
+export const I18N_NO_RESULTS_MESSAGE = __('No matching results');
+
+export const I18N_PROJECT_HEADER = s__('ChangeTypeAction|Switch project');
+export const I18N_PROJECT_SEARCH_PLACEHOLDER = s__('ChangeTypeAction|Search projects');
+
+export const I18N_BRANCH_HEADER = s__('ChangeTypeAction|Switch branch');
+export const I18N_BRANCH_SEARCH_PLACEHOLDER = s__('ChangeTypeAction|Search branches');
export const PROJECT_BRANCHES_ERROR = __('Something went wrong while fetching branches');
diff --git a/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js b/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js
index 24baa27ff70..ad31ad14b2a 100644
--- a/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js
+++ b/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import { parseBoolean } from '~/lib/utils/common_utils';
+import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import CommitFormModal from './components/form_modal.vue';
import {
I18N_MODAL,
@@ -19,21 +19,27 @@ export default function initInviteMembersModal() {
title,
endpoint,
branch,
+ targetProjectId,
+ targetProjectName,
pushCode,
branchCollaboration,
existingBranch,
branchesEndpoint,
+ projects,
} = el.dataset;
const store = createStore({
endpoint,
branchesEndpoint,
branch,
+ targetProjectId,
+ targetProjectName,
pushCode: parseBoolean(pushCode),
branchCollaboration: parseBoolean(branchCollaboration),
defaultBranch: branch,
modalTitle: title,
existingBranch,
+ projects: convertObjectPropsToCamelCase(JSON.parse(projects), { deep: true }),
});
return new Vue({
diff --git a/app/assets/javascripts/projects/commit/store/actions.js b/app/assets/javascripts/projects/commit/store/actions.js
index 10135e55351..c72704303ca 100644
--- a/app/assets/javascripts/projects/commit/store/actions.js
+++ b/app/assets/javascripts/projects/commit/store/actions.js
@@ -11,6 +11,10 @@ export const requestBranches = ({ commit }) => {
commit(types.REQUEST_BRANCHES);
};
+export const setBranchesEndpoint = ({ commit }, endpoint) => {
+ commit(types.SET_BRANCHES_ENDPOINT, endpoint);
+};
+
export const fetchBranches = ({ commit, dispatch, state }, query) => {
dispatch('requestBranches');
@@ -18,8 +22,8 @@ export const fetchBranches = ({ commit, dispatch, state }, query) => {
.get(state.branchesEndpoint, {
params: { search: query },
})
- .then((res) => {
- commit(types.RECEIVE_BRANCHES_SUCCESS, res.data);
+ .then(({ data }) => {
+ commit(types.RECEIVE_BRANCHES_SUCCESS, data.Branches || []);
})
.catch(() => {
createFlash({ message: PROJECT_BRANCHES_ERROR });
@@ -34,3 +38,15 @@ export const setBranch = ({ commit, dispatch }, branch) => {
export const setSelectedBranch = ({ commit }, branch) => {
commit(types.SET_SELECTED_BRANCH, branch);
};
+
+export const setSelectedProject = ({ commit, dispatch, state }, id) => {
+ let { branchesEndpoint } = state;
+
+ if (state.projects?.length) {
+ branchesEndpoint = state.projects.find((p) => p.id === id).refsUrl;
+ }
+
+ commit(types.SET_SELECTED_PROJECT, id);
+ dispatch('setBranchesEndpoint', branchesEndpoint);
+ dispatch('fetchBranches');
+};
diff --git a/app/assets/javascripts/projects/commit/store/getters.js b/app/assets/javascripts/projects/commit/store/getters.js
index 664eaca32cf..e0c36df8a75 100644
--- a/app/assets/javascripts/projects/commit/store/getters.js
+++ b/app/assets/javascripts/projects/commit/store/getters.js
@@ -3,3 +3,5 @@ import { uniq } from 'lodash';
export const joinedBranches = (state) => {
return uniq(state.branches).sort();
};
+
+export const sortedProjects = (state) => uniq(state.projects).sort();
diff --git a/app/assets/javascripts/projects/commit/store/mutation_types.js b/app/assets/javascripts/projects/commit/store/mutation_types.js
index de0bb47e18d..d0cf9f25ee9 100644
--- a/app/assets/javascripts/projects/commit/store/mutation_types.js
+++ b/app/assets/javascripts/projects/commit/store/mutation_types.js
@@ -1,6 +1,9 @@
export const CLEAR_MODAL = 'CLEAR_MODAL';
+export const SET_BRANCHES_ENDPOINT = 'SET_BRANCHES_ENDPOINT';
export const REQUEST_BRANCHES = 'REQUEST_BRANCHES';
export const RECEIVE_BRANCHES_SUCCESS = 'RECEIVE_BRANCHES_SUCCESS';
export const SET_BRANCH = 'SET_BRANCH';
export const SET_SELECTED_BRANCH = 'SET_SELECTED_BRANCH';
+
+export const SET_SELECTED_PROJECT = 'SET_SELECTED_PROJECT';
diff --git a/app/assets/javascripts/projects/commit/store/mutations.js b/app/assets/javascripts/projects/commit/store/mutations.js
index 6add00deadb..43f8f173014 100644
--- a/app/assets/javascripts/projects/commit/store/mutations.js
+++ b/app/assets/javascripts/projects/commit/store/mutations.js
@@ -1,6 +1,10 @@
import * as types from './mutation_types';
export default {
+ [types.SET_BRANCHES_ENDPOINT](state, endpoint) {
+ state.branchesEndpoint = endpoint;
+ },
+
[types.REQUEST_BRANCHES](state) {
state.isFetching = true;
},
@@ -22,4 +26,9 @@ export default {
[types.SET_SELECTED_BRANCH](state, branch) {
state.selectedBranch = branch;
},
+
+ [types.SET_SELECTED_PROJECT](state, projectId) {
+ state.targetProjectId = projectId;
+ state.branch = state.defaultBranch;
+ },
};
diff --git a/app/assets/javascripts/projects/commit/store/state.js b/app/assets/javascripts/projects/commit/store/state.js
index 78c294324df..660fd2ef17c 100644
--- a/app/assets/javascripts/projects/commit/store/state.js
+++ b/app/assets/javascripts/projects/commit/store/state.js
@@ -3,6 +3,7 @@ export default () => ({
branchesEndpoint: null,
isFetching: false,
branches: [],
+ projects: [],
selectedBranch: '',
pushCode: false,
branchCollaboration: false,
@@ -10,4 +11,6 @@ export default () => ({
existingBranch: '',
defaultBranch: '',
branch: '',
+ targetProjectId: '',
+ targetProjectName: '',
});
diff --git a/app/assets/javascripts/projects/commit_box/info/index.js b/app/assets/javascripts/projects/commit_box/info/index.js
index 4bbdb5c2357..17c63ecf66b 100644
--- a/app/assets/javascripts/projects/commit_box/info/index.js
+++ b/app/assets/javascripts/projects/commit_box/info/index.js
@@ -1,5 +1,6 @@
import { fetchCommitMergeRequests } from '~/commit_merge_requests';
import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown';
+import { initCommitPipelineMiniGraph } from './init_commit_pipeline_mini_graph';
import { initDetailsButton } from './init_details_button';
import { loadBranches } from './load_branches';
@@ -12,10 +13,15 @@ export const initCommitBoxInfo = (containerSelector = '.js-commit-box-info') =>
// Related merge requests to this commit
fetchCommitMergeRequests();
- // Display pipeline info for this commit
- new MiniPipelineGraph({
- container: '.js-commit-pipeline-graph',
- }).bindEvents();
+ // Display pipeline mini graph for this commit
+ // Feature flag ci_commit_pipeline_mini_graph_vue
+ if (gon.features.ciCommitPipelineMiniGraphVue) {
+ initCommitPipelineMiniGraph();
+ } else {
+ new MiniPipelineGraph({
+ container: '.js-commit-pipeline-graph',
+ }).bindEvents();
+ }
initDetailsButton();
};
diff --git a/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js b/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js
new file mode 100644
index 00000000000..9173f5c771f
--- /dev/null
+++ b/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js
@@ -0,0 +1,26 @@
+import Vue from 'vue';
+
+export const initCommitPipelineMiniGraph = async (selector = '.js-commit-pipeline-mini-graph') => {
+ const el = document.querySelector(selector);
+ if (!el) {
+ return;
+ }
+
+ // Some commits have no pipeline, code splitting to load the pipeline optionally
+ const { stages } = el.dataset;
+ const { default: PipelineMiniGraph } = await import(
+ /* webpackChunkName: 'pipelineMiniGraph' */ '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'
+ );
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ render(createElement) {
+ return createElement(PipelineMiniGraph, {
+ props: {
+ stages: JSON.parse(stages),
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js
index 72d4f0c31e5..741dc20b1f1 100644
--- a/app/assets/javascripts/projects/commits/store/actions.js
+++ b/app/assets/javascripts/projects/commits/store/actions.js
@@ -1,8 +1,8 @@
+import * as Sentry from '@sentry/browser';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-import * as Sentry from '~/sentry/wrapper';
import * as types from './mutation_types';
export default {
diff --git a/app/assets/javascripts/projects/compare/components/app.vue b/app/assets/javascripts/projects/compare/components/app.vue
index 05bd0f1370b..d2fb524489e 100644
--- a/app/assets/javascripts/projects/compare/components/app.vue
+++ b/app/assets/javascripts/projects/compare/components/app.vue
@@ -1,12 +1,12 @@
<script>
import { GlButton } from '@gitlab/ui';
import csrf from '~/lib/utils/csrf';
-import RevisionDropdown from './revision_dropdown.vue';
+import RevisionCard from './revision_card.vue';
export default {
csrf,
components: {
- RevisionDropdown,
+ RevisionCard,
GlButton,
},
props: {
@@ -48,42 +48,53 @@ export default {
<template>
<form
ref="form"
- class="form-inline js-requires-input js-signature-container"
+ class="js-requires-input js-signature-container"
method="POST"
:action="projectCompareIndexPath"
>
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
- <revision-dropdown
- :refs-project-path="refsProjectPath"
- revision-text="Source"
- params-name="to"
- :params-branch="paramsTo"
- />
- <div class="compare-ellipsis gl-display-inline" data-testid="ellipsis">...</div>
- <revision-dropdown
- :refs-project-path="refsProjectPath"
- revision-text="Target"
- params-name="from"
- :params-branch="paramsFrom"
- />
- <gl-button category="primary" variant="success" class="gl-ml-3" @click="onSubmit">
- {{ s__('CompareRevisions|Compare') }}
- </gl-button>
- <a
- v-if="projectMergeRequestPath"
- :href="projectMergeRequestPath"
- data-testid="projectMrButton"
- class="btn btn-default gl-button gl-ml-3"
+ <div
+ class="gl-lg-flex-direction-row gl-lg-display-flex gl-align-items-center compare-revision-cards"
>
- {{ s__('CompareRevisions|View open merge request') }}
- </a>
- <a
- v-else-if="createMrPath"
- :href="createMrPath"
- data-testid="createMrButton"
- class="btn btn-default gl-button gl-ml-3"
- >
- {{ s__('CompareRevisions|Create merge request') }}
- </a>
+ <revision-card
+ :refs-project-path="refsProjectPath"
+ revision-text="Source"
+ params-name="to"
+ :params-branch="paramsTo"
+ />
+ <div
+ class="compare-ellipsis gl-display-flex gl-justify-content-center gl-align-items-center gl-my-4 gl-md-my-0"
+ data-testid="ellipsis"
+ >
+ ...
+ </div>
+ <revision-card
+ :refs-project-path="refsProjectPath"
+ revision-text="Target"
+ params-name="from"
+ :params-branch="paramsFrom"
+ />
+ </div>
+ <div class="gl-mt-4">
+ <gl-button category="primary" variant="success" @click="onSubmit">
+ {{ s__('CompareRevisions|Compare') }}
+ </gl-button>
+ <gl-button
+ v-if="projectMergeRequestPath"
+ :href="projectMergeRequestPath"
+ data-testid="projectMrButton"
+ class="btn btn-default gl-button"
+ >
+ {{ s__('CompareRevisions|View open merge request') }}
+ </gl-button>
+ <gl-button
+ v-else-if="createMrPath"
+ :href="createMrPath"
+ data-testid="createMrButton"
+ class="btn btn-default gl-button"
+ >
+ {{ s__('CompareRevisions|Create merge request') }}
+ </gl-button>
+ </div>
</form>
</template>
diff --git a/app/assets/javascripts/projects/compare/components/app_legacy.vue b/app/assets/javascripts/projects/compare/components/app_legacy.vue
new file mode 100644
index 00000000000..c0ff58ee074
--- /dev/null
+++ b/app/assets/javascripts/projects/compare/components/app_legacy.vue
@@ -0,0 +1,89 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import csrf from '~/lib/utils/csrf';
+import RevisionDropdown from './revision_dropdown_legacy.vue';
+
+export default {
+ csrf,
+ components: {
+ RevisionDropdown,
+ GlButton,
+ },
+ props: {
+ projectCompareIndexPath: {
+ type: String,
+ required: true,
+ },
+ refsProjectPath: {
+ type: String,
+ required: true,
+ },
+ paramsFrom: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ paramsTo: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ projectMergeRequestPath: {
+ type: String,
+ required: true,
+ },
+ createMrPath: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ onSubmit() {
+ this.$refs.form.submit();
+ },
+ },
+};
+</script>
+
+<template>
+ <form
+ ref="form"
+ class="form-inline js-requires-input js-signature-container"
+ method="POST"
+ :action="projectCompareIndexPath"
+ >
+ <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+ <revision-dropdown
+ :refs-project-path="refsProjectPath"
+ revision-text="Source"
+ params-name="to"
+ :params-branch="paramsTo"
+ />
+ <div class="compare-ellipsis gl-display-inline" data-testid="ellipsis">...</div>
+ <revision-dropdown
+ :refs-project-path="refsProjectPath"
+ revision-text="Target"
+ params-name="from"
+ :params-branch="paramsFrom"
+ />
+ <gl-button category="primary" variant="success" class="gl-ml-3" @click="onSubmit">
+ {{ s__('CompareRevisions|Compare') }}
+ </gl-button>
+ <gl-button
+ v-if="projectMergeRequestPath"
+ :href="projectMergeRequestPath"
+ data-testid="projectMrButton"
+ class="btn btn-default gl-button gl-ml-3"
+ >
+ {{ s__('CompareRevisions|View open merge request') }}
+ </gl-button>
+ <gl-button
+ v-else-if="createMrPath"
+ :href="createMrPath"
+ data-testid="createMrButton"
+ class="btn btn-default gl-button gl-ml-3"
+ >
+ {{ s__('CompareRevisions|Create merge request') }}
+ </gl-button>
+ </form>
+</template>
diff --git a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue
new file mode 100644
index 00000000000..822dfc09d81
--- /dev/null
+++ b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue
@@ -0,0 +1,93 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
+
+const SOURCE_PARAM_NAME = 'to';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ },
+ inject: ['projectTo', 'projectsFrom'],
+ props: {
+ paramsName: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ searchTerm: '',
+ selectedRepo: {},
+ };
+ },
+ computed: {
+ filteredRepos() {
+ const lowerCaseSearchTerm = this.searchTerm.toLowerCase();
+
+ return this?.projectsFrom.filter(({ name }) =>
+ name.toLowerCase().includes(lowerCaseSearchTerm),
+ );
+ },
+ isSourceRevision() {
+ return this.paramsName === SOURCE_PARAM_NAME;
+ },
+ inputName() {
+ return `${this.paramsName}_project_id`;
+ },
+ },
+ mounted() {
+ this.setDefaultRepo();
+ },
+ methods: {
+ onClick(repo) {
+ this.selectedRepo = repo;
+ this.emitTargetProject(repo.name);
+ },
+ setDefaultRepo() {
+ if (this.isSourceRevision) {
+ this.selectedRepo = this.projectTo;
+ return;
+ }
+
+ const [defaultTargetProject] = this.projectsFrom;
+ this.emitTargetProject(defaultTargetProject.name);
+ this.selectedRepo = defaultTargetProject;
+ },
+ emitTargetProject(name) {
+ if (!this.isSourceRevision) {
+ this.$emit('changeTargetProject', name);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <input type="hidden" :name="inputName" :value="selectedRepo.id" />
+ <gl-dropdown
+ :text="selectedRepo.name"
+ :header-text="s__(`CompareRevisions|Select target project`)"
+ class="gl-w-full gl-font-monospace gl-sm-pr-3"
+ toggle-class="gl-min-w-0"
+ :disabled="isSourceRevision"
+ >
+ <template #header>
+ <gl-search-box-by-type v-if="!isSourceRevision" v-model.trim="searchTerm" />
+ </template>
+ <template v-if="!isSourceRevision">
+ <gl-dropdown-item
+ v-for="repo in filteredRepos"
+ :key="repo.id"
+ is-check-item
+ :is-checked="selectedRepo.id === repo.id"
+ @click="onClick(repo)"
+ >
+ {{ repo.name }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/compare/components/revision_card.vue b/app/assets/javascripts/projects/compare/components/revision_card.vue
new file mode 100644
index 00000000000..15d24792310
--- /dev/null
+++ b/app/assets/javascripts/projects/compare/components/revision_card.vue
@@ -0,0 +1,65 @@
+<script>
+import { GlCard } from '@gitlab/ui';
+import RepoDropdown from './repo_dropdown.vue';
+import RevisionDropdown from './revision_dropdown.vue';
+
+export default {
+ components: {
+ RepoDropdown,
+ RevisionDropdown,
+ GlCard,
+ },
+ props: {
+ refsProjectPath: {
+ type: String,
+ required: true,
+ },
+ revisionText: {
+ type: String,
+ required: true,
+ },
+ paramsName: {
+ type: String,
+ required: true,
+ },
+ paramsBranch: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ selectedRefsProjectPath: this.refsProjectPath,
+ };
+ },
+ methods: {
+ onChangeTargetProject(targetProjectName) {
+ if (this.paramsName === 'from') {
+ this.selectedRefsProjectPath = `/${targetProjectName}/refs`;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-card header-class="gl-py-2 gl-px-3 gl-font-weight-bold" body-class="gl-px-3">
+ <template #header>
+ {{ s__(`CompareRevisions|${revisionText}`) }}
+ </template>
+ <div class="gl-sm-display-flex gl-align-items-center">
+ <repo-dropdown
+ class="gl-sm-w-half"
+ :params-name="paramsName"
+ @changeTargetProject="onChangeTargetProject"
+ />
+ <revision-dropdown
+ class="gl-sm-w-half gl-mt-3 gl-sm-mt-0"
+ :refs-project-path="selectedRefsProjectPath"
+ :params-name="paramsName"
+ :params-branch="paramsBranch"
+ />
+ </div>
+ </gl-card>
+</template>
diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue
index 13d80b5ae0b..a175af2f32e 100644
--- a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue
+++ b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue
@@ -4,6 +4,8 @@ import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
+const emptyDropdownText = s__('CompareRevisions|Select branch/tag');
+
export default {
components: {
GlDropdown,
@@ -16,10 +18,6 @@ export default {
type: String,
required: true,
},
- revisionText: {
- type: String,
- required: true,
- },
paramsName: {
type: String,
required: true,
@@ -55,12 +53,24 @@ export default {
return this.filteredTags.length;
},
},
+ watch: {
+ refsProjectPath(newRefsProjectPath, oldRefsProjectPath) {
+ if (newRefsProjectPath !== oldRefsProjectPath) {
+ this.fetchBranchesAndTags(true);
+ }
+ },
+ },
mounted() {
this.fetchBranchesAndTags();
},
methods: {
- fetchBranchesAndTags() {
+ fetchBranchesAndTags(reset = false) {
const endpoint = this.refsProjectPath;
+ this.loading = true;
+
+ if (reset) {
+ this.selectedRevision = this.getDefaultBranch();
+ }
return axios
.get(endpoint)
@@ -70,9 +80,9 @@ export default {
})
.catch(() => {
createFlash({
- message: `${s__(
- 'CompareRevisions|There was an error while updating the branch/tag list. Please try again.',
- )}`,
+ message: s__(
+ 'CompareRevisions|There was an error while loading the branch/tag list. Please try again.',
+ ),
});
})
.finally(() => {
@@ -80,7 +90,7 @@ export default {
});
},
getDefaultBranch() {
- return this.paramsBranch || s__('CompareRevisions|Select branch/tag');
+ return this.paramsBranch || emptyDropdownText;
},
onClick(revision) {
this.selectedRevision = revision;
@@ -93,53 +103,46 @@ export default {
</script>
<template>
- <div class="form-group compare-form-group" :class="`js-compare-${paramsName}-dropdown`">
- <div class="input-group inline-input-group">
- <span class="input-group-prepend">
- <div class="input-group-text">
- {{ revisionText }}
- </div>
- </span>
- <input type="hidden" :name="paramsName" :value="selectedRevision" />
- <gl-dropdown
- class="gl-flex-grow-1 gl-flex-basis-0 gl-min-w-0 gl-font-monospace"
- toggle-class="form-control compare-dropdown-toggle js-compare-dropdown gl-min-w-0 gl-rounded-top-left-none! gl-rounded-bottom-left-none!"
- :text="selectedRevision"
- header-text="Select Git revision"
- :loading="loading"
+ <div :class="`js-compare-${paramsName}-dropdown`">
+ <input type="hidden" :name="paramsName" :value="selectedRevision" />
+ <gl-dropdown
+ class="gl-w-full gl-font-monospace"
+ toggle-class="form-control compare-dropdown-toggle js-compare-dropdown gl-min-w-0"
+ :text="selectedRevision"
+ :header-text="s__('CompareRevisions|Select Git revision')"
+ :loading="loading"
+ >
+ <template #header>
+ <gl-search-box-by-type
+ v-model.trim="searchTerm"
+ :placeholder="s__('CompareRevisions|Filter by Git revision')"
+ @keyup.enter="onSearchEnter"
+ />
+ </template>
+ <gl-dropdown-section-header v-if="hasFilteredBranches">
+ {{ s__('CompareRevisions|Branches') }}
+ </gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="(branch, index) in filteredBranches"
+ :key="`branch${index}`"
+ is-check-item
+ :is-checked="selectedRevision === branch"
+ @click="onClick(branch)"
+ >
+ {{ branch }}
+ </gl-dropdown-item>
+ <gl-dropdown-section-header v-if="hasFilteredTags">
+ {{ s__('CompareRevisions|Tags') }}
+ </gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="(tag, index) in filteredTags"
+ :key="`tag${index}`"
+ is-check-item
+ :is-checked="selectedRevision === tag"
+ @click="onClick(tag)"
>
- <template #header>
- <gl-search-box-by-type
- v-model.trim="searchTerm"
- :placeholder="s__('CompareRevisions|Filter by Git revision')"
- @keyup.enter="onSearchEnter"
- />
- </template>
- <gl-dropdown-section-header v-if="hasFilteredBranches">
- {{ s__('CompareRevisions|Branches') }}
- </gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="(branch, index) in filteredBranches"
- :key="`branch${index}`"
- is-check-item
- :is-checked="selectedRevision === branch"
- @click="onClick(branch)"
- >
- {{ branch }}
- </gl-dropdown-item>
- <gl-dropdown-section-header v-if="hasFilteredTags">
- {{ s__('CompareRevisions|Tags') }}
- </gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="(tag, index) in filteredTags"
- :key="`tag${index}`"
- is-check-item
- :is-checked="selectedRevision === tag"
- @click="onClick(tag)"
- >
- {{ tag }}
- </gl-dropdown-item>
- </gl-dropdown>
- </div>
+ {{ tag }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
</template>
diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue
new file mode 100644
index 00000000000..13d80b5ae0b
--- /dev/null
+++ b/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue
@@ -0,0 +1,145 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlDropdownSectionHeader } from '@gitlab/ui';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { s__ } from '~/locale';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlSearchBoxByType,
+ },
+ props: {
+ refsProjectPath: {
+ type: String,
+ required: true,
+ },
+ revisionText: {
+ type: String,
+ required: true,
+ },
+ paramsName: {
+ type: String,
+ required: true,
+ },
+ paramsBranch: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ branches: [],
+ tags: [],
+ loading: true,
+ searchTerm: '',
+ selectedRevision: this.getDefaultBranch(),
+ };
+ },
+ computed: {
+ filteredBranches() {
+ return this.branches.filter((branch) =>
+ branch.toLowerCase().includes(this.searchTerm.toLowerCase()),
+ );
+ },
+ hasFilteredBranches() {
+ return this.filteredBranches.length;
+ },
+ filteredTags() {
+ return this.tags.filter((tag) => tag.toLowerCase().includes(this.searchTerm.toLowerCase()));
+ },
+ hasFilteredTags() {
+ return this.filteredTags.length;
+ },
+ },
+ mounted() {
+ this.fetchBranchesAndTags();
+ },
+ methods: {
+ fetchBranchesAndTags() {
+ const endpoint = this.refsProjectPath;
+
+ return axios
+ .get(endpoint)
+ .then(({ data }) => {
+ this.branches = data.Branches || [];
+ this.tags = data.Tags || [];
+ })
+ .catch(() => {
+ createFlash({
+ message: `${s__(
+ 'CompareRevisions|There was an error while updating the branch/tag list. Please try again.',
+ )}`,
+ });
+ })
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ getDefaultBranch() {
+ return this.paramsBranch || s__('CompareRevisions|Select branch/tag');
+ },
+ onClick(revision) {
+ this.selectedRevision = revision;
+ },
+ onSearchEnter() {
+ this.selectedRevision = this.searchTerm;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="form-group compare-form-group" :class="`js-compare-${paramsName}-dropdown`">
+ <div class="input-group inline-input-group">
+ <span class="input-group-prepend">
+ <div class="input-group-text">
+ {{ revisionText }}
+ </div>
+ </span>
+ <input type="hidden" :name="paramsName" :value="selectedRevision" />
+ <gl-dropdown
+ class="gl-flex-grow-1 gl-flex-basis-0 gl-min-w-0 gl-font-monospace"
+ toggle-class="form-control compare-dropdown-toggle js-compare-dropdown gl-min-w-0 gl-rounded-top-left-none! gl-rounded-bottom-left-none!"
+ :text="selectedRevision"
+ header-text="Select Git revision"
+ :loading="loading"
+ >
+ <template #header>
+ <gl-search-box-by-type
+ v-model.trim="searchTerm"
+ :placeholder="s__('CompareRevisions|Filter by Git revision')"
+ @keyup.enter="onSearchEnter"
+ />
+ </template>
+ <gl-dropdown-section-header v-if="hasFilteredBranches">
+ {{ s__('CompareRevisions|Branches') }}
+ </gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="(branch, index) in filteredBranches"
+ :key="`branch${index}`"
+ is-check-item
+ :is-checked="selectedRevision === branch"
+ @click="onClick(branch)"
+ >
+ {{ branch }}
+ </gl-dropdown-item>
+ <gl-dropdown-section-header v-if="hasFilteredTags">
+ {{ s__('CompareRevisions|Tags') }}
+ </gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="(tag, index) in filteredTags"
+ :key="`tag${index}`"
+ is-check-item
+ :is-checked="selectedRevision === tag"
+ @click="onClick(tag)"
+ >
+ {{ tag }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/compare/index.js b/app/assets/javascripts/projects/compare/index.js
index 4337eecb667..4ba4e308cd4 100644
--- a/app/assets/javascripts/projects/compare/index.js
+++ b/app/assets/javascripts/projects/compare/index.js
@@ -1,8 +1,46 @@
import Vue from 'vue';
import CompareApp from './components/app.vue';
+import CompareAppLegacy from './components/app_legacy.vue';
export default function init() {
const el = document.getElementById('js-compare-selector');
+
+ if (gon.features?.compareRepoDropdown) {
+ const {
+ refsProjectPath,
+ paramsFrom,
+ paramsTo,
+ projectCompareIndexPath,
+ projectMergeRequestPath,
+ createMrPath,
+ projectTo,
+ projectsFrom,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ components: {
+ CompareApp,
+ },
+ provide: {
+ projectTo: JSON.parse(projectTo),
+ projectsFrom: JSON.parse(projectsFrom),
+ },
+ render(createElement) {
+ return createElement(CompareApp, {
+ props: {
+ refsProjectPath,
+ paramsFrom,
+ paramsTo,
+ projectCompareIndexPath,
+ projectMergeRequestPath,
+ createMrPath,
+ },
+ });
+ },
+ });
+ }
+
const {
refsProjectPath,
paramsFrom,
@@ -15,10 +53,10 @@ export default function init() {
return new Vue({
el,
components: {
- CompareApp,
+ CompareAppLegacy,
},
render(createElement) {
- return createElement(CompareApp, {
+ return createElement(CompareAppLegacy, {
props: {
refsProjectPath,
paramsFrom,
diff --git a/app/assets/javascripts/projects/details/upload_button.vue b/app/assets/javascripts/projects/details/upload_button.vue
new file mode 100644
index 00000000000..5b19f15c233
--- /dev/null
+++ b/app/assets/javascripts/projects/details/upload_button.vue
@@ -0,0 +1,59 @@
+<script>
+import { GlButton, GlModalDirective } from '@gitlab/ui';
+import UploadBlobModal from '~/repository/components/upload_blob_modal.vue';
+import { trackFileUploadEvent } from '../upload_file_experiment_tracking';
+
+const UPLOAD_BLOB_MODAL_ID = 'details-modal-upload-blob';
+
+export default {
+ components: {
+ GlButton,
+ UploadBlobModal,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ inject: {
+ targetBranch: {
+ default: '',
+ },
+ originalBranch: {
+ default: '',
+ },
+ canPushCode: {
+ default: false,
+ },
+ path: {
+ default: '',
+ },
+ projectPath: {
+ default: '',
+ },
+ },
+ methods: {
+ trackOpenModal() {
+ trackFileUploadEvent('click_upload_modal_trigger');
+ },
+ },
+ uploadBlobModalId: UPLOAD_BLOB_MODAL_ID,
+};
+</script>
+<template>
+ <span>
+ <gl-button
+ v-gl-modal="$options.uploadBlobModalId"
+ icon="upload"
+ data-testid="upload-file-button"
+ @click="trackOpenModal"
+ >{{ __('Upload File') }}</gl-button
+ >
+ <upload-blob-modal
+ :modal-id="$options.uploadBlobModalId"
+ :commit-message="__('Upload New File')"
+ :target-branch="targetBranch"
+ :original-branch="originalBranch"
+ :can-push-code="canPushCode"
+ :path="path"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/new_project_push_tip_popover.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/new_project_push_tip_popover.vue
new file mode 100644
index 00000000000..e42d9154866
--- /dev/null
+++ b/app/assets/javascripts/projects/experiment_new_project_creation/components/new_project_push_tip_popover.vue
@@ -0,0 +1,66 @@
+<script>
+import { GlPopover, GlFormInputGroup } from '@gitlab/ui';
+import { __ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+
+export default {
+ components: {
+ GlPopover,
+ GlFormInputGroup,
+ ClipboardButton,
+ },
+ inject: ['pushToCreateProjectCommand', 'workingWithProjectsHelpPath'],
+ props: {
+ target: {
+ type: [Function, HTMLElement],
+ required: true,
+ },
+ },
+ i18n: {
+ clipboardButtonTitle: __('Copy command'),
+ commandInputAriaLabel: __('Push project from command line'),
+ helpLinkText: __('What does this command do?'),
+ labelText: __('Private projects can be created in your personal namespace with:'),
+ popoverTitle: __('Push to create a project'),
+ },
+};
+</script>
+<template>
+ <gl-popover
+ :target="target"
+ :title="$options.i18n.popoverTitle"
+ triggers="click blur"
+ placement="top"
+ >
+ <p>
+ <label for="push-to-create-tip" class="gl-font-weight-normal">
+ {{ $options.i18n.labelText }}
+ </label>
+ </p>
+ <p>
+ <gl-form-input-group
+ id="push-to-create-tip"
+ :value="pushToCreateProjectCommand"
+ readonly
+ select-on-click
+ :aria-label="$options.i18n.commandInputAriaLabel"
+ >
+ <template #append>
+ <clipboard-button
+ :text="pushToCreateProjectCommand"
+ :title="$options.i18n.clipboardButtonTitle"
+ tooltip-placement="right"
+ />
+ </template>
+ </gl-form-input-group>
+ </p>
+ <p>
+ <a
+ :href="`${workingWithProjectsHelpPath}#push-to-create-a-new-project`"
+ class="gl-font-sm"
+ target="_blank"
+ >{{ $options.i18n.helpLinkText }}</a
+ >
+ </p>
+ </gl-popover>
+</template>
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue
index 63a65975fff..ed82a635b1f 100644
--- a/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue
+++ b/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue
@@ -1,15 +1,13 @@
<script>
/* eslint-disable vue/no-v-html */
-import { GlPopover } from '@gitlab/ui';
import Tracking from '~/tracking';
-import LegacyContainer from './legacy_container.vue';
+import NewProjectPushTipPopover from './new_project_push_tip_popover.vue';
const trackingMixin = Tracking.mixin(gon.tracking_data);
export default {
components: {
- GlPopover,
- LegacyContainer,
+ NewProjectPushTipPopover,
},
mixins: [trackingMixin],
props: {
@@ -52,19 +50,15 @@ export default {
<p>
{{ __('You can also create a project from the command line.') }}
<a
- id="cli-tip"
+ ref="clipTip"
href="#"
click.prevent
class="push-new-project-tip"
- data-title="Push to create a project"
rel="noopener noreferrer"
>
{{ __('Show command') }}
</a>
-
- <gl-popover target="cli-tip" triggers="click blur" placement="top">
- <legacy-container selector=".push-new-project-tip-template" />
- </gl-popover>
+ <new-project-push-tip-popover :target="() => $refs.clipTip" />
</p>
</div>
</div>
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/index.js b/app/assets/javascripts/projects/experiment_new_project_creation/index.js
index 0414f7ef6a5..ea686d4e1e8 100644
--- a/app/assets/javascripts/projects/experiment_new_project_creation/index.js
+++ b/app/assets/javascripts/projects/experiment_new_project_creation/index.js
@@ -2,11 +2,17 @@ import Vue from 'vue';
import NewProjectCreationApp from './components/app.vue';
export default function initNewProjectCreation(el, props) {
+ const { pushToCreateProjectCommand, workingWithProjectsHelpPath } = el.dataset;
+
return new Vue({
el,
components: {
NewProjectCreationApp,
},
+ provide: {
+ workingWithProjectsHelpPath,
+ pushToCreateProjectCommand,
+ },
render(h) {
return h(NewProjectCreationApp, { props });
},
diff --git a/app/assets/javascripts/projects/feature_flags_user_lists/show/index.js b/app/assets/javascripts/projects/feature_flags_user_lists/show/index.js
new file mode 100644
index 00000000000..2bd3e57322d
--- /dev/null
+++ b/app/assets/javascripts/projects/feature_flags_user_lists/show/index.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import UserList from '~/user_lists/components/user_list.vue';
+import createStore from '~/user_lists/store/show';
+
+Vue.use(Vuex);
+
+export default function featureFlagsUserListInit() {
+ const el = document.getElementById('js-edit-user-list');
+
+ if (!el) {
+ return null;
+ }
+
+ return new Vue({
+ el,
+ store: createStore(el.dataset),
+ render(h) {
+ const { emptyStatePath } = el.dataset;
+ return h(UserList, { props: { emptyStatePath } });
+ },
+ });
+}
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue
index 733f833d51a..6a963616224 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue
@@ -252,10 +252,10 @@ export default {
},
errorTexts: {
[LOAD_ANALYTICS_FAILURE]: s__(
- 'PipelineCharts|An error has ocurred when retrieving the analytics data',
+ 'PipelineCharts|An error has occurred when retrieving the analytics data',
),
[LOAD_PIPELINES_FAILURE]: s__(
- 'PipelineCharts|An error has ocurred when retrieving the pipelines data',
+ 'PipelineCharts|An error has occurred when retrieving the pipelines data',
),
[PARSE_FAILURE]: s__('PipelineCharts|There was an error parsing the data for the charts.'),
[DEFAULT]: s__('PipelineCharts|An unknown error occurred while processing CI/CD analytics.'),
@@ -292,7 +292,7 @@ export default {
failure.text
}}</gl-alert>
<div class="gl-mb-3">
- <h3>{{ s__('PipelineCharts|CI / CD Analytics') }}</h3>
+ <h3>{{ s__('PipelineCharts|CI/CD Analytics') }}</h3>
</div>
<h4 class="gl-my-4">{{ s__('PipelineCharts|Overall statistics') }}</h4>
<div class="row">
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index e3ba84102a8..04ea6f760f6 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -1,6 +1,5 @@
import $ from 'jquery';
import DEFAULT_PROJECT_TEMPLATES from 'ee_else_ce/projects/default_project_templates';
-import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils';
import {
convertToTitleCase,
humanize,
@@ -81,7 +80,6 @@ const bindEvents = () => {
const $selectedTemplateText = $('.selected-template');
const $changeTemplateBtn = $('.change-template');
const $selectedIcon = $('.selected-icon');
- const $pushNewProjectTipTrigger = $('.push-new-project-tip');
const $projectTemplateButtons = $('.project-templates-buttons');
const $projectName = $('.tab-pane.active #project_name');
@@ -108,39 +106,6 @@ const bindEvents = () => {
);
});
- if ($pushNewProjectTipTrigger) {
- $pushNewProjectTipTrigger
- .removeAttr('rel')
- .removeAttr('target')
- .on('click', (e) => {
- e.preventDefault();
- })
- .popover({
- title: $pushNewProjectTipTrigger.data('title'),
- placement: 'bottom',
- html: true,
- content: $('.push-new-project-tip-template').html(),
- })
- .on('shown.bs.popover', () => {
- $(document).on('click.popover touchstart.popover', (event) => {
- if ($(event.target).closest('.popover').length === 0) {
- $pushNewProjectTipTrigger.trigger('click');
- }
- });
-
- const target = $(`#${$pushNewProjectTipTrigger.attr('aria-describedby')}`).find(
- '.js-select-on-focus',
- );
- addSelectOnFocusBehaviour(target);
-
- target.focus();
- })
- .on('hide.bs.popover', () => {
- // eslint-disable-next-line @gitlab/no-global-event-off
- $(document).off('click.popover touchstart.popover');
- });
- }
-
function chooseTemplate() {
$projectTemplateButtons.addClass('hidden');
$projectFieldsForm.addClass('selected');
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
index 9b3c0dd2755..fb00f58abae 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
@@ -95,7 +95,7 @@ export default {
})
.catch((err) => {
this.showAlert(
- sprintf(__('An error occured while saving changes: %{error}'), {
+ sprintf(__('An error occurred while saving changes: %{error}'), {
error: err?.response?.data?.message,
}),
);
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
index 39d9a6a4239..3294a37c26a 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
@@ -4,6 +4,9 @@ import { __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
export default {
+ i18n: {
+ toggleLabel: __('Activate Service Desk'),
+ },
components: {
ClipboardButton,
GlButton,
@@ -99,11 +102,12 @@ export default {
id="service-desk-checkbox"
:value="isEnabled"
class="d-inline-block align-middle mr-1"
- label-position="left"
+ :label="$options.i18n.toggleLabel"
+ label-position="hidden"
@change="onCheckboxToggle"
/>
- <label class="align-middle" for="service-desk-checkbox">
- {{ __('Activate Service Desk') }}
+ <label class="align-middle">
+ {{ $options.i18n.toggleLabel }}
</label>
<div v-if="isEnabled" class="row mt-3">
<div class="col-md-9 mb-0">
@@ -162,6 +166,7 @@ export default {
<gl-form-select
id="service-desk-template-select"
v-model="selectedTemplate"
+ data-qa-selector="service_desk_template_dropdown"
:options="templateOptions"
/>
<label for="service-desk-email-from-name" class="mt-3">
@@ -175,6 +180,7 @@ export default {
<gl-button
variant="success"
class="gl-mt-5"
+ data-qa-selector="save_service_desk_settings_button"
:disabled="isTemplateSaving"
@click="onSaveTemplate"
>
diff --git a/app/assets/javascripts/projects/upload_file_experiment.js b/app/assets/javascripts/projects/upload_file_experiment.js
new file mode 100644
index 00000000000..a7519f2bce8
--- /dev/null
+++ b/app/assets/javascripts/projects/upload_file_experiment.js
@@ -0,0 +1,33 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import createRouter from '~/repository/router';
+import UploadButton from './details/upload_button.vue';
+
+export const initUploadFileTrigger = () => {
+ const uploadFileTriggerEl = document.querySelector('.js-upload-file-experiment-trigger');
+
+ if (!uploadFileTriggerEl) return false;
+
+ const {
+ targetBranch,
+ originalBranch,
+ canPushCode,
+ path,
+ projectPath,
+ } = uploadFileTriggerEl.dataset;
+
+ return new Vue({
+ el: uploadFileTriggerEl,
+ router: createRouter(projectPath, originalBranch),
+ provide: {
+ targetBranch,
+ originalBranch,
+ canPushCode: parseBoolean(canPushCode),
+ path,
+ projectPath,
+ },
+ render(h) {
+ return h(UploadButton);
+ },
+ });
+};
diff --git a/app/assets/javascripts/projects/upload_file_experiment_tracking.js b/app/assets/javascripts/projects/upload_file_experiment_tracking.js
new file mode 100644
index 00000000000..c5e93f19b32
--- /dev/null
+++ b/app/assets/javascripts/projects/upload_file_experiment_tracking.js
@@ -0,0 +1,9 @@
+import ExperimentTracking from '~/experimentation/experiment_tracking';
+
+export const trackFileUploadEvent = (eventName) => {
+ const isEmpty = Boolean(document.querySelector('.project-home-panel.empty-project'));
+ const property = isEmpty ? 'empty' : 'nonempty';
+ const label = 'blob-upload-modal';
+ const FileUploadTracking = new ExperimentTracking('empty_repo_upload', { label, property });
+ FileUploadTracking.event(eventName);
+};
diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js
index c9474f0516c..726ddba1014 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_create.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_create.js
@@ -15,17 +15,23 @@ export default class ProtectedBranchCreate {
this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
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));
if (this.hasLicense) {
this.$codeOwnerToggle.on('click', this.onCodeOwnerToggleClick.bind(this));
}
this.$form.on('submit', this.onFormSubmit.bind(this));
}
+ onForcePushToggleClick() {
+ this.$forcePushToggle.toggleClass('is-checked');
+ }
+
onCodeOwnerToggleClick() {
this.$codeOwnerToggle.toggleClass('is-checked');
}
@@ -86,6 +92,7 @@ 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'),
},
};
diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js
index 986abeecafa..bd2694e0cf7 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_edit.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js
@@ -14,6 +14,7 @@ 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(
@@ -28,11 +29,23 @@ export default class ProtectedBranchEdit {
}
bindEvents() {
+ this.$forcePushToggle.on('click', this.onForcePushToggleClick.bind(this));
if (this.hasLicense) {
this.$codeOwnerToggle.on('click', this.onCodeOwnerToggleClick.bind(this));
}
}
+ 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);
@@ -41,17 +54,15 @@ export default class ProtectedBranchEdit {
code_owner_approval_required: this.$codeOwnerToggle.hasClass('is-checked'),
};
- this.updateCodeOwnerApproval(formData);
+ this.updateProtectedBranch(formData, () => this.$codeOwnerToggle.prop('disabled', false));
}
- updateCodeOwnerApproval(formData) {
+ updateProtectedBranch(formData, callback) {
axios
.patch(this.$wrap.data('url'), {
protected_branch: formData,
})
- .then(() => {
- this.$codeOwnerToggle.prop('disabled', false);
- })
+ .then(callback)
.catch(() => {
flash(__('Failed to update branch!'));
});
diff --git a/app/assets/javascripts/ref/components/ref_results_section.vue b/app/assets/javascripts/ref/components/ref_results_section.vue
index 87ce4f1a49c..4fa2a92ff03 100644
--- a/app/assets/javascripts/ref/components/ref_results_section.vue
+++ b/app/assets/javascripts/ref/components/ref_results_section.vue
@@ -11,6 +11,12 @@ export default {
GlIcon,
},
props: {
+ showHeader: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+
sectionTitle: {
type: String,
required: true,
@@ -84,7 +90,7 @@ export default {
<template>
<div>
- <gl-dropdown-section-header>
+ <gl-dropdown-section-header v-if="showHeader">
<div class="gl-display-flex align-items-center" data-testid="section-header">
<span class="gl-mr-2 gl-mb-1">{{ sectionTitle }}</span>
<gl-badge variant="neutral">{{ totalCountText }}</gl-badge>
diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue
index 8f2805b36f6..82963fe98fd 100644
--- a/app/assets/javascripts/ref/components/ref_selector.vue
+++ b/app/assets/javascripts/ref/components/ref_selector.vue
@@ -2,32 +2,48 @@
import {
GlDropdown,
GlDropdownDivider,
- GlDropdownSectionHeader,
GlSearchBoxByType,
GlSprintf,
- GlIcon,
GlLoadingIcon,
} from '@gitlab/ui';
-import { debounce } from 'lodash';
+import { debounce, isArray } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
-import { SEARCH_DEBOUNCE_MS, DEFAULT_I18N } from '../constants';
+import {
+ ALL_REF_TYPES,
+ SEARCH_DEBOUNCE_MS,
+ DEFAULT_I18N,
+ REF_TYPE_BRANCHES,
+ REF_TYPE_TAGS,
+ REF_TYPE_COMMITS,
+} from '../constants';
import createStore from '../stores';
import RefResultsSection from './ref_results_section.vue';
export default {
name: 'RefSelector',
- store: createStore(),
components: {
GlDropdown,
GlDropdownDivider,
- GlDropdownSectionHeader,
GlSearchBoxByType,
GlSprintf,
- GlIcon,
GlLoadingIcon,
RefResultsSection,
},
props: {
+ enabledRefTypes: {
+ type: Array,
+ required: false,
+ default: () => ALL_REF_TYPES,
+ validator: (val) =>
+ // It has to be an arrray
+ isArray(val) &&
+ // with at least one item
+ val.length > 0 &&
+ // and only "REF_TYPE_BRANCHES", "REF_TYPE_TAGS", and "REF_TYPE_COMMITS" are allowed
+ val.every((item) => ALL_REF_TYPES.includes(item)) &&
+ // and no duplicates are allowed
+ val.length === new Set(val).size,
+ },
value: {
type: String,
required: false,
@@ -42,6 +58,13 @@ export default {
required: false,
default: () => ({}),
},
+
+ /** The validation state of this component. */
+ state: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -62,17 +85,45 @@ export default {
};
},
showBranchesSection() {
- return Boolean(this.matches.branches.totalCount > 0 || this.matches.branches.error);
+ return (
+ this.enabledRefTypes.includes(REF_TYPE_BRANCHES) &&
+ Boolean(this.matches.branches.totalCount > 0 || this.matches.branches.error)
+ );
},
showTagsSection() {
- return Boolean(this.matches.tags.totalCount > 0 || this.matches.tags.error);
+ return (
+ this.enabledRefTypes.includes(REF_TYPE_TAGS) &&
+ Boolean(this.matches.tags.totalCount > 0 || this.matches.tags.error)
+ );
},
showCommitsSection() {
- return Boolean(this.matches.commits.totalCount > 0 || this.matches.commits.error);
+ return (
+ this.enabledRefTypes.includes(REF_TYPE_COMMITS) &&
+ Boolean(this.matches.commits.totalCount > 0 || this.matches.commits.error)
+ );
},
showNoResults() {
return !this.showBranchesSection && !this.showTagsSection && !this.showCommitsSection;
},
+ showSectionHeaders() {
+ return this.enabledRefTypes.length > 1;
+ },
+ toggleButtonClass() {
+ return {
+ 'gl-inset-border-1-red-500!': !this.state,
+ 'gl-font-monospace': Boolean(this.selectedRef),
+ };
+ },
+ footerSlotProps() {
+ return {
+ isLoading: this.isLoading,
+ matches: this.matches,
+ query: this.lastQuery,
+ };
+ },
+ buttonText() {
+ return this.selectedRef || this.i18n.noRefSelected;
+ },
},
watch: {
// Keep the Vuex store synchronized if the parent
@@ -86,6 +137,14 @@ export default {
},
},
},
+ beforeCreate() {
+ // Setting the store here instead of using
+ // the built in `store` component option because
+ // we need each new `RefSelector` instance to
+ // create a new Vuex store instance.
+ // See https://github.com/vuejs/vuex/issues/414#issue-184491718.
+ this.$store = createStore();
+ },
created() {
// This method is defined here instead of in `methods`
// because we need to access the .cancel() method
@@ -93,20 +152,29 @@ export default {
// made inaccessible by Vue. More info:
// https://stackoverflow.com/a/52988020/1063392
this.debouncedSearch = debounce(function search() {
- this.search(this.query);
+ this.search();
}, SEARCH_DEBOUNCE_MS);
this.setProjectId(this.projectId);
- this.search(this.query);
+
+ this.$watch(
+ 'enabledRefTypes',
+ () => {
+ this.setEnabledRefTypes(this.enabledRefTypes);
+ this.search();
+ },
+ { immediate: true },
+ );
},
methods: {
- ...mapActions(['setProjectId', 'setSelectedRef', 'search']),
+ ...mapActions(['setEnabledRefTypes', 'setProjectId', 'setSelectedRef']),
+ ...mapActions({ storeSearch: 'search' }),
focusSearchBox() {
this.$refs.searchBox.$el.querySelector('input').focus();
},
onSearchBoxEnter() {
this.debouncedSearch.cancel();
- this.search(this.query);
+ this.search();
},
onSearchBoxInput() {
this.debouncedSearch();
@@ -115,97 +183,96 @@ export default {
this.setSelectedRef(ref);
this.$emit('input', this.selectedRef);
},
+ search() {
+ this.storeSearch(this.query);
+ },
},
};
</script>
<template>
- <gl-dropdown v-bind="$attrs" class="ref-selector" @shown="focusSearchBox">
- <template slot="button-content">
- <span class="gl-flex-grow-1 gl-ml-2 gl-text-gray-400" data-testid="button-content">
- <span v-if="selectedRef" class="gl-font-monospace">{{ selectedRef }}</span>
- <span v-else>{{ i18n.noRefSelected }}</span>
- </span>
- <gl-icon name="chevron-down" />
- </template>
-
- <div class="gl-display-flex gl-flex-direction-column ref-selector-dropdown-content">
- <gl-dropdown-section-header>
- <span class="gl-text-center gl-display-block">{{ i18n.dropdownHeader }}</span>
- </gl-dropdown-section-header>
-
- <gl-dropdown-divider />
-
+ <gl-dropdown
+ :header-text="i18n.dropdownHeader"
+ :toggle-class="toggleButtonClass"
+ :text="buttonText"
+ class="ref-selector"
+ v-bind="$attrs"
+ v-on="$listeners"
+ @shown="focusSearchBox"
+ >
+ <template #header>
<gl-search-box-by-type
ref="searchBox"
v-model.trim="query"
:placeholder="i18n.searchPlaceholder"
+ autocomplete="off"
@input="onSearchBoxInput"
@keydown.enter.prevent="onSearchBoxEnter"
/>
+ </template>
- <div class="gl-flex-grow-1 gl-overflow-y-auto">
- <gl-loading-icon v-if="isLoading" size="lg" class="gl-my-3" />
+ <gl-loading-icon v-if="isLoading" size="lg" class="gl-my-3" />
- <div
- v-else-if="showNoResults"
- class="gl-text-center gl-mx-3 gl-py-3"
- data-testid="no-results"
- >
- <gl-sprintf v-if="lastQuery" :message="i18n.noResultsWithQuery">
- <template #query>
- <b class="gl-word-break-all">{{ lastQuery }}</b>
- </template>
- </gl-sprintf>
+ <div v-else-if="showNoResults" class="gl-text-center gl-mx-3 gl-py-3" data-testid="no-results">
+ <gl-sprintf v-if="lastQuery" :message="i18n.noResultsWithQuery">
+ <template #query>
+ <b class="gl-word-break-all">{{ lastQuery }}</b>
+ </template>
+ </gl-sprintf>
- <span v-else>{{ i18n.noResults }}</span>
- </div>
+ <span v-else>{{ i18n.noResults }}</span>
+ </div>
- <template v-else>
- <template v-if="showBranchesSection">
- <ref-results-section
- :section-title="i18n.branches"
- :total-count="matches.branches.totalCount"
- :items="matches.branches.list"
- :selected-ref="selectedRef"
- :error="matches.branches.error"
- :error-message="i18n.branchesErrorMessage"
- data-testid="branches-section"
- @selected="selectRef($event)"
- />
+ <template v-else>
+ <template v-if="showBranchesSection">
+ <ref-results-section
+ :section-title="i18n.branches"
+ :total-count="matches.branches.totalCount"
+ :items="matches.branches.list"
+ :selected-ref="selectedRef"
+ :error="matches.branches.error"
+ :error-message="i18n.branchesErrorMessage"
+ :show-header="showSectionHeaders"
+ data-testid="branches-section"
+ @selected="selectRef($event)"
+ />
- <gl-dropdown-divider v-if="showTagsSection || showCommitsSection" />
- </template>
+ <gl-dropdown-divider v-if="showTagsSection || showCommitsSection" />
+ </template>
- <template v-if="showTagsSection">
- <ref-results-section
- :section-title="i18n.tags"
- :total-count="matches.tags.totalCount"
- :items="matches.tags.list"
- :selected-ref="selectedRef"
- :error="matches.tags.error"
- :error-message="i18n.tagsErrorMessage"
- data-testid="tags-section"
- @selected="selectRef($event)"
- />
+ <template v-if="showTagsSection">
+ <ref-results-section
+ :section-title="i18n.tags"
+ :total-count="matches.tags.totalCount"
+ :items="matches.tags.list"
+ :selected-ref="selectedRef"
+ :error="matches.tags.error"
+ :error-message="i18n.tagsErrorMessage"
+ :show-header="showSectionHeaders"
+ data-testid="tags-section"
+ @selected="selectRef($event)"
+ />
- <gl-dropdown-divider v-if="showCommitsSection" />
- </template>
+ <gl-dropdown-divider v-if="showCommitsSection" />
+ </template>
- <template v-if="showCommitsSection">
- <ref-results-section
- :section-title="i18n.commits"
- :total-count="matches.commits.totalCount"
- :items="matches.commits.list"
- :selected-ref="selectedRef"
- :error="matches.commits.error"
- :error-message="i18n.commitsErrorMessage"
- data-testid="commits-section"
- @selected="selectRef($event)"
- />
- </template>
- </template>
- </div>
- </div>
+ <template v-if="showCommitsSection">
+ <ref-results-section
+ :section-title="i18n.commits"
+ :total-count="matches.commits.totalCount"
+ :items="matches.commits.list"
+ :selected-ref="selectedRef"
+ :error="matches.commits.error"
+ :error-message="i18n.commitsErrorMessage"
+ :show-header="showSectionHeaders"
+ data-testid="commits-section"
+ @selected="selectRef($event)"
+ />
+ </template>
+ </template>
+
+ <template #footer>
+ <slot name="footer" v-bind="footerSlotProps"></slot>
+ </template>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/ref/constants.js b/app/assets/javascripts/ref/constants.js
index ca82b951377..44d0f50b832 100644
--- a/app/assets/javascripts/ref/constants.js
+++ b/app/assets/javascripts/ref/constants.js
@@ -1,5 +1,10 @@
import { __ } from '~/locale';
+export const REF_TYPE_BRANCHES = 'REF_TYPE_BRANCHES';
+export const REF_TYPE_TAGS = 'REF_TYPE_TAGS';
+export const REF_TYPE_COMMITS = 'REF_TYPE_COMMITS';
+export const ALL_REF_TYPES = Object.freeze([REF_TYPE_BRANCHES, REF_TYPE_TAGS, REF_TYPE_COMMITS]);
+
export const X_TOTAL_HEADER = 'x-total';
export const SEARCH_DEBOUNCE_MS = 250;
diff --git a/app/assets/javascripts/ref/stores/actions.js b/app/assets/javascripts/ref/stores/actions.js
index d9bdd64ace5..3832cc0c21d 100644
--- a/app/assets/javascripts/ref/stores/actions.js
+++ b/app/assets/javascripts/ref/stores/actions.js
@@ -1,17 +1,26 @@
import Api from '~/api';
+import { REF_TYPE_BRANCHES, REF_TYPE_TAGS, REF_TYPE_COMMITS } from '../constants';
import * as types from './mutation_types';
+export const setEnabledRefTypes = ({ commit }, refTypes) =>
+ commit(types.SET_ENABLED_REF_TYPES, refTypes);
+
export const setProjectId = ({ commit }, projectId) => commit(types.SET_PROJECT_ID, projectId);
export const setSelectedRef = ({ commit }, selectedRef) =>
commit(types.SET_SELECTED_REF, selectedRef);
-export const search = ({ dispatch, commit }, query) => {
+export const search = ({ state, dispatch, commit }, query) => {
commit(types.SET_QUERY, query);
- dispatch('searchBranches');
- dispatch('searchTags');
- dispatch('searchCommits');
+ const dispatchIfRefTypeEnabled = (refType, action) => {
+ if (state.enabledRefTypes.includes(refType)) {
+ dispatch(action);
+ }
+ };
+ dispatchIfRefTypeEnabled(REF_TYPE_BRANCHES, 'searchBranches');
+ dispatchIfRefTypeEnabled(REF_TYPE_TAGS, 'searchTags');
+ dispatchIfRefTypeEnabled(REF_TYPE_COMMITS, 'searchCommits');
};
export const searchBranches = ({ commit, state }) => {
diff --git a/app/assets/javascripts/ref/stores/mutation_types.js b/app/assets/javascripts/ref/stores/mutation_types.js
index 9f6195f5f3f..c26f4fa00c7 100644
--- a/app/assets/javascripts/ref/stores/mutation_types.js
+++ b/app/assets/javascripts/ref/stores/mutation_types.js
@@ -1,3 +1,5 @@
+export const SET_ENABLED_REF_TYPES = 'SET_ENABLED_REF_TYPES';
+
export const SET_PROJECT_ID = 'SET_PROJECT_ID';
export const SET_SELECTED_REF = 'SET_SELECTED_REF';
export const SET_QUERY = 'SET_QUERY';
diff --git a/app/assets/javascripts/ref/stores/mutations.js b/app/assets/javascripts/ref/stores/mutations.js
index 4dc73dabfe2..f91cbae8462 100644
--- a/app/assets/javascripts/ref/stores/mutations.js
+++ b/app/assets/javascripts/ref/stores/mutations.js
@@ -4,6 +4,9 @@ import { X_TOTAL_HEADER } from '../constants';
import * as types from './mutation_types';
export default {
+ [types.SET_ENABLED_REF_TYPES](state, refTypes) {
+ state.enabledRefTypes = refTypes;
+ },
[types.SET_PROJECT_ID](state, projectId) {
state.projectId = projectId;
},
diff --git a/app/assets/javascripts/ref/stores/state.js b/app/assets/javascripts/ref/stores/state.js
index 65b9d6449d7..3affa8f8d03 100644
--- a/app/assets/javascripts/ref/stores/state.js
+++ b/app/assets/javascripts/ref/stores/state.js
@@ -1,23 +1,18 @@
+const createRefTypeState = () => ({
+ list: [],
+ totalCount: 0,
+ error: null,
+});
+
export default () => ({
+ enabledRefTypes: [],
projectId: null,
query: '',
matches: {
- branches: {
- list: [],
- totalCount: 0,
- error: null,
- },
- tags: {
- list: [],
- totalCount: 0,
- error: null,
- },
- commits: {
- list: [],
- totalCount: 0,
- error: null,
- },
+ branches: createRefTypeState(),
+ tags: createRefTypeState(),
+ commits: createRefTypeState(),
},
selectedRef: null,
requestCount: 0,
diff --git a/app/assets/javascripts/registry/explorer/components/delete_button.vue b/app/assets/javascripts/registry/explorer/components/delete_button.vue
index ee856a3e546..e4a1a1a8266 100644
--- a/app/assets/javascripts/registry/explorer/components/delete_button.vue
+++ b/app/assets/javascripts/registry/explorer/components/delete_button.vue
@@ -48,6 +48,7 @@ export default {
:title="title"
:aria-label="title"
variant="danger"
+ category="secondary"
icon="remove"
@click="$emit('delete')"
/>
diff --git a/app/assets/javascripts/registry/explorer/components/delete_image.vue b/app/assets/javascripts/registry/explorer/components/delete_image.vue
index 22fe9fc1da6..a313854f5e4 100644
--- a/app/assets/javascripts/registry/explorer/components/delete_image.vue
+++ b/app/assets/javascripts/registry/explorer/components/delete_image.vue
@@ -29,7 +29,6 @@ export default {
});
const data = produce(sourceData, (draftState) => {
- // eslint-disable-next-line no-param-reassign
draftState.containerRepository.status =
destroyContainerRepository.containerRepository.status;
});
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue
index a4b4c08bc34..f46068acd68 100644
--- a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue
+++ b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue
@@ -1,11 +1,10 @@
<script>
-import { GlSprintf, GlButton } from '@gitlab/ui';
+import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { sprintf, n__ } 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 {
- DETAILS_PAGE_TITLE,
UPDATED_AT,
CLEANUP_UNSCHEDULED_TEXT,
CLEANUP_SCHEDULED_TEXT,
@@ -20,11 +19,16 @@ import {
UNSCHEDULED_STATUS,
SCHEDULED_STATUS,
ONGOING_STATUS,
+ ROOT_IMAGE_TEXT,
+ ROOT_IMAGE_TOOLTIP,
} from '../../constants/index';
export default {
name: 'DetailsHeader',
- components: { GlSprintf, GlButton, TitleArea, MetadataItem },
+ components: { GlButton, GlIcon, TitleArea, MetadataItem },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
mixins: [timeagoMixin],
props: {
image: {
@@ -73,9 +77,12 @@ export default {
deleteButtonDisabled() {
return this.disabled || !this.image.canDelete;
},
- },
- i18n: {
- DETAILS_PAGE_TITLE,
+ rootImageTooltip() {
+ return !this.image.name ? ROOT_IMAGE_TOOLTIP : '';
+ },
+ imageName() {
+ return this.image.name || ROOT_IMAGE_TEXT;
+ },
},
};
</script>
@@ -84,12 +91,15 @@ export default {
<title-area :metadata-loading="metadataLoading">
<template #title>
<span data-testid="title">
- <gl-sprintf :message="$options.i18n.DETAILS_PAGE_TITLE">
- <template #imageName>
- {{ image.name }}
- </template>
- </gl-sprintf>
+ {{ imageName }}
</span>
+ <gl-icon
+ v-if="rootImageTooltip"
+ v-gl-tooltip="rootImageTooltip"
+ class="gl-text-blue-600"
+ name="information-o"
+ :aria-label="rootImageTooltip"
+ />
</template>
<template #metadata-tags-count>
<metadata-item icon="tag" :text="tagCountText" data-testid="tags-count" />
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue
index c66f92bdd67..74027a376a7 100644
--- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue
+++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue
@@ -166,6 +166,7 @@ export default {
:title="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
:tooltip-title="$options.i18n.REMOVE_TAG_BUTTON_DISABLE_TOOLTIP"
:tooltip-disabled="tag.canDelete"
+ data-qa-selector="tag_delete_button"
data-testid="single-delete-button"
@delete="$emit('delete')"
/>
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue
index 10ad99d5956..5bd13322ebb 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue
@@ -37,7 +37,6 @@ export default {
v-for="(listItem, index) in images"
:key="index"
:item="listItem"
- :first="index === 0"
:metadata-loading="metadataLoading"
@delete="$emit('delete', $event)"
/>
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
index 9ae5b0f9eb1..0373a84b271 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
@@ -13,6 +13,7 @@ import {
CLEANUP_TIMED_OUT_ERROR_MESSAGE,
IMAGE_DELETE_SCHEDULED_STATUS,
IMAGE_FAILED_DELETED_STATUS,
+ ROOT_IMAGE_TEXT,
} from '../../constants/index';
import DeleteButton from '../delete_button.vue';
@@ -74,6 +75,9 @@ export default {
}
return null;
},
+ imageName() {
+ return this.item.name ? this.item.path : `${this.item.path}/ ${ROOT_IMAGE_TEXT}`;
+ },
},
};
</script>
@@ -92,9 +96,10 @@ export default {
<router-link
class="gl-text-body gl-font-weight-bold"
data-testid="details-link"
+ data-qa-selector="registry_image_content"
:to="{ name: 'details', params: { id } }"
>
- {{ item.path }}
+ {{ imageName }}
</router-link>
<clipboard-button
v-if="item.location"
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue
index 8d7505dfbae..6d2ff9ea7b6 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue
@@ -9,7 +9,6 @@ import {
LIST_INTRO_TEXT,
EXPIRATION_POLICY_WILL_RUN_IN,
EXPIRATION_POLICY_DISABLED_TEXT,
- EXPIRATION_POLICY_DISABLED_MESSAGE,
} from '../../constants/index';
export default {
@@ -34,11 +33,6 @@ export default {
default: '',
required: false,
},
- expirationPolicyHelpPagePath: {
- type: String,
- default: '',
- required: false,
- },
hideExpirationPolicyData: {
type: Boolean,
required: false,
@@ -79,19 +73,8 @@ export default {
? sprintf(EXPIRATION_POLICY_WILL_RUN_IN, { time: this.timeTillRun })
: EXPIRATION_POLICY_DISABLED_TEXT;
},
- showExpirationPolicyTip() {
- return (
- !this.expirationPolicyEnabled && this.imagesCount > 0 && !this.hideExpirationPolicyData
- );
- },
infoMessages() {
- const base = [{ text: LIST_INTRO_TEXT, link: this.helpPagePath }];
- return this.showExpirationPolicyTip
- ? [
- ...base,
- { text: EXPIRATION_POLICY_DISABLED_MESSAGE, link: this.expirationPolicyHelpPagePath },
- ]
- : base;
+ return [{ text: LIST_INTRO_TEXT, link: this.helpPagePath }];
},
},
};
diff --git a/app/assets/javascripts/registry/explorer/constants/common.js b/app/assets/javascripts/registry/explorer/constants/common.js
new file mode 100644
index 00000000000..dc71ef8450b
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/constants/common.js
@@ -0,0 +1,3 @@
+import { s__ } from '~/locale';
+
+export const ROOT_IMAGE_TEXT = s__('ContainerRegistry|Root image');
diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js
index 3f04538a18b..7220f9646db 100644
--- a/app/assets/javascripts/registry/explorer/constants/details.js
+++ b/app/assets/javascripts/registry/explorer/constants/details.js
@@ -2,7 +2,6 @@ import { helpPagePath } from '~/helpers/help_page_helper';
import { s__, __ } from '~/locale';
// Translations strings
-export const DETAILS_PAGE_TITLE = s__('ContainerRegistry|%{imageName} tags');
export const DELETE_TAG_ERROR_MESSAGE = s__(
'ContainerRegistry|Something went wrong while marking the tag for deletion.',
);
@@ -53,7 +52,8 @@ export const MISSING_OR_DELETED_IMAGE_TITLE = s__(
export const MISSING_OR_DELETED_IMAGE_MESSAGE = s__(
'ContainerRegistry|The requested image repository does not exist or has been deleted. If you think this is an error, try refreshing the page.',
);
-export const MISSING_OR_DELETE_IMAGE_BREADCRUMB = s__(
+
+export const MISSING_OR_DELETED_IMAGE_BREADCRUMB = s__(
'ContainerRegistry|Image repository not found',
);
@@ -112,6 +112,10 @@ export const FAILED_DELETION_STATUS_MESSAGE = s__(
'ContainerRegistry|This image repository has failed to be deleted',
);
+export const ROOT_IMAGE_TOOLTIP = s__(
+ 'ContainerRegistry|Image repository with no name located at the project URL.',
+);
+
// Parameters
export const DEFAULT_PAGE = 1;
diff --git a/app/assets/javascripts/registry/explorer/constants/expiration_policies.js b/app/assets/javascripts/registry/explorer/constants/expiration_policies.js
index 48a6a015461..40f9b09a982 100644
--- a/app/assets/javascripts/registry/explorer/constants/expiration_policies.js
+++ b/app/assets/javascripts/registry/explorer/constants/expiration_policies.js
@@ -6,9 +6,6 @@ export const EXPIRATION_POLICY_WILL_RUN_IN = s__(
export const EXPIRATION_POLICY_DISABLED_TEXT = s__(
'ContainerRegistry|Expiration policy is disabled',
);
-export const EXPIRATION_POLICY_DISABLED_MESSAGE = s__(
- 'ContainerRegistry|Expiration policies help manage the storage space used by the Container Registry, but the expiration policies for this registry are disabled. Contact your administrator to enable. %{docLinkStart}More information%{docLinkEnd}',
-);
export const DELETE_ALERT_TITLE = s__('ContainerRegistry|Some tags were not deleted');
export const DELETE_ALERT_LINK_TEXT = s__(
'ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}manually run cleanup now%{adminLinkEnd} or you can wait for the cleanup policy to automatically run again. %{docLinkStart}More information%{docLinkEnd}',
diff --git a/app/assets/javascripts/registry/explorer/constants/index.js b/app/assets/javascripts/registry/explorer/constants/index.js
index 10816e12ead..6886356d8e2 100644
--- a/app/assets/javascripts/registry/explorer/constants/index.js
+++ b/app/assets/javascripts/registry/explorer/constants/index.js
@@ -1,3 +1,4 @@
+export * from './common';
export * from './expiration_policies';
export * from './quick_start';
export * from './list';
diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue
index 0403467468a..2f515356fa7 100644
--- a/app/assets/javascripts/registry/explorer/pages/details.vue
+++ b/app/assets/javascripts/registry/explorer/pages/details.vue
@@ -24,7 +24,8 @@ import {
GRAPHQL_PAGE_SIZE,
FETCH_IMAGES_LIST_ERROR_MESSAGE,
UNFINISHED_STATUS,
- MISSING_OR_DELETE_IMAGE_BREADCRUMB,
+ MISSING_OR_DELETED_IMAGE_BREADCRUMB,
+ ROOT_IMAGE_TEXT,
} from '../constants/index';
import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.mutation.graphql';
import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql';
@@ -116,7 +117,9 @@ export default {
},
methods: {
updateBreadcrumb() {
- const name = this.image?.name || MISSING_OR_DELETE_IMAGE_BREADCRUMB;
+ const name = this.image?.id
+ ? this.image?.name || ROOT_IMAGE_TEXT
+ : MISSING_OR_DELETED_IMAGE_BREADCRUMB;
this.breadCrumbState.updateName(name);
},
deleteTags(toBeDeleted) {
diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue
index 8cad9b4ecfc..625d491db6a 100644
--- a/app/assets/javascripts/registry/explorer/pages/list.vue
+++ b/app/assets/javascripts/registry/explorer/pages/list.vue
@@ -288,7 +288,6 @@ export default {
:images-count="containerRepositoriesCount"
:expiration-policy="config.expirationPolicy"
:help-page-path="config.helpPagePath"
- :expiration-policy-help-page-path="config.expirationPolicyHelpPagePath"
:hide-expiration-policy-data="config.isGroupPage"
>
<template #commands>
diff --git a/app/assets/javascripts/registry/settings/components/expiration_toggle.vue b/app/assets/javascripts/registry/settings/components/expiration_toggle.vue
index 0ffd8216ab1..6aa78c69ba9 100644
--- a/app/assets/javascripts/registry/settings/components/expiration_toggle.vue
+++ b/app/assets/javascripts/registry/settings/components/expiration_toggle.vue
@@ -1,8 +1,12 @@
<script>
import { GlFormGroup, GlToggle, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
import { ENABLED_TOGGLE_DESCRIPTION, DISABLED_TOGGLE_DESCRIPTION } from '../constants';
export default {
+ i18n: {
+ toggleLabel: s__('ContainerRegistry|Enable expiration policy'),
+ },
components: {
GlFormGroup,
GlToggle,
@@ -39,7 +43,13 @@ export default {
<template>
<gl-form-group id="expiration-policy-toggle-group" label-for="expiration-policy-toggle">
<div class="gl-display-flex">
- <gl-toggle id="expiration-policy-toggle" v-model="enabled" :disabled="disabled" />
+ <gl-toggle
+ id="expiration-policy-toggle"
+ v-model="enabled"
+ :label="$options.i18n.toggleLabel"
+ label-position="hidden"
+ :disabled="disabled"
+ />
<span class="gl-ml-5 gl-line-height-24" data-testid="description">
<gl-sprintf :message="toggleText">
<template #strong="{ content }">
diff --git a/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js b/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js
index 6becaa38c7e..c4b2af13862 100644
--- a/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js
+++ b/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js
@@ -9,7 +9,6 @@ export const updateContainerExpirationPolicy = (projectPath) => (client, { data:
const sourceData = client.readQuery(queryAndParams);
const data = produce(sourceData, (draftState) => {
- // eslint-disable-next-line no-param-reassign
draftState.project.containerExpirationPolicy = {
...updatedData.updateContainerExpirationPolicy.containerExpirationPolicy,
};
diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
index 2dc56c3110b..46b97370d66 100644
--- a/app/assets/javascripts/related_issues/components/related_issuable_input.vue
+++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
@@ -223,6 +223,7 @@ export default {
type="text"
class="js-add-issuable-form-input add-issuable-form-input"
data-qa-selector="add_issue_field"
+ autocomplete="off"
@input="onInput"
@focus="onFocus"
@blur="onBlur"
diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue
index b16bb76c305..a8c7b7c857a 100644
--- a/app/assets/javascripts/releases/components/app_edit_new.vue
+++ b/app/assets/javascripts/releases/components/app_edit_new.vue
@@ -86,12 +86,11 @@ export default {
];
},
},
- mounted() {
- // eslint-disable-next-line promise/catch-or-return
- this.initializeRelease().then(() => {
- // Focus the first non-disabled input element
- this.$el.querySelector('input:enabled').focus();
- });
+ async mounted() {
+ await this.initializeRelease();
+
+ // Focus the first non-disabled input or button element
+ this.$el.querySelector('input:enabled, button:enabled').focus();
},
methods: {
...mapActions('detail', [
diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue
index 9e095c8a9c2..cfcb9f6978d 100644
--- a/app/assets/javascripts/releases/components/asset_links_form.vue
+++ b/app/assets/javascripts/releases/components/asset_links_form.vue
@@ -141,6 +141,7 @@ export default {
:value="link.url"
type="text"
class="form-control"
+ name="asset-url"
:state="isUrlValid(link)"
@change="updateUrl(link, $event)"
@keydown.ctrl.enter="updateUrl(link, $event.target.value)"
@@ -180,6 +181,7 @@ export default {
:value="link.name"
type="text"
class="form-control"
+ name="asset-link-name"
:state="isNameValid(link)"
@change="updateName(link, $event)"
@keydown.ctrl.enter="updateName(link, $event.target.value)"
@@ -202,6 +204,7 @@ export default {
ref="typeSelect"
:value="link.linkType || $options.defaultTypeOptionValue"
class="form-control pr-4"
+ name="asset-type"
:options="$options.typeOptions"
@change="updateAssetLinkType({ linkIdToUpdate: link.id, newType: $event })"
/>
diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue
index 660fd7ac950..21360a5c6cb 100644
--- a/app/assets/javascripts/releases/components/tag_field_new.vue
+++ b/app/assets/javascripts/releases/components/tag_field_new.vue
@@ -1,20 +1,29 @@
<script>
-import { GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { GlFormGroup, GlDropdownItem, GlSprintf } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { mapState, mapActions, mapGetters } from 'vuex';
import { __ } from '~/locale';
import RefSelector from '~/ref/components/ref_selector.vue';
+import { REF_TYPE_TAGS } from '~/ref/constants';
import FormFieldContainer from './form_field_container.vue';
export default {
name: 'TagFieldNew',
- components: { GlFormGroup, GlFormInput, RefSelector, FormFieldContainer },
+ components: {
+ GlFormGroup,
+ RefSelector,
+ FormFieldContainer,
+ GlDropdownItem,
+ GlSprintf,
+ },
data() {
return {
// Keeps track of whether or not the user has interacted with
// the input field. This is used to avoid showing validation
// errors immediately when the page loads.
isInputDirty: false,
+
+ showCreateFrom: true,
};
},
computed: {
@@ -26,6 +35,12 @@ export default {
},
set(tagName) {
this.updateReleaseTagName(tagName);
+
+ // This setter is used by the `v-model` on the `RefSelector`.
+ // When this is called, the selection originated from the
+ // dropdown list of existing tag names, so we know the tag
+ // already exists and don't need to show the "create from" input
+ this.showCreateFrom = false;
},
},
createFromModel: {
@@ -51,12 +66,28 @@ export default {
markInputAsDirty() {
this.isInputDirty = true;
},
+ createTagClicked(newTagName) {
+ this.updateReleaseTagName(newTagName);
+
+ // This method is called when the user selects the "create tag"
+ // option, so the tag does not already exist. Because of this,
+ // we need to show the "create from" input.
+ this.showCreateFrom = true;
+ },
},
translations: {
- noRefSelected: __('No source selected'),
- searchPlaceholder: __('Search branches, tags, and commits'),
- dropdownHeader: __('Select source'),
+ tagName: {
+ noRefSelected: __('No tag selected'),
+ dropdownHeader: __('Tag name'),
+ searchPlaceholder: __('Search or create tag'),
+ },
+ createFrom: {
+ noRefSelected: __('No source selected'),
+ searchPlaceholder: __('Search branches, tags, and commits'),
+ dropdownHeader: __('Select source'),
+ },
},
+ tagNameEnabledRefTypes: [REF_TYPE_TAGS],
};
</script>
<template>
@@ -69,17 +100,34 @@ export default {
:invalid-feedback="__('Tag name is required')"
>
<form-field-container>
- <gl-form-input
+ <ref-selector
:id="tagNameInputId"
v-model="tagName"
+ :project-id="projectId"
+ :translations="$options.translations.tagName"
+ :enabled-ref-types="$options.tagNameEnabledRefTypes"
:state="!showTagNameValidationError"
- type="text"
- class="form-control"
- @blur.once="markInputAsDirty"
- />
+ @hide.once="markInputAsDirty"
+ >
+ <template #footer="{ isLoading, matches, query }">
+ <gl-dropdown-item
+ v-if="!isLoading && matches && matches.tags.totalCount === 0"
+ is-check-item
+ :is-checked="tagName === query"
+ @click="createTagClicked(query)"
+ >
+ <gl-sprintf :message="__('Create tag %{tagName}')">
+ <template #tagName>
+ <b>{{ query }}</b>
+ </template>
+ </gl-sprintf>
+ </gl-dropdown-item>
+ </template>
+ </ref-selector>
</form-field-container>
</gl-form-group>
<gl-form-group
+ v-if="showCreateFrom"
:label="__('Create from')"
:label-for="createFromSelectorId"
data-testid="create-from-field"
@@ -89,7 +137,7 @@ export default {
:id="createFromSelectorId"
v-model="createFromModel"
:project-id="projectId"
- :translations="$options.translations"
+ :translations="$options.translations.createFrom"
/>
</form-field-container>
<template #description>
diff --git a/app/assets/javascripts/reports/components/grouped_issues_list.vue b/app/assets/javascripts/reports/components/grouped_issues_list.vue
index 4d3c5f48e94..585127f901e 100644
--- a/app/assets/javascripts/reports/components/grouped_issues_list.vue
+++ b/app/assets/javascripts/reports/components/grouped_issues_list.vue
@@ -14,6 +14,12 @@ export default {
required: false,
default: '',
},
+ nestedLevel: {
+ type: Number,
+ required: false,
+ default: 0,
+ validator: (value) => [0, 1, 2].includes(value),
+ },
resolvedIssues: {
type: Array,
required: false,
@@ -58,6 +64,12 @@ export default {
return groupsCount + issuesCount;
},
+ listClasses() {
+ return {
+ 'gl-pl-7': this.nestedLevel === 1,
+ 'gl-pl-9': this.nestedLevel === 2,
+ };
+ },
},
};
</script>
@@ -67,6 +79,7 @@ export default {
:length="listLength"
:remain="$options.maxShownReportItems"
:size="$options.typicalReportItemHeight"
+ :class="listClasses"
class="report-block-container"
wtag="ul"
wclass="report-block-list"
diff --git a/app/assets/javascripts/reports/components/issue_body.js b/app/assets/javascripts/reports/components/issue_body.js
index a0349506b69..56f46a3938e 100644
--- a/app/assets/javascripts/reports/components/issue_body.js
+++ b/app/assets/javascripts/reports/components/issue_body.js
@@ -1,6 +1,6 @@
import AccessibilityIssueBody from '../accessibility_report/components/accessibility_issue_body.vue';
import CodequalityIssueBody from '../codequality_report/components/codequality_issue_body.vue';
-import TestIssueBody from './test_issue_body.vue';
+import TestIssueBody from '../grouped_test_report/components/test_issue_body.vue';
export const components = {
AccessibilityIssueBody,
diff --git a/app/assets/javascripts/reports/components/issues_list.vue b/app/assets/javascripts/reports/components/issues_list.vue
index 16d5b14d3e9..ea3f0d78d8c 100644
--- a/app/assets/javascripts/reports/components/issues_list.vue
+++ b/app/assets/javascripts/reports/components/issues_list.vue
@@ -67,6 +67,12 @@ export default {
required: false,
default: null,
},
+ nestedLevel: {
+ type: Number,
+ required: false,
+ default: 0,
+ validator: (value) => [0, 1, 2].includes(value),
+ },
},
computed: {
issuesWithState() {
@@ -80,6 +86,12 @@ export default {
wclass() {
return `report-block-list ${this.issuesUlElementClass}`;
},
+ listClasses() {
+ return {
+ 'gl-pl-7': this.nestedLevel === 1,
+ 'gl-pl-8': this.nestedLevel === 2,
+ };
+ },
},
};
</script>
@@ -89,6 +101,7 @@ export default {
:remain="$options.maxShownReportItems"
:size="$options.typicalReportItemHeight"
class="report-block-container"
+ :class="listClasses"
wtag="ul"
:wclass="wclass"
>
diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue
index 9d0631fbc01..ff58cd20ca1 100644
--- a/app/assets/javascripts/reports/components/report_section.vue
+++ b/app/assets/javascripts/reports/components/report_section.vue
@@ -184,13 +184,14 @@ export default {
<slot name="sub-heading"></slot>
</div>
- <slot name="action-buttons"></slot>
+ <slot name="action-buttons" :is-collapsible="isCollapsible"></slot>
<button
v-if="isCollapsible"
type="button"
data-testid="report-section-expand-button"
- class="js-collapse-btn btn float-right btn-sm align-self-center qa-expand-report-button"
+ class="js-collapse-btn btn float-right btn-sm align-self-center"
+ data-qa-selector="expand_report_button"
@click="toggleCollapsed"
>
{{ collapseText }}
diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/reports/components/summary_row.vue
index 3232c0edf96..8eb43bcf1ba 100644
--- a/app/assets/javascripts/reports/components/summary_row.vue
+++ b/app/assets/javascripts/reports/components/summary_row.vue
@@ -2,6 +2,7 @@
import { GlLoadingIcon } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import Popover from '~/vue_shared/components/help_popover.vue';
+import { ICON_WARNING } from '../constants';
/**
* Renders the summary row for each report
@@ -19,6 +20,11 @@ export default {
GlLoadingIcon,
},
props: {
+ nestedSummary: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
summary: {
type: String,
required: false,
@@ -41,24 +47,36 @@ export default {
icon: `status_${this.statusIcon}`,
};
},
+ rowClasses() {
+ if (!this.nestedSummary) {
+ return ['gl-px-5'];
+ }
+ return ['gl-pl-7', 'gl-pr-5', { 'gl-bg-gray-10': this.statusIcon === ICON_WARNING }];
+ },
+ statusIconSize() {
+ if (!this.nestedSummary) {
+ return 24;
+ }
+ return 16;
+ },
},
};
</script>
<template>
- <div class="report-block-list-issue report-block-list-issue-parent align-items-center">
- <div class="report-block-list-icon gl-mr-3">
+ <div
+ class="gl-border-t-solid gl-border-t-gray-100 gl-border-t-1 gl-py-3 gl-display-flex gl-align-items-center"
+ :class="rowClasses"
+ >
+ <div class="gl-mr-3">
<gl-loading-icon
v-if="statusIcon === 'loading'"
css-class="report-block-list-loading-icon"
size="md"
/>
- <ci-icon v-else :status="iconStatus" :size="24" />
+ <ci-icon v-else :status="iconStatus" :size="statusIconSize" data-testid="summary-row-icon" />
</div>
<div class="report-block-list-issue-description">
- <div
- class="report-block-list-issue-description-text"
- data-testid="test-summary-row-description"
- >
+ <div class="report-block-list-issue-description-text" data-testid="summary-row-description">
<slot name="summary">{{ summary }}</slot
><span v-if="popoverOptions" class="text-nowrap"
>&nbsp;<popover v-if="popoverOptions" :options="popoverOptions" class="align-top" />
diff --git a/app/assets/javascripts/reports/components/test_issue_body.vue b/app/assets/javascripts/reports/components/test_issue_body.vue
deleted file mode 100644
index 7508e1d1f0d..00000000000
--- a/app/assets/javascripts/reports/components/test_issue_body.vue
+++ /dev/null
@@ -1,64 +0,0 @@
-<script>
-import { GlBadge, GlSprintf } from '@gitlab/ui';
-import { mapActions } from 'vuex';
-
-export default {
- name: 'TestIssueBody',
- components: {
- GlBadge,
- GlSprintf,
- },
- props: {
- issue: {
- type: Object,
- required: true,
- },
- // failed || success
- status: {
- type: String,
- required: true,
- },
- isNew: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- computed: {
- showRecentFailures() {
- return this.issue.recent_failures?.count && this.issue.recent_failures?.base_branch;
- },
- },
- methods: {
- ...mapActions(['openModal']),
- },
-};
-</script>
-<template>
- <div class="report-block-list-issue-description gl-mt-2 gl-mb-2">
- <div class="report-block-list-issue-description-text" data-testid="test-issue-body-description">
- <button
- type="button"
- class="btn-link btn-blank text-left break-link vulnerability-name-button"
- @click="openModal({ issue })"
- >
- <gl-badge v-if="isNew" variant="danger" class="gl-mr-2">{{ s__('New') }}</gl-badge>
- <gl-badge v-if="showRecentFailures" variant="warning" class="gl-mr-2">
- <gl-sprintf
- :message="
- n__(
- 'Reports|Failed %{count} time in %{base_branch} in the last 14 days',
- 'Reports|Failed %{count} times in %{base_branch} in the last 14 days',
- issue.recent_failures.count,
- )
- "
- >
- <template #count>{{ issue.recent_failures.count }}</template>
- <template #base_branch>{{ issue.recent_failures.base_branch }}</template>
- </gl-sprintf>
- </gl-badge>
- {{ issue.name }}
- </button>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/reports/components/modal.vue b/app/assets/javascripts/reports/grouped_test_report/components/modal.vue
index 6243bddf941..b0310fd003e 100644
--- a/app/assets/javascripts/reports/components/modal.vue
+++ b/app/assets/javascripts/reports/grouped_test_report/components/modal.vue
@@ -2,7 +2,7 @@
import { GlModal, GlLink, GlSprintf } from '@gitlab/ui';
import CodeBlock from '~/vue_shared/components/code_block.vue';
-import { fieldTypes } from '../constants';
+import { fieldTypes } from '../../constants';
export default {
components: {
diff --git a/app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue b/app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue
new file mode 100644
index 00000000000..522245a442d
--- /dev/null
+++ b/app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue
@@ -0,0 +1,64 @@
+<script>
+import { GlBadge, GlButton } from '@gitlab/ui';
+import { mapActions } from 'vuex';
+import { sprintf, n__ } from '~/locale';
+import IssueStatusIcon from '~/reports/components/issue_status_icon.vue';
+import { STATUS_NEUTRAL } from '../../constants';
+
+export default {
+ name: 'TestIssueBody',
+ components: {
+ GlBadge,
+ GlButton,
+ IssueStatusIcon,
+ },
+ props: {
+ issue: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ recentFailureMessage() {
+ return sprintf(
+ n__(
+ 'Reports|Failed %{count} time in %{base_branch} in the last 14 days',
+ 'Reports|Failed %{count} times in %{base_branch} in the last 14 days',
+ this.issue.recent_failures.count,
+ ),
+ this.issue.recent_failures,
+ );
+ },
+ showRecentFailures() {
+ return this.issue.recent_failures?.count && this.issue.recent_failures?.base_branch;
+ },
+ status() {
+ return this.issue.status || STATUS_NEUTRAL;
+ },
+ },
+ methods: {
+ ...mapActions(['openModal']),
+ },
+};
+</script>
+<template>
+ <div class="gl-display-flex gl-mt-2 gl-mb-2">
+ <issue-status-icon :status="status" :status-icon-size="24" class="gl-mr-3" />
+ <gl-badge
+ v-if="showRecentFailures"
+ variant="warning"
+ class="gl-mr-2"
+ data-testid="test-issue-body-recent-failures"
+ >
+ {{ recentFailureMessage }}
+ </gl-badge>
+ <gl-button
+ button-text-classes="gl-white-space-normal! gl-word-break-all gl-text-left"
+ variant="link"
+ data-testid="test-issue-body-description"
+ @click="openModal({ issue })"
+ >
+ {{ issue.name }}
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue
index 033b8798473..b863e55ae94 100644
--- a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
+++ b/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue
@@ -1,21 +1,21 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlIcon } from '@gitlab/ui';
import { once } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
import { sprintf, s__ } from '~/locale';
import Tracking from '~/tracking';
-import createStore from '../store';
+import GroupedIssuesList from '../components/grouped_issues_list.vue';
+import { componentNames } from '../components/issue_body';
+import ReportSection from '../components/report_section.vue';
+import SummaryRow from '../components/summary_row.vue';
+import Modal from './components/modal.vue';
+import createStore from './store';
import {
summaryTextBuilder,
reportTextBuilder,
statusIcon,
recentFailuresTextBuilder,
-} from '../store/utils';
-import { componentNames } from './issue_body';
-import IssuesList from './issues_list.vue';
-import Modal from './modal.vue';
-import ReportSection from './report_section.vue';
-import SummaryRow from './summary_row.vue';
+} from './store/utils';
export default {
name: 'GroupedTestReportsApp',
@@ -23,9 +23,10 @@ export default {
components: {
ReportSection,
SummaryRow,
- IssuesList,
+ GroupedIssuesList,
Modal,
GlButton,
+ GlIcon,
},
mixins: [Tracking.mixin()],
props: {
@@ -86,7 +87,7 @@ export default {
}
if (!report.name) {
- return s__('Reports|An error occured while loading report');
+ return s__('Reports|An error occurred while loading report');
}
return reportTextBuilder(name, summary);
@@ -111,10 +112,12 @@ export default {
);
},
unresolvedIssues(report) {
- return report.existing_failures.concat(report.existing_errors);
- },
- newIssues(report) {
- return report.new_failures.concat(report.new_errors);
+ return [
+ ...report.new_failures,
+ ...report.new_errors,
+ ...report.existing_failures,
+ ...report.existing_errors,
+ ];
},
resolvedIssues(report) {
return report.resolved_failures.concat(report.resolved_errors);
@@ -151,24 +154,39 @@ export default {
<template #body>
<div class="mr-widget-grouped-section report-block">
<template v-for="(report, i) in reports">
- <summary-row :key="`summary-row-${i}`" :status-icon="getReportIcon(report)">
+ <summary-row
+ :key="`summary-row-${i}`"
+ :status-icon="getReportIcon(report)"
+ nested-summary
+ >
<template #summary>
<div class="gl-display-inline-flex gl-flex-direction-column">
<div>{{ reportText(report) }}</div>
+ <div v-if="report.suite_errors">
+ <div v-if="report.suite_errors.head">
+ <gl-icon name="warning" class="gl-mx-2 gl-text-orange-500" />
+ {{ s__('Reports|Head report parsing error:') }}
+ {{ report.suite_errors.head }}
+ </div>
+ <div v-if="report.suite_errors.base">
+ <gl-icon name="warning" class="gl-mx-2 gl-text-orange-500" />
+ {{ s__('Reports|Base report parsing error:') }}
+ {{ report.suite_errors.base }}
+ </div>
+ </div>
<div v-if="hasRecentFailures(report.summary)">
{{ recentFailuresText(report.summary) }}
</div>
</div>
</template>
</summary-row>
- <issues-list
+ <grouped-issues-list
v-if="shouldRenderIssuesList(report)"
:key="`issues-list-${i}`"
:unresolved-issues="unresolvedIssues(report)"
- :new-issues="newIssues(report)"
:resolved-issues="resolvedIssues(report)"
:component="$options.componentNames.TestIssueBody"
- class="report-block-group-list"
+ :nested-level="2"
/>
</template>
<modal
diff --git a/app/assets/javascripts/reports/store/actions.js b/app/assets/javascripts/reports/grouped_test_report/store/actions.js
index c9b8ee64930..ebc8c735b03 100644
--- a/app/assets/javascripts/reports/store/actions.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/actions.js
@@ -1,7 +1,7 @@
import Visibility from 'visibilityjs';
-import axios from '../../lib/utils/axios_utils';
-import httpStatusCodes from '../../lib/utils/http_status';
-import Poll from '../../lib/utils/poll';
+import axios from '../../../lib/utils/axios_utils';
+import httpStatusCodes from '../../../lib/utils/http_status';
+import Poll from '../../../lib/utils/poll';
import * as types from './mutation_types';
export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint);
diff --git a/app/assets/javascripts/reports/store/getters.js b/app/assets/javascripts/reports/grouped_test_report/store/getters.js
index cc8c4cc446c..e7d1675f74a 100644
--- a/app/assets/javascripts/reports/store/getters.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/getters.js
@@ -1,4 +1,4 @@
-import { LOADING, ERROR, SUCCESS, STATUS_FAILED } from '../constants';
+import { LOADING, ERROR, SUCCESS, STATUS_FAILED } from '../../constants';
export const summaryStatus = (state) => {
if (state.isLoading) {
diff --git a/app/assets/javascripts/reports/store/index.js b/app/assets/javascripts/reports/grouped_test_report/store/index.js
index a2edfa94a48..a2edfa94a48 100644
--- a/app/assets/javascripts/reports/store/index.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/index.js
diff --git a/app/assets/javascripts/reports/store/mutation_types.js b/app/assets/javascripts/reports/grouped_test_report/store/mutation_types.js
index 337085f9bf0..337085f9bf0 100644
--- a/app/assets/javascripts/reports/store/mutation_types.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/mutation_types.js
diff --git a/app/assets/javascripts/reports/store/mutations.js b/app/assets/javascripts/reports/grouped_test_report/store/mutations.js
index 3bb31d71d8f..3bb31d71d8f 100644
--- a/app/assets/javascripts/reports/store/mutations.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/mutations.js
diff --git a/app/assets/javascripts/reports/store/state.js b/app/assets/javascripts/reports/grouped_test_report/store/state.js
index e8a0db2e1a8..dd55c7abab4 100644
--- a/app/assets/javascripts/reports/store/state.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/state.js
@@ -1,5 +1,5 @@
import { s__ } from '~/locale';
-import { fieldTypes } from '../constants';
+import { fieldTypes } from '../../constants';
export default () => ({
endpoint: null,
diff --git a/app/assets/javascripts/reports/store/utils.js b/app/assets/javascripts/reports/grouped_test_report/store/utils.js
index d89833032a0..189b87bfa8d 100644
--- a/app/assets/javascripts/reports/store/utils.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/utils.js
@@ -5,7 +5,7 @@ import {
ICON_WARNING,
ICON_SUCCESS,
ICON_NOTFOUND,
-} from '../constants';
+} from '../../constants';
const textBuilder = (results) => {
const { failed, errored, resolved, total } = results;
diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue
new file mode 100644
index 00000000000..ec7ba469ca0
--- /dev/null
+++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue
@@ -0,0 +1,220 @@
+<script>
+import {
+ GlModal,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlToggle,
+ GlButton,
+ GlAlert,
+} from '@gitlab/ui';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { ContentTypeMultipartFormData } from '~/lib/utils/headers';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import { visitUrl, joinPaths } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+import { trackFileUploadEvent } from '~/projects/upload_file_experiment_tracking';
+import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
+
+const PRIMARY_OPTIONS_TEXT = __('Upload file');
+const SECONDARY_OPTIONS_TEXT = __('Cancel');
+const MODAL_TITLE = __('Upload New File');
+const COMMIT_LABEL = __('Commit message');
+const TARGET_BRANCH_LABEL = __('Target branch');
+const TOGGLE_CREATE_MR_LABEL = __('Start a new merge request with these changes');
+const REMOVE_FILE_TEXT = __('Remove file');
+const NEW_BRANCH_IN_FORK = __(
+ 'A new branch will be created in your fork and a new merge request will be started.',
+);
+const ERROR_MESSAGE = __('Error uploading file. Please try again.');
+
+export default {
+ components: {
+ GlModal,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlToggle,
+ GlButton,
+ UploadDropzone,
+ GlAlert,
+ },
+ i18n: {
+ MODAL_TITLE,
+ COMMIT_LABEL,
+ TARGET_BRANCH_LABEL,
+ TOGGLE_CREATE_MR_LABEL,
+ REMOVE_FILE_TEXT,
+ NEW_BRANCH_IN_FORK,
+ },
+ props: {
+ modalId: {
+ type: String,
+ required: true,
+ },
+ commitMessage: {
+ type: String,
+ required: true,
+ },
+ targetBranch: {
+ type: String,
+ required: true,
+ },
+ originalBranch: {
+ type: String,
+ required: true,
+ },
+ canPushCode: {
+ type: Boolean,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ commit: this.commitMessage,
+ target: this.targetBranch,
+ createNewMr: true,
+ file: null,
+ filePreviewURL: null,
+ fileBinary: null,
+ loading: false,
+ };
+ },
+ computed: {
+ primaryOptions() {
+ return {
+ text: PRIMARY_OPTIONS_TEXT,
+ attributes: [
+ {
+ variant: 'success',
+ loading: this.loading,
+ disabled: !this.formCompleted || this.loading,
+ },
+ ],
+ };
+ },
+ cancelOptions() {
+ return {
+ text: SECONDARY_OPTIONS_TEXT,
+ attributes: [
+ {
+ disabled: this.loading,
+ },
+ ],
+ };
+ },
+ formattedFileSize() {
+ return numberToHumanSize(this.file.size);
+ },
+ showCreateNewMrToggle() {
+ return this.canPushCode && this.target !== this.originalBranch;
+ },
+ formCompleted() {
+ return this.file && this.commit && this.target;
+ },
+ },
+ methods: {
+ setFile(file) {
+ this.file = file;
+
+ const fileUurlReader = new FileReader();
+
+ fileUurlReader.readAsDataURL(this.file);
+
+ fileUurlReader.onload = (e) => {
+ this.filePreviewURL = e.target?.result;
+ };
+ },
+ removeFile() {
+ this.file = null;
+ this.filePreviewURL = null;
+ },
+ uploadFile() {
+ this.loading = true;
+
+ const {
+ $route: {
+ params: { path },
+ },
+ } = this;
+ const uploadPath = joinPaths(this.path, path);
+
+ const formData = new FormData();
+ formData.append('branch_name', this.target);
+ formData.append('create_merge_request', this.createNewMr);
+ formData.append('commit_message', this.commit);
+ formData.append('file', this.file);
+
+ return axios
+ .post(uploadPath, formData, {
+ headers: {
+ ...ContentTypeMultipartFormData,
+ },
+ })
+ .then((response) => {
+ trackFileUploadEvent('click_upload_modal_form_submit');
+ visitUrl(response.data.filePath);
+ })
+ .catch(() => {
+ this.loading = false;
+ createFlash(ERROR_MESSAGE);
+ });
+ },
+ },
+};
+</script>
+<template>
+ <gl-form>
+ <gl-modal
+ :modal-id="modalId"
+ :title="$options.i18n.MODAL_TITLE"
+ :action-primary="primaryOptions"
+ :action-cancel="cancelOptions"
+ @primary.prevent="uploadFile"
+ >
+ <upload-dropzone class="gl-h-200! gl-mb-4" single-file-selection @change="setFile">
+ <div
+ v-if="file"
+ class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3"
+ >
+ <img v-if="filePreviewURL" :src="filePreviewURL" class="gl-h-11" />
+ <div>{{ formattedFileSize }}</div>
+ <div>{{ file.name }}</div>
+ <gl-button
+ category="tertiary"
+ variant="confirm"
+ :disabled="loading"
+ @click="removeFile"
+ >{{ $options.i18n.REMOVE_FILE_TEXT }}</gl-button
+ >
+ </div>
+ </upload-dropzone>
+ <gl-form-group :label="$options.i18n.COMMIT_LABEL" label-for="commit_message">
+ <gl-form-textarea v-model="commit" name="commit_message" :disabled="loading" />
+ </gl-form-group>
+ <gl-form-group
+ v-if="canPushCode"
+ :label="$options.i18n.TARGET_BRANCH_LABEL"
+ label-for="branch_name"
+ >
+ <gl-form-input v-model="target" :disabled="loading" name="branch_name" />
+ </gl-form-group>
+ <gl-toggle
+ v-if="showCreateNewMrToggle"
+ v-model="createNewMr"
+ :disabled="loading"
+ :label="$options.i18n.TOGGLE_CREATE_MR_LABEL"
+ />
+ <gl-alert v-if="!canPushCode" variant="info" :dismissible="false" class="gl-mt-3">
+ {{ $options.i18n.NEW_BRANCH_IN_FORK }}
+ </gl-alert>
+ </gl-modal>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 747b85f5c1c..e6969b7c8b2 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -1,3 +1,4 @@
+import { GlButton } from '@gitlab/ui';
import Vue from 'vue';
import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
import { parseBoolean } from '../lib/utils/common_utils';
@@ -7,7 +8,6 @@ import App from './components/app.vue';
import Breadcrumbs from './components/breadcrumbs.vue';
import DirectoryDownloadLinks from './components/directory_download_links.vue';
import LastCommit from './components/last_commit.vue';
-import TreeActionLink from './components/tree_action_link.vue';
import apolloProvider from './graphql';
import createRouter from './router';
import { updateFormAction } from './utils/dom';
@@ -101,14 +101,17 @@ export default function setupVueRepositoryList() {
el: treeHistoryLinkEl,
router,
render(h) {
- return h(TreeActionLink, {
- props: {
- path: `${historyLink}/${
- this.$route.params.path ? escapeFileUrl(this.$route.params.path) : ''
- }`,
- text: __('History'),
+ return h(
+ GlButton,
+ {
+ attrs: {
+ href: `${historyLink}/${
+ this.$route.params.path ? escapeFileUrl(this.$route.params.path) : ''
+ }`,
+ },
},
- });
+ [__('History')],
+ );
},
});
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 52ff4e7b100..6cdd89ad431 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -26,6 +26,8 @@ Sidebar.prototype.removeListeners = function () {
// eslint-disable-next-line @gitlab/no-global-event-off
this.sidebar.off('hidden.gl.dropdown');
// eslint-disable-next-line @gitlab/no-global-event-off
+ this.sidebar.off('hiddenGlDropdown');
+ // eslint-disable-next-line @gitlab/no-global-event-off
$('.dropdown').off('loading.gl.dropdown');
// eslint-disable-next-line @gitlab/no-global-event-off
$('.dropdown').off('loaded.gl.dropdown');
@@ -37,6 +39,7 @@ Sidebar.prototype.addEventListeners = function () {
this.sidebar.on('click', '.sidebar-collapsed-icon', this, this.sidebarCollapseClicked);
this.sidebar.on('hidden.gl.dropdown', this, this.onSidebarDropdownHidden);
+ this.sidebar.on('hiddenGlDropdown', this, this.onSidebarDropdownHidden);
$document.on('click', '.js-sidebar-toggle', this.sidebarToggleClicked);
return $(document)
diff --git a/app/assets/javascripts/security_configuration/components/configuration_table.vue b/app/assets/javascripts/security_configuration/components/configuration_table.vue
index 9475cc1781f..4a3f988296c 100644
--- a/app/assets/javascripts/security_configuration/components/configuration_table.vue
+++ b/app/assets/javascripts/security_configuration/components/configuration_table.vue
@@ -1,16 +1,18 @@
<script>
-import { GlLink, GlSprintf, GlTable, GlAlert } from '@gitlab/ui';
+import { GlLink, GlTable, GlAlert } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import {
REPORT_TYPE_SAST,
REPORT_TYPE_DAST,
+ REPORT_TYPE_DAST_PROFILES,
REPORT_TYPE_DEPENDENCY_SCANNING,
REPORT_TYPE_CONTAINER_SCANNING,
REPORT_TYPE_COVERAGE_FUZZING,
+ REPORT_TYPE_API_FUZZING,
REPORT_TYPE_LICENSE_COMPLIANCE,
} from '~/vue_shared/security_reports/constants';
-import { features } from './features_constants';
import ManageSast from './manage_sast.vue';
+import { scanners } from './scanners_constants';
import Upgrade from './upgrade.vue';
const borderClasses = 'gl-border-b-1! gl-border-b-solid! gl-border-gray-100!';
@@ -19,14 +21,14 @@ const thClass = `gl-text-gray-900 gl-bg-transparent! ${borderClasses}`;
export default {
components: {
GlLink,
- GlSprintf,
GlTable,
GlAlert,
},
- data: () => ({
- features,
- errorMessage: '',
- }),
+ data() {
+ return {
+ errorMessage: '',
+ };
+ },
methods: {
getFeatureDocumentationLinkLabel(item) {
return sprintf(s__('SecurityConfiguration|Feature documentation for %{featureName}'), {
@@ -40,9 +42,11 @@ export default {
const COMPONENTS = {
[REPORT_TYPE_SAST]: ManageSast,
[REPORT_TYPE_DAST]: Upgrade,
+ [REPORT_TYPE_DAST_PROFILES]: Upgrade,
[REPORT_TYPE_DEPENDENCY_SCANNING]: Upgrade,
[REPORT_TYPE_CONTAINER_SCANNING]: Upgrade,
[REPORT_TYPE_COVERAGE_FUZZING]: Upgrade,
+ [REPORT_TYPE_API_FUZZING]: Upgrade,
[REPORT_TYPE_LICENSE_COMPLIANCE]: Upgrade,
};
@@ -62,7 +66,7 @@ export default {
thClass,
},
],
- items: features,
+ items: scanners,
},
};
</script>
@@ -81,7 +85,8 @@ export default {
{{ item.description }}
<gl-link
target="_blank"
- :href="item.link"
+ data-testid="help-link"
+ :href="item.helpPath"
:aria-label="getFeatureDocumentationLinkLabel(item)"
>
{{ s__('SecurityConfiguration|More information') }}
diff --git a/app/assets/javascripts/security_configuration/components/manage_sast.vue b/app/assets/javascripts/security_configuration/components/manage_sast.vue
index 5169096d563..a2528edd914 100644
--- a/app/assets/javascripts/security_configuration/components/manage_sast.vue
+++ b/app/assets/javascripts/security_configuration/components/manage_sast.vue
@@ -14,9 +14,11 @@ export default {
default: '',
},
},
- data: () => ({
- isLoading: false,
- }),
+ data() {
+ return {
+ isLoading: false,
+ };
+ },
methods: {
async mutate() {
this.isLoading = true;
diff --git a/app/assets/javascripts/security_configuration/components/features_constants.js b/app/assets/javascripts/security_configuration/components/scanners_constants.js
index d846a2761d9..9846df0b4bf 100644
--- a/app/assets/javascripts/security_configuration/components/features_constants.js
+++ b/app/assets/javascripts/security_configuration/components/scanners_constants.js
@@ -1,61 +1,73 @@
import { helpPagePath } from '~/helpers/help_page_helper';
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
import {
REPORT_TYPE_SAST,
REPORT_TYPE_DAST,
+ REPORT_TYPE_DAST_PROFILES,
REPORT_TYPE_SECRET_DETECTION,
REPORT_TYPE_DEPENDENCY_SCANNING,
REPORT_TYPE_CONTAINER_SCANNING,
REPORT_TYPE_COVERAGE_FUZZING,
+ REPORT_TYPE_API_FUZZING,
REPORT_TYPE_LICENSE_COMPLIANCE,
} from '~/vue_shared/security_reports/constants';
/**
* Translations & helpPagePaths for Static Security Configuration Page
*/
-export const SAST_NAME = s__('Static Application Security Testing (SAST)');
-export const SAST_DESCRIPTION = s__('Analyze your source code for known vulnerabilities.');
+export const SAST_NAME = __('Static Application Security Testing (SAST)');
+export const SAST_DESCRIPTION = __('Analyze your source code for known vulnerabilities.');
export const SAST_HELP_PATH = helpPagePath('user/application_security/sast/index');
-export const DAST_NAME = s__('Dynamic Application Security Testing (DAST)');
-export const DAST_DESCRIPTION = s__('Analyze a review version of your web application.');
+export const DAST_NAME = __('Dynamic Application Security Testing (DAST)');
+export const DAST_DESCRIPTION = __('Analyze a review version of your web application.');
export const DAST_HELP_PATH = helpPagePath('user/application_security/dast/index');
-export const SECRET_DETECTION_NAME = s__('Secret Detection');
-export const SECRET_DETECTION_DESCRIPTION = s__(
+export const DAST_PROFILES_NAME = __('DAST Scans');
+export const DAST_PROFILES_DESCRIPTION = __(
+ 'Saved scan settings and target site settings which are reusable.',
+);
+export const DAST_PROFILES_HELP_PATH = helpPagePath('user/application_security/dast/index');
+
+export const SECRET_DETECTION_NAME = __('Secret Detection');
+export const SECRET_DETECTION_DESCRIPTION = __(
'Analyze your source code and git history for secrets.',
);
export const SECRET_DETECTION_HELP_PATH = helpPagePath(
'user/application_security/secret_detection/index',
);
-export const DEPENDENCY_SCANNING_NAME = s__('Dependency Scanning');
-export const DEPENDENCY_SCANNING_DESCRIPTION = s__(
+export const DEPENDENCY_SCANNING_NAME = __('Dependency Scanning');
+export const DEPENDENCY_SCANNING_DESCRIPTION = __(
'Analyze your dependencies for known vulnerabilities.',
);
export const DEPENDENCY_SCANNING_HELP_PATH = helpPagePath(
'user/application_security/dependency_scanning/index',
);
-export const CONTAINER_SCANNING_NAME = s__('Container Scanning');
-export const CONTAINER_SCANNING_DESCRIPTION = s__(
+export const CONTAINER_SCANNING_NAME = __('Container Scanning');
+export const CONTAINER_SCANNING_DESCRIPTION = __(
'Check your Docker images for known vulnerabilities.',
);
export const CONTAINER_SCANNING_HELP_PATH = helpPagePath(
'user/application_security/container_scanning/index',
);
-export const COVERAGE_FUZZING_NAME = s__('Coverage Fuzzing');
-export const COVERAGE_FUZZING_DESCRIPTION = s__(
+export const COVERAGE_FUZZING_NAME = __('Coverage Fuzzing');
+export const COVERAGE_FUZZING_DESCRIPTION = __(
'Find bugs in your code with coverage-guided fuzzing.',
);
export const COVERAGE_FUZZING_HELP_PATH = helpPagePath(
'user/application_security/coverage_fuzzing/index',
);
-export const LICENSE_COMPLIANCE_NAME = s__('License Compliance');
-export const LICENSE_COMPLIANCE_DESCRIPTION = s__(
+export const API_FUZZING_NAME = __('API Fuzzing');
+export const API_FUZZING_DESCRIPTION = __('Find bugs in your code with API fuzzing.');
+export const API_FUZZING_HELP_PATH = helpPagePath('user/application_security/api_fuzzing/index');
+
+export const LICENSE_COMPLIANCE_NAME = __('License Compliance');
+export const LICENSE_COMPLIANCE_DESCRIPTION = __(
'Search your project dependencies for their licenses and apply policies.',
);
export const LICENSE_COMPLIANCE_HELP_PATH = helpPagePath(
@@ -66,7 +78,7 @@ export const UPGRADE_CTA = s__(
'SecurityConfiguration|Available with %{linkStart}upgrade or free trial%{linkEnd}',
);
-export const features = [
+export const scanners = [
{
name: SAST_NAME,
description: SAST_DESCRIPTION,
@@ -80,10 +92,10 @@ export const features = [
type: REPORT_TYPE_DAST,
},
{
- name: SECRET_DETECTION_NAME,
- description: SECRET_DETECTION_DESCRIPTION,
- helpPath: SECRET_DETECTION_HELP_PATH,
- type: REPORT_TYPE_SECRET_DETECTION,
+ name: DAST_PROFILES_NAME,
+ description: DAST_PROFILES_DESCRIPTION,
+ helpPath: DAST_PROFILES_HELP_PATH,
+ type: REPORT_TYPE_DAST_PROFILES,
},
{
name: DEPENDENCY_SCANNING_NAME,
@@ -98,12 +110,24 @@ export const features = [
type: REPORT_TYPE_CONTAINER_SCANNING,
},
{
+ name: SECRET_DETECTION_NAME,
+ description: SECRET_DETECTION_DESCRIPTION,
+ helpPath: SECRET_DETECTION_HELP_PATH,
+ type: REPORT_TYPE_SECRET_DETECTION,
+ },
+ {
name: COVERAGE_FUZZING_NAME,
description: COVERAGE_FUZZING_DESCRIPTION,
helpPath: COVERAGE_FUZZING_HELP_PATH,
type: REPORT_TYPE_COVERAGE_FUZZING,
},
{
+ name: API_FUZZING_NAME,
+ description: API_FUZZING_DESCRIPTION,
+ helpPath: API_FUZZING_HELP_PATH,
+ type: REPORT_TYPE_API_FUZZING,
+ },
+ {
name: LICENSE_COMPLIANCE_NAME,
description: LICENSE_COMPLIANCE_DESCRIPTION,
helpPath: LICENSE_COMPLIANCE_HELP_PATH,
diff --git a/app/assets/javascripts/security_configuration/components/upgrade.vue b/app/assets/javascripts/security_configuration/components/upgrade.vue
index 166ee4ff194..518eb57731d 100644
--- a/app/assets/javascripts/security_configuration/components/upgrade.vue
+++ b/app/assets/javascripts/security_configuration/components/upgrade.vue
@@ -1,12 +1,18 @@
<script>
import { GlLink, GlSprintf } from '@gitlab/ui';
-import { UPGRADE_CTA } from './features_constants';
+import { UPGRADE_CTA } from './scanners_constants';
export default {
components: {
GlLink,
GlSprintf,
},
+ inject: {
+ upgradePath: {
+ from: 'upgradePath',
+ default: '#',
+ },
+ },
i18n: {
UPGRADE_CTA,
},
@@ -17,7 +23,7 @@ export default {
<span>
<gl-sprintf :message="$options.i18n.UPGRADE_CTA">
<template #link="{ content }">
- <gl-link target="_blank" href="https://about.gitlab.com/pricing/">
+ <gl-link target="_blank" :href="upgradePath">
{{ content }}
</gl-link>
</template>
diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js
index c98fa46b32b..1134a1ffb44 100644
--- a/app/assets/javascripts/security_configuration/index.js
+++ b/app/assets/javascripts/security_configuration/index.js
@@ -14,13 +14,14 @@ export const initStaticSecurityConfiguration = (el) => {
defaultClient: createDefaultClient(),
});
- const { projectPath } = el.dataset;
+ const { projectPath, upgradePath } = el.dataset;
return new Vue({
el,
apolloProvider,
provide: {
projectPath,
+ upgradePath,
},
render(createElement) {
return createElement(SecurityConfigurationApp);
diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
index 9b7264e92ce..c608c71714b 100644
--- a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
+++ b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
@@ -132,11 +132,11 @@ export default {
<div class="settings-content">
<form name="self-monitoring-form">
<p ref="selfMonitoringFormText" v-html="selfMonitoringFormText"></p>
- <gl-form-group :label="$options.formLabels.createProject" label-for="self-monitor-toggle">
+ <gl-form-group>
<gl-toggle
v-model="selfMonitorEnabled"
:is-loading="loading"
- name="self-monitor-toggle"
+ :label="$options.formLabels.createProject"
/>
</gl-form-group>
</form>
diff --git a/app/assets/javascripts/sentry/sentry_config.js b/app/assets/javascripts/sentry/sentry_config.js
index 4277ffec545..bc3b2f16a6a 100644
--- a/app/assets/javascripts/sentry/sentry_config.js
+++ b/app/assets/javascripts/sentry/sentry_config.js
@@ -1,6 +1,6 @@
+import * as Sentry from '@sentry/browser';
import $ from 'jquery';
import { __ } from '~/locale';
-import * as Sentry from '~/sentry/wrapper';
const IGNORE_ERRORS = [
// Random plugins/extensions
diff --git a/app/assets/javascripts/sentry/wrapper.js b/app/assets/javascripts/sentry/wrapper.js
deleted file mode 100644
index 24039e6141c..00000000000
--- a/app/assets/javascripts/sentry/wrapper.js
+++ /dev/null
@@ -1,26 +0,0 @@
-// Temporarily commented out to investigate performance: https://gitlab.com/gitlab-org/gitlab/-/issues/251179
-// export * from '@sentry/browser';
-
-export function init(...args) {
- return args;
-}
-
-export function setUser(...args) {
- return args;
-}
-
-export function captureException(...args) {
- return args;
-}
-
-export function captureMessage(...args) {
- return args;
-}
-
-export function withScope(fn) {
- fn({
- setTag(...args) {
- return args;
- },
- });
-}
diff --git a/app/assets/javascripts/shared/popover.js b/app/assets/javascripts/shared/popover.js
deleted file mode 100644
index 435ee8fb968..00000000000
--- a/app/assets/javascripts/shared/popover.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import $ from 'jquery';
-import { debounce } from 'lodash';
-
-export function togglePopover(show) {
- const isAlreadyShown = this.hasClass('js-popover-show');
- if ((show && isAlreadyShown) || (!show && !isAlreadyShown)) {
- return false;
- }
- this.popover(show ? 'show' : 'hide');
- this.toggleClass('disable-animation js-popover-show', show);
-
- return true;
-}
-
-export function mouseleave() {
- if (!$('.popover:hover').length > 0) {
- const $popover = $(this);
- togglePopover.call($popover, false);
- }
-}
-
-export function mouseenter() {
- const $popover = $(this);
-
- const showedPopover = togglePopover.call($popover, true);
- if (showedPopover) {
- $('.popover').on('mouseleave', mouseleave.bind($popover));
- }
-}
-
-export function debouncedMouseleave(debounceTimeout = 300) {
- return debounce(mouseleave, debounceTimeout);
-}
diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
index e2dc37a0ac2..b53b7039018 100644
--- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
@@ -31,13 +31,18 @@ export default {
</script>
<template>
- <div class="gl-display-flex gl-flex-direction-column">
- <div v-if="emptyUsers" data-testid="none">
+ <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"
+ data-testid="none"
+ >
<span> {{ __('None') }} -</span>
<gl-button
data-testid="assign-yourself"
category="tertiary"
variant="link"
+ class="gl-ml-2"
@click="$emit('assign-self')"
>
<span class="gl-text-gray-500 gl-hover-text-blue-800">{{ __('assign yourself') }}</span>
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 8f3f77cb5f0..cc2201ad359 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
@@ -15,13 +15,12 @@ import { IssuableType } from '~/issue_show/constants';
import { __, n__ } from '~/locale';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
-import { assigneesQueries } from '~/sidebar/constants';
+import { assigneesQueries, ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants';
import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
export const assigneesWidget = Vue.observable({
updateAssignees: null,
});
-
export default {
i18n: {
unassigned: __('Unassigned'),
@@ -88,10 +87,10 @@ export default {
return this.queryVariables;
},
update(data) {
- return data.issuable || data.project?.issuable;
+ return data.workspace?.issuable;
},
result({ data }) {
- const issuable = data.issuable || data.project?.issuable;
+ const issuable = data.workspace?.issuable;
if (issuable) {
this.selected = this.moveCurrentUserToStart(cloneDeep(issuable.assignees.nodes));
}
@@ -104,13 +103,24 @@ export default {
query: searchUsers,
variables() {
return {
+ fullPath: this.fullPath,
search: this.search,
};
},
update(data) {
- return data.users?.nodes || [];
+ const searchResults = data.workspace?.users?.nodes.map(({ user }) => user) || [];
+ const mergedSearchResults = this.participants.reduce((acc, current) => {
+ if (
+ !acc.some((user) => current.username === user.username) &&
+ (current.name.includes(this.search) || current.username.includes(this.search))
+ ) {
+ acc.push(current);
+ }
+ return acc;
+ }, searchResults);
+ return mergedSearchResults;
},
- debounce: 250,
+ debounce: ASSIGNEES_DEBOUNCE_DELAY,
skip() {
return this.isSearchEmpty;
},
@@ -185,7 +195,7 @@ export default {
return this.selected.some(isCurrentUser) || this.participants.some(isCurrentUser);
},
noUsersFound() {
- return !this.isSearchEmpty && this.unselectedFiltered.length === 0;
+ return !this.isSearchEmpty && this.searchUsers.length === 0;
},
showCurrentUser() {
return !this.isCurrentUserInParticipants && (this.isSearchEmpty || this.isSearching);
@@ -218,7 +228,7 @@ export default {
},
})
.then(({ data }) => {
- this.$emit('assignees-updated', data);
+ this.$emit('assignees-updated', data.issuableSetAssignees.issuable.assignees.nodes);
return data;
})
.catch(() => {
@@ -281,6 +291,9 @@ export default {
collapseWidget() {
this.$refs.toggle.collapse();
},
+ showDivider(list) {
+ return list.length > 0 && this.isSearchEmpty;
+ },
},
};
</script>
@@ -306,6 +319,7 @@ export default {
<issuable-assignees
:users="assignees"
:issuable-type="issuableType"
+ class="gl-mt-2"
@assign-self="assignSelf"
/>
</template>
@@ -334,12 +348,14 @@ export default {
data-testid="unassign"
@click="selectAssignee()"
>
- <span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'">{{
- $options.i18n.unassigned
- }}</span></gl-dropdown-item
+ <span
+ :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'"
+ class="gl-font-weight-bold"
+ >{{ $options.i18n.unassigned }}</span
+ ></gl-dropdown-item
>
- <gl-dropdown-divider data-testid="unassign-divider" />
</template>
+ <gl-dropdown-divider v-if="showDivider(selectedFiltered)" />
<gl-dropdown-item
v-for="item in selectedFiltered"
:key="item.id"
@@ -358,10 +374,10 @@ export default {
/>
</gl-avatar-link>
</gl-dropdown-item>
- <gl-dropdown-divider v-if="!selectedIsEmpty" data-testid="selected-user-divider" />
<template v-if="showCurrentUser">
+ <gl-dropdown-divider />
<gl-dropdown-item
- data-testid="unselected-participant"
+ data-testid="current-user"
@click.stop="selectAssignee(currentUser)"
>
<gl-avatar-link>
@@ -370,12 +386,12 @@ export default {
:label="currentUser.name"
:sub-label="currentUser.username"
:src="currentUser.avatarUrl"
- class="gl-align-items-center"
+ class="gl-align-items-center gl-pl-6!"
/>
</gl-avatar-link>
</gl-dropdown-item>
- <gl-dropdown-divider />
</template>
+ <gl-dropdown-divider v-if="showDivider(unselectedFiltered)" />
<gl-dropdown-item
v-for="unselectedUser in unselectedFiltered"
:key="unselectedUser.id"
@@ -392,7 +408,7 @@ export default {
/>
</gl-avatar-link>
</gl-dropdown-item>
- <gl-dropdown-item v-if="noUsersFound && !isSearching">
+ <gl-dropdown-item v-if="noUsersFound && !isSearching" data-testid="empty-results">
{{ __('No matching results') }}
</gl-dropdown-item>
</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 36775648809..d0da4a9c75a 100644
--- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -83,7 +83,7 @@ export default {
<assignee-avatar-link :user="user" :issuable-type="issuableType" />
</div>
</div>
- <div v-if="renderShowMoreSection" class="user-list-more">
+ <div v-if="renderShowMoreSection" class="user-list-more gl-hover-text-blue-800">
<button
type="button"
class="btn-link"
diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
deleted file mode 100644
index 57b3705e803..00000000000
--- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
+++ /dev/null
@@ -1,113 +0,0 @@
-<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { mapState } from 'vuex';
-import { __, sprintf } from '~/locale';
-import eventHub from '~/sidebar/event_hub';
-import EditForm from './edit_form.vue';
-
-export default {
- components: {
- EditForm,
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- fullPath: {
- required: true,
- type: String,
- },
- isEditable: {
- required: true,
- type: Boolean,
- },
- issuableType: {
- required: false,
- type: String,
- default: 'issue',
- },
- },
- data() {
- return {
- edit: false,
- };
- },
- computed: {
- ...mapState({
- confidential: ({ noteableData, confidential }) => {
- if (noteableData) {
- return noteableData.confidential;
- }
- return Boolean(confidential);
- },
- }),
- confidentialityIcon() {
- return this.confidential ? 'eye-slash' : 'eye';
- },
- tooltipLabel() {
- return this.confidential ? __('Confidential') : __('Not confidential');
- },
- confidentialText() {
- return sprintf(__('This %{issuableType} is confidential'), {
- issuableType: this.issuableType,
- });
- },
- },
- created() {
- eventHub.$on('closeConfidentialityForm', this.toggleForm);
- },
- beforeDestroy() {
- eventHub.$off('closeConfidentialityForm', this.toggleForm);
- },
- methods: {
- toggleForm() {
- this.edit = !this.edit;
- },
- },
-};
-</script>
-
-<template>
- <div class="block issuable-sidebar-item confidentiality">
- <div
- ref="collapseIcon"
- v-gl-tooltip.viewport.left
- :title="tooltipLabel"
- class="sidebar-collapsed-icon"
- @click="toggleForm"
- >
- <gl-icon :name="confidentialityIcon" />
- </div>
- <div class="title hide-collapsed">
- {{ __('Confidentiality') }}
- <a
- v-if="isEditable"
- ref="editLink"
- class="float-right confidential-edit"
- href="#"
- data-track-event="click_edit_button"
- data-track-label="right_sidebar"
- data-track-property="confidentiality"
- @click.prevent="toggleForm"
- >{{ __('Edit') }}</a
- >
- </div>
- <div class="value sidebar-item-value hide-collapsed">
- <edit-form
- v-if="edit"
- :confidential="confidential"
- :full-path="fullPath"
- :issuable-type="issuableType"
- />
- <div v-if="!confidential" class="no-value sidebar-item-value" data-testid="not-confidential">
- <gl-icon :size="16" name="eye" class="sidebar-item-icon inline" />
- {{ __('Not confidential') }}
- </div>
- <div v-else class="value sidebar-item-value hide-collapsed">
- <gl-icon :size="16" name="eye-slash" class="sidebar-item-icon inline is-active" />
- {{ confidentialText }}
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue
deleted file mode 100644
index 057224d5918..00000000000
--- a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue
+++ /dev/null
@@ -1,64 +0,0 @@
-<script>
-import { GlSprintf } from '@gitlab/ui';
-import { __ } from '../../../locale';
-import editFormButtons from './edit_form_buttons.vue';
-
-export default {
- components: {
- editFormButtons,
- GlSprintf,
- },
- props: {
- confidential: {
- required: true,
- type: Boolean,
- },
- fullPath: {
- required: true,
- type: String,
- },
- issuableType: {
- required: true,
- type: String,
- },
- },
- computed: {
- confidentialityOnWarning() {
- return __(
- 'You are going to turn on the confidentiality. This means that only team members with %{strongStart}at least Reporter access%{strongEnd} are able to see and leave comments on the %{issuableType}.',
- );
- },
- confidentialityOffWarning() {
- return __(
- 'You are going to turn off the confidentiality. This means %{strongStart}everyone%{strongEnd} will be able to see and leave a comment on this %{issuableType}.',
- );
- },
- },
-};
-</script>
-
-<template>
- <div class="dropdown show">
- <div class="dropdown-menu sidebar-item-warning-message">
- <div>
- <p v-if="!confidential">
- <gl-sprintf :message="confidentialityOnWarning">
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- <template #issuableType>{{ issuableType }}</template>
- </gl-sprintf>
- </p>
- <p v-else>
- <gl-sprintf :message="confidentialityOffWarning">
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- <template #issuableType>{{ issuableType }}</template>
- </gl-sprintf>
- </p>
- <edit-form-buttons :full-path="fullPath" :confidential="confidential" />
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
deleted file mode 100644
index 154a228c978..00000000000
--- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
+++ /dev/null
@@ -1,81 +0,0 @@
-<script>
-import { GlButton } from '@gitlab/ui';
-import $ from 'jquery';
-import { mapActions } from 'vuex';
-import { deprecatedCreateFlash as Flash } from '~/flash';
-import { __ } from '~/locale';
-import eventHub from '../../event_hub';
-
-export default {
- components: {
- GlButton,
- },
- props: {
- fullPath: {
- required: true,
- type: String,
- },
- confidential: {
- required: true,
- type: Boolean,
- },
- },
- data() {
- return {
- isLoading: false,
- };
- },
- computed: {
- toggleButtonText() {
- if (this.isLoading) {
- return __('Applying');
- }
-
- return this.confidential ? __('Turn Off') : __('Turn On');
- },
- },
- methods: {
- ...mapActions(['updateConfidentialityOnIssuable']),
- closeForm() {
- eventHub.$emit('closeConfidentialityForm');
- $(this.$el).trigger('hidden.gl.dropdown');
- },
- submitForm() {
- this.isLoading = true;
- const confidential = !this.confidential;
-
- this.updateConfidentialityOnIssuable({ confidential, fullPath: this.fullPath })
- .then(() => {
- eventHub.$emit('updateIssuableConfidentiality', confidential);
- })
- .catch((err) => {
- Flash(
- err || __('Something went wrong trying to change the confidentiality of this issue'),
- );
- })
- .finally(() => {
- this.closeForm();
- this.isLoading = false;
- });
- },
- },
-};
-</script>
-
-<template>
- <div class="sidebar-item-warning-message-actions">
- <gl-button class="gl-mr-3" @click="closeForm">
- {{ __('Cancel') }}
- </gl-button>
- <gl-button
- category="secondary"
- variant="warning"
- :disabled="isLoading"
- :loading="isLoading"
- data-testid="confidential-toggle"
- @click.prevent="submitForm"
- >
- {{ toggleButtonText }}
- </gl-button>
- </div>
-</template>
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue
new file mode 100644
index 00000000000..37a44eb8f01
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue
@@ -0,0 +1,64 @@
+<script>
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ confidential: {
+ type: Boolean,
+ required: true,
+ },
+ issuableType: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ confidentialText() {
+ return this.confidential
+ ? sprintf(__('This %{issuableType} is confidential'), {
+ issuableType: this.issuableType,
+ })
+ : __('Not confidential');
+ },
+ confidentialIcon() {
+ return this.confidential ? 'eye-slash' : 'eye';
+ },
+ tooltipLabel() {
+ return this.confidential ? __('Confidential') : __('Not confidential');
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div
+ v-gl-tooltip.viewport.left
+ :title="tooltipLabel"
+ class="sidebar-collapsed-icon"
+ data-testid="sidebar-collapsed-icon"
+ @click="$emit('expandSidebar')"
+ >
+ <gl-icon
+ :size="16"
+ :name="confidentialIcon"
+ class="sidebar-item-icon inline"
+ :class="{ 'is-active': confidential }"
+ />
+ </div>
+ <gl-icon
+ :size="16"
+ :name="confidentialIcon"
+ class="sidebar-item-icon inline hide-collapsed"
+ :class="{ 'is-active': confidential }"
+ />
+ <span class="hide-collapsed" data-testid="confidential-text">{{ confidentialText }}</span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
new file mode 100644
index 00000000000..a21ac73f131
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
@@ -0,0 +1,136 @@
+<script>
+import { GlSprintf, GlButton } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { IssuableType } from '~/issue_show/constants';
+import { __, sprintf } from '~/locale';
+import { confidentialityQueries } from '~/sidebar/constants';
+
+export default {
+ i18n: {
+ confidentialityOnWarning: __(
+ 'You are going to turn on confidentiality. Only team members with %{strongStart}at least Reporter access%{strongEnd} will be able to see and leave comments on the %{issuableType}.',
+ ),
+ confidentialityOffWarning: __(
+ 'You are going to turn off the confidentiality. This means %{strongStart}everyone%{strongEnd} will be able to see and leave a comment on this %{issuableType}.',
+ ),
+ },
+ components: {
+ GlSprintf,
+ GlButton,
+ },
+ inject: ['fullPath', 'iid'],
+ props: {
+ confidential: {
+ required: true,
+ type: Boolean,
+ },
+ issuableType: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ loading: false,
+ };
+ },
+ computed: {
+ toggleButtonText() {
+ if (this.loading) {
+ return __('Applying');
+ }
+ return this.confidential ? __('Turn off') : __('Turn on');
+ },
+ warningMessage() {
+ return this.confidential
+ ? this.$options.i18n.confidentialityOffWarning
+ : this.$options.i18n.confidentialityOnWarning;
+ },
+ workspacePath() {
+ return this.issuableType === IssuableType.Issue
+ ? {
+ projectPath: this.fullPath,
+ }
+ : {
+ groupPath: this.fullPath,
+ };
+ },
+ },
+ methods: {
+ submitForm() {
+ this.loading = true;
+ this.$apollo
+ .mutate({
+ mutation: confidentialityQueries[this.issuableType].mutation,
+ variables: {
+ input: {
+ ...this.workspacePath,
+ iid: this.iid,
+ confidential: !this.confidential,
+ },
+ },
+ })
+ .then(
+ ({
+ data: {
+ issuableSetConfidential: { errors },
+ },
+ }) => {
+ if (errors.length) {
+ createFlash({
+ message: errors[0],
+ });
+ } else {
+ this.$emit('closeForm');
+ }
+ },
+ )
+ .catch(() => {
+ createFlash({
+ message: sprintf(
+ __('Something went wrong while setting %{issuableType} confidentiality.'),
+ {
+ issuableType: this.issuableType,
+ },
+ ),
+ });
+ })
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="dropdown show">
+ <div class="dropdown-menu sidebar-item-warning-message">
+ <div>
+ <p data-testid="warning-message">
+ <gl-sprintf :message="warningMessage">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ <template #issuableType>{{ issuableType }}</template>
+ </gl-sprintf>
+ </p>
+ <div class="sidebar-item-warning-message-actions">
+ <gl-button class="gl-mr-3" data-testid="confidential-cancel" @click="$emit('closeForm')">
+ {{ __('Cancel') }}
+ </gl-button>
+ <gl-button
+ category="secondary"
+ variant="warning"
+ :disabled="loading"
+ :loading="loading"
+ data-testid="confidential-toggle"
+ @click.prevent="submitForm"
+ >
+ {{ toggleButtonText }}
+ </gl-button>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
new file mode 100644
index 00000000000..ec5f07f9785
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
@@ -0,0 +1,142 @@
+<script>
+import produce from 'immer';
+import Vue from 'vue';
+import createFlash from '~/flash';
+import { __, sprintf } from '~/locale';
+import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
+import { confidentialityQueries } from '~/sidebar/constants';
+import SidebarConfidentialityContent from './sidebar_confidentiality_content.vue';
+import SidebarConfidentialityForm from './sidebar_confidentiality_form.vue';
+
+export const confidentialWidget = Vue.observable({
+ setConfidentiality: null,
+});
+
+const hideDropdownEvent = new CustomEvent('hiddenGlDropdown', {
+ bubbles: true,
+});
+
+export default {
+ tracking: {
+ event: 'click_edit_button',
+ label: 'right_sidebar',
+ property: 'confidentiality',
+ },
+ components: {
+ SidebarEditableItem,
+ SidebarConfidentialityContent,
+ SidebarConfidentialityForm,
+ },
+ inject: ['fullPath', 'iid'],
+ props: {
+ issuableType: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ confidential: false,
+ };
+ },
+ apollo: {
+ confidential: {
+ query() {
+ return confidentialityQueries[this.issuableType].query;
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: String(this.iid),
+ };
+ },
+ update(data) {
+ return data.workspace?.issuable?.confidential || false;
+ },
+ result({ data }) {
+ this.$emit('confidentialityUpdated', data.workspace?.issuable?.confidential);
+ },
+ error() {
+ createFlash({
+ message: sprintf(
+ __('Something went wrong while setting %{issuableType} confidentiality.'),
+ {
+ issuableType: this.issuableType,
+ },
+ ),
+ });
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.confidential.loading;
+ },
+ },
+ mounted() {
+ confidentialWidget.setConfidentiality = this.setConfidentiality;
+ },
+ destroyed() {
+ confidentialWidget.setConfidentiality = null;
+ },
+ methods: {
+ closeForm() {
+ this.$refs.editable.collapse();
+ this.$el.dispatchEvent(hideDropdownEvent);
+ this.$emit('closeForm');
+ },
+ // synchronizing the quick action with the sidebar widget
+ // this is a temporary solution until we have confidentiality real-time updates
+ setConfidentiality() {
+ const { defaultClient: client } = this.$apollo.provider.clients;
+ const sourceData = client.readQuery({
+ query: confidentialityQueries[this.issuableType].query,
+ variables: { fullPath: this.fullPath, iid: this.iid },
+ });
+
+ const data = produce(sourceData, (draftData) => {
+ draftData.workspace.issuable.confidential = !this.confidential;
+ });
+
+ client.writeQuery({
+ query: confidentialityQueries[this.issuableType].query,
+ variables: { fullPath: this.fullPath, iid: this.iid },
+ data,
+ });
+ },
+ expandSidebar() {
+ this.$refs.editable.expand();
+ this.$emit('expandSidebar');
+ },
+ },
+};
+</script>
+
+<template>
+ <sidebar-editable-item
+ ref="editable"
+ :title="__('Confidentiality')"
+ :tracking="$options.tracking"
+ :loading="isLoading"
+ class="block confidentiality"
+ >
+ <template #collapsed>
+ <div>
+ <sidebar-confidentiality-content
+ v-if="!isLoading"
+ :confidential="confidential"
+ :issuable-type="issuableType"
+ @expandSidebar="expandSidebar"
+ />
+ </div>
+ </template>
+ <template #default>
+ <sidebar-confidentiality-content :confidential="confidential" :issuable-type="issuableType" />
+ <sidebar-confidentiality-form
+ :confidential="confidential"
+ :issuable-type="issuableType"
+ @closeForm="closeForm"
+ />
+ </template>
+ </sidebar-editable-item>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue b/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue
new file mode 100644
index 00000000000..567c921b74e
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue
@@ -0,0 +1,84 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { referenceQueries } from '~/sidebar/constants';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+
+export default {
+ i18n: {
+ copyReference: __('Copy reference'),
+ text: __('Reference'),
+ },
+ components: {
+ ClipboardButton,
+ GlLoadingIcon,
+ },
+ inject: ['fullPath', 'iid'],
+ props: {
+ issuableType: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ reference: '',
+ };
+ },
+ apollo: {
+ reference: {
+ query() {
+ return referenceQueries[this.issuableType].query;
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: this.iid,
+ };
+ },
+ update(data) {
+ return data.workspace?.issuable?.reference || '';
+ },
+ error(error) {
+ this.$emit('fetch-error', {
+ message: __('An error occurred while fetching reference'),
+ error,
+ });
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.reference.loading;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="sub-block">
+ <clipboard-button
+ v-if="!isLoading"
+ :title="$options.i18n.copyReference"
+ :text="reference"
+ category="tertiary"
+ css-class="sidebar-collapsed-icon dont-change-state"
+ tooltip-placement="left"
+ />
+ <div class="gl-display-flex gl-align-items-center gl-justify-between gl-mb-2 hide-collapsed">
+ <span class="gl-overflow-hidden gl-text-overflow-ellipsis gl-white-space-nowrap">
+ {{ $options.i18n.text }}: {{ reference }}
+ <gl-loading-icon v-if="isLoading" inline :label="$options.i18n.text" />
+ </span>
+ <clipboard-button
+ v-if="!isLoading"
+ :title="$options.i18n.copyReference"
+ :text="reference"
+ size="small"
+ category="tertiary"
+ css-class="gl-mr-1"
+ tooltip-placement="left"
+ />
+ </div>
+ </div>
+</template>
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 cbd68f2513a..dd1d54d67f2 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { sprintf, s__ } from '~/locale';
import ReviewerAvatarLink from './reviewer_avatar_link.vue';
const LOADING_STATE = 'loading';
@@ -50,6 +51,9 @@ export default {
},
},
methods: {
+ approvedByTooltipTitle(user) {
+ return sprintf(s__('MergeRequest|Approved by @%{username}'), user);
+ },
toggleShowLess() {
this.showLess = !this.showLess;
},
@@ -57,6 +61,7 @@ export default {
this.loadingStates[userId] = LOADING_STATE;
this.$emit('request-review', { userId, callback: this.requestReviewComplete });
},
+
requestReviewComplete(userId, success) {
if (success) {
this.loadingStates[userId] = SUCCESS_STATE;
@@ -86,10 +91,19 @@ export default {
<div class="gl-ml-3">@{{ user.username }}</div>
</reviewer-avatar-link>
<gl-icon
+ v-if="user.approved"
+ v-gl-tooltip.left
+ :size="16"
+ :title="approvedByTooltipTitle(user)"
+ name="status-success"
+ class="float-right gl-my-2 gl-ml-2 gl-text-green-500"
+ data-testid="re-approved"
+ />
+ <gl-icon
v-if="loadingStates[user.id] === $options.SUCCESS_STATE"
:size="24"
name="check"
- class="float-right gl-text-green-500"
+ class="float-right gl-py-2 gl-mr-2 gl-text-green-500"
data-testid="re-request-success"
/>
<gl-button
diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
index 9da839cd133..4ab4606ac1c 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
@@ -3,7 +3,12 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui';
export default {
components: { GlButton, GlLoadingIcon },
- inject: ['canUpdate'],
+ inject: {
+ canUpdate: {},
+ isClassicSidebar: {
+ default: false,
+ },
+ },
props: {
title: {
type: String,
@@ -15,6 +20,15 @@ export default {
required: false,
default: false,
},
+ tracking: {
+ type: Object,
+ required: false,
+ default: () => ({
+ event: null,
+ label: null,
+ property: null,
+ }),
+ },
},
data() {
return {
@@ -71,24 +85,33 @@ export default {
<template>
<div>
- <div class="gl-display-flex gl-align-items-center gl-mb-3" @click.self="collapse">
- <span data-testid="title">{{ title }}</span>
- <gl-loading-icon v-if="loading" inline class="gl-ml-2" />
+ <div class="gl-display-flex gl-align-items-center" @click.self="collapse">
+ <span class="hide-collapsed" data-testid="title">{{ title }}</span>
+ <gl-loading-icon v-if="loading" inline class="gl-ml-2 hide-collapsed" />
+ <gl-loading-icon
+ v-if="loading && isClassicSidebar"
+ inline
+ class="gl-mx-auto gl-my-0 hide-expanded"
+ />
<gl-button
v-if="canUpdate"
variant="link"
- class="gl-text-gray-900! gl-hover-text-blue-800! gl-ml-auto js-sidebar-dropdown-toggle"
+ class="gl-text-gray-900! gl-hover-text-blue-800! gl-ml-auto hide-collapsed"
data-testid="edit-button"
+ :data-track-event="tracking.event"
+ :data-track-label="tracking.label"
+ :data-track-property="tracking.property"
+ data-qa-selector="edit_link"
@keyup.esc="toggle"
@click="toggle"
>
{{ __('Edit') }}
</gl-button>
</div>
- <div v-show="!edit" class="gl-text-gray-500" data-testid="collapsed-content">
+ <div v-show="!edit" data-testid="collapsed-content">
<slot name="collapsed">{{ __('None') }}</slot>
</div>
- <div v-show="edit" data-testid="expanded-content">
+ <div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }">
<slot :edit="edit"></slot>
</div>
</div>
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
index 9b06c20a6f3..c0424dc2873 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
@@ -122,6 +122,8 @@ export default {
:value="subscribed"
class="hide-collapsed"
data-testid="subscription-toggle"
+ :label="__('Notifications')"
+ label-position="hidden"
@change="toggleSubscription"
/>
</div>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
index e0f60b9af08..d1a5685fdd3 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
@@ -1,10 +1,14 @@
<script>
/* eslint-disable vue/no-v-html */
+import { GlButton } from '@gitlab/ui';
import { joinPaths } from '~/lib/utils/url_utility';
import { sprintf, s__ } from '../../../locale';
export default {
name: 'TimeTrackingHelpState',
+ components: {
+ GlButton,
+ },
computed: {
href() {
return joinPaths(gon.relative_url_root || '', '/help/user/project/time_tracking.md');
@@ -40,7 +44,7 @@ export default {
<p>{{ __('Quick actions can be used in the issues description and comment boxes.') }}</p>
<p v-html="estimateText"></p>
<p v-html="spendText"></p>
- <a :href="href" class="btn btn-default learn-more-button"> {{ __('Learn more') }} </a>
+ <gl-button :href="href">{{ __('Learn more') }}</gl-button>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
index 274aa237aea..a0e636488f4 100644
--- a/app/assets/javascripts/sidebar/constants.js
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -1,9 +1,17 @@
import { IssuableType } from '~/issue_show/constants';
+import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql';
+import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql';
+import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql';
+import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql';
+import updateEpicMutation from '~/sidebar/queries/update_epic_confidential.mutation.graphql';
+import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_confidential.mutation.graphql';
import getIssueParticipants from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql';
import updateAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
import updateMergeRequestParticipantsMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql';
+export const ASSIGNEES_DEBOUNCE_DELAY = 250;
+
export const assigneesQueries = {
[IssuableType.Issue]: {
query: getIssueParticipants,
@@ -14,3 +22,23 @@ export const assigneesQueries = {
mutation: updateMergeRequestParticipantsMutation,
},
};
+
+export const confidentialityQueries = {
+ [IssuableType.Issue]: {
+ query: issueConfidentialQuery,
+ mutation: updateIssueConfidentialMutation,
+ },
+ [IssuableType.Epic]: {
+ query: epicConfidentialQuery,
+ mutation: updateEpicMutation,
+ },
+};
+
+export const referenceQueries = {
+ [IssuableType.Issue]: {
+ query: issueReferenceQuery,
+ },
+ [IssuableType.MergeRequest]: {
+ query: mergeRequestReferenceQuery,
+ },
+};
diff --git a/app/assets/javascripts/sidebar/graphql.js b/app/assets/javascripts/sidebar/graphql.js
new file mode 100644
index 00000000000..aa139540a51
--- /dev/null
+++ b/app/assets/javascripts/sidebar/graphql.js
@@ -0,0 +1,8 @@
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+
+export const defaultClient = createDefaultClient();
+
+export const apolloProvider = new VueApollo({
+ defaultClient,
+});
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 662edbc4f8d..312c0c89f29 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -2,7 +2,7 @@ import $ from 'jquery';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createFlash from '~/flash';
-import createDefaultClient from '~/lib/graphql';
+import { IssuableType } from '~/issue_show/constants';
import {
isInIssuePage,
isInDesignPage,
@@ -10,9 +10,11 @@ import {
parseBoolean,
} from '~/lib/utils/common_utils';
import { __ } from '~/locale';
+import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
+import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue';
+import { apolloProvider } from '~/sidebar/graphql';
import Translate from '../vue_shared/translate';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
-import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue';
import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue';
import SidebarLabels from './components/labels/sidebar_labels.vue';
import IssuableLockForm from './components/lock/issuable_lock_form.vue';
@@ -54,9 +56,6 @@ function getSidebarAssigneeAvailabilityData() {
function mountAssigneesComponent(mediator) {
const el = document.getElementById('js-vue-sidebar-assignees');
- const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
- });
if (!el) return;
@@ -78,7 +77,9 @@ function mountAssigneesComponent(mediator) {
field: el.dataset.field,
signedIn: el.hasAttribute('data-signed-in'),
issuableType:
- isInIssuePage() || isInIncidentPage() || isInDesignPage() ? 'issue' : 'merge_request',
+ isInIssuePage() || isInIncidentPage() || isInDesignPage()
+ ? IssuableType.Issue
+ : IssuableType.MergeRequest,
assigneeAvailabilityStatus,
},
}),
@@ -87,9 +88,6 @@ function mountAssigneesComponent(mediator) {
function mountReviewersComponent(mediator) {
const el = document.getElementById('js-vue-sidebar-reviewers');
- const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
- });
if (!el) return;
@@ -121,10 +119,6 @@ export function mountSidebarLabels() {
return false;
}
- const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
- });
-
return new Vue({
el,
apolloProvider,
@@ -139,39 +133,71 @@ export function mountSidebarLabels() {
});
}
-function mountConfidentialComponent(mediator) {
+function mountConfidentialComponent() {
const el = document.getElementById('js-confidential-entry-point');
+ if (!el) {
+ return;
+ }
const { fullPath, iid } = getSidebarOptions();
-
- if (!el) return;
-
const dataNode = document.getElementById('js-confidential-issue-data');
const initialData = JSON.parse(dataNode.innerHTML);
- import(/* webpackChunkName: 'notesStore' */ '~/notes/stores')
- .then(
- ({ store }) =>
- new Vue({
- el,
- store,
- components: {
- ConfidentialIssueSidebar,
- },
- render: (createElement) =>
- createElement('confidential-issue-sidebar', {
- props: {
- iid: String(iid),
- fullPath,
- isEditable: initialData.is_editable,
- service: mediator.service,
- },
- }),
- }),
- )
- .catch(() => {
- createFlash({ message: __('Failed to load sidebar confidential toggle') });
- });
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ apolloProvider,
+ components: {
+ SidebarConfidentialityWidget,
+ },
+ provide: {
+ iid: String(iid),
+ fullPath,
+ canUpdate: initialData.is_editable,
+ },
+
+ render: (createElement) =>
+ createElement('sidebar-confidentiality-widget', {
+ props: {
+ issuableType:
+ isInIssuePage() || isInIncidentPage() || isInDesignPage()
+ ? IssuableType.Issue
+ : IssuableType.MergeRequest,
+ },
+ }),
+ });
+}
+
+function mountReferenceComponent() {
+ const el = document.getElementById('js-reference-entry-point');
+ if (!el) {
+ return;
+ }
+
+ const { fullPath, iid } = getSidebarOptions();
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ apolloProvider,
+ components: {
+ SidebarReferenceWidget,
+ },
+ provide: {
+ iid: String(iid),
+ fullPath,
+ },
+
+ render: (createElement) =>
+ createElement('sidebar-reference-widget', {
+ props: {
+ issuableType:
+ isInIssuePage() || isInIncidentPage() || isInDesignPage()
+ ? IssuableType.Issue
+ : IssuableType.MergeRequest,
+ },
+ }),
+ });
}
function mountLockComponent() {
@@ -280,9 +306,6 @@ function mountSeverityComponent() {
if (!severityContainerEl) {
return false;
}
- const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
- });
const { fullPath, iid, severity } = getSidebarOptions();
@@ -322,6 +345,7 @@ export function mountSidebar(mediator) {
mountAssigneesComponent(mediator);
mountReviewersComponent(mediator);
mountConfidentialComponent(mediator);
+ mountReferenceComponent(mediator);
mountLockComponent();
mountParticipantsComponent(mediator);
mountSubscriptionsComponent(mediator);
diff --git a/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql b/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql
new file mode 100644
index 00000000000..7a1fdb40e93
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql
@@ -0,0 +1,10 @@
+query epicConfidential($fullPath: ID!, $iid: ID) {
+ workspace: group(fullPath: $fullPath) {
+ __typename
+ issuable: epic(iid: $iid) {
+ __typename
+ id
+ confidential
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql b/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql
new file mode 100644
index 00000000000..92cabf46af7
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql
@@ -0,0 +1,10 @@
+query issueConfidential($fullPath: ID!, $iid: String) {
+ workspace: project(fullPath: $fullPath) {
+ __typename
+ issuable: issue(iid: $iid) {
+ __typename
+ id
+ confidential
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql b/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql
new file mode 100644
index 00000000000..db4f58a4f69
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql
@@ -0,0 +1,10 @@
+query issueReference($fullPath: ID!, $iid: String) {
+ workspace: project(fullPath: $fullPath) {
+ __typename
+ issuable: issue(iid: $iid) {
+ __typename
+ id
+ reference(full: true)
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql
new file mode 100644
index 00000000000..7979a1ccb3e
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql
@@ -0,0 +1,10 @@
+query mergeRequestReference($fullPath: ID!, $iid: String!) {
+ workspace: project(fullPath: $fullPath) {
+ __typename
+ issuable: mergeRequest(iid: $iid) {
+ __typename
+ id
+ reference(full: true)
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql b/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql
new file mode 100644
index 00000000000..02498b18832
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql
@@ -0,0 +1,7 @@
+query mergeRequestSidebarDetails($fullPath: ID!, $iid: String!) {
+ project(fullPath: $fullPath) {
+ mergeRequest(iid: $iid) {
+ iid # currently unused.
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/update_epic_confidential.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_confidential.mutation.graphql
new file mode 100644
index 00000000000..69927ddd205
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/update_epic_confidential.mutation.graphql
@@ -0,0 +1,9 @@
+mutation updateEpic($input: UpdateEpicInput!) {
+ issuableSetConfidential: updateEpic(input: $input) {
+ issuable: epic {
+ id
+ confidential
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/sidebar/components/confidential/mutations/update_issue_confidential.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_issue_confidential.mutation.graphql
index 5caf5f6b555..8f716c882d6 100644
--- a/app/assets/javascripts/sidebar/components/confidential/mutations/update_issue_confidential.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/update_issue_confidential.mutation.graphql
@@ -1,6 +1,7 @@
mutation updateIssueConfidential($input: IssueSetConfidentialInput!) {
- issueSetConfidential(input: $input) {
- issue {
+ issuableSetConfidential: issueSetConfidential(input: $input) {
+ issuable: issue {
+ id
confidential
}
errors
diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js
index f31e4a3e0dd..88501f2c305 100644
--- a/app/assets/javascripts/sidebar/services/sidebar_service.js
+++ b/app/assets/javascripts/sidebar/services/sidebar_service.js
@@ -1,8 +1,14 @@
-import sidebarDetailsQuery from 'ee_else_ce/sidebar/queries/sidebarDetails.query.graphql';
+import sidebarDetailsIssueQuery from 'ee_else_ce/sidebar/queries/sidebarDetails.query.graphql';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import axios from '~/lib/utils/axios_utils';
import reviewerRereviewMutation from '../queries/reviewer_rereview.mutation.graphql';
+import sidebarDetailsMRQuery from '../queries/sidebarDetailsMR.query.graphql';
+
+const queries = {
+ merge_request: sidebarDetailsMRQuery,
+ issue: sidebarDetailsIssueQuery,
+};
export const gqClient = createGqClient(
{},
@@ -20,6 +26,7 @@ export default class SidebarService {
this.projectsAutocompleteEndpoint = endpointMap.projectsAutocompleteEndpoint;
this.fullPath = endpointMap.fullPath;
this.iid = endpointMap.iid;
+ this.issuableType = endpointMap.issuableType;
SidebarService.singleton = this;
}
@@ -31,7 +38,7 @@ export default class SidebarService {
return Promise.all([
axios.get(this.endpoint),
gqClient.query({
- query: sidebarDetailsQuery,
+ query: this.sidebarDetailsQuery(),
variables: {
fullPath: this.fullPath,
iid: this.iid.toString(),
@@ -40,6 +47,10 @@ export default class SidebarService {
]);
}
+ sidebarDetailsQuery() {
+ return queries[this.issuableType];
+ }
+
update(key, data) {
return axios.put(this.endpoint, { [key]: data });
}
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index bd382ed0fdb..3595354da80 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -22,6 +22,7 @@ export default class SidebarMediator {
projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint,
fullPath: options.fullPath,
iid: options.iid,
+ issuableType: options.issuableType,
});
SidebarMediator.singleton = this;
}
diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js
index 687289b6675..2c4928fc338 100644
--- a/app/assets/javascripts/single_file_diff.js
+++ b/app/assets/javascripts/single_file_diff.js
@@ -73,7 +73,7 @@ export default class SingleFileDiff {
this.collapsedContent.hide();
this.loadingContent.show();
- axios
+ return axios
.get(this.diffForPath)
.then(({ data }) => {
this.loadingContent.hide();
diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue
index 9f43ac36df7..bee9d7b8c2a 100644
--- a/app/assets/javascripts/snippets/components/edit.vue
+++ b/app/assets/javascripts/snippets/components/edit.vue
@@ -221,7 +221,10 @@ export default {
this.captchaResponse = captchaResponse;
if (this.captchaResponse) {
- // If the user solved the captcha resubmit the form.
+ // If the user solved the captcha, resubmit the form.
+ // NOTE: we do not need to clear out the captchaResponse and spamLogId
+ // data values after submit, because this component always does a full page reload.
+ // Otherwise, we would need to.
this.handleFormSubmit();
} else {
// If the user didn't solve the captcha (e.g. they just closed the modal),
diff --git a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue
index 18a7d4ad218..e6aa3be0371 100644
--- a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue
@@ -55,7 +55,12 @@ export default {
>
<div class="d-flex align-items-center">
<gl-icon :size="16" :name="option.icon" />
- <span class="font-weight-bold ml-1 js-visibility-option">{{ option.label }}</span>
+ <span
+ class="font-weight-bold ml-1 js-visibility-option"
+ data-qa-selector="visibility_content"
+ :data-qa-visibility="option.label"
+ >{{ option.label }}</span
+ >
</div>
<template #help>{{
isProjectSnippet && option.description_project
diff --git a/app/assets/javascripts/static_site_editor/constants.js b/app/assets/javascripts/static_site_editor/constants.js
index 4cabd943e22..5fb20b00705 100644
--- a/app/assets/javascripts/static_site_editor/constants.js
+++ b/app/assets/javascripts/static_site_editor/constants.js
@@ -12,7 +12,7 @@ export const SUBMIT_CHANGES_MERGE_REQUEST_ERROR = s__(
'StaticSiteEditor|Could not create merge request.',
);
export const LOAD_CONTENT_ERROR = __(
- 'An error ocurred while loading your content. Please try again.',
+ 'An error occurred while loading your content. Please try again.',
);
export const DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE = s__(
diff --git a/app/assets/javascripts/tooltips/components/tooltips.vue b/app/assets/javascripts/tooltips/components/tooltips.vue
index 90bdf06bc4c..1ad18508294 100644
--- a/app/assets/javascripts/tooltips/components/tooltips.vue
+++ b/app/assets/javascripts/tooltips/components/tooltips.vue
@@ -82,9 +82,10 @@ export default {
},
triggerEvent(target, event) {
const tooltip = this.findTooltipByTarget(target);
+ const tooltipRef = this.$refs[tooltip?.id];
- if (tooltip) {
- this.$refs[tooltip.id][0].$emit(event);
+ if (tooltipRef) {
+ tooltipRef[0].$emit(event);
}
},
tooltipExists(element) {
@@ -113,6 +114,7 @@ export default {
:boundary="tooltip.boundary"
:disabled="tooltip.disabled"
:show="tooltip.show"
+ @hidden="$emit('hidden', tooltip)"
>
<span v-if="tooltip.html" v-safe-html:[$options.safeHtmlConfig]="tooltip.title"></span>
<span v-else>{{ tooltip.title }}</span>
diff --git a/app/assets/javascripts/tooltips/index.js b/app/assets/javascripts/tooltips/index.js
index a9978c03a6e..f60c0759c72 100644
--- a/app/assets/javascripts/tooltips/index.js
+++ b/app/assets/javascripts/tooltips/index.js
@@ -92,6 +92,7 @@ export const hide = createTooltipApiInvoker((element) =>
export const show = createTooltipApiInvoker((element) =>
tooltipsApp().triggerEvent(element, 'open'),
);
+export const once = (event, cb) => tooltipsApp().$once(event, cb);
export const destroy = () => {
tooltipsApp().$destroy();
app = null;
diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js
index 5d82d56f4ba..01de034417e 100644
--- a/app/assets/javascripts/tracking.js
+++ b/app/assets/javascripts/tracking.js
@@ -1,4 +1,16 @@
import { omitBy, isUndefined } from 'lodash';
+import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants';
+import { getExperimentData } from '~/experimentation/utils';
+
+const standardContext = { ...window.gl?.snowplowStandardContext };
+
+export const STANDARD_CONTEXT = {
+ schema: standardContext.schema,
+ data: {
+ ...(standardContext.data || {}),
+ source: 'gitlab-javascript',
+ },
+};
const DEFAULT_SNOWPLOW_OPTIONS = {
namespace: 'gl',
@@ -20,11 +32,17 @@ const createEventPayload = (el, { suffix = '' } = {}) => {
let value = el.dataset.trackValue || el.value || undefined;
if (el.type === 'checkbox' && !el.checked) value = false;
+ let context = el.dataset.trackContext;
+ if (el.dataset.trackExperiment) {
+ const data = getExperimentData(el.dataset.trackExperiment);
+ if (data) context = { schema: TRACKING_CONTEXT_SCHEMA, data };
+ }
+
const data = {
label: el.dataset.trackLabel,
property: el.dataset.trackProperty,
value,
- context: el.dataset.trackContext,
+ context,
};
return {
@@ -51,25 +69,51 @@ const eventHandlers = (category, func) => {
return handlers;
};
+const dispatchEvent = (category = document.body.dataset.page, action = 'generic', data = {}) => {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ if (!category) throw new Error('Tracking: no category provided for tracking.');
+
+ const { label, property, value } = data;
+ const contexts = [STANDARD_CONTEXT];
+
+ if (data.context) {
+ contexts.push(data.context);
+ }
+
+ return window.snowplow('trackStructEvent', category, action, label, property, value, contexts);
+};
+
export default class Tracking {
+ static queuedEvents = [];
+ static initialized = false;
+
static trackable() {
return !['1', 'yes'].includes(
window.doNotTrack || navigator.doNotTrack || navigator.msDoNotTrack,
);
}
+ static flushPendingEvents() {
+ this.initialized = true;
+
+ while (this.queuedEvents.length) {
+ dispatchEvent(...this.queuedEvents.shift());
+ }
+ }
+
static enabled() {
return typeof window.snowplow === 'function' && this.trackable();
}
- static event(category = document.body.dataset.page, action = 'generic', data = {}) {
+ static event(...eventData) {
if (!this.enabled()) return false;
- // eslint-disable-next-line @gitlab/require-i18n-strings
- if (!category) throw new Error('Tracking: no category provided for tracking.');
- const { label, property, value, context } = data;
- const contexts = context ? [context] : undefined;
- return window.snowplow('trackStructEvent', category, action, label, property, value, contexts);
+ if (!this.initialized) {
+ this.queuedEvents.push(eventData);
+ return false;
+ }
+
+ return dispatchEvent(...eventData);
}
static bindDocument(category = document.body.dataset.page, parent = document) {
@@ -128,13 +172,15 @@ export function initUserTracking() {
window.snowplow('newTracker', opts.namespace, opts.hostname, opts);
document.dispatchEvent(new Event('SnowplowInitialized'));
+ Tracking.flushPendingEvents();
}
export function initDefaultTrackers() {
if (!Tracking.enabled()) return;
window.snowplow('enableActivityTracking', 30, 30);
- window.snowplow('trackPageView'); // must be after enableActivityTracking
+ // must be after enableActivityTracking
+ window.snowplow('trackPageView', null, [STANDARD_CONTEXT]);
if (window.snowplowOptions.formTracking) window.snowplow('enableFormTracking');
if (window.snowplowOptions.linkClickTracking) window.snowplow('enableLinkClickTracking');
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
index c18f4fb46cc..21368edb6af 100644
--- a/app/assets/javascripts/user_popovers.js
+++ b/app/assets/javascripts/user_popovers.js
@@ -36,6 +36,7 @@ const populateUserInfo = (user) => {
if (userData) {
Object.assign(user, {
avatarUrl: userData.avatar_url,
+ bot: userData.bot,
username: userData.username,
name: userData.name,
location: userData.location,
@@ -59,11 +60,33 @@ const populateUserInfo = (user) => {
};
const initializedPopovers = new Map();
+let domObservedForChanges = false;
-export default (elements = document.querySelectorAll('.js-user-link')) => {
+const addPopoversToModifiedTree = new MutationObserver(() => {
+ const userLinks = document?.querySelectorAll('.js-user-link, .gfm-project_member');
+
+ if (userLinks) {
+ addPopovers(userLinks); /* eslint-disable-line no-use-before-define */
+ }
+});
+
+function observeBody() {
+ if (!domObservedForChanges) {
+ addPopoversToModifiedTree.observe(document.body, {
+ subtree: true,
+ childList: true,
+ });
+
+ domObservedForChanges = true;
+ }
+}
+
+export default function addPopovers(elements = document.querySelectorAll('.js-user-link')) {
const userLinks = Array.from(elements);
const UserPopoverComponent = Vue.extend(UserPopover);
+ observeBody();
+
return userLinks
.filter(({ dataset }) => dataset.user || dataset.userId)
.map((el) => {
@@ -105,4 +128,4 @@ export default (elements = document.querySelectorAll('.js-user-link')) => {
return renderedPopover;
});
-};
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue
new file mode 100644
index 00000000000..d23c7f016fb
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue
@@ -0,0 +1,92 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import { n__ } from '~/locale';
+import MrCollapsibleExtension from '../mr_collapsible_extension.vue';
+
+export default {
+ components: {
+ Deployment: () => import('./deployment.vue'),
+ GlSprintf,
+ MrCollapsibleExtension,
+ },
+ props: {
+ deployments: {
+ type: Array,
+ required: true,
+ },
+ deploymentClass: {
+ type: String,
+ required: true,
+ },
+ hasDeploymentMetrics: {
+ type: Boolean,
+ required: true,
+ },
+ visualReviewAppMeta: {
+ type: Object,
+ required: false,
+ default: () => ({
+ sourceProjectId: '',
+ sourceProjectPath: '',
+ mergeRequestId: '',
+ appUrl: '',
+ }),
+ },
+ showVisualReviewAppLink: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ showCollapsedDeployments() {
+ return this.deployments.length > 3;
+ },
+ multipleDeploymentsTitle() {
+ return n__(
+ 'Deployments|%{deployments} environment impacted.',
+ 'Deployments|%{deployments} environments impacted.',
+ this.deployments.length,
+ );
+ },
+ },
+};
+</script>
+<template>
+ <mr-collapsible-extension
+ v-if="showCollapsedDeployments"
+ :title="__('View all environments.')"
+ data-testid="mr-collapsed-deployments"
+ >
+ <template #header>
+ <div class="gl-mr-3 gl-line-height-normal">
+ <gl-sprintf :message="multipleDeploymentsTitle">
+ <template #deployments>
+ <span class="gl-font-weight-bold gl-mr-2">{{ deployments.length }}</span>
+ </template>
+ </gl-sprintf>
+ </div>
+ </template>
+ <deployment
+ v-for="deployment in deployments"
+ :key="deployment.id"
+ :class="deploymentClass"
+ class="gl-bg-gray-50"
+ :deployment="deployment"
+ :show-metrics="hasDeploymentMetrics"
+ :show-visual-review-app="showVisualReviewAppLink"
+ :visual-review-app-meta="visualReviewAppMeta"
+ />
+ </mr-collapsible-extension>
+ <div v-else class="mr-widget-extension">
+ <deployment
+ v-for="deployment in deployments"
+ :key="deployment.id"
+ :class="deploymentClass"
+ :deployment="deployment"
+ :show-metrics="hasDeploymentMetrics"
+ :show-visual-review-app="showVisualReviewAppLink"
+ :visual-review-app-meta="visualReviewAppMeta"
+ />
+ </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 b6b5b56e5aa..a619ae9c351 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
@@ -50,9 +50,9 @@ export default {
<div class="mr-widget-extension d-flex align-items-center pl-3">
<div v-if="hasError" class="ci-widget media">
<div class="media-body">
- <span class="gl-font-sm mr-widget-margin-left gl-line-height-24 js-error-state">{{
- title
- }}</span>
+ <span class="gl-font-sm mr-widget-margin-left gl-line-height-24 js-error-state">
+ {{ title }}
+ </span>
</div>
</div>
@@ -67,16 +67,27 @@ export default {
<gl-loading-icon v-if="isLoading" />
<gl-icon v-else :name="arrowIconName" class="js-icon" />
</button>
+ <template v-if="isCollapsed">
+ <slot name="header"></slot>
+ <gl-button
+ variant="link"
+ data-testid="mr-collapsible-title"
+ :disabled="isLoading"
+ :class="{ 'border-0': isLoading }"
+ @click="toggleCollapsed"
+ >
+ {{ title }}
+ </gl-button>
+ </template>
<gl-button
+ v-else
variant="link"
- class="js-title"
+ data-testid="mr-collapsible-title"
:disabled="isLoading"
:class="{ 'border-0': isLoading }"
@click="toggleCollapsed"
+ >{{ __('Collapse') }}</gl-button
>
- <template v-if="isCollapsed">{{ title }}</template>
- <template v-else>{{ __('Collapse') }}</template>
- </gl-button>
</template>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
index 71ff0fe6fbd..f1230e2fdeb 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
@@ -175,7 +175,7 @@ export default {
:href="mr.emailPatchesPath"
class="js-download-email-patches"
download
- data-qa-selector="download_email_patches"
+ data-qa-selector="download_email_patches_menu_item"
>
{{ s__('mrWidget|Email patches') }}
</gl-dropdown-item>
@@ -183,7 +183,7 @@ export default {
:href="mr.plainDiffPath"
class="js-download-plain-diff"
download
- data-qa-selector="download_plain_diff"
+ data-qa-selector="download_plain_diff_menu_item"
>
{{ s__('mrWidget|Plain diff') }}
</gl-dropdown-item>
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 7c50df5f104..7532eabee8a 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
@@ -108,9 +108,7 @@ export default {
{{ $options.i18n.steps.step1.help }}
</p>
<div class="gl-display-flex">
- <pre class="gl-overflow-scroll gl-w-full" data-testid="how-to-merge-instructions">{{
- mergeInfo1
- }}</pre>
+ <pre class="gl-w-full" data-testid="how-to-merge-instructions">{{ mergeInfo1 }}</pre>
<clipboard-button
:text="mergeInfo1"
:title="$options.i18n.copyCommands"
@@ -131,9 +129,7 @@ export default {
{{ $options.i18n.steps.step3.help }}
</p>
<div class="gl-display-flex">
- <pre class="gl-overflow-scroll gl-w-full" data-testid="how-to-merge-instructions">{{
- mergeInfo2
- }}</pre>
+ <pre class="gl-w-full" data-testid="how-to-merge-instructions">{{ mergeInfo2 }}</pre>
<clipboard-button
:text="mergeInfo2"
:title="$options.i18n.copyCommands"
@@ -147,9 +143,7 @@ export default {
{{ $options.i18n.steps.step4.help }}
</p>
<div class="gl-display-flex">
- <pre class="gl-overflow-scroll gl-w-full" data-testid="how-to-merge-instructions">{{
- mergeInfo3
- }}</pre>
+ <pre class="gl-w-full" data-testid="how-to-merge-instructions">{{ mergeInfo3 }}</pre>
<clipboard-button
:text="mergeInfo3"
:title="$options.i18n.copyCommands"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue
deleted file mode 100644
index 3cd003461b3..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue
+++ /dev/null
@@ -1,49 +0,0 @@
-<script>
-import { GlButton, GlModalDirective } from '@gitlab/ui';
-import { sprintf, s__ } from '~/locale';
-
-export default {
- name: 'MRWidgetMergeHelp',
- components: {
- GlButton,
- },
- directives: {
- GlModalDirective,
- },
- props: {
- missingBranch: {
- type: String,
- required: false,
- default: '',
- },
- },
- computed: {
- missingBranchInfo() {
- return sprintf(
- s__(
- 'mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the',
- ),
- { branch: this.missingBranch },
- );
- },
- },
-};
-</script>
-<template>
- <section class="gl-py-3 gl-pr-3 gl-pl-5 gl-ml-7 mr-widget-help gl-font-style-italic">
- <template v-if="missingBranch">
- {{ missingBranchInfo }}
- </template>
- <template v-else>
- {{ s__('mrWidget|You can merge this merge request manually using the') }}
- </template>
-
- <gl-button
- v-gl-modal-directive="'modal-merge-info'"
- variant="link"
- class="gl-mt-n2 js-open-modal-help"
- >
- {{ s__('mrWidget|command line') }}
- </gl-button>
- </section>
-</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
index d022579ef54..3419abd4738 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
@@ -11,10 +11,11 @@ import {
} from '@gitlab/ui';
import mrWidgetPipelineMixin from 'ee_else_ce/vue_merge_request_widget/mixins/mr_widget_pipeline';
import { s__, n__ } from '~/locale';
+import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue';
import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue';
-import PipelineStage from '~/pipelines/components/pipelines_list/stage.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import { MT_MERGE_STRATEGY } from '../constants';
export default {
name: 'MRWidgetPipeline',
@@ -26,7 +27,7 @@ export default {
GlSprintf,
GlTooltip,
PipelineArtifacts,
- PipelineStage,
+ PipelineMiniGraph,
TooltipOnTruncate,
LinkedPipelinesMiniList: () =>
import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'),
@@ -80,6 +81,11 @@ export default {
type: String,
required: true,
},
+ mergeStrategy: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
hasPipeline() {
@@ -94,9 +100,7 @@ export default {
: {};
},
hasStages() {
- return (
- this.pipeline.details && this.pipeline.details.stages && this.pipeline.details.stages.length
- );
+ return this.pipeline?.details?.stages?.length > 0;
},
hasCommitInfo() {
return this.pipeline.commit && Object.keys(this.pipeline.commit).length > 0;
@@ -130,6 +134,9 @@ export default {
this.buildsWithCoverage.length,
);
},
+ isMergeTrain() {
+ return this.mergeStrategy === MT_MERGE_STRATEGY;
+ },
},
errorText: s__(
'Pipeline|Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation%{linkEnd}.',
@@ -242,19 +249,13 @@ export default {
<span class="mr-widget-pipeline-graph">
<span class="stage-cell">
<linked-pipelines-mini-list v-if="triggeredBy.length" :triggered-by="triggeredBy" />
- <template v-if="hasStages">
- <div
- v-for="(stage, i) in pipeline.details.stages"
- :key="i"
- :class="{
- 'has-downstream': hasDownstream(i),
- }"
- class="stage-container dropdown mr-widget-pipeline-stages"
- data-testid="widget-mini-pipeline-graph"
- >
- <pipeline-stage :stage="stage" />
- </div>
- </template>
+ <pipeline-mini-graph
+ v-if="hasStages"
+ class="gl-display-inline-block"
+ stages-class="mr-widget-pipeline-stages"
+ :stages="pipeline.details.stages"
+ :is-merge-train="isMergeTrain"
+ />
</span>
<linked-pipelines-mini-list v-if="triggered.length" :triggered="triggered" />
<pipeline-artifacts
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
index 2bf86c1863a..c24ae92db4f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
@@ -1,8 +1,11 @@
<script>
import { isNumber } from 'lodash';
import { sanitize } from '~/lib/dompurify';
+import { n__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import MergeRequestStore from '../stores/mr_widget_store';
import ArtifactsApp from './artifacts_list_app.vue';
+import DeploymentList from './deployment/deployment_list.vue';
import MrWidgetContainer from './mr_widget_container.vue';
import MrWidgetPipeline from './mr_widget_pipeline.vue';
@@ -18,7 +21,7 @@ export default {
name: 'MrWidgetPipelineContainer',
components: {
ArtifactsApp,
- Deployment: () => import('./deployment/deployment.vue'),
+ DeploymentList,
MrWidgetContainer,
MrWidgetPipeline,
MergeTrainPositionIndicator: () =>
@@ -64,11 +67,32 @@ export default {
return this.isPostMerge ? this.mr.mergePipeline : this.mr.pipeline;
},
showVisualReviewAppLink() {
- return this.mr.visualReviewAppAvailable && this.glFeatures.anonymousVisualReviewFeedback;
+ return Boolean(
+ this.mr.visualReviewAppAvailable && this.glFeatures.anonymousVisualReviewFeedback,
+ );
},
showMergeTrainPositionIndicator() {
return isNumber(this.mr.mergeTrainIndex);
},
+ showCollapsedDeployments() {
+ return this.deployments.length > 3;
+ },
+ multipleDeploymentsTitle() {
+ return n__(
+ 'Deployments|%{deployments} environment impacted.',
+ 'Deployments|%{deployments} environments impacted.',
+ this.deployments.length,
+ );
+ },
+ preferredAutoMergeStrategy() {
+ if (this.glFeatures.mergeRequestWidgetGraphql) {
+ return MergeRequestStore.getPreferredAutoMergeStrategy(
+ this.mr.availableAutoMergeStrategies,
+ );
+ }
+
+ return this.mr.preferredAutoMergeStrategy;
+ },
},
};
</script>
@@ -85,22 +109,20 @@ export default {
:source-branch-link="branchLink"
:mr-troubleshooting-docs-path="mr.mrTroubleshootingDocsPath"
:ci-troubleshooting-docs-path="mr.ciTroubleshootingDocsPath"
+ :merge-strategy="preferredAutoMergeStrategy"
/>
<template #footer>
<div v-if="mr.exposedArtifactsPath" class="js-exposed-artifacts">
<artifacts-app :endpoint="mr.exposedArtifactsPath" />
</div>
- <div v-if="deployments.length" class="mr-widget-extension">
- <deployment
- v-for="deployment in deployments"
- :key="deployment.id"
- :class="deploymentClass"
- :deployment="deployment"
- :show-metrics="hasDeploymentMetrics"
- :show-visual-review-app="showVisualReviewAppLink"
- :visual-review-app-meta="visualReviewAppMeta"
- />
- </div>
+ <deployment-list
+ v-if="deployments.length"
+ :deployments="deployments"
+ :deployment-class="deploymentClass"
+ :has-deployment-metrics="hasDeploymentMetrics"
+ :visual-review-app-meta="visualReviewAppMeta"
+ :show-visual-review-app-link="showVisualReviewAppLink"
+ />
<merge-train-position-indicator
v-if="showMergeTrainPositionIndicator"
class="mr-widget-extension"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
index 428641a1109..84a21a25552 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
@@ -154,7 +154,7 @@ export default {
<status-icon status="success" />
<div class="media-body">
<h4 class="gl-display-flex">
- <span class="gl-mr-3">
+ <span class="gl-mr-3" data-qa-selector="merge_request_status_content">
<span class="js-status-text-before-author" data-testid="beforeStatusText">{{
statusTextBeforeAuthor
}}</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 2335e2984e4..23f415c3116 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
@@ -1,9 +1,6 @@
<script>
-import { GlButton, GlModalDirective, GlSkeletonLoader } from '@gitlab/ui';
-import $ from 'jquery';
-import { escape } from 'lodash';
-import { s__, sprintf } from '~/locale';
-import { mouseenter, debouncedMouseleave, togglePopover } from '~/shared/popover';
+import { GlButton, GlModalDirective, GlSkeletonLoader, GlPopover, GlLink } from '@gitlab/ui';
+import { s__ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import userPermissionsQuery from '../../queries/permissions.query.graphql';
@@ -16,6 +13,8 @@ export default {
GlSkeletonLoader,
StatusIcon,
GlButton,
+ GlPopover,
+ GlLink,
},
directives: {
GlModalDirective,
@@ -106,48 +105,11 @@ export default {
return this.showResolveButton && this.sourceBranchProtected;
},
},
- watch: {
- showPopover: {
- handler(newVal) {
- if (newVal) {
- this.$nextTick(this.initPopover);
- }
- },
- immediate: true,
- },
- },
- methods: {
- initPopover() {
- const $el = $(this.$refs.popover);
-
- $el
- .popover({
- html: true,
- trigger: 'focus',
- container: 'body',
- placement: 'top',
- template:
- '<div class="popover" role="tooltip"><div class="arrow"></div><p class="popover-header"></p><div class="popover-body"></div></div>',
- title: s__(
- 'mrWidget|This feature merges changes from the target branch to the source branch. You cannot use this feature since the source branch is protected.',
- ),
- content: sprintf(
- s__('mrWidget|%{link_start}Learn more about resolving conflicts%{link_end}'),
- {
- link_start: `<a href="${escape(
- this.mr.conflictsDocsPath,
- )}" target="_blank" rel="noopener noreferrer">`,
- link_end: '</a>',
- },
- false,
- ),
- })
- .on('mouseenter', mouseenter)
- .on('mouseleave', debouncedMouseleave(300))
- .on('show.bs.popover', () => {
- window.addEventListener('scroll', togglePopover.bind($el, false), { once: true });
- });
- },
+ i18n: {
+ title: s__(
+ 'mrWidget|This feature merges changes from the target branch to the source branch. You cannot use this feature since the source branch is protected.',
+ ),
+ linkText: s__('mrWidget|Learn more about resolving conflicts'),
},
};
</script>
@@ -162,7 +124,7 @@ export default {
<rect x="250" y="7" width="84" height="16" rx="4" />
</gl-skeleton-loader>
</div>
- <div v-else class="media-body space-children">
+ <div v-else class="media-body space-children gl-display-flex gl-align-items-center">
<span v-if="shouldBeRebased" class="bold">
{{
s__(`mrWidget|Fast-forward merge is not possible.
@@ -181,17 +143,35 @@ export default {
</span>
<span v-if="showResolveButton" ref="popover">
<gl-button
- :href="!sourceBranchProtected && mr.conflictResolutionPath"
+ :href="mr.conflictResolutionPath"
:disabled="sourceBranchProtected"
- class="js-resolve-conflicts-button"
+ data-testid="resolve-conflicts-button"
>
{{ s__('mrWidget|Resolve conflicts') }}
</gl-button>
+ <gl-popover
+ v-if="showPopover"
+ :target="() => $refs.popover"
+ placement="top"
+ triggers="hover focus"
+ >
+ <template #title>
+ <div class="gl-font-weight-normal gl-font-base">
+ {{ $options.i18n.title }}
+ </div>
+ </template>
+
+ <div class="gl-text-center">
+ <gl-link :href="mr.conflictsDocsPath" target="_blank" rel="noopener noreferrer">
+ {{ $options.i18n.linkText }}
+ </gl-link>
+ </div>
+ </gl-popover>
</span>
<gl-button
v-if="canMerge"
v-gl-modal-directive="'modal-merge-info'"
- class="js-merge-locally-button"
+ data-testid="merge-locally-button"
>
{{ s__('mrWidget|Merge locally') }}
</gl-button>
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 d15794c71b1..33ca582583b 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
@@ -176,16 +176,22 @@ export default {
<gl-button
:loading="isMakingRequest"
variant="success"
- class="qa-mr-rebase-button"
+ data-qa-selector="mr_rebase_button"
@click="rebase"
>
{{ __('Rebase') }}
</gl-button>
- <span v-if="!rebasingError" class="gl-font-weight-bold" data-testid="rebase-message">{{
- __(
- 'Fast-forward merge is not possible. Rebase the source branch onto the target branch.',
- )
- }}</span>
+ <span
+ v-if="!rebasingError"
+ class="gl-font-weight-bold"
+ data-testid="rebase-message"
+ data-qa-selector="no_fast_forward_message_content"
+ >{{
+ __(
+ 'Fast-forward merge is not possible. Rebase the source branch onto the target branch.',
+ )
+ }}</span
+ >
<span v-else class="gl-font-weight-bold danger" data-testid="rebase-message">{{
rebasingError
}}</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
index f0259a975db..01e0b91bd4a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
@@ -1,12 +1,15 @@
<script>
/* eslint-disable vue/no-v-html */
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlSprintf, GlLink } from '@gitlab/ui';
import emptyStateSVG from 'icons/_mr_widget_empty_state.svg';
+import { helpPagePath } from '~/helpers/help_page_helper';
export default {
name: 'MRWidgetNothingToMerge',
components: {
GlButton,
+ GlSprintf,
+ GlLink,
},
props: {
mr: {
@@ -17,6 +20,7 @@ export default {
data() {
return { emptyStateSVG };
},
+ ciHelpPage: helpPagePath('/ci/quick_start/index.html'),
};
</script>
@@ -30,25 +34,20 @@ export default {
</div>
<div class="text col-md-7 order-md-first col-12">
<p class="highlight">
- {{
- s__(
- 'mrWidgetNothingToMerge|Merge requests are a place to propose changes you have made to a project and discuss those changes with others.',
- )
- }}
+ {{ s__('mrWidgetNothingToMerge|This merge request contains no changes.') }}
</p>
<p>
- {{
- s__(
- 'mrWidgetNothingToMerge|Interested parties can even contribute by pushing commits if they want to.',
- )
- }}
- </p>
- <p>
- {{
- s__(
- "mrWidgetNothingToMerge|Currently there are no changes in this merge request's source branch. Please push new commits or use a different branch.",
- )
- }}
+ <gl-sprintf
+ :message="
+ s__(
+ 'mrWidgetNothingToMerge|Use merge requests to propose changes to your project and discuss them with your team. To make changes, push a commit or edit this merge request to use a different branch. With %{linkStart}CI/CD%{linkEnd}, automatically test your changes before merging.',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="$options.ciHelpPage" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
</p>
<div>
<gl-button
@@ -56,6 +55,7 @@ export default {
:href="mr.newBlobPath"
category="secondary"
variant="success"
+ data-testid="createFileButton"
>
{{ __('Create file') }}
</gl-button>
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 690b6e9c462..62c5cd90035 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
@@ -5,6 +5,7 @@ import {
GlButtonGroup,
GlDropdown,
GlDropdownItem,
+ GlFormCheckbox,
GlSprintf,
GlLink,
GlTooltipDirective,
@@ -81,6 +82,7 @@ export default {
GlButtonGroup,
GlDropdown,
GlDropdownItem,
+ GlFormCheckbox,
GlSkeletonLoader,
MergeTrainHelperText: () =>
import('ee_component/vue_merge_request_widget/components/merge_train_helper_text.vue'),
@@ -453,12 +455,13 @@ export default {
<div class="mr-widget-body media" :class="{ 'gl-pb-3': shouldRenderMergeTrainHelperText }">
<status-icon :status="iconClass" />
<div class="media-body">
- <div class="mr-widget-body-controls media space-children">
- <gl-button-group>
+ <div class="mr-widget-body-controls gl-display-flex gl-align-items-center">
+ <gl-button-group class="gl-align-self-start">
<gl-button
size="medium"
category="primary"
- class="qa-merge-button accept-merge-request"
+ class="accept-merge-request"
+ data-testid="merge-button"
:variant="mergeButtonVariant"
:disabled="isMergeButtonDisabled"
:loading="isMakingRequest"
@@ -481,7 +484,7 @@ export default {
<gl-dropdown-item
icon-name="warning"
button-class="accept-merge-request js-merge-immediately-button"
- data-qa-selector="merge_immediately_option"
+ data-qa-selector="merge_immediately_menu_item"
@click="handleMergeImmediatelyButtonClick"
>
{{ __('Merge immediately') }}
@@ -493,47 +496,48 @@ export default {
/>
</gl-dropdown>
</gl-button-group>
- <div class="media-body-wrap space-children">
- <template v-if="shouldShowMergeControls">
- <label v-if="canRemoveSourceBranch">
- <input
- id="remove-source-branch-input"
- v-model="removeSourceBranch"
- :disabled="isRemoveSourceBranchButtonDisabled"
- class="js-remove-source-branch-checkbox"
- type="checkbox"
- />
- {{ __('Delete source branch') }}
- </label>
-
- <!-- Placeholder for EE extension of this component -->
- <squash-before-merge
- v-if="shouldShowSquashBeforeMerge"
- v-model="squashBeforeMerge"
- :help-path="mr.squashBeforeMergeHelpPath"
- :is-disabled="isSquashReadOnly"
- />
- </template>
- <template v-else>
- <div class="bold js-resolve-mr-widget-items-message">
- <div
- v-if="hasPipelineMustSucceedConflict"
- class="gl-display-flex gl-align-items-center"
- data-testid="pipeline-succeed-conflict"
+ <div
+ v-if="shouldShowMergeControls"
+ class="gl-display-flex gl-align-items-center gl-flex-wrap"
+ >
+ <gl-form-checkbox
+ v-if="canRemoveSourceBranch"
+ id="remove-source-branch-input"
+ v-model="removeSourceBranch"
+ :disabled="isRemoveSourceBranchButtonDisabled"
+ class="js-remove-source-branch-checkbox gl-mx-3 gl-display-flex gl-align-items-center"
+ >
+ {{ __('Delete source branch') }}
+ </gl-form-checkbox>
+
+ <!-- Placeholder for EE extension of this component -->
+ <squash-before-merge
+ v-if="shouldShowSquashBeforeMerge"
+ v-model="squashBeforeMerge"
+ :help-path="mr.squashBeforeMergeHelpPath"
+ :is-disabled="isSquashReadOnly"
+ class="gl-mx-3"
+ />
+ </div>
+ <template v-else>
+ <div class="bold js-resolve-mr-widget-items-message gl-ml-3">
+ <div
+ v-if="hasPipelineMustSucceedConflict"
+ class="gl-display-flex gl-align-items-center"
+ data-testid="pipeline-succeed-conflict"
+ >
+ <gl-sprintf :message="pipelineMustSucceedConflictText" />
+ <gl-link
+ :href="mr.pipelineMustSucceedDocsPath"
+ target="_blank"
+ class="gl-display-flex gl-ml-2"
>
- <gl-sprintf :message="pipelineMustSucceedConflictText" />
- <gl-link
- :href="mr.pipelineMustSucceedDocsPath"
- target="_blank"
- class="gl-display-flex gl-ml-2"
- >
- <gl-icon name="question" />
- </gl-link>
- </div>
- <gl-sprintf v-else :message="mergeDisabledText" />
+ <gl-icon name="question" />
+ </gl-link>
</div>
- </template>
- </div>
+ <gl-sprintf v-else :message="mergeDisabledText" />
+ </div>
+ </template>
</div>
<div v-if="isSHAMismatch" class="d-flex align-items-center mt-2 js-sha-mismatch">
<gl-icon name="warning-solid" class="text-warning mr-1" />
@@ -559,7 +563,11 @@ export default {
:merge-train-when-pipeline-succeeds-docs-path="mr.mergeTrainWhenPipelineSucceedsDocsPath"
/>
<template v-if="shouldShowMergeControls">
- <div v-if="!shouldShowMergeEdit" class="mr-fast-forward-message">
+ <div
+ v-if="!shouldShowMergeEdit"
+ class="mr-fast-forward-message"
+ data-qa-selector="fast_forward_message_content"
+ >
{{ __('Fast-forward merge without a merge commit') }}
</div>
<commits-header
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
index 8acca0d6481..89edf588213 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
@@ -13,7 +13,7 @@ export default {
<div class="mr-widget-body media">
<status-icon :show-disabled-button="true" status="warning" />
<div class="media-body space-children">
- <span class="bold">
+ <span class="bold" data-qa-selector="head_mismatch_content">
{{
s__(`mrWidget|The source branch HEAD has recently changed.
Please reload the page and review the changes before merging`)
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
index 12fdfe601a4..6388b817e46 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
@@ -44,7 +44,7 @@ export default {
:checked="value"
:disabled="isDisabled"
name="squash"
- class="qa-squash-checkbox js-squash-checkbox gl-mb-0 gl-mr-2"
+ class="qa-squash-checkbox js-squash-checkbox gl-mr-2 gl-display-flex gl-align-items-center"
:title="tooltipTitle"
@change="(checked) => $emit('input', checked)"
>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
index af305815381..f0c624c5d8d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
@@ -70,7 +70,7 @@ export default {
data: {
mergeRequestSetWip: {
errors,
- mergeRequest: { workInProgress, title },
+ mergeRequest: { mergeableDiscussionsState, workInProgress, title },
},
},
},
@@ -87,9 +87,8 @@ export default {
});
const data = produce(sourceData, (draftState) => {
- // eslint-disable-next-line no-param-reassign
+ draftState.project.mergeRequest.mergeableDiscussionsState = mergeableDiscussionsState;
draftState.project.mergeRequest.workInProgress = workInProgress;
- // eslint-disable-next-line no-param-reassign
draftState.project.mergeRequest.title = title;
});
@@ -107,6 +106,7 @@ export default {
errors: [],
mergeRequest: {
__typename: 'MergeRequest',
+ mergeableDiscussionsState: true,
title: this.mr.title,
workInProgress: false,
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js b/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js
index 96e8bb45e34..7b77d7475bc 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js
@@ -7,9 +7,4 @@ export default {
return [];
},
},
- methods: {
- hasDownstream() {
- return false;
- },
- },
};
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
index 2a53eb8b241..89b095fbfc1 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
@@ -14,11 +14,10 @@ import { deprecatedCreateFlash as createFlash } from '../flash';
import { setFaviconOverlay } from '../lib/utils/favicon';
import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue';
import GroupedCodequalityReportsApp from '../reports/codequality_report/grouped_codequality_reports_app.vue';
-import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue';
+import GroupedTestReportsApp from '../reports/grouped_test_report/grouped_test_reports_app.vue';
import Loading from './components/loading.vue';
import MrWidgetAlertMessage from './components/mr_widget_alert_message.vue';
import WidgetHeader from './components/mr_widget_header.vue';
-import WidgetMergeHelp from './components/mr_widget_merge_help.vue';
import MrWidgetPipelineContainer from './components/mr_widget_pipeline_container.vue';
import WidgetRelatedLinks from './components/mr_widget_related_links.vue';
import WidgetSuggestPipeline from './components/mr_widget_suggest_pipeline.vue';
@@ -59,7 +58,6 @@ export default {
// ExtensionsContainer,
'mr-widget-header': WidgetHeader,
'mr-widget-suggest-pipeline': WidgetSuggestPipeline,
- 'mr-widget-merge-help': WidgetMergeHelp,
MrWidgetPipelineContainer,
'mr-widget-related-links': WidgetRelatedLinks,
MrWidgetAlertMessage,
@@ -140,9 +138,6 @@ export default {
componentName() {
return stateMaps.stateToComponentMap[this.mr.state];
},
- shouldRenderMergeHelp() {
- return stateMaps.statesToShowHelpWidget.indexOf(this.mr.state) > -1;
- },
hasPipelineMustSucceedConflict() {
return !this.mr.hasCI && this.mr.onlyAllowMergeIfPipelineSucceeds;
},
@@ -530,9 +525,6 @@ export default {
<source-branch-removal-status v-if="shouldRenderSourceBranchRemovalStatus" />
</div>
</div>
- <div v-if="shouldRenderMergeHelp" class="mr-widget-footer">
- <mr-widget-merge-help />
- </div>
</div>
<mr-widget-pipeline-container
v-if="shouldRenderMergedPipeline"
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql
index 37abe5ddf3c..cfaa198d516 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql
@@ -1,6 +1,7 @@
mutation toggleWIPStatus($projectPath: ID!, $iid: String!, $wip: Boolean!) {
mergeRequestSetWip(input: { projectPath: $projectPath, iid: $iid, wip: $wip }) {
mergeRequest {
+ mergeableDiscussionsState
title
workInProgress
}
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 a0f14f558d2..7ccbd771379 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
@@ -177,7 +177,7 @@ export default class MergeRequestStore {
this.ciStatus = `${this.ciStatus}-with-warnings`;
}
- this.commitsCount = mergeRequest.commitCount || 10;
+ this.commitsCount = mergeRequest.commitCount;
this.branchMissing = !mergeRequest.sourceBranchExists || !mergeRequest.targetBranchExists;
this.hasConflicts = mergeRequest.conflicts;
this.hasMergeableDiscussionsState = mergeRequest.mergeableDiscussionsState === false;
@@ -241,6 +241,7 @@ export default class MergeRequestStore {
this.reviewingDocsPath = data.reviewing_and_managing_merge_requests_docs_path;
this.ciEnvironmentsStatusPath = data.ci_environments_status_path;
this.securityApprovalsHelpPagePath = data.security_approvals_help_page_path;
+ this.licenseComplianceDocsPath = data.license_compliance_docs_path;
this.eligibleApproversDocsPath = data.eligible_approvers_docs_path;
this.mergeImmediatelyDocsPath = data.merge_immediately_docs_path;
this.approvalsHelpPath = data.approvals_help_path;
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
index 0af5d028a2a..f7b49a85b83 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
@@ -11,12 +11,12 @@ import {
GlButton,
GlSafeHtmlDirective,
} from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user';
import { fetchPolicies } from '~/lib/graphql';
import { toggleContainerClasses } from '~/lib/utils/dom_utils';
import { visitUrl, joinPaths } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
-import * as Sentry from '~/sentry/wrapper';
import Tracking from '~/tracking';
import initUserPopovers from '~/user_popovers';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
@@ -222,7 +222,9 @@ export default {
});
},
incidentPath(issueId) {
- return joinPaths(this.projectIssuesPath, issueId);
+ return this.isThreatMonitoringPage
+ ? joinPaths(this.projectIssuesPath, issueId)
+ : joinPaths(this.projectIssuesPath, 'incident', issueId);
},
trackPageViews() {
const { category, action } = this.trackAlertsDetailsViewsOptions;
@@ -268,10 +270,10 @@ export default {
</span>
</div>
<gl-button
- v-if="alert.issueIid"
+ v-if="alert.issue"
class="gl-mt-3 mt-sm-0 align-self-center align-self-sm-baseline alert-details-incident-button"
data-testid="viewIncidentBtn"
- :href="incidentPath(alert.issueIid)"
+ :href="incidentPath(alert.issue.iid)"
category="primary"
variant="success"
>
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue
index dd4faa03c00..9d5006564ef 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue
@@ -1,7 +1,7 @@
<script>
+import * as Sentry from '@sentry/browser';
import Vue from 'vue';
import Vuex from 'vuex';
-import * as Sentry from '~/sentry/wrapper';
Vue.use(Vuex);
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
index 39ac6c7feca..271f0b4e4bb 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
@@ -116,7 +116,6 @@ export default {
});
const data = produce(sourceData, (draftData) => {
- // eslint-disable-next-line no-param-reassign
draftData.project.alertManagementAlerts.nodes[0].todos.nodes = [];
});
diff --git a/app/assets/javascripts/vue_shared/alert_details/index.js b/app/assets/javascripts/vue_shared/alert_details/index.js
index 3ea43d7a843..50f2e63702b 100644
--- a/app/assets/javascripts/vue_shared/alert_details/index.js
+++ b/app/assets/javascripts/vue_shared/alert_details/index.js
@@ -20,7 +20,6 @@ export default (selector) => {
toggleSidebarStatus: (_, __, { cache }) => {
const sourceData = cache.readQuery({ query: sidebarStatusQuery });
const data = produce(sourceData, (draftData) => {
- // eslint-disable-next-line no-param-reassign
draftData.sidebarStatus = !draftData.sidebarStatus;
});
cache.writeQuery({ query: sidebarStatusQuery, data });
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
index ce67d33d4a1..82b3545117f 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -2,7 +2,9 @@
/* eslint-disable vue/no-v-html */
import { GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui';
import { groupBy } from 'lodash';
+import EmojiPicker from '~/emoji/components/picker.vue';
import { __, sprintf } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { glEmojiTag } from '../../emoji';
// Internal constant, specific to this component, used when no `currentUserId` is given
@@ -12,10 +14,12 @@ export default {
components: {
GlButton,
GlIcon,
+ EmojiPicker,
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
awards: {
type: Array,
@@ -166,7 +170,25 @@ export default {
<span class="js-counter">{{ awardList.list.length }}</span>
</gl-button>
<div v-if="canAwardEmoji" class="award-menu-holder">
+ <emoji-picker
+ v-if="glFeatures.improvedEmojiPicker"
+ toggle-class="add-reaction-button gl-relative!"
+ @click="handleAward"
+ >
+ <template #button-content>
+ <span class="reaction-control-icon reaction-control-icon-neutral">
+ <gl-icon name="slight-smile" />
+ </span>
+ <span class="reaction-control-icon reaction-control-icon-positive">
+ <gl-icon name="smiley" />
+ </span>
+ <span class="reaction-control-icon reaction-control-icon-super-positive">
+ <gl-icon name="smile" />
+ </span>
+ </template>
+ </emoji-picker>
<gl-button
+ v-else
v-gl-tooltip.viewport
:class="addButtonClass"
class="add-reaction-button js-add-award"
diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
index 14e99977a85..4b53f55b856 100644
--- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
@@ -82,7 +82,13 @@ export default {
data-qa-selector="changed_file_icon_content"
:data-qa-title="tooltipTitle"
>
- <gl-icon v-if="showIcon" :name="changedIcon" :size="size" :class="changedIconClass" />
+ <gl-icon
+ v-if="showIcon"
+ :name="changedIcon"
+ :size="size"
+ :class="changedIconClass"
+ use-deprecated-sizes
+ />
</span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue
index 07bd6019b80..dbf459cb289 100644
--- a/app/assets/javascripts/vue_shared/components/ci_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue
@@ -64,6 +64,12 @@ export default {
</script>
<template>
<span :class="cssClass">
- <gl-icon :name="icon" :size="size" :class="cssClasses" :aria-label="status.icon" />
+ <gl-icon
+ :name="icon"
+ :size="size"
+ :class="cssClasses"
+ :aria-label="status.icon"
+ use-deprecated-sizes
+ />
</span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
index bf1361f1a6a..fe329b18f30 100644
--- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue
+++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
@@ -46,6 +46,11 @@ export default {
required: false,
default: false,
},
+ tooltipBoundary: {
+ type: String,
+ required: false,
+ default: null,
+ },
cssClass: {
type: String,
required: false,
@@ -75,8 +80,11 @@ export default {
<template>
<gl-button
- v-gl-tooltip="{ placement: tooltipPlacement, container: tooltipContainer }"
- v-gl-tooltip.hover.blur
+ v-gl-tooltip.hover.blur.viewport="{
+ placement: tooltipPlacement,
+ container: tooltipContainer,
+ boundary: tooltipBoundary,
+ }"
:class="cssClass"
:title="title"
:data-clipboard-text="clipboardText"
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
index d6f99e9a049..b3edd05b0ee 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
@@ -37,9 +37,11 @@ export default {
required: true,
},
},
- data: () => ({
- state: STATE_IDLING,
- }),
+ data() {
+ return {
+ state: STATE_IDLING,
+ };
+ },
computed: {
shortSha() {
return truncateSha(this.diffFile.content_sha);
diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue
index 8ac8a3beb7d..4244cab902a 100644
--- a/app/assets/javascripts/vue_shared/components/file_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/file_icon.vue
@@ -86,7 +86,7 @@ export default {
<template>
<span>
<gl-loading-icon v-if="loading" :inline="true" />
- <gl-icon v-else-if="isSymlink" name="symlink" :size="size" />
+ <gl-icon v-else-if="isSymlink" name="symlink" :size="size" use-deprecated-sizes />
<svg v-else-if="!folder" :key="spriteHref" :class="[iconSizeClass, cssClasses]">
<use v-bind="{ 'xlink:href': spriteHref }" />
</svg>
@@ -95,6 +95,7 @@ export default {
:name="folderIconName"
:size="size"
class="folder-icon"
+ use-deprecated-sizes
data-qa-selector="folder_icon_content"
/>
</span>
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
index 80ca62a0e9b..b4cac13168a 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -102,7 +102,7 @@ export default {
data-qa-selector="pipeline_header"
data-testid="ci-header-content"
>
- <section class="header-main-content">
+ <section class="header-main-content gl-mr-3">
<ci-icon-badge :status="status" />
<strong data-testid="ci-header-item-text"> {{ itemName }} #{{ itemId }} </strong>
@@ -142,12 +142,16 @@ export default {
</template>
</section>
- <section v-if="$slots.default" data-testid="ci-header-action-buttons" class="gl-display-flex">
+ <section
+ v-if="$slots.default"
+ data-testid="ci-header-action-buttons"
+ class="gl-display-flex gl-mr-3"
+ >
<slot></slot>
</section>
<gl-button
v-if="hasSidebarButton"
- class="d-sm-none js-sidebar-build-toggle gl-ml-auto"
+ class="gl-md-display-none gl-ml-auto gl-align-self-start js-sidebar-build-toggle"
icon="chevron-double-lg-left"
:aria-label="__('Toggle sidebar')"
@click="onClickSidebarButton"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
index 4c6fa71398d..7c28e74e256 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -62,9 +62,6 @@ export default {
canBeBatched() {
return Boolean(this.glFeatures.batchSuggestions);
},
- canAddCustomCommitMessage() {
- return this.glFeatures.suggestionsCustomCommit;
- },
isApplying() {
return this.isApplyingSingle || this.isApplyingBatch;
},
@@ -89,11 +86,7 @@ export default {
if (!this.canApply) return;
this.isApplyingSingle = true;
- this.$emit(
- 'apply',
- this.applySuggestionCallback,
- gon.features?.suggestionsCustomCommit ? message : undefined,
- );
+ this.$emit('apply', this.applySuggestionCallback, message);
},
applySuggestionCallback() {
this.isApplyingSingle = false;
@@ -114,7 +107,7 @@ export default {
<template>
<div class="md-suggestion-header border-bottom-0 mt-2">
- <div class="qa-suggestion-diff-header js-suggestion-diff-header font-weight-bold">
+ <div class="js-suggestion-diff-header font-weight-bold">
{{ __('Suggested change') }}
<a v-if="helpPagePath" :href="helpPagePath" :aria-label="__('Help')" class="js-help-btn">
<gl-icon name="question-o" css-classes="link-highlight" />
@@ -158,23 +151,12 @@ export default {
{{ __('Add suggestion to batch') }}
</gl-button>
<apply-suggestion
- v-if="canAddCustomCommitMessage"
+ v-if="isLoggedIn"
:disabled="isDisableButton"
:default-commit-message="defaultCommitMessage"
class="gl-ml-3"
@apply="applySuggestion"
/>
- <span v-else v-gl-tooltip.viewport="tooltipMessage" tabindex="0">
- <gl-button
- v-if="isLoggedIn"
- class="btn-inverted js-apply-btn btn-grouped"
- :disabled="isDisableButton"
- variant="success"
- @click="applySuggestion"
- >
- {{ __('Apply suggestion') }}
- </gl-button>
- </span>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue b/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue
new file mode 100644
index 00000000000..35f9ac14681
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue
@@ -0,0 +1,3 @@
+<template>
+ <div class="timeline-icon"><slot></slot></div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/registry/code_instruction.vue b/app/assets/javascripts/vue_shared/components/registry/code_instruction.vue
index bc7f8a2b17a..1a85a641dd1 100644
--- a/app/assets/javascripts/vue_shared/components/registry/code_instruction.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/code_instruction.vue
@@ -56,27 +56,29 @@ export default {
</script>
<template>
- <div v-if="!multiline" class="gl-mb-3">
+ <div>
<label v-if="label" :for="generateFormId('instruction-input')">{{ label }}</label>
- <div class="input-group gl-mb-3">
- <input
- :id="generateFormId('instruction-input')"
- :value="instruction"
- type="text"
- class="form-control gl-font-monospace"
- data-testid="instruction-input"
- readonly
- @copy="trackCopy"
- />
- <span class="input-group-append" data-testid="instruction-button" @click="trackCopy">
- <clipboard-button :text="instruction" :title="copyText" class="input-group-text" />
- </span>
+ <div v-if="!multiline" class="gl-mb-3">
+ <div class="input-group gl-mb-3">
+ <input
+ :id="generateFormId('instruction-input')"
+ :value="instruction"
+ type="text"
+ class="form-control gl-font-monospace"
+ data-testid="instruction-input"
+ readonly
+ @copy="trackCopy"
+ />
+ <span class="input-group-append" data-testid="instruction-button" @click="trackCopy">
+ <clipboard-button :text="instruction" :title="copyText" class="input-group-text" />
+ </span>
+ </div>
</div>
- </div>
- <div v-else>
- <pre class="gl-font-monospace" data-testid="multiline-instruction" @copy="trackCopy">{{
- instruction
- }}</pre>
+ <div v-else>
+ <pre class="gl-font-monospace" data-testid="multiline-instruction" @copy="trackCopy">{{
+ instruction
+ }}</pre>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/registry/list_item.vue b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
index 9db5d6953d7..4ade75e705e 100644
--- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
@@ -54,7 +54,7 @@ export default {
class="gl-display-flex gl-flex-direction-column gl-border-b-solid gl-border-t-solid gl-border-t-1 gl-border-b-1"
:class="optionalClasses"
>
- <div class="gl-display-flex gl-align-items-center gl-py-3">
+ <div class="gl-display-flex gl-align-items-center gl-py-3 gl-px-5">
<div
v-if="$slots['left-action']"
class="gl-w-7 gl-display-none gl-sm-display-flex gl-justify-content-start gl-pl-2"
diff --git a/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue b/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue
new file mode 100644
index 00000000000..36b1a9c49f4
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue
@@ -0,0 +1,59 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+
+export default {
+ name: 'PersistedDropdownSelection',
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ LocalStorageSync,
+ },
+ props: {
+ options: {
+ type: Array,
+ required: true,
+ },
+ storageKey: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ selected: null,
+ };
+ },
+ computed: {
+ dropdownText() {
+ const selected = this.parsedOptions.find((o) => o.selected);
+ return selected?.label || this.options[0].label;
+ },
+ parsedOptions() {
+ return this.options.map((o) => ({ ...o, selected: o.value === this.selected }));
+ },
+ },
+ methods: {
+ setSelected(value) {
+ this.selected = value;
+ this.$emit('change', value);
+ },
+ },
+};
+</script>
+
+<template>
+ <local-storage-sync :storage-key="storageKey" :value="selected" @input="setSelected">
+ <gl-dropdown :text="dropdownText" lazy>
+ <gl-dropdown-item
+ v-for="option in parsedOptions"
+ :key="option.value"
+ :is-checked="option.selected"
+ :is-check-item="true"
+ @click="setSelected(option.value)"
+ >
+ {{ option.label }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </local-storage-sync>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/select2_select.vue b/app/assets/javascripts/vue_shared/components/select2_select.vue
index 6574a5ddfde..bb1a8fae7b0 100644
--- a/app/assets/javascripts/vue_shared/components/select2_select.vue
+++ b/app/assets/javascripts/vue_shared/components/select2_select.vue
@@ -20,6 +20,12 @@ export default {
},
},
+ watch: {
+ value() {
+ $(this.$refs.dropdownInput).val(this.value).trigger('change');
+ },
+ },
+
mounted() {
loadCSSFile(gon.select2_css_path)
.then(() => {
diff --git a/app/assets/javascripts/vue_shared/components/settings/settings_block.vue b/app/assets/javascripts/vue_shared/components/settings/settings_block.vue
index 31094b985a2..92ae4575c52 100644
--- a/app/assets/javascripts/vue_shared/components/settings/settings_block.vue
+++ b/app/assets/javascripts/vue_shared/components/settings/settings_block.vue
@@ -5,6 +5,11 @@ import { __ } from '~/locale';
export default {
components: { GlButton },
props: {
+ slideAnimated: {
+ type: Boolean,
+ default: true,
+ required: false,
+ },
defaultExpanded: {
type: Boolean,
default: false,
@@ -28,7 +33,7 @@ export default {
</script>
<template>
- <section class="settings no-animate" :class="{ expanded }">
+ <section class="settings" :class="{ 'no-animate': !slideAnimated, expanded }">
<div class="settings-header">
<h4><slot name="title"></slot></h4>
<gl-button @click="sectionExpanded = !sectionExpanded">
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
index f173c8db540..46ccb9470e5 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
@@ -21,11 +21,14 @@ export default {
'allowLabelRemove',
'allowScopedLabels',
'labelsFilterBasePath',
+ 'labelsFilterParam',
]),
},
methods: {
labelFilterUrl(label) {
- return `${this.labelsFilterBasePath}?label_name[]=${encodeURIComponent(label.title)}`;
+ return `${this.labelsFilterBasePath}?${this.labelsFilterParam}[]=${encodeURIComponent(
+ label.title,
+ )}`;
},
scopedLabel(label) {
return this.allowScopedLabels && isScopedLabel(label);
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
index 93fdae19a8d..426ae430ce7 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
@@ -81,6 +81,11 @@ export default {
required: false,
default: '',
},
+ labelsFilterParam: {
+ type: String,
+ required: false,
+ default: 'label_name',
+ },
dropdownButtonText: {
type: String,
required: false,
@@ -156,6 +161,7 @@ export default {
labelsFetchPath: this.labelsFetchPath,
labelsManagePath: this.labelsManagePath,
labelsFilterBasePath: this.labelsFilterBasePath,
+ labelsFilterParam: this.labelsFilterParam,
labelsListTitle: this.labelsListTitle,
labelsCreateTitle: this.labelsCreateTitle,
footerCreateLabelTitle: this.footerCreateLabelTitle,
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
index 132abcab82b..ef5f052527b 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
@@ -1,10 +1,11 @@
<script>
-import { GlDropdown, GlDropdownForm } from '@gitlab/ui';
+import { GlDropdown, GlDropdownForm, GlDropdownDivider } from '@gitlab/ui';
export default {
components: {
GlDropdownForm,
GlDropdown,
+ GlDropdownDivider,
},
props: {
headerText: {
@@ -20,8 +21,12 @@ export default {
</script>
<template>
- <gl-dropdown class="show" :text="text" :header-text="headerText" @toggle="$emit('toggle')">
- <slot name="search"></slot>
+ <gl-dropdown class="show" :text="text" @toggle="$emit('toggle')">
+ <template #header>
+ <p class="gl-font-weight-bold gl-text-center gl-mt-2 gl-mb-4">{{ headerText }}</p>
+ <gl-dropdown-divider />
+ <slot name="search"></slot>
+ </template>
<gl-dropdown-form>
<slot name="items"></slot>
</gl-dropdown-form>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
index 62c0b05426b..459ea27e9cd 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
@@ -1,8 +1,10 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
query issueParticipants($fullPath: ID!, $iid: String!) {
- project(fullPath: $fullPath) {
+ workspace: project(fullPath: $fullPath) {
+ __typename
issuable: issue(iid: $iid) {
+ __typename
id
participants {
nodes {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
index a75ce85a1dc..43bd9f17e9a 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
@@ -1,7 +1,7 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
query getMrParticipants($fullPath: ID!, $iid: String!) {
- project(fullPath: $fullPath) {
+ workspace: project(fullPath: $fullPath) {
issuable: mergeRequest(iid: $iid) {
id
participants {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql
index 2eb9bb4b07b..8ee8de2cb5c 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql
@@ -1,10 +1,10 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
- issueSetAssignees(
+ issuableSetAssignees: issueSetAssignees(
input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $fullPath }
) {
- issue {
+ issuable: issue {
id
assignees {
nodes {
diff --git a/app/assets/javascripts/vue_shared/components/tabs/tab.vue b/app/assets/javascripts/vue_shared/components/tabs/tab.vue
deleted file mode 100644
index d24c27cfcc3..00000000000
--- a/app/assets/javascripts/vue_shared/components/tabs/tab.vue
+++ /dev/null
@@ -1,47 +0,0 @@
-<script>
-export default {
- props: {
- title: {
- type: String,
- required: false,
- default: '',
- },
- active: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- // props can't be updated, so we map it to data where we can
- localActive: this.active,
- };
- },
- watch: {
- active() {
- this.localActive = this.active;
- },
- },
- created() {
- this.isTab = true;
- },
- updated() {
- if (this.$parent) {
- this.$parent.$forceUpdate();
- }
- },
-};
-</script>
-
-<template>
- <div
- :class="{
- active: localActive,
- }"
- class="tab-pane"
- role="tabpanel"
- >
- <slot></slot>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/tabs/tabs.js b/app/assets/javascripts/vue_shared/components/tabs/tabs.js
deleted file mode 100644
index 233df96a520..00000000000
--- a/app/assets/javascripts/vue_shared/components/tabs/tabs.js
+++ /dev/null
@@ -1,76 +0,0 @@
-export default {
- props: {
- stopPropagation: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- currentIndex: 0,
- tabs: [],
- };
- },
- mounted() {
- this.updateTabs();
- },
- methods: {
- updateTabs() {
- this.tabs = this.$children.filter((child) => child.isTab);
- this.currentIndex = this.tabs.findIndex((tab) => tab.localActive);
- },
- setTab(e, index) {
- if (this.stopPropagation) {
- e.stopPropagation();
- e.preventDefault();
- }
-
- this.tabs[this.currentIndex].localActive = false;
- this.tabs[index].localActive = true;
-
- this.currentIndex = index;
- },
- },
- render(h) {
- const navItems = this.tabs.map((tab, i) =>
- h(
- 'li',
- {
- key: i,
- },
- [
- h(
- 'a',
- {
- class: tab.localActive ? 'active' : null,
- attrs: {
- href: '#',
- },
- on: {
- click: (e) => this.setTab(e, i),
- },
- },
- tab.$slots.title || tab.title,
- ),
- ],
- ),
- );
- const nav = h(
- 'ul',
- {
- class: 'nav-links tab-links',
- },
- [navItems],
- );
- const content = h(
- 'div',
- {
- class: ['tab-content'],
- },
- [this.$slots.default],
- );
-
- return h('div', {}, [[nav], content]);
- },
-};
diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue
index 8aa6e29adf1..c5fdb5fc242 100644
--- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue
+++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue
@@ -1,11 +1,11 @@
<script>
+import { GlTooltipDirective as GlTooltip } from '@gitlab/ui';
import { isFunction } from 'lodash';
import { hasHorizontalOverflow } from '~/lib/utils/dom_utils';
-import tooltip from '../directives/tooltip';
export default {
directives: {
- tooltip,
+ GlTooltip,
},
props: {
title: {
@@ -59,9 +59,8 @@ export default {
<template>
<span
v-if="showTooltip"
- v-tooltip
+ v-gl-tooltip="{ placement }"
:title="title"
- :data-placement="placement"
class="js-show-tooltip gl-min-w-0"
>
<slot></slot>
diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
index 5a08e992084..afb1ea702fa 100644
--- a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
+++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
@@ -36,6 +36,11 @@ export default {
required: false,
default: () => [VALID_IMAGE_FILE_MIMETYPE.mimetype],
},
+ singleFileSelection: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -79,7 +84,7 @@ export default {
return;
}
- this.$emit('change', files);
+ this.$emit('change', this.singleFileSelection ? files[0] : files);
},
ondragenter(e) {
this.dragCounter += 1;
@@ -92,7 +97,7 @@ export default {
this.$refs.fileUpload.click();
},
onFileInputChange(e) {
- this.$emit('change', e.target.files);
+ this.$emit('change', this.singleFileSelection ? e.target.files[0] : e.target.files);
},
},
};
@@ -119,9 +124,15 @@ export default {
data-testid="dropzone-area"
>
<gl-icon name="upload" :size="iconStyles.size" :class="iconStyles.class" />
- <p class="gl-mb-0">
+ <p class="gl-mb-0" data-testid="upload-text">
<slot name="upload-text" :openFileUpload="openFileUpload">
- <gl-sprintf :message="__('Drop or %{linkStart}upload%{linkEnd} files to attach')">
+ <gl-sprintf
+ :message="
+ singleFileSelection
+ ? __('Drop or %{linkStart}upload%{linkEnd} file to attach')
+ : __('Drop or %{linkStart}upload%{linkEnd} files to attach')
+ "
+ >
<template #link="{ content }">
<gl-link @click.stop="openFileUpload">
{{ content }}
@@ -139,7 +150,7 @@ export default {
name="upload_file"
:accept="validFileMimetypes"
class="hide"
- multiple
+ :multiple="!singleFileSelection"
@change="onFileInputChange"
/>
</slot>
diff --git a/app/assets/javascripts/vue_shared/components/user_access_role_badge.vue b/app/assets/javascripts/vue_shared/components/user_access_role_badge.vue
new file mode 100644
index 00000000000..e5558c038b3
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_access_role_badge.vue
@@ -0,0 +1,22 @@
+<script>
+/**
+ * This component applies particular styling to GlBadge that isn't
+ * available in the current GlBadge variants.
+ * Where possible, prefer one of the supported GlBadge variants.
+ * Discussion issue: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1247
+ */
+import { GlBadge } from '@gitlab/ui';
+
+export default {
+ name: 'UserAccessRoleBadge',
+ components: {
+ GlBadge,
+ },
+};
+</script>
+
+<template>
+ <gl-badge class="gl-bg-transparent! gl-inset-border-1-gray-100!">
+ <slot></slot>
+ </gl-badge>
+</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 37bde089de8..dbd8efec948 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
@@ -12,11 +12,6 @@ import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
const MAX_SKELETON_LINES = 4;
-const SECURITY_BOT_USER_DATA = {
- username: 'GitLab-Security-Bot',
- name: 'GitLab Security Bot',
-};
-
export default {
name: 'UserPopover',
maxSkeletonLines: MAX_SKELETON_LINES,
@@ -56,15 +51,6 @@ export default {
userIsLoading() {
return !this.user?.loaded;
},
- isSecurityBot() {
- const { username, name, websiteUrl = '' } = this.user;
- return (
- gon.features?.securityAutoFix &&
- username === SECURITY_BOT_USER_DATA.username &&
- name === SECURITY_BOT_USER_DATA.name &&
- websiteUrl.length
- );
- },
availabilityStatus() {
return this.user?.status?.availability || '';
},
@@ -114,7 +100,7 @@ export default {
<div v-if="statusHtml" class="js-user-status gl-mt-3">
<span v-html="statusHtml"></span>
</div>
- <div v-if="isSecurityBot" class="gl-text-blue-500">
+ <div v-if="user.bot" class="gl-text-blue-500">
<gl-icon name="question" />
<gl-link data-testid="user-popover-bot-docs-link" :href="user.websiteUrl">
{{ sprintf(__('Learn more about %{username}'), { username: user.name }) }}
diff --git a/app/assets/javascripts/vue_shared/directives/tooltip.js b/app/assets/javascripts/vue_shared/directives/tooltip.js
deleted file mode 100644
index 0eb505bfce8..00000000000
--- a/app/assets/javascripts/vue_shared/directives/tooltip.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import $ from 'jquery';
-import '~/commons/bootstrap';
-import { parseBoolean } from '~/lib/utils/common_utils';
-
-export default {
- bind(el) {
- const glTooltipDelay = localStorage.getItem('gl-tooltip-delay');
- const delay = glTooltipDelay ? JSON.parse(glTooltipDelay) : 0;
-
- $(el).tooltip({
- trigger: 'hover',
- delay,
- // By default, sanitize is run even if there is no `html` or `template` present
- // so let's optimize to only run this when necessary.
- // https://github.com/twbs/bootstrap/blob/c5966de27395a407f9a3d20d0eb2ff8e8fb7b564/js/src/tooltip.js#L716
- sanitize: parseBoolean(el.dataset.html) || Boolean(el.dataset.template),
- });
- },
-
- componentUpdated(el) {
- $(el).tooltip('_fixTitle');
-
- // update visible tooltips
- const tooltipInstance = $(el).data('bs.tooltip');
- const tip = tooltipInstance.getTipElement();
- tooltipInstance.setElementContent(
- $(tip.querySelectorAll('.tooltip-inner')),
- tooltipInstance.getTitle(),
- );
- },
-
- unbind(el) {
- $(el).tooltip('dispose');
- },
-};
diff --git a/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js b/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js
index e1734809bce..c12ffaac40a 100644
--- a/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js
+++ b/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js
@@ -1,7 +1,12 @@
export default (Vue) => {
Vue.mixin({
provide: {
- glFeatures: { ...((window.gon && window.gon.features) || {}) },
+ glFeatures:
+ {
+ ...window.gon?.features,
+ // TODO: extract into glLicensedFeatures https://gitlab.com/gitlab-org/gitlab/-/issues/322460
+ ...window.gon?.licensed_features,
+ } || {},
},
});
};
diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
index 52ded0e0cc1..4a6edae0c06 100644
--- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
+++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
@@ -175,7 +175,7 @@ const mixins = {
return __('Merged');
}
- return this.isOpen ? __('Opened') : __('Closed');
+ return this.isOpen ? __('Created') : __('Closed');
},
stateTimeInWords() {
if (this.isMerged) {
diff --git a/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue b/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue
index 3c606283c7d..26bc9b5d60e 100644
--- a/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue
+++ b/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue
@@ -34,7 +34,7 @@ export default {
<span v-if="discoverProjectSecurityPath">
<gl-button
ref="discoverProjectSecurity"
- icon="information-o"
+ icon="question-o"
category="tertiary"
:aria-label="$options.i18n.upgradeToManageVulnerabilities"
/>
diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js
index aac5a5c1def..1cdcf87097f 100644
--- a/app/assets/javascripts/vue_shared/security_reports/constants.js
+++ b/app/assets/javascripts/vue_shared/security_reports/constants.js
@@ -18,11 +18,12 @@ export const REPORT_FILE_TYPES = {
*/
export const REPORT_TYPE_SAST = 'sast';
export const REPORT_TYPE_DAST = 'dast';
+export const REPORT_TYPE_DAST_PROFILES = 'dast_profiles';
export const REPORT_TYPE_SECRET_DETECTION = 'secret_detection';
export const REPORT_TYPE_DEPENDENCY_SCANNING = 'dependency_scanning';
export const REPORT_TYPE_CONTAINER_SCANNING = 'container_scanning';
export const REPORT_TYPE_COVERAGE_FUZZING = 'coverage_fuzzing';
-export const REPORT_TYPE_LICENSE_COMPLIANCE = 'license_compliance';
+export const REPORT_TYPE_LICENSE_COMPLIANCE = 'license_scanning';
export const REPORT_TYPE_API_FUZZING = 'api_fuzzing';
/**
diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss
index 20ff78d32d3..4a15e0eb458 100644
--- a/app/assets/stylesheets/_page_specific_files.scss
+++ b/app/assets/stylesheets/_page_specific_files.scss
@@ -14,7 +14,6 @@
@import './pages/issues';
@import './pages/labels';
@import './pages/login';
-@import './pages/members';
@import './pages/merge_requests';
@import './pages/monitor';
@import './pages/note_form';
@@ -35,5 +34,4 @@
@import './pages/sherlock';
@import './pages/storage_quota';
@import './pages/tree';
-@import './pages/trials';
@import './pages/users';
diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss
index deeef86c386..a5cfc8d12b0 100644
--- a/app/assets/stylesheets/bootstrap_migration.scss
+++ b/app/assets/stylesheets/bootstrap_migration.scss
@@ -170,12 +170,6 @@ table {
display: none;
}
-h3.popover-header {
- // Default bootstrap popovers use <h3>
- // which we default to having a top margin
- margin-top: 0;
-}
-
// Add to .label so that old system notes that are saved to the db
// will still receive the correct styling
.badge:not(.gl-badge),
@@ -198,7 +192,15 @@ h3.popover-header {
}
.divider {
- @extend .dropdown-divider;
+ // copied rules from node_modules/bootstrap/scss/_dropdown.scss:116
+ // this might be safe to just remove instead
+ // most places that use divider add overrides to undo these things
+ // there is also a probably-unintentional use in deprecated_dropdown_divider.scss
+ // so we would end up with .gl-dropdown .dropdown-divider
+ height: 0;
+ margin: 4px 0;
+ overflow: hidden;
+ border-top: 1px solid $border-color;
}
.info-well {
@@ -231,12 +233,9 @@ h3.popover-header {
}
.card {
- &.card-without-border {
- @extend .border-0;
- }
-
+ &.card-without-border,
&.bg-light {
- @extend .border-0;
+ border: 0 !important;
}
}
diff --git a/app/assets/stylesheets/components/avatar.scss b/app/assets/stylesheets/components/avatar.scss
index 3c8abe43070..c8f69bfdbaf 100644
--- a/app/assets/stylesheets/components/avatar.scss
+++ b/app/assets/stylesheets/components/avatar.scss
@@ -70,7 +70,8 @@ $avatar-sizes: (
$identicon-backgrounds: $identicon-red, $identicon-purple, $identicon-indigo, $identicon-blue, $identicon-teal,
$identicon-orange, $identicon-gray;
-%avatar-circle {
+.avatar,
+.avatar-container {
float: left;
margin-right: $gl-padding;
border-radius: $avatar-radius;
@@ -84,7 +85,6 @@ $identicon-backgrounds: $identicon-red, $identicon-purple, $identicon-indigo, $i
}
.avatar {
- @extend %avatar-circle;
transition-property: none;
width: 40px;
@@ -151,7 +151,6 @@ $identicon-backgrounds: $identicon-red, $identicon-purple, $identicon-indigo, $i
}
.avatar-container {
- @extend %avatar-circle;
overflow: hidden;
display: flex;
diff --git a/app/assets/stylesheets/components/milestone_combobox.scss b/app/assets/stylesheets/components/milestone_combobox.scss
index f73ec4d5998..5d1709c22ec 100644
--- a/app/assets/stylesheets/components/milestone_combobox.scss
+++ b/app/assets/stylesheets/components/milestone_combobox.scss
@@ -1,8 +1,14 @@
-.selected-item {
- /* stylelint-disable-next-line function-url-quotes */
- background: url(asset_path('checkmark.png')) no-repeat 0 2px;
-}
+.milestone-combobox {
+ .selected-item {
+ /* stylelint-disable-next-line function-url-quotes */
+ background: url(asset_path('checkmark.png')) no-repeat 0 2px;
+ }
+
+ .dropdown-item-space {
+ padding: 8px 12px;
+ }
-.dropdown-item-space {
- padding: 8px 12px;
+ .dropdown-menu.show {
+ overflow: hidden;
+ }
}
diff --git a/app/assets/stylesheets/components/ref_selector.scss b/app/assets/stylesheets/components/ref_selector.scss
index 970a7b967ee..ded911c2492 100644
--- a/app/assets/stylesheets/components/ref_selector.scss
+++ b/app/assets/stylesheets/components/ref_selector.scss
@@ -1,17 +1,13 @@
.ref-selector {
- & &-dropdown-content {
- // Setting a max height is necessary to allow the dropdown's content
- // to control where and how scrollbars appear.
- // This content is limited to the max-height of the dropdown
- // ($dropdown-max-height-lg) minus the additional padding
- // on the top and bottom (2 * $gl-padding-8)
- max-height: $dropdown-max-height-lg - 2 * $gl-padding-8;
- }
-
.dropdown-menu.show {
// Make the dropdown a little wider and longer than usual
// since it contains quite a bit of content.
+ overflow: hidden;
width: 20rem;
- max-height: $dropdown-max-height-lg;
+
+ &,
+ .gl-new-dropdown-inner {
+ max-height: $dropdown-max-height-lg;
+ }
}
}
diff --git a/app/assets/stylesheets/disable_animations.scss b/app/assets/stylesheets/disable_animations.scss
index 799c6e80ec9..1e63cdcfa39 100644
--- a/app/assets/stylesheets/disable_animations.scss
+++ b/app/assets/stylesheets/disable_animations.scss
@@ -12,9 +12,3 @@
animation: none !important;
/* stylelint-enable property-no-vendor-prefix */
}
-
-// Disable sticky changes bar for tests
-.diff-files-changed {
- position: relative !important;
- top: 0 !important;
-}
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index 7931f4deea0..1fe94a796f5 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -42,7 +42,6 @@
@import 'framework/notes';
@import 'framework/tabs';
@import 'framework/timeline';
-@import 'framework/tooltips';
@import 'framework/toggle';
@import 'framework/typography';
@import 'framework/zen';
diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss
index a7623b65539..662f7f52d61 100644
--- a/app/assets/stylesheets/framework/awards.scss
+++ b/app/assets/stylesheets/framework/awards.scss
@@ -274,7 +274,9 @@
// `position:absolute`
&::after {
content: '\a0';
+ display: block !important;
width: 1em;
+ color: transparent;
}
.reaction-control-icon {
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index b51fec925cb..d1fa1187703 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -196,10 +196,6 @@
@include btn-orange;
}
- &.btn-close {
- @include btn-outline($white, $orange-500, $orange-500, $orange-50, $orange-600, $orange-600, $orange-100, $orange-700, $orange-700);
- }
-
&.btn-danger {
@include btn-red;
}
@@ -216,6 +212,7 @@
color: $gray-700;
}
+ // deprecated class
&.btn-text-field {
width: 100%;
text-align: left;
@@ -436,32 +433,27 @@
}
// All disabled buttons, regardless of color, type, etc
-%disabled {
- background-color: $gray-light;
- border-color: $gray-100;
- color: $gl-text-color-disabled;
- opacity: 1;
- text-decoration: none;
- cursor: default;
-
- &.cursor-not-allowed {
- cursor: not-allowed;
- }
-
- i {
- color: $gl-text-color-disabled;
- }
-}
-
.btn.disabled,
.btn[disabled],
fieldset[disabled] .btn,
.dropdown-toggle[disabled],
[disabled].dropdown-menu-toggle {
- @extend %disabled;
-
+ &,
&:hover {
- @extend %disabled;
+ background-color: $gray-light;
+ border-color: $gray-100;
+ color: $gl-text-color-disabled;
+ opacity: 1;
+ text-decoration: none;
+ cursor: default;
+
+ &.cursor-not-allowed {
+ cursor: not-allowed;
+ }
+
+ i {
+ color: $gl-text-color-disabled;
+ }
}
&.btn-link {
diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss
index e30caeb1dfb..bd15022eadf 100644
--- a/app/assets/stylesheets/framework/diffs.scss
+++ b/app/assets/stylesheets/framework/diffs.scss
@@ -1035,7 +1035,6 @@ table.code {
auto;
// Retina cursor
- // scss-lint:disable DuplicateProperty
cursor: image-set(image-url('illustrations/image_comment_light_cursor.svg') 1x,
image-url('illustrations/image_comment_light_cursor@2x.svg') 2x) $image-comment-cursor-left-offset $image-comment-cursor-top-offset,
auto;
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index f56d8f2c2a9..bdde6c7b313 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -34,7 +34,14 @@
overflow-y: auto;
&.dropdown-extended-height {
- max-height: 400px;
+ $extended-max-height: 400px;
+
+ max-height: $extended-max-height;
+
+ // See comment below for explanation
+ .gl-new-dropdown-inner {
+ max-height: $extended-max-height - 2px;
+ }
}
@include media-breakpoint-down(xs) {
@@ -46,6 +53,15 @@
overflow-y: initial;
max-height: initial;
}
+
+ // `GlDropdown` specifies the `max-height` of `.gl-new-dropdown-inner`
+ // as `$dropdown-max-height`, but the `max-height` rule above forces
+ // the parent `.dropdown-menu` to be _slightly_ too small because of
+ // the 1px borders. The workaround below overrides the @gitlab/ui style
+ // to avoid a double scroll bar.
+ .gl-new-dropdown-inner {
+ max-height: $dropdown-max-height - 2px;
+ }
}
.dropdown-toggle,
@@ -1093,3 +1109,11 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
width: $gl-dropdown-width-wide;
}
}
+
+// This class won't be needed once we can add a prop for this in the GitLab UI component
+// https://gitlab.com/gitlab-org/gitlab-ui/-/issues/966
+.gl-new-dropdown.gl-dropdown-menu-full-width {
+ .dropdown-menu {
+ width: 100%;
+ }
+}
diff --git a/app/assets/stylesheets/framework/editor-lite.scss b/app/assets/stylesheets/framework/editor-lite.scss
index c3b287a6c3d..eda3e33a6aa 100644
--- a/app/assets/stylesheets/framework/editor-lite.scss
+++ b/app/assets/stylesheets/framework/editor-lite.scss
@@ -3,6 +3,11 @@
@include gl-display-flex;
@include gl-justify-content-center;
@include gl-align-items-center;
+ @include gl-z-index-0;
+
+ > * {
+ filter: blur(5px);
+ }
&::before {
content: '';
diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss
index 13c5541da92..c5c660c1014 100644
--- a/app/assets/stylesheets/framework/emojis.scss
+++ b/app/assets/stylesheets/framework/emojis.scss
@@ -16,3 +16,26 @@ gl-emoji {
vertical-align: baseline;
}
}
+
+.emoji-picker-category-header {
+ @include gl-sticky;
+ background-color: $white-transparent;
+}
+
+.emoji-picker-emoji {
+ height: 30px;
+ // Create a width that fits 9 emojis per row
+ width: 100 / 9 * 1%;
+}
+
+.emoji-picker .gl-new-dropdown .dropdown-menu {
+ width: 350px;
+}
+
+.emoji-picker-category-tab {
+ border-bottom-color: transparent;
+}
+
+.emoji-picker .gl-new-dropdown-inner > :last-child {
+ padding-bottom: 0;
+}
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index 07d59847829..a4af45a467c 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -265,6 +265,7 @@
flex: 1;
position: relative;
min-width: 0;
+ height: 2rem;
background-color: $input-bg;
}
@@ -453,7 +454,7 @@
.search-token-target-branch {
.value {
font-family: $monospace-font;
- font-size: 13px;
+ font-size: $gl-font-size-monospace;
}
}
diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss
index 6e47fef02d5..c366bf80093 100644
--- a/app/assets/stylesheets/framework/forms.scss
+++ b/app/assets/stylesheets/framework/forms.scss
@@ -35,7 +35,8 @@ label {
margin: 0;
}
- &.form-check-label {
+ &.form-check-label,
+ &.custom-control-label {
font-weight: $gl-font-weight-normal;
}
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index ffcc20b624b..fdb56a128c7 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -4,7 +4,6 @@
margin-bottom: 0;
min-height: $header-height;
border: 0;
- border-bottom: 1px solid $border-color;
position: fixed;
top: 0;
left: 0;
diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss
index 28577e2801e..73a2170fc68 100644
--- a/app/assets/stylesheets/framework/highlight.scss
+++ b/app/assets/stylesheets/framework/highlight.scss
@@ -45,8 +45,7 @@
a {
font-family: $monospace-font;
- display: flex;
- justify-content: flex-end;
+ display: block;
font-size: $code-font-size !important;
white-space: nowrap;
diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss
index cac3695e1a1..4f9896dd58a 100644
--- a/app/assets/stylesheets/framework/layout.scss
+++ b/app/assets/stylesheets/framework/layout.scss
@@ -8,9 +8,8 @@ html {
body {
// Improves readability for dyslexic users; supported only in Chrome/Safari so far
- // scss-lint:disable PropertySpelling
text-decoration-skip: ink;
- // scss-lint:enable PropertySpelling
+
&.navless {
background-color: $white !important;
}
diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss
index b0334da6943..df2ba718c72 100644
--- a/app/assets/stylesheets/framework/lists.scss
+++ b/app/assets/stylesheets/framework/lists.scss
@@ -192,11 +192,6 @@ ul.content-list {
display: flex;
align-items: center;
white-space: nowrap;
-
- // Override style that allows the flex-row text to wrap.
- &.allow-wrap {
- white-space: normal;
- }
}
.row-main-content {
@@ -283,12 +278,6 @@ ul.indent-list {
to { transform: rotate(360deg); }
}
-.namespace-title {
- .tooltip-inner {
- max-width: 350px;
- }
-}
-
.horizontal-list {
padding-left: 0;
list-style: none;
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index e29e204b14f..1e2fc1445e8 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -433,7 +433,6 @@
@mixin middle-dot-divider {
&::after {
// Duplicate `content` property used as a fallback
- // scss-lint:disable DuplicateProperty
content: '\00B7'; // middle dot fallback if browser does not support alternative content
content: '\00B7' / ''; // tell screen readers to ignore the content https://www.w3.org/TR/css-content-3/#accessibility
padding: 0 0.375rem;
diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss
index 2dbeacb0f8c..ec433434573 100644
--- a/app/assets/stylesheets/framework/modal.scss
+++ b/app/assets/stylesheets/framework/modal.scss
@@ -130,22 +130,6 @@ body.modal-open {
.issues-import-modal,
.issuable-export-modal {
- .modal-header {
- justify-content: flex-start;
-
- .import-export-svg-container {
- flex-grow: 1;
- height: 56px;
- padding: $gl-btn-padding $gl-btn-padding 0;
- text-align: right;
-
- .illustration {
- height: inherit;
- width: initial;
- }
- }
- }
-
.modal-body {
padding: 0;
diff --git a/app/assets/stylesheets/framework/tooltips.scss b/app/assets/stylesheets/framework/tooltips.scss
deleted file mode 100644
index edc2fb532c8..00000000000
--- a/app/assets/stylesheets/framework/tooltips.scss
+++ /dev/null
@@ -1,6 +0,0 @@
-.tooltip-inner {
- font-size: $gl-font-size-small;
- border-radius: $border-radius-default;
- line-height: $gl-line-height;
- font-weight: $gl-font-weight-normal;
-}
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 496e2aba421..5624a6ea8a3 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -78,7 +78,9 @@
code {
font-family: $monospace-font;
white-space: pre-wrap;
- word-wrap: normal;
+ // Safari
+ word-wrap: break-word;
+ overflow-wrap: break-word;
word-break: keep-all;
}
@@ -344,6 +346,9 @@
// Multi-line code blocks should scroll horizontally
code {
white-space: pre;
+ // Safari
+ word-wrap: normal;
+ overflow-wrap: normal;
}
&.plain-readme {
@@ -430,8 +435,7 @@
}
}
- a[href*='/uploads/'],
- a[href*='storage.googleapis.com/google-code-attachments/'] {
+ a.with-attachment-icon {
&::before {
margin-right: 4px;
@@ -441,6 +445,11 @@
-webkit-font-smoothing: antialiased;
content: '📎';
}
+ }
+
+ a[href*='/uploads/'],
+ a[href*='storage.googleapis.com/google-code-attachments/'] {
+ @extend .with-attachment-icon;
&.no-attachment-icon {
&::before {
@@ -604,7 +613,7 @@ pre {
display: block;
padding: $gl-padding-8 $input-horizontal-padding;
margin: 0 0 $gl-padding-8;
- font-size: 13px;
+ font-size: $gl-font-size-monospace;
word-break: break-all;
word-wrap: break-word;
color: $gl-text-color;
@@ -646,7 +655,7 @@ code {
*/
textarea.js-gfm-input {
font-family: $monospace-font;
- font-size: 13px;
+ font-size: $gl-font-size-monospace;
}
.strikethrough {
@@ -699,13 +708,11 @@ textarea {
opacity: 1; // FF defaults to 0.54
}
- // scss-lint:disable PseudoElement
// support Edge vendor prefix
&::-ms-input-placeholder {
color: $gl-text-color-tertiary;
}
- // scss-lint:disable PseudoElement
// support IE vendor prefix
&:-ms-input-placeholder {
color: $gl-text-color-tertiary;
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 4bf9236407f..1aa4177c902 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -443,7 +443,7 @@ $gl-avatar-size: 40px;
$border-radius-default: 4px;
$border-radius-small: 2px;
$border-radius-large: 8px;
-$default-icon-size: 18px;
+$default-icon-size: 16px;
$layout-link-gray: #7e7c7c;
$btn-side-margin: 10px;
$btn-sm-side-margin: 7px;
@@ -830,8 +830,8 @@ $ci-variable-remove-button-width: calc(1em + #{2 * $gl-padding});
/*
GitLab Plans
*/
-$gl-gold-plan: #d4af37;
-$gl-silver-plan: #91a1ab;
+$gl-ultimate-plan: #d4af37;
+$gl-premium-plan: #91a1ab;
$gl-bronze-plan: #cd7f32;
/*
diff --git a/app/assets/stylesheets/highlight/conflict_colors.scss b/app/assets/stylesheets/highlight/conflict_colors.scss
index 98ca3775b72..910dc6f4ad6 100644
--- a/app/assets/stylesheets/highlight/conflict_colors.scss
+++ b/app/assets/stylesheets/highlight/conflict_colors.scss
@@ -1,5 +1,4 @@
// Disabled to use the color map for creating color schemes
-// scss-lint:disable ColorVariable
$conflict-colors: (
white-header-head-neutral : #e1fad7,
white-line-head-neutral : #effdec,
@@ -116,4 +115,3 @@ $conflict-colors: (
none_line_not_chosen : $gray-light
);
-// scss-lint:enable ColorVariable
diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss
index 64387fbce09..0dc01213606 100644
--- a/app/assets/stylesheets/highlight/themes/dark.scss
+++ b/app/assets/stylesheets/highlight/themes/dark.scss
@@ -38,7 +38,7 @@ $dark-cp: #969896;
$dark-c1: #969896;
$dark-cs: #969896;
$dark-gd: #c66;
-$dark-gh: #c5c8c6;
+$dark-gh: #8abeb7;
$dark-gi: #b5bd68;
$dark-gp: #969896;
$dark-gu: #8abeb7;
diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss
index 119908ffba8..95c3e8e9103 100644
--- a/app/assets/stylesheets/highlight/themes/monokai.scss
+++ b/app/assets/stylesheets/highlight/themes/monokai.scss
@@ -87,6 +87,7 @@ $monokai-il: #ae81ff;
$monokai-gu: #75715e;
$monokai-gd: #f92672;
$monokai-gi: #a6e22e;
+$monokai-gh: #75715e;
.code.monokai {
// Line numbers
@@ -281,4 +282,5 @@ $monokai-gi: #a6e22e;
.gu { color: $monokai-gu; } /* Generic.Subheading & Diff Unified/Comment? */
.gd { color: $monokai-gd; } /* Generic.Deleted & Diff Deleted */
.gi { color: $monokai-gi; } /* Generic.Inserted & Diff Inserted */
+ .gh { color: $monokai-gh; } /* Generic.Heading */
}
diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss
index 777332881f3..128fe0cc046 100644
--- a/app/assets/stylesheets/highlight/white_base.scss
+++ b/app/assets/stylesheets/highlight/white_base.scss
@@ -24,7 +24,7 @@ $white-gd-bg: #fdd;
$white-gd-x: $black;
$white-gd-x-bg: #faa;
$white-gr: #a00;
-$white-gh: #999;
+$white-gh: #800080;
$white-gi: $black;
$white-gi-bg: #dfd;
$white-gi-x: $black;
@@ -280,7 +280,9 @@ span.highlight_word {
.ge { font-style: italic; }
.gr { color: $white-gr; }
-.gh { color: $white-gh; }
+
+.gh { color: $white-gh;
+ font-weight: $gl-font-weight-bold; }
.gi {
color: $white-gi;
diff --git a/app/assets/stylesheets/lazy_bundles/select2_overrides.scss b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss
index b148cc8f0e7..0d40159f6de 100644
--- a/app/assets/stylesheets/lazy_bundles/select2_overrides.scss
+++ b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss
@@ -339,3 +339,19 @@
display: inline;
}
}
+
+.gl-select2-html5-required-fix {
+ .select2-container {
+ + .select2 {
+ @include gl-opacity-0;
+ @include gl-border-0;
+ @include gl-bg-none;
+ @include gl-bg-transparent;
+ display: block !important;
+ width: 1px;
+ height: 1px;
+ z-index: -1;
+ margin: -3px auto 0;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/mailer_client_specific.scss b/app/assets/stylesheets/mailer_client_specific.scss
index 41bedecf90f..0547aa7752f 100644
--- a/app/assets/stylesheets/mailer_client_specific.scss
+++ b/app/assets/stylesheets/mailer_client_specific.scss
@@ -3,7 +3,6 @@
// These are client-specific rules, ignore some linting rules
//
// stylelint-disable property-no-vendor-prefix, property-no-unknown, length-zero-no-unit
-// scss-lint:disable PropertySpelling, ZeroUnit
body,
table,
diff --git a/app/assets/stylesheets/mailers/highlighted_diff_email.scss b/app/assets/stylesheets/mailers/highlighted_diff_email.scss
index d8bb021e1f5..75c2428c1d4 100644
--- a/app/assets/stylesheets/mailers/highlighted_diff_email.scss
+++ b/app/assets/stylesheets/mailers/highlighted_diff_email.scss
@@ -27,7 +27,7 @@ $highlighted-gd-bg: #fdd;
$highlighted-gd-x: #000;
$highlighted-gd-x-bg: #faa;
$highlighted-gr: #a00;
-$highlighted-gh: #999;
+$highlighted-gh: #800080;
$highlighted-gi: #000;
$highlighted-gi-bg: #dfd;
$highlighted-gi-x: #000;
@@ -185,7 +185,9 @@ span.highlight_word {
.ge { font-style: italic; }
.gr { color: $highlighted-gr; }
-.gh { color: $highlighted-gh; }
+
+.gh { color: $highlighted-gh;
+ font-weight: $gl-font-weight-bold; }
.gi {
color: $highlighted-gi;
diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss
index 620b37914d8..fdf9e157e1e 100644
--- a/app/assets/stylesheets/page_bundles/boards.scss
+++ b/app/assets/stylesheets/page_bundles/boards.scss
@@ -30,12 +30,21 @@
color: var(--gray-500, $gray-500);
}
+[data-page$='epic_boards:index'],
+[data-page$='epic_boards:show'],
.issue-boards-page {
.content-wrapper {
padding-bottom: 0;
}
}
+[data-page$='epic_boards:index'],
+[data-page$='epic_boards:show'] {
+ .filter-form {
+ display: none;
+ }
+}
+
.boards-app {
@include media-breakpoint-up(sm) {
transition: width $sidebar-transition-duration;
@@ -515,7 +524,7 @@
}
}
-.board-issue-path.js-show-tooltip {
+.board-item-path.js-show-tooltip {
cursor: help;
}
diff --git a/app/assets/stylesheets/page_bundles/build.scss b/app/assets/stylesheets/page_bundles/build.scss
index 3962c546b51..34f88f9405a 100644
--- a/app/assets/stylesheets/page_bundles/build.scss
+++ b/app/assets/stylesheets/page_bundles/build.scss
@@ -140,23 +140,6 @@
width: 289px;
}
- .block {
- width: 100%;
- word-break: break-word;
-
- &:last-child {
- border-bottom: 1px solid $border-gray-normal;
- }
-
- &.coverage {
- padding: 0 16px 11px;
- }
- }
-
- .block-last {
- padding: 16px 0;
- }
-
.trigger-variables-btn-container {
justify-content: space-between;
align-items: center;
@@ -198,18 +181,6 @@
margin-left: 2px;
}
- .retry-link {
- display: block;
-
- .btn-inverted-secondary {
- color: $blue-500;
-
- &:hover {
- color: $white;
- }
- }
- }
-
.stage-item {
cursor: pointer;
diff --git a/app/assets/stylesheets/page_bundles/cycle_analytics.scss b/app/assets/stylesheets/page_bundles/cycle_analytics.scss
index 4a48333cd27..2742c95c6e1 100644
--- a/app/assets/stylesheets/page_bundles/cycle_analytics.scss
+++ b/app/assets/stylesheets/page_bundles/cycle_analytics.scss
@@ -1,6 +1,5 @@
@import 'mixins_and_variables_and_functions';
-#cycle-analytics,
.cycle-analytics {
margin: 24px auto 0;
position: relative;
@@ -316,35 +315,4 @@
}
}
}
-
- .empty-stage,
- .no-access-stage {
- text-align: center;
- width: 75%;
- margin: 0 auto;
- padding-top: 130px;
- color: var(--gray-500, $gray-500);
-
- h4 {
- color: var(--gl-text-color, $gl-text-color);
- }
- }
-
- .empty-stage {
- .icon-no-data {
- height: 36px;
- width: 78px;
- display: inline-block;
- margin-bottom: 20px;
- }
- }
-
- .no-access-stage {
- .icon-lock {
- height: 36px;
- width: 78px;
- display: inline-block;
- margin-bottom: 20px;
- }
- }
}
diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss
index 7c4d51ab677..009019a45d9 100644
--- a/app/assets/stylesheets/page_bundles/ide.scss
+++ b/app/assets/stylesheets/page_bundles/ide.scss
@@ -1043,8 +1043,7 @@ $ide-commit-header-height: 48px;
.input-icon {
right: auto;
left: 10px;
- top: 50%;
- transform: translateY(-50%);
+ top: 1rem;
}
}
diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_solarized-light.scss b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-light.scss
index 315a0ae6202..26d55dfe2d0 100644
--- a/app/assets/stylesheets/page_bundles/ide_themes/_solarized-light.scss
+++ b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-light.scss
@@ -2,14 +2,14 @@
// Please see `app/assets/stylesheets/page_bundles/ide_themes/README.md` for a guide on contributing new themes
// -------
.ide.theme-solarized-light {
- --ide-border-color: #dfd7bf;
+ --ide-border-color: #ded7c1;
--ide-border-color-alt: #dfd7bf;
--ide-highlight-accent: #5c4e21;
--ide-text-color: #616161;
--ide-text-color-secondary: #526f76;
- --ide-background: #efe8d3;
- --ide-background-hover: #ded6be;
- --ide-highlight-background: #fef6e1;
+ --ide-background: #ddd6c1;
+ --ide-background-hover: #d3cbb7;
+ --ide-highlight-background: #eee8d5;
--ide-link-color: #955800;
--ide-footer-background: #efe8d3;
--ide-empty-state-background: #fef6e1;
diff --git a/app/assets/stylesheets/page_bundles/import.scss b/app/assets/stylesheets/page_bundles/import.scss
index 453b810196b..525481638f3 100644
--- a/app/assets/stylesheets/page_bundles/import.scss
+++ b/app/assets/stylesheets/page_bundles/import.scss
@@ -1,5 +1,12 @@
@import 'mixins_and_variables_and_functions';
+// Fixing double scrollbar issue
+// See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1156 and
+// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/54837
+.import-entities-namespace-dropdown.show.dropdown .dropdown-menu {
+ max-height: initial;
+}
+
.import-jobs-to-col {
width: 39%;
}
diff --git a/app/assets/stylesheets/page_bundles/jira_connect.scss b/app/assets/stylesheets/page_bundles/jira_connect.scss
index 25401a161da..eb2dd6e578e 100644
--- a/app/assets/stylesheets/page_bundles/jira_connect.scss
+++ b/app/assets/stylesheets/page_bundles/jira_connect.scss
@@ -20,36 +20,23 @@
@import '@gitlab/ui/src/components/base/tooltip/tooltip';
$atlaskit-border-color: #dfe1e6;
+$header-height: 40px;
-.ac-content {
- margin: 20px;
-
- .subscription-form {
- margin-bottom: 20px;
-
- .field-group-input {
- display: flex;
- padding-top: $gl-padding-4;
+.subscription-form {
+ .field-group-input {
+ display: flex;
+ padding-top: $gl-padding-4;
- .ak-button {
- align-items: center;
- height: auto;
- margin-left: $btn-margin-5;
- }
+ .ak-button {
+ align-items: center;
+ height: auto;
+ margin-left: $btn-margin-5;
}
}
}
-$header-height: 40px;
-
.jira-connect-header {
- border-bottom: 1px solid $gray-100;
- display: flex;
- align-items: center;
- justify-content: center;
min-height: $header-height;
- padding-left: 16px;
- padding-right: 16px;
position: fixed;
top: 0;
left: 0;
@@ -57,7 +44,6 @@ $header-height: 40px;
}
.jira-connect-user {
- font-size: $gl-font-size;
position: fixed;
top: 10px;
right: 20px;
@@ -65,20 +51,17 @@ $header-height: 40px;
.jira-connect-app {
margin-top: $header-height;
+ height: calc(100% - #{$header-height});
+ max-width: 1000px;
+}
+
+.jira-connect-app-body {
max-width: 600px;
- min-height: 95vh;
- padding-top: 48px;
- padding-left: 16px;
- padding-right: 16px;
margin-left: auto;
margin-right: auto;
- text-align: center;
-}
-
-.gl-mt-5 {
- margin-top: 16px;
}
+// for external_link buttons
svg {
fill: currentColor;
@@ -115,12 +98,3 @@ svg {
}
}
}
-
-.empty-subscriptions {
- color: $gray-900;
-}
-
-.browser-limitations-notice {
- font-size: $gl-font-size;
- margin-top: 32px;
-}
diff --git a/app/assets/stylesheets/page_bundles/learn_gitlab.scss b/app/assets/stylesheets/page_bundles/learn_gitlab.scss
new file mode 100644
index 00000000000..189aefb330b
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/learn_gitlab.scss
@@ -0,0 +1,3 @@
+.learn-gitlab-info-card-content {
+ height: 200px;
+}
diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/page_bundles/members.scss
index 0ccde57746a..7b4c74b8253 100644
--- a/app/assets/stylesheets/pages/members.scss
+++ b/app/assets/stylesheets/page_bundles/members.scss
@@ -1,3 +1,5 @@
+@import 'mixins_and_variables_and_functions';
+
.project-members-title {
padding-bottom: 10px;
border-bottom: 1px solid $border-color;
diff --git a/app/assets/stylesheets/page_bundles/oncall_schedules.scss b/app/assets/stylesheets/page_bundles/oncall_schedules.scss
index a6668f00147..5eaf91c3017 100644
--- a/app/assets/stylesheets/page_bundles/oncall_schedules.scss
+++ b/app/assets/stylesheets/page_bundles/oncall_schedules.scss
@@ -182,10 +182,3 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
transform: translateX(-50%);
}
}
-
-.gl-token {
- .gl-avatar-labeled-label {
- @include gl-text-white;
- @include gl-font-weight-normal;
- }
-}
diff --git a/app/assets/stylesheets/page_bundles/pipelines.scss b/app/assets/stylesheets/page_bundles/pipelines.scss
index ae36f7e3ac1..6ef7f912ea9 100644
--- a/app/assets/stylesheets/page_bundles/pipelines.scss
+++ b/app/assets/stylesheets/page_bundles/pipelines.scss
@@ -67,8 +67,7 @@
// Mini Pipelines
.stage-cell {
- .mini-pipeline-graph-dropdown-toggle,
- .mini-pipeline-graph-gl-dropdown-toggle {
+ .mini-pipeline-graph-dropdown-toggle {
svg {
height: $ci-action-icon-size;
width: $ci-action-icon-size;
@@ -138,14 +137,16 @@
}
}
-// Dropdown button in mini pipeline graph
+// Commit mini pipeline (HAML)
button.mini-pipeline-graph-dropdown-toggle,
-// As the `mini-pipeline-item` mixin specificity is lower
-// than the toggle of dropdown with 'variant="link"' we add
-// classes ".gl-button.btn-link" to make it more specific.
-// Once FF ci_mini_pipeline_gl_dropdown is removed, the `mini-pipeline-item`
-// itself could increase its specificity to simplify this selector
-button.gl-button.btn-link.mini-pipeline-graph-gl-dropdown-toggle {
+// GlDropdown mini pipeline (Vue)
+// As the `mini-pipeline-item` mixin specificity is lower
+// than the toggle of dropdown with 'variant="link"' we add
+// classes ".gl-button.btn-link" to make it more specific
+// and avoid having the size overriden
+//
+// See https://gitlab.com/gitlab-org/gitlab/-/issues/320737
+button.gl-button.btn-link.mini-pipeline-graph-dropdown-toggle {
@include mini-pipeline-item();
}
diff --git a/app/assets/stylesheets/page_bundles/reports.scss b/app/assets/stylesheets/page_bundles/reports.scss
index 18ca5f9a3a9..ce91988cb8a 100644
--- a/app/assets/stylesheets/page_bundles/reports.scss
+++ b/app/assets/stylesheets/page_bundles/reports.scss
@@ -5,11 +5,6 @@
max-height: 170px;
overflow: auto;
}
-
- .report-block-list-issue-parent {
- padding: $gl-padding-top $gl-padding;
- border-top: 1px solid var(--border-color, $border-color);
- }
}
.report-block-container {
diff --git a/app/assets/stylesheets/page_bundles/signup.scss b/app/assets/stylesheets/page_bundles/signup.scss
index a207c10b04f..d6c3a3ff5da 100644
--- a/app/assets/stylesheets/page_bundles/signup.scss
+++ b/app/assets/stylesheets/page_bundles/signup.scss
@@ -32,11 +32,6 @@
@include media-breakpoint-down(md) {
width: 100%;
}
-
- img {
- width: $default-icon-size;
- height: $default-icon-size;
- }
}
.decline-page {
diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss
index f7b8a4c5b84..98074f8af29 100644
--- a/app/assets/stylesheets/pages/clusters.scss
+++ b/app/assets/stylesheets/pages/clusters.scss
@@ -105,6 +105,7 @@
border: 0;
&.table-row-header {
+ // stylelint-disable-next-line
background-color: none;
border: 0;
font-weight: bold;
diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss
index 87307fd682e..ee1385d36df 100644
--- a/app/assets/stylesheets/pages/groups.scss
+++ b/app/assets/stylesheets/pages/groups.scss
@@ -519,10 +519,6 @@ table.pipeline-project-metrics tr td {
margin-left: 25px;
}
- .item-visibility {
- margin-right: 0;
- }
-
.last-updated {
position: relative;
min-width: 250px;
diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss
index 019d827798c..2d04354a99d 100644
--- a/app/assets/stylesheets/pages/login.scss
+++ b/app/assets/stylesheets/pages/login.scss
@@ -106,17 +106,6 @@
width: 100%;
}
}
-
- .omniauth-btn {
- width: 100%;
- padding: $gl-padding-8;
-
- img {
- width: $default-icon-size;
- height: $default-icon-size;
- margin-right: $gl-padding;
- }
- }
}
.new-session-tabs {
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index a62df1258af..23e368a2e73 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -4,6 +4,7 @@
*/
$mr-widget-min-height: 69px;
+$tabs-holder-z-index: 250;
.space-children {
@include clearfix;
@@ -225,10 +226,6 @@ $mr-widget-min-height: 69px;
}
}
- .mini-pipeline-graph-dropdown-toggle {
- vertical-align: top;
- }
-
.normal {
flex: 1;
flex-basis: auto;
@@ -675,7 +672,7 @@ $mr-widget-min-height: 69px;
.mr-version-controls {
position: relative;
- z-index: 203;
+ z-index: $tabs-holder-z-index + 10;
background: $white;
color: $gl-text-color;
margin-top: -1px;
@@ -745,7 +742,7 @@ $mr-widget-min-height: 69px;
.merge-request-tabs-holder,
.epic-tabs-holder {
top: $header-height;
- z-index: 250;
+ z-index: $tabs-holder-z-index;
background-color: $body-bg;
border-bottom: 1px solid $border-color;
@@ -981,15 +978,15 @@ $mr-widget-min-height: 69px;
line-height: initial;
}
- .mini-pipeline-graph-dropdown-toggle,
- .stage-cell .mini-pipeline-graph-dropdown-toggle svg,
- // As the `mini-pipeline-item` mixin specificity is lower
- // than the toggle of dropdown with 'variant="link"' we add
- // classes ".gl-button.btn-link" to make it more specific.
- // Once FF ci_mini_pipeline_gl_dropdown is removed, the `mini-pipeline-item`
- // itself could increase its specificity to simplify this selector
- button.gl-button.btn-link.mini-pipeline-graph-gl-dropdown-toggle,
- .stage-cell button.gl-button.btn-link.mini-pipeline-graph-gl-dropdown-toggle svg {
+ // GlDropdown mini pipeline (Vue)
+ // As the `mini-pipeline-item` mixin specificity is lower
+ // than the toggle of dropdown with 'variant="link"' we add
+ // classes ".gl-button.btn-link" to make it more specific
+ // and avoid having the size overriden
+ //
+ // See https://gitlab.com/gitlab-org/gitlab/-/issues/320737
+ button.gl-button.btn-link.mini-pipeline-graph-dropdown-toggle,
+ .stage-cell button.gl-button.btn-link.mini-pipeline-graph-dropdown-toggle svg {
height: $ci-action-icon-size-lg;
width: $ci-action-icon-size-lg;
}
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index ffbfa47f9bd..cb5050fc578 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -302,8 +302,21 @@ table {
width: 100%;
}
}
+}
+
+.discussion-reply-holder {
+ .reply-placeholder-text-field {
+ font-family: $monospace-font;
+ font-size: $gl-font-size-monospace;
+ border-radius: $gl-border-radius-base;
+ width: 100%;
+ resize: none;
+ padding: $gl-padding-8 $gl-padding-12;
+ line-height: 1;
+ border: 1px solid $border-color;
+ background-color: $white;
+ overflow: hidden;
- .btn-text-field {
@include media-breakpoint-down(xs) {
margin-bottom: $gl-padding-8;
}
@@ -381,32 +394,6 @@ table {
}
.comment-type-dropdown {
- .btn-success {
- width: auto;
- }
-
- .dropdown-toggle {
- float: right;
-
- i {
- color: $white;
- padding-right: 2px;
- margin-top: 2px;
- }
-
- &[disabled] {
- i {
- color: $gl-text-color-disabled;
- }
- }
- }
-
- .dropdown-menu {
- top: initial;
- bottom: 100%;
- width: 298px;
- }
-
@include media-breakpoint-down(xs) {
display: flex;
width: 100%;
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 8251cdb9bbb..e51cc0b4479 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -136,33 +136,9 @@
.project-repo-buttons {
.btn {
- &:last-child {
- margin-left: 0;
- }
-
svg {
- fill: $layout-link-gray;
+ fill: $gray-500;
}
-
- .notifications-icon {
- top: 1px;
- margin-right: 0;
- }
- }
-
- .icon {
- top: 0;
- }
-
- .count-badge,
- .btn-xs {
- height: 24px;
- }
-
- .home-panel-action-button,
- .project-action-button {
- margin: $gl-padding $gl-padding-8 0 0;
- vertical-align: top;
}
.notification-dropdown .dropdown-menu {
@@ -175,34 +151,6 @@
}
}
- .count-buttons {
- display: inline-block;
- vertical-align: top;
- margin-top: $gl-padding;
-
- .count-badge-count,
- .count-badge-button {
- border: 1px solid $border-color;
- line-height: 1;
- }
-
- .count,
- .count-badge-button {
- color: $gl-text-color;
- }
-
- .count-badge-count {
- padding: 0 12px;
- background: $gray-light;
- border-radius: 0 $border-radius-base $border-radius-base 0;
- }
-
- .count-badge-button {
- border-right: 0;
- border-radius: $border-radius-base 0 0 $border-radius-base;
- }
- }
-
.project-clone-holder {
display: inline-block;
margin: $gl-padding 0 0;
@@ -313,7 +261,6 @@
}
.fork-thumbnail {
- height: 200px;
width: calc((100% / 2) - #{$gl-padding * 2});
@include media-breakpoint-up(md) {
@@ -887,10 +834,6 @@ pre.light-well {
}
.git-clone-holder {
- .btn-clipboard {
- border: 1px solid $border-color;
- }
-
.clone-options {
display: table-cell;
@@ -1008,6 +951,18 @@ pre.light-well {
}
}
+.compare-revision-cards {
+ @media (min-width: $breakpoint-lg) {
+ .gl-card {
+ width: calc(50% - 15px);
+ }
+
+ .compare-ellipsis {
+ width: 30px;
+ }
+ }
+}
+
.clearable-input {
position: relative;
diff --git a/app/assets/stylesheets/pages/trials.scss b/app/assets/stylesheets/pages/trials.scss
deleted file mode 100644
index 55f323b7df7..00000000000
--- a/app/assets/stylesheets/pages/trials.scss
+++ /dev/null
@@ -1,15 +0,0 @@
-/*
-* A CSS cross-browser fix for Select2 failire to display HTML5 required warnings
-* MR link https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22716
-*/
-.gl-select2-html5-required-fix div.select2-container+select.select2 {
- @include gl-opacity-0;
- @include gl-border-0;
- @include gl-bg-none;
- @include gl-bg-transparent;
- display: block !important;
- width: 1px;
- height: 1px;
- z-index: -1;
- margin: -3px auto 0;
-}
diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss
index 849749ee7c7..9f7a8860e4d 100644
--- a/app/assets/stylesheets/startup/startup-dark.scss
+++ b/app/assets/stylesheets/startup/startup-dark.scss
@@ -1031,6 +1031,7 @@ body.gl-dark .logo-text svg {
}
body.gl-dark .navbar-gitlab {
background-color: #2e2e2e;
+ box-shadow: 0 1px 0 0 var(--gray-100);
}
body.gl-dark .navbar-gitlab .navbar-sub-nav li.active > a,
body.gl-dark .navbar-gitlab .navbar-sub-nav li.active > button,
diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss
index 4b88b94f3a6..6b78abdb5e0 100644
--- a/app/assets/stylesheets/startup/startup-signin.scss
+++ b/app/assets/stylesheets/startup/startup-signin.scss
@@ -2142,11 +2142,6 @@ table.code {
width: 100%;
padding: 8px;
}
-.login-page .omniauth-container .omniauth-btn img {
- width: 1.125rem;
- height: 1.125rem;
- margin-right: 16px;
-}
.login-page .new-session-tabs {
display: flex;
box-shadow: 0 0 0 1px #dbdbdb;
diff --git a/app/assets/stylesheets/test_environment.scss b/app/assets/stylesheets/test_environment.scss
new file mode 100644
index 00000000000..e9ba41f9bb7
--- /dev/null
+++ b/app/assets/stylesheets/test_environment.scss
@@ -0,0 +1,11 @@
+// Disable sticky changes bar for tests
+.diff-files-changed {
+ position: relative !important;
+ top: 0 !important;
+}
+
+// Un-hide inputs for @gitlab/ui custom checkboxes and radios so Capybara can target them
+.custom-control-input {
+ z-index: 500;
+ opacity: 1;
+}
diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss
index 417377b514e..5b3e2ab4cd0 100644
--- a/app/assets/stylesheets/themes/theme_helper.scss
+++ b/app/assets/stylesheets/themes/theme_helper.scss
@@ -92,6 +92,7 @@
.notification-dot {
will-change: border-color, background-color;
+ // stylelint-disable-next-line
border-color: $nav-svg-color + 33;
}
@@ -204,6 +205,10 @@
}
}
+ .emoji-picker-category-active {
+ border-bottom-color: $active-tab-border;
+ }
+
.branch-header-title {
color: $border-and-box-shadow;
}
diff --git a/app/assets/stylesheets/themes/theme_light.scss b/app/assets/stylesheets/themes/theme_light.scss
index 58003db4236..228bff94f5d 100644
--- a/app/assets/stylesheets/themes/theme_light.scss
+++ b/app/assets/stylesheets/themes/theme_light.scss
@@ -84,11 +84,12 @@ body {
&.gl-dark {
.logo-text svg {
- fill: $gl-text-color;
+ fill: var(--gl-text-color);
}
.navbar-gitlab {
- background-color: $gray-50;
+ background-color: var(--gray-50);
+ box-shadow: 0 1px 0 0 var(--gray-100);
.navbar-sub-nav,
.navbar-nav {
@@ -97,8 +98,8 @@ body {
> a:focus,
> button:hover,
> button:focus {
- color: $gl-text-color;
- background-color: $gray-200;
+ color: var(--gl-text-color);
+ background-color: var(--gray-200);
}
}
@@ -106,21 +107,21 @@ body {
li.dropdown.show {
> a,
> button {
- color: $gl-text-color;
- background-color: $gray-200;
+ color: var(--gl-text-color);
+ background-color: var(--gray-200);
}
}
}
.search {
form {
- background-color: $gray-100;
- box-shadow: inset 0 0 0 1px $border-color;
+ background-color: var(--gray-100);
+ box-shadow: inset 0 0 0 1px var(--border-color);
&:active,
&:hover {
- background-color: $gray-100;
- box-shadow: inset 0 0 0 1px $blue-200;
+ background-color: var(--gray-100);
+ box-shadow: inset 0 0 0 1px var(--blue-200);
}
}
}
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index d424dcbf8f2..024162eba3e 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -117,6 +117,7 @@
flex-basis: 25%;
}
+// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1168
.gl-md-ml-3 {
@media (min-width: $breakpoint-md) {
margin-left: $gl-spacing-scale-3;
@@ -157,3 +158,17 @@
margin-bottom: $gl-spacing-scale-4 !important;
}
}
+
+// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1168
+.gl-sm-pr-3 {
+ @media (min-width: $breakpoint-sm) {
+ padding-right: $gl-spacing-scale-3;
+ }
+}
+
+// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1168
+.gl-sm-w-half {
+ @media (min-width: $breakpoint-sm) {
+ width: 50%;
+ }
+}
diff --git a/app/assets/stylesheets/vendors/atwho.scss b/app/assets/stylesheets/vendors/atwho.scss
index f31dbbeafe8..b92331facee 100644
--- a/app/assets/stylesheets/vendors/atwho.scss
+++ b/app/assets/stylesheets/vendors/atwho.scss
@@ -1,6 +1,7 @@
.atwho-view {
overflow-y: auto;
overflow-x: hidden;
+ max-width: calc(100% - 6px);
.name,
small.aliases,
@@ -80,10 +81,6 @@
}
@include media-breakpoint-down(xs) {
- .atwho-view-ul {
- width: 350px;
- }
-
.atwho-view ul li {
overflow: hidden;
text-overflow: ellipsis;