summaryrefslogtreecommitdiff
path: root/app/assets/javascripts
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2019-10-22 11:31:16 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2019-10-22 11:31:16 +0000
commit905c1110b08f93a19661cf42a276c7ea90d0a0ff (patch)
tree756d138db422392c00471ab06acdff92c5a9b69c /app/assets/javascripts
parent50d93f8d1686950fc58dda4823c4835fd0d8c14b (diff)
downloadgitlab-ce-905c1110b08f93a19661cf42a276c7ea90d0a0ff.tar.gz
Add latest changes from gitlab-org/gitlab@12-4-stable-ee
Diffstat (limited to 'app/assets/javascripts')
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/mixins/add_stage_mixin.js11
-rw-r--r--app/assets/javascripts/api.js22
-rw-r--r--app/assets/javascripts/autosave.js4
-rw-r--r--app/assets/javascripts/badges/components/badge_settings.vue4
-rw-r--r--app/assets/javascripts/behaviors/markdown/editor_extensions.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/audio.js53
-rw-r--r--app/assets/javascripts/behaviors/preview_markdown.js46
-rw-r--r--app/assets/javascripts/behaviors/requires_input.js2
-rw-r--r--app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js37
-rw-r--r--app/assets/javascripts/blob/blob_file_dropzone.js10
-rw-r--r--app/assets/javascripts/blob/file_template_mediator.js49
-rw-r--r--app/assets/javascripts/blob/template_selector.js13
-rw-r--r--app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js1
-rw-r--r--app/assets/javascripts/blob/template_selectors/dockerfile_selector.js1
-rw-r--r--app/assets/javascripts/blob/template_selectors/gitignore_selector.js1
-rw-r--r--app/assets/javascripts/blob/template_selectors/license_selector.js1
-rw-r--r--app/assets/javascripts/blob/template_selectors/type_selector.js1
-rw-r--r--app/assets/javascripts/blob/viewer/index.js6
-rw-r--r--app/assets/javascripts/boards/components/board_blank_state.vue30
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue20
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue1
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue218
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue7
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.vue12
-rw-r--r--app/assets/javascripts/boards/components/issue_time_estimate.vue2
-rw-r--r--app/assets/javascripts/boards/constants.js11
-rw-r--r--app/assets/javascripts/boards/index.js19
-rw-r--r--app/assets/javascripts/boards/mixins/issue_card_inner.js5
-rw-r--r--app/assets/javascripts/boards/models/list.js92
-rw-r--r--app/assets/javascripts/boards/services/board_service.js10
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js148
-rw-r--r--app/assets/javascripts/build_artifacts.js10
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js9
-rw-r--r--app/assets/javascripts/clusters/components/application_row.vue9
-rw-r--r--app/assets/javascripts/clusters/components/applications.vue43
-rw-r--r--app/assets/javascripts/clusters/components/knative_domain_editor.vue2
-rw-r--r--app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue13
-rw-r--r--app/assets/javascripts/clusters/constants.js7
-rw-r--r--app/assets/javascripts/clusters/services/application_state_machine.js25
-rw-r--r--app/assets/javascripts/clusters/stores/clusters_store.js13
-rw-r--r--app/assets/javascripts/commit/image_file.js22
-rw-r--r--app/assets/javascripts/commons/vue.js4
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue13
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue17
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue393
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue63
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/role_name_dropdown.vue53
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/subnet_dropdown.vue0
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/vpc_dropdown.vue0
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/constants.js7
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/index.js17
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js84
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/actions.js42
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/actions.js14
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/getters.js (renamed from app/assets/javascripts/create_cluster/eks_cluster/components/security_group_dropdown.vue)0
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js13
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutation_types.js3
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutations.js16
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/state.js5
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/index.js32
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js10
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js34
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/state.js21
-rw-r--r--app/assets/javascripts/create_item_dropdown.js1
-rw-r--r--app/assets/javascripts/create_label.js4
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue2
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js8
-rw-r--r--app/assets/javascripts/diff_notes/services/resolve.js1
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue2
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_view.vue11
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_view.vue13
-rw-r--r--app/assets/javascripts/environments/components/stop_environment_modal.vue4
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue53
-rw-r--r--app/assets/javascripts/error_tracking/utils.js23
-rw-r--r--app/assets/javascripts/event_tracking/issue_sidebar.js2
-rw-r--r--app/assets/javascripts/event_tracking/notes.js2
-rw-r--r--app/assets/javascripts/filterable_list.js1
-rw-r--r--app/assets/javascripts/flash.js11
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js1
-rw-r--r--app/assets/javascripts/gl_dropdown.js140
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue6
-rw-r--r--app/assets/javascripts/groups/components/item_actions.vue4
-rw-r--r--app/assets/javascripts/groups_select.js175
-rw-r--r--app/assets/javascripts/header.js5
-rw-r--r--app/assets/javascripts/ide/.eslintrc.yml3
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue7
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list.vue4
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue4
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_item.vue5
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue4
-rw-r--r--app/assets/javascripts/ide/components/external_link.vue2
-rw-r--r--app/assets/javascripts/ide/components/file_row_extra.vue12
-rw-r--r--app/assets/javascripts/ide/components/ide_tree_list.vue7
-rw-r--r--app/assets/javascripts/ide/components/jobs/stage.vue2
-rw-r--r--app/assets/javascripts/ide/components/mr_file_icon.vue2
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/button.vue2
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue5
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue24
-rw-r--r--app/assets/javascripts/ide/components/repo_file_status_icon.vue2
-rw-r--r--app/assets/javascripts/ide/lib/files.js4
-rw-r--r--app/assets/javascripts/ide/stores/actions.js71
-rw-r--r--app/assets/javascripts/ide/stores/actions/file.js9
-rw-r--r--app/assets/javascripts/ide/stores/actions/project.js84
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js14
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js4
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js114
-rw-r--r--app/assets/javascripts/ide/stores/mutations/file.js18
-rw-r--r--app/assets/javascripts/ide/stores/state.js1
-rw-r--r--app/assets/javascripts/ide/stores/utils.js70
-rw-r--r--app/assets/javascripts/image_diff/helpers/badge_helper.js4
-rw-r--r--app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js4
-rw-r--r--app/assets/javascripts/import_projects/components/import_projects_table.vue45
-rw-r--r--app/assets/javascripts/import_projects/index.js2
-rw-r--r--app/assets/javascripts/import_projects/store/actions.js22
-rw-r--r--app/assets/javascripts/import_projects/store/getters.js5
-rw-r--r--app/assets/javascripts/import_projects/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/import_projects/store/mutations.js4
-rw-r--r--app/assets/javascripts/import_projects/store/state.js1
-rw-r--r--app/assets/javascripts/integrations/integration_settings_form.js1
-rw-r--r--app/assets/javascripts/issuable_bulk_update_actions.js4
-rw-r--r--app/assets/javascripts/issuable_sidebar/components/sidebar_app.vue23
-rw-r--r--app/assets/javascripts/issuable_sidebar/sidebar_bundle.js27
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue36
-rw-r--r--app/assets/javascripts/issue_show/index.js5
-rw-r--r--app/assets/javascripts/issue_show/services/index.js9
-rw-r--r--app/assets/javascripts/issue_show/stores/index.js12
-rw-r--r--app/assets/javascripts/issue_show/utils/update_description.js38
-rw-r--r--app/assets/javascripts/jobs/components/commit_block.vue2
-rw-r--r--app/assets/javascripts/jobs/components/environments_block.vue151
-rw-r--r--app/assets/javascripts/jobs/components/job_app.vue13
-rw-r--r--app/assets/javascripts/jobs/components/job_container_item.vue4
-rw-r--r--app/assets/javascripts/jobs/components/job_log.vue52
-rw-r--r--app/assets/javascripts/jobs/components/job_log_json.vue10
-rw-r--r--app/assets/javascripts/jobs/components/log/collapsible_section.vue51
-rw-r--r--app/assets/javascripts/jobs/components/log/duration_badge.vue2
-rw-r--r--app/assets/javascripts/jobs/components/log/line.vue12
-rw-r--r--app/assets/javascripts/jobs/components/log/line_header.vue12
-rw-r--r--app/assets/javascripts/jobs/components/log/line_number.vue2
-rw-r--r--app/assets/javascripts/jobs/components/log/log.vue29
-rw-r--r--app/assets/javascripts/jobs/store/mutations.js10
-rw-r--r--app/assets/javascripts/jobs/store/state.js1
-rw-r--r--app/assets/javascripts/jobs/store/utils.js196
-rw-r--r--app/assets/javascripts/labels_select.js41
-rw-r--r--app/assets/javascripts/lib/utils/axios_utils.js23
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js12
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js24
-rw-r--r--app/assets/javascripts/lib/utils/notify.js14
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js11
-rw-r--r--app/assets/javascripts/lib/utils/set.js9
-rw-r--r--app/assets/javascripts/lib/utils/suppress_ajax_errors_during_navigation.js16
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js8
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js8
-rw-r--r--app/assets/javascripts/line_highlighter.js10
-rw-r--r--app/assets/javascripts/main.js3
-rw-r--r--app/assets/javascripts/merge_request.js6
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue45
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue175
-rw-r--r--app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue151
-rw-r--r--app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue77
-rw-r--r--app/assets/javascripts/monitoring/components/embed.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/graph_group.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/panel_type.vue16
-rw-r--r--app/assets/javascripts/monitoring/constants.js10
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle.js1
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js12
-rw-r--r--app/assets/javascripts/monitoring/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/monitoring/stores/mutations.js18
-rw-r--r--app/assets/javascripts/monitoring/stores/state.js1
-rw-r--r--app/assets/javascripts/monitoring/stores/utils.js19
-rw-r--r--app/assets/javascripts/monitoring/utils.js98
-rw-r--r--app/assets/javascripts/namespace_select.js9
-rw-r--r--app/assets/javascripts/network/branch_graph.js8
-rw-r--r--app/assets/javascripts/new_branch_form.js8
-rw-r--r--app/assets/javascripts/notebook/cells/code.vue1
-rw-r--r--app/assets/javascripts/notebook/cells/code/index.vue12
-rw-r--r--app/assets/javascripts/notebook/cells/output/index.vue5
-rw-r--r--app/assets/javascripts/notes.js25
-rw-r--r--app/assets/javascripts/notes/components/discussion_actions.vue1
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter.vue8
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue10
-rw-r--r--app/assets/javascripts/notes/components/note_actions/reply_button.vue6
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue6
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue34
-rw-r--r--app/assets/javascripts/notes/constants.js3
-rw-r--r--app/assets/javascripts/notes/index.js4
-rw-r--r--app/assets/javascripts/notes/stores/actions.js106
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js7
-rw-r--r--app/assets/javascripts/pager.js4
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue4
-rw-r--r--app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue113
-rw-r--r--app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue77
-rw-r--r--app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue70
-rw-r--r--app/assets/javascripts/pages/admin/users/index.js75
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/todos.js6
-rw-r--r--app/assets/javascripts/pages/groups/registry/repositories/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/shared/group_details.js7
-rw-r--r--app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue4
-rw-r--r--app/assets/javascripts/pages/projects/clusters/new/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/commit/pipelines/index.js1
-rw-r--r--app/assets/javascripts/pages/projects/commit/show/index.js1
-rw-r--r--app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js6
-rw-r--r--app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js45
-rw-r--r--app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js14
-rw-r--r--app/assets/javascripts/pages/projects/issues/form.js4
-rw-r--r--app/assets/javascripts/pages/projects/issues/show.js7
-rw-r--r--app/assets/javascripts/pages/projects/issues/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue4
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js4
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js7
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/network/network.js4
-rw-r--r--app/assets/javascripts/pages/projects/project.js43
-rw-r--r--app/assets/javascripts/pages/projects/releases/edit/index.js9
-rw-r--r--app/assets/javascripts/pages/projects/releases/index/index.js2
-rw-r--r--app/assets/javascripts/pages/registrations/new/index.js9
-rw-r--r--app/assets/javascripts/pages/registrations/welcome/index.js7
-rw-r--r--app/assets/javascripts/pages/search/show/search.js1
-rw-r--r--app/assets/javascripts/performance_bar/components/detailed_metric.vue18
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue8
-rw-r--r--app/assets/javascripts/performance_bar/components/request_selector.vue29
-rw-r--r--app/assets/javascripts/performance_bar/components/request_warning.vue41
-rw-r--r--app/assets/javascripts/performance_bar/index.js4
-rw-r--r--app/assets/javascripts/performance_bar/stores/performance_bar_store.js8
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue169
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue65
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue52
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_url.vue2
-rw-r--r--app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js60
-rw-r--r--app/assets/javascripts/pipelines/mixins/stage_column_mixin.js9
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js6
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_mediator.js2
-rw-r--r--app/assets/javascripts/pipelines/stores/pipeline_store.js190
-rw-r--r--app/assets/javascripts/privacy_policy_update_callout.js2
-rw-r--r--app/assets/javascripts/profile/account/components/update_username.vue4
-rw-r--r--app/assets/javascripts/project_find_file.js9
-rw-r--r--app/assets/javascripts/project_select.js181
-rw-r--r--app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue14
-rw-r--r--app/assets/javascripts/ref_select_dropdown.js1
-rw-r--r--app/assets/javascripts/registry/components/app.vue112
-rw-r--r--app/assets/javascripts/registry/components/collapsible_container.vue43
-rw-r--r--app/assets/javascripts/registry/components/group_empty_state.vue46
-rw-r--r--app/assets/javascripts/registry/components/project_empty_state.vue133
-rw-r--r--app/assets/javascripts/registry/components/table_registry.vue94
-rw-r--r--app/assets/javascripts/registry/index.js25
-rw-r--r--app/assets/javascripts/registry/stores/actions.js2
-rw-r--r--app/assets/javascripts/registry/stores/getters.js1
-rw-r--r--app/assets/javascripts/registry/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/registry/stores/mutations.js5
-rw-r--r--app/assets/javascripts/registry/stores/state.js1
-rw-r--r--app/assets/javascripts/releases/components/milestone_list.vue45
-rw-r--r--app/assets/javascripts/releases/detail/components/app.vue156
-rw-r--r--app/assets/javascripts/releases/detail/index.js19
-rw-r--r--app/assets/javascripts/releases/detail/store/actions.js62
-rw-r--r--app/assets/javascripts/releases/detail/store/index.js14
-rw-r--r--app/assets/javascripts/releases/detail/store/mutation_types.js12
-rw-r--r--app/assets/javascripts/releases/detail/store/mutations.js42
-rw-r--r--app/assets/javascripts/releases/detail/store/state.js15
-rw-r--r--app/assets/javascripts/releases/list/components/app.vue (renamed from app/assets/javascripts/releases/components/app.vue)0
-rw-r--r--app/assets/javascripts/releases/list/components/release_block.vue (renamed from app/assets/javascripts/releases/components/release_block.vue)120
-rw-r--r--app/assets/javascripts/releases/list/index.js (renamed from app/assets/javascripts/releases/index.js)0
-rw-r--r--app/assets/javascripts/releases/list/store/actions.js (renamed from app/assets/javascripts/releases/store/actions.js)0
-rw-r--r--app/assets/javascripts/releases/list/store/index.js (renamed from app/assets/javascripts/releases/store/index.js)0
-rw-r--r--app/assets/javascripts/releases/list/store/mutation_types.js (renamed from app/assets/javascripts/releases/store/mutation_types.js)0
-rw-r--r--app/assets/javascripts/releases/list/store/mutations.js (renamed from app/assets/javascripts/releases/store/mutations.js)0
-rw-r--r--app/assets/javascripts/releases/list/store/state.js (renamed from app/assets/javascripts/releases/store/state.js)0
-rw-r--r--app/assets/javascripts/reports/components/modal.vue4
-rw-r--r--app/assets/javascripts/reports/components/report_section.vue2
-rw-r--r--app/assets/javascripts/reports/store/utils.js2
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue6
-rw-r--r--app/assets/javascripts/repository/queries/pathLastCommit.query.graphql1
-rw-r--r--app/assets/javascripts/right_sidebar.js4
-rw-r--r--app/assets/javascripts/search_autocomplete.js6
-rw-r--r--app/assets/javascripts/serverless/components/url.vue2
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_title.vue13
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue7
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue26
-rw-r--r--app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue11
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue18
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/todo.vue6
-rw-r--r--app/assets/javascripts/sidebar/lib/sidebar_move_issue.js1
-rw-r--r--app/assets/javascripts/single_file_diff.js11
-rw-r--r--app/assets/javascripts/snippet/snippet_embed.js41
-rw-r--r--app/assets/javascripts/templates/issuable_template_selector.js58
-rw-r--r--app/assets/javascripts/templates/issuable_template_selectors.js3
-rw-r--r--app/assets/javascripts/test_utils/index.js2
-rw-r--r--app/assets/javascripts/tracking.js103
-rw-r--r--app/assets/javascripts/tree.js4
-rw-r--r--app/assets/javascripts/user_popovers.js23
-rw-r--r--app/assets/javascripts/users_select.js39
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/artifacts_list.vue40
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/artifacts_list_app.vue36
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue88
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.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/stores/artifacts_list/actions.js74
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/getters.js16
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/index.js16
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/mutation_types.js5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/mutations.js22
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/state.js8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/changed_file_icon.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_icon.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/clipboard_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/deprecated_modal.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue118
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_modal.vue117
-rw-r--r--app/assets/javascripts/vue_shared/components/icon.vue52
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js21
-rw-r--r--app/assets/javascripts/vue_shared/components/recaptcha_modal.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/toggle_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue10
-rw-r--r--app/assets/javascripts/vue_shared/directives/track_event.js20
-rw-r--r--app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js7
-rw-r--r--app/assets/javascripts/vue_shared/mixins/gl_feature_flags_mixin.js8
-rw-r--r--app/assets/javascripts/vue_shared/plugins/global_toast.js3
-rw-r--r--app/assets/javascripts/vue_shared/vue_resource_interceptor.js34
-rw-r--r--app/assets/javascripts/zen_mode.js8
337 files changed, 6131 insertions, 2076 deletions
diff --git a/app/assets/javascripts/analytics/cycle_analytics/mixins/add_stage_mixin.js b/app/assets/javascripts/analytics/cycle_analytics/mixins/add_stage_mixin.js
deleted file mode 100644
index 6a40f1cbc5e..00000000000
--- a/app/assets/javascripts/analytics/cycle_analytics/mixins/add_stage_mixin.js
+++ /dev/null
@@ -1,11 +0,0 @@
-export default {
- data() {
- return {
- isCustomStageForm: false,
- };
- },
- methods: {
- showAddStageForm: () => {},
- hideAddStageForm: () => {},
- },
-};
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 992c5e5e330..908dc730aa4 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -36,6 +36,7 @@ const Api = {
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches',
releasesPath: '/api/:version/projects/:id/releases',
+ releasePath: '/api/:version/projects/:id/releases/:tag_name',
mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines',
adminStatisticsPath: 'api/:version/application/statistics',
@@ -74,6 +75,11 @@ const Api = {
});
},
+ groupLabels(namespace) {
+ const url = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespace);
+ return axios.get(url).then(({ data }) => data);
+ },
+
// Return namespaces list. Filtered by query
namespaces(query, callback) {
const url = Api.buildUrl(Api.namespacesPath);
@@ -386,6 +392,22 @@ const Api = {
return axios.get(url);
},
+ release(projectPath, tagName) {
+ const url = Api.buildUrl(this.releasePath)
+ .replace(':id', encodeURIComponent(projectPath))
+ .replace(':tag_name', encodeURIComponent(tagName));
+
+ return axios.get(url);
+ },
+
+ updateRelease(projectPath, tagName, release) {
+ const url = Api.buildUrl(this.releasePath)
+ .replace(':id', encodeURIComponent(projectPath))
+ .replace(':tag_name', encodeURIComponent(tagName));
+
+ return axios.put(url, release);
+ },
+
adminStatistics() {
const url = Api.buildUrl(this.adminStatisticsPath);
return axios.get(url);
diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js
index e8c59fab609..7652b67ae1e 100644
--- a/app/assets/javascripts/autosave.js
+++ b/app/assets/javascripts/autosave.js
@@ -1,4 +1,4 @@
-/* eslint-disable no-param-reassign, prefer-template, no-void, consistent-return */
+/* eslint-disable no-param-reassign, no-void, consistent-return */
import AccessorUtilities from './lib/utils/accessor';
@@ -10,7 +10,7 @@ export default class Autosave {
if (key.join != null) {
key = key.join('/');
}
- this.key = 'autosave/' + key;
+ this.key = `autosave/${key}`;
this.field.data('autosave', this);
this.restore();
this.field.on('input', () => this.save());
diff --git a/app/assets/javascripts/badges/components/badge_settings.vue b/app/assets/javascripts/badges/components/badge_settings.vue
index 75a522efe7e..531f84ad272 100644
--- a/app/assets/javascripts/badges/components/badge_settings.vue
+++ b/app/assets/javascripts/badges/components/badge_settings.vue
@@ -2,7 +2,7 @@
import { mapState, mapActions } from 'vuex';
import createFlash from '~/flash';
import { s__ } from '~/locale';
-import GlModal from '~/vue_shared/components/gl_modal.vue';
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import Badge from './badge.vue';
import BadgeForm from './badge_form.vue';
import BadgeList from './badge_list.vue';
@@ -13,7 +13,7 @@ export default {
Badge,
BadgeForm,
BadgeList,
- GlModal,
+ GlModal: DeprecatedModal2,
},
computed: {
...mapState(['badgeInModal', 'isEditing']),
diff --git a/app/assets/javascripts/behaviors/markdown/editor_extensions.js b/app/assets/javascripts/behaviors/markdown/editor_extensions.js
index 47e5fc65c48..8bd2145db1c 100644
--- a/app/assets/javascripts/behaviors/markdown/editor_extensions.js
+++ b/app/assets/javascripts/behaviors/markdown/editor_extensions.js
@@ -21,6 +21,7 @@ import Reference from './nodes/reference';
import TableOfContents from './nodes/table_of_contents';
import Video from './nodes/video';
+import Audio from './nodes/audio';
import BulletList from './nodes/bullet_list';
import OrderedList from './nodes/ordered_list';
@@ -78,6 +79,7 @@ export default [
new TableOfContents(),
new Video(),
+ new Audio(),
new BulletList(),
new OrderedList(),
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/audio.js b/app/assets/javascripts/behaviors/markdown/nodes/audio.js
new file mode 100644
index 00000000000..48ac408cf24
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/audio.js
@@ -0,0 +1,53 @@
+/* eslint-disable class-methods-use-this */
+
+import { Node } from 'tiptap';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::AudioLinkFilter
+export default class Audio extends Node {
+ get name() {
+ return 'audio';
+ }
+
+ get schema() {
+ return {
+ attrs: {
+ src: {},
+ alt: {
+ default: null,
+ },
+ },
+ group: 'block',
+ draggable: true,
+ parseDOM: [
+ {
+ tag: '.audio-container',
+ skip: true,
+ },
+ {
+ tag: '.audio-container p',
+ priority: 51,
+ ignore: true,
+ },
+ {
+ tag: 'audio[src]',
+ getAttrs: el => ({ src: el.getAttribute('src'), alt: el.dataset.title }),
+ },
+ ],
+ toDOM: node => [
+ 'audio',
+ {
+ src: node.attrs.src,
+ controls: true,
+ 'data-setup': '{}',
+ 'data-title': node.attrs.alt,
+ },
+ ],
+ };
+ }
+
+ toMarkdown(state, node) {
+ defaultMarkdownSerializer.nodes.image(state, node);
+ state.closeBlock(node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js
index 1909830e9ed..a07942d87cb 100644
--- a/app/assets/javascripts/behaviors/preview_markdown.js
+++ b/app/assets/javascripts/behaviors/preview_markdown.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, prefer-arrow-callback */
+/* eslint-disable func-names, no-var */
import $ from 'jquery';
import axios from '~/lib/utils/axios_utils';
@@ -45,26 +45,22 @@ MarkdownPreview.prototype.showPreview = function($form) {
this.hideReferencedUsers($form);
} else {
preview.addClass('md-preview-loading').text(__('Loading...'));
- this.fetchMarkdownPreview(
- mdText,
- url,
- function(response) {
- var body;
- if (response.body.length > 0) {
- ({ body } = response);
- } else {
- body = this.emptyMessage;
- }
-
- preview.removeClass('md-preview-loading').html(body);
- preview.renderGFM();
- this.renderReferencedUsers(response.references.users, $form);
-
- if (response.references.commands) {
- this.renderReferencedCommands(response.references.commands, $form);
- }
- }.bind(this),
- );
+ this.fetchMarkdownPreview(mdText, url, response => {
+ var body;
+ if (response.body.length > 0) {
+ ({ body } = response);
+ } else {
+ body = this.emptyMessage;
+ }
+
+ preview.removeClass('md-preview-loading').html(body);
+ preview.renderGFM();
+ this.renderReferencedUsers(response.references.users, $form);
+
+ if (response.references.commands) {
+ this.renderReferencedCommands(response.references.commands, $form);
+ }
+ });
}
};
@@ -132,12 +128,12 @@ const markdownToolbar = $('.md-header-toolbar');
$.fn.setupMarkdownPreview = function() {
var $form = $(this);
- $form.find('textarea.markdown-area').on('input', function() {
+ $form.find('textarea.markdown-area').on('input', () => {
markdownPreview.hideReferencedUsers($form);
});
};
-$(document).on('markdown-preview:show', function(e, $form) {
+$(document).on('markdown-preview:show', (e, $form) => {
if (!$form) {
return;
}
@@ -162,7 +158,7 @@ $(document).on('markdown-preview:show', function(e, $form) {
markdownPreview.showPreview($form);
});
-$(document).on('markdown-preview:hide', function(e, $form) {
+$(document).on('markdown-preview:hide', (e, $form) => {
if (!$form) {
return;
}
@@ -191,7 +187,7 @@ $(document).on('markdown-preview:hide', function(e, $form) {
markdownPreview.hideReferencedCommands($form);
});
-$(document).on('markdown-preview:toggle', function(e, keyboardEvent) {
+$(document).on('markdown-preview:toggle', (e, keyboardEvent) => {
var $target;
$target = $(keyboardEvent.target);
if ($target.is('textarea.markdown-area')) {
diff --git a/app/assets/javascripts/behaviors/requires_input.js b/app/assets/javascripts/behaviors/requires_input.js
index d8056e48d4e..7cf18d1fd83 100644
--- a/app/assets/javascripts/behaviors/requires_input.js
+++ b/app/assets/javascripts/behaviors/requires_input.js
@@ -26,7 +26,7 @@ $.fn.requiresInput = function requiresInput() {
const values = _.map($(fieldSelector, $form), field => field.value);
// Disable the button if any required fields are empty
- if (values.length && _.any(values, _.isEmpty)) {
+ if (values.length && _.some(values, _.isEmpty)) {
$button.disable();
} else {
$button.enable();
diff --git a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js
index 75777b910ca..87c8568802e 100644
--- a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js
+++ b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js
@@ -1,5 +1,7 @@
import sqljs from 'sql.js';
import { template as _template } from 'underscore';
+import axios from '~/lib/utils/axios_utils';
+import { successCodes } from '~/lib/utils/http_status';
const PREVIEW_TEMPLATE = _template(`
<div class="card">
@@ -16,30 +18,25 @@ class BalsamiqViewer {
}
loadFile(endpoint) {
- return new Promise((resolve, reject) => {
- const xhr = new XMLHttpRequest();
-
- xhr.open('GET', endpoint, true);
- xhr.responseType = 'arraybuffer';
- xhr.onload = loadEvent => this.fileLoaded(loadEvent, resolve, reject);
- xhr.onerror = reject;
-
- xhr.send();
- });
- }
-
- fileLoaded(loadEvent, resolve, reject) {
- if (loadEvent.target.status !== 200) return reject();
-
- this.renderFile(loadEvent);
-
- return resolve();
+ return axios
+ .get(endpoint, {
+ responseType: 'arraybuffer',
+ validateStatus(status) {
+ return status !== successCodes.OK;
+ },
+ })
+ .then(({ data }) => {
+ this.renderFile(data);
+ })
+ .catch(e => {
+ throw new Error(e);
+ });
}
- renderFile(loadEvent) {
+ renderFile(fileBuffer) {
const container = document.createElement('ul');
- this.initDatabase(loadEvent.target.response);
+ this.initDatabase(fileBuffer);
const previews = this.getPreviews();
previews.forEach(preview => {
diff --git a/app/assets/javascripts/blob/blob_file_dropzone.js b/app/assets/javascripts/blob/blob_file_dropzone.js
index 9f0680cc6a7..8acf0827c44 100644
--- a/app/assets/javascripts/blob/blob_file_dropzone.js
+++ b/app/assets/javascripts/blob/blob_file_dropzone.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, prefer-arrow-callback */
+/* eslint-disable func-names */
import $ from 'jquery';
import Dropzone from 'dropzone';
@@ -43,18 +43,18 @@ export default class BlobFileDropzone {
previewsContainer: '.dropzone-previews',
headers: csrf.headers,
init() {
- this.on('addedfile', function() {
+ this.on('addedfile', () => {
toggleLoading(submitButton, submitButtonLoadingIcon, false);
dropzoneMessage.addClass(HIDDEN_CLASS);
$('.dropzone-alerts')
.html('')
.hide();
});
- this.on('removedfile', function() {
+ this.on('removedfile', () => {
toggleLoading(submitButton, submitButtonLoadingIcon, false);
dropzoneMessage.removeClass(HIDDEN_CLASS);
});
- this.on('success', function(header, response) {
+ this.on('success', (header, response) => {
$('#modal-upload-blob').modal('hide');
visitUrl(response.filePath);
});
@@ -62,7 +62,7 @@ export default class BlobFileDropzone {
dropzoneMessage.addClass(HIDDEN_CLASS);
this.removeFile(file);
});
- this.on('sending', function(file, xhr, formData) {
+ this.on('sending', (file, xhr, formData) => {
formData.append('branch_name', form.find('.js-branch-name').val());
formData.append('create_merge_request', form.find('.js-create-merge-request').val());
formData.append('commit_message', form.find('.js-commit-message').val());
diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js
index 106fe2e0cef..b371f6be268 100644
--- a/app/assets/javascripts/blob/file_template_mediator.js
+++ b/app/assets/javascripts/blob/file_template_mediator.js
@@ -7,6 +7,8 @@ import BlobCiYamlSelector from './template_selectors/ci_yaml_selector';
import DockerfileSelector from './template_selectors/dockerfile_selector';
import GitignoreSelector from './template_selectors/gitignore_selector';
import LicenseSelector from './template_selectors/license_selector';
+import toast from '~/vue_shared/plugins/global_toast';
+import { __ } from '~/locale';
export default class FileTemplateMediator {
constructor({ editor, currentAction, projectId }) {
@@ -19,6 +21,7 @@ export default class FileTemplateMediator {
this.initDomElements();
this.initDropdowns();
this.initPageEvents();
+ this.cacheFileContents();
}
initTemplateSelectors() {
@@ -40,6 +43,7 @@ export default class FileTemplateMediator {
return {
name: cfg.name,
key: cfg.key,
+ id: cfg.key,
};
}),
});
@@ -58,6 +62,7 @@ export default class FileTemplateMediator {
this.$fileContent = $fileEditor.find('#file-content');
this.$commitForm = $fileEditor.find('form');
this.$navLinks = $fileEditor.find('.nav-links');
+ this.$templateTypes = this.$templateSelectors.find('.template-type-selector');
}
initDropdowns() {
@@ -113,7 +118,11 @@ export default class FileTemplateMediator {
}
});
- this.typeSelector.setToggleText(item.name);
+ this.setFilename(item.name);
+
+ if (this.editor.getValue() !== '') {
+ this.setTypeSelectorToggleText(item.name);
+ }
this.cacheToggleText();
}
@@ -123,15 +132,24 @@ export default class FileTemplateMediator {
}
selectTemplateFile(selector, query, data) {
+ const self = this;
+
selector.renderLoading();
- // in case undo menu is already there
- this.destroyUndoMenu();
+
this.fetchFileTemplate(selector.config.type, query, data)
.then(file => {
- this.showUndoMenu();
this.setEditorContent(file);
- this.setFilename(selector.config.name);
selector.renderLoaded();
+ this.typeSelector.setToggleText(selector.config.name);
+ toast(__(`${query} template applied`), {
+ action: {
+ text: __('Undo'),
+ onClick: (e, toastObj) => {
+ self.restoreFromCache();
+ toastObj.goAway(0);
+ },
+ },
+ });
})
.catch(err => new Flash(`An error occurred while fetching the template: ${err}`));
}
@@ -173,22 +191,6 @@ export default class FileTemplateMediator {
return this.templateSelectors.find(selector => selector.config.key === key);
}
- showUndoMenu() {
- this.$undoMenu.removeClass('hidden');
-
- this.$undoBtn.on('click', () => {
- this.restoreFromCache();
- this.destroyUndoMenu();
- });
- }
-
- destroyUndoMenu() {
- this.cacheFileContents();
- this.cacheToggleText();
- this.$undoMenu.addClass('hidden');
- this.$undoBtn.off('click');
- }
-
hideTemplateSelectorMenu() {
this.$templatesMenu.hide();
}
@@ -210,6 +212,7 @@ export default class FileTemplateMediator {
this.setEditorContent(this.cachedContent);
this.setFilename(this.cachedFilename);
this.setTemplateSelectorToggleText();
+ this.setTypeSelectorToggleText(__('Select a template type'));
}
getTemplateSelectorToggleText() {
@@ -228,6 +231,10 @@ export default class FileTemplateMediator {
return this.typeSelector.getToggleText();
}
+ setTypeSelectorToggleText(text) {
+ this.typeSelector.setToggleText(text);
+ }
+
getFilename() {
return this.$filenameInput.val();
}
diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js
index 9e69c7d7164..b0de4dc8628 100644
--- a/app/assets/javascripts/blob/template_selector.js
+++ b/app/assets/javascripts/blob/template_selector.js
@@ -1,6 +1,7 @@
/* eslint-disable class-methods-use-this */
import $ from 'jquery';
+import '~/gl_dropdown';
export default class TemplateSelector {
constructor({ dropdown, data, pattern, wrapper, editor, $input } = {}) {
@@ -26,11 +27,16 @@ export default class TemplateSelector {
search: {
fields: ['name'],
},
- clicked: options => this.fetchFileTemplate(options),
+ clicked: options => this.onDropdownClicked(options),
text: item => item.name,
});
}
+ // Subclasses can override this method to conditionally prevent fetching file templates
+ onDropdownClicked(options) {
+ this.fetchFileTemplate(options);
+ }
+
initAutosizeUpdateEvent() {
this.autosizeUpdateEvent = document.createEvent('Event');
this.autosizeUpdateEvent.initEvent('autosize:update', true, false);
@@ -77,9 +83,14 @@ export default class TemplateSelector {
if (this.editor instanceof $) {
this.editor.get(0).dispatchEvent(this.autosizeUpdateEvent);
+ this.editor.trigger('input');
}
}
+ getEditorContent() {
+ return this.editor.getValue();
+ }
+
startLoadingSpinner() {
this.$dropdownIcon.addClass('fa-spinner fa-spin').removeClass('fa-chevron-down');
}
diff --git a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js
index 43f7aead8b9..d819452df68 100644
--- a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js
@@ -19,7 +19,6 @@ export default class BlobCiYamlSelector extends FileTemplateSelector {
data: this.$dropdown.data('data'),
filterable: true,
selectable: true,
- toggleLabel: item => item.name,
search: {
fields: ['name'],
},
diff --git a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
index 659d57e6a6f..7d5e98889d3 100644
--- a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
@@ -20,7 +20,6 @@ export default class DockerfileSelector extends FileTemplateSelector {
data: this.$dropdown.data('data'),
filterable: true,
selectable: true,
- toggleLabel: item => item.name,
search: {
fields: ['name'],
},
diff --git a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js
index a8067ec5c84..39a8937641d 100644
--- a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js
@@ -18,7 +18,6 @@ export default class BlobGitignoreSelector extends FileTemplateSelector {
data: this.$dropdown.data('data'),
filterable: true,
selectable: true,
- toggleLabel: item => item.name,
search: {
fields: ['name'],
},
diff --git a/app/assets/javascripts/blob/template_selectors/license_selector.js b/app/assets/javascripts/blob/template_selectors/license_selector.js
index d01ab9257d6..f4041835a7d 100644
--- a/app/assets/javascripts/blob/template_selectors/license_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/license_selector.js
@@ -18,7 +18,6 @@ export default class BlobLicenseSelector extends FileTemplateSelector {
data: this.$dropdown.data('data'),
filterable: true,
selectable: true,
- toggleLabel: item => item.name,
search: {
fields: ['name'],
},
diff --git a/app/assets/javascripts/blob/template_selectors/type_selector.js b/app/assets/javascripts/blob/template_selectors/type_selector.js
index db3c144cbe3..cb4e1aaa9ac 100644
--- a/app/assets/javascripts/blob/template_selectors/type_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/type_selector.js
@@ -16,7 +16,6 @@ export default class FileTemplateTypeSelector extends FileTemplateSelector {
data: this.config.dropdownData,
filterable: false,
selectable: true,
- toggleLabel: item => item.name,
clicked: options => this.mediator.selectTemplateTypeOptions(options),
text: item => item.name,
});
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index 9ea455069f3..07e4dde41d9 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -107,18 +107,18 @@ export default class BlobViewer {
toggleCopyButtonState() {
if (!this.copySourceBtn) return;
if (this.simpleViewer.getAttribute('data-loaded')) {
- this.copySourceBtn.setAttribute('title', __('Copy source to clipboard'));
+ this.copySourceBtn.setAttribute('title', __('Copy file contents'));
this.copySourceBtn.classList.remove('disabled');
} else if (this.activeViewer === this.simpleViewer) {
this.copySourceBtn.setAttribute(
'title',
- __('Wait for the source to load to copy it to the clipboard'),
+ __('Wait for the file to load to copy its contents'),
);
this.copySourceBtn.classList.add('disabled');
} else {
this.copySourceBtn.setAttribute(
'title',
- __('Switch to the source to copy it to the clipboard'),
+ __('Switch to the source to copy the file contents'),
);
this.copySourceBtn.classList.add('disabled');
}
diff --git a/app/assets/javascripts/boards/components/board_blank_state.vue b/app/assets/javascripts/boards/components/board_blank_state.vue
index 9f26337d153..9a1da810ad0 100644
--- a/app/assets/javascripts/boards/components/board_blank_state.vue
+++ b/app/assets/javascripts/boards/components/board_blank_state.vue
@@ -29,25 +29,25 @@ export default {
});
});
+ const loadListIssues = listObj => {
+ const list = boardsStore.findList('title', listObj.title);
+
+ if (!list) {
+ return null;
+ }
+
+ list.id = listObj.id;
+ list.label.id = listObj.label.id;
+ return list.getIssues().catch(() => {
+ // TODO: handle request error
+ });
+ };
+
// Save the labels
boardsStore
.generateDefaultLists()
.then(res => res.data)
- .then(data => {
- data.forEach(listObj => {
- const list = boardsStore.findList('title', listObj.title);
-
- if (!list) {
- return;
- }
-
- list.id = listObj.id;
- list.label.id = listObj.label.id;
- list.getIssues().catch(() => {
- // TODO: handle request error
- });
- });
- })
+ .then(data => Promise.all(data.map(loadListIssues)))
.catch(() => {
boardsStore.removeList(undefined, 'label');
Cookies.remove('issue_board_welcome_hidden', {
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue
index faf722f61af..12d68256598 100644
--- a/app/assets/javascripts/boards/components/board_card.vue
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -42,12 +42,19 @@ export default {
return {
showDetail: false,
detailIssue: boardsStore.detail,
+ multiSelect: boardsStore.multiSelect,
};
},
computed: {
issueDetailVisible() {
return this.detailIssue.issue && this.detailIssue.issue.id === this.issue.id;
},
+ multiSelectVisible() {
+ return this.multiSelect.list.findIndex(issue => issue.id === this.issue.id) > -1;
+ },
+ canMultiSelect() {
+ return gon.features && gon.features.multiSelectBoard;
+ },
},
methods: {
mouseDown() {
@@ -58,14 +65,20 @@ export default {
},
showIssue(e) {
if (e.target.classList.contains('js-no-trigger')) return;
-
if (this.showDetail) {
this.showDetail = false;
+ // If CMD or CTRL is clicked
+ const isMultiSelect = this.canMultiSelect && (e.ctrlKey || e.metaKey);
+
if (boardsStore.detail.issue && boardsStore.detail.issue.id === this.issue.id) {
- eventHub.$emit('clearDetailIssue');
+ eventHub.$emit('clearDetailIssue', isMultiSelect);
+
+ if (isMultiSelect) {
+ eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
+ }
} else {
- eventHub.$emit('newDetailIssue', this.issue);
+ eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
boardsStore.setListDetail(this.list);
}
}
@@ -77,6 +90,7 @@ export default {
<template>
<li
:class="{
+ 'multi-select': multiSelectVisible,
'user-can-drag': !disabled && issue.id,
'is-disabled': disabled || !issue.id,
'is-active': issueDetailVisible,
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index ebf48cee2ae..34560560756 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -194,6 +194,7 @@ export default {
ref="name"
v-model="board.name"
class="form-control"
+ data-qa-selector="board_name_field"
type="text"
:placeholder="__('Enter board name')"
@keyup.enter="submit"
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index de41698ca04..1273fcc6a91 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -1,12 +1,22 @@
<script>
-/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
-import Sortable from 'sortablejs';
+import { Sortable, MultiDrag } from 'sortablejs';
import { GlLoadingIcon } from '@gitlab/ui';
+import _ from 'underscore';
import boardNewIssue from './board_new_issue.vue';
import boardCard from './board_card.vue';
import eventHub from '../eventhub';
import boardsStore from '../stores/boards_store';
-import { getBoardSortableDefaultOptions, sortableStart } from '../mixins/sortable_default_options';
+import { sprintf, __ } from '~/locale';
+import createFlash from '~/flash';
+import {
+ getBoardSortableDefaultOptions,
+ sortableStart,
+ sortableEnd,
+} from '../mixins/sortable_default_options';
+
+if (gon.features && gon.features.multiSelectBoard) {
+ Sortable.mount(new MultiDrag());
+}
export default {
name: 'BoardList',
@@ -54,6 +64,14 @@ export default {
showIssueForm: false,
};
},
+ computed: {
+ paginatedIssueText() {
+ return sprintf(__('Showing %{pageSize} of %{total} issues'), {
+ pageSize: this.list.issues.length,
+ total: this.list.issuesSize,
+ });
+ },
+ },
watch: {
filters: {
handler() {
@@ -87,11 +105,20 @@ export default {
eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
},
mounted() {
+ const multiSelectOpts = {};
+ if (gon.features && gon.features.multiSelectBoard) {
+ multiSelectOpts.multiDrag = true;
+ multiSelectOpts.selectedClass = 'js-multi-select';
+ multiSelectOpts.animation = 500;
+ }
+
const options = getBoardSortableDefaultOptions({
scroll: true,
disabled: this.disabled,
filter: '.board-list-count, .is-disabled',
dataIdAttr: 'data-issue-id',
+ removeCloneOnHide: false,
+ ...multiSelectOpts,
group: {
name: 'issues',
/**
@@ -145,25 +172,66 @@ export default {
card.showDetail = false;
const { list } = card;
+
const issue = list.findIssue(Number(e.item.dataset.issueId));
+
boardsStore.startMoving(list, issue);
sortableStart();
},
onAdd: e => {
- boardsStore.moveIssueToList(
- boardsStore.moving.list,
- this.list,
- boardsStore.moving.issue,
- e.newIndex,
- );
+ const { items = [], newIndicies = [] } = e;
+ if (items.length) {
+ // Not using e.newIndex here instead taking a min of all
+ // the newIndicies. Basically we have to find that during
+ // a drop what is the index we're going to start putting
+ // all the dropped elements from.
+ const newIndex = Math.min(...newIndicies.map(obj => obj.index).filter(i => i !== -1));
+ const issues = items.map(item =>
+ boardsStore.moving.list.findIssue(Number(item.dataset.issueId)),
+ );
- this.$nextTick(() => {
- e.item.remove();
- });
+ boardsStore.moveMultipleIssuesToList({
+ listFrom: boardsStore.moving.list,
+ listTo: this.list,
+ issues,
+ newIndex,
+ });
+ } else {
+ boardsStore.moveIssueToList(
+ boardsStore.moving.list,
+ this.list,
+ boardsStore.moving.issue,
+ e.newIndex,
+ );
+ this.$nextTick(() => {
+ e.item.remove();
+ });
+ }
},
onUpdate: e => {
const sortedArray = this.sortable.toArray().filter(id => id !== '-1');
+
+ const { items = [], newIndicies = [], oldIndicies = [] } = e;
+ if (items.length) {
+ const newIndex = Math.min(...newIndicies.map(obj => obj.index));
+ const issues = items.map(item =>
+ boardsStore.moving.list.findIssue(Number(item.dataset.issueId)),
+ );
+ boardsStore.moveMultipleIssuesInList({
+ list: this.list,
+ issues,
+ oldIndicies: oldIndicies.map(obj => obj.index),
+ newIndex,
+ idArray: sortedArray,
+ });
+ e.items.forEach(el => {
+ Sortable.utils.deselect(el);
+ });
+ boardsStore.clearMultiSelect();
+ return;
+ }
+
boardsStore.moveIssueInList(
this.list,
boardsStore.moving.issue,
@@ -172,9 +240,133 @@ export default {
sortedArray,
);
},
+ onEnd: e => {
+ const { items = [], clones = [], to } = e;
+
+ // This is not a multi select operation
+ if (!items.length && !clones.length) {
+ sortableEnd();
+ return;
+ }
+
+ let toList;
+ if (to) {
+ const containerEl = to.closest('.js-board-list');
+ toList = boardsStore.findList('id', Number(containerEl.dataset.board));
+ }
+
+ /**
+ * onEnd is called irrespective if the cards were moved in the
+ * same list or the other list. Don't remove items if it's same list.
+ */
+ const isSameList = toList && toList.id === this.list.id;
+
+ if (toList && !isSameList && boardsStore.shouldRemoveIssue(this.list, toList)) {
+ const issues = items.map(item => this.list.findIssue(Number(item.dataset.issueId)));
+
+ if (_.compact(issues).length && !boardsStore.issuesAreContiguous(this.list, issues)) {
+ const indexes = [];
+ const ids = this.list.issues.map(i => i.id);
+ issues.forEach(issue => {
+ const index = ids.indexOf(issue.id);
+ if (index > -1) {
+ indexes.push(index);
+ }
+ });
+
+ // Descending sort because splice would cause index discrepancy otherwise
+ const sortedIndexes = indexes.sort((a, b) => (a < b ? 1 : -1));
+
+ sortedIndexes.forEach(i => {
+ /**
+ * **setTimeout and splice each element one-by-one in a loop
+ * is intended.**
+ *
+ * The problem here is all the indexes are in the list but are
+ * non-contiguous. Due to that, when we splice all the indexes,
+ * at once, Vue -- during a re-render -- is unable to find reference
+ * nodes and the entire app crashes.
+ *
+ * If the indexes are contiguous, this piece of code is not
+ * executed. If it is, this is a possible regression. Only when
+ * issue indexes are far apart, this logic should ever kick in.
+ */
+ setTimeout(() => {
+ this.list.issues.splice(i, 1);
+ }, 0);
+ });
+ }
+ }
+
+ if (!toList) {
+ createFlash(__('Something went wrong while performing the action.'));
+ }
+
+ if (!isSameList) {
+ boardsStore.clearMultiSelect();
+
+ // Since Vue's list does not re-render the same keyed item, we'll
+ // remove `multi-select` class to express it's unselected
+ if (clones && clones.length) {
+ clones.forEach(el => el.classList.remove('multi-select'));
+ }
+
+ // Due to some bug which I am unable to figure out
+ // Sortable does not deselect some pending items from the
+ // source list.
+ // We'll just do it forcefully here.
+ Array.from(document.querySelectorAll('.js-multi-select') || []).forEach(item => {
+ Sortable.utils.deselect(item);
+ });
+
+ /**
+ * SortableJS leaves all the moving items "as is" on the DOM.
+ * Vue picks up and rehydrates the DOM, but we need to explicity
+ * remove the "trash" items from the DOM.
+ *
+ * This is in parity to the logic on single item move from a list/in
+ * a list. For reference, look at the implementation of onAdd method.
+ */
+ this.$nextTick(() => {
+ if (items && items.length) {
+ items.forEach(item => {
+ item.remove();
+ });
+ }
+ });
+ }
+ sortableEnd();
+ },
onMove(e) {
return !e.related.classList.contains('board-list-count');
},
+ onSelect(e) {
+ const {
+ item: { classList },
+ } = e;
+
+ if (
+ classList &&
+ classList.contains('js-multi-select') &&
+ !classList.contains('multi-select')
+ ) {
+ Sortable.utils.deselect(e.item);
+ }
+ },
+ onDeselect: e => {
+ const {
+ item: { dataset, classList },
+ } = e;
+
+ if (
+ classList &&
+ classList.contains('multi-select') &&
+ !classList.contains('js-multi-select')
+ ) {
+ const issue = this.list.findIssue(Number(dataset.issueId));
+ boardsStore.toggleMultiSelect(issue);
+ }
+ },
});
this.sortable = Sortable.create(this.$refs.list, options);
@@ -260,7 +452,7 @@ export default {
<li v-if="showCount" class="board-list-count text-center" data-issue-id="-1">
<gl-loading-icon v-show="list.loadingMore" label="Loading more issues" />
<span v-if="list.issues.length === list.issuesSize">{{ __('Showing all issues') }}</span>
- <span v-else> Showing {{ list.issues.length }} of {{ list.issuesSize }} issues </span>
+ <span v-else>{{ paginatedIssueText }}</span>
</li>
</ul>
</div>
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index ebb2f5b23e4..334c162954e 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -305,13 +305,18 @@ export default {
<div v-if="canAdminBoard">
<gl-dropdown-divider />
- <gl-dropdown-item v-if="multipleIssueBoardsAvailable" @click.prevent="showPage('new')">
+ <gl-dropdown-item
+ v-if="multipleIssueBoardsAvailable"
+ data-qa-selector="create_new_board_button"
+ @click.prevent="showPage('new')"
+ >
{{ s__('IssueBoards|Create new board') }}
</gl-dropdown-item>
<gl-dropdown-item
v-if="showDelete"
class="text-danger"
+ data-qa-selector="delete_board_button"
@click.prevent="showPage('delete')"
>
{{ s__('IssueBoards|Delete board') }}
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue
index 7f554c99669..40d75d53f75 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.vue
+++ b/app/assets/javascripts/boards/components/issue_card_inner.vue
@@ -99,7 +99,10 @@ export default {
return !groupId ? referencePath.split('#')[0] : null;
},
orderedLabels() {
- return _.sortBy(this.issue.labels, 'title');
+ return _.chain(this.issue.labels)
+ .filter(this.isNonListLabel)
+ .sortBy('title')
+ .value();
},
helpLink() {
return boardsStore.scopedLabels.helpLink;
@@ -130,6 +133,9 @@ export default {
if (!label.id) return false;
return true;
},
+ isNonListLabel(label) {
+ return label.id && !(this.list.type === 'label' && this.list.title === label.title);
+ },
filterByLabel(label) {
if (!this.updateFilters) return;
const labelTitle = encodeURIComponent(label.title);
@@ -167,7 +173,7 @@ export default {
</h4>
</div>
<div v-if="showLabelFooter" class="board-card-labels prepend-top-4 d-flex flex-wrap">
- <template v-for="label in orderedLabels" v-if="showLabel(label)">
+ <template v-for="label in orderedLabels">
<issue-card-inner-scoped-label
v-if="showScopedLabel(label)"
:key="label.id"
@@ -212,7 +218,7 @@ export default {
<issue-due-date v-if="issue.dueDate" :date="issue.dueDate" />
<issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" />
<issue-card-weight
- v-if="issue.weight"
+ v-if="validIssueWeight"
:weight="issue.weight"
@click="filterByWeight(issue.weight)"
/>
diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue
index 3385aad5b11..5c33ba9461c 100644
--- a/app/assets/javascripts/boards/components/issue_time_estimate.vue
+++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue
@@ -34,7 +34,7 @@ export default {
<template>
<span>
<span ref="issueTimeEstimate" class="board-card-info card-number">
- <icon name="hourglass" css-classes="board-card-info-icon align-top" /><time
+ <icon name="hourglass" class="board-card-info-icon align-top" /><time
class="board-card-info-text"
>{{ timeEstimate }}</time
>
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
new file mode 100644
index 00000000000..3c66c7a0660
--- /dev/null
+++ b/app/assets/javascripts/boards/constants.js
@@ -0,0 +1,11 @@
+export const ListType = {
+ assignee: 'assignee',
+ milestone: 'milestone',
+ backlog: 'backlog',
+ closed: 'closed',
+ label: 'label',
+};
+
+export default {
+ ListType,
+};
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index 3bded4a3258..befca70eeae 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -22,7 +22,6 @@ import Board from 'ee_else_ce/boards/components/board';
import BoardSidebar from 'ee_else_ce/boards/components/board_sidebar';
import initNewListDropdown from 'ee_else_ce/boards/components/new_list_dropdown';
import BoardAddIssuesModal from '~/boards/components/modal/index.vue';
-import '~/vue_shared/vue_resource_interceptor';
import {
NavigationType,
convertObjectPropsToCamelCase,
@@ -147,7 +146,7 @@ export default () => {
updateTokens() {
this.filterManager.updateTokens();
},
- updateDetailIssue(newIssue) {
+ updateDetailIssue(newIssue, multiSelect = false) {
const { sidebarInfoEndpoint } = newIssue;
if (sidebarInfoEndpoint && newIssue.subscribed === undefined) {
newIssue.setFetchingState('subscriptions', true);
@@ -186,9 +185,23 @@ export default () => {
});
}
+ if (multiSelect) {
+ boardsStore.toggleMultiSelect(newIssue);
+
+ if (boardsStore.detail.issue) {
+ boardsStore.clearDetailIssue();
+ return;
+ }
+
+ return;
+ }
+
boardsStore.setIssueDetail(newIssue);
},
- clearDetailIssue() {
+ clearDetailIssue(multiSelect = false) {
+ if (multiSelect) {
+ boardsStore.clearMultiSelect();
+ }
boardsStore.clearDetailIssue();
},
toggleSubscription(id) {
diff --git a/app/assets/javascripts/boards/mixins/issue_card_inner.js b/app/assets/javascripts/boards/mixins/issue_card_inner.js
index 8000237da6d..04e971b756d 100644
--- a/app/assets/javascripts/boards/mixins/issue_card_inner.js
+++ b/app/assets/javascripts/boards/mixins/issue_card_inner.js
@@ -1,4 +1,9 @@
export default {
+ computed: {
+ validIssueWeight() {
+ return false;
+ },
+ },
methods: {
filterByWeight() {},
},
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index b3e56a34c28..1e213c324eb 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -5,6 +5,7 @@ import ListLabel from './label';
import ListAssignee from './assignee';
import ListIssue from 'ee_else_ce/boards/models/issue';
import { urlParamsToObject } from '~/lib/utils/common_utils';
+import flash from '~/flash';
import boardsStore from '../stores/boards_store';
import ListMilestone from './milestone';
@@ -176,6 +177,53 @@ class List {
});
}
+ addMultipleIssues(issues, listFrom, newIndex) {
+ let moveBeforeId = null;
+ let moveAfterId = null;
+
+ const listHasIssues = issues.every(issue => this.findIssue(issue.id));
+
+ if (!listHasIssues) {
+ if (newIndex !== undefined) {
+ if (this.issues[newIndex - 1]) {
+ moveBeforeId = this.issues[newIndex - 1].id;
+ }
+
+ if (this.issues[newIndex]) {
+ moveAfterId = this.issues[newIndex].id;
+ }
+
+ this.issues.splice(newIndex, 0, ...issues);
+ } else {
+ this.issues.push(...issues);
+ }
+
+ if (this.label) {
+ issues.forEach(issue => issue.addLabel(this.label));
+ }
+
+ if (this.assignee) {
+ if (listFrom && listFrom.type === 'assignee') {
+ issues.forEach(issue => issue.removeAssignee(listFrom.assignee));
+ }
+ issues.forEach(issue => issue.addAssignee(this.assignee));
+ }
+
+ if (IS_EE && this.milestone) {
+ if (listFrom && listFrom.type === 'milestone') {
+ issues.forEach(issue => issue.removeMilestone(listFrom.milestone));
+ }
+ issues.forEach(issue => issue.addMilestone(this.milestone));
+ }
+
+ if (listFrom) {
+ this.issuesSize += issues.length;
+
+ this.updateMultipleIssues(issues, listFrom, moveBeforeId, moveAfterId);
+ }
+ }
+ }
+
addIssue(issue, listFrom, newIndex) {
let moveBeforeId = null;
let moveAfterId = null;
@@ -230,6 +278,23 @@ class List {
});
}
+ moveMultipleIssues({ issues, oldIndicies, newIndex, moveBeforeId, moveAfterId }) {
+ oldIndicies.reverse().forEach(index => {
+ this.issues.splice(index, 1);
+ });
+ this.issues.splice(newIndex, 0, ...issues);
+
+ gl.boardService
+ .moveMultipleIssues({
+ ids: issues.map(issue => issue.id),
+ fromListId: null,
+ toListId: null,
+ moveBeforeId,
+ moveAfterId,
+ })
+ .catch(() => flash(__('Something went wrong while moving issues.')));
+ }
+
updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId) {
gl.boardService
.moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId)
@@ -238,10 +303,37 @@ class List {
});
}
+ updateMultipleIssues(issues, listFrom, moveBeforeId, moveAfterId) {
+ gl.boardService
+ .moveMultipleIssues({
+ ids: issues.map(issue => issue.id),
+ fromListId: listFrom.id,
+ toListId: this.id,
+ moveBeforeId,
+ moveAfterId,
+ })
+ .catch(() => flash(__('Something went wrong while moving issues.')));
+ }
+
findIssue(id) {
return this.issues.find(issue => issue.id === id);
}
+ removeMultipleIssues(removeIssues) {
+ const ids = removeIssues.map(issue => issue.id);
+
+ this.issues = this.issues.filter(issue => {
+ const matchesRemove = ids.includes(issue.id);
+
+ if (matchesRemove) {
+ this.issuesSize -= 1;
+ issue.removeLabel(this.label);
+ }
+
+ return !matchesRemove;
+ });
+ }
+
removeIssue(removeIssue) {
this.issues = this.issues.filter(issue => {
const matchesRemove = removeIssue.id === issue.id;
diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js
index 0d11db89511..03369febb4a 100644
--- a/app/assets/javascripts/boards/services/board_service.js
+++ b/app/assets/javascripts/boards/services/board_service.js
@@ -48,6 +48,16 @@ export default class BoardService {
return boardsStore.moveIssue(id, fromListId, toListId, moveBeforeId, moveAfterId);
}
+ moveMultipleIssues({
+ ids,
+ fromListId = null,
+ toListId = null,
+ moveBeforeId = null,
+ moveAfterId = null,
+ }) {
+ return boardsStore.moveMultipleIssues({ ids, fromListId, toListId, moveBeforeId, moveAfterId });
+ }
+
newIssue(id, issue) {
return boardsStore.newIssue(id, issue);
}
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index 6da1cca9628..8b737d1dab0 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -11,6 +11,7 @@ import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import eventHub from '../eventhub';
+import { ListType } from '../constants';
const boardsStore = {
disabled: false,
@@ -39,6 +40,7 @@ const boardsStore = {
issue: {},
list: {},
},
+ multiSelect: { list: [] },
setEndpoints({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) {
const listsEndpointGenerate = `${listsEndpoint}/generate.json`;
@@ -51,7 +53,6 @@ const boardsStore = {
recentBoardsEndpoint: `${recentBoardsEndpoint}.json`,
};
},
-
create() {
this.state.lists = [];
this.filter.path = getUrlParamsArray().join('&');
@@ -134,6 +135,107 @@ const boardsStore = {
Object.assign(this.moving, { list, issue });
},
+ moveMultipleIssuesToList({ listFrom, listTo, issues, newIndex }) {
+ const issueTo = issues.map(issue => listTo.findIssue(issue.id));
+ const issueLists = _.flatten(issues.map(issue => issue.getLists()));
+ const listLabels = issueLists.map(list => list.label);
+
+ const hasMoveableIssues = _.compact(issueTo).length > 0;
+
+ if (!hasMoveableIssues) {
+ // Check if target list assignee is already present in this issue
+ if (
+ listTo.type === ListType.assignee &&
+ listFrom.type === ListType.assignee &&
+ issues.some(issue => issue.findAssignee(listTo.assignee))
+ ) {
+ const targetIssues = issues.map(issue => listTo.findIssue(issue.id));
+ targetIssues.forEach(targetIssue => targetIssue.removeAssignee(listFrom.assignee));
+ } else if (listTo.type === 'milestone') {
+ const currentMilestones = issues.map(issue => issue.milestone);
+ const currentLists = this.state.lists
+ .filter(list => list.type === 'milestone' && list.id !== listTo.id)
+ .filter(list =>
+ list.issues.some(listIssue => issues.some(issue => listIssue.id === issue.id)),
+ );
+
+ issues.forEach(issue => {
+ currentMilestones.forEach(milestone => {
+ issue.removeMilestone(milestone);
+ });
+ });
+
+ issues.forEach(issue => {
+ issue.addMilestone(listTo.milestone);
+ });
+
+ currentLists.forEach(currentList => {
+ issues.forEach(issue => {
+ currentList.removeIssue(issue);
+ });
+ });
+
+ listTo.addMultipleIssues(issues, listFrom, newIndex);
+ } else {
+ // Add to new lists issues if it doesn't already exist
+ listTo.addMultipleIssues(issues, listFrom, newIndex);
+ }
+ } else {
+ listTo.updateMultipleIssues(issues, listFrom);
+ issues.forEach(issue => {
+ issue.removeLabel(listFrom.label);
+ });
+ }
+
+ if (listTo.type === ListType.closed && listFrom.type !== ListType.backlog) {
+ issueLists.forEach(list => {
+ issues.forEach(issue => {
+ list.removeIssue(issue);
+ });
+ });
+
+ issues.forEach(issue => {
+ issue.removeLabels(listLabels);
+ });
+ } else if (listTo.type === ListType.backlog && listFrom.type === ListType.assignee) {
+ issues.forEach(issue => {
+ issue.removeAssignee(listFrom.assignee);
+ });
+ issueLists.forEach(list => {
+ issues.forEach(issue => {
+ list.removeIssue(issue);
+ });
+ });
+ } else if (listTo.type === ListType.backlog && listFrom.type === ListType.milestone) {
+ issues.forEach(issue => {
+ issue.removeMilestone(listFrom.milestone);
+ });
+ issueLists.forEach(list => {
+ issues.forEach(issue => {
+ list.removeIssue(issue);
+ });
+ });
+ } else if (
+ this.shouldRemoveIssue(listFrom, listTo) &&
+ this.issuesAreContiguous(listFrom, issues)
+ ) {
+ listFrom.removeMultipleIssues(issues);
+ }
+ },
+
+ issuesAreContiguous(list, issues) {
+ // When there's only 1 issue selected, we can return early.
+ if (issues.length === 1) return true;
+
+ // Create list of ids for issues involved.
+ const listIssueIds = list.issues.map(issue => issue.id);
+ const movedIssueIds = issues.map(issue => issue.id);
+
+ // Check if moved issue IDs is sub-array
+ // of source list issue IDs (i.e. contiguous selection).
+ return listIssueIds.join('|').includes(movedIssueIds.join('|'));
+ },
+
moveIssueToList(listFrom, listTo, issue, newIndex) {
const issueTo = listTo.findIssue(issue.id);
const issueLists = issue.getLists();
@@ -195,6 +297,17 @@ const boardsStore = {
list.moveIssue(issue, oldIndex, newIndex, beforeId, afterId);
},
+ moveMultipleIssuesInList({ list, issues, oldIndicies, newIndex, idArray }) {
+ const beforeId = parseInt(idArray[newIndex - 1], 10) || null;
+ const afterId = parseInt(idArray[newIndex + issues.length], 10) || null;
+ list.moveMultipleIssues({
+ issues,
+ oldIndicies,
+ newIndex,
+ moveBeforeId: beforeId,
+ moveAfterId: afterId,
+ });
+ },
findList(key, val, type = 'label') {
const filteredList = this.state.lists.filter(list => {
const byType = type
@@ -260,6 +373,10 @@ const boardsStore = {
}`;
},
+ generateMultiDragPath(boardId) {
+ return `${gon.relative_url_root}/-/boards/${boardId ? `${boardId}` : ''}/issues/bulk_move`;
+ },
+
all() {
return axios.get(this.state.endpoints.listsEndpoint);
},
@@ -309,6 +426,16 @@ const boardsStore = {
});
},
+ moveMultipleIssues({ ids, fromListId, toListId, moveBeforeId, moveAfterId }) {
+ return axios.put(this.generateMultiDragPath(this.state.endpoints.boardId), {
+ from_list_id: fromListId,
+ to_list_id: toListId,
+ move_before_id: moveBeforeId,
+ move_after_id: moveAfterId,
+ ids,
+ });
+ },
+
newIssue(id, issue) {
return axios.post(this.generateIssuesPath(id), {
issue,
@@ -379,6 +506,25 @@ const boardsStore = {
setCurrentBoard(board) {
this.state.currentBoard = board;
},
+
+ toggleMultiSelect(issue) {
+ const selectedIssueIds = this.multiSelect.list.map(issue => issue.id);
+ const index = selectedIssueIds.indexOf(issue.id);
+
+ if (index === -1) {
+ this.multiSelect.list.push(issue);
+ return;
+ }
+
+ this.multiSelect.list = [
+ ...this.multiSelect.list.slice(0, index),
+ ...this.multiSelect.list.slice(index + 1),
+ ];
+ },
+
+ clearMultiSelect() {
+ this.multiSelect.list = [];
+ },
};
BoardsStoreEE.initEESpecific(boardsStore);
diff --git a/app/assets/javascripts/build_artifacts.js b/app/assets/javascripts/build_artifacts.js
index b2c88e8c14e..2955f0f014b 100644
--- a/app/assets/javascripts/build_artifacts.js
+++ b/app/assets/javascripts/build_artifacts.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, prefer-arrow-callback */
+/* eslint-disable func-names */
import $ from 'jquery';
import { visitUrl } from './lib/utils/url_utility';
@@ -12,11 +12,11 @@ export default class BuildArtifacts {
}
// eslint-disable-next-line class-methods-use-this
disablePropagation() {
- $('.top-block').on('click', '.download', function(e) {
- return e.stopPropagation();
+ $('.top-block').on('click', '.download', e => {
+ e.stopPropagation();
});
- return $('.tree-holder').on('click', 'tr[data-link] a', function(e) {
- return e.stopImmediatePropagation();
+ return $('.tree-holder').on('click', 'tr[data-link] a', e => {
+ e.stopImmediatePropagation();
});
}
// eslint-disable-next-line class-methods-use-this
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index d386960f3b6..7ea8901ecbb 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -41,6 +41,8 @@ export default class Clusters {
managePrometheusPath,
clusterEnvironmentsPath,
hasRbac,
+ providerType,
+ preInstalledKnative,
clusterType,
clusterStatus,
clusterStatusReason,
@@ -50,6 +52,7 @@ export default class Clusters {
environmentsHelpPath,
clustersHelpPath,
deployBoardsHelpPath,
+ cloudRunHelpPath,
clusterId,
} = document.querySelector('.js-edit-cluster-form').dataset;
@@ -65,10 +68,13 @@ export default class Clusters {
environmentsHelpPath,
clustersHelpPath,
deployBoardsHelpPath,
+ cloudRunHelpPath,
);
this.store.setManagePrometheusPath(managePrometheusPath);
this.store.updateStatus(clusterStatus);
this.store.updateStatusReason(clusterStatusReason);
+ this.store.updateProviderType(providerType);
+ this.store.updatePreInstalledKnative(preInstalledKnative);
this.store.updateRbac(hasRbac);
this.service = new ClustersService({
endpoint: statusPath,
@@ -153,6 +159,9 @@ export default class Clusters {
ingressHelpPath: this.state.ingressHelpPath,
managePrometheusPath: this.state.managePrometheusPath,
ingressDnsHelpPath: this.state.ingressDnsHelpPath,
+ cloudRunHelpPath: this.state.cloudRunHelpPath,
+ providerType: this.state.providerType,
+ preInstalledKnative: this.state.preInstalledKnative,
rbac: this.state.rbac,
},
});
diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue
index 64364092016..c6c8dc6352c 100644
--- a/app/assets/javascripts/clusters/components/application_row.vue
+++ b/app/assets/javascripts/clusters/components/application_row.vue
@@ -78,6 +78,10 @@ export default {
required: false,
default: false,
},
+ installedVia: {
+ type: String,
+ required: false,
+ },
version: {
type: String,
required: false,
@@ -311,6 +315,11 @@ export default {
>
<span v-else class="js-cluster-application-title">{{ title }}</span>
</strong>
+ <span
+ v-if="installedVia"
+ class="js-cluster-application-installed-via"
+ v-html="installedVia"
+ ></span>
<slot name="description"></slot>
<div v-if="hasError" class="cluster-application-error text-danger prepend-top-10">
<p class="js-cluster-application-general-error-message append-bottom-0">
diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue
index 27959898fb7..b95f97077f6 100644
--- a/app/assets/javascripts/clusters/components/applications.vue
+++ b/app/assets/javascripts/clusters/components/applications.vue
@@ -16,7 +16,7 @@ import { s__, sprintf } from '../../locale';
import applicationRow from './application_row.vue';
import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
import KnativeDomainEditor from './knative_domain_editor.vue';
-import { CLUSTER_TYPE, APPLICATION_STATUS, INGRESS } from '../constants';
+import { CLUSTER_TYPE, PROVIDER_TYPE, APPLICATION_STATUS, INGRESS } from '../constants';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import eventHub from '~/clusters/event_hub';
@@ -54,11 +54,26 @@ export default {
required: false,
default: '',
},
+ cloudRunHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
managePrometheusPath: {
type: String,
required: false,
default: '',
},
+ providerType: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ preInstalledKnative: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
rbac: {
type: Boolean,
required: false,
@@ -156,6 +171,25 @@ export default {
knative() {
return this.applications.knative;
},
+ cloudRun() {
+ return this.providerType === PROVIDER_TYPE.GCP && this.preInstalledKnative;
+ },
+ installedVia() {
+ if (this.cloudRun) {
+ return sprintf(
+ _.escape(s__(`ClusterIntegration|installed via %{installed_via}`)),
+ {
+ installed_via: `<a href="${
+ this.cloudRunHelpPath
+ }" target="_blank" rel="noopener noreferrer">${_.escape(
+ s__('ClusterIntegration|Cloud Run'),
+ )}</a>`,
+ },
+ false,
+ );
+ }
+ return null;
+ },
},
created() {
this.helmInstallIllustration = helmInstallIllustration;
@@ -260,7 +294,7 @@ export default {
<span class="input-group-append">
<clipboard-button
:text="ingressExternalEndpoint"
- :title="s__('ClusterIntegration|Copy Ingress Endpoint to clipboard')"
+ :title="s__('ClusterIntegration|Copy Ingress Endpoint')"
class="input-group-text js-clipboard-btn"
/>
</span>
@@ -438,7 +472,7 @@ export default {
<span class="input-group-btn">
<clipboard-button
:text="jupyterHostname"
- :title="s__('ClusterIntegration|Copy Jupyter Hostname to clipboard')"
+ :title="s__('ClusterIntegration|Copy Jupyter Hostname')"
class="js-clipboard-btn"
/>
</span>
@@ -468,6 +502,7 @@ export default {
:installed="applications.knative.installed"
:install-failed="applications.knative.installFailed"
:install-application-request-params="{ hostname: applications.knative.hostname }"
+ :installed-via="installedVia"
:uninstallable="applications.knative.uninstallable"
:uninstall-successful="applications.knative.uninstallSuccessful"
:uninstall-failed="applications.knative.uninstallFailed"
@@ -499,7 +534,7 @@ export default {
</p>
<knative-domain-editor
- v-if="knative.installed || (helmInstalled && rbac)"
+ v-if="(knative.installed || (helmInstalled && rbac)) && !preInstalledKnative"
:knative="knative"
:ingress-dns-help-path="ingressDnsHelpPath"
@save="saveKnativeDomain"
diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue
index e26ef135bc5..25347b11b6c 100644
--- a/app/assets/javascripts/clusters/components/knative_domain_editor.vue
+++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue
@@ -103,7 +103,7 @@ export default {
<span class="input-group-append">
<clipboard-button
:text="knativeExternalEndpoint"
- :title="s__('ClusterIntegration|Copy Knative Endpoint to clipboard')"
+ :title="s__('ClusterIntegration|Copy Knative Endpoint')"
class="input-group-text js-knative-endpoint-clipboard-btn"
/>
</span>
diff --git a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
index 4f60e543666..f1925c243f2 100644
--- a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
+++ b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
@@ -5,8 +5,14 @@ import trackUninstallButtonClickMixin from 'ee_else_ce/clusters/mixins/track_uni
import { HELM, INGRESS, CERT_MANAGER, PROMETHEUS, RUNNER, KNATIVE, JUPYTER } from '../constants';
const CUSTOM_APP_WARNING_TEXT = {
- [HELM]: s__(
- 'ClusterIntegration|The associated Tiller pod will be deleted and cannot be restored.',
+ [HELM]: sprintf(
+ s__(
+ 'ClusterIntegration|The associated Tiller pod, the %{gitlabManagedAppsNamespace} namespace, and all of its resources will be deleted and cannot be restored.',
+ ),
+ {
+ gitlabManagedAppsNamespace: '<code>gitlab-managed-apps</code>',
+ },
+ false,
),
[INGRESS]: s__(
'ClusterIntegration|The associated load balancer and IP will be deleted and cannot be restored.',
@@ -76,6 +82,7 @@ export default {
:modal-id="modalId"
:title="title"
@ok="confirmUninstall()"
- >{{ warningText }} {{ customAppWarningText }}</gl-modal
>
+ {{ warningText }} <span v-html="customAppWarningText"></span>
+ </gl-modal>
</template>
diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js
index 8fd752092c9..c6e4b7951cf 100644
--- a/app/assets/javascripts/clusters/constants.js
+++ b/app/assets/javascripts/clusters/constants.js
@@ -5,6 +5,11 @@ export const CLUSTER_TYPE = {
PROJECT: 'project_type',
};
+// These need to match the available providers in app/models/clusters/providers/
+export const PROVIDER_TYPE = {
+ GCP: 'gcp',
+};
+
// These need to match what is returned from the server
export const APPLICATION_STATUS = {
NO_STATUS: null,
@@ -19,6 +24,7 @@ export const APPLICATION_STATUS = {
UNINSTALLING: 'uninstalling',
UNINSTALL_ERRORED: 'uninstall_errored',
ERROR: 'errored',
+ PRE_INSTALLED: 'pre_installed',
};
/*
@@ -29,6 +35,7 @@ export const APPLICATION_INSTALLED_STATUSES = [
APPLICATION_STATUS.INSTALLED,
APPLICATION_STATUS.UPDATING,
APPLICATION_STATUS.UNINSTALLING,
+ APPLICATION_STATUS.PRE_INSTALLED,
];
// These are only used client-side
diff --git a/app/assets/javascripts/clusters/services/application_state_machine.js b/app/assets/javascripts/clusters/services/application_state_machine.js
index 6e632519d8a..6bc4be7b93a 100644
--- a/app/assets/javascripts/clusters/services/application_state_machine.js
+++ b/app/assets/javascripts/clusters/services/application_state_machine.js
@@ -13,6 +13,7 @@ const {
UPDATE_ERRORED,
UNINSTALLING,
UNINSTALL_ERRORED,
+ PRE_INSTALLED,
} = APPLICATION_STATUS;
const applicationStateMachine = {
@@ -63,6 +64,9 @@ const applicationStateMachine = {
uninstallFailed: true,
},
},
+ [PRE_INSTALLED]: {
+ target: PRE_INSTALLED,
+ },
},
},
[NOT_INSTALLABLE]: {
@@ -123,6 +127,27 @@ const applicationStateMachine = {
},
},
},
+ [PRE_INSTALLED]: {
+ on: {
+ [UPDATE_EVENT]: {
+ target: UPDATING,
+ effects: {
+ updateFailed: false,
+ updateSuccessful: false,
+ },
+ },
+ [NOT_INSTALLABLE]: {
+ target: NOT_INSTALLABLE,
+ },
+ [UNINSTALL_EVENT]: {
+ target: UNINSTALLING,
+ effects: {
+ uninstallFailed: false,
+ uninstallSuccessful: false,
+ },
+ },
+ },
+ },
[UPDATING]: {
on: {
[UPDATED]: {
diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js
index 5cddb4cc098..6464461ea0c 100644
--- a/app/assets/javascripts/clusters/stores/clusters_store.js
+++ b/app/assets/javascripts/clusters/stores/clusters_store.js
@@ -35,7 +35,10 @@ export default class ClusterStore {
environmentsHelpPath: null,
clustersHelpPath: null,
deployBoardsHelpPath: null,
+ cloudRunHelpPath: null,
status: null,
+ providerType: null,
+ preInstalledKnative: false,
rbac: false,
statusReason: null,
applications: {
@@ -95,6 +98,7 @@ export default class ClusterStore {
environmentsHelpPath,
clustersHelpPath,
deployBoardsHelpPath,
+ cloudRunHelpPath,
) {
this.state.helpPath = helpPath;
this.state.ingressHelpPath = ingressHelpPath;
@@ -102,6 +106,7 @@ export default class ClusterStore {
this.state.environmentsHelpPath = environmentsHelpPath;
this.state.clustersHelpPath = clustersHelpPath;
this.state.deployBoardsHelpPath = deployBoardsHelpPath;
+ this.state.cloudRunHelpPath = cloudRunHelpPath;
}
setManagePrometheusPath(managePrometheusPath) {
@@ -112,6 +117,14 @@ export default class ClusterStore {
this.state.status = status;
}
+ updateProviderType(providerType) {
+ this.state.providerType = providerType;
+ }
+
+ updatePreInstalledKnative(preInstalledKnative) {
+ this.state.preInstalledKnative = parseBoolean(preInstalledKnative);
+ }
+
updateRbac(rbac) {
this.state.rbac = parseBoolean(rbac);
}
diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js
index 9454f760df8..6c04e0beb4d 100644
--- a/app/assets/javascripts/commit/image_file.js
+++ b/app/assets/javascripts/commit/image_file.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, prefer-arrow-callback, no-else-return, consistent-return, prefer-template, one-var, no-return-assign, no-unused-expressions, no-sequences */
+/* eslint-disable func-names, no-var, no-else-return, consistent-return, one-var, no-return-assign, no-unused-expressions, no-sequences */
import $ from 'jquery';
@@ -13,14 +13,14 @@ export default class ImageFile {
$('.two-up.view .frame.deleted img', this.file),
(function(_this) {
return function() {
- return _this.requestImageInfo($('.two-up.view .frame.added img', _this.file), function() {
+ return _this.requestImageInfo($('.two-up.view .frame.added img', _this.file), () => {
_this.initViewModes();
// Load two-up view after images are loaded
// so that we can display the correct width and height information
const $images = $('.two-up.view img', _this.file);
- $images.waitForImages(function() {
+ $images.waitForImages(() => {
_this.initView('two-up');
});
});
@@ -49,13 +49,13 @@ export default class ImageFile {
activateViewMode(viewMode) {
$('.view-modes-menu li', this.file)
.removeClass('active')
- .filter('.' + viewMode)
+ .filter(`.${viewMode}`)
.addClass('active');
- return $('.view:visible:not(.' + viewMode + ')', this.file).fadeOut(
+ return $(`.view:visible:not(.${viewMode})`, this.file).fadeOut(
200,
(function(_this) {
return function() {
- $('.view.' + viewMode, _this.file).fadeIn(200);
+ $(`.view.${viewMode}`, _this.file).fadeIn(200);
return _this.initView(viewMode);
};
})(this),
@@ -138,9 +138,9 @@ export default class ImageFile {
return $(this).width(availWidth / 2);
}
});
- return _this.requestImageInfo($('img', wrap), function(width, height) {
- $('.image-info .meta-width', wrap).text(width + 'px');
- $('.image-info .meta-height', wrap).text(height + 'px');
+ return _this.requestImageInfo($('img', wrap), (width, height) => {
+ $('.image-info .meta-width', wrap).text(`${width}px`);
+ $('.image-info .meta-height', wrap).text(`${height}px`);
return $('.image-info', wrap).removeClass('hide');
});
};
@@ -175,7 +175,7 @@ export default class ImageFile {
wrapPadding = parseInt($swipeWrap.css('right').replace('px', ''), 10);
- _this.initDraggable($swipeBar, wrapPadding, function(e, left) {
+ _this.initDraggable($swipeBar, wrapPadding, (e, left) => {
if (left > 0 && left < $swipeFrame.width() - wrapPadding * 2) {
$swipeWrap.width(maxWidth + 1 - left);
$swipeBar.css('left', left);
@@ -215,7 +215,7 @@ export default class ImageFile {
$frameAdded.css('opacity', 1);
framePadding = parseInt($frameAdded.css('right').replace('px', ''), 10);
- _this.initDraggable($dragger, framePadding, function(e, left) {
+ _this.initDraggable($dragger, framePadding, (e, left) => {
var opacity = left / dragTrackWidth;
if (opacity >= 0 && opacity <= 1) {
diff --git a/app/assets/javascripts/commons/vue.js b/app/assets/javascripts/commons/vue.js
index 798623b94fb..5b5a1507d38 100644
--- a/app/assets/javascripts/commons/vue.js
+++ b/app/assets/javascripts/commons/vue.js
@@ -1,6 +1,8 @@
import Vue from 'vue';
-import '../vue_shared/vue_resource_interceptor';
+import GlFeatureFlagsPlugin from '~/vue_shared/gl_feature_flags_plugin';
if (process.env.NODE_ENV !== 'production') {
Vue.config.productionTip = false;
}
+
+Vue.use(GlFeatureFlagsPlugin);
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue
index f9465da6fda..3c6da43c4c4 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue
@@ -3,6 +3,8 @@ import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_searc
import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue';
import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue';
+const findItem = (items, valueProp, value) => items.find(item => item[valueProp] === value);
+
export default {
components: {
DropdownButton,
@@ -26,7 +28,7 @@ export default {
default: '',
},
value: {
- type: Object,
+ type: [Object, String],
required: false,
default: () => null,
},
@@ -93,8 +95,8 @@ export default {
},
data() {
return {
+ selectedItem: findItem(this.items, this.value),
searchQuery: '',
- selectedItem: null,
};
},
computed: {
@@ -127,10 +129,15 @@ export default {
return (this.selectedItem && this.selectedItem[this.valueProperty]) || '';
},
},
+ watch: {
+ value(value) {
+ this.selectedItem = findItem(this.items, this.valueProperty, value);
+ },
+ },
methods: {
select(item) {
this.selectedItem = item;
- this.$emit('input', item);
+ this.$emit('input', item[this.valueProperty]);
},
},
};
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue
index ce2e4b883e4..22ee368b8e0 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue
@@ -7,8 +7,23 @@ export default {
ServiceCredentialsForm,
EksClusterConfigurationForm,
},
+ props: {
+ gitlabManagedClusterHelpPath: {
+ type: String,
+ required: true,
+ },
+ kubernetesIntegrationHelpPath: {
+ type: String,
+ required: true,
+ },
+ },
};
</script>
<template>
- <eks-cluster-configuration-form />
+ <div class="js-create-eks-cluster">
+ <eks-cluster-configuration-form
+ :gitlab-managed-cluster-help-path="gitlabManagedClusterHelpPath"
+ :kubernetes-integration-help-path="kubernetesIntegrationHelpPath"
+ />
+ </div>
</template>
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
index 6e74963dcb0..1188cf08850 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
@@ -1,25 +1,394 @@
<script>
-import RoleNameDropdown from './role_name_dropdown.vue';
-import SecurityGroupDropdown from './security_group_dropdown.vue';
-import SubnetDropdown from './subnet_dropdown.vue';
-import VPCDropdown from './vpc_dropdown.vue';
+import { createNamespacedHelpers, mapState, mapActions } from 'vuex';
+import { sprintf, s__ } from '~/locale';
+import _ from 'underscore';
+import { GlFormInput, GlFormCheckbox } from '@gitlab/ui';
+import ClusterFormDropdown from './cluster_form_dropdown.vue';
+import RegionDropdown from './region_dropdown.vue';
+import { KUBERNETES_VERSIONS } from '../constants';
+
+const { mapState: mapRolesState, mapActions: mapRolesActions } = createNamespacedHelpers('roles');
+const { mapState: mapRegionsState, mapActions: mapRegionsActions } = createNamespacedHelpers(
+ 'regions',
+);
+const { mapState: mapKeyPairsState, mapActions: mapKeyPairsActions } = createNamespacedHelpers(
+ 'keyPairs',
+);
+const { mapState: mapVpcsState, mapActions: mapVpcActions } = createNamespacedHelpers('vpcs');
+const { mapState: mapSubnetsState, mapActions: mapSubnetActions } = createNamespacedHelpers(
+ 'subnets',
+);
+const {
+ mapState: mapSecurityGroupsState,
+ mapActions: mapSecurityGroupsActions,
+} = createNamespacedHelpers('securityGroups');
export default {
components: {
- RoleNameDropdown,
- SecurityGroupDropdown,
- SubnetDropdown,
- VPCDropdown,
+ ClusterFormDropdown,
+ RegionDropdown,
+ GlFormInput,
+ GlFormCheckbox,
+ },
+ props: {
+ gitlabManagedClusterHelpPath: {
+ type: String,
+ required: true,
+ },
+ kubernetesIntegrationHelpPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState([
+ 'clusterName',
+ 'environmentScope',
+ 'kubernetesVersion',
+ 'selectedRegion',
+ 'selectedKeyPair',
+ 'selectedVpc',
+ 'selectedSubnet',
+ 'selectedRole',
+ 'selectedSecurityGroup',
+ 'gitlabManagedCluster',
+ ]),
+ ...mapRolesState({
+ roles: 'items',
+ isLoadingRoles: 'isLoadingItems',
+ loadingRolesError: 'loadingItemsError',
+ }),
+ ...mapRegionsState({
+ regions: 'items',
+ isLoadingRegions: 'isLoadingItems',
+ loadingRegionsError: 'loadingItemsError',
+ }),
+ ...mapKeyPairsState({
+ keyPairs: 'items',
+ isLoadingKeyPairs: 'isLoadingItems',
+ loadingKeyPairsError: 'loadingItemsError',
+ }),
+ ...mapVpcsState({
+ vpcs: 'items',
+ isLoadingVpcs: 'isLoadingItems',
+ loadingVpcsError: 'loadingItemsError',
+ }),
+ ...mapSubnetsState({
+ subnets: 'items',
+ isLoadingSubnets: 'isLoadingItems',
+ loadingSubnetsError: 'loadingItemsError',
+ }),
+ ...mapSecurityGroupsState({
+ securityGroups: 'items',
+ isLoadingSecurityGroups: 'isLoadingItems',
+ loadingSecurityGroupsError: 'loadingItemsError',
+ }),
+ kubernetesVersions() {
+ return KUBERNETES_VERSIONS;
+ },
+ vpcDropdownDisabled() {
+ return !this.selectedRegion;
+ },
+ keyPairDropdownDisabled() {
+ return !this.selectedRegion;
+ },
+ subnetDropdownDisabled() {
+ return !this.selectedVpc;
+ },
+ securityGroupDropdownDisabled() {
+ return !this.selectedVpc;
+ },
+ kubernetesIntegrationHelpText() {
+ const escapedUrl = _.escape(this.kubernetesIntegrationHelpPath);
+
+ return sprintf(
+ s__(
+ 'ClusterIntegration|Read our %{link_start}help page%{link_end} on Kubernetes cluster integration.',
+ ),
+ {
+ link_start: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`,
+ link_end: '</a>',
+ },
+ false,
+ );
+ },
+ roleDropdownHelpText() {
+ return sprintf(
+ s__(
+ 'ClusterIntegration|Select the IAM Role to allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role name, first create one on %{startLink}Amazon Web Services%{endLink}.',
+ ),
+ {
+ startLink:
+ '<a href="https://console.aws.amazon.com/iam/home?#roles" target="_blank" rel="noopener noreferrer">',
+ endLink: '</a>',
+ },
+ false,
+ );
+ },
+ keyPairDropdownHelpText() {
+ return sprintf(
+ s__(
+ 'ClusterIntegration|Select the key pair name that will be used to create EC2 nodes. To use a new key pair name, first create one on %{startLink}Amazon Web Services%{endLink}.',
+ ),
+ {
+ startLink:
+ '<a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html#having-ec2-create-your-key-pair" target="_blank" rel="noopener noreferrer">',
+ endLink: '</a>',
+ },
+ false,
+ );
+ },
+ vpcDropdownHelpText() {
+ return sprintf(
+ s__(
+ 'ClusterIntegration|Select a VPC to use for your EKS Cluster resources. To use a new VPC, first create one on %{startLink}Amazon Web Services%{endLink}.',
+ ),
+ {
+ startLink:
+ '<a href="https://console.aws.amazon.com/vpc/home?#vpc" target="_blank" rel="noopener noreferrer">',
+ endLink: '</a>',
+ },
+ false,
+ );
+ },
+ subnetDropdownHelpText() {
+ return sprintf(
+ s__(
+ 'ClusterIntegration|Choose the %{startLink}subnets%{endLink} in your VPC where your worker nodes will run.',
+ ),
+ {
+ startLink:
+ '<a href="https://console.aws.amazon.com/vpc/home?#subnets" target="_blank" rel="noopener noreferrer">',
+ endLink: '</a>',
+ },
+ false,
+ );
+ },
+ securityGroupDropdownHelpText() {
+ return sprintf(
+ s__(
+ 'ClusterIntegration|Choose the %{startLink}security groups%{endLink} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.',
+ ),
+ {
+ startLink:
+ '<a href="https://console.aws.amazon.com/vpc/home?#securityGroups" target="_blank" rel="noopener noreferrer">',
+ endLink: '</a>',
+ },
+ false,
+ );
+ },
+ gitlabManagedHelpText() {
+ const escapedUrl = _.escape(this.gitlabManagedClusterHelpPath);
+
+ return sprintf(
+ s__(
+ 'ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster. %{startLink}More information%{endLink}',
+ ),
+ {
+ startLink: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`,
+ endLink: '</a>',
+ },
+ false,
+ );
+ },
+ },
+ mounted() {
+ this.fetchRegions();
+ this.fetchRoles();
+ },
+ methods: {
+ ...mapActions([
+ 'setClusterName',
+ 'setEnvironmentScope',
+ 'setKubernetesVersion',
+ 'setRegion',
+ 'setVpc',
+ 'setSubnet',
+ 'setRole',
+ 'setKeyPair',
+ 'setSecurityGroup',
+ 'setGitlabManagedCluster',
+ ]),
+ ...mapRegionsActions({ fetchRegions: 'fetchItems' }),
+ ...mapVpcActions({ fetchVpcs: 'fetchItems' }),
+ ...mapSubnetActions({ fetchSubnets: 'fetchItems' }),
+ ...mapRolesActions({ fetchRoles: 'fetchItems' }),
+ ...mapKeyPairsActions({ fetchKeyPairs: 'fetchItems' }),
+ ...mapSecurityGroupsActions({ fetchSecurityGroups: 'fetchItems' }),
+ setRegionAndFetchVpcsAndKeyPairs(region) {
+ this.setRegion({ region });
+ this.fetchVpcs({ region });
+ this.fetchKeyPairs({ region });
+ },
+ setVpcAndFetchSubnets(vpc) {
+ this.setVpc({ vpc });
+ this.fetchSubnets({ vpc });
+ this.fetchSecurityGroups({ vpc });
+ },
},
};
</script>
<template>
<form name="eks-cluster-configuration-form">
+ <h2>
+ {{ s__('ClusterIntegration|Enter the details for your Amazon EKS Kubernetes cluster') }}
+ </h2>
+ <p v-html="kubernetesIntegrationHelpText"></p>
+ <div class="form-group">
+ <label class="label-bold" for="eks-cluster-name">{{
+ s__('ClusterIntegration|Kubernetes cluster name')
+ }}</label>
+ <gl-form-input
+ id="eks-cluster-name"
+ :value="clusterName"
+ @input="setClusterName({ clusterName: $event })"
+ />
+ </div>
+ <div class="form-group">
+ <label class="label-bold" for="eks-environment-scope">{{
+ s__('ClusterIntegration|Environment scope')
+ }}</label>
+ <gl-form-input
+ id="eks-environment-scope"
+ :value="environmentScope"
+ @input="setEnvironmentScope({ environmentScope: $event })"
+ />
+ </div>
+ <div class="form-group">
+ <label class="label-bold" for="eks-kubernetes-version">{{
+ s__('ClusterIntegration|Kubernetes version')
+ }}</label>
+ <cluster-form-dropdown
+ field-id="eks-kubernetes-version"
+ field-name="eks-kubernetes-version"
+ :value="kubernetesVersion"
+ :items="kubernetesVersions"
+ :empty-text="s__('ClusterIntegration|Kubernetes version not found')"
+ @input="setKubernetesVersion({ kubernetesVersion: $event })"
+ />
+ <p class="form-text text-muted" v-html="roleDropdownHelpText"></p>
+ </div>
+ <div class="form-group">
+ <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Role name') }}</label>
+ <cluster-form-dropdown
+ field-id="eks-role"
+ field-name="eks-role"
+ :input="selectedRole"
+ :items="roles"
+ :loading="isLoadingRoles"
+ :loading-text="s__('ClusterIntegration|Loading IAM Roles')"
+ :placeholder="s__('ClusterIntergation|Select role name')"
+ :search-field-placeholder="s__('ClusterIntegration|Search IAM Roles')"
+ :empty-text="s__('ClusterIntegration|No IAM Roles found')"
+ :has-errors="Boolean(loadingRolesError)"
+ :error-message="s__('ClusterIntegration|Could not load IAM roles')"
+ @input="setRole({ role: $event })"
+ />
+ <p class="form-text text-muted" v-html="roleDropdownHelpText"></p>
+ </div>
+ <div class="form-group">
+ <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Region') }}</label>
+ <region-dropdown
+ :value="selectedRegion"
+ :regions="regions"
+ :error="loadingRegionsError"
+ :loading="isLoadingRegions"
+ @input="setRegionAndFetchVpcsAndKeyPairs($event)"
+ />
+ </div>
+ <div class="form-group">
+ <label class="label-bold" for="eks-key-pair">{{
+ s__('ClusterIntegration|Key pair name')
+ }}</label>
+ <cluster-form-dropdown
+ field-id="eks-key-pair"
+ field-name="eks-key-pair"
+ :input="selectedKeyPair"
+ :items="keyPairs"
+ :disabled="keyPairDropdownDisabled"
+ :disabled-text="s__('ClusterIntegration|Select a region to choose a Key Pair')"
+ :loading="isLoadingKeyPairs"
+ :loading-text="s__('ClusterIntegration|Loading Key Pairs')"
+ :placeholder="s__('ClusterIntergation|Select key pair')"
+ :search-field-placeholder="s__('ClusterIntegration|Search Key Pairs')"
+ :empty-text="s__('ClusterIntegration|No Key Pairs found')"
+ :has-errors="Boolean(loadingKeyPairsError)"
+ :error-message="s__('ClusterIntegration|Could not load Key Pairs')"
+ @input="setKeyPair({ keyPair: $event })"
+ />
+ <p class="form-text text-muted" v-html="keyPairDropdownHelpText"></p>
+ </div>
+ <div class="form-group">
+ <label class="label-bold" for="eks-vpc">{{ s__('ClusterIntegration|VPC') }}</label>
+ <cluster-form-dropdown
+ field-id="eks-vpc"
+ field-name="eks-vpc"
+ :input="selectedVpc"
+ :items="vpcs"
+ :loading="isLoadingVpcs"
+ :disabled="vpcDropdownDisabled"
+ :disabled-text="s__('ClusterIntegration|Select a region to choose a VPC')"
+ :loading-text="s__('ClusterIntegration|Loading VPCs')"
+ :placeholder="s__('ClusterIntergation|Select a VPC')"
+ :search-field-placeholder="s__('ClusterIntegration|Search VPCs')"
+ :empty-text="s__('ClusterIntegration|No VPCs found')"
+ :has-errors="Boolean(loadingVpcsError)"
+ :error-message="s__('ClusterIntegration|Could not load VPCs for the selected region')"
+ @input="setVpcAndFetchSubnets($event)"
+ />
+ <p class="form-text text-muted" v-html="vpcDropdownHelpText"></p>
+ </div>
+ <div class="form-group">
+ <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Subnet') }}</label>
+ <cluster-form-dropdown
+ field-id="eks-subnet"
+ field-name="eks-subnet"
+ :input="selectedSubnet"
+ :items="subnets"
+ :loading="isLoadingSubnets"
+ :disabled="subnetDropdownDisabled"
+ :disabled-text="s__('ClusterIntegration|Select a VPC to choose a subnet')"
+ :loading-text="s__('ClusterIntegration|Loading subnets')"
+ :placeholder="s__('ClusterIntergation|Select a subnet')"
+ :search-field-placeholder="s__('ClusterIntegration|Search subnets')"
+ :empty-text="s__('ClusterIntegration|No subnet found')"
+ :has-errors="Boolean(loadingSubnetsError)"
+ :error-message="s__('ClusterIntegration|Could not load subnets for the selected VPC')"
+ @input="setSubnet({ subnet: $event })"
+ />
+ <p class="form-text text-muted" v-html="subnetDropdownHelpText"></p>
+ </div>
+ <div class="form-group">
+ <label class="label-bold" for="eks-security-group">{{
+ s__('ClusterIntegration|Security groups')
+ }}</label>
+ <cluster-form-dropdown
+ field-id="eks-security-group"
+ field-name="eks-security-group"
+ :input="selectedSecurityGroup"
+ :items="securityGroups"
+ :loading="isLoadingSecurityGroups"
+ :disabled="securityGroupDropdownDisabled"
+ :disabled-text="s__('ClusterIntegration|Select a VPC to choose a security group')"
+ :loading-text="s__('ClusterIntegration|Loading security groups')"
+ :placeholder="s__('ClusterIntergation|Select a security group')"
+ :search-field-placeholder="s__('ClusterIntegration|Search security groups')"
+ :empty-text="s__('ClusterIntegration|No security group found')"
+ :has-errors="Boolean(loadingSecurityGroupsError)"
+ :error-message="
+ s__('ClusterIntegration|Could not load security groups for the selected VPC')
+ "
+ @input="setSecurityGroup({ securityGroup: $event })"
+ />
+ <p class="form-text text-muted" v-html="securityGroupDropdownHelpText"></p>
+ </div>
<div class="form-group">
- <label class="label-bold" name="role" for="eks-role">
- {{ s__('ClusterIntegration|Role name') }}
- </label>
- <role-name-dropdown />
+ <gl-form-checkbox
+ :checked="gitlabManagedCluster"
+ @input="setGitlabManagedCluster({ gitlabManagedCluster: $event })"
+ >{{ s__('ClusterIntegration|GitLab-managed cluster') }}</gl-form-checkbox
+ >
+ <p class="form-text text-muted" v-html="gitlabManagedHelpText"></p>
</div>
</form>
</template>
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue
new file mode 100644
index 00000000000..765955305c8
--- /dev/null
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue
@@ -0,0 +1,63 @@
+<script>
+import { sprintf, s__ } from '~/locale';
+
+import ClusterFormDropdown from './cluster_form_dropdown.vue';
+
+export default {
+ components: {
+ ClusterFormDropdown,
+ },
+ props: {
+ regions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ error: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ hasErrors() {
+ return Boolean(this.error);
+ },
+ helpText() {
+ return sprintf(
+ s__('ClusterIntegration|Learn more about %{startLink}Regions%{endLink}.'),
+ {
+ startLink:
+ '<a href="https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/" target="_blank" rel="noopener noreferrer">',
+ endLink: '</a>',
+ },
+ false,
+ );
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <cluster-form-dropdown
+ field-id="eks-region"
+ field-name="eks-region"
+ :items="regions"
+ :loading="loading"
+ :loading-text="s__('ClusterIntegration|Loading Regions')"
+ :placeholder="s__('ClusterIntergation|Select a region')"
+ :search-field-placeholder="s__('ClusterIntegration|Search regions')"
+ :empty-text="s__('ClusterIntegration|No region found')"
+ :has-errors="hasErrors"
+ :error-message="s__('ClusterIntegration|Could not load regions from your AWS account')"
+ v-bind="$attrs"
+ v-on="$listeners"
+ />
+ <p class="form-text text-muted" v-html="helpText"></p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/role_name_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/role_name_dropdown.vue
deleted file mode 100644
index 70230b294ac..00000000000
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/role_name_dropdown.vue
+++ /dev/null
@@ -1,53 +0,0 @@
-<script>
-import { sprintf, s__ } from '~/locale';
-
-import ClusterFormDropdown from './cluster_form_dropdown.vue';
-
-export default {
- components: {
- ClusterFormDropdown,
- },
- props: {
- roles: {
- type: Array,
- required: false,
- default: () => [],
- },
- loading: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- computed: {
- helpText() {
- return sprintf(
- s__(
- 'ClusterIntegration|Select the IAM Role to allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role name, first create one on %{startLink}Amazon Web Services%{endLink}.',
- ),
- {
- startLink:
- '<a href="https://console.aws.amazon.com/iam/home?#roles" target="_blank" rel="noopener noreferrer">',
- endLink: '</a>',
- },
- false,
- );
- },
- },
-};
-</script>
-<template>
- <div>
- <cluster-form-dropdown
- field-id="eks-role-name"
- field-name="eks-role-name"
- :items="roles"
- :loading="loading"
- :loading-text="s__('ClusterIntegration|Loading IAM Roles')"
- :placeholder="s__('ClusterIntergation|Select role name')"
- :search-field-placeholder="s__('ClusterIntegration|Search IAM Roles')"
- :empty-text="s__('ClusterIntegration|No IAM Roles found')"
- />
- <p class="form-text text-muted" v-html="helpText"></p>
- </div>
-</template>
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/subnet_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/subnet_dropdown.vue
deleted file mode 100644
index e69de29bb2d..00000000000
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/subnet_dropdown.vue
+++ /dev/null
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/vpc_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/vpc_dropdown.vue
deleted file mode 100644
index e69de29bb2d..00000000000
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/vpc_dropdown.vue
+++ /dev/null
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/constants.js b/app/assets/javascripts/create_cluster/eks_cluster/constants.js
new file mode 100644
index 00000000000..339642f991e
--- /dev/null
+++ b/app/assets/javascripts/create_cluster/eks_cluster/constants.js
@@ -0,0 +1,7 @@
+// eslint-disable-next-line import/prefer-default-export
+export const KUBERNETES_VERSIONS = [
+ { name: '1.14', value: '1.14' },
+ { name: '1.13', value: '1.13' },
+ { name: '1.12', value: '1.12' },
+ { name: '1.11', value: '1.11' },
+];
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/index.js b/app/assets/javascripts/create_cluster/eks_cluster/index.js
index c62e5ec101d..1f595e9b2df 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/index.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/index.js
@@ -5,15 +5,22 @@ import createStore from './store';
Vue.use(Vuex);
-export default () =>
- new Vue({
- el: '.js-create-eks-cluster-form-container',
+export default el => {
+ const { gitlabManagedClusterHelpPath, kubernetesIntegrationHelpPath } = el.dataset;
+
+ return new Vue({
+ el,
store: createStore(),
components: {
CreateEksCluster,
},
- data() {},
render(createElement) {
- return createElement('create-eks-cluster');
+ return createElement('create-eks-cluster', {
+ props: {
+ gitlabManagedClusterHelpPath,
+ kubernetesIntegrationHelpPath,
+ },
+ });
},
});
+};
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js
index e69de29bb2d..d982e4db4c1 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js
@@ -0,0 +1,84 @@
+import EC2 from 'aws-sdk/clients/ec2';
+import IAM from 'aws-sdk/clients/iam';
+
+export const fetchRoles = () => {
+ const iam = new IAM();
+
+ return iam
+ .listRoles()
+ .promise()
+ .then(({ Roles: roles }) => roles.map(({ RoleName: name }) => ({ name })));
+};
+
+export const fetchKeyPairs = () => {
+ const ec2 = new EC2();
+
+ return ec2
+ .describeKeyPairs()
+ .promise()
+ .then(({ KeyPairs: keyPairs }) => keyPairs.map(({ RegionName: name }) => ({ name })));
+};
+
+export const fetchRegions = () => {
+ const ec2 = new EC2();
+
+ return ec2
+ .describeRegions()
+ .promise()
+ .then(({ Regions: regions }) =>
+ regions.map(({ RegionName: name }) => ({
+ name,
+ value: name,
+ })),
+ );
+};
+
+export const fetchVpcs = () => {
+ const ec2 = new EC2();
+
+ return ec2
+ .describeVpcs()
+ .promise()
+ .then(({ Vpcs: vpcs }) =>
+ vpcs.map(({ VpcId: id }) => ({
+ value: id,
+ name: id,
+ })),
+ );
+};
+
+export const fetchSubnets = ({ vpc }) => {
+ const ec2 = new EC2();
+
+ return ec2
+ .describeSubnets({
+ Filters: [
+ {
+ Name: 'vpc-id',
+ Values: [vpc],
+ },
+ ],
+ })
+ .promise()
+ .then(({ Subnets: subnets }) => subnets.map(({ SubnetId: id }) => ({ id, name: id })));
+};
+
+export const fetchSecurityGroups = ({ vpc }) => {
+ const ec2 = new EC2();
+
+ return ec2
+ .describeSecurityGroups({
+ Filters: [
+ {
+ Name: 'vpc-id',
+ Values: [vpc],
+ },
+ ],
+ })
+ .promise()
+ .then(({ SecurityGroups: securityGroups }) =>
+ securityGroups.map(({ GroupName: name, GroupId: value }) => ({ name, value })),
+ );
+};
+
+export default () => {};
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js
index 861bcddfcc7..917c8da6c3e 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js
@@ -1,3 +1,43 @@
-// import awsServices from '../services/aws_services_facade';
+import * as types from './mutation_types';
+
+export const setClusterName = ({ commit }, payload) => {
+ commit(types.SET_CLUSTER_NAME, payload);
+};
+
+export const setEnvironmentScope = ({ commit }, payload) => {
+ commit(types.SET_ENVIRONMENT_SCOPE, payload);
+};
+
+export const setKubernetesVersion = ({ commit }, payload) => {
+ commit(types.SET_KUBERNETES_VERSION, payload);
+};
+
+export const setRegion = ({ commit }, payload) => {
+ commit(types.SET_REGION, payload);
+};
+
+export const setKeyPair = ({ commit }, payload) => {
+ commit(types.SET_KEY_PAIR, payload);
+};
+
+export const setVpc = ({ commit }, payload) => {
+ commit(types.SET_VPC, payload);
+};
+
+export const setSubnet = ({ commit }, payload) => {
+ commit(types.SET_SUBNET, payload);
+};
+
+export const setRole = ({ commit }, payload) => {
+ commit(types.SET_ROLE, payload);
+};
+
+export const setSecurityGroup = ({ commit }, payload) => {
+ commit(types.SET_SECURITY_GROUP, payload);
+};
+
+export const setGitlabManagedCluster = ({ commit }, payload) => {
+ commit(types.SET_GITLAB_MANAGED_CLUSTER, payload);
+};
export default () => {};
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/actions.js
new file mode 100644
index 00000000000..5d250b2e29e
--- /dev/null
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/actions.js
@@ -0,0 +1,14 @@
+import * as types from './mutation_types';
+
+export default fetchItems => ({
+ requestItems: ({ commit }) => commit(types.REQUEST_ITEMS),
+ receiveItemsSuccess: ({ commit }, payload) => commit(types.RECEIVE_ITEMS_SUCCESS, payload),
+ receiveItemsError: ({ commit }, payload) => commit(types.RECEIVE_ITEMS_ERROR, payload),
+ fetchItems: ({ dispatch }, payload) => {
+ dispatch('requestItems');
+
+ return fetchItems(payload)
+ .then(items => dispatch('receiveItemsSuccess', { items }))
+ .catch(error => dispatch('receiveItemsError', { error }));
+ },
+});
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/security_group_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/getters.js
index e69de29bb2d..e69de29bb2d 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/security_group_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/getters.js
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js
new file mode 100644
index 00000000000..07a5821c47d
--- /dev/null
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js
@@ -0,0 +1,13 @@
+import * as getters from './getters';
+import actions from './actions';
+import mutations from './mutations';
+import state from './state';
+
+const createStore = fetchFn => ({
+ actions: actions(fetchFn),
+ getters,
+ mutations,
+ state: state(),
+});
+
+export default createStore;
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutation_types.js b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutation_types.js
new file mode 100644
index 00000000000..48959a73924
--- /dev/null
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutation_types.js
@@ -0,0 +1,3 @@
+export const REQUEST_ITEMS = 'REQUEST_ITEMS';
+export const RECEIVE_ITEMS_SUCCESS = 'REQUEST_ITEMS_SUCCESS';
+export const RECEIVE_ITEMS_ERROR = 'RECEIVE_ITEMS_ERROR';
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutations.js b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutations.js
new file mode 100644
index 00000000000..d09689f1f6c
--- /dev/null
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutations.js
@@ -0,0 +1,16 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.REQUEST_ITEMS](state) {
+ state.isLoadingItems = true;
+ state.loadingItemsError = null;
+ },
+ [types.RECEIVE_ITEMS_SUCCESS](state, { items }) {
+ state.isLoadingItems = false;
+ state.items = items;
+ },
+ [types.RECEIVE_ITEMS_ERROR](state, { error }) {
+ state.isLoadingItems = false;
+ state.loadingItemsError = error;
+ },
+};
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/state.js b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/state.js
new file mode 100644
index 00000000000..b949a24216e
--- /dev/null
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/state.js
@@ -0,0 +1,5 @@
+export default () => ({
+ isLoadingItems: false,
+ items: [],
+ loadingItemsError: null,
+});
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js
index 99e9e35fd1a..d575deafd19 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js
@@ -4,12 +4,42 @@ import * as getters from './getters';
import mutations from './mutations';
import state from './state';
+import clusterDropdownStore from './cluster_dropdown';
+
+import * as awsServices from '../services/aws_services_facade';
+
const createStore = () =>
new Vuex.Store({
actions,
getters,
mutations,
- state,
+ state: state(),
+ modules: {
+ roles: {
+ namespaced: true,
+ ...clusterDropdownStore(awsServices.fetchRoles),
+ },
+ regions: {
+ namespaced: true,
+ ...clusterDropdownStore(awsServices.fetchRegions),
+ },
+ keyPairs: {
+ namespaced: true,
+ ...clusterDropdownStore(awsServices.fetchKeyPairs),
+ },
+ vpcs: {
+ namespaced: true,
+ ...clusterDropdownStore(awsServices.fetchVpcs),
+ },
+ subnets: {
+ namespaced: true,
+ ...clusterDropdownStore(awsServices.fetchSubnets),
+ },
+ securityGroups: {
+ namespaced: true,
+ ...clusterDropdownStore(awsServices.fetchSecurityGroups),
+ },
+ },
});
export default createStore;
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js
index e69de29bb2d..82eb512ac07 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js
@@ -0,0 +1,10 @@
+export const SET_CLUSTER_NAME = 'SET_CLUSTER_NAME';
+export const SET_ENVIRONMENT_SCOPE = 'SET_ENVIRONMENT_SCOPE';
+export const SET_KUBERNETES_VERSION = 'SET_KUBERNETES_VERSION';
+export const SET_REGION = 'SET_REGION';
+export const SET_VPC = 'SET_VPC';
+export const SET_KEY_PAIR = 'SET_KEY_PAIR';
+export const SET_SUBNET = 'SET_SUBNET';
+export const SET_ROLE = 'SET_ROLE';
+export const SET_SECURITY_GROUP = 'SET_SECURITY_GROUP';
+export const SET_GITLAB_MANAGED_CLUSTER = 'SET_GITLAB_MANAGED_CLUSTER';
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js
index e69de29bb2d..79950ac7dce 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js
@@ -0,0 +1,34 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_CLUSTER_NAME](state, { clusterName }) {
+ state.clusterName = clusterName;
+ },
+ [types.SET_ENVIRONMENT_SCOPE](state, { environmentScope }) {
+ state.environmentScope = environmentScope;
+ },
+ [types.SET_KUBERNETES_VERSION](state, { kubernetesVersion }) {
+ state.kubernetesVersion = kubernetesVersion;
+ },
+ [types.SET_REGION](state, { region }) {
+ state.selectedRegion = region;
+ },
+ [types.SET_KEY_PAIR](state, { keyPair }) {
+ state.selectedKeyPair = keyPair;
+ },
+ [types.SET_VPC](state, { vpc }) {
+ state.selectedVpc = vpc;
+ },
+ [types.SET_SUBNET](state, { subnet }) {
+ state.selectedSubnet = subnet;
+ },
+ [types.SET_ROLE](state, { role }) {
+ state.selectedRole = role;
+ },
+ [types.SET_SECURITY_GROUP](state, { securityGroup }) {
+ state.selectedSecurityGroup = securityGroup;
+ },
+ [types.SET_GITLAB_MANAGED_CLUSTER](state, { gitlabManagedCluster }) {
+ state.gitlabManagedCluster = gitlabManagedCluster;
+ },
+};
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js
index 9754ccfeeaf..bf74213bdce 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js
@@ -1,19 +1,18 @@
+import { KUBERNETES_VERSIONS } from '../constants';
+
export default () => ({
isValidatingCredentials: false,
validCredentials: false,
- isLoadingRoles: false,
- isLoadingVPCs: false,
- isLoadingSubnets: false,
- isLoadingSecurityGroups: false,
-
- roles: [],
- vpcs: [],
- subnets: [],
- securityGroups: [],
-
+ clusterName: '',
+ environmentScope: '*',
+ kubernetesVersion: [KUBERNETES_VERSIONS].value,
+ selectedRegion: '',
selectedRole: '',
- selectedVPC: '',
+ selectedKeyPair: '',
+ selectedVpc: '',
selectedSubnet: '',
selectedSecurityGroup: '',
+
+ gitlabManagedCluster: true,
});
diff --git a/app/assets/javascripts/create_item_dropdown.js b/app/assets/javascripts/create_item_dropdown.js
index fa0f04c7d82..95b890b04c1 100644
--- a/app/assets/javascripts/create_item_dropdown.js
+++ b/app/assets/javascripts/create_item_dropdown.js
@@ -1,4 +1,5 @@
import _ from 'underscore';
+import '~/gl_dropdown';
export default class CreateItemDropdown {
/**
diff --git a/app/assets/javascripts/create_label.js b/app/assets/javascripts/create_label.js
index eac0e37bcaa..9c0ed7f79d4 100644
--- a/app/assets/javascripts/create_label.js
+++ b/app/assets/javascripts/create_label.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, prefer-arrow-callback */
+/* eslint-disable func-names */
import $ from 'jquery';
import Api from './api';
@@ -50,7 +50,7 @@ export default class CreateLabelDropdown {
this.$dropdownBack.on('click', this.resetForm.bind(this));
- this.$cancelButton.on('click', function(e) {
+ this.$cancelButton.on('click', e => {
e.preventDefault();
e.stopPropagation();
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue
index 63549596fac..fc6d83bf96c 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue
+++ b/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue
@@ -34,7 +34,7 @@ export default {
class="more-actions-toggle btn btn-transparent p-0"
data-toggle="dropdown"
>
- <icon css-classes="icon" name="ellipsis_v" />
+ <icon class="icon" name="ellipsis_v" />
</gl-button>
<ul class="more-actions-dropdown dropdown-menu dropdown-open-left">
<slot name="dropdown-options"></slot>
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
index 7744984edfc..cd67ba5fab8 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
@@ -3,7 +3,6 @@ import Vue from 'vue';
import Cookies from 'js-cookie';
import { GlEmptyState } from '@gitlab/ui';
import filterMixins from 'ee_else_ce/analytics/cycle_analytics/mixins/filter_mixins';
-import addStageMixin from 'ee_else_ce/analytics/cycle_analytics/mixins/add_stage_mixin';
import Flash from '../flash';
import { __ } from '~/locale';
import Translate from '../vue_shared/translate';
@@ -44,12 +43,8 @@ export default () => {
DateRangeDropdown: () =>
import('ee_component/analytics/shared/components/date_range_dropdown.vue'),
'stage-nav-item': stageNavItem,
- CustomStageForm: () =>
- import('ee_component/analytics/cycle_analytics/components/custom_stage_form.vue'),
- AddStageButton: () =>
- import('ee_component/analytics/cycle_analytics/components/add_stage_button.vue'),
},
- mixins: [filterMixins, addStageMixin],
+ mixins: [filterMixins],
data() {
return {
store: CycleAnalyticsStore,
@@ -129,7 +124,6 @@ export default () => {
return;
}
- this.hideAddStageForm();
this.isLoadingStage = true;
this.store.setStageEvents([], stage);
this.store.setActiveStage(stage);
diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js
index 0687028ca54..27990b0a45e 100644
--- a/app/assets/javascripts/diff_notes/services/resolve.js
+++ b/app/assets/javascripts/diff_notes/services/resolve.js
@@ -2,7 +2,6 @@
import Vue from 'vue';
import Flash from '../../flash';
-import '../../vue_shared/vue_resource_interceptor';
import { __ } from '~/locale';
window.gl = window.gl || {};
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index 761fd1583ed..43a7703f611 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -121,7 +121,7 @@ export default {
<div class="label label-monospace monospace" v-text="commit.short_id"></div>
<clipboard-button
:text="commit.id"
- :title="__('Copy commit SHA to clipboard')"
+ :title="__('Copy commit SHA')"
class="btn btn-default"
/>
</div>
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index bfcc726a030..665328eb234 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -209,7 +209,7 @@ export default {
</a>
<clipboard-button
- :title="__('Copy file path to clipboard')"
+ :title="__('Copy file path')"
:text="diffFile.file_path"
:gfm="gfmCopyText"
css-class="btn-default btn-transparent btn-clipboard"
diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue
index aee01409db7..1eb17588376 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_view.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue
@@ -45,12 +45,11 @@ export default {
:data-commit-id="commitId"
class="code diff-wrap-lines js-syntax-highlight text-file js-diff-inline-view"
>
- <!-- Need to insert an empty row to solve "table-layout:fixed" equal width when expansion row is the first line -->
- <tr>
- <td style="width: 50px;"></td>
- <td style="width: 50px;"></td>
- <td></td>
- </tr>
+ <colgroup>
+ <col style="width: 50px;" />
+ <col style="width: 50px;" />
+ <col />
+ </colgroup>
<tbody>
<template v-for="(line, index) in diffLines">
<inline-diff-expansion-row
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
index d400eb2c586..88baac092a1 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
@@ -45,13 +45,12 @@ export default {
:data-commit-id="commitId"
class="code diff-wrap-lines js-syntax-highlight text-file"
>
- <!-- Need to insert an empty row to solve "table-layout:fixed" equal width when expansion row is the first line -->
- <tr>
- <td style="width: 50px;"></td>
- <td></td>
- <td style="width: 50px;"></td>
- <td></td>
- </tr>
+ <colgroup>
+ <col style="width: 50px;" />
+ <col />
+ <col style="width: 50px;" />
+ <col />
+ </colgroup>
<tbody>
<template v-for="(line, index) in diffLines">
<parallel-diff-expansion-row
diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue
index 2cc3412e075..1ea4e30a7c1 100644
--- a/app/assets/javascripts/environments/components/stop_environment_modal.vue
+++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue
@@ -1,7 +1,7 @@
<script>
/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import { GlTooltipDirective } from '@gitlab/ui';
-import GlModal from '~/vue_shared/components/gl_modal.vue';
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { s__, sprintf } from '~/locale';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import eventHub from '../event_hub';
@@ -11,7 +11,7 @@ export default {
name: 'StopEnvironmentModal',
components: {
- GlModal,
+ GlModal: DeprecatedModal2,
LoadingButton,
},
diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
index b1d568532a6..cd298e2c692 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -4,13 +4,15 @@ import { GlEmptyState, GlButton, GlLink, GlLoadingIcon, GlTable } from '@gitlab/
import Icon from '~/vue_shared/components/icon.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { __ } from '~/locale';
+import TrackEventDirective from '~/vue_shared/directives/track_event';
+import { trackViewInSentryOptions, trackClickErrorLinkToSentryOptions } from '../utils';
export default {
fields: [
- { key: 'error', label: __('Open errors') },
+ { key: 'error', label: __('Open errors'), thClass: 'w-70p' },
{ key: 'events', label: __('Events') },
{ key: 'users', label: __('Users') },
- { key: 'lastSeen', label: __('Last seen') },
+ { key: 'lastSeen', label: __('Last seen'), thClass: 'w-15p' },
],
components: {
GlEmptyState,
@@ -21,6 +23,9 @@ export default {
Icon,
TimeAgo,
},
+ directives: {
+ TrackEvent: TrackEventDirective,
+ },
props: {
indexPath: {
type: String,
@@ -53,6 +58,8 @@ export default {
},
methods: {
...mapActions(['startPolling', 'restartPolling']),
+ trackViewInSentryOptions,
+ trackClickErrorLinkToSentryOptions,
},
};
</script>
@@ -65,42 +72,52 @@ export default {
</div>
<div v-else>
<div class="d-flex justify-content-end">
- <gl-button class="my-3 ml-auto" variant="primary" :href="externalUrl" target="_blank">
+ <gl-button
+ v-track-event="trackViewInSentryOptions(externalUrl)"
+ class="my-3 ml-auto"
+ variant="primary"
+ :href="externalUrl"
+ target="_blank"
+ >
{{ __('View in Sentry') }}
- <icon name="external-link" />
+ <icon name="external-link" class="flex-shrink-0" />
</gl-button>
</div>
- <gl-table :items="errors" :fields="$options.fields" :show-empty="true">
+
+ <gl-table :items="errors" :fields="$options.fields" :show-empty="true" fixed stacked="sm">
<template slot="HEAD_events" slot-scope="data">
- <div class="text-right">{{ data.label }}</div>
+ <div class="text-md-right">{{ data.label }}</div>
</template>
<template slot="HEAD_users" slot-scope="data">
- <div class="text-right">{{ data.label }}</div>
+ <div class="text-md-right">{{ data.label }}</div>
</template>
<template slot="error" slot-scope="errors">
<div class="d-flex flex-column">
- <div class="d-flex">
- <gl-link :href="errors.item.externalUrl" class="d-flex text-dark" target="_blank">
- <strong>{{ errors.item.title.trim() }}</strong>
- <icon name="external-link" class="ml-1" />
- </gl-link>
- <span class="text-secondary ml-2">{{ errors.item.culprit }}</span>
- </div>
- {{ errors.item.message || __('No details available') }}
+ <gl-link
+ v-track-event="trackClickErrorLinkToSentryOptions(errors.item.externalUrl)"
+ :href="errors.item.externalUrl"
+ class="d-flex text-dark"
+ target="_blank"
+ >
+ <strong class="text-truncate">{{ errors.item.title.trim() }}</strong>
+ <icon name="external-link" class="ml-1 flex-shrink-0" />
+ </gl-link>
+ <span class="text-secondary text-truncate">
+ {{ errors.item.culprit }}
+ </span>
</div>
</template>
<template slot="events" slot-scope="errors">
- <div class="text-right">{{ errors.item.count }}</div>
+ <div class="text-md-right">{{ errors.item.count }}</div>
</template>
<template slot="users" slot-scope="errors">
- <div class="text-right">{{ errors.item.userCount }}</div>
+ <div class="text-md-right">{{ errors.item.userCount }}</div>
</template>
<template slot="lastSeen" slot-scope="errors">
<div class="d-flex align-items-center">
- <icon name="calendar" css-classes="text-secondary mr-1" />
<time-ago :time="errors.item.lastSeen" class="text-secondary" />
</div>
</template>
diff --git a/app/assets/javascripts/error_tracking/utils.js b/app/assets/javascripts/error_tracking/utils.js
new file mode 100644
index 00000000000..b832b1371b1
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/utils.js
@@ -0,0 +1,23 @@
+/* eslint-disable @gitlab/i18n/no-non-i18n-strings */
+
+/**
+ * Tracks snowplow event when user clicks View in Sentry btn
+ * @param {String} externalUrl that will be send as a property for the event
+ */
+export const trackViewInSentryOptions = url => ({
+ category: 'Error Tracking',
+ action: 'click_view_in_sentry',
+ label: 'External Url',
+ property: url,
+});
+
+/**
+ * Tracks snowplow event when User clicks on error link to Sentry
+ * @param {String} externalUrl that will be send as a property for the event
+ */
+export const trackClickErrorLinkToSentryOptions = url => ({
+ category: 'Error Tracking',
+ action: 'click_error_link_to_sentry',
+ label: 'Error Link',
+ property: url,
+});
diff --git a/app/assets/javascripts/event_tracking/issue_sidebar.js b/app/assets/javascripts/event_tracking/issue_sidebar.js
deleted file mode 100644
index 6909f82c66f..00000000000
--- a/app/assets/javascripts/event_tracking/issue_sidebar.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export const initSidebarTracking = () => {};
-export const trackEvent = () => {};
diff --git a/app/assets/javascripts/event_tracking/notes.js b/app/assets/javascripts/event_tracking/notes.js
deleted file mode 100644
index 1f70290c397..00000000000
--- a/app/assets/javascripts/event_tracking/notes.js
+++ /dev/null
@@ -1,2 +0,0 @@
-// Noop function which has a EE counter-part
-export default () => {};
diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js
index 77080691dcb..c21fba06d42 100644
--- a/app/assets/javascripts/filterable_list.js
+++ b/app/assets/javascripts/filterable_list.js
@@ -22,6 +22,7 @@ export default class FilterableList {
getPagePath() {
const action = this.filterForm.getAttribute('action');
+ // eslint-disable-next-line no-jquery/no-serialize
const params = $(this.filterForm).serialize();
return `${action}${action.indexOf('?') > 0 ? '&' : '?'}${params}`;
}
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index 660f0f0ba3e..fc9c5827ed4 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -40,13 +40,17 @@ const createFlashEl = (message, type) => `
<div class="flash-content flash-${type} rounded">
<div class="flash-text">
${_.escape(message)}
- ${spriteIcon('close', 'close-icon')}
+ <div class="close-icon-wrapper js-close-icon">
+ ${spriteIcon('close', 'close-icon')}
+ </div>
</div>
</div>
`;
const removeFlashClickListener = (flashEl, fadeTransition) => {
- flashEl.addEventListener('click', () => hideFlash(flashEl, fadeTransition));
+ flashEl
+ .querySelector('.js-close-icon')
+ .addEventListener('click', () => hideFlash(flashEl, fadeTransition));
};
/*
@@ -78,7 +82,6 @@ const createFlash = function createFlash(
flashContainer.innerHTML = createFlashEl(message, type);
const flashEl = flashContainer.querySelector(`.flash-${type}`);
- removeFlashClickListener(flashEl, fadeTransition);
if (actionConfig) {
flashEl.innerHTML += createAction(actionConfig);
@@ -90,6 +93,8 @@ const createFlash = function createFlash(
}
}
+ removeFlashClickListener(flashEl, fadeTransition);
+
flashContainer.style.display = 'block';
if (addBodyClass) document.body.classList.add('flash-shown');
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index b308cd9c236..db3ad0bb4c9 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -337,6 +337,7 @@ class GfmAutoComplete {
},
// eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${title}',
+ limit: 20,
callbacks: {
...this.getDefaultCallbacks(),
beforeSave(merges) {
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index f49246cf07b..4e1b4f2652c 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-underscore-dangle, no-var, one-var, vars-on-top, no-shadow, no-cond-assign, prefer-arrow-callback, no-return-assign, no-else-return, camelcase, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, prefer-template, no-param-reassign, no-loop-func */
+/* eslint-disable func-names, no-underscore-dangle, no-var, one-var, vars-on-top, no-shadow, no-cond-assign, no-return-assign, no-else-return, camelcase, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, no-param-reassign, no-loop-func */
import $ from 'jquery';
import _ from 'underscore';
@@ -35,13 +35,13 @@ GitLabDropdownInput = (function() {
);
this.input
- .on('keydown', function(e) {
+ .on('keydown', e => {
var keyCode = e.which;
if (keyCode === 13 && !options.elIsInput) {
e.preventDefault();
}
})
- .on('input', function(e) {
+ .on('input', e => {
var val = e.currentTarget.value || _this.options.inputFieldName;
val = val
.split(' ')
@@ -95,42 +95,33 @@ GitLabDropdownFilter = (function() {
// Key events
timeout = '';
this.input
- .on('keydown', function(e) {
+ .on('keydown', e => {
var keyCode = e.which;
if (keyCode === 13 && !options.elIsInput) {
e.preventDefault();
}
})
- .on(
- 'input',
- function() {
- if (this.input.val() !== '' && !$inputContainer.hasClass(HAS_VALUE_CLASS)) {
- $inputContainer.addClass(HAS_VALUE_CLASS);
- } else if (this.input.val() === '' && $inputContainer.hasClass(HAS_VALUE_CLASS)) {
- $inputContainer.removeClass(HAS_VALUE_CLASS);
- }
- // Only filter asynchronously only if option remote is set
- if (this.options.remote) {
- clearTimeout(timeout);
- return (timeout = setTimeout(
- function() {
- $inputContainer.parent().addClass('is-loading');
-
- return this.options.query(
- this.input.val(),
- function(data) {
- $inputContainer.parent().removeClass('is-loading');
- return this.options.callback(data);
- }.bind(this),
- );
- }.bind(this),
- 250,
- ));
- } else {
- return this.filter(this.input.val());
- }
- }.bind(this),
- );
+ .on('input', () => {
+ if (this.input.val() !== '' && !$inputContainer.hasClass(HAS_VALUE_CLASS)) {
+ $inputContainer.addClass(HAS_VALUE_CLASS);
+ } else if (this.input.val() === '' && $inputContainer.hasClass(HAS_VALUE_CLASS)) {
+ $inputContainer.removeClass(HAS_VALUE_CLASS);
+ }
+ // Only filter asynchronously only if option remote is set
+ if (this.options.remote) {
+ clearTimeout(timeout);
+ return (timeout = setTimeout(() => {
+ $inputContainer.parent().addClass('is-loading');
+
+ return this.options.query(this.input.val(), data => {
+ $inputContainer.parent().removeClass('is-loading');
+ return this.options.callback(data);
+ });
+ }, 250));
+ } else {
+ return this.filter(this.input.val());
+ }
+ });
}
GitLabDropdownFilter.prototype.shouldBlur = function(keyCode) {
@@ -175,9 +166,7 @@ GitLabDropdownFilter = (function() {
key: this.options.keys,
});
if (tmp.length) {
- results[key] = tmp.map(function(item) {
- return item;
- });
+ results[key] = tmp.map(item => item);
}
}
}
@@ -283,7 +272,7 @@ GitLabDropdown = (function() {
NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-item';
- SELECTABLE_CLASSES = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ', .option-hidden)';
+ SELECTABLE_CLASSES = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES}, .option-hidden)`;
CURSOR_SELECT_SCROLL_PADDING = 5;
@@ -370,9 +359,9 @@ GitLabDropdown = (function() {
instance: this,
elements: (function(_this) {
return function() {
- selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')';
+ selector = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES})`;
if (_this.dropdown.find('.dropdown-toggle-page').length) {
- selector = '.dropdown-page-one ' + selector;
+ selector = `.dropdown-page-one ${selector}`;
}
return $(selector, this.instance.dropdown);
};
@@ -388,7 +377,7 @@ GitLabDropdown = (function() {
if (_this.filterInput.val() !== '') {
selector = SELECTABLE_CLASSES;
if (_this.dropdown.find('.dropdown-toggle-page').length) {
- selector = '.dropdown-page-one ' + selector;
+ selector = `.dropdown-page-one ${selector}`;
}
if ($(_this.el).is('input')) {
currentIndex = -1;
@@ -453,32 +442,28 @@ GitLabDropdown = (function() {
if (this.dropdown.find('.dropdown-toggle-page').length) {
selector = '.dropdown-page-one .dropdown-content a';
}
- this.dropdown.on(
- 'click',
- selector,
- function(e) {
- var $el, selected, selectedObj, isMarking;
- $el = $(e.currentTarget);
- selected = self.rowClicked($el);
- selectedObj = selected ? selected[0] : null;
- isMarking = selected ? selected[1] : null;
- if (this.options.clicked) {
- this.options.clicked.call(this, {
- selectedObj,
- $el,
- e,
- isMarking,
- });
- }
+ this.dropdown.on('click', selector, e => {
+ var $el, selected, selectedObj, isMarking;
+ $el = $(e.currentTarget);
+ selected = self.rowClicked($el);
+ selectedObj = selected ? selected[0] : null;
+ isMarking = selected ? selected[1] : null;
+ if (this.options.clicked) {
+ this.options.clicked.call(this, {
+ selectedObj,
+ $el,
+ e,
+ isMarking,
+ });
+ }
- // Update label right after all modifications in dropdown has been done
- if (this.options.toggleLabel) {
- this.updateLabel(selectedObj, $el, this);
- }
+ // Update label right after all modifications in dropdown has been done
+ if (this.options.toggleLabel) {
+ this.updateLabel(selectedObj, $el, this);
+ }
- $el.trigger('blur');
- }.bind(this),
- );
+ $el.trigger('blur');
+ });
}
}
@@ -525,9 +510,7 @@ GitLabDropdown = (function() {
name,
),
);
- this.renderData(groupData, name).map(function(item) {
- return html.push(item);
- });
+ this.renderData(groupData, name).map(item => html.push(item));
}
} else {
// Render each row
@@ -708,9 +691,9 @@ GitLabDropdown = (function() {
return text
.split('')
- .map(function(character, i) {
+ .map((character, i) => {
if (indexOf.call(occurrences, i) !== -1) {
- return '<b>' + character + '</b>';
+ return `<b>${character}</b>`;
} else {
return character;
}
@@ -734,6 +717,7 @@ GitLabDropdown = (function() {
selectedObject = this.renderedData[groupName][selectedIndex];
} else {
selectedIndex = el.closest('li').index();
+ this.selectedIndex = selectedIndex;
selectedObject = this.renderedData[selectedIndex];
}
}
@@ -755,9 +739,7 @@ GitLabDropdown = (function() {
} else if (value != null) {
field = this.dropdown
.parent()
- .find(
- "input[name='" + fieldName + "'][value='" + value.toString().replace(/'/g, "\\'") + "']",
- );
+ .find(`input[name='${fieldName}'][value='${value.toString().replace(/'/g, "\\'")}']`);
}
if (this.options.isSelectable && !this.options.isSelectable(selectedObject, el)) {
@@ -783,11 +765,11 @@ GitLabDropdown = (function() {
} else {
isMarking = true;
if (!this.options.multiSelect || el.hasClass('dropdown-clear-active')) {
- this.dropdown.find('.' + ACTIVE_CLASS).removeClass(ACTIVE_CLASS);
+ this.dropdown.find(`.${ACTIVE_CLASS}`).removeClass(ACTIVE_CLASS);
if (!isInput) {
this.dropdown
.parent()
- .find("input[name='" + fieldName + "']")
+ .find(`input[name='${fieldName}']`)
.remove();
}
}
@@ -826,7 +808,7 @@ GitLabDropdown = (function() {
var $input;
// Create hidden input for form
if (single) {
- $('input[name="' + fieldName + '"]').remove();
+ $(`input[name="${fieldName}"]`).remove();
}
$input = $('<input>')
@@ -854,12 +836,12 @@ GitLabDropdown = (function() {
var $el, selector;
// If we pass an option index
if (typeof index !== 'undefined') {
- selector = SELECTABLE_CLASSES + ':eq(' + index + ') a';
+ selector = `${SELECTABLE_CLASSES}:eq(${index}) a`;
} else {
selector = '.dropdown-content .is-focused';
}
if (this.dropdown.find('.dropdown-toggle-page').length) {
- selector = '.dropdown-page-one ' + selector;
+ selector = `.dropdown-page-one ${selector}`;
}
// simulate a click on the first link
$el = $(selector, this.dropdown);
@@ -878,7 +860,7 @@ GitLabDropdown = (function() {
ARROW_KEY_CODES = [38, 40];
selector = SELECTABLE_CLASSES;
if (this.dropdown.find('.dropdown-toggle-page').length) {
- selector = '.dropdown-page-one ' + selector;
+ selector = `.dropdown-page-one ${selector}`;
}
return $('body').on(
'keydown',
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 830385941d8..ede74d18ed4 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -104,11 +104,11 @@ export default {
/>
<div
:class="{ 'd-sm-flex': !group.isChildrenLoading }"
- class="avatar-container rect-avatar s32 d-none flex-grow-0 flex-shrink-0 "
+ class="avatar-container rect-avatar s40 d-none flex-grow-0 flex-shrink-0 "
>
<a :href="group.relativePath" class="no-expand">
- <img v-if="hasAvatar" :src="group.avatarUrl" class="avatar s32" />
- <identicon v-else :entity-id="group.id" :entity-name="group.name" size-class="s32" />
+ <img v-if="hasAvatar" :src="group.avatarUrl" class="avatar s40" />
+ <identicon v-else :entity-id="group.id" :entity-name="group.name" size-class="s40" />
</a>
</div>
<div class="group-text-container d-flex flex-fill align-items-center">
diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue
index cafd22731b1..4b569970204 100644
--- a/app/assets/javascripts/groups/components/item_actions.vue
+++ b/app/assets/javascripts/groups/components/item_actions.vue
@@ -56,7 +56,7 @@ export default {
class="leave-group btn btn-xs no-expand"
@click.prevent="onLeaveGroup"
>
- <icon name="leave" css-classes="position-top-0" />
+ <icon name="leave" class="position-top-0" />
</a>
<a
v-if="group.canEdit"
@@ -68,7 +68,7 @@ export default {
data-placement="bottom"
class="edit-group btn btn-xs no-expand"
>
- <icon name="settings" css-classes="position-top-0" />
+ <icon name="settings" class="position-top-0" />
</a>
</div>
</template>
diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js
index f1cc6756583..a5e38022b8d 100644
--- a/app/assets/javascripts/groups_select.js
+++ b/app/assets/javascripts/groups_select.js
@@ -4,96 +4,97 @@ import Api from './api';
import { normalizeHeaders } from './lib/utils/common_utils';
import { __ } from '~/locale';
-export default function groupsSelect() {
- import(/* webpackChunkName: 'select2' */ 'select2/select2')
- .then(() => {
- // Needs to be accessible in rspec
- window.GROUP_SELECT_PER_PAGE = 20;
- $('.ajax-groups-select').each(function setAjaxGroupsSelect2() {
- const $select = $(this);
- const allAvailable = $select.data('allAvailable');
- const skipGroups = $select.data('skipGroups') || [];
- const parentGroupID = $select.data('parentId');
- const groupsPath = parentGroupID
- ? Api.subgroupsPath.replace(':id', parentGroupID)
- : Api.groupsPath;
+const groupsSelect = () => {
+ // Needs to be accessible in rspec
+ window.GROUP_SELECT_PER_PAGE = 20;
+ $('.ajax-groups-select').each(function setAjaxGroupsSelect2() {
+ const $select = $(this);
+ const allAvailable = $select.data('allAvailable');
+ const skipGroups = $select.data('skipGroups') || [];
+ const parentGroupID = $select.data('parentId');
+ const groupsPath = parentGroupID
+ ? Api.subgroupsPath.replace(':id', parentGroupID)
+ : Api.groupsPath;
- $select.select2({
- placeholder: __('Search for a group'),
- allowClear: $select.hasClass('allowClear'),
- multiple: $select.hasClass('multiselect'),
- minimumInputLength: 0,
- ajax: {
- url: Api.buildUrl(groupsPath),
- dataType: 'json',
- quietMillis: 250,
- transport(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;
+ $select.select2({
+ placeholder: __('Search for a group'),
+ allowClear: $select.hasClass('allowClear'),
+ multiple: $select.hasClass('multiselect'),
+ minimumInputLength: 0,
+ ajax: {
+ url: Api.buildUrl(groupsPath),
+ dataType: 'json',
+ quietMillis: 250,
+ transport(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);
- },
- data(search, page) {
- return {
- search,
- page,
- per_page: window.GROUP_SELECT_PER_PAGE,
- all_available: allAvailable,
- };
- },
- results(data, page) {
- if (data.length) return { results: [] };
+ params.success({
+ results,
+ pagination: {
+ more,
+ },
+ });
+ })
+ .catch(params.error);
+ },
+ data(search, page) {
+ return {
+ search,
+ page,
+ per_page: window.GROUP_SELECT_PER_PAGE,
+ all_available: allAvailable,
+ };
+ },
+ 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);
+ 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);
- return {
- results,
- page,
- more,
- };
- },
- },
- // eslint-disable-next-line consistent-return
- initSelection(element, callback) {
- const id = $(element).val();
- if (id !== '') {
- return Api.group(id, callback);
- }
- },
- formatResult(object) {
- return `<div class='group-result'> <div class='group-name'>${object.full_name}</div> <div class='group-path'>${object.full_path}</div> </div>`;
- },
- formatSelection(object) {
- return object.full_name;
- },
- dropdownCssClass: 'ajax-groups-dropdown select2-infinite',
- // we do not want to escape markup since we are displaying html in results
- escapeMarkup(m) {
- return m;
- },
- });
+ return {
+ results,
+ page,
+ more,
+ };
+ },
+ },
+ // eslint-disable-next-line consistent-return
+ initSelection(element, callback) {
+ const id = $(element).val();
+ if (id !== '') {
+ return Api.group(id, callback);
+ }
+ },
+ formatResult(object) {
+ return `<div class='group-result'> <div class='group-name'>${object.full_name}</div> <div class='group-path'>${object.full_path}</div> </div>`;
+ },
+ formatSelection(object) {
+ return object.full_name;
+ },
+ dropdownCssClass: 'ajax-groups-dropdown select2-infinite',
+ // we do not want to escape markup since we are displaying html in results
+ escapeMarkup(m) {
+ return m;
+ },
+ });
- $select.on('select2-loaded', () => {
- const dropdown = document.querySelector('.select2-infinite .select2-results');
- dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`;
- });
- });
- })
+ $select.on('select2-loaded', () => {
+ const dropdown = document.querySelector('.select2-infinite .select2-results');
+ dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`;
+ });
+ });
+};
+
+export default () =>
+ import(/* webpackChunkName: 'select2' */ 'select2/select2')
+ .then(groupsSelect)
.catch(() => {});
-}
diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js
index 3d846310008..fdd27e08793 100644
--- a/app/assets/javascripts/header.js
+++ b/app/assets/javascripts/header.js
@@ -15,11 +15,10 @@ import { parseBoolean } from '~/lib/utils/common_utils';
*/
export default function initTodoToggle() {
$(document).on('todo:toggle', (e, count) => {
- const parsedCount = parseInt(count, 10);
const $todoPendingCount = $('.todos-count');
- $todoPendingCount.text(highCountTrim(parsedCount));
- $todoPendingCount.toggleClass('hidden', parsedCount === 0);
+ $todoPendingCount.text(highCountTrim(count));
+ $todoPendingCount.toggleClass('hidden', count === 0);
});
}
diff --git a/app/assets/javascripts/ide/.eslintrc.yml b/app/assets/javascripts/ide/.eslintrc.yml
new file mode 100644
index 00000000000..92b96d717be
--- /dev/null
+++ b/app/assets/javascripts/ide/.eslintrc.yml
@@ -0,0 +1,3 @@
+rules:
+ # https://gitlab.com/gitlab-org/gitlab/issues/33024
+ promise/no-nesting: off
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
index 11d5d9639b6..6b2ef34c960 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
@@ -43,7 +43,12 @@ export default {
<template>
<div class="d-flex ide-commit-editor-header align-items-center">
<file-icon :file-name="activeFile.name" :size="16" class="mr-2" />
- <strong class="mr-2"> {{ activeFile.path }} </strong>
+ <strong class="mr-2">
+ <template v-if="activeFile.prevPath && activeFile.prevPath !== activeFile.path">
+ {{ activeFile.prevPath }} &#x2192;
+ </template>
+ {{ activeFile.path }}
+ </strong>
<changed-file-icon :file="activeFile" :is-centered="false" />
<div class="ml-auto">
<button
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
index 4f1260de0bc..e16918ae025 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
@@ -3,7 +3,7 @@ import $ from 'jquery';
import { mapActions } from 'vuex';
import { __, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
-import GlModal from '~/vue_shared/components/gl_modal.vue';
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import ListItem from './list_item.vue';
@@ -11,7 +11,7 @@ export default {
components: {
Icon,
ListItem,
- GlModal,
+ GlModal: DeprecatedModal2,
},
directives: {
tooltip,
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue
index 3156a398113..b6fc567f8cc 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue
@@ -86,7 +86,7 @@ export default {
data-placement="left"
class="append-bottom-10"
>
- <icon :name="additionIconName" :size="18" :css-classes="addedFilesIconClass" />
+ <icon :name="additionIconName" :size="18" :class="addedFilesIconClass" />
</div>
{{ addedFilesLength }}
<div
@@ -96,7 +96,7 @@ export default {
data-placement="left"
class="prepend-top-10 append-bottom-10"
>
- <icon :name="modifiedIconName" :size="18" :css-classes="modifiedFilesClass" />
+ <icon :name="modifiedIconName" :size="18" :class="modifiedFilesClass" />
</div>
{{ modifiedFilesLength }}
</div>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
index 302adccd759..230dfaf047b 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
@@ -110,11 +110,14 @@ export default {
>
<span class="multi-file-commit-list-file-path d-flex align-items-center">
<file-icon :file-name="file.name" class="append-right-8" />
+ <template v-if="file.prevName && file.prevName !== file.name">
+ {{ file.prevName }} &#x2192;
+ </template>
{{ file.name }}
</span>
<div class="ml-auto d-flex align-items-center">
<div class="d-flex align-items-center ide-commit-list-changed-icon">
- <icon :name="iconName" :size="16" :css-classes="iconClass" />
+ <icon :name="iconName" :size="16" :class="iconClass" />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue
index 09c9d135614..c14b8a47841 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue
@@ -4,12 +4,12 @@ import { mapActions } from 'vuex';
import { sprintf, __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
-import GlModal from '~/vue_shared/components/gl_modal.vue';
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
export default {
components: {
Icon,
- GlModal,
+ GlModal: DeprecatedModal2,
},
directives: {
tooltip,
diff --git a/app/assets/javascripts/ide/components/external_link.vue b/app/assets/javascripts/ide/components/external_link.vue
index d1857f0176a..558da9b706e 100644
--- a/app/assets/javascripts/ide/components/external_link.vue
+++ b/app/assets/javascripts/ide/components/external_link.vue
@@ -28,7 +28,7 @@ export default {
rel="noopener noreferrer"
>
<span class="vertical-align-middle">{{ __('Open in file view') }}</span>
- <icon :size="16" name="external-link" css-classes="vertical-align-middle space-right" />
+ <icon :size="16" name="external-link" class="vertical-align-middle space-right" />
</a>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue
index 48be97c8952..f0bedcfbd6b 100644
--- a/app/assets/javascripts/ide/components/file_row_extra.vue
+++ b/app/assets/javascripts/ide/components/file_row_extra.vue
@@ -34,6 +34,9 @@ export default {
'getUnstagedFilesCountForPath',
'getStagedFilesCountForPath',
]),
+ isTree() {
+ return this.file.type === 'tree';
+ },
folderUnstagedCount() {
return this.getUnstagedFilesCountForPath(this.file.path);
},
@@ -58,10 +61,13 @@ export default {
});
},
showTreeChangesCount() {
- return this.file.type === 'tree' && this.changesCount > 0 && !this.file.opened;
+ return this.isTree && this.changesCount > 0 && !this.file.opened;
+ },
+ isModified() {
+ return this.file.changed || this.file.tempFile || this.file.staged || this.file.prevPath;
},
showChangedFileIcon() {
- return this.file.changed || this.file.tempFile || this.file.staged;
+ return !this.isTree && this.isModified;
},
},
};
@@ -79,7 +85,7 @@ export default {
data-container="body"
data-placement="right"
name="file-modified"
- css-classes="prepend-left-5 ide-file-modified"
+ class="prepend-left-5 ide-file-modified"
/>
</span>
<changed-file-icon
diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue
index 1af86a94482..95782b2c88a 100644
--- a/app/assets/javascripts/ide/components/ide_tree_list.vue
+++ b/app/assets/javascripts/ide/components/ide_tree_list.vue
@@ -30,9 +30,6 @@ export default {
showLoading() {
return !this.currentTree || this.currentTree.loading;
},
- actualTreeList() {
- return this.currentTree.tree.filter(entry => !entry.moved);
- },
},
mounted() {
this.updateViewer(this.viewerType);
@@ -57,9 +54,9 @@ export default {
<slot name="header"></slot>
</header>
<div class="ide-tree-body h-100">
- <template v-if="actualTreeList.length">
+ <template v-if="currentTree.tree.length">
<file-row
- v-for="file in actualTreeList"
+ v-for="file in currentTree.tree"
:key="file.key"
:file="file"
:level="0"
diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue
index b1be25ea602..9ad9d4455b5 100644
--- a/app/assets/javascripts/ide/components/jobs/stage.vue
+++ b/app/assets/javascripts/ide/components/jobs/stage.vue
@@ -77,7 +77,7 @@ export default {
<div v-if="!stage.isLoading || stage.jobs.length" class="append-right-8 prepend-left-4">
<span class="badge badge-pill"> {{ jobsCount }} </span>
</div>
- <icon :name="collapseIcon" css-classes="ide-stage-collapse-icon" />
+ <icon :name="collapseIcon" class="ide-stage-collapse-icon" />
</div>
<div v-show="!stage.isCollapsed" class="card-body">
<gl-loading-icon v-if="showLoadingIcon" />
diff --git a/app/assets/javascripts/ide/components/mr_file_icon.vue b/app/assets/javascripts/ide/components/mr_file_icon.vue
index 821be319cce..cf8a1abbde4 100644
--- a/app/assets/javascripts/ide/components/mr_file_icon.vue
+++ b/app/assets/javascripts/ide/components/mr_file_icon.vue
@@ -18,6 +18,6 @@ export default {
:title="__('Part of merge request changes')"
:size="12"
name="git-merge"
- css-classes="append-right-8"
+ class="append-right-8"
/>
</template>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/button.vue b/app/assets/javascripts/ide/components/new_dropdown/button.vue
index 062a64a19d7..5bd6642930c 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/button.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/button.vue
@@ -52,7 +52,7 @@ export default {
class="btn-blank"
@click.stop.prevent="clicked"
>
- <icon :name="icon" :css-classes="iconClasses" />
+ <icon :name="icon" :class="iconClasses" />
<template v-if="showLabel">
{{ label }}
</template>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index f67666f1fbf..d2ed1fe3e55 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -3,12 +3,12 @@ import $ from 'jquery';
import flash from '~/flash';
import { __, sprintf, s__ } from '~/locale';
import { mapActions, mapState, mapGetters } from 'vuex';
-import GlModal from '~/vue_shared/components/gl_modal.vue';
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { modalTypes } from '../../constants';
export default {
components: {
- GlModal,
+ GlModal: DeprecatedModal2,
},
data() {
return {
@@ -91,7 +91,6 @@ export default {
this.renameEntry({
path: this.entryModal.entry.path,
name: entryName,
- entryPath: null,
parentPath,
}),
)
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index 802b7f1fa6f..3bf8308ccea 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -155,15 +155,7 @@ export default {
this.editor.clearEditor();
- this.getFileData({
- path: this.file.path,
- makeFileActive: false,
- })
- .then(() =>
- this.getRawFileData({
- path: this.file.path,
- }),
- )
+ this.fetchFileData()
.then(() => {
this.createEditorInstance();
})
@@ -179,6 +171,20 @@ export default {
throw err;
});
},
+ fetchFileData() {
+ if (this.file.tempFile) {
+ return Promise.resolve();
+ }
+
+ return this.getFileData({
+ path: this.file.path,
+ makeFileActive: false,
+ }).then(() =>
+ this.getRawFileData({
+ path: this.file.path,
+ }),
+ );
+ },
createEditorInstance() {
this.editor.dispose();
diff --git a/app/assets/javascripts/ide/components/repo_file_status_icon.vue b/app/assets/javascripts/ide/components/repo_file_status_icon.vue
index 84a962bfc7d..9773e835a5c 100644
--- a/app/assets/javascripts/ide/components/repo_file_status_icon.vue
+++ b/app/assets/javascripts/ide/components/repo_file_status_icon.vue
@@ -29,6 +29,6 @@ export default {
<template>
<span v-if="file.file_lock" v-tooltip :title="lockTooltip" data-container="body">
- <icon name="lock" css-classes="file-status-icon" />
+ <icon name="lock" class="file-status-icon" />
</span>
</template>
diff --git a/app/assets/javascripts/ide/lib/files.js b/app/assets/javascripts/ide/lib/files.js
index 51278640b5b..e86dac20104 100644
--- a/app/assets/javascripts/ide/lib/files.js
+++ b/app/assets/javascripts/ide/lib/files.js
@@ -1,7 +1,5 @@
import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
-import { decorateData, sortTree } from '../stores/utils';
-
-export const escapeFileUrl = fileUrl => encodeURIComponent(fileUrl).replace(/%2F/g, '/');
+import { decorateData, sortTree, escapeFileUrl } from '../stores/utils';
export const splitParent = path => {
const idx = path.lastIndexOf('/');
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index 8c0119a1fed..4e18ec58feb 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -9,6 +9,7 @@ import { decorateFiles } from '../lib/files';
import { stageKeys } from '../constants';
import service from '../services';
import router from '../ide_router';
+import eventHub from '../eventhub';
export const redirectToUrl = (self, url) => visitUrl(url);
@@ -171,8 +172,10 @@ export const setCurrentBranchId = ({ commit }, currentBranchId) => {
export const updateTempFlagForEntry = ({ commit, dispatch, state }, { file, tempFile }) => {
commit(types.UPDATE_TEMP_FLAG, { path: file.path, tempFile });
- if (file.parentPath) {
- dispatch('updateTempFlagForEntry', { file: state.entries[file.parentPath], tempFile });
+ const parent = file.parentPath && state.entries[file.parentPath];
+
+ if (parent) {
+ dispatch('updateTempFlagForEntry', { file: parent, tempFile });
}
};
@@ -199,51 +202,71 @@ export const openNewEntryModal = ({ commit }, { type, path = '' }) => {
export const deleteEntry = ({ commit, dispatch, state }, path) => {
const entry = state.entries[path];
-
+ const { prevPath, prevName, prevParentPath } = entry;
+ const isTree = entry.type === 'tree';
+
+ if (prevPath) {
+ dispatch('renameEntry', {
+ path,
+ name: prevName,
+ parentPath: prevParentPath,
+ });
+ dispatch('deleteEntry', prevPath);
+ return;
+ }
if (state.unusedSeal) dispatch('burstUnusedSeal');
if (entry.opened) dispatch('closeFile', entry);
- if (entry.type === 'tree') {
+ if (isTree) {
entry.tree.forEach(f => dispatch('deleteEntry', f.path));
}
commit(types.DELETE_ENTRY, path);
- dispatch('stageChange', path);
+
+ // Only stage if we're not a directory or a new file
+ if (!isTree && !entry.tempFile) {
+ dispatch('stageChange', path);
+ }
dispatch('triggerFilesChange');
};
export const resetOpenFiles = ({ commit }) => commit(types.RESET_OPEN_FILES);
-export const renameEntry = (
- { dispatch, commit, state },
- { path, name, entryPath = null, parentPath },
-) => {
- const entry = state.entries[entryPath || path];
+export const renameEntry = ({ dispatch, commit, state }, { path, name, parentPath }) => {
+ const entry = state.entries[path];
+ const newPath = parentPath ? `${parentPath}/${name}` : name;
- commit(types.RENAME_ENTRY, { path, name, entryPath, parentPath });
+ commit(types.RENAME_ENTRY, { path, name, parentPath });
if (entry.type === 'tree') {
- const slashedParentPath = parentPath ? `${parentPath}/` : '';
- const targetEntry = entryPath ? entryPath.split('/').pop() : name;
- const newParentPath = `${slashedParentPath}${targetEntry}`;
-
- state.entries[entryPath || path].tree.forEach(f => {
+ state.entries[newPath].tree.forEach(f => {
dispatch('renameEntry', {
- path,
- name,
- entryPath: f.path,
- parentPath: newParentPath,
+ path: f.path,
+ name: f.name,
+ parentPath: newPath,
});
});
} else {
- const newPath = parentPath ? `${parentPath}/${name}` : name;
const newEntry = state.entries[newPath];
- commit(types.TOGGLE_FILE_CHANGED, { file: newEntry, changed: true });
+ const isRevert = newPath === entry.prevPath;
+ const isReset = isRevert && !newEntry.changed && !newEntry.tempFile;
+ const isInChanges = state.changedFiles
+ .concat(state.stagedFiles)
+ .some(({ key }) => key === newEntry.key);
+
+ if (isReset) {
+ commit(types.REMOVE_FILE_FROM_STAGED_AND_CHANGED, newEntry);
+ } else if (!isInChanges) {
+ commit(types.ADD_FILE_TO_CHANGED, newPath);
+ }
+
+ if (!newEntry.tempFile) {
+ eventHub.$emit(`editor.update.model.dispose.${entry.key}`);
+ }
- if (entry.opened) {
+ if (newEntry.opened) {
router.push(`/project${newEntry.url}`);
- commit(types.TOGGLE_FILE_OPEN, entry.path);
}
}
diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js
index 7627b6e03af..59445afc7a4 100644
--- a/app/assets/javascripts/ide/stores/actions/file.js
+++ b/app/assets/javascripts/ide/stores/actions/file.js
@@ -5,7 +5,7 @@ import eventHub from '../../eventhub';
import service from '../../services';
import * as types from '../mutation_types';
import router from '../../ide_router';
-import { setPageTitle } from '../utils';
+import { setPageTitle, replaceFileUrl } from '../utils';
import { viewerTypes, stageKeys } from '../../constants';
export const closeFile = ({ commit, state, dispatch }, file) => {
@@ -67,7 +67,7 @@ export const getFileData = (
commit(types.TOGGLE_LOADING, { entry: file });
- const url = file.prevPath ? file.url.replace(file.path, file.prevPath) : file.url;
+ const url = file.prevPath ? replaceFileUrl(file.url, file.path, file.prevPath) : file.url;
return service
.getFileData(joinPaths(gon.relative_url_root || '', url.replace('/-/', '/')))
@@ -186,11 +186,6 @@ export const discardFileChanges = ({ dispatch, state, commit, getters }, path) =
dispatch('restoreTree', file.parentPath);
}
- if (file.movedPath) {
- commit(types.DISCARD_FILE_CHANGES, file.movedPath);
- commit(types.REMOVE_FILE_FROM_CHANGED, file.movedPath);
- }
-
commit(types.DISCARD_FILE_CHANGES, path);
commit(types.REMOVE_FILE_FROM_CHANGED, path);
diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js
index dd8f17e4f3a..20887e7d0ac 100644
--- a/app/assets/javascripts/ide/stores/actions/project.js
+++ b/app/assets/javascripts/ide/stores/actions/project.js
@@ -92,13 +92,27 @@ export const showEmptyState = ({ commit, state }, { projectId, branchId }) => {
});
};
-export const openBranch = ({ dispatch, state, getters }, { projectId, branchId, basePath }) => {
- dispatch('setCurrentBranchId', branchId);
+export const loadFile = ({ dispatch, state }, { basePath }) => {
+ if (basePath) {
+ const path = basePath.slice(-1) === '/' ? basePath.slice(0, -1) : basePath;
+ const treeEntryKey = Object.keys(state.entries).find(
+ key => key === path && !state.entries[key].pending,
+ );
+ const treeEntry = state.entries[treeEntryKey];
- if (getters.emptyRepo) {
- return dispatch('showEmptyState', { projectId, branchId });
+ if (treeEntry) {
+ dispatch('handleTreeEntryAction', treeEntry);
+ } else {
+ dispatch('createTempEntry', {
+ name: path,
+ type: 'blob',
+ });
+ }
}
- return dispatch('getBranchData', {
+};
+
+export const loadBranch = ({ dispatch }, { projectId, branchId }) =>
+ dispatch('getBranchData', {
projectId,
branchId,
})
@@ -107,42 +121,38 @@ export const openBranch = ({ dispatch, state, getters }, { projectId, branchId,
projectId,
branchId,
});
- dispatch('getFiles', {
+ return dispatch('getFiles', {
projectId,
branchId,
- })
- .then(() => {
- if (basePath) {
- const path = basePath.slice(-1) === '/' ? basePath.slice(0, -1) : basePath;
- const treeEntryKey = Object.keys(state.entries).find(
- key => key === path && !state.entries[key].pending,
- );
- const treeEntry = state.entries[treeEntryKey];
-
- if (treeEntry) {
- dispatch('handleTreeEntryAction', treeEntry);
- } else {
- dispatch('createTempEntry', {
- name: path,
- type: 'blob',
- });
- }
- }
- })
- .catch(
- () =>
- new Error(
- sprintf(
- __('An error occurred whilst getting files for - %{branchId}'),
- {
- branchId: `<strong>${_.escape(projectId)}/${_.escape(branchId)}</strong>`,
- },
- false,
- ),
- ),
- );
+ });
})
.catch(() => {
dispatch('showBranchNotFoundError', branchId);
+ return Promise.reject();
});
+
+export const openBranch = ({ dispatch, state, getters }, { projectId, branchId, basePath }) => {
+ const currentProject = state.projects[projectId];
+ if (getters.emptyRepo) {
+ return dispatch('showEmptyState', { projectId, branchId });
+ }
+ if (!currentProject || !currentProject.branches[branchId]) {
+ dispatch('setCurrentBranchId', branchId);
+
+ return dispatch('loadBranch', { projectId, branchId })
+ .then(() => dispatch('loadFile', { basePath }))
+ .catch(
+ () =>
+ new Error(
+ sprintf(
+ __('An error occurred whilst getting files for - %{branchId}'),
+ {
+ branchId: `<strong>${_.escape(projectId)}/${_.escape(branchId)}</strong>`,
+ },
+ false,
+ ),
+ ),
+ );
+ }
+ return Promise.resolve(dispatch('loadFile', { basePath }));
};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
index 23caf2d48ed..e89ed49318b 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -152,6 +152,12 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
branch: getters.branchName,
})
.then(() => {
+ commit(rootTypes.CLEAR_STAGED_CHANGES, null, { root: true });
+
+ setTimeout(() => {
+ commit(rootTypes.SET_LAST_COMMIT_MSG, '', { root: true });
+ }, 5000);
+
if (state.shouldCreateMR) {
const { currentProject } = rootGetters;
const targetBranch = getters.isCreatingNewBranch
@@ -164,14 +170,6 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
{ root: true },
);
}
-
- commit(rootTypes.CLEAR_STAGED_CHANGES, null, { root: true });
-
- commit(rootTypes.CLEAR_REPLACED_FILES, null, { root: true });
-
- setTimeout(() => {
- commit(rootTypes.SET_LAST_COMMIT_MSG, '', { root: true });
- }, 5000);
})
.then(() => {
if (rootGetters.lastOpenedFile) {
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index f021729c451..f0b4718d025 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -59,8 +59,7 @@ export const UPDATE_DELAY_VIEWER_CHANGE = 'UPDATE_DELAY_VIEWER_CHANGE';
export const CLEAR_STAGED_CHANGES = 'CLEAR_STAGED_CHANGES';
export const STAGE_CHANGE = 'STAGE_CHANGE';
export const UNSTAGE_CHANGE = 'UNSTAGE_CHANGE';
-
-export const CLEAR_REPLACED_FILES = 'CLEAR_REPLACED_FILES';
+export const REMOVE_FILE_FROM_STAGED_AND_CHANGED = 'REMOVE_FILE_FROM_STAGED_AND_CHANGED';
export const UPDATE_FILE_AFTER_COMMIT = 'UPDATE_FILE_AFTER_COMMIT';
export const ADD_PENDING_TAB = 'ADD_PENDING_TAB';
@@ -79,5 +78,6 @@ export const SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE';
export const OPEN_NEW_ENTRY_MODAL = 'OPEN_NEW_ENTRY_MODAL';
export const DELETE_ENTRY = 'DELETE_ENTRY';
export const RENAME_ENTRY = 'RENAME_ENTRY';
+export const REVERT_RENAME_ENTRY = 'REVERT_RENAME_ENTRY';
export const RESTORE_TREE = 'RESTORE_TREE';
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
index ea125214ebb..e84e2782e46 100644
--- a/app/assets/javascripts/ide/stores/mutations.js
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -5,7 +5,14 @@ import mergeRequestMutation from './mutations/merge_request';
import fileMutations from './mutations/file';
import treeMutations from './mutations/tree';
import branchMutations from './mutations/branch';
-import { sortTree } from './utils';
+import {
+ sortTree,
+ replaceFileUrl,
+ swapInParentTreeWithSorting,
+ updateFileCollections,
+ removeFromParentTree,
+ pathsAreEqual,
+} from './utils';
export default {
[types.SET_INITIAL_DATA](state, data) {
@@ -56,11 +63,6 @@ export default {
stagedFiles: [],
});
},
- [types.CLEAR_REPLACED_FILES](state) {
- Object.assign(state, {
- replacedFiles: [],
- });
- },
[types.SET_ENTRIES](state, entries) {
Object.assign(state, {
entries,
@@ -71,16 +73,15 @@ export default {
const entry = data.entries[key];
const foundEntry = state.entries[key];
+ // NOTE: We can't clone `entry` in any of the below assignments because
+ // we need `state.entries` and the `entry.tree` to reference the same object.
if (!foundEntry) {
Object.assign(state.entries, {
[key]: entry,
});
} else if (foundEntry.deleted) {
Object.assign(state.entries, {
- [key]: {
- ...entry,
- replaces: true,
- },
+ [key]: Object.assign(entry, { replaces: true }),
});
} else {
const tree = entry.tree.filter(
@@ -157,9 +158,14 @@ export default {
changed: Boolean(changedFile),
staged: false,
replaces: false,
- prevPath: '',
- moved: false,
lastCommitSha: lastCommit.commit.id,
+
+ prevId: undefined,
+ prevPath: undefined,
+ prevName: undefined,
+ prevUrl: undefined,
+ prevKey: undefined,
+ prevParentPath: undefined,
});
if (prevPath) {
@@ -209,7 +215,9 @@ export default {
entry.deleted = true;
- parent.tree = parent.tree.filter(f => f.path !== entry.path);
+ if (parent) {
+ parent.tree = parent.tree.filter(f => f.path !== entry.path);
+ }
if (entry.type === 'blob') {
if (tempFile) {
@@ -219,51 +227,61 @@ export default {
}
}
},
- [types.RENAME_ENTRY](state, { path, name, entryPath = null, parentPath }) {
- const oldEntry = state.entries[entryPath || path];
- const slashedParentPath = parentPath ? `${parentPath}/` : '';
- const newPath = entryPath
- ? `${slashedParentPath}${oldEntry.name}`
- : `${slashedParentPath}${name}`;
+ [types.RENAME_ENTRY](state, { path, name, parentPath }) {
+ const oldEntry = state.entries[path];
+ const newPath = parentPath ? `${parentPath}/${name}` : name;
+ const isRevert = newPath === oldEntry.prevPath;
- Vue.set(state.entries, newPath, {
+ const newUrl = replaceFileUrl(oldEntry.url, oldEntry.path, newPath);
+
+ const newKey = oldEntry.key.replace(new RegExp(oldEntry.path, 'g'), newPath);
+
+ const baseProps = {
...oldEntry,
+ name,
id: newPath,
- key: `${newPath}-${oldEntry.type}-${oldEntry.path}`,
path: newPath,
- name: entryPath ? oldEntry.name : name,
- tempFile: true,
- prevPath: oldEntry.tempFile ? null : oldEntry.path,
- url: oldEntry.url.replace(new RegExp(`${oldEntry.path}/?$`), newPath),
- tree: [],
- raw: '',
- opened: false,
- parentPath,
- });
+ url: newUrl,
+ key: newKey,
+ parentPath: parentPath || '',
+ };
- oldEntry.moved = true;
- oldEntry.movedPath = newPath;
+ const prevProps =
+ oldEntry.tempFile || isRevert
+ ? {
+ prevId: undefined,
+ prevPath: undefined,
+ prevName: undefined,
+ prevUrl: undefined,
+ prevKey: undefined,
+ prevParentPath: undefined,
+ }
+ : {
+ prevId: oldEntry.prevId || oldEntry.id,
+ prevPath: oldEntry.prevPath || oldEntry.path,
+ prevName: oldEntry.prevName || oldEntry.name,
+ prevUrl: oldEntry.prevUrl || oldEntry.url,
+ prevKey: oldEntry.prevKey || oldEntry.key,
+ prevParentPath: oldEntry.prevParentPath || oldEntry.parentPath,
+ };
- const parent = parentPath
- ? state.entries[parentPath]
- : state.trees[`${state.currentProjectId}/${state.currentBranchId}`];
- const newEntry = state.entries[newPath];
-
- parent.tree = sortTree(parent.tree.concat(newEntry));
+ Vue.set(state.entries, newPath, {
+ ...baseProps,
+ ...prevProps,
+ });
- if (newEntry.type === 'blob') {
- state.changedFiles = state.changedFiles.concat(newEntry);
+ if (pathsAreEqual(oldEntry.parentPath, parentPath)) {
+ swapInParentTreeWithSorting(state, oldEntry.key, newPath, parentPath);
+ } else {
+ removeFromParentTree(state, oldEntry.key, oldEntry.parentPath);
+ swapInParentTreeWithSorting(state, oldEntry.key, newPath, parentPath);
}
- if (oldEntry.tempFile) {
- const filterMethod = f => f.path !== oldEntry.path;
-
- state.openFiles = state.openFiles.filter(filterMethod);
- state.changedFiles = state.changedFiles.filter(filterMethod);
- parent.tree = parent.tree.filter(filterMethod);
-
- Vue.delete(state.entries, oldEntry.path);
+ if (oldEntry.type === 'blob') {
+ updateFileCollections(state, oldEntry.key, newPath);
}
+
+ Vue.delete(state.entries, oldEntry.path);
},
...projectMutations,
diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js
index 1442ea7dbfa..8caeb2d73b2 100644
--- a/app/assets/javascripts/ide/stores/mutations/file.js
+++ b/app/assets/javascripts/ide/stores/mutations/file.js
@@ -138,8 +138,6 @@ export default {
content: stagedFile ? stagedFile.content : state.entries[path].raw,
changed: false,
deleted: false,
- moved: false,
- movedPath: '',
});
if (deleted) {
@@ -179,11 +177,6 @@ export default {
});
if (stagedFile) {
- Object.assign(state, {
- replacedFiles: state.replacedFiles.concat({
- ...stagedFile,
- }),
- });
Object.assign(stagedFile, {
...state.entries[path],
});
@@ -252,4 +245,15 @@ export default {
openFiles: state.openFiles.filter(f => f.key !== file.key),
});
},
+ [types.REMOVE_FILE_FROM_STAGED_AND_CHANGED](state, file) {
+ Object.assign(state, {
+ changedFiles: state.changedFiles.filter(f => f.key !== file.key),
+ stagedFiles: state.stagedFiles.filter(f => f.key !== file.key),
+ });
+
+ Object.assign(state.entries[file.path], {
+ changed: false,
+ staged: false,
+ });
+ },
};
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
index c4da482bf0a..d400b9831a9 100644
--- a/app/assets/javascripts/ide/stores/state.js
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -6,7 +6,6 @@ export default () => ({
currentMergeRequestId: '',
changedFiles: [],
stagedFiles: [],
- replacedFiles: [],
endpoints: {},
lastCommitMsg: '',
lastCommitPath: '',
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
index 52200ce7847..a8d8ff31afe 100644
--- a/app/assets/javascripts/ide/stores/utils.js
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -50,9 +50,7 @@ export const dataStructure = () => ({
lastOpenedAt: 0,
mrChange: null,
deleted: false,
- prevPath: '',
- movedPath: '',
- moved: false,
+ prevPath: undefined,
});
export const decorateData = entity => {
@@ -129,7 +127,7 @@ export const commitActionForFile = file => {
export const getCommitFiles = stagedFiles =>
stagedFiles.reduce((acc, file) => {
- if (file.moved || file.type === 'tree') return acc;
+ if (file.type === 'tree') return acc;
return acc.concat({
...file,
@@ -148,9 +146,9 @@ export const createCommitPayload = ({
commit_message: state.commitMessage || getters.preBuiltCommitMessage,
actions: getCommitFiles(rootState.stagedFiles).map(f => ({
action: commitActionForFile(f),
- file_path: f.moved ? f.movedPath : f.path,
- previous_path: f.prevPath === '' ? undefined : f.prevPath,
- content: f.prevPath ? null : f.content || undefined,
+ file_path: f.path,
+ previous_path: f.prevPath || undefined,
+ content: f.prevPath && !f.changed ? null : f.content || undefined,
encoding: f.base64 ? 'base64' : 'text',
last_commit_id:
newBranch || f.deleted || f.prevPath || f.replaces ? undefined : f.lastCommitSha,
@@ -213,3 +211,61 @@ export const mergeTrees = (fromTree, toTree) => {
return toTree;
};
+
+export const escapeFileUrl = fileUrl => encodeURIComponent(fileUrl).replace(/%2F/g, '/');
+
+export const replaceFileUrl = (url, oldPath, newPath) => {
+ // Add `/-/` so that we don't accidentally replace project path
+ const result = url.replace(`/-/${escapeFileUrl(oldPath)}`, `/-/${escapeFileUrl(newPath)}`);
+
+ return result;
+};
+
+export const swapInStateArray = (state, arr, key, entryPath) =>
+ Object.assign(state, {
+ [arr]: state[arr].map(f => (f.key === key ? state.entries[entryPath] : f)),
+ });
+
+export const getEntryOrRoot = (state, path) =>
+ path ? state.entries[path] : state.trees[`${state.currentProjectId}/${state.currentBranchId}`];
+
+export const swapInParentTreeWithSorting = (state, oldKey, newPath, parentPath) => {
+ if (!newPath) {
+ return;
+ }
+
+ const parent = getEntryOrRoot(state, parentPath);
+
+ if (parent) {
+ const tree = parent.tree
+ // filter out old entry && new entry
+ .filter(({ key, path }) => key !== oldKey && path !== newPath)
+ // concat new entry
+ .concat(state.entries[newPath]);
+
+ parent.tree = sortTree(tree);
+ }
+};
+
+export const removeFromParentTree = (state, oldKey, parentPath) => {
+ const parent = getEntryOrRoot(state, parentPath);
+
+ if (parent) {
+ parent.tree = sortTree(parent.tree.filter(({ key }) => key !== oldKey));
+ }
+};
+
+export const updateFileCollections = (state, key, entryPath) => {
+ ['openFiles', 'changedFiles', 'stagedFiles'].forEach(fileCollection => {
+ swapInStateArray(state, fileCollection, key, entryPath);
+ });
+};
+
+export const cleanTrailingSlash = path => path.replace(/\/$/, '');
+
+export const pathsAreEqual = (a, b) => {
+ const cleanA = a ? cleanTrailingSlash(a) : '';
+ const cleanB = b ? cleanTrailingSlash(b) : '';
+
+ return cleanA === cleanB;
+};
diff --git a/app/assets/javascripts/image_diff/helpers/badge_helper.js b/app/assets/javascripts/image_diff/helpers/badge_helper.js
index 000157efad0..7921650e8a0 100644
--- a/app/assets/javascripts/image_diff/helpers/badge_helper.js
+++ b/app/assets/javascripts/image_diff/helpers/badge_helper.js
@@ -1,3 +1,5 @@
+import { spriteIcon } from '~/lib/utils/common_utils';
+
export function createImageBadge(noteId, { x, y }, classNames = []) {
const buttonEl = document.createElement('button');
const classList = classNames.concat(['js-image-badge']);
@@ -20,7 +22,7 @@ export function addImageBadge(containerEl, { coordinate, badgeText, noteId }) {
export function addImageCommentBadge(containerEl, { coordinate, noteId }) {
const buttonEl = createImageBadge(noteId, coordinate, ['image-comment-badge']);
- buttonEl.innerHTML = gl.utils.spriteIcon('image-comment-dark');
+ buttonEl.innerHTML = spriteIcon('image-comment-dark');
containerEl.appendChild(buttonEl);
}
diff --git a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js
index 7051a968dac..df3d90cff68 100644
--- a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js
+++ b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js
@@ -1,3 +1,5 @@
+import { spriteIcon } from '~/lib/utils/common_utils';
+
export function addCommentIndicator(containerEl, { x, y }) {
const buttonEl = document.createElement('button');
buttonEl.classList.add('btn-transparent');
@@ -6,7 +8,7 @@ export function addCommentIndicator(containerEl, { x, y }) {
buttonEl.style.left = `${x}px`;
buttonEl.style.top = `${y}px`;
- buttonEl.innerHTML = gl.utils.spriteIcon('image-comment-dark');
+ buttonEl.innerHTML = spriteIcon('image-comment-dark');
containerEl.appendChild(buttonEl);
}
diff --git a/app/assets/javascripts/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_projects/components/import_projects_table.vue
index 00eb0afb3bf..e5ac3cbafe5 100644
--- a/app/assets/javascripts/import_projects/components/import_projects_table.vue
+++ b/app/assets/javascripts/import_projects/components/import_projects_table.vue
@@ -1,4 +1,5 @@
<script>
+import _ from 'underscore';
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
@@ -7,6 +8,8 @@ import ImportedProjectTableRow from './imported_project_table_row.vue';
import ProviderRepoTableRow from './provider_repo_table_row.vue';
import eventHub from '../event_hub';
+const reposFetchThrottleDelay = 1000;
+
export default {
name: 'ImportProjectsTable',
components: {
@@ -23,11 +26,11 @@ export default {
},
computed: {
- ...mapState(['importedProjects', 'providerRepos', 'isLoadingRepos']),
+ ...mapState(['importedProjects', 'providerRepos', 'isLoadingRepos', 'filter']),
...mapGetters(['isImportingAnyRepo', 'hasProviderRepos', 'hasImportedProjects']),
emptyStateText() {
- return sprintf(__('No %{providerTitle} repositories available to import'), {
+ return sprintf(__('No %{providerTitle} repositories found'), {
providerTitle: this.providerTitle,
});
},
@@ -47,21 +50,38 @@ export default {
},
methods: {
- ...mapActions(['fetchRepos', 'fetchJobs', 'stopJobsPolling', 'clearJobsEtagPoll']),
+ ...mapActions([
+ 'fetchRepos',
+ 'fetchReposFiltered',
+ 'fetchJobs',
+ 'stopJobsPolling',
+ 'clearJobsEtagPoll',
+ 'setFilter',
+ ]),
importAll() {
eventHub.$emit('importAll');
},
+
+ handleFilterInput({ target }) {
+ this.setFilter(target.value);
+ },
+
+ throttledFetchRepos: _.throttle(function fetch() {
+ eventHub.$off('importAll');
+ this.fetchRepos();
+ }, reposFetchThrottleDelay),
},
};
</script>
<template>
<div>
+ <p class="light text-nowrap mt-2">
+ {{ s__('ImportProjects|Select the projects you want to import') }}
+ </p>
+
<div class="d-flex justify-content-between align-items-end flex-wrap mb-3">
- <p class="light text-nowrap mt-2 my-sm-0">
- {{ s__('ImportProjects|Select the projects you want to import') }}
- </p>
<loading-button
container-class="btn btn-success js-import-all"
:loading="isImportingAnyRepo"
@@ -70,6 +90,19 @@ export default {
type="button"
@click="importAll"
/>
+ <form novalidate @submit.prevent>
+ <input
+ :value="filter"
+ data-qa-selector="githubish_import_filter_field"
+ class="form-control"
+ name="filter"
+ :placeholder="__('Filter your projects by name')"
+ autofocus
+ size="40"
+ @input="handleFilterInput($event)"
+ @keyup.enter="throttledFetchRepos"
+ />
+ </form>
</div>
<gl-loading-icon
v-if="isLoadingRepos"
diff --git a/app/assets/javascripts/import_projects/index.js b/app/assets/javascripts/import_projects/index.js
index 2d99d716609..b069dcb7766 100644
--- a/app/assets/javascripts/import_projects/index.js
+++ b/app/assets/javascripts/import_projects/index.js
@@ -38,7 +38,7 @@ export default function mountImportProjectsTable(mountElement) {
},
methods: {
- ...mapActions(['setInitialData']),
+ ...mapActions(['setInitialData', 'setFilter']),
},
render(createElement) {
diff --git a/app/assets/javascripts/import_projects/store/actions.js b/app/assets/javascripts/import_projects/store/actions.js
index c44500937cc..0fb9a4cdfd4 100644
--- a/app/assets/javascripts/import_projects/store/actions.js
+++ b/app/assets/javascripts/import_projects/store/actions.js
@@ -5,6 +5,7 @@ import Poll from '~/lib/utils/poll';
import createFlash from '~/flash';
import { s__, sprintf } from '~/locale';
import axios from '~/lib/utils/axios_utils';
+import { jobsPathWithFilter, reposPathWithFilter } from './getters';
let eTagPoll;
@@ -19,16 +20,20 @@ export const restartJobsPolling = () => {
};
export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
+export const setFilter = ({ commit }, filter) => commit(types.SET_FILTER, filter);
export const requestRepos = ({ commit }, repos) => commit(types.REQUEST_REPOS, repos);
export const receiveReposSuccess = ({ commit }, repos) =>
commit(types.RECEIVE_REPOS_SUCCESS, repos);
export const receiveReposError = ({ commit }) => commit(types.RECEIVE_REPOS_ERROR);
export const fetchRepos = ({ state, dispatch }) => {
+ dispatch('stopJobsPolling');
dispatch('requestRepos');
+ const { provider } = state;
+
return axios
- .get(state.reposPath)
+ .get(reposPathWithFilter(state))
.then(({ data }) =>
dispatch('receiveReposSuccess', convertObjectPropsToCamelCase(data, { deep: true })),
)
@@ -36,7 +41,7 @@ export const fetchRepos = ({ state, dispatch }) => {
.catch(() => {
createFlash(
sprintf(s__('ImportProjects|Requesting your %{provider} repositories failed'), {
- provider: state.provider,
+ provider,
}),
);
@@ -77,16 +82,23 @@ export const fetchImport = ({ state, dispatch }, { newName, targetNamespace, rep
export const receiveJobsSuccess = ({ commit }, updatedProjects) =>
commit(types.RECEIVE_JOBS_SUCCESS, updatedProjects);
export const fetchJobs = ({ state, dispatch }) => {
- if (eTagPoll) return;
+ const { filter } = state;
+
+ if (eTagPoll) {
+ stopJobsPolling();
+ clearJobsEtagPoll();
+ }
eTagPoll = new Poll({
resource: {
- fetchJobs: () => axios.get(state.jobsPath),
+ fetchJobs: () => axios.get(jobsPathWithFilter(state)),
},
method: 'fetchJobs',
successCallback: ({ data }) =>
dispatch('receiveJobsSuccess', convertObjectPropsToCamelCase(data, { deep: true })),
- errorCallback: () => createFlash(s__('ImportProjects|Updating the imported projects failed')),
+ errorCallback: () =>
+ createFlash(s__('ImportProjects|Update of imported projects with realtime changes failed')),
+ data: { filter },
});
if (!Visibility.hidden()) {
diff --git a/app/assets/javascripts/import_projects/store/getters.js b/app/assets/javascripts/import_projects/store/getters.js
index 727b80765bd..b107c293181 100644
--- a/app/assets/javascripts/import_projects/store/getters.js
+++ b/app/assets/javascripts/import_projects/store/getters.js
@@ -20,3 +20,8 @@ export const isImportingAnyRepo = state => state.reposBeingImported.length > 0;
export const hasProviderRepos = state => state.providerRepos.length > 0;
export const hasImportedProjects = state => state.importedProjects.length > 0;
+
+export const reposPathWithFilter = ({ reposPath, filter = '' }) =>
+ filter ? `${reposPath}?filter=${filter}` : reposPath;
+export const jobsPathWithFilter = ({ jobsPath, filter = '' }) =>
+ filter ? `${jobsPath}?filter=${filter}` : jobsPath;
diff --git a/app/assets/javascripts/import_projects/store/mutation_types.js b/app/assets/javascripts/import_projects/store/mutation_types.js
index 6ba3fd6f29e..16574f4450f 100644
--- a/app/assets/javascripts/import_projects/store/mutation_types.js
+++ b/app/assets/javascripts/import_projects/store/mutation_types.js
@@ -9,3 +9,5 @@ export const RECEIVE_IMPORT_SUCCESS = 'RECEIVE_IMPORT_SUCCESS';
export const RECEIVE_IMPORT_ERROR = 'RECEIVE_IMPORT_ERROR';
export const RECEIVE_JOBS_SUCCESS = 'RECEIVE_JOBS_SUCCESS';
+
+export const SET_FILTER = 'SET_FILTER';
diff --git a/app/assets/javascripts/import_projects/store/mutations.js b/app/assets/javascripts/import_projects/store/mutations.js
index b88de0268e7..6c56cfa8298 100644
--- a/app/assets/javascripts/import_projects/store/mutations.js
+++ b/app/assets/javascripts/import_projects/store/mutations.js
@@ -6,6 +6,10 @@ export default {
Object.assign(state, data);
},
+ [types.SET_FILTER](state, filter) {
+ state.filter = filter;
+ },
+
[types.REQUEST_REPOS](state) {
state.isLoadingRepos = true;
},
diff --git a/app/assets/javascripts/import_projects/store/state.js b/app/assets/javascripts/import_projects/store/state.js
index 637fef6e53c..829f3aa4fbb 100644
--- a/app/assets/javascripts/import_projects/store/state.js
+++ b/app/assets/javascripts/import_projects/store/state.js
@@ -12,4 +12,5 @@ export default () => ({
isLoadingRepos: false,
canSelectNamespace: false,
ciCdOnly: false,
+ filter: '',
});
diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js
index a7746bb3a0b..1c9b94ade8a 100644
--- a/app/assets/javascripts/integrations/integration_settings_form.js
+++ b/app/assets/javascripts/integrations/integration_settings_form.js
@@ -42,6 +42,7 @@ export default class IntegrationSettingsForm {
// and test the service using provided configuration.
if (this.$form.get(0).checkValidity() && this.canTestService) {
e.preventDefault();
+ // eslint-disable-next-line no-jquery/no-serialize
this.testSettings(this.$form.serialize());
}
}
diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js
index c855f3973b0..45de287d44d 100644
--- a/app/assets/javascripts/issuable_bulk_update_actions.js
+++ b/app/assets/javascripts/issuable_bulk_update_actions.js
@@ -1,4 +1,4 @@
-/* eslint-disable consistent-return, func-names, array-callback-return, prefer-arrow-callback */
+/* eslint-disable consistent-return, func-names, array-callback-return */
import $ from 'jquery';
import _ from 'underscore';
@@ -45,7 +45,7 @@ export default {
this.getSelectedIssues().map(function() {
const labelsData = $(this).data('labels');
if (labelsData) {
- return labelsData.map(function(labelId) {
+ return labelsData.map(labelId => {
if (labels.indexOf(labelId) === -1) {
return labels.push(labelId);
}
diff --git a/app/assets/javascripts/issuable_sidebar/components/sidebar_app.vue b/app/assets/javascripts/issuable_sidebar/components/sidebar_app.vue
new file mode 100644
index 00000000000..06c50f62aab
--- /dev/null
+++ b/app/assets/javascripts/issuable_sidebar/components/sidebar_app.vue
@@ -0,0 +1,23 @@
+<script>
+export default {
+ props: {
+ signedIn: {
+ type: Boolean,
+ required: true,
+ },
+ sidebarStatusClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+};
+</script>
+
+<template>
+ <aside
+ :class="sidebarStatusClass"
+ class="right-sidebar js-right-sidebar js-issuable-sidebar"
+ aria-live="polite"
+ ></aside>
+</template>
diff --git a/app/assets/javascripts/issuable_sidebar/sidebar_bundle.js b/app/assets/javascripts/issuable_sidebar/sidebar_bundle.js
new file mode 100644
index 00000000000..c8acafa8cd8
--- /dev/null
+++ b/app/assets/javascripts/issuable_sidebar/sidebar_bundle.js
@@ -0,0 +1,27 @@
+import Vue from 'vue';
+
+import SidebarApp from './components/sidebar_app.vue';
+
+export default () => {
+ const el = document.getElementById('js-vue-issuable-sidebar');
+
+ if (!el) {
+ return false;
+ }
+
+ const { sidebarStatusClass } = el.dataset;
+ // An empty string is present when user is signed in.
+ const signedIn = el.dataset.signedIn === '';
+
+ return new Vue({
+ el,
+ components: { SidebarApp },
+ render: createElement =>
+ createElement('sidebar-app', {
+ props: {
+ signedIn,
+ sidebarStatusClass,
+ },
+ }),
+ });
+};
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index 88975c2cc73..b8b3a4f44fd 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -102,10 +102,10 @@ export default {
required: false,
default: '',
},
- issuableTemplates: {
- type: Array,
+ issuableTemplateNamesPath: {
+ type: String,
required: false,
- default: () => [],
+ default: '',
},
markdownPreviewPath: {
type: String,
@@ -156,9 +156,13 @@ export default {
store,
state: store.state,
showForm: false,
+ templatesRequested: false,
};
},
computed: {
+ issuableTemplates() {
+ return this.store.formState.issuableTemplates;
+ },
formState() {
return this.store.formState;
},
@@ -233,6 +237,7 @@ export default {
}
return undefined;
},
+
updateStoreState() {
return this.service
.getData()
@@ -245,7 +250,7 @@ export default {
});
},
- openForm() {
+ updateAndShowForm(templates = []) {
if (!this.showForm) {
this.showForm = true;
this.store.setFormState({
@@ -254,9 +259,32 @@ export default {
lock_version: this.state.lock_version,
lockedWarningVisible: false,
updateLoading: false,
+ issuableTemplates: templates,
+ });
+ }
+ },
+
+ requestTemplatesAndShowForm() {
+ return this.service
+ .loadTemplates(this.issuableTemplateNamesPath)
+ .then(res => {
+ this.updateAndShowForm(res.data);
+ })
+ .catch(() => {
+ createFlash(this.defaultErrorMessage);
+ this.updateAndShowForm();
});
+ },
+
+ openForm() {
+ if (!this.templatesRequested) {
+ this.templatesRequested = true;
+ this.requestTemplatesAndShowForm();
+ } else {
+ this.updateAndShowForm(this.issuableTemplates);
}
},
+
closeForm() {
this.showForm = false;
},
diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js
index 5a9dd91817e..e170d338408 100644
--- a/app/assets/javascripts/issue_show/index.js
+++ b/app/assets/javascripts/issue_show/index.js
@@ -1,8 +1,6 @@
import Vue from 'vue';
-import { initSidebarTracking } from 'ee_else_ce/event_tracking/issue_sidebar';
import issuableApp from './components/app.vue';
import { parseIssuableData } from './utils/parse_data';
-import '../vue_shared/vue_resource_interceptor';
export default function initIssueableApp() {
return new Vue({
@@ -10,9 +8,6 @@ export default function initIssueableApp() {
components: {
issuableApp,
},
- mounted() {
- initSidebarTracking();
- },
render(createElement) {
return createElement('issuable-app', {
props: parseIssuableData(),
diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issue_show/services/index.js
index 3c8334bee50..b1deeaae0fc 100644
--- a/app/assets/javascripts/issue_show/services/index.js
+++ b/app/assets/javascripts/issue_show/services/index.js
@@ -17,4 +17,13 @@ export default class Service {
updateIssuable(data) {
return axios.put(this.endpoint, data);
}
+
+ // eslint-disable-next-line class-methods-use-this
+ loadTemplates(templateNamesEndpoint) {
+ if (!templateNamesEndpoint) {
+ return Promise.resolve([]);
+ }
+
+ return axios.get(templateNamesEndpoint);
+ }
}
diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js
index 3c17e73ccec..688ba7b268d 100644
--- a/app/assets/javascripts/issue_show/stores/index.js
+++ b/app/assets/javascripts/issue_show/stores/index.js
@@ -1,4 +1,6 @@
+import _ from 'underscore';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import updateDescription from '../utils/update_description';
export default class Store {
constructor(initialState) {
@@ -9,6 +11,7 @@ export default class Store {
lockedWarningVisible: false,
updateLoading: false,
lock_version: 0,
+ issuableTemplates: [],
};
}
@@ -18,8 +21,15 @@ export default class Store {
}
Object.assign(this.state, convertObjectPropsToCamelCase(data));
+ // find if there is an open details node inside of the issue description.
+ const descriptionSection = document.body.querySelector(
+ '.detail-page-description.content-block',
+ );
+ const details =
+ !_.isNull(descriptionSection) && descriptionSection.getElementsByTagName('details');
+
+ this.state.descriptionHtml = updateDescription(data.description, details);
this.state.titleHtml = data.title;
- this.state.descriptionHtml = data.description;
this.state.lock_version = data.lock_version;
}
diff --git a/app/assets/javascripts/issue_show/utils/update_description.js b/app/assets/javascripts/issue_show/utils/update_description.js
new file mode 100644
index 00000000000..315f6c23b02
--- /dev/null
+++ b/app/assets/javascripts/issue_show/utils/update_description.js
@@ -0,0 +1,38 @@
+import _ from 'underscore';
+
+/**
+ * Function that replaces the open attribute for the <details> element.
+ *
+ * @param {String} descriptionHtml - The html string passed back from the server as a result of polling
+ * @param {Array} details - All detail nodes inside of the issue description.
+ */
+
+const updateDescription = (descriptionHtml = '', details) => {
+ let detailNodes = details;
+
+ if (_.isEmpty(details)) {
+ detailNodes = [];
+ }
+
+ const placeholder = document.createElement('div');
+ placeholder.innerHTML = descriptionHtml;
+
+ const newDetails = placeholder.getElementsByTagName('details');
+
+ if (newDetails.length !== detailNodes.length) {
+ return descriptionHtml;
+ }
+
+ Array.from(newDetails).forEach((el, i) => {
+ /*
+ * <details> has an open attribute that can have a value, "", "true", "false"
+ * and will show the dropdown, which is why we are setting the attribute
+ * explicitly to true.
+ */
+ if (detailNodes[i].open) el.setAttribute('open', true);
+ });
+
+ return placeholder.innerHTML;
+};
+
+export default updateDescription;
diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue
index 9fac880c5f8..8156f26ffb1 100644
--- a/app/assets/javascripts/jobs/components/commit_block.vue
+++ b/app/assets/javascripts/jobs/components/commit_block.vue
@@ -41,7 +41,7 @@ export default {
<clipboard-button
:text="commit.id"
- :title="__('Copy commit SHA to clipboard')"
+ :title="__('Copy commit SHA')"
css-class="btn btn-clipboard btn-transparent"
/>
diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/environments_block.vue
index 8cda7dac51f..163849d3c40 100644
--- a/app/assets/javascripts/jobs/components/environments_block.vue
+++ b/app/assets/javascripts/jobs/components/environments_block.vue
@@ -19,69 +19,18 @@ export default {
},
computed: {
environment() {
- let environmentText;
switch (this.deploymentStatus.status) {
case 'last':
- environmentText = sprintf(
- __('This job is the most recent deployment to %{link}.'),
- { link: this.environmentLink },
- false,
- );
- break;
+ return this.lastEnvironmentMessage();
case 'out_of_date':
- if (this.hasLastDeployment) {
- environmentText = sprintf(
- __(
- 'This job is an out-of-date deployment to %{environmentLink}. View the most recent deployment %{deploymentLink}.',
- ),
- {
- environmentLink: this.environmentLink,
- deploymentLink: this.deploymentLink(`#${this.lastDeployment.iid}`),
- },
- false,
- );
- } else {
- environmentText = sprintf(
- __('This job is an out-of-date deployment to %{environmentLink}.'),
- { environmentLink: this.environmentLink },
- false,
- );
- }
-
- break;
+ return this.outOfDateEnvironmentMessage();
case 'failed':
- environmentText = sprintf(
- __('The deployment of this job to %{environmentLink} did not succeed.'),
- { environmentLink: this.environmentLink },
- false,
- );
- break;
+ return this.failedEnvironmentMessage();
case 'creating':
- if (this.hasLastDeployment) {
- environmentText = sprintf(
- __(
- 'This job is creating a deployment to %{environmentLink} and will overwrite the %{deploymentLink}.',
- ),
- {
- environmentLink: this.environmentLink,
- deploymentLink: this.deploymentLink(__('latest deployment')),
- },
- false,
- );
- } else {
- environmentText = sprintf(
- __('This job is creating a deployment to %{environmentLink}.'),
- { environmentLink: this.environmentLink },
- false,
- );
- }
- break;
+ return this.creatingEnvironmentMessage();
default:
- break;
+ return '';
}
- return environmentText && this.hasCluster
- ? `${environmentText} ${this.clusterText}`
- : environmentText;
},
environmentLink() {
if (this.hasEnvironment) {
@@ -137,11 +86,6 @@ export default {
false,
);
},
- clusterText() {
- return this.hasCluster
- ? sprintf(__('Cluster %{cluster} was used.'), { cluster: this.clusterNameOrLink }, false)
- : '';
- },
},
methods: {
deploymentLink(name) {
@@ -155,6 +99,91 @@ export default {
false,
);
},
+ failedEnvironmentMessage() {
+ const { environmentLink } = this;
+
+ return sprintf(
+ __('The deployment of this job to %{environmentLink} did not succeed.'),
+ { environmentLink },
+ false,
+ );
+ },
+ lastEnvironmentMessage() {
+ const { environmentLink, clusterNameOrLink, hasCluster } = this;
+
+ const message = hasCluster
+ ? __('This job is deployed to %{environmentLink} using cluster %{clusterNameOrLink}.')
+ : __('This job is deployed to %{environmentLink}.');
+
+ return sprintf(message, { environmentLink, clusterNameOrLink }, false);
+ },
+ outOfDateEnvironmentMessage() {
+ const { hasLastDeployment, hasCluster, environmentLink, clusterNameOrLink } = this;
+
+ if (hasLastDeployment) {
+ const message = hasCluster
+ ? __(
+ 'This job is an out-of-date deployment to %{environmentLink} using cluster %{clusterNameOrLink}. View the %{deploymentLink}.',
+ )
+ : __(
+ 'This job is an out-of-date deployment to %{environmentLink}. View the %{deploymentLink}.',
+ );
+
+ return sprintf(
+ message,
+ {
+ environmentLink,
+ clusterNameOrLink,
+ deploymentLink: this.deploymentLink(__('most recent deployment')),
+ },
+ false,
+ );
+ }
+
+ const message = hasCluster
+ ? __(
+ 'This job is an out-of-date deployment to %{environmentLink} using cluster %{clusterNameOrLink}.',
+ )
+ : __('This job is an out-of-date deployment to %{environmentLink}.');
+
+ return sprintf(
+ message,
+ {
+ environmentLink,
+ clusterNameOrLink,
+ },
+ false,
+ );
+ },
+ creatingEnvironmentMessage() {
+ const { hasLastDeployment, hasCluster, environmentLink, clusterNameOrLink } = this;
+
+ if (hasLastDeployment) {
+ const message = hasCluster
+ ? __(
+ 'This job is creating a deployment to %{environmentLink} using cluster %{clusterNameOrLink}. This will overwrite the %{deploymentLink}.',
+ )
+ : __(
+ 'This job is creating a deployment to %{environmentLink}. This will overwrite the %{deploymentLink}.',
+ );
+
+ return sprintf(
+ message,
+ {
+ environmentLink,
+ clusterNameOrLink,
+ deploymentLink: this.deploymentLink(__('latest deployment')),
+ },
+ false,
+ );
+ }
+
+ return sprintf(
+ __('This job is creating a deployment to %{environmentLink}.'),
+ { environmentLink },
+ false,
+ );
+ },
},
};
</script>
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue
index 36701a95673..859f839741f 100644
--- a/app/assets/javascripts/jobs/components/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job_app.vue
@@ -30,7 +30,7 @@ export default {
EnvironmentsBlock,
ErasedBlock,
Icon,
- Log: () => (isNewJobLogActive() ? import('./job_log_json.vue') : import('./job_log.vue')),
+ Log: () => (isNewJobLogActive() ? import('./log/log.vue') : import('./job_log.vue')),
LogTopBar,
StuckBlock,
UnmetPrerequisitesBlock,
@@ -130,6 +130,10 @@ export default {
return title;
},
+
+ shouldRenderHeaderCallout() {
+ return this.shouldRenderCalloutMessage && !this.hasUnmetPrerequisitesFailure;
+ },
},
watch: {
// Once the job log is loaded,
@@ -239,10 +243,9 @@ export default {
/>
</div>
- <callout
- v-if="shouldRenderCalloutMessage && !hasUnmetPrerequisitesFailure"
- :message="job.callout_message"
- />
+ <callout v-if="shouldRenderHeaderCallout">
+ <div v-html="job.callout_message"></div>
+ </callout>
</header>
<!-- EO Header Section -->
diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job_container_item.vue
index a55dffbe488..7bd299bcfa0 100644
--- a/app/assets/javascripts/jobs/components/job_container_item.vue
+++ b/app/assets/javascripts/jobs/components/job_container_item.vue
@@ -54,7 +54,7 @@ export default {
:href="job.status.details_path"
:title="tooltipText"
data-boundary="viewport"
- class="js-job-link"
+ class="js-job-link d-flex"
>
<icon
v-if="isActive"
@@ -64,7 +64,7 @@ export default {
<ci-icon :status="job.status" />
- <span>{{ job.name ? job.name : job.id }}</span>
+ <span class="text-truncate w-100">{{ job.name ? job.name : job.id }}</span>
<icon v-if="job.retried" name="retry" class="js-retry-icon" />
</gl-link>
diff --git a/app/assets/javascripts/jobs/components/job_log.vue b/app/assets/javascripts/jobs/components/job_log.vue
index a3fbe9338ee..20888c0af42 100644
--- a/app/assets/javascripts/jobs/components/job_log.vue
+++ b/app/assets/javascripts/jobs/components/job_log.vue
@@ -19,18 +19,13 @@ export default {
updated() {
this.$nextTick(() => {
this.handleScrollDown();
- this.handleCollapsibleRows();
});
},
mounted() {
this.$nextTick(() => {
this.handleScrollDown();
- this.handleCollapsibleRows();
});
},
- destroyed() {
- this.removeEventListener();
- },
methods: {
...mapActions(['scrollBottom']),
/**
@@ -47,53 +42,6 @@ export default {
}, 0);
}
},
- removeEventListener() {
- this.$el.querySelectorAll('.js-section-start').forEach(el => {
- const titleSection = el.nextSibling;
- titleSection.removeEventListener(
- 'click',
- this.handleHeaderClick.bind(this, el, el.dataset.section),
- );
- el.removeEventListener('click', this.handleSectionClick);
- });
- },
- /**
- * The collapsible rows are sent in HTML from the backend
- * We need tos add a onclick handler for the divs that match `.js-section-start`
- *
- */
- handleCollapsibleRows() {
- this.$el.querySelectorAll('.js-section-start').forEach(el => {
- const titleSection = el.nextSibling;
- titleSection.addEventListener(
- 'click',
- this.handleHeaderClick.bind(this, el, el.dataset.section),
- );
- el.addEventListener('click', this.handleSectionClick);
- });
- },
-
- handleHeaderClick(arrowElement, section) {
- this.updateToggleSection(arrowElement, section);
- },
-
- updateToggleSection(arrow, section) {
- // toggle the arrow class
- arrow.classList.toggle('fa-caret-right');
- arrow.classList.toggle('fa-caret-down');
-
- // hide the sections
- const sibilings = this.$el.querySelectorAll(`.js-s-${section}:not(.js-section-header)`);
- sibilings.forEach(row => row.classList.toggle('hidden'));
- },
- /**
- * On click, we toggle the hidden class of
- * all the rows that match the `data-section` selector
- */
- handleSectionClick(evt) {
- const clickedArrow = evt.currentTarget;
- this.updateToggleSection(clickedArrow, clickedArrow.dataset.section);
- },
},
};
</script>
diff --git a/app/assets/javascripts/jobs/components/job_log_json.vue b/app/assets/javascripts/jobs/components/job_log_json.vue
deleted file mode 100644
index 2198b20eb8f..00000000000
--- a/app/assets/javascripts/jobs/components/job_log_json.vue
+++ /dev/null
@@ -1,10 +0,0 @@
-<script>
-export default {
- name: 'JobLogJSON',
-};
-</script>
-<template>
- <pre>
- {{ __('This feature is in development. Please disable the `job_log_json` feature flag') }}
- </pre>
-</template>
diff --git a/app/assets/javascripts/jobs/components/log/collapsible_section.vue b/app/assets/javascripts/jobs/components/log/collapsible_section.vue
new file mode 100644
index 00000000000..0c7b78a3da7
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/log/collapsible_section.vue
@@ -0,0 +1,51 @@
+<script>
+import LogLine from './line.vue';
+import LogLineHeader from './line_header.vue';
+
+export default {
+ name: 'CollpasibleLogSection',
+ components: {
+ LogLine,
+ LogLineHeader,
+ },
+ props: {
+ section: {
+ type: Object,
+ required: true,
+ },
+ traceEndpoint: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ badgeDuration() {
+ return this.section.line && this.section.line.section_duration;
+ },
+ },
+ methods: {
+ handleOnClickCollapsibleLine(section) {
+ this.$emit('onClickCollapsibleLine', section);
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <log-line-header
+ :line="section.line"
+ :duration="badgeDuration"
+ :path="traceEndpoint"
+ :is-closed="section.isClosed"
+ @toggleLine="handleOnClickCollapsibleLine(section)"
+ />
+ <template v-if="!section.isClosed">
+ <log-line
+ v-for="line in section.lines"
+ :key="line.offset"
+ :line="line"
+ :path="traceEndpoint"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/components/log/duration_badge.vue b/app/assets/javascripts/jobs/components/log/duration_badge.vue
index 31a101d2c95..8e5dcdcc902 100644
--- a/app/assets/javascripts/jobs/components/log/duration_badge.vue
+++ b/app/assets/javascripts/jobs/components/log/duration_badge.vue
@@ -9,7 +9,7 @@ export default {
};
</script>
<template>
- <div class="log-duration-badge rounded align-self-start px-2 ml-2 flex-shrink-0">
+ <div class="log-duration-badge rounded align-self-start px-2 ml-2 flex-shrink-0 ws-normal">
{{ duration }}
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/log/line.vue b/app/assets/javascripts/jobs/components/log/line.vue
index 4e09c85b25a..33ee84bd4ee 100644
--- a/app/assets/javascripts/jobs/components/log/line.vue
+++ b/app/assets/javascripts/jobs/components/log/line.vue
@@ -19,10 +19,14 @@ export default {
</script>
<template>
- <div class="log-line">
+ <div class="js-line log-line">
<line-number :line-number="line.lineNumber" :path="path" />
- <span v-for="(content, i) in line.content" :key="i" :class="content.style">{{
- content.text
- }}</span>
+ <span
+ v-for="(content, i) in line.content"
+ :key="i"
+ :class="content.style"
+ class="ws-pre-wrap"
+ >{{ content.text }}</span
+ >
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/log/line_header.vue b/app/assets/javascripts/jobs/components/log/line_header.vue
index 92cf3b3cf5f..85ccd5996b5 100644
--- a/app/assets/javascripts/jobs/components/log/line_header.vue
+++ b/app/assets/javascripts/jobs/components/log/line_header.vue
@@ -43,15 +43,19 @@ export default {
<template>
<div
- class="log-line collapsible-line d-flex justify-content-between"
+ class="log-line collapsible-line d-flex justify-content-between ws-normal"
role="button"
@click="handleOnClick"
>
<icon :name="iconName" class="arrow position-absolute" />
<line-number :line-number="line.lineNumber" :path="path" />
- <span v-for="(content, i) in line.content" :key="i" class="line-text" :class="content.style">{{
- content.text
- }}</span>
+ <span
+ v-for="(content, i) in line.content"
+ :key="i"
+ class="line-text w-100 ws-pre-wrap"
+ :class="content.style"
+ >{{ content.text }}</span
+ >
<duration-badge v-if="duration" :duration="duration" />
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/log/line_number.vue b/app/assets/javascripts/jobs/components/log/line_number.vue
index 6c76bef13d3..ae96c32874b 100644
--- a/app/assets/javascripts/jobs/components/log/line_number.vue
+++ b/app/assets/javascripts/jobs/components/log/line_number.vue
@@ -48,7 +48,7 @@ export default {
<template>
<gl-link
:id="lineNumberId"
- class="d-inline-block text-right position-absolute line-number"
+ class="d-inline-block text-right line-number flex-shrink-0"
:href="buildLineNumber"
>{{ parsedLineNumber }}</gl-link
>
diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue
index 429796aeb4e..ef126166e8b 100644
--- a/app/assets/javascripts/jobs/components/log/log.vue
+++ b/app/assets/javascripts/jobs/components/log/log.vue
@@ -1,12 +1,12 @@
<script>
import { mapState, mapActions } from 'vuex';
+import CollpasibleLogSection from './collapsible_section.vue';
import LogLine from './line.vue';
-import LogLineHeader from './line_header.vue';
export default {
components: {
+ CollpasibleLogSection,
LogLine,
- LogLineHeader,
},
computed: {
...mapState(['traceEndpoint', 'trace', 'isTraceComplete']),
@@ -22,24 +22,13 @@ export default {
<template>
<code class="job-log d-block">
<template v-for="(section, index) in trace">
- <template v-if="section.isHeader">
- <log-line-header
- :key="`collapsible-${index}`"
- :line="section.line"
- :duration="section.section_duration"
- :path="traceEndpoint"
- :is-closed="section.isClosed"
- @toggleLine="handleOnClickCollapsibleLine(section)"
- />
- <template v-if="!section.isClosed">
- <log-line
- v-for="line in section.lines"
- :key="line.offset"
- :line="line"
- :path="traceEndpoint"
- />
- </template>
- </template>
+ <collpasible-log-section
+ v-if="section.isHeader"
+ :key="`collapsible-${index}`"
+ :section="section"
+ :trace-endpoint="traceEndpoint"
+ @onClickCollapsibleLine="handleOnClickCollapsibleLine"
+ />
<log-line v-else :key="section.offset" :line="section" :path="traceEndpoint" />
</template>
diff --git a/app/assets/javascripts/jobs/store/mutations.js b/app/assets/javascripts/jobs/store/mutations.js
index 540c3e2ad69..77c68cac4a6 100644
--- a/app/assets/javascripts/jobs/store/mutations.js
+++ b/app/assets/javascripts/jobs/store/mutations.js
@@ -19,15 +19,14 @@ export default {
state.isSidebarOpen = true;
},
- [types.RECEIVE_TRACE_SUCCESS](state, log) {
+ [types.RECEIVE_TRACE_SUCCESS](state, log = {}) {
if (log.state) {
state.traceState = log.state;
}
if (log.append) {
if (isNewJobLogActive()) {
- state.originalTrace = state.originalTrace.concat(log.trace);
- state.trace = updateIncrementalTrace(state.originalTrace, state.trace, log.lines);
+ state.trace = log.lines ? updateIncrementalTrace(log.lines, state.trace) : state.trace;
} else {
state.trace += log.html;
}
@@ -36,10 +35,9 @@ export default {
// When the job still does not have a trace
// the trace response will not have a defined
// html or size. We keep the old value otherwise these
- // will be set to `undefined`
+ // will be set to `null`
if (isNewJobLogActive()) {
- state.originalTrace = log.lines || state.trace;
- state.trace = logLinesParser(log.lines) || state.trace;
+ state.trace = log.lines ? logLinesParser(log.lines) : state.trace;
} else {
state.trace = log.html || state.trace;
}
diff --git a/app/assets/javascripts/jobs/store/state.js b/app/assets/javascripts/jobs/store/state.js
index 585878f8240..cdc1780f3d6 100644
--- a/app/assets/javascripts/jobs/store/state.js
+++ b/app/assets/javascripts/jobs/store/state.js
@@ -19,7 +19,6 @@ export default () => ({
isScrolledToBottomBeforeReceivingTrace: true,
trace: isNewJobLogActive() ? [] : '',
- originalTrace: [],
isTraceComplete: false,
traceSize: 0,
isTraceSizeVisible: false,
diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js
index 261ec90cd12..58e49f54d96 100644
--- a/app/assets/javascripts/jobs/store/utils.js
+++ b/app/assets/javascripts/jobs/store/utils.js
@@ -9,6 +9,85 @@ export const parseLine = (line = {}, lineNumber) => ({
});
/**
+ * When a line has `section_header` set to true, we create a new
+ * structure to allow to nest the lines that belong to the
+ * collpasible section
+ *
+ * @param Object line
+ * @param Number lineNumber
+ */
+export const parseHeaderLine = (line = {}, lineNumber) => ({
+ isClosed: true,
+ isHeader: true,
+ line: parseLine(line, lineNumber),
+ lines: [],
+});
+
+/**
+ * Finds the matching header section
+ * for the section_duration object and adds it to it
+ *
+ * {
+ * isHeader: true,
+ * line: {
+ * content: [],
+ * lineNumber: 0,
+ * section_duration: "",
+ * },
+ * lines: []
+ * }
+ *
+ * @param Array data
+ * @param Object durationLine
+ */
+export function addDurationToHeader(data, durationLine) {
+ data.forEach(el => {
+ if (el.line && el.line.section === durationLine.section) {
+ el.line.section_duration = durationLine.section_duration;
+ }
+ });
+}
+
+/**
+ * Check is the current section belongs to a collapsible section
+ *
+ * @param Array acc
+ * @param Object last
+ * @param Object section
+ *
+ * @returns Boolean
+ */
+export const isCollapsibleSection = (acc = [], last = {}, section = {}) =>
+ acc.length > 0 &&
+ last.isHeader === true &&
+ !section.section_duration &&
+ section.section === last.line.section;
+
+/**
+ * Returns the lineNumber of the last line in
+ * a parsed log
+ *
+ * @param Array acc
+ * @returns Number
+ */
+export const getIncrementalLineNumber = acc => {
+ let lineNumberValue;
+ const lastIndex = acc.length - 1;
+ const lastElement = acc[lastIndex];
+ const nestedLines = lastElement.lines;
+
+ if (lastElement.isHeader && !nestedLines.length && lastElement.line) {
+ lineNumberValue = lastElement.line.lineNumber;
+ } else if (lastElement.isHeader && nestedLines.length) {
+ lineNumberValue = nestedLines[nestedLines.length - 1].lineNumber;
+ } else {
+ lineNumberValue = lastElement.lineNumber;
+ }
+
+ return lineNumberValue === 0 ? 1 : lineNumberValue + 1;
+};
+
+/**
* Parses the job log content into a structure usable by the template
*
* For collaspible lines (section_header = true):
@@ -17,33 +96,71 @@ export const parseLine = (line = {}, lineNumber) => ({
* - adds a isHeader property to handle template logic
* - adds the section_duration
* For each line:
- * - adds the index as lineNumber
+ * - adds the index as lineNumber
*
- * @param {Array} lines
- * @returns {Array}
+ * @param Array lines
+ * @param Array accumulator
+ * @returns Array parsed log lines
*/
-export const logLinesParser = (lines = [], lineNumberStart) =>
- lines.reduce((acc, line, index) => {
- const lineNumber = lineNumberStart ? lineNumberStart + index : index;
- const last = acc[acc.length - 1];
-
- if (line.section_header) {
- acc.push({
- isClosed: true,
- isHeader: true,
- line: parseLine(line, lineNumber),
- lines: [],
- });
- } else if (acc.length && last.isHeader && !line.section_duration && line.content.length) {
- last.lines.push(parseLine(line, lineNumber));
- } else if (acc.length && last.isHeader && line.section_duration) {
- last.section_duration = line.section_duration;
- } else if (line.content.length) {
- acc.push(parseLine(line, lineNumber));
+export const logLinesParser = (lines = [], accumulator = []) =>
+ lines.reduce(
+ (acc, line, index) => {
+ const lineNumber = accumulator.length > 0 ? getIncrementalLineNumber(acc) : index;
+
+ const last = acc[acc.length - 1];
+
+ // If the object is an header, we parse it into another structure
+ if (line.section_header) {
+ acc.push(parseHeaderLine(line, lineNumber));
+ } else if (isCollapsibleSection(acc, last, line)) {
+ // if the object belongs to a nested section, we append it to the new `lines` array of the
+ // previously formated header
+ last.lines.push(parseLine(line, lineNumber));
+ } else if (line.section_duration) {
+ // if the line has section_duration, we look for the correct header to add it
+ addDurationToHeader(acc, line);
+ } else {
+ // otherwise it's a regular line
+ acc.push(parseLine(line, lineNumber));
+ }
+
+ return acc;
+ },
+ [...accumulator],
+ );
+
+/**
+ * Finds the repeated offset, removes the old one
+ *
+ * Returns a new array with the updated log without
+ * the repeated offset
+ *
+ * @param Array newLog
+ * @param Array oldParsed
+ * @returns Array
+ *
+ */
+export const findOffsetAndRemove = (newLog = [], oldParsed = []) => {
+ const cloneOldLog = [...oldParsed];
+ const lastIndex = cloneOldLog.length - 1;
+ const last = cloneOldLog[lastIndex];
+
+ const firstNew = newLog[0];
+
+ if (last && firstNew) {
+ if (last.offset === firstNew.offset || (last.line && last.line.offset === firstNew.offset)) {
+ cloneOldLog.splice(lastIndex);
+ } else if (last.lines && last.lines.length) {
+ const lastNestedIndex = last.lines.length - 1;
+ const lastNested = last.lines[lastNestedIndex];
+ if (lastNested.offset === firstNew.offset) {
+ last.lines.splice(lastNestedIndex);
+ }
}
+ }
- return acc;
- }, []);
+ return cloneOldLog;
+};
/**
* When the trace is not complete, backend may send the last received line
@@ -52,40 +169,13 @@ export const logLinesParser = (lines = [], lineNumberStart) =>
* We need to check if that is the case by looking for the offset property
* before parsing the incremental part
*
- * @param array originalTrace
* @param array oldLog
* @param array newLog
*/
-export const updateIncrementalTrace = (originalTrace = [], oldLog = [], newLog = []) => {
- const firstLine = newLog[0];
- const firstLineOffset = firstLine.offset;
-
- // We are going to return a new array,
- // let's make a shallow copy to make sure we
- // are not updating the state outside of a mutation first.
- const cloneOldLog = [...oldLog];
+export const updateIncrementalTrace = (newLog = [], oldParsed = []) => {
+ const parsedLog = findOffsetAndRemove(newLog, oldParsed);
- const lastIndex = cloneOldLog.length - 1;
- const lastLine = cloneOldLog[lastIndex];
-
- // The last line may be inside a collpasible section
- // If it is, we use the not parsed saved log, remove the last element
- // and parse the first received part togheter with the incremental log
- if (
- lastLine.isHeader &&
- (lastLine.line.offset === firstLineOffset ||
- (lastLine.lines.length &&
- lastLine.lines[lastLine.lines.length - 1].offset === firstLineOffset))
- ) {
- const cloneOriginal = [...originalTrace];
- cloneOriginal.splice(cloneOriginal.length - 1);
- return logLinesParser(cloneOriginal.concat(newLog));
- } else if (lastLine.offset === firstLineOffset) {
- cloneOldLog.splice(lastIndex);
- return cloneOldLog.concat(logLinesParser(newLog, cloneOldLog.length));
- }
- // there are no matches, let's parse the new log and return them together
- return cloneOldLog.concat(logLinesParser(newLog, cloneOldLog.length));
+ return logLinesParser(newLog, parsedLog);
};
export const isNewJobLogActive = () => gon && gon.features && gon.features.jobLogJson;
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index b028e9564c9..72de3b5d726 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -1,4 +1,4 @@
-/* eslint-disable no-useless-return, func-names, no-var, no-underscore-dangle, prefer-arrow-callback, one-var, prefer-template, no-new, consistent-return, no-shadow, no-param-reassign, vars-on-top, no-lonely-if, no-else-return, dot-notation, no-empty */
+/* eslint-disable no-useless-return, func-names, no-var, no-underscore-dangle, one-var, no-new, consistent-return, no-shadow, no-param-reassign, vars-on-top, no-lonely-if, no-else-return, dot-notation, no-empty */
/* global Issuable */
/* global ListLabel */
@@ -24,7 +24,7 @@ export default class LabelsSelect {
$els = $('.js-label-select');
}
- $els.each(function(i, dropdown) {
+ $els.each((i, dropdown) => {
var $block,
$dropdown,
$form,
@@ -32,6 +32,7 @@ export default class LabelsSelect {
$selectbox,
$sidebarCollapsedValue,
$value,
+ $dropdownMenu,
abilityName,
defaultLabel,
issueUpdateURL,
@@ -67,10 +68,11 @@ export default class LabelsSelect {
$sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span');
$sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip');
$value = $block.find('.value');
+ $dropdownMenu = $dropdown.parent().find('.dropdown-menu');
$loading = $block.find('.block-loading').fadeOut();
fieldName = $dropdown.data('fieldName');
initialSelected = $selectbox
- .find('input[name="' + $dropdown.data('fieldName') + '"]')
+ .find(`input[name="${$dropdown.data('fieldName')}"]`)
.map(function() {
return this.value;
})
@@ -92,7 +94,7 @@ export default class LabelsSelect {
var data, selected;
selected = $dropdown
.closest('.selectbox')
- .find("input[name='" + fieldName + "']")
+ .find(`input[name='${fieldName}']`)
.map(function() {
return this.value;
})
@@ -120,7 +122,7 @@ export default class LabelsSelect {
labelCount = 0;
if (data.labels.length && issueUpdateURL) {
template = LabelsSelect.getLabelTemplate({
- labels: data.labels,
+ labels: _.sortBy(data.labels, 'title'),
issueUpdateURL,
enableScopedLabels: scopedLabels,
scopedLabelsDocumentationLink,
@@ -172,9 +174,7 @@ export default class LabelsSelect {
$sidebarCollapsedValue.text(labelCount);
if (data.labels.length) {
- labelTitles = data.labels.map(function(label) {
- return label.title;
- });
+ labelTitles = data.labels.map(label => label.title);
if (labelTitles.length > 5) {
labelTitles = labelTitles.slice(0, 5);
@@ -269,11 +269,7 @@ export default class LabelsSelect {
if (
$form.find(
- "input[type='hidden'][name='" +
- this.fieldName +
- "'][value='" +
- dropdownValue +
- "']",
+ `input[type='hidden'][name='${this.fieldName}'][value='${dropdownValue}']`,
).length
) {
selectedClass.push('is-active');
@@ -286,8 +282,7 @@ export default class LabelsSelect {
}
if (label.color) {
- colorEl =
- "<span class='dropdown-label-box' style='background: " + label.color + "'></span>";
+ colorEl = `<span class='dropdown-label-box' style='background: ${label.color}'></span>`;
} else {
colorEl = '';
}
@@ -456,16 +451,26 @@ export default class LabelsSelect {
);
} else {
var { labels } = boardsStore.detail.issue;
- labels = labels.filter(function(selectedLabel) {
- return selectedLabel.id !== label.id;
- });
+ labels = labels.filter(selectedLabel => selectedLabel.id !== label.id);
boardsStore.detail.issue.labels = labels;
}
$loading.fadeIn();
+ const oldLabels = boardsStore.detail.issue.labels;
boardsStore.detail.issue
.update($dropdown.attr('data-issue-update'))
+ .then(() => {
+ if (isScopedLabel(label)) {
+ const prevIds = oldLabels.map(label => label.id);
+ const newIds = boardsStore.detail.issue.labels.map(label => label.id);
+ const differentIds = _.difference(prevIds, newIds);
+ $dropdown.data('marked', newIds);
+ $dropdownMenu
+ .find(differentIds.map(id => `[data-label-id="${id}"]`).join(','))
+ .removeClass('is-active');
+ }
+ })
.then(fadeOutLoader)
.catch(fadeOutLoader);
} else if (handleClick) {
diff --git a/app/assets/javascripts/lib/utils/axios_utils.js b/app/assets/javascripts/lib/utils/axios_utils.js
index 37721cd030c..a04fe609015 100644
--- a/app/assets/javascripts/lib/utils/axios_utils.js
+++ b/app/assets/javascripts/lib/utils/axios_utils.js
@@ -1,5 +1,6 @@
import axios from 'axios';
import csrf from './csrf';
+import suppressAjaxErrorsDuringNavigation from './suppress_ajax_errors_during_navigation';
axios.defaults.headers.common[csrf.headerKey] = csrf.token;
// Used by Rails to check if it is a valid XHR request
@@ -8,23 +9,37 @@ axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
// Maintain a global counter for active requests
// see: spec/support/wait_for_requests.rb
axios.interceptors.request.use(config => {
- window.activeVueResources = window.activeVueResources || 0;
- window.activeVueResources += 1;
+ window.pendingRequests = window.pendingRequests || 0;
+ window.pendingRequests += 1;
return config;
});
// Remove the global counter
axios.interceptors.response.use(
response => {
- window.activeVueResources -= 1;
+ window.pendingRequests -= 1;
return response;
},
err => {
- window.activeVueResources -= 1;
+ window.pendingRequests -= 1;
return Promise.reject(err);
},
);
+let isUserNavigating = false;
+window.addEventListener('beforeunload', () => {
+ isUserNavigating = true;
+});
+
+// Ignore AJAX errors caused by requests
+// being cancelled due to browser navigation
+const { gon } = window;
+const featureFlagEnabled = gon && gon.features && gon.features.suppressAjaxNavigationErrors;
+axios.interceptors.response.use(
+ response => response,
+ err => suppressAjaxErrorsDuringNavigation(err, isUserNavigating, featureFlagEnabled),
+);
+
export default axios;
/**
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 6e8f63a10a4..177ae4f9838 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -15,6 +15,8 @@ export const getPagePath = (index = 0) => {
return page.split(':')[index];
};
+export const getDashPath = (path = window.location.pathname) => path.split('/-/')[1] || null;
+
export const isInGroupsPage = () => getPagePath() === 'groups';
export const isInProjectPage = () => getPagePath() === 'projects';
@@ -175,6 +177,15 @@ export const urlParamsToArray = (path = '') =>
export const getUrlParamsArray = () => urlParamsToArray(window.location.search);
+/**
+ * Accepts encoding string which includes query params being
+ * sent to URL.
+ *
+ * @param {string} path Query param string
+ *
+ * @returns {object} Query params object containing key-value pairs
+ * with both key and values decoded into plain string.
+ */
export const urlParamsToObject = (path = '') =>
splitPath(path).reduce((dataParam, filterParam) => {
if (filterParam === '') {
@@ -183,6 +194,7 @@ export const urlParamsToObject = (path = '') =>
const data = dataParam;
let [key, value] = filterParam.split('=');
+ key = /%\w+/g.test(key) ? decodeURIComponent(key) : key;
const isArray = key.includes('[]');
key = key.replace('[]', '');
value = decodeURIComponent(value.replace(/\+/g, ' '));
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index a4715789337..37b0215f6f9 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -537,13 +537,6 @@ export const stringifyTime = (timeObject, fullNameFormat = false) => {
};
/**
- * Accepts a time string of any size (e.g. '1w 2d 3h 5m' or '1w 2d') and returns
- * the first non-zero unit/value pair.
- */
-export const abbreviateTime = timeStr =>
- timeStr.split(' ').filter(unitStr => unitStr.charAt(0) !== '0')[0];
-
-/**
* Calculates the milliseconds between now and a given date string.
* The result cannot become negative.
*
@@ -554,3 +547,20 @@ export const calculateRemainingMilliseconds = endDate => {
const remainingMilliseconds = new Date(endDate).getTime() - Date.now();
return Math.max(remainingMilliseconds, 0);
};
+
+/**
+ * Subtracts a given number of days from a given date and returns the new date.
+ *
+ * @param {Date} date the date that we will substract days from
+ * @param {number} daysInPast number of days that are subtracted from a given date
+ * @returns {String} Date string in ISO format
+ */
+export const getDateInPast = (date, daysInPast) => {
+ const dateClone = newDate(date);
+ return new Date(
+ dateClone.setTime(dateClone.getTime() - daysInPast * 24 * 60 * 60 * 1000),
+ ).toISOString();
+};
+
+export const beginOfDayTime = 'T00:00:00Z';
+export const endOfDayTime = 'T23:59:59Z';
diff --git a/app/assets/javascripts/lib/utils/notify.js b/app/assets/javascripts/lib/utils/notify.js
index 3439db1e326..cd509a13193 100644
--- a/app/assets/javascripts/lib/utils/notify.js
+++ b/app/assets/javascripts/lib/utils/notify.js
@@ -1,12 +1,14 @@
-/* eslint-disable func-names, no-var, consistent-return, prefer-arrow-callback, no-return-assign */
+/* eslint-disable no-var, consistent-return, no-return-assign */
function notificationGranted(message, opts, onclick) {
var notification;
notification = new Notification(message, opts);
- setTimeout(function() {
- // Hide the notification after X amount of seconds
- return notification.close();
- }, 8000);
+ setTimeout(
+ () =>
+ // Hide the notification after X amount of seconds
+ notification.close(),
+ 8000,
+ );
return (notification.onclick = onclick || notification.close);
}
@@ -32,7 +34,7 @@ function notifyMe(message, body, icon, onclick) {
// If it's okay let's create a notification
return notificationGranted(message, opts, onclick);
} else if (Notification.permission !== 'denied') {
- return Notification.requestPermission(function(permission) {
+ return Notification.requestPermission(permission => {
// If the user accepts, let's create a notification
if (permission === 'granted') {
return notificationGranted(message, opts, onclick);
diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js
index 61c8b8803d7..0f2cc57b1f9 100644
--- a/app/assets/javascripts/lib/utils/number_utils.js
+++ b/app/assets/javascripts/lib/utils/number_utils.js
@@ -106,3 +106,14 @@ export const sum = (a = 0, b = 0) => a + b;
* @param {Int} number
*/
export const isOdd = (number = 0) => number % 2;
+
+/**
+ * Computes the median for a given array.
+ * @param {Array} arr An array of numbers
+ * @returns {Number} The median of the given array
+ */
+export const median = arr => {
+ const middle = Math.floor(arr.length / 2);
+ const sorted = arr.sort((a, b) => a - b);
+ return arr.length % 2 !== 0 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
+};
diff --git a/app/assets/javascripts/lib/utils/set.js b/app/assets/javascripts/lib/utils/set.js
new file mode 100644
index 00000000000..3845d648b61
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/set.js
@@ -0,0 +1,9 @@
+/**
+ * Checks if the first argument is a subset of the second argument.
+ * @param {Set} subset The set to be considered as the subset.
+ * @param {Set} superset The set to be considered as the superset.
+ * @returns {boolean}
+ */
+// eslint-disable-next-line import/prefer-default-export
+export const isSubset = (subset, superset) =>
+ Array.from(subset).every(value => superset.has(value));
diff --git a/app/assets/javascripts/lib/utils/suppress_ajax_errors_during_navigation.js b/app/assets/javascripts/lib/utils/suppress_ajax_errors_during_navigation.js
new file mode 100644
index 00000000000..4c61da9b862
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/suppress_ajax_errors_during_navigation.js
@@ -0,0 +1,16 @@
+/**
+ * An Axios error interceptor that suppresses AJAX errors caused
+ * by the request being cancelled when the user navigates to a new page
+ */
+export default (err, isUserNavigating, featureFlagEnabled) => {
+ if (featureFlagEnabled && isUserNavigating && err.code === 'ECONNABORTED') {
+ // If the user is navigating away from the current page,
+ // prevent .then() and .catch() handlers from being
+ // called by returning a Promise that never resolves
+ return new Promise(() => {});
+ }
+
+ // The error is not related to browser navigation,
+ // so propagate the error
+ return Promise.reject(err);
+};
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 7873eaf059f..2e0270ee42f 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, no-param-reassign, one-var, operator-assignment, no-else-return, prefer-template, prefer-arrow-callback, consistent-return */
+/* eslint-disable func-names, no-var, no-param-reassign, one-var, operator-assignment, no-else-return, consistent-return */
import $ from 'jquery';
import { insertText } from '~/lib/utils/common_utils';
@@ -218,7 +218,7 @@ export function insertMarkdownText({
: blockTagText(text, textArea, blockTag, selected);
} else {
textToInsert = selectedSplit
- .map(function(val) {
+ .map(val => {
if (tag.indexOf(textPlaceholder) > -1) {
return tag.replace(textPlaceholder, val);
}
@@ -237,7 +237,7 @@ export function insertMarkdownText({
}
if (removedFirstNewLine) {
- textToInsert = '\n' + textToInsert;
+ textToInsert = `\n${textToInsert}`;
}
if (removedLastNewLine) {
@@ -301,7 +301,7 @@ export function addMarkdownListeners(form) {
export function addEditorMarkdownListeners(editor) {
$('.js-md')
.off('click')
- .on('click', function(e) {
+ .on('click', e => {
const { mdTag, mdBlock, mdPrepend, mdSelect } = $(e.currentTarget).data();
insertMarkdownText({
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index 7ead9d46fbb..4be0d05a9b7 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -88,6 +88,14 @@ export function getLocationHash(url = window.location.href) {
}
/**
+ * Returns a boolean indicating whether the URL hash contains the given string value
+ */
+export function doesHashExistInUrl(hashName) {
+ const hash = getLocationHash();
+ return hash && hash.includes(hashName);
+}
+
+/**
* Apply the fragment to the given url by returning a new url string that includes
* the fragment. If the given url already contains a fragment, the original fragment
* will be removed.
diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js
index 4db63c841a9..b6b96fe7bd5 100644
--- a/app/assets/javascripts/line_highlighter.js
+++ b/app/assets/javascripts/line_highlighter.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, no-underscore-dangle, no-param-reassign, prefer-template, consistent-return, one-var, no-else-return */
+/* eslint-disable func-names, no-var, no-underscore-dangle, no-param-reassign, consistent-return, one-var, no-else-return */
import $ from 'jquery';
@@ -106,7 +106,7 @@ LineHighlighter.prototype.clickHandler = function(event) {
};
LineHighlighter.prototype.clearHighlight = function() {
- return $('.' + this.highlightLineClass).removeClass(this.highlightLineClass);
+ return $(`.${this.highlightLineClass}`).removeClass(this.highlightLineClass);
};
// Convert a URL hash String into line numbers
@@ -137,7 +137,7 @@ LineHighlighter.prototype.hashToRange = function(hash) {
//
// lineNumber - Line number to highlight
LineHighlighter.prototype.highlightLine = function(lineNumber) {
- return $('#LC' + lineNumber).addClass(this.highlightLineClass);
+ return $(`#LC${lineNumber}`).addClass(this.highlightLineClass);
};
// Highlight all lines within a range
@@ -162,9 +162,9 @@ LineHighlighter.prototype.highlightRange = function(range) {
LineHighlighter.prototype.setHash = function(firstLineNumber, lastLineNumber) {
var hash;
if (lastLineNumber) {
- hash = '#L' + firstLineNumber + '-' + lastLineNumber;
+ hash = `#L${firstLineNumber}-${lastLineNumber}`;
} else {
- hash = '#L' + firstLineNumber;
+ hash = `#L${firstLineNumber}`;
}
this._hash = hash;
return this.__setLocationHash__(hash);
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 0ddf40b0405..c19a845eb69 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -37,6 +37,7 @@ import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
import { initUserTracking } from './tracking';
import { __ } from './locale';
+import initPrivacyPolicyUpdateCallout from './privacy_policy_update_callout';
import 'ee_else_ce/main_ee';
@@ -96,6 +97,7 @@ function deferredInitialisation() {
initUsagePingConsent();
initUserPopovers();
initUserTracking();
+ initPrivacyPolicyUpdateCallout();
if (document.querySelector('.search')) initSearchAutocomplete();
@@ -312,6 +314,7 @@ document.addEventListener('DOMContentLoaded', () => {
const action = `${this.action}${link.search === '' ? '?' : '&'}`;
event.preventDefault();
+ // eslint-disable-next-line no-jquery/no-serialize
visitUrl(`${action}${$(this).serialize()}`);
});
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index 3b42a154af8..7223b5c0d43 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, no-underscore-dangle, one-var, consistent-return, prefer-arrow-callback */
+/* eslint-disable func-names, no-var, no-underscore-dangle, one-var, consistent-return */
import $ from 'jquery';
import { __ } from '~/locale';
@@ -105,7 +105,7 @@ MergeRequest.prototype.submitNoteForm = function(form, $button) {
};
MergeRequest.prototype.initCommitMessageListeners = function() {
- $(document).on('click', 'a.js-with-description-link', function(e) {
+ $(document).on('click', 'a.js-with-description-link', e => {
var textarea = $('textarea.js-commit-message');
e.preventDefault();
@@ -114,7 +114,7 @@ MergeRequest.prototype.initCommitMessageListeners = function() {
$('.js-without-description-hint').show();
});
- $(document).on('click', 'a.js-without-description-link', function(e) {
+ $(document).on('click', 'a.js-without-description-link', e => {
var textarea = $('textarea.js-commit-message');
e.preventDefault();
diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue
index f3f3bf15295..78fe575717a 100644
--- a/app/assets/javascripts/monitoring/components/charts/time_series.vue
+++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue
@@ -1,6 +1,6 @@
<script>
-import { __ } from '~/locale';
-import { GlLink, GlButton } from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import { GlLink, GlButton, GlTooltip } from '@gitlab/ui';
import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat';
import { debounceByAnimationFrame, roundOffFloat } from '~/lib/utils/common_utils';
@@ -16,6 +16,7 @@ export default {
components: {
GlAreaChart,
GlLineChart,
+ GlTooltip,
GlButton,
GlChartSeriesLabel,
GlLink,
@@ -52,6 +53,21 @@ export default {
required: false,
default: () => [],
},
+ legendAverageText: {
+ type: String,
+ required: false,
+ default: s__('Metrics|Avg'),
+ },
+ legendMaxText: {
+ type: String,
+ required: false,
+ default: s__('Metrics|Max'),
+ },
+ groupId: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -62,6 +78,7 @@ export default {
isDeployment: false,
sha: '',
},
+ showTitleTooltip: false,
width: 0,
height: chartHeight,
svgs: {},
@@ -122,7 +139,7 @@ export default {
},
},
series: this.scatterSeries,
- dataZoom: this.dataZoomConfig,
+ dataZoom: [this.dataZoomConfig],
};
},
dataZoomConfig() {
@@ -192,6 +209,12 @@ export default {
watch: {
containerWidth: 'onResize',
},
+ mounted() {
+ const graphTitleEl = this.$refs.graphTitle;
+ if (graphTitleEl && graphTitleEl.scrollWidth > graphTitleEl.offsetWidth) {
+ this.showTitleTooltip = true;
+ }
+ },
beforeDestroy() {
window.removeEventListener('resize', debouncedResize);
},
@@ -255,22 +278,32 @@ export default {
<template>
<div class="prometheus-graph">
<div class="prometheus-graph-header">
- <h5 class="prometheus-graph-title js-graph-title">{{ graphData.title }}</h5>
- <div class="prometheus-graph-widgets js-graph-widgets">
+ <h5
+ ref="graphTitle"
+ class="prometheus-graph-title js-graph-title text-truncate append-right-8"
+ >
+ {{ graphData.title }}
+ </h5>
+ <gl-tooltip :target="() => $refs.graphTitle" :disabled="!showTitleTooltip">
+ {{ graphData.title }}
+ </gl-tooltip>
+ <div class="prometheus-graph-widgets js-graph-widgets flex-fill">
<slot></slot>
</div>
</div>
-
<component
:is="glChartComponent"
ref="chart"
v-bind="$attrs"
+ :group-id="groupId"
:data="chartData"
:option="chartOptions"
:format-tooltip-text="formatTooltipText"
:thresholds="thresholds"
:width="width"
:height="height"
+ :average-text="legendAverageText"
+ :max-text="legendMaxText"
@updated="onChartUpdated"
>
<template v-if="tooltip.isDeployment">
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 12a4c83e053..b4ea415bb51 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -1,4 +1,7 @@
<script>
+import _ from 'underscore';
+import { mapActions, mapState } from 'vuex';
+import VueDraggable from 'vuedraggable';
import {
GlButton,
GlDropdown,
@@ -8,24 +11,26 @@ import {
GlModalDirective,
GlTooltipDirective,
} from '@gitlab/ui';
-import _ from 'underscore';
-import { mapActions, mapState } from 'vuex';
import { __, s__ } from '~/locale';
+import createFlash from '~/flash';
import Icon from '~/vue_shared/components/icon.vue';
-import { getParameterValues, mergeUrlParams } from '~/lib/utils/url_utility';
+import { getParameterValues, mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
import invalidUrl from '~/lib/utils/invalid_url';
import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
+import DateTimePicker from './date_time_picker/date_time_picker.vue';
import MonitorTimeSeriesChart from './charts/time_series.vue';
import MonitorSingleStatChart from './charts/single_stat.vue';
import GraphGroup from './graph_group.vue';
import EmptyState from './empty_state.vue';
-import { sidebarAnimationDuration, timeWindows } from '../constants';
-import { getTimeDiff, getTimeWindow } from '../utils';
+import { sidebarAnimationDuration } from '../constants';
+import TrackEventDirective from '~/vue_shared/directives/track_event';
+import { getTimeDiff, isValidDate, downloadCSVOptions, generateLinkToChartOptions } from '../utils';
let sidebarMutationObserver;
export default {
components: {
+ VueDraggable,
MonitorTimeSeriesChart,
MonitorSingleStatChart,
PanelType,
@@ -37,10 +42,12 @@ export default {
GlDropdownItem,
GlFormGroup,
GlModal,
+ DateTimePicker,
},
directives: {
GlModal: GlModalDirective,
GlTooltip: GlTooltipDirective,
+ TrackEvent: TrackEventDirective,
},
props: {
externalDashboardUrl: {
@@ -151,15 +158,19 @@ export default {
required: false,
default: false,
},
+ rearrangePanelsAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
state: 'gettingStarted',
elWidth: 0,
- selectedTimeWindow: '',
- selectedTimeWindowKey: '',
formIsValid: null,
- timeWindows: {},
+ selectedTimeWindow: {},
+ isRearrangingPanels: false,
};
},
computed: {
@@ -175,7 +186,6 @@ export default {
'metricsWithData',
'useDashboardEndpoint',
'allDashboards',
- 'multipleDashboardsEnabled',
'additionalPanelTypesEnabled',
]),
firstDashboard() {
@@ -184,6 +194,9 @@ export default {
selectedDashboardText() {
return this.currentDashboard || this.firstDashboard.display_name;
},
+ showRearrangePanelsBtn() {
+ return !this.showEmptyState && this.rearrangePanelsAvailable;
+ },
addingMetricsAvailable() {
return IS_EE && this.canAddMetrics && !this.showEmptyState;
},
@@ -219,11 +232,13 @@ export default {
end,
};
- this.timeWindows = timeWindows;
- this.selectedTimeWindowKey = getTimeWindow(range);
- this.selectedTimeWindow = this.timeWindows[this.selectedTimeWindowKey];
+ this.selectedTimeWindow = range;
- this.fetchData(range);
+ if (!isValidDate(start) || !isValidDate(end)) {
+ this.showInvalidDateError();
+ } else {
+ this.fetchData(range);
+ }
sidebarMutationObserver = new MutationObserver(this.onSidebarMutation);
sidebarMutationObserver.observe(document.querySelector('.layout-page'), {
@@ -272,9 +287,17 @@ export default {
return Object.values(this.getGraphAlerts(queries));
},
showToast() {
- this.$toast.show(__('Link copied to clipboard'));
+ this.$toast.show(__('Link copied'));
},
// TODO: END
+ removeGraph(metrics, graphIndex) {
+ // At present graphs will not be removed, they should removed using the vuex store
+ // See https://gitlab.com/gitlab-org/gitlab/issues/27835
+ metrics.splice(graphIndex, 1);
+ },
+ showInvalidDateError() {
+ createFlash(s__('Metrics|Link contains an invalid time window.'));
+ },
generateLink(group, title, yLabel) {
const dashboard = this.currentDashboard || this.firstDashboard.path;
const params = _.pick({ dashboard, group, title, y_label: yLabel }, value => value != null);
@@ -288,22 +311,23 @@ export default {
this.elWidth = this.$el.clientWidth;
}, sidebarAnimationDuration);
},
+ toggleRearrangingPanels() {
+ this.isRearrangingPanels = !this.isRearrangingPanels;
+ },
setFormValidity(isValid) {
this.formIsValid = isValid;
},
submitCustomMetricsForm() {
this.$refs.customMetricsForm.submit();
},
- activeTimeWindow(key) {
- return this.timeWindows[key] === this.selectedTimeWindow;
- },
- setTimeWindowParameter(key) {
- const { start, end } = getTimeDiff(key);
- return `?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`;
- },
groupHasData(group) {
return this.chartsWithData(group.metrics).length > 0;
},
+ onDateTimePickerApply(timeWindowUrlParams) {
+ return redirectTo(mergeUrlParams(timeWindowUrlParams, window.location.href));
+ },
+ downloadCSVOptions,
+ generateLinkToChartOptions,
},
addMetric: {
title: s__('Metrics|Add metric'),
@@ -314,15 +338,14 @@ export default {
<template>
<div class="prometheus-graphs">
- <div class="gl-p-3 pb-0 border-bottom bg-gray-light">
+ <div class="prometheus-graphs-header gl-p-3 pb-0 border-bottom bg-gray-light">
<div class="row">
<template v-if="environmentsEndpoint">
<gl-form-group
- v-if="multipleDashboardsEnabled"
:label="__('Dashboard')"
label-size="sm"
label-for="monitor-dashboards-dropdown"
- class="col-sm-12 col-md-4 col-lg-2"
+ class="col-sm-12 col-md-6 col-lg-2"
>
<gl-dropdown
id="monitor-dashboards-dropdown"
@@ -345,7 +368,7 @@ export default {
:label="s__('Metrics|Environment')"
label-size="sm"
label-for="monitor-environments-dropdown"
- class="col-sm-6 col-md-4 col-lg-2"
+ class="col-sm-6 col-md-6 col-lg-2"
>
<gl-dropdown
id="monitor-environments-dropdown"
@@ -370,36 +393,35 @@ export default {
:label="s__('Metrics|Show last')"
label-size="sm"
label-for="monitor-time-window-dropdown"
- class="col-sm-6 col-md-4 col-lg-2"
+ class="col-sm-6 col-md-6 col-lg-4"
>
- <gl-dropdown
- id="monitor-time-window-dropdown"
- class="mb-0 d-flex js-time-window-dropdown"
- toggle-class="dropdown-menu-toggle"
- :text="selectedTimeWindow"
- >
- <gl-dropdown-item
- v-for="(value, key) in timeWindows"
- :key="key"
- :active="activeTimeWindow(key)"
- :href="setTimeWindowParameter(key)"
- active-class="active"
- >{{ value }}</gl-dropdown-item
- >
- </gl-dropdown>
+ <date-time-picker
+ :selected-time-window="selectedTimeWindow"
+ @onApply="onDateTimePickerApply"
+ />
</gl-form-group>
</template>
<gl-form-group
- v-if="addingMetricsAvailable || externalDashboardUrl.length"
+ v-if="addingMetricsAvailable || showRearrangePanelsBtn || externalDashboardUrl.length"
label-for="prometheus-graphs-dropdown-buttons"
- class="dropdown-buttons col-lg d-lg-flex align-items-end"
+ class="dropdown-buttons col-md d-md-flex col-lg d-lg-flex align-items-end"
>
<div id="prometheus-graphs-dropdown-buttons">
<gl-button
+ v-if="showRearrangePanelsBtn"
+ :pressed="isRearrangingPanels"
+ variant="default"
+ class="mr-2 mt-1 js-rearrange-button"
+ @click="toggleRearrangingPanels"
+ >
+ {{ __('Arrange charts') }}
+ </gl-button>
+ <gl-button
v-if="addingMetricsAvailable"
v-gl-modal="$options.addMetric.modalId"
- class="mr-2 mt-1 js-add-metric-button text-success border-success"
+ variant="outline-success"
+ class="mr-2 mt-1 js-add-metric-button"
>
{{ $options.addMetric.title }}
</gl-button>
@@ -453,17 +475,42 @@ export default {
:collapse-group="groupHasData(groupData)"
>
<template v-if="additionalPanelTypesEnabled">
- <panel-type
- v-for="(graphData, graphIndex) in groupData.metrics"
- :key="`panel-type-${graphIndex}`"
- class="col-12 col-lg-6 pb-3"
- :clipboard-text="generateLink(groupData.group, graphData.title, graphData.y_label)"
- :graph-data="graphData"
- :dashboard-width="elWidth"
- :alerts-endpoint="alertsEndpoint"
- :prometheus-alerts-available="prometheusAlertsAvailable"
- :index="`${index}-${graphIndex}`"
- />
+ <vue-draggable
+ :list="groupData.metrics"
+ group="metrics-dashboard"
+ :component-data="{ attrs: { class: 'row mx-0 w-100' } }"
+ :disabled="!isRearrangingPanels"
+ >
+ <div
+ v-for="(graphData, graphIndex) in groupData.metrics"
+ :key="`panel-type-${graphIndex}`"
+ class="col-12 col-lg-6 px-2 mb-2 draggable"
+ :class="{ 'draggable-enabled': isRearrangingPanels }"
+ >
+ <div class="position-relative draggable-panel js-draggable-panel">
+ <div
+ v-if="isRearrangingPanels"
+ class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end"
+ @click="removeGraph(groupData.metrics, graphIndex)"
+ >
+ <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')"
+ ><icon name="close"
+ /></a>
+ </div>
+
+ <panel-type
+ :clipboard-text="
+ generateLink(groupData.group, graphData.title, graphData.y_label)
+ "
+ :graph-data="graphData"
+ :dashboard-width="elWidth"
+ :alerts-endpoint="alertsEndpoint"
+ :prometheus-alerts-available="prometheusAlertsAvailable"
+ :index="`${index}-${graphIndex}`"
+ />
+ </div>
+ </div>
+ </vue-draggable>
</template>
<template v-else>
<monitor-time-series-chart
@@ -477,7 +524,10 @@ export default {
:project-path="projectPath"
group-id="monitor-time-series-chart"
>
- <div class="d-flex align-items-center">
+ <div
+ class="d-flex align-items-center"
+ :class="alertWidgetAvailable ? 'justify-content-between' : 'justify-content-end'"
+ >
<alert-widget
v-if="alertWidgetAvailable && graphData"
:modal-id="`alert-modal-${index}-${graphIndex}`"
@@ -488,7 +538,7 @@ export default {
/>
<gl-dropdown
v-gl-tooltip
- class="mx-2"
+ class="ml-2 mr-3"
toggle-class="btn btn-transparent border-0"
:right="true"
:no-caret="true"
@@ -497,10 +547,19 @@ export default {
<template slot="button-content">
<icon name="ellipsis_v" class="text-secondary" />
</template>
- <gl-dropdown-item :href="downloadCsv(graphData)" download="chart_metrics.csv">
+ <gl-dropdown-item
+ v-track-event="downloadCSVOptions(graphData.title)"
+ :href="downloadCsv(graphData)"
+ download="chart_metrics.csv"
+ >
{{ __('Download CSV') }}
</gl-dropdown-item>
<gl-dropdown-item
+ v-track-event="
+ generateLinkToChartOptions(
+ generateLink(groupData.group, graphData.title, graphData.y_label),
+ )
+ "
class="js-chart-link"
:data-clipboard-text="
generateLink(groupData.group, graphData.title, graphData.y_label)
diff --git a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue
new file mode 100644
index 00000000000..4616a767295
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue
@@ -0,0 +1,151 @@
+<script>
+import { GlButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+import DateTimePickerInput from './date_time_picker_input.vue';
+import {
+ getTimeDiff,
+ getTimeWindow,
+ stringToISODate,
+ ISODateToString,
+ truncateZerosInDateTime,
+ isDateTimePickerInputValid,
+} from '~/monitoring/utils';
+import { timeWindows } from '~/monitoring/constants';
+
+export default {
+ components: {
+ Icon,
+ DateTimePickerInput,
+ GlFormGroup,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ },
+ props: {
+ timeWindows: {
+ type: Object,
+ required: false,
+ default: () => timeWindows,
+ },
+ selectedTimeWindow: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ },
+ data() {
+ return {
+ selectedTimeWindowText: '',
+ customTime: {
+ from: null,
+ to: null,
+ },
+ };
+ },
+ computed: {
+ applyEnabled() {
+ return Boolean(this.inputState.from && this.inputState.to);
+ },
+ inputState() {
+ const { from, to } = this.customTime;
+ return {
+ from: from && isDateTimePickerInputValid(from),
+ to: to && isDateTimePickerInputValid(to),
+ };
+ },
+ },
+ mounted() {
+ const range = getTimeWindow(this.selectedTimeWindow);
+ if (range) {
+ this.selectedTimeWindowText = this.timeWindows[range];
+ } else {
+ this.customTime = {
+ from: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.start)),
+ to: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.end)),
+ };
+ this.selectedTimeWindowText = sprintf(s__('%{from} to %{to}'), this.customTime);
+ }
+ },
+ methods: {
+ activeTimeWindow(key) {
+ return this.timeWindows[key] === this.selectedTimeWindowText;
+ },
+ setCustomTimeWindowParameter() {
+ this.$emit('onApply', {
+ start: stringToISODate(this.customTime.from),
+ end: stringToISODate(this.customTime.to),
+ });
+ },
+ setTimeWindowParameter(key) {
+ const { start, end } = getTimeDiff(key);
+ this.$emit('onApply', {
+ start,
+ end,
+ });
+ },
+ closeDropdown() {
+ this.$refs.dropdown.hide();
+ },
+ },
+};
+</script>
+<template>
+ <gl-dropdown
+ ref="dropdown"
+ :text="selectedTimeWindowText"
+ menu-class="time-window-dropdown-menu"
+ class="js-time-window-dropdown"
+ >
+ <div class="d-flex justify-content-between time-window-dropdown-menu-container">
+ <gl-form-group
+ :label="__('Custom range')"
+ label-for="custom-from-time"
+ class="custom-time-range-form-group col-md-7 p-0 m-0"
+ >
+ <date-time-picker-input
+ id="custom-time-from"
+ v-model="customTime.from"
+ :label="__('From')"
+ :state="inputState.from"
+ />
+ <date-time-picker-input
+ id="custom-time-to"
+ v-model="customTime.to"
+ :label="__('To')"
+ :state="inputState.to"
+ />
+ <gl-form-group>
+ <gl-button @click="closeDropdown">{{ __('Cancel') }}</gl-button>
+ <gl-button
+ variant="success"
+ :disabled="!applyEnabled"
+ @click="setCustomTimeWindowParameter"
+ >{{ __('Apply') }}</gl-button
+ >
+ </gl-form-group>
+ </gl-form-group>
+ <gl-form-group
+ :label="__('Quick range')"
+ label-for="group-id-dropdown"
+ label-align="center"
+ class="col-md-4 p-0 m-0"
+ >
+ <gl-dropdown-item
+ v-for="(value, key) in timeWindows"
+ :key="key"
+ :active="activeTimeWindow(key)"
+ active-class="active"
+ @click="setTimeWindowParameter(key)"
+ >
+ <icon
+ name="mobile-issue-close"
+ class="align-bottom"
+ :class="{ invisible: !activeTimeWindow(key) }"
+ />
+ {{ value }}
+ </gl-dropdown-item>
+ </gl-form-group>
+ </div>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue
new file mode 100644
index 00000000000..0388a6190d9
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue
@@ -0,0 +1,77 @@
+<script>
+import _ from 'underscore';
+import { s__, sprintf } from '~/locale';
+import { GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { dateFormats } from '~/monitoring/constants';
+
+const inputGroupText = {
+ invalidFeedback: sprintf(s__('Format: %{dateFormat}'), {
+ dateFormat: dateFormats.dateTimePicker.format,
+ }),
+ placeholder: dateFormats.dateTimePicker.format,
+};
+
+export default {
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ },
+ props: {
+ state: {
+ default: null,
+ required: true,
+ validator: prop => typeof prop === 'boolean' || prop === null,
+ },
+ value: {
+ default: null,
+ required: false,
+ validator: prop => typeof prop === 'string' || prop === null,
+ },
+ label: {
+ type: String,
+ default: '',
+ required: true,
+ },
+ id: {
+ type: String,
+ required: false,
+ default: () => _.uniqueId('dateTimePicker_'),
+ },
+ },
+ data() {
+ return {
+ inputGroupText,
+ };
+ },
+ computed: {
+ invalidFeedback() {
+ return this.state ? '' : this.inputGroupText.invalidFeedback;
+ },
+ inputState() {
+ // When the state is valid we want to show no
+ // green outline. Hence passing null and not true.
+ if (this.state === true) {
+ return null;
+ }
+ return this.state;
+ },
+ },
+ methods: {
+ onInputBlur(e) {
+ this.$emit('input', e.target.value.trim() || null);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-group :label="label" label-size="sm" :label-for="id" :invalid-feedback="invalidFeedback">
+ <gl-form-input
+ :id="id"
+ :value="value"
+ :state="inputState"
+ :placeholder="inputGroupText.placeholder"
+ @blur="onInputBlur"
+ />
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/embed.vue b/app/assets/javascripts/monitoring/components/embed.vue
index da1e88071ab..7857aaa6ecc 100644
--- a/app/assets/javascripts/monitoring/components/embed.vue
+++ b/app/assets/javascripts/monitoring/components/embed.vue
@@ -98,7 +98,7 @@ export default {
class="w-100"
:graph-data="graphData"
:container-width="elWidth"
- group-id="monitor-area-chart"
+ :group-id="dashboardUrl"
:project-path="null"
:show-border="true"
:single-embed="isSingleChart"
diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue
index 72ddd8d4fcf..ee3a2bae79b 100644
--- a/app/assets/javascripts/monitoring/components/graph_group.vue
+++ b/app/assets/javascripts/monitoring/components/graph_group.vue
@@ -52,7 +52,7 @@ export default {
<div
v-if="collapseGroup"
v-show="collapseGroup && showGroup"
- class="card-body prometheus-graph-group"
+ class="card-body prometheus-graph-group p-0"
>
<slot></slot>
</div>
diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue
index 73ff651d510..1a14d06f4c8 100644
--- a/app/assets/javascripts/monitoring/components/panel_type.vue
+++ b/app/assets/javascripts/monitoring/components/panel_type.vue
@@ -13,6 +13,8 @@ import Icon from '~/vue_shared/components/icon.vue';
import MonitorTimeSeriesChart from './charts/time_series.vue';
import MonitorSingleStatChart from './charts/single_stat.vue';
import MonitorEmptyChart from './charts/empty_chart.vue';
+import TrackEventDirective from '~/vue_shared/directives/track_event';
+import { downloadCSVOptions, generateLinkToChartOptions } from '../utils';
export default {
components: {
@@ -27,6 +29,7 @@ export default {
directives: {
GlModal: GlModalDirective,
GlTooltip: GlTooltipDirective,
+ TrackEvent: TrackEventDirective,
},
props: {
clipboardText: {
@@ -82,8 +85,10 @@ export default {
return this.graphData.type && this.graphData.type === type;
},
showToast() {
- this.$toast.show(__('Link copied to clipboard'));
+ this.$toast.show(__('Link copied'));
},
+ downloadCSVOptions,
+ generateLinkToChartOptions,
},
};
</script>
@@ -121,13 +126,18 @@ export default {
<template slot="button-content">
<icon name="ellipsis_v" class="text-secondary" />
</template>
- <gl-dropdown-item :href="downloadCsv" download="chart_metrics.csv">
+ <gl-dropdown-item
+ v-track-event="downloadCSVOptions(graphData.title)"
+ :href="downloadCsv"
+ download="chart_metrics.csv"
+ >
{{ __('Download CSV') }}
</gl-dropdown-item>
<gl-dropdown-item
+ v-track-event="generateLinkToChartOptions(clipboardText)"
class="js-chart-link"
:data-clipboard-text="clipboardText"
- @click="showToast"
+ @click="showToast(clipboardText)"
>
{{ __('Generate link to chart') }}
</gl-dropdown-item>
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
index 13aba3d9f44..2836fe4fc26 100644
--- a/app/assets/javascripts/monitoring/constants.js
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -3,6 +3,11 @@ import { __ } from '~/locale';
export const sidebarAnimationDuration = 300; // milliseconds.
export const chartHeight = 300;
+/**
+ * Valid strings for this regex are
+ * 2019-10-01 and 2019-10-01 01:02:03
+ */
+export const dateTimePickerRegex = /^(\d{4})-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])(?: (0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]))?$/;
export const graphTypes = {
deploymentData: 'scatter',
@@ -28,6 +33,11 @@ export const timeWindows = {
export const dateFormats = {
timeOfDay: 'h:MM TT',
default: 'dd mmm yyyy, h:MMTT',
+ dateTimePicker: {
+ format: 'yyyy-mm-dd hh:mm:ss',
+ ISODate: "yyyy-mm-dd'T'HH:MM:ss'Z'",
+ stringDate: 'yyyy-mm-dd HH:MM:ss',
+ },
};
export const secondsIn = {
diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js
index 51cef20455c..6aa1fb5e9c6 100644
--- a/app/assets/javascripts/monitoring/monitoring_bundle.js
+++ b/app/assets/javascripts/monitoring/monitoring_bundle.js
@@ -14,7 +14,6 @@ export default (props = {}) => {
if (gon.features) {
store.dispatch('monitoringDashboard/setFeatureFlags', {
prometheusEndpointEnabled: gon.features.environmentMetricsUsePrometheusEndpoint,
- multipleDashboardsEnabled: gon.features.environmentMetricsShowMultipleDashboards,
additionalPanelTypesEnabled: gon.features.environmentMetricsAdditionalPanelTypes,
});
}
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 0cbad179f17..2cf34ddb45b 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -37,10 +37,9 @@ export const setEndpoints = ({ commit }, endpoints) => {
export const setFeatureFlags = (
{ commit },
- { prometheusEndpointEnabled, multipleDashboardsEnabled, additionalPanelTypesEnabled },
+ { prometheusEndpointEnabled, additionalPanelTypesEnabled },
) => {
commit(types.SET_DASHBOARD_ENABLED, prometheusEndpointEnabled);
- commit(types.SET_MULTIPLE_DASHBOARDS_ENABLED, multipleDashboardsEnabled);
commit(types.SET_ADDITIONAL_PANEL_TYPES_ENABLED, additionalPanelTypesEnabled);
};
@@ -51,13 +50,8 @@ export const setShowErrorBanner = ({ commit }, enabled) => {
export const requestMetricsDashboard = ({ commit }) => {
commit(types.REQUEST_METRICS_DATA);
};
-export const receiveMetricsDashboardSuccess = (
- { state, commit, dispatch },
- { response, params },
-) => {
- if (state.multipleDashboardsEnabled) {
- commit(types.SET_ALL_DASHBOARDS, response.all_dashboards);
- }
+export const receiveMetricsDashboardSuccess = ({ commit, dispatch }, { response, params }) => {
+ commit(types.SET_ALL_DASHBOARDS, response.all_dashboards);
commit(types.RECEIVE_METRICS_DATA_SUCCESS, response.dashboard.panel_groups);
dispatch('fetchPrometheusMetrics', params);
};
diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js
index 4b1aadbcf05..9c546427c6e 100644
--- a/app/assets/javascripts/monitoring/stores/mutation_types.js
+++ b/app/assets/javascripts/monitoring/stores/mutation_types.js
@@ -10,7 +10,6 @@ export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAIL
export const SET_QUERY_RESULT = 'SET_QUERY_RESULT';
export const SET_TIME_WINDOW = 'SET_TIME_WINDOW';
export const SET_DASHBOARD_ENABLED = 'SET_DASHBOARD_ENABLED';
-export const SET_MULTIPLE_DASHBOARDS_ENABLED = 'SET_MULTIPLE_DASHBOARDS_ENABLED';
export const SET_ADDITIONAL_PANEL_TYPES_ENABLED = 'SET_ADDITIONAL_PANEL_TYPES_ENABLED';
export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS';
export const SET_ENDPOINTS = 'SET_ENDPOINTS';
diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js
index b19520d6638..320b33d3d69 100644
--- a/app/assets/javascripts/monitoring/stores/mutations.js
+++ b/app/assets/javascripts/monitoring/stores/mutations.js
@@ -1,6 +1,8 @@
import Vue from 'vue';
import * as types from './mutation_types';
-import { normalizeMetrics, sortMetrics, normalizeQueryResult } from './utils';
+import { normalizeMetrics, sortMetrics, normalizeMetric, normalizeQueryResult } from './utils';
+
+const normalizePanel = panel => panel.metrics.map(normalizeMetric);
export default {
[types.REQUEST_METRICS_DATA](state) {
@@ -9,13 +11,19 @@ export default {
},
[types.RECEIVE_METRICS_DATA_SUCCESS](state, groupData) {
state.groups = groupData.map(group => {
- let { metrics } = group;
+ let { metrics = [], panels = [] } = group;
+
+ // each panel has metric information that needs to be normalized
+ panels = panels.map(panel => ({
+ ...panel,
+ metrics: normalizePanel(panel),
+ }));
// for backwards compatibility, and to limit Vue template changes:
// for each group alias panels to metrics
// for each panel alias metrics to queries
if (state.useDashboardEndpoint) {
- metrics = group.panels.map(panel => ({
+ metrics = panels.map(panel => ({
...panel,
queries: panel.metrics,
}));
@@ -23,6 +31,7 @@ export default {
return {
...group,
+ panels,
metrics: normalizeMetrics(sortMetrics(metrics)),
};
});
@@ -80,9 +89,6 @@ export default {
[types.SET_DASHBOARD_ENABLED](state, enabled) {
state.useDashboardEndpoint = enabled;
},
- [types.SET_MULTIPLE_DASHBOARDS_ENABLED](state, enabled) {
- state.multipleDashboardsEnabled = enabled;
- },
[types.SET_GETTING_STARTED_EMPTY_STATE](state) {
state.emptyState = 'gettingStarted';
},
diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js
index 440bdc951e0..e894e988f6a 100644
--- a/app/assets/javascripts/monitoring/stores/state.js
+++ b/app/assets/javascripts/monitoring/stores/state.js
@@ -8,7 +8,6 @@ export default () => ({
deploymentsEndpoint: null,
dashboardEndpoint: invalidUrl,
useDashboardEndpoint: false,
- multipleDashboardsEnabled: false,
additionalPanelTypesEnabled: false,
emptyState: 'gettingStarted',
showEmptyState: true,
diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js
index 938ee2f0a9a..a19829f0c65 100644
--- a/app/assets/javascripts/monitoring/stores/utils.js
+++ b/app/assets/javascripts/monitoring/stores/utils.js
@@ -63,6 +63,25 @@ export function groupQueriesByChartInfo(metrics) {
return Object.values(metricsByChart);
}
+export const uniqMetricsId = metric => `${metric.metric_id}_${metric.id}`;
+
+/**
+ * Not to confuse with normalizeMetrics (plural)
+ * Metrics loaded from project-defined dashboards do not have a metric_id.
+ * This method creates a unique ID combining metric_id and id, if either is present.
+ * This is hopefully a temporary solution until BE processes metrics before passing to fE
+ * @param {Object} metric - metric
+ * @returns {Object} - normalized metric with a uniqueID
+ */
+export const normalizeMetric = (metric = {}) =>
+ _.omit(
+ {
+ ...metric,
+ metric_id: uniqMetricsId(metric),
+ },
+ 'id',
+ );
+
export const sortMetrics = metrics =>
_.chain(metrics)
.sortBy('title')
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
index 46b01f753f8..4c72f5226b7 100644
--- a/app/assets/javascripts/monitoring/utils.js
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -1,4 +1,7 @@
-import { secondsIn, timeWindowsKeyNames } from './constants';
+import dateformat from 'dateformat';
+import { secondsIn, dateTimePickerRegex, dateFormats } from './constants';
+
+const secondsToMilliseconds = seconds => seconds * 1000;
export const getTimeDiff = timeWindow => {
const end = Math.floor(Date.now() / 1000); // convert milliseconds to seconds
@@ -6,18 +9,60 @@ export const getTimeDiff = timeWindow => {
const start = end - difference;
return {
- start: new Date(start * 1000).toISOString(),
- end: new Date(end * 1000).toISOString(),
+ start: new Date(secondsToMilliseconds(start)).toISOString(),
+ end: new Date(secondsToMilliseconds(end)).toISOString(),
};
};
export const getTimeWindow = ({ start, end }) =>
Object.entries(secondsIn).reduce((acc, [timeRange, value]) => {
- if (end - start === value) {
+ if (new Date(end) - new Date(start) === secondsToMilliseconds(value)) {
return timeRange;
}
return acc;
- }, timeWindowsKeyNames.eightHours);
+ }, null);
+
+export const isDateTimePickerInputValid = val => dateTimePickerRegex.test(val);
+
+export const truncateZerosInDateTime = datetime => datetime.replace(' 00:00:00', '');
+
+/**
+ * The URL params start and end need to be validated
+ * before passing them down to other components.
+ *
+ * @param {string} dateString
+ */
+export const isValidDate = dateString => {
+ try {
+ // dateformat throws error that can be caught.
+ // This is better than using `new Date()`
+ if (dateString && dateString.trim()) {
+ dateformat(dateString, 'isoDateTime');
+ return true;
+ }
+ return false;
+ } catch {
+ return false;
+ }
+};
+
+/**
+ * Convert the input in Time picker component to ISO date.
+ *
+ * @param {string} val
+ * @returns {string}
+ */
+export const stringToISODate = val =>
+ dateformat(new Date(val.replace(/-/g, '/')), dateFormats.dateTimePicker.ISODate, true);
+
+/**
+ * Convert the ISO date received from the URL to string
+ * for the Time picker component.
+ *
+ * @param {Date} date
+ * @returns {string}
+ */
+export const ISODateToString = date => dateformat(date, dateFormats.dateTimePicker.stringDate);
/**
* This method is used to validate if the graph data format for a chart component
@@ -43,4 +88,47 @@ export const graphDataValidatorForValues = (isValues, graphData) => {
);
};
+/* eslint-disable @gitlab/i18n/no-non-i18n-strings */
+/**
+ * Checks that element that triggered event is located on cluster health check dashboard
+ * @param {HTMLElement} element to check against
+ * @returns {boolean}
+ */
+const isClusterHealthBoard = () => (document.body.dataset.page || '').includes(':clusters:show');
+
+/**
+ * Tracks snowplow event when user generates link to metric chart
+ * @param {String} chart link that will be sent as a property for the event
+ * @return {Object} config object for event tracking
+ */
+export const generateLinkToChartOptions = chartLink => {
+ const isCLusterHealthBoard = isClusterHealthBoard();
+
+ const category = isCLusterHealthBoard
+ ? 'Cluster Monitoring'
+ : 'Incident Management::Embedded metrics';
+ const action = isCLusterHealthBoard
+ ? 'generate_link_to_cluster_metric_chart'
+ : 'generate_link_to_metrics_chart';
+
+ return { category, action, label: 'Chart link', property: chartLink };
+};
+
+/**
+ * Tracks snowplow event when user downloads CSV of cluster metric
+ * @param {String} chart title that will be sent as a property for the event
+ */
+export const downloadCSVOptions = title => {
+ const isCLusterHealthBoard = isClusterHealthBoard();
+
+ const category = isCLusterHealthBoard
+ ? 'Cluster Monitoring'
+ : 'Incident Management::Embedded metrics';
+ const action = isCLusterHealthBoard
+ ? 'download_csv_of_cluster_metric_chart'
+ : 'download_csv_of_metrics_dashboard_chart';
+
+ return { category, action, label: 'Chart title', property: title };
+};
+
export default {};
diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js
index 4ddbec71ba6..8671f0fd783 100644
--- a/app/assets/javascripts/namespace_select.js
+++ b/app/assets/javascripts/namespace_select.js
@@ -1,6 +1,7 @@
-/* eslint-disable func-names, no-else-return, prefer-template, prefer-arrow-callback */
+/* eslint-disable no-else-return */
import $ from 'jquery';
+import '~/gl_dropdown';
import Api from './api';
import { mergeUrlParams } from './lib/utils/url_utility';
import { parseBoolean } from '~/lib/utils/common_utils';
@@ -23,11 +24,11 @@ export default class NamespaceSelect {
if (selected.id == null) {
return selected.text;
} else {
- return selected.kind + ': ' + selected.full_path;
+ return `${selected.kind}: ${selected.full_path}`;
}
},
data(term, dataCallback) {
- return Api.namespaces(term, function(namespaces) {
+ return Api.namespaces(term, namespaces => {
if (isFilter) {
const anyNamespace = {
text: __('Any namespace'),
@@ -43,7 +44,7 @@ export default class NamespaceSelect {
if (namespace.id == null) {
return namespace.text;
} else {
- return namespace.kind + ': ' + namespace.full_path;
+ return `${namespace.kind}: ${namespace.full_path}`;
}
},
renderRow: this.renderRow,
diff --git a/app/assets/javascripts/network/branch_graph.js b/app/assets/javascripts/network/branch_graph.js
index fcfc2570b3d..a0ba2193d90 100644
--- a/app/assets/javascripts/network/branch_graph.js
+++ b/app/assets/javascripts/network/branch_graph.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, one-var, no-loop-func, consistent-return, prefer-template, prefer-arrow-callback, camelcase */
+/* eslint-disable func-names, no-var, one-var, no-loop-func, consistent-return, camelcase */
import $ from 'jquery';
import { __ } from '../locale';
@@ -223,7 +223,7 @@ export default (function() {
shortrefs = commit.refs;
// Truncate if longer than 15 chars
if (shortrefs.length > 17) {
- shortrefs = shortrefs.substr(0, 15) + '…';
+ shortrefs = `${shortrefs.substr(0, 15)}…`;
}
text = r.text(x + 4, y, shortrefs).attr({
'text-anchor': 'start',
@@ -259,9 +259,7 @@ export default (function() {
opacity: 0,
cursor: 'pointer',
})
- .click(function() {
- return window.open(options.commit_url.replace('%s', commit.id), '_blank');
- })
+ .click(() => window.open(options.commit_url.replace('%s', commit.id), '_blank'))
.hover(
function() {
this.tooltip = r.commitTooltip(x + 5, y, commit);
diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js
index 98522c67696..9f9db21d65b 100644
--- a/app/assets/javascripts/new_branch_form.js
+++ b/app/assets/javascripts/new_branch_form.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, one-var, consistent-return, no-return-assign, prefer-arrow-callback, prefer-template, no-shadow, no-else-return, @gitlab/i18n/no-non-i18n-strings */
+/* eslint-disable func-names, no-var, one-var, consistent-return, no-return-assign, no-shadow, no-else-return, @gitlab/i18n/no-non-i18n-strings */
import $ from 'jquery';
import RefSelectDropdown from './ref_select_dropdown';
@@ -63,17 +63,17 @@ export default class NewBranchForm {
};
formatter = function(values, restriction) {
var formatted;
- formatted = values.map(function(value) {
+ formatted = values.map(value => {
switch (false) {
case !/\s/.test(value):
return 'spaces';
case !/\/{2,}/g.test(value):
return 'consecutive slashes';
default:
- return "'" + value + "'";
+ return `'${value}'`;
}
});
- return restriction.prefix + ' ' + formatted.join(restriction.conjunction);
+ return `${restriction.prefix} ${formatted.join(restriction.conjunction)}`;
};
validator = (function(_this) {
return function(errors, restriction) {
diff --git a/app/assets/javascripts/notebook/cells/code.vue b/app/assets/javascripts/notebook/cells/code.vue
index eefc801ed7a..1782e5bfe5a 100644
--- a/app/assets/javascripts/notebook/cells/code.vue
+++ b/app/assets/javascripts/notebook/cells/code.vue
@@ -49,6 +49,7 @@ export default {
v-if="hasOutput"
:count="cell.execution_count"
:outputs="outputs"
+ :metadata="cell.metadata"
:code-css-class="codeCssClass"
/>
</div>
diff --git a/app/assets/javascripts/notebook/cells/code/index.vue b/app/assets/javascripts/notebook/cells/code/index.vue
index 98b6cdd0944..470d8c87d59 100644
--- a/app/assets/javascripts/notebook/cells/code/index.vue
+++ b/app/assets/javascripts/notebook/cells/code/index.vue
@@ -26,6 +26,10 @@ export default {
type: String,
required: true,
},
+ metadata: {
+ type: Object,
+ default: () => ({}),
+ },
},
computed: {
code() {
@@ -36,6 +40,12 @@ export default {
return type.charAt(0).toUpperCase() + type.slice(1);
},
+ cellCssClass() {
+ return {
+ [this.codeCssClass]: true,
+ 'jupyter-notebook-scrolled': this.metadata.scrolled,
+ };
+ },
},
mounted() {
Prism.highlightElement(this.$refs.code);
@@ -46,6 +56,6 @@ export default {
<template>
<div :class="type">
<prompt :type="promptType" :count="count" />
- <pre ref="code" :class="codeCssClass" class="language-python" v-text="code"></pre>
+ <pre ref="code" :class="cellCssClass" class="language-python" v-text="code"></pre>
</div>
</template>
diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue
index b59ddd0d57a..d8b0e099bc4 100644
--- a/app/assets/javascripts/notebook/cells/output/index.vue
+++ b/app/assets/javascripts/notebook/cells/output/index.vue
@@ -19,6 +19,10 @@ export default {
type: Array,
required: true,
},
+ metadata: {
+ type: Object,
+ default: () => ({}),
+ },
},
methods: {
outputType(output) {
@@ -78,6 +82,7 @@ export default {
:count="count"
:index="index"
:raw-code="rawCode(output)"
+ :metadata="metadata"
:code-css-class="codeCssClass"
/>
</div>
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 9cc56b34c75..3715a91d599 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -1,7 +1,7 @@
/* eslint-disable no-restricted-properties, func-names, no-var, camelcase,
no-unused-expressions, one-var, default-case,
-prefer-template, consistent-return, no-alert, no-return-assign,
-no-param-reassign, prefer-arrow-callback, no-else-return, vars-on-top,
+consistent-return, no-alert, no-return-assign,
+no-param-reassign, no-else-return, vars-on-top,
no-shadow, no-useless-escape, class-methods-use-this */
/* global ResolveService */
@@ -490,7 +490,7 @@ export default class Notes {
diffAvatarContainer = row
.prevAll('.line_holder')
.first()
- .find('.js-avatar-container.' + lineType + '_line');
+ .find(`.js-avatar-container.${lineType}_line`);
// is this the first note of discussion?
discussionContainer = $(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`);
if (!discussionContainer.length) {
@@ -506,16 +506,14 @@ export default class Notes {
} else {
// Merge new discussion HTML in
var $notes = $discussion.find(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`);
- var contentContainerClass =
- '.' +
- $notes
- .closest('.notes-content')
- .attr('class')
- .split(' ')
- .join('.');
+ var contentContainerClass = $notes
+ .closest('.notes-content')
+ .attr('class')
+ .split(' ')
+ .join('.');
row
- .find(contentContainerClass + ' .content')
+ .find(`.${contentContainerClass} .content`)
.append($notes.closest('.content').children());
}
} else {
@@ -722,7 +720,7 @@ export default class Notes {
this.revertNoteEditForm($targetNote);
$noteEntityEl.renderGFM();
// Find the note's `li` element by ID and replace it with the updated HTML
- $note_li = $('.note-row-' + noteEntity.id);
+ $note_li = $(`.note-row-${noteEntity.id}`);
$note_li.replaceWith($noteEntityEl);
this.setupNewNote($noteEntityEl);
@@ -1370,7 +1368,7 @@ export default class Notes {
.find('li.system-note')
.has('ul');
- $.each(systemNotes, function(index, systemNote) {
+ $.each(systemNotes, (index, systemNote) => {
const $systemNote = $(systemNote);
const headerMessage = $systemNote
.find('.note-text')
@@ -1461,6 +1459,7 @@ export default class Notes {
getFormData($form) {
const content = $form.find('.js-note-text').val();
return {
+ // eslint-disable-next-line no-jquery/no-serialize
formData: $form.serialize(),
formContent: _.escape(content),
formAction: $form.attr('action'),
diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue
index 6bbf2fa6ee4..fad1bc67be7 100644
--- a/app/assets/javascripts/notes/components/discussion_actions.vue
+++ b/app/assets/javascripts/notes/components/discussion_actions.vue
@@ -58,6 +58,7 @@ export default {
<div class="btn-group">
<resolve-discussion-button
v-if="discussion.resolvable"
+ data-qa-selector="resolve_discussion_button"
:is-resolving="isResolving"
:button-title="resolveButtonTitle"
@onClick="$emit('resolve')"
diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue
index 743684e7046..6b1e3298f9a 100644
--- a/app/assets/javascripts/notes/components/discussion_filter.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter.vue
@@ -1,13 +1,14 @@
<script>
import $ from 'jquery';
import { mapGetters, mapActions } from 'vuex';
-import { getLocationHash } from '../../lib/utils/url_utility';
+import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility';
import Icon from '~/vue_shared/components/icon.vue';
import {
DISCUSSION_FILTERS_DEFAULT_VALUE,
HISTORY_ONLY_FILTER_VALUE,
DISCUSSION_TAB_LABEL,
DISCUSSION_FILTER_TYPES,
+ NOTE_UNDERSCORE,
} from '../constants';
import notesEventHub from '../event_hub';
@@ -28,7 +29,9 @@ export default {
},
data() {
return {
- currentValue: this.selectedValue,
+ currentValue: doesHashExistInUrl(NOTE_UNDERSCORE)
+ ? DISCUSSION_FILTERS_DEFAULT_VALUE
+ : this.selectedValue,
defaultValue: DISCUSSION_FILTERS_DEFAULT_VALUE,
displayFilters: true,
};
@@ -50,7 +53,6 @@ export default {
notesEventHub.$on('dropdownSelect', this.selectFilter);
window.addEventListener('hashchange', this.handleLocationHash);
- this.handleLocationHash();
},
mounted() {
this.toggleCommentsForm();
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index 6cc873359da..89d434a60ba 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -149,9 +149,9 @@ export default {
title="Add reaction"
data-position="right"
>
- <icon css-classes="link-highlight award-control-icon-neutral" name="slight-smile" />
- <icon css-classes="link-highlight award-control-icon-positive" name="smiley" />
- <icon css-classes="link-highlight award-control-icon-super-positive" name="smiley" />
+ <icon class="link-highlight award-control-icon-neutral" name="slight-smile" />
+ <icon class="link-highlight award-control-icon-positive" name="smiley" />
+ <icon class="link-highlight award-control-icon-super-positive" name="smiley" />
</a>
</div>
<reply-button
@@ -168,7 +168,7 @@ export default {
class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button"
@click="onEdit"
>
- <icon name="pencil" css-classes="link-highlight" />
+ <icon name="pencil" class="link-highlight" />
</button>
</div>
<div v-if="showDeleteAction" class="note-actions-item">
@@ -191,7 +191,7 @@ export default {
data-toggle="dropdown"
@click="closeTooltip"
>
- <icon css-classes="icon" name="ellipsis_v" />
+ <icon class="icon" name="ellipsis_v" />
</button>
<ul class="dropdown-menu more-actions-dropdown dropdown-open-left">
<li v-if="canReportAsAbuse">
diff --git a/app/assets/javascripts/notes/components/note_actions/reply_button.vue b/app/assets/javascripts/notes/components/note_actions/reply_button.vue
index 1aeb07d6608..20551279aec 100644
--- a/app/assets/javascripts/notes/components/note_actions/reply_button.vue
+++ b/app/assets/javascripts/notes/components/note_actions/reply_button.vue
@@ -19,12 +19,14 @@ export default {
<gl-button
ref="button"
v-gl-tooltip
- class="note-action-button js-note-action-reply"
+ class="note-action-button"
+ data-track-event="click_button"
+ data-track-label="reply_comment_button"
variant="transparent"
:title="__('Reply to comment')"
@click="$emit('startReplying')"
>
- <icon name="comment" css-classes="link-highlight" />
+ <icon name="comment" class="link-highlight" />
</gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index ac743d9f4b8..cb1975a8962 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -306,7 +306,11 @@ export default {
<template>
<timeline-entry-item class="note note-discussion">
<div class="timeline-content">
- <div :data-discussion-id="discussion.id" class="discussion js-discussion-container">
+ <div
+ :data-discussion-id="discussion.id"
+ class="discussion js-discussion-container"
+ data-qa-selector="discussion_content"
+ >
<div v-if="shouldRenderDiffs" class="discussion-header note-wrapper">
<div v-once class="timeline-icon align-self-start flex-shrink-0">
<user-avatar-link
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 16a0fb3f33a..c6c97489e5e 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -1,7 +1,7 @@
<script>
import { __ } from '~/locale';
import { mapGetters, mapActions } from 'vuex';
-import { getLocationHash } from '../../lib/utils/url_utility';
+import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility';
import Flash from '../../flash';
import * as constants from '../constants';
import eventHub from '../event_hub';
@@ -69,6 +69,7 @@ export default {
'commentsDisabled',
'getNoteableData',
'userCanReply',
+ 'discussionTabCounter',
]),
noteableType() {
return this.noteableData.noteableType;
@@ -95,13 +96,13 @@ export default {
}
},
allDiscussions() {
- if (this.discussonsCount) {
- this.discussonsCount.textContent = this.allDiscussions.length;
+ if (this.discussionsCount && !this.isLoading) {
+ this.discussionsCount.textContent = this.discussionTabCounter;
}
},
},
created() {
- this.discussonsCount = document.querySelector('.js-discussions-count');
+ this.discussionsCount = document.querySelector('.js-discussions-count');
this.setNotesData(this.notesData);
this.setNoteableData(this.noteableData);
@@ -155,19 +156,17 @@ export default {
this.isFetching = true;
- return this.fetchDiscussions({ path: this.getNotesDataByProp('discussionsPath') })
- .then(() => {
- this.initPolling();
- })
+ return this.fetchDiscussions(this.getFetchDiscussionsConfig())
+ .then(this.initPolling)
.then(() => {
this.setLoadingState(false);
this.setNotesFetchedState(true);
eventHub.$emit('fetchedNotesData');
this.isFetching = false;
})
- .then(() => this.$nextTick())
- .then(() => this.startTaskList())
- .then(() => this.checkLocationHash())
+ .then(this.$nextTick)
+ .then(this.startTaskList)
+ .then(this.checkLocationHash)
.catch(() => {
this.setLoadingState(false);
this.setNotesFetchedState(true);
@@ -198,9 +197,20 @@ export default {
},
startReplying(discussionId) {
return this.convertToDiscussion(discussionId)
- .then(() => this.$nextTick())
+ .then(this.$nextTick)
.then(() => eventHub.$emit('startReplying', discussionId));
},
+ getFetchDiscussionsConfig() {
+ const defaultConfig = { path: this.getNotesDataByProp('discussionsPath') };
+
+ if (doesHashExistInUrl(constants.NOTE_UNDERSCORE)) {
+ return Object.assign({}, defaultConfig, {
+ filter: constants.DISCUSSION_FILTERS_DEFAULT_VALUE,
+ persistFilter: false,
+ });
+ }
+ return defaultConfig;
+ },
},
systemNote: constants.SYSTEM_NOTE,
};
diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js
index bdfb6b8f105..68c117183a1 100644
--- a/app/assets/javascripts/notes/constants.js
+++ b/app/assets/javascripts/notes/constants.js
@@ -8,8 +8,6 @@ export const OPENED = 'opened';
export const REOPENED = 'reopened';
export const CLOSED = 'closed';
export const MERGED = 'merged';
-export const EMOJI_THUMBSUP = 'thumbsup';
-export const EMOJI_THUMBSDOWN = 'thumbsdown';
export const ISSUE_NOTEABLE_TYPE = 'issue';
export const EPIC_NOTEABLE_TYPE = 'epic';
export const MERGE_REQUEST_NOTEABLE_TYPE = 'MergeRequest';
@@ -19,6 +17,7 @@ export const DESCRIPTION_TYPE = 'changed the description';
export const HISTORY_ONLY_FILTER_VALUE = 2;
export const DISCUSSION_FILTERS_DEFAULT_VALUE = 0;
export const DISCUSSION_TAB_LABEL = 'show';
+export const NOTE_UNDERSCORE = 'note_';
export const NOTEABLE_TYPE_MAPPING = {
Issue: ISSUE_NOTEABLE_TYPE,
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
index c70c0e4095c..30372103590 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -1,5 +1,4 @@
import Vue from 'vue';
-import initNoteStats from 'ee_else_ce/event_tracking/notes';
import notesApp from './components/notes_app.vue';
import initDiscussionFilters from './discussion_filters';
import createStore from './stores';
@@ -39,9 +38,6 @@ document.addEventListener('DOMContentLoaded', () => {
notesData: JSON.parse(notesDataset.notesData),
};
},
- mounted() {
- initNoteStats();
- },
render(createElement) {
return createElement('notes-app', {
props: {
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 6c236981a24..004035ea1d4 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -251,58 +251,80 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
}
}
- return dispatch(methodToDispatch, postData, { root: true }).then(res => {
+ const processErrors = res => {
const { errors } = res;
- const commandsChanges = res.commands_changes;
+ if (!errors || !Object.keys(errors).length) {
+ return res;
+ }
- if (errors && Object.keys(errors).length) {
- /*
- The following reply means that quick actions have been successfully applied:
+ /*
+ The following reply means that quick actions have been successfully applied:
- {"commands_changes":{},"valid":false,"errors":{"commands_only":["Commands applied"]}}
- */
- if (hasQuickActions) {
- eTagPoll.makeRequest();
+ {"commands_changes":{},"valid":false,"errors":{"commands_only":["Commands applied"]}}
+ */
+ if (hasQuickActions) {
+ eTagPoll.makeRequest();
- $('.js-gfm-input').trigger('clear-commands-cache.atwho');
- Flash(__('Commands applied'), 'notice', noteData.flashContainer);
- } else {
- throw new Error(__('Failed to save comment!'));
- }
+ $('.js-gfm-input').trigger('clear-commands-cache.atwho');
+
+ const { commands_only: message } = errors;
+ Flash(message || __('Commands applied'), 'notice', noteData.flashContainer);
+
+ return res;
}
- if (commandsChanges) {
- if (commandsChanges.emoji_award) {
- const votesBlock = $('.js-awards-block').eq(0);
-
- loadAwardsHandler()
- .then(awardsHandler => {
- awardsHandler.addAwardToEmojiBar(votesBlock, commandsChanges.emoji_award);
- awardsHandler.scrollToAwards();
- })
- .catch(() => {
- Flash(
- __('Something went wrong while adding your award. Please try again.'),
- 'alert',
- noteData.flashContainer,
- );
- });
- }
+ throw new Error(__('Failed to save comment!'));
+ };
- if (commandsChanges.spend_time != null || commandsChanges.time_estimate != null) {
- sidebarTimeTrackingEventHub.$emit('timeTrackingUpdated', res);
- }
+ const processEmojiAward = res => {
+ const { commands_changes: commandsChanges } = res;
+ const { emoji_award: emojiAward } = commandsChanges || {};
+ if (!emojiAward) {
+ return res;
}
- if (errors && errors.commands_only) {
- Flash(errors.commands_only, 'notice', noteData.flashContainer);
+ const votesBlock = $('.js-awards-block').eq(0);
+
+ return loadAwardsHandler()
+ .then(awardsHandler => {
+ awardsHandler.addAwardToEmojiBar(votesBlock, emojiAward);
+ awardsHandler.scrollToAwards();
+ })
+ .catch(() => {
+ Flash(
+ __('Something went wrong while adding your award. Please try again.'),
+ 'alert',
+ noteData.flashContainer,
+ );
+ })
+ .then(() => res);
+ };
+
+ const processTimeTracking = res => {
+ const { commands_changes: commandsChanges } = res;
+ const { spend_time: spendTime, time_estimate: timeEstimate } = commandsChanges || {};
+ if (spendTime != null || timeEstimate != null) {
+ sidebarTimeTrackingEventHub.$emit('timeTrackingUpdated', {
+ commands_changes: commandsChanges,
+ });
}
+
+ return res;
+ };
+
+ const removePlaceholder = res => {
if (replyId) {
commit(types.REMOVE_PLACEHOLDER_NOTES);
}
return res;
- });
+ };
+
+ return dispatch(methodToDispatch, postData, { root: true })
+ .then(processErrors)
+ .then(processEmojiAward)
+ .then(processTimeTracking)
+ .then(removePlaceholder);
};
const pollSuccessCallBack = (resp, commit, state, getters, dispatch) => {
@@ -430,10 +452,13 @@ export const updateResolvableDiscussionsCounts = ({ commit }) =>
export const submitSuggestion = (
{ commit, dispatch },
{ discussionId, noteId, suggestionId, flashContainer },
-) =>
- Api.applySuggestion(suggestionId)
+) => {
+ const dispatchResolveDiscussion = () =>
+ dispatch('resolveDiscussion', { discussionId }).catch(() => {});
+
+ return Api.applySuggestion(suggestionId)
.then(() => commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId }))
- .then(() => dispatch('resolveDiscussion', { discussionId }).catch(() => {}))
+ .then(dispatchResolveDiscussion)
.catch(err => {
const defaultMessage = __(
'Something went wrong while applying the suggestion. Please try again.',
@@ -442,6 +467,7 @@ export const submitSuggestion = (
Flash(__(flashMessage), 'alert', flashContainer);
});
+};
export const convertToDiscussion = ({ commit }, noteId) =>
commit(types.CONVERT_TO_DISCUSSION, noteId);
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index fa44ef2d057..e70f0238316 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -33,10 +33,11 @@ export default {
},
[types.ADD_NEW_REPLY_TO_DISCUSSION](state, note) {
- const noteObj = utils.findNoteObjectById(state.discussions, note.discussion_id);
+ const discussion = utils.findNoteObjectById(state.discussions, note.discussion_id);
+ const existingNote = discussion && utils.findNoteObjectById(discussion.notes, note.id);
- if (noteObj) {
- noteObj.notes.push(note);
+ if (discussion && !existingNote) {
+ discussion.notes.push(note);
}
},
diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js
index 386a9b2c740..46e80ba72e3 100644
--- a/app/assets/javascripts/pager.js
+++ b/app/assets/javascripts/pager.js
@@ -56,6 +56,10 @@ export default {
$('.content_list').append(html);
if (count > 0) {
this.offset += count;
+
+ if (count < this.limit) {
+ this.disable = true;
+ }
} else {
this.disable = true;
}
diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue
index e2fec3c7172..eb03baf4894 100644
--- a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue
+++ b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue
@@ -1,13 +1,13 @@
<script>
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
-import GlModal from '~/vue_shared/components/gl_modal.vue';
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { redirectTo } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
export default {
components: {
- GlModal,
+ GlModal: DeprecatedModal2,
},
props: {
url: {
diff --git a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
index e8905b479ee..78aaa9df0ec 100644
--- a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
+++ b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
@@ -1,37 +1,46 @@
<script>
import _ from 'underscore';
-import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
+import { GlModal, GlButton, GlFormInput } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
export default {
components: {
- DeprecatedModal,
+ GlModal,
+ GlButton,
+ GlFormInput,
},
props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ content: {
+ type: String,
+ required: true,
+ },
+ action: {
+ type: String,
+ required: true,
+ },
+ secondaryAction: {
+ type: String,
+ required: true,
+ },
deleteUserUrl: {
type: String,
- required: false,
- default: '',
+ required: true,
},
blockUserUrl: {
type: String,
- required: false,
- default: '',
- },
- deleteContributions: {
- type: Boolean,
- required: false,
- default: false,
+ required: true,
},
username: {
type: String,
- required: false,
- default: '',
+ required: true,
},
csrfToken: {
type: String,
- required: false,
- default: '',
+ required: true,
},
},
data() {
@@ -40,32 +49,12 @@ export default {
};
},
computed: {
- title() {
- const keepContributionsTitle = s__('AdminUsers|Delete User %{username}?');
- const deleteContributionsTitle = s__('AdminUsers|Delete User %{username} and contributions?');
-
- return sprintf(
- this.deleteContributions ? deleteContributionsTitle : keepContributionsTitle,
- {
- username: `'${_.escape(this.username)}'`,
- },
- false,
- );
+ modalTitle() {
+ return sprintf(this.title, { username: this.username });
},
text() {
- const keepContributionsText = s__(`AdminArea|
- You are about to permanently delete the user %{username}.
- Issues, merge requests, and groups linked to them will be transferred to a system-wide "Ghost-user".
- To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead.
- Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered.`);
-
- const deleteContributionsText = s__(`AdminArea|
- You are about to permanently delete the user %{username}.
- This will delete all of the issues, merge requests, and groups linked to them.
- To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead.
- Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered.`);
return sprintf(
- this.deleteContributions ? deleteContributionsText : keepContributionsText,
+ this.content,
{
username: `<strong>${_.escape(this.username)}</strong>`,
strong_start: '<strong>',
@@ -83,12 +72,7 @@ export default {
false,
);
},
- primaryButtonLabel() {
- const keepContributionsLabel = s__('AdminUsers|Delete user');
- const deleteContributionsLabel = s__('AdminUsers|Delete user and contributions');
- return this.deleteContributions ? deleteContributionsLabel : keepContributionsLabel;
- },
secondaryButtonLabel() {
return s__('AdminUsers|Block user');
},
@@ -97,8 +81,12 @@ export default {
},
},
methods: {
+ show() {
+ this.$refs.modal.show();
+ },
onCancel() {
this.enteredUsername = '';
+ this.$refs.modal.hide();
},
onSecondaryAction() {
const { form } = this.$refs;
@@ -117,43 +105,28 @@ export default {
</script>
<template>
- <deprecated-modal
- id="delete-user-modal"
- :title="title"
- :text="text"
- :primary-button-label="primaryButtonLabel"
- :secondary-button-label="secondaryButtonLabel"
- :submit-disabled="!canSubmit"
- kind="danger"
- @submit="onSubmit"
- @cancel="onCancel"
- >
- <template slot="body" slot-scope="props">
- <p v-html="props.text"></p>
+ <gl-modal ref="modal" modal-id="delete-user-modal" :title="modalTitle" kind="danger">
+ <template>
+ <p v-html="text"></p>
<p v-html="confirmationTextLabel"></p>
<form ref="form" :action="deleteUserUrl" method="post">
<input ref="method" type="hidden" name="_method" value="delete" />
<input :value="csrfToken" type="hidden" name="authenticity_token" />
- <input
+ <gl-form-input
v-model="enteredUsername"
+ autofocus
type="text"
name="username"
- class="form-control"
- aria-labelledby="input-label"
autocomplete="off"
/>
</form>
</template>
- <template slot="secondary-button">
- <button
- :disabled="!canSubmit"
- type="button"
- class="btn js-secondary-button btn-warning"
- data-dismiss="modal"
- @click="onSecondaryAction"
- >
- {{ secondaryButtonLabel }}
- </button>
+ <template slot="modal-footer">
+ <gl-button variant="secondary" @click="onCancel">{{ s__('Cancel') }}</gl-button>
+ <gl-button :disabled="!canSubmit" variant="warning" @click="onSecondaryAction">
+ {{ secondaryAction }}
+ </gl-button>
+ <gl-button :disabled="!canSubmit" variant="danger" @click="onSubmit">{{ action }}</gl-button>
</template>
- </deprecated-modal>
+ </gl-modal>
</template>
diff --git a/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue b/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue
new file mode 100644
index 00000000000..a08d32028c3
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue
@@ -0,0 +1,77 @@
+<script>
+export default {
+ props: {
+ modalConfiguration: {
+ required: true,
+ type: Object,
+ },
+ actionModals: {
+ required: true,
+ type: Object,
+ },
+ csrfToken: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ currentModalData: null,
+ };
+ },
+ computed: {
+ activeModal() {
+ if (!this.currentModalData) return null;
+ const { glModalAction: action } = this.currentModalData;
+
+ return this.actionModals[action];
+ },
+
+ modalProps() {
+ const { glModalAction: requestedAction } = this.currentModalData;
+ return {
+ ...this.modalConfiguration[requestedAction],
+ ...this.currentModalData,
+ csrfToken: this.csrfToken,
+ };
+ },
+ },
+
+ mounted() {
+ document.addEventListener('click', this.handleClick);
+ },
+
+ beforeDestroy() {
+ document.removeEventListener('click', this.handleClick);
+ },
+
+ methods: {
+ handleClick(e) {
+ const { glModalAction: action } = e.target.dataset;
+ if (!action) return;
+
+ this.show(e.target.dataset);
+ e.preventDefault();
+ },
+
+ show(modalData) {
+ const { glModalAction: requestedAction } = modalData;
+ if (!this.actionModals[requestedAction]) {
+ throw new Error(`Requested non-existing modal action ${requestedAction}`);
+ }
+ if (!this.modalConfiguration[requestedAction]) {
+ throw new Error(`Modal action ${requestedAction} has no configuration in HTML`);
+ }
+
+ this.currentModalData = modalData;
+
+ return this.$nextTick().then(() => {
+ this.$refs.modal.show();
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div :is="activeModal" v-if="activeModal" ref="modal" v-bind="modalProps" />
+</template>
diff --git a/app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue b/app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue
new file mode 100644
index 00000000000..4c335cfb018
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue
@@ -0,0 +1,70 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+import { sprintf } from '~/locale';
+
+export default {
+ components: {
+ GlModal,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ content: {
+ type: String,
+ required: true,
+ },
+ action: {
+ type: String,
+ required: true,
+ },
+ url: {
+ type: String,
+ required: true,
+ },
+ username: {
+ type: String,
+ required: true,
+ },
+ csrfToken: {
+ type: String,
+ required: true,
+ },
+ method: {
+ type: String,
+ required: false,
+ default: 'put',
+ },
+ },
+ computed: {
+ modalTitle() {
+ return sprintf(this.title, { username: this.username });
+ },
+ },
+ methods: {
+ show() {
+ this.$refs.modal.show();
+ },
+ submit() {
+ this.$refs.form.submit();
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ ref="modal"
+ modal-id="user-operation-modal"
+ :title="modalTitle"
+ ok-variant="warning"
+ :ok-title="action"
+ @ok="submit"
+ >
+ <form ref="form" :action="url" method="post">
+ <span v-html="content"></span>
+ <input ref="method" type="hidden" name="_method" :value="method" />
+ <input :value="csrfToken" type="hidden" name="authenticity_token" />
+ </form>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/pages/admin/users/index.js b/app/assets/javascripts/pages/admin/users/index.js
index 45046688b57..bc96e88351b 100644
--- a/app/assets/javascripts/pages/admin/users/index.js
+++ b/app/assets/javascripts/pages/admin/users/index.js
@@ -1,46 +1,65 @@
-import $ from 'jquery';
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
+import ModalManager from './components/user_modal_manager.vue';
+import DeleteUserModal from './components/delete_user_modal.vue';
+import UserOperationConfirmationModal from './components/user_operation_confirmation_modal.vue';
import csrf from '~/lib/utils/csrf';
-import deleteUserModal from './components/delete_user_modal.vue';
+const MODAL_TEXTS_CONTAINER_SELECTOR = '#modal-texts';
+const MODAL_MANAGER_SELECTOR = '#user-modal';
+const ACTION_MODALS = {
+ deactivate: UserOperationConfirmationModal,
+ block: UserOperationConfirmationModal,
+ delete: DeleteUserModal,
+ 'delete-with-contributions': DeleteUserModal,
+};
+
+function loadModalsConfigurationFromHtml(modalsElement) {
+ const modalsConfiguration = {};
+
+ if (!modalsElement) {
+ /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
+ throw new Error('Modals content element not found!');
+ }
+
+ Array.from(modalsElement.children).forEach(node => {
+ const { modal, ...config } = node.dataset;
+ modalsConfiguration[modal] = {
+ title: node.dataset.title,
+ ...config,
+ content: node.innerHTML,
+ };
+ });
+
+ return modalsConfiguration;
+}
document.addEventListener('DOMContentLoaded', () => {
Vue.use(Translate);
- const deleteUserModalEl = document.getElementById('delete-user-modal');
+ const modalConfiguration = loadModalsConfigurationFromHtml(
+ document.querySelector(MODAL_TEXTS_CONTAINER_SELECTOR),
+ );
- const deleteModal = new Vue({
- el: deleteUserModalEl,
- data: {
- deleteUserUrl: '',
- blockUserUrl: '',
- deleteContributions: '',
- username: '',
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: MODAL_MANAGER_SELECTOR,
+ functional: true,
+ methods: {
+ show(...args) {
+ this.$refs.manager.show(...args);
+ },
},
- render(createElement) {
- return createElement(deleteUserModal, {
+ render(h) {
+ return h(ModalManager, {
+ ref: 'manager',
props: {
- deleteUserUrl: this.deleteUserUrl,
- blockUserUrl: this.blockUserUrl,
- deleteContributions: this.deleteContributions,
- username: this.username,
+ modalConfiguration,
+ actionModals: ACTION_MODALS,
csrfToken: csrf.token,
},
});
},
});
-
- $(document).on('shown.bs.modal', event => {
- if (event.relatedTarget.classList.contains('delete-user-button')) {
- const buttonProps = event.relatedTarget.dataset;
- deleteModal.deleteUserUrl = buttonProps.deleteUserUrl;
- deleteModal.blockUserUrl = buttonProps.blockUserUrl;
- deleteModal.deleteContributions = event.relatedTarget.hasAttribute(
- 'data-delete-contributions',
- );
- deleteModal.username = buttonProps.username;
- }
- });
});
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
index d51d411f3c6..5230bdf9cdd 100644
--- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js
+++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
@@ -1,9 +1,11 @@
/* eslint-disable class-methods-use-this, no-unneeded-ternary */
import $ from 'jquery';
+import '~/gl_dropdown';
import { visitUrl } from '~/lib/utils/url_utility';
import UsersSelect from '~/users_select';
import { isMetaClick } from '~/lib/utils/common_utils';
+import { addDelimiter } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
import flash from '~/flash';
import axios from '~/lib/utils/axios_utils';
@@ -145,8 +147,8 @@ export default class Todos {
updateBadges(data) {
$(document).trigger('todo:toggle', data.count);
- document.querySelector('.todos-pending .badge').innerHTML = data.count;
- document.querySelector('.todos-done .badge').innerHTML = data.done_count;
+ document.querySelector('.todos-pending .badge').innerHTML = addDelimiter(data.count);
+ document.querySelector('.todos-done .badge').innerHTML = addDelimiter(data.done_count);
}
goToTodoUrl(e) {
diff --git a/app/assets/javascripts/pages/groups/registry/repositories/index.js b/app/assets/javascripts/pages/groups/registry/repositories/index.js
new file mode 100644
index 00000000000..b663defad0e
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/registry/repositories/index.js
@@ -0,0 +1,3 @@
+import initRegistryImages from '~/registry';
+
+document.addEventListener('DOMContentLoaded', initRegistryImages);
diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js
index 01ef3f1db2b..37b253d7c48 100644
--- a/app/assets/javascripts/pages/groups/shared/group_details.js
+++ b/app/assets/javascripts/pages/groups/shared/group_details.js
@@ -1,6 +1,6 @@
/* eslint-disable no-new */
-import { getPagePath } from '~/lib/utils/common_utils';
+import { getPagePath, getDashPath } from '~/lib/utils/common_utils';
import { ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED } from '~/groups/constants';
import NewGroupChild from '~/groups/new_group_child';
import notificationsDropdown from '~/notifications_dropdown';
@@ -12,9 +12,8 @@ import GroupTabs from './group_tabs';
export default function initGroupDetails(actionName = 'show') {
const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
const loadableActions = [ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED];
- const paths = window.location.pathname.split('/');
- const subpath = paths[paths.length - 1];
- let action = loadableActions.includes(subpath) ? subpath : getPagePath(1);
+ const dashPath = getDashPath();
+ let action = loadableActions.includes(dashPath) ? dashPath : getPagePath(1);
if (actionName && action === actionName) {
action = 'show'; // 'show' resets GroupTabs to default action through base class
}
diff --git a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
index c563514d36b..26adf4cbbe0 100644
--- a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
+++ b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
@@ -1,14 +1,14 @@
<script>
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
-import GlModal from '~/vue_shared/components/gl_modal.vue';
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { s__, sprintf } from '~/locale';
import { visitUrl } from '~/lib/utils/url_utility';
import eventHub from '../event_hub';
export default {
components: {
- GlModal,
+ GlModal: DeprecatedModal2,
},
props: {
milestoneTitle: {
diff --git a/app/assets/javascripts/pages/projects/clusters/new/index.js b/app/assets/javascripts/pages/projects/clusters/new/index.js
index 55aa29c9797..14d5ab21555 100644
--- a/app/assets/javascripts/pages/projects/clusters/new/index.js
+++ b/app/assets/javascripts/pages/projects/clusters/new/index.js
@@ -1,7 +1,13 @@
document.addEventListener('DOMContentLoaded', () => {
if (gon.features.createEksClusters) {
import(/* webpackChunkName: 'eks_cluster' */ '~/create_cluster/eks_cluster')
- .then(({ default: initCreateEKSCluster }) => initCreateEKSCluster())
+ .then(({ default: initCreateEKSCluster }) => {
+ const el = document.querySelector('.js-create-eks-cluster-form-container');
+
+ if (el) {
+ initCreateEKSCluster(el);
+ }
+ })
.catch(() => {});
}
});
diff --git a/app/assets/javascripts/pages/projects/commit/pipelines/index.js b/app/assets/javascripts/pages/projects/commit/pipelines/index.js
index 8cc3cb0a57c..9f08260c3d6 100644
--- a/app/assets/javascripts/pages/projects/commit/pipelines/index.js
+++ b/app/assets/javascripts/pages/projects/commit/pipelines/index.js
@@ -6,6 +6,7 @@ document.addEventListener('DOMContentLoaded', () => {
new MiniPipelineGraph({
container: '.js-commit-pipeline-graph',
}).bindEvents();
+ // eslint-disable-next-line no-jquery/no-load
$('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath);
initPipelines();
});
diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js
index 6fc982967eb..5aa4734244e 100644
--- a/app/assets/javascripts/pages/projects/commit/show/index.js
+++ b/app/assets/javascripts/pages/projects/commit/show/index.js
@@ -21,6 +21,7 @@ document.addEventListener('DOMContentLoaded', () => {
}).bindEvents();
initNotes();
initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight + performanceHeight);
+ // eslint-disable-next-line no-jquery/no-load
$('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath);
fetchCommitMergeRequests();
initDiffNotes();
diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js
index 76613394af6..5b873e6b909 100644
--- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js
+++ b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, one-var, camelcase, no-param-reassign, prefer-template, no-return-assign */
+/* eslint-disable func-names, no-var, one-var, camelcase, no-param-reassign, no-return-assign */
import $ from 'jquery';
import _ from 'underscore';
@@ -66,8 +66,8 @@ export default (function() {
class: 'person',
style: 'display: block;',
});
- author_name = $('<h4>' + author.author_name + '</h4>');
- author_email = $('<p class="graph-author-email">' + author.author_email + '</p>');
+ author_name = $(`<h4>${author.author_name}</h4>`);
+ author_email = $(`<p class="graph-author-email">${author.author_email}</p>`);
author_commit_info_span = $('<span/>', {
class: 'commits',
});
diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js
index 506e6075d16..86794800f87 100644
--- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js
+++ b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-restricted-syntax, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, no-return-assign, prefer-arrow-callback, prefer-template, no-else-return, no-shadow */
+/* eslint-disable func-names, no-restricted-syntax, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, no-return-assign, no-else-return, no-shadow */
import $ from 'jquery';
import _ from 'underscore';
@@ -69,24 +69,18 @@ export const ContributorsGraph = (function() {
ContributorsGraph.set_y_domain = function(data) {
return (ContributorsGraph.prototype.y_domain = [
0,
- d3.max(data, function(d) {
- return (d.commits = d.commits || d.additions || d.deletions);
- }),
+ d3.max(data, d => (d.commits = d.commits || d.additions || d.deletions)),
]);
};
ContributorsGraph.init_x_domain = function(data) {
- return (ContributorsGraph.prototype.x_domain = d3.extent(data, function(d) {
- return d.date;
- }));
+ return (ContributorsGraph.prototype.x_domain = d3.extent(data, d => d.date));
};
ContributorsGraph.init_y_domain = function(data) {
return (ContributorsGraph.prototype.y_domain = [
0,
- d3.max(data, function(d) {
- return (d.commits = d.commits || d.additions || d.deletions);
- }),
+ d3.max(data, d => (d.commits = d.commits || d.additions || d.deletions)),
]);
};
@@ -124,14 +118,11 @@ export const ContributorsGraph = (function() {
};
ContributorsGraph.prototype.draw_x_axis = function() {
- return (
- this.svg
- .append('g')
- .attr('class', 'x axis')
- /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
- .attr('transform', 'translate(0, ' + this.height + ')')
- .call(this.x_axis)
- );
+ return this.svg
+ .append('g')
+ .attr('class', 'x axis')
+ .attr('transform', `translate(0, ${this.height})`)
+ .call(this.x_axis);
};
ContributorsGraph.prototype.draw_y_axis = function() {
@@ -180,9 +171,7 @@ export const ContributorsMasterGraph = (function(superClass) {
ContributorsMasterGraph.prototype.parse_dates = function(data) {
const parseDate = d3.timeParse('%Y-%m-%d');
- return data.forEach(function(d) {
- return (d.date = parseDate(d.date));
- });
+ return data.forEach(d => (d.date = parseDate(d.date)));
};
ContributorsMasterGraph.prototype.create_scale = function() {
@@ -208,19 +197,16 @@ export const ContributorsMasterGraph = (function(superClass) {
.attr('height', this.height + this.MARGIN.top + this.MARGIN.bottom)
.attr('class', 'tint-box')
.append('g')
- /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
- .attr('transform', 'translate(' + this.MARGIN.left + ',' + this.MARGIN.top + ')');
+ .attr('transform', `translate(${this.MARGIN.left},${this.MARGIN.top})`);
return this.svg;
};
ContributorsMasterGraph.prototype.create_area = function(x, y) {
return (this.area = d3
.area()
- .x(function(d) {
- return x(d.date);
- })
+ .x(d => x(d.date))
.y0(this.height)
- .y1(function(d) {
+ .y1(d => {
d.commits = d.commits || d.additions || d.deletions;
return y(d.commits);
}));
@@ -330,7 +316,7 @@ export const ContributorsAuthorGraph = (function(superClass) {
ContributorsAuthorGraph.prototype.create_area = function(x, y) {
return (this.area = d3
.area()
- .x(function(d) {
+ .x(d => {
const parseDate = d3.timeParse('%Y-%m-%d');
return x(parseDate(d));
})
@@ -358,8 +344,7 @@ export const ContributorsAuthorGraph = (function(superClass) {
.attr('height', this.height + this.MARGIN.top + this.MARGIN.bottom)
.attr('class', 'spark')
.append('g')
- /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
- .attr('transform', 'translate(' + this.MARGIN.left + ',' + this.MARGIN.top + ')');
+ .attr('transform', `translate(${this.MARGIN.left},${this.MARGIN.top})`);
return this.svg;
};
diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js
index 505ca938f40..a89a13fe37a 100644
--- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js
+++ b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, one-var, camelcase, no-param-reassign, no-return-assign, prefer-arrow-callback, consistent-return, no-cond-assign, no-else-return */
+/* eslint-disable func-names, no-var, one-var, camelcase, no-param-reassign, no-return-assign, consistent-return, no-cond-assign, no-else-return */
import _ from 'underscore';
export default {
@@ -76,16 +76,12 @@ export default {
var log, total_data;
log = parsed_log.total;
total_data = this.pick_field(log, field);
- return _.sortBy(total_data, function(d) {
- return d.date;
- });
+ return _.sortBy(total_data, d => d.date);
},
pick_field(log, field) {
var total_data;
total_data = [];
- _.each(log, function(d) {
- return total_data.push(_.pick(d, [field, 'date']));
- });
+ _.each(log, d => total_data.push(_.pick(d, [field, 'date'])));
return total_data;
},
get_author_data(parsed_log, field, date_range) {
@@ -107,9 +103,7 @@ export default {
};
})(this),
);
- return _.sortBy(author_data, function(d) {
- return d[field];
- }).reverse();
+ return _.sortBy(author_data, d => d[field]).reverse();
},
parse_log_entry(log_entry, field, date_range) {
var parsed_entry;
diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/pages/projects/issues/form.js
index 2205a7bafe3..96e47187fed 100644
--- a/app/assets/javascripts/pages/projects/issues/form.js
+++ b/app/assets/javascripts/pages/projects/issues/form.js
@@ -15,7 +15,9 @@ export default () => {
new IssuableForm($('.issue-form'));
new LabelsSelect();
new MilestoneSelect();
- new IssuableTemplateSelectors();
+ new IssuableTemplateSelectors({
+ warnTemplateOverride: true,
+ });
initSuggestions();
};
diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js
index 0447d1f79fb..28a136a5fa5 100644
--- a/app/assets/javascripts/pages/projects/issues/show.js
+++ b/app/assets/javascripts/pages/projects/issues/show.js
@@ -5,6 +5,7 @@ import ZenMode from '~/zen_mode';
import '~/notes/index';
import initIssueableApp from '~/issue_show';
import initRelatedMergeRequestsApp from '~/related_merge_requests';
+import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle';
export default function() {
initIssueableApp();
@@ -12,5 +13,9 @@ export default function() {
new Issue(); // eslint-disable-line no-new
new ShortcutsIssuable(); // eslint-disable-line no-new
new ZenMode(); // eslint-disable-line no-new
- initIssuableSidebar();
+ if (gon.features && gon.features.vueIssuableSidebar) {
+ initVueIssuableSidebarApp();
+ } else {
+ initIssuableSidebar();
+ }
}
diff --git a/app/assets/javascripts/pages/projects/issues/show/index.js b/app/assets/javascripts/pages/projects/issues/show/index.js
index 7968dfd7a12..ce74a6de11f 100644
--- a/app/assets/javascripts/pages/projects/issues/show/index.js
+++ b/app/assets/javascripts/pages/projects/issues/show/index.js
@@ -3,5 +3,7 @@ import initShow from '../show';
document.addEventListener('DOMContentLoaded', () => {
initShow();
- initSidebarBundle();
+ if (gon.features && !gon.features.vueIssuableSidebar) {
+ initSidebarBundle();
+ }
});
diff --git a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue
index e723cd3fea9..bb95f33c838 100644
--- a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue
+++ b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue
@@ -2,14 +2,14 @@
import _ from 'underscore';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
-import GlModal from '~/vue_shared/components/gl_modal.vue';
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { s__, sprintf } from '~/locale';
import { visitUrl } from '~/lib/utils/url_utility';
import eventHub from '../event_hub';
export default {
components: {
- GlModal,
+ GlModal: DeprecatedModal2,
},
props: {
url: {
diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
index 8f0dc8554e2..e51ab79a51d 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
@@ -16,5 +16,7 @@ export default () => {
new IssuableForm($('.merge-request-form'));
new LabelsSelect();
new MilestoneSelect();
- new IssuableTemplateSelectors();
+ new IssuableTemplateSelectors({
+ warnTemplateOverride: true,
+ });
};
diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
index 7bfb83a2204..fa1de1f13cb 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
@@ -4,11 +4,16 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import { handleLocationHash } from '~/lib/utils/common_utils';
import howToMerge from '~/how_to_merge';
import initPipelines from '~/commit/pipelines/pipelines_bundle';
+import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle';
import initWidget from '../../../vue_merge_request_widget';
export default function() {
new ZenMode(); // eslint-disable-line no-new
- initIssuableSidebar();
+ if (gon.features && gon.features.vueIssuableSidebar) {
+ initVueIssuableSidebarApp();
+ } else {
+ initIssuableSidebar();
+ }
initPipelines();
new ShortcutsIssuable(true); // eslint-disable-line no-new
handleLocationHash();
diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
index f61f4db78d5..ddc648702f1 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
@@ -4,6 +4,8 @@ import initShow from '../init_merge_request_show';
document.addEventListener('DOMContentLoaded', () => {
initShow();
- initSidebarBundle();
+ if (gon.features && !gon.features.vueIssuableSidebar) {
+ initSidebarBundle();
+ }
initMrNotes();
});
diff --git a/app/assets/javascripts/pages/projects/network/network.js b/app/assets/javascripts/pages/projects/network/network.js
index 226d63f05c4..43417fa9702 100644
--- a/app/assets/javascripts/pages/projects/network/network.js
+++ b/app/assets/javascripts/pages/projects/network/network.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, prefer-template */
+/* eslint-disable func-names, no-var */
import $ from 'jquery';
import BranchGraph from '../../../network/branch_graph';
@@ -14,7 +14,7 @@ export default (function() {
this.branch_graph = new BranchGraph($('.network-graph'), opts);
vph = $(window).height() - 250;
$('.network-graph').css({
- height: vph + 'px',
+ height: `${vph}px`,
});
}
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index b99408e3609..435e8705803 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -1,9 +1,9 @@
-/* eslint-disable func-names, no-var, no-return-assign, vars-on-top */
+/* eslint-disable func-names, no-var, no-return-assign */
import $ from 'jquery';
import Cookies from 'js-cookie';
import { __ } from '~/locale';
-import { visitUrl, mergeUrlParams } from '~/lib/utils/url_utility';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
import { serializeForm } from '~/lib/utils/forms';
import axios from '~/lib/utils/axios_utils';
import flash from '~/flash';
@@ -105,6 +105,10 @@ export default class Project {
var selected = $dropdown.data('selected');
var fieldName = $dropdown.data('fieldName');
var shouldVisit = Boolean($dropdown.data('visit'));
+ var $form = $dropdown.closest('form');
+ var action = $form.attr('action');
+ var linkTarget = mergeUrlParams(serializeForm($form[0]), action);
+
return $dropdown.glDropdown({
data(term, callback) {
axios
@@ -126,21 +130,18 @@ export default class Project {
renderRow(ref) {
var li = refListItem.cloneNode(false);
- if (ref.header != null) {
- li.className = 'dropdown-header';
- li.textContent = ref.header;
- } else {
- var link = refLink.cloneNode(false);
-
- if (ref === selected) {
- link.className = 'is-active';
- }
-
- link.textContent = ref;
- link.dataset.ref = ref;
+ var link = refLink.cloneNode(false);
- li.appendChild(link);
+ if (ref === selected) {
+ link.className = 'is-active';
}
+ link.textContent = ref;
+ link.dataset.ref = ref;
+ if (ref.length > 0 && shouldVisit) {
+ link.href = mergeUrlParams({ [fieldName]: ref }, linkTarget);
+ }
+
+ li.appendChild(link);
return li;
},
@@ -152,15 +153,11 @@ export default class Project {
},
clicked(options) {
const { e } = options;
- e.preventDefault();
- if ($(`input[name="${fieldName}"]`).length) {
- var $form = $dropdown.closest('form');
- var action = $form.attr('action');
-
- if (shouldVisit) {
- visitUrl(mergeUrlParams(serializeForm($form[0]), action));
- }
+ if (!shouldVisit) {
+ e.preventDefault();
}
+ /* The actual process is removed since `link.href` in `RenderRow` contains the full target.
+ * It makes the visitable link can be visited when opening on a new tab of browser */
},
});
});
diff --git a/app/assets/javascripts/pages/projects/releases/edit/index.js b/app/assets/javascripts/pages/projects/releases/edit/index.js
index c70271b09c4..98ec196fc37 100644
--- a/app/assets/javascripts/pages/projects/releases/edit/index.js
+++ b/app/assets/javascripts/pages/projects/releases/edit/index.js
@@ -1,4 +1,7 @@
-import $ from 'jquery';
-import initForm from '~/pages/projects/init_form';
+import ZenMode from '~/zen_mode';
+import initEditRelease from '~/releases/detail';
-document.addEventListener('DOMContentLoaded', () => initForm($('.release-form')));
+document.addEventListener('DOMContentLoaded', () => {
+ new ZenMode(); // eslint-disable-line no-new
+ initEditRelease();
+});
diff --git a/app/assets/javascripts/pages/projects/releases/index/index.js b/app/assets/javascripts/pages/projects/releases/index/index.js
index c183fbb9610..6402023149f 100644
--- a/app/assets/javascripts/pages/projects/releases/index/index.js
+++ b/app/assets/javascripts/pages/projects/releases/index/index.js
@@ -1,3 +1,3 @@
-import initReleases from '~/releases';
+import initReleases from '~/releases/list';
document.addEventListener('DOMContentLoaded', initReleases);
diff --git a/app/assets/javascripts/pages/registrations/new/index.js b/app/assets/javascripts/pages/registrations/new/index.js
new file mode 100644
index 00000000000..a33d11f3613
--- /dev/null
+++ b/app/assets/javascripts/pages/registrations/new/index.js
@@ -0,0 +1,9 @@
+import LengthValidator from '~/pages/sessions/new/length_validator';
+import UsernameValidator from '~/pages/sessions/new/username_validator';
+import NoEmojiValidator from '~/emoji/no_emoji_validator';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new UsernameValidator(); // eslint-disable-line no-new
+ new LengthValidator(); // eslint-disable-line no-new
+ new NoEmojiValidator(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/registrations/welcome/index.js b/app/assets/javascripts/pages/registrations/welcome/index.js
new file mode 100644
index 00000000000..2d555fa7977
--- /dev/null
+++ b/app/assets/javascripts/pages/registrations/welcome/index.js
@@ -0,0 +1,7 @@
+import LengthValidator from '~/pages/sessions/new/length_validator';
+import NoEmojiValidator from '~/emoji/no_emoji_validator';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new LengthValidator(); // eslint-disable-line no-new
+ new NoEmojiValidator(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/search/show/search.js b/app/assets/javascripts/pages/search/show/search.js
index 8f6c48ab065..dff9d855b67 100644
--- a/app/assets/javascripts/pages/search/show/search.js
+++ b/app/assets/javascripts/pages/search/show/search.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import '~/gl_dropdown';
import Flash from '~/flash';
import Api from '~/api';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
index a271284dd89..7ce32032ed3 100644
--- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue
+++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
@@ -1,10 +1,13 @@
<script>
-import GlModal from '~/vue_shared/components/gl_modal.vue';
+import RequestWarning from './request_warning.vue';
+
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
- GlModal,
+ RequestWarning,
+ GlModal: DeprecatedModal2,
Icon,
},
props: {
@@ -39,6 +42,16 @@ export default {
detailsList() {
return this.metricDetails.details;
},
+ warnings() {
+ return this.metricDetails.warnings || [];
+ },
+ htmlId() {
+ if (this.currentRequest) {
+ return `performance-bar-warning-${this.currentRequest.id}-${this.metric}`;
+ }
+
+ return '';
+ },
},
};
</script>
@@ -105,5 +118,6 @@ export default {
<div slot="footer"></div>
</gl-modal>
{{ title }}
+ <request-warning :html-id="htmlId" :warnings="warnings" />
</div>
</template>
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 9ad6e75b86b..3b07eba02b7 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -1,14 +1,14 @@
<script>
import { glEmojiTag } from '~/emoji';
-import detailedMetric from './detailed_metric.vue';
-import requestSelector from './request_selector.vue';
+import DetailedMetric from './detailed_metric.vue';
+import RequestSelector from './request_selector.vue';
import { s__ } from '~/locale';
export default {
components: {
- detailedMetric,
- requestSelector,
+ DetailedMetric,
+ RequestSelector,
},
props: {
store: {
diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue
index 297507b85af..793aba3189b 100644
--- a/app/assets/javascripts/performance_bar/components/request_selector.vue
+++ b/app/assets/javascripts/performance_bar/components/request_selector.vue
@@ -1,5 +1,12 @@
<script>
+import { glEmojiTag } from '~/emoji';
+import { n__ } from '~/locale';
+import { GlPopover } from '@gitlab/ui';
+
export default {
+ components: {
+ GlPopover,
+ },
props: {
currentRequest: {
type: Object,
@@ -15,6 +22,18 @@ export default {
currentRequestId: this.currentRequest.id,
};
},
+ computed: {
+ requestsWithWarnings() {
+ return this.requests.filter(request => request.hasWarnings);
+ },
+ warningMessage() {
+ return n__(
+ '%d request with warnings',
+ '%d requests with warnings',
+ this.requestsWithWarnings.length,
+ );
+ },
+ },
watch: {
currentRequestId(newRequestId) {
this.$emit('change-current-request', newRequestId);
@@ -31,6 +50,7 @@ export default {
return truncated;
},
+ glEmojiTag,
},
};
</script>
@@ -44,7 +64,16 @@ export default {
class="qa-performance-bar-request"
>
{{ truncatedUrl(request.url) }}
+ <span v-if="request.hasWarnings">(!)</span>
</option>
</select>
+ <span v-if="requestsWithWarnings.length">
+ <span id="performance-bar-request-selector-warning" v-html="glEmojiTag('warning')"></span>
+ <gl-popover
+ target="performance-bar-request-selector-warning"
+ :content="warningMessage"
+ triggers="hover focus"
+ />
+ </span>
</div>
</template>
diff --git a/app/assets/javascripts/performance_bar/components/request_warning.vue b/app/assets/javascripts/performance_bar/components/request_warning.vue
new file mode 100644
index 00000000000..0da3c271214
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/components/request_warning.vue
@@ -0,0 +1,41 @@
+<script>
+import { glEmojiTag } from '~/emoji';
+import { GlPopover } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlPopover,
+ },
+ props: {
+ htmlId: {
+ type: String,
+ required: true,
+ },
+ warnings: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ hasWarnings() {
+ return this.warnings && this.warnings.length;
+ },
+ warningMessage() {
+ if (!this.hasWarnings) {
+ return '';
+ }
+
+ return this.warnings.join('\n');
+ },
+ },
+ methods: {
+ glEmojiTag,
+ },
+};
+</script>
+<template>
+ <span v-if="hasWarnings">
+ <span :id="htmlId" v-html="glEmojiTag('warning')"></span>
+ <gl-popover :target="htmlId" :content="warningMessage" triggers="hover focus" />
+ </span>
+</template>
diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js
index 29bfb7ee5df..1ae9487f391 100644
--- a/app/assets/javascripts/performance_bar/index.js
+++ b/app/assets/javascripts/performance_bar/index.js
@@ -6,7 +6,7 @@ export default ({ container }) =>
new Vue({
el: container,
components: {
- performanceBarApp: () => import('./components/performance_bar_app.vue'),
+ PerformanceBarApp: () => import('./components/performance_bar_app.vue'),
},
data() {
const performanceBarData = document.querySelector(this.$options.el).dataset;
@@ -41,7 +41,7 @@ export default ({ container }) =>
PerformanceBarService.fetchRequestDetails(this.peekUrl, requestId)
.then(res => {
- this.store.addRequestDetails(requestId, res.data.data);
+ this.store.addRequestDetails(requestId, res.data);
})
.catch(() =>
// eslint-disable-next-line no-console
diff --git a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
index 031e774d533..64f4f5e0c76 100644
--- a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
+++ b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
@@ -3,12 +3,13 @@ export default class PerformanceBarStore {
this.requests = [];
}
- addRequest(requestId, requestUrl, requestDetails) {
+ addRequest(requestId, requestUrl) {
if (!this.findRequest(requestId)) {
this.requests.push({
id: requestId,
url: requestUrl,
- details: requestDetails,
+ details: {},
+ hasWarnings: false,
});
}
@@ -22,7 +23,8 @@ export default class PerformanceBarStore {
addRequestDetails(requestId, requestDetails) {
const request = this.findRequest(requestId);
- request.details = requestDetails;
+ request.details = requestDetails.data;
+ request.hasWarnings = requestDetails.has_warnings;
return request;
}
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index cfc72327ef7..e29509ce074 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -1,20 +1,120 @@
<script>
+import _ from 'underscore';
import { GlLoadingIcon } from '@gitlab/ui';
import StageColumnComponent from './stage_column_component.vue';
import GraphMixin from '../../mixins/graph_component_mixin';
-import GraphWidthMixin from '~/pipelines/mixins/graph_width_mixin';
+import GraphWidthMixin from '../../mixins/graph_width_mixin';
+import LinkedPipelinesColumn from './linked_pipelines_column.vue';
+import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin';
export default {
+ name: 'PipelineGraph',
components: {
StageColumnComponent,
GlLoadingIcon,
+ LinkedPipelinesColumn,
+ },
+ mixins: [GraphMixin, GraphWidthMixin, GraphBundleMixin],
+ props: {
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ isLinkedPipeline: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ mediator: {
+ type: Object,
+ required: true,
+ },
+ type: {
+ type: String,
+ required: false,
+ default: 'main',
+ },
+ },
+ upstream: 'upstream',
+ downstream: 'downstream',
+ data() {
+ return {
+ triggeredTopIndex: 1,
+ };
+ },
+ computed: {
+ hasTriggeredBy() {
+ return (
+ this.type !== this.$options.downstream &&
+ this.triggeredByPipelines &&
+ this.pipeline.triggered_by !== null
+ );
+ },
+ triggeredByPipelines() {
+ return this.pipeline.triggered_by;
+ },
+ hasTriggered() {
+ return (
+ this.type !== this.$options.upstream &&
+ this.triggeredPipelines &&
+ this.pipeline.triggered.length > 0
+ );
+ },
+ triggeredPipelines() {
+ return this.pipeline.triggered;
+ },
+ expandedTriggeredBy() {
+ return (
+ this.pipeline.triggered_by &&
+ _.isArray(this.pipeline.triggered_by) &&
+ this.pipeline.triggered_by.find(el => el.isExpanded)
+ );
+ },
+ expandedTriggered() {
+ return this.pipeline.triggered && this.pipeline.triggered.find(el => el.isExpanded);
+ },
+
+ /**
+ * Calculates the margin top of the clicked downstream pipeline by
+ * adding the height of each linked pipeline and the margin
+ */
+ marginTop() {
+ return `${this.triggeredTopIndex * 52}px`;
+ },
+ pipelineTypeUpstream() {
+ return this.type !== this.$options.downstream && this.expandedTriggeredBy;
+ },
+ pipelineTypeDownstream() {
+ return this.type !== this.$options.upstream && this.expandedTriggered;
+ },
+ },
+ methods: {
+ handleClickedDownstream(pipeline, clickedIndex) {
+ this.triggeredTopIndex = clickedIndex;
+ this.$emit('onClickTriggered', this.pipeline, pipeline);
+ },
+ hasOnlyOneJob(stage) {
+ return stage.groups.length === 1;
+ },
+ hasDownstream(index, length) {
+ return index === length - 1 && this.hasTriggered;
+ },
+ hasUpstream(index) {
+ return index === 0 && this.hasTriggeredBy;
+ },
},
- mixins: [GraphMixin, GraphWidthMixin],
};
</script>
<template>
<div class="build-content middle-block js-pipeline-graph">
- <div class="pipeline-visualization pipeline-graph pipeline-tab-content">
+ <div
+ class="pipeline-visualization pipeline-graph"
+ :class="{ 'pipeline-tab-content': !isLinkedPipeline }"
+ >
<div
:style="{
paddingLeft: `${graphLeftPadding}px`,
@@ -23,21 +123,80 @@ export default {
>
<gl-loading-icon v-if="isLoading" class="m-auto" :size="3" />
- <ul v-if="!isLoading" class="stage-column-list">
+ <pipeline-graph
+ v-if="pipelineTypeUpstream"
+ type="upstream"
+ class="d-inline-block upstream-pipeline"
+ :class="`js-upstream-pipeline-${expandedTriggeredBy.id}`"
+ :is-loading="false"
+ :pipeline="expandedTriggeredBy"
+ :is-linked-pipeline="true"
+ :mediator="mediator"
+ @onClickTriggeredBy="
+ (parentPipeline, pipeline) => clickTriggeredByPipeline(parentPipeline, pipeline)
+ "
+ @refreshPipelineGraph="requestRefreshPipelineGraph"
+ />
+
+ <linked-pipelines-column
+ v-if="hasTriggeredBy"
+ :linked-pipelines="triggeredByPipelines"
+ :column-title="__('Upstream')"
+ graph-position="left"
+ @linkedPipelineClick="
+ linkedPipeline => $emit('onClickTriggeredBy', pipeline, linkedPipeline)
+ "
+ />
+
+ <ul
+ v-if="!isLoading"
+ :class="{
+ 'inline js-has-linked-pipelines': hasTriggered || hasTriggeredBy,
+ }"
+ class="stage-column-list align-top"
+ >
<stage-column-component
v-for="(stage, index) in graph"
:key="stage.name"
:class="{
- 'append-right-48': shouldAddRightMargin(index),
+ 'has-upstream prepend-left-64': hasUpstream(index),
+ 'has-downstream': hasDownstream(index, graph.length),
+ 'has-only-one-job': hasOnlyOneJob(stage),
+ 'append-right-46': shouldAddRightMargin(index),
}"
:title="capitalizeStageName(stage.name)"
:groups="stage.groups"
:stage-connector-class="stageConnectorClass(index, stage)"
:is-first-column="isFirstColumn(index)"
+ :has-triggered-by="hasTriggeredBy"
:action="stage.status.action"
@refreshPipelineGraph="refreshPipelineGraph"
/>
</ul>
+
+ <linked-pipelines-column
+ v-if="hasTriggered"
+ :linked-pipelines="triggeredPipelines"
+ :column-title="__('Downstream')"
+ graph-position="right"
+ @linkedPipelineClick="handleClickedDownstream"
+ />
+
+ <pipeline-graph
+ v-if="pipelineTypeDownstream"
+ type="downstream"
+ class="d-inline-block"
+ :class="`js-downstream-pipeline-${expandedTriggered.id}`"
+ :is-loading="false"
+ :pipeline="expandedTriggered"
+ :is-linked-pipeline="true"
+ :style="{ 'margin-top': marginTop }"
+ :mediator="mediator"
+ @onClickTriggered="
+ (parentPipeline, pipeline) => clickTriggeredPipeline(parentPipeline, pipeline)
+ "
+ @refreshPipelineGraph="requestRefreshPipelineGraph"
+ />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
new file mode 100644
index 00000000000..4e7d77863b9
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
@@ -0,0 +1,65 @@
+<script>
+import { GlLoadingIcon, GlTooltipDirective, GlButton } from '@gitlab/ui';
+import CiStatus from '~/vue_shared/components/ci_icon.vue';
+
+export default {
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ CiStatus,
+ GlLoadingIcon,
+ GlButton,
+ },
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ tooltipText() {
+ return `${this.projectName} - ${this.pipelineStatus.label}`;
+ },
+ buttonId() {
+ return `js-linked-pipeline-${this.pipeline.id}`;
+ },
+ pipelineStatus() {
+ return this.pipeline.details.status;
+ },
+ projectName() {
+ return this.pipeline.project.name;
+ },
+ },
+ methods: {
+ onClickLinkedPipeline() {
+ this.$root.$emit('bv::hide::tooltip', this.buttonId);
+ this.$emit('pipelineClicked');
+ },
+ },
+};
+</script>
+
+<template>
+ <li class="linked-pipeline build">
+ <div class="curve"></div>
+ <gl-button
+ :id="buttonId"
+ v-gl-tooltip
+ :title="tooltipText"
+ class="js-linked-pipeline-content linked-pipeline-content"
+ data-qa-selector="linked_pipeline_button"
+ :class="`js-pipeline-expand-${pipeline.id}`"
+ @click="onClickLinkedPipeline"
+ >
+ <gl-loading-icon v-if="pipeline.isLoading" class="js-linked-pipeline-loading d-inline" />
+ <ci-status
+ v-else
+ :status="pipelineStatus"
+ css-classes="position-top-0"
+ class="js-linked-pipeline-status"
+ />
+ <span class="str-truncated align-bottom"> {{ projectName }} &#8226; #{{ pipeline.id }} </span>
+ </gl-button>
+ </li>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
new file mode 100644
index 00000000000..6efdde2b17e
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
@@ -0,0 +1,52 @@
+<script>
+import LinkedPipeline from './linked_pipeline.vue';
+
+export default {
+ components: {
+ LinkedPipeline,
+ },
+ props: {
+ columnTitle: {
+ type: String,
+ required: true,
+ },
+ linkedPipelines: {
+ type: Array,
+ required: true,
+ },
+ graphPosition: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ columnClass() {
+ const positionValues = {
+ right: 'prepend-left-64',
+ left: 'append-right-32',
+ };
+ return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <div :class="columnClass" class="stage-column linked-pipelines-column">
+ <div class="stage-name linked-pipelines-column-title">{{ columnTitle }}</div>
+ <div class="cross-project-triangle"></div>
+ <ul>
+ <linked-pipeline
+ v-for="(pipeline, index) in linkedPipelines"
+ :key="pipeline.id"
+ :class="{
+ 'flat-connector-before': index === 0 && graphPosition === 'right',
+ active: pipeline.isExpanded,
+ 'left-connector': pipeline.isExpanded && graphPosition === 'left',
+ }"
+ :pipeline="pipeline"
+ @pipelineClicked="$emit('linkedPipelineClick', pipeline, index)"
+ />
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
index d5c124dc0ca..db7714808fd 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -1,6 +1,6 @@
<script>
import _ from 'underscore';
-import stageColumnMixin from 'ee_else_ce/pipelines/mixins/stage_column_mixin';
+import stageColumnMixin from '../../mixins/stage_column_mixin';
import JobItem from './job_item.vue';
import JobGroupDropdown from './job_group_dropdown.vue';
import ActionComponent from './action_component.vue';
diff --git a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue
index 4cafd147511..2e71b3c637b 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue
@@ -1,6 +1,6 @@
<script>
import _ from 'underscore';
-import GlModal from '~/vue_shared/components/gl_modal.vue';
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { GlLink } from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
@@ -13,7 +13,7 @@ import { s__, sprintf } from '~/locale';
*/
export default {
components: {
- GlModal,
+ GlModal: DeprecatedModal2,
GlLink,
ClipboardButton,
CiIcon,
diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue
index a08f732dda7..30c830d78f9 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue
@@ -104,7 +104,7 @@ export default {
v-gl-tooltip
:title="
__(
- 'Pipelines for merge requests are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more on the documentation for Pipelines for Merged Results.',
+ 'Pipelines for merge requests are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more in the documentation for Pipelines for Merged Results.',
)
"
class="js-pipeline-url-detached badge badge-info"
diff --git a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js
index dd79ade5bc9..c76869d90d5 100644
--- a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js
+++ b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js
@@ -1,16 +1,68 @@
-import Flash from '~/flash';
+import flash from '~/flash';
import { __ } from '~/locale';
export default {
methods: {
- clickTriggeredByPipeline() {},
- clickTriggeredPipeline() {},
+ getExpandedPipelines(pipeline) {
+ this.mediator.service
+ .getPipeline(this.mediator.getExpandedParameters())
+ .then(response => {
+ this.mediator.store.toggleLoading(pipeline);
+ this.mediator.store.storePipeline(response.data);
+ this.mediator.poll.enable({ data: this.mediator.getExpandedParameters() });
+ })
+ .catch(() => {
+ this.mediator.store.toggleLoading(pipeline);
+ flash(__('An error occurred while fetching the pipeline.'));
+ });
+ },
+ /**
+ * Called when a linked pipeline is clicked.
+ *
+ * If the pipeline is collapsed we will start polling it & we will reset the other pipelines.
+ * If the pipeline is expanded we will close it.
+ *
+ * @param {String} method Method to fetch the pipeline
+ * @param {String} storeKey Store property that will be updates
+ * @param {String} resetStoreKey Store key for the visible pipeline that will need to be reset
+ * @param {Object} pipeline The clicked pipeline
+ */
+ clickPipeline(parentPipeline, pipeline, openMethod, closeMethod) {
+ if (!pipeline.isExpanded) {
+ this.mediator.store[openMethod](parentPipeline, pipeline);
+ this.mediator.store.toggleLoading(pipeline);
+ this.mediator.poll.stop();
+
+ this.getExpandedPipelines(pipeline);
+ } else {
+ this.mediator.store[closeMethod](pipeline);
+ this.mediator.poll.stop();
+
+ this.mediator.poll.enable({ data: this.mediator.getExpandedParameters() });
+ }
+ },
+ clickTriggeredByPipeline(parentPipeline, pipeline) {
+ this.clickPipeline(
+ parentPipeline,
+ pipeline,
+ 'openTriggeredByPipeline',
+ 'closeTriggeredByPipeline',
+ );
+ },
+ clickTriggeredPipeline(parentPipeline, pipeline) {
+ this.clickPipeline(
+ parentPipeline,
+ pipeline,
+ 'openTriggeredPipeline',
+ 'closeTriggeredPipeline',
+ );
+ },
requestRefreshPipelineGraph() {
// When an action is clicked
// (wether in the dropdown or in the main nodes, we refresh the big graph)
this.mediator
.refreshPipeline()
- .catch(() => Flash(__('An error occurred while making the request.')));
+ .catch(() => flash(__('An error occurred while making the request.')));
},
},
};
diff --git a/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js b/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js
index 64283ed0e58..3f3007ba11a 100644
--- a/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js
+++ b/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js
@@ -1,7 +1,14 @@
export default {
+ props: {
+ hasTriggeredBy: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
methods: {
buildConnnectorClass(index) {
- return index === 0 && !this.isFirstColumn ? 'left-connector' : '';
+ return index === 0 && (!this.isFirstColumn || this.hasTriggeredBy) ? 'left-connector' : '';
},
},
};
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index b8976f77bac..b6f8716d37d 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -2,8 +2,8 @@ import Vue from 'vue';
import Flash from '~/flash';
import Translate from '~/vue_shared/translate';
import { __ } from '~/locale';
-import pipelineGraph from 'ee_else_ce/pipelines/components/graph/graph_component.vue';
-import GraphEEMixin from 'ee_else_ce/pipelines/mixins/graph_pipeline_bundle_mixin';
+import pipelineGraph from './components/graph/graph_component.vue';
+import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin';
import PipelinesMediator from './pipeline_details_mediator';
import pipelineHeader from './components/header_component.vue';
import eventHub from './event_hub';
@@ -23,7 +23,7 @@ export default () => {
components: {
pipelineGraph,
},
- mixins: [GraphEEMixin],
+ mixins: [GraphBundleMixin],
data() {
return {
mediator,
diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js
index c8819cf35cf..bf021a0b447 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_mediator.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js
@@ -1,5 +1,5 @@
import Visibility from 'visibilityjs';
-import PipelineStore from 'ee_else_ce/pipelines/stores/pipeline_store';
+import PipelineStore from './stores/pipeline_store';
import Flash from '../flash';
import Poll from '../lib/utils/poll';
import { __ } from '../locale';
diff --git a/app/assets/javascripts/pipelines/stores/pipeline_store.js b/app/assets/javascripts/pipelines/stores/pipeline_store.js
index 259278b6410..441c9f3c25f 100644
--- a/app/assets/javascripts/pipelines/stores/pipeline_store.js
+++ b/app/assets/javascripts/pipelines/stores/pipeline_store.js
@@ -1,10 +1,196 @@
+import Vue from 'vue';
+import _ from 'underscore';
+
export default class PipelineStore {
constructor() {
this.state = {};
this.state.pipeline = {};
+ this.state.expandedPipelines = [];
}
-
+ /**
+ * For the triggered pipelines adds the `isExpanded` key
+ *
+ * For the triggered_by pipeline adds the `isExpanded` key
+ * and saves it as an array
+ *
+ * @param {Object} pipeline
+ */
storePipeline(pipeline = {}) {
- this.state.pipeline = pipeline;
+ const pipelineCopy = Object.assign({}, pipeline);
+
+ if (pipelineCopy.triggered_by) {
+ pipelineCopy.triggered_by = [pipelineCopy.triggered_by];
+
+ const oldTriggeredBy =
+ this.state.pipeline &&
+ this.state.pipeline.triggered_by &&
+ this.state.pipeline.triggered_by[0];
+
+ this.parseTriggeredByPipelines(oldTriggeredBy, pipelineCopy.triggered_by[0]);
+ }
+
+ if (pipelineCopy.triggered && pipelineCopy.triggered.length) {
+ pipelineCopy.triggered.forEach(el => {
+ const oldPipeline =
+ this.state.pipeline &&
+ this.state.pipeline.triggered &&
+ this.state.pipeline.triggered.find(element => element.id === el.id);
+
+ this.parseTriggeredPipelines(oldPipeline, el);
+ });
+ }
+
+ this.state.pipeline = pipelineCopy;
+ }
+
+ /**
+ * Recursiverly parses the triggered by pipelines.
+ *
+ * Sets triggered_by as an array, there is always only 1 triggered_by pipeline.
+ * Adds key `isExpanding`
+ * Keeps old isExpading value due to polling
+ *
+ * @param {Array} parentPipeline
+ * @param {Object} pipeline
+ */
+ parseTriggeredByPipelines(oldPipeline = {}, newPipeline) {
+ // keep old value in case it's opened because we're polling
+
+ Vue.set(newPipeline, 'isExpanded', oldPipeline.isExpanded || false);
+ // add isLoading property
+ Vue.set(newPipeline, 'isLoading', false);
+
+ if (newPipeline.triggered_by) {
+ if (!_.isArray(newPipeline.triggered_by)) {
+ Object.assign(newPipeline, { triggered_by: [newPipeline.triggered_by] });
+ }
+ this.parseTriggeredByPipelines(oldPipeline, newPipeline.triggered_by[0]);
+ }
+ }
+
+ /**
+ * Recursively parses the triggered pipelines
+ * @param {Array} parentPipeline
+ * @param {Object} pipeline
+ */
+ parseTriggeredPipelines(oldPipeline = {}, newPipeline) {
+ // keep old value in case it's opened because we're polling
+ Vue.set(newPipeline, 'isExpanded', oldPipeline.isExpanded || false);
+
+ // add isLoading property
+ Vue.set(newPipeline, 'isLoading', false);
+
+ if (newPipeline.triggered && newPipeline.triggered.length > 0) {
+ newPipeline.triggered.forEach(el => {
+ const oldTriggered =
+ oldPipeline.triggered && oldPipeline.triggered.find(element => element.id === el.id);
+ this.parseTriggeredPipelines(oldTriggered, el);
+ });
+ }
+ }
+
+ /**
+ * Recursively resets all triggered by pipelines
+ *
+ * @param {Object} pipeline
+ */
+ resetTriggeredByPipeline(parentPipeline, pipeline) {
+ parentPipeline.triggered_by.forEach(el => this.closePipeline(el));
+
+ if (pipeline.triggered_by && pipeline.triggered_by) {
+ this.resetTriggeredByPipeline(pipeline, pipeline.triggered_by);
+ }
+ }
+
+ /**
+ * Opens the clicked pipeline and closes all other ones.
+ * @param {Object} pipeline
+ */
+ openTriggeredByPipeline(parentPipeline, pipeline) {
+ // first we need to reset all triggeredBy pipelines
+ this.resetTriggeredByPipeline(parentPipeline, pipeline);
+
+ this.openPipeline(pipeline);
+ }
+
+ /**
+ * On click, will close the given pipeline and all nested triggered by pipelines
+ *
+ * @param {Object} pipeline
+ */
+ closeTriggeredByPipeline(pipeline) {
+ this.closePipeline(pipeline);
+
+ if (pipeline.triggered_by && pipeline.triggered_by.length) {
+ pipeline.triggered_by.forEach(triggeredBy => this.closeTriggeredByPipeline(triggeredBy));
+ }
+ }
+
+ /**
+ * Recursively closes all triggered pipelines for the given one.
+ *
+ * @param {Object} pipeline
+ */
+ resetTriggeredPipelines(parentPipeline, pipeline) {
+ parentPipeline.triggered.forEach(el => this.closePipeline(el));
+
+ if (pipeline.triggered && pipeline.triggered.length) {
+ pipeline.triggered.forEach(el => this.resetTriggeredPipelines(pipeline, el));
+ }
+ }
+
+ /**
+ * Opens the clicked triggered pipeline and closes all other ones.
+ *
+ * @param {Object} pipeline
+ */
+ openTriggeredPipeline(parentPipeline, pipeline) {
+ this.resetTriggeredPipelines(parentPipeline, pipeline);
+
+ this.openPipeline(pipeline);
+ }
+
+ /**
+ * On click, will close the given pipeline and all the nested triggered ones
+ * @param {Object} pipeline
+ */
+ closeTriggeredPipeline(pipeline) {
+ this.closePipeline(pipeline);
+
+ if (pipeline.triggered && pipeline.triggered.length) {
+ pipeline.triggered.forEach(triggered => this.closeTriggeredPipeline(triggered));
+ }
+ }
+
+ /**
+ * Utility function, Closes the given pipeline
+ * @param {Object} pipeline
+ */
+ closePipeline(pipeline) {
+ Vue.set(pipeline, 'isExpanded', false);
+ // remove the pipeline from the parameters
+ this.removeExpandedPipelineToRequestData(pipeline.id);
+ }
+
+ /**
+ * Utility function, Opens the given pipeline
+ * @param {Object} pipeline
+ */
+ openPipeline(pipeline) {
+ Vue.set(pipeline, 'isExpanded', true);
+ // add the pipeline to the parameters
+ this.addExpandedPipelineToRequestData(pipeline.id);
+ }
+ // eslint-disable-next-line class-methods-use-this
+ toggleLoading(pipeline) {
+ Vue.set(pipeline, 'isLoading', !pipeline.isLoading);
+ }
+
+ addExpandedPipelineToRequestData(id) {
+ this.state.expandedPipelines.push(id);
+ }
+
+ removeExpandedPipelineToRequestData(id) {
+ this.state.expandedPipelines.splice(this.state.expandedPipelines.findIndex(el => el === id), 1);
}
}
diff --git a/app/assets/javascripts/privacy_policy_update_callout.js b/app/assets/javascripts/privacy_policy_update_callout.js
index 126b1ee1132..97f41deb30f 100644
--- a/app/assets/javascripts/privacy_policy_update_callout.js
+++ b/app/assets/javascripts/privacy_policy_update_callout.js
@@ -1,7 +1,7 @@
import PersistentUserCallout from '~/persistent_user_callout';
function initPrivacyPolicyUpdateCallout() {
- const callout = document.querySelector('.privacy-policy-update-64341');
+ const callout = document.querySelector('.js-privacy-policy-update');
PersistentUserCallout.factory(callout);
}
diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue
index e1085c0a44d..72867ecd709 100644
--- a/app/assets/javascripts/profile/account/components/update_username.vue
+++ b/app/assets/javascripts/profile/account/components/update_username.vue
@@ -1,13 +1,13 @@
<script>
import _ from 'underscore';
import axios from '~/lib/utils/axios_utils';
-import GlModal from '~/vue_shared/components/gl_modal.vue';
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { s__, sprintf } from '~/locale';
import Flash from '~/flash';
export default {
components: {
- GlModal,
+ GlModal: DeprecatedModal2,
},
props: {
actionUrl: {
diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js
index e73a828c0ae..2c375b39c1f 100644
--- a/app/assets/javascripts/project_find_file.js
+++ b/app/assets/javascripts/project_find_file.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, consistent-return, one-var, no-cond-assign, prefer-template, no-return-assign */
+/* eslint-disable func-names, no-var, consistent-return, one-var, no-cond-assign, no-return-assign */
import $ from 'jquery';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
@@ -81,7 +81,7 @@ export default class ProjectFindFile {
// find file
}
- // files pathes load
+ // files paths load
load(url) {
axios
.get(url)
@@ -112,10 +112,13 @@ export default class ProjectFindFile {
if (searchText) {
matches = fuzzaldrinPlus.match(filePath, searchText);
}
- blobItemUrl = this.options.blobUrlTemplate + '/' + encodeURIComponent(filePath);
+ blobItemUrl = `${this.options.blobUrlTemplate}/${encodeURIComponent(filePath)}`;
html = ProjectFindFile.makeHtml(filePath, matches, blobItemUrl);
results.push(this.element.find('.tree-table > tbody').append(html));
}
+
+ this.element.find('.empty-state').toggleClass('hidden', Boolean(results.length));
+
return results;
}
diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js
index 88665ed2ab7..0fbb7e5ca42 100644
--- a/app/assets/javascripts/project_select.js
+++ b/app/assets/javascripts/project_select.js
@@ -5,102 +5,103 @@ import Api from './api';
import ProjectSelectComboButton from './project_select_combo_button';
import { s__ } from './locale';
-export default function projectSelect() {
- import(/* webpackChunkName: 'select2' */ 'select2/select2')
- .then(() => {
- $('.ajax-project-select').each(function(i, select) {
- var placeholder;
- const simpleFilter = $(select).data('simpleFilter') || false;
- this.groupId = $(select).data('groupId');
- this.includeGroups = $(select).data('includeGroups');
- this.allProjects = $(select).data('allProjects') || false;
- this.orderBy = $(select).data('orderBy') || 'id';
- this.withIssuesEnabled = $(select).data('withIssuesEnabled');
- this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled');
- this.withShared =
- $(select).data('withShared') === undefined ? true : $(select).data('withShared');
- this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false;
- this.allowClear = $(select).data('allowClear') || false;
+const projectSelect = () => {
+ $('.ajax-project-select').each(function(i, select) {
+ var placeholder;
+ const simpleFilter = $(select).data('simpleFilter') || false;
+ this.groupId = $(select).data('groupId');
+ this.includeGroups = $(select).data('includeGroups');
+ this.allProjects = $(select).data('allProjects') || false;
+ this.orderBy = $(select).data('orderBy') || 'id';
+ this.withIssuesEnabled = $(select).data('withIssuesEnabled');
+ this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled');
+ this.withShared =
+ $(select).data('withShared') === undefined ? true : $(select).data('withShared');
+ this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false;
+ this.allowClear = $(select).data('allowClear') || false;
- placeholder = s__('ProjectSelect|Search for project');
- if (this.includeGroups) {
- placeholder += s__('ProjectSelect| or group');
- }
+ placeholder = s__('ProjectSelect|Search for project');
+ if (this.includeGroups) {
+ placeholder += s__('ProjectSelect| or group');
+ }
- $(select).select2({
- placeholder,
- minimumInputLength: 0,
- query: (function(_this) {
- return function(query) {
- var finalCallback, projectsCallback;
- finalCallback = function(projects) {
+ $(select).select2({
+ placeholder,
+ minimumInputLength: 0,
+ query: (function(_this) {
+ return function(query) {
+ var finalCallback, projectsCallback;
+ finalCallback = function(projects) {
+ var data;
+ data = {
+ results: projects,
+ };
+ return query.callback(data);
+ };
+ if (_this.includeGroups) {
+ projectsCallback = function(projects) {
+ var groupsCallback;
+ groupsCallback = function(groups) {
var data;
- data = {
- results: projects,
- };
- return query.callback(data);
+ data = groups.concat(projects);
+ return finalCallback(data);
};
- if (_this.includeGroups) {
- projectsCallback = function(projects) {
- var groupsCallback;
- groupsCallback = function(groups) {
- var data;
- data = groups.concat(projects);
- return finalCallback(data);
- };
- return Api.groups(query.term, {}, groupsCallback);
- };
- } else {
- projectsCallback = finalCallback;
- }
- if (_this.groupId) {
- return Api.groupProjects(
- _this.groupId,
- query.term,
- {
- with_issues_enabled: _this.withIssuesEnabled,
- with_merge_requests_enabled: _this.withMergeRequestsEnabled,
- with_shared: _this.withShared,
- include_subgroups: _this.includeProjectsInSubgroups,
- },
- projectsCallback,
- );
- } else {
- return Api.projects(
- query.term,
- {
- order_by: _this.orderBy,
- with_issues_enabled: _this.withIssuesEnabled,
- with_merge_requests_enabled: _this.withMergeRequestsEnabled,
- membership: !_this.allProjects,
- },
- projectsCallback,
- );
- }
+ return Api.groups(query.term, {}, groupsCallback);
};
- })(this),
- id(project) {
- if (simpleFilter) return project.id;
- return JSON.stringify({
- name: project.name,
- url: project.web_url,
- });
- },
- text(project) {
- return project.name_with_namespace || project.name;
- },
+ } else {
+ projectsCallback = finalCallback;
+ }
+ if (_this.groupId) {
+ return Api.groupProjects(
+ _this.groupId,
+ query.term,
+ {
+ with_issues_enabled: _this.withIssuesEnabled,
+ with_merge_requests_enabled: _this.withMergeRequestsEnabled,
+ with_shared: _this.withShared,
+ include_subgroups: _this.includeProjectsInSubgroups,
+ },
+ projectsCallback,
+ );
+ } else {
+ return Api.projects(
+ query.term,
+ {
+ order_by: _this.orderBy,
+ with_issues_enabled: _this.withIssuesEnabled,
+ with_merge_requests_enabled: _this.withMergeRequestsEnabled,
+ membership: !_this.allProjects,
+ },
+ projectsCallback,
+ );
+ }
+ };
+ })(this),
+ id(project) {
+ if (simpleFilter) return project.id;
+ return JSON.stringify({
+ name: project.name,
+ url: project.web_url,
+ });
+ },
+ text(project) {
+ return project.name_with_namespace || project.name;
+ },
- initSelection(el, callback) {
- return Api.project(el.val()).then(({ data }) => callback(data));
- },
+ initSelection(el, callback) {
+ return Api.project(el.val()).then(({ data }) => callback(data));
+ },
- allowClear: this.allowClear,
+ allowClear: this.allowClear,
- dropdownCssClass: 'ajax-project-dropdown',
- });
- if (simpleFilter) return select;
- return new ProjectSelectComboButton(select);
- });
- })
+ dropdownCssClass: 'ajax-project-dropdown',
+ });
+ if (simpleFilter) return select;
+ return new ProjectSelectComboButton(select);
+ });
+};
+
+export default () =>
+ import(/* webpackChunkName: 'select2' */ 'select2/select2')
+ .then(projectSelect)
.catch(() => {});
-}
diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
index 12ee1ce2f0c..60fd3ed5ea7 100644
--- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
+++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
@@ -21,14 +21,6 @@ export default {
type: String,
required: true,
},
- /* This prop can be used to replace some of the `render_commit_status`
- used across GitLab, this way we could use this vue component and add a
- realtime status where it makes sense
- realtime: {
- type: Boolean,
- required: false,
- default: true,
- }, */
},
data() {
return {
@@ -47,6 +39,9 @@ export default {
this.service = new CommitPipelineService(this.endpoint);
this.initPolling();
},
+ beforeDestroy() {
+ this.poll.stop();
+ },
methods: {
successCallback(res) {
const { pipelines } = res.data;
@@ -95,9 +90,6 @@ export default {
.catch(this.errorCallback);
},
},
- destroy() {
- this.poll.stop();
- },
};
</script>
<template>
diff --git a/app/assets/javascripts/ref_select_dropdown.js b/app/assets/javascripts/ref_select_dropdown.js
index 75bac035aca..2e0113271df 100644
--- a/app/assets/javascripts/ref_select_dropdown.js
+++ b/app/assets/javascripts/ref_select_dropdown.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import '~/gl_dropdown';
class RefSelectDropdown {
constructor($dropdownButton, availableRefs) {
diff --git a/app/assets/javascripts/registry/components/app.vue b/app/assets/javascripts/registry/components/app.vue
index 346dc470a59..11b2c3b7016 100644
--- a/app/assets/javascripts/registry/components/app.vue
+++ b/app/assets/javascripts/registry/components/app.vue
@@ -2,28 +2,34 @@
import { mapGetters, mapActions } from 'vuex';
import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import store from '../stores';
-import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
import CollapsibleContainer from './collapsible_container.vue';
+import ProjectEmptyState from './project_empty_state.vue';
+import GroupEmptyState from './group_empty_state.vue';
import { s__, sprintf } from '../../locale';
export default {
name: 'RegistryListApp',
components: {
- clipboardButton,
CollapsibleContainer,
GlEmptyState,
GlLoadingIcon,
+ ProjectEmptyState,
+ GroupEmptyState,
},
props: {
- endpoint: {
- type: String,
- required: true,
- },
characterError: {
type: Boolean,
required: false,
default: false,
},
+ containersErrorImage: {
+ type: String,
+ required: true,
+ },
+ endpoint: {
+ type: String,
+ required: true,
+ },
helpPagePath: {
type: String,
required: true,
@@ -32,14 +38,30 @@ export default {
type: String,
required: true,
},
- containersErrorImage: {
+ personalAccessTokensHelpLink: {
type: String,
- required: true,
+ required: false,
+ default: null,
+ },
+ registryHostUrlWithPort: {
+ type: String,
+ required: false,
+ default: null,
},
repositoryUrl: {
type: String,
required: true,
},
+ isGroupPage: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ twoFactorAuthHelpLink: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
store,
computed: {
@@ -47,7 +69,7 @@ export default {
dockerConnectionErrorText() {
return sprintf(
s__(`ContainerRegistry|We are having trouble connecting to Docker, which could be due to an
- issue with your project name or path.
+ issue with your project name or path.
%{docLinkStart}More Information%{docLinkEnd}`),
{
docLinkStart: `<a href="${this.helpPagePath}#docker-connection-error" target="_blank">`,
@@ -58,8 +80,8 @@ export default {
},
introText() {
return sprintf(
- s__(`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every
- project can have its own space to store its Docker images.
+ s__(`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every
+ project can have its own space to store its Docker images.
%{docLinkStart}More Information%{docLinkEnd}`),
{
docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`,
@@ -79,17 +101,10 @@ export default {
false,
);
},
- dockerBuildCommand() {
- // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
- return `docker build -t ${this.repositoryUrl} .`;
- },
- dockerPushCommand() {
- // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
- return `docker push ${this.repositoryUrl}`;
- },
},
created() {
this.setMainEndpoint(this.endpoint);
+ this.setIsDeleteDisabled(this.isGroupPage);
},
mounted() {
if (!this.characterError) {
@@ -97,7 +112,7 @@ export default {
}
},
methods: {
- ...mapActions(['setMainEndpoint', 'fetchRepos']),
+ ...mapActions(['setMainEndpoint', 'fetchRepos', 'setIsDeleteDisabled']),
},
};
</script>
@@ -109,7 +124,7 @@ export default {
:svg-path="containersErrorImage"
>
<template #description>
- <p v-html="dockerConnectionErrorText"></p>
+ <p class="js-character-error-text" v-html="dockerConnectionErrorText"></p>
</template>
</gl-empty-state>
@@ -120,46 +135,19 @@ export default {
<p v-html="introText"></p>
<collapsible-container v-for="item in repos" :key="item.id" :repo="item" />
</div>
-
- <gl-empty-state
- v-else
- :title="s__('ContainerRegistry|There are no container images stored for this project')"
- :svg-path="noContainersImage"
- class="container-message"
- >
- <template #description>
- <p class="js-no-container-images-text" v-html="noContainerImagesText"></p>
- <h5>{{ s__('ContainerRegistry|Quick Start') }}</h5>
- <p>
- {{
- s__(
- 'ContainerRegistry|You can add an image to this registry with the following commands:',
- )
- }}
- </p>
-
- <div class="input-group append-bottom-10">
- <input :value="dockerBuildCommand" type="text" class="form-control monospace" readonly />
- <span class="input-group-append">
- <clipboard-button
- :text="dockerBuildCommand"
- :title="s__('ContainerRegistry|Copy build command to clipboard')"
- class="input-group-text"
- />
- </span>
- </div>
-
- <div class="input-group">
- <input :value="dockerPushCommand" type="text" class="form-control monospace" readonly />
- <span class="input-group-append">
- <clipboard-button
- :text="dockerPushCommand"
- :title="s__('ContainerRegistry|Copy push command to clipboard')"
- class="input-group-text"
- />
- </span>
- </div>
- </template>
- </gl-empty-state>
+ <project-empty-state
+ v-else-if="!isGroupPage"
+ :no-containers-image="noContainersImage"
+ :help-page-path="helpPagePath"
+ :repository-url="repositoryUrl"
+ :two-factor-auth-help-link="twoFactorAuthHelpLink"
+ :personal-access-tokens-help-link="personalAccessTokensHelpLink"
+ :registry-host-url-with-port="registryHostUrlWithPort"
+ />
+ <group-empty-state
+ v-else-if="isGroupPage"
+ :no-containers-image="noContainersImage"
+ :help-page-path="helpPagePath"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue
index bfb2305c48c..95f8270b5d0 100644
--- a/app/assets/javascripts/registry/components/collapsible_container.vue
+++ b/app/assets/javascripts/registry/components/collapsible_container.vue
@@ -1,6 +1,13 @@
<script>
-import { mapActions } from 'vuex';
-import { GlLoadingIcon, GlButton, GlTooltipDirective, GlModal, GlModalDirective } from '@gitlab/ui';
+import { mapActions, mapGetters } from 'vuex';
+import {
+ GlLoadingIcon,
+ GlButton,
+ GlTooltipDirective,
+ GlModal,
+ GlModalDirective,
+ GlEmptyState,
+} from '@gitlab/ui';
import createFlash from '../../flash';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
import Icon from '../../vue_shared/components/icon.vue';
@@ -17,6 +24,7 @@ export default {
GlButton,
Icon,
GlModal,
+ GlEmptyState,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -35,9 +43,13 @@ export default {
};
},
computed: {
+ ...mapGetters(['isDeleteDisabled']),
iconName() {
return this.isOpen ? 'angle-up' : 'angle-right';
},
+ canDeleteRepo() {
+ return this.repo.canDelete && !this.isDeleteDisabled;
+ },
},
methods: {
...mapActions(['fetchRepos', 'fetchList', 'deleteItem']),
@@ -49,7 +61,7 @@ export default {
}
},
handleDeleteRepository() {
- this.deleteItem(this.repo)
+ return this.deleteItem(this.repo)
.then(() => {
createFlash(__('This container registry has been scheduled for deletion.'), 'notice');
this.fetchRepos();
@@ -67,7 +79,8 @@ export default {
<div class="container-image">
<div class="container-image-head">
<gl-button class="js-toggle-repo btn-link align-baseline" @click="toggleRepo">
- <icon :name="iconName" /> {{ repo.name }}
+ <icon :name="iconName" />
+ {{ repo.name }}
</gl-button>
<clipboard-button
@@ -79,11 +92,13 @@ export default {
<div class="controls d-none d-sm-block float-right">
<gl-button
- v-if="repo.canDelete"
+ v-if="canDeleteRepo"
v-gl-tooltip
v-gl-modal="modalId"
:title="s__('ContainerRegistry|Remove repository')"
:aria-label="s__('ContainerRegistry|Remove repository')"
+ data-track-event="click_button"
+ data-track-label="registry_repository_delete"
class="js-remove-repo btn-inverted"
variant="danger"
>
@@ -95,11 +110,19 @@ export default {
<gl-loading-icon v-if="repo.isLoading" size="md" class="append-bottom-20" />
<div v-else-if="!repo.isLoading && isOpen" class="container-image-tags">
- <table-registry v-if="repo.list.length" :repo="repo" />
-
- <div v-else class="nothing-here-block">
- {{ s__('ContainerRegistry|No tags in Container Registry for this container image.') }}
- </div>
+ <table-registry v-if="repo.list.length" :repo="repo" :can-delete-repo="canDeleteRepo" />
+ <gl-empty-state
+ v-else
+ :title="s__('ContainerRegistry|This image has no active tags')"
+ :description="
+ s__(
+ `ContainerRegistry|The last tag related to this image was recently removed.
+ This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process.
+ If you have any questions, contact your administrator.`,
+ )
+ "
+ class="mx-auto my-0"
+ />
</div>
<gl-modal :modal-id="modalId" ok-variant="danger" @ok="handleDeleteRepository">
<template v-slot:modal-title>{{ s__('ContainerRegistry|Remove repository') }}</template>
diff --git a/app/assets/javascripts/registry/components/group_empty_state.vue b/app/assets/javascripts/registry/components/group_empty_state.vue
new file mode 100644
index 00000000000..7885fd2146d
--- /dev/null
+++ b/app/assets/javascripts/registry/components/group_empty_state.vue
@@ -0,0 +1,46 @@
+<script>
+import { GlEmptyState } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+
+export default {
+ name: 'GroupEmptyState',
+ components: {
+ GlEmptyState,
+ },
+ props: {
+ noContainersImage: {
+ type: String,
+ required: true,
+ },
+ helpPagePath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ noContainerImagesText() {
+ return sprintf(
+ s__(
+ `ContainerRegistry|With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here. %{docLinkStart}More Information%{docLinkEnd}`,
+ ),
+ {
+ docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`,
+ docLinkEnd: '</a>',
+ },
+ false,
+ );
+ },
+ },
+};
+</script>
+<template>
+ <gl-empty-state
+ :title="s__('ContainerRegistry|There are no container images available in this group')"
+ :svg-path="noContainersImage"
+ class="container-message"
+ >
+ <template #description>
+ <p class="js-no-container-images-text" v-html="noContainerImagesText"></p>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/registry/components/project_empty_state.vue b/app/assets/javascripts/registry/components/project_empty_state.vue
new file mode 100644
index 00000000000..80ef31004c8
--- /dev/null
+++ b/app/assets/javascripts/registry/components/project_empty_state.vue
@@ -0,0 +1,133 @@
+<script>
+import { GlEmptyState } from '@gitlab/ui';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { s__, sprintf } from '~/locale';
+
+export default {
+ name: 'ProjectEmptyState',
+ components: {
+ ClipboardButton,
+ GlEmptyState,
+ },
+ props: {
+ noContainersImage: {
+ type: String,
+ required: true,
+ },
+ repositoryUrl: {
+ type: String,
+ required: true,
+ },
+ helpPagePath: {
+ type: String,
+ required: true,
+ },
+ twoFactorAuthHelpLink: {
+ type: String,
+ required: true,
+ },
+ personalAccessTokensHelpLink: {
+ type: String,
+ required: true,
+ },
+ registryHostUrlWithPort: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ dockerBuildCommand() {
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
+ return `docker build -t ${this.repositoryUrl} .`;
+ },
+ dockerPushCommand() {
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
+ return `docker push ${this.repositoryUrl}`;
+ },
+ dockerLoginCommand() {
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
+ return `docker login ${this.registryHostUrlWithPort}`;
+ },
+ noContainerImagesText() {
+ return sprintf(
+ s__(`ContainerRegistry|With the Container Registry, every project can have its own space to
+ store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`),
+ {
+ docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`,
+ docLinkEnd: '</a>',
+ },
+ false,
+ );
+ },
+ notLoggedInToRegistryText() {
+ return sprintf(
+ s__(`ContainerRegistry|If you are not already logged in, you need to authenticate to
+ the Container Registry by using your GitLab username and password. If you have
+ %{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a
+ %{personalAccessTokensDocLinkStart}Personal Access Token%{personalAccessTokensDocLinkEnd}
+ instead of a password.`),
+ {
+ twofaDocLinkStart: `<a href="${this.twoFactorAuthHelpLink}" target="_blank">`,
+ twofaDocLinkEnd: '</a>',
+ personalAccessTokensDocLinkStart: `<a href="${this.personalAccessTokensHelpLink}" target="_blank">`,
+ personalAccessTokensDocLinkEnd: '</a>',
+ },
+ false,
+ );
+ },
+ },
+};
+</script>
+<template>
+ <gl-empty-state
+ :title="s__('ContainerRegistry|There are no container images stored for this project')"
+ :svg-path="noContainersImage"
+ class="container-message"
+ >
+ <template #description>
+ <p class="js-no-container-images-text" v-html="noContainerImagesText"></p>
+ <h5>{{ s__('ContainerRegistry|Quick Start') }}</h5>
+ <p class="js-not-logged-in-to-registry-text" v-html="notLoggedInToRegistryText"></p>
+ <div class="input-group append-bottom-10">
+ <input :value="dockerLoginCommand" type="text" class="form-control monospace" readonly />
+ <span class="input-group-append">
+ <clipboard-button
+ :text="dockerLoginCommand"
+ :title="s__('ContainerRegistry|Copy login command')"
+ class="input-group-text"
+ />
+ </span>
+ </div>
+ <p></p>
+ <p>
+ {{
+ s__(
+ 'ContainerRegistry|You can add an image to this registry with the following commands:',
+ )
+ }}
+ </p>
+
+ <div class="input-group append-bottom-10">
+ <input :value="dockerBuildCommand" type="text" class="form-control monospace" readonly />
+ <span class="input-group-append">
+ <clipboard-button
+ :text="dockerBuildCommand"
+ :title="s__('ContainerRegistry|Copy build command')"
+ class="input-group-text"
+ />
+ </span>
+ </div>
+
+ <div class="input-group">
+ <input :value="dockerPushCommand" type="text" class="form-control monospace" readonly />
+ <span class="input-group-append">
+ <clipboard-button
+ :text="dockerPushCommand"
+ :title="s__('ContainerRegistry|Copy push command')"
+ class="input-group-text"
+ />
+ </span>
+ </div>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue
index e9067bc2b56..8470fbc2b59 100644
--- a/app/assets/javascripts/registry/components/table_registry.vue
+++ b/app/assets/javascripts/registry/components/table_registry.vue
@@ -1,5 +1,5 @@
<script>
-import { mapActions } from 'vuex';
+import { mapActions, mapGetters } from 'vuex';
import {
GlButton,
GlFormCheckbox,
@@ -35,9 +35,15 @@ export default {
type: Object,
required: true,
},
+ canDeleteRepo: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
},
data() {
return {
+ selectedItems: [],
itemsToBeDeleted: [],
modalId: `confirm-image-deletion-modal-${this.repo.id}`,
selectAllChecked: false,
@@ -45,16 +51,17 @@ export default {
};
},
computed: {
+ ...mapGetters(['isDeleteDisabled']),
bulkDeletePath() {
return this.repo.tagsPath ? this.repo.tagsPath.replace('?format=json', '/bulk_destroy') : '';
},
shouldRenderPagination() {
return this.repo.pagination.total > this.repo.pagination.perPage;
},
- modalTitle() {
+ modalAction() {
return n__(
- 'ContainerRegistry|Remove image',
- 'ContainerRegistry|Remove images',
+ 'ContainerRegistry|Remove tag',
+ 'ContainerRegistry|Remove tags',
this.itemsToBeDeleted.length === 0 ? 1 : this.itemsToBeDeleted.length,
);
},
@@ -67,16 +74,14 @@ export default {
setModalDescription(itemIndex = -1) {
if (itemIndex === -1) {
this.modalDescription = sprintf(
- s__(`ContainerRegistry|You are about to delete <b>%{count}</b> images. This will
- delete the images and all tags pointing to them.`),
+ s__(`ContainerRegistry|You are about to remove <b>%{count}</b> tags. Are you sure?`),
{ count: this.itemsToBeDeleted.length },
);
} else {
const { tag } = this.repo.list[itemIndex];
this.modalDescription = sprintf(
- s__(`ContainerRegistry|You are about to delete the image <b>%{title}</b>. This will
- delete the image and all tags pointing to this image.`),
+ s__(`ContainerRegistry|You are about to remove <b>%{title}</b>. Are you sure?`),
{ title: `${this.repo.name}:${tag}` },
);
}
@@ -92,6 +97,7 @@ export default {
},
deleteSingleItem(index) {
this.setModalDescription(index);
+ this.itemsToBeDeleted = [index];
this.$refs.deleteModal.$refs.modal.$once('ok', () => {
this.removeModalEvents();
@@ -99,9 +105,10 @@ export default {
});
},
deleteMultipleItems() {
- if (this.itemsToBeDeleted.length === 1) {
+ this.itemsToBeDeleted = [...this.selectedItems];
+ if (this.selectedItems.length === 1) {
this.setModalDescription(this.itemsToBeDeleted[0]);
- } else if (this.itemsToBeDeleted.length > 1) {
+ } else if (this.selectedItems.length > 1) {
this.setModalDescription();
}
@@ -111,6 +118,7 @@ export default {
});
},
handleSingleDelete(itemToDelete) {
+ this.itemsToBeDeleted = [];
this.deleteItem(itemToDelete)
.then(() => this.fetchList({ repo: this.repo }))
.catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY));
@@ -118,6 +126,7 @@ export default {
handleMultipleDelete() {
const { itemsToBeDeleted } = this;
this.itemsToBeDeleted = [];
+ this.selectedItems = [];
if (this.bulkDeletePath) {
this.multiDeleteItems({
@@ -146,27 +155,30 @@ export default {
}
},
selectAll() {
- this.itemsToBeDeleted = this.repo.list.map((x, index) => index);
+ this.selectedItems = this.repo.list.map((x, index) => index);
this.selectAllChecked = true;
},
deselectAll() {
- this.itemsToBeDeleted = [];
+ this.selectedItems = [];
this.selectAllChecked = false;
},
- updateItemsToBeDeleted(index) {
- const delIndex = this.itemsToBeDeleted.findIndex(x => x === index);
+ updateselectedItems(index) {
+ const delIndex = this.selectedItems.findIndex(x => x === index);
if (delIndex > -1) {
- this.itemsToBeDeleted.splice(delIndex, 1);
+ this.selectedItems.splice(delIndex, 1);
this.selectAllChecked = false;
} else {
- this.itemsToBeDeleted.push(index);
+ this.selectedItems.push(index);
- if (this.itemsToBeDeleted.length === this.repo.list.length) {
+ if (this.selectedItems.length === this.repo.list.length) {
this.selectAllChecked = true;
}
}
},
+ canDeleteRow(item) {
+ return item && item.canDelete && !this.isDeleteDisabled;
+ },
},
};
</script>
@@ -177,7 +189,7 @@ export default {
<tr>
<th>
<gl-form-checkbox
- v-if="repo.canDelete"
+ v-if="canDeleteRepo"
class="js-select-all-checkbox"
:checked="selectAllChecked"
@change="onSelectAllChange"
@@ -189,17 +201,20 @@ export default {
<th>{{ s__('ContainerRegistry|Last Updated') }}</th>
<th>
<gl-button
- v-if="repo.canDelete"
+ v-if="canDeleteRepo"
v-gl-tooltip
v-gl-modal="modalId"
- :disabled="!itemsToBeDeleted || itemsToBeDeleted.length === 0"
+ :disabled="!selectedItems || selectedItems.length === 0"
class="js-delete-registry float-right"
+ data-track-event="click_button"
+ data-track-label="bulk_registry_tag_delete"
variant="danger"
- :title="s__('ContainerRegistry|Remove selected images')"
- :aria-label="s__('ContainerRegistry|Remove selected images')"
+ :title="s__('ContainerRegistry|Remove selected tags')"
+ :aria-label="s__('ContainerRegistry|Remove selected tags')"
@click="deleteMultipleItems()"
- ><icon name="remove"
- /></gl-button>
+ >
+ <icon name="remove" />
+ </gl-button>
</th>
</tr>
</thead>
@@ -207,10 +222,10 @@ export default {
<tr v-for="(item, index) in repo.list" :key="item.tag" class="registry-image-row">
<td class="check">
<gl-form-checkbox
- v-if="item.canDelete"
+ v-if="canDeleteRow(item)"
class="js-select-checkbox"
- :checked="itemsToBeDeleted && itemsToBeDeleted.includes(index)"
- @change="updateItemsToBeDeleted(index)"
+ :checked="selectedItems && selectedItems.includes(index)"
+ @change="updateselectedItems(index)"
/>
</td>
<td class="monospace">
@@ -223,9 +238,9 @@ export default {
/>
</td>
<td>
- <span v-gl-tooltip.bottom class="monospace" :title="item.revision">
- {{ item.shortRevision }}
- </span>
+ <span v-gl-tooltip.bottom class="monospace" :title="item.revision">{{
+ item.shortRevision
+ }}</span>
</td>
<td>
{{ formatSize(item.size) }}
@@ -236,17 +251,19 @@ export default {
</td>
<td>
- <span v-gl-tooltip.bottom :title="tooltipTitle(item.createdAt)">
- {{ timeFormated(item.createdAt) }}
- </span>
+ <span v-gl-tooltip.bottom :title="tooltipTitle(item.createdAt)">{{
+ timeFormated(item.createdAt)
+ }}</span>
</td>
<td class="content action-buttons">
<gl-button
- v-if="item.canDelete"
+ v-if="canDeleteRow(item)"
v-gl-modal="modalId"
- :title="s__('ContainerRegistry|Remove image')"
- :aria-label="s__('ContainerRegistry|Remove image')"
+ :title="s__('ContainerRegistry|Remove tag')"
+ :aria-label="s__('ContainerRegistry|Remove tag')"
+ data-track-event="click_button"
+ data-track-label="registry_tag_delete"
variant="danger"
class="js-delete-registry-row float-right btn-inverted btn-border-color btn-icon"
@click="deleteSingleItem(index)"
@@ -262,11 +279,12 @@ export default {
v-if="shouldRenderPagination"
:change="onPageChange"
:page-info="repo.pagination"
+ class="js-registry-pagination"
/>
<gl-modal ref="deleteModal" :modal-id="modalId" ok-variant="danger">
- <template v-slot:modal-title>{{ modalTitle }}</template>
- <template v-slot:modal-ok>{{ s__('ContainerRegistry|Remove image(s) and tags') }}</template>
+ <template v-slot:modal-title>{{ modalAction }}</template>
+ <template v-slot:modal-ok>{{ modalAction }}</template>
<p v-html="modalDescription"></p>
</gl-modal>
</div>
diff --git a/app/assets/javascripts/registry/index.js b/app/assets/javascripts/registry/index.js
index d8daec29fda..18fd360f586 100644
--- a/app/assets/javascripts/registry/index.js
+++ b/app/assets/javascripts/registry/index.js
@@ -13,23 +13,24 @@ export default () =>
data() {
const { dataset } = document.querySelector(this.$options.el);
return {
- endpoint: dataset.endpoint,
- characterError: Boolean(dataset.characterError),
- helpPagePath: dataset.helpPagePath,
- noContainersImage: dataset.noContainersImage,
- containersErrorImage: dataset.containersErrorImage,
- repositoryUrl: dataset.repositoryUrl,
+ registryData: {
+ endpoint: dataset.endpoint,
+ characterError: Boolean(dataset.characterError),
+ helpPagePath: dataset.helpPagePath,
+ noContainersImage: dataset.noContainersImage,
+ containersErrorImage: dataset.containersErrorImage,
+ repositoryUrl: dataset.repositoryUrl,
+ isGroupPage: dataset.isGroupPage,
+ personalAccessTokensHelpLink: dataset.personalAccessTokensHelpLink,
+ registryHostUrlWithPort: dataset.registryHostUrlWithPort,
+ twoFactorAuthHelpLink: dataset.twoFactorAuthHelpLink,
+ },
};
},
render(createElement) {
return createElement('registry-app', {
props: {
- endpoint: this.endpoint,
- characterError: this.characterError,
- helpPagePath: this.helpPagePath,
- noContainersImage: this.noContainersImage,
- containersErrorImage: this.containersErrorImage,
- repositoryUrl: this.repositoryUrl,
+ ...this.registryData,
},
});
},
diff --git a/app/assets/javascripts/registry/stores/actions.js b/app/assets/javascripts/registry/stores/actions.js
index a2e0130e79e..2121f518a7a 100644
--- a/app/assets/javascripts/registry/stores/actions.js
+++ b/app/assets/javascripts/registry/stores/actions.js
@@ -20,7 +20,6 @@ export const fetchRepos = ({ commit, state }) => {
export const fetchList = ({ commit }, { repo, page }) => {
commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo);
-
return axios
.get(repo.tagsPath, { params: { page } })
.then(response => {
@@ -40,6 +39,7 @@ export const multiDeleteItems = (_, { path, items }) =>
axios.delete(path, { params: { ids: items } });
export const setMainEndpoint = ({ commit }, data) => commit(types.SET_MAIN_ENDPOINT, data);
+export const setIsDeleteDisabled = ({ commit }, data) => commit(types.SET_IS_DELETE_DISABLED, data);
export const toggleLoading = ({ commit }) => commit(types.TOGGLE_MAIN_LOADING);
// prevent babel-plugin-rewire from generating an invalid default during karma tests
diff --git a/app/assets/javascripts/registry/stores/getters.js b/app/assets/javascripts/registry/stores/getters.js
index f4923512578..ac90bde1b2a 100644
--- a/app/assets/javascripts/registry/stores/getters.js
+++ b/app/assets/javascripts/registry/stores/getters.js
@@ -1,5 +1,6 @@
export const isLoading = state => state.isLoading;
export const repos = state => state.repos;
+export const isDeleteDisabled = state => state.isDeleteDisabled;
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/registry/stores/mutation_types.js b/app/assets/javascripts/registry/stores/mutation_types.js
index 2c69bf11807..6740bfede1a 100644
--- a/app/assets/javascripts/registry/stores/mutation_types.js
+++ b/app/assets/javascripts/registry/stores/mutation_types.js
@@ -1,4 +1,5 @@
export const SET_MAIN_ENDPOINT = 'SET_MAIN_ENDPOINT';
+export const SET_IS_DELETE_DISABLED = 'SET_IS_DELETE_DISABLED';
export const SET_REPOS_LIST = 'SET_REPOS_LIST';
export const TOGGLE_MAIN_LOADING = 'TOGGLE_MAIN_LOADING';
diff --git a/app/assets/javascripts/registry/stores/mutations.js b/app/assets/javascripts/registry/stores/mutations.js
index 8ace6657ad1..ea5925247d1 100644
--- a/app/assets/javascripts/registry/stores/mutations.js
+++ b/app/assets/javascripts/registry/stores/mutations.js
@@ -6,6 +6,10 @@ export default {
Object.assign(state, { endpoint });
},
+ [types.SET_IS_DELETE_DISABLED](state, isDeleteDisabled) {
+ Object.assign(state, { isDeleteDisabled });
+ },
+
[types.SET_REPOS_LIST](state, list) {
Object.assign(state, {
repos: list.map(el => ({
@@ -17,6 +21,7 @@ export default {
location: el.location,
name: el.path,
tagsPath: el.tags_path,
+ projectId: el.project_id,
})),
});
},
diff --git a/app/assets/javascripts/registry/stores/state.js b/app/assets/javascripts/registry/stores/state.js
index feeac10cbe1..724c64b4994 100644
--- a/app/assets/javascripts/registry/stores/state.js
+++ b/app/assets/javascripts/registry/stores/state.js
@@ -1,6 +1,7 @@
export default () => ({
isLoading: false,
endpoint: '', // initial endpoint to fetch the repos list
+ isDeleteDisabled: false, // controls the delete buttons in the registry
/**
* Each object in `repos` has the following strucure:
* {
diff --git a/app/assets/javascripts/releases/components/milestone_list.vue b/app/assets/javascripts/releases/components/milestone_list.vue
deleted file mode 100644
index 53416f0ab4d..00000000000
--- a/app/assets/javascripts/releases/components/milestone_list.vue
+++ /dev/null
@@ -1,45 +0,0 @@
-<script>
-import { GlLink, GlTooltipDirective } from '@gitlab/ui';
-import Icon from '~/vue_shared/components/icon.vue';
-import { s__ } from '~/locale';
-
-export default {
- name: 'MilestoneList',
- components: {
- GlLink,
- Icon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- milestones: {
- type: Array,
- required: true,
- },
- },
- computed: {
- labelText() {
- return this.milestones.length === 1 ? s__('Milestone') : s__('Milestones');
- },
- },
-};
-</script>
-<template>
- <div>
- <icon name="flag" class="align-middle" /> <span class="js-label-text">{{ labelText }}</span>
- <template v-for="(milestone, index) in milestones">
- <gl-link
- :key="milestone.id"
- v-gl-tooltip
- :title="milestone.description"
- :href="milestone.web_url"
- >
- {{ milestone.title }}
- </gl-link>
- <template v-if="index !== milestones.length - 1">
- &bull;
- </template>
- </template>
- </div>
-</template>
diff --git a/app/assets/javascripts/releases/detail/components/app.vue b/app/assets/javascripts/releases/detail/components/app.vue
new file mode 100644
index 00000000000..54a441de886
--- /dev/null
+++ b/app/assets/javascripts/releases/detail/components/app.vue
@@ -0,0 +1,156 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
+
+export default {
+ name: 'ReleaseDetailApp',
+ components: {
+ GlFormInput,
+ GlFormGroup,
+ GlButton,
+ MarkdownField,
+ },
+ directives: {
+ autofocusonshow,
+ },
+ computed: {
+ ...mapState([
+ 'isFetchingRelease',
+ 'fetchError',
+ 'markdownDocsPath',
+ 'markdownPreviewPath',
+ 'releasesPagePath',
+ ]),
+ showForm() {
+ return !this.isFetchingRelease && !this.fetchError;
+ },
+ subtitleText() {
+ return sprintf(
+ __(
+ 'Releases are based on Git tags. We recommend naming tags that fit within semantic versioning, for example %{codeStart}v1.0%{codeEnd}, %{codeStart}v2.0-pre%{codeEnd}.',
+ ),
+ {
+ codeStart: '<code>',
+ codeEnd: '</code>',
+ },
+ false,
+ );
+ },
+ tagName() {
+ return this.$store.state.release.tagName;
+ },
+ releaseTitle: {
+ get() {
+ return this.$store.state.release.name;
+ },
+ set(title) {
+ this.updateReleaseTitle(title);
+ },
+ },
+ releaseNotes: {
+ get() {
+ return this.$store.state.release.description;
+ },
+ set(notes) {
+ this.updateReleaseNotes(notes);
+ },
+ },
+ },
+ created() {
+ this.fetchRelease();
+ },
+ methods: {
+ ...mapActions([
+ 'fetchRelease',
+ 'updateRelease',
+ 'updateReleaseTitle',
+ 'updateReleaseNotes',
+ 'navigateToReleasesPage',
+ ]),
+ },
+};
+</script>
+<template>
+ <div class="d-flex flex-column">
+ <p class="pt-3 js-subtitle-text" v-html="subtitleText"></p>
+ <form v-if="showForm" @submit.prevent="updateRelease()">
+ <div class="row">
+ <gl-form-group class="col-md-6 col-lg-5 col-xl-4">
+ <label for="git-ref">{{ __('Tag name') }}</label>
+ <gl-form-input
+ id="git-ref"
+ v-model="tagName"
+ type="text"
+ class="form-control"
+ aria-describedby="tag-name-help"
+ disabled
+ />
+ <div id="tag-name-help" class="form-text text-muted">
+ {{ __('Choose an existing tag, or create a new one') }}
+ </div>
+ </gl-form-group>
+ </div>
+ <gl-form-group>
+ <label for="release-title">{{ __('Release title') }}</label>
+ <gl-form-input
+ id="release-title"
+ ref="releaseTitleInput"
+ v-model="releaseTitle"
+ v-autofocusonshow
+ autofocus
+ type="text"
+ class="form-control"
+ />
+ </gl-form-group>
+ <gl-form-group>
+ <label for="release-notes">{{ __('Release notes') }}</label>
+ <div class="bordered-box pr-3 pl-3">
+ <markdown-field
+ :can-attach-file="true"
+ :markdown-preview-path="markdownPreviewPath"
+ :markdown-docs-path="markdownDocsPath"
+ :add-spacing-classes="false"
+ class="prepend-top-10 append-bottom-10"
+ >
+ <textarea
+ id="release-notes"
+ slot="textarea"
+ v-model="releaseNotes"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ dir="auto"
+ data-supports-quick-actions="false"
+ :aria-label="__('Release notes')"
+ :placeholder="__('Write your release notes or drag your files here…')"
+ @keydown.meta.enter="updateRelease()"
+ @keydown.ctrl.enter="updateRelease()"
+ >
+ </textarea>
+ </markdown-field>
+ </div>
+ </gl-form-group>
+
+ <div class="d-flex pt-3">
+ <gl-button
+ class="mr-auto js-submit-button"
+ variant="success"
+ type="submit"
+ :aria-label="__('Save changes')"
+ >
+ {{ __('Save changes') }}
+ </gl-button>
+ <gl-button
+ class="js-cancel-button"
+ variant="default"
+ type="button"
+ :aria-label="__('Cancel')"
+ @click="navigateToReleasesPage()"
+ >
+ {{ __('Cancel') }}
+ </gl-button>
+ </div>
+ </form>
+ </div>
+</template>
diff --git a/app/assets/javascripts/releases/detail/index.js b/app/assets/javascripts/releases/detail/index.js
new file mode 100644
index 00000000000..3da971e6d90
--- /dev/null
+++ b/app/assets/javascripts/releases/detail/index.js
@@ -0,0 +1,19 @@
+import Vue from 'vue';
+import ReleaseDetailApp from './components/app.vue';
+import createStore from './store';
+
+export default () => {
+ const el = document.getElementById('js-edit-release-page');
+
+ const store = createStore(el.dataset);
+ store.dispatch('setInitialState', el.dataset);
+
+ return new Vue({
+ el,
+ store,
+ components: { ReleaseDetailApp },
+ render(createElement) {
+ return createElement('release-detail-app');
+ },
+ });
+};
diff --git a/app/assets/javascripts/releases/detail/store/actions.js b/app/assets/javascripts/releases/detail/store/actions.js
new file mode 100644
index 00000000000..c9749582f5c
--- /dev/null
+++ b/app/assets/javascripts/releases/detail/store/actions.js
@@ -0,0 +1,62 @@
+import * as types from './mutation_types';
+import api from '~/api';
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+import { redirectTo } from '~/lib/utils/url_utility';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
+export const setInitialState = ({ commit }, initialState) =>
+ commit(types.SET_INITIAL_STATE, initialState);
+
+export const requestRelease = ({ commit }) => commit(types.REQUEST_RELEASE);
+export const receiveReleaseSuccess = ({ commit }, data) =>
+ commit(types.RECEIVE_RELEASE_SUCCESS, data);
+export const receiveReleaseError = ({ commit }, error) => {
+ commit(types.RECEIVE_RELEASE_ERROR, error);
+ createFlash(s__('Release|Something went wrong while getting the release details'));
+};
+
+export const fetchRelease = ({ dispatch, state }) => {
+ dispatch('requestRelease');
+
+ return api
+ .release(state.projectId, state.tagName)
+ .then(({ data: release }) => {
+ const camelCasedRelease = convertObjectPropsToCamelCase(release, { deep: true });
+ dispatch('receiveReleaseSuccess', camelCasedRelease);
+ })
+ .catch(error => {
+ dispatch('receiveReleaseError', error);
+ });
+};
+
+export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_RELEASE_TITLE, title);
+export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_RELEASE_NOTES, notes);
+
+export const requestUpdateRelease = ({ commit }) => commit(types.REQUEST_UPDATE_RELEASE);
+export const receiveUpdateReleaseSuccess = ({ commit, dispatch }) => {
+ commit(types.RECEIVE_UPDATE_RELEASE_SUCCESS);
+ dispatch('navigateToReleasesPage');
+};
+export const receiveUpdateReleaseError = ({ commit }, error) => {
+ commit(types.RECEIVE_UPDATE_RELEASE_ERROR, error);
+ createFlash(s__('Release|Something went wrong while saving the release details'));
+};
+
+export const updateRelease = ({ dispatch, state }) => {
+ dispatch('requestUpdateRelease');
+
+ return api
+ .updateRelease(state.projectId, state.tagName, {
+ name: state.release.name,
+ description: state.release.description,
+ })
+ .then(() => dispatch('receiveUpdateReleaseSuccess'))
+ .catch(error => {
+ dispatch('receiveUpdateReleaseError', error);
+ });
+};
+
+export const navigateToReleasesPage = ({ state }) => {
+ redirectTo(state.releasesPagePath);
+};
diff --git a/app/assets/javascripts/releases/detail/store/index.js b/app/assets/javascripts/releases/detail/store/index.js
new file mode 100644
index 00000000000..e8623a49356
--- /dev/null
+++ b/app/assets/javascripts/releases/detail/store/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import mutations from './mutations';
+import state from './state';
+
+Vue.use(Vuex);
+
+export default () =>
+ new Vuex.Store({
+ actions,
+ mutations,
+ state,
+ });
diff --git a/app/assets/javascripts/releases/detail/store/mutation_types.js b/app/assets/javascripts/releases/detail/store/mutation_types.js
new file mode 100644
index 00000000000..75e1d78a645
--- /dev/null
+++ b/app/assets/javascripts/releases/detail/store/mutation_types.js
@@ -0,0 +1,12 @@
+export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
+
+export const REQUEST_RELEASE = 'REQUEST_RELEASE';
+export const RECEIVE_RELEASE_SUCCESS = 'RECEIVE_RELEASE_SUCCESS';
+export const RECEIVE_RELEASE_ERROR = 'RECEIVE_RELEASE_ERROR';
+
+export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE';
+export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES';
+
+export const REQUEST_UPDATE_RELEASE = 'REQUEST_UPDATE_RELEASE';
+export const RECEIVE_UPDATE_RELEASE_SUCCESS = 'RECEIVE_UPDATE_RELEASE_SUCCESS';
+export const RECEIVE_UPDATE_RELEASE_ERROR = 'RECEIVE_UPDATE_RELEASE_ERROR';
diff --git a/app/assets/javascripts/releases/detail/store/mutations.js b/app/assets/javascripts/releases/detail/store/mutations.js
new file mode 100644
index 00000000000..d739978d755
--- /dev/null
+++ b/app/assets/javascripts/releases/detail/store/mutations.js
@@ -0,0 +1,42 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_INITIAL_STATE](state, initialState) {
+ Object.keys(state).forEach(key => {
+ state[key] = initialState[key];
+ });
+ },
+
+ [types.REQUEST_RELEASE](state) {
+ state.isFetchingRelease = true;
+ },
+ [types.RECEIVE_RELEASE_SUCCESS](state, data) {
+ state.fetchError = undefined;
+ state.isFetchingRelease = false;
+ state.release = data;
+ },
+ [types.RECEIVE_RELEASE_ERROR](state, error) {
+ state.fetchError = error;
+ state.isFetchingRelease = false;
+ state.release = undefined;
+ },
+
+ [types.UPDATE_RELEASE_TITLE](state, title) {
+ state.release.name = title;
+ },
+ [types.UPDATE_RELEASE_NOTES](state, notes) {
+ state.release.description = notes;
+ },
+
+ [types.REQUEST_UPDATE_RELEASE](state) {
+ state.isUpdatingRelease = true;
+ },
+ [types.RECEIVE_UPDATE_RELEASE_SUCCESS](state) {
+ state.updateError = undefined;
+ state.isUpdatingRelease = false;
+ },
+ [types.RECEIVE_UPDATE_RELEASE_ERROR](state, error) {
+ state.updateError = error;
+ state.isUpdatingRelease = false;
+ },
+};
diff --git a/app/assets/javascripts/releases/detail/store/state.js b/app/assets/javascripts/releases/detail/store/state.js
new file mode 100644
index 00000000000..ff98e2bed78
--- /dev/null
+++ b/app/assets/javascripts/releases/detail/store/state.js
@@ -0,0 +1,15 @@
+export default () => ({
+ projectId: null,
+ tagName: null,
+ releasesPagePath: null,
+ markdownDocsPath: null,
+ markdownPreviewPath: null,
+
+ release: null,
+
+ isFetchingRelease: false,
+ fetchError: null,
+
+ isUpdatingRelease: false,
+ updateError: null,
+});
diff --git a/app/assets/javascripts/releases/components/app.vue b/app/assets/javascripts/releases/list/components/app.vue
index 5a06c4fec58..5a06c4fec58 100644
--- a/app/assets/javascripts/releases/components/app.vue
+++ b/app/assets/javascripts/releases/list/components/app.vue
diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/list/components/release_block.vue
index 2dacd8549ad..8d4b32e9dc0 100644
--- a/app/assets/javascripts/releases/components/release_block.vue
+++ b/app/assets/javascripts/releases/list/components/release_block.vue
@@ -1,26 +1,29 @@
<script>
/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import _ from 'underscore';
-import { GlTooltipDirective, GlLink, GlBadge } from '@gitlab/ui';
+import { GlTooltipDirective, GlLink, GlBadge, GlButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
-import MilestoneList from './milestone_list.vue';
-import { __, sprintf } from '../../locale';
+import { __, n__, sprintf } from '~/locale';
+import { slugify } from '~/lib/utils/text_utility';
+import { getLocationHash } from '~/lib/utils/url_utility';
+import { scrollToElement } from '~/lib/utils/common_utils';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
name: 'ReleaseBlock',
components: {
GlLink,
GlBadge,
+ GlButton,
Icon,
UserAvatarLink,
- MilestoneList,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [timeagoMixin],
+ mixins: [timeagoMixin, glFeatureFlagsMixin()],
props: {
release: {
type: Object,
@@ -28,7 +31,15 @@ export default {
default: () => ({}),
},
},
+ data() {
+ return {
+ isHighlighted: false,
+ };
+ },
computed: {
+ id() {
+ return slugify(this.release.tag_name);
+ },
releasedTimeAgo() {
return sprintf(__('released %{time}'), {
time: this.timeFormated(this.release.released_at),
@@ -42,6 +53,12 @@ export default {
commit() {
return this.release.commit || {};
},
+ commitUrl() {
+ return this.release.commit_path;
+ },
+ tagUrl() {
+ return this.release.tag_path;
+ },
assets() {
return this.release.assets || {};
},
@@ -51,49 +68,90 @@ export default {
hasAuthor() {
return !_.isEmpty(this.author);
},
- milestones() {
- // At the moment, a release can only be associated to
- // one milestone. This will be expanded to be many-to-many
- // in the near future, so we pass the milestone as an
- // array here in anticipation of this change.
- return [this.release.milestone];
- },
shouldRenderMilestones() {
- // Similar to the `milestones` computed above,
- // this check will need to be updated once
- // the API begins sending an array of milestones
- // instead of just a single object.
- return Boolean(this.release.milestone);
+ return !_.isEmpty(this.release.milestones);
+ },
+ labelText() {
+ return n__('Milestone', 'Milestones', this.release.milestones.length);
+ },
+ shouldShowEditButton() {
+ return Boolean(
+ this.glFeatures.releaseEditPage && this.release._links && this.release._links.edit,
+ );
},
},
+ mounted() {
+ const hash = getLocationHash();
+ if (hash && slugify(hash) === this.id) {
+ this.isHighlighted = true;
+ setTimeout(() => {
+ this.isHighlighted = false;
+ }, 2000);
+
+ scrollToElement(this.$el);
+ }
+ },
};
</script>
<template>
- <div :id="release.tag_name" class="card">
+ <div :id="id" :class="{ 'bg-line-target-blue': isHighlighted }" class="card release-block">
<div class="card-body">
- <h2 class="card-title mt-0">
- {{ release.name }}
- <gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{
- __('Upcoming Release')
- }}</gl-badge>
- </h2>
+ <div class="d-flex align-items-start">
+ <h2 class="card-title mt-0 mr-auto">
+ {{ release.name }}
+ <gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{
+ __('Upcoming Release')
+ }}</gl-badge>
+ </h2>
+ <gl-link
+ v-if="shouldShowEditButton"
+ v-gl-tooltip
+ class="btn btn-default js-edit-button ml-2"
+ :title="__('Edit this release')"
+ :href="release._links.edit"
+ >
+ <icon name="pencil" />
+ </gl-link>
+ </div>
<div class="card-subtitle d-flex flex-wrap text-secondary">
<div class="append-right-8">
<icon name="commit" class="align-middle" />
- <span v-gl-tooltip.bottom :title="commit.title">{{ commit.short_id }}</span>
+ <gl-link v-if="commitUrl" v-gl-tooltip.bottom :title="commit.title" :href="commitUrl">
+ {{ commit.short_id }}
+ </gl-link>
+ <span v-else v-gl-tooltip.bottom :title="commit.title">{{ commit.short_id }}</span>
</div>
<div class="append-right-8">
<icon name="tag" class="align-middle" />
- <span v-gl-tooltip.bottom :title="__('Tag')">{{ release.tag_name }}</span>
+ <gl-link v-if="tagUrl" v-gl-tooltip.bottom :title="__('Tag')" :href="tagUrl">
+ {{ release.tag_name }}
+ </gl-link>
+ <span v-else v-gl-tooltip.bottom :title="__('Tag')">{{ release.tag_name }}</span>
</div>
- <milestone-list
- v-if="shouldRenderMilestones"
- class="append-right-4 js-milestone-list"
- :milestones="milestones"
- />
+ <template v-if="shouldRenderMilestones">
+ <div class="js-milestone-list-label">
+ <icon name="flag" class="align-middle" />
+ <span class="js-label-text">{{ labelText }}</span>
+ </div>
+
+ <template v-for="(milestone, index) in release.milestones">
+ <gl-link
+ :key="milestone.id"
+ v-gl-tooltip
+ :title="milestone.description"
+ :href="milestone.web_url"
+ class="append-right-4 prepend-left-4 js-milestone-link"
+ >
+ {{ milestone.title }}
+ </gl-link>
+ <template v-if="index !== release.milestones.length - 1">
+ &bull;
+ </template>
+ </template>
+ </template>
<div class="append-right-4">
&bull;
diff --git a/app/assets/javascripts/releases/index.js b/app/assets/javascripts/releases/list/index.js
index adbed3cb8e2..adbed3cb8e2 100644
--- a/app/assets/javascripts/releases/index.js
+++ b/app/assets/javascripts/releases/list/index.js
diff --git a/app/assets/javascripts/releases/store/actions.js b/app/assets/javascripts/releases/list/store/actions.js
index e0a922d5ef6..e0a922d5ef6 100644
--- a/app/assets/javascripts/releases/store/actions.js
+++ b/app/assets/javascripts/releases/list/store/actions.js
diff --git a/app/assets/javascripts/releases/store/index.js b/app/assets/javascripts/releases/list/store/index.js
index 968b94f0e0d..968b94f0e0d 100644
--- a/app/assets/javascripts/releases/store/index.js
+++ b/app/assets/javascripts/releases/list/store/index.js
diff --git a/app/assets/javascripts/releases/store/mutation_types.js b/app/assets/javascripts/releases/list/store/mutation_types.js
index a74bf15c515..a74bf15c515 100644
--- a/app/assets/javascripts/releases/store/mutation_types.js
+++ b/app/assets/javascripts/releases/list/store/mutation_types.js
diff --git a/app/assets/javascripts/releases/store/mutations.js b/app/assets/javascripts/releases/list/store/mutations.js
index b97dc6cb0ab..b97dc6cb0ab 100644
--- a/app/assets/javascripts/releases/store/mutations.js
+++ b/app/assets/javascripts/releases/list/store/mutations.js
diff --git a/app/assets/javascripts/releases/store/state.js b/app/assets/javascripts/releases/list/store/state.js
index bf25e651c99..bf25e651c99 100644
--- a/app/assets/javascripts/releases/store/state.js
+++ b/app/assets/javascripts/releases/list/store/state.js
diff --git a/app/assets/javascripts/reports/components/modal.vue b/app/assets/javascripts/reports/components/modal.vue
index cb9c1642608..6019af2dfe0 100644
--- a/app/assets/javascripts/reports/components/modal.vue
+++ b/app/assets/javascripts/reports/components/modal.vue
@@ -1,13 +1,13 @@
<script>
// import { sprintf, __ } from '~/locale';
-import Modal from '~/vue_shared/components/gl_modal.vue';
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import CodeBlock from '~/vue_shared/components/code_block.vue';
import { fieldTypes } from '../constants';
export default {
components: {
- Modal,
+ Modal: DeprecatedModal2,
LoadingButton,
CodeBlock,
},
diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue
index 24612c8681a..45c890769a0 100644
--- a/app/assets/javascripts/reports/components/report_section.vue
+++ b/app/assets/javascripts/reports/components/report_section.vue
@@ -179,7 +179,7 @@ export default {
<button
v-if="isCollapsible"
type="button"
- class="js-collapse-btn btn float-right btn-sm qa-expand-report-button"
+ class="js-collapse-btn btn float-right btn-sm align-self-start qa-expand-report-button"
@click="toggleCollapsed"
>
{{ collapseText }}
diff --git a/app/assets/javascripts/reports/store/utils.js b/app/assets/javascripts/reports/store/utils.js
index 10560d0ae8e..7381f038eaf 100644
--- a/app/assets/javascripts/reports/store/utils.js
+++ b/app/assets/javascripts/reports/store/utils.js
@@ -11,7 +11,7 @@ const textBuilder = results => {
const { failed, resolved, total } = results;
const failedString = failed
- ? n__('%d failed test result', '%d failed test results', failed)
+ ? n__('%d failed/error test result', '%d failed/error test results', failed)
: null;
const resolvedString = resolved
? n__('%d fixed test result', '%d fixed test results', resolved)
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index e2060d4aeec..19a2db2db25 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import { GlTooltipDirective, GlLink, GlButton, GlLoadingIcon } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import Icon from '../../vue_shared/components/icon.vue';
@@ -113,7 +112,7 @@ export default {
>
{{ commit.author.name }}
</gl-link>
- authored
+ {{ s__('LastCommit|authored') }}
<timeago-tooltip :time="commit.authoredDate" tooltip-placement="bottom" />
</div>
<pre
@@ -125,6 +124,7 @@ export default {
</pre>
</div>
<div class="commit-actions flex-row">
+ <div v-if="commit.signatureHtml" v-html="commit.signatureHtml"></div>
<gl-link
v-if="commit.latestPipeline"
v-gl-tooltip
@@ -144,7 +144,7 @@ export default {
</div>
<clipboard-button
:text="commit.sha"
- :title="__('Copy commit SHA to clipboard')"
+ :title="__('Copy commit SHA')"
tooltip-placement="bottom"
/>
</div>
diff --git a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql
index 3bdfd979fa4..71c1bf12749 100644
--- a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql
+++ b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql
@@ -13,6 +13,7 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) {
avatarUrl
webUrl
}
+ signatureHtml
latestPipeline {
detailedStatus {
detailsPath
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 0cc7a22325b..87454ee056f 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, consistent-return, one-var, prefer-template, no-else-return, no-param-reassign */
+/* eslint-disable func-names, no-var, consistent-return, one-var, no-else-return, no-param-reassign */
import $ from 'jquery';
import _ from 'underscore';
@@ -247,7 +247,7 @@ Sidebar.prototype.isOpen = function() {
};
Sidebar.prototype.getBlock = function(name) {
- return this.sidebar.find('.block.' + name);
+ return this.sidebar.find(`.block.${name}`);
};
export default Sidebar;
diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js
index 2f37dcec197..f6722ff7bca 100644
--- a/app/assets/javascripts/search_autocomplete.js
+++ b/app/assets/javascripts/search_autocomplete.js
@@ -1,4 +1,4 @@
-/* eslint-disable no-return-assign, one-var, no-var, consistent-return, prefer-template, class-methods-use-this, no-lonely-if, vars-on-top */
+/* eslint-disable no-return-assign, one-var, no-var, consistent-return, class-methods-use-this, no-lonely-if, vars-on-top */
import $ from 'jquery';
import { escape, throttle } from 'underscore';
@@ -416,7 +416,7 @@ export class SearchAutocomplete {
inputs = Object.keys(this.originalState);
for (i = 0, len = inputs.length; i < len; i += 1) {
input = inputs[i];
- this.getElement('#' + input).val(this.originalState[input]);
+ this.getElement(`#${input}`).val(this.originalState[input]);
}
}
@@ -426,7 +426,7 @@ export class SearchAutocomplete {
results = [];
for (i = 0, len = inputs.length; i < len; i += 1) {
input = inputs[i];
- results.push(this.getElement('#' + input).val(''));
+ results.push(this.getElement(`#${input}`).val(''));
}
return results;
}
diff --git a/app/assets/javascripts/serverless/components/url.vue b/app/assets/javascripts/serverless/components/url.vue
index e47a03f1939..5e30c8d614e 100644
--- a/app/assets/javascripts/serverless/components/url.vue
+++ b/app/assets/javascripts/serverless/components/url.vue
@@ -23,7 +23,7 @@ export default {
<div class="url-text-field label label-monospace monospace">{{ uri }}</div>
<clipboard-button
:text="uri"
- :title="s__('ServerlessURL|Copy URL to clipboard')"
+ :title="s__('ServerlessURL|Copy URL')"
class="input-group-text js-clipboard-btn"
/>
<gl-button
diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
index 35eba266625..df950e79690 100644
--- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
+++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
@@ -163,7 +163,7 @@ export default {
:ok-title="s__('SetStatusModal|Set status')"
:cancel-title="s__('SetStatusModal|Remove status')"
ok-variant="success"
- class="set-user-status-modal"
+ modal-class="set-user-status-modal"
@shown="setupEmojiListAndAutocomplete"
@hide="hideEmojiMenu"
@ok="setStatus"
@@ -194,9 +194,9 @@ export default {
v-show="noEmoji"
class="js-no-emoji-placeholder no-emoji-placeholder position-relative"
>
- <icon name="slight-smile" css-classes="award-control-icon-neutral" />
- <icon name="smiley" css-classes="award-control-icon-positive" />
- <icon name="smile" css-classes="award-control-icon-super-positive" />
+ <icon name="slight-smile" class="award-control-icon-neutral" />
+ <icon name="smiley" class="award-control-icon-positive" />
+ <icon name="smile" class="award-control-icon-super-positive" />
</span>
</button>
</span>
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
index 71a1fc31315..052bb3dcb53 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
@@ -42,6 +42,7 @@ export default {
:width="imgSize"
:class="`s${imgSize}`"
class="avatar avatar-inline m-0"
+ data-qa-selector="avatar_image"
/>
<i v-if="hasMergeIcon" aria-hidden="true" class="fa fa-exclamation-triangle merge-icon"></i>
</span>
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
index 63b93a80ead..b107e9789a7 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
@@ -1,6 +1,5 @@
<script>
import { n__ } from '~/locale';
-import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar';
export default {
name: 'AssigneeTitle',
@@ -30,22 +29,20 @@ export default {
return n__('Assignee', `%d Assignees`, assignees);
},
},
- methods: {
- trackEdit() {
- trackEvent('click_edit_button', 'assignee');
- },
- },
};
</script>
<template>
- <div class="title hide-collapsed">
+ <div class="title hide-collapsed" data-qa-selector="assignee_title">
{{ assigneeTitle }}
<i v-if="loading" aria-hidden="true" class="fa fa-spinner fa-spin block-loading"></i>
<a
v-if="editable"
class="js-sidebar-dropdown-toggle edit-link float-right"
href="#"
- @click.prevent="trackEdit"
+ data-qa-selector="assignee_edit_link"
+ data-track-event="click_edit_button"
+ data-track-label="right_sidebar"
+ data-track-property="assignee"
>
{{ __('Edit') }}
</a>
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 3a4623121f4..3d112bba668 100644
--- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -85,7 +85,12 @@ export default {
</div>
</div>
<div v-if="renderShowMoreSection" class="user-list-more">
- <button type="button" class="btn-link" @click="toggleShowLess">
+ <button
+ type="button"
+ class="btn-link"
+ data-qa-selector="more_assignees_link"
+ @click="toggleShowLess"
+ >
<template v-if="showLess">
{{ hiddenAssigneesLabel }}
</template>
diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
index 1c75b6148e8..5b3c3642290 100644
--- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
@@ -5,7 +5,7 @@ import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '~/sidebar/event_hub';
import editForm from './edit_form.vue';
-import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar';
+import recaptchaModalImplementor from '~/vue_shared/mixins/recaptcha_modal_implementor';
export default {
components: {
@@ -15,6 +15,7 @@ export default {
directives: {
tooltip,
},
+ mixins: [recaptchaModalImplementor],
props: {
isConfidential: {
required: true,
@@ -52,17 +53,17 @@ export default {
toggleForm() {
this.edit = !this.edit;
},
- onEditClick() {
- this.toggleForm();
-
- trackEvent('click_edit_button', 'confidentiality');
- },
updateConfidentialAttribute(confidential) {
this.service
.update('issue', { confidential })
+ .then(({ data }) => this.checkForSpam(data))
.then(() => window.location.reload())
- .catch(() => {
- Flash(__('Something went wrong trying to change the confidentiality of this issue'));
+ .catch(error => {
+ if (error.name === 'SpamError') {
+ this.openRecaptcha();
+ } else {
+ Flash(__('Something went wrong trying to change the confidentiality of this issue'));
+ }
});
},
},
@@ -72,6 +73,7 @@ export default {
<template>
<div class="block issuable-sidebar-item confidentiality">
<div
+ ref="collapseIcon"
v-tooltip
:title="tooltipLabel"
class="sidebar-collapsed-icon"
@@ -86,9 +88,13 @@ export default {
{{ __('Confidentiality') }}
<a
v-if="isEditable"
+ ref="editLink"
class="float-right confidential-edit"
href="#"
- @click.prevent="onEditClick"
+ data-track-event="click_edit_button"
+ data-track-label="right_sidebar"
+ data-track-property="confidentiality"
+ @click.prevent="toggleForm"
>
{{ __('Edit') }}
</a>
@@ -113,5 +119,7 @@ export default {
{{ __('This issue is confidential') }}
</div>
</div>
+
+ <recaptcha-modal v-if="showRecaptcha" :html="recaptchaHTML" @close="closeRecaptcha" />
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue
index ec2a7b93a98..c7c5e0e20f1 100644
--- a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue
+++ b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue
@@ -6,7 +6,6 @@ import issuableMixin from '~/vue_shared/mixins/issuable';
import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '~/sidebar/event_hub';
import editForm from './edit_form.vue';
-import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar';
export default {
components: {
@@ -66,11 +65,6 @@ export default {
toggleForm() {
this.mediator.store.isLockDialogOpen = !this.mediator.store.isLockDialogOpen;
},
- onEditClick() {
- this.toggleForm();
-
- trackEvent('click_edit_button', 'lock_issue');
- },
updateLockedAttribute(locked) {
this.mediator.service
.update(this.issuableType, {
@@ -114,7 +108,10 @@ export default {
v-if="isEditable"
class="float-right lock-edit"
type="button"
- @click.prevent="onEditClick"
+ data-track-event="click_edit_button"
+ data-track-label="right_sidebar"
+ data-track-property="lock_issue"
+ @click.prevent="toggleForm"
>
{{ __('Edit') }}
</button>
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
index 1f5f19d1931..ea5edb3ce3f 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
@@ -1,10 +1,10 @@
<script>
import { __ } from '~/locale';
+import Tracking from '~/tracking';
import icon from '~/vue_shared/components/icon.vue';
import toggleButton from '~/vue_shared/components/toggle_button.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import eventHub from '../../event_hub';
-import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar';
const ICON_ON = 'notifications';
const ICON_OFF = 'notifications-off';
@@ -19,6 +19,7 @@ export default {
icon,
toggleButton,
},
+ mixins: [Tracking.mixin({ label: 'right_sidebar' })],
props: {
loading: {
type: Boolean,
@@ -65,7 +66,10 @@ export default {
// Component event emission.
this.$emit('toggleSubscription', this.id);
- trackEvent('toggle_button', 'notifications', this.subscribed ? 0 : 1);
+ this.track('toggle_button', {
+ property: 'notifications',
+ value: this.subscribed ? 0 : 1,
+ });
},
onClickCollapsedIcon() {
this.$emit('toggleSidebar');
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue
index 24d5b14ded9..65ecd5be05d 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue
@@ -1,6 +1,5 @@
<script>
import { __, sprintf } from '~/locale';
-import { abbreviateTime } from '~/lib/utils/datetime_utility';
import icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
@@ -41,12 +40,6 @@ export default {
},
},
computed: {
- timeSpent() {
- return this.abbreviateTime(this.timeSpentHumanReadable);
- },
- timeEstimate() {
- return this.abbreviateTime(this.timeEstimateHumanReadable);
- },
divClass() {
if (this.showComparisonState) {
return 'compare';
@@ -73,11 +66,11 @@ export default {
},
text() {
if (this.showComparisonState) {
- return `${this.timeSpent} / ${this.timeEstimate}`;
+ return `${this.timeSpentHumanReadable} / ${this.timeEstimateHumanReadable}`;
} else if (this.showEstimateOnlyState) {
- return `-- / ${this.timeEstimate}`;
+ return `-- / ${this.timeEstimateHumanReadable}`;
} else if (this.showSpentOnlyState) {
- return `${this.timeSpent} / --`;
+ return `${this.timeSpentHumanReadable} / --`;
} else if (this.showNoTimeTrackingState) {
return __('None');
}
@@ -100,11 +93,6 @@ export default {
return this.showNoTimeTrackingState ? __('Time tracking') : this.timeTrackedTooltipText;
},
},
- methods: {
- abbreviateTime(timeStr) {
- return abbreviateTime(timeStr);
- },
- },
};
</script>
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
index e6f2fe2b5fc..3d96405896d 100644
--- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
@@ -82,11 +82,7 @@ export default {
data-boundary="viewport"
@click="handleButtonClick"
>
- <icon
- v-show="collapsed"
- :css-classes="collapsedButtonIconClasses"
- :name="collapsedButtonIcon"
- />
+ <icon v-show="collapsed" :class="collapsedButtonIconClasses" :name="collapsedButtonIcon" />
<span v-show="!collapsed" class="issuable-todo-inner"> {{ buttonLabel }} </span>
<gl-loading-icon v-show="isActionActive" :inline="true" />
</button>
diff --git a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
index 110175a6779..66d1fed7d31 100644
--- a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
+++ b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import '~/gl_dropdown';
import _ from 'underscore';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js
index b70e384fae5..de4a7f89449 100644
--- a/app/assets/javascripts/single_file_diff.js
+++ b/app/assets/javascripts/single_file_diff.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, prefer-arrow-callback, consistent-return, */
+/* eslint-disable consistent-return */
import $ from 'jquery';
import { __ } from './locale';
@@ -40,12 +40,9 @@ export default class SingleFileDiff {
this.$toggleIcon.addClass('fa-caret-down');
}
- $('.js-file-title, .click-to-expand', this.file).on(
- 'click',
- function(e) {
- this.toggleDiff($(e.target));
- }.bind(this),
- );
+ $('.js-file-title, .click-to-expand', this.file).on('click', e => {
+ this.toggleDiff($(e.target));
+ });
}
toggleDiff($target, cb) {
diff --git a/app/assets/javascripts/snippet/snippet_embed.js b/app/assets/javascripts/snippet/snippet_embed.js
index fe08d2c7ebb..6606271c4fa 100644
--- a/app/assets/javascripts/snippet/snippet_embed.js
+++ b/app/assets/javascripts/snippet/snippet_embed.js
@@ -1,25 +1,30 @@
import { __ } from '~/locale';
export default () => {
- const { protocol, host, pathname } = window.location;
const shareBtn = document.querySelector('.js-share-btn');
- const embedBtn = document.querySelector('.js-embed-btn');
- const snippetUrlArea = document.querySelector('.js-snippet-url-area');
- const embedAction = document.querySelector('.js-embed-action');
- const url = `${protocol}//${host + pathname}`;
- shareBtn.addEventListener('click', () => {
- shareBtn.classList.add('is-active');
- embedBtn.classList.remove('is-active');
- snippetUrlArea.value = url;
- embedAction.innerText = __('Share');
- });
+ if (shareBtn) {
+ const { protocol, host, pathname } = window.location;
- embedBtn.addEventListener('click', () => {
- embedBtn.classList.add('is-active');
- shareBtn.classList.remove('is-active');
- const scriptTag = `<script src="${url}.js"></script>`;
- snippetUrlArea.value = scriptTag;
- embedAction.innerText = __('Embed');
- });
+ const embedBtn = document.querySelector('.js-embed-btn');
+
+ const snippetUrlArea = document.querySelector('.js-snippet-url-area');
+ const embedAction = document.querySelector('.js-embed-action');
+ const url = `${protocol}//${host + pathname}`;
+
+ shareBtn.addEventListener('click', () => {
+ shareBtn.classList.add('is-active');
+ embedBtn.classList.remove('is-active');
+ snippetUrlArea.value = url;
+ embedAction.innerText = __('Share');
+ });
+
+ embedBtn.addEventListener('click', () => {
+ embedBtn.classList.add('is-active');
+ shareBtn.classList.remove('is-active');
+ const scriptTag = `<script src="${url}.js"></script>`;
+ snippetUrlArea.value = scriptTag;
+ embedAction.innerText = __('Embed');
+ });
+ }
};
diff --git a/app/assets/javascripts/templates/issuable_template_selector.js b/app/assets/javascripts/templates/issuable_template_selector.js
index 78609ce0610..10ad4170930 100644
--- a/app/assets/javascripts/templates/issuable_template_selector.js
+++ b/app/assets/javascripts/templates/issuable_template_selector.js
@@ -8,10 +8,13 @@ import { __ } from '~/locale';
export default class IssuableTemplateSelector extends TemplateSelector {
constructor(...args) {
super(...args);
+
this.projectPath = this.dropdown.data('projectPath');
this.namespacePath = this.dropdown.data('namespacePath');
this.issuableType = this.$dropdownContainer.data('issuableType');
this.titleInput = $(`#${this.issuableType}_title`);
+ this.templateWarningEl = $('.js-issuable-template-warning');
+ this.warnTemplateOverride = args[0].warnTemplateOverride;
const initialQuery = {
name: this.dropdown.data('selected'),
@@ -24,14 +27,62 @@ export default class IssuableTemplateSelector extends TemplateSelector {
});
$('.no-template', this.dropdown.parent()).on('click', () => {
- this.currentTemplate.content = '';
- this.setInputValueToTemplateContent();
- $('.dropdown-toggle-text', this.dropdown).text(__('Choose a template'));
+ this.reset();
+ });
+
+ this.templateWarningEl.find('.js-close-btn').on('click', () => {
+ // Explicitly check against 0 value
+ if (this.previousSelectedIndex !== undefined) {
+ this.dropdown.data('glDropdown').selectRowAtIndex(this.previousSelectedIndex);
+ } else {
+ this.reset();
+ }
+
+ this.templateWarningEl.addClass('hidden');
});
+
+ this.templateWarningEl.find('.js-override-template').on('click', () => {
+ this.requestFile(this.overridingTemplate);
+ this.setSelectedIndex();
+
+ this.templateWarningEl.addClass('hidden');
+ this.overridingTemplate = null;
+ });
+ }
+
+ reset() {
+ if (this.currentTemplate) {
+ this.currentTemplate.content = '';
+ }
+
+ this.setInputValueToTemplateContent();
+ $('.dropdown-toggle-text', this.dropdown).text(__('Choose a template'));
+ this.previousSelectedIndex = null;
+ }
+
+ setSelectedIndex() {
+ this.previousSelectedIndex = this.dropdown.data('glDropdown').selectedIndex;
+ }
+
+ onDropdownClicked(query) {
+ const content = this.getEditorContent();
+ const isContentUnchanged =
+ content === '' || (this.currentTemplate && content === this.currentTemplate.content);
+
+ if (!this.warnTemplateOverride || isContentUnchanged) {
+ super.onDropdownClicked(query);
+ this.setSelectedIndex();
+
+ return;
+ }
+
+ this.overridingTemplate = query.selectedObj;
+ this.templateWarningEl.removeClass('hidden');
}
requestFile(query) {
this.startLoadingSpinner();
+
Api.issueTemplate(
this.namespacePath,
this.projectPath,
@@ -59,6 +110,7 @@ export default class IssuableTemplateSelector extends TemplateSelector {
} else {
this.setEditorContent(this.currentTemplate, { skipFocus: false });
}
+
return;
}
}
diff --git a/app/assets/javascripts/templates/issuable_template_selectors.js b/app/assets/javascripts/templates/issuable_template_selectors.js
index 50e58ec5c46..443b3084113 100644
--- a/app/assets/javascripts/templates/issuable_template_selectors.js
+++ b/app/assets/javascripts/templates/issuable_template_selectors.js
@@ -4,7 +4,7 @@ import $ from 'jquery';
import IssuableTemplateSelector from './issuable_template_selector';
export default class IssuableTemplateSelectors {
- constructor({ $dropdowns, editor } = {}) {
+ constructor({ $dropdowns, editor, warnTemplateOverride } = {}) {
this.$dropdowns = $dropdowns || $('.js-issuable-selector');
this.editor = editor || this.initEditor();
@@ -16,6 +16,7 @@ export default class IssuableTemplateSelectors {
wrapper: $dropdown.closest('.js-issuable-selector-wrap'),
dropdown: $dropdown,
editor: this.editor,
+ warnTemplateOverride,
});
});
}
diff --git a/app/assets/javascripts/test_utils/index.js b/app/assets/javascripts/test_utils/index.js
index 1e75ee60671..1a1f3e8d0a8 100644
--- a/app/assets/javascripts/test_utils/index.js
+++ b/app/assets/javascripts/test_utils/index.js
@@ -1,8 +1,10 @@
import 'core-js/es/map';
import 'core-js/es/set';
+import { Sortable } from 'sortablejs';
import simulateDrag from './simulate_drag';
import simulateInput from './simulate_input';
// Export to global space for rspec to use
window.simulateDrag = simulateDrag;
window.simulateInput = simulateInput;
+window.Sortable = Sortable;
diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js
index 1b4ca1d5741..7c0097fbe37 100644
--- a/app/assets/javascripts/tracking.js
+++ b/app/assets/javascripts/tracking.js
@@ -1,4 +1,4 @@
-import $ from 'jquery';
+import _ from 'underscore';
const DEFAULT_SNOWPLOW_OPTIONS = {
namespace: 'gl',
@@ -14,18 +14,31 @@ const DEFAULT_SNOWPLOW_OPTIONS = {
linkClickTracking: false,
};
-const extractData = (el, opts = {}) => {
- const { trackEvent, trackLabel = '', trackProperty = '' } = el.dataset;
- let trackValue = el.dataset.trackValue || el.value || '';
- if (el.type === 'checkbox' && !el.checked) trackValue = false;
- return [
- trackEvent + (opts.suffix || ''),
- {
- label: trackLabel,
- property: trackProperty,
- value: trackValue,
- },
- ];
+const eventHandler = (e, func, opts = {}) => {
+ const el = e.target.closest('[data-track-event]');
+ const action = el && el.dataset.trackEvent;
+ if (!action) return;
+
+ let value = el.dataset.trackValue || el.value || undefined;
+ if (el.type === 'checkbox' && !el.checked) value = false;
+
+ const data = {
+ label: el.dataset.trackLabel,
+ property: el.dataset.trackProperty,
+ value,
+ context: el.dataset.trackContext,
+ };
+
+ func(opts.category, action + (opts.suffix || ''), _.omit(data, _.isUndefined));
+};
+
+const eventHandlers = (category, func) => {
+ const handler = opts => e => eventHandler(e, func, { ...{ category }, ...opts });
+ const handlers = [];
+ handlers.push({ name: 'click', func: handler() });
+ handlers.push({ name: 'show.bs.dropdown', func: handler({ suffix: '_show' }) });
+ handlers.push({ name: 'hide.bs.dropdown', func: handler({ suffix: '_hide' }) });
+ return handlers;
};
export default class Tracking {
@@ -39,49 +52,43 @@ export default class Tracking {
return typeof window.snowplow === 'function' && this.trackable();
}
- static event(category = document.body.dataset.page, event = 'generic', data = {}) {
+ static event(category = document.body.dataset.page, action = 'generic', data = {}) {
if (!this.enabled()) return false;
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
if (!category) throw new Error('Tracking: no category provided for tracking.');
- return window.snowplow(
- 'trackStructEvent',
- category,
- event,
- Object.assign({}, { label: '', property: '', value: '' }, data),
- );
+ const { label, property, value, context } = data;
+ const contexts = context ? [context] : undefined;
+ return window.snowplow('trackStructEvent', category, action, label, property, value, contexts);
}
- constructor(category = document.body.dataset.page) {
- this.category = category;
- }
-
- bind(container = document) {
- if (!this.constructor.enabled()) return;
- container.querySelectorAll(`[data-track-event]`).forEach(el => {
- if (this.customHandlingFor(el)) return;
- // jquery is required for select2, so we use it always
- // see: https://github.com/select2/select2/issues/4686
- $(el).on('click', this.eventHandler(this.category));
- });
- }
+ static bindDocument(category = document.body.dataset.page, documentOverride = null) {
+ const el = documentOverride || document;
+ if (!this.enabled() || el.trackingBound) return [];
- customHandlingFor(el) {
- const classes = el.classList;
+ el.trackingBound = true;
- // bootstrap dropdowns
- if (classes.contains('dropdown')) {
- $(el).on('show.bs.dropdown', this.eventHandler(this.category, { suffix: '_show' }));
- $(el).on('hide.bs.dropdown', this.eventHandler(this.category, { suffix: '_hide' }));
- return true;
- }
-
- return false;
+ const handlers = eventHandlers(category, (...args) => this.event(...args));
+ handlers.forEach(event => el.addEventListener(event.name, event.func));
+ return handlers;
}
- eventHandler(category = null, opts = {}) {
- return e => {
- this.constructor.event(category || this.category, ...extractData(e.currentTarget, opts));
+ static mixin(opts) {
+ return {
+ data() {
+ return {
+ tracking: {
+ // eslint-disable-next-line no-underscore-dangle
+ category: this.$options.name || this.$options._componentTag,
+ },
+ };
+ },
+ methods: {
+ track(action, data) {
+ const category = opts.category || data.category || this.tracking.category;
+ Tracking.event(category || 'unspecified', action, { ...opts, ...this.tracking, ...data });
+ },
+ },
};
}
}
@@ -89,7 +96,7 @@ export default class Tracking {
export function initUserTracking() {
if (!Tracking.enabled()) return;
- const opts = Object.assign({}, DEFAULT_SNOWPLOW_OPTIONS, window.snowplowOptions);
+ const opts = { ...DEFAULT_SNOWPLOW_OPTIONS, ...window.snowplowOptions };
window.snowplow('newTracker', opts.namespace, opts.hostname, opts);
window.snowplow('enableActivityTracking', 30, 30);
@@ -97,4 +104,6 @@ export function initUserTracking() {
if (opts.formTracking) window.snowplow('enableFormTracking');
if (opts.linkClickTracking) window.snowplow('enableLinkClickTracking');
+
+ Tracking.bindDocument();
}
diff --git a/app/assets/javascripts/tree.js b/app/assets/javascripts/tree.js
index 3e659c9e7ea..69b3d20914a 100644
--- a/app/assets/javascripts/tree.js
+++ b/app/assets/javascripts/tree.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, consistent-return, no-var, one-var, no-else-return, prefer-arrow-callback, class-methods-use-this */
+/* eslint-disable func-names, consistent-return, no-var, one-var, no-else-return, class-methods-use-this */
import $ from 'jquery';
import { visitUrl } from './lib/utils/url_utility';
@@ -29,7 +29,7 @@ export default class TreeView {
var li, liSelected;
li = $('tr.tree-item');
liSelected = null;
- return $('body').keydown(function(e) {
+ return $('body').keydown(e => {
var next, path;
if ($('input:focus').length > 0 && (e.which === 38 || e.which === 40)) {
return false;
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
index 948f4d5e631..c0b7587be10 100644
--- a/app/assets/javascripts/user_popovers.js
+++ b/app/assets/javascripts/user_popovers.js
@@ -63,7 +63,7 @@ const handleUserPopoverMouseOver = event => {
UsersCache.retrieveById(userId)
.then(userData => {
if (!userData) {
- return;
+ return undefined;
}
Object.assign(user, {
@@ -76,19 +76,16 @@ const handleUserPopoverMouseOver = event => {
loaded: true,
});
- UsersCache.retrieveStatusById(userId)
- .then(status => {
- if (!status) {
- return;
- }
+ return UsersCache.retrieveStatusById(userId);
+ })
+ .then(status => {
+ if (!status) {
+ return;
+ }
- Object.assign(user, {
- status,
- });
- })
- .catch(() => {
- throw new Error(`User status for "${userId}" could not be retrieved!`);
- });
+ Object.assign(user, {
+ status,
+ });
})
.catch(() => {
renderedPopover.$destroy();
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index e78ca56be0e..da1a7c290f8 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, one-var, no-var, prefer-rest-params, vars-on-top, prefer-arrow-callback, consistent-return, no-shadow, no-else-return, no-self-compare, prefer-template, no-unused-expressions, yoda, prefer-spread, camelcase, no-param-reassign */
+/* eslint-disable func-names, one-var, no-var, prefer-rest-params, vars-on-top, consistent-return, no-shadow, no-else-return, no-self-compare, no-unused-expressions, yoda, prefer-spread, camelcase, no-param-reassign */
/* global Issuable */
/* global emitSidebarEvent */
@@ -7,6 +7,7 @@ import _ from 'underscore';
import axios from './lib/utils/axios_utils';
import { s__, __, sprintf } from './locale';
import ModalStore from './boards/stores/modal_store';
+import { parseBoolean } from './lib/utils/common_utils';
// TODO: remove eventHub hack after code splitting refactor
window.emitSidebarEvent = window.emitSidebarEvent || $.noop;
@@ -250,16 +251,12 @@ function UsersSelect(currentUser, els, options = {}) {
return $dropdown.glDropdown({
showMenuAbove,
data(term, callback) {
- return _this.users(
- term,
- options,
- function(users) {
- // GitLabDropdownFilter returns this.instance
- // GitLabDropdownRemote returns this.options.instance
- const glDropdown = this.instance || this.options.instance;
- glDropdown.options.processData(term, users, callback);
- }.bind(this),
- );
+ return _this.users(term, options, users => {
+ // GitLabDropdownFilter returns this.instance
+ // GitLabDropdownRemote returns this.options.instance
+ const glDropdown = this.instance || this.options.instance;
+ glDropdown.options.processData(term, users, callback);
+ });
},
processData(term, data, callback) {
let users = data;
@@ -279,12 +276,13 @@ function UsersSelect(currentUser, els, options = {}) {
})
.map(input => {
const userId = parseInt(input.value, 10);
- const { avatarUrl, avatar_url, name, username } = input.dataset;
+ const { avatarUrl, avatar_url, name, username, canMerge } = input.dataset;
return {
avatar_url: avatarUrl || avatar_url,
id: userId,
name,
username,
+ can_merge: parseBoolean(canMerge),
};
});
@@ -432,8 +430,7 @@ function UsersSelect(currentUser, els, options = {}) {
const isActive = $el.hasClass('is-active');
const previouslySelected = $dropdown
.closest('.selectbox')
- /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
- .find("input[name='" + $dropdown.data('fieldName') + "'][value!=0]");
+ .find(`input[name='${$dropdown.data('fieldName')}'][value!=0]`);
// Enables support for limiting the number of users selected
// Automatically removes the first on the list if more users are selected
@@ -452,7 +449,7 @@ function UsersSelect(currentUser, els, options = {}) {
// Remove unassigned selection (if it was previously selected)
const unassignedSelected = $dropdown
.closest('.selectbox')
- .find("input[name='" + $dropdown.data('fieldName') + "'][value=0]");
+ .find(`input[name='${$dropdown.data('fieldName')}'][value=0]`);
if (unassignedSelected) {
unassignedSelected.remove();
@@ -506,7 +503,7 @@ function UsersSelect(currentUser, els, options = {}) {
} else if (!$dropdown.hasClass('js-multiselect')) {
selected = $dropdown
.closest('.selectbox')
- .find("input[name='" + $dropdown.data('fieldName') + "']")
+ .find(`input[name='${$dropdown.data('fieldName')}']`)
.val();
return assignTo(selected);
}
@@ -548,7 +545,7 @@ function UsersSelect(currentUser, els, options = {}) {
updateLabel: $dropdown.data('dropdownTitle'),
renderRow(user) {
var avatar, img, username;
- username = user.username ? '@' + user.username : '';
+ username = user.username ? `@${user.username}` : '';
avatar = user.avatar_url ? user.avatar_url : gon.default_avatar_url;
let selected = false;
@@ -559,7 +556,7 @@ function UsersSelect(currentUser, els, options = {}) {
const { fieldName } = this;
const field = $dropdown
.closest('.selectbox')
- .find("input[name='" + fieldName + "'][value='" + user.id + "']");
+ .find(`input[name='${fieldName}'][value='${user.id}']`);
if (field.length) {
selected = true;
@@ -575,7 +572,7 @@ function UsersSelect(currentUser, els, options = {}) {
)}</a></li>`;
} else {
// 0 margin, because it's now handled by a wrapper
- img = "<img src='" + avatar + "' class='avatar avatar-inline m-0' width='32' />";
+ img = `<img src='${avatar}' class='avatar avatar-inline m-0' width='32' />`;
}
return _this.renderRow(options.issuableType, user, selected, username, img);
@@ -606,7 +603,7 @@ function UsersSelect(currentUser, els, options = {}) {
multiple: $(select).hasClass('multiselect'),
minimumInputLength: 0,
query(query) {
- return _this.users(query.term, options, function(users) {
+ return _this.users(query.term, options, users => {
var anyUser, data, emailUser, index, len, name, nullUser, obj, ref;
data = {
results: users,
@@ -719,7 +716,7 @@ UsersSelect.prototype.formatResult = function(user) {
${_.escape(user.name)}
</div>
<div class='user-username dropdown-menu-user-username text-secondary'>
- ${!user.invite ? '@' + _.escape(user.username) : ''}
+ ${!user.invite ? `@${_.escape(user.username)}` : ''}
</div>
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list.vue b/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list.vue
new file mode 100644
index 00000000000..dc766176617
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list.vue
@@ -0,0 +1,40 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlLink,
+ },
+ props: {
+ artifacts: {
+ type: Array,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <table class="table m-0">
+ <thead class="thead-white text-nowrap">
+ <tr class="d-none d-sm-table-row">
+ <th class="w-0"></th>
+ <th>{{ __('Artifact') }}</th>
+ <th class="w-50"></th>
+ <th>{{ __('Job') }}</th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <tr v-for="item in artifacts" :key="item.text">
+ <td class="w-0"></td>
+ <td>
+ <gl-link :href="item.url" target="_blank">{{ item.text }}</gl-link>
+ </td>
+ <td class="w-0"></td>
+ <td>
+ <gl-link :href="item.job_path">{{ item.job_name }}</gl-link>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list_app.vue b/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list_app.vue
new file mode 100644
index 00000000000..730e67761be
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list_app.vue
@@ -0,0 +1,36 @@
+<script>
+import { mapActions, mapState, mapGetters } from 'vuex';
+import ArtifactsList from './artifacts_list.vue';
+import MrCollapsibleExtension from './mr_collapsible_extension.vue';
+import createStore from '../stores/artifacts_list';
+
+export default {
+ store: createStore(),
+ components: {
+ ArtifactsList,
+ MrCollapsibleExtension,
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['artifacts', 'isLoading', 'hasError']),
+ ...mapGetters(['title']),
+ },
+ created() {
+ this.setEndpoint(this.endpoint);
+ this.fetchArtifacts();
+ },
+ methods: {
+ ...mapActions(['setEndpoint', 'fetchArtifacts']),
+ },
+};
+</script>
+<template>
+ <mr-collapsible-extension :title="title" :is-loading="isLoading" :has-error="hasError">
+ <artifacts-list :artifacts="artifacts" />
+ </mr-collapsible-extension>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue
index bb6921225c2..1873e09c370 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue
@@ -211,7 +211,7 @@ export default {
<template v-else>
<review-app-link
:link="deploymentExternalUrl"
- css-class="js-deploy-url js-deploy-url-feature-flag deploy-link btn btn-default btn-sm inline"
+ css-class="js-deploy-url deploy-link btn btn-default btn-sm inline"
/>
</template>
<visual-review-app-link
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
new file mode 100644
index 00000000000..36f291e995c
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
@@ -0,0 +1,88 @@
+<script>
+import { GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ GlButton,
+ GlLink,
+ GlLoadingIcon,
+ Icon,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ hasError: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ isCollapsed: true,
+ };
+ },
+
+ computed: {
+ arrowIconName() {
+ return this.isCollapsed ? 'angle-right' : 'angle-down';
+ },
+ ariaLabel() {
+ return this.isCollapsed ? __('Expand') : __('Collapse');
+ },
+ },
+ methods: {
+ toggleCollapsed() {
+ this.isCollapsed = !this.isCollapsed;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <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-size-small mr-widget-margin-left gl-line-height-24 js-error-state">{{
+ title
+ }}</span>
+ </div>
+ </div>
+
+ <template v-else>
+ <gl-button
+ class="btn-blank btn s32 square append-right-default"
+ :aria-label="ariaLabel"
+ :disabled="isLoading"
+ @click="toggleCollapsed"
+ >
+ <gl-loading-icon v-if="isLoading" />
+ <icon v-else :name="arrowIconName" class="js-icon" />
+ </gl-button>
+ <gl-button
+ variant="link"
+ class="js-title"
+ :disabled="isLoading"
+ :class="{ 'border-0': isLoading }"
+ @click="toggleCollapsed"
+ >
+ <template v-if="isCollapsed">{{ title }}</template>
+ <template v-else>{{ __('Collapse') }}</template>
+ </gl-button>
+ </template>
+ </div>
+
+ <div v-if="!isCollapsed" class="border-top js-slot-container">
+ <slot></slot>
+ </div>
+ </div>
+</template>
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 fb826be19f5..2aaba6e1c8a 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
@@ -90,7 +90,7 @@ export default {
v-html="mr.sourceBranchLink"
/><clipboard-button
:text="branchNameClipboardData"
- :title="__('Copy branch name to clipboard')"
+ :title="__('Copy branch name')"
css-class="btn-default btn-transparent btn-clipboard"
/>
{{ s__('mrWidget|into') }}
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 52acd1de666..7c5f35579b8 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
@@ -110,9 +110,15 @@ export default {
<div class="ci-widget-container d-flex">
<div class="ci-widget-content">
<div class="media-body">
- <div class="font-weight-bold js-pipeline-info-container">
+ <div
+ class="font-weight-bold js-pipeline-info-container"
+ data-qa-selector="merge_request_pipeline_info_content"
+ >
{{ pipeline.details.name }}
- <gl-link :href="pipeline.path" class="pipeline-id font-weight-normal pipeline-number"
+ <gl-link
+ :href="pipeline.path"
+ class="pipeline-id font-weight-normal pipeline-number"
+ data-qa-selector="pipeline_link"
>#{{ pipeline.id }}</gl-link
>
{{ pipeline.details.status.label }}
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 8fdf61a6b8d..ffc3e0967d4 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,5 +1,6 @@
<script>
import _ from 'underscore';
+import ArtifactsApp from './artifacts_list_app.vue';
import Deployment from './deployment.vue';
import MrWidgetContainer from './mr_widget_container.vue';
import MrWidgetPipeline from './mr_widget_pipeline.vue';
@@ -15,6 +16,7 @@ import MrWidgetPipeline from './mr_widget_pipeline.vue';
export default {
name: 'MrWidgetPipelineContainer',
components: {
+ ArtifactsApp,
Deployment,
MrWidgetContainer,
MrWidgetPipeline,
@@ -79,6 +81,9 @@ export default {
:troubleshooting-docs-path="mr.troubleshootingDocsPath"
/>
<template v-slot: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"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
index 457a71cab95..75f557d05dd 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
@@ -19,6 +19,6 @@ export default {
</script>
<template>
<a :href="link" target="_blank" rel="noopener noreferrer nofollow" :class="cssClass">
- {{ __('View app') }} <icon css-classes="fgray" name="external-link" />
+ {{ __('View app') }} <icon class="fgray" name="external-link" />
</a>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
index fb07c03e34d..a2b5a79af36 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
@@ -170,7 +170,7 @@ export default {
>
</a>
<clipboard-button
- :title="__('Copy commit SHA to clipboard')"
+ :title="__('Copy commit SHA')"
:text="mr.mergeCommitSha"
css-class="btn-default btn-transparent btn-clipboard js-mr-merged-copy-sha"
/>
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 91c0b40a0b5..8132b1a944b 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
@@ -1,5 +1,7 @@
<script>
import $ from 'jquery';
+import { __ } from '~/locale';
+import createFlash from '~/flash';
import statusIcon from '../mr_widget_status_icon.vue';
import tooltip from '../../../vue_shared/directives/tooltip';
import eventHub from '../../event_hub';
@@ -29,12 +31,12 @@ export default {
.then(res => res.data)
.then(data => {
eventHub.$emit('UpdateWidgetData', data);
- new window.Flash('The merge request can now be merged.', 'notice'); // eslint-disable-line
+ createFlash(__('The merge request can now be merged.'), 'notice');
$('.merge-request .detail-page-description .title').text(this.mr.title);
})
.catch(() => {
this.isMakingRequest = false;
- new window.Flash('Something went wrong. Please try again.'); // eslint-disable-line
+ createFlash(__('Something went wrong. Please try again.'));
});
},
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/actions.js b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/actions.js
new file mode 100644
index 00000000000..3648db795f5
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/actions.js
@@ -0,0 +1,74 @@
+import Visibility from 'visibilityjs';
+import axios from '~/lib/utils/axios_utils';
+import Poll from '~/lib/utils/poll';
+import httpStatusCodes from '~/lib/utils/http_status';
+
+import * as types from './mutation_types';
+
+export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint);
+
+export const requestArtifacts = ({ commit }) => commit(types.REQUEST_ARTIFACTS);
+
+let eTagPoll;
+
+export const clearEtagPoll = () => {
+ eTagPoll = null;
+};
+
+export const stopPolling = () => {
+ if (eTagPoll) eTagPoll.stop();
+};
+
+export const restartPolling = () => {
+ if (eTagPoll) eTagPoll.restart();
+};
+
+export const fetchArtifacts = ({ state, dispatch }) => {
+ dispatch('requestArtifacts');
+
+ eTagPoll = new Poll({
+ resource: {
+ getArtifacts(endpoint) {
+ return axios.get(endpoint);
+ },
+ },
+ data: state.endpoint,
+ method: 'getArtifacts',
+ successCallback: ({ data, status }) => {
+ dispatch('receiveArtifactsSuccess', {
+ data,
+ status,
+ });
+ },
+ errorCallback: () => dispatch('receiveArtifactsError'),
+ });
+
+ if (!Visibility.hidden()) {
+ eTagPoll.makeRequest();
+ } else {
+ axios
+ .get(state.endpoint)
+ .then(({ data, status }) => dispatch('receiveArtifactsSuccess', { data, status }))
+ .catch(() => dispatch('receiveArtifactsError'));
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ dispatch('restartPolling');
+ } else {
+ dispatch('stopPolling');
+ }
+ });
+};
+
+export const receiveArtifactsSuccess = ({ commit }, response) => {
+ // With 204 we keep polling and don't update the state
+ if (response.status === httpStatusCodes.OK) {
+ commit(types.RECEIVE_ARTIFACTS_SUCCESS, response.data);
+ }
+};
+
+export const receiveArtifactsError = ({ commit }) => commit(types.RECEIVE_ARTIFACTS_ERROR);
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/getters.js b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/getters.js
new file mode 100644
index 00000000000..8921637b93b
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/getters.js
@@ -0,0 +1,16 @@
+import { s__, n__ } from '~/locale';
+
+export const title = state => {
+ if (state.isLoading) {
+ return s__('BuildArtifacts|Loading artifacts');
+ }
+
+ if (state.hasError) {
+ return s__('BuildArtifacts|An error occurred while fetching the artifacts');
+ }
+
+ return n__('View exposed artifact', 'View %d exposed artifacts', state.artifacts.length);
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/index.js b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/index.js
new file mode 100644
index 00000000000..f8abbc99f0f
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/index.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import mutations from './mutations';
+import * as getters from './getters';
+import state from './state';
+
+Vue.use(Vuex);
+
+export default () =>
+ new Vuex.Store({
+ actions,
+ mutations,
+ getters,
+ state: state(),
+ });
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/mutation_types.js b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/mutation_types.js
new file mode 100644
index 00000000000..282faf6f8a4
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/mutation_types.js
@@ -0,0 +1,5 @@
+export const SET_ENDPOINT = 'SET_ENDPOINT';
+
+export const REQUEST_ARTIFACTS = 'REQUEST_ARTIFACTS';
+export const RECEIVE_ARTIFACTS_SUCCESS = 'RECEIVE_ARTIFACTS_SUCCESS';
+export const RECEIVE_ARTIFACTS_ERROR = 'RECEIVE_ARTIFACTS_ERROR';
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/mutations.js b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/mutations.js
new file mode 100644
index 00000000000..95a091f1bd6
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/mutations.js
@@ -0,0 +1,22 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_ENDPOINT](state, endpoint) {
+ state.endpoint = endpoint;
+ },
+ [types.REQUEST_ARTIFACTS](state) {
+ state.isLoading = true;
+ },
+ [types.RECEIVE_ARTIFACTS_SUCCESS](state, response) {
+ state.hasError = false;
+ state.isLoading = false;
+
+ state.artifacts = response;
+ },
+ [types.RECEIVE_ARTIFACTS_ERROR](state) {
+ state.isLoading = false;
+ state.hasError = true;
+
+ state.artifacts = [];
+ },
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/state.js b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/state.js
new file mode 100644
index 00000000000..92dad171b1b
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/state.js
@@ -0,0 +1,8 @@
+export default () => ({
+ endpoint: null,
+
+ isLoading: false,
+ hasError: false,
+
+ artifacts: [],
+});
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 699d41494bf..f51d0fa4f52 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
@@ -100,6 +100,7 @@ export default class MergeRequestStore {
this.isPipelineBlocked = pipelineStatus ? pipelineStatus.group === 'manual' : false;
this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null;
this.testResultsPath = data.test_reports_path;
+ this.exposedArtifactsPath = data.exposed_artifacts_path;
this.cancelAutoMergePath = data.cancel_auto_merge_path;
this.canCancelAutomaticMerge = Boolean(data.cancel_auto_merge_path);
@@ -168,6 +169,7 @@ export default class MergeRequestStore {
this.mergeRequestPipelinesHelpPath = data.merge_request_pipelines_docs_path;
this.conflictsDocsPath = data.conflicts_docs_path;
this.ciEnvironmentsStatusPath = data.ci_environments_status_path;
+ this.securityApprovalsHelpPagePath = data.security_approvals_help_page_path;
}
get isNothingToMergeState() {
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 a97538d813a..75c3c544c77 100644
--- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
@@ -70,7 +70,13 @@ export default {
return undefined;
},
showIcon() {
- return this.file.changed || this.file.tempFile || this.file.staged || this.file.deleted;
+ return (
+ this.file.changed ||
+ this.file.tempFile ||
+ this.file.staged ||
+ this.file.deleted ||
+ this.file.prevPath
+ );
},
},
};
@@ -83,7 +89,7 @@ export default {
:class="{ 'ml-auto': isCentered }"
class="file-changed-icon d-inline-block"
>
- <icon v-if="showIcon" :name="changedIcon" :size="size" :css-classes="changedIconClass" />
+ <icon v-if="showIcon" :name="changedIcon" :size="size" :class="changedIconClass" />
</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 5d373e179b2..162cfc02959 100644
--- a/app/assets/javascripts/vue_shared/components/ci_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue
@@ -66,5 +66,5 @@ export default {
};
</script>
<template>
- <span :class="cssClass"> <icon :name="icon" :size="size" :css-classes="cssClasses" /> </span>
+ <span :class="cssClass"> <icon :name="icon" :size="size" :class="cssClasses" /> </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 a620f560b52..9f498037185 100644
--- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue
+++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
@@ -7,7 +7,7 @@
*
* @example
* <clipboard-button
- * title="Copy to clipboard"
+ * title="Copy"
* text="Content to be copied"
* css-class="btn-transparent"
* />
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
index c6d61d6ee62..fe1a2a092ad 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
@@ -40,7 +40,7 @@ export default {
</template>
</p>
<gl-link :href="path" class="btn btn-default" rel="nofollow" download target="_blank">
- <icon :size="16" name="download" css-classes="float-left append-right-8" />
+ <icon :size="16" name="download" class="float-left append-right-8" />
{{ __('Download') }}
</gl-link>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue
index d5558d93219..3f55f43edbb 100644
--- a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue
@@ -132,6 +132,7 @@ export default {
type="button"
class="btn js-primary-button"
data-dismiss="modal"
+ data-qa-selector="save_changes_button"
@click="emitSubmit($event)"
>
{{ primaryButtonLabel }}
diff --git a/app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue b/app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue
new file mode 100644
index 00000000000..543547b37fe
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue
@@ -0,0 +1,118 @@
+<script>
+import $ from 'jquery';
+
+const buttonVariants = ['danger', 'primary', 'success', 'warning'];
+const sizeVariants = ['sm', 'md', 'lg', 'xl'];
+
+export default {
+ name: 'DeprecatedModal2', // use GlModal instead
+
+ props: {
+ id: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ modalSize: {
+ type: String,
+ required: false,
+ default: 'md',
+ validator: value => sizeVariants.includes(value),
+ },
+ headerTitleText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ footerPrimaryButtonVariant: {
+ type: String,
+ required: false,
+ default: 'primary',
+ validator: value => buttonVariants.includes(value),
+ },
+ footerPrimaryButtonText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ modalSizeClass() {
+ return this.modalSize === 'md' ? '' : `modal-${this.modalSize}`;
+ },
+ },
+ mounted() {
+ $(this.$el)
+ .on('shown.bs.modal', this.opened)
+ .on('hidden.bs.modal', this.closed);
+ },
+ beforeDestroy() {
+ $(this.$el)
+ .off('shown.bs.modal', this.opened)
+ .off('hidden.bs.modal', this.closed);
+ },
+ methods: {
+ emitCancel(event) {
+ this.$emit('cancel', event);
+ },
+ emitSubmit(event) {
+ this.$emit('submit', event);
+ },
+ opened() {
+ this.$emit('open');
+ },
+ closed() {
+ this.$emit('closed');
+ },
+ },
+};
+</script>
+
+<template>
+ <div :id="id" class="modal fade" tabindex="-1" role="dialog">
+ <div :class="modalSizeClass" class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-header">
+ <slot name="header">
+ <h4 class="modal-title">
+ <slot name="title"> {{ headerTitleText }} </slot>
+ </h4>
+ <button
+ :aria-label="s__('Modal|Close')"
+ type="button"
+ class="close js-modal-close-action"
+ data-dismiss="modal"
+ @click="emitCancel($event)"
+ >
+ <span aria-hidden="true">&times;</span>
+ </button>
+ </slot>
+ </div>
+
+ <div class="modal-body"><slot></slot></div>
+
+ <div class="modal-footer">
+ <slot name="footer">
+ <button
+ type="button"
+ class="btn js-modal-cancel-action qa-modal-cancel-button"
+ data-dismiss="modal"
+ @click="emitCancel($event)"
+ >
+ {{ s__('Modal|Cancel') }}
+ </button>
+ <button
+ :class="`btn-${footerPrimaryButtonVariant}`"
+ type="button"
+ class="btn js-modal-primary-action qa-modal-primary-button"
+ data-dismiss="modal"
+ @click="emitSubmit($event)"
+ >
+ {{ footerPrimaryButtonText }}
+ </button>
+ </slot>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue
index b69ecc1dce6..952ffa1fa0e 100644
--- a/app/assets/javascripts/vue_shared/components/file_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/file_icon.vue
@@ -75,7 +75,7 @@ export default {
<svg v-if="!loading && !folder" :class="[iconSizeClass, cssClasses]">
<use v-bind="{ 'xlink:href': spriteHref }" />
</svg>
- <icon v-if="!loading && folder" :name="folderIconName" :size="size" css-classes="folder-icon" />
+ <icon v-if="!loading && folder" :name="folderIconName" :size="size" class="folder-icon" />
<gl-loading-icon v-if="loading" :inline="true" />
</span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue
index f49e69c473b..341c9534763 100644
--- a/app/assets/javascripts/vue_shared/components/file_row.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row.vue
@@ -131,7 +131,7 @@ export default {
</script>
<template>
- <div v-if="!file.moved">
+ <div>
<file-header v-if="file.isHeader" :path="file.path" />
<div
v-else
diff --git a/app/assets/javascripts/vue_shared/components/gl_modal.vue b/app/assets/javascripts/vue_shared/components/gl_modal.vue
index 438851e5ac7..4b91d4c00e3 100644
--- a/app/assets/javascripts/vue_shared/components/gl_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/gl_modal.vue
@@ -1,117 +1,6 @@
<script>
-import $ from 'jquery';
+// This file was only introduced to not break master and shall be delete soon.
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
-const buttonVariants = ['danger', 'primary', 'success', 'warning'];
-const sizeVariants = ['sm', 'md', 'lg', 'xl'];
-
-export default {
- name: 'GlModal',
- props: {
- id: {
- type: String,
- required: false,
- default: null,
- },
- modalSize: {
- type: String,
- required: false,
- default: 'md',
- validator: value => sizeVariants.includes(value),
- },
- headerTitleText: {
- type: String,
- required: false,
- default: '',
- },
- footerPrimaryButtonVariant: {
- type: String,
- required: false,
- default: 'primary',
- validator: value => buttonVariants.includes(value),
- },
- footerPrimaryButtonText: {
- type: String,
- required: false,
- default: '',
- },
- },
- computed: {
- modalSizeClass() {
- return this.modalSize === 'md' ? '' : `modal-${this.modalSize}`;
- },
- },
- mounted() {
- $(this.$el)
- .on('shown.bs.modal', this.opened)
- .on('hidden.bs.modal', this.closed);
- },
- beforeDestroy() {
- $(this.$el)
- .off('shown.bs.modal', this.opened)
- .off('hidden.bs.modal', this.closed);
- },
- methods: {
- emitCancel(event) {
- this.$emit('cancel', event);
- },
- emitSubmit(event) {
- this.$emit('submit', event);
- },
- opened() {
- this.$emit('open');
- },
- closed() {
- this.$emit('closed');
- },
- },
-};
+export default DeprecatedModal2;
</script>
-
-<template>
- <div :id="id" class="modal fade" tabindex="-1" role="dialog">
- <div :class="modalSizeClass" class="modal-dialog" role="document">
- <div class="modal-content">
- <div class="modal-header">
- <slot name="header">
- <h4 class="modal-title">
- <slot name="title"> {{ headerTitleText }} </slot>
- </h4>
- <button
- :aria-label="s__('Modal|Close')"
- type="button"
- class="close js-modal-close-action"
- data-dismiss="modal"
- @click="emitCancel($event)"
- >
- <span aria-hidden="true">&times;</span>
- </button>
- </slot>
- </div>
-
- <div class="modal-body"><slot></slot></div>
-
- <div class="modal-footer">
- <slot name="footer">
- <button
- type="button"
- class="btn js-modal-cancel-action qa-modal-cancel-button"
- data-dismiss="modal"
- @click="emitCancel($event)"
- >
- {{ s__('Modal|Cancel') }}
- </button>
- <button
- :class="`btn-${footerPrimaryButtonVariant}`"
- type="button"
- class="btn js-modal-primary-action qa-modal-primary-button"
- data-dismiss="modal"
- @click="emitSubmit($event)"
- >
- {{ footerPrimaryButtonText }}
- </button>
- </slot>
- </div>
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/icon.vue b/app/assets/javascripts/vue_shared/components/icon.vue
index fa89473da62..73f4dfef062 100644
--- a/app/assets/javascripts/vue_shared/components/icon.vue
+++ b/app/assets/javascripts/vue_shared/components/icon.vue
@@ -27,7 +27,7 @@ if (process.env.NODE_ENV !== 'production') {
* <icon
* name="retry"
* :size="32"
- * css-classes="top"
+ * class="top"
* />
*/
export default {
@@ -42,45 +42,7 @@ export default {
type: Number,
required: false,
default: 16,
- validator(value) {
- return validSizes.includes(value);
- },
- },
-
- cssClasses: {
- type: String,
- required: false,
- default: '',
- },
-
- width: {
- type: Number,
- required: false,
- default: null,
- },
-
- height: {
- type: Number,
- required: false,
- default: null,
- },
-
- y: {
- type: Number,
- required: false,
- default: null,
- },
-
- x: {
- type: Number,
- required: false,
- default: null,
- },
-
- tabIndex: {
- type: String,
- required: false,
- default: null,
+ validator: value => validSizes.includes(value),
},
},
@@ -99,15 +61,7 @@ export default {
</script>
<template>
- <svg
- :class="[iconSizeClass, iconTestClass, cssClasses]"
- :width="width"
- :height="height"
- :x="x"
- :y="y"
- :tabindex="tabIndex"
- aria-hidden="true"
- >
+ <svg :class="[iconSizeClass, iconTestClass]" aria-hidden="true">
<use v-bind="{ 'xlink:href': spriteHref }" />
</svg>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
index b76679960ca..5d7e9557aff 100644
--- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
@@ -63,7 +63,7 @@ export default {
<icon
v-if="hasState"
ref="iconElementXL"
- :css-classes="iconClass"
+ :class="iconClass"
:name="iconName"
:size="16"
:title="stateTitle"
@@ -100,7 +100,7 @@ export default {
<span ref="iconElement">
<icon
v-if="hasState"
- :css-classes="iconClass"
+ :class="iconClass"
:name="iconName"
:title="stateTitle"
:aria-label="state"
@@ -159,7 +159,8 @@ export default {
v-gl-tooltip
:disabled="removeDisabled"
type="button"
- class="btn btn-default btn-svg btn-item-remove js-issue-item-remove-button qa-remove-issue-button mr-xl-0 align-self-xl-center"
+ class="btn btn-default btn-svg btn-item-remove js-issue-item-remove-button mr-xl-0 align-self-xl-center"
+ data-qa-selector="remove_related_issue_button"
:title="__('Remove')"
:aria-label="__('Remove')"
@click="onRemoveRequest"
diff --git a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue
index 1e2d4ffa7e3..e89638130f5 100644
--- a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue
+++ b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue
@@ -27,8 +27,7 @@ export default {
/**
pageInfo will come from the headers of the API call
- in the `.then` clause of the VueResource API call
- there should be a function that contructs the pageInfo for this component
+ there should be a function that constructs the pageInfo for this component
This is an example:
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
index 7f0345c7ec0..478e44d104c 100644
--- a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
@@ -52,7 +52,7 @@ export default {
this.$emit('projectClicked', project);
},
isSelected(project) {
- return Boolean(_.findWhere(this.selectedProjects, { id: project.id }));
+ return Boolean(_.find(this.selectedProjects, { id: project.id }));
},
onInput: _.debounce(function debouncedOnInput() {
this.$emit('searched', this.searchQuery);
diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js b/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js
new file mode 100644
index 00000000000..a4e004c3341
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js
@@ -0,0 +1,21 @@
+import Vue from 'vue';
+
+// see recaptcha_tags in app/views/shared/_recaptcha_form.html.haml
+export const callbackName = 'recaptchaDialogCallback';
+
+export const eventHub = new Vue();
+
+const throwDuplicateCallbackError = () => {
+ throw new Error(`${callbackName} is already defined!`);
+};
+
+if (window[callbackName]) {
+ throwDuplicateCallbackError();
+}
+
+const callback = () => eventHub.$emit('submit');
+
+Object.defineProperty(window, callbackName, {
+ get: () => callback,
+ set: throwDuplicateCallbackError,
+});
diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue
index f0aae20477b..25701df33f3 100644
--- a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue
@@ -1,5 +1,6 @@
<script>
import DeprecatedModal from './deprecated_modal.vue';
+import { eventHub } from './recaptcha_eventhub';
export default {
name: 'RecaptchaModal',
@@ -30,14 +31,15 @@ export default {
},
mounted() {
- if (window.recaptchaDialogCallback) {
- throw new Error('recaptchaDialogCallback is already defined!');
+ eventHub.$on('submit', this.submit);
+
+ if (this.html) {
+ this.appendRecaptchaScript();
}
- window.recaptchaDialogCallback = this.submit.bind(this);
},
beforeDestroy() {
- window.recaptchaDialogCallback = null;
+ eventHub.$off('submit', this.submit);
},
methods: {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
index 9c258c4651f..13795eff714 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
@@ -167,7 +167,7 @@ dropdown-menu-labels dropdown-menu-selectable"
<div class="dropdown-page-one">
<dropdown-header v-if="showCreate" />
<dropdown-search-input />
- <div class="dropdown-content"></div>
+ <div class="dropdown-content" data-qa-selector="labels_dropdown_content"></div>
<div class="dropdown-loading"><gl-loading-icon /></div>
<dropdown-footer
v-if="showCreate"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue
index cb53273c786..574b63cf8a6 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue
@@ -14,7 +14,11 @@ export default {
{{ __('Labels') }}
<template v-if="canEdit">
<i aria-hidden="true" class="fa fa-spinner fa-spin block-loading" data-hidden="true"> </i>
- <button type="button" class="edit-link btn btn-blank float-right js-sidebar-dropdown-toggle">
+ <button
+ type="button"
+ class="edit-link btn btn-blank float-right js-sidebar-dropdown-toggle"
+ data-qa-selector="labels_edit_button"
+ >
{{ __('Edit') }}
</button>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/toggle_button.vue b/app/assets/javascripts/vue_shared/components/toggle_button.vue
index de70fa2182b..1de866bed37 100644
--- a/app/assets/javascripts/vue_shared/components/toggle_button.vue
+++ b/app/assets/javascripts/vue_shared/components/toggle_button.vue
@@ -74,7 +74,7 @@ export default {
@click="toggleFeature"
>
<gl-loading-icon class="loading-icon" />
- <span class="toggle-icon"> <icon :name="toggleIcon" css-classes="toggle-icon-svg" /> </span>
+ <span class="toggle-icon"> <icon :name="toggleIcon" class="toggle-icon-svg" /> </span>
</button>
</label>
</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 a60d5eb491e..7c7d46ee759 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
@@ -71,15 +71,11 @@ export default {
</div>
<div class="text-secondary">
<div v-if="user.bio" class="js-bio d-flex mb-1">
- <icon name="profile" css-classes="category-icon flex-shrink-0" />
+ <icon name="profile" class="category-icon flex-shrink-0" />
<span class="ml-1">{{ user.bio }}</span>
</div>
<div v-if="user.organization" class="js-organization d-flex mb-1">
- <icon
- v-show="!jobInfoIsLoading"
- name="work"
- css-classes="category-icon flex-shrink-0"
- />
+ <icon v-show="!jobInfoIsLoading" name="work" class="category-icon flex-shrink-0" />
<span class="ml-1">{{ user.organization }}</span>
</div>
<gl-skeleton-loading
@@ -92,7 +88,7 @@ export default {
<icon
v-show="!locationIsLoading && user.location"
name="location"
- css-classes="category-icon flex-shrink-0"
+ class="category-icon flex-shrink-0"
/>
<span class="ml-1">{{ user.location }}</span>
<gl-skeleton-loading
diff --git a/app/assets/javascripts/vue_shared/directives/track_event.js b/app/assets/javascripts/vue_shared/directives/track_event.js
new file mode 100644
index 00000000000..d1c05c5c267
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/directives/track_event.js
@@ -0,0 +1,20 @@
+import Tracking from '~/tracking';
+
+export default {
+ bind(el, binding) {
+ el.dataset.trackingOptions = JSON.stringify(binding.value || {});
+
+ el.addEventListener('click', () => {
+ const { category, action, label, property, value } = JSON.parse(el.dataset.trackingOptions);
+ if (!category || !action) {
+ return;
+ }
+ Tracking.event(category, action, { label, property, value });
+ });
+ },
+ update(el, binding) {
+ if (binding.value !== binding.oldValue) {
+ el.dataset.trackingOptions = JSON.stringify(binding.value || {});
+ }
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js b/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js
new file mode 100644
index 00000000000..3488a44bd0f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js
@@ -0,0 +1,7 @@
+export default Vue => {
+ Vue.mixin({
+ provide: {
+ glFeatures: { ...((window.gon && window.gon.features) || {}) },
+ },
+ });
+};
diff --git a/app/assets/javascripts/vue_shared/mixins/gl_feature_flags_mixin.js b/app/assets/javascripts/vue_shared/mixins/gl_feature_flags_mixin.js
new file mode 100644
index 00000000000..dc8a63f26ac
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/mixins/gl_feature_flags_mixin.js
@@ -0,0 +1,8 @@
+export default () => ({
+ inject: {
+ glFeatures: {
+ from: 'glFeatures',
+ default: () => ({}),
+ },
+ },
+});
diff --git a/app/assets/javascripts/vue_shared/plugins/global_toast.js b/app/assets/javascripts/vue_shared/plugins/global_toast.js
index c0de1cdc615..7a2e5d80a5d 100644
--- a/app/assets/javascripts/vue_shared/plugins/global_toast.js
+++ b/app/assets/javascripts/vue_shared/plugins/global_toast.js
@@ -2,7 +2,8 @@ import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
Vue.use(GlToast);
+const instance = new Vue();
export default function showGlobalToast(...args) {
- return Vue.toasted.show(...args);
+ return instance.$toast.show(...args);
}
diff --git a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js
deleted file mode 100644
index 754025207c8..00000000000
--- a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import Vue from 'vue';
-import VueResource from 'vue-resource';
-import csrf from '../lib/utils/csrf';
-
-Vue.use(VueResource);
-
-// Maintain a global counter for active requests
-// see: spec/support/wait_for_requests.rb
-Vue.http.interceptors.push((request, next) => {
- window.activeVueResources = window.activeVueResources || 0;
- window.activeVueResources += 1;
-
- next(() => {
- window.activeVueResources -= 1;
- });
-});
-
-// Inject CSRF token and parse headers.
-// New Vue Resource version uses Headers, we are expecting a plain object to render pagination
-// and polling.
-Vue.http.interceptors.push((request, next) => {
- request.headers.set(csrf.headerKey, csrf.token);
-
- next(response => {
- // Headers object has a `forEach` property that iterates through all values.
- const headers = {};
-
- response.headers.forEach((value, key) => {
- headers[key] = value;
- });
- // eslint-disable-next-line no-param-reassign
- response.headers = headers;
- });
-});
diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js
index 5438572eadf..7a60ab1380f 100644
--- a/app/assets/javascripts/zen_mode.js
+++ b/app/assets/javascripts/zen_mode.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, prefer-arrow-callback, consistent-return, camelcase, class-methods-use-this */
+/* eslint-disable func-names, consistent-return, camelcase, class-methods-use-this */
// Zen Mode (full screen) textarea
//
@@ -39,11 +39,11 @@ export default class ZenMode {
constructor() {
this.active_backdrop = null;
this.active_textarea = null;
- $(document).on('click', '.js-zen-enter', function(e) {
+ $(document).on('click', '.js-zen-enter', e => {
e.preventDefault();
return $(e.currentTarget).trigger('zen_mode:enter');
});
- $(document).on('click', '.js-zen-leave', function(e) {
+ $(document).on('click', '.js-zen-leave', e => {
e.preventDefault();
return $(e.currentTarget).trigger('zen_mode:leave');
});
@@ -67,7 +67,7 @@ export default class ZenMode {
};
})(this),
);
- $(document).on('keydown', function(e) {
+ $(document).on('keydown', e => {
// Esc
if (e.keyCode === 27) {
e.preventDefault();