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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>2017-06-02 11:05:38 +0300
committerDmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>2017-06-02 11:05:38 +0300
commitea531e1effa51bcec84e50a69901e6eec7c789c1 (patch)
treed3c1281deea1c9b2e8596cfa79a2e9d5cd4f7a10 /app/assets/javascripts
parent4d141cb30dfcad94db89bdc08f4ea907dc2f8bdf (diff)
parentfc56d2fbaa2a317813c9dd7ba36e584162175fe6 (diff)
Merge remote-tracking branch 'origin/master' into 25426-group-dashboard-ui
Signed-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>
Diffstat (limited to 'app/assets/javascripts')
-rw-r--r--app/assets/javascripts/api.js229
-rw-r--r--app/assets/javascripts/autosave.js44
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js17
-rw-r--r--app/assets/javascripts/behaviors/quick_submit.js2
-rw-r--r--app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js114
-rw-r--r--app/assets/javascripts/blob/balsamiq_viewer.js22
-rw-r--r--app/assets/javascripts/blob/file_template_mediator.js6
-rw-r--r--app/assets/javascripts/blob/file_template_selector.js13
-rw-r--r--app/assets/javascripts/blob/target_branch_dropdown.js4
-rw-r--r--app/assets/javascripts/blob/template_selector.js7
-rw-r--r--app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js4
-rw-r--r--app/assets/javascripts/blob/template_selectors/dockerfile_selector.js4
-rw-r--r--app/assets/javascripts/blob/template_selectors/gitignore_selector.js4
-rw-r--r--app/assets/javascripts/blob/template_selectors/license_selector.js15
-rw-r--r--app/assets/javascripts/blob/template_selectors/type_selector.js2
-rw-r--r--app/assets/javascripts/blob/viewer/index.js101
-rw-r--r--app/assets/javascripts/boards/boards_bundle.js39
-rw-r--r--app/assets/javascripts/boards/components/board.js9
-rw-r--r--app/assets/javascripts/boards/components/board_blank_state.js5
-rw-r--r--app/assets/javascripts/boards/components/board_card.js2
-rw-r--r--app/assets/javascripts/boards/components/board_list.js24
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.js1
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js74
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.js99
-rw-r--r--app/assets/javascripts/boards/components/modal/filters.js1
-rw-r--r--app/assets/javascripts/boards/components/modal/footer.js3
-rw-r--r--app/assets/javascripts/boards/components/modal/header.js3
-rw-r--r--app/assets/javascripts/boards/components/modal/index.js15
-rw-r--r--app/assets/javascripts/boards/components/new_list_dropdown.js4
-rw-r--r--app/assets/javascripts/boards/filtered_search_boards.js8
-rw-r--r--app/assets/javascripts/boards/models/assignee.js12
-rw-r--r--app/assets/javascripts/boards/models/issue.js34
-rw-r--r--app/assets/javascripts/boards/models/list.js29
-rw-r--r--app/assets/javascripts/boards/models/user.js12
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js4
-rw-r--r--app/assets/javascripts/branches/branches_delete_modal.js36
-rw-r--r--app/assets/javascripts/build.js333
-rw-r--r--app/assets/javascripts/ci_status_icons.js34
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.js42
-rw-r--r--app/assets/javascripts/copy_as_gfm.js130
-rw-r--r--app/assets/javascripts/create_label.js2
-rw-r--r--app/assets/javascripts/cycle_analytics/components/limit_warning_component.js4
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_code_component.js11
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_issue_component.js12
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_plan_component.js15
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_production_component.js12
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_review_component.js12
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_staging_component.js13
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_test_component.js4
-rw-r--r--app/assets/javascripts/cycle_analytics/components/total_time_component.js8
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js23
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_service.js2
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_store.js21
-rw-r--r--app/assets/javascripts/cycle_analytics/default_event_objects.js2
-rw-r--r--app/assets/javascripts/deploy_keys/components/action_btn.vue55
-rw-r--r--app/assets/javascripts/deploy_keys/components/app.vue100
-rw-r--r--app/assets/javascripts/deploy_keys/components/key.vue80
-rw-r--r--app/assets/javascripts/deploy_keys/components/keys_panel.vue52
-rw-r--r--app/assets/javascripts/deploy_keys/eventhub.js3
-rw-r--r--app/assets/javascripts/deploy_keys/index.js21
-rw-r--r--app/assets/javascripts/deploy_keys/service/index.js34
-rw-r--r--app/assets/javascripts/deploy_keys/store/index.js9
-rw-r--r--app/assets/javascripts/diff.js2
-rw-r--r--app/assets/javascripts/diff_notes/components/diff_note_avatars.js30
-rw-r--r--app/assets/javascripts/diff_notes/components/jump_to_discussion.js7
-rw-r--r--app/assets/javascripts/diff_notes/components/resolve_btn.js1
-rw-r--r--app/assets/javascripts/diff_notes/diff_notes_bundle.js27
-rw-r--r--app/assets/javascripts/diff_notes/services/resolve.js7
-rw-r--r--app/assets/javascripts/dispatcher.js53
-rw-r--r--app/assets/javascripts/droplab/constants.js3
-rw-r--r--app/assets/javascripts/droplab/drop_down.js128
-rw-r--r--app/assets/javascripts/droplab/drop_lab.js126
-rw-r--r--app/assets/javascripts/droplab/hook.js29
-rw-r--r--app/assets/javascripts/droplab/hook_button.js59
-rw-r--r--app/assets/javascripts/droplab/hook_input.js68
-rw-r--r--app/assets/javascripts/droplab/keyboard.js2
-rw-r--r--app/assets/javascripts/droplab/plugins/ajax.js34
-rw-r--r--app/assets/javascripts/droplab/plugins/ajax_filter.js48
-rw-r--r--app/assets/javascripts/droplab/utils.js16
-rw-r--r--app/assets/javascripts/dropzone_input.js255
-rw-r--r--app/assets/javascripts/environments/components/environment.vue118
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.vue10
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue50
-rw-r--r--app/assets/javascripts/environments/components/environment_monitoring.vue1
-rw-r--r--app/assets/javascripts/environments/components/environment_rollback.vue10
-rw-r--r--app/assets/javascripts/environments/components/environment_stop.vue10
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue8
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_view.vue123
-rw-r--r--app/assets/javascripts/environments/mixins/environments_mixin.js17
-rw-r--r--app/assets/javascripts/environments/services/environments_service.js3
-rw-r--r--app/assets/javascripts/environments/stores/environments_store.js6
-rw-r--r--app/assets/javascripts/files_comment_button.js5
-rw-r--r--app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js18
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_hint.js32
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_non_user.js5
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_user.js16
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_utils.js6
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_bundle.js20
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js10
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js68
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_token_keys.js8
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_tokenizer.js5
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js68
-rw-r--r--app/assets/javascripts/filtered_search/recent_searches_root.js8
-rw-r--r--app/assets/javascripts/filtered_search/services/recent_searches_service.js14
-rw-r--r--app/assets/javascripts/filtered_search/services/recent_searches_service_error.js11
-rw-r--r--app/assets/javascripts/filtered_search/stores/recent_searches_store.js4
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js482
-rw-r--r--app/assets/javascripts/gl_dropdown.js90
-rw-r--r--app/assets/javascripts/gl_field_error.js4
-rw-r--r--app/assets/javascripts/gl_field_errors.js11
-rw-r--r--app/assets/javascripts/gl_form.js15
-rw-r--r--app/assets/javascripts/graphs/stat_graph_contributors_graph.js3
-rw-r--r--app/assets/javascripts/group_name.js6
-rw-r--r--app/assets/javascripts/groups_select.js2
-rw-r--r--app/assets/javascripts/issuable/auto_width_dropdown_select.js38
-rw-r--r--app/assets/javascripts/issuable/issuable_bundle.js1
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js42
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js70
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js14
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/help_state.js25
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js12
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js14
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/time_tracker.js117
-rw-r--r--app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js66
-rw-r--r--app/assets/javascripts/issuable_context.js3
-rw-r--r--app/assets/javascripts/issuable_form.js16
-rw-r--r--app/assets/javascripts/issue.js12
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue245
-rw-r--r--app/assets/javascripts/issue_show/components/description.vue108
-rw-r--r--app/assets/javascripts/issue_show/components/edit_actions.vue79
-rw-r--r--app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue23
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description.vue54
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description_template.vue111
-rw-r--r--app/assets/javascripts/issue_show/components/fields/project_move.vue83
-rw-r--r--app/assets/javascripts/issue_show/components/fields/title.vue31
-rw-r--r--app/assets/javascripts/issue_show/components/form.vue104
-rw-r--r--app/assets/javascripts/issue_show/components/locked_warning.vue20
-rw-r--r--app/assets/javascripts/issue_show/components/title.vue53
-rw-r--r--app/assets/javascripts/issue_show/event_hub.js3
-rw-r--r--app/assets/javascripts/issue_show/index.js57
-rw-r--r--app/assets/javascripts/issue_show/issue_title.vue80
-rw-r--r--app/assets/javascripts/issue_show/mixins/animate.js13
-rw-r--r--app/assets/javascripts/issue_show/mixins/update.js10
-rw-r--r--app/assets/javascripts/issue_show/services/index.js27
-rw-r--r--app/assets/javascripts/issue_show/stores/index.js45
-rw-r--r--app/assets/javascripts/issue_status_select.js4
-rw-r--r--app/assets/javascripts/issues_bulk_assignment.js3
-rw-r--r--app/assets/javascripts/labels.js6
-rw-r--r--app/assets/javascripts/labels_select.js7
-rw-r--r--app/assets/javascripts/layout_nav.js10
-rw-r--r--app/assets/javascripts/lib/utils/accessor.js47
-rw-r--r--app/assets/javascripts/lib/utils/ajax_cache.js44
-rw-r--r--app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js116
-rw-r--r--app/assets/javascripts/lib/utils/cache.js19
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js19
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js12
-rw-r--r--app/assets/javascripts/lib/utils/http_status.js4
-rw-r--r--app/assets/javascripts/lib/utils/notify.js85
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js10
-rw-r--r--app/assets/javascripts/lib/utils/simple_poll.js15
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js5
-rw-r--r--app/assets/javascripts/lib/utils/type_utility.js17
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js3
-rw-r--r--app/assets/javascripts/lib/utils/users_cache.js28
-rw-r--r--app/assets/javascripts/line_highlighter.js10
-rw-r--r--app/assets/javascripts/locale/de/app.js1
-rw-r--r--app/assets/javascripts/locale/en/app.js1
-rw-r--r--app/assets/javascripts/locale/es/app.js1
-rw-r--r--app/assets/javascripts/locale/index.js70
-rw-r--r--app/assets/javascripts/main.js8
-rw-r--r--app/assets/javascripts/members.js4
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js15
-rw-r--r--app/assets/javascripts/merge_request.js25
-rw-r--r--app/assets/javascripts/merge_request_tabs.js32
-rw-r--r--app/assets/javascripts/merge_request_widget.js305
-rw-r--r--app/assets/javascripts/merge_request_widget/ci_bundle.js53
-rw-r--r--app/assets/javascripts/merged_buttons.js47
-rw-r--r--app/assets/javascripts/milestone_select.js40
-rw-r--r--app/assets/javascripts/namespace_select.js9
-rw-r--r--app/assets/javascripts/new_branch_form.js38
-rw-r--r--app/assets/javascripts/new_commit_form.js4
-rw-r--r--app/assets/javascripts/notebook/cells/markdown.vue24
-rw-r--r--app/assets/javascripts/notes.js769
-rw-r--r--app/assets/javascripts/notifications_form.js4
-rw-r--r--app/assets/javascripts/pager.js4
-rw-r--r--app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.js145
-rw-r--r--app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js48
-rw-r--r--app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js52
-rw-r--r--app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js66
-rw-r--r--app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg1
-rw-r--r--app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js21
-rw-r--r--app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js12
-rw-r--r--app/assets/javascripts/pipelines.js46
-rw-r--r--app/assets/javascripts/pipelines/components/async_button.vue10
-rw-r--r--app/assets/javascripts/pipelines/components/graph/action_component.vue64
-rw-r--r--app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue56
-rw-r--r--app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue86
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue77
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_component.vue124
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_name_component.vue37
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue83
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_url.js56
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_url.vue65
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_actions.js10
-rw-r--r--app/assets/javascripts/pipelines/components/stage.vue19
-rw-r--r--app/assets/javascripts/pipelines/components/status.js60
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js33
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_mediatior.js51
-rw-r--r--app/assets/javascripts/pipelines/pipelines.js50
-rw-r--r--app/assets/javascripts/pipelines/services/pipeline_service.js14
-rw-r--r--app/assets/javascripts/pipelines/services/pipelines_service.js2
-rw-r--r--app/assets/javascripts/pipelines/stores/pipeline_store.js11
-rw-r--r--app/assets/javascripts/preview_markdown.js48
-rw-r--r--app/assets/javascripts/profile/profile_bundle.js4
-rw-r--r--app/assets/javascripts/project.js3
-rw-r--r--app/assets/javascripts/project_edit.js9
-rw-r--r--app/assets/javascripts/project_find_file.js10
-rw-r--r--app/assets/javascripts/project_new.js4
-rw-r--r--app/assets/javascripts/project_select.js11
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js4
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_dropdown.js3
-rw-r--r--app/assets/javascripts/protected_branches/protected_branches_bundle.js10
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js4
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_dropdown.js4
-rw-r--r--app/assets/javascripts/raven/index.js20
-rw-r--r--app/assets/javascripts/raven/raven_config.js103
-rw-r--r--app/assets/javascripts/ref_select_dropdown.js46
-rw-r--r--app/assets/javascripts/right_sidebar.js4
-rw-r--r--app/assets/javascripts/search.js36
-rw-r--r--app/assets/javascripts/shortcuts.js4
-rw-r--r--app/assets/javascripts/shortcuts_blob.js6
-rw-r--r--app/assets/javascripts/shortcuts_find_file.js2
-rw-r--r--app/assets/javascripts/shortcuts_issuable.js16
-rw-r--r--app/assets/javascripts/shortcuts_navigation.js4
-rw-r--r--app/assets/javascripts/shortcuts_network.js2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_title.js41
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees.js224
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js85
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js97
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js98
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js17
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/help_state.js44
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js10
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js51
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js15
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js163
-rw-r--r--app/assets/javascripts/sidebar/event_hub.js8
-rw-r--r--app/assets/javascripts/sidebar/services/sidebar_service.js28
-rw-r--r--app/assets/javascripts/sidebar/sidebar_bundle.js24
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js38
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js56
-rw-r--r--app/assets/javascripts/signin_tabs_memoizer.js12
-rw-r--r--app/assets/javascripts/single_file_diff.js6
-rw-r--r--app/assets/javascripts/subbable_resource.js51
-rw-r--r--app/assets/javascripts/subscription_select.js4
-rw-r--r--app/assets/javascripts/task_list.js3
-rw-r--r--app/assets/javascripts/templates/issuable_template_selector.js2
-rw-r--r--app/assets/javascripts/terminal/terminal_bundle.js12
-rw-r--r--app/assets/javascripts/test.js1
-rw-r--r--app/assets/javascripts/todos.js3
-rw-r--r--app/assets/javascripts/u2f/authenticate.js16
-rw-r--r--app/assets/javascripts/u2f/error.js4
-rw-r--r--app/assets/javascripts/u2f/register.js18
-rw-r--r--app/assets/javascripts/users/calendar.js30
-rw-r--r--app/assets/javascripts/users/users_bundle.js2
-rw-r--r--app/assets/javascripts/users_select.js1088
-rw-r--r--app/assets/javascripts/version_check_image.js4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js23
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js27
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js116
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js106
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js147
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js23
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js88
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js42
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js16
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js48
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js19
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js30
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js39
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js76
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js24
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js116
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js130
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js34
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js17
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js42
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js16
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js16
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js313
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js16
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js27
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js59
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/dependencies.js44
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/event_hub.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/index.js14
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js247
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js57
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js30
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js138
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js37
-rw-r--r--app/assets/javascripts/vue_shared/ci_action_icons.js21
-rw-r--r--app/assets/javascripts/vue_shared/ci_status_icons.js43
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_badge_link.vue52
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_icon.vue50
-rw-r--r--app/assets/javascripts/vue_shared/components/commit.js24
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue122
-rw-r--r--app/assets/javascripts/vue_shared/components/loading_icon.vue33
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue107
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue113
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue33
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue58
-rw-r--r--app/assets/javascripts/vue_shared/components/memory_graph.js115
-rw-r--r--app/assets/javascripts/vue_shared/components/pipelines_table_row.js32
-rw-r--r--app/assets/javascripts/vue_shared/components/table_pagination.vue (renamed from app/assets/javascripts/vue_shared/components/table_pagination.js)38
-rw-r--r--app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue58
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue80
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue80
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue45
-rw-r--r--app/assets/javascripts/vue_shared/mixins/timeago.js18
-rw-r--r--app/assets/javascripts/vue_shared/mixins/tooltip.js13
-rw-r--r--app/assets/javascripts/vue_shared/translate.js42
-rw-r--r--app/assets/javascripts/vue_shared/vue_resource_interceptor.js2
-rw-r--r--app/assets/javascripts/wikis.js4
-rw-r--r--app/assets/javascripts/zen_mode.js11
327 files changed, 11278 insertions, 3714 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index e5f36c84987..6680834a8d1 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -1,148 +1,175 @@
-/* eslint-disable func-names, space-before-function-paren, quotes, object-shorthand, camelcase, no-var, comma-dangle, prefer-arrow-callback, quote-props, no-param-reassign, max-len */
-
-var Api = {
- groupsPath: "/api/:version/groups.json",
- groupPath: "/api/:version/groups/:id.json",
- namespacesPath: "/api/:version/namespaces.json",
- groupProjectsPath: "/api/:version/groups/:id/projects.json",
- projectsPath: "/api/:version/projects.json?simple=true",
- labelsPath: "/:namespace_path/:project_path/labels",
- licensePath: "/api/:version/templates/licenses/:key",
- gitignorePath: "/api/:version/templates/gitignores/:key",
- gitlabCiYmlPath: "/api/:version/templates/gitlab_ci_ymls/:key",
- dockerfilePath: "/api/:version/templates/dockerfiles/:key",
- issuableTemplatePath: "/:namespace_path/:project_path/templates/:type/:key",
- group: function(group_id, callback) {
- var url = Api.buildUrl(Api.groupPath)
- .replace(':id', group_id);
+import $ from 'jquery';
+
+const Api = {
+ groupsPath: '/api/:version/groups.json',
+ groupPath: '/api/:version/groups/:id.json',
+ namespacesPath: '/api/:version/namespaces.json',
+ groupProjectsPath: '/api/:version/groups/:id/projects.json',
+ projectsPath: '/api/:version/projects.json?simple=true',
+ labelsPath: '/:namespace_path/:project_path/labels',
+ licensePath: '/api/:version/templates/licenses/:key',
+ gitignorePath: '/api/:version/templates/gitignores/:key',
+ gitlabCiYmlPath: '/api/:version/templates/gitlab_ci_ymls/:key',
+ dockerfilePath: '/api/:version/templates/dockerfiles/:key',
+ issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
+ usersPath: '/api/:version/users.json',
+
+ group(groupId, callback) {
+ const url = Api.buildUrl(Api.groupPath)
+ .replace(':id', groupId);
return $.ajax({
- url: url,
- dataType: "json"
- }).done(function(group) {
- return callback(group);
- });
+ url,
+ dataType: 'json',
+ })
+ .done(group => callback(group));
},
+
// Return groups list. Filtered by query
- groups: function(query, options, callback) {
- var url = Api.buildUrl(Api.groupsPath);
+ groups(query, options, callback) {
+ const url = Api.buildUrl(Api.groupsPath);
return $.ajax({
- url: url,
- data: $.extend({
+ url,
+ data: Object.assign({
search: query,
- per_page: 20
+ per_page: 20,
}, options),
- dataType: "json"
- }).done(function(groups) {
- return callback(groups);
- });
+ dataType: 'json',
+ })
+ .done(groups => callback(groups));
},
+
// Return namespaces list. Filtered by query
- namespaces: function(query, callback) {
- var url = Api.buildUrl(Api.namespacesPath);
+ namespaces(query, callback) {
+ const url = Api.buildUrl(Api.namespacesPath);
return $.ajax({
- url: url,
+ url,
data: {
search: query,
- per_page: 20
+ per_page: 20,
},
- dataType: "json"
- }).done(function(namespaces) {
- return callback(namespaces);
- });
+ dataType: 'json',
+ }).done(namespaces => callback(namespaces));
},
+
// Return projects list. Filtered by query
- projects: function(query, options, callback) {
- var url = Api.buildUrl(Api.projectsPath);
+ projects(query, options, callback) {
+ const url = Api.buildUrl(Api.projectsPath);
return $.ajax({
- url: url,
- data: $.extend({
+ url,
+ data: Object.assign({
search: query,
per_page: 20,
- membership: true
+ membership: true,
}, options),
- dataType: "json"
- }).done(function(projects) {
- return callback(projects);
- });
+ dataType: 'json',
+ })
+ .done(projects => callback(projects));
},
- newLabel: function(namespace_path, project_path, data, callback) {
- var url = Api.buildUrl(Api.labelsPath)
- .replace(':namespace_path', namespace_path)
- .replace(':project_path', project_path);
+
+ newLabel(namespacePath, projectPath, data, callback) {
+ const url = Api.buildUrl(Api.labelsPath)
+ .replace(':namespace_path', namespacePath)
+ .replace(':project_path', projectPath);
return $.ajax({
- url: url,
- type: "POST",
- data: { 'label': data },
- dataType: "json"
- }).done(function(label) {
- return callback(label);
- }).error(function(message) {
- return callback(message.responseJSON);
- });
+ url,
+ type: 'POST',
+ data: { label: data },
+ dataType: 'json',
+ })
+ .done(label => callback(label))
+ .error(message => callback(message.responseJSON));
},
+
// Return group projects list. Filtered by query
- groupProjects: function(group_id, query, callback) {
- var url = Api.buildUrl(Api.groupProjectsPath)
- .replace(':id', group_id);
+ groupProjects(groupId, query, callback) {
+ const url = Api.buildUrl(Api.groupProjectsPath)
+ .replace(':id', groupId);
return $.ajax({
- url: url,
+ url,
data: {
search: query,
- per_page: 20
+ per_page: 20,
},
- dataType: "json"
- }).done(function(projects) {
- return callback(projects);
- });
+ dataType: 'json',
+ })
+ .done(projects => callback(projects));
},
+
// Return text for a specific license
- licenseText: function(key, data, callback) {
- var url = Api.buildUrl(Api.licensePath)
+ licenseText(key, data, callback) {
+ const url = Api.buildUrl(Api.licensePath)
.replace(':key', key);
return $.ajax({
- url: url,
- data: data
- }).done(function(license) {
- return callback(license);
- });
+ url,
+ data,
+ })
+ .done(license => callback(license));
},
- gitignoreText: function(key, callback) {
- var url = Api.buildUrl(Api.gitignorePath)
+
+ gitignoreText(key, callback) {
+ const url = Api.buildUrl(Api.gitignorePath)
.replace(':key', key);
- return $.get(url, function(gitignore) {
- return callback(gitignore);
- });
+ return $.get(url, gitignore => callback(gitignore));
},
- gitlabCiYml: function(key, callback) {
- var url = Api.buildUrl(Api.gitlabCiYmlPath)
+
+ gitlabCiYml(key, callback) {
+ const url = Api.buildUrl(Api.gitlabCiYmlPath)
.replace(':key', key);
- return $.get(url, function(file) {
- return callback(file);
- });
+ return $.get(url, file => callback(file));
},
- dockerfileYml: function(key, callback) {
- var url = Api.buildUrl(Api.dockerfilePath).replace(':key', key);
+
+ dockerfileYml(key, callback) {
+ const url = Api.buildUrl(Api.dockerfilePath).replace(':key', key);
$.get(url, callback);
},
- issueTemplate: function(namespacePath, projectPath, key, type, callback) {
- var url = Api.buildUrl(Api.issuableTemplatePath)
+
+ issueTemplate(namespacePath, projectPath, key, type, callback) {
+ const url = Api.buildUrl(Api.issuableTemplatePath)
.replace(':key', key)
.replace(':type', type)
.replace(':project_path', projectPath)
.replace(':namespace_path', namespacePath);
$.ajax({
- url: url,
- dataType: 'json'
- }).done(function(file) {
- callback(null, file);
- }).error(callback);
+ url,
+ dataType: 'json',
+ })
+ .done(file => callback(null, file))
+ .error(callback);
},
- buildUrl: function(url) {
+
+ users(query, options) {
+ const url = Api.buildUrl(this.usersPath);
+ return Api.wrapAjaxCall({
+ url,
+ data: Object.assign({
+ search: query,
+ per_page: 20,
+ }, options),
+ dataType: 'json',
+ });
+ },
+
+ buildUrl(url) {
+ let urlRoot = '';
if (gon.relative_url_root != null) {
- url = gon.relative_url_root + url;
+ urlRoot = gon.relative_url_root;
}
- return url.replace(':version', gon.api_version);
- }
+ return urlRoot + url.replace(':version', gon.api_version);
+ },
+
+ wrapAjaxCall(options) {
+ return new Promise((resolve, reject) => {
+ // jQuery 2 is not Promises/A+ compatible (missing catch)
+ $.ajax(options) // eslint-disable-line promise/catch-or-return
+ .then(data => resolve(data),
+ (jqXHR, textStatus, errorThrown) => {
+ const error = new Error(`${options.url}: ${errorThrown}`);
+ error.textStatus = textStatus;
+ reject(error);
+ },
+ );
+ });
+ },
};
-window.Api = Api;
+export default Api;
diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js
index 8630b18a73f..cfab6c40b34 100644
--- a/app/assets/javascripts/autosave.js
+++ b/app/assets/javascripts/autosave.js
@@ -1,8 +1,11 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-param-reassign, quotes, prefer-template, no-var, one-var, no-unused-vars, one-var-declaration-per-line, no-void, consistent-return, no-empty, max-len */
+import AccessorUtilities from './lib/utils/accessor';
window.Autosave = (function() {
function Autosave(field, key) {
this.field = field;
+ this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
+
if (key.join != null) {
key = key.join("/");
}
@@ -17,16 +20,12 @@ window.Autosave = (function() {
}
Autosave.prototype.restore = function() {
- var e, text;
- if (window.localStorage == null) {
- return;
- }
- try {
- text = window.localStorage.getItem(this.key);
- } catch (error) {
- e = error;
- return;
- }
+ var text;
+
+ if (!this.isLocalStorageAvailable) return;
+
+ text = window.localStorage.getItem(this.key);
+
if ((text != null ? text.length : void 0) > 0) {
this.field.val(text);
}
@@ -35,27 +34,22 @@ window.Autosave = (function() {
Autosave.prototype.save = function() {
var text;
- if (window.localStorage == null) {
- return;
- }
text = this.field.val();
- if ((text != null ? text.length : void 0) > 0) {
- try {
- return window.localStorage.setItem(this.key, text);
- } catch (error) {}
- } else {
- return this.reset();
+
+ if (this.isLocalStorageAvailable && (text != null ? text.length : void 0) > 0) {
+ return window.localStorage.setItem(this.key, text);
}
+
+ return this.reset();
};
Autosave.prototype.reset = function() {
- if (window.localStorage == null) {
- return;
- }
- try {
- return window.localStorage.removeItem(this.key);
- } catch (error) {}
+ if (!this.isLocalStorageAvailable) return;
+
+ return window.localStorage.removeItem(this.key);
};
return Autosave;
})();
+
+export default window.Autosave;
diff --git a/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js b/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js
index aa522e20c36..257df55e54f 100644
--- a/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js
+++ b/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js
@@ -1,3 +1,5 @@
+import AccessorUtilities from '../../lib/utils/accessor';
+
const unicodeSupportTestMap = {
// man, student (emojione does not have any of these yet), http://emojipedia.org/emoji-zwj-sequences/
// occupationZwj: '\u{1F468}\u{200D}\u{1F393}',
@@ -140,16 +142,25 @@ function generateUnicodeSupportMap(testMap) {
function getUnicodeSupportMap() {
let unicodeSupportMap;
- const userAgentFromCache = window.localStorage.getItem('gl-emoji-user-agent');
+ let userAgentFromCache;
+
+ const isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
+
+ if (isLocalStorageAvailable) userAgentFromCache = window.localStorage.getItem('gl-emoji-user-agent');
+
try {
unicodeSupportMap = JSON.parse(window.localStorage.getItem('gl-emoji-unicode-support-map'));
} catch (err) {
// swallow
}
+
if (!unicodeSupportMap || userAgentFromCache !== navigator.userAgent) {
unicodeSupportMap = generateUnicodeSupportMap(unicodeSupportTestMap);
- window.localStorage.setItem('gl-emoji-user-agent', navigator.userAgent);
- window.localStorage.setItem('gl-emoji-unicode-support-map', JSON.stringify(unicodeSupportMap));
+
+ if (isLocalStorageAvailable) {
+ window.localStorage.setItem('gl-emoji-user-agent', navigator.userAgent);
+ window.localStorage.setItem('gl-emoji-unicode-support-map', JSON.stringify(unicodeSupportMap));
+ }
}
return unicodeSupportMap;
diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js
index 3d162b24413..1f9e0448084 100644
--- a/app/assets/javascripts/behaviors/quick_submit.js
+++ b/app/assets/javascripts/behaviors/quick_submit.js
@@ -43,8 +43,8 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', (e) => {
const $submitButton = $form.find('input[type=submit], button[type=submit]');
if (!$submitButton.attr('disabled')) {
+ $submitButton.trigger('click', [e]);
$submitButton.disable();
- $form.submit();
}
});
diff --git a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js
new file mode 100644
index 00000000000..c17877a276d
--- /dev/null
+++ b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js
@@ -0,0 +1,114 @@
+import sqljs from 'sql.js';
+import { template as _template } from 'underscore';
+
+const PREVIEW_TEMPLATE = _template(`
+ <div class="panel panel-default">
+ <div class="panel-heading"><%- name %></div>
+ <div class="panel-body">
+ <img class="img-thumbnail" src="data:image/png;base64,<%- image %>"/>
+ </div>
+ </div>
+`);
+
+class BalsamiqViewer {
+ constructor(viewer) {
+ this.viewer = viewer;
+ }
+
+ 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();
+ }
+
+ renderFile(loadEvent) {
+ const container = document.createElement('ul');
+
+ this.initDatabase(loadEvent.target.response);
+
+ const previews = this.getPreviews();
+ previews.forEach((preview) => {
+ const renderedPreview = this.renderPreview(preview);
+
+ container.appendChild(renderedPreview);
+ });
+
+ container.classList.add('list-inline');
+ container.classList.add('previews');
+
+ this.viewer.appendChild(container);
+ }
+
+ initDatabase(data) {
+ const previewBinary = new Uint8Array(data);
+
+ this.database = new sqljs.Database(previewBinary);
+ }
+
+ getPreviews() {
+ const thumbnails = this.database.exec('SELECT * FROM thumbnails');
+
+ return thumbnails[0].values.map(BalsamiqViewer.parsePreview);
+ }
+
+ getResource(resourceID) {
+ const resources = this.database.exec(`SELECT * FROM resources WHERE id = '${resourceID}'`);
+
+ return resources[0];
+ }
+
+ renderPreview(preview) {
+ const previewElement = document.createElement('li');
+
+ previewElement.classList.add('preview');
+ previewElement.innerHTML = this.renderTemplate(preview);
+
+ return previewElement;
+ }
+
+ renderTemplate(preview) {
+ const resource = this.getResource(preview.resourceID);
+ const name = BalsamiqViewer.parseTitle(resource);
+ const image = preview.image;
+
+ const template = PREVIEW_TEMPLATE({
+ name,
+ image,
+ });
+
+ return template;
+ }
+
+ static parsePreview(preview) {
+ return JSON.parse(preview[1]);
+ }
+
+ /*
+ * resource = {
+ * columns: ['ID', 'BRANCHID', 'ATTRIBUTES', 'DATA'],
+ * values: [['id', 'branchId', 'attributes', 'data']],
+ * }
+ *
+ * 'attributes' being a JSON string containing the `name` property.
+ */
+ static parseTitle(resource) {
+ return JSON.parse(resource.values[0][2]).name;
+ }
+}
+
+export default BalsamiqViewer;
diff --git a/app/assets/javascripts/blob/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq_viewer.js
new file mode 100644
index 00000000000..8641a6fdae6
--- /dev/null
+++ b/app/assets/javascripts/blob/balsamiq_viewer.js
@@ -0,0 +1,22 @@
+/* global Flash */
+
+import BalsamiqViewer from './balsamiq/balsamiq_viewer';
+
+function onError() {
+ const flash = new window.Flash('Balsamiq file could not be loaded.');
+
+ return flash;
+}
+
+function loadBalsamiqFile() {
+ const viewer = document.getElementById('js-balsamiq-viewer');
+
+ if (!(viewer instanceof Element)) return;
+
+ const endpoint = viewer.dataset.endpoint;
+
+ const balsamiqViewer = new BalsamiqViewer(viewer);
+ balsamiqViewer.loadFile(endpoint).catch(onError);
+}
+
+$(loadBalsamiqFile);
diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js
index 3062cd51ee3..a20c6ca7a21 100644
--- a/app/assets/javascripts/blob/file_template_mediator.js
+++ b/app/assets/javascripts/blob/file_template_mediator.js
@@ -99,7 +99,7 @@ export default class FileTemplateMediator {
});
}
- selectTemplateType(item, el, e) {
+ selectTemplateType(item, e) {
if (e) {
e.preventDefault();
}
@@ -117,6 +117,10 @@ export default class FileTemplateMediator {
this.cacheToggleText();
}
+ selectTemplateTypeOptions(options) {
+ this.selectTemplateType(options.selectedObj, options.e);
+ }
+
selectTemplateFile(selector, query, data) {
selector.renderLoading();
// in case undo menu is already already there
diff --git a/app/assets/javascripts/blob/file_template_selector.js b/app/assets/javascripts/blob/file_template_selector.js
index 31dd45fac89..5ae30990aea 100644
--- a/app/assets/javascripts/blob/file_template_selector.js
+++ b/app/assets/javascripts/blob/file_template_selector.js
@@ -1,5 +1,3 @@
-/* global Api */
-
export default class FileTemplateSelector {
constructor(mediator) {
this.mediator = mediator;
@@ -52,9 +50,16 @@ export default class FileTemplateSelector {
.removeClass('fa-spinner fa-spin');
}
- reportSelection(query, el, e, data) {
+ reportSelection(options) {
+ const { query, e, data } = options;
e.preventDefault();
return this.mediator.selectTemplateFile(this, query, data);
}
-}
+ reportSelectionName(options) {
+ const opts = options;
+ opts.query = options.selectedObj.name;
+
+ this.reportSelection(opts);
+ }
+}
diff --git a/app/assets/javascripts/blob/target_branch_dropdown.js b/app/assets/javascripts/blob/target_branch_dropdown.js
index 216f069ef71..d52d69b1274 100644
--- a/app/assets/javascripts/blob/target_branch_dropdown.js
+++ b/app/assets/javascripts/blob/target_branch_dropdown.js
@@ -37,8 +37,8 @@ class TargetBranchDropDown {
}
return SELECT_ITEM_MSG;
},
- clicked(item, el, e) {
- e.preventDefault();
+ clicked(options) {
+ options.e.preventDefault();
self.onClick.call(self);
},
fieldName: self.fieldName,
diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js
index d7c1c32efbd..888883163c5 100644
--- a/app/assets/javascripts/blob/template_selector.js
+++ b/app/assets/javascripts/blob/template_selector.js
@@ -24,7 +24,7 @@ export default class TemplateSelector {
search: {
fields: ['name'],
},
- clicked: (item, el, e) => this.fetchFileTemplate(item, el, e),
+ clicked: options => this.fetchFileTemplate(options),
text: item => item.name,
});
}
@@ -51,7 +51,10 @@ export default class TemplateSelector {
return this.$dropdownContainer.removeClass('hidden');
}
- fetchFileTemplate(item, el, e) {
+ fetchFileTemplate(options) {
+ const { e } = options;
+ const item = options.selectedObj;
+
e.preventDefault();
return this.requestFile(item);
}
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 935df07677c..9c41e429c8d 100644
--- a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js
@@ -1,4 +1,4 @@
-/* global Api */
+import Api from '../../api';
import FileTemplateSelector from '../file_template_selector';
@@ -25,7 +25,7 @@ export default class BlobCiYamlSelector extends FileTemplateSelector {
search: {
fields: ['name'],
},
- clicked: (query, el, e) => this.reportSelection(query.name, el, e),
+ clicked: options => this.reportSelectionName(options),
text: item => item.name,
});
}
diff --git a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
index b4b4d09c315..45fb614fe00 100644
--- a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
@@ -1,4 +1,4 @@
-/* global Api */
+import Api from '../../api';
import FileTemplateSelector from '../file_template_selector';
@@ -25,7 +25,7 @@ export default class DockerfileSelector extends FileTemplateSelector {
search: {
fields: ['name'],
},
- clicked: (query, el, e) => this.reportSelection(query.name, el, e),
+ clicked: options => this.reportSelectionName(options),
text: item => item.name,
});
}
diff --git a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js
index aefae54ae71..a894953cc86 100644
--- a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js
@@ -1,4 +1,4 @@
-/* global Api */
+import Api from '../../api';
import FileTemplateSelector from '../file_template_selector';
@@ -24,7 +24,7 @@ export default class BlobGitignoreSelector extends FileTemplateSelector {
search: {
fields: ['name'],
},
- clicked: (query, el, e) => this.reportSelection(query.name, el, e),
+ clicked: options => this.reportSelectionName(options),
text: item => item.name,
});
}
diff --git a/app/assets/javascripts/blob/template_selectors/license_selector.js b/app/assets/javascripts/blob/template_selectors/license_selector.js
index c8abd689ab4..b7c4da0f62e 100644
--- a/app/assets/javascripts/blob/template_selectors/license_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/license_selector.js
@@ -1,4 +1,4 @@
-/* global Api */
+import Api from '../../api';
import FileTemplateSelector from '../file_template_selector';
@@ -24,13 +24,22 @@ export default class BlobLicenseSelector extends FileTemplateSelector {
search: {
fields: ['name'],
},
- clicked: (query, el, e) => {
+ clicked: (options) => {
+ const { e } = options;
+ const el = options.$el;
+ const query = options.selectedObj;
+
const data = {
project: this.$dropdown.data('project'),
fullname: this.$dropdown.data('fullname'),
};
- this.reportSelection(query.id, el, e, data);
+ this.reportSelection({
+ query: query.id,
+ el,
+ e,
+ data,
+ });
},
text: item => item.name,
});
diff --git a/app/assets/javascripts/blob/template_selectors/type_selector.js b/app/assets/javascripts/blob/template_selectors/type_selector.js
index 56f23ef0568..a09381014a7 100644
--- a/app/assets/javascripts/blob/template_selectors/type_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/type_selector.js
@@ -17,7 +17,7 @@ export default class FileTemplateTypeSelector extends FileTemplateSelector {
filterable: false,
selectable: true,
toggleLabel: item => item.name,
- clicked: (item, el, e) => this.mediator.selectTemplateType(item, el, e),
+ 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 07d67d49aa5..187fab084fd 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -1,17 +1,38 @@
/* global Flash */
export default class BlobViewer {
constructor() {
+ BlobViewer.initAuxiliaryViewer();
+
+ this.initMainViewers();
+ }
+
+ static initAuxiliaryViewer() {
+ const auxiliaryViewer = document.querySelector('.blob-viewer[data-type="auxiliary"]');
+ if (!auxiliaryViewer) return;
+
+ BlobViewer.loadViewer(auxiliaryViewer);
+ }
+
+ initMainViewers() {
+ this.$fileHolder = $('.file-holder');
+ if (!this.$fileHolder.length) return;
+
this.switcher = document.querySelector('.js-blob-viewer-switcher');
this.switcherBtns = document.querySelectorAll('.js-blob-viewer-switch-btn');
this.copySourceBtn = document.querySelector('.js-copy-blob-source-btn');
- this.simpleViewer = document.querySelector('.blob-viewer[data-type="simple"]');
- this.richViewer = document.querySelector('.blob-viewer[data-type="rich"]');
- this.$fileHolder = $('.file-holder');
- let initialViewerName = document.querySelector('.blob-viewer:not(.hidden)').getAttribute('data-type');
+ this.simpleViewer = this.$fileHolder[0].querySelector('.blob-viewer[data-type="simple"]');
+ this.richViewer = this.$fileHolder[0].querySelector('.blob-viewer[data-type="rich"]');
this.initBindings();
+ this.switchToInitialViewer();
+ }
+
+ switchToInitialViewer() {
+ const initialViewer = this.$fileHolder[0].querySelector('.blob-viewer:not(.hidden)');
+ let initialViewerName = initialViewer.getAttribute('data-type');
+
if (this.switcher && location.hash.indexOf('#L') === 0) {
initialViewerName = 'simple';
}
@@ -29,9 +50,9 @@ export default class BlobViewer {
if (this.copySourceBtn) {
this.copySourceBtn.addEventListener('click', () => {
- if (this.copySourceBtn.classList.contains('disabled')) return;
+ if (this.copySourceBtn.classList.contains('disabled')) return this.copySourceBtn.blur();
- this.switchToViewer('simple');
+ return this.switchToViewer('simple');
});
}
}
@@ -61,40 +82,13 @@ export default class BlobViewer {
$(this.copySourceBtn).tooltip('fixTitle');
}
- loadViewer(viewerParam) {
- const viewer = viewerParam;
- const url = viewer.getAttribute('data-url');
-
- if (!url || viewer.getAttribute('data-loaded') || viewer.getAttribute('data-loading')) {
- return;
- }
-
- viewer.setAttribute('data-loading', 'true');
-
- $.ajax({
- url,
- dataType: 'JSON',
- })
- .fail(() => new Flash('Error loading source view'))
- .done((data) => {
- viewer.innerHTML = data.html;
- $(viewer).syntaxHighlight();
-
- viewer.setAttribute('data-loaded', 'true');
-
- this.$fileHolder.trigger('highlight:line');
-
- this.toggleCopyButtonState();
- });
- }
-
switchToViewer(name) {
- const newViewer = document.querySelector(`.blob-viewer[data-type='${name}']`);
+ const newViewer = this.$fileHolder[0].querySelector(`.blob-viewer[data-type='${name}']`);
if (this.activeViewer === newViewer) return;
const oldButton = document.querySelector('.js-blob-viewer-switch-btn.active');
const newButton = document.querySelector(`.js-blob-viewer-switch-btn[data-viewer='${name}']`);
- const oldViewer = document.querySelector(`.blob-viewer:not([data-type='${name}'])`);
+ const oldViewer = this.$fileHolder[0].querySelector(`.blob-viewer:not([data-type='${name}'])`);
if (oldButton) {
oldButton.classList.remove('active');
@@ -115,6 +109,41 @@ export default class BlobViewer {
this.toggleCopyButtonState();
- this.loadViewer(newViewer);
+ BlobViewer.loadViewer(newViewer)
+ .then((viewer) => {
+ $(viewer).renderGFM();
+
+ this.$fileHolder.trigger('highlight:line');
+ gl.utils.handleLocationHash();
+
+ this.toggleCopyButtonState();
+ })
+ .catch(() => new Flash('Error loading viewer'));
+ }
+
+ static loadViewer(viewerParam) {
+ const viewer = viewerParam;
+ const url = viewer.getAttribute('data-url');
+
+ return new Promise((resolve, reject) => {
+ if (!url || viewer.getAttribute('data-loaded') || viewer.getAttribute('data-loading')) {
+ resolve(viewer);
+ return;
+ }
+
+ viewer.setAttribute('data-loading', 'true');
+
+ $.ajax({
+ url,
+ dataType: 'JSON',
+ })
+ .fail(reject)
+ .done((data) => {
+ viewer.innerHTML = data.html;
+ viewer.setAttribute('data-loaded', 'true');
+
+ resolve(viewer);
+ });
+ });
}
}
diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js
index b6dee8177d2..0e4aa39226b 100644
--- a/app/assets/javascripts/boards/boards_bundle.js
+++ b/app/assets/javascripts/boards/boards_bundle.js
@@ -6,23 +6,22 @@ import Vue from 'vue';
import VueResource from 'vue-resource';
import FilteredSearchBoards from './filtered_search_boards';
import eventHub from './eventhub';
-
-require('./models/issue');
-require('./models/label');
-require('./models/list');
-require('./models/milestone');
-require('./models/user');
-require('./stores/boards_store');
-require('./stores/modal_store');
-require('./services/board_service');
-require('./mixins/modal_mixins');
-require('./mixins/sortable_default_options');
-require('./filters/due_date_filters');
-require('./components/board');
-require('./components/board_sidebar');
-require('./components/new_list_dropdown');
-require('./components/modal/index');
-require('../vue_shared/vue_resource_interceptor');
+import './models/issue';
+import './models/label';
+import './models/list';
+import './models/milestone';
+import './models/assignee';
+import './stores/boards_store';
+import './stores/modal_store';
+import './services/board_service';
+import './mixins/modal_mixins';
+import './mixins/sortable_default_options';
+import './filters/due_date_filters';
+import './components/board';
+import './components/board_sidebar';
+import './components/new_list_dropdown';
+import './components/modal/index';
+import '../vue_shared/vue_resource_interceptor';
Vue.use(VueResource);
@@ -59,7 +58,8 @@ $(() => {
issueLinkBase: $boardApp.dataset.issueLinkBase,
rootPath: $boardApp.dataset.rootPath,
bulkUpdatePath: $boardApp.dataset.bulkUpdatePath,
- detailIssue: Store.detail
+ detailIssue: Store.detail,
+ defaultAvatar: $boardApp.dataset.defaultAvatar,
},
computed: {
detailIssueVisible () {
@@ -70,6 +70,7 @@ $(() => {
gl.boardService = new BoardService(this.endpoint, this.bulkUpdatePath, this.boardId);
this.filterManager = new FilteredSearchBoards(Store.filter, true);
+ this.filterManager.setup();
// Listen for updateTokens event
eventHub.$on('updateTokens', this.updateTokens);
@@ -82,7 +83,7 @@ $(() => {
gl.boardService.all()
.then((resp) => {
resp.json().forEach((board) => {
- const list = Store.addList(board);
+ const list = Store.addList(board, this.defaultAvatar);
if (list.type === 'closed') {
list.position = Infinity;
diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js
index 239eeacf2d7..9ba84489910 100644
--- a/app/assets/javascripts/boards/components/board.js
+++ b/app/assets/javascripts/boards/components/board.js
@@ -3,9 +3,7 @@
import Vue from 'vue';
import boardList from './board_list';
import boardBlankState from './board_blank_state';
-
-require('./board_delete');
-require('./board_list');
+import './board_delete';
const Store = gl.issueBoards.BoardsStore;
@@ -35,7 +33,10 @@ gl.issueBoards.Board = Vue.extend({
filter: {
handler() {
this.list.page = 1;
- this.list.getIssues(true);
+ this.list.getIssues(true)
+ .catch(() => {
+ // TODO: handle request error
+ });
},
deep: true,
},
diff --git a/app/assets/javascripts/boards/components/board_blank_state.js b/app/assets/javascripts/boards/components/board_blank_state.js
index 3fc68457961..870e115bd1a 100644
--- a/app/assets/javascripts/boards/components/board_blank_state.js
+++ b/app/assets/javascripts/boards/components/board_blank_state.js
@@ -70,7 +70,10 @@ export default {
list.id = listObj.id;
list.label.id = listObj.label.id;
- list.getIssues();
+ list.getIssues()
+ .catch(() => {
+ // TODO: handle request error
+ });
});
})
.catch(() => {
diff --git a/app/assets/javascripts/boards/components/board_card.js b/app/assets/javascripts/boards/components/board_card.js
index f591134c548..079fb6438b9 100644
--- a/app/assets/javascripts/boards/components/board_card.js
+++ b/app/assets/javascripts/boards/components/board_card.js
@@ -1,4 +1,4 @@
-require('./issue_card_inner');
+import './issue_card_inner';
const Store = gl.issueBoards.BoardsStore;
diff --git a/app/assets/javascripts/boards/components/board_list.js b/app/assets/javascripts/boards/components/board_list.js
index b13386536bf..7ee2696e720 100644
--- a/app/assets/javascripts/boards/components/board_list.js
+++ b/app/assets/javascripts/boards/components/board_list.js
@@ -2,6 +2,7 @@
import boardNewIssue from './board_new_issue';
import boardCard from './board_card';
import eventHub from '../eventhub';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
const Store = gl.issueBoards.BoardsStore;
@@ -44,6 +45,7 @@ export default {
components: {
boardCard,
boardNewIssue,
+ loadingIcon,
},
methods: {
listHeight() {
@@ -90,7 +92,10 @@ export default {
if (this.scrollHeight() <= this.listHeight() &&
this.list.issuesSize > this.list.issues.length) {
this.list.page += 1;
- this.list.getIssues(false);
+ this.list.getIssues(false)
+ .catch(() => {
+ // TODO: handle request error
+ });
}
if (this.scrollHeight() > Math.ceil(this.listHeight())) {
@@ -153,10 +158,7 @@ export default {
class="board-list-loading text-center"
aria-label="Loading issues"
v-if="loading">
- <i
- class="fa fa-spinner fa-spin"
- aria-hidden="true">
- </i>
+ <loading-icon />
</div>
<board-new-issue
:list="list"
@@ -181,12 +183,12 @@ export default {
class="board-list-count text-center"
v-if="showCount"
data-id="-1">
- <i
- class="fa fa-spinner fa-spin"
- aria-label="Loading more issues"
- aria-hidden="true"
- v-show="list.loadingMore">
- </i>
+
+ <loading-icon
+ v-show="list.loadingMore"
+ label="Loading more issues"
+ />
+
<span v-if="list.issues.length === list.issuesSize">
Showing all issues
</span>
diff --git a/app/assets/javascripts/boards/components/board_new_issue.js b/app/assets/javascripts/boards/components/board_new_issue.js
index 0fa85b6fe14..1ce95b62138 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.js
+++ b/app/assets/javascripts/boards/components/board_new_issue.js
@@ -26,6 +26,7 @@ export default {
title: this.title,
labels,
subscribed: true,
+ assignees: [],
});
this.list.newIssue(issue)
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
index f0066d4ec5d..386102032cb 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -3,10 +3,13 @@
/* global MilestoneSelect */
/* global LabelsSelect */
/* global Sidebar */
+/* global Flash */
import Vue from 'vue';
-
-require('./sidebar/remove_issue');
+import eventHub from '../../sidebar/event_hub';
+import AssigneeTitle from '../../sidebar/components/assignees/assignee_title';
+import Assignees from '../../sidebar/components/assignees/assignees';
+import './sidebar/remove_issue';
const Store = gl.issueBoards.BoardsStore;
@@ -22,6 +25,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({
detail: Store.detail,
issue: {},
list: {},
+ loadingAssignees: false,
};
},
computed: {
@@ -30,12 +34,21 @@ gl.issueBoards.BoardSidebar = Vue.extend({
},
assigneeId() {
return this.issue.assignee ? this.issue.assignee.id : 0;
+ },
+ milestoneTitle() {
+ return this.issue.milestone ? this.issue.milestone.title : 'No Milestone';
}
},
watch: {
detail: {
handler () {
if (this.issue.id !== this.detail.issue.id) {
+ $('.block.assignee')
+ .find('input:not(.js-vue)[name="issue[assignee_ids][]"]')
+ .each((i, el) => {
+ $(el).remove();
+ });
+
$('.js-issue-board-sidebar', this.$el).each((i, el) => {
$(el).data('glDropdown').clearMenu();
});
@@ -43,22 +56,59 @@ gl.issueBoards.BoardSidebar = Vue.extend({
this.issue = this.detail.issue;
this.list = this.detail.list;
+
+ this.$nextTick(() => {
+ this.endpoint = this.$refs.assigneeDropdown.dataset.issueUpdate;
+ });
},
deep: true
},
- issue () {
- if (this.showSidebar) {
- this.$nextTick(() => {
- $('.right-sidebar').getNiceScroll(0).doScrollTop(0, 0);
- $('.right-sidebar').getNiceScroll().resize();
- });
- }
- }
},
methods: {
closeSidebar () {
this.detail.issue = {};
- }
+ },
+ assignSelf () {
+ // Notify gl dropdown that we are now assigning to current user
+ this.$refs.assigneeBlock.dispatchEvent(new Event('assignYourself'));
+
+ this.addAssignee(this.currentUser);
+ this.saveAssignees();
+ },
+ removeAssignee (a) {
+ gl.issueBoards.BoardsStore.detail.issue.removeAssignee(a);
+ },
+ addAssignee (a) {
+ gl.issueBoards.BoardsStore.detail.issue.addAssignee(a);
+ },
+ removeAllAssignees () {
+ gl.issueBoards.BoardsStore.detail.issue.removeAllAssignees();
+ },
+ saveAssignees () {
+ this.loadingAssignees = true;
+
+ gl.issueBoards.BoardsStore.detail.issue.update(this.endpoint)
+ .then(() => {
+ this.loadingAssignees = false;
+ })
+ .catch(() => {
+ this.loadingAssignees = false;
+ return new Flash('An error occurred while saving assignees');
+ });
+ },
+ },
+ created () {
+ // Get events from glDropdown
+ eventHub.$on('sidebar.removeAssignee', this.removeAssignee);
+ eventHub.$on('sidebar.addAssignee', this.addAssignee);
+ eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees);
+ eventHub.$on('sidebar.saveAssignees', this.saveAssignees);
+ },
+ beforeDestroy() {
+ eventHub.$off('sidebar.removeAssignee', this.removeAssignee);
+ eventHub.$off('sidebar.addAssignee', this.addAssignee);
+ eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees);
+ eventHub.$off('sidebar.saveAssignees', this.saveAssignees);
},
mounted () {
new IssuableContext(this.currentUser);
@@ -70,5 +120,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({
},
components: {
removeBtn: gl.issueBoards.RemoveIssueBtn,
+ 'assignee-title': AssigneeTitle,
+ assignees: Assignees,
},
});
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js
index fc154ee7b8b..4699ef5a51c 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.js
+++ b/app/assets/javascripts/boards/components/issue_card_inner.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import eventHub from '../eventhub';
const Store = gl.issueBoards.BoardsStore;
@@ -31,18 +32,39 @@ gl.issueBoards.IssueCardInner = Vue.extend({
default: false,
},
},
+ data() {
+ return {
+ limitBeforeCounter: 3,
+ maxRender: 4,
+ maxCounter: 99,
+ };
+ },
+ components: {
+ userAvatarLink,
+ },
computed: {
- cardUrl() {
- return `${this.issueLinkBase}/${this.issue.id}`;
+ numberOverLimit() {
+ return this.issue.assignees.length - this.limitBeforeCounter;
},
- assigneeUrl() {
- return `${this.rootPath}${this.issue.assignee.username}`;
+ assigneeCounterTooltip() {
+ return `${this.assigneeCounterLabel} more`;
},
- assigneeUrlTitle() {
- return `Assigned to ${this.issue.assignee.name}`;
+ assigneeCounterLabel() {
+ if (this.numberOverLimit > this.maxCounter) {
+ return `${this.maxCounter}+`;
+ }
+
+ return `+${this.numberOverLimit}`;
},
- avatarUrlTitle() {
- return `Avatar for ${this.issue.assignee.name}`;
+ shouldRenderCounter() {
+ if (this.issue.assignees.length <= this.maxRender) {
+ return false;
+ }
+
+ return this.issue.assignees.length > this.numberOverLimit;
+ },
+ cardUrl() {
+ return `${this.issueLinkBase}/${this.issue.id}`;
},
issueId() {
return `#${this.issue.id}`;
@@ -52,6 +74,28 @@ gl.issueBoards.IssueCardInner = Vue.extend({
},
},
methods: {
+ isIndexLessThanlimit(index) {
+ return index < this.limitBeforeCounter;
+ },
+ shouldRenderAssignee(index) {
+ // Eg. maxRender is 4,
+ // Render up to all 4 assignees if there are only 4 assigness
+ // Otherwise render up to the limitBeforeCounter
+ if (this.issue.assignees.length <= this.maxRender) {
+ return index < this.maxRender;
+ }
+
+ return index < this.limitBeforeCounter;
+ },
+ assigneeUrl(assignee) {
+ return `${this.rootPath}${assignee.username}`;
+ },
+ assigneeUrlTitle(assignee) {
+ return `Assigned to ${assignee.name}`;
+ },
+ avatarUrlTitle(assignee) {
+ return `Avatar for ${assignee.name}`;
+ },
showLabel(label) {
if (!this.list) return true;
@@ -105,25 +149,32 @@ gl.issueBoards.IssueCardInner = Vue.extend({
{{ issueId }}
</span>
</h4>
- <a
- class="card-assignee has-tooltip js-no-trigger"
- :href="assigneeUrl"
- :title="assigneeUrlTitle"
- v-if="issue.assignee"
- data-container="body"
- >
- <img
- class="avatar avatar-inline s20 js-no-trigger"
- :src="issue.assignee.avatar"
- width="20"
- height="20"
- :alt="avatarUrlTitle"
+ <div class="card-assignee">
+ <user-avatar-link
+ v-for="(assignee, index) in issue.assignees"
+ v-if="shouldRenderAssignee(index)"
+ class="js-no-trigger"
+ :link-href="assigneeUrl(assignee)"
+ :img-alt="avatarUrlTitle(assignee)"
+ :img-src="assignee.avatar"
+ :tooltip-text="assigneeUrlTitle(assignee)"
+ tooltip-placement="bottom"
/>
- </a>
+ <span
+ class="avatar-counter has-tooltip"
+ :title="assigneeCounterTooltip"
+ v-if="shouldRenderCounter"
+ >
+ {{ assigneeCounterLabel }}
+ </span>
+ </div>
</div>
- <div class="card-footer" v-if="showLabelFooter">
+ <div
+ class="card-footer"
+ v-if="showLabelFooter"
+ >
<button
- class="label color-label has-tooltip js-no-trigger"
+ class="label color-label has-tooltip"
v-for="label in issue.labels"
type="button"
v-if="showLabel(label)"
diff --git a/app/assets/javascripts/boards/components/modal/filters.js b/app/assets/javascripts/boards/components/modal/filters.js
index b214b5a7199..56a0fde5a91 100644
--- a/app/assets/javascripts/boards/components/modal/filters.js
+++ b/app/assets/javascripts/boards/components/modal/filters.js
@@ -13,6 +13,7 @@ export default {
FilteredSearchContainer.container = this.$el;
this.filteredSearch = new FilteredSearchBoards(this.store);
+ this.filteredSearch.setup();
this.filteredSearch.removeTokens();
this.filteredSearch.handleInputPlaceholder();
this.filteredSearch.toggleClearSearchButton();
diff --git a/app/assets/javascripts/boards/components/modal/footer.js b/app/assets/javascripts/boards/components/modal/footer.js
index ccd270b27da..fe7ab2db85d 100644
--- a/app/assets/javascripts/boards/components/modal/footer.js
+++ b/app/assets/javascripts/boards/components/modal/footer.js
@@ -2,8 +2,7 @@
/* global Flash */
import Vue from 'vue';
-
-require('./lists_dropdown');
+import './lists_dropdown';
const ModalStore = gl.issueBoards.ModalStore;
diff --git a/app/assets/javascripts/boards/components/modal/header.js b/app/assets/javascripts/boards/components/modal/header.js
index e2b3f9ae7e2..31f59d295bf 100644
--- a/app/assets/javascripts/boards/components/modal/header.js
+++ b/app/assets/javascripts/boards/components/modal/header.js
@@ -1,7 +1,6 @@
import Vue from 'vue';
import modalFilters from './filters';
-
-require('./tabs');
+import './tabs';
const ModalStore = gl.issueBoards.ModalStore;
diff --git a/app/assets/javascripts/boards/components/modal/index.js b/app/assets/javascripts/boards/components/modal/index.js
index fdab317dc23..6356c266ee2 100644
--- a/app/assets/javascripts/boards/components/modal/index.js
+++ b/app/assets/javascripts/boards/components/modal/index.js
@@ -2,11 +2,11 @@
import Vue from 'vue';
import queryData from '../../utils/query_data';
-
-require('./header');
-require('./list');
-require('./footer');
-require('./empty_state');
+import loadingIcon from '../../../vue_shared/components/loading_icon.vue';
+import './header';
+import './list';
+import './footer';
+import './empty_state';
const ModalStore = gl.issueBoards.ModalStore;
@@ -108,6 +108,8 @@ gl.issueBoards.IssuesModal = Vue.extend({
if (!this.issuesCount) {
this.issuesCount = data.size;
}
+ }).catch(() => {
+ // TODO: handle request error
});
},
},
@@ -135,6 +137,7 @@ gl.issueBoards.IssuesModal = Vue.extend({
'modal-list': gl.issueBoards.ModalList,
'modal-footer': gl.issueBoards.ModalFooter,
'empty-state': gl.issueBoards.ModalEmptyState,
+ loadingIcon,
},
template: `
<div
@@ -159,7 +162,7 @@ gl.issueBoards.IssuesModal = Vue.extend({
class="add-issues-list text-center"
v-if="loading || filterLoading">
<div class="add-issues-list-loading">
- <i class="fa fa-spinner fa-spin"></i>
+ <loading-icon />
</div>
</section>
<modal-footer></modal-footer>
diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js
index 7e3bb79af1d..f29b6caa1ac 100644
--- a/app/assets/javascripts/boards/components/new_list_dropdown.js
+++ b/app/assets/javascripts/boards/components/new_list_dropdown.js
@@ -52,7 +52,9 @@ gl.issueBoards.newListDropdownInit = () => {
filterable: true,
selectable: true,
multiSelect: true,
- clicked (label, $el, e) {
+ clicked (options) {
+ const { e } = options;
+ const label = options.selectedObj;
e.preventDefault();
if (!Store.findList('title', label.title)) {
diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js
index 1264280284c..b37698fe9ca 100644
--- a/app/assets/javascripts/boards/filtered_search_boards.js
+++ b/app/assets/javascripts/boards/filtered_search_boards.js
@@ -2,7 +2,7 @@
import FilteredSearchContainer from '../filtered_search/container';
export default class FilteredSearchBoards extends gl.FilteredSearchManager {
- constructor(store, updateUrl = false) {
+ constructor(store, updateUrl = false, cantEdit = []) {
super('boards');
this.store = store;
@@ -11,6 +11,8 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager {
// Issue boards is slightly different, we handle all the requests async
// instead or reloading the page, we just re-fire the list ajax requests
this.isHandledAsync = true;
+
+ this.cantEdit = cantEdit;
}
updateObject(path) {
@@ -40,4 +42,8 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager {
// Get the placeholder back if search is empty
this.filteredSearchInput.dispatchEvent(new Event('input'));
}
+
+ canEdit(tokenName) {
+ return this.cantEdit.indexOf(tokenName) === -1;
+ }
}
diff --git a/app/assets/javascripts/boards/models/assignee.js b/app/assets/javascripts/boards/models/assignee.js
new file mode 100644
index 00000000000..05dd449e4fd
--- /dev/null
+++ b/app/assets/javascripts/boards/models/assignee.js
@@ -0,0 +1,12 @@
+/* eslint-disable no-unused-vars */
+
+class ListAssignee {
+ constructor(user, defaultAvatar) {
+ this.id = user.id;
+ this.name = user.name;
+ this.username = user.username;
+ this.avatar = user.avatar_url || defaultAvatar;
+ }
+}
+
+window.ListAssignee = ListAssignee;
diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js
index d6175069e37..6c2d8a3781b 100644
--- a/app/assets/javascripts/boards/models/issue.js
+++ b/app/assets/javascripts/boards/models/issue.js
@@ -1,12 +1,12 @@
/* eslint-disable no-unused-vars, space-before-function-paren, arrow-body-style, arrow-parens, comma-dangle, max-len */
/* global ListLabel */
/* global ListMilestone */
-/* global ListUser */
+/* global ListAssignee */
import Vue from 'vue';
class ListIssue {
- constructor (obj) {
+ constructor (obj, defaultAvatar) {
this.globalId = obj.id;
this.id = obj.iid;
this.title = obj.title;
@@ -14,14 +14,10 @@ class ListIssue {
this.dueDate = obj.due_date;
this.subscribed = obj.subscribed;
this.labels = [];
+ this.assignees = [];
this.selected = false;
- this.assignee = false;
this.position = obj.relative_position || Infinity;
- if (obj.assignee) {
- this.assignee = new ListUser(obj.assignee);
- }
-
if (obj.milestone) {
this.milestone = new ListMilestone(obj.milestone);
}
@@ -29,6 +25,8 @@ class ListIssue {
obj.labels.forEach((label) => {
this.labels.push(new ListLabel(label));
});
+
+ this.assignees = obj.assignees.map(a => new ListAssignee(a, defaultAvatar));
}
addLabel (label) {
@@ -51,6 +49,26 @@ class ListIssue {
labels.forEach(this.removeLabel.bind(this));
}
+ addAssignee (assignee) {
+ if (!this.findAssignee(assignee)) {
+ this.assignees.push(new ListAssignee(assignee));
+ }
+ }
+
+ findAssignee (findAssignee) {
+ return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0];
+ }
+
+ removeAssignee (removeAssignee) {
+ if (removeAssignee) {
+ this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id);
+ }
+ }
+
+ removeAllAssignees () {
+ this.assignees = [];
+ }
+
getLists () {
return gl.issueBoards.BoardsStore.state.lists.filter(list => list.findIssue(this.id));
}
@@ -60,7 +78,7 @@ class ListIssue {
issue: {
milestone_id: this.milestone ? this.milestone.id : null,
due_date: this.dueDate,
- assignee_id: this.assignee ? this.assignee.id : null,
+ assignee_ids: this.assignees.length > 0 ? this.assignees.map((u) => u.id) : [0],
label_ids: this.labels.map((label) => label.id)
}
};
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index f2b79a88a4a..90561d0f7a8 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -6,7 +6,7 @@ import queryData from '../utils/query_data';
const PER_PAGE = 20;
class List {
- constructor (obj) {
+ constructor (obj, defaultAvatar) {
this.id = obj.id;
this._uid = this.guid();
this.position = obj.position;
@@ -18,13 +18,16 @@ class List {
this.loadingMore = false;
this.issues = [];
this.issuesSize = 0;
+ this.defaultAvatar = defaultAvatar;
if (obj.label) {
this.label = new ListLabel(obj.label);
}
if (this.type !== 'blank' && this.id) {
- this.getIssues();
+ this.getIssues().catch(() => {
+ // TODO: handle request error
+ });
}
}
@@ -51,11 +54,17 @@ class List {
gl.issueBoards.BoardsStore.state.lists.splice(index, 1);
gl.issueBoards.BoardsStore.updateNewListDropdown(this.id);
- gl.boardService.destroyList(this.id);
+ gl.boardService.destroyList(this.id)
+ .catch(() => {
+ // TODO: handle request error
+ });
}
update () {
- gl.boardService.updateList(this.id, this.position);
+ gl.boardService.updateList(this.id, this.position)
+ .catch(() => {
+ // TODO: handle request error
+ });
}
nextPage () {
@@ -106,7 +115,7 @@ class List {
createIssues (data) {
data.forEach((issueObj) => {
- this.addIssue(new ListIssue(issueObj));
+ this.addIssue(new ListIssue(issueObj, this.defaultAvatar));
});
}
@@ -145,11 +154,17 @@ class List {
this.issues.splice(oldIndex, 1);
this.issues.splice(newIndex, 0, issue);
- gl.boardService.moveIssue(issue.id, null, null, moveBeforeIid, moveAfterIid);
+ gl.boardService.moveIssue(issue.id, null, null, moveBeforeIid, moveAfterIid)
+ .catch(() => {
+ // TODO: handle request error
+ });
}
updateIssueLabel(issue, listFrom, moveBeforeIid, moveAfterIid) {
- gl.boardService.moveIssue(issue.id, listFrom.id, this.id, moveBeforeIid, moveAfterIid);
+ gl.boardService.moveIssue(issue.id, listFrom.id, this.id, moveBeforeIid, moveAfterIid)
+ .catch(() => {
+ // TODO: handle request error
+ });
}
findIssue (id) {
diff --git a/app/assets/javascripts/boards/models/user.js b/app/assets/javascripts/boards/models/user.js
deleted file mode 100644
index 8e9de4d4cbb..00000000000
--- a/app/assets/javascripts/boards/models/user.js
+++ /dev/null
@@ -1,12 +0,0 @@
-/* eslint-disable no-unused-vars */
-
-class ListUser {
- constructor(user) {
- this.id = user.id;
- this.name = user.name;
- this.username = user.username;
- this.avatar = user.avatar_url;
- }
-}
-
-window.ListUser = ListUser;
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index ccb00099215..ad9997ac334 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -23,8 +23,8 @@ gl.issueBoards.BoardsStore = {
this.state.lists = [];
this.filter.path = gl.utils.getUrlParamsArray().join('&');
},
- addList (listObj) {
- const list = new List(listObj);
+ addList (listObj, defaultAvatar) {
+ const list = new List(listObj, defaultAvatar);
this.state.lists.push(list);
return list;
diff --git a/app/assets/javascripts/branches/branches_delete_modal.js b/app/assets/javascripts/branches/branches_delete_modal.js
new file mode 100644
index 00000000000..af8bcdc1794
--- /dev/null
+++ b/app/assets/javascripts/branches/branches_delete_modal.js
@@ -0,0 +1,36 @@
+const MODAL_SELECTOR = '#modal-delete-branch';
+
+class DeleteModal {
+ constructor() {
+ this.$modal = $(MODAL_SELECTOR);
+ this.$toggleBtns = $(`[data-target="${MODAL_SELECTOR}"]`);
+ this.$branchName = $('.js-branch-name', this.$modal);
+ this.$confirmInput = $('.js-delete-branch-input', this.$modal);
+ this.$deleteBtn = $('.js-delete-branch', this.$modal);
+ this.bindEvents();
+ }
+
+ bindEvents() {
+ this.$toggleBtns.on('click', this.setModalData.bind(this));
+ this.$confirmInput.on('input', this.setDeleteDisabled.bind(this));
+ }
+
+ setModalData(e) {
+ this.branchName = e.currentTarget.dataset.branchName || '';
+ this.deletePath = e.currentTarget.dataset.deletePath || '';
+ this.updateModal();
+ }
+
+ setDeleteDisabled(e) {
+ this.$deleteBtn.attr('disabled', e.currentTarget.value !== this.branchName);
+ }
+
+ updateModal() {
+ this.$branchName.text(this.branchName);
+ this.$confirmInput.val('');
+ this.$deleteBtn.attr('href', this.deletePath);
+ this.$deleteBtn.attr('disabled', true);
+ }
+}
+
+export default DeleteModal;
diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js
index 97f279e4be4..1a602cbd8a7 100644
--- a/app/assets/javascripts/build.js
+++ b/app/assets/javascripts/build.js
@@ -2,15 +2,11 @@
consistent-return, prefer-rest-params */
/* global Breakpoints */
+import _ from 'underscore';
import { bytesToKiB } from './lib/utils/number_utils';
-const bind = function (fn, me) { return function () { return fn.apply(me, arguments); }; };
-const AUTO_SCROLL_OFFSET = 75;
-const DOWN_BUILD_TRACE = '#down-build-trace';
-
window.Build = (function () {
Build.timeout = null;
-
Build.state = null;
function Build(options) {
@@ -23,21 +19,22 @@ window.Build = (function () {
this.buildStage = this.options.buildStage;
this.$document = $(document);
this.logBytes = 0;
+ this.scrollOffsetPadding = 30;
- this.updateDropdown = bind(this.updateDropdown, this);
+ this.updateDropdown = this.updateDropdown.bind(this);
+ this.getBuildTrace = this.getBuildTrace.bind(this);
+ this.scrollToBottom = this.scrollToBottom.bind(this);
this.$body = $('body');
this.$buildTrace = $('#build-trace');
- this.$autoScrollContainer = $('.autoscroll-container');
- this.$autoScrollStatus = $('#autoscroll-status');
- this.$autoScrollStatusText = this.$autoScrollStatus.find('.status-text');
- this.$upBuildTrace = $('#up-build-trace');
- this.$downBuildTrace = $(DOWN_BUILD_TRACE);
- this.$scrollTopBtn = $('#scroll-top');
- this.$scrollBottomBtn = $('#scroll-bottom');
this.$buildRefreshAnimation = $('.js-build-refresh');
- this.$buildScroll = $('#js-build-scroll');
this.$truncatedInfo = $('.js-truncated-info');
+ this.$buildTraceOutput = $('.js-build-output');
+ this.$scrollContainer = $('.js-scroll-container');
+
+ // Scroll controllers
+ this.$scrollTopBtn = $('.js-scroll-up');
+ this.$scrollBottomBtn = $('.js-scroll-down');
clearTimeout(Build.timeout);
// Init breakpoint checker
@@ -56,54 +53,149 @@ window.Build = (function () {
.off('click', '.stage-item')
.on('click', '.stage-item', this.updateDropdown);
- this.$document.on('scroll', this.initScrollMonitor.bind(this));
+ // add event listeners to the scroll buttons
+ this.$scrollTopBtn
+ .off('click')
+ .on('click', this.scrollToTop.bind(this));
+
+ this.$scrollBottomBtn
+ .off('click')
+ .on('click', this.scrollToBottom.bind(this));
$(window)
.off('resize.build')
.on('resize.build', this.sidebarOnResize.bind(this));
- $('a', this.$buildScroll)
- .off('click.stepTrace')
- .on('click.stepTrace', this.stepTrace);
-
this.updateArtifactRemoveDate();
- this.initScrollButtonAffix();
- this.invokeBuildTrace();
+
+ // eslint-disable-next-line
+ this.getBuildTrace()
+ .then(() => this.makeTraceScrollable())
+ .then(() => this.scrollToBottom());
+
+ this.verifyTopPosition();
}
+ Build.prototype.makeTraceScrollable = function () {
+ this.$scrollContainer.niceScroll({
+ cursorcolor: '#fff',
+ cursoropacitymin: 1,
+ cursorwidth: '3px',
+ railpadding: { top: 5, bottom: 5, right: 5 },
+ });
+
+ this.$scrollContainer.on('scroll', _.throttle(this.toggleScroll.bind(this), 100));
+
+ this.toggleScroll();
+ };
+
+ Build.prototype.canScroll = function () {
+ return (this.$scrollContainer.prop('scrollHeight') - this.scrollOffsetPadding) > this.$scrollContainer.height();
+ };
+
+ /**
+ * | | Up | Down |
+ * |--------------------------|----------|----------|
+ * | on scroll bottom | active | disabled |
+ * | on scroll top | disabled | active |
+ * | no scroll | disabled | disabled |
+ * | on.('scroll') is on top | disabled | active |
+ * | on('scroll) is on bottom | active | disabled |
+ *
+ */
+ Build.prototype.toggleScroll = function () {
+ const bottomScroll = this.$scrollContainer.scrollTop() +
+ this.scrollOffsetPadding +
+ this.$scrollContainer.height();
+
+ if (this.canScroll()) {
+ if (this.$scrollContainer.scrollTop() === 0) {
+ this.toggleDisableButton(this.$scrollTopBtn, true);
+ this.toggleDisableButton(this.$scrollBottomBtn, false);
+ } else if (bottomScroll === this.$scrollContainer.prop('scrollHeight')) {
+ this.toggleDisableButton(this.$scrollTopBtn, false);
+ this.toggleDisableButton(this.$scrollBottomBtn, true);
+ } else {
+ this.toggleDisableButton(this.$scrollTopBtn, false);
+ this.toggleDisableButton(this.$scrollBottomBtn, false);
+ }
+ }
+ };
+
+ Build.prototype.scrollToTop = function () {
+ this.$scrollContainer.getNiceScroll(0).doScrollTop(0);
+ this.toggleScroll();
+ };
+
+ Build.prototype.scrollToBottom = function () {
+ this.$scrollContainer.getNiceScroll(0).doScrollTo(this.$scrollContainer.prop('scrollHeight'));
+ this.toggleScroll();
+ };
+
+ Build.prototype.toggleDisableButton = function ($button, disable) {
+ if (disable && $button.prop('disabled')) return;
+ $button.prop('disabled', disable);
+ };
+
+ Build.prototype.toggleScrollAnimation = function (toggle) {
+ this.$scrollBottomBtn.toggleClass('animate', toggle);
+ };
+
+ /**
+ * Build trace top position depends on the space ocupied by the elments rendered before
+ */
+ Build.prototype.verifyTopPosition = function () {
+ const $buildPage = $('.build-page');
+
+ const $header = $('.build-header', $buildPage);
+ const $runnersStuck = $('.js-build-stuck', $buildPage);
+ const $startsEnvironment = $('.js-environment-container', $buildPage);
+ const $erased = $('.js-build-erased', $buildPage);
+
+ let topPostion = 168;
+
+ if ($header) {
+ topPostion += $header.outerHeight();
+ }
+
+ if ($runnersStuck) {
+ topPostion += $runnersStuck.outerHeight();
+ }
+
+ if ($startsEnvironment) {
+ topPostion += $startsEnvironment.outerHeight();
+ }
+
+ if ($erased) {
+ topPostion += $erased.outerHeight() + 10;
+ }
+
+ this.$buildTrace.css({
+ top: topPostion,
+ });
+ };
+
Build.prototype.initSidebar = function () {
this.$sidebar = $('.js-build-sidebar');
this.$sidebar.niceScroll();
- this.$document
- .off('click', '.js-sidebar-build-toggle')
- .on('click', '.js-sidebar-build-toggle', this.toggleSidebar);
- };
-
- Build.prototype.invokeBuildTrace = function () {
- return this.getBuildTrace();
};
Build.prototype.getBuildTrace = function () {
return $.ajax({
url: `${this.pageUrl}/trace.json`,
- dataType: 'json',
- data: {
- state: this.state,
- },
- success: ((log) => {
- const $buildContainer = $('.js-build-output');
-
+ data: this.state,
+ })
+ .done((log) => {
gl.utils.setCiStatusFavicon(`${this.pageUrl}/status.json`);
-
if (log.state) {
this.state = log.state;
}
if (log.append) {
- $buildContainer.append(log.html);
+ this.$buildTraceOutput.append(log.html);
this.logBytes += log.size;
} else {
- $buildContainer.html(log.html);
+ this.$buildTraceOutput.html(log.html);
this.logBytes = log.size;
}
@@ -114,141 +206,30 @@ window.Build = (function () {
const size = bytesToKiB(this.logBytes);
$('.js-truncated-info-size').html(`${size}`);
this.$truncatedInfo.removeClass('hidden');
- this.initAffixTruncatedInfo();
} else {
this.$truncatedInfo.addClass('hidden');
}
- this.checkAutoscroll();
-
if (!log.complete) {
+ this.toggleScrollAnimation(true);
+
Build.timeout = setTimeout(() => {
- this.invokeBuildTrace();
+ //eslint-disable-next-line
+ this.getBuildTrace()
+ .then(() => this.scrollToBottom());
}, 4000);
} else {
this.$buildRefreshAnimation.remove();
+ this.toggleScrollAnimation(false);
}
if (log.status !== this.buildStatus) {
- let pageUrl = this.pageUrl;
-
- if (this.$autoScrollStatus.data('state') === 'enabled') {
- pageUrl += DOWN_BUILD_TRACE;
- }
-
- gl.utils.visitUrl(pageUrl);
+ gl.utils.visitUrl(this.pageUrl);
}
- }),
- error: () => {
+ })
+ .fail(() => {
this.$buildRefreshAnimation.remove();
- return this.initScrollMonitor();
- },
- });
- };
-
- Build.prototype.checkAutoscroll = function () {
- if (this.$autoScrollStatus.data('state') === 'enabled') {
- return $('html,body').scrollTop(this.$buildTrace.height());
- }
-
- // Handle a situation where user started new build
- // but never scrolled a page
- if (!this.$scrollTopBtn.is(':visible') &&
- !this.$scrollBottomBtn.is(':visible') &&
- !gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
- this.$scrollBottomBtn.show();
- }
- };
-
- Build.prototype.initScrollButtonAffix = function () {
- // Hide everything initially
- this.$scrollTopBtn.hide();
- this.$scrollBottomBtn.hide();
- this.$autoScrollContainer.hide();
- };
-
- // Page scroll listener to detect if user has scrolling page
- // and handle following cases
- // 1) User is at Top of Build Log;
- // - Hide Top Arrow button
- // - Show Bottom Arrow button
- // - Disable Autoscroll and hide indicator (when build is running)
- // 2) User is at Bottom of Build Log;
- // - Show Top Arrow button
- // - Hide Bottom Arrow button
- // - Enable Autoscroll and show indicator (when build is running)
- // 3) User is somewhere in middle of Build Log;
- // - Show Top Arrow button
- // - Show Bottom Arrow button
- // - Disable Autoscroll and hide indicator (when build is running)
- Build.prototype.initScrollMonitor = function () {
- if (!gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
- !gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
- // User is somewhere in middle of Build Log
-
- this.$scrollTopBtn.show();
-
- if (this.buildStatus === 'success' || this.buildStatus === 'failed') { // Check if Build is completed
- this.$scrollBottomBtn.show();
- } else if (this.$buildRefreshAnimation.is(':visible') &&
- !gl.utils.isInViewport(this.$buildRefreshAnimation.get(0))) {
- this.$scrollBottomBtn.show();
- } else {
- this.$scrollBottomBtn.hide();
- }
-
- // Hide Autoscroll Status Indicator
- if (this.$scrollBottomBtn.is(':visible')) {
- this.$autoScrollContainer.hide();
- this.$autoScrollStatusText.removeClass('animate');
- } else {
- this.$autoScrollContainer.css({
- top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET,
- }).show();
- this.$autoScrollStatusText.addClass('animate');
- }
- } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
- !gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
- // User is at Top of Build Log
-
- this.$scrollTopBtn.hide();
- this.$scrollBottomBtn.show();
-
- this.$autoScrollContainer.hide();
- this.$autoScrollStatusText.removeClass('animate');
- } else if ((!gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
- gl.utils.isInViewport(this.$downBuildTrace.get(0))) ||
- (this.$buildRefreshAnimation.is(':visible') &&
- gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)))) {
- // User is at Bottom of Build Log
-
- this.$scrollTopBtn.show();
- this.$scrollBottomBtn.hide();
-
- // Show and Reposition Autoscroll Status Indicator
- this.$autoScrollContainer.css({
- top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET,
- }).show();
- this.$autoScrollStatusText.addClass('animate');
- } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
- gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
- // Build Log height is small
-
- this.$scrollTopBtn.hide();
- this.$scrollBottomBtn.hide();
-
- // Hide Autoscroll Status Indicator
- this.$autoScrollContainer.hide();
- this.$autoScrollStatusText.removeClass('animate');
- }
-
- if (this.buildStatus === 'running' || this.buildStatus === 'pending') {
- // Check if Refresh Animation is in Viewport and enable Autoscroll, disable otherwise.
- this.$autoScrollStatus.data(
- 'state',
- gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)) ? 'enabled' : 'disabled',
- );
- }
+ });
};
Build.prototype.shouldHideSidebarForViewport = function () {
@@ -257,18 +238,23 @@ window.Build = (function () {
};
Build.prototype.toggleSidebar = function (shouldHide) {
- const shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined;
+ const shouldShow = !shouldHide;
- this.$buildScroll.toggleClass('sidebar-expanded', shouldShow)
+ this.$buildTrace
+ .toggleClass('sidebar-expanded', shouldShow)
.toggleClass('sidebar-collapsed', shouldHide);
- this.$truncatedInfo.toggleClass('sidebar-expanded', shouldShow)
- .toggleClass('sidebar-collapsed', shouldHide);
- this.$sidebar.toggleClass('right-sidebar-expanded', shouldShow)
+ this.$sidebar
+ .toggleClass('right-sidebar-expanded', shouldShow)
.toggleClass('right-sidebar-collapsed', shouldHide);
};
Build.prototype.sidebarOnResize = function () {
this.toggleSidebar(this.shouldHideSidebarForViewport());
+ this.verifyTopPosition();
+
+ if (this.$scrollContainer.getNiceScroll(0)) {
+ this.toggleScroll();
+ }
};
Build.prototype.sidebarOnClick = function () {
@@ -301,24 +287,5 @@ window.Build = (function () {
this.populateJobs(stage);
};
- Build.prototype.stepTrace = function (e) {
- e.preventDefault();
-
- const $currentTarget = $(e.currentTarget);
- $.scrollTo($currentTarget.attr('href'), {
- offset: 0,
- });
- };
-
- Build.prototype.initAffixTruncatedInfo = function () {
- const offsetTop = this.$buildTrace.offset().top;
-
- this.$truncatedInfo.affix({
- offset: {
- top: offsetTop,
- },
- });
- };
-
return Build;
})();
diff --git a/app/assets/javascripts/ci_status_icons.js b/app/assets/javascripts/ci_status_icons.js
deleted file mode 100644
index f16616873b2..00000000000
--- a/app/assets/javascripts/ci_status_icons.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import CANCELED_SVG from 'icons/_icon_status_canceled_borderless.svg';
-import CREATED_SVG from 'icons/_icon_status_created_borderless.svg';
-import FAILED_SVG from 'icons/_icon_status_failed_borderless.svg';
-import MANUAL_SVG from 'icons/_icon_status_manual_borderless.svg';
-import PENDING_SVG from 'icons/_icon_status_pending_borderless.svg';
-import RUNNING_SVG from 'icons/_icon_status_running_borderless.svg';
-import SKIPPED_SVG from 'icons/_icon_status_skipped_borderless.svg';
-import SUCCESS_SVG from 'icons/_icon_status_success_borderless.svg';
-import WARNING_SVG from 'icons/_icon_status_warning_borderless.svg';
-
-const StatusIconEntityMap = {
- icon_status_canceled: CANCELED_SVG,
- icon_status_created: CREATED_SVG,
- icon_status_failed: FAILED_SVG,
- icon_status_manual: MANUAL_SVG,
- icon_status_pending: PENDING_SVG,
- icon_status_running: RUNNING_SVG,
- icon_status_skipped: SKIPPED_SVG,
- icon_status_success: SUCCESS_SVG,
- icon_status_warning: WARNING_SVG,
-};
-
-export {
- CANCELED_SVG,
- CREATED_SVG,
- FAILED_SVG,
- MANUAL_SVG,
- PENDING_SVG,
- RUNNING_SVG,
- SKIPPED_SVG,
- SUCCESS_SVG,
- WARNING_SVG,
- StatusIconEntityMap as default,
-};
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.js b/app/assets/javascripts/commit/pipelines/pipelines_table.js
index ad9c600b499..98698143d22 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.js
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.js
@@ -1,11 +1,12 @@
import Vue from 'vue';
import Visibility from 'visibilityjs';
-import PipelinesTableComponent from '../../vue_shared/components/pipelines_table';
+import pipelinesTableComponent from '../../vue_shared/components/pipelines_table';
import PipelinesService from '../../pipelines/services/pipelines_service';
import PipelineStore from '../../pipelines/stores/pipelines_store';
import eventHub from '../../pipelines/event_hub';
-import EmptyState from '../../pipelines/components/empty_state.vue';
-import ErrorState from '../../pipelines/components/error_state.vue';
+import emptyState from '../../pipelines/components/empty_state.vue';
+import errorState from '../../pipelines/components/error_state.vue';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import '../../lib/utils/common_utils';
import '../../vue_shared/vue_resource_interceptor';
import Poll from '../../lib/utils/poll';
@@ -17,16 +18,15 @@ import Poll from '../../lib/utils/poll';
* We need a store to store the received environemnts.
* We need a service to communicate with the server.
*
- * Necessary SVG in the table are provided as props. This should be refactored
- * as soon as we have Webpack and can load them directly into JS files.
*/
export default Vue.component('pipelines-table', {
components: {
- 'pipelines-table-component': PipelinesTableComponent,
- 'error-state': ErrorState,
- 'empty-state': EmptyState,
+ pipelinesTableComponent,
+ errorState,
+ emptyState,
+ loadingIcon,
},
/**
@@ -47,6 +47,7 @@ export default Vue.component('pipelines-table', {
hasError: false,
isMakingRequest: false,
updateGraphDropdown: false,
+ hasMadeRequest: false,
};
},
@@ -55,9 +56,15 @@ export default Vue.component('pipelines-table', {
return this.hasError && !this.isLoading;
},
+ /**
+ * Empty state is only rendered if after the first request we receive no pipelines.
+ *
+ * @return {Boolean}
+ */
shouldRenderEmptyState() {
return !this.state.pipelines.length &&
!this.isLoading &&
+ this.hasMadeRequest &&
!this.hasError;
},
@@ -94,6 +101,10 @@ export default Vue.component('pipelines-table', {
if (!Visibility.hidden()) {
this.isLoading = true;
this.poll.makeRequest();
+ } else {
+ // If tab is not visible we need to make the first request so we don't show the empty
+ // state without knowing if there are any pipelines
+ this.fetchPipelines();
}
Visibility.change(() => {
@@ -127,6 +138,8 @@ export default Vue.component('pipelines-table', {
successCallback(resp) {
const response = resp.json();
+ this.hasMadeRequest = true;
+
// depending of the endpoint the response can either bring a `pipelines` key or not.
const pipelines = response.pipelines || response;
this.store.storePipelines(pipelines);
@@ -151,13 +164,12 @@ export default Vue.component('pipelines-table', {
template: `
<div class="content-list pipelines">
- <div
- class="realtime-loading"
- v-if="isLoading">
- <i
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
- </div>
+
+ <loading-icon
+ label="Loading pipelines"
+ size="3"
+ v-if="isLoading"
+ />
<empty-state
v-if="shouldRenderEmptyState"
diff --git a/app/assets/javascripts/copy_as_gfm.js b/app/assets/javascripts/copy_as_gfm.js
index 570799c030e..ba9d9a3e1f7 100644
--- a/app/assets/javascripts/copy_as_gfm.js
+++ b/app/assets/javascripts/copy_as_gfm.js
@@ -1,6 +1,6 @@
/* eslint-disable class-methods-use-this, object-shorthand, no-unused-vars, no-use-before-define, no-new, max-len, no-restricted-syntax, guard-for-in, no-continue */
-require('./lib/utils/common_utils');
+import './lib/utils/common_utils';
const gfmRules = {
// The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert
@@ -18,12 +18,12 @@ const gfmRules = {
},
},
TaskListFilter: {
- 'input[type=checkbox].task-list-item-checkbox'(el, text) {
+ 'input[type=checkbox].task-list-item-checkbox'(el) {
return `[${el.checked ? 'x' : ' '}]`;
},
},
ReferenceFilter: {
- '.tooltip'(el, text) {
+ '.tooltip'(el) {
return '';
},
'a.gfm:not([data-link=true])'(el, text) {
@@ -39,15 +39,15 @@ const gfmRules = {
},
},
TableOfContentsFilter: {
- 'ul.section-nav'(el, text) {
+ 'ul.section-nav'(el) {
return '[[_TOC_]]';
},
},
EmojiFilter: {
- 'img.emoji'(el, text) {
+ 'img.emoji'(el) {
return el.getAttribute('alt');
},
- 'gl-emoji'(el, text) {
+ 'gl-emoji'(el) {
return `:${el.getAttribute('data-name')}:`;
},
},
@@ -57,13 +57,13 @@ const gfmRules = {
},
},
VideoLinkFilter: {
- '.video-container'(el, text) {
+ '.video-container'(el) {
const videoEl = el.querySelector('video');
if (!videoEl) return false;
return CopyAsGFM.nodeToGFM(videoEl);
},
- 'video'(el, text) {
+ 'video'(el) {
return `![${el.dataset.title}](${el.getAttribute('src')})`;
},
},
@@ -74,19 +74,19 @@ const gfmRules = {
'code.code.math[data-math-style=inline]'(el, text) {
return `$\`${text}\`$`;
},
- 'span.katex-display span.katex-mathml'(el, text) {
+ 'span.katex-display span.katex-mathml'(el) {
const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
if (!mathAnnotation) return false;
return `\`\`\`math\n${CopyAsGFM.nodeToGFM(mathAnnotation)}\n\`\`\``;
},
- 'span.katex-mathml'(el, text) {
+ 'span.katex-mathml'(el) {
const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
if (!mathAnnotation) return false;
return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`;
},
- 'span.katex-html'(el, text) {
+ 'span.katex-html'(el) {
// We don't want to include the content of this element in the copied text.
return '';
},
@@ -95,7 +95,7 @@ const gfmRules = {
},
},
SanitizationFilter: {
- 'a[name]:not([href]):empty'(el, text) {
+ 'a[name]:not([href]):empty'(el) {
return el.outerHTML;
},
'dl'(el, text) {
@@ -143,7 +143,7 @@ const gfmRules = {
},
},
MarkdownFilter: {
- 'br'(el, text) {
+ 'br'(el) {
// Two spaces at the end of a line are turned into a BR
return ' ';
},
@@ -162,7 +162,7 @@ const gfmRules = {
'blockquote'(el, text) {
return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n');
},
- 'img'(el, text) {
+ 'img'(el) {
return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`;
},
'a.anchor'(el, text) {
@@ -222,10 +222,10 @@ const gfmRules = {
'sup'(el, text) {
return `^${text}`;
},
- 'hr'(el, text) {
+ 'hr'(el) {
return '-----';
},
- 'table'(el, text) {
+ 'table'(el) {
const theadEl = el.querySelector('thead');
const tbodyEl = el.querySelector('tbody');
if (!theadEl || !tbodyEl) return false;
@@ -233,11 +233,11 @@ const gfmRules = {
const theadText = CopyAsGFM.nodeToGFM(theadEl);
const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl);
- return theadText + tbodyText;
+ return [theadText, tbodyText].join('\n');
},
'thead'(el, text) {
const cells = _.map(el.querySelectorAll('th'), (cell) => {
- let chars = CopyAsGFM.nodeToGFM(cell).trim().length + 2;
+ let chars = CopyAsGFM.nodeToGFM(cell).length + 2;
let before = '';
let after = '';
@@ -262,10 +262,15 @@ const gfmRules = {
return before + middle + after;
});
- return `${text}|${cells.join('|')}|`;
+ const separatorRow = `|${cells.join('|')}|`;
+
+ return [text, separatorRow].join('\n');
},
- 'tr'(el, text) {
- const cells = _.map(el.querySelectorAll('td, th'), cell => CopyAsGFM.nodeToGFM(cell).trim());
+ 'tr'(el) {
+ const cellEls = el.querySelectorAll('td, th');
+ if (cellEls.length === 0) return false;
+
+ const cells = _.map(cellEls, cell => CopyAsGFM.nodeToGFM(cell));
return `| ${cells.join(' | ')} |`;
},
},
@@ -273,12 +278,12 @@ const gfmRules = {
class CopyAsGFM {
constructor() {
- $(document).on('copy', '.md, .wiki', (e) => { this.copyAsGFM(e, CopyAsGFM.transformGFMSelection); });
- $(document).on('copy', 'pre.code.highlight, .diff-content .line_content', (e) => { this.copyAsGFM(e, CopyAsGFM.transformCodeSelection); });
- $(document).on('paste', '.js-gfm-input', this.pasteGFM.bind(this));
+ $(document).on('copy', '.md, .wiki', (e) => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformGFMSelection); });
+ $(document).on('copy', 'pre.code.highlight, .diff-content .line_content', (e) => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformCodeSelection); });
+ $(document).on('paste', '.js-gfm-input', CopyAsGFM.pasteGFM);
}
- copyAsGFM(e, transformer) {
+ static copyAsGFM(e, transformer) {
const clipboardData = e.originalEvent.clipboardData;
if (!clipboardData) return;
@@ -292,26 +297,59 @@ class CopyAsGFM {
e.stopPropagation();
clipboardData.setData('text/plain', el.textContent);
- clipboardData.setData('text/x-gfm', CopyAsGFM.nodeToGFM(el));
+ clipboardData.setData('text/x-gfm', this.nodeToGFM(el));
}
- pasteGFM(e) {
+ static pasteGFM(e) {
const clipboardData = e.originalEvent.clipboardData;
if (!clipboardData) return;
+ const text = clipboardData.getData('text/plain');
const gfm = clipboardData.getData('text/x-gfm');
if (!gfm) return;
e.preventDefault();
- window.gl.utils.insertText(e.target, gfm);
+ window.gl.utils.insertText(e.target, (textBefore, textAfter) => {
+ // If the text before the cursor contains an odd number of backticks,
+ // we are either inside an inline code span that starts with 1 backtick
+ // or a code block that starts with 3 backticks.
+ // This logic still holds when there are one or more _closed_ code spans
+ // or blocks that will have 2 or 6 backticks.
+ // This will break down when the actual code block contains an uneven
+ // number of backticks, but this is a rare edge case.
+ const backtickMatch = textBefore.match(/`/g);
+ const insideCodeBlock = backtickMatch && (backtickMatch.length % 2) === 1;
+
+ if (insideCodeBlock) {
+ return text;
+ }
+
+ return gfm;
+ });
}
static transformGFMSelection(documentFragment) {
- // If the documentFragment contains more than just Markdown, don't copy as GFM.
- if (documentFragment.querySelector('.md, .wiki')) return null;
+ const gfmEls = documentFragment.querySelectorAll('.md, .wiki');
+ switch (gfmEls.length) {
+ case 0: {
+ return documentFragment;
+ }
+ case 1: {
+ return gfmEls[0];
+ }
+ default: {
+ const allGfmEl = document.createElement('div');
+
+ for (let i = 0; i < gfmEls.length; i += 1) {
+ const lineEl = gfmEls[i];
+ allGfmEl.appendChild(lineEl);
+ allGfmEl.appendChild(document.createTextNode('\n\n'));
+ }
- return documentFragment;
+ return allGfmEl;
+ }
+ }
}
static transformCodeSelection(documentFragment) {
@@ -343,7 +381,7 @@ class CopyAsGFM {
return codeEl;
}
- static nodeToGFM(node) {
+ static nodeToGFM(node, respectWhitespaceParam = false) {
if (node.nodeType === Node.COMMENT_NODE) {
return '';
}
@@ -352,7 +390,9 @@ class CopyAsGFM {
return node.textContent;
}
- const text = this.innerGFM(node);
+ const respectWhitespace = respectWhitespaceParam || (node.nodeName === 'PRE' || node.nodeName === 'CODE');
+
+ const text = this.innerGFM(node, respectWhitespace);
if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
return text;
@@ -366,7 +406,17 @@ class CopyAsGFM {
if (!window.gl.utils.nodeMatchesSelector(node, selector)) continue;
- const result = func(node, text);
+ let result;
+ if (func.length === 2) {
+ // if `func` takes 2 arguments, it depends on text.
+ // if there is no text, we don't need to generate GFM for this node.
+ if (text.length === 0) continue;
+
+ result = func(node, text);
+ } else {
+ result = func(node);
+ }
+
if (result === false) continue;
return result;
@@ -376,7 +426,7 @@ class CopyAsGFM {
return text;
}
- static innerGFM(parentNode) {
+ static innerGFM(parentNode, respectWhitespace = false) {
const nodes = parentNode.childNodes;
const clonedParentNode = parentNode.cloneNode(true);
@@ -386,13 +436,19 @@ class CopyAsGFM {
const node = nodes[i];
const clonedNode = clonedNodes[i];
- const text = this.nodeToGFM(node);
+ const text = this.nodeToGFM(node, respectWhitespace);
// `clonedNode.replaceWith(text)` is not yet widely supported
clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode);
}
- return clonedParentNode.innerText || clonedParentNode.textContent;
+ let nodeText = clonedParentNode.innerText || clonedParentNode.textContent;
+
+ if (!respectWhitespace) {
+ nodeText = nodeText.trim();
+ }
+
+ return nodeText;
}
}
diff --git a/app/assets/javascripts/create_label.js b/app/assets/javascripts/create_label.js
index 121d64db789..907b468e576 100644
--- a/app/assets/javascripts/create_label.js
+++ b/app/assets/javascripts/create_label.js
@@ -1,5 +1,5 @@
/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, comma-dangle, prefer-template, quotes, no-param-reassign, wrap-iife, max-len */
-/* global Api */
+import Api from './api';
class CreateLabelDropdown {
constructor ($el, namespacePath, projectPath) {
diff --git a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js
index abe48572347..8d3d34f836f 100644
--- a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js
@@ -9,9 +9,9 @@ export default {
<span v-if="count === 50" class="events-info pull-right">
<i class="fa fa-warning has-tooltip"
aria-hidden="true"
- title="Limited to showing 50 events at most"
+ :title="n__('Limited to showing %d event at most', 'Limited to showing %d events at most', 50)"
data-placement="top"></i>
- Showing 50 events
+ {{ n__('Showing %d event', 'Showing %d events', 50) }}
</span>
`,
};
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js
index 80bd2df6f42..7c32a38fbe7 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js
@@ -1,6 +1,7 @@
/* eslint-disable no-param-reassign */
import Vue from 'vue';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {};
@@ -10,6 +11,9 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({
items: Array,
stage: Object,
},
+ components: {
+ userAvatarImage,
+ },
template: `
<div>
<div class="events-description">
@@ -19,7 +23,8 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({
<ul class="stage-event-list">
<li v-for="mergeRequest in items" class="stage-event-item">
<div class="item-details">
- <img class="avatar" :src="mergeRequest.author.avatarUrl">
+ <!-- FIXME: Pass an alt attribute here for accessibility -->
+ <user-avatar-image :img-src="mergeRequest.author.avatarUrl"/>
<h5 class="item-title merge-merquest-title">
<a :href="mergeRequest.url">
{{ mergeRequest.title }}
@@ -28,11 +33,11 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({
<a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a>
&middot;
<span>
- Opened
+ {{ s__('OpenedNDaysAgo|Opened') }}
<a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a>
</span>
<span>
- by
+ {{ s__('ByAuthor|by') }}
<a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a>
</span>
</div>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js
index 20a43798fbe..5f4a0ac8590 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js
@@ -1,6 +1,6 @@
/* eslint-disable no-param-reassign */
-
import Vue from 'vue';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {};
@@ -10,6 +10,9 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({
items: Array,
stage: Object,
},
+ components: {
+ userAvatarImage,
+ },
template: `
<div>
<div class="events-description">
@@ -19,7 +22,8 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({
<ul class="stage-event-list">
<li v-for="issue in items" class="stage-event-item">
<div class="item-details">
- <img class="avatar" :src="issue.author.avatarUrl">
+ <!-- FIXME: Pass an alt attribute here for accessibility -->
+ <user-avatar-image :img-src="issue.author.avatarUrl"/>
<h5 class="item-title issue-title">
<a class="issue-title" :href="issue.url">
{{ issue.title }}
@@ -28,11 +32,11 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({
<a :href="issue.url" class="issue-link">#{{ issue.iid }}</a>
&middot;
<span>
- Opened
+ {{ s__('OpenedNDaysAgo|Opened') }}
<a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a>
</span>
<span>
- by
+ {{ s__('ByAuthor|by') }}
<a :href="issue.author.webUrl" class="issue-author-link">
{{ issue.author.name }}
</a>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js
index f33cac3da82..11fee5410d9 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js
@@ -1,5 +1,6 @@
/* eslint-disable no-param-reassign */
import Vue from 'vue';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
import iconCommit from '../svg/icon_commit.svg';
const global = window.gl || (window.gl = {});
@@ -10,11 +11,12 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({
items: Array,
stage: Object,
},
-
+ components: {
+ userAvatarImage,
+ },
data() {
return { iconCommit };
},
-
template: `
<div>
<div class="events-description">
@@ -24,17 +26,18 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({
<ul class="stage-event-list">
<li v-for="commit in items" class="stage-event-item">
<div class="item-details item-conmmit-component">
- <img class="avatar" :src="commit.author.avatarUrl">
+ <!-- FIXME: Pass an alt attribute here for accessibility -->
+ <user-avatar-image :img-src="commit.author.avatarUrl"/>
<h5 class="item-title commit-title">
<a :href="commit.commitUrl">
{{ commit.title }}
</a>
</h5>
<span>
- First
+ {{ s__('FirstPushedBy|First') }}
<span class="commit-icon">${iconCommit}</span>
- <a :href="commit.commitUrl" class="commit-hash-link monospace">{{ commit.shortSha }}</a>
- pushed by
+ <a :href="commit.commitUrl" class="commit-hash-link commit-sha">{{ commit.shortSha }}</a>
+ {{ s__('FirstPushedBy|pushed by') }}
<a :href="commit.author.webUrl" class="commit-author-link">
{{ commit.author.name }}
</a>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js
index 657f5385374..b7ba9360f70 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js
@@ -1,6 +1,6 @@
/* eslint-disable no-param-reassign */
-
import Vue from 'vue';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {};
@@ -10,6 +10,9 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({
items: Array,
stage: Object,
},
+ components: {
+ userAvatarImage,
+ },
template: `
<div>
<div class="events-description">
@@ -19,7 +22,8 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({
<ul class="stage-event-list">
<li v-for="issue in items" class="stage-event-item">
<div class="item-details">
- <img class="avatar" :src="issue.author.avatarUrl">
+ <!-- FIXME: Pass an alt attribute here for accessibility -->
+ <user-avatar-image :img-src="issue.author.avatarUrl"/>
<h5 class="item-title issue-title">
<a class="issue-title" :href="issue.url">
{{ issue.title }}
@@ -28,11 +32,11 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({
<a :href="issue.url" class="issue-link">#{{ issue.iid }}</a>
&middot;
<span>
- Opened
+ {{ s__('OpenedNDaysAgo|Opened') }}
<a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a>
</span>
<span>
- by
+ {{ s__('ByAuthor|by') }}
<a :href="issue.author.webUrl" class="issue-author-link">
{{ issue.author.name }}
</a>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js
index 8a801300647..f41a0d0e4ff 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js
@@ -1,6 +1,6 @@
/* eslint-disable no-param-reassign */
-
import Vue from 'vue';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {};
@@ -10,6 +10,9 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({
items: Array,
stage: Object,
},
+ components: {
+ userAvatarImage,
+ },
template: `
<div>
<div class="events-description">
@@ -19,7 +22,8 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({
<ul class="stage-event-list">
<li v-for="mergeRequest in items" class="stage-event-item">
<div class="item-details">
- <img class="avatar" :src="mergeRequest.author.avatarUrl">
+ <!-- FIXME: Pass an alt attribute here for accessibility -->
+ <user-avatar-image :img-src="mergeRequest.author.avatarUrl"/>
<h5 class="item-title merge-merquest-title">
<a :href="mergeRequest.url">
{{ mergeRequest.title }}
@@ -28,11 +32,11 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({
<a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a>
&middot;
<span>
- Opened
+ {{ s__('OpenedNDaysAgo|Opened') }}
<a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a>
</span>
<span>
- by
+ {{ s__('ByAuthor|by') }}
<a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a>
</span>
<template v-if="mergeRequest.state === 'closed'">
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js
index 4a286379588..d7c906c9d39 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js
@@ -1,5 +1,6 @@
/* eslint-disable no-param-reassign */
import Vue from 'vue';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
import iconBranch from '../svg/icon_branch.svg';
const global = window.gl || (window.gl = {});
@@ -13,6 +14,9 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({
data() {
return { iconBranch };
},
+ components: {
+ userAvatarImage,
+ },
template: `
<div>
<div class="events-description">
@@ -22,17 +26,18 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({
<ul class="stage-event-list">
<li v-for="build in items" class="stage-event-item item-build-component">
<div class="item-details">
- <img class="avatar" :src="build.author.avatarUrl">
+ <!-- FIXME: Pass an alt attribute here for accessibility -->
+ <user-avatar-image :img-src="build.author.avatarUrl"/>
<h5 class="item-title">
<a :href="build.url" class="pipeline-id">#{{ build.id }}</a>
<i class="fa fa-code-fork"></i>
- <a :href="build.branch.url" class="branch-name monospace">{{ build.branch.name }}</a>
+ <a :href="build.branch.url" class="ref-name">{{ build.branch.name }}</a>
<span class="icon-branch">${iconBranch}</span>
- <a :href="build.commitUrl" class="short-sha monospace">{{ build.shortSha }}</a>
+ <a :href="build.commitUrl" class="commit-sha">{{ build.shortSha }}</a>
</h5>
<span>
<a :href="build.url" class="build-date">{{ build.date }}</a>
- by
+ {{ s__('ByAuthor|by') }}
<a :href="build.author.webUrl" class="issue-author-link">
{{ build.author.name }}
</a>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_test_component.js b/app/assets/javascripts/cycle_analytics/components/stage_test_component.js
index e306026429e..78cc97eea0b 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_test_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_test_component.js
@@ -29,9 +29,9 @@ global.cycleAnalytics.StageTestComponent = Vue.extend({
&middot;
<a :href="build.url" class="pipeline-id">#{{ build.id }}</a>
<i class="fa fa-code-fork"></i>
- <a :href="build.branch.url" class="branch-name monospace">{{ build.branch.name }}</a>
+ <a :href="build.branch.url" class="ref-name">{{ build.branch.name }}</a>
<span class="icon-branch">${iconBranch}</span>
- <a :href="build.commitUrl" class="short-sha monospace">{{ build.shortSha }}</a>
+ <a :href="build.commitUrl" class="commit-sha">{{ build.shortSha }}</a>
</h5>
<span>
<a :href="build.url" class="issue-date">
diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.js b/app/assets/javascripts/cycle_analytics/components/total_time_component.js
index 77edcb76273..d5e6167b2a8 100644
--- a/app/assets/javascripts/cycle_analytics/components/total_time_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/total_time_component.js
@@ -12,10 +12,10 @@ global.cycleAnalytics.TotalTimeComponent = Vue.extend({
template: `
<span class="total-time">
<template v-if="Object.keys(time).length">
- <template v-if="time.days">{{ time.days }} <span>{{ time.days === 1 ? 'day' : 'days' }}</span></template>
- <template v-if="time.hours">{{ time.hours }} <span>hr</span></template>
- <template v-if="time.mins && !time.days">{{ time.mins }} <span>mins</span></template>
- <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>s</span></template>
+ <template v-if="time.days">{{ time.days }} <span>{{ n__('day', 'days', time.days) }}</span></template>
+ <template v-if="time.hours">{{ time.hours }} <span>{{ n__('Time|hr', 'Time|hrs', time.hours) }}</span></template>
+ <template v-if="time.mins && !time.days">{{ time.mins }} <span>{{ n__('Time|min', 'Time|mins', time.mins) }}</span></template>
+ <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>{{ s__('Time|s') }}</span></template>
</template>
<template v-else>
--
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
index 48cab437e02..44791a93936 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
@@ -2,19 +2,20 @@
import Vue from 'vue';
import Cookies from 'js-cookie';
+import Translate from '../vue_shared/translate';
import LimitWarningComponent from './components/limit_warning_component';
+import './components/stage_code_component';
+import './components/stage_issue_component';
+import './components/stage_plan_component';
+import './components/stage_production_component';
+import './components/stage_review_component';
+import './components/stage_staging_component';
+import './components/stage_test_component';
+import './components/total_time_component';
+import './cycle_analytics_service';
+import './cycle_analytics_store';
-require('./components/stage_code_component');
-require('./components/stage_issue_component');
-require('./components/stage_plan_component');
-require('./components/stage_production_component');
-require('./components/stage_review_component');
-require('./components/stage_staging_component');
-require('./components/stage_test_component');
-require('./components/total_time_component');
-require('./cycle_analytics_service');
-require('./cycle_analytics_store');
-require('./default_event_objects');
+Vue.use(Translate);
$(() => {
const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js
index 681d6eef565..6504d7db2f2 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js
@@ -30,7 +30,7 @@ class CycleAnalyticsService {
startDate,
} = options;
- return $.get(`${this.requestPath}/events/${stage.title.toLowerCase()}.json`, {
+ return $.get(`${this.requestPath}/events/${stage.name}.json`, {
cycle_analytics: {
start_date: startDate,
},
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
index 6536a8fd7fa..991f8c1f6fd 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
@@ -1,19 +1,20 @@
/* eslint-disable no-param-reassign */
-require('../lib/utils/text_utility');
-const DEFAULT_EVENT_OBJECTS = require('./default_event_objects');
+import { __ } from '../locale';
+import '../lib/utils/text_utility';
+import DEFAULT_EVENT_OBJECTS from './default_event_objects';
const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {};
const EMPTY_STAGE_TEXTS = {
- issue: 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.',
- plan: 'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.',
- code: 'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.',
- test: 'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.',
- review: 'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.',
- staging: 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.',
- production: 'The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.',
+ issue: __('The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.'),
+ plan: __('The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.'),
+ code: __('The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.'),
+ test: __('The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.'),
+ review: __('The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.'),
+ staging: __('The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.'),
+ production: __('The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.'),
};
global.cycleAnalytics.CycleAnalyticsStore = {
@@ -38,7 +39,7 @@ global.cycleAnalytics.CycleAnalyticsStore = {
});
newData.stages.forEach((item) => {
- const stageSlug = gl.text.dasherize(item.title.toLowerCase());
+ const stageSlug = gl.text.dasherize(item.name.toLowerCase());
item.active = false;
item.isUserAllowed = data.permissions[stageSlug];
item.emptyStageText = EMPTY_STAGE_TEXTS[stageSlug];
diff --git a/app/assets/javascripts/cycle_analytics/default_event_objects.js b/app/assets/javascripts/cycle_analytics/default_event_objects.js
index cfaf9835bf8..57f9019d2f8 100644
--- a/app/assets/javascripts/cycle_analytics/default_event_objects.js
+++ b/app/assets/javascripts/cycle_analytics/default_event_objects.js
@@ -1,4 +1,4 @@
-module.exports = {
+export default {
issue: {
created_at: '',
url: '',
diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue
new file mode 100644
index 00000000000..3f993213dd0
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue
@@ -0,0 +1,55 @@
+<script>
+ import eventHub from '../eventhub';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+
+ export default {
+ data() {
+ return {
+ isLoading: false,
+ };
+ },
+ props: {
+ deployKey: {
+ type: Object,
+ required: true,
+ },
+ type: {
+ type: String,
+ required: true,
+ },
+ btnCssClass: {
+ type: String,
+ required: false,
+ default: 'btn-default',
+ },
+ },
+
+ components: {
+ loadingIcon,
+ },
+
+ methods: {
+ doAction() {
+ this.isLoading = true;
+
+ eventHub.$emit(`${this.type}.key`, this.deployKey);
+ },
+ },
+ computed: {
+ text() {
+ return `${this.type.charAt(0).toUpperCase()}${this.type.slice(1)}`;
+ },
+ },
+ };
+</script>
+
+<template>
+ <button
+ class="btn btn-sm prepend-left-10"
+ :class="[{ disabled: isLoading }, btnCssClass]"
+ :disabled="isLoading"
+ @click="doAction">
+ {{ text }}
+ <loading-icon v-if="isLoading" />
+ </button>
+</template>
diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue
new file mode 100644
index 00000000000..5f6eed0c67c
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/components/app.vue
@@ -0,0 +1,100 @@
+<script>
+ /* global Flash */
+ import eventHub from '../eventhub';
+ import DeployKeysService from '../service';
+ import DeployKeysStore from '../store';
+ import keysPanel from './keys_panel.vue';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+
+ export default {
+ data() {
+ return {
+ isLoading: false,
+ store: new DeployKeysStore(),
+ };
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ hasKeys() {
+ return Object.keys(this.keys).length;
+ },
+ keys() {
+ return this.store.keys;
+ },
+ },
+ components: {
+ keysPanel,
+ loadingIcon,
+ },
+ methods: {
+ fetchKeys() {
+ this.isLoading = true;
+
+ this.service.getKeys()
+ .then((data) => {
+ this.isLoading = false;
+ this.store.keys = data;
+ })
+ .catch(() => new Flash('Error getting deploy keys'));
+ },
+ enableKey(deployKey) {
+ this.service.enableKey(deployKey.id)
+ .then(() => this.fetchKeys())
+ .catch(() => new Flash('Error enabling deploy key'));
+ },
+ disableKey(deployKey) {
+ // eslint-disable-next-line no-alert
+ if (confirm('You are going to remove this deploy key. Are you sure?')) {
+ this.service.disableKey(deployKey.id)
+ .then(() => this.fetchKeys())
+ .catch(() => new Flash('Error removing deploy key'));
+ }
+ },
+ },
+ created() {
+ this.service = new DeployKeysService(this.endpoint);
+
+ eventHub.$on('enable.key', this.enableKey);
+ eventHub.$on('remove.key', this.disableKey);
+ eventHub.$on('disable.key', this.disableKey);
+ },
+ mounted() {
+ this.fetchKeys();
+ },
+ beforeDestroy() {
+ eventHub.$off('enable.key', this.enableKey);
+ eventHub.$off('remove.key', this.disableKey);
+ eventHub.$off('disable.key', this.disableKey);
+ },
+ };
+</script>
+
+<template>
+ <div class="col-lg-9 col-lg-offset-3 append-bottom-default deploy-keys">
+ <loading-icon
+ v-if="isLoading && !hasKeys"
+ size="2"
+ label="Loading deploy keys"
+ />
+ <div v-else-if="hasKeys">
+ <keys-panel
+ title="Enabled deploy keys for this project"
+ :keys="keys.enabled_keys"
+ :store="store" />
+ <keys-panel
+ title="Deploy keys from projects you have access to"
+ :keys="keys.available_project_keys"
+ :store="store" />
+ <keys-panel
+ v-if="keys.public_keys.length"
+ title="Public deploy keys available to any project"
+ :keys="keys.public_keys"
+ :store="store" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue
new file mode 100644
index 00000000000..0a06a481b96
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/components/key.vue
@@ -0,0 +1,80 @@
+<script>
+ import actionBtn from './action_btn.vue';
+
+ export default {
+ props: {
+ deployKey: {
+ type: Object,
+ required: true,
+ },
+ store: {
+ type: Object,
+ required: true,
+ },
+ },
+ components: {
+ actionBtn,
+ },
+ computed: {
+ timeagoDate() {
+ return gl.utils.getTimeago().format(this.deployKey.created_at);
+ },
+ },
+ methods: {
+ isEnabled(id) {
+ return this.store.findEnabledKey(id) !== undefined;
+ },
+ },
+ };
+</script>
+
+<template>
+ <div>
+ <div class="pull-left append-right-10 hidden-xs">
+ <i
+ aria-hidden="true"
+ class="fa fa-key key-icon">
+ </i>
+ </div>
+ <div class="deploy-key-content key-list-item-info">
+ <strong class="title">
+ {{ deployKey.title }}
+ </strong>
+ <div class="description">
+ {{ deployKey.fingerprint }}
+ </div>
+ <div
+ v-if="deployKey.can_push"
+ class="write-access-allowed">
+ Write access allowed
+ </div>
+ </div>
+ <div class="deploy-key-content prepend-left-default deploy-key-projects">
+ <a
+ v-for="project in deployKey.projects"
+ class="label deploy-project-label"
+ :href="project.full_path">
+ {{ project.full_name }}
+ </a>
+ </div>
+ <div class="deploy-key-content">
+ <span class="key-created-at">
+ created {{ timeagoDate }}
+ </span>
+ <action-btn
+ v-if="!isEnabled(deployKey.id)"
+ :deploy-key="deployKey"
+ type="enable"/>
+ <action-btn
+ v-else-if="deployKey.destroyed_when_orphaned && deployKey.almost_orphaned"
+ :deploy-key="deployKey"
+ btn-css-class="btn-warning"
+ type="remove" />
+ <action-btn
+ v-else
+ :deploy-key="deployKey"
+ btn-css-class="btn-warning"
+ type="disable" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
new file mode 100644
index 00000000000..eccc470578b
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
@@ -0,0 +1,52 @@
+<script>
+ import key from './key.vue';
+
+ export default {
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ keys: {
+ type: Array,
+ required: true,
+ },
+ showHelpBox: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ store: {
+ type: Object,
+ required: true,
+ },
+ },
+ components: {
+ key,
+ },
+ };
+</script>
+
+<template>
+ <div class="deploy-keys-panel">
+ <h5>
+ {{ title }}
+ ({{ keys.length }})
+ </h5>
+ <ul class="well-list"
+ v-if="keys.length">
+ <li
+ v-for="deployKey in keys"
+ :key="deployKey.id">
+ <key
+ :deploy-key="deployKey"
+ :store="store" />
+ </li>
+ </ul>
+ <div
+ class="settings-message text-center"
+ v-else-if="showHelpBox">
+ No deploy keys found. Create one with the form above.
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/deploy_keys/eventhub.js b/app/assets/javascripts/deploy_keys/eventhub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/eventhub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/deploy_keys/index.js b/app/assets/javascripts/deploy_keys/index.js
new file mode 100644
index 00000000000..a5f232f950a
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/index.js
@@ -0,0 +1,21 @@
+import Vue from 'vue';
+import deployKeysApp from './components/app.vue';
+
+document.addEventListener('DOMContentLoaded', () => new Vue({
+ el: document.getElementById('js-deploy-keys'),
+ data() {
+ return {
+ endpoint: this.$options.el.dataset.endpoint,
+ };
+ },
+ components: {
+ deployKeysApp,
+ },
+ render(createElement) {
+ return createElement('deploy-keys-app', {
+ props: {
+ endpoint: this.endpoint,
+ },
+ });
+ },
+}));
diff --git a/app/assets/javascripts/deploy_keys/service/index.js b/app/assets/javascripts/deploy_keys/service/index.js
new file mode 100644
index 00000000000..fe6dbaa9498
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/service/index.js
@@ -0,0 +1,34 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+Vue.use(VueResource);
+
+export default class DeployKeysService {
+ constructor(endpoint) {
+ this.endpoint = endpoint;
+
+ this.resource = Vue.resource(`${this.endpoint}{/id}`, {}, {
+ enable: {
+ method: 'PUT',
+ url: `${this.endpoint}{/id}/enable`,
+ },
+ disable: {
+ method: 'PUT',
+ url: `${this.endpoint}{/id}/disable`,
+ },
+ });
+ }
+
+ getKeys() {
+ return this.resource.get()
+ .then(response => response.json());
+ }
+
+ enableKey(id) {
+ return this.resource.enable({ id }, {});
+ }
+
+ disableKey(id) {
+ return this.resource.disable({ id }, {});
+ }
+}
diff --git a/app/assets/javascripts/deploy_keys/store/index.js b/app/assets/javascripts/deploy_keys/store/index.js
new file mode 100644
index 00000000000..6210361af26
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/store/index.js
@@ -0,0 +1,9 @@
+export default class DeployKeysStore {
+ constructor() {
+ this.keys = {};
+ }
+
+ findEnabledKey(id) {
+ return this.keys.enabled_keys.find(key => key.id === id);
+ }
+}
diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js
index 5aa3eb46a69..725ec7b9c70 100644
--- a/app/assets/javascripts/diff.js
+++ b/app/assets/javascripts/diff.js
@@ -1,6 +1,6 @@
/* eslint-disable class-methods-use-this */
-require('./lib/utils/url_utility');
+import './lib/utils/url_utility';
const UNFOLD_COUNT = 20;
let isBound = false;
diff --git a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
index f3a688fbf2f..517bdb6be09 100644
--- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
+++ b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
@@ -3,6 +3,7 @@
import Vue from 'vue';
import collapseIcon from '../icons/collapse_icon.svg';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const DiffNoteAvatars = Vue.extend({
props: ['discussionId'],
@@ -15,22 +16,24 @@ const DiffNoteAvatars = Vue.extend({
collapseIcon,
};
},
+ components: {
+ userAvatarImage,
+ },
template: `
<div class="diff-comment-avatar-holders"
v-show="notesCount !== 0">
<div v-if="!isVisible">
- <img v-for="note in notesSubset"
- class="avatar diff-comment-avatar has-tooltip js-diff-comment-avatar"
- width="19"
- height="19"
- role="button"
- data-container="body"
- data-placement="top"
- data-html="true"
+ <!-- FIXME: Pass an alt attribute here for accessibility -->
+ <user-avatar-image
+ v-for="note in notesSubset"
+ class="diff-comment-avatar js-diff-comment-avatar"
+ @click.native="clickedAvatar($event)"
+ :img-src="note.authorAvatar"
+ :tooltip-text="getTooltipText(note)"
:data-line-type="lineType"
- :title="note.authorName + ': ' + note.noteTruncated"
- :src="note.authorAvatar"
- @click="clickedAvatar($event)" />
+ :size="19"
+ data-html="true"
+ />
<span v-if="notesCount > shownAvatars"
class="diff-comments-more-count has-tooltip js-diff-comment-avatar"
data-container="body"
@@ -120,7 +123,7 @@ const DiffNoteAvatars = Vue.extend({
},
methods: {
clickedAvatar(e) {
- notes.addDiffNote(e);
+ notes.onAddDiffNote(e);
// Toggle the active state of the toggle all button
this.toggleDiscussionsToggleState();
@@ -150,6 +153,9 @@ const DiffNoteAvatars = Vue.extend({
setDiscussionVisible() {
this.isVisible = $(`.diffs .notes[data-discussion-id="${this.discussion.id}"]`).is(':visible');
},
+ getTooltipText(note) {
+ return `${note.authorName}: ${note.noteTruncated}`;
+ },
},
});
diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
index 8a0fd3bb4a7..37ddca29e71 100644
--- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
+++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
@@ -16,6 +16,13 @@ const JumpToDiscussion = Vue.extend({
};
},
computed: {
+ buttonText: function () {
+ if (this.discussionId) {
+ return 'Jump to next unresolved discussion';
+ } else {
+ return 'Jump to first unresolved discussion';
+ }
+ },
allResolved: function () {
return this.unresolvedDiscussionCount === 0;
},
diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js
index 92f6fd654b3..9d51fb53eb2 100644
--- a/app/assets/javascripts/diff_notes/components/resolve_btn.js
+++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js
@@ -88,6 +88,7 @@ const ResolveBtn = Vue.extend({
CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by);
this.discussion.updateHeadline(data);
+ gl.mrWidget.checkStatus();
} else {
new Flash(errorFlashMsg);
}
diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js
index b6b47e2da6f..a2d33b0936e 100644
--- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js
+++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js
@@ -2,19 +2,18 @@
/* global ResolveCount */
import Vue from 'vue';
-
-require('./models/discussion');
-require('./models/note');
-require('./stores/comments');
-require('./services/resolve');
-require('./mixins/discussion');
-require('./components/comment_resolve_btn');
-require('./components/jump_to_discussion');
-require('./components/resolve_btn');
-require('./components/resolve_count');
-require('./components/resolve_discussion_btn');
-require('./components/diff_note_avatars');
-require('./components/new_issue_for_discussion');
+import './models/discussion';
+import './models/note';
+import './stores/comments';
+import './services/resolve';
+import './mixins/discussion';
+import './components/comment_resolve_btn';
+import './components/jump_to_discussion';
+import './components/resolve_btn';
+import './components/resolve_count';
+import './components/resolve_discussion_btn';
+import './components/diff_note_avatars';
+import './components/new_issue_for_discussion';
$(() => {
const projectPath = document.querySelector('.merge-request').dataset.projectPath;
@@ -65,4 +64,6 @@ $(() => {
'resolve-count': ResolveCount
}
});
+
+ $(window).trigger('resize.nav');
});
diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js
index 4ea6ba8a73d..807ab11d292 100644
--- a/app/assets/javascripts/diff_notes/services/resolve.js
+++ b/app/assets/javascripts/diff_notes/services/resolve.js
@@ -3,11 +3,7 @@
/* global CommentsStore */
import Vue from 'vue';
-import VueResource from 'vue-resource';
-
-require('../../vue_shared/vue_resource_interceptor');
-
-Vue.use(VueResource);
+import '../../vue_shared/vue_resource_interceptor';
window.gl = window.gl || {};
@@ -49,6 +45,7 @@ class ResolveServiceClass {
discussion.resolveAllNotes(resolved_by);
}
+ gl.mrWidget.checkStatus();
discussion.updateHeadline(data);
} else {
throw new Error('An error occurred when trying to resolve discussion.');
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index e4c60ef1188..bb49c9c5aba 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -10,12 +10,10 @@
/* global IssuableForm */
/* global LabelsSelect */
/* global MilestoneSelect */
-/* global MergedButtons */
/* global Commit */
/* global NotificationsForm */
/* global TreeView */
/* global NotificationsDropdown */
-/* global UsersSelect */
/* global GroupAvatar */
/* global LineHighlighter */
/* global ProjectFork */
@@ -36,11 +34,12 @@
/* global ShortcutsWiki */
import Issue from './issue';
-
import BindInOut from './behaviors/bind_in_out';
+import DeleteModal from './branches/branches_delete_modal';
import Group from './group';
import GroupName from './group_name';
import ProjectsList from './projects_list';
+import setupProjectEdit from './project_edit';
import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
import BlobLinePermalinkUpdater from './blob/blob_line_permalink_updater';
import Landing from './landing';
@@ -48,9 +47,13 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
import UserCallout from './user_callout';
import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags';
import ShortcutsWiki from './shortcuts_wiki';
+import Pipelines from './pipelines';
import BlobViewer from './blob/viewer/index';
-
-const ShortcutsBlob = require('./shortcuts_blob');
+import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select';
+import UsersSelect from './users_select';
+import RefSelectDropdown from './ref_select_dropdown';
+import GfmAutoComplete from './gfm_auto_complete';
+import ShortcutsBlob from './shortcuts_blob';
(function() {
var Dispatcher;
@@ -75,6 +78,8 @@ const ShortcutsBlob = require('./shortcuts_blob');
path = page.split(':');
shortcut_handler = null;
+ new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources).setup();
+
function initBlob() {
new LineHighlighter();
@@ -110,20 +115,23 @@ const ShortcutsBlob = require('./shortcuts_blob');
case 'projects:boards:show':
case 'projects:boards:index':
shortcut_handler = new ShortcutsNavigation();
+ new UsersSelect();
break;
- case 'projects:builds:show':
+ case 'projects:jobs:show':
new Build();
break;
case 'projects:merge_requests:index':
case 'projects:issues:index':
- if (gl.FilteredSearchManager) {
- new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests');
+ if (gl.FilteredSearchManager && document.querySelector('.filtered-search')) {
+ const filteredSearchManager = new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests');
+ filteredSearchManager.setup();
}
Issuable.init();
new gl.IssuableBulkActions({
prefixId: page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_',
});
shortcut_handler = new ShortcutsNavigation();
+ new UsersSelect();
break;
case 'projects:issues:show':
new Issue();
@@ -136,6 +144,10 @@ const ShortcutsBlob = require('./shortcuts_blob');
new Milestone();
new Sidebar();
break;
+ case 'groups:issues':
+ case 'groups:merge_requests':
+ new UsersSelect();
+ break;
case 'dashboard:todos:index':
new gl.Todos();
break;
@@ -172,6 +184,7 @@ const ShortcutsBlob = require('./shortcuts_blob');
break;
case 'projects:branches:index':
gl.AjaxLoadingSpinner.init();
+ new DeleteModal();
break;
case 'projects:issues:new':
case 'projects:issues:edit':
@@ -192,10 +205,12 @@ const ShortcutsBlob = require('./shortcuts_blob');
new LabelsSelect();
new MilestoneSelect();
new gl.IssuableTemplateSelectors();
+ new AutoWidthDropdownSelect($('.js-target-branch-select')).init();
break;
case 'projects:tags:new':
new ZenMode();
new gl.GLForm($('.tag-form'));
+ new RefSelectDropdown($('.js-branch-select'), window.gl.availableRefs);
break;
case 'projects:releases:edit':
new ZenMode();
@@ -205,19 +220,18 @@ const ShortcutsBlob = require('./shortcuts_blob');
new gl.Diff();
shortcut_handler = new ShortcutsIssuable(true);
new ZenMode();
- new MergedButtons();
- break;
- case 'projects:merge_requests:commits':
- new MergedButtons();
break;
case "projects:merge_requests:diffs":
new gl.Diff();
new ZenMode();
- new MergedButtons();
break;
case 'dashboard:activity':
new gl.Activities();
break;
+ case 'dashboard:issues':
+ case 'dashboard:merge_requests':
+ new UsersSelect();
+ break;
case 'projects:commit:show':
new Commit();
new gl.Diff();
@@ -242,13 +256,20 @@ const ShortcutsBlob = require('./shortcuts_blob');
if ($('#tree-slider').length) {
new TreeView();
}
+ if ($('.blob-viewer').length) {
+ new BlobViewer();
+ }
+ break;
+ case 'projects:edit':
+ setupProjectEdit();
break;
case 'projects:pipelines:builds':
+ case 'projects:pipelines:failures':
case 'projects:pipelines:show':
const { controllerAction } = document.querySelector('.js-pipeline-container').dataset;
const pipelineStatusUrl = `${document.querySelector('.js-pipeline-tab-link a').getAttribute('href')}/status.json`;
- new gl.Pipelines({
+ new Pipelines({
initTabs: true,
pipelineStatusUrl,
tabsOptions: {
@@ -294,6 +315,7 @@ const ShortcutsBlob = require('./shortcuts_blob');
case 'projects:tree:show':
shortcut_handler = new ShortcutsNavigation();
new TreeView();
+ new BlobViewer();
gl.TargetBranchDropDown.bootstrap();
break;
case 'projects:find_file:show':
@@ -369,6 +391,9 @@ const ShortcutsBlob = require('./shortcuts_blob');
new LineHighlighter();
new BlobViewer();
break;
+ case 'import:fogbugz:new_user_map':
+ new UsersSelect();
+ break;
}
switch (path.first()) {
case 'sessions':
diff --git a/app/assets/javascripts/droplab/constants.js b/app/assets/javascripts/droplab/constants.js
index 8883ed9aa14..868d47e91b3 100644
--- a/app/assets/javascripts/droplab/constants.js
+++ b/app/assets/javascripts/droplab/constants.js
@@ -3,11 +3,14 @@ const DATA_DROPDOWN = 'data-dropdown';
const SELECTED_CLASS = 'droplab-item-selected';
const ACTIVE_CLASS = 'droplab-item-active';
const IGNORE_CLASS = 'droplab-item-ignore';
+// Matches `{{anything}}` and `{{ everything }}`.
+const TEMPLATE_REGEX = /\{\{(.+?)\}\}/g;
export {
DATA_TRIGGER,
DATA_DROPDOWN,
SELECTED_CLASS,
ACTIVE_CLASS,
+ TEMPLATE_REGEX,
IGNORE_CLASS,
};
diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js
index 1fb4d63923c..70cd337fb8a 100644
--- a/app/assets/javascripts/droplab/drop_down.js
+++ b/app/assets/javascripts/droplab/drop_down.js
@@ -1,44 +1,42 @@
-/* eslint-disable */
-
import utils from './utils';
import { SELECTED_CLASS, IGNORE_CLASS } from './constants';
-var DropDown = function(list) {
- this.currentIndex = 0;
- this.hidden = true;
- this.list = typeof list === 'string' ? document.querySelector(list) : list;
- this.items = [];
+class DropDown {
+ constructor(list) {
+ this.currentIndex = 0;
+ this.hidden = true;
+ this.list = typeof list === 'string' ? document.querySelector(list) : list;
+ this.items = [];
- this.eventWrapper = {};
+ this.eventWrapper = {};
- this.getItems();
- this.initTemplateString();
- this.addEvents();
+ this.getItems();
+ this.initTemplateString();
+ this.addEvents();
- this.initialState = list.innerHTML;
-};
+ this.initialState = list.innerHTML;
+ }
-Object.assign(DropDown.prototype, {
- getItems: function() {
+ getItems() {
this.items = [].slice.call(this.list.querySelectorAll('li'));
return this.items;
- },
+ }
- initTemplateString: function() {
- var items = this.items || this.getItems();
+ initTemplateString() {
+ const items = this.items || this.getItems();
- var templateString = '';
+ let templateString = '';
if (items.length > 0) templateString = items[items.length - 1].outerHTML;
this.templateString = templateString;
return this.templateString;
- },
+ }
- clickEvent: function(e) {
+ clickEvent(e) {
if (e.target.tagName === 'UL') return;
if (e.target.classList.contains(IGNORE_CLASS)) return;
- var selected = utils.closest(e.target, 'LI');
+ const selected = utils.closest(e.target, 'LI');
if (!selected) return;
this.addSelectedClass(selected);
@@ -46,95 +44,95 @@ Object.assign(DropDown.prototype, {
e.preventDefault();
this.hide();
- var listEvent = new CustomEvent('click.dl', {
+ const listEvent = new CustomEvent('click.dl', {
detail: {
list: this,
- selected: selected,
+ selected,
data: e.target.dataset,
},
});
this.list.dispatchEvent(listEvent);
- },
+ }
- addSelectedClass: function (selected) {
+ addSelectedClass(selected) {
this.removeSelectedClasses();
selected.classList.add(SELECTED_CLASS);
- },
+ }
- removeSelectedClasses: function () {
+ removeSelectedClasses() {
const items = this.items || this.getItems();
items.forEach(item => item.classList.remove(SELECTED_CLASS));
- },
+ }
- addEvents: function() {
- this.eventWrapper.clickEvent = this.clickEvent.bind(this)
+ addEvents() {
+ this.eventWrapper.clickEvent = this.clickEvent.bind(this);
this.list.addEventListener('click', this.eventWrapper.clickEvent);
- },
-
- toggle: function() {
- this.hidden ? this.show() : this.hide();
- },
+ }
- setData: function(data) {
+ setData(data) {
this.data = data;
this.render(data);
- },
+ }
- addData: function(data) {
+ addData(data) {
this.data = (this.data || []).concat(data);
this.render(this.data);
- },
+ }
- render: function(data) {
+ render(data) {
const children = data ? data.map(this.renderChildren.bind(this)) : [];
const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list;
renderableList.innerHTML = children.join('');
- },
+ }
- renderChildren: function(data) {
- var html = utils.t(this.templateString, data);
- var template = document.createElement('div');
+ renderChildren(data) {
+ const html = utils.template(this.templateString, data);
+ const template = document.createElement('div');
template.innerHTML = html;
- this.setImagesSrc(template);
+ DropDown.setImagesSrc(template);
template.firstChild.style.display = data.droplab_hidden ? 'none' : 'block';
return template.firstChild.outerHTML;
- },
-
- setImagesSrc: function(template) {
- const images = [].slice.call(template.querySelectorAll('img[data-src]'));
-
- images.forEach((image) => {
- image.src = image.getAttribute('data-src');
- image.removeAttribute('data-src');
- });
- },
+ }
- show: function() {
+ show() {
if (!this.hidden) return;
this.list.style.display = 'block';
this.currentIndex = 0;
this.hidden = false;
- },
+ }
- hide: function() {
+ hide() {
if (this.hidden) return;
this.list.style.display = 'none';
this.currentIndex = 0;
this.hidden = true;
- },
+ }
- toggle: function () {
- this.hidden ? this.show() : this.hide();
- },
+ toggle() {
+ if (this.hidden) return this.show();
- destroy: function() {
+ return this.hide();
+ }
+
+ destroy() {
this.hide();
this.list.removeEventListener('click', this.eventWrapper.clickEvent);
}
-});
+
+ static setImagesSrc(template) {
+ const images = [...template.querySelectorAll('img[data-src]')];
+
+ images.forEach((image) => {
+ const img = image;
+
+ img.src = img.getAttribute('data-src');
+ img.removeAttribute('data-src');
+ });
+ }
+}
export default DropDown;
diff --git a/app/assets/javascripts/droplab/drop_lab.js b/app/assets/javascripts/droplab/drop_lab.js
index 6eb9f314af7..2a02ede72bf 100644
--- a/app/assets/javascripts/droplab/drop_lab.js
+++ b/app/assets/javascripts/droplab/drop_lab.js
@@ -1,99 +1,99 @@
-/* eslint-disable */
-
import HookButton from './hook_button';
import HookInput from './hook_input';
import utils from './utils';
import Keyboard from './keyboard';
import { DATA_TRIGGER } from './constants';
-var DropLab = function() {
- this.ready = false;
- this.hooks = [];
- this.queuedData = [];
- this.config = {};
+class DropLab {
+ constructor() {
+ this.ready = false;
+ this.hooks = [];
+ this.queuedData = [];
+ this.config = {};
- this.eventWrapper = {};
-};
+ this.eventWrapper = {};
+ }
-Object.assign(DropLab.prototype, {
- loadStatic: function(){
- var dropdownTriggers = [].slice.apply(document.querySelectorAll(`[${DATA_TRIGGER}]`));
+ loadStatic() {
+ const dropdownTriggers = [].slice.apply(document.querySelectorAll(`[${DATA_TRIGGER}]`));
this.addHooks(dropdownTriggers);
- },
+ }
- addData: function () {
- var args = [].slice.apply(arguments);
- this.applyArgs(args, '_addData');
- },
+ addData(...args) {
+ this.applyArgs(args, 'processAddData');
+ }
- setData: function() {
- var args = [].slice.apply(arguments);
- this.applyArgs(args, '_setData');
- },
+ setData(...args) {
+ this.applyArgs(args, 'processSetData');
+ }
- destroy: function() {
+ destroy() {
this.hooks.forEach(hook => hook.destroy());
this.hooks = [];
this.removeEvents();
- },
+ }
- applyArgs: function(args, methodName) {
- if (this.ready) return this[methodName].apply(this, args);
+ applyArgs(args, methodName) {
+ if (this.ready) return this[methodName](...args);
this.queuedData = this.queuedData || [];
this.queuedData.push(args);
- },
- _addData: function(trigger, data) {
- this._processData(trigger, data, 'addData');
- },
+ return this.ready;
+ }
+
+ processAddData(trigger, data) {
+ this.processData(trigger, data, 'addData');
+ }
- _setData: function(trigger, data) {
- this._processData(trigger, data, 'setData');
- },
+ processSetData(trigger, data) {
+ this.processData(trigger, data, 'setData');
+ }
- _processData: function(trigger, data, methodName) {
+ processData(trigger, data, methodName) {
this.hooks.forEach((hook) => {
if (Array.isArray(trigger)) hook.list[methodName](trigger);
if (hook.trigger.id === trigger) hook.list[methodName](data);
});
- },
+ }
- addEvents: function() {
- this.eventWrapper.documentClicked = this.documentClicked.bind(this)
+ addEvents() {
+ this.eventWrapper.documentClicked = this.documentClicked.bind(this);
document.addEventListener('click', this.eventWrapper.documentClicked);
- },
+ }
- documentClicked: function(e) {
+ documentClicked(e) {
let thisTag = e.target;
if (thisTag.tagName !== 'UL') thisTag = utils.closest(thisTag, 'UL');
- if (utils.isDropDownParts(thisTag, this.hooks) || utils.isDropDownParts(e.target, this.hooks)) return;
+ if (utils.isDropDownParts(thisTag, this.hooks)) return;
+ if (utils.isDropDownParts(e.target, this.hooks)) return;
this.hooks.forEach(hook => hook.list.hide());
- },
+ }
- removeEvents: function(){
+ removeEvents() {
document.removeEventListener('click', this.eventWrapper.documentClicked);
- },
-
- changeHookList: function(trigger, list, plugins, config) {
- const availableTrigger = typeof trigger === 'string' ? document.getElementById(trigger) : trigger;
+ }
+ changeHookList(trigger, list, plugins, config) {
+ const availableTrigger = typeof trigger === 'string' ? document.getElementById(trigger) : trigger;
this.hooks.forEach((hook, i) => {
- hook.list.list.dataset.dropdownActive = false;
+ const aHook = hook;
+
+ aHook.list.list.dataset.dropdownActive = false;
- if (hook.trigger !== availableTrigger) return;
+ if (aHook.trigger !== availableTrigger) return;
- hook.destroy();
+ aHook.destroy();
this.hooks.splice(i, 1);
this.addHook(availableTrigger, list, plugins, config);
});
- },
+ }
- addHook: function(hook, list, plugins, config) {
+ addHook(hook, list, plugins, config) {
const availableHook = typeof hook === 'string' ? document.querySelector(hook) : hook;
let availableList;
@@ -111,18 +111,18 @@ Object.assign(DropLab.prototype, {
this.hooks.push(new HookObject(availableHook, availableList, plugins, config));
return this;
- },
+ }
- addHooks: function(hooks, plugins, config) {
+ addHooks(hooks, plugins, config) {
hooks.forEach(hook => this.addHook(hook, null, plugins, config));
return this;
- },
+ }
- setConfig: function(obj){
+ setConfig(obj) {
this.config = obj;
- },
+ }
- fireReady: function() {
+ fireReady() {
const readyEvent = new CustomEvent('ready.dl', {
detail: {
dropdown: this,
@@ -131,10 +131,14 @@ Object.assign(DropLab.prototype, {
document.dispatchEvent(readyEvent);
this.ready = true;
- },
+ }
- init: function (hook, list, plugins, config) {
- hook ? this.addHook(hook, list, plugins, config) : this.loadStatic();
+ init(hook, list, plugins, config) {
+ if (hook) {
+ this.addHook(hook, list, plugins, config);
+ } else {
+ this.loadStatic();
+ }
this.addEvents();
@@ -146,7 +150,7 @@ Object.assign(DropLab.prototype, {
this.queuedData = [];
return this;
- },
-});
+ }
+}
export default DropLab;
diff --git a/app/assets/javascripts/droplab/hook.js b/app/assets/javascripts/droplab/hook.js
index 2f840083571..cf78165b0d8 100644
--- a/app/assets/javascripts/droplab/hook.js
+++ b/app/assets/javascripts/droplab/hook.js
@@ -1,22 +1,15 @@
-/* eslint-disable */
-
import DropDown from './drop_down';
-var Hook = function(trigger, list, plugins, config){
- this.trigger = trigger;
- this.list = new DropDown(list);
- this.type = 'Hook';
- this.event = 'click';
- this.plugins = plugins || [];
- this.config = config || {};
- this.id = trigger.id;
-};
-
-Object.assign(Hook.prototype, {
-
- addEvents: function(){},
-
- constructor: Hook,
-});
+class Hook {
+ constructor(trigger, list, plugins, config) {
+ this.trigger = trigger;
+ this.list = new DropDown(list);
+ this.type = 'Hook';
+ this.event = 'click';
+ this.plugins = plugins || [];
+ this.config = config || {};
+ this.id = trigger.id;
+ }
+}
export default Hook;
diff --git a/app/assets/javascripts/droplab/hook_button.js b/app/assets/javascripts/droplab/hook_button.js
index be8aead1303..af45eba74e7 100644
--- a/app/assets/javascripts/droplab/hook_button.js
+++ b/app/assets/javascripts/droplab/hook_button.js
@@ -1,65 +1,58 @@
-/* eslint-disable */
-
import Hook from './hook';
-var HookButton = function(trigger, list, plugins, config) {
- Hook.call(this, trigger, list, plugins, config);
-
- this.type = 'button';
- this.event = 'click';
+class HookButton extends Hook {
+ constructor(trigger, list, plugins, config) {
+ super(trigger, list, plugins, config);
- this.eventWrapper = {};
+ this.type = 'button';
+ this.event = 'click';
- this.addEvents();
- this.addPlugins();
-};
+ this.eventWrapper = {};
-HookButton.prototype = Object.create(Hook.prototype);
+ this.addEvents();
+ this.addPlugins();
+ }
-Object.assign(HookButton.prototype, {
- addPlugins: function() {
+ addPlugins() {
this.plugins.forEach(plugin => plugin.init(this));
- },
+ }
- clicked: function(e){
- var buttonEvent = new CustomEvent('click.dl', {
+ clicked(e) {
+ const buttonEvent = new CustomEvent('click.dl', {
detail: {
hook: this,
},
bubbles: true,
- cancelable: true
+ cancelable: true,
});
e.target.dispatchEvent(buttonEvent);
this.list.toggle();
- },
+ }
- addEvents: function(){
+ addEvents() {
this.eventWrapper.clicked = this.clicked.bind(this);
this.trigger.addEventListener('click', this.eventWrapper.clicked);
- },
+ }
- removeEvents: function(){
+ removeEvents() {
this.trigger.removeEventListener('click', this.eventWrapper.clicked);
- },
+ }
- restoreInitialState: function() {
+ restoreInitialState() {
this.list.list.innerHTML = this.list.initialState;
- },
+ }
- removePlugins: function() {
+ removePlugins() {
this.plugins.forEach(plugin => plugin.destroy());
- },
+ }
- destroy: function() {
+ destroy() {
this.restoreInitialState();
this.removeEvents();
this.removePlugins();
- },
-
- constructor: HookButton,
-});
-
+ }
+}
export default HookButton;
diff --git a/app/assets/javascripts/droplab/hook_input.js b/app/assets/javascripts/droplab/hook_input.js
index 05082334045..19131a64f2c 100644
--- a/app/assets/javascripts/droplab/hook_input.js
+++ b/app/assets/javascripts/droplab/hook_input.js
@@ -1,25 +1,23 @@
-/* eslint-disable */
-
import Hook from './hook';
-var HookInput = function(trigger, list, plugins, config) {
- Hook.call(this, trigger, list, plugins, config);
+class HookInput extends Hook {
+ constructor(trigger, list, plugins, config) {
+ super(trigger, list, plugins, config);
- this.type = 'input';
- this.event = 'input';
+ this.type = 'input';
+ this.event = 'input';
- this.eventWrapper = {};
+ this.eventWrapper = {};
- this.addEvents();
- this.addPlugins();
-};
+ this.addEvents();
+ this.addPlugins();
+ }
-Object.assign(HookInput.prototype, {
- addPlugins: function() {
+ addPlugins() {
this.plugins.forEach(plugin => plugin.init(this));
- },
+ }
- addEvents: function(){
+ addEvents() {
this.eventWrapper.mousedown = this.mousedown.bind(this);
this.eventWrapper.input = this.input.bind(this);
this.eventWrapper.keyup = this.keyup.bind(this);
@@ -29,19 +27,19 @@ Object.assign(HookInput.prototype, {
this.trigger.addEventListener('input', this.eventWrapper.input);
this.trigger.addEventListener('keyup', this.eventWrapper.keyup);
this.trigger.addEventListener('keydown', this.eventWrapper.keydown);
- },
+ }
- removeEvents: function() {
+ removeEvents() {
this.hasRemovedEvents = true;
this.trigger.removeEventListener('mousedown', this.eventWrapper.mousedown);
this.trigger.removeEventListener('input', this.eventWrapper.input);
this.trigger.removeEventListener('keyup', this.eventWrapper.keyup);
this.trigger.removeEventListener('keydown', this.eventWrapper.keydown);
- },
+ }
- input: function(e) {
- if(this.hasRemovedEvents) return;
+ input(e) {
+ if (this.hasRemovedEvents) return;
this.list.show();
@@ -51,12 +49,12 @@ Object.assign(HookInput.prototype, {
text: e.target.value,
},
bubbles: true,
- cancelable: true
+ cancelable: true,
});
e.target.dispatchEvent(inputEvent);
- },
+ }
- mousedown: function(e) {
+ mousedown(e) {
if (this.hasRemovedEvents) return;
const mouseEvent = new CustomEvent('mousedown.dl', {
@@ -68,21 +66,21 @@ Object.assign(HookInput.prototype, {
cancelable: true,
});
e.target.dispatchEvent(mouseEvent);
- },
+ }
- keyup: function(e) {
+ keyup(e) {
if (this.hasRemovedEvents) return;
this.keyEvent(e, 'keyup.dl');
- },
+ }
- keydown: function(e) {
+ keydown(e) {
if (this.hasRemovedEvents) return;
this.keyEvent(e, 'keydown.dl');
- },
+ }
- keyEvent: function(e, eventName) {
+ keyEvent(e, eventName) {
this.list.show();
const keyEvent = new CustomEvent(eventName, {
@@ -96,17 +94,17 @@ Object.assign(HookInput.prototype, {
cancelable: true,
});
e.target.dispatchEvent(keyEvent);
- },
+ }
- restoreInitialState: function() {
+ restoreInitialState() {
this.list.list.innerHTML = this.list.initialState;
- },
+ }
- removePlugins: function() {
+ removePlugins() {
this.plugins.forEach(plugin => plugin.destroy());
- },
+ }
- destroy: function() {
+ destroy() {
this.restoreInitialState();
this.removeEvents();
@@ -114,6 +112,6 @@ Object.assign(HookInput.prototype, {
this.list.destroy();
}
-});
+}
export default HookInput;
diff --git a/app/assets/javascripts/droplab/keyboard.js b/app/assets/javascripts/droplab/keyboard.js
index 36740a430e1..02f1b805ce4 100644
--- a/app/assets/javascripts/droplab/keyboard.js
+++ b/app/assets/javascripts/droplab/keyboard.js
@@ -8,7 +8,7 @@ const Keyboard = function () {
var isUpArrow = false;
var isDownArrow = false;
var removeHighlight = function removeHighlight(list) {
- var itemElements = Array.prototype.slice.call(list.list.querySelectorAll('li:not(.divider)'), 0);
+ var itemElements = Array.prototype.slice.call(list.list.querySelectorAll('li:not(.divider):not(.hidden)'), 0);
var listItems = [];
for(var i = 0; i < itemElements.length; i++) {
var listItem = itemElements[i];
diff --git a/app/assets/javascripts/droplab/plugins/ajax.js b/app/assets/javascripts/droplab/plugins/ajax.js
index 12afe53ed76..c0da5866139 100644
--- a/app/assets/javascripts/droplab/plugins/ajax.js
+++ b/app/assets/javascripts/droplab/plugins/ajax.js
@@ -1,25 +1,8 @@
/* eslint-disable */
+import AjaxCache from '~/lib/utils/ajax_cache';
+
const Ajax = {
- _loadUrlData: function _loadUrlData(url) {
- var self = this;
- return new Promise(function(resolve, reject) {
- var xhr = new XMLHttpRequest;
- xhr.open('GET', url, true);
- xhr.onreadystatechange = function () {
- if(xhr.readyState === XMLHttpRequest.DONE) {
- if (xhr.status === 200) {
- var data = JSON.parse(xhr.responseText);
- self.cache[url] = data;
- return resolve(data);
- } else {
- return reject([xhr.responseText, xhr.status]);
- }
- }
- };
- xhr.send();
- });
- },
_loadData: function _loadData(data, config, self) {
if (config.loadingTemplate) {
var dataLoadingTemplate = self.hook.list.list.querySelector('[data-loading-template]');
@@ -31,7 +14,6 @@ const Ajax = {
init: function init(hook) {
var self = this;
self.destroyed = false;
- self.cache = self.cache || {};
var config = hook.config.Ajax;
this.hook = hook;
if (!config || !config.endpoint || !config.method) {
@@ -48,14 +30,10 @@ const Ajax = {
this.listTemplate = dynamicList.outerHTML;
dynamicList.outerHTML = loadingTemplate.outerHTML;
}
- if (self.cache[config.endpoint]) {
- self._loadData(self.cache[config.endpoint], config, self);
- } else {
- this._loadUrlData(config.endpoint)
- .then(function(d) {
- self._loadData(d, config, self);
- }, config.onError).catch(config.onError);
- }
+
+ AjaxCache.retrieve(config.endpoint)
+ .then((data) => self._loadData(data, config, self))
+ .catch(config.onError);
},
destroy: function() {
this.destroyed = true;
diff --git a/app/assets/javascripts/droplab/plugins/ajax_filter.js b/app/assets/javascripts/droplab/plugins/ajax_filter.js
index cfd7e2ca189..1db20227a16 100644
--- a/app/assets/javascripts/droplab/plugins/ajax_filter.js
+++ b/app/assets/javascripts/droplab/plugins/ajax_filter.js
@@ -1,4 +1,5 @@
/* eslint-disable */
+import AjaxCache from '../../lib/utils/ajax_cache';
const AjaxFilter = {
init: function(hook) {
@@ -58,50 +59,27 @@ const AjaxFilter = {
this.loading = true;
var params = config.params || {};
params[config.searchKey] = searchValue;
- var self = this;
- self.cache = self.cache || {};
var url = config.endpoint + this.buildParams(params);
- var urlCachedData = self.cache[url];
- if (urlCachedData) {
- self._loadData(urlCachedData, config, self);
- } else {
- this._loadUrlData(url)
- .then(function(data) {
- self._loadData(data, config, self);
- }, config.onError).catch(config.onError);
- }
- },
-
- _loadUrlData: function _loadUrlData(url) {
- var self = this;
- return new Promise(function(resolve, reject) {
- var xhr = new XMLHttpRequest;
- xhr.open('GET', url, true);
- xhr.onreadystatechange = function () {
- if(xhr.readyState === XMLHttpRequest.DONE) {
- if (xhr.status === 200) {
- var data = JSON.parse(xhr.responseText);
- self.cache[url] = data;
- return resolve(data);
- } else {
- return reject([xhr.responseText, xhr.status]);
- }
+ return AjaxCache.retrieve(url)
+ .then((data) => {
+ this._loadData(data, config);
+ if (config.onLoadingFinished) {
+ config.onLoadingFinished(data);
}
- };
- xhr.send();
- });
+ })
+ .catch(config.onError);
},
- _loadData: function _loadData(data, config, self) {
- const list = self.hook.list;
+ _loadData(data, config) {
+ const list = this.hook.list;
if (config.loadingTemplate && list.data === undefined ||
list.data.length === 0) {
const dataLoadingTemplate = list.list.querySelector('[data-loading-template]');
if (dataLoadingTemplate) {
- dataLoadingTemplate.outerHTML = self.listTemplate;
+ dataLoadingTemplate.outerHTML = this.listTemplate;
}
}
- if (!self.destroyed) {
+ if (!this.destroyed) {
var hookListChildren = list.list.children;
var onlyDynamicList = hookListChildren.length === 1 && hookListChildren[0].hasAttribute('data-dynamic');
if (onlyDynamicList && data.length === 0) {
@@ -109,7 +87,7 @@ const AjaxFilter = {
}
list.setData.call(list, data);
}
- self.notLoading();
+ this.notLoading();
list.currentIndex = 0;
},
diff --git a/app/assets/javascripts/droplab/utils.js b/app/assets/javascripts/droplab/utils.js
index c149a33a1e9..4da7344604e 100644
--- a/app/assets/javascripts/droplab/utils.js
+++ b/app/assets/javascripts/droplab/utils.js
@@ -1,19 +1,19 @@
/* eslint-disable */
-import { DATA_TRIGGER, DATA_DROPDOWN } from './constants';
+import { template as _template } from 'underscore';
+import { DATA_TRIGGER, DATA_DROPDOWN, TEMPLATE_REGEX } from './constants';
const utils = {
toCamelCase(attr) {
return this.camelize(attr.split('-').slice(1).join(' '));
},
- t(s, d) {
- for (const p in d) {
- if (Object.prototype.hasOwnProperty.call(d, p)) {
- s = s.replace(new RegExp(`{{${p}}}`, 'g'), d[p]);
- }
- }
- return s;
+ template(templateString, data) {
+ const template = _template(templateString, {
+ escape: TEMPLATE_REGEX,
+ });
+
+ return template(data);
},
camelize(str) {
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index b3a76fbb43e..111449bb8f7 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -1,108 +1,158 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, max-len, one-var, no-var, one-var-declaration-per-line, no-unused-vars, camelcase, quotes, no-useless-concat, prefer-template, quote-props, comma-dangle, object-shorthand, consistent-return, prefer-arrow-callback */
/* global Dropzone */
-require('./preview_markdown');
+import './preview_markdown';
window.DropzoneInput = (function() {
function DropzoneInput(form) {
- var $mdArea, alertAttr, alertClass, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divAlert, divHover, divSpinner, dropzone, form_dropzone, form_textarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, max_file_size, pasteText, uploads_path, showError, showSpinner, uploadFile, uploadProgress;
+ var updateAttachingMessage, $attachingFileMessage, $mdArea, $attachButton, $cancelButton, $retryLink, $uploadingErrorContainer, $uploadingErrorMessage, $uploadProgress, $uploadingProgressContainer, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divHover, divSpinner, dropzone, $formDropzone, formTextarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, maxFileSize, pasteText, uploadsPath, showError, showSpinner, uploadFile;
Dropzone.autoDiscover = false;
- alertClass = "alert alert-danger alert-dismissable div-dropzone-alert";
- alertAttr = "class=\"close\" data-dismiss=\"alert\"" + "aria-hidden=\"true\"";
- divHover = "<div class=\"div-dropzone-hover\"></div>";
- divSpinner = "<div class=\"div-dropzone-spinner\"></div>";
- divAlert = "<div class=\"" + alertClass + "\"></div>";
- iconPaperclip = "<i class=\"fa fa-paperclip div-dropzone-icon\"></i>";
- iconSpinner = "<i class=\"fa fa-spinner fa-spin div-dropzone-icon\"></i>";
- uploadProgress = $("<div class=\"div-dropzone-progress\"></div>");
- btnAlert = "<button type=\"button\"" + alertAttr + ">&times;</button>";
- uploads_path = window.uploads_path || null;
- max_file_size = gon.max_file_size || 10;
- form_textarea = $(form).find(".js-gfm-input");
- form_textarea.wrap("<div class=\"div-dropzone\"></div>");
- form_textarea.on('paste', (function(_this) {
+ divHover = '<div class="div-dropzone-hover"></div>';
+ iconPaperclip = '<i class="fa fa-paperclip div-dropzone-icon"></i>';
+ $attachButton = form.find('.button-attach-file');
+ $attachingFileMessage = form.find('.attaching-file-message');
+ $cancelButton = form.find('.button-cancel-uploading-files');
+ $retryLink = form.find('.retry-uploading-link');
+ $uploadProgress = form.find('.uploading-progress');
+ $uploadingErrorContainer = form.find('.uploading-error-container');
+ $uploadingErrorMessage = form.find('.uploading-error-message');
+ $uploadingProgressContainer = form.find('.uploading-progress-container');
+ uploadsPath = window.uploads_path || null;
+ maxFileSize = gon.max_file_size || 10;
+ formTextarea = form.find('.js-gfm-input');
+ formTextarea.wrap('<div class="div-dropzone"></div>');
+ formTextarea.on('paste', (function(_this) {
return function(event) {
return handlePaste(event);
};
})(this));
- $mdArea = $(form_textarea).closest('.md-area');
- $(form).setupMarkdownPreview();
- form_dropzone = $(form).find('.div-dropzone');
- form_dropzone.parent().addClass("div-dropzone-wrapper");
- form_dropzone.append(divHover);
- form_dropzone.find(".div-dropzone-hover").append(iconPaperclip);
- form_dropzone.append(divSpinner);
- form_dropzone.find(".div-dropzone-spinner").append(iconSpinner);
- form_dropzone.find(".div-dropzone-spinner").append(uploadProgress);
- form_dropzone.find(".div-dropzone-spinner").css({
- "opacity": 0,
- "display": "none"
- });
- if (!uploads_path) return;
+ // Add dropzone area to the form.
+ $mdArea = formTextarea.closest('.md-area');
+ form.setupMarkdownPreview();
+ $formDropzone = form.find('.div-dropzone');
+ $formDropzone.parent().addClass('div-dropzone-wrapper');
+ $formDropzone.append(divHover);
+ $formDropzone.find('.div-dropzone-hover').append(iconPaperclip);
+
+ if (!uploadsPath) return;
- dropzone = form_dropzone.dropzone({
- url: uploads_path,
- dictDefaultMessage: "",
+ dropzone = $formDropzone.dropzone({
+ url: uploadsPath,
+ dictDefaultMessage: '',
clickable: true,
- paramName: "file",
- maxFilesize: max_file_size,
+ paramName: 'file',
+ maxFilesize: maxFileSize,
uploadMultiple: false,
headers: {
- "X-CSRF-Token": $("meta[name=\"csrf-token\"]").attr("content")
+ 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content')
},
previewContainer: false,
processing: function() {
- return $(".div-dropzone-alert").alert("close");
+ return $('.div-dropzone-alert').alert('close');
},
dragover: function() {
$mdArea.addClass('is-dropzone-hover');
- form.find(".div-dropzone-hover").css("opacity", 0.7);
+ form.find('.div-dropzone-hover').css('opacity', 0.7);
},
dragleave: function() {
$mdArea.removeClass('is-dropzone-hover');
- form.find(".div-dropzone-hover").css("opacity", 0);
+ form.find('.div-dropzone-hover').css('opacity', 0);
},
drop: function() {
$mdArea.removeClass('is-dropzone-hover');
- form.find(".div-dropzone-hover").css("opacity", 0);
- form_textarea.focus();
+ form.find('.div-dropzone-hover').css('opacity', 0);
+ formTextarea.focus();
},
success: function(header, response) {
const processingFileCount = this.getQueuedFiles().length + this.getUploadingFiles().length;
const shouldPad = processingFileCount >= 1;
pasteText(response.link.markdown, shouldPad);
+ // Show 'Attach a file' link only when all files have been uploaded.
+ if (!processingFileCount) $attachButton.removeClass('hide');
},
- error: function(temp) {
- var checkIfMsgExists, errorAlert;
- errorAlert = $(form).find('.error-alert');
- checkIfMsgExists = errorAlert.children().length;
- if (checkIfMsgExists === 0) {
- errorAlert.append(divAlert);
- $(".div-dropzone-alert").append(btnAlert + "Attaching the file failed.");
- }
+ error: function(file, errorMessage = 'Attaching the file failed.', xhr) {
+ // If 'error' event is fired by dropzone, the second parameter is error message.
+ // If the 'errorMessage' parameter is empty, the default error message is set.
+ // If the 'error' event is fired by backend (xhr) error response, the third parameter is
+ // xhr object (xhr.responseText is error message).
+ // On error we hide the 'Attach' and 'Cancel' buttons
+ // and show an error.
+
+ // If there's xhr error message, let's show it instead of dropzone's one.
+ const message = xhr ? xhr.responseText : errorMessage;
+
+ $uploadingErrorContainer.removeClass('hide');
+ $uploadingErrorMessage.html(message);
+ $attachButton.addClass('hide');
+ $cancelButton.addClass('hide');
},
totaluploadprogress: function(totalUploadProgress) {
- uploadProgress.text(Math.round(totalUploadProgress) + "%");
+ updateAttachingMessage(this.files, $attachingFileMessage);
+ $uploadProgress.text(Math.round(totalUploadProgress) + '%');
+ },
+ sending: function(file) {
+ // DOM elements already exist.
+ // Instead of dynamically generating them,
+ // we just either hide or show them.
+ $attachButton.addClass('hide');
+ $uploadingErrorContainer.addClass('hide');
+ $uploadingProgressContainer.removeClass('hide');
+ $cancelButton.removeClass('hide');
},
- sending: function() {
- form_dropzone.find(".div-dropzone-spinner").css({
- "opacity": 0.7,
- "display": "inherit"
- });
+ removedfile: function() {
+ $attachButton.removeClass('hide');
+ $cancelButton.addClass('hide');
+ $uploadingProgressContainer.addClass('hide');
+ $uploadingErrorContainer.addClass('hide');
},
queuecomplete: function() {
- uploadProgress.text("");
- $(".dz-preview").remove();
- $(".markdown-area").trigger("input");
- $(".div-dropzone-spinner").css({
- "opacity": 0,
- "display": "none"
- });
+ $('.dz-preview').remove();
+ $('.markdown-area').trigger('input');
+
+ $uploadingProgressContainer.addClass('hide');
+ $cancelButton.addClass('hide');
}
});
- child = $(dropzone[0]).children("textarea");
+
+ child = $(dropzone[0]).children('textarea');
+
+ // removeAllFiles(true) stops uploading files (if any)
+ // and remove them from dropzone files queue.
+ $cancelButton.on('click', (e) => {
+ const target = e.target.closest('form').querySelector('.div-dropzone');
+
+ e.preventDefault();
+ e.stopPropagation();
+ Dropzone.forElement(target).removeAllFiles(true);
+ });
+
+ // If 'error' event is fired, we store a failed files,
+ // clear dropzone files queue, change status of failed files to undefined,
+ // and add that files to the dropzone files queue again.
+ // addFile() adds file to dropzone files queue and upload it.
+ $retryLink.on('click', (e) => {
+ const dropzoneInstance = Dropzone.forElement(e.target.closest('form').querySelector('.div-dropzone'));
+ const failedFiles = dropzoneInstance.files;
+
+ e.preventDefault();
+
+ // 'true' parameter of removeAllFiles() cancels uploading of files that are being uploaded at the moment.
+ dropzoneInstance.removeAllFiles(true);
+
+ failedFiles.map((failedFile, i) => {
+ const file = failedFile;
+
+ if (file.status === Dropzone.ERROR) {
+ file.status = undefined;
+ file.accepted = undefined;
+ }
+
+ return dropzoneInstance.addFile(file);
+ });
+ });
+
handlePaste = function(event) {
var filename, image, pasteEvent, text;
pasteEvent = event.originalEvent;
@@ -110,25 +160,27 @@ window.DropzoneInput = (function() {
image = isImage(pasteEvent);
if (image) {
event.preventDefault();
- filename = getFilename(pasteEvent) || "image.png";
- text = "{{" + filename + "}}";
+ filename = getFilename(pasteEvent) || 'image.png';
+ text = `{{${filename}}}`;
pasteText(text);
return uploadFile(image.getAsFile(), filename);
}
}
};
+
isImage = function(data) {
var i, item;
i = 0;
while (i < data.clipboardData.items.length) {
item = data.clipboardData.items[i];
- if (item.type.indexOf("image") !== -1) {
+ if (item.type.indexOf('image') !== -1) {
return item;
}
i += 1;
}
return false;
};
+
pasteText = function(text, shouldPad) {
var afterSelection, beforeSelection, caretEnd, caretStart, textEnd;
var formattedText = text;
@@ -142,31 +194,34 @@ window.DropzoneInput = (function() {
$(child).val(beforeSelection + formattedText + afterSelection);
textarea.setSelectionRange(caretStart + formattedText.length, caretEnd + formattedText.length);
textarea.style.height = `${textarea.scrollHeight}px`;
- return form_textarea.trigger("input");
+ formTextarea.get(0).dispatchEvent(new Event('input'));
+ return formTextarea.trigger('input');
};
+
getFilename = function(e) {
var value;
if (window.clipboardData && window.clipboardData.getData) {
- value = window.clipboardData.getData("Text");
+ value = window.clipboardData.getData('Text');
} else if (e.clipboardData && e.clipboardData.getData) {
- value = e.clipboardData.getData("text/plain");
+ value = e.clipboardData.getData('text/plain');
}
value = value.split("\r");
return value.first();
};
+
uploadFile = function(item, filename) {
var formData;
formData = new FormData();
- formData.append("file", item, filename);
+ formData.append('file', item, filename);
return $.ajax({
- url: uploads_path,
- type: "POST",
+ url: uploadsPath,
+ type: 'POST',
data: formData,
- dataType: "json",
+ dataType: 'json',
processData: false,
contentType: false,
headers: {
- "X-CSRF-Token": $("meta[name=\"csrf-token\"]").attr("content")
+ 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content')
},
beforeSend: function() {
showSpinner();
@@ -183,44 +238,54 @@ window.DropzoneInput = (function() {
}
});
};
+
+ updateAttachingMessage = (files, messageContainer) => {
+ let attachingMessage;
+ const filesCount = files.filter(function(file) {
+ return file.status === 'uploading' ||
+ file.status === 'queued';
+ }).length;
+
+ // Dinamycally change uploading files text depending on files number in
+ // dropzone files queue.
+ if (filesCount > 1) {
+ attachingMessage = 'Attaching ' + filesCount + ' files -';
+ } else {
+ attachingMessage = 'Attaching a file -';
+ }
+
+ messageContainer.text(attachingMessage);
+ };
+
insertToTextArea = function(filename, url) {
return $(child).val(function(index, val) {
- return val.replace("{{" + filename + "}}", url);
+ return val.replace(`{{${filename}}}`, url);
});
};
+
appendToTextArea = function(url) {
return $(child).val(function(index, val) {
return val + url + "\n";
});
};
+
showSpinner = function(e) {
- return form.find(".div-dropzone-spinner").css({
- "opacity": 0.7,
- "display": "inherit"
- });
+ return $uploadingProgressContainer.removeClass('hide');
};
+
closeSpinner = function() {
- return form.find(".div-dropzone-spinner").css({
- "opacity": 0,
- "display": "none"
- });
+ return $uploadingProgressContainer.addClass('hide');
};
+
showError = function(message) {
- var checkIfMsgExists, errorAlert;
- errorAlert = $(form).find('.error-alert');
- checkIfMsgExists = errorAlert.children().length;
- if (checkIfMsgExists === 0) {
- errorAlert.append(divAlert);
- return $(".div-dropzone-alert").append(btnAlert + message);
- }
+ $uploadingErrorContainer.removeClass('hide');
+ $uploadingErrorMessage.html(message);
};
- closeAlertMessage = function() {
- return form.find(".div-dropzone-alert").alert("close");
- };
- form.find(".markdown-selector").click(function(e) {
+
+ form.find('.markdown-selector').click(function(e) {
e.preventDefault();
$(this).closest('.gfm-form').find('.div-dropzone').click();
- form_textarea.focus();
+ formTextarea.focus();
});
}
diff --git a/app/assets/javascripts/environments/components/environment.vue b/app/assets/javascripts/environments/components/environment.vue
index e0088d496eb..86d8fe89010 100644
--- a/app/assets/javascripts/environments/components/environment.vue
+++ b/app/assets/javascripts/environments/components/environment.vue
@@ -1,19 +1,28 @@
<script>
/* global Flash */
+import Visibility from 'visibilityjs';
import EnvironmentsService from '../services/environments_service';
-import EnvironmentTable from './environments_table.vue';
+import environmentTable from './environments_table.vue';
import EnvironmentsStore from '../stores/environments_store';
-import TablePaginationComponent from '../../vue_shared/components/table_pagination';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import tablePagination from '../../vue_shared/components/table_pagination.vue';
import '../../lib/utils/common_utils';
import eventHub from '../event_hub';
+import Poll from '../../lib/utils/poll';
+import environmentsMixin from '../mixins/environments_mixin';
export default {
components: {
- 'environment-table': EnvironmentTable,
- 'table-pagination': TablePaginationComponent,
+ environmentTable,
+ tablePagination,
+ loadingIcon,
},
+ mixins: [
+ environmentsMixin,
+ ],
+
data() {
const environmentsData = document.querySelector('#environments-list-view').dataset;
const store = new EnvironmentsStore();
@@ -33,6 +42,7 @@ export default {
projectStoppedEnvironmentsPath: environmentsData.projectStoppedEnvironmentsPath,
newEnvironmentPath: environmentsData.newEnvironmentPath,
helpPagePath: environmentsData.helpPagePath,
+ isMakingRequest: false,
// Pagination Properties,
paginationInformation: {},
@@ -63,17 +73,43 @@ export default {
* Toggles loading property.
*/
created() {
+ const scope = gl.utils.getParameterByName('scope') || this.visibility;
+ const page = gl.utils.getParameterByName('page') || this.pageNumber;
+
this.service = new EnvironmentsService(this.endpoint);
- this.fetchEnvironments();
+ const poll = new Poll({
+ resource: this.service,
+ method: 'get',
+ data: { scope, page },
+ successCallback: this.successCallback,
+ errorCallback: this.errorCallback,
+ notificationCallback: (isMakingRequest) => {
+ this.isMakingRequest = isMakingRequest;
+
+ // We need to verify if any folder is open to also fecth it
+ this.openFolders = this.store.getOpenFolders();
+ },
+ });
+
+ if (!Visibility.hidden()) {
+ this.isLoading = true;
+ poll.makeRequest();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ poll.restart();
+ } else {
+ poll.stop();
+ }
+ });
- eventHub.$on('refreshEnvironments', this.fetchEnvironments);
eventHub.$on('toggleFolder', this.toggleFolder);
eventHub.$on('postAction', this.postAction);
},
beforeDestroyed() {
- eventHub.$off('refreshEnvironments');
eventHub.$off('toggleFolder');
eventHub.$off('postAction');
},
@@ -102,29 +138,13 @@ export default {
fetchEnvironments() {
const scope = gl.utils.getParameterByName('scope') || this.visibility;
- const pageNumber = gl.utils.getParameterByName('page') || this.pageNumber;
+ const page = gl.utils.getParameterByName('page') || this.pageNumber;
this.isLoading = true;
- return this.service.get(scope, pageNumber)
- .then(resp => ({
- headers: resp.headers,
- body: resp.json(),
- }))
- .then((response) => {
- this.store.storeAvailableCount(response.body.available_count);
- this.store.storeStoppedCount(response.body.stopped_count);
- this.store.storeEnvironments(response.body.environments);
- this.store.setPagination(response.headers);
- })
- .then(() => {
- this.isLoading = false;
- })
- .catch(() => {
- this.isLoading = false;
- // eslint-disable-next-line no-new
- new Flash('An error occurred while fetching the environments.');
- });
+ return this.service.get({ scope, page })
+ .then(this.successCallback)
+ .catch(this.errorCallback);
},
fetchChildEnvironments(folder, folderUrl) {
@@ -144,9 +164,34 @@ export default {
},
postAction(endpoint) {
- this.service.postAction(endpoint)
- .then(() => this.fetchEnvironments())
- .catch(() => new Flash('An error occured while making the request.'));
+ if (!this.isMakingRequest) {
+ this.isLoading = true;
+
+ this.service.postAction(endpoint)
+ .then(() => this.fetchEnvironments())
+ .catch(() => new Flash('An error occured while making the request.'));
+ }
+ },
+
+ successCallback(resp) {
+ this.saveData(resp);
+
+ // If folders are open while polling we need to open them again
+ if (this.openFolders.length) {
+ this.openFolders.map((folder) => {
+ // TODO - Move this to the backend
+ const folderUrl = `${window.location.pathname}/folders/${folder.folderName}`;
+
+ this.store.updateFolder(folder, 'isOpen', true);
+ return this.fetchChildEnvironments(folder, folderUrl);
+ });
+ }
+ },
+
+ errorCallback() {
+ this.isLoading = false;
+ // eslint-disable-next-line no-new
+ new Flash('An error occurred while fetching the environments.');
},
},
};
@@ -186,14 +231,11 @@ export default {
</div>
<div class="content-list environments-container">
- <div
- class="environments-list-loading text-center"
- v-if="isLoading">
-
- <i
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
- </div>
+ <loading-icon
+ label="Loading environments"
+ size="3"
+ v-if="isLoading"
+ />
<div
class="blank-state blank-state-no-icon"
diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue
index 63bffe8a998..a2448520a5f 100644
--- a/app/assets/javascripts/environments/components/environment_actions.vue
+++ b/app/assets/javascripts/environments/components/environment_actions.vue
@@ -1,6 +1,7 @@
<script>
import playIconSvg from 'icons/_icon_play.svg';
import eventHub from '../event_hub';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
props: {
@@ -11,6 +12,10 @@ export default {
},
},
+ components: {
+ loadingIcon,
+ },
+
data() {
return {
playIconSvg,
@@ -61,10 +66,7 @@ export default {
<i
class="fa fa-caret-down"
aria-hidden="true"/>
- <i
- v-if="isLoading"
- class="fa fa-spinner fa-spin"
- aria-hidden="true"/>
+ <loading-icon v-if="isLoading" />
</span>
</button>
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index 0ffe9ea17fa..012ff1f975b 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -1,5 +1,7 @@
<script>
import Timeago from 'timeago.js';
+import _ from 'underscore';
+import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import '../../lib/utils/text_utility';
import ActionsComponent from './environment_actions.vue';
import ExternalUrlComponent from './environment_external_url.vue';
@@ -19,6 +21,7 @@ const timeagoInstance = new Timeago();
export default {
components: {
+ userAvatarLink,
'commit-component': CommitComponent,
'actions-component': ActionsComponent,
'external-url-component': ExternalUrlComponent,
@@ -59,7 +62,7 @@ export default {
hasLastDeploymentKey() {
if (this.model &&
this.model.last_deployment &&
- !this.$options.isObjectEmpty(this.model.last_deployment)) {
+ !_.isEmpty(this.model.last_deployment)) {
return true;
}
return false;
@@ -310,8 +313,8 @@ export default {
*/
deploymentHasUser() {
return this.model &&
- !this.$options.isObjectEmpty(this.model.last_deployment) &&
- !this.$options.isObjectEmpty(this.model.last_deployment.user);
+ !_.isEmpty(this.model.last_deployment) &&
+ !_.isEmpty(this.model.last_deployment.user);
},
/**
@@ -322,8 +325,8 @@ export default {
*/
deploymentUser() {
if (this.model &&
- !this.$options.isObjectEmpty(this.model.last_deployment) &&
- !this.$options.isObjectEmpty(this.model.last_deployment.user)) {
+ !_.isEmpty(this.model.last_deployment) &&
+ !_.isEmpty(this.model.last_deployment.user)) {
return this.model.last_deployment.user;
}
return {};
@@ -338,8 +341,8 @@ export default {
*/
shouldRenderBuildName() {
return !this.model.isFolder &&
- !this.$options.isObjectEmpty(this.model.last_deployment) &&
- !this.$options.isObjectEmpty(this.model.last_deployment.deployable);
+ !_.isEmpty(this.model.last_deployment) &&
+ !_.isEmpty(this.model.last_deployment.deployable);
},
/**
@@ -380,7 +383,7 @@ export default {
*/
shouldRenderDeploymentID() {
return !this.model.isFolder &&
- !this.$options.isObjectEmpty(this.model.last_deployment) &&
+ !_.isEmpty(this.model.last_deployment) &&
this.model.last_deployment.iid !== undefined;
},
@@ -410,21 +413,6 @@ export default {
},
},
- /**
- * Helper to verify if certain given object are empty.
- * Should be replaced by lodash _.isEmpty - https://lodash.com/docs/4.17.2#isEmpty
- * @param {Object} object
- * @returns {Bollean}
- */
- isObjectEmpty(object) {
- for (const key in object) { // eslint-disable-line
- if (hasOwnProperty.call(object, key)) {
- return false;
- }
- }
- return true;
- },
-
methods: {
onClickFolder() {
eventHub.$emit('toggleFolder', this.model, this.folderUrl);
@@ -482,15 +470,13 @@ export default {
<span v-if="!model.isFolder && deploymentHasUser">
by
- <a
- :href="deploymentUser.web_url"
- class="js-deploy-user-container">
- <img
- class="avatar has-tooltip s20"
- :src="deploymentUser.avatar_url"
- :alt="userImageAltDescription"
- :title="deploymentUser.username" />
- </a>
+ <user-avatar-link
+ class="js-deploy-user-container"
+ :link-href="deploymentUser.web_url"
+ :img-src="deploymentUser.avatar_url"
+ :img-alt="userImageAltDescription"
+ :tooltip-text="deploymentUser.username"
+ />
</span>
</td>
diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue
index 4b030a27900..79c019b3491 100644
--- a/app/assets/javascripts/environments/components/environment_monitoring.vue
+++ b/app/assets/javascripts/environments/components/environment_monitoring.vue
@@ -21,7 +21,6 @@ export default {
<a
class="btn monitoring-url has-tooltip"
data-container="body"
- target="_blank"
rel="noopener noreferrer nofollow"
:href="monitoringUrl"
:title="title"
diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue
index 44b8730fd09..2ba985bfe3e 100644
--- a/app/assets/javascripts/environments/components/environment_rollback.vue
+++ b/app/assets/javascripts/environments/components/environment_rollback.vue
@@ -6,6 +6,7 @@
* Makes a post request when the button is clicked.
*/
import eventHub from '../event_hub';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
props: {
@@ -20,6 +21,10 @@ export default {
},
},
+ components: {
+ loadingIcon,
+ },
+
data() {
return {
isLoading: false,
@@ -49,9 +54,6 @@ export default {
Rollback
</span>
- <i
- v-if="isLoading"
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
+ <loading-icon v-if="isLoading" />
</button>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue
index f483ea7e937..a904453ffa9 100644
--- a/app/assets/javascripts/environments/components/environment_stop.vue
+++ b/app/assets/javascripts/environments/components/environment_stop.vue
@@ -4,6 +4,7 @@
* Used in environments table.
*/
import eventHub from '../event_hub';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
props: {
@@ -19,6 +20,10 @@ export default {
};
},
+ components: {
+ loadingIcon,
+ },
+
computed: {
title() {
return 'Stop';
@@ -51,9 +56,6 @@ export default {
<i
class="fa fa-stop stop-env-icon"
aria-hidden="true" />
- <i
- v-if="isLoading"
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
+ <loading-icon v-if="isLoading" />
</button>
</template>
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index 15eedaf76e1..5148a2ae79b 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -3,10 +3,12 @@
* Render environments table.
*/
import EnvironmentTableRowComponent from './environment_item.vue';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
components: {
'environment-item': EnvironmentTableRowComponent,
+ loadingIcon,
},
props: {
@@ -77,10 +79,8 @@ export default {
<template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0">
<tr v-if="isLoadingFolderContent">
- <td colspan="6" class="text-center">
- <i
- class="fa fa-spin fa-spinner fa-2x"
- aria-hidden="true" />
+ <td colspan="6">
+ <loading-icon size="2" />
</td>
</tr>
diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue
index f4a0c390c91..925503a01c4 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_view.vue
+++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue
@@ -1,18 +1,27 @@
<script>
/* global Flash */
+import Visibility from 'visibilityjs';
import EnvironmentsService from '../services/environments_service';
-import EnvironmentTable from '../components/environments_table.vue';
+import environmentTable from '../components/environments_table.vue';
import EnvironmentsStore from '../stores/environments_store';
-import TablePaginationComponent from '../../vue_shared/components/table_pagination';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import tablePagination from '../../vue_shared/components/table_pagination.vue';
+import Poll from '../../lib/utils/poll';
+import eventHub from '../event_hub';
+import environmentsMixin from '../mixins/environments_mixin';
import '../../lib/utils/common_utils';
-import '../../vue_shared/vue_resource_interceptor';
export default {
components: {
- 'environment-table': EnvironmentTable,
- 'table-pagination': TablePaginationComponent,
+ environmentTable,
+ tablePagination,
+ loadingIcon,
},
+ mixins: [
+ environmentsMixin,
+ ],
+
data() {
const environmentsData = document.querySelector('#environments-folder-list-view').dataset;
const store = new EnvironmentsStore();
@@ -74,33 +83,39 @@ export default {
*/
created() {
const scope = gl.utils.getParameterByName('scope') || this.visibility;
- const pageNumber = gl.utils.getParameterByName('page') || this.pageNumber;
-
- const endpoint = `${this.endpoint}?scope=${scope}&page=${pageNumber}`;
-
- this.service = new EnvironmentsService(endpoint);
-
- this.isLoading = true;
-
- return this.service.get()
- .then(resp => ({
- headers: resp.headers,
- body: resp.json(),
- }))
- .then((response) => {
- this.store.storeAvailableCount(response.body.available_count);
- this.store.storeStoppedCount(response.body.stopped_count);
- this.store.storeEnvironments(response.body.environments);
- this.store.setPagination(response.headers);
- })
- .then(() => {
- this.isLoading = false;
- })
- .catch(() => {
- this.isLoading = false;
- // eslint-disable-next-line no-new
- new Flash('An error occurred while fetching the environments.', 'alert');
- });
+ const page = gl.utils.getParameterByName('page') || this.pageNumber;
+
+ this.service = new EnvironmentsService(this.endpoint);
+
+ const poll = new Poll({
+ resource: this.service,
+ method: 'get',
+ data: { scope, page },
+ successCallback: this.successCallback,
+ errorCallback: this.errorCallback,
+ notificationCallback: (isMakingRequest) => {
+ this.isMakingRequest = isMakingRequest;
+ },
+ });
+
+ if (!Visibility.hidden()) {
+ this.isLoading = true;
+ poll.makeRequest();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ poll.restart();
+ } else {
+ poll.stop();
+ }
+ });
+
+ eventHub.$on('postAction', this.postAction);
+ },
+
+ beforeDestroyed() {
+ eventHub.$off('postAction');
},
methods: {
@@ -115,6 +130,37 @@ export default {
gl.utils.visitUrl(param);
return param;
},
+
+ fetchEnvironments() {
+ const scope = gl.utils.getParameterByName('scope') || this.visibility;
+ const page = gl.utils.getParameterByName('page') || this.pageNumber;
+
+ this.isLoading = true;
+
+ return this.service.get({ scope, page })
+ .then(this.successCallback)
+ .catch(this.errorCallback);
+ },
+
+ successCallback(resp) {
+ this.saveData(resp);
+ },
+
+ errorCallback() {
+ this.isLoading = false;
+ // eslint-disable-next-line no-new
+ new Flash('An error occurred while fetching the environments.');
+ },
+
+ postAction(endpoint) {
+ if (!this.isMakingRequest) {
+ this.isLoading = true;
+
+ this.service.postAction(endpoint)
+ .then(() => this.fetchEnvironments())
+ .catch(() => new Flash('An error occured while making the request.'));
+ }
+ },
},
};
</script>
@@ -153,13 +199,12 @@ export default {
</div>
<div class="environments-container">
- <div
- class="environments-list-loading text-center"
- v-if="isLoading">
- <i
- class="fa fa-spinner fa-spin"
- aria-hidden="true"/>
- </div>
+
+ <loading-icon
+ label="Loading environments"
+ v-if="isLoading"
+ size="3"
+ />
<div
class="table-holder"
diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js
new file mode 100644
index 00000000000..25b24fbd6dc
--- /dev/null
+++ b/app/assets/javascripts/environments/mixins/environments_mixin.js
@@ -0,0 +1,17 @@
+export default {
+ methods: {
+ saveData(resp) {
+ const response = {
+ headers: resp.headers,
+ body: resp.json(),
+ };
+
+ this.isLoading = false;
+
+ this.store.storeAvailableCount(response.body.available_count);
+ this.store.storeStoppedCount(response.body.stopped_count);
+ this.store.storeEnvironments(response.body.environments);
+ this.store.setPagination(response.headers);
+ },
+ },
+};
diff --git a/app/assets/javascripts/environments/services/environments_service.js b/app/assets/javascripts/environments/services/environments_service.js
index 8adb53ea86d..03ab74b3338 100644
--- a/app/assets/javascripts/environments/services/environments_service.js
+++ b/app/assets/javascripts/environments/services/environments_service.js
@@ -10,7 +10,8 @@ export default class EnvironmentsService {
this.folderResults = 3;
}
- get(scope, page) {
+ get(options = {}) {
+ const { scope, page } = options;
return this.environments.get({ scope, page });
}
diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js
index 158e7922e3c..8a2f6a473de 100644
--- a/app/assets/javascripts/environments/stores/environments_store.js
+++ b/app/assets/javascripts/environments/stores/environments_store.js
@@ -153,4 +153,10 @@ export default class EnvironmentsStore {
return updatedEnvironments;
}
+ getOpenFolders() {
+ const environments = this.state.environments;
+
+ return environments.filter(env => env.isFolder && env.isOpen);
+ }
+
}
diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js
index 59d6508fc02..534e651b030 100644
--- a/app/assets/javascripts/files_comment_button.js
+++ b/app/assets/javascripts/files_comment_button.js
@@ -3,7 +3,6 @@
/* global notes */
let $commentButtonTemplate;
-var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
window.FilesCommentButton = (function() {
var COMMENT_BUTTON_CLASS, EMPTY_CELL_CLASS, LINE_COLUMN_CLASSES, LINE_CONTENT_CLASS, LINE_HOLDER_CLASS, LINE_NUMBER_CLASS, OLD_LINE_CLASS, TEXT_FILE_SELECTOR, UNFOLDABLE_LINE_CLASS;
@@ -27,8 +26,8 @@ window.FilesCommentButton = (function() {
TEXT_FILE_SELECTOR = '.text-file';
function FilesCommentButton(filesContainerElement) {
- this.render = bind(this.render, this);
- this.hideButton = bind(this.hideButton, this);
+ this.render = this.render.bind(this);
+ this.hideButton = this.hideButton.bind(this);
this.isParallelView = notes.isParallelView();
filesContainerElement.on('mouseover', LINE_COLUMN_CLASSES, this.render)
.on('mouseleave', LINE_COLUMN_CLASSES, this.hideButton);
diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js
index 9126422b335..c51d4b056af 100644
--- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js
+++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js
@@ -8,13 +8,22 @@ export default {
type: Array,
required: true,
},
+ isLocalStorageAvailable: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ allowedKeys: {
+ type: Array,
+ required: true,
+ },
},
computed: {
processedItems() {
return this.items.map((item) => {
const { tokens, searchToken }
- = gl.FilteredSearchTokenizer.processTokens(item);
+ = gl.FilteredSearchTokenizer.processTokens(item, this.allowedKeys);
const resultantTokens = tokens.map(token => ({
prefix: `${token.key}:`,
@@ -47,7 +56,12 @@ export default {
template: `
<div>
- <ul v-if="hasItems">
+ <div
+ v-if="!isLocalStorageAvailable"
+ class="dropdown-info-note">
+ This feature requires local storage to be enabled
+ </div>
+ <ul v-else-if="hasItems">
<li
v-for="(item, index) in processedItems"
:key="index">
diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js
index 3e7a892756c..2af242a69df 100644
--- a/app/assets/javascripts/filtered_search/dropdown_hint.js
+++ b/app/assets/javascripts/filtered_search/dropdown_hint.js
@@ -1,16 +1,19 @@
import Filter from '~/droplab/plugins/filter';
-
-require('./filtered_search_dropdown');
+import './filtered_search_dropdown';
class DropdownHint extends gl.FilteredSearchDropdown {
- constructor(droplab, dropdown, input, filter) {
+ constructor(droplab, dropdown, input, tokenKeys, filter) {
super(droplab, dropdown, input, filter);
this.config = {
Filter: {
template: 'hint',
- filterFunction: gl.DropdownUtils.filterHint.bind(null, input),
+ filterFunction: gl.DropdownUtils.filterHint.bind(null, {
+ input,
+ allowedKeys: tokenKeys.getKeys(),
+ }),
},
};
+ this.tokenKeys = tokenKeys;
}
itemClicked(e) {
@@ -53,20 +56,13 @@ class DropdownHint extends gl.FilteredSearchDropdown {
}
renderContent() {
- const dropdownData = [];
-
- [].forEach.call(this.input.closest('.filtered-search-box-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => {
- const { icon, hint, tag, type } = dropdownMenu.dataset;
- if (icon && hint && tag) {
- dropdownData.push(
- Object.assign({
- icon: `fa-${icon}`,
- hint,
- tag: `&lt;${tag}&gt;`,
- }, type && { type }),
- );
- }
- });
+ const dropdownData = gl.FilteredSearchTokenKeys.get()
+ .map(tokenKey => ({
+ icon: `fa-${tokenKey.icon}`,
+ hint: tokenKey.key,
+ tag: `<${tokenKey.symbol}${tokenKey.key}>`,
+ type: tokenKey.type,
+ }));
this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config);
this.droplab.setData(this.hookId, dropdownData);
diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js
index 982dc4b61be..34a9e34070c 100644
--- a/app/assets/javascripts/filtered_search/dropdown_non_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js
@@ -2,11 +2,10 @@
import Ajax from '~/droplab/plugins/ajax';
import Filter from '~/droplab/plugins/filter';
-
-require('./filtered_search_dropdown');
+import './filtered_search_dropdown';
class DropdownNonUser extends gl.FilteredSearchDropdown {
- constructor(droplab, dropdown, input, filter, endpoint, symbol) {
+ constructor(droplab, dropdown, input, tokenKeys, filter, endpoint, symbol) {
super(droplab, dropdown, input, filter);
this.symbol = symbol;
this.config = {
diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js
index 74cec3d75fe..65c1b2050ac 100644
--- a/app/assets/javascripts/filtered_search/dropdown_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_user.js
@@ -1,11 +1,10 @@
/* global Flash */
import AjaxFilter from '~/droplab/plugins/ajax_filter';
-
-require('./filtered_search_dropdown');
+import './filtered_search_dropdown';
class DropdownUser extends gl.FilteredSearchDropdown {
- constructor(droplab, dropdown, input, filter) {
+ constructor(droplab, dropdown, input, tokenKeys, filter) {
super(droplab, dropdown, input, filter);
this.config = {
AjaxFilter: {
@@ -19,6 +18,9 @@ class DropdownUser extends gl.FilteredSearchDropdown {
},
searchValueFunction: this.getSearchInput.bind(this),
loadingTemplate: this.loadingTemplate,
+ onLoadingFinished: () => {
+ this.hideCurrentUser();
+ },
onError() {
/* eslint-disable no-new */
new Flash('An error occured fetching the dropdown data.');
@@ -26,6 +28,12 @@ class DropdownUser extends gl.FilteredSearchDropdown {
},
},
};
+ this.tokenKeys = tokenKeys;
+ }
+
+ hideCurrentUser() {
+ const currenUserItem = this.dropdown.querySelector('.js-current-user');
+ currenUserItem.classList.add('hidden');
}
itemClicked(e) {
@@ -44,7 +52,7 @@ class DropdownUser extends gl.FilteredSearchDropdown {
getSearchInput() {
const query = gl.DropdownUtils.getSearchInput(this.input);
- const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query);
+ const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get());
let value = lastToken || '';
diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js
index bc7c1dffece..5c02a7a53d3 100644
--- a/app/assets/javascripts/filtered_search/dropdown_utils.js
+++ b/app/assets/javascripts/filtered_search/dropdown_utils.js
@@ -50,10 +50,12 @@ class DropdownUtils {
return updatedItem;
}
- static filterHint(input, item) {
+ static filterHint(config, item) {
+ const { input, allowedKeys } = config;
const updatedItem = item;
const searchInput = gl.DropdownUtils.getSearchQuery(input);
- const { lastToken, tokens } = gl.FilteredSearchTokenizer.processTokens(searchInput);
+ const { lastToken, tokens } =
+ gl.FilteredSearchTokenizer.processTokens(searchInput, allowedKeys);
const lastKey = lastToken.key || lastToken || '';
const allowMultiple = item.type === 'array';
const itemInExistingTokens = tokens.some(t => t.key === item.hint);
diff --git a/app/assets/javascripts/filtered_search/filtered_search_bundle.js b/app/assets/javascripts/filtered_search/filtered_search_bundle.js
index 856eb6590ee..132b6fe698a 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_bundle.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_bundle.js
@@ -1,10 +1,10 @@
-require('./dropdown_hint');
-require('./dropdown_non_user');
-require('./dropdown_user');
-require('./dropdown_utils');
-require('./filtered_search_dropdown_manager');
-require('./filtered_search_dropdown');
-require('./filtered_search_manager');
-require('./filtered_search_token_keys');
-require('./filtered_search_tokenizer');
-require('./filtered_search_visual_tokens');
+import './dropdown_hint';
+import './dropdown_non_user';
+import './dropdown_user';
+import './dropdown_utils';
+import './filtered_search_token_keys';
+import './filtered_search_dropdown_manager';
+import './filtered_search_dropdown';
+import './filtered_search_manager';
+import './filtered_search_tokenizer';
+import './filtered_search_visual_tokens';
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
index 49a6cd1ac77..6bc6bc43f51 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
@@ -2,10 +2,10 @@ import DropLab from '~/droplab/drop_lab';
import FilteredSearchContainer from './container';
class FilteredSearchDropdownManager {
- constructor(baseEndpoint = '', page) {
+ constructor(baseEndpoint = '', tokenizer, page) {
this.container = FilteredSearchContainer.container;
this.baseEndpoint = baseEndpoint.replace(/\/$/, '');
- this.tokenizer = gl.FilteredSearchTokenizer;
+ this.tokenizer = tokenizer;
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.page = page;
@@ -98,7 +98,8 @@ class FilteredSearchDropdownManager {
if (!mappingKey.reference) {
const dl = this.droplab;
- const defaultArguments = [null, dl, element, this.filteredSearchInput, key];
+ const defaultArguments =
+ [null, dl, element, this.filteredSearchInput, this.filteredSearchTokenKeys, key];
const glArguments = defaultArguments.concat(mappingKey.extraArguments || []);
// Passing glArguments to `new gl[glClass](<arguments>)`
@@ -141,7 +142,8 @@ class FilteredSearchDropdownManager {
setDropdown() {
const query = gl.DropdownUtils.getSearchQuery(true);
- const { lastToken, searchToken } = this.tokenizer.processTokens(query);
+ const { lastToken, searchToken } =
+ this.tokenizer.processTokens(query, this.filteredSearchTokenKeys.getKeys());
if (this.currentDropdown) {
this.updateCurrentDropdownOffset();
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 36af0674ac6..3be889c684b 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -1,5 +1,3 @@
-/* global Flash */
-
import FilteredSearchContainer from './container';
import RecentSearchesRoot from './recent_searches_root';
import RecentSearchesStore from './stores/recent_searches_store';
@@ -8,6 +6,7 @@ import eventHub from './event_hub';
class FilteredSearchManager {
constructor(page) {
+ this.page = page;
this.container = FilteredSearchContainer.container;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.filteredSearchInputForm = this.filteredSearchInput.form;
@@ -15,18 +14,28 @@ class FilteredSearchManager {
this.tokensContainer = this.container.querySelector('.tokens-container');
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
- this.recentSearchesStore = new RecentSearchesStore();
- let recentSearchesKey = 'issue-recent-searches';
- if (page === 'merge_requests') {
- recentSearchesKey = 'merge-request-recent-searches';
+ this.recentSearchesStore = new RecentSearchesStore({
+ isLocalStorageAvailable: RecentSearchesService.isAvailable(),
+ allowedKeys: this.filteredSearchTokenKeys.getKeys(),
+ });
+ this.searchHistoryDropdownElement = document.querySelector('.js-filtered-search-history-dropdown');
+ const projectPath = this.searchHistoryDropdownElement ?
+ this.searchHistoryDropdownElement.dataset.projectFullPath : 'project';
+ let recentSearchesPagePrefix = 'issue-recent-searches';
+ if (this.page === 'merge_requests') {
+ recentSearchesPagePrefix = 'merge-request-recent-searches';
}
+ const recentSearchesKey = `${projectPath}-${recentSearchesPagePrefix}`;
this.recentSearchesService = new RecentSearchesService(recentSearchesKey);
+ }
+ setup() {
// Fetch recent searches from localStorage
this.fetchingRecentSearchesPromise = this.recentSearchesService.fetch()
- .catch(() => {
+ .catch((error) => {
+ if (error.name === 'RecentSearchesServiceError') return undefined;
// eslint-disable-next-line no-new
- new Flash('An error occured while parsing recent searches');
+ new window.Flash('An error occured while parsing recent searches');
// Gracefully fail to empty array
return [];
})
@@ -41,12 +50,12 @@ class FilteredSearchManager {
if (this.filteredSearchInput) {
this.tokenizer = gl.FilteredSearchTokenizer;
- this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page);
+ this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', this.tokenizer, this.page);
this.recentSearchesRoot = new RecentSearchesRoot(
this.recentSearchesStore,
this.recentSearchesService,
- document.querySelector('.js-filtered-search-history-dropdown'),
+ this.searchHistoryDropdownElement,
);
this.recentSearchesRoot.init();
@@ -135,7 +144,9 @@ class FilteredSearchManager {
if (e.keyCode === 8 || e.keyCode === 46) {
const { lastVisualToken } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
- if (this.filteredSearchInput.value === '' && lastVisualToken) {
+ const sanitizedTokenName = lastVisualToken && lastVisualToken.querySelector('.name').textContent.trim();
+ const canEdit = sanitizedTokenName && this.canEdit && this.canEdit(sanitizedTokenName);
+ if (this.filteredSearchInput.value === '' && lastVisualToken && canEdit) {
this.filteredSearchInput.value = gl.FilteredSearchVisualTokens.getLastTokenPartial();
gl.FilteredSearchVisualTokens.removeLastTokenPartial();
}
@@ -234,8 +245,10 @@ class FilteredSearchManager {
editToken(e) {
const token = e.target.closest('.js-visual-token');
+ const sanitizedTokenName = token.querySelector('.name').textContent.trim();
+ const canEdit = this.canEdit && this.canEdit(sanitizedTokenName);
- if (token) {
+ if (token && canEdit) {
gl.FilteredSearchVisualTokens.editToken(token);
this.tokenChange();
}
@@ -313,7 +326,7 @@ class FilteredSearchManager {
handleInputVisualToken() {
const input = this.filteredSearchInput;
const { tokens, searchToken }
- = gl.FilteredSearchTokenizer.processTokens(input.value);
+ = this.tokenizer.processTokens(input.value, this.filteredSearchTokenKeys.getKeys());
const { isLastVisualTokenValid }
= gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
@@ -385,7 +398,12 @@ class FilteredSearchManager {
if (condition) {
hasFilteredSearch = true;
- gl.FilteredSearchVisualTokens.addFilterVisualToken(condition.tokenKey, condition.value);
+ const canEdit = this.canEdit && this.canEdit(condition.tokenKey);
+ gl.FilteredSearchVisualTokens.addFilterVisualToken(
+ condition.tokenKey,
+ condition.value,
+ canEdit,
+ );
} else {
// Sanitize value since URL converts spaces into +
// Replace before decode so that we know what was originally + versus the encoded +
@@ -404,18 +422,27 @@ class FilteredSearchManager {
}
hasFilteredSearch = true;
- gl.FilteredSearchVisualTokens.addFilterVisualToken(sanitizedKey, `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`);
+ const canEdit = this.canEdit && this.canEdit(sanitizedKey);
+ gl.FilteredSearchVisualTokens.addFilterVisualToken(
+ sanitizedKey,
+ `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`,
+ canEdit,
+ );
} else if (!match && keyParam === 'assignee_id') {
const id = parseInt(value, 10);
if (usernameParams[id]) {
hasFilteredSearch = true;
- gl.FilteredSearchVisualTokens.addFilterVisualToken('assignee', `@${usernameParams[id]}`);
+ const tokenName = 'assignee';
+ const canEdit = this.canEdit && this.canEdit(tokenName);
+ gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit);
}
} else if (!match && keyParam === 'author_id') {
const id = parseInt(value, 10);
if (usernameParams[id]) {
hasFilteredSearch = true;
- gl.FilteredSearchVisualTokens.addFilterVisualToken('author', `@${usernameParams[id]}`);
+ const tokenName = 'author';
+ const canEdit = this.canEdit && this.canEdit(tokenName);
+ gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit);
}
} else if (!match && keyParam === 'search') {
hasFilteredSearch = true;
@@ -439,7 +466,7 @@ class FilteredSearchManager {
this.saveCurrentSearchQuery();
const { tokens, searchToken }
- = this.tokenizer.processTokens(searchQuery);
+ = this.tokenizer.processTokens(searchQuery, this.filteredSearchTokenKeys.getKeys());
const currentState = gl.utils.getParameterByName('state') || 'opened';
paths.push(`state=${currentState}`);
@@ -510,6 +537,11 @@ class FilteredSearchManager {
this.filteredSearchInput.dispatchEvent(new CustomEvent('input'));
this.search();
}
+
+ // eslint-disable-next-line class-methods-use-this
+ canEdit() {
+ return true;
+ }
}
window.gl = window.gl || {};
diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
index 1abad9d1b73..025d4d8795b 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
@@ -3,21 +3,25 @@ const tokenKeys = [{
type: 'string',
param: 'username',
symbol: '@',
+ icon: 'pencil',
}, {
key: 'assignee',
type: 'string',
param: 'username',
symbol: '@',
+ icon: 'user',
}, {
key: 'milestone',
type: 'string',
param: 'title',
symbol: '%',
+ icon: 'clock-o',
}, {
key: 'label',
type: 'array',
param: 'name[]',
symbol: '~',
+ icon: 'tag',
}];
const alternativeTokenKeys = [{
@@ -56,6 +60,10 @@ class FilteredSearchTokenKeys {
return tokenKeys;
}
+ static getKeys() {
+ return tokenKeys.map(i => i.key);
+ }
+
static getAlternatives() {
return alternativeTokenKeys;
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
index 2808e4b238a..f2e66503e5e 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
@@ -1,8 +1,7 @@
-require('./filtered_search_token_keys');
+import './filtered_search_token_keys';
class FilteredSearchTokenizer {
- static processTokens(input) {
- const allowedKeys = gl.FilteredSearchTokenKeys.get().map(i => i.key);
+ static processTokens(input, allowedKeys) {
// Regex extracts `(token):(symbol)(value)`
// Values that start with a double quote must end in a double quote (same for single)
const tokenRegex = new RegExp(`(${allowedKeys.join('|')}):([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`, 'g');
diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
index 453ecccc6fc..bc1226f5879 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
@@ -1,3 +1,5 @@
+import AjaxCache from '~/lib/utils/ajax_cache';
+import '~/flash'; /* global Flash */
import FilteredSearchContainer from './container';
class FilteredSearchVisualTokens {
@@ -34,28 +36,69 @@ class FilteredSearchVisualTokens {
}
}
- static createVisualTokenElementHTML() {
+ static createVisualTokenElementHTML(canEdit = true) {
+ let removeTokenMarkup = '';
+ if (canEdit) {
+ removeTokenMarkup = `
+ <div class="remove-token" role="button">
+ <i class="fa fa-close"></i>
+ </div>
+ `;
+ }
+
return `
<div class="selectable" role="button">
<div class="name"></div>
<div class="value-container">
<div class="value"></div>
- <div class="remove-token" role="button">
- <i class="fa fa-close"></i>
- </div>
+ ${removeTokenMarkup}
</div>
</div>
`;
}
- static addVisualTokenElement(name, value, isSearchTerm) {
+ static updateLabelTokenColor(tokenValueContainer, tokenValue) {
+ const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search');
+ const baseEndpoint = filteredSearchInput.dataset.baseEndpoint;
+ const labelsEndpoint = `${baseEndpoint}/labels.json`;
+
+ return AjaxCache.retrieve(labelsEndpoint)
+ .then((labels) => {
+ const matchingLabel = (labels || []).find(label => `~${gl.DropdownUtils.getEscapedText(label.title)}` === tokenValue);
+
+ if (!matchingLabel) {
+ return;
+ }
+
+ const tokenValueStyle = tokenValueContainer.style;
+ tokenValueStyle.backgroundColor = matchingLabel.color;
+ tokenValueStyle.color = matchingLabel.text_color;
+
+ if (matchingLabel.text_color === '#FFFFFF') {
+ const removeToken = tokenValueContainer.querySelector('.remove-token');
+ removeToken.classList.add('inverted');
+ }
+ })
+ .catch(() => new Flash('An error occurred while fetching label colors.'));
+ }
+
+ static renderVisualTokenValue(parentElement, tokenName, tokenValue) {
+ const tokenValueContainer = parentElement.querySelector('.value-container');
+ tokenValueContainer.querySelector('.value').innerText = tokenValue;
+
+ if (tokenName.toLowerCase() === 'label') {
+ FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue);
+ }
+ }
+
+ static addVisualTokenElement(name, value, isSearchTerm, canEdit) {
const li = document.createElement('li');
li.classList.add('js-visual-token');
li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token');
if (value) {
- li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML();
- li.querySelector('.value').innerText = value;
+ li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(canEdit);
+ FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value);
} else {
li.innerHTML = '<div class="name"></div>';
}
@@ -74,24 +117,24 @@ class FilteredSearchVisualTokens {
const name = FilteredSearchVisualTokens.getLastTokenPartial();
lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML();
lastVisualToken.querySelector('.name').innerText = name;
- lastVisualToken.querySelector('.value').innerText = value;
+ FilteredSearchVisualTokens.renderVisualTokenValue(lastVisualToken, name, value);
}
}
- static addFilterVisualToken(tokenName, tokenValue) {
+ static addFilterVisualToken(tokenName, tokenValue, canEdit) {
const { lastVisualToken, isLastVisualTokenValid }
= FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
const addVisualTokenElement = FilteredSearchVisualTokens.addVisualTokenElement;
if (isLastVisualTokenValid) {
- addVisualTokenElement(tokenName, tokenValue, false);
+ addVisualTokenElement(tokenName, tokenValue, false, canEdit);
} else {
const previousTokenName = lastVisualToken.querySelector('.name').innerText;
const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
tokensContainer.removeChild(lastVisualToken);
const value = tokenValue || tokenName;
- addVisualTokenElement(previousTokenName, value, false);
+ addVisualTokenElement(previousTokenName, value, false, canEdit);
}
}
@@ -183,6 +226,9 @@ class FilteredSearchVisualTokens {
static moveInputToTheRight() {
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
+
+ if (!input) return;
+
const inputLi = input.parentElement;
const tokenContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
diff --git a/app/assets/javascripts/filtered_search/recent_searches_root.js b/app/assets/javascripts/filtered_search/recent_searches_root.js
index 4e38409e12a..27e49d4fb96 100644
--- a/app/assets/javascripts/filtered_search/recent_searches_root.js
+++ b/app/assets/javascripts/filtered_search/recent_searches_root.js
@@ -29,12 +29,16 @@ class RecentSearchesRoot {
}
render() {
+ const state = this.store.state;
this.vm = new Vue({
el: this.wrapperElement,
- data: this.store.state,
+ data() { return state; },
template: `
<recent-searches-dropdown-content
- :items="recentSearches" />
+ :items="recentSearches"
+ :is-local-storage-available="isLocalStorageAvailable"
+ :allowed-keys="allowedKeys"
+ />
`,
components: {
'recent-searches-dropdown-content': RecentSearchesDropdownContent,
diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service.js b/app/assets/javascripts/filtered_search/services/recent_searches_service.js
index 3e402d5aed0..a056dea928d 100644
--- a/app/assets/javascripts/filtered_search/services/recent_searches_service.js
+++ b/app/assets/javascripts/filtered_search/services/recent_searches_service.js
@@ -1,9 +1,17 @@
+import RecentSearchesServiceError from './recent_searches_service_error';
+import AccessorUtilities from '../../lib/utils/accessor';
+
class RecentSearchesService {
constructor(localStorageKey = 'issuable-recent-searches') {
this.localStorageKey = localStorageKey;
}
fetch() {
+ if (!RecentSearchesService.isAvailable()) {
+ const error = new RecentSearchesServiceError();
+ return Promise.reject(error);
+ }
+
const input = window.localStorage.getItem(this.localStorageKey);
let searches = [];
@@ -19,8 +27,14 @@ class RecentSearchesService {
}
save(searches = []) {
+ if (!RecentSearchesService.isAvailable()) return;
+
window.localStorage.setItem(this.localStorageKey, JSON.stringify(searches));
}
+
+ static isAvailable() {
+ return AccessorUtilities.isLocalStorageAccessSafe();
+ }
}
export default RecentSearchesService;
diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js b/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js
new file mode 100644
index 00000000000..5917b223d63
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js
@@ -0,0 +1,11 @@
+class RecentSearchesServiceError {
+ constructor(message) {
+ this.name = 'RecentSearchesServiceError';
+ this.message = message || 'Recent Searches Service is unavailable';
+ }
+}
+
+// Can't use `extends` for builtin prototypes and get true inheritance yet
+RecentSearchesServiceError.prototype = Error.prototype;
+
+export default RecentSearchesServiceError;
diff --git a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js
index 066be69766a..aaa0c349d93 100644
--- a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js
+++ b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js
@@ -1,9 +1,11 @@
import _ from 'underscore';
class RecentSearchesStore {
- constructor(initialState = {}) {
+ constructor(initialState = {}, allowedKeys) {
this.state = Object.assign({
+ isLocalStorageAvailable: true,
recentSearches: [],
+ allowedKeys,
}, initialState);
}
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index f1b99023c72..b8a923cf619 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -1,119 +1,33 @@
-/* eslint-disable func-names, space-before-function-paren, no-template-curly-in-string, comma-dangle, object-shorthand, quotes, dot-notation, no-else-return, one-var, no-var, no-underscore-dangle, one-var-declaration-per-line, no-param-reassign, no-useless-escape, prefer-template, consistent-return, wrap-iife, prefer-arrow-callback, camelcase, no-unused-vars, no-useless-return, vars-on-top, max-len */
-
import emojiMap from 'emojis/digests.json';
import emojiAliases from 'emojis/aliases.json';
import { glEmojiTag } from '~/behaviors/gl_emoji';
import glRegexp from '~/lib/utils/regexp';
-// Creates the variables for setting up GFM auto-completion
-window.gl = window.gl || {};
-
function sanitize(str) {
return str.replace(/<(?:.|\n)*?>/gm, '');
}
-window.gl.GfmAutoComplete = {
- dataSources: {},
- defaultLoadingData: ['loading'],
- cachedData: {},
- isLoadingData: {},
- atTypeMap: {
- ':': 'emojis',
- '@': 'members',
- '#': 'issues',
- '!': 'mergeRequests',
- '~': 'labels',
- '%': 'milestones',
- '/': 'commands'
- },
- // Emoji
- Emoji: {
- templateFunction: function(name) {
- return `<li>
- ${name} ${glEmojiTag(name)}
- </li>
- `;
- }
- },
- // Team Members
- Members: {
- template: '<li>${avatarTag} ${username} <small>${title}</small></li>'
- },
- Labels: {
- template: '<li><span class="dropdown-label-box" style="background: ${color}"></span> ${title}</li>'
- },
- // Issues and MergeRequests
- Issues: {
- template: '<li><small>${id}</small> ${title}</li>'
- },
- // Milestones
- Milestones: {
- template: '<li>${title}</li>'
- },
- Loading: {
- template: '<li style="pointer-events: none;"><i class="fa fa-spinner fa-spin"></i> Loading...</li>'
- },
- DefaultOptions: {
- sorter: function(query, items, searchKey) {
- this.setting.highlightFirst = this.setting.alwaysHighlightFirst || query.length > 0;
- if (gl.GfmAutoComplete.isLoading(items)) {
- this.setting.highlightFirst = false;
- return items;
- }
- return $.fn.atwho["default"].callbacks.sorter(query, items, searchKey);
- },
- filter: function(query, data, searchKey) {
- if (gl.GfmAutoComplete.isLoading(data)) {
- gl.GfmAutoComplete.fetchData(this.$inputor, this.at);
- return data;
- } else {
- return $.fn.atwho["default"].callbacks.filter(query, data, searchKey);
- }
- },
- beforeInsert: function(value) {
- if (value && !this.setting.skipSpecialCharacterTest) {
- var withoutAt = value.substring(1);
- if (withoutAt && /[^\w\d]/.test(withoutAt)) value = value.charAt() + '"' + withoutAt + '"';
- }
- return value;
- },
- matcher: function (flag, subtext) {
- // The below is taken from At.js source
- // Tweaked to commands to start without a space only if char before is a non-word character
- // https://github.com/ichord/At.js
- var _a, _y, regexp, match, atSymbolsWithBar, atSymbolsWithoutBar;
- atSymbolsWithBar = Object.keys(this.app.controllers).join('|');
- atSymbolsWithoutBar = Object.keys(this.app.controllers).join('');
- subtext = subtext.split(/\s+/g).pop();
- flag = flag.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
-
- _a = decodeURI("%C3%80");
- _y = decodeURI("%C3%BF");
-
- regexp = new RegExp("^(?:\\B|[^a-zA-Z0-9_" + atSymbolsWithoutBar + "]|\\s)" + flag + "(?!" + atSymbolsWithBar + ")((?:[A-Za-z" + _a + "-" + _y + "0-9_\'\.\+\-]|[^\\x00-\\x7a])*)$", 'gi');
-
- match = regexp.exec(subtext);
+class GfmAutoComplete {
+ constructor(dataSources) {
+ this.dataSources = dataSources || {};
+ this.cachedData = {};
+ this.isLoadingData = {};
+ }
- if (match) {
- return match[1];
- } else {
- return null;
- }
- }
- },
- setup: function(input, enableMap = {
+ setup(input, enableMap = {
emojis: true,
members: true,
issues: true,
milestones: true,
mergeRequests: true,
- labels: true
+ labels: true,
}) {
// Add GFM auto-completion to all input fields, that accept GFM input.
this.input = input || $('.js-gfm-input');
this.enableMap = enableMap;
this.setupLifecycle();
- },
+ }
+
setupLifecycle() {
this.input.each((i, input) => {
const $input = $(input);
@@ -122,9 +36,9 @@ window.gl.GfmAutoComplete = {
// Needed for slash commands with suffixes (ex: /label ~)
$input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup'));
});
- },
+ }
- setupAtWho: function($input) {
+ setupAtWho($input) {
if (this.enableMap.emojis) this.setupEmoji($input);
if (this.enableMap.members) this.setupMembers($input);
if (this.enableMap.issues) this.setupIssues($input);
@@ -138,10 +52,11 @@ window.gl.GfmAutoComplete = {
alias: 'commands',
searchKey: 'search',
skipSpecialCharacterTest: true,
- data: this.defaultLoadingData,
- displayTpl: function(value) {
- if (this.isLoading(value)) return this.Loading.template;
- var tpl = '<li>/${name}';
+ data: GfmAutoComplete.defaultLoadingData,
+ displayTpl(value) {
+ if (GfmAutoComplete.isLoading(value)) return GfmAutoComplete.Loading.template;
+ // eslint-disable-next-line no-template-curly-in-string
+ let tpl = '<li>/${name}';
if (value.aliases.length > 0) {
tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>';
}
@@ -153,105 +68,106 @@ window.gl.GfmAutoComplete = {
}
tpl += '</li>';
return _.template(tpl)(value);
- }.bind(this),
- insertTpl: function(value) {
- var tpl = "/${name} ";
- var reference_prefix = null;
+ },
+ insertTpl(value) {
+ // eslint-disable-next-line no-template-curly-in-string
+ let tpl = '/${name} ';
+ let referencePrefix = null;
if (value.params.length > 0) {
- reference_prefix = value.params[0][0];
- if (/^[@%~]/.test(reference_prefix)) {
- tpl += '<%- reference_prefix %>';
+ referencePrefix = value.params[0][0];
+ if (/^[@%~]/.test(referencePrefix)) {
+ tpl += '<%- referencePrefix %>';
}
}
- return _.template(tpl)({ reference_prefix: reference_prefix });
+ return _.template(tpl)({ referencePrefix });
},
suffix: '',
callbacks: {
- sorter: this.DefaultOptions.sorter,
- filter: this.DefaultOptions.filter,
- beforeInsert: this.DefaultOptions.beforeInsert,
- beforeSave: function(commands) {
- if (gl.GfmAutoComplete.isLoading(commands)) return commands;
- return $.map(commands, function(c) {
- var search = c.name;
+ ...this.getDefaultCallbacks(),
+ beforeSave(commands) {
+ if (GfmAutoComplete.isLoading(commands)) return commands;
+ return $.map(commands, (c) => {
+ let search = c.name;
if (c.aliases.length > 0) {
- search = search + " " + c.aliases.join(" ");
+ search = `${search} ${c.aliases.join(' ')}`;
}
return {
name: c.name,
aliases: c.aliases,
params: c.params,
description: c.description,
- search: search
+ search,
};
});
},
- matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) {
- var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi;
- var match = regexp.exec(subtext);
+ matcher(flag, subtext) {
+ const regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi;
+ const match = regexp.exec(subtext);
if (match) {
return match[1];
- } else {
- return null;
}
- }
- }
+ return null;
+ },
+ },
});
- return;
- },
+ }
setupEmoji($input) {
// Emoji
$input.atwho({
at: ':',
- displayTpl: function(value) {
- return value && value.name ? this.Emoji.templateFunction(value.name) : this.Loading.template;
- }.bind(this),
+ displayTpl(value) {
+ let tmpl = GfmAutoComplete.Loading.template;
+ if (value && value.name) {
+ tmpl = GfmAutoComplete.Emoji.templateFunction(value.name);
+ }
+ return tmpl;
+ },
+ // eslint-disable-next-line no-template-curly-in-string
insertTpl: ':${name}:',
skipSpecialCharacterTest: true,
- data: this.defaultLoadingData,
+ data: GfmAutoComplete.defaultLoadingData,
callbacks: {
- sorter: this.DefaultOptions.sorter,
- beforeInsert: this.DefaultOptions.beforeInsert,
- filter: this.DefaultOptions.filter,
-
- matcher: (flag, subtext) => {
+ ...this.getDefaultCallbacks(),
+ matcher(flag, subtext) {
const relevantText = subtext.trim().split(/\s/).pop();
const regexp = new RegExp(`(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^:]*)$`, 'gi');
const match = regexp.exec(relevantText);
return match && match.length ? match[1] : null;
- }
- }
+ },
+ },
});
- },
+ }
setupMembers($input) {
// Team Members
$input.atwho({
at: '@',
- displayTpl: function(value) {
- return value.username != null ? this.Members.template : this.Loading.template;
- }.bind(this),
+ displayTpl(value) {
+ let tmpl = GfmAutoComplete.Loading.template;
+ if (value.username != null) {
+ tmpl = GfmAutoComplete.Members.template;
+ }
+ return tmpl;
+ },
+ // eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${username}',
searchKey: 'search',
alwaysHighlightFirst: true,
skipSpecialCharacterTest: true,
- data: this.defaultLoadingData,
+ data: GfmAutoComplete.defaultLoadingData,
callbacks: {
- sorter: this.DefaultOptions.sorter,
- filter: this.DefaultOptions.filter,
- beforeInsert: this.DefaultOptions.beforeInsert,
- matcher: this.DefaultOptions.matcher,
- beforeSave: function(members) {
- return $.map(members, function(m) {
+ ...this.getDefaultCallbacks(),
+ beforeSave(members) {
+ return $.map(members, (m) => {
let title = '';
if (m.username == null) {
return m;
}
title = m.name;
if (m.count) {
- title += " (" + m.count + ")";
+ title += ` (${m.count})`;
}
const autoCompleteAvatar = m.avatar_url || m.username.charAt(0).toUpperCase();
@@ -262,173 +178,271 @@ window.gl.GfmAutoComplete = {
username: m.username,
avatarTag: autoCompleteAvatar.length === 1 ? txtAvatar : imgAvatar,
title: sanitize(title),
- search: sanitize(m.username + " " + m.name)
+ search: sanitize(`${m.username} ${m.name}`),
};
});
- }
- }
+ },
+ },
});
- },
+ }
setupIssues($input) {
$input.atwho({
at: '#',
alias: 'issues',
searchKey: 'search',
- displayTpl: function(value) {
- return value.title != null ? this.Issues.template : this.Loading.template;
- }.bind(this),
- data: this.defaultLoadingData,
+ displayTpl(value) {
+ let tmpl = GfmAutoComplete.Loading.template;
+ if (value.title != null) {
+ tmpl = GfmAutoComplete.Issues.template;
+ }
+ return tmpl;
+ },
+ data: GfmAutoComplete.defaultLoadingData,
+ // eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${id}',
callbacks: {
- sorter: this.DefaultOptions.sorter,
- filter: this.DefaultOptions.filter,
- beforeInsert: this.DefaultOptions.beforeInsert,
- matcher: this.DefaultOptions.matcher,
- beforeSave: function(issues) {
- return $.map(issues, function(i) {
+ ...this.getDefaultCallbacks(),
+ beforeSave(issues) {
+ return $.map(issues, (i) => {
if (i.title == null) {
return i;
}
return {
id: i.iid,
title: sanitize(i.title),
- search: i.iid + " " + i.title
+ search: `${i.iid} ${i.title}`,
};
});
- }
- }
+ },
+ },
});
- },
+ }
setupMilestones($input) {
$input.atwho({
at: '%',
alias: 'milestones',
searchKey: 'search',
+ // eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${title}',
- displayTpl: function(value) {
- return value.title != null ? this.Milestones.template : this.Loading.template;
- }.bind(this),
- data: this.defaultLoadingData,
+ displayTpl(value) {
+ let tmpl = GfmAutoComplete.Loading.template;
+ if (value.title != null) {
+ tmpl = GfmAutoComplete.Milestones.template;
+ }
+ return tmpl;
+ },
+ data: GfmAutoComplete.defaultLoadingData,
callbacks: {
- matcher: this.DefaultOptions.matcher,
- sorter: this.DefaultOptions.sorter,
- beforeInsert: this.DefaultOptions.beforeInsert,
- filter: this.DefaultOptions.filter,
- beforeSave: function(milestones) {
- return $.map(milestones, function(m) {
+ ...this.getDefaultCallbacks(),
+ beforeSave(milestones) {
+ return $.map(milestones, (m) => {
if (m.title == null) {
return m;
}
return {
id: m.iid,
title: sanitize(m.title),
- search: "" + m.title
+ search: m.title,
};
});
- }
- }
+ },
+ },
});
- },
+ }
setupMergeRequests($input) {
$input.atwho({
at: '!',
alias: 'mergerequests',
searchKey: 'search',
- displayTpl: function(value) {
- return value.title != null ? this.Issues.template : this.Loading.template;
- }.bind(this),
- data: this.defaultLoadingData,
+ displayTpl(value) {
+ let tmpl = GfmAutoComplete.Loading.template;
+ if (value.title != null) {
+ tmpl = GfmAutoComplete.Issues.template;
+ }
+ return tmpl;
+ },
+ data: GfmAutoComplete.defaultLoadingData,
+ // eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${id}',
callbacks: {
- sorter: this.DefaultOptions.sorter,
- filter: this.DefaultOptions.filter,
- beforeInsert: this.DefaultOptions.beforeInsert,
- matcher: this.DefaultOptions.matcher,
- beforeSave: function(merges) {
- return $.map(merges, function(m) {
+ ...this.getDefaultCallbacks(),
+ beforeSave(merges) {
+ return $.map(merges, (m) => {
if (m.title == null) {
return m;
}
return {
id: m.iid,
title: sanitize(m.title),
- search: m.iid + " " + m.title
+ search: `${m.iid} ${m.title}`,
};
});
- }
- }
+ },
+ },
});
- },
+ }
setupLabels($input) {
$input.atwho({
at: '~',
alias: 'labels',
searchKey: 'search',
- data: this.defaultLoadingData,
- displayTpl: function(value) {
- return this.isLoading(value) ? this.Loading.template : this.Labels.template;
- }.bind(this),
+ data: GfmAutoComplete.defaultLoadingData,
+ displayTpl(value) {
+ let tmpl = GfmAutoComplete.Labels.template;
+ if (GfmAutoComplete.isLoading(value)) {
+ tmpl = GfmAutoComplete.Loading.template;
+ }
+ return tmpl;
+ },
+ // eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${title}',
callbacks: {
- matcher: this.DefaultOptions.matcher,
- beforeInsert: this.DefaultOptions.beforeInsert,
- filter: this.DefaultOptions.filter,
- sorter: this.DefaultOptions.sorter,
- beforeSave: function(merges) {
- if (gl.GfmAutoComplete.isLoading(merges)) return merges;
- var sanitizeLabelTitle;
- sanitizeLabelTitle = function(title) {
- if (/[\w\?&]+\s+[\w\?&]+/g.test(title)) {
- return "\"" + (sanitize(title)) + "\"";
- } else {
- return sanitize(title);
- }
- };
- return $.map(merges, function(m) {
- return {
- title: sanitize(m.title),
- color: m.color,
- search: "" + m.title
- };
- });
- }
- }
+ ...this.getDefaultCallbacks(),
+ beforeSave(merges) {
+ if (GfmAutoComplete.isLoading(merges)) return merges;
+ return $.map(merges, m => ({
+ title: sanitize(m.title),
+ color: m.color,
+ search: m.title,
+ }));
+ },
+ },
});
- },
+ }
- fetchData: function($input, at) {
+ getDefaultCallbacks() {
+ const fetchData = this.fetchData.bind(this);
+
+ return {
+ sorter(query, items, searchKey) {
+ this.setting.highlightFirst = this.setting.alwaysHighlightFirst || query.length > 0;
+ if (GfmAutoComplete.isLoading(items)) {
+ this.setting.highlightFirst = false;
+ return items;
+ }
+ return $.fn.atwho.default.callbacks.sorter(query, items, searchKey);
+ },
+ filter(query, data, searchKey) {
+ if (GfmAutoComplete.isLoading(data)) {
+ fetchData(this.$inputor, this.at);
+ return data;
+ }
+ return $.fn.atwho.default.callbacks.filter(query, data, searchKey);
+ },
+ beforeInsert(value) {
+ let resultantValue = value;
+ if (value && !this.setting.skipSpecialCharacterTest) {
+ const withoutAt = value.substring(1);
+ if (withoutAt && /[^\w\d]/.test(withoutAt)) {
+ resultantValue = `${value.charAt()}"${withoutAt}"`;
+ }
+ }
+ return resultantValue;
+ },
+ matcher(flag, subtext) {
+ // The below is taken from At.js source
+ // Tweaked to commands to start without a space only if char before is a non-word character
+ // https://github.com/ichord/At.js
+ const atSymbolsWithBar = Object.keys(this.app.controllers).join('|');
+ const atSymbolsWithoutBar = Object.keys(this.app.controllers).join('');
+ const targetSubtext = subtext.split(/\s+/g).pop();
+ const resultantFlag = flag.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
+
+ const accentAChar = decodeURI('%C3%80');
+ const accentYChar = decodeURI('%C3%BF');
+
+ const regexp = new RegExp(`^(?:\\B|[^a-zA-Z0-9_${atSymbolsWithoutBar}]|\\s)${resultantFlag}(?!${atSymbolsWithBar})((?:[A-Za-z${accentAChar}-${accentYChar}0-9_'.+-]|[^\\x00-\\x7a])*)$`, 'gi');
+
+ const match = regexp.exec(targetSubtext);
+
+ if (match) {
+ return match[1];
+ }
+ return null;
+ },
+ };
+ }
+
+ fetchData($input, at) {
if (this.isLoadingData[at]) return;
this.isLoadingData[at] = true;
if (this.cachedData[at]) {
this.loadData($input, at, this.cachedData[at]);
- } else if (this.atTypeMap[at] === 'emojis') {
+ } else if (GfmAutoComplete.atTypeMap[at] === 'emojis') {
this.loadData($input, at, Object.keys(emojiMap).concat(Object.keys(emojiAliases)));
} else {
- $.getJSON(this.dataSources[this.atTypeMap[at]], (data) => {
+ $.getJSON(this.dataSources[GfmAutoComplete.atTypeMap[at]], (data) => {
this.loadData($input, at, data);
}).fail(() => { this.isLoadingData[at] = false; });
}
- },
- loadData: function($input, at, data) {
+ }
+ loadData($input, at, data) {
this.isLoadingData[at] = false;
this.cachedData[at] = data;
$input.atwho('load', at, data);
// This trigger at.js again
// otherwise we would be stuck with loading until the user types
return $input.trigger('keyup');
- },
- isLoading(data) {
- var dataToInspect = data;
+ }
+
+ static isLoading(data) {
+ let dataToInspect = data;
if (data && data.length > 0) {
dataToInspect = data[0];
}
- var loadingState = this.defaultLoadingData[0];
+ const loadingState = GfmAutoComplete.defaultLoadingData[0];
return dataToInspect &&
(dataToInspect === loadingState || dataToInspect.name === loadingState);
}
+}
+
+GfmAutoComplete.defaultLoadingData = ['loading'];
+
+GfmAutoComplete.atTypeMap = {
+ ':': 'emojis',
+ '@': 'members',
+ '#': 'issues',
+ '!': 'mergeRequests',
+ '~': 'labels',
+ '%': 'milestones',
+ '/': 'commands',
+};
+
+// Emoji
+GfmAutoComplete.Emoji = {
+ templateFunction(name) {
+ return `<li>
+ ${name} ${glEmojiTag(name)}
+ </li>
+ `;
+ },
};
+// Team Members
+GfmAutoComplete.Members = {
+ // eslint-disable-next-line no-template-curly-in-string
+ template: '<li>${avatarTag} ${username} <small>${title}</small></li>',
+};
+GfmAutoComplete.Labels = {
+ // eslint-disable-next-line no-template-curly-in-string
+ template: '<li><span class="dropdown-label-box" style="background: ${color}"></span> ${title}</li>',
+};
+// Issues and MergeRequests
+GfmAutoComplete.Issues = {
+ // eslint-disable-next-line no-template-curly-in-string
+ template: '<li><small>${id}</small> ${title}</li>',
+};
+// Milestones
+GfmAutoComplete.Milestones = {
+ // eslint-disable-next-line no-template-curly-in-string
+ template: '<li>${title}</li>',
+};
+GfmAutoComplete.Loading = {
+ template: '<li style="pointer-events: none;"><i class="fa fa-spinner fa-spin"></i> Loading...</li>',
+};
+
+export default GfmAutoComplete;
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index a03f1202a6d..d34561e5512 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -1,9 +1,8 @@
/* eslint-disable func-names, space-before-function-paren, no-var, one-var, one-var-declaration-per-line, prefer-rest-params, max-len, vars-on-top, wrap-iife, no-unused-vars, quotes, no-shadow, no-cond-assign, prefer-arrow-callback, no-return-assign, no-else-return, camelcase, comma-dangle, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, prefer-template, no-param-reassign, no-loop-func, no-mixed-operators */
/* global fuzzaldrinPlus */
+import { isObject } from './lib/utils/type_utility';
-var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote,
- bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; },
- indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i += 1) { if (i in this && this[i] === item) return i; } return -1; };
+var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote;
GitLabDropdownFilter = (function() {
var ARROW_KEY_CODES, BLUR_KEYCODES, HAS_VALUE_CLASS;
@@ -95,7 +94,7 @@ GitLabDropdownFilter = (function() {
// { prop: 'def' }
// ]
// }
- if (gl.utils.isObject(data)) {
+ if (isObject(data)) {
results = {};
for (key in data) {
group = data[key];
@@ -213,10 +212,10 @@ GitLabDropdown = (function() {
var searchFields, selector, self;
this.el = el1;
this.options = options;
- this.updateLabel = bind(this.updateLabel, this);
- this.hidden = bind(this.hidden, this);
- this.opened = bind(this.opened, this);
- this.shouldPropagate = bind(this.shouldPropagate, this);
+ this.updateLabel = this.updateLabel.bind(this);
+ this.hidden = this.hidden.bind(this);
+ this.opened = this.opened.bind(this);
+ this.shouldPropagate = this.shouldPropagate.bind(this);
self = this;
selector = $(this.el).data("target");
this.dropdown = selector != null ? $(selector) : $(this.el).parent();
@@ -255,7 +254,8 @@ GitLabDropdown = (function() {
}
};
// Remote data
- })(this)
+ })(this),
+ instance: this,
});
}
}
@@ -269,6 +269,7 @@ GitLabDropdown = (function() {
remote: this.options.filterRemote,
query: this.options.data,
keys: searchFields,
+ instance: this,
elements: (function(_this) {
return function() {
selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')';
@@ -343,21 +344,26 @@ GitLabDropdown = (function() {
}
this.dropdown.on("click", selector, function(e) {
var $el, selected, selectedObj, isMarking;
- $el = $(this);
+ $el = $(e.currentTarget);
selected = self.rowClicked($el);
selectedObj = selected ? selected[0] : null;
isMarking = selected ? selected[1] : null;
- if (self.options.clicked) {
- self.options.clicked(selectedObj, $el, e, isMarking);
+ 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 (self.options.toggleLabel) {
- self.updateLabel(selectedObj, $el, self);
+ if (this.options.toggleLabel) {
+ this.updateLabel(selectedObj, $el, this);
}
$el.trigger('blur');
- });
+ }.bind(this));
}
}
@@ -391,7 +397,7 @@ GitLabDropdown = (function() {
html = [this.noResults()];
} else {
// Handle array groups
- if (gl.utils.isObject(data)) {
+ if (isObject(data)) {
html = [];
for (name in data) {
groupData = data[name];
@@ -439,15 +445,34 @@ GitLabDropdown = (function() {
}
};
+ GitLabDropdown.prototype.filteredFullData = function() {
+ return this.fullData.filter(r => typeof r === 'object'
+ && !Object.prototype.hasOwnProperty.call(r, 'beforeDivider')
+ && !Object.prototype.hasOwnProperty.call(r, 'header')
+ );
+ };
+
GitLabDropdown.prototype.opened = function(e) {
var contentHtml;
this.resetRows();
this.addArrowKeyEvent();
+ const dropdownToggle = this.dropdown.find('.dropdown-menu-toggle');
+ const hasFilterBulkUpdate = dropdownToggle.hasClass('js-filter-bulk-update');
+ const hasMultiSelect = dropdownToggle.hasClass('js-multiselect');
+
// Makes indeterminate items effective
- if (this.fullData && this.dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update')) {
+ if (this.fullData && hasFilterBulkUpdate) {
this.parseData(this.fullData);
}
+
+ // Process the data to make sure rendered data
+ // matches the correct layout
+ const inputValue = this.filterInput.val();
+ if (this.fullData && hasMultiSelect && this.options.processData && inputValue.length === 0) {
+ this.options.processData.call(this.options, inputValue, this.filteredFullData(), this.parseData.bind(this));
+ }
+
contentHtml = $('.dropdown-content', this.dropdown).html();
if (this.remote && contentHtml === "") {
this.remote.execute();
@@ -584,7 +609,12 @@ GitLabDropdown = (function() {
var link = document.createElement('a');
link.href = url;
- link.innerHTML = text;
+
+ if (this.highlight) {
+ link.innerHTML = text;
+ } else {
+ link.textContent = text;
+ }
if (selected) {
link.className = 'is-active';
@@ -601,8 +631,8 @@ GitLabDropdown = (function() {
};
GitLabDropdown.prototype.highlightTextMatches = function(text, term) {
- var occurrences;
- occurrences = fuzzaldrinPlus.match(text, term);
+ const occurrences = fuzzaldrinPlus.match(text, term);
+ const indexOf = [].indexOf;
return text.split('').map(function(character, i) {
if (indexOf.call(occurrences, i) !== -1) {
return "<b>" + character + "</b>";
@@ -709,6 +739,17 @@ GitLabDropdown = (function() {
if (this.options.inputId != null) {
$input.attr('id', this.options.inputId);
}
+
+ if (this.options.multiSelect) {
+ Object.keys(selectedObject).forEach((attribute) => {
+ $input.attr(`data-${attribute}`, selectedObject[attribute]);
+ });
+ }
+
+ if (this.options.inputMeta) {
+ $input.attr('data-meta', selectedObject[this.options.inputMeta]);
+ }
+
return this.dropdown.before($input);
};
@@ -829,7 +870,14 @@ GitLabDropdown = (function() {
if (instance == null) {
instance = null;
}
- return $(this.el).find(".dropdown-toggle-text").text(this.options.toggleLabel(selected, el, instance));
+
+ let toggleText = this.options.toggleLabel(selected, el, instance);
+ if (this.options.updateLabel) {
+ // Option to override the dropdown label text
+ toggleText = this.options.updateLabel;
+ }
+
+ return $(this.el).find(".dropdown-toggle-text").text(toggleText);
};
GitLabDropdown.prototype.clearField = function(field, isInput) {
diff --git a/app/assets/javascripts/gl_field_error.js b/app/assets/javascripts/gl_field_error.js
index 76de249ac3b..0add7075254 100644
--- a/app/assets/javascripts/gl_field_error.js
+++ b/app/assets/javascripts/gl_field_error.js
@@ -65,6 +65,7 @@ class GlFieldError {
this.state = {
valid: false,
empty: true,
+ submitted: false,
};
this.initFieldValidation();
@@ -108,9 +109,10 @@ class GlFieldError {
const currentValue = this.accessCurrentValue();
this.state.valid = false;
this.state.empty = currentValue === '';
-
+ this.state.submitted = true;
this.renderValidity();
this.form.focusOnFirstInvalid.apply(this.form);
+
// For UX, wait til after first invalid submission to check each keyup
this.inputElement.off('keyup.fieldValidator')
.on('keyup.fieldValidator', this.updateValidity.bind(this));
diff --git a/app/assets/javascripts/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js
index 636258ec555..4f226ff96ea 100644
--- a/app/assets/javascripts/gl_field_errors.js
+++ b/app/assets/javascripts/gl_field_errors.js
@@ -1,6 +1,6 @@
/* eslint-disable comma-dangle, class-methods-use-this, max-len, space-before-function-paren, arrow-parens, no-param-reassign */
-require('./gl_field_error');
+import './gl_field_error';
const customValidationFlag = 'gl-field-error-ignore';
@@ -37,6 +37,15 @@ class GlFieldErrors {
}
}
+ /* Public method for triggering validity updates manually */
+ updateFormValidityState() {
+ this.state.inputs.forEach((field) => {
+ if (field.state.submitted) {
+ field.updateValidity();
+ }
+ });
+ }
+
focusOnFirstInvalid () {
const firstInvalid = this.state.inputs.filter((input) => !input.inputDomElement.validity.valid)[0];
firstInvalid.inputElement.focus();
diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js
index ff06092e4d6..dc9f114af99 100644
--- a/app/assets/javascripts/gl_form.js
+++ b/app/assets/javascripts/gl_form.js
@@ -3,11 +3,14 @@
/* global DropzoneInput */
/* global autosize */
+import GfmAutoComplete from './gfm_auto_complete';
+
window.gl = window.gl || {};
-function GLForm(form) {
+function GLForm(form, enableGFM = false) {
this.form = form;
this.textarea = this.form.find('textarea.js-gfm-input');
+ this.enableGFM = enableGFM;
// Before we start, we should clean up any previous data for this form
this.destroy();
// Setup the form
@@ -30,8 +33,14 @@ GLForm.prototype.setupForm = function() {
this.form.addClass('gfm-form');
// remove notify commit author checkbox for non-commit notes
gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button, .js-note-new-discussion'));
-
- gl.GfmAutoComplete.setup(this.form.find('.js-gfm-input'));
+ new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources).setup(this.form.find('.js-gfm-input'), {
+ emojis: true,
+ members: this.enableGFM,
+ issues: this.enableGFM,
+ milestones: this.enableGFM,
+ mergeRequests: this.enableGFM,
+ labels: this.enableGFM,
+ });
new DropzoneInput(this.form);
autosize(this.textarea);
}
diff --git a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js
index 521bc77db66..0deb27e522b 100644
--- a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js
+++ b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js
@@ -2,7 +2,6 @@
import d3 from 'd3';
-const bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
const extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; };
const hasProp = {}.hasOwnProperty;
@@ -95,7 +94,7 @@ export const ContributorsMasterGraph = (function(superClass) {
function ContributorsMasterGraph(data1) {
this.data = data1;
- this.update_content = bind(this.update_content, this);
+ this.update_content = this.update_content.bind(this);
this.width = $('.content').width() - 70;
this.height = 200;
this.x = null;
diff --git a/app/assets/javascripts/group_name.js b/app/assets/javascripts/group_name.js
index 62675d7e67e..462d792b8d5 100644
--- a/app/assets/javascripts/group_name.js
+++ b/app/assets/javascripts/group_name.js
@@ -44,18 +44,18 @@ export default class GroupName {
showToggle() {
this.title.classList.add('wrap');
this.toggle.classList.remove('hidden');
- if (this.isHidden) this.groupTitle.classList.add('is-hidden');
+ if (this.isHidden) this.groupTitle.classList.add('hidden');
}
hideToggle() {
this.title.classList.remove('wrap');
this.toggle.classList.add('hidden');
- if (this.isHidden) this.groupTitle.classList.remove('is-hidden');
+ if (this.isHidden) this.groupTitle.classList.remove('hidden');
}
toggleGroups() {
this.isHidden = !this.isHidden;
- this.groupTitle.classList.toggle('is-hidden');
+ this.groupTitle.classList.toggle('hidden');
}
render() {
diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js
index acfa4bd4c6b..b5975295329 100644
--- a/app/assets/javascripts/groups_select.js
+++ b/app/assets/javascripts/groups_select.js
@@ -3,7 +3,7 @@
prefer-arrow-callback, comma-dangle, consistent-return, yoda,
prefer-rest-params, prefer-spread, no-unused-vars, prefer-template,
promise/catch-or-return */
-/* global Api */
+import Api from './api';
var slice = [].slice;
diff --git a/app/assets/javascripts/issuable/auto_width_dropdown_select.js b/app/assets/javascripts/issuable/auto_width_dropdown_select.js
new file mode 100644
index 00000000000..2203a56315e
--- /dev/null
+++ b/app/assets/javascripts/issuable/auto_width_dropdown_select.js
@@ -0,0 +1,38 @@
+let instanceCount = 0;
+
+class AutoWidthDropdownSelect {
+ constructor(selectElement) {
+ this.$selectElement = $(selectElement);
+ this.dropdownClass = `js-auto-width-select-dropdown-${instanceCount}`;
+ instanceCount += 1;
+ }
+
+ init() {
+ const dropdownClass = this.dropdownClass;
+ this.$selectElement.select2({
+ dropdownCssClass: dropdownClass,
+ dropdownCss() {
+ let resultantWidth = 'auto';
+ const $dropdown = $(`.${dropdownClass}`);
+
+ // We have to look at the parent because
+ // `offsetParent` on a `display: none;` is `null`
+ const offsetParentWidth = $(this).parent().offsetParent().width();
+ // Reset any width to let it naturally flow
+ $dropdown.css('width', 'auto');
+ if ($dropdown.outerWidth(false) > offsetParentWidth) {
+ resultantWidth = offsetParentWidth;
+ }
+
+ return {
+ width: resultantWidth,
+ maxWidth: offsetParentWidth,
+ };
+ },
+ });
+
+ return this;
+ }
+}
+
+export default AutoWidthDropdownSelect;
diff --git a/app/assets/javascripts/issuable/issuable_bundle.js b/app/assets/javascripts/issuable/issuable_bundle.js
deleted file mode 100644
index e927cc0077c..00000000000
--- a/app/assets/javascripts/issuable/issuable_bundle.js
+++ /dev/null
@@ -1 +0,0 @@
-require('./time_tracking/time_tracking_bundle');
diff --git a/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js b/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js
deleted file mode 100644
index aec13e78f42..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import Vue from 'vue';
-import stopwatchSvg from 'icons/_icon_stopwatch.svg';
-
-require('../../../lib/utils/pretty_time');
-
-(() => {
- Vue.component('time-tracking-collapsed-state', {
- name: 'time-tracking-collapsed-state',
- props: [
- 'showComparisonState',
- 'showSpentOnlyState',
- 'showEstimateOnlyState',
- 'showNoTimeTrackingState',
- 'timeSpentHumanReadable',
- 'timeEstimateHumanReadable',
- ],
- methods: {
- abbreviateTime(timeStr) {
- return gl.utils.prettyTime.abbreviateTime(timeStr);
- },
- },
- template: `
- <div class='sidebar-collapsed-icon'>
- ${stopwatchSvg}
- <div class='time-tracking-collapsed-summary'>
- <div class='compare' v-if='showComparisonState'>
- <span>{{ abbreviateTime(timeSpentHumanReadable) }} / {{ abbreviateTime(timeEstimateHumanReadable) }}</span>
- </div>
- <div class='estimate-only' v-if='showEstimateOnlyState'>
- <span class='bold'>-- / {{ abbreviateTime(timeEstimateHumanReadable) }}</span>
- </div>
- <div class='spend-only' v-if='showSpentOnlyState'>
- <span class='bold'>{{ abbreviateTime(timeSpentHumanReadable) }} / --</span>
- </div>
- <div class='no-tracking' v-if='showNoTimeTrackingState'>
- <span class='no-value'>None</span>
- </div>
- </div>
- </div>
- `,
- });
-})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js b/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js
deleted file mode 100644
index c55e263f6f4..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js
+++ /dev/null
@@ -1,70 +0,0 @@
-import Vue from 'vue';
-
-require('../../../lib/utils/pretty_time');
-
-(() => {
- const prettyTime = gl.utils.prettyTime;
-
- Vue.component('time-tracking-comparison-pane', {
- name: 'time-tracking-comparison-pane',
- props: [
- 'timeSpent',
- 'timeEstimate',
- 'timeSpentHumanReadable',
- 'timeEstimateHumanReadable',
- ],
- computed: {
- parsedRemaining() {
- const diffSeconds = this.timeEstimate - this.timeSpent;
- return prettyTime.parseSeconds(diffSeconds);
- },
- timeRemainingHumanReadable() {
- return prettyTime.stringifyTime(this.parsedRemaining);
- },
- timeRemainingTooltip() {
- const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:';
- return `${prefix} ${this.timeRemainingHumanReadable}`;
- },
- /* Diff values for comparison meter */
- timeRemainingMinutes() {
- return this.timeEstimate - this.timeSpent;
- },
- timeRemainingPercent() {
- return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`;
- },
- timeRemainingStatusClass() {
- return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate';
- },
- /* Parsed time values */
- parsedEstimate() {
- return prettyTime.parseSeconds(this.timeEstimate);
- },
- parsedSpent() {
- return prettyTime.parseSeconds(this.timeSpent);
- },
- },
- template: `
- <div class='time-tracking-comparison-pane'>
- <div class='compare-meter' data-toggle='tooltip' data-placement='top' role='timeRemainingDisplay'
- :aria-valuenow='timeRemainingTooltip'
- :title='timeRemainingTooltip'
- :data-original-title='timeRemainingTooltip'
- :class='timeRemainingStatusClass'>
- <div class='meter-container' role='timeSpentPercent' :aria-valuenow='timeRemainingPercent'>
- <div :style='{ width: timeRemainingPercent }' class='meter-fill'></div>
- </div>
- <div class='compare-display-container'>
- <div class='compare-display pull-left'>
- <span class='compare-label'>Spent</span>
- <span class='compare-value spent'>{{ timeSpentHumanReadable }}</span>
- </div>
- <div class='compare-display estimated pull-right'>
- <span class='compare-label'>Est</span>
- <span class='compare-value'>{{ timeEstimateHumanReadable }}</span>
- </div>
- </div>
- </div>
- </div>
- `,
- });
-})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js b/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js
deleted file mode 100644
index a7fbd704c40..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import Vue from 'vue';
-
-(() => {
- Vue.component('time-tracking-estimate-only-pane', {
- name: 'time-tracking-estimate-only-pane',
- props: ['timeEstimateHumanReadable'],
- template: `
- <div class='time-tracking-estimate-only-pane'>
- <span class='bold'>Estimated:</span>
- {{ timeEstimateHumanReadable }}
- </div>
- `,
- });
-})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/help_state.js b/app/assets/javascripts/issuable/time_tracking/components/help_state.js
deleted file mode 100644
index 344b29ebea4..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/components/help_state.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import Vue from 'vue';
-
-(() => {
- Vue.component('time-tracking-help-state', {
- name: 'time-tracking-help-state',
- props: ['docsUrl'],
- template: `
- <div class='time-tracking-help-state'>
- <div class='time-tracking-info'>
- <h4>Track time with slash commands</h4>
- <p>Slash commands can be used in the issues description and comment boxes.</p>
- <p>
- <code>/estimate</code>
- will update the estimated time with the latest command.
- </p>
- <p>
- <code>/spend</code>
- will update the sum of the time spent.
- </p>
- <a class='btn btn-default learn-more-button' :href='docsUrl'>Learn more</a>
- </div>
- </div>
- `,
- });
-})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js b/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js
deleted file mode 100644
index b081adf5e64..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import Vue from 'vue';
-
-(() => {
- Vue.component('time-tracking-no-tracking-pane', {
- name: 'time-tracking-no-tracking-pane',
- template: `
- <div class='time-tracking-no-tracking-pane'>
- <span class='no-value'>No estimate or time spent</span>
- </div>
- `,
- });
-})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js b/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js
deleted file mode 100644
index edb9169112f..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import Vue from 'vue';
-
-(() => {
- Vue.component('time-tracking-spent-only-pane', {
- name: 'time-tracking-spent-only-pane',
- props: ['timeSpentHumanReadable'],
- template: `
- <div class='time-tracking-spend-only-pane'>
- <span class='bold'>Spent:</span>
- {{ timeSpentHumanReadable }}
- </div>
- `,
- });
-})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js b/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js
deleted file mode 100644
index 0213522f551..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js
+++ /dev/null
@@ -1,117 +0,0 @@
-import Vue from 'vue';
-
-require('./help_state');
-require('./collapsed_state');
-require('./spent_only_pane');
-require('./no_tracking_pane');
-require('./estimate_only_pane');
-require('./comparison_pane');
-
-(() => {
- Vue.component('issuable-time-tracker', {
- name: 'issuable-time-tracker',
- props: [
- 'time_estimate',
- 'time_spent',
- 'human_time_estimate',
- 'human_time_spent',
- 'docsUrl',
- ],
- data() {
- return {
- showHelp: false,
- };
- },
- computed: {
- timeSpent() {
- return this.time_spent;
- },
- timeEstimate() {
- return this.time_estimate;
- },
- timeEstimateHumanReadable() {
- return this.human_time_estimate;
- },
- timeSpentHumanReadable() {
- return this.human_time_spent;
- },
- hasTimeSpent() {
- return !!this.timeSpent;
- },
- hasTimeEstimate() {
- return !!this.timeEstimate;
- },
- showComparisonState() {
- return this.hasTimeEstimate && this.hasTimeSpent;
- },
- showEstimateOnlyState() {
- return this.hasTimeEstimate && !this.hasTimeSpent;
- },
- showSpentOnlyState() {
- return this.hasTimeSpent && !this.hasTimeEstimate;
- },
- showNoTimeTrackingState() {
- return !this.hasTimeEstimate && !this.hasTimeSpent;
- },
- showHelpState() {
- return !!this.showHelp;
- },
- },
- methods: {
- toggleHelpState(show) {
- this.showHelp = show;
- },
- },
- template: `
- <div class='time_tracker time-tracking-component-wrap' v-cloak>
- <time-tracking-collapsed-state
- :show-comparison-state='showComparisonState'
- :show-help-state='showHelpState'
- :show-spent-only-state='showSpentOnlyState'
- :show-estimate-only-state='showEstimateOnlyState'
- :time-spent-human-readable='timeSpentHumanReadable'
- :time-estimate-human-readable='timeEstimateHumanReadable'>
- </time-tracking-collapsed-state>
- <div class='title hide-collapsed'>
- Time tracking
- <div class='help-button pull-right'
- v-if='!showHelpState'
- @click='toggleHelpState(true)'>
- <i class='fa fa-question-circle' aria-hidden='true'></i>
- </div>
- <div class='close-help-button pull-right'
- v-if='showHelpState'
- @click='toggleHelpState(false)'>
- <i class='fa fa-close' aria-hidden='true'></i>
- </div>
- </div>
- <div class='time-tracking-content hide-collapsed'>
- <time-tracking-estimate-only-pane
- v-if='showEstimateOnlyState'
- :time-estimate-human-readable='timeEstimateHumanReadable'>
- </time-tracking-estimate-only-pane>
- <time-tracking-spent-only-pane
- v-if='showSpentOnlyState'
- :time-spent-human-readable='timeSpentHumanReadable'>
- </time-tracking-spent-only-pane>
- <time-tracking-no-tracking-pane
- v-if='showNoTimeTrackingState'>
- </time-tracking-no-tracking-pane>
- <time-tracking-comparison-pane
- v-if='showComparisonState'
- :time-estimate='timeEstimate'
- :time-spent='timeSpent'
- :time-spent-human-readable='timeSpentHumanReadable'
- :time-estimate-human-readable='timeEstimateHumanReadable'>
- </time-tracking-comparison-pane>
- <transition name='help-state-toggle'>
- <time-tracking-help-state
- v-if='showHelpState'
- :docs-url='docsUrl'>
- </time-tracking-help-state>
- </transition>
- </div>
- </div>
- `,
- });
-})();
diff --git a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js b/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js
deleted file mode 100644
index 1689a69e1ed..00000000000
--- a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js
+++ /dev/null
@@ -1,66 +0,0 @@
-import Vue from 'vue';
-import VueResource from 'vue-resource';
-
-require('./components/time_tracker');
-require('../../smart_interval');
-require('../../subbable_resource');
-
-Vue.use(VueResource);
-
-(() => {
- /* This Vue instance represents what will become the parent instance for the
- * sidebar. It will be responsible for managing `issuable` state and propagating
- * changes to sidebar components. We will want to create a separate service to
- * interface with the server at that point.
- */
-
- class IssuableTimeTracking {
- constructor(issuableJSON) {
- const parsedIssuable = JSON.parse(issuableJSON);
- return this.initComponent(parsedIssuable);
- }
-
- initComponent(parsedIssuable) {
- this.parentInstance = new Vue({
- el: '#issuable-time-tracker',
- data: {
- issuable: parsedIssuable,
- },
- methods: {
- fetchIssuable() {
- return gl.IssuableResource.get.call(gl.IssuableResource, {
- type: 'GET',
- url: gl.IssuableResource.endpoint,
- });
- },
- updateState(data) {
- this.issuable = data;
- },
- subscribeToUpdates() {
- gl.IssuableResource.subscribe(data => this.updateState(data));
- },
- listenForSlashCommands() {
- $(document).on('ajax:success', '.gfm-form', (e, data) => {
- const subscribedCommands = ['spend_time', 'time_estimate'];
- const changedCommands = data.commands_changes
- ? Object.keys(data.commands_changes)
- : [];
- if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) {
- this.fetchIssuable();
- }
- });
- },
- },
- created() {
- this.fetchIssuable();
- },
- mounted() {
- this.subscribeToUpdates();
- this.listenForSlashCommands();
- },
- });
- }
- }
-
- gl.IssuableTimeTracking = IssuableTimeTracking;
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js
index 834b98e8601..a4d7bf096ef 100644
--- a/app/assets/javascripts/issuable_context.js
+++ b/app/assets/javascripts/issuable_context.js
@@ -1,8 +1,8 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-new, comma-dangle, quotes, prefer-arrow-callback, consistent-return, one-var, no-var, one-var-declaration-per-line, no-underscore-dangle, max-len */
-/* global UsersSelect */
/* global bp */
import Cookies from 'js-cookie';
+import UsersSelect from './users_select';
(function() {
this.IssuableContext = (function() {
@@ -47,7 +47,6 @@ import Cookies from 'js-cookie';
Cookies.set('collapsed_gutter', true);
}
});
- $(".right-sidebar").niceScroll();
}
IssuableContext.prototype.initParticipants = function() {
diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js
index 687c2bb6110..92f6f0d4117 100644
--- a/app/assets/javascripts/issuable_form.js
+++ b/app/assets/javascripts/issuable_form.js
@@ -1,14 +1,14 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-use-before-define, no-useless-escape, no-new, quotes, object-shorthand, no-unused-vars, comma-dangle, no-alert, consistent-return, no-else-return, prefer-template, one-var, one-var-declaration-per-line, curly, max-len */
/* global GitLab */
-/* global UsersSelect */
/* global ZenMode */
/* global Autosave */
/* global dateFormat */
/* global Pikaday */
-(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
+import UsersSelect from './users_select';
+import GfmAutoComplete from './gfm_auto_complete';
+(function() {
this.IssuableForm = (function() {
IssuableForm.prototype.issueMoveConfirmMsg = 'Are you sure you want to move this issue to another project?';
@@ -17,11 +17,11 @@
function IssuableForm(form) {
var $issuableDueDate, calendar;
this.form = form;
- this.toggleWip = bind(this.toggleWip, this);
- this.renderWipExplanation = bind(this.renderWipExplanation, this);
- this.resetAutosave = bind(this.resetAutosave, this);
- this.handleSubmit = bind(this.handleSubmit, this);
- gl.GfmAutoComplete.setup();
+ this.toggleWip = this.toggleWip.bind(this);
+ this.renderWipExplanation = this.renderWipExplanation.bind(this);
+ this.resetAutosave = this.resetAutosave.bind(this);
+ this.handleSubmit = this.handleSubmit.bind(this);
+ new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources).setup();
new UsersSelect();
new ZenMode();
this.titleField = this.form.find("input[name*='[title]']");
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index 694c6177a07..0860e237ce1 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -1,11 +1,11 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, no-underscore-dangle, one-var-declaration-per-line, object-shorthand, no-unused-vars, no-new, comma-dangle, consistent-return, quotes, dot-notation, quote-props, prefer-arrow-callback, max-len */
- /* global Flash */
-import CreateMergeRequestDropdown from './create_merge_request_dropdown';
+/* global Flash */
-require('./flash');
-require('~/lib/utils/text_utility');
-require('vendor/jquery.waitforimages');
-require('./task_list');
+import 'vendor/jquery.waitforimages';
+import '~/lib/utils/text_utility';
+import './flash';
+import './task_list';
+import CreateMergeRequestDropdown from './create_merge_request_dropdown';
class Issue {
constructor() {
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
new file mode 100644
index 00000000000..800bb9f1fe8
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -0,0 +1,245 @@
+<script>
+/* global Flash */
+import Visibility from 'visibilityjs';
+import Poll from '../../lib/utils/poll';
+import eventHub from '../event_hub';
+import Service from '../services/index';
+import Store from '../stores';
+import titleComponent from './title.vue';
+import descriptionComponent from './description.vue';
+import formComponent from './form.vue';
+import '../../lib/utils/url_utility';
+
+export default {
+ props: {
+ endpoint: {
+ required: true,
+ type: String,
+ },
+ canMove: {
+ required: true,
+ type: Boolean,
+ },
+ canUpdate: {
+ required: true,
+ type: Boolean,
+ },
+ canDestroy: {
+ required: true,
+ type: Boolean,
+ },
+ issuableRef: {
+ type: String,
+ required: true,
+ },
+ initialTitleHtml: {
+ type: String,
+ required: true,
+ },
+ initialTitleText: {
+ type: String,
+ required: true,
+ },
+ initialDescriptionHtml: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ initialDescriptionText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ issuableTemplates: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ isConfidential: {
+ type: Boolean,
+ required: true,
+ },
+ markdownPreviewUrl: {
+ type: String,
+ required: true,
+ },
+ markdownDocs: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ projectNamespace: {
+ type: String,
+ required: true,
+ },
+ projectsAutocompleteUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ const store = new Store({
+ titleHtml: this.initialTitleHtml,
+ titleText: this.initialTitleText,
+ descriptionHtml: this.initialDescriptionHtml,
+ descriptionText: this.initialDescriptionText,
+ });
+
+ return {
+ store,
+ state: store.state,
+ showForm: false,
+ };
+ },
+ computed: {
+ formState() {
+ return this.store.formState;
+ },
+ },
+ components: {
+ descriptionComponent,
+ titleComponent,
+ formComponent,
+ },
+ methods: {
+ openForm() {
+ if (!this.showForm) {
+ this.showForm = true;
+ this.store.setFormState({
+ title: this.state.titleText,
+ confidential: this.isConfidential,
+ description: this.state.descriptionText,
+ lockedWarningVisible: false,
+ move_to_project_id: 0,
+ updateLoading: false,
+ });
+ }
+ },
+ closeForm() {
+ this.showForm = false;
+ },
+ updateIssuable() {
+ const canPostUpdate = this.store.formState.move_to_project_id !== 0 ?
+ confirm('Are you sure you want to move this issue to another project?') : true; // eslint-disable-line no-alert
+
+ if (!canPostUpdate) {
+ this.store.setFormState({
+ updateLoading: false,
+ });
+ return;
+ }
+
+ this.service.updateIssuable(this.store.formState)
+ .then(res => res.json())
+ .then((data) => {
+ if (location.pathname !== data.web_url) {
+ gl.utils.visitUrl(data.web_url);
+ } else if (data.confidential !== this.isConfidential) {
+ gl.utils.visitUrl(location.pathname);
+ }
+
+ return this.service.getData();
+ })
+ .then(res => res.json())
+ .then((data) => {
+ this.store.updateState(data);
+ eventHub.$emit('close.form');
+ })
+ .catch(() => {
+ eventHub.$emit('close.form');
+ return new Flash('Error updating issue');
+ });
+ },
+ deleteIssuable() {
+ this.service.deleteIssuable()
+ .then(res => res.json())
+ .then((data) => {
+ // Stop the poll so we don't get 404's with the issue not existing
+ this.poll.stop();
+
+ gl.utils.visitUrl(data.web_url);
+ })
+ .catch(() => {
+ eventHub.$emit('close.form');
+ return new Flash('Error deleting issue');
+ });
+ },
+ },
+ created() {
+ this.service = new Service(this.endpoint);
+ this.poll = new Poll({
+ resource: this.service,
+ method: 'getData',
+ successCallback: (res) => {
+ const data = res.json();
+ const shouldUpdate = this.store.stateShouldUpdate(data);
+
+ this.store.updateState(data);
+
+ if (this.showForm && (shouldUpdate.title || shouldUpdate.description)) {
+ this.store.formState.lockedWarningVisible = true;
+ }
+ },
+ errorCallback(err) {
+ throw new Error(err);
+ },
+ });
+
+ if (!Visibility.hidden()) {
+ this.poll.makeRequest();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ this.poll.restart();
+ } else {
+ this.poll.stop();
+ }
+ });
+
+ eventHub.$on('delete.issuable', this.deleteIssuable);
+ eventHub.$on('update.issuable', this.updateIssuable);
+ eventHub.$on('close.form', this.closeForm);
+ eventHub.$on('open.form', this.openForm);
+ },
+ beforeDestroy() {
+ eventHub.$off('delete.issuable', this.deleteIssuable);
+ eventHub.$off('update.issuable', this.updateIssuable);
+ eventHub.$off('close.form', this.closeForm);
+ eventHub.$off('open.form', this.openForm);
+ },
+};
+</script>
+
+<template>
+ <div>
+ <form-component
+ v-if="canUpdate && showForm"
+ :form-state="formState"
+ :can-move="canMove"
+ :can-destroy="canDestroy"
+ :issuable-templates="issuableTemplates"
+ :markdown-docs="markdownDocs"
+ :markdown-preview-url="markdownPreviewUrl"
+ :project-path="projectPath"
+ :project-namespace="projectNamespace"
+ :projects-autocomplete-url="projectsAutocompleteUrl"
+ />
+ <div v-else>
+ <title-component
+ :issuable-ref="issuableRef"
+ :title-html="state.titleHtml"
+ :title-text="state.titleText" />
+ <description-component
+ v-if="state.descriptionHtml"
+ :can-update="canUpdate"
+ :description-html="state.descriptionHtml"
+ :description-text="state.descriptionText"
+ :updated-at="state.updatedAt"
+ :task-status="state.taskStatus" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue
new file mode 100644
index 00000000000..3281ec6b172
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/description.vue
@@ -0,0 +1,108 @@
+<script>
+ import animateMixin from '../mixins/animate';
+
+ export default {
+ mixins: [animateMixin],
+ props: {
+ canUpdate: {
+ type: Boolean,
+ required: true,
+ },
+ descriptionHtml: {
+ type: String,
+ required: true,
+ },
+ descriptionText: {
+ type: String,
+ required: true,
+ },
+ updatedAt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ taskStatus: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ preAnimation: false,
+ pulseAnimation: false,
+ timeAgoEl: $('.js-issue-edited-ago'),
+ };
+ },
+ watch: {
+ descriptionHtml() {
+ this.animateChange();
+
+ this.$nextTick(() => {
+ const toolTipTime = gl.utils.formatDate(this.updatedAt);
+
+ this.timeAgoEl.attr('datetime', this.updatedAt)
+ .attr('title', toolTipTime)
+ .tooltip('fixTitle');
+
+ this.renderGFM();
+ });
+ },
+ taskStatus() {
+ const taskRegexMatches = this.taskStatus.match(/(\d+) of (\d+)/);
+ const $issuableHeader = $('.issuable-meta');
+ const $tasks = $('#task_status', $issuableHeader);
+ const $tasksShort = $('#task_status_short', $issuableHeader);
+
+ if (taskRegexMatches) {
+ $tasks.text(this.taskStatus);
+ $tasksShort.text(`${taskRegexMatches[1]}/${taskRegexMatches[2]} task${taskRegexMatches[2] > 1 ? 's' : ''}`);
+ } else {
+ $tasks.text('');
+ $tasksShort.text('');
+ }
+ },
+ },
+ methods: {
+ renderGFM() {
+ $(this.$refs['gfm-entry-content']).renderGFM();
+
+ if (this.canUpdate) {
+ // eslint-disable-next-line no-new
+ new gl.TaskList({
+ dataType: 'issue',
+ fieldName: 'description',
+ selector: '.detail-page-description',
+ });
+ }
+ },
+ },
+ mounted() {
+ this.renderGFM();
+ },
+ };
+</script>
+
+<template>
+ <div
+ v-if="descriptionHtml"
+ class="description"
+ :class="{
+ 'js-task-list-container': canUpdate
+ }">
+ <div
+ class="wiki"
+ :class="{
+ 'issue-realtime-pre-pulse': preAnimation,
+ 'issue-realtime-trigger-pulse': pulseAnimation
+ }"
+ v-html="descriptionHtml"
+ ref="gfm-content">
+ </div>
+ <textarea
+ class="hidden js-task-list-field"
+ v-if="descriptionText"
+ v-model="descriptionText">
+ </textarea>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue
new file mode 100644
index 00000000000..8c81575fe6f
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/edit_actions.vue
@@ -0,0 +1,79 @@
+<script>
+ import updateMixin from '../mixins/update';
+ import eventHub from '../event_hub';
+
+ export default {
+ mixins: [updateMixin],
+ props: {
+ canDestroy: {
+ type: Boolean,
+ required: true,
+ },
+ formState: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ deleteLoading: false,
+ };
+ },
+ computed: {
+ isSubmitEnabled() {
+ return this.formState.title.trim() !== '';
+ },
+ },
+ methods: {
+ closeForm() {
+ eventHub.$emit('close.form');
+ },
+ deleteIssuable() {
+ // eslint-disable-next-line no-alert
+ if (confirm('Issue will be removed! Are you sure?')) {
+ this.deleteLoading = true;
+
+ eventHub.$emit('delete.issuable');
+ }
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="prepend-top-default append-bottom-default clearfix">
+ <button
+ class="btn btn-save pull-left"
+ :class="{ disabled: formState.updateLoading || !isSubmitEnabled }"
+ type="submit"
+ :disabled="formState.updateLoading || !isSubmitEnabled"
+ @click.prevent="updateIssuable">
+ Save changes
+ <i
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true"
+ v-if="formState.updateLoading">
+ </i>
+ </button>
+ <button
+ class="btn btn-default pull-right"
+ type="button"
+ @click="closeForm">
+ Cancel
+ </button>
+ <button
+ v-if="canDestroy"
+ class="btn btn-danger pull-right append-right-default"
+ :class="{ disabled: deleteLoading }"
+ type="button"
+ :disabled="deleteLoading"
+ @click="deleteIssuable">
+ Delete
+ <i
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true"
+ v-if="deleteLoading">
+ </i>
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue b/app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue
new file mode 100644
index 00000000000..a0ff08e9111
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue
@@ -0,0 +1,23 @@
+<script>
+ export default {
+ props: {
+ formState: {
+ type: Object,
+ required: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <fieldset class="checkbox">
+ <label for="issue-confidential">
+ <input
+ type="checkbox"
+ value="1"
+ id="issue-confidential"
+ v-model="formState.confidential" />
+ This issue is confidential and should only be visible to team members with at least Reporter access.
+ </label>
+ </fieldset>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue
new file mode 100644
index 00000000000..30a1be5cb50
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/fields/description.vue
@@ -0,0 +1,54 @@
+<script>
+ /* global Flash */
+ import updateMixin from '../../mixins/update';
+ import markdownField from '../../../vue_shared/components/markdown/field.vue';
+
+ export default {
+ mixins: [updateMixin],
+ props: {
+ formState: {
+ type: Object,
+ required: true,
+ },
+ markdownPreviewUrl: {
+ type: String,
+ required: true,
+ },
+ markdownDocs: {
+ type: String,
+ required: true,
+ },
+ },
+ components: {
+ markdownField,
+ },
+ mounted() {
+ this.$refs.textarea.focus();
+ },
+ };
+</script>
+
+<template>
+ <div class="common-note-form">
+ <label
+ class="sr-only"
+ for="issue-description">
+ Description
+ </label>
+ <markdown-field
+ :markdown-preview-url="markdownPreviewUrl"
+ :markdown-docs="markdownDocs">
+ <textarea
+ id="issue-description"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ data-supports-slash-commands="false"
+ aria-label="Description"
+ v-model="formState.description"
+ ref="textarea"
+ slot="textarea"
+ placeholder="Write a comment or drag your files here..."
+ @keydown.meta.enter="updateIssuable">
+ </textarea>
+ </markdown-field>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue
new file mode 100644
index 00000000000..1c40b286513
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue
@@ -0,0 +1,111 @@
+<script>
+ export default {
+ props: {
+ formState: {
+ type: Object,
+ required: true,
+ },
+ issuableTemplates: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ projectNamespace: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ issuableTemplatesJson() {
+ return JSON.stringify(this.issuableTemplates);
+ },
+ },
+ mounted() {
+ // Create the editor for the template
+ const editor = document.querySelector('.detail-page-description .note-textarea') || {};
+ editor.setValue = (val) => {
+ this.formState.description = val;
+ };
+ editor.getValue = () => this.formState.description;
+
+ this.issuableTemplate = new gl.IssuableTemplateSelectors({
+ $dropdowns: $(this.$refs.toggle),
+ editor,
+ });
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="dropdown js-issuable-selector-wrap"
+ data-issuable-type="issue">
+ <button
+ class="dropdown-menu-toggle js-issuable-selector"
+ type="button"
+ ref="toggle"
+ data-field-name="issuable_template"
+ data-selected="null"
+ data-toggle="dropdown"
+ :data-namespace-path="projectNamespace"
+ :data-project-path="projectPath"
+ :data-data="issuableTemplatesJson">
+ <span class="dropdown-toggle-text">
+ Choose a template
+ </span>
+ <i
+ aria-hidden="true"
+ class="fa fa-chevron-down">
+ </i>
+ </button>
+ <div class="dropdown-menu dropdown-select">
+ <div class="dropdown-title">
+ Choose a template
+ <button
+ class="dropdown-title-button dropdown-menu-close"
+ aria-label="Close"
+ type="button">
+ <i
+ aria-hidden="true"
+ class="fa fa-times dropdown-menu-close-icon">
+ </i>
+ </button>
+ </div>
+ <div class="dropdown-input">
+ <input
+ type="search"
+ class="dropdown-input-field"
+ placeholder="Filter"
+ autocomplete="off" />
+ <i
+ aria-hidden="true"
+ class="fa fa-search dropdown-input-search">
+ </i>
+ <i
+ role="button"
+ aria-label="Clear templates search input"
+ class="fa fa-times dropdown-input-clear js-dropdown-input-clear">
+ </i>
+ </div>
+ <div class="dropdown-content"></div>
+ <div class="dropdown-footer">
+ <ul class="dropdown-footer-list">
+ <li>
+ <a class="no-template">
+ No template
+ </a>
+ </li>
+ <li>
+ <a class="reset-template">
+ Reset template
+ </a>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/fields/project_move.vue b/app/assets/javascripts/issue_show/components/fields/project_move.vue
new file mode 100644
index 00000000000..f811fb0de24
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/fields/project_move.vue
@@ -0,0 +1,83 @@
+<script>
+ import tooltipMixin from '../../../vue_shared/mixins/tooltip';
+
+ export default {
+ mixins: [
+ tooltipMixin,
+ ],
+ props: {
+ formState: {
+ type: Object,
+ required: true,
+ },
+ projectsAutocompleteUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ mounted() {
+ const $moveDropdown = $(this.$refs['move-dropdown']);
+
+ $moveDropdown.select2({
+ ajax: {
+ url: this.projectsAutocompleteUrl,
+ quietMillis: 125,
+ data(term, page, context) {
+ return {
+ search: term,
+ offset_id: context,
+ };
+ },
+ results(data) {
+ const more = data.length >= 50;
+ const context = data[data.length - 1] ? data[data.length - 1].id : null;
+
+ return {
+ results: data,
+ more,
+ context,
+ };
+ },
+ },
+ formatResult(project) {
+ return project.name_with_namespace;
+ },
+ formatSelection(project) {
+ return project.name_with_namespace;
+ },
+ })
+ .on('change', (e) => {
+ this.formState.move_to_project_id = parseInt(e.target.value, 10);
+ });
+ },
+ beforeDestroy() {
+ $(this.$refs['move-dropdown']).select2('destroy');
+ },
+ };
+</script>
+
+<template>
+ <fieldset>
+ <label
+ for="issuable-move"
+ class="sr-only">
+ Move
+ </label>
+ <div class="issuable-form-select-holder append-right-5">
+ <input
+ ref="move-dropdown"
+ type="hidden"
+ id="issuable-move"
+ data-placeholder="Move to a different project" />
+ </div>
+ <span
+ data-placement="auto top"
+ title="Moving an issue will copy the discussion to a different project and close it here. All participants will be notified of the new location."
+ ref="tooltip">
+ <i
+ class="fa fa-question-circle"
+ aria-hidden="true">
+ </i>
+ </span>
+ </fieldset>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/fields/title.vue b/app/assets/javascripts/issue_show/components/fields/title.vue
new file mode 100644
index 00000000000..6556bf117e2
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/fields/title.vue
@@ -0,0 +1,31 @@
+<script>
+ import updateMixin from '../../mixins/update';
+
+ export default {
+ mixins: [updateMixin],
+ props: {
+ formState: {
+ type: Object,
+ required: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <fieldset>
+ <label
+ class="sr-only"
+ for="issue-title">
+ Title
+ </label>
+ <input
+ id="issue-title"
+ class="form-control"
+ type="text"
+ placeholder="Issue title"
+ aria-label="Issue title"
+ v-model="formState.title"
+ @keydown.meta.enter="updateIssuable" />
+ </fieldset>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue
new file mode 100644
index 00000000000..76ec3dc9a5d
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/form.vue
@@ -0,0 +1,104 @@
+<script>
+ import lockedWarning from './locked_warning.vue';
+ import titleField from './fields/title.vue';
+ import descriptionField from './fields/description.vue';
+ import editActions from './edit_actions.vue';
+ import descriptionTemplate from './fields/description_template.vue';
+ import projectMove from './fields/project_move.vue';
+ import confidentialCheckbox from './fields/confidential_checkbox.vue';
+
+ export default {
+ props: {
+ canMove: {
+ type: Boolean,
+ required: true,
+ },
+ canDestroy: {
+ type: Boolean,
+ required: true,
+ },
+ formState: {
+ type: Object,
+ required: true,
+ },
+ issuableTemplates: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ markdownPreviewUrl: {
+ type: String,
+ required: true,
+ },
+ markdownDocs: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ projectNamespace: {
+ type: String,
+ required: true,
+ },
+ projectsAutocompleteUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ components: {
+ lockedWarning,
+ titleField,
+ descriptionField,
+ descriptionTemplate,
+ editActions,
+ projectMove,
+ confidentialCheckbox,
+ },
+ computed: {
+ hasIssuableTemplates() {
+ return this.issuableTemplates.length;
+ },
+ },
+ };
+</script>
+
+<template>
+ <form>
+ <locked-warning v-if="formState.lockedWarningVisible" />
+ <div class="row">
+ <div
+ class="col-sm-4 col-lg-3"
+ v-if="hasIssuableTemplates">
+ <description-template
+ :form-state="formState"
+ :issuable-templates="issuableTemplates"
+ :project-path="projectPath"
+ :project-namespace="projectNamespace" />
+ </div>
+ <div
+ :class="{
+ 'col-sm-8 col-lg-9': hasIssuableTemplates,
+ 'col-xs-12': !hasIssuableTemplates,
+ }">
+ <title-field
+ :form-state="formState"
+ :issuable-templates="issuableTemplates" />
+ </div>
+ </div>
+ <description-field
+ :form-state="formState"
+ :markdown-preview-url="markdownPreviewUrl"
+ :markdown-docs="markdownDocs" />
+ <confidential-checkbox
+ :form-state="formState" />
+ <project-move
+ v-if="canMove"
+ :form-state="formState"
+ :projects-autocomplete-url="projectsAutocompleteUrl" />
+ <edit-actions
+ :form-state="formState"
+ :can-destroy="canDestroy" />
+ </form>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/locked_warning.vue b/app/assets/javascripts/issue_show/components/locked_warning.vue
new file mode 100644
index 00000000000..1c2789f154a
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/locked_warning.vue
@@ -0,0 +1,20 @@
+<script>
+ export default {
+ computed: {
+ currentPath() {
+ return location.pathname;
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="alert alert-danger">
+ Someone edited the issue at the same time you did. Please check out
+ <a
+ :href="currentPath"
+ target="_blank"
+ rel="nofollow">the issue</a>
+ and make sure your changes will not unintentionally remove theirs.
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue
new file mode 100644
index 00000000000..a9dabd4cff1
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/title.vue
@@ -0,0 +1,53 @@
+<script>
+ import animateMixin from '../mixins/animate';
+
+ export default {
+ mixins: [animateMixin],
+ data() {
+ return {
+ preAnimation: false,
+ pulseAnimation: false,
+ titleEl: document.querySelector('title'),
+ };
+ },
+ props: {
+ issuableRef: {
+ type: String,
+ required: true,
+ },
+ titleHtml: {
+ type: String,
+ required: true,
+ },
+ titleText: {
+ type: String,
+ required: true,
+ },
+ },
+ watch: {
+ titleHtml() {
+ this.setPageTitle();
+ this.animateChange();
+ },
+ },
+ methods: {
+ setPageTitle() {
+ const currentPageTitleScope = this.titleEl.innerText.split('·');
+ currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `;
+ this.titleEl.textContent = currentPageTitleScope.join('·');
+ },
+ },
+ };
+</script>
+
+<template>
+ <h2
+ class="title"
+ :class="{
+ 'issue-realtime-pre-pulse': preAnimation,
+ 'issue-realtime-trigger-pulse': pulseAnimation
+ }"
+ v-html="titleHtml"
+ >
+ </h2>
+</template>
diff --git a/app/assets/javascripts/issue_show/event_hub.js b/app/assets/javascripts/issue_show/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/issue_show/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js
index 4d491e70d83..faf79471946 100644
--- a/app/assets/javascripts/issue_show/index.js
+++ b/app/assets/javascripts/issue_show/index.js
@@ -1,20 +1,49 @@
import Vue from 'vue';
-import IssueTitle from './issue_title.vue';
+import eventHub from './event_hub';
+import issuableApp from './components/app.vue';
import '../vue_shared/vue_resource_interceptor';
-(() => {
- const issueTitleData = document.querySelector('.issue-title-data').dataset;
- const { initialTitle, endpoint } = issueTitleData;
+document.addEventListener('DOMContentLoaded', () => {
+ const initialDataEl = document.getElementById('js-issuable-app-initial-data');
+ const initialData = JSON.parse(initialDataEl.innerHTML.replace(/&quot;/g, '"'));
- const vm = new Vue({
- el: '.issue-title-entrypoint',
- render: createElement => createElement(IssueTitle, {
- props: {
- initialTitle,
- endpoint,
- },
- }),
+ $('.issuable-edit').on('click', (e) => {
+ e.preventDefault();
+
+ eventHub.$emit('open.form');
});
- return vm;
-})();
+ return new Vue({
+ el: document.getElementById('js-issuable-app'),
+ components: {
+ issuableApp,
+ },
+ data() {
+ return {
+ ...initialData,
+ };
+ },
+ render(createElement) {
+ return createElement('issuable-app', {
+ props: {
+ canUpdate: this.canUpdate,
+ canDestroy: this.canDestroy,
+ canMove: this.canMove,
+ endpoint: this.endpoint,
+ issuableRef: this.issuableRef,
+ initialTitleHtml: this.initialTitleHtml,
+ initialTitleText: this.initialTitleText,
+ initialDescriptionHtml: this.initialDescriptionHtml,
+ initialDescriptionText: this.initialDescriptionText,
+ issuableTemplates: this.issuableTemplates,
+ isConfidential: this.isConfidential,
+ markdownPreviewUrl: this.markdownPreviewUrl,
+ markdownDocs: this.markdownDocs,
+ projectPath: this.projectPath,
+ projectNamespace: this.projectNamespace,
+ projectsAutocompleteUrl: this.projectsAutocompleteUrl,
+ },
+ });
+ },
+ });
+});
diff --git a/app/assets/javascripts/issue_show/issue_title.vue b/app/assets/javascripts/issue_show/issue_title.vue
deleted file mode 100644
index 00b0e56030a..00000000000
--- a/app/assets/javascripts/issue_show/issue_title.vue
+++ /dev/null
@@ -1,80 +0,0 @@
-<script>
-import Visibility from 'visibilityjs';
-import Poll from './../lib/utils/poll';
-import Service from './services/index';
-
-export default {
- props: {
- initialTitle: { required: true, type: String },
- endpoint: { required: true, type: String },
- },
- data() {
- const resource = new Service(this.$http, this.endpoint);
-
- const poll = new Poll({
- resource,
- method: 'getTitle',
- successCallback: (res) => {
- this.renderResponse(res);
- },
- errorCallback: (err) => {
- if (process.env.NODE_ENV !== 'production') {
- // eslint-disable-next-line no-console
- console.error('ISSUE SHOW TITLE REALTIME ERROR', err);
- } else {
- throw new Error(err);
- }
- },
- });
-
- return {
- poll,
- timeoutId: null,
- title: this.initialTitle,
- };
- },
- methods: {
- renderResponse(res) {
- const body = JSON.parse(res.body);
- this.triggerAnimation(body);
- },
- triggerAnimation(body) {
- const { title } = body;
-
- /**
- * since opacity is changed, even if there is no diff for Vue to update
- * we must check the title even on a 304 to ensure no visual change
- */
- if (this.title === title) return;
-
- this.$el.style.opacity = 0;
-
- this.timeoutId = setTimeout(() => {
- this.title = title;
-
- this.$el.style.transition = 'opacity 0.2s ease';
- this.$el.style.opacity = 1;
-
- clearTimeout(this.timeoutId);
- }, 100);
- },
- },
- created() {
- if (!Visibility.hidden()) {
- this.poll.makeRequest();
- }
-
- Visibility.change(() => {
- if (!Visibility.hidden()) {
- this.poll.restart();
- } else {
- this.poll.stop();
- }
- });
- },
-};
-</script>
-
-<template>
- <h2 class="title" v-html="title"></h2>
-</template>
diff --git a/app/assets/javascripts/issue_show/mixins/animate.js b/app/assets/javascripts/issue_show/mixins/animate.js
new file mode 100644
index 00000000000..4816393da1f
--- /dev/null
+++ b/app/assets/javascripts/issue_show/mixins/animate.js
@@ -0,0 +1,13 @@
+export default {
+ methods: {
+ animateChange() {
+ this.preAnimation = true;
+ this.pulseAnimation = false;
+
+ setTimeout(() => {
+ this.preAnimation = false;
+ this.pulseAnimation = true;
+ });
+ },
+ },
+};
diff --git a/app/assets/javascripts/issue_show/mixins/update.js b/app/assets/javascripts/issue_show/mixins/update.js
new file mode 100644
index 00000000000..72be65b426f
--- /dev/null
+++ b/app/assets/javascripts/issue_show/mixins/update.js
@@ -0,0 +1,10 @@
+import eventHub from '../event_hub';
+
+export default {
+ methods: {
+ updateIssuable() {
+ this.formState.updateLoading = true;
+ eventHub.$emit('update.issuable');
+ },
+ },
+};
diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issue_show/services/index.js
index c4ab0b1e07a..6f0fd0b1768 100644
--- a/app/assets/javascripts/issue_show/services/index.js
+++ b/app/assets/javascripts/issue_show/services/index.js
@@ -1,10 +1,29 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+Vue.use(VueResource);
+
export default class Service {
- constructor(resource, endpoint) {
- this.resource = resource;
+ constructor(endpoint) {
this.endpoint = endpoint;
+
+ this.resource = Vue.resource(`${this.endpoint}.json`, {}, {
+ realtimeChanges: {
+ method: 'GET',
+ url: `${this.endpoint}/realtime_changes`,
+ },
+ });
+ }
+
+ getData() {
+ return this.resource.realtimeChanges();
+ }
+
+ deleteIssuable() {
+ return this.resource.delete();
}
- getTitle() {
- return this.resource.get(this.endpoint);
+ updateIssuable(data) {
+ return this.resource.update(data);
}
}
diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js
new file mode 100644
index 00000000000..4a16c3cb4dc
--- /dev/null
+++ b/app/assets/javascripts/issue_show/stores/index.js
@@ -0,0 +1,45 @@
+export default class Store {
+ constructor({
+ titleHtml,
+ titleText,
+ descriptionHtml,
+ descriptionText,
+ }) {
+ this.state = {
+ titleHtml,
+ titleText,
+ descriptionHtml,
+ descriptionText,
+ taskStatus: '',
+ updatedAt: '',
+ };
+ this.formState = {
+ title: '',
+ confidential: false,
+ description: '',
+ lockedWarningVisible: false,
+ move_to_project_id: 0,
+ updateLoading: false,
+ };
+ }
+
+ updateState(data) {
+ this.state.titleHtml = data.title;
+ this.state.titleText = data.title_text;
+ this.state.descriptionHtml = data.description;
+ this.state.descriptionText = data.description_text;
+ this.state.taskStatus = data.task_status;
+ this.state.updatedAt = data.updated_at;
+ }
+
+ stateShouldUpdate(data) {
+ return {
+ title: this.state.titleText !== data.title_text,
+ description: this.state.descriptionText !== data.description_text,
+ };
+ }
+
+ setFormState(state) {
+ this.formState = Object.assign(this.formState, state);
+ }
+}
diff --git a/app/assets/javascripts/issue_status_select.js b/app/assets/javascripts/issue_status_select.js
index b2cfd3ef2a3..56cb536dcde 100644
--- a/app/assets/javascripts/issue_status_select.js
+++ b/app/assets/javascripts/issue_status_select.js
@@ -19,8 +19,8 @@
return label;
};
})(this),
- clicked: function(item, $el, e) {
- return e.preventDefault();
+ clicked: function(options) {
+ return options.e.preventDefault();
},
id: function(obj, el) {
return $(el).data("id");
diff --git a/app/assets/javascripts/issues_bulk_assignment.js b/app/assets/javascripts/issues_bulk_assignment.js
index e0ebd36a65c..fee3429e2b8 100644
--- a/app/assets/javascripts/issues_bulk_assignment.js
+++ b/app/assets/javascripts/issues_bulk_assignment.js
@@ -88,7 +88,10 @@
const formData = {
update: {
state_event: this.form.find('input[name="update[state_event]"]').val(),
+ // For Merge Requests
assignee_id: this.form.find('input[name="update[assignee_id]"]').val(),
+ // For Issues
+ assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()],
milestone_id: this.form.find('input[name="update[milestone_id]"]').val(),
issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(),
subscription_event: this.form.find('input[name="update[subscription_event]"]').val(),
diff --git a/app/assets/javascripts/labels.js b/app/assets/javascripts/labels.js
index 17a3fc1b1e4..03dd61b4263 100644
--- a/app/assets/javascripts/labels.js
+++ b/app/assets/javascripts/labels.js
@@ -1,11 +1,9 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, vars-on-top, no-unused-vars, max-len */
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.Labels = (function() {
function Labels() {
- this.setSuggestedColor = bind(this.setSuggestedColor, this);
- this.updateColorPreview = bind(this.updateColorPreview, this);
+ this.setSuggestedColor = this.setSuggestedColor.bind(this);
+ this.updateColorPreview = this.updateColorPreview.bind(this);
var form;
form = $('.label-form');
this.cleanBinding();
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index 9a60f5464df..ac5ce84e31b 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -330,7 +330,10 @@
},
multiSelect: $dropdown.hasClass('js-multiselect'),
vue: $dropdown.hasClass('js-issue-board-sidebar'),
- clicked: function(label, $el, e, isMarking) {
+ clicked: function(options) {
+ const { $el, e, isMarking } = options;
+ const label = options.selectedObj;
+
var isIssueIndex, isMRIndex, page, boardsModel;
var fadeOutLoader = () => {
$loading.fadeOut();
@@ -352,7 +355,7 @@
if ($dropdown.hasClass('js-filter-bulk-update')) {
_this.enableBulkLabelDropdown();
- _this.setDropdownData($dropdown, isMarking, this.id(label));
+ _this.setDropdownData($dropdown, isMarking, label.id);
return;
}
diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js
index a5f99bcdd8f..71064ccc539 100644
--- a/app/assets/javascripts/layout_nav.js
+++ b/app/assets/javascripts/layout_nav.js
@@ -1,4 +1,5 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, no-unused-vars, one-var, one-var-declaration-per-line, vars-on-top, max-len */
+import _ from 'underscore';
(function() {
var hideEndFade;
@@ -45,4 +46,13 @@
}
});
});
+
+ function applyScrollNavClass() {
+ const scrollOpacityHeight = 40;
+ $('.navbar-border').css('opacity', Math.min($(window).scrollTop() / scrollOpacityHeight, 1));
+ }
+
+ $(() => {
+ $(window).on('scroll', _.throttle(applyScrollNavClass, 100));
+ });
}).call(window);
diff --git a/app/assets/javascripts/lib/utils/accessor.js b/app/assets/javascripts/lib/utils/accessor.js
new file mode 100644
index 00000000000..1d18992af63
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/accessor.js
@@ -0,0 +1,47 @@
+function isPropertyAccessSafe(base, property) {
+ let safe;
+
+ try {
+ safe = !!base[property];
+ } catch (error) {
+ safe = false;
+ }
+
+ return safe;
+}
+
+function isFunctionCallSafe(base, functionName, ...args) {
+ let safe = true;
+
+ try {
+ base[functionName](...args);
+ } catch (error) {
+ safe = false;
+ }
+
+ return safe;
+}
+
+function isLocalStorageAccessSafe() {
+ let safe;
+
+ const TEST_KEY = 'isLocalStorageAccessSafe';
+ const TEST_VALUE = 'true';
+
+ safe = isPropertyAccessSafe(window, 'localStorage');
+ if (!safe) return safe;
+
+ safe = isFunctionCallSafe(window.localStorage, 'setItem', TEST_KEY, TEST_VALUE);
+
+ if (safe) window.localStorage.removeItem(TEST_KEY);
+
+ return safe;
+}
+
+const AccessorUtilities = {
+ isPropertyAccessSafe,
+ isFunctionCallSafe,
+ isLocalStorageAccessSafe,
+};
+
+export default AccessorUtilities;
diff --git a/app/assets/javascripts/lib/utils/ajax_cache.js b/app/assets/javascripts/lib/utils/ajax_cache.js
new file mode 100644
index 00000000000..f1fe95e12e8
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/ajax_cache.js
@@ -0,0 +1,44 @@
+import Cache from './cache';
+
+class AjaxCache extends Cache {
+ constructor() {
+ super();
+ this.pendingRequests = { };
+ }
+
+ retrieve(endpoint) {
+ if (this.hasData(endpoint)) {
+ return Promise.resolve(this.get(endpoint));
+ }
+
+ let pendingRequest = this.pendingRequests[endpoint];
+
+ if (!pendingRequest) {
+ pendingRequest = new Promise((resolve, reject) => {
+ // jQuery 2 is not Promises/A+ compatible (missing catch)
+ $.ajax(endpoint) // eslint-disable-line promise/catch-or-return
+ .then(data => resolve(data),
+ (jqXHR, textStatus, errorThrown) => {
+ const error = new Error(`${endpoint}: ${errorThrown}`);
+ error.textStatus = textStatus;
+ reject(error);
+ },
+ );
+ })
+ .then((data) => {
+ this.internalStorage[endpoint] = data;
+ delete this.pendingRequests[endpoint];
+ })
+ .catch((error) => {
+ delete this.pendingRequests[endpoint];
+ throw error;
+ });
+
+ this.pendingRequests[endpoint] = pendingRequest;
+ }
+
+ return pendingRequest.then(() => this.get(endpoint));
+ }
+}
+
+export default new AjaxCache();
diff --git a/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js b/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js
index 2955bda1a36..0bf2ba6acc2 100644
--- a/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js
+++ b/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js
@@ -31,82 +31,78 @@
*
* ### How to use
*
- * new window.gl.LinkedTabs({
+ * new LinkedTabs({
* action: "#{controller.action_name}",
* defaultAction: 'tab1',
* parentEl: '.tab-links'
* });
*/
-(() => {
- window.gl = window.gl || {};
+export default class LinkedTabs {
+ /**
+ * Binds the events and activates de default tab.
+ *
+ * @param {Object} options
+ */
+ constructor(options = {}) {
+ this.options = options;
- window.gl.LinkedTabs = class LinkedTabs {
- /**
- * Binds the events and activates de default tab.
- *
- * @param {Object} options
- */
- constructor(options) {
- this.options = options || {};
+ this.defaultAction = this.options.defaultAction;
+ this.action = this.options.action || this.defaultAction;
- this.defaultAction = this.options.defaultAction;
- this.action = this.options.action || this.defaultAction;
-
- if (this.action === 'show') {
- this.action = this.defaultAction;
- }
+ if (this.action === 'show') {
+ this.action = this.defaultAction;
+ }
- this.currentLocation = window.location;
+ this.currentLocation = window.location;
- const tabSelector = `${this.options.parentEl} a[data-toggle="tab"]`;
+ const tabSelector = `${this.options.parentEl} a[data-toggle="tab"]`;
- // since this is a custom event we need jQuery :(
- $(document)
- .off('shown.bs.tab', tabSelector)
- .on('shown.bs.tab', tabSelector, e => this.tabShown(e));
+ // since this is a custom event we need jQuery :(
+ $(document)
+ .off('shown.bs.tab', tabSelector)
+ .on('shown.bs.tab', tabSelector, e => this.tabShown(e));
- this.activateTab(this.action);
- }
+ this.activateTab(this.action);
+ }
- /**
- * Handles the `shown.bs.tab` event to set the currect url action.
- *
- * @param {type} evt
- * @return {Function}
- */
- tabShown(evt) {
- const source = evt.target.getAttribute('href');
+ /**
+ * Handles the `shown.bs.tab` event to set the currect url action.
+ *
+ * @param {type} evt
+ * @return {Function}
+ */
+ tabShown(evt) {
+ const source = evt.target.getAttribute('href');
- return this.setCurrentAction(source);
- }
+ return this.setCurrentAction(source);
+ }
- /**
- * Updates the URL with the path that matched the given action.
- *
- * @param {String} source
- * @return {String}
- */
- setCurrentAction(source) {
- const copySource = source;
+ /**
+ * Updates the URL with the path that matched the given action.
+ *
+ * @param {String} source
+ * @return {String}
+ */
+ setCurrentAction(source) {
+ const copySource = source;
- copySource.replace(/\/+$/, '');
+ copySource.replace(/\/+$/, '');
- const newState = `${copySource}${this.currentLocation.search}${this.currentLocation.hash}`;
+ const newState = `${copySource}${this.currentLocation.search}${this.currentLocation.hash}`;
- history.replaceState({
- url: newState,
- }, document.title, newState);
- return newState;
- }
+ history.replaceState({
+ url: newState,
+ }, document.title, newState);
+ return newState;
+ }
- /**
- * Given the current action activates the correct tab.
- * http://getbootstrap.com/javascript/#tab-show
- * Note: Will trigger `shown.bs.tab`
- */
- activateTab() {
- return $(`${this.options.parentEl} a[data-action='${this.action}']`).tab('show');
- }
- };
-})();
+ /**
+ * Given the current action activates the correct tab.
+ * http://getbootstrap.com/javascript/#tab-show
+ * Note: Will trigger `shown.bs.tab`
+ */
+ activateTab() {
+ return $(`${this.options.parentEl} a[data-action='${this.action}']`).tab('show');
+ }
+}
diff --git a/app/assets/javascripts/lib/utils/cache.js b/app/assets/javascripts/lib/utils/cache.js
new file mode 100644
index 00000000000..3141f1eeafc
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/cache.js
@@ -0,0 +1,19 @@
+class Cache {
+ constructor() {
+ this.internalStorage = { };
+ }
+
+ get(key) {
+ return this.internalStorage[key];
+ }
+
+ hasData(key) {
+ return Object.prototype.hasOwnProperty.call(this.internalStorage, key);
+ }
+
+ remove(key) {
+ delete this.internalStorage[key];
+ }
+}
+
+export default Cache;
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 8058672eaa9..a537267643e 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -35,6 +35,14 @@
});
};
+ w.gl.utils.ajaxPost = function(url, data) {
+ return $.ajax({
+ type: 'POST',
+ url: url,
+ data: data,
+ });
+ };
+
w.gl.utils.extractLast = function(term) {
return this.split(term).pop();
};
@@ -127,7 +135,10 @@
gl.utils.getUrlParamsArray = function () {
// We can trust that each param has one & since values containing & will be encoded
// Remove the first character of search as it is always ?
- return window.location.search.slice(1).split('&');
+ return window.location.search.slice(1).split('&').map((param) => {
+ const split = param.split('=');
+ return [decodeURI(split[0]), split[1]].join('=');
+ });
};
gl.utils.isMetaKey = function(e) {
@@ -187,10 +198,12 @@
const textBefore = value.substring(0, selectionStart);
const textAfter = value.substring(selectionEnd, value.length);
- const newText = textBefore + text + textAfter;
+
+ const insertedText = text instanceof Function ? text(textBefore, textAfter) : text;
+ const newText = textBefore + insertedText + textAfter;
target.value = newText;
- target.selectionStart = target.selectionEnd = selectionStart + text.length;
+ target.selectionStart = target.selectionEnd = selectionStart + insertedText.length;
// Trigger autosave
$(target).trigger('input');
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index 82dcbdc26c8..b2f48049bb4 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -1,9 +1,10 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, comma-dangle, no-unused-expressions, prefer-template, max-len */
-/* global timeago */
-/* global dateFormat */
-window.timeago = require('timeago.js');
-window.dateFormat = require('vendor/date.format');
+import timeago from 'timeago.js';
+import dateFormat from 'vendor/date.format';
+
+window.timeago = timeago;
+window.dateFormat = dateFormat;
(function() {
(function(w) {
@@ -101,8 +102,7 @@ window.dateFormat = require('vendor/date.format');
};
w.gl.utils.updateTimeagoText = function(el) {
- const timeago = gl.utils.getTimeago();
- const formattedDate = timeago.format(el.getAttribute('datetime'), 'gl_en');
+ const formattedDate = gl.utils.getTimeago().format(el.getAttribute('datetime'), 'gl_en');
if (el.textContent !== formattedDate) {
el.textContent = formattedDate;
diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js
index bc109a69c20..415e50f32ae 100644
--- a/app/assets/javascripts/lib/utils/http_status.js
+++ b/app/assets/javascripts/lib/utils/http_status.js
@@ -2,9 +2,7 @@
* exports HTTP status codes
*/
-const statusCodes = {
+export default {
NO_CONTENT: 204,
OK: 200,
};
-
-module.exports = statusCodes;
diff --git a/app/assets/javascripts/lib/utils/notify.js b/app/assets/javascripts/lib/utils/notify.js
index 66f39122a66..973d6119158 100644
--- a/app/assets/javascripts/lib/utils/notify.js
+++ b/app/assets/javascripts/lib/utils/notify.js
@@ -1,47 +1,48 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, one-var-declaration-per-line, consistent-return, prefer-arrow-callback, no-return-assign, object-shorthand, comma-dangle, no-param-reassign, max-len */
-(function() {
- (function(w) {
- var notificationGranted, notifyMe, notifyPermissions;
- notificationGranted = function(message, opts, onclick) {
- var notification;
- notification = new Notification(message, opts);
- setTimeout(function() {
- return notification.close();
- // Hide the notification after X amount of seconds
- }, 8000);
- if (onclick) {
- return notification.onclick = onclick;
- }
- };
- notifyPermissions = function() {
- if ('Notification' in window) {
- return Notification.requestPermission();
- }
- };
- notifyMe = function(message, body, icon, onclick) {
- var opts;
- opts = {
- body: body,
- icon: icon
- };
- // Let's check if the browser supports notifications
- if (!('Notification' in window)) {
+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);
+
+ return notification.onclick = onclick || notification.close;
+}
- // do nothing
- } else if (Notification.permission === 'granted') {
- // If it's okay let's create a notification
+function notifyPermissions() {
+ if ('Notification' in window) {
+ return Notification.requestPermission();
+ }
+}
+
+function notifyMe(message, body, icon, onclick) {
+ var opts;
+ opts = {
+ body: body,
+ icon: icon
+ };
+ // Let's check if the browser supports notifications
+ if (!('Notification' in window)) {
+ // do nothing
+ } else if (Notification.permission === 'granted') {
+ // If it's okay let's create a notification
+ return notificationGranted(message, opts, onclick);
+ } else if (Notification.permission !== 'denied') {
+ return Notification.requestPermission(function(permission) {
+ // If the user accepts, let's create a notification
+ if (permission === 'granted') {
return notificationGranted(message, opts, onclick);
- } else if (Notification.permission !== 'denied') {
- return Notification.requestPermission(function(permission) {
- // If the user accepts, let's create a notification
- if (permission === 'granted') {
- return notificationGranted(message, opts, onclick);
- }
- });
}
- };
- w.notify = notifyMe;
- return w.notifyPermissions = notifyPermissions;
- })(window);
-}).call(window);
+ });
+ }
+}
+
+const notify = {
+ notificationGranted,
+ notifyPermissions,
+ notifyMe,
+};
+
+export default notify;
diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js
index f1b07408671..57394097944 100644
--- a/app/assets/javascripts/lib/utils/number_utils.js
+++ b/app/assets/javascripts/lib/utils/number_utils.js
@@ -42,3 +42,13 @@ export function formatRelevantDigits(number) {
export function bytesToKiB(number) {
return number / BYTES_IN_KIB;
}
+
+/**
+ * Utility function that calculates MiB of the given bytes.
+ *
+ * @param {Number} number bytes
+ * @return {Number} MiB
+ */
+export function bytesToMiB(number) {
+ return number / (BYTES_IN_KIB * BYTES_IN_KIB);
+}
diff --git a/app/assets/javascripts/lib/utils/simple_poll.js b/app/assets/javascripts/lib/utils/simple_poll.js
new file mode 100644
index 00000000000..25ca98afbe7
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/simple_poll.js
@@ -0,0 +1,15 @@
+export default (fn, interval = 2000, timeout = 60000) => {
+ const startTime = Date.now();
+
+ return new Promise((resolve, reject) => {
+ const stop = arg => ((arg instanceof Error) ? reject(arg) : resolve(arg));
+ const next = () => {
+ if (Date.now() - startTime < timeout) {
+ setTimeout(fn.bind(null, next, stop), interval);
+ } else {
+ reject(new Error('SIMPLE_POLL_TIMEOUT'));
+ }
+ };
+ fn(next, stop);
+ });
+};
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index fecd531328d..601d01e1be1 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -1,5 +1,6 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, quotes, one-var, one-var-declaration-per-line, operator-assignment, no-else-return, prefer-template, prefer-arrow-callback, no-empty, max-len, consistent-return, no-unused-vars, no-return-assign, max-len, vars-on-top */
-require('vendor/latinise');
+
+import 'vendor/latinise';
var base;
var w = window;
@@ -169,7 +170,7 @@ gl.text.init = function(form) {
});
};
gl.text.removeListeners = function(form) {
- return $('.js-md', form).off();
+ return $('.js-md', form).off('click');
};
gl.text.humanize = function(string) {
return string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1);
diff --git a/app/assets/javascripts/lib/utils/type_utility.js b/app/assets/javascripts/lib/utils/type_utility.js
index db62e0be324..be86f336bcd 100644
--- a/app/assets/javascripts/lib/utils/type_utility.js
+++ b/app/assets/javascripts/lib/utils/type_utility.js
@@ -1,15 +1,2 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, no-return-assign, max-len */
-(function() {
- (function(w) {
- var base;
- if (w.gl == null) {
- w.gl = {};
- }
- if ((base = w.gl).utils == null) {
- base.utils = {};
- }
- return w.gl.utils.isObject = function(obj) {
- return (obj != null) && (obj.constructor === Object);
- };
- })(window);
-}).call(window);
+// eslint-disable-next-line import/prefer-default-export
+export const isObject = obj => obj && obj.constructor === Object;
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index b9d2fc25c39..3328ff9cc23 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -66,7 +66,8 @@ w.gl.utils.removeParamQueryString = function(url, param) {
})()).join('&');
};
w.gl.utils.removeParams = (params) => {
- const url = new URL(window.location.href);
+ const url = document.createElement('a');
+ url.href = window.location.href;
params.forEach((param) => {
url.search = w.gl.utils.removeParamQueryString(url.search, param);
});
diff --git a/app/assets/javascripts/lib/utils/users_cache.js b/app/assets/javascripts/lib/utils/users_cache.js
new file mode 100644
index 00000000000..88f8a622c00
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/users_cache.js
@@ -0,0 +1,28 @@
+import Api from '../../api';
+import Cache from './cache';
+
+class UsersCache extends Cache {
+ retrieve(username) {
+ if (this.hasData(username)) {
+ return Promise.resolve(this.get(username));
+ }
+
+ return Api.users('', { username })
+ .then((users) => {
+ if (!users.length) {
+ throw new Error(`User "${username}" could not be found!`);
+ }
+
+ if (users.length > 1) {
+ throw new Error(`Expected username "${username}" to be unique!`);
+ }
+
+ const user = users[0];
+ this.internalStorage[username] = user;
+ return user;
+ });
+ // missing catch is intentional, error handling depends on use case
+ }
+}
+
+export default new UsersCache();
diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js
index 3ac6dedf131..7400c22543f 100644
--- a/app/assets/javascripts/line_highlighter.js
+++ b/app/assets/javascripts/line_highlighter.js
@@ -4,8 +4,6 @@
//
// Handles single- and multi-line selection and highlight for blob views.
//
-require('vendor/jquery.scrollTo');
-
//
// ### Example Markup
//
@@ -31,8 +29,6 @@ require('vendor/jquery.scrollTo');
// </div>
//
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.LineHighlighter = (function() {
// CSS class applied to highlighted lines
LineHighlighter.prototype.highlightClass = 'hll';
@@ -47,9 +43,9 @@ require('vendor/jquery.scrollTo');
// hash - String URL hash for dependency injection in tests
hash = location.hash;
}
- this.setHash = bind(this.setHash, this);
- this.highlightLine = bind(this.highlightLine, this);
- this.clickHandler = bind(this.clickHandler, this);
+ this.setHash = this.setHash.bind(this);
+ this.highlightLine = this.highlightLine.bind(this);
+ this.clickHandler = this.clickHandler.bind(this);
this.highlightHash = this.highlightHash.bind(this);
this._hash = hash;
this.bindEvents();
diff --git a/app/assets/javascripts/locale/de/app.js b/app/assets/javascripts/locale/de/app.js
new file mode 100644
index 00000000000..9411f078ecf
--- /dev/null
+++ b/app/assets/javascripts/locale/de/app.js
@@ -0,0 +1 @@
+var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-09 13:44+0200","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":["Von"],"Commit":["Commit","Commits"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics liefern einen Überblick darüber, wie viel Zeit in Ihrem Projekt von einer Idee bis zum Produktivdeployment vergeht."],"CycleAnalyticsStage|Code":["Code"],"CycleAnalyticsStage|Issue":["Issue"],"CycleAnalyticsStage|Plan":["Planung"],"CycleAnalyticsStage|Production":["Produktiv"],"CycleAnalyticsStage|Review":["Review"],"CycleAnalyticsStage|Staging":["Staging"],"CycleAnalyticsStage|Test":["Test"],"Deploy":["Deployment","Deployments"],"FirstPushedBy|First":["Erster"],"FirstPushedBy|pushed by":["gepusht von"],"From issue creation until deploy to production":["Vom Anlegen des Issues bis zum Produktivdeployment"],"From merge request merge until deploy to production":["Vom Merge Request bis zum Produktivdeployment"],"Introducing Cycle Analytics":["Was sind Cycle Analytics?"],"Last %d day":["Letzter %d Tag","Letzten %d Tage"],"Limited to showing %d event at most":["Eingeschränkt auf maximal %d Ereignis","Eingeschränkt auf maximal %d Ereignisse"],"Median":["Median"],"New Issue":["Neues Issue","Neue Issues"],"Not available":["Nicht verfügbar"],"Not enough data":["Nicht genügend Daten"],"OpenedNDaysAgo|Opened":["Erstellt"],"Pipeline Health":["Pipeline Kennzahlen"],"ProjectLifecycle|Stage":["Phase"],"Read more":["Mehr"],"Related Commits":["Zugehörige Commits"],"Related Deployed Jobs":["Zugehörige Deploymentjobs"],"Related Issues":["Zugehörige Issues"],"Related Jobs":["Zugehörige Jobs"],"Related Merge Requests":["Zugehörige Merge Requests"],"Related Merged Requests":["Zugehörige abgeschlossene Merge Requests"],"Showing %d event":["Zeige %d Ereignis","Zeige %d Ereignisse"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["Die Code-Phase stellt die Zeit vom ersten Commit bis zum Erstellen eines Merge Requests dar. Sobald Sie Ihren ersten Merge Request anlegen, werden dessen Daten automatisch ergänzt."],"The collection of events added to the data gathered for that stage.":["Ereignisse, die für diese Phase ausgewertet wurden."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["Die Issue-Phase stellt die Zeit vom Anlegen eines Issues bis zum Zuweisen eines Meilensteins oder Hinzufügen zum Issue Board dar. Erstellen Sie einen Issue, damit dessen Daten hier erscheinen."],"The phase of the development lifecycle.":["Die Phase im Entwicklungsprozess."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["Die Planungsphase stellt die Zeit von der vorherigen Phase bis zum Pushen des ersten Commits dar. Sobald Sie den ersten Commit pushen, werden dessen Daten hier erscheinen."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["Die Produktiv-Phase stellt die Gesamtzeit vom Anlegen eines Issues bis zum Deployment auf dem Produktivsystem dar. Sobald Sie den vollständigen Entwicklungszyklus von einer Idee bis zum Produktivdeployment durchlaufen haben, erscheinen die zugehörigen Daten hier."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["Die Review-Phase stellt die Zeit vom Anlegen eines Merge Requests bis zum Mergen dar. Sobald Sie Ihren ersten Merge Request abschließen, werden dessen Daten hier automatisch angezeigt."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["Die Staging-Phase stellt die Zeit zwischen Mergen eines Merge Requests und dem Produktivdeployment dar. Sobald Sie das erste Produktivdeployment durchgeführt haben, werden dessen Daten hier automatisch angezeigt."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["Die Test-Phase stellt die Zeit dar, die GitLab CI benötigt um die Pipelines von Merge Requests abzuarbeiten. Sobald die erste Pipeline abgeschlossen ist, werden deren Daten hier automatisch angezeigt."],"The time taken by each data entry gathered by that stage.":["Zeit die für das jeweilige Ereignis in der Phase ermittelt wurde."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["Der mittlere aller erfassten Werte. Zum Beispiel ist für 3, 5, 9 der Median 5. Bei 3, 5, 7, 8 ist der Median (5+7)/2 = 6."],"Time before an issue gets scheduled":["Zeit bis ein Issue geplant wird"],"Time before an issue starts implementation":["Zeit bis die Implementierung für ein Issue beginnt"],"Time between merge request creation and merge/close":["Zeit zwischen Anlegen und Mergen/Schließen eines Merge Requests"],"Time until first merge request":["Zeit bis zum ersten Merge Request"],"Time|hr":["h","h"],"Time|min":["min","min"],"Time|s":["s"],"Total Time":["Gesamtzeit"],"Total test time for all commits/merges":["Gesamte Testlaufzeit für alle Commits/Merges"],"Want to see the data? Please ask an administrator for access.":["Um diese Daten einsehen zu können, wenden Sie sich bitte an Ihren Administrator."],"We don't have enough data to show this stage.":["Es liegen nicht genügend Daten vor, um diese Phase anzuzeigen."],"You need permission.":["Sie benötigen Zugriffsrechte."],"day":["Tag","Tage"]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/en/app.js b/app/assets/javascripts/locale/en/app.js
new file mode 100644
index 00000000000..ade9b667b3c
--- /dev/null
+++ b/app/assets/javascripts/locale/en/app.js
@@ -0,0 +1 @@
+var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":[""],"Commit":["",""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issue":["",""],"Not available":[""],"Not enough data":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Deployed Jobs":[""],"Related Issues":[""],"Related Jobs":[""],"Related Merge Requests":[""],"Related Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/es/app.js b/app/assets/javascripts/locale/es/app.js
new file mode 100644
index 00000000000..f5f510d7c2b
--- /dev/null
+++ b/app/assets/javascripts/locale/es/app.js
@@ -0,0 +1 @@
+var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-20 22:37-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":["por"],"Commit":["Cambio","Cambios"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"CycleAnalyticsStage|Code":["Código"],"CycleAnalyticsStage|Issue":["Incidencia"],"CycleAnalyticsStage|Plan":["Planificación"],"CycleAnalyticsStage|Production":["Producción"],"CycleAnalyticsStage|Review":["Revisión"],"CycleAnalyticsStage|Staging":["Puesta en escena"],"CycleAnalyticsStage|Test":["Pruebas"],"Deploy":["Despliegue","Despliegues"],"FirstPushedBy|First":["Primer"],"FirstPushedBy|pushed by":["enviado por"],"From issue creation until deploy to production":["Desde la creación de la incidencia hasta el despliegue a producción"],"From merge request merge until deploy to production":["Desde la integración de la solicitud de fusión hasta el despliegue a producción"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last %d day":["Último %d día","Últimos %d días"],"Limited to showing %d event at most":["Limitado a mostrar máximo %d evento","Limitado a mostrar máximo %d eventos"],"Median":["Mediana"],"New Issue":["Nueva incidencia","Nuevas incidencias"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"OpenedNDaysAgo|Opened":["Abierto"],"Pipeline Health":["Estado del Pipeline"],"ProjectLifecycle|Stage":["Etapa"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Deployed Jobs":["Trabajos Desplegados Relacionados"],"Related Issues":["Incidencias Relacionadas"],"Related Jobs":["Trabajos Relacionados"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Related Merged Requests":["Solicitudes de fusión Relacionadas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de desarrollo muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquí una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":["La colección de eventos agregados a los datos recopilados para esa etapa."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":["La etapa del ciclo de vida de desarrollo."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envío de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envíe el primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["El tiempo utilizado por cada entrada de datos obtenido por esa etapa."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."],"Time before an issue gets scheduled":["Tiempo antes de que una incidencia sea programada"],"Time before an issue starts implementation":["Tiempo antes de que empieze la implementación de una incidencia"],"Time between merge request creation and merge/close":["Tiempo entre la creación de la solicitud de fusión y la integración o cierre de ésta"],"Time until first merge request":["Tiempo hasta la primera solicitud de fusión"],"Time|hr":["hr","hrs"],"Time|min":["min","mins"],"Time|s":["s"],"Total Time":["Tiempo Total"],"Total test time for all commits/merges":["Tiempo total de pruebas para todos los cambios o integraciones"],"Want to see the data? Please ask an administrator for access.":["¿Quieres ver los datos? Por favor pide acceso al administrador."],"We don't have enough data to show this stage.":["No hay suficientes datos para mostrar en esta etapa."],"You need permission.":["Necesitas permisos."],"day":["día","días"]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js
new file mode 100644
index 00000000000..7ba676d6d20
--- /dev/null
+++ b/app/assets/javascripts/locale/index.js
@@ -0,0 +1,70 @@
+import Jed from 'jed';
+
+/**
+ This is required to require all the translation folders in the current directory
+ this saves us having to do this manually & keep up to date with new languages
+**/
+function requireAll(requireContext) { return requireContext.keys().map(requireContext); }
+
+const allLocales = requireAll(require.context('./', true, /^(?!.*(?:index.js$)).*\.js$/));
+const locales = allLocales.reduce((d, obj) => {
+ const data = d;
+ const localeKey = Object.keys(obj)[0];
+
+ data[localeKey] = obj[localeKey];
+
+ return data;
+}, {});
+
+let lang = document.querySelector('html').getAttribute('lang') || 'en';
+lang = lang.replace(/-/g, '_');
+
+const locale = new Jed(locales[lang]);
+
+/**
+ Translates `text`
+
+ @param text The text to be translated
+ @returns {String} The translated text
+**/
+const gettext = locale.gettext.bind(locale);
+
+/**
+ Translate the text with a number
+ if the number is more than 1 it will use the `pluralText` translation.
+ This method allows for contexts, see below re. contexts
+
+ @param text Singular text to translate (eg. '%d day')
+ @param pluralText Plural text to translate (eg. '%d days')
+ @param count Number to decide which translation to use (eg. 2)
+ @returns {String} Translated text with the number replaced (eg. '2 days')
+**/
+const ngettext = (text, pluralText, count) => {
+ const translated = locale.ngettext(text, pluralText, count).replace(/%d/g, count).split('|');
+
+ return translated[translated.length - 1];
+};
+
+/**
+ Translate context based text
+ Either pass in the context translation like `Context|Text to translate`
+ or allow for dynamic text by doing passing in the context first & then the text to translate
+
+ @param keyOrContext Can be either the key to translate including the context
+ (eg. 'Context|Text') or just the context for the translation
+ (eg. 'Context')
+ @param key Is the dynamic variable you want to be translated
+ @returns {String} Translated context based text
+**/
+const pgettext = (keyOrContext, key) => {
+ const normalizedKey = key ? `${keyOrContext}|${key}` : keyOrContext;
+ const translated = gettext(normalizedKey).split('|');
+
+ return translated[translated.length - 1];
+};
+
+export { lang };
+export { gettext as __ };
+export { ngettext as n__ };
+export { pgettext as s__ };
+export default locale;
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index be3c2c9fbb1..1ac82b7e291 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -56,10 +56,8 @@ import './lib/utils/animate';
import './lib/utils/bootstrap_linked_tabs';
import './lib/utils/common_utils';
import './lib/utils/datetime_utility';
-import './lib/utils/notify';
import './lib/utils/pretty_time';
import './lib/utils/text_utility';
-import './lib/utils/type_utility';
import './lib/utils/url_utility';
// u2f
@@ -97,7 +95,6 @@ import './dropzone_input';
import './due_date_select';
import './files_comment_button';
import './flash';
-import './gfm_auto_complete';
import './gl_dropdown';
import './gl_field_error';
import './gl_field_errors';
@@ -123,8 +120,6 @@ import './member_expiration_date';
import './members';
import './merge_request';
import './merge_request_tabs';
-import './merge_request_widget';
-import './merged_buttons';
import './milestone';
import './milestone_select';
import './mini_pipeline_graph_dropdown';
@@ -158,7 +153,6 @@ import './single_file_diff';
import './smart_interval';
import './snippets_list';
import './star';
-import './subbable_resource';
import './subscription';
import './subscription_select';
import './syntax_highlight';
@@ -175,7 +169,7 @@ import './visibility_select';
import './wikis';
import './zen_mode';
-// eslint-disable-next-line global-require
+// eslint-disable-next-line global-require, import/no-commonjs
if (process.env.NODE_ENV !== 'production') require('./test_utils/');
document.addEventListener('beforeunload', function () {
diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js
index e3f367a11eb..8291b8c4a70 100644
--- a/app/assets/javascripts/members.js
+++ b/app/assets/javascripts/members.js
@@ -31,8 +31,8 @@
toggleLabel(selected, $el) {
return $el.text();
},
- clicked: (selected, $link) => {
- this.formSubmit(null, $link);
+ clicked: (options) => {
+ this.formSubmit(null, options.$el);
},
});
});
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
index 15992460146..17030c3e4d3 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
+++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
@@ -2,14 +2,13 @@
/* global Flash */
import Vue from 'vue';
-
-require('./merge_conflict_store');
-require('./merge_conflict_service');
-require('./mixins/line_conflict_utils');
-require('./mixins/line_conflict_actions');
-require('./components/diff_file_editor');
-require('./components/inline_conflict_lines');
-require('./components/parallel_conflict_lines');
+import './merge_conflict_store';
+import './merge_conflict_service';
+import './mixins/line_conflict_utils';
+import './mixins/line_conflict_actions';
+import './components/diff_file_editor';
+import './components/inline_conflict_lines';
+import './components/parallel_conflict_lines';
$(() => {
const INTERACTIVE_RESOLVE_MODE = 'interactive';
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index 5e01aacf2ba..f93feeec1c2 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -1,13 +1,11 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, quotes, no-underscore-dangle, one-var, one-var-declaration-per-line, consistent-return, dot-notation, quote-props, comma-dangle, object-shorthand, max-len, prefer-arrow-callback */
/* global MergeRequestTabs */
-require('vendor/jquery.waitforimages');
-require('./task_list');
-require('./merge_request_tabs');
+import 'vendor/jquery.waitforimages';
+import './task_list';
+import './merge_request_tabs';
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.MergeRequest = (function() {
function MergeRequest(opts) {
// Initialize MergeRequest behavior
@@ -16,7 +14,7 @@ require('./merge_request_tabs');
// action - String, current controller action
//
this.opts = opts != null ? opts : {};
- this.submitNoteForm = bind(this.submitNoteForm, this);
+ this.submitNoteForm = this.submitNoteForm.bind(this);
this.$el = $('.merge-request');
this.$('.show-all-commits').on('click', (function(_this) {
return function() {
@@ -106,6 +104,21 @@ require('./merge_request_tabs');
});
};
+ MergeRequest.prototype.updateStatusText = function(classToRemove, classToAdd, newStatusText) {
+ $('.detail-page-header .status-box')
+ .removeClass(classToRemove)
+ .addClass(classToAdd)
+ .find('span')
+ .text(newStatusText);
+ };
+
+ MergeRequest.prototype.decreaseCounter = function(by = 1) {
+ const $el = $('.nav-links .js-merge-counter');
+ const count = Math.max((parseInt($el.text().replace(/[^\d]/, ''), 10) - by), 0);
+
+ $el.text(gl.text.addDelimiter(count));
+ };
+
return MergeRequest;
})();
}).call(window);
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 93c30c54a8e..894ed81b044 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -1,6 +1,7 @@
/* eslint-disable no-new, class-methods-use-this */
/* global Breakpoints */
/* global Flash */
+/* global notes */
import Cookies from 'js-cookie';
import './breakpoints';
@@ -251,7 +252,8 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
this.ajaxGet({
url: `${urlPathname}.json${location.search}`,
success: (data) => {
- $('#diffs').html(data.html);
+ const $container = $('#diffs');
+ $container.html(data.html);
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
gl.diffNotesCompileComponents();
@@ -278,6 +280,24 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
})
.init();
});
+
+ // Scroll any linked note into view
+ // Similar to `toggler_behavior` in the discussion tab
+ const hash = window.gl.utils.getLocationHash();
+ const anchor = hash && $container.find(`[id="${hash}"]`);
+ if (anchor && anchor.length > 0) {
+ const notesContent = anchor.closest('.notes_content');
+ const lineType = notesContent.hasClass('new') ? 'new' : 'old';
+ notes.toggleDiffNote({
+ target: anchor,
+ lineType,
+ forceShow: true,
+ });
+ anchor[0].scrollIntoView();
+ // We have multiple elements on the page with `#note_xxx`
+ // (discussion and diff tabs) and `:target` only applies to the first
+ anchor.addClass('target');
+ }
},
});
}
@@ -353,18 +373,26 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
initAffix() {
const $tabs = $('.js-tabs-affix');
+ const $fixedNav = $('.navbar-gitlab');
// Screen space on small screens is usually very sparse
// So we dont affix the tabs on these
if (Breakpoints.get().getBreakpointSize() === 'xs' || !$tabs.length) return;
+ /**
+ If the browser does not support position sticky, it returns the position as static.
+ If the browser does support sticky, then we allow the browser to handle it, if not
+ then we default back to Bootstraps affix
+ **/
+ if ($tabs.css('position') !== 'static') return;
+
const $diffTabs = $('#diff-notes-app');
$tabs.off('affix.bs.affix affix-top.bs.affix')
.affix({
offset: {
top: () => (
- $diffTabs.offset().top - $tabs.height()
+ $diffTabs.offset().top - $tabs.height() - $fixedNav.height()
),
},
})
diff --git a/app/assets/javascripts/merge_request_widget.js b/app/assets/javascripts/merge_request_widget.js
deleted file mode 100644
index 42ecf0d6cb2..00000000000
--- a/app/assets/javascripts/merge_request_widget.js
+++ /dev/null
@@ -1,305 +0,0 @@
-/* eslint-disable max-len, no-var, func-names, space-before-function-paren, vars-on-top, comma-dangle, no-return-assign, consistent-return, no-param-reassign, one-var, one-var-declaration-per-line, quotes, prefer-template, no-else-return, prefer-arrow-callback, no-unused-vars, no-underscore-dangle, no-shadow, no-mixed-operators, camelcase, default-case, wrap-iife */
-/* global notify */
-/* global notifyPermissions */
-/* global merge_request_widget */
-
-import './smart_interval';
-import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
-
-((global) => {
- var indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i += 1) { if (i in this && this[i] === item) return i; } return -1; };
-
- const DEPLOYMENT_TEMPLATE = `<div class="mr-widget-heading" id="<%- id %>">
- <div class="ci_widget ci-success">
- <%= ci_success_icon %>
- <span>
- Deployed to
- <a href="<%- url %>" target="_blank" rel="noopener noreferrer" class="environment">
- <%- name %>
- </a>
- <span class="js-environment-timeago" data-toggle="tooltip" data-placement="top" data-title="<%- deployed_at_formatted %>">
- <%- deployed_at %>
- </span>
- <a class="js-environment-link" href="<%- external_url %>" target="_blank" rel="noopener noreferrer">
- <i class="fa fa-external-link"></i>
- View on <%- external_url_formatted %>
- </a>
- </span>
- <span class="stop-env-container js-stop-env-link">
- <a href="<%- stop_url %>" class="close-evn-link" data-method="post" rel="nofollow" data-confirm="Are you sure you want to stop this environment?">
- <i class="fa fa-stop-circle-o"/>
- Stop environment
- </a>
- </span>
- </div>
- </div>`;
-
- global.MergeRequestWidget = (function() {
- function MergeRequestWidget(opts) {
- // Initialize MergeRequestWidget behavior
- //
- // check_enable - Boolean, whether to check automerge status
- // merge_check_url - String, URL to use to check automerge status
- // ci_status_url - String, URL to use to check CI status
- // pipeline_status_url - String, URL to use to get CI status for Favicon
- //
- this.opts = opts;
- this.opts.pipeline_status_url = `${this.opts.pipeline_status_url}.json`;
- this.$widgetBody = $('.mr-widget-body');
- $('#modal_merge_info').modal({
- show: false
- });
- this.clearEventListeners();
- this.addEventListeners();
- this.getCIStatus(false);
- this.retrieveSuccessIcon();
-
- this.initMiniPipelineGraph();
-
- this.ciStatusInterval = new global.SmartInterval({
- callback: this.getCIStatus.bind(this, true),
- startingInterval: 10000,
- maxInterval: 30000,
- hiddenInterval: 120000,
- incrementByFactorOf: 5000,
- });
- this.ciEnvironmentStatusInterval = new global.SmartInterval({
- callback: this.getCIEnvironmentsStatus.bind(this),
- startingInterval: 30000,
- maxInterval: 120000,
- hiddenInterval: 240000,
- incrementByFactorOf: 15000,
- immediateExecution: true,
- });
-
- notifyPermissions();
- }
-
- MergeRequestWidget.prototype.clearEventListeners = function() {
- return $(document).off('DOMContentLoaded');
- };
-
- MergeRequestWidget.prototype.addEventListeners = function() {
- var allowedPages;
- allowedPages = ['show', 'commits', 'pipelines', 'changes'];
- $(document).on('DOMContentLoaded', (function(_this) {
- return function() {
- var page;
- page = $('body').data('page').split(':').last();
- if (allowedPages.indexOf(page) === -1) {
- return _this.clearEventListeners();
- }
- };
- })(this));
- };
-
- MergeRequestWidget.prototype.retrieveSuccessIcon = function() {
- const $ciSuccessIcon = $('.js-success-icon');
- this.$ciSuccessIcon = $ciSuccessIcon.html();
- $ciSuccessIcon.remove();
- };
-
- MergeRequestWidget.prototype.mergeInProgress = function(deleteSourceBranch) {
- if (deleteSourceBranch == null) {
- deleteSourceBranch = false;
- }
- return $.ajax({
- type: 'GET',
- url: $('.merge-request').data('url'),
- success: (function(_this) {
- return function(data) {
- var callback, urlSuffix;
- if (data.state === "merged") {
- urlSuffix = deleteSourceBranch ? '?deleted_source_branch=true' : '';
- return window.location.href = window.location.pathname + urlSuffix;
- } else if (data.merge_error) {
- return $('.mr-widget-body').html("<h4>" + data.merge_error + "</h4>");
- } else {
- callback = function() {
- return merge_request_widget.mergeInProgress(deleteSourceBranch);
- };
- return setTimeout(callback, 2000);
- }
- };
- })(this),
- dataType: 'json'
- });
- };
-
- MergeRequestWidget.prototype.cancelPolling = function () {
- this.ciStatusInterval.cancel();
- this.ciEnvironmentStatusInterval.cancel();
- };
-
- MergeRequestWidget.prototype.getMergeStatus = function() {
- return $.get(this.opts.merge_check_url, (data) => {
- var $html = $(data);
- this.updateMergeButton(this.status, this.hasCi, $html);
- $('.mr-widget-body').replaceWith($html.find('.mr-widget-body'));
- $('.mr-widget-footer').replaceWith($html.find('.mr-widget-footer'));
- });
- };
-
- MergeRequestWidget.prototype.ciLabelForStatus = function(status) {
- switch (status) {
- case 'success':
- return 'passed';
- case 'success_with_warnings':
- return 'passed with warnings';
- default:
- return status;
- }
- };
-
- MergeRequestWidget.prototype.getCIStatus = function(showNotification) {
- var _this;
- _this = this;
- $('.ci-widget-fetching').show();
- return $.getJSON(this.opts.ci_status_url, (function(_this) {
- return function(data) {
- var message, status, title, callback;
- _this.status = data.status;
- _this.hasCi = data.has_ci;
- _this.updateMergeButton(_this.status, _this.hasCi);
- gl.utils.setCiStatusFavicon(_this.opts.pipeline_status_url);
- if (data.environments && data.environments.length) _this.renderEnvironments(data.environments);
- if (data.status !== _this.opts.ci_status ||
- data.sha !== _this.opts.ci_sha ||
- data.pipeline !== _this.opts.ci_pipeline) {
- _this.opts.ci_status = data.status;
- _this.showCIStatus(data.status);
- if (data.coverage) {
- _this.showCICoverage(data.coverage);
- }
- if (data.pipeline) {
- _this.opts.ci_pipeline = data.pipeline;
- _this.updatePipelineUrls(data.pipeline);
- }
- if (data.sha) {
- _this.opts.ci_sha = data.sha;
- _this.updateCommitUrls(data.sha);
- }
- if (data.status === "success" || data.status === "failed") {
- callback = function() {
- return _this.getMergeStatus();
- };
- return setTimeout(callback, 2000);
- }
- if (showNotification && data.status) {
- status = _this.ciLabelForStatus(data.status);
- if (status === "preparing") {
- title = _this.opts.ci_title.preparing;
- status = status.charAt(0).toUpperCase() + status.slice(1);
- message = _this.opts.ci_message.preparing.replace('{{status}}', status);
- } else {
- title = _this.opts.ci_title.normal;
- message = _this.opts.ci_message.normal.replace('{{status}}', status);
- }
- title = title.replace('{{status}}', status);
- message = message.replace('{{sha}}', data.sha);
- message = message.replace('{{title}}', data.title);
- notify(title, message, _this.opts.gitlab_icon, function() {
- this.close();
- });
- }
- }
- };
- })(this));
- };
-
- MergeRequestWidget.prototype.getCIEnvironmentsStatus = function() {
- $.getJSON(this.opts.ci_environments_status_url, (environments) => {
- if (environments && environments.length) this.renderEnvironments(environments);
- });
- };
-
- MergeRequestWidget.prototype.renderEnvironments = function(environments) {
- for (let i = 0; i < environments.length; i += 1) {
- const environment = environments[i];
- if ($(`.mr-state-widget #${environment.id}`).length) return;
- const $template = $(DEPLOYMENT_TEMPLATE);
- if (!environment.external_url || !environment.external_url_formatted) $('.js-environment-link', $template).remove();
-
- if (!environment.stop_url) {
- $('.js-stop-env-link', $template).remove();
- }
-
- if (environment.deployed_at && environment.deployed_at_formatted) {
- environment.deployed_at = gl.utils.getTimeago().format(environment.deployed_at, 'gl_en') + '.';
- } else {
- $('.js-environment-timeago', $template).remove();
- environment.name += '.';
- }
- environment.ci_success_icon = this.$ciSuccessIcon;
- const templateString = _.unescape($template[0].outerHTML);
- const template = _.template(templateString)(environment);
- this.$widgetBody.before(template);
- }
- };
-
- MergeRequestWidget.prototype.showCIStatus = function(state) {
- var allowed_states;
- if (state == null) {
- return;
- }
- $('.ci_widget').hide();
- $('.ci_widget.ci-' + state).show();
-
- this.initMiniPipelineGraph();
- };
-
- MergeRequestWidget.prototype.showCICoverage = function(coverage) {
- var text = `Coverage ${coverage}%`;
- return $('.ci_widget:visible .ci-coverage').text(text);
- };
-
- MergeRequestWidget.prototype.updateMergeButton = function(state, hasCi, $html) {
- const allowed_states = ["failed", "canceled", "running", "pending", "success", "success_with_warnings", "skipped", "not_found"];
- let stateClass = 'btn-danger';
- if (!hasCi) {
- stateClass = 'btn-create';
- } else if (indexOf.call(allowed_states, state) !== -1) {
- switch (state) {
- case "failed":
- case "canceled":
- case "not_found":
- stateClass = 'btn-danger';
- break;
- case "running":
- stateClass = 'btn-info';
- break;
- case "success":
- case "success_with_warnings":
- stateClass = 'btn-create';
- }
- } else {
- $('.ci_widget.ci-error').show();
- stateClass = 'btn-danger';
- }
-
- this.setMergeButtonClass(stateClass, $html);
- };
-
- MergeRequestWidget.prototype.setMergeButtonClass = function(css_class, $html = $('.mr-state-widget')) {
- return $html.find('.js-merge-button').removeClass('btn-danger btn-info btn-create').addClass(css_class);
- };
-
- MergeRequestWidget.prototype.updatePipelineUrls = function(id) {
- const pipelineUrl = this.opts.pipeline_path;
- $('.pipeline').text(`#${id}`).attr('href', [pipelineUrl, id].join('/'));
- };
-
- MergeRequestWidget.prototype.updateCommitUrls = function(id) {
- const commitsUrl = this.opts.commits_path;
- $('.js-commit-link').text(`#${id}`).attr('href', [commitsUrl, id].join('/'));
- };
-
- MergeRequestWidget.prototype.initMiniPipelineGraph = function() {
- new MiniPipelineGraph({
- container: '.js-pipeline-inline-mr-widget-graph:visible',
- }).bindEvents();
- };
-
- return MergeRequestWidget;
- })();
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/merge_request_widget/ci_bundle.js b/app/assets/javascripts/merge_request_widget/ci_bundle.js
deleted file mode 100644
index 21d7c3e168e..00000000000
--- a/app/assets/javascripts/merge_request_widget/ci_bundle.js
+++ /dev/null
@@ -1,53 +0,0 @@
-/* global merge_request_widget */
-
-(() => {
- $(() => {
- /* TODO: This needs a better home, or should be refactored. It was previously contained
- * in a script tag in app/views/projects/merge_requests/widget/open/_accept.html.haml,
- * but Vue chokes on script tags and prevents their execution. So it was moved here
- * temporarily.
- * */
-
- $(document)
- .off('ajax:send', '.accept-mr-form')
- .on('ajax:send', '.accept-mr-form', () => {
- $('.accept-mr-form :input').disable();
- });
-
- $(document)
- .off('click', '.accept-merge-request')
- .on('click', '.accept-merge-request', () => {
- $('.js-merge-button, .js-merge-when-pipeline-succeeds-button').html('<i class="fa fa-spinner fa-spin"></i> Merge in progress');
- });
-
- $(document)
- .off('click', '.merge-when-pipeline-succeeds')
- .on('click', '.merge-when-pipeline-succeeds', () => {
- $('#merge_when_pipeline_succeeds').val('1');
- });
-
- $(document)
- .off('click', '.js-merge-dropdown a')
- .on('click', '.js-merge-dropdown a', (e) => {
- e.preventDefault();
- $(e.target).closest('form').submit();
- });
- if ($('.rebase-in-progress').length) {
- merge_request_widget.rebaseInProgress();
- } else if ($('.rebase-mr-form').length) {
- $(document)
- .off('ajax:send', '.rebase-mr-form')
- .on('ajax:send', '.rebase-mr-form', () => {
- $('.rebase-mr-form :input').disable();
- });
-
- $(document)
- .off('click', '.js-rebase-button')
- .on('click', '.js-rebase-button', () => {
- $('.js-rebase-button').html("<i class='fa fa-spinner fa-spin'></i> Rebase in progress");
- });
- } else {
- setTimeout(() => merge_request_widget.getMergeStatus(), 200);
- }
- });
-})();
diff --git a/app/assets/javascripts/merged_buttons.js b/app/assets/javascripts/merged_buttons.js
deleted file mode 100644
index 7b0997c6520..00000000000
--- a/app/assets/javascripts/merged_buttons.js
+++ /dev/null
@@ -1,47 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, max-len */
-
-import '~/lib/utils/url_utility';
-
-(function() {
- this.MergedButtons = (function() {
- function MergedButtons() {
- this.removeSourceBranch = this.removeSourceBranch.bind(this);
- this.removeBranchSuccess = this.removeBranchSuccess.bind(this);
- this.removeBranchError = this.removeBranchError.bind(this);
- this.$removeBranchWidget = $('.remove_source_branch_widget');
- this.$removeBranchProgress = $('.remove_source_branch_in_progress');
- this.$removeBranchFailed = $('.remove_source_branch_widget.failed');
- this.cleanEventListeners();
- this.initEventListeners();
- }
-
- MergedButtons.prototype.cleanEventListeners = function() {
- $(document).off('click', '.remove_source_branch');
- $(document).off('ajax:success', '.remove_source_branch');
- return $(document).off('ajax:error', '.remove_source_branch');
- };
-
- MergedButtons.prototype.initEventListeners = function() {
- $(document).on('click', '.remove_source_branch', this.removeSourceBranch);
- $(document).on('ajax:success', '.remove_source_branch', this.removeBranchSuccess);
- $(document).on('ajax:error', '.remove_source_branch', this.removeBranchError);
- };
-
- MergedButtons.prototype.removeSourceBranch = function() {
- this.$removeBranchWidget.hide();
- return this.$removeBranchProgress.show();
- };
-
- MergedButtons.prototype.removeBranchSuccess = function() {
- gl.utils.refreshCurrentPage();
- };
-
- MergedButtons.prototype.removeBranchError = function() {
- this.$removeBranchWidget.hide();
- this.$removeBranchProgress.hide();
- return this.$removeBranchFailed.show();
- };
-
- return MergedButtons;
- })();
-}).call(window);
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index bebd0aa357e..9d481d7c003 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -18,12 +18,11 @@
}
$els.each(function(i, dropdown) {
- var $block, $dropdown, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, collapsedSidebarLabelTemplate, defaultLabel, issuableId, issueUpdateURL, milestoneLinkNoneTemplate, milestoneLinkTemplate, milestonesUrl, projectId, selectedMilestone, showAny, showNo, showUpcoming, showStarted, useId, showMenuAbove;
+ var $block, $dropdown, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, collapsedSidebarLabelTemplate, defaultLabel, defaultNo, issuableId, issueUpdateURL, milestoneLinkNoneTemplate, milestoneLinkTemplate, milestonesUrl, projectId, selectedMilestone, selectedMilestoneDefault, showAny, showNo, showUpcoming, showStarted, useId, showMenuAbove;
$dropdown = $(dropdown);
projectId = $dropdown.data('project-id');
milestonesUrl = $dropdown.data('milestones');
issueUpdateURL = $dropdown.data('issueUpdate');
- selectedMilestone = $dropdown.data('selected');
showNo = $dropdown.data('show-no');
showAny = $dropdown.data('show-any');
showMenuAbove = $dropdown.data('showMenuAbove');
@@ -31,6 +30,7 @@
showStarted = $dropdown.data('show-started');
useId = $dropdown.data('use-id');
defaultLabel = $dropdown.data('default-label');
+ defaultNo = $dropdown.data('default-no');
issuableId = $dropdown.data('issuable-id');
abilityName = $dropdown.data('ability-name');
$selectbox = $dropdown.closest('.selectbox');
@@ -38,6 +38,9 @@
$sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon');
$value = $block.find('.value');
$loading = $block.find('.block-loading').fadeOut();
+ selectedMilestoneDefault = (showAny ? '' : null);
+ selectedMilestoneDefault = (showNo && defaultNo ? 'No Milestone' : selectedMilestoneDefault);
+ selectedMilestone = $dropdown.data('selected') || selectedMilestoneDefault;
if (issueUpdateURL) {
milestoneLinkTemplate = _.template('<a href="/<%- full_path %>/milestones/<%- iid %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>');
milestoneLinkNoneTemplate = '<span class="no-value">None</span>';
@@ -86,8 +89,18 @@
if (showMenuAbove) {
$dropdown.data('glDropdown').positionMenuAbove();
}
+ $(`[data-milestone-id="${selectedMilestone}"] > a`).addClass('is-active');
});
},
+ renderRow: function(milestone) {
+ return `
+ <li data-milestone-id="${milestone.name}">
+ <a href='#' class='dropdown-menu-milestone-link'>
+ ${_.escape(milestone.title)}
+ </a>
+ </li>
+ `;
+ },
filterable: true,
search: {
fields: ['title']
@@ -120,12 +133,24 @@
// display:block overrides the hide-collapse rule
return $value.css('display', '');
},
+ opened: function(e) {
+ const $el = $(e.currentTarget);
+ if ($dropdown.hasClass('js-issue-board-sidebar')) {
+ selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault;
+ }
+ $('a.is-active', $el).removeClass('is-active');
+ $(`[data-milestone-id="${selectedMilestone}"] > a`, $el).addClass('is-active');
+ },
vue: $dropdown.hasClass('js-issue-board-sidebar'),
- clicked: function(selected, $el, e) {
- var data, isIssueIndex, isMRIndex, page, boardsStore;
+ clicked: function(options) {
+ const { $el, e } = options;
+ let selected = options.selectedObj;
+ var data, isIssueIndex, isMRIndex, isSelecting, page, boardsStore;
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
isMRIndex = (page === page && page === 'projects:merge_requests:index');
+ isSelecting = (selected.name !== selectedMilestone);
+ selectedMilestone = isSelecting ? selected.name : selectedMilestoneDefault;
if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) {
e.preventDefault();
return;
@@ -139,16 +164,11 @@
boardsStore[$dropdown.data('field-name')] = selected.name;
e.preventDefault();
} else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
- if (selected.name != null) {
- selectedMilestone = selected.name;
- } else {
- selectedMilestone = '';
- }
return Issuable.filterResults($dropdown.closest('form'));
} else if ($dropdown.hasClass('js-filter-submit')) {
return $dropdown.closest('form').submit();
} else if ($dropdown.hasClass('js-issue-board-sidebar')) {
- if (selected.id !== -1) {
+ if (selected.id !== -1 && isSelecting) {
gl.issueBoards.boardStoreIssueSet('milestone', new ListMilestone({
id: selected.id,
title: selected.name
diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js
index b98e6121967..5da2db063a4 100644
--- a/app/assets/javascripts/namespace_select.js
+++ b/app/assets/javascripts/namespace_select.js
@@ -1,12 +1,10 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, vars-on-top, one-var-declaration-per-line, comma-dangle, object-shorthand, no-else-return, prefer-template, quotes, prefer-arrow-callback, no-param-reassign, no-cond-assign, max-len */
-/* global Api */
+import Api from './api';
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
window.NamespaceSelect = (function() {
function NamespaceSelect(opts) {
- this.onSelectItem = bind(this.onSelectItem, this);
+ this.onSelectItem = this.onSelectItem.bind(this);
var fieldName, showAny;
this.dropdown = opts.dropdown;
showAny = true;
@@ -58,7 +56,8 @@
});
}
- NamespaceSelect.prototype.onSelectItem = function(item, el, e) {
+ NamespaceSelect.prototype.onSelectItem = function(options) {
+ const { e } = options;
return e.preventDefault();
};
diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js
index 5828f460a23..39fb302b644 100644
--- a/app/assets/javascripts/new_branch_form.js
+++ b/app/assets/javascripts/new_branch_form.js
@@ -1,15 +1,14 @@
/* eslint-disable func-names, space-before-function-paren, no-var, one-var, prefer-rest-params, max-len, vars-on-top, wrap-iife, consistent-return, comma-dangle, one-var-declaration-per-line, quotes, no-return-assign, prefer-arrow-callback, prefer-template, no-shadow, no-else-return, max-len, object-shorthand */
-(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; },
- indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i += 1) { if (i in this && this[i] === item) return i; } return -1; };
+import RefSelectDropdown from '~/ref_select_dropdown';
+(function() {
this.NewBranchForm = (function() {
function NewBranchForm(form, availableRefs) {
- this.validate = bind(this.validate, this);
+ this.validate = this.validate.bind(this);
this.branchNameError = form.find('.js-branch-name-error');
this.name = form.find('.js-branch-name');
this.ref = form.find('#ref');
- this.setupAvailableRefs(availableRefs);
+ new RefSelectDropdown($('.js-branch-select'), availableRefs); // eslint-disable-line no-new
this.setupRestrictions();
this.addBinding();
this.init();
@@ -25,33 +24,6 @@
}
};
- NewBranchForm.prototype.setupAvailableRefs = function(availableRefs) {
- var $branchSelect = $('.js-branch-select');
-
- $branchSelect.glDropdown({
- data: availableRefs,
- filterable: true,
- filterByText: true,
- remote: false,
- fieldName: $branchSelect.data('field-name'),
- selectable: true,
- isSelectable: function(branch, $el) {
- return !$el.hasClass('is-active');
- },
- text: function(branch) {
- return branch;
- },
- id: function(branch) {
- return branch;
- },
- toggleLabel: function(branch) {
- if (branch) {
- return branch;
- }
- }
- });
- };
-
NewBranchForm.prototype.setupRestrictions = function() {
var endsWith, invalid, single, startsWith;
startsWith = {
@@ -79,6 +51,8 @@
NewBranchForm.prototype.validate = function() {
var errorMessage, errors, formatter, unique, validator;
+ const indexOf = [].indexOf;
+
this.branchNameError.empty();
unique = function(values, value) {
if (indexOf.call(values, value) === -1) {
diff --git a/app/assets/javascripts/new_commit_form.js b/app/assets/javascripts/new_commit_form.js
index ad36f08840d..658879607e2 100644
--- a/app/assets/javascripts/new_commit_form.js
+++ b/app/assets/javascripts/new_commit_form.js
@@ -1,12 +1,10 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-return-assign, max-len */
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.NewCommitForm = (function() {
function NewCommitForm(form, targetBranchName = 'target_branch') {
this.form = form;
this.targetBranchName = targetBranchName;
- this.renderDestination = bind(this.renderDestination, this);
+ this.renderDestination = this.renderDestination.bind(this);
this.targetBranchDropdown = form.find('button.js-target-branch');
this.originalBranch = form.find('.js-original-branch');
this.createMergeRequest = form.find('.js-create-merge-request');
diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue
index 3e8240d10ec..814d2ea92b4 100644
--- a/app/assets/javascripts/notebook/cells/markdown.vue
+++ b/app/assets/javascripts/notebook/cells/markdown.vue
@@ -30,7 +30,7 @@
|
\\s\\$(?!\\$)
)
- (.+?)
+ ((.|\\n)+?)
(
\\s\\\\end{[a-zA-Z]+}$
|
@@ -45,15 +45,25 @@
let inline = false;
if (typeof katex !== 'undefined') {
- const katexString = text.replace(/\\/g, '\\');
- const matches = new RegExp(katexRegexString, 'gi').exec(katexString);
+ const katexString = text.replace(/&amp;/g, '&')
+ .replace(/&=&/g, '\\space=\\space')
+ .replace(/<(\/?)em>/g, '_');
+ const regex = new RegExp(katexRegexString, 'gi');
+ const matchLocation = katexString.search(regex);
+ const numberOfMatches = katexString.match(regex);
- if (matches && matches.length > 0) {
- if (matches[1].trim() === '$' && matches[3].trim() === '$') {
+ if (numberOfMatches && numberOfMatches.length !== 0) {
+ if (matchLocation > 0) {
+ let matches = regex.exec(katexString);
inline = true;
- text = `${katexString.replace(matches[0], '')} ${katex.renderToString(matches[2])}`;
+ while (matches !== null) {
+ const renderedKatex = katex.renderToString(matches[0].replace(/\$/g, ''));
+ text = `${text.replace(matches[0], ` ${renderedKatex}`)}`;
+ matches = regex.exec(katexString);
+ }
} else {
+ const matches = regex.exec(katexString);
text = katex.renderToString(matches[2]);
}
}
@@ -79,7 +89,7 @@
},
computed: {
markdown() {
- return marked(this.cell.source.join(''));
+ return marked(this.cell.source.join('').replace(/\\/g, '\\\\'));
},
},
};
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 87f03a40eba..0ca7cabfc5a 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -1,4 +1,10 @@
-/* eslint-disable no-restricted-properties, func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-use-before-define, camelcase, no-unused-expressions, quotes, max-len, one-var, one-var-declaration-per-line, default-case, prefer-template, consistent-return, no-alert, no-return-assign, no-param-reassign, prefer-arrow-callback, no-else-return, comma-dangle, no-new, brace-style, no-lonely-if, vars-on-top, no-unused-vars, no-sequences, no-shadow, newline-per-chained-call, no-useless-escape */
+/* eslint-disable no-restricted-properties, func-names, space-before-function-paren,
+no-var, prefer-rest-params, wrap-iife, no-use-before-define, camelcase,
+no-unused-expressions, quotes, max-len, one-var, one-var-declaration-per-line,
+default-case, prefer-template, consistent-return, no-alert, no-return-assign,
+no-param-reassign, prefer-arrow-callback, no-else-return, comma-dangle, no-new,
+brace-style, no-lonely-if, vars-on-top, no-unused-vars, no-sequences, no-shadow,
+newline-per-chained-call, no-useless-escape */
/* global Flash */
/* global Autosave */
/* global ResolveService */
@@ -6,57 +12,61 @@
import $ from 'jquery';
import Cookies from 'js-cookie';
+import autosize from 'vendor/autosize';
+import Dropzone from 'dropzone';
+import 'vendor/jquery.caret'; // required by jquery.atwho
+import 'vendor/jquery.atwho';
import CommentTypeToggle from './comment_type_toggle';
+import './autosave';
+import './dropzone_input';
+import './task_list';
-require('./autosave');
-window.autosize = require('vendor/autosize');
-window.Dropzone = require('dropzone');
-require('./dropzone_input');
-require('./gfm_auto_complete');
-require('vendor/jquery.caret'); // required by jquery.atwho
-require('vendor/jquery.atwho');
-require('./task_list');
+window.autosize = autosize;
+window.Dropzone = Dropzone;
const normalizeNewlines = function(str) {
return str.replace(/\r\n/g, '\n');
};
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.Notes = (function() {
const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
+ const REGEX_SLASH_COMMANDS = /^\/\w+.*$/gm;
Notes.interval = null;
- function Notes(notes_url, note_ids, last_fetched_at, view) {
- this.updateTargetButtons = bind(this.updateTargetButtons, this);
- this.updateCloseButton = bind(this.updateCloseButton, this);
- this.visibilityChange = bind(this.visibilityChange, this);
- this.cancelDiscussionForm = bind(this.cancelDiscussionForm, this);
- this.addDiffNote = bind(this.addDiffNote, this);
- this.setupDiscussionNoteForm = bind(this.setupDiscussionNoteForm, this);
- this.replyToDiscussionNote = bind(this.replyToDiscussionNote, this);
- this.removeNote = bind(this.removeNote, this);
- this.cancelEdit = bind(this.cancelEdit, this);
- this.updateNote = bind(this.updateNote, this);
- this.addDiscussionNote = bind(this.addDiscussionNote, this);
- this.addNoteError = bind(this.addNoteError, this);
- this.addNote = bind(this.addNote, this);
- this.resetMainTargetForm = bind(this.resetMainTargetForm, this);
- this.refresh = bind(this.refresh, this);
- this.keydownNoteText = bind(this.keydownNoteText, this);
- this.toggleCommitList = bind(this.toggleCommitList, this);
+ function Notes(notes_url, note_ids, last_fetched_at, view, enableGFM = true) {
+ this.updateTargetButtons = this.updateTargetButtons.bind(this);
+ this.updateComment = this.updateComment.bind(this);
+ this.visibilityChange = this.visibilityChange.bind(this);
+ this.cancelDiscussionForm = this.cancelDiscussionForm.bind(this);
+ this.onAddDiffNote = this.onAddDiffNote.bind(this);
+ this.setupDiscussionNoteForm = this.setupDiscussionNoteForm.bind(this);
+ this.onReplyToDiscussionNote = this.onReplyToDiscussionNote.bind(this);
+ this.removeNote = this.removeNote.bind(this);
+ this.cancelEdit = this.cancelEdit.bind(this);
+ this.updateNote = this.updateNote.bind(this);
+ this.addDiscussionNote = this.addDiscussionNote.bind(this);
+ this.addNoteError = this.addNoteError.bind(this);
+ this.addNote = this.addNote.bind(this);
+ this.resetMainTargetForm = this.resetMainTargetForm.bind(this);
+ this.refresh = this.refresh.bind(this);
+ this.keydownNoteText = this.keydownNoteText.bind(this);
+ this.toggleCommitList = this.toggleCommitList.bind(this);
+ this.postComment = this.postComment.bind(this);
+ this.clearFlashWrapper = this.clearFlash.bind(this);
this.notes_url = notes_url;
this.note_ids = note_ids;
+ this.enableGFM = enableGFM;
// Used to keep track of updated notes while people are editing things
this.updatedNotesTrackingMap = {};
this.last_fetched_at = last_fetched_at;
this.noteable_url = document.URL;
- this.notesCountBadge || (this.notesCountBadge = $(".issuable-details").find(".notes-tab .badge"));
+ this.notesCountBadge || (this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge'));
this.basePollingInterval = 15000;
this.maxPollingSteps = 4;
+ this.flashErrors = [];
this.cleanBinding();
this.addBinding();
@@ -82,68 +92,62 @@ const normalizeNewlines = function(str) {
};
Notes.prototype.addBinding = function() {
- // add note to UI after creation
- $(document).on("ajax:success", ".js-main-target-form", this.addNote);
- $(document).on("ajax:success", ".js-discussion-note-form", this.addDiscussionNote);
- // catch note ajax errors
- $(document).on("ajax:error", ".js-main-target-form", this.addNoteError);
- // change note in UI after update
- $(document).on("ajax:success", "form.edit-note", this.updateNote);
// Edit note link
- $(document).on("click", ".js-note-edit", this.showEditForm.bind(this));
- $(document).on("click", ".note-edit-cancel", this.cancelEdit);
+ $(document).on('click', '.js-note-edit', this.showEditForm.bind(this));
+ $(document).on('click', '.note-edit-cancel', this.cancelEdit);
// Reopen and close actions for Issue/MR combined with note form submit
- $(document).on("click", ".js-comment-button", this.updateCloseButton);
- $(document).on("keyup input", ".js-note-text", this.updateTargetButtons);
+ $(document).on('click', '.js-comment-submit-button', this.postComment);
+ $(document).on('click', '.js-comment-save-button', this.updateComment);
+ $(document).on('keyup input', '.js-note-text', this.updateTargetButtons);
// resolve a discussion
- $(document).on('click', '.js-comment-resolve-button', this.resolveDiscussion);
+ $(document).on('click', '.js-comment-resolve-button', this.postComment);
// remove a note (in general)
- $(document).on("click", ".js-note-delete", this.removeNote);
+ $(document).on('click', '.js-note-delete', this.removeNote);
// delete note attachment
- $(document).on("click", ".js-note-attachment-delete", this.removeAttachment);
- // reset main target form after submit
- $(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton);
- $(document).on("ajax:success", ".js-main-target-form", this.resetMainTargetForm);
+ $(document).on('click', '.js-note-attachment-delete', this.removeAttachment);
// reset main target form when clicking discard
- $(document).on("click", ".js-note-discard", this.resetMainTargetForm);
+ $(document).on('click', '.js-note-discard', this.resetMainTargetForm);
// update the file name when an attachment is selected
- $(document).on("change", ".js-note-attachment-input", this.updateFormAttachment);
+ $(document).on('change', '.js-note-attachment-input', this.updateFormAttachment);
// reply to diff/discussion notes
- $(document).on("click", ".js-discussion-reply-button", this.replyToDiscussionNote);
+ $(document).on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote);
// add diff note
- $(document).on("click", ".js-add-diff-note-button", this.addDiffNote);
+ $(document).on('click', '.js-add-diff-note-button', this.onAddDiffNote);
// hide diff note form
- $(document).on("click", ".js-close-discussion-note-form", this.cancelDiscussionForm);
+ $(document).on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm);
// toggle commit list
- $(document).on("click", '.system-note-commit-list-toggler', this.toggleCommitList);
+ $(document).on('click', '.system-note-commit-list-toggler', this.toggleCommitList);
// fetch notes when tab becomes visible
- $(document).on("visibilitychange", this.visibilityChange);
+ $(document).on('visibilitychange', this.visibilityChange);
// when issue status changes, we need to refresh data
- $(document).on("issuable:change", this.refresh);
+ $(document).on('issuable:change', this.refresh);
+ // ajax:events that happen on Form when actions like Reopen, Close are performed on Issues and MRs.
+ $(document).on('ajax:success', '.js-main-target-form', this.addNote);
+ $(document).on('ajax:success', '.js-discussion-note-form', this.addDiscussionNote);
+ $(document).on('ajax:success', '.js-main-target-form', this.resetMainTargetForm);
+ $(document).on('ajax:complete', '.js-main-target-form', this.reenableTargetFormSubmitButton);
// when a key is clicked on the notes
- return $(document).on("keydown", ".js-note-text", this.keydownNoteText);
+ return $(document).on('keydown', '.js-note-text', this.keydownNoteText);
};
Notes.prototype.cleanBinding = function() {
- $(document).off("ajax:success", ".js-main-target-form");
- $(document).off("ajax:success", ".js-discussion-note-form");
- $(document).off("ajax:success", "form.edit-note");
- $(document).off("click", ".js-note-edit");
- $(document).off("click", ".note-edit-cancel");
- $(document).off("click", ".js-note-delete");
- $(document).off("click", ".js-note-attachment-delete");
- $(document).off("ajax:complete", ".js-main-target-form");
- $(document).off("ajax:success", ".js-main-target-form");
- $(document).off("click", ".js-discussion-reply-button");
- $(document).off("click", ".js-add-diff-note-button");
- $(document).off("visibilitychange");
- $(document).off("keyup input", ".js-note-text");
- $(document).off("click", ".js-note-target-reopen");
- $(document).off("click", ".js-note-target-close");
- $(document).off("click", ".js-note-discard");
- $(document).off("keydown", ".js-note-text");
+ $(document).off('click', '.js-note-edit');
+ $(document).off('click', '.note-edit-cancel');
+ $(document).off('click', '.js-note-delete');
+ $(document).off('click', '.js-note-attachment-delete');
+ $(document).off('click', '.js-discussion-reply-button');
+ $(document).off('click', '.js-add-diff-note-button');
+ $(document).off('visibilitychange');
+ $(document).off('keyup input', '.js-note-text');
+ $(document).off('click', '.js-note-target-reopen');
+ $(document).off('click', '.js-note-target-close');
+ $(document).off('click', '.js-note-discard');
+ $(document).off('keydown', '.js-note-text');
$(document).off('click', '.js-comment-resolve-button');
- $(document).off("click", '.system-note-commit-list-toggler');
+ $(document).off('click', '.system-note-commit-list-toggler');
+ $(document).off('ajax:success', '.js-main-target-form');
+ $(document).off('ajax:success', '.js-discussion-note-form');
+ $(document).off('ajax:complete', '.js-main-target-form');
};
Notes.initCommentTypeToggle = function (form) {
@@ -179,7 +183,7 @@ const normalizeNewlines = function(str) {
if ($textarea.val() !== '') {
return;
}
- myLastNote = $("li.note[data-author-id='" + gon.current_user_id + "'][data-editable]:last");
+ myLastNote = $(`li.note[data-author-id='${gon.current_user_id}'][data-editable]:last`, $textarea.closest('.note, #notes'));
if (myLastNote.length) {
myLastNoteEditBtn = myLastNote.find('.js-note-edit');
return myLastNoteEditBtn.trigger('click', [true, myLastNote]);
@@ -233,8 +237,8 @@ const normalizeNewlines = function(str) {
this.refreshing = true;
return $.ajax({
url: this.notes_url,
- headers: { "X-Last-Fetched-At": this.last_fetched_at },
- dataType: "json",
+ headers: { 'X-Last-Fetched-At': this.last_fetched_at },
+ dataType: 'json',
success: (function(_this) {
return function(data) {
var notes;
@@ -276,15 +280,11 @@ const normalizeNewlines = function(str) {
return this.initRefresh();
};
- Notes.prototype.handleCreateChanges = function(noteEntity) {
+ Notes.prototype.handleSlashCommands = function(noteEntity) {
var votesBlock;
- if (typeof noteEntity === 'undefined') {
- return;
- }
-
if (noteEntity.commands_changes) {
if ('merge' in noteEntity.commands_changes) {
- $.get(mrRefreshWidgetUrl);
+ Notes.checkMergeRequestStatus();
}
if ('emoji_award' in noteEntity.commands_changes) {
@@ -295,6 +295,13 @@ const normalizeNewlines = function(str) {
}
};
+ Notes.prototype.setupNewNote = function($note) {
+ // Update datetime format on the recent note
+ gl.utils.localTimeAgo($note.find('.js-timeago'), false);
+ this.collapseLongCommitList();
+ this.taskList.init();
+ };
+
/*
Render note in main comments area.
@@ -302,33 +309,30 @@ const normalizeNewlines = function(str) {
*/
Notes.prototype.renderNote = function(noteEntity, $form, $notesList = $('.main-notes-list')) {
- if (noteEntity.discussion_html != null) {
+ if (noteEntity.discussion_html) {
return this.renderDiscussionNote(noteEntity, $form);
}
if (!noteEntity.valid) {
if (noteEntity.errors.commands_only) {
- new Flash(noteEntity.errors.commands_only, 'notice', this.parentTimeline);
+ this.addFlash(noteEntity.errors.commands_only, 'notice', this.parentTimeline);
this.refresh();
}
return;
}
const $note = $notesList.find(`#note_${noteEntity.id}`);
- if (this.isNewNote(noteEntity)) {
+ if (Notes.isNewNote(noteEntity, this.note_ids)) {
this.note_ids.push(noteEntity.id);
const $newNote = Notes.animateAppendNote(noteEntity.html, $notesList);
- // Update datetime format on the recent note
- gl.utils.localTimeAgo($newNote.find('.js-timeago'), false);
- this.collapseLongCommitList();
- this.taskList.init();
+ this.setupNewNote($newNote);
this.refresh();
return this.updateNotesCount(1);
}
// The server can send the same update multiple times so we need to make sure to only update once per actual update.
- else if (this.isUpdatedNote(noteEntity, $note)) {
+ else if (Notes.isUpdatedNote(noteEntity, $note)) {
const isEditing = $note.hasClass('is-editing');
const initialContent = normalizeNewlines(
$note.find('.original-note-content').text().trim()
@@ -349,30 +353,11 @@ const normalizeNewlines = function(str) {
}
else {
const $updatedNote = Notes.animateUpdateNote(noteEntity.html, $note);
-
- // Update datetime format on the recent note
- gl.utils.localTimeAgo($updatedNote.find('.js-timeago'), false);
+ this.setupNewNote($updatedNote);
}
}
};
- /*
- Check if note does not exists on page
- */
-
- Notes.prototype.isNewNote = function(noteEntity) {
- return $.inArray(noteEntity.id, this.note_ids) === -1;
- };
-
- Notes.prototype.isUpdatedNote = function(noteEntity, $note) {
- // There can be CRLF vs LF mismatches if we don't sanitize and compare the same way
- const sanitizedNoteNote = normalizeNewlines(noteEntity.note);
- const currentNoteText = normalizeNewlines(
- $note.find('.original-note-content').text().trim()
- );
- return sanitizedNoteNote !== currentNoteText;
- };
-
Notes.prototype.isParallelView = function() {
return Cookies.get('diff_view') === 'parallel';
};
@@ -385,12 +370,12 @@ const normalizeNewlines = function(str) {
Notes.prototype.renderDiscussionNote = function(noteEntity, $form) {
var discussionContainer, form, row, lineType, diffAvatarContainer;
- if (!this.isNewNote(noteEntity)) {
+ if (!Notes.isNewNote(noteEntity, this.note_ids)) {
return;
}
this.note_ids.push(noteEntity.id);
- form = $form || $(".js-discussion-note-form[data-discussion-id='" + noteEntity.discussion_id + "']");
- row = form.closest("tr");
+ form = $form || $(`.js-discussion-note-form[data-discussion-id="${noteEntity.discussion_id}"]`);
+ row = form.closest('tr');
lineType = this.isParallelView() ? form.find('#line_type').val() : 'old';
diffAvatarContainer = row.prevAll('.line_holder').first().find('.js-avatar-container.' + lineType + '_line');
// is this the first note of discussion?
@@ -407,7 +392,7 @@ const normalizeNewlines = function(str) {
row.after($discussion);
} else {
// Merge new discussion HTML in
- var $notes = $discussion.find('.notes[data-discussion-id="' + noteEntity.discussion_id + '"]');
+ var $notes = $discussion.find(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`);
var contentContainerClass = '.' + $notes.closest('.notes_content')
.attr('class')
.split(' ')
@@ -418,7 +403,7 @@ const normalizeNewlines = function(str) {
}
// Init discussion on 'Discussion' page if it is merge request page
const page = $('body').attr('data-page');
- if ((page && page.indexOf('projects:merge_request') === 0) || !noteEntity.diff_discussion_html) {
+ if ((page && page.indexOf('projects:merge_request') !== -1) || !noteEntity.diff_discussion_html) {
Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list'));
}
} else {
@@ -432,6 +417,7 @@ const normalizeNewlines = function(str) {
}
gl.utils.localTimeAgo($('.js-timeago'), false);
+ Notes.checkMergeRequestStatus();
return this.updateNotesCount(1);
};
@@ -470,13 +456,13 @@ const normalizeNewlines = function(str) {
Notes.prototype.resetMainTargetForm = function(e) {
var form;
- form = $(".js-main-target-form");
+ form = $('.js-main-target-form');
// remove validation errors
- form.find(".js-errors").remove();
+ form.find('.js-errors').remove();
// reset text and preview
- form.find(".js-md-write-button").click();
- form.find(".js-note-text").val("").trigger("input");
- form.find(".js-note-text").data("autosave").reset();
+ form.find('.js-md-write-button').click();
+ form.find('.js-note-text').val('').trigger('input');
+ form.find('.js-note-text').data('autosave').reset();
var event = document.createEvent('Event');
event.initEvent('autosize:update', true, false);
@@ -487,8 +473,8 @@ const normalizeNewlines = function(str) {
Notes.prototype.reenableTargetFormSubmitButton = function() {
var form;
- form = $(".js-main-target-form");
- return form.find(".js-note-text").trigger("input");
+ form = $('.js-main-target-form');
+ return form.find('.js-note-text').trigger('input');
};
/*
@@ -500,18 +486,18 @@ const normalizeNewlines = function(str) {
Notes.prototype.setupMainTargetNoteForm = function() {
var form;
// find the form
- form = $(".js-new-note-form");
+ form = $('.js-new-note-form');
// Set a global clone of the form for later cloning
this.formClone = form.clone();
// show the form
this.setupNoteForm(form);
// fix classes
- form.removeClass("js-new-note-form");
- form.addClass("js-main-target-form");
- form.find("#note_line_code").remove();
- form.find("#note_position").remove();
- form.find("#note_type").val('');
- form.find("#in_reply_to_discussion_id").remove();
+ form.removeClass('js-new-note-form');
+ form.addClass('js-main-target-form');
+ form.find('#note_line_code').remove();
+ form.find('#note_position').remove();
+ form.find('#note_type').val('');
+ form.find('#in_reply_to_discussion_id').remove();
form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove();
this.parentTimeline = form.parents('.timeline');
@@ -531,21 +517,21 @@ const normalizeNewlines = function(str) {
Notes.prototype.setupNoteForm = function(form) {
var textarea, key;
- new gl.GLForm(form);
- textarea = form.find(".js-note-text");
+ new gl.GLForm(form, this.enableGFM);
+ textarea = form.find('.js-note-text');
key = [
- "Note",
- form.find("#note_noteable_type").val(),
- form.find("#note_noteable_id").val(),
- form.find("#note_commit_id").val(),
- form.find("#note_type").val(),
- form.find("#in_reply_to_discussion_id").val(),
+ 'Note',
+ form.find('#note_noteable_type').val(),
+ form.find('#note_noteable_id').val(),
+ form.find('#note_commit_id').val(),
+ form.find('#note_type').val(),
+ form.find('#in_reply_to_discussion_id').val(),
// LegacyDiffNote
- form.find("#note_line_code").val(),
+ form.find('#note_line_code').val(),
// DiffNote
- form.find("#note_position").val()
+ form.find('#note_position').val()
];
return new Autosave(textarea, key);
};
@@ -556,24 +542,29 @@ const normalizeNewlines = function(str) {
Adds new note to list.
*/
- Notes.prototype.addNote = function(xhr, note, status) {
- this.handleCreateChanges(note);
+ Notes.prototype.addNote = function($form, note) {
return this.renderNote(note);
};
- Notes.prototype.addNoteError = function(xhr, note, status) {
- return new Flash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', this.parentTimeline);
+ Notes.prototype.addNoteError = function($form) {
+ let formParentTimeline;
+ if ($form.hasClass('js-main-target-form')) {
+ formParentTimeline = $form.parents('.timeline');
+ } else if ($form.hasClass('js-discussion-note-form')) {
+ formParentTimeline = $form.closest('.discussion-notes').find('.notes');
+ }
+ return this.addFlash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', formParentTimeline);
};
+ Notes.prototype.updateNoteError = $parentTimeline => new Flash('Your comment could not be updated! Please check your network connection and try again.');
+
/*
Called in response to the new note form being submitted
Adds new note to list.
*/
- Notes.prototype.addDiscussionNote = function(xhr, note, status) {
- var $form = $(xhr.target);
-
+ Notes.prototype.addDiscussionNote = function($form, note, isNewDiffComment) {
if ($form.attr('data-resolve-all') != null) {
var projectPath = $form.data('project-path');
var discussionId = $form.data('discussion-id');
@@ -586,7 +577,9 @@ const normalizeNewlines = function(str) {
this.renderNote(note, $form);
// cleanup after successfully creating a diff/discussion note
- this.removeDiscussionNoteForm($form);
+ if (isNewDiffComment) {
+ this.removeDiscussionNoteForm($form);
+ }
};
/*
@@ -595,18 +588,19 @@ const normalizeNewlines = function(str) {
Updates the current note field.
*/
- Notes.prototype.updateNote = function(_xhr, noteEntity, _status) {
- var $html, $note_li;
+ Notes.prototype.updateNote = function(noteEntity, $targetNote) {
+ var $noteEntityEl, $note_li;
// Convert returned HTML to a jQuery object so we can modify it further
- $html = $(noteEntity.html);
- this.revertNoteEditForm();
- gl.utils.localTimeAgo($('.js-timeago', $html));
- $html.renderGFM();
- $html.find('.js-task-list-container').taskList('enable');
+ $noteEntityEl = $(noteEntity.html);
+ $noteEntityEl.addClass('fade-in-full');
+ this.revertNoteEditForm($targetNote);
+ gl.utils.localTimeAgo($('.js-timeago', $noteEntityEl));
+ $noteEntityEl.renderGFM();
+ $noteEntityEl.find('.js-task-list-container').taskList('enable');
// Find the note's `li` element by ID and replace it with the updated HTML
$note_li = $('.note-row-' + noteEntity.id);
- $note_li.replaceWith($html);
+ $note_li.replaceWith($noteEntityEl);
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
gl.diffNotesCompileComponents();
@@ -681,10 +675,9 @@ const normalizeNewlines = function(str) {
if (this.updatedNotesTrackingMap[noteId]) {
const $newNote = $(this.updatedNotesTrackingMap[noteId].html);
$note.replaceWith($newNote);
- this.updatedNotesTrackingMap[noteId] = null;
-
- // Update datetime format on the recent note
- gl.utils.localTimeAgo($newNote.find('.js-timeago'), false);
+ this.setupNewNote($newNote);
+ // Now that we have taken care of the update, clear it out
+ delete this.updatedNotesTrackingMap[noteId];
}
else {
$note.find('.js-finish-edit-warning').hide();
@@ -698,7 +691,7 @@ const normalizeNewlines = function(str) {
var $editForm = $(selector);
$editForm.insertBefore('.notes-form');
- $editForm.find('.js-comment-button').enable();
+ $editForm.find('.js-comment-save-button').enable();
$editForm.find('.js-finish-edit-warning').hide();
};
@@ -736,14 +729,14 @@ const normalizeNewlines = function(str) {
lineHolder = $(e.currentTarget).closest('.notes[data-discussion-id]')
.closest('.notes_holder')
.prev('.line_holder');
- $(".note[id='" + noteElId + "']").each((function(_this) {
+ $(`.note[id="${noteElId}"]`).each((function(_this) {
// A same note appears in the "Discussion" and in the "Changes" tab, we have
- // to remove all. Using $(".note[id='noteId']") ensure we get all the notes,
- // where $("#noteId") would return only one.
+ // to remove all. Using $('.note[id='noteId']') ensure we get all the notes,
+ // where $('#noteId') would return only one.
return function(i, el) {
var $note, $notes;
$note = $(el);
- $notes = $note.closest(".discussion-notes");
+ $notes = $note.closest('.discussion-notes');
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
if (gl.diffNoteApps[noteElId]) {
@@ -754,11 +747,11 @@ const normalizeNewlines = function(str) {
$note.remove();
// check if this is the last note for this line
- if ($notes.find(".note").length === 0) {
- var notesTr = $notes.closest("tr");
+ if ($notes.find('.note').length === 0) {
+ var notesTr = $notes.closest('tr');
// "Discussions" tab
- $notes.closest(".timeline-entry").remove();
+ $notes.closest('.timeline-entry').remove();
// The notes tr can contain multiple lists of notes, like on the parallel diff
if (notesTr.find('.discussion-notes').length > 1) {
@@ -769,7 +762,8 @@ const normalizeNewlines = function(str) {
}
};
})(this));
- // Decrement the "Discussions" counter only once
+
+ Notes.checkMergeRequestStatus();
return this.updateNotesCount(-1);
};
@@ -781,11 +775,11 @@ const normalizeNewlines = function(str) {
*/
Notes.prototype.removeAttachment = function() {
- const $note = $(this).closest(".note");
- $note.find(".note-attachment").remove();
- $note.find(".note-body > .note-text").show();
- $note.find(".note-header").show();
- return $note.find(".current-note-edit-form").remove();
+ const $note = $(this).closest('.note');
+ $note.find('.note-attachment').remove();
+ $note.find('.note-body > .note-text').show();
+ $note.find('.note-header').show();
+ return $note.find('.current-note-edit-form').remove();
};
/*
@@ -794,10 +788,14 @@ const normalizeNewlines = function(str) {
Shows the note form below the notes.
*/
- Notes.prototype.replyToDiscussionNote = function(e) {
+ Notes.prototype.onReplyToDiscussionNote = function(e) {
+ this.replyToDiscussionNote(e.target);
+ };
+
+ Notes.prototype.replyToDiscussionNote = function(target) {
var form, replyLink;
form = this.cleanForm(this.formClone.clone());
- replyLink = $(e.target).closest(".js-discussion-reply-button");
+ replyLink = $(target).closest('.js-discussion-reply-button');
// insert the form after the button
replyLink
.closest('.discussion-reply-holder')
@@ -817,26 +815,26 @@ const normalizeNewlines = function(str) {
Notes.prototype.setupDiscussionNoteForm = function(dataHolder, form) {
// setup note target
- var discussionID = dataHolder.data("discussionId");
+ var discussionID = dataHolder.data('discussionId');
if (discussionID) {
- form.attr("data-discussion-id", discussionID);
- form.find("#in_reply_to_discussion_id").val(discussionID);
+ form.attr('data-discussion-id', discussionID);
+ form.find('#in_reply_to_discussion_id').val(discussionID);
}
- form.attr("data-line-code", dataHolder.data("lineCode"));
- form.find("#line_type").val(dataHolder.data("lineType"));
+ form.attr('data-line-code', dataHolder.data('lineCode'));
+ form.find('#line_type').val(dataHolder.data('lineType'));
- form.find("#note_noteable_type").val(dataHolder.data("noteableType"));
- form.find("#note_noteable_id").val(dataHolder.data("noteableId"));
- form.find("#note_commit_id").val(dataHolder.data("commitId"));
- form.find("#note_type").val(dataHolder.data("noteType"));
+ form.find('#note_noteable_type').val(dataHolder.data('noteableType'));
+ form.find('#note_noteable_id').val(dataHolder.data('noteableId'));
+ form.find('#note_commit_id').val(dataHolder.data('commitId'));
+ form.find('#note_type').val(dataHolder.data('noteType'));
// LegacyDiffNote
- form.find("#note_line_code").val(dataHolder.data("lineCode"));
+ form.find('#note_line_code').val(dataHolder.data('lineCode'));
// DiffNote
- form.find("#note_position").val(dataHolder.attr("data-position"));
+ form.find('#note_position').val(dataHolder.attr('data-position'));
form.find('.js-note-discard').show().removeClass('js-note-discard').addClass('js-close-discussion-note-form').text(form.find('.js-close-discussion-note-form').data('cancel-text'));
form.find('.js-note-target-close').remove();
@@ -845,7 +843,7 @@ const normalizeNewlines = function(str) {
form
.removeClass('js-main-target-form')
- .addClass("discussion-form js-discussion-note-form");
+ .addClass('discussion-form js-discussion-note-form');
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
var $commentBtn = form.find('comment-and-resolve-btn');
@@ -854,7 +852,7 @@ const normalizeNewlines = function(str) {
gl.diffNotesCompileComponents();
}
- form.find(".js-note-text").focus();
+ form.find('.js-note-text').focus();
form
.find('.js-comment-resolve-button')
.attr('data-discussion-id', discussionID);
@@ -867,56 +865,74 @@ const normalizeNewlines = function(str) {
Sets up the form and shows it.
*/
- Notes.prototype.addDiffNote = function(e) {
- var $link, addForm, hasNotes, lineType, newForm, nextRow, noteForm, notesContent, notesContentSelector, replyButton, row, rowCssToAdd, targetContent, isDiffCommentAvatar;
+ Notes.prototype.onAddDiffNote = function(e) {
e.preventDefault();
- $link = $(e.currentTarget || e.target);
- row = $link.closest("tr");
- nextRow = row.next();
- hasNotes = nextRow.is(".notes_holder");
+ const link = e.currentTarget || e.target;
+ const $link = $(link);
+ const showReplyInput = !$link.hasClass('js-diff-comment-avatar');
+ this.toggleDiffNote({
+ target: $link,
+ lineType: link.dataset.lineType,
+ showReplyInput
+ });
+ };
+
+ Notes.prototype.toggleDiffNote = function({
+ target,
+ lineType,
+ forceShow,
+ showReplyInput = false,
+ }) {
+ var $link, addForm, hasNotes, newForm, noteForm, replyButton, row, rowCssToAdd, targetContent, isDiffCommentAvatar;
+ $link = $(target);
+ row = $link.closest('tr');
+ const nextRow = row.next();
+ let targetRow = row;
+ if (nextRow.is('.notes_holder')) {
+ targetRow = nextRow;
+ }
+
+ hasNotes = nextRow.is('.notes_holder');
addForm = false;
- notesContentSelector = ".notes_content";
- rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\" colspan=\"2\"></td><td class=\"notes_content\"><div class=\"content\"></div></td></tr>";
- isDiffCommentAvatar = $link.hasClass('js-diff-comment-avatar');
+ let lineTypeSelector = '';
+ rowCssToAdd = '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line" colspan="2"></td><td class="notes_content"><div class="content"></div></td></tr>';
// In parallel view, look inside the correct left/right pane
if (this.isParallelView()) {
- lineType = $link.data("lineType");
- notesContentSelector += "." + lineType;
- rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line old\"></td><td class=\"notes_content parallel old\"><div class=\"content\"></div></td><td class=\"notes_line new\"></td><td class=\"notes_content parallel new\"><div class=\"content\"></div></td></tr>";
+ lineTypeSelector = `.${lineType}`;
+ rowCssToAdd = '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line old"></td><td class="notes_content parallel old"><div class="content"></div></td><td class="notes_line new"></td><td class="notes_content parallel new"><div class="content"></div></td></tr>';
}
- notesContentSelector += " .content";
- notesContent = nextRow.find(notesContentSelector);
+ const notesContentSelector = `.notes_content${lineTypeSelector} .content`;
+ let notesContent = targetRow.find(notesContentSelector);
- if (hasNotes && !isDiffCommentAvatar) {
- nextRow.show();
- notesContent = nextRow.find(notesContentSelector);
+ if (hasNotes && showReplyInput) {
+ targetRow.show();
+ notesContent = targetRow.find(notesContentSelector);
if (notesContent.length) {
notesContent.show();
- replyButton = notesContent.find(".js-discussion-reply-button:visible");
+ replyButton = notesContent.find('.js-discussion-reply-button:visible');
if (replyButton.length) {
- e.target = replyButton[0];
- $.proxy(this.replyToDiscussionNote, replyButton[0], e).call();
+ this.replyToDiscussionNote(replyButton[0]);
} else {
// In parallel view, the form may not be present in one of the panes
- noteForm = notesContent.find(".js-discussion-note-form");
+ noteForm = notesContent.find('.js-discussion-note-form');
if (noteForm.length === 0) {
addForm = true;
}
}
}
- } else if (!isDiffCommentAvatar) {
+ } else if (showReplyInput) {
// add a notes row and insert the form
row.after(rowCssToAdd);
- nextRow = row.next();
- notesContent = nextRow.find(notesContentSelector);
+ targetRow = row.next();
+ notesContent = targetRow.find(notesContentSelector);
addForm = true;
} else {
- nextRow.show();
- notesContent.toggle(!notesContent.is(':visible'));
+ const isCurrentlyShown = targetRow.find('.content:not(:empty)').is(':visible');
+ const isForced = forceShow === true || forceShow === false;
+ const showNow = forceShow === true || (!isCurrentlyShown && !isForced);
- if (!nextRow.find('.content:not(:empty)').is(':visible')) {
- nextRow.hide();
- }
+ targetRow.toggle(showNow);
+ notesContent.toggle(showNow);
}
if (addForm) {
@@ -936,15 +952,15 @@ const normalizeNewlines = function(str) {
Notes.prototype.removeDiscussionNoteForm = function(form) {
var glForm, row;
- row = form.closest("tr");
+ row = form.closest('tr');
glForm = form.data('gl-form');
glForm.destroy();
- form.find(".js-note-text").data("autosave").reset();
+ form.find('.js-note-text').data('autosave').reset();
// show the reply button (will only work for replies)
form
.prev('.discussion-reply-holder')
.show();
- if (row.is(".js-temp-notes-holder")) {
+ if (row.is('.js-temp-notes-holder')) {
// remove temporary row for diff lines
return row.remove();
} else {
@@ -956,7 +972,7 @@ const normalizeNewlines = function(str) {
Notes.prototype.cancelDiscussionForm = function(e) {
var form;
e.preventDefault();
- form = $(e.target).closest(".js-discussion-note-form");
+ form = $(e.target).closest('.js-discussion-note-form');
return this.removeDiscussionNoteForm(form);
};
@@ -968,10 +984,10 @@ const normalizeNewlines = function(str) {
Notes.prototype.updateFormAttachment = function() {
var filename, form;
- form = $(this).closest("form");
+ form = $(this).closest('form');
// get only the basename
- filename = $(this).val().replace(/^.*[\\\/]/, "");
- return form.find(".js-attachment-filename").text(filename);
+ filename = $(this).val().replace(/^.*[\\\/]/, '');
+ return form.find('.js-attachment-filename').text(filename);
};
/*
@@ -982,14 +998,6 @@ const normalizeNewlines = function(str) {
return this.refresh();
};
- Notes.prototype.updateCloseButton = function(e) {
- var closebtn, form, textarea;
- textarea = $(e.target);
- form = textarea.parents('form');
- closebtn = form.find('.js-note-target-close');
- return closebtn.text(closebtn.data('original-text'));
- };
-
Notes.prototype.updateTargetButtons = function(e) {
var closebtn, closetext, discardbtn, form, reopenbtn, reopentext, textarea;
textarea = $(e.target);
@@ -1078,17 +1086,6 @@ const normalizeNewlines = function(str) {
return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount);
};
- Notes.prototype.resolveDiscussion = function() {
- var $this = $(this);
- var discussionId = $this.attr('data-discussion-id');
-
- $this
- .closest('form')
- .attr('data-discussion-id', discussionId)
- .attr('data-resolve-all', 'true')
- .attr('data-project-path', $this.attr('data-project-path'));
- };
-
Notes.prototype.toggleCommitList = function(e) {
const $element = $(e.currentTarget);
const $closestSystemCommitList = $element.siblings('.system-note-commit-list');
@@ -1120,6 +1117,15 @@ const normalizeNewlines = function(str) {
});
};
+ Notes.prototype.addFlash = function(...flashParams) {
+ this.flashErrors.push(new Flash(...flashParams));
+ };
+
+ Notes.prototype.clearFlash = function() {
+ this.flashErrors.forEach(flash => flash.flashContainer.remove());
+ this.flashErrors = [];
+ };
+
Notes.prototype.cleanForm = function($form) {
// Remove JS classes that are not needed here
$form
@@ -1134,10 +1140,35 @@ const normalizeNewlines = function(str) {
return $form;
};
+ /**
+ * Check if note does not exists on page
+ */
+ Notes.isNewNote = function(noteEntity, noteIds) {
+ return $.inArray(noteEntity.id, noteIds) === -1;
+ };
+
+ /**
+ * Check if $note already contains the `noteEntity` content
+ */
+ Notes.isUpdatedNote = function(noteEntity, $note) {
+ // There can be CRLF vs LF mismatches if we don't sanitize and compare the same way
+ const sanitizedNoteEntityText = normalizeNewlines(noteEntity.note.trim());
+ const currentNoteText = normalizeNewlines(
+ $note.find('.original-note-content').first().text().trim()
+ );
+ return sanitizedNoteEntityText !== currentNoteText;
+ };
+
+ Notes.checkMergeRequestStatus = function() {
+ if (gl.utils.getPagePath(1) === 'merge_requests') {
+ gl.mrWidget.checkStatus();
+ }
+ };
+
Notes.animateAppendNote = function(noteHtml, $notesList) {
const $note = $(noteHtml);
- $note.addClass('fade-in').renderGFM();
+ $note.addClass('fade-in-full').renderGFM();
$notesList.append($note);
return $note;
};
@@ -1150,6 +1181,254 @@ const normalizeNewlines = function(str) {
return $updatedNote;
};
+ /**
+ * Get data from Form attributes to use for saving/submitting comment.
+ */
+ Notes.prototype.getFormData = function($form) {
+ return {
+ formData: $form.serialize(),
+ formContent: $form.find('.js-note-text').val(),
+ formAction: $form.attr('action'),
+ };
+ };
+
+ /**
+ * Identify if comment has any slash commands
+ */
+ Notes.prototype.hasSlashCommands = function(formContent) {
+ return REGEX_SLASH_COMMANDS.test(formContent);
+ };
+
+ /**
+ * Remove slash commands and leave comment with pure message
+ */
+ Notes.prototype.stripSlashCommands = function(formContent) {
+ return formContent.replace(REGEX_SLASH_COMMANDS, '').trim();
+ };
+
+ /**
+ * Create placeholder note DOM element populated with comment body
+ * that we will show while comment is being posted.
+ * Once comment is _actually_ posted on server, we will have final element
+ * in response that we will show in place of this temporary element.
+ */
+ Notes.prototype.createPlaceholderNote = function({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname }) {
+ const discussionClass = isDiscussionNote ? 'discussion' : '';
+ const escapedFormContent = _.escape(formContent);
+ const $tempNote = $(
+ `<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry">
+ <div class="timeline-entry-inner">
+ <div class="timeline-icon">
+ <a href="/${currentUsername}"><span class="avatar dummy-avatar"></span></a>
+ </div>
+ <div class="timeline-content ${discussionClass}">
+ <div class="note-header">
+ <div class="note-header-info">
+ <a href="/${currentUsername}">
+ <span class="hidden-xs">${currentUserFullname}</span>
+ <span class="note-headline-light">@${currentUsername}</span>
+ </a>
+ </div>
+ </div>
+ <div class="note-body">
+ <div class="note-text">
+ <p>${escapedFormContent}</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </li>`
+ );
+
+ return $tempNote;
+ };
+
+ /**
+ * This method does following tasks step-by-step whenever a new comment
+ * is submitted by user (both main thread comments as well as discussion comments).
+ *
+ * 1) Get Form metadata
+ * 2) Identify comment type; a) Main thread b) Discussion thread c) Discussion resolve
+ * 3) Build temporary placeholder element (using `createPlaceholderNote`)
+ * 4) Show placeholder note on UI
+ * 5) Perform network request to submit the note using `gl.utils.ajaxPost`
+ * a) If request is successfully completed
+ * 1. Remove placeholder element
+ * 2. Show submitted Note element
+ * 3. Perform post-submit errands
+ * a. Mark discussion as resolved if comment submission was for resolve.
+ * b. Reset comment form to original state.
+ * b) If request failed
+ * 1. Remove placeholder element
+ * 2. Show error Flash message about failure
+ */
+ Notes.prototype.postComment = function(e) {
+ e.preventDefault();
+
+ // Get Form metadata
+ const $submitBtn = $(e.target);
+ let $form = $submitBtn.parents('form');
+ const $closeBtn = $form.find('.js-note-target-close');
+ const isDiscussionNote = $submitBtn.parent().find('li.droplab-item-selected').attr('id') === 'discussion';
+ const isMainForm = $form.hasClass('js-main-target-form');
+ const isDiscussionForm = $form.hasClass('js-discussion-note-form');
+ const isDiscussionResolve = $submitBtn.hasClass('js-comment-resolve-button');
+ const { formData, formContent, formAction } = this.getFormData($form);
+ const uniqueId = _.uniqueId('tempNote_');
+ let $notesContainer;
+ let tempFormContent;
+
+ // Get reference to notes container based on type of comment
+ if (isDiscussionForm) {
+ $notesContainer = $form.parent('.discussion-notes').find('.notes');
+ } else if (isMainForm) {
+ $notesContainer = $('ul.main-notes-list');
+ }
+
+ // If comment is to resolve discussion, disable submit buttons while
+ // comment posting is finished.
+ if (isDiscussionResolve) {
+ $submitBtn.disable();
+ $form.find('.js-comment-submit-button').disable();
+ }
+
+ tempFormContent = formContent;
+ if (this.hasSlashCommands(formContent)) {
+ tempFormContent = this.stripSlashCommands(formContent);
+ }
+
+ if (tempFormContent) {
+ // Show placeholder note
+ $notesContainer.append(this.createPlaceholderNote({
+ formContent: tempFormContent,
+ uniqueId,
+ isDiscussionNote,
+ currentUsername: gon.current_username,
+ currentUserFullname: gon.current_user_fullname,
+ }));
+ }
+
+ // Clear the form textarea
+ if ($notesContainer.length) {
+ if (isMainForm) {
+ this.resetMainTargetForm(e);
+ } else if (isDiscussionForm) {
+ this.removeDiscussionNoteForm($form);
+ }
+ }
+
+ /* eslint-disable promise/catch-or-return */
+ // Make request to submit comment on server
+ gl.utils.ajaxPost(formAction, formData)
+ .then((note) => {
+ // Submission successful! remove placeholder
+ $notesContainer.find(`#${uniqueId}`).remove();
+ // Clear previous form errors
+ this.clearFlashWrapper();
+
+ // Check if this was discussion comment
+ if (isDiscussionForm) {
+ // Remove flash-container
+ $notesContainer.find('.flash-container').remove();
+
+ // If comment intends to resolve discussion, do the same.
+ if (isDiscussionResolve) {
+ $form
+ .attr('data-discussion-id', $submitBtn.data('discussion-id'))
+ .attr('data-resolve-all', 'true')
+ .attr('data-project-path', $submitBtn.data('project-path'));
+ }
+
+ // Show final note element on UI
+ this.addDiscussionNote($form, note, $notesContainer.length === 0);
+
+ // append flash-container to the Notes list
+ if ($notesContainer.length) {
+ $notesContainer.append('<div class="flash-container" style="display: none;"></div>');
+ }
+ } else if (isMainForm) { // Check if this was main thread comment
+ // Show final note element on UI and perform form and action buttons cleanup
+ this.addNote($form, note);
+ this.reenableTargetFormSubmitButton(e);
+ }
+
+ if (note.commands_changes) {
+ this.handleSlashCommands(note);
+ }
+
+ $form.trigger('ajax:success', [note]);
+ }).fail(() => {
+ // Submission failed, remove placeholder note and show Flash error message
+ $notesContainer.find(`#${uniqueId}`).remove();
+
+ // Show form again on UI on failure
+ if (isDiscussionForm && $notesContainer.length) {
+ const replyButton = $notesContainer.parent().find('.js-discussion-reply-button');
+ this.replyToDiscussionNote(replyButton[0]);
+ $form = $notesContainer.parent().find('form');
+ }
+
+ $form.find('.js-note-text').val(formContent);
+ this.reenableTargetFormSubmitButton(e);
+ this.addNoteError($form);
+ });
+
+ return $closeBtn.text($closeBtn.data('original-text'));
+ };
+
+ /**
+ * This method does following tasks step-by-step whenever an existing comment
+ * is updated by user (both main thread comments as well as discussion comments).
+ *
+ * 1) Get Form metadata
+ * 2) Update note element with new content
+ * 3) Perform network request to submit the updated note using `gl.utils.ajaxPost`
+ * a) If request is successfully completed
+ * 1. Show submitted Note element
+ * b) If request failed
+ * 1. Revert Note element to original content
+ * 2. Show error Flash message about failure
+ */
+ Notes.prototype.updateComment = function(e) {
+ e.preventDefault();
+
+ // Get Form metadata
+ const $submitBtn = $(e.target);
+ const $form = $submitBtn.parents('form');
+ const $closeBtn = $form.find('.js-note-target-close');
+ const $editingNote = $form.parents('.note.is-editing');
+ const $noteBody = $editingNote.find('.js-task-list-container');
+ const $noteBodyText = $noteBody.find('.note-text');
+ const { formData, formContent, formAction } = this.getFormData($form);
+
+ // Cache original comment content
+ const cachedNoteBodyText = $noteBodyText.html();
+
+ // Show updated comment content temporarily
+ $noteBodyText.html(formContent);
+ $editingNote.removeClass('is-editing fade-in-full').addClass('being-posted fade-in-half');
+ $editingNote.find('.note-headline-meta a').html('<i class="fa fa-spinner fa-spin" aria-label="Comment is being updated" aria-hidden="true"></i>');
+
+ /* eslint-disable promise/catch-or-return */
+ // Make request to update comment on server
+ gl.utils.ajaxPost(formAction, formData)
+ .then((note) => {
+ // Submission successful! render final note element
+ this.updateNote(note, $editingNote);
+ })
+ .fail(() => {
+ // Submission failed, revert back to original note
+ $noteBodyText.html(cachedNoteBodyText);
+ $editingNote.removeClass('being-posted fade-in');
+ $editingNote.find('.fa.fa-spinner').remove();
+
+ // Show Flash message about failure
+ this.updateNoteError();
+ });
+
+ return $closeBtn.text($closeBtn.data('original-text'));
+ };
+
return Notes;
})();
}).call(window);
diff --git a/app/assets/javascripts/notifications_form.js b/app/assets/javascripts/notifications_form.js
index 5005af90d48..2ab9c4fed2c 100644
--- a/app/assets/javascripts/notifications_form.js
+++ b/app/assets/javascripts/notifications_form.js
@@ -1,10 +1,8 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, one-var-declaration-per-line, newline-per-chained-call, comma-dangle, consistent-return, prefer-arrow-callback, max-len */
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.NotificationsForm = (function() {
function NotificationsForm() {
- this.toggleCheckbox = bind(this.toggleCheckbox, this);
+ this.toggleCheckbox = this.toggleCheckbox.bind(this);
this.removeEventListeners();
this.initEventListeners();
}
diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js
index 5f6bc902cf8..0ef20af9260 100644
--- a/app/assets/javascripts/pager.js
+++ b/app/assets/javascripts/pager.js
@@ -1,5 +1,5 @@
-require('~/lib/utils/common_utils');
-require('~/lib/utils/url_utility');
+import '~/lib/utils/common_utils';
+import '~/lib/utils/url_utility';
(() => {
const ENDLESS_SCROLL_BOTTOM_PX = 400;
diff --git a/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.js b/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.js
new file mode 100644
index 00000000000..4d623763ca7
--- /dev/null
+++ b/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.js
@@ -0,0 +1,145 @@
+import Vue from 'vue';
+
+const inputNameAttribute = 'schedule[cron]';
+
+export default {
+ props: {
+ initialCronInterval: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ inputNameAttribute,
+ cronInterval: this.initialCronInterval,
+ cronIntervalPresets: {
+ everyDay: '0 4 * * *',
+ everyWeek: '0 4 * * 0',
+ everyMonth: '0 4 1 * *',
+ },
+ cronSyntaxUrl: 'https://en.wikipedia.org/wiki/Cron',
+ customInputEnabled: false,
+ };
+ },
+ computed: {
+ intervalIsPreset() {
+ return _.contains(this.cronIntervalPresets, this.cronInterval);
+ },
+ // The text input is editable when there's a custom interval, or when it's
+ // a preset interval and the user clicks the 'custom' radio button
+ isEditable() {
+ return !!(this.customInputEnabled || !this.intervalIsPreset);
+ },
+ },
+ methods: {
+ toggleCustomInput(shouldEnable) {
+ this.customInputEnabled = shouldEnable;
+
+ if (shouldEnable) {
+ // We need to change the value so other radios don't remain selected
+ // because the model (cronInterval) hasn't changed. The server trims it.
+ this.cronInterval = `${this.cronInterval} `;
+ }
+ },
+ },
+ created() {
+ if (this.intervalIsPreset) {
+ this.enableCustomInput = false;
+ }
+ },
+ watch: {
+ cronInterval() {
+ // updates field validation state when model changes, as
+ // glFieldError only updates on input.
+ Vue.nextTick(() => {
+ gl.pipelineScheduleFieldErrors.updateFormValidityState();
+ });
+ },
+ },
+ template: `
+ <div class="interval-pattern-form-group">
+ <div class="cron-preset-radio-input">
+ <input
+ id="custom"
+ class="label-light"
+ type="radio"
+ :name="inputNameAttribute"
+ :value="cronInterval"
+ :checked="isEditable"
+ @click="toggleCustomInput(true)"
+ />
+
+ <label for="custom">
+ Custom
+ </label>
+
+ <span class="cron-syntax-link-wrap">
+ (<a :href="cronSyntaxUrl" target="_blank">Cron syntax</a>)
+ </span>
+ </div>
+
+ <div class="cron-preset-radio-input">
+ <input
+ id="every-day"
+ class="label-light"
+ type="radio"
+ v-model="cronInterval"
+ :name="inputNameAttribute"
+ :value="cronIntervalPresets.everyDay"
+ @click="toggleCustomInput(false)"
+ />
+
+ <label class="label-light" for="every-day">
+ Every day (at 4:00am)
+ </label>
+ </div>
+
+ <div class="cron-preset-radio-input">
+ <input
+ id="every-week"
+ class="label-light"
+ type="radio"
+ v-model="cronInterval"
+ :name="inputNameAttribute"
+ :value="cronIntervalPresets.everyWeek"
+ @click="toggleCustomInput(false)"
+ />
+
+ <label class="label-light" for="every-week">
+ Every week (Sundays at 4:00am)
+ </label>
+ </div>
+
+ <div class="cron-preset-radio-input">
+ <input
+ id="every-month"
+ class="label-light"
+ type="radio"
+ v-model="cronInterval"
+ :name="inputNameAttribute"
+ :value="cronIntervalPresets.everyMonth"
+ @click="toggleCustomInput(false)"
+ />
+
+ <label class="label-light" for="every-month">
+ Every month (on the 1st at 4:00am)
+ </label>
+ </div>
+
+ <div class="cron-interval-input-wrapper">
+ <input
+ id="schedule_cron"
+ class="form-control inline cron-interval-input"
+ type="text"
+ placeholder="Define a custom pattern with cron syntax"
+ required="true"
+ v-model="cronInterval"
+ :name="inputNameAttribute"
+ :disabled="!isEditable"
+ />
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js b/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js
new file mode 100644
index 00000000000..5109b110b31
--- /dev/null
+++ b/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js
@@ -0,0 +1,48 @@
+import Cookies from 'js-cookie';
+import illustrationSvg from '../icons/intro_illustration.svg';
+
+const cookieKey = 'pipeline_schedules_callout_dismissed';
+
+export default {
+ name: 'PipelineSchedulesCallout',
+ data() {
+ return {
+ docsUrl: document.getElementById('pipeline-schedules-callout').dataset.docsUrl,
+ illustrationSvg,
+ calloutDismissed: Cookies.get(cookieKey) === 'true',
+ };
+ },
+ methods: {
+ dismissCallout() {
+ this.calloutDismissed = true;
+ Cookies.set(cookieKey, this.calloutDismissed, { expires: 365 });
+ },
+ },
+ template: `
+ <div v-if="!calloutDismissed" class="pipeline-schedules-user-callout user-callout">
+ <div class="bordered-box landing content-block">
+ <button
+ id="dismiss-callout-btn"
+ class="btn btn-default close"
+ @click="dismissCallout">
+ <i class="fa fa-times"></i>
+ </button>
+ <div class="svg-container" v-html="illustrationSvg"></div>
+ <div class="user-callout-copy">
+ <h4>Scheduling Pipelines</h4>
+ <p>
+ The pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags.
+ Those scheduled pipelines will inherit limited project access based on their associated user.
+ </p>
+ <p> Learn more in the
+ <a
+ :href="docsUrl"
+ target="_blank"
+ rel="nofollow">pipeline schedules documentation</a>. <!-- oneline to prevent extra space before period -->
+ </p>
+ </div>
+ </div>
+ </div>
+ `,
+};
+
diff --git a/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js b/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js
new file mode 100644
index 00000000000..0c3926d76b5
--- /dev/null
+++ b/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js
@@ -0,0 +1,52 @@
+export default class TargetBranchDropdown {
+ constructor() {
+ this.$dropdown = $('.js-target-branch-dropdown');
+ this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text');
+ this.$input = $('#schedule_ref');
+ this.initDefaultBranch();
+ this.initDropdown();
+ }
+
+ initDropdown() {
+ this.$dropdown.glDropdown({
+ data: this.formatBranchesList(),
+ filterable: true,
+ selectable: true,
+ toggleLabel: item => item.name,
+ search: {
+ fields: ['name'],
+ },
+ clicked: cfg => this.updateInputValue(cfg),
+ text: item => item.name,
+ });
+
+ this.setDropdownToggle();
+ }
+
+ formatBranchesList() {
+ return this.$dropdown.data('data')
+ .map(val => ({ name: val }));
+ }
+
+ setDropdownToggle() {
+ const initialValue = this.$input.val();
+
+ this.$dropdownToggle.text(initialValue);
+ }
+
+ initDefaultBranch() {
+ const initialValue = this.$input.val();
+ const defaultBranch = this.$dropdown.data('defaultBranch');
+
+ if (!initialValue) {
+ this.$input.val(defaultBranch);
+ }
+ }
+
+ updateInputValue({ selectedObj, e }) {
+ e.preventDefault();
+
+ this.$input.val(selectedObj.name);
+ gl.pipelineScheduleFieldErrors.updateFormValidityState();
+ }
+}
diff --git a/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js b/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js
new file mode 100644
index 00000000000..95ed9c7dc21
--- /dev/null
+++ b/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js
@@ -0,0 +1,66 @@
+/* eslint-disable class-methods-use-this */
+
+const defaultTimezone = 'UTC';
+
+export default class TimezoneDropdown {
+ constructor() {
+ this.$dropdown = $('.js-timezone-dropdown');
+ this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text');
+ this.$input = $('#schedule_cron_timezone');
+ this.timezoneData = this.$dropdown.data('data');
+ this.initDefaultTimezone();
+ this.initDropdown();
+ }
+
+ initDropdown() {
+ this.$dropdown.glDropdown({
+ data: this.timezoneData,
+ filterable: true,
+ selectable: true,
+ toggleLabel: item => item.name,
+ search: {
+ fields: ['name'],
+ },
+ clicked: cfg => this.updateInputValue(cfg),
+ text: item => this.formatTimezone(item),
+ });
+
+ this.setDropdownToggle();
+ }
+
+ formatUtcOffset(offset) {
+ let prefix = '';
+
+ if (offset > 0) {
+ prefix = '+';
+ } else if (offset < 0) {
+ prefix = '-';
+ }
+
+ return `${prefix} ${Math.abs(offset / 3600)}`;
+ }
+
+ formatTimezone(item) {
+ return `[UTC ${this.formatUtcOffset(item.offset)}] ${item.name}`;
+ }
+
+ initDefaultTimezone() {
+ const initialValue = this.$input.val();
+
+ if (!initialValue) {
+ this.$input.val(defaultTimezone);
+ }
+ }
+
+ setDropdownToggle() {
+ const initialValue = this.$input.val();
+
+ this.$dropdownToggle.text(initialValue);
+ }
+
+ updateInputValue({ selectedObj, e }) {
+ e.preventDefault();
+ this.$input.val(selectedObj.identifier);
+ gl.pipelineScheduleFieldErrors.updateFormValidityState();
+ }
+}
diff --git a/app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg b/app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg
new file mode 100644
index 00000000000..26d1ff97b3e
--- /dev/null
+++ b/app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg
@@ -0,0 +1 @@
+<svg width="140" height="102" viewBox="0 0 140 102" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>illustration</title><defs><rect id="a" width="12.033" height="40.197" rx="3"/><rect id="b" width="12.033" height="40.197" rx="3"/></defs><g fill="none" fill-rule="evenodd"><g transform="translate(-.446)"><path d="M91.747 35.675v-6.039a2.996 2.996 0 0 0-2.999-3.005H54.635a2.997 2.997 0 0 0-2.999 3.005v6.039H40.092a3.007 3.007 0 0 0-2.996 3.005v34.187a2.995 2.995 0 0 0 2.996 3.005h11.544V79.9a2.996 2.996 0 0 0 2.999 3.005h34.113a2.997 2.997 0 0 0 2.999-3.005v-4.03h11.544a3.007 3.007 0 0 0 2.996-3.004V38.68a2.995 2.995 0 0 0-2.996-3.005H91.747z" stroke="#B5A7DD" stroke-width="2"/><rect stroke="#E5E5E5" stroke-width="2" fill="#FFF" x="21.556" y="38.69" width="98.27" height="34.167" rx="3"/><path d="M121.325 38.19c.55 0 .995.444.995 1.002 0 .554-.453 1.003-.995 1.003h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.454 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.453 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zM121.325 71.854a1.004 1.004 0 0 1 0 2.006h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038z" fill="#E5E5E5"/><g transform="translate(110.3 35.675)"><use fill="#FFF" xlink:href="#a"/><rect stroke="#FDE5D8" stroke-width="2" x="1" y="1" width="10.033" height="38.197" rx="3"/><ellipse fill="#FC8A51" cx="6.017" cy="9.547" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="20.099" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="30.65" rx="1.504" ry="1.507"/></g><path d="M6.008 38.19c.55 0 .996.444.996 1.002 0 .554-.454 1.003-.996 1.003H1.97a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.453 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.045 0c.55 0 .995.444.995 1.002 0 .554-.453 1.003-.995 1.003h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zM6.008 71.854a1.004 1.004 0 0 1 0 2.006H1.97a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.045 0a1.004 1.004 0 0 1 0 2.006h-4.039a1.004 1.004 0 0 1 0-2.006h4.039z" fill="#E5E5E5"/><g transform="translate(19.05 35.675)"><use fill="#FFF" xlink:href="#b"/><rect stroke="#FDE5D8" stroke-width="2" x="1" y="1" width="10.033" height="38.197" rx="3"/><ellipse fill="#FC8A51" cx="6.017" cy="10.049" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="20.601" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="31.153" rx="1.504" ry="1.507"/></g><g transform="translate(47.096)"><g transform="translate(7.05)"><ellipse fill="#FC8A51" cx="17.548" cy="5.025" rx="4.512" ry="4.522"/><rect stroke="#B5A7DD" stroke-width="2" fill="#FFF" x="13.036" y="4.02" width="9.025" height="20.099" rx="1.5"/><rect stroke="#FDE5D8" stroke-width="2" fill="#FFF" y="4.02" width="35.096" height="4.02" rx="2.01"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="4.512" y="18.089" width="26.072" height="17.084" rx="1.5"/></g><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(-45 43.117 35.117)" x="38.168" y="31.416" width="9.899" height="7.403" rx="3.702"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25" cy="55" rx="25" ry="25"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25" cy="55" rx="21" ry="21"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="43.05" y="53.281" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="4.305" y="53.281" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(90 25.153 74.422)" x="23.677" y="73.653" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(90 25.153 35.51)" x="23.844" y="34.742" width="2.616" height="1.538" rx=".769"/><path d="M13.362 42.502c-.124-.543.198-.854.74-.69l2.321.704c.533.161.643.592.235.972l-.22.206 7.06 7.572a1.002 1.002 0 1 1-1.467 1.368l-7.06-7.573-.118.11c-.402.375-.826.248-.952-.304l-.54-2.365zM21.606 67.576c-.408.38-.84.255-.968-.295l-.551-2.363c-.127-.542.191-.852.725-.69l.288.089 3.027-9.901a1.002 1.002 0 1 1 1.918.586l-3.027 9.901.154.047c.525.16.627.592.213.977l-1.779 1.65z" fill="#FC8A51"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25.099" cy="54.768" rx="2.507" ry="2.512"/></g></g><path d="M52.697 96.966a1.004 1.004 0 0 1 2.006 0v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zm0-9.044a1.004 1.004 0 0 1 2.006 0v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zM86.29 96.966c0-.55.444-.996 1.002-.996.554 0 1.003.454 1.003.996v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zm0-9.044c0-.55.444-.996 1.002-.996.554 0 1.003.453 1.003.996v4.038a1.004 1.004 0 0 1-2.006 0v-4.038z" fill="#E5E5E5"/></g></svg> \ No newline at end of file
diff --git a/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js b/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js
new file mode 100644
index 00000000000..c60e77decce
--- /dev/null
+++ b/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js
@@ -0,0 +1,21 @@
+import Vue from 'vue';
+import IntervalPatternInput from './components/interval_pattern_input';
+import TimezoneDropdown from './components/timezone_dropdown';
+import TargetBranchDropdown from './components/target_branch_dropdown';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const IntervalPatternInputComponent = Vue.extend(IntervalPatternInput);
+ const intervalPatternMount = document.getElementById('interval-pattern-input');
+ const initialCronInterval = intervalPatternMount ? intervalPatternMount.dataset.initialInterval : '';
+
+ new IntervalPatternInputComponent({
+ propsData: {
+ initialCronInterval,
+ },
+ }).$mount(intervalPatternMount);
+
+ const formElement = document.getElementById('new-pipeline-schedule-form');
+ gl.timezoneDropdown = new TimezoneDropdown();
+ gl.targetBranchDropdown = new TargetBranchDropdown();
+ gl.pipelineScheduleFieldErrors = new gl.GlFieldErrors(formElement);
+});
diff --git a/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js b/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js
new file mode 100644
index 00000000000..6584549ad06
--- /dev/null
+++ b/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js
@@ -0,0 +1,12 @@
+import Vue from 'vue';
+import PipelineSchedulesCallout from './components/pipeline_schedules_callout';
+
+document.addEventListener('DOMContentLoaded', () => new Vue({
+ el: '#pipeline-schedules-callout',
+ components: {
+ 'pipeline-schedules-callout': PipelineSchedulesCallout,
+ },
+ render(createElement) {
+ return createElement('pipeline-schedules-callout');
+ },
+}));
diff --git a/app/assets/javascripts/pipelines.js b/app/assets/javascripts/pipelines.js
index 4252b615887..26a36ad54d1 100644
--- a/app/assets/javascripts/pipelines.js
+++ b/app/assets/javascripts/pipelines.js
@@ -1,42 +1,14 @@
-/* eslint-disable no-new, guard-for-in, no-restricted-syntax, no-continue, no-param-reassign, max-len */
+import LinkedTabs from './lib/utils/bootstrap_linked_tabs';
-require('./lib/utils/bootstrap_linked_tabs');
-
-((global) => {
- class Pipelines {
- constructor(options = {}) {
- if (options.initTabs && options.tabsOptions) {
- new global.LinkedTabs(options.tabsOptions);
- }
-
- if (options.pipelineStatusUrl) {
- gl.utils.setCiStatusFavicon(options.pipelineStatusUrl);
- }
-
- this.addMarginToBuildColumns();
+export default class Pipelines {
+ constructor(options = {}) {
+ if (options.initTabs && options.tabsOptions) {
+ // eslint-disable-next-line no-new
+ new LinkedTabs(options.tabsOptions);
}
- addMarginToBuildColumns() {
- this.pipelineGraph = document.querySelector('.js-pipeline-graph');
-
- const secondChildBuildNodes = this.pipelineGraph.querySelectorAll('.build:nth-child(2)');
-
- for (const buildNodeIndex in secondChildBuildNodes) {
- const buildNode = secondChildBuildNodes[buildNodeIndex];
- const firstChildBuildNode = buildNode.previousElementSibling;
- if (!firstChildBuildNode || !firstChildBuildNode.matches('.build')) continue;
- const multiBuildColumn = buildNode.closest('.stage-column');
- const previousColumn = multiBuildColumn.previousElementSibling;
- if (!previousColumn || !previousColumn.matches('.stage-column')) continue;
- multiBuildColumn.classList.add('left-margin');
- firstChildBuildNode.classList.add('left-connector');
- const columnBuilds = previousColumn.querySelectorAll('.build');
- if (columnBuilds.length === 1) previousColumn.classList.add('no-margin');
- }
-
- this.pipelineGraph.classList.remove('hidden');
+ if (options.pipelineStatusUrl) {
+ gl.utils.setCiStatusFavicon(options.pipelineStatusUrl);
}
}
-
- global.Pipelines = Pipelines;
-})(window.gl || (window.gl = {}));
+}
diff --git a/app/assets/javascripts/pipelines/components/async_button.vue b/app/assets/javascripts/pipelines/components/async_button.vue
index d1c60b570de..37a6f02d8fd 100644
--- a/app/assets/javascripts/pipelines/components/async_button.vue
+++ b/app/assets/javascripts/pipelines/components/async_button.vue
@@ -3,6 +3,7 @@
/* global Flash */
import '~/flash';
import eventHub from '../event_hub';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
props: {
@@ -37,6 +38,10 @@ export default {
},
},
+ components: {
+ loadingIcon,
+ },
+
data() {
return {
isLoading: false,
@@ -94,9 +99,6 @@ export default {
<i
:class="iconClass"
aria-hidden="true" />
- <i
- class="fa fa-spinner fa-spin"
- aria-hidden="true"
- v-if="isLoading" />
+ <loading-icon v-if="isLoading" />
</button>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue
new file mode 100644
index 00000000000..1f9e3d39779
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue
@@ -0,0 +1,64 @@
+<script>
+ import getActionIcon from '../../../vue_shared/ci_action_icons';
+ import tooltipMixin from '../../../vue_shared/mixins/tooltip';
+
+ /**
+ * Renders either a cancel, retry or play icon pointing to the given path.
+ * TODO: Remove UJS from here and use an async request instead.
+ */
+ export default {
+ props: {
+ tooltipText: {
+ type: String,
+ required: true,
+ },
+
+ link: {
+ type: String,
+ required: true,
+ },
+
+ actionMethod: {
+ type: String,
+ required: true,
+ },
+
+ actionIcon: {
+ type: String,
+ required: true,
+ },
+ },
+
+ mixins: [
+ tooltipMixin,
+ ],
+
+ computed: {
+ actionIconSvg() {
+ return getActionIcon(this.actionIcon);
+ },
+
+ cssClass() {
+ return `js-${gl.text.dasherize(this.actionIcon)}`;
+ },
+ },
+ };
+</script>
+<template>
+ <a
+ :data-method="actionMethod"
+ :title="tooltipText"
+ :href="link"
+ ref="tooltip"
+ class="ci-action-icon-container"
+ data-toggle="tooltip"
+ data-container="body">
+
+ <i
+ class="ci-action-icon-wrapper"
+ :class="cssClass"
+ v-html="actionIconSvg"
+ aria-hidden="true"
+ />
+ </a>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue
new file mode 100644
index 00000000000..19cafff4e1c
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue
@@ -0,0 +1,56 @@
+<script>
+ import getActionIcon from '../../../vue_shared/ci_action_icons';
+ import tooltipMixin from '../../../vue_shared/mixins/tooltip';
+
+ /**
+ * Renders either a cancel, retry or play icon pointing to the given path.
+ * TODO: Remove UJS from here and use an async request instead.
+ */
+ export default {
+ props: {
+ tooltipText: {
+ type: String,
+ required: true,
+ },
+
+ link: {
+ type: String,
+ required: true,
+ },
+
+ actionMethod: {
+ type: String,
+ required: true,
+ },
+
+ actionIcon: {
+ type: String,
+ required: true,
+ },
+ },
+
+ mixins: [
+ tooltipMixin,
+ ],
+
+ computed: {
+ actionIconSvg() {
+ return getActionIcon(this.actionIcon);
+ },
+ },
+ };
+</script>
+<template>
+ <a
+ :data-method="actionMethod"
+ :title="tooltipText"
+ :href="link"
+ ref="tooltip"
+ rel="nofollow"
+ class="ci-action-icon-wrapper js-ci-status-icon"
+ data-toggle="tooltip"
+ data-container="body"
+ v-html="actionIconSvg"
+ aria-label="Job's action">
+ </a>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue
new file mode 100644
index 00000000000..d597af8dfb5
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue
@@ -0,0 +1,86 @@
+<script>
+ import jobNameComponent from './job_name_component.vue';
+ import jobComponent from './job_component.vue';
+ import tooltipMixin from '../../../vue_shared/mixins/tooltip';
+
+ /**
+ * Renders the dropdown for the pipeline graph.
+ *
+ * The following object should be provided as `job`:
+ *
+ * {
+ * "id": 4256,
+ * "name": "test",
+ * "status": {
+ * "icon": "icon_status_success",
+ * "text": "passed",
+ * "label": "passed",
+ * "group": "success",
+ * "details_path": "/root/ci-mock/builds/4256",
+ * "action": {
+ * "icon": "icon_action_retry",
+ * "title": "Retry",
+ * "path": "/root/ci-mock/builds/4256/retry",
+ * "method": "post"
+ * }
+ * }
+ * }
+ */
+ export default {
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ mixins: [
+ tooltipMixin,
+ ],
+
+ components: {
+ jobComponent,
+ jobNameComponent,
+ },
+
+ computed: {
+ tooltipText() {
+ return `${this.job.name} - ${this.job.status.label}`;
+ },
+ },
+ };
+</script>
+<template>
+ <div>
+ <button
+ type="button"
+ data-toggle="dropdown"
+ data-container="body"
+ class="dropdown-menu-toggle build-content"
+ :title="tooltipText"
+ ref="tooltip">
+
+ <job-name-component
+ :name="job.name"
+ :status="job.status" />
+
+ <span class="dropdown-counter-badge">
+ {{job.size}}
+ </span>
+ </button>
+
+ <ul class="dropdown-menu big-pipeline-graph-dropdown-menu js-grouped-pipeline-dropdown">
+ <li class="scrollable-menu">
+ <ul>
+ <li v-for="item in job.jobs">
+ <job-component
+ :job="item"
+ :is-dropdown="true"
+ css-class-job-name="mini-pipeline-graph-dropdown-item"
+ />
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
new file mode 100644
index 00000000000..77cbaeb43ef
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -0,0 +1,77 @@
+<script>
+ import stageColumnComponent from './stage_column_component.vue';
+ import loadingIcon from '../../../vue_shared/components/loading_icon.vue';
+ import '../../../flash';
+
+ export default {
+ props: {
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ components: {
+ stageColumnComponent,
+ loadingIcon,
+ },
+
+ computed: {
+ graph() {
+ return this.pipeline.details && this.pipeline.details.stages;
+ },
+ },
+
+ methods: {
+ capitalizeStageName(name) {
+ return name.charAt(0).toUpperCase() + name.slice(1);
+ },
+
+ isFirstColumn(index) {
+ return index === 0;
+ },
+
+ stageConnectorClass(index, stage) {
+ let className;
+
+ // If it's the first stage column and only has one job
+ if (index === 0 && stage.groups.length === 1) {
+ className = 'no-margin';
+ } else if (index > 0) {
+ // If it is not the first column
+ className = 'left-margin';
+ }
+
+ return className;
+ },
+ },
+ };
+</script>
+<template>
+ <div class="build-content middle-block js-pipeline-graph">
+ <div class="pipeline-visualization pipeline-graph">
+ <div class="text-center">
+ <loading-icon
+ v-if="isLoading"
+ size="3"
+ />
+ </div>
+
+ <ul
+ v-if="!isLoading"
+ class="stage-column-list">
+ <stage-column-component
+ v-for="(stage, index) in graph"
+ :title="capitalizeStageName(stage.name)"
+ :jobs="stage.groups"
+ :key="stage.name"
+ :stage-connector-class="stageConnectorClass(index, stage)"
+ :is-first-column="isFirstColumn(index)"/>
+ </ul>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_component.vue b/app/assets/javascripts/pipelines/components/graph/job_component.vue
new file mode 100644
index 00000000000..b39c936101e
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/job_component.vue
@@ -0,0 +1,124 @@
+<script>
+ import actionComponent from './action_component.vue';
+ import dropdownActionComponent from './dropdown_action_component.vue';
+ import jobNameComponent from './job_name_component.vue';
+ import tooltipMixin from '../../../vue_shared/mixins/tooltip';
+
+ /**
+ * Renders the badge for the pipeline graph and the job's dropdown.
+ *
+ * The following object should be provided as `job`:
+ *
+ * {
+ * "id": 4256,
+ * "name": "test",
+ * "status": {
+ * "icon": "icon_status_success",
+ * "text": "passed",
+ * "label": "passed",
+ * "group": "success",
+ * "details_path": "/root/ci-mock/builds/4256",
+ * "action": {
+ * "icon": "icon_action_retry",
+ * "title": "Retry",
+ * "path": "/root/ci-mock/builds/4256/retry",
+ * "method": "post"
+ * }
+ * }
+ * }
+ */
+
+ export default {
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+
+ cssClassJobName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+
+ isDropdown: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ components: {
+ actionComponent,
+ dropdownActionComponent,
+ jobNameComponent,
+ },
+
+ mixins: [
+ tooltipMixin,
+ ],
+
+ computed: {
+ tooltipText() {
+ return `${this.job.name} - ${this.job.status.label}`;
+ },
+
+ /**
+ * Verifies if the provided job has an action path
+ *
+ * @return {Boolean}
+ */
+ hasAction() {
+ return this.job.status && this.job.status.action && this.job.status.action.path;
+ },
+ },
+ };
+</script>
+<template>
+ <div>
+ <a
+ v-if="job.status.details_path"
+ :href="job.status.details_path"
+ :title="tooltipText"
+ :class="cssClassJobName"
+ ref="tooltip"
+ data-toggle="tooltip"
+ data-container="body">
+
+ <job-name-component
+ :name="job.name"
+ :status="job.status"
+ />
+ </a>
+
+ <div
+ v-else
+ :title="tooltipText"
+ :class="cssClassJobName"
+ ref="tooltip"
+ data-toggle="tooltip"
+ data-container="body">
+
+ <job-name-component
+ :name="job.name"
+ :status="job.status"
+ />
+ </div>
+
+ <action-component
+ v-if="hasAction && !isDropdown"
+ :tooltip-text="job.status.action.title"
+ :link="job.status.action.path"
+ :action-icon="job.status.action.icon"
+ :action-method="job.status.action.method"
+ />
+
+ <dropdown-action-component
+ v-if="hasAction && isDropdown"
+ :tooltip-text="job.status.action.title"
+ :link="job.status.action.path"
+ :action-icon="job.status.action.icon"
+ :action-method="job.status.action.method"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
new file mode 100644
index 00000000000..d8856e10668
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
@@ -0,0 +1,37 @@
+<script>
+ import ciIcon from '../../../vue_shared/components/ci_icon.vue';
+
+ /**
+ * Component that renders both the CI icon status and the job name.
+ * Used in
+ * - Badge component
+ * - Dropdown badge components
+ */
+ export default {
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+
+ status: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ components: {
+ ciIcon,
+ },
+ };
+</script>
+<template>
+ <span>
+ <ci-icon
+ :status="status" />
+
+ <span class="ci-status-text">
+ {{name}}
+ </span>
+ </span>
+</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
new file mode 100644
index 00000000000..9b1bbb0906f
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -0,0 +1,83 @@
+<script>
+import jobComponent from './job_component.vue';
+import dropdownJobComponent from './dropdown_job_component.vue';
+
+export default {
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+
+ jobs: {
+ type: Array,
+ required: true,
+ },
+
+ isFirstColumn: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+
+ stageConnectorClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+
+ components: {
+ jobComponent,
+ dropdownJobComponent,
+ },
+
+ methods: {
+ firstJob(list) {
+ return list[0];
+ },
+
+ jobId(job) {
+ return `ci-badge-${job.name}`;
+ },
+
+ buildConnnectorClass(index) {
+ return index === 0 && !this.isFirstColumn ? 'left-connector' : '';
+ },
+ },
+};
+</script>
+<template>
+ <li
+ class="stage-column"
+ :class="stageConnectorClass">
+ <div class="stage-name">
+ {{title}}
+ </div>
+ <div class="builds-container">
+ <ul>
+ <li
+ v-for="(job, index) in jobs"
+ :key="job.id"
+ class="build"
+ :class="buildConnnectorClass(index)"
+ :id="jobId(job)">
+
+ <div class="curve"></div>
+
+ <job-component
+ v-if="job.size === 1"
+ :job="job"
+ css-class-job-name="build-content"
+ />
+
+ <dropdown-job-component
+ v-if="job.size > 1"
+ :job="job"
+ />
+
+ </li>
+ </ul>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.js b/app/assets/javascripts/pipelines/components/pipeline_url.js
deleted file mode 100644
index 4e183d5c8ec..00000000000
--- a/app/assets/javascripts/pipelines/components/pipeline_url.js
+++ /dev/null
@@ -1,56 +0,0 @@
-export default {
- props: [
- 'pipeline',
- ],
- computed: {
- user() {
- return !!this.pipeline.user;
- },
- },
- template: `
- <td>
- <a
- :href="pipeline.path"
- class="js-pipeline-url-link">
- <span class="pipeline-id">#{{pipeline.id}}</span>
- </a>
- <span>by</span>
- <a
- class="js-pipeline-url-user"
- v-if="user"
- :href="pipeline.user.web_url">
- <img
- v-if="user"
- class="avatar has-tooltip s20 "
- :title="pipeline.user.name"
- data-container="body"
- :src="pipeline.user.avatar_url"
- >
- </a>
- <span
- v-if="!user"
- class="js-pipeline-url-api api monospace">
- API
- </span>
- <span
- v-if="pipeline.flags.latest"
- class="js-pipeline-url-lastest label label-success has-tooltip"
- title="Latest pipeline for this branch"
- data-original-title="Latest pipeline for this branch">
- latest
- </span>
- <span
- v-if="pipeline.flags.yaml_errors"
- class="js-pipeline-url-yaml label label-danger has-tooltip"
- :title="pipeline.yaml_errors"
- :data-original-title="pipeline.yaml_errors">
- yaml invalid
- </span>
- <span
- v-if="pipeline.flags.stuck"
- class="js-pipeline-url-stuck label label-warning">
- stuck
- </span>
- </td>
- `,
-};
diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue
new file mode 100644
index 00000000000..b8457fae967
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue
@@ -0,0 +1,65 @@
+<script>
+import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+import tooltipMixin from '../../vue_shared/mixins/tooltip';
+
+export default {
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ },
+ components: {
+ userAvatarLink,
+ },
+ mixins: [
+ tooltipMixin,
+ ],
+ computed: {
+ user() {
+ return this.pipeline.user;
+ },
+ },
+};
+</script>
+<template>
+ <td>
+ <a
+ :href="pipeline.path"
+ class="js-pipeline-url-link">
+ <span class="pipeline-id">#{{pipeline.id}}</span>
+ </a>
+ <span>by</span>
+ <user-avatar-link
+ v-if="user"
+ class="js-pipeline-url-user"
+ :link-href="pipeline.user.web_url"
+ :img-src="pipeline.user.avatar_url"
+ :tooltip-text="pipeline.user.name"
+ />
+ <span
+ v-if="!user"
+ class="js-pipeline-url-api api">
+ API
+ </span>
+ <span
+ v-if="pipeline.flags.latest"
+ class="js-pipeline-url-lastest label label-success"
+ title="Latest pipeline for this branch"
+ ref="tooltip">
+ latest
+ </span>
+ <span
+ v-if="pipeline.flags.yaml_errors"
+ class="js-pipeline-url-yaml label label-danger"
+ :title="pipeline.yaml_errors"
+ ref="tooltip">
+ yaml invalid
+ </span>
+ <span
+ v-if="pipeline.flags.stuck"
+ class="js-pipeline-url-stuck label label-warning">
+ stuck
+ </span>
+ </td>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.js b/app/assets/javascripts/pipelines/components/pipelines_actions.js
index ffda18d2e0f..b9e066c5db1 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_actions.js
+++ b/app/assets/javascripts/pipelines/components/pipelines_actions.js
@@ -3,6 +3,7 @@
import '~/flash';
import playIconSvg from 'icons/_icon_play.svg';
import eventHub from '../event_hub';
+import loadingIconComponent from '../../vue_shared/components/loading_icon.vue';
export default {
props: {
@@ -17,6 +18,10 @@ export default {
},
},
+ components: {
+ loadingIconComponent,
+ },
+
data() {
return {
playIconSvg,
@@ -65,10 +70,7 @@ export default {
<i
class="fa fa-caret-down"
aria-hidden="true" />
- <i
- v-if="isLoading"
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
+ <loading-icon v-if="isLoading" />
</button>
<ul class="dropdown-menu dropdown-menu-align-right">
diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue
index 2e485f951a1..7fc19fce1ff 100644
--- a/app/assets/javascripts/pipelines/components/stage.vue
+++ b/app/assets/javascripts/pipelines/components/stage.vue
@@ -14,7 +14,8 @@
*/
/* global Flash */
-import StatusIconEntityMap from '../../ci_status_icons';
+import { borderlessStatusIconEntityMap } from '../../vue_shared/ci_status_icons';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
props: {
@@ -38,6 +39,10 @@ export default {
};
},
+ components: {
+ loadingIcon,
+ },
+
updated() {
if (this.dropdownContent.length > 0) {
this.stopDropdownClickPropagation();
@@ -113,7 +118,7 @@ export default {
},
svgIcon() {
- return StatusIconEntityMap[this.stage.status.icon];
+ return borderlessStatusIconEntityMap[this.stage.status.icon];
},
},
};
@@ -153,15 +158,7 @@ export default {
:class="dropdownClass"
class="js-builds-dropdown-list scrollable-menu">
- <div
- class="text-center"
- v-if="isLoading">
- <i
- class="fa fa-spin fa-spinner"
- aria-hidden="true"
- aria-label="Loading">
- </i>
- </div>
+ <loading-icon v-if="isLoading"/>
<ul
v-else
diff --git a/app/assets/javascripts/pipelines/components/status.js b/app/assets/javascripts/pipelines/components/status.js
deleted file mode 100644
index 21a281af438..00000000000
--- a/app/assets/javascripts/pipelines/components/status.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import canceledSvg from 'icons/_icon_status_canceled.svg';
-import createdSvg from 'icons/_icon_status_created.svg';
-import failedSvg from 'icons/_icon_status_failed.svg';
-import manualSvg from 'icons/_icon_status_manual.svg';
-import pendingSvg from 'icons/_icon_status_pending.svg';
-import runningSvg from 'icons/_icon_status_running.svg';
-import skippedSvg from 'icons/_icon_status_skipped.svg';
-import successSvg from 'icons/_icon_status_success.svg';
-import warningSvg from 'icons/_icon_status_warning.svg';
-
-export default {
- props: {
- pipeline: {
- type: Object,
- required: true,
- },
- },
-
- data() {
- const svgsDictionary = {
- icon_status_canceled: canceledSvg,
- icon_status_created: createdSvg,
- icon_status_failed: failedSvg,
- icon_status_manual: manualSvg,
- icon_status_pending: pendingSvg,
- icon_status_running: runningSvg,
- icon_status_skipped: skippedSvg,
- icon_status_success: successSvg,
- icon_status_warning: warningSvg,
- };
-
- return {
- svg: svgsDictionary[this.pipeline.details.status.icon],
- };
- },
-
- computed: {
- cssClasses() {
- return `ci-status ci-${this.pipeline.details.status.group}`;
- },
-
- detailsPath() {
- const { status } = this.pipeline.details;
- return status.has_details ? status.details_path : false;
- },
-
- content() {
- return `${this.svg} ${this.pipeline.details.status.text}`;
- },
- },
- template: `
- <td class="commit-link">
- <a
- :class="cssClasses"
- :href="detailsPath"
- v-html="content">
- </a>
- </td>
- `,
-};
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
new file mode 100644
index 00000000000..5aab25e0348
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -0,0 +1,33 @@
+import Vue from 'vue';
+import PipelinesMediator from './pipeline_details_mediatior';
+import pipelineGraph from './components/graph/graph_component.vue';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const dataset = document.querySelector('.js-pipeline-details-vue').dataset;
+
+ const mediator = new PipelinesMediator({ endpoint: dataset.endpoint });
+
+ mediator.fetchPipeline();
+
+ const pipelineGraphApp = new Vue({
+ el: '#js-pipeline-graph-vue',
+ data() {
+ return {
+ mediator,
+ };
+ },
+ components: {
+ pipelineGraph,
+ },
+ render(createElement) {
+ return createElement('pipeline-graph', {
+ props: {
+ isLoading: this.mediator.state.isLoading,
+ pipeline: this.mediator.store.state.pipeline,
+ },
+ });
+ },
+ });
+
+ return pipelineGraphApp;
+});
diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediatior.js b/app/assets/javascripts/pipelines/pipeline_details_mediatior.js
new file mode 100644
index 00000000000..b9a6d5ca5fc
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_details_mediatior.js
@@ -0,0 +1,51 @@
+/* global Flash */
+
+import Visibility from 'visibilityjs';
+import Poll from '../lib/utils/poll';
+import PipelineStore from './stores/pipeline_store';
+import PipelineService from './services/pipeline_service';
+
+export default class pipelinesMediator {
+ constructor(options = {}) {
+ this.options = options;
+ this.store = new PipelineStore();
+ this.service = new PipelineService(options.endpoint);
+
+ this.state = {};
+ this.state.isLoading = false;
+ }
+
+ fetchPipeline() {
+ this.poll = new Poll({
+ resource: this.service,
+ method: 'getPipeline',
+ successCallback: this.successCallback.bind(this),
+ errorCallback: this.errorCallback.bind(this),
+ });
+
+ if (!Visibility.hidden()) {
+ this.state.isLoading = true;
+ this.poll.makeRequest();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ this.poll.restart();
+ } else {
+ this.poll.stop();
+ }
+ });
+ }
+
+ successCallback(response) {
+ const data = response.json();
+
+ this.state.isLoading = false;
+ this.store.storePipeline(data);
+ }
+
+ errorCallback() {
+ this.state.isLoading = false;
+ return new Flash('An error occurred while fetching the pipeline.');
+ }
+}
diff --git a/app/assets/javascripts/pipelines/pipelines.js b/app/assets/javascripts/pipelines/pipelines.js
index 9275b3efeb1..ba06d79102f 100644
--- a/app/assets/javascripts/pipelines/pipelines.js
+++ b/app/assets/javascripts/pipelines/pipelines.js
@@ -1,12 +1,13 @@
import Visibility from 'visibilityjs';
import PipelinesService from './services/pipelines_service';
import eventHub from './event_hub';
-import PipelinesTableComponent from '../vue_shared/components/pipelines_table';
-import TablePaginationComponent from '../vue_shared/components/table_pagination';
-import EmptyState from './components/empty_state.vue';
-import ErrorState from './components/error_state.vue';
-import NavigationTabs from './components/navigation_tabs';
-import NavigationControls from './components/nav_controls';
+import pipelinesTableComponent from '../vue_shared/components/pipelines_table';
+import tablePagination from '../vue_shared/components/table_pagination.vue';
+import emptyState from './components/empty_state.vue';
+import errorState from './components/error_state.vue';
+import navigationTabs from './components/navigation_tabs';
+import navigationControls from './components/nav_controls';
+import loadingIcon from '../vue_shared/components/loading_icon.vue';
import Poll from '../lib/utils/poll';
export default {
@@ -18,12 +19,13 @@ export default {
},
components: {
- 'gl-pagination': TablePaginationComponent,
- 'pipelines-table-component': PipelinesTableComponent,
- 'empty-state': EmptyState,
- 'error-state': ErrorState,
- 'navigation-tabs': NavigationTabs,
- 'navigation-controls': NavigationControls,
+ tablePagination,
+ pipelinesTableComponent,
+ emptyState,
+ errorState,
+ navigationTabs,
+ navigationControls,
+ loadingIcon,
},
data() {
@@ -50,6 +52,7 @@ export default {
hasError: false,
isMakingRequest: false,
updateGraphDropdown: false,
+ hasMadeRequest: false,
};
},
@@ -76,6 +79,7 @@ export default {
shouldRenderEmptyState() {
return !this.isLoading &&
!this.hasError &&
+ this.hasMadeRequest &&
!this.state.pipelines.length &&
(this.scope === 'all' || this.scope === null);
},
@@ -148,6 +152,10 @@ export default {
if (!Visibility.hidden()) {
this.isLoading = true;
poll.makeRequest();
+ } else {
+ // If tab is not visible we need to make the first request so we don't show the empty
+ // state without knowing if there are any pipelines
+ this.fetchPipelines();
}
Visibility.change(() => {
@@ -200,6 +208,7 @@ export default {
this.isLoading = false;
this.updateGraphDropdown = true;
+ this.hasMadeRequest = true;
},
errorCallback() {
@@ -244,13 +253,11 @@ export default {
<div class="content-list pipelines">
- <div
- class="realtime-loading"
- v-if="isLoading">
- <i
- class="fa fa-spinner fa-spin"
- aria-hidden="true" />
- </div>
+ <loading-icon
+ label="Loading Pipelines"
+ size="3"
+ v-if="isLoading"
+ />
<empty-state
v-if="shouldRenderEmptyState"
@@ -275,10 +282,11 @@ export default {
/>
</div>
- <gl-pagination
+ <table-pagination
v-if="shouldRenderPagination"
:change="change"
- :pageInfo="state.pageInfo"/>
+ :pageInfo="state.pageInfo"
+ />
</div>
</div>
`,
diff --git a/app/assets/javascripts/pipelines/services/pipeline_service.js b/app/assets/javascripts/pipelines/services/pipeline_service.js
new file mode 100644
index 00000000000..f1cc60c1ee0
--- /dev/null
+++ b/app/assets/javascripts/pipelines/services/pipeline_service.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+Vue.use(VueResource);
+
+export default class PipelineService {
+ constructor(endpoint) {
+ this.pipeline = Vue.resource(endpoint);
+ }
+
+ getPipeline() {
+ return this.pipeline.get();
+ }
+}
diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js
index 255cd513490..b21f84b4545 100644
--- a/app/assets/javascripts/pipelines/services/pipelines_service.js
+++ b/app/assets/javascripts/pipelines/services/pipelines_service.js
@@ -40,6 +40,6 @@ export default class PipelinesService {
* @return {Promise}
*/
postAction(endpoint) {
- return Vue.http.post(endpoint, {}, { emulateJSON: true });
+ return Vue.http.post(`${endpoint}.json`);
}
}
diff --git a/app/assets/javascripts/pipelines/stores/pipeline_store.js b/app/assets/javascripts/pipelines/stores/pipeline_store.js
new file mode 100644
index 00000000000..052e34a8aef
--- /dev/null
+++ b/app/assets/javascripts/pipelines/stores/pipeline_store.js
@@ -0,0 +1,11 @@
+export default class PipelineStore {
+ constructor() {
+ this.state = {};
+
+ this.state.pipeline = {};
+ }
+
+ storePipeline(pipeline = {}) {
+ this.state.pipeline = pipeline;
+ }
+}
diff --git a/app/assets/javascripts/preview_markdown.js b/app/assets/javascripts/preview_markdown.js
index 07eea98e737..4a3df2fd465 100644
--- a/app/assets/javascripts/preview_markdown.js
+++ b/app/assets/javascripts/preview_markdown.js
@@ -2,8 +2,9 @@
// MarkdownPreview
//
-// Handles toggling the "Write" and "Preview" tab clicks, rendering the preview,
-// and showing a warning when more than `x` users are referenced.
+// Handles toggling the "Write" and "Preview" tab clicks, rendering the preview
+// (including the explanation of slash commands), and showing a warning when
+// more than `x` users are referenced.
//
(function () {
var lastTextareaPreviewed;
@@ -17,32 +18,45 @@
// Minimum number of users referenced before triggering a warning
MarkdownPreview.prototype.referenceThreshold = 10;
+ MarkdownPreview.prototype.emptyMessage = 'Nothing to preview.';
MarkdownPreview.prototype.ajaxCache = {};
MarkdownPreview.prototype.showPreview = function ($form) {
var mdText;
var preview = $form.find('.js-md-preview');
+ var url = preview.data('url');
if (preview.hasClass('md-preview-loading')) {
return;
}
mdText = $form.find('textarea.markdown-area').val();
if (mdText.trim().length === 0) {
- preview.text('Nothing to preview.');
+ preview.text(this.emptyMessage);
this.hideReferencedUsers($form);
} else {
preview.addClass('md-preview-loading').text('Loading...');
- this.fetchMarkdownPreview(mdText, (function (response) {
- preview.removeClass('md-preview-loading').html(response.body);
+ this.fetchMarkdownPreview(mdText, url, (function (response) {
+ var body;
+ if (response.body.length > 0) {
+ body = response.body;
+ } 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));
}
};
- MarkdownPreview.prototype.fetchMarkdownPreview = function (text, success) {
- if (!window.preview_markdown_path) {
+ MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) {
+ if (!url) {
return;
}
if (text === this.ajaxCache.text) {
@@ -51,7 +65,7 @@
}
$.ajax({
type: 'POST',
- url: window.preview_markdown_path,
+ url: url,
data: {
text: text
},
@@ -83,6 +97,22 @@
}
};
+ MarkdownPreview.prototype.hideReferencedCommands = function ($form) {
+ $form.find('.referenced-commands').hide();
+ };
+
+ MarkdownPreview.prototype.renderReferencedCommands = function (commands, $form) {
+ var referencedCommands;
+ referencedCommands = $form.find('.referenced-commands');
+ if (commands.length > 0) {
+ referencedCommands.html(commands);
+ referencedCommands.show();
+ } else {
+ referencedCommands.html('');
+ referencedCommands.hide();
+ }
+ };
+
return MarkdownPreview;
}());
@@ -137,6 +167,8 @@
$form.find('.md-write-holder').show();
$form.find('textarea.markdown-area').focus();
$form.find('.md-preview-holder').hide();
+
+ markdownPreview.hideReferencedCommands($form);
});
$(document).on('markdown-preview:toggle', function (e, keyboardEvent) {
diff --git a/app/assets/javascripts/profile/profile_bundle.js b/app/assets/javascripts/profile/profile_bundle.js
index 15d32825583..ff35a9bcb83 100644
--- a/app/assets/javascripts/profile/profile_bundle.js
+++ b/app/assets/javascripts/profile/profile_bundle.js
@@ -1,2 +1,2 @@
-require('./gl_crop');
-require('./profile');
+import './gl_crop';
+import './profile';
diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js
index f944fcc5a58..738e710deb9 100644
--- a/app/assets/javascripts/project.js
+++ b/app/assets/javascripts/project.js
@@ -112,7 +112,8 @@ import Cookies from 'js-cookie';
toggleLabel: function(obj, $el) {
return $el.text().trim();
},
- clicked: function(selected, $el, e) {
+ clicked: function(options) {
+ const { e } = options;
e.preventDefault();
if ($('input[name="ref"]').length) {
var $form = $dropdown.closest('form');
diff --git a/app/assets/javascripts/project_edit.js b/app/assets/javascripts/project_edit.js
new file mode 100644
index 00000000000..d7d284b6c86
--- /dev/null
+++ b/app/assets/javascripts/project_edit.js
@@ -0,0 +1,9 @@
+export default function setupProjectEdit() {
+ const $transferForm = $('.js-project-transfer-form');
+ const $selectNamespace = $transferForm.find('.select2');
+
+ $selectNamespace.on('change', () => {
+ $transferForm.find(':submit').prop('disabled', !$selectNamespace.val());
+ });
+ $selectNamespace.trigger('change');
+}
diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js
index e01668eabef..11f9754780d 100644
--- a/app/assets/javascripts/project_find_file.js
+++ b/app/assets/javascripts/project_find_file.js
@@ -2,18 +2,16 @@
/* global fuzzaldrinPlus */
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.ProjectFindFile = (function() {
var highlighter;
function ProjectFindFile(element1, options) {
this.element = element1;
this.options = options;
- this.goToBlob = bind(this.goToBlob, this);
- this.goToTree = bind(this.goToTree, this);
- this.selectRowDown = bind(this.selectRowDown, this);
- this.selectRowUp = bind(this.selectRowUp, this);
+ this.goToBlob = this.goToBlob.bind(this);
+ this.goToTree = this.goToTree.bind(this);
+ this.selectRowDown = this.selectRowDown.bind(this);
+ this.selectRowUp = this.selectRowUp.bind(this);
this.filePaths = {};
this.inputElement = this.element.find(".file-finder-input");
// init event
diff --git a/app/assets/javascripts/project_new.js b/app/assets/javascripts/project_new.js
index e9927c1bf51..04b381fe0e0 100644
--- a/app/assets/javascripts/project_new.js
+++ b/app/assets/javascripts/project_new.js
@@ -1,11 +1,9 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-unused-vars, one-var, no-underscore-dangle, prefer-template, no-else-return, prefer-arrow-callback, max-len */
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.ProjectNew = (function() {
function ProjectNew() {
- this.toggleSettings = bind(this.toggleSettings, this);
+ this.toggleSettings = this.toggleSettings.bind(this);
this.$selects = $('.features select');
this.$repoSelects = this.$selects.filter('.js-repo-select');
diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js
index 3c1c1e7dceb..9896b88d487 100644
--- a/app/assets/javascripts/project_select.js
+++ b/app/assets/javascripts/project_select.js
@@ -1,5 +1,5 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, no-var, comma-dangle, object-shorthand, one-var, one-var-declaration-per-line, no-else-return, quotes, max-len */
-/* global Api */
+import Api from './api';
(function() {
this.ProjectSelect = (function() {
@@ -51,6 +51,9 @@
this.groupId = $(select).data('group-id');
this.includeGroups = $(select).data('include-groups');
this.orderBy = $(select).data('order-by') || 'id';
+ this.withIssuesEnabled = $(select).data('with-issues-enabled');
+ this.withMergeRequestsEnabled = $(select).data('with-merge-requests-enabled');
+
placeholder = "Search for project";
if (this.includeGroups) {
placeholder += " or group";
@@ -84,7 +87,11 @@
if (_this.groupId) {
return Api.groupProjects(_this.groupId, query.term, projectsCallback);
} else {
- return Api.projects(query.term, { order_by: _this.orderBy }, projectsCallback);
+ return Api.projects(query.term, {
+ order_by: _this.orderBy,
+ with_issues_enabled: _this.withIssuesEnabled,
+ with_merge_requests_enabled: _this.withMergeRequestsEnabled
+ }, projectsCallback);
}
};
})(this),
diff --git a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
index e7fff57ff45..42993a252c3 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
@@ -19,7 +19,9 @@
return 'Select';
}
},
- clicked(item, $el, e) {
+ clicked(opts) {
+ const { e } = opts;
+
e.preventDefault();
onSelect();
}
diff --git a/app/assets/javascripts/protected_branches/protected_branch_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_dropdown.js
index 1d4bb8a13d6..bc6110fcd4e 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_dropdown.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_dropdown.js
@@ -35,7 +35,8 @@ class ProtectedBranchDropdown {
return _.escape(protectedBranch.id);
},
onFilter: this.toggleCreateNewButton.bind(this),
- clicked: (item, $el, e) => {
+ clicked: (options) => {
+ const { $el, e } = options;
e.preventDefault();
this.onSelect();
}
diff --git a/app/assets/javascripts/protected_branches/protected_branches_bundle.js b/app/assets/javascripts/protected_branches/protected_branches_bundle.js
index 849c1e31623..874d70a1431 100644
--- a/app/assets/javascripts/protected_branches/protected_branches_bundle.js
+++ b/app/assets/javascripts/protected_branches/protected_branches_bundle.js
@@ -1,5 +1,5 @@
-require('./protected_branch_access_dropdown');
-require('./protected_branch_create');
-require('./protected_branch_dropdown');
-require('./protected_branch_edit');
-require('./protected_branch_edit_list');
+import './protected_branch_access_dropdown';
+import './protected_branch_create';
+import './protected_branch_dropdown';
+import './protected_branch_edit';
+import './protected_branch_edit_list';
diff --git a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
index fff83f3af3b..d4c9a91a74a 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
@@ -17,8 +17,8 @@ export default class ProtectedTagAccessDropdown {
}
return 'Select';
},
- clicked(item, $el, e) {
- e.preventDefault();
+ clicked(options) {
+ options.e.preventDefault();
onSelect();
},
});
diff --git a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
index 5ff4e443262..068e9698e1d 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
@@ -39,8 +39,8 @@ export default class ProtectedTagDropdown {
return _.escape(protectedTag.id);
},
onFilter: this.toggleCreateNewButton.bind(this),
- clicked: (item, $el, e) => {
- e.preventDefault();
+ clicked: (options) => {
+ options.e.preventDefault();
this.onSelect();
},
});
diff --git a/app/assets/javascripts/raven/index.js b/app/assets/javascripts/raven/index.js
new file mode 100644
index 00000000000..edc2293915f
--- /dev/null
+++ b/app/assets/javascripts/raven/index.js
@@ -0,0 +1,20 @@
+import RavenConfig from './raven_config';
+
+const index = function index() {
+ RavenConfig.init({
+ sentryDsn: gon.sentry_dsn,
+ currentUserId: gon.current_user_id,
+ whitelistUrls: [gon.gitlab_url],
+ isProduction: process.env.NODE_ENV,
+ release: gon.revision,
+ tags: {
+ revision: gon.revision,
+ },
+ });
+
+ return RavenConfig;
+};
+
+index();
+
+export default index;
diff --git a/app/assets/javascripts/raven/raven_config.js b/app/assets/javascripts/raven/raven_config.js
new file mode 100644
index 00000000000..ae54fa5f1a9
--- /dev/null
+++ b/app/assets/javascripts/raven/raven_config.js
@@ -0,0 +1,103 @@
+import Raven from 'raven-js';
+import $ from 'jquery';
+
+const IGNORE_ERRORS = [
+ // Random plugins/extensions
+ 'top.GLOBALS',
+ // See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error. html
+ 'originalCreateNotification',
+ 'canvas.contentDocument',
+ 'MyApp_RemoveAllHighlights',
+ 'http://tt.epicplay.com',
+ 'Can\'t find variable: ZiteReader',
+ 'jigsaw is not defined',
+ 'ComboSearch is not defined',
+ 'http://loading.retry.widdit.com/',
+ 'atomicFindClose',
+ // Facebook borked
+ 'fb_xd_fragment',
+ // ISP "optimizing" proxy - `Cache-Control: no-transform` seems to
+ // reduce this. (thanks @acdha)
+ // See http://stackoverflow.com/questions/4113268
+ 'bmi_SafeAddOnload',
+ 'EBCallBackMessageReceived',
+ // See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx
+ 'conduitPage',
+];
+
+const IGNORE_URLS = [
+ // Facebook flakiness
+ /graph\.facebook\.com/i,
+ // Facebook blocked
+ /connect\.facebook\.net\/en_US\/all\.js/i,
+ // Woopra flakiness
+ /eatdifferent\.com\.woopra-ns\.com/i,
+ /static\.woopra\.com\/js\/woopra\.js/i,
+ // Chrome extensions
+ /extensions\//i,
+ /^chrome:\/\//i,
+ // Other plugins
+ /127\.0\.0\.1:4001\/isrunning/i, // Cacaoweb
+ /webappstoolbarba\.texthelp\.com\//i,
+ /metrics\.itunes\.apple\.com\.edgesuite\.net\//i,
+];
+
+const SAMPLE_RATE = 95;
+
+const RavenConfig = {
+ IGNORE_ERRORS,
+ IGNORE_URLS,
+ SAMPLE_RATE,
+ init(options = {}) {
+ this.options = options;
+
+ this.configure();
+ this.bindRavenErrors();
+ if (this.options.currentUserId) this.setUser();
+ },
+
+ configure() {
+ Raven.config(this.options.sentryDsn, {
+ release: this.options.release,
+ tags: this.options.tags,
+ whitelistUrls: this.options.whitelistUrls,
+ environment: this.options.isProduction ? 'production' : 'development',
+ ignoreErrors: this.IGNORE_ERRORS,
+ ignoreUrls: this.IGNORE_URLS,
+ shouldSendCallback: this.shouldSendSample.bind(this),
+ }).install();
+ },
+
+ setUser() {
+ Raven.setUserContext({
+ id: this.options.currentUserId,
+ });
+ },
+
+ bindRavenErrors() {
+ $(document).on('ajaxError.raven', this.handleRavenErrors);
+ },
+
+ handleRavenErrors(event, req, config, err) {
+ const error = err || req.statusText;
+ const responseText = req.responseText || 'Unknown response text';
+
+ Raven.captureMessage(error, {
+ extra: {
+ type: config.type,
+ url: config.url,
+ data: config.data,
+ status: req.status,
+ response: responseText,
+ error,
+ event,
+ },
+ });
+ },
+
+ shouldSendSample() {
+ return Math.random() * 100 <= this.SAMPLE_RATE;
+ },
+};
+
+export default RavenConfig;
diff --git a/app/assets/javascripts/ref_select_dropdown.js b/app/assets/javascripts/ref_select_dropdown.js
new file mode 100644
index 00000000000..215cd6fbdfd
--- /dev/null
+++ b/app/assets/javascripts/ref_select_dropdown.js
@@ -0,0 +1,46 @@
+class RefSelectDropdown {
+ constructor($dropdownButton, availableRefs) {
+ $dropdownButton.glDropdown({
+ data: availableRefs,
+ filterable: true,
+ filterByText: true,
+ remote: false,
+ fieldName: $dropdownButton.data('field-name'),
+ filterInput: 'input[type="search"]',
+ selectable: true,
+ isSelectable(branch, $el) {
+ return !$el.hasClass('is-active');
+ },
+ text(branch) {
+ return branch;
+ },
+ id(branch) {
+ return branch;
+ },
+ toggleLabel(branch) {
+ return branch;
+ },
+ });
+
+ const $dropdownContainer = $dropdownButton.closest('.dropdown');
+ const $fieldInput = $(`input[name="${$dropdownButton.data('field-name')}"]`, $dropdownContainer);
+ const $filterInput = $('input[type="search"]', $dropdownContainer);
+
+ $filterInput.on('keyup', (e) => {
+ const keyCode = e.keyCode || e.which;
+ if (keyCode !== 13) return;
+
+ const ref = $filterInput.val().trim();
+ if (ref === '') {
+ return;
+ }
+
+ $fieldInput.val(ref);
+ $('.dropdown-toggle-text', $dropdownButton).text(ref);
+
+ $dropdownContainer.removeClass('open');
+ });
+ }
+}
+
+export default RefSelectDropdown;
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index a9b3de281e1..b71c3097706 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -3,11 +3,9 @@
import Cookies from 'js-cookie';
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.Sidebar = (function() {
function Sidebar(currentUser) {
- this.toggleTodo = bind(this.toggleTodo, this);
+ this.toggleTodo = this.toggleTodo.bind(this);
this.sidebar = $('aside');
this.removeListeners();
this.addEventListeners();
diff --git a/app/assets/javascripts/search.js b/app/assets/javascripts/search.js
index 15f5963353a..05caf177aec 100644
--- a/app/assets/javascripts/search.js
+++ b/app/assets/javascripts/search.js
@@ -1,5 +1,6 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, one-var-declaration-per-line, object-shorthand, prefer-arrow-callback, comma-dangle, prefer-template, quotes, no-else-return, max-len */
-/* global Api */
+/* global Flash */
+import Api from './api';
(function() {
this.Search = (function() {
@@ -7,6 +8,7 @@
var $groupDropdown, $projectDropdown;
$groupDropdown = $('.js-search-group-dropdown');
$projectDropdown = $('.js-search-project-dropdown');
+ this.groupId = $groupDropdown.data('group-id');
this.eventListeners();
$groupDropdown.glDropdown({
selectable: true,
@@ -46,14 +48,18 @@
search: {
fields: ['name']
},
- data: function(term, callback) {
- return Api.projects(term, { order_by: 'id' }, function(data) {
- data.unshift({
- name_with_namespace: 'Any'
- });
- data.splice(1, 0, 'divider');
- return callback(data);
- });
+ data: (term, callback) => {
+ this.getProjectsData(term)
+ .then((data) => {
+ data.unshift({
+ name_with_namespace: 'Any'
+ });
+ data.splice(1, 0, 'divider');
+
+ return data;
+ })
+ .then(data => callback(data))
+ .catch(() => new Flash('Error fetching projects'));
},
id: function(obj) {
return obj.id;
@@ -95,6 +101,18 @@
return $('.js-search-input').val('').trigger('keyup').focus();
};
+ Search.prototype.getProjectsData = function(term) {
+ return new Promise((resolve) => {
+ if (this.groupId) {
+ Api.groupProjects(this.groupId, term, resolve);
+ } else {
+ Api.projects(term, {
+ order_by: 'id',
+ }, resolve);
+ }
+ });
+ };
+
return Search;
})();
}).call(window);
diff --git a/app/assets/javascripts/shortcuts.js b/app/assets/javascripts/shortcuts.js
index 85659d7fa39..8ac71797c14 100644
--- a/app/assets/javascripts/shortcuts.js
+++ b/app/assets/javascripts/shortcuts.js
@@ -4,11 +4,9 @@
import findAndFollowLink from './shortcuts_dashboard_navigation';
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.Shortcuts = (function() {
function Shortcuts(skipResetBindings) {
- this.onToggleHelp = bind(this.onToggleHelp, this);
+ this.onToggleHelp = this.onToggleHelp.bind(this);
this.enabledHelp = [];
if (!skipResetBindings) {
Mousetrap.reset();
diff --git a/app/assets/javascripts/shortcuts_blob.js b/app/assets/javascripts/shortcuts_blob.js
index bfe90aef71e..ccbf7c59165 100644
--- a/app/assets/javascripts/shortcuts_blob.js
+++ b/app/assets/javascripts/shortcuts_blob.js
@@ -1,14 +1,14 @@
/* global Mousetrap */
/* global Shortcuts */
-require('./shortcuts');
+import './shortcuts';
const defaults = {
skipResetBindings: false,
fileBlobPermalinkUrl: null,
};
-class ShortcutsBlob extends Shortcuts {
+export default class ShortcutsBlob extends Shortcuts {
constructor(opts) {
const options = Object.assign({}, defaults, opts);
super(options.skipResetBindings);
@@ -25,5 +25,3 @@ class ShortcutsBlob extends Shortcuts {
}
}
}
-
-module.exports = ShortcutsBlob;
diff --git a/app/assets/javascripts/shortcuts_find_file.js b/app/assets/javascripts/shortcuts_find_file.js
index a27ac264a5c..b18b6139b35 100644
--- a/app/assets/javascripts/shortcuts_find_file.js
+++ b/app/assets/javascripts/shortcuts_find_file.js
@@ -2,7 +2,7 @@
/* global Mousetrap */
/* global ShortcutsNavigation */
-require('./shortcuts_navigation');
+import './shortcuts_navigation';
(function() {
var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js
index fe58e98cee5..51448252c0f 100644
--- a/app/assets/javascripts/shortcuts_issuable.js
+++ b/app/assets/javascripts/shortcuts_issuable.js
@@ -3,8 +3,8 @@
/* global ShortcutsNavigation */
/* global sidebar */
-require('mousetrap');
-require('./shortcuts_navigation');
+import 'mousetrap';
+import './shortcuts_navigation';
(function() {
var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
@@ -38,7 +38,7 @@ require('./shortcuts_navigation');
}
ShortcutsIssuable.prototype.replyWithSelectedText = function() {
- var quote, documentFragment, selected, separator;
+ var quote, documentFragment, el, selected, separator;
var replyField = $('.js-main-target-form #note_note');
documentFragment = window.gl.utils.getSelectedFragment();
@@ -47,10 +47,8 @@ require('./shortcuts_navigation');
return;
}
- // If the documentFragment contains more than just Markdown, don't copy as GFM.
- if (documentFragment.querySelector('.md, .wiki')) return;
-
- selected = window.gl.CopyAsGFM.nodeToGFM(documentFragment);
+ el = window.gl.CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true));
+ selected = window.gl.CopyAsGFM.nodeToGFM(el);
if (selected.trim() === "") {
return;
@@ -79,7 +77,9 @@ require('./shortcuts_navigation');
ShortcutsIssuable.prototype.editIssue = function() {
var $editBtn;
$editBtn = $('.issuable-edit');
- return gl.utils.visitUrl($editBtn.attr('href'));
+ // Need to click the element as on issues, editing is inline
+ // on merge request, editing is on a different page
+ $editBtn.get(0).click();
};
ShortcutsIssuable.prototype.openSidebarDropdown = function(name) {
diff --git a/app/assets/javascripts/shortcuts_navigation.js b/app/assets/javascripts/shortcuts_navigation.js
index c74ab0afd0c..55bae0c08a1 100644
--- a/app/assets/javascripts/shortcuts_navigation.js
+++ b/app/assets/javascripts/shortcuts_navigation.js
@@ -1,9 +1,9 @@
/* eslint-disable func-names, space-before-function-paren, max-len, no-var, one-var, no-restricted-syntax, vars-on-top, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, wrap-iife, prefer-arrow-callback, consistent-return, no-return-assign */
/* global Mousetrap */
/* global Shortcuts */
-import findAndFollowLink from './shortcuts_dashboard_navigation';
-require('./shortcuts');
+import findAndFollowLink from './shortcuts_dashboard_navigation';
+import './shortcuts';
(function() {
var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
diff --git a/app/assets/javascripts/shortcuts_network.js b/app/assets/javascripts/shortcuts_network.js
index 4c2bf8bf001..cc44082efa9 100644
--- a/app/assets/javascripts/shortcuts_network.js
+++ b/app/assets/javascripts/shortcuts_network.js
@@ -2,7 +2,7 @@
/* global Mousetrap */
/* global ShortcutsNavigation */
-require('./shortcuts_navigation');
+import './shortcuts_navigation';
(function() {
var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.js b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js
new file mode 100644
index 00000000000..a9ad3708514
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js
@@ -0,0 +1,41 @@
+export default {
+ name: 'AssigneeTitle',
+ props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ numberOfAssignees: {
+ type: Number,
+ required: true,
+ },
+ editable: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ assigneeTitle() {
+ const assignees = this.numberOfAssignees;
+ return assignees > 1 ? `${assignees} Assignees` : 'Assignee';
+ },
+ },
+ template: `
+ <div class="title hide-collapsed">
+ {{assigneeTitle}}
+ <i
+ v-if="loading"
+ aria-hidden="true"
+ class="fa fa-spinner fa-spin block-loading"
+ />
+ <a
+ v-if="editable"
+ class="edit-link pull-right"
+ href="#"
+ >
+ Edit
+ </a>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.js b/app/assets/javascripts/sidebar/components/assignees/assignees.js
new file mode 100644
index 00000000000..7e5feac622c
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees.js
@@ -0,0 +1,224 @@
+export default {
+ name: 'Assignees',
+ data() {
+ return {
+ defaultRenderCount: 5,
+ defaultMaxCounter: 99,
+ showLess: true,
+ };
+ },
+ props: {
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ users: {
+ type: Array,
+ required: true,
+ },
+ editable: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ firstUser() {
+ return this.users[0];
+ },
+ hasMoreThanTwoAssignees() {
+ return this.users.length > 2;
+ },
+ hasMoreThanOneAssignee() {
+ return this.users.length > 1;
+ },
+ hasAssignees() {
+ return this.users.length > 0;
+ },
+ hasNoUsers() {
+ return !this.users.length;
+ },
+ hasOneUser() {
+ return this.users.length === 1;
+ },
+ renderShowMoreSection() {
+ return this.users.length > this.defaultRenderCount;
+ },
+ numberOfHiddenAssignees() {
+ return this.users.length - this.defaultRenderCount;
+ },
+ isHiddenAssignees() {
+ return this.numberOfHiddenAssignees > 0;
+ },
+ hiddenAssigneesLabel() {
+ return `+ ${this.numberOfHiddenAssignees} more`;
+ },
+ collapsedTooltipTitle() {
+ const maxRender = Math.min(this.defaultRenderCount, this.users.length);
+ const renderUsers = this.users.slice(0, maxRender);
+ const names = renderUsers.map(u => u.name);
+
+ if (this.users.length > maxRender) {
+ names.push(`+ ${this.users.length - maxRender} more`);
+ }
+
+ return names.join(', ');
+ },
+ sidebarAvatarCounter() {
+ let counter = `+${this.users.length - 1}`;
+
+ if (this.users.length > this.defaultMaxCounter) {
+ counter = `${this.defaultMaxCounter}+`;
+ }
+
+ return counter;
+ },
+ },
+ methods: {
+ assignSelf() {
+ this.$emit('assign-self');
+ },
+ toggleShowLess() {
+ this.showLess = !this.showLess;
+ },
+ renderAssignee(index) {
+ return !this.showLess || (index < this.defaultRenderCount && this.showLess);
+ },
+ avatarUrl(user) {
+ return user.avatar || user.avatar_url;
+ },
+ assigneeUrl(user) {
+ return `${this.rootPath}${user.username}`;
+ },
+ assigneeAlt(user) {
+ return `${user.name}'s avatar`;
+ },
+ assigneeUsername(user) {
+ return `@${user.username}`;
+ },
+ shouldRenderCollapsedAssignee(index) {
+ const firstTwo = this.users.length <= 2 && index <= 2;
+
+ return index === 0 || firstTwo;
+ },
+ },
+ template: `
+ <div>
+ <div
+ class="sidebar-collapsed-icon sidebar-collapsed-user"
+ :class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }"
+ data-container="body"
+ data-placement="left"
+ :title="collapsedTooltipTitle"
+ >
+ <i
+ v-if="hasNoUsers"
+ aria-label="No Assignee"
+ class="fa fa-user"
+ />
+ <button
+ type="button"
+ class="btn-link"
+ v-for="(user, index) in users"
+ v-if="shouldRenderCollapsedAssignee(index)"
+ >
+ <img
+ width="24"
+ class="avatar avatar-inline s24"
+ :alt="assigneeAlt(user)"
+ :src="avatarUrl(user)"
+ />
+ <span class="author">
+ {{ user.name }}
+ </span>
+ </button>
+ <button
+ v-if="hasMoreThanTwoAssignees"
+ class="btn-link"
+ type="button"
+ >
+ <span
+ class="avatar-counter sidebar-avatar-counter"
+ >
+ {{ sidebarAvatarCounter }}
+ </span>
+ </button>
+ </div>
+ <div class="value hide-collapsed">
+ <template v-if="hasNoUsers">
+ <span class="assign-yourself no-value">
+ No assignee
+ <template v-if="editable">
+ -
+ <button
+ type="button"
+ class="btn-link"
+ @click="assignSelf"
+ >
+ assign yourself
+ </button>
+ </template>
+ </span>
+ </template>
+ <template v-else-if="hasOneUser">
+ <a
+ class="author_link bold"
+ :href="assigneeUrl(firstUser)"
+ >
+ <img
+ width="32"
+ class="avatar avatar-inline s32"
+ :alt="assigneeAlt(firstUser)"
+ :src="avatarUrl(firstUser)"
+ />
+ <span class="author">
+ {{ firstUser.name }}
+ </span>
+ <span class="username">
+ {{ assigneeUsername(firstUser) }}
+ </span>
+ </a>
+ </template>
+ <template v-else>
+ <div class="user-list">
+ <div
+ class="user-item"
+ v-for="(user, index) in users"
+ v-if="renderAssignee(index)"
+ >
+ <a
+ class="user-link has-tooltip"
+ data-placement="bottom"
+ :href="assigneeUrl(user)"
+ :data-title="user.name"
+ >
+ <img
+ width="32"
+ class="avatar avatar-inline s32"
+ :alt="assigneeAlt(user)"
+ :src="avatarUrl(user)"
+ />
+ </a>
+ </div>
+ </div>
+ <div
+ v-if="renderShowMoreSection"
+ class="user-list-more"
+ >
+ <button
+ type="button"
+ class="btn-link"
+ @click="toggleShowLess"
+ >
+ <template v-if="showLess">
+ {{ hiddenAssigneesLabel }}
+ </template>
+ <template v-else>
+ - show less
+ </template>
+ </button>
+ </div>
+ </template>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js
new file mode 100644
index 00000000000..da4abf0b68f
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js
@@ -0,0 +1,85 @@
+/* global Flash */
+
+import AssigneeTitle from './assignee_title';
+import Assignees from './assignees';
+
+import Store from '../../stores/sidebar_store';
+import Mediator from '../../sidebar_mediator';
+
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'SidebarAssignees',
+ data() {
+ return {
+ mediator: new Mediator(),
+ store: new Store(),
+ loading: false,
+ field: '',
+ };
+ },
+ components: {
+ 'assignee-title': AssigneeTitle,
+ assignees: Assignees,
+ },
+ methods: {
+ assignSelf() {
+ // Notify gl dropdown that we are now assigning to current user
+ this.$el.parentElement.dispatchEvent(new Event('assignYourself'));
+
+ this.mediator.assignYourself();
+ this.saveAssignees();
+ },
+ saveAssignees() {
+ this.loading = true;
+
+ function setLoadingFalse() {
+ this.loading = false;
+ }
+
+ this.mediator.saveAssignees(this.field)
+ .then(setLoadingFalse.bind(this))
+ .catch(() => {
+ setLoadingFalse();
+ return new Flash('Error occurred when saving assignees');
+ });
+ },
+ },
+ created() {
+ this.removeAssignee = this.store.removeAssignee.bind(this.store);
+ this.addAssignee = this.store.addAssignee.bind(this.store);
+ this.removeAllAssignees = this.store.removeAllAssignees.bind(this.store);
+
+ // Get events from glDropdown
+ eventHub.$on('sidebar.removeAssignee', this.removeAssignee);
+ eventHub.$on('sidebar.addAssignee', this.addAssignee);
+ eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees);
+ eventHub.$on('sidebar.saveAssignees', this.saveAssignees);
+ },
+ beforeDestroy() {
+ eventHub.$off('sidebar.removeAssignee', this.removeAssignee);
+ eventHub.$off('sidebar.addAssignee', this.addAssignee);
+ eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees);
+ eventHub.$off('sidebar.saveAssignees', this.saveAssignees);
+ },
+ beforeMount() {
+ this.field = this.$el.dataset.field;
+ },
+ template: `
+ <div>
+ <assignee-title
+ :number-of-assignees="store.assignees.length"
+ :loading="loading || store.isFetching.assignees"
+ :editable="store.editable"
+ />
+ <assignees
+ v-if="!store.isFetching.assignees"
+ class="value"
+ :root-path="store.rootPath"
+ :users="store.assignees"
+ :editable="store.editable"
+ @assign-self="assignSelf"
+ />
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js
new file mode 100644
index 00000000000..0da265053bd
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js
@@ -0,0 +1,97 @@
+import stopwatchSvg from 'icons/_icon_stopwatch.svg';
+
+import '../../../lib/utils/pretty_time';
+
+export default {
+ name: 'time-tracking-collapsed-state',
+ props: {
+ showComparisonState: {
+ type: Boolean,
+ required: true,
+ },
+ showSpentOnlyState: {
+ type: Boolean,
+ required: true,
+ },
+ showEstimateOnlyState: {
+ type: Boolean,
+ required: true,
+ },
+ showNoTimeTrackingState: {
+ type: Boolean,
+ required: true,
+ },
+ timeSpentHumanReadable: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ timeEstimateHumanReadable: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ timeSpent() {
+ return this.abbreviateTime(this.timeSpentHumanReadable);
+ },
+ timeEstimate() {
+ return this.abbreviateTime(this.timeEstimateHumanReadable);
+ },
+ divClass() {
+ if (this.showComparisonState) {
+ return 'compare';
+ } else if (this.showEstimateOnlyState) {
+ return 'estimate-only';
+ } else if (this.showSpentOnlyState) {
+ return 'spend-only';
+ } else if (this.showNoTimeTrackingState) {
+ return 'no-tracking';
+ }
+
+ return '';
+ },
+ spanClass() {
+ if (this.showComparisonState) {
+ return '';
+ } else if (this.showEstimateOnlyState || this.showSpentOnlyState) {
+ return 'bold';
+ } else if (this.showNoTimeTrackingState) {
+ return 'no-value';
+ }
+
+ return '';
+ },
+ text() {
+ if (this.showComparisonState) {
+ return `${this.timeSpent} / ${this.timeEstimate}`;
+ } else if (this.showEstimateOnlyState) {
+ return `-- / ${this.timeEstimate}`;
+ } else if (this.showSpentOnlyState) {
+ return `${this.timeSpent} / --`;
+ } else if (this.showNoTimeTrackingState) {
+ return 'None';
+ }
+
+ return '';
+ },
+ },
+ methods: {
+ abbreviateTime(timeStr) {
+ return gl.utils.prettyTime.abbreviateTime(timeStr);
+ },
+ },
+ template: `
+ <div class="sidebar-collapsed-icon">
+ ${stopwatchSvg}
+ <div class="time-tracking-collapsed-summary">
+ <div :class="divClass">
+ <span :class="spanClass">
+ {{ text }}
+ </span>
+ </div>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js
new file mode 100644
index 00000000000..40f5c89c5bb
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js
@@ -0,0 +1,98 @@
+import '../../../lib/utils/pretty_time';
+
+const prettyTime = gl.utils.prettyTime;
+
+export default {
+ name: 'time-tracking-comparison-pane',
+ props: {
+ timeSpent: {
+ type: Number,
+ required: true,
+ },
+ timeEstimate: {
+ type: Number,
+ required: true,
+ },
+ timeSpentHumanReadable: {
+ type: String,
+ required: true,
+ },
+ timeEstimateHumanReadable: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ parsedRemaining() {
+ const diffSeconds = this.timeEstimate - this.timeSpent;
+ return prettyTime.parseSeconds(diffSeconds);
+ },
+ timeRemainingHumanReadable() {
+ return prettyTime.stringifyTime(this.parsedRemaining);
+ },
+ timeRemainingTooltip() {
+ const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:';
+ return `${prefix} ${this.timeRemainingHumanReadable}`;
+ },
+ /* Diff values for comparison meter */
+ timeRemainingMinutes() {
+ return this.timeEstimate - this.timeSpent;
+ },
+ timeRemainingPercent() {
+ return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`;
+ },
+ timeRemainingStatusClass() {
+ return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate';
+ },
+ /* Parsed time values */
+ parsedEstimate() {
+ return prettyTime.parseSeconds(this.timeEstimate);
+ },
+ parsedSpent() {
+ return prettyTime.parseSeconds(this.timeSpent);
+ },
+ },
+ template: `
+ <div class="time-tracking-comparison-pane">
+ <div
+ class="compare-meter"
+ data-toggle="tooltip"
+ data-placement="top"
+ role="timeRemainingDisplay"
+ :aria-valuenow="timeRemainingTooltip"
+ :title="timeRemainingTooltip"
+ :data-original-title="timeRemainingTooltip"
+ :class="timeRemainingStatusClass"
+ >
+ <div
+ class="meter-container"
+ role="timeSpentPercent"
+ :aria-valuenow="timeRemainingPercent"
+ >
+ <div
+ :style="{ width: timeRemainingPercent }"
+ class="meter-fill"
+ />
+ </div>
+ <div class="compare-display-container">
+ <div class="compare-display pull-left">
+ <span class="compare-label">
+ Spent
+ </span>
+ <span class="compare-value spent">
+ {{ timeSpentHumanReadable }}
+ </span>
+ </div>
+ <div class="compare-display estimated pull-right">
+ <span class="compare-label">
+ Est
+ </span>
+ <span class="compare-value">
+ {{ timeEstimateHumanReadable }}
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js
new file mode 100644
index 00000000000..ad1b9179db0
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js
@@ -0,0 +1,17 @@
+export default {
+ name: 'time-tracking-estimate-only-pane',
+ props: {
+ timeEstimateHumanReadable: {
+ type: String,
+ required: true,
+ },
+ },
+ template: `
+ <div class="time-tracking-estimate-only-pane">
+ <span class="bold">
+ Estimated:
+ </span>
+ {{ timeEstimateHumanReadable }}
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.js b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js
new file mode 100644
index 00000000000..b2a77462fe0
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js
@@ -0,0 +1,44 @@
+export default {
+ name: 'time-tracking-help-state',
+ props: {
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ href() {
+ return `${this.rootPath}help/workflow/time_tracking.md`;
+ },
+ },
+ template: `
+ <div class="time-tracking-help-state">
+ <div class="time-tracking-info">
+ <h4>
+ Track time with slash commands
+ </h4>
+ <p>
+ Slash commands can be used in the issues description and comment boxes.
+ </p>
+ <p>
+ <code>
+ /estimate
+ </code>
+ will update the estimated time with the latest command.
+ </p>
+ <p>
+ <code>
+ /spend
+ </code>
+ will update the sum of the time spent.
+ </p>
+ <a
+ class="btn btn-default learn-more-button"
+ :href="href"
+ >
+ Learn more
+ </a>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js
new file mode 100644
index 00000000000..d1dd1dcdd27
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js
@@ -0,0 +1,10 @@
+export default {
+ name: 'time-tracking-no-tracking-pane',
+ template: `
+ <div class="time-tracking-no-tracking-pane">
+ <span class="no-value">
+ No estimate or time spent
+ </span>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js
new file mode 100644
index 00000000000..244b67b3ad9
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js
@@ -0,0 +1,51 @@
+import '~/smart_interval';
+
+import timeTracker from './time_tracker';
+
+import Store from '../../stores/sidebar_store';
+import Mediator from '../../sidebar_mediator';
+
+export default {
+ data() {
+ return {
+ mediator: new Mediator(),
+ store: new Store(),
+ };
+ },
+ components: {
+ 'issuable-time-tracker': timeTracker,
+ },
+ methods: {
+ listenForSlashCommands() {
+ $(document).on('ajax:success', '.gfm-form', this.slashCommandListened);
+ },
+ slashCommandListened(e, data) {
+ const subscribedCommands = ['spend_time', 'time_estimate'];
+ let changedCommands;
+ if (data !== undefined) {
+ changedCommands = data.commands_changes
+ ? Object.keys(data.commands_changes)
+ : [];
+ } else {
+ changedCommands = [];
+ }
+ if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) {
+ this.mediator.fetch();
+ }
+ },
+ },
+ mounted() {
+ this.listenForSlashCommands();
+ },
+ template: `
+ <div class="block">
+ <issuable-time-tracker
+ :time_estimate="store.timeEstimate"
+ :time_spent="store.totalTimeSpent"
+ :human_time_estimate="store.humanTimeEstimate"
+ :human_time_spent="store.humanTotalTimeSpent"
+ :rootPath="store.rootPath"
+ />
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js
new file mode 100644
index 00000000000..bf987562647
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js
@@ -0,0 +1,15 @@
+export default {
+ name: 'time-tracking-spent-only-pane',
+ props: {
+ timeSpentHumanReadable: {
+ type: String,
+ required: true,
+ },
+ },
+ template: `
+ <div class="time-tracking-spend-only-pane">
+ <span class="bold">Spent:</span>
+ {{ timeSpentHumanReadable }}
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js
new file mode 100644
index 00000000000..ed0d71a4f79
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js
@@ -0,0 +1,163 @@
+import timeTrackingHelpState from './help_state';
+import timeTrackingCollapsedState from './collapsed_state';
+import timeTrackingSpentOnlyPane from './spent_only_pane';
+import timeTrackingNoTrackingPane from './no_tracking_pane';
+import timeTrackingEstimateOnlyPane from './estimate_only_pane';
+import timeTrackingComparisonPane from './comparison_pane';
+
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'issuable-time-tracker',
+ props: {
+ time_estimate: {
+ type: Number,
+ required: true,
+ },
+ time_spent: {
+ type: Number,
+ required: true,
+ },
+ human_time_estimate: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ human_time_spent: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ showHelp: false,
+ };
+ },
+ components: {
+ 'time-tracking-collapsed-state': timeTrackingCollapsedState,
+ 'time-tracking-estimate-only-pane': timeTrackingEstimateOnlyPane,
+ 'time-tracking-spent-only-pane': timeTrackingSpentOnlyPane,
+ 'time-tracking-no-tracking-pane': timeTrackingNoTrackingPane,
+ 'time-tracking-comparison-pane': timeTrackingComparisonPane,
+ 'time-tracking-help-state': timeTrackingHelpState,
+ },
+ computed: {
+ timeSpent() {
+ return this.time_spent;
+ },
+ timeEstimate() {
+ return this.time_estimate;
+ },
+ timeEstimateHumanReadable() {
+ return this.human_time_estimate;
+ },
+ timeSpentHumanReadable() {
+ return this.human_time_spent;
+ },
+ hasTimeSpent() {
+ return !!this.timeSpent;
+ },
+ hasTimeEstimate() {
+ return !!this.timeEstimate;
+ },
+ showComparisonState() {
+ return this.hasTimeEstimate && this.hasTimeSpent;
+ },
+ showEstimateOnlyState() {
+ return this.hasTimeEstimate && !this.hasTimeSpent;
+ },
+ showSpentOnlyState() {
+ return this.hasTimeSpent && !this.hasTimeEstimate;
+ },
+ showNoTimeTrackingState() {
+ return !this.hasTimeEstimate && !this.hasTimeSpent;
+ },
+ showHelpState() {
+ return !!this.showHelp;
+ },
+ },
+ methods: {
+ toggleHelpState(show) {
+ this.showHelp = show;
+ },
+ update(data) {
+ this.time_estimate = data.time_estimate;
+ this.time_spent = data.time_spent;
+ this.human_time_estimate = data.human_time_estimate;
+ this.human_time_spent = data.human_time_spent;
+ },
+ },
+ created() {
+ eventHub.$on('timeTracker:updateData', this.update);
+ },
+ template: `
+ <div
+ class="time_tracker time-tracking-component-wrap"
+ v-cloak
+ >
+ <time-tracking-collapsed-state
+ :show-comparison-state="showComparisonState"
+ :show-no-time-tracking-state="showNoTimeTrackingState"
+ :show-help-state="showHelpState"
+ :show-spent-only-state="showSpentOnlyState"
+ :show-estimate-only-state="showEstimateOnlyState"
+ :time-spent-human-readable="timeSpentHumanReadable"
+ :time-estimate-human-readable="timeEstimateHumanReadable"
+ />
+ <div class="title hide-collapsed">
+ Time tracking
+ <div
+ class="help-button pull-right"
+ v-if="!showHelpState"
+ @click="toggleHelpState(true)"
+ >
+ <i
+ class="fa fa-question-circle"
+ aria-hidden="true"
+ />
+ </div>
+ <div
+ class="close-help-button pull-right"
+ v-if="showHelpState"
+ @click="toggleHelpState(false)"
+ >
+ <i
+ class="fa fa-close"
+ aria-hidden="true"
+ />
+ </div>
+ </div>
+ <div class="time-tracking-content hide-collapsed">
+ <time-tracking-estimate-only-pane
+ v-if="showEstimateOnlyState"
+ :time-estimate-human-readable="timeEstimateHumanReadable"
+ />
+ <time-tracking-spent-only-pane
+ v-if="showSpentOnlyState"
+ :time-spent-human-readable="timeSpentHumanReadable"
+ />
+ <time-tracking-no-tracking-pane
+ v-if="showNoTimeTrackingState"
+ />
+ <time-tracking-comparison-pane
+ v-if="showComparisonState"
+ :time-estimate="timeEstimate"
+ :time-spent="timeSpent"
+ :time-spent-human-readable="timeSpentHumanReadable"
+ :time-estimate-human-readable="timeEstimateHumanReadable"
+ />
+ <transition name="help-state-toggle">
+ <time-tracking-help-state
+ v-if="showHelpState"
+ :rootPath="rootPath"
+ />
+ </transition>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/event_hub.js b/app/assets/javascripts/sidebar/event_hub.js
new file mode 100644
index 00000000000..f35506fd5de
--- /dev/null
+++ b/app/assets/javascripts/sidebar/event_hub.js
@@ -0,0 +1,8 @@
+import Vue from 'vue';
+
+const eventHub = new Vue();
+
+// TODO: remove eventHub hack after code splitting refactor
+window.emitSidebarEvent = (...args) => eventHub.$emit(...args);
+
+export default eventHub;
diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js
new file mode 100644
index 00000000000..5a82d01dc41
--- /dev/null
+++ b/app/assets/javascripts/sidebar/services/sidebar_service.js
@@ -0,0 +1,28 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+Vue.use(VueResource);
+
+export default class SidebarService {
+ constructor(endpoint) {
+ if (!SidebarService.singleton) {
+ this.endpoint = endpoint;
+
+ SidebarService.singleton = this;
+ }
+
+ return SidebarService.singleton;
+ }
+
+ get() {
+ return Vue.http.get(this.endpoint);
+ }
+
+ update(key, data) {
+ return Vue.http.put(this.endpoint, {
+ [key]: data,
+ }, {
+ emulateJSON: true,
+ });
+ }
+}
diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js
new file mode 100644
index 00000000000..2b02af87d8a
--- /dev/null
+++ b/app/assets/javascripts/sidebar/sidebar_bundle.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking';
+import sidebarAssignees from './components/assignees/sidebar_assignees';
+
+import Mediator from './sidebar_mediator';
+
+function domContentLoaded() {
+ const mediator = new Mediator(gl.sidebarOptions);
+ mediator.fetch();
+
+ const sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees');
+
+ // Only create the sidebarAssignees vue app if it is found in the DOM
+ // We currently do not use sidebarAssignees for the MR page
+ if (sidebarAssigneesEl) {
+ new Vue(sidebarAssignees).$mount(sidebarAssigneesEl);
+ }
+
+ new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker');
+}
+
+document.addEventListener('DOMContentLoaded', domContentLoaded);
+
+export default domContentLoaded;
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
new file mode 100644
index 00000000000..5ccfb4ee9c1
--- /dev/null
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -0,0 +1,38 @@
+/* global Flash */
+
+import Service from './services/sidebar_service';
+import Store from './stores/sidebar_store';
+
+export default class SidebarMediator {
+ constructor(options) {
+ if (!SidebarMediator.singleton) {
+ this.store = new Store(options);
+ this.service = new Service(options.endpoint);
+ SidebarMediator.singleton = this;
+ }
+
+ return SidebarMediator.singleton;
+ }
+
+ assignYourself() {
+ this.store.addAssignee(this.store.currentUser);
+ }
+
+ saveAssignees(field) {
+ const selected = this.store.assignees.map(u => u.id);
+
+ // If there are no ids, that means we have to unassign (which is id = 0)
+ // And it only accepts an array, hence [0]
+ return this.service.update(field, selected.length === 0 ? [0] : selected);
+ }
+
+ fetch() {
+ this.service.get()
+ .then((response) => {
+ const data = response.json();
+ this.store.setAssigneeData(data);
+ this.store.setTimeTrackingData(data);
+ })
+ .catch(() => new Flash('Error occured when fetching sidebar data'));
+ }
+}
diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
new file mode 100644
index 00000000000..3356dd0191f
--- /dev/null
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -0,0 +1,56 @@
+export default class SidebarStore {
+ constructor(store) {
+ if (!SidebarStore.singleton) {
+ const { currentUser, rootPath, editable } = store;
+ this.currentUser = currentUser;
+ this.rootPath = rootPath;
+ this.editable = editable;
+ this.timeEstimate = 0;
+ this.totalTimeSpent = 0;
+ this.humanTimeEstimate = '';
+ this.humanTimeSpent = '';
+ this.assignees = [];
+ this.isFetching = {
+ assignees: true,
+ };
+
+ SidebarStore.singleton = this;
+ }
+
+ return SidebarStore.singleton;
+ }
+
+ setAssigneeData(data) {
+ this.isFetching.assignees = false;
+ if (data.assignees) {
+ this.assignees = data.assignees;
+ }
+ }
+
+ setTimeTrackingData(data) {
+ this.timeEstimate = data.time_estimate;
+ this.totalTimeSpent = data.total_time_spent;
+ this.humanTimeEstimate = data.human_time_estimate;
+ this.humanTotalTimeSpent = data.human_total_time_spent;
+ }
+
+ addAssignee(assignee) {
+ if (!this.findAssignee(assignee)) {
+ this.assignees.push(assignee);
+ }
+ }
+
+ findAssignee(findAssignee) {
+ return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0];
+ }
+
+ removeAssignee(removeAssignee) {
+ if (removeAssignee) {
+ this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id);
+ }
+ }
+
+ removeAllAssignees() {
+ this.assignees = [];
+ }
+}
diff --git a/app/assets/javascripts/signin_tabs_memoizer.js b/app/assets/javascripts/signin_tabs_memoizer.js
index d811d1cd53a..2587facc582 100644
--- a/app/assets/javascripts/signin_tabs_memoizer.js
+++ b/app/assets/javascripts/signin_tabs_memoizer.js
@@ -1,5 +1,7 @@
/* eslint no-param-reassign: ["error", { "props": false }]*/
/* eslint no-new: "off" */
+import AccessorUtilities from './lib/utils/accessor';
+
((global) => {
/**
* Memorize the last selected tab after reloading a page.
@@ -9,6 +11,8 @@
constructor({ currentTabKey = 'current_signin_tab', tabSelector = 'ul.nav-tabs' } = {}) {
this.currentTabKey = currentTabKey;
this.tabSelector = tabSelector;
+ this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
+
this.bootstrap();
}
@@ -37,11 +41,15 @@
}
saveData(val) {
- localStorage.setItem(this.currentTabKey, val);
+ if (!this.isLocalStorageAvailable) return undefined;
+
+ return window.localStorage.setItem(this.currentTabKey, val);
}
readData() {
- return localStorage.getItem(this.currentTabKey);
+ if (!this.isLocalStorageAvailable) return null;
+
+ return window.localStorage.getItem(this.currentTabKey);
}
}
diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js
index 294d087554e..c44892dae3d 100644
--- a/app/assets/javascripts/single_file_diff.js
+++ b/app/assets/javascripts/single_file_diff.js
@@ -1,12 +1,10 @@
/* eslint-disable func-names, prefer-arrow-callback, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, one-var-declaration-per-line, consistent-return, no-param-reassign, max-len */
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
window.SingleFileDiff = (function() {
var COLLAPSED_HTML, ERROR_HTML, LOADING_HTML, WRAPPER;
- WRAPPER = '<div class="diff-content diff-wrap-lines"></div>';
+ WRAPPER = '<div class="diff-content"></div>';
LOADING_HTML = '<i class="fa fa-spinner fa-spin"></i>';
@@ -16,7 +14,7 @@
function SingleFileDiff(file) {
this.file = file;
- this.toggleDiff = bind(this.toggleDiff, this);
+ this.toggleDiff = this.toggleDiff.bind(this);
this.content = $('.diff-content', this.file);
this.$toggleIcon = $('.diff-toggle-caret', this.file);
this.diffForPath = this.content.find('[data-diff-for-path]').data('diff-for-path');
diff --git a/app/assets/javascripts/subbable_resource.js b/app/assets/javascripts/subbable_resource.js
deleted file mode 100644
index d8191605128..00000000000
--- a/app/assets/javascripts/subbable_resource.js
+++ /dev/null
@@ -1,51 +0,0 @@
-(() => {
-/*
-* SubbableResource can be extended to provide a pubsub-style service for one-off REST
-* calls. Subscribe by passing a callback or render method you will use to handle responses.
- *
-* */
-
- class SubbableResource {
- constructor(resourcePath) {
- this.endpoint = resourcePath;
-
- // TODO: Switch to axios.create
- this.resource = $.ajax;
- this.subscribers = [];
- }
-
- subscribe(callback) {
- this.subscribers.push(callback);
- }
-
- publish(newResponse) {
- const responseCopy = _.extend({}, newResponse);
- this.subscribers.forEach((fn) => {
- fn(responseCopy);
- });
- return newResponse;
- }
-
- get(payload) {
- return this.resource(payload)
- .then(data => this.publish(data));
- }
-
- post(payload) {
- return this.resource(payload)
- .then(data => this.publish(data));
- }
-
- put(payload) {
- return this.resource(payload)
- .then(data => this.publish(data));
- }
-
- delete(payload) {
- return this.resource(payload)
- .then(data => this.publish(data));
- }
- }
-
- gl.SubbableResource = SubbableResource;
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/subscription_select.js b/app/assets/javascripts/subscription_select.js
index 8b25f43ffc7..0cd591c7320 100644
--- a/app/assets/javascripts/subscription_select.js
+++ b/app/assets/javascripts/subscription_select.js
@@ -19,8 +19,8 @@
return label;
};
})(this),
- clicked: function(item, $el, e) {
- return e.preventDefault();
+ clicked: function(options) {
+ return options.e.preventDefault();
},
id: function(obj, el) {
return $(el).data("id");
diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js
index b1402c0a880..419c458ff34 100644
--- a/app/assets/javascripts/task_list.js
+++ b/app/assets/javascripts/task_list.js
@@ -1,5 +1,6 @@
/* global Flash */
-require('vendor/task_list');
+
+import 'deckar01-task_list';
class TaskList {
constructor(options = {}) {
diff --git a/app/assets/javascripts/templates/issuable_template_selector.js b/app/assets/javascripts/templates/issuable_template_selector.js
index e62f429f1ae..9dd14488f22 100644
--- a/app/assets/javascripts/templates/issuable_template_selector.js
+++ b/app/assets/javascripts/templates/issuable_template_selector.js
@@ -1,5 +1,5 @@
/* eslint-disable comma-dangle, max-len, no-useless-return, no-param-reassign, max-len */
-/* global Api */
+import Api from '../api';
import TemplateSelector from '../blob/template_selector';
diff --git a/app/assets/javascripts/terminal/terminal_bundle.js b/app/assets/javascripts/terminal/terminal_bundle.js
index 13cf3a10a38..134522ef961 100644
--- a/app/assets/javascripts/terminal/terminal_bundle.js
+++ b/app/assets/javascripts/terminal/terminal_bundle.js
@@ -1,7 +1,9 @@
-require('vendor/xterm/encoding-indexes.js');
-require('vendor/xterm/encoding.js');
-window.Terminal = require('vendor/xterm/xterm.js');
-require('vendor/xterm/fit.js');
-require('./terminal.js');
+import 'vendor/xterm/encoding-indexes';
+import 'vendor/xterm/encoding';
+import Terminal from 'vendor/xterm/xterm';
+import 'vendor/xterm/fit';
+import './terminal';
+
+window.Terminal = Terminal;
$(() => new gl.Terminal({ selector: '#terminal' }));
diff --git a/app/assets/javascripts/test.js b/app/assets/javascripts/test.js
new file mode 100644
index 00000000000..c4c7918a68f
--- /dev/null
+++ b/app/assets/javascripts/test.js
@@ -0,0 +1 @@
+$.fx.off = true;
diff --git a/app/assets/javascripts/todos.js b/app/assets/javascripts/todos.js
index 8be58023c84..7230946b484 100644
--- a/app/assets/javascripts/todos.js
+++ b/app/assets/javascripts/todos.js
@@ -1,5 +1,6 @@
/* eslint-disable class-methods-use-this, no-unneeded-ternary, quote-props */
-/* global UsersSelect */
+
+import UsersSelect from './users_select';
class Todos {
constructor() {
diff --git a/app/assets/javascripts/u2f/authenticate.js b/app/assets/javascripts/u2f/authenticate.js
index 500b78fc5d8..cd5280948fd 100644
--- a/app/assets/javascripts/u2f/authenticate.js
+++ b/app/assets/javascripts/u2f/authenticate.js
@@ -10,18 +10,16 @@
(function() {
const global = window.gl || (window.gl = {});
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
global.U2FAuthenticate = (function() {
function U2FAuthenticate(container, form, u2fParams, fallbackButton, fallbackUI) {
this.container = container;
- this.renderNotSupported = bind(this.renderNotSupported, this);
- this.renderAuthenticated = bind(this.renderAuthenticated, this);
- this.renderError = bind(this.renderError, this);
- this.renderInProgress = bind(this.renderInProgress, this);
- this.renderTemplate = bind(this.renderTemplate, this);
- this.authenticate = bind(this.authenticate, this);
- this.start = bind(this.start, this);
+ this.renderNotSupported = this.renderNotSupported.bind(this);
+ this.renderAuthenticated = this.renderAuthenticated.bind(this);
+ this.renderError = this.renderError.bind(this);
+ this.renderInProgress = this.renderInProgress.bind(this);
+ this.renderTemplate = this.renderTemplate.bind(this);
+ this.authenticate = this.authenticate.bind(this);
+ this.start = this.start.bind(this);
this.appId = u2fParams.app_id;
this.challenge = u2fParams.challenge;
this.form = form;
diff --git a/app/assets/javascripts/u2f/error.js b/app/assets/javascripts/u2f/error.js
index fd1829efe18..3119b3480c3 100644
--- a/app/assets/javascripts/u2f/error.js
+++ b/app/assets/javascripts/u2f/error.js
@@ -2,12 +2,10 @@
/* global u2f */
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.U2FError = (function() {
function U2FError(errorCode, u2fFlowType) {
this.errorCode = errorCode;
- this.message = bind(this.message, this);
+ this.message = this.message.bind(this);
this.httpsDisabled = window.location.protocol !== 'https:';
this.u2fFlowType = u2fFlowType;
}
diff --git a/app/assets/javascripts/u2f/register.js b/app/assets/javascripts/u2f/register.js
index 17631f2908d..1234d17b8fd 100644
--- a/app/assets/javascripts/u2f/register.js
+++ b/app/assets/javascripts/u2f/register.js
@@ -8,19 +8,17 @@
// State Flow #1: setup -> in_progress -> registered -> POST to server
// State Flow #2: setup -> in_progress -> error -> setup
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.U2FRegister = (function() {
function U2FRegister(container, u2fParams) {
this.container = container;
- this.renderNotSupported = bind(this.renderNotSupported, this);
- this.renderRegistered = bind(this.renderRegistered, this);
- this.renderError = bind(this.renderError, this);
- this.renderInProgress = bind(this.renderInProgress, this);
- this.renderSetup = bind(this.renderSetup, this);
- this.renderTemplate = bind(this.renderTemplate, this);
- this.register = bind(this.register, this);
- this.start = bind(this.start, this);
+ this.renderNotSupported = this.renderNotSupported.bind(this);
+ this.renderRegistered = this.renderRegistered.bind(this);
+ this.renderError = this.renderError.bind(this);
+ this.renderInProgress = this.renderInProgress.bind(this);
+ this.renderSetup = this.renderSetup.bind(this);
+ this.renderTemplate = this.renderTemplate.bind(this);
+ this.register = this.register.bind(this);
+ this.start = this.start.bind(this);
this.appId = u2fParams.app_id;
this.registerRequests = u2fParams.register_requests;
this.signRequests = u2fParams.sign_requests;
diff --git a/app/assets/javascripts/users/calendar.js b/app/assets/javascripts/users/calendar.js
index 754d448564f..b11f691e424 100644
--- a/app/assets/javascripts/users/calendar.js
+++ b/app/assets/javascripts/users/calendar.js
@@ -3,12 +3,10 @@
import d3 from 'd3';
(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-
this.Calendar = (function() {
function Calendar(timestamps, calendar_activities_path) {
this.calendar_activities_path = calendar_activities_path;
- this.clickDay = bind(this.clickDay, this);
+ this.clickDay = this.clickDay.bind(this);
this.currentSelectedDate = '';
this.daySpace = 1;
this.daySize = 15;
@@ -168,15 +166,23 @@ import d3 from 'd3';
};
Calendar.prototype.renderKey = function() {
- var keyColors;
- keyColors = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)];
- return this.svg.append('g').attr('transform', "translate(18, " + (this.daySizeWithSpace * 8 + 16) + ")").selectAll('rect').data(keyColors).enter().append('rect').attr('width', this.daySize).attr('height', this.daySize).attr('x', (function(_this) {
- return function(color, i) {
- return _this.daySizeWithSpace * i;
- };
- })(this)).attr('y', 0).attr('fill', function(color) {
- return color;
- });
+ const keyValues = ['no contributions', '1-9 contributions', '10-19 contributions', '20-29 contributions', '30+ contributions'];
+ const keyColors = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)];
+
+ this.svg.append('g')
+ .attr('transform', `translate(18, ${this.daySizeWithSpace * 8 + 16})`)
+ .selectAll('rect')
+ .data(keyColors)
+ .enter()
+ .append('rect')
+ .attr('width', this.daySize)
+ .attr('height', this.daySize)
+ .attr('x', (color, i) => this.daySizeWithSpace * i)
+ .attr('y', 0)
+ .attr('fill', color => color)
+ .attr('class', 'js-tooltip')
+ .attr('title', (color, i) => keyValues[i])
+ .attr('data-container', 'body');
};
Calendar.prototype.initColor = function() {
diff --git a/app/assets/javascripts/users/users_bundle.js b/app/assets/javascripts/users/users_bundle.js
index 580e2d84be5..a38ce4eb25e 100644
--- a/app/assets/javascripts/users/users_bundle.js
+++ b/app/assets/javascripts/users/users_bundle.js
@@ -1 +1 @@
-require('./calendar');
+import './calendar';
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index 68cf9ced3ef..ec45253e50b 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -1,459 +1,703 @@
/* eslint-disable func-names, space-before-function-paren, one-var, no-var, prefer-rest-params, wrap-iife, quotes, max-len, one-var-declaration-per-line, vars-on-top, prefer-arrow-callback, consistent-return, comma-dangle, object-shorthand, no-shadow, no-unused-vars, no-else-return, no-self-compare, prefer-template, no-unused-expressions, no-lonely-if, yoda, prefer-spread, no-void, camelcase, no-param-reassign */
/* global Issuable */
-/* global ListUser */
-
-(function() {
- var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; },
- slice = [].slice;
-
- this.UsersSelect = (function() {
- function UsersSelect(currentUser, els) {
- var $els;
- this.users = bind(this.users, this);
- this.user = bind(this.user, this);
- this.usersPath = "/autocomplete/users.json";
- this.userPath = "/autocomplete/users/:id.json";
- if (currentUser != null) {
- if (typeof currentUser === 'object') {
- this.currentUser = currentUser;
- } else {
- this.currentUser = JSON.parse(currentUser);
- }
+/* global emitSidebarEvent */
+
+// TODO: remove eventHub hack after code splitting refactor
+window.emitSidebarEvent = window.emitSidebarEvent || $.noop;
+
+function UsersSelect(currentUser, els) {
+ var $els;
+ this.users = this.users.bind(this);
+ this.user = this.user.bind(this);
+ this.usersPath = "/autocomplete/users.json";
+ this.userPath = "/autocomplete/users/:id.json";
+ if (currentUser != null) {
+ if (typeof currentUser === 'object') {
+ this.currentUser = currentUser;
+ } else {
+ this.currentUser = JSON.parse(currentUser);
+ }
+ }
+
+ $els = $(els);
+
+ if (!els) {
+ $els = $('.js-user-search');
+ }
+
+ $els.each((function(_this) {
+ return function(i, dropdown) {
+ var options = {};
+ var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, defaultNullUser, firstUser, issueURL, selectedId, selectedIdDefault, showAnyUser, showNullUser, showMenuAbove;
+ $dropdown = $(dropdown);
+ options.projectId = $dropdown.data('project-id');
+ options.groupId = $dropdown.data('group-id');
+ options.showCurrentUser = $dropdown.data('current-user');
+ options.todoFilter = $dropdown.data('todo-filter');
+ options.todoStateFilter = $dropdown.data('todo-state-filter');
+ options.perPage = $dropdown.data('per-page');
+ showNullUser = $dropdown.data('null-user');
+ defaultNullUser = $dropdown.data('null-user-default');
+ showMenuAbove = $dropdown.data('showMenuAbove');
+ showAnyUser = $dropdown.data('any-user');
+ firstUser = $dropdown.data('first-user');
+ options.authorId = $dropdown.data('author-id');
+ defaultLabel = $dropdown.data('default-label');
+ issueURL = $dropdown.data('issueUpdate');
+ $selectbox = $dropdown.closest('.selectbox');
+ $block = $selectbox.closest('.block');
+ abilityName = $dropdown.data('ability-name');
+ $value = $block.find('.value');
+ $collapsedSidebar = $block.find('.sidebar-collapsed-user');
+ $loading = $block.find('.block-loading').fadeOut();
+ selectedIdDefault = (defaultNullUser && showNullUser) ? 0 : null;
+ selectedId = $dropdown.data('selected');
+
+ if (selectedId === undefined) {
+ selectedId = selectedIdDefault;
}
- $els = $(els);
+ const assignYourself = function () {
+ const unassignedSelected = $dropdown.closest('.selectbox')
+ .find(`input[name='${$dropdown.data('field-name')}'][value=0]`);
+
+ if (unassignedSelected) {
+ unassignedSelected.remove();
+ }
+
+ // Save current selected user to the DOM
+ const input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = $dropdown.data('field-name');
+
+ const currentUserInfo = $dropdown.data('currentUserInfo');
+
+ if (currentUserInfo) {
+ input.value = currentUserInfo.id;
+ input.dataset.meta = currentUserInfo.name;
+ } else if (_this.currentUser) {
+ input.value = _this.currentUser.id;
+ }
- if (!els) {
- $els = $('.js-user-search');
+ if ($selectbox) {
+ $dropdown.parent().before(input);
+ } else {
+ $dropdown.after(input);
+ }
+ };
+
+ if ($block[0]) {
+ $block[0].addEventListener('assignYourself', assignYourself);
}
- $els.each((function(_this) {
- return function(i, dropdown) {
- var options = {};
- var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, defaultNullUser, firstUser, issueURL, selectedId, selectedIdDefault, showAnyUser, showNullUser, showMenuAbove;
- $dropdown = $(dropdown);
- options.projectId = $dropdown.data('project-id');
- options.groupId = $dropdown.data('group-id');
- options.showCurrentUser = $dropdown.data('current-user');
- options.todoFilter = $dropdown.data('todo-filter');
- options.todoStateFilter = $dropdown.data('todo-state-filter');
- showNullUser = $dropdown.data('null-user');
- defaultNullUser = $dropdown.data('null-user-default');
- showMenuAbove = $dropdown.data('showMenuAbove');
- showAnyUser = $dropdown.data('any-user');
- firstUser = $dropdown.data('first-user');
- options.authorId = $dropdown.data('author-id');
- defaultLabel = $dropdown.data('default-label');
- issueURL = $dropdown.data('issueUpdate');
- $selectbox = $dropdown.closest('.selectbox');
- $block = $selectbox.closest('.block');
- abilityName = $dropdown.data('ability-name');
- $value = $block.find('.value');
- $collapsedSidebar = $block.find('.sidebar-collapsed-user');
- $loading = $block.find('.block-loading').fadeOut();
- selectedIdDefault = (defaultNullUser && showNullUser) ? 0 : null;
- selectedId = $dropdown.data('selected') || selectedIdDefault;
-
- var updateIssueBoardsIssue = function () {
- $loading.removeClass('hidden').fadeIn();
- gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update'))
- .then(function () {
- $loading.fadeOut();
- })
- .catch(function () {
- $loading.fadeOut();
- });
- };
+ const getSelectedUserInputs = function() {
+ return $selectbox
+ .find(`input[name="${$dropdown.data('field-name')}"]`);
+ };
- $('.assign-to-me-link').on('click', (e) => {
- e.preventDefault();
- $(e.currentTarget).hide();
- const $input = $(`input[name="${$dropdown.data('field-name')}"]`);
- $input.val(gon.current_user_id);
- selectedId = $input.val();
- $dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default');
- });
+ const getSelected = function() {
+ return getSelectedUserInputs()
+ .map((index, input) => parseInt(input.value, 10))
+ .get();
+ };
- $block.on('click', '.js-assign-yourself', function(e) {
- e.preventDefault();
+ const checkMaxSelect = function() {
+ const maxSelect = $dropdown.data('max-select');
+ if (maxSelect) {
+ const selected = getSelected();
- if ($dropdown.hasClass('js-issue-board-sidebar')) {
- gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({
- id: _this.currentUser.id,
- username: _this.currentUser.username,
- name: _this.currentUser.name,
- avatar_url: _this.currentUser.avatar_url
- }));
+ if (selected.length > maxSelect) {
+ const firstSelectedId = selected[0];
+ const firstSelected = $dropdown.closest('.selectbox')
+ .find(`input[name='${$dropdown.data('field-name')}'][value=${firstSelectedId}]`);
- updateIssueBoardsIssue();
- } else {
- return assignTo(_this.currentUser.id);
- }
- });
- assignTo = function(selected) {
- var data;
- data = {};
- data[abilityName] = {};
- data[abilityName].assignee_id = selected != null ? selected : null;
- $loading.removeClass('hidden').fadeIn();
- $dropdown.trigger('loading.gl.dropdown');
- return $.ajax({
- type: 'PUT',
- dataType: 'json',
- url: issueURL,
- data: data
- }).done(function(data) {
- var user;
- $dropdown.trigger('loaded.gl.dropdown');
- $loading.fadeOut();
- $selectbox.hide();
- if (data.assignee) {
- user = {
- name: data.assignee.name,
- username: data.assignee.username,
- avatar: data.assignee.avatar_url
- };
- } else {
- user = {
- name: 'Unassigned',
- username: '',
- avatar: ''
- };
- }
- $value.html(assigneeTemplate(user));
- $collapsedSidebar.attr('title', user.name).tooltip('fixTitle');
- return $collapsedSidebar.html(collapsedAssigneeTemplate(user));
+ firstSelected.remove();
+ emitSidebarEvent('sidebar.removeAssignee', {
+ id: firstSelectedId,
});
- };
- collapsedAssigneeTemplate = _.template('<% if( avatar ) { %> <a class="author_link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>');
- assigneeTemplate = _.template('<% if (username) { %> <a class="author_link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> No assignee - <a href="#" class="js-assign-yourself"> assign yourself </a> </span> <% } %>');
- return $dropdown.glDropdown({
- showMenuAbove: showMenuAbove,
- data: function(term, callback) {
- var isAuthorFilter;
- isAuthorFilter = $('.js-author-search');
- return _this.users(term, options, function(users) {
- var anyUser, index, j, len, name, obj, showDivider;
- if (term.length === 0) {
- showDivider = 0;
- if (firstUser) {
- // Move current user to the front of the list
- for (index = j = 0, len = users.length; j < len; index = (j += 1)) {
- obj = users[index];
- if (obj.username === firstUser) {
- users.splice(index, 1);
- users.unshift(obj);
- break;
- }
- }
- }
- if (showNullUser) {
- showDivider += 1;
- users.unshift({
- beforeDivider: true,
- name: 'Unassigned',
- id: 0
- });
- }
- if (showAnyUser) {
- showDivider += 1;
- name = showAnyUser;
- if (name === true) {
- name = 'Any User';
- }
- anyUser = {
- beforeDivider: true,
- name: name,
- id: null
- };
- users.unshift(anyUser);
- }
- }
- if (showDivider) {
- users.splice(showDivider, 0, "divider");
- }
+ }
+ }
+ };
- callback(users);
- if (showMenuAbove) {
- $dropdown.data('glDropdown').positionMenuAbove();
- }
+ const getMultiSelectDropdownTitle = function(selectedUser, isSelected) {
+ const selectedUsers = getSelected()
+ .filter(u => u !== 0);
+
+ const firstUser = getSelectedUserInputs()
+ .map((index, input) => ({
+ name: input.dataset.meta,
+ value: parseInt(input.value, 10),
+ }))
+ .filter(u => u.id !== 0)
+ .get(0);
+
+ if (selectedUsers.length === 0) {
+ return 'Unassigned';
+ } else if (selectedUsers.length === 1) {
+ return firstUser.name;
+ } else if (isSelected) {
+ const otherSelected = selectedUsers.filter(s => s !== selectedUser.id);
+ return `${selectedUser.name} + ${otherSelected.length} more`;
+ } else {
+ return `${firstUser.name} + ${selectedUsers.length - 1} more`;
+ }
+ };
+
+ $('.assign-to-me-link').on('click', (e) => {
+ e.preventDefault();
+ $(e.currentTarget).hide();
+
+ if ($dropdown.data('multiSelect')) {
+ assignYourself();
+ checkMaxSelect();
+
+ const currentUserInfo = $dropdown.data('currentUserInfo');
+ $dropdown.find('.dropdown-toggle-text').text(getMultiSelectDropdownTitle(currentUserInfo)).removeClass('is-default');
+ } else {
+ const $input = $(`input[name="${$dropdown.data('field-name')}"]`);
+ $input.val(gon.current_user_id);
+ selectedId = $input.val();
+ $dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default');
+ }
+ });
+
+ $block.on('click', '.js-assign-yourself', (e) => {
+ e.preventDefault();
+ return assignTo(_this.currentUser.id);
+ });
+
+ assignTo = function(selected) {
+ var data;
+ data = {};
+ data[abilityName] = {};
+ data[abilityName].assignee_id = selected != null ? selected : null;
+ $loading.removeClass('hidden').fadeIn();
+ $dropdown.trigger('loading.gl.dropdown');
+
+ return $.ajax({
+ type: 'PUT',
+ dataType: 'json',
+ url: issueURL,
+ data: data
+ }).done(function(data) {
+ var user;
+ $dropdown.trigger('loaded.gl.dropdown');
+ $loading.fadeOut();
+ if (data.assignee) {
+ user = {
+ name: data.assignee.name,
+ username: data.assignee.username,
+ avatar: data.assignee.avatar_url
+ };
+ } else {
+ user = {
+ name: 'Unassigned',
+ username: '',
+ avatar: ''
+ };
+ }
+ $value.html(assigneeTemplate(user));
+ $collapsedSidebar.attr('title', user.name).tooltip('fixTitle');
+ return $collapsedSidebar.html(collapsedAssigneeTemplate(user));
+ });
+ };
+ collapsedAssigneeTemplate = _.template('<% if( avatar ) { %> <a class="author_link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>');
+ assigneeTemplate = _.template('<% if (username) { %> <a class="author_link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> No assignee - <a href="#" class="js-assign-yourself"> assign yourself </a> </span> <% } %>');
+ return $dropdown.glDropdown({
+ showMenuAbove: showMenuAbove,
+ data: function(term, callback) {
+ var isAuthorFilter;
+ isAuthorFilter = $('.js-author-search');
+ 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));
+ },
+ processData: function(term, data, callback) {
+ let users = data;
+
+ // Only show assigned user list when there is no search term
+ if ($dropdown.hasClass('js-multiselect') && term.length === 0) {
+ const selectedInputs = getSelectedUserInputs();
+
+ // Potential duplicate entries when dealing with issue board
+ // because issue board is also managed by vue
+ const selectedUsers = _.uniq(selectedInputs, false, a => a.value)
+ .filter((input) => {
+ const userId = parseInt(input.value, 10);
+ const inUsersArray = users.find(u => u.id === userId);
+
+ return !inUsersArray && userId !== 0;
+ })
+ .map((input) => {
+ const userId = parseInt(input.value, 10);
+ const { avatarUrl, avatar_url, name, username } = input.dataset;
+ return {
+ avatar_url: avatarUrl || avatar_url,
+ id: userId,
+ name,
+ username,
+ };
});
- },
- filterable: true,
- filterRemote: true,
- search: {
- fields: ['name', 'username']
- },
- selectable: true,
- fieldName: $dropdown.data('field-name'),
- toggleLabel: function(selected, el) {
- if (selected && 'id' in selected && $(el).hasClass('is-active')) {
- $dropdown.find('.dropdown-toggle-text').removeClass('is-default');
- if (selected.text) {
- return selected.text;
- } else {
- return selected.name;
+
+ users = data.concat(selectedUsers);
+ }
+
+ let anyUser;
+ let index;
+ let j;
+ let len;
+ let name;
+ let obj;
+ let showDivider;
+ if (term.length === 0) {
+ showDivider = 0;
+ if (firstUser) {
+ // Move current user to the front of the list
+ for (index = j = 0, len = users.length; j < len; index = (j += 1)) {
+ obj = users[index];
+ if (obj.username === firstUser) {
+ users.splice(index, 1);
+ users.unshift(obj);
+ break;
}
- } else {
- $dropdown.find('.dropdown-toggle-text').addClass('is-default');
- return defaultLabel;
}
- },
- defaultLabel: defaultLabel,
- inputId: 'issue_assignee_id',
- hidden: function(e) {
- $selectbox.hide();
- // display:block overrides the hide-collapse rule
- return $value.css('display', '');
- },
- vue: $dropdown.hasClass('js-issue-board-sidebar'),
- clicked: function(user, $el, e) {
- var isIssueIndex, isMRIndex, page, selected, isSelecting;
- page = $('body').data('page');
- isIssueIndex = page === 'projects:issues:index';
- isMRIndex = (page === page && page === 'projects:merge_requests:index');
- isSelecting = (user.id !== selectedId);
- selectedId = isSelecting ? user.id : selectedIdDefault;
- if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) {
- e.preventDefault();
- if (selectedId === gon.current_user_id) {
- $('.assign-to-me-link').hide();
- } else {
- $('.assign-to-me-link').show();
- }
- return;
+ }
+ if (showNullUser) {
+ showDivider += 1;
+ users.unshift({
+ beforeDivider: true,
+ name: 'Unassigned',
+ id: 0
+ });
+ }
+ if (showAnyUser) {
+ showDivider += 1;
+ name = showAnyUser;
+ if (name === true) {
+ name = 'Any User';
}
- if ($el.closest('.add-issues-modal').length) {
- gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id;
- } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
- return Issuable.filterResults($dropdown.closest('form'));
- } else if ($dropdown.hasClass('js-filter-submit')) {
- return $dropdown.closest('form').submit();
- } else if ($dropdown.hasClass('js-issue-board-sidebar')) {
- if (user.id && isSelecting) {
- gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({
- id: user.id,
- username: user.username,
- name: user.name,
- avatar_url: user.avatar_url
- }));
- } else {
- gl.issueBoards.boardStoreIssueDelete('assignee');
+ anyUser = {
+ beforeDivider: true,
+ name: name,
+ id: null
+ };
+ users.unshift(anyUser);
+ }
+
+ if (showDivider) {
+ users.splice(showDivider, 0, 'divider');
+ }
+
+ if ($dropdown.hasClass('js-multiselect')) {
+ const selected = getSelected().filter(i => i !== 0);
+
+ if (selected.length > 0) {
+ if ($dropdown.data('dropdown-header')) {
+ showDivider += 1;
+ users.splice(showDivider, 0, {
+ header: $dropdown.data('dropdown-header'),
+ });
}
- updateIssueBoardsIssue();
- } else {
- selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val();
- return assignTo(selected);
- }
- },
- id: function (user) {
- return user.id;
- },
- opened: function(e) {
- const $el = $(e.currentTarget);
- if ($dropdown.hasClass('js-issue-board-sidebar')) {
- selectedId = parseInt($dropdown[0].dataset.selected, 10) || selectedIdDefault;
+ const selectedUsers = users
+ .filter(u => selected.indexOf(u.id) !== -1)
+ .sort((a, b) => a.name > b.name);
+
+ users = users.filter(u => selected.indexOf(u.id) === -1);
+
+ selectedUsers.forEach((selectedUser) => {
+ showDivider += 1;
+ users.splice(showDivider, 0, selectedUser);
+ });
+
+ users.splice(showDivider + 1, 0, 'divider');
}
- $el.find('.is-active').removeClass('is-active');
- $el.find(`li[data-user-id="${selectedId}"] .dropdown-menu-user-link`).addClass('is-active');
- },
- renderRow: function(user) {
- var avatar, img, listClosingTags, listWithName, listWithUserName, selected, username;
- username = user.username ? "@" + user.username : "";
- avatar = user.avatar_url ? user.avatar_url : false;
- selected = user.id === parseInt(selectedId, 10) ? "is-active" : "";
- img = "";
- if (user.beforeDivider != null) {
- "<li> <a href='#' class='" + selected + "'> " + user.name + " </a> </li>";
- } else {
- if (avatar) {
- img = "<img src='" + avatar + "' class='avatar avatar-inline' width='30' />";
- }
+ }
+ }
+
+ callback(users);
+ if (showMenuAbove) {
+ $dropdown.data('glDropdown').positionMenuAbove();
+ }
+ },
+ filterable: true,
+ filterRemote: true,
+ search: {
+ fields: ['name', 'username']
+ },
+ selectable: true,
+ fieldName: $dropdown.data('field-name'),
+ toggleLabel: function(selected, el, glDropdown) {
+ const inputValue = glDropdown.filterInput.val();
+
+ if (this.multiSelect && inputValue === '') {
+ // Remove non-users from the fullData array
+ const users = glDropdown.filteredFullData();
+ const callback = glDropdown.parseData.bind(glDropdown);
+
+ // Update the data model
+ this.processData(inputValue, users, callback);
+ }
+
+ if (this.multiSelect) {
+ return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active'));
+ }
+
+ if (selected && 'id' in selected && $(el).hasClass('is-active')) {
+ $dropdown.find('.dropdown-toggle-text').removeClass('is-default');
+ if (selected.text) {
+ return selected.text;
+ } else {
+ return selected.name;
+ }
+ } else {
+ $dropdown.find('.dropdown-toggle-text').addClass('is-default');
+ return defaultLabel;
+ }
+ },
+ defaultLabel: defaultLabel,
+ hidden: function(e) {
+ if ($dropdown.hasClass('js-multiselect')) {
+ emitSidebarEvent('sidebar.saveAssignees');
+ }
+
+ if (!$dropdown.data('always-show-selectbox')) {
+ $selectbox.hide();
+
+ // Recalculate where .value is because vue might have changed it
+ $block = $selectbox.closest('.block');
+ $value = $block.find('.value');
+ // display:block overrides the hide-collapse rule
+ $value.css('display', '');
+ }
+ },
+ multiSelect: $dropdown.hasClass('js-multiselect'),
+ inputMeta: $dropdown.data('input-meta'),
+ clicked: function(options) {
+ const { $el, e, isMarking } = options;
+ const user = options.selectedObj;
+
+ if ($dropdown.hasClass('js-multiselect')) {
+ const isActive = $el.hasClass('is-active');
+ const previouslySelected = $dropdown.closest('.selectbox')
+ .find("input[name='" + ($dropdown.data('field-name')) + "'][value!=0]");
+
+ // Enables support for limiting the number of users selected
+ // Automatically removes the first on the list if more users are selected
+ checkMaxSelect();
+
+ if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') {
+ // Unassigned selected
+ previouslySelected.each((index, element) => {
+ const id = parseInt(element.value, 10);
+ element.remove();
+ });
+ emitSidebarEvent('sidebar.removeAllAssignees');
+ } else if (isActive) {
+ // user selected
+ emitSidebarEvent('sidebar.addAssignee', user);
+
+ // Remove unassigned selection (if it was previously selected)
+ const unassignedSelected = $dropdown.closest('.selectbox')
+ .find("input[name='" + ($dropdown.data('field-name')) + "'][value=0]");
+
+ if (unassignedSelected) {
+ unassignedSelected.remove();
}
- // split into three parts so we can remove the username section if nessesary
- listWithName = "<li data-user-id=" + user.id + "> <a href='#' class='dropdown-menu-user-link " + selected + "'> " + img + " <strong class='dropdown-menu-user-full-name'> " + user.name + " </strong>";
- listWithUserName = "<span class='dropdown-menu-user-username'> " + username + " </span>";
- listClosingTags = "</a> </li>";
- if (username === '') {
- listWithUserName = '';
+ } else {
+ if (previouslySelected.length === 0) {
+ // Select unassigned because there is no more selected users
+ this.addInput($dropdown.data('field-name'), 0, {});
}
- return listWithName + listWithUserName + listClosingTags;
+
+ // User unselected
+ emitSidebarEvent('sidebar.removeAssignee', user);
}
- });
- };
- })(this));
- $('.ajax-users-select').each((function(_this) {
- return function(i, select) {
- var firstUser, showAnyUser, showEmailUser, showNullUser;
- var options = {};
- options.skipLdap = $(select).hasClass('skip_ldap');
- options.projectId = $(select).data('project-id');
- options.groupId = $(select).data('group-id');
- options.showCurrentUser = $(select).data('current-user');
- options.pushCodeToProtectedBranches = $(select).data('push-code-to-protected-branches');
- options.authorId = $(select).data('author-id');
- options.skipUsers = $(select).data('skip-users');
- showNullUser = $(select).data('null-user');
- showAnyUser = $(select).data('any-user');
- showEmailUser = $(select).data('email-user');
- firstUser = $(select).data('first-user');
- return $(select).select2({
- placeholder: "Search for a user",
- multiple: $(select).hasClass('multiselect'),
- minimumInputLength: 0,
- query: function(query) {
- return _this.users(query.term, options, function(users) {
- var anyUser, data, emailUser, index, j, len, name, nullUser, obj, ref;
- data = {
- results: users
- };
- if (query.term.length === 0) {
- if (firstUser) {
- // Move current user to the front of the list
- ref = data.results;
- for (index = j = 0, len = ref.length; j < len; index = (j += 1)) {
- obj = ref[index];
- if (obj.username === firstUser) {
- data.results.splice(index, 1);
- data.results.unshift(obj);
- break;
- }
- }
- }
- if (showNullUser) {
- nullUser = {
- name: 'Unassigned',
- id: 0
- };
- data.results.unshift(nullUser);
- }
- if (showAnyUser) {
- name = showAnyUser;
- if (name === true) {
- name = 'Any User';
- }
- anyUser = {
- name: name,
- id: null
- };
- data.results.unshift(anyUser);
- }
- }
- if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) {
- var trimmed = query.term.trim();
- emailUser = {
- name: "Invite \"" + query.term + "\"",
- username: trimmed,
- id: trimmed
- };
- data.results.unshift(emailUser);
- }
- return query.callback(data);
- });
- },
- initSelection: function() {
- var args;
- args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
- return _this.initSelection.apply(_this, args);
- },
- formatResult: function() {
- var args;
- args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
- return _this.formatResult.apply(_this, args);
- },
- formatSelection: function() {
- var args;
- args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
- return _this.formatSelection.apply(_this, args);
- },
- dropdownCssClass: "ajax-users-dropdown",
- // we do not want to escape markup since we are displaying html in results
- escapeMarkup: function(m) {
- return m;
+
+ if (getSelected().find(u => u === gon.current_user_id)) {
+ $('.assign-to-me-link').hide();
+ } else {
+ $('.assign-to-me-link').show();
}
- });
- };
- })(this));
- }
+ }
- UsersSelect.prototype.initSelection = function(element, callback) {
- var id, nullUser;
- id = $(element).val();
- if (id === "0") {
- nullUser = {
- name: 'Unassigned'
- };
- return callback(nullUser);
- } else if (id !== "") {
- return this.user(id, callback);
- }
- };
+ var isIssueIndex, isMRIndex, page, selected;
+ page = $('body').data('page');
+ isIssueIndex = page === 'projects:issues:index';
+ isMRIndex = (page === page && page === 'projects:merge_requests:index');
+ if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) {
+ e.preventDefault();
- UsersSelect.prototype.formatResult = function(user) {
- var avatar;
- if (user.avatar_url) {
- avatar = user.avatar_url;
- } else {
- avatar = gon.default_avatar_url;
- }
- return "<div class='user-result " + (!user.username ? 'no-username' : void 0) + "'> <div class='user-image'><img class='avatar s24' src='" + avatar + "'></div> <div class='user-name'>" + user.name + "</div> <div class='user-username'>" + (user.username || "") + "</div> </div>";
- };
+ const isSelecting = (user.id !== selectedId);
+ selectedId = isSelecting ? user.id : selectedIdDefault;
- UsersSelect.prototype.formatSelection = function(user) {
- return user.name;
- };
+ if (selectedId === gon.current_user_id) {
+ $('.assign-to-me-link').hide();
+ } else {
+ $('.assign-to-me-link').show();
+ }
+ return;
+ }
+ if ($el.closest('.add-issues-modal').length) {
+ gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id;
+ } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
+ return Issuable.filterResults($dropdown.closest('form'));
+ } else if ($dropdown.hasClass('js-filter-submit')) {
+ return $dropdown.closest('form').submit();
+ } else if (!$dropdown.hasClass('js-multiselect')) {
+ selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val();
+ return assignTo(selected);
+ }
- UsersSelect.prototype.user = function(user_id, callback) {
- if (!/^\d+$/.test(user_id)) {
- return false;
- }
+ // Automatically close dropdown after assignee is selected
+ // since CE has no multiple assignees
+ // EE does not have a max-select
+ if ($dropdown.data('max-select') &&
+ getSelected().length === $dropdown.data('max-select')) {
+ // Close the dropdown
+ $dropdown.dropdown('toggle');
+ }
+ },
+ id: function (user) {
+ return user.id;
+ },
+ opened: function(e) {
+ const $el = $(e.currentTarget);
+ const selected = getSelected();
+ if ($dropdown.hasClass('js-issue-board-sidebar') && selected.length === 0) {
+ this.addInput($dropdown.data('field-name'), 0, {});
+ }
+ $el.find('.is-active').removeClass('is-active');
+
+ function highlightSelected(id) {
+ $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active');
+ }
+
+ if (selected.length > 0) {
+ getSelected().forEach(selectedId => highlightSelected(selectedId));
+ } else if ($dropdown.hasClass('js-issue-board-sidebar')) {
+ highlightSelected(0);
+ } else {
+ highlightSelected(selectedId);
+ }
+ },
+ updateLabel: $dropdown.data('dropdown-title'),
+ renderRow: function(user) {
+ var avatar, img, listClosingTags, listWithName, listWithUserName, username;
+ username = user.username ? "@" + user.username : "";
+ avatar = user.avatar_url ? user.avatar_url : false;
+
+ let selected = false;
+
+ if (this.multiSelect) {
+ selected = getSelected().find(u => user.id === u);
- var url;
- url = this.buildUrl(this.userPath);
- url = url.replace(':id', user_id);
- return $.ajax({
- url: url,
- dataType: "json"
- }).done(function(user) {
- return callback(user);
+ const fieldName = this.fieldName;
+ const field = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "'][value='" + user.id + "']");
+
+ if (field.length) {
+ selected = true;
+ }
+ } else {
+ selected = user.id === selectedId;
+ }
+
+ img = "";
+ if (user.beforeDivider != null) {
+ `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${user.name}</a></li>`;
+ } else {
+ if (avatar) {
+ img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />";
+ }
+ }
+
+ return `
+ <li data-user-id=${user.id}>
+ <a href='#' class='dropdown-menu-user-link ${selected === true ? 'is-active' : ''}'>
+ ${img}
+ <strong class='dropdown-menu-user-full-name'>
+ ${user.name}
+ </strong>
+ ${username ? `<span class='dropdown-menu-user-username'>${username}</span>` : ''}
+ </a>
+ </li>
+ `;
+ }
});
};
-
- // Return users list. Filtered by query
- // Only active users retrieved
- UsersSelect.prototype.users = function(query, options, callback) {
- var url;
- url = this.buildUrl(this.usersPath);
- return $.ajax({
- url: url,
- data: {
- search: query,
- per_page: 20,
- active: true,
- project_id: options.projectId || null,
- group_id: options.groupId || null,
- skip_ldap: options.skipLdap || null,
- todo_filter: options.todoFilter || null,
- todo_state_filter: options.todoStateFilter || null,
- current_user: options.showCurrentUser || null,
- push_code_to_protected_branches: options.pushCodeToProtectedBranches || null,
- author_id: options.authorId || null,
- skip_users: options.skipUsers || null
+ })(this));
+ $('.ajax-users-select').each((function(_this) {
+ return function(i, select) {
+ var firstUser, showAnyUser, showEmailUser, showNullUser;
+ var options = {};
+ options.skipLdap = $(select).hasClass('skip_ldap');
+ options.projectId = $(select).data('project-id');
+ options.groupId = $(select).data('group-id');
+ options.showCurrentUser = $(select).data('current-user');
+ options.pushCodeToProtectedBranches = $(select).data('push-code-to-protected-branches');
+ options.authorId = $(select).data('author-id');
+ options.skipUsers = $(select).data('skip-users');
+ showNullUser = $(select).data('null-user');
+ showAnyUser = $(select).data('any-user');
+ showEmailUser = $(select).data('email-user');
+ firstUser = $(select).data('first-user');
+ return $(select).select2({
+ placeholder: "Search for a user",
+ multiple: $(select).hasClass('multiselect'),
+ minimumInputLength: 0,
+ query: function(query) {
+ return _this.users(query.term, options, function(users) {
+ var anyUser, data, emailUser, index, j, len, name, nullUser, obj, ref;
+ data = {
+ results: users
+ };
+ if (query.term.length === 0) {
+ if (firstUser) {
+ // Move current user to the front of the list
+ ref = data.results;
+ for (index = j = 0, len = ref.length; j < len; index = (j += 1)) {
+ obj = ref[index];
+ if (obj.username === firstUser) {
+ data.results.splice(index, 1);
+ data.results.unshift(obj);
+ break;
+ }
+ }
+ }
+ if (showNullUser) {
+ nullUser = {
+ name: 'Unassigned',
+ id: 0
+ };
+ data.results.unshift(nullUser);
+ }
+ if (showAnyUser) {
+ name = showAnyUser;
+ if (name === true) {
+ name = 'Any User';
+ }
+ anyUser = {
+ name: name,
+ id: null
+ };
+ data.results.unshift(anyUser);
+ }
+ }
+ if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) {
+ var trimmed = query.term.trim();
+ emailUser = {
+ name: "Invite \"" + query.term + "\"",
+ username: trimmed,
+ id: trimmed
+ };
+ data.results.unshift(emailUser);
+ }
+ return query.callback(data);
+ });
+ },
+ initSelection: function() {
+ var args;
+ args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
+ return _this.initSelection.apply(_this, args);
+ },
+ formatResult: function() {
+ var args;
+ args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
+ return _this.formatResult.apply(_this, args);
},
- dataType: "json"
- }).done(function(users) {
- return callback(users);
+ formatSelection: function() {
+ var args;
+ args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
+ return _this.formatSelection.apply(_this, args);
+ },
+ dropdownCssClass: "ajax-users-dropdown",
+ // we do not want to escape markup since we are displaying html in results
+ escapeMarkup: function(m) {
+ return m;
+ }
});
};
+ })(this));
+}
- UsersSelect.prototype.buildUrl = function(url) {
- if (gon.relative_url_root != null) {
- url = gon.relative_url_root.replace(/\/$/, '') + url;
- }
- return url;
+UsersSelect.prototype.initSelection = function(element, callback) {
+ var id, nullUser;
+ id = $(element).val();
+ if (id === "0") {
+ nullUser = {
+ name: 'Unassigned'
};
+ return callback(nullUser);
+ } else if (id !== "") {
+ return this.user(id, callback);
+ }
+};
+
+UsersSelect.prototype.formatResult = function(user) {
+ var avatar;
+ if (user.avatar_url) {
+ avatar = user.avatar_url;
+ } else {
+ avatar = gon.default_avatar_url;
+ }
+ return "<div class='user-result " + (!user.username ? 'no-username' : void 0) + "'> <div class='user-image'><img class='avatar s24' src='" + avatar + "'></div> <div class='user-name'>" + user.name + "</div> <div class='user-username'>" + (user.username || "") + "</div> </div>";
+};
+
+UsersSelect.prototype.formatSelection = function(user) {
+ return user.name;
+};
+
+UsersSelect.prototype.user = function(user_id, callback) {
+ if (!/^\d+$/.test(user_id)) {
+ return false;
+ }
+
+ var url;
+ url = this.buildUrl(this.userPath);
+ url = url.replace(':id', user_id);
+ return $.ajax({
+ url: url,
+ dataType: "json"
+ }).done(function(user) {
+ return callback(user);
+ });
+};
+
+// Return users list. Filtered by query
+// Only active users retrieved
+UsersSelect.prototype.users = function(query, options, callback) {
+ var url;
+ url = this.buildUrl(this.usersPath);
+ return $.ajax({
+ url: url,
+ data: {
+ search: query,
+ per_page: options.perPage || 20,
+ active: true,
+ project_id: options.projectId || null,
+ group_id: options.groupId || null,
+ skip_ldap: options.skipLdap || null,
+ todo_filter: options.todoFilter || null,
+ todo_state_filter: options.todoStateFilter || null,
+ current_user: options.showCurrentUser || null,
+ push_code_to_protected_branches: options.pushCodeToProtectedBranches || null,
+ author_id: options.authorId || null,
+ skip_users: options.skipUsers || null
+ },
+ dataType: "json"
+ }).done(function(users) {
+ return callback(users);
+ });
+};
+
+UsersSelect.prototype.buildUrl = function(url) {
+ if (gon.relative_url_root != null) {
+ url = gon.relative_url_root.replace(/\/$/, '') + url;
+ }
+ return url;
+};
- return UsersSelect;
- })();
-}).call(window);
+export default UsersSelect;
diff --git a/app/assets/javascripts/version_check_image.js b/app/assets/javascripts/version_check_image.js
index d4f716acb72..88ba991af47 100644
--- a/app/assets/javascripts/version_check_image.js
+++ b/app/assets/javascripts/version_check_image.js
@@ -1,4 +1,4 @@
-class VersionCheckImage {
+export default class VersionCheckImage {
static bindErrorEvent(imageElement) {
imageElement.off('error').on('error', () => imageElement.hide());
}
@@ -6,5 +6,3 @@ class VersionCheckImage {
window.gl = window.gl || {};
gl.VersionCheckImage = VersionCheckImage;
-
-module.exports = VersionCheckImage;
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js
new file mode 100644
index 00000000000..a01cb8cc202
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js
@@ -0,0 +1,23 @@
+export default {
+ name: 'MRWidgetAuthor',
+ props: {
+ author: { type: Object, required: true },
+ showAuthorName: { type: Boolean, required: false, default: true },
+ showAuthorTooltip: { type: Boolean, required: false, default: false },
+ },
+ template: `
+ <a
+ :href="author.webUrl || author.web_url"
+ class="author-link"
+ :class="{ 'has-tooltip': showAuthorTooltip }"
+ :title="author.name">
+ <img
+ :src="author.avatarUrl || author.avatar_url"
+ class="avatar avatar-inline s16" />
+ <span
+ v-if="showAuthorName"
+ class="author">{{author.name}}
+ </span>
+ </a>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js
new file mode 100644
index 00000000000..6d2ed5fda64
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js
@@ -0,0 +1,27 @@
+import MRWidgetAuthor from './mr_widget_author';
+
+export default {
+ name: 'MRWidgetAuthorTime',
+ props: {
+ actionText: { type: String, required: true },
+ author: { type: Object, required: true },
+ dateTitle: { type: String, required: true },
+ dateReadable: { type: String, required: true },
+ },
+ components: {
+ 'mr-widget-author': MRWidgetAuthor,
+ },
+ template: `
+ <h4 class="js-mr-widget-author">
+ {{actionText}}
+ <mr-widget-author :author="author" />
+ <time
+ :title="dateTitle"
+ data-toggle="tooltip"
+ data-placement="top"
+ data-container="body">
+ {{dateReadable}}
+ </time>
+ </h4>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js
new file mode 100644
index 00000000000..e8e22ad93a5
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js
@@ -0,0 +1,116 @@
+/* global Flash */
+
+import '~/lib/utils/datetime_utility';
+import { statusIconEntityMap } from '../../vue_shared/ci_status_icons';
+import MemoryUsage from './mr_widget_memory_usage';
+import MRWidgetService from '../services/mr_widget_service';
+
+export default {
+ name: 'MRWidgetDeployment',
+ props: {
+ mr: { type: Object, required: true },
+ service: { type: Object, required: true },
+ },
+ components: {
+ 'mr-widget-memory-usage': MemoryUsage,
+ },
+ computed: {
+ svg() {
+ return statusIconEntityMap.icon_status_success;
+ },
+ },
+ methods: {
+ formatDate(date) {
+ return gl.utils.getTimeago().format(date);
+ },
+ hasExternalUrls(deployment = {}) {
+ return deployment.external_url && deployment.external_url_formatted;
+ },
+ hasDeploymentTime(deployment = {}) {
+ return deployment.deployed_at && deployment.deployed_at_formatted;
+ },
+ hasDeploymentMeta(deployment = {}) {
+ return deployment.url && deployment.name;
+ },
+ stopEnvironment(deployment) {
+ const msg = 'Are you sure you want to stop this environment?';
+ const isConfirmed = confirm(msg); // eslint-disable-line
+
+ if (isConfirmed) {
+ MRWidgetService.stopEnvironment(deployment.stop_url)
+ .then(res => res.json())
+ .then((res) => {
+ if (res.redirect_url) {
+ gl.utils.visitUrl(res.redirect_url);
+ }
+ })
+ .catch(() => {
+ new Flash('Something went wrong while stopping this environment. Please try again.'); // eslint-disable-line
+ });
+ }
+ },
+ },
+ template: `
+ <div class="mr-widget-heading">
+ <div v-for="deployment in mr.deployments">
+ <div class="ci-widget">
+ <div class="ci-status-icon ci-status-icon-success">
+ <span class="js-icon-link icon-link">
+ <span class="ci-status-icon"
+ v-html="svg"
+ aria-hidden="true"></span>
+ </span>
+ </div>
+ <span>
+ <span
+ v-if="hasDeploymentMeta(deployment)">
+ Deployed to
+ </span>
+ <a
+ v-if="hasDeploymentMeta(deployment)"
+ :href="deployment.url"
+ target="_blank"
+ rel="noopener noreferrer nofollow"
+ class="js-deploy-meta">
+ {{deployment.name}}
+ </a>
+ <span
+ v-if="hasExternalUrls(deployment)">
+ on
+ </span>
+ <a
+ v-if="hasExternalUrls(deployment)"
+ :href="deployment.external_url"
+ target="_blank"
+ rel="noopener noreferrer nofollow"
+ class="js-deploy-url">
+ <i
+ class="fa fa-external-link"
+ aria-hidden="true" />
+ {{deployment.external_url_formatted}}
+ </a>
+ <span
+ v-if="hasDeploymentTime(deployment)"
+ :data-title="deployment.deployed_at_formatted"
+ class="js-deploy-time"
+ data-toggle="tooltip"
+ data-placement="top">
+ {{formatDate(deployment.deployed_at)}}
+ </span>
+ <button
+ type="button"
+ v-if="deployment.stop_url"
+ @click="stopEnvironment(deployment)"
+ class="btn btn-default btn-xs">
+ Stop environment
+ </button>
+ </span>
+ </div>
+ <mr-widget-memory-usage
+ v-if="deployment.metrics_url"
+ :metricsUrl="deployment.metrics_url"
+ />
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js
new file mode 100644
index 00000000000..f8b3fb748ae
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js
@@ -0,0 +1,106 @@
+import '../../lib/utils/text_utility';
+
+export default {
+ name: 'MRWidgetHeader',
+ props: {
+ mr: { type: Object, required: true },
+ },
+ computed: {
+ shouldShowCommitsBehindText() {
+ return this.mr.divergedCommitsCount > 0;
+ },
+ commitsText() {
+ return gl.text.pluralize('commit', this.mr.divergedCommitsCount);
+ },
+ branchNameClipboardData() {
+ // This supports code in app/assets/javascripts/copy_to_clipboard.js that
+ // works around ClipboardJS limitations to allow the context-specific
+ // copy/pasting of plain text or GFM.
+ return JSON.stringify({
+ text: this.mr.sourceBranch,
+ gfm: `\`${this.mr.sourceBranch}\``,
+ });
+ },
+ },
+ methods: {
+ isBranchTitleLong(branchTitle) {
+ return branchTitle.length > 32;
+ },
+ },
+ template: `
+ <div class="mr-source-target">
+ <div
+ v-if="mr.isOpen"
+ class="pull-right">
+ <a
+ href="#modal_merge_info"
+ data-toggle="modal"
+ class="btn inline btn-grouped btn-sm">
+ Check out branch
+ </a>
+ <span class="dropdown inline prepend-left-5">
+ <a
+ class="btn btn-sm dropdown-toggle"
+ data-toggle="dropdown"
+ aria-label="Download as"
+ role="button">
+ <i
+ class="fa fa-download"
+ aria-hidden="true" />
+ <i
+ class="fa fa-caret-down"
+ aria-hidden="true" />
+ </a>
+ <ul class="dropdown-menu dropdown-menu-align-right">
+ <li>
+ <a
+ :href="mr.emailPatchesPath"
+ download>
+ Email patches
+ </a>
+ </li>
+ <li>
+ <a
+ :href="mr.plainDiffPath"
+ download>
+ Plain diff
+ </a>
+ </li>
+ </ul>
+ </span>
+ </div>
+ <div class="normal">
+ <strong>
+ Request to merge
+ <span
+ class="label-branch"
+ :class="{'label-truncated has-tooltip': isBranchTitleLong(mr.sourceBranch)}"
+ :title="isBranchTitleLong(mr.sourceBranch) ? mr.sourceBranch : ''"
+ data-placement="bottom"
+ v-html="mr.sourceBranchLink"></span>
+ <button
+ class="btn btn-transparent btn-clipboard has-tooltip"
+ data-title="Copy branch name to clipboard"
+ :data-clipboard-text="branchNameClipboardData">
+ <i
+ aria-hidden="true"
+ class="fa fa-clipboard"></i>
+ </button>
+ into
+ <span
+ class="label-branch"
+ :class="{'label-truncated has-tooltip': isBranchTitleLong(mr.targetBranch)}"
+ :title="isBranchTitleLong(mr.targetBranch) ? mr.targetBranch : ''"
+ data-placement="bottom">
+ <a :href="mr.targetBranchPath">{{mr.targetBranch}}</a>
+ </span>
+ </strong>
+ <span
+ v-if="shouldShowCommitsBehindText"
+ class="diverged-commits-count">
+ ({{mr.divergedCommitsCount}} {{commitsText}} behind)
+ </span>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js
new file mode 100644
index 00000000000..8155218681c
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js
@@ -0,0 +1,147 @@
+import statusCodes from '~/lib/utils/http_status';
+import { bytesToMiB } from '~/lib/utils/number_utils';
+
+import MemoryGraph from '../../vue_shared/components/memory_graph';
+import MRWidgetService from '../services/mr_widget_service';
+
+export default {
+ name: 'MemoryUsage',
+ props: {
+ metricsUrl: { type: String, required: true },
+ },
+ data() {
+ return {
+ memoryFrom: 0,
+ memoryTo: 0,
+ memoryMetrics: [],
+ deploymentTime: 0,
+ hasMetrics: false,
+ loadFailed: false,
+ loadingMetrics: true,
+ backOffRequestCounter: 0,
+ };
+ },
+ components: {
+ 'mr-memory-graph': MemoryGraph,
+ },
+ computed: {
+ shouldShowLoading() {
+ return this.loadingMetrics && !this.hasMetrics && !this.loadFailed;
+ },
+ shouldShowMemoryGraph() {
+ return !this.loadingMetrics && this.hasMetrics && !this.loadFailed;
+ },
+ shouldShowLoadFailure() {
+ return !this.loadingMetrics && !this.hasMetrics && this.loadFailed;
+ },
+ shouldShowMetricsUnavailable() {
+ return !this.loadingMetrics && !this.hasMetrics && !this.loadFailed;
+ },
+ memoryChangeType() {
+ const memoryTo = Number(this.memoryTo);
+ const memoryFrom = Number(this.memoryFrom);
+
+ if (memoryTo > memoryFrom) {
+ return 'increased';
+ } else if (memoryTo < memoryFrom) {
+ return 'decreased';
+ }
+
+ return 'unchanged';
+ },
+ },
+ methods: {
+ getMegabytes(bytesString) {
+ const valueInBytes = Number(bytesString).toFixed(2);
+ return (bytesToMiB(valueInBytes)).toFixed(2);
+ },
+ computeGraphData(metrics, deploymentTime) {
+ this.loadingMetrics = false;
+ const { memory_before, memory_after, memory_values } = metrics;
+
+ // Both `memory_before` and `memory_after` objects
+ // have peculiar structure where accessing only a specific
+ // index yeilds correct value that we can use to show memory delta.
+ if (memory_before.length > 0) {
+ this.memoryFrom = this.getMegabytes(memory_before[0].value[1]);
+ }
+
+ if (memory_after.length > 0) {
+ this.memoryTo = this.getMegabytes(memory_after[0].value[1]);
+ }
+
+ if (memory_values.length > 0) {
+ this.hasMetrics = true;
+ this.memoryMetrics = memory_values[0].values;
+ this.deploymentTime = deploymentTime;
+ }
+ },
+ loadMetrics() {
+ gl.utils.backOff((next, stop) => {
+ MRWidgetService.fetchMetrics(this.metricsUrl)
+ .then((res) => {
+ if (res.status === statusCodes.NO_CONTENT) {
+ this.backOffRequestCounter = this.backOffRequestCounter += 1;
+ /* eslint-disable no-unused-expressions */
+ this.backOffRequestCounter < 3 ? next() : stop(res);
+ } else {
+ stop(res);
+ }
+ })
+ .catch(stop);
+ })
+ .then((res) => {
+ if (res.status === statusCodes.NO_CONTENT) {
+ return res;
+ }
+
+ return res.json();
+ })
+ .then((res) => {
+ this.computeGraphData(res.metrics, res.deployment_time);
+ return res;
+ })
+ .catch(() => {
+ this.loadFailed = true;
+ this.loadingMetrics = false;
+ });
+ },
+ },
+ mounted() {
+ this.loadingMetrics = true;
+ this.loadMetrics();
+ },
+ template: `
+ <div class="mr-info-list clearfix mr-memory-usage js-mr-memory-usage">
+ <div class="legend"></div>
+ <p
+ v-if="shouldShowLoading"
+ class="usage-info js-usage-info usage-info-loading">
+ <i
+ class="fa fa-spinner fa-spin usage-info-load-spinner"
+ aria-hidden="true" />Loading deployment statistics.
+ </p>
+ <p
+ v-if="shouldShowMemoryGraph"
+ class="usage-info js-usage-info">
+ Memory usage <b>{{memoryChangeType}}</b> from {{memoryFrom}}MB to {{memoryTo}}MB
+ </p>
+ <p
+ v-if="shouldShowLoadFailure"
+ class="usage-info js-usage-info usage-info-failed">
+ Failed to load deployment statistics.
+ </p>
+ <p
+ v-if="shouldShowMetricsUnavailable"
+ class="usage-info js-usage-info usage-info-unavailable">
+ Deployment statistics are not available currently.
+ </p>
+ <mr-memory-graph
+ v-if="shouldShowMemoryGraph"
+ :metrics="memoryMetrics"
+ :deploymentTime="deploymentTime"
+ height="25"
+ width="100" />
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js
new file mode 100644
index 00000000000..2fecebce7a0
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js
@@ -0,0 +1,23 @@
+export default {
+ name: 'MRWidgetMergeHelp',
+ props: {
+ missingBranch: { type: String, required: false, default: '' },
+ },
+ template: `
+ <section class="mr-widget-help">
+ <template
+ v-if="missingBranch">
+ If the {{missingBranch}} branch exists in your local repository, you
+ </template>
+ <template v-else>
+ You
+ </template>
+ can merge this merge request manually using the
+ <a
+ data-toggle="modal"
+ href="#modal_merge_info">
+ command line.
+ </a>
+ </section>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js
new file mode 100644
index 00000000000..c02e10128e2
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js
@@ -0,0 +1,88 @@
+import PipelineStage from '../../pipelines/components/stage.vue';
+import ciIcon from '../../vue_shared/components/ci_icon.vue';
+import { statusIconEntityMap } from '../../vue_shared/ci_status_icons';
+
+export default {
+ name: 'MRWidgetPipeline',
+ props: {
+ mr: { type: Object, required: true },
+ },
+ components: {
+ 'pipeline-stage': PipelineStage,
+ ciIcon,
+ },
+ computed: {
+ hasCIError() {
+ const { hasCI, ciStatus } = this.mr;
+
+ return hasCI && !ciStatus;
+ },
+ svg() {
+ return statusIconEntityMap.icon_status_failed;
+ },
+ stageText() {
+ return this.mr.pipeline.details.stages.length > 1 ? 'stages' : 'stage';
+ },
+ status() {
+ return this.mr.pipeline.details.status || {};
+ },
+ },
+ template: `
+ <div class="mr-widget-heading">
+ <div class="ci-widget">
+ <template v-if="hasCIError">
+ <div class="ci-status-icon ci-status-icon-failed ci-error js-ci-error">
+ <span class="js-icon-link icon-link">
+ <span
+ v-html="svg"
+ aria-hidden="true"></span>
+ </span>
+ </div>
+ <span>Could not connect to the CI server. Please check your settings and try again.</span>
+ </template>
+ <template v-else>
+ <div>
+ <a
+ class="icon-link"
+ :href="this.status.details_path">
+ <ci-icon :status="status" />
+ </a>
+ </div>
+ <span>
+ Pipeline
+ <a
+ :href="mr.pipeline.path"
+ class="pipeline-id">#{{mr.pipeline.id}}</a>
+ {{mr.pipeline.details.status.label}}
+ </span>
+ <span
+ v-if="mr.pipeline.details.stages.length > 0">
+ with {{stageText}}
+ </span>
+ <div class="mr-widget-pipeline-graph">
+ <div class="stage-cell">
+ <div
+ v-if="mr.pipeline.details.stages.length > 0"
+ v-for="stage in mr.pipeline.details.stages"
+ class="stage-container dropdown js-mini-pipeline-graph">
+ <pipeline-stage :stage="stage" />
+ </div>
+ </div>
+ </div>
+ <span>
+ for
+ <a
+ :href="mr.pipeline.commit.commit_path"
+ class="commit-sha js-commit-link">
+ {{mr.pipeline.commit.short_id}}</a>.
+ </span>
+ <span
+ v-if="mr.pipeline.coverage"
+ class="js-mr-coverage">
+ Coverage {{mr.pipeline.coverage}}%.
+ </span>
+ </template>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js
new file mode 100644
index 00000000000..205804670fa
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js
@@ -0,0 +1,42 @@
+export default {
+ name: 'MRWidgetRelatedLinks',
+ props: {
+ relatedLinks: { type: Object, required: true },
+ },
+ computed: {
+ hasLinks() {
+ const { closing, mentioned, assignToMe } = this.relatedLinks;
+ return closing || mentioned || assignToMe;
+ },
+ },
+ methods: {
+ hasMultipleIssues(text) {
+ return !text ? false : text.match(/<\/a> and <a/);
+ },
+ issueLabel(field) {
+ return this.hasMultipleIssues(this.relatedLinks[field]) ? 'issues' : 'issue';
+ },
+ verbLabel(field) {
+ return this.hasMultipleIssues(this.relatedLinks[field]) ? 'are' : 'is';
+ },
+ },
+ template: `
+ <section
+ v-if="hasLinks"
+ class="mr-info-list mr-links">
+ <div class="legend"></div>
+ <p v-if="relatedLinks.closing">
+ Closes {{issueLabel('closing')}}
+ <span v-html="relatedLinks.closing"></span>.
+ </p>
+ <p v-if="relatedLinks.mentioned">
+ <span class="capitalize">{{issueLabel('mentioned')}}</span>
+ <span v-html="relatedLinks.mentioned"></span>
+ {{verbLabel('mentioned')}} mentioned but will not be closed.
+ </p>
+ <p v-if="relatedLinks.assignToMe">
+ <span v-html="relatedLinks.assignToMe"></span>
+ </p>
+ </section>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js
new file mode 100644
index 00000000000..c7f25a1697c
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js
@@ -0,0 +1,16 @@
+export default {
+ name: 'MRWidgetArchived',
+ template: `
+ <div class="mr-widget-body">
+ <button
+ type="button"
+ class="btn btn-success btn-small"
+ disabled="true">
+ Merge
+ </button>
+ <span class="bold">
+ This project is archived, write access has been disabled.
+ </span>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js
new file mode 100644
index 00000000000..4063859d5d0
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js
@@ -0,0 +1,48 @@
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'MRWidgetAutoMergeFailed',
+ props: {
+ mr: { type: Object, required: true },
+ },
+ data() {
+ return {
+ isRefreshing: false,
+ };
+ },
+ methods: {
+ refreshWidget() {
+ this.isRefreshing = true;
+ eventHub.$emit('MRWidgetUpdateRequested', () => {
+ this.isRefreshing = false;
+ });
+ },
+ },
+ template: `
+ <div class="mr-widget-body">
+ <button
+ class="btn btn-success btn-small"
+ disabled="true"
+ type="button">
+ Merge
+ </button>
+ <span class="bold danger">
+ This merge request failed to be merged automatically.
+ <button
+ @click="refreshWidget"
+ :class="{ disabled: isRefreshing }"
+ type="button"
+ class="btn btn-xs btn-default">
+ <i
+ v-if="isRefreshing"
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true" />
+ Refresh
+ </button>
+ </span>
+ <div class="merge-error-text danger bold">
+ {{mr.mergeError}}
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js
new file mode 100644
index 00000000000..8515b54e62d
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js
@@ -0,0 +1,19 @@
+export default {
+ name: 'MRWidgetChecking',
+ template: `
+ <div class="mr-widget-body">
+ <button
+ type="button"
+ class="btn btn-success btn-small"
+ disabled="true">
+ Merge
+ </button>
+ <span class="bold">
+ Checking ability to merge automatically.
+ <i
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true" />
+ </span>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js
new file mode 100644
index 00000000000..fc2e42c6821
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js
@@ -0,0 +1,30 @@
+import mrWidgetAuthorTime from '../../components/mr_widget_author_time';
+
+export default {
+ name: 'MRWidgetClosed',
+ props: {
+ mr: { type: Object, required: true },
+ },
+ components: {
+ 'mr-widget-author-and-time': mrWidgetAuthorTime,
+ },
+ template: `
+ <div class="mr-widget-body">
+ <mr-widget-author-and-time
+ actionText="Closed by"
+ :author="mr.closedBy"
+ :dateTitle="mr.updatedAt"
+ :dateReadable="mr.closedAt"
+ />
+ <section>
+ <p>
+ The changes were not merged into
+ <a
+ :href="mr.targetBranchPath"
+ class="label-branch">
+ {{mr.targetBranch}}</a>.
+ </p>
+ </section>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js
new file mode 100644
index 00000000000..36596c6f37e
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js
@@ -0,0 +1,39 @@
+export default {
+ name: 'MRWidgetConflicts',
+ props: {
+ mr: { type: Object, required: true },
+ },
+ template: `
+ <div class="mr-widget-body">
+ <button
+ type="button"
+ class="btn btn-success btn-small"
+ disabled="true">
+ Merge
+ </button>
+ <span class="bold">
+ There are merge conflicts.
+ <span v-if="!mr.canMerge">
+ Resolve these conflicts or ask someone with write access to this repository to merge it locally.
+ </span>
+ </span>
+ <div
+ v-if="mr.canMerge"
+ class="btn-group">
+ <a
+ v-if="mr.conflictResolutionPath"
+ :href="mr.conflictResolutionPath"
+ class="btn btn-default btn-xs js-resolve-conflicts-button">
+ Resolve conflicts
+ </a>
+ <a
+ v-if="mr.canMerge"
+ class="btn btn-default btn-xs js-merge-locally-button"
+ data-toggle="modal"
+ href="#modal_merge_info">
+ Merge locally
+ </a>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js
new file mode 100644
index 00000000000..600b4d42e3d
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js
@@ -0,0 +1,76 @@
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'MRWidgetFailedToMerge',
+ props: {
+ mr: { type: Object, required: true },
+ },
+ data() {
+ return {
+ timer: 10,
+ isRefreshing: false,
+ };
+ },
+ mounted() {
+ setInterval(() => {
+ this.updateTimer();
+ }, 1000);
+ },
+ created() {
+ eventHub.$emit('DisablePolling');
+ },
+ computed: {
+ timerText() {
+ return this.timer > 1 ? `${this.timer} seconds` : 'a second';
+ },
+ },
+ methods: {
+ refresh() {
+ this.isRefreshing = true;
+ eventHub.$emit('MRWidgetUpdateRequested');
+ eventHub.$emit('EnablePolling');
+ },
+ updateTimer() {
+ this.timer = this.timer - 1;
+
+ if (this.timer === 0) {
+ this.refresh();
+ }
+ },
+ },
+ template: `
+ <div class="mr-widget-body">
+ <button
+ class="btn btn-success btn-small"
+ disabled="true"
+ type="button">
+ Merge
+ </button>
+ <span
+ v-if="!isRefreshing"
+ class="bold danger">
+ <span
+ class="has-error-message"
+ v-if="mr.mergeError">
+ {{mr.mergeError}}
+ </span>
+ <span v-else>Merge failed.</span>
+ <span
+ :class="{ 'has-custom-error': mr.mergeError }">
+ Refreshing in {{timerText}} to show the updated status...
+ </span>
+ <button
+ @click="refresh"
+ class="btn btn-default btn-xs js-refresh-button"
+ type="button">
+ Refresh now
+ </button>
+ </span>
+ <span
+ v-if="isRefreshing"
+ class="bold js-refresh-label">
+ Refreshing now...
+ </span>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js
new file mode 100644
index 00000000000..0bd31731a0b
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js
@@ -0,0 +1,24 @@
+export default {
+ name: 'MRWidgetLocked',
+ props: {
+ mr: { type: Object, required: true },
+ },
+ template: `
+ <div class="mr-widget-body mr-state-locked">
+ <span class="state-label">Locked</span>
+ This merge request is in the process of being merged, during which time it is locked and cannot be closed.
+ <i
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true" />
+ <section class="mr-info-list mr-links">
+ <div class="legend"></div>
+ <p>
+ The changes will be merged into
+ <span class="label-branch">
+ <a :href="mr.targetBranchPath">{{mr.targetBranch}}</a>
+ </span>.
+ </p>
+ </section>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js
new file mode 100644
index 00000000000..419d174f3ff
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js
@@ -0,0 +1,116 @@
+/* global Flash */
+
+import MRWidgetAuthor from '../../components/mr_widget_author';
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'MRWidgetMergeWhenPipelineSucceeds',
+ props: {
+ mr: { type: Object, required: true },
+ service: { type: Object, required: true },
+ },
+ components: {
+ 'mr-widget-author': MRWidgetAuthor,
+ },
+ data() {
+ return {
+ isCancellingAutoMerge: false,
+ isRemovingSourceBranch: false,
+ };
+ },
+ computed: {
+ canRemoveSourceBranch() {
+ const { shouldRemoveSourceBranch, canRemoveSourceBranch,
+ mergeUserId, currentUserId } = this.mr;
+
+ return !shouldRemoveSourceBranch && canRemoveSourceBranch && mergeUserId === currentUserId;
+ },
+ },
+ methods: {
+ cancelAutomaticMerge() {
+ this.isCancellingAutoMerge = true;
+ this.service.cancelAutomaticMerge()
+ .then(res => res.json())
+ .then((res) => {
+ eventHub.$emit('UpdateWidgetData', res);
+ })
+ .catch(() => {
+ this.isCancellingAutoMerge = false;
+ new Flash('Something went wrong. Please try again.'); // eslint-disable-line
+ });
+ },
+ removeSourceBranch() {
+ const options = {
+ sha: this.mr.sha,
+ merge_when_pipeline_succeeds: true,
+ should_remove_source_branch: true,
+ };
+
+ this.isRemovingSourceBranch = true;
+ this.service.mergeResource.save(options)
+ .then(res => res.json())
+ .then((res) => {
+ if (res.status === 'merge_when_pipeline_succeeds') {
+ eventHub.$emit('MRWidgetUpdateRequested');
+ }
+ })
+ .catch(() => {
+ this.isRemovingSourceBranch = false;
+ new Flash('Something went wrong. Please try again.'); // eslint-disable-line
+ });
+ },
+ },
+ template: `
+ <div class="mr-widget-body">
+ <h4>
+ Set by
+ <mr-widget-author :author="mr.setToMWPSBy" />
+ to be merged automatically when the pipeline succeeds.
+ <a
+ v-if="mr.canCancelAutomaticMerge"
+ @click.prevent="cancelAutomaticMerge"
+ :disabled="isCancellingAutoMerge"
+ role="button"
+ href="#"
+ class="btn btn-xs btn-default js-cancel-auto-merge">
+ <i
+ v-if="isCancellingAutoMerge"
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true" />
+ Cancel automatic merge
+ </a>
+ </h4>
+ <section class="mr-info-list">
+ <div class="legend"></div>
+ <p>The changes will be merged into
+ <a
+ :href="mr.targetBranchPath"
+ class="label-branch">
+ {{mr.targetBranch}}
+ </a>.
+ </p>
+ <p v-if="mr.shouldRemoveSourceBranch">
+ The source branch will be removed.
+ </p>
+ <p
+ v-else
+ class="with-button">
+ The source branch will not be removed.
+ <a
+ v-if="canRemoveSourceBranch"
+ :disabled="isRemovingSourceBranch"
+ @click.prevent="removeSourceBranch"
+ role="button"
+ class="btn btn-xs btn-default js-remove-source-branch"
+ href="#">
+ <i
+ v-if="isRemovingSourceBranch"
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true" />
+ Remove source branch
+ </a>
+ </p>
+ </section>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js
new file mode 100644
index 00000000000..c7d32d18141
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js
@@ -0,0 +1,130 @@
+/* global Flash */
+
+import mrWidgetAuthorTime from '../../components/mr_widget_author_time';
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'MRWidgetMerged',
+ props: {
+ mr: { type: Object, required: true },
+ service: { type: Object, required: true },
+ },
+ components: {
+ 'mr-widget-author-and-time': mrWidgetAuthorTime,
+ },
+ data() {
+ return {
+ isMakingRequest: false,
+ };
+ },
+ computed: {
+ shouldShowRemoveSourceBranch() {
+ const { sourceBranchRemoved, isRemovingSourceBranch, canRemoveSourceBranch } = this.mr;
+
+ return !sourceBranchRemoved && canRemoveSourceBranch &&
+ !this.isMakingRequest && !isRemovingSourceBranch;
+ },
+ shouldShowSourceBranchRemoving() {
+ const { sourceBranchRemoved, isRemovingSourceBranch } = this.mr;
+ return !sourceBranchRemoved && (isRemovingSourceBranch || this.isMakingRequest);
+ },
+ shouldShowMergedButtons() {
+ const { canRevertInCurrentMR, canCherryPickInCurrentMR, revertInForkPath,
+ cherryPickInForkPath } = this.mr;
+
+ return canRevertInCurrentMR || canCherryPickInCurrentMR ||
+ revertInForkPath || cherryPickInForkPath;
+ },
+ },
+ methods: {
+ removeSourceBranch() {
+ this.isMakingRequest = true;
+ this.service.removeSourceBranch()
+ .then(res => res.json())
+ .then((res) => {
+ if (res.message === 'Branch was removed') {
+ eventHub.$emit('MRWidgetUpdateRequested', () => {
+ this.isMakingRequest = false;
+ });
+ }
+ })
+ .catch(() => {
+ this.isMakingRequest = false;
+ new Flash('Something went wrong. Please try again.'); // eslint-disable-line
+ });
+ },
+ },
+ template: `
+ <div class="mr-widget-body">
+ <mr-widget-author-and-time
+ actionText="Merged by"
+ :author="mr.mergedBy"
+ :dateTitle="mr.updatedAt"
+ :dateReadable="mr.mergedAt" />
+ <section class="mr-info-list">
+ <div class="legend"></div>
+ <p>
+ The changes were merged into
+ <span class="label-branch">
+ <a :href="mr.targetBranchPath">{{mr.targetBranch}}</a>
+ </span>
+ </p>
+ <p v-if="mr.sourceBranchRemoved">The source branch has been removed.</p>
+ <p v-if="shouldShowRemoveSourceBranch">
+ You can remove source branch now.
+ <button
+ @click="removeSourceBranch"
+ :class="{ disabled: isMakingRequest }"
+ type="button"
+ class="btn btn-xs btn-default js-remove-branch-button">
+ Remove Source Branch
+ </button>
+ </p>
+ <p v-if="shouldShowSourceBranchRemoving">
+ <i
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true" />
+ The source branch is being removed.
+ </p>
+ </section>
+ <div
+ v-if="shouldShowMergedButtons"
+ class="merged-buttons clearfix">
+ <a
+ v-if="mr.canRevertInCurrentMR"
+ class="btn btn-close btn-sm has-tooltip"
+ href="#modal-revert-commit"
+ data-toggle="modal"
+ data-container="body"
+ title="Revert this merge request in a new merge request">
+ Revert
+ </a>
+ <a
+ v-else-if="mr.revertInForkPath"
+ class="btn btn-close btn-sm has-tooltip"
+ data-method="post"
+ :href="mr.revertInForkPath"
+ title="Revert this merge request in a new merge request">
+ Revert
+ </a>
+ <a
+ v-if="mr.canCherryPickInCurrentMR"
+ class="btn btn-default btn-sm has-tooltip"
+ href="#modal-cherry-pick-commit"
+ data-toggle="modal"
+ data-container="body"
+ title="Cherry-pick this merge request in a new merge request">
+ Cherry-pick
+ </a>
+ <a
+ v-else-if="mr.cherryPickInForkPath"
+ class="btn btn-default btn-sm has-tooltip"
+ data-method="post"
+ :href="mr.cherryPickInForkPath"
+ title="Cherry-pick this merge request in a new merge request">
+ Cherry-pick
+ </a>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js
new file mode 100644
index 00000000000..328382485f6
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js
@@ -0,0 +1,34 @@
+import mrWidgetMergeHelp from '../../components/mr_widget_merge_help';
+
+export default {
+ name: 'MRWidgetMissingBranch',
+ props: {
+ mr: { type: Object, required: true },
+ },
+ components: {
+ 'mr-widget-merge-help': mrWidgetMergeHelp,
+ },
+ computed: {
+ missingBranchName() {
+ return this.mr.sourceBranchRemoved ? 'source' : 'target';
+ },
+ },
+ template: `
+ <div class="mr-widget-body">
+ <button
+ type="button"
+ class="btn btn-success btn-small"
+ disabled="true">
+ Merge
+ </button>
+ <span class="bold js-branch-text">
+ <span class="capitalize">
+ {{missingBranchName}}
+ </span> branch does not exist.
+ Please restore the {{missingBranchName}} branch or use a different {{missingBranchName}} branch.
+ </span>
+ <mr-widget-merge-help
+ :missing-branch="missingBranchName" />
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js
new file mode 100644
index 00000000000..07169b349be
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js
@@ -0,0 +1,17 @@
+export default {
+ name: 'MRWidgetNotAllowed',
+ template: `
+ <div class="mr-widget-body">
+ <button
+ type="button"
+ class="btn btn-success btn-small"
+ disabled="true">
+ Merge
+ </button>
+ <span class="bold">
+ Ready to be merged automatically.
+ Ask someone with write access to this repository to merge this request.
+ </span>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js
new file mode 100644
index 00000000000..375a382615a
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js
@@ -0,0 +1,42 @@
+import emptyStateSVG from 'icons/_mr_widget_empty_state.svg';
+
+export default {
+ name: 'MRWidgetNothingToMerge',
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return { emptyStateSVG };
+ },
+ template: `
+ <div class="mr-widget-body empty-state">
+ <div class="row">
+ <div class="artwork col-sm-5 col-sm-push-7 col-xs-12 text-center">
+ <span v-html="emptyStateSVG"></span>
+ </div>
+ <div class="text col-sm-7 col-sm-pull-5 col-xs-12">
+ <span>
+ Merge requests are a place to propose changes you have made to a project
+ and discuss those changes with others.
+ </span>
+ <p>
+ Interested parties can even contribute by pushing commits if they want to.
+ </p>
+ <p>
+ Currently there are no changes in this merge request's source branch.
+ Please push new commits or use a different branch.
+ </p>
+ <a
+ v-if="mr.newBlobPath"
+ :href="mr.newBlobPath"
+ class="btn btn-inverted btn-save">
+ Create file
+ </a>
+ </div>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js
new file mode 100644
index 00000000000..31c53b679ed
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js
@@ -0,0 +1,16 @@
+export default {
+ name: 'MRWidgetPipelineBlocked',
+ template: `
+ <div class="mr-widget-body">
+ <button
+ type="button"
+ class="btn btn-success btn-small"
+ disabled="true">
+ Merge
+ </button>
+ <span class="bold">
+ Pipeline blocked. The pipeline for this merge request requires a manual action to proceed.
+ </span>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js
new file mode 100644
index 00000000000..002820123ca
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js
@@ -0,0 +1,16 @@
+export default {
+ name: 'MRWidgetPipelineBlocked',
+ template: `
+ <div class="mr-widget-body">
+ <button
+ class="btn btn-success btn-small"
+ disabled="true"
+ type="button">
+ Merge
+ </button>
+ <span class="bold">
+ The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure.
+ </span>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js
new file mode 100644
index 00000000000..fcd4fdaf09f
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js
@@ -0,0 +1,313 @@
+/* global Flash */
+
+import successSvg from 'icons/_icon_status_success.svg';
+import warningSvg from 'icons/_icon_status_warning.svg';
+import simplePoll from '~/lib/utils/simple_poll';
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'MRWidgetReadyToMerge',
+ props: {
+ mr: { type: Object, required: true },
+ service: { type: Object, required: true },
+ },
+ data() {
+ return {
+ removeSourceBranch: this.mr.shouldRemoveSourceBranch,
+ mergeWhenBuildSucceeds: false,
+ useCommitMessageWithDescription: false,
+ setToMergeWhenPipelineSucceeds: false,
+ showCommitMessageEditor: false,
+ isMakingRequest: false,
+ isMergingImmediately: false,
+ commitMessage: this.mr.commitMessage,
+ successSvg,
+ warningSvg,
+ };
+ },
+ computed: {
+ commitMessageLinkTitle() {
+ const withDesc = 'Include description in commit message';
+ const withoutDesc = "Don't include description in commit message";
+
+ return this.useCommitMessageWithDescription ? withoutDesc : withDesc;
+ },
+ mergeButtonClass() {
+ const defaultClass = 'btn btn-small btn-success accept-merge-request';
+ const failedClass = `${defaultClass} btn-danger`;
+ const inActionClass = `${defaultClass} btn-info`;
+ const { pipeline, isPipelineActive, isPipelineFailed, hasCI, ciStatus } = this.mr;
+
+ if (hasCI && !ciStatus) {
+ return failedClass;
+ } else if (!pipeline) {
+ return defaultClass;
+ } else if (isPipelineActive) {
+ return inActionClass;
+ } else if (isPipelineFailed) {
+ return failedClass;
+ }
+
+ return defaultClass;
+ },
+ mergeButtonText() {
+ if (this.isMergingImmediately) {
+ return 'Merge in progress';
+ } else if (this.mr.isPipelineActive) {
+ return 'Merge when pipeline succeeds';
+ }
+
+ return 'Merge';
+ },
+ shouldShowMergeOptionsDropdown() {
+ return this.mr.isPipelineActive && !this.mr.onlyAllowMergeIfPipelineSucceeds;
+ },
+ isMergeButtonDisabled() {
+ const { commitMessage } = this;
+ return Boolean(!commitMessage.length
+ || !this.isMergeAllowed()
+ || this.isMakingRequest
+ || this.mr.preventMerge);
+ },
+ isRemoveSourceBranchButtonDisabled() {
+ return this.isMergeButtonDisabled || !this.mr.canRemoveSourceBranch;
+ },
+ shouldShowSquashBeforeMerge() {
+ const { commitsCount, enableSquashBeforeMerge } = this.mr;
+ return enableSquashBeforeMerge && commitsCount > 1;
+ },
+ },
+ methods: {
+ isMergeAllowed() {
+ return !(this.mr.onlyAllowMergeIfPipelineSucceeds && this.mr.isPipelineFailed);
+ },
+ updateCommitMessage() {
+ const cmwd = this.mr.commitMessageWithDescription;
+ this.useCommitMessageWithDescription = !this.useCommitMessageWithDescription;
+ this.commitMessage = this.useCommitMessageWithDescription ? cmwd : this.mr.commitMessage;
+ },
+ toggleCommitMessageEditor() {
+ this.showCommitMessageEditor = !this.showCommitMessageEditor;
+ },
+ handleMergeButtonClick(mergeWhenBuildSucceeds, mergeImmediately) {
+ // TODO: Remove no-param-reassign
+ if (mergeWhenBuildSucceeds === undefined) {
+ mergeWhenBuildSucceeds = this.mr.isPipelineActive; // eslint-disable-line no-param-reassign
+ } else if (mergeImmediately) {
+ this.isMergingImmediately = true;
+ }
+
+ this.setToMergeWhenPipelineSucceeds = mergeWhenBuildSucceeds === true;
+
+ const options = {
+ sha: this.mr.sha,
+ commit_message: this.commitMessage,
+ merge_when_pipeline_succeeds: this.setToMergeWhenPipelineSucceeds,
+ should_remove_source_branch: this.removeSourceBranch === true,
+ };
+
+ // Only truthy in EE extension of this component
+ if (this.setAdditionalParams) {
+ this.setAdditionalParams(options);
+ }
+
+ this.isMakingRequest = true;
+ this.service.merge(options)
+ .then(res => res.json())
+ .then((res) => {
+ const hasError = res.status === 'failed' || res.status === 'hook_validation_error';
+
+ if (res.status === 'merge_when_pipeline_succeeds') {
+ eventHub.$emit('MRWidgetUpdateRequested');
+ } else if (res.status === 'success') {
+ this.initiateMergePolling();
+ } else if (hasError) {
+ eventHub.$emit('FailedToMerge', res.merge_error);
+ }
+ })
+ .catch(() => {
+ this.isMakingRequest = false;
+ new Flash('Something went wrong. Please try again.'); // eslint-disable-line
+ });
+ },
+ initiateMergePolling() {
+ simplePoll((continuePolling, stopPolling) => {
+ this.handleMergePolling(continuePolling, stopPolling);
+ });
+ },
+ handleMergePolling(continuePolling, stopPolling) {
+ this.service.poll()
+ .then(res => res.json())
+ .then((res) => {
+ if (res.state === 'merged') {
+ // If state is merged we should update the widget and stop the polling
+ eventHub.$emit('MRWidgetUpdateRequested');
+ eventHub.$emit('FetchActionsContent');
+ if (window.mergeRequest) {
+ window.mergeRequest.updateStatusText('status-box-open', 'status-box-merged', 'Merged');
+ window.mergeRequest.decreaseCounter();
+ }
+ stopPolling();
+
+ // If user checked remove source branch and we didn't remove the branch yet
+ // we should start another polling for source branch remove process
+ if (this.removeSourceBranch && res.source_branch_exists) {
+ this.initiateRemoveSourceBranchPolling();
+ }
+ } else if (res.merge_error) {
+ eventHub.$emit('FailedToMerge', res.merge_error);
+ stopPolling();
+ } else {
+ // MR is not merged yet, continue polling until the state becomes 'merged'
+ continuePolling();
+ }
+ })
+ .catch(() => {
+ new Flash('Something went wrong while merging this merge request. Please try again.'); // eslint-disable-line
+ });
+ },
+ initiateRemoveSourceBranchPolling() {
+ // We need to show source branch is being removed spinner in another component
+ eventHub.$emit('SetBranchRemoveFlag', [true]);
+
+ simplePoll((continuePolling, stopPolling) => {
+ this.handleRemoveBranchPolling(continuePolling, stopPolling);
+ });
+ },
+ handleRemoveBranchPolling(continuePolling, stopPolling) {
+ this.service.poll()
+ .then(res => res.json())
+ .then((res) => {
+ // If source branch exists then we should continue polling
+ // because removing a source branch is a background task and takes time
+ if (res.source_branch_exists) {
+ continuePolling();
+ } else {
+ // Branch is removed. Update widget, stop polling and hide the spinner
+ eventHub.$emit('MRWidgetUpdateRequested', () => {
+ eventHub.$emit('SetBranchRemoveFlag', [false]);
+ });
+ stopPolling();
+ }
+ })
+ .catch(() => {
+ new Flash('Something went wrong while removing the source branch. Please try again.'); // eslint-disable-line
+ });
+ },
+ },
+ template: `
+ <div class="mr-widget-body">
+ <span class="btn-group">
+ <button
+ @click="handleMergeButtonClick()"
+ :disabled="isMergeButtonDisabled"
+ :class="mergeButtonClass"
+ type="button">
+ <i
+ v-if="isMakingRequest"
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true" />
+ {{mergeButtonText}}
+ </button>
+ <button
+ v-if="shouldShowMergeOptionsDropdown"
+ :disabled="isMergeButtonDisabled"
+ type="button"
+ class="btn btn-small btn-info dropdown-toggle"
+ data-toggle="dropdown">
+ <i
+ class="fa fa-caret-down"
+ aria-hidden="true" />
+ <span class="sr-only">
+ Select merge moment
+ </span>
+ </button>
+ <ul
+ v-if="shouldShowMergeOptionsDropdown"
+ class="dropdown-menu dropdown-menu-right"
+ role="menu">
+ <li>
+ <a
+ @click.prevent="handleMergeButtonClick(true)"
+ class="merge_when_pipeline_succeeds"
+ href="#">
+ <span
+ v-html="successSvg"
+ class="merge-opt-icon"
+ aria-hidden="true"></span>
+ <span class="merge-opt-title">Merge when pipeline succeeds</span>
+ </a>
+ </li>
+ <li>
+ <a
+ @click.prevent="handleMergeButtonClick(false, true)"
+ class="accept-merge-request"
+ href="#">
+ <span
+ v-html="warningSvg"
+ class="merge-opt-icon"
+ aria-hidden="true"></span>
+ <span class="merge-opt-title">Merge immediately</span>
+ </a>
+ </li>
+ </ul>
+ </span>
+ <template v-if="isMergeAllowed()">
+ <label class="spacing">
+ <input
+ id="remove-source-branch-input"
+ v-model="removeSourceBranch"
+ :disabled="isRemoveSourceBranchButtonDisabled"
+ type="checkbox"/> Remove source branch
+ </label>
+
+ <!-- Placeholder for EE extension of this component -->
+ <squash-before-merge
+ v-if="shouldShowSquashBeforeMerge"
+ :mr="mr"
+ :is-merge-button-disabled="isMergeButtonDisabled" />
+
+ <button
+ @click="toggleCommitMessageEditor"
+ :disabled="isMergeButtonDisabled"
+ class="btn btn-default btn-xs"
+ type="button">
+ Modify commit message
+ </button>
+ <div
+ v-if="showCommitMessageEditor"
+ class="prepend-top-default commit-message-editor">
+ <div class="form-group clearfix">
+ <label
+ class="control-label"
+ for="commit-message">
+ Commit message
+ </label>
+ <div class="col-sm-10">
+ <div class="commit-message-container">
+ <div class="max-width-marker"></div>
+ <textarea
+ v-model="commitMessage"
+ class="form-control js-commit-message"
+ required="required"
+ rows="14"
+ name="Commit message"></textarea>
+ </div>
+ <p class="hint">Try to keep the first line under 52 characters and the others under 72.</p>
+ <div class="hint">
+ <a
+ @click.prevent="updateCommitMessage"
+ href="#">{{commitMessageLinkTitle}}</a>
+ </div>
+ </div>
+ </div>
+ </div>
+ </template>
+ <template v-else>
+ <span class="bold">
+ The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure.
+ </span>
+ </template>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js
new file mode 100644
index 00000000000..79f8ef408e6
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js
@@ -0,0 +1,16 @@
+export default {
+ name: 'MRWidgetSHAMismatch',
+ template: `
+ <div class="mr-widget-body">
+ <button
+ type="button"
+ class="btn btn-success btn-small"
+ disabled="true">
+ Merge
+ </button>
+ <span class="bold">
+ The source branch HEAD has recently changed. Please reload the page and review the changes before merging.
+ </span>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js
new file mode 100644
index 00000000000..bf8628d18a6
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js
@@ -0,0 +1,15 @@
+/*
+The squash-before-merge button is EE only, but it's located right in the middle
+of the readyToMerge state component template.
+
+If we didn't declare this component in CE, we'd need to maintain a separate copy
+of the readyToMergeState template in EE, which is pretty big and likely to change.
+
+Instead, in CE, we declare the component, but it's hidden and is configured to do nothing.
+In EE, the configuration extends this object to add a functioning squash-before-merge
+button.
+*/
+
+export default {
+ template: '',
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js
new file mode 100644
index 00000000000..f4ab2d9fa58
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js
@@ -0,0 +1,27 @@
+export default {
+ name: 'MRWidgetUnresolvedDiscussions',
+ props: {
+ mr: { type: Object, required: true },
+ },
+ template: `
+ <div class="mr-widget-body">
+ <button
+ type="button"
+ class="btn btn-success btn-small"
+ disabled="true">
+ Merge
+ </button>
+ <span class="bold">
+ There are unresolved discussions. Please resolve these discussions
+ <span v-if="mr.canCreateIssue">or</span>
+ <span v-else>.</span>
+ </span>
+ <a
+ v-if="mr.createIssueToResolveDiscussionsPath"
+ :href="mr.createIssueToResolveDiscussionsPath"
+ class="btn btn-default btn-xs js-create-issue">
+ Create an issue to resolve them later
+ </a>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js
new file mode 100644
index 00000000000..cb02ffe93bd
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js
@@ -0,0 +1,59 @@
+/* global Flash */
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'MRWidgetWIP',
+ props: {
+ mr: { type: Object, required: true },
+ service: { type: Object, required: true },
+ },
+ data() {
+ return {
+ isMakingRequest: false,
+ };
+ },
+ methods: {
+ removeWIP() {
+ this.isMakingRequest = true;
+ this.service.removeWIP()
+ .then(res => res.json())
+ .then((res) => {
+ eventHub.$emit('UpdateWidgetData', res);
+ new Flash('The merge request can now be merged.', 'notice'); // eslint-disable-line
+ $('.merge-request .detail-page-description .title').text(this.mr.title);
+ })
+ .catch(() => {
+ this.isMakingRequest = false;
+ new Flash('Something went wrong. Please try again.'); // eslint-disable-line
+ });
+ },
+ },
+ template: `
+ <div class="mr-widget-body">
+ <button
+ type="button"
+ class="btn btn-success btn-small"
+ disabled="true">
+ Merge</button>
+ <span class="bold">
+ This merge request is currently Work In Progress and therefore unable to merge
+ </span>
+ <template v-if="mr.removeWIPPath">
+ <i
+ class="fa fa-question-circle has-tooltip"
+ title="When this merge request is ready, remove the WIP: prefix from the title to allow it to be merged." />
+ <button
+ @click="removeWIP"
+ :disabled="isMakingRequest"
+ type="button"
+ class="btn btn-default btn-xs js-remove-wip">
+ <i
+ v-if="isMakingRequest"
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true" />
+ Resolve WIP status
+ </button>
+ </template>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js
new file mode 100644
index 00000000000..fe5e1bbb55c
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js
@@ -0,0 +1,44 @@
+/**
+ * This file is the centerpiece of an attempt to reduce potential conflicts
+ * between the CE and EE versions of the MR widget. EE additions to the MR widget should
+ * be contained in the ./vue_merge_request_widget/ee directory, and should **extend**
+ * rather than mutate CE MR Widget code.
+ *
+ * This file should be the only source of conflicts between EE and CE. EE-only components should
+ * imported directly where they are needed, and import paths for EE extensions of CE components
+ * should overwrite import paths **without** changing the order of dependencies listed here.
+ */
+
+export { default as Vue } from 'vue';
+export { default as SmartInterval } from '~/smart_interval';
+export { default as WidgetHeader } from './components/mr_widget_header';
+export { default as WidgetMergeHelp } from './components/mr_widget_merge_help';
+export { default as WidgetPipeline } from './components/mr_widget_pipeline';
+export { default as WidgetDeployment } from './components/mr_widget_deployment';
+export { default as WidgetRelatedLinks } from './components/mr_widget_related_links';
+export { default as MergedState } from './components/states/mr_widget_merged';
+export { default as FailedToMerge } from './components/states/mr_widget_failed_to_merge';
+export { default as ClosedState } from './components/states/mr_widget_closed';
+export { default as LockedState } from './components/states/mr_widget_locked';
+export { default as WipState } from './components/states/mr_widget_wip';
+export { default as ArchivedState } from './components/states/mr_widget_archived';
+export { default as ConflictsState } from './components/states/mr_widget_conflicts';
+export { default as NothingToMergeState } from './components/states/mr_widget_nothing_to_merge';
+export { default as MissingBranchState } from './components/states/mr_widget_missing_branch';
+export { default as NotAllowedState } from './components/states/mr_widget_not_allowed';
+export { default as ReadyToMergeState } from './components/states/mr_widget_ready_to_merge';
+export { default as SHAMismatchState } from './components/states/mr_widget_sha_mismatch';
+export { default as UnresolvedDiscussionsState } from './components/states/mr_widget_unresolved_discussions';
+export { default as PipelineBlockedState } from './components/states/mr_widget_pipeline_blocked';
+export { default as PipelineFailedState } from './components/states/mr_widget_pipeline_failed';
+export { default as MergeWhenPipelineSucceedsState } from './components/states/mr_widget_merge_when_pipeline_succeeds';
+export { default as AutoMergeFailed } from './components/states/mr_widget_auto_merge_failed';
+export { default as CheckingState } from './components/states/mr_widget_checking';
+export { default as MRWidgetStore } from './stores/mr_widget_store';
+export { default as MRWidgetService } from './services/mr_widget_service';
+export { default as eventHub } from './event_hub';
+export { default as getStateKey } from './stores/get_state_key';
+export { default as mrWidgetOptions } from './mr_widget_options';
+export { default as stateMaps } from './stores/state_maps';
+export { default as SquashBeforeMerge } from './components/states/mr_widget_squash_before_merge';
+export { default as notify } from '../lib/utils/notify';
diff --git a/app/assets/javascripts/vue_merge_request_widget/event_hub.js b/app/assets/javascripts/vue_merge_request_widget/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js
new file mode 100644
index 00000000000..43ef468c303
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/index.js
@@ -0,0 +1,14 @@
+import {
+ Vue,
+ mrWidgetOptions,
+} from './dependencies';
+
+document.addEventListener('DOMContentLoaded', () => {
+ gl.mrWidgetData.gitlabLogo = gon.gitlab_logo;
+
+ const vm = new Vue(mrWidgetOptions);
+
+ window.gl.mrWidget = {
+ checkStatus: vm.checkStatus,
+ };
+});
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js
new file mode 100644
index 00000000000..2339a00ddd0
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js
@@ -0,0 +1,247 @@
+/* global Flash */
+
+import {
+ WidgetHeader,
+ WidgetMergeHelp,
+ WidgetPipeline,
+ WidgetDeployment,
+ WidgetRelatedLinks,
+ MergedState,
+ ClosedState,
+ LockedState,
+ WipState,
+ ArchivedState,
+ ConflictsState,
+ NothingToMergeState,
+ MissingBranchState,
+ NotAllowedState,
+ ReadyToMergeState,
+ SHAMismatchState,
+ UnresolvedDiscussionsState,
+ PipelineBlockedState,
+ PipelineFailedState,
+ FailedToMerge,
+ MergeWhenPipelineSucceedsState,
+ AutoMergeFailed,
+ CheckingState,
+ MRWidgetStore,
+ MRWidgetService,
+ eventHub,
+ stateMaps,
+ SquashBeforeMerge,
+ notify,
+} from './dependencies';
+
+export default {
+ el: '#js-vue-mr-widget',
+ name: 'MRWidget',
+ data() {
+ const store = new MRWidgetStore(gl.mrWidgetData);
+ const service = this.createService(store);
+ return {
+ mr: store,
+ service,
+ };
+ },
+ computed: {
+ componentName() {
+ return stateMaps.stateToComponentMap[this.mr.state];
+ },
+ shouldRenderMergeHelp() {
+ return stateMaps.statesToShowHelpWidget.indexOf(this.mr.state) > -1;
+ },
+ shouldRenderPipelines() {
+ return Object.keys(this.mr.pipeline).length || this.mr.hasCI;
+ },
+ shouldRenderRelatedLinks() {
+ return this.mr.relatedLinks;
+ },
+ shouldRenderDeployments() {
+ return this.mr.deployments.length;
+ },
+ },
+ methods: {
+ createService(store) {
+ const endpoints = {
+ mergePath: store.mergePath,
+ mergeCheckPath: store.mergeCheckPath,
+ cancelAutoMergePath: store.cancelAutoMergePath,
+ removeWIPPath: store.removeWIPPath,
+ sourceBranchPath: store.sourceBranchPath,
+ ciEnvironmentsStatusPath: store.ciEnvironmentsStatusPath,
+ statusPath: store.statusPath,
+ mergeActionsContentPath: store.mergeActionsContentPath,
+ };
+ return new MRWidgetService(endpoints);
+ },
+ checkStatus(cb) {
+ this.service.checkStatus()
+ .then(res => res.json())
+ .then((res) => {
+ this.handleNotification(res);
+ this.mr.setData(res);
+ this.setFavicon();
+
+ if (cb) {
+ cb.call(null, res);
+ }
+ })
+ .catch(() => {
+ new Flash('Something went wrong. Please try again.'); // eslint-disable-line
+ });
+ },
+ initPolling() {
+ this.pollingInterval = new gl.SmartInterval({
+ callback: this.checkStatus,
+ startingInterval: 10000,
+ maxInterval: 30000,
+ hiddenInterval: 120000,
+ incrementByFactorOf: 5000,
+ });
+ },
+ initDeploymentsPolling() {
+ this.deploymentsInterval = new gl.SmartInterval({
+ callback: this.fetchDeployments,
+ startingInterval: 30000,
+ maxInterval: 120000,
+ hiddenInterval: 240000,
+ incrementByFactorOf: 15000,
+ immediateExecution: true,
+ });
+ },
+ setFavicon() {
+ if (this.mr.ciStatusFaviconPath) {
+ gl.utils.setFavicon(this.mr.ciStatusFaviconPath);
+ }
+ },
+ fetchDeployments() {
+ this.service.fetchDeployments()
+ .then(res => res.json())
+ .then((res) => {
+ if (res.length) {
+ this.mr.deployments = res;
+ }
+ })
+ .catch(() => {
+ new Flash('Something went wrong while fetching the environments for this merge request. Please try again.'); // eslint-disable-line
+ });
+ },
+ fetchActionsContent() {
+ this.service.fetchMergeActionsContent()
+ .then((res) => {
+ if (res.body) {
+ const el = document.createElement('div');
+ el.innerHTML = res.body;
+ document.body.appendChild(el);
+ }
+ })
+ .catch(() => {
+ new Flash('Something went wrong. Please try again.'); // eslint-disable-line
+ });
+ },
+ handleNotification(data) {
+ if (data.ci_status === this.mr.ciStatus) return;
+
+ const label = data.pipeline.details.status.label;
+ const title = `Pipeline ${label}`;
+ const message = `Pipeline ${label} for "${data.title}"`;
+
+ notify.notifyMe(title, message, this.mr.gitlabLogo);
+ },
+ resumePolling() {
+ this.pollingInterval.resume();
+ },
+ stopPolling() {
+ this.pollingInterval.stopTimer();
+ },
+ bindEventHubListeners() {
+ eventHub.$on('MRWidgetUpdateRequested', (cb) => {
+ this.checkStatus(cb);
+ });
+
+ // `params` should be an Array contains a Boolean, like `[true]`
+ // Passing parameter as Boolean didn't work.
+ eventHub.$on('SetBranchRemoveFlag', (params) => {
+ this.mr.isRemovingSourceBranch = params[0];
+ });
+
+ eventHub.$on('FailedToMerge', (mergeError) => {
+ this.mr.state = 'failedToMerge';
+ this.mr.mergeError = mergeError;
+ });
+
+ eventHub.$on('UpdateWidgetData', (data) => {
+ this.mr.setData(data);
+ });
+
+ eventHub.$on('FetchActionsContent', () => {
+ this.fetchActionsContent();
+ });
+
+ eventHub.$on('EnablePolling', () => {
+ this.resumePolling();
+ });
+
+ eventHub.$on('DisablePolling', () => {
+ this.stopPolling();
+ });
+ },
+ handleMounted() {
+ this.setFavicon();
+ this.initDeploymentsPolling();
+ },
+ },
+ created() {
+ this.initPolling();
+ this.bindEventHubListeners();
+ },
+ mounted() {
+ this.handleMounted();
+ },
+ components: {
+ 'mr-widget-header': WidgetHeader,
+ 'mr-widget-merge-help': WidgetMergeHelp,
+ 'mr-widget-pipeline': WidgetPipeline,
+ 'mr-widget-deployment': WidgetDeployment,
+ 'mr-widget-related-links': WidgetRelatedLinks,
+ 'mr-widget-merged': MergedState,
+ 'mr-widget-closed': ClosedState,
+ 'mr-widget-locked': LockedState,
+ 'mr-widget-failed-to-merge': FailedToMerge,
+ 'mr-widget-wip': WipState,
+ 'mr-widget-archived': ArchivedState,
+ 'mr-widget-conflicts': ConflictsState,
+ 'mr-widget-nothing-to-merge': NothingToMergeState,
+ 'mr-widget-not-allowed': NotAllowedState,
+ 'mr-widget-missing-branch': MissingBranchState,
+ 'mr-widget-ready-to-merge': ReadyToMergeState,
+ 'mr-widget-sha-mismatch': SHAMismatchState,
+ 'mr-widget-squash-before-merge': SquashBeforeMerge,
+ 'mr-widget-checking': CheckingState,
+ 'mr-widget-unresolved-discussions': UnresolvedDiscussionsState,
+ 'mr-widget-pipeline-blocked': PipelineBlockedState,
+ 'mr-widget-pipeline-failed': PipelineFailedState,
+ 'mr-widget-merge-when-pipeline-succeeds': MergeWhenPipelineSucceedsState,
+ 'mr-widget-auto-merge-failed': AutoMergeFailed,
+ },
+ template: `
+ <div class="mr-state-widget prepend-top-default">
+ <mr-widget-header :mr="mr" />
+ <mr-widget-pipeline
+ v-if="shouldRenderPipelines"
+ :mr="mr" />
+ <mr-widget-deployment
+ v-if="shouldRenderDeployments"
+ :mr="mr"
+ :service="service" />
+ <component
+ :is="componentName"
+ :mr="mr"
+ :service="service" />
+ <mr-widget-related-links
+ v-if="shouldRenderRelatedLinks"
+ :related-links="mr.relatedLinks" />
+ <mr-widget-merge-help v-if="shouldRenderMergeHelp" />
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
new file mode 100644
index 00000000000..79c3d335679
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
@@ -0,0 +1,57 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+Vue.use(VueResource);
+
+export default class MRWidgetService {
+ constructor(endpoints) {
+ this.mergeResource = Vue.resource(endpoints.mergePath);
+ this.mergeCheckResource = Vue.resource(endpoints.statusPath);
+ this.cancelAutoMergeResource = Vue.resource(endpoints.cancelAutoMergePath);
+ this.removeWIPResource = Vue.resource(endpoints.removeWIPPath);
+ this.removeSourceBranchResource = Vue.resource(endpoints.sourceBranchPath);
+ this.deploymentsResource = Vue.resource(endpoints.ciEnvironmentsStatusPath);
+ this.pollResource = Vue.resource(`${endpoints.statusPath}?basic=true`);
+ this.mergeActionsContentResource = Vue.resource(endpoints.mergeActionsContentPath);
+ }
+
+ merge(data) {
+ return this.mergeResource.save(data);
+ }
+
+ cancelAutomaticMerge() {
+ return this.cancelAutoMergeResource.save();
+ }
+
+ removeWIP() {
+ return this.removeWIPResource.save();
+ }
+
+ removeSourceBranch() {
+ return this.removeSourceBranchResource.delete();
+ }
+
+ fetchDeployments() {
+ return this.deploymentsResource.get();
+ }
+
+ poll() {
+ return this.pollResource.get();
+ }
+
+ checkStatus() {
+ return this.mergeCheckResource.get();
+ }
+
+ fetchMergeActionsContent() {
+ return this.mergeActionsContentResource.get();
+ }
+
+ static stopEnvironment(url) {
+ return Vue.http.post(url);
+ }
+
+ static fetchMetrics(metricsUrl) {
+ return Vue.http.get(`${metricsUrl}.json`);
+ }
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
new file mode 100644
index 00000000000..7c15abfff10
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
@@ -0,0 +1,30 @@
+export default function deviseState(data) {
+ if (data.project_archived) {
+ return 'archived';
+ } else if (data.branch_missing) {
+ return 'missingBranch';
+ } else if (!data.commits_count) {
+ return 'nothingToMerge';
+ } else if (this.mergeStatus === 'unchecked') {
+ return 'checking';
+ } else if (data.has_conflicts) {
+ return 'conflicts';
+ } else if (data.work_in_progress) {
+ return 'workInProgress';
+ } else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) {
+ return 'pipelineFailed';
+ } else if (this.hasMergeableDiscussionsState) {
+ return 'unresolvedDiscussions';
+ } else if (this.isPipelineBlocked) {
+ return 'pipelineBlocked';
+ } else if (this.hasSHAChanged) {
+ return 'shaMismatch';
+ } else if (this.mergeWhenPipelineSucceeds) {
+ return this.mergeError ? 'autoMergeFailed' : 'mergeWhenPipelineSucceeds';
+ } else if (!this.canMerge) {
+ return 'notAllowedToMerge';
+ } else if (this.canBeMerged) {
+ return 'readyToMerge';
+ }
+ return null;
+}
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
new file mode 100644
index 00000000000..69bc1436284
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -0,0 +1,138 @@
+import Timeago from 'timeago.js';
+import { getStateKey } from '../dependencies';
+
+export default class MergeRequestStore {
+
+ constructor(data) {
+ this.sha = data.diff_head_sha;
+ this.gitlabLogo = data.gitlabLogo;
+
+ this.setData(data);
+ }
+
+ setData(data) {
+ const currentUser = data.current_user;
+ const pipelineStatus = data.pipeline ? data.pipeline.details.status : null;
+
+ this.title = data.title;
+ this.targetBranch = data.target_branch;
+ this.sourceBranch = data.source_branch;
+ this.mergeStatus = data.merge_status;
+ this.commitMessage = data.merge_commit_message;
+ this.commitMessageWithDescription = data.merge_commit_message_with_description;
+ this.commitsCount = data.commits_count;
+ this.divergedCommitsCount = data.diverged_commits_count;
+ this.pipeline = data.pipeline || {};
+ this.deployments = this.deployments || data.deployments || [];
+
+ if (data.issues_links) {
+ const links = data.issues_links;
+ const { closing } = links;
+ const mentioned = links.mentioned_but_not_closing;
+ const assignToMe = links.assign_to_closing;
+
+ if (closing || mentioned || assignToMe) {
+ this.relatedLinks = { closing, mentioned, assignToMe };
+ }
+ }
+
+ this.updatedAt = data.updated_at;
+ this.mergedAt = MergeRequestStore.getEventDate(data.merge_event);
+ this.closedAt = MergeRequestStore.getEventDate(data.closed_event);
+ this.mergedBy = MergeRequestStore.getAuthorObject(data.merge_event);
+ this.closedBy = MergeRequestStore.getAuthorObject(data.closed_event);
+ this.setToMWPSBy = MergeRequestStore.getAuthorObject({ author: data.merge_user || {} });
+ this.mergeUserId = data.merge_user_id;
+ this.currentUserId = gon.current_user_id;
+ this.sourceBranchPath = data.source_branch_path;
+ this.sourceBranchLink = data.source_branch_with_namespace_link;
+ this.mergeError = data.merge_error;
+ this.targetBranchPath = data.target_branch_commits_path;
+ this.conflictResolutionPath = data.conflict_resolution_path;
+ this.cancelAutoMergePath = data.cancel_merge_when_pipeline_succeeds_path;
+ this.removeWIPPath = data.remove_wip_path;
+ this.sourceBranchRemoved = !data.source_branch_exists;
+ this.shouldRemoveSourceBranch = data.remove_source_branch || false;
+ this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false;
+ this.mergeWhenPipelineSucceeds = data.merge_when_pipeline_succeeds || false;
+ this.mergePath = data.merge_path;
+ this.statusPath = data.status_path;
+ this.emailPatchesPath = data.email_patches_path;
+ this.plainDiffPath = data.plain_diff_path;
+ this.newBlobPath = data.new_blob_path;
+ this.createIssueToResolveDiscussionsPath = data.create_issue_to_resolve_discussions_path;
+ this.mergeCheckPath = data.merge_check_path;
+ this.mergeActionsContentPath = data.commit_change_content_path;
+ this.isRemovingSourceBranch = this.isRemovingSourceBranch || false;
+ this.isOpen = data.state === 'opened' || data.state === 'reopened' || false;
+ this.hasMergeableDiscussionsState = data.mergeable_discussions_state === false;
+ this.canRemoveSourceBranch = currentUser.can_remove_source_branch || false;
+ this.canMerge = !!data.merge_path;
+ this.canCreateIssue = currentUser.can_create_issue || false;
+ this.canCancelAutomaticMerge = !!data.cancel_merge_when_pipeline_succeeds_path;
+ this.hasSHAChanged = this.sha !== data.diff_head_sha;
+ this.canBeMerged = data.can_be_merged || false;
+
+ // Cherry-pick and Revert actions related
+ this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false;
+ this.canRevertInCurrentMR = currentUser.can_revert_on_current_merge_request || false;
+ this.cherryPickInForkPath = currentUser.cherry_pick_in_fork_path;
+ this.revertInForkPath = currentUser.revert_in_fork_path;
+
+ // CI related
+ this.ciEnvironmentsStatusPath = data.ci_environments_status_path;
+ this.hasCI = data.has_ci;
+ this.ciStatus = data.ci_status;
+ this.isPipelineFailed = this.ciStatus ? (this.ciStatus === 'failed' || this.ciStatus === 'canceled') : false;
+ this.pipelineDetailedStatus = pipelineStatus;
+ this.isPipelineActive = data.pipeline ? data.pipeline.active : false;
+ this.isPipelineBlocked = pipelineStatus ? pipelineStatus.group === 'manual' : false;
+ this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null;
+
+ this.setState(data);
+ }
+
+ setState(data) {
+ if (this.isOpen) {
+ this.state = getStateKey.call(this, data);
+ } else {
+ switch (data.state) {
+ case 'merged':
+ this.state = 'merged';
+ break;
+ case 'closed':
+ this.state = 'closed';
+ break;
+ case 'locked':
+ this.state = 'locked';
+ break;
+ default:
+ this.state = null;
+ }
+ }
+ }
+
+ static getAuthorObject(event) {
+ if (!event) {
+ return {};
+ }
+
+ return {
+ name: event.author.name || '',
+ username: event.author.username || '',
+ webUrl: event.author.web_url || '',
+ avatarUrl: event.author.avatar_url || '',
+ };
+ }
+
+ static getEventDate(event) {
+ const timeagoInstance = new Timeago();
+
+ if (!event) {
+ return '';
+ }
+
+ return timeagoInstance.format(event.updated_at);
+ }
+
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
new file mode 100644
index 00000000000..605dd3a1ff4
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
@@ -0,0 +1,37 @@
+const stateToComponentMap = {
+ merged: 'mr-widget-merged',
+ closed: 'mr-widget-closed',
+ locked: 'mr-widget-locked',
+ conflicts: 'mr-widget-conflicts',
+ missingBranch: 'mr-widget-missing-branch',
+ workInProgress: 'mr-widget-wip',
+ readyToMerge: 'mr-widget-ready-to-merge',
+ nothingToMerge: 'mr-widget-nothing-to-merge',
+ notAllowedToMerge: 'mr-widget-not-allowed',
+ archived: 'mr-widget-archived',
+ checking: 'mr-widget-checking',
+ unresolvedDiscussions: 'mr-widget-unresolved-discussions',
+ pipelineBlocked: 'mr-widget-pipeline-blocked',
+ pipelineFailed: 'mr-widget-pipeline-failed',
+ mergeWhenPipelineSucceeds: 'mr-widget-merge-when-pipeline-succeeds',
+ failedToMerge: 'mr-widget-failed-to-merge',
+ autoMergeFailed: 'mr-widget-auto-merge-failed',
+ shaMismatch: 'mr-widget-sha-mismatch',
+};
+
+const statesToShowHelpWidget = [
+ 'locked',
+ 'conflicts',
+ 'workInProgress',
+ 'readyToMerge',
+ 'checking',
+ 'unresolvedDiscussions',
+ 'pipelineFailed',
+ 'pipelineBlocked',
+ 'autoMergeFailed',
+];
+
+export default {
+ stateToComponentMap,
+ statesToShowHelpWidget,
+};
diff --git a/app/assets/javascripts/vue_shared/ci_action_icons.js b/app/assets/javascripts/vue_shared/ci_action_icons.js
new file mode 100644
index 00000000000..b21f0ab49fd
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/ci_action_icons.js
@@ -0,0 +1,21 @@
+import cancelSVG from 'icons/_icon_action_cancel.svg';
+import retrySVG from 'icons/_icon_action_retry.svg';
+import playSVG from 'icons/_icon_action_play.svg';
+import stopSVG from 'icons/_icon_action_stop.svg';
+
+/**
+ * For the provided action returns the respective SVG
+ *
+ * @param {String} action
+ * @return {SVG|String}
+ */
+export default function getActionIcon(action) {
+ const icons = {
+ icon_action_cancel: cancelSVG,
+ icon_action_play: playSVG,
+ icon_action_retry: retrySVG,
+ icon_action_stop: stopSVG,
+ };
+
+ return icons[action] || '';
+}
diff --git a/app/assets/javascripts/vue_shared/ci_status_icons.js b/app/assets/javascripts/vue_shared/ci_status_icons.js
new file mode 100644
index 00000000000..d9d0cad38e4
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/ci_status_icons.js
@@ -0,0 +1,43 @@
+import BORDERLESS_CANCELED_SVG from 'icons/_icon_status_canceled_borderless.svg';
+import BORDERLESS_CREATED_SVG from 'icons/_icon_status_created_borderless.svg';
+import BORDERLESS_FAILED_SVG from 'icons/_icon_status_failed_borderless.svg';
+import BORDERLESS_MANUAL_SVG from 'icons/_icon_status_manual_borderless.svg';
+import BORDERLESS_PENDING_SVG from 'icons/_icon_status_pending_borderless.svg';
+import BORDERLESS_RUNNING_SVG from 'icons/_icon_status_running_borderless.svg';
+import BORDERLESS_SKIPPED_SVG from 'icons/_icon_status_skipped_borderless.svg';
+import BORDERLESS_SUCCESS_SVG from 'icons/_icon_status_success_borderless.svg';
+import BORDERLESS_WARNING_SVG from 'icons/_icon_status_warning_borderless.svg';
+
+import CANCELED_SVG from 'icons/_icon_status_canceled.svg';
+import CREATED_SVG from 'icons/_icon_status_created.svg';
+import FAILED_SVG from 'icons/_icon_status_failed.svg';
+import MANUAL_SVG from 'icons/_icon_status_manual.svg';
+import PENDING_SVG from 'icons/_icon_status_pending.svg';
+import RUNNING_SVG from 'icons/_icon_status_running.svg';
+import SKIPPED_SVG from 'icons/_icon_status_skipped.svg';
+import SUCCESS_SVG from 'icons/_icon_status_success.svg';
+import WARNING_SVG from 'icons/_icon_status_warning.svg';
+
+export const borderlessStatusIconEntityMap = {
+ icon_status_canceled: BORDERLESS_CANCELED_SVG,
+ icon_status_created: BORDERLESS_CREATED_SVG,
+ icon_status_failed: BORDERLESS_FAILED_SVG,
+ icon_status_manual: BORDERLESS_MANUAL_SVG,
+ icon_status_pending: BORDERLESS_PENDING_SVG,
+ icon_status_running: BORDERLESS_RUNNING_SVG,
+ icon_status_skipped: BORDERLESS_SKIPPED_SVG,
+ icon_status_success: BORDERLESS_SUCCESS_SVG,
+ icon_status_warning: BORDERLESS_WARNING_SVG,
+};
+
+export const statusIconEntityMap = {
+ icon_status_canceled: CANCELED_SVG,
+ icon_status_created: CREATED_SVG,
+ icon_status_failed: FAILED_SVG,
+ icon_status_manual: MANUAL_SVG,
+ icon_status_pending: PENDING_SVG,
+ icon_status_running: RUNNING_SVG,
+ icon_status_skipped: SKIPPED_SVG,
+ icon_status_success: SUCCESS_SVG,
+ icon_status_warning: WARNING_SVG,
+};
diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
new file mode 100644
index 00000000000..caa28bff6db
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
@@ -0,0 +1,52 @@
+<script>
+import ciIcon from './ci_icon.vue';
+/**
+ * Renders CI Badge link with CI icon and status text based on
+ * API response shared between all places where it is used.
+ *
+ * Receives status object containing:
+ * status: {
+ * details_path: "/gitlab-org/gitlab-ce/pipelines/8150156" // url
+ * group:"running" // used for CSS class
+ * icon: "icon_status_running" // used to render the icon
+ * label:"running" // used for potential tooltip
+ * text:"running" // text rendered
+ * }
+ *
+ * Used in:
+ * - Pipelines table - first column
+ * - Jobs table - first column
+ * - Pipeline show view - header
+ * - Job show view - header
+ * - MR widget
+ */
+
+export default {
+ props: {
+ status: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ components: {
+ ciIcon,
+ },
+
+ computed: {
+ cssClass() {
+ const className = this.status.group;
+
+ return className ? `ci-status ci-${this.status.group}` : 'ci-status';
+ },
+ },
+};
+</script>
+<template>
+ <a
+ :href="status.details_path"
+ :class="cssClass">
+ <ci-icon :status="status" />
+ {{status.text}}
+ </a>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue
new file mode 100644
index 00000000000..ec88119e16c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue
@@ -0,0 +1,50 @@
+<script>
+ import { statusIconEntityMap } from '../ci_status_icons';
+
+ /**
+ * Renders CI icon based on API response shared between all places where it is used.
+ *
+ * Receives status object containing:
+ * status: {
+ * details_path: "/gitlab-org/gitlab-ce/pipelines/8150156" // url
+ * group:"running" // used for CSS class
+ * icon: "icon_status_running" // used to render the icon
+ * label:"running" // used for potential tooltip
+ * text:"running" // text rendered
+ * }
+ *
+ * Used in:
+ * - Pipelines table Badge
+ * - Pipelines table mini graph
+ * - Pipeline graph
+ * - Pipeline show view badge
+ * - Jobs table
+ * - Jobs show view header
+ * - Jobs show view sidebar
+ */
+ export default {
+ props: {
+ status: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ computed: {
+ statusIconSvg() {
+ return statusIconEntityMap[this.status.icon];
+ },
+
+ cssClass() {
+ const status = this.status.group;
+ return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status}`;
+ },
+ },
+ };
+</script>
+<template>
+ <span
+ :class="cssClass"
+ v-html="statusIconSvg">
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/commit.js b/app/assets/javascripts/vue_shared/components/commit.js
index fb68abd95a2..23bc5fbc034 100644
--- a/app/assets/javascripts/vue_shared/components/commit.js
+++ b/app/assets/javascripts/vue_shared/components/commit.js
@@ -1,4 +1,5 @@
import commitIconSvg from 'icons/_icon_commit.svg';
+import userAvatarLink from './user_avatar/user_avatar_link.vue';
export default {
props: {
@@ -110,6 +111,9 @@ export default {
return { commitIconSvg };
},
+ components: {
+ userAvatarLink,
+ },
template: `
<div class="branch-commit">
@@ -119,30 +123,28 @@ export default {
</div>
<a v-if="hasCommitRef"
- class="monospace branch-name"
+ class="ref-name"
:href="commitRef.ref_url">
{{commitRef.name}}
</a>
<div v-html="commitIconSvg" class="commit-icon js-commit-icon"></div>
- <a class="commit-id monospace"
+ <a class="commit-sha"
:href="commitUrl">
{{shortSha}}
</a>
<p class="commit-title">
<span v-if="title">
- <a v-if="hasAuthor"
+ <user-avatar-link
+ v-if="hasAuthor"
class="avatar-image-container"
- :href="author.web_url">
- <img
- class="avatar has-tooltip s20"
- :src="author.avatar_url"
- :alt="userImageAltDescription"
- :title="author.username" />
- </a>
-
+ :link-href="author.web_url"
+ :img-src="author.avatar_url"
+ :img-alt="userImageAltDescription"
+ :tooltip-text="author.username"
+ />
<a class="commit-row-message"
:href="commitUrl">
{{title}}
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
new file mode 100644
index 00000000000..fd0dcd716d6
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -0,0 +1,122 @@
+<script>
+import ciIconBadge from './ci_badge_link.vue';
+import timeagoTooltip from './time_ago_tooltip.vue';
+import tooltipMixin from '../mixins/tooltip';
+import userAvatarLink from './user_avatar/user_avatar_link.vue';
+
+/**
+ * Renders header component for job and pipeline page based on UI mockups
+ *
+ * Used in:
+ * - job show page
+ * - pipeline show page
+ */
+export default {
+ props: {
+ status: {
+ type: Object,
+ required: true,
+ },
+ itemName: {
+ type: String,
+ required: true,
+ },
+ itemId: {
+ type: Number,
+ required: true,
+ },
+ time: {
+ type: String,
+ required: true,
+ },
+ user: {
+ type: Object,
+ required: true,
+ },
+ actions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+
+ mixins: [
+ tooltipMixin,
+ ],
+
+ components: {
+ ciIconBadge,
+ timeagoTooltip,
+ userAvatarLink,
+ },
+
+ computed: {
+ userAvatarAltText() {
+ return `${this.user.name}'s avatar`;
+ },
+ },
+
+ methods: {
+ onClickAction(action) {
+ this.$emit('postAction', action);
+ },
+ },
+};
+</script>
+<template>
+ <header class="page-content-header top-area">
+ <section class="header-main-content">
+
+ <ci-icon-badge :status="status" />
+
+ <strong>
+ {{itemName}} #{{itemId}}
+ </strong>
+
+ triggered
+
+ <timeago-tooltip :time="time" />
+
+ by
+
+ <user-avatar-link
+ :link-href="user.web_url"
+ :img-src="user.avatar_url"
+ :img-alt="userAvatarAltText"
+ :tooltip-text="user.name"
+ :img-size="24"
+ />
+
+ <a
+ :href="user.web_url"
+ :title="user.email"
+ class="js-user-link commit-committer-link"
+ ref="tooltip">
+ {{user.name}}
+ </a>
+ </section>
+
+ <section
+ class="header-action-button nav-controls"
+ v-if="actions.length">
+ <template
+ v-for="action in actions">
+ <a
+ v-if="action.type === 'link'"
+ :href="action.path"
+ :class="action.cssClass">
+ {{action.label}}
+ </a>
+
+ <button
+ v-else="action.type === 'button'"
+ @click="onClickAction(action)"
+ :class="action.cssClass"
+ type="button">
+ {{action.label}}
+ </button>
+
+ </template>
+ </section>
+ </header>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/loading_icon.vue b/app/assets/javascripts/vue_shared/components/loading_icon.vue
new file mode 100644
index 00000000000..41b1d0165b0
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/loading_icon.vue
@@ -0,0 +1,33 @@
+<script>
+ export default {
+ props: {
+ label: {
+ type: String,
+ required: false,
+ default: 'Loading',
+ },
+
+ size: {
+ type: String,
+ required: false,
+ default: '1',
+ },
+ },
+
+ computed: {
+ cssClass() {
+ return `fa-${this.size}x`;
+ },
+ },
+ };
+</script>
+<template>
+ <div class="text-center">
+ <i
+ class="fa fa-spin fa-spinner"
+ :class="cssClass"
+ aria-hidden="true"
+ :aria-label="label">
+ </i>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
new file mode 100644
index 00000000000..e6977681e96
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -0,0 +1,107 @@
+<script>
+ /* global Flash */
+ import markdownHeader from './header.vue';
+ import markdownToolbar from './toolbar.vue';
+
+ export default {
+ props: {
+ markdownPreviewUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ markdownDocs: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ markdownPreview: '',
+ markdownPreviewLoading: false,
+ previewMarkdown: false,
+ };
+ },
+ components: {
+ markdownHeader,
+ markdownToolbar,
+ },
+ methods: {
+ toggleMarkdownPreview() {
+ this.previewMarkdown = !this.previewMarkdown;
+
+ if (!this.previewMarkdown) {
+ this.markdownPreview = '';
+ } else {
+ this.markdownPreviewLoading = true;
+ this.$http.post(
+ this.markdownPreviewUrl,
+ {
+ /*
+ Can't use `$refs` as the component is technically in the parent component
+ so we access the VNode & then get the element
+ */
+ text: this.$slots.textarea[0].elm.value,
+ },
+ )
+ .then((res) => {
+ const data = res.json();
+
+ this.markdownPreviewLoading = false;
+ this.markdownPreview = data.body;
+
+ this.$nextTick(() => {
+ $(this.$refs['markdown-preview']).renderGFM();
+ });
+ })
+ .catch(() => new Flash('Error loading markdown preview'));
+ }
+ },
+ },
+ mounted() {
+ /*
+ GLForm class handles all the toolbar buttons
+ */
+ return new gl.GLForm($(this.$refs['gl-form']), true);
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="md-area prepend-top-default append-bottom-default js-vue-markdown-field"
+ ref="gl-form">
+ <markdown-header
+ :preview-markdown="previewMarkdown"
+ @toggle-markdown="toggleMarkdownPreview" />
+ <div
+ class="md-write-holder"
+ v-show="!previewMarkdown">
+ <div class="zen-backdrop">
+ <slot name="textarea"></slot>
+ <a
+ class="zen-control zen-control-leave js-zen-leave"
+ href="#"
+ aria-label="Enter zen mode">
+ <i
+ class="fa fa-compress"
+ aria-hidden="true">
+ </i>
+ </a>
+ <markdown-toolbar
+ :markdown-docs="markdownDocs" />
+ </div>
+ </div>
+ <div
+ class="md md-preview-holder md-preview"
+ v-show="previewMarkdown">
+ <div
+ ref="markdown-preview"
+ v-html="markdownPreview">
+ </div>
+ <span v-if="markdownPreviewLoading">
+ Loading...
+ </span>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
new file mode 100644
index 00000000000..1a11f493b7f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -0,0 +1,113 @@
+<script>
+ import tooltipMixin from '../../mixins/tooltip';
+ import toolbarButton from './toolbar_button.vue';
+
+ export default {
+ mixins: [
+ tooltipMixin,
+ ],
+ props: {
+ previewMarkdown: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ components: {
+ toolbarButton,
+ },
+ methods: {
+ toggleMarkdownPreview(e, form) {
+ if (form && !form.find('.js-vue-markdown-field').length) {
+ return;
+ } else if (e.target.blur) {
+ e.target.blur();
+ }
+
+ this.$emit('toggle-markdown');
+ },
+ },
+ mounted() {
+ $(document).on('markdown-preview:show.vue', this.toggleMarkdownPreview);
+ $(document).on('markdown-preview:hide.vue', this.toggleMarkdownPreview);
+ },
+ beforeDestroy() {
+ $(document).on('markdown-preview:show.vue', this.toggleMarkdownPreview);
+ $(document).off('markdown-preview:hide.vue', this.toggleMarkdownPreview);
+ },
+ };
+</script>
+
+<template>
+ <div class="md-header">
+ <ul class="nav-links clearfix">
+ <li :class="{ active: !previewMarkdown }">
+ <a
+ href="#md-write-holder"
+ tabindex="-1"
+ @click.prevent="toggleMarkdownPreview($event)">
+ Write
+ </a>
+ </li>
+ <li :class="{ active: previewMarkdown }">
+ <a
+ href="#md-preview-holder"
+ tabindex="-1"
+ @click.prevent="toggleMarkdownPreview($event)">
+ Preview
+ </a>
+ </li>
+ <li class="pull-right">
+ <div class="toolbar-group">
+ <toolbar-button
+ tag="**"
+ button-title="Add bold text"
+ icon="bold" />
+ <toolbar-button
+ tag="*"
+ button-title="Add italic text"
+ icon="italic" />
+ <toolbar-button
+ tag="> "
+ :prepend="true"
+ button-title="Insert a quote"
+ icon="quote-right" />
+ <toolbar-button
+ tag="`"
+ tag-block="```"
+ button-title="Insert code"
+ icon="code" />
+ <toolbar-button
+ tag="* "
+ :prepend="true"
+ button-title="Add a bullet list"
+ icon="list-ul" />
+ <toolbar-button
+ tag="1. "
+ :prepend="true"
+ button-title="Add a numbered list"
+ icon="list-ol" />
+ <toolbar-button
+ tag="* [ ] "
+ :prepend="true"
+ button-title="Add a task list"
+ icon="check-square-o" />
+ </div>
+ <div class="toolbar-group">
+ <button
+ aria-label="Go full screen"
+ class="toolbar-btn js-zen-enter"
+ data-container="body"
+ tabindex="-1"
+ title="Go full screen"
+ type="button"
+ ref="tooltip">
+ <i
+ aria-hidden="true"
+ class="fa fa-arrows-alt fa-fw">
+ </i>
+ </button>
+ </div>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
new file mode 100644
index 00000000000..93252293ba6
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -0,0 +1,33 @@
+<script>
+ export default {
+ props: {
+ markdownDocs: {
+ type: String,
+ required: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="comment-toolbar clearfix">
+ <div class="toolbar-text">
+ <a
+ :href="markdownDocs"
+ target="_blank"
+ tabindex="-1">
+ Markdown is supported
+ </a>
+ </div>
+ <button
+ class="toolbar-button markdown-selector"
+ type="button"
+ tabindex="-1">
+ <i
+ class="fa fa-file-image-o toolbar-button-icon"
+ aria-hidden="true">
+ </i>
+ Attach a file
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
new file mode 100644
index 00000000000..096be507625
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
@@ -0,0 +1,58 @@
+<script>
+ import tooltipMixin from '../../mixins/tooltip';
+
+ export default {
+ mixins: [
+ tooltipMixin,
+ ],
+ props: {
+ buttonTitle: {
+ type: String,
+ required: true,
+ },
+ icon: {
+ type: String,
+ required: true,
+ },
+ tag: {
+ type: String,
+ required: true,
+ },
+ tagBlock: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ prepend: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ iconClass() {
+ return `fa-${this.icon}`;
+ },
+ },
+ };
+</script>
+
+<template>
+ <button
+ type="button"
+ class="toolbar-btn js-md hidden-xs"
+ tabindex="-1"
+ ref="tooltip"
+ data-container="body"
+ :data-md-tag="tag"
+ :data-md-block="tagBlock"
+ :data-md-prepend="prepend"
+ :title="buttonTitle"
+ :aria-label="buttonTitle">
+ <i
+ aria-hidden="true"
+ class="fa fa-fw"
+ :class="iconClass">
+ </i>
+ </button>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/memory_graph.js b/app/assets/javascripts/vue_shared/components/memory_graph.js
new file mode 100644
index 00000000000..643b77e04c7
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/memory_graph.js
@@ -0,0 +1,115 @@
+export default {
+ name: 'MemoryGraph',
+ props: {
+ metrics: { type: Array, required: true },
+ deploymentTime: { type: Number, required: true },
+ width: { type: String, required: true },
+ height: { type: String, required: true },
+ },
+ data() {
+ return {
+ pathD: '',
+ pathViewBox: '',
+ dotX: '',
+ dotY: '',
+ };
+ },
+ computed: {
+ getFormattedMedian() {
+ const deployedSince = gl.utils.getTimeago().format(this.deploymentTime * 1000);
+ return `Deployed ${deployedSince}`;
+ },
+ },
+ methods: {
+ /**
+ * Returns metric value index in metrics array
+ * with timestamp closest to matching median
+ */
+ getMedianMetricIndex(median, metrics) {
+ let matchIndex = 0;
+ let timestampDiff = 0;
+ let smallestDiff = 0;
+
+ const metricTimestamps = metrics.map(v => v[0]);
+
+ // Find metric timestamp which is closest to deploymentTime
+ timestampDiff = Math.abs(metricTimestamps[0] - median);
+ metricTimestamps.forEach((timestamp, index) => {
+ if (index === 0) { // Skip first element
+ return;
+ }
+
+ smallestDiff = Math.abs(timestamp - median);
+ if (smallestDiff < timestampDiff) {
+ matchIndex = index;
+ timestampDiff = smallestDiff;
+ }
+ });
+
+ return matchIndex;
+ },
+
+ /**
+ * Get Graph Plotting values to render Line and Dot
+ */
+ getGraphPlotValues(median, metrics) {
+ const renderData = metrics.map(v => v[1]);
+ const medianMetricIndex = this.getMedianMetricIndex(median, metrics);
+ let cx = 0;
+ let cy = 0;
+
+ // Find Maximum and Minimum values from `renderData` array
+ const maxMemory = Math.max.apply(null, renderData);
+ const minMemory = Math.min.apply(null, renderData);
+
+ // Find difference between extreme ends
+ const diff = maxMemory - minMemory;
+ const lineWidth = renderData.length;
+
+ // Iterate over metrics values and perform following
+ // 1. Find x & y co-ords for deploymentTime's memory value
+ // 2. Return line path against maxMemory
+ const linePath = renderData.map((y, x) => {
+ if (medianMetricIndex === x) {
+ cx = x;
+ cy = maxMemory - y;
+ }
+ return `${x} ${maxMemory - y}`;
+ });
+
+ return {
+ pathD: linePath,
+ pathViewBox: {
+ lineWidth,
+ diff,
+ },
+ dotX: cx,
+ dotY: cy,
+ };
+ },
+
+ /**
+ * Render Graph based on provided median and metrics values
+ */
+ renderGraph(median, metrics) {
+ const { pathD, pathViewBox, dotX, dotY } = this.getGraphPlotValues(median, metrics);
+
+ // Set props and update graph on UI.
+ this.pathD = `M ${pathD}`;
+ this.pathViewBox = `0 0 ${pathViewBox.lineWidth} ${pathViewBox.diff}`;
+ this.dotX = dotX;
+ this.dotY = dotY;
+ },
+ },
+ mounted() {
+ this.renderGraph(this.deploymentTime, this.metrics);
+ },
+ template: `
+ <div class="memory-graph-container">
+ <svg class="has-tooltip" :title="getFormattedMedian" :width="width" :height="height" xmlns="http://www.w3.org/2000/svg">
+ <path :d="pathD" :viewBox="pathViewBox" />
+ <circle r="1.5" :cx="dotX" :cy="dotY" tranform="translate(0 -1)" />
+ </svg>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js
index fbae85c85f6..3283a6bcacc 100644
--- a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js
+++ b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js
@@ -2,9 +2,9 @@
import AsyncButtonComponent from '../../pipelines/components/async_button.vue';
import PipelinesActionsComponent from '../../pipelines/components/pipelines_actions';
import PipelinesArtifactsComponent from '../../pipelines/components/pipelines_artifacts';
-import PipelinesStatusComponent from '../../pipelines/components/status';
+import ciBadge from './ci_badge_link.vue';
import PipelinesStageComponent from '../../pipelines/components/stage.vue';
-import PipelinesUrlComponent from '../../pipelines/components/pipeline_url';
+import PipelinesUrlComponent from '../../pipelines/components/pipeline_url.vue';
import PipelinesTimeagoComponent from '../../pipelines/components/time_ago';
import CommitComponent from './commit';
@@ -39,7 +39,7 @@ export default {
'commit-component': CommitComponent,
'dropdown-stage': PipelinesStageComponent,
'pipeline-url': PipelinesUrlComponent,
- 'status-scope': PipelinesStatusComponent,
+ ciBadge,
'time-ago': PipelinesTimeagoComponent,
},
@@ -62,10 +62,12 @@ export default {
commitAuthor() {
let commitAuthorInformation;
+ if (!this.pipeline || !this.pipeline.commit) {
+ return null;
+ }
+
// 1. person who is an author of a commit might be a GitLab user
- if (this.pipeline &&
- this.pipeline.commit &&
- this.pipeline.commit.author) {
+ if (this.pipeline.commit.author) {
// 2. if person who is an author of a commit is a GitLab user
// he/she can have a GitLab avatar
if (this.pipeline.commit.author.avatar_url) {
@@ -77,11 +79,8 @@ export default {
avatar_url: this.pipeline.commit.author_gravatar_url,
});
}
- }
-
- // 4. If committer is not a GitLab User he/she can have a Gravatar
- if (this.pipeline &&
- this.pipeline.commit) {
+ // 4. If committer is not a GitLab User he/she can have a Gravatar
+ } else {
commitAuthorInformation = {
avatar_url: this.pipeline.commit.author_gravatar_url,
web_url: `mailto:${this.pipeline.commit.author_email}`,
@@ -197,11 +196,20 @@ export default {
return '';
},
+
+ pipelineStatus() {
+ if (this.pipeline.details && this.pipeline.details.status) {
+ return this.pipeline.details.status;
+ }
+ return {};
+ },
},
template: `
<tr class="commit">
- <status-scope :pipeline="pipeline"/>
+ <td class="commit-link">
+ <ci-badge :status="pipelineStatus"/>
+ </td>
<pipeline-url :pipeline="pipeline"></pipeline-url>
diff --git a/app/assets/javascripts/vue_shared/components/table_pagination.js b/app/assets/javascripts/vue_shared/components/table_pagination.vue
index ebb14912b00..5e7df22dd83 100644
--- a/app/assets/javascripts/vue_shared/components/table_pagination.js
+++ b/app/assets/javascripts/vue_shared/components/table_pagination.vue
@@ -1,3 +1,4 @@
+<script>
const PAGINATION_UI_BUTTON_LIMIT = 4;
const UI_LIMIT = 6;
const SPREAD = '...';
@@ -114,22 +115,23 @@ export default {
return items;
},
},
- template: `
- <div class="gl-pagination">
- <ul class="pagination clearfix">
- <li v-for='item in getItems'
- :class='{
- page: item.page,
- prev: item.prev,
- next: item.next,
- separator: item.separator,
- active: item.active,
- disabled: item.disabled
- }'
- >
- <a @click="changePage($event)">{{item.title}}</a>
- </li>
- </ul>
- </div>
- `,
};
+</script>
+<template>
+ <div class="gl-pagination">
+ <ul class="pagination clearfix">
+ <li
+ v-for="item in getItems"
+ :class="{
+ page: item.page,
+ prev: item.prev,
+ next: item.next,
+ separator: item.separator,
+ active: item.active,
+ disabled: item.disabled
+ }">
+ <a @click="changePage($event)">{{item.title}}</a>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
new file mode 100644
index 00000000000..af2b4c6786e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
@@ -0,0 +1,58 @@
+<script>
+import tooltipMixin from '../mixins/tooltip';
+import timeagoMixin from '../mixins/timeago';
+import '../../lib/utils/datetime_utility';
+
+/**
+ * Port of ruby helper time_ago_with_tooltip
+ */
+
+export default {
+ props: {
+ time: {
+ type: String,
+ required: true,
+ },
+
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+
+ shortFormat: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+
+ cssClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+
+ mixins: [
+ tooltipMixin,
+ timeagoMixin,
+ ],
+
+ computed: {
+ timeagoCssClass() {
+ return this.shortFormat ? 'js-short-timeago' : 'js-timeago';
+ },
+ },
+};
+</script>
+<template>
+ <time
+ :class="[timeagoCssClass, cssClass]"
+ class="js-timeago js-timeago-render"
+ :title="tooltipTitle(time)"
+ :data-placement="tooltipPlacement"
+ data-container="body"
+ ref="tooltip">
+ {{timeFormated(time)}}
+ </time>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
new file mode 100644
index 00000000000..b8db6afda12
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
@@ -0,0 +1,80 @@
+<script>
+
+/* This is a re-usable vue component for rendering a user avatar that
+ does not need to link to the user's profile. The image and an optional
+ tooltip can be configured by props passed to this component.
+
+ Sample configuration:
+
+ <user-avatar-image
+ :img-src="userAvatarSrc"
+ :img-alt="tooltipText"
+ :tooltip-text="tooltipText"
+ tooltip-placement="top"
+ />
+
+*/
+
+import defaultAvatarUrl from 'images/no_avatar.png';
+import TooltipMixin from '../../mixins/tooltip';
+
+export default {
+ name: 'UserAvatarImage',
+ mixins: [TooltipMixin],
+ props: {
+ imgSrc: {
+ type: String,
+ required: false,
+ default: defaultAvatarUrl,
+ },
+ cssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgAlt: {
+ type: String,
+ required: false,
+ default: 'user avatar',
+ },
+ size: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ tooltipText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ },
+ computed: {
+ tooltipContainer() {
+ return this.tooltipText ? 'body' : null;
+ },
+ avatarSizeClass() {
+ return `s${this.size}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <img
+ class="avatar"
+ :class="[avatarSizeClass, cssClasses]"
+ :src="imgSrc"
+ :width="size"
+ :height="size"
+ :alt="imgAlt"
+ :data-container="tooltipContainer"
+ :data-placement="tooltipPlacement"
+ :title="tooltipText"
+ ref="tooltip"
+ />
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
new file mode 100644
index 00000000000..95898d54cf7
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
@@ -0,0 +1,80 @@
+<script>
+
+/* This is a re-usable vue component for rendering a user avatar wrapped in
+ a clickable link (likely to the user's profile). The link, image, and
+ tooltip can be configured by props passed to this component.
+
+ Sample configuration:
+
+ <user-avatar-link
+ :link-href="userProfileUrl"
+ :img-src="userAvatarSrc"
+ :img-alt="tooltipText"
+ :img-size="20"
+ :tooltip-text="tooltipText"
+ tooltip-placement="top"
+ />
+
+*/
+
+import userAvatarImage from './user_avatar_image.vue';
+
+export default {
+ name: 'UserAvatarLink',
+ components: {
+ userAvatarImage,
+ },
+ props: {
+ linkHref: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgSrc: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgAlt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgCssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgSize: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ tooltipText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ },
+};
+</script>
+
+<template>
+ <a
+ class="user-avatar-link"
+ :href="linkHref">
+ <user-avatar-image
+ :img-src="imgSrc"
+ :img-alt="imgAlt"
+ :css-classes="imgCssClasses"
+ :size="imgSize"
+ :tooltip-text="tooltipText"
+ :tooltip-placement="tooltipPlacement"
+ />
+ </a>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue
new file mode 100644
index 00000000000..d2ff2ac006e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue
@@ -0,0 +1,45 @@
+<script>
+
+/* This is a re-usable vue component for rendering a user avatar svg (typically
+ for a blank state). It will receive styles comparable to the user avatar,
+ but no image is loaded, it isn't wrapped in a link, and tooltips aren't supported.
+ The svg and avatar size can be configured by props passed to this component.
+
+ Sample configuration:
+
+ <user-avatar-svg
+ :svg="potentialApproverSvg"
+ :size="20"
+ />
+
+*/
+
+export default {
+ props: {
+ svg: {
+ type: String,
+ required: true,
+ },
+ size: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ },
+ computed: {
+ avatarSizeClass() {
+ return `s${this.size}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <svg
+ :class="avatarSizeClass"
+ :height="size"
+ :width="size"
+ v-html="svg">
+ </svg>
+</template>
+
diff --git a/app/assets/javascripts/vue_shared/mixins/timeago.js b/app/assets/javascripts/vue_shared/mixins/timeago.js
new file mode 100644
index 00000000000..20f63ab663c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/mixins/timeago.js
@@ -0,0 +1,18 @@
+import '../../lib/utils/datetime_utility';
+
+/**
+ * Mixin with time ago methods used in some vue components
+ */
+export default {
+ methods: {
+ timeFormated(time) {
+ const timeago = gl.utils.getTimeago();
+
+ return timeago.format(time);
+ },
+
+ tooltipTitle(time) {
+ return gl.utils.formatDate(time);
+ },
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/mixins/tooltip.js b/app/assets/javascripts/vue_shared/mixins/tooltip.js
new file mode 100644
index 00000000000..995c0c98505
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/mixins/tooltip.js
@@ -0,0 +1,13 @@
+export default {
+ mounted() {
+ $(this.$refs.tooltip).tooltip();
+ },
+
+ updated() {
+ $(this.$refs.tooltip).tooltip('fixTitle');
+ },
+
+ beforeDestroy() {
+ $(this.$refs.tooltip).tooltip('destroy');
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/translate.js b/app/assets/javascripts/vue_shared/translate.js
new file mode 100644
index 00000000000..f83c4b00761
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/translate.js
@@ -0,0 +1,42 @@
+import {
+ __,
+ n__,
+ s__,
+} from '../locale';
+
+export default (Vue) => {
+ Vue.mixin({
+ methods: {
+ /**
+ Translates `text`
+
+ @param text The text to be translated
+ @returns {String} The translated text
+ **/
+ __,
+ /**
+ Translate the text with a number
+ if the number is more than 1 it will use the `pluralText` translation.
+ This method allows for contexts, see below re. contexts
+
+ @param text Singular text to translate (eg. '%d day')
+ @param pluralText Plural text to translate (eg. '%d days')
+ @param count Number to decide which translation to use (eg. 2)
+ @returns {String} Translated text with the number replaced (eg. '2 days')
+ **/
+ n__,
+ /**
+ Translate context based text
+ Either pass in the context translation like `Context|Text to translate`
+ or allow for dynamic text by doing passing in the context first & then the text to translate
+
+ @param keyOrContext Can be either the key to translate including the context
+ (eg. 'Context|Text') or just the context for the translation
+ (eg. 'Context')
+ @param key Is the dynamic variable you want to be translated
+ @returns {String} Translated context based text
+ **/
+ s__,
+ },
+ });
+};
diff --git a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js
index d5f87588c28..740930dce5b 100644
--- a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js
+++ b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js
@@ -4,7 +4,7 @@ import VueResource from 'vue-resource';
Vue.use(VueResource);
// Maintain a global counter for active requests
-// see: spec/support/wait_for_vue_resource.rb
+// see: spec/support/wait_for_requests.rb
Vue.http.interceptors.push((request, next) => {
window.activeVueResources = window.activeVueResources || 0;
window.activeVueResources += 1;
diff --git a/app/assets/javascripts/wikis.js b/app/assets/javascripts/wikis.js
index 75fd1394a03..4194c1bc08d 100644
--- a/app/assets/javascripts/wikis.js
+++ b/app/assets/javascripts/wikis.js
@@ -1,8 +1,8 @@
/* eslint-disable no-param-reassign */
/* global Breakpoints */
-require('./breakpoints');
-require('vendor/jquery.nicescroll');
+import 'vendor/jquery.nicescroll';
+import './breakpoints';
((global) => {
class Wikis {
diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js
index ce626cf7b46..b7fe552dec2 100644
--- a/app/assets/javascripts/zen_mode.js
+++ b/app/assets/javascripts/zen_mode.js
@@ -1,5 +1,4 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, no-unused-vars, consistent-return, camelcase, comma-dangle, max-len */
-/* global Dropzone */
/* global Mousetrap */
// Zen Mode (full screen) textarea
@@ -7,10 +6,12 @@
/*= provides zen_mode:enter */
/*= provides zen_mode:leave */
-require('vendor/jquery.scrollTo');
-window.Dropzone = require('dropzone');
-require('mousetrap');
-require('mousetrap/plugins/pause/mousetrap-pause');
+import 'vendor/jquery.scrollTo';
+import Dropzone from 'dropzone';
+import 'mousetrap';
+import 'mousetrap/plugins/pause/mousetrap-pause';
+
+window.Dropzone = Dropzone;
//
// ### Events